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 _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) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -118,6 +118,21 @@
|
||||
</view>
|
||||
<switch checked="{{voiceGuide}}" bindchange="onToggleVoice" color="{{theme.primary}}"/>
|
||||
</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-left">
|
||||
<image class="setting-icon" src="{{icons.notificationFill}}" mode="aspectFit"></image>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+14
-4
@@ -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) => {
|
||||
|
||||
+30
-6
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user