Compare commits

...
Author SHA1 Message Date
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 305 additions and 50 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();
+5 -1
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;
}
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 };
+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];
+1 -1
View File
@@ -115,7 +115,7 @@ if has appimage; then
if [ -z "$TOOL" ]; then
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
if [ ! -x "$TOOL" ]; then
curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
chmod +x "$TOOL"
fi
fi
+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