diff --git a/src/backing/metronome.py b/src/backing/metronome.py index caba0c1..01c1568 100644 --- a/src/backing/metronome.py +++ b/src/backing/metronome.py @@ -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 diff --git a/src/backing/player.py b/src/backing/player.py index e9acf42..9adf979 100644 --- a/src/backing/player.py +++ b/src/backing/player.py @@ -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 diff --git a/src/backing/playlist.py b/src/backing/playlist.py index 15f084b..dcc8d38 100644 --- a/src/backing/playlist.py +++ b/src/backing/playlist.py @@ -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 diff --git a/src/network/rest_api.py b/src/network/rest_api.py index 24322bd..fcdfe3b 100644 --- a/src/network/rest_api.py +++ b/src/network/rest_api.py @@ -62,6 +62,7 @@ class MixerStateProvider: self.list_scenes: Optional[Callable[[], list[str]]] = None self.load_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_transport: Optional[Callable[[str], None]] = None @@ -219,6 +220,16 @@ def create_router( ok = state.save_scene(name) 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 ───────────────────────────────────────────────────────── @router.get("/state", response_model=MixerStateSchema) diff --git a/src/network/server.py b/src/network/server.py index 5d6c295..c080d8e 100644 --- a/src/network/server.py +++ b/src/network/server.py @@ -192,6 +192,7 @@ class NetworkServer: sp.list_scenes = lambda: self._engine.automation.list_scenes() sp.load_scene = lambda name: self._engine.load_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_transport = self._handle_transport_command diff --git a/tests/test_backing.py b/tests/test_backing.py index 209a864..aa63a9d 100644 --- a/tests/test_backing.py +++ b/tests/test_backing.py @@ -737,8 +737,9 @@ class TestMetronome: assert m.is_count_in_active() is True # Generate enough buffers to exhaust the count-in - buf_size = 12000 # 0.25s at 48k = 1 beat at 120 BPM - for _ in range(10): # 10 beats = more than 8 + # 8 beats * 24000 samples/beat / 12000 buffer = 16 buffers needed + 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) # Count-in should be complete diff --git a/web/app.js b/web/app.js index 917347c..00efbc8 100644 --- a/web/app.js +++ b/web/app.js @@ -1,294 +1,917 @@ -// RPi Audio Mixer — Web UI Application +// ═══════════════════════════════════════════════════════════════════════ +// RPi Audio Mixer — Web UI Application (SPA) +// Vanilla JS + WebSocket, no external dependencies +// ═══════════════════════════════════════════════════════════════════════ -let ws = null; -let sessionToken = null; -let connected = false; -let channelCount = 16; -let paramState = {}; // channel -> { param_type -> value } +const App = { + // ── State ──────────────────────────────────────────────────────────── + ws: null, + sessionToken: null, + connected: false, + reconnectTimer: null, + paramState: {}, // channel → { param_type → value } + mixerState: null, // full MixerStateSchema from server + currentView: 'mixer', + eqChannel: -1, // currently open EQ modal channel + logExpanded: false, -// ── Logging ──────────────────────────────────────────────────────────── + // ═════════════════════════════════════════════════════════════════════ + // Logging + // ═════════════════════════════════════════════════════════════════════ + _logEntries: 0, + log(msg, cls) { + const el = document.getElementById('log'); + const div = document.createElement('div'); + div.className = 'log-entry ' + (cls || ''); + div.textContent = new Date().toLocaleTimeString() + ' ' + msg; + el.prepend(div); + this._logEntries++; + if (this._logEntries > 200) { + el.lastChild?.remove(); + this._logEntries--; + } + }, -function log(msg, cls) { - const el = document.getElementById('log'); - const div = document.createElement('div'); - div.textContent = new Date().toLocaleTimeString() + ' ' + msg; - div.className = cls || ''; - el.prepend(div); - if (el.children.length > 50) el.lastChild.remove(); -} - -// ── Connection ────────────────────────────────────────────────────────── - -async function login() { - const apiKey = document.getElementById('apikey-input').value; - try { - const resp = await fetch('/api/v1/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey }, - body: JSON.stringify({ api_key: apiKey }), - }); - const data = await resp.json(); - if (data.token) { - sessionToken = data.token; - document.getElementById('connect-btn').disabled = false; - log('Session obtained (expires in ' + data.expires_in + 's)', 'log-info'); - // Auto-connect - connectWS(); + toggleLog() { + this.logExpanded = !this.logExpanded; + const body = document.getElementById('log-body'); + const toggle = document.getElementById('log-toggle'); + if (this.logExpanded) { + body.classList.remove('collapsed'); + toggle.textContent = '📋 Log ▴'; } else { - log('Login failed: ' + JSON.stringify(data), 'log-error'); + body.classList.add('collapsed'); + toggle.textContent = '📋 Log ▾'; } - } catch (e) { - log('Login error: ' + e.message, 'log-error'); - } -} + }, -function connectWS() { - if (!sessionToken) { - log('No session token — login first', 'log-error'); - return; - } - if (ws) ws.close(); - - const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const url = proto + '//' + location.host + '/ws?session=' + sessionToken; - - setStatus('connecting', '● Connecting...'); - - ws = new WebSocket(url); - ws.onopen = () => { - connected = true; - setStatus('connected', '● Connected'); - document.getElementById('connect-btn').disabled = true; - log('WebSocket connected', 'log-state'); - }; - ws.onclose = (e) => { - connected = false; - setStatus('disconnected', '● Disconnected'); - document.getElementById('connect-btn').disabled = false; - log('WebSocket closed: ' + (e.reason || 'code ' + e.code), 'log-error'); - }; - ws.onerror = () => log('WebSocket error', 'log-error'); - ws.onmessage = handleMessage; -} - -function disconnectWS() { - if (ws) ws.close(); -} - -function setStatus(cls, text) { - const el = document.getElementById('conn-status'); - el.className = 'status ' + cls; - el.textContent = text; -} - -// ── Message handling ──────────────────────────────────────────────────── - -function handleMessage(event) { - try { - const msg = JSON.parse(event.data); - switch (msg.type) { - case 'full_state': - handleFullState(msg.payload); - break; - case 'parameter_update': - handleParameterUpdate(msg.payload); - break; - case 'transport_command': - handleTransportUpdate(msg.payload); - break; - case 'error': - log('Server error: ' + msg.payload.message, 'log-error'); - break; - default: - log('Unknown msg type: ' + msg.type, 'log-info'); + // ═════════════════════════════════════════════════════════════════════ + // Connection + // ═════════════════════════════════════════════════════════════════════ + async login() { + const apiKey = document.getElementById('apikey-input').value; + if (!apiKey) { + this.log('No API key entered', 'log-error'); + return; } - } catch (e) { - log('Parse error: ' + e.message, 'log-error'); - } -} + try { + const resp = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey }, + body: JSON.stringify({ api_key: apiKey }), + }); + const data = await resp.json(); + if (data.token) { + this.sessionToken = data.token; + document.getElementById('connect-btn').disabled = false; + this.log('Session obtained (expires ' + data.expires_in + 's)', 'log-state'); + this.connectWS(); + } else { + this.log('Login failed: ' + (data.detail || JSON.stringify(data)), 'log-error'); + } + } catch (e) { + this.log('Login error: ' + e.message, 'log-error'); + } + }, -function handleFullState(state) { - log('Full state received: ' + (state.channels ? state.channels.length : 0) + ' channels', 'log-state'); + connectWS() { + if (!this.sessionToken) { + this.log('No session token — login first', 'log-error'); + return; + } + if (this.ws) { + this.ws.close(); + this.ws = null; + } + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } - if (state.channels) { - channelCount = state.channels.length; - buildChannelStrips(state.channels); - } + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = proto + '//' + location.host + '/ws?session=' + this.sessionToken; - if (state.transport) { - updateTransportUI(state.transport); - } + this.setStatus('connecting', '● Connecting...'); - if (state.master) { - updateMasterUI(state.master); - } -} + this.ws = new WebSocket(url); + this.ws.onopen = () => { + this.connected = true; + this.setStatus('connected', '● Live'); + document.getElementById('connect-btn').disabled = true; + this.log('WebSocket connected', 'log-ws'); + }; + this.ws.onclose = (e) => { + this.connected = false; + this.setStatus('disconnected', '● Offline'); + document.getElementById('connect-btn').disabled = false; + this.log('WS closed: ' + (e.reason || 'code ' + e.code) + ' — reconnecting in 3s', 'log-error'); + // Auto-reconnect after 3s + this.reconnectTimer = setTimeout(() => { + if (this.sessionToken && !this.connected) { + this.log('Reconnecting...', 'log-ws'); + this.connectWS(); + } + }, 3000); + }; + this.ws.onerror = () => { + this.log('WebSocket error', 'log-error'); + }; + this.ws.onmessage = (e) => this.handleMessage(e); + }, -function handleParameterUpdate(payload) { - const { param_type, value, channel } = payload; - if (!paramState[channel]) paramState[channel] = {}; - paramState[channel][param_type] = value; + disconnectWS() { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + this.ws.close(); + this.ws = null; + } + }, - updateChannelUI(channel, param_type, value); + setStatus(cls, text) { + const el = document.getElementById('conn-status'); + el.className = 'status ' + cls; + el.textContent = text; + }, - if (param_type === 'master_volume') { - document.getElementById('master-vol-val').textContent = value.toFixed(1) + ' dB'; - document.getElementById('master-fader').querySelector('input').value = value; - } -} + // ═════════════════════════════════════════════════════════════════════ + // Message handling + // ═════════════════════════════════════════════════════════════════════ + handleMessage(event) { + try { + const msg = JSON.parse(event.data); + switch (msg.type) { + case 'full_state': + this.handleFullState(msg.payload); + break; + case 'parameter_update': + this.handleParameterUpdate(msg.payload); + break; + case 'transport_command': + this.handleTransportUpdate(msg.payload); + break; + case 'error': + this.log('Server: ' + msg.payload.message, 'log-error'); + break; + default: + this.log('Unknown msg: ' + msg.type, 'log-info'); + } + } catch (e) { + this.log('Parse error: ' + e.message, 'log-error'); + } + }, -function handleTransportUpdate(payload) { - updateTransportUI(payload); -} + handleFullState(state) { + this.mixerState = state; + const chCount = state.channels ? state.channels.length : 0; + this.log('State received: ' + chCount + ' channels, ' + + (state.plugins ? state.plugins.length : 0) + ' plugins, ' + + (state.scenes ? state.scenes.length : 0) + ' scenes', 'log-state'); -// ── Channel strip builder ─────────────────────────────────────────────── + // Initialize paramState from channels + if (state.channels) { + state.channels.forEach(ch => { + const i = ch.channel; + if (!this.paramState[i]) this.paramState[i] = {}; + this.paramState[i].volume = ch.volume_db; + this.paramState[i].pan = ch.pan; + this.paramState[i].mute = ch.mute ? 1 : 0; + this.paramState[i].solo = ch.solo ? 1 : 0; + this.paramState[i].gain = ch.gain_db; + this.paramState[i].eq_enable = ch.eq?.enabled ? 1 : 0; + }); + } -function buildChannelStrips(channels) { - const container = document.getElementById('channels'); - container.innerHTML = ''; + if (state.channels) this.buildChannelStrips(state.channels); + if (state.master) this.updateMasterUI(state.master); + if (state.transport) this.updateTransportUI(state.transport); + if (state.plugins) this.buildPluginView(state.plugins); + if (state.routing) this.buildRoutingView(state.routing); + if (state.scenes) this.buildSessionsView(state.scenes); + if (state.uptime_seconds) { + document.getElementById('uptime-display').textContent = + 'up ' + this.formatUptime(state.uptime_seconds); + } + }, - channels.forEach((ch, i) => { - const strip = document.createElement('div'); - strip.className = 'channel-strip'; - strip.id = 'ch-' + i; + handleParameterUpdate(payload) { + const { param_type, value, channel } = payload; + const ch = channel !== undefined ? channel : -1; - const volDb = ch.volume_db !== undefined ? ch.volume_db : 0; + // Update state + if (ch >= 0) { + if (!this.paramState[ch]) this.paramState[ch] = {}; + this.paramState[ch][param_type] = value; + } - strip.innerHTML = ` -

${ch.label || 'CH' + (i + 1)}

-
- - Vol - ${volDb.toFixed(1)} dB -
-
- -
Pan: ${((ch.pan || 0) * 100).toFixed(0)}%
-
-
- - -
- `; - container.appendChild(strip); + // Update UI + this.updateChannelUI(ch, param_type, value); - // Initialize state - if (!paramState[i]) paramState[i] = {}; - paramState[i].mute = ch.mute || false; - paramState[i].solo = ch.solo || false; - paramState[i].volume = volDb; - paramState[i].pan = ch.pan || 0; - }); - - // Update channel count in master section context if needed -} - -function updateChannelUI(channel, paramType, value) { - const strip = document.getElementById('ch-' + channel); - if (!strip) return; - - switch (paramType) { - case 'volume': { - const fader = strip.querySelector('.fader-v input'); - const label = strip.querySelector('.fader-value'); + // Master parameters + if (param_type === 'master_volume') { + document.getElementById('master-vol-val').textContent = Number(value).toFixed(1) + ' dB'; + const fader = document.querySelector('#master-fader input'); if (fader) fader.value = value; - if (label) label.textContent = value.toFixed(1) + ' dB'; - break; } - case 'pan': { - const knob = strip.querySelector('.pan-knob input'); - const label = strip.querySelector('.pan-value'); - if (knob) knob.value = value; - if (label) label.textContent = 'Pan: ' + (value * 100).toFixed(0) + '%'; - break; + if (param_type === 'master_mute') { + document.getElementById('btn-mute-master').classList.toggle('on', value >= 0.5); } - case 'mute': { - const btn = document.getElementById('mute-' + channel); - if (btn) { - btn.className = 'chan-btn' + (value >= 0.5 ? ' muted' : ''); + if (param_type === 'master_dim') { + document.getElementById('btn-dim').classList.toggle('on', value >= 0.5); + } + }, + + handleTransportUpdate(payload) { + this.updateTransportUI(payload); + }, + + // ═════════════════════════════════════════════════════════════════════ + // View routing + // ═════════════════════════════════════════════════════════════════════ + showView(viewName) { + this.currentView = viewName; + + // Update tabs + document.querySelectorAll('.tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.view === viewName); + }); + + // Update views + document.querySelectorAll('.view').forEach(view => { + view.classList.toggle('active', view.id === 'view-' + viewName); + }); + + // Refresh view if we have data + if (this.mixerState) { + switch (viewName) { + case 'routing': + this.buildRoutingView(this.mixerState.routing); + break; + case 'plugins': + this.buildPluginView(this.mixerState.plugins); + this.populatePluginChannelSelect(); + break; + case 'sessions': + this.buildSessionsView(this.mixerState.scenes); + break; } - break; } - case 'solo': { - const btn = document.getElementById('solo-' + channel); - if (btn) { - btn.className = 'chan-btn' + (value >= 0.5 ? ' soloed' : ''); + }, + + // ═════════════════════════════════════════════════════════════════════ + // Channel strips + // ═════════════════════════════════════════════════════════════════════ + buildChannelStrips(channels) { + const container = document.getElementById('channels'); + container.innerHTML = ''; + + channels.forEach((ch, i) => { + const strip = document.createElement('div'); + strip.className = 'channel-strip'; + strip.id = 'ch-' + i; + if (ch.solo) strip.classList.add('soloed'); + if (ch.mute) strip.classList.add('muted'); + + const volDb = ch.volume_db !== undefined ? ch.volume_db : 0; + const pan = ch.pan !== undefined ? ch.pan : 0; + + strip.innerHTML = + `
${ch.label || 'CH' + (i+1)}
` + + `
` + + `
` + + `` + + `Vol` + + `${volDb.toFixed(1)} dB` + + `
` + + `
` + + `` + + `` + + `` + + `
` + + `
` + + `` + + `` + + `
`; + + container.appendChild(strip); + }); + }, + + updateChannelUI(channel, paramType, value) { + if (channel < 0) return; + + const strip = document.getElementById('ch-' + channel); + if (!strip) return; + + switch (paramType) { + case 'volume': { + const fader = strip.querySelector('.fader-container input'); + const valEl = document.getElementById('vol-val-' + channel); + if (fader) fader.value = value; + if (valEl) valEl.textContent = Number(value).toFixed(1) + ' dB'; + break; + } + case 'mute': { + const btn = document.getElementById('mute-' + channel); + const muted = value >= 0.5; + if (btn) btn.classList.toggle('on', muted); + strip.classList.toggle('muted', muted); + break; + } + case 'solo': { + const btn = document.getElementById('solo-' + channel); + const soloed = value >= 0.5; + if (btn) btn.classList.toggle('solo-on', soloed); + strip.classList.toggle('soloed', soloed); + break; + } + case 'pan': { + const btn = document.getElementById('pan-btn-' + channel); + if (btn) btn.title = 'Pan: ' + Number(value).toFixed(2); + break; + } + case 'eq_enable': { + const btn = document.getElementById('eq-btn-' + channel); + if (btn) btn.classList.toggle('eq-on', value >= 0.5); + break; + } + case 'gain': { + // Could show in a tooltip or sub-label + break; } - break; } - } -} + }, -function updateMasterUI(master) { - if (master.volume_db !== undefined) { - document.getElementById('master-fader').querySelector('input').value = master.volume_db; - document.getElementById('master-vol-val').textContent = master.volume_db.toFixed(1) + ' dB'; - } -} + // ═════════════════════════════════════════════════════════════════════ + // Level meters (simulated for demo — real data via OSC bridge later) + // ═════════════════════════════════════════════════════════════════════ + _meterInterval: null, -function updateTransportUI(transport) { - document.getElementById('btn-play').className = 'tbtn' + (transport.playing ? ' active' : ''); - document.getElementById('btn-record').className = 'tbtn' + (transport.recording ? ' active' : ''); - document.getElementById('btn-loop').className = 'tbtn' + (transport.loop ? ' active' : ''); + startMeters() { + if (this._meterInterval) return; + this._meterInterval = setInterval(() => this._updateMeters(), 80); + }, - if (transport.tempo_bpm) { - document.getElementById('tempo-display').textContent = transport.tempo_bpm.toFixed(1) + ' BPM'; - } - if (transport.position_sec !== undefined) { - const secs = Math.floor(transport.position_sec); - const h = Math.floor(secs / 3600); - const m = Math.floor((secs % 3600) / 60); - const s = secs % 60; - document.getElementById('time-display').textContent = - String(h).padStart(2, '0') + ':' + - String(m).padStart(2, '0') + ':' + - String(s).padStart(2, '0'); - } -} + stopMeters() { + if (this._meterInterval) { + clearInterval(this._meterInterval); + this._meterInterval = null; + } + }, -function toggleMute(channel) { - const current = paramState[channel] && paramState[channel].mute ? 0 : 1; - sendParam('mute', current, channel); -} + _updateMeters() { + // Simulate random meter levels if not receiving real data + if (!this.connected) return; -function toggleSolo(channel) { - const current = paramState[channel] && paramState[channel].solo ? 0 : 1; - sendParam('solo', current, channel); -} + // Only update visible channel meters + document.querySelectorAll('.meter-v').forEach(meter => { + const fill = meter.querySelector('.meter-fill'); + if (!fill) return; + // Keep existing value with slight drift for demo + const current = parseFloat(fill.style.height) || 0; + const drift = current + (Math.random() - 0.55) * 3; + const pct = Math.max(0, Math.min(100, drift)); + fill.style.height = pct + '%'; + fill.className = 'meter-fill ' + + (pct > 75 ? 'high' : pct > 40 ? 'mid' : 'low'); + }); + }, -// ── Sending ───────────────────────────────────────────────────────────── + // ═════════════════════════════════════════════════════════════════════ + // Master UI + // ═════════════════════════════════════════════════════════════════════ + updateMasterUI(master) { + if (master.volume_db !== undefined) { + document.getElementById('master-fader').querySelector('input').value = master.volume_db; + document.getElementById('master-vol-val').textContent = master.volume_db.toFixed(1) + ' dB'; + } + document.getElementById('btn-mute-master').classList.toggle('on', !!master.muted); + document.getElementById('btn-dim').classList.toggle('on', !!master.dim_active); -function sendParam(paramType, value, channel) { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - const msg = { - type: 'parameter_update', - payload: { + // Show bus info + const info = document.getElementById('master-buses-info'); + if (info && master.aux_buses) { + info.textContent = master.aux_buses.length + ' aux · ' + + (master.subgroups?.length || 0) + ' sub · ' + + (master.vca_groups?.length || 0) + ' vca'; + } + }, + + // ═════════════════════════════════════════════════════════════════════ + // Transport UI + // ═════════════════════════════════════════════════════════════════════ + updateTransportUI(transport) { + document.getElementById('btn-play').classList.toggle('active', !!transport.playing); + document.getElementById('btn-record').classList.toggle('recording', !!transport.recording); + document.getElementById('btn-loop').classList.toggle('active', !!transport.loop); + + if (transport.tempo_bpm) { + document.getElementById('tempo-display').textContent = + Number(transport.tempo_bpm).toFixed(1) + ' BPM'; + } + if (transport.position_sec !== undefined) { + document.getElementById('time-display').textContent = + this.formatTime(transport.position_sec); + } + }, + + // ═════════════════════════════════════════════════════════════════════ + // EQ Modal + // ═════════════════════════════════════════════════════════════════════ + openEQ(channel) { + this.eqChannel = channel; + const ch = this.mixerState?.channels?.[channel]; + if (!ch) { + this.log('No data for channel ' + channel, 'log-error'); + return; + } + + document.getElementById('eq-modal-title').textContent = + (ch.label || 'CH' + (channel + 1)) + ' — EQ & Dynamics'; + + // EQ enable + const eqEnable = document.getElementById('eq-enable'); + eqEnable.checked = ch.eq?.enabled || false; + eqEnable.onchange = () => App.sendParam('eq_enable', eqEnable.checked ? 1 : 0, channel); + + // EQ bands + this._buildEQBand('eq-bands', channel, ch.eq || {}); + + // Compressor + this._buildDynRows('comp-params', channel, ch.compressor || {}, [ + ['comp_threshold', 'Thresh', -60, 0, 'dB'], + ['comp_ratio', 'Ratio', 1, 20, ':1'], + ['comp_attack', 'Attack', 0.1, 100, 'ms'], + ['comp_release', 'Release', 10, 1000, 'ms'], + ['comp_gain', 'Makeup', -20, 20, 'dB'], + ]); + + // Gate + this._buildDynRows('gate-params', channel, ch.gate || {}, [ + ['gate_threshold', 'Thresh', -80, 0, 'dB'], + ['gate_range', 'Range', -80, 0, 'dB'], + ]); + + document.getElementById('eq-modal').classList.remove('hidden'); + }, + + closeEQ() { + document.getElementById('eq-modal').classList.add('hidden'); + this.eqChannel = -1; + }, + + _buildEQBand(containerId, channel, eq) { + const container = document.getElementById(containerId); + const bands = [ + { id: 'low', label: 'Low', params: [ + ['eq_low_freq', 'Freq', 20, 500, 'Hz'], + ['eq_low_gain', 'Gain', -18, 18, 'dB'], + ['eq_low_q', 'Q', 0.1, 5, ''], + ]}, + { id: 'mid', label: 'Mid', params: [ + ['eq_mid_freq', 'Freq', 200, 8000, 'Hz'], + ['eq_mid_gain', 'Gain', -18, 18, 'dB'], + ['eq_mid_q', 'Q', 0.1, 5, ''], + ]}, + { id: 'high', label: 'High', params: [ + ['eq_high_freq', 'Freq', 1000, 20000, 'Hz'], + ['eq_high_gain', 'Gain', -18, 18, 'dB'], + ['eq_high_q', 'Q', 0.1, 5, ''], + ]}, + ]; + + container.innerHTML = ''; + bands.forEach(band => { + const div = document.createElement('div'); + div.className = 'eq-band'; + const header = document.createElement('div'); + header.className = 'eq-band-header'; + header.textContent = band.label; + div.appendChild(header); + + const rows = document.createElement('div'); + rows.className = 'param-rows'; + band.params.forEach(([pType, name, min, max, unit]) => { + const val = eq[band.id] ? (eq[band.id][pType.replace('eq_' + band.id + '_', '') + '_' + unit.toLowerCase()] || 0) : 0; + // Build default values + const defaults = { + eq_low_freq: 100, eq_low_gain: 0, eq_low_q: 0.71, + eq_mid_freq: 1000, eq_mid_gain: 0, eq_mid_q: 0.71, + eq_high_freq: 5000, eq_high_gain: 0, eq_high_q: 0.71, + }; + const defVal = defaults[pType] || 0; + + const row = this._makeParamRow(pType, name, min, max, defVal, unit, channel); + rows.appendChild(row); + }); + div.appendChild(rows); + container.appendChild(div); + }); + }, + + _buildDynRows(containerId, channel, obj, paramDefs) { + const container = document.getElementById(containerId); + container.innerHTML = ''; + paramDefs.forEach(([pType, name, min, max, unit]) => { + // Map schema field names to param types + const fieldMap = { + comp_threshold: 'threshold_db', comp_ratio: 'ratio', + comp_attack: 'attack_ms', comp_release: 'release_ms', comp_gain: 'makeup_gain_db', + gate_threshold: 'threshold_db', gate_range: 'range_db', + }; + const val = obj[fieldMap[pType]] || 0; + const row = this._makeParamRow(pType, name, min, max, val, unit, channel); + container.appendChild(row); + }); + }, + + _makeParamRow(paramType, name, min, max, value, unit, channel) { + const row = document.createElement('div'); + row.className = 'param-row'; + row.innerHTML = + `${name}` + + `` + + `${Number(value).toFixed(1)}${unit}`; + return row; + }, + + // ═════════════════════════════════════════════════════════════════════ + // Routing View + // ═════════════════════════════════════════════════════════════════════ + buildRoutingView(routing) { + if (!routing) return; + + // Sources panel + const sourcesEl = document.getElementById('routing-sources'); + if (routing.nodes) { + sourcesEl.innerHTML = ''; + const sourceTypes = ['system_input', 'channel_input', 'channel_direct', 'aux_send', 'fx_input']; + routing.nodes.filter(n => sourceTypes.includes(n.type)).forEach(node => { + const div = document.createElement('div'); + div.className = 'routing-node'; + div.draggable = true; + div.dataset.nodeId = node.node_id; + div.innerHTML = `${node.type.replace(/_/g,' ')} ${node.label || node.node_id}`; + div.addEventListener('dragstart', (e) => this._onDragStart(e, node)); + sourcesEl.appendChild(div); + }); + } + + // Destinations panel + const destsEl = document.getElementById('routing-dests'); + if (routing.nodes) { + destsEl.innerHTML = ''; + const destTypes = ['master_input', 'aux_bus', 'aux_return', 'subgroup', 'system_output', 'fx_output']; + routing.nodes.filter(n => destTypes.includes(n.type)).forEach(node => { + const div = document.createElement('div'); + div.className = 'routing-node'; + div.dataset.nodeId = node.node_id; + div.innerHTML = `${node.type.replace(/_/g,' ')} ${node.label || node.node_id}`; + div.addEventListener('dragover', (e) => { e.preventDefault(); div.classList.add('drop-target'); }); + div.addEventListener('dragleave', () => div.classList.remove('drop-target')); + div.addEventListener('drop', (e) => { + e.preventDefault(); + div.classList.remove('drop-target'); + const srcId = e.dataTransfer.getData('text/plain'); + if (srcId && node.node_id) { + this.log('Route: ' + srcId + ' → ' + node.node_id, 'log-param'); + // TODO: send routing WS message when backend supports it + this.sendParam('route_connect', 1, -1, {source: srcId, dest: node.node_id}); + } + }); + destsEl.appendChild(div); + }); + } + + // Matrix/connections panel + const matrixEl = document.getElementById('routing-matrix'); + if (routing.edges) { + matrixEl.innerHTML = ''; + const nodeMap = {}; + if (routing.nodes) routing.nodes.forEach(n => { nodeMap[n.node_id] = n; }); + + routing.edges.forEach(edge => { + const srcLabel = nodeMap[edge.source]?.label || edge.source; + const dstLabel = nodeMap[edge.dest]?.label || edge.dest; + const div = document.createElement('div'); + div.className = 'routing-edge' + (edge.muted ? ' muted' : ''); + div.innerHTML = + `
` + + `${srcLabel}` + + `` + + `${dstLabel}` + + `
` + + `${(edge.gain_db||0).toFixed(1)} dB` + + `
` + + `` + + `
`; + matrixEl.appendChild(div); + }); + + if (routing.edges.length === 0) { + matrixEl.innerHTML = '
No connections — drag sources to destinations
'; + } + } + }, + + _onDragStart(e, node) { + e.dataTransfer.setData('text/plain', node.node_id); + e.target.classList.add('dragging'); + e.target.addEventListener('dragend', () => e.target.classList.remove('dragging'), {once: true}); + }, + + // ═════════════════════════════════════════════════════════════════════ + // Plugins View + // ═════════════════════════════════════════════════════════════════════ + populatePluginChannelSelect() { + const sel = document.getElementById('plugin-channel-select'); + if (!this.mixerState?.channels) return; + sel.innerHTML = ''; + this.mixerState.channels.forEach((ch, i) => { + sel.innerHTML += ``; + }); + }, + + showPluginsForChannel(chIdx) { + const plugins = chIdx === 'all' + ? this.mixerState?.plugins || [] + : (this.mixerState?.plugins || []).filter(p => p.channel === parseInt(chIdx)); + this.buildPluginView(plugins); + }, + + buildPluginView(plugins) { + const container = document.getElementById('plugins-list'); + if (!plugins || plugins.length === 0) { + container.innerHTML = '
No plugins loaded
'; + return; + } + + container.innerHTML = ''; + plugins.forEach(plugin => { + const card = document.createElement('div'); + card.className = 'plugin-card'; + + const chLabel = plugin.channel >= 0 + ? (this.mixerState?.channels?.[plugin.channel]?.label || 'CH' + (plugin.channel + 1)) + : 'Master'; + + card.innerHTML = + `
` + + `
` + + `
${plugin.name}
` + + `
${plugin.role}
` + + `
${chLabel}
` + + `
` + + `` + + `
`; + + // Parameters + if (plugin.params && plugin.params.length > 0) { + const paramsDiv = document.createElement('div'); + paramsDiv.className = 'plugin-params'; + plugin.params.forEach(param => { + const row = document.createElement('div'); + row.className = 'plugin-param'; + row.innerHTML = + `${param.name}` + + `` + + `${(param.value*100).toFixed(0)}%`; + paramsDiv.appendChild(row); + }); + card.appendChild(paramsDiv); + } + + container.appendChild(card); + }); + }, + + togglePluginBypass(pluginId, btn) { + // Send bypass toggle via WS + // The backend will echo back the state update + const currentBypassed = btn.classList.contains('bypassed'); + const newState = currentBypassed ? 0 : 1; + this.sendParam('plugin_bypass', newState, -1, {plugin_id: pluginId}); + // Optimistic UI update + btn.classList.toggle('bypassed'); + btn.textContent = btn.classList.contains('bypassed') ? 'Bypassed' : 'Bypass'; + }, + + sendPluginParam(pluginId, paramIndex, value) { + this.sendParam('plugin_param', value, -1, {plugin_id: pluginId, param_index: paramIndex}); + }, + + // ═════════════════════════════════════════════════════════════════════ + // Sessions View + // ═════════════════════════════════════════════════════════════════════ + buildSessionsView(scenes) { + const container = document.getElementById('sessions-list'); + if (!scenes || scenes.length === 0) { + container.innerHTML = '
No saved sessions. Save current state above.
'; + return; + } + + container.innerHTML = ''; + scenes.forEach(name => { + const card = document.createElement('div'); + card.className = 'session-card'; + card.innerHTML = + `
💾 ${this._escapeHTML(name)}
` + + `
` + + `` + + `` + + `
`; + card.addEventListener('click', (e) => { + if (e.target.tagName === 'BUTTON') return; + this.loadScene(name); + }); + container.appendChild(card); + }); + }, + + saveScene() { + const nameInput = document.getElementById('scene-name-input'); + const name = nameInput.value.trim(); + if (!name) { + this.log('Enter a scene name', 'log-error'); + return; + } + this.sendParam('snapshot_save', 1, -1, {name: name}); + this.log('Saving scene: ' + name, 'log-state'); + nameInput.value = ''; + }, + + loadScene(name) { + this.log('Loading scene: ' + name, 'log-state'); + this.sendParam('snapshot_load', 1, -1, {name: name}); + }, + + deleteScene(name) { + if (!confirm('Delete session "' + name + '"?')) return; + // Delete via REST API + fetch('/api/v1/scenes/' + encodeURIComponent(name), { method: 'DELETE' }) + .then(() => { + this.log('Deleted scene: ' + name, 'log-state'); + // Remove from local state + if (this.mixerState?.scenes) { + this.mixerState.scenes = this.mixerState.scenes.filter(s => s !== name); + this.buildSessionsView(this.mixerState.scenes); + } + }) + .catch(e => this.log('Delete failed: ' + e.message, 'log-error')); + }, + + // ═════════════════════════════════════════════════════════════════════ + // Sending + // ═════════════════════════════════════════════════════════════════════ + sendParam(paramType, value, channel, extra) { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + // Queue or ignore silently — don't spam errors + return; + } + const payload = { param_type: String(paramType), value: parseFloat(value), channel: parseInt(channel), timestamp: Date.now() / 1000, - }, - }; - ws.send(JSON.stringify(msg)); -} + }; + if (extra) Object.assign(payload, extra); -function sendTransport(command) { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - ws.send(JSON.stringify({ - type: 'transport_command', - payload: { command: command }, - })); -} + this.ws.send(JSON.stringify({ + type: 'parameter_update', + payload: payload, + })); + }, -// ── Startup ───────────────────────────────────────────────────────────── + toggleParam(paramType, channel) { + const current = this.paramState[channel]?.[paramType] || 0; + const newVal = current >= 0.5 ? 0 : 1; + this.sendParam(paramType, newVal, channel); + }, -// Auto-login on page load -window.addEventListener('DOMContentLoaded', () => { - log('Mixer UI loaded — login to connect', 'log-info'); - // Auto-login with default key - login(); -}); + sendTransport(command) { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + this.ws.send(JSON.stringify({ + type: 'transport_command', + payload: { command: command }, + })); + }, + + cyclePan(channel) { + const current = this.paramState[channel]?.pan || 0; + // Cycle: center → right → left → center + let newPan; + if (current > -0.1 && current < 0.1) newPan = 1.0; + else if (current > 0.5) newPan = -1.0; + else newPan = 0.0; + this.sendParam('pan', newPan, channel); + }, + + showLocateDialog() { + const timecode = prompt('Go to position (seconds or HH:MM:SS):', '0'); + if (!timecode) return; + let secs; + if (timecode.includes(':')) { + const parts = timecode.split(':').map(Number); + secs = parts.length === 3 + ? parts[0] * 3600 + parts[1] * 60 + parts[2] + : parts[0] * 60 + parts[1]; + } else { + secs = parseFloat(timecode); + } + if (!isNaN(secs) && secs >= 0) { + this.sendParam('locate', secs, -1); + this.log('Locate to ' + this.formatTime(secs), 'log-param'); + } + }, + + showGainDialog(channel) { + const current = this.paramState[channel]?.gain || 0; + const val = prompt('Gain for ' + (this.mixerState?.channels?.[channel]?.label || 'CH'+(channel+1)) + + ' (dB, -20 to +60):', current); + if (val !== null) { + const db = parseFloat(val); + if (!isNaN(db)) this.sendParam('gain', db, channel); + } + }, + + // ═════════════════════════════════════════════════════════════════════ + // Helpers + // ═════════════════════════════════════════════════════════════════════ + formatTime(secs) { + const s = Math.max(0, Math.floor(secs)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const ss = s % 60; + return String(h).padStart(2, '0') + ':' + + String(m).padStart(2, '0') + ':' + + String(ss).padStart(2, '0'); + }, + + formatUptime(secs) { + const s = Math.floor(secs); + if (s < 60) return s + 's'; + if (s < 3600) return Math.floor(s / 60) + 'm'; + return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm'; + }, + + _escapeHTML(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + }, + + _escapeAttr(str) { + return str.replace(/'/g, "\\'").replace(/"/g, '"'); + }, + + // ═════════════════════════════════════════════════════════════════════ + // Startup + // ═════════════════════════════════════════════════════════════════════ + init() { + this.log('RPi Mixer UI loaded', 'log-info'); + this.startMeters(); + + // Auto-login with default key + this.login(); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + switch (e.key) { + case ' ': e.preventDefault(); this.sendTransport('play'); break; + case 'Escape': this.closeEQ(); break; + case '1': this.showView('mixer'); break; + case '2': this.showView('routing'); break; + case '3': this.showView('plugins'); break; + case '4': this.showView('sessions'); break; + case 'ArrowLeft': this.sendTransport('stop'); break; // Stop can be left arrow too + } + }); + + // Close EQ modal on backdrop click + document.getElementById('eq-modal')?.addEventListener('click', (e) => { + if (e.target.classList.contains('modal-backdrop')) this.closeEQ(); + }); + } +}; + +// Boot +window.addEventListener('DOMContentLoaded', () => App.init()); diff --git a/web/index.html b/web/index.html index c01d563..adfa515 100644 --- a/web/index.html +++ b/web/index.html @@ -2,52 +2,156 @@ - + + + + RPi Audio Mixer
+ +
-

RPi Audio Mixer

+

🎚️ RPi Mixer

- ● Disconnected + ● Offline - - + +
+ + + +
- - - - - 120 BPM - 00:00:00 + + + + + + 120.0 BPM + 00:00:00 +
-
-
-
-

Master

-
- - Master - 0.0 dB + +
+ + +
+
+
+
+ +
+
+ + Master + 0.0 dB +
+
+ + + +
+
+
-
- - - +
+ + +
+
+
+

Sources

+
+
+
+

Connections

+
+
+
+

Destinations

+
+
+
+
+ + +
+
+

Channel Plugins

+ +
+
+
+ + +
+
+

Saved Sessions

+
+ + +
+
+
+
+ +
+ + + +
-

Activity Log

-
+
+ 📋 Log ▾ +
+
diff --git a/web/style.css b/web/style.css index d50c8c2..f451716 100644 --- a/web/style.css +++ b/web/style.css @@ -1,284 +1,1100 @@ -/* RPi Audio Mixer — Web UI Styles */ +/* ═══════════════════════════════════════════════════════════════════════ + RPi Audio Mixer — Web UI Styles + Dark theme optimized for stage use, touch-friendly, responsive + ═══════════════════════════════════════════════════════════════════════ */ +/* ── CSS Custom Properties ─────────────────────────────────────────────── */ :root { - --bg: #1a1a2e; - --panel: #16213e; + --bg: #0d0d1a; + --bg-alt: #12122a; + --panel: #181835; + --panel-hover: #1e1e42; + --border: #2a2a50; + --border-active: #4a4a80; --accent: #e94560; - --text: #eee; - --text-dim: #888; - --fader-track: #0f3460; + --accent-dim: #c03050; + --accent-glow: rgba(233, 69, 96, 0.3); + --text: #e8e8f0; + --text-dim: #8888aa; + --text-faint: #555577; + --meter-bg: #1a1a35; + --meter-green: #00cc66; + --meter-yellow: #ffaa00; + --meter-red: #ff3333; + --fader-track: #181835; --fader-thumb: #e94560; - --btn-bg: #0f3460; - --btn-hover: #1a4a7a; - --channel-bg: #1a1a3e; + --btn-bg: #222255; + --btn-hover: #333388; + --btn-active: #e94560; --connected: #00cc66; --disconnected: #ff4444; --warning: #ffaa00; + --success: #00cc66; + --danger: #ff4444; + + /* Touch targets */ + --touch-min: 44px; + + /* Typography */ + --font: 'Segoe UI', system-ui, -apple-system, sans-serif; + --font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace; + --font-size-xs: 0.625rem; + --font-size-sm: 0.75rem; + --font-size-md: 0.875rem; + --font-size-lg: 1rem; + --font-size-xl: 1.25rem; } -* { box-sizing: border-box; margin: 0; padding: 0; } +/* ── Reset ─────────────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } -body { - font-family: 'Segoe UI', system-ui, sans-serif; +html, body { + font-family: var(--font); background: var(--bg); color: var(--text); - height: 100vh; + height: 100%; overflow: hidden; + -webkit-tap-highlight-color: transparent; + -webkit-user-select: none; + user-select: none; + touch-action: manipulation; } +input, button, select, textarea { + font-family: inherit; + font-size: inherit; +} + +/* ── App shell ─────────────────────────────────────────────────────────── */ #app { display: flex; flex-direction: column; height: 100vh; + height: 100dvh; } -/* Toolbar */ +/* ── Toolbar ───────────────────────────────────────────────────────────── */ #toolbar { display: flex; justify-content: space-between; align-items: center; - padding: 0.5rem 1rem; + padding: 0.5rem 0.75rem; background: var(--panel); border-bottom: 2px solid var(--accent); flex-shrink: 0; + min-height: var(--touch-min); } #toolbar h1 { - font-size: 1.2rem; + font-size: var(--font-size-lg); color: var(--accent); + white-space: nowrap; + letter-spacing: -0.5px; } #connection { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.4rem; + flex-wrap: wrap; + justify-content: flex-end; } #connection input { - padding: 0.3rem 0.5rem; + padding: 0.35rem 0.5rem; background: var(--bg); color: var(--text); - border: 1px solid var(--text-dim); + border: 1px solid var(--border); border-radius: 4px; - font-size: 0.85rem; + font-size: var(--font-size-sm); + width: 110px; } -#connection button { - padding: 0.3rem 0.8rem; +#connection input:focus { + outline: none; + border-color: var(--accent); +} + +.btn { + padding: 0.35rem 0.7rem; background: var(--btn-bg); color: var(--text); - border: none; + border: 1px solid var(--border); border-radius: 4px; cursor: pointer; - font-size: 0.85rem; + font-size: var(--font-size-sm); + white-space: nowrap; + min-height: var(--touch-min); + display: inline-flex; + align-items: center; + gap: 0.2rem; } -#connection button:hover { background: var(--btn-hover); } -#connection button:disabled { opacity: 0.5; cursor: not-allowed; } +.btn:hover { background: var(--btn-hover); } +.btn:active { transform: scale(0.97); } +.btn:disabled { opacity: 0.4; cursor: not-allowed; } +.btn-primary { background: var(--accent); border-color: var(--accent); } +.btn-primary:hover { background: var(--accent-dim); } .status { - font-size: 0.85rem; - font-weight: bold; + font-size: var(--font-size-sm); + font-weight: 600; padding: 0.2rem 0.5rem; border-radius: 4px; + white-space: nowrap; } .status.connected { color: var(--connected); } +.status.connecting { color: var(--warning); animation: pulse 1s infinite; } .status.disconnected { color: var(--disconnected); } -.status.connecting { color: var(--warning); } -/* Transport */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* ── Tab bar ───────────────────────────────────────────────────────────── */ +#tabs { + display: flex; + background: var(--bg-alt); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.tab { + flex: 1; + min-width: 70px; + padding: 0.5rem 0.5rem; + background: none; + color: var(--text-dim); + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + font-size: var(--font-size-sm); + font-weight: 500; + white-space: nowrap; + min-height: var(--touch-min); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.3rem; + transition: color 0.15s, border-color 0.15s; +} + +.tab:hover { color: var(--text); } +.tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.tab-icon { font-size: 1rem; } + +/* ── Transport bar ──────────────────────────────────────────────────────── */ #transport-bar { display: flex; align-items: center; gap: 0.3rem; - padding: 0.4rem 1rem; + padding: 0.35rem 0.75rem; background: var(--panel); - border-bottom: 1px solid #333; + border-bottom: 1px solid var(--border); flex-shrink: 0; + min-height: var(--touch-min); + overflow-x: auto; } .tbtn { - padding: 0.3rem 0.8rem; + width: var(--touch-min); + height: var(--touch-min); + padding: 0; background: var(--btn-bg); color: var(--text); - border: none; + border: 1px solid var(--border); border-radius: 4px; cursor: pointer; - font-size: 0.9rem; - min-width: 40px; + font-size: 1.1rem; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; } -.tbtn:hover { background: var(--accent); } -.tbtn.active { background: var(--accent); } +.tbtn:hover { background: var(--btn-hover); } +.tbtn:active { transform: scale(0.93); } +.tbtn.active { background: var(--accent); border-color: var(--accent); } +.tbtn.recording { background: var(--danger); animation: pulse 1.2s infinite; } -#tempo-display, #time-display { - margin-left: 1rem; - font-size: 0.9rem; +.transport-info { + font-size: var(--font-size-sm); color: var(--text-dim); + white-space: nowrap; + margin-left: 0.5rem; + font-variant-numeric: tabular-nums; } -/* Mixer area */ -#mixer { +.transport-info.dimmed { color: var(--text-faint); font-size: var(--font-size-xs); } + +/* ── Main content ──────────────────────────────────────────────────────── */ +#view-container { + flex: 1; + overflow: hidden; + position: relative; + min-height: 0; +} + +.view { + display: none; + height: 100%; + overflow: auto; +} + +.view.active { display: flex; flex-direction: column; } + +/* ── Mixer View ────────────────────────────────────────────────────────── */ +#mixer-scroll { display: flex; flex: 1; overflow-x: auto; overflow-y: hidden; padding: 0.5rem; - gap: 0.5rem; + gap: 0.4rem; + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; min-height: 0; } #channels { display: flex; - gap: 0.5rem; - flex: 1; - overflow-x: auto; + gap: 0.35rem; + flex-shrink: 0; } +/* Channel strip */ .channel-strip { display: flex; flex-direction: column; align-items: center; - background: var(--channel-bg); + background: var(--panel); border-radius: 8px; - padding: 0.5rem; - min-width: 80px; + padding: 0.35rem; + min-width: 72px; + width: 80px; flex-shrink: 0; - gap: 0.3rem; + gap: 0.2rem; + border: 1px solid var(--border); + transition: border-color 0.2s; + position: relative; } -.channel-strip h4 { - font-size: 0.75rem; +.channel-strip.soloed { border-color: var(--warning); } +.channel-strip.muted { opacity: 0.6; } + +.chan-label { + font-size: var(--font-size-xs); color: var(--accent); + font-weight: 600; white-space: nowrap; + text-align: center; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + min-height: 20px; + display: flex; + align-items: center; + justify-content: center; } -.fader-v { +.chan-label:hover { color: var(--text); } + +/* Fader */ +.fader-container { display: flex; flex-direction: column; align-items: center; flex: 1; - min-height: 120px; + min-height: 100px; + gap: 2px; + position: relative; } -.fader-v input[type="range"] { - writing-mode: bt-lr; +.fader-container input[type="range"] { -webkit-appearance: slider-vertical; - width: 24px; - height: 120px; + appearance: slider-vertical; + width: 28px; flex: 1; min-height: 80px; background: var(--fader-track); accent-color: var(--fader-thumb); + cursor: pointer; + margin: 0; + border-radius: 4px; } +.fader-container input[type="range"]:active { accent-color: var(--accent-glow); } + .fader-label { - font-size: 0.7rem; + font-size: 0.6rem; + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.fader-db { + font-size: var(--font-size-xs); color: var(--text-dim); + font-weight: 600; + font-variant-numeric: tabular-nums; + min-height: 16px; } -.fader-value { - font-size: 0.7rem; - color: var(--accent); - font-weight: bold; +/* Meter (vertical) */ +.meter-v { + width: 8px; + height: 100%; + background: var(--meter-bg); + border-radius: 4px; + position: absolute; + right: 2px; + top: 0; + overflow: hidden; + z-index: 1; } +.meter-v .meter-fill { + width: 100%; + height: 0%; + border-radius: 4px; + transition: height 0.08s linear, background 0.3s; + position: absolute; + bottom: 0; +} + +.meter-fill.low { background: var(--meter-green); } +.meter-fill.mid { background: var(--meter-yellow); } +.meter-fill.high { background: var(--meter-red); } + +/* Channel buttons */ .chan-buttons { display: flex; - gap: 0.2rem; + gap: 3px; + width: 100%; } .chan-btn { - padding: 0.15rem 0.4rem; - font-size: 0.65rem; + flex: 1; + padding: 0.2rem; + font-size: 0.6rem; + font-weight: 700; background: var(--btn-bg); color: var(--text); - border: none; + border: 1px solid var(--border); border-radius: 3px; cursor: pointer; + min-height: 28px; + letter-spacing: 0.5px; } .chan-btn:hover { background: var(--btn-hover); } -.chan-btn.muted { background: var(--accent); } -.chan-btn.soloed { background: var(--warning); color: #000; } +.chan-btn:active { transform: scale(0.95); } +.chan-btn.on { background: var(--accent); border-color: var(--accent); } +.chan-btn.solo-on { background: var(--warning); color: #000; border-color: var(--warning); } -.pan-knob { - width: 40px; - text-align: center; +.chan-extra { + display: flex; + gap: 3px; + width: 100%; } -.pan-knob input { width: 100%; accent-color: var(--accent); } -.pan-value { font-size: 0.65rem; color: var(--text-dim); } +.chan-extra-btn { + flex: 1; + padding: 0.15rem; + font-size: 0.55rem; + background: var(--btn-bg); + color: var(--text-dim); + border: 1px solid var(--border); + border-radius: 3px; + cursor: pointer; + min-height: 24px; +} + +.chan-extra-btn:hover { color: var(--text); background: var(--btn-hover); } +.chan-extra-btn.eq-on { color: var(--accent); border-color: var(--accent-dim); } /* Master section */ #master-section { display: flex; flex-direction: column; align-items: center; - background: var(--channel-bg); + background: var(--panel); border-radius: 8px; - padding: 0.5rem; - min-width: 100px; - border-left: 2px solid var(--accent); + padding: 0.35rem; + min-width: 85px; flex-shrink: 0; -} - -#master-section h3 { - font-size: 0.85rem; - color: var(--accent); - margin-bottom: 0.5rem; -} - -#master-section .fader-v input[type="range"] { - height: 160px; -} - -.master-controls { - display: flex; + border: 1px solid var(--border); + border-left: 2px solid var(--accent); gap: 0.2rem; - margin-top: 0.5rem; } -.master-controls button { - padding: 0.2rem 0.5rem; - font-size: 0.7rem; +.section-label { + font-size: var(--font-size-xs); + color: var(--accent); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; +} + +.master-buttons { + display: flex; + gap: 3px; + width: 100%; +} + +.toggle-btn { + flex: 1; + padding: 0.2rem; + font-size: 0.6rem; + font-weight: 700; background: var(--btn-bg); color: var(--text); - border: none; + border: 1px solid var(--border); + border-radius: 3px; + cursor: pointer; + min-height: 28px; +} + +.toggle-btn:hover { background: var(--btn-hover); } +.toggle-btn:active { transform: scale(0.95); } +.toggle-btn.on { background: var(--accent); border-color: var(--accent); } + +.master-buses { + margin-top: 0.3rem; + font-size: var(--font-size-xs); + color: var(--text-faint); + text-align: center; + width: 100%; + overflow: hidden; +} + +/* ── Routing View ──────────────────────────────────────────────────────── */ +.routing-layout { + display: flex; + flex: 1; + gap: 0.5rem; + padding: 0.5rem; + overflow: hidden; + min-height: 0; +} + +.routing-panel { + display: flex; + flex-direction: column; + background: var(--panel); + border-radius: 8px; + border: 1px solid var(--border); + overflow: hidden; +} + +.routing-panel h3 { + font-size: var(--font-size-sm); + color: var(--accent); + padding: 0.5rem; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + text-align: center; +} + +.sources-panel, .dests-panel { + flex: 0 0 180px; + min-width: 100px; +} + +.matrix-panel { + flex: 1; + min-width: 200px; +} + +.routing-list { + flex: 1; + overflow-y: auto; + padding: 0.25rem; +} + +.routing-node { + padding: 0.4rem 0.5rem; + margin: 2px 0; + background: var(--btn-bg); + border-radius: 4px; + cursor: grab; + font-size: var(--font-size-xs); + color: var(--text); + border: 1px solid transparent; + min-height: var(--touch-min); + display: flex; + align-items: center; + gap: 0.3rem; +} + +.routing-node:hover { border-color: var(--accent); } +.routing-node:active { cursor: grabbing; } +.routing-node.dragging { opacity: 0.5; } +.routing-node.drop-target { border-color: var(--accent); background: var(--accent-glow); } + +.routing-node-type { + font-size: 0.55rem; + color: var(--text-faint); + text-transform: uppercase; +} + +.routing-grid { + flex: 1; + overflow: auto; + padding: 0.5rem; +} + +.routing-edge { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.3rem 0.5rem; + margin: 2px 0; + background: var(--bg-alt); + border-radius: 4px; + font-size: var(--font-size-xs); + border: 1px solid var(--border); + min-height: 32px; + gap: 0.5rem; +} + +.routing-edge:hover { border-color: var(--accent-dim); } + +.routing-edge-path { + flex: 1; + display: flex; + align-items: center; + gap: 0.3rem; + overflow: hidden; +} + +.routing-edge-path .arrow { color: var(--accent); flex-shrink: 0; } +.routing-edge-path .src { color: var(--text); } +.routing-edge-path .dst { color: var(--text-dim); } + +.routing-edge-actions { + display: flex; + gap: 3px; +} + +.routing-edge-actions button { + padding: 0.15rem 0.4rem; + font-size: 0.55rem; + background: var(--btn-bg); + color: var(--text-dim); + border: 1px solid var(--border); border-radius: 3px; cursor: pointer; } -.master-controls button:hover { background: var(--btn-hover); } -.master-controls button.active { background: var(--accent); } +.routing-edge-actions button:hover { color: var(--text); background: var(--btn-hover); } -/* Log panel */ -#log-panel { - background: var(--panel); - border-top: 1px solid #333; +.routing-edge.muted { opacity: 0.4; } + +/* ── Plugins View ──────────────────────────────────────────────────────── */ +.plugins-header { + display: flex; + align-items: center; + gap: 0.5rem; padding: 0.5rem; + border-bottom: 1px solid var(--border); flex-shrink: 0; - max-height: 150px; - overflow: hidden; + flex-wrap: wrap; } -#log-panel h3 { - font-size: 0.8rem; +.plugins-header h3 { + font-size: var(--font-size-md); + color: var(--accent); +} + +.plugins-grid { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.5rem; + align-content: start; +} + +.plugin-card { + background: var(--panel); + border-radius: 8px; + border: 1px solid var(--border); + padding: 0.5rem; +} + +.plugin-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.4rem; +} + +.plugin-card-name { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--text); +} + +.plugin-card-role { + font-size: var(--font-size-xs); + color: var(--text-faint); + text-transform: uppercase; +} + +.plugin-card-channel { + font-size: var(--font-size-xs); + color: var(--accent); +} + +.plugin-bypass-btn { + padding: 0.2rem 0.5rem; + font-size: var(--font-size-xs); + background: var(--btn-bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 3px; + cursor: pointer; + min-height: 28px; +} + +.plugin-bypass-btn:hover { background: var(--btn-hover); } +.plugin-bypass-btn.bypassed { background: var(--danger); border-color: var(--danger); } + +.plugin-params { + display: flex; + flex-direction: column; + gap: 4px; +} + +.plugin-param { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.plugin-param-name { + font-size: var(--font-size-xs); color: var(--text-dim); + width: 70px; + flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.plugin-param input[type="range"] { + flex: 1; + accent-color: var(--accent); + height: 16px; +} + +.plugin-param-value { + font-size: var(--font-size-xs); + color: var(--text); + width: 40px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* ── Sessions View ─────────────────────────────────────────────────────── */ +.sessions-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + flex-wrap: wrap; + gap: 0.5rem; +} + +.sessions-header h3 { + font-size: var(--font-size-md); + color: var(--accent); +} + +.sessions-actions { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.text-input { + padding: 0.35rem 0.5rem; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + font-size: var(--font-size-sm); + min-height: var(--touch-min); +} + +.text-input:focus { + outline: none; + border-color: var(--accent); +} + +.sessions-grid { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.4rem; + align-content: start; +} + +.session-card { + background: var(--panel); + border-radius: 8px; + border: 1px solid var(--border); + padding: 0.5rem; + cursor: pointer; + min-height: 80px; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.session-card:hover { border-color: var(--accent-dim); } +.session-card.loading { opacity: 0.5; pointer-events: none; } + +.session-card-name { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--text); +} + +.session-card-actions { + display: flex; + gap: 3px; + margin-top: 0.3rem; +} + +.session-card-actions button { + padding: 0.2rem 0.5rem; + font-size: var(--font-size-xs); + background: var(--btn-bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 3px; + cursor: pointer; + min-height: 28px; +} + +.session-card-actions button:hover { background: var(--btn-hover); } +.session-card-actions button.danger { color: var(--danger); } +.session-card-actions button.danger:hover { background: var(--danger); color: var(--text); } + +/* ── Modal (EQ) ────────────────────────────────────────────────────────── */ +.modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.modal.hidden { display: none; } + +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.7); +} + +.modal-content { + position: relative; + background: var(--panel); + border: 1px solid var(--border-active); + border-radius: 12px; + padding: 1rem; + width: min(400px, 95vw); + max-height: 80vh; + overflow-y: auto; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.modal-header h3 { + font-size: var(--font-size-lg); + color: var(--accent); +} + +.modal-close { + width: 32px; + height: 32px; + padding: 0; + background: var(--btn-bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-close:hover { background: var(--danger); } + +.eq-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: var(--font-size-sm); + color: var(--text-dim); + margin-bottom: 0.5rem; +} + +.eq-toggle input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent); +} + +.eq-bands { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.eq-band { + background: var(--bg-alt); + border-radius: 6px; + padding: 0.4rem; + border: 1px solid var(--border); +} + +.eq-band-header { + font-size: var(--font-size-xs); + color: var(--accent); + font-weight: 600; margin-bottom: 0.3rem; } -#log { - font-family: 'Cascadia Code', 'Fira Code', monospace; - font-size: 0.7rem; - color: var(--text-dim); - max-height: 110px; - overflow-y: auto; - line-height: 1.3; +.param-rows { + display: flex; + flex-direction: column; + gap: 4px; } -#log .log-param { color: var(--accent); } -#log .log-state { color: var(--connected); } -#log .log-error { color: var(--disconnected); } -#log .log-info { color: var(--text-dim); } +.param-row { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.param-row-name { + font-size: var(--font-size-xs); + color: var(--text-dim); + width: 50px; + flex-shrink: 0; +} + +.param-row input[type="range"] { + flex: 1; + accent-color: var(--accent); + height: 16px; +} + +.param-row-value { + font-size: var(--font-size-xs); + color: var(--text); + width: 45px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +#dyn-controls h4 { + font-size: var(--font-size-sm); + color: var(--text-dim); + margin: 0.4rem 0 0.2rem; + border-top: 1px solid var(--border); + padding-top: 0.4rem; +} + +/* ── Log panel ─────────────────────────────────────────────────────────── */ +#log-panel { + background: var(--bg-alt); + border-top: 1px solid var(--border); + flex-shrink: 0; + max-height: 40vh; +} + +#log-header { + padding: 0.3rem 0.75rem; + cursor: pointer; + font-size: var(--font-size-xs); + color: var(--text-faint); +} + +#log-header:hover { color: var(--text-dim); } + +#log-body { + max-height: 120px; + overflow-y: auto; + transition: max-height 0.2s; +} + +#log-body.collapsed { max-height: 0; overflow: hidden; } + +#log { + font-family: var(--font-mono); + font-size: 0.65rem; + color: var(--text-dim); + padding: 0 0.75rem 0.5rem; + line-height: 1.4; +} + +.log-entry { + padding: 0.1rem 0; + border-bottom: 1px solid var(--border); +} + +.log-param { color: var(--accent); } +.log-state { color: var(--connected); } +.log-error { color: var(--danger); } +.log-info { color: var(--text-faint); } +.log-ws { color: var(--warning); } + +/* ── Locate dialog ─────────────────────────────────────────────────────── */ +#locate-dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--panel); + border: 1px solid var(--border-active); + border-radius: 12px; + padding: 1rem; + z-index: 500; +} + +#locate-dialog.hidden { display: none; } + +.locate-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.locate-row input { + width: 60px; + padding: 0.3rem; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + font-size: var(--font-size-md); + text-align: center; +} + +.locate-row span { color: var(--text-dim); } + +/* ── Select inputs ─────────────────────────────────────────────────────── */ +select { + padding: 0.35rem 0.5rem; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + font-size: var(--font-size-sm); + min-height: var(--touch-min); + cursor: pointer; +} + +select:focus { outline: none; border-color: var(--accent); } + +/* ═══════════════════════════════════════════════════════════════════════ + RESPONSIVE BREAKPOINTS + ═══════════════════════════════════════════════════════════════════════ */ + +/* Phone portrait (< 480px) */ +@media (max-width: 479px) { + #toolbar { padding: 0.35rem 0.5rem; } + #toolbar h1 { font-size: var(--font-size-sm); } + #connection { gap: 0.2rem; } + #connection input { width: 80px; font-size: var(--font-size-xs); } + .btn { padding: 0.3rem 0.5rem; font-size: var(--font-size-xs); } + + .tab { font-size: 0.65rem; padding: 0.4rem 0.3rem; min-width: 55px; } + .tab-icon { display: none; } + + .channel-strip { min-width: 58px; width: 66px; padding: 0.2rem; } + .fader-container input[type="range"] { width: 22px; } + .meter-v { width: 5px; right: 1px; } + + .routing-layout { flex-direction: column; } + .sources-panel, .dests-panel { flex: 0 0 auto; max-height: 120px; } + + .plugins-grid { grid-template-columns: 1fr; } + .sessions-grid { grid-template-columns: 1fr; } + + #transport-bar { gap: 0.15rem; padding: 0.25rem 0.4rem; } + .tbtn { width: 36px; height: 36px; font-size: 0.9rem; } + .transport-info { font-size: var(--font-size-xs); margin-left: 0.2rem; } + + .modal-content { width: 95vw; padding: 0.75rem; } +} + +/* Phone landscape / small tablet (480px - 767px) */ +@media (min-width: 480px) and (max-width: 767px) { + .channel-strip { min-width: 64px; width: 74px; } + + .routing-layout { flex-direction: row; flex-wrap: wrap; } + .sources-panel, .dests-panel { flex: 1 1 120px; max-height: 200px; } + .matrix-panel { flex: 2 1 200px; } + + .plugins-grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); } +} + +/* Tablet (768px - 1023px) */ +@media (min-width: 768px) and (max-width: 1023px) { + .channel-strip { min-width: 76px; width: 84px; } + .plugins-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } + .sessions-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } +} + +/* Desktop (1024px+) */ +@media (min-width: 1024px) { + .channel-strip { min-width: 80px; width: 88px; } + .plugins-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); } + .sessions-grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); } + .routing-layout { max-width: 1400px; margin: 0 auto; } +} + +/* ── High contrast / accessibility ─────────────────────────────────────── */ +@media (prefers-contrast: high) { + :root { + --border: #555; + --border-active: #888; + --text-dim: #bbb; + } +} + +/* ── Reduced motion ────────────────────────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +}