mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 10:36:46 -06:00
Merge remote-tracking branch 'origin/main' into m/983
This commit is contained in:
@@ -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)"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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};
|
||||||
@@ -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;
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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![
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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![
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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![
|
||||||
|
|||||||
@@ -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")));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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![
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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
@@ -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()));
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
//! Integration tests, built as one test binary: each file is a module.
|
||||||
|
|
||||||
|
mod ops;
|
||||||
|
mod smf;
|
||||||
@@ -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};
|
||||||
@@ -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
@@ -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};
|
||||||
@@ -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
Reference in New Issue
Block a user