Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 68d91f9b04 refactor: split auto_pan process and audiosuite frame limit into helpers
Reduces cognitive complexity flagged by SonarCloud (rust:S3776). Behaviour is unchanged:
per-sample order is envelope update, envelope trigger, LFO phase, modulator, width/manual/position
smoothing, output write.
2026-10-25 21:06:47 -04:00
aiongg 23f90a9164 chore(deps): safe dependency refresh (#1102) 2026-10-25 20:49:42 -04:00
Dylan M. Taylor 1c63b2a5b9 tests(clap-host): answer nextest's --list for the custom-harness editor test (#1100) 2026-10-25 20:14:33 -04:00
Dylan M. Taylor cf0f4d2f6a tests(vst3-host): answer nextest's --list for custom-harness tests (#1099) 2026-10-25 19:49:11 -04:00
Dylan M. Taylor e14cea42b2 Linux: default to XWayland for file drag-and-drop on Wayland desktops (#150)
* Linux: default to XWayland for file drag-and-drop on Wayland desktops

* Ignore an empty DISPLAY before forcing X11
2026-10-25 19:37:13 -04:00
Dylan M. Taylor d683aee3eb 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
2026-10-25 19:32:34 -04:00
Dylan M. Taylor 0197e27fc4 build: pin craft-update to craft-libs main (495e597) (#1096)
Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down.

Part of tracking issue storytold/craft-libs#319
2026-10-25 17:50:34 -04:00
Dylan M. TaylorandCodebuff 5bbefeae62 build: pin craft-update to craft-libs main (495e597) (#1092)
Picks up the update prompt fix that draws its close mark and version pill
without glyphs a font may lack, so the prompt no longer takes the app down
when a bundled face has no ✕ or →.

Part of tracking issue storytold/craft-libs#319

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-25 17:37:38 -04:00
Dylan M. Taylor a44d1b830b Merge pull request #1014 from storytold/fix/sonar-s107
refactor: bundle parameters of long signatures (Sonar S107)
2026-10-25 16:35:58 -04:00
Dylan M. Taylor eec559f057 build: pin craft-update to craft-libs main (db19c37) (#1090)
Picks up the fix that keeps the close request pending while the app holds unsaved work, the executable-name check in the Windows update helper, and the zip 9 / dirs-free build.

Part of tracking issue storytold/craft-libs#319
2026-10-25 15:34:58 -04:00
Dylan M. Taylor b854771251 feat: startup update prompt modal and direct self-updating (#1073)
* feat: startup update prompt modal and direct self-updating

Integrate `craft-update` pinned to storytold/craft-libs fe61243 to check for
updates on startup, display the standard suite update modal with changelog notes,
and support one-click in-app updating and restart.

Ref: storytold/craft-libs#319

* wip

* build: refresh the lock

* refactor(updates): drive the flow with craft-update's Updater

The check at start is opt-in for the desktop app, a successful update closes through the app's own close path, and no update starts over unsaved work.

* fix(updates): register the update command and hold the install back during a recording

Help ▸ Check for Updates… goes through the shared command id, and an active recording counts as work the update must not interrupt.
2026-10-25 14:38:14 -04:00
aiongg 0a95b6dadf Merge pull request #1045 from forwardaudio/meters-accumulate
Meters: keep every block's peak until the UI takes it
2026-10-25 12:12:08 -04:00
aiongg cf96cb888c Follow-ups to #1058: Auto Pan keeps the stereo image, paste and Strip Silence fixes (#1060)
* Auto Pan: balance the left and right channels at constant power instead of panning their mono sum

* Strip Silence rectangles: on the web, rescan when a slider drag ends instead of on every frame of it

* Paste: keep the clipboard's remapped Auto Rename keys only when the paste succeeds

* Subharmonic: test that Q 5 filters after the saturator keep the output finite

* AudioSuite window: drop the constant-speed varispeed controls no menu item opens

* docs: gaps: Clip Looping has no loop crossfade and caps Number of Loops at 512
2026-10-25 12:06:36 -04:00
skylar taylor-barrick e0951aecf9 Migrate SoundCraft to egui 0.37 (#1063)
* fix(ui): preserve host keys during scripted input and clarify mixer labels

* Preserve host input while expiring scripted gestures

* Document migrated audio and UI behavior and regression invariants
2026-10-25 10:48:56 -04:00
Brandon ThomasandClaude Opus 5.5 01b39850ad Release 0.13.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:29:18 -04:00
Brandon ThomasandClaude Opus 5.5 b48df2a8b9 Release workflow: grant contents: write on the release job
#988 dropped the workflow-level permission for SonarCloud S8233 but never added the job-level one, so the 0.13.0 release run failed at startup: the shared workflow's draft-release job asks for contents: write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:29:08 -04:00
Brandon ThomasandClaude Opus 5.5 106678471f Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:26:25 -04:00
Forward AudioandClaude Opus 5.5 7d4ae23202 Meters: a channel-count change takes the new stereo peaks; takes drop deleted tracks
When a strip's channel count changes, the first-two-channel peaks now take the new values
along with the per-channel ones. A take that finds no new block still drops the meters of
deleted tracks from the UI's copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:26:03 +01:00
Forward AudioandClaude Opus 5.5 37e155c828 Meters: keep every block's peak until the UI takes it
The audio thread overwrote the shared meter snapshot every block (~94 blocks/s at 512
frames, 48 kHz) while the UI read it 20-60 times a second, so it only ever saw the latest
block and short transients were under-read. Blocks are now folded together (max of each
channel's peak and of the gain reduction; plugin failures keep the latest count) until the UI
takes them with Player::take_meters, which also starts a new period. Blocks rendered while the
UI holds the lock wait in an audio-side accumulator instead of being lost; the audio thread
still only try_locks.

Stopping, starting and locating now zero the meters in place instead of clearing the maps,
so playing again no longer re-allocates every track's entry on the audio thread. The UI
thread works out which tracks a session update deleted and tells the audio thread, which
drops just their entries.

The Mix window and the Renderer now read the snapshot the app takes once per poll instead of
reading the player themselves, so a period's peaks are not split between readers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:14:52 +02:00
Dylan M. TaylorandClaude Sonnet 5.5 7469834420 refactor: leave timebase_ruler, draw_clip, process_strip for their cognitive-complexity refactor
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 08:44:41 +00:00
Dylan M. TaylorandClaude Sonnet 5.5 e220666546 refactor: group parameters into structs for too-many-args functions (S107)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 08:32:19 +00:00
51 changed files with 2290 additions and 944 deletions
+3
View File
@@ -16,6 +16,9 @@ concurrency:
jobs:
release:
# The shared workflow creates the draft release, which needs write access to contents.
permissions:
contents: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
Generated
+634 -234
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.12.1"
version = "0.13.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -49,6 +49,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
# Video containers and bitstreams, shared with the other craft apps.
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
# ui
# 650b1c0b058017c4eb3af06d5b39586d6732e07b is the revision produced by the separate craft-libs current-main-0135
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** trivial (Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
@@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track
## Progress log
- **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change.
- **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change.
- **2026-10-24:** While the Strip Silence window is open, the Edit window draws a rectangle around each range that Strip would keep on the selected tracks, padded by Clip Start Pad and Clip End Pad, and the rectangles follow the sliders as they move. They are removed when the window closes. `edit.strip_silence_preview` returns the same ranges without changing the session. No readiness percentage change.
- **2026-10-24:** The Strip Silence window has a Rename button. It opens Rename Selected Clips, where a name, a starting number, a count of zeros and a suffix set the names of the clips that Strip, Extract and Separate leave in the selection (for example SFX023.Reel1, SFX024.Reel1). The app keeps the scheme for later uses of Strip Silence until the app quits, or until Clear and then OK empty it. No readiness percentage change.
@@ -174,6 +175,7 @@ mutated and truncated movies. Field reports from the first public week are track
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Strip Silence rectangles; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Strip Silence Rename; readiness estimates unchanged |
+6
View File
@@ -24,6 +24,9 @@ serde_json.workspace = true
log.workspace = true
chrono.workspace = true
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd.workspace = true
@@ -35,3 +38,6 @@ winresource = "0.1"
muda = { version = "0.21.2", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] }
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
+16
View File
@@ -210,6 +210,11 @@ fn app_icon() -> Option<egui::IconData> {
}
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::SOUNDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so every start-up record (and the engine's panic hook, installed with the first
// Engine) is captured; see `logging`.
let logger = logging::install();
@@ -269,6 +274,16 @@ fn main() -> eframe::Result {
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
#[cfg(all(unix, not(target_os = "macos")))]
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; SOUNDCRAFT_WAYLAND=1
// keeps the native Wayland backend.
if std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()) && std::env::var_os("SOUNDCRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
eframe::run_native(
"SoundCraft",
options,
@@ -276,6 +291,7 @@ fn main() -> eframe::Result {
let player =
if no_audio { None } else { Some(soundcraft_playback::Player::with_config(Arc::new(engine.session().clone()), &prefs.audio)) };
let mut app = SoundApp::new(engine, player, services());
app.set_startup_update_check(true);
app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave")));
app.leftovers = app.autosave_dir.as_deref().map(soundcraft_engine::autosave::leftovers).unwrap_or_default();
app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets")));
+8 -1
View File
@@ -321,7 +321,14 @@ impl<W: Write + Seek> FileEncoder<W> {
pub fn new(out: W, opts: &EncodeOptions, channels: usize, sample_rate: u32, mask: Option<u32>) -> Result<Self> {
let (depth, dither) = (opts.bit_depth, opts.dither);
Ok(FileEncoder(match opts.format {
FileFormat::Wav => Writer::Wav(wav::Writer::new(out, channels, sample_rate, depth, dither, opts.bwf.as_ref(), mask, false)?),
FileFormat::Wav => Writer::Wav(wav::Writer::new(
out,
channels,
sample_rate,
depth,
dither,
wav::HeaderExtras { bwf: opts.bwf.as_ref(), mask, force_rf64: false },
)?),
FileFormat::Aiff => Writer::Aiff(aiff::Writer::new(out, channels, sample_rate, depth, dither)?),
FileFormat::Flac => Writer::Flac(flac::Writer::new(out, channels, sample_rate, depth, dither)?),
other => return Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))),
+37 -16
View File
@@ -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<Vec<Vec<f32>>> {
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<f32>], 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()));
+52 -32
View File
@@ -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<Option<(PcmKind, SampleFormat)>> {
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<WavParse> {
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<WavParse> {
// 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<WavParse> {
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);
@@ -369,17 +389,17 @@ pub(crate) struct Writer<W> {
failed: bool,
}
/// The optional header parts of a WAV file: BWF metadata, speaker mask, and whether to force RF64.
#[derive(Clone, Copy)]
pub(crate) struct HeaderExtras<'a> {
pub(crate) bwf: Option<&'a BwfInfo>,
pub(crate) mask: Option<u32>,
pub(crate) force_rf64: bool,
}
impl<W: Write + Seek> Writer<W> {
pub(crate) fn new(
mut out: W,
channels: usize,
sample_rate: u32,
depth: BitDepth,
dither: bool,
bwf: Option<&BwfInfo>,
mask: Option<u32>,
force_rf64: bool,
) -> Result<Self> {
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool, extras: HeaderExtras<'_>) -> Result<Self> {
let HeaderExtras { bwf, mask, force_rf64 } = extras;
validate_layout(channels, sample_rate)?;
let format = sample_format(depth);
let start = out.stream_position()?;
@@ -463,7 +483,7 @@ pub(crate) fn encode(
let mut out = Vec::new();
out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1))
.map_err(|_| AudioError::TooLarge(format!("cannot allocate {data_len} bytes for WAV output")))?;
let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, bwf, mask, force_rf64)?;
let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, HeaderExtras { bwf, mask, force_rf64 })?;
w.write(&buf.channels, frames)?;
Ok(w.finish()?.into_inner())
}
+31 -19
View File
@@ -102,6 +102,35 @@ fn note_mode(p: Option<NotePort>) -> 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<f32>], 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<f32>], 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),
_ => {}
@@ -15,6 +15,11 @@ fn block(p: &mut Box<dyn Plugin>) -> Vec<Vec<f32>> {
}
fn main() {
// nextest lists custom-harness tests with `--list`; each binary here is one test.
if std::env::args().any(|a| a == "--list") {
println!("editor_main_thread: test");
return;
}
common::fixture_dir();
let mut p = soundcraft_clap_host::create(GAIN).expect("fixture");
p.prepare(48_000.0, 64, 2);
+57 -47
View File
@@ -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::<clap_event_param_value>() 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::<clap_event_note>() 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, &note.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::<clap_event_param_value>() 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::<clap_event_note>() 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, &note.header) });
unsafe { push_gui_edit(pr) };
}
let mut next = 0u32;
let mut last_time = 0u32;
+74 -63
View File
@@ -322,6 +322,77 @@ pub fn resample(ch: &[Vec<f32>], from: u32, to: u32) -> Vec<Vec<f32>> {
// ---------------------------------------------------------------------------------------------
// 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<f32>], out: &mut [Vec<f32>], 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<f32>], 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<f32>], ratio: f64, sample_rate: f32) -> Vec<Vec<f3
if out_pos >= 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
}
+94 -64
View File
@@ -94,9 +94,35 @@ fn duration_bars(duration: usize) -> Option<i64> {
}
}
/// Pans the mono sum of the first two channels between them. The pan position is
/// `manual · (1 - width) + modulator · width`, where the modulator is the LFO or the envelope
/// follower in -1..=1. A signal that is the same on both channels keeps its level at the center.
/// Writes one frame: a constant-power pan for stereo and wider, plain gain for mono. Channels past
/// the second only get the output gain.
fn write_frame(io: &mut [Vec<f32>], n: usize, ch: usize, left: f32, right: f32, position: f32, out: f32) {
if ch >= 2 {
// Constant power: sin² + cos² = 1 across the field, and both gains are 1 at the center.
let theta = (position.clamp(-1.0, 1.0) + 1.0) * FRAC_PI_4;
let (l, r) = (theta.cos().max(0.0), theta.sin().max(0.0));
if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
*x = left * l * SQRT_2 * out;
}
if let Some(x) = io.get_mut(1).and_then(|b| b.get_mut(n)) {
*x = right * r * SQRT_2 * out;
}
for c in 2..ch {
if let Some(x) = io.get_mut(c).and_then(|b| b.get_mut(n)) {
*x = if x.is_finite() { *x * out } else { 0.0 };
}
}
} else if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
*x = left * out;
}
}
/// Moves the balance of the first two channels with a constant-power law: as the pan position
/// moves right the left channel's gain falls and the right's rises, and each channel stays on its
/// own side. The pan position is `manual · (1 - width) + modulator · width`, where the modulator is
/// the LFO or the envelope follower in -1..=1. At the center both gains are 1, so a stereo signal
/// passes unchanged; a signal that is the same on both channels pans across the field at a
/// constant level.
pub struct AutoPan {
p: Params,
sr: f32,
@@ -227,6 +253,63 @@ impl AutoPan {
let ratio = hz * dur * 60.0 / t.tempo;
Some((into * ratio).rem_euclid(1.0))
}
/// Advances the envelope follower by one sample. `level` is the louder channel's magnitude.
fn follow_envelope(&mut self, level: f32, attack: f32, release: f32) {
let k = if level > self.env { attack } else { release };
self.env = level + (self.env - level) * k;
if self.env < 1.0e-12 {
self.env = 0.0;
}
}
/// Arms the Envelope trigger on a rising threshold crossing and re-arms it after a Release of silence.
fn envelope_trigger(&mut self, threshold: f32, release_samples: u64) {
if self.armed && self.env >= threshold {
self.armed = false;
self.phase = 0.0;
self.holding = false;
self.below = 0;
} else if !self.armed {
if self.env < threshold {
self.below = self.below.saturating_add(1);
self.armed = self.below >= release_samples;
} else {
self.below = 0;
}
}
}
/// The LFO phase for sample `n` of the block. Advances the phase unless a held or Beat Clock position sets it.
fn lfo_phase(&mut self, n: usize, trigger: usize, hz: f64, inc: f64) -> f64 {
match (trigger == TRIGGER_BEAT_CLOCK).then(|| self.beat_clock_phase(n, hz)).flatten() {
Some(phase) => phase,
None if self.holding => self.phase,
None => {
let current = self.phase;
self.phase += inc;
if self.phase >= 1.0 {
if matches!(trigger, TRIGGER_FREE | TRIGGER_BEAT_CLOCK) {
self.phase -= 1.0;
} else {
self.phase = 1.0;
self.holding = true;
}
}
current
}
}
}
/// The modulator in -1..=1: the envelope level above the threshold, or the LFO shape.
fn modulator(&self, envelope_source: bool, threshold_db: f32, waveform: usize, lfo_phase: f64) -> f32 {
if envelope_source {
let above = (gain_to_db(self.env) - threshold_db) / -threshold_db;
2.0 * above.clamp(0.0, 1.0) - 1.0
} else {
wave_at(waveform, lfo_phase as f32)
}
}
}
impl Plugin for AutoPan {
@@ -277,51 +360,15 @@ impl Plugin for AutoPan {
let release_samples = ms_to_samples(self.p.v(13), self.sr) as u64;
for n in 0..frames {
let sample = |io: &[Vec<f32>], c: usize| io.get(c).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
let mono = if ch >= 2 { 0.5 * (sample(io, 0) + sample(io, 1)) } else { sample(io, 0) };
let level = mono.abs();
let k = if level > self.env { attack } else { release };
self.env = level + (self.env - level) * k;
if self.env < 1.0e-12 {
self.env = 0.0;
}
let left = sample(io, 0);
let right = if ch >= 2 { sample(io, 1) } else { left };
// The louder channel drives the follower, so anti-phase material still has a level.
self.follow_envelope(left.abs().max(right.abs()), attack, release);
if trigger == TRIGGER_ENVELOPE {
if self.armed && self.env >= threshold {
self.armed = false;
self.phase = 0.0;
self.holding = false;
self.below = 0;
} else if !self.armed {
if self.env < threshold {
self.below = self.below.saturating_add(1);
self.armed = self.below >= release_samples;
} else {
self.below = 0;
}
}
self.envelope_trigger(threshold, release_samples);
}
let lfo_phase = match (trigger == TRIGGER_BEAT_CLOCK).then(|| self.beat_clock_phase(n, hz)).flatten() {
Some(phase) => phase,
None if self.holding => self.phase,
None => {
let current = self.phase;
self.phase += inc;
if self.phase >= 1.0 {
if matches!(trigger, TRIGGER_FREE | TRIGGER_BEAT_CLOCK) {
self.phase -= 1.0;
} else {
self.phase = 1.0;
self.holding = true;
}
}
current
}
};
let modulator = if envelope_source {
let above = (gain_to_db(self.env) - threshold_db) / -threshold_db;
2.0 * above.clamp(0.0, 1.0) - 1.0
} else {
wave_at(waveform, lfo_phase as f32)
};
let lfo_phase = self.lfo_phase(n, trigger, hz, inc);
let modulator = self.modulator(envelope_source, threshold_db, waveform, lfo_phase);
let width = self.width.next();
let manual = self.manual.next();
self.position.set(manual * (1.0 - width) + sign * modulator * width);
@@ -331,24 +378,7 @@ impl Plugin for AutoPan {
}
let position = self.position.next();
let out = self.output.next();
if ch >= 2 {
// Constant power: sin² + cos² = 1 across the field.
let theta = (position.clamp(-1.0, 1.0) + 1.0) * FRAC_PI_4;
let (l, r) = (theta.cos().max(0.0), theta.sin().max(0.0));
if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
*x = mono * l * SQRT_2 * out;
}
if let Some(x) = io.get_mut(1).and_then(|b| b.get_mut(n)) {
*x = mono * r * SQRT_2 * out;
}
for c in 2..ch {
if let Some(x) = io.get_mut(c).and_then(|b| b.get_mut(n)) {
*x = if x.is_finite() { *x * out } else { 0.0 };
}
}
} else if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
*x = mono * out;
}
write_frame(io, n, ch, left, right, position, out);
}
self.since_transport = self.since_transport.saturating_add(frames as u64);
}
+60 -55
View File
@@ -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,
+27 -15
View File
@@ -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<f32>], 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<f32>], 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);
}
}
+49 -1
View File
@@ -1,4 +1,4 @@
use std::f32::consts::PI;
use std::f32::consts::{FRAC_PI_4, PI, TAU};
use soundcraft_dsp::{Plugin, Transport, create, db_to_gain};
@@ -294,3 +294,51 @@ fn auto_pan_output_gain_and_mono_tracks() {
p.process(&mut buf, BLOCK);
assert_near(buf[0][BLOCK - 1], 0.5 * db_to_gain(-6.0), 1e-4, "mono output");
}
#[test]
fn auto_pan_balances_a_stereo_signal_without_summing_it() {
// A sine on the left, the same sine inverted plus an offset on the right: the channels differ,
// and a mono sum would cancel the sine.
let len = at(0.2);
let left: Vec<f32> = (0..len).map(|i| 0.5 * (TAU * 440.0 * i as f32 / SR).sin()).collect();
let right: Vec<f32> = left.iter().map(|x| 0.25 - x).collect();
let run = |p: &mut dyn Plugin| {
let mut out = vec![Vec::new(), Vec::new()];
for start in (0..len).step_by(BLOCK) {
let end = (start + BLOCK).min(len);
let mut buf = vec![left[start..end].to_vec(), right[start..end].to_vec()];
p.process(&mut buf, end - start);
out[0].extend_from_slice(&buf[0]);
out[1].extend_from_slice(&buf[1]);
}
out
};
let mut p = auto_pan(&[("width", 0.0)]);
let out = run(p.as_mut());
for i in 0..len {
assert_near(out[0][i], left[i], 1e-5, "center passes the left channel");
assert_near(out[1][i], right[i], 1e-5, "center passes the right channel");
}
// Half left: each channel keeps its own content, the left louder and the right quieter.
let mut p = auto_pan(&[("width", 0.0), ("manual", -50.0)]);
let out = run(p.as_mut());
let theta = 0.5 * FRAC_PI_4;
let (gl, gr) = (2f32.sqrt() * theta.cos(), 2f32.sqrt() * theta.sin());
for i in at(0.05)..len {
assert_near(out[0][i], left[i] * gl, 1e-5, "half left, left channel");
assert_near(out[1][i], right[i] * gr, 1e-5, "half left, right channel");
}
// Anti-phase channels still drive the envelope follower to the right.
let mut p = auto_pan(&[("source", 1.0), ("threshold", -30.0)]);
let mut buf = vec![vec![1.0f32; BLOCK], vec![-1.0f32; BLOCK]];
for _ in 0..at(0.5) / BLOCK {
buf[0].fill(1.0);
buf[1].fill(-1.0);
p.process(&mut buf, BLOCK);
}
assert_near(buf[0][BLOCK - 1], 0.0, 1e-3, "anti-phase at full scale pans right: left");
assert_near(buf[1][BLOCK - 1], -(2f32.sqrt()), 1e-3, "anti-phase at full scale pans right: right");
}
+11 -2
View File
@@ -597,16 +597,25 @@ fn subharmonic_solo_plays_one_band_and_mutes_the_dry_signal() {
#[test]
fn subharmonic_drive_keeps_every_character_bounded() {
for character in 0..6 {
let driven = |character: usize, q: f32| {
let mut p = subharmonic_wet();
p.set_param("input", 18.0);
p.set_param("lower_gain", 24.0);
p.set_param("upper_gain", 24.0);
p.set_param("drive", 18.0);
p.set_param("character", character as f32);
let out = run(p.as_mut(), &[sine(60.0, 1.0, 48_000)]);
p.set_param("hp_q", q);
p.set_param("lp_q", q);
run(p.as_mut(), &[sine(60.0, 1.0, 48_000)])
};
for character in 0..6 {
let out = driven(character, 0.707);
let top = peak(&out[0][24_000..]);
assert!(top > 0.5 && top < 1.5, "character {character}: peak {top}");
// Q 5 filters after the saturator ring well above full scale, but never to infinity.
let out = driven(character, 5.0);
assert!(out[0].iter().all(|v| v.is_finite()), "character {character} at Q 5");
assert!(peak(&out[0][24_000..]) > 0.5, "character {character} at Q 5 is silent");
}
}
+13 -9
View File
@@ -20,6 +20,18 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// The most frames a process can take in: these processes cut their output at MAX_OFFLINE_SAMPLES frames.
fn frame_limit(name: &str, ratio: f64, semitones: f32, sr: f64, to: u32, selection: SpeedRampSelection) -> usize {
let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize;
match name {
"time_stretch" => longest(ratio),
"pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)),
"varispeed" => longest(f64::from(to) / sr),
"speed_ramp" => longest(if selection == SpeedRampSelection::Extend { 1.5 } else { 1.0 }),
_ => usize::MAX,
}
}
/// Process each target clip's audio and replace the clip with one backed by a new source.
fn process(e: &mut Engine, p: &Value) -> Result<Value> {
let name = str_param(p, "process").or_else(|| str_param(p, "plugin")).ok_or_else(|| bad("audiosuite.process", "`process` required"))?.to_string();
@@ -63,15 +75,7 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
let change = if get("change", 0.0) >= 0.5 { SpeedRampChange::SpeedUp } else { SpeedRampChange::SlowDown };
let selection = if get("selection", 0.0) >= 0.5 { SpeedRampSelection::Extend } else { SpeedRampSelection::FitTo };
let fades = get("fades", 1.0) >= 0.5;
// These processes cut their output at MAX_OFFLINE_SAMPLES frames.
let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize;
let most = match name.as_str() {
"time_stretch" => longest(ratio),
"pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)),
"varispeed" => longest(f64::from(to) / sr.as_f64()),
"speed_ramp" => longest(if selection == SpeedRampSelection::Extend { 1.5 } else { 1.0 }),
_ => usize::MAX,
};
let most = frame_limit(&name, ratio, semitones, sr.as_f64(), to, selection);
let mut done = 0;
for id in ids {
let Some((track, clip)) = e.session().find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
+20
View File
@@ -188,6 +188,26 @@ mod tests {
assert_eq!(s.auto_names[&key].number, 100, "the other scheme's count is untouched");
}
#[test]
fn a_refused_paste_leaves_the_clipboard_as_it_was() {
let mut e = crate::demo::demo_engine();
let (t, id, start, len) = first_kick(&e);
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap();
e.execute("edit.copy", &json!({"tracks": [t], "start": start, "end": start + len})).unwrap();
// Without its scheme in the session, a paste puts the scheme back under a new key.
e.session_mut().auto_names.clear();
let copied = e.clipboard.clone();
// A time-locked clip at the paste point leaves Shuffle no room.
e.session_mut().find_clip_mut(id).unwrap().time_locked = true;
e.session_mut().edit.edit_mode = soundcraft_model::EditMode::Shuffle;
assert!(e.execute("edit.paste", &json!({"tracks": [t], "at": start})).is_err());
assert_eq!(e.clipboard, copied);
e.session_mut().edit.edit_mode = soundcraft_model::EditMode::Slip;
let at = e.session().content_end() + 48_000;
e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap();
assert_ne!(e.clipboard, copied, "a paste that succeeds keeps the scheme's new key");
}
#[test]
fn bad_auto_rename_params_are_refused() {
let mut e = crate::demo::demo_engine();
+38 -27
View File
@@ -327,12 +327,12 @@ fn cut_track(s: &mut Session, t: TrackId, r: soundcraft_time::Range, sh: bool, f
}
/// The clipboard for a paste, with the Auto Rename schemes of its clips put into the session first
/// ([`Clipboard::adopt_auto_names`]).
/// ([`Clipboard::adopt_auto_names`]). A paste that succeeds stores it back in `e.clipboard`, so later
/// pastes find its schemes under their session keys; a refused paste leaves the clipboard as it was.
pub(super) fn clipboard_for_paste(e: &mut Engine) -> Clipboard {
let mut cb = e.clipboard.clone();
if cb.tracks.iter().flatten().any(|c| c.auto_name.is_some()) {
cb.adopt_auto_names(e.session_mut());
e.clipboard = cb.clone();
}
cb
}
@@ -373,6 +373,7 @@ fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
}
}
s.edit.selection = soundcraft_time::Range::new(at, at + cb.length);
e.clipboard = cb;
Ok(json!({"pasted": pasted}))
}
@@ -462,6 +463,7 @@ fn repeat_to_fill(e: &mut Engine, p: &Value) -> Result<Value> {
at += len;
n += 1;
}
e.clipboard = cb;
Ok(json!({"repeats": n}))
}
@@ -473,6 +475,38 @@ fn select_all(e: &mut Engine, _: &Value) -> Result<Value> {
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<TrackId>) {
let extra: Vec<TrackId> =
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<TrackId> = 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<ClipId>) {
let mut range: Option<soundcraft_time::Range> = 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<Value> {
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")?;
@@ -488,15 +522,7 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
if let Some(mut t) = tracks {
// Active edit groups select all their members together.
if !exact {
let extra: Vec<soundcraft_model::TrackId> =
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<soundcraft_model::TrackId> = 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;
}
@@ -506,22 +532,7 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
_ => {}
}
if let Some(c) = clips {
// Selecting clips also selects their range and tracks (Grabber click).
let mut range: Option<soundcraft_time::Range> = 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();
}
+54 -43
View File
@@ -510,6 +510,7 @@ fn paste_merge_midi(e: &mut Engine, p: &Value) -> Result<Value> {
}
}
}
e.clipboard = cb;
Ok(json!({"merged": merged, "placed": placed}))
}
@@ -856,6 +857,57 @@ fn coalesce_clip_gain_to_volume(e: &mut Engine, p: &Value) -> Result<Value> {
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<AutoParam> {
let mut params: Vec<AutoParam> = 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<Value> {
let tracks = tracks_required(e, id, p)?;
let r = nonempty_range(e, p, id)?;
@@ -863,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<AutoParam> = 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, &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);
}
}
for param in trim_params(tr, pan_only) {
trim_lane(tr, &param, r, glide);
lanes += 1;
}
}
+35 -22
View File
@@ -564,18 +564,11 @@ pub fn chord_name(weights: &[f64; 12], bass: usize) -> Option<String> {
const MAX_CHORD_BARS: usize = 100_000;
const MAX_CHORD_NOTES: usize = 10_000_000;
fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
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<TrackId> = 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<Value> {
}
}
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<u8>) {
let mut w = [0.0f64; 12];
let mut bass: Option<u8> = 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<Value> {
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<TrackId> = 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<Value> {
}
bars += 1;
heard += sounding.len();
let mut w = [0.0f64; 12];
let mut bass: Option<u8> = 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}));
+72 -62
View File
@@ -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<Value> {
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<Value> {
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!({}))
}
+18 -13
View File
@@ -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<Value> {
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<Value> {
"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)) {
+32 -27
View File
@@ -671,6 +671,35 @@ fn change_width(e: &mut Engine, p: &Value) -> Result<Value> {
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<TrackId> {
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<Value> {
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<Value> {
.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<Value> {
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<Value> {
}
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()}))
}
+66 -55
View File
@@ -439,6 +439,70 @@ fn write_track(out: &mut Vec<u8>, mut events: Vec<WEvent>) -> Result<(), MidiErr
Ok(())
}
/// The conductor track: tempos, meters, key signatures and markers.
fn conductor_events(smf: &Smf) -> Result<Vec<WEvent>, 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<usize> = (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<usize> = 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<Vec<u8>, 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<Vec<u8>, MidiError> {
let notes = &track.sequence.notes;
let mut ends: Vec<u64> = 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<usize> = (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<usize> = 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;
+46 -3
View File
@@ -148,7 +148,9 @@ pub fn create_instance(id: &str, state: Option<&[u8]>, sample_rate: f32, max_blo
Some(NewInstance { plugin, refused_state })
}
/// Peak meter values for one strip (linear, per channel, max over the last block).
/// Peak meter values for one strip (linear, per channel). [`MixEngine`] measures one block; a
/// realtime host folds blocks together with [`StripMeter::accumulate`] so a reader polling less
/// often than blocks arrive sees the highest peak since it last looked, not just the latest block.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct StripMeter {
/// First two channels (mono strips repeat channel 0), for stereo meters.
@@ -186,6 +188,34 @@ impl StripMeter {
self.plugin_failures = o.plugin_failures;
}
/// Fold another (later) measurement in: per-channel peaks and the gain reduction keep their
/// maximum, plugin failures take the later value (the count only grows). Allocation-free once
/// the channel count is stable, so it can run on the audio thread every block.
pub fn accumulate(&mut self, o: &StripMeter) {
if self.peaks.len() != o.peaks.len() {
// The strip's format changed (a document edit): earlier channels no longer line up.
self.peaks.clone_from(&o.peaks);
self.peak = o.peak;
} else {
for (p, q) in self.peaks.iter_mut().zip(&o.peaks) {
*p = max_level(*p, *q);
}
for (p, q) in self.peak.iter_mut().zip(o.peak) {
*p = max_level(*p, q);
}
}
self.gain_reduction_db = max_level(self.gain_reduction_db, o.gain_reduction_db);
self.plugin_failures = o.plugin_failures;
}
/// Zero the levels (peaks and gain reduction) after a reader took them, keeping the channel
/// count and the plugin failure record. Never allocates.
pub fn reset_levels(&mut self) {
self.peak = [0.0; 2];
self.peaks.iter_mut().for_each(|p| *p = 0.0);
self.gain_reduction_db = 0.0;
}
fn measure(&mut self, bufs: &[Vec<f32>], frames: usize, gr: f32) {
if self.peaks.len() != bufs.len() {
self.peaks.resize(bufs.len(), 0.0);
@@ -199,6 +229,16 @@ impl StripMeter {
}
}
/// The larger of two meter levels; a NaN (from a misbehaving plugin) never wins or sticks.
fn max_level(a: f32, b: f32) -> f32 {
match (a.is_finite(), b.is_finite()) {
(true, true) => a.max(b),
(true, false) => a,
(false, true) => b,
(false, false) => 0.0,
}
}
/// A bus: its format and one buffer per channel.
struct BusBuf {
fmt: ChannelFormat,
@@ -544,8 +584,10 @@ impl MixEngine {
fx.reset();
}
}
self.meters.clear();
self.main_meter = StripMeter::default();
// Zeroed in place: rebuilding the entries would allocate on the audio thread at every
// play, stop and locate.
self.meters.values_mut().for_each(StripMeter::reset_levels);
self.main_meter.reset_levels();
}
/// Bring plugin instances in line with the session (creates/destroys as needed). Allocates;
@@ -560,6 +602,7 @@ impl MixEngine {
let mut cx = SlotCtx { sr: self.sample_rate, mb, external: self.external, adopt: &mut self.adopt, retired: &mut self.retired };
let gone: Vec<TrackId> = self.strips.keys().filter(|id| s.track(**id).is_none()).copied().collect();
for id in gone {
self.meters.remove(&id);
if let Some(mut st) = self.strips.remove(&id) {
for p in st.plugins.drain(..) {
cx.retire(p);
+287 -47
View File
@@ -45,11 +45,53 @@ const OLD_SESSIONS: usize = 16;
/// renders on worker threads; the cpal callbacks here set it.
pub use soundcraft_mix::{mark_audio_thread, on_audio_thread};
/// Meter values for the UI.
/// Meter values for the UI: the highest peak (and gain reduction) of every block rendered since
/// the UI last took them with [`Player::take_meters`]. The audio thread renders blocks faster than
/// the UI draws (about 94 blocks a second at 512 frames and 48 kHz against 20-60 polls), so
/// keeping only the latest block would miss most transients.
#[derive(Debug, Clone, Default)]
pub struct MeterSnapshot {
pub tracks: HashMap<TrackId, StripMeter>,
pub main: StripMeter,
/// Something was written since the UI last took the values.
fresh: bool,
}
impl MeterSnapshot {
/// Fold one rendered block's meters in (max). Allocation-free once the tracks are known.
fn accumulate(&mut self, main: &StripMeter, tracks: &HashMap<TrackId, StripMeter>) {
self.main.accumulate(main);
for (k, v) in tracks {
match self.tracks.get_mut(k) {
Some(d) => d.accumulate(v),
None => {
self.tracks.insert(*k, v.clone());
}
}
}
self.fresh = true;
}
/// Start a new accumulation period, keeping every entry and its storage.
fn reset_levels(&mut self) {
self.main.reset_levels();
self.tracks.values_mut().for_each(StripMeter::reset_levels);
self.fresh = false;
}
/// Nothing is playing or monitored: every meter reads silence. Levels are zeroed in place,
/// so starting again does not allocate the tracks' entries anew.
fn silence(&mut self) {
self.reset_levels();
self.fresh = true;
}
/// Drop the entries of deleted tracks.
fn forget(&mut self, gone: &[TrackId]) {
for id in gone {
self.tracks.remove(id);
}
}
}
/// Plugin states read from the live instances: `(track, slot or INSTRUMENT_SLOT, state)`.
@@ -72,6 +114,8 @@ enum Cmd {
},
Stop,
Locate(Samples),
/// Tracks the latest session no longer has (worked out on the UI thread): their meters go.
Forget(Vec<TrackId>),
}
struct Shared {
@@ -172,6 +216,11 @@ struct AudioState {
/// Retired plugin instances go back to the UI thread to be dropped there.
garbage: Sender<Vec<Box<dyn Plugin>>>,
old_sessions: Option<SyncSender<Arc<Session>>>,
/// Meters of blocks rendered while the UI held the shared snapshot (`try_lock` failed), so
/// their peaks reach it with the next block instead of being lost.
unsent: MeterSnapshot,
/// Deleted tracks still to drop from the shared snapshot (it was locked when they went).
forget_shared: Vec<TrackId>,
}
fn new_engine(sr: f32) -> MixEngine {
@@ -203,6 +252,8 @@ impl AudioState {
device_rate,
input: None,
recording: false,
unsent: MeterSnapshot::default(),
forget_shared: Vec::new(),
}
}
@@ -211,6 +262,15 @@ impl AudioState {
while let Ok(c) = self.rx.try_recv() {
match c {
Cmd::Input(r) => self.input = Some(r),
Cmd::Forget(gone) => {
self.unsent.forget(&gone);
if self.forget_shared.is_empty() {
self.forget_shared = gone;
} else {
self.forget_shared.extend_from_slice(&gone);
}
self.forget_meters();
}
Cmd::Recording(r) => self.recording = r,
Cmd::Session(s, instances) => {
changed = true;
@@ -311,23 +371,34 @@ impl AudioState {
self.mix.render(&self.session, self.pos, n, &mut self.pending);
self.send_garbage();
self.pending_len = n;
if let Ok(mut m) = self.shared.meters.try_lock() {
m.main.copy_from(&self.mix.main_meter);
for (k, v) in &self.mix.meters {
match m.tracks.get_mut(k) {
Some(d) => d.copy_from(v),
None => {
m.tracks.insert(*k, v.clone());
}
}
}
}
self.publish_meters();
if self.playing {
self.pos += n as i64;
self.shared.position.store(self.pos, Ordering::Relaxed);
}
}
/// Drop deleted tracks' meters from the shared snapshot, unless the UI holds it right now.
fn forget_meters(&mut self) {
if !self.forget_shared.is_empty()
&& let Ok(mut m) = self.shared.meters.try_lock()
{
m.forget(&self.forget_shared);
self.forget_shared.clear();
}
}
/// Hand this block's meters to the UI without blocking: they are folded (max) into what the
/// UI has not taken yet. If the UI holds the snapshot right now, they wait in `unsent`.
fn publish_meters(&mut self) {
self.unsent.accumulate(&self.mix.main_meter, &self.mix.meters);
self.forget_meters();
if let Ok(mut m) = self.shared.meters.try_lock() {
m.accumulate(&self.unsent.main, &self.unsent.tracks);
self.unsent.reset_levels();
}
}
/// Fill an interleaved device buffer.
fn fill(&mut self, out: &mut [f32], channels: usize) {
self.drain();
@@ -335,9 +406,10 @@ impl AudioState {
let monitoring = self.input.is_some() && self.session.tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm);
if !self.playing && !monitoring {
out.iter_mut().for_each(|x| *x = 0.0);
self.unsent.silence();
self.forget_meters();
if let Ok(mut m) = self.shared.meters.try_lock() {
m.main = StripMeter::default();
m.tracks.clear();
m.silence();
}
return;
}
@@ -367,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 };
}
}
}
@@ -419,11 +497,30 @@ struct Side {
refused: HashSet<(TrackId, usize)>,
garbage: Receiver<Vec<Box<dyn Plugin>>>,
old_sessions: Option<Receiver<Arc<Session>>>,
/// The tracks of the session last sent, to tell the audio thread which ones went.
tracks: HashSet<TrackId>,
}
impl Side {
fn new(rate: f32, garbage: Receiver<Vec<Box<dyn Plugin>>>) -> Side {
Side { shadow: HashMap::new(), rate, editors: HashMap::new(), states: BTreeMap::new(), refused: HashSet::new(), garbage, old_sessions: None }
Side {
shadow: HashMap::new(),
rate,
editors: HashMap::new(),
states: BTreeMap::new(),
refused: HashSet::new(),
garbage,
old_sessions: None,
tracks: HashSet::new(),
}
}
/// Note `s`'s tracks; the ones the previous session had and `s` doesn't.
fn gone_tracks(&mut self, s: &Session) -> Vec<TrackId> {
let now: HashSet<TrackId> = s.tracks.iter().map(|t| t.id).collect();
let gone = self.tracks.difference(&now).copied().collect();
self.tracks = now;
gone
}
/// Drops retired instances (closing their editors first, inside the plugin's drop) and
@@ -525,7 +622,11 @@ impl Player {
let want = session.sample_rate.hz();
let want_ch = u16::try_from(main_channels(&session)).unwrap_or(2);
let side_rate = session.sample_rate.as_f64() as f32;
let new_side = |garbage: Receiver<Vec<Box<dyn Plugin>>>| Mutex::new(Side::new(side_rate, garbage));
let new_side = |garbage: Receiver<Vec<Box<dyn Plugin>>>| {
let mut side = Side::new(side_rate, garbage);
side.gone_tracks(&session);
Mutex::new(side)
};
match open_device(want, want_ch, cfg) {
Ok((device, config, format, name)) => {
// The chosen buffer size first; if the device refuses it, its default.
@@ -682,8 +783,14 @@ impl Player {
/// Sends a new session snapshot to the audio thread, with freshly created (state-restored,
/// prepared) instances of any third-party plugin it newly needs. Call on the UI thread.
pub fn update_session(&self, s: Arc<Session>) {
let instances = self.side().prepare(&s);
let (instances, gone) = {
let mut side = self.side();
(side.prepare(&s), side.gone_tracks(&s))
};
let _ = self.tx.send(Cmd::Session(s, instances));
if !gone.is_empty() {
let _ = self.tx.send(Cmd::Forget(gone));
}
}
/// Reads the state of every live third-party plugin, for saving the session.
@@ -805,18 +912,35 @@ impl Player {
self.shared.playing.load(Ordering::Relaxed)
}
/// A copy of the meters accumulated since the last [`Player::take_meters`], leaving them in
/// place (for inspection; the UI takes them instead).
pub fn meters(&self) -> MeterSnapshot {
self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
/// Copy meters into reusable UI storage. Once track/channel counts are stable this performs
/// no allocation, unlike cloning a new snapshot every video frame.
pub fn copy_meters(&self, out: &mut MeterSnapshot) {
let source = self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner);
copy_meter_snapshot(&source, out);
/// Take the meters accumulated since the last call into reusable UI storage and start a new
/// period: `out` gets the highest peak of every block rendered in between. If no block was
/// rendered since (a large device buffer renders several blocks per callback), `out` keeps
/// the previous values rather than dropping to silence. Call it from one place only (two
/// readers would split the peaks between them). Once track/channel counts are stable this
/// performs no allocation. The audio thread only ever `try_lock`s, so holding the lock here
/// never blocks it; blocks rendered meanwhile reach the next call.
pub fn take_meters(&self, out: &mut MeterSnapshot) {
let mut source = self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner);
take_meter_snapshot(&mut source, out);
}
}
fn take_meter_snapshot(source: &mut MeterSnapshot, out: &mut MeterSnapshot) {
if !source.fresh {
// No block since the last take: keep the readings, but not those of deleted tracks.
out.tracks.retain(|id, _| source.tracks.contains_key(id));
return;
}
copy_meter_snapshot(source, out);
source.reset_levels();
}
fn copy_meter_snapshot(source: &MeterSnapshot, out: &mut MeterSnapshot) {
out.tracks.retain(|id, _| source.tracks.contains_key(id));
for (id, meter) in &source.tracks {
@@ -1201,6 +1325,122 @@ mod tests {
assert_eq!(out.main, source.main);
}
/// A mono track playing a quiet signal with one short spike in the block starting at
/// `spike_at`, and an audio thread ready to play it.
fn spike_session(spike_at: usize) -> (Arc<Shared>, AudioState, std::sync::mpsc::Sender<Cmd>, TrackId) {
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
let mut s = soundcraft_model::Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let mut x = vec![0.01f32; 48_000];
x[spike_at..spike_at + 32].fill(0.9);
s.pool.insert(SourceId(5), Arc::new(SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![x] })));
let id = s.new_clip_id();
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "x", SourceId(5), 0, 0, 48_000));
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(false),
speed: AtomicU32::new(1000),
meters: Mutex::new(MeterSnapshot::default()),
});
let (tx, rx) = channel();
let st = AudioState::new(rx, Arc::clone(&shared), Arc::new(s), 48_000.0, channel().0);
tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();
(shared, st, tx, t)
}
#[test]
fn a_spike_in_one_block_among_quiet_ones_reaches_the_ui() {
// Eight 512-frame blocks per callback, as between two UI polls; the spike is in the 3rd.
let (shared, mut st, _tx, t) = spike_session(2 * 512 + 100);
let mut out = vec![0.0f32; 2 * 8 * 512];
st.fill(&mut out, 2);
// The last block alone (what the UI used to see) is quiet.
assert!(st.mix.meters[&t].peak[0] < 0.05);
let mut ui = MeterSnapshot::default();
take_meter_snapshot(&mut shared.meters.lock().unwrap(), &mut ui);
assert!(ui.tracks[&t].peak[0] > 0.85, "{:?}", ui.tracks[&t]);
assert!(ui.tracks[&t].peaks[0] > 0.85);
assert!(ui.main.peak[0] > 0.5 && ui.main.peaks.iter().all(|p| *p > 0.5), "{:?}", ui.main);
// Taking starts a new period: the next quiet blocks read quiet.
st.fill(&mut out, 2);
take_meter_snapshot(&mut shared.meters.lock().unwrap(), &mut ui);
assert!(ui.tracks[&t].peak[0] < 0.05 && ui.main.peak[0] < 0.05);
// No block since the last take: the reading stays rather than dropping to silence.
take_meter_snapshot(&mut shared.meters.lock().unwrap(), &mut ui);
assert!(ui.tracks[&t].peak[0] > 0.005);
}
#[test]
fn a_spike_rendered_while_the_ui_holds_the_meters_arrives_later() {
let (shared, mut st, _tx, t) = spike_session(100);
let mut out = vec![0.0f32; 2 * 512];
{
let _held = shared.meters.lock().unwrap();
st.fill(&mut out, 2);
}
st.fill(&mut out, 2);
let mut ui = MeterSnapshot::default();
take_meter_snapshot(&mut shared.meters.lock().unwrap(), &mut ui);
assert!(ui.tracks[&t].peak[0] > 0.85, "{:?}", ui.tracks[&t]);
}
#[test]
fn stopping_zeroes_meters_in_place_and_removed_tracks_are_pruned() {
let (shared, mut st, tx, t) = spike_session(100);
let mut out = vec![0.0f32; 2 * 512];
st.fill(&mut out, 2);
tx.send(Cmd::Stop).unwrap();
st.fill(&mut out, 2);
{
let m = shared.meters.lock().unwrap();
let e = m.tracks.get(&t).expect("kept while stopped, so playing again does not allocate it");
assert!(e.peak == [0.0; 2] && e.peaks.iter().all(|p| *p == 0.0) && e.peaks.len() == 1, "{e:?}");
assert!(m.fresh, "the UI sees the silence");
}
// The track is deleted: the UI side works out which tracks went and says so.
let mut side = Side::new(48_000.0, channel().1);
side.gone_tracks(&st.session);
let empty = soundcraft_model::Session::default();
let gone = side.gone_tracks(&empty);
assert_eq!(gone, vec![t]);
tx.send(Cmd::Session(Arc::new(empty), Vec::new())).unwrap();
tx.send(Cmd::Forget(gone)).unwrap();
tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();
st.fill(&mut out, 2);
assert!(!shared.meters.lock().unwrap().tracks.contains_key(&t));
assert!(!st.unsent.tracks.contains_key(&t) && !st.mix.meters.contains_key(&t));
}
#[test]
fn a_take_without_new_blocks_still_drops_deleted_tracks() {
let gone = TrackId(7);
let mut source = MeterSnapshot::default();
source.accumulate(&StripMeter::default(), &HashMap::from([(gone, StripMeter::default())]));
let mut ui = MeterSnapshot::default();
take_meter_snapshot(&mut source, &mut ui);
source.forget(&[gone]);
take_meter_snapshot(&mut source, &mut ui);
assert!(ui.tracks.is_empty());
}
#[test]
fn a_channel_count_change_takes_the_new_stereo_peaks() {
let mut acc = StripMeter { peak: [0.9, 0.9], peaks: vec![0.9], ..StripMeter::default() };
acc.accumulate(&StripMeter { peak: [0.9, 0.0], peaks: vec![0.9, 0.0], ..StripMeter::default() });
assert_eq!(acc.peak, [0.9, 0.0]);
}
#[test]
fn gain_reduction_keeps_its_maximum_until_taken() {
let mut acc = StripMeter::default();
for gr in [1.0, 9.0, 2.0, f32::NAN] {
acc.accumulate(&StripMeter { peak: [0.1, 0.1], peaks: vec![0.1], gain_reduction_db: gr, ..StripMeter::default() });
}
assert_eq!(acc.gain_reduction_db, 9.0);
acc.reset_levels();
assert_eq!((acc.gain_reduction_db, acc.peak, acc.peaks.len()), (0.0, [0.0; 2], 1));
}
#[test]
fn a_block_that_panics_goes_silent_and_is_reported_once() {
let shared = Shared {
+3
View File
@@ -33,6 +33,9 @@ image.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
zbus = "5"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
+5 -9
View File
@@ -907,8 +907,9 @@ fn run_then(app: &mut SoundApp, cmd: &str, params: Value) -> Result<Value, Strin
/// with the values of the open Strip Silence window (or of the one Rename Selected Clips goes back
/// to). The Edit window draws them as the Strip Silence rectangles. Empty when neither window is
/// open. A scan runs again only when the session or a value changes. Off the web it runs on a worker
/// thread, one at a time, and the rectangles last found stay until it ends. A scan for older values,
/// or one that runs on after the window closes, stops before its next window of audio.
/// thread, one at a time, and the rectangles last found stay until it ends. On the web it runs on the
/// frame thread once no pointer button is down, so a slider drag scans once, on release. A scan for
/// older values, or one that runs on after the window closes, stops before its next window of audio.
pub fn strip_silence_rectangles(app: &mut SoundApp, ctx: &egui::Context) -> StripRects {
let values = match &app.dialogs.open {
Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) => [*threshold, *min_ms, *pre_ms, *post_ms],
@@ -936,8 +937,9 @@ pub fn strip_silence_rectangles(app: &mut SoundApp, ctx: &egui::Context) -> Stri
let shown = r.shown.as_ref().map(|(k, rects)| (*k == key, Arc::clone(rects)));
#[cfg(not(target_arch = "wasm32"))]
let busy = r.running.is_some();
// The browser's scan blocks the frame, so it waits for the release that ends a slider drag.
#[cfg(target_arch = "wasm32")]
let busy = false;
let busy = ctx.input(|i| i.pointer.any_down());
if let Some((true, rects)) = shown {
return rects;
}
@@ -968,8 +970,6 @@ pub fn strip_silence_rectangles(app: &mut SoundApp, ctx: &egui::Context) -> Stri
return shown.map(|(_, rects)| rects).unwrap_or_default();
}
}
#[cfg(target_arch = "wasm32")]
let _ = ctx;
// The browser has no threads, and without a thread to spare the scan runs on this one.
let rects: StripRects = Arc::new(scan.run().unwrap_or_default());
app.dialogs.strip_rectangles.shown = Some((key, Arc::clone(&rects)));
@@ -1106,10 +1106,6 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) {
let v = params.entry("semitones".into()).or_insert(0.0);
ui.add(egui::Slider::new(v, -24.0..=24.0).text("Semitones"));
}
"varispeed" => {
let v = params.entry("speed".into()).or_insert(1.0);
ui.add(egui::Slider::new(v, 0.25..=4.0).text("Speed"));
}
"speed_ramp" => {
for (id, label, default, choices) in SPEED_RAMP_CHOICES {
let v = params.entry(id.to_string()).or_insert(default);
+171 -24
View File
@@ -1712,7 +1712,14 @@ fn draw_live_take(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &
let (body, _, wave) = clip_colors([t.rec.r(), t.rec.g(), t.rec.b()], false);
painter.rect(r, CornerRadius::same(2), body, Stroke::new(1.0, Color32::from_rgb(10, 10, 10)), StrokeKind::Inside);
if track.view != "blocks" {
crate::waveform::draw_live(&painter.with_clip_rect(r.intersect(lane)), s, tl, origin as f64, block_len, &take, r, wave);
crate::waveform::draw_live(
crate::waveform::Canvas { painter: &painter.with_clip_rect(r.intersect(lane)), s, tl },
origin as f64,
block_len,
&take,
r,
wave,
);
}
}
@@ -1778,7 +1785,14 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
preview = Clip { fade_in, fade_out, ..clip.clone() };
&preview
};
crate::waveform::draw(&painter.with_clip_rect(vis), s, tl, shown, audio, track.channels(), body_r, wave);
crate::waveform::draw(
crate::waveform::Canvas { painter: &painter.with_clip_rect(vis), s, tl },
shown,
audio,
track.channels(),
body_r,
wave,
);
let n = track.channels().min(audio.peaks.len()).max(1);
for k in 1..n {
let y = body_r.min.y + body_r.height() / n as f32 * k as f32;
@@ -1832,7 +1846,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
} else {
clip_colors(prev.color.unwrap_or(track.color), false).2.gamma_multiply(0.6)
};
crate::waveform::draw(&painter.with_clip_rect(xf), s, tl, prev, audio, track.channels(), body_r, tail);
crate::waveform::draw(crate::waveform::Canvas { painter: &painter.with_clip_rect(xf), s, tl }, prev, audio, track.channels(), body_r, tail);
}
// Clip gain line.
let show_gain = s.edit.flag("view.clip.gain_line") && (!clip.gain_env.is_empty() || clip.gain_db.abs() > 0.01 || track.view == "clip_gain");
@@ -2796,6 +2810,18 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
}
}
/// One frame's inputs to [`follow_update`].
struct FollowFrame {
playing: bool,
mode_follow: bool,
playhead_visible: bool,
cur: Samples,
last_scroll: Samples,
last_follow_to: Option<Samples>,
hold: bool,
pos: Samples,
}
/// Next follow state after observing one frame: `(hold, follow_to)`.
///
/// The view is ours to move only while nothing else moved it: when the
@@ -2805,16 +2831,8 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
/// jump is issued. Stopping, or scrolling back to the playhead, clears the
/// hold. Only `page`/`continuous`/`center` ever issue follow jumps while
/// playing; `none`/`after_playback` leave the view alone.
fn follow_update(
playing: bool,
mode_follow: bool,
playhead_visible: bool,
cur: Samples,
last_scroll: Samples,
last_follow_to: Option<Samples>,
hold: bool,
pos: Samples,
) -> (bool, Option<Samples>) {
fn follow_update(frame: FollowFrame) -> (bool, Option<Samples>) {
let FollowFrame { playing, mode_follow, playhead_visible, cur, last_scroll, last_follow_to, hold, pos } = frame;
if !playing || playhead_visible {
return (false, None);
}
@@ -2892,7 +2910,16 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
let mode_follow = matches!(s.edit.scrolling.as_str(), "page" | "continuous" | "center");
let visible = x >= tl.min.x && x <= tl.max.x;
let layout = &mut app.edit_layout;
let (hold, follow_to) = follow_update(true, mode_follow, visible, cur, layout.last_scroll, layout.last_follow_to, layout.follow_hold, pos);
let (hold, follow_to) = follow_update(FollowFrame {
playing: true,
mode_follow,
playhead_visible: visible,
cur,
last_scroll: layout.last_scroll,
last_follow_to: layout.last_follow_to,
hold: layout.follow_hold,
pos,
});
layout.follow_hold = hold;
layout.last_scroll = cur;
layout.last_follow_to = follow_to;
@@ -3096,40 +3123,160 @@ mod tests {
#[test]
fn follow_jumps_to_offscreen_playhead_while_playing() {
// Steady view, follow mode, playhead left the screen: jump to it.
assert_eq!(follow_update(true, true, false, 1_000, 1_000, None, false, 96_000), (false, Some(96_000)));
assert_eq!(
follow_update(FollowFrame {
playing: true,
mode_follow: true,
playhead_visible: false,
cur: 1_000,
last_scroll: 1_000,
last_follow_to: None,
hold: false,
pos: 96_000
}),
(false, Some(96_000))
);
}
#[test]
fn follow_leaves_onscreen_playhead_alone() {
assert_eq!(follow_update(true, true, true, 1_000, 1_000, None, false, 96_000), (false, None));
assert_eq!(
follow_update(FollowFrame {
playing: true,
mode_follow: true,
playhead_visible: true,
cur: 1_000,
last_scroll: 1_000,
last_follow_to: None,
hold: false,
pos: 96_000
}),
(false, None)
);
}
#[test]
fn follow_never_scrolls_when_stopped_nor_in_free_modes() {
assert_eq!(follow_update(false, true, false, 1_000, 1_000, None, false, 96_000), (false, None));
assert_eq!(
follow_update(FollowFrame {
playing: false,
mode_follow: true,
playhead_visible: false,
cur: 1_000,
last_scroll: 1_000,
last_follow_to: None,
hold: false,
pos: 96_000
}),
(false, None)
);
// `none`/`after_playback` never follow, even with a steady view.
assert_eq!(follow_update(true, false, false, 1_000, 1_000, None, false, 96_000), (false, None));
assert_eq!(follow_update(true, false, false, 9_000, 1_000, None, false, 96_000), (false, None));
assert_eq!(
follow_update(FollowFrame {
playing: true,
mode_follow: false,
playhead_visible: false,
cur: 1_000,
last_scroll: 1_000,
last_follow_to: None,
hold: false,
pos: 96_000
}),
(false, None)
);
assert_eq!(
follow_update(FollowFrame {
playing: true,
mode_follow: false,
playhead_visible: false,
cur: 9_000,
last_scroll: 1_000,
last_follow_to: None,
hold: false,
pos: 96_000
}),
(false, None)
);
}
#[test]
fn outside_view_move_pauses_the_follow() {
// The view moved and it was not our own jump: pause, no jump.
assert_eq!(follow_update(true, true, false, 9_000, 1_000, None, false, 96_000), (true, None));
assert_eq!(
follow_update(FollowFrame {
playing: true,
mode_follow: true,
playhead_visible: false,
cur: 9_000,
last_scroll: 1_000,
last_follow_to: None,
hold: false,
pos: 96_000
}),
(true, None)
);
// A held view stays held while it sits still elsewhere.
assert_eq!(follow_update(true, true, false, 9_000, 9_000, None, true, 96_000), (true, None));
assert_eq!(
follow_update(FollowFrame {
playing: true,
mode_follow: true,
playhead_visible: false,
cur: 9_000,
last_scroll: 9_000,
last_follow_to: None,
hold: true,
pos: 96_000
}),
(true, None)
);
}
#[test]
fn own_follow_jump_is_not_mistaken_for_a_takeover() {
// The scroll equals the target we issued last frame: keep following.
assert_eq!(follow_update(true, true, false, 96_000, 1_000, Some(96_000), false, 97_000), (false, Some(97_000)));
assert_eq!(
follow_update(FollowFrame {
playing: true,
mode_follow: true,
playhead_visible: false,
cur: 96_000,
last_scroll: 1_000,
last_follow_to: Some(96_000),
hold: false,
pos: 97_000
}),
(false, Some(97_000))
);
}
#[test]
fn hold_clears_when_playhead_returns_or_transport_stops() {
assert_eq!(follow_update(true, true, true, 9_000, 9_000, None, true, 96_000), (false, None));
assert_eq!(follow_update(false, true, false, 9_000, 9_000, None, true, 96_000), (false, None));
assert_eq!(
follow_update(FollowFrame {
playing: true,
mode_follow: true,
playhead_visible: true,
cur: 9_000,
last_scroll: 9_000,
last_follow_to: None,
hold: true,
pos: 96_000
}),
(false, None)
);
assert_eq!(
follow_update(FollowFrame {
playing: false,
mode_follow: true,
playhead_visible: false,
cur: 9_000,
last_scroll: 9_000,
last_follow_to: None,
hold: true,
pos: 96_000
}),
(false, None)
);
}
fn harness(n: usize) -> Harness<'static, SoundApp> {
+1 -1
View File
@@ -90,7 +90,7 @@ fn renderer(app: &mut SoundApp, ctx: &egui::Context) {
}
let t = Tokens::current();
let fmt = app.engine.session().main_format();
let peaks: Vec<f32> = app.player.as_ref().map(|p| p.meters().main.peaks).unwrap_or_default();
let peaks: Vec<f32> = if app.player.is_some() { app.meter_snapshot.main.peaks.clone() } else { Vec::new() };
let objects: Vec<(String, f32, f32, f32)> = app
.engine
.session()
+11 -3
View File
@@ -29,6 +29,7 @@ pub mod shortcuts;
pub mod spot;
pub mod theme;
pub mod toolbar;
pub mod updates_ui;
pub mod video_track;
pub mod video_window;
pub mod waveform;
@@ -285,10 +286,12 @@ pub struct SoundApp {
pub preset_dir: Option<std::path::PathBuf>,
last_autosave: Option<std::time::Instant>,
pub dialogs: dialogs::Dialogs,
pub(crate) updates_rt: updates_ui::Runtime,
pub meters: HashMap<TrackId, MeterDisplay>,
pub main_meter: MeterDisplay,
/// Reused multichannel snapshot; populated at an adaptive rate by `logic`.
meter_snapshot: soundcraft_playback::MeterSnapshot,
/// Reused meter snapshot, taken from the player at an adaptive rate by `logic`: the highest
/// peaks since the previous take. The only reader of the player's meters; windows read this.
pub(crate) meter_snapshot: soundcraft_playback::MeterSnapshot,
pub gesture: Option<Gesture>,
pub edit_layout: edit_window::EditLayout,
pub midi: midi_editor::MidiEditorState,
@@ -491,6 +494,7 @@ impl SoundApp {
preset_dir: None,
last_autosave: None,
dialogs: dialogs::Dialogs::default(),
updates_rt: updates_ui::Runtime::default(),
meters: HashMap::new(),
main_meter: MeterDisplay::default(),
meter_snapshot: soundcraft_playback::MeterSnapshot::default(),
@@ -1311,7 +1315,7 @@ impl SoundApp {
let now = self.uptime;
let meter_interval = if self.ui.window == MainWindow::Mix { 1.0 / 60.0 } else { 1.0 / 20.0 };
if now - self.last_meter_poll >= meter_interval {
p.copy_meters(&mut self.meter_snapshot);
p.take_meters(&mut self.meter_snapshot);
self.last_meter_poll = now;
}
let snap = &self.meter_snapshot;
@@ -1400,6 +1404,8 @@ impl SoundApp {
if ctx.current_pass_index() == 0 {
bounce::step(self, ctx);
import::step(self, ctx);
#[cfg(not(target_arch = "wasm32"))]
updates_ui::tick(self, ctx);
}
if !self.pointer_down {
if matches!(self.gesture, Some(Gesture::Fader { .. })) {
@@ -1764,6 +1770,8 @@ impl SoundApp {
score_editor::show(self, &ctx);
palette::show(self, &ctx);
dialogs::show(self, &ctx);
#[cfg(not(target_arch = "wasm32"))]
updates_ui::show(self, &ctx);
// The sampler's drop rects are current now, so route any captured drop in this pass.
self.route_dropped_files();
// The pass that actually received the injected events has already dispatched their widget
+8
View File
@@ -7,6 +7,7 @@ use soundcraft_engine::catalog;
/// UI-layer commands: (id, label, catalog path or "", shortcut).
pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
("app.checkForUpdates", "Check for Updates…", "", None),
("ui.dock", "Arrange Panel", "", None),
("ui.dock.reset", "Reset Panel Layout", "Window > Reset Panel Layout", None),
("window.mix", "Mix", "Window > Mix", Some("Cmd+=")),
@@ -232,6 +233,9 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
if ui.button("About SoundCraft").clicked() {
app.ui.show_about = true;
}
if ui.button("Check for Updates…").clicked() {
let _ = app.run("app.checkForUpdates", json!({}));
}
if ui.button("Session Info").clicked() {
app.ui.show_session_info = true;
}
@@ -519,6 +523,10 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
json!({"value": *v})
};
let r = match id {
"app.checkForUpdates" | "help.checkForUpdates" => {
crate::updates_ui::check(app);
json!({})
}
"window.mix" => {
app.ui.window = MainWindow::Mix;
app.focus_mix = app.ui.mix_separate;
+3 -2
View File
@@ -47,8 +47,9 @@ pub fn separate_closed(app: &mut SoundApp) {
/// New Tracks when the space past the last strip is double-clicked.
pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) {
let ids: Vec<TrackId> = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect();
// Per-channel peaks for multichannel meters (read once per frame).
let snap = app.player.as_ref().filter(|_| app.meters_live()).map(|p| p.meters());
// Per-channel peaks for multichannel meters, from the snapshot `logic` takes from the player
// (taking them here too would split each period's peaks between two readers).
let snap = (app.player.is_some() && app.meters_live()).then(|| app.meter_snapshot.clone());
// Taken for this frame only: a strip that isn't drawn (hidden track) must not scroll the Mix
// whenever it comes back. A request made during this frame (the strip's menu) waits for the next.
let scroll_to = app.mix_scroll_into_view.take();
+122
View File
@@ -0,0 +1,122 @@
//! Software updates, background check at startup, and startup prompt modal.
use crate::SoundApp;
#[cfg(not(target_arch = "wasm32"))]
use craft_update::updater::{Action, Status, Updater};
pub(crate) struct Runtime {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater,
/// The desktop app turns the check at start on; tests and the web build leave it off.
#[cfg(not(target_arch = "wasm32"))]
startup_check: bool,
#[cfg(not(target_arch = "wasm32"))]
check_requested: bool,
pub(crate) manual_window_open: bool,
pub(crate) status_message: Option<String>,
}
impl Default for Runtime {
fn default() -> Self {
Self {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater::new(&craft_update::suite::SOUNDCRAFT, env!("CARGO_PKG_VERSION")),
#[cfg(not(target_arch = "wasm32"))]
startup_check: false,
#[cfg(not(target_arch = "wasm32"))]
check_requested: false,
manual_window_open: false,
status_message: None,
}
}
}
impl SoundApp {
/// Whether the app asks for a newer release when it starts (off until the desktop app turns it on).
#[cfg(not(target_arch = "wasm32"))]
pub fn set_startup_update_check(&mut self, on: bool) {
self.updates_rt.startup_check = on;
}
}
pub(crate) fn check(app: &mut SoundApp) {
#[cfg(not(target_arch = "wasm32"))]
{
app.updates_rt.check_requested = true;
}
#[cfg(target_arch = "wasm32")]
{
app.updates_rt.manual_window_open = true;
app.updates_rt.status_message = Some("Update check is not supported in the web browser.".into());
}
}
pub(crate) fn tick(app: &mut SoundApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
let rt = &mut app.updates_rt;
if std::mem::take(&mut rt.check_requested) {
rt.updater.check_now(ctx);
} else if rt.startup_check {
rt.updater.check_at_start(ctx);
}
rt.updater.tick(ctx);
}
#[cfg(target_arch = "wasm32")]
let _ = (app, ctx);
}
pub(crate) fn show(app: &mut SoundApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
// The restart helper is scheduled before the app closes, so no update starts over unsaved work or a take being recorded.
let clean = !app.has_unsaved_changes() && !app.engine.transport.recording;
match app.updates_rt.updater.show_if(ctx, clean) {
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
Action::None => {}
}
// The window answers Help ▸ Check for Updates, so it follows the updater's status.
let rt = &mut app.updates_rt;
(rt.manual_window_open, rt.status_message) = match rt.updater.status() {
Status::Idle => (false, None),
Status::Checking => (true, None),
Status::UpToDate => (true, Some(format!("SoundCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION")))),
Status::Failed(e) => (true, Some(format!("Could not check for updates:\n{e}"))),
};
}
if app.updates_rt.manual_window_open {
let mut open = true;
let mut close = false;
egui::Window::new("Check for Updates")
.open(&mut open)
.resizable(false)
.collapsible(false)
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
.show(ctx, |ui| {
if let Some(msg) = &app.updates_rt.status_message {
ui.label(msg);
} else {
ui.label("Checking for updates…");
}
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Close").clicked() {
close = true;
}
});
});
if !open || close {
app.updates_rt.manual_window_open = false;
app.updates_rt.status_message = None;
}
}
// Closing the window dismisses the outcome it showed.
#[cfg(not(target_arch = "wasm32"))]
if !app.updates_rt.manual_window_open {
app.updates_rt.updater.dismiss_status();
}
}
+14 -4
View File
@@ -4,10 +4,18 @@ use egui::{Color32, Mesh, Painter, Rect, Shape, Stroke, pos2, vec2};
use soundcraft_model::{Clip, Session, SourceAudio};
use soundcraft_time::Samples;
/// Where to draw: the painter, the session and the timeline rect that maps samples to x.
#[derive(Clone, Copy)]
pub struct Canvas<'a> {
pub painter: &'a Painter,
pub s: &'a Session,
pub tl: Rect,
}
/// Draw a clip's audio across `body`, one band per channel, `channels` of them at most.
pub fn draw(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &SourceAudio, channels: usize, body: Rect, wave: Color32) {
pub fn draw(canvas: Canvas<'_>, clip: &Clip, audio: &SourceAudio, channels: usize, body: Rect, wave: Color32) {
for (ch, band) in bands(body, channels.min(audio.peaks.len()).max(1)).enumerate() {
channel(painter, s, tl, clip, audio, ch, band, wave);
channel(canvas, clip, audio, ch, band, wave);
}
}
@@ -16,7 +24,8 @@ fn bands(body: Rect, n: usize) -> impl Iterator<Item = Rect> {
(0..n).map(move |k| Rect::from_min_size(pos2(body.min.x, body.min.y + h * k as f32), vec2(body.width(), h)))
}
fn channel(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &SourceAudio, ch: usize, band: Rect, wave: Color32) {
fn channel(canvas: Canvas<'_>, clip: &Clip, audio: &SourceAudio, ch: usize, band: Rect, wave: Color32) {
let Canvas { painter, s, tl } = canvas;
let (Some(samples), Some(peaks)) = (audio.channel(ch), audio.peaks.get(ch)) else { return };
let Some(g) = Grid::new(painter, s, tl, band) else { return };
let (rectified, power, outlines) = (s.edit.flag("waveform.rectified"), s.edit.flag("waveform.power"), s.edit.flag("waveform.outlines"));
@@ -59,7 +68,8 @@ fn channel(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &Source
/// Draw a take while it records across `body`, one band per channel: `blocks` holds each channel's
/// (min, max) per block of input frames, the blocks `block_len` timeline samples long from
/// timeline position `origin`.
pub fn draw_live(painter: &Painter, s: &Session, tl: Rect, origin: f64, block_len: f64, blocks: &[Vec<(f32, f32)>], body: Rect, wave: Color32) {
pub fn draw_live(canvas: Canvas<'_>, origin: f64, block_len: f64, blocks: &[Vec<(f32, f32)>], body: Rect, wave: Color32) {
let Canvas { painter, s, tl } = canvas;
let zoom = s.edit.zoom.waveform_zoom;
for (blocks, band) in blocks.iter().zip(bands(body, blocks.len())) {
let Some(g) = Grid::new(painter, s, tl, band) else { continue };
+2 -2
View File
@@ -22,13 +22,13 @@ fn paint(samples_per_px: f64, f: impl Fn(&egui::Painter, &Session, Rect)) -> Vec
fn draw(samples: Vec<f32>, samples_per_px: f64) -> Vec<Shape> {
let clip = Clip::audio(ClipId(1), "c", SourceId(1), 0, 0, samples.len() as i64);
let audio = SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![samples] });
paint(samples_per_px, |p, s, band| waveform::draw(p, s, band, &clip, &audio, 1, band, WAVE))
paint(samples_per_px, |p, s, band| waveform::draw(waveform::Canvas { painter: p, s, tl: band }, &clip, &audio, 1, band, WAVE))
}
#[test]
fn a_take_draws_its_overview_while_it_records() {
let blocks = vec![vec![(-0.5, 0.5); 100]];
let shapes = paint(128.0, |p, s, band| waveform::draw_live(p, s, band, 1280.0, 128.0, &blocks, band, WAVE));
let shapes = paint(128.0, |p, s, band| waveform::draw_live(waveform::Canvas { painter: p, s, tl: band }, 1280.0, 128.0, &blocks, band, WAVE));
let [Shape::Mesh(mesh)] = shapes.as_slice() else { panic!("{shapes:?}") };
let span = |f: fn(&Pos2) -> f32| mesh.vertices.iter().map(|v| f(&v.pos)).fold((f32::MAX, f32::MIN), |(a, b), v| (a.min(v), b.max(v)));
let ((left, right), (top, bottom)) = (span(|p| p.x), span(|p| p.y));
+17 -13
View File
@@ -54,20 +54,24 @@ impl Matrix {
}
}
/// The planes (and their geometry) of an 8-bit planar Y'CbCr picture for [`planar_to_rgba`].
#[derive(Clone, Copy)]
pub struct Planes<'a> {
pub width: usize,
pub height: usize,
pub y: &'a [u8],
pub y_stride: usize,
pub u: &'a [u8],
pub v: &'a [u8],
pub uv_stride: usize,
pub cx: usize,
pub cy: usize,
}
/// Planar 8-bit Y'CbCr with chroma subsampled by `cx` horizontally and `cy` vertically
/// (1 or 2 each) → packed RGBA. Missing samples (short planes) read as black / neutral.
pub fn planar_to_rgba(
width: usize,
height: usize,
y: &[u8],
y_stride: usize,
u: &[u8],
v: &[u8],
uv_stride: usize,
cx: usize,
cy: usize,
m: &Matrix,
) -> Vec<u8> {
pub fn planar_to_rgba(p: &Planes<'_>, m: &Matrix) -> Vec<u8> {
let Planes { width, height, y, y_stride, u, v, uv_stride, cx, cy } = *p;
let (cx, cy) = (cx.max(1), cy.max(1));
let mut out = vec![0u8; width.saturating_mul(height).saturating_mul(4)];
for (row, line) in out.chunks_exact_mut(width.max(1) * 4).enumerate().take(height) {
@@ -164,7 +168,7 @@ mod tests {
#[test]
fn planar_conversion_tolerates_short_planes() {
let m = Matrix::new(true, false);
let out = planar_to_rgba(4, 4, &[235; 3], 4, &[], &[], 2, 2, 2, &m);
let out = planar_to_rgba(&Planes { width: 4, height: 4, y: &[235; 3], y_stride: 4, u: &[], v: &[], uv_stride: 2, cx: 2, cy: 2 }, &m);
assert_eq!(out.len(), 64);
assert_eq!(&out[..4], &[255, 255, 255, 255]);
}
+1 -1
View File
@@ -268,7 +268,7 @@ impl Frame {
/// Copy the w x h window of chroma component `c` at (x0, y0) (clamped) into `out`.
#[inline]
pub fn chroma_window(&self, c: usize, x0: i32, y0: i32, w: usize, h: usize, out: &mut [u8], os: usize) {
pub fn chroma_window(&self, c: usize, (x0, y0): (i32, i32), w: usize, h: usize, out: &mut [u8], os: usize) {
let (cw, ch) = (self.cwidth, self.cheight);
for r in 0..h {
let yy = (y0 + r as i32).clamp(0, ch as i32 - 1) as usize;
+1 -1
View File
@@ -236,7 +236,7 @@ fn predict_block(f: &Frame, mv: [i16; 2], px: usize, py: usize, bw: usize, bh: u
let (fx, fy) = ((mv[0] & 7) as u32, (mv[1] & 7) as u32);
for (comp, out) in c.into_iter().enumerate() {
let mut w = [0u8; 16 * 9];
f.chroma_window(comp, cx, cy, cw + 1, ch + 1, &mut w, 16);
f.chroma_window(comp, (cx, cy), cw + 1, ch + 1, &mut w, 16);
inter::mc_chroma_win(&w, 16, fx, fy, cw, ch, out, cs);
}
}
+16 -3
View File
@@ -1,6 +1,6 @@
//! [`Movie`]: open an MP4/MOV, report its picture format, and decode frames at times to RGBA.
use crate::convert::{Matrix, planar_to_rgba, scale_rgba};
use crate::convert::{Matrix, Planes, planar_to_rgba, scale_rgba};
use crate::isobmff::{self, ByteSource, CodecConfig, Mp4File, TrackKind};
use crate::{Result, VideoError};
use std::collections::VecDeque;
@@ -411,7 +411,7 @@ impl Movie {
let (y, u, v) = (narrow(&f.y), narrow(&f.cb), narrow(&f.cr));
let cx = if cw < w { 2 } else { 1 };
let m = Matrix::for_code(f.color.matrix, f.height, false);
let rgba = planar_to_rgba(w, h, &y, w, &u, &v, cw, cx, 1, &m);
let rgba = planar_to_rgba(&Planes { width: w, height: h, y: &y, y_stride: w, u: &u, v: &v, uv_stride: cw, cx, cy: 1 }, &m);
Ok(Frame { width: f.width, height: f.height, rgba, index, time })
}
Codec::Jpeg => {
@@ -429,7 +429,20 @@ impl Movie {
fn h264_to_frame(&self, p: &crate::h264::Picture, index: usize) -> Frame {
let m = Matrix::for_code(p.color.matrix, p.height, p.color.full_range);
let rgba = planar_to_rgba(p.width as usize, p.height as usize, &p.y, p.y_stride, &p.u, &p.v, p.uv_stride, 2, 2, &m);
let rgba = planar_to_rgba(
&Planes {
width: p.width as usize,
height: p.height as usize,
y: &p.y,
y_stride: p.y_stride,
u: &p.u,
v: &p.v,
uv_stride: p.uv_stride,
cx: 2,
cy: 2,
},
&m,
);
Frame { width: p.width, height: p.height, rgba, index, time: self.times.get(index).copied().unwrap_or(0.0) }
}
+31 -19
View File
@@ -194,6 +194,35 @@ fn keep_releases(notes: &mut Vec<NoteEvent>) {
}
}
/// 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<f32>], 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<f32>], 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),
_ => {}
@@ -6,6 +6,11 @@
mod common;
fn main() {
// nextest lists custom-harness tests with `--list`; each binary here is one test.
if std::env::args().any(|a| a == "--list") {
println!("editor_main_thread: test");
return;
}
let dir = common::fixture_dir();
let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
let mut p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate");
+5
View File
@@ -6,6 +6,11 @@
mod common;
fn main() {
// nextest lists custom-harness tests with `--list`; each binary here is one test.
if std::env::args().any(|a| a == "--list") {
println!("module_exit: test");
return;
}
let dir = common::fixture_dir();
let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
let p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate");
@@ -5,6 +5,11 @@
mod common;
fn main() {
// nextest lists custom-harness tests with `--list`; each binary here is one test.
if std::env::args().any(|a| a == "--list") {
println!("module_exit_live_instance: test");
return;
}
let dir = common::fixture_dir();
let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
let p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate");
@@ -29,6 +29,11 @@ fn run(case: &str) -> Output {
}
fn main() {
// nextest lists custom-harness tests with `--list`; each binary here is one test.
if std::env::args().any(|a| a == "--list") {
println!("module_exit_shutdown: test");
return;
}
match std::env::args().nth(1).as_deref() {
Some("shutdown") => return child(false),
Some("exit-handler") => return child(true),
@@ -6,6 +6,11 @@
mod common;
fn main() {
// nextest lists custom-harness tests with `--list`; each binary here is one test.
if std::env::args().any(|a| a == "--list") {
println!("module_exit_worker_thread: test");
return;
}
std::thread::spawn(|| {
let dir = common::fixture_dir();
let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
+6 -1
View File
@@ -1,6 +1,6 @@
# Where SoundCraft falls short of Pro Tools
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (G-08 and G-20: built-in plugin count, the sampler shipped) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-25 · **Change:** trivial (G-04: Clip Looping crossfade and loop count; previously trivial: G-08 and G-20: built-in plugin count, the sampler shipped) · **Target:** Avid Pro Tools Ultimate 2026.4.1
Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing
real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker**
@@ -100,6 +100,10 @@ channels, clipping order, centered mono tracks, each 5.1 channel and 1-, 37- and
including surround channels, instead of repeating channel zero. Clip edits, source timestamps,
Undo/Redo and save/reopen are covered by regression tests; missing or empty media fails atomically.
**Open (2026-10-25):** the Clip Looping dialog (#1030) has no option to crossfade the loops into
each other, and its Number of Loops field stops at 512, while Loop Length and Loop Until End of
Session or Next Clip make up to 1000 loops.
Open on 2026-10-10 (several have PRs open):
editing #3, #5, #140, #144; clip/session #86 (PR #123), #88 (PR #130), #91 (PR #124), #92 (PR #132);
automation #104 (PR #134); mix/bounce #59 (PR #131), #103 (PR #133), #152; playback #101 (PR #129);
@@ -221,6 +225,7 @@ missing. Out of scope unless the owner decides otherwise.
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | G-04: Clip Looping (#1030) lacks the loop crossfade option, and Number of Loops stops at 512 while length and fill make up to 1000 loops |
| 2026-10-24 | trivial | G-08: 27 processors + 3 instruments, as the plugin registry holds; G-20: the sampler shipped (#108), so it is no longer listed as missing |
| 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained |
| 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged |
+3 -2
View File
@@ -1,6 +1,6 @@
# Plugin parity: built-in processors and plugin hosting
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (AudioSuite speed ramps (speed_ramp); Auto Pan; Subharmonic; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Auto Pan stereo balance; previously minor: AudioSuite speed ramps (speed_ramp); Auto Pan; Subharmonic; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1
Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins
cover the rest. This doc tracks both. Our plugins have our own names and are written from
@@ -17,7 +17,7 @@ scratch (clean-room rule): Avid plugin names are not used here, only categories.
| Dynamics | compressor/limiter, expander/gate, de-esser, maximizer, vintage compressors, multiband | `compressor`, `expander_gate`, `de_esser`, `maximizer` | Vintage/opto models, multiband, sidechain input (no key input anywhere) |
| Reverb | algorithmic room/plate/hall; convolution (paid tier) | `room_reverb`, `plate_reverb` | Hall/chamber, convolution, early-reflection editor |
| Delay | modulated delay, tape/analog delays | `mod_delay` | Tape, ping-pong, multi-tap, tempo-sync depth |
| Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `auto_pan`, `chorus`, `flanger`, `phaser`, `ring_mod` (ring, frequency modulation, positive and negative resonators; LFO, envelope or sample-and-hold modulation). Auto Pan pans the mono sum of a stereo track with an LFO (free, single, beat clock, envelope or manual trigger; rate in BPM or linked to the session tempo) or an envelope follower. | Tremolo, frequency shift; Auto Pan on a mono track (inserts on a mono track have one channel), LCR, quad and 5.0 outputs with Angle, Place and Spread, circular surround paths, output meters, side-chain input |
| Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `auto_pan`, `chorus`, `flanger`, `phaser`, `ring_mod` (ring, frequency modulation, positive and negative resonators; LFO, envelope or sample-and-hold modulation). Auto Pan moves the balance of a stereo track at constant power, keeping each channel on its own side and the track unchanged at the center, with an LFO (free, single, beat clock, envelope or manual trigger; rate in BPM or linked to the session tempo) or an envelope follower. | Tremolo, frequency shift; Auto Pan on a mono track (inserts on a mono track have one channel), LCR, quad and 5.0 outputs with Angle, Place and Spread, circular surround paths, output meters, side-chain input |
| Harmonic | lo-fi, rectifier, amp simulation, saturation, tape, subharmonic synthesis | `lofi`, `rectifier`, `saturator`, `subharmonic` (octave-down bass from a lower and an upper band, direct bass, drive characters, HP and LP filters) | Amp and cabinet models, tape; subharmonic MIDI tuning and surround (LFE) version |
| Pitch and time | pitch shift, varispeed effects, time shift | `pitch_shifter`, `time_shift`; the AudioSuite speed ramp (`speed_ramp`) slows a clip down to a stop or speeds it up from one, fitted to the clip's length or extended to 150 %, with or without a fade. | Formant control, pitch correction |
| Utility | trim, gain, invert, DC offset, signal generator, dither, time adjuster | `trim`, `gain`, `invert`, `dc_offset_removal`, `signal_generator`, `dither` | Time adjuster, meter/analyzer plugins, noise shaping options |
@@ -58,6 +58,7 @@ User report #152: a saved instrument level does not reach the bounce.
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | Auto Pan: constant-power balance of the left and right channels; stereo passes unchanged at the center |
| 2026-10-24 | minor | AudioSuite speed ramps (`speed_ramp`): down to a stop and up from one, Fit To or Extend, fades on or off |
| 2026-10-24 | minor | Auto Pan: LFO and envelope panning of stereo tracks; built-in count 27 processors + 3 instruments |
| 2026-10-24 | trivial | Harmonic: Subharmonic (`subharmonic`); built-in count 26 processors + 3 instruments |