// 运动提醒宝(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) => { 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 = {}; let activeSound: Audio.Sound | null = null; let playToken = 0; let cloudChain: Promise = 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) => { 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((resolve) => { let settled = false; let timer: ReturnType | 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 => 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 => { 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 => { 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', '系统语音未出声,已切换云端语音兜底(需先启动一次流程完成预合成)。'); };