fix(voice): TTS играет через AudioContext (фикс для iPad Safari)
All checks were successful
Deploy / deploy (push) Successful in 1m45s
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:
@@ -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. Запрос разрешения на микрофон отдельно
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user