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:
2026-07-25 20:37:30 +08:00
parent c2358686ce
commit ab3af277c1
3 changed files with 173 additions and 17 deletions
@@ -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 {
// 清理失败不影响播放主流程
}
};
+50 -6
View File
@@ -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,28 +203,71 @@ export const createSpeechPlaybackController = (
};
const playAudioSource = (key: string, source: string, cacheKey: string, currentGeneration: number) => {
const isActivePlayback = () => currentGeneration === generation && activeKey === key;
const failPlayback = () => {
if (!isActivePlayback()) return;
sourceCache.delete(cacheKey);
stop();
onError('语音播放失败,请稍后重试');
};
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()) stop();
if (isCurrent()) {
finish();
stop();
}
});
context.onStop(() => {
if (isCurrent()) publish();
if (isCurrent()) {
finish();
publish();
}
});
context.onError(() => {
if (!isCurrent()) return;
sourceCache.delete(cacheKey);
stop();
onError('语音播放失败,请稍后重试');
finish();
failPlayback();
});
context.src = source;
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) => {
if (activeKey === key) {
stop();
Binary file not shown.