From d683aee3ebd9d8901c64e2f6c016fff237e4bac2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 19:32:34 -0400 Subject: [PATCH] refactor: reduce cognitive complexity in audio-io, midi, dsp, engine, playback, plugin hosts (S3776) (#1097) * refactor: reduce cognitive complexity in audio-io, midi, dsp, engine, playback, plugin hosts (S3776) Extract helpers; behavior-preserving. * fix: clippy lints in extracted helpers --- crates/audio-io/src/symph.rs | 53 ++++++--- crates/audio-io/src/wav.rs | 62 ++++++---- crates/clap-host/src/plugin.rs | 50 +++++--- crates/clap-host/tests/fixture/src/lib.rs | 104 ++++++++-------- crates/dsp/src/offline.rs | 137 ++++++++++++---------- crates/dsp/src/plugins/drums.rs | 115 +++++++++--------- crates/dsp/src/plugins/reverb.rs | 42 ++++--- crates/engine/src/cmd/edit.rs | 59 ++++++---- crates/engine/src/cmd/edit_more.rs | 96 ++++++++------- crates/engine/src/cmd/event_more.rs | 57 +++++---- crates/engine/src/cmd/mix.rs | 134 +++++++++++---------- crates/engine/src/cmd/setup_more.rs | 31 +++-- crates/engine/src/cmd/track.rs | 59 +++++----- crates/midi/src/smf.rs | 121 ++++++++++--------- crates/playback/src/lib.rs | 56 +++++---- crates/vst3-host/src/plugin.rs | 50 +++++--- 16 files changed, 699 insertions(+), 527 deletions(-) diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs index 12ae4326..ddf3d719 100644 --- a/crates/audio-io/src/symph.rs +++ b/crates/audio-io/src/symph.rs @@ -456,6 +456,39 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi )) } +/// A demux error ends the decode; it is only an error when nothing was decoded yet. +fn demux_failure(frames: u64, e: SymError) -> Result<()> { + if frames == 0 { + return Err(map_err(e)); + } + log::warn!("stopping decode after demux error: {e}"); + Ok(()) +} + +/// Note a corrupt packet (the `errors`-th); a stream that never decodes is malformed. +fn corrupt_packet(errors: usize, frames: u64, msg: &str) -> Result<()> { + log::debug!("skipping corrupt packet: {msg}"); + if errors > 1000 && frames == 0 { + return Err(AudioError::Malformed(format!("no decodable packets: {msg}"))); + } + Ok(()) +} + +/// The decoded stream is authoritative for the channel count: record it and allocate the planes. +fn init_planar(info: &mut AudioInfo, ch: usize) -> Result>> { + info.channels = u16::try_from(ch).map_err(|_| AudioError::Unsupported("too many channels".into()))?; + Ok((0..ch).map(|_| Vec::new()).collect()) +} + +/// Append one interleaved block to the planes, replacing non-finite samples with silence. +fn append_interleaved(planar: &mut [Vec], interleaved: &[f32], ch: usize) { + for frame in interleaved.chunks_exact(ch) { + for (dst, &s) in planar.iter_mut().zip(frame) { + dst.push(if s.is_finite() { s } else { 0.0 }); + } + } +} + /// Decode the whole first audio track. When `count_only`, samples are counted but not stored. fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> { let mut reader = match open(bytes, detected)? { @@ -491,10 +524,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, Err(SymError::ResetRequired) => break, Err(e) => { - if frames == 0 { - return Err(map_err(e)); - } - log::warn!("stopping decode after demux error: {e}"); + demux_failure(frames, e)?; break; } }; @@ -505,10 +535,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf Ok(d) => d, Err(SymError::DecodeError(m)) => { errors += 1; - log::debug!("skipping corrupt packet: {m}"); - if errors > 1000 && frames == 0 { - return Err(AudioError::Malformed(format!("no decodable packets: {m}"))); - } + corrupt_packet(errors, frames, m)?; continue; } Err(SymError::IoError(_)) => break, @@ -521,9 +548,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf continue; } if planar.is_empty() { - // The decoded stream is authoritative for the channel count. - info.channels = u16::try_from(ch).map_err(|_| AudioError::Unsupported("too many channels".into()))?; - planar = (0..ch).map(|_| Vec::new()).collect(); + planar = init_planar(&mut info, ch)?; } let n = decoded.frames() as u64; frames = frames.saturating_add(n); @@ -542,11 +567,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf interleaved.resize(needed, 0.0); } decoded.copy_to_slice_interleaved(&mut interleaved[..needed]); - for frame in interleaved[..needed].chunks_exact(ch) { - for (dst, &s) in planar.iter_mut().zip(frame) { - dst.push(if s.is_finite() { s } else { 0.0 }); - } - } + append_interleaved(&mut planar, &interleaved[..needed], ch); } if info.channels == 0 { return Err(AudioError::Malformed("stream contains no audio".into())); diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index 90bc231e..ee93cd46 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -178,6 +178,45 @@ fn only_chunks_follow(b: &[u8], from: usize) -> bool { false } +/// A `data` chunk header as found in the file, for [`data_chunk_len`]. +struct DataChunk { + body: usize, + size32: u32, + is_rf64: bool, + remaining: usize, + declared: usize, +} + +/// Bytes of audio a `data` chunk holds: leniently, whatever is present when the size is wrong or +/// the recording was never finalised. +fn data_chunk_len(b: &[u8], c: DataChunk) -> usize { + let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64; + let empty = c.size32 == 0 && !c.is_rf64 && finished && only_chunks_follow(b, c.body); + if c.size32 == 0 && !c.is_rf64 && !empty { c.remaining } else { c.declared.min(c.remaining) } +} + +/// The sample layout of a `fmt` chunk, `None` for a format we do not read natively. +fn pcm_layout(fmt: &Fmt) -> Result> { + Ok(Some(match (fmt.tag, fmt.bits) { + (WAVE_FORMAT_PCM, bits @ 1..=32) => { + let bytes = usize::from(bits.div_ceil(8)); + let sf = match fmt.valid_bits.min(bits) { + 0..=8 => SampleFormat::Int8, + 9..=16 => SampleFormat::Int16, + 17..=24 => SampleFormat::Int24, + _ => SampleFormat::Int32, + }; + (PcmKind::Int { bytes, big_endian: false, unsigned: bytes == 1 }, sf) + } + (WAVE_FORMAT_IEEE_FLOAT, 32) => (PcmKind::Float { bytes: 4, big_endian: false }, SampleFormat::Float32), + (WAVE_FORMAT_IEEE_FLOAT, 64) => (PcmKind::Float { bytes: 8, big_endian: false }, SampleFormat::Float64), + (WAVE_FORMAT_PCM | WAVE_FORMAT_IEEE_FLOAT, bits) => { + return Err(AudioError::Unsupported(format!("WAV format {} with {bits} bits per sample", fmt.tag))); + } + _ => return Ok(None), + })) +} + pub(crate) fn parse(b: &[u8]) -> Result { if !is_wav(b) { return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into())); @@ -209,9 +248,7 @@ pub(crate) fn parse(b: &[u8]) -> Result { // A size of 0 in a file whose RIFF size is right and that has only chunks after it is an // empty data chunk followed by metadata (LIST, bext, …); otherwise it is a recording // that was never finalised, and what follows is audio. - let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64; - let empty = size32 == 0 && !is_rf64 && finished && only_chunks_follow(b, body); - let len = if size32 == 0 && !is_rf64 && !empty { remaining } else { declared.min(remaining) }; + let len = data_chunk_len(b, DataChunk { body, size32, is_rf64, remaining, declared }); if data.is_none() { data = Some((body, len)); } @@ -246,24 +283,7 @@ pub(crate) fn parse(b: &[u8]) -> Result { return Err(AudioError::Malformed("WAV declares a zero sample rate".into())); } let channels = usize::from(fmt.channels); - let (kind, sample_format) = match (fmt.tag, fmt.bits) { - (WAVE_FORMAT_PCM, bits @ 1..=32) => { - let bytes = usize::from(bits.div_ceil(8)); - let sf = match fmt.valid_bits.min(bits) { - 0..=8 => SampleFormat::Int8, - 9..=16 => SampleFormat::Int16, - 17..=24 => SampleFormat::Int24, - _ => SampleFormat::Int32, - }; - (PcmKind::Int { bytes, big_endian: false, unsigned: bytes == 1 }, sf) - } - (WAVE_FORMAT_IEEE_FLOAT, 32) => (PcmKind::Float { bytes: 4, big_endian: false }, SampleFormat::Float32), - (WAVE_FORMAT_IEEE_FLOAT, 64) => (PcmKind::Float { bytes: 8, big_endian: false }, SampleFormat::Float64), - (WAVE_FORMAT_PCM | WAVE_FORMAT_IEEE_FLOAT, bits) => { - return Err(AudioError::Unsupported(format!("WAV format {} with {bits} bits per sample", fmt.tag))); - } - _ => return Ok(WavParse::Foreign { bwf }), - }; + let Some((kind, sample_format)) = pcm_layout(&fmt)? else { return Ok(WavParse::Foreign { bwf }) }; let block = kind.bytes().checked_mul(channels).ok_or_else(|| AudioError::Malformed("block size overflow".into()))?; if usize::from(fmt.block_align) != block { log::debug!("WAV block_align {} disagrees with computed {block}; using computed", fmt.block_align); diff --git a/crates/clap-host/src/plugin.rs b/crates/clap-host/src/plugin.rs index c6bcb9ed..4b685cf8 100644 --- a/crates/clap-host/src/plugin.rs +++ b/crates/clap-host/src/plugin.rs @@ -102,6 +102,35 @@ fn note_mode(p: Option) -> NoteMode { } } +/// Copy `n` frames from `start` of the host buffers into the plugin's input `port` (mono sources +/// fill every port channel). +fn feed_input(inputs: &mut PortBuffers, port: usize, io: &[Vec], ch: usize, (start, n): (usize, usize)) { + for c in 0..inputs.channels(port) { + let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n)); + if let (Some(src), Some(dst)) = (src, inputs.channel_mut(port, c).and_then(|d| d.get_mut(..n))) { + dst.copy_from_slice(src); + } + } +} + +/// Copy `n` frames of the plugin's output `port` into the host buffers from `start`; a mono host +/// buffer takes the average of a stereo port. +fn read_output(outputs: &PortBuffers, port: usize, io: &mut [Vec], ch: usize, (start, n): (usize, usize)) { + let oc = outputs.channels(port); + for (c, dst) in io.iter_mut().take(ch).enumerate() { + let Some(dst) = dst.get_mut(start..start + n) else { continue }; + if ch == 1 && oc >= 2 { + if let (Some(l), Some(r)) = (outputs.channel(port, 0), outputs.channel(port, 1)) { + for ((d, a), b) in dst.iter_mut().zip(l).zip(r) { + *d = 0.5 * (a + b); + } + } + } else if let Some(src) = outputs.channel(port, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) { + dst.copy_from_slice(src); + } + } +} + impl Plugin for ClapPlugin { fn info(&self) -> &'static PluginInfo { self.info @@ -197,12 +226,7 @@ impl Plugin for ClapPlugin { let end = u32::try_from(start + n).unwrap_or(u32::MAX); // Feed the main input port (mono sources fill every port channel). if let Some(p) = self.in_main { - for c in 0..self.inputs.channels(p) { - let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n)); - if let (Some(src), Some(dst)) = (src, self.inputs.channel_mut(p, c).and_then(|d| d.get_mut(..n))) { - dst.copy_from_slice(src); - } - } + feed_input(&mut self.inputs, p, io, ch, (start, n)); } let k = self.queue.iter().position(|e| e.time() >= end).unwrap_or(self.queue.len()); let offset = u32::try_from(start).unwrap_or(u32::MAX); @@ -212,19 +236,7 @@ impl Plugin for ClapPlugin { self.steady = self.steady.saturating_add(i64::try_from(n).unwrap_or(0)); match (r, self.out_main) { (Ok(_), Some(p)) => { - let oc = self.outputs.channels(p); - for (c, dst) in io.iter_mut().take(ch).enumerate() { - let Some(dst) = dst.get_mut(start..start + n) else { continue }; - if ch == 1 && oc >= 2 { - if let (Some(l), Some(r)) = (self.outputs.channel(p, 0), self.outputs.channel(p, 1)) { - for ((d, a), b) in dst.iter_mut().zip(l).zip(r) { - *d = 0.5 * (a + b); - } - } - } else if let Some(src) = self.outputs.channel(p, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) { - dst.copy_from_slice(src); - } - } + read_output(&self.outputs, p, io, ch, (start, n)); } (Err(e), _) => log::debug!("{}: {e}", self.info.id), _ => {} diff --git a/crates/clap-host/tests/fixture/src/lib.rs b/crates/clap-host/tests/fixture/src/lib.rs index b88f434f..10611670 100644 --- a/crates/clap-host/tests/fixture/src/lib.rs +++ b/crates/clap-host/tests/fixture/src/lib.rs @@ -189,6 +189,61 @@ fn apply(f: &mut Fx, h: &clap_event_header) { } } +/// Fill every output channel with 1.0 when all ports have the wide layout, else -1.0. +unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize) { + let fits = |bufs: *const clap_audio_buffer, n: u32| { + n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels) + }; + let ok = fits(pr.audio_inputs, pr.audio_inputs_count) && fits(pr.audio_outputs, pr.audio_outputs_count); + if pr.audio_outputs_count > 0 { + let out = unsafe { &*pr.audio_outputs }; + for c in 0..out.channel_count as usize { + let o = unsafe { *out.data32.add(c) }; + for i in 0..n { + unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } }; + } + } + } +} + +/// Push the host-visible parameter edit and an ignorable note event onto the output queue. +unsafe fn push_gui_edit(pr: &clap_process) { + let e = clap_event_param_value { + header: clap_event_header { + size: std::mem::size_of::() as u32, + time: 0, + space_id: CLAP_CORE_EVENT_SPACE_ID, + type_: CLAP_EVENT_PARAM_VALUE, + flags: 0, + }, + param_id: 7, + cookie: std::ptr::null_mut(), + note_id: -1, + port_index: -1, + channel: -1, + key: -1, + value: 0.25, + }; + let out = unsafe { &*pr.out_events }; + assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &e.header) }); + // Other event types are accepted and ignored by the host. + let note = clap_event_note { + header: clap_event_header { + size: std::mem::size_of::() as u32, + time: 0, + space_id: CLAP_CORE_EVENT_SPACE_ID, + type_: CLAP_EVENT_NOTE_END, + flags: 0, + }, + note_id: -1, + port_index: 0, + channel: 0, + key: 60, + velocity: 0.0, + }; + assert!(unsafe { (out.try_push.unwrap())(pr.out_events, ¬e.header) }); +} + unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) -> clap_process_status { let f = unsafe { fx(p) }; let pr = unsafe { &*pr }; @@ -197,19 +252,7 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) -> let ev = unsafe { &*pr.in_events }; let count = unsafe { (ev.size.unwrap())(pr.in_events) }; if let Some((ports, channels)) = f.wide { - let fits = |bufs: *const clap_audio_buffer, n: u32| { - n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels) - }; - let ok = fits(pr.audio_inputs, pr.audio_inputs_count) && fits(pr.audio_outputs, pr.audio_outputs_count); - if pr.audio_outputs_count > 0 { - let out = unsafe { &*pr.audio_outputs }; - for c in 0..out.channel_count as usize { - let o = unsafe { *out.data32.add(c) }; - for i in 0..n { - unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } }; - } - } - } + unsafe { fill_wide(pr, ports, channels, n) }; return CLAP_PROCESS_CONTINUE; } if pr.audio_outputs_count != 1 { @@ -230,40 +273,7 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) -> if f.gui_edit_pending { f.gui_edit_pending = false; f.gain = 0.25; - let e = clap_event_param_value { - header: clap_event_header { - size: std::mem::size_of::() as u32, - time: 0, - space_id: CLAP_CORE_EVENT_SPACE_ID, - type_: CLAP_EVENT_PARAM_VALUE, - flags: 0, - }, - param_id: 7, - cookie: std::ptr::null_mut(), - note_id: -1, - port_index: -1, - channel: -1, - key: -1, - value: 0.25, - }; - let out = unsafe { &*pr.out_events }; - assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &e.header) }); - // Other event types are accepted and ignored by the host. - let note = clap_event_note { - header: clap_event_header { - size: std::mem::size_of::() as u32, - time: 0, - space_id: CLAP_CORE_EVENT_SPACE_ID, - type_: CLAP_EVENT_NOTE_END, - flags: 0, - }, - note_id: -1, - port_index: 0, - channel: 0, - key: 60, - velocity: 0.0, - }; - assert!(unsafe { (out.try_push.unwrap())(pr.out_events, ¬e.header) }); + unsafe { push_gui_edit(pr) }; } let mut next = 0u32; let mut last_time = 0u32; diff --git a/crates/dsp/src/offline.rs b/crates/dsp/src/offline.rs index 89e1e588..7ed5779b 100644 --- a/crates/dsp/src/offline.rs +++ b/crates/dsp/src/offline.rs @@ -322,6 +322,77 @@ pub fn resample(ch: &[Vec], from: u32, to: u32) -> Vec> { // --------------------------------------------------------------------------------------------- // Time stretch (WSOLA) and pitch shift +/// Start position in the guide signal whose `hop` samples best match the natural continuation at +/// `natural`, searched within `tol` of `nominal` (coarse stride 4, then refined +-3). +fn best_start(g: &impl Fn(i64) -> f32, nominal: i64, natural: i64, tol: i64, hop: usize) -> i64 { + // Similarity of candidate start `p` with the natural continuation (stride 2). + let score = |p: i64| -> f32 { + let (mut xy, mut yy) = (0.0f32, 1e-9f32); + let mut i = 0i64; + while i < hop as i64 { + let a = g(natural + i); + let b = g(p + i); + xy += a * b; + yy += b * b; + i += 2; + } + xy / yy.sqrt() + }; + let lo = (nominal - tol).max(0); + let hi = nominal + tol; + let mut best = nominal.max(0); + let mut best_s = f32::NEG_INFINITY; + let mut p = lo; + while p <= hi { + let s = score(p); + if s > best_s { + best_s = s; + best = p; + } + p += 4; + } + let (rlo, rhi) = ((best - 3).max(lo), (best + 3).min(hi)); + for p in rlo..=rhi { + let s = score(p); + if s > best_s { + best_s = s; + best = p; + } + } + best +} + +/// Add one windowed frame of every channel, read from `at.0`, into `out` at `at.1`, and the window +/// into `wsum`. +fn overlap_add(ch: &[Vec], out: &mut [Vec], wsum: &mut [f32], window: &[f32], at: (usize, usize)) { + let (in_pos, out_pos) = at; + for (c, o) in ch.iter().zip(out.iter_mut()) { + for (i, w) in window.iter().enumerate() { + let v = c.get(in_pos + i).copied().unwrap_or(0.0); + if let Some(d) = o.get_mut(out_pos + i) { + *d += v * w; + } + } + } + for (i, w) in window.iter().enumerate() { + if let Some(d) = wsum.get_mut(out_pos + i) { + *d += w; + } + } +} + +/// Divide out the accumulated window weights and cut each channel to `out_len`. +fn normalise(out: &mut [Vec], wsum: &[f32], out_len: usize) { + for o in out.iter_mut() { + for (s, w) in o.iter_mut().zip(wsum) { + if *w > 1e-3 { + *s /= w; + } + } + o.truncate(out_len); + } +} + /// Pitch-preserving time stretch by WSOLA (waveform-similarity overlap-add). /// `ratio` = output length / input length, clamped to 0.1 ..= 10. All channels share the same /// segment choices so the stereo image stays coherent. @@ -354,72 +425,12 @@ pub fn time_stretch(ch: &[Vec], ratio: f64, sample_rate: f32) -> Vec= out_len { break; } - let in_pos = if k == 0 { - 0 - } else { - let nominal = (out_pos as f64 / ratio).round() as i64; - let natural = prev_in + hop as i64; - // Similarity of candidate start `p` with the natural continuation (stride 2). - let score = |p: i64| -> f32 { - let (mut xy, mut yy) = (0.0f32, 1e-9f32); - let mut i = 0i64; - while i < hop as i64 { - let a = g(natural + i); - let b = g(p + i); - xy += a * b; - yy += b * b; - i += 2; - } - xy / yy.sqrt() - }; - let lo = (nominal - tol).max(0); - let hi = nominal + tol; - let mut best = nominal.max(0); - let mut best_s = f32::NEG_INFINITY; - let mut p = lo; - while p <= hi { - let s = score(p); - if s > best_s { - best_s = s; - best = p; - } - p += 4; - } - let (rlo, rhi) = ((best - 3).max(lo), (best + 3).min(hi)); - for p in rlo..=rhi { - let s = score(p); - if s > best_s { - best_s = s; - best = p; - } - } - best - }; - for (c, o) in ch.iter().zip(out.iter_mut()) { - for (i, w) in window.iter().enumerate() { - let src = in_pos as usize + i; - let v = c.get(src).copied().unwrap_or(0.0); - if let Some(d) = o.get_mut(out_pos + i) { - *d += v * w; - } - } - } - for (i, w) in window.iter().enumerate() { - if let Some(d) = wsum.get_mut(out_pos + i) { - *d += w; - } - } + let in_pos = if k == 0 { 0 } else { best_start(&g, (out_pos as f64 / ratio).round() as i64, prev_in + hop as i64, tol, hop) }; + overlap_add(ch, &mut out, &mut wsum, &window, (in_pos as usize, out_pos)); prev_in = in_pos; k += 1; } - for o in out.iter_mut() { - for (s, w) in o.iter_mut().zip(&wsum) { - if *w > 1e-3 { - *s /= w; - } - } - o.truncate(out_len); - } + normalise(&mut out, &wsum, out_len); out } diff --git a/crates/dsp/src/plugins/drums.rs b/crates/dsp/src/plugins/drums.rs index ca3a52b9..0dd36e7b 100644 --- a/crates/dsp/src/plugins/drums.rs +++ b/crates/dsp/src/plugins/drums.rs @@ -135,6 +135,65 @@ impl Default for DrumSynth { } } +/// One output sample of a sounding voice of `kind`, advancing its envelopes. +fn voice_sample(v: &mut DrumVoice, kind: Kind, sr: f32, clap_gap: usize) -> f32 { + match kind { + Kind::Kick | Kind::Tom(_) => { + v.pitch_env *= v.pitch_coef; + let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env; + v.phase += f / sr; + v.phase -= v.phase.floor(); + let tone = (v.phase * TAU).sin() * v.amp; + let click = v.filt.process_sample(v.rng.bipolar()) * v.noise_amp; + v.amp *= v.amp_coef; + v.noise_amp *= v.noise_coef; + tone + click + } + Kind::Snare => { + v.pitch_env *= v.pitch_coef; + let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env; + v.phase += f / sr; + v.phase -= v.phase.floor(); + let tone = (v.phase * TAU).sin() * v.amp * 0.6; + let n = v.filt2.process_sample(v.filt.process_sample(v.rng.bipolar())) * v.noise_amp * 0.7; + v.amp *= v.amp_coef; + v.noise_amp *= v.noise_coef; + tone + n + } + Kind::Clap => { + // Three quick bursts then a diffuse tail. + let burst = if v.t < clap_gap * 3 { + if v.t.is_multiple_of(clap_gap) { + v.amp = 1.0; + } + v.amp + } else { + 0.0 + }; + let env = burst.max(if v.t >= clap_gap * 2 { v.noise_amp * 0.6 } else { 0.0 }); + let y = v.filt.process_sample(v.rng.bipolar()) * env * 2.0; + v.amp *= v.amp_coef; + if v.t >= clap_gap * 2 { + v.noise_amp *= v.noise_coef; + } + y + } + Kind::ClosedHat | Kind::OpenHat | Kind::Crash | Kind::Ride => { + let scale = if matches!(kind, Kind::Crash | Kind::Ride) { 1.0 } else { 1.9 }; + let mut metal = 0.0; + for (m, f) in v.metal.iter_mut().zip(METAL) { + *m += f * scale / sr; + *m -= m.floor(); + metal += if *m < 0.5 { 1.0 } else { -1.0 }; + } + let raw = metal / 6.0 * (1.0 - v.noise_amp * 0.5) + v.rng.bipolar() * v.noise_amp; + let y = v.filt2.process_sample(v.filt.process_sample(raw)) * v.amp; + v.amp *= v.amp_coef; + y + } + } +} + impl DrumSynth { pub fn new() -> Self { let mut voices = [DrumVoice::new(1); VOICES]; @@ -246,61 +305,7 @@ impl DrumSynth { let mut out = 0.0; for v in &mut self.voices { let Some(kind) = v.kind else { continue }; - let y = match kind { - Kind::Kick | Kind::Tom(_) => { - v.pitch_env *= v.pitch_coef; - let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env; - v.phase += f / sr; - v.phase -= v.phase.floor(); - let tone = (v.phase * TAU).sin() * v.amp; - let click = v.filt.process_sample(v.rng.bipolar()) * v.noise_amp; - v.amp *= v.amp_coef; - v.noise_amp *= v.noise_coef; - tone + click - } - Kind::Snare => { - v.pitch_env *= v.pitch_coef; - let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env; - v.phase += f / sr; - v.phase -= v.phase.floor(); - let tone = (v.phase * TAU).sin() * v.amp * 0.6; - let n = v.filt2.process_sample(v.filt.process_sample(v.rng.bipolar())) * v.noise_amp * 0.7; - v.amp *= v.amp_coef; - v.noise_amp *= v.noise_coef; - tone + n - } - Kind::Clap => { - // Three quick bursts then a diffuse tail. - let burst = if v.t < clap_gap * 3 { - if v.t % clap_gap == 0 { - v.amp = 1.0; - } - v.amp - } else { - 0.0 - }; - let env = burst.max(if v.t >= clap_gap * 2 { v.noise_amp * 0.6 } else { 0.0 }); - let y = v.filt.process_sample(v.rng.bipolar()) * env * 2.0; - v.amp *= v.amp_coef; - if v.t >= clap_gap * 2 { - v.noise_amp *= v.noise_coef; - } - y - } - Kind::ClosedHat | Kind::OpenHat | Kind::Crash | Kind::Ride => { - let scale = if matches!(kind, Kind::Crash | Kind::Ride) { 1.0 } else { 1.9 }; - let mut metal = 0.0; - for (m, f) in v.metal.iter_mut().zip(METAL) { - *m += f * scale / sr; - *m -= m.floor(); - metal += if *m < 0.5 { 1.0 } else { -1.0 }; - } - let raw = metal / 6.0 * (1.0 - v.noise_amp * 0.5) + v.rng.bipolar() * v.noise_amp; - let y = v.filt2.process_sample(v.filt.process_sample(raw)) * v.amp; - v.amp *= v.amp_coef; - y - } - }; + let y = voice_sample(v, kind, sr, clap_gap); v.t += 1; let alive = match kind { Kind::Clap => v.t < clap_gap * 3 || v.noise_amp > OFF, diff --git a/crates/dsp/src/plugins/reverb.rs b/crates/dsp/src/plugins/reverb.rs index d1f22a7b..da0b83f5 100644 --- a/crates/dsp/src/plugins/reverb.rs +++ b/crates/dsp/src/plugins/reverb.rs @@ -201,6 +201,31 @@ impl Reverb { } } +/// Input sample `n` of the left and right channels (a mono input feeds both); non-finite reads as 0. +fn input_pair(io: &[Vec], ch: usize, n: usize) -> (f32, f32) { + let xl = io.first().and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0); + let xr = if ch > 1 { io.get(1).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0) } else { xl }; + (xl, xr) +} + +/// Blend the wet signal `[mid, left, right]` into sample `n` of the first `ch` channels at mix `m`. +fn mix_out(io: &mut [Vec], ch: usize, n: usize, wet: [f32; 3], m: f32) { + let [mid, wl, wr] = wet; + for (c, b) in io.iter_mut().take(ch).enumerate() { + let wet = if ch == 1 { + mid + } else if c % 2 == 0 { + wl + } else { + wr + }; + if let Some(x) = b.get_mut(n) { + let dry = if x.is_finite() { *x } else { 0.0 }; + *x = dry * (1.0 - m) + wet * m; + } + } +} + impl Plugin for Reverb { fn info(&self) -> &'static PluginInfo { self.info @@ -279,8 +304,7 @@ impl Plugin for Reverb { let size = self.size.value(); let base: [f32; LINES] = self.cfg.delays_ms.map(|ms| ms_to_samples(ms * size, sr)); for n in 0..frames { - let xl = io.first().and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0); - let xr = if ch > 1 { io.get(1).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0) } else { xl }; + let (xl, xr) = input_pair(io, ch, n); let pre = ms_to_samples(self.pre_ms.next(), sr); let mut ins = [xl, xr]; for (side, x) in ins.iter_mut().enumerate() { @@ -319,19 +343,7 @@ impl Plugin for Reverb { let side = 0.5 * (wl - wr) * w; let (wl, wr) = (mid + side, mid - side); let m = self.mix.next(); - for (c, b) in io.iter_mut().take(ch).enumerate() { - let wet = if ch == 1 { - mid - } else if c % 2 == 0 { - wl - } else { - wr - }; - if let Some(x) = b.get_mut(n) { - let dry = if x.is_finite() { *x } else { 0.0 }; - *x = dry * (1.0 - m) + wet * m; - } - } + mix_out(io, ch, n, [mid, wl, wr], m); } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index c11d3cbb..06e6bec0 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -475,6 +475,38 @@ fn select_all(e: &mut Engine, _: &Value) -> Result { Ok(json!({"tracks": s.edit.selected_tracks.len(), "end": end})) } +/// Adds the members of every active edit group that touches `t`, then puts `t` in track order. +fn add_group_members(s: &Session, t: &mut Vec) { + let extra: Vec = + s.groups.iter().filter(|g| g.active && g.edit && g.members.iter().any(|m| t.contains(m))).flat_map(|g| g.members.clone()).collect(); + for m in extra { + if !t.contains(&m) { + t.push(m); + } + } + let order: Vec = s.tracks.iter().map(|x| x.id).collect(); + t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX)); +} + +/// Selecting clips also selects their range and tracks (Grabber click). +fn select_clip_ids(s: &Session, ed: &mut soundcraft_model::EditState, c: Vec) { + let mut range: Option = None; + let mut trs = Vec::new(); + for id in &c { + if let Some((tid, clip)) = s.find_clip(*id) { + range = Some(range.map_or(clip.range(), |r| soundcraft_time::Range::new(r.start.min(clip.start), r.end.max(clip.end())))); + if !trs.contains(&tid) { + trs.push(tid); + } + } + } + if let Some(r) = range { + ed.selection = r; + ed.selected_tracks = trs; + } + ed.selected_clips = c; +} + fn select(e: &mut Engine, p: &Value) -> Result { let tracks = if p.get("tracks").is_some() || p.get("track").is_some() { Some(tracks_param(e, "edit.select", p)?) } else { None }; let st = position_param(e, "edit.select", p, "start")?; @@ -490,15 +522,7 @@ fn select(e: &mut Engine, p: &Value) -> Result { if let Some(mut t) = tracks { // Active edit groups select all their members together. if !exact { - let extra: Vec = - s.groups.iter().filter(|g| g.active && g.edit && g.members.iter().any(|m| t.contains(m))).flat_map(|g| g.members.clone()).collect(); - for m in extra { - if !t.contains(&m) { - t.push(m); - } - } - let order: Vec = s.tracks.iter().map(|x| x.id).collect(); - t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX)); + add_group_members(s, &mut t); } ed.selected_tracks = t; } @@ -508,22 +532,7 @@ fn select(e: &mut Engine, p: &Value) -> Result { _ => {} } if let Some(c) = clips { - // Selecting clips also selects their range and tracks (Grabber click). - let mut range: Option = None; - let mut trs = Vec::new(); - for id in &c { - if let Some((tid, clip)) = s.find_clip(*id) { - range = Some(range.map_or(clip.range(), |r| soundcraft_time::Range::new(r.start.min(clip.start), r.end.max(clip.end())))); - if !trs.contains(&tid) { - trs.push(tid); - } - } - } - if let Some(r) = range { - ed.selection = r; - ed.selected_tracks = trs; - } - ed.selected_clips = c; + select_clip_ids(s, &mut ed, c); } else if list_changes { ed.selected_clips.clear(); } diff --git a/crates/engine/src/cmd/edit_more.rs b/crates/engine/src/cmd/edit_more.rs index 4e67608e..8c9a5a1a 100644 --- a/crates/engine/src/cmd/edit_more.rs +++ b/crates/engine/src/cmd/edit_more.rs @@ -857,6 +857,57 @@ fn coalesce_clip_gain_to_volume(e: &mut Engine, p: &Value) -> Result { Ok(json!({"clips": n})) } +/// The automation parameters a trim/glide over a range touches on `tr`. +fn trim_params(tr: &soundcraft_model::Track, pan_only: bool) -> Vec { + let mut params: Vec = tr + .automation + .iter() + .filter(|l| l.enabled && !matches!(l.param, AutoParam::Mute | AutoParam::SendMute(_))) + .filter(|l| !pan_only || matches!(l.param, AutoParam::Pan(_))) + .map(|l| l.param.clone()) + .collect(); + if pan_only { + for i in 0..tr.mixer.pan.len().min(2) { + let pp = AutoParam::Pan(i as u8); + if !params.contains(&pp) { + params.push(pp); + } + } + } + params +} + +/// Trim (shift the points inside `r` by the change at its end) or glide (ramp across `r`) one lane. +fn trim_lane(tr: &mut soundcraft_model::Track, param: &AutoParam, r: Range, glide: bool) { + let cur = super::mix::current_value(tr, param); + let (lo, hi, _) = param.range(); + let l = tr.lane_mut(param); + let before = l.value_at(r.start - 1, cur); + let after = l.value_at(r.end, cur); + let from = l.value_at(r.start, cur); + if glide { + l.clear_range(r.start, r.end.saturating_add(2)); + if r.start > 0 { + l.set_point(r.start - 1, before); + } + l.set_point(r.start, from); + l.set_point(r.end, cur); + l.set_point(r.end.saturating_add(1), after); + } else { + let delta = cur - from; + let last = l.value_at(r.end - 1, cur); + if r.start > 0 { + l.set_point(r.start - 1, before); + } + l.set_point(r.start, from); + l.set_point(r.end - 1, last); + l.set_point(r.end, after); + for pt in l.points.iter_mut().filter(|pt| pt.at >= r.start && pt.at < r.end) { + pt.value = (pt.value + delta).clamp(lo, hi); + } + } +} + fn trim_glide(e: &mut Engine, p: &Value, glide: bool, pan_only: bool, id: &str) -> Result { let tracks = tracks_required(e, id, p)?; let r = nonempty_range(e, p, id)?; @@ -864,49 +915,8 @@ fn trim_glide(e: &mut Engine, p: &Value, glide: bool, pan_only: bool, id: &str) let mut lanes = 0; for t in &tracks { let Some(tr) = s.track_mut(*t) else { continue }; - let mut params: Vec = tr - .automation - .iter() - .filter(|l| l.enabled && !matches!(l.param, AutoParam::Mute | AutoParam::SendMute(_))) - .filter(|l| !pan_only || matches!(l.param, AutoParam::Pan(_))) - .map(|l| l.param.clone()) - .collect(); - if pan_only { - for i in 0..tr.mixer.pan.len().min(2) { - let pp = AutoParam::Pan(i as u8); - if !params.contains(&pp) { - params.push(pp); - } - } - } - for param in params { - let cur = super::mix::current_value(tr, ¶m); - let (lo, hi, _) = param.range(); - let l = tr.lane_mut(¶m); - let before = l.value_at(r.start - 1, cur); - let after = l.value_at(r.end, cur); - let from = l.value_at(r.start, cur); - if glide { - l.clear_range(r.start, r.end.saturating_add(2)); - if r.start > 0 { - l.set_point(r.start - 1, before); - } - l.set_point(r.start, from); - l.set_point(r.end, cur); - l.set_point(r.end.saturating_add(1), after); - } else { - let delta = cur - from; - let last = l.value_at(r.end - 1, cur); - if r.start > 0 { - l.set_point(r.start - 1, before); - } - l.set_point(r.start, from); - l.set_point(r.end - 1, last); - l.set_point(r.end, after); - for pt in l.points.iter_mut().filter(|pt| pt.at >= r.start && pt.at < r.end) { - pt.value = (pt.value + delta).clamp(lo, hi); - } - } + for param in trim_params(tr, pan_only) { + trim_lane(tr, ¶m, r, glide); lanes += 1; } } diff --git a/crates/engine/src/cmd/event_more.rs b/crates/engine/src/cmd/event_more.rs index 51016627..678cf3aa 100644 --- a/crates/engine/src/cmd/event_more.rs +++ b/crates/engine/src/cmd/event_more.rs @@ -564,18 +564,11 @@ pub fn chord_name(weights: &[f64; 12], bass: usize) -> Option { const MAX_CHORD_BARS: usize = 100_000; const MAX_CHORD_NOTES: usize = 10_000_000; -fn extract_chords(e: &mut Engine, p: &Value) -> Result { - let id = "event.extract_chords"; - let (_, t0, t1) = selection_ticks(e, p, id)?; - let s = e.session(); - let picked = tracks_param(e, id, p)?; - let mut tracks: Vec = picked.into_iter().filter(|t| s.track(*t).is_some_and(|x| x.kind.is_midi())).collect(); - if tracks.is_empty() { - tracks = s.tracks.iter().filter(|t| t.kind.is_midi()).map(|t| t.id).collect(); - } - // Absolute (tick) notes from every MIDI clip on those tracks, cut to their clip. +/// Absolute `(start, end, pitch)` tick notes from every MIDI clip on `tracks`, cut to their clip +/// and sorted by start. +fn midi_tick_notes(s: &Session, tracks: &[TrackId]) -> Vec<(i64, i64, u8)> { let mut notes: Vec<(i64, i64, u8)> = Vec::new(); - for t in &tracks { + for t in tracks { for c in s.track(*t).map(|tr| tr.clips()).unwrap_or(&[]) { let ClipContent::Midi { sequence } = &c.content else { continue }; let base = s.tempo.samples_to_ticks(c.start, s.sample_rate); @@ -590,6 +583,36 @@ fn extract_chords(e: &mut Engine, p: &Value) -> Result { } } notes.sort_unstable_by_key(|n| n.0); + notes +} + +/// Duration-weighted pitch classes of the notes sounding in `a..b`, and the lowest pitch among them. +fn pitch_class_weights(sounding: &[(i64, i64, u8)], a: i64, b: i64) -> ([f64; 12], Option) { + let mut w = [0.0f64; 12]; + let mut bass: Option = None; + for (na, nb, pitch) in sounding { + let ov = (*nb).min(b) - (*na).max(a); + if ov > 0 { + if let Some(x) = w.get_mut(usize::from(*pitch) % 12) { + *x += ov as f64; + } + bass = Some(bass.map_or(*pitch, |bp| bp.min(*pitch))); + } + } + (w, bass) +} + +fn extract_chords(e: &mut Engine, p: &Value) -> Result { + let id = "event.extract_chords"; + let (_, t0, t1) = selection_ticks(e, p, id)?; + let s = e.session(); + let picked = tracks_param(e, id, p)?; + let mut tracks: Vec = picked.into_iter().filter(|t| s.track(*t).is_some_and(|x| x.kind.is_midi())).collect(); + if tracks.is_empty() { + tracks = s.tracks.iter().filter(|t| t.kind.is_midi()).map(|t| t.id).collect(); + } + // Absolute (tick) notes from every MIDI clip on those tracks, cut to their clip. + let notes = midi_tick_notes(s, &tracks); let mut bar = s.tempo.bar_beat_at_tick(t0).bar; let mut sounding: Vec<(i64, i64, u8)> = Vec::new(); let (mut next, mut bars, mut heard) = (0, 0, 0); @@ -616,17 +639,7 @@ fn extract_chords(e: &mut Engine, p: &Value) -> Result { } bars += 1; heard += sounding.len(); - let mut w = [0.0f64; 12]; - let mut bass: Option = None; - for (na, nb, pitch) in &sounding { - let ov = (*nb).min(b) - (*na).max(a); - if ov > 0 { - if let Some(x) = w.get_mut(usize::from(*pitch) % 12) { - *x += ov as f64; - } - bass = Some(bass.map_or(*pitch, |bp| bp.min(*pitch))); - } - } + let (w, bass) = pitch_class_weights(&sounding, a, b); if let Some(name) = bass.and_then(|bp| chord_name(&w, usize::from(bp) % 12)) { let at = s.tempo.tick_to_samples(a, s.sample_rate); chords.push(json!({"bar": bar, "chord": name, "at": at})); diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index 8daacedf..e5e59fd7 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -612,6 +612,76 @@ fn env_db(env: &[(i64, f32)], at: i64) -> f32 { prev.map_or(0.0, |(_, v)| v) } +/// Fold the volume `lane` into the gain envelope of clip `c` over the selection `r`. +fn bake_volume_into_clip(c: &mut soundcraft_model::Clip, lane: &soundcraft_model::AutomationLane, base: f32, r: soundcraft_time::Range) { + let delta = |at: i64| lane.value_at(at, base) - base; + let sel0 = r.start.max(c.start); + let sel1 = r.end.min(c.end()); + if sel1 <= sel0 { + return; + } + let rel = |at: i64| at - c.start; + let rel0 = rel(sel0); + let rel1 = rel(sel1); + // Later writes win, so the selection anchors replace any point they land on. + let mut pts: Vec<(i64, f32)> = + c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect(); + if rel0 > 0 { + pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1))); + } + pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0))); + for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) { + let at = rel(pt.at); + pts.push((at, env_db(&c.gain_env, at) + delta(pt.at))); + } + // The clip's own gain points inside the selection keep their shape, offset by the lane. + for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) { + pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at)))); + } + if sel1 - 1 > sel0 { + let at = rel(sel1 - 1); + pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1))); + } + if sel1 < c.end() { + pts.push((rel1, env_db(&c.gain_env, rel1))); + } + pts.sort_by_key(|p| p.0); + let mut env = Vec::new(); + for p in pts { + if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) { + env.pop(); + } + env.push(p); + } + c.gain_env = env; +} + +/// Clear the volume lane over `r` when the clips all sit inside it, else hold the fader there. +fn reset_volume_lane(tr: &mut soundcraft_model::Track, lane: &soundcraft_model::AutomationLane, base: f32, r: soundcraft_time::Range) { + // Clearing the lane is only safe when no audio plays outside the selection; otherwise the + // held edge values outside would fall back to the fader level. + let clips_inside = tr.clips().iter().all(|c| c.start >= r.start && c.end() <= r.end); + let covers_all = clips_inside && lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end); + let lane_m = tr.lane_mut(&AutoParam::Volume); + if covers_all { + lane_m.clear_range(r.start, r.end); + } else { + // Hold the fader across the selection and anchor the samples on either side, so the + // curve outside does not interpolate through the gap or keep the old level inside. + let before = (r.start > 0).then(|| lane.value_at(r.start - 1, base)); + let after = lane.value_at(r.end, base); + lane_m.clear_range(r.start, r.end); + if let Some(v) = before { + lane_m.set_point(r.start - 1, v); + } + lane_m.set_point(r.start, base); + if r.end - 1 > r.start { + lane_m.set_point(r.end - 1, base); + } + lane_m.set_point(r.end, after); + } +} + fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "automation.volume_to_clip_gain", p)?; let r = range_param(e, "automation.volume_to_clip_gain", p)?; @@ -620,72 +690,12 @@ fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result { let Some(tr) = s.track_mut(*t) else { continue }; let Some(lane) = tr.lane(&AutoParam::Volume).cloned().filter(|l| !l.points.is_empty()) else { continue }; let base = tr.mixer.volume_db; - let delta = |at: i64| lane.value_at(at, base) - base; if let Some(pl) = tr.playlist_mut() { for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) { - let sel0 = r.start.max(c.start); - let sel1 = r.end.min(c.end()); - if sel1 <= sel0 { - continue; - } - let rel = |at: i64| at - c.start; - let rel0 = rel(sel0); - let rel1 = rel(sel1); - // Later writes win, so the selection anchors replace any point they land on. - let mut pts: Vec<(i64, f32)> = - c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect(); - if rel0 > 0 { - pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1))); - } - pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0))); - for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) { - let at = rel(pt.at); - pts.push((at, env_db(&c.gain_env, at) + delta(pt.at))); - } - // The clip's own gain points inside the selection keep their shape, offset by the lane. - for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) { - pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at)))); - } - if sel1 - 1 > sel0 { - let at = rel(sel1 - 1); - pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1))); - } - if sel1 < c.end() { - pts.push((rel1, env_db(&c.gain_env, rel1))); - } - pts.sort_by_key(|p| p.0); - let mut env = Vec::new(); - for p in pts { - if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) { - env.pop(); - } - env.push(p); - } - c.gain_env = env; + bake_volume_into_clip(c, &lane, base, r); } } - // Clearing the lane is only safe when no audio plays outside the selection; otherwise the - // held edge values outside would fall back to the fader level. - let clips_inside = tr.clips().iter().all(|c| c.start >= r.start && c.end() <= r.end); - let covers_all = clips_inside && lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end); - let lane_m = tr.lane_mut(&AutoParam::Volume); - if covers_all { - lane_m.clear_range(r.start, r.end); - } else { - // Hold the fader across the selection and anchor the samples on either side, so the - // curve outside does not interpolate through the gap or keep the old level inside. - let before = (r.start > 0).then(|| lane.value_at(r.start - 1, base)); - let after = lane.value_at(r.end, base); - lane_m.clear_range(r.start, r.end); - if let Some(v) = before { - lane_m.set_point(r.start - 1, v); - } - lane_m.set_point(r.start, base); - if r.end - 1 > r.start { - lane_m.set_point(r.end - 1, base); - } - lane_m.set_point(r.end, after); - } + reset_volume_lane(tr, &lane, base, r); } Ok(json!({})) } diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs index cad1fc4f..9fe82b08 100644 --- a/crates/engine/src/cmd/setup_more.rs +++ b/crates/engine/src/cmd/setup_more.rs @@ -221,6 +221,23 @@ fn io_state(s: &Session) -> Value { json!({"busses": s.busses, "outputs": s.outputs}) } +/// Disconnect every track input, output and send that used bus `bid`. +fn unroute_bus(s: &mut Session, bid: BusId) { + for t in &mut s.tracks { + if t.mixer.input == Route::Bus(bid) { + t.mixer.input = Route::None; + } + if t.mixer.output == Route::Bus(bid) { + t.mixer.output = Route::None; + } + for slot in &mut t.mixer.sends { + if slot.as_ref().is_some_and(|x| x.target == Route::Bus(bid)) { + *slot = None; + } + } + } +} + fn io_setup(e: &mut Engine, p: &Value) -> Result { let id = "setup.io"; let action = str_param(p, "action").unwrap_or("list"); @@ -252,19 +269,7 @@ fn io_setup(e: &mut Engine, p: &Value) -> Result { "delete_bus" => { let bid: BusId = s.bus_by_name(&name).map(|b| b.id).ok_or_else(|| bad(id, format!("no bus `{name}`")))?; s.busses.retain(|b| b.id != bid); - for t in &mut s.tracks { - if t.mixer.input == Route::Bus(bid) { - t.mixer.input = Route::None; - } - if t.mixer.output == Route::Bus(bid) { - t.mixer.output = Route::None; - } - for slot in &mut t.mixer.sends { - if slot.as_ref().is_some_and(|x| x.target == Route::Bus(bid)) { - *slot = None; - } - } - } + unroute_bus(s, bid); } "create_output" => { if s.outputs.iter().any(|o| o.name.eq_ignore_ascii_case(&name)) { diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index 7eae3f54..07c6ee9e 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -671,6 +671,35 @@ fn change_width(e: &mut Engine, p: &Value) -> Result { Ok(json!({"format": f})) } +/// The nearest ancestor folder of `folder` that is not itself being deleted (walks at most 64 levels). +fn surviving_parent(s: &Session, deleted: &[TrackId], folder: TrackId) -> Option { + let mut parent = s.track(folder).and_then(|folder| folder.folder); + for _ in 0..64 { + match parent { + Some(candidate) if deleted.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder), + _ => break, + } + } + parent +} + +/// Clear inputs and sends that used the removed bus; outputs that used it move to `replacement`. +fn reroute_deleted_bus(s: &mut Session, bus: soundcraft_model::BusId, replacement: &Route) { + for track in &mut s.tracks { + if track.mixer.input == Route::Bus(bus) { + track.mixer.input = Route::None; + } + if track.mixer.output == Route::Bus(bus) { + track.mixer.output = replacement.clone(); + } + for send in &mut track.mixer.sends { + if send.as_ref().is_some_and(|send| send.target == Route::Bus(bus)) { + *send = None; + } + } + } +} + fn delete(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "track.delete", p)?; let s = e.session_mut(); @@ -678,13 +707,7 @@ fn delete(e: &mut Engine, p: &Value) -> Result { .iter() .filter_map(|folder| { let bus = owned_folder_bus(s, *folder)?; - let mut parent = s.track(*folder).and_then(|folder| folder.folder); - for _ in 0..64 { - match parent { - Some(candidate) if tracks.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder), - _ => break, - } - } + let parent = surviving_parent(s, &tracks, *folder); Some((bus, parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main))) }) .collect(); @@ -696,13 +719,7 @@ fn delete(e: &mut Engine, p: &Value) -> Result { continue; } let Some(folder) = child.folder.filter(|folder| tracks.contains(folder)) else { continue }; - let mut parent = s.track(folder).and_then(|folder| folder.folder); - for _ in 0..64 { - match parent { - Some(candidate) if tracks.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder), - _ => break, - } - } + let parent = surviving_parent(s, &tracks, folder); promoted.push((child.id, folder_bus(s, folder), parent, parent.and_then(|parent| folder_bus(s, parent)))); } for (child, old_bus, parent, parent_bus) in promoted { @@ -720,19 +737,7 @@ fn delete(e: &mut Engine, p: &Value) -> Result { } for (bus, replacement) in removed_busses { s.busses.retain(|candidate| candidate.id != bus); - for track in &mut s.tracks { - if track.mixer.input == Route::Bus(bus) { - track.mixer.input = Route::None; - } - if track.mixer.output == Route::Bus(bus) { - track.mixer.output = replacement.clone(); - } - for send in &mut track.mixer.sends { - if send.as_ref().is_some_and(|send| send.target == Route::Bus(bus)) { - *send = None; - } - } - } + reroute_deleted_bus(s, bus, &replacement); } Ok(json!({"deleted": tracks.len()})) } diff --git a/crates/midi/src/smf.rs b/crates/midi/src/smf.rs index 945735c4..7ec83801 100644 --- a/crates/midi/src/smf.rs +++ b/crates/midi/src/smf.rs @@ -439,6 +439,70 @@ fn write_track(out: &mut Vec, mut events: Vec) -> Result<(), MidiErr Ok(()) } +/// The conductor track: tempos, meters, key signatures and markers. +fn conductor_events(smf: &Smf) -> Result, MidiError> { + let mut ev = Vec::new(); + for &(t, bpm) in &smf.tempos { + if !bpm.is_finite() || bpm <= 0.0 { + return Err(MidiError::Write(format!("invalid tempo {bpm} bpm"))); + } + let us = (60_000_000.0 / bpm).round().clamp(1.0, f64::from(0x00FF_FFFFu32)) as u32; + let [_, a, b, c] = us.to_be_bytes(); + ev.push(WEvent { tick: clamp_tick(t), order: 0, bytes: meta(0x51, &[a, b, c])? }); + } + for &(t, num, den) in &smf.meters { + if den == 0 || !den.is_power_of_two() { + return Err(MidiError::Write(format!("meter denominator {den} is not a power of two"))); + } + let dd = den.trailing_zeros() as u8; + ev.push(WEvent { tick: clamp_tick(t), order: 1, bytes: meta(0x58, &[num, dd, 24, 8])? }); + } + for &(t, sf, minor) in &smf.key_sigs { + let sf = sf.clamp(-7, 7); + ev.push(WEvent { tick: clamp_tick(t), order: 2, bytes: meta(0x59, &[sf as u8, u8::from(minor)])? }); + } + for (t, text) in &smf.markers { + ev.push(WEvent { tick: clamp_tick(*t), order: 3, bytes: meta(0x06, text.as_bytes())? }); + } + Ok(ev) +} + +/// Trim notes still sounding when the next same-pitch note starts, and drop duplicates. +fn trim_overlaps(notes: &[Note], ends: &mut [u64], dropped: &mut [bool]) { + // The reader pairs offs with ons first-in first-out, so a note still sounding when the next + // same-pitch note starts would swap ends with it: trim it to that start. When two same-pitch + // notes start on the same tick they cannot be told apart, so only the longer one is written. + let mut order: Vec = (0..notes.len()).collect(); + order.sort_by_key(|&i| { + notes.get(i).map_or((0, 0, 0, std::cmp::Reverse(0)), |n| { + (n.channel & 0x0F, n.pitch.min(127), clamp_tick(n.start), std::cmp::Reverse(ends.get(i).copied().unwrap_or(0))) + }) + }); + let mut prev: Option = None; + for &b in &order { + let Some(nb) = notes.get(b) else { continue }; + if let Some(a) = prev + && let Some(na) = notes.get(a) + && na.channel & 0x0F == nb.channel & 0x0F + && na.pitch.min(127) == nb.pitch.min(127) + { + let (sa, sb) = (clamp_tick(na.start), clamp_tick(nb.start)); + if sa == sb { + if let Some(d) = dropped.get_mut(b) { + *d = true; + } + continue; + } + if let Some(ea) = ends.get_mut(a) + && sb < *ea + { + *ea = sb; + } + } + prev = Some(b); + } +} + /// Writes a format-1 Standard MIDI File at [`PPQ`] (960) ticks per quarter note. /// /// Track 1 is a conductor track holding tempos, meters, key signatures and markers; every @@ -463,29 +527,7 @@ pub fn write_smf(smf: &Smf) -> Result, MidiError> { out.extend_from_slice(&u16::try_from(PPQ).unwrap_or(960).to_be_bytes()); // Conductor track. - let mut ev = Vec::new(); - for &(t, bpm) in &smf.tempos { - if !bpm.is_finite() || bpm <= 0.0 { - return Err(MidiError::Write(format!("invalid tempo {bpm} bpm"))); - } - let us = (60_000_000.0 / bpm).round().clamp(1.0, f64::from(0x00FF_FFFFu32)) as u32; - let [_, a, b, c] = us.to_be_bytes(); - ev.push(WEvent { tick: clamp_tick(t), order: 0, bytes: meta(0x51, &[a, b, c])? }); - } - for &(t, num, den) in &smf.meters { - if den == 0 || !den.is_power_of_two() { - return Err(MidiError::Write(format!("meter denominator {den} is not a power of two"))); - } - let dd = den.trailing_zeros() as u8; - ev.push(WEvent { tick: clamp_tick(t), order: 1, bytes: meta(0x58, &[num, dd, 24, 8])? }); - } - for &(t, sf, minor) in &smf.key_sigs { - let sf = sf.clamp(-7, 7); - ev.push(WEvent { tick: clamp_tick(t), order: 2, bytes: meta(0x59, &[sf as u8, u8::from(minor)])? }); - } - for (t, text) in &smf.markers { - ev.push(WEvent { tick: clamp_tick(*t), order: 3, bytes: meta(0x06, text.as_bytes())? }); - } + let ev = conductor_events(smf)?; if has_conductor { write_track(&mut out, ev)?; } @@ -498,38 +540,7 @@ pub fn write_smf(smf: &Smf) -> Result, MidiError> { let notes = &track.sequence.notes; let mut ends: Vec = notes.iter().map(|n| clamp_tick(n.end()).max(clamp_tick(n.start).saturating_add(1))).collect(); let mut dropped = vec![false; notes.len()]; - // The reader pairs offs with ons first-in first-out, so a note still sounding when the next - // same-pitch note starts would swap ends with it: trim it to that start. When two same-pitch - // notes start on the same tick they cannot be told apart, so only the longer one is written. - let mut order: Vec = (0..notes.len()).collect(); - order.sort_by_key(|&i| { - notes.get(i).map_or((0, 0, 0, std::cmp::Reverse(0)), |n| { - (n.channel & 0x0F, n.pitch.min(127), clamp_tick(n.start), std::cmp::Reverse(ends.get(i).copied().unwrap_or(0))) - }) - }); - let mut prev: Option = None; - for &b in &order { - let Some(nb) = notes.get(b) else { continue }; - if let Some(a) = prev - && let Some(na) = notes.get(a) - && na.channel & 0x0F == nb.channel & 0x0F - && na.pitch.min(127) == nb.pitch.min(127) - { - let (sa, sb) = (clamp_tick(na.start), clamp_tick(nb.start)); - if sa == sb { - if let Some(d) = dropped.get_mut(b) { - *d = true; - } - continue; - } - if let Some(ea) = ends.get_mut(a) - && sb < *ea - { - *ea = sb; - } - } - prev = Some(b); - } + trim_overlaps(notes, &mut ends, &mut dropped); for (i, n) in notes.iter().enumerate() { if dropped.get(i).copied().unwrap_or(false) { continue; diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index 96fb67a1..c8904641 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -439,34 +439,40 @@ impl AudioState { let b = if i0 + 1 < self.pending_len { ch.and_then(|v| v.get(i0 + 1)).copied().unwrap_or(a) } else { a }; a * (1.0 - fr) + b * fr }; - if channels < nch { - // Fold surround to stereo first; a mono device averages the two sides. - let (mut l, mut r) = (0.0f32, 0.0f32); - if sounding { - if nch == 2 { - (l, r) = (sample(0), sample(1)); - } else { - for (c, (gl, gr)) in self.fold.iter().enumerate().take(nch) { - let v = sample(c); - l += v * gl; - r += v * gr; - } - } - } - for (c, o) in frame.iter_mut().enumerate() { - *o = match c { - 0 if channels == 1 => out_sample(0.5 * l + 0.5 * r), - 0 => out_sample(l), - 1 => out_sample(r), - _ => 0.0, - }; - } + write_frame(frame, channels, nch, sounding, &self.fold, sample); + self.read += step; + } + } +} + +/// Write one device frame from `nch` rendered channels: fold surround to stereo (a mono device +/// averages the two sides), or copy the channels straight through. `sample(c)` is channel `c`. +fn write_frame(frame: &mut [f32], channels: usize, nch: usize, sounding: bool, fold: &[(f32, f32)], sample: impl Fn(usize) -> f32) { + if channels < nch { + // Fold surround to stereo first; a mono device averages the two sides. + let (mut l, mut r) = (0.0f32, 0.0f32); + if sounding { + if nch == 2 { + (l, r) = (sample(0), sample(1)); } else { - for (c, o) in frame.iter_mut().enumerate() { - *o = if c < nch.max(1) && sounding { out_sample(sample(c)) } else { 0.0 }; + for (c, (gl, gr)) in fold.iter().enumerate().take(nch) { + let v = sample(c); + l += v * gl; + r += v * gr; } } - self.read += step; + } + for (c, o) in frame.iter_mut().enumerate() { + *o = match c { + 0 if channels == 1 => out_sample(0.5 * l + 0.5 * r), + 0 => out_sample(l), + 1 => out_sample(r), + _ => 0.0, + }; + } + } else { + for (c, o) in frame.iter_mut().enumerate() { + *o = if c < nch.max(1) && sounding { out_sample(sample(c)) } else { 0.0 }; } } } diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs index 89435747..b2264a66 100644 --- a/crates/vst3-host/src/plugin.rs +++ b/crates/vst3-host/src/plugin.rs @@ -194,6 +194,35 @@ fn keep_releases(notes: &mut Vec) { } } +/// Copy `n` frames from `start` of the host buffers into the plugin's input `port` (mono sources +/// fill every port channel). +fn feed_input(inputs: &mut BusBuffers, port: usize, io: &[Vec], ch: usize, (start, n): (usize, usize)) { + for c in 0..inputs.channels(port) { + let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n)); + if let (Some(src), Some(dst)) = (src, inputs.channel_mut(port, c).and_then(|d| d.get_mut(..n))) { + dst.copy_from_slice(src); + } + } +} + +/// Copy `n` frames of the plugin's output `port` into the host buffers from `start`; a mono host +/// buffer takes the average of a stereo port. +fn read_output(outputs: &BusBuffers, port: usize, io: &mut [Vec], ch: usize, (start, n): (usize, usize)) { + let oc = outputs.channels(port); + for (c, dst) in io.iter_mut().take(ch).enumerate() { + let Some(dst) = dst.get_mut(start..start + n) else { continue }; + if ch == 1 && oc >= 2 { + if let (Some(l), Some(r)) = (outputs.channel(port, 0), outputs.channel(port, 1)) { + for ((d, a), b) in dst.iter_mut().zip(l).zip(r) { + *d = 0.5 * (a + b); + } + } + } else if let Some(src) = outputs.channel(port, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) { + dst.copy_from_slice(src); + } + } +} + impl Plugin for Vst3Plugin { fn info(&self) -> &'static PluginInfo { self.info @@ -290,12 +319,7 @@ impl Plugin for Vst3Plugin { let end = u32::try_from(start + n).unwrap_or(u32::MAX); // Feed the main input bus (mono sources fill every bus channel). if self.has_input { - for c in 0..self.inputs.channels(0) { - let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n)); - if let (Some(src), Some(dst)) = (src, self.inputs.channel_mut(0, c).and_then(|d| d.get_mut(..n))) { - dst.copy_from_slice(src); - } - } + feed_input(&mut self.inputs, 0, io, ch, (start, n)); } let k = self.notes.iter().position(|e| e.time() >= end).unwrap_or(self.notes.len()); let offset = u32::try_from(start).unwrap_or(u32::MAX); @@ -316,19 +340,7 @@ impl Plugin for Vst3Plugin { self.position = self.position.saturating_add(i64::try_from(n).unwrap_or(0)); match r { Ok(()) if self.has_output => { - let oc = self.outputs.channels(0); - for (c, dst) in io.iter_mut().take(ch).enumerate() { - let Some(dst) = dst.get_mut(start..start + n) else { continue }; - if ch == 1 && oc >= 2 { - if let (Some(l), Some(r)) = (self.outputs.channel(0, 0), self.outputs.channel(0, 1)) { - for ((d, a), b) in dst.iter_mut().zip(l).zip(r) { - *d = 0.5 * (a + b); - } - } - } else if let Some(src) = self.outputs.channel(0, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) { - dst.copy_from_slice(src); - } - } + read_output(&self.outputs, 0, io, ch, (start, n)); } Err(e) => log::debug!("{}: {e}", self.info.id), _ => {}