ui-nyla/src/pages/admin/SttBenchmarkPage.tsx
2026-05-16 22:55:48 +02:00

258 lines
10 KiB
TypeScript

/**
* SttBenchmarkPage — Compare STT v1 (latest_long) vs v2 (Chirp 2).
* SysAdmin only. Upload audio, run both engines, compare results.
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { FaMicrophone, FaUpload, FaPlay, FaStop, FaSpinner } from 'react-icons/fa';
import { useLanguage } from '../../providers/language/LanguageContext';
import { useApiRequest } from '../../hooks/useApi';
import styles from '../admin/Admin.module.css';
interface ModelOption { value: string; label: string }
interface BenchmarkResult {
api: string;
model: string;
latencyMs: number;
results: { transcript: string; confidence: number; words: number }[];
resultCount: number;
location?: string;
error?: string;
}
interface BenchmarkResponse {
filename: string;
fileSizeBytes: number;
language: string;
v1: BenchmarkResult | { error: string };
v2: BenchmarkResult | { error: string };
}
interface ModelsResponse {
v1Models: ModelOption[];
v2Models: ModelOption[];
locations: ModelOption[];
languages: ModelOption[];
}
export const SttBenchmarkPage: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const [models, setModels] = useState<ModelsResponse | null>(null);
const [language, setLanguage] = useState('de-DE');
const [v1Model, setV1Model] = useState('latest_long');
const [v2Model, setV2Model] = useState('chirp_2');
const [v2Location, setV2Location] = useState('europe-west4');
const [running, setRunning] = useState(false);
const [result, setResult] = useState<BenchmarkResponse | null>(null);
const [recording, setRecording] = useState(false);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
request({ url: '/api/admin/stt-benchmark/models', method: 'get' })
.then((data: any) => setModels(data))
.catch(() => {});
}, []);
const _startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
chunksRef.current = [];
recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
recorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
setAudioBlob(blob);
setAudioUrl(URL.createObjectURL(blob));
stream.getTracks().forEach(t => t.stop());
};
mediaRecorderRef.current = recorder;
recorder.start();
setRecording(true);
} catch (err) {
console.error('Microphone access denied', err);
}
}, []);
const _stopRecording = useCallback(() => {
mediaRecorderRef.current?.stop();
setRecording(false);
}, []);
const _handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setAudioBlob(file);
setAudioUrl(URL.createObjectURL(file));
}, []);
const _runBenchmark = useCallback(async () => {
if (!audioBlob) return;
setRunning(true);
setResult(null);
try {
const formData = new FormData();
const filename = audioBlob instanceof File ? audioBlob.name : 'recording.webm';
formData.append('file', audioBlob, filename);
formData.append('language', language);
formData.append('v1Model', v1Model);
formData.append('v2Model', v2Model);
formData.append('v2Location', v2Location);
const resp = await fetch('/api/admin/stt-benchmark/run', {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data: BenchmarkResponse = await resp.json();
setResult(data);
} catch (err: any) {
console.error('Benchmark failed:', err);
} finally {
setRunning(false);
}
}, [audioBlob, language, v1Model, v2Model, v2Location]);
const _renderResult = (label: string, r: BenchmarkResult | { error: string }) => {
if ('error' in r && r.error) {
return (
<div style={{ flex: 1, padding: 16, border: '1px solid #e74c3c', borderRadius: 8, background: '#fdf2f2' }}>
<h3>{label}</h3>
<p style={{ color: '#e74c3c' }}>{r.error}</p>
</div>
);
}
const res = r as BenchmarkResult;
const topTranscript = res.results?.[0]?.transcript || '(no result)';
const topConfidence = res.results?.[0]?.confidence ?? 0;
return (
<div style={{ flex: 1, padding: 16, border: '1px solid #ddd', borderRadius: 8, background: '#fafafa' }}>
<h3 style={{ margin: '0 0 8px' }}>{label}</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 12 }}>
<div><strong>{t('Modell')}:</strong> {res.model}</div>
<div><strong>{t('Latenz')}:</strong> {res.latencyMs} ms</div>
<div><strong>{t('Konfidenz')}:</strong> {(topConfidence * 100).toFixed(1)}%</div>
<div><strong>{t('Alternativen')}:</strong> {res.results?.length || 0}</div>
{res.location && <div><strong>{t('Region')}:</strong> {res.location}</div>}
</div>
<div style={{ background: '#fff', padding: 12, borderRadius: 6, border: '1px solid #eee', fontSize: 15 }}>
{topTranscript}
</div>
{res.results?.length > 1 && (
<details style={{ marginTop: 8 }}>
<summary style={{ cursor: 'pointer', fontSize: 13, color: '#666' }}>{t('Weitere Alternativen')}</summary>
{res.results.slice(1).map((alt, i) => (
<div key={i} style={{ marginTop: 4, fontSize: 13, color: '#888' }}>
[{(alt.confidence * 100).toFixed(1)}%] {alt.transcript}
</div>
))}
</details>
)}
</div>
);
};
return (
<div className={styles.adminPage}>
<div className={styles.adminHeader}>
<h1><FaMicrophone style={{ marginRight: 8 }} /> {t('STT Benchmark')}</h1>
<p style={{ color: '#666', margin: '4px 0 0' }}>
{t('Vergleiche Speech-to-Text v1 (latest_long) mit v2 (Chirp 2). Lade eine Audio-Datei hoch oder nimm direkt auf.')}
</p>
</div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', margin: '20px 0' }}>
<label>
<strong>{t('Sprache')}:</strong>
<select value={language} onChange={e => setLanguage(e.target.value)} style={{ marginLeft: 8 }}>
{(models?.languages || [{ value: 'de-DE', label: 'Deutsch' }]).map(l => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</select>
</label>
<label>
<strong>v1 {t('Modell')}:</strong>
<select value={v1Model} onChange={e => setV1Model(e.target.value)} style={{ marginLeft: 8 }}>
{(models?.v1Models || [{ value: 'latest_long', label: 'latest_long' }]).map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</label>
<label>
<strong>v2 {t('Modell')}:</strong>
<select value={v2Model} onChange={e => setV2Model(e.target.value)} style={{ marginLeft: 8 }}>
{(models?.v2Models || [{ value: 'chirp_2', label: 'Chirp 2' }]).map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</label>
<label>
<strong>{t('Region')} (v2):</strong>
<select value={v2Location} onChange={e => setV2Location(e.target.value)} style={{ marginLeft: 8 }}>
{(models?.locations || [{ value: 'europe-west4', label: 'Europe West' }]).map(l => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</select>
</label>
</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', margin: '16px 0' }}>
{!recording ? (
<button onClick={_startRecording} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', cursor: 'pointer', background: '#e74c3c', color: '#fff', border: 'none', borderRadius: 6 }}>
<FaMicrophone /> {t('Aufnehmen')}
</button>
) : (
<button onClick={_stopRecording} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', cursor: 'pointer', background: '#333', color: '#fff', border: 'none', borderRadius: 6, animation: 'pulse 1s infinite' }}>
<FaStop /> {t('Stoppen')}
</button>
)}
<button onClick={() => fileInputRef.current?.click()} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', cursor: 'pointer', background: '#3498db', color: '#fff', border: 'none', borderRadius: 6 }}>
<FaUpload /> {t('Datei hochladen')}
</button>
<input ref={fileInputRef} type="file" accept="audio/*" onChange={_handleFileSelect} style={{ display: 'none' }} />
{audioBlob && (
<>
<span style={{ color: '#666', fontSize: 13 }}>
{audioBlob instanceof File ? audioBlob.name : 'recording.webm'} ({(audioBlob.size / 1024).toFixed(0)} KB)
</span>
{audioUrl && <audio src={audioUrl} controls style={{ height: 32 }} />}
</>
)}
</div>
<button
onClick={_runBenchmark}
disabled={!audioBlob || running}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '10px 24px', cursor: audioBlob && !running ? 'pointer' : 'not-allowed',
background: audioBlob && !running ? '#27ae60' : '#bdc3c7', color: '#fff', border: 'none', borderRadius: 6, fontSize: 15, fontWeight: 600,
}}
>
{running ? <FaSpinner className="fa-spin" /> : <FaPlay />}
{running ? t('Benchmark laeuft...') : t('Benchmark starten')}
</button>
{result && (
<div style={{ marginTop: 24 }}>
<h2>{t('Ergebnis')}</h2>
<p style={{ fontSize: 13, color: '#888' }}>
{result.filename} ({(result.fileSizeBytes / 1024).toFixed(0)} KB) {result.language}
</p>
<div style={{ display: 'flex', gap: 16, marginTop: 12 }}>
{_renderResult(`v1 — ${(result.v1 as any).model || v1Model}`, result.v1)}
{_renderResult(`v2 — ${(result.v2 as any).model || v2Model}`, result.v2)}
</div>
</div>
)}
</div>
);
};
export default SttBenchmarkPage;