306 lines
10 KiB
Python
306 lines
10 KiB
Python
# audio_engine.py
|
|||
|
|
|
||
|
|
import numpy as np
|
||
|
|
import soundfile as sf
|
||
|
|
import jack
|
||
|
|
import threading
|
||
|
|
import subprocess
|
||
|
|
import os
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────
|
||
|
|
# FORMAT LOADER
|
||
|
|
# ─────────────────────────────────────────
|
||
|
|
|
||
|
|
SOUNDFILE_FORMATS = {'.wav', '.flac', '.ogg', '.aiff', '.aif'}
|
||
|
|
FFMPEG_FORMATS = {'.mp3', '.m4a', '.mp4', '.opus', '.aac', '.wma'}
|
||
|
|
|
||
|
|
|
||
|
|
def load_audio(filepath: str, target_sr: int) -> np.ndarray:
|
||
|
|
ext = os.path.splitext(filepath)[1].lower()
|
||
|
|
|
||
|
|
if ext in SOUNDFILE_FORMATS:
|
||
|
|
data, sr = sf.read(filepath, always_2d=True, dtype='float32')
|
||
|
|
data = data.T
|
||
|
|
|
||
|
|
elif ext in FFMPEG_FORMATS:
|
||
|
|
cmd = [
|
||
|
|
'ffmpeg', '-i', filepath, '-f', 'f32le', '-acodec', 'pcm_f32le',
|
||
|
|
'-ar', str(target_sr), '-ac', '2', '-', '-loglevel', 'quiet'
|
||
|
|
]
|
||
|
|
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||
|
|
if result.returncode != 0:
|
||
|
|
raise ValueError(f"FFmpeg failed: {result.stderr.decode()}")
|
||
|
|
raw = np.frombuffer(result.stdout, dtype=np.float32)
|
||
|
|
data = raw.reshape(-1, 2).T
|
||
|
|
sr = target_sr
|
||
|
|
|
||
|
|
else:
|
||
|
|
try:
|
||
|
|
data, sr = sf.read(filepath, always_2d=True, dtype='float32')
|
||
|
|
data = data.T
|
||
|
|
except Exception as e:
|
||
|
|
raise ValueError(f"Unsupported format: {ext} — {e}")
|
||
|
|
|
||
|
|
if sr != target_sr:
|
||
|
|
import librosa
|
||
|
|
data = np.array([
|
||
|
|
librosa.resample(data[ch], orig_sr=sr, target_sr=target_sr)
|
||
|
|
for ch in range(data.shape[0])
|
||
|
|
], dtype=np.float32)
|
||
|
|
|
||
|
|
if data.shape[0] == 1:
|
||
|
|
data = np.vstack([data, data])
|
||
|
|
|
||
|
|
data = np.clip(data, -1.0, 1.0)
|
||
|
|
return data
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────
|
||
|
|
# TRACK
|
||
|
|
# ─────────────────────────────────────────
|
||
|
|
|
||
|
|
class Track:
|
||
|
|
def __init__(self, filepath: str, audio: np.ndarray, sr: int):
|
||
|
|
self.filepath = filepath
|
||
|
|
self.filename = os.path.basename(filepath)
|
||
|
|
self.audio = audio
|
||
|
|
self.sr = sr
|
||
|
|
self.num_samples = audio.shape[1]
|
||
|
|
self.duration = self.num_samples / sr
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────
|
||
|
|
# DECK STATE
|
||
|
|
# ─────────────────────────────────────────
|
||
|
|
|
||
|
|
class DeckState:
|
||
|
|
def __init__(self):
|
||
|
|
self.track = None
|
||
|
|
self.queued = None
|
||
|
|
self.position = 0
|
||
|
|
self.playing = False
|
||
|
|
self.lock = threading.Lock()
|
||
|
|
|
||
|
|
def load(self, track: Track):
|
||
|
|
with self.lock:
|
||
|
|
self.track = track
|
||
|
|
self.position = 0
|
||
|
|
self.playing = False
|
||
|
|
|
||
|
|
def load_queue(self, track: Track):
|
||
|
|
with self.lock:
|
||
|
|
self.queued = track
|
||
|
|
|
||
|
|
def play(self):
|
||
|
|
with self.lock:
|
||
|
|
if self.track:
|
||
|
|
self.playing = True
|
||
|
|
|
||
|
|
def pause(self):
|
||
|
|
with self.lock:
|
||
|
|
self.playing = False
|
||
|
|
|
||
|
|
def stop(self):
|
||
|
|
with self.lock:
|
||
|
|
self.playing = False
|
||
|
|
self.track = None
|
||
|
|
self.position = 0
|
||
|
|
|
||
|
|
def skip_seconds(self, seconds: int):
|
||
|
|
with self.lock:
|
||
|
|
if self.track:
|
||
|
|
new = self.position + int(seconds * self.track.sr)
|
||
|
|
self.position = max(0, min(new, self.track.num_samples - 1))
|
||
|
|
|
||
|
|
def go_to_start(self):
|
||
|
|
with self.lock:
|
||
|
|
self.position = 0
|
||
|
|
|
||
|
|
def go_to_end(self):
|
||
|
|
with self.lock:
|
||
|
|
if self.track:
|
||
|
|
self.position = self.track.num_samples - 1
|
||
|
|
|
||
|
|
def get_elapsed(self) -> str:
|
||
|
|
if not self.track:
|
||
|
|
return "00:00"
|
||
|
|
s = self.position / self.track.sr
|
||
|
|
return f"{int(s//60):02d}:{int(s%60):02d}"
|
||
|
|
|
||
|
|
def get_remaining(self) -> str:
|
||
|
|
if not self.track:
|
||
|
|
return "-00:00"
|
||
|
|
remaining = max(0, self.track.duration - (self.position / self.track.sr))
|
||
|
|
return f"-{int(remaining//60):02d}:{int(remaining%60):02d}"
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────
|
||
|
|
# AUDIO ENGINE
|
||
|
|
# ─────────────────────────────────────────
|
||
|
|
|
||
|
|
class AudioEngine:
|
||
|
|
def __init__(self):
|
||
|
|
self.client = jack.Client("RadioPanel")
|
||
|
|
self.sr = self.client.samplerate
|
||
|
|
|
||
|
|
# Output ports
|
||
|
|
self.ports = {
|
||
|
|
'deck1': (
|
||
|
|
self.client.outports.register("deck1_L"),
|
||
|
|
self.client.outports.register("deck1_R"),
|
||
|
|
),
|
||
|
|
'deck2': (
|
||
|
|
self.client.outports.register("deck2_L"),
|
||
|
|
self.client.outports.register("deck2_R"),
|
||
|
|
),
|
||
|
|
'cart': (
|
||
|
|
self.client.outports.register("cart_L"),
|
||
|
|
self.client.outports.register("cart_R"),
|
||
|
|
),
|
||
|
|
}
|
||
|
|
|
||
|
|
# Input ports for fingerprinting
|
||
|
|
self.inports = {
|
||
|
|
'fingerprint': (
|
||
|
|
self.client.inports.register("fingerprint_L"),
|
||
|
|
self.client.inports.register("fingerprint_R"),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
# Deck states
|
||
|
|
self.decks = {
|
||
|
|
'deck1': DeckState(),
|
||
|
|
'deck2': DeckState(),
|
||
|
|
'cart1': DeckState(),
|
||
|
|
'cart2': DeckState(),
|
||
|
|
'cart3': DeckState(),
|
||
|
|
'cart4': DeckState(),
|
||
|
|
}
|
||
|
|
|
||
|
|
# Recording state
|
||
|
|
self.recording = False
|
||
|
|
self.record_buffer = None
|
||
|
|
self.record_position = 0
|
||
|
|
self.record_target_frames = 0
|
||
|
|
self.record_callback = None
|
||
|
|
|
||
|
|
# VU levels (L, R) 0.0-1.0
|
||
|
|
self.fingerprint_levels = [0.0, 0.0]
|
||
|
|
|
||
|
|
self.client.set_process_callback(self._process)
|
||
|
|
self.client.activate()
|
||
|
|
|
||
|
|
def _fill_port(self, port_L, port_R, deck: DeckState, frames: int):
|
||
|
|
buf_L = port_L.get_array()
|
||
|
|
buf_R = port_R.get_array()
|
||
|
|
buf_L.fill(0.0)
|
||
|
|
buf_R.fill(0.0)
|
||
|
|
|
||
|
|
if not deck.playing or deck.track is None:
|
||
|
|
return
|
||
|
|
|
||
|
|
pos = deck.position
|
||
|
|
track = deck.track
|
||
|
|
end = min(pos + frames, track.num_samples)
|
||
|
|
n = end - pos
|
||
|
|
|
||
|
|
if n <= 0:
|
||
|
|
deck.playing = False
|
||
|
|
return
|
||
|
|
|
||
|
|
buf_L[:n] = track.audio[0, pos:end]
|
||
|
|
buf_R[:n] = track.audio[1, pos:end]
|
||
|
|
deck.position += n
|
||
|
|
|
||
|
|
if deck.position >= track.num_samples:
|
||
|
|
deck.playing = False
|
||
|
|
deck.position = track.num_samples - 1
|
||
|
|
|
||
|
|
def _process(self, frames: int):
|
||
|
|
# Decks
|
||
|
|
self._fill_port(*self.ports['deck1'], self.decks['deck1'], frames)
|
||
|
|
self._fill_port(*self.ports['deck2'], self.decks['deck2'], frames)
|
||
|
|
|
||
|
|
# Mix carts
|
||
|
|
buf_L = self.ports['cart'][0].get_array()
|
||
|
|
buf_R = self.ports['cart'][1].get_array()
|
||
|
|
buf_L.fill(0.0)
|
||
|
|
buf_R.fill(0.0)
|
||
|
|
|
||
|
|
for cart_key in ('cart1', 'cart2', 'cart3', 'cart4'):
|
||
|
|
deck = self.decks[cart_key]
|
||
|
|
if not deck.playing or deck.track is None:
|
||
|
|
continue
|
||
|
|
pos = deck.position
|
||
|
|
track = deck.track
|
||
|
|
end = min(pos + frames, track.num_samples)
|
||
|
|
n = end - pos
|
||
|
|
if n <= 0:
|
||
|
|
deck.playing = False
|
||
|
|
continue
|
||
|
|
buf_L[:n] += track.audio[0, pos:end]
|
||
|
|
buf_R[:n] += track.audio[1, pos:end]
|
||
|
|
deck.position += n
|
||
|
|
if deck.position >= track.num_samples:
|
||
|
|
deck.playing = False
|
||
|
|
deck.position = track.num_samples - 1
|
||
|
|
|
||
|
|
# Fingerprint recording + VU
|
||
|
|
in_L = self.inports['fingerprint'][0].get_array()
|
||
|
|
in_R = self.inports['fingerprint'][1].get_array()
|
||
|
|
|
||
|
|
rms_l = np.sqrt(np.mean(in_L**2)) if len(in_L) > 0 else 0.0
|
||
|
|
rms_r = np.sqrt(np.mean(in_R**2)) if len(in_R) > 0 else 0.0
|
||
|
|
self.fingerprint_levels[0] = min(1.0, rms_l * 8)
|
||
|
|
self.fingerprint_levels[1] = min(1.0, rms_r * 8)
|
||
|
|
|
||
|
|
if self.recording and self.record_buffer is not None:
|
||
|
|
remaining = self.record_target_frames - self.record_position
|
||
|
|
to_record = min(frames, remaining)
|
||
|
|
|
||
|
|
if to_record > 0:
|
||
|
|
self.record_buffer[0, self.record_position:self.record_position + to_record] = in_L[:to_record]
|
||
|
|
self.record_buffer[1, self.record_position:self.record_position + to_record] = in_R[:to_record]
|
||
|
|
self.record_position += to_record
|
||
|
|
|
||
|
|
if self.record_position >= self.record_target_frames:
|
||
|
|
self.recording = False
|
||
|
|
if self.record_callback:
|
||
|
|
audio_copy = self.record_buffer.copy()
|
||
|
|
cb = self.record_callback
|
||
|
|
sr = self.sr
|
||
|
|
self.record_buffer = None
|
||
|
|
threading.Thread(target=cb, args=(audio_copy, sr), daemon=True).start()
|
||
|
|
|
||
|
|
def start_recording(self, duration_seconds: int, callback=None):
|
||
|
|
frames = int(duration_seconds * self.sr)
|
||
|
|
self.record_buffer = np.zeros((2, frames), dtype=np.float32)
|
||
|
|
self.record_position = 0
|
||
|
|
self.record_target_frames = frames
|
||
|
|
self.record_callback = callback
|
||
|
|
self.recording = True
|
||
|
|
|
||
|
|
def stop_recording(self):
|
||
|
|
self.recording = False
|
||
|
|
actual = self.record_buffer[:, :self.record_position].copy()
|
||
|
|
self.record_buffer = None
|
||
|
|
return actual, self.sr
|
||
|
|
|
||
|
|
def load_track(self, deck_key: str, filepath: str):
|
||
|
|
def _load():
|
||
|
|
audio = load_audio(filepath, self.sr)
|
||
|
|
track = Track(filepath, audio, self.sr)
|
||
|
|
self.decks[deck_key].load(track)
|
||
|
|
threading.Thread(target=_load, daemon=True).start()
|
||
|
|
|
||
|
|
def load_queue(self, deck_key: str, filepath: str):
|
||
|
|
def _load():
|
||
|
|
audio = load_audio(filepath, self.sr)
|
||
|
|
track = Track(filepath, audio, self.sr)
|
||
|
|
self.decks[deck_key].load_queue(track)
|
||
|
|
threading.Thread(target=_load, daemon=True).start()
|
||
|
|
|
||
|
|
def shutdown(self):
|
||
|
|
self.client.deactivate()
|
||
|
|
self.client.close()
|