From 2c2a69d1dd4b082bf09cab00ce49a964a02c088f Mon Sep 17 00:00:00 2001 From: Nathan Bradshaw Date: Thu, 16 Jul 2026 22:34:10 -0700 Subject: [PATCH 1/6] Mute melody synth in vocal effect modes In Vocode and Harmony modes, silence synth output on the melody channel (MIDI ch 1) while keeping it in the pitch-correction frequency cache. This allows the processed voice to be heard without the synth playing over it. Also fixes: - Prevent drum notes (channel 9) from affecting pitch control targets - In PitchControl mode, ignore MIDI note frequencies as pitch-correction targets to avoid force-tuning voice to held notes - Improve drum velocity response by compressing to 0.5..1.0 range and increase makeup gain from 0.75 to 2.5 --- src/handler.rs | 29 +++++++++++++++++++++++++++-- src/midi/voice_generator.rs | 22 ++++++++++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/handler.rs b/src/handler.rs index bb80cd9..f967edd 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -31,6 +31,10 @@ pub fn audio_handler( let mut bit_depth = 32; let mut volume_gain = 1.0f32; let mut waveform_compensation = 1.0f32; + // In Vocode / Harmony the melody channel (ch 1) drives the vocal effect only — + // its notes are silenced as raw synth so you hear the processed voice, not a + // synth playing over it. Every other mode plays the note sound normally. + let mut mute_melody_synth = false; shared.app_state_machine.lock(|msm| { let snapshot = msm.snapshot(); sr_factor = snapshot.sample_reduction; @@ -53,6 +57,14 @@ pub fn audio_handler( Waveform::Triangle => 1.2, Waveform::Square | Waveform::Saw => 1.0, }; + let profile = match snapshot.current_state { + AppState::Processing(p) | AppState::EffectsProfile(p) | AppState::Menu(_, p) => p, + AppState::Splash => ProcessingProfile::PitchControl, + }; + mute_melody_synth = matches!( + profile, + ProcessingProfile::Vocode | ProcessingProfile::Harmony + ); }); if audio.get_stereo(buffer) { @@ -172,7 +184,9 @@ pub fn audio_handler( // Get MIDI sample and mix it with the processed audio. Waveform // compensation applies only to the synthesized note, not the live // audio-in signal already folded into out_sample above. - let midi_sample = shared.voice_manager.lock(|vm| vm.get_mixed_sample()); + let midi_sample = shared + .voice_manager + .lock(|vm| vm.get_mixed_sample(mute_melody_synth)); out_sample = out_sample * 0.75 + (midi_sample * waveform_compensation) * 0.1; // Normalize final output @@ -473,12 +487,23 @@ pub fn handle_vocal_effects( } } + // In Pitch Control the voice must always autotune to the musical scale, exactly + // as it does with no note held. MIDI notes still play the synth (that mixing + // happens elsewhere), but they must not become the pitch-correction target — + // otherwise singing while playing force-tunes your voice to the held note. Other + // modes keep the live frequencies (Vocode/Harmony are driven by them). + let effect_midi_frequencies = if mode == ProcessingMode::PitchControl { + [0.0; 8] + } else { + midi_frequencies + }; + let musical_settings = MusicalSettings { formant, formant_male_ratio, formant_female_ratio, note, - midi_frequencies, + midi_frequencies: effect_midi_frequencies, key, octave_ratio: pitch_ratio, mode, diff --git a/src/midi/voice_generator.rs b/src/midi/voice_generator.rs index cd70ecd..0b7cbda 100644 --- a/src/midi/voice_generator.rs +++ b/src/midi/voice_generator.rs @@ -114,7 +114,14 @@ impl HybridVoice { self.note = None; self.velocity = 0; } - sample * vel_scale * 0.9 + // Compress velocity into 0.5..1.0 so soft hits stay audible while + // hard hits still accent — a linear 0..1 curve made low-velocity + // drums vanish under the sustained synth. + let drum_vel = 0.5 + 0.5 * (self.velocity as f32 * (1.0 / 127.0)); + // Drum makeup gain > 1.0: percussion is a short transient, so it + // needs a higher peak than a sustained synth note to feel equally + // loud. (Was 0.75, then 1.4.) + sample * drum_vel * 2.5 } } } @@ -193,6 +200,8 @@ impl VoiceManager { }; // Whether this channel feeds the vocal effects frequency cache. + // Sound-only channels and drum-map note numbers must not retune the + // frequency cache used by Pitch Control / Harmony / Vocode. let update_cache = !is_sound_only_channel(channel); // 1. Check for retrigger @@ -266,14 +275,23 @@ impl VoiceManager { } } + /// Mix all active voices. When `mute_melody_synth` is set (Vocode / Harmony + /// profiles), pitched synth voices on the melody channel (0 / MIDI ch 1) stay + /// silent — they still track pitch for the vocal-effect frequency cache, so + /// you hear your processed voice instead of a raw synth on top. Drums and any + /// other channel still sound. #[inline(always)] - pub fn get_mixed_sample(&mut self) -> f32 { + pub fn get_mixed_sample(&mut self, mute_melody_synth: bool) -> f32 { let mut sum = 0.0f32; let mut count = 0usize; for voice in &mut self.voices { let s = voice.get_sample(); if s != 0.0 { + if mute_melody_synth && voice.channel == 0 && voice.type_id() == VoiceTypeId::Synth + { + continue; + } sum += s; count += 1; } From 8b14f9fd2d34ebf55e5e2c4c46c6dc1c764f9b92 Mon Sep 17 00:00:00 2001 From: Nathan Bradshaw Date: Fri, 17 Jul 2026 10:50:00 -0700 Subject: [PATCH 2/6] Add persistent mixer volume controls Adds independent volume controls for voice, melody, and drums with power-loss-safe storage in QSPI flash. Features include dual-slot storage with generation-based recovery, CRC32 validation, and automatic save-after-idle (2 seconds) for changed settings. Volumes are restored on startup, and the master volume range is extended from 0-10 to 0-20. Also optimizes the debug profile for binary size by disabling assertions and overflow checks. --- Cargo.toml | 5 ++ src/handler.rs | 20 +++++-- src/lib.rs | 1 + src/main.rs | 37 +++++++++++- src/midi/voice_generator.rs | 17 +++++- src/settings_storage.rs | 113 ++++++++++++++++++++++++++++++++++++ src/state_machine/mod.rs | 37 +++++++++++- 7 files changed, 218 insertions(+), 12 deletions(-) create mode 100644 src/settings_storage.rs diff --git a/Cargo.toml b/Cargo.toml index bec98e3..4801242 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,11 @@ codegen-units = 1 # better optimizations debug = true # symbols are nice and they don't increase the size in flash lto = true # better optimizations opt-level = "s" # optimize for binary size +# This firmware is within ~1 KB of the STM32H750's 128 KB internal flash limit. +# Keep debug symbols, but omit runtime assertion/overflow machinery just like the +# release image so `cargo build` and the default probe-rs runner still fit. +debug-assertions = false +overflow-checks = false [profile.release] codegen-units = 1 # better optimizations diff --git a/src/handler.rs b/src/handler.rs index f967edd..7fc9b8d 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -29,12 +29,15 @@ pub fn audio_handler( let mut sr_factor = 1; let mut wave_type = Waveform::Sine; let mut bit_depth = 32; - let mut volume_gain = 1.0f32; let mut waveform_compensation = 1.0f32; // In Vocode / Harmony the melody channel (ch 1) drives the vocal effect only — // its notes are silenced as raw synth so you hear the processed voice, not a // synth playing over it. Every other mode plays the note sound normally. let mut mute_melody_synth = false; + let mut voice_gain = 1.0f32; + let mut melody_gain = 1.0f32; + let mut drum_gain = 1.0f32; + let mut master_gain = 1.0f32; shared.app_state_machine.lock(|msm| { let snapshot = msm.snapshot(); sr_factor = snapshot.sample_reduction; @@ -45,7 +48,6 @@ pub fn audio_handler( _ => Waveform::Sine, }; bit_depth = snapshot.bit_rate; - volume_gain = snapshot.volume as f32 / 10.0; // Sine and triangle are perceptually quieter than saw/square at the same // peak amplitude — they're spectrally pure, while saw/square spread energy // across many harmonics, which the ear perceives as louder. Compensate so @@ -65,6 +67,10 @@ pub fn audio_handler( profile, ProcessingProfile::Vocode | ProcessingProfile::Harmony ); + voice_gain = snapshot.voice_volume as f32 * 0.1; + melody_gain = snapshot.melody_volume as f32 * 0.1; + drum_gain = snapshot.drum_volume as f32 * 0.1; + master_gain = snapshot.volume as f32 * 0.1; }); if audio.get_stereo(buffer) { @@ -186,11 +192,13 @@ pub fn audio_handler( // audio-in signal already folded into out_sample above. let midi_sample = shared .voice_manager - .lock(|vm| vm.get_mixed_sample(mute_melody_synth)); - out_sample = out_sample * 0.75 + (midi_sample * waveform_compensation) * 0.1; + .lock(|vm| vm.get_mixed_sample(mute_melody_synth, melody_gain, drum_gain)); + out_sample = (out_sample * voice_gain + + (midi_sample * waveform_compensation) * 0.1) + * master_gain; // Normalize final output - out_sample = normalize_sample(out_sample, 0.8) * volume_gain; + out_sample = normalize_sample(out_sample, 0.8); // ********************************************** // Check and handle hop counter @@ -232,7 +240,7 @@ pub fn audio_handler( } pub fn interface_handler( - local: crate::rtic_app::app::interface_handler::LocalResources, + local: &mut crate::rtic_app::app::interface_handler::LocalResources, shared: &mut crate::rtic_app::app::interface_handler::SharedResources, ) { local.timer2.clear_irq(); diff --git a/src/lib.rs b/src/lib.rs index a710bb3..c91661b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,3 @@ #![no_std] +pub mod settings_storage; pub mod state_machine; diff --git a/src/main.rs b/src/main.rs index a9daaad..f654457 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ mod display; mod handler; mod input; mod midi; +mod settings_storage; mod state_machine; mod types; @@ -105,6 +106,7 @@ mod rtic_app { #[local] struct Local { audio: audio::Audio, + flash: libdaisy::flash::Flash, buffer: audio::AudioBuffer, timer2: Timer, knob_1: Knob, @@ -141,6 +143,7 @@ mod rtic_app { let device = ctx.device; let ccdr = system::System::init_clocks(device.PWR, device.RCC, &device.SYSCFG); let mut system = libdaisy::system_init!(core, device, ccdr, BLOCK_SIZE); + let saved_volumes = crate::settings_storage::load(&mut system.flash); let buffer = [(0.0, 0.0); audio::BLOCK_SIZE_MAX]; @@ -321,13 +324,19 @@ mod rtic_app { info!("Startup done!! yo!"); startup_complete_task::spawn().ok(); + let mut app_state_machine = AppStateMachine::new(); + if let Some(volumes) = saved_volumes { + app_state_machine.apply_volume_settings(volumes); + info!("restored mixer volumes"); + } + ( Shared { in_ring: RingBuffer::new(), out_ring: RingBuffer::with_offset((FFT_SIZE + (2 * HOP_SIZE)) as u32), previous_pitch_shift_ratio: 1.0, in_pointer_cached: 0, - app_state_machine: AppStateMachine::new(), + app_state_machine, old_matrix_state: [[false; 3]; 4], display_needs_update: true, midi_events: heapless::spsc::Queue::new(), @@ -335,6 +344,7 @@ mod rtic_app { }, Local { audio: system.audio, + flash: system.flash, buffer, timer2, knob_1, @@ -515,6 +525,8 @@ mod rtic_app { row_3_pin, row_4_pin, encoder_button, + flash, + save_countdown: u16 = 0, ], shared = [ app_state_machine, @@ -525,7 +537,28 @@ mod rtic_app { priority = 3 )] fn interface_handler(mut ctx: interface_handler::Context) { - crate::handler::interface_handler(ctx.local, &mut ctx.shared); + let before = ctx + .shared + .app_state_machine + .lock(|state| state.volume_settings()); + crate::handler::interface_handler(&mut ctx.local, &mut ctx.shared); + let after = ctx + .shared + .app_state_machine + .lock(|state| state.volume_settings()); + if after != before { + *ctx.local.save_countdown = 2000; + } else if *ctx.local.save_countdown > 0 { + *ctx.local.save_countdown -= 1; + if *ctx.local.save_countdown == 0 { + if crate::settings_storage::save(ctx.local.flash, after) { + info!("mixer volumes saved"); + } else { + log::warn!("mixer volume save failed"); + *ctx.local.save_countdown = 2000; + } + } + } } /// FFT TASK diff --git a/src/midi/voice_generator.rs b/src/midi/voice_generator.rs index 0b7cbda..7798d47 100644 --- a/src/midi/voice_generator.rs +++ b/src/midi/voice_generator.rs @@ -281,7 +281,12 @@ impl VoiceManager { /// you hear your processed voice instead of a raw synth on top. Drums and any /// other channel still sound. #[inline(always)] - pub fn get_mixed_sample(&mut self, mute_melody_synth: bool) -> f32 { + pub fn get_mixed_sample( + &mut self, + mute_melody_synth: bool, + melody_gain: f32, + drum_gain: f32, + ) -> f32 { let mut sum = 0.0f32; let mut count = 0usize; @@ -292,8 +297,14 @@ impl VoiceManager { { continue; } - sum += s; - count += 1; + let scaled = match voice.type_id() { + VoiceTypeId::Synth => s * melody_gain, + VoiceTypeId::Drum => s * drum_gain, + }; + if scaled != 0.0 { + sum += scaled; + count += 1; + } } } diff --git a/src/settings_storage.rs b/src/settings_storage.rs new file mode 100644 index 0000000..f9b7d93 --- /dev/null +++ b/src/settings_storage.rs @@ -0,0 +1,113 @@ +//! Power-loss-safe storage for phone mixer levels in onboard QSPI flash. + +use libdaisy::flash::{Flash, FlashErase}; + +const SLOT_ADDRESSES: [u32; 2] = [0x7F_C000, 0x7F_D000]; +const RECORD_SIZE: usize = 32; +const MAGIC: [u8; 4] = *b"SPHV"; +const VERSION: u8 = 1; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VolumeSettings { + pub master: i8, + pub voice: i8, + pub melody: i8, + pub drums: i8, +} + +impl Default for VolumeSettings { + fn default() -> Self { + Self { + master: 10, + voice: 10, + melody: 10, + drums: 10, + } + } +} + +#[derive(Clone, Copy)] +struct Record { + generation: u32, + volumes: VolumeSettings, +} + +pub fn load(flash: &mut Flash) -> Option { + newest(flash).map(|(_, record)| record.volumes) +} + +pub fn save(flash: &mut Flash, volumes: VolumeSettings) -> bool { + let (slot, generation) = match newest(flash) { + Some((current, record)) => (1 - current, record.generation.wrapping_add(1)), + None => (0, 0), + }; + let bytes = encode(Record { + generation, + volumes, + }); + if stm32h7xx_hal::nb::block!(flash.erase(FlashErase::Sector4K(SLOT_ADDRESSES[slot]))).is_err() { + return false; + } + stm32h7xx_hal::nb::block!(flash.program(SLOT_ADDRESSES[slot], &bytes)).is_ok() +} + +fn newest(flash: &mut Flash) -> Option<(usize, Record)> { + match (read_slot(flash, 0), read_slot(flash, 1)) { + (Some(a), Some(b)) if b.generation.wrapping_sub(a.generation) < 0x8000_0000 => Some((1, b)), + (Some(a), Some(_)) => Some((0, a)), + (Some(a), None) => Some((0, a)), + (None, Some(b)) => Some((1, b)), + (None, None) => None, + } +} + +fn read_slot(flash: &mut Flash, slot: usize) -> Option { + let mut bytes = [0u8; RECORD_SIZE]; + flash.read(SLOT_ADDRESSES[slot], &mut bytes).ok()?; + decode(&bytes) +} + +fn encode(record: Record) -> [u8; RECORD_SIZE] { + let mut bytes = [0xFF; RECORD_SIZE]; + bytes[..4].copy_from_slice(&MAGIC); + bytes[4] = VERSION; + bytes[5] = 4; + bytes[8..12].copy_from_slice(&record.generation.to_le_bytes()); + bytes[12] = record.volumes.master as u8; + bytes[13] = record.volumes.voice as u8; + bytes[14] = record.volumes.melody as u8; + bytes[15] = record.volumes.drums as u8; + let crc = crc32(&bytes[..28]); + bytes[28..].copy_from_slice(&crc.to_le_bytes()); + bytes +} + +fn decode(bytes: &[u8; RECORD_SIZE]) -> Option { + if bytes[..4] != MAGIC || bytes[4] != VERSION || bytes[5] != 4 { + return None; + } + let expected = u32::from_le_bytes(bytes[28..32].try_into().ok()?); + if crc32(&bytes[..28]) != expected || bytes[12..16].iter().any(|&v| v > 20) { + return None; + } + Some(Record { + generation: u32::from_le_bytes(bytes[8..12].try_into().ok()?), + volumes: VolumeSettings { + master: bytes[12] as i8, + voice: bytes[13] as i8, + melody: bytes[14] as i8, + drums: bytes[15] as i8, + }, + }) +} + +fn crc32(bytes: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFFu32; + for &byte in bytes { + crc ^= byte as u32; + for _ in 0..8 { + crc = (crc >> 1) ^ (0xEDB8_8320 & 0u32.wrapping_sub(crc & 1)); + } + } + !crc +} diff --git a/src/state_machine/mod.rs b/src/state_machine/mod.rs index 87f69da..fc576c8 100644 --- a/src/state_machine/mod.rs +++ b/src/state_machine/mod.rs @@ -97,6 +97,9 @@ menu_items! { AutotuneSpeed => { field: autotune_speed, name: "Autotune Speed", min: 1, max: 10 }, Magnitude => { field: magnitude, name: "Magnitude", min: 1, max: 10 }, PadMatrix => { field: pad_matrix, name: "Pad Matrix", min: 0, max: 1 }, + VoiceVolume => { field: voice_volume, name: "Voice Volume", min: 0, max: 20 }, + MelodyVolume => { field: melody_volume, name: "Melody Volume", min: 0, max: 20 }, + DrumVolume => { field: drum_volume, name: "Drum Volume", min: 0, max: 20 }, } /// Storage for all adjustable menu values @@ -115,6 +118,9 @@ pub struct MenuValues { pub autotune_speed: i8, pub magnitude: i8, pub pad_matrix: i8, + pub voice_volume: i8, + pub melody_volume: i8, + pub drum_volume: i8, } impl Default for MenuValues { @@ -131,6 +137,10 @@ impl Default for MenuValues { autotune_speed: 5, magnitude: 5, pad_matrix: 0, + // 10 is unity gain, preserving the pre-settings mix exactly. + voice_volume: 10, + melody_volume: 10, + drum_volume: 10, } } } @@ -329,6 +339,9 @@ pub struct AppStateMachineSnapshot { pub octave_preset: i8, pub note: i8, pub volume: i8, + pub voice_volume: i8, + pub melody_volume: i8, + pub drum_volume: i8, pub crush: i8, pub sample_reduction: i8, pub bit_rate: i8, @@ -376,6 +389,22 @@ impl AppStateMachine { } } + pub fn volume_settings(&self) -> crate::settings_storage::VolumeSettings { + crate::settings_storage::VolumeSettings { + master: self.volume, + voice: self.values.voice_volume, + melody: self.values.melody_volume, + drums: self.values.drum_volume, + } + } + + pub fn apply_volume_settings(&mut self, volumes: crate::settings_storage::VolumeSettings) { + self.volume = volumes.master.clamp(0, 20); + self.values.voice_volume = volumes.voice.clamp(0, 20); + self.values.melody_volume = volumes.melody.clamp(0, 20); + self.values.drum_volume = volumes.drums.clamp(0, 20); + } + /// Get the current state pub fn state(&self) -> AppState { self.state @@ -403,6 +432,9 @@ impl AppStateMachine { octave_preset: self.current_octave_preset, note: self.note, volume: self.volume, + voice_volume: self.values.voice_volume, + melody_volume: self.values.melody_volume, + drum_volume: self.values.drum_volume, crush: self.current_bitcrush, sample_reduction, bit_rate, @@ -425,7 +457,7 @@ impl AppStateMachine { //adjust volume (AppState::Processing(_), AppEvent::EncoderRotate(delta)) | (AppState::EffectsProfile(_), AppEvent::EncoderRotate(delta)) => { - self.volume = clamp_value(self.volume, delta, 0, 10); + self.volume = clamp_value(self.volume, delta, 0, 20); } // Handle keypad presses in Processing profile (for notes) @@ -567,6 +599,9 @@ impl AppStateMachine { ("Speed", self.values.autotune_speed), ("Magnitude", self.values.magnitude), ("Pad Matrix", self.values.pad_matrix), + ("Voice Volume", self.values.voice_volume), + ("Melody Volume", self.values.melody_volume), + ("Drum Volume", self.values.drum_volume), ]; let total = menu_items.len(); From 554f27c5183ec79c46e25fa996ceee837370af5d Mon Sep 17 00:00:00 2001 From: Nathan Bradshaw Date: Fri, 17 Jul 2026 12:14:59 -0700 Subject: [PATCH 3/6] Update voice_generator.rs --- src/midi/voice_generator.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/midi/voice_generator.rs b/src/midi/voice_generator.rs index 7798d47..01ef151 100644 --- a/src/midi/voice_generator.rs +++ b/src/midi/voice_generator.rs @@ -142,13 +142,18 @@ impl HybridVoice { /// MIDI channel routing (0-indexed): /// 0 (MIDI ch 1) — voice effects only, no audio /// 1 (MIDI ch 2) — audio only, no voice effects -/// 2 (MIDI ch 3) — audio only, no voice effects +/// 2 (MIDI ch 3) — auto-switch: plays audio normally, but in Vocode/Harmony it +/// goes silent-as-synth and drives the vocal effect instead /// 9 (MIDI ch 10) — drums, no voice effects const VOICE_CTRL_CHANNEL: u8 = 0; +/// The auto-switch channel (MIDI ch 3). Feeds the vocal-effect frequency cache and +/// is silenced as raw synth in Vocode/Harmony (see `get_mixed_sample`). +const AUTO_SWITCH_CHANNEL: u8 = 2; + #[inline(always)] fn is_sound_only_channel(channel: u8) -> bool { - channel == 1 || channel == 2 || channel == 9 + channel == 1 || channel == 9 } pub struct VoiceManager { @@ -276,7 +281,7 @@ impl VoiceManager { } /// Mix all active voices. When `mute_melody_synth` is set (Vocode / Harmony - /// profiles), pitched synth voices on the melody channel (0 / MIDI ch 1) stay + /// profiles), pitched synth voices on the auto-switch channel (MIDI ch 3) stay /// silent — they still track pitch for the vocal-effect frequency cache, so /// you hear your processed voice instead of a raw synth on top. Drums and any /// other channel still sound. @@ -293,7 +298,9 @@ impl VoiceManager { for voice in &mut self.voices { let s = voice.get_sample(); if s != 0.0 { - if mute_melody_synth && voice.channel == 0 && voice.type_id() == VoiceTypeId::Synth + if mute_melody_synth + && voice.channel == AUTO_SWITCH_CHANNEL + && voice.type_id() == VoiceTypeId::Synth { continue; } From 21c45ab174d8c633735932ae9dbc64ba6b286b09 Mon Sep 17 00:00:00 2001 From: Nathan Bradshaw Date: Fri, 17 Jul 2026 12:17:10 -0700 Subject: [PATCH 4/6] Update handler.rs --- src/handler.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/handler.rs b/src/handler.rs index 7fc9b8d..9b8f66d 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -193,8 +193,7 @@ pub fn audio_handler( let midi_sample = shared .voice_manager .lock(|vm| vm.get_mixed_sample(mute_melody_synth, melody_gain, drum_gain)); - out_sample = (out_sample * voice_gain - + (midi_sample * waveform_compensation) * 0.1) + out_sample = (out_sample * voice_gain + (midi_sample * waveform_compensation) * 0.1) * master_gain; // Normalize final output From d1646e658bdba7f4bcd7e4982844329bcb41455e Mon Sep 17 00:00:00 2001 From: Nathan Bradshaw Date: Fri, 17 Jul 2026 12:31:12 -0700 Subject: [PATCH 5/6] Update settings_storage.rs --- src/settings_storage.rs | 170 ++++++++++++++++++++++------------------ 1 file changed, 92 insertions(+), 78 deletions(-) diff --git a/src/settings_storage.rs b/src/settings_storage.rs index f9b7d93..d055fb0 100644 --- a/src/settings_storage.rs +++ b/src/settings_storage.rs @@ -1,12 +1,5 @@ //! Power-loss-safe storage for phone mixer levels in onboard QSPI flash. -use libdaisy::flash::{Flash, FlashErase}; - -const SLOT_ADDRESSES: [u32; 2] = [0x7F_C000, 0x7F_D000]; -const RECORD_SIZE: usize = 32; -const MAGIC: [u8; 4] = *b"SPHV"; -const VERSION: u8 = 1; - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct VolumeSettings { pub master: i8, @@ -26,88 +19,109 @@ impl Default for VolumeSettings { } } -#[derive(Clone, Copy)] -struct Record { - generation: u32, - volumes: VolumeSettings, -} +// The flash I/O below talks to QSPI via `libdaisy`, so it only exists on the +// firmware target. `cargo test --lib` (host) compiles just `VolumeSettings` above, +// keeping `libdaisy`'s embedded panic handler out of the host test binary. +#[cfg(target_os = "none")] +pub use flash_io::{load, save}; -pub fn load(flash: &mut Flash) -> Option { - newest(flash).map(|(_, record)| record.volumes) -} +#[cfg(target_os = "none")] +mod flash_io { + use super::VolumeSettings; + use libdaisy::flash::{Flash, FlashErase}; + + const SLOT_ADDRESSES: [u32; 2] = [0x7F_C000, 0x7F_D000]; + const RECORD_SIZE: usize = 32; + const MAGIC: [u8; 4] = *b"SPHV"; + const VERSION: u8 = 1; -pub fn save(flash: &mut Flash, volumes: VolumeSettings) -> bool { - let (slot, generation) = match newest(flash) { - Some((current, record)) => (1 - current, record.generation.wrapping_add(1)), - None => (0, 0), - }; - let bytes = encode(Record { - generation, - volumes, - }); - if stm32h7xx_hal::nb::block!(flash.erase(FlashErase::Sector4K(SLOT_ADDRESSES[slot]))).is_err() { - return false; + #[derive(Clone, Copy)] + struct Record { + generation: u32, + volumes: VolumeSettings, } - stm32h7xx_hal::nb::block!(flash.program(SLOT_ADDRESSES[slot], &bytes)).is_ok() -} -fn newest(flash: &mut Flash) -> Option<(usize, Record)> { - match (read_slot(flash, 0), read_slot(flash, 1)) { - (Some(a), Some(b)) if b.generation.wrapping_sub(a.generation) < 0x8000_0000 => Some((1, b)), - (Some(a), Some(_)) => Some((0, a)), - (Some(a), None) => Some((0, a)), - (None, Some(b)) => Some((1, b)), - (None, None) => None, + pub fn load(flash: &mut Flash) -> Option { + newest(flash).map(|(_, record)| record.volumes) } -} -fn read_slot(flash: &mut Flash, slot: usize) -> Option { - let mut bytes = [0u8; RECORD_SIZE]; - flash.read(SLOT_ADDRESSES[slot], &mut bytes).ok()?; - decode(&bytes) -} + pub fn save(flash: &mut Flash, volumes: VolumeSettings) -> bool { + let (slot, generation) = match newest(flash) { + Some((current, record)) => (1 - current, record.generation.wrapping_add(1)), + None => (0, 0), + }; + let bytes = encode(Record { + generation, + volumes, + }); + if stm32h7xx_hal::nb::block!(flash.erase(FlashErase::Sector4K(SLOT_ADDRESSES[slot]))) + .is_err() + { + return false; + } + stm32h7xx_hal::nb::block!(flash.program(SLOT_ADDRESSES[slot], &bytes)).is_ok() + } -fn encode(record: Record) -> [u8; RECORD_SIZE] { - let mut bytes = [0xFF; RECORD_SIZE]; - bytes[..4].copy_from_slice(&MAGIC); - bytes[4] = VERSION; - bytes[5] = 4; - bytes[8..12].copy_from_slice(&record.generation.to_le_bytes()); - bytes[12] = record.volumes.master as u8; - bytes[13] = record.volumes.voice as u8; - bytes[14] = record.volumes.melody as u8; - bytes[15] = record.volumes.drums as u8; - let crc = crc32(&bytes[..28]); - bytes[28..].copy_from_slice(&crc.to_le_bytes()); - bytes -} + fn newest(flash: &mut Flash) -> Option<(usize, Record)> { + match (read_slot(flash, 0), read_slot(flash, 1)) { + (Some(a), Some(b)) if b.generation.wrapping_sub(a.generation) < 0x8000_0000 => { + Some((1, b)) + } + (Some(a), Some(_)) => Some((0, a)), + (Some(a), None) => Some((0, a)), + (None, Some(b)) => Some((1, b)), + (None, None) => None, + } + } -fn decode(bytes: &[u8; RECORD_SIZE]) -> Option { - if bytes[..4] != MAGIC || bytes[4] != VERSION || bytes[5] != 4 { - return None; + fn read_slot(flash: &mut Flash, slot: usize) -> Option { + let mut bytes = [0u8; RECORD_SIZE]; + flash.read(SLOT_ADDRESSES[slot], &mut bytes).ok()?; + decode(&bytes) } - let expected = u32::from_le_bytes(bytes[28..32].try_into().ok()?); - if crc32(&bytes[..28]) != expected || bytes[12..16].iter().any(|&v| v > 20) { - return None; + + fn encode(record: Record) -> [u8; RECORD_SIZE] { + let mut bytes = [0xFF; RECORD_SIZE]; + bytes[..4].copy_from_slice(&MAGIC); + bytes[4] = VERSION; + bytes[5] = 4; + bytes[8..12].copy_from_slice(&record.generation.to_le_bytes()); + bytes[12] = record.volumes.master as u8; + bytes[13] = record.volumes.voice as u8; + bytes[14] = record.volumes.melody as u8; + bytes[15] = record.volumes.drums as u8; + let crc = crc32(&bytes[..28]); + bytes[28..].copy_from_slice(&crc.to_le_bytes()); + bytes + } + + fn decode(bytes: &[u8; RECORD_SIZE]) -> Option { + if bytes[..4] != MAGIC || bytes[4] != VERSION || bytes[5] != 4 { + return None; + } + let expected = u32::from_le_bytes(bytes[28..32].try_into().ok()?); + if crc32(&bytes[..28]) != expected || bytes[12..16].iter().any(|&v| v > 20) { + return None; + } + Some(Record { + generation: u32::from_le_bytes(bytes[8..12].try_into().ok()?), + volumes: VolumeSettings { + master: bytes[12] as i8, + voice: bytes[13] as i8, + melody: bytes[14] as i8, + drums: bytes[15] as i8, + }, + }) } - Some(Record { - generation: u32::from_le_bytes(bytes[8..12].try_into().ok()?), - volumes: VolumeSettings { - master: bytes[12] as i8, - voice: bytes[13] as i8, - melody: bytes[14] as i8, - drums: bytes[15] as i8, - }, - }) -} -fn crc32(bytes: &[u8]) -> u32 { - let mut crc = 0xFFFF_FFFFu32; - for &byte in bytes { - crc ^= byte as u32; - for _ in 0..8 { - crc = (crc >> 1) ^ (0xEDB8_8320 & 0u32.wrapping_sub(crc & 1)); + fn crc32(bytes: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFFu32; + for &byte in bytes { + crc ^= byte as u32; + for _ in 0..8 { + crc = (crc >> 1) ^ (0xEDB8_8320 & 0u32.wrapping_sub(crc & 1)); + } } + !crc } - !crc } From c9d1a58a7b59149956742489fc602c6074c385d1 Mon Sep 17 00:00:00 2001 From: Nathan Bradshaw Date: Fri, 17 Jul 2026 13:46:00 -0700 Subject: [PATCH 6/6] feat: make vocal modes togglable --- src/settings_storage.rs | 18 +++++- src/state_machine/mod.rs | 127 ++++++++++++++++++++++++++++++++------- 2 files changed, 120 insertions(+), 25 deletions(-) diff --git a/src/settings_storage.rs b/src/settings_storage.rs index d055fb0..36ae14d 100644 --- a/src/settings_storage.rs +++ b/src/settings_storage.rs @@ -6,8 +6,14 @@ pub struct VolumeSettings { pub voice: i8, pub melody: i8, pub drums: i8, + /// Which vocal modes are enabled, one bit per `ProcessingProfile` + /// (bit 0 = PitchControl, 1 = Vocode, 2 = Dry, 3 = Harmony, 4 = Percussion). + pub modes: u8, } +/// All five vocal modes enabled — the low 5 bits set. +pub const ALL_MODES_ENABLED: u8 = 0x1F; + impl Default for VolumeSettings { fn default() -> Self { Self { @@ -15,6 +21,7 @@ impl Default for VolumeSettings { voice: 10, melody: 10, drums: 10, + modes: ALL_MODES_ENABLED, } } } @@ -84,23 +91,27 @@ mod flash_io { let mut bytes = [0xFF; RECORD_SIZE]; bytes[..4].copy_from_slice(&MAGIC); bytes[4] = VERSION; - bytes[5] = 4; + bytes[5] = 5; bytes[8..12].copy_from_slice(&record.generation.to_le_bytes()); bytes[12] = record.volumes.master as u8; bytes[13] = record.volumes.voice as u8; bytes[14] = record.volumes.melody as u8; bytes[15] = record.volumes.drums as u8; + bytes[16] = record.volumes.modes; let crc = crc32(&bytes[..28]); bytes[28..].copy_from_slice(&crc.to_le_bytes()); bytes } fn decode(bytes: &[u8; RECORD_SIZE]) -> Option { - if bytes[..4] != MAGIC || bytes[4] != VERSION || bytes[5] != 4 { + if bytes[..4] != MAGIC || bytes[4] != VERSION || bytes[5] != 5 { return None; } let expected = u32::from_le_bytes(bytes[28..32].try_into().ok()?); - if crc32(&bytes[..28]) != expected || bytes[12..16].iter().any(|&v| v > 20) { + if crc32(&bytes[..28]) != expected + || bytes[12..16].iter().any(|&v| v > 20) + || bytes[16] > super::ALL_MODES_ENABLED + { return None; } Some(Record { @@ -110,6 +121,7 @@ mod flash_io { voice: bytes[13] as i8, melody: bytes[14] as i8, drums: bytes[15] as i8, + modes: bytes[16], }, }) } diff --git a/src/state_machine/mod.rs b/src/state_machine/mod.rs index fc576c8..3476bf8 100644 --- a/src/state_machine/mod.rs +++ b/src/state_machine/mod.rs @@ -100,6 +100,12 @@ menu_items! { VoiceVolume => { field: voice_volume, name: "Voice Volume", min: 0, max: 20 }, MelodyVolume => { field: melody_volume, name: "Melody Volume", min: 0, max: 20 }, DrumVolume => { field: drum_volume, name: "Drum Volume", min: 0, max: 20 }, + // Per-mode on/off (1 = enabled). Disabled modes are skipped when cycling. + ModePitch => { field: mode_pitch, name: "Pitch On", min: 0, max: 1 }, + ModeVocode => { field: mode_vocode, name: "Vocode On", min: 0, max: 1 }, + ModeDry => { field: mode_dry, name: "Dry On", min: 0, max: 1 }, + ModeHarmony => { field: mode_harmony, name: "Harmony On", min: 0, max: 1 }, + ModePercussion => { field: mode_percussion, name: "Percuss On", min: 0, max: 1 }, } /// Storage for all adjustable menu values @@ -121,6 +127,12 @@ pub struct MenuValues { pub voice_volume: i8, pub melody_volume: i8, pub drum_volume: i8, + /// Per-mode enable flags (1 = enabled, 0 = disabled). + pub mode_pitch: i8, + pub mode_vocode: i8, + pub mode_dry: i8, + pub mode_harmony: i8, + pub mode_percussion: i8, } impl Default for MenuValues { @@ -141,6 +153,12 @@ impl Default for MenuValues { voice_volume: 10, melody_volume: 10, drum_volume: 10, + // All vocal modes enabled by default. + mode_pitch: 1, + mode_vocode: 1, + mode_dry: 1, + mode_harmony: 1, + mode_percussion: 1, } } } @@ -395,6 +413,7 @@ impl AppStateMachine { voice: self.values.voice_volume, melody: self.values.melody_volume, drums: self.values.drum_volume, + modes: self.mode_bitmask(), } } @@ -403,6 +422,52 @@ impl AppStateMachine { self.values.voice_volume = volumes.voice.clamp(0, 20); self.values.melody_volume = volumes.melody.clamp(0, 20); self.values.drum_volume = volumes.drums.clamp(0, 20); + let m = volumes.modes; + self.values.mode_pitch = (m & 0b0_0001 != 0) as i8; + self.values.mode_vocode = (m & 0b0_0010 != 0) as i8; + self.values.mode_dry = (m & 0b0_0100 != 0) as i8; + self.values.mode_harmony = (m & 0b0_1000 != 0) as i8; + self.values.mode_percussion = (m & 0b1_0000 != 0) as i8; + } + + /// Packs the five per-mode enable flags into the persisted bitmask + /// (bit 0 = PitchControl … bit 4 = Percussion). + fn mode_bitmask(&self) -> u8 { + (self.values.mode_pitch != 0) as u8 + | (((self.values.mode_vocode != 0) as u8) << 1) + | (((self.values.mode_dry != 0) as u8) << 2) + | (((self.values.mode_harmony != 0) as u8) << 3) + | (((self.values.mode_percussion != 0) as u8) << 4) + } + + /// Whether a given vocal mode is enabled in settings. + fn is_mode_enabled(&self, profile: ProcessingProfile) -> bool { + match profile { + ProcessingProfile::PitchControl => self.values.mode_pitch != 0, + ProcessingProfile::Vocode => self.values.mode_vocode != 0, + ProcessingProfile::Dry => self.values.mode_dry != 0, + ProcessingProfile::Harmony => self.values.mode_harmony != 0, + ProcessingProfile::Percussion => self.values.mode_percussion != 0, + } + } + + /// Advance to the next enabled profile, skipping any the user disabled. If no + /// other mode is enabled it leaves the current state unchanged. + fn cycle_to_next_enabled_profile(&mut self) { + let mut candidate = self.state.cycle_profile(); + // At most one full lap around the five profiles. + for _ in 0..5 { + match candidate { + AppState::EffectsProfile(p) | AppState::Processing(p) => { + if self.is_mode_enabled(p) { + self.state = candidate; + return; + } + candidate = candidate.cycle_profile(); + } + _ => return, + } + } } /// Get the current state @@ -521,7 +586,7 @@ impl AppStateMachine { } 11 => { self.process_cycle_pressed = true; - self.state = self.state.cycle_profile(); // Cycle profile + self.cycle_to_next_enabled_profile(); // Cycle, skipping disabled modes } 12 => { self.key_up_pressed = true; @@ -587,29 +652,18 @@ impl AppStateMachine { // Add a current method to get menu context pub fn current(&self) -> MenuContext { let idx = self.active_menu_index().unwrap_or(0); - let menu_items = [ - ("Pitch Low", self.values.pitch_low), - ("Pitch High", self.values.pitch_high), - ("Bit Rate 1", self.values.bit_rate_soft), - ("Bit Rate 2", self.values.bit_rate_harsh), - ("Sample Rate 1", self.values.sample_reduction_soft), - ("Sample Rate 2", self.values.sample_reduction_harsh), - ("Formant Male", self.values.formant_male), - ("Formant Female", self.values.formant_female), - ("Speed", self.values.autotune_speed), - ("Magnitude", self.values.magnitude), - ("Pad Matrix", self.values.pad_matrix), - ("Voice Volume", self.values.voice_volume), - ("Melody Volume", self.values.melody_volume), - ("Drum Volume", self.values.drum_volume), - ]; - - let total = menu_items.len(); + let total = MENU_ITEMS.len(); + // Driven straight off MENU_ITEMS so new menu entries appear automatically + // and can never drift out of sync with their indices. + let item_at = |i: usize| { + let item = MENU_ITEMS[i]; + (MenuValues::get_item_name(item), self.values.get(item)) + }; MenuContext { - previous_item: menu_items[(idx + total - 1) % total], - current_item: menu_items[idx], - next_item: menu_items[(idx + 1) % total], + previous_item: item_at((idx + total - 1) % total), + current_item: item_at(idx), + next_item: item_at((idx + 1) % total), } } @@ -1055,4 +1109,33 @@ mod tests { assert_eq!(app.get_values().formant_female, 6); } + + #[test] + fn test_mode_bitmask_roundtrip() { + let mut app = AppStateMachine::new(); + let mut vs = app.volume_settings(); + vs.modes = 0b1_0101; // pitch + dry + percussion on; vocode + harmony off + app.apply_volume_settings(vs); + assert_eq!(app.volume_settings().modes, 0b1_0101); + assert_eq!(app.get_values().mode_pitch, 1); + assert_eq!(app.get_values().mode_vocode, 0); + assert_eq!(app.get_values().mode_harmony, 0); + } + + #[test] + fn test_cycle_skips_disabled_mode() { + let mut app = effects_app(); // EffectsProfile(PitchControl) + + // Disable Vocode; leave the rest enabled. + let mut vs = app.volume_settings(); + vs.modes = crate::settings_storage::ALL_MODES_ENABLED & !0b0_0010; + app.apply_volume_settings(vs); + + // Cycling from PitchControl should skip Vocode and land on Dry. + app.handle_event(AppEvent::KeypadPress(11)); + assert!(matches!( + app.state(), + AppState::EffectsProfile(ProcessingProfile::Dry) + )); + } }