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 {
|
||||
// 清理失败不影响播放主流程
|
||||
}
|
||||
};
|
||||
@@ -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.
Reference in New Issue
Block a user