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:
@@ -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) {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+14
-4
@@ -164,10 +164,20 @@ const getTotalStats = () => {
|
|||||||
return { totalDuration, totalSessions, maxDuration }
|
return { totalDuration, totalSessions, maxDuration }
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSettings = () => wx.getStorageSync(SETTINGS_KEY) || {
|
const getSettings = () => {
|
||||||
planId: 'beginner',
|
const s = wx.getStorageSync(SETTINGS_KEY)
|
||||||
voiceGuide: true,
|
if (s) {
|
||||||
vibrate: true
|
// 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) => {
|
const saveSettings = (settings) => {
|
||||||
|
|||||||
+30
-6
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user