- Complete SPA rewrite: 4 tabs (Mixer, Routing, Plugins, Sessions)
- Touch-optimized faders with animated level meters (dark theme)
- Channel strips: fader, mute, solo, pan, EQ modal with 3-band EQ + compressor + gate
- Routing matrix: drag-and-drop source→destination, connection list with mute toggles
- Plugin control: per-channel plugin cards with bypass and parameter sliders
- Session browser: list/load/save/delete scenes via REST API
- Transport controls: play, stop, record, loop, locate (keyboard shortcuts: space=play)
- Responsive: phone portrait (touch-first), landscape, tablet, desktop
- Zero dependencies: vanilla JS + WebSocket only
- Backend: added DELETE /api/v1/scenes/{name} endpoint, wired delete_scene callback
web/app.js (917 lines), web/style.css (1100 lines), web/index.html (160 lines)
This commit is contained in:
+26
-11
@@ -157,6 +157,7 @@ class Metronome:
|
|||||||
self._count_in_active = True
|
self._count_in_active = True
|
||||||
self._count_in_bar = 0
|
self._count_in_bar = 0
|
||||||
self._count_in_beat = 0
|
self._count_in_beat = 0
|
||||||
|
self._count_in_sample = 0
|
||||||
self._count_in_total_beats = total_bars * beats_per_bar
|
self._count_in_total_beats = total_bars * beats_per_bar
|
||||||
logger.info("Count-in started: %d bars (%d beats)",
|
logger.info("Count-in started: %d bars (%d beats)",
|
||||||
total_bars, self._count_in_total_beats)
|
total_bars, self._count_in_total_beats)
|
||||||
@@ -246,21 +247,35 @@ class Metronome:
|
|||||||
self._count_in_active = False
|
self._count_in_active = False
|
||||||
logger.info("Count-in complete")
|
logger.info("Count-in complete")
|
||||||
else:
|
else:
|
||||||
# Generate count-in clicks for beats falling in this buffer
|
# Generate count-in clicks for beats falling in this buffer.
|
||||||
|
# Track absolute sample position across calls for accurate
|
||||||
|
# beat placement regardless of buffer size / tempo alignment.
|
||||||
|
if not hasattr(self, '_count_in_sample'):
|
||||||
|
self._count_in_sample = 0
|
||||||
|
|
||||||
|
start_sample = self._count_in_sample
|
||||||
|
end_sample = start_sample + buffer_size
|
||||||
current_count_beat = self._count_in_beat
|
current_count_beat = self._count_in_beat
|
||||||
offset = samples_until_next_beat
|
|
||||||
|
|
||||||
while offset < buffer_size and current_count_beat < self._count_in_total_beats:
|
while current_count_beat < self._count_in_total_beats:
|
||||||
cb = (current_count_beat % beats_per_bar) + 1
|
beat_sample = current_count_beat * samples_per_beat
|
||||||
click = self.generate_click(beat=cb, bar=1, is_count_in=True)
|
if beat_sample >= end_sample:
|
||||||
if len(click) > 0:
|
break # Beat falls in a future buffer
|
||||||
end = min(offset + len(click), buffer_size)
|
|
||||||
copy_len = end - offset
|
if beat_sample >= start_sample:
|
||||||
if copy_len > 0:
|
offset = beat_sample - start_sample
|
||||||
output[offset:end] += click[:copy_len]
|
cb = (current_count_beat % beats_per_bar) + 1
|
||||||
|
click = self.generate_click(beat=cb, bar=1,
|
||||||
|
is_count_in=True)
|
||||||
|
if len(click) > 0:
|
||||||
|
end = min(offset + len(click), buffer_size)
|
||||||
|
copy_len = end - offset
|
||||||
|
if copy_len > 0:
|
||||||
|
output[offset:end] += click[:copy_len]
|
||||||
|
|
||||||
current_count_beat += 1
|
current_count_beat += 1
|
||||||
offset += samples_per_beat
|
|
||||||
|
|
||||||
|
self._count_in_sample += buffer_size
|
||||||
self._count_in_beat = current_count_beat
|
self._count_in_beat = current_count_beat
|
||||||
if current_count_beat >= self._count_in_total_beats:
|
if current_count_beat >= self._count_in_total_beats:
|
||||||
self._count_in_active = False
|
self._count_in_active = False
|
||||||
|
|||||||
+20
-8
@@ -107,8 +107,8 @@ class BackingTrackPlayer:
|
|||||||
self._playback_position: float = 0.0 # seconds into active track
|
self._playback_position: float = 0.0 # seconds into active track
|
||||||
self._count_in_remaining: float = 0.0 # seconds of count-in left
|
self._count_in_remaining: float = 0.0 # seconds of count-in left
|
||||||
|
|
||||||
# Thread safety
|
# Thread safety (reentrant — play() may be called from next_track() etc.)
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
# DSP integration
|
# DSP integration
|
||||||
self._dsp_engine = self.config.dsp_engine
|
self._dsp_engine = self.config.dsp_engine
|
||||||
@@ -232,15 +232,20 @@ class BackingTrackPlayer:
|
|||||||
|
|
||||||
track = pl.current_track
|
track = pl.current_track
|
||||||
if track is None:
|
if track is None:
|
||||||
logger.warning("No track selected")
|
# Auto-select first track if none selected
|
||||||
return False
|
if len(pl.tracks) > 0:
|
||||||
|
pl.current_index = 0
|
||||||
|
track = pl.tracks[0]
|
||||||
|
else:
|
||||||
|
logger.warning("No track selected")
|
||||||
|
return False
|
||||||
|
|
||||||
# Load if needed
|
# Load if needed
|
||||||
if not track.loaded and track.file_path:
|
if not track.loaded and track.file_path:
|
||||||
if not self.load_track(track.id):
|
if not self.load_track(track.id):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if track_id not in self._audio_cache:
|
if track.id not in self._audio_cache:
|
||||||
logger.warning("Track audio not loaded: %s", track.id)
|
logger.warning("Track audio not loaded: %s", track.id)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -373,8 +378,9 @@ class BackingTrackPlayer:
|
|||||||
logger.info("End of playlist reached")
|
logger.info("End of playlist reached")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
self._active_track_id = track.id
|
||||||
if was_playing:
|
if was_playing:
|
||||||
self.play(track.id)
|
self.play(track.id, count_in=False)
|
||||||
return True
|
return True
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -392,8 +398,9 @@ class BackingTrackPlayer:
|
|||||||
if track is None:
|
if track is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
self._active_track_id = track.id
|
||||||
if was_playing:
|
if was_playing:
|
||||||
self.play(track.id)
|
self.play(track.id, count_in=False)
|
||||||
return True
|
return True
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -412,8 +419,9 @@ class BackingTrackPlayer:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
pl.current_index = index
|
pl.current_index = index
|
||||||
|
self._active_track_id = track.id
|
||||||
if was_playing:
|
if was_playing:
|
||||||
self.play(track.id)
|
self.play(track.id, count_in=False)
|
||||||
return True
|
return True
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -672,6 +680,10 @@ class BackingTrackPlayer:
|
|||||||
def active_track(self) -> Track | None:
|
def active_track(self) -> Track | None:
|
||||||
if self._active_track_id:
|
if self._active_track_id:
|
||||||
return self.playlists.get_track(self._active_track_id)
|
return self.playlists.get_track(self._active_track_id)
|
||||||
|
# Fall back to playlist's current track if nothing actively playing
|
||||||
|
pl = self.playlists.active
|
||||||
|
if pl is not None:
|
||||||
|
return pl.current_track
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ class PlaylistManager:
|
|||||||
play_mode=play_mode,
|
play_mode=play_mode,
|
||||||
)
|
)
|
||||||
pl.tracks.append(track)
|
pl.tracks.append(track)
|
||||||
|
|
||||||
|
# Auto-select first track
|
||||||
|
if pl.current_index < 0 and len(pl.tracks) == 1:
|
||||||
|
pl.current_index = 0
|
||||||
|
|
||||||
logger.info("Added track %s: %s", track_id, track.title)
|
logger.info("Added track %s: %s", track_id, track.title)
|
||||||
return track_id
|
return track_id
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class MixerStateProvider:
|
|||||||
self.list_scenes: Optional[Callable[[], list[str]]] = None
|
self.list_scenes: Optional[Callable[[], list[str]]] = None
|
||||||
self.load_scene: Optional[Callable[[str], bool]] = None
|
self.load_scene: Optional[Callable[[str], bool]] = None
|
||||||
self.save_scene: Optional[Callable[[str], bool]] = None
|
self.save_scene: Optional[Callable[[str], bool]] = None
|
||||||
|
self.delete_scene: Optional[Callable[[str], bool]] = None
|
||||||
self.handle_parameter: Optional[Callable[[str, float, int], None]] = None
|
self.handle_parameter: Optional[Callable[[str, float, int], None]] = None
|
||||||
self.handle_transport: Optional[Callable[[str], None]] = None
|
self.handle_transport: Optional[Callable[[str], None]] = None
|
||||||
|
|
||||||
@@ -219,6 +220,16 @@ def create_router(
|
|||||||
ok = state.save_scene(name)
|
ok = state.save_scene(name)
|
||||||
return APIResponse(ok=True, data={"scene": name, "saved": ok})
|
return APIResponse(ok=True, data={"scene": name, "saved": ok})
|
||||||
|
|
||||||
|
@router.delete("/scenes/{name}", response_model=APIResponse)
|
||||||
|
async def delete_scene(name: str):
|
||||||
|
"""Delete a saved scene."""
|
||||||
|
if not state.delete_scene:
|
||||||
|
raise HTTPException(status_code=503, detail="Scenes not available")
|
||||||
|
ok = state.delete_scene(name)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Scene '{name}' not found")
|
||||||
|
return APIResponse(ok=True, data={"scene": name, "deleted": True})
|
||||||
|
|
||||||
# ── Full state ─────────────────────────────────────────────────────────
|
# ── Full state ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/state", response_model=MixerStateSchema)
|
@router.get("/state", response_model=MixerStateSchema)
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ class NetworkServer:
|
|||||||
sp.list_scenes = lambda: self._engine.automation.list_scenes()
|
sp.list_scenes = lambda: self._engine.automation.list_scenes()
|
||||||
sp.load_scene = lambda name: self._engine.load_snapshot(name)
|
sp.load_scene = lambda name: self._engine.load_snapshot(name)
|
||||||
sp.save_scene = lambda name: bool(self._engine.save_snapshot(name))
|
sp.save_scene = lambda name: bool(self._engine.save_snapshot(name))
|
||||||
|
sp.delete_scene = lambda name: self._engine.automation.delete_scene(name)
|
||||||
sp.handle_parameter = self._handle_api_parameter
|
sp.handle_parameter = self._handle_api_parameter
|
||||||
sp.handle_transport = self._handle_transport_command
|
sp.handle_transport = self._handle_transport_command
|
||||||
|
|
||||||
|
|||||||
@@ -737,8 +737,9 @@ class TestMetronome:
|
|||||||
assert m.is_count_in_active() is True
|
assert m.is_count_in_active() is True
|
||||||
|
|
||||||
# Generate enough buffers to exhaust the count-in
|
# Generate enough buffers to exhaust the count-in
|
||||||
buf_size = 12000 # 0.25s at 48k = 1 beat at 120 BPM
|
# 8 beats * 24000 samples/beat / 12000 buffer = 16 buffers needed
|
||||||
for _ in range(10): # 10 beats = more than 8
|
buf_size = 12000 # 0.25s at 48k
|
||||||
|
for _ in range(20): # 20 > 16, safe margin
|
||||||
buf = m.generate_clicks_for_position(0, 120, 1, 1, buf_size)
|
buf = m.generate_clicks_for_position(0, 120, 1, 1, buf_size)
|
||||||
|
|
||||||
# Count-in should be complete
|
# Count-in should be complete
|
||||||
|
|||||||
+875
-252
File diff suppressed because it is too large
Load Diff
+130
-26
@@ -2,52 +2,156 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="theme-color" content="#1a1a2e">
|
||||||
<title>RPi Audio Mixer</title>
|
<title>RPi Audio Mixer</title>
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
|
|
||||||
|
<!-- ═══ Toolbar ═══ -->
|
||||||
<header id="toolbar">
|
<header id="toolbar">
|
||||||
<h1>RPi Audio Mixer</h1>
|
<h1>🎚️ RPi Mixer</h1>
|
||||||
<div id="connection">
|
<div id="connection">
|
||||||
<span id="conn-status" class="status disconnected">● Disconnected</span>
|
<span id="conn-status" class="status disconnected">● Offline</span>
|
||||||
<input type="text" id="apikey-input" placeholder="API Key" value="mixer-local" size="14">
|
<input type="text" id="apikey-input" placeholder="API Key" value="mixer-local" size="14">
|
||||||
<button id="login-btn" onclick="login()">Login</button>
|
<button id="login-btn" class="btn" onclick="App.login()">Login</button>
|
||||||
<button id="connect-btn" onclick="connectWS()" disabled>Connect</button>
|
<button id="connect-btn" class="btn" onclick="App.connectWS()" disabled>Connect</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- ═══ Tab bar ═══ -->
|
||||||
|
<nav id="tabs">
|
||||||
|
<button class="tab active" data-view="mixer" onclick="App.showView('mixer')">
|
||||||
|
<span class="tab-icon">🎛️</span> Mixer
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-view="routing" onclick="App.showView('routing')">
|
||||||
|
<span class="tab-icon">🔀</span> Routing
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-view="plugins" onclick="App.showView('plugins')">
|
||||||
|
<span class="tab-icon">🔌</span> Plugins
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-view="sessions" onclick="App.showView('sessions')">
|
||||||
|
<span class="tab-icon">💾</span> Sessions
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ═══ Transport bar (global) ═══ -->
|
||||||
<div id="transport-bar">
|
<div id="transport-bar">
|
||||||
<button class="tbtn" id="btn-play" onclick="sendTransport('play')">▶ Play</button>
|
<button class="tbtn" id="btn-play" onclick="App.sendTransport('play')" title="Play">▶</button>
|
||||||
<button class="tbtn" id="btn-stop" onclick="sendTransport('stop')">■ Stop</button>
|
<button class="tbtn" id="btn-stop" onclick="App.sendTransport('stop')" title="Stop">■</button>
|
||||||
<button class="tbtn" id="btn-record" onclick="sendTransport('record')">● Rec</button>
|
<button class="tbtn" id="btn-record" onclick="App.sendTransport('record')" title="Record">●</button>
|
||||||
<button class="tbtn" id="btn-loop" onclick="sendTransport('loop')">↻ Loop</button>
|
<button class="tbtn" id="btn-loop" onclick="App.sendTransport('loop')" title="Loop">↻</button>
|
||||||
<span id="tempo-display">120 BPM</span>
|
<button class="tbtn" id="btn-locate" onclick="App.showLocateDialog()" title="Locate">⏱️</button>
|
||||||
<span id="time-display">00:00:00</span>
|
<span class="transport-info" id="tempo-display">120.0 BPM</span>
|
||||||
|
<span class="transport-info" id="time-display">00:00:00</span>
|
||||||
|
<span class="transport-info dimmed" id="uptime-display"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="mixer">
|
<!-- ═══ Main content ═══ -->
|
||||||
<div id="channels"></div>
|
<main id="view-container">
|
||||||
<div id="master-section">
|
|
||||||
<h3>Master</h3>
|
<!-- ── Mixer View ── -->
|
||||||
<div class="fader-v" id="master-fader">
|
<section id="view-mixer" class="view active">
|
||||||
<input type="range" min="-60" max="12" value="0" step="0.5" orient="vertical"
|
<div id="mixer-scroll">
|
||||||
oninput="sendParam('master_volume', this.value, -1)">
|
<div id="channels"></div>
|
||||||
<span class="fader-label">Master</span>
|
<div id="master-section">
|
||||||
<span class="fader-value" id="master-vol-val">0.0 dB</span>
|
<div class="section-label">Master</div>
|
||||||
|
<div class="fader-container" id="master-fader">
|
||||||
|
<div class="meter-v" id="master-meter"><div class="meter-fill"></div></div>
|
||||||
|
<input type="range" min="-60" max="12" value="0" step="0.5" orient="vertical"
|
||||||
|
oninput="App.sendParam('master_volume', this.value, -1)">
|
||||||
|
<span class="fader-label">Master</span>
|
||||||
|
<span class="fader-db" id="master-vol-val">0.0 dB</span>
|
||||||
|
</div>
|
||||||
|
<div class="master-buttons">
|
||||||
|
<button class="toggle-btn" id="btn-mute-master" onclick="App.toggleParam('master_mute', -1)">M</button>
|
||||||
|
<button class="toggle-btn" id="btn-dim" onclick="App.toggleParam('master_dim', -1)">Dim</button>
|
||||||
|
<button class="toggle-btn" id="btn-mono" onclick="App.toggleParam('master_mono', -1)">Mono</button>
|
||||||
|
</div>
|
||||||
|
<div class="master-buses" id="master-buses-info"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="master-controls">
|
</section>
|
||||||
<button id="btn-mute-master" onclick="sendParam('master_mute', 1, -1)">Mute</button>
|
|
||||||
<button id="btn-dim" onclick="sendParam('master_dim', 1, -1)">Dim</button>
|
<!-- ── Routing View ── -->
|
||||||
<button id="btn-mono" onclick="sendParam('master_mono', 1, -1)">Mono</button>
|
<section id="view-routing" class="view">
|
||||||
|
<div class="routing-layout">
|
||||||
|
<div class="routing-panel sources-panel">
|
||||||
|
<h3>Sources</h3>
|
||||||
|
<div id="routing-sources" class="routing-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="routing-panel matrix-panel">
|
||||||
|
<h3>Connections</h3>
|
||||||
|
<div id="routing-matrix" class="routing-grid"></div>
|
||||||
|
</div>
|
||||||
|
<div class="routing-panel dests-panel">
|
||||||
|
<h3>Destinations</h3>
|
||||||
|
<div id="routing-dests" class="routing-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Plugins View ── -->
|
||||||
|
<section id="view-plugins" class="view">
|
||||||
|
<div class="plugins-header">
|
||||||
|
<h3>Channel Plugins</h3>
|
||||||
|
<select id="plugin-channel-select" onchange="App.showPluginsForChannel(this.value)">
|
||||||
|
<option value="all">All Channels</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="plugins-list" class="plugins-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Sessions View ── -->
|
||||||
|
<section id="view-sessions" class="view">
|
||||||
|
<div class="sessions-header">
|
||||||
|
<h3>Saved Sessions</h3>
|
||||||
|
<div class="sessions-actions">
|
||||||
|
<input type="text" id="scene-name-input" placeholder="Scene name" class="text-input">
|
||||||
|
<button class="btn btn-primary" onclick="App.saveScene()">💾 Save Current</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="sessions-list" class="sessions-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ═══ Channel EQ modal ═══ -->
|
||||||
|
<div id="eq-modal" class="modal hidden">
|
||||||
|
<div class="modal-backdrop" onclick="App.closeEQ()"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="eq-modal-title">Channel EQ</h3>
|
||||||
|
<button class="modal-close" onclick="App.closeEQ()">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="eq-controls">
|
||||||
|
<label class="eq-toggle">
|
||||||
|
<input type="checkbox" id="eq-enable" onchange="App.sendParam('eq_enable', this.checked ? 1 : 0, App.eqChannel)">
|
||||||
|
Enable EQ
|
||||||
|
</label>
|
||||||
|
<div class="eq-bands" id="eq-bands"></div>
|
||||||
|
</div>
|
||||||
|
<div id="dyn-controls">
|
||||||
|
<h4>Compressor</h4>
|
||||||
|
<div class="param-rows" id="comp-params"></div>
|
||||||
|
<h4>Noise Gate</h4>
|
||||||
|
<div class="param-rows" id="gate-params"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ Log panel ═══ -->
|
||||||
<div id="log-panel">
|
<div id="log-panel">
|
||||||
<h3>Activity Log</h3>
|
<div id="log-header">
|
||||||
<div id="log"></div>
|
<span id="log-toggle" onclick="App.toggleLog()">📋 Log ▾</span>
|
||||||
|
</div>
|
||||||
|
<div id="log-body" class="collapsed">
|
||||||
|
<div id="log"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+935
-119
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user