Compare commits

..
Author SHA1 Message Date
skylar taylor-barrick 44663d9601 #264: require the whole tempo field visible and unclipped
The test only checked the tempo text's left edge, so a field cut off on either side would still pass. It now checks that the whole text lies inside the window and inside the clip rect it is painted with. With the toolbar's scrolling turned off it still fails.
2026-10-22 19:13:26 -06:00
skylar taylor-barrick a36228f0ec #264: test that the Edit toolbar scrolls horizontally in a narrow window
The Edit toolbar laid its tool groups out in a bare row, so in a narrow window the groups on the right (the tempo among them) were clipped and unreachable. The test renders a 700 pt wide window, scrolls the toolbar sideways with the wheel and checks the tempo field is then painted inside the window. Without the horizontal ScrollArea the tempo is never painted, so the assertion fails.
2026-10-22 18:02:15 -06:00
Brandon ThomasandClaude Opus 5.5 e85a4801e9 Release 0.12.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:16:17 -04:00
Brandon ThomasandClaude Opus 5.5 239c78e3a0 macOS packaging: retry stapler when Apple's CloudKit drops the connection
stapler staple/validate fetch the notarization ticket from CloudKit, which
sometimes fails with "The network connection was lost" (Error 68). That
failed this release's macOS job twice in a row. Both steps are idempotent,
so retry up to 5 times with backoff, like hdiutil_retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:16:16 -04:00
Brandon ThomasandClaude Opus 5.5 2cfc56d515 Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:38:08 -04:00
skylar taylor-barrick 06db0bd373 Merge pull request #526 from saadkhantareen/fix/length-prefixed-nals-zero-width
fix(video): reject zero/invalid NAL length widths in length_prefixed_nals (#469)
2026-10-22 07:49:15 -06:00
skylar taylor-barrick 6c596fca13 Merge pull request #702 from Silvertree2010/fix/697-random-edit-test-pad-playlist-no
Keep playlist sorted after delete_fades trims overlapping clips
2026-10-22 07:49:07 -06:00
skylar taylor-barrick 5cdc72a0ed Merge pull request #786 from MichaelMuClaude/fix/plus-key-nudge-734
fix(ui): bare "=" key nudges the selected clip right, matching "-"
2026-10-22 07:49:00 -06:00
skylar taylor-barrick 589608a5d3 Merge pull request #812 from Silvertree2010/fix/h840-automationlane-write-range-overw
Keep existing breakpoint at end + 1 in AutomationLane::write_range
2026-10-22 07:48:53 -06:00
skylar taylor-barrick 55d9e8f86d Merge pull request #820 from Silvertree2010/fix/h907-dsp-offline-normalize-turns-near
Fix normalize producing inf samples for subnormal peaks
2026-10-22 07:48:47 -06:00
skylar taylor-barrick 84495f1c9b Merge pull request #829 from Silvertree2010/fix/816-mix-send-to-an-unknown-bus-name
Reject mix.send to an unknown bus name instead of creating a bus
2026-10-22 07:48:40 -06:00
skylar taylor-barrick 095b483ce1 Merge pull request #828 from MMexicann/fix/timecode-frame-count-overflow
Fix overflow in extreme timecode frame counts
2026-10-22 07:48:33 -06:00
skylar taylor-barrick 816a6efb77 Merge pull request #830 from MMexicann/fix/midi-invalid-smpte-division
Reject undefined SMPTE frame rates in MIDI headers
2026-10-22 07:48:26 -06:00
skylar taylor-barrick 1b3e87c9c0 Merge pull request #831 from Silvertree2010/fix/h1126-tempomap-bar-beat-at-tick-panics
Fix integer overflow in TempoMap::bar_beat_at_tick for i64::MIN
2026-10-22 07:48:19 -06:00
skylar taylor-barrick 2a5d308c55 Merge pull request #834 from Silvertree2010/fix/h1179-autoparam-label-panics-with-inte
Fix integer overflow in AutoParam::label for Pan(255)
2026-10-22 07:48:13 -06:00
BFlat b1466e451c Merge pull request #934 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:02:28 +07:00
bflatastic 24543e1486 README: the app icon above the title 2026-10-22 17:58:22 +07:00
Dylan M. Taylor 9b8283b908 security: enforce HTTPS on curl downloads (SonarCloud S6506) (#860)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 20:13:52 -04:00
NetterSilverandClaude Haiku 5.5 01c8c85ebc Fix integer overflow in AutoParam::label for Pan(255)
Found while testing crates/model/src/automation.rs:73

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 07:12:50 +02:00
NetterSilverandClaude Haiku 5.5 4ef6458e4e Fix integer overflow in TempoMap::bar_beat_at_tick for i64::MIN
Found while testing crates/time/src/tempo.rs:271

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 01:12:42 +02:00
Mex 152c2c45e6 Reject undefined SMPTE frame rates in MIDI headers
Accept only the four defined SMPTE frame-rate codes. Add exhaustive invalid-code rejection and valid-rate timing and zero-tick regressions.
2026-10-20 23:51:29 +02:00
NetterSilverandClaude Haiku 5.5 96130d22c7 Reject mix.send to an unknown bus name instead of creating a bus
Fixes #816

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:30:04 +00:00
Mex 988d678eec Fix overflow in extreme timecode frame counts
Widen the magnitude and drop-frame label arithmetic to i128 so i64::MIN stays exact and extreme drop-frame counts cannot overflow. Add round-trip regressions for all frame rates and both drop-frame variants.
2026-10-20 21:50:38 +02:00
NetterSilverandClaude Haiku 5.5 5703db0b80 Fix normalize producing inf samples for subnormal peaks
Found while testing crates/dsp/src/offline.rs:62-79

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 17:24:47 +02:00
NetterSilverandClaude Haiku 5.5 1816f2dc92 Keep existing breakpoint at end + 1 in AutomationLane::write_range
Found while testing crates/model/src/automation.rs:190-203

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 14:30:22 +02:00
MichaelandClaude Sonnet 5 9ac264184e fix(ui): bare "=" key nudges the selected clip right, matching "-"
`edit.nudge`'s "Plus/Minus" shortcut only matched `Key::Plus`, which a
real keyboard without a dedicated numpad `+` can never send: typing
"+" needs Shift, and Shift+=/- is already a separate, intentional
shortcut (Grid value). So nudge-right was unreachable on most
keyboards, while nudge-left worked fine ("-" needs no shift). Fixes
#736.

Add `Key::Equals` (the bare, unshifted key) to the nudge match arm,
the same way it already works for `Key::Minus`. Also exclude `m.alt`
from the arm, since `Alt+=` is the separate, already-registered
`window.event_list` shortcut that would otherwise be shadowed.

Verified live against a running demo session over the control channel
before writing the fix (plain "-" moved a clip; plain "=" and
Shift+"=" both did nothing), and again after. New regression test
covers bare Equals/Plus/Minus nudging, Shift+Equals still stepping the
Grid value instead, and Alt+Equals still opening the MIDI Event List
instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 20:01:40 +08:00
NetterSilverandClaude Haiku 5.5 0a9d29f227 Keep playlist sorted after delete_fades trims overlapping clips
Fixes #697

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:21:32 +00:00
Saad Abdullah 08b0704b0c fix(video): reject zero/invalid NAL length widths in length_prefixed_nals (#469) 2026-10-13 16:37:51 +00:00
22 changed files with 355 additions and 49 deletions
Generated
+18 -18
View File
@@ -3572,7 +3572,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"eframe",
"egui",
@@ -3590,7 +3590,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"log",
"serde",
@@ -3600,7 +3600,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"log",
"proptest",
@@ -3612,7 +3612,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"log",
"serde_json",
@@ -3622,7 +3622,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"clap-sys",
"libloading",
@@ -3635,7 +3635,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"serde_json",
"soundcraft-audio-io",
@@ -3646,7 +3646,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"log",
"proptest",
@@ -3657,7 +3657,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"craft-docpath",
"log",
@@ -3680,7 +3680,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"proptest",
"serde",
@@ -3690,7 +3690,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"log",
"rayon",
@@ -3706,7 +3706,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"proptest",
"serde",
@@ -3719,7 +3719,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"cpal",
"log",
@@ -3734,7 +3734,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"proptest",
"serde",
@@ -3744,7 +3744,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"craft-ui",
"egui",
@@ -3770,7 +3770,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"image",
"proptest",
@@ -3781,7 +3781,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"libloading",
"log",
@@ -3797,7 +3797,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"eframe",
"egui",
@@ -5354,7 +5354,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.11.0"
version = "0.12.1"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.11.0"
version = "0.12.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+4
View File
@@ -7,6 +7,10 @@
</a>
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.soundcraft.png" width="128" height="128" alt="SoundCraft icon: a singing bird on a blue tile">
</p>
<h1 align="center">SoundCraft</h1>
<p align="center">
+14 -4
View File
@@ -65,19 +65,29 @@ pub fn normalize(ch: &mut [Vec<f32>], target_db: f32, per_channel: bool) {
for c in ch.iter_mut() {
let p = peak(std::slice::from_ref(c));
if p > 0.0 {
let g = target / p;
c.iter_mut().for_each(|s| *s *= g);
scale_to(c.iter_mut(), target, p);
}
}
} else {
let p = peak(ch);
if p > 0.0 {
let g = target / p;
ch.iter_mut().flatten().for_each(|s| *s *= g);
scale_to(ch.iter_mut().flatten(), target, p);
}
}
}
/// Scales samples whose peak is `p` to `target`. A subnormal `p` makes the f32 gain overflow to
/// infinity, so that case scales in f64.
fn scale_to<'a>(samples: impl Iterator<Item = &'a mut f32>, target: f32, p: f32) {
let g = target / p;
if g.is_finite() {
samples.for_each(|s| *s *= g);
} else {
let g = f64::from(target) / f64::from(p);
samples.for_each(|s| *s = (f64::from(*s) * g) as f32);
}
}
/// Applies a gain change in dB.
pub fn gain(ch: &mut [Vec<f32>], db: f32) {
let g = db_to_gain(if db.is_nan() { 0.0 } else { db.min(96.0) });
+19
View File
@@ -0,0 +1,19 @@
use soundcraft_dsp::offline::{normalize, peak};
#[test]
fn normalize_subnormal_peak_stays_finite() {
for per_channel in [false, true] {
let mut c = vec![vec![1e-44f32, -1e-44]];
normalize(&mut c, 0.0, per_channel);
assert!(c[0].iter().all(|s| s.is_finite()), "{c:?}");
assert!((peak(&c) - 1.0).abs() < 1e-3, "{c:?}");
}
}
#[test]
fn normalize_silent_and_normal_channels() {
let mut c = vec![vec![0.0f32; 4], vec![0.25, -0.5]];
normalize(&mut c, 0.0, true);
assert_eq!(c[0], vec![0.0; 4]);
assert!((peak(&c[1..]) - 1.0).abs() < 1e-5);
}
+17 -1
View File
@@ -444,7 +444,7 @@ fn send(e: &mut Engine, p: &Value) -> Result<Value> {
let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?;
let explicit = optional_slot(p, "mix.send", SEND_SLOTS)?;
let s = e.session_mut();
let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo);
let bid = s.bus_by_name(&bus).map(|b| b.id).ok_or_else(|| bad("mix.send", format!("no bus named \"{bus}\"")))?;
let tr = s.track_mut(t).ok_or_else(|| bad("mix.send", "no track"))?;
let slot = explicit.or_else(|| tr.mixer.sends.iter().position(Option::is_none)).ok_or_else(|| bad("mix.send", "all send slots are in use"))?;
// Re-targeting an existing send keeps its level, pan, mute and pre/post setting.
@@ -930,8 +930,10 @@ mod tests {
fn retargeting_a_send_keeps_its_settings() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap();
e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Delay"})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap();
let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap();
let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id;
@@ -985,10 +987,24 @@ mod tests {
assert_eq!(count(&e), 0, "absent param must still clear every lane");
}
#[test]
fn send_rejects_unknown_bus() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
let err = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Nowhere"})).unwrap_err();
assert!(err.to_string().contains("no bus named \"Nowhere\""), "{err}");
assert_eq!(e.session().busses.len(), 1);
assert!(e.session().tracks[0].mixer.sends.iter().all(Option::is_none));
e.execute("mix.send", &json!({"track": "Gtr", "bus": "verb"})).unwrap();
assert_eq!(e.session().busses.len(), 1);
}
#[test]
fn insert_and_send_reject_invalid_slot() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
for slot in [json!(12), json!("k"), json!("x1")] {
let r = e.execute("mix.insert", &json!({"track": "Gtr", "slot": slot, "plugin": "compressor"}));
assert!(r.is_err(), "insert slot {slot} accepted: {r:?}");
+1
View File
@@ -184,6 +184,7 @@ mod tests {
assert!(e.session().track(t).unwrap().mixer.surround.is_none());
// Sends.
assert!(e.execute("mix.send_surround_pan", &json!({"track": t.0, "slot": "a", "x": 1.0})).is_err(), "empty slot");
e.execute("mix.new_bus", &json!({"name": "FX"})).unwrap();
e.execute("mix.send", &json!({"track": t.0, "slot": 0, "bus": "FX"})).unwrap();
e.execute("mix.send_surround_pan", &json!({"track": t.0, "slot": "a", "x": 1.0, "y": -1.0})).unwrap();
let snd = e.session().track(t).unwrap().mixer.sends[0].clone().unwrap();
+16
View File
@@ -835,6 +835,8 @@ pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize {
}
}
}
// Trimming can move a start past a later clip's start when one clip overlapped several.
pl.sort();
n
}
@@ -1041,4 +1043,18 @@ mod tests {
s.find_clip_mut(id).unwrap().time_locked = true;
assert_eq!(move_clips(&mut s, &[id], 500, &[]), 0);
}
#[test]
fn delete_fades_keeps_playlist_sorted_when_a_clip_overlaps_several() {
let (mut s, t) = session_with_clip();
let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).expect("track has a playlist");
pl.clips.clear();
pl.clips.push(Clip::audio(ClipId(901), "a", SourceId(500), 0, 0, 1000));
pl.clips.push(Clip::audio(ClipId(902), "b", SourceId(500), 100, 100, 100));
pl.clips.push(Clip::audio(ClipId(903), "c", SourceId(500), 150, 150, 750));
delete_fades(&mut s, t, Range::new(0, 10_000));
let got = clips(&s, t);
assert!(got.windows(2).all(|w| w[0].0 <= w[1].0), "playlist not sorted: {got:?}");
assert!(got.iter().all(|c| c.1 > 0), "empty clip: {got:?}");
}
}
@@ -5,3 +5,4 @@
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 1dc212f0026723d05bfcfbb29fcff476eda7f491e9bea9cd5d26dccf006a81f6 # shrinks to ops = [(9, 0, 2420494, 1), (3, 0, 2396495, 1)]
cc 87a0afb56f43d6ad377071e28bd6ba94c3d67485fc69dbefa9a2c7de3f698ed6 # shrinks to ops = [(10, 2215386, 0, 0), (0, 844227, 213110, 1), (3, 0, 443078, 0), (0, 443079, 443079, 0), (11, 0, 0, 0)]
+1 -1
View File
@@ -122,7 +122,7 @@ impl TickConv {
let [hi, lo] = division.to_be_bytes();
let fps = -i128::from(hi as i8);
let tpf = i128::from(lo);
if fps <= 0 || tpf == 0 {
if !matches!(fps, 24 | 25 | 29 | 30) || tpf == 0 {
return Err(MidiError::Invalid { offset, reason: "invalid SMPTE division" });
}
let two_ppq = 2 * i128::from(PPQ);
+23
View File
@@ -148,6 +148,29 @@ fn division_conversion_96_and_smpte() {
assert_eq!(smf.tracks[0].sequence.notes[0].length, 480);
}
#[test]
fn smpte_division_rejects_undefined_frame_rates() {
let body = vec![0, 0x90, 60, 100, 40, 0x80, 60, 64, 0, 0xFF, 0x2F, 0];
for high in 0x80..=0xFF {
if [(-24i8) as u8, (-25i8) as u8, (-29i8) as u8, (-30i8) as u8].contains(&high) {
continue;
}
let division = u16::from_be_bytes([high, 40]);
assert!(matches!(read_smf(&file(0, division, std::slice::from_ref(&body))), Err(MidiError::Invalid { offset: 12, .. })), "{division:#06x}");
}
}
#[test]
fn smpte_division_keeps_all_four_standard_frame_rates() {
for (fps, file_ticks, model_ticks) in [(24i8, 960, 1920), (25, 1000, 1920), (29, 1200, 1922), (30, 1200, 1920)] {
let body = [vec![0, 0x90, 60, 100], vlq(file_ticks), vec![0x80, 60, 64, 0, 0xFF, 0x2F, 0]].concat();
let division = u16::from_be_bytes([(-fps) as u8, 40]);
let smf = read_smf(&file(0, division, std::slice::from_ref(&body))).unwrap();
assert_eq!(smf.tracks[0].sequence.notes, vec![note(60, 0, model_ticks)], "{fps} fps");
assert!(read_smf(&file(0, u16::from_be_bytes([(-fps) as u8, 0]), std::slice::from_ref(&body))).is_err());
}
}
#[test]
fn running_status_and_velocity_zero_note_off() {
// Note-on with running status for both on and off (velocity 0).
+20 -2
View File
@@ -70,7 +70,7 @@ impl AutoParam {
match self {
AutoParam::Volume => "volume".into(),
AutoParam::Pan(0) => "pan".into(),
AutoParam::Pan(i) => format!("pan {}", i + 1),
AutoParam::Pan(i) => format!("pan {}", u16::from(*i) + 1),
AutoParam::Mute => "mute".into(),
AutoParam::SendLevel(i) => format!("send {} level", slot_letter(*i)),
AutoParam::SendPan(i) => format!("send {} pan", slot_letter(*i)),
@@ -199,7 +199,11 @@ impl AutomationLane {
}
self.set_point(start, value);
self.set_point(end, value);
self.set_point(end.saturating_add(1), after);
let anchor = end.saturating_add(1);
// A real breakpoint already at `end + 1` keeps its own value.
if self.points.binary_search_by(|p| p.at.cmp(&anchor)).is_err() {
self.set_point(anchor, after);
}
}
/// Thin: remove points whose removal changes the curve by less than `tolerance`.
@@ -251,6 +255,11 @@ impl AutomationLane {
mod tests {
use super::*;
#[test]
fn pan_label_max_does_not_overflow() {
assert_eq!(AutoParam::Pan(255).label(), "pan 256");
}
#[test]
fn interpolation_and_steps() {
let mut l = AutomationLane::new(AutoParam::Volume);
@@ -266,6 +275,15 @@ mod tests {
assert_eq!(m.value_at(100, 0.0), 1.0);
}
#[test]
fn write_range_keeps_point_right_after_end() {
let mut l = AutomationLane::new(AutoParam::Volume);
l.set_point(0, 0.0);
l.set_point(11, -100.0);
l.write_range(2, 10, -20.0, 0.0);
assert_eq!(l.value_at(11, 0.0), -100.0);
}
#[test]
fn write_range_preserves_outside() {
let mut l = AutomationLane::new(AutoParam::Volume);
+15 -3
View File
@@ -268,9 +268,12 @@ impl TempoMap {
let first = self.meter_at_tick(0);
if tick < 0 {
let tpb = first.ticks_per_bar();
let bars_back = (-tick + tpb - 1) / tpb;
let bar_tick = -bars_back * tpb;
let within = tick - bar_tick;
let abs = tick.unsigned_abs();
let tpb_u = tpb.unsigned_abs();
let bars_back_u = abs / tpb_u + u64::from(!abs.is_multiple_of(tpb_u));
let bars_back = i64::try_from(bars_back_u).unwrap_or(i64::MAX);
// Offset of `tick` inside its bar, without negating `i64::MIN`.
let within = ((tpb_u - abs % tpb_u) % tpb_u) as i64;
let beat_t = first.ticks_per_beat();
return BarBeat { bar: 1 - bars_back, beat: within / beat_t + 1, tick: within % beat_t };
}
@@ -341,6 +344,15 @@ mod tests {
assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4);
}
#[test]
fn bar_beat_at_min_tick_does_not_overflow() {
let m = TempoMap::default();
let bb = m.bar_beat_at_tick(i64::MIN);
assert!(bb.bar < 1);
assert!(bb.beat >= 1 && bb.tick >= 0);
assert_eq!(m.bar_beat_at_tick(-1).bar, 0);
}
#[test]
fn tempo_change_is_integrated() {
let mut m = TempoMap::default();
+33 -3
View File
@@ -133,8 +133,10 @@ impl Timecode {
/// Label for an absolute frame count.
pub fn from_frames(frames: i64, rate: FrameRate) -> Timecode {
let negative = frames < 0;
let mut f = frames.saturating_abs();
let base = rate.nominal().max(1);
// Drop-frame labels can exceed i64::MAX even when the frame count fits. Widen before
// taking the magnitude so i64::MIN is represented exactly too.
let mut f = i128::from(frames).abs();
let base = i128::from(rate.nominal().max(1));
if rate.is_drop() {
// Drop 2 (or 4) labels each minute except every tenth.
let drop = if base >= 60 { 4 } else { 2 };
@@ -147,7 +149,13 @@ impl Timecode {
}
let frames_l = f % base;
let total_secs = f / base;
Timecode { negative, hours: total_secs / 3600, minutes: (total_secs / 60) % 60, seconds: total_secs % 60, frames: frames_l }
Timecode {
negative,
hours: i64::try_from(total_secs / 3600).unwrap_or(i64::MAX),
minutes: i64::try_from((total_secs / 60) % 60).unwrap_or(0),
seconds: i64::try_from(total_secs % 60).unwrap_or(0),
frames: i64::try_from(frames_l).unwrap_or(0),
}
}
/// Absolute frame count of this label.
@@ -247,6 +255,28 @@ mod tests {
assert_eq!(tc.to_samples(FrameRate::Fps30, sr), 178_710_400);
}
#[test]
fn extreme_frame_counts_round_trip_at_every_rate() {
for rate in FrameRate::ALL {
for frames in [i64::MIN, i64::MIN + 1, -1, 0, 1, i64::MAX - 1, i64::MAX] {
let tc = Timecode::from_frames(frames, rate);
assert_eq!(tc.to_frames(rate), frames, "{rate:?}: {frames}");
assert_eq!(tc.negative, frames < 0);
assert!((0..60).contains(&tc.minutes));
assert!((0..60).contains(&tc.seconds));
assert!((0..rate.nominal()).contains(&tc.frames));
assert_eq!(Timecode::parse(&tc.format(rate)).unwrap(), tc);
}
}
}
#[test]
fn extreme_drop_frame_labels_do_not_overflow() {
for rate in [FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
assert_eq!(Timecode::from_frames(i64::MAX, rate).to_frames(rate), i64::MAX);
}
}
#[test]
fn parse_variants() {
let tc = Timecode::parse("01:02:03:04").unwrap();
+6 -2
View File
@@ -1247,10 +1247,14 @@ fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, cu
ui.separator();
if ui.button("new bus...").clicked() {
let mut n = busses.len() + 1;
while busses.iter().any(|bus| bus.name == format!("Bus {n}")) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() {
while busses.iter().any(|bus| bus.name.eq_ignore_ascii_case(&format!("Bus {n}")))
|| app.engine.session().track_by_name(&format!("Bus {n}")).is_some()
{
n += 1;
}
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")}));
if app.run("mix.new_bus", json!({"name": format!("Bus {n}")})).is_ok() {
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")}));
}
ui.close();
}
}
+5 -2
View File
@@ -162,8 +162,11 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let _ = app.run("edit.tool", json!({"tool": tool}));
true
}
Key::Plus | Key::Minus if !m.command && app.ui.window == MainWindow::Edit => {
let dir = if key == Key::Plus { 1 } else { -1 };
// `=` is what a bare press of the unlabeled +/- key reports (it has no shift held, so it
// can never be `Key::Plus`); `Key::Plus` is kept for a keyboard that does send it (e.g. a
// numpad +). `!m.alt` leaves Alt+= to `window.event_list`'s registered shortcut.
Key::Plus | Key::Equals | Key::Minus if !m.command && !m.alt && app.ui.window == MainWindow::Edit => {
let dir = if key == Key::Minus { -1 } else { 1 };
let _ = app.run("edit.nudge", json!({"direction": dir}));
true
}
+1
View File
@@ -143,6 +143,7 @@ fn send_and_output_menus_search_buses_as_soon_as_they_open() {
let mut engine = vox();
let vox = engine.session().tracks[0].id;
for (slot, bus) in [(0, "Verb"), (1, "Delay")] {
engine.execute("mix.new_bus", &json!({"name": bus})).unwrap();
engine.execute("mix.send", &json!({"track": vox.0, "slot": slot, "bus": bus})).unwrap();
}
let mut h = harness(engine);
+1
View File
@@ -12,6 +12,7 @@ fn harness() -> (Harness<'static, SoundApp>, soundcraft_model::TrackId) {
engine.execute("track.new", &json!({"name": "Vox"})).unwrap();
let vox = engine.session().tracks[0].id;
for (slot, bus) in [(0, "Verb"), (1, "Delay")] {
engine.execute("mix.new_bus", &json!({"name": bus})).unwrap();
engine.execute("mix.send", &json!({"track": vox.0, "slot": slot, "bus": bus, "level_db": -10.0})).unwrap();
}
let mut app = SoundApp::new(engine, None, Services::default());
+38
View File
@@ -56,6 +56,44 @@ fn tab_leaves_the_keys_to_the_shortcuts() {
}
}
#[test]
fn bare_plus_and_minus_nudge_the_selected_clip_in_edit() {
// A laptop keyboard has no dedicated "+": typing it needs Shift, which is already claimed by
// the Grid-value shortcut (#125 above). So nudge must fire on the bare, unshifted key the "+"
// symbol sits on, same as "-" already does; `Key::Plus` stays bound too for a keyboard (e.g. a
// numpad) that does send it on its own.
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id;
app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap();
let ctx = egui::Context::default();
let start = |app: &SoundApp| app.engine.session().find_clip(kick).unwrap().1.start;
let original = start(&app);
press(&ctx, &mut app, Key::Minus, Modifiers::NONE);
let after_minus = start(&app);
assert!(after_minus < original, "bare Minus must nudge left");
press(&ctx, &mut app, Key::Equals, Modifiers::NONE);
assert_eq!(start(&app), original, "bare Equals must nudge back right, same step as Minus");
press(&ctx, &mut app, Key::Plus, Modifiers::NONE);
assert!(start(&app) > original, "Key::Plus (e.g. a numpad +) must still nudge right");
// Shift+Equals is the pre-existing, separate Grid-value shortcut: it must not also nudge.
let before_shift = start(&app);
let grid = app.engine.session().edit.grid;
press(&ctx, &mut app, Key::Equals, Modifiers::SHIFT);
assert_eq!(start(&app), before_shift, "Shift+Equals steps the Grid value, not the clip");
assert_ne!(app.engine.session().edit.grid, grid, "Shift+Equals must still step the Grid value");
// Alt+Equals is the registered `window.event_list` shortcut: it must not also nudge.
let before_alt = start(&app);
assert!(!app.ui.show_event_list);
press(&ctx, &mut app, Key::Equals, Modifiers::ALT);
assert_eq!(start(&app), before_alt, "Alt+Equals opens the MIDI Event List, not a nudge");
assert!(app.ui.show_event_list, "Alt+Equals must still open the MIDI Event List");
}
#[test]
fn cmd_equals_toggles_mix_and_edit_without_zooming() {
let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
+51
View File
@@ -0,0 +1,51 @@
// Regression test for #264: the Edit toolbar clipped its tools in a narrow window; it now scrolls horizontally.
use egui::{Event, Pos2, Shape, vec2};
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp};
const WIDTH: f32 = 700.0;
/// The toolbar's tempo text (the last group but one) and the clip rect it is painted in, once painted.
fn tempo(h: &egui_kittest::Harness<'_, SoundApp>) -> Option<(egui::Rect, egui::Rect)> {
h.output().shapes.iter().find_map(|c| match &c.shape {
Shape::Text(t) if t.galley.text() == "120.0000" => Some((t.galley.rect.translate(t.pos.to_vec2()), c.clip_rect)),
_ => None,
})
}
#[test]
fn a_narrow_window_scrolls_the_toolbar_to_reach_the_tempo() {
let mut engine = soundcraft_engine::Engine::default();
engine.execute("track.new", &json!({})).unwrap();
let app = SoundApp::new(engine, None, Services::default());
let mut h = egui_kittest::Harness::builder().with_size(vec2(WIDTH, 700.0)).build_ui_state(
|ui, app: &mut SoundApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
for _ in 0..4 {
h.step();
}
// Off the right edge, so egui culls it (or paints it beyond the window).
assert!(tempo(&h).is_none_or(|(r, _)| r.min.x > WIDTH), "the tempo should start off-screen in a {WIDTH} pt window");
let y = 40.0;
// Wheel over the toolbar (a vertical wheel does not scroll a horizontal-only area, a horizontal one does).
for _ in 0..12 {
h.event(Event::PointerMoved(Pos2::new(WIDTH / 2.0, y)));
h.event(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(-300.0, 0.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
h.step();
}
let at = tempo(&h);
assert!(
at.is_some_and(|(r, clip)| r.min.x >= 0.0 && r.max.x <= WIDTH && clip.contains_rect(r)),
"scrolling the toolbar must bring the whole tempo into the window, unclipped: (text, clip) = {at:?}"
);
}
+52 -8
View File
@@ -13,6 +13,8 @@ pub enum BitError {
Eof,
/// An Exp-Golomb code longer than 32 leading zeros.
InvalidExpGolomb,
/// A NAL length-prefix width outside the AVCC/HVCC range of 1..=4 bytes.
InvalidLengthSize,
}
impl fmt::Display for BitError {
@@ -20,6 +22,7 @@ impl fmt::Display for BitError {
match self {
BitError::Eof => f.write_str("unexpected end of bitstream"),
BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"),
BitError::InvalidLengthSize => f.write_str("invalid NAL length size"),
}
}
}
@@ -302,24 +305,37 @@ pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> {
out
}
/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1/2/4 bytes) into NAL units.
/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1..=4 bytes) into NAL units.
///
/// The width comes from the stream's configuration record (`(byte & 3) + 1`), so anything
/// outside 1..=4 is a programming or parsing error and is rejected up front: a width of 0
/// would never advance the cursor, looping forever and appending empty NALs until the
/// process runs out of memory, and a wider prefix cannot occur in a real sample.
pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result<Vec<&[u8]>> {
if !(1..=4).contains(&length_size) {
return Err(BitError::InvalidLengthSize);
}
let mut out = Vec::new();
let mut i = 0;
while i < data.len() {
if i + length_size > data.len() {
let Some(start) = i.checked_add(length_size) else {
return Err(BitError::Eof);
};
if start > data.len() {
return Err(BitError::Eof);
}
let mut len = 0usize;
for k in 0..length_size {
len = (len << 8) | data[i + k] as usize;
for &b in &data[i..start] {
len = (len << 8) | b as usize;
}
i += length_size;
if i + len > data.len() {
let Some(end) = start.checked_add(len) else {
return Err(BitError::Eof);
};
if end > data.len() {
return Err(BitError::Eof);
}
out.push(&data[i..i + len]);
i += len;
out.push(&data[start..end]);
i = end;
}
Ok(out)
}
@@ -392,6 +408,34 @@ mod tests {
assert_eq!(length_prefixed_nals(&l, 4).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]);
}
#[test]
fn length_prefixed_rejects_bad_length_size() {
// Regression test for #469: with a width of 0 neither the prefix nor a
// zero-length payload advanced the cursor, so any non-empty sample
// looped forever appending empty NALs. Widths above 4 cannot come
// from an AVCC/HVCC record either.
let d = [0, 0, 0, 2, 0x67, 1];
assert_eq!(length_prefixed_nals(&d, 0), Err(BitError::InvalidLengthSize));
assert_eq!(length_prefixed_nals(&d, 5), Err(BitError::InvalidLengthSize));
assert_eq!(length_prefixed_nals(&d, usize::MAX), Err(BitError::InvalidLengthSize));
assert_eq!(length_prefixed_nals(&[], 0), Err(BitError::InvalidLengthSize));
}
#[test]
fn length_prefixed_widths() {
// 1-byte prefixes.
assert_eq!(length_prefixed_nals(&[2, 0x67, 1, 1, 0x68], 1).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]);
// 2-byte prefixes.
assert_eq!(length_prefixed_nals(&[0, 2, 0x67, 1, 0, 1, 0x68], 2).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]);
// 3-byte prefixes (lengthSizeMinusOne == 2 in the configuration record).
assert_eq!(length_prefixed_nals(&[0, 0, 2, 0x67, 1], 3).unwrap(), vec![&[0x67, 1][..]]);
// A truncated prefix or payload is Eof, not a panic or a short read.
assert_eq!(length_prefixed_nals(&[0, 0], 4), Err(BitError::Eof));
assert_eq!(length_prefixed_nals(&[0, 0, 0, 9, 0x67], 4), Err(BitError::Eof));
// Empty input splits to no NALs.
assert!(length_prefixed_nals(&[], 4).unwrap().is_empty());
}
#[test]
fn more_rbsp() {
let d = [0b1100_0000];
+18 -4
View File
@@ -80,6 +80,20 @@ sign() {
codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@"
}
# stapler fetches the ticket from Apple's CloudKit, which sometimes drops the connection ("The
# network connection was lost", Error 68). Stapling and validating are idempotent, so retry.
stapler_retry() {
local attempt
for attempt in 1 2 3 4 5; do
if xcrun stapler "$@"; then
return 0
fi
[ "$attempt" = 5 ] && { echo "stapler $1 failed 5 times" >&2; return 1; }
warn "stapler $1 failed (attempt $attempt of 5); retrying in $((attempt * 15))s"
sleep $((attempt * 15))
done
}
notarize() {
local file="$1" out id status
echo "==> notarizing $(basename "$file") (this can take a few minutes)"
@@ -122,8 +136,8 @@ codesign --verify --strict --deep --verbose=2 "$APP"
if [ "$NOTARIZE" = 1 ]; then
ditto -c -k --keepParent "$APP" "$WORK/SoundCraft-notarize.zip"
notarize "$WORK/SoundCraft-notarize.zip"
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
stapler_retry staple "$APP"
stapler_retry validate "$APP"
spctl --assess --type execute -vvv "$APP"
fi
@@ -185,8 +199,8 @@ if [ "$dmg_ok" != 1 ]; then
fi
if [ "$NOTARIZE" = 1 ]; then
notarize "$DMG"
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
stapler_retry staple "$DMG"
stapler_retry validate "$DMG"
spctl --assess --type open --context context:primary-signature -vvv "$DMG"
fi