- 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_bar = 0
|
||||
self._count_in_beat = 0
|
||||
self._count_in_sample = 0
|
||||
self._count_in_total_beats = total_bars * beats_per_bar
|
||||
logger.info("Count-in started: %d bars (%d beats)",
|
||||
total_bars, self._count_in_total_beats)
|
||||
@@ -246,21 +247,35 @@ class Metronome:
|
||||
self._count_in_active = False
|
||||
logger.info("Count-in complete")
|
||||
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
|
||||
offset = samples_until_next_beat
|
||||
|
||||
while offset < buffer_size and current_count_beat < self._count_in_total_beats:
|
||||
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]
|
||||
while current_count_beat < self._count_in_total_beats:
|
||||
beat_sample = current_count_beat * samples_per_beat
|
||||
if beat_sample >= end_sample:
|
||||
break # Beat falls in a future buffer
|
||||
|
||||
if beat_sample >= start_sample:
|
||||
offset = beat_sample - start_sample
|
||||
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
|
||||
offset += samples_per_beat
|
||||
|
||||
self._count_in_sample += buffer_size
|
||||
self._count_in_beat = current_count_beat
|
||||
if current_count_beat >= self._count_in_total_beats:
|
||||
self._count_in_active = False
|
||||
|
||||
+20
-8
@@ -107,8 +107,8 @@ class BackingTrackPlayer:
|
||||
self._playback_position: float = 0.0 # seconds into active track
|
||||
self._count_in_remaining: float = 0.0 # seconds of count-in left
|
||||
|
||||
# Thread safety
|
||||
self._lock = threading.Lock()
|
||||
# Thread safety (reentrant — play() may be called from next_track() etc.)
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# DSP integration
|
||||
self._dsp_engine = self.config.dsp_engine
|
||||
@@ -232,15 +232,20 @@ class BackingTrackPlayer:
|
||||
|
||||
track = pl.current_track
|
||||
if track is None:
|
||||
logger.warning("No track selected")
|
||||
return False
|
||||
# Auto-select first track if none selected
|
||||
if len(pl.tracks) > 0:
|
||||
pl.current_index = 0
|
||||
track = pl.tracks[0]
|
||||
else:
|
||||
logger.warning("No track selected")
|
||||
return False
|
||||
|
||||
# Load if needed
|
||||
if not track.loaded and track.file_path:
|
||||
if not self.load_track(track.id):
|
||||
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)
|
||||
return False
|
||||
|
||||
@@ -373,8 +378,9 @@ class BackingTrackPlayer:
|
||||
logger.info("End of playlist reached")
|
||||
return False
|
||||
|
||||
self._active_track_id = track.id
|
||||
if was_playing:
|
||||
self.play(track.id)
|
||||
self.play(track.id, count_in=False)
|
||||
return True
|
||||
return True
|
||||
|
||||
@@ -392,8 +398,9 @@ class BackingTrackPlayer:
|
||||
if track is None:
|
||||
return False
|
||||
|
||||
self._active_track_id = track.id
|
||||
if was_playing:
|
||||
self.play(track.id)
|
||||
self.play(track.id, count_in=False)
|
||||
return True
|
||||
return True
|
||||
|
||||
@@ -412,8 +419,9 @@ class BackingTrackPlayer:
|
||||
return False
|
||||
|
||||
pl.current_index = index
|
||||
self._active_track_id = track.id
|
||||
if was_playing:
|
||||
self.play(track.id)
|
||||
self.play(track.id, count_in=False)
|
||||
return True
|
||||
return True
|
||||
|
||||
@@ -672,6 +680,10 @@ class BackingTrackPlayer:
|
||||
def active_track(self) -> Track | None:
|
||||
if 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
|
||||
|
||||
@property
|
||||
|
||||
@@ -106,6 +106,11 @@ class PlaylistManager:
|
||||
play_mode=play_mode,
|
||||
)
|
||||
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)
|
||||
return track_id
|
||||
|
||||
|
||||
Reference in New Issue
Block a user