From 94ee29f59cc00ad030d175a39794919a9256da94 Mon Sep 17 00:00:00 2001 From: let5sne Date: Sun, 26 Jul 2026 12:10:50 +0800 Subject: [PATCH] fix(speech): write native TTS temp file as binary and clean it up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App-Plus 的 plus.io FileWriter 只接受字符串:write 写文本,writeAsBinary 收 base64。原先传 Blob 会被原生桥接序列化成空内容,落地 0 字节且照样回调 onwriteend,InnerAudioContext 静默播完、不报错,表现为"界面正常但没有声音"。 改用 writeAsBinary 并在 onwriteend 里复核 size > 0,让写盘失败能被暴露。 清理侧同一处存在文件泄漏:播放要 convertLocalFileSystemURL 转出的 file:// 绝对路径,而 resolveLocalFileSystemURL 删除只认 `_doc/xxx.mp3` 相对路径。 原先只回传前者,拿它去 resolve 解析不到,remove() 从不执行,且 catch 静默, 临时 mp3 逐句堆积。改为返回 { playbackUrl, plusPath } 两个路径,清理失败加告警。 真机验证(Android 10 / io.dcloud.HBuilder):落盘 80251 字节,ID3+LAME 头, MPEG layer III 160 kbps 24 kHz 单声道,播完文件自动删除。 顺带把测试里 MP3 头字面量的真实 NUL 字节改为 \x00 转义(运行时字节不变), 此前 git 将该测试文件判为二进制,diff 无法审阅。 --- .../src/services/speech-audio-source.ts | 76 ++++++++++++++---- mobile-uni/src/services/speech.ts | 8 +- mobile-uni/tests/speech-audio-source.test.mjs | Bin 4138 -> 6318 bytes 3 files changed, 66 insertions(+), 18 deletions(-) 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 87c1829d0118476a53b1561ebd68a941dbf069c4..3ddeedc4c46952f8e4cee9281ac29bab578f7373 100644 GIT binary patch delta 1868 zcmZuxU2oG?7}j7ElC(vFT?`oX6-bS8LWhaHh*9j`vtqf$9B-B-Rx$QChZ(M>B1nABcJ#9ecrF< zdFS9^^+IfQ>DQ+Nd*~Bj&*Rgpd)IVURY5cyQ~jb0=JoS%NDQN6il`w4$ncyoG);!b z$^5waU`9{<_@e8aF@Kj_9E6D6(hkPxBGiTDW+2ZNjj?%awhNl+(XVwx5$~qJMqgiU z^qQ4SlIcrkdWKA-$-tO#VPt!*>$;Wg-5xX(owtlc8cwl@wQ8Lx2}%d(nIR)FXHDI= z7QQz}XL)O6i6j;v05lP+@0`^&KnAZEKlPLDSu>NRNwHXDVv^=&Wv}IW2b^R`&o$CL zO^Y(FCZSnS(Y`$l!70T^Jh0ODdA&0=NU!xwMqj!@lFzJ1w;5iGEU6kuf+n6s^|U*1 z0J!E34XLa(8j{#KBxr&U4jhEKSZga%{H=-{v5h=h* z6IXGi>vR0He8;Z;=D@|I5NZ`A6e_foRaE1q_|C{Q6p7L#@!#bE>?tYT7!kCVHjY{S z(Xi0bEVQ)apGyyY$jF)MHb;6$wmWw_WAtaui4>dv((w)h`t$2#vd6raWb6SDR23 z6N%iKCq`qSiLyr36j3^X&#v2uE6PeLY~zvo-N!$zZKyr|)t86;YBVfroR|0d8mpV| zuWxV0r`B)9xNIxubZkW;B@;Kva1wvys$h~gM{nurduD>E z&zM+dQt3^@e$Z2MD7QYnfpwRM|%Vj4^bP8u7z&eq#|OI9pHwA138T@s_ookDbBlsVBzxV(!-C+|;EY-6F;F zbp}Yc7Ht6C56+qa1x4hOi1Xn;$~Mu1Du+*^kS$rpWF|%D3uX)8`SQvw)C~6a3_?LN zOw;Tgcf0lHr{@GS$r^mh8~vB5gy{U8GtRF5qQMbs zROkrH0&AxdlQc1c{FE$;(4yJosk1jWS1f?nQ^`a4BVq&}Era(9XJ-NPQ0)D>d^Wru X50|U~{7cEUH(Xs_Tlsjptb5hJRGSs+ delta 291 zcmZ2yxJqF|DKj$*1HUn?tBE2|t7(nIM<_;!*Xn>= z;?El}`8B(qOhHa*u>wdED43j|S6rfymYI_pT9l(;t1!7v++gx@J|Py6(Bxx$Zj<-$ l>+_ZuWtOBCM;pZIl~fi?He{2U9L2&nIZVK6vx!(069CL0RjmL3