feat(speaker-id): Phase 2 — Enrollment-UI (App) + Voice-ID-Section (Diagnostic)
App-Seite: - VoiceIdEnrollment.tsx (neue Komponente, ~370 Zeilen): Status-Karte (loading/unenrolled/enrolled/error), Sample-Recorder mit Countdown (4s fest pro Sample), Liste mit einzelnem Loeschen, Save-Button (disabled bis 5 Samples), Fingerprint-Delete mit Confirm. - SettingsScreen.tsx: neue Section 🎤 'Stimme einrichten' zwischen Wake-Word und Sprachausgabe. - Sample-Format: WAV via audioService.startRecording — wird whisper-bridge-seitig per wave-Modul gestrippt. Diagnostic-Seite: - Neue settings-section 'Voice-ID (Sprecher-Erkennung)': Status-Anzeige (live ueber voice_id_status_response), Threshold-Slider 0.30-0.70 (persistiert in voice_config.json, broadcast als config-Message), Refresh + Delete-Button. - server.js: 2 neue actions (voice_id_status, voice_id_delete), send_voice_config nimmt voiceIdThreshold mit auf. Backend: - speaker_id.py: _normalize_audio_bytes erkennt jetzt WAV-Header (RIFF/WAVE) und strippt auf rohes PCM — sonst werfen die ECAPA- Embeddings auf den 44-Byte-Header rein. - bridge.py: config-Broadcast-Handler setzt voiceIdThreshold auf speaker_id.DEFAULT_THRESHOLD (wird erst in Phase 3 beim Gating genutzt, persistiert aber schon). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -61,10 +61,35 @@ def _ensure_loaded():
|
||||
return _model
|
||||
|
||||
|
||||
def _normalize_audio_bytes(audio_bytes: bytes) -> bytes:
|
||||
"""Akzeptiert entweder rohes 16kHz int16 LE PCM ODER eine WAV-Datei (RIFF/WAVE).
|
||||
Bei WAV wird der Header gestrippt + Format validiert (16kHz / mono / int16).
|
||||
Ergebnis: rohes PCM."""
|
||||
if (len(audio_bytes) >= 44
|
||||
and audio_bytes[:4] == b"RIFF"
|
||||
and audio_bytes[8:12] == b"WAVE"):
|
||||
import io
|
||||
import wave
|
||||
with wave.open(io.BytesIO(audio_bytes), "rb") as wav:
|
||||
sr = wav.getframerate()
|
||||
ch = wav.getnchannels()
|
||||
sw = wav.getsampwidth()
|
||||
if sr != 16000:
|
||||
raise ValueError(f"WAV-Samplerate {sr} != 16000")
|
||||
if ch != 1:
|
||||
raise ValueError(f"WAV-Kanalzahl {ch} != 1 (mono erwartet)")
|
||||
if sw != 2:
|
||||
raise ValueError(f"WAV-Sampleweite {sw} != 2 (int16 erwartet)")
|
||||
return wav.readframes(wav.getnframes())
|
||||
return audio_bytes
|
||||
|
||||
|
||||
def _audio_bytes_to_tensor(audio_bytes: bytes):
|
||||
"""int16 LE PCM (16kHz mono) → Torch-Tensor (1, N), normalisiert auf [-1, 1]."""
|
||||
"""int16 LE PCM (16kHz mono) → Torch-Tensor (1, N), normalisiert auf [-1, 1].
|
||||
WAV wird vorher auf rohes PCM reduziert (Header strippen)."""
|
||||
import torch
|
||||
arr = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
raw = _normalize_audio_bytes(audio_bytes)
|
||||
arr = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
return torch.from_numpy(arr).unsqueeze(0)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user