From 135fcbb077d64a9e2698c4770ca5ae6383407f90 Mon Sep 17 00:00:00 2001 From: general electrix Date: Mon, 6 Jul 2026 17:21:44 -0600 Subject: [PATCH 1/8] Check in first unit of work. --- Cargo.lock | 4 + tunnels/Cargo.toml | 1 + tunnels/src/animation.rs | 20 +-- tunnels/src/clock_bank.rs | 157 ++++++++++++------ tunnels/src/clock_server.rs | 154 ++++++++++++++++- tunnels/src/midi_controls/animation.rs | 10 +- tunnels/src/midi_controls/clock.rs | 12 +- tunnels/src/show.rs | 16 +- .../midi_interpret_expectations.yaml | 120 ++++++------- 9 files changed, 336 insertions(+), 158 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4d3533dd..a191f6fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -350,6 +350,9 @@ name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", +] [[package]] name = "as-raw-xcb-connection" @@ -5730,6 +5733,7 @@ version = "0.1.0" dependencies = [ "anyhow", "arc-swap", + "arrayvec", "bitflags 2.11.0", "derive_more 2.0.1", "eframe", diff --git a/tunnels/Cargo.toml b/tunnels/Cargo.toml index e34e6183..343ec68b 100644 --- a/tunnels/Cargo.toml +++ b/tunnels/Cargo.toml @@ -19,6 +19,7 @@ rmp-serde.workspace = true rosc = "0.11" noise = "0.9.0" +arrayvec = { version = "0.7", features = ["serde"] } egui_plot = "0.34" eframe = "0.33" diff --git a/tunnels/src/animation.rs b/tunnels/src/animation.rs index 3e50031c..fba5c4c5 100644 --- a/tunnels/src/animation.rs +++ b/tunnels/src/animation.rs @@ -1,11 +1,10 @@ use crate::clock::Clock; use crate::clock::ControllableClock; use crate::clock::Ticks; -use crate::clock_bank::{ClockIdxExt, ClockStore}; +use crate::clock_bank::ClockStore; use crate::master_ui::EmitStateChange as EmitShowStateChange; use crate::waveforms::WaveformArgs; use crate::{clock_bank::ClockIdx, waveforms}; -use log::error; use noise::NoiseFn; use noise::Simplex; use serde::{Deserialize, Serialize}; @@ -287,16 +286,6 @@ impl Animation { match msg { Set(sc) => self.handle_state_change(sc, emitter), SetClockSource(source) => { - let source: Option = match source { - Some(s) => match s.try_into() { - Ok(s) => Some(s), - Err(e) => { - error!("could not process animation control message: {e}"); - return; - } - }, - None => None, - }; self.handle_state_change(StateChange::ClockSource(source), emitter); } TogglePulse => { @@ -363,11 +352,8 @@ pub enum StateChange { #[derive(Debug, Clone)] pub enum ControlMessage { Set(StateChange), - /// Since clock IDs need to be validated, this path handles the fallible case. - /// FIXME: it would be nicer to validate this at control message creation time, - /// but at the moment control message creator functions are infallible and - /// that's more refactoring than I want to deal with right now. - SetClockSource(Option), + /// Set the clock that drives this animation, or `None` for the internal clock. + SetClockSource(Option), TogglePulse, ToggleStanding, ToggleInvert, diff --git a/tunnels/src/clock_bank.rs b/tunnels/src/clock_bank.rs index bd9da7f7..f674feeb 100644 --- a/tunnels/src/clock_bank.rs +++ b/tunnels/src/clock_bank.rs @@ -1,6 +1,5 @@ use std::time::Duration; -use crate::typed_index::typed_index; use crate::{ clock::{ ControlMessage as ClockControlMessage, ControllableClock, @@ -9,13 +8,21 @@ use crate::{ }, master_ui::EmitStateChange as EmitShowStateChange, }; -use anyhow::{Error, Result, bail}; -use log::error; +use arrayvec::ArrayVec; +use log::{error, warn}; use serde::{Deserialize, Serialize}; use tunnels_lib::number::{Phase, UnipolarFloat}; /// Read-only interface to the state of a collection of clocks. pub trait ClockStore { + /// Return the number of clocks in the bank. + fn len(&self) -> usize; + + /// Return true if the bank contains no clocks. + fn is_empty(&self) -> bool { + self.len() == 0 + } + /// Return the current phase of this clock. fn phase(&self, index: ClockIdx) -> Phase; @@ -32,55 +39,74 @@ pub trait ClockStore { fn use_audio_size(&self, index: ClockIdx) -> bool; } -/// how many globally-available clocks? -pub const N_CLOCKS: usize = 4; +/// The maximum number of clocks a bank can hold. The number in use is a runtime +/// value that can vary up to this bound. +pub const MAX_CLOCKS: usize = 12; -#[derive(Debug, Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Serialize, Deserialize)] -/// Index of a clock in the bank. -/// Care should be taken to ensure that these values are always valid. -/// External input should be accepted through ClockIdxExt and validated -/// using from. -pub struct ClockIdx(usize); -typed_index!(ClockIdx, ControllableClock); +/// The number of clocks in a bank when no count is otherwise specified. +pub const DEFAULT_N_CLOCKS: usize = 4; #[derive(Debug, Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Serialize, Deserialize)] -/// Public-facing "request" for a clock index. -/// Must be validated to become a proper ClockIdx. -pub struct ClockIdxExt(pub usize); - -impl TryFrom for ClockIdx { - type Error = Error; - fn try_from(value: ClockIdxExt) -> Result { - if value.0 >= N_CLOCKS { - bail!("clock index {} out of range", value.0); - } - Ok(ClockIdx(value.0)) +/// Index of a clock in a bank. +/// +/// Not a proof of validity: the clock count is dynamic, so a read for an +/// out-of-range index yields a neutral default rather than a value. +pub struct ClockIdx(pub usize); + +/// Maintain an indexable collection of clocks. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClockBank(ArrayVec); + +impl Default for ClockBank { + fn default() -> Self { + Self::new(DEFAULT_N_CLOCKS) } } -/// Maintain a indexable collection of clocks. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ClockBank([ControllableClock; N_CLOCKS]); - impl ClockStore for ClockBank { + fn len(&self) -> usize { + self.0.len() + } + fn phase(&self, index: ClockIdx) -> Phase { - self.get(index).phase() + self.get(index).map(|c| c.phase()).unwrap_or(Phase::ZERO) } fn ticks(&self, index: ClockIdx) -> Ticks { - self.get(index).ticks() + self.get(index).map(|c| c.ticks()).unwrap_or(0) } fn submaster_level(&self, index: ClockIdx) -> UnipolarFloat { - self.get(index).submaster_level() + self.get(index) + .map(|c| c.submaster_level()) + .unwrap_or(UnipolarFloat::ZERO) } fn use_audio_size(&self, index: ClockIdx) -> bool { - self.get(index).use_audio_size() + self.get(index).map(|c| c.use_audio_size()).unwrap_or(false) } } impl ClockBank { + /// Create a bank with `n` clocks, clamped to [`MAX_CLOCKS`]. + pub fn new(n: usize) -> Self { + let n = n.min(MAX_CLOCKS); + Self((0..n).map(|_| ControllableClock::default()).collect()) + } + + /// Grow or shrink the bank to hold `n` clocks, clamped to [`MAX_CLOCKS`]. + /// New clocks are added in their default state; removed clocks are dropped. + pub fn set_clock_count(&mut self, n: usize) { + let clamped = n.min(MAX_CLOCKS); + if clamped != n { + warn!("requested {n} clocks exceeds the maximum of {MAX_CLOCKS}; clamping."); + } + while self.0.len() < clamped { + self.0.push(ControllableClock::default()); + } + self.0.truncate(clamped); + } + pub fn update_state( &mut self, delta_t: Duration, @@ -99,20 +125,14 @@ impl ClockBank { } } - pub fn get(&self, index: ClockIdx) -> &ControllableClock { - &self.0[index] + /// Return the clock at `index`, or `None` if the index is out of range. + pub fn get(&self, index: ClockIdx) -> Option<&ControllableClock> { + self.0.get(index.0) } /// Return a static snapshot of the state of this clock bank. - pub fn as_static(&self) -> [StaticClock; N_CLOCKS] { - // FIXME: need this method to avoid having to write this out explicitly - // https://doc.rust-lang.org/std/primitive.array.html#method.each_ref - [ - self.0[0].as_static(), - self.0[1].as_static(), - self.0[2].as_static(), - self.0[3].as_static(), - ] + pub fn as_static(&self) -> ArrayVec { + self.0.iter().map(|c| c.as_static()).collect() } pub fn emit_state(&self, emitter: &mut E) { @@ -125,14 +145,16 @@ impl ClockBank { } pub fn control(&mut self, msg: ControlMessage, emitter: &mut E) { - let channel: ClockIdx = match msg.channel.try_into() { - Ok(id) => id, - Err(e) => { - error!("could not process clock control message {msg:?}: {e}"); - return; - } + let channel = msg.channel; + let Some(clock) = self.0.get_mut(channel.0) else { + error!( + "could not process clock control message {msg:?}: channel {} is out of range for {} clocks", + channel.0, + self.0.len() + ); + return; }; - self.0[channel].control(msg.msg, &mut ChannelEmitter { channel, emitter }) + clock.control(msg.msg, &mut ChannelEmitter { channel, emitter }) } } @@ -153,7 +175,7 @@ impl<'e, E: EmitStateChange> EmitClockStateChange for ChannelEmitter<'e, E> { #[derive(Debug, Clone)] pub struct ControlMessage { - pub channel: ClockIdxExt, + pub channel: ClockIdx, pub msg: ClockControlMessage, } @@ -173,3 +195,38 @@ impl EmitStateChange for T { self.emit(ShowStateChange::Clock(sc)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clock_count_grows_shrinks_and_clamps() { + let mut bank = ClockBank::new(4); + assert_eq!(bank.len(), 4); + assert_eq!(bank.as_static().len(), 4); + + bank.set_clock_count(8); + assert_eq!(bank.len(), 8); + assert_eq!(bank.as_static().len(), 8); + + bank.set_clock_count(4); + assert_eq!(bank.len(), 4); + + // Requests beyond MAX_CLOCKS clamp rather than overflow. + bank.set_clock_count(MAX_CLOCKS + 5); + assert_eq!(bank.len(), MAX_CLOCKS); + assert_eq!(ClockBank::new(MAX_CLOCKS + 5).len(), MAX_CLOCKS); + } + + #[test] + fn out_of_range_index_reads_are_neutral() { + let bank = ClockBank::new(4); + let missing = ClockIdx(9); + assert!(bank.get(missing).is_none()); + assert_eq!(bank.phase(missing), Phase::ZERO); + assert_eq!(bank.ticks(missing), 0); + assert_eq!(bank.submaster_level(missing), UnipolarFloat::ZERO); + assert!(!bank.use_audio_size(missing)); + } +} diff --git a/tunnels/src/clock_server.rs b/tunnels/src/clock_server.rs index 6c9dd479..cbe4f1fc 100644 --- a/tunnels/src/clock_server.rs +++ b/tunnels/src/clock_server.rs @@ -1,15 +1,19 @@ //! Advertise a clock bank stream over DNSSD. //! Provide a strongly-typed receiver. +use std::fmt; + use anyhow::Result; +use arrayvec::ArrayVec; +use serde::de::{Deserializer, SeqAccess, Visitor}; use serde::{Deserialize, Serialize}; use tunnels_lib::number::{Phase, UnipolarFloat}; use zero_configure::pub_sub::{PublisherService, SubscriberService}; use crate::{ clock::StaticClock, - clock_bank::{ClockIdx, ClockStore, N_CLOCKS}, + clock_bank::{ClockIdx, ClockStore, MAX_CLOCKS}, }; const SERVICE_NAME: &str = "showclocks"; @@ -36,30 +40,71 @@ pub type ClockPublisher = PublisherService; pub type ClockSubscriber = SubscriberService; /// A collection of static clock state data, rendered from a ClockBank. -#[derive(Serialize, Deserialize, Default, Debug, Clone)] -pub struct StaticClockBank(pub [StaticClock; N_CLOCKS]); +#[derive(Serialize, Default, Debug, Clone)] +pub struct StaticClockBank(pub ArrayVec); + +impl<'de> Deserialize<'de> for StaticClockBank { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct BankVisitor; + + impl<'de> Visitor<'de> for BankVisitor { + type Value = StaticClockBank; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "a sequence of clock states") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let mut clocks = ArrayVec::new(); + // Keep the first MAX_CLOCKS clocks and discard any beyond the + // capacity ceiling, so a peer with a higher ceiling degrades to a + // usable bank instead of dropping the frame. The loop still drains + // the whole sequence to keep the rest of the message aligned. + while let Some(clock) = seq.next_element::()? { + let _ = clocks.try_push(clock); + } + Ok(StaticClockBank(clocks)) + } + } + + deserializer.deserialize_seq(BankVisitor) + } +} impl ClockStore for StaticClockBank { + fn len(&self) -> usize { + self.0.len() + } + fn phase(&self, index: ClockIdx) -> Phase { - self.get(index).phase + self.get(index).map(|c| c.phase).unwrap_or(Phase::ZERO) } fn ticks(&self, index: ClockIdx) -> crate::clock::Ticks { - self.get(index).ticks + self.get(index).map(|c| c.ticks).unwrap_or(0) } fn submaster_level(&self, index: ClockIdx) -> UnipolarFloat { - self.get(index).submaster_level + self.get(index) + .map(|c| c.submaster_level) + .unwrap_or(UnipolarFloat::ZERO) } fn use_audio_size(&self, index: ClockIdx) -> bool { - self.get(index).use_audio_size + self.get(index).map(|c| c.use_audio_size).unwrap_or(false) } } impl StaticClockBank { - fn get(&self, index: ClockIdx) -> &StaticClock { - &self.0[usize::from(index)] + /// Return the clock at `index`, or `None` if the index is out of range. + fn get(&self, index: ClockIdx) -> Option<&StaticClock> { + self.0.get(index.0) } } @@ -81,4 +126,95 @@ mod tests { Ok(_) => panic!("should have rejected name longer than 15 chars"), } } + + /// Build a bank of `n` clocks whose `ticks` encode their index, so ordering + /// and length survive a round trip observably. + fn bank(n: usize) -> StaticClockBank { + StaticClockBank( + (0..n) + .map(|i| StaticClock { + phase: Phase::ZERO, + ticks: i as crate::clock::Ticks, + submaster_level: UnipolarFloat::ZERO, + use_audio_size: false, + }) + .collect(), + ) + } + + #[test] + fn wire_round_trips_at_various_lengths() { + for n in [0usize, 4, 8, MAX_CLOCKS] { + let original = bank(n); + let bytes = rmp_serde::to_vec(&original).unwrap(); + let decoded: StaticClockBank = rmp_serde::from_slice(&bytes).unwrap(); + assert_eq!(decoded.0.len(), n, "length preserved for {n} clocks"); + let ticks: Vec = decoded.0.iter().map(|c| c.ticks).collect(); + assert_eq!( + ticks, + (0..n as i64).collect::>(), + "clock order preserved for {n} clocks" + ); + } + } + + #[test] + fn wire_decode_truncates_over_capacity() { + // At capacity decodes fully. + let bytes = rmp_serde::to_vec(&bank(MAX_CLOCKS)).unwrap(); + let decoded: StaticClockBank = rmp_serde::from_slice(&bytes).unwrap(); + assert_eq!(decoded.0.len(), MAX_CLOCKS); + + // Beyond capacity: the excess clocks are dropped, keeping the first + // MAX_CLOCKS in order, rather than erroring or panicking. + let over: Vec = (0..MAX_CLOCKS + 3) + .map(|i| StaticClock { + phase: Phase::ZERO, + ticks: i as crate::clock::Ticks, + submaster_level: UnipolarFloat::ZERO, + use_audio_size: false, + }) + .collect(); + let bytes = rmp_serde::to_vec(&over).unwrap(); + let decoded: StaticClockBank = rmp_serde::from_slice(&bytes).unwrap(); + assert_eq!(decoded.0.len(), MAX_CLOCKS, "truncated to capacity"); + let ticks: Vec = decoded.0.iter().map(|c| c.ticks).collect(); + assert_eq!( + ticks, + (0..MAX_CLOCKS as i64).collect::>(), + "kept the first MAX_CLOCKS in order" + ); + + // Draining the overflow keeps the rest of the message aligned: an + // over-length clock_bank in a SharedClockData-shaped message still + // decodes the trailing audio_envelope correctly. + #[derive(Serialize)] + struct WireShaped { + clock_bank: Vec, + audio_envelope: UnipolarFloat, + } + let envelope = UnipolarFloat::new(0.75); + let bytes = rmp_serde::to_vec(&WireShaped { + clock_bank: over, + audio_envelope: envelope, + }) + .unwrap(); + let decoded: SharedClockData = rmp_serde::from_slice(&bytes).unwrap(); + assert_eq!(decoded.clock_bank.0.len(), MAX_CLOCKS); + assert_eq!( + decoded.audio_envelope, envelope, + "trailing field stayed aligned after draining overflow" + ); + } + + #[test] + fn out_of_range_reads_return_neutral_defaults() { + let b = bank(4); + let missing = ClockIdx(9); + assert_eq!(b.phase(missing), Phase::ZERO); + assert_eq!(b.ticks(missing), 0); + assert_eq!(b.submaster_level(missing), UnipolarFloat::ZERO); + assert!(!b.use_audio_size(missing)); + assert_eq!(b.len(), 4); + } } diff --git a/tunnels/src/midi_controls/animation.rs b/tunnels/src/midi_controls/animation.rs index bac7e9b1..5aedf700 100644 --- a/tunnels/src/midi_controls/animation.rs +++ b/tunnels/src/midi_controls/animation.rs @@ -1,6 +1,6 @@ use crate::{ animation::{ControlMessage, StateChange, Waveform as WaveformType}, - clock_bank::{ClockIdxExt, N_CLOCKS}, + clock_bank::{ClockIdx, DEFAULT_N_CLOCKS}, midi::{Event, EventType, Mapping, MidiOutput, cc_ch0, event, note_on_ch0, note_on_ch1}, midi_controls::Device, show::ControlMessage::Animation, @@ -43,7 +43,7 @@ lazy_static! { }; static ref CLOCK_SELECT_BUTTONS: RadioButtons = RadioButtons { // -1 corresponds to "internal", the rest as global clock IDs. - mappings: (-1..N_CLOCKS as i32) + mappings: (-1..DEFAULT_N_CLOCKS as i32) .map(|clock_id| note_on_ch0((clock_id + CLOCK_SELECT_CONTROL_OFFSET) as u8)) .collect(), off: 0, @@ -78,13 +78,13 @@ pub fn interpret(event: &Event) -> Option { m if m.event_type == EventType::NoteOn && m.channel == 0 && m.control >= (CLOCK_SELECT_CONTROL_OFFSET - 1) as u8 - && m.control < (CLOCK_SELECT_CONTROL_OFFSET + N_CLOCKS as i32) as u8 => + && m.control < (CLOCK_SELECT_CONTROL_OFFSET + DEFAULT_N_CLOCKS as i32) as u8 => { let clock_id = m.control as i32 - CLOCK_SELECT_CONTROL_OFFSET; if clock_id < 0 { Animation(Set(ClockSource(None))) } else { - Animation(SetClockSource(Some(ClockIdxExt(clock_id as usize)))) + Animation(SetClockSource(Some(ClockIdx(clock_id as usize)))) } } _ => return None, @@ -125,7 +125,7 @@ pub fn update_animation_control(sc: StateChange, manager: &mut impl MidiOutput) Pulse(v) => send(event(PULSE, v as u8)), ClockSource(v) => { let index = match v { - Some(source) => usize::from(source) as i32, + Some(source) => source.0 as i32, None => -1, }; CLOCK_SELECT_BUTTONS.select( diff --git a/tunnels/src/midi_controls/clock.rs b/tunnels/src/midi_controls/clock.rs index c979b4d4..9fd86dee 100644 --- a/tunnels/src/midi_controls/clock.rs +++ b/tunnels/src/midi_controls/clock.rs @@ -5,9 +5,9 @@ use crate::midi::Event as MidiEvent; use crate::{ clock::ControlMessage as ClockControlMessage, clock::StateChange as ClockStateChange, - clock_bank::ClockIdxExt, + clock_bank::ClockIdx, clock_bank::ControlMessage, - clock_bank::N_CLOCKS, + clock_bank::DEFAULT_N_CLOCKS, clock_bank::StateChange, midi::{Mapping, MidiOutput, cc, event, note_on}, midi_controls::Device, @@ -74,10 +74,10 @@ fn interpret_with_mapping_fn( use ClockStateChange::*; let v = event.value; - for channel in 0..N_CLOCKS { + for channel in 0..DEFAULT_N_CLOCKS { let mkmsg = |msg| { crate::show::ControlMessage::Clock(ControlMessage { - channel: ClockIdxExt(channel), + channel: ClockIdx(channel), msg, }) }; @@ -123,10 +123,10 @@ pub fn update_clock_control(sc: StateChange, manager: &mut impl MidiOutput) { use ClockStateChange::*; let mut send = |control, value| { - if let Some(mapping) = mapping_cmd_mm1(control, sc.channel.into()) { + if let Some(mapping) = mapping_cmd_mm1(control, sc.channel.0) { manager.send(&Device::BehringerCmdMM1, event(mapping, value)); } - if let Some(mapping) = mapping_touchosc(control, sc.channel.into()) { + if let Some(mapping) = mapping_touchosc(control, sc.channel.0) { manager.send(&Device::TouchOsc, event(mapping, value)); } }; diff --git a/tunnels/src/show.rs b/tunnels/src/show.rs index 0c8c9428..2a4c1826 100644 --- a/tunnels/src/show.rs +++ b/tunnels/src/show.rs @@ -681,7 +681,7 @@ mod test { { use crate::animation::StateChange as A; use crate::animation::Waveform; - use crate::clock_bank::ClockIdxExt; + use crate::clock_bank::ClockIdx; let mut a = |name, sc| changes.push((name, StateChange::Animation(sc))); a("anim/speed", A::Speed(bip)); a("anim/size", A::Size(uni)); @@ -699,14 +699,8 @@ mod test { a("anim/invert_on", A::Invert(true)); a("anim/standing_on", A::Standing(true)); a("anim/clock_internal", A::ClockSource(None)); - a( - "anim/clock_0", - A::ClockSource(Some(ClockIdxExt(0).try_into().unwrap())), - ); - a( - "anim/clock_1", - A::ClockSource(Some(ClockIdxExt(1).try_into().unwrap())), - ); + a("anim/clock_0", A::ClockSource(Some(ClockIdx(0)))); + a("anim/clock_1", A::ClockSource(Some(ClockIdx(1)))); a("anim/use_audio_size_on", A::UseAudioSize(true)); a("anim/use_audio_speed_on", A::UseAudioSpeed(true)); } @@ -805,12 +799,12 @@ mod test { // Clock state changes -- test multiple clock channels. { use crate::clock::StateChange as CS; - use crate::clock_bank::{ClockIdxExt, StateChange as CBS}; + use crate::clock_bank::{ClockIdx, StateChange as CBS}; let mut c = |name, ch: usize, sc| { changes.push(( name, StateChange::Clock(CBS { - channel: ClockIdxExt(ch).try_into().unwrap(), + channel: ClockIdx(ch), change: sc, }), )) diff --git a/tunnels/src/snapshots/midi_interpret_expectations.yaml b/tunnels/src/snapshots/midi_interpret_expectations.yaml index 1fa842f6..a9a4a8ce 100644 --- a/tunnels/src/snapshots/midi_interpret_expectations.yaml +++ b/tunnels/src/snapshots/midi_interpret_expectations.yaml @@ -213,10 +213,10 @@ Akai APC40: | NoteOn 0:100 -> MasterUI(AnimationPaste) NoteOn 0:101 -> MasterUI(AnimationCopy) NoteOn 0:111 -> Animation(Set(ClockSource(None))) - NoteOn 0:112 -> Animation(SetClockSource(Some(ClockIdxExt(0)))) - NoteOn 0:113 -> Animation(SetClockSource(Some(ClockIdxExt(1)))) - NoteOn 0:114 -> Animation(SetClockSource(Some(ClockIdxExt(2)))) - NoteOn 0:115 -> Animation(SetClockSource(Some(ClockIdxExt(3)))) + NoteOn 0:112 -> Animation(SetClockSource(Some(ClockIdx(0)))) + NoteOn 0:113 -> Animation(SetClockSource(Some(ClockIdx(1)))) + NoteOn 0:114 -> Animation(SetClockSource(Some(ClockIdx(2)))) + NoteOn 0:115 -> Animation(SetClockSource(Some(ClockIdx(3)))) NoteOn 0:120 -> Tunnel(ResetRotation) NoteOn 0:121 -> Tunnel(ResetMarquee) NoteOn 0:122 -> Tunnel(ResetSpin) @@ -379,30 +379,30 @@ Akai APC40: | CC 8:1 -> Tunnel(Set(PositionX(0.0))) CMD MM-1: | NoteOn 4:18 -> Audio(ToggleMonitor) - NoteOn 4:19 -> Clock(ControlMessage { channel: ClockIdxExt(0), msg: ToggleOneShot }) - NoteOn 4:20 -> Clock(ControlMessage { channel: ClockIdxExt(0), msg: Retrigger }) - NoteOn 4:23 -> Clock(ControlMessage { channel: ClockIdxExt(1), msg: ToggleOneShot }) - NoteOn 4:24 -> Clock(ControlMessage { channel: ClockIdxExt(1), msg: Retrigger }) - NoteOn 4:27 -> Clock(ControlMessage { channel: ClockIdxExt(2), msg: ToggleOneShot }) - NoteOn 4:28 -> Clock(ControlMessage { channel: ClockIdxExt(2), msg: Retrigger }) - NoteOn 4:31 -> Clock(ControlMessage { channel: ClockIdxExt(3), msg: ToggleOneShot }) - NoteOn 4:32 -> Clock(ControlMessage { channel: ClockIdxExt(3), msg: Retrigger }) - NoteOn 4:48 -> Clock(ControlMessage { channel: ClockIdxExt(0), msg: Tap }) - NoteOn 4:49 -> Clock(ControlMessage { channel: ClockIdxExt(1), msg: Tap }) - NoteOn 4:50 -> Clock(ControlMessage { channel: ClockIdxExt(2), msg: Tap }) - NoteOn 4:51 -> Clock(ControlMessage { channel: ClockIdxExt(3), msg: Tap }) - CC 4:6 -> Clock(ControlMessage { channel: ClockIdxExt(0), msg: Set(Rate(BipolarFloat(0.0))) }) - CC 4:7 -> Clock(ControlMessage { channel: ClockIdxExt(1), msg: Set(Rate(BipolarFloat(0.0))) }) - CC 4:8 -> Clock(ControlMessage { channel: ClockIdxExt(2), msg: Set(Rate(BipolarFloat(0.0))) }) - CC 4:9 -> Clock(ControlMessage { channel: ClockIdxExt(3), msg: Set(Rate(BipolarFloat(0.0))) }) - CC 4:18 -> Clock(ControlMessage { channel: ClockIdxExt(0), msg: Set(RateFine(BipolarFloat(0.0))) }) - CC 4:19 -> Clock(ControlMessage { channel: ClockIdxExt(1), msg: Set(RateFine(BipolarFloat(0.0))) }) - CC 4:20 -> Clock(ControlMessage { channel: ClockIdxExt(2), msg: Set(RateFine(BipolarFloat(0.0))) }) - CC 4:21 -> Clock(ControlMessage { channel: ClockIdxExt(3), msg: Set(RateFine(BipolarFloat(0.0))) }) - CC 4:48 -> Clock(ControlMessage { channel: ClockIdxExt(0), msg: Set(SubmasterLevel(UnipolarFloat(0.5039370078740157))) }) - CC 4:49 -> Clock(ControlMessage { channel: ClockIdxExt(1), msg: Set(SubmasterLevel(UnipolarFloat(0.5039370078740157))) }) - CC 4:50 -> Clock(ControlMessage { channel: ClockIdxExt(2), msg: Set(SubmasterLevel(UnipolarFloat(0.5039370078740157))) }) - CC 4:51 -> Clock(ControlMessage { channel: ClockIdxExt(3), msg: Set(SubmasterLevel(UnipolarFloat(0.5039370078740157))) }) + NoteOn 4:19 -> Clock(ControlMessage { channel: ClockIdx(0), msg: ToggleOneShot }) + NoteOn 4:20 -> Clock(ControlMessage { channel: ClockIdx(0), msg: Retrigger }) + NoteOn 4:23 -> Clock(ControlMessage { channel: ClockIdx(1), msg: ToggleOneShot }) + NoteOn 4:24 -> Clock(ControlMessage { channel: ClockIdx(1), msg: Retrigger }) + NoteOn 4:27 -> Clock(ControlMessage { channel: ClockIdx(2), msg: ToggleOneShot }) + NoteOn 4:28 -> Clock(ControlMessage { channel: ClockIdx(2), msg: Retrigger }) + NoteOn 4:31 -> Clock(ControlMessage { channel: ClockIdx(3), msg: ToggleOneShot }) + NoteOn 4:32 -> Clock(ControlMessage { channel: ClockIdx(3), msg: Retrigger }) + NoteOn 4:48 -> Clock(ControlMessage { channel: ClockIdx(0), msg: Tap }) + NoteOn 4:49 -> Clock(ControlMessage { channel: ClockIdx(1), msg: Tap }) + NoteOn 4:50 -> Clock(ControlMessage { channel: ClockIdx(2), msg: Tap }) + NoteOn 4:51 -> Clock(ControlMessage { channel: ClockIdx(3), msg: Tap }) + CC 4:6 -> Clock(ControlMessage { channel: ClockIdx(0), msg: Set(Rate(BipolarFloat(0.0))) }) + CC 4:7 -> Clock(ControlMessage { channel: ClockIdx(1), msg: Set(Rate(BipolarFloat(0.0))) }) + CC 4:8 -> Clock(ControlMessage { channel: ClockIdx(2), msg: Set(Rate(BipolarFloat(0.0))) }) + CC 4:9 -> Clock(ControlMessage { channel: ClockIdx(3), msg: Set(Rate(BipolarFloat(0.0))) }) + CC 4:18 -> Clock(ControlMessage { channel: ClockIdx(0), msg: Set(RateFine(BipolarFloat(0.0))) }) + CC 4:19 -> Clock(ControlMessage { channel: ClockIdx(1), msg: Set(RateFine(BipolarFloat(0.0))) }) + CC 4:20 -> Clock(ControlMessage { channel: ClockIdx(2), msg: Set(RateFine(BipolarFloat(0.0))) }) + CC 4:21 -> Clock(ControlMessage { channel: ClockIdx(3), msg: Set(RateFine(BipolarFloat(0.0))) }) + CC 4:48 -> Clock(ControlMessage { channel: ClockIdx(0), msg: Set(SubmasterLevel(UnipolarFloat(0.5039370078740157))) }) + CC 4:49 -> Clock(ControlMessage { channel: ClockIdx(1), msg: Set(SubmasterLevel(UnipolarFloat(0.5039370078740157))) }) + CC 4:50 -> Clock(ControlMessage { channel: ClockIdx(2), msg: Set(SubmasterLevel(UnipolarFloat(0.5039370078740157))) }) + CC 4:51 -> Clock(ControlMessage { channel: ClockIdx(3), msg: Set(SubmasterLevel(UnipolarFloat(0.5039370078740157))) }) TouchOSC Bridge: | NoteOn 0:0 -> Animation(Set(NPeriods(0))) NoteOn 0:1 -> Animation(Set(NPeriods(1))) @@ -473,10 +473,10 @@ TouchOSC Bridge: | NoteOn 0:100 -> MasterUI(AnimationPaste) NoteOn 0:101 -> MasterUI(AnimationCopy) NoteOn 0:111 -> Animation(Set(ClockSource(None))) - NoteOn 0:112 -> Animation(SetClockSource(Some(ClockIdxExt(0)))) - NoteOn 0:113 -> Animation(SetClockSource(Some(ClockIdxExt(1)))) - NoteOn 0:114 -> Animation(SetClockSource(Some(ClockIdxExt(2)))) - NoteOn 0:115 -> Animation(SetClockSource(Some(ClockIdxExt(3)))) + NoteOn 0:112 -> Animation(SetClockSource(Some(ClockIdx(0)))) + NoteOn 0:113 -> Animation(SetClockSource(Some(ClockIdx(1)))) + NoteOn 0:114 -> Animation(SetClockSource(Some(ClockIdx(2)))) + NoteOn 0:115 -> Animation(SetClockSource(Some(ClockIdx(3)))) NoteOn 0:120 -> Tunnel(ResetRotation) NoteOn 0:121 -> Tunnel(ResetMarquee) NoteOn 0:122 -> Tunnel(ResetSpin) @@ -603,26 +603,26 @@ TouchOSC Bridge: | NoteOn 8:59 -> Tunnel(Set(PaletteSelection(Some(ColorPaletteIdx(0))))) NoteOn 8:60 -> Tunnel(Set(PaletteSelection(Some(ColorPaletteIdx(1))))) NoteOn 8:61 -> Tunnel(Set(PaletteSelection(Some(ColorPaletteIdx(2))))) - NoteOn 9:0 -> Clock(ControlMessage { channel: ClockIdxExt(0), msg: Tap }) - NoteOn 9:1 -> Clock(ControlMessage { channel: ClockIdxExt(0), msg: ToggleOneShot }) - NoteOn 9:2 -> Clock(ControlMessage { channel: ClockIdxExt(0), msg: Retrigger }) - NoteOn 9:3 -> Clock(ControlMessage { channel: ClockIdxExt(0), msg: ToggleUseAudioSize }) - NoteOn 9:4 -> Clock(ControlMessage { channel: ClockIdxExt(0), msg: ToggleUseAudioSpeed }) - NoteOn 10:0 -> Clock(ControlMessage { channel: ClockIdxExt(1), msg: Tap }) - NoteOn 10:1 -> Clock(ControlMessage { channel: ClockIdxExt(1), msg: ToggleOneShot }) - NoteOn 10:2 -> Clock(ControlMessage { channel: ClockIdxExt(1), msg: Retrigger }) - NoteOn 10:3 -> Clock(ControlMessage { channel: ClockIdxExt(1), msg: ToggleUseAudioSize }) - NoteOn 10:4 -> Clock(ControlMessage { channel: ClockIdxExt(1), msg: ToggleUseAudioSpeed }) - NoteOn 11:0 -> Clock(ControlMessage { channel: ClockIdxExt(2), msg: Tap }) - NoteOn 11:1 -> Clock(ControlMessage { channel: ClockIdxExt(2), msg: ToggleOneShot }) - NoteOn 11:2 -> Clock(ControlMessage { channel: ClockIdxExt(2), msg: Retrigger }) - NoteOn 11:3 -> Clock(ControlMessage { channel: ClockIdxExt(2), msg: ToggleUseAudioSize }) - NoteOn 11:4 -> Clock(ControlMessage { channel: ClockIdxExt(2), msg: ToggleUseAudioSpeed }) - NoteOn 12:0 -> Clock(ControlMessage { channel: ClockIdxExt(3), msg: Tap }) - NoteOn 12:1 -> Clock(ControlMessage { channel: ClockIdxExt(3), msg: ToggleOneShot }) - NoteOn 12:2 -> Clock(ControlMessage { channel: ClockIdxExt(3), msg: Retrigger }) - NoteOn 12:3 -> Clock(ControlMessage { channel: ClockIdxExt(3), msg: ToggleUseAudioSize }) - NoteOn 12:4 -> Clock(ControlMessage { channel: ClockIdxExt(3), msg: ToggleUseAudioSpeed }) + NoteOn 9:0 -> Clock(ControlMessage { channel: ClockIdx(0), msg: Tap }) + NoteOn 9:1 -> Clock(ControlMessage { channel: ClockIdx(0), msg: ToggleOneShot }) + NoteOn 9:2 -> Clock(ControlMessage { channel: ClockIdx(0), msg: Retrigger }) + NoteOn 9:3 -> Clock(ControlMessage { channel: ClockIdx(0), msg: ToggleUseAudioSize }) + NoteOn 9:4 -> Clock(ControlMessage { channel: ClockIdx(0), msg: ToggleUseAudioSpeed }) + NoteOn 10:0 -> Clock(ControlMessage { channel: ClockIdx(1), msg: Tap }) + NoteOn 10:1 -> Clock(ControlMessage { channel: ClockIdx(1), msg: ToggleOneShot }) + NoteOn 10:2 -> Clock(ControlMessage { channel: ClockIdx(1), msg: Retrigger }) + NoteOn 10:3 -> Clock(ControlMessage { channel: ClockIdx(1), msg: ToggleUseAudioSize }) + NoteOn 10:4 -> Clock(ControlMessage { channel: ClockIdx(1), msg: ToggleUseAudioSpeed }) + NoteOn 11:0 -> Clock(ControlMessage { channel: ClockIdx(2), msg: Tap }) + NoteOn 11:1 -> Clock(ControlMessage { channel: ClockIdx(2), msg: ToggleOneShot }) + NoteOn 11:2 -> Clock(ControlMessage { channel: ClockIdx(2), msg: Retrigger }) + NoteOn 11:3 -> Clock(ControlMessage { channel: ClockIdx(2), msg: ToggleUseAudioSize }) + NoteOn 11:4 -> Clock(ControlMessage { channel: ClockIdx(2), msg: ToggleUseAudioSpeed }) + NoteOn 12:0 -> Clock(ControlMessage { channel: ClockIdx(3), msg: Tap }) + NoteOn 12:1 -> Clock(ControlMessage { channel: ClockIdx(3), msg: ToggleOneShot }) + NoteOn 12:2 -> Clock(ControlMessage { channel: ClockIdx(3), msg: Retrigger }) + NoteOn 12:3 -> Clock(ControlMessage { channel: ClockIdx(3), msg: ToggleUseAudioSize }) + NoteOn 12:4 -> Clock(ControlMessage { channel: ClockIdx(3), msg: ToggleUseAudioSpeed }) NoteOn 13:0 -> Tunnel(NudgeCW) NoteOn 13:1 -> Tunnel(NudgeCCW) NoteOff 0:50 -> Mixer(ControlMessage { channel: ChannelIdx(0), msg: Set(Bump(false)) }) @@ -663,11 +663,11 @@ TouchOSC Bridge: | CC 7:7 -> Mixer(ControlMessage { channel: ChannelIdx(7), msg: Set(Level(UnipolarFloat(0.5039370078740157))) }) CC 8:0 -> Tunnel(Set(PositionY(0.0))) CC 8:1 -> Tunnel(Set(PositionX(0.0))) - CC 9:0 -> Clock(ControlMessage { channel: ClockIdxExt(0), msg: Set(Rate(BipolarFloat(0.0))) }) - CC 9:1 -> Clock(ControlMessage { channel: ClockIdxExt(0), msg: Set(SubmasterLevel(UnipolarFloat(0.5039370078740157))) }) - CC 10:0 -> Clock(ControlMessage { channel: ClockIdxExt(1), msg: Set(Rate(BipolarFloat(0.0))) }) - CC 10:1 -> Clock(ControlMessage { channel: ClockIdxExt(1), msg: Set(SubmasterLevel(UnipolarFloat(0.5039370078740157))) }) - CC 11:0 -> Clock(ControlMessage { channel: ClockIdxExt(2), msg: Set(Rate(BipolarFloat(0.0))) }) - CC 11:1 -> Clock(ControlMessage { channel: ClockIdxExt(2), msg: Set(SubmasterLevel(UnipolarFloat(0.5039370078740157))) }) - CC 12:0 -> Clock(ControlMessage { channel: ClockIdxExt(3), msg: Set(Rate(BipolarFloat(0.0))) }) - CC 12:1 -> Clock(ControlMessage { channel: ClockIdxExt(3), msg: Set(SubmasterLevel(UnipolarFloat(0.5039370078740157))) }) + CC 9:0 -> Clock(ControlMessage { channel: ClockIdx(0), msg: Set(Rate(BipolarFloat(0.0))) }) + CC 9:1 -> Clock(ControlMessage { channel: ClockIdx(0), msg: Set(SubmasterLevel(UnipolarFloat(0.5039370078740157))) }) + CC 10:0 -> Clock(ControlMessage { channel: ClockIdx(1), msg: Set(Rate(BipolarFloat(0.0))) }) + CC 10:1 -> Clock(ControlMessage { channel: ClockIdx(1), msg: Set(SubmasterLevel(UnipolarFloat(0.5039370078740157))) }) + CC 11:0 -> Clock(ControlMessage { channel: ClockIdx(2), msg: Set(Rate(BipolarFloat(0.0))) }) + CC 11:1 -> Clock(ControlMessage { channel: ClockIdx(2), msg: Set(SubmasterLevel(UnipolarFloat(0.5039370078740157))) }) + CC 12:0 -> Clock(ControlMessage { channel: ClockIdx(3), msg: Set(Rate(BipolarFloat(0.0))) }) + CC 12:1 -> Clock(ControlMessage { channel: ClockIdx(3), msg: Set(SubmasterLevel(UnipolarFloat(0.5039370078740157))) }) From 84526de9097dc3d23ed95cfce8db5ba998b7f08d Mon Sep 17 00:00:00 2001 From: general electrix Date: Mon, 6 Jul 2026 17:32:14 -0600 Subject: [PATCH 2/8] Validate clock ID at the master UI level. --- tunnels/src/master_ui.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tunnels/src/master_ui.rs b/tunnels/src/master_ui.rs index fdf4ca78..db847e11 100644 --- a/tunnels/src/master_ui.rs +++ b/tunnels/src/master_ui.rs @@ -2,7 +2,7 @@ use crate::{ audio::{AudioInput, ShowEmitter}, beam::Beam, beam_store::{BeamStore, BeamStoreAddr}, - clock_bank::ClockBank, + clock_bank::{ClockBank, ClockStore}, gui_state::GuiDirty, midi_controls::MIXER_CHANNELS_PER_PAGE, mixer::{ChannelIdx, Mixer}, @@ -12,6 +12,7 @@ use crate::{ tunnel::{AnimationIdx, TargetedAnimation}, }; +use log::error; use serde::{Deserialize, Serialize}; /// Manage stateful aspects of the UI. @@ -86,7 +87,15 @@ impl MasterUI { GuiDirty::CLEAN } Animation(am) => { - if let Some(a) = self.current_animation(mixer) { + if let crate::animation::ControlMessage::SetClockSource(Some(clock_id)) = am + && clock_id.0 >= clocks.len() + { + error!( + "clock ID {} out of range, only {} clocks are configured", + clock_id.0, + clocks.len() + ); + } else if let Some(a) = self.current_animation(mixer) { a.animation.control(am, emitter); } GuiDirty::CLEAN From fcf1e2e9a6c5f14c5b1ac57f1d6a5169dc0ab75d Mon Sep 17 00:00:00 2001 From: general electrix Date: Mon, 6 Jul 2026 22:46:31 -0600 Subject: [PATCH 3/8] Add machinery for having multiple clock wings. --- console/src/main.rs | 4 +- tunnels/src/clock_bank.rs | 3 + tunnels/src/midi.rs | 55 +++++++++++++++--- tunnels/src/midi_controls/animation.rs | 3 + tunnels/src/midi_controls/audio.rs | 5 +- tunnels/src/midi_controls/clock.rs | 80 ++++++++++++++++++++------ tunnels/src/midi_controls/device.rs | 16 ++++-- tunnels/src/show.rs | 11 ++-- 8 files changed, 138 insertions(+), 39 deletions(-) diff --git a/console/src/main.rs b/console/src/main.rs index 3850f8d9..3e777ed9 100644 --- a/console/src/main.rs +++ b/console/src/main.rs @@ -128,7 +128,9 @@ fn main() -> Result<()> { let (envelope_tx, envelope_rx) = channel(); std::thread::spawn(move || { - let mut show = Show::new(send, recv, show_gui_state, envelope_tx) + // TODO(C1b): replace the hardcoded wing count with the startup splash answer. + let n_clock_wings = 1; + let mut show = Show::new(send, recv, show_gui_state, envelope_tx, n_clock_wings) .expect("show construction should not fail at startup"); loop { if let Err(e) = show.run(RENDER_INTERVAL) { diff --git a/tunnels/src/clock_bank.rs b/tunnels/src/clock_bank.rs index f674feeb..3f0dc24f 100644 --- a/tunnels/src/clock_bank.rs +++ b/tunnels/src/clock_bank.rs @@ -46,6 +46,9 @@ pub const MAX_CLOCKS: usize = 12; /// The number of clocks in a bank when no count is otherwise specified. pub const DEFAULT_N_CLOCKS: usize = 4; +/// The number of clocks contributed by one clock wing. +pub const CLOCKS_PER_WING: usize = 4; + #[derive(Debug, Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Serialize, Deserialize)] /// Index of a clock in a bank. /// diff --git a/tunnels/src/midi.rs b/tunnels/src/midi.rs index b50b8d58..ef84e387 100644 --- a/tunnels/src/midi.rs +++ b/tunnels/src/midi.rs @@ -8,6 +8,7 @@ use std::sync::mpsc::Sender; use tunnels_lib::prompt::{prompt_bool, prompt_indexed_value}; use crate::{ + clock_bank::CLOCKS_PER_WING, control::ControlEvent, midi_controls::{Device, MidiDevice}, }; @@ -21,9 +22,15 @@ pub enum MidiDeviceInit { Slot { name: String, device: Device }, } -/// The standard set of MIDI device slots for a tunnels show. -pub fn default_midi_slots() -> Vec { - vec![ +/// The name of the clock-wing slot at the given zero-based wing index. +pub fn clock_wing_slot_name(wing: usize) -> String { + format!("Clock Wing {}", wing + 1) +} + +/// The standard set of MIDI device slots for a tunnels show, with `n_clock_wings` +/// CMD MM-1 clock wings each mapped to a contiguous block of clocks. +pub fn default_midi_slots(n_clock_wings: usize) -> Vec { + let mut slots = vec![ MidiDeviceInit::Slot { name: "APC-40".into(), device: Device::AkaiApc40, @@ -32,11 +39,16 @@ pub fn default_midi_slots() -> Vec { name: "TouchOSC".into(), device: Device::TouchOsc, }, - MidiDeviceInit::Slot { - name: "Clock Wing".into(), - device: Device::BehringerCmdMM1, - }, - ] + ]; + for wing in 0..n_clock_wings { + slots.push(MidiDeviceInit::Slot { + name: clock_wing_slot_name(wing), + device: Device::BehringerCmdMM1 { + channel_offset: wing * CLOCKS_PER_WING, + }, + }); + } + slots } pub use midi_harness::event::*; @@ -204,3 +216,30 @@ fn prompt_input_output( output_id, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clock_wings_get_cumulative_offsets() { + let wings: Vec<(String, usize)> = default_midi_slots(3) + .into_iter() + .filter_map(|s| match s { + MidiDeviceInit::Slot { + name, + device: Device::BehringerCmdMM1 { channel_offset }, + } => Some((name, channel_offset)), + _ => None, + }) + .collect(); + assert_eq!( + wings, + vec![ + ("Clock Wing 1".to_string(), 0), + ("Clock Wing 2".to_string(), 4), + ("Clock Wing 3".to_string(), 8), + ] + ); + } +} diff --git a/tunnels/src/midi_controls/animation.rs b/tunnels/src/midi_controls/animation.rs index 5aedf700..aedb81c4 100644 --- a/tunnels/src/midi_controls/animation.rs +++ b/tunnels/src/midi_controls/animation.rs @@ -43,6 +43,9 @@ lazy_static! { }; static ref CLOCK_SELECT_BUTTONS: RadioButtons = RadioButtons { // -1 corresponds to "internal", the rest as global clock IDs. + // NOTE: expanding this past a handful of clocks collides with other APC40 + // control numbers (offset 112 + clock id); a wider MIDI clock-source + // selector needs a deliberate control-layout change. mappings: (-1..DEFAULT_N_CLOCKS as i32) .map(|clock_id| note_on_ch0((clock_id + CLOCK_SELECT_CONTROL_OFFSET) as u8)) .collect(), diff --git a/tunnels/src/midi_controls/audio.rs b/tunnels/src/midi_controls/audio.rs index f3107030..142d7ffb 100644 --- a/tunnels/src/midi_controls/audio.rs +++ b/tunnels/src/midi_controls/audio.rs @@ -60,15 +60,16 @@ pub(crate) fn update_audio_control(sc: StateChange, manager: &mut impl MidiOutpu match sc { EnvelopeValue(v) => { send(event(MONITOR, unipolar_to_midi(v))); + // Audio metering is global; address the first (canonical) clock wing. manager.send( - &Device::BehringerCmdMM1, + &Device::BehringerCmdMM1 { channel_offset: 0 }, event(CMD_MM1_VU_METER, 48 + (v.val() * 15.) as u8), ); } Monitor(v) => { send(event(MONITOR_TOGGLE, v as u8)); manager.send( - &Device::BehringerCmdMM1, + &Device::BehringerCmdMM1 { channel_offset: 0 }, event(CMD_MM1_MONITOR_TOGGLE, v as u8), ); } diff --git a/tunnels/src/midi_controls/clock.rs b/tunnels/src/midi_controls/clock.rs index 9fd86dee..68b36118 100644 --- a/tunnels/src/midi_controls/clock.rs +++ b/tunnels/src/midi_controls/clock.rs @@ -5,9 +5,9 @@ use crate::midi::Event as MidiEvent; use crate::{ clock::ControlMessage as ClockControlMessage, clock::StateChange as ClockStateChange, + clock_bank::CLOCKS_PER_WING, clock_bank::ClockIdx, clock_bank::ControlMessage, - clock_bank::DEFAULT_N_CLOCKS, clock_bank::StateChange, midi::{Mapping, MidiOutput, cc, event, note_on}, midi_controls::Device, @@ -66,44 +66,51 @@ fn mapping_touchosc(control: Control, channel: usize) -> Option { }) } +/// TouchOSC clock control maps one MIDI channel per clock starting at channel 9, +/// so it can only address clocks that fit within the MIDI channel range. The +/// primary clock surface is the CMD MM-1 wings. +const TOUCHOSC_CLOCK_CHANNELS: usize = 4; + fn interpret_with_mapping_fn( event: &MidiEvent, get_mapping: fn(Control, usize) -> Option, + channel_offset: usize, + n_channels: usize, ) -> Option { use ClockControlMessage::*; use ClockStateChange::*; let v = event.value; - for channel in 0..DEFAULT_N_CLOCKS { + for local in 0..n_channels { let mkmsg = |msg| { crate::show::ControlMessage::Clock(ControlMessage { - channel: ClockIdx(channel), + channel: ClockIdx(local + channel_offset), msg, }) }; - if get_mapping(Control::Rate, channel) == Some(event.mapping) { + if get_mapping(Control::Rate, local) == Some(event.mapping) { return Some(mkmsg(Set(Rate(bipolar_from_midi(v))))); } - if get_mapping(Control::RateFine, channel) == Some(event.mapping) { + if get_mapping(Control::RateFine, local) == Some(event.mapping) { return Some(mkmsg(Set(RateFine(bipolar_from_midi(v))))); } - if get_mapping(Control::Level, channel) == Some(event.mapping) { + if get_mapping(Control::Level, local) == Some(event.mapping) { return Some(mkmsg(Set(SubmasterLevel(unipolar_from_midi(v))))); } - if get_mapping(Control::Tap, channel) == Some(event.mapping) { + if get_mapping(Control::Tap, local) == Some(event.mapping) { return Some(mkmsg(Tap)); } - if get_mapping(Control::OneShot, channel) == Some(event.mapping) { + if get_mapping(Control::OneShot, local) == Some(event.mapping) { return Some(mkmsg(ToggleOneShot)); } - if get_mapping(Control::Retrigger, channel) == Some(event.mapping) { + if get_mapping(Control::Retrigger, local) == Some(event.mapping) { return Some(mkmsg(Retrigger)); } - if get_mapping(Control::AudioSize, channel) == Some(event.mapping) { + if get_mapping(Control::AudioSize, local) == Some(event.mapping) { return Some(mkmsg(ToggleUseAudioSize)); } - if get_mapping(Control::AudioSpeed, channel) == Some(event.mapping) { + if get_mapping(Control::AudioSpeed, local) == Some(event.mapping) { return Some(mkmsg(ToggleUseAudioSpeed)); } } @@ -111,22 +118,39 @@ fn interpret_with_mapping_fn( } pub fn interpret_touchosc(event: &MidiEvent) -> Option { - interpret_with_mapping_fn(event, mapping_touchosc) + interpret_with_mapping_fn(event, mapping_touchosc, 0, TOUCHOSC_CLOCK_CHANNELS) } -pub fn interpret_cmdmm1(event: &MidiEvent) -> Option { - interpret_with_mapping_fn(event, mapping_cmd_mm1) +/// Interpret a CMD MM-1 clock-wing event, mapping its physical faders to the +/// clocks starting at `channel_offset`. +pub fn interpret_cmdmm1( + event: &MidiEvent, + channel_offset: usize, +) -> Option { + interpret_with_mapping_fn(event, mapping_cmd_mm1, channel_offset, CLOCKS_PER_WING) } /// Emit midi messages to update UIs given the provided state change. pub fn update_clock_control(sc: StateChange, manager: &mut impl MidiOutput) { use ClockStateChange::*; + // Route feedback to the wing that owns this clock, at its local fader index. + let global = sc.channel.0; + let wing_offset = (global / CLOCKS_PER_WING) * CLOCKS_PER_WING; + let local = global % CLOCKS_PER_WING; + let mut send = |control, value| { - if let Some(mapping) = mapping_cmd_mm1(control, sc.channel.0) { - manager.send(&Device::BehringerCmdMM1, event(mapping, value)); + if let Some(mapping) = mapping_cmd_mm1(control, local) { + manager.send( + &Device::BehringerCmdMM1 { + channel_offset: wing_offset, + }, + event(mapping, value), + ); } - if let Some(mapping) = mapping_touchosc(control, sc.channel.0) { + if global < TOUCHOSC_CLOCK_CHANNELS + && let Some(mapping) = mapping_touchosc(control, global) + { manager.send(&Device::TouchOsc, event(mapping, value)); } }; @@ -141,3 +165,25 @@ pub fn update_clock_control(sc: StateChange, manager: &mut impl MidiOutput) { UseAudioSpeed(v) => send(Control::AudioSpeed, v as u8), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cmdmm1_wing_offset_selects_the_right_clock() { + // The Rate control for a wing's first fader is cc(4, 6). + let event = MidiEvent { + mapping: cc(4, 6), + value: 64, + }; + for (offset, expected) in [(0usize, 0usize), (4, 4), (8, 8)] { + match interpret_cmdmm1(&event, offset) { + Some(crate::show::ControlMessage::Clock(ControlMessage { channel, .. })) => { + assert_eq!(channel.0, expected, "wing offset {offset}"); + } + other => panic!("expected a clock control message for offset {offset}, got {other:?}"), + } + } + } +} diff --git a/tunnels/src/midi_controls/device.rs b/tunnels/src/midi_controls/device.rs index f780521d..705ba89f 100644 --- a/tunnels/src/midi_controls/device.rs +++ b/tunnels/src/midi_controls/device.rs @@ -10,7 +10,11 @@ pub enum Device { AkaiApc40, AkaiApc20, TouchOsc, - BehringerCmdMM1, + /// A CMD MM-1 acting as a clock wing. `channel_offset` is the index of the + /// first clock it controls. + BehringerCmdMM1 { + channel_offset: usize, + }, } impl std::fmt::Display for Device { @@ -24,7 +28,7 @@ impl Device { vec![ Self::AkaiApc40, Self::TouchOsc, - Self::BehringerCmdMM1, + Self::BehringerCmdMM1 { channel_offset: 0 }, // TODO: update support for Apc20. ] } @@ -42,7 +46,7 @@ impl InitMidiDevice for Device { Self::AkaiApc40 => init_apc_40(out), Self::AkaiApc20 => init_apc_20(out), Self::TouchOsc => Ok(()), - Self::BehringerCmdMM1 => Ok(()), + Self::BehringerCmdMM1 { .. } => Ok(()), } } } @@ -54,7 +58,7 @@ impl MidiDevice for Device { Self::AkaiApc40 => "Akai APC40", Self::AkaiApc20 => "Akai APC20", Self::TouchOsc => "TouchOSC Bridge", - Self::BehringerCmdMM1 => "CMD MM-1", + Self::BehringerCmdMM1 { .. } => "CMD MM-1", } } } @@ -145,8 +149,8 @@ impl MidiHandler for Device { .or_else(|| super::master_ui::interpret(event, 0)) .or_else(|| super::clock::interpret_touchosc(event)) .or_else(|| super::audio::interpret_touchosc(event)), - Device::BehringerCmdMM1 => None - .or_else(|| super::clock::interpret_cmdmm1(event)) + Device::BehringerCmdMM1 { channel_offset } => None + .or_else(|| super::clock::interpret_cmdmm1(event, *channel_offset)) .or_else(|| super::audio::interpret_cmdmm1(event)), } } diff --git a/tunnels/src/show.rs b/tunnels/src/show.rs index 2a4c1826..5342e941 100644 --- a/tunnels/src/show.rs +++ b/tunnels/src/show.rs @@ -56,8 +56,9 @@ impl Show { recv_control_event: Receiver, gui_state: SharedGuiState, envelope_streams_tx: Sender, + n_clock_wings: usize, ) -> Result { - let midi_devices = default_midi_slots(); + let midi_devices = default_midi_slots(n_clock_wings); // Determine if we need to configure a double-wide mixer for APC20 wing. let use_wing = midi_devices.iter().any(|init| match init { @@ -79,7 +80,7 @@ impl Show { state: ShowState { ui: MasterUI::new(n_pages), mixer: Mixer::new(n_pages), - clocks: ClockBank::default(), + clocks: ClockBank::new(n_clock_wings * clock_bank::CLOCKS_PER_WING), positions: PositionBank::default(), color_palette: ColorPalette::new(), }, @@ -432,7 +433,7 @@ mod test { fn test_render() -> Result<()> { let (send, recv) = channel(); let (envelope_tx, _envelope_rx) = channel(); - let mut show = Show::new(send, recv, test_gui_state(), envelope_tx)?; + let mut show = Show::new(send, recv, test_gui_state(), envelope_tx, 1)?; show.test_mode(stress); @@ -535,7 +536,7 @@ mod test { Device::AkaiApc40, Device::AkaiApc20, Device::TouchOsc, - Device::BehringerCmdMM1, + Device::BehringerCmdMM1 { channel_offset: 0 }, ]; let event_types = [ EventType::NoteOn, @@ -1023,7 +1024,7 @@ mod test { fn test_new() -> (Self, Sender) { let (send, recv) = channel(); let (envelope_tx, _envelope_rx) = channel(); - let show = Show::new(send.clone(), recv, test_gui_state(), envelope_tx).unwrap(); + let show = Show::new(send.clone(), recv, test_gui_state(), envelope_tx, 1).unwrap(); (show, send) } } From be01dfb3c62720cd76504c4bfbf471952739c575 Mon Sep 17 00:00:00 2001 From: general electrix Date: Fri, 10 Jul 2026 01:47:57 -0700 Subject: [PATCH 4/8] Select clock wing could using an initial splash. --- console/src/lib.rs | 1 + console/src/main.rs | 9 ++- console/src/startup_config.rs | 105 ++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 console/src/startup_config.rs diff --git a/console/src/lib.rs b/console/src/lib.rs index 4b0f82f0..950b5af9 100644 --- a/console/src/lib.rs +++ b/console/src/lib.rs @@ -3,6 +3,7 @@ mod animation_panel; mod audio_panel; pub mod bootstrap_controller; mod midi_panel; +pub mod startup_config; mod ui_util; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/console/src/main.rs b/console/src/main.rs index 3e777ed9..2972e0ac 100644 --- a/console/src/main.rs +++ b/console/src/main.rs @@ -72,6 +72,13 @@ fn main() -> Result<()> { let (send_control_event, recv_control_event) = channel(); install_midi_device_change_handler(ControlEventHandler(send_control_event.clone()))?; + // Ask how many clock wings to configure before opening the main window. + // Closing the splash without starting exits cleanly. + let Some(config) = console::startup_config::run_startup_config()? else { + return Ok(()); + }; + let n_clock_wings = config.n_clock_wings; + let client = CommandClient::new(send_control_event.clone()); let admin: Arc = @@ -128,8 +135,6 @@ fn main() -> Result<()> { let (envelope_tx, envelope_rx) = channel(); std::thread::spawn(move || { - // TODO(C1b): replace the hardcoded wing count with the startup splash answer. - let n_clock_wings = 1; let mut show = Show::new(send, recv, show_gui_state, envelope_tx, n_clock_wings) .expect("show construction should not fail at startup"); loop { diff --git a/console/src/startup_config.rs b/console/src/startup_config.rs new file mode 100644 index 00000000..560a9d18 --- /dev/null +++ b/console/src/startup_config.rs @@ -0,0 +1,105 @@ +use std::sync::{Arc, Mutex}; + +use anyhow::Result; +use eframe::egui; +use tunnels::clock_bank::{CLOCKS_PER_WING, MAX_CLOCKS}; + +/// The maximum number of clock wings the session may configure. +pub const MAX_WINGS: usize = MAX_CLOCKS / CLOCKS_PER_WING; + +/// The session configuration chosen at startup. +#[derive(Debug, Clone, Copy)] +pub struct StartupConfig { + /// Number of clock-control wings, each contributing `CLOCKS_PER_WING` clocks. + pub n_clock_wings: usize, +} + +/// Clamp a requested wing count into the valid `1..=MAX_WINGS` range. +fn clamp_wings(n: usize) -> usize { + n.clamp(1, MAX_WINGS) +} + +struct StartupConfigApp { + /// Written when the user commits a choice, read after the window closes. + result: Arc>>, + n_clock_wings: usize, +} + +impl eframe::App for StartupConfigApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(16.0); + ui.heading("Tunnels"); + ui.add_space(24.0); + + ui.horizontal(|ui| { + ui.label("Clock wings:"); + ui.add(egui::DragValue::new(&mut self.n_clock_wings).range(1..=MAX_WINGS)); + }); + self.n_clock_wings = clamp_wings(self.n_clock_wings); + + let n_clocks = self.n_clock_wings * CLOCKS_PER_WING; + ui.add_space(8.0); + ui.label(format!("{n_clocks} clocks")); + + ui.add_space(24.0); + if ui.button("Start").clicked() { + *self.result.lock().expect("startup config mutex poisoned") = + Some(StartupConfig { + n_clock_wings: self.n_clock_wings, + }); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + }); + } +} + +/// Run the startup config splash, blocking until the user starts a session or +/// closes the window. Returns the chosen config, or `None` if the window was +/// closed without starting. +pub fn run_startup_config() -> Result> { + let result: Arc>> = Arc::new(Mutex::new(None)); + let app_result = result.clone(); + + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([320.0, 220.0]) + .with_resizable(false) + .with_icon(std::sync::Arc::new(egui::IconData::default())), + ..Default::default() + }; + + eframe::run_native( + "Tunnels", + options, + Box::new(move |cc| { + stage_theme::apply(&cc.egui_ctx); + Ok(Box::new(StartupConfigApp { + result: app_result, + n_clock_wings: 1, + })) + }), + ) + .map_err(|e| anyhow::anyhow!("eframe startup config window failed: {e}"))?; + + let inner = Arc::try_unwrap(result) + .map_err(|_| anyhow::anyhow!("startup config Arc still shared"))? + .into_inner() + .expect("startup config mutex poisoned"); + Ok(inner) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clamps_wings_to_valid_range() { + assert_eq!(clamp_wings(0), 1); + assert_eq!(clamp_wings(1), 1); + assert_eq!(clamp_wings(MAX_WINGS), MAX_WINGS); + assert_eq!(clamp_wings(MAX_WINGS + 5), MAX_WINGS); + } +} From 4db692475f63e24434206f97bbdcc76ad2791eb7 Mon Sep 17 00:00:00 2001 From: general electrix Date: Fri, 10 Jul 2026 01:48:36 -0700 Subject: [PATCH 5/8] fmt --- tunnels/src/midi_controls/clock.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tunnels/src/midi_controls/clock.rs b/tunnels/src/midi_controls/clock.rs index 68b36118..f4ce227f 100644 --- a/tunnels/src/midi_controls/clock.rs +++ b/tunnels/src/midi_controls/clock.rs @@ -182,7 +182,9 @@ mod tests { Some(crate::show::ControlMessage::Clock(ControlMessage { channel, .. })) => { assert_eq!(channel.0, expected, "wing offset {offset}"); } - other => panic!("expected a clock control message for offset {offset}, got {other:?}"), + other => { + panic!("expected a clock control message for offset {offset}, got {other:?}") + } } } } From a9cfc30680387d35f75126f4aa3c9566bba2c4e1 Mon Sep 17 00:00:00 2001 From: general electrix Date: Fri, 10 Jul 2026 02:21:38 -0700 Subject: [PATCH 6/8] Remove runtime resize method. --- tunnels/src/clock_bank.rs | 30 ++++-------------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/tunnels/src/clock_bank.rs b/tunnels/src/clock_bank.rs index 3f0dc24f..6dd739ae 100644 --- a/tunnels/src/clock_bank.rs +++ b/tunnels/src/clock_bank.rs @@ -9,7 +9,7 @@ use crate::{ master_ui::EmitStateChange as EmitShowStateChange, }; use arrayvec::ArrayVec; -use log::{error, warn}; +use log::error; use serde::{Deserialize, Serialize}; use tunnels_lib::number::{Phase, UnipolarFloat}; @@ -97,19 +97,6 @@ impl ClockBank { Self((0..n).map(|_| ControllableClock::default()).collect()) } - /// Grow or shrink the bank to hold `n` clocks, clamped to [`MAX_CLOCKS`]. - /// New clocks are added in their default state; removed clocks are dropped. - pub fn set_clock_count(&mut self, n: usize) { - let clamped = n.min(MAX_CLOCKS); - if clamped != n { - warn!("requested {n} clocks exceeds the maximum of {MAX_CLOCKS}; clamping."); - } - while self.0.len() < clamped { - self.0.push(ControllableClock::default()); - } - self.0.truncate(clamped); - } - pub fn update_state( &mut self, delta_t: Duration, @@ -204,21 +191,12 @@ mod tests { use super::*; #[test] - fn clock_count_grows_shrinks_and_clamps() { - let mut bank = ClockBank::new(4); + fn new_sizes_the_bank_and_clamps_to_max() { + let bank = ClockBank::new(4); assert_eq!(bank.len(), 4); assert_eq!(bank.as_static().len(), 4); - bank.set_clock_count(8); - assert_eq!(bank.len(), 8); - assert_eq!(bank.as_static().len(), 8); - - bank.set_clock_count(4); - assert_eq!(bank.len(), 4); - - // Requests beyond MAX_CLOCKS clamp rather than overflow. - bank.set_clock_count(MAX_CLOCKS + 5); - assert_eq!(bank.len(), MAX_CLOCKS); + // Construction beyond MAX_CLOCKS clamps rather than overflowing. assert_eq!(ClockBank::new(MAX_CLOCKS + 5).len(), MAX_CLOCKS); } From 14d9507d1259110ecf37a3bb99416a229ea3b851 Mon Sep 17 00:00:00 2001 From: general electrix Date: Fri, 10 Jul 2026 02:30:04 -0700 Subject: [PATCH 7/8] Make ClockBank return values Option. --- tunnels/src/animation.rs | 11 ++++--- tunnels/src/clock_bank.rs | 62 +++++++++++++++++++------------------ tunnels/src/clock_server.rs | 30 +++++++++--------- 3 files changed, 54 insertions(+), 49 deletions(-) diff --git a/tunnels/src/animation.rs b/tunnels/src/animation.rs index fba5c4c5..d23d4c20 100644 --- a/tunnels/src/animation.rs +++ b/tunnels/src/animation.rs @@ -105,14 +105,15 @@ impl Animation { fn phase(&self, external_clocks: &impl ClockStore) -> Phase { match self.clock_source { None => self.internal_clock.phase(), - Some(id) => external_clocks.phase(id), + // A selected clock that no longer exists reads as the neutral default. + Some(id) => external_clocks.phase(id).unwrap_or_default(), } } fn ticks(&self, external_clocks: &impl ClockStore) -> Ticks { match self.clock_source { None => self.internal_clock.ticks(), - Some(id) => external_clocks.ticks(id), + Some(id) => external_clocks.ticks(id).unwrap_or_default(), } } @@ -235,8 +236,10 @@ impl Animation { // scale this animation by submaster level if using external clock let mut use_audio_size = self.use_audio_size; if let Some(id) = self.clock_source { - v *= external_clocks.submaster_level(id).val(); - use_audio_size = use_audio_size || external_clocks.use_audio_size(id); + // A selected clock that no longer exists reads as the neutral default + // (submaster 0 → this animation contributes nothing). + v *= external_clocks.submaster_level(id).unwrap_or_default().val(); + use_audio_size = use_audio_size || external_clocks.use_audio_size(id).unwrap_or_default(); } // scale this animation by audio envelope if set if use_audio_size { diff --git a/tunnels/src/clock_bank.rs b/tunnels/src/clock_bank.rs index 6dd739ae..475e501e 100644 --- a/tunnels/src/clock_bank.rs +++ b/tunnels/src/clock_bank.rs @@ -23,20 +23,22 @@ pub trait ClockStore { self.len() == 0 } - /// Return the current phase of this clock. - fn phase(&self, index: ClockIdx) -> Phase; - - /// Returnt the absolute number of ticks. - fn ticks(&self, index: ClockIdx) -> Ticks; - - /// Return the current submaster level of this clock. - fn submaster_level(&self, index: ClockIdx) -> UnipolarFloat; - - /// Return true if we should use audio envelope to scale submaster level. - /// This is returned independently, rather than applied to the submaster - /// level directly, to allow clients of the submaster to avoid double- - /// modulating with audio envelope. - fn use_audio_size(&self, index: ClockIdx) -> bool; + /// Return the phase of the clock at `index`, or `None` if it does not exist. + fn phase(&self, index: ClockIdx) -> Option; + + /// Return the absolute number of ticks of the clock at `index`, or `None` if + /// it does not exist. + fn ticks(&self, index: ClockIdx) -> Option; + + /// Return the submaster level of the clock at `index`, or `None` if it does + /// not exist. + fn submaster_level(&self, index: ClockIdx) -> Option; + + /// Return whether the clock at `index` scales its submaster by the audio + /// envelope, or `None` if it does not exist. This is returned independently, + /// rather than applied to the submaster level directly, to allow clients to + /// avoid double-modulating with the audio envelope. + fn use_audio_size(&self, index: ClockIdx) -> Option; } /// The maximum number of clocks a bank can hold. The number in use is a runtime @@ -53,7 +55,7 @@ pub const CLOCKS_PER_WING: usize = 4; /// Index of a clock in a bank. /// /// Not a proof of validity: the clock count is dynamic, so a read for an -/// out-of-range index yields a neutral default rather than a value. +/// out-of-range index returns `None`. pub struct ClockIdx(pub usize); /// Maintain an indexable collection of clocks. @@ -71,22 +73,20 @@ impl ClockStore for ClockBank { self.0.len() } - fn phase(&self, index: ClockIdx) -> Phase { - self.get(index).map(|c| c.phase()).unwrap_or(Phase::ZERO) + fn phase(&self, index: ClockIdx) -> Option { + self.get(index).map(|c| c.phase()) } - fn ticks(&self, index: ClockIdx) -> Ticks { - self.get(index).map(|c| c.ticks()).unwrap_or(0) + fn ticks(&self, index: ClockIdx) -> Option { + self.get(index).map(|c| c.ticks()) } - fn submaster_level(&self, index: ClockIdx) -> UnipolarFloat { - self.get(index) - .map(|c| c.submaster_level()) - .unwrap_or(UnipolarFloat::ZERO) + fn submaster_level(&self, index: ClockIdx) -> Option { + self.get(index).map(|c| c.submaster_level()) } - fn use_audio_size(&self, index: ClockIdx) -> bool { - self.get(index).map(|c| c.use_audio_size()).unwrap_or(false) + fn use_audio_size(&self, index: ClockIdx) -> Option { + self.get(index).map(|c| c.use_audio_size()) } } @@ -201,13 +201,15 @@ mod tests { } #[test] - fn out_of_range_index_reads_are_neutral() { + fn out_of_range_index_reads_are_none() { let bank = ClockBank::new(4); let missing = ClockIdx(9); assert!(bank.get(missing).is_none()); - assert_eq!(bank.phase(missing), Phase::ZERO); - assert_eq!(bank.ticks(missing), 0); - assert_eq!(bank.submaster_level(missing), UnipolarFloat::ZERO); - assert!(!bank.use_audio_size(missing)); + assert!(bank.phase(missing).is_none()); + assert!(bank.ticks(missing).is_none()); + assert!(bank.submaster_level(missing).is_none()); + assert!(bank.use_audio_size(missing).is_none()); + // In-range reads return Some. + assert!(bank.phase(ClockIdx(0)).is_some()); } } diff --git a/tunnels/src/clock_server.rs b/tunnels/src/clock_server.rs index cbe4f1fc..f8ee20ae 100644 --- a/tunnels/src/clock_server.rs +++ b/tunnels/src/clock_server.rs @@ -82,22 +82,20 @@ impl ClockStore for StaticClockBank { self.0.len() } - fn phase(&self, index: ClockIdx) -> Phase { - self.get(index).map(|c| c.phase).unwrap_or(Phase::ZERO) + fn phase(&self, index: ClockIdx) -> Option { + self.get(index).map(|c| c.phase) } - fn ticks(&self, index: ClockIdx) -> crate::clock::Ticks { - self.get(index).map(|c| c.ticks).unwrap_or(0) + fn ticks(&self, index: ClockIdx) -> Option { + self.get(index).map(|c| c.ticks) } - fn submaster_level(&self, index: ClockIdx) -> UnipolarFloat { - self.get(index) - .map(|c| c.submaster_level) - .unwrap_or(UnipolarFloat::ZERO) + fn submaster_level(&self, index: ClockIdx) -> Option { + self.get(index).map(|c| c.submaster_level) } - fn use_audio_size(&self, index: ClockIdx) -> bool { - self.get(index).map(|c| c.use_audio_size).unwrap_or(false) + fn use_audio_size(&self, index: ClockIdx) -> Option { + self.get(index).map(|c| c.use_audio_size) } } @@ -208,13 +206,15 @@ mod tests { } #[test] - fn out_of_range_reads_return_neutral_defaults() { + fn out_of_range_reads_return_none() { let b = bank(4); let missing = ClockIdx(9); - assert_eq!(b.phase(missing), Phase::ZERO); - assert_eq!(b.ticks(missing), 0); - assert_eq!(b.submaster_level(missing), UnipolarFloat::ZERO); - assert!(!b.use_audio_size(missing)); + assert!(b.phase(missing).is_none()); + assert!(b.ticks(missing).is_none()); + assert!(b.submaster_level(missing).is_none()); + assert!(b.use_audio_size(missing).is_none()); assert_eq!(b.len(), 4); + // In-range reads return Some. + assert!(b.phase(ClockIdx(0)).is_some()); } } From 5b9be2e695308f739ede7482d70f14659b300732 Mon Sep 17 00:00:00 2001 From: general electrix Date: Fri, 10 Jul 2026 02:32:10 -0700 Subject: [PATCH 8/8] fmt --- tunnels/src/animation.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tunnels/src/animation.rs b/tunnels/src/animation.rs index d23d4c20..23e7e555 100644 --- a/tunnels/src/animation.rs +++ b/tunnels/src/animation.rs @@ -238,8 +238,12 @@ impl Animation { if let Some(id) = self.clock_source { // A selected clock that no longer exists reads as the neutral default // (submaster 0 → this animation contributes nothing). - v *= external_clocks.submaster_level(id).unwrap_or_default().val(); - use_audio_size = use_audio_size || external_clocks.use_audio_size(id).unwrap_or_default(); + v *= external_clocks + .submaster_level(id) + .unwrap_or_default() + .val(); + use_audio_size = + use_audio_size || external_clocks.use_audio_size(id).unwrap_or_default(); } // scale this animation by audio envelope if set if use_audio_size {