feat(app): AI 对话语音输入(微信式)+ 回复语音朗读——ASR V2 引擎自采修复 1002200006 busy(recognitionMode 0 不再自建采集器)+ busy 自愈;TTS V2 长文分段串行朗读(单次 500 字符上限)+ 状态回调;按住说话/松开发送/上滑取消,麦克风权限运行时申请

This commit is contained in:
xsl
2026-08-16 11:40:47 +08:00
parent db9c4da0e3
commit 83fd48b910
5 changed files with 900 additions and 111 deletions
@@ -0,0 +1,239 @@
/**
* TtsEngine —— AI 回复语音朗读(CoreSpeechKit textToSpeech)。
*
* V2(真机修复版):
* - 长文本分段:单次 speak 文本上限 500 字符(1002300001),超长直接失败。
* 按句子边界切 ≤400 字符/段,在 onComplete(type=1 播报完成) 回调里串行
* 播下一段,150 秒兜底定时器防回调丢失卡死链条
* - 不依赖 queueMode:官方 API 文档(0=flush)与 Codelabs(0=排队)说法
* 互相矛盾,新朗读前显式 engine.stop() 打断,语义自持
* - 播报状态回调 onStateChanged:页面据此复位 🔊 图标
*
* 模型: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
/** 播报状态变化回调(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 = text.trim()
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()
}
})
}
}