Files
Toolbox/apps/app_order_box/kits/motion_reminder/speech.ts
T
yangxiangyuan bbc7ca6523 feat(personal_collect,motion_reminder): 新增回收站与云端TTS语音功能
- 新增小程序个人收藏回收站页面,支持查看已删除收藏、恢复与彻底删除操作
- 修复个人收藏文件上传时的中文名乱码问题,新增原文件名透传参数
- 接入阿里云百炼TTS服务,实现运动提醒模块的云端语音合成与本地缓存,支持系统TTS与云端MP3双降级播放
- 新增语音引擎探测与异常预警UI,覆盖web端与移动端
- 优化web端订单盒页面布局,修复卡片操作按钮换行问题
- 新增调试探针脚本、ESLint配置与相关lint脚本
- 完善后端接口与路由,更新项目依赖与配置文件
2026-09-17 16:26:27 +08:00

349 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 运动提醒宝(motion_reminder)语音播报:系统 TTS + 云端 mp3 双通道兜底
// 说明:项目 Rule 6 要求每个 tool 独立维护自己的第三方能力封装,
// 因此本文件不引用其他 kit 的语音实现,只依赖本 kit 的 ./api 与 expo-av / expo-speech。
// 场景:安卓设备无系统 TTS 引擎、引擎被回收、或系统 TTS 静默失败时,改播服务端预合成的 mp3。
// 语义与 Web 端 order_box.js 语音区保持一致:探测 → 预合成 → 运行期自动降级 → 不可用预警。
import { Audio } from 'expo-av';
import * as Speech from 'expo-speech';
import { API_BASE_URL, motionReminderApi } from './api';
import { MotionCard, MotionStep, TTSPrepareResult } from './types';
export type SpeechEngineMode = 'unknown' | 'system' | 'cloud' | 'none';
export interface SpeechState {
mode: SpeechEngineMode;
/** 非空即在卡片列表展示语音预警横幅 */
warning: string;
}
/** 欢迎语,同时承担「系统 TTS 是否真的出声」的探测信号;必须与服务端 tts.js 的 WELCOME_TEXT 逐字一致 */
export const WELCOME_TEXT = '欢迎回来,每天锻炼身体好!';
// 系统语音引擎启动判定超时:超过该时间既没 onStart 也没 onError,视为引擎没起来
const PROBE_TIMEOUT_MS = 1200;
// 云端 mp3 单条播放的兜底上限,避免回调异常导致播放队列卡死
const CLOUD_PLAY_TIMEOUT_MS = 20000;
const formatSpeechDuration = (seconds: number | null | undefined) => {
const safe = Math.max(0, Math.floor(Number(seconds) || 0));
const mm = Math.floor(safe / 60);
const ss = safe % 60;
const parts: string[] = [];
if (mm > 0) parts.push(`${mm}分钟`);
if (ss > 0) parts.push(`${ss}秒`);
return parts.length ? parts.join('') : '0秒';
};
// ==================== 状态(供页面订阅展示预警) ====================
let speechState: SpeechState = { mode: 'unknown', warning: '' };
const stateListeners = new Set<(state: SpeechState) => void>();
const setSpeechState = (next: Partial<SpeechState>) => {
const merged = { ...speechState, ...next };
if (merged.mode === speechState.mode && merged.warning === speechState.warning) return;
speechState = merged;
stateListeners.forEach((listener) => {
try {
listener(speechState);
} catch {}
});
};
export const getSpeechState = (): SpeechState => speechState;
export const subscribeSpeechState = (listener: (state: SpeechState) => void) => {
stateListeners.add(listener);
return () => {
stateListeners.delete(listener);
};
};
export const setSpeechMode = (mode: SpeechEngineMode, warning = '') => setSpeechState({ mode, warning });
export const setSpeechWarning = (warning: string) => setSpeechState({ warning });
// ==================== 音频会话与云端播放 ====================
let audioModeConfigured = false;
/** iOS 静音键下也要能播云端 mp3,与系统 TTS 不受静音键影响的语义对齐 */
export const configureSpeechAudioMode = async () => {
if (audioModeConfigured) return;
audioModeConfigured = true;
try {
await Audio.setAudioModeAsync({
playsInSilentModeIOS: true,
staysActiveInBackground: false,
shouldDuckAndroid: true,
});
} catch (error) {
console.warn('[order_box tts] 音频模式设置失败:', error);
}
};
let ttsUrlMap: Record<string, string> = {};
let activeSound: Audio.Sound | null = null;
let playToken = 0;
let cloudChain: Promise<void> = Promise.resolve();
let assetOriginWarned = false;
/** 服务端返回的是 /uploads/... 相对路径,播放器需要绝对 URL(外网走后端注入的 https 基址) */
const resolveAssetUrl = (url: string): string => {
const raw = String(url || '').trim();
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) return raw;
const origin = API_BASE_URL.replace(/\/api\/app_order_box\/?$/i, '').replace(/\/+$/, '');
if (!/^https?:\/\//i.test(origin)) {
if (!assetOriginWarned) {
assetOriginWarned = true;
console.warn('[order_box tts] EXPO_PUBLIC_API_BASE_URL 不是绝对地址,云端语音无法播放,将回退系统 TTS');
}
return '';
}
return origin + (raw.startsWith('/') ? raw : `/${raw}`);
};
const enqueueCloud = (task: () => Promise<void>) => {
const run = cloudChain.then(task, task);
cloudChain = run.catch(() => {});
return run;
};
const unloadActiveSound = async () => {
const sound = activeSound;
activeSound = null;
if (!sound) return;
try {
await sound.stopAsync();
} catch {}
try {
await sound.unloadAsync();
} catch {}
};
/** 云端 mp3 播放/加载失败的统一预警,与 Web 端一致:不静默失败 */
const notifyCloudPlayFailed = () =>
setSpeechWarning('云端语音播放失败,请检查网络后重试,或点「重新检测」。');
/** 播放一条 mp3 并等它播完;被 stopSpeech 打断时立即返回,避免队列卡住 */
const playOneUrl = (url: string, token: number) =>
new Promise<void>((resolve) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const finish = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
resolve();
};
timer = setTimeout(finish, CLOUD_PLAY_TIMEOUT_MS);
Audio.Sound.createAsync(
{ uri: url },
{ shouldPlay: true },
(status) => {
if (token !== playToken) {
finish();
return;
}
if (!status.isLoaded) {
if (status.error) {
console.warn('[order_box tts] 云端语音播放失败:', status.error);
notifyCloudPlayFailed();
finish();
}
return;
}
if (status.didJustFinish) finish();
}
)
.then(({ sound }) => {
if (token !== playToken) {
void sound.unloadAsync().catch(() => {});
finish();
return;
}
activeSound = sound;
})
.catch((error) => {
console.warn('[order_box tts] 云端语音加载失败:', error);
notifyCloudPlayFailed();
finish();
});
});
/**
* 依次播放一组原子文本对应的 mp3。
* 全部原子都命中缓存才返回 true;只要有一条查不到就整体回退系统 TTS,避免只念半句。
*/
const playCloudAtoms = (atoms: string[]): boolean => {
const urls = atoms.map((text) => resolveAssetUrl(ttsUrlMap[text] || ''));
if (!urls.length || urls.some((url) => !url)) return false;
playToken += 1;
const token = playToken;
void unloadActiveSound().then(() => {
urls.forEach((url) => {
void enqueueCloud(() => (token === playToken ? playOneUrl(url, token) : Promise.resolve()));
});
});
return true;
};
// ==================== 系统 TTS ====================
/** 运行期系统 TTS 播报(不关心是否启动,失败由降级链兜底) */
export const speakText = (text: string, pitch = 1) => {
const content = String(text || '').trim();
if (!content) return;
try {
void Speech.stop();
} catch {}
try {
Speech.speak(content, { language: 'zh-CN', pitch, rate: 0.95 });
} catch (error) {
console.warn('[order_box tts] 系统语音播报异常:', error);
}
};
/** 带启动判定的系统 TTS:onStart 视为引擎已启动,onError / 超时视为没起来(探测专用) */
export const speakSystem = (text: string, pitch = 1): Promise<boolean> =>
new Promise((resolve) => {
const content = String(text || '').trim();
if (!content) {
resolve(false);
return;
}
let settled = false;
const settle = (ok: boolean) => {
if (settled) return;
settled = true;
resolve(ok);
};
try {
void Speech.stop();
} catch {}
try {
Speech.speak(content, {
language: 'zh-CN',
pitch,
rate: 0.95,
onStart: () => settle(true),
onError: () => settle(false),
});
} catch {
settle(false);
return;
}
setTimeout(() => settle(false), PROBE_TIMEOUT_MS);
});
/** 统一播报入口:云端通道优先(命中缓存时),否则回退系统 TTS */
export const speakAtoms = (atoms: string[], pitch = 1) => {
const list = atoms.map((text) => String(text || '').trim()).filter(Boolean);
if (!list.length) return;
if (speechState.mode === 'cloud') {
if (playCloudAtoms(list)) return;
if (!Object.keys(ttsUrlMap).length) {
setSpeechWarning('云端语音尚未预合成,请回卡片列表点「重生语音」后重试。');
}
}
// 系统 TTS 用合并句播报更自然;'下一步' 前保留句号,保持原有语气
const merged = list.map((text, index) => (index > 0 && text.indexOf('下一步') === 0 ? `。${text}` : text)).join(',');
speakText(merged, pitch);
};
export const stopSpeech = async () => {
try {
await Speech.stop();
} catch {}
playToken += 1;
cloudChain = Promise.resolve();
await unloadActiveSound();
};
// ==================== 播报原子取词 ====================
/** 步骤播报的最小原子单元,必须与服务端 tts.js 的 buildCardTTSBatch 逐字一致(云端查表依赖) */
export const stepSpeechAtoms = (
loop: number,
step: MotionStep,
nextStep: MotionStep | null,
loopMode: string
): string[] => {
const atoms: string[] = [];
if (loopMode !== 'once') atoms.push(`第${loop}轮`);
const voiceText = String(step?.voiceText || '').trim();
atoms.push(voiceText || `${String(step?.name || '').trim() || '步骤'},${formatSpeechDuration(step?.durationSec)}`);
if (nextStep) atoms.push(`下一步,${String(nextStep.name || '').trim() || '下一步'}`);
return atoms;
};
/** 倒计时原子,必须与服务端 tts.js 的 '还有 5 秒' / '还有 3 秒' 逐字一致 */
export const countdownAtom = (seconds: number) => `还有 ${seconds} 秒`;
// ==================== 预合成与探测 ====================
let ttsPreparedCardKey = '';
/** 卡片内容指纹:步骤/循环任一变化即视为新内容,需重新预合成(与 card.id 无关) */
export const cardTTSFingerprint = (card: MotionCard): string => {
try {
return JSON.stringify({
steps: (card.steps || []).map((step) => [
String(step.name || ''),
Number(step.durationSec) || 0,
String(step.voiceText || ''),
String(step.type || ''),
]),
loopMode: card.loopMode,
loopCount: card.loopCount,
});
} catch {
return '';
}
};
/** 批量预合成整卡语音;force=true 用于卡片列表的「重生语音」手动补救 */
export const prepareCardTTS = async (card: MotionCard, force = false): Promise<TTSPrepareResult | null> => {
if (!card?.id) return null;
const key = cardTTSFingerprint(card);
if (!force && key && ttsPreparedCardKey === key) return null;
ttsPreparedCardKey = key;
try {
const result = await motionReminderApi.prepareTTS(card);
ttsUrlMap = { ...ttsUrlMap, ...(result.map || {}) };
console.log(`[order_box tts] 预合成完成 ${result.success}/${result.total}`);
if (speechState.mode === 'cloud' && Object.keys(ttsUrlMap).length) setSpeechWarning('');
return result;
} catch (error) {
ttsPreparedCardKey = '';
console.warn('[order_box tts] 预合成失败:', error);
return null;
}
};
let probeDone = false;
/**
* 系统 TTS 是否真的可用:欢迎语自身当探测信号,onStart/onError + 1200ms 超时判定引擎是否启动;
* 「喇叭有没有真响」由调用方弹窗让用户确认。force=true 忽略「已探测过」强制重测。
* 返回 true 表示引擎已启动(仍需用户听觉确认),false 表示引擎没起来(已自动切云端兜底)。
*/
export const probeSpeech = async (force = false): Promise<boolean> => {
if (probeDone && !force) return speechState.mode === 'system';
probeDone = true;
const started = await speakSystem(WELCOME_TEXT, 1);
if (started) return true;
setSpeechMode('cloud', '系统语音未能启动,已切换云端语音兜底(需先启动一次流程完成预合成)。');
return false;
};
/** 用户确认「听到了 / 没听到」后落定通道 */
export const confirmSpeechHeard = (heard: boolean) => {
if (heard) {
setSpeechMode('system', '');
return;
}
setSpeechMode('cloud', '系统语音未出声,已切换云端语音兜底(需先启动一次流程完成预合成)。');
};