"""Audio conversion service""" from io import BytesIO import struct import numpy as np import scipy.io.wavfile as wavfile import soundfile as sf from loguru import logger from pydub import AudioSegment from ..core.config import settings class AudioNormalizer: """Handles audio normalization state for a single stream""" def __init__(self): self.int16_max = np.iinfo(np.int16).max self.chunk_trim_ms = settings.gap_trim_ms self.sample_rate = 24000 # Sample rate of the audio self.samples_to_trim = int(self.chunk_trim_ms * self.sample_rate / 1000) async def normalize(self, audio_data: np.ndarray) -> np.ndarray: """Convert audio data to int16 range and trim silence from start and end Args: audio_data: Input audio data as numpy array Returns: Normalized and trimmed audio data """ if len(audio_data) == 0: raise ValueError("Audio data cannot be empty") # Convert to float32 for processing audio_float = audio_data.astype(np.float32) # Trim start and end if enough samples if len(audio_float) > (2 * self.samples_to_trim): audio_float = audio_float[self.samples_to_trim:-self.samples_to_trim] # Scale to int16 range return (audio_float * 32767).astype(np.int16) class AudioService: """Service for audio format conversions""" # Default audio format settings balanced for speed and compression DEFAULT_SETTINGS = { "mp3": { "bitrate_mode": "CONSTANT", # Faster than variable bitrate "compression_level": 0.0, # Balanced compression }, "opus": { "compression_level": 0.0, # Good balance for speech }, "flac": { "compression_level": 0.0, # Light compression, still fast }, "aac": { "bitrate": "192k", # Default AAC bitrate }, } @staticmethod async def convert_audio( audio_data: np.ndarray, sample_rate: int, output_format: str, is_first_chunk: bool = True, is_last_chunk: bool = False, normalizer: AudioNormalizer = None, format_settings: dict = None, stream: bool = True, ) -> bytes: """Convert audio data to specified format Args: audio_data: Numpy array of audio samples sample_rate: Sample rate of the audio output_format: Target format (wav, mp3, opus, flac, pcm) is_first_chunk: Whether this is the first chunk of a stream normalizer: Optional AudioNormalizer instance for consistent normalization across chunks format_settings: Optional dict of format-specific settings to override defaults Example: { "mp3": { "bitrate_mode": "VARIABLE", "compression_level": 0.8 } } Default settings balance speed and compression: optimized for localhost @ 0.0 - MP3: constant bitrate, no compression (0.0) - OPUS: no compression (0.0) - FLAC: no compression (0.0) Returns: Bytes of the converted audio """ buffer = BytesIO() try: # Always normalize audio to ensure proper amplitude scaling if normalizer is None: normalizer = AudioNormalizer() normalized_audio = await normalizer.normalize(audio_data) if output_format == "pcm": # Raw 16-bit PCM samples, no header buffer.write(normalized_audio.tobytes()) elif output_format == "wav": # Write the WAV header ourselves so that we can specify a "fake" data size. # This is necessary for streaming responses to work properly: if we simply # concatenated individual WAV files then the initial chunk's header length # would be shorter than the full file length and subsequent chunks' RIFF # headers would appear in the middle of the audio data. if is_first_chunk: # Modified from Python stdlib's wave.py module: buffer.write(b'RIFF') buffer.write(struct.pack('