mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 10:36:46 -06:00
Compare commits
273
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7469834420 | ||
|
|
e220666546 | ||
|
|
93800e0764 | ||
|
|
ef646cf626 | ||
|
|
aa06d87410 | ||
|
|
c422084a4d | ||
|
|
1779610626 | ||
|
|
7cec02403e | ||
|
|
7ae583895d | ||
|
|
b7fe88a72e | ||
|
|
16c3e222b7 | ||
|
|
04d014b662 | ||
|
|
ffd88ee73d | ||
|
|
279d32080e | ||
|
|
6db7d29218 | ||
|
|
11aa5b99b3 | ||
|
|
567172f530 | ||
|
|
6a4bcadf1e | ||
|
|
cdc07eef35 | ||
|
|
0a1b1b185d | ||
|
|
a0f78c0a1f | ||
|
|
03183ae308 | ||
|
|
58b4208e1f | ||
|
|
2ac0200c11 | ||
|
|
a6963aaeea | ||
|
|
9a9da473c6 | ||
|
|
b7dda1433a | ||
|
|
a19b67eb58 | ||
|
|
3903e0a894 | ||
|
|
0982bdedee | ||
|
|
62d2a47878 | ||
|
|
8e8157f550 | ||
|
|
0888ca8ac3 | ||
|
|
77d96affc3 | ||
|
|
a18f86cc89 | ||
|
|
ad7b9bcac6 | ||
|
|
19f1069109 | ||
|
|
5088846d45 | ||
|
|
b0f07331a2 | ||
|
|
1053073302 | ||
|
|
a50fdf9cc9 | ||
|
|
99579a5421 | ||
|
|
0000566f5e | ||
|
|
b8b9a90c6f | ||
|
|
dfb3815eb3 | ||
|
|
bf9fa3cc60 | ||
|
|
64669e9a7c | ||
|
|
07b85277ee | ||
|
|
525a4e02fe | ||
|
|
8b34dc533f | ||
|
|
4b54d72c23 | ||
|
|
6b84a4f046 | ||
|
|
67865b23e8 | ||
|
|
fe4bb3fe0c | ||
|
|
a4d3d8541f | ||
|
|
e8abd44712 | ||
|
|
cc493b2d8b | ||
|
|
ab1849cbf8 | ||
|
|
265645a8fc | ||
|
|
9b68982738 | ||
|
|
ef8a63f8c3 | ||
|
|
9ecf197aa7 | ||
|
|
072b4ff3da | ||
|
|
0235d11d4c | ||
|
|
767feacf72 | ||
|
|
d01abd0e4e | ||
|
|
f0d5acb800 | ||
|
|
7c690b19e1 | ||
|
|
d47abe484d | ||
|
|
f431791c26 | ||
|
|
53a18ec037 | ||
|
|
44067c8779 | ||
|
|
ce235c6f80 | ||
|
|
811d78729d | ||
|
|
52471b37e9 | ||
|
|
44663d9601 | ||
|
|
9049eb53c5 | ||
|
|
a36228f0ec | ||
|
|
d9f2b070cb | ||
|
|
38d62661ce | ||
|
|
e85a4801e9 | ||
|
|
239c78e3a0 | ||
|
|
8063aa7173 | ||
|
|
2cfc56d515 | ||
|
|
ce42466dd6 | ||
|
|
79adead5bf | ||
|
|
a966917c76 | ||
|
|
06db0bd373 | ||
|
|
6c596fca13 | ||
|
|
5cdc72a0ed | ||
|
|
589608a5d3 | ||
|
|
55d9e8f86d | ||
|
|
84495f1c9b | ||
|
|
095b483ce1 | ||
|
|
816a6efb77 | ||
|
|
1b3e87c9c0 | ||
|
|
2a5d308c55 | ||
|
|
b1466e451c | ||
|
|
24543e1486 | ||
|
|
d49e114d84 | ||
|
|
da26cf832a | ||
|
|
5029626f9e | ||
|
|
590ac97e93 | ||
|
|
9b8283b908 | ||
|
|
30ef8306f8 | ||
|
|
7587ae2690 | ||
|
|
b74fca0fb0 | ||
|
|
52253e89c5 | ||
|
|
63eacf115d | ||
|
|
76bac6495e | ||
|
|
16371a8502 | ||
|
|
717ae97af5 | ||
|
|
01c8c85ebc | ||
|
|
187ee51ae0 | ||
|
|
4ef6458e4e | ||
|
|
152c2c45e6 | ||
|
|
96130d22c7 | ||
|
|
988d678eec | ||
|
|
797aaac058 | ||
|
|
7b339fe921 | ||
|
|
5703db0b80 | ||
|
|
1816f2dc92 | ||
|
|
09c2e71141 | ||
|
|
dfde8a6724 | ||
|
|
0d8a92f53b | ||
|
|
e8940766d7 | ||
|
|
9ac264184e | ||
|
|
278b41b8ff | ||
|
|
25d52c646a | ||
|
|
9d53e4d97a | ||
|
|
3776e3338f | ||
|
|
bec2d06ac6 | ||
|
|
01622f15bd | ||
|
|
6d268d5623 | ||
|
|
60f4342697 | ||
|
|
fdb2ea6504 | ||
|
|
93d3cbfecc | ||
|
|
dea681cd65 | ||
|
|
42ceeaf02f | ||
|
|
5b9f6e5028 | ||
|
|
b537e82bd9 | ||
|
|
4c9a036f95 | ||
|
|
90a2c386dd | ||
|
|
d8000e9756 | ||
|
|
9d3d02dbe7 | ||
|
|
8ae491b6d2 | ||
|
|
412af03dbf | ||
|
|
81e4c5c1ec | ||
|
|
a8fa1e53d3 | ||
|
|
798ee7d88f | ||
|
|
3a57a0edc8 | ||
|
|
12c224aa66 | ||
|
|
9c8afc1cf0 | ||
|
|
e9cda9000d | ||
|
|
d81a6c2d49 | ||
|
|
95ce47dbc6 | ||
|
|
16be837ebc | ||
|
|
a17dc98023 | ||
|
|
c62d2533b7 | ||
|
|
8a06cee05d | ||
|
|
8e196cfe2e | ||
|
|
7694771a5e | ||
|
|
582bfb2f7e | ||
|
|
b042a73dbf | ||
|
|
99d50d9811 | ||
|
|
979efff8a9 | ||
|
|
efbb5d9168 | ||
|
|
ef00e8322c | ||
|
|
ef4a4a0a9a | ||
|
|
603d62761a | ||
|
|
f3cc778673 | ||
|
|
e5e0d860f3 | ||
|
|
afb8e10ff2 | ||
|
|
b337801c66 | ||
|
|
00315b6fb4 | ||
|
|
a593ca0130 | ||
|
|
5e94c0503f | ||
|
|
f32b07a646 | ||
|
|
461520dca6 | ||
|
|
6258b15d8a | ||
|
|
18409ed52b | ||
|
|
d67961a402 | ||
|
|
7b8b69f5d3 | ||
|
|
8385c22616 | ||
|
|
bfd9bfbb89 | ||
|
|
14967a61bf | ||
|
|
1d8e879c96 | ||
|
|
06a303954e | ||
|
|
4e932785fe | ||
|
|
b96379e443 | ||
|
|
9d474a5388 | ||
|
|
47e1b0e523 | ||
|
|
ef106bff24 | ||
|
|
6bb5fbc978 | ||
|
|
3b3ccbd563 | ||
|
|
2adae474b7 | ||
|
|
5de30f96a1 | ||
|
|
d624f7ddb8 | ||
|
|
f53051a35f | ||
|
|
ed5c7ba19b | ||
|
|
437459f6eb | ||
|
|
168b54e0fb | ||
|
|
fdb8acd531 | ||
|
|
d92c8601a7 | ||
|
|
07665e676a | ||
|
|
4b1ba55bfa | ||
|
|
79e5bb6efb | ||
|
|
2e9ad2f421 | ||
|
|
172dc4edc6 | ||
|
|
31f0389397 | ||
|
|
c1bf044a85 | ||
|
|
516ef23703 | ||
|
|
c6037f3f7d | ||
|
|
4a4e82daba | ||
|
|
0a9d29f227 | ||
|
|
055ddbee7a | ||
|
|
89d113a52a | ||
|
|
d65b04571c | ||
|
|
2a232036c2 | ||
|
|
4b1c025427 | ||
|
|
783ce8f44b | ||
|
|
ef65ceafb8 | ||
|
|
da404551b1 | ||
|
|
f2ae345f20 | ||
|
|
18dd68eb1d | ||
|
|
d484a716ad | ||
|
|
6594badf37 | ||
|
|
5f03a5991e | ||
|
|
9477e7cb08 | ||
|
|
29c014deef | ||
|
|
0dfb074820 | ||
|
|
eb4e2d21ba | ||
|
|
f887489603 | ||
|
|
705c2b9077 | ||
|
|
8840fbc87a | ||
|
|
0a6e08a9c3 | ||
|
|
fff096876d | ||
|
|
0224ca1e24 | ||
|
|
039b9f4f03 | ||
|
|
c3d37f367d | ||
|
|
253707f65f | ||
|
|
8a5da53870 | ||
|
|
6125f49ab1 | ||
|
|
7bf80663dc | ||
|
|
6b4a7da010 | ||
|
|
750db81371 | ||
|
|
79adfc0b57 | ||
|
|
90318fd6d6 | ||
|
|
95b14c8df0 | ||
|
|
08b0704b0c | ||
|
|
52dc4f9822 | ||
|
|
33d59b26a6 | ||
|
|
9afb266fce | ||
|
|
df0e8928d7 | ||
|
|
75eb152f03 | ||
|
|
2a43710e52 | ||
|
|
6c84416c3c | ||
|
|
807230167d | ||
|
|
7bcb4e4802 | ||
|
|
9209909ff7 | ||
|
|
edd2b0ef73 | ||
|
|
8ac95607b7 | ||
|
|
7b22753330 | ||
|
|
d8ab50b5fc | ||
|
|
8d252c8b2e | ||
|
|
686f8c9692 | ||
|
|
7f2ff83c44 | ||
|
|
0c19777c8a | ||
|
|
bf3cd7ac09 | ||
|
|
1bebc52bf4 | ||
|
|
194cf8c83d | ||
|
|
606ddac169 | ||
|
|
e10b245652 |
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,38 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
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:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(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.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
|
||||
# or dependency change: a drifted dependency still shows up within a day, and always before a
|
||||
# release ships. PRs that change the FreeBSD packaging itself still run it.
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow or packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "47 3 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- "crates/playback/**"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
schedule:
|
||||
- cron: "47 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -18,16 +18,44 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
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.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
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
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
# 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:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -32,6 +32,7 @@ People trust SoundCraft with their recordings; a crash loses takes. **This outra
|
||||
- The audio callback never blocks, allocates in steady state, or panics.
|
||||
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
- `Engine::execute` catches escaped panics and restores the document. Every crash fix lands with a regression test.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
|
||||
|
||||
## Architecture (layers enforced by `cargo xtask layers`)
|
||||
|
||||
Generated
+261
-318
File diff suppressed because it is too large
Load Diff
+11
-3
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.12.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -32,21 +32,27 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
proptest = "1"
|
||||
|
||||
# audio
|
||||
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
|
||||
symphonia = { version = "0.6", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
|
||||
rustfft = "6"
|
||||
# Session bundles (one file holding a session and its audio, for the browser): stored entries only.
|
||||
zip = { version = "9", default-features = false }
|
||||
cpal = "0.16"
|
||||
cpal = "0.18"
|
||||
|
||||
# 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" }
|
||||
|
||||
# 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
|
||||
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.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
@@ -64,9 +70,11 @@ type_complexity = "allow"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.soundcraft.png" width="128" height="128" alt="SoundCraft icon: a singing bird on a blue tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">SoundCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Recording, editing and mixing audio; an open-source, clean-room reimplementation of Avid Pro Tools, rebuilt in pure Rust.</b>
|
||||
<b>Recording, editing and mixing audio; an open-source, modern digital audio workstation (DAW), built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -25,6 +29,9 @@
|
||||
<img alt="Platforms" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-0b7385?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue?style=flat-square">
|
||||
<img alt="Status: pre-alpha" src="https://img.shields.io/badge/status-pre--alpha-orange?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_soundcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/soundcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -375,10 +382,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and SoundCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Avid and Pro Tools are trademarks or registered trademarks of Avid Technology, Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Avid Technology, Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+13
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (readiness by audience with hours to ~95 %) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
|
||||
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
|
||||
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
|
||||
@@ -122,6 +122,12 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change.
|
||||
- **2026-10-16:** Master fader volume automation uses each render block's actual last sample, matching track faders. Breakpoints in the next block no longer pull the preceding block's level toward them, and ramps reach their last-sample value. Regression tests cover nonzero starts, one-sample callbacks and several block sizes. No readiness percentage change.
|
||||
- **2026-10-16:** Aux, folder and master latency calculations exclude inserts already taken out for producing NaN or infinity, matching audio tracks. Subsequent blocks no longer compensate for an insert that is bypassed after failure. Regression tests check impulse timing, healthy insert latency and reset/retry at 64- and 512-sample blocks. No readiness percentage change.
|
||||
- **2026-10-14:** MCP `batch` rejects more than 1000 calls before executing any command, instead of silently running only the first 1000 (#460). No readiness percentage change.
|
||||
- **2026-10-13:** WAV: an empty data chunk followed by metadata (LIST, bext) reads as no audio; a recording that was never finalised (sizes left at 0) still gives what was written (#466)
|
||||
- **2026-10-13:** Sampler: a ping-pong loop keeps its phase and direction when a step is wider than the loop (a narrow loop with high transposition), instead of reflecting once and dropping the rest of the travel (#463)
|
||||
- **2026-10-13:** `mix.instrument` takes the built-in instrument's parameters in `params` (validated, clamped) and refuses any other field, so a synth setting written beside `plugin` is an error instead of being accepted and ignored (#265)
|
||||
- **2026-10-13:** A bounce of a bus that an aux returns onto lines up with the timeline: the aux insert's lookahead is compensated like the main mix's (it left the bus late and cut the end off), and tracks routed to that bus wait for it too (#246)
|
||||
- **2026-10-13:** Delay compensation covers the inserts of the master faders: a bounce of the main mix with a Maximizer on the master lines up with the same mix without it (it came out 71 samples late) (#351)
|
||||
@@ -155,6 +161,12 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged |
|
||||
| 2026-10-16 | trivial | Master volume automation block boundaries corrected; readiness estimates unchanged |
|
||||
| 2026-10-16 | trivial | Failed inserts no longer contribute aux, folder or master latency after containment; readiness estimates unchanged |
|
||||
| 2026-10-14 | trivial | Progress log: oversized MCP batches rejected before execution (#460) |
|
||||
| 2026-10-13 | trivial | Progress log: empty WAV data chunk followed by metadata (#466) |
|
||||
| 2026-10-13 | trivial | Progress log: sampler ping-pong phase for wide steps (#463) |
|
||||
| 2026-10-13 | trivial | Progress log: mix.instrument params, unknown fields refused (#265) |
|
||||
| 2026-10-13 | trivial | Progress log: bus bounce compensates aux returns (#246) |
|
||||
| 2026-10-13 | trivial | Progress log: master insert latency compensated (#351) |
|
||||
|
||||
@@ -3,30 +3,22 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AutomationArgs {
|
||||
pub read_root: Option<PathBuf>,
|
||||
pub write_root: Option<PathBuf>,
|
||||
read_given: bool,
|
||||
write_given: bool,
|
||||
pub read_roots: Vec<PathBuf>,
|
||||
pub write_roots: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||
Self { read_root, write_root, read_given: false, write_given: false }
|
||||
}
|
||||
|
||||
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
|
||||
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
|
||||
/// Returns whether this argument is a root flag. Each flag may be given more than once (#874),
|
||||
/// in either spelling (`--flag DIR`, `--flag=DIR`): every use adds a folder.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let (root, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
||||
let roots = match flag {
|
||||
"--automation-read-root" => &mut self.read_roots,
|
||||
"--automation-write-root" => &mut self.write_roots,
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *given {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
@@ -40,8 +32,7 @@ impl AutomationArgs {
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
*root = Some(value.into());
|
||||
*given = true;
|
||||
roots.push(value.into());
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -51,40 +42,43 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_are_taken_once_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
fn roots_add_up_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::default();
|
||||
let mut args = ["a folder".to_owned(), "song.scraft".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--demo", &mut args).unwrap());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
assert_eq!(roots.write_root, Some("out".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("a folder")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("out")]);
|
||||
assert_eq!(args.next().as_deref(), Some("song.scraft"));
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
// #874: a flag given again adds a folder, in either spelling.
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_operands_leave_roots_unchanged() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
|
||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||
let mut roots = AutomationArgs { read_roots: vec!["read".into()], write_roots: vec!["write".into()] };
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_root, Some("read".into()));
|
||||
assert_eq!(roots.write_root, Some("write".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("read")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("write")]);
|
||||
}
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
let mut roots = AutomationArgs::default();
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
let mut roots = AutomationArgs::default();
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
//! soundcraft-cli commands [FILTER] list commands
|
||||
//! soundcraft-cli describe ID one command's details
|
||||
//! soundcraft-cli app [--port P] METHOD [JSON] call a running app's control channel
|
||||
//! soundcraft-cli mcp [--connect PORT] [--demo] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
//! MCP server on stdio. The two roots confine every file the
|
||||
//! soundcraft-cli mcp [--connect PORT] [--demo] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
//! MCP server on stdio. The roots confine every file the
|
||||
//! agent's commands read (open, import, presets) and write
|
||||
//! (save, bounce, export) to those folders, links followed;
|
||||
//! a root left out grants none of its access. They run the
|
||||
//! in-process server, so they conflict with --connect.
|
||||
//! each flag may be given more than once, a path inside any
|
||||
//! of its folders being allowed, and a kind left out grants
|
||||
//! none of its access. They run the in-process server, so
|
||||
//! they conflict with --connect.
|
||||
//! soundcraft-cli parity [--write PATH] parity report (markdown)
|
||||
//! soundcraft-cli plugins list built-in plugins
|
||||
//! ```
|
||||
@@ -110,7 +112,7 @@ fn info(args: &[String]) -> ExitCode {
|
||||
Ok(b) => b,
|
||||
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) {
|
||||
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());
|
||||
@@ -134,7 +136,7 @@ fn convert(args: &[String]) -> ExitCode {
|
||||
Ok(b) => b,
|
||||
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) {
|
||||
Ok(x) => x,
|
||||
Err(e) => return fail(format!("{input}: {e}")),
|
||||
@@ -146,7 +148,7 @@ fn convert(args: &[String]) -> ExitCode {
|
||||
if args.iter().any(|a| a == "--normalize") {
|
||||
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 {
|
||||
return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS));
|
||||
};
|
||||
@@ -398,7 +400,7 @@ struct McpArgs {
|
||||
fn parse_mcp_args(args: &[String]) -> Result<McpArgs, String> {
|
||||
let mut connect = None;
|
||||
let mut demo = false;
|
||||
let mut roots_args = automation_args::AutomationArgs::new(None, None);
|
||||
let mut roots_args = automation_args::AutomationArgs::default();
|
||||
let mut it = args.iter().cloned();
|
||||
while let Some(a) = it.next() {
|
||||
if roots_args.consume(&a, &mut it)? {
|
||||
@@ -411,7 +413,9 @@ fn parse_mcp_args(args: &[String]) -> Result<McpArgs, String> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
|
||||
let read: Vec<&std::path::Path> = roots_args.read_roots.iter().map(std::path::PathBuf::as_path).collect();
|
||||
let write: Vec<&std::path::Path> = roots_args.write_roots.iter().map(std::path::PathBuf::as_path).collect();
|
||||
let roots = AutomationRoots::new(&read, &write)?;
|
||||
if roots.is_some() && connect.is_some() {
|
||||
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
|
||||
so drop --connect (or use --connect without the roots)"
|
||||
@@ -437,8 +441,7 @@ fn mcp(args: &[String]) -> ExitCode {
|
||||
if let Some(roots) = roots {
|
||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||
file_access::confine_process(roots.clone());
|
||||
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
eprintln!("soundcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||||
eprintln!("soundcraft-cli: files confined: {}", roots.describe());
|
||||
h = h.with_automation_roots(Some(roots));
|
||||
}
|
||||
let mut s = Server::new(h);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! `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).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
use serde_json::Value;
|
||||
@@ -28,7 +27,16 @@ fn tmpdir(tag: &str) -> PathBuf {
|
||||
/// Send `calls` (tool name, arguments) to `soundcraft-cli mcp <args>`; returns whether it exited
|
||||
/// cleanly, its stderr, and the stdout lines after `initialize`, one per call.
|
||||
fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) {
|
||||
let mut child = bin().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
mcp_session_in(None, args, calls)
|
||||
}
|
||||
|
||||
/// [`mcp_session`] with the server started in the working directory `cwd`.
|
||||
fn mcp_session_in(cwd: Option<&Path>, args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) {
|
||||
let mut cmd = bin();
|
||||
if let Some(cwd) = cwd {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
let mut child = cmd.arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
|
||||
@@ -102,6 +110,74 @@ fn mcp_automation_roots_confine_the_server() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// #874: each root flag may be given more than once. A path inside any root of its kind passes;
|
||||
/// the two roots' common parent, `..` out of a root and a link out of one stay refused.
|
||||
#[test]
|
||||
fn mcp_automation_roots_take_several_folders() {
|
||||
// Resolved, as the server shows the roots (`/var` is `/private/var` on macOS).
|
||||
let base = std::fs::canonicalize(tmpdir("several")).unwrap();
|
||||
for d in ["in", "in2", "out", "out2", "outside"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
for f in ["in/a.wav", "in2/b.wav", "outside/secret.wav", "parent.wav"] {
|
||||
make_wav(&base.join(f));
|
||||
}
|
||||
let p = |f: &str| base.join(f).to_str().unwrap().to_string();
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let calls = [
|
||||
("import_audio", serde_json::json!({"path": p("in/a.wav")})),
|
||||
("import_audio", serde_json::json!({"path": p("in2/b.wav")})),
|
||||
// The roots' common parent is not a root.
|
||||
("import_audio", serde_json::json!({"path": p("parent.wav")})),
|
||||
("import_audio", serde_json::json!({"path": format!("{}{sep}..{sep}outside{sep}secret.wav", p("in2"))})),
|
||||
("bounce_mix", serde_json::json!({"path": p("out/a.wav")})),
|
||||
("bounce_mix", serde_json::json!({"path": p("out2/b.wav")})),
|
||||
("bounce_mix", serde_json::json!({"path": p("x.wav")})),
|
||||
// Read and write stay separate: a read root is not a write root, nor the other way round.
|
||||
("bounce_mix", serde_json::json!({"path": p("in2/c.wav")})),
|
||||
("import_audio", serde_json::json!({"path": p("out2/b.wav")})),
|
||||
];
|
||||
let read2 = format!("--automation-read-root={}", p("in2"));
|
||||
let args = ["--automation-read-root", &p("in"), &read2, "--automation-write-root", &p("out"), "--automation-write-root", &p("out2")];
|
||||
let (success, stderr, replies) = mcp_session(&args, &calls);
|
||||
assert!(success, "{stderr}");
|
||||
assert!(stderr.contains(&format!("read {}, {}", p("in"), p("in2"))), "{stderr}");
|
||||
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
|
||||
assert_eq!(errors, [false, false, true, true, false, false, true, true, true], "{replies:?}");
|
||||
for (i, kind) in [(2, "read"), (3, "read"), (6, "write"), (7, "write"), (8, "read")] {
|
||||
assert!(text(&replies[i]).contains(&format!("outside the {kind} roots ")), "{}", text(&replies[i]));
|
||||
}
|
||||
assert!(base.join("out/a.wav").is_file() && base.join("out2/b.wav").is_file());
|
||||
assert!(!base.join("x.wav").exists() && !base.join("in2/c.wav").exists());
|
||||
|
||||
// A link in one root that leads out of all of them is outside.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(base.join("outside/secret.wav"), base.join("in2/leak.wav")).unwrap();
|
||||
let (_, _, replies) = mcp_session(&args, &[("import_audio", serde_json::json!({"path": p("in2/leak.wav")}))]);
|
||||
assert!(text(&replies[0]).contains("outside the read roots"), "{}", text(&replies[0]));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_save_session_name_lands_in_the_working_directory_inside_the_roots() {
|
||||
let work = tmpdir("bare-name");
|
||||
let work_s = work.to_str().unwrap();
|
||||
let calls = [
|
||||
("new_session", serde_json::json!({"name": "t", "sample_rate": 48000})),
|
||||
("execute", serde_json::json!({"command": "track.new", "params": {"kind": "audio", "name": "Tone", "format": "mono"}})),
|
||||
("save_session", serde_json::json!({"path": "t.scraft"})),
|
||||
];
|
||||
let (success, stderr, replies) = mcp_session_in(Some(&work), &["--automation-read-root", work_s, "--automation-write-root", work_s], &calls);
|
||||
assert!(success, "{stderr}");
|
||||
for reply in &replies {
|
||||
assert_eq!(reply["result"]["isError"], false, "{reply}");
|
||||
}
|
||||
assert!(work.join("t.scraft").is_file());
|
||||
let _ = std::fs::remove_dir_all(work);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
let base = tmpdir("roots-args");
|
||||
@@ -118,8 +194,9 @@ fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
// The flags confine the in-process server, so they cannot be combined with --connect.
|
||||
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
|
||||
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
|
||||
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
|
||||
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
|
||||
// #874: a flag may be given again, but every folder it names must exist.
|
||||
fails(&["--automation-read-root", dir, "--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
fails(&["--automation-write-root", dir, "--automation-write-root"], "needs a folder");
|
||||
fails(&["--automation-read-root"], "needs a folder");
|
||||
fails(&["--automation-write-root", "--demo"], "needs a folder");
|
||||
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
@@ -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">
|
||||
<head>
|
||||
<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>
|
||||
<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 />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! The browser shell: the eframe web runner around [`SoundApp`].
|
||||
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::{Engine, io};
|
||||
use soundcraft_ui_egui::dialogs::Dialog;
|
||||
use soundcraft_ui_egui::sampler_window::SampleRead;
|
||||
@@ -166,30 +165,23 @@ impl WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) {
|
||||
if matches!(purpose, Purpose::Session) && soundcraft_ui_egui::bounce::refuses_session_switch(&mut self.app) {
|
||||
return;
|
||||
}
|
||||
fn handle(&mut self, purpose: Purpose, name: String, bytes: Vec<u8>) {
|
||||
let e = &mut self.app.engine;
|
||||
let at = e.session().edit.selection.start.max(0);
|
||||
let result = match purpose {
|
||||
Purpose::Session => {
|
||||
let _ = self.app.run("transport.stop", json!({}));
|
||||
io::open_session_bytes(&mut self.app.engine, name, bytes).map(|missing| {
|
||||
if missing.is_empty() { format!("Opened {name}") } else { format!("Opened {name} ({} missing media)", missing.len()) }
|
||||
})
|
||||
}
|
||||
// Asks first when the open session has changes: the bytes wait for the answer.
|
||||
Purpose::Session => return soundcraft_ui_egui::dialogs::open_session_bytes(&mut self.app, name, bytes),
|
||||
// Imports are edits: one undo step each, like the import commands.
|
||||
Purpose::Audio => {
|
||||
let before = e.session_arc();
|
||||
io::import_audio_bytes(e, name, bytes, None, None, at).map(|_| {
|
||||
io::import_audio_bytes(e, &name, &bytes, None, None, at).map(|_| {
|
||||
e.push_undo("Import Audio", before);
|
||||
format!("Imported {name}")
|
||||
})
|
||||
}
|
||||
Purpose::Midi => {
|
||||
let before = e.session_arc();
|
||||
io::import_midi_bytes(e, bytes, at, true).map(|_| {
|
||||
io::import_midi_bytes(e, &bytes, at, true).map(|_| {
|
||||
e.push_undo("Import MIDI", before);
|
||||
format!("Imported {name}")
|
||||
})
|
||||
@@ -205,7 +197,7 @@ impl eframe::App for WebShell {
|
||||
self.take_path_prompt(ctx);
|
||||
let pending = std::mem::take(&mut *self.inbox.borrow_mut());
|
||||
for (purpose, name, bytes) in pending {
|
||||
self.handle(purpose, &name, &bytes);
|
||||
self.handle(purpose, name, bytes);
|
||||
}
|
||||
let samples = std::mem::take(&mut *self.samples.borrow_mut());
|
||||
for (read, bytes) in samples {
|
||||
|
||||
@@ -22,6 +22,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
|
||||
egui.workspace = true
|
||||
serde_json.workspace = true
|
||||
log.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd.workspace = true
|
||||
@@ -31,6 +32,6 @@ rfd.workspace = true
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { version = "0.19.2", default-features = false }
|
||||
muda = { version = "0.21.2", default-features = false }
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] }
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
//! Per-user settings and automation profile directories.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Portable mode (#268): this file beside the executable keeps settings, autosaves, presets and
|
||||
/// logs in [`PORTABLE_DATA_DIR`] next to it instead of the user profile.
|
||||
const PORTABLE_MARKER: &str = "portable.txt";
|
||||
const PORTABLE_DATA_DIR: &str = "SoundCraftData";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Platform {
|
||||
@@ -13,7 +18,18 @@ enum Platform {
|
||||
/// SoundCraft's settings folder. A nonempty `$SOUNDCRAFT_CONFIG_DIR` overrides the platform default.
|
||||
/// The override is the complete directory; no application-name suffix is appended. An explicitly
|
||||
/// empty value disables the settings directory instead of falling back to a personal profile.
|
||||
/// Otherwise `portable.txt` beside the executable selects `<exe dir>/SoundCraftData`, when that
|
||||
/// folder can be written (a read-only medium falls back to the platform default).
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
static DIR: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let exe = std::env::current_exe().ok();
|
||||
resolve(exe.as_deref().and_then(Path::parent))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn resolve(exe_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
let platform = if cfg!(target_os = "macos") {
|
||||
Platform::Mac
|
||||
} else if cfg!(windows) {
|
||||
@@ -21,13 +37,20 @@ pub fn config_dir() -> Option<PathBuf> {
|
||||
} else {
|
||||
Platform::Other
|
||||
};
|
||||
config_dir_with(platform, |name| std::env::var_os(name))
|
||||
config_dir_with(platform, |name| std::env::var_os(name), exe_dir)
|
||||
}
|
||||
|
||||
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
if let Some(dir) = env("SOUNDCRAFT_CONFIG_DIR") {
|
||||
return (!dir.is_empty()).then(|| PathBuf::from(dir));
|
||||
}
|
||||
if let Some(exe_dir) = exe_dir.filter(|d| d.join(PORTABLE_MARKER).is_file()) {
|
||||
let data = exe_dir.join(PORTABLE_DATA_DIR);
|
||||
match writable(&data) {
|
||||
Ok(()) => return Some(data),
|
||||
Err(e) => log::warn!("portable mode: {} can't be written ({e}); using the user profile instead", data.display()),
|
||||
}
|
||||
}
|
||||
match platform {
|
||||
Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/SoundCraft")),
|
||||
Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("SoundCraft")),
|
||||
@@ -37,6 +60,14 @@ fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -
|
||||
}
|
||||
}
|
||||
|
||||
/// Create `dir` if needed and prove a file can be written in it.
|
||||
fn writable(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let probe = dir.join(format!(".write-test-{}", std::process::id()));
|
||||
std::fs::write(&probe, b"")?;
|
||||
std::fs::remove_file(probe)
|
||||
}
|
||||
|
||||
/// Read bounded settings, recovering unreadable docking metadata independently of other settings.
|
||||
pub fn load_ui(path: &std::path::Path) -> Result<Option<soundcraft_ui_egui::UiState>, String> {
|
||||
read_ui(path, true)
|
||||
@@ -195,16 +226,20 @@ mod config_tests {
|
||||
}
|
||||
|
||||
fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option<PathBuf> {
|
||||
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))))
|
||||
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_override_wins_without_reading_user_profile() {
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
let actual = config_dir_with(platform, |name| {
|
||||
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
|
||||
Some(OsString::from("isolated/settings"))
|
||||
});
|
||||
let actual = config_dir_with(
|
||||
platform,
|
||||
|name| {
|
||||
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
|
||||
Some(OsString::from("isolated/settings"))
|
||||
},
|
||||
None,
|
||||
);
|
||||
assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
|
||||
}
|
||||
}
|
||||
@@ -213,10 +248,14 @@ mod config_tests {
|
||||
fn config_empty_override_disables_profile_without_reading_defaults() {
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
assert_eq!(
|
||||
config_dir_with(platform, |name| {
|
||||
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
|
||||
Some(OsString::new())
|
||||
}),
|
||||
config_dir_with(
|
||||
platform,
|
||||
|name| {
|
||||
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
|
||||
Some(OsString::new())
|
||||
},
|
||||
None
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -234,11 +273,34 @@ mod config_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_txt_beside_the_exe_keeps_settings_next_to_it() {
|
||||
let exe = std::env::temp_dir().join(format!("soundcraft-portable-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
std::fs::create_dir_all(&exe).unwrap();
|
||||
let vars = [("APPDATA", "/roaming")];
|
||||
assert_eq!(resolve_with_exe(&vars, &exe), Some(PathBuf::from("/roaming/SoundCraft")), "no marker: the profile");
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
assert_eq!(resolve_with_exe(&vars, &exe), Some(exe.join("SoundCraftData")));
|
||||
assert!(exe.join("SoundCraftData").is_dir());
|
||||
// The explicit override still wins.
|
||||
assert_eq!(resolve_with_exe(&[("SOUNDCRAFT_CONFIG_DIR", "/custom")], &exe), Some(PathBuf::from("/custom")));
|
||||
// A data folder that can't be made (a file is in the way) falls back to the profile.
|
||||
std::fs::remove_dir_all(exe.join("SoundCraftData")).unwrap();
|
||||
std::fs::write(exe.join("SoundCraftData"), b"").unwrap();
|
||||
assert_eq!(resolve_with_exe(&vars, &exe), Some(PathBuf::from("/roaming/SoundCraft")));
|
||||
std::fs::remove_dir_all(exe).unwrap();
|
||||
}
|
||||
|
||||
fn resolve_with_exe(vars: &[(&str, &str)], exe: &Path) -> Option<PathBuf> {
|
||||
config_dir_with(Platform::Windows, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))), Some(exe))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn config_override_preserves_non_unicode_paths() {
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
let value = OsString::from_vec(b"isolated/\xff".to_vec());
|
||||
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value)));
|
||||
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone()), None), Some(PathBuf::from(value)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,28 +112,36 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
|
||||
/// logging never panics and never silently resets to the epoch for a convertible instant.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
|
||||
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
|
||||
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
|
||||
let (y, t) = utc_parts(secs, since.subsec_nanos());
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
||||
/// clock can't overflow).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
|
||||
/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
|
||||
/// so month, day and time of day come from chrono and only the year is carried, which keeps every
|
||||
/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
|
||||
fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -533,6 +541,20 @@ mod tests {
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
|
||||
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
|
||||
// SystemTime can't hold this instant, so it is only checked where it exists.
|
||||
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
|
||||
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -182,7 +182,7 @@ fn services() -> Services {
|
||||
if let Some(dir) = path.parent().filter(|dir| dir.is_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.save_file().map(|p| p.to_string_lossy().into_owned())
|
||||
|
||||
@@ -50,3 +50,8 @@ sync_github_name = false
|
||||
|
||||
[people.Momentum96]
|
||||
sync_github_name = true
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -192,7 +192,7 @@ pub enum AuError {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod api {
|
||||
use super::*;
|
||||
use super::{AuDescriptor, AuError, Plugin, PluginInfo};
|
||||
|
||||
pub fn scan() -> Vec<AuDescriptor> {
|
||||
registry::scan()
|
||||
@@ -210,7 +210,7 @@ mod api {
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod api {
|
||||
use super::*;
|
||||
use super::{AuDescriptor, AuError, Plugin, PluginInfo};
|
||||
|
||||
pub fn scan() -> Vec<AuDescriptor> {
|
||||
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 →
|
||||
//! process → state, plus an instrument playing a note. Skips (passes) when a unit is missing.
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use soundcraft_dsp::{Category, Plugin};
|
||||
|
||||
@@ -185,6 +185,9 @@ pub(crate) struct Writer<W> {
|
||||
quantizers: Vec<Quantizer>,
|
||||
frames: u64,
|
||||
bytes: Vec<u8>,
|
||||
/// Set when a block write has failed: the sink may hold a prefix of that block while
|
||||
/// `frames` does not count it, so the writer refuses every later call.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Writer<W> {
|
||||
@@ -193,7 +196,7 @@ impl<W: Write + Seek> Writer<W> {
|
||||
let start = out.stream_position()?;
|
||||
out.write_all(&header(channels, sample_rate, depth, 0)?)?;
|
||||
let quantizers = Quantizer::per_channel(u32::from(sample_format(depth).0), dither, channels);
|
||||
Ok(Writer { out, start, channels, sample_rate, depth, quantizers, frames: 0, bytes: Vec::new() })
|
||||
Ok(Writer { out, start, channels, sample_rate, depth, quantizers, frames: 0, bytes: Vec::new(), failed: false })
|
||||
}
|
||||
|
||||
/// The frame count after `frames` more, or an error when AIFF can't hold that many (4 GiB).
|
||||
@@ -206,13 +209,22 @@ impl<W: Write + Seek> Writer<W> {
|
||||
/// Appends `frames` frames of planar audio (missing samples are silence), unless they don't
|
||||
/// fit (see [`Writer::check_room`]).
|
||||
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let total = self.check_room(frames as u64)?;
|
||||
write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, sample_format(self.depth), true)?;
|
||||
if let Err(e) = write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, sample_format(self.depth), true) {
|
||||
self.failed = true;
|
||||
return Err(AudioError::from(e));
|
||||
}
|
||||
self.frames = total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut self) -> Result<W> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let data_len = u64::from(sample_format(self.depth).0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames);
|
||||
if data_len % 2 == 1 {
|
||||
self.out.write_all(&[0])?;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! partitioned Rice-coded residuals. Decoding is symphonia's, after [`check_metadata`].
|
||||
|
||||
use crate::pcm::{Quantizer, be_u32, le_u32, validate_buffer};
|
||||
use crate::{AudioBuffer, AudioError, BitDepth, Result};
|
||||
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat};
|
||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||
|
||||
const BLOCK_SIZE: usize = 4096;
|
||||
@@ -318,6 +318,10 @@ pub(crate) struct Writer<W> {
|
||||
pending: Vec<Vec<i64>>,
|
||||
frames: u64,
|
||||
frame_no: u64,
|
||||
/// Set when a frame write has failed: `write_frame` clears the pending samples before
|
||||
/// writing, so a failed frame is lost while `frames` still counts it; the writer refuses
|
||||
/// every later call.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Writer<W> {
|
||||
@@ -336,6 +340,7 @@ impl<W: Write + Seek> Writer<W> {
|
||||
pending: (0..channels).map(|_| Vec::with_capacity(BLOCK_SIZE)).collect(),
|
||||
frames: 0,
|
||||
frame_no: 0,
|
||||
failed: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -351,6 +356,9 @@ impl<W: Write + Seek> Writer<W> {
|
||||
/// Appends `frames` frames of planar audio (missing samples are silence), unless they don't
|
||||
/// fit (see [`Writer::check_room`]).
|
||||
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let total = self.check_room(frames as u64)?;
|
||||
let mut done = 0;
|
||||
while done < frames {
|
||||
@@ -361,8 +369,11 @@ impl<W: Write + Seek> Writer<W> {
|
||||
p.extend((0..n).map(|i| i64::from(q.quantize(src.get(i).copied().unwrap_or(0.0)))));
|
||||
}
|
||||
done += n;
|
||||
if held + n == BLOCK_SIZE {
|
||||
self.write_frame()?;
|
||||
if held + n == BLOCK_SIZE
|
||||
&& let Err(e) = self.write_frame()
|
||||
{
|
||||
self.failed = true;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
self.frames = total;
|
||||
@@ -408,6 +419,9 @@ impl<W: Write + Seek> Writer<W> {
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut self) -> Result<W> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
self.write_frame()?;
|
||||
let info = stream_info(self.channels, self.sample_rate, self.bps, self.frames);
|
||||
self.out.seek(SeekFrom::Start(self.start.saturating_add(4)))?;
|
||||
@@ -454,6 +468,49 @@ pub(crate) fn check_metadata(b: &[u8], at: usize) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The info of a FLAC stream with no audio frames at all: STREAMINFO declares 0 samples and
|
||||
/// nothing follows the metadata blocks. Symphonia's reader fails on these, as it looks for a first
|
||||
/// frame, so the caller answers them itself.
|
||||
pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option<AudioInfo> {
|
||||
let mut pos = at.checked_add(4)?;
|
||||
let mut first = true;
|
||||
loop {
|
||||
let head = be_u32(b, pos)?;
|
||||
let (body, len) = (pos.checked_add(4)?, (head & 0x00ff_ffff) as usize);
|
||||
let end = body.checked_add(len)?;
|
||||
if end > b.len() {
|
||||
return None;
|
||||
}
|
||||
if first && ((head >> 24) & 0x7f != 0 || len != 34) {
|
||||
return None;
|
||||
}
|
||||
first = false;
|
||||
if head >> 31 == 1 {
|
||||
if end != b.len() {
|
||||
return None;
|
||||
}
|
||||
break;
|
||||
}
|
||||
pos = end;
|
||||
}
|
||||
// STREAMINFO, from the sample rate on: 20 bits rate, 3 channels - 1, 5 bits per sample - 1, 36 bits total.
|
||||
let si = at.checked_add(4 + 4 + 10)?;
|
||||
let packed = u64::from_be_bytes(b.get(si..si.checked_add(8)?)?.try_into().ok()?);
|
||||
let sample_rate = (packed >> 44) as u32;
|
||||
let channels = ((packed >> 41) & 0x7) as u16 + 1;
|
||||
let bps = ((packed >> 36) & 0x1f) as u32 + 1;
|
||||
if packed & 0xf_ffff_ffff != 0 || sample_rate == 0 {
|
||||
return None;
|
||||
}
|
||||
let sample_format = match bps {
|
||||
1..=8 => SampleFormat::Int8,
|
||||
9..=16 => SampleFormat::Int16,
|
||||
17..=24 => SampleFormat::Int24,
|
||||
_ => SampleFormat::Int32,
|
||||
};
|
||||
Some(AudioInfo { format: FileFormat::Flac, sample_format, sample_rate, channels, frames: 0, bwf: None })
|
||||
}
|
||||
|
||||
/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds.
|
||||
/// The vendor string is skipped unread.
|
||||
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
|
||||
|
||||
@@ -294,6 +294,13 @@ pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u
|
||||
}
|
||||
}
|
||||
|
||||
/// The error a stream encoder returns for every call after one of its writes has failed: the
|
||||
/// sink may already hold a prefix of the failed block, so the encoder's counters can no longer
|
||||
/// describe the bytes on the sink, and later writes or `finish` must not claim success.
|
||||
pub(crate) fn failed_write_error() -> AudioError {
|
||||
AudioError::Encode("the stream encoder already failed on an earlier write; its output is incomplete".into())
|
||||
}
|
||||
|
||||
/// Writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32` or FLAC block by block, producing
|
||||
/// the same bytes [`encode_with_channel_mask`] would for the whole buffer.
|
||||
pub struct FileEncoder<W: Write + Seek>(Writer<W>);
|
||||
@@ -310,7 +317,14 @@ impl<W: Write + Seek> FileEncoder<W> {
|
||||
pub fn new(out: W, opts: &EncodeOptions, channels: usize, sample_rate: u32, mask: Option<u32>) -> Result<Self> {
|
||||
let (depth, dither) = (opts.bit_depth, opts.dither);
|
||||
Ok(FileEncoder(match opts.format {
|
||||
FileFormat::Wav => Writer::Wav(wav::Writer::new(out, channels, sample_rate, depth, dither, opts.bwf.as_ref(), mask, false)?),
|
||||
FileFormat::Wav => Writer::Wav(wav::Writer::new(
|
||||
out,
|
||||
channels,
|
||||
sample_rate,
|
||||
depth,
|
||||
dither,
|
||||
wav::HeaderExtras { bwf: opts.bwf.as_ref(), mask, force_rf64: false },
|
||||
)?),
|
||||
FileFormat::Aiff => Writer::Aiff(aiff::Writer::new(out, channels, sample_rate, depth, dither)?),
|
||||
FileFormat::Flac => Writer::Flac(flac::Writer::new(out, channels, sample_rate, depth, dither)?),
|
||||
other => return Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))),
|
||||
@@ -329,7 +343,9 @@ impl<W: Write + Seek> FileEncoder<W> {
|
||||
}
|
||||
|
||||
/// Appends the first `frames` frames of `block`, one `Vec` per channel (missing samples are
|
||||
/// silence), unless they don't fit (see [`FileEncoder::check_room`]).
|
||||
/// silence), unless they don't fit (see [`FileEncoder::check_room`]). Once a write has
|
||||
/// failed, the encoder is unusable: the sink may hold a prefix of the failed block, so
|
||||
/// every later `write` and `finish` fails instead of completing an inconsistent file.
|
||||
pub fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
match &mut self.0 {
|
||||
Writer::Wav(w) => w.write(block, frames),
|
||||
|
||||
+186
-81
@@ -3,18 +3,18 @@
|
||||
use std::io::Cursor;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::{
|
||||
CODEC_TYPE_AAC, CODEC_TYPE_ADPCM_G722, CODEC_TYPE_ADPCM_G726, CODEC_TYPE_ADPCM_G726LE, CODEC_TYPE_ADPCM_IMA_QT, CODEC_TYPE_ADPCM_IMA_WAV,
|
||||
CODEC_TYPE_ADPCM_MS, CODEC_TYPE_ALAC, CODEC_TYPE_FLAC, CODEC_TYPE_MP1, CODEC_TYPE_MP2, CODEC_TYPE_MP3, CODEC_TYPE_NULL, CODEC_TYPE_OPUS,
|
||||
CODEC_TYPE_PCM_ALAW, CODEC_TYPE_PCM_F32BE, CODEC_TYPE_PCM_F32LE, CODEC_TYPE_PCM_F64BE, CODEC_TYPE_PCM_F64LE, CODEC_TYPE_PCM_MULAW,
|
||||
CODEC_TYPE_VORBIS, CodecParameters, CodecType, DecoderOptions,
|
||||
use symphonia::core::codecs::CodecParameters;
|
||||
use symphonia::core::codecs::audio::well_known::{
|
||||
CODEC_ID_AAC, CODEC_ID_ADPCM_G722, CODEC_ID_ADPCM_G726, CODEC_ID_ADPCM_G726LE, CODEC_ID_ADPCM_IMA_QT, CODEC_ID_ADPCM_IMA_WAV, CODEC_ID_ADPCM_MS,
|
||||
CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP1, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_OPUS, CODEC_ID_PCM_ALAW, CODEC_ID_PCM_F32BE, CODEC_ID_PCM_F32LE,
|
||||
CODEC_ID_PCM_F64BE, CODEC_ID_PCM_F64LE, CODEC_ID_PCM_MULAW, CODEC_ID_VORBIS,
|
||||
};
|
||||
use symphonia::core::codecs::audio::{AudioCodecId, AudioCodecParameters, AudioDecoderOptions, CODEC_ID_NULL_AUDIO};
|
||||
use symphonia::core::errors::Error as SymError;
|
||||
use symphonia::core::formats::probe::Hint;
|
||||
use symphonia::core::formats::{FormatOptions, FormatReader};
|
||||
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ReadBytes};
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Instantiate;
|
||||
|
||||
use crate::pcm::{MAX_DECODE_SAMPLES, be_u32};
|
||||
use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat};
|
||||
@@ -23,6 +23,8 @@ use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat
|
||||
/// takes its frame length from the file (encoders use 4096).
|
||||
const MAX_PACKET_FRAMES: u64 = 1 << 20;
|
||||
|
||||
/// Turn a symphonia error into the audio layer's error, keeping "unsupported" and "too large"
|
||||
/// distinct from a malformed file.
|
||||
fn map_err(e: SymError) -> AudioError {
|
||||
match e {
|
||||
SymError::Unsupported(m) => AudioError::Unsupported(m.to_string()),
|
||||
@@ -36,75 +38,170 @@ fn guarded<T>(f: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into())))
|
||||
}
|
||||
|
||||
/// Symphonia's probe loop (`Probe::format`, whose hint 0.5 ignores), checking the container it
|
||||
/// finds before its reader allocates whatever the file claims.
|
||||
fn open(bytes: &[u8]) -> Result<Box<dyn FormatReader>> {
|
||||
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
|
||||
let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() };
|
||||
/// What [`open`] found: a reader, or a stream that holds no audio frames and has no reader.
|
||||
enum Opened {
|
||||
Reader(Box<dyn FormatReader>),
|
||||
Empty(AudioInfo),
|
||||
}
|
||||
|
||||
/// Skip a leading ID3v2 tag before probing.
|
||||
///
|
||||
/// Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims, so the tag is skipped
|
||||
/// with [`ReadBytes::ignore_bytes`] (which allocates nothing) instead of being handed to the
|
||||
/// metadata reader. Returns the stream position the container starts at.
|
||||
fn skip_leading_id3(mss: &mut MediaSourceStream<'_>, bytes: &[u8]) -> Result<usize> {
|
||||
loop {
|
||||
let found = symphonia::default::get_probe().next(&mut mss).map_err(map_err)?;
|
||||
let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX);
|
||||
let head = bytes.get(at..).unwrap_or_default();
|
||||
match (found, head.get(..10)) {
|
||||
(Instantiate::Format(reader), _) => {
|
||||
match head.get(..4) {
|
||||
Some(b"caff") => crate::caf::check(bytes, at)?,
|
||||
Some(b"ftyp") => crate::mp4::check(bytes, at.saturating_sub(4))?,
|
||||
Some(b"fLaC") => crate::flac::check_metadata(bytes, at)?,
|
||||
Some(b"RIFF") => crate::wav::check_info(bytes, at)?,
|
||||
_ => {}
|
||||
}
|
||||
return reader(mss, &fmt_opts).map_err(map_err);
|
||||
}
|
||||
// Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims: skip it.
|
||||
(Instantiate::Metadata(_), Some([b'I', b'D', b'3', _, _, _, size @ ..])) => {
|
||||
let size = size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f));
|
||||
mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?;
|
||||
}
|
||||
(Instantiate::Metadata(tag), _) => {
|
||||
tag(&MetadataOptions::default()).read_all(&mut mss).map_err(map_err)?;
|
||||
}
|
||||
if head.len() < 10 || head.get(..3) != Some(b"ID3") {
|
||||
return Ok(at);
|
||||
}
|
||||
let size = head.get(6..10).map(|size| size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f))).unwrap_or(0);
|
||||
mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
fn format_from_codec(codec: CodecType, detected: FileFormat) -> FileFormat {
|
||||
/// How far symphonia's probe scans for a format marker before giving up.
|
||||
const MAX_PROBE_DEPTH: usize = 1024 * 1024;
|
||||
|
||||
/// The container marker each detected format starts with, if its reader needs size checks first.
|
||||
fn container_markers(detected: FileFormat) -> &'static [&'static [u8; 4]] {
|
||||
match detected {
|
||||
// RF64 and BW64 are the >4 GiB RIFF forms.
|
||||
FileFormat::Wav => &[b"RIFF", b"RF64", b"BW64"],
|
||||
FileFormat::Flac => &[b"fLaC"],
|
||||
FileFormat::Caf => &[b"caff"],
|
||||
// MP4/M4A put `ftyp` after the first atom's size field.
|
||||
FileFormat::Aac | FileFormat::Alac => &[b"ftyp"],
|
||||
// Unknown files may carry junk before the container; symphonia's probe scans for markers.
|
||||
FileFormat::Other => &[b"caff", b"ftyp", b"fLaC", b"RIFF"],
|
||||
FileFormat::Aiff | FileFormat::Mp3 | FileFormat::Ogg => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe short names a container marker stands for.
|
||||
fn expected_probe_format(marker: &[u8; 4]) -> &'static [&'static str] {
|
||||
match marker {
|
||||
b"caff" => &["caf"],
|
||||
// The isomp4 reader reads the `ftyp` atom.
|
||||
b"ftyp" => &["isomp4"],
|
||||
b"fLaC" => &["flac"],
|
||||
b"RIFF" | b"RF64" | b"BW64" => &["wave"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe short names whose demuxer allocates from sizes in the file, so the matching container
|
||||
/// check has to run before the stream is decoded.
|
||||
const SIZE_CHECKED_PROBE_FORMATS: &[&str] = &["caf", "flac", "isomp4", "wave"];
|
||||
|
||||
/// 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])> {
|
||||
let end = bytes.len().min(at.saturating_add(MAX_PROBE_DEPTH));
|
||||
for pos in at..end.saturating_sub(3) {
|
||||
let window = bytes.get(pos..pos + 4)?;
|
||||
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));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Probe for a container, checking the container it finds before its reader allocates whatever the
|
||||
/// file claims.
|
||||
///
|
||||
/// The check has to run on the container the probe actually selects, but the probe reports that
|
||||
/// container only through the reader it returns. The check therefore runs on the first marker the
|
||||
/// detected format allows, and the reader is refused if its format is not the checked one: a
|
||||
/// misleading marker must not let a later container slip through unchecked.
|
||||
fn open(bytes: &[u8], detected: FileFormat) -> Result<Opened> {
|
||||
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
|
||||
let at = skip_leading_id3(&mut mss, bytes)?;
|
||||
let mut checked: Option<&'static [&'static str]> = None;
|
||||
if let Some((found, marker)) = find_marker(bytes, at, container_markers(detected)) {
|
||||
match marker {
|
||||
b"caff" => {
|
||||
crate::caf::check(bytes, found)?;
|
||||
checked = Some(expected_probe_format(marker));
|
||||
}
|
||||
b"ftyp" => {
|
||||
crate::mp4::check(bytes, found.saturating_sub(4))?;
|
||||
checked = Some(expected_probe_format(marker));
|
||||
}
|
||||
b"fLaC" => {
|
||||
crate::flac::check_metadata(bytes, found)?;
|
||||
if let Some(info) = crate::flac::empty_stream_info(bytes, found) {
|
||||
return Ok(Opened::Empty(info));
|
||||
}
|
||||
checked = Some(expected_probe_format(marker));
|
||||
}
|
||||
b"RIFF" | b"RF64" | b"BW64" => {
|
||||
crate::wav::check_info(bytes, found)?;
|
||||
checked = Some(expected_probe_format(marker));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let reader = symphonia::default::get_probe().probe(&Hint::new(), mss, FormatOptions::default(), MetadataOptions::default()).map_err(map_err)?;
|
||||
let probed = reader.format_info().short_name;
|
||||
// Only containers whose readers allocate from sizes in the file need the check. A stream the
|
||||
// probe reads with another reader (MP3, Ogg, AIFF, ...) has no such size fields, so a marker
|
||||
// found in its audio data must not reject it.
|
||||
if let Some(names) = checked.filter(|_| SIZE_CHECKED_PROBE_FORMATS.contains(&probed)) {
|
||||
if !names.contains(&probed) {
|
||||
return Err(AudioError::Malformed(format!("the probed container ({probed}) is not the one whose size checks ran")));
|
||||
}
|
||||
} else if checked.is_none() && SIZE_CHECKED_PROBE_FORMATS.contains(&probed) {
|
||||
return Err(AudioError::Malformed(format!("the probed container ({probed}) was not size-checked")));
|
||||
}
|
||||
Ok(Opened::Reader(reader))
|
||||
}
|
||||
|
||||
/// The container `detected` stands for: the detection wins when it knows the file, otherwise the
|
||||
/// decoded codec names the family.
|
||||
fn format_from_codec(codec: AudioCodecId, detected: FileFormat) -> FileFormat {
|
||||
if detected != FileFormat::Other {
|
||||
return detected;
|
||||
}
|
||||
match codec {
|
||||
c if c == CODEC_TYPE_FLAC => FileFormat::Flac,
|
||||
c if c == CODEC_TYPE_MP3 || c == CODEC_TYPE_MP2 || c == CODEC_TYPE_MP1 => FileFormat::Mp3,
|
||||
c if c == CODEC_TYPE_AAC => FileFormat::Aac,
|
||||
c if c == CODEC_TYPE_ALAC => FileFormat::Alac,
|
||||
c if c == CODEC_TYPE_VORBIS => FileFormat::Ogg,
|
||||
c if c == CODEC_ID_FLAC => FileFormat::Flac,
|
||||
c if c == CODEC_ID_MP3 || c == CODEC_ID_MP2 || c == CODEC_ID_MP1 => FileFormat::Mp3,
|
||||
c if c == CODEC_ID_AAC => FileFormat::Aac,
|
||||
c if c == CODEC_ID_ALAC => FileFormat::Alac,
|
||||
c if c == CODEC_ID_VORBIS => FileFormat::Ogg,
|
||||
_ => FileFormat::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_format_of(p: &CodecParameters) -> SampleFormat {
|
||||
/// The stored sample format, using the codec and its declared bit depth. Lossy and unusual
|
||||
/// codecs decode to `Compressed`.
|
||||
fn sample_format_of(p: &AudioCodecParameters) -> SampleFormat {
|
||||
let c = p.codec;
|
||||
if c == CODEC_TYPE_PCM_F32LE || c == CODEC_TYPE_PCM_F32BE {
|
||||
if c == CODEC_ID_PCM_F32LE || c == CODEC_ID_PCM_F32BE {
|
||||
return SampleFormat::Float32;
|
||||
}
|
||||
if c == CODEC_TYPE_PCM_F64LE || c == CODEC_TYPE_PCM_F64BE {
|
||||
if c == CODEC_ID_PCM_F64LE || c == CODEC_ID_PCM_F64BE {
|
||||
return SampleFormat::Float64;
|
||||
}
|
||||
let lossy = [
|
||||
CODEC_TYPE_MP1,
|
||||
CODEC_TYPE_MP2,
|
||||
CODEC_TYPE_MP3,
|
||||
CODEC_TYPE_AAC,
|
||||
CODEC_TYPE_VORBIS,
|
||||
CODEC_TYPE_OPUS,
|
||||
CODEC_TYPE_ADPCM_MS,
|
||||
CODEC_TYPE_ADPCM_IMA_WAV,
|
||||
CODEC_TYPE_ADPCM_IMA_QT,
|
||||
CODEC_TYPE_ADPCM_G722,
|
||||
CODEC_TYPE_ADPCM_G726,
|
||||
CODEC_TYPE_ADPCM_G726LE,
|
||||
CODEC_TYPE_PCM_ALAW,
|
||||
CODEC_TYPE_PCM_MULAW,
|
||||
CODEC_ID_MP1,
|
||||
CODEC_ID_MP2,
|
||||
CODEC_ID_MP3,
|
||||
CODEC_ID_AAC,
|
||||
CODEC_ID_VORBIS,
|
||||
CODEC_ID_OPUS,
|
||||
CODEC_ID_ADPCM_MS,
|
||||
CODEC_ID_ADPCM_IMA_WAV,
|
||||
CODEC_ID_ADPCM_IMA_QT,
|
||||
CODEC_ID_ADPCM_G722,
|
||||
CODEC_ID_ADPCM_G726,
|
||||
CODEC_ID_ADPCM_G726LE,
|
||||
CODEC_ID_PCM_ALAW,
|
||||
CODEC_ID_PCM_MULAW,
|
||||
];
|
||||
let lossless = !lossy.contains(&c);
|
||||
match (lossless, p.bits_per_sample) {
|
||||
@@ -116,15 +213,18 @@ fn sample_format_of(p: &CodecParameters) -> SampleFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// The first track with a decodable audio codec, with the stream information the UI shows.
|
||||
fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, AudioInfo)> {
|
||||
let track = reader
|
||||
let (track, p) = reader
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.find_map(|t| match t.codec_params.as_ref().and_then(CodecParameters::audio) {
|
||||
Some(p) if p.codec != CODEC_ID_NULL_AUDIO => Some((t, p)),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?;
|
||||
let p = &track.codec_params;
|
||||
let sample_rate = p.sample_rate.filter(|&r| r > 0).ok_or_else(|| AudioError::Malformed("unknown sample rate".into()))?;
|
||||
let channels = p.channels.map(|c| c.count()).unwrap_or(0);
|
||||
let channels = p.channels.as_ref().map(|c| c.count()).unwrap_or(0);
|
||||
let channels = u16::try_from(channels).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
|
||||
Ok((
|
||||
track.id,
|
||||
@@ -133,7 +233,7 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi
|
||||
sample_format: sample_format_of(p),
|
||||
sample_rate,
|
||||
channels,
|
||||
frames: p.n_frames.unwrap_or(0),
|
||||
frames: track.num_frames.unwrap_or(0),
|
||||
bwf: None,
|
||||
},
|
||||
))
|
||||
@@ -141,9 +241,15 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi
|
||||
|
||||
/// Decode the whole first audio track. When `count_only`, samples are counted but not stored.
|
||||
fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
|
||||
let mut reader = open(bytes)?;
|
||||
let mut reader = match open(bytes, detected)? {
|
||||
Opened::Reader(reader) => reader,
|
||||
Opened::Empty(info) => {
|
||||
let buf = AudioBuffer::new(info.sample_rate, usize::from(info.channels), 0);
|
||||
return Ok((info, buf));
|
||||
}
|
||||
};
|
||||
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
|
||||
if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.codec_params.n_frames) {
|
||||
if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.num_frames) {
|
||||
let total = n.saturating_mul(u64::from(info.channels.max(1)));
|
||||
if !count_only && total > MAX_DECODE_SAMPLES {
|
||||
return Err(AudioError::TooLarge(format!("{total} samples exceeds the decode limit")));
|
||||
@@ -153,21 +259,24 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.id == track_id)
|
||||
.map(|t| t.codec_params.clone())
|
||||
.and_then(|t| t.codec_params.as_ref())
|
||||
.and_then(CodecParameters::audio)
|
||||
.cloned()
|
||||
.ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
|
||||
let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_TYPE_ALAC).and_then(|d| be_u32(d, 0));
|
||||
let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_ID_ALAC).and_then(|d| be_u32(d, 0));
|
||||
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) {
|
||||
return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into()));
|
||||
}
|
||||
let mut decoder = symphonia::default::get_codecs().make(¶ms, &DecoderOptions::default()).map_err(map_err)?;
|
||||
let mut decoder = symphonia::default::get_codecs().make_audio_decoder(¶ms, &AudioDecoderOptions::default()).map_err(map_err)?;
|
||||
|
||||
let mut planar: Vec<Vec<f32>> = Vec::new();
|
||||
let mut scratch: Vec<Vec<f32>> = Vec::new();
|
||||
let mut frames: u64 = 0;
|
||||
let mut sbuf: Option<SampleBuffer<f32>> = None;
|
||||
let mut errors = 0usize;
|
||||
loop {
|
||||
let packet = match reader.next_packet() {
|
||||
Ok(p) => p,
|
||||
Ok(Some(packet)) => packet,
|
||||
Ok(None) => break,
|
||||
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
|
||||
Err(SymError::ResetRequired) => break,
|
||||
Err(e) => {
|
||||
@@ -178,7 +287,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
break;
|
||||
}
|
||||
};
|
||||
if packet.track_id() != track_id {
|
||||
if packet.track_id != track_id {
|
||||
continue;
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
@@ -194,8 +303,8 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
Err(SymError::IoError(_)) => break,
|
||||
Err(e) => return Err(map_err(e)),
|
||||
};
|
||||
let spec = *decoded.spec();
|
||||
let ch = spec.channels.count();
|
||||
let spec = decoded.spec();
|
||||
let ch = spec.channels().count();
|
||||
if ch == 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -212,16 +321,9 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
if count_only {
|
||||
continue;
|
||||
}
|
||||
let needed = decoded.capacity() as u64;
|
||||
let sb = match &mut sbuf {
|
||||
Some(sb) if sb.capacity() >= decoded.capacity() * ch => sb,
|
||||
_ => sbuf.insert(SampleBuffer::<f32>::new(needed, spec)),
|
||||
};
|
||||
sb.copy_interleaved_ref(decoded);
|
||||
for frame in sb.samples().chunks_exact(ch) {
|
||||
for (dst, &s) in planar.iter_mut().zip(frame) {
|
||||
dst.push(if s.is_finite() { s } else { 0.0 });
|
||||
}
|
||||
decoded.copy_to_vecs_planar::<f32>(&mut scratch);
|
||||
for (dst, src) in planar.iter_mut().zip(&scratch) {
|
||||
dst.extend(src.iter().map(|&s| if s.is_finite() { s } else { 0.0 }));
|
||||
}
|
||||
}
|
||||
if info.channels == 0 {
|
||||
@@ -237,7 +339,10 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
|
||||
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
|
||||
guarded(|| {
|
||||
let reader = open(bytes)?;
|
||||
let reader = match open(bytes, detected)? {
|
||||
Opened::Reader(reader) => reader,
|
||||
Opened::Empty(info) => return Ok(info),
|
||||
};
|
||||
let (_, info) = info_of(reader.as_ref(), detected)?;
|
||||
if info.frames > 0 && info.channels > 0 {
|
||||
return Ok(info);
|
||||
|
||||
+72
-15
@@ -140,6 +140,30 @@ pub(crate) fn is_wav(b: &[u8]) -> bool {
|
||||
matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE")
|
||||
}
|
||||
|
||||
/// True when everything from `from` to the end of the file is whole RIFF chunks (four printable
|
||||
/// ASCII id bytes, a size that fits, the pad byte after an odd size; the last chunk's pad may be
|
||||
/// missing): the metadata of a finished file, not samples.
|
||||
fn only_chunks_follow(b: &[u8], from: usize) -> bool {
|
||||
let mut pos = from;
|
||||
while pos < b.len() {
|
||||
let (Some(id), Some(size)) = (tag(b, pos), le_u32(b, pos + 4)) else { return false };
|
||||
if !id.iter().all(|c| (0x20..0x7f).contains(c)) {
|
||||
return false;
|
||||
}
|
||||
let size = size as usize;
|
||||
let Some(end) = pos.checked_add(8).and_then(|body| body.checked_add(size)) else { return false };
|
||||
if end > b.len() {
|
||||
return false;
|
||||
}
|
||||
let next = end + (size & 1);
|
||||
if end == b.len() || next == b.len() {
|
||||
return true;
|
||||
}
|
||||
pos = next;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
|
||||
if !is_wav(b) {
|
||||
return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into()));
|
||||
@@ -168,7 +192,12 @@ pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
|
||||
if &id == b"data" {
|
||||
// Lenient: a truncated recording (or a streaming writer's 0 / 0xFFFFFFFF size) still
|
||||
// yields whatever audio is actually present.
|
||||
let len = if size32 == 0 && !is_rf64 { remaining } else { declared.min(remaining) };
|
||||
// A size of 0 in a file whose RIFF size is right and that has only chunks after it is an
|
||||
// empty data chunk followed by metadata (LIST, bext, …); otherwise it is a recording
|
||||
// that was never finalised, and what follows is audio.
|
||||
let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64;
|
||||
let empty = size32 == 0 && !is_rf64 && finished && only_chunks_follow(b, body);
|
||||
let len = if size32 == 0 && !is_rf64 && !empty { remaining } else { declared.min(remaining) };
|
||||
if data.is_none() {
|
||||
data = Some((body, len));
|
||||
}
|
||||
@@ -321,25 +350,41 @@ pub(crate) struct Writer<W> {
|
||||
quantizers: Vec<Quantizer>,
|
||||
frames: u64,
|
||||
bytes: Vec<u8>,
|
||||
/// Set when a block write has failed: the sink may hold a prefix of that block while
|
||||
/// `frames` does not count it, so the writer refuses every later call.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
/// The optional header parts of a WAV file: BWF metadata, speaker mask, and whether to force RF64.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct HeaderExtras<'a> {
|
||||
pub(crate) bwf: Option<&'a BwfInfo>,
|
||||
pub(crate) mask: Option<u32>,
|
||||
pub(crate) force_rf64: bool,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Writer<W> {
|
||||
pub(crate) fn new(
|
||||
mut out: W,
|
||||
channels: usize,
|
||||
sample_rate: u32,
|
||||
depth: BitDepth,
|
||||
dither: bool,
|
||||
bwf: Option<&BwfInfo>,
|
||||
mask: Option<u32>,
|
||||
force_rf64: bool,
|
||||
) -> Result<Self> {
|
||||
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool, extras: HeaderExtras<'_>) -> Result<Self> {
|
||||
let HeaderExtras { bwf, mask, force_rf64 } = extras;
|
||||
validate_layout(channels, sample_rate)?;
|
||||
let format = sample_format(depth);
|
||||
let start = out.stream_position()?;
|
||||
out.write_all(&header(channels, sample_rate, format, bwf, mask, 0, force_rf64)?)?;
|
||||
let quantizers = Quantizer::per_channel(u32::from(format.0), dither, channels);
|
||||
Ok(Writer { out, start, channels, sample_rate, format, bwf: bwf.cloned(), mask, force_rf64, quantizers, frames: 0, bytes: Vec::new() })
|
||||
Ok(Writer {
|
||||
out,
|
||||
start,
|
||||
channels,
|
||||
sample_rate,
|
||||
format,
|
||||
bwf: bwf.cloned(),
|
||||
mask,
|
||||
force_rf64,
|
||||
quantizers,
|
||||
frames: 0,
|
||||
bytes: Vec::new(),
|
||||
failed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn header(&self, frames: u64) -> Result<Vec<u8>> {
|
||||
@@ -355,13 +400,22 @@ impl<W: Write + Seek> Writer<W> {
|
||||
|
||||
/// Appends `frames` frames of planar audio (missing samples are silence).
|
||||
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let total = self.check_room(frames as u64)?;
|
||||
write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, self.format, false)?;
|
||||
if let Err(e) = write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, self.format, false) {
|
||||
self.failed = true;
|
||||
return Err(AudioError::from(e));
|
||||
}
|
||||
self.frames = total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut self) -> Result<W> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let data_len = u64::from(self.format.0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames);
|
||||
if data_len % 2 == 1 {
|
||||
self.out.write_all(&[0])?;
|
||||
@@ -395,7 +449,7 @@ pub(crate) fn encode(
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1))
|
||||
.map_err(|_| AudioError::TooLarge(format!("cannot allocate {data_len} bytes for WAV output")))?;
|
||||
let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, bwf, mask, force_rf64)?;
|
||||
let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, HeaderExtras { bwf, mask, force_rf64 })?;
|
||||
w.write(&buf.channels, frames)?;
|
||||
Ok(w.finish()?.into_inner())
|
||||
}
|
||||
@@ -442,7 +496,10 @@ pub(crate) fn header(
|
||||
if is_float {
|
||||
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
|
||||
}
|
||||
let total = (4 + (8 + rf64_body_len) + meta.len() + 8) as u64 + data_len + (data_len & 1);
|
||||
let total = ((4 + (8 + rf64_body_len) + meta.len() + 8) as u64)
|
||||
.checked_add(data_len)
|
||||
.and_then(|t| t.checked_add(data_len & 1))
|
||||
.ok_or_else(|| AudioError::TooLarge("WAV size overflow".into()))?;
|
||||
let rf64 = force_rf64 || u32::try_from(total).is_err();
|
||||
|
||||
if rf64 {
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
#[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]
|
||||
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();
|
||||
@@ -451,6 +463,20 @@ fn garbage_in_compressed_containers_errors() {
|
||||
assert!(decode(&[], None).is_err());
|
||||
}
|
||||
|
||||
/// A marker that passes its container check must not let the probe select a different container
|
||||
/// without its own checks: the reader is refused when the probed format disagrees.
|
||||
#[test]
|
||||
fn a_probed_container_that_disagrees_with_the_checked_marker_is_refused() {
|
||||
// The junk byte hides both magics, so the hint picks the marker: the FLAC is checked, but the
|
||||
// probe reads the WAVE before it.
|
||||
let bytes = [vec![0], small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Flac, BitDepth::Int24, 2)].concat();
|
||||
match decode(&bytes, Some("flac")) {
|
||||
Err(AudioError::Malformed(message)) => assert!(message.contains("probed container"), "unexpected message: {message}"),
|
||||
Err(other) => panic!("unexpected error kind: {other:?}"),
|
||||
Ok(_) => panic!("the mismatched container must be refused"),
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
|
||||
|
||||
@@ -284,3 +284,35 @@ fn float_wav_header_is_rewritten_in_place_as_the_file_grows() {
|
||||
assert_eq!((&big[..4], big.len()), (&b"RF64"[..], len));
|
||||
assert!(wav_float_header(0, 48_000, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_frame_flac_round_trips_like_wav_and_aiff() {
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||
for depth in [BitDepth::Int16, BitDepth::Int24] {
|
||||
for channels in [1, 2] {
|
||||
let opts = EncodeOptions { format, bit_depth: depth, ..EncodeOptions::default() };
|
||||
let bytes = encode(&AudioBuffer::new(48_000, channels, 0), &opts).unwrap();
|
||||
let info = probe(&bytes, None).unwrap();
|
||||
assert_eq!((info.format, info.sample_format, info.sample_rate, info.frames), (format, expected_sf(depth), 48_000, 0));
|
||||
assert_eq!(usize::from(info.channels), channels);
|
||||
let (info, buf) = decode(&bytes, None).unwrap();
|
||||
assert_eq!((info.frames, buf.frames(), buf.num_channels()), (0, 0, channels));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_claiming_frames_but_holding_none_still_errors() {
|
||||
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, ..EncodeOptions::default() };
|
||||
let mut bytes = encode(&AudioBuffer::new(48_000, 2, 0), &opts).unwrap();
|
||||
bytes[25] = 1; // STREAMINFO total samples (low byte of the 36-bit field) = 1
|
||||
assert!(decode(&bytes, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() {
|
||||
// 4 bytes per frame: the data length fits in u64, but adding the header to it does not.
|
||||
let frames = u64::MAX / 4;
|
||||
assert!(matches!(wav_float_header(1, 48_000, frames), Err(soundcraft_audio_io::AudioError::TooLarge(_))));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, BwfInfo, EncodeOptions, FileEncoder, FileFormat, encode, encode_with_channel_mask};
|
||||
use std::io::Cursor;
|
||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||
|
||||
fn tone(channels: usize, frames: usize) -> AudioBuffer {
|
||||
let channels = (0..channels).map(|c| (0..frames).map(|i| ((i * (c + 3)) as f32 * 0.013).sin() * 1.2).collect()).collect();
|
||||
@@ -49,16 +49,62 @@ fn streamed_files_match_whole_buffer_encodes() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Write + Seek` sink that accepts `budget` bytes in total and then fails every write, so
|
||||
/// a `write_all` on it appends a prefix of the block and only then reports the error.
|
||||
struct FailAfter {
|
||||
inner: Cursor<Vec<u8>>,
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
impl Write for FailAfter {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
if self.budget == 0 {
|
||||
return Err(std::io::Error::other("sink is full"));
|
||||
}
|
||||
let n = buf.len().min(self.budget);
|
||||
self.budget -= n;
|
||||
self.inner.write(&buf[..n])
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for FailAfter {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
self.inner.seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_poisons_the_encoder() {
|
||||
let buf = tone(2, 20_001);
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||
let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
let sink = FailAfter { inner: Cursor::new(Vec::new()), budget: 256 };
|
||||
let mut enc = FileEncoder::new(sink, &opts, 2, buf.sample_rate, None).unwrap();
|
||||
// The sink accepts a prefix of the first block, then fails.
|
||||
assert!(enc.write(&buf.channels, buf.frames()).is_err(), "{format:?}");
|
||||
// Later calls must not claim success: the bytes on the sink no longer match the
|
||||
// encoder's counters (and for FLAC the failed frame's samples are already gone).
|
||||
assert!(matches!(enc.write(&buf.channels, 16), Err(AudioError::Encode(_))), "{format:?}");
|
||||
assert!(matches!(enc.finish(), Err(AudioError::Encode(_))), "{format:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_past_a_format_limit_are_refused_before_anything_is_written() {
|
||||
let buf = tone(2, 100);
|
||||
for (format, limit) in [(FileFormat::Aiff, 1usize << 30), (FileFormat::Flac, 1 << 36)] {
|
||||
for (format, limit) in [(FileFormat::Aiff, 1u64 << 30), (FileFormat::Flac, 1u64 << 36)] {
|
||||
let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
let mut enc = FileEncoder::new(Cursor::new(Vec::new()), &opts, 2, buf.sample_rate, None).unwrap();
|
||||
enc.write(&buf.channels, 100).unwrap();
|
||||
assert!(enc.check_room(limit as u64 / 2).is_ok(), "{format:?}");
|
||||
assert!(matches!(enc.check_room(limit as u64), Err(AudioError::TooLarge(_))), "{format:?}");
|
||||
assert!(matches!(enc.write(&[], limit), Err(AudioError::TooLarge(_))), "{format:?}");
|
||||
assert!(enc.check_room(limit / 2).is_ok(), "{format:?}");
|
||||
assert!(matches!(enc.check_room(limit), Err(AudioError::TooLarge(_))), "{format:?}");
|
||||
if let Ok(frames) = usize::try_from(limit) {
|
||||
assert!(matches!(enc.write(&[], frames), Err(AudioError::TooLarge(_))), "{format:?}");
|
||||
}
|
||||
assert_eq!(enc.finish().unwrap().into_inner(), encode(&buf, &opts).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! A WAV whose data chunk is empty and followed by metadata decodes to no audio; a recording
|
||||
//! that was never finalised (RIFF and data sizes left at 0) still yields what was written (#466).
|
||||
|
||||
use soundcraft_audio_io::{decode, probe};
|
||||
|
||||
fn fmt16(channels: u16, sr: u32) -> Vec<u8> {
|
||||
let align = channels * 2;
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(&1u16.to_le_bytes());
|
||||
f.extend_from_slice(&channels.to_le_bytes());
|
||||
f.extend_from_slice(&sr.to_le_bytes());
|
||||
f.extend_from_slice(&(sr * u32::from(align)).to_le_bytes());
|
||||
f.extend_from_slice(&align.to_le_bytes());
|
||||
f.extend_from_slice(&16u16.to_le_bytes());
|
||||
f
|
||||
}
|
||||
|
||||
fn chunk(id: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut c = id.to_vec();
|
||||
c.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
c.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
c.push(0);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// A finished file: correct RIFF size, chunks as given.
|
||||
fn riff(chunks: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut body = b"WAVE".to_vec();
|
||||
for c in chunks {
|
||||
body.extend_from_slice(c);
|
||||
}
|
||||
let mut out = b"RIFF".to_vec();
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out
|
||||
}
|
||||
|
||||
fn list_info() -> Vec<u8> {
|
||||
let mut l = b"INFO".to_vec();
|
||||
l.extend_from_slice(&chunk(b"INAM", b"Silence"));
|
||||
chunk(b"LIST", &l)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_data_chunk_followed_by_metadata_has_no_frames() {
|
||||
let file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[]), list_info(), chunk(b"bext", &[0u8; 602])]);
|
||||
let info = probe(&file, Some("wav")).unwrap();
|
||||
assert_eq!(info.frames, 0, "the LIST and bext chunks are not samples");
|
||||
let (info, buf) = decode(&file, Some("wav")).unwrap();
|
||||
assert_eq!((info.frames, buf.frames()), (0, 0));
|
||||
// The same with the metadata first.
|
||||
let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), list_info(), chunk(b"data", &[])]);
|
||||
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0);
|
||||
// And with a single trailing chunk of odd size and no pad byte at the very end.
|
||||
let mut file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &[]), chunk(b"note", b"abc")]);
|
||||
file.pop();
|
||||
let riff_size = (file.len() - 8) as u32;
|
||||
file[4..8].copy_from_slice(&riff_size.to_le_bytes());
|
||||
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_that_was_never_finalised_still_gives_its_audio() {
|
||||
let samples: Vec<u8> = (0..400i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
|
||||
// RIFF size and data size both left at 0, as a recorder writes them until it finishes.
|
||||
let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]);
|
||||
file.extend_from_slice(&samples);
|
||||
file[4..8].copy_from_slice(&0u32.to_le_bytes());
|
||||
let (info, buf) = decode(&file, Some("wav")).unwrap();
|
||||
assert_eq!(info.frames, 200);
|
||||
assert_eq!(buf.frames(), 200);
|
||||
// Even if the RIFF size is the file's, audio that is not a run of chunks is still audio.
|
||||
let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]);
|
||||
file.extend_from_slice(&samples);
|
||||
let riff_size = (file.len() - 8) as u32;
|
||||
file[4..8].copy_from_slice(&riff_size.to_le_bytes());
|
||||
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_data_chunk_with_audio_is_unchanged() {
|
||||
let samples: Vec<u8> = (0..100i16).flat_map(|i| i.to_le_bytes()).collect();
|
||||
let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &samples), list_info()]);
|
||||
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 100);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use serde_json::{Value, json};
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_engine::file_access::{self, AutomationRoots};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -97,46 +97,67 @@ impl Remote {
|
||||
if self.conn.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let sock = self.addr.parse().map_err(|e| format!("{}: {e}", self.addr))?;
|
||||
let s =
|
||||
TcpStream::connect_timeout(&sock, Duration::from_millis(800)).map_err(|e| format!("cannot reach SoundCraft at {}: {e}", self.addr))?;
|
||||
let socks: Vec<_> = self.addr.to_socket_addrs().map_err(|e| format!("{}: {e}", self.addr))?.collect();
|
||||
let mut last = "no addresses found".to_string();
|
||||
let mut stream = None;
|
||||
for sock in &socks {
|
||||
match TcpStream::connect_timeout(sock, Duration::from_millis(800)) {
|
||||
Ok(s) => {
|
||||
stream = Some(s);
|
||||
break;
|
||||
}
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
}
|
||||
let s = stream.ok_or_else(|| format!("cannot reach SoundCraft at {}: {last}", self.addr))?;
|
||||
s.set_read_timeout(Some(Duration::from_secs(120))).map_err(|e| e.to_string())?;
|
||||
let r = s.try_clone().map_err(|e| e.to_string())?;
|
||||
self.conn = Some((s, BufReader::new(r)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, String> {
|
||||
self.connect()?;
|
||||
/// One request/reply exchange. On failure the flag says whether the request line was already
|
||||
/// written to the socket (so the app may have executed it).
|
||||
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, (String, bool)> {
|
||||
self.connect().map_err(|e| (e, false))?;
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let line = json!({"id": id, "method": method, "params": params}).to_string();
|
||||
let (w, r) = self.conn.as_mut().ok_or("not connected")?;
|
||||
writeln!(w, "{line}").map_err(|e| e.to_string())?;
|
||||
let (w, r) = self.conn.as_mut().ok_or_else(|| ("not connected".to_string(), false))?;
|
||||
writeln!(w, "{line}").map_err(|e| (e.to_string(), false))?;
|
||||
let mut buf = String::new();
|
||||
r.read_line(&mut buf).map_err(|e| e.to_string())?;
|
||||
r.read_line(&mut buf).map_err(|e| (e.to_string(), true))?;
|
||||
if buf.is_empty() {
|
||||
return Err("connection closed".into());
|
||||
return Err(("connection closed".into(), true));
|
||||
}
|
||||
let v: Value = serde_json::from_str(&buf).map_err(|e| e.to_string())?;
|
||||
let v: Value = serde_json::from_str(&buf).map_err(|e| (e.to_string(), true))?;
|
||||
if v.get("ok").and_then(Value::as_bool).unwrap_or(false) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())
|
||||
Err((v.get("error").and_then(Value::as_str).unwrap_or("error").to_string(), true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Methods that only read state, so repeating them after a lost reply is harmless.
|
||||
fn is_read_only(method: &str) -> bool {
|
||||
matches!(method, "session.inspect" | "document.inspect" | "engine.commands" | "engine.parity" | "ui.inspect" | "ui.menu.list")
|
||||
}
|
||||
|
||||
impl Backend for Remote {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
match self.roundtrip(method, ¶ms) {
|
||||
Ok(v) => Ok(v),
|
||||
Err(e) if e.contains("closed") || e.contains("Broken pipe") || e.contains("reset") => {
|
||||
// Retry once on a fresh connection.
|
||||
Err((e, sent)) if e.contains("closed") || e.contains("Broken pipe") || e.contains("reset") => {
|
||||
self.conn = None;
|
||||
self.roundtrip(method, ¶ms)
|
||||
if sent && !is_read_only(method) {
|
||||
// The app may already have executed the request; repeating it could apply it twice.
|
||||
return Err(format!("{e}; `{method}` may or may not have been executed, check the session before repeating it"));
|
||||
}
|
||||
// Retry once on a fresh connection.
|
||||
self.roundtrip(method, ¶ms).map_err(|(e, _)| e)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
Err((e, _)) => Err(e),
|
||||
}
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
@@ -167,4 +188,51 @@ mod tests {
|
||||
let mut r = Remote::new("127.0.0.1:1");
|
||||
assert!(r.call("session.inspect", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_resolves_hostnames() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let mut r = Remote::new(&format!("localhost:{port}"));
|
||||
assert_eq!(r.connect(), Ok(()));
|
||||
}
|
||||
|
||||
/// Fake app: accepts one connection per entry in `replies`, counts the request line it reads,
|
||||
/// then either drops the connection (`false`) or answers ok (`true`).
|
||||
fn fake_app(replies: Vec<bool>) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let seen = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counter = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
for answer in replies {
|
||||
let Ok((mut sock, _)) = listener.accept() else { return };
|
||||
let mut line = String::new();
|
||||
if BufReader::new(sock.try_clone().unwrap()).read_line(&mut line).unwrap_or(0) > 0 {
|
||||
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
if answer {
|
||||
let _ = writeln!(sock, "{}", json!({"id": 1, "ok": true, "result": {"done": true}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
(addr, seen)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_does_not_repeat_a_mutation_after_a_lost_reply() {
|
||||
let (addr, seen) = fake_app(vec![false, true]);
|
||||
let mut r = Remote::new(&addr);
|
||||
let err = r.call("engine.execute", json!({"command": "track.new"})).unwrap_err();
|
||||
assert!(err.contains("may or may not have been executed"), "{err}");
|
||||
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_retries_a_read_after_a_lost_reply() {
|
||||
let (addr, seen) = fake_app(vec![false, true]);
|
||||
let mut r = Remote::new(&addr);
|
||||
assert!(r.call("session.inspect", json!({})).is_ok());
|
||||
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::io::{BufRead, Write};
|
||||
|
||||
pub const PROTOCOL_VERSION: &str = "2025-06-18";
|
||||
const SUPPORTED: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
|
||||
const MAX_BATCH_CALLS: usize = 1000;
|
||||
|
||||
const INSTRUCTIONS: &str = "SoundCraft is a digital audio workstation (Pro Tools-class). Everything a user can do is a \
|
||||
command: call `list_commands` to discover ids and their params, then `execute` (or `batch`). Positions are samples \
|
||||
@@ -37,8 +38,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
),
|
||||
tool(
|
||||
"batch",
|
||||
"Run several commands in order; stops at the first error unless keep_going.",
|
||||
json!({"calls": {"type": "array", "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}}}, "keep_going": {"type": "boolean"}}),
|
||||
"Run up to 1000 commands in order; stops at the first error unless keep_going. Larger batches are rejected before any command runs.",
|
||||
json!({"calls": {"type": "array", "maxItems": MAX_BATCH_CALLS, "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}}}, "keep_going": {"type": "boolean"}}),
|
||||
&["calls"],
|
||||
),
|
||||
tool(
|
||||
@@ -135,9 +136,12 @@ impl<B: Backend> Server<B> {
|
||||
let Some(calls) = args.get("calls").and_then(Value::as_array) else {
|
||||
return error_result("`calls` must be an array");
|
||||
};
|
||||
if calls.len() > MAX_BATCH_CALLS {
|
||||
return error_result(&format!("`calls` must contain at most {MAX_BATCH_CALLS} commands; no commands were executed"));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut failed = false;
|
||||
for c in calls.iter().take(1000) {
|
||||
for c in calls {
|
||||
let Some(cmd) = c.get("command").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else {
|
||||
out.push(json!({"command": "", "ok": false, "error": "`command` required"}));
|
||||
failed = true;
|
||||
|
||||
@@ -31,6 +31,8 @@ fn handshake_and_tool_list() {
|
||||
assert_eq!(r["result"]["serverInfo"]["name"], "soundcraft");
|
||||
let r = rpc(&mut s, 2, "tools/list", json!({}));
|
||||
assert!(r["result"]["tools"].as_array().unwrap().len() >= 10);
|
||||
let batch = r["result"]["tools"].as_array().unwrap().iter().find(|tool| tool["name"] == "batch").unwrap();
|
||||
assert_eq!(batch["inputSchema"]["properties"]["calls"]["maxItems"], 1000);
|
||||
assert!(s.handle(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).is_none());
|
||||
let r = rpc(&mut s, 3, "nope", json!({}));
|
||||
assert_eq!(r["error"]["code"], -32601);
|
||||
@@ -171,3 +173,35 @@ fn batch_rejects_non_array_calls() {
|
||||
assert!(text.contains("`calls` must be an array"), "{text}");
|
||||
assert_eq!(session(&mut s)["tracks"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_rejects_oversize_calls_before_any_mutation() {
|
||||
for keep_going in [false, true] {
|
||||
let mut s = Server::new(Headless::new(Engine::default()));
|
||||
let before = session(&mut s);
|
||||
let mut calls = vec![json!({"command": "engine.history"}); 1001];
|
||||
calls[0] = json!({"command": "track.new", "params": {"name": "Must not be created"}});
|
||||
let r = rpc(&mut s, 1, "tools/call", json!({"name": "batch", "arguments": {"calls": calls, "keep_going": keep_going}}));
|
||||
assert_eq!(session(&mut s), before, "oversize batch mutated the session (keep_going={keep_going})");
|
||||
assert!(s.backend.engine.journal.is_empty());
|
||||
assert!(!s.backend.engine.can_undo());
|
||||
assert_eq!(r["result"]["isError"], true);
|
||||
let text = r["result"]["content"][0]["text"].as_str().unwrap();
|
||||
assert!(text.contains("1000"), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_accepts_empty_and_limit_sized_calls_in_order() {
|
||||
for count in [0, 1000] {
|
||||
let mut s = Server::new(Headless::new(Engine::default()));
|
||||
let calls: Vec<_> = (0..count).map(|at| json!({"command": "session.format_time", "params": {"at": at, "format": "samples"}})).collect();
|
||||
let results = tool(&mut s, "batch", json!({"calls": calls}));
|
||||
let results = results.as_array().unwrap();
|
||||
assert_eq!(results.len(), count);
|
||||
for (at, result) in results.iter().enumerate() {
|
||||
assert_eq!(result["ok"], true);
|
||||
assert_eq!(result["result"]["text"], at.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -1,7 +1,6 @@
|
||||
//! `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.
|
||||
// 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 soundcraft_automation::{Headless, mcp::Server};
|
||||
@@ -58,7 +57,7 @@ fn fixture(name: &str) -> Fixture {
|
||||
}
|
||||
|
||||
fn server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> {
|
||||
let roots = AutomationRoots::new(read, write).unwrap();
|
||||
let roots = AutomationRoots::new(read.as_slice(), write.as_slice()).unwrap();
|
||||
Server::new(Headless::new(Engine::default()).with_automation_roots(roots))
|
||||
}
|
||||
|
||||
@@ -187,7 +186,7 @@ fn writes(dir: &Path) -> Vec<(&'static str, Value)> {
|
||||
}
|
||||
|
||||
fn demo_server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> {
|
||||
let roots = AutomationRoots::new(read, write).unwrap();
|
||||
let roots = AutomationRoots::new(read.as_slice(), write.as_slice()).unwrap();
|
||||
Server::new(Headless::new(soundcraft_engine::demo::demo_engine()).with_automation_roots(roots))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -23,7 +23,7 @@ harness = false
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
clap-sys = "0.5"
|
||||
libloading = "0.8"
|
||||
libloading = "0.9"
|
||||
|
||||
# The isolated unsafe helper crate (craftrules never-crash): this crate opts out of the workspace
|
||||
# `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the
|
||||
|
||||
@@ -1241,25 +1241,38 @@ impl Instance {
|
||||
ok.then(|| read_name(&buf)).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
pub fn audio_ports(&self, is_input: bool) -> Vec<PortInfo> {
|
||||
/// The plugin's audio ports in one direction. A layout beyond `MAX_PORTS` ports or
|
||||
/// `MAX_PORT_CHANNELS` channels per port is an error: we never hand a plugin fewer ports or
|
||||
/// channels than it advertised.
|
||||
pub fn audio_ports(&self, is_input: bool) -> Result<Vec<PortInfo>, ClapError> {
|
||||
// SAFETY: see `params`.
|
||||
let Some(p) = (unsafe { self.ports.as_ref() }) else { return Vec::new() };
|
||||
let (Some(count), Some(get)) = (p.count, p.get) else { return Vec::new() };
|
||||
let Some(p) = (unsafe { self.ports.as_ref() }) else { return Ok(Vec::new()) };
|
||||
let (Some(count), Some(get)) = (p.count, p.get) else { return Ok(Vec::new()) };
|
||||
let dir = if is_input { "input" } else { "output" };
|
||||
// SAFETY: vtable call with its instance.
|
||||
let n = unsafe { count(self.plugin, is_input) }.min(MAX_PORTS);
|
||||
let n = unsafe { count(self.plugin, is_input) };
|
||||
if n > MAX_PORTS {
|
||||
return Err(ClapError::Process(format!("{n} audio {dir} ports (at most {MAX_PORTS} supported)")));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for i in 0..n {
|
||||
// SAFETY: plain data; all-zero (null port_type pointer) is valid.
|
||||
let mut info: clap_audio_port_info = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: index below the count; `info` is writable.
|
||||
if unsafe { get(self.plugin, i, is_input, &mut info) } {
|
||||
out.push(PortInfo { channels: info.channel_count.min(MAX_PORT_CHANNELS), main: info.flags & CLAP_AUDIO_PORT_IS_MAIN != 0 });
|
||||
if info.channel_count > MAX_PORT_CHANNELS {
|
||||
return Err(ClapError::Process(format!(
|
||||
"audio {dir} port {i} has {} channels (at most {MAX_PORT_CHANNELS} supported)",
|
||||
info.channel_count
|
||||
)));
|
||||
}
|
||||
out.push(PortInfo { channels: info.channel_count, main: info.flags & CLAP_AUDIO_PORT_IS_MAIN != 0 });
|
||||
} else {
|
||||
// Keep port indices aligned: an unreadable port still needs a (silent) buffer slot.
|
||||
out.push(PortInfo { channels: 0, main: false });
|
||||
}
|
||||
}
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn note_port(&self) -> Option<NotePort> {
|
||||
|
||||
@@ -88,7 +88,7 @@ pub enum ClapError {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod api {
|
||||
use super::*;
|
||||
use super::{ClapDescriptor, ClapError, Path, PathBuf, Plugin, PluginInfo, registry};
|
||||
|
||||
pub fn cached() -> Option<Vec<ClapDescriptor>> {
|
||||
registry::cached()
|
||||
@@ -118,7 +118,7 @@ mod api {
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api {
|
||||
use super::*;
|
||||
use super::{ClapDescriptor, ClapError, Path, PathBuf, Plugin, PluginInfo};
|
||||
|
||||
pub fn cached() -> Option<Vec<ClapDescriptor>> {
|
||||
Some(Vec::new())
|
||||
|
||||
@@ -113,8 +113,21 @@ impl Plugin for ClapPlugin {
|
||||
self.channels = channels.clamp(1, MAX_CHANNELS);
|
||||
self.inst.deactivate();
|
||||
self.inst.service_callback();
|
||||
let ins = self.inst.audio_ports(true);
|
||||
let outs = self.inst.audio_ports(false);
|
||||
let ports = self.inst.audio_ports(true).and_then(|ins| Ok((ins, self.inst.audio_ports(false)?)));
|
||||
let (ins, outs) = match ports {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
// Unsupported layout: stay inactive (`process` passes audio through untouched).
|
||||
log::warn!("{}: {e}", self.info.id);
|
||||
self.inputs = PortBuffers::new(&[], 0);
|
||||
self.outputs = PortBuffers::new(&[], 0);
|
||||
self.in_main = None;
|
||||
self.out_main = None;
|
||||
self.max_block = 0;
|
||||
self.latency = 0;
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.in_main = pick_main(&ins);
|
||||
self.out_main = pick_main(&outs);
|
||||
self.inputs = PortBuffers::new(&ins.iter().map(|p| p.channels).collect::<Vec<_>>(), mb);
|
||||
@@ -327,7 +340,6 @@ impl PluginEditor for ClapEditor {
|
||||
|
||||
fn idle(&mut self) -> Vec<(String, f32)> {
|
||||
let raw = self.link.idle();
|
||||
let _ = self.link.host().dirty.swap(false, Ordering::Relaxed);
|
||||
let mut out: Vec<(String, f32)> = Vec::new();
|
||||
for (cid, v) in raw {
|
||||
let Some(i) = self.ids.iter().position(|&x| x == cid) else { continue };
|
||||
@@ -341,4 +353,8 @@ impl PluginEditor for ClapEditor {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn state_changed(&mut self) -> bool {
|
||||
self.link.host().dirty.swap(false, Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
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).
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn fixture_dir() -> &'static Path {
|
||||
std::fs::create_dir_all(dir.join("vendor")).unwrap();
|
||||
std::fs::copy(&built, dir.join("vendor/Fixture.clap")).unwrap();
|
||||
}
|
||||
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 2);
|
||||
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 5);
|
||||
dir
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
//! to the velocity of the held note (0 when none), switching at the event's sample offset. A
|
||||
//! MIDI CC 7 (volume) sets the constant to its value / 127.
|
||||
//!
|
||||
//! - `org.soundcraft.test.wide16`, `.wide17ports`, `.wide65ch`: effects advertising wide layouts
|
||||
//! (16 ports of 64 channels, 17 ports of 2 channels, one port of 65 channels per direction).
|
||||
//! `process` writes 1.0 into every output channel it was given when the host's buffers match the
|
||||
//! advertised layout, -1.0 when they do not.
|
||||
//!
|
||||
//! The gain plugin also implements `clap.state` (gain and mode as two little-endian f64s) and a
|
||||
//! floating-only `clap.gui` without a real window: showing it "edits" the gain to 0.25 in the GUI,
|
||||
//! reported as an output parameter event by the next `process`.
|
||||
@@ -36,6 +41,27 @@ unsafe impl<const N: usize> Sync for Features<N> {}
|
||||
static GAIN_FEATURES: Features<3> = Features([c"audio-effect".as_ptr(), c"stereo".as_ptr(), std::ptr::null()]);
|
||||
static SYNTH_FEATURES: Features<3> = Features([c"instrument".as_ptr(), c"synthesizer".as_ptr(), std::ptr::null()]);
|
||||
|
||||
static WIDE_FEATURES: Features<2> = Features([c"audio-effect".as_ptr(), std::ptr::null()]);
|
||||
|
||||
const fn wide_desc(id: &'static CStr, name: &'static CStr) -> clap_plugin_descriptor {
|
||||
clap_plugin_descriptor {
|
||||
clap_version: CLAP_VERSION,
|
||||
id: id.as_ptr(),
|
||||
name: name.as_ptr(),
|
||||
vendor: c"SoundCraft tests".as_ptr(),
|
||||
url: std::ptr::null(),
|
||||
manual_url: std::ptr::null(),
|
||||
support_url: std::ptr::null(),
|
||||
version: c"1.0.0".as_ptr(),
|
||||
description: std::ptr::null(),
|
||||
features: WIDE_FEATURES.0.as_ptr(),
|
||||
}
|
||||
}
|
||||
|
||||
static WIDE16_DESC: clap_plugin_descriptor = wide_desc(c"org.soundcraft.test.wide16", c"Fixture Wide 16x64");
|
||||
static WIDE17_DESC: clap_plugin_descriptor = wide_desc(c"org.soundcraft.test.wide17ports", c"Fixture Wide 17 ports");
|
||||
static WIDE65_DESC: clap_plugin_descriptor = wide_desc(c"org.soundcraft.test.wide65ch", c"Fixture Wide 65 channels");
|
||||
|
||||
static GAIN_DESC: clap_plugin_descriptor = clap_plugin_descriptor {
|
||||
clap_version: CLAP_VERSION,
|
||||
id: c"org.soundcraft.test.gain".as_ptr(),
|
||||
@@ -66,6 +92,8 @@ struct Fx {
|
||||
plugin: clap_plugin,
|
||||
host: *const clap_host,
|
||||
synth: bool,
|
||||
/// `(ports per direction, channels per port)` for the wide fixtures.
|
||||
wide: Option<(u32, u32)>,
|
||||
gain: f64,
|
||||
mode: f64,
|
||||
level: f64,
|
||||
@@ -168,6 +196,22 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
|
||||
let n = pr.frames_count as usize;
|
||||
let ev = unsafe { &*pr.in_events };
|
||||
let count = unsafe { (ev.size.unwrap())(pr.in_events) };
|
||||
if let Some((ports, channels)) = f.wide {
|
||||
let fits = |bufs: *const clap_audio_buffer, n: u32| {
|
||||
n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels)
|
||||
};
|
||||
let ok = fits(pr.audio_inputs, pr.audio_inputs_count) && fits(pr.audio_outputs, pr.audio_outputs_count);
|
||||
if pr.audio_outputs_count > 0 {
|
||||
let out = unsafe { &*pr.audio_outputs };
|
||||
for c in 0..out.channel_count as usize {
|
||||
let o = unsafe { *out.data32.add(c) };
|
||||
for i in 0..n {
|
||||
unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } };
|
||||
}
|
||||
}
|
||||
}
|
||||
return CLAP_PROCESS_CONTINUE;
|
||||
}
|
||||
if pr.audio_outputs_count != 1 {
|
||||
return CLAP_PROCESS_ERROR;
|
||||
}
|
||||
@@ -324,10 +368,26 @@ static PARAMS: clap_plugin_params = clap_plugin_params {
|
||||
};
|
||||
|
||||
unsafe extern "C" fn ports_count(p: *const clap_plugin, is_input: bool) -> u32 {
|
||||
if let Some((ports, _)) = unsafe { fx(p) }.wide {
|
||||
return ports;
|
||||
}
|
||||
if is_input && unsafe { fx(p) }.synth { 0 } else { 1 }
|
||||
}
|
||||
|
||||
unsafe extern "C" fn ports_get(p: *const clap_plugin, index: u32, is_input: bool, info: *mut clap_audio_port_info) -> bool {
|
||||
if let Some((ports, channels)) = unsafe { fx(p) }.wide {
|
||||
if index >= ports {
|
||||
return false;
|
||||
}
|
||||
let info = unsafe { &mut *info };
|
||||
info.id = index * 2 + u32::from(is_input);
|
||||
write_name(&mut info.name, c"wide");
|
||||
info.flags = if index == 0 { CLAP_AUDIO_PORT_IS_MAIN } else { 0 };
|
||||
info.channel_count = channels;
|
||||
info.port_type = std::ptr::null();
|
||||
info.in_place_pair = clap_sys::id::CLAP_INVALID_ID;
|
||||
return true;
|
||||
}
|
||||
if index != 0 || (is_input && unsafe { fx(p) }.synth) {
|
||||
return false;
|
||||
}
|
||||
@@ -498,23 +558,32 @@ unsafe extern "C" fn on_main_thread(_p: *const clap_plugin) {}
|
||||
// ---- factory + entry ----
|
||||
|
||||
unsafe extern "C" fn factory_count(_f: *const clap_plugin_factory) -> u32 {
|
||||
2
|
||||
5
|
||||
}
|
||||
|
||||
unsafe extern "C" fn factory_desc(_f: *const clap_plugin_factory, i: u32) -> *const clap_plugin_descriptor {
|
||||
match i {
|
||||
0 => &GAIN_DESC,
|
||||
1 => &SYNTH_DESC,
|
||||
2 => &WIDE16_DESC,
|
||||
3 => &WIDE17_DESC,
|
||||
4 => &WIDE65_DESC,
|
||||
_ => std::ptr::null(),
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const clap_host, id: *const c_char) -> *const clap_plugin {
|
||||
let id = unsafe { CStr::from_ptr(id) };
|
||||
let (desc, synth) = if id == c"org.soundcraft.test.gain" {
|
||||
(&GAIN_DESC as *const _, false)
|
||||
let (desc, synth, wide) = if id == c"org.soundcraft.test.gain" {
|
||||
(&GAIN_DESC as *const _, false, None)
|
||||
} else if id == c"org.soundcraft.test.synth" {
|
||||
(&SYNTH_DESC as *const _, true)
|
||||
(&SYNTH_DESC as *const _, true, None)
|
||||
} else if id == c"org.soundcraft.test.wide16" {
|
||||
(&WIDE16_DESC as *const _, false, Some((16, 64)))
|
||||
} else if id == c"org.soundcraft.test.wide17ports" {
|
||||
(&WIDE17_DESC as *const _, false, Some((17, 2)))
|
||||
} else if id == c"org.soundcraft.test.wide65ch" {
|
||||
(&WIDE65_DESC as *const _, false, Some((1, 65)))
|
||||
} else {
|
||||
return std::ptr::null();
|
||||
};
|
||||
@@ -535,6 +604,7 @@ unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const
|
||||
},
|
||||
host,
|
||||
synth,
|
||||
wide,
|
||||
gain: 1.0,
|
||||
mode: 0.0,
|
||||
level: 0.0,
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
|
||||
use soundcraft_dsp::{Category, Unit};
|
||||
|
||||
mod common;
|
||||
use common::fixture_dir;
|
||||
use crate::common::fixture_dir;
|
||||
|
||||
const GAIN: &str = "clap:org.soundcraft.test.gain";
|
||||
const SYNTH: &str = "clap:org.soundcraft.test.synth";
|
||||
@@ -13,7 +12,7 @@ const SYNTH: &str = "clap:org.soundcraft.test.synth";
|
||||
fn scan_lists_both_fixture_plugins() {
|
||||
let dir = fixture_dir();
|
||||
let found = soundcraft_clap_host::scan_paths(&[dir.to_path_buf()]);
|
||||
assert_eq!(found.len(), 2);
|
||||
assert_eq!(found.len(), 5);
|
||||
let g = found.iter().find(|d| d.id == GAIN).unwrap();
|
||||
assert_eq!(g.name, "Fixture Gain");
|
||||
assert_eq!(g.vendor, "SoundCraft tests");
|
||||
@@ -110,6 +109,27 @@ fn synth_plays_notes_at_their_offsets() {
|
||||
assert!(io[0][19].abs() < 1e-6 && (io[0][20] - 64.0 / 127.0).abs() < 1e-6, "controllers arrive as MIDI at their offset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
|
||||
fixture_dir();
|
||||
// Control: exactly at the limits (16 ports of 64 channels) activates and gets its full layout.
|
||||
let mut p = soundcraft_clap_host::create("clap:org.soundcraft.test.wide16").unwrap();
|
||||
p.prepare(44_100.0, 64, 2);
|
||||
assert_eq!(p.latency(), 3);
|
||||
let mut io = vec![vec![0.5f32; 64]; 2];
|
||||
p.process(&mut io, 64);
|
||||
assert!(io.iter().flatten().all(|&x| x == 1.0), "16x64 layout delivered as advertised: {:?}", &io[0][..4]);
|
||||
// 17 ports, or a 65-channel port: never activated, never processed with a cut-down layout.
|
||||
for id in ["clap:org.soundcraft.test.wide17ports", "clap:org.soundcraft.test.wide65ch"] {
|
||||
let mut p = soundcraft_clap_host::create(id).unwrap();
|
||||
p.prepare(44_100.0, 64, 2);
|
||||
assert_eq!(p.latency(), 0, "{id}: not activated");
|
||||
let mut io = vec![vec![0.5f32; 64]; 2];
|
||||
p.process(&mut io, 64);
|
||||
assert!(io.iter().flatten().all(|&x| x == 0.5), "{id}: the plugin never ran: {:?}", &io[0][..4]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_instances_create_and_drop_cleanly() {
|
||||
fixture_dir();
|
||||
@@ -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;
|
||||
@@ -272,9 +272,20 @@ pub trait PluginEditor: Send {
|
||||
/// user closed, and returns parameter edits made in the editor as `(param id, value)` in
|
||||
/// SoundCraft units (ids and ranges of [`PluginInfo::params`]).
|
||||
fn idle(&mut self) -> Vec<(String, f32)>;
|
||||
/// True once after the plugin said its state changed in a way its parameters don't show
|
||||
/// (CLAP `state.mark_dirty`), so the session stores the new state.
|
||||
fn state_changed(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Tells the editor that a parameter changed elsewhere (generic editor, automation), so the
|
||||
/// GUI shows it. Values are in SoundCraft units.
|
||||
fn set_param(&mut self, _id: &str, _value: f32) {}
|
||||
/// The plugin's own text for a parameter value (SoundCraft units) when SoundCraft's
|
||||
/// [`ParamInfo::format`] would be wrong for it. Main thread only; `None` means "format it
|
||||
/// yourself".
|
||||
fn value_text(&mut self, _id: &str, _value: f32) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a plugin's state on the main (UI) thread while the instance processes on the audio
|
||||
|
||||
@@ -65,19 +65,29 @@ pub fn normalize(ch: &mut [Vec<f32>], target_db: f32, per_channel: bool) {
|
||||
for c in ch.iter_mut() {
|
||||
let p = peak(std::slice::from_ref(c));
|
||||
if p > 0.0 {
|
||||
let g = target / p;
|
||||
c.iter_mut().for_each(|s| *s *= g);
|
||||
scale_to(c.iter_mut(), target, p);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let p = peak(ch);
|
||||
if p > 0.0 {
|
||||
let g = target / p;
|
||||
ch.iter_mut().flatten().for_each(|s| *s *= g);
|
||||
scale_to(ch.iter_mut().flatten(), target, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scales samples whose peak is `p` to `target`. A subnormal `p` makes the f32 gain overflow to
|
||||
/// infinity, so that case scales in f64.
|
||||
fn scale_to<'a>(samples: impl Iterator<Item = &'a mut f32>, target: f32, p: f32) {
|
||||
let g = target / p;
|
||||
if g.is_finite() {
|
||||
samples.for_each(|s| *s *= g);
|
||||
} else {
|
||||
let g = f64::from(target) / f64::from(p);
|
||||
samples.for_each(|s| *s = (f64::from(*s) * g) as f32);
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a gain change in dB.
|
||||
pub fn gain(ch: &mut [Vec<f32>], db: f32) {
|
||||
let g = db_to_gain(if db.is_nan() { 0.0 } else { db.min(96.0) });
|
||||
|
||||
@@ -331,15 +331,26 @@ impl Sampler {
|
||||
/// Moves a voice's read position one output frame on, looping or ending it.
|
||||
#[inline]
|
||||
fn advance(v: &mut Voice, len: f64, region: Option<(f64, f64)>, mode: LoopMode) {
|
||||
v.pos = if v.forward { v.pos + v.step } else { v.pos - v.step };
|
||||
let was_forward = v.forward;
|
||||
v.pos = if was_forward { v.pos + v.step } else { v.pos - v.step };
|
||||
match region {
|
||||
Some((ls, le)) if mode == LoopMode::PingPong => {
|
||||
if v.forward && v.pos >= le {
|
||||
v.pos = (le - (v.pos - le)).max(ls);
|
||||
v.forward = false;
|
||||
} else if !v.forward && v.pos <= ls {
|
||||
v.pos = (ls + (ls - v.pos)).min(le);
|
||||
v.forward = true;
|
||||
// How far past the edge it was heading for, measured from the edge it left. A step of
|
||||
// more than the loop's width crosses it more than once: fold the distance into the
|
||||
// loop (even crossings keep the direction, odd ones turn it round) instead of
|
||||
// reflecting once and dropping the rest of the travel.
|
||||
let width = le - ls;
|
||||
let dist = if was_forward { v.pos - ls } else { le - v.pos };
|
||||
if width > 0.0 && dist >= width && dist.is_finite() {
|
||||
let crossings = (dist / width).floor();
|
||||
let into = dist - crossings * width;
|
||||
let odd = crossings % 2.0 == 1.0;
|
||||
(v.pos, v.forward) = match (was_forward, odd) {
|
||||
(true, false) => (ls + into, true),
|
||||
(true, true) => (le - into, false),
|
||||
(false, false) => (le - into, false),
|
||||
(false, true) => (ls + into, true),
|
||||
};
|
||||
}
|
||||
}
|
||||
Some((ls, le)) => {
|
||||
@@ -703,6 +714,52 @@ mod tests {
|
||||
assert!(out[0].iter().all(|x| x.is_finite() && *x <= 1.0));
|
||||
}
|
||||
|
||||
/// Where a ping-pong voice is after `n` steps, by walking a triangle wave over the loop.
|
||||
fn triangle(start: f64, forward: bool, step: f64, n: u32, ls: f64, le: f64) -> (f64, bool) {
|
||||
let width = le - ls;
|
||||
// Unfold the walk onto one line, then fold it back: phase 0..width rising, width..2 width falling.
|
||||
let signed = if forward { start - ls } else { (le - start) + width };
|
||||
let t = (signed + f64::from(n) * step).rem_euclid(2.0 * width);
|
||||
if t < width { (ls + t, true) } else { (le - (t - width), false) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ping_pong_step_wider_than_the_loop_keeps_the_phase() {
|
||||
let (ls, le) = (100.0, 110.0);
|
||||
// Steps below, equal to and above the loop's width, with fractions, both ways round.
|
||||
for step in [0.37, 3.0, 9.99, 10.0, 10.5, 23.7, 25.0, 31.3, 99.9] {
|
||||
for forward in [true, false] {
|
||||
let mut v = Voice::new();
|
||||
v.pos = if forward { 102.5 } else { 107.25 };
|
||||
v.forward = forward;
|
||||
v.step = step;
|
||||
let start = v.pos;
|
||||
for n in 1..=40u32 {
|
||||
advance(&mut v, 1000.0, Some((ls, le)), LoopMode::PingPong);
|
||||
let (want, want_forward) = triangle(start, forward, step, n, ls, le);
|
||||
assert!(v.pos >= ls - 1e-9 && v.pos <= le + 1e-9, "step {step} n {n}: {} left the loop", v.pos);
|
||||
// At an exact end the direction is where the position is heading next.
|
||||
let at_end = (want - ls).abs() < 1e-6 || (want - le).abs() < 1e-6;
|
||||
assert!((v.pos - want).abs() < 1e-6, "step {step} {forward} n {n}: at {} want {want}", v.pos);
|
||||
assert!(at_end || v.forward == want_forward, "step {step} {forward} n {n}: direction");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ping_pong_step_below_the_loop_width_moves_as_before() {
|
||||
let mut v = Voice::new();
|
||||
v.pos = 108.0;
|
||||
v.step = 5.0;
|
||||
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
|
||||
assert_eq!((v.pos, v.forward), (107.0, false), "one reflection off the end");
|
||||
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
|
||||
assert_eq!((v.pos, v.forward), (102.0, false));
|
||||
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
|
||||
assert_eq!((v.pos, v.forward), (103.0, true), "and one off the start");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_too_short_to_loop_plays_once() {
|
||||
let mut s = sampler(mono(SR, vec![0.5; 1000]));
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,19 @@
|
||||
use soundcraft_dsp::offline::{normalize, peak};
|
||||
|
||||
#[test]
|
||||
fn normalize_subnormal_peak_stays_finite() {
|
||||
for per_channel in [false, true] {
|
||||
let mut c = vec![vec![1e-44f32, -1e-44]];
|
||||
normalize(&mut c, 0.0, per_channel);
|
||||
assert!(c[0].iter().all(|s| s.is_finite()), "{c:?}");
|
||||
assert!((peak(&c) - 1.0).abs() < 1e-3, "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_silent_and_normal_channels() {
|
||||
let mut c = vec![vec![0.0f32; 4], vec![0.25, -0.5]];
|
||||
normalize(&mut c, 0.0, true);
|
||||
assert_eq!(c[0], vec![0.0; 4]);
|
||||
assert!((peak(&c[1..]) - 1.0).abs() < 1e-5);
|
||||
}
|
||||
@@ -176,11 +176,29 @@ struct 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> {
|
||||
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 lock = std::fs::create_dir_all(&dir).and_then(|()| std::fs::File::create(dir.join(LOCK))).map_err(|err| io_error(&dir, err))?;
|
||||
let base = format!("{} {stamp}-{}", sanitize_name(name), std::process::id());
|
||||
create_folder(root).map_err(|err| io_error(root, err))?;
|
||||
let mut dir = root.join(&base);
|
||||
let mut n = 1;
|
||||
loop {
|
||||
// Not recursive, so a folder that already exists is reported and the next name tried.
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||
match builder.create(&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())))?;
|
||||
Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1, recording: false })
|
||||
}
|
||||
@@ -210,9 +228,7 @@ impl Autosave {
|
||||
self.next += 1;
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
std::fs::create_dir_all(&audio_dir)
|
||||
.and_then(|()| write_synced(&audio_dir.join(&name), &bytes))
|
||||
.map_err(|err| io_error(&audio_dir, err))?;
|
||||
create_folder(&audio_dir).and_then(|()| write_synced(&audio_dir.join(&name), &bytes)).map_err(|err| io_error(&audio_dir, err))?;
|
||||
name
|
||||
}
|
||||
};
|
||||
@@ -395,6 +411,18 @@ pub fn discard(left: &Leftover) {
|
||||
let _ = std::fs::remove_dir_all(&left.dir);
|
||||
}
|
||||
|
||||
/// Create `dir` and its missing parents under the autosave root. On Unix every folder made here is
|
||||
/// owner-only (`0o700`): autosaves and the takes of a session never saved are unsaved work, and
|
||||
/// with the default mode other accounts on the machine could read them. A folder that already
|
||||
/// exists keeps its mode.
|
||||
pub fn create_folder(dir: &Path) -> std::io::Result<()> {
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||
builder.create(dir)
|
||||
}
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let mut f = std::fs::File::create(path)?;
|
||||
f.write_all(bytes)?;
|
||||
@@ -427,6 +455,14 @@ mod tests {
|
||||
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) {
|
||||
let before = e.session_arc();
|
||||
let s = e.session_mut();
|
||||
@@ -453,6 +489,7 @@ mod tests {
|
||||
let mut e = Engine::default();
|
||||
record(&mut e, 0.5);
|
||||
crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap();
|
||||
backdate(&path);
|
||||
record(&mut e, -0.25);
|
||||
let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap();
|
||||
a.write(e.session(), e.path.as_deref(), None).unwrap();
|
||||
@@ -511,6 +548,50 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Autosaves are unsaved work: on Unix the folders the autosaver makes are owner-only, and an
|
||||
/// autosave root that already exists keeps its mode.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_autosaver_makes_owner_only_folders() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
let base = folder("autosave-modes");
|
||||
let mut e = Engine::default();
|
||||
record(&mut e, 0.5);
|
||||
// A root that doesn't exist yet is made with the first autosave, owner-only like the
|
||||
// session's folder and the audio copied into it.
|
||||
let new = base.join("Autosave");
|
||||
let w = Autosaver::new(new.clone());
|
||||
w.write(e.session_arc(), None, None);
|
||||
w.flush().unwrap();
|
||||
let session = std::fs::read_dir(&new).unwrap().flatten().map(|d| d.path()).next().unwrap();
|
||||
assert_eq!((mode(&new), mode(&session), mode(&session.join("Audio Files"))), (0o700, 0o700, 0o700));
|
||||
w.close(false);
|
||||
w.flush().unwrap();
|
||||
// A root that already exists keeps its mode; the folders made inside it are owner-only.
|
||||
let old = base.join("old");
|
||||
std::fs::create_dir_all(&old).unwrap();
|
||||
std::fs::set_permissions(&old, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let w = Autosaver::new(old.clone());
|
||||
w.write(e.session_arc(), None, None);
|
||||
w.flush().unwrap();
|
||||
let session = std::fs::read_dir(&old).unwrap().flatten().map(|d| d.path()).next().unwrap();
|
||||
assert_eq!((mode(&old), mode(&session)), (0o755, 0o700));
|
||||
// A session never saved records its takes into `<name>/Audio Files` under the root. Both
|
||||
// are made owner-only, and so is `Audio Files` in a take folder an earlier version left.
|
||||
let takes = old.join("Untitled").join("Audio Files");
|
||||
create_folder(&takes).unwrap();
|
||||
assert_eq!((mode(&old.join("Untitled")), mode(&takes)), (0o700, 0o700));
|
||||
let earlier = old.join("Earlier");
|
||||
std::fs::create_dir_all(&earlier).unwrap();
|
||||
std::fs::set_permissions(&earlier, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
create_folder(&earlier.join("Audio Files")).unwrap();
|
||||
assert_eq!((mode(&earlier), mode(&earlier.join("Audio Files"))), (0o755, 0o700));
|
||||
w.close(false);
|
||||
w.flush().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_take_still_recording_at_a_crash_is_placed_on_recovery() {
|
||||
use crate::io::Take;
|
||||
@@ -590,6 +671,21 @@ mod tests {
|
||||
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]
|
||||
fn unsaved_work_is_offered_once_its_session_closes() {
|
||||
let dir = folder("autosave-leftovers");
|
||||
@@ -598,6 +694,7 @@ mod tests {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut e = Engine::default();
|
||||
crate::io::save_session(&mut e, &path).unwrap();
|
||||
backdate(Path::new(&path));
|
||||
record(&mut e, 0.5);
|
||||
let mut a = Autosave::create(&root, "Song").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();
|
||||
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 dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let mut audio = BTreeMap::new();
|
||||
@@ -80,26 +80,17 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn decode_group_audio(
|
||||
audio_entries: &BTreeMap<u64, String>,
|
||||
dir: &Path,
|
||||
rate: u32,
|
||||
cmd_id: &str,
|
||||
) -> Result<(Vec<(String, soundcraft_audio_io::AudioBuffer)>, BTreeMap<u64, usize>)> {
|
||||
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 &file.audio {
|
||||
for (old, rel) in audio_entries {
|
||||
let full = dir.join(rel);
|
||||
if !full.starts_with(&dir) || rel.contains("..") {
|
||||
return Err(EngineError::BadParams(id.into(), format!("audio path escapes the group folder: {rel}")));
|
||||
if !full.starts_with(dir) || rel.contains("..") {
|
||||
return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
|
||||
}
|
||||
let key = crate::io::file_key(&full);
|
||||
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);
|
||||
}
|
||||
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 s = e.session_mut();
|
||||
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
|
||||
.into_iter()
|
||||
.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)
|
||||
})
|
||||
.collect();
|
||||
@@ -149,6 +159,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
|
||||
s.add_track(kind, fmt, Some("Clip Group"))
|
||||
}
|
||||
};
|
||||
let mut prepared = Vec::new();
|
||||
for mut c in lane {
|
||||
if let ClipContent::Audio { source, offset } = &mut c.content {
|
||||
let Some(n) = map.get(&source.0) else { continue };
|
||||
@@ -160,10 +171,23 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
|
||||
c.id = s.new_clip_id();
|
||||
c.start = at.saturating_add(scale(c.start.max(0)));
|
||||
c.length = scale(c.length);
|
||||
c.fade_in.len = scale(c.fade_in.len);
|
||||
c.fade_out.len = scale(c.fade_out.len);
|
||||
c.sync_point = scale(c.sync_point);
|
||||
c.gain_env.iter_mut().for_each(|(at, _)| *at = scale(*at));
|
||||
c.group = Some(group_id);
|
||||
c.sanitize();
|
||||
placed.push(c.id);
|
||||
crate::edit::place_clip(s, target, c);
|
||||
prepared.push(c);
|
||||
}
|
||||
// Clear what was already on the track first, then add the whole lane at once, so clips of
|
||||
// the group that overlap each other (crossfades) keep their overlap.
|
||||
for c in &prepared {
|
||||
crate::edit::clear_range(s, target, c.range(), false);
|
||||
}
|
||||
if let Some(pl) = s.track_mut(target).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips.extend(prepared);
|
||||
pl.sort();
|
||||
}
|
||||
}
|
||||
Ok(placed)
|
||||
@@ -229,6 +253,51 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_clips_of_a_group_keep_their_overlap_on_import() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-overlap-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let p = dir.join("overlap.scgrp");
|
||||
let note = json!({"pitch": 60, "velocity": 100, "release_velocity": 64, "channel": 0, "start": 0, "length": 100});
|
||||
let content = json!({"Midi": {"sequence": {"notes": [note], "ctrls": []}}});
|
||||
let a = json!({"id": 1, "name": "a", "start": 0, "length": 1000, "content": content});
|
||||
let b = json!({"id": 2, "name": "b", "start": 600, "length": 1000, "content": content});
|
||||
let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": 48000, "length": 1600, "lanes": [[a, b]], "audio": {}});
|
||||
std::fs::write(&p, file.to_string()).unwrap();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap();
|
||||
let ids: Vec<_> = r["clips"].as_array().unwrap().iter().filter_map(serde_json::Value::as_u64).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
let spans: Vec<_> =
|
||||
ids.iter().filter_map(|id| e.session().find_clip(soundcraft_model::ClipId(*id)).map(|(_, c)| (c.start, c.length))).collect();
|
||||
// The group lands at the insertion point; its clips keep their relative positions and overlap.
|
||||
let at = spans.first().map_or(0, |s| s.0);
|
||||
assert_eq!(spans, vec![(at, 1000), (at + 600, 1000)]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fades_sync_point_and_envelope_follow_the_sample_rate() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-rate-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let p = dir.join("rate.scgrp");
|
||||
let mut e = crate::demo::demo_engine();
|
||||
// The file was saved at half the session rate, so every position doubles.
|
||||
let src_rate = e.session().sample_rate.hz() / 2;
|
||||
let fade = |len: i64| json!({"len": len, "shape": "Linear"});
|
||||
let clip = json!({
|
||||
"id": 1, "name": "m", "start": 0, "length": 10_000, "content": {"Midi": {"sequence": {"notes": [], "ctrls": []}}},
|
||||
"fade_in": fade(1000), "fade_out": fade(2000), "sync_point": 500, "gain_env": [[100, -6.0], [4000, 0.0]]
|
||||
});
|
||||
let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": src_rate, "length": 10_000, "lanes": [[clip]], "audio": {}});
|
||||
std::fs::write(&p, file.to_string()).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap();
|
||||
let c = e.session().find_clip(soundcraft_model::ClipId(r["clips"][0].as_u64().unwrap())).unwrap().1;
|
||||
assert_eq!((c.length, c.fade_in.len, c.fade_out.len, c.sync_point), (20_000, 2000, 4000, 1000));
|
||||
assert_eq!(c.gain_env.iter().map(|&(at, _)| at).collect::<Vec<_>>(), vec![200, 8000]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_named_by_many_ids_becomes_one_source() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-shared-{}", std::process::id()));
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! 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).
|
||||
use super::{CommandSpec, bad, has_range, range_param, tracks_required};
|
||||
|
||||
use super::*;
|
||||
use crate::{Clipboard, cmd};
|
||||
use serde_json::json;
|
||||
use crate::{Clipboard, Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_midi::{Note, Sequence};
|
||||
use soundcraft_model::{Clip, ClipId};
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! 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::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_audio_io::{AudioBuffer, FileFormat};
|
||||
use soundcraft_model::{Clip, ClipContent, TrackKind};
|
||||
|
||||
@@ -117,6 +118,8 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
nc.name = new_name;
|
||||
nc.content = ClipContent::Audio { source: src, offset: 0 };
|
||||
nc.length = i64::try_from(frames).unwrap_or(nc.length);
|
||||
// The render already has the clip's stretch applied; keeping it would stretch the new file again.
|
||||
nc.stretch = 1.0;
|
||||
// Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain,
|
||||
// the same way its fades stay editable. Other processes bake a new file at unity gain.
|
||||
if !matches!(name.as_str(), "invert" | "duplicate") {
|
||||
@@ -137,9 +140,17 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn generate(e: &mut Engine, p: &Value, name: &str, params: &[(String, f32)], r: Range) -> Result<Value> {
|
||||
let sr = e.session().sample_rate;
|
||||
let tracks = tracks_param(e, "audiosuite.process", p)?;
|
||||
let mut budget = soundcraft_mix::MAX_RENDER_SAMPLES;
|
||||
// Refuse before generating anything, rather than cut the clip short.
|
||||
for t in tracks.iter().filter_map(|t| e.session().track(*t)).filter(|t| t.kind == TrackKind::Audio) {
|
||||
crate::io::refuse_longer(e.session(), "audiosuite.process", &t.name, "generate on", r.len(), crate::io::render_limit(t.channels()))?;
|
||||
let channels = t.channels().max(1);
|
||||
crate::io::refuse_longer(e.session(), "audiosuite.process", &t.name, "generate on", r.len(), crate::io::render_limit(channels))?;
|
||||
// Each track keeps its generated buffer for the life of the command (and undo holds it), so
|
||||
// cap the total samples generated across tracks, not just each one.
|
||||
let frames = usize::try_from(r.len()).unwrap_or(0).min(crate::io::render_limit(channels));
|
||||
budget = budget
|
||||
.checked_sub(frames.saturating_mul(channels))
|
||||
.ok_or_else(|| bad("audiosuite.process", "too much audio to generate in one command"))?;
|
||||
}
|
||||
let mut clips = Vec::new();
|
||||
for track in tracks {
|
||||
@@ -216,6 +227,21 @@ mod tests {
|
||||
assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processed_clip_does_not_keep_the_stretch_it_baked() {
|
||||
for stretch in [0.5, 2.0] {
|
||||
for process in ["invert", "duplicate"] {
|
||||
let (mut e, id) = gained_clip();
|
||||
e.session_mut().find_clip_mut(id).unwrap().stretch = stretch;
|
||||
e.execute("audiosuite.process", &json!({"process": process, "clips": [id.0]})).unwrap();
|
||||
let c = e.session().tracks[0].clips()[0].clone();
|
||||
let frames = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0].len();
|
||||
assert_eq!(c.stretch, 1.0, "{process} at {stretch}");
|
||||
assert_eq!(c.length, i64::try_from(frames).unwrap(), "{process} at {stretch}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_generator_fills_the_selection_with_a_clip() {
|
||||
let (mut e, _) = gained_clip();
|
||||
@@ -239,4 +265,43 @@ mod tests {
|
||||
let ranges: Vec<_> = e.session().tracks[0].clips().iter().map(|c| (c.start, c.length)).collect();
|
||||
assert_eq!(ranges, vec![(0, 32), (32, 64)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_track_ids_generate_once_not_per_listing() {
|
||||
let mut e = Engine::new(Session::default());
|
||||
let t = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
|
||||
e.execute("edit.select", &json!({"tracks": [t.0], "start": 0, "end": 4800})).unwrap();
|
||||
// The same track eight times over: it must generate once, not eight times (#896).
|
||||
let out = e
|
||||
.execute(
|
||||
"audiosuite.process",
|
||||
&json!({"process": "signal_generator", "tracks": [t.0, t.0, t.0, t.0, t.0, t.0, t.0, t.0], "params": {"freq": 440.0}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out["processed"], 1);
|
||||
assert_eq!(e.session().tracks[0].clips().len(), 1, "one clip, not one per duplicate listing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_refuses_a_total_budget_across_tracks() {
|
||||
let mut e = Engine::new(Session::default());
|
||||
// Two tracks each near the per-track limit: the second would push the total past the budget.
|
||||
for n in 0..2 {
|
||||
e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, Some(&format!("T{n}")));
|
||||
}
|
||||
let t: Vec<_> = e.session().tracks.iter().map(|t| t.id.0).collect();
|
||||
// Select both tracks so signal_generator targets them.
|
||||
e.execute("edit.select", &json!({"tracks": [t[0], t[1]], "start": 0, "end": 4800})).unwrap();
|
||||
e.execute("audiosuite.process", &serde_json::json!({"process": "signal_generator", "track": t[0], "params": {"freq": 440.0}})).unwrap();
|
||||
assert_eq!(e.session().tracks[0].clips().len(), 1, "one track generates one clip");
|
||||
// Two distinct tracks over the total budget are refused (MAX_RENDER_SAMPLES is f32 samples
|
||||
// across all channels; a single track over the limit is already refused per-track).
|
||||
let len = (soundcraft_mix::MAX_RENDER_SAMPLES as i64) / 3 * 2;
|
||||
let out = e.execute(
|
||||
"audiosuite.process",
|
||||
&serde_json::json!({"process": "signal_generator", "tracks": [t[0], t[1]], "params": {"freq": 440.0}, "start": 0, "end": len}),
|
||||
);
|
||||
let ok = matches!(out, Err(crate::EngineError::BadParams(_, ref m)) if m.contains("too much audio"));
|
||||
assert!(ok, "two tracks over budget, but got: {out:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Third-party CLAP plugins: discovery (`engine.clap_plugins`) and id lookup shared by the mixer
|
||||
//! commands. Hosting itself lives in `soundcraft-clap-host`.
|
||||
use super::{CommandSpec, always};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_dsp::PluginInfo;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! 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::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -102,6 +104,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("clip.remove_sync_point", "Remove Sync Point", ["Clip"], None, "{clips?}", has_sync_point, remove_sync_point),
|
||||
cmd!("clip.quantize_to_grid", "Quantize to Grid", ["Clip"], Some("Cmd+0"), "{clips?}", has_selection, quantize_to_grid),
|
||||
cmd!("clip.color", "Clip Color", [], None, "{clips?, color: [r,g,b] | null}", has_selection, |e, p| {
|
||||
let ids = clip_ids_param(e, p);
|
||||
@@ -348,6 +351,31 @@ fn nudge_gain(e: &mut Engine, p: &Value, db: f64) -> Result<Value> {
|
||||
gain(e, &json!({"clips": clips, "delta_db": db}))
|
||||
}
|
||||
|
||||
/// Sync point 0 is "none" (edit_window only draws one when > 0).
|
||||
fn has_sync_point(e: &Engine) -> std::result::Result<(), String> {
|
||||
let s = e.session();
|
||||
if clip_ids_param(e, &json!({})).iter().any(|id| s.find_clip(*id).is_some_and(|(_, c)| c.sync_point != 0)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("no selected clip has a sync point".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_sync_point(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session();
|
||||
// Only clips that have a point are touched, so a call that changes nothing leaves no undo step.
|
||||
let ids: Vec<ClipId> = clip_ids_param(e, p).into_iter().filter(|id| s.find_clip(*id).is_some_and(|(_, c)| c.sync_point != 0)).collect();
|
||||
if !ids.is_empty() {
|
||||
let s = e.session_mut();
|
||||
for id in &ids {
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
c.sync_point = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"clips": ids.len()}))
|
||||
}
|
||||
|
||||
fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session();
|
||||
@@ -452,4 +480,72 @@ mod tests {
|
||||
e.execute("clip.gain_nudge_up", &json!({})).unwrap();
|
||||
assert_eq!(gain(&e), 36.0);
|
||||
}
|
||||
|
||||
/// Three 10 000-sample clips side by side on one track; sync points are set by each test.
|
||||
fn three_clips() -> (crate::Engine, [ClipId; 3]) {
|
||||
use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind};
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Dialog"));
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.1; 10_000]] };
|
||||
let src = crate::io::add_source(&mut s, "Line", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let ids = [0, 1, 2].map(|k| {
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(&mut s, t, Clip::audio(cid, "Line", src, 0, k * 12_000, 10_000));
|
||||
cid
|
||||
});
|
||||
(crate::Engine::new(s), ids)
|
||||
}
|
||||
|
||||
fn sync_of(e: &crate::Engine, id: ClipId) -> i64 {
|
||||
e.session().find_clip(id).unwrap().1.sync_point
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_sync_point_clears_the_point_identify_set() {
|
||||
let (mut e, [a, ..]) = three_clips();
|
||||
e.execute("edit.select", &json!({"clips": [a.0]})).unwrap();
|
||||
e.execute("clip.identify_sync_point", &json!({"at": 4_000})).unwrap();
|
||||
assert_eq!(sync_of(&e, a), 4_000);
|
||||
e.execute("clip.remove_sync_point", &json!({})).unwrap();
|
||||
assert_eq!(sync_of(&e, a), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_sync_point_clears_every_selected_clip_and_only_those() {
|
||||
let (mut e, [a, b, c]) = three_clips();
|
||||
for (id, at) in [(a, 1_000), (b, 2_000), (c, 3_000)] {
|
||||
e.session_mut().find_clip_mut(id).unwrap().sync_point = at;
|
||||
}
|
||||
e.execute("edit.select", &json!({"clips": [a.0, b.0]})).unwrap();
|
||||
let r = e.execute("clip.remove_sync_point", &json!({})).unwrap();
|
||||
assert_eq!(r["clips"], 2);
|
||||
assert_eq!([sync_of(&e, a), sync_of(&e, b), sync_of(&e, c)], [0, 0, 3_000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_sync_point_is_disabled_unless_a_selected_clip_has_one() {
|
||||
let (mut e, [a, b, c]) = three_clips();
|
||||
e.session_mut().find_clip_mut(c).unwrap().sync_point = 3_000;
|
||||
let enabled = |e: &crate::Engine| crate::find_command("clip.remove_sync_point").unwrap().info(e).enabled;
|
||||
e.execute("edit.select", &json!({"clips": [a.0, b.0]})).unwrap();
|
||||
assert!(!enabled(&e), "neither selected clip has a sync point (the unselected one does)");
|
||||
e.execute("edit.select", &json!({"clips": [b.0, c.0]})).unwrap();
|
||||
assert!(enabled(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_sync_point_is_one_undo_step_that_puts_the_points_back() {
|
||||
let (mut e, [a, b, _]) = three_clips();
|
||||
for (id, at) in [(a, 1_000), (b, 2_000)] {
|
||||
e.session_mut().find_clip_mut(id).unwrap().sync_point = at;
|
||||
}
|
||||
e.execute("edit.select", &json!({"clips": [a.0, b.0]})).unwrap();
|
||||
let steps = e.undo_history().len();
|
||||
e.execute("clip.remove_sync_point", &json!({})).unwrap();
|
||||
assert_eq!(e.undo_history().len(), steps + 1);
|
||||
assert_eq!(e.undo_label(), Some("Remove Sync Point"));
|
||||
assert_eq!([sync_of(&e, a), sync_of(&e, b)], [0, 0]);
|
||||
assert!(e.undo());
|
||||
assert_eq!([sync_of(&e, a), sync_of(&e, b)], [1_000, 2_000]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Clip List commands.
|
||||
use super::{CommandSpec, bad, bool_or};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::SourceId;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{ClipId, SourceId};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const CLEAR: &str = "clip.clear";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! More Clip menu commands: Regroup, Unloop, Clip Effects bypass/render, Conform to Tempo and
|
||||
//! Remove Pitch Shift.
|
||||
use super::{CommandSpec, bad, clip_ids_param, has_selection, str_param};
|
||||
|
||||
use super::more_util::{render_stretched, replace_clip};
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{Clip, ClipContent, ClipId, Session, TrackId};
|
||||
use soundcraft_time::Samples;
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
//! 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::*;
|
||||
use crate::edit as ops;
|
||||
use crate::{Clipboard, cmd};
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{EditMode, Fade, FadeShape, Session};
|
||||
use crate::{Clipboard, Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{ClipId, EditMode, Fade, FadeShape, Session, TrackId};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! 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.
|
||||
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::*;
|
||||
use crate::edit as ops;
|
||||
use crate::{Clipboard, TransportRequest, cmd};
|
||||
use serde_json::json;
|
||||
use crate::{Clipboard, Engine, Result, TransportRequest, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{AutoParam, AutomationLane, Clip, ClipContent, ClipId, EditMode, Playlist, Session};
|
||||
use soundcraft_time::{Range, Samples};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! 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::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_midi::ops::{DurationOp, QuantizeOptions, VelocityOp};
|
||||
use soundcraft_model::{ClipContent, MarkerKind};
|
||||
use soundcraft_time::TICKS_PER_QUARTER;
|
||||
@@ -94,7 +94,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
add_marker
|
||||
),
|
||||
cmd!("markers.delete", "Delete Memory Location", [], None, "{number | id}", always, delete_marker),
|
||||
cmd!("markers.edit", "Edit Memory Location", [], None, "{number | id, name?, comments?, at?, color?}", always, edit_marker),
|
||||
cmd!("markers.edit", "Edit Memory Location", [], None, "{number | id, name?, comments?, at?, color?: [r,g,b] | null}", always, edit_marker),
|
||||
cmd!(noundo "markers.recall", "Recall Memory Location", [], Some("Period + n + Period"), "{number}", always, recall_marker),
|
||||
cmd!(noundo "markers.next", "Go to Next Marker", [], None, "{}", always, |e, _| step_marker(e, 1)),
|
||||
cmd!(noundo "markers.previous", "Go to Previous Marker", [], None, "{}", always, |e, _| step_marker(e, -1)),
|
||||
@@ -300,6 +300,7 @@ fn add_marker(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let (a, b) = match (at, st, en) {
|
||||
(Some(a), _, _) => (a, a),
|
||||
(None, Some(a), Some(b)) => (a, b),
|
||||
(None, Some(p), None) | (None, None, Some(p)) => (p, p),
|
||||
_ if kind == MarkerKind::Selection => (sel.start, sel.end),
|
||||
_ => (if e.transport.playing { e.transport.position } else { sel.start }, sel.start),
|
||||
};
|
||||
@@ -337,8 +338,23 @@ fn delete_marker(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn edit_marker(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let i = marker_index(e, p, "markers.edit")?;
|
||||
let at = position_param(e, "markers.edit", p, "at")?;
|
||||
let color = match p.get("color") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::Array(a)) if a.len() == 3 => {
|
||||
let ch = |i: usize| a.get(i).and_then(Value::as_u64).and_then(|v| u8::try_from(v).ok());
|
||||
match (ch(0), ch(1), ch(2)) {
|
||||
(Some(r), Some(g), Some(b)) => Some(Some([r, g, b])),
|
||||
_ => return Err(bad("markers.edit", "`color` components must be integers 0..=255")),
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(bad("markers.edit", "`color` must be [r,g,b] or null")),
|
||||
};
|
||||
let s = e.session_mut();
|
||||
let Some(m) = s.markers.get_mut(i) else { return Err(bad("markers.edit", "gone")) };
|
||||
if let Some(c) = color {
|
||||
m.color = c;
|
||||
}
|
||||
// `name` is the documented new name; when it was the selector it already matches, so this is a no-op.
|
||||
if let Some(n) = str_param(p, "rename").or_else(|| str_param(p, "name")) {
|
||||
m.name = n.to_string();
|
||||
@@ -443,6 +459,44 @@ mod tests {
|
||||
assert_eq!((bridge.name.as_str(), bridge.comments.as_str()), ("Bridge", "last"));
|
||||
}
|
||||
|
||||
/// `markers.add` with only `start` or only `end` places the marker there, not at the edit selection.
|
||||
#[test]
|
||||
fn markers_add_start_or_end_alone_is_used() {
|
||||
let mut e = demo_engine();
|
||||
assert_ne!(e.session().edit.selection.start, 96_000);
|
||||
let a = e.execute("markers.add", &json!({"name": "A", "start": 96_000})).unwrap();
|
||||
let b = e.execute("markers.add", &json!({"name": "B", "end": 96_000})).unwrap();
|
||||
let c = e.execute("markers.add", &json!({"name": "C", "start": 1000, "end": 2000})).unwrap();
|
||||
let find = |r: &serde_json::Value| e.session().markers.iter().find(|m| Some(m.id.0) == r["id"].as_u64()).map(|m| (m.start, m.end));
|
||||
assert_eq!(find(&a), Some((96_000, 96_000)));
|
||||
assert_eq!(find(&b), Some((96_000, 96_000)));
|
||||
assert_eq!(find(&c), Some((1000, 2000)));
|
||||
}
|
||||
|
||||
/// `markers.edit` documents `color`: it sets the memory location's RGB colour, undoably; `null` clears it.
|
||||
#[test]
|
||||
fn edit_marker_sets_documented_color() {
|
||||
let mut e = demo_engine();
|
||||
let n = e.session().markers.first().unwrap().number;
|
||||
let color = |e: &crate::Engine| e.session().markers.iter().find(|m| m.number == n).unwrap().color;
|
||||
assert_eq!(color(&e), None);
|
||||
|
||||
e.execute("markers.edit", &json!({"number": n, "color": [255, 0, 0]})).unwrap();
|
||||
assert_eq!(color(&e), Some([255, 0, 0]));
|
||||
assert!(e.undo());
|
||||
assert_eq!(color(&e), None);
|
||||
assert!(e.redo());
|
||||
assert_eq!(color(&e), Some([255, 0, 0]));
|
||||
|
||||
e.execute("markers.edit", &json!({"number": n, "color": null})).unwrap();
|
||||
assert_eq!(color(&e), None);
|
||||
|
||||
for bad in [json!([1, 2]), json!([1, 2, 300]), json!("red"), json!([1, 2, -3])] {
|
||||
assert!(e.execute("markers.edit", &json!({"number": n, "color": bad})).is_err());
|
||||
assert_eq!(color(&e), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Constant replaces the tempo over [start, end) only; the tempo at the old end tick carries on after it.
|
||||
#[test]
|
||||
fn tempo_constant_is_bounded_by_its_range() {
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
//! operations (split notes, input quantize, step input, restore/flatten performance), MIDI track
|
||||
//! offsets and real-time properties, chord extraction, Beat Detective, Identify Beat and
|
||||
//! 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::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_midi::Note;
|
||||
use soundcraft_midi::ops::QuantizeOptions;
|
||||
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.
|
||||
const MAX_TEMPO_EVENTS: i64 = 10_000;
|
||||
@@ -414,7 +417,9 @@ fn step_input(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let end = s.tempo.tick_to_samples(end_tick, sr);
|
||||
// Continue the clip under (or ending at) the insertion point, else start a new one.
|
||||
let host = s.track(t).and_then(|tr| {
|
||||
tr.clips().iter().filter(|c| matches!(c.content, ClipContent::Midi { .. })).find(|c| c.range().contains(at) || c.end() == at).map(|c| c.id)
|
||||
let mut midi = tr.clips().iter().filter(|c| matches!(c.content, ClipContent::Midi { .. }));
|
||||
// A clip starting at `at` wins over one that merely ends there.
|
||||
midi.clone().find(|c| c.range().contains(at)).or_else(|| midi.find(|c| c.end() == at)).map(|c| c.id)
|
||||
});
|
||||
let host = match host {
|
||||
Some(h) => h,
|
||||
@@ -576,8 +581,8 @@ fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
|
||||
let end = s.tempo.samples_to_ticks(c.end(), s.sample_rate);
|
||||
for n in &sequence.notes {
|
||||
let a = base + n.start;
|
||||
let b = (base + n.end()).min(end);
|
||||
let a = base.saturating_add(n.start);
|
||||
let b = base.saturating_add(n.end()).min(end);
|
||||
if b > a {
|
||||
notes.push((a, b, n.pitch));
|
||||
}
|
||||
@@ -730,13 +735,16 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let t = midi_target(e, p, id)?;
|
||||
let s = e.session();
|
||||
let sr = s.sample_rate;
|
||||
// Latest start tick whose longest note still ends within MAX_POSITION.
|
||||
let max_tick = s.tempo.samples_to_ticks(MAX_POSITION, sr).saturating_sub(TICKS_PER_QUARTER * 1024).max(0);
|
||||
let mut raw: Vec<(i64, Note)> = Vec::new();
|
||||
for v in list.iter().take(100_000) {
|
||||
let tick = match (v.get("start_ticks").and_then(Value::as_i64), v.get("start")) {
|
||||
(Some(tk), _) => tk.max(0),
|
||||
(None, Some(_)) => s.tempo.samples_to_ticks(position_param(e, id, v, "start")?.unwrap_or(0).max(0), sr),
|
||||
_ => return Err(bad(id, "each note needs `start` or `start_ticks`")),
|
||||
};
|
||||
}
|
||||
.min(max_tick);
|
||||
let note = Note {
|
||||
pitch: i64_or(v, "pitch", 60).clamp(0, 127) as u8,
|
||||
velocity: i64_or(v, "velocity", 100).clamp(1, 127) as u8,
|
||||
@@ -750,7 +758,7 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let first = raw.iter().map(|x| x.0).min().unwrap_or(0);
|
||||
// Start the clip on the bar line so the quantize grid lines up with the music.
|
||||
let clip_tick = s.tempo.bar_start_tick(s.tempo.bar_beat_at_tick(first).bar).min(first).max(0);
|
||||
let end_tick = raw.iter().map(|(tk, n)| tk.saturating_add(n.length)).max().unwrap_or(first + 1);
|
||||
let end_tick = raw.iter().map(|(tk, n)| tk.saturating_add(n.length)).max().unwrap_or_else(|| first.saturating_add(1));
|
||||
let mut seq = soundcraft_midi::Sequence::default();
|
||||
for (tk, mut n) in raw {
|
||||
n.start = tk - clip_tick;
|
||||
@@ -764,7 +772,7 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
seq.sort();
|
||||
}
|
||||
let start = s.tempo.tick_to_samples(clip_tick, sr);
|
||||
let end = s.tempo.tick_to_samples(end_tick, sr);
|
||||
let end = s.tempo.tick_to_samples(end_tick, sr).min(MAX_POSITION);
|
||||
let n = seq.notes.len();
|
||||
let s = e.session_mut();
|
||||
let cid = s.new_clip_id();
|
||||
@@ -777,7 +785,6 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn midi_engine() -> (Engine, TrackId) {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"kind": "midi"})).unwrap();
|
||||
@@ -862,6 +869,25 @@ mod tests {
|
||||
assert_eq!(e.session().edit.selection.start, 3 * 24_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_input_at_clip_boundary_uses_next_clip() {
|
||||
let (mut e, t) = midi_engine();
|
||||
let sr = 48_000;
|
||||
e.execute("midi.new_clip", &json!({"track": t.0, "start": 0, "end": sr})).unwrap();
|
||||
let second = e.execute("midi.new_clip", &json!({"track": t.0, "start": sr, "end": 2 * sr})).unwrap()["clip"].as_u64().unwrap();
|
||||
e.execute("edit.select", &json!({"tracks": [t.0], "start": sr})).unwrap();
|
||||
let r = e.execute("event.step_input", &json!({"track": t.0, "pitch": 60, "duration": "1/4"})).unwrap();
|
||||
assert_eq!(r["clip"].as_u64(), Some(second));
|
||||
let mut ranges: Vec<(i64, i64)> = e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect();
|
||||
ranges.sort();
|
||||
assert_eq!(ranges, vec![(0, sr), (sr, 2 * sr)]);
|
||||
let c = e.session().find_clip(soundcraft_model::ClipId(second)).map(|(_, c)| c.clone()).unwrap();
|
||||
match &c.content {
|
||||
ClipContent::Midi { sequence } => assert_eq!(sequence.notes.iter().map(|n| n.start).collect::<Vec<_>>(), vec![0]),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tempo_curves() {
|
||||
let mut e = Engine::default();
|
||||
@@ -943,6 +969,29 @@ mod tests {
|
||||
assert!(r["bpm"].as_f64().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_chords_survives_huge_note_start() {
|
||||
let (mut e, t) = midi_engine();
|
||||
let r = e.execute("midi.new_clip", &json!({"track": t.0, "start": 48_000, "end": 96_000})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": i64::MAX, "length_ticks": 480})).unwrap();
|
||||
e.execute("edit.select", &json!({"tracks": [t.0], "start": 0, "end": 96_000})).unwrap();
|
||||
let r = e.execute("event.extract_chords", &json!({}));
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retro_record_huge_start_ticks_stay_in_range() {
|
||||
for tk in [i64::MAX, 1_000_000_000_000_000] {
|
||||
let (mut e, t) = midi_engine();
|
||||
let notes = json!([{"pitch": 60, "start_ticks": tk, "length_ticks": TICKS_PER_QUARTER * 1024}]);
|
||||
e.execute("event.retrospective_record", &json!({"track": t.0, "notes": notes})).unwrap();
|
||||
let c = e.session().track(t).unwrap().clips()[0].clone();
|
||||
assert!(c.start >= 0 && c.length >= 1, "{tk}: {} {}", c.start, c.length);
|
||||
assert!(c.start.saturating_add(c.length) <= MAX_POSITION, "{tk}: start {} len {}", c.start, c.length);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_song_start_moves_tick_material() {
|
||||
let (mut e, t) = midi_engine();
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! 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::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{Session, TrackKind};
|
||||
use crate::{Engine, EngineError, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{Session, TrackId, TrackKind};
|
||||
use soundcraft_time::SampleRate;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -110,9 +113,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
crate::io::write_file(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}),
|
||||
cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?}", always, |e, p| {
|
||||
cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?, replace?: false}", always, |e, p| {
|
||||
let text = crate::inspect::session_text(e);
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
refuse_replacing(e, "file.export_session_text", std::path::Path::new(path), p)?;
|
||||
crate::io::write_file(path, text.as_bytes())?;
|
||||
}
|
||||
Ok(json!({"text": text}))
|
||||
@@ -238,7 +242,7 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let mut out = Vec::new();
|
||||
for path in &paths {
|
||||
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)?;
|
||||
out.push(r);
|
||||
}
|
||||
@@ -299,7 +303,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 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 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 = 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))
|
||||
@@ -350,6 +354,24 @@ mod tests {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_session_text_needs_replace_to_overwrite() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-export-text-replace-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("info.txt");
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
let mut e = Engine::default();
|
||||
e.execute("file.export_session_text", &json!({"path": path_str})).unwrap();
|
||||
std::fs::write(&path, "keep me").unwrap();
|
||||
let err = e.execute("file.export_session_text", &json!({"path": path_str})).unwrap_err();
|
||||
assert!(err.to_string().contains("replace: true"), "{err}");
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "keep me");
|
||||
e.execute("file.export_session_text", &json!({"path": path_str, "replace": true})).unwrap();
|
||||
assert_ne!(std::fs::read_to_string(&path).unwrap(), "keep me");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_audio_honours_new_tracks() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-import-new-tracks-{}", std::process::id()));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Media availability checks without decoding files or changing the session.
|
||||
use super::{CommandSpec, always};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::ClipContent;
|
||||
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> {
|
||||
let s = e.session();
|
||||
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 (index, playlist) in track.playlists.iter().enumerate() {
|
||||
let active = index == track.active_playlist;
|
||||
let ctx = ClipContext { track, playlist, active, session_sample_rate: s.sample_rate.hz() };
|
||||
for clip in &playlist.clips {
|
||||
let ClipContent::Audio { source, offset } = clip.content else { continue };
|
||||
clips += 1;
|
||||
if active {
|
||||
active_end = active_end.max(clip.end());
|
||||
}
|
||||
let metadata = sources.get(&source);
|
||||
let audio = s.pool.get(source);
|
||||
let mut add = |code: &str, message: &str| {
|
||||
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.");
|
||||
}
|
||||
let metadata = sources.get(&source).copied();
|
||||
let audio = s.pool.get(source).map(|a| a.as_ref());
|
||||
check_audio_clip(&ctx, clip, source, offset, metadata, audio, &mut issues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! 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`;
|
||||
//! 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::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{Insert, TrackKind};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! 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::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_midi::{Note, Sequence};
|
||||
use soundcraft_model::{Clip, ClipContent, ClipId};
|
||||
use soundcraft_time::TICKS_PER_QUARTER;
|
||||
@@ -126,8 +126,8 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let seq = seq_mut(e, id, "midi.notes_move")?;
|
||||
for (i, n) in seq.notes.iter_mut().enumerate() {
|
||||
if idx.contains(&i) {
|
||||
n.start = (n.start + dt).max(0);
|
||||
n.pitch = (i64::from(n.pitch) + ds).clamp(0, 127) as u8;
|
||||
n.start = n.start.saturating_add(dt).max(0);
|
||||
n.pitch = i64::from(n.pitch).saturating_add(ds).clamp(0, 127) as u8;
|
||||
}
|
||||
}
|
||||
seq.sort();
|
||||
@@ -157,6 +157,21 @@ mod tests {
|
||||
assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_move_huge_params_do_not_overflow() {
|
||||
let mut e = Engine::default();
|
||||
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
|
||||
let r = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 480})).unwrap();
|
||||
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "ticks": i64::MAX})).unwrap();
|
||||
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
|
||||
assert_eq!(n["notes"][0]["start"], i64::MAX);
|
||||
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "semitones": i64::MAX})).unwrap();
|
||||
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
|
||||
assert_eq!(n["notes"][0]["pitch"], 127);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_audio_tracks() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
+156
-17
@@ -1,8 +1,10 @@
|
||||
//! 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::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, EngineError, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{AutoParam, AutomationMode, INSERT_SLOTS, Insert, SEND_SLOTS, SendSlot, Session, TrackId};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -73,7 +75,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
)),
|
||||
cmd!("mix.trim", "Input Trim", [], None, "{tracks?, db}", has_selection, |e, p| {
|
||||
let tracks = tracks_required(e, "mix.trim", p)?;
|
||||
let db = f32_or(p, "db", 0.0).clamp(-144.0, 24.0);
|
||||
let db = (p.get("db").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("mix.trim", "`db` (number) required"))? as f32)
|
||||
.clamp(-144.0, 24.0);
|
||||
let s = e.session_mut();
|
||||
for t in tracks {
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
@@ -181,19 +184,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Apply a change to `t` and its active mix-group siblings.
|
||||
fn set_grouped(s: &mut Session, t: TrackId, attr: &str, f: impl Fn(&mut soundcraft_model::Track)) {
|
||||
let mut targets = vec![t];
|
||||
/// `t` plus the members of its active mix groups that share `attr`.
|
||||
fn grouped_ids(s: &Session, t: TrackId, attr: &str) -> Vec<TrackId> {
|
||||
let mut ids = vec![t];
|
||||
for g in &s.groups {
|
||||
if g.active && g.mix && g.members.contains(&t) && g.attributes.iter().any(|a| a == attr) {
|
||||
for m in &g.members {
|
||||
if !targets.contains(m) {
|
||||
targets.push(*m);
|
||||
if !ids.contains(m) {
|
||||
ids.push(*m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in targets {
|
||||
ids
|
||||
}
|
||||
|
||||
/// Apply a change to `t` and its active mix-group siblings.
|
||||
fn set_grouped(s: &mut Session, t: TrackId, attr: &str, f: impl Fn(&mut soundcraft_model::Track)) {
|
||||
for id in grouped_ids(s, t, attr) {
|
||||
if let Some(tr) = s.track_mut(id) {
|
||||
f(tr);
|
||||
}
|
||||
@@ -218,7 +226,13 @@ fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
return Err(bad("mix.volume", "`db` or `delta_db` required"));
|
||||
}
|
||||
let s = e.session_mut();
|
||||
// Tracks already moved as a group sibling of an earlier target must not move again.
|
||||
let mut done: Vec<TrackId> = Vec::new();
|
||||
for t in &tracks {
|
||||
if done.contains(t) {
|
||||
continue;
|
||||
}
|
||||
done.extend(grouped_ids(s, *t, "volume"));
|
||||
let cur = s.track(*t).map_or(0.0, |tr| tr.mixer.volume_db);
|
||||
let new = match (abs, delta) {
|
||||
(Some(a), _) => a as f32,
|
||||
@@ -234,7 +248,8 @@ fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
|
||||
fn pan(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "mix.pan", p)?;
|
||||
let v = f32_or(p, "pan", 0.0).clamp(-1.0, 1.0);
|
||||
let v = (p.get("pan").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("mix.pan", "`pan` (number) required"))? as f32)
|
||||
.clamp(-1.0, 1.0);
|
||||
let idx = i64_or(p, "index", 0).clamp(0, 1) as usize;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
@@ -287,17 +302,30 @@ fn slot_of(p: &Value, key: &str, max: usize) -> Option<usize> {
|
||||
(i < max).then_some(i)
|
||||
}
|
||||
|
||||
fn slot_error(cmd: &str, max: usize) -> EngineError {
|
||||
bad(cmd, format!("`slot` must be 0..{} or a..{}", max - 1, (b'a' + max as u8 - 1) as char))
|
||||
}
|
||||
|
||||
fn track_slot(e: &Engine, p: &Value, cmd: &str, max: usize) -> Result<(TrackId, usize)> {
|
||||
let t = tracks_required(e, cmd, p)?.first().copied().ok_or_else(|| bad(cmd, "no track"))?;
|
||||
let slot = slot_of(p, "slot", max).ok_or_else(|| bad(cmd, format!("`slot` must be 0..{} or a..{}", max - 1, (b'a' + max as u8 - 1) as char)))?;
|
||||
let slot = slot_of(p, "slot", max).ok_or_else(|| slot_error(cmd, max))?;
|
||||
Ok((t, slot))
|
||||
}
|
||||
|
||||
/// An optional `slot`: absent (or null) means "first free"; a present but invalid value is
|
||||
/// an error, never silently treated as absent.
|
||||
fn optional_slot(p: &Value, cmd: &str, max: usize) -> Result<Option<usize>> {
|
||||
match p.get("slot") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(_) => slot_of(p, "slot", max).map(Some).ok_or_else(|| slot_error(cmd, max)),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let plugin = str_param(p, "plugin").ok_or_else(|| bad("mix.insert", "`plugin` required"))?.to_string();
|
||||
let info = super::clap::plugin_info(&plugin).ok_or_else(|| bad("mix.insert", format!("unknown plugin `{plugin}`")))?;
|
||||
let t = tracks_required(e, "mix.insert", p)?.first().copied().ok_or_else(|| bad("mix.insert", "no track"))?;
|
||||
let explicit = slot_of(p, "slot", INSERT_SLOTS);
|
||||
let explicit = optional_slot(p, "mix.insert", INSERT_SLOTS)?;
|
||||
let s = e.session_mut();
|
||||
let tr = s.track_mut(t).ok_or_else(|| bad("mix.insert", "no track"))?;
|
||||
let slot =
|
||||
@@ -416,9 +444,9 @@ fn insert_move(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn send(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let bus = str_param(p, "bus").ok_or_else(|| bad("mix.send", "`bus` required"))?.to_string();
|
||||
let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?;
|
||||
let explicit = slot_of(p, "slot", SEND_SLOTS);
|
||||
let explicit = optional_slot(p, "mix.send", SEND_SLOTS)?;
|
||||
let s = e.session_mut();
|
||||
let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo);
|
||||
let bid = s.bus_by_name(&bus).map(|b| b.id).ok_or_else(|| bad("mix.send", format!("no bus named \"{bus}\"")))?;
|
||||
let tr = s.track_mut(t).ok_or_else(|| bad("mix.send", "no track"))?;
|
||||
let slot = explicit.or_else(|| tr.mixer.sends.iter().position(Option::is_none)).ok_or_else(|| bad("mix.send", "all send slots are in use"))?;
|
||||
// Re-targeting an existing send keeps its level, pan, mute and pre/post setting.
|
||||
@@ -513,7 +541,7 @@ pub(crate) fn current_value(tr: &soundcraft_model::Track, param: &AutoParam) ->
|
||||
|
||||
fn clear_lane(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "automation.clear", p)?;
|
||||
let param = str_param(p, "param").and_then(AutoParam::parse);
|
||||
let param = if str_param(p, "param").is_some() { Some(lane_param(p, "automation.clear")?) } else { None };
|
||||
let r = range_param(e, "automation.clear", p)?;
|
||||
let (a, b) = if r.is_empty() { (i64::MIN, i64::MAX) } else { (r.start, r.end) };
|
||||
let s = e.session_mut();
|
||||
@@ -694,7 +722,13 @@ fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
|
||||
fn copy_to_send(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "automation.copy_to_send", p)?;
|
||||
let slot = slot_of(p, "send", SEND_SLOTS).unwrap_or(0) as u8;
|
||||
// No `send` means Send A (the menu item passes none); a given one must be valid.
|
||||
let slot = match p.get("send") {
|
||||
None | Some(Value::Null) => 0,
|
||||
Some(_) => slot_of(p, "send", SEND_SLOTS).ok_or_else(|| {
|
||||
bad("automation.copy_to_send", format!("`send` must be 0..{} or a..{}", SEND_SLOTS - 1, (b'a' + SEND_SLOTS as u8 - 1) as char))
|
||||
})?,
|
||||
} as u8;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
@@ -874,16 +908,121 @@ mod tests {
|
||||
assert_eq!(e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().preset, "Saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_to_send_rejects_an_invalid_send_slot() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let history = e.undo_history().len();
|
||||
let lanes = |e: &Engine| e.session().tracks.iter().filter(|t| t.lane(&AutoParam::SendLevel(0)).is_some_and(|l| !l.points.is_empty())).count();
|
||||
let before = lanes(&e);
|
||||
for bad_params in [json!({"track": "Kick", "send": 99}), json!({"track": "Kick", "send": "zz"})] {
|
||||
let r = e.execute("automation.copy_to_send", &bad_params);
|
||||
assert!(r.is_err(), "{bad_params} accepted: {r:?}");
|
||||
assert_eq!(e.undo_history().len(), history, "{bad_params} pushed an undo step");
|
||||
assert_eq!(lanes(&e), before, "{bad_params} edited Send A");
|
||||
}
|
||||
// No `send` (the menu item's call) still means Send A.
|
||||
e.execute("automation.copy_to_send", &json!({"track": "Kick"})).unwrap();
|
||||
assert_eq!(lanes(&e), before + 1);
|
||||
// A valid slot still copies.
|
||||
e.execute("automation.copy_to_send", &json!({"track": "Kick", "send": "a"})).unwrap();
|
||||
assert_eq!(lanes(&e), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retargeting_a_send_keeps_its_settings() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
|
||||
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
|
||||
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap();
|
||||
e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap();
|
||||
e.execute("mix.new_bus", &json!({"name": "Delay"})).unwrap();
|
||||
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap();
|
||||
let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap();
|
||||
let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id;
|
||||
assert_eq!(sl.target, soundcraft_model::Route::Bus(delay));
|
||||
assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true));
|
||||
}
|
||||
|
||||
fn grouped_demo() -> Engine {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
e.execute("mix.volume", &json!({"tracks": [3], "db": 0.0})).unwrap();
|
||||
e.execute("mix.volume", &json!({"tracks": [6], "db": 0.0})).unwrap();
|
||||
e.execute("track.group", &json!({"tracks": [3, 6]})).unwrap();
|
||||
e
|
||||
}
|
||||
|
||||
fn volumes(e: &Engine) -> Vec<f32> {
|
||||
[3, 6].iter().map(|id| e.session().track(TrackId(*id)).unwrap().mixer.volume_db).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_delta_on_a_whole_group_applies_once() {
|
||||
let mut e = grouped_demo();
|
||||
e.execute("mix.volume", &json!({"tracks": [3, 6], "delta_db": -3.0})).unwrap();
|
||||
assert_eq!(volumes(&e), [-3.0, -3.0]);
|
||||
|
||||
let mut e = grouped_demo();
|
||||
e.execute("mix.volume", &json!({"tracks": [3], "delta_db": -3.0})).unwrap();
|
||||
assert_eq!(volumes(&e), [-3.0, -3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_absolute_on_a_whole_group_keeps_offsets() {
|
||||
let mut e = grouped_demo();
|
||||
e.session_mut().track_mut(TrackId(6)).unwrap().mixer.volume_db = -2.0;
|
||||
e.execute("mix.volume", &json!({"tracks": [3, 6], "db": -10.0})).unwrap();
|
||||
assert_eq!(volumes(&e), [-10.0, -12.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_with_unknown_param_keeps_lanes() {
|
||||
let (mut e, t) = tone_session(&[(0, -6.0), (100, -3.0)]);
|
||||
e.session_mut().track_mut(t).unwrap().lane_mut(&AutoParam::Pan(0)).set_point(0, 0.5);
|
||||
e.session_mut().edit.selection = Range::new(0, 0);
|
||||
let count = |e: &Engine| e.session().track(t).unwrap().automation.iter().map(|l| l.points.len()).sum::<usize>();
|
||||
let r = e.execute("automation.clear", &json!({"track": t.0, "param": "volum"}));
|
||||
assert!(r.is_err(), "unknown param succeeded: {r:?}");
|
||||
assert_eq!(count(&e), 3, "unknown param changed the lanes");
|
||||
e.execute("automation.clear", &json!({"track": t.0, "param": "volume"})).unwrap();
|
||||
assert_eq!(count(&e), 1, "named param cleared the wrong lanes");
|
||||
e.execute("automation.clear", &json!({"track": t.0})).unwrap();
|
||||
assert_eq!(count(&e), 0, "absent param must still clear every lane");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_rejects_unknown_bus() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
|
||||
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
|
||||
let err = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Nowhere"})).unwrap_err();
|
||||
assert!(err.to_string().contains("no bus named \"Nowhere\""), "{err}");
|
||||
assert_eq!(e.session().busses.len(), 1);
|
||||
assert!(e.session().tracks[0].mixer.sends.iter().all(Option::is_none));
|
||||
e.execute("mix.send", &json!({"track": "Gtr", "bus": "verb"})).unwrap();
|
||||
assert_eq!(e.session().busses.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_send_reject_invalid_slot() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
|
||||
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
|
||||
for slot in [json!(12), json!("k"), json!("x1")] {
|
||||
let r = e.execute("mix.insert", &json!({"track": "Gtr", "slot": slot, "plugin": "compressor"}));
|
||||
assert!(r.is_err(), "insert slot {slot} accepted: {r:?}");
|
||||
}
|
||||
for slot in [json!(12), json!("k")] {
|
||||
let r = e.execute("mix.send", &json!({"track": "Gtr", "slot": slot, "bus": "Verb"}));
|
||||
assert!(r.is_err(), "send slot {slot} accepted: {r:?}");
|
||||
}
|
||||
let mixer = &e.session().tracks[0].mixer;
|
||||
assert!(mixer.inserts.iter().all(Option::is_none));
|
||||
assert!(mixer.sends.iter().all(Option::is_none));
|
||||
|
||||
// Leaving `slot` out still picks the first free slot.
|
||||
let r = e.execute("mix.insert", &json!({"track": "Gtr", "plugin": "compressor"})).unwrap();
|
||||
assert_eq!(r["slot"], 0);
|
||||
let r = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Verb"})).unwrap();
|
||||
assert_eq!(r["slot"], 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,27 @@ mod view_more;
|
||||
mod vst3;
|
||||
mod zoom_toggle;
|
||||
|
||||
#[cfg(test)]
|
||||
mod panic_probe {
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(noundo "test.panic_probe", "Panic Probe", [], None, "{}", always, probe)]
|
||||
}
|
||||
|
||||
fn probe(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if p.get("panic").is_none() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
e.clipboard.length = 123;
|
||||
e.message("probe");
|
||||
e.transport_requests.push(crate::TransportRequest::Play);
|
||||
e.last_selection = Some((soundcraft_time::Range::new(0, 10), Vec::new()));
|
||||
panic!("probe")
|
||||
}
|
||||
}
|
||||
|
||||
pub use file::bounce_job;
|
||||
|
||||
pub(crate) use edit::remember_selection;
|
||||
@@ -81,7 +102,7 @@ impl CommandSpec {
|
||||
CommandInfo {
|
||||
id: self.id.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),
|
||||
params: self.params.into(),
|
||||
enabled: en.is_ok(),
|
||||
@@ -136,6 +157,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(sampler::specs());
|
||||
v.extend(video::specs());
|
||||
v.extend(zoom_toggle::specs());
|
||||
#[cfg(test)]
|
||||
v.extend(panic_probe::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
@@ -224,13 +247,19 @@ pub fn tracks_param(e: &Engine, cmd: &str, p: &Value) -> Result<Vec<TrackId>> {
|
||||
if let Some(arr) = p.get("tracks").and_then(Value::as_array) {
|
||||
let s = e.session();
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for v in arr {
|
||||
let id = match (v.as_u64(), v.as_str()) {
|
||||
(Some(n), _) => s.track(TrackId(n)).map(|t| t.id),
|
||||
(_, Some(name)) => s.track_by_name(name).map(|t| t.id),
|
||||
_ => None,
|
||||
};
|
||||
out.push(id.ok_or_else(|| bad(cmd, format!("unknown track {v}")))?);
|
||||
let id = id.ok_or_else(|| bad(cmd, format!("unknown track {v}")))?;
|
||||
// Duplicates multiply the work (each entry renders and keeps its own result), so drop
|
||||
// repeated ids instead of letting them blow the budget.
|
||||
if seen.insert(id) {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Helpers shared by the newer command modules: settings kept in `EditState.flags`/`values`,
|
||||
//! per-track settings, target playlists, clip-gain envelopes and stretch rendering.
|
||||
use super::bad;
|
||||
use crate::{Engine, Result};
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_audio_io::{AudioBuffer, FileFormat};
|
||||
use soundcraft_model::{Clip, ClipContent, EditState, Session, SourceId, TrackId};
|
||||
use soundcraft_time::Samples;
|
||||
@@ -187,7 +188,6 @@ pub fn replace_clip(s: &mut Session, t: TrackId, old: soundcraft_model::ClipId,
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn text_settings_round_trip() {
|
||||
let mut ed = EditState::default();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Options menu toggles and Setup › Session settings.
|
||||
use super::{CommandSpec, always, bad, bool_or, position_param, str_param};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{BitDepthSetting, EditState};
|
||||
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
|
||||
//! state right before saving and stores it on its insert (base64), from where the mixer restores
|
||||
//! it into new instances.
|
||||
use super::{CommandSpec, bad, has_tracks, track_param};
|
||||
use crate::{Engine, Result};
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{INSERT_SLOTS, b64};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -47,6 +48,15 @@ fn insert_state(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Some(Value::String(s)) => Some(b64::decode(s).ok_or_else(|| bad(CMD, "`state` is not valid base64 (or is too large)"))?),
|
||||
Some(_) => return Err(bad(CMD, "`state` must be a base64 string or null")),
|
||||
};
|
||||
let n = state.as_ref().map_or(0, Vec::len);
|
||||
// Plugins report changes that leave their state as it was: don't modify the session for those.
|
||||
let stored = e.session().track(t).and_then(|tr| match slot {
|
||||
Some(i) => tr.mixer.inserts.get(i).and_then(Option::as_ref),
|
||||
None => tr.instrument.as_ref(),
|
||||
});
|
||||
if stored.is_some_and(|ins| ins.state_bytes().as_deref() == state.as_deref().filter(|b| !b.is_empty())) {
|
||||
return Ok(json!({"bytes": n}));
|
||||
}
|
||||
let s = e.session_mut();
|
||||
let tr = s.track_mut(t).ok_or_else(|| bad(CMD, "no track"))?;
|
||||
let ins = match slot {
|
||||
@@ -54,7 +64,6 @@ fn insert_state(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
None => tr.instrument.as_mut(),
|
||||
}
|
||||
.ok_or_else(|| bad(CMD, "empty slot"))?;
|
||||
let n = state.as_ref().map_or(0, Vec::len);
|
||||
match state {
|
||||
Some(bytes) => ins.set_state_bytes(&bytes),
|
||||
None => ins.state = None,
|
||||
@@ -96,6 +105,19 @@ mod tests {
|
||||
assert_eq!(e.session().tracks[0].mixer.inserts[2].as_ref().unwrap().state, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storing_the_same_state_again_changes_nothing() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
let t = e.session().tracks[0].id.0;
|
||||
e.execute("mix.insert", &json!({"track": t, "plugin": "gain", "slot": 0})).unwrap();
|
||||
let blob = b64::encode(b"same");
|
||||
e.execute("mix.insert_state", &json!({"track": t, "slot": 0, "state": blob})).unwrap();
|
||||
let rev = e.revision;
|
||||
e.execute("mix.insert_state", &json!({"track": t, "slot": 0, "state": blob})).unwrap();
|
||||
assert_eq!(e.revision, rev);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_are_errors() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! 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 serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
//! 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
|
||||
//! 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::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{Insert, TrackKind};
|
||||
use crate::{Engine, EngineError, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{Insert, TrackId, TrackKind};
|
||||
use std::path::Path;
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
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}")))?;
|
||||
if buf.frames() == 0 || buf.num_channels() == 0 {
|
||||
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,
|
||||
//! 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::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, EngineError, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, SourceId, TrackId, TrackKind};
|
||||
use soundcraft_time::Timecode;
|
||||
use std::path::Path;
|
||||
@@ -503,7 +503,6 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn option_flags_toggle() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! 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::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{ChannelFormat, Mixer, OutputPath, Route, SEND_SLOTS, Session, SurroundPan, TrackKind};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -184,6 +184,7 @@ mod tests {
|
||||
assert!(e.session().track(t).unwrap().mixer.surround.is_none());
|
||||
// Sends.
|
||||
assert!(e.execute("mix.send_surround_pan", &json!({"track": t.0, "slot": "a", "x": 1.0})).is_err(), "empty slot");
|
||||
e.execute("mix.new_bus", &json!({"name": "FX"})).unwrap();
|
||||
e.execute("mix.send", &json!({"track": t.0, "slot": 0, "bus": "FX"})).unwrap();
|
||||
e.execute("mix.send_surround_pan", &json!({"track": t.0, "slot": "a", "x": 1.0, "y": -1.0})).unwrap();
|
||||
let snd = e.session().track(t).unwrap().mixer.sends[0].clone().unwrap();
|
||||
|
||||
@@ -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.
|
||||
use super::{CommandSpec, always, bool_or};
|
||||
|
||||
use super::*;
|
||||
use crate::{TransportRequest, cmd};
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{Clip, ClipContent, Session, SourceAudio};
|
||||
use soundcraft_time::SampleRate;
|
||||
use crate::{Engine, Result, TransportRequest, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{Clip, ClipContent, Session, SourceAudio, TrackId};
|
||||
use soundcraft_time::{Range, SampleRate, Samples};
|
||||
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
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! 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::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackKind};
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -19,6 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("track.group", "Group...", ["Track"], Some("Cmd+G"), "{name?, tracks?, edit?: true, mix?: true}", has_selection, group),
|
||||
cmd!("track.ungroup", "Delete Group", [], None, "{group: id|name}", always, delete_group),
|
||||
cmd!("track.group_toggle", "Toggle Group Active", [], None, "{group: id|name}", always, toggle_group),
|
||||
cmd!("track.group_modify", "Modify Group", [], None, "{group: id|name, name?, tracks?, edit?, mix?}", always, modify_group),
|
||||
cmd!(
|
||||
"track.duplicate",
|
||||
"Duplicate...",
|
||||
@@ -320,6 +323,55 @@ fn toggle_group(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"active": active}))
|
||||
}
|
||||
|
||||
/// Change an existing group's name, membership and Edit/Mix linking. Omitted fields keep their value.
|
||||
fn modify_group(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "track.group_modify";
|
||||
let g = find_group(e, p, CMD)?;
|
||||
// Absent `tracks` leaves membership alone; present, it replaces it. Only an array counts, so a
|
||||
// mistyped param can't silently swap the members for whatever happens to be selected.
|
||||
let members = match p.get("tracks") {
|
||||
None => None,
|
||||
Some(Value::Array(_)) => {
|
||||
let mut t = tracks_param(e, CMD, p)?;
|
||||
// A track listed twice would take every linked move twice over.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
t.retain(|id| seen.insert(*id));
|
||||
if t.is_empty() {
|
||||
return Err(bad(CMD, "a group needs at least one track"));
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
Some(_) => return Err(bad(CMD, "`tracks` must be an array of track ids or names")),
|
||||
};
|
||||
let name = match str_param(p, "name") {
|
||||
None => None,
|
||||
Some(n) => {
|
||||
let n = n.trim();
|
||||
if n.is_empty() {
|
||||
return Err(bad(CMD, "`name` must not be empty"));
|
||||
}
|
||||
// `find_group` resolves by name, so duplicates would make a group unaddressable.
|
||||
if e.session().groups.iter().any(|x| x.name == n && x.id != g) {
|
||||
return Err(bad(CMD, format!("a group named `{n}` already exists")));
|
||||
}
|
||||
let mut n = n.to_string();
|
||||
soundcraft_model::truncate_chars(&mut n, soundcraft_model::MAX_NAME_CHARS);
|
||||
Some(n)
|
||||
}
|
||||
};
|
||||
let s = e.session_mut();
|
||||
let Some(gr) = s.groups.iter_mut().find(|x| x.id == g) else { return Err(bad(CMD, "unknown `group`")) };
|
||||
if let Some(n) = name {
|
||||
gr.name = n;
|
||||
}
|
||||
if let Some(m) = members {
|
||||
gr.members = m;
|
||||
}
|
||||
gr.edit = bool_or(p, "edit", gr.edit);
|
||||
gr.mix = bool_or(p, "mix", gr.mix);
|
||||
Ok(json!({"group": g, "name": gr.name, "tracks": gr.members, "edit": gr.edit, "mix": gr.mix}))
|
||||
}
|
||||
|
||||
fn duplicate(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.duplicate", p)?;
|
||||
let (pl, auto, ins, snd) =
|
||||
@@ -493,6 +545,15 @@ fn folder_bus(s: &Session, folder: TrackId) -> Option<Route> {
|
||||
s.track(folder).map(|folder| folder.mixer.input.clone()).filter(|route| matches!(route, Route::Bus(_)))
|
||||
}
|
||||
|
||||
/// The summing bus owned by a routing folder.
|
||||
fn owned_folder_bus(s: &Session, folder: TrackId) -> Option<soundcraft_model::BusId> {
|
||||
let folder = s.track(folder).filter(|track| track.kind == TrackKind::Folder)?;
|
||||
match folder.mixer.input {
|
||||
Route::Bus(id) => Some(id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep only the selected roots so selecting a folder and one of its children preserves the subtree.
|
||||
fn root_tracks(s: &Session, tracks: Vec<TrackId>) -> Vec<TrackId> {
|
||||
tracks.iter().copied().filter(|track| !tracks.iter().any(|root| root != track && in_folder(s, *track, *root))).collect()
|
||||
@@ -577,6 +638,20 @@ fn change_width(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.delete", p)?;
|
||||
let s = e.session_mut();
|
||||
let removed_busses: Vec<_> = tracks
|
||||
.iter()
|
||||
.filter_map(|folder| {
|
||||
let bus = owned_folder_bus(s, *folder)?;
|
||||
let mut parent = s.track(*folder).and_then(|folder| folder.folder);
|
||||
for _ in 0..64 {
|
||||
match parent {
|
||||
Some(candidate) if tracks.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Some((bus, parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main)))
|
||||
})
|
||||
.collect();
|
||||
// Promote children of deleted folders and repair only routes that used the deleted
|
||||
// folder's own bus. Custom outputs remain untouched.
|
||||
let mut promoted = Vec::new();
|
||||
@@ -607,6 +682,22 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
for g in &mut s.groups {
|
||||
g.members.retain(|t| !tracks.contains(t));
|
||||
}
|
||||
for (bus, replacement) in removed_busses {
|
||||
s.busses.retain(|candidate| candidate.id != bus);
|
||||
for track in &mut s.tracks {
|
||||
if track.mixer.input == Route::Bus(bus) {
|
||||
track.mixer.input = Route::None;
|
||||
}
|
||||
if track.mixer.output == Route::Bus(bus) {
|
||||
track.mixer.output = replacement.clone();
|
||||
}
|
||||
for send in &mut track.mixer.sends {
|
||||
if send.as_ref().is_some_and(|send| send.target == Route::Bus(bus)) {
|
||||
*send = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"deleted": tracks.len()}))
|
||||
}
|
||||
|
||||
@@ -855,7 +946,6 @@ fn route(e: &mut Engine, p: &Value, input: bool) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn comp_from_alternate_playlist() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
@@ -872,6 +962,35 @@ mod tests {
|
||||
assert!(e.execute("track.playlist_promote", &json!({"track": kick, "playlist": 1, "start": 0, "end": 10})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_modify_changes_name_membership_and_linking() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let g = e.execute("track.group", &json!({"tracks": ["Kick", "Snare"], "name": "Drums"})).unwrap()["group"].as_u64().unwrap();
|
||||
// Every field at once, the way the dialog submits it.
|
||||
let r = e.execute("track.group_modify", &json!({"group": g, "name": "Rhythm", "tracks": ["Kick", "Bass"], "edit": false})).unwrap();
|
||||
assert_eq!(r["name"], "Rhythm");
|
||||
let gr = e.session().groups.iter().find(|x| x.id.0 == g).unwrap().clone();
|
||||
assert_eq!(gr.name, "Rhythm");
|
||||
assert_eq!(gr.members, vec![e.session().track_by_name("Kick").unwrap().id, e.session().track_by_name("Bass").unwrap().id]);
|
||||
assert!(!gr.edit);
|
||||
assert!(gr.mix, "an omitted field keeps its value");
|
||||
assert_eq!(gr.letter, 'a', "the letter is the group's stable id and never changes");
|
||||
// Omitting `tracks` leaves membership alone rather than falling back to the selection.
|
||||
e.execute("edit.select", &json!({"tracks": ["Pad"], "exact": true})).unwrap();
|
||||
e.execute("track.group_modify", &json!({"group": g, "name": "Rhythm Section"})).unwrap();
|
||||
assert_eq!(e.session().groups.iter().find(|x| x.id.0 == g).unwrap().members, gr.members);
|
||||
// Resolving the group by its new name keeps working, so names stay unique and non-empty.
|
||||
e.execute("track.group", &json!({"tracks": ["Pad"], "name": "Pads"})).unwrap();
|
||||
assert!(e.execute("track.group_modify", &json!({"group": "Pads", "name": "Rhythm Section"})).is_err());
|
||||
assert!(e.execute("track.group_modify", &json!({"group": g, "name": " "})).is_err());
|
||||
assert!(e.execute("track.group_modify", &json!({"group": g, "tracks": []})).is_err());
|
||||
assert!(e.execute("track.group_modify", &json!({"group": g, "tracks": "Kick"})).is_err(), "`tracks` must be an array");
|
||||
assert!(e.execute("track.group_modify", &json!({"group": g, "tracks": ["Kick", "Kick"]})).is_ok());
|
||||
assert_eq!(e.session().groups.iter().find(|x| x.id.0 == g).unwrap().members.len(), 1, "a repeated track is listed once");
|
||||
assert!(e.execute("track.group_modify", &json!({"group": 9999, "name": "x"})).is_err());
|
||||
assert_eq!(e.session().groups.iter().find(|x| x.id.0 == g).unwrap().name, "Rhythm Section", "a rejected call changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_routes_members_through_its_bus() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
@@ -920,6 +1039,14 @@ mod tests {
|
||||
|
||||
e.execute("track.move_to_folder", &json!({"tracks": ["Kick"], "folder": inner.0})).unwrap();
|
||||
e.execute("track.move_to_folder", &json!({"tracks": [inner.0], "folder": outer.0})).unwrap();
|
||||
let inner_bus = match e.session().track(inner).unwrap().mixer.input {
|
||||
Route::Bus(bus) => bus,
|
||||
_ => panic!("folder did not receive a bus"),
|
||||
};
|
||||
let outer_bus = match e.session().track(outer).unwrap().mixer.input {
|
||||
Route::Bus(bus) => bus,
|
||||
_ => panic!("folder did not receive a bus"),
|
||||
};
|
||||
assert!(e.execute("track.move_to_folder", &json!({"tracks": [outer.0], "folder": inner.0})).is_err());
|
||||
|
||||
let kick = e.session().track_by_name("Kick").unwrap();
|
||||
@@ -946,6 +1073,8 @@ mod tests {
|
||||
let kick = e.session().track_by_name("Kick").unwrap();
|
||||
assert_eq!(kick.folder, None);
|
||||
assert_eq!(kick.mixer.output, Route::Main);
|
||||
assert!(e.session().bus(inner_bus).is_none(), "the deleted folder's owned bus must be removed");
|
||||
assert!(e.session().bus(outer_bus).is_some(), "the surviving parent folder's bus must remain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! 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.
|
||||
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::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, EngineError, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_dsp::Category;
|
||||
use soundcraft_model::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipContent, Insert, Route, Session, Track, TrackId, TrackKind};
|
||||
use soundcraft_time::Samples;
|
||||
@@ -383,6 +383,10 @@ fn write_rtp(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
n.start = n.start.saturating_add(r.delay_ticks).max(0);
|
||||
notes += 1;
|
||||
}
|
||||
// Playback delays controller events too, but ignores those before tick 0 (hidden by a start trim).
|
||||
for c in sequence.ctrls.iter_mut().filter(|c| c.tick >= 0) {
|
||||
c.tick = c.tick.saturating_add(r.delay_ticks).max(0);
|
||||
}
|
||||
sequence.sort();
|
||||
}
|
||||
let pre = format!("rtp.{}.", t.0);
|
||||
@@ -543,7 +547,6 @@ fn toggle_recent(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bypass_by_category_and_range() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
@@ -635,6 +638,31 @@ mod tests {
|
||||
assert!(!e.session().edit.values.keys().any(|k| k.starts_with(&format!("rtp.{}.", keys.0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtp_delay_shifts_controller_events() {
|
||||
use soundcraft_midi::{CtrlEvent, CtrlKind};
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let keys = e.session().track_by_name("Keys").unwrap().id;
|
||||
let s = e.session_mut();
|
||||
if let Some(pl) = s.track_mut(keys).and_then(Track::playlist_mut)
|
||||
&& let ClipContent::Midi { sequence } = &mut pl.clips[0].content
|
||||
{
|
||||
sequence.ctrls.clear();
|
||||
sequence.ctrls.push(CtrlEvent { tick: 100, channel: 0, kind: CtrlKind::Cc(7), value: 90 });
|
||||
sequence.ctrls.push(CtrlEvent { tick: 200, channel: 0, kind: CtrlKind::PitchBend, value: 10 });
|
||||
sequence.ctrls.push(CtrlEvent { tick: -30, channel: 0, kind: CtrlKind::Cc(1), value: 5 });
|
||||
}
|
||||
s.edit.values.insert(track_key("rtp", keys, "delay"), 50.0);
|
||||
e.execute("edit.select", &json!({"tracks": [keys.0]})).unwrap();
|
||||
e.execute("track.write_midi_rtp", &json!({})).unwrap();
|
||||
let ClipContent::Midi { sequence } = &e.session().track(keys).unwrap().clips()[0].content else { panic!() };
|
||||
let tick_of = |k: CtrlKind| sequence.ctrls.iter().find(|c| c.kind == k).map(|c| c.tick);
|
||||
assert_eq!(tick_of(CtrlKind::Cc(7)), Some(150), "playback delayed the CC by 50 ticks; baked result must match");
|
||||
assert_eq!(tick_of(CtrlKind::PitchBend), Some(250));
|
||||
// Hidden by a start trim: playback ignores it, so baking leaves it alone.
|
||||
assert_eq!(tick_of(CtrlKind::Cc(1)), Some(-30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_and_vca_coalesce() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Transport commands. They update the session's stopped playhead and queue requests for the
|
||||
//! host's audio engine (which may be absent when headless).
|
||||
use super::{CommandSpec, always, bad, f64_or, position_param};
|
||||
|
||||
use super::*;
|
||||
use crate::{TransportRequest, cmd};
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, TransportRequest, cmd};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
//!
|
||||
//! 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.
|
||||
use super::{CommandSpec, always, bad, position_param, track_param};
|
||||
use soundcraft_time::MAX_POSITION;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{ClipContent, TrackKind};
|
||||
|
||||
/// `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).
|
||||
use super::{CommandSpec, always, bad, bool_or, f32_or, f64_or, i64_or, position_param, str_param};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_time::{GridValue, NoteValue, TimeFormat};
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_time::TimeFormat;
|
||||
use soundcraft_time::{GridValue, MAX_POSITION, NoteValue};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -160,7 +161,7 @@ pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use soundcraft_time::Samples;
|
||||
#[test]
|
||||
fn grid_values_parse() {
|
||||
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
|
||||
//! 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).
|
||||
use super::{CommandSpec, always, str_param};
|
||||
|
||||
use super::more_util::{exclusive_flag, set_flags, toggle_flag};
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// A view toggle: `{value?: bool}` sets it, otherwise it flips.
|
||||
macro_rules! flag {
|
||||
@@ -219,7 +219,6 @@ fn toggle_ruler(e: &mut Engine, p: &Value, id: &str) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flags_toggle_and_persist() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! 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
|
||||
//! `cmd::clap::plugin_info`.
|
||||
use super::{CommandSpec, always};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Zoom Toggle: zoom in on the edit selection, then back out to the view before.
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use super::{CommandSpec, always, f64_or};
|
||||
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> {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::TrackHeight::{Fit, Large, Medium};
|
||||
use super::*;
|
||||
use TrackHeight::{Fit, Large, Medium};
|
||||
use soundcraft_model::TrackId;
|
||||
use soundcraft_time::Samples;
|
||||
|
||||
fn engine(select: Value) -> Engine {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -149,9 +149,11 @@ pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec<Clip> {
|
||||
let old = part.start;
|
||||
part.trim_start_to(isect.start);
|
||||
rebase_midi_start(&mut part, old, t);
|
||||
part.fade_in = Fade::default();
|
||||
}
|
||||
if isect.end < part.end() {
|
||||
part.trim_end_to(isect.end);
|
||||
part.fade_out = Fade::default();
|
||||
}
|
||||
part.start -= range.start;
|
||||
out.push(part);
|
||||
@@ -405,7 +407,8 @@ pub fn trim_end_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
n
|
||||
}
|
||||
|
||||
/// Heal separations inside `range`: merge neighbouring audio clips that continue the same file.
|
||||
/// Heal separations inside `range`: merge neighbouring audio clips that continue the same file,
|
||||
/// either touching (a split) or with an empty gap that matches the gap in the source (a cut).
|
||||
pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
pl.sort();
|
||||
@@ -413,13 +416,16 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
let mut healed = 0;
|
||||
for c in pl.clips.drain(..) {
|
||||
if let Some(prev) = out.last_mut() {
|
||||
let contiguous = prev.end() == c.start && c.start >= range.start && c.start <= range.end;
|
||||
let contiguous = c.start >= prev.end() && prev.end() >= range.start && c.start <= range.end;
|
||||
let gap = if contiguous { c.start.saturating_sub(prev.end()) } else { 0 };
|
||||
let same = match (&prev.content, &c.content) {
|
||||
(ClipContent::Audio { source: a, offset: oa }, ClipContent::Audio { source: b, offset: ob }) => a == b && oa + prev.length == *ob,
|
||||
(ClipContent::Audio { source: a, offset: oa }, ClipContent::Audio { source: b, offset: ob }) => {
|
||||
a == b && oa.saturating_add(prev.length).saturating_add(gap) == *ob
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if contiguous && same && (prev.gain_db - c.gain_db).abs() < 1e-6 {
|
||||
prev.length += c.length;
|
||||
prev.length = c.end().saturating_sub(prev.start);
|
||||
prev.fade_out = c.fade_out;
|
||||
healed += 1;
|
||||
continue;
|
||||
@@ -835,6 +841,8 @@ pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Trimming can move a start past a later clip's start when one clip overlapped several.
|
||||
pl.sort();
|
||||
n
|
||||
}
|
||||
|
||||
@@ -868,6 +876,20 @@ mod tests {
|
||||
s.track(t).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_range_resets_fades_at_cut_edges() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
if let Some(c) = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()) {
|
||||
c.fade_in = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
|
||||
c.fade_out = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
|
||||
}
|
||||
let fades = |r: Range| -> Vec<(Samples, Samples)> { copy_range(&s, t, r).iter().map(|c| (c.fade_in.len, c.fade_out.len)).collect() };
|
||||
assert_eq!(fades(Range::new(2000, 3000)), vec![(0, 0)]);
|
||||
assert_eq!(fades(Range::new(0, 3000)), vec![(100, 0)]);
|
||||
assert_eq!(fades(Range::new(2000, 6000)), vec![(0, 100)]);
|
||||
assert_eq!(fades(Range::new(0, 6000)), vec![(100, 100)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_splits_and_heal_rejoins() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
@@ -878,6 +900,37 @@ mod tests {
|
||||
assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heal_rejoins_cut_separation() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
clear_range(&mut s, t, Range::new(2000, 3000), false);
|
||||
assert_eq!(heal(&mut s, t, Range::new(1500, 3500)), 1);
|
||||
assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]);
|
||||
// A selection that does not span the whole gap leaves it alone.
|
||||
let (mut s, t) = session_with_clip();
|
||||
clear_range(&mut s, t, Range::new(2000, 3000), false);
|
||||
assert_eq!(heal(&mut s, t, Range::new(2500, 3500)), 0);
|
||||
// Shuffled pieces no longer line up with their source and stay apart.
|
||||
let (mut s, t) = session_with_clip();
|
||||
clear_range(&mut s, t, Range::new(2000, 3000), true);
|
||||
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
|
||||
// Moved apart: the timeline gap no longer matches the source gap.
|
||||
let (mut s, t) = session_with_clip();
|
||||
clear_range(&mut s, t, Range::new(2000, 3000), false);
|
||||
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips[1].start = 3500;
|
||||
}
|
||||
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
|
||||
// Overlapping neighbours whose offsets line up are not a separation.
|
||||
let (mut s, t) = session_with_clip();
|
||||
assert_eq!(separate_at(&mut s, t, 3000), 1);
|
||||
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips[1].start = 2500;
|
||||
}
|
||||
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
|
||||
assert_eq!(clips(&s, t).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_slip_and_shuffle() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
@@ -1041,4 +1094,18 @@ mod tests {
|
||||
s.find_clip_mut(id).unwrap().time_locked = true;
|
||||
assert_eq!(move_clips(&mut s, &[id], 500, &[]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_fades_keeps_playlist_sorted_when_a_clip_overlaps_several() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).expect("track has a playlist");
|
||||
pl.clips.clear();
|
||||
pl.clips.push(Clip::audio(ClipId(901), "a", SourceId(500), 0, 0, 1000));
|
||||
pl.clips.push(Clip::audio(ClipId(902), "b", SourceId(500), 100, 100, 100));
|
||||
pl.clips.push(Clip::audio(ClipId(903), "c", SourceId(500), 150, 150, 750));
|
||||
delete_fades(&mut s, t, Range::new(0, 10_000));
|
||||
let got = clips(&s, t);
|
||||
assert!(got.windows(2).all(|w| w[0].0 <= w[1].0), "playlist not sorted: {got:?}");
|
||||
assert!(got.iter().all(|c| c.1 > 0), "empty clip: {got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
//! The folders automation may read and write.
|
||||
//!
|
||||
//! `soundcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the
|
||||
//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in
|
||||
//! force, every path a command reads goes through [`check_read`] and every path it writes through [`check_write`]:
|
||||
//! files an agent's commands reach; each flag may be given more than once (#874). [`AutomationRoots`]
|
||||
//! holds the folders of each kind. While they are in force, every path a command reads goes through
|
||||
//! [`check_read`] and every path it writes through [`check_write`]:
|
||||
//!
|
||||
//! - The path is made absolute against the working directory, as the file system reads it. Its
|
||||
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
|
||||
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
|
||||
//! is a link that leads nowhere).
|
||||
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
|
||||
//! `/work`), without regard to case on Windows.
|
||||
//! - The result must lie inside one of the roots of its kind, compared by whole components
|
||||
//! (`/work2` isn't inside `/work`), without regard to case on Windows.
|
||||
//! - Read and write access are separate: a root given alone grants only its own kind of access.
|
||||
//!
|
||||
//! Roots are in force on a thread inside [`confine`], and in the whole process after
|
||||
@@ -32,11 +33,11 @@ pub enum Access {
|
||||
Write,
|
||||
}
|
||||
|
||||
/// The folders automation may read and write; a root left out grants no access of its kind.
|
||||
/// The folders automation may read and write; a kind with no root grants no access of its kind.
|
||||
#[derive(Debug)]
|
||||
pub struct AutomationRoots {
|
||||
read: Option<Root>,
|
||||
write: Option<Root>,
|
||||
read: Vec<Root>,
|
||||
write: Vec<Root>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -62,48 +63,61 @@ impl Root {
|
||||
}
|
||||
|
||||
impl AutomationRoots {
|
||||
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
|
||||
/// (nothing is confined then).
|
||||
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
|
||||
if read.is_none() && write.is_none() {
|
||||
/// The roots `read` and `write`, which must be existing folders (one given twice counts once);
|
||||
/// `None` when neither kind has any (nothing is confined then).
|
||||
pub fn new(read: &[&Path], write: &[&Path]) -> Result<Option<Arc<Self>>, String> {
|
||||
if read.is_empty() && write.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
|
||||
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
|
||||
Ok(Some(Arc::new(Self { read, write })))
|
||||
let open = |paths: &[&Path], access: Access| -> Result<Vec<Root>, String> {
|
||||
let mut roots: Vec<Root> = Vec::with_capacity(paths.len());
|
||||
for path in paths {
|
||||
let root = Root::open(path, access)?;
|
||||
if !roots.iter().any(|r| r.resolved == root.resolved) {
|
||||
roots.push(root);
|
||||
}
|
||||
}
|
||||
Ok(roots)
|
||||
};
|
||||
Ok(Some(Arc::new(Self { read: open(read, Access::Read)?, write: open(write, Access::Write)? })))
|
||||
}
|
||||
|
||||
/// The read root, absolute with its links followed.
|
||||
pub fn read_root(&self) -> Option<&Path> {
|
||||
self.read.as_ref().map(|r| r.shown.as_path())
|
||||
/// The roots for people: `read A, B; write C` (`none` for a kind without any).
|
||||
pub fn describe(&self) -> String {
|
||||
let list = |roots: &[Root]| {
|
||||
if roots.is_empty() {
|
||||
return "none".to_string();
|
||||
}
|
||||
roots.iter().map(|r| r.shown.display().to_string()).collect::<Vec<_>>().join(", ")
|
||||
};
|
||||
format!("read {}; write {}", list(&self.read), list(&self.write))
|
||||
}
|
||||
|
||||
/// The write root, absolute with its links followed.
|
||||
pub fn write_root(&self) -> Option<&Path> {
|
||||
self.write.as_ref().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
||||
/// May the file or folder at `path` be read (or written)? It must lie inside one of the roots
|
||||
/// of that kind. `Err` says why not.
|
||||
pub fn check(&self, path: impl AsRef<Path>, access: Access) -> Result<(), String> {
|
||||
let path = path.as_ref();
|
||||
let shown_path = path.display();
|
||||
let root = match access {
|
||||
Access::Read => self.read.as_ref(),
|
||||
Access::Write => self.write.as_ref(),
|
||||
let roots = match access {
|
||||
Access::Read => &self.read,
|
||||
Access::Write => &self.write,
|
||||
};
|
||||
let Some(Root { resolved: folder, shown }) = root else {
|
||||
if roots.is_empty() {
|
||||
return match access {
|
||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {shown_path}")),
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown_path}")),
|
||||
};
|
||||
};
|
||||
if inside(&resolve(path)?, folder) {
|
||||
}
|
||||
let resolved = resolve(path)?;
|
||||
if roots.iter().any(|r| inside(&resolved, &r.resolved)) {
|
||||
return Ok(());
|
||||
}
|
||||
let root = shown.display();
|
||||
match access {
|
||||
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {shown_path}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {shown_path}")),
|
||||
let shown: Vec<String> = roots.iter().map(|r| r.shown.display().to_string()).collect();
|
||||
match (shown.as_slice(), access) {
|
||||
([root], Access::Read) => Err(format!("automation path rejected: outside the read root {root}: {shown_path}")),
|
||||
([root], Access::Write) => Err(format!("automation path rejected: outside the write root {root}: {shown_path}")),
|
||||
(_, Access::Read) => Err(format!("automation path rejected: outside the read roots {}: {shown_path}", shown.join(", "))),
|
||||
(_, Access::Write) => Err(format!("automation path rejected: outside the write roots {}: {shown_path}", shown.join(", "))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,7 +294,7 @@ mod tests {
|
||||
}
|
||||
std::fs::write(base.join("in/a.scraft"), "x").unwrap();
|
||||
std::fs::write(base.join("outside/secret.scraft"), "x").unwrap();
|
||||
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
|
||||
let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap().unwrap();
|
||||
(base, roots)
|
||||
}
|
||||
|
||||
@@ -340,18 +354,18 @@ mod tests {
|
||||
#[test]
|
||||
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
|
||||
let (base, _) = folders("missing");
|
||||
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
|
||||
let e = AutomationRoots::new(&[&base.join("nope")], &[]).unwrap_err();
|
||||
assert!(e.starts_with("cannot open the automation read root"), "{e}");
|
||||
let e = AutomationRoots::new(None, Some(&base.join("in/a.scraft"))).unwrap_err();
|
||||
let e = AutomationRoots::new(&[], &[&base.join("in/a.scraft")]).unwrap_err();
|
||||
assert!(
|
||||
e.starts_with("cannot open the automation write root")
|
||||
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
|
||||
"{e}"
|
||||
);
|
||||
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(None, None).unwrap().is_none());
|
||||
assert!(AutomationRoots::new(&[Path::new("")], &[]).unwrap_err().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(&[], &[]).unwrap().is_none());
|
||||
// One root alone grants only its own access.
|
||||
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
|
||||
let read_only = AutomationRoots::new(&[&base.join("in")], &[]).unwrap().unwrap();
|
||||
let e = read_only.check(s(base.join("in/x.scraft")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("write authority is absent"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
@@ -388,11 +402,66 @@ mod tests {
|
||||
assert_eq!(roots.check(s(base.join("in/alias/x.scraft")), Access::Read), Ok(()));
|
||||
// A root given through a link is the folder it leads to.
|
||||
assert!(made(&base.join("in"), &base.join("in-link")));
|
||||
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
|
||||
let linked = AutomationRoots::new(&[&base.join("in-link")], &[]).unwrap().unwrap();
|
||||
assert_eq!(linked.check(s(base.join("in/a.scraft")), Access::Read), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// #874: several roots of a kind: a path inside any of them passes, one inside none is refused
|
||||
/// naming them all, and read and write stay separate.
|
||||
#[test]
|
||||
fn several_roots_of_a_kind_each_grant_their_access() {
|
||||
let (base, _) = folders("several");
|
||||
std::fs::create_dir_all(base.join("out2")).unwrap();
|
||||
std::fs::write(base.join("in2/b.scraft"), "x").unwrap();
|
||||
std::fs::write(base.join("parent.scraft"), "x").unwrap();
|
||||
let roots = AutomationRoots::new(&[&base.join("in"), &base.join("in2")], &[&base.join("out"), &base.join("out2")]).unwrap().unwrap();
|
||||
assert_eq!(roots.check(s(base.join("in/a.scraft")), Access::Read), Ok(()));
|
||||
assert_eq!(roots.check(s(base.join("in2/b.scraft")), Access::Read), Ok(()));
|
||||
let e = roots.check(s(base.join("outside/secret.scraft")), Access::Read).unwrap_err();
|
||||
assert!(e.starts_with("automation path rejected: outside the read roots ") && e.contains(", "), "{e}");
|
||||
// Two sibling roots don't make their parent a root.
|
||||
assert!(roots.check(s(base.join("parent.scraft")), Access::Read).unwrap_err().contains("outside the read roots"));
|
||||
assert!(roots.check(s(base.clone()), Access::Read).is_err());
|
||||
assert_eq!(roots.check(s(base.join("out/x.wav")), Access::Write), Ok(()));
|
||||
assert_eq!(roots.check(s(base.join("out2/new/x.wav")), Access::Write), Ok(()));
|
||||
assert!(roots.check(s(base.join("outside/x.wav")), Access::Write).unwrap_err().contains("outside the write roots"));
|
||||
assert!(roots.check(s(base.join("x.wav")), Access::Write).is_err());
|
||||
assert!(roots.check(s(base.join("in2/b.scraft")), Access::Write).is_err());
|
||||
assert!(roots.check(s(base.join("out2/x.wav")), Access::Read).is_err());
|
||||
// `..` out of one root is outside, also when it lands next to another root.
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let up = format!("{}{sep}..{sep}outside{sep}secret.scraft", s(base.join("in2")));
|
||||
assert!(roots.check(up, Access::Read).is_err());
|
||||
// `..` from one root into another stays inside.
|
||||
let across = format!("{}{sep}..{sep}in{sep}a.scraft", s(base.join("in2")));
|
||||
assert_eq!(roots.check(across, Access::Read), Ok(()));
|
||||
// A folder given twice (here once through `..`) counts once.
|
||||
let twice = AutomationRoots::new(&[&base.join("in"), &base.join("in/sub/..")], &[]).unwrap().unwrap();
|
||||
assert!(!twice.describe().contains(", ") && twice.describe().ends_with("; write none"), "{}", twice.describe());
|
||||
// A missing folder among several is an error.
|
||||
let e = AutomationRoots::new(&[&base.join("in"), &base.join("nope")], &[]).unwrap_err();
|
||||
assert!(e.starts_with("cannot open the automation read root"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A link inside one root that leads into another root of the same kind stays inside; one that
|
||||
/// leads out of all of them is outside.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn links_between_several_roots() {
|
||||
let (base, _) = folders("several-links");
|
||||
std::fs::write(base.join("in2/b.scraft"), "x").unwrap();
|
||||
std::os::unix::fs::symlink(base.join("in2/b.scraft"), base.join("in/to-in2.scraft")).unwrap();
|
||||
std::os::unix::fs::symlink(base.join("outside/secret.scraft"), base.join("in2/leak.scraft")).unwrap();
|
||||
std::os::unix::fs::symlink(&base, base.join("in2/up")).unwrap();
|
||||
let roots = AutomationRoots::new(&[&base.join("in"), &base.join("in2")], &[]).unwrap().unwrap();
|
||||
assert_eq!(roots.check(s(base.join("in/to-in2.scraft")), Access::Read), Ok(()));
|
||||
assert!(roots.check(s(base.join("in2/leak.scraft")), Access::Read).unwrap_err().contains("outside the read roots"));
|
||||
assert!(roots.check(s(base.join("in2/up/outside/secret.scraft")), Access::Read).unwrap_err().contains("outside the read roots"));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_ignores_case_and_refuses_devices() {
|
||||
|
||||
+264
-47
@@ -5,6 +5,7 @@ use serde_json::{Value, json};
|
||||
use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat};
|
||||
use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
|
||||
use soundcraft_time::{Range, Samples};
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -68,19 +69,16 @@ pub fn file_found(path: impl AsRef<Path>) -> bool {
|
||||
/// 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> {
|
||||
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}"))?;
|
||||
// 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)
|
||||
craft_docpath::resolve_absolute(stored, Some(&session)).map_err(|why| format!("not opened: {why}"))
|
||||
}
|
||||
|
||||
/// Create `dir` and its parents, inside the automation write root when there is one.
|
||||
pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> {
|
||||
let dir = dir.as_ref();
|
||||
crate::file_access::check_write(dir).map_err(EngineError::Io)?;
|
||||
// The empty folder of a bare file name ("t.scraft") is the working directory, which the root
|
||||
// check would refuse as an empty path.
|
||||
let checked = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
|
||||
crate::file_access::check_write(checked).map_err(EngineError::Io)?;
|
||||
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))
|
||||
}
|
||||
|
||||
@@ -120,7 +118,7 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio
|
||||
buf.sample_rate = sr;
|
||||
}
|
||||
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 {
|
||||
Some(p) => p.to_string(),
|
||||
None => unique_audio_rel(s, &stem),
|
||||
@@ -151,7 +149,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.
|
||||
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 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")))?;
|
||||
@@ -170,7 +168,7 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option
|
||||
let track = match target {
|
||||
Some(t) => t,
|
||||
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))
|
||||
}
|
||||
};
|
||||
@@ -194,8 +192,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
|
||||
/// 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> {
|
||||
let stem = Path::new(path).file_stem().and_then(|n| n.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 stem = Path::new(path).file_stem().and_then(OsStr::to_str).unwrap_or("Video").to_string();
|
||||
let ext = Path::new(path).extension().and_then(OsStr::to_str).unwrap_or("mp4").to_ascii_lowercase();
|
||||
let bytes: Arc<[u8]> = bytes.into();
|
||||
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() });
|
||||
@@ -216,7 +214,7 @@ pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec<u8>, at: Sample
|
||||
let vid = SourceId(s.alloc());
|
||||
s.videos.push(soundcraft_model::VideoSource {
|
||||
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,
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
@@ -309,17 +307,19 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b
|
||||
let base_tick = s.tempo.samples_to_ticks(at, s.sample_rate);
|
||||
let mut out = Vec::new();
|
||||
for (i, tr) in smf.tracks.iter().enumerate() {
|
||||
if tr.sequence.notes.is_empty() {
|
||||
if tr.sequence.notes.is_empty() && tr.sequence.ctrls.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut name = if tr.name.trim().is_empty() { format!("MIDI {}", i + 1) } else { tr.name.clone() };
|
||||
soundcraft_model::truncate_chars(&mut name, soundcraft_model::MAX_NAME_CHARS);
|
||||
let id = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some(&name));
|
||||
let drums = tr.sequence.notes.iter().all(|n| n.channel == 9);
|
||||
let drums = !tr.sequence.notes.is_empty() && tr.sequence.notes.iter().all(|n| n.channel == 9);
|
||||
if let Some(t) = s.track_mut(id) {
|
||||
t.instrument = Some(soundcraft_model::Insert::new(if drums { "drum_synth" } else { "subtractive_synth" }));
|
||||
}
|
||||
let end_tick = base_tick.saturating_add(tr.sequence.end_tick().max(1));
|
||||
// A controller on the clip's last tick would not play, so the clip runs one tick past the last one.
|
||||
let past_ctrls = tr.sequence.ctrls.iter().map(|c| c.tick.saturating_add(1)).max().unwrap_or(0);
|
||||
let end_tick = base_tick.saturating_add(tr.sequence.end_tick().max(past_ctrls).max(1));
|
||||
let len = s.tempo.tick_to_samples(end_tick, s.sample_rate).saturating_sub(at);
|
||||
if len > s.sample_rate.samples(86_400.0) {
|
||||
return Err(EngineError::Io(format!("MIDI track {}: longer than 24 hours", i + 1)));
|
||||
@@ -996,7 +996,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
sync_dir(&audio_dir);
|
||||
}
|
||||
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();
|
||||
}
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
@@ -1041,10 +1041,22 @@ fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
f.sync_all()
|
||||
}
|
||||
|
||||
/// Copies `from` to `to`, which must not exist yet; an existing entry, even a link, is never followed.
|
||||
fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
let mut src = std::fs::File::open(from)?;
|
||||
let mut dst = std::fs::OpenOptions::new().write(true).create_new(true).open(to)?;
|
||||
if let Err(err) = std::io::copy(&mut src, &mut dst) {
|
||||
drop(dst);
|
||||
let _ = std::fs::remove_file(to);
|
||||
return Err(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy the session file about to be replaced to `Backups/<name> backup <n>.<ext>`, keeping the
|
||||
/// newest ten.
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
@@ -1061,9 +1073,16 @@ fn back_up(file: &Path) {
|
||||
.flatten()
|
||||
.filter_map(|f| f.file_name().to_str()?.strip_prefix(&prefix)?.strip_suffix(&suffix)?.parse().ok())
|
||||
.collect();
|
||||
let next = numbers.iter().max().map_or(1, |n| n.saturating_add(1));
|
||||
let copied = std::fs::create_dir_all(&backups).and_then(|()| std::fs::copy(file, backups.join(format!("{prefix}{next}{suffix}"))));
|
||||
if let Err(err) = copied {
|
||||
let Some(next) = numbers.iter().max().map_or(Some(1), |n| n.checked_add(1)) else {
|
||||
log::warn!("no backup of {}: backup numbers are exhausted", file.display());
|
||||
return;
|
||||
};
|
||||
let dest = backups.join(format!("{prefix}{next}{suffix}"));
|
||||
if let Err(err) = crate::file_access::check_write(&dest) {
|
||||
log::warn!("no backup of {}: {err}", file.display());
|
||||
return;
|
||||
}
|
||||
if let Err(err) = std::fs::create_dir_all(&backups).and_then(|()| copy_new(file, &dest)) {
|
||||
log::warn!("no backup of {}: {err}", file.display());
|
||||
return;
|
||||
}
|
||||
@@ -1088,7 +1107,7 @@ pub fn replaces(e: &Engine, command: &str, file: &Path) -> bool {
|
||||
"session.save" | "session.save_as" | "session.save_copy" => {
|
||||
file.exists() && !e.path.as_deref().is_some_and(|own| same_path(Path::new(own), file))
|
||||
}
|
||||
"session.save_template" | "file.export_selected_tracks_as_session" => file.exists(),
|
||||
"session.save_template" | "file.export_selected_tracks_as_session" | "file.export_session_text" => file.exists(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -1180,7 +1199,7 @@ pub(crate) fn decode_file(files: &mut DecodedFiles, p: &Path, rate: u32) -> std:
|
||||
.entry(file_key(p))
|
||||
.or_insert_with(|| {
|
||||
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()
|
||||
}
|
||||
@@ -1212,7 +1231,7 @@ pub fn set_deliver(h: DeliverHook) {
|
||||
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
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);
|
||||
}
|
||||
crate::file_access::check_write(path).map_err(EngineError::Io)?;
|
||||
@@ -1257,7 +1276,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`]).
|
||||
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();
|
||||
if let Some(stem) = stem {
|
||||
s.name = stem;
|
||||
@@ -1286,18 +1305,36 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
|
||||
};
|
||||
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
|
||||
let mut missing = Vec::new();
|
||||
// Several sources may share one file (native sessions do): decode each path once and share the result.
|
||||
let mut decoded: std::collections::HashMap<String, Option<Arc<SourceAudio>>> = std::collections::HashMap::new();
|
||||
for src in &s.sources {
|
||||
match files.remove(&src.path) {
|
||||
Some(audio) => match decode_source(&audio, Path::new(&src.path).extension().and_then(|x| x.to_str()), s.sample_rate.hz()) {
|
||||
Ok(audio) => s.pool.insert(src.id, audio),
|
||||
Err(err) => missing.push(format!("{}: {err}", src.path)),
|
||||
},
|
||||
None => missing.push(src.path.clone()),
|
||||
if let Some(audio) = decoded.get(&src.path) {
|
||||
if let Some(audio) = audio {
|
||||
s.pool.insert(src.id, Arc::clone(audio));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let audio = match files.remove(&src.path) {
|
||||
Some(bytes) => match decode_source(&bytes, Path::new(&src.path).extension().and_then(OsStr::to_str), s.sample_rate.hz()) {
|
||||
Ok(audio) => Some(audio),
|
||||
Err(err) => {
|
||||
missing.push(format!("{}: {err}", src.path));
|
||||
None
|
||||
}
|
||||
},
|
||||
None => {
|
||||
missing.push(src.path.clone());
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(audio) = &audio {
|
||||
s.pool.insert(src.id, Arc::clone(audio));
|
||||
}
|
||||
decoded.insert(src.path.clone(), audio);
|
||||
}
|
||||
// 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()));
|
||||
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.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem)));
|
||||
for m in &missing {
|
||||
@@ -1329,7 +1366,7 @@ fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap<String
|
||||
let mut data = Vec::new();
|
||||
// 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}")))?;
|
||||
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() {
|
||||
text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?);
|
||||
} else {
|
||||
@@ -1388,18 +1425,25 @@ pub struct Take {
|
||||
|
||||
/// Place recorded take files as `plan` says, as one undo step ("Record").
|
||||
pub fn place_recording(e: &mut Engine, plan: &Recording, takes: Vec<(TrackId, PathBuf)>) -> Vec<soundcraft_model::ClipId> {
|
||||
let ids = add_recording(e, plan.start, takes, plan.pass);
|
||||
if let Some(p) = plan.punch {
|
||||
let clips: Vec<u64> = ids.iter().map(|c| c.0).collect();
|
||||
let _ = e.execute_merged("edit.trim_to_fill_selection", &json!({"clips": clips, "start": p.start, "end": p.end}), "record");
|
||||
}
|
||||
ids
|
||||
add_recording_in(e, plan.start, takes, plan.pass, plan.punch)
|
||||
}
|
||||
|
||||
/// Turn recorded take files into clips at `start`, as one undo step ("Record"); each file is the
|
||||
/// source of its clips. With `pass` (loop recording), a take becomes one clip per pass (at most
|
||||
/// 1000), each pass after the first on a new playlist.
|
||||
pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBuf)>, pass: Option<Samples>) -> Vec<soundcraft_model::ClipId> {
|
||||
add_recording_in(e, start, takes, pass, None)
|
||||
}
|
||||
|
||||
/// [`add_recording`] limited to the `punch` range: each clip is cut to it before it's placed, so
|
||||
/// the material around the punch isn't overwritten.
|
||||
fn add_recording_in(
|
||||
e: &mut Engine,
|
||||
start: Samples,
|
||||
takes: Vec<(TrackId, PathBuf)>,
|
||||
pass: Option<Samples>,
|
||||
punch: Option<Range>,
|
||||
) -> Vec<soundcraft_model::ClipId> {
|
||||
let before = e.session_arc();
|
||||
let mut out = Vec::new();
|
||||
for (tid, path) in takes {
|
||||
@@ -1410,18 +1454,40 @@ pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBu
|
||||
if s.track(tid).is_none() {
|
||||
continue;
|
||||
}
|
||||
let resampled = buf.sample_rate != s.sample_rate.hz();
|
||||
let name = path.file_stem().and_then(|x| x.to_str()).unwrap_or("Take");
|
||||
let src = add_source(s, name, buf, Some(&path.to_string_lossy()), FileFormat::Wav);
|
||||
let Some(x) = s.sources.last_mut() else { continue };
|
||||
x.unsaved = resampled;
|
||||
let len = i64::try_from(x.frames).unwrap_or(0);
|
||||
let hz = s.sample_rate.hz();
|
||||
let resampled = buf.sample_rate != hz;
|
||||
let mut buf = buf;
|
||||
if resampled && buf.sample_rate > 0 {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, hz);
|
||||
buf.sample_rate = hz;
|
||||
}
|
||||
let name = path.file_stem().and_then(OsStr::to_str).unwrap_or("Take");
|
||||
let len = i64::try_from(buf.frames()).unwrap_or(0);
|
||||
let pass = pass.unwrap_or(len).max(1);
|
||||
// Work out what each pass keeps first, so a take nothing is placed from isn't registered.
|
||||
let mut kept_passes = Vec::new();
|
||||
for offset in (0..len).step_by(usize::try_from(pass).unwrap_or(usize::MAX)).take(1_000) {
|
||||
let n = pass.min(len - offset);
|
||||
if offset > 0 && n < pass / 8 {
|
||||
break;
|
||||
}
|
||||
let (clip_start, cut, clip_len) = match punch {
|
||||
Some(p) => {
|
||||
let Some(kept) = Range::new(start, start.saturating_add(n)).intersect(&p) else { continue };
|
||||
(kept.start, kept.start.saturating_sub(start), kept.len())
|
||||
}
|
||||
None => (start, 0, n),
|
||||
};
|
||||
kept_passes.push((offset, clip_start, cut, clip_len));
|
||||
}
|
||||
if kept_passes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let src = add_source(s, name, buf, Some(&path.to_string_lossy()), FileFormat::Wav);
|
||||
if let Some(x) = s.sources.last_mut() {
|
||||
x.unsaved = resampled;
|
||||
}
|
||||
for (offset, clip_start, cut, clip_len) in kept_passes {
|
||||
if offset > 0
|
||||
&& let Some(t) = s.track_mut(tid)
|
||||
{
|
||||
@@ -1429,7 +1495,7 @@ pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBu
|
||||
t.active_playlist = t.playlists.len() - 1;
|
||||
}
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(s, tid, Clip::audio(cid, name, src, offset, start, n));
|
||||
crate::edit::place_clip(s, tid, Clip::audio(cid, name, src, offset.saturating_add(cut), clip_start, clip_len));
|
||||
out.push(cid);
|
||||
}
|
||||
}
|
||||
@@ -1511,6 +1577,63 @@ mod record_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn punching_in_keeps_the_material_around_the_punch_range() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-punch-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut e = Engine::default();
|
||||
let vox = armed(&mut e, "Vox", "mono", "In 1");
|
||||
let base = dir.join("Audio Files/Base.wav");
|
||||
let take = dir.join("Audio Files/Take.wav");
|
||||
write_take(&base, 3_000);
|
||||
write_take(&take, 1_000);
|
||||
add_recording(&mut e, 0, vec![(vox, base)], None);
|
||||
let plan = Recording {
|
||||
start: 1_000,
|
||||
pass: None,
|
||||
punch: Some(Range::new(1_400, 1_600)),
|
||||
rate: 48_000,
|
||||
takes: vec![Take { track: vox, path: take.clone(), channels: 1 }],
|
||||
};
|
||||
place_recording(&mut e, &plan, vec![(vox, take)]);
|
||||
let clips: Vec<_> = e.session().track(vox).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect();
|
||||
assert_eq!(clips, vec![(0, 1_400, 0), (1_400, 200, 400), (1_600, 1_400, 1_600)]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_punch_that_misses_the_take_leaves_the_session_alone() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-punch-miss-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut e = Engine::default();
|
||||
let vox = armed(&mut e, "Vox", "mono", "In 1");
|
||||
let take = dir.join("Audio Files/Take.wav");
|
||||
write_take(&take, 1_000);
|
||||
let shared = add_source(
|
||||
e.session_mut(),
|
||||
"Take",
|
||||
AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 100]] },
|
||||
Some(&take.to_string_lossy()),
|
||||
FileFormat::Wav,
|
||||
);
|
||||
let sources = e.session().sources.len();
|
||||
let pool = e.session().pool.len();
|
||||
let plan = Recording {
|
||||
start: 1_000,
|
||||
pass: None,
|
||||
punch: Some(Range::new(5_000, 6_000)),
|
||||
rate: 48_000,
|
||||
takes: vec![Take { track: vox, path: take.clone(), channels: 1 }],
|
||||
};
|
||||
let placed = place_recording(&mut e, &plan, vec![(vox, take)]);
|
||||
assert!(placed.is_empty());
|
||||
assert_eq!(e.session().sources.len(), sources);
|
||||
assert_eq!(e.session().pool.len(), pool);
|
||||
assert!(e.session().sources.iter().any(|x| x.id == shared));
|
||||
assert!(e.session().pool.contains(shared));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_take_shows_the_pass_under_way_within_the_punch_while_it_records() {
|
||||
assert_eq!(live_span(1_000, None, None, 0), None);
|
||||
@@ -1686,6 +1809,43 @@ mod save_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_never_overwrites_an_existing_entry_when_numbers_run_out() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-backups-full-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("Backups")).unwrap();
|
||||
let path = dir.join("Song.scraft");
|
||||
std::fs::write(&path, b"session").unwrap();
|
||||
let last = dir.join(format!("Backups/Song backup {}.scraft", u32::MAX));
|
||||
std::fs::write(&last, b"sentinel").unwrap();
|
||||
back_up(&path);
|
||||
assert_eq!(std::fs::read(&last).unwrap(), b"sentinel");
|
||||
let dest = dir.join("Backups/Song backup 1.scraft");
|
||||
std::fs::write(&dest, b"taken").unwrap();
|
||||
assert!(copy_new(&path, &dest).is_err());
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"taken");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn backup_never_writes_through_a_link_to_an_outside_file() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-backups-link-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("Project/Backups")).unwrap();
|
||||
let outside = dir.join("outside.txt");
|
||||
std::fs::write(&outside, b"sentinel").unwrap();
|
||||
let path = dir.join("Project/Song.scraft");
|
||||
std::fs::write(&path, b"session").unwrap();
|
||||
std::os::unix::fs::symlink(&outside, dir.join(format!("Project/Backups/Song backup {}.scraft", u32::MAX))).unwrap();
|
||||
back_up(&path);
|
||||
let link = dir.join("Project/Backups/Song backup 1.scraft");
|
||||
std::os::unix::fs::symlink(&outside, &link).unwrap();
|
||||
assert!(copy_new(&path, &link).is_err());
|
||||
assert_eq!(std::fs::read(&outside).unwrap(), b"sentinel");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_gives_sources_that_share_a_path_their_own_files() {
|
||||
let mut s = Session::default();
|
||||
@@ -1769,6 +1929,16 @@ mod open_tests {
|
||||
assert_eq!(std::fs::read_dir(dir.join("copy/Audio Files")).unwrap().count(), 1);
|
||||
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]
|
||||
fn stored_media_paths_are_followed_only_on_this_computer() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-stored-paths-{}", std::process::id()));
|
||||
@@ -1840,6 +2010,28 @@ mod name_tests {
|
||||
assert_eq!((track.name.len(), track.clips()[0].name.len()), (soundcraft_model::MAX_NAME_CHARS, soundcraft_model::MAX_NAME_CHARS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_controller_only_midi_track_is_imported_with_its_events() {
|
||||
let ctrls = vec![
|
||||
soundcraft_midi::CtrlEvent { tick: 0, channel: 0, kind: soundcraft_midi::CtrlKind::Cc(7), value: 100 },
|
||||
soundcraft_midi::CtrlEvent { tick: 480, channel: 0, kind: soundcraft_midi::CtrlKind::Cc(7), value: 64 },
|
||||
soundcraft_midi::CtrlEvent { tick: 960, channel: 0, kind: soundcraft_midi::CtrlKind::Cc(7), value: 20 },
|
||||
];
|
||||
let sequence = soundcraft_midi::Sequence { ctrls: ctrls.clone(), ..Default::default() };
|
||||
let smf = soundcraft_midi::Smf { tracks: vec![soundcraft_midi::SmfTrack { name: "Vol".into(), sequence }], ..Default::default() };
|
||||
let mut e = Engine::default();
|
||||
let ids = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, false).unwrap();
|
||||
assert_eq!(ids.len(), 1);
|
||||
let track = e.session().track(ids[0]).unwrap();
|
||||
assert_eq!(track.instrument.as_ref().map(|i| i.plugin.as_str()), Some("subtractive_synth"));
|
||||
// The clip must reach past the last event, or playback skips it.
|
||||
assert!(track.clips()[0].end() > e.session().tempo.tick_to_samples(960, e.session().sample_rate));
|
||||
match &track.clips()[0].content {
|
||||
ClipContent::Midi { sequence } => assert_eq!(sequence.ctrls, ctrls),
|
||||
_ => panic!("expected a MIDI clip"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midi_longer_than_a_day_is_refused() {
|
||||
let held = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: (1 << 28) - 1 };
|
||||
@@ -1978,6 +2170,31 @@ mod bundle_tests {
|
||||
assert_eq!(r.path.as_deref(), Some("Midnight Groove.scraftz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_sharing_one_file_all_load_from_a_bundle() {
|
||||
use std::io::Write as _;
|
||||
let e = crate::demo::demo_engine();
|
||||
let (bytes, _) = session_bundle(e.session()).unwrap();
|
||||
let (text, files) = read_bundle(&bytes).unwrap();
|
||||
let mut s = Session::from_json(&text).unwrap();
|
||||
s.sources[1].path = s.sources[0].path.clone();
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for path in s.sources.iter().map(|x| &x.path).collect::<std::collections::BTreeSet<_>>() {
|
||||
w.start_file(path.as_str(), opts).unwrap();
|
||||
w.write_all(&files[path]).unwrap();
|
||||
}
|
||||
w.start_file("Song.scraft", opts).unwrap();
|
||||
w.write_all(s.to_json().unwrap().as_bytes()).unwrap();
|
||||
let bundle = w.finish().unwrap().into_inner();
|
||||
let mut r = Engine::default();
|
||||
let missing = open_session_bytes(&mut r, "Song.scraftz", &bundle).unwrap();
|
||||
assert!(missing.is_empty(), "{missing:?}");
|
||||
for src in &r.session().sources {
|
||||
assert!(r.session().pool.get(src.id).is_some(), "{} has no audio", src.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_named_sources_get_distinct_files() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
||||
@@ -316,6 +316,7 @@ impl Engine {
|
||||
let before = Arc::clone(&self.doc);
|
||||
let rev = self.revision;
|
||||
let dirty = self.dirty;
|
||||
let side_effects = (self.clipboard.clone(), self.messages.clone(), self.transport_requests.clone(), self.last_selection.clone());
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
|
||||
let result = match result {
|
||||
Ok(r) => r,
|
||||
@@ -323,6 +324,7 @@ impl Engine {
|
||||
self.doc = before;
|
||||
self.revision = rev.wrapping_add(1);
|
||||
self.dirty = dirty;
|
||||
(self.clipboard, self.messages, self.transport_requests, self.last_selection) = side_effects;
|
||||
return Err(EngineError::Internal(id.to_string(), panic_message(&*p)));
|
||||
}
|
||||
};
|
||||
@@ -369,7 +371,15 @@ impl Engine {
|
||||
let key = self.merge_hold.clone().unwrap_or_else(|| key.to_string());
|
||||
let continuing = self.merge_key.as_ref() == Some(&key);
|
||||
let before_len = self.undo.len();
|
||||
let r = self.execute(id, params)?;
|
||||
// The step pushed for a continuing tick is dropped again below, so it must not trim the
|
||||
// oldest entry first when the history is full.
|
||||
let limit = self.history_limit;
|
||||
if continuing && before_len > 0 {
|
||||
self.history_limit = limit.max(before_len.saturating_add(1));
|
||||
}
|
||||
let r = self.execute(id, params);
|
||||
self.history_limit = limit;
|
||||
let r = r?;
|
||||
if continuing && self.undo.len() == before_len + 1 && before_len > 0 {
|
||||
// Drop the step just pushed; the earlier one still holds the pre-gesture document.
|
||||
self.undo.pop();
|
||||
@@ -386,6 +396,11 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// The gesture `execute_merged` is continuing, if any.
|
||||
pub fn merge_key(&self) -> Option<&str> {
|
||||
self.merge_key.as_deref()
|
||||
}
|
||||
|
||||
/// End a gesture started with `execute_merged`.
|
||||
pub fn end_merge(&mut self) {
|
||||
self.merge_key = None;
|
||||
@@ -441,7 +456,7 @@ fn install_panic_hook() {
|
||||
}
|
||||
|
||||
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)]
|
||||
@@ -513,3 +528,26 @@ mod dirty_tests {
|
||||
assert!(!e.is_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod panic_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_panicking_command_restores_side_effect_state() {
|
||||
let mut e = Engine::default();
|
||||
// Non-default values, so clearing them instead of restoring them would fail.
|
||||
e.clipboard.length = 42;
|
||||
e.messages.push("prior".into());
|
||||
e.transport_requests.push(TransportRequest::Stop);
|
||||
e.last_selection = Some((soundcraft_time::Range::new(20, 30), Vec::new()));
|
||||
let (clipboard, messages, requests, selection) =
|
||||
(e.clipboard.clone(), e.messages.clone(), e.transport_requests.clone(), e.last_selection.clone());
|
||||
let r = e.execute("test.panic_probe", &json!({"panic": true}));
|
||||
assert!(matches!(r, Err(EngineError::Internal(..))));
|
||||
assert_eq!(e.clipboard, clipboard);
|
||||
assert_eq!(e.messages, messages);
|
||||
assert_eq!(e.transport_requests, requests);
|
||||
assert_eq!(e.last_selection, selection);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user