mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-12 08:53:41 -06:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d47f48e65 | ||
|
|
1f96843a24 | ||
|
|
ff4be04936 | ||
|
|
1483dc833b | ||
|
|
dc4f82ea9f | ||
|
|
df67089ccd | ||
|
|
44e80dcc24 |
+209
-16
@@ -10,6 +10,10 @@ use std::sync::atomic::Ordering;
|
||||
const MAX_BLOCK: usize = 1 << 16;
|
||||
/// Most channels a host strip may ask for.
|
||||
const MAX_CHANNELS: usize = 64;
|
||||
/// Queue slots that only releases from `note_off` may take: one per key, so a queue that
|
||||
/// parameter changes, note-ons or other MIDI filled still has room to release every held key on
|
||||
/// a reset (#1464).
|
||||
const RELEASE_RESERVE: usize = 128;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum NoteMode {
|
||||
@@ -79,14 +83,104 @@ impl ClapPlugin {
|
||||
self.queue.retain(|e| !matches!(e, Event::Param { .. }));
|
||||
}
|
||||
|
||||
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
|
||||
/// allocates on the audio thread).
|
||||
/// Queues an event other than a note release; see [`push_event`].
|
||||
fn push(&mut self, e: Event) {
|
||||
if self.queue.len() >= self.queue.capacity() {
|
||||
return;
|
||||
push_event(&mut self.queue, e, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
|
||||
/// allocates on the audio thread). Only a `release` may take the last [`RELEASE_RESERVE`] slots.
|
||||
/// Returns whether the event was queued.
|
||||
fn push_event(queue: &mut Vec<Event>, e: Event, release: bool) -> bool {
|
||||
let room = if release { queue.capacity() } else { queue.capacity().saturating_sub(RELEASE_RESERVE) };
|
||||
if queue.len() >= room {
|
||||
return false;
|
||||
}
|
||||
let pos = queue.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
|
||||
queue.insert(pos, e);
|
||||
true
|
||||
}
|
||||
|
||||
/// Queues the release of `key` at `time`. The key stays held when the queue has no room, so a
|
||||
/// later reset still releases it (#1464).
|
||||
fn queue_release(queue: &mut Vec<Event>, held: &mut [bool; 128], notes: NoteMode, time: u32, key: u8) {
|
||||
if let Some(e) = release_event(notes, time, key)
|
||||
&& push_event(queue, e, true)
|
||||
&& let Some(h) = held.get_mut(usize::from(key))
|
||||
{
|
||||
*h = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The release of `key` in the note port's dialect (`None` without a note port).
|
||||
fn release_event(notes: NoteMode, time: u32, key: u8) -> Option<Event> {
|
||||
match notes {
|
||||
NoteMode::Clap => Some(Event::NoteOff { time, key }),
|
||||
NoteMode::Midi => Some(Event::Midi { time, data: [0x80, key, 0] }),
|
||||
NoteMode::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The queue after a reset: [`keep_releases`], then a release at the start of the next block for
|
||||
/// every key still `held` that has none yet, so each key is released once. Clears `held`.
|
||||
///
|
||||
/// It never runs out of room: events other than `note_off` releases fill at most
|
||||
/// `capacity - RELEASE_RESERVE` slots, and the releases left are one per key.
|
||||
fn release_on_reset(queue: &mut Vec<Event>, held: &mut [bool; 128], notes: NoteMode) {
|
||||
keep_releases(queue);
|
||||
for e in queue.iter() {
|
||||
let key = match *e {
|
||||
Event::NoteOff { key, .. } => Some(key),
|
||||
// Channel 1, where `note_off` sends them.
|
||||
Event::Midi { data: [status, key, velocity], .. } if status & 0x0F == 0 && is_midi_note_off(status, velocity) => Some(key),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(h) = key.and_then(|k| held.get_mut(usize::from(k))) {
|
||||
*h = false;
|
||||
}
|
||||
let pos = self.queue.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
|
||||
self.queue.insert(pos, e);
|
||||
}
|
||||
for key in 0..128u8 {
|
||||
if held.get(usize::from(key)).copied().unwrap_or(false)
|
||||
&& let Some(e) = release_event(notes, 0, key)
|
||||
{
|
||||
let _ = push_event(queue, e, true);
|
||||
}
|
||||
}
|
||||
*held = [false; 128];
|
||||
}
|
||||
|
||||
/// What a reset keeps of the pending events: parameter changes, and the releases (CLAP note-offs
|
||||
/// and MIDI note-offs, one per channel and key), all moved to the start of the next block. Note-ons
|
||||
/// and other MIDI belong to the position being left. One release per key leaves room in the queue
|
||||
/// for `all_notes_off`.
|
||||
fn keep_releases(queue: &mut Vec<Event>) {
|
||||
let mut clap_off = [false; 128];
|
||||
let mut midi_off = [[false; 128]; 16];
|
||||
queue.retain(|e| match *e {
|
||||
Event::Param { .. } => true,
|
||||
Event::NoteOff { key, .. } => first_release(clap_off.get_mut(usize::from(key))),
|
||||
Event::Midi { data: [status, key, velocity], .. } if is_midi_note_off(status, velocity) => {
|
||||
first_release(midi_off.get_mut(usize::from(status & 0x0F)).and_then(|c| c.get_mut(usize::from(key))))
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
for e in queue.iter_mut() {
|
||||
e.set_time(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a key's release as seen; true only the first time (false for a key out of range).
|
||||
fn first_release(seen: Option<&mut bool>) -> bool {
|
||||
seen.is_some_and(|s| !std::mem::replace(s, true))
|
||||
}
|
||||
|
||||
/// A MIDI 1.0 note-off: status 0x8n, or a note-on (0x9n) with velocity 0.
|
||||
fn is_midi_note_off(status: u8, velocity: u8) -> bool {
|
||||
match status & 0xF0 {
|
||||
0x80 => true,
|
||||
0x90 => velocity == 0,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,8 +279,10 @@ impl Plugin for ClapPlugin {
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.inst.reset();
|
||||
self.queue.retain(|e| matches!(e, Event::Param { .. }));
|
||||
self.held = [false; 128];
|
||||
// Keep pending releases and release whatever is still held: CLAP asks `reset` to kill
|
||||
// voices, but some plugins' doesn't (Surge XT's), and they would sound on with nothing
|
||||
// left to release them (#1464).
|
||||
release_on_reset(&mut self.queue, &mut self.held, self.notes);
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: &str, value: f32) -> bool {
|
||||
@@ -287,14 +383,7 @@ impl Plugin for ClapPlugin {
|
||||
return;
|
||||
}
|
||||
let time = u32::try_from(offset).unwrap_or(u32::MAX);
|
||||
match self.notes {
|
||||
NoteMode::Clap => self.push(Event::NoteOff { time, key: note }),
|
||||
NoteMode::Midi => self.push(Event::Midi { time, data: [0x80, note, 0] }),
|
||||
NoteMode::None => return,
|
||||
}
|
||||
if let Some(h) = self.held.get_mut(note as usize) {
|
||||
*h = false;
|
||||
}
|
||||
queue_release(&mut self.queue, &mut self.held, self.notes, time, note);
|
||||
}
|
||||
|
||||
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
|
||||
@@ -383,3 +472,107 @@ impl PluginEditor for ClapEditor {
|
||||
self.link.host().dirty.swap(false, Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A reset keeps parameter changes and one release per key, at the start of the next block,
|
||||
/// and drops note-ons and other MIDI.
|
||||
#[test]
|
||||
fn reset_keeps_params_and_one_release_per_key() {
|
||||
let mut q = vec![
|
||||
Event::Param { time: 0, id: 4, value: 0.5 },
|
||||
Event::NoteOn { time: 3, key: 60, velocity: 1.0 },
|
||||
Event::NoteOff { time: 5, key: 60 },
|
||||
Event::NoteOff { time: 9, key: 60 },
|
||||
Event::NoteOff { time: 12, key: 64 },
|
||||
Event::Midi { time: 20, data: [0x90, 62, 90] },
|
||||
Event::Midi { time: 21, data: [0xB0, 64, 127] },
|
||||
Event::Midi { time: 22, data: [0x80, 62, 0] },
|
||||
Event::Midi { time: 23, data: [0x80, 62, 40] },
|
||||
Event::Midi { time: 24, data: [0x83, 62, 0] },
|
||||
Event::Midi { time: 25, data: [0x91, 67, 0] },
|
||||
Event::Midi { time: 26, data: [0x80, 200, 0] },
|
||||
];
|
||||
keep_releases(&mut q);
|
||||
assert_eq!(
|
||||
q,
|
||||
vec![
|
||||
Event::Param { time: 0, id: 4, value: 0.5 },
|
||||
Event::NoteOff { time: 0, key: 60 },
|
||||
Event::NoteOff { time: 0, key: 64 },
|
||||
Event::Midi { time: 0, data: [0x80, 62, 0] },
|
||||
Event::Midi { time: 0, data: [0x83, 62, 0] },
|
||||
Event::Midi { time: 0, data: [0x91, 67, 0] },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A queue that parameter changes filled still releases every held key on a reset, once
|
||||
/// each, and keeps the parameter changes.
|
||||
#[test]
|
||||
fn a_full_queue_still_releases_every_held_key_on_reset() {
|
||||
for notes in [NoteMode::Clap, NoteMode::Midi] {
|
||||
let mut q = Vec::with_capacity(EVENT_CAP);
|
||||
for id in 0..EVENT_CAP as u32 {
|
||||
let _ = push_event(&mut q, Event::Param { time: 0, id, value: 0.0 }, false);
|
||||
}
|
||||
assert_eq!(q.len(), EVENT_CAP - RELEASE_RESERVE, "parameter changes leave the reserve free");
|
||||
// Keys 0..4 already have a release queued, and are held again after it.
|
||||
for key in 0..4u8 {
|
||||
let _ = push_event(&mut q, release_event(notes, 7, key).unwrap(), true);
|
||||
}
|
||||
let mut held = [true; 128];
|
||||
release_on_reset(&mut q, &mut held, notes);
|
||||
assert_eq!(held, [false; 128]);
|
||||
assert_eq!(q.iter().filter(|e| matches!(e, Event::Param { .. })).count(), EVENT_CAP - RELEASE_RESERVE, "{notes:?}");
|
||||
for key in 0..128u8 {
|
||||
let want = release_event(notes, 0, key).unwrap();
|
||||
assert_eq!(q.iter().filter(|e| **e == want).count(), 1, "{notes:?}: key {key} is released once");
|
||||
}
|
||||
assert_eq!(q.len(), EVENT_CAP);
|
||||
}
|
||||
}
|
||||
|
||||
/// A release that finds the queue full (parameter changes plus duplicate releases) leaves its
|
||||
/// key held, so the reset that follows still releases it, as the mix's `all_notes_off`
|
||||
/// then `reset` does on Stop.
|
||||
#[test]
|
||||
fn a_release_the_full_queue_drops_is_sent_by_the_next_reset() {
|
||||
for notes in [NoteMode::Clap, NoteMode::Midi] {
|
||||
let mut q = Vec::with_capacity(EVENT_CAP);
|
||||
let mut held = [false; 128];
|
||||
for id in 0..EVENT_CAP as u32 {
|
||||
let _ = push_event(&mut q, Event::Param { time: 0, id, value: 0.0 }, false);
|
||||
}
|
||||
// Key 0 pressed and released over and over fills the reserve with its releases.
|
||||
for t in 0..RELEASE_RESERVE as u32 {
|
||||
held[0] = true;
|
||||
queue_release(&mut q, &mut held, notes, t, 0);
|
||||
}
|
||||
assert_eq!(q.len(), EVENT_CAP, "{notes:?}: the queue is full");
|
||||
held[5] = true;
|
||||
queue_release(&mut q, &mut held, notes, 0, 5);
|
||||
assert!(held[5], "{notes:?}: a release that was not queued leaves its key held");
|
||||
release_on_reset(&mut q, &mut held, notes);
|
||||
for key in [0u8, 5] {
|
||||
let want = release_event(notes, 0, key).unwrap();
|
||||
assert_eq!(q.iter().filter(|e| **e == want).count(), 1, "{notes:?}: key {key} is released once");
|
||||
}
|
||||
assert_eq!(held, [false; 128]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A queue full of releases still has room to release every held key after a reset.
|
||||
#[test]
|
||||
fn reset_keeps_room_to_release_every_held_key() {
|
||||
let mut q = Vec::with_capacity(EVENT_CAP);
|
||||
for i in 0..EVENT_CAP {
|
||||
q.push(Event::NoteOff { time: i as u32, key: (i % 4) as u8 });
|
||||
}
|
||||
keep_releases(&mut q);
|
||||
let want: Vec<Event> = (0..4u8).map(|key| Event::NoteOff { time: 0, key }).collect();
|
||||
assert_eq!(q, want, "one release per key remains");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! (stepped enum A/B/C), hidden param 9, latency 3 samples.
|
||||
//! - `org.soundcraft.test.synth`: instrument, stereo out, CLAP note port; outputs a constant equal
|
||||
//! to the velocity of the held note (0 when none), switching at the event's sample offset. A
|
||||
//! MIDI CC 7 (volume) sets the constant to its value / 127.
|
||||
//! MIDI CC 7 (volume) sets the constant to its value / 127. `reset` leaves the note sounding.
|
||||
//!
|
||||
//! - `org.soundcraft.test.wide16`, `.wide17ports`, `.wide65ch`: effects advertising wide layouts
|
||||
//! (16 ports of 64 channels, 17 ports of 2 channels, one port of 65 channels per direction).
|
||||
@@ -166,9 +166,9 @@ unsafe extern "C" fn stop_processing(p: *const clap_plugin) {
|
||||
unsafe { fx(p) }.processing = false;
|
||||
}
|
||||
|
||||
unsafe extern "C" fn reset(p: *const clap_plugin) {
|
||||
unsafe { fx(p) }.level = 0.0;
|
||||
}
|
||||
/// Leaves the synth's note sounding. CLAP asks `reset` to kill voices, but some synths' doesn't
|
||||
/// (Surge XT's), so the host must send the releases itself.
|
||||
unsafe extern "C" fn reset(_p: *const clap_plugin) {}
|
||||
|
||||
fn apply(f: &mut Fx, h: &clap_event_header) {
|
||||
if h.space_id != CLAP_CORE_EVENT_SPACE_ID {
|
||||
|
||||
@@ -109,6 +109,48 @@ fn synth_plays_notes_at_their_offsets() {
|
||||
assert!(io[0][19].abs() < 1e-6 && (io[0][20] - 64.0 / 127.0).abs() < 1e-6, "controllers arrive as MIDI at their offset");
|
||||
}
|
||||
|
||||
/// Renders one 64-frame stereo block of `p` and returns its left channel.
|
||||
fn render_block(p: &mut dyn soundcraft_dsp::Plugin) -> Vec<f32> {
|
||||
let mut io = vec![vec![0.0f32; 64], vec![0.0f32; 64]];
|
||||
p.process(&mut io, 64);
|
||||
io.swap_remove(0)
|
||||
}
|
||||
|
||||
/// Stop, Play and Locate run `all_notes_off` then `reset`: the releases reach a synth whose own
|
||||
/// reset leaves its voices sounding, and a later block is silent (#1464).
|
||||
#[test]
|
||||
fn reset_delivers_the_releases_of_held_notes() {
|
||||
fixture_dir();
|
||||
let mut p = soundcraft_clap_host::create(SYNTH).unwrap();
|
||||
p.prepare(48_000.0, 64, 2);
|
||||
let silent = |b: &[f32]| b.iter().all(|x| x.abs() < 1e-6);
|
||||
|
||||
// As the mix engine does on Stop: release the held note, then reset.
|
||||
p.note_on(0, 60, 127);
|
||||
assert!(render_block(p.as_mut()).iter().all(|&x| (x - 1.0).abs() < 1e-6), "the note sounds");
|
||||
p.all_notes_off();
|
||||
p.reset();
|
||||
assert!(silent(&render_block(p.as_mut())), "all_notes_off's release was dropped by reset");
|
||||
|
||||
// A reset alone releases what is still held.
|
||||
p.note_on(0, 61, 127);
|
||||
render_block(p.as_mut());
|
||||
p.reset();
|
||||
assert!(silent(&render_block(p.as_mut())), "reset did not release the held note");
|
||||
|
||||
// A release queued for a block that never ran is still delivered.
|
||||
p.note_on(0, 62, 127);
|
||||
render_block(p.as_mut());
|
||||
p.note_off(30, 62);
|
||||
p.reset();
|
||||
assert!(silent(&render_block(p.as_mut())), "the pending release was dropped by reset");
|
||||
|
||||
// A note-on queued for the position being left is dropped, not played after the reset.
|
||||
p.note_on(10, 63, 127);
|
||||
p.reset();
|
||||
assert!(silent(&render_block(p.as_mut())), "a note-on from before the reset sounded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
|
||||
fixture_dir();
|
||||
|
||||
@@ -454,6 +454,50 @@ pub fn create_folder(dir: &Path) -> std::io::Result<()> {
|
||||
builder.create(dir)
|
||||
}
|
||||
|
||||
/// A folder at a predictable place in a shared folder (the system's temporary folder), for work only
|
||||
/// this account may see. It is created exclusively, owner-only; one that already exists must be a
|
||||
/// real folder (not a link) owned by this account and open to no one else, since another account
|
||||
/// could have made it first and could read, or swap, what is written into it.
|
||||
pub fn create_private_folder(dir: &Path) -> std::io::Result<()> {
|
||||
if let Some(parent) = dir.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||
match builder.create(dir) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => check_own_private_folder(dir),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn check_own_private_folder(dir: &Path) -> std::io::Result<()> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let refuse = |why: &str| Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, why.to_string()));
|
||||
let meta = std::fs::symlink_metadata(dir)?;
|
||||
if !meta.is_dir() {
|
||||
return refuse("it exists and is not a folder");
|
||||
}
|
||||
if meta.mode() & 0o077 != 0 {
|
||||
return refuse("it exists and is open to other accounts");
|
||||
}
|
||||
// This account's id, from a file it creates (std has no direct call for it).
|
||||
let probe = dir.parent().unwrap_or(dir).join(format!(".soundcraft-owner-{}", std::process::id()));
|
||||
let own = std::fs::OpenOptions::new().write(true).create_new(true).open(&probe).and_then(|f| f.metadata());
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
if own?.uid() != meta.uid() {
|
||||
return refuse("it exists and belongs to another account");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn check_own_private_folder(_dir: &Path) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let mut f = std::fs::File::create(path)?;
|
||||
f.write_all(bytes)?;
|
||||
@@ -663,6 +707,24 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_private_folder_is_refused_when_it_already_exists_open() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let base = folder("private-folder");
|
||||
let dir = base.join("soundcraft-recordings-1");
|
||||
create_private_folder(&dir).unwrap();
|
||||
create_private_folder(&dir).unwrap();
|
||||
assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700);
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(create_private_folder(&dir).is_err(), "open to others");
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let link = base.join("soundcraft-recordings-2");
|
||||
std::os::unix::fs::symlink(&dir, &link).unwrap();
|
||||
assert!(create_private_folder(&link).is_err(), "a link is not our folder");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_take_still_recording_at_a_crash_is_placed_on_recovery() {
|
||||
use crate::io::Take;
|
||||
|
||||
@@ -103,7 +103,7 @@ fn decode_group_audio(
|
||||
let bytes = crate::io::read_file(&full)?;
|
||||
let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
|
||||
let buf = crate::io::at_rate(buf, rate, crate::io::MAX_IMPORT_SAMPLES)
|
||||
.ok_or_else(|| EngineError::Io(format!("{rel}: too large to import")))?;
|
||||
.map_err(|r| EngineError::Io(format!("{rel}: {}", r.message("too large to import"))))?;
|
||||
decoded.push((rel.clone(), buf));
|
||||
index.insert(key, decoded.len() - 1);
|
||||
decoded.len() - 1
|
||||
|
||||
@@ -75,7 +75,7 @@ pub(crate) fn attach(e: &mut Engine, t: TrackId, name: &str, bytes: &[u8], path:
|
||||
return Err(EngineError::Io(format!("{name}: no audio")));
|
||||
}
|
||||
let buf = crate::io::at_rate(buf, e.session().sample_rate.hz(), MAX_SAMPLE_VALUES)
|
||||
.ok_or_else(|| EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")))?;
|
||||
.map_err(|r| EngineError::Io(format!("{name}: {}", r.message("too long for a sample (ten minutes of 192 kHz stereo at most)"))))?;
|
||||
let channels = buf.num_channels();
|
||||
let s = e.session_mut();
|
||||
let src = crate::io::add_source(s, name, buf, path, format.format);
|
||||
|
||||
@@ -7,18 +7,48 @@ use super::{
|
||||
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
|
||||
pub(crate) const MAX_IMPORT_SAMPLES: usize = 1 << 30;
|
||||
|
||||
/// `buf` converted to `rate`, unless it would then hold more than `limit` samples: a file with a
|
||||
/// tiny sample rate grows by orders of magnitude.
|
||||
pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> Option<AudioBuffer> {
|
||||
/// The most a file's audio may grow when converted to the session rate. Sessions run at 8 kHz to
|
||||
/// 768 kHz (96×); past this a file is lying about its rate, and converting a few kilobytes would
|
||||
/// otherwise allocate and filter hundreds of megabytes.
|
||||
const MAX_RATE_EXPANSION: u32 = 128;
|
||||
|
||||
/// Why [`at_rate`] refused a conversion.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum RateError {
|
||||
/// The converted audio would hold more samples than the limit.
|
||||
TooLarge,
|
||||
/// The file's rate is so far below the session's that converting would grow it past [`MAX_RATE_EXPANSION`].
|
||||
Ratio { from: u32, to: u32 },
|
||||
}
|
||||
|
||||
impl RateError {
|
||||
/// The refusal as a sentence: `size` says what a size refusal means to the caller.
|
||||
pub(crate) fn message(&self, size: &str) -> String {
|
||||
match self {
|
||||
RateError::TooLarge => size.to_string(),
|
||||
RateError::Ratio { from, to } => {
|
||||
format!("its {from} Hz sample rate is too low to convert to the session's {to} Hz (at most {MAX_RATE_EXPANSION}-fold)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `buf` converted to `rate`, unless it would then hold more than `limit` samples, or grow more
|
||||
/// than [`MAX_RATE_EXPANSION`]-fold: a file with a tiny sample rate grows by orders of magnitude.
|
||||
pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> std::result::Result<AudioBuffer, RateError> {
|
||||
let from = buf.sample_rate.max(1);
|
||||
// widened integers: dividing first would truncate 128.86-fold down to 128
|
||||
if u64::from(rate) > u64::from(from) * u64::from(MAX_RATE_EXPANSION) {
|
||||
return Err(RateError::Ratio { from, to: rate });
|
||||
}
|
||||
if buf.frames() as f64 * buf.num_channels() as f64 * f64::from(rate) / f64::from(from) > limit as f64 {
|
||||
return None;
|
||||
return Err(RateError::TooLarge);
|
||||
}
|
||||
if from != rate {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, from, rate);
|
||||
buf.sample_rate = rate;
|
||||
}
|
||||
Some(buf)
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// A relative `Audio Files/` path no other source in `s` already uses.
|
||||
@@ -115,7 +145,7 @@ pub fn decode_import(path: &str, rate: u32) -> Result<DecodedImport> {
|
||||
fn decode_import_bytes(name: &str, bytes: &[u8], rate: u32) -> Result<(soundcraft_audio_io::AudioInfo, Arc<SourceAudio>)> {
|
||||
let ext = Path::new(name).extension().and_then(OsStr::to_str).map(str::to_ascii_lowercase);
|
||||
let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
|
||||
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
|
||||
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).map_err(|e| EngineError::Io(format!("{name}: {}", e.message("file is too large to import"))))?;
|
||||
if buf.frames() == 0 {
|
||||
return Err(EngineError::Io(format!("{name}: no audio")));
|
||||
}
|
||||
@@ -215,8 +245,22 @@ mod rate_tests {
|
||||
#[test]
|
||||
fn a_tiny_sample_rate_is_refused_before_it_is_converted() {
|
||||
let buf = |rate| AudioBuffer { sample_rate: rate, channels: vec![vec![0.5; 100]; 2] };
|
||||
assert!(at_rate(buf(1), 48_000, 1_000_000).is_none());
|
||||
assert!(at_rate(buf(1), 48_000, 1_000_000).is_err());
|
||||
let ok = at_rate(buf(8_000), 48_000, 1_000_000).unwrap();
|
||||
assert_eq!((ok.sample_rate, ok.frames()), (48_000, 600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extreme_rate_expansion_is_refused_whatever_the_sample_budget() {
|
||||
let buf = |rate| AudioBuffer { sample_rate: rate, channels: vec![vec![0.5; 100]; 2] };
|
||||
// 100 frames at 300 Hz would fit the budget as 128 000 samples, but grow 640-fold.
|
||||
assert_eq!(at_rate(buf(300), 192_000, usize::MAX).unwrap_err(), RateError::Ratio { from: 300, to: 192_000 });
|
||||
assert!(at_rate(buf(1_500), 192_000, usize::MAX).is_ok(), "128-fold is the supported extreme");
|
||||
assert!(at_rate(buf(8_000), 768_000, usize::MAX).is_ok(), "the slowest file at the fastest session");
|
||||
// 128.86-fold: integer division would truncate it to 128 and let it through
|
||||
assert_eq!(at_rate(buf(1_490), 192_000, usize::MAX).unwrap_err(), RateError::Ratio { from: 1_490, to: 192_000 });
|
||||
// a short file that is only over the sample budget is a size refusal, not a rate one
|
||||
assert_eq!(at_rate(buf(48_000), 48_000, 10).unwrap_err(), RateError::TooLarge);
|
||||
assert!(RateError::Ratio { from: 300, to: 192_000 }.message("big").contains("300 Hz"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ pub(crate) fn open_session_linked(e: &mut Engine, path: &Path, links: &Path) ->
|
||||
/// Decode one source's audio file at the session rate.
|
||||
pub(super) fn decode_source(bytes: &[u8], ext: Option<&str>, rate: u32) -> std::result::Result<Arc<SourceAudio>, String> {
|
||||
let (_, buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?;
|
||||
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or("too large to load")?;
|
||||
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).map_err(|e| e.message("too large to load"))?;
|
||||
Ok(Arc::new(SourceAudio::new(buf)))
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,10 @@ impl ClipFx {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether the EQ or compressor is on: only they carry parameters that settle (a gain does not).
|
||||
pub(crate) fn has_smoothed_modules(&self) -> bool {
|
||||
self.eq.is_some() || self.comp.is_some()
|
||||
}
|
||||
pub(crate) fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
if let Some(eq) = &mut self.eq {
|
||||
eq.process(io, frames);
|
||||
|
||||
@@ -132,33 +132,85 @@ pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec<f32>]) -> Vec<Vec<f32>> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Block size for clip effects in [`render_clips`].
|
||||
const CLIP_FX_BLOCK: usize = 4096;
|
||||
/// Most a clip effect keeps rendering past its clip's end in [`render_clips`], in seconds.
|
||||
const MAX_CLIP_FX_TAIL_SECONDS: f64 = 10.0;
|
||||
/// Silent lead-in run before a clip with effects so smoothed parameters (EQ) settle, in seconds
|
||||
/// (the smoothers settle within ~0.3 s).
|
||||
const CLIP_FX_SETTLE_SECONDS: f64 = 1.0;
|
||||
/// Frames an effect advances its smoothed parameters by at a time (the EQ's chunk). Processing starts on a
|
||||
/// multiple of it from the selection's start, as a render of the whole selection would, so the smoothers
|
||||
/// step at the same samples.
|
||||
const CLIP_FX_CHUNK: usize = 32;
|
||||
/// A tail block whose peak is below this (-180 dBFS) ends a clip effect's tail.
|
||||
const CLIP_FX_TAIL_FLOOR: f32 = 1e-9;
|
||||
|
||||
/// Render only one track's clips (no mixer processing) — Consolidate.
|
||||
pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>> {
|
||||
let Some(t) = s.track(track) else { return Vec::new() };
|
||||
let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / t.channels().max(1));
|
||||
let mut out = vec![vec![0.0f32; len]; t.channels().max(1)];
|
||||
let mut scratch = Vec::new();
|
||||
// One reusable block for every clip with effects.
|
||||
let mut block: Vec<Vec<f32>> = Vec::new();
|
||||
let max_tail = s.sample_rate.samples(MAX_CLIP_FX_TAIL_SECONDS).max(0);
|
||||
for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) {
|
||||
match clip_fx_params(s, c.id.0) {
|
||||
None => render_clip(s, c, range.start, len, &mut out, &mut scratch),
|
||||
Some(params) => {
|
||||
let mut tmp = vec![vec![0.0f32; len]; out.len()];
|
||||
render_clip(s, c, range.start, len, &mut tmp, &mut scratch);
|
||||
let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, tmp.len());
|
||||
if block.len() != out.len() {
|
||||
block = vec![vec![0.0f32; CLIP_FX_BLOCK]; out.len()];
|
||||
}
|
||||
let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, out.len());
|
||||
fx.configure(params);
|
||||
let mut done = 0;
|
||||
while done < len {
|
||||
let n = (len - done).min(4096);
|
||||
let mut block: Vec<Vec<f32>> = tmp.iter().map(|ch| ch.get(done..done + n).map(<[f32]>::to_vec).unwrap_or_default()).collect();
|
||||
// The effects start from silence, so nothing comes out before the clip starts:
|
||||
// process from there through the clip and a bounded tail, not the whole selection.
|
||||
let offset = |at: Samples| usize::try_from(at.saturating_sub(range.start).max(0)).unwrap_or(usize::MAX).min(len);
|
||||
let first = offset(c.start);
|
||||
let clip_end = offset(c.end());
|
||||
let tail_end = offset(c.end().saturating_add(max_tail));
|
||||
// Smoothed parameters start at their defaults; settle them on silence, as the
|
||||
// full-span render did, so the clip opens at the configured setting.
|
||||
// (the clip's own samples start inside the first chunk when `first` is not on the grid)
|
||||
let grid = first - first % CLIP_FX_CHUNK;
|
||||
let mut lead = grid.min(usize::try_from(s.sample_rate.samples(CLIP_FX_SETTLE_SECONDS).max(0)).unwrap_or(0));
|
||||
lead -= lead % CLIP_FX_CHUNK;
|
||||
// a gain-only effect has no smoother to settle
|
||||
if !fx.has_smoothed_modules() {
|
||||
lead = 0;
|
||||
}
|
||||
while lead > 0 {
|
||||
let n = lead.min(CLIP_FX_BLOCK);
|
||||
for ch in &mut block {
|
||||
ch.fill(0.0);
|
||||
}
|
||||
fx.process(&mut block, n);
|
||||
lead -= n;
|
||||
}
|
||||
let mut done = grid;
|
||||
while done < len {
|
||||
let n = (len - done).min(CLIP_FX_BLOCK);
|
||||
for ch in &mut block {
|
||||
ch.fill(0.0);
|
||||
}
|
||||
if done < clip_end {
|
||||
render_clip(s, c, range.start.saturating_add(done as i64), n, &mut block, &mut scratch);
|
||||
}
|
||||
fx.process(&mut block, n);
|
||||
let mut peak = 0.0f32;
|
||||
for (o, b) in out.iter_mut().zip(block.iter()) {
|
||||
if let Some(dst) = o.get_mut(done..done + n) {
|
||||
for (x, y) in dst.iter_mut().zip(b.iter()) {
|
||||
*x += *y;
|
||||
peak = peak.max(y.abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
done += n;
|
||||
if done >= clip_end && (done >= tail_end || peak < CLIP_FX_TAIL_FLOOR) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1208,3 +1208,95 @@ fn gain_ramp_matches_converting_both_ends_every_sample() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Consolidate's clip-effect pass, processing every clip's effects across the whole selection
|
||||
/// (what `render_clips` did before it limited them to the clip and its tail).
|
||||
fn render_clips_full_span(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>> {
|
||||
let t = s.track(track).unwrap();
|
||||
let len = range.len() as usize;
|
||||
let mut out = vec![vec![0.0f32; len]; t.channels().max(1)];
|
||||
let mut scratch = Vec::new();
|
||||
for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) {
|
||||
let params = crate::clips::clip_fx_params(s, c.id.0).unwrap();
|
||||
let mut tmp = vec![vec![0.0f32; len]; out.len()];
|
||||
crate::clips::render_clip(s, c, range.start, len, &mut tmp, &mut scratch);
|
||||
let mut fx = crate::clips::ClipFx::new(48_000.0, tmp.len());
|
||||
fx.configure(params);
|
||||
for done in (0..len).step_by(4096) {
|
||||
let n = (len - done).min(4096);
|
||||
let mut block: Vec<Vec<f32>> = tmp.iter().map(|ch| ch[done..done + n].to_vec()).collect();
|
||||
fx.process(&mut block, n);
|
||||
for (o, b) in out.iter_mut().zip(&block) {
|
||||
o[done..done + n].iter_mut().zip(b).for_each(|(x, y)| *x += y);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidate_clip_effects_cover_the_clip_and_its_tail() {
|
||||
let fx_sets: [&[(&str, f64)]; 3] =
|
||||
[&[("comp.lookahead", 5.0), ("comp.threshold", -20.0), ("gain", -6.0)], &[("eq.hpf_on", 1.0), ("gain", 3.0)], &[("eq.low_shelf_gain", 12.0)]];
|
||||
for (k, params) in fx_sets.iter().enumerate() {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })));
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "dc", SourceId(900), 0, 20_000, 4800));
|
||||
for (param, value) in params.iter() {
|
||||
s.edit.values.insert(format!("clip_fx.{}.{param}", cid.0), *value);
|
||||
}
|
||||
let range = Range::new(5_000, 100_000);
|
||||
let got = render_clips(&s, t, range);
|
||||
let want = render_clips_full_span(&s, t, range);
|
||||
assert_eq!(got.len(), want.len());
|
||||
assert!(want[0].iter().any(|x| x.abs() > 0.05), "the fixture should sound");
|
||||
if k == 0 {
|
||||
// The compressor's lookahead output after the clip's end is kept.
|
||||
assert!(got[0][20_000 + 4800 - 5_000 + 10].abs() > 1e-3);
|
||||
}
|
||||
for (i, (a, b)) in got[0].iter().zip(&want[0]).enumerate() {
|
||||
assert!((a - b).abs() < 1e-5, "set {k}, sample {i}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A clip that starts a few frames into the selection, while the EQ's smoothers are still settling: the
|
||||
/// effect's 32-frame chunks must line up with the full-span render's.
|
||||
#[test]
|
||||
fn consolidate_clip_effects_keep_the_reference_chunk_grid() {
|
||||
for offset in [1i64, 31, 32, 33, 1000] {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })));
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "dc", SourceId(900), 0, offset, 4800));
|
||||
s.edit.values.insert(format!("clip_fx.{}.eq.low_shelf_gain", cid.0), 12.0);
|
||||
let range = Range::new(0, 20_000);
|
||||
let got = render_clips(&s, t, range);
|
||||
let want = render_clips_full_span(&s, t, range);
|
||||
for (i, (a, b)) in got[0].iter().zip(&want[0]).enumerate() {
|
||||
assert!((a - b).abs() < 1e-5, "clip at {offset}, sample {i}: {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidate_many_tiny_effect_clips_stays_fast() {
|
||||
// Thousands of one-sample clips with effects across a long selection: each one is processed
|
||||
// over its own span, not the whole selection.
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 1]] })));
|
||||
for i in 0..2000i64 {
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "x", SourceId(900), 0, i * 1000, 1));
|
||||
s.edit.values.insert(format!("clip_fx.{}.gain", cid.0), -6.0);
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
let out = render_clips(&s, t, Range::new(0, 4_000_000));
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
|
||||
assert!((out[0][1000] - 0.5 * db_to_gain(-6.0)).abs() < 1e-6);
|
||||
assert_eq!(out[0][1001], 0.0);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,10 @@ impl AutomationLane {
|
||||
// Widen so far-apart breakpoints cannot overflow i64.
|
||||
let span = i128::from(b.at) - i128::from(a.at);
|
||||
let off = i128::from(at) - i128::from(a.at);
|
||||
a.value + (b.value - a.value) * (off as f64 / span as f64) as f32
|
||||
// Interpolate in f64: the f32 difference overflows for
|
||||
// opposite-sign values near f32::MAX.
|
||||
let (av, bv) = (f64::from(a.value), f64::from(b.value));
|
||||
(av + (bv - av) * (off as f64 / span as f64)) as f32
|
||||
}
|
||||
(Some(a), _) => a.value,
|
||||
_ => default,
|
||||
@@ -281,6 +284,17 @@ mod tests {
|
||||
assert!(w.is_finite() && (-10.0..=0.0).contains(&w));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_at_opposite_sign_extremes_stays_finite() {
|
||||
let mut l = AutomationLane::new(AutoParam::Plugin { slot: 0, param: "Cutoff".into() });
|
||||
l.set_point(0, -3.0e38);
|
||||
l.set_point(1000, 3.0e38);
|
||||
let v = l.value_at(500, 0.0);
|
||||
assert!(v.is_finite() && v.abs() < 1.0e32, "mid value {v}");
|
||||
let w = l.value_at(1, 0.0);
|
||||
assert!(w.is_finite() && w < -2.9e38, "early value {w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_label_max_does_not_overflow() {
|
||||
assert_eq!(AutoParam::Pan(255).label(), "pan 256");
|
||||
|
||||
@@ -155,7 +155,13 @@ impl SoundApp {
|
||||
/// autosave folder. Returns the takes it records.
|
||||
fn arm_recorder(&mut self) -> Result<Vec<soundcraft_engine::io::Take>, String> {
|
||||
let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name);
|
||||
let unsaved_dir = self.autosave_dir.as_ref().map_or_else(std::env::temp_dir, |d| d.join(name));
|
||||
// Without autosave storage the takes of a session never saved go to a folder of their own under
|
||||
// the shared temporary folder, made private below, not into the shared folder itself.
|
||||
let shared_temp = self.autosave_dir.is_none();
|
||||
let unsaved_dir = self
|
||||
.autosave_dir
|
||||
.as_ref()
|
||||
.map_or_else(|| std::env::temp_dir().join(format!("soundcraft-recordings-{}", std::process::id())), |d| d.join(name));
|
||||
let Some(r) = &mut self.recorder else { return Err("no audio input device".into()) };
|
||||
let plan = soundcraft_engine::io::plan_takes(&self.engine, &unsaved_dir, r.channels);
|
||||
if plan.is_empty() {
|
||||
@@ -163,9 +169,17 @@ impl SoundApp {
|
||||
}
|
||||
// The takes are unsaved work: the folder they go into under the autosave root is
|
||||
// owner-only, like the autosaves, before the recorder makes anything in it.
|
||||
if self.engine.path.is_none() && self.autosave_dir.is_some() {
|
||||
if self.engine.path.is_none() {
|
||||
if shared_temp {
|
||||
soundcraft_engine::autosave::create_private_folder(&unsaved_dir).map_err(|e| format!("{}: {e}", unsaved_dir.display()))?;
|
||||
}
|
||||
for dir in plan.iter().filter_map(|(_, _, path)| path.parent()) {
|
||||
soundcraft_engine::autosave::create_folder(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let made = if shared_temp {
|
||||
soundcraft_engine::autosave::create_private_folder(dir)
|
||||
} else {
|
||||
soundcraft_engine::autosave::create_folder(dir)
|
||||
};
|
||||
made.map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
}
|
||||
let takes = plan
|
||||
|
||||
@@ -22,7 +22,7 @@ FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
TEST=1
|
||||
OCI=0
|
||||
TARBALL=""
|
||||
while [ $# -gt 0 ]; do
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-test) TEST=0; shift ;;
|
||||
--oci) OCI=1; shift ;;
|
||||
@@ -40,7 +40,7 @@ case "$ARCH" in
|
||||
esac
|
||||
BASENAME="soundcraft-$VERSION-linux-$ARCH"
|
||||
TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}"
|
||||
[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
|
||||
[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
|
||||
for tool in flatpak flatpak-builder; do
|
||||
command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; }
|
||||
done
|
||||
@@ -51,7 +51,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle"
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/stage"
|
||||
tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1
|
||||
[ -x "$WORK/stage/bin/soundcraft" ] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; }
|
||||
[[ -x "$WORK/stage/bin/soundcraft" ]] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; }
|
||||
cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml"
|
||||
|
||||
flatpak remote-add --user --if-not-exists flathub "$FLATHUB"
|
||||
@@ -70,7 +70,7 @@ if [[ "$OCI" = 1 ]]; then
|
||||
echo "wrote OCI layout to $OUT_OCI"
|
||||
fi
|
||||
|
||||
if [ "$TEST" = 1 ]; then
|
||||
if [[ "$TEST" = 1 ]]; then
|
||||
flatpak install --user -y --noninteractive --reinstall "$OUT"
|
||||
flatpak info --user "$APP_ID"
|
||||
flatpak run --command=soundcraft-cli "$APP_ID" --version
|
||||
|
||||
Reference in New Issue
Block a user