Files
meida/app/entry/src/main/ets/components/f4/TtsEngine.ets
T

254 lines
7.8 KiB
Plaintext
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.
/**
* 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<void> {
if (this.engine !== null) {
return
}
const extraParam: Record<string, Object> = {
'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<void> {
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<string, Object> = {
'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()
}
})
}
}