258 lines
10 KiB
TypeScript
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;
|