fix(voice): TTS играет через AudioContext (фикс для iPad Safari)
All checks were successful
Deploy / deploy (push) Successful in 1m45s

iOS Safari блокирует <audio>.play() даже после silent-WAV unlock —
каждый new Audio() считается новым элементом без gesture.

Решение: при тапе кнопки в VoiceController создаём общий
AudioContext (под user-gesture) и пробуждаем его. VoiceOverlay
теперь играет TTS через этот ctx (decodeAudioData + BufferSource).
HTMLAudioElement остаётся fallback'ом если ctx недоступен.

decodeAudioData в Safari исторически callback, в Chrome — Promise:
используем оба варианта.
This commit is contained in:
Cosmo
2026-04-27 11:17:41 +00:00
parent 6c3992bb4e
commit 6083597065
2 changed files with 52 additions and 11 deletions

View File

@@ -178,19 +178,21 @@ export default function VoiceController() {
if (state !== 'idle' && state !== 'error') return if (state !== 'idle' && state !== 'error') return
setState('loading') setState('loading')
// 0. «Audio unlock» — Android Chrome не даёт <audio>.play() без user-gesture. // 0. «Audio unlock» — iOS Safari / Android Chrome не дают воспроизводить
// Wake-word срабатывает сам, поэтому позже play() будет тихо отвергнут. // звук без user-gesture. Wake-word срабатывает сам, поэтому позже TTS
// Проигрываем 1мс silent WAV прямо сейчас (user тапнул кнопку → есть gesture). // тихо отвергнется. Создаём общий AudioContext прямо сейчас (тап = gesture)
// и сохраняем в window — VoiceOverlay будет играть через него.
try { try {
const silent = new Audio( const w = window as any
'data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA', if (!w.__voicePlaybackCtx) {
) const Ctx = w.AudioContext || w.webkitAudioContext
silent.volume = 0 if (Ctx) w.__voicePlaybackCtx = new Ctx()
await silent.play() }
silent.pause() const ctx: AudioContext | undefined = w.__voicePlaybackCtx
console.log('[voice] audio unlock ok') if (ctx && ctx.state === 'suspended') await ctx.resume()
console.log('[voice] playback AudioContext state=', ctx?.state)
} catch (e: any) { } catch (e: any) {
console.warn('[voice] audio unlock failed:', e?.message) console.warn('[voice] AudioContext init failed:', e?.message)
} }
// 1. Запрос разрешения на микрофон отдельно // 1. Запрос разрешения на микрофон отдельно

View File

@@ -35,6 +35,7 @@ export default function VoiceOverlay() {
const dismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const dismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null) const audioRef = useRef<HTMLAudioElement | null>(null)
const audioUrlRef = useRef<string | null>(null) const audioUrlRef = useRef<string | null>(null)
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null)
const clearDismiss = () => { const clearDismiss = () => {
if (dismissTimer.current) { if (dismissTimer.current) {
@@ -48,6 +49,11 @@ export default function VoiceOverlay() {
} }
const stopAudio = () => { const stopAudio = () => {
if (audioSourceRef.current) {
try { audioSourceRef.current.stop() } catch {}
try { audioSourceRef.current.disconnect() } catch {}
audioSourceRef.current = null
}
if (audioRef.current) { if (audioRef.current) {
try { try {
audioRef.current.pause() audioRef.current.pause()
@@ -78,6 +84,39 @@ export default function VoiceOverlay() {
return return
} }
const blob = await r.blob() const blob = await r.blob()
// Сначала пытаемся через общий AudioContext (он разблокирован в start()
// VoiceController при тапе пользователя). На iOS Safari это единственный
// надёжный путь; на остальных тоже работает.
const ctx: AudioContext | undefined = (window as any).__voicePlaybackCtx
if (ctx) {
try {
if (ctx.state === 'suspended') await ctx.resume()
const arrayBuf = await blob.arrayBuffer()
const audioBuf: AudioBuffer = await new Promise((resolve, reject) => {
// decodeAudioData в Safari исторически callback-API, поддерживает оба.
try {
const p = ctx.decodeAudioData(arrayBuf, resolve, reject) as any
if (p && typeof p.then === 'function') p.then(resolve, reject)
} catch (e) { reject(e) }
})
const source = ctx.createBufferSource()
source.buffer = audioBuf
source.connect(ctx.destination)
const finish = () => {
if (audioSourceRef.current === source) audioSourceRef.current = null
onEnded?.()
}
source.onended = finish
audioSourceRef.current = source
source.start()
return
} catch (e: any) {
console.warn('[voice] AudioContext playback failed, fallback to <audio>:', e?.message || e)
}
}
// Fallback на HTMLAudioElement (на десктопе обычно тоже работает).
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
audioUrlRef.current = url audioUrlRef.current = url
const audio = new Audio(url) const audio = new Audio(url)