feat(app): AI 对话语音输入(微信式)+ 回复语音朗读——ASR V2 引擎自采修复 1002200006 busy(recognitionMode 0 不再自建采集器)+ busy 自愈;TTS V2 长文分段串行朗读(单次 500 字符上限)+ 状态回调;按住说话/松开发送/上滑取消,麦克风权限运行时申请
This commit is contained in:
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user