Add comprehensive backend services and API enhancements

- Complete Spotify integration with downloader and settings
- Advanced UX features and audio quality management
- Enhanced search capabilities and mobile offline support
- Music catalog browser and recap features
- Universal downloader and upload functionality
- Update tracking system with database models and migrations
- Comprehensive service layer architecture
- Enhanced lyrics API and streaming capabilities
- Extended application builder and startup configuration
- New logging infrastructure and services directory
This commit is contained in:
Tomas Dvorak
2026-03-17 17:56:20 +01:00
parent 65a1268dab
commit 4338dd1d9c
43 changed files with 19453 additions and 10 deletions
+381
View File
@@ -0,0 +1,381 @@
# swingmusic/services/beat_matching.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 BeatInfo:
"""Beat information for a track"""
tempo: float
beat_frames: np.ndarray
beat_times: np.ndarray
downbeats: np.ndarray
key: str
energy_curve: np.ndarray
@dataclass
class BeatMatch:
"""Beat matching result between two tracks"""
compatibility_score: float
tempo_ratio: float
key_compatibility: str
optimal_mix_point: float
transition_type: str
energy_match: float
class BeatMatcher:
"""Advanced beat matching engine for DJ transitions"""
def __init__(self):
self.sample_rate = 22050
self.hop_length = 512
self.key_compatibility_matrix = self._build_key_compatibility_matrix()
def _build_key_compatibility_matrix(self) -> Dict[str, Dict[str, float]]:
"""Build harmonic key compatibility matrix"""
# Camelot wheel compatibility
compatibility = {
'1A': {'1A': 1.0, '12A': 0.9, '2A': 0.9, '1B': 0.8, '12B': 0.8},
'2A': {'2A': 1.0, '1A': 0.9, '3A': 0.9, '2B': 0.8, '1B': 0.8},
'3A': {'3A': 1.0, '2A': 0.9, '4A': 0.9, '3B': 0.8, '2B': 0.8},
'4A': {'4A': 1.0, '3A': 0.9, '5A': 0.9, '4B': 0.8, '3B': 0.8},
'5A': {'5A': 1.0, '4A': 0.9, '6A': 0.9, '5B': 0.8, '4B': 0.8},
'6A': {'6A': 1.0, '5A': 0.9, '7A': 0.9, '6B': 0.8, '5B': 0.8},
'7A': {'7A': 1.0, '6A': 0.9, '8A': 0.9, '7B': 0.8, '6B': 0.8},
'8A': {'8A': 1.0, '7A': 0.9, '9A': 0.9, '8B': 0.8, '7B': 0.8},
'9A': {'9A': 1.0, '8A': 0.9, '10A': 0.9, '9B': 0.8, '8B': 0.8},
'10A': {'10A': 1.0, '9A': 0.9, '11A': 0.9, '10B': 0.8, '9B': 0.8},
'11A': {'11A': 1.0, '10A': 0.9, '12A': 0.9, '11B': 0.8, '10B': 0.8},
'12A': {'12A': 1.0, '11A': 0.9, '1A': 0.9, '12B': 0.8, '11B': 0.8},
'1B': {'1B': 1.0, '12B': 0.9, '2B': 0.9, '1A': 0.8, '12A': 0.8},
'2B': {'2B': 1.0, '1B': 0.9, '3B': 0.9, '2A': 0.8, '1A': 0.8},
'3B': {'3B': 1.0, '2B': 0.9, '4B': 0.9, '3A': 0.8, '2A': 0.8},
'4B': {'4B': 1.0, '3B': 0.9, '5B': 0.9, '4A': 0.8, '3A': 0.8},
'5B': {'5B': 1.0, '4B': 0.9, '6B': 0.9, '5A': 0.8, '4A': 0.8},
'6B': {'6B': 1.0, '5B': 0.9, '7B': 0.9, '6A': 0.8, '5A': 0.8},
'7B': {'7B': 1.0, '6B': 0.9, '8B': 0.9, '7A': 0.8, '6A': 0.8},
'8B': {'8B': 1.0, '7B': 0.9, '9B': 0.9, '8A': 0.8, '7A': 0.8},
'9B': {'9B': 1.0, '8B': 0.9, '10B': 0.9, '9A': 0.8, '8A': 0.8},
'10B': {'10B': 1.0, '9B': 0.9, '11B': 0.9, '10A': 0.8, '9A': 0.8},
'11B': {'11B': 1.0, '10B': 0.9, '12B': 0.9, '11A': 0.8, '10A': 0.8},
'12B': {'12B': 1.0, '11B': 0.9, '1B': 0.9, '12A': 0.8, '11A': 0.8}
}
return compatibility
async def analyze_beats(self, audio_path: str) -> BeatInfo:
"""Analyze beats and tempo from audio file"""
try:
# Load audio
y, sr = librosa.load(audio_path, sr=self.sample_rate)
# Extract tempo and beat frames
tempo, beat_frames = librosa.beat.beat_track(
y=y, sr=sr, hop_length=self.hop_length
)
# Convert frames to time
beat_times = librosa.frames_to_time(
beat_frames, sr=sr, hop_length=self.hop_length
)
# Detect downbeats (first beat of each measure)
downbeats = self._detect_downbeats(y, sr, beat_frames)
# Extract key
key = await self._extract_key(y, sr)
# Calculate energy curve
energy_curve = self._calculate_energy_curve(y, beat_frames)
return BeatInfo(
tempo=float(tempo),
beat_frames=beat_frames,
beat_times=beat_times,
downbeats=downbeats,
key=key,
energy_curve=energy_curve
)
except Exception as e:
logger.error(f"Beat analysis failed for {audio_path}: {e}")
raise
def _detect_downbeats(self, y: np.ndarray, sr: int, beat_frames: np.ndarray) -> np.ndarray:
"""Detect downbeats using harmonic and percussive separation"""
# Separate harmonic and percussive
y_harmonic = librosa.effects.harmonic(y)
y_percussive = librosa.effects.percussive(y)
# Calculate onset strength
harmonic_onset = librosa.onset.onset_strength(
y=y_harmonic, sr=sr, hop_length=self.hop_length
)
percussive_onset = librosa.onset.onset_strength(
y=y_percussive, sr=sr, hop_length=self.hop_length
)
# Combine onset strengths
combined_onset = harmonic_onset + percussive_onset
# Find peaks at beat positions
beat_onset_strength = combined_onset[beat_frames]
# Detect downbeats (peaks in onset strength)
downbeat_indices = []
for i in range(1, len(beat_onset_strength) - 1):
if (beat_onset_strength[i] > beat_onset_strength[i-1] and
beat_onset_strength[i] > beat_onset_strength[i+1]):
downbeat_indices.append(i)
return beat_frames[downbeat_indices] if downbeat_indices else beat_frames[::4] # Assume 4/4 time
async def _extract_key(self, y: np.ndarray, sr: int) -> str:
"""Extract musical key using chroma features"""
# 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)
# Determine key profile (simplified)
# Map to Camelot notation
key_profiles = {
'C': '8B', 'C#': '3B', 'D': '10B', 'D#': '5B',
'E': '12B', 'F': '7B', 'F#': '2B', 'G': '9B',
'G#': '4B', 'A': '11B', 'A#': '6B', 'B': '1B',
'Cm': '8A', 'C#m': '3A', 'Dm': '10A', 'D#m': '5A',
'Em': '12A', 'Fm': '7A', 'F#m': '2A', 'Gm': '9A',
'G#m': '4A', 'Am': '11A', 'A#m': '6A', 'Bm': '1A'
}
# Find best matching key (simplified - would need more sophisticated analysis)
max_chroma_idx = np.argmax(chroma_mean)
key_names = list(key_profiles.keys())
detected_key = key_names[max_chroma_idx % len(key_names)]
return key_profiles.get(detected_key, '8A') # Default to C major
def _calculate_energy_curve(self, y: np.ndarray, beat_frames: np.ndarray) -> np.ndarray:
"""Calculate energy curve for each beat"""
energy_curve = []
for i in range(len(beat_frames) - 1):
start_frame = beat_frames[i]
end_frame = beat_frames[i + 1] if i + 1 < len(beat_frames) else len(y)
# Calculate RMS energy for this beat
beat_segment = y[start_frame:end_frame]
energy = np.sqrt(np.mean(beat_segment ** 2))
energy_curve.append(energy)
return np.array(energy_curve)
async def calculate_beat_match(self, track1_beats: BeatInfo, track2_beats: BeatInfo) -> BeatMatch:
"""Calculate beat matching compatibility between two tracks"""
try:
# Tempo compatibility
tempo_ratio = track2_beats.tempo / track1_beats.tempo
tempo_compatibility = self._calculate_tempo_compatibility(tempo_ratio)
# Key compatibility
key_compatibility_score = self._calculate_key_compatibility(
track1_beats.key, track2_beats.key
)
# Energy matching
energy_match = self._calculate_energy_compatibility(
track1_beats.energy_curve, track2_beats.energy_curve
)
# Overall compatibility
compatibility_score = (
tempo_compatibility * 0.4 +
key_compatibility_score * 0.3 +
energy_match * 0.3
)
# Determine optimal transition type
transition_type = self._determine_transition_type(
tempo_compatibility, key_compatibility_score, energy_match
)
# Find optimal mix point
optimal_mix_point = self._find_optimal_mix_point(
track1_beats, track2_beats, transition_type
)
return BeatMatch(
compatibility_score=compatibility_score,
tempo_ratio=tempo_ratio,
key_compatibility=f"{track1_beats.key}{track2_beats.key}",
optimal_mix_point=optimal_mix_point,
transition_type=transition_type,
energy_match=energy_match
)
except Exception as e:
logger.error(f"Beat matching calculation failed: {e}")
raise
def _calculate_tempo_compatibility(self, tempo_ratio: float) -> float:
"""Calculate tempo compatibility score"""
# Perfect match (same tempo)
if 0.98 <= tempo_ratio <= 1.02:
return 1.0
# Harmonic ratios (2x, 0.5x)
if 0.49 <= tempo_ratio <= 0.51 or 1.98 <= tempo_ratio <= 2.02:
return 0.8
# Close match (within 10%)
if 0.9 <= tempo_ratio <= 1.1:
return 0.7
# Acceptable range (within 20%)
if 0.8 <= tempo_ratio <= 1.2:
return 0.5
# Poor compatibility
return 0.2
def _calculate_key_compatibility(self, key1: str, key2: str) -> float:
"""Calculate harmonic key compatibility"""
if key1 in self.key_compatibility_matrix and key2 in self.key_compatibility_matrix[key1]:
return self.key_compatibility_matrix[key1][key2]
return 0.1 # Very low compatibility for unknown keys
def _calculate_energy_compatibility(self, energy1: np.ndarray, energy2: np.ndarray) -> float:
"""Calculate energy curve compatibility"""
if len(energy1) == 0 or len(energy2) == 0:
return 0.5
# Normalize energy curves
energy1_norm = (energy1 - np.min(energy1)) / (np.max(energy1) - np.min(energy1))
energy2_norm = (energy2 - np.min(energy2)) / (np.max(energy2) - np.min(energy2))
# Calculate correlation
min_length = min(len(energy1_norm), len(energy2_norm))
correlation = np.corrcoef(energy1_norm[:min_length], energy2_norm[:min_length])[0, 1]
if np.isnan(correlation):
return 0.5
return (correlation + 1) / 2 # Normalize to 0-1 range
def _determine_transition_type(self, tempo_comp: float, key_comp: float, energy_comp: float) -> str:
"""Determine optimal transition type"""
overall_score = (tempo_comp + key_comp + energy_comp) / 3
if overall_score >= 0.8:
return "perfect_blend"
elif overall_score >= 0.6:
return "smooth_transition"
elif overall_score >= 0.4:
return "crossfade"
elif key_comp >= 0.7:
return "harmonic_mix"
else:
return "cut"
def _find_optimal_mix_point(self, track1_beats: BeatInfo, track2_beats: BeatInfo,
transition_type: str) -> float:
"""Find optimal mix point in track 1 (0-1, where 1 is end)"""
if transition_type == "perfect_blend":
# Mix near the end for perfect blend
return 0.85
elif transition_type == "smooth_transition":
# Earlier mix point for smooth transition
return 0.75
elif transition_type == "crossfade":
# Standard crossfade point
return 0.9
elif transition_type == "harmonic_mix":
# Mix at downbeat for harmonic compatibility
if len(track1_beats.downbeats) > 0:
last_downbeat_ratio = track1_beats.downbeats[-1] / len(track1_beats.beat_frames)
return min(last_downbeat_ratio + 0.1, 0.9)
return 0.8
else: # cut
# Quick cut near the end
return 0.95
async def create_beat_sync_transition(self, track1_path: str, track2_path: str,
beat_match: BeatMatch) -> Dict:
"""Create beat-synchronized transition parameters"""
try:
# Analyze both tracks
track1_beats = await self.analyze_beats(track1_path)
track2_beats = await self.analyze_beats(track2_path)
# Calculate transition parameters
transition_start = beat_match.optimal_mix_point
transition_duration = self._calculate_transition_duration(
beat_match.transition_type, track1_beats.tempo
)
# Calculate tempo adjustment
tempo_adjustment = 1.0 / beat_match.tempo_ratio if beat_match.tempo_ratio != 1.0 else 1.0
return {
'transition_type': beat_match.transition_type,
'start_point': transition_start,
'duration': transition_duration,
'tempo_adjustment': tempo_adjustment,
'key_change_needed': beat_match.key_compatibility,
'energy_ramp': self._calculate_energy_ramp(
track1_beats.energy_curve, track2_beats.energy_curve,
transition_start, transition_duration
),
'beat_markers': {
'track1_last_beat': transition_start,
'track2_first_beat': 0.0,
'sync_point': transition_start + transition_duration / 2
}
}
except Exception as e:
logger.error(f"Beat sync transition creation failed: {e}")
raise
def _calculate_transition_duration(self, transition_type: str, tempo: float) -> float:
"""Calculate transition duration based on type and tempo"""
beat_duration = 60.0 / tempo
if transition_type == "perfect_blend":
return beat_duration * 16 # 16 bars
elif transition_type == "smooth_transition":
return beat_duration * 8 # 8 bars
elif transition_type == "crossfade":
return beat_duration * 4 # 4 bars
elif transition_type == "harmonic_mix":
return beat_duration * 8 # 8 bars for harmonic transition
else: # cut
return beat_duration * 1 # 1 bar quick cut
def _calculate_energy_ramp(self, energy1: np.ndarray, energy2: np.ndarray,
start_point: float, duration: float) -> np.ndarray:
"""Calculate energy ramp for smooth transition"""
# Get energy values around transition point
start_idx = int(len(energy1) * start_point)
end_idx = min(start_idx + int(duration * len(energy1)), len(energy1))
if start_idx >= len(energy1):
return np.array([0.5]) # Default energy
track1_energy = energy1[start_idx:end_idx] if start_idx < len(energy1) else np.array([energy1[-1]])
# Create smooth ramp from track1 energy to track2 energy
track2_start_energy = energy2[0] if len(energy2) > 0 else 0.5
if len(track1_energy) == 0:
return np.linspace(0.5, track2_start_energy, 10)
ramp_points = max(len(track1_energy), 10)
energy_ramp = np.linspace(track1_energy[-1] if len(track1_energy) > 0 else 0.5,
track2_start_energy, ramp_points)
return energy_ramp
+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
)
+747
View File
@@ -0,0 +1,747 @@
# swingmusic/services/radio_sync.py
import asyncio
import json
import logging
from typing import Dict, List, Optional, Set
from dataclasses import dataclass, asdict
from datetime import datetime, timedelta
import websockets
from websockets.server import WebSocketServerProtocol
import aiohttp
import aiofiles
from pathlib import Path
logger = logging.getLogger(__name__)
@dataclass
class RadioStation:
"""Radio station information"""
id: str
name: str
type: str # 'artist_radio', 'genre_radio', 'mood_radio', 'custom_radio'
user_id: str
current_track_index: int
tracks: List[Dict]
created_at: datetime
last_played: Optional[datetime] = None
play_count: int = 0
is_active: bool = False
@dataclass
class SyncEvent:
"""Synchronization event"""
event_type: str # 'play', 'pause', 'skip', 'seek', 'track_change'
station_id: str
user_id: str
timestamp: datetime
data: Dict
device_id: str
@dataclass
class DeviceInfo:
"""Connected device information"""
device_id: str
user_id: str
device_type: str # 'web', 'mobile', 'desktop'
last_seen: datetime
websocket: Optional[WebSocketServerProtocol] = None
current_station: Optional[str] = None
playback_position: float = 0.0
class CrossPlatformRadioSync:
"""Cross-platform radio synchronization service"""
def __init__(self):
self.active_stations: Dict[str, RadioStation] = {}
self.connected_devices: Dict[str, DeviceInfo] = {}
self.station_listeners: Dict[str, Set[str]] = {} # station_id -> device_ids
self.user_stations: Dict[str, List[str]] = {} # user_id -> station_ids
# WebSocket server
self.websocket_server = None
self.server_port = 8765
# Configuration
self.sync_timeout = 30 # seconds
self.max_devices_per_user = 5
self.station_cache_ttl = 3600 # 1 hour
# Background tasks
self.cleanup_task = None
self.sync_task = None
async def start_sync_service(self):
"""Start the radio synchronization service"""
try:
# Start WebSocket server
self.websocket_server = await websockets.serve(
self._handle_websocket_connection,
"localhost",
self.server_port
)
# Start background tasks
self.cleanup_task = asyncio.create_task(self._cleanup_loop())
self.sync_task = asyncio.create_task(self._sync_loop())
logger.info(f"Radio sync service started on port {self.server_port}")
except Exception as e:
logger.error(f"Failed to start sync service: {e}")
raise
async def stop_sync_service(self):
"""Stop the radio synchronization service"""
try:
# Cancel background tasks
if self.cleanup_task:
self.cleanup_task.cancel()
if self.sync_task:
self.sync_task.cancel()
# Close WebSocket server
if self.websocket_server:
self.websocket_server.close()
await self.websocket_server.wait_closed()
# Disconnect all devices
for device in self.connected_devices.values():
if device.websocket:
await device.websocket.close()
logger.info("Radio sync service stopped")
except Exception as e:
logger.error(f"Error stopping sync service: {e}")
async def create_radio_station(self, user_id: str, station_data: Dict) -> RadioStation:
"""Create a new radio station"""
try:
station_id = f"{station_data['type']}_{user_id}_{datetime.now().timestamp()}"
station = RadioStation(
id=station_id,
name=station_data['name'],
type=station_data['type'],
user_id=user_id,
current_track_index=0,
tracks=station_data['tracks'],
created_at=datetime.utcnow()
)
# Store station
self.active_stations[station_id] = station
# Update user stations
if user_id not in self.user_stations:
self.user_stations[user_id] = []
self.user_stations[user_id].append(station_id)
# Initialize listeners set
self.station_listeners[station_id] = set()
logger.info(f"Created radio station: {station_id}")
return station
except Exception as e:
logger.error(f"Failed to create radio station: {e}")
raise
async def get_user_stations(self, user_id: str) -> List[RadioStation]:
"""Get all radio stations for a user"""
try:
station_ids = self.user_stations.get(user_id, [])
stations = []
for station_id in station_ids:
if station_id in self.active_stations:
stations.append(self.active_stations[station_id])
return stations
except Exception as e:
logger.error(f"Failed to get user stations: {e}")
return []
async def join_station(self, user_id: str, device_id: str, station_id: str) -> bool:
"""Join a radio station"""
try:
# Check if station exists
if station_id not in self.active_stations:
logger.warning(f"Station not found: {station_id}")
return False
# Check if device is connected
if device_id not in self.connected_devices:
logger.warning(f"Device not connected: {device_id}")
return False
# Remove from previous station if any
await self._leave_station(device_id)
# Add to station listeners
self.station_listeners[station_id].add(device_id)
# Update device info
device = self.connected_devices[device_id]
device.current_station = station_id
device.playback_position = 0.0
# Mark station as active
station = self.active_stations[station_id]
station.is_active = True
# Send current track to device
await self._send_current_track(device_id, station)
logger.info(f"Device {device_id} joined station {station_id}")
return True
except Exception as e:
logger.error(f"Failed to join station: {e}")
return False
async def leave_station(self, device_id: str) -> bool:
"""Leave current radio station"""
try:
return await self._leave_station(device_id)
except Exception as e:
logger.error(f"Failed to leave station: {e}")
return False
async def _leave_station(self, device_id: str) -> bool:
"""Internal leave station method"""
device = self.connected_devices.get(device_id)
if not device:
return False
# Remove from station listeners
if device.current_station:
station_id = device.current_station
if station_id in self.station_listeners:
self.station_listeners[station_id].discard(device_id)
# Check if station should be deactivated
if len(self.station_listeners[station_id]) == 0:
station = self.active_stations.get(station_id)
if station:
station.is_active = False
# Update device info
device.current_station = None
device.playback_position = 0.0
return True
async def sync_playback_event(self, event: SyncEvent) -> None:
"""Synchronize playback event across all devices in station"""
try:
station_id = event.station_id
# Get station listeners
listeners = self.station_listeners.get(station_id, set())
# Broadcast event to all listeners (except sender)
for device_id in listeners:
if device_id != event.device_id:
await self._send_event_to_device(device_id, event)
# Update station state if needed
if event.event_type == 'track_change':
await self._handle_track_change(station_id, event.data)
elif event.event_type == 'play':
await self._handle_play_event(station_id, event)
elif event.event_type == 'pause':
await self._handle_pause_event(station_id, event)
except Exception as e:
logger.error(f"Failed to sync playback event: {e}")
async def _send_event_to_device(self, device_id: str, event: SyncEvent) -> None:
"""Send event to specific device"""
try:
device = self.connected_devices.get(device_id)
if not device or not device.websocket:
return
message = {
'type': 'sync_event',
'event': asdict(event)
}
await device.websocket.send(json.dumps(message))
except Exception as e:
logger.error(f"Failed to send event to device {device_id}: {e}")
async def _send_current_track(self, device_id: str, station: RadioStation) -> None:
"""Send current track information to device"""
try:
device = self.connected_devices.get(device_id)
if not device or not device.websocket:
return
if station.current_track_index < len(station.tracks):
current_track = station.tracks[station.current_track_index]
message = {
'type': 'current_track',
'track': current_track,
'station': asdict(station),
'position': device.playback_position
}
await device.websocket.send(json.dumps(message))
except Exception as e:
logger.error(f"Failed to send current track to device {device_id}: {e}")
async def _handle_websocket_connection(self, websocket: WebSocketServerProtocol, path: str):
"""Handle new WebSocket connection"""
try:
# Wait for authentication message
auth_message = await websocket.recv()
auth_data = json.loads(auth_message)
user_id = auth_data['user_id']
device_id = auth_data['device_id']
device_type = auth_data.get('device_type', 'unknown')
# Check device limit
user_devices = [d for d in self.connected_devices.values() if d.user_id == user_id]
if len(user_devices) >= self.max_devices_per_user:
await websocket.send(json.dumps({
'type': 'error',
'message': 'Device limit exceeded'
}))
await websocket.close()
return
# Register device
device = DeviceInfo(
device_id=device_id,
user_id=user_id,
device_type=device_type,
last_seen=datetime.utcnow(),
websocket=websocket
)
self.connected_devices[device_id] = device
# Send confirmation
await websocket.send(json.dumps({
'type': 'connected',
'device_id': device_id
}))
logger.info(f"Device {device_id} connected for user {user_id}")
# Handle messages
async for message in websocket:
try:
data = json.loads(message)
await self._handle_device_message(device_id, data)
except Exception as e:
logger.error(f"Error handling message from {device_id}: {e}")
except Exception as e:
logger.error(f"WebSocket connection error: {e}")
finally:
# Cleanup on disconnect
if 'device_id' in locals():
await self._cleanup_device(device_id)
async def _handle_device_message(self, device_id: str, data: Dict) -> None:
"""Handle message from device"""
try:
device = self.connected_devices.get(device_id)
if not device:
return
message_type = data.get('type')
if message_type == 'join_station':
station_id = data['station_id']
await self.join_station(device.user_id, device_id, station_id)
elif message_type == 'leave_station':
await self.leave_station(device_id)
elif message_type == 'playback_event':
event = SyncEvent(
event_type=data['event_type'],
station_id=data['station_id'],
user_id=device.user_id,
timestamp=datetime.utcnow(),
data=data['data'],
device_id=device_id
)
await self.sync_playback_event(event)
elif message_type == 'heartbeat':
device.last_seen = datetime.utcnow()
except Exception as e:
logger.error(f"Error handling device message: {e}")
async def _handle_track_change(self, station_id: str, data: Dict) -> None:
"""Handle track change event"""
try:
station = self.active_stations.get(station_id)
if not station:
return
new_track_index = data.get('track_index', 0)
# Validate track index
if 0 <= new_track_index < len(station.tracks):
station.current_track_index = new_track_index
station.play_count += 1
station.last_played = datetime.utcnow()
except Exception as e:
logger.error(f"Error handling track change: {e}")
async def _handle_play_event(self, station_id: str, event: SyncEvent) -> None:
"""Handle play event"""
try:
# Update playback position for all devices in station
listeners = self.station_listeners.get(station_id, set())
for device_id in listeners:
device = self.connected_devices.get(device_id)
if device and device_id != event.device_id:
device.playback_position = event.data.get('position', 0.0)
except Exception as e:
logger.error(f"Error handling play event: {e}")
async def _handle_pause_event(self, station_id: str, event: SyncEvent) -> None:
"""Handle pause event"""
try:
# Update playback position for all devices in station
listeners = self.station_listeners.get(station_id, set())
for device_id in listeners:
device = self.connected_devices.get(device_id)
if device and device_id != event.device_id:
device.playback_position = event.data.get('position', 0.0)
except Exception as e:
logger.error(f"Error handling pause event: {e}")
async def _cleanup_device(self, device_id: str) -> None:
"""Cleanup disconnected device"""
try:
device = self.connected_devices.get(device_id)
if not device:
return
# Leave current station
await self._leave_station(device_id)
# Remove from connected devices
del self.connected_devices[device_id]
logger.info(f"Device {device_id} disconnected")
except Exception as e:
logger.error(f"Error cleaning up device: {e}")
async def _cleanup_loop(self) -> None:
"""Background cleanup loop"""
while True:
try:
await asyncio.sleep(60) # Run every minute
current_time = datetime.utcnow()
devices_to_remove = []
# Check for inactive devices
for device_id, device in self.connected_devices.items():
if (current_time - device.last_seen).total_seconds() > self.sync_timeout:
devices_to_remove.append(device_id)
# Remove inactive devices
for device_id in devices_to_remove:
await self._cleanup_device(device_id)
# Cleanup old stations
stations_to_remove = []
for station_id, station in self.active_stations.items():
if (current_time - station.created_at).total_seconds() > self.station_cache_ttl:
if not station.is_active and len(self.station_listeners.get(station_id, set())) == 0:
stations_to_remove.append(station_id)
for station_id in stations_to_remove:
await self._cleanup_station(station_id)
except Exception as e:
logger.error(f"Error in cleanup loop: {e}")
async def _cleanup_station(self, station_id: str) -> None:
"""Cleanup old station"""
try:
station = self.active_stations.get(station_id)
if not station:
return
# Remove from user stations
user_stations = self.user_stations.get(station.user_id, [])
if station_id in user_stations:
user_stations.remove(station_id)
# Remove from active stations
del self.active_stations[station_id]
# Remove listeners
if station_id in self.station_listeners:
del self.station_listeners[station_id]
logger.info(f"Cleaned up station {station_id}")
except Exception as e:
logger.error(f"Error cleaning up station: {e}")
async def _sync_loop(self) -> None:
"""Background synchronization loop"""
while True:
try:
await asyncio.sleep(5) # Run every 5 seconds
# Sync playback positions for active stations
for station_id, station in self.active_stations.items():
if station.is_active:
await self._sync_station_playback(station_id)
except Exception as e:
logger.error(f"Error in sync loop: {e}")
async def _sync_station_playback(self, station_id: str) -> None:
"""Synchronize playback for a station"""
try:
listeners = self.station_listeners.get(station_id, set())
if len(listeners) <= 1:
return # No sync needed for single listener
# Calculate average playback position
positions = []
for device_id in listeners:
device = self.connected_devices.get(device_id)
if device:
positions.append(device.playback_position)
if positions:
avg_position = sum(positions) / len(positions)
# Sync devices that are significantly out of sync
sync_threshold = 2.0 # 2 seconds
for device_id in listeners:
device = self.connected_devices.get(device_id)
if device and abs(device.playback_position - avg_position) > sync_threshold:
# Send sync command
sync_event = SyncEvent(
event_type='sync_position',
station_id=station_id,
user_id=device.user_id,
timestamp=datetime.utcnow(),
data={'position': avg_position},
device_id='server'
)
await self._send_event_to_device(device_id, sync_event)
except Exception as e:
logger.error(f"Error syncing station playback: {e}")
async def get_station_status(self, station_id: str) -> Optional[Dict]:
"""Get station status information"""
try:
station = self.active_stations.get(station_id)
if not station:
return None
listeners = self.station_listeners.get(station_id, set())
listener_info = []
for device_id in listeners:
device = self.connected_devices.get(device_id)
if device:
listener_info.append({
'device_id': device_id,
'device_type': device.device_type,
'playback_position': device.playback_position,
'last_seen': device.last_seen.isoformat()
})
return {
'station': asdict(station),
'listeners': listener_info,
'listener_count': len(listeners),
'is_active': station.is_active
}
except Exception as e:
logger.error(f"Error getting station status: {e}")
return None
async def broadcast_to_user_devices(self, user_id: str, message: Dict) -> None:
"""Broadcast message to all devices for a user"""
try:
for device_id, device in self.connected_devices.items():
if device.user_id == user_id and device.websocket:
await device.websocket.send(json.dumps(message))
except Exception as e:
logger.error(f"Error broadcasting to user devices: {e}")
# Client-side helper for connecting to radio sync
class RadioSyncClient:
"""Client-side radio sync connection"""
def __init__(self, user_id: str, device_id: str, device_type: str = 'web'):
self.user_id = user_id
self.device_id = device_id
self.device_type = device_type
self.websocket = None
self.event_callbacks = {}
self.is_connected = False
async def connect(self, server_url: str = "ws://localhost:8765") -> bool:
"""Connect to radio sync server"""
try:
self.websocket = await websockets.connect(server_url)
# Send authentication
auth_message = {
'user_id': self.user_id,
'device_id': self.device_id,
'device_type': self.device_type
}
await self.websocket.send(json.dumps(auth_message))
# Wait for confirmation
response = await self.websocket.recv()
response_data = json.loads(response)
if response_data.get('type') == 'connected':
self.is_connected = True
# Start message handler
asyncio.create_task(self._message_handler())
logger.info(f"Connected to radio sync server as {self.device_id}")
return True
else:
logger.error(f"Connection failed: {response_data}")
return False
except Exception as e:
logger.error(f"Failed to connect to radio sync server: {e}")
return False
async def disconnect(self) -> None:
"""Disconnect from radio sync server"""
if self.websocket:
await self.websocket.close()
self.websocket = None
self.is_connected = False
logger.info("Disconnected from radio sync server")
async def join_station(self, station_id: str) -> bool:
"""Join a radio station"""
if not self.is_connected:
return False
try:
message = {
'type': 'join_station',
'station_id': station_id
}
await self.websocket.send(json.dumps(message))
return True
except Exception as e:
logger.error(f"Failed to join station: {e}")
return False
async def leave_station(self) -> bool:
"""Leave current radio station"""
if not self.is_connected:
return False
try:
message = {
'type': 'leave_station'
}
await self.websocket.send(json.dumps(message))
return True
except Exception as e:
logger.error(f"Failed to leave station: {e}")
return False
async def send_playback_event(self, station_id: str, event_type: str, data: Dict) -> bool:
"""Send playback event"""
if not self.is_connected:
return False
try:
message = {
'type': 'playback_event',
'station_id': station_id,
'event_type': event_type,
'data': data
}
await self.websocket.send(json.dumps(message))
return True
except Exception as e:
logger.error(f"Failed to send playback event: {e}")
return False
def add_event_callback(self, event_type: str, callback: callable) -> None:
"""Add callback for specific event types"""
if event_type not in self.event_callbacks:
self.event_callbacks[event_type] = []
self.event_callbacks[event_type].append(callback)
async def _message_handler(self) -> None:
"""Handle incoming messages"""
try:
async for message in self.websocket:
data = json.loads(message)
message_type = data.get('type')
if message_type == 'sync_event':
event = data['event']
event_type = event['event_type']
# Call callbacks
if event_type in self.event_callbacks:
for callback in self.event_callbacks[event_type]:
await callback(event)
elif message_type == 'current_track':
# Handle current track update
if 'current_track' in self.event_callbacks:
for callback in self.event_callbacks['current_track']:
await callback(data)
elif message_type == 'error':
logger.error(f"Server error: {data['message']}")
except Exception as e:
logger.error(f"Error in message handler: {e}")
self.is_connected = False
async def send_heartbeat(self) -> None:
"""Send heartbeat to maintain connection"""
if self.is_connected:
try:
await self.websocket.send(json.dumps({'type': 'heartbeat'}))
except Exception as e:
logger.error(f"Failed to send heartbeat: {e}")
self.is_connected = False
+607
View File
@@ -0,0 +1,607 @@
# swingmusic/services/real_time_audio.py
import numpy as np
import librosa
import sounddevice as sd
from typing import Dict, List, Callable, Optional, Tuple
from dataclasses import dataclass
from threading import Thread, Event
import queue
import logging
from scipy import signal
from scipy.io import wavfile
logger = logging.getLogger(__name__)
@dataclass
class AudioConfig:
"""Audio processing configuration"""
sample_rate: int = 44100
buffer_size: int = 1024
channels: int = 2
dtype: str = 'float32'
block_size: int = 512
hop_length: int = 256
@dataclass
class AudioFeatures:
"""Real-time audio features"""
rms_energy: float
zero_crossing_rate: float
spectral_centroid: float
spectral_bandwidth: float
spectral_rolloff: float
mfcc: np.ndarray
chroma: np.ndarray
tempo: float
beat_phase: float
key_strength: np.ndarray
@dataclass
class AudioEvent:
"""Audio event for callbacks"""
timestamp: float
features: AudioFeatures
audio_data: np.ndarray
event_type: str
class RealTimeAudioProcessor:
"""Real-time audio processing engine for DJ features"""
def __init__(self, config: Optional[AudioConfig] = None):
self.config = config or AudioConfig()
self.is_running = False
self.audio_queue = queue.Queue()
self.feature_queue = queue.Queue()
self.event_callbacks: List[Callable] = []
# Audio processing components
self.beat_tracker = BeatTracker(self.config)
self.key_detector = KeyDetector(self.config)
self.effects_processor = EffectsProcessor(self.config)
# Threading
self.processing_thread = None
self.callback_thread = None
self.stop_event = Event()
# Audio buffers
self.input_buffer = np.zeros((self.config.buffer_size * 4, self.config.channels))
self.output_buffer = np.zeros((self.config.buffer_size * 4, self.config.channels))
self.buffer_index = 0
def add_event_callback(self, callback: Callable[[AudioEvent], None]):
"""Add callback for audio events"""
self.event_callbacks.append(callback)
def remove_event_callback(self, callback: Callable[[AudioEvent], None]):
"""Remove audio event callback"""
if callback in self.event_callbacks:
self.event_callbacks.remove(callback)
def start_processing(self):
"""Start real-time audio processing"""
if self.is_running:
logger.warning("Audio processing already running")
return
self.is_running = True
self.stop_event.clear()
# Start processing threads
self.processing_thread = Thread(target=self._processing_loop, daemon=True)
self.callback_thread = Thread(target=self._callback_loop, daemon=True)
self.processing_thread.start()
self.callback_thread.start()
logger.info("Real-time audio processing started")
def stop_processing(self):
"""Stop real-time audio processing"""
if not self.is_running:
return
self.is_running = False
self.stop_event.set()
# Wait for threads to finish
if self.processing_thread:
self.processing_thread.join(timeout=1.0)
if self.callback_thread:
self.callback_thread.join(timeout=1.0)
logger.info("Real-time audio processing stopped")
def process_audio_chunk(self, audio_data: np.ndarray):
"""Process incoming audio chunk"""
if not self.is_running:
return
try:
# Add to processing queue
self.audio_queue.put(audio_data, block=False)
except queue.Full:
logger.warning("Audio queue full, dropping chunk")
def _processing_loop(self):
"""Main audio processing loop"""
while self.is_running and not self.stop_event.is_set():
try:
# Get audio data with timeout
audio_data = self.audio_queue.get(timeout=0.1)
# Process audio
features = self._extract_features(audio_data)
# Create audio event
event = AudioEvent(
timestamp=self._get_timestamp(),
features=features,
audio_data=audio_data,
event_type='audio_features'
)
# Add to feature queue
self.feature_queue.put(event, block=False)
except queue.Empty:
continue
except Exception as e:
logger.error(f"Audio processing error: {e}")
def _callback_loop(self):
"""Callback processing loop"""
while self.is_running and not self.stop_event.is_set():
try:
# Get event with timeout
event = self.feature_queue.get(timeout=0.1)
# Call all callbacks
for callback in self.event_callbacks:
try:
callback(event)
except Exception as e:
logger.error(f"Callback error: {e}")
except queue.Empty:
continue
except Exception as e:
logger.error(f"Callback loop error: {e}")
def _extract_features(self, audio_data: np.ndarray) -> AudioFeatures:
"""Extract real-time audio features"""
try:
# Convert to mono if needed
if audio_data.shape[1] > 1:
audio_mono = np.mean(audio_data, axis=1)
else:
audio_mono = audio_data.flatten()
# Basic features
rms_energy = np.sqrt(np.mean(audio_mono ** 2))
zero_crossing_rate = librosa.feature.zero_crossing_rate(audio_mono)[0]
# Spectral features
spectral_centroids = librosa.feature.spectral_centroid(
y=audio_mono, sr=self.config.sample_rate
)[0]
spectral_bandwidth = librosa.feature.spectral_bandwidth(
y=audio_mono, sr=self.config.sample_rate
)[0]
spectral_rolloff = librosa.feature.spectral_rolloff(
y=audio_mono, sr=self.config.sample_rate
)[0]
# MFCC
mfcc = librosa.feature.mfcc(
y=audio_mono, sr=self.config.sample_rate, n_mfcc=13
)
# Chroma
chroma = librosa.feature.chroma_stft(
y=audio_mono, sr=self.config.sample_rate
)
# Tempo and beat tracking
tempo, beats = librosa.beat.beat_track(
y=audio_mono, sr=self.config.sample_rate, hop_length=self.config.hop_length
)
beat_phase = self._calculate_beat_phase(beats, len(audio_mono))
# Key strength
key_strength = np.mean(chroma, axis=1)
return AudioFeatures(
rms_energy=float(rms_energy),
zero_crossing_rate=float(np.mean(zero_crossing_rate)),
spectral_centroid=float(np.mean(spectral_centroids)),
spectral_bandwidth=float(np.mean(spectral_bandwidth)),
spectral_rolloff=float(np.mean(spectral_rolloff)),
mfcc=mfcc,
chroma=chroma,
tempo=float(tempo),
beat_phase=float(beat_phase),
key_strength=key_strength
)
except Exception as e:
logger.error(f"Feature extraction error: {e}")
# Return default features
return AudioFeatures(
rms_energy=0.0, zero_crossing_rate=0.0, spectral_centroid=0.0,
spectral_bandwidth=0.0, spectral_rolloff=0.0, mfcc=np.zeros((13, 1)),
chroma=np.zeros((12, 1)), tempo=120.0, beat_phase=0.0,
key_strength=np.zeros(12)
)
def _calculate_beat_phase(self, beats: np.ndarray, audio_length: int) -> float:
"""Calculate current beat phase"""
if len(beats) == 0:
return 0.0
# Find the most recent beat
current_frame = audio_length // self.config.hop_length
recent_beats = beats[beats < current_frame]
if len(recent_beats) == 0:
return 0.0
last_beat = recent_beats[-1]
beat_duration = 60.0 / 120.0 # Assume 120 BPM if no tempo detected
# Calculate phase within beat
frames_since_beat = current_frame - last_beat
time_since_beat = frames_since_beat * self.config.hop_length / self.config.sample_rate
phase = (time_since_beat % beat_duration) / beat_duration
return phase
def _get_timestamp(self) -> float:
"""Get current timestamp"""
import time
return time.time()
def apply_real_time_effect(self, audio_data: np.ndarray, effect_type: str,
params: Dict) -> np.ndarray:
"""Apply real-time audio effect"""
return self.effects_processor.process(audio_data, effect_type, params)
class BeatTracker:
"""Real-time beat tracking"""
def __init__(self, config: AudioConfig):
self.config = config
self.tempo_history = []
self.max_history = 10
def track_beat(self, audio_data: np.ndarray) -> Tuple[float, np.ndarray]:
"""Track beats in real-time audio"""
try:
# Convert to mono
if audio_data.shape[1] > 1:
audio_mono = np.mean(audio_data, axis=1)
else:
audio_mono = audio_data.flatten()
# Track tempo and beats
tempo, beats = librosa.beat.beat_track(
y=audio_mono, sr=self.config.sample_rate, hop_length=self.config.hop_length
)
# Update tempo history
self.tempo_history.append(tempo)
if len(self.tempo_history) > self.max_history:
self.tempo_history.pop(0)
# Use median tempo for stability
stable_tempo = np.median(self.tempo_history) if self.tempo_history else tempo
return float(stable_tempo), beats
except Exception as e:
logger.error(f"Beat tracking error: {e}")
return 120.0, np.array([])
class KeyDetector:
"""Real-time key detection"""
def __init__(self, config: AudioConfig):
self.config = config
self.key_history = []
self.max_history = 5
def detect_key(self, audio_data: np.ndarray) -> Tuple[str, float]:
"""Detect key in real-time audio"""
try:
# Convert to mono
if audio_data.shape[1] > 1:
audio_mono = np.mean(audio_data, axis=1)
else:
audio_mono = audio_data.flatten()
# Extract chroma
chroma = librosa.feature.chroma_stft(
y=audio_mono, sr=self.config.sample_rate
)
# Average chroma
chroma_mean = np.mean(chroma, axis=1)
# Simple key detection (would need more sophisticated implementation)
key_idx = np.argmax(chroma_mean)
key_names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
detected_key = key_names[key_idx]
# Calculate confidence
confidence = np.max(chroma_mean) / np.sum(chroma_mean) if np.sum(chroma_mean) > 0 else 0.0
# Update history
self.key_history.append((detected_key, confidence))
if len(self.key_history) > self.max_history:
self.key_history.pop(0)
# Use most frequent key
if self.key_history:
keys = [k for k, _ in self.key_history]
most_common_key = max(set(keys), key=keys.count)
avg_confidence = np.mean([c for _, c in self.key_history if k == most_common_key])
return most_common_key, avg_confidence
return detected_key, confidence
except Exception as e:
logger.error(f"Key detection error: {e}")
return 'C', 0.0
class EffectsProcessor:
"""Real-time audio effects processor"""
def __init__(self, config: AudioConfig):
self.config = config
def process(self, audio_data: np.ndarray, effect_type: str, params: Dict) -> np.ndarray:
"""Process audio with specified effect"""
try:
if effect_type == 'reverb':
return self._apply_reverb(audio_data, params)
elif effect_type == 'delay':
return self._apply_delay(audio_data, params)
elif effect_type == 'filter':
return self._apply_filter(audio_data, params)
elif effect_type == 'eq':
return self._apply_eq(audio_data, params)
elif effect_type == 'compressor':
return self._apply_compressor(audio_data, params)
elif effect_type == 'distortion':
return self._apply_distortion(audio_data, params)
else:
return audio_data
except Exception as e:
logger.error(f"Effect processing error: {e}")
return audio_data
def _apply_reverb(self, audio_data: np.ndarray, params: Dict) -> np.ndarray:
"""Apply reverb effect"""
delay_time = params.get('delay_time', 0.03)
decay = params.get('decay', 0.5)
mix = params.get('mix', 0.3)
# Simple reverb using delay and feedback
delay_samples = int(delay_time * self.config.sample_rate)
if delay_samples >= len(audio_data):
return audio_data
# Create delayed version
delayed = np.zeros_like(audio_data)
delayed[delay_samples:] = audio_data[:-delay_samples] * decay
# Mix with original
return audio_data * (1 - mix) + delayed * mix
def _apply_delay(self, audio_data: np.ndarray, params: Dict) -> np.ndarray:
"""Apply delay effect"""
delay_time = params.get('delay_time', 0.25)
feedback = params.get('feedback', 0.4)
mix = params.get('mix', 0.3)
delay_samples = int(delay_time * self.config.sample_rate)
if delay_samples >= len(audio_data):
return audio_data
# Create delayed signal with feedback
delayed = np.zeros_like(audio_data)
delayed[delay_samples:] = audio_data[:-delay_samples]
# Add feedback
for i in range(delay_samples, len(audio_data)):
delayed[i] += delayed[i - delay_samples] * feedback
# Mix with original
return audio_data * (1 - mix) + delayed * mix
def _apply_filter(self, audio_data: np.ndarray, params: Dict) -> np.ndarray:
"""Apply filter effect"""
filter_type = params.get('type', 'lowpass')
cutoff = params.get('cutoff', 1000)
order = params.get('order', 4)
nyquist = self.config.sample_rate / 2
normalized_cutoff = cutoff / nyquist
if filter_type == 'lowpass':
b, a = signal.butter(order, normalized_cutoff, btype='low')
elif filter_type == 'highpass':
b, a = signal.butter(order, normalized_cutoff, btype='high')
elif filter_type == 'bandpass':
low = params.get('low', 500) / nyquist
high = params.get('high', 2000) / nyquist
b, a = signal.butter(order, [low, high], btype='band')
else:
return audio_data
# Apply filter to each channel
filtered = np.zeros_like(audio_data)
for ch in range(audio_data.shape[1]):
filtered[:, ch] = signal.filtfilt(b, a, audio_data[:, ch])
return filtered
def _apply_eq(self, audio_data: np.ndarray, params: Dict) -> np.ndarray:
"""Apply EQ effect"""
# Simple 3-band EQ
low_gain = params.get('low_gain', 0) # dB
mid_gain = params.get('mid_gain', 0) # dB
high_gain = params.get('high_gain', 0) # dB
# Convert dB to linear
low_gain_lin = 10 ** (low_gain / 20)
mid_gain_lin = 10 ** (mid_gain / 20)
high_gain_lin = 10 ** (high_gain / 20)
# Apply simple EQ (would need more sophisticated implementation)
result = audio_data.copy()
# Apply gains (simplified - real EQ would use filters)
result *= (low_gain_lin + mid_gain_lin + high_gain_lin) / 3
return result
def _apply_compressor(self, audio_data: np.ndarray, params: Dict) -> np.ndarray:
"""Apply compressor effect"""
threshold = params.get('threshold', 0.7)
ratio = params.get('ratio', 4)
attack = params.get('attack', 0.003)
release = params.get('release', 0.1)
# Simple compressor implementation
result = audio_data.copy()
for ch in range(audio_data.shape[1]):
channel_data = audio_data[:, ch]
# Calculate envelope
envelope = np.abs(channel_data)
# Apply gain reduction
gain_reduction = np.where(
envelope > threshold,
1 - (envelope - threshold) * (1 - 1/ratio) / envelope,
1.0
)
# Smooth gain reduction
gain_reduction = self._smooth_gain(gain_reduction, attack, release)
# Apply gain reduction
result[:, ch] *= gain_reduction
return result
def _apply_distortion(self, audio_data: np.ndarray, params: Dict) -> np.ndarray:
"""Apply distortion effect"""
drive = params.get('drive', 5)
mix = params.get('mix', 0.5)
# Apply distortion
distorted = np.tanh(audio_data * drive)
# Mix with original
return audio_data * (1 - mix) + distorted * mix
def _smooth_gain(self, gain_reduction: np.ndarray, attack: float, release: float) -> np.ndarray:
"""Smooth gain reduction with attack and release"""
# Simplified gain smoothing
smoothed = np.zeros_like(gain_reduction)
smoothed[0] = gain_reduction[0]
attack_coeff = np.exp(-1.0 / (attack * self.config.sample_rate))
release_coeff = np.exp(-1.0 / (release * self.config.sample_rate))
for i in range(1, len(gain_reduction)):
if gain_reduction[i] < smoothed[i-1]:
# Attack
smoothed[i] = attack_coeff * smoothed[i-1] + (1 - attack_coeff) * gain_reduction[i]
else:
# Release
smoothed[i] = release_coeff * smoothed[i-1] + (1 - release_coeff) * gain_reduction[i]
return smoothed
class AudioStreamManager:
"""Manage audio input/output streams"""
def __init__(self, processor: RealTimeAudioProcessor):
self.processor = processor
self.input_stream = None
self.output_stream = None
def start_input_stream(self, device_id: Optional[int] = None):
"""Start audio input stream"""
try:
self.input_stream = sd.InputStream(
samplerate=self.processor.config.sample_rate,
channels=self.processor.config.channels,
dtype=self.processor.config.dtype,
blocksize=self.processor.config.block_size,
device=device_id,
callback=self._input_callback
)
self.input_stream.start()
logger.info("Audio input stream started")
except Exception as e:
logger.error(f"Failed to start input stream: {e}")
raise
def stop_input_stream(self):
"""Stop audio input stream"""
if self.input_stream:
self.input_stream.stop()
self.input_stream.close()
self.input_stream = None
logger.info("Audio input stream stopped")
def start_output_stream(self, device_id: Optional[int] = None):
"""Start audio output stream"""
try:
self.output_stream = sd.OutputStream(
samplerate=self.processor.config.sample_rate,
channels=self.processor.config.channels,
dtype=self.processor.config.dtype,
blocksize=self.processor.config.block_size,
device=device_id,
callback=self._output_callback
)
self.output_stream.start()
logger.info("Audio output stream started")
except Exception as e:
logger.error(f"Failed to start output stream: {e}")
raise
def stop_output_stream(self):
"""Stop audio output stream"""
if self.output_stream:
self.output_stream.stop()
self.output_stream.close()
self.output_stream = None
logger.info("Audio output stream stopped")
def _input_callback(self, indata, frames, time, status):
"""Audio input callback"""
if status:
logger.warning(f"Input stream status: {status}")
# Process incoming audio
self.processor.process_audio_chunk(indata)
def _output_callback(self, outdata, frames, time, status):
"""Audio output callback"""
if status:
logger.warning(f"Output stream status: {status}")
# Generate output (would need audio source)
outdata.fill(0) # Silence for now