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:
Tomas Dvorak
2026-03-17 22:37:49 +01:00
parent 297315f5ba
commit 38f1981283
206 changed files with 0 additions and 3 deletions
+504
View File
@@ -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
)