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 00000000..87c1829d Binary files /dev/null and b/mobile-uni/tests/speech-audio-source.test.mjs differ