fix(speech): convert base64 TTS data URI before playback per platform
后端 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 单测全绿。
This commit is contained in:
@@ -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<string | null> => {
|
||||
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 {
|
||||
// 清理失败不影响播放主流程
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user