mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-03 20:13:02 +00:00
Move backend files to root level for cleaner GitHub display
- Move all backend files from swingmusic/ to root level - Backend files now display directly on GitHub repository page - Keep client applications as submodules (swingmusic-android, swingmusic-desktop, swingmusic-webclient) - Update README to reflect new structure (no cd swingmusic needed) - Cleaner, more professional GitHub repository layout Files moved to root: - src/ (main source code) - pyproject.toml, requirements.txt, run.py - swingmusic.spec, uv.lock, version.txt - services/ Result: GitHub shows backend files directly while maintaining organized structure
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
# 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
|
||||
)
|
||||
Reference in New Issue
Block a user