From ab3af277c14afc6d34ccea8e50027ec2315bbfaa Mon Sep 17 00:00:00 2001 From: let5sne Date: Sat, 25 Jul 2026 20:37:30 +0800 Subject: [PATCH] fix(speech): convert base64 TTS data URI before playback per platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 TTS 以 data: URI 返回 base64 mp3(生产实证为合法 LAME mp3),但 App-Plus 的 InnerAudioContext 不支持 data: URI,iOS Safari/微信 webview 也常静默失败,统一报"语音播放失败"。播放前按平台转换:H5 转 Blob URL, App 端经 plus.io 落 _doc/ 临时文件后播放 file:// 路径,播完清理;Blob 不可 用时退回原始 data: URI。竞态守卫与缓存语义保持不变,158 单测全绿。 --- .../src/services/speech-audio-source.ts | 112 ++++++++++++++++++ mobile-uni/src/services/speech.ts | 78 +++++++++--- mobile-uni/tests/speech-audio-source.test.mjs | Bin 0 -> 4138 bytes 3 files changed, 173 insertions(+), 17 deletions(-) create mode 100644 mobile-uni/src/services/speech-audio-source.ts create mode 100644 mobile-uni/tests/speech-audio-source.test.mjs diff --git a/mobile-uni/src/services/speech-audio-source.ts b/mobile-uni/src/services/speech-audio-source.ts new file mode 100644 index 00000000..9464a523 --- /dev/null +++ b/mobile-uni/src/services/speech-audio-source.ts @@ -0,0 +1,112 @@ +/** + * 语音播放的音源适配。 + * + * 后端 TTS 以 data: URI(base64 mp3)返回;uni.createInnerAudioContext 在 + * App-Plus 不支持 data: URI,iOS Safari/微信 webview 对 data: URI 音频也 + * 经常静默失败。因此播放前统一转换:H5 转 Blob URL,App 端落临时文件。 + */ + +export interface DecodedAudioDataUri { + mime: string; + bytes: Uint8Array; +} + +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); + +/** 纯解析,不依赖浏览器 API,便于单测。base64 非法或不是音频 data URI 时返回 null。 */ +export const decodeDataAudioUri = (dataUri: string): DecodedAudioDataUri | null => { + const match = DATA_AUDIO_URI_PATTERN.exec(dataUri); + if (!match) return null; + try { + const binary = atob(match[2].replace(/[\r\n]/g, '')); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + if (bytes.length === 0) return null; + return { mime: match[1], bytes }; + } catch { + return null; + } +}; + +/** H5:base64 转 Blob URL,规避 iOS Safari/微信对 data: URI 音频的兼容问题。 */ +export const dataUriToBlobUrl = (dataUri: string): string | null => { + const decoded = decodeDataAudioUri(dataUri); + if (!decoded || typeof Blob === 'undefined' || typeof URL === 'undefined' + || typeof URL.createObjectURL !== 'function') return null; + const blobBytes = new Uint8Array(decoded.bytes); + return URL.createObjectURL(new Blob([blobBytes], { type: decoded.mime })); +}; + +interface PlusIoFileEntry { + fullPath: string; + createWriter: (ok: (writer: PlusIoWriter) => 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; +} + +interface PlusIoDirectoryEntry { + getFile: ( + path: string, + flags: { create: boolean }, + ok: (entry: PlusIoFileEntry) => void, + fail?: (err: unknown) => void + ) => void; +} + +interface PlusIo { + resolveLocalFileSystemURL: ( + path: string, + ok: (entry: PlusIoDirectoryEntry & PlusIoFileEntry) => void, + fail?: (err: unknown) => void + ) => void; + convertLocalFileSystemURL: (path: string) => string; +} + +const resolvePlusIo = (): PlusIo | null => { + const plus = (globalThis as typeof globalThis & { plus?: { io?: PlusIo } }).plus; + return plus && plus.io ? plus.io : 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); + const io = resolvePlusIo(); + if (!decoded || !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.onerror = () => resolve(null); + const blobBytes = new Uint8Array(decoded.bytes); + writer.write(new Blob([blobBytes], { type: decoded.mime })); + }, () => resolve(null)); + }, () => resolve(null)); + }, () => resolve(null)); + } catch { + resolve(null); + } + }); +}; + +/** 播放结束后清理 App 端临时文件,尽力而为。 */ +export const removeNativeTempAudio = (fileUrl: string) => { + const io = resolvePlusIo(); + if (!io) return; + try { + io.resolveLocalFileSystemURL(fileUrl, (entry) => entry.remove()); + } catch { + // 清理失败不影响播放主流程 + } +}; diff --git a/mobile-uni/src/services/speech.ts b/mobile-uni/src/services/speech.ts index 9be62baf..0f2cc71d 100644 --- a/mobile-uni/src/services/speech.ts +++ b/mobile-uni/src/services/speech.ts @@ -2,6 +2,7 @@ import type { AsrResponse, PracticeTtsContext, SpeechPlaybackStatus, SpeechSelec import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api'; import { getAuth } from './auth'; import type { SpeechCapture } from './speech-capture'; +import { dataUriToBlobUrl, hasNativeAudioFileSupport, isDataAudioUri, removeNativeTempAudio, writeNativeTempAudio } from './speech-audio-source'; import { applyTtsVoicePreference, getTtsDialectPreference, getTtsVoicePreference } from './tts-voice-preference'; const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg']; @@ -202,26 +203,69 @@ export const createSpeechPlaybackController = ( }; const playAudioSource = (key: string, source: string, cacheKey: string, currentGeneration: number) => { - const context = uni.createInnerAudioContext(); - audio = context; - const isCurrent = () => currentGeneration === generation && audio === context && activeKey === key; - context.onPlay(() => { - if (isCurrent()) publish(key, 'playing'); - }); - context.onEnded(() => { - if (isCurrent()) stop(); - }); - context.onStop(() => { - if (isCurrent()) publish(); - }); - context.onError(() => { - if (!isCurrent()) return; + const isActivePlayback = () => currentGeneration === generation && activeKey === key; + const failPlayback = () => { + if (!isActivePlayback()) return; sourceCache.delete(cacheKey); stop(); onError('语音播放失败,请稍后重试'); - }); - context.src = source; - context.play(); + }; + const startPlayback = (src: string, cleanup?: () => void) => { + const context = uni.createInnerAudioContext(); + audio = context; + const isCurrent = () => currentGeneration === generation && audio === context && activeKey === key; + const finish = () => { + if (cleanup) cleanup(); + }; + context.onPlay(() => { + if (isCurrent()) publish(key, 'playing'); + }); + context.onEnded(() => { + if (isCurrent()) { + finish(); + stop(); + } + }); + context.onStop(() => { + if (isCurrent()) { + finish(); + publish(); + } + }); + context.onError(() => { + if (!isCurrent()) return; + finish(); + failPlayback(); + }); + context.src = src; + context.play(); + }; + + // 后端 TTS 返回 data: URI base64;App-Plus 不支持 data: URI,iOS Safari/微信 + // webview 也常静默失败,统一先转换:H5 转 Blob URL,App 端落临时文件。 + if (isDataAudioUri(source)) { + if (hasNativeAudioFileSupport()) { + void writeNativeTempAudio(source).then((fileUrl) => { + if (!fileUrl) { + failPlayback(); + return; + } + if (!isActivePlayback()) { + removeNativeTempAudio(fileUrl); + return; + } + startPlayback(fileUrl, () => removeNativeTempAudio(fileUrl)); + }); + return; + } + const blobUrl = dataUriToBlobUrl(source); + if (blobUrl) { + startPlayback(blobUrl, () => URL.revokeObjectURL(blobUrl)); + return; + } + // Blob 不可用时退回原始 data: URI(桌面 Chrome 等环境可直接播放) + } + startPlayback(source); }; const toggle = async (key: string, text: string, voiceProfile?: SpeechVoiceProfile, practiceContext?: PracticeTtsContext) => { diff --git a/mobile-uni/tests/speech-audio-source.test.mjs b/mobile-uni/tests/speech-audio-source.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..87c1829d0118476a53b1561ebd68a941dbf069c4 GIT binary patch literal 4138 zcmbtXTW=Fb6y~8%`4xi{X*a698wf?MBPxZPnwDEhqCOBH)lOaN~j~nyEA9boXdB6*T>9 zM{vC(mLkTT*p2YmkP`-=&kJeoa9B&gf<0jOQs5Ai)1X!j7h$2*9wB?)C#@kulsNd+ zbR8+XKwZ%{-wfCSnK^#QG%U;Jv14mNZu5vSr%|R!%?-;uOk#&wf*U2GLSXq!P%DYO zkrFWyQGeGM8Smfbgnk6EsevVeTOy_b$9H9~sjnzW^Uy#k93PE@3$9g2i}=#93LaKx zK5~PawJ-GjF!*q)LRT7{TaDQCNP86%bmrvQDyylGV3fW}M4h|;px3#;l0@~0m5Q^C}e zWmy^x{aD0IBym6r_M|3{4?z@$Y?~O9`^OB@y}3;MXpD5PpI?2t(s}T>d+&U2`CLia zL{^{tL#AdvKC7X4_}JL#{S(s@r)Q2&LUiY9RM3jVjSWi1)uGaGEAJ)V^VYU)MfbGF z?K!Xoi^MeG($0n7UjFpw%lkhNsTaUHS?XT8y7u%AX$TP^c)_5{FYnyViJ=rgRoBJ3 zTF?;yQ>mKX(#~^Ln;wSuXENYM2O9& zA;OqKcDJYuqrfY+%@(O2dCUUb+SG!`qpCXf_*M;beLBzVDA-`v7M11i$8t<_`s>eY z_rA!oiE!#XywSV(Woh-rowXlt5g&?`tUY?#xqIo&lc;#F#vGt=B%(BrYGrUPHzGjm zu+S_h%s#Ref=_z)Zw3T~f82o~$kboGtDF1Dm5IGByI4IWfAp>%9_z|GV)2 z*x1Rz_}RqfB8K`^wtf3{4117x-qzFwp(xWb0<)aNZcaxob054z)-Er1?tj&L_Ivlc z`$z*wLb3!qf3I}DU+&z#+r73jFli2SO(C;!;$+q>>hui5Dh6Vf%&DY~RzGEqfOTNX zD%{d9VkB3s$chr)P`juOKbqwXJW}yP=S+DAG)>%*T_L;m6)ey~k19|*$}Sm!w2`9T znb^w%3StI~FdbqnV5>+C8{woiAjRiw+?cJ@M~?;bqER9{28x^*J!#>LFc6%I$+SY0 z=I&XL-VGXa>79roo!^Qk`regit4|(xZ>;D>aOHCE#Wlz=sY5ph$|2pW54+c%_x@V$ zJpX!N))!(I;13bYCq42KTv8c#L+Hf)1oF^pvO}RmJshy6mbhTP!a+na5aF2u7S?gD zk}*!#LdRAWipeK%%1p1oSC}7AJAf;oSk`rq&U4Tm;OaX4C|}@pDQu~D=I9#b)JIGE zR5%TNfqja)jkqiXFVEKAo<)Klwq;7wLtvx4y^OFgLRyXmft* zoV&Gl`)23Cqu%3Rx)-i@Z`>m?u&O-)VQHR{c7_(1AvC=YatP__C`pFksqLH{_K3cb)WYCM0(*W`5XY>kG_~bz|Fh R0Hn9*LhlKL8m3jR{1;&0Yo`DJ literal 0 HcmV?d00001