// Cloud function: synthesize the 4 fixed training-voice prompts. // // Caching strategy: the client stores the permanent `fileID` returned by // the first successful synthesis in local storage. On subsequent calls it // passes `fileID` back to us; we call `getTempFileURL` with it to get a // fresh temporary URL instantly (no synthesis, no upload). If the cached // fileID is stale (file deleted from cloud storage), we fall through and // re-synthesize, returning the new fileID so the client can update its cache. // // Configuration (云开发控制台 → 云函数 → tts → 函数配置 → 环境变量): // TTS_SECRET_ID — Tencent Cloud API key id // TTS_SECRET_KEY — Tencent Cloud API key // TTS_REGION — (optional) defaults to ap-guangzhou // // NOTE: env var names cannot start with SCF_ / QCLOUD_ / TENCENTCLOUD_ — // those prefixes are reserved by Tencent Cloud SCF. Use the TTS_ prefix. const cloud = require('wx-server-sdk') const { cachedFileId } = require('./cache') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) // Polished, fixed prompts. const PROMPTS = { start: '训练已开始', halfway: '已完成一半啦,坚持就是胜利!', last30: '最后30秒,保持呼吸,稳住姿势!', last10: '最后10秒,再加把劲!', goal: '目标达成,继续挑战!', complete: '太棒了!今天的目标已完成,继续加油!' } const CACHE_DIR = 'tts-cache' const _synthesize = async (text, promptKey, opts) => { let TtsClient try { TtsClient = require('tencentcloud-sdk-nodejs').tts.v20190823.Client } catch (e) { throw new Error('tencentcloud-sdk-nodejs not installed. Run `npm i tencentcloud-sdk-nodejs` in this cloud function directory.') } const secretId = process.env.TTS_SECRET_ID const secretKey = process.env.TTS_SECRET_KEY if (!secretId || !secretKey) { throw new Error('TTS_SECRET_ID / TTS_SECRET_KEY not configured.') } const client = new TtsClient({ credential: { secretId, secretKey }, region: process.env.TTS_REGION || 'ap-guangzhou' }) const res = await client.TextToVoice({ Text: text, SessionId: `${promptKey}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, VoiceType: (opts && opts.voiceType) || 101001, // 智瑜,温柔女声 Codec: 'mp3', SampleRate: 16000, Speed: (opts && opts.speed != null) ? opts.speed : 0, Volume: (opts && opts.volume != null) ? opts.volume : 5 }) return Buffer.from(res.Audio, 'base64') } const _upload = async (key, buffer, voiceType, volume, speed) => { // cloudPath 含 volume/speed,避免不同音量/语速的音频互相覆盖(缓存碰撞) const cloudPath = `${CACHE_DIR}/${key}-${voiceType || 'default'}-v${volume != null ? volume : 0}-s${speed != null ? speed : 0}.mp3` // uploadFile returns the canonical fileID (e.g. "cloud://env.xxx/..."). // We must use THAT — not the relative cloudPath we passed in — when // calling getTempFileURL. A self-constructed fileID is invalid. const res = await cloud.uploadFile({ cloudPath, fileContent: buffer }) return res.fileID } exports.main = async (event) => { const { promptKey, fileID, voiceType, volume, speed } = event || {} const text = PROMPTS[promptKey] if (!text) { return { success: false, error: `Unknown prompt key: ${promptKey}` } } // If the client passed a cached fileID, try to get a fresh temp URL // from it — this is the fast path (no synthesis, no upload). const db = cloud.database() const cacheKey = `${promptKey}-${voiceType || 'default'}-v${volume != null ? volume : 0}-s${speed != null ? speed : 0}` // 解析 fileID:优先 client 传入,否则查服务端 tts_cache,防 client 传 null 强制重合成(刷 TTS 账单) let resolvedFileID = fileID || null // 校验 client 传的 fileID 必须属于 tts-cache 目录,防伪造 cloud:// 换取他人私有文件 if (resolvedFileID && !resolvedFileID.includes(CACHE_DIR)) resolvedFileID = null if (!resolvedFileID) { try { const r = await db.collection('tts_cache').doc(cacheKey).get() const cached = cachedFileId(r.data) if (cached) resolvedFileID = cached } catch (e) {} // 集合/文档不存在,走合成 } if (resolvedFileID) { try { const urlRes = await cloud.getTempFileURL({ fileList: [resolvedFileID] }) const url = urlRes.fileList[0].tempFileURL if (url) { return { success: true, audioUrl: url, fileID: resolvedFileID, text, cached: true } } } catch (e) { // fileID stale or file deleted — fall through to re-synthesize } } // Slow path: synthesize + upload + return fresh fileID for caching try { const buffer = await _synthesize(text, promptKey, { voiceType, volume, speed }) const newFileID = await _upload(promptKey, buffer, voiceType, volume, speed) const urlRes = await cloud.getTempFileURL({ fileList: [newFileID] }) const url = urlRes.fileList[0].tempFileURL if (!url) { return { success: false, error: 'getTempFileURL returned empty URL', text } } // 写服务端 tts_cache,下次相同参数直接命中,不重复合成 try { await db.collection('tts_cache').doc(cacheKey).set({ data: { fileID: newFileID, updatedAt: db.serverDate() } }) } catch (e) {} return { success: true, audioUrl: url, fileID: newFileID, text, cached: false } } catch (e) { return { success: false, error: e.message, text } } }