| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427 |
- # -*- coding: utf-8 -*-
- """
- Voice Activity Detection (VAD) für Silence-Erkennung.
- """
- from dataclasses import dataclass, field
- from datetime import datetime, timedelta
- from enum import Enum
- from typing import Callable
- import numpy as np
- class VADState(Enum):
- """VAD-Zustand."""
- WAITING = "waiting" # Warten auf Sprache
- SPEECH = "speech" # Sprache erkannt
- SILENCE = "silence" # Stille nach Sprache
- TIMEOUT = "timeout" # Timeout erreicht
- NO_SPEECH = "no_speech" # Keine Sprache innerhalb Timeout
- @dataclass
- class VADConfig:
- """Konfiguration für Voice Activity Detection."""
- # Schwellenwerte
- speech_threshold: float = 0.02 # RMS-Threshold für Sprache
- silence_threshold: float = 0.01 # RMS-Threshold für Stille
- # Zeitkonfiguration
- min_speech_ms: int = 200 # Mindestdauer für Sprache
- silence_duration_ms: int = 3000 # Stille-Dauer für Ende (3 Sekunden)
- max_duration_ms: int = 60000 # Maximale Aufnahmedauer (60 Sekunden)
- # Audio-Format
- sample_rate: int = 16000
- frame_length_ms: int = 80
- # No-Speech Timeout
- no_speech_timeout_ms: int = 5000 # 5 Sekunden ohne Sprache → Abbruch
- # Glättung
- smoothing_frames: int = 3 # Frames für Glättung
- @property
- def frame_length_samples(self) -> int:
- """Samples pro Frame."""
- return int(self.sample_rate * self.frame_length_ms / 1000)
- @dataclass
- class SpeechSegment:
- """Ein Sprach-Segment."""
- start_time: datetime
- end_time: datetime | None = None
- peak_level: float = 0.0
- avg_level: float = 0.0
- frame_count: int = 0
- @property
- def duration_ms(self) -> float:
- """Dauer in Millisekunden."""
- if self.end_time is None:
- return (datetime.now() - self.start_time).total_seconds() * 1000
- return (self.end_time - self.start_time).total_seconds() * 1000
- @property
- def is_complete(self) -> bool:
- """Prüft ob Segment abgeschlossen ist."""
- return self.end_time is not None
- class VoiceActivityDetector:
- """
- Voice Activity Detector für Sprach-/Stille-Erkennung.
- Erkennt:
- - Beginn von Sprache (User hat angefangen zu sprechen)
- - Ende von Sprache (Stille nach Sprache)
- - Timeout (maximale Aufnahmedauer erreicht)
- """
- def __init__(self, config: VADConfig | None = None):
- """
- Initialisiert den VAD.
- Args:
- config: VAD-Konfiguration
- """
- self._config = config or VADConfig()
- # State
- self._state = VADState.WAITING
- self._start_time: datetime | None = None
- self._speech_detected = False
- self._speech_start_time: datetime | None = None
- self._last_speech_time: datetime | None = None
- # Level-Tracking
- self._level_history: list[float] = []
- self._peak_level = 0.0
- self._total_level = 0.0
- self._frame_count = 0
- # Segmente
- self._current_segment: SpeechSegment | None = None
- self._segments: list[SpeechSegment] = []
- # Callbacks
- self._on_speech_start: list[Callable[[], None]] = []
- self._on_speech_end: list[Callable[[float], None]] = [] # Dauer in ms
- self._on_silence: list[Callable[[], None]] = []
- self._on_timeout: list[Callable[[], None]] = []
- self._on_no_speech: list[Callable[[], None]] = []
- @property
- def config(self) -> VADConfig:
- """Gibt Konfiguration zurück."""
- return self._config
- @property
- def state(self) -> VADState:
- """Aktueller Zustand."""
- return self._state
- @property
- def is_active(self) -> bool:
- """Prüft ob VAD aktiv ist."""
- return self._start_time is not None
- @property
- def has_speech(self) -> bool:
- """Prüft ob Sprache erkannt wurde."""
- return self._speech_detected
- @property
- def duration_ms(self) -> float:
- """Aktuelle Dauer in Millisekunden."""
- if self._start_time is None:
- return 0.0
- return (datetime.now() - self._start_time).total_seconds() * 1000
- @property
- def silence_duration_ms(self) -> float:
- """Aktuelle Stille-Dauer in Millisekunden."""
- if self._last_speech_time is None:
- return 0.0
- return (datetime.now() - self._last_speech_time).total_seconds() * 1000
- @property
- def segments(self) -> list[SpeechSegment]:
- """Alle erkannten Sprach-Segmente."""
- return list(self._segments)
- def start(self) -> None:
- """Startet die VAD-Analyse."""
- self._start_time = datetime.now()
- self._state = VADState.WAITING
- self._speech_detected = False
- self._speech_start_time = None
- self._last_speech_time = None
- self._level_history.clear()
- self._peak_level = 0.0
- self._total_level = 0.0
- self._frame_count = 0
- self._current_segment = None
- self._segments.clear()
- def stop(self) -> None:
- """Stoppt die VAD-Analyse."""
- # Schließe aktuelles Segment ab
- if self._current_segment and not self._current_segment.is_complete:
- self._current_segment.end_time = datetime.now()
- self._segments.append(self._current_segment)
- self._current_segment = None
- self._start_time = None
- def process_frame(self, audio_frame: bytes | np.ndarray) -> VADState:
- """
- Verarbeitet einen Audio-Frame.
- Args:
- audio_frame: Audio-Daten (16-bit PCM oder numpy array)
- Returns:
- Aktueller VAD-Zustand
- """
- if self._start_time is None:
- self.start()
- # Konvertiere zu numpy
- if isinstance(audio_frame, bytes):
- audio = np.frombuffer(audio_frame, dtype=np.int16)
- else:
- audio = audio_frame
- # Berechne RMS-Level
- level = self._calculate_rms(audio)
- self._update_level_tracking(level)
- # Prüfe Timeout
- if self.duration_ms >= self._config.max_duration_ms:
- self._state = VADState.TIMEOUT
- self._trigger_timeout()
- return self._state
- # Geglättetes Level für Entscheidungen
- smoothed_level = self._get_smoothed_level()
- # State-Machine
- if self._state == VADState.WAITING:
- self._handle_waiting_state(smoothed_level)
- elif self._state == VADState.SPEECH:
- self._handle_speech_state(smoothed_level)
- elif self._state == VADState.SILENCE:
- self._handle_silence_state(smoothed_level)
- return self._state
- def _calculate_rms(self, audio: np.ndarray) -> float:
- """Berechnet RMS-Level."""
- if len(audio) == 0:
- return 0.0
- audio_float = audio.astype(np.float32) / 32768.0
- rms = np.sqrt(np.mean(audio_float ** 2))
- return float(rms)
- def _update_level_tracking(self, level: float) -> None:
- """Aktualisiert Level-Tracking."""
- self._level_history.append(level)
- # Begrenze History
- max_history = self._config.smoothing_frames * 3
- while len(self._level_history) > max_history:
- self._level_history.pop(0)
- # Statistiken
- self._peak_level = max(self._peak_level, level)
- self._total_level += level
- self._frame_count += 1
- def _get_smoothed_level(self) -> float:
- """Gibt geglättetes Level zurück."""
- if len(self._level_history) < self._config.smoothing_frames:
- return sum(self._level_history) / len(self._level_history) if self._level_history else 0.0
- # Durchschnitt der letzten N Frames
- recent = self._level_history[-self._config.smoothing_frames:]
- return sum(recent) / len(recent)
- def _handle_waiting_state(self, level: float) -> None:
- """Behandelt WAITING-Zustand."""
- if level >= self._config.speech_threshold:
- # Sprache beginnt
- self._speech_start_time = datetime.now()
- self._last_speech_time = datetime.now()
- self._state = VADState.SPEECH
- # Starte neues Segment
- self._current_segment = SpeechSegment(
- start_time=datetime.now(),
- peak_level=level,
- avg_level=level,
- frame_count=1,
- )
- else:
- # No-Speech Timeout prüfen
- if (self._config.no_speech_timeout_ms > 0
- and self._start_time
- and self.duration_ms >= self._config.no_speech_timeout_ms):
- self._state = VADState.NO_SPEECH
- self._trigger_no_speech()
- def _handle_speech_state(self, level: float) -> None:
- """Behandelt SPEECH-Zustand."""
- if level >= self._config.silence_threshold:
- # Immer noch Sprache
- self._last_speech_time = datetime.now()
- # Update Segment
- if self._current_segment:
- self._current_segment.peak_level = max(self._current_segment.peak_level, level)
- self._current_segment.frame_count += 1
- self._current_segment.avg_level = (
- (self._current_segment.avg_level * (self._current_segment.frame_count - 1) + level)
- / self._current_segment.frame_count
- )
- # Prüfe ob Mindestdauer erreicht
- if self._speech_start_time:
- speech_duration = (datetime.now() - self._speech_start_time).total_seconds() * 1000
- if speech_duration >= self._config.min_speech_ms and not self._speech_detected:
- self._speech_detected = True
- self._trigger_speech_start()
- else:
- # Beginn von Stille
- self._state = VADState.SILENCE
- def _handle_silence_state(self, level: float) -> None:
- """Behandelt SILENCE-Zustand."""
- if level >= self._config.speech_threshold:
- # Sprache wieder da
- self._last_speech_time = datetime.now()
- self._state = VADState.SPEECH
- # Update Segment
- if self._current_segment:
- self._current_segment.peak_level = max(self._current_segment.peak_level, level)
- self._current_segment.frame_count += 1
- else:
- # Prüfe Stille-Dauer (nur wenn vorher Sprache war)
- if self._speech_detected and self._last_speech_time:
- silence_ms = (datetime.now() - self._last_speech_time).total_seconds() * 1000
- if silence_ms >= self._config.silence_duration_ms:
- # Stille lange genug - Ende
- self._complete_segment()
- self._trigger_silence()
- def _complete_segment(self) -> None:
- """Schließt aktuelles Segment ab."""
- if self._current_segment:
- self._current_segment.end_time = datetime.now()
- self._segments.append(self._current_segment)
- self._current_segment = None
- def _trigger_speech_start(self) -> None:
- """Triggert Speech-Start Callbacks."""
- for callback in self._on_speech_start:
- try:
- callback()
- except Exception:
- pass
- def _trigger_silence(self) -> None:
- """Triggert Silence Callbacks."""
- speech_duration = 0.0
- if self._speech_start_time and self._last_speech_time:
- speech_duration = (self._last_speech_time - self._speech_start_time).total_seconds() * 1000
- for callback in self._on_speech_end:
- try:
- callback(speech_duration)
- except Exception:
- pass
- for callback in self._on_silence:
- try:
- callback()
- except Exception:
- pass
- def _trigger_no_speech(self) -> None:
- """Triggert No-Speech Callbacks."""
- for callback in self._on_no_speech:
- try:
- callback()
- except Exception:
- pass
- def _trigger_timeout(self) -> None:
- """Triggert Timeout Callbacks."""
- self._complete_segment()
- for callback in self._on_timeout:
- try:
- callback()
- except Exception:
- pass
- def on_speech_start(self, callback: Callable[[], None]) -> None:
- """Registriert Callback für Sprach-Beginn."""
- self._on_speech_start.append(callback)
- def on_speech_end(self, callback: Callable[[float], None]) -> None:
- """Registriert Callback für Sprach-Ende (mit Dauer in ms)."""
- self._on_speech_end.append(callback)
- def on_silence(self, callback: Callable[[], None]) -> None:
- """Registriert Callback für Stille-Erkennung."""
- self._on_silence.append(callback)
- def on_timeout(self, callback: Callable[[], None]) -> None:
- """Registriert Callback für Timeout."""
- self._on_timeout.append(callback)
- def on_no_speech(self, callback: Callable[[], None]) -> None:
- """Registriert Callback für No-Speech-Timeout."""
- self._on_no_speech.append(callback)
- def get_stats(self) -> dict:
- """Gibt Statistiken zurück."""
- avg_level = self._total_level / self._frame_count if self._frame_count > 0 else 0.0
- return {
- "state": self._state.value,
- "is_active": self.is_active,
- "has_speech": self._speech_detected,
- "duration_ms": self.duration_ms,
- "silence_duration_ms": self.silence_duration_ms,
- "peak_level": self._peak_level,
- "avg_level": avg_level,
- "frame_count": self._frame_count,
- "segment_count": len(self._segments),
- }
- def reset(self) -> None:
- """Setzt VAD zurück."""
- self._state = VADState.WAITING
- self._start_time = None
- self._speech_detected = False
- self._speech_start_time = None
- self._last_speech_time = None
- self._level_history.clear()
- self._peak_level = 0.0
- self._total_level = 0.0
- self._frame_count = 0
- self._current_segment = None
- self._segments.clear()
|