diff --git a/cloudfunctions/tts/index.js b/cloudfunctions/tts/index.js index b60ab2d..337a8df 100644 --- a/cloudfunctions/tts/index.js +++ b/cloudfunctions/tts/index.js @@ -30,7 +30,7 @@ const PROMPTS = { const CACHE_DIR = 'tts-cache' -const _synthesize = async (text, promptKey) => { +const _synthesize = async (text, promptKey, opts) => { let TtsClient try { TtsClient = require('tencentcloud-sdk-nodejs').tts.v20190823.Client @@ -52,17 +52,17 @@ const _synthesize = async (text, promptKey) => { const res = await client.TextToVoice({ Text: text, SessionId: `${promptKey}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, - VoiceType: 101001, // 智瑜,温柔女声 + VoiceType: (opts && opts.voiceType) || 101001, // 智瑜,温柔女声 Codec: 'mp3', SampleRate: 16000, - Speed: 0, - Volume: 5 + 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) => { - const cloudPath = `${CACHE_DIR}/${key}.mp3` +const _upload = async (key, buffer, voiceType) => { + const cloudPath = `${CACHE_DIR}/${key}-${voiceType || 'default'}.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. @@ -71,7 +71,7 @@ const _upload = async (key, buffer) => { } exports.main = async (event) => { - const { promptKey, fileID } = event || {} + const { promptKey, fileID, voiceType, volume, speed } = event || {} const text = PROMPTS[promptKey] if (!text) { return { success: false, error: `Unknown prompt key: ${promptKey}` } @@ -93,8 +93,8 @@ exports.main = async (event) => { // Slow path: synthesize + upload + return fresh fileID for caching try { - const buffer = await _synthesize(text, promptKey) - const newFileID = await _upload(promptKey, buffer) + const buffer = await _synthesize(text, promptKey, { voiceType, volume, speed }) + const newFileID = await _upload(promptKey, buffer, voiceType) const urlRes = await cloud.getTempFileURL({ fileList: [newFileID] }) const url = urlRes.fileList[0].tempFileURL if (!url) { diff --git a/config.js b/config.js index 3d327af..3d6542e 100644 --- a/config.js +++ b/config.js @@ -13,5 +13,18 @@ module.exports = { developer: '刘承', /** 排行榜最大显示人数 */ - leaderboardMaxRank: 20 + leaderboardMaxRank: 20, + + /** + * 语音合成(TTS)参数 - 直接改这里调整音色/音量/语速,无需改逻辑代码。 + * 设置页可选男声/女声,客户端按选择取下面对应的一组传给 tts 云函数。 + * + * voiceType: 音色 ID。登录腾讯云 TTS 控制台试听音色,把数字填进来。 + * volume: 音量 0-10(10 最响)。 + * speed: 语速 -2~2(正数加快,0 为默认)。 + */ + tts: { + female: { voiceType: 502005, volume: 9, speed: 1 }, + male: { voiceType: 602005, volume: 9, speed: 1 } + } } diff --git a/pages/settings/settings.js b/pages/settings/settings.js index 375446d..586fa6d 100644 --- a/pages/settings/settings.js +++ b/pages/settings/settings.js @@ -53,6 +53,11 @@ Page({ currentPlanId: 'beginner', voiceGuide: true, vibrate: true, + voiceGender: 'female', + voiceGenderOptions: [ + { key: 'female', name: '女声' }, + { key: 'male', name: '男声' } + ], icons: iconsMod.build(), nickName: '', avatarUrl: '', @@ -86,6 +91,7 @@ Page({ currentPlanId: s.planId, voiceGuide: s.voiceGuide, vibrate: s.vibrate, + voiceGender: s.voiceGender || 'female', currentThemeId: theme.id, theme, icons: iconsMod.build(theme), @@ -238,6 +244,15 @@ Page({ this.setData({ vibrate: e.detail.value }) }, + onSelectVoiceGender(e) { + const key = e.currentTarget.dataset.key + if (key === this.data.voiceGender) return + const s = storage.getSettings() + s.voiceGender = key + storage.saveSettings(s) + this.setData({ voiceGender: key }) + }, + onCopyWechat() { wx.setClipboardData({ data: config.developer, diff --git a/pages/settings/settings.wxml b/pages/settings/settings.wxml index 5f3a2d5..98a872d 100644 --- a/pages/settings/settings.wxml +++ b/pages/settings/settings.wxml @@ -118,6 +118,21 @@ + + + + 语音音色 + + + {{item.name}} + + diff --git a/pages/settings/settings.wxss b/pages/settings/settings.wxss index ec1ed31..c209b13 100644 --- a/pages/settings/settings.wxss +++ b/pages/settings/settings.wxss @@ -178,6 +178,27 @@ .setting-row:last-child { border-bottom: none; } +/* ---- segmented control (语音音色等二选一) ---- */ +.segmented { + display: flex; + background: var(--bg-soft); + border-radius: 12rpx; + padding: 4rpx; +} +.segmented-item { + padding: 10rpx 28rpx; + font-size: 24rpx; + color: var(--text-secondary); + border-radius: 8rpx; + transition: background 0.2s ease, color 0.2s ease; +} +.segmented-item.active { + background: var(--card-bg); + color: var(--primary); + font-weight: 600; + box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.08); +} + .setting-left { display: flex; align-items: center; diff --git a/utils/storage.js b/utils/storage.js index 92da125..9ce82cb 100644 --- a/utils/storage.js +++ b/utils/storage.js @@ -164,10 +164,20 @@ const getTotalStats = () => { return { totalDuration, totalSessions, maxDuration } } -const getSettings = () => wx.getStorageSync(SETTINGS_KEY) || { - planId: 'beginner', - voiceGuide: true, - vibrate: true +const getSettings = () => { + const s = wx.getStorageSync(SETTINGS_KEY) + if (s) { + // Backfill voiceGender for existing users (added with the male/female + // voice option). Defaults to female, the prior behavior. + if (!s.voiceGender) s.voiceGender = 'female' + return s + } + return { + planId: 'beginner', + voiceGuide: true, + vibrate: true, + voiceGender: 'female' + } } const saveSettings = (settings) => { diff --git a/utils/voice.js b/utils/voice.js index 307b6d7..784361d 100644 --- a/utils/voice.js +++ b/utils/voice.js @@ -17,6 +17,8 @@ * the user with toast errors mid-plank. */ +const config = require('../config') + const PROMPTS = { start: '训练已开始', halfway: '已完成一半啦,坚持就是胜利!', @@ -58,34 +60,56 @@ const _getCtx = () => { return _ctx } +/** + * Resolve the TTS params (voiceType/volume/speed) for the user's chosen + * voice gender from config.js. Cached fileIDs are keyed on these params, + * so changing them (or switching gender) auto-invalidates stale audio. + */ +const _getTtsParams = () => { + const s = wx.getStorageSync('user_settings') || {} + const gender = s.voiceGender === 'male' ? 'male' : 'female' + const params = (config.tts && config.tts[gender]) || config.tts.female + return { + voiceType: params.voiceType, + volume: params.volume, + speed: params.speed + } +} + const _fetchUrl = async (promptKey) => { + const { voiceType, volume, speed } = _getTtsParams() + // Key the cache on the full param set so changing voiceType/volume/speed + // (or switching gender) automatically re-synthesizes instead of serving + // stale audio. No manual cache clearing needed. + const cacheKey = `${promptKey}:${voiceType}:${volume}:${speed}` + // 1. In-memory cache: same-session instant return - if (_urlCache[promptKey]) return _urlCache[promptKey] + if (_urlCache[cacheKey]) return _urlCache[cacheKey] // 2. Persistent fileID cache: pass to cloud function for instant getTempFileURL _loadFileIDCache() - const cachedFileID = _fileIDCache[promptKey] || null + const cachedFileID = _fileIDCache[cacheKey] || null try { const res = await wx.cloud.callFunction({ name: 'tts', - data: { promptKey, fileID: cachedFileID }, + data: { promptKey, fileID: cachedFileID, voiceType, volume, speed }, // Timeout: 30s for first synthesis (cold start), <1s for cached config: { timeout: 30000 } }) if (res && res.result && res.result.success && res.result.audioUrl) { - _urlCache[promptKey] = res.result.audioUrl + _urlCache[cacheKey] = res.result.audioUrl // Persist the fileID if we got a new one (first synthesis or // re-synthesis after cache invalidation). if (res.result.fileID && res.result.fileID !== cachedFileID) { - _fileIDCache[promptKey] = res.result.fileID + _fileIDCache[cacheKey] = res.result.fileID _saveFileIDCache() } return res.result.audioUrl } } catch (e) { - // cloud function not deployed or other error — silent fallback + // cloud function not deployed or other error - silent fallback } return null }