/** * TtsEngine —— AI 回复语音朗读(CoreSpeechKit textToSpeech)。 * * V2(真机修复版): * - 长文本分段:单次 speak 文本上限 500 字符(1002300001),超长直接失败。 * 按句子边界切 ≤400 字符/段,在 onComplete(type=1 播报完成) 回调里串行 * 播下一段,150 秒兜底定时器防回调丢失卡死链条 * - 不依赖 queueMode:官方 API 文档(0=flush)与 Codelabs(0=排队)说法 * 互相矛盾,新朗读前显式 engine.stop() 打断,语义自持 * - 播报状态回调 onStateChanged:页面据此复位 🔊 图标 * - V3:speak 前 sanitizeForSpeech 净化——不念 Markdown/英文标点/表情符号 * (中文停顿标点保留做自然停顿) * * 模型:offline 中文引擎(online: 1,系统内置);模拟器通常无此服务,须真机。 * 日志 tag MEIDA_TTS:hdc shell hilog | grep MEIDA_TTS */ import { textToSpeech } from '@kit.CoreSpeechKit' const SEGMENT_MAX_CHARS: number = 400 const SEGMENT_FALLBACK_MS: number = 150000 /** * 朗读净化(V3):剥掉会被引擎念出来的符号——Markdown 标记(**、#、- 列表符)、 * 英文标点、表情等,只留中英文、数字与中文停顿标点(,。;:!?、……靠它们做自然停顿, * 引擎不会逐字念)。顾问回答被要求不写 Markdown(agent_chat v1),这里是双保险。 */ function sanitizeForSpeech(text: string): string { return text .replace(/[^一-龥a-zA-Z0-9\s。,、;:!?]/g, ' ') .replace(/\s+/g, ' ') .trim() } /** 播报状态变化回调(speaking=false 表示本轮朗读完全结束或被停止)。 */ export type TtsStateChanged = (speaking: boolean) => void function splitSegments(text: string, maxChars: number): string[] { const segments: string[] = [] let current: string = '' const sentenceEnds: string[] = ['。', '!', '?', ';', '\n'] for (let i = 0; i < text.length; i++) { current += text[i] const atSentenceEnd: boolean = sentenceEnds.indexOf(text[i]) >= 0 if ((atSentenceEnd && current.length >= 16) || current.length >= maxChars) { segments.push(current) current = '' } } if (current !== '') { segments.push(current) } return segments } export class TtsEngine { private engine: textToSpeech.TextToSpeechEngine | null = null private queue: string[] = [] private currentRequestId: string = '' private requestSeq: number = 0 private speaking: boolean = false private fallbackTimer: number = -1 private stateChanged: TtsStateChanged | null = null /** 页面注入状态回调(如复位朗读高亮图标)。 */ setOnStateChanged(cb: TtsStateChanged): void { this.stateChanged = cb } private async ensureEngine(): Promise { if (this.engine !== null) { return } const extraParam: Record = { 'style': 'interaction-broadcast', 'locate': 'CN', 'name': 'MeiDaTTS' } const createParams: textToSpeech.CreateEngineParams = { language: 'zh-CN', person: 0, online: 1, extraParams: extraParam } this.engine = await textToSpeech.createEngine(createParams) console.info('MEIDA_TTS engine created') this.attachListener() } /** * 朗读入口:切段入队并立即播首段。引擎创建失败会抛 BusinessError, * 由调用方决定是否提示(自动朗读静默、手动点击提示)。 */ async speak(text: string): Promise { const trimmed: string = sanitizeForSpeech(text) if (trimmed.length === 0) { return } await this.ensureEngine() this.stop() this.queue = splitSegments(trimmed, SEGMENT_MAX_CHARS) console.info(`MEIDA_TTS speak start: ${trimmed.length} chars, ${this.queue.length} segment(s)`) this.speakNext() } /** 串行驱动:每段播完(onComplete type=1)再播下一段。 */ private speakNext(): void { if (this.queue.length === 0) { this.finishSpeaking() return } if (this.engine === null) { this.finishSpeaking() return } const segment: string = this.queue.shift() ?? '' if (segment === '') { this.finishSpeaking() return } this.requestSeq += 1 this.currentRequestId = `meida-tts-${this.requestSeq}-${Date.now()}` this.speaking = true if (this.stateChanged !== null) { this.stateChanged(true) } const speakExtra: Record = { 'queueMode': 0, 'speed': 1, 'volume': 2, 'pitch': 1, 'languageContext': 'zh-CN', 'audioType': 'pcm', 'soundChannel': 3, 'playType': 1 } const speakParams: textToSpeech.SpeakParams = { requestId: this.currentRequestId, extraParams: speakExtra } this.armFallback() try { this.engine.speak(segment, speakParams) } catch (e) { console.error(`MEIDA_TTS segment speak throw: ${String(e)}`) this.clearFallback() this.finishSpeaking() } } /** 停止朗读并清空待播分段。幂等:未在播/引擎为空也安全。 */ stop(): void { this.queue = [] this.clearFallback() if (this.engine !== null) { try { this.engine.stop() } catch (_e) { // 空闲态调用 stop 可能抛错:忽略 } } if (this.speaking) { this.speaking = false if (this.stateChanged !== null) { this.stateChanged(false) } } } isSpeaking(): boolean { return this.speaking } release(): void { this.stop() const engine = this.engine this.engine = null if (engine !== null) { try { engine.shutdown() } catch (_e) { // 页面销毁路径,失败即随对象丢弃 } } } private finishSpeaking(): void { this.queue = [] this.clearFallback() if (this.speaking) { this.speaking = false console.info('MEIDA_TTS all segments done') if (this.stateChanged !== null) { this.stateChanged(false) } } } /** 某段回调丢失时强制推进,避免长文朗读链条卡死。 */ private armFallback(): void { this.clearFallback() this.fallbackTimer = setTimeout(() => { console.warn(`MEIDA_TTS segment fallback fire, ${this.queue.length} segment(s) left`) this.speakNext() }, SEGMENT_FALLBACK_MS) } private clearFallback(): void { if (this.fallbackTimer >= 0) { clearTimeout(this.fallbackTimer) this.fallbackTimer = -1 } } private attachListener(): void { const engine = this.engine if (engine === null) { return } engine.setListener({ onStart: (requestId: string, response: textToSpeech.StartResponse) => { console.info(`MEIDA_TTS onStart rid=${requestId}`) }, onComplete: (requestId: string, response: textToSpeech.CompleteResponse) => { if (requestId !== this.currentRequestId) { return } // type=0 合成完成、type=1 播报完成:只等播报完成才推进下一段 if (response.type === 1) { console.info(`MEIDA_TTS segment done, ${this.queue.length} segment(s) left`) this.clearFallback() this.speakNext() } }, onStop: (requestId: string, response: textToSpeech.StopResponse) => { console.info(`MEIDA_TTS onStop rid=${requestId}`) if (requestId !== this.currentRequestId) { return } this.finishSpeaking() }, onData: (requestId: string, audio: ArrayBuffer, response: textToSpeech.SynthesisResponse) => { }, onError: (requestId: string, errorCode: number, errorMessage: string) => { console.error(`MEIDA_TTS onError rid=${requestId} code=${errorCode} msg=${errorMessage}`) if (requestId !== this.currentRequestId) { return } this.finishSpeaking() } }) } }