fix: use AudioContext for voice previews to bypass browser autoplay policy

new Audio().play() after an async await loses the user gesture context and
is silently blocked. Creating AudioContext synchronously before the fetch
preserves the permission, then decode/play through it after the await.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-31 12:55:07 -04:00
parent 71ca0ecb5c
commit c31cf11767
+14 -9
View File
@@ -524,7 +524,6 @@ const voiceBlend = ref<VoiceBlendEntry[]>([
]); ]);
const blendPreviewText = ref("Hello! I'm your assistant. How can I help you today?"); const blendPreviewText = ref("Hello! I'm your assistant. How can I help you today?");
const blendPreviewing = ref(false); const blendPreviewing = ref(false);
let _blendAudioUrl = "";
function addBlendSlot() { function addBlendSlot() {
voiceBlend.value.push({ voice: "af_heart", weight: 1.0 }); voiceBlend.value.push({ voice: "af_heart", weight: 1.0 });
@@ -537,6 +536,8 @@ function removeBlendSlot(idx: number) {
async function previewBlend() { async function previewBlend() {
if (blendPreviewing.value || !blendPreviewText.value.trim()) return; if (blendPreviewing.value || !blendPreviewText.value.trim()) return;
blendPreviewing.value = true; blendPreviewing.value = true;
// Create AudioContext synchronously in user-gesture context to bypass autoplay policy
const ctx = new AudioContext();
try { try {
const blob = await synthesiseSpeech( const blob = await synthesiseSpeech(
blendPreviewText.value, blendPreviewText.value,
@@ -544,10 +545,11 @@ async function previewBlend() {
voiceTtsSpeed.value, voiceTtsSpeed.value,
voiceBlend.value, voiceBlend.value,
); );
if (_blendAudioUrl) URL.revokeObjectURL(_blendAudioUrl); const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
_blendAudioUrl = URL.createObjectURL(blob); const src = ctx.createBufferSource();
const audio = new Audio(_blendAudioUrl); src.buffer = buf;
audio.play(); src.connect(ctx.destination);
src.start(0);
} catch { } catch {
toastStore.show("Preview failed — TTS may not be ready", "error"); toastStore.show("Preview failed — TTS may not be ready", "error");
} finally { } finally {
@@ -572,20 +574,23 @@ async function loadVoiceTab() {
} }
const voicePreviewing = ref(false); const voicePreviewing = ref(false);
let _voicePreviewUrl = "";
async function previewVoice() { async function previewVoice() {
if (voicePreviewing.value) return; if (voicePreviewing.value) return;
voicePreviewing.value = true; voicePreviewing.value = true;
// Create AudioContext synchronously in user-gesture context to bypass autoplay policy
const ctx = new AudioContext();
try { try {
const blob = await synthesiseSpeech( const blob = await synthesiseSpeech(
"Hello! I'm your assistant. How can I help you today?", "Hello! I'm your assistant. How can I help you today?",
voiceTtsVoice.value, voiceTtsVoice.value,
voiceTtsSpeed.value, voiceTtsSpeed.value,
); );
if (_voicePreviewUrl) URL.revokeObjectURL(_voicePreviewUrl); const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
_voicePreviewUrl = URL.createObjectURL(blob); const src = ctx.createBufferSource();
new Audio(_voicePreviewUrl).play(); src.buffer = buf;
src.connect(ctx.destination);
src.start(0);
} catch { } catch {
toastStore.show("Preview failed — TTS may not be ready", "error"); toastStore.show("Preview failed — TTS may not be ready", "error");
} finally { } finally {