Files
prop-ai-hr/mobile-uni/src/services/speech-audio-source.ts
T
admin 94ee29f59c fix(speech): write native TTS temp file as binary and clean it up
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 无法审阅。
2026-07-26 12:10:50 +08:00

161 lines
5.9 KiB
TypeScript

/**
* 语音播放的音源适配。
*
* 后端 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;
}
/**
* 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);
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;
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;
// plus.io 的 FileWriter 只接受字符串:write 写文本,writeAsBinary 收 base64。
// 传 Blob 会被原生桥接序列化成空内容,落地 0 字节且照样回调 onwriteend。
writeAsBinary: (data: string) => void;
}
interface PlusIoFileMeta {
size: number;
}
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。
*
* 写入后必须复核 `size > 0`:`onwriteend` 会在实际写了 0 字节时照样回调,
* 空文件交给 InnerAudioContext 会静默播完、不触发 onError,表现为"界面正常
* 但没有声音"。非空校验是这条链路唯一能暴露写盘失败的地方。
*/
export const writeNativeTempAudio = (dataUri: string): Promise<NativeTempAudio | null> => {
const payload = extractAudioBase64(dataUri);
const io = resolvePlusIo();
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 = () => {
fileEntry.file(
(meta) => resolve(meta && meta.size > 0
? {
playbackUrl: io.convertLocalFileSystemURL(fileEntry.fullPath),
plusPath: fileEntry.fullPath
}
: null),
() => resolve(null)
);
};
writer.onerror = () => resolve(null);
writer.writeAsBinary(payload.base64);
}, () => resolve(null));
}, () => resolve(null));
}, () => resolve(null));
} catch {
resolve(null);
}
});
};
/**
* 播放结束后清理 App 端临时文件,尽力而为。
* 入参必须是 `_doc/xxx.mp3` 形式的 plus 路径,不能传 file:// 绝对路径,否则解析不到。
*/
export const removeNativeTempAudio = (plusPath: string) => {
const io = resolvePlusIo();
if (!io || !plusPath) return;
try {
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);
}
};