import { Audio } from 'expo-av'; import * as FileSystem from 'expo-file-system/legacy'; import Constants from 'expo-constants'; import { supabase } from './supabase'; /** * One-shot Lyra-TTS für Onboarding/Bubble-Playback. * * Vereinfachte Version des SosTtsQueue für Single-Sentence-Playback: * - Kein Pre-Fetch, keine Queue, kein Streaming * - Bei neuem play(): vorheriger Sound + Fetch wird abgebrochen * - status-Callback für UI-Feedback (idle/loading/playing) * * Genutzt für Lyra-Bubble Accessibility (DiGA-relevant): User tappt Audio- * Button neben der Bubble → Text wird per /api/coach/speak gesprochen. * * Endpoint /api/coach/speak ist der Default-Provider — Backend pickt selbst * (Cartesia/ElevenLabs/Deepgram je nach Plan + Voice-Picker-Setting). */ const apiUrl = Constants.expoConfig?.extra?.apiUrl as string; let currentSound: Audio.Sound | null = null; let currentAbort: AbortController | null = null; export type LyraSpeechStatus = 'idle' | 'loading' | 'playing'; export async function stopLyraSpeech(): Promise { if (currentAbort) { currentAbort.abort(); currentAbort = null; } if (currentSound) { try { await currentSound.unloadAsync(); } catch { // ignore — sound might already be released } currentSound = null; } } export async function playLyraSpeech( text: string, locale: string, onStatus?: (status: LyraSpeechStatus) => void, ): Promise { await stopLyraSpeech(); onStatus?.('loading'); const controller = new AbortController(); currentAbort = controller; try { const session = (await supabase.auth.getSession()).data.session; if (!session?.access_token) { throw new Error('not_authenticated'); } const res = await fetch(`${apiUrl}/api/coach/speak`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.access_token}`, }, body: JSON.stringify({ text, locale, mode: 'coach' }), signal: controller.signal, }); if (!res.ok || controller.signal.aborted) { onStatus?.('idle'); return; } // Default-Provider liefert audio/mpeg als raw bytes const buffer = await res.arrayBuffer(); if (controller.signal.aborted || buffer.byteLength === 0) { onStatus?.('idle'); return; } // Bytes → Base64 (chunked damit String.fromCharCode nicht overflowt) const bytes = new Uint8Array(buffer); const chunks: string[] = []; const cs = 0x8000; for (let i = 0; i < bytes.length; i += cs) { chunks.push( String.fromCharCode(...bytes.subarray(i, Math.min(i + cs, bytes.length))), ); } const base64 = btoa(chunks.join('')); const tmpPath = `${FileSystem.cacheDirectory}lyra-speech-${Date.now()}.mp3`; await FileSystem.writeAsStringAsync(tmpPath, base64, { encoding: FileSystem.EncodingType.Base64, }); if (controller.signal.aborted) { onStatus?.('idle'); return; } const { sound } = await Audio.Sound.createAsync({ uri: tmpPath }); currentSound = sound; sound.setOnPlaybackStatusUpdate((status) => { if (!status.isLoaded) return; if (status.didJustFinish) { onStatus?.('idle'); stopLyraSpeech().catch(() => {}); } }); await sound.playAsync(); onStatus?.('playing'); } catch (e) { if ((e as Error).name === 'AbortError') return; console.warn('[lyraSpeech] failed:', e); onStatus?.('idle'); } }