feat(voice): 语音参数移入 config.js + 设置页男女声切换

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 10:08:09 +08:00
parent dd9cd1859c
commit a7ed017267
7 changed files with 118 additions and 20 deletions
+9 -9
View File
@@ -30,7 +30,7 @@ const PROMPTS = {
const CACHE_DIR = 'tts-cache' const CACHE_DIR = 'tts-cache'
const _synthesize = async (text, promptKey) => { const _synthesize = async (text, promptKey, opts) => {
let TtsClient let TtsClient
try { try {
TtsClient = require('tencentcloud-sdk-nodejs').tts.v20190823.Client TtsClient = require('tencentcloud-sdk-nodejs').tts.v20190823.Client
@@ -52,17 +52,17 @@ const _synthesize = async (text, promptKey) => {
const res = await client.TextToVoice({ const res = await client.TextToVoice({
Text: text, Text: text,
SessionId: `${promptKey}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, SessionId: `${promptKey}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
VoiceType: 101001, // 智瑜,温柔女声 VoiceType: (opts && opts.voiceType) || 101001, // 智瑜,温柔女声
Codec: 'mp3', Codec: 'mp3',
SampleRate: 16000, SampleRate: 16000,
Speed: 0, Speed: (opts && opts.speed != null) ? opts.speed : 0,
Volume: 5 Volume: (opts && opts.volume != null) ? opts.volume : 5
}) })
return Buffer.from(res.Audio, 'base64') return Buffer.from(res.Audio, 'base64')
} }
const _upload = async (key, buffer) => { const _upload = async (key, buffer, voiceType) => {
const cloudPath = `${CACHE_DIR}/${key}.mp3` const cloudPath = `${CACHE_DIR}/${key}-${voiceType || 'default'}.mp3`
// uploadFile returns the canonical fileID (e.g. "cloud://env.xxx/..."). // uploadFile returns the canonical fileID (e.g. "cloud://env.xxx/...").
// We must use THAT — not the relative cloudPath we passed in — when // We must use THAT — not the relative cloudPath we passed in — when
// calling getTempFileURL. A self-constructed fileID is invalid. // calling getTempFileURL. A self-constructed fileID is invalid.
@@ -71,7 +71,7 @@ const _upload = async (key, buffer) => {
} }
exports.main = async (event) => { exports.main = async (event) => {
const { promptKey, fileID } = event || {} const { promptKey, fileID, voiceType, volume, speed } = event || {}
const text = PROMPTS[promptKey] const text = PROMPTS[promptKey]
if (!text) { if (!text) {
return { success: false, error: `Unknown prompt key: ${promptKey}` } 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 // Slow path: synthesize + upload + return fresh fileID for caching
try { try {
const buffer = await _synthesize(text, promptKey) const buffer = await _synthesize(text, promptKey, { voiceType, volume, speed })
const newFileID = await _upload(promptKey, buffer) const newFileID = await _upload(promptKey, buffer, voiceType)
const urlRes = await cloud.getTempFileURL({ fileList: [newFileID] }) const urlRes = await cloud.getTempFileURL({ fileList: [newFileID] })
const url = urlRes.fileList[0].tempFileURL const url = urlRes.fileList[0].tempFileURL
if (!url) { if (!url) {
+14 -1
View File
@@ -13,5 +13,18 @@ module.exports = {
developer: '刘承', 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 }
}
} }
+15
View File
@@ -53,6 +53,11 @@ Page({
currentPlanId: 'beginner', currentPlanId: 'beginner',
voiceGuide: true, voiceGuide: true,
vibrate: true, vibrate: true,
voiceGender: 'female',
voiceGenderOptions: [
{ key: 'female', name: '女声' },
{ key: 'male', name: '男声' }
],
icons: iconsMod.build(), icons: iconsMod.build(),
nickName: '', nickName: '',
avatarUrl: '', avatarUrl: '',
@@ -86,6 +91,7 @@ Page({
currentPlanId: s.planId, currentPlanId: s.planId,
voiceGuide: s.voiceGuide, voiceGuide: s.voiceGuide,
vibrate: s.vibrate, vibrate: s.vibrate,
voiceGender: s.voiceGender || 'female',
currentThemeId: theme.id, currentThemeId: theme.id,
theme, theme,
icons: iconsMod.build(theme), icons: iconsMod.build(theme),
@@ -238,6 +244,15 @@ Page({
this.setData({ vibrate: e.detail.value }) 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() { onCopyWechat() {
wx.setClipboardData({ wx.setClipboardData({
data: config.developer, data: config.developer,
+15
View File
@@ -118,6 +118,21 @@
</view> </view>
<switch checked="{{voiceGuide}}" bindchange="onToggleVoice" color="{{theme.primary}}"/> <switch checked="{{voiceGuide}}" bindchange="onToggleVoice" color="{{theme.primary}}"/>
</view> </view>
<view class="setting-row">
<view class="setting-left">
<image class="setting-icon" src="{{icons.voiceFill}}" mode="aspectFit"></image>
<text class="setting-label">语音音色</text>
</view>
<view class="segmented">
<view
class="segmented-item {{voiceGender === item.key ? 'active' : ''}}"
wx:for="{{voiceGenderOptions}}"
wx:key="key"
data-key="{{item.key}}"
bindtap="onSelectVoiceGender"
>{{item.name}}</view>
</view>
</view>
<view class="setting-row"> <view class="setting-row">
<view class="setting-left"> <view class="setting-left">
<image class="setting-icon" src="{{icons.notificationFill}}" mode="aspectFit"></image> <image class="setting-icon" src="{{icons.notificationFill}}" mode="aspectFit"></image>
+21
View File
@@ -178,6 +178,27 @@
.setting-row:last-child { border-bottom: none; } .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 { .setting-left {
display: flex; display: flex;
align-items: center; align-items: center;
+12 -2
View File
@@ -164,10 +164,20 @@ const getTotalStats = () => {
return { totalDuration, totalSessions, maxDuration } return { totalDuration, totalSessions, maxDuration }
} }
const getSettings = () => wx.getStorageSync(SETTINGS_KEY) || { 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', planId: 'beginner',
voiceGuide: true, voiceGuide: true,
vibrate: true vibrate: true,
voiceGender: 'female'
}
} }
const saveSettings = (settings) => { const saveSettings = (settings) => {
+30 -6
View File
@@ -17,6 +17,8 @@
* the user with toast errors mid-plank. * the user with toast errors mid-plank.
*/ */
const config = require('../config')
const PROMPTS = { const PROMPTS = {
start: '训练已开始', start: '训练已开始',
halfway: '已完成一半啦,坚持就是胜利!', halfway: '已完成一半啦,坚持就是胜利!',
@@ -58,34 +60,56 @@ const _getCtx = () => {
return _ctx 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 _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 // 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 // 2. Persistent fileID cache: pass to cloud function for instant getTempFileURL
_loadFileIDCache() _loadFileIDCache()
const cachedFileID = _fileIDCache[promptKey] || null const cachedFileID = _fileIDCache[cacheKey] || null
try { try {
const res = await wx.cloud.callFunction({ const res = await wx.cloud.callFunction({
name: 'tts', name: 'tts',
data: { promptKey, fileID: cachedFileID }, data: { promptKey, fileID: cachedFileID, voiceType, volume, speed },
// Timeout: 30s for first synthesis (cold start), <1s for cached // Timeout: 30s for first synthesis (cold start), <1s for cached
config: { timeout: 30000 } config: { timeout: 30000 }
}) })
if (res && res.result && res.result.success && res.result.audioUrl) { 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 // Persist the fileID if we got a new one (first synthesis or
// re-synthesis after cache invalidation). // re-synthesis after cache invalidation).
if (res.result.fileID && res.result.fileID !== cachedFileID) { if (res.result.fileID && res.result.fileID !== cachedFileID) {
_fileIDCache[promptKey] = res.result.fileID _fileIDCache[cacheKey] = res.result.fileID
_saveFileIDCache() _saveFileIDCache()
} }
return res.result.audioUrl return res.result.audioUrl
} }
} catch (e) { } catch (e) {
// cloud function not deployed or other error silent fallback // cloud function not deployed or other error - silent fallback
} }
return null return null
} }