254 lines
7.8 KiB
Plaintext
254 lines
7.8 KiB
Plaintext
/**
|
||
* 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()
|
||
}
|
||
})
|
||
}
|
||
}
|