Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 6731ba2dce security: enforce HTTPS on curl downloads (SonarCloud S6506)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 17:07:16 -04:00
22 changed files with 49 additions and 355 deletions
Generated
+18 -18
View File
@@ -3572,7 +3572,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"eframe",
"egui",
@@ -3590,7 +3590,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"log",
"serde",
@@ -3600,7 +3600,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"log",
"proptest",
@@ -3612,7 +3612,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"log",
"serde_json",
@@ -3622,7 +3622,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"clap-sys",
"libloading",
@@ -3635,7 +3635,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"serde_json",
"soundcraft-audio-io",
@@ -3646,7 +3646,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"log",
"proptest",
@@ -3657,7 +3657,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"craft-docpath",
"log",
@@ -3680,7 +3680,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"proptest",
"serde",
@@ -3690,7 +3690,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"log",
"rayon",
@@ -3706,7 +3706,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"proptest",
"serde",
@@ -3719,7 +3719,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"cpal",
"log",
@@ -3734,7 +3734,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"proptest",
"serde",
@@ -3744,7 +3744,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"craft-ui",
"egui",
@@ -3770,7 +3770,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"image",
"proptest",
@@ -3781,7 +3781,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"libloading",
"log",
@@ -3797,7 +3797,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"eframe",
"egui",
@@ -5354,7 +5354,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.12.1"
version = "0.11.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.12.1"
version = "0.11.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
-4
View File
@@ -7,10 +7,6 @@
</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">
+4 -14
View File
@@ -65,29 +65,19 @@ 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 {
scale_to(c.iter_mut(), target, p);
let g = target / p;
c.iter_mut().for_each(|s| *s *= g);
}
}
} else {
let p = peak(ch);
if p > 0.0 {
scale_to(ch.iter_mut().flatten(), target, p);
let g = target / p;
ch.iter_mut().flatten().for_each(|s| *s *= g);
}
}
}
/// 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
@@ -1,19 +0,0 @@
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);
}
+1 -17
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.bus_by_name(&bus).map(|b| b.id).ok_or_else(|| bad("mix.send", format!("no bus named \"{bus}\"")))?;
let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo);
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,10 +930,8 @@ 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;
@@ -987,24 +985,10 @@ 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,7 +184,6 @@ 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,8 +835,6 @@ 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
}
@@ -1043,18 +1041,4 @@ 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,4 +5,3 @@
# 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 !matches!(fps, 24 | 25 | 29 | 30) || tpf == 0 {
if fps <= 0 || tpf == 0 {
return Err(MidiError::Invalid { offset, reason: "invalid SMPTE division" });
}
let two_ppq = 2 * i128::from(PPQ);
-23
View File
@@ -148,29 +148,6 @@ 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).
+2 -20
View File
@@ -70,7 +70,7 @@ impl AutoParam {
match self {
AutoParam::Volume => "volume".into(),
AutoParam::Pan(0) => "pan".into(),
AutoParam::Pan(i) => format!("pan {}", u16::from(*i) + 1),
AutoParam::Pan(i) => format!("pan {}", 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,11 +199,7 @@ impl AutomationLane {
}
self.set_point(start, value);
self.set_point(end, value);
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);
}
self.set_point(end.saturating_add(1), after);
}
/// Thin: remove points whose removal changes the curve by less than `tolerance`.
@@ -255,11 +251,6 @@ 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);
@@ -275,15 +266,6 @@ 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);
+3 -15
View File
@@ -268,12 +268,9 @@ impl TempoMap {
let first = self.meter_at_tick(0);
if tick < 0 {
let tpb = first.ticks_per_bar();
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 bars_back = (-tick + tpb - 1) / tpb;
let bar_tick = -bars_back * tpb;
let within = tick - bar_tick;
let beat_t = first.ticks_per_beat();
return BarBeat { bar: 1 - bars_back, beat: within / beat_t + 1, tick: within % beat_t };
}
@@ -344,15 +341,6 @@ 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();
+3 -33
View File
@@ -133,10 +133,8 @@ impl Timecode {
/// Label for an absolute frame count.
pub fn from_frames(frames: i64, rate: FrameRate) -> Timecode {
let negative = frames < 0;
// 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));
let mut f = frames.saturating_abs();
let base = 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 };
@@ -149,13 +147,7 @@ impl Timecode {
}
let frames_l = f % base;
let total_secs = f / base;
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),
}
Timecode { negative, hours: total_secs / 3600, minutes: (total_secs / 60) % 60, seconds: total_secs % 60, frames: frames_l }
}
/// Absolute frame count of this label.
@@ -255,28 +247,6 @@ 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();
+2 -6
View File
@@ -1247,14 +1247,10 @@ 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.eq_ignore_ascii_case(&format!("Bus {n}")))
|| app.engine.session().track_by_name(&format!("Bus {n}")).is_some()
{
while busses.iter().any(|bus| bus.name == 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}")}));
}
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")}));
ui.close();
}
}
+2 -5
View File
@@ -162,11 +162,8 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let _ = app.run("edit.tool", json!({"tool": tool}));
true
}
// `=` 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 };
Key::Plus | Key::Minus if !m.command && app.ui.window == MainWindow::Edit => {
let dir = if key == Key::Plus { 1 } else { -1 };
let _ = app.run("edit.nudge", json!({"direction": dir}));
true
}
-1
View File
@@ -143,7 +143,6 @@ 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,7 +12,6 @@ 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,44 +56,6 @@ 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
@@ -1,51 +0,0 @@
// 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:?}"
);
}
+8 -52
View File
@@ -13,8 +13,6 @@ 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 {
@@ -22,7 +20,6 @@ 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"),
}
}
}
@@ -305,37 +302,24 @@ pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> {
out
}
/// 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.
/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1/2/4 bytes) into NAL units.
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() {
let Some(start) = i.checked_add(length_size) else {
return Err(BitError::Eof);
};
if start > data.len() {
if i + length_size > data.len() {
return Err(BitError::Eof);
}
let mut len = 0usize;
for &b in &data[i..start] {
len = (len << 8) | b as usize;
for k in 0..length_size {
len = (len << 8) | data[i + k] as usize;
}
let Some(end) = start.checked_add(len) else {
return Err(BitError::Eof);
};
if end > data.len() {
i += length_size;
if i + len > data.len() {
return Err(BitError::Eof);
}
out.push(&data[start..end]);
i = end;
out.push(&data[i..i + len]);
i += len;
}
Ok(out)
}
@@ -408,34 +392,6 @@ 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];
+4 -18
View File
@@ -80,20 +80,6 @@ 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)"
@@ -136,8 +122,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"
stapler_retry staple "$APP"
stapler_retry validate "$APP"
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
spctl --assess --type execute -vvv "$APP"
fi
@@ -199,8 +185,8 @@ if [ "$dmg_ok" != 1 ]; then
fi
if [ "$NOTARIZE" = 1 ]; then
notarize "$DMG"
stapler_retry staple "$DMG"
stapler_retry validate "$DMG"
xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG"
spctl --assess --type open --context context:primary-signature -vvv "$DMG"
fi