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 无法审阅。
161 lines
5.9 KiB
TypeScript
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);
|
|
}
|
|
};
|