# swingmusic/services/key_harmony.py import numpy as np import librosa from typing import Dict, List, Tuple, Optional from dataclasses import dataclass import logging logger = logging.getLogger(__name__) @dataclass class KeyInfo: """Musical key information""" key: str mode: str # 'major' or 'minor' camelot: str # Camelot wheel notation confidence: float chroma_profile: np.ndarray tonic_frequency: float @dataclass class HarmonyAnalysis: """Harmonic relationship analysis between two keys""" relationship_type: str compatibility_score: float transition_suggestions: List[str] energy_change: str emotional_impact: str class KeyHarmonyDetector: """Advanced key harmony detection for musical transitions""" def __init__(self): self.sample_rate = 22050 self.hop_length = 512 self.key_templates = self._build_key_templates() self.harmony_rules = self._build_harmony_rules() def _build_key_templates(self) -> Dict[str, np.ndarray]: """Build chroma templates for all 24 keys""" # 12 major and 12 minor key templates keys = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] templates = {} # Major key templates (Ionian mode) major_intervals = [0, 2, 4, 5, 7, 9, 11] # Major scale intervals for i, key in enumerate(keys): template = np.zeros(12) for interval in major_intervals: template[(i + interval) % 12] = 1.0 templates[f"{key}_major"] = template # Minor key templates (Aeolian mode) minor_intervals = [0, 2, 3, 5, 7, 8, 10] # Natural minor scale intervals for i, key in enumerate(keys): template = np.zeros(12) for interval in minor_intervals: template[(i + interval) % 12] = 1.0 templates[f"{key}_minor"] = template return templates def _build_harmony_rules(self) -> Dict[str, Dict[str, float]]: """Build harmonic compatibility rules""" return { 'perfect_match': {'score': 1.0, 'energy': 'stable', 'emotion': 'seamless'}, 'relative_major_minor': {'score': 0.9, 'energy': 'gentle_rise', 'emotion': 'uplifting'}, 'parallel_major_minor': {'score': 0.8, 'energy': 'dramatic_shift', 'emotion': 'intense'}, 'dominant': {'score': 0.7, 'energy': 'building_tension', 'emotion': 'anticipating'}, 'subdominant': {'score': 0.7, 'energy': 'resolving', 'emotion': 'calming'}, 'chromatic_mediant': {'score': 0.6, 'energy': 'surprising', 'emotion': 'dreamy'}, 'change_mode': {'score': 0.5, 'energy': 'noticeable_shift', 'emotion': 'reflective'}, 'tritone_substitution': {'score': 0.4, 'energy': 'dramatic', 'emotion': 'tense'}, 'moderate_dissonance': {'score': 0.3, 'energy': 'uncomfortable', 'emotion': 'edgy'}, 'strong_dissonance': {'score': 0.1, 'energy': 'jarring', 'emotion': 'chaotic'} } async def analyze_key(self, audio_path: str) -> KeyInfo: """Analyze the musical key of an audio file""" try: # Load audio y, sr = librosa.load(audio_path, sr=self.sample_rate) # Extract chroma features chroma = librosa.feature.chroma_stft(y=y, sr=sr, hop_length=self.hop_length) # Average chroma across time chroma_mean = np.mean(chroma, axis=1) # Normalize chroma chroma_norm = chroma_mean / np.sum(chroma_mean) # Find best matching key best_key, confidence = self._match_key_template(chroma_norm) # Extract mode and Camelot notation mode = 'major' if 'major' in best_key else 'minor' key_name = best_key.replace('_major', '').replace('_minor', '') camelot = self._key_to_camelot(key_name, mode) # Detect tonic frequency tonic_freq = self._detect_tonic_frequency(y, sr, key_name) return KeyInfo( key=key_name, mode=mode, camelot=camelot, confidence=confidence, chroma_profile=chroma_norm, tonic_frequency=tonic_freq ) except Exception as e: logger.error(f"Key analysis failed for {audio_path}: {e}") raise def _match_key_template(self, chroma_norm: np.ndarray) -> Tuple[str, float]: """Match chroma profile against key templates""" best_key = None best_score = 0.0 for key_name, template in self.key_templates.items(): # Calculate correlation with template correlation = np.corrcoef(chroma_norm, template)[0, 1] if not np.isnan(correlation) and correlation > best_score: best_score = correlation best_key = key_name # Normalize score to 0-1 range best_score = max(0, (best_score + 1) / 2) return best_key or 'C_major', best_score def _key_to_camelot(self, key_name: str, mode: str) -> str: """Convert key name to Camelot wheel notation""" camelot_major = { 'C': '8B', 'C#': '3B', 'D': '10B', 'D#': '5B', 'E': '12B', 'F': '7B', 'F#': '2B', 'G': '9B', 'G#': '4B', 'A': '11B', 'A#': '6B', 'B': '1B' } camelot_minor = { 'C': '8A', 'C#': '3A', 'D': '10A', 'D#': '5A', 'E': '12A', 'F': '7A', 'F#': '2A', 'G': '9A', 'G#': '4A', 'A': '11A', 'A#': '6A', 'B': '1A' } if mode == 'major': return camelot_major.get(key_name, '8B') else: return camelot_minor.get(key_name, '8A') def _detect_tonic_frequency(self, y: np.ndarray, sr: int, key_name: str) -> float: """Detect the fundamental frequency of the tonic""" # Map key names to frequencies (A4 = 440 Hz reference) key_frequencies = { 'C': 261.63, 'C#': 277.18, 'D': 293.66, 'D#': 311.13, 'E': 329.63, 'F': 349.23, 'F#': 369.99, 'G': 392.00, 'G#': 415.30, 'A': 440.00, 'A#': 466.16, 'B': 493.88 } # Use autocorrelation to find fundamental frequency f0 = librosa.yin(y, fmin=50, fmax=2000, sr=sr) # Get the most common f0 estimate f0_clean = f0[f0 > 0] if len(f0_clean) > 0: median_f0 = np.median(f0_clean) # Find closest key frequency key_freq = key_frequencies.get(key_name, 440.0) # Return the detected frequency if it's close to expected if abs(median_f0 - key_freq) / key_freq < 0.1: # Within 10% return median_f0 return key_frequencies.get(key_name, 440.0) async def analyze_harmony(self, key1: KeyInfo, key2: KeyInfo) -> HarmonyAnalysis: """Analyze harmonic relationship between two keys""" try: # Determine relationship type relationship_type = self._determine_relationship(key1, key2) # Calculate compatibility score compatibility_score = self._calculate_compatibility(key1, key2, relationship_type) # Generate transition suggestions transition_suggestions = self._generate_transition_suggestions( key1, key2, relationship_type ) # Analyze energy change energy_change = self._analyze_energy_change(key1, key2, relationship_type) # Determine emotional impact emotional_impact = self._determine_emotional_impact( key1, key2, relationship_type ) return HarmonyAnalysis( relationship_type=relationship_type, compatibility_score=compatibility_score, transition_suggestions=transition_suggestions, energy_change=energy_change, emotional_impact=emotional_impact ) except Exception as e: logger.error(f"Harmony analysis failed: {e}") raise def _determine_relationship(self, key1: KeyInfo, key2: KeyInfo) -> str: """Determine the harmonic relationship between two keys""" # Perfect match if key1.key == key2.key and key1.mode == key2.mode: return 'perfect_match' # Relative major/minor if self._is_relative_major_minor(key1, key2): return 'relative_major_minor' # Parallel major/minor if key1.key == key2.key and key1.mode != key2.mode: return 'parallel_major_minor' # Dominant relationship if self._is_dominant_relationship(key1, key2): return 'dominant' # Subdominant relationship if self._is_subdominant_relationship(key1, key2): return 'subdominant' # Chromatic mediant if self._is_chromatic_mediant(key1, key2): return 'chromatic_mediant' # Change mode (same tonic, different mode) if key1.key == key2.key: return 'change_mode' # Tritone substitution if self._is_tritone_substitution(key1, key2): return 'tritone_substitution' # Check for moderate or strong dissonance dissonance_level = self._calculate_dissonance(key1, key2) if dissonance_level > 0.7: return 'strong_dissonance' elif dissonance_level > 0.4: return 'moderate_dissonance' return 'unknown' def _is_relative_major_minor(self, key1: KeyInfo, key2: KeyInfo) -> bool: """Check if keys are relative major/minor""" # Relative minor is a minor third below major # Relative major is a minor third above minor key_order = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] idx1 = key_order.index(key1.key) idx2 = key_order.index(key2.key) # Check relative relationships if key1.mode == 'major' and key2.mode == 'minor': # Major to relative minor: down 3 semitones return (idx1 - 3) % 12 == idx2 elif key1.mode == 'minor' and key2.mode == 'major': # Minor to relative major: up 3 semitones return (idx1 + 3) % 12 == idx2 return False def _is_dominant_relationship(self, key1: KeyInfo, key2: KeyInfo) -> bool: """Check if keys have dominant relationship""" key_order = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] idx1 = key_order.index(key1.key) idx2 = key_order.index(key2.key) # Dominant is up a perfect fifth (7 semitones) return ((idx1 + 7) % 12 == idx2 or (idx2 + 7) % 12 == idx1) and \ key1.mode == key2.mode def _is_subdominant_relationship(self, key1: KeyInfo, key2: KeyInfo) -> bool: """Check if keys have subdominant relationship""" key_order = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] idx1 = key_order.index(key1.key) idx2 = key_order.index(key2.key) # Subdominant is up a perfect fourth (5 semitones) return ((idx1 + 5) % 12 == idx2 or (idx2 + 5) % 12 == idx1) and \ key1.mode == key2.mode def _is_chromatic_mediant(self, key1: KeyInfo, key2: KeyInfo) -> bool: """Check if keys are chromatic mediants""" key_order = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] idx1 = key_order.index(key1.key) idx2 = key_order.index(key2.key) # Chromatic mediant relationships (major third or minor third) interval = (idx2 - idx1) % 12 return interval in [3, 4, 8, 9] and key1.mode == key2.mode def _is_tritone_substitution(self, key1: KeyInfo, key2: KeyInfo) -> bool: """Check if keys are tritone substitutions""" key_order = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] idx1 = key_order.index(key1.key) idx2 = key_order.index(key2.key) # Tritone is 6 semitones return ((idx1 + 6) % 12 == idx2) and key1.mode == key2.mode def _calculate_dissonance(self, key1: KeyInfo, key2: KeyInfo) -> float: """Calculate dissonance level between two keys""" # Use chroma correlation to measure dissonance correlation = np.corrcoef(key1.chroma_profile, key2.chroma_profile)[0, 1] if np.isnan(correlation): return 0.5 # Convert correlation to dissonance (inverse relationship) dissonance = 1.0 - max(0, correlation) return dissonance def _calculate_compatibility(self, key1: KeyInfo, key2: KeyInfo, relationship_type: str) -> float: """Calculate compatibility score based on relationship type""" if relationship_type in self.harmony_rules: base_score = self.harmony_rules[relationship_type]['score'] # Adjust based on confidence levels confidence_factor = (key1.confidence + key2.confidence) / 2 return base_score * confidence_factor return 0.5 # Default compatibility for unknown relationships def _generate_transition_suggestions(self, key1: KeyInfo, key2: KeyInfo, relationship_type: str) -> List[str]: """Generate transition suggestions based on harmonic relationship""" suggestions = [] if relationship_type == 'perfect_match': suggestions.extend([ "Direct mix - perfect harmonic compatibility", "Long crossfade - maintain energy", "EQ sweep for subtle texture change" ]) elif relationship_type == 'relative_major_minor': suggestions.extend([ "Bass note transition to establish new mode", "Gradual mode shift over 8 bars", "Highlight the third to emphasize mode change" ]) elif relationship_type == 'parallel_major_minor': suggestions.extend([ "Dramatic transition with filter sweep", "Use the third as pivot point", "Emphasize the emotional shift with reverb" ]) elif relationship_type == 'dominant': suggestions.extend([ "Build tension with dominant chord", "Resolve quickly for satisfying transition", "Use riser effect to enhance anticipation" ]) elif relationship_type == 'subdominant': suggestions.extend([ "Gentle, resolving transition", "Use pad sounds to smooth the change", "Gradual bass movement" ]) elif relationship_type == 'chromatic_mediant': suggestions.extend([ "Unexpected but pleasing transition", "Use delay to create dreamy effect", "Highlight the chromatic mediant relationship" ]) elif relationship_type == 'change_mode': suggestions.extend([ "Emphasize the third or sixth", "Use filter to change tonal character", "Create emotional contrast with dynamics" ]) else: suggestions.extend([ "Quick cut to minimize dissonance", "Use effects mask during transition", "Consider reordering tracks for better flow" ]) return suggestions def _analyze_energy_change(self, key1: KeyInfo, key2: KeyInfo, relationship_type: str) -> str: """Analyze the energy change between keys""" if relationship_type in self.harmony_rules: return self.harmony_rules[relationship_type]['energy'] # Default energy analysis based on mode change if key1.mode != key2.mode: return "mode_shift_energy" return "neutral_energy" def _determine_emotional_impact(self, key1: KeyInfo, key2: KeyInfo, relationship_type: str) -> str: """Determine the emotional impact of the transition""" if relationship_type in self.harmony_rules: return self.harmony_rules[relationship_type]['emotion'] # Default emotional analysis if key1.mode == 'major' and key2.mode == 'minor': return "melancholy_shift" elif key1.mode == 'minor' and key2.mode == 'major': return "hopeful_rise" return "neutral_emotion" async def suggest_harmonic_transitions(self, current_key: KeyInfo, target_keys: List[KeyInfo]) -> List[Tuple[KeyInfo, HarmonyAnalysis]]: """Suggest the best harmonic transitions from current key to targets""" transitions = [] for target_key in target_keys: harmony = await self.analyze_harmony(current_key, target_key) transitions.append((target_key, harmony)) # Sort by compatibility score transitions.sort(key=lambda x: x[1].compatibility_score, reverse=True) return transitions def create_harmonic_mix_plan(self, keys: List[KeyInfo]) -> Dict: """Create a harmonic mix plan for a sequence of keys""" if len(keys) < 2: return {'error': 'Need at least 2 keys for mix plan'} mix_plan = { 'total_keys': len(keys), 'overall_compatibility': 0.0, 'transitions': [], 'suggestions': [] } total_compatibility = 0.0 for i in range(len(keys) - 1): current_key = keys[i] next_key = keys[i + 1] harmony = self._analyze_harmony_sync(current_key, next_key) transition_info = { 'from_key': f"{current_key.key} {current_key.mode}", 'to_key': f"{next_key.key} {next_key.mode}", 'relationship': harmony.relationship_type, 'compatibility': harmony.compatibility_score, 'energy_change': harmony.energy_change, 'emotional_impact': harmony.emotional_impact, 'suggestions': harmony.transition_suggestions } mix_plan['transitions'].append(transition_info) total_compatibility += harmony.compatibility_score mix_plan['overall_compatibility'] = total_compatibility / (len(keys) - 1) # Generate overall suggestions if mix_plan['overall_compatibility'] >= 0.8: mix_plan['suggestions'].append("Excellent harmonic flow - mix as planned") elif mix_plan['overall_compatibility'] >= 0.6: mix_plan['suggestions'].append("Good harmonic flow - consider minor adjustments") else: mix_plan['suggestions'].append("Consider reordering tracks for better harmonic flow") return mix_plan def _analyze_harmony_sync(self, key1: KeyInfo, key2: KeyInfo) -> HarmonyAnalysis: """Synchronous harmony analysis (non-async version)""" relationship_type = self._determine_relationship(key1, key2) compatibility_score = self._calculate_compatibility(key1, key2, relationship_type) transition_suggestions = self._generate_transition_suggestions(key1, key2, relationship_type) energy_change = self._analyze_energy_change(key1, key2, relationship_type) emotional_impact = self._determine_emotional_impact(key1, key2, relationship_type) return HarmonyAnalysis( relationship_type=relationship_type, compatibility_score=compatibility_score, transition_suggestions=transition_suggestions, energy_change=energy_change, emotional_impact=emotional_impact )