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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user