mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 10:36:46 -06:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e85a4801e9 | ||
|
|
239c78e3a0 | ||
|
|
2cfc56d515 | ||
|
|
06db0bd373 | ||
|
|
6c596fca13 | ||
|
|
5cdc72a0ed | ||
|
|
589608a5d3 | ||
|
|
55d9e8f86d | ||
|
|
84495f1c9b | ||
|
|
095b483ce1 | ||
|
|
816a6efb77 | ||
|
|
1b3e87c9c0 | ||
|
|
2a5d308c55 | ||
|
|
b1466e451c | ||
|
|
24543e1486 | ||
|
|
9b8283b908 | ||
|
|
01c8c85ebc | ||
|
|
4ef6458e4e | ||
|
|
152c2c45e6 | ||
|
|
96130d22c7 | ||
|
|
988d678eec | ||
|
|
5703db0b80 | ||
|
|
1816f2dc92 | ||
|
|
9ac264184e | ||
|
|
0a9d29f227 | ||
|
|
08b0704b0c |
Generated
+18
-18
@@ -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
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user