commit 5a53c4fc3ff00d1e2c003db19882eb19fad6b08a
parent 4e9662a218da717b7626d46eaefa800582145330
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date: Mon, 1 Jun 2026 14:13:31 -0500
feat: generalize monitor fader to groups; add ADAT 1-8 group
Refactored the monitor-via-mixer fader into reusable MonitorGroup (out/bus/
mixer-in/pcm bases + count) with route_group_via_mixer/route_group_direct/
set_group_level/group_is_via_mixer. MONITOR_GROUPS = Analogue 1-2 (buses 0-1)
and ADAT 1-8 (buses 2-9, PCM 13-20) — non-overlapping so both work at once.
Monitor tab: [ ] switches group, v routes it via mixer, arrows are its fader;
title shows the active group. ADAT is the user's main monitoring path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
3 files changed, 158 insertions(+), 84 deletions(-)
diff --git a/scarlett-core/src/matrix.rs b/scarlett-core/src/matrix.rs
@@ -61,6 +61,43 @@ impl MuxEntry {
}
}
+/// A set of output channels that can be monitored "via the mixer" so a software
+/// fader controls their level. Each channel `i` (0..count) uses output hardware
+/// id `out_id_base+i`, mix bus `bus_base+i`, mixer-input `mix_in_base+i`, fed by
+/// PCM `pcm_base+i`. Bases are chosen so groups don't collide on buses/inputs.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct MonitorGroup {
+ pub name: &'static str,
+ pub out_id_base: u16, // hardware id of first output (e.g. 0x080 analogue, 0x200 ADAT)
+ pub bus_base: u16, // first mix bus index (0-based)
+ pub mix_in_base: u16, // first mixer-input index (0-based)
+ pub pcm_base: u16, // first PCM source index (0-based; matches the DAW channel)
+ pub count: u16, // number of channels
+}
+
+/// The 18i20 g3 monitor groups. Analogue 1-2 on buses 0-1 / mixer-in 0-1 / PCM
+/// 1-2; ADAT 1-8 on buses 2-9 / mixer-in 2-9 / PCM 13-20 (the user's ADAT
+/// monitoring path). Non-overlapping so both can be active at once (10 of 12
+/// buses, 10 of 25 mixer-inputs).
+pub const MONITOR_GROUPS: &[MonitorGroup] = &[
+ MonitorGroup {
+ name: "Analogue 1-2",
+ out_id_base: 0x080,
+ bus_base: 0,
+ mix_in_base: 0,
+ pcm_base: 0, // PCM 1-2
+ count: 2,
+ },
+ MonitorGroup {
+ name: "ADAT 1-8",
+ out_id_base: 0x200,
+ bus_base: 2,
+ mix_in_base: 2,
+ pcm_base: 12, // PCM 13-20
+ count: 8,
+ },
+];
+
/// Convenience operations layered on a connected [`Scarlett`].
impl<T: Transport> Scarlett<T> {
/// Read the input gains feeding mix bus `mix_num`. Returns one raw `u16`
@@ -87,66 +124,83 @@ impl<T: Transport> Scarlett<T> {
Ok(())
}
- /// Route the monitor output pair (Analogue Out 1/2) THROUGH the mixer so its
- /// level is controllable: `Mixer In 1/2 ← PCM 1/2`, `Analogue Out 1/2 ← Mix
- /// A/B`. With [`Self::set_monitor_level`], Mix A/B's gain is the monitor
- /// volume. One atomic routing write. Port numbers derived via mux helpers.
- pub fn route_monitor_via_mixer(
+ /// Route a monitor group's outputs THROUGH the mixer so their level is
+ /// controllable by a software fader: for each channel `i`,
+ /// `MixerIn(mix_in_base+i) ← PCM(pcm_base+i)` and `Out(out_hw+i) ← Mix(bus_base+i)`.
+ /// With [`Self::set_group_level`], the buses' gains are the group's volume.
+ /// One atomic routing write. See [`MonitorGroup`].
+ pub fn route_group_via_mixer(
&mut self,
pc: [(u16, u16); 6],
+ g: &MonitorGroup,
) -> Result<crate::mux::MuxState, TransportError> {
use crate::mux::{id_to_num, Dir};
let n = |dir, id| id_to_num(&pc, dir, id).unwrap_or(0);
- let changes = [
- (n(Dir::Out, 0x300), n(Dir::In, 0x600)), // Mixer In 1 ← PCM 1
- (n(Dir::Out, 0x301), n(Dir::In, 0x601)), // Mixer In 2 ← PCM 2
- (n(Dir::Out, 0x080), n(Dir::In, 0x300)), // Analogue Out 1 ← Mix A
- (n(Dir::Out, 0x081), n(Dir::In, 0x301)), // Analogue Out 2 ← Mix B
- ];
+ let mut changes = Vec::with_capacity(g.count as usize * 2);
+ for i in 0..g.count {
+ // MixerIn(base+i) ← PCM(pcm_base+i)
+ changes.push((
+ n(Dir::Out, 0x300 + g.mix_in_base + i),
+ n(Dir::In, 0x600 + g.pcm_base + i),
+ ));
+ // Out(base+i) ← Mix(bus_base+i)
+ changes.push((
+ n(Dir::Out, g.out_id_base + i),
+ n(Dir::In, 0x300 + g.bus_base + i),
+ ));
+ }
self.set_routes(pc, &changes)
}
- /// Route the monitor pair straight from the DAW again (Analogue Out 1/2 ←
- /// PCM 1/2), undoing [`Self::route_monitor_via_mixer`].
- pub fn route_monitor_direct(
+ /// Route a group's outputs straight from the DAW again
+ /// (`Out(out_hw+i) ← PCM(pcm_base+i)`), undoing [`Self::route_group_via_mixer`].
+ pub fn route_group_direct(
&mut self,
pc: [(u16, u16); 6],
+ g: &MonitorGroup,
) -> Result<crate::mux::MuxState, TransportError> {
use crate::mux::{id_to_num, Dir};
let n = |dir, id| id_to_num(&pc, dir, id).unwrap_or(0);
- let changes = [
- (n(Dir::Out, 0x080), n(Dir::In, 0x600)), // Analogue Out 1 ← PCM 1
- (n(Dir::Out, 0x081), n(Dir::In, 0x601)), // Analogue Out 2 ← PCM 2
- ];
+ let changes: Vec<(u16, u16)> = (0..g.count)
+ .map(|i| (n(Dir::Out, g.out_id_base + i), n(Dir::In, 0x600 + g.pcm_base + i)))
+ .collect();
self.set_routes(pc, &changes)
}
- /// Set the monitor level (dB) when monitoring via the mixer: Mix A passes
- /// only mixer-input 1, Mix B only mixer-input 2, both at `db`; other inputs
- /// on those buses are silenced for a clean monitor path. `inputs` = mixer
- /// input count.
- pub fn set_monitor_level(&mut self, db: f32, inputs: usize) -> Result<(), TransportError> {
+ /// Set a group's level (dB): each of the group's buses passes only its own
+ /// mixer-input at `db`; all other inputs on those buses are silenced for a
+ /// clean monitor path. `inputs` = mixer input count.
+ pub fn set_group_level(
+ &mut self,
+ g: &MonitorGroup,
+ db: f32,
+ inputs: usize,
+ ) -> Result<(), TransportError> {
let v = db_to_mixer_value(db);
- let mut bus_a = vec![0u16; inputs];
- let mut bus_b = vec![0u16; inputs];
- if inputs >= 2 {
- bus_a[0] = v; // Mix A ← mixer input 1
- bus_b[1] = v; // Mix B ← mixer input 2
+ for i in 0..g.count {
+ let mut bus = vec![0u16; inputs];
+ let in_idx = (g.mix_in_base + i) as usize;
+ if in_idx < inputs {
+ bus[in_idx] = v;
+ }
+ self.set_mix(g.bus_base + i, &bus)?;
}
- self.set_mix(0, &bus_a)?;
- self.set_mix(1, &bus_b)?;
Ok(())
}
- /// True if the monitor pair is currently routed via the mixer (Analogue Out 1
- /// fed by Mix A).
- pub fn monitor_is_via_mixer(&mut self, pc: [(u16, u16); 6]) -> Result<bool, TransportError> {
+ /// True if a group is currently routed via the mixer (its first output is fed
+ /// by its first mix bus).
+ pub fn group_is_via_mixer(
+ &mut self,
+ pc: [(u16, u16); 6],
+ g: &MonitorGroup,
+ ) -> Result<bool, TransportError> {
use crate::mux::{id_to_num, Dir, MuxState};
let entries = self.get_mux(crate::mux::num_dsts(&pc))?;
let st = MuxState::from_entries(pc, &entries);
- let out1 = id_to_num(&pc, Dir::Out, 0x080).unwrap_or(0);
- let mix_a = id_to_num(&pc, Dir::In, 0x300).unwrap_or(0);
- Ok(st.get(out1) == mix_a)
+ let out0 = id_to_num(&pc, Dir::Out, g.out_id_base).unwrap_or(0);
+ let bus0 = id_to_num(&pc, Dir::In, 0x300 + g.bus_base).unwrap_or(0);
+ Ok(st.get(out0) == bus0)
}
/// Read the full routing table: `count` destination assignments.
diff --git a/valentine/src/main.rs b/valentine/src/main.rs
@@ -157,12 +157,13 @@ struct App {
modal: Modal,
/// Inputs panel: stereo-pair view (default) vs. one row per channel.
stereo_inputs: bool,
- /// Software monitor level in dB (used when monitoring via the mixer). The
- /// device has no writable output volume, so this is a Mix A/B gain.
- monitor_level_db: f32,
- /// Whether the monitor pair is currently routed through the mixer (so the
- /// software fader is active). Detected on connect / monitor-tab open.
- monitor_via_mixer: bool,
+ /// Which monitor group is selected on the Monitor tab (index into
+ /// `scarlett_core::matrix::MONITOR_GROUPS`: 0 = Analogue 1-2, 1 = ADAT 1-8).
+ monitor_group: usize,
+ /// Per-group software level in dB (the group's mix-bus gain when via mixer).
+ group_level_db: Vec<f32>,
+ /// Per-group: is it currently routed through the mixer (fader active)?
+ group_via_mixer: Vec<bool>,
status: Option<String>,
show_help: bool,
last_poll: Instant,
@@ -189,8 +190,9 @@ impl App {
routing_pending: None,
modal: Modal::None,
stereo_inputs: true,
- monitor_level_db: 0.0,
- monitor_via_mixer: false,
+ monitor_group: 0,
+ group_level_db: vec![0.0; scarlett_core::matrix::MONITOR_GROUPS.len()],
+ group_via_mixer: vec![false; scarlett_core::matrix::MONITOR_GROUPS.len()],
status: None,
show_help: false,
last_poll: Instant::now(),
@@ -287,11 +289,13 @@ impl App {
/// Lazy-load data a freshly-opened tab needs.
fn on_tab_enter(&mut self) {
if self.tab == 1 {
- // Detect whether the monitor pair is currently routed via the mixer.
+ // Detect which monitor groups are currently routed via the mixer.
let pc = scarlett_core::mux::PORT_COUNT_18I20_GEN3;
if let Ok(dev) = &mut self.device {
- self.monitor_via_mixer =
- dev.scarlett.monitor_is_via_mixer(pc).unwrap_or(false);
+ for (i, g) in scarlett_core::matrix::MONITOR_GROUPS.iter().enumerate() {
+ self.group_via_mixer[i] =
+ dev.scarlett.group_is_via_mixer(pc, g).unwrap_or(false);
+ }
}
}
if self.tab == 2 {
@@ -575,79 +579,89 @@ impl App {
}
fn monitor_key(&mut self, code: KeyCode) {
+ let ngroups = scarlett_core::matrix::MONITOR_GROUPS.len();
match code {
KeyCode::Up | KeyCode::Char('k') => self.monitor_cursor.up(),
KeyCode::Down | KeyCode::Char('j') => self.monitor_cursor.down(),
KeyCode::Char(' ') | KeyCode::Enter => self.toggle_monitor_button(),
- // Software monitor fader (works when routed via the mixer).
- KeyCode::Left | KeyCode::Char('-') => self.nudge_monitor(-2.0),
- KeyCode::Right | KeyCode::Char('=') | KeyCode::Char('+') => self.nudge_monitor(2.0),
- // Toggle routing the monitor pair through the mixer (enables the fader).
- KeyCode::Char('v') => self.toggle_monitor_via_mixer(),
+ // Tab/[ ] selects which monitor group the fader controls.
+ KeyCode::Char('[') => {
+ self.monitor_group = self.monitor_group.saturating_sub(1);
+ }
+ KeyCode::Char(']') => {
+ if self.monitor_group + 1 < ngroups {
+ self.monitor_group += 1;
+ }
+ }
+ // Software fader for the selected group (works when via mixer).
+ KeyCode::Left | KeyCode::Char('-') => self.nudge_group(-2.0),
+ KeyCode::Right | KeyCode::Char('=') | KeyCode::Char('+') => self.nudge_group(2.0),
+ // Toggle routing the selected group through the mixer.
+ KeyCode::Char('v') => self.toggle_group_via_mixer(),
_ => {}
}
}
- /// Adjust the software monitor level by `delta` dB (only meaningful when the
- /// monitor pair is routed via the mixer).
- fn nudge_monitor(&mut self, delta: f32) {
- if !self.monitor_via_mixer {
- self.status =
- Some("monitor fader needs mixer routing — press 'v' to enable".into());
+ /// Adjust the selected group's software level by `delta` dB.
+ fn nudge_group(&mut self, delta: f32) {
+ let gi = self.monitor_group;
+ if !self.group_via_mixer.get(gi).copied().unwrap_or(false) {
+ self.status = Some("fader needs mixer routing — press 'v' to enable".into());
return;
}
+ let g = &scarlett_core::matrix::MONITOR_GROUPS[gi];
let inputs = S18I20_GEN3.mixer_inputs() as usize;
- let new_db = (self.monitor_level_db + delta).clamp(scarlett_core::matrix::MIXER_MIN_DB, 0.0);
+ let new_db =
+ (self.group_level_db[gi] + delta).clamp(scarlett_core::matrix::MIXER_MIN_DB, 0.0);
let dev = match &mut self.device {
Ok(d) => d,
Err(_) => return,
};
- match dev.scarlett.set_monitor_level(new_db, inputs) {
+ match dev.scarlett.set_group_level(g, new_db, inputs) {
Ok(()) => {
- self.monitor_level_db = new_db;
+ self.group_level_db[gi] = new_db;
dev.mixer.clear();
- self.status = Some(format!("monitor level {new_db:.0} dB"));
+ self.status = Some(format!("{} level {new_db:.0} dB", g.name));
}
- Err(e) => self.status = Some(format!("monitor set failed: {e}")),
+ Err(e) => self.status = Some(format!("level set failed: {e}")),
}
}
- /// Toggle whether the monitor pair (Analogue Out 1/2) runs through the mixer
- /// (enabling the software fader) or straight from the DAW.
- fn toggle_monitor_via_mixer(&mut self) {
+ /// Toggle whether the selected group runs through the mixer (fader on) or
+ /// straight from the DAW.
+ fn toggle_group_via_mixer(&mut self) {
+ let gi = self.monitor_group;
+ let g = &scarlett_core::matrix::MONITOR_GROUPS[gi];
let pc = scarlett_core::mux::PORT_COUNT_18I20_GEN3;
let inputs = S18I20_GEN3.mixer_inputs() as usize;
- let enable = !self.monitor_via_mixer;
- let level = self.monitor_level_db;
+ let enable = !self.group_via_mixer.get(gi).copied().unwrap_or(false);
+ let level = self.group_level_db[gi];
let dev = match &mut self.device {
Ok(d) => d,
Err(_) => return,
};
let res = if enable {
- // Route via mixer, then set the bus gains to the current fader level.
- dev.scarlett
- .route_monitor_via_mixer(pc)
- .and_then(|st| {
- dev.routing = Some(st);
- dev.scarlett.set_monitor_level(level, inputs)
- })
+ dev.scarlett.route_group_via_mixer(pc, g).and_then(|st| {
+ dev.routing = Some(st);
+ dev.scarlett.set_group_level(g, level, inputs)
+ })
} else {
- dev.scarlett.route_monitor_direct(pc).map(|st| {
+ dev.scarlett.route_group_direct(pc, g).map(|st| {
dev.routing = Some(st);
})
};
match res {
Ok(()) => {
- self.monitor_via_mixer = enable;
+ self.group_via_mixer[gi] = enable;
dev.mixer.clear();
dev.refresh_src_meter();
self.status = Some(if enable {
- "monitor via mixer ON — ←→ adjusts level".into()
+ format!("{} via mixer ON — ←→ adjusts level", g.name)
} else {
- "monitor direct from DAW (fader off)".into()
+ format!("{} direct from DAW (fader off)", g.name)
});
}
- Err(e) => self.status = Some(format!("monitor routing failed: {e}")),
+ Err(e) => self.status = Some(format!("routing failed: {e}")),
}
}
@@ -1083,14 +1097,16 @@ fn ui(f: &mut Frame, app: &App) {
);
}
(Ok(dev), 1) => {
+ let gi = app.monitor_group;
monitor::render(
f,
chunks[2],
t,
&dev.monitor,
app.monitor_cursor,
- app.monitor_via_mixer,
- app.monitor_level_db,
+ scarlett_core::matrix::MONITOR_GROUPS[gi].name,
+ app.group_via_mixer[gi],
+ app.group_level_db[gi],
true,
);
}
diff --git a/valentine/src/panels/monitor.rs b/valentine/src/panels/monitor.rs
@@ -70,13 +70,17 @@ pub fn render(
theme: &Theme,
state: &MonitorState,
cursor: Cursor,
+ group_name: &str,
via_mixer: bool,
soft_db: f32,
focused: bool,
) {
let border = if focused { theme.border_focus } else { theme.border };
let block = Block::default()
- .title(Span::styled(" monitor ", Style::default().fg(theme.accent)))
+ .title(Span::styled(
+ format!(" monitor · {group_name} "),
+ Style::default().fg(theme.accent),
+ ))
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(border));
@@ -179,7 +183,7 @@ pub fn render(
f.render_widget(
Paragraph::new(Span::styled(
- "↑↓ select space toggle ←→ fader v via-mixer",
+ "↑↓ select space toggle ←→ fader v via-mixer [ ] group",
Style::default().fg(theme.fg_dim),
)),
rows[8],