mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-03 20:13:02 +00:00
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:
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user