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
}