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 =
+ ``;
+
+ // 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
+
+
+
+
+
+
-
-
-
-
- 120 BPM
- 00:00:00
+
+
+
+
+
+ 120.0 BPM
+ 00:00:00
+
-
-
-
-
Master
-
-
-
Master
-
0.0 dB
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Compressor
+
+
Noise Gate
+
+
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;
+ }
+}