""" audio_soundscape.py - Soundscape y FX automáticos T051-T062: Ambiente, FX Bus y Tonal Conflict Detection """ import logging from typing import List, Dict, Any, Optional, Tuple from pathlib import Path logger = logging.getLogger("AudioSoundscape") class SoundscapeEngine: """T051-T054: Engine de ambientes y texturas""" def __init__(self): self.atmos_templates = { 'intro': ['*Atmos*Intro*.wav', '*Texture*Intro*.wav', '*Pad*Intro*.wav'], 'break': ['*Atmos*Break*.wav', '*Texture*Break*.wav', '*Pad*Break*.wav'], 'outro': ['*Atmos*Outro*.wav', '*Texture*Outro*.wav', '*Pad*Outro*.wav'], } def detect_ambience_gaps(self, timeline: List[Dict], min_gap_beats: float = 8.0) -> List[Dict]: """T051: Detecta espacios vacíos sin audio.""" gaps = [] for i in range(len(timeline) - 1): current_end = timeline[i].get('end', 0) next_start = timeline[i + 1].get('start', current_end) gap = next_start - current_end if gap >= min_gap_beats: gaps.append({ 'start': current_end, 'end': next_start, 'duration': gap, 'section': timeline[i].get('kind', 'unknown') }) return gaps def fill_with_atmos(self, gaps: List[Dict], genre: str, key: str) -> List[Dict]: """T052-T053: Carga atmos loops en gaps detectados.""" atmos_events = [] for gap in gaps: section = gap.get('section', 'intro') templates = self.atmos_templates.get(section, self.atmos_templates['break']) atmos_events.append({ 'position': gap['start'], 'duration': min(gap['duration'], 16.0), # Max 16 beats 'templates': templates, 'genre': genre, 'key': key, 'type': 'atmos_fill' }) return atmos_events class FXEngine: """T055-T058: Engine de FX automáticos""" def __init__(self): self.fx_patterns = { 'riser': {'template': '*Riser*.wav', 'pre_beats': 8}, 'downlifter': {'template': '*Downlifter*.wav', 'post_beats': 2}, 'impact': {'template': '*Impact*.wav', 'at_position': True}, 'crash': {'template': '*Crash*.wav', 'at_position': True}, 'snare_roll': {'template': '*Snare Roll*.wav', 'pre_beats': 4}, } def auto_riser_before_drop(self, section_start: float, n_beats: int = 8) -> Optional[Dict]: """T055: Genera riser N beats antes de drop.""" return { 'type': 'riser', 'position': max(0, section_start - n_beats), 'duration': n_beats, 'template': self.fx_patterns['riser']['template'] } def auto_downlifter_transition(self, from_section: str, to_section: str, section_end: float) -> Optional[Dict]: """T056: Auto-downlifter en transiciones.""" if to_section in ['drop', 'break'] and from_section in ['build', 'drop']: return { 'type': 'downlifter', 'position': section_end - 2, 'duration': 2, 'template': self.fx_patterns['downlifter']['template'] } return None def auto_impact_on_downbeat(self, section_start: float, section_kind: str) -> Optional[Dict]: """T057: Impact/crash en downbeats de drop.""" if section_kind in ['drop', 'build']: return { 'type': 'impact', 'position': section_start, 'template': self.fx_patterns['impact']['template'] } return None def auto_snare_roll(self, section_start: float, duration_beats: int = 4) -> Optional[Dict]: """T058: Snare roll automático antes de drops.""" return { 'type': 'snare_roll', 'position': max(0, section_start - duration_beats), 'duration': duration_beats, 'template': self.fx_patterns['snare_roll']['template'] } class TonalAnalyzer: """T059-T062: Análisis de conflictos tonales""" NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] def detect_key_conflict(self, samples: List[Dict], target_key: str) -> List[Dict]: """T059: Detecta si samples tienen key conflict con target_key.""" conflicts = [] for sample in samples: sample_key = sample.get('key', '') if sample_key and sample_key != target_key: # Check compatibility using circle of fifths distance = self._key_distance(target_key, sample_key) if distance > 2: # More than 2 steps on circle conflicts.append({ 'sample': sample.get('path', 'unknown'), 'sample_key': sample_key, 'target_key': target_key, 'distance': distance, 'severity': 'high' if distance > 4 else 'medium' }) return conflicts def _key_distance(self, key1: str, key2: str) -> int: """Calcula distancia en círculo de quintas.""" # Normalize keys is_minor1 = 'm' in key1.lower() is_minor2 = 'm' in key2.lower() if is_minor1 != is_minor2: return 6 # Different modes = max distance root1 = key1.replace('m', '').replace('M', '') root2 = key2.replace('m', '').replace('M', '') try: idx1 = self.NOTE_NAMES.index(root1) idx2 = self.NOTE_NAMES.index(root2) except ValueError: return 6 # Unknown note # Distance on circle of fifths circle_of_fifths = [0, 7, 2, 9, 4, 11, 6, 1, 8, 3, 10, 5] # Perfect fifths order pos1 = circle_of_fifths.index(idx1) if idx1 in circle_of_fifths else 0 pos2 = circle_of_fifths.index(idx2) if idx2 in circle_of_fifths else 0 return min(abs(pos1 - pos2), 12 - abs(pos1 - pos2)) def suggest_transpose(self, sample_path: str, from_key: str, to_key: str) -> int: """T060-T061: Sugiere semitonos para transponer sample a key objetivo.""" try: root_from = from_key.replace('m', '').replace('M', '') root_to = to_key.replace('m', '').replace('M', '') idx_from = self.NOTE_NAMES.index(root_from) idx_to = self.NOTE_NAMES.index(root_to) semitones = idx_to - idx_from # Normalize to -6 to +6 range if semitones > 6: semitones -= 12 elif semitones < -6: semitones += 12 return semitones except ValueError: return 0 # Can't calculate def generate_dissonance_alert(self, conflicts: List[Dict]) -> str: """T062: Genera alertas de disonancia.""" if not conflicts: return "No tonal conflicts detected." high_conflicts = [c for c in conflicts if c['severity'] == 'high'] if high_conflicts: return f"WARNING: {len(high_conflicts)} high-severity key conflicts detected!" return f"INFO: {len(conflicts)} minor key variations (acceptable)."