diff --git a/mobile-uni/src/services/speech-audio-source.ts b/mobile-uni/src/services/speech-audio-source.ts index 9464a523..3d970a69 100644 --- a/mobile-uni/src/services/speech-audio-source.ts +++ b/mobile-uni/src/services/speech-audio-source.ts @@ -11,10 +11,29 @@ export interface DecodedAudioDataUri { bytes: Uint8Array; } +/** + * App 端临时音频的两个路径缺一不可: + * - `playbackUrl` 是 convertLocalFileSystemURL 转出的 file:// 绝对路径,InnerAudioContext 只认这个; + * - `plusPath` 是 `_doc/xxx.mp3` 相对路径,resolveLocalFileSystemURL 删除时只认这个。 + * 早前只回传前者,清理时拿绝对路径去 resolve 解析不到,remove() 从不执行,文件逐句堆积。 + */ +export interface NativeTempAudio { + playbackUrl: string; + plusPath: string; +} + const DATA_AUDIO_URI_PATTERN = /^data:(audio\/[\w.+-]+);base64,([A-Za-z0-9+/=\r\n]+)$/i; export const isDataAudioUri = (source: string) => DATA_AUDIO_URI_PATTERN.test(source); +/** 取出 base64 载荷,供 App 端 `writeAsBinary` 直接写入,无需 atob/Blob。 */ +export const extractAudioBase64 = (dataUri: string): { mime: string; base64: string } | null => { + const match = DATA_AUDIO_URI_PATTERN.exec(dataUri); + if (!match) return null; + const base64 = match[2].replace(/[\r\n]/g, ''); + return base64 ? { mime: match[1], base64 } : null; +}; + /** 纯解析,不依赖浏览器 API,便于单测。base64 非法或不是音频 data URI 时返回 null。 */ export const decodeDataAudioUri = (dataUri: string): DecodedAudioDataUri | null => { const match = DATA_AUDIO_URI_PATTERN.exec(dataUri); @@ -42,13 +61,20 @@ export const dataUriToBlobUrl = (dataUri: string): string | null => { interface PlusIoFileEntry { fullPath: string; createWriter: (ok: (writer: PlusIoWriter) => void, fail?: (err: unknown) => void) => void; + file: (ok: (meta: PlusIoFileMeta) => void, fail?: (err: unknown) => void) => void; remove: (ok?: () => void, fail?: (err: unknown) => void) => void; } interface PlusIoWriter { onwriteend: (() => void) | null; onerror: ((err: unknown) => void) | null; - write: (data: Blob) => void; + // plus.io 的 FileWriter 只接受字符串:write 写文本,writeAsBinary 收 base64。 + // 传 Blob 会被原生桥接序列化成空内容,落地 0 字节且照样回调 onwriteend。 + writeAsBinary: (data: string) => void; +} + +interface PlusIoFileMeta { + size: number; } interface PlusIoDirectoryEntry { @@ -77,20 +103,35 @@ const resolvePlusIo = (): PlusIo | null => { /** 是否处于需要落盘播放的 App-Plus 原生环境(避免与 speech-capture 循环引用,本地判定)。 */ export const hasNativeAudioFileSupport = () => resolvePlusIo() !== null; -/** App-Plus:base64 落 `_doc/` 临时文件,返回可播放的 file:// 路径;失败返回 null。 */ -export const writeNativeTempAudio = (dataUri: string): Promise => { - const decoded = decodeDataAudioUri(dataUri); +/** + * App-Plus:base64 落 `_doc/` 临时文件,返回可播放的 file:// 路径;失败返回 null。 + * + * 写入后必须复核 `size > 0`:`onwriteend` 会在实际写了 0 字节时照样回调, + * 空文件交给 InnerAudioContext 会静默播完、不触发 onError,表现为"界面正常 + * 但没有声音"。非空校验是这条链路唯一能暴露写盘失败的地方。 + */ +export const writeNativeTempAudio = (dataUri: string): Promise => { + const payload = extractAudioBase64(dataUri); const io = resolvePlusIo(); - if (!decoded || !io) return Promise.resolve(null); + if (!payload || !io) return Promise.resolve(null); return new Promise((resolve) => { try { io.resolveLocalFileSystemURL('_doc/', (dir) => { dir.getFile(`aihr-tts-${Date.now()}.mp3`, { create: true }, (fileEntry) => { fileEntry.createWriter((writer) => { - writer.onwriteend = () => resolve(io.convertLocalFileSystemURL(fileEntry.fullPath)); + writer.onwriteend = () => { + fileEntry.file( + (meta) => resolve(meta && meta.size > 0 + ? { + playbackUrl: io.convertLocalFileSystemURL(fileEntry.fullPath), + plusPath: fileEntry.fullPath + } + : null), + () => resolve(null) + ); + }; writer.onerror = () => resolve(null); - const blobBytes = new Uint8Array(decoded.bytes); - writer.write(new Blob([blobBytes], { type: decoded.mime })); + writer.writeAsBinary(payload.base64); }, () => resolve(null)); }, () => resolve(null)); }, () => resolve(null)); @@ -100,13 +141,20 @@ export const writeNativeTempAudio = (dataUri: string): Promise => }); }; -/** 播放结束后清理 App 端临时文件,尽力而为。 */ -export const removeNativeTempAudio = (fileUrl: string) => { +/** + * 播放结束后清理 App 端临时文件,尽力而为。 + * 入参必须是 `_doc/xxx.mp3` 形式的 plus 路径,不能传 file:// 绝对路径,否则解析不到。 + */ +export const removeNativeTempAudio = (plusPath: string) => { const io = resolvePlusIo(); - if (!io) return; + if (!io || !plusPath) return; try { - io.resolveLocalFileSystemURL(fileUrl, (entry) => entry.remove()); - } catch { - // 清理失败不影响播放主流程 + io.resolveLocalFileSystemURL( + plusPath, + (entry) => entry.remove(undefined, (err) => console.warn('[tts] 临时音频删除失败', plusPath, err)), + (err) => console.warn('[tts] 临时音频路径解析失败', plusPath, err) + ); + } catch (err) { + console.warn('[tts] 临时音频清理异常', plusPath, err); } }; diff --git a/mobile-uni/src/services/speech.ts b/mobile-uni/src/services/speech.ts index 0f2cc71d..47bece63 100644 --- a/mobile-uni/src/services/speech.ts +++ b/mobile-uni/src/services/speech.ts @@ -245,16 +245,16 @@ export const createSpeechPlaybackController = ( // webview 也常静默失败,统一先转换:H5 转 Blob URL,App 端落临时文件。 if (isDataAudioUri(source)) { if (hasNativeAudioFileSupport()) { - void writeNativeTempAudio(source).then((fileUrl) => { - if (!fileUrl) { + void writeNativeTempAudio(source).then((temp) => { + if (!temp) { failPlayback(); return; } if (!isActivePlayback()) { - removeNativeTempAudio(fileUrl); + removeNativeTempAudio(temp.plusPath); return; } - startPlayback(fileUrl, () => removeNativeTempAudio(fileUrl)); + startPlayback(temp.playbackUrl, () => removeNativeTempAudio(temp.plusPath)); }); return; } diff --git a/mobile-uni/tests/speech-audio-source.test.mjs b/mobile-uni/tests/speech-audio-source.test.mjs index 87c1829d..3ddeedc4 100644 Binary files a/mobile-uni/tests/speech-audio-source.test.mjs and b/mobile-uni/tests/speech-audio-source.test.mjs differ