commit 4e9662a218da717b7626d46eaefa800582145330
parent c89c962772742c013271b3b881fc1f8ba948ce51
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date: Mon, 1 Jun 2026 14:02:53 -0500
feat: software monitor fader via mixer-inserted output path
The device has no writable output volume, so to give Valentine real monitor
level control: 'v' on the Monitor tab routes Analogue Out 1/2 through Mix A/B
(Mixer In 1/2 <- PCM 1/2; Out <- Mix A/B), and ←→ sets Mix A/B gain as the
monitor fader. Reversible (v again = direct from DAW). Core adds
route_monitor_via_mixer/route_monitor_direct/set_monitor_level/
monitor_is_via_mixer. Monitor panel shows hardware knob (read-only) + the
software fader. Prototype for the monitor pair; ADAT outs to follow once
verified on hardware.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
3 files changed, 205 insertions(+), 10 deletions(-)
diff --git a/scarlett-core/src/matrix.rs b/scarlett-core/src/matrix.rs
@@ -87,6 +87,68 @@ 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(
+ &mut self,
+ pc: [(u16, u16); 6],
+ ) -> 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
+ ];
+ 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(
+ &mut self,
+ pc: [(u16, u16); 6],
+ ) -> 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
+ ];
+ 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> {
+ 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
+ }
+ 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> {
+ 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)
+ }
+
/// Read the full routing table: `count` destination assignments.
pub fn get_mux(&mut self, count: usize) -> Result<Vec<MuxEntry>, TransportError> {
let mut payload = Vec::with_capacity(4);
diff --git a/valentine/src/main.rs b/valentine/src/main.rs
@@ -157,6 +157,12 @@ 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,
status: Option<String>,
show_help: bool,
last_poll: Instant,
@@ -183,6 +189,8 @@ impl App {
routing_pending: None,
modal: Modal::None,
stereo_inputs: true,
+ monitor_level_db: 0.0,
+ monitor_via_mixer: false,
status: None,
show_help: false,
last_poll: Instant::now(),
@@ -278,6 +286,14 @@ 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.
+ 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);
+ }
+ }
if self.tab == 2 {
if let Ok(dev) = &mut self.device {
dev.load_mixer();
@@ -563,10 +579,78 @@ impl App {
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(),
_ => {}
}
}
+ /// 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());
+ return;
+ }
+ 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 dev = match &mut self.device {
+ Ok(d) => d,
+ Err(_) => return,
+ };
+ match dev.scarlett.set_monitor_level(new_db, inputs) {
+ Ok(()) => {
+ self.monitor_level_db = new_db;
+ dev.mixer.clear();
+ self.status = Some(format!("monitor level {new_db:.0} dB"));
+ }
+ Err(e) => self.status = Some(format!("monitor 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) {
+ 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 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)
+ })
+ } else {
+ dev.scarlett.route_monitor_direct(pc).map(|st| {
+ dev.routing = Some(st);
+ })
+ };
+ match res {
+ Ok(()) => {
+ self.monitor_via_mixer = enable;
+ dev.mixer.clear();
+ dev.refresh_src_meter();
+ self.status = Some(if enable {
+ "monitor via mixer ON — ←→ adjusts level".into()
+ } else {
+ "monitor direct from DAW (fader off)".into()
+ });
+ }
+ Err(e) => self.status = Some(format!("monitor routing failed: {e}")),
+ }
+ }
+
fn toggle_monitor_button(&mut self) {
let row = self.monitor_cursor.current();
let dev = match &mut self.device {
@@ -999,7 +1083,16 @@ fn ui(f: &mut Frame, app: &App) {
);
}
(Ok(dev), 1) => {
- monitor::render(f, chunks[2], t, &dev.monitor, app.monitor_cursor, true);
+ monitor::render(
+ f,
+ chunks[2],
+ t,
+ &dev.monitor,
+ app.monitor_cursor,
+ app.monitor_via_mixer,
+ app.monitor_level_db,
+ true,
+ );
}
(Ok(dev), 2) => {
mixer::render(f, chunks[2], t, &dev.mixer, app.mixer_cursor, true);
diff --git a/valentine/src/panels/monitor.rs b/valentine/src/panels/monitor.rs
@@ -63,12 +63,15 @@ fn row_on(state: &MonitorState, row: Row) -> bool {
}
}
+#[allow(clippy::too_many_arguments)]
pub fn render(
f: &mut Frame,
area: Rect,
theme: &Theme,
state: &MonitorState,
cursor: Cursor,
+ via_mixer: bool,
+ soft_db: f32,
focused: bool,
) {
let border = if focused { theme.border_focus } else { theme.border };
@@ -81,8 +84,11 @@ pub fn render(
f.render_widget(block, area);
let rows = Layout::vertical([
- Constraint::Length(1), // label
- Constraint::Length(1), // gauge
+ Constraint::Length(1), // hardware-knob label
+ Constraint::Length(1), // hardware-knob gauge
+ Constraint::Length(1), // spacer
+ Constraint::Length(1), // software fader label
+ Constraint::Length(1), // software fader gauge
Constraint::Length(1), // spacer
Constraint::Length(1), // mute
Constraint::Length(1), // dim
@@ -90,12 +96,12 @@ pub fn render(
])
.split(inner);
- // Master level (read-only): bias so 0 dB = full, -127 dB = empty.
+ // Hardware monitor knob (read-only).
let db = state.master_db;
let ratio = ((db + VOLUME_BIAS) as f64 / VOLUME_BIAS as f64).clamp(0.0, 1.0);
f.render_widget(
Paragraph::new(Span::styled(
- format!("Master level (hardware knob): {db} dB"),
+ "hardware knob (read-only):",
Style::default().fg(theme.fg_dim),
)),
rows[0],
@@ -105,11 +111,45 @@ pub fn render(
Gauge::default()
.gauge_style(Style::default().fg(gauge_color).bg(theme.bg_elevated))
.ratio(ratio)
- .label(format!("{} dB", db)),
+ .label(format!("{db} dB")),
rows[1],
);
- // Toggle rows.
+ // Software monitor fader (active only when routed via the mixer).
+ let fader_label = if via_mixer {
+ "monitor fader (via mixer) ←→ adjust:"
+ } else {
+ "monitor fader — press 'v' to route via mixer & enable:"
+ };
+ f.render_widget(
+ Paragraph::new(Span::styled(
+ fader_label,
+ Style::default().fg(if via_mixer { theme.fg } else { theme.fg_dim }),
+ )),
+ rows[3],
+ );
+ if via_mixer {
+ // soft_db in MIXER_MIN..0 → ratio
+ let min = scarlett_core::matrix::MIXER_MIN_DB;
+ let r = ((soft_db - min) / -min).clamp(0.0, 1.0) as f64;
+ f.render_widget(
+ Gauge::default()
+ .gauge_style(Style::default().fg(theme.armed).bg(theme.bg_elevated))
+ .ratio(r)
+ .label(format!("{soft_db:.0} dB")),
+ rows[4],
+ );
+ } else {
+ f.render_widget(
+ Paragraph::new(Span::styled(
+ " (monitors currently direct from DAW)",
+ Style::default().fg(theme.fg_dim),
+ )),
+ rows[4],
+ );
+ }
+
+ // Toggle rows (mute / dim).
for (i, row) in Row::ALL.iter().enumerate() {
let row = *row;
let on = row_on(state, row);
@@ -134,14 +174,14 @@ pub fn render(
},
),
]);
- f.render_widget(Paragraph::new(line), rows[3 + i]);
+ f.render_widget(Paragraph::new(line), rows[6 + i]);
}
f.render_widget(
Paragraph::new(Span::styled(
- "↑↓ select space/enter toggle",
+ "↑↓ select space toggle ←→ fader v via-mixer",
Style::default().fg(theme.fg_dim),
)),
- rows[5],
+ rows[8],
);
}