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
+61 -17
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,26 +203,69 @@ export const createSpeechPlaybackController = (
};
const playAudioSource = (key: string, source: string, cacheKey: string, currentGeneration: number) => {
const context = uni.createInnerAudioContext();
audio = context;
const isCurrent = () => currentGeneration === generation && audio === context && activeKey === key;
context.onPlay(() => {
if (isCurrent()) publish(key, 'playing');
});
context.onEnded(() => {
if (isCurrent()) stop();
});
context.onStop(() => {
if (isCurrent()) publish();
});
context.onError(() => {
if (!isCurrent()) return;
const isActivePlayback = () => currentGeneration === generation && activeKey === key;
const failPlayback = () => {
if (!isActivePlayback()) return;
sourceCache.delete(cacheKey);
stop();
onError('语音播放失败,请稍后重试');
});
context.src = source;
context.play();
};
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()) {
finish();
stop();
}
});
context.onStop(() => {
if (isCurrent()) {
finish();
publish();
}
});
context.onError(() => {
if (!isCurrent()) return;
finish();
failPlayback();
});
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) => {