Merge remote-tracking branch 'origin/main' into m/983

This commit is contained in:
Brandon Thomas
2026-10-24 00:54:53 -04:00
189 changed files with 891 additions and 5142 deletions
+8
View File
@@ -12,6 +12,14 @@ updates:
cargo-dependencies: cargo-dependencies:
patterns: patterns:
- "*" - "*"
# craft-ui is on egui 0.36 until storytold/craft-libs#199 lands.
ignore:
- dependency-name: "egui"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "eframe"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "egui_kittest"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
commit-message: commit-message:
prefix: "chore(deps)" prefix: "chore(deps)"
+27
View File
@@ -0,0 +1,27 @@
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
libgl1-mesa-dev libasound2-dev
+5 -6
View File
@@ -3,20 +3,19 @@
# player falls back to its silent clock when the VM has no sound device. # player falls back to its silent clock when the VM has no sound device.
name: FreeBSD name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push # The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# or dependency change: a drifted dependency still shows up within a day, and always before a # request: a drifted dependency still shows up within a day, and always before a release ships.
# release ships. PRs that change the FreeBSD packaging itself still run it. # PRs that change the FreeBSD workflow or packaging itself still run it.
on: on:
schedule:
- cron: "47 3 * * *"
push: push:
branches: [release] branches: [release]
pull_request: pull_request:
paths: paths:
- "crates/playback/**"
- .github/workflows/freebsd.yml - .github/workflows/freebsd.yml
- packaging/freebsd/** - packaging/freebsd/**
- packaging/env.sh - packaging/env.sh
schedule:
- cron: "47 3 * * *"
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
+38
View File
@@ -21,3 +21,41 @@ jobs:
nix: nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
-3
View File
@@ -10,9 +10,6 @@ on:
type: string type: string
default: "" default: ""
permissions:
contents: write
concurrency: concurrency:
group: release-${{ github.ref }} group: release-${{ github.ref }}
cancel-in-progress: false cancel-in-progress: false
+2 -1
View File
@@ -11,7 +11,8 @@ permissions:
concurrency: concurrency:
group: sonarcloud-${{ github.ref }} group: sonarcloud-${{ github.ref }}
cancel-in-progress: true # A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs: jobs:
sonarcloud: sonarcloud:
Generated
+9
View File
@@ -906,6 +906,14 @@ name = "craft-docpath"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-media"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4"
dependencies = [
"thiserror 2.0.21",
]
[[package]] [[package]]
name = "craft-ui" name = "craft-ui"
version = "0.1.0" version = "0.1.0"
@@ -3798,6 +3806,7 @@ dependencies = [
name = "soundcraft-video" name = "soundcraft-video"
version = "0.12.1" version = "0.12.1"
dependencies = [ dependencies = [
"craft-media",
"image", "image",
"proptest", "proptest",
"rayon", "rayon",
+5
View File
@@ -47,6 +47,9 @@ cpal = "0.18"
# Which file paths stored in a session may be opened. # Which file paths stored in a session may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# Video containers and bitstreams, shared with the other craft apps.
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4" }
# ui # ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# egui 0.37 needs craft-libs#199 (craft-ui) first; pin 0.36 until it lands. # egui 0.37 needs craft-libs#199 (craft-ui) first; pin 0.36 until it lands.
@@ -67,9 +70,11 @@ type_complexity = "allow"
[profile.dev.package."*"] [profile.dev.package."*"]
opt-level = 2 opt-level = 2
debug = false
[profile.dev] [profile.dev]
opt-level = 1 opt-level = 1
debug = "line-tables-only"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
+3 -3
View File
@@ -110,7 +110,7 @@ fn info(args: &[String]) -> ExitCode {
Ok(b) => b, Ok(b) => b,
Err(e) => return fail(format!("{path}: {e}")), Err(e) => return fail(format!("{path}: {e}")),
}; };
let ext = std::path::Path::new(path).extension().and_then(|x| x.to_str()); let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str);
match soundcraft_audio_io::probe(&bytes, ext) { match soundcraft_audio_io::probe(&bytes, ext) {
Ok(i) => { Ok(i) => {
outln!("{}", serde_json::to_string_pretty(&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf})).unwrap_or_default()); outln!("{}", serde_json::to_string_pretty(&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf})).unwrap_or_default());
@@ -134,7 +134,7 @@ fn convert(args: &[String]) -> ExitCode {
Ok(b) => b, Ok(b) => b,
Err(e) => return fail(format!("{input}: {e}")), Err(e) => return fail(format!("{input}: {e}")),
}; };
let ext = std::path::Path::new(input).extension().and_then(|x| x.to_str()); let ext = std::path::Path::new(input).extension().and_then(std::ffi::OsStr::to_str);
let (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) { let (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
Ok(x) => x, Ok(x) => x,
Err(e) => return fail(format!("{input}: {e}")), Err(e) => return fail(format!("{input}: {e}")),
@@ -146,7 +146,7 @@ fn convert(args: &[String]) -> ExitCode {
if args.iter().any(|a| a == "--normalize") { if args.iter().any(|a| a == "--normalize") {
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false); soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
} }
let out_ext = std::path::Path::new(output).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase(); let out_ext = std::path::Path::new(output).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else { let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else {
return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS)); return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS));
}; };
@@ -1,6 +1,5 @@
//! `soundcraft-cli mcp` with the automation root flags, run as the real binary. //! `soundcraft-cli mcp` with the automation root flags, run as the real binary.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
+5
View File
@@ -0,0 +1,5 @@
//! Integration tests, built as one test binary: each file is a module.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod cli;
mod closed_stdout;
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>SoundCraft</title> <title>SoundCraft</title>
<link data-trunk rel="icon" type="image/png" href="../../assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" /> <link data-trunk rel="icon" type="image/png" href="../../assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" />
<link data-trunk rel="rust" data-bin="soundcraft-web" data-wasm-opt="z" data-weak-refs /> <link data-trunk rel="rust" data-bin="soundcraft-web" data-wasm-opt="z" data-weak-refs />
+1 -1
View File
@@ -182,7 +182,7 @@ fn services() -> Services {
if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) { if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) {
d = d.set_directory(dir); d = d.set_directory(dir);
} }
if let Some(n) = path.file_name().and_then(|n| n.to_str()) { if let Some(n) = path.file_name().and_then(std::ffi::OsStr::to_str) {
d = d.set_file_name(n); d = d.set_file_name(n);
} }
d.save_file().map(|p| p.to_string_lossy().into_owned()) d.save_file().map(|p| p.to_string_lossy().into_owned())
+2 -2
View File
@@ -192,7 +192,7 @@ pub enum AuError {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod api { mod api {
use super::*; use super::{AuDescriptor, AuError, Plugin, PluginInfo};
pub fn scan() -> Vec<AuDescriptor> { pub fn scan() -> Vec<AuDescriptor> {
registry::scan() registry::scan()
@@ -210,7 +210,7 @@ mod api {
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
mod api { mod api {
use super::*; use super::{AuDescriptor, AuError, Plugin, PluginInfo};
pub fn scan() -> Vec<AuDescriptor> { pub fn scan() -> Vec<AuDescriptor> {
Vec::new() Vec::new()
+4
View File
@@ -0,0 +1,4 @@
//! Integration tests, built as one test binary: each file is a module.
#[cfg(target_os = "macos")]
mod system_units;
@@ -1,6 +1,5 @@
//! End to end against the Audio Units Apple ships with macOS: scan → instantiate → params → //! End to end against the Audio Units Apple ships with macOS: scan → instantiate → params →
//! process → state, plus an instrument playing a note. Skips (passes) when a unit is missing. //! process → state, plus an instrument playing a note. Skips (passes) when a unit is missing.
#![cfg(target_os = "macos")]
use soundcraft_dsp::{Category, Plugin}; use soundcraft_dsp::{Category, Plugin};
+5 -1
View File
@@ -95,12 +95,16 @@ fn expected_probe_format(marker: &[u8; 4]) -> &'static [&'static str] {
/// check has to run before the stream is decoded. /// check has to run before the stream is decoded.
const SIZE_CHECKED_PROBE_FORMATS: &[&str] = &["caf", "flac", "isomp4", "wave"]; const SIZE_CHECKED_PROBE_FORMATS: &[&str] = &["caf", "flac", "isomp4", "wave"];
/// Find the first container marker at or after `at`, within symphonia's probe depth. /// Find the first container marker at or after `at`, within symphonia's probe depth. A RIFF form
/// other than WAVE is skipped, as the probe skips it and reads a WAVE behind it.
fn find_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) -> Option<(usize, &'a [u8; 4])> { fn find_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) -> Option<(usize, &'a [u8; 4])> {
let end = bytes.len().min(at.saturating_add(MAX_PROBE_DEPTH)); let end = bytes.len().min(at.saturating_add(MAX_PROBE_DEPTH));
for pos in at..end.saturating_sub(3) { for pos in at..end.saturating_sub(3) {
let window = bytes.get(pos..pos + 4)?; let window = bytes.get(pos..pos + 4)?;
if let Some(marker) = markers.iter().find(|marker| window == marker.as_slice()) { if let Some(marker) = markers.iter().find(|marker| window == marker.as_slice()) {
if matches!(*marker, b"RIFF" | b"RF64" | b"BW64") && bytes.get(pos + 8..pos + 12) != Some(&b"WAVE"[..]) {
continue;
}
return Some((pos, *marker)); return Some((pos, *marker));
} }
} }
+7
View File
@@ -0,0 +1,7 @@
//! Integration tests, built as one test binary: each file is a module.
mod encode_format;
mod malformed;
mod roundtrip;
mod stream;
mod wav_empty_data;
@@ -328,6 +328,18 @@ fn wav_info_entries_beyond_the_file_are_refused() {
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
} }
#[test]
fn a_wave_behind_another_riff_form_is_checked() {
let info = [&b"INFO"[..], b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat();
let mut wav = riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])]);
wav[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
let list = wav.windows(4).position(|w| w == b"LIST").unwrap();
wav[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes());
// The probe skips the AVI form and reads the WAVE behind it.
let f = [&b"RIFF"[..], &4u32.to_le_bytes(), b"AVI ", &wav].concat();
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
#[test] #[test]
fn wav_info_entries_are_found_where_symphonia_reads_them() { fn wav_info_entries_are_found_where_symphonia_reads_them() {
let list = [&b"LIST"[..], &0x7fff_fffcu32.to_le_bytes(), b"INFO", b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat(); let list = [&b"LIST"[..], &0x7fff_fffcu32.to_le_bytes(), b"INFO", b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat();
@@ -455,12 +467,10 @@ fn garbage_in_compressed_containers_errors() {
/// without its own checks: the reader is refused when the probed format disagrees. /// without its own checks: the reader is refused when the probed format disagrees.
#[test] #[test]
fn a_probed_container_that_disagrees_with_the_checked_marker_is_refused() { fn a_probed_container_that_disagrees_with_the_checked_marker_is_refused() {
let flac = small(FileFormat::Flac, BitDepth::Int24, 2); // The junk byte hides both magics, so the hint picks the marker: the FLAC is checked, but the
let mut bytes = b"RIFF".to_vec(); // probe reads the WAVE before it.
bytes.extend_from_slice(&3u32.to_le_bytes()); let bytes = [vec![0], small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Flac, BitDepth::Int24, 2)].concat();
bytes.extend_from_slice(b"FAKE"); match decode(&bytes, Some("flac")) {
bytes.extend_from_slice(&flac);
match decode(&bytes, None) {
Err(AudioError::Malformed(message)) => assert!(message.contains("probed container"), "unexpected message: {message}"), Err(AudioError::Malformed(message)) => assert!(message.contains("probed container"), "unexpected message: {message}"),
Err(other) => panic!("unexpected error kind: {other:?}"), Err(other) => panic!("unexpected error kind: {other:?}"),
Ok(_) => panic!("the mismatched container must be refused"), Ok(_) => panic!("the mismatched container must be refused"),
@@ -1,7 +1,6 @@
//! `Headless::with_automation_roots` through the real MCP protocol: every file a command reads //! `Headless::with_automation_roots` through the real MCP protocol: every file a command reads
//! stays inside the read root, every file it writes inside the write root. //! stays inside the read root, every file it writes inside the write root.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::{Value, json}; use serde_json::{Value, json};
use soundcraft_automation::{Headless, mcp::Server}; use soundcraft_automation::{Headless, mcp::Server};
+5
View File
@@ -0,0 +1,5 @@
//! Integration tests, built as one test binary: each file is a module.
mod agent_tasks;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod automation_roots;
+2 -2
View File
@@ -88,7 +88,7 @@ pub enum ClapError {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
mod api { mod api {
use super::*; use super::{ClapDescriptor, ClapError, Path, PathBuf, Plugin, PluginInfo, registry};
pub fn cached() -> Option<Vec<ClapDescriptor>> { pub fn cached() -> Option<Vec<ClapDescriptor>> {
registry::cached() registry::cached()
@@ -118,7 +118,7 @@ mod api {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
mod api { mod api {
use super::*; use super::{ClapDescriptor, ClapError, Path, PathBuf, Plugin, PluginInfo};
pub fn cached() -> Option<Vec<ClapDescriptor>> { pub fn cached() -> Option<Vec<ClapDescriptor>> {
Some(Vec::new()) Some(Vec::new())
+1 -1
View File
@@ -51,7 +51,7 @@ pub fn os_search_paths(home: Option<PathBuf>, common_program_files: Option<PathB
/// True when the path names a CLAP plugin (`*.clap`, case-insensitive). /// True when the path names a CLAP plugin (`*.clap`, case-insensitive).
pub fn is_clap_path(p: &Path) -> bool { pub fn is_clap_path(p: &Path) -> bool {
p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("clap")) p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("clap"))
} }
/// Recursively collects `.clap` files and bundles under `dirs` (missing directories are skipped). /// Recursively collects `.clap` files and bundles under `dirs` (missing directories are skipped).
@@ -3,8 +3,7 @@
use soundcraft_dsp::{Category, Unit}; use soundcraft_dsp::{Category, Unit};
mod common; use crate::common::fixture_dir;
use common::fixture_dir;
const GAIN: &str = "clap:org.soundcraft.test.gain"; const GAIN: &str = "clap:org.soundcraft.test.gain";
const SYNTH: &str = "clap:org.soundcraft.test.synth"; const SYNTH: &str = "clap:org.soundcraft.test.synth";
+6
View File
@@ -0,0 +1,6 @@
//! Integration tests, built as one test binary: each file is a module.
#[path = "../common/mod.rs"]
mod common;
mod fixture_e2e;
+7
View File
@@ -0,0 +1,7 @@
//! Integration tests, built as one test binary: each file is a module.
mod fuzz;
mod offline_meters;
mod offline_normalize;
mod plugins;
mod transients;
+42 -3
View File
@@ -176,11 +176,25 @@ struct Autosave {
} }
impl Autosave { impl Autosave {
/// A new folder under `root` for the session called `name`. /// A new folder under `root` for the session called `name`. Two sessions started within the
/// same millisecond get folders of their own, never a shared one.
fn create(root: &Path, name: &str) -> Result<Autosave> { fn create(root: &Path, name: &str) -> Result<Autosave> {
let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis(); let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis();
let dir = root.join(format!("{} {stamp}-{}", sanitize_name(name), std::process::id())); let base = format!("{} {stamp}-{}", sanitize_name(name), std::process::id());
let lock = std::fs::create_dir_all(&dir).and_then(|()| std::fs::File::create(dir.join(LOCK))).map_err(|err| io_error(&dir, err))?; std::fs::create_dir_all(root).map_err(|err| io_error(root, err))?;
let mut dir = root.join(&base);
let mut n = 1;
loop {
match std::fs::create_dir(&dir) {
Ok(()) => break,
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
n += 1;
dir = root.join(format!("{base}-{n}"));
}
Err(err) => return Err(io_error(&dir, err)),
}
}
let lock = std::fs::File::create(dir.join(LOCK)).map_err(|err| io_error(&dir, err))?;
lock.try_lock().map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?; lock.try_lock().map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?;
Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1, recording: false }) Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1, recording: false })
} }
@@ -427,6 +441,14 @@ mod tests {
dir dir
} }
/// Moves a saved session file's mtime a second into the past. Filesystems with coarse
/// timestamps (tmpfs) can give it and an autosave written right after it the same mtime,
/// and `leftovers` only offers autosaves strictly newer than the file they came from.
fn backdate(path: &Path) {
let then = SystemTime::now() - Duration::from_secs(1);
std::fs::File::options().write(true).open(path).unwrap().set_modified(then).unwrap();
}
fn record(e: &mut Engine, level: f32) { fn record(e: &mut Engine, level: f32) {
let before = e.session_arc(); let before = e.session_arc();
let s = e.session_mut(); let s = e.session_mut();
@@ -453,6 +475,7 @@ mod tests {
let mut e = Engine::default(); let mut e = Engine::default();
record(&mut e, 0.5); record(&mut e, 0.5);
crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap(); crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap();
backdate(&path);
record(&mut e, -0.25); record(&mut e, -0.25);
let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap(); let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap();
a.write(e.session(), e.path.as_deref(), None).unwrap(); a.write(e.session(), e.path.as_deref(), None).unwrap();
@@ -590,6 +613,21 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
fn sessions_started_together_get_folders_of_their_own() {
// #579: the folder name has millisecond resolution, so two sessions created back to back
// shared one folder and the second overwrote the first's unsaved work.
let dir = folder("autosave-unique");
let root = dir.join("Autosave");
let made: Vec<Autosave> = (0..20).map(|_| Autosave::create(&root, "Song").unwrap()).collect();
let mut dirs: Vec<&PathBuf> = made.iter().map(|a| &a.dir).collect();
dirs.sort();
dirs.dedup();
assert_eq!(dirs.len(), made.len());
drop(made);
let _ = std::fs::remove_dir_all(&dir);
}
#[test] #[test]
fn unsaved_work_is_offered_once_its_session_closes() { fn unsaved_work_is_offered_once_its_session_closes() {
let dir = folder("autosave-leftovers"); let dir = folder("autosave-leftovers");
@@ -598,6 +636,7 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&dir).unwrap();
let mut e = Engine::default(); let mut e = Engine::default();
crate::io::save_session(&mut e, &path).unwrap(); crate::io::save_session(&mut e, &path).unwrap();
backdate(Path::new(&path));
record(&mut e, 0.5); record(&mut e, 0.5);
let mut a = Autosave::create(&root, "Song").unwrap(); let mut a = Autosave::create(&root, "Song").unwrap();
a.write(e.session(), e.path.as_deref(), None).unwrap(); a.write(e.session(), e.path.as_deref(), None).unwrap();
+30 -20
View File
@@ -50,7 +50,7 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
}; };
let n = clips.len(); let n = clips.len();
lanes.sort_by_key(|(i, _)| *i); lanes.sort_by_key(|(i, _)| *i);
let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("Clip Group").to_string(); let stem = p.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Clip Group").to_string();
let audio_dir_name = format!("{} Audio", crate::io::sanitize_name(&stem)); let audio_dir_name = format!("{} Audio", crate::io::sanitize_name(&stem));
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default(); let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
let mut audio = BTreeMap::new(); let mut audio = BTreeMap::new();
@@ -80,26 +80,17 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
Ok(n) Ok(n)
} }
/// Import a clip group file at `at`, one lane per track starting at `first` (new audio tracks fn decode_group_audio(
/// are made when the session runs out). Returns the ids of the placed clips. audio_entries: &BTreeMap<u64, String>,
pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -> Result<Vec<ClipId>> { dir: &Path,
let id = "file.import_clip_groups"; rate: u32,
let text = crate::io::read_text(path)?; cmd_id: &str,
let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?; ) -> Result<(Vec<(String, soundcraft_audio_io::AudioBuffer)>, BTreeMap<u64, usize>)> {
if file.format != FORMAT {
return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into()));
}
if file.lanes.iter().map(|l| l.len().max(1)).sum::<usize>() > MAX_CLIPS || file.audio.len() > MAX_CLIPS {
return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into()));
}
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
// Decode audio first so a bad file changes nothing, and each file once however many ids name it.
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new()); let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
let rate = e.session().sample_rate.hz(); for (old, rel) in audio_entries {
for (old, rel) in &file.audio {
let full = dir.join(rel); let full = dir.join(rel);
if !full.starts_with(&dir) || rel.contains("..") { if !full.starts_with(dir) || rel.contains("..") {
return Err(EngineError::BadParams(id.into(), format!("audio path escapes the group folder: {rel}"))); return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
} }
let key = crate::io::file_key(&full); let key = crate::io::file_key(&full);
let n = match index.get(&key) { let n = match index.get(&key) {
@@ -116,6 +107,25 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
}; };
file_of.insert(*old, n); file_of.insert(*old, n);
} }
Ok((decoded, file_of))
}
/// Import a clip group file at `at`, one lane per track starting at `first` (new audio tracks
/// are made when the session runs out). Returns the ids of the placed clips.
pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -> Result<Vec<ClipId>> {
let id = "file.import_clip_groups";
let text = crate::io::read_text(path)?;
let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?;
if file.format != FORMAT {
return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into()));
}
if file.lanes.iter().map(|l| l.len().max(1)).sum::<usize>() > MAX_CLIPS || file.audio.len() > MAX_CLIPS {
return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into()));
}
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
// Decode audio first so a bad file changes nothing, and each file once however many ids name it.
let rate = e.session().sample_rate.hz();
let (decoded, file_of) = decode_group_audio(&file.audio, &dir, rate, id)?;
let src_rate = f64::from(file.sample_rate.max(1)); let src_rate = f64::from(file.sample_rate.max(1));
let s = e.session_mut(); let s = e.session_mut();
let ratio = f64::from(s.sample_rate.hz()) / src_rate; let ratio = f64::from(s.sample_rate.hz()) / src_rate;
@@ -123,7 +133,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
let sources: Vec<SourceId> = decoded let sources: Vec<SourceId> = decoded
.into_iter() .into_iter()
.map(|(rel, buf)| { .map(|(rel, buf)| {
let name = Path::new(&rel).file_name().and_then(|x| x.to_str()).unwrap_or("clip group").to_string(); let name = Path::new(&rel).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("clip group").to_string();
crate::io::add_source(s, &name, buf, None, FileFormat::Wav) crate::io::add_source(s, &name, buf, None, FileFormat::Wav)
}) })
.collect(); .collect();
+3 -3
View File
@@ -1,9 +1,9 @@
//! Edit › Copy Special › Audio as MIDI: pitch-track the selected audio into MIDI notes on the //! Edit › Copy Special › Audio as MIDI: pitch-track the selected audio into MIDI notes on the
//! clipboard (paste them onto a MIDI or instrument track). //! clipboard (paste them onto a MIDI or instrument track).
use super::{CommandSpec, bad, has_range, range_param, tracks_required};
use super::*; use crate::{Clipboard, Engine, Result, cmd};
use crate::{Clipboard, cmd}; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_midi::{Note, Sequence}; use soundcraft_midi::{Note, Sequence};
use soundcraft_model::{Clip, ClipId}; use soundcraft_model::{Clip, ClipId};
+4 -3
View File
@@ -1,8 +1,9 @@
//! AudioSuite-style offline processing of selected clips. //! AudioSuite-style offline processing of selected clips.
use super::{CommandSpec, bad, clip_ids_param, has_selection, range_param, str_param, tracks_param};
use soundcraft_time::Range;
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_audio_io::{AudioBuffer, FileFormat}; use soundcraft_audio_io::{AudioBuffer, FileFormat};
use soundcraft_model::{Clip, ClipContent, TrackKind}; use soundcraft_model::{Clip, ClipContent, TrackKind};
+2 -2
View File
@@ -1,9 +1,9 @@
//! Third-party CLAP plugins: discovery (`engine.clap_plugins`) and id lookup shared by the mixer //! Third-party CLAP plugins: discovery (`engine.clap_plugins`) and id lookup shared by the mixer
//! commands. Hosting itself lives in `soundcraft-clap-host`. //! commands. Hosting itself lives in `soundcraft-clap-host`.
use super::{CommandSpec, always};
use super::*;
use crate::cmd; use crate::cmd;
use serde_json::json; use serde_json::{Value, json};
use soundcraft_dsp::PluginInfo; use soundcraft_dsp::PluginInfo;
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
+5 -3
View File
@@ -1,8 +1,10 @@
//! Clip menu commands. //! Clip menu commands.
use super::{CommandSpec, always, bad, clip_ids_param, f64_or, has_range, has_selection, i64_or, position_param, str_param, track_param};
use soundcraft_model::ClipId;
use soundcraft_time::Samples;
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
+4 -4
View File
@@ -1,9 +1,9 @@
//! Clip List commands. //! Clip List commands.
use super::{CommandSpec, bad, bool_or};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json; use soundcraft_model::{ClipId, SourceId};
use soundcraft_model::SourceId;
use std::collections::BTreeSet; use std::collections::BTreeSet;
const CLEAR: &str = "clip.clear"; const CLEAR: &str = "clip.clear";
+3 -3
View File
@@ -1,10 +1,10 @@
//! More Clip menu commands: Regroup, Unloop, Clip Effects bypass/render, Conform to Tempo and //! More Clip menu commands: Regroup, Unloop, Clip Effects bypass/render, Conform to Tempo and
//! Remove Pitch Shift. //! Remove Pitch Shift.
use super::{CommandSpec, bad, clip_ids_param, has_selection, str_param};
use super::more_util::{render_stretched, replace_clip}; use super::more_util::{render_stretched, replace_clip};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::{Clip, ClipContent, ClipId, Session, TrackId}; use soundcraft_model::{Clip, ClipContent, ClipId, Session, TrackId};
use soundcraft_time::Samples; use soundcraft_time::Samples;
+8 -4
View File
@@ -1,11 +1,15 @@
//! Edit menu commands. //! Edit menu commands.
use super::{
CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, f64_or, has_clipboard, has_range, has_selection,
has_tracks, i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required,
};
use soundcraft_time::Samples;
use super::more_util::toggle_flag; use super::more_util::toggle_flag;
use super::*;
use crate::edit as ops; use crate::edit as ops;
use crate::{Clipboard, cmd}; use crate::{Clipboard, Engine, Result, cmd};
use serde_json::json; use serde_json::{Value, json};
use soundcraft_model::{EditMode, Fade, FadeShape, Session}; use soundcraft_model::{ClipId, EditMode, Fade, FadeShape, Session, TrackId};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
+5 -3
View File
@@ -1,11 +1,13 @@
//! More Edit menu commands: Cut/Copy/Clear Special variants, Paste Special, selection helpers, //! More Edit menu commands: Cut/Copy/Clear Special variants, Paste Special, selection helpers,
//! Space Clips, Snap to, Copy/Move Selection to playlists, TCE, and automation coalesce/trim/glide. //! Space Clips, Snap to, Copy/Move Selection to playlists, TCE, and automation coalesce/trim/glide.
use super::{
CommandSpec, always, bad, clip_ids_param, has_clipboard, has_range, has_selection, position_param, range_param, str_param, tracks_required,
};
use super::more_util::{clip_db, render_stretched, replace_clip, set_clip_env, target_playlist}; use super::more_util::{clip_db, render_stretched, replace_clip, set_clip_env, target_playlist};
use super::*;
use crate::edit as ops; use crate::edit as ops;
use crate::{Clipboard, TransportRequest, cmd}; use crate::{Clipboard, Engine, Result, TransportRequest, cmd};
use serde_json::json; use serde_json::{Value, json};
use soundcraft_model::{AutoParam, AutomationLane, Clip, ClipContent, ClipId, EditMode, Playlist, Session}; use soundcraft_model::{AutoParam, AutomationLane, Clip, ClipContent, ClipId, EditMode, Playlist, Session};
use soundcraft_time::{Range, Samples}; use soundcraft_time::{Range, Samples};
+3 -3
View File
@@ -1,8 +1,8 @@
//! Event menu: time/tempo operations, MIDI operations, markers. //! Event menu: time/tempo operations, MIDI operations, markers.
use super::{CommandSpec, always, bad, clip_ids_param, f32_or, f64_or, has_selection, has_tracks, i64_or, position_param, range_param, str_param};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_midi::ops::{DurationOp, QuantizeOptions, VelocityOp}; use soundcraft_midi::ops::{DurationOp, QuantizeOptions, VelocityOp};
use soundcraft_model::{ClipContent, MarkerKind}; use soundcraft_model::{ClipContent, MarkerKind};
use soundcraft_time::TICKS_PER_QUARTER; use soundcraft_time::TICKS_PER_QUARTER;
+7 -5
View File
@@ -2,15 +2,18 @@
//! operations (split notes, input quantize, step input, restore/flatten performance), MIDI track //! operations (split notes, input quantize, step input, restore/flatten performance), MIDI track
//! offsets and real-time properties, chord extraction, Beat Detective, Identify Beat and //! offsets and real-time properties, chord extraction, Beat Detective, Identify Beat and
//! Retrospective Record. //! Retrospective Record.
use super::{
CommandSpec, always, bad, bool_or, clip_ids_param, f32_or, f64_or, has_range, has_selection, has_tracks, i64_or, position_param, range_param,
str_param, track_param, tracks_param, tracks_required,
};
use super::more_util::{store_value, track_key, values_with_prefix}; use super::more_util::{store_value, track_key, values_with_prefix};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_midi::Note; use soundcraft_midi::Note;
use soundcraft_midi::ops::QuantizeOptions; use soundcraft_midi::ops::QuantizeOptions;
use soundcraft_model::{ChannelFormat, Clip, ClipContent, ClipId, MarkerKind, Session, TrackId, TrackKind}; use soundcraft_model::{ChannelFormat, Clip, ClipContent, ClipId, MarkerKind, Session, TrackId, TrackKind};
use soundcraft_time::{Range, Samples, TICKS_PER_QUARTER, TempoMap}; use soundcraft_time::{MAX_POSITION, Range, Samples, TICKS_PER_QUARTER, TempoMap};
/// Most tempo events one curve may write. /// Most tempo events one curve may write.
const MAX_TEMPO_EVENTS: i64 = 10_000; const MAX_TEMPO_EVENTS: i64 = 10_000;
@@ -782,7 +785,6 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn midi_engine() -> (Engine, TrackId) { fn midi_engine() -> (Engine, TrackId) {
let mut e = Engine::default(); let mut e = Engine::default();
e.execute("track.new", &json!({"kind": "midi"})).unwrap(); e.execute("track.new", &json!({"kind": "midi"})).unwrap();
+9 -6
View File
@@ -1,9 +1,12 @@
//! File menu commands. //! File menu commands.
use super::{
CommandSpec, always, bad, bool_or, clip_ids_param, has_selection, has_tracks, i64_or, position_param, range_param, str_param, track_param,
tracks_param, tracks_required,
};
use super::*; use crate::{Engine, EngineError, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json; use soundcraft_model::{Session, TrackId, TrackKind};
use soundcraft_model::{Session, TrackKind};
use soundcraft_time::SampleRate; use soundcraft_time::SampleRate;
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
@@ -238,7 +241,7 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result<Value> {
let mut out = Vec::new(); let mut out = Vec::new();
for path in &paths { for path in &paths {
let bytes = crate::io::read_file(path)?; let bytes = crate::io::read_file(path)?;
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("Audio").to_string(); let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Audio").to_string();
let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?; let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?;
out.push(r); out.push(r);
} }
@@ -299,7 +302,7 @@ pub fn bounce_job(e: &Engine, p: &Value) -> Result<crate::io::Bounce> {
let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string(); let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string();
let r = range_param(e, "file.bounce_mix", p)?; let r = range_param(e, "file.bounce_mix", p)?;
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r }; let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
let ext = std::path::Path::new(&path).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase(); let ext = std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
let format_name = str_param(p, "format").unwrap_or(ext.as_str()); let format_name = str_param(p, "format").unwrap_or(ext.as_str());
let format = soundcraft_audio_io::encode_format_for(format_name).ok_or_else(|| { let format = soundcraft_audio_io::encode_format_for(format_name).ok_or_else(|| {
bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS)) bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS))
+56 -42
View File
@@ -1,8 +1,8 @@
//! Media availability checks without decoding files or changing the session. //! Media availability checks without decoding files or changing the session.
use super::{CommandSpec, always};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::ClipContent; use soundcraft_model::ClipContent;
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -12,6 +12,55 @@ pub fn specs() -> Vec<CommandSpec> {
] ]
} }
struct ClipContext<'a> {
track: &'a soundcraft_model::Track,
playlist: &'a soundcraft_model::Playlist,
active: bool,
session_sample_rate: u32,
}
fn check_audio_clip(
ctx: &ClipContext<'_>,
clip: &soundcraft_model::Clip,
source: soundcraft_model::SourceId,
offset: i64,
metadata: Option<&soundcraft_model::Source>,
audio: Option<&soundcraft_model::SourceAudio>,
issues: &mut Vec<Value>,
) {
let mut add = |code: &str, message: &str| {
issues.push(json!({"code": code, "message": message, "track": ctx.track.name, "track_id": ctx.track.id.0,
"playlist": ctx.playlist.name, "active": ctx.active, "clip": clip.name, "clip_id": clip.id.0,
"source_id": source.0, "path": metadata.map(|m| m.path.as_str())}));
};
if metadata.is_none() {
add("missing_source", "Source metadata is missing. Reimport the audio and replace this clip.");
}
let Some(audio) = audio else {
add("unavailable_audio", "Audio is not loaded; this clip will be silent. Restore the media and reopen the session, or reimport it.");
return;
};
if audio.buffer.sample_rate != ctx.session_sample_rate {
add("sample_rate_mismatch", "Loaded audio differs from the session sample rate. Reimport it to convert to the session rate.");
}
if audio.buffer.channels.is_empty() || audio.frames() == 0 {
add("empty_audio", "Loaded audio has no playable frames. Reimport a valid audio file.");
return;
}
if audio.buffer.channels.iter().any(|channel| channel.len() != audio.frames()) {
add("uneven_channels", "Loaded audio channels have different lengths. Reimport a valid audio file; bounds below use the shortest channel.");
}
if !clip.stretch.is_finite() || clip.stretch <= 0.0 || clip.length <= 0 {
add("invalid_clip", "Clip length or stretch is invalid. Replace this clip.");
}
let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 };
// Match the mixer's last requested source sample, including elastic playback.
let last = offset as f64 + clip.length.saturating_sub(1).max(0) as f64 / stretch;
if offset < 0 || last >= audio.frames() as f64 {
add("source_bounds", "Clip extends outside the loaded audio; part will be silent. Trim the clip or replace its source.");
}
}
fn report(e: &mut Engine, _: &Value) -> Result<Value> { fn report(e: &mut Engine, _: &Value) -> Result<Value> {
let s = e.session(); let s = e.session();
let sources: BTreeMap<_, _> = s.sources.iter().map(|source| (source.id, source)).collect(); let sources: BTreeMap<_, _> = s.sources.iter().map(|source| (source.id, source)).collect();
@@ -24,51 +73,16 @@ fn report(e: &mut Engine, _: &Value) -> Result<Value> {
for track in &s.tracks { for track in &s.tracks {
for (index, playlist) in track.playlists.iter().enumerate() { for (index, playlist) in track.playlists.iter().enumerate() {
let active = index == track.active_playlist; let active = index == track.active_playlist;
let ctx = ClipContext { track, playlist, active, session_sample_rate: s.sample_rate.hz() };
for clip in &playlist.clips { for clip in &playlist.clips {
let ClipContent::Audio { source, offset } = clip.content else { continue }; let ClipContent::Audio { source, offset } = clip.content else { continue };
clips += 1; clips += 1;
if active { if active {
active_end = active_end.max(clip.end()); active_end = active_end.max(clip.end());
} }
let metadata = sources.get(&source); let metadata = sources.get(&source).copied();
let audio = s.pool.get(source); let audio = s.pool.get(source).map(|a| a.as_ref());
let mut add = |code: &str, message: &str| { check_audio_clip(&ctx, clip, source, offset, metadata, audio, &mut issues);
issues.push(json!({"code": code, "message": message, "track": track.name, "track_id": track.id.0,
"playlist": playlist.name, "active": active, "clip": clip.name, "clip_id": clip.id.0,
"source_id": source.0, "path": metadata.map(|m| m.path.as_str())}));
};
if metadata.is_none() {
add("missing_source", "Source metadata is missing. Reimport the audio and replace this clip.");
}
let Some(audio) = audio else {
add(
"unavailable_audio",
"Audio is not loaded; this clip will be silent. Restore the media and reopen the session, or reimport it.",
);
continue;
};
if audio.buffer.sample_rate != s.sample_rate.hz() {
add("sample_rate_mismatch", "Loaded audio differs from the session sample rate. Reimport it to convert to the session rate.");
}
if audio.buffer.channels.is_empty() || audio.frames() == 0 {
add("empty_audio", "Loaded audio has no playable frames. Reimport a valid audio file.");
continue;
}
if audio.buffer.channels.iter().any(|channel| channel.len() != audio.frames()) {
add(
"uneven_channels",
"Loaded audio channels have different lengths. Reimport a valid audio file; bounds below use the shortest channel.",
);
}
if !clip.stretch.is_finite() || clip.stretch <= 0.0 || clip.length <= 0 {
add("invalid_clip", "Clip length or stretch is invalid. Replace this clip.");
}
let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 };
// Match the mixer's last requested source sample, including elastic playback.
let last = offset as f64 + clip.length.saturating_sub(1).max(0) as f64 / stretch;
if offset < 0 || last >= audio.frames() as f64 {
add("source_bounds", "Clip extends outside the loaded audio; part will be silent. Trim the clip or replace its source.");
}
} }
} }
} }
+3 -3
View File
@@ -1,10 +1,10 @@
//! An instrument track's instrument (`mix.instrument`): the plugin that turns the track's MIDI //! An instrument track's instrument (`mix.instrument`): the plugin that turns the track's MIDI
//! into audio, built in or hosted (CLAP, VST3, Audio Units). The mixer plays `Track::instrument`; //! into audio, built in or hosted (CLAP, VST3, Audio Units). The mixer plays `Track::instrument`;
//! the instrument is otherwise only set when the track is created (`track.new`). //! the instrument is otherwise only set when the track is created (`track.new`).
use super::{CommandSpec, bad, has_selection, str_param, tracks_required};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::{Insert, TrackKind}; use soundcraft_model::{Insert, TrackKind};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
+3 -3
View File
@@ -1,8 +1,8 @@
//! MIDI note editing (MIDI Editor, pencil on MIDI tracks). //! MIDI note editing (MIDI Editor, pencil on MIDI tracks).
use super::{CommandSpec, always, bad, clip_id, has_tracks, i64_or, range_param, tracks_required};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_midi::{Note, Sequence}; use soundcraft_midi::{Note, Sequence};
use soundcraft_model::{Clip, ClipContent, ClipId}; use soundcraft_model::{Clip, ClipContent, ClipId};
use soundcraft_time::TICKS_PER_QUARTER; use soundcraft_time::TICKS_PER_QUARTER;
+5 -3
View File
@@ -1,8 +1,10 @@
//! Mixer, insert, send and automation commands. //! Mixer, insert, send and automation commands.
use super::{
CommandSpec, always, bad, bool_or, f32_or, has_range, has_selection, has_tracks, i64_or, position_param, range_param, str_param, tracks_required,
};
use super::*; use crate::{Engine, EngineError, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::{AutoParam, AutomationMode, INSERT_SLOTS, Insert, SEND_SLOTS, SendSlot, Session, TrackId}; use soundcraft_model::{AutoParam, AutomationMode, INSERT_SLOTS, Insert, SEND_SLOTS, SendSlot, Session, TrackId};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
+1 -1
View File
@@ -102,7 +102,7 @@ impl CommandSpec {
CommandInfo { CommandInfo {
id: self.id.into(), id: self.id.into(),
label: self.label.into(), label: self.label.into(),
menu: self.menu.iter().map(|s| s.to_string()).collect(), menu: self.menu.iter().map(ToString::to_string).collect(),
shortcut: self.shortcut.map(str::to_string), shortcut: self.shortcut.map(str::to_string),
params: self.params.into(), params: self.params.into(),
enabled: en.is_ok(), enabled: en.is_ok(),
+3 -3
View File
@@ -1,8 +1,9 @@
//! Helpers shared by the newer command modules: settings kept in `EditState.flags`/`values`, //! Helpers shared by the newer command modules: settings kept in `EditState.flags`/`values`,
//! per-track settings, target playlists, clip-gain envelopes and stretch rendering. //! per-track settings, target playlists, clip-gain envelopes and stretch rendering.
use super::bad;
use crate::{Engine, Result};
use super::*; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_audio_io::{AudioBuffer, FileFormat}; use soundcraft_audio_io::{AudioBuffer, FileFormat};
use soundcraft_model::{Clip, ClipContent, EditState, Session, SourceId, TrackId}; use soundcraft_model::{Clip, ClipContent, EditState, Session, SourceId, TrackId};
use soundcraft_time::Samples; use soundcraft_time::Samples;
@@ -187,7 +188,6 @@ pub fn replace_clip(s: &mut Session, t: TrackId, old: soundcraft_model::ClipId,
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn text_settings_round_trip() { fn text_settings_round_trip() {
let mut ed = EditState::default(); let mut ed = EditState::default();
+3 -3
View File
@@ -1,8 +1,8 @@
//! Options menu toggles and Setup › Session settings. //! Options menu toggles and Setup › Session settings.
use super::{CommandSpec, always, bad, bool_or, position_param, str_param};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::{BitDepthSetting, EditState}; use soundcraft_model::{BitDepthSetting, EditState};
use soundcraft_time::FrameRate; use soundcraft_time::FrameRate;
+3 -2
View File
@@ -1,9 +1,10 @@
//! Third-party plugin state in the session (`mix.insert_state`): the UI reads each live plugin's //! Third-party plugin state in the session (`mix.insert_state`): the UI reads each live plugin's
//! state right before saving and stores it on its insert (base64), from where the mixer restores //! state right before saving and stores it on its insert (base64), from where the mixer restores
//! it into new instances. //! it into new instances.
use super::{CommandSpec, bad, has_tracks, track_param};
use crate::{Engine, Result};
use super::*; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::{INSERT_SLOTS, b64}; use soundcraft_model::{INSERT_SLOTS, b64};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
+2 -2
View File
@@ -1,8 +1,8 @@
//! Read-only queries for agents. //! Read-only queries for agents.
use super::{CommandInfo, CommandSpec, always, bad, command_specs, has_tracks, position_param, str_param, track_param};
use super::*;
use crate::cmd; use crate::cmd;
use serde_json::json; use serde_json::{Value, json};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
+6 -6
View File
@@ -4,11 +4,11 @@
//! The source is saved in `Audio Files/` with the session like any other audio, so a session that //! The source is saved in `Audio Files/` with the session like any other audio, so a session that
//! uses a sample reopens with it. Sampler parameters (root note, key range, ADSR, loop…) are set //! uses a sample reopens with it. Sampler parameters (root note, key range, ADSR, loop…) are set
//! with `mix.instrument_param`, or all at once with `params` here. //! with `mix.instrument_param`, or all at once with `params` here.
use super::{CommandSpec, bad, has_selection, str_param, tracks_required};
use super::*; use crate::{Engine, EngineError, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json; use soundcraft_model::{Insert, TrackId, TrackKind};
use soundcraft_model::{Insert, TrackKind};
use std::path::Path; use std::path::Path;
const SAMPLER: &str = "sampler"; const SAMPLER: &str = "sampler";
@@ -56,7 +56,7 @@ fn load(e: &mut Engine, p: &Value) -> Result<Value> {
} }
} }
let bytes = crate::io::read_file(&path)?; let bytes = crate::io::read_file(&path)?;
let name = Path::new(&path).file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string(); let name = Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Sample").to_string();
attach(e, t, &name, &bytes, Some(&path), values) attach(e, t, &name, &bytes, Some(&path), values)
} }
@@ -69,7 +69,7 @@ pub(crate) fn attach(e: &mut Engine, t: TrackId, name: &str, bytes: &[u8], path:
return Err(bad(id, "only instrument tracks have a sampler")); return Err(bad(id, "only instrument tracks have a sampler"));
} }
let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?; let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?;
let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); let ext = Path::new(name).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
let (format, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; let (format, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
if buf.frames() == 0 || buf.num_channels() == 0 { if buf.frames() == 0 || buf.num_channels() == 0 {
return Err(EngineError::Io(format!("{name}: no audio"))); return Err(EngineError::Io(format!("{name}: no audio")));
+3 -4
View File
@@ -1,10 +1,10 @@
//! More Options toggles, the Setup menu (hardware, playback engine, I/O, timecode, click, //! More Options toggles, the Setup menu (hardware, playback engine, I/O, timecode, click,
//! shortcuts, MIDI setup) and File › Import › Session Data. //! shortcuts, MIDI setup) and File › Import › Session Data.
use super::{CommandSpec, always, bad, command_specs, i64_or, str_param, track_param, tracks_param};
use super::more_util::{set_text, store_value, text, toggle_flag, values_with_prefix}; use super::more_util::{set_text, store_value, text, toggle_flag, values_with_prefix};
use super::*; use crate::{Engine, EngineError, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, SourceId, TrackId, TrackKind}; use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, SourceId, TrackId, TrackKind};
use soundcraft_time::Timecode; use soundcraft_time::Timecode;
use std::path::Path; use std::path::Path;
@@ -503,7 +503,6 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn option_flags_toggle() { fn option_flags_toggle() {
let mut e = Engine::default(); let mut e = Engine::default();
+3 -3
View File
@@ -1,8 +1,8 @@
//! Surround mixing: the main output format and the surround panners of tracks and sends. //! Surround mixing: the main output format and the surround panners of tracks and sends.
use super::{CommandSpec, always, bad, bool_or, f32_or, has_selection, str_param, tracks_required};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::{ChannelFormat, Mixer, OutputPath, Route, SEND_SLOTS, Session, SurroundPan, TrackKind}; use soundcraft_model::{ChannelFormat, Mixer, OutputPath, Route, SEND_SLOTS, Session, SurroundPan, TrackKind};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
+5 -5
View File
@@ -1,10 +1,10 @@
//! Tab and Tab to Transient: the insertion point or selection end to the next or previous clip boundary, transient or note. //! Tab and Tab to Transient: the insertion point or selection end to the next or previous clip boundary, transient or note.
use super::{CommandSpec, always, bool_or};
use super::*; use crate::{Engine, Result, TransportRequest, cmd};
use crate::{TransportRequest, cmd}; use serde_json::{Value, json};
use serde_json::json; use soundcraft_model::{Clip, ClipContent, Session, SourceAudio, TrackId};
use soundcraft_model::{Clip, ClipContent, Session, SourceAudio}; use soundcraft_time::{Range, SampleRate, Samples};
use soundcraft_time::SampleRate;
use std::sync::{Arc, Mutex, PoisonError, Weak}; use std::sync::{Arc, Mutex, PoisonError, Weak};
/// Transients are found in fixed blocks of each clip, so a long clip costs a block per press and /// Transients are found in fixed blocks of each clip, so a long clip costs a block per press and
+6 -5
View File
@@ -1,9 +1,11 @@
//! Track menu commands. //! Track menu commands.
use super::{
CommandSpec, always, bad, bool_or, has_selection, has_tracks, i64_or, range_param, str_param, track_param, tracks_param, tracks_required,
};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json; use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackKind};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
@@ -944,7 +946,6 @@ fn route(e: &mut Engine, p: &Value, input: bool) -> Result<Value> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn comp_from_alternate_playlist() { fn comp_from_alternate_playlist() {
let mut e = crate::demo::demo_engine(); let mut e = crate::demo::demo_engine();
+3 -4
View File
@@ -1,10 +1,10 @@
//! More Track menu commands: Convert Aux to Routing Folder, Extract MIDI, track presets, insert / //! More Track menu commands: Convert Aux to Routing Folder, Extract MIDI, track presets, insert /
//! send bypass subsets, MIDI real-time properties, trim/VCA coalescing and target playlists. //! send bypass subsets, MIDI real-time properties, trim/VCA coalescing and target playlists.
use super::{CommandSpec, bad, has_selection, has_tracks, range_param, str_param, tracks_param, tracks_required};
use super::more_util::{activate_playlist, target_playlist, track_key}; use super::more_util::{activate_playlist, target_playlist, track_key};
use super::*; use crate::{Engine, EngineError, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_dsp::Category; use soundcraft_dsp::Category;
use soundcraft_model::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipContent, Insert, Route, Session, Track, TrackId, TrackKind}; use soundcraft_model::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipContent, Insert, Route, Session, Track, TrackId, TrackKind};
use soundcraft_time::Samples; use soundcraft_time::Samples;
@@ -547,7 +547,6 @@ fn toggle_recent(e: &mut Engine, p: &Value) -> Result<Value> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn bypass_by_category_and_range() { fn bypass_by_category_and_range() {
let mut e = crate::demo::demo_engine(); let mut e = crate::demo::demo_engine();
+3 -3
View File
@@ -1,9 +1,9 @@
//! Transport commands. They update the session's stopped playhead and queue requests for the //! Transport commands. They update the session's stopped playhead and queue requests for the
//! host's audio engine (which may be absent when headless). //! host's audio engine (which may be absent when headless).
use super::{CommandSpec, always, bad, f64_or, position_param};
use super::*; use crate::{Engine, Result, TransportRequest, cmd};
use crate::{TransportRequest, cmd}; use serde_json::{Value, json};
use serde_json::json;
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
+4 -3
View File
@@ -2,10 +2,11 @@
//! //!
//! Movies are imported with `file.import_video`; the session only references them //! Movies are imported with `file.import_video`; the session only references them
//! (`Session::videos`). Decoding happens in the UI (`soundcraft-video`), never in the engine. //! (`Session::videos`). Decoding happens in the UI (`soundcraft-video`), never in the engine.
use super::{CommandSpec, always, bad, position_param, track_param};
use soundcraft_time::MAX_POSITION;
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::{ClipContent, TrackKind}; use soundcraft_model::{ClipContent, TrackKind};
/// `edit.flags` key set while the Video track is offline (online is the default). /// `edit.flags` key set while the Video track is offline (online is the default).
+6 -5
View File
@@ -1,9 +1,10 @@
//! View menu commands that change saved view state (zoom, rulers, counters, grid/nudge). //! View menu commands that change saved view state (zoom, rulers, counters, grid/nudge).
use super::{CommandSpec, always, bad, bool_or, f32_or, f64_or, i64_or, position_param, str_param};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json; use soundcraft_time::TimeFormat;
use soundcraft_time::{GridValue, NoteValue, TimeFormat}; use soundcraft_time::{GridValue, MAX_POSITION, NoteValue};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
@@ -160,7 +161,7 @@ pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use soundcraft_time::Samples;
#[test] #[test]
fn grid_values_parse() { fn grid_values_parse() {
assert_eq!(grid_param(&json!({"v": "1/16"}), "v"), Some(GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false })); assert_eq!(grid_param(&json!({"v": "1/16"}), "v"), Some(GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }));
+3 -4
View File
@@ -1,11 +1,11 @@
//! More View menu commands: Edit-window column views, extra rulers, marker displays, clip //! More View menu commands: Edit-window column views, extra rulers, marker displays, clip
//! display options, waveform styles, expanded sends, transport views and other view toggles. //! display options, waveform styles, expanded sends, transport views and other view toggles.
//! Every setting is saved with the session in `edit.flags` (or `edit.rulers` for rulers). //! Every setting is saved with the session in `edit.flags` (or `edit.rulers` for rulers).
use super::{CommandSpec, always, str_param};
use super::more_util::{exclusive_flag, set_flags, toggle_flag}; use super::more_util::{exclusive_flag, set_flags, toggle_flag};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
/// A view toggle: `{value?: bool}` sets it, otherwise it flips. /// A view toggle: `{value?: bool}` sets it, otherwise it flips.
macro_rules! flag { macro_rules! flag {
@@ -219,7 +219,6 @@ fn toggle_ruler(e: &mut Engine, p: &Value, id: &str) -> Result<Value> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn flags_toggle_and_persist() { fn flags_toggle_and_persist() {
let mut e = Engine::default(); let mut e = Engine::default();
+2 -2
View File
@@ -1,10 +1,10 @@
//! Third-party VST3 plugins: discovery (`engine.vst3_plugins`). Hosting itself lives in //! Third-party VST3 plugins: discovery (`engine.vst3_plugins`). Hosting itself lives in
//! `soundcraft-vst3-host`; id lookup for the mixer commands is shared with CLAP in //! `soundcraft-vst3-host`; id lookup for the mixer commands is shared with CLAP in
//! `cmd::clap::plugin_info`. //! `cmd::clap::plugin_info`.
use super::{CommandSpec, always};
use super::*;
use crate::cmd; use crate::cmd;
use serde_json::json; use serde_json::{Value, json};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![cmd!( vec![cmd!(
+8 -5
View File
@@ -1,9 +1,10 @@
//! Zoom Toggle: zoom in on the edit selection, then back out to the view before. //! Zoom Toggle: zoom in on the edit selection, then back out to the view before.
use super::{CommandSpec, always, f64_or};
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::{TrackHeight, ZoomToggle}; use soundcraft_model::{TrackHeight, ZoomToggle};
use soundcraft_time::MAX_POSITION;
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle)] vec![cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle)]
@@ -37,8 +38,10 @@ fn toggle(e: &mut Engine, p: &Value) -> Result<Value> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::TrackHeight::{Fit, Large, Medium};
use super::*; use super::*;
use TrackHeight::{Fit, Large, Medium}; use soundcraft_model::TrackId;
use soundcraft_time::Samples;
fn engine(select: Value) -> Engine { fn engine(select: Value) -> Engine {
let mut e = Engine::default(); let mut e = Engine::default();
+27 -22
View File
@@ -5,6 +5,7 @@ use serde_json::{Value, json};
use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat}; use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat};
use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind}; use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
use soundcraft_time::{Range, Samples}; use soundcraft_time::{Range, Samples};
use std::ffi::OsStr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -68,13 +69,7 @@ pub fn file_found(path: impl AsRef<Path>) -> bool {
/// Paths the person picks in this session (imports) are not stored paths and skip this. /// Paths the person picks in this session (imports) are not stored paths and skip this.
pub fn stored_path(stored: &str, session: &Path) -> std::result::Result<PathBuf, String> { pub fn stored_path(stored: &str, session: &Path) -> std::result::Result<PathBuf, String> {
let session = std::path::absolute(session).unwrap_or_else(|_| session.to_path_buf()); let session = std::path::absolute(session).unwrap_or_else(|_| session.to_path_buf());
let path = craft_docpath::resolve(stored, Some(&session)).map_err(|why| format!("not opened: {why}"))?; craft_docpath::resolve_absolute(stored, Some(&session)).map_err(|why| format!("not opened: {why}"))
// A path that is still relative would be taken from the working directory, which the session
// does not choose.
if path.is_relative() {
return Err("not opened: the path is not absolute".into());
}
Ok(path)
} }
/// Create `dir` and its parents, inside the automation write root when there is one. /// Create `dir` and its parents, inside the automation write root when there is one.
@@ -120,7 +115,7 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio
buf.sample_rate = sr; buf.sample_rate = sr;
} }
let id = SourceId(s.alloc()); let id = SourceId(s.alloc());
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or(name).to_string(); let stem = Path::new(name).file_stem().and_then(OsStr::to_str).unwrap_or(name).to_string();
let stored = match path { let stored = match path {
Some(p) => p.to_string(), Some(p) => p.to_string(),
None => unique_audio_rel(s, &stem), None => unique_audio_rel(s, &stem),
@@ -151,7 +146,7 @@ fn stamp_at_rate(stamp: u64, file_rate: u32, session_rate: u32) -> u64 {
/// Decode and place an audio file. With no target track, a new track of matching width is made. /// Decode and place an audio file. With no target track, a new track of matching width is made.
pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> { pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> {
let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); 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 (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
let buf = at_rate(buf, e.session().sample_rate.hz(), MAX_IMPORT_SAMPLES) let buf = at_rate(buf, e.session().sample_rate.hz(), MAX_IMPORT_SAMPLES)
.ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?; .ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
@@ -170,7 +165,7 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option
let track = match target { let track = match target {
Some(t) => t, Some(t) => t,
None => { None => {
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or("Audio").to_string(); let stem = Path::new(name).file_stem().and_then(OsStr::to_str).unwrap_or("Audio").to_string();
s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels), Some(&stem)) s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels), Some(&stem))
} }
}; };
@@ -194,8 +189,8 @@ pub fn load_sampler_bytes(e: &mut Engine, track: TrackId, name: &str, bytes: &[u
/// never copied) plus its audio on a new audio track when it has a usable audio stream. Fails /// never copied) plus its audio on a new audio track when it has a usable audio stream. Fails
/// only when the file has neither a readable picture nor audio. /// only when the file has neither a readable picture nor audio.
pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec<u8>, at: Samples) -> Result<Value> { pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec<u8>, at: Samples) -> Result<Value> {
let stem = Path::new(path).file_stem().and_then(|n| n.to_str()).unwrap_or("Video").to_string(); let stem = Path::new(path).file_stem().and_then(OsStr::to_str).unwrap_or("Video").to_string();
let ext = Path::new(path).extension().and_then(|x| x.to_str()).unwrap_or("mp4").to_ascii_lowercase(); let ext = Path::new(path).extension().and_then(OsStr::to_str).unwrap_or("mp4").to_ascii_lowercase();
let bytes: Arc<[u8]> = bytes.into(); let bytes: Arc<[u8]> = bytes.into();
let movie = soundcraft_video::Movie::open(Arc::clone(&bytes)).map(|m| m.info().clone()); let movie = soundcraft_video::Movie::open(Arc::clone(&bytes)).map(|m| m.info().clone());
let audio_name = format!("{stem} audio.{}", if ext == "mov" { "mp4" } else { ext.as_str() }); let audio_name = format!("{stem} audio.{}", if ext == "mov" { "mp4" } else { ext.as_str() });
@@ -216,7 +211,7 @@ pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec<u8>, at: Sample
let vid = SourceId(s.alloc()); let vid = SourceId(s.alloc());
s.videos.push(soundcraft_model::VideoSource { s.videos.push(soundcraft_model::VideoSource {
id: vid, id: vid,
name: Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("movie").to_string(), name: Path::new(path).file_name().and_then(OsStr::to_str).unwrap_or("movie").to_string(),
path: abs, path: abs,
width: info.width, width: info.width,
height: info.height, height: info.height,
@@ -998,7 +993,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
sync_dir(&audio_dir); sync_dir(&audio_dir);
} }
s.folder = Some(dir.clone()); s.folder = Some(dir.clone());
if !overwrite && let Some(stem) = p.file_stem().and_then(|x| x.to_str()) { if !overwrite && let Some(stem) = p.file_stem().and_then(OsStr::to_str) {
s.name = stem.to_string(); s.name = stem.to_string();
} }
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
@@ -1058,7 +1053,7 @@ fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> {
/// Copy the session file about to be replaced to `Backups/<name> backup <n>.<ext>`, keeping the /// Copy the session file about to be replaced to `Backups/<name> backup <n>.<ext>`, keeping the
/// newest ten. /// newest ten.
fn back_up(file: &Path) { fn back_up(file: &Path) {
let (Some(dir), Some(stem), Some(ext)) = (file.parent(), file.file_stem().and_then(|x| x.to_str()), file.extension().and_then(|x| x.to_str())) let (Some(dir), Some(stem), Some(ext)) = (file.parent(), file.file_stem().and_then(OsStr::to_str), file.extension().and_then(OsStr::to_str))
else { else {
return; return;
}; };
@@ -1201,7 +1196,7 @@ pub(crate) fn decode_file(files: &mut DecodedFiles, p: &Path, rate: u32) -> std:
.entry(file_key(p)) .entry(file_key(p))
.or_insert_with(|| { .or_insert_with(|| {
let bytes = read_file(p).map_err(|_| p.display().to_string())?; let bytes = read_file(p).map_err(|_| p.display().to_string())?;
decode_source(&bytes, p.extension().and_then(|x| x.to_str()), rate).map_err(|err| format!("{}: {err}", p.display())) decode_source(&bytes, p.extension().and_then(OsStr::to_str), rate).map_err(|err| format!("{}: {err}", p.display()))
}) })
.clone() .clone()
} }
@@ -1233,7 +1228,7 @@ pub fn set_deliver(h: DeliverHook) {
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
if cfg!(target_arch = "wasm32") { if cfg!(target_arch = "wasm32") {
let deliver = DELIVER.get().ok_or_else(|| EngineError::Io("saving files is not available in this build".into()))?; let deliver = DELIVER.get().ok_or_else(|| EngineError::Io("saving files is not available in this build".into()))?;
let name = Path::new(path).file_name().and_then(|n| n.to_str()).filter(|n| !n.is_empty()).unwrap_or("Untitled"); let name = Path::new(path).file_name().and_then(OsStr::to_str).filter(|n| !n.is_empty()).unwrap_or("Untitled");
return deliver(name, bytes).map_err(EngineError::Io); return deliver(name, bytes).map_err(EngineError::Io);
} }
crate::file_access::check_write(path).map_err(EngineError::Io)?; crate::file_access::check_write(path).map_err(EngineError::Io)?;
@@ -1278,7 +1273,7 @@ pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
/// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]). /// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]).
fn save_session_bundle(e: &mut Engine, path: &str) -> Result<usize> { fn save_session_bundle(e: &mut Engine, path: &str) -> Result<usize> {
let stem = Path::new(path).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).map(str::to_string); let stem = Path::new(path).file_stem().and_then(OsStr::to_str).filter(|x| !x.is_empty()).map(str::to_string);
let mut s = e.session().clone(); let mut s = e.session().clone();
if let Some(stem) = stem { if let Some(stem) = stem {
s.name = stem; s.name = stem;
@@ -1317,7 +1312,7 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
continue; continue;
} }
let audio = match files.remove(&src.path) { let audio = match files.remove(&src.path) {
Some(bytes) => match decode_source(&bytes, Path::new(&src.path).extension().and_then(|x| x.to_str()), s.sample_rate.hz()) { Some(bytes) => match decode_source(&bytes, Path::new(&src.path).extension().and_then(OsStr::to_str), s.sample_rate.hz()) {
Ok(audio) => Some(audio), Ok(audio) => Some(audio),
Err(err) => { Err(err) => {
missing.push(format!("{}: {err}", src.path)); missing.push(format!("{}: {err}", src.path));
@@ -1336,7 +1331,7 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
} }
// Movies are referenced by path, never copied into a session: none can be found here. // Movies are referenced by path, never copied into a session: none can be found here.
missing.extend(s.videos.iter().map(|v| v.path.clone())); missing.extend(s.videos.iter().map(|v| v.path.clone()));
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).unwrap_or("Untitled").to_string(); let stem = Path::new(name).file_stem().and_then(OsStr::to_str).filter(|x| !x.is_empty()).unwrap_or("Untitled").to_string();
e.replace_session(s); e.replace_session(s);
e.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem))); e.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem)));
for m in &missing { for m in &missing {
@@ -1368,7 +1363,7 @@ fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap<String
let mut data = Vec::new(); let mut data = Vec::new();
// The declared size is hostile too: never read past it. // The declared size is hostile too: never read past it.
f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?; f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?;
let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(|x| x.to_str()) == Some(soundcraft_model::SESSION_EXTENSION); let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(OsStr::to_str) == Some(soundcraft_model::SESSION_EXTENSION);
if is_session && text.is_none() { if is_session && text.is_none() {
text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?); text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?);
} else { } else {
@@ -1457,7 +1452,7 @@ fn add_recording_in(
continue; continue;
} }
let resampled = buf.sample_rate != s.sample_rate.hz(); let resampled = buf.sample_rate != s.sample_rate.hz();
let name = path.file_stem().and_then(|x| x.to_str()).unwrap_or("Take"); let name = path.file_stem().and_then(OsStr::to_str).unwrap_or("Take");
let src = add_source(s, name, buf, Some(&path.to_string_lossy()), FileFormat::Wav); let src = add_source(s, name, buf, Some(&path.to_string_lossy()), FileFormat::Wav);
let Some(x) = s.sources.last_mut() else { continue }; let Some(x) = s.sources.last_mut() else { continue };
x.unsaved = resampled; x.unsaved = resampled;
@@ -1883,6 +1878,16 @@ mod open_tests {
assert_eq!(std::fs::read_dir(dir.join("copy/Audio Files")).unwrap().count(), 1); assert_eq!(std::fs::read_dir(dir.join("copy/Audio Files")).unwrap().count(), 1);
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
fn stored_paths_are_taken_from_the_session_folder() {
let session = std::env::temp_dir().join("s").join("Song.scraft");
assert_eq!(stored_path("Audio Files/k.wav", &session), Ok(session.with_file_name("Audio Files").join("k.wav")));
#[cfg(windows)]
assert_eq!(stored_path("Audio Files/take:2.wav", &session), Err("not opened: the path is not valid".into()));
#[cfg(not(windows))]
assert_eq!(stored_path(r"C:\Audio\k.wav", &session), Err("not opened: the path is not valid".into()));
}
#[test] #[test]
fn stored_media_paths_are_followed_only_on_this_computer() { fn stored_media_paths_are_followed_only_on_this_computer() {
let dir = std::env::temp_dir().join(format!("soundcraft-stored-paths-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("soundcraft-stored-paths-{}", std::process::id()));
+1 -1
View File
@@ -451,7 +451,7 @@ fn install_panic_hook() {
} }
fn panic_message(p: &(dyn std::any::Any + Send)) -> String { fn panic_message(p: &(dyn std::any::Any + Send)) -> String {
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into()) p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
} }
#[cfg(test)] #[cfg(test)]
+17
View File
@@ -0,0 +1,17 @@
//! Integration tests, built as one test binary: each file is a module.
mod bounce;
mod clap_e2e;
mod consolidate_selection;
mod current_feet_frames_position;
mod edit_modes;
mod fader_merge_full_history;
mod hostile_params;
mod hostile_sessions;
mod long_renders;
mod midi_offset;
mod mix_required_params;
mod random_edits;
mod recording;
mod session_transport;
mod trim_to_selection;
+4
View File
@@ -0,0 +1,4 @@
//! Integration tests, built as one test binary: each file is a module.
mod ops;
mod smf;
+3 -1
View File
@@ -1360,7 +1360,9 @@ fn receives_solo(s: &Session, t: &Track) -> bool {
/// Metronome: a short synthesized blip on every beat of the tempo map, accented on beat 1 of /// Metronome: a short synthesized blip on every beat of the tempo map, accented on beat 1 of
/// each bar. Settings come from `setup.click_countoff` (`click.*` values). /// each bar. Settings come from `setup.click_countoff` (`click.*` values).
mod click { mod click {
use super::*; use super::db_to_gain;
use soundcraft_model::Session;
use soundcraft_time::Samples;
/// Blip length in seconds. /// Blip length in seconds.
const LEN_S: f64 = 0.03; const LEN_S: f64 = 0.03;
@@ -1,6 +1,5 @@
//! A bounce of a bus keeps the requested timeline interval when an aux returns onto the bus //! A bounce of a bus keeps the requested timeline interval when an aux returns onto the bus
//! (its insert's lookahead is compensated like the main mix's). //! (its insert's lookahead is compensated like the main mix's).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use soundcraft_audio_io::AudioBuffer; use soundcraft_audio_io::AudioBuffer;
use soundcraft_mix::{render_bus, render_range}; use soundcraft_mix::{render_bus, render_range};
+6
View File
@@ -0,0 +1,6 @@
//! Integration tests, built as one test binary: each file is a module.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod bus_aux_interval;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod master_insert_latency;
@@ -1,6 +1,5 @@
//! Delay compensation covers the inserts of the master faders: a bounce of the main mix lines up //! Delay compensation covers the inserts of the master faders: a bounce of the main mix lines up
//! with the same mix without them. //! with the same mix without them.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use soundcraft_audio_io::AudioBuffer; use soundcraft_audio_io::AudioBuffer;
use soundcraft_mix::{MixEngine, render_range}; use soundcraft_mix::{MixEngine, render_range};
+1 -1
View File
@@ -196,7 +196,7 @@ impl ChannelFormat {
pub fn channel_label(self, ch: usize) -> String { pub fn channel_label(self, ch: usize) -> String {
match self.speakers().get(ch) { match self.speakers().get(ch) {
Some(s) => s.label().to_string(), Some(s) => s.label().to_string(),
None if self.is_ambisonic() => ["W", "Y", "Z", "X"].get(ch).map_or_else(|| format!("{}", ch + 1), |s| s.to_string()), None if self.is_ambisonic() => ["W", "Y", "Z", "X"].get(ch).map_or_else(|| format!("{}", ch + 1), ToString::to_string),
None => format!("{}", ch + 1), None => format!("{}", ch + 1),
} }
} }

Some files were not shown because too many files have changed in this diff Show More