Spaces:
Sleeping
Sleeping
VoiceGuard Bot
Optimize: Tighten Heuristic thresholds and disable dithering to fix AI false negatives
eeaad03 | """Audio Processing Module. | |
| Handles all audio-related operations: | |
| - Base64 decoding | |
| - MP3/WAV conversion | |
| - Resampling to 16kHz | |
| - Duration validation | |
| """ | |
| import base64 | |
| import io | |
| import numpy as np | |
| from typing import Tuple | |
| from pydub import AudioSegment | |
| import librosa | |
| from app.config import settings | |
| class AudioProcessingError(Exception): | |
| """Base exception for audio processing errors.""" | |
| pass | |
| class InvalidBase64Error(AudioProcessingError): | |
| """Raised when Base64 decoding fails.""" | |
| pass | |
| class InvalidAudioFormatError(AudioProcessingError): | |
| """Raised when audio format is invalid.""" | |
| pass | |
| class AudioTooShortError(AudioProcessingError): | |
| """Raised when audio is too short.""" | |
| pass | |
| class AudioTooLongError(AudioProcessingError): | |
| """Raised when audio is too long.""" | |
| pass | |
| class AudioProcessor: | |
| """Handles audio processing operations.""" | |
| def __init__(self, target_sr: int = None): | |
| """Initialize audio processor. | |
| Args: | |
| target_sr: Target sample rate (default from settings: 16000) | |
| """ | |
| self.target_sr = target_sr or settings.TARGET_SAMPLE_RATE | |
| def decode_base64(self, b64_string: str) -> bytes: | |
| """Decode Base64 string to bytes. | |
| Args: | |
| b64_string: Base64 encoded audio string | |
| Returns: | |
| Raw audio bytes | |
| Raises: | |
| InvalidBase64Error: If decoding fails | |
| """ | |
| try: | |
| return base64.b64decode(b64_string) | |
| except Exception as e: | |
| raise InvalidBase64Error(f"Failed to decode Base64: {str(e)}") | |
| def load_audio_from_bytes(self, audio_bytes: bytes) -> AudioSegment: | |
| """Load audio from bytes (supports MP3, WAV). | |
| Args: | |
| audio_bytes: Raw audio file bytes | |
| Returns: | |
| AudioSegment object | |
| Raises: | |
| InvalidAudioFormatError: If format is not supported | |
| """ | |
| try: | |
| # Try MP3 first | |
| audio = AudioSegment.from_mp3(io.BytesIO(audio_bytes)) | |
| return audio | |
| except Exception: | |
| pass | |
| try: | |
| # Try WAV | |
| audio = AudioSegment.from_wav(io.BytesIO(audio_bytes)) | |
| return audio | |
| except Exception: | |
| pass | |
| try: | |
| # Try generic format detection (ffmpeg handles M4A, MOV, etc.) | |
| audio = AudioSegment.from_file(io.BytesIO(audio_bytes)) | |
| return audio | |
| except Exception as e: | |
| raise InvalidAudioFormatError( | |
| f"Could not decode audio. Supported formats: MP3, WAV, M4A, AAC. Error: {str(e)}" | |
| ) | |
| def validate_duration(self, audio: AudioSegment) -> float: | |
| """Validate audio duration is within limits. | |
| Args: | |
| audio: AudioSegment object | |
| Returns: | |
| Duration in seconds | |
| Raises: | |
| AudioTooShortError: If duration < MIN_DURATION | |
| AudioTooLongError: If duration > MAX_DURATION | |
| """ | |
| duration = len(audio) / 1000.0 # Convert ms to seconds | |
| if duration < settings.MIN_DURATION: | |
| raise AudioTooShortError( | |
| f"Audio too short: {duration:.2f}s (minimum: {settings.MIN_DURATION}s)" | |
| ) | |
| if duration > settings.MAX_DURATION: | |
| raise AudioTooLongError( | |
| f"Audio too long: {duration:.2f}s (maximum: {settings.MAX_DURATION}s)" | |
| ) | |
| return duration | |
| def convert_to_wav_buffer(self, audio: AudioSegment) -> io.BytesIO: | |
| """Convert AudioSegment to WAV buffer. | |
| Args: | |
| audio: AudioSegment object | |
| Returns: | |
| BytesIO buffer containing WAV data | |
| """ | |
| wav_buffer = io.BytesIO() | |
| audio.export(wav_buffer, format="wav") | |
| wav_buffer.seek(0) | |
| return wav_buffer | |
| def resample_to_numpy(self, wav_buffer: io.BytesIO) -> np.ndarray: | |
| """Load WAV and resample to target sample rate. | |
| Args: | |
| wav_buffer: BytesIO buffer with WAV data | |
| Returns: | |
| Numpy array of audio samples (mono, resampled) | |
| """ | |
| waveform, _ = librosa.load( | |
| wav_buffer, | |
| sr=self.target_sr, | |
| mono=True | |
| ) | |
| # 1. Trim Silence (DISABLED: Removing silence makes real speech sound unnaturally continuous/AI-like) | |
| # waveform, _ = librosa.effects.trim(waveform, top_db=30) | |
| # 2. Normalize Volume (Re-enabled: Necessary for consistent chunked inference) | |
| waveform = librosa.util.normalize(waveform) | |
| # 3. Dithering / Noise Injection (DISABLED) | |
| # Reason: Dithering added noise that helped Human audio pass, but also helped High-Quality AI pass. | |
| # We now rely on the Heuristic Override to catch compressed human audio, so we don't need to fake the noise. | |
| # noise_amp = 0.005 * np.max(np.abs(waveform)) | |
| # waveform = waveform + noise_amp * np.random.normal(size=len(waveform)) | |
| return waveform | |
| def process(self, b64_audio: str) -> Tuple[np.ndarray, float]: | |
| """Full audio processing pipeline (Base64 input). | |
| Decodes Base64 -> Loads audio -> Validates -> Converts -> Resamples | |
| """ | |
| # Step 1: Decode Base64 | |
| audio_bytes = self.decode_base64(b64_audio) | |
| return self.process_bytes(audio_bytes) | |
| def process_bytes(self, audio_bytes: bytes) -> Tuple[np.ndarray, float]: | |
| """Full audio processing pipeline (Bytes input). | |
| Loads audio -> Validates -> Converts -> Resamples | |
| Args: | |
| audio_bytes: Raw audio bytes | |
| Returns: | |
| Tuple of (waveform numpy array, duration in seconds) | |
| """ | |
| # Step 2: Load audio | |
| audio_segment = self.load_audio_from_bytes(audio_bytes) | |
| # Step 3: Validate duration | |
| duration = self.validate_duration(audio_segment) | |
| # Step 4: Convert to WAV | |
| wav_buffer = self.convert_to_wav_buffer(audio_segment) | |
| # Step 5: Resample to numpy array | |
| waveform = self.resample_to_numpy(wav_buffer) | |
| return waveform, duration | |
| # Create singleton instance | |
| audio_processor = AudioProcessor() | |