bbc7ca6523
- 新增小程序个人收藏回收站页面,支持查看已删除收藏、恢复与彻底删除操作 - 修复个人收藏文件上传时的中文名乱码问题,新增原文件名透传参数 - 接入阿里云百炼TTS服务,实现运动提醒模块的云端语音合成与本地缓存,支持系统TTS与云端MP3双降级播放 - 新增语音引擎探测与异常预警UI,覆盖web端与移动端 - 优化web端订单盒页面布局,修复卡片操作按钮换行问题 - 新增调试探针脚本、ESLint配置与相关lint脚本 - 完善后端接口与路由,更新项目依赖与配置文件
349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
// 运动提醒宝(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', '系统语音未出声,已切换云端语音兜底(需先启动一次流程完成预合成)。');
|
||
}; |