feat(personal_collect,motion_reminder): 新增回收站与云端TTS语音功能
- 新增小程序个人收藏回收站页面,支持查看已删除收藏、恢复与彻底删除操作 - 修复个人收藏文件上传时的中文名乱码问题,新增原文件名透传参数 - 接入阿里云百炼TTS服务,实现运动提醒模块的云端语音合成与本地缓存,支持系统TTS与云端MP3双降级播放 - 新增语音引擎探测与异常预警UI,覆盖web端与移动端 - 优化web端订单盒页面布局,修复卡片操作按钮换行问题 - 新增调试探针脚本、ESLint配置与相关lint脚本 - 完善后端接口与路由,更新项目依赖与配置文件
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { ApiEnvelope, MotionCard, MotionSession } from './types';
|
||||
import { ApiEnvelope, MotionCard, MotionSession, TTSPrepareResult } from './types';
|
||||
|
||||
const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || '/api/app_order_box';
|
||||
export const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || '/api/app_order_box';
|
||||
// 注意:MOTION_BASE 是相对于 axios baseURL 的路径,不要包含 API_BASE_URL 前缀
|
||||
// 否则 axios 会双重拼接导致 404(如 /api/app_order_box/api/app_order_box/kits/...)
|
||||
const MOTION_BASE = '/kits/motion_reminder';
|
||||
@@ -95,4 +95,17 @@ export const motionReminderApi = {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
/** 批量预合成整卡「最小播报单元」的云端 mp3,返回文本→URL 映射(服务端 md5 内容寻址,重复文本不会重复合成) */
|
||||
async prepareTTS(card: MotionCard): Promise<TTSPrepareResult> {
|
||||
try {
|
||||
const response = await api.post<ApiEnvelope<TTSPrepareResult>>(`${MOTION_BASE}/tts/prepare`, { card });
|
||||
if (!response.data.ok || !response.data.data) {
|
||||
throw new Error(response.data.error || '语音预合成失败');
|
||||
}
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as Speech from 'expo-speech';
|
||||
import { motionReminderApi } from '../api';
|
||||
import { MotionCard, SessionStatus } from '../types';
|
||||
import {
|
||||
countdownAtom,
|
||||
prepareCardTTS,
|
||||
speakAtoms,
|
||||
stepSpeechAtoms,
|
||||
stopSpeech,
|
||||
} from '../speech';
|
||||
|
||||
export interface RunnerState {
|
||||
sessionId: string;
|
||||
@@ -24,17 +30,6 @@ export interface UseRunnerReturn {
|
||||
syncToServer: () => Promise<void>;
|
||||
}
|
||||
|
||||
const formatSpeechDuration = (seconds: number) => {
|
||||
const safe = Math.max(0, Math.floor(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}秒`);
|
||||
if (!parts.length) parts.push('0秒');
|
||||
return parts.join('');
|
||||
};
|
||||
|
||||
export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
|
||||
const [runner, setRunner] = useState<RunnerState | null>(null);
|
||||
const announcementRef = useRef('');
|
||||
@@ -55,7 +50,7 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
|
||||
const message = error instanceof Error ? error.message : '结束失败';
|
||||
console.error('[Runner] 同步失败:', message);
|
||||
} finally {
|
||||
Speech.stop();
|
||||
void stopSpeech();
|
||||
setRunner(null);
|
||||
announcementRef.current = '';
|
||||
onSessionEnd?.();
|
||||
@@ -105,10 +100,9 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
|
||||
const isAction = step?.type === 'action';
|
||||
const countdownPitch = isAction ? 1.1 : 0.9;
|
||||
|
||||
// 5/3 秒倒计时播报
|
||||
// 5/3 秒倒计时播报:文本与系统 TTS / 云端 mp3 查表共用同一原子
|
||||
if (runner.remainingSec === 5 || runner.remainingSec === 3) {
|
||||
Speech.stop();
|
||||
Speech.speak(`还有 ${runner.remainingSec} 秒`, { language: 'zh-CN', pitch: countdownPitch, rate: 0.95 });
|
||||
speakAtoms([countdownAtom(runner.remainingSec)], countdownPitch);
|
||||
}
|
||||
|
||||
if (runner.remainingSec <= 1) {
|
||||
@@ -138,15 +132,7 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
|
||||
|
||||
// 动作步骤用较高音调,休息步骤用较低音调
|
||||
const stepPitch = step.type === 'action' ? 1.1 : 0.9;
|
||||
|
||||
const title = step.voiceText || `${step.name},${formatSpeechDuration(step.durationSec || 0)}`;
|
||||
const nextText = nextStep ? `。下一步,${nextStep.name}` : '';
|
||||
Speech.stop();
|
||||
Speech.speak(`第 ${runner.currentLoop} 轮,${title}${nextText}`, {
|
||||
language: 'zh-CN',
|
||||
pitch: stepPitch,
|
||||
rate: 0.95,
|
||||
});
|
||||
speakAtoms(stepSpeechAtoms(runner.currentLoop, step, nextStep, runner.card.loopMode), stepPitch);
|
||||
|
||||
// 同步到服务端
|
||||
void motionReminderApi.syncSession({
|
||||
@@ -162,7 +148,7 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
|
||||
// 清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Speech.stop();
|
||||
void stopSpeech();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -170,6 +156,8 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
|
||||
if (!card.steps.length) {
|
||||
throw new Error('当前卡片没有可执行步骤');
|
||||
}
|
||||
// 会话启动时批量预合成整卡云端语音(不阻塞启动,失败时运行期回退系统 TTS)
|
||||
void prepareCardTTS(card);
|
||||
const session = await motionReminderApi.startSession(card);
|
||||
setRunner({
|
||||
sessionId: session.id,
|
||||
@@ -238,4 +226,4 @@ export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
|
||||
finishSession: finishRunner,
|
||||
syncToServer,
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -13,6 +13,15 @@ import { motionReminderApi } from '../api';
|
||||
import { useRunner } from '../hooks/useRunner';
|
||||
import { CardEditor } from '../components/CardEditor';
|
||||
import { RunnerModal } from '../components/RunnerModal';
|
||||
import {
|
||||
WELCOME_TEXT,
|
||||
configureSpeechAudioMode,
|
||||
confirmSpeechHeard,
|
||||
getSpeechState,
|
||||
prepareCardTTS,
|
||||
probeSpeech,
|
||||
subscribeSpeechState,
|
||||
} from '../speech';
|
||||
import { theme } from '../../../theme';
|
||||
import { MotionCard, MotionSession, OrderBoxState } from '../../../types';
|
||||
|
||||
@@ -40,11 +49,59 @@ export const CardsScreen: React.FC<CardsScreenProps> = ({ onExit, serverState, o
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editorVisible, setEditorVisible] = useState(false);
|
||||
const [editingCard, setEditingCard] = useState<MotionCard | null>(null);
|
||||
const [speechState, setSpeechState] = useState(getSpeechState());
|
||||
const [ttsRegenId, setTtsRegenId] = useState<string | null>(null);
|
||||
|
||||
const runner = useRunner(() => {
|
||||
onRefresh();
|
||||
});
|
||||
|
||||
useEffect(() => subscribeSpeechState(setSpeechState), []);
|
||||
|
||||
const askSpeechHeard = () => {
|
||||
Alert.alert(
|
||||
'语音检查',
|
||||
`正在播报欢迎语:${WELCOME_TEXT}\n请确认是否听到声音。`,
|
||||
[
|
||||
{ text: '没听到', style: 'destructive', onPress: () => confirmSpeechHeard(false) },
|
||||
{ text: '听到了', onPress: () => confirmSpeechHeard(true) },
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const retrySpeechProbe = async () => {
|
||||
const engineStarted = await probeSpeech(true);
|
||||
if (engineStarted) askSpeechHeard();
|
||||
};
|
||||
|
||||
/** 手动补救:强制重建该卡片的云端语音缓存(内容寻址,已存在的文本不会重复调用百炼) */
|
||||
const regenCardTTS = async (card: MotionCard) => {
|
||||
if (!card.steps.length) {
|
||||
Alert.alert('无法生成', '当前卡片没有可执行步骤');
|
||||
return;
|
||||
}
|
||||
setTtsRegenId(card.id);
|
||||
try {
|
||||
const result = await prepareCardTTS(card, true);
|
||||
if (!result) {
|
||||
Alert.alert('生成失败', '云端语音生成失败,请检查服务与网络后重试(详情见控制台日志)。');
|
||||
return;
|
||||
}
|
||||
Alert.alert(`语音已重新生成(${result.success}/${result.total})`, '该卡片的云端语音缓存已强制重建。');
|
||||
} finally {
|
||||
setTtsRegenId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 进入运动 kit 时:先配置音频会话(iOS 静音键下也要能播 mp3),再播欢迎语探测系统 TTS
|
||||
useEffect(() => {
|
||||
void configureSpeechAudioMode();
|
||||
void (async () => {
|
||||
const engineStarted = await probeSpeech();
|
||||
if (engineStarted) askSpeechHeard();
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const nextCards = await motionReminderApi.listCards();
|
||||
@@ -141,6 +198,16 @@ export const CardsScreen: React.FC<CardsScreenProps> = ({ onExit, serverState, o
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{speechState.warning ? (
|
||||
<View style={[styles.warnCard, styles.voiceWarnCard]}>
|
||||
<Text style={styles.warnCardTitle}>语音播报可能不可用</Text>
|
||||
<Text style={styles.warnCardText}>{speechState.warning}</Text>
|
||||
<TouchableOpacity style={styles.secondaryButton} onPress={retrySpeechProbe}>
|
||||
<Text style={styles.secondaryButtonText}>重新检测</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<ActivityIndicator style={styles.loader} color={theme.colors.primary} />
|
||||
) : (
|
||||
@@ -177,6 +244,15 @@ export const CardsScreen: React.FC<CardsScreenProps> = ({ onExit, serverState, o
|
||||
<TouchableOpacity style={styles.ghostActionButton} onPress={() => deleteCard(item)}>
|
||||
<Text style={[styles.ghostActionButtonText, styles.dangerText]}>删除</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.ghostActionButton, ttsRegenId === item.id && styles.buttonDisabled]}
|
||||
onPress={() => regenCardTTS(item)}
|
||||
disabled={ttsRegenId === item.id}
|
||||
>
|
||||
<Text style={styles.ghostActionButtonText}>
|
||||
{ttsRegenId === item.id ? '生成中…' : '重生语音'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -347,6 +423,12 @@ const styles = StyleSheet.create({
|
||||
dangerText: {
|
||||
color: theme.colors.error,
|
||||
},
|
||||
voiceWarnCard: {
|
||||
borderColor: theme.colors.error,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
emptyCard: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
// 运动提醒宝(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', '系统语音未出声,已切换云端语音兜底(需先启动一次流程完成预合成)。');
|
||||
};
|
||||
@@ -40,3 +40,12 @@ export interface ApiEnvelope<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** POST /kits/motion_reminder/tts/prepare 的返回:map 为「播报原子文本 → 云端 mp3 URL」 */
|
||||
export interface TTSPrepareResult {
|
||||
voice: string;
|
||||
total: number;
|
||||
success: number;
|
||||
map: Record<string, string>;
|
||||
missing: string[];
|
||||
}
|
||||
|
||||
Generated
+18
@@ -14,6 +14,7 @@
|
||||
"@react-navigation/native": "^6.1.0",
|
||||
"axios": "^1.6.0",
|
||||
"expo": "~54.0.0",
|
||||
"expo-av": "~16.0.8",
|
||||
"expo-haptics": "^57.0.0",
|
||||
"expo-keep-awake": "^57.0.0",
|
||||
"expo-speech": "~13.0.1",
|
||||
@@ -4452,6 +4453,23 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-av": {
|
||||
"version": "16.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-av/-/expo-av-16.0.8.tgz",
|
||||
"integrity": "sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-web": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "18.0.13",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@react-navigation/native": "^6.1.0",
|
||||
"axios": "^1.6.0",
|
||||
"expo": "~54.0.0",
|
||||
"expo-av": "~16.0.8",
|
||||
"expo-haptics": "^57.0.0",
|
||||
"expo-keep-awake": "^57.0.0",
|
||||
"expo-speech": "~13.0.1",
|
||||
|
||||
Reference in New Issue
Block a user