From a7ed017267b0c71d70093500baa9ad1a278f1a7b Mon Sep 17 00:00:00 2001 From: cnliucheng Date: Thu, 9 Jul 2026 10:08:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(voice):=20=E8=AF=AD=E9=9F=B3=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E7=A7=BB=E5=85=A5=20config.js=20+=20=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E7=94=B7=E5=A5=B3=E5=A3=B0=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config: 新增 tts.female/male 两组参数(voiceType/volume/speed), 直接改数字即可调音色,无需改逻辑代码 - storage: getSettings 兜底 voiceGender(旧用户迁移为女声) - voice: 新增 _getTtsParams 读 config+性别;缓存 key 含完整参数, 改音色/音量/性别自动重新合成,无需手动清缓存 - settings: 训练偏好加"语音音色"segmented(女声/男声) - tts 云函数: _synthesize 用传入 opts 合成;_upload 路径含 voiceType (男/女不同文件);需重新部署 Co-Authored-By: Claude Fable 5 --- cloudfunctions/tts/index.js | 18 +++++++++--------- config.js | 15 ++++++++++++++- pages/settings/settings.js | 15 +++++++++++++++ pages/settings/settings.wxml | 15 +++++++++++++++ pages/settings/settings.wxss | 21 +++++++++++++++++++++ utils/storage.js | 18 ++++++++++++++---- utils/voice.js | 36 ++++++++++++++++++++++++++++++------ 7 files changed, 118 insertions(+), 20 deletions(-) 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 }