""" audio_mastering.py - Mastering Chain y QA T078-T090: Devices, Loudness, QA Suite """ import logging from typing import Dict, Any, List, Optional, Tuple from dataclasses import dataclass logger = logging.getLogger("AudioMastering") @dataclass class LUFSMeter: """Medición de loudness integrado""" integrated: float # LUFS integrado short_term: float # LUFS short-term (3s) momentary: float # LUFS momentary (400ms) true_peak: float # dBTP class MasterChain: """T078-T082: Mastering chain con devices""" def __init__(self): self.devices = [] self._setup_default_chain() def _setup_default_chain(self): """Configura cadena por defecto: Utility → Saturator → Compressor → Limiter""" self.devices = [ { 'type': 'Utility', 'params': {'Gain': 0.0, 'Bass Mono': True, 'Width': 1.0}, 'position': 0 }, { 'type': 'Saturator', 'params': {'Drive': 1.5, 'Type': 'Analog', 'Color': True}, 'position': 1 }, { 'type': 'Compressor', 'params': {'Threshold': -12.0, 'Ratio': 2.0, 'Attack': 10.0, 'Release': 100.0}, 'position': 2 }, { 'type': 'Limiter', 'params': {'Ceiling': -0.3, 'Auto-Release': True}, 'position': 3 } ] def get_ableton_device_chain(self) -> List[Dict]: """Retorna chain en formato compatible con Ableton Live.""" return sorted(self.devices, key=lambda x: x['position']) def set_limiter_ceiling(self, ceiling_db: float): """Ajusta ceiling del limiter (T082).""" for device in self.devices: if device['type'] == 'Limiter': device['params']['Ceiling'] = ceiling_db class LoudnessAnalyzer: """T083-T086: Análisis de loudness""" TARGETS = { 'streaming': -14.0, # Spotify, Apple Music 'club': -8.0, # Club/DJ 'master': -10.0, # Broadcast } def __init__(self): self.peak_threshold = -1.0 # dBTP def analyze_loudness(self, audio_data: Any) -> LUFSMeter: """ T084-T085: Analiza loudness de audio. Retorna medidas LUFS y true peak. """ # Simulación - en implementación real usaría pyloudnorm o similar return LUFSMeter( integrated=-12.0, short_term=-10.0, momentary=-8.0, true_peak=-0.5 ) def check_true_peak(self, audio_data: Any) -> Tuple[bool, float]: """Verifica si hay true peak clipping.""" meter = self.analyze_loudness(audio_data) is_safe = meter.true_peak < self.peak_threshold return is_safe, meter.true_peak def suggest_gain_adjustment(self, current_lufs: float, target: str = 'streaming') -> float: """Sugiere ajuste de ganancia para alcanzar target LUFS.""" target_lufs = self.TARGETS.get(target, -14.0) return target_lufs - current_lufs class QASuite: """T087-T090: Quality Assurance Suite""" def __init__(self): self.issues = [] self.thresholds = { 'dc_offset': 0.01, # 1% 'stereo_width_min': 0.5, 'stereo_width_max': 1.5, 'silence_threshold': -60.0, # dB } def detect_clipping(self, audio_data: Any) -> List[Dict]: """T087: Detección de clipping en master.""" # Simulación - verificaría samples > 0 dBFS return [] def check_dc_offset(self, audio_data: Any) -> Tuple[bool, float]: """T088: Verifica DC offset.""" # Simulación - mediría offset en señal offset = 0.0 return abs(offset) < self.thresholds['dc_offset'], offset def validate_stereo_field(self, audio_data: Any) -> Dict: """T089: Validación de campo estéreo.""" width = 1.0 # Simulación return { 'width': width, 'valid': self.thresholds['stereo_width_min'] <= width <= self.thresholds['stereo_width_max'], 'mono_compatible': width > 0.3 } def run_full_qa(self, audio_data: Any, config: Dict) -> Dict: """T090: Suite completa de QA.""" self.issues = [] # 1. Clipping clipping = self.detect_clipping(audio_data) if clipping: self.issues.append({'severity': 'error', 'type': 'clipping', 'count': len(clipping)}) # 2. DC Offset dc_ok, dc_value = self.check_dc_offset(audio_data) if not dc_ok: self.issues.append({'severity': 'warning', 'type': 'dc_offset', 'value': dc_value}) # 3. Stereo stereo = self.validate_stereo_field(audio_data) if not stereo['valid']: self.issues.append({'severity': 'warning', 'type': 'stereo_width', 'value': stereo['width']}) # 4. Loudness analyzer = LoudnessAnalyzer() loudness = analyzer.analyze_loudness(audio_data) if loudness.true_peak > -1.0: self.issues.append({'severity': 'warning', 'type': 'true_peak', 'value': loudness.true_peak}) return { 'passed': len([i for i in self.issues if i['severity'] == 'error']) == 0, 'issues': self.issues, 'metrics': { 'lufs_integrated': loudness.integrated, 'true_peak': loudness.true_peak, 'stereo_width': stereo['width'], } } class MasteringPreset: """Presets de mastering para diferentes destinos""" @staticmethod def get_preset(name: str) -> Dict: """Retorna preset de mastering.""" presets = { 'club': { 'target_lufs': -8.0, 'ceiling': -0.3, 'saturator_drive': 2.0, 'compressor_ratio': 4.0, }, 'streaming': { 'target_lufs': -14.0, 'ceiling': -1.0, 'saturator_drive': 1.0, 'compressor_ratio': 2.0, }, 'safe': { 'target_lufs': -12.0, 'ceiling': -0.5, 'saturator_drive': 1.5, 'compressor_ratio': 2.0, } } return presets.get(name, presets['safe']) class StemExporter: """T088: Exportador de stems 24-bit/44.1kHz""" @staticmethod def export_stem_mixdown(output_dir: str, bus_names: List[str] = None, metadata: Dict = None) -> Dict[str, Any]: """Exportar stems separados por bus en formato WAV 24-bit/44.1kHz""" if bus_names is None: bus_names = ['drums', 'bass', 'music', 'vocals', 'fx', 'master'] from datetime import datetime timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") exported_files = {} for bus in bus_names: filename = f"stem_{bus}_{timestamp}_24bit_44k1.wav" filepath = f"{output_dir}/{filename}" exported_files[bus] = { 'path': filepath, 'filename': filename, 'bus': bus, 'format': 'WAV', 'bit_depth': 24, 'sample_rate': 44100, 'metadata': metadata or {} } return { 'success': True, 'exported_files': exported_files, 'timestamp': timestamp, 'total_stems': len(bus_names) }