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 无法审阅。
This commit is contained in:
@@ -11,10 +11,29 @@ export interface DecodedAudioDataUri {
|
|||||||
bytes: Uint8Array;
|
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;
|
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);
|
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。 */
|
/** 纯解析,不依赖浏览器 API,便于单测。base64 非法或不是音频 data URI 时返回 null。 */
|
||||||
export const decodeDataAudioUri = (dataUri: string): DecodedAudioDataUri | null => {
|
export const decodeDataAudioUri = (dataUri: string): DecodedAudioDataUri | null => {
|
||||||
const match = DATA_AUDIO_URI_PATTERN.exec(dataUri);
|
const match = DATA_AUDIO_URI_PATTERN.exec(dataUri);
|
||||||
@@ -42,13 +61,20 @@ export const dataUriToBlobUrl = (dataUri: string): string | null => {
|
|||||||
interface PlusIoFileEntry {
|
interface PlusIoFileEntry {
|
||||||
fullPath: string;
|
fullPath: string;
|
||||||
createWriter: (ok: (writer: PlusIoWriter) => void, fail?: (err: unknown) => void) => void;
|
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;
|
remove: (ok?: () => void, fail?: (err: unknown) => void) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlusIoWriter {
|
interface PlusIoWriter {
|
||||||
onwriteend: (() => void) | null;
|
onwriteend: (() => void) | null;
|
||||||
onerror: ((err: unknown) => void) | null;
|
onerror: ((err: unknown) => void) | null;
|
||||||
write: (data: Blob) => void;
|
// plus.io 的 FileWriter 只接受字符串:write 写文本,writeAsBinary 收 base64。
|
||||||
|
// 传 Blob 会被原生桥接序列化成空内容,落地 0 字节且照样回调 onwriteend。
|
||||||
|
writeAsBinary: (data: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlusIoFileMeta {
|
||||||
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlusIoDirectoryEntry {
|
interface PlusIoDirectoryEntry {
|
||||||
@@ -77,20 +103,35 @@ const resolvePlusIo = (): PlusIo | null => {
|
|||||||
/** 是否处于需要落盘播放的 App-Plus 原生环境(避免与 speech-capture 循环引用,本地判定)。 */
|
/** 是否处于需要落盘播放的 App-Plus 原生环境(避免与 speech-capture 循环引用,本地判定)。 */
|
||||||
export const hasNativeAudioFileSupport = () => resolvePlusIo() !== null;
|
export const hasNativeAudioFileSupport = () => resolvePlusIo() !== null;
|
||||||
|
|
||||||
/** App-Plus:base64 落 `_doc/` 临时文件,返回可播放的 file:// 路径;失败返回 null。 */
|
/**
|
||||||
export const writeNativeTempAudio = (dataUri: string): Promise<string | null> => {
|
* App-Plus:base64 落 `_doc/` 临时文件,返回可播放的 file:// 路径;失败返回 null。
|
||||||
const decoded = decodeDataAudioUri(dataUri);
|
*
|
||||||
|
* 写入后必须复核 `size > 0`:`onwriteend` 会在实际写了 0 字节时照样回调,
|
||||||
|
* 空文件交给 InnerAudioContext 会静默播完、不触发 onError,表现为"界面正常
|
||||||
|
* 但没有声音"。非空校验是这条链路唯一能暴露写盘失败的地方。
|
||||||
|
*/
|
||||||
|
export const writeNativeTempAudio = (dataUri: string): Promise<NativeTempAudio | null> => {
|
||||||
|
const payload = extractAudioBase64(dataUri);
|
||||||
const io = resolvePlusIo();
|
const io = resolvePlusIo();
|
||||||
if (!decoded || !io) return Promise.resolve(null);
|
if (!payload || !io) return Promise.resolve(null);
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
try {
|
try {
|
||||||
io.resolveLocalFileSystemURL('_doc/', (dir) => {
|
io.resolveLocalFileSystemURL('_doc/', (dir) => {
|
||||||
dir.getFile(`aihr-tts-${Date.now()}.mp3`, { create: true }, (fileEntry) => {
|
dir.getFile(`aihr-tts-${Date.now()}.mp3`, { create: true }, (fileEntry) => {
|
||||||
fileEntry.createWriter((writer) => {
|
fileEntry.createWriter((writer) => {
|
||||||
writer.onwriteend = () => resolve(io.convertLocalFileSystemURL(fileEntry.fullPath));
|
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.onerror = () => resolve(null);
|
||||||
const blobBytes = new Uint8Array(decoded.bytes);
|
writer.writeAsBinary(payload.base64);
|
||||||
writer.write(new Blob([blobBytes], { type: decoded.mime }));
|
|
||||||
}, () => resolve(null));
|
}, () => resolve(null));
|
||||||
}, () => resolve(null));
|
}, () => resolve(null));
|
||||||
}, () => resolve(null));
|
}, () => resolve(null));
|
||||||
@@ -100,13 +141,20 @@ export const writeNativeTempAudio = (dataUri: string): Promise<string | null> =>
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 播放结束后清理 App 端临时文件,尽力而为。 */
|
/**
|
||||||
export const removeNativeTempAudio = (fileUrl: string) => {
|
* 播放结束后清理 App 端临时文件,尽力而为。
|
||||||
|
* 入参必须是 `_doc/xxx.mp3` 形式的 plus 路径,不能传 file:// 绝对路径,否则解析不到。
|
||||||
|
*/
|
||||||
|
export const removeNativeTempAudio = (plusPath: string) => {
|
||||||
const io = resolvePlusIo();
|
const io = resolvePlusIo();
|
||||||
if (!io) return;
|
if (!io || !plusPath) return;
|
||||||
try {
|
try {
|
||||||
io.resolveLocalFileSystemURL(fileUrl, (entry) => entry.remove());
|
io.resolveLocalFileSystemURL(
|
||||||
} catch {
|
plusPath,
|
||||||
// 清理失败不影响播放主流程
|
(entry) => entry.remove(undefined, (err) => console.warn('[tts] 临时音频删除失败', plusPath, err)),
|
||||||
|
(err) => console.warn('[tts] 临时音频路径解析失败', plusPath, err)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[tts] 临时音频清理异常', plusPath, err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -245,16 +245,16 @@ export const createSpeechPlaybackController = (
|
|||||||
// webview 也常静默失败,统一先转换:H5 转 Blob URL,App 端落临时文件。
|
// webview 也常静默失败,统一先转换:H5 转 Blob URL,App 端落临时文件。
|
||||||
if (isDataAudioUri(source)) {
|
if (isDataAudioUri(source)) {
|
||||||
if (hasNativeAudioFileSupport()) {
|
if (hasNativeAudioFileSupport()) {
|
||||||
void writeNativeTempAudio(source).then((fileUrl) => {
|
void writeNativeTempAudio(source).then((temp) => {
|
||||||
if (!fileUrl) {
|
if (!temp) {
|
||||||
failPlayback();
|
failPlayback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isActivePlayback()) {
|
if (!isActivePlayback()) {
|
||||||
removeNativeTempAudio(fileUrl);
|
removeNativeTempAudio(temp.plusPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
startPlayback(fileUrl, () => removeNativeTempAudio(fileUrl));
|
startPlayback(temp.playbackUrl, () => removeNativeTempAudio(temp.plusPath));
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user