// SOS+TTS Latenz-Benchmark. // // Eine BenchSession pro sendToLyra-Call. Aggregiert Timing-Marker aus // sosStream + sosTtsQueue und druckt am Ende eine Tabelle ins Dev-Console. // // Marker-Reihenfolge im typischen Flow: // t0 request-fired (sendToLyra start) // t1 session-post-done (POST /sos-session resolved → sessionId da) // t2 sse-first-chunk ("Lyra denkt fertig" — erstes Token) // t3 sse-done (full text fertig) // t4 tts-fetch-start (POST an /api/coach/speak-* fired) // t5 tts-fetch-headers (response headers da, body kommt noch) // t6 tts-body-done (kompletter Audio-Body geladen — DAS ist der Bottleneck) // t7 tts-file-written (base64 → File geschrieben) // t8 audio-loaded (Audio.Sound.createAsync resolved) // t9 first-audio (erstes onPlaybackStatusUpdate mit isPlaying) // // Bottleneck-Diagnose: // - (t6 - t5) groß → Body-Download dominiert. Cartesia's TTFB-Vorteil // verpufft hier weil wir auf alles warten statt zu streamen. // - (t9 - t8) groß → expo-av lädt langsam (file-codec-detect etc.) export type BenchMarker = | 'session-post-start' | 'session-post-done' | 'sse-first-chunk' | 'sse-done' | 'tts-fetch-start' | 'tts-fetch-headers' | 'tts-body-done' | 'tts-file-written' | 'audio-loaded' | 'first-audio'; export type BenchOnMetric = (marker: BenchMarker, meta?: Record) => void; type MarkerEntry = { marker: BenchMarker; /** ms relativ zu t0 */ tRel: number; meta?: Record; }; export class BenchSession { readonly t0: number; readonly provider: string; readonly label: string; private entries: MarkerEntry[] = []; private printed = false; constructor(opts: { provider: string; label?: string }) { this.t0 = Date.now(); this.provider = opts.provider; this.label = opts.label ?? 'sos-turn'; } /** Bound version — kann direkt als onMetric weitergegeben werden. */ readonly mark: BenchOnMetric = (marker, meta) => { if (this.printed) return; this.entries.push({ marker, tRel: Date.now() - this.t0, meta }); }; /** Druckt eine kompakte Tabelle. Idempotent (nur 1x pro Session). */ print(extraNote?: string): void { if (this.printed) return; this.printed = true; const get = (m: BenchMarker) => this.entries.find((e) => e.marker === m)?.tRel; const fmt = (v: number | undefined) => (v == null ? '—' : `${v}ms`); const diff = (a: BenchMarker, b: BenchMarker) => { const va = get(a), vb = get(b); return va != null && vb != null ? `${vb - va}ms` : '—'; }; const stages = { provider: this.provider, label: this.label, 'req→session': fmt(get('session-post-done')), 'lyra-ttfb': fmt(get('sse-first-chunk')), 'lyra-done': fmt(get('sse-done')), 'tts-fired': fmt(get('tts-fetch-start')), 'tts-ttfb (rel)': diff('tts-fetch-start', 'tts-fetch-headers'), 'tts-body (rel)': diff('tts-fetch-headers', 'tts-body-done'), 'tts-file (rel)': diff('tts-body-done', 'tts-file-written'), 'audio-load (rel)': diff('tts-file-written', 'audio-loaded'), 'first-audio': fmt(get('first-audio')), 'TOTAL → speak': fmt(get('first-audio')), }; // Eine kompakte Zeile als console.log (für Logbox-Lesbarkeit) + // console.table mit allen Markern (für strukturierte Inspektion). // eslint-disable-next-line no-console console.log( `[bench] ${this.provider} (${this.label})${extraNote ? ' ' + extraNote : ''}`, stages, ); // eslint-disable-next-line no-console console.table(this.entries.map((e) => ({ marker: e.marker, tRel: e.tRel }))); } /** Snapshot für UI-Overlays (Debug-Drawer etc.). */ snapshot(): { provider: string; label: string; entries: MarkerEntry[] } { return { provider: this.provider, label: this.label, entries: [...this.entries] }; } }