mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-25 11:06:47 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01b39850ad | ||
|
|
b48df2a8b9 | ||
|
|
106678471f | ||
|
|
541096d2cb | ||
|
|
353ea145aa | ||
|
|
35905fb90b | ||
|
|
d7b1562cc1 | ||
|
|
9048e04d60 | ||
|
|
e954639900 | ||
|
|
bbebfa1a6e | ||
|
|
9821168bc4 | ||
|
|
e46339606a | ||
|
|
835de9f038 | ||
|
|
e910e72940 | ||
|
|
54467f544b | ||
|
|
4311742624 | ||
|
|
a8ca533b9b | ||
|
|
3a139d4bed | ||
|
|
092122b49c | ||
|
|
be75be896a | ||
|
|
f77c360f94 | ||
|
|
a6cec46275 | ||
|
|
10e26f6fe2 | ||
|
|
c0dc2c3ec6 | ||
|
|
06f30628bb | ||
|
|
746a825ed5 | ||
|
|
fe4cd8095d | ||
|
|
a41f254aa0 | ||
|
|
698507cf30 | ||
|
|
1e9a0e9c88 | ||
|
|
ee050db59c | ||
|
|
0ca492e0ab | ||
|
|
3e56738290 | ||
|
|
d5be1e6de3 | ||
|
|
7b19c3068a | ||
|
|
9b60e84fc3 | ||
|
|
02b153797d | ||
|
|
4a0e4caa3f | ||
|
|
dc9e84978f | ||
|
|
aa74bffac1 | ||
|
|
40ced8132b | ||
|
|
b322ff2f13 | ||
|
|
c5ddbbafd1 | ||
|
|
f0f9addd16 | ||
|
|
e884d6eb37 | ||
|
|
4ab3bde39b | ||
|
|
824cf5cecb | ||
|
|
f6a27c2ccf | ||
|
|
08679d5a65 | ||
|
|
93800e0764 | ||
|
|
33c979cd34 | ||
|
|
a05b1e7dc1 | ||
|
|
c29510af86 | ||
|
|
1cc7b40e6a | ||
|
|
42f22369fb | ||
|
|
b47f544c7a | ||
|
|
7bf20dbbac | ||
|
|
de206f46b6 | ||
|
|
4f959bee40 | ||
|
|
0ca5dd74ab | ||
|
|
848978a4af | ||
|
|
f3b897f532 | ||
|
|
c13e55c7c3 | ||
|
|
419e7726bb | ||
|
|
bc530b172a | ||
|
|
fb9d4ac78b | ||
|
|
76bd7bc3af | ||
|
|
c4fee770ca | ||
|
|
c0f72e5fe3 | ||
|
|
7ec4216ba2 | ||
|
|
d33563ebe2 | ||
|
|
95243c7698 | ||
|
|
4240873586 | ||
|
|
9eb2077dd1 | ||
|
|
3fe53ee834 | ||
|
|
9e7583d99b | ||
|
|
9f03eeb354 | ||
|
|
ec7cfbb8ac | ||
|
|
d2d6518a5f | ||
|
|
045938728f | ||
|
|
2e17315d5d | ||
|
|
678cb0f57d | ||
|
|
a1eafbd1c5 | ||
|
|
c8f44fbbef | ||
|
|
fd07262983 | ||
|
|
9719f955a4 | ||
|
|
98aa5dd1b5 | ||
|
|
9a5ab203d2 | ||
|
|
0cb3c692ac | ||
|
|
5bd755f604 | ||
|
|
b0e35aa988 | ||
|
|
bb990d9bfa | ||
|
|
2adec7f128 | ||
|
|
038c4c0ca7 | ||
|
|
fc87f5a5c6 | ||
|
|
a851761270 | ||
|
|
c84bdda2f1 | ||
|
|
886969bd19 | ||
|
|
6c03d7e8de | ||
|
|
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 | ||
|
|
c2da4cf912 | ||
|
|
0888ca8ac3 | ||
|
|
77d96affc3 | ||
|
|
a18f86cc89 | ||
|
|
ad7b9bcac6 | ||
|
|
19f1069109 | ||
|
|
5088846d45 | ||
|
|
b0f07331a2 | ||
|
|
1053073302 | ||
|
|
a50fdf9cc9 | ||
|
|
99579a5421 | ||
|
|
0000566f5e | ||
|
|
b8b9a90c6f | ||
|
|
dfb3815eb3 | ||
|
|
bf9fa3cc60 | ||
|
|
64669e9a7c | ||
|
|
07b85277ee | ||
|
|
1f72dc30be | ||
|
|
525a4e02fe | ||
|
|
8b34dc533f | ||
|
|
4b54d72c23 | ||
|
|
6b84a4f046 | ||
|
|
67865b23e8 | ||
|
|
e82de5cb97 | ||
|
|
fe4bb3fe0c | ||
|
|
a4d3d8541f | ||
|
|
e8abd44712 | ||
|
|
cc493b2d8b | ||
|
|
ab1849cbf8 | ||
|
|
265645a8fc | ||
|
|
9b68982738 | ||
|
|
ef8a63f8c3 | ||
|
|
9ecf197aa7 | ||
|
|
072b4ff3da | ||
|
|
0235d11d4c | ||
|
|
767feacf72 | ||
|
|
d01abd0e4e | ||
|
|
f0d5acb800 | ||
|
|
7c690b19e1 | ||
|
|
d47abe484d | ||
|
|
f431791c26 | ||
|
|
53a18ec037 | ||
|
|
44067c8779 | ||
|
|
24fa4668ef | ||
|
|
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 | ||
|
|
8198d6e35a | ||
|
|
1c1df8728d | ||
|
|
52253e89c5 | ||
|
|
63eacf115d | ||
|
|
76bac6495e | ||
|
|
16371a8502 | ||
|
|
717ae97af5 | ||
|
|
01c8c85ebc | ||
|
|
187ee51ae0 | ||
|
|
4ef6458e4e | ||
|
|
152c2c45e6 | ||
|
|
96130d22c7 | ||
|
|
988d678eec | ||
|
|
797aaac058 | ||
|
|
5703db0b80 | ||
|
|
1816f2dc92 | ||
|
|
09c2e71141 | ||
|
|
dfde8a6724 | ||
|
|
0d8a92f53b | ||
|
|
b5bcf577b2 | ||
|
|
4eee6b17a8 | ||
|
|
82616dcfb8 | ||
|
|
5ddd4b115f | ||
|
|
c8f8fc45eb | ||
|
|
9ac264184e | ||
|
|
3c04a3c36f | ||
|
|
278b41b8ff | ||
|
|
c09dab9fef | ||
|
|
4110f5ff10 | ||
|
|
a72e46e7b6 | ||
|
|
6f3591ae11 | ||
|
|
0a9d29f227 | ||
|
|
a4b6bd0179 | ||
|
|
3ea890496e | ||
|
|
7a086baa7b | ||
|
|
5f03a5991e | ||
|
|
adab577f9a | ||
|
|
05fe56ec1a | ||
|
|
fb087c879b | ||
|
|
075104f448 | ||
|
|
ea0e4719fc | ||
|
|
2cba9ed4cd | ||
|
|
315745b022 | ||
|
|
952ca5e4ed | ||
|
|
800b27013d | ||
|
|
7bf80663dc | ||
|
|
b50373d6db | ||
|
|
1e1458caf1 | ||
|
|
76696826cd | ||
|
|
f8b2f8ce2e | ||
|
|
d4096db1f0 | ||
|
|
28c936a256 | ||
|
|
2dd8f7da55 | ||
|
|
08b0704b0c | ||
|
|
d13f6657f8 | ||
|
|
bddc113f46 | ||
|
|
b7329ee904 | ||
|
|
9a398d79ff | ||
|
|
f9d04ffc98 | ||
|
|
245015fd89 |
@@ -12,6 +12,14 @@ updates:
|
||||
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)"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -21,3 +21,41 @@ jobs:
|
||||
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: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
|
||||
# Source replacement, patches and path overrides in cargo config never show up
|
||||
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
|
||||
# Cargo still reads the legacy .cargo/config.
|
||||
python3 - <<'PY'
|
||||
import os, sys, tomllib
|
||||
for path in (".cargo/config", ".cargo/config.toml"):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
config = tomllib.load(open(path, "rb"))
|
||||
replaced = [name for name, source in config.get("source", {}).items()
|
||||
if isinstance(source, dict) and "replace-with" in source]
|
||||
if replaced or "patch" in config or "paths" in config:
|
||||
print(f"{path} replaces, patches or overrides a source")
|
||||
sys.exit(1)
|
||||
PY
|
||||
# Fails when Cargo.lock is out of date with the manifests.
|
||||
cargo metadata --locked --format-version 1 > /dev/null
|
||||
python3 - <<'PY'
|
||||
import sys, tomllib
|
||||
lock = tomllib.load(open("Cargo.lock", "rb"))
|
||||
bad = sorted({p["source"] for p in lock.get("package", [])
|
||||
if p.get("source", "").startswith("git+")
|
||||
and not p["source"].startswith("git+https://github.com/storytold/")})
|
||||
if bad:
|
||||
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
@@ -10,15 +10,15 @@ on:
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# The shared workflow creates the draft release, which needs write access to contents.
|
||||
permissions:
|
||||
contents: write
|
||||
# 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/release.yml@main
|
||||
with:
|
||||
|
||||
@@ -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
+267
-320
File diff suppressed because it is too large
Load Diff
+13
-5
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.11.0"
|
||||
version = "0.13.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" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
|
||||
|
||||
# Video containers and bitstreams, shared with the other craft apps.
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", 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,6 +7,10 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.soundcraft.png" width="128" height="128" alt="SoundCraft icon: a singing bird on a blue tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">SoundCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
@@ -118,11 +122,11 @@ where things are.
|
||||
- **Automation:** volume, pan, mute, send and every plugin parameter; Write, Touch, Latch and
|
||||
Trim passes recorded live from the faders; thin, glide, coalesce and convert to clip gain.
|
||||
- **Plugins:** 7-band and 1-band EQ, compressor/limiter, expander/gate, de-esser, maximizer,
|
||||
channel strip, room and plate reverbs, mod delay, chorus, flanger, phaser, saturator, lo-fi,
|
||||
rectifier, pitch shifter, time shift, gain, trim, invert, DC removal, signal generator, dither,
|
||||
a subtractive synth, a drum synth and a sampler (plays any WAV, AIFF, FLAC, MP3 or OGG across the
|
||||
keyboard), all original. **CLAP plugins** load too. Any processor
|
||||
runs offline as AudioSuite, and user presets save per plugin.
|
||||
channel strip, room and plate reverbs, mod delay, auto pan, chorus, flanger, phaser, ring
|
||||
modulator, saturator, lo-fi, rectifier, subharmonic, pitch shifter, time shift, gain, trim,
|
||||
invert, DC removal, signal generator, dither, a subtractive synth, a drum synth and a sampler
|
||||
(plays any WAV, AIFF, FLAC, MP3 or OGG across the keyboard), all original. **CLAP plugins**
|
||||
load too. Any processor runs offline as AudioSuite, and user presets save per plugin.
|
||||
- **MIDI:** instrument and MIDI tracks, a piano-roll MIDI editor with a velocity lane, an event
|
||||
list, a score view, step input, quantize, transpose, real-time properties, Standard MIDI Files,
|
||||
and Audio-to-MIDI from a sung or played melody.
|
||||
@@ -134,7 +138,7 @@ where things are.
|
||||
pitch-preserving Elastic warping.
|
||||
- **Audio files:** WAV/BWF/RF64, AIFF/AIFC and FLAC in and out; MP3, Ogg Vorbis, AAC, ALAC, CAF
|
||||
and the audio of MP4/MOV movies in. Any sample rate and bit depth.
|
||||
- **Bounce:** mix or stems to WAV, AIFF or FLAC at 16/24/32-bit float with dither and
|
||||
- **Bounce:** mix or stems to WAV, AIFF or FLAC at 16/24/32-bit float with optional dither and
|
||||
normalisation, reporting peak, true peak and integrated loudness (LUFS).
|
||||
- **Fast:** a parallel mix engine, copy-on-write undo, and a UI that holds 120 frames per second
|
||||
with seventy-odd tracks playing.
|
||||
@@ -383,4 +387,4 @@ Forks and modified versions must remove them.
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
|
||||
Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float Panel, Move Panel to Group, and Close Panel; the tab group's menu adds Float Tab Group and Close Tab Group. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
|
||||
|
||||
+25
-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-18 · **Last updated:** 2026-10-18 · **Change:** trivial (mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** trivial (Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **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,12 +122,25 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change.
|
||||
- **2026-10-24:** While the Strip Silence window is open, the Edit window draws a rectangle around each range that Strip would keep on the selected tracks, padded by Clip Start Pad and Clip End Pad, and the rectangles follow the sliders as they move. They are removed when the window closes. `edit.strip_silence_preview` returns the same ranges without changing the session. No readiness percentage change.
|
||||
- **2026-10-24:** The Strip Silence window has a Rename button. It opens Rename Selected Clips, where a name, a starting number, a count of zeros and a suffix set the names of the clips that Strip, Extract and Separate leave in the selection (for example SFX023.Reel1, SFX024.Reel1). The app keeps the scheme for later uses of Strip Silence until the app quits, or until Clear and then OK empty it. No readiness percentage change.
|
||||
- **2026-10-24:** The Strip Silence window has Extract and Separate buttons. Extract removes the audio and keeps the silent parts of the selection, and Separate separates the clips at the edges of the silence without removing anything. Minimum Strip Duration goes up to 4,000 ms. No readiness percentage change.
|
||||
- **2026-10-24:** Clip › Loop… (Cmd+Alt+L) opens the Clip Looping dialog: Number of Loops, Loop Length in the main counter's time format, or Loop Until End of Session or Next Clip. A loop length that is not a whole number of loops cuts the last loop short, and Unloop removes that short loop too. No readiness percentage change.
|
||||
- **2026-10-24:** AudioSuite ▸ Pitch Shift ▸ Vari-Fi slows the selected clips down to a complete stop or speeds them up from one, and the pitch follows the speed. Fit To keeps each clip's length and plays two-thirds of it; Extend plays all of it and makes the clip 150 % long. Fades On fades the audio out with the slowdown or in with the speedup. Before, the menu item resampled the clip at a constant speed. No readiness percentage change.
|
||||
- **2026-10-24:** A built-in Auto Pan plugin pans the mono sum of a stereo track between left and right. An LFO with seven waveforms moves it at a rate in BPM or at the session tempo, running free, once, in step with the transport, or when the level crosses a threshold or a manual trigger changes; an envelope follower can move it by level instead. Width, Manual and Path set the field, and Output sets the level. No readiness percentage change.
|
||||
- **2026-10-24:** A built-in Subharmonic (`subharmonic`) adds the octave below a lower and an upper band of the input, chosen from four frequency ranges, with a direct bass band, a gain and a solo for each band, Drive with six characters, HP and LP filters, and a Mix that keeps one path at full level. No readiness percentage change.
|
||||
- **2026-10-23:** A built-in Ring Modulator (`ring_mod`) applies ring modulation, frequency modulation or a positive or negative resonator, and an LFO, an envelope follower or sample and hold moves its frequency. AudioSuite › Modulation › Sci-Fi now runs it instead of the Flanger. No readiness percentage change.
|
||||
- **2026-10-19:** Float32 WAV and AIFF-C encoding preserve finite samples above full scale. Integer PCM still clips at full scale. Tests cover whole-file and streaming encoders, non-finite samples, and bounce output with normalization on and off. No readiness percentage change.
|
||||
- **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-16:** Split into Mono extracts the correct source channel for each mono track instead of playing channel zero on every track. Clip offsets, gain, fades and stretch, source timestamps, Undo/Redo and saved audio are preserved; repeated clips share each extracted mono source. Unavailable media returns an error without partial edits. 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-14:** `soundcraft-cli convert` dithers only when the output loses resolution (fewer bits than the source, a float or lossy source, or resampled/normalized samples); a same-depth, wider or 32-bit float conversion copies the samples exactly and digital silence stays silent. `--dither` and `--no-dither` override the choice (#548)
|
||||
- **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 (VST3 host messages):** The VST3 host creates `IMessage` and `IAttributeList` objects for plugins (`IHostApplication::createInstance`), so plugins whose controller and processor talk through `IConnectionPoint::notify` work: Line 6 Helix Stadium Native now loads its content database (it reported "Failed to get content database recovery info (Out of memory condition)") and exposes its parameters. No readiness percentage change.
|
||||
- **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)
|
||||
@@ -161,9 +174,20 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Strip Silence rectangles; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Strip Silence Rename; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Strip Silence Extract and Separate; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Clip Looping dialog; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: AudioSuite Vari-Fi speed ramps; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Auto Pan plugin; readiness estimates unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Subharmonic plugin; readiness estimates unchanged |
|
||||
| 2026-10-23 | trivial | Progress log: Ring Modulator plugin; readiness estimates unchanged |
|
||||
| 2026-10-19 | trivial | Float32 file encoding preserves finite headroom (#791); readiness estimates unchanged |
|
||||
| 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-16 | trivial | Split into Mono preserves each source channel, clip edits and saved audio; 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) |
|
||||
|
||||
@@ -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")]);
|
||||
}
|
||||
}
|
||||
|
||||
+174
-32
@@ -2,18 +2,22 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! soundcraft-cli info FILE audio file or .scraft summary
|
||||
//! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize]
|
||||
//! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize] [--dither | --no-dither]
|
||||
//! dithers only when the output loses resolution, unless told
|
||||
//! soundcraft-cli run [--in S.scraft | --demo] [--cmd ID[=JSON]]… [--bounce OUT] [--start T --end T] [--save S.scraft] [--inspect]
|
||||
//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT] (one `ID JSON` per line)
|
||||
//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT [--start T --end T]] (one `ID JSON` per line;
|
||||
//! without --start/--end, --bounce covers the whole session)
|
||||
//! 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
|
||||
//! ```
|
||||
@@ -38,9 +42,57 @@ macro_rules! outln {
|
||||
}
|
||||
|
||||
fn arg_value(args: &[String], flag: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned()
|
||||
args.iter().enumerate().find_map(|(i, a)| {
|
||||
if a == flag { args.get(i + 1).cloned() } else { a.strip_prefix(flag).and_then(|rest| rest.strip_prefix('=')).map(str::to_owned) }
|
||||
})
|
||||
}
|
||||
|
||||
/// Every option a subcommand accepts, with whether it carries a value.
|
||||
type Options = [(&'static str, bool)];
|
||||
|
||||
/// A misspelled option, or one whose value is missing, reads as absent through `arg_value`, so the
|
||||
/// subcommand would run without what was asked for and still report success. Reject both instead.
|
||||
fn check_options(args: &[String], allowed: &Options) -> Result<(), String> {
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let Some(name) = args[i].strip_prefix("--") else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let (name, inline) = name.split_once('=').map_or((name, None), |(n, v)| (n, Some(v)));
|
||||
let flag = format!("--{name}");
|
||||
let Some(&(_, takes_value)) = allowed.iter().find(|(f, _)| f == &flag) else {
|
||||
return Err(format!("unknown option {flag}"));
|
||||
};
|
||||
match (takes_value, inline) {
|
||||
(false, Some(_)) => return Err(format!("{flag} takes no value")),
|
||||
(true, None) => {
|
||||
// A following option is the next flag, not this one's value.
|
||||
if args.get(i + 1).is_none_or(|v| v.starts_with("--")) {
|
||||
return Err(format!("{flag} needs a value"));
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `--in`, `--demo`/`--sample`, `--bounce`, `--start`, `--end`, `--save` and `--inspect`, read by
|
||||
/// `load_engine` and `finish` and so shared by `run` and `script`.
|
||||
const ENGINE_OPTIONS: &Options = &[
|
||||
("--in", true),
|
||||
("--demo", false),
|
||||
("--sample", false),
|
||||
("--bounce", true),
|
||||
("--start", true),
|
||||
("--end", true),
|
||||
("--save", true),
|
||||
("--inspect", false),
|
||||
];
|
||||
|
||||
fn fail(msg: impl std::fmt::Display) -> ExitCode {
|
||||
eprintln!("soundcraft-cli: {msg}");
|
||||
ExitCode::FAILURE
|
||||
@@ -110,7 +162,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());
|
||||
@@ -122,6 +174,10 @@ fn info(args: &[String]) -> ExitCode {
|
||||
|
||||
fn convert(args: &[String]) -> ExitCode {
|
||||
let (Some(input), Some(output)) = (args.first(), args.get(1)) else { return fail("convert IN OUT") };
|
||||
const OPTIONS: &Options = &[("--rate", true), ("--bit-depth", true), ("--normalize", false), ("--dither", false), ("--no-dither", false)];
|
||||
if let Err(e) = check_options(&args[2..], OPTIONS) {
|
||||
return fail(e);
|
||||
}
|
||||
let rate = match parse_rate(arg_value(args, "--rate").as_deref()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return fail(e),
|
||||
@@ -130,12 +186,16 @@ fn convert(args: &[String]) -> ExitCode {
|
||||
Ok(b) => b,
|
||||
Err(e) => return fail(e),
|
||||
};
|
||||
let dither_flag = match parse_dither(args) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return fail(e),
|
||||
};
|
||||
let bytes = match std::fs::read(input) {
|
||||
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 (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
|
||||
let ext = std::path::Path::new(input).extension().and_then(std::ffi::OsStr::to_str);
|
||||
let (info, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
|
||||
Ok(x) => x,
|
||||
Err(e) => return fail(format!("{input}: {e}")),
|
||||
};
|
||||
@@ -143,14 +203,17 @@ fn convert(args: &[String]) -> ExitCode {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
|
||||
buf.sample_rate = rate;
|
||||
}
|
||||
if args.iter().any(|a| a == "--normalize") {
|
||||
let normalize = args.iter().any(|a| a == "--normalize");
|
||||
if 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 processed = rate.is_some_and(|r| r != info.sample_rate) || normalize;
|
||||
let dither = dither_flag.unwrap_or_else(|| auto_dither(info.sample_format, bit_depth, processed));
|
||||
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));
|
||||
};
|
||||
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None };
|
||||
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf: None };
|
||||
match soundcraft_audio_io::encode(&buf, &opts).map_err(|e| e.to_string()).and_then(|b| std::fs::write(output, b).map_err(|e| e.to_string())) {
|
||||
Ok(()) => {
|
||||
outln!("wrote {output} ({} ch, {} Hz, {:.2} s)", buf.num_channels(), buf.sample_rate, buf.duration_secs());
|
||||
@@ -179,6 +242,41 @@ fn parse_bit_depth(v: Option<&str>) -> Result<soundcraft_audio_io::BitDepth, Str
|
||||
}
|
||||
}
|
||||
|
||||
/// `--dither` / `--no-dither`: `Some` when the caller chose, `None` to decide from the depths.
|
||||
fn parse_dither(args: &[String]) -> Result<Option<bool>, String> {
|
||||
match (args.iter().any(|a| a == "--dither"), args.iter().any(|a| a == "--no-dither")) {
|
||||
(true, true) => Err("--dither and --no-dither: choose one".into()),
|
||||
(true, false) => Ok(Some(true)),
|
||||
(false, true) => Ok(Some(false)),
|
||||
(false, false) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `convert` dithers when not told: only when writing integer samples that cannot hold the
|
||||
/// source exactly, i.e. the output has fewer bits than the source, the source is float or lossy, or
|
||||
/// resampling/normalizing produced new sample values. A 16-bit file written as 16 or 24-bit, or
|
||||
/// anything written as 32-bit float, is copied sample for sample (digital silence stays silent).
|
||||
fn auto_dither(source: soundcraft_audio_io::SampleFormat, target: soundcraft_audio_io::BitDepth, processed: bool) -> bool {
|
||||
use soundcraft_audio_io::{BitDepth, SampleFormat};
|
||||
let target_bits = match target {
|
||||
BitDepth::Float32 => return false,
|
||||
BitDepth::Int16 => 16,
|
||||
BitDepth::Int24 => 24,
|
||||
BitDepth::Int32 => 32,
|
||||
};
|
||||
if processed {
|
||||
return true;
|
||||
}
|
||||
match source {
|
||||
SampleFormat::Int8 => target_bits < 8,
|
||||
SampleFormat::Int16 => target_bits < 16,
|
||||
SampleFormat::Int24 => target_bits < 24,
|
||||
// Decoded through f32, which holds 24 bits: a 32-bit integer source has already lost
|
||||
// resolution, so treat it like float.
|
||||
SampleFormat::Int32 | SampleFormat::Float32 | SampleFormat::Float64 | SampleFormat::Compressed => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// One stderr line per media file a session refers to but could not load (the exit code is unchanged).
|
||||
fn missing_warnings(missing: &[String]) -> Vec<String> {
|
||||
missing.iter().map(|m| format!("warning: missing media: {m}")).collect()
|
||||
@@ -212,6 +310,11 @@ fn finish(e: &mut Engine, args: &[String]) -> ExitCode {
|
||||
if let Some(s) = arg_value(args, "--end") {
|
||||
p["end"] = time_arg(&s);
|
||||
}
|
||||
// No range given: bounce the whole session, not whatever edit selection the commands left.
|
||||
if p.get("start").is_none() && p.get("end").is_none() {
|
||||
p["start"] = json!(0);
|
||||
p["end"] = json!(e.session().content_end().max(1));
|
||||
}
|
||||
match e.execute("file.bounce_mix", &p) {
|
||||
Ok(v) => outln!("{v}"),
|
||||
Err(err) => return fail(err),
|
||||
@@ -243,32 +346,42 @@ fn parse_cmd_spec(spec: &str) -> Result<(String, Value), String> {
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> ExitCode {
|
||||
let options: Vec<(&str, bool)> = ENGINE_OPTIONS.iter().copied().chain([("--cmd", true)]).collect();
|
||||
if let Err(e) = check_options(args, &options) {
|
||||
return fail(e);
|
||||
}
|
||||
let mut e = match load_engine(args) {
|
||||
Ok(e) => e,
|
||||
Err(err) => return fail(err),
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
if args.get(i).is_some_and(|a| a == "--cmd")
|
||||
&& let Some(spec) = args.get(i + 1)
|
||||
{
|
||||
let (id, params) = match parse_cmd_spec(spec) {
|
||||
Ok(v) => v,
|
||||
Err(err) => return fail(err),
|
||||
};
|
||||
match e.execute(&id, ¶ms) {
|
||||
Ok(v) => outln!("{id}: {v}"),
|
||||
Err(err) => return fail(format!("{id}: {err}")),
|
||||
}
|
||||
i += 1;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
let spec = match a.strip_prefix("--cmd=") {
|
||||
Some(inline) => inline,
|
||||
None if a == "--cmd" => match it.next() {
|
||||
Some(next) => next,
|
||||
None => continue,
|
||||
},
|
||||
None => continue,
|
||||
};
|
||||
let (id, params) = match parse_cmd_spec(spec) {
|
||||
Ok(v) => v,
|
||||
Err(err) => return fail(err),
|
||||
};
|
||||
match e.execute(&id, ¶ms) {
|
||||
Ok(v) => outln!("{id}: {v}"),
|
||||
Err(err) => return fail(format!("{id}: {err}")),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
finish(&mut e, args)
|
||||
}
|
||||
|
||||
fn script(args: &[String]) -> ExitCode {
|
||||
let Some(path) = args.first() else { return fail("script FILE") };
|
||||
let options: Vec<(&str, bool)> = ENGINE_OPTIONS.iter().copied().chain([("--keep-going", false)]).collect();
|
||||
if let Err(e) = check_options(&args[1..], &options) {
|
||||
return fail(e);
|
||||
}
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return fail(format!("{path}: {e}")),
|
||||
@@ -398,7 +511,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 +524,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 +552,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);
|
||||
@@ -511,6 +625,34 @@ mod tests {
|
||||
assert!(parse_bit_depth(Some("8")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_dither_flags() {
|
||||
let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(parse_dither(&args(&["in.wav", "out.wav"])), Ok(None));
|
||||
assert_eq!(parse_dither(&args(&["in.wav", "out.wav", "--dither"])), Ok(Some(true)));
|
||||
assert_eq!(parse_dither(&args(&["in.wav", "out.wav", "--no-dither"])), Ok(Some(false)));
|
||||
assert!(parse_dither(&args(&["in.wav", "out.wav", "--dither", "--no-dither"])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_dithers_only_when_resolution_is_lost() {
|
||||
use soundcraft_audio_io::{BitDepth, SampleFormat};
|
||||
// Same depth or wider: the samples fit exactly.
|
||||
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int16, false));
|
||||
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int24, false));
|
||||
assert!(!auto_dither(SampleFormat::Int24, BitDepth::Int24, false));
|
||||
assert!(!auto_dither(SampleFormat::Int8, BitDepth::Int16, false));
|
||||
// Float output never dithers.
|
||||
assert!(!auto_dither(SampleFormat::Float32, BitDepth::Float32, true));
|
||||
assert!(!auto_dither(SampleFormat::Int24, BitDepth::Float32, false));
|
||||
// Fewer bits, a float or lossy source, or processed samples: dither.
|
||||
assert!(auto_dither(SampleFormat::Int24, BitDepth::Int16, false));
|
||||
assert!(auto_dither(SampleFormat::Float32, BitDepth::Int24, false));
|
||||
assert!(auto_dither(SampleFormat::Compressed, BitDepth::Int16, false));
|
||||
assert!(auto_dither(SampleFormat::Int32, BitDepth::Int24, false));
|
||||
assert!(auto_dither(SampleFormat::Int16, BitDepth::Int16, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_media_gets_one_warning_each() {
|
||||
assert!(missing_warnings(&[]).is_empty());
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! `soundcraft-cli convert` dithers only when the output loses resolution (#548), 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::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("sc-cli-convert-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A 16-bit mono WAV of `samples`, written without dither so the file holds exactly these values.
|
||||
fn write_wav16(path: &Path, samples: &[i16]) {
|
||||
let mut buf = AudioBuffer::new(48_000, 1, samples.len());
|
||||
for (d, s) in buf.channels[0].iter_mut().zip(samples) {
|
||||
*d = f32::from(*s) / 32768.0;
|
||||
}
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
std::fs::write(path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn convert(input: &Path, output: &Path, extra: &[&str]) {
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"))
|
||||
.env("SOUNDCRAFT_NO_PREFS", "1")
|
||||
.arg("convert")
|
||||
.arg(input)
|
||||
.arg(output)
|
||||
.args(extra)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
}
|
||||
|
||||
/// The decoded samples of `path`, scaled to 16-bit steps.
|
||||
fn steps16(path: &Path) -> Vec<f64> {
|
||||
let (_, buf) = soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap();
|
||||
buf.channels[0].iter().map(|s| f64::from(*s) * 32768.0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_copies_samples_when_nothing_is_lost() {
|
||||
let dir = tmpdir("exact");
|
||||
let (silence, tone) = (dir.join("silence.wav"), dir.join("tone.wav"));
|
||||
let tone_samples: Vec<i16> = (0..4800).map(|i| (8000.0 * (f64::from(i) * 0.05).sin()) as i16).collect();
|
||||
write_wav16(&silence, &[0; 4800]);
|
||||
write_wav16(&tone, &tone_samples);
|
||||
let want: Vec<f64> = tone_samples.iter().map(|s| f64::from(*s)).collect();
|
||||
|
||||
// Same depth, wider and float: every sample comes back unchanged, silence stays silent.
|
||||
for (depth, name) in [("16", "s16.wav"), ("24", "s24.wav"), ("32f", "s32.wav")] {
|
||||
let out = dir.join(name);
|
||||
convert(&silence, &out, &["--bit-depth", depth]);
|
||||
assert!(steps16(&out).iter().all(|s| *s == 0.0), "silence at {depth} is not silent");
|
||||
let out = dir.join(format!("t{name}"));
|
||||
convert(&tone, &out, &["--bit-depth", depth]);
|
||||
assert_eq!(steps16(&out), want, "tone at {depth} changed");
|
||||
}
|
||||
// The default output depth (24-bit) too.
|
||||
let out = dir.join("default.wav");
|
||||
convert(&tone, &out, &[]);
|
||||
assert_eq!(steps16(&out), want);
|
||||
|
||||
// Asked for, dither is still applied.
|
||||
let out = dir.join("forced.wav");
|
||||
convert(&silence, &out, &["--bit-depth", "16", "--dither"]);
|
||||
assert!(steps16(&out).iter().any(|s| *s != 0.0), "--dither did not dither");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_dithers_when_reducing_depth_unless_told_not_to() {
|
||||
let dir = tmpdir("reduce");
|
||||
let src = dir.join("src24.wav");
|
||||
// A 24-bit file with detail below one 16-bit step.
|
||||
let mut buf = AudioBuffer::new(48_000, 1, 4800);
|
||||
for (i, s) in buf.channels[0].iter_mut().enumerate() {
|
||||
*s = 0.3 / 32768.0 * (i as f32 * 0.01).sin();
|
||||
}
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None };
|
||||
std::fs::write(&src, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
|
||||
|
||||
let dithered = dir.join("d16.wav");
|
||||
convert(&src, &dithered, &["--bit-depth", "16"]);
|
||||
assert!(steps16(&dithered).iter().any(|s| *s != 0.0), "24 to 16-bit was not dithered");
|
||||
// Plain rounding: everything is below half a step, so it all rounds to zero.
|
||||
let plain = dir.join("p16.wav");
|
||||
convert(&src, &plain, &["--bit-depth", "16", "--no-dither"]);
|
||||
assert!(steps16(&plain).iter().all(|s| *s == 0.0), "--no-dither still dithered");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -25,10 +24,33 @@ fn tmpdir(tag: &str) -> PathBuf {
|
||||
dir
|
||||
}
|
||||
|
||||
/// `path` made absolute with links followed (`/var` becomes `/private/var` on macOS), as the server
|
||||
/// prints a root: without the `\\?\` prefix that `canonicalize` adds on Windows.
|
||||
fn resolved(path: &Path) -> PathBuf {
|
||||
let path = std::fs::canonicalize(path).unwrap();
|
||||
if !cfg!(windows) {
|
||||
return path;
|
||||
}
|
||||
let text = path.to_str().unwrap();
|
||||
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
|
||||
return PathBuf::from(format!(r"\\{rest}"));
|
||||
}
|
||||
text.strip_prefix(r"\\?\").map_or(path.clone(), PathBuf::from)
|
||||
}
|
||||
|
||||
/// 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 +124,73 @@ 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() {
|
||||
let base = resolved(&tmpdir("several"));
|
||||
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 +207,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");
|
||||
@@ -138,3 +228,28 @@ fn usage_lists_the_root_flags() {
|
||||
let help = String::from_utf8_lossy(&o.stdout).into_owned();
|
||||
assert!(help.contains("--automation-read-root DIR") && help.contains("--automation-write-root DIR"), "{help}");
|
||||
}
|
||||
|
||||
/// Without `--start`/`--end`, `--bounce` covers the whole session, not a stray edit selection
|
||||
/// left by the script (a bare `19.2` in command params is 19 samples).
|
||||
#[test]
|
||||
fn script_bounce_without_range_covers_the_session() {
|
||||
let base = tmpdir("bounce-range");
|
||||
let script = base.join("cmds.txt");
|
||||
std::fs::write(
|
||||
&script,
|
||||
concat!(
|
||||
"track.new {\"count\":1,\"kind\":\"instrument\",\"name\":\"Keys\"}\n",
|
||||
"midi.new_clip {\"track\":\"Keys\",\"start\":0,\"end\":{\"seconds\":19.2}}\n",
|
||||
"edit.select {\"start\":0,\"end\":19.2}\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let out = base.join("out.wav");
|
||||
let o = run(&["script", script.to_str().unwrap(), "--bounce", out.to_str().unwrap()]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let stdout = String::from_utf8_lossy(&o.stdout).into_owned();
|
||||
let last: Value = serde_json::from_str(stdout.lines().last().unwrap()).unwrap();
|
||||
let secs = last["seconds"].as_f64().unwrap();
|
||||
assert!((secs - 19.2).abs() < 0.01, "bounced {secs} s: {stdout}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Unknown options and missing option values must be reported, not silently skipped (#549).
|
||||
// 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::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn bin() -> Command {
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
|
||||
cmd.env("SOUNDCRAFT_NO_PREFS", "1");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn run(args: &[&str]) -> Output {
|
||||
bin().args(args).output().unwrap()
|
||||
}
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("sc-cli-options-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A 0.1 s mono 48 kHz 16-bit WAV, written by hand so the test needs no fixture.
|
||||
fn tone(dir: &Path) -> PathBuf {
|
||||
let frames: u32 = 4800;
|
||||
let data: Vec<u8> = (0..frames).flat_map(|_| [0x00, 0x10]).collect();
|
||||
let (bytes, rate, ch, bits) = (data.len() as u32, 48_000u32, 1u16, 16u16);
|
||||
let mut wav = Vec::new();
|
||||
wav.extend_from_slice(b"RIFF");
|
||||
wav.extend_from_slice(&(36 + bytes).to_le_bytes());
|
||||
wav.extend_from_slice(b"WAVEfmt ");
|
||||
wav.extend_from_slice(&16u32.to_le_bytes());
|
||||
wav.extend_from_slice(&1u16.to_le_bytes());
|
||||
wav.extend_from_slice(&ch.to_le_bytes());
|
||||
wav.extend_from_slice(&rate.to_le_bytes());
|
||||
wav.extend_from_slice(&(rate * u32::from(ch) * u32::from(bits) / 8).to_le_bytes());
|
||||
wav.extend_from_slice(&(ch * bits / 8).to_le_bytes());
|
||||
wav.extend_from_slice(&bits.to_le_bytes());
|
||||
wav.extend_from_slice(b"data");
|
||||
wav.extend_from_slice(&bytes.to_le_bytes());
|
||||
wav.extend_from_slice(&data);
|
||||
let path = dir.join("tone.wav");
|
||||
std::fs::write(&path, wav).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn sample_format(cli_info: &str) -> String {
|
||||
serde_json::from_str::<serde_json::Value>(cli_info).unwrap()["sample_format"].as_str().unwrap().to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rejects_an_unknown_option_instead_of_writing_the_file() {
|
||||
let dir = tmpdir("convert-unknown");
|
||||
let input = tone(&dir);
|
||||
let out = dir.join("out.wav");
|
||||
for bad in ["--bitdepth", "--normalise", "--bogus"] {
|
||||
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), bad]);
|
||||
assert!(!o.status.success(), "{bad} should fail");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("unknown option {bad}")), "{bad}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(!out.exists(), "{bad} should not write the output");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rejects_an_option_whose_value_is_missing() {
|
||||
let dir = tmpdir("convert-novalue");
|
||||
let input = tone(&dir);
|
||||
let out = dir.join("out.wav");
|
||||
for bad in ["--rate", "--bit-depth"] {
|
||||
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), bad]);
|
||||
assert!(!o.status.success(), "{bad} should fail");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("{bad} needs a value")), "{bad}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(!out.exists(), "{bad} should not write the output");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_accepts_the_inline_value_form() {
|
||||
let dir = tmpdir("convert-inline");
|
||||
let input = tone(&dir);
|
||||
let out = dir.join("out.wav");
|
||||
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), "--bit-depth=32f"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let info = run(&["info", out.to_str().unwrap()]);
|
||||
assert_eq!(sample_format(&String::from_utf8_lossy(&info.stdout)), "Float32");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_still_accepts_the_documented_space_separated_form() {
|
||||
let dir = tmpdir("convert-space");
|
||||
let input = tone(&dir);
|
||||
let out = dir.join("out.wav");
|
||||
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), "--bit-depth", "16"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let info = run(&["info", out.to_str().unwrap()]);
|
||||
assert_eq!(sample_format(&String::from_utf8_lossy(&info.stdout)), "Int16");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_rejects_a_misspelled_or_value_less_bounce() {
|
||||
let dir = tmpdir("run-bounce");
|
||||
let out = dir.join("x.wav");
|
||||
let o = run(&["run", "--cmd", "session.new", "--bounce"]);
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("--bounce needs a value"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
|
||||
let o = run(&["run", "--cmd", "session.new", "--bounces", out.to_str().unwrap()]);
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown option --bounces"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(!out.exists(), "nothing should be bounced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_bounces_with_the_inline_value_form() {
|
||||
let dir = tmpdir("run-inline");
|
||||
let out = dir.join("y.wav");
|
||||
let o = run(&["run", "--cmd", "session.new", &format!("--bounce={}", out.display())]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(out.exists(), "--bounce=PATH should write the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_executes_the_inline_cmd_form() {
|
||||
let o = run(&["run", "--cmd=track.new", "--cmd", "track.new"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
assert_eq!(stdout.lines().filter(|l| l.starts_with("track.new: ")).count(), 2, "both --cmd forms should run: {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 />
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use soundcraft_ui_egui::docking;
|
||||
|
||||
/// 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";
|
||||
@@ -88,19 +90,18 @@ fn read_ui(path: &std::path::Path, recover_docking: bool) -> Result<Option<sound
|
||||
let mut saved: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| format!("Settings could not be restored: {error}"))?;
|
||||
if recover_docking && let Some(fields) = saved.as_object_mut() {
|
||||
for (field, mix) in [("docking", false), ("mix_docking", true)] {
|
||||
let unreadable =
|
||||
fields.get(field).is_some_and(|value| match serde_json::from_value::<soundcraft_ui_egui::docking::Workspace>(value.clone()) {
|
||||
Ok(workspace) => if mix { workspace.validate_mix() } else { workspace.validate() }.is_err(),
|
||||
Err(_) => true,
|
||||
});
|
||||
let unreadable = fields.get(field).is_some_and(|value| match serde_json::from_value::<docking::Workspace>(value.clone()) {
|
||||
Ok(workspace) => if mix { docking::validate_mix(&workspace) } else { docking::validate(&workspace) }.is_err(),
|
||||
Err(_) => true,
|
||||
});
|
||||
if unreadable {
|
||||
fields.remove(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
let state: soundcraft_ui_egui::UiState = serde_json::from_value(saved).map_err(|error| format!("Settings could not be restored: {error}"))?;
|
||||
state.docking.validate()?;
|
||||
state.mix_docking.validate_mix()?;
|
||||
docking::validate(&state.docking)?;
|
||||
docking::validate_mix(&state.mix_docking)?;
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
@@ -111,8 +112,8 @@ pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Resu
|
||||
use std::io::Write as _;
|
||||
// Loading can recover a future layout for this session, but saving must preserve its source.
|
||||
read_ui(path, false)?;
|
||||
ui.docking.validate()?;
|
||||
ui.mix_docking.validate_mix()?;
|
||||
docking::validate(&ui.docking)?;
|
||||
docking::validate_mix(&ui.mix_docking)?;
|
||||
let mut saved = ui.clone();
|
||||
saved.plugin_windows.clear();
|
||||
saved.status.clear();
|
||||
@@ -160,18 +161,18 @@ mod config_tests {
|
||||
app.ui = load_ui(&path).unwrap().unwrap();
|
||||
assert_eq!(app.ui.theme, soundcraft_ui_egui::theme::ThemeMode::Light);
|
||||
assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), edit);
|
||||
assert_eq!(app.ui.mix_docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]);
|
||||
assert_eq!(app.ui.mix_docking.layout.floating.iter().find(|g| g.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]);
|
||||
app.ui.window = MainWindow::Edit;
|
||||
app.run("ui.dock", serde_json::json!({"operation":"open","panel":"tracks"})).unwrap();
|
||||
assert_eq!(app.ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]);
|
||||
assert_eq!(app.ui.docking.layout.floating.iter().find(|g| g.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]);
|
||||
let mut invalid_mix = app.ui.clone();
|
||||
invalid_mix.mix_docking = soundcraft_ui_egui::docking::Workspace::default();
|
||||
invalid_mix.mix_docking = soundcraft_ui_egui::docking::edit_default();
|
||||
let invalid_bytes = serde_json::to_vec(&invalid_mix).unwrap();
|
||||
std::fs::write(&path, &invalid_bytes).unwrap();
|
||||
let recovered = load_ui(&path).unwrap().unwrap();
|
||||
assert_eq!(recovered.theme, app.ui.theme);
|
||||
assert_eq!(serde_json::to_value(recovered.docking).unwrap(), serde_json::to_value(&app.ui.docking).unwrap());
|
||||
recovered.mix_docking.validate_mix().unwrap();
|
||||
soundcraft_ui_egui::docking::validate_mix(&recovered.mix_docking).unwrap();
|
||||
assert!(save_ui(&path, &app.ui).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), invalid_bytes);
|
||||
std::fs::write(&path, b"damaged settings").unwrap();
|
||||
@@ -201,8 +202,8 @@ mod config_tests {
|
||||
let recovered = load_ui(&path).unwrap().unwrap();
|
||||
assert_eq!(recovered.theme, app.ui.theme);
|
||||
assert!(recovered.narrow_mix);
|
||||
recovered.docking.validate().unwrap();
|
||||
recovered.mix_docking.validate_mix().unwrap();
|
||||
soundcraft_ui_egui::docking::validate(&recovered.docking).unwrap();
|
||||
soundcraft_ui_egui::docking::validate_mix(&recovered.mix_docking).unwrap();
|
||||
let other = if field == "docking" { "mix_docking" } else { "docking" };
|
||||
assert_eq!(serde_json::to_value(&recovered).unwrap()[other], saved[other]);
|
||||
assert!(save_ui(&path, &recovered).is_err());
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -112,8 +112,10 @@ fn open_path(app: &mut SoundApp, p: &str) {
|
||||
} else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") {
|
||||
app.run("file.import_midi", serde_json::json!({"path": p}))
|
||||
} else {
|
||||
// Decoded in the background so a long file doesn't freeze the window (#597).
|
||||
let at = app.engine.session().edit.selection.start;
|
||||
app.run("file.import_audio", serde_json::json!({"path": p, "at": at}))
|
||||
soundcraft_ui_egui::import::start(app, serde_json::json!({"path": p, "at": at}));
|
||||
Ok(serde_json::Value::Null)
|
||||
};
|
||||
if let Err(e) = r {
|
||||
log::warn!("{p}: {e}");
|
||||
@@ -182,7 +184,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())
|
||||
|
||||
@@ -55,3 +55,9 @@ sync_github_name = true
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.mattburlage]
|
||||
display_name = "Matt Smith-Burlage"
|
||||
|
||||
[people.ProdByBuddha]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -192,7 +192,7 @@ pub enum AuError {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod api {
|
||||
use super::*;
|
||||
use super::{AuDescriptor, AuError, Plugin, PluginInfo, registry};
|
||||
|
||||
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};
|
||||
|
||||
@@ -115,6 +115,7 @@ pub enum BitDepth {
|
||||
Int16,
|
||||
Int24,
|
||||
Int32,
|
||||
/// Preserve finite samples, including values above full scale; map NaN to 0 and infinities to ±1.
|
||||
Float32,
|
||||
}
|
||||
|
||||
|
||||
@@ -193,8 +193,8 @@ pub(crate) fn write_interleaved(
|
||||
for (c, q) in quantizers.iter_mut().enumerate() {
|
||||
let s = block.get(c).and_then(|ch| ch.get(f)).copied().unwrap_or(0.0);
|
||||
match (is_float, big_endian) {
|
||||
(true, false) => scratch.extend_from_slice(&clamp_float(s).to_le_bytes()),
|
||||
(true, true) => scratch.extend_from_slice(&clamp_float(s).to_be_bytes()),
|
||||
(true, false) => scratch.extend_from_slice(&sanitize_float(s).to_le_bytes()),
|
||||
(true, true) => scratch.extend_from_slice(&sanitize_float(s).to_be_bytes()),
|
||||
(false, false) => scratch.extend_from_slice(q.quantize(s).to_le_bytes().get(..size).unwrap_or_default()),
|
||||
(false, true) => scratch.extend_from_slice(q.quantize(s).to_be_bytes().get(4 - size..).unwrap_or_default()),
|
||||
}
|
||||
@@ -206,9 +206,15 @@ pub(crate) fn write_interleaved(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clamp a float sample to [-1, 1], mapping NaN to 0.
|
||||
pub(crate) fn clamp_float(x: f32) -> f32 {
|
||||
if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) }
|
||||
/// Preserve finite float samples, mapping NaN to 0 and infinities to their sign (±1).
|
||||
fn sanitize_float(x: f32) -> f32 {
|
||||
if x.is_finite() {
|
||||
x
|
||||
} else if x.is_nan() {
|
||||
0.0
|
||||
} else {
|
||||
x.signum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that a buffer is encodable: at least one channel and a non-zero sample rate.
|
||||
|
||||
+171
-86
@@ -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()),
|
||||
@@ -42,80 +44,164 @@ enum Opened {
|
||||
Empty(AudioInfo),
|
||||
}
|
||||
|
||||
/// 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<Opened> {
|
||||
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
|
||||
let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() };
|
||||
/// 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)?;
|
||||
if let Some(info) = crate::flac::empty_stream_info(bytes, at) {
|
||||
return Ok(Opened::Empty(info));
|
||||
}
|
||||
}
|
||||
Some(b"RIFF") => crate::wav::check_info(bytes, at)?,
|
||||
_ => {}
|
||||
}
|
||||
return reader(mss, &fmt_opts).map(Opened::Reader).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) {
|
||||
@@ -127,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,
|
||||
@@ -144,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,
|
||||
},
|
||||
))
|
||||
@@ -152,7 +241,7 @@ 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 = match 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);
|
||||
@@ -160,7 +249,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
}
|
||||
};
|
||||
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")));
|
||||
@@ -170,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) => {
|
||||
@@ -195,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) {
|
||||
@@ -211,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;
|
||||
}
|
||||
@@ -229,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 {
|
||||
@@ -254,7 +339,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
|
||||
|
||||
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
|
||||
guarded(|| {
|
||||
let reader = match open(bytes)? {
|
||||
let reader = match open(bytes, detected)? {
|
||||
Opened::Reader(reader) => reader,
|
||||
Opened::Empty(info) => return Ok(info),
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ const WAVE_FORMAT_IEEE_FLOAT: u16 = 3;
|
||||
const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE;
|
||||
/// Tail of the KSDATAFORMAT_SUBTYPE_* GUIDs (bytes 2..16); bytes 0..2 hold the format tag.
|
||||
const GUID_TAIL: [u8; 14] = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71];
|
||||
/// Tail of the Ambisonic subtype GUIDs {0000000x-0721-11D3-8644-C8C1CA000000}: PCM (x = 1) and float (x = 3) as well.
|
||||
const AMBISONIC_TAIL: [u8; 14] = [0x00, 0x00, 0x21, 0x07, 0xD3, 0x11, 0x86, 0x44, 0xC8, 0xC1, 0xCA, 0x00, 0x00, 0x00];
|
||||
const BEXT_MIN_LEN: usize = 602;
|
||||
|
||||
/// Parsed WAV layout (no sample data decoded yet).
|
||||
@@ -50,13 +52,25 @@ fn parse_fmt(c: &[u8]) -> Result<Fmt> {
|
||||
fmt.valid_bits = fmt.bits;
|
||||
if fmt.tag == WAVE_FORMAT_EXTENSIBLE {
|
||||
// cbSize(2) validBits(2) channelMask(4) subFormat GUID(16)
|
||||
if let (Some(valid), Some(sub)) = (le_u16(c, 18), le_u16(c, 24)) {
|
||||
if valid != 0 {
|
||||
fmt.valid_bits = valid;
|
||||
}
|
||||
fmt.tag = sub;
|
||||
} else {
|
||||
let (Some(cb_size), Some(valid), Some(sub)) = (le_u16(c, 16), le_u16(c, 18), le_u16(c, 24)) else {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into()));
|
||||
};
|
||||
let Some(tail) = c.get(26..40) else {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into()));
|
||||
};
|
||||
if cb_size < 22 {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE cbSize is below 22".into()));
|
||||
}
|
||||
if valid > fmt.bits {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE valid bits exceed the container size".into()));
|
||||
}
|
||||
if valid != 0 {
|
||||
fmt.valid_bits = valid;
|
||||
}
|
||||
// A subtype outside the KSDATAFORMAT_SUBTYPE_* family is not PCM/float, whatever its first two bytes say:
|
||||
// keep the extensible tag so the file goes to the foreign decoder.
|
||||
if tail == GUID_TAIL || tail == AMBISONIC_TAIL {
|
||||
fmt.tag = sub;
|
||||
}
|
||||
}
|
||||
Ok(fmt)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileEncoder, FileFormat, SampleFormat, decode, encode};
|
||||
use std::io::Cursor;
|
||||
|
||||
fn encoded(audio: &AudioBuffer, options: &EncodeOptions, streaming: bool) -> Vec<u8> {
|
||||
if !streaming {
|
||||
return encode(audio, options).unwrap();
|
||||
}
|
||||
let mut writer = FileEncoder::new(Cursor::new(Vec::new()), options, audio.num_channels(), audio.sample_rate, None).unwrap();
|
||||
// Cross the writer's 4096-frame scratch boundary, then write a short final block.
|
||||
for start in (0..audio.frames()).step_by(4097) {
|
||||
let end = (start + 4097).min(audio.frames());
|
||||
let block: Vec<Vec<f32>> = audio.channels.iter().map(|channel| channel[start..end].to_vec()).collect();
|
||||
writer.write(&block, end - start).unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn assert_float_headroom(streaming: bool) {
|
||||
let samples = [
|
||||
-f32::MAX,
|
||||
-2.0,
|
||||
-1.25,
|
||||
-1.0,
|
||||
-0.5,
|
||||
-f32::MIN_POSITIVE,
|
||||
-f32::from_bits(1),
|
||||
-0.0,
|
||||
0.0,
|
||||
f32::from_bits(1),
|
||||
f32::MIN_POSITIVE,
|
||||
0.5,
|
||||
1.0,
|
||||
1.25,
|
||||
2.0,
|
||||
f32::MAX,
|
||||
];
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff] {
|
||||
for dither in [false, true] {
|
||||
for channels in [1, 2] {
|
||||
let audio = AudioBuffer {
|
||||
sample_rate: 48_000,
|
||||
channels: (0..channels).map(|c| samples.iter().copied().cycle().skip(c).take(4103).collect()).collect(),
|
||||
};
|
||||
let options = EncodeOptions { format, bit_depth: BitDepth::Float32, dither, bwf: None };
|
||||
let bytes = encoded(&audio, &options, streaming);
|
||||
let pcm: Vec<u8> =
|
||||
audio.interleaved().iter().flat_map(|v| if format == FileFormat::Wav { v.to_le_bytes() } else { v.to_be_bytes() }).collect();
|
||||
assert!(bytes.ends_with(&pcm), "{format:?}, dither={dither}: stored sample bytes changed");
|
||||
let (info, decoded) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(info.sample_format, SampleFormat::Float32);
|
||||
assert_eq!((decoded.sample_rate, decoded.num_channels(), decoded.frames()), (48_000, channels, 4103));
|
||||
for (channel, (actual, expected)) in decoded.channels.iter().zip(&audio.channels).enumerate() {
|
||||
for (frame, (actual, expected)) in actual.iter().zip(expected).enumerate() {
|
||||
assert_eq!(actual.to_bits(), expected.to_bits(), "{format:?}, dither={dither}, channel={channel}, frame={frame}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_file_float32_preserves_finite_headroom() {
|
||||
assert_float_headroom(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_float32_preserves_finite_headroom() {
|
||||
assert_float_headroom(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float32_keeps_non_finite_sample_handling() {
|
||||
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![f32::NAN, f32::NEG_INFINITY, f32::INFINITY]] };
|
||||
let expected = [0.0_f32, -1.0, 1.0];
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff] {
|
||||
for streaming in [false, true] {
|
||||
let options = EncodeOptions { format, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = encoded(&audio, &options, streaming);
|
||||
// Inspect stored samples too: the decoder itself replaces non-finite values.
|
||||
let pcm: Vec<u8> = expected.iter().flat_map(|v| if format == FileFormat::Wav { v.to_le_bytes() } else { v.to_be_bytes() }).collect();
|
||||
assert!(bytes.ends_with(&pcm), "{format:?}, streaming={streaming}");
|
||||
assert_eq!(decode(&bytes, None).unwrap().1.channels[0], expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_pcm_still_clips_at_full_scale() {
|
||||
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![-2.0, -1.25, -1.0, -0.5, 0.0, 0.5, 1.0, 1.25, 2.0]] };
|
||||
for (depth, max) in [(BitDepth::Int16, 32767.0 / 32768.0), (BitDepth::Int24, 8_388_607.0 / 8_388_608.0), (BitDepth::Int32, 1.0)] {
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||
for streaming in [false, true] {
|
||||
let options = EncodeOptions { format, bit_depth: depth, dither: false, bwf: None };
|
||||
let bytes = encoded(&audio, &options, streaming);
|
||||
let (_, decoded) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(decoded.channels[0], [-1.0, -1.0, -1.0, -0.5, 0.0, 0.5, max, max, max], "{format:?} {depth:?}, streaming={streaming}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
mod encode_format;
|
||||
mod float_headroom;
|
||||
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() })]
|
||||
|
||||
@@ -485,3 +511,41 @@ proptest! {
|
||||
prop_assert_eq!(flac, wav);
|
||||
}
|
||||
}
|
||||
|
||||
/// An extensible `fmt ` chunk: cbSize, valid bits, mask, then the 16-byte subtype GUID.
|
||||
fn extensible_fmt(cb_size: u16, valid_bits: u16, guid: &[u8]) -> Vec<u8> {
|
||||
let mut f = fmt(0xFFFE, 1, 44_100, 16);
|
||||
f.extend_from_slice(&cb_size.to_le_bytes());
|
||||
f.extend_from_slice(&valid_bits.to_le_bytes());
|
||||
f.extend_from_slice(&4u32.to_le_bytes());
|
||||
f.extend_from_slice(guid);
|
||||
f
|
||||
}
|
||||
|
||||
const PCM_GUID: [u8; 16] = [1, 0, 0, 0, 0, 0, 0x10, 0, 0x80, 0, 0, 0xAA, 0, 0x38, 0x9B, 0x71];
|
||||
|
||||
#[test]
|
||||
fn extensible_fmt_is_checked_in_full() {
|
||||
let data = vec![0u8; 8];
|
||||
let good = riff(&[(b"fmt ", extensible_fmt(22, 16, &PCM_GUID)), (b"data", data.clone())]);
|
||||
assert_eq!(probe(&good, None).unwrap().sample_format, SampleFormat::Int16);
|
||||
// Subtype GUID cut short after the format code.
|
||||
let cut = riff(&[(b"fmt ", extensible_fmt(22, 16, &PCM_GUID[..4])), (b"data", data.clone())]);
|
||||
assert!(matches!(probe(&cut, None), Err(AudioError::Malformed(_))));
|
||||
assert!(matches!(decode(&cut, None), Err(AudioError::Malformed(_))));
|
||||
// cbSize too small for the extension.
|
||||
let small_cb = riff(&[(b"fmt ", extensible_fmt(0, 16, &PCM_GUID)), (b"data", data.clone())]);
|
||||
assert!(matches!(probe(&small_cb, None), Err(AudioError::Malformed(_))));
|
||||
// More valid bits than the 16-bit container holds.
|
||||
let wide = riff(&[(b"fmt ", extensible_fmt(22, 24, &PCM_GUID)), (b"data", data.clone())]);
|
||||
assert!(matches!(probe(&wide, None), Err(AudioError::Malformed(_))));
|
||||
// PCM-like prefix but a foreign GUID tail is not read as native PCM.
|
||||
let mut foreign = PCM_GUID;
|
||||
foreign[15] ^= 0xFF;
|
||||
let foreign = riff(&[(b"fmt ", extensible_fmt(22, 16, &foreign)), (b"data", data)]);
|
||||
assert!(!matches!(probe(&foreign, None), Ok(i) if i.sample_format == SampleFormat::Int16));
|
||||
// The Ambisonic PCM subtype is PCM.
|
||||
let ambi = [1, 0, 0, 0, 0x21, 0x07, 0xD3, 0x11, 0x86, 0x44, 0xC8, 0xC1, 0xCA, 0, 0, 0];
|
||||
let ambi = riff(&[(b"fmt ", extensible_fmt(22, 16, &ambi)), (b"data", vec![0u8; 8])]);
|
||||
assert_eq!(probe(&ambi, None).unwrap().sample_format, SampleFormat::Int16);
|
||||
}
|
||||
@@ -205,7 +205,7 @@ fn encode_rejects_bad_buffers_and_formats() {
|
||||
for f in [FileFormat::Mp3, FileFormat::Ogg, FileFormat::Aac, FileFormat::Alac, FileFormat::Caf, FileFormat::Other] {
|
||||
assert!(encode(&sine(48_000, 1, 10), &EncodeOptions { format: f, ..EncodeOptions::default() }).is_err());
|
||||
}
|
||||
// Non-finite and out-of-range samples are clamped, not errors.
|
||||
// Non-finite samples are sanitized; only integer output clips finite samples.
|
||||
let mut b = AudioBuffer::new(48_000, 1, 4);
|
||||
b.channels[0] = vec![f32::NAN, f32::INFINITY, -5.0, 0.5];
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||
@@ -217,7 +217,7 @@ fn encode_rejects_bad_buffers_and_formats() {
|
||||
let (_, out) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(out.channels[0][0], 0.0);
|
||||
assert!(out.channels[0][1] > 0.99 && out.channels[0][1] <= 1.0);
|
||||
assert_eq!(out.channels[0][2], -1.0);
|
||||
assert_eq!(out.channels[0][2], if depth == BitDepth::Float32 { -5.0 } else { -1.0 });
|
||||
}
|
||||
}
|
||||
// Ragged buffers encode the common length.
|
||||
@@ -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,9 +97,19 @@ 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)));
|
||||
@@ -179,6 +189,14 @@ mod tests {
|
||||
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>) {
|
||||
|
||||
+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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -340,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 };
|
||||
@@ -354,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).
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::OnceLock;
|
||||
pub fn fixture_dir() -> &'static Path {
|
||||
static DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixture/Cargo.toml");
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../clap-host/tests/fixture/Cargo.toml");
|
||||
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("clap-fixture-target");
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let st = Command::new(cargo)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# A tiny CLAP plugin used only by soundcraft-clap-host's integration tests. Not part of the
|
||||
# A tiny CLAP plugin used only by SoundCraft's integration tests. Not part of the
|
||||
# SoundCraft workspace (the empty [workspace] table makes it its own root); the test builds it
|
||||
# with `cargo build` and loads the resulting library. Never shipped.
|
||||
[package]
|
||||
|
||||
@@ -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";
|
||||
@@ -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,6 +272,11 @@ 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) {}
|
||||
|
||||
+196
-5
@@ -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) });
|
||||
@@ -216,16 +226,95 @@ fn resample_ratio(ch: &[Vec<f32>], ratio: f64, out_len: usize) -> Vec<Vec<f32>>
|
||||
ch.iter().map(|c| resample_channel(c, ratio, out_len, &table)).collect()
|
||||
}
|
||||
|
||||
/// Most kernel weights a [`Polyphase`] bank may hold (16 MiB of f32).
|
||||
const MAX_POLYPHASE_WEIGHTS: usize = 1 << 22;
|
||||
|
||||
/// The windowed-sinc kernel of [`resample_channel`] precomputed for every phase of a rational
|
||||
/// rate change `from` → `to` (reduced to `step` / `phases`). Output sample `n` reads input time
|
||||
/// `n * step / phases`: whole part `i`, phase `p`. Its taps are input samples `i + first ..`, one
|
||||
/// weight each, so every output is one dot product over contiguous memory instead of a table
|
||||
/// lookup and interpolation per tap. The weights are the ones `resample_channel` computes, taps
|
||||
/// beyond the kernel's half width weigh 0, and taps are summed in the same order.
|
||||
struct Polyphase {
|
||||
phases: u64,
|
||||
step: u64,
|
||||
/// Offset of the first tap from `i`.
|
||||
first: i64,
|
||||
taps: usize,
|
||||
/// `phases * taps` weights, phase-major.
|
||||
weights: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Polyphase {
|
||||
/// `None` when the bank would be too large (rates with no small common ratio); the caller
|
||||
/// then falls back to [`resample_channel`].
|
||||
fn new(from: u32, to: u32, table: &SincTable) -> Option<Polyphase> {
|
||||
let g = gcd(from, to);
|
||||
let (step, phases) = (u64::from(from / g), u64::from(to / g));
|
||||
let hw = table.half_width;
|
||||
let first = (-hw).floor() as i64;
|
||||
let last = hw.ceil() as i64 + 1;
|
||||
let taps = usize::try_from(last - first + 1).ok()?;
|
||||
if usize::try_from(phases).ok()?.checked_mul(taps)? > MAX_POLYPHASE_WEIGHTS {
|
||||
return None;
|
||||
}
|
||||
let mut weights = Vec::with_capacity(taps * phases as usize);
|
||||
for p in 0..phases {
|
||||
let frac = p as f64 / phases as f64;
|
||||
for k in first..first + taps as i64 {
|
||||
let d = frac - k as f64;
|
||||
weights.push(if d.abs() <= hw { table.at(d) } else { 0.0 });
|
||||
}
|
||||
}
|
||||
Some(Polyphase { phases, step, first, taps, weights })
|
||||
}
|
||||
|
||||
fn run(&self, x: &[f32], out_len: usize) -> Vec<f32> {
|
||||
let len = x.len() as i64;
|
||||
let mut out = Vec::with_capacity(out_len);
|
||||
for n in 0..out_len as u64 {
|
||||
let Some(pos) = n.checked_mul(self.step) else { break };
|
||||
let (i, p) = ((pos / self.phases) as i64, (pos % self.phases) as usize);
|
||||
let w = self.weights.get(p * self.taps..(p + 1) * self.taps).unwrap_or_default();
|
||||
let start = i + self.first;
|
||||
let acc = match usize::try_from(start).ok().and_then(|s| x.get(s..s + self.taps)) {
|
||||
// Inside the signal: a plain dot product.
|
||||
Some(xs) => xs.iter().zip(w).fold(0.0f32, |a, (v, k)| a + v * k),
|
||||
// At the edges, taps outside the signal are skipped, as `resample_channel` does.
|
||||
None => (0..self.taps as i64)
|
||||
.filter(|k| (0..len).contains(&(start + k)))
|
||||
.fold(0.0f32, |a, k| a + x.get((start + k) as usize).copied().unwrap_or(0.0) * w.get(k as usize).copied().unwrap_or(0.0)),
|
||||
};
|
||||
out.push(acc);
|
||||
}
|
||||
out.resize(out_len, 0.0);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn gcd(mut a: u32, mut b: u32) -> u32 {
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
a.max(1)
|
||||
}
|
||||
|
||||
/// High-quality windowed-sinc sample-rate conversion from `from` Hz to `to` Hz.
|
||||
pub fn resample(ch: &[Vec<f32>], from: u32, to: u32) -> Vec<Vec<f32>> {
|
||||
if from == 0 || to == 0 || from == to {
|
||||
return ch.to_vec();
|
||||
}
|
||||
let ratio = f64::from(to) / f64::from(from);
|
||||
let cutoff = if ratio < 1.0 { ratio * 0.94 } else { 0.97 };
|
||||
let table = SincTable::new(cutoff);
|
||||
let bank = Polyphase::new(from, to, &table);
|
||||
ch.iter()
|
||||
.map(|c| {
|
||||
let out_len = ((c.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES);
|
||||
resample_ratio(std::slice::from_ref(c), ratio, out_len).into_iter().next().unwrap_or_default()
|
||||
match &bank {
|
||||
Some(b) => b.run(c, out_len),
|
||||
None => resample_channel(c, ratio, out_len, &table),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -363,6 +452,78 @@ pub fn pitch_shift(ch: &[Vec<f32>], semitones: f32, sample_rate: f32) -> Vec<Vec
|
||||
out
|
||||
}
|
||||
|
||||
/// Which way [`speed_ramp`] ramps the playback speed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SpeedRampChange {
|
||||
/// From normal speed down to a complete stop.
|
||||
SlowDown,
|
||||
/// From a complete stop up to normal speed.
|
||||
SpeedUp,
|
||||
}
|
||||
|
||||
/// How much of the input [`speed_ramp`] plays, and how long the result is.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SpeedRampSelection {
|
||||
/// The result is as long as the input. Two thirds of the input are played: the first two
|
||||
/// thirds for Slow Down, the last two thirds for Speed Up, so each joins its neighbour
|
||||
/// without a jump.
|
||||
FitTo,
|
||||
/// All of the input is played, and the result is 150 % of its length.
|
||||
Extend,
|
||||
}
|
||||
|
||||
/// Plays `ch` with the speed ramping between normal speed and a complete stop, so the pitch follows
|
||||
/// the speed. The speed is `1 - u²` for Slow Down and `1 - (1 - u)²` for Speed Up, where `u`
|
||||
/// runs from 0 to 1 over the result; both curves average 2/3 of normal speed. With `fades`, the
|
||||
/// gain follows the same curve: a fade-out for Slow Down and a fade-in for Speed Up. Every channel
|
||||
/// is read at the same positions, and a channel shorter than the others is silent past its end.
|
||||
pub fn speed_ramp(ch: &[Vec<f32>], change: SpeedRampChange, selection: SpeedRampSelection, fades: bool) -> Vec<Vec<f32>> {
|
||||
let len = max_len(ch) as f64;
|
||||
let (total, first, played) = match selection {
|
||||
SpeedRampSelection::Extend => ((len * 1.5).round(), 0.0, len),
|
||||
SpeedRampSelection::FitTo => {
|
||||
let played = len * 2.0 / 3.0;
|
||||
(len, if change == SpeedRampChange::SlowDown { 0.0 } else { len - played }, played)
|
||||
}
|
||||
};
|
||||
let out_len = (total as usize).min(MAX_OFFLINE_SAMPLES);
|
||||
let table = SincTable::new(0.97);
|
||||
let hw = table.half_width;
|
||||
// The fraction of the played input reached at `u`, and the speed there (0 to 1).
|
||||
let at = |u: f64| -> (f64, f64) {
|
||||
match change {
|
||||
SpeedRampChange::SlowDown => ((3.0 * u - u * u * u) / 2.0, 1.0 - u * u),
|
||||
SpeedRampChange::SpeedUp => {
|
||||
let r = 1.0 - u;
|
||||
(1.0 - (3.0 * r - r * r * r) / 2.0, 1.0 - r * r)
|
||||
}
|
||||
}
|
||||
};
|
||||
// The interpolation reads the first or last sample of the input for taps before or after it.
|
||||
let last = (len as i64 - 1).max(0);
|
||||
ch.iter()
|
||||
.map(|x| {
|
||||
(0..out_len)
|
||||
.map(|n| {
|
||||
let (pos, speed) = at(n as f64 / total);
|
||||
let t = first + played * pos;
|
||||
let lo = (t - hw).ceil() as i64;
|
||||
let hi = (t + hw).floor() as i64;
|
||||
let mut acc = 0.0f32;
|
||||
let mut j = lo;
|
||||
while j <= hi {
|
||||
if let Some(&v) = x.get(j.clamp(0, last) as usize) {
|
||||
acc += v * table.at(t - j as f64);
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if fades { acc * speed as f32 } else { acc }
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Generators and analysis
|
||||
|
||||
@@ -652,6 +813,36 @@ pub fn peak_db(ch: &[Vec<f32>]) -> f32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The polyphase bank gives what the per-tap kernel gives (it only moves where the weights
|
||||
/// are computed), at common rate pairs, both directions, and at the signal's edges.
|
||||
#[test]
|
||||
fn polyphase_resample_matches_the_per_tap_kernel() {
|
||||
let x: Vec<f32> = (0..3_000).map(|i| (i as f32 * 0.071).sin() * 0.6 + (i as f32 * 0.53).sin() * 0.3).collect();
|
||||
let pairs = [(44_100, 48_000), (48_000, 44_100), (48_000, 96_000), (96_000, 48_000), (22_050, 48_000), (8_000, 44_100), (48_000, 22_050)];
|
||||
for (from, to) in pairs {
|
||||
let ratio = f64::from(to) / f64::from(from);
|
||||
let out_len = ((x.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES);
|
||||
let cutoff = if ratio < 1.0 { ratio * 0.94 } else { 0.97 };
|
||||
let table = SincTable::new(cutoff);
|
||||
let reference = resample_channel(&x, ratio, out_len, &table);
|
||||
let bank = Polyphase::new(from, to, &table).expect("small common ratio");
|
||||
let fast = bank.run(&x, out_len);
|
||||
assert_eq!(fast.len(), reference.len(), "{from}->{to}");
|
||||
let worst = fast.iter().zip(&reference).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
assert!(worst < 1e-5, "{from}->{to}: worst difference {worst}");
|
||||
assert_eq!(resample(std::slice::from_ref(&x), from, to)[0], fast, "resample uses the bank");
|
||||
}
|
||||
// Signals shorter than the kernel are all edge.
|
||||
let short = [0.5f32, -0.25, 0.75];
|
||||
let table = SincTable::new(0.97);
|
||||
let reference = resample_channel(&short, 48_000.0 / 44_100.0, 3, &table);
|
||||
let fast = Polyphase::new(44_100, 48_000, &table).expect("bank").run(&short, 3);
|
||||
assert!(fast.iter().zip(&reference).all(|(a, b)| (a - b).abs() < 1e-6), "{fast:?} {reference:?}");
|
||||
// Rates with no small common ratio fall back to the per-tap kernel.
|
||||
assert!(Polyphase::new(44_100, 96_001, &table).is_none());
|
||||
assert_eq!(resample(&[short.to_vec()], 44_100, 96_001)[0].len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fade_shapes_endpoints() {
|
||||
for s in FadeShape::ALL {
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
//! Auto Pan: moves a signal between the left and right channels with an LFO or an envelope.
|
||||
|
||||
use std::f32::consts::{FRAC_PI_4, PI, SQRT_2, TAU};
|
||||
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
|
||||
use crate::util::{Smoother, frames_in, ms_to_samples, sane_channels, sane_sr, time_coef};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Transport, Unit, db_to_gain, gain_to_db};
|
||||
|
||||
const SOURCE_CHOICES: &[&str] = &["LFO", "Envelope"];
|
||||
const PATH_CHOICES: &[&str] = &["Left to Right", "Right to Left"];
|
||||
const WAVEFORM_CHOICES: &[&str] = &["Sine", "Triangle", "Square", "Saw Up", "Saw Down", "Half Sine", "4 Step Triangle"];
|
||||
const TRIGGER_CHOICES: &[&str] = &["Free", "Single", "Beat Clock", "Envelope", "Manual"];
|
||||
const DURATION_CHOICES: &[&str] = &["16th Note", "8th Note", "1 Beat", "2 Beats", "1 Bar", "2 Bars", "4 Bars"];
|
||||
|
||||
const SOURCE_ENVELOPE: usize = 1;
|
||||
const PATH_RIGHT_TO_LEFT: usize = 1;
|
||||
const TRIGGER_FREE: usize = 0;
|
||||
const TRIGGER_SINGLE: usize = 1;
|
||||
const TRIGGER_BEAT_CLOCK: usize = 2;
|
||||
const TRIGGER_ENVELOPE: usize = 3;
|
||||
const TRIGGER_MANUAL: usize = 4;
|
||||
|
||||
static AUTOPAN_PARAMS: [ParamInfo; 14] = [
|
||||
lin("output", "Output", -24.0, 12.0, 0.0, Unit::Db),
|
||||
lin("width", "Width", 0.0, 100.0, 100.0, Unit::Percent),
|
||||
lin("manual", "Manual", -100.0, 100.0, 0.0, Unit::Percent),
|
||||
choice("source", "Source", SOURCE_CHOICES, 0),
|
||||
choice("path", "Path", PATH_CHOICES, 0),
|
||||
log("rate", "Rate (BPM)", 1.0, 960.0, 120.0, Unit::None),
|
||||
choice("waveform", "Waveform", WAVEFORM_CHOICES, 0),
|
||||
choice("trigger", "Trigger", TRIGGER_CHOICES, 0),
|
||||
toggle("retrigger", "Manual Trigger", false),
|
||||
toggle("link_tempo", "Link to Tempo", false),
|
||||
choice("duration", "Duration", DURATION_CHOICES, 4),
|
||||
lin("threshold", "Threshold", -60.0, -1.0, -30.0, Unit::Db),
|
||||
log("attack", "Attack", 0.1, 100.0, 5.0, Unit::Ms),
|
||||
log("release", "Release", 5.0, 2000.0, 150.0, Unit::Ms),
|
||||
];
|
||||
|
||||
pub static AUTOPAN_INFO: PluginInfo = PluginInfo {
|
||||
id: "auto_pan",
|
||||
name: "Auto Pan",
|
||||
short_name: "Auto Pan",
|
||||
category: Category::Modulation,
|
||||
params: &AUTOPAN_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// The LFO shape at `t` in 0..=1 of a cycle, from -1 (the start of the path) to 1 (its end).
|
||||
fn wave_at(waveform: usize, t: f32) -> f32 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let tri = if t < 0.5 { 4.0 * t - 1.0 } else { 3.0 - 4.0 * t };
|
||||
match waveform {
|
||||
1 => tri,
|
||||
2 => {
|
||||
if t < 0.5 {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
3 => 2.0 * t - 1.0,
|
||||
4 => 1.0 - 2.0 * t,
|
||||
5 => 2.0 * (PI * t).sin() - 1.0,
|
||||
6 => {
|
||||
let step = ((tri + 1.0) * 2.0).floor().clamp(0.0, 3.0);
|
||||
step * 2.0 / 3.0 - 1.0
|
||||
}
|
||||
_ => -(TAU * t).cos(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Length of one Duration choice in quarter notes, for a bar of `bar` quarter notes.
|
||||
fn duration_quarters(duration: usize, bar: f64) -> f64 {
|
||||
match duration {
|
||||
0 => 0.25,
|
||||
1 => 0.5,
|
||||
2 => 1.0,
|
||||
3 => 2.0,
|
||||
5 => 2.0 * bar,
|
||||
6 => 4.0 * bar,
|
||||
_ => bar,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of bars in a bar-based Duration choice.
|
||||
fn duration_bars(duration: usize) -> Option<i64> {
|
||||
match duration {
|
||||
4 => Some(1),
|
||||
5 => Some(2),
|
||||
6 => Some(4),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pans the mono sum of the first two channels between them. The pan position is
|
||||
/// `manual · (1 - width) + modulator · width`, where the modulator is the LFO or the envelope
|
||||
/// follower in -1..=1. A signal that is the same on both channels keeps its level at the center.
|
||||
pub struct AutoPan {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
ch: usize,
|
||||
transport: Transport,
|
||||
/// Samples processed since the last `set_transport`, for hosts that call it less than once a block.
|
||||
since_transport: u64,
|
||||
was_playing: bool,
|
||||
/// LFO phase in cycles, in `f64`: at the slowest rates an `f32` step is below half an ULP.
|
||||
phase: f64,
|
||||
/// A Single, Envelope or Manual trigger has finished its cycle and holds the last position.
|
||||
holding: bool,
|
||||
env: f32,
|
||||
armed: bool,
|
||||
/// Samples the envelope has spent below the threshold since the last Envelope trigger. The
|
||||
/// trigger re-arms once this reaches the Release time, so a longer Release spaces triggers out.
|
||||
below: u64,
|
||||
last_retrigger: bool,
|
||||
last_trigger: usize,
|
||||
output: Smoother,
|
||||
width: Smoother,
|
||||
manual: Smoother,
|
||||
/// Short declicking smoother on the pan position.
|
||||
position: Smoother,
|
||||
/// The next sample starts the position at its target instead of gliding to it.
|
||||
snap_position: bool,
|
||||
}
|
||||
|
||||
impl Default for AutoPan {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AutoPan {
|
||||
pub fn new() -> Self {
|
||||
let mut a = Self {
|
||||
p: Params::new(&AUTOPAN_PARAMS),
|
||||
sr: 48_000.0,
|
||||
ch: 0,
|
||||
transport: Transport::default(),
|
||||
since_transport: 0,
|
||||
was_playing: false,
|
||||
phase: 0.0,
|
||||
holding: false,
|
||||
env: 0.0,
|
||||
armed: true,
|
||||
below: 0,
|
||||
last_retrigger: false,
|
||||
last_trigger: TRIGGER_FREE,
|
||||
output: Smoother::new(1.0),
|
||||
width: Smoother::new(1.0),
|
||||
manual: Smoother::new(0.0),
|
||||
position: Smoother::new(0.0),
|
||||
snap_position: true,
|
||||
};
|
||||
a.update();
|
||||
a.restart();
|
||||
a
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.output.set(db_to_gain(self.p.v(0)));
|
||||
self.width.set(self.p.v(1) / 100.0);
|
||||
self.manual.set(self.p.v(2) / 100.0);
|
||||
let trigger = self.p.choice(7);
|
||||
if trigger != self.last_trigger {
|
||||
self.last_trigger = trigger;
|
||||
self.restart();
|
||||
}
|
||||
let retrigger = self.p.on(8);
|
||||
if retrigger != self.last_retrigger {
|
||||
self.last_retrigger = retrigger;
|
||||
if trigger == TRIGGER_MANUAL {
|
||||
self.phase = 0.0;
|
||||
self.holding = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the LFO back at the start of the path. Single runs one cycle from there; Envelope and
|
||||
/// Manual wait at the start for their first trigger.
|
||||
fn restart(&mut self) {
|
||||
self.phase = 0.0;
|
||||
self.holding = matches!(self.p.choice(7), TRIGGER_ENVELOPE | TRIGGER_MANUAL);
|
||||
self.armed = true;
|
||||
self.below = 0;
|
||||
}
|
||||
|
||||
/// LFO frequency in cycles per second.
|
||||
fn lfo_hz(&self) -> f64 {
|
||||
let tempo = if self.p.on(9) && self.transport.tempo.is_finite() && self.transport.tempo > 0.0 {
|
||||
self.transport.tempo
|
||||
} else {
|
||||
f64::from(self.p.v(5))
|
||||
};
|
||||
tempo / 60.0 / duration_quarters(self.p.choice(10), self.bar_quarters())
|
||||
}
|
||||
|
||||
fn bar_quarters(&self) -> f64 {
|
||||
let (num, den) = (self.transport.numerator, self.transport.denominator);
|
||||
if num == 0 || den == 0 { 4.0 } else { (f64::from(num) * 4.0 / f64::from(den)).clamp(0.25, 64.0) }
|
||||
}
|
||||
|
||||
/// The LFO phase at `n` samples into the block when Beat Clock follows a playing transport.
|
||||
/// Beat Clock restarts the LFO at the start of every Duration and runs it at Rate in between,
|
||||
/// so a Rate slower than the session tempo plays only part of each cycle before the restart.
|
||||
fn beat_clock_phase(&self, n: usize, hz: f64) -> Option<f64> {
|
||||
let t = &self.transport;
|
||||
if !(t.playing && t.tempo.is_finite() && t.tempo > 0.0 && t.quarters.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let duration = self.p.choice(10);
|
||||
let bar = self.bar_quarters();
|
||||
let dur = duration_quarters(duration, bar);
|
||||
let elapsed = self.since_transport.saturating_add(n as u64) as f64;
|
||||
let quarters = t.quarters + elapsed * t.tempo / 60.0 / f64::from(self.sr);
|
||||
// Bar-based cycles start on the bar line, so a change of time signature does not shift them;
|
||||
// note and beat cycles follow the song position in quarter notes.
|
||||
let into = match duration_bars(duration) {
|
||||
Some(bars) => {
|
||||
let in_bar = if t.bar_start.is_finite() { quarters - t.bar_start } else { quarters };
|
||||
((t.bar.rem_euclid(bars) as f64 + in_bar / bar) / bars as f64).rem_euclid(1.0)
|
||||
}
|
||||
None => (quarters / dur).rem_euclid(1.0),
|
||||
};
|
||||
// Cycles of the LFO per Duration: 1 when its rate matches the session tempo.
|
||||
let ratio = hz * dur * 60.0 / t.tempo;
|
||||
Some((into * ratio).rem_euclid(1.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for AutoPan {
|
||||
param_plumbing!(AUTOPAN_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.ch = sane_channels(channels);
|
||||
for s in [&mut self.output, &mut self.width, &mut self.manual] {
|
||||
s.set_time(20.0, self.sr);
|
||||
}
|
||||
self.position.set_time(2.0, self.sr);
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.restart();
|
||||
self.env = 0.0;
|
||||
for s in [&mut self.output, &mut self.width, &mut self.manual] {
|
||||
s.snap();
|
||||
}
|
||||
self.snap_position = true;
|
||||
}
|
||||
|
||||
fn set_transport(&mut self, transport: &Transport) {
|
||||
self.transport = *transport;
|
||||
self.since_transport = 0;
|
||||
if transport.playing && !self.was_playing && self.p.choice(7) == TRIGGER_SINGLE {
|
||||
self.restart();
|
||||
}
|
||||
self.was_playing = transport.playing;
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.ch.min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let hz = self.lfo_hz();
|
||||
let inc = if hz.is_finite() { (hz / f64::from(self.sr)).clamp(0.0, 0.5) } else { 0.0 };
|
||||
let waveform = self.p.choice(6);
|
||||
let trigger = self.p.choice(7);
|
||||
let envelope_source = self.p.choice(3) == SOURCE_ENVELOPE;
|
||||
let sign = if self.p.choice(4) == PATH_RIGHT_TO_LEFT { -1.0 } else { 1.0 };
|
||||
let threshold_db = self.p.v(11);
|
||||
let threshold = db_to_gain(threshold_db);
|
||||
let attack = time_coef(self.p.v(12), self.sr);
|
||||
let release = time_coef(self.p.v(13), self.sr);
|
||||
let release_samples = ms_to_samples(self.p.v(13), self.sr) as u64;
|
||||
for n in 0..frames {
|
||||
let sample = |io: &[Vec<f32>], c: usize| io.get(c).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let mono = if ch >= 2 { 0.5 * (sample(io, 0) + sample(io, 1)) } else { sample(io, 0) };
|
||||
let level = mono.abs();
|
||||
let k = if level > self.env { attack } else { release };
|
||||
self.env = level + (self.env - level) * k;
|
||||
if self.env < 1.0e-12 {
|
||||
self.env = 0.0;
|
||||
}
|
||||
if trigger == TRIGGER_ENVELOPE {
|
||||
if self.armed && self.env >= threshold {
|
||||
self.armed = false;
|
||||
self.phase = 0.0;
|
||||
self.holding = false;
|
||||
self.below = 0;
|
||||
} else if !self.armed {
|
||||
if self.env < threshold {
|
||||
self.below = self.below.saturating_add(1);
|
||||
self.armed = self.below >= release_samples;
|
||||
} else {
|
||||
self.below = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let lfo_phase = match (trigger == TRIGGER_BEAT_CLOCK).then(|| self.beat_clock_phase(n, hz)).flatten() {
|
||||
Some(phase) => phase,
|
||||
None if self.holding => self.phase,
|
||||
None => {
|
||||
let current = self.phase;
|
||||
self.phase += inc;
|
||||
if self.phase >= 1.0 {
|
||||
if matches!(trigger, TRIGGER_FREE | TRIGGER_BEAT_CLOCK) {
|
||||
self.phase -= 1.0;
|
||||
} else {
|
||||
self.phase = 1.0;
|
||||
self.holding = true;
|
||||
}
|
||||
}
|
||||
current
|
||||
}
|
||||
};
|
||||
let modulator = if envelope_source {
|
||||
let above = (gain_to_db(self.env) - threshold_db) / -threshold_db;
|
||||
2.0 * above.clamp(0.0, 1.0) - 1.0
|
||||
} else {
|
||||
wave_at(waveform, lfo_phase as f32)
|
||||
};
|
||||
let width = self.width.next();
|
||||
let manual = self.manual.next();
|
||||
self.position.set(manual * (1.0 - width) + sign * modulator * width);
|
||||
if self.snap_position {
|
||||
self.snap_position = false;
|
||||
self.position.snap();
|
||||
}
|
||||
let position = self.position.next();
|
||||
let out = self.output.next();
|
||||
if ch >= 2 {
|
||||
// Constant power: sin² + cos² = 1 across the field.
|
||||
let theta = (position.clamp(-1.0, 1.0) + 1.0) * FRAC_PI_4;
|
||||
let (l, r) = (theta.cos().max(0.0), theta.sin().max(0.0));
|
||||
if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
|
||||
*x = mono * l * SQRT_2 * out;
|
||||
}
|
||||
if let Some(x) = io.get_mut(1).and_then(|b| b.get_mut(n)) {
|
||||
*x = mono * r * SQRT_2 * out;
|
||||
}
|
||||
for c in 2..ch {
|
||||
if let Some(x) = io.get_mut(c).and_then(|b| b.get_mut(n)) {
|
||||
*x = if x.is_finite() { *x * out } else { 0.0 };
|
||||
}
|
||||
}
|
||||
} else if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
|
||||
*x = mono * out;
|
||||
}
|
||||
}
|
||||
self.since_transport = self.since_transport.saturating_add(frames as u64);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction) and Rectifier.
|
||||
//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction), Rectifier and
|
||||
//! Subharmonic (octave-down bass synthesis).
|
||||
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing};
|
||||
use crate::util::{DcBlocker, Rng, Smoother, flush, frames_in, sane_channels, sane_sr};
|
||||
use crate::biquad::{Biquad, FilterType};
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
|
||||
use crate::util::{DcBlocker, Rng, Smoother, flush, frames_in, sane_channels, sane_sr, time_coef};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit, db_to_gain};
|
||||
|
||||
static SAT_PARAMS: [ParamInfo; 5] = [
|
||||
@@ -307,3 +309,289 @@ impl Plugin for Rectifier {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
const SUB_RANGES: &[&str] = &["120-180 Hz", "80-120 Hz", "60-90 Hz", "40-60 Hz"];
|
||||
/// Lower and upper band frequency of each range.
|
||||
const SUB_RANGE_HZ: [(f32, f32); 4] = [(120.0, 180.0), (80.0, 120.0), (60.0, 90.0), (40.0, 60.0)];
|
||||
const SUB_CHARACTERS: &[&str] = &["Clean I", "Clean II", "Resonant I", "Resonant II", "Distort I", "Distort II"];
|
||||
/// Band gains at or below this are off.
|
||||
const SUB_OFF_DB: f32 = -120.0;
|
||||
/// Q of the band-pass that picks each band out of the input.
|
||||
const SUB_BAND_Q: f32 = 2.0;
|
||||
/// Cutoff of the low-pass after each divider, as a fraction of the band frequency.
|
||||
const SUB_SMOOTH: f32 = 0.75;
|
||||
/// Zero-crossing hysteresis, as a fraction of the band's envelope.
|
||||
const SUB_HYSTERESIS: f32 = 0.1;
|
||||
/// Release of the band envelope that sets the hysteresis.
|
||||
const SUB_ENV_RELEASE_MS: f32 = 50.0;
|
||||
/// Bias of the asymmetric Distort I curve.
|
||||
const SUB_ASYM: f32 = 0.25;
|
||||
|
||||
static SUB_PARAMS: [ParamInfo; 17] = [
|
||||
lin("input", "Input", -18.0, 18.0, 0.0, Unit::Db),
|
||||
choice("range", "Range", SUB_RANGES, 2),
|
||||
lin("lower_gain", "Lower Gain", SUB_OFF_DB, 24.0, 0.0, Unit::Db),
|
||||
lin("upper_gain", "Upper Gain", SUB_OFF_DB, 24.0, -6.0, Unit::Db),
|
||||
lin("direct_gain", "Direct Gain", SUB_OFF_DB, 24.0, SUB_OFF_DB, Unit::Db),
|
||||
toggle("lower_solo", "Lower Solo", false),
|
||||
toggle("upper_solo", "Upper Solo", false),
|
||||
toggle("direct_solo", "Direct Solo", false),
|
||||
lin("drive", "Drive", -18.0, 18.0, 0.0, Unit::Db),
|
||||
choice("character", "Character", SUB_CHARACTERS, 0),
|
||||
log("hp_freq", "HP Freq", 5.0, 1000.0, 20.0, Unit::Hz),
|
||||
log("hp_q", "HP Q", 0.1, 5.0, 0.707, Unit::None),
|
||||
log("lp_freq", "LP Freq", 5.0, 1000.0, 200.0, Unit::Hz),
|
||||
log("lp_q", "LP Q", 0.1, 5.0, 0.707, Unit::None),
|
||||
lin("mix", "Mix", 0.0, 100.0, 50.0, Unit::Percent),
|
||||
toggle("wet_solo", "Wet Solo", false),
|
||||
lin("output", "Output", -18.0, 18.0, 0.0, Unit::Db),
|
||||
];
|
||||
|
||||
pub static SUB_INFO: PluginInfo = PluginInfo {
|
||||
id: "subharmonic",
|
||||
name: "Subharmonic",
|
||||
short_name: "SubHarm",
|
||||
category: Category::Harmonic,
|
||||
params: &SUB_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// One synthesized band: a band-pass, a divide-by-two flip-flop and a smoothing low-pass.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct SubBand {
|
||||
band: Biquad,
|
||||
smooth: Biquad,
|
||||
/// Divider output: ±1, flipped on every rising zero crossing of the band.
|
||||
flip: f32,
|
||||
/// The band has gone below the negative threshold since the last flip.
|
||||
armed: bool,
|
||||
env: f32,
|
||||
}
|
||||
|
||||
impl SubBand {
|
||||
fn new() -> Self {
|
||||
Self { band: Biquad::default(), smooth: Biquad::default(), flip: 1.0, armed: false, env: 0.0 }
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.band.reset();
|
||||
self.smooth.reset();
|
||||
self.flip = 1.0;
|
||||
self.armed = false;
|
||||
self.env = 0.0;
|
||||
}
|
||||
|
||||
/// The octave below the band's part of `x`; `rel` is the envelope's release coefficient.
|
||||
#[inline]
|
||||
fn process(&mut self, x: f32, rel: f32) -> f32 {
|
||||
let b = self.band.process_sample(x);
|
||||
let a = b.abs();
|
||||
self.env = flush(if a > self.env { a } else { self.env * rel });
|
||||
let th = self.env * SUB_HYSTERESIS;
|
||||
if b < -th {
|
||||
self.armed = true;
|
||||
} else if self.armed && b > th {
|
||||
self.armed = false;
|
||||
self.flip = -self.flip;
|
||||
}
|
||||
self.smooth.process_sample(b * self.flip)
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter and divider state of one channel.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct SubChannel {
|
||||
/// The lower and the upper band.
|
||||
bands: [SubBand; 2],
|
||||
/// Low-pass that takes the direct bass out of the input.
|
||||
direct: Biquad,
|
||||
hp: Biquad,
|
||||
lp: Biquad,
|
||||
dc: DcBlocker,
|
||||
}
|
||||
|
||||
impl SubChannel {
|
||||
fn new(sr: f32) -> Self {
|
||||
Self { bands: [SubBand::new(); 2], direct: Biquad::default(), hp: Biquad::default(), lp: Biquad::default(), dc: DcBlocker::new(5.0, sr) }
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.bands.iter_mut().for_each(SubBand::reset);
|
||||
self.direct.reset();
|
||||
self.hp.reset();
|
||||
self.lp.reset();
|
||||
self.dc.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// Subharmonic synthesizer. The Range sets a lower and an upper band frequency. Each band
|
||||
/// band-passes the input at its frequency, and a divide-by-two flip-flop on the band's rising zero
|
||||
/// crossings multiplies the band, which moves it an octave down at a level that follows the band.
|
||||
/// The direct band is the input low-passed at the upper band frequency.
|
||||
///
|
||||
/// - The three bands are summed with their gains, raised by Drive and saturated, then
|
||||
/// shaped by the HP and LP filters. A soloed band plays alone and mutes the dry signal.
|
||||
/// - Clean I saturates with tanh and Clean II with a softer algebraic curve. Resonant I and II use
|
||||
/// tanh and raise the Q of the low-pass after each divider to 2 and 4. Distort I bends the curve
|
||||
/// asymmetrically (even harmonics, DC removed) and Distort II clips hard at full scale.
|
||||
/// - Below 50 % Mix the dry signal stays at full level and the wet level falls; above 50 % the wet
|
||||
/// signal stays at full level and the dry level falls. Wet Solo mutes the dry signal.
|
||||
pub struct SubHarmonic {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
ch: Vec<SubChannel>,
|
||||
input: Smoother,
|
||||
bands: [Smoother; 3],
|
||||
drive: Smoother,
|
||||
dry: Smoother,
|
||||
wet: Smoother,
|
||||
out: Smoother,
|
||||
env_rel: f32,
|
||||
}
|
||||
|
||||
impl Default for SubHarmonic {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SubHarmonic {
|
||||
pub fn new() -> Self {
|
||||
let mut s = Self {
|
||||
p: Params::new(&SUB_PARAMS),
|
||||
sr: 48_000.0,
|
||||
ch: Vec::new(),
|
||||
input: Smoother::new(1.0),
|
||||
bands: [Smoother::new(0.0); 3],
|
||||
drive: Smoother::new(1.0),
|
||||
dry: Smoother::new(1.0),
|
||||
wet: Smoother::new(1.0),
|
||||
out: Smoother::new(1.0),
|
||||
env_rel: 0.0,
|
||||
};
|
||||
s.update();
|
||||
s
|
||||
}
|
||||
|
||||
/// The lower and upper band frequencies of the current range.
|
||||
fn range_hz(&self) -> (f32, f32) {
|
||||
SUB_RANGE_HZ.get(self.p.choice(1)).copied().unwrap_or((60.0, 90.0))
|
||||
}
|
||||
|
||||
/// Q of the low-pass after each divider for the current Drive Character.
|
||||
fn smooth_q(&self) -> f32 {
|
||||
match self.p.choice(9) {
|
||||
2 => 2.0,
|
||||
3 => 4.0,
|
||||
_ => std::f32::consts::FRAC_1_SQRT_2,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
let gain = |db: f32| if db <= SUB_OFF_DB { 0.0 } else { db_to_gain(db) };
|
||||
let solo = [self.p.on(5), self.p.on(6), self.p.on(7)];
|
||||
let any_solo = solo.contains(&true);
|
||||
for (k, s) in self.bands.iter_mut().enumerate() {
|
||||
let heard = !any_solo || solo.get(k).copied().unwrap_or(false);
|
||||
s.set(if heard { gain(self.p.v(2 + k)) } else { 0.0 });
|
||||
}
|
||||
self.input.set(db_to_gain(self.p.v(0)));
|
||||
self.drive.set(db_to_gain(self.p.v(8)));
|
||||
self.out.set(db_to_gain(self.p.v(16)));
|
||||
let m = self.p.v(14) / 100.0;
|
||||
self.wet.set((2.0 * m).min(1.0));
|
||||
self.dry.set(if any_solo || self.p.on(15) { 0.0 } else { (2.0 * (1.0 - m)).min(1.0) });
|
||||
self.env_rel = time_coef(SUB_ENV_RELEASE_MS, self.sr);
|
||||
|
||||
let (lo, hi) = self.range_hz();
|
||||
let sq = self.smooth_q();
|
||||
let sr = self.sr;
|
||||
let (hp_f, hp_q, lp_f, lp_q) = (self.p.v(10), self.p.v(11), self.p.v(12), self.p.v(13));
|
||||
for c in &mut self.ch {
|
||||
for (b, f) in c.bands.iter_mut().zip([lo, hi]) {
|
||||
b.band.set(FilterType::BandPass, f, SUB_BAND_Q, 0.0, sr);
|
||||
b.smooth.set(FilterType::LowPass, f * SUB_SMOOTH, sq, 0.0, sr);
|
||||
}
|
||||
c.direct.set(FilterType::LowPass, hi, std::f32::consts::FRAC_1_SQRT_2, 0.0, sr);
|
||||
c.hp.set(FilterType::HighPass, hp_f, hp_q, 0.0, sr);
|
||||
c.lp.set(FilterType::LowPass, lp_f, lp_q, 0.0, sr);
|
||||
}
|
||||
}
|
||||
|
||||
fn smoothers(&mut self) -> impl Iterator<Item = &mut Smoother> {
|
||||
[&mut self.input, &mut self.drive, &mut self.dry, &mut self.wet, &mut self.out].into_iter().chain(self.bands.iter_mut())
|
||||
}
|
||||
}
|
||||
|
||||
/// Saturation curve of each Drive Character (index into `SUB_CHARACTERS`).
|
||||
fn sub_saturate(character: usize, x: f32) -> f32 {
|
||||
match character {
|
||||
1 => x / (1.0 + x * x).sqrt(),
|
||||
4 => ((x + SUB_ASYM).tanh() - SUB_ASYM.tanh()) / (1.0 + SUB_ASYM.tanh()),
|
||||
5 => x.clamp(-1.0, 1.0),
|
||||
_ => x.tanh(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for SubHarmonic {
|
||||
param_plumbing!(SUB_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
self.ch = vec![SubChannel::new(self.sr); sane_channels(channels)];
|
||||
let sr = self.sr;
|
||||
for s in self.smoothers() {
|
||||
s.set_time(20.0, sr);
|
||||
}
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ch.iter_mut().for_each(SubChannel::reset);
|
||||
for s in self.smoothers() {
|
||||
s.snap();
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.ch.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let character = self.p.choice(9);
|
||||
let rel = self.env_rel;
|
||||
for n in 0..frames {
|
||||
let gin = self.input.next();
|
||||
let g = [self.bands[0].next(), self.bands[1].next(), self.bands[2].next()];
|
||||
let drive = self.drive.next();
|
||||
let dg = self.dry.next();
|
||||
let wg = self.wet.next();
|
||||
let o = self.out.next();
|
||||
for c in 0..ch {
|
||||
let (Some(buf), Some(st)) = (io.get_mut(c), self.ch.get_mut(c)) else { continue };
|
||||
let Some(x) = buf.get_mut(n) else { continue };
|
||||
let dry = if x.is_finite() { *x * gin } else { 0.0 };
|
||||
let mut sum = st.direct.process_sample(dry) * g[2];
|
||||
for (band, gain) in st.bands.iter_mut().zip(g) {
|
||||
sum += band.process(dry, rel) * gain;
|
||||
}
|
||||
let mut y = sub_saturate(character, sum * drive);
|
||||
if character == 4 {
|
||||
y = st.dc.process(y);
|
||||
}
|
||||
let wet = st.lp.process_sample(st.hp.process_sample(y));
|
||||
*x = (dry * dg + wet * wg) * o;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
// Time for the slowest, most resonant filter to ring down by 60 dB (6.9 time constants).
|
||||
let (lo, _) = self.range_hz();
|
||||
let f = self.p.v(10).min(self.p.v(12)).min(lo * SUB_SMOOTH).max(1.0);
|
||||
let q = self.p.v(11).max(self.p.v(13)).max(self.smooth_q()).max(SUB_BAND_Q);
|
||||
(6.9 * q / (std::f32::consts::PI * f) * self.sr) as usize
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The built-in plugins and their registry.
|
||||
|
||||
pub mod auto_pan;
|
||||
pub mod drums;
|
||||
pub mod dynamics;
|
||||
pub mod eq;
|
||||
@@ -26,12 +27,15 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[
|
||||
&reverb::ROOM_INFO,
|
||||
&reverb::PLATE_INFO,
|
||||
&modulation::DELAY_INFO,
|
||||
&auto_pan::AUTOPAN_INFO,
|
||||
&modulation::CHORUS_INFO,
|
||||
&modulation::FLANGER_INFO,
|
||||
&modulation::PHASER_INFO,
|
||||
&modulation::RING_INFO,
|
||||
&harmonic::SAT_INFO,
|
||||
&harmonic::LOFI_INFO,
|
||||
&harmonic::RECT_INFO,
|
||||
&harmonic::SUB_INFO,
|
||||
&pitch::PITCH_INFO,
|
||||
&utility::SHIFT_INFO,
|
||||
&utility::GAIN_INFO,
|
||||
@@ -58,12 +62,15 @@ pub(crate) fn instantiate(id: &str) -> Option<Box<dyn Plugin>> {
|
||||
"room_reverb" => Box::new(reverb::Reverb::room()),
|
||||
"plate_reverb" => Box::new(reverb::Reverb::plate()),
|
||||
"mod_delay" => Box::new(modulation::ModDelay::new()),
|
||||
"auto_pan" => Box::new(auto_pan::AutoPan::new()),
|
||||
"chorus" => Box::new(modulation::ModFx::chorus()),
|
||||
"flanger" => Box::new(modulation::ModFx::flanger()),
|
||||
"phaser" => Box::new(modulation::Phaser::new()),
|
||||
"ring_mod" => Box::new(modulation::RingMod::new()),
|
||||
"saturator" => Box::new(harmonic::Saturator::new()),
|
||||
"lofi" => Box::new(harmonic::LoFi::new()),
|
||||
"rectifier" => Box::new(harmonic::Rectifier::new()),
|
||||
"subharmonic" => Box::new(harmonic::SubHarmonic::new()),
|
||||
"pitch_shifter" => Box::new(pitch::PitchShifter::new()),
|
||||
"time_shift" => Box::new(utility::TimeShift::new()),
|
||||
"gain" => Box::new(utility::Gain::new()),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Delay and modulation effects: Mod Delay, Chorus, Flanger, Phaser.
|
||||
//! Delay and modulation effects: Mod Delay, Chorus, Flanger, Phaser, Ring Modulator.
|
||||
|
||||
use crate::params::{Params, choice, lin, log, param_plumbing};
|
||||
use crate::util::{DelayLine, Lfo, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr};
|
||||
use crate::util::{DelayLine, Lfo, Rng, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr, time_coef};
|
||||
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit};
|
||||
|
||||
/// Per-channel LFO phase offset for stereo movement (channel 1 is offset by `spread` cycles).
|
||||
@@ -459,3 +459,221 @@ impl Plugin for Phaser {
|
||||
ms_to_samples(100.0, self.sr) as usize
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
const RING_EFFECTS: &[&str] = &["Ring", "Frequency Mod", "Resonator +", "Resonator -"];
|
||||
const RING_SOURCES: &[&str] = &["LFO", "Envelope", "Sample & Hold"];
|
||||
const RING_MIN_HZ: f32 = 20.0;
|
||||
/// Octaves the frequency moves at full modulation depth.
|
||||
const RING_MOD_OCTAVES: f32 = 2.0;
|
||||
/// Peak delay swing of Frequency Mod.
|
||||
const RING_FM_SWING_MS: f32 = 1.0;
|
||||
/// Sample-and-hold noise seed (the same sequence after every reset).
|
||||
const RING_SEED: u32 = 0x5EED_1234;
|
||||
|
||||
static RING_PARAMS: [ParamInfo; 7] = [
|
||||
choice("effect", "Effect", RING_EFFECTS, 0),
|
||||
log("frequency", "Frequency", RING_MIN_HZ, 5000.0, 440.0, Unit::Hz),
|
||||
lin("resonance", "Resonance", 0.0, 95.0, 50.0, Unit::Percent),
|
||||
choice("mod_source", "Mod Source", RING_SOURCES, 0),
|
||||
log("mod_rate", "Mod Rate", 0.05, 20.0, 1.0, Unit::Hz),
|
||||
lin("mod_depth", "Mod Depth", 0.0, 100.0, 0.0, Unit::Percent),
|
||||
lin("mix", "Mix", 0.0, 100.0, 100.0, Unit::Percent),
|
||||
];
|
||||
|
||||
pub static RING_INFO: PluginInfo = PluginInfo {
|
||||
id: "ring_mod",
|
||||
name: "Ring Modulator",
|
||||
short_name: "RingMod",
|
||||
category: Category::Modulation,
|
||||
params: &RING_PARAMS,
|
||||
is_instrument: false,
|
||||
audiosuite: true,
|
||||
};
|
||||
|
||||
/// Ring modulation, frequency modulation and positive or negative comb resonators, with the
|
||||
/// frequency moved by an LFO, an envelope follower or a sample-and-hold source.
|
||||
///
|
||||
/// - Ring multiplies the input by a sine carrier at the frequency.
|
||||
/// - Frequency Mod reads the input through a delay that the carrier swings by ±1 ms.
|
||||
/// - Resonator + and Resonator - feed the output back after one period of the frequency, with
|
||||
/// a positive or a negative sign. The output is scaled by `1 - resonance`, so the peak gain at
|
||||
/// resonance is unity.
|
||||
///
|
||||
/// At 100 % depth the modulation moves the frequency by up to two octaves either way. The
|
||||
/// envelope follower moves it up only.
|
||||
pub struct RingMod {
|
||||
p: Params,
|
||||
sr: f32,
|
||||
lines: Vec<DelayLine>,
|
||||
lfo: Lfo,
|
||||
rng: Rng,
|
||||
carrier: f32,
|
||||
env: f32,
|
||||
env_attack: f32,
|
||||
env_release: f32,
|
||||
held: f32,
|
||||
hold_left: usize,
|
||||
last_effect: usize,
|
||||
freq: Smoother,
|
||||
res: Smoother,
|
||||
depth: Smoother,
|
||||
mix: Smoother,
|
||||
}
|
||||
|
||||
impl Default for RingMod {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl RingMod {
|
||||
pub fn new() -> Self {
|
||||
let mut r = Self {
|
||||
p: Params::new(&RING_PARAMS),
|
||||
sr: 48_000.0,
|
||||
lines: Vec::new(),
|
||||
lfo: Lfo::new(),
|
||||
rng: Rng::new(RING_SEED),
|
||||
carrier: 0.0,
|
||||
env: 0.0,
|
||||
env_attack: 0.0,
|
||||
env_release: 0.0,
|
||||
held: 0.0,
|
||||
hold_left: 0,
|
||||
last_effect: 0,
|
||||
freq: Smoother::new(440.0),
|
||||
res: Smoother::new(0.0),
|
||||
depth: Smoother::new(0.0),
|
||||
mix: Smoother::new(1.0),
|
||||
};
|
||||
r.update();
|
||||
r
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
self.freq.set(self.p.v(1));
|
||||
self.res.set(self.p.v(2) / 100.0);
|
||||
self.depth.set(self.p.v(5) / 100.0);
|
||||
self.mix.set(self.p.v(6) / 100.0);
|
||||
}
|
||||
|
||||
/// Samples in one period of the lowest frequency the modulation can reach.
|
||||
fn longest_period(&self) -> f32 {
|
||||
self.sr / (self.p.v(1) * 2f32.powf(-RING_MOD_OCTAVES * self.p.v(5) / 100.0)).max(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for RingMod {
|
||||
param_plumbing!(RING_INFO);
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
|
||||
self.sr = sane_sr(sample_rate);
|
||||
let comb = self.sr / (RING_MIN_HZ * 2f32.powf(-RING_MOD_OCTAVES));
|
||||
let max = comb.max(ms_to_samples(2.0 * RING_FM_SWING_MS, self.sr)) as usize + 8;
|
||||
self.lines = (0..sane_channels(channels))
|
||||
.map(|_| {
|
||||
let mut d = DelayLine::default();
|
||||
d.allocate(max);
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
self.env_attack = time_coef(5.0, self.sr);
|
||||
self.env_release = time_coef(100.0, self.sr);
|
||||
for s in [&mut self.freq, &mut self.res, &mut self.depth, &mut self.mix] {
|
||||
s.set_time(20.0, self.sr);
|
||||
}
|
||||
self.update();
|
||||
self.reset();
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.lines.iter_mut().for_each(DelayLine::clear);
|
||||
self.lfo.reset();
|
||||
self.rng = Rng::new(RING_SEED);
|
||||
self.carrier = 0.0;
|
||||
self.env = 0.0;
|
||||
self.held = 0.0;
|
||||
self.hold_left = 0;
|
||||
self.last_effect = self.p.choice(0);
|
||||
for s in [&mut self.freq, &mut self.res, &mut self.depth, &mut self.mix] {
|
||||
s.snap();
|
||||
}
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
let ch = self.lines.len().min(io.len());
|
||||
let frames = frames_in(io, frames, ch);
|
||||
let effect = self.p.choice(0);
|
||||
if effect != self.last_effect {
|
||||
// The lines hold the previous effect's state.
|
||||
self.lines.iter_mut().for_each(DelayLine::clear);
|
||||
self.last_effect = effect;
|
||||
}
|
||||
let source = self.p.choice(3);
|
||||
let rate = self.p.v(4);
|
||||
let inc = rate / self.sr;
|
||||
let hold = ((self.sr / rate.max(0.05)) as usize).max(1);
|
||||
let nyq = self.sr * 0.45;
|
||||
let swing = ms_to_samples(RING_FM_SWING_MS, self.sr);
|
||||
for n in 0..frames {
|
||||
let level = io.iter().take(ch).filter_map(|b| b.get(n)).filter(|x| x.is_finite()).fold(0.0f32, |m, x| m.max(x.abs()));
|
||||
let k = if level > self.env { self.env_attack } else { self.env_release };
|
||||
self.env = flush(level + (self.env - level) * k);
|
||||
let lfo = Lfo::sin_at(self.lfo.tick(inc));
|
||||
let m = match source {
|
||||
1 => self.env.min(1.0),
|
||||
2 => {
|
||||
if self.hold_left == 0 {
|
||||
self.held = self.rng.bipolar();
|
||||
self.hold_left = hold;
|
||||
}
|
||||
self.hold_left -= 1;
|
||||
self.held
|
||||
}
|
||||
_ => lfo,
|
||||
};
|
||||
let f = (self.freq.next() * 2f32.powf(RING_MOD_OCTAVES * self.depth.next() * m)).clamp(1.0, nyq);
|
||||
let fb = self.res.next();
|
||||
let mix = self.mix.next();
|
||||
self.carrier += f / self.sr;
|
||||
if self.carrier >= 1.0 {
|
||||
self.carrier -= 1.0;
|
||||
}
|
||||
let carrier = Lfo::sin_at(self.carrier);
|
||||
for c in 0..ch {
|
||||
let (Some(buf), Some(line)) = (io.get_mut(c), self.lines.get_mut(c)) else { continue };
|
||||
let Some(x) = buf.get_mut(n) else { continue };
|
||||
let dry = if x.is_finite() { *x } else { 0.0 };
|
||||
let wet = match effect {
|
||||
1 => {
|
||||
line.push(dry);
|
||||
line.read_cubic(2.0 + swing * (1.0 + carrier))
|
||||
}
|
||||
2 | 3 => {
|
||||
let sign = if effect == 2 { 1.0 } else { -1.0 };
|
||||
let y = flush(dry + sign * fb * line.read_cubic(self.sr / f));
|
||||
line.push(y);
|
||||
y * (1.0 - fb)
|
||||
}
|
||||
_ => dry * carrier,
|
||||
};
|
||||
*x = dry * (1.0 - mix) + wet * mix;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tail_samples(&self) -> usize {
|
||||
match self.p.choice(0) {
|
||||
1 => ms_to_samples(2.0 * RING_FM_SWING_MS, self.sr) as usize + 4,
|
||||
2 | 3 => {
|
||||
// Time for the feedback to fall by 60 dB at the longest period the modulation reaches.
|
||||
let fb = (self.p.v(2) / 100.0).clamp(0.0, 0.99);
|
||||
let repeats = if fb < 1e-3 { 1.0 } else { (-60.0 / (20.0 * fb.log10())).min(500.0) + 1.0 };
|
||||
(self.longest_period() * repeats) as usize
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use soundcraft_dsp::{Plugin, Transport, create, db_to_gain};
|
||||
|
||||
const SR: f32 = 48_000.0;
|
||||
const BLOCK: usize = 512;
|
||||
|
||||
fn auto_pan(params: &[(&str, f32)]) -> Box<dyn Plugin> {
|
||||
let mut p = create("auto_pan").unwrap();
|
||||
p.prepare(SR, BLOCK, 2);
|
||||
for (id, v) in params {
|
||||
assert!(p.set_param(id, *v), "{id}");
|
||||
}
|
||||
p.reset();
|
||||
p
|
||||
}
|
||||
|
||||
/// Runs `seconds` of a constant `level` on both channels in host-sized blocks.
|
||||
fn run_level(p: &mut dyn Plugin, level: f32, seconds: f32) -> Vec<Vec<f32>> {
|
||||
let len = (seconds * SR) as usize;
|
||||
let mut out = vec![Vec::new(), Vec::new()];
|
||||
let mut pos = 0;
|
||||
while pos < len {
|
||||
let n = BLOCK.min(len - pos);
|
||||
let mut buf = vec![vec![level; n]; 2];
|
||||
p.process(&mut buf, n);
|
||||
for c in 0..2 {
|
||||
out[c].extend_from_slice(&buf[c]);
|
||||
}
|
||||
pos += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Pan position (-1 left ..= 1 right) of frame `i`, from the constant-power gains.
|
||||
fn position(out: &[Vec<f32>], i: usize) -> f32 {
|
||||
4.0 / PI * out[1][i].atan2(out[0][i]) - 1.0
|
||||
}
|
||||
|
||||
fn at(seconds: f32) -> usize {
|
||||
(seconds * SR) as usize
|
||||
}
|
||||
|
||||
fn assert_near(got: f32, want: f32, tol: f32, what: &str) {
|
||||
assert!((got - want).abs() <= tol, "{what}: got {got}, want {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_manual_places_the_signal_when_width_is_zero() {
|
||||
let mut p = auto_pan(&[("width", 0.0), ("manual", -100.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 0.1);
|
||||
assert_near(out[0][at(0.05)], 0.5 * 2f32.sqrt(), 1e-4, "hard left, left channel");
|
||||
assert_near(out[1][at(0.05)], 0.0, 1e-4, "hard left, right channel");
|
||||
|
||||
let mut p = auto_pan(&[("width", 0.0), ("manual", 50.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 0.1);
|
||||
assert_near(position(&out, at(0.05)), 0.5, 1e-3, "manual 50 %");
|
||||
|
||||
let mut p = auto_pan(&[("width", 0.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 0.1);
|
||||
assert_near(out[0][at(0.05)], 0.5, 1e-4, "center keeps the level on the left");
|
||||
assert_near(out[1][at(0.05)], 0.5, 1e-4, "center keeps the level on the right");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_lfo_moves_from_left_to_right_and_back() {
|
||||
// 60 BPM with a Duration of 1 beat is one cycle per second.
|
||||
let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
for (t, want) in [(0.0, -1.0), (0.25, 0.0), (0.5, 1.0), (0.75, 0.0)] {
|
||||
assert_near(position(&out, at(t)), want, 0.05, &format!("left to right at {t} s"));
|
||||
}
|
||||
|
||||
let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0), ("path", 1.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
assert_near(position(&out, at(0.01)), 1.0, 0.05, "right to left starts on the right");
|
||||
assert_near(position(&out, at(0.5)), -1.0, 0.05, "right to left at 0.5 s");
|
||||
|
||||
// Width 50 % sweeps half the field, and Manual moves that half to the right.
|
||||
let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0), ("width", 50.0), ("manual", 50.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
assert_near(position(&out, at(0.0)), -0.25, 0.05, "width 50 % at the start");
|
||||
assert_near(position(&out, at(0.5)), 0.75, 0.05, "width 50 % at the far end");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_keeps_moving_at_the_slowest_rate() {
|
||||
// Rate 1 BPM over 4 bars of 4/4 is one cycle in 960 s; 600 s in, a saw is 62.5 % across.
|
||||
let mut p = auto_pan(&[("rate", 1.0), ("duration", 6.0), ("waveform", 3.0)]);
|
||||
let mut buf = vec![vec![0.5f32; BLOCK]; 2];
|
||||
for _ in 0..(600.0 * SR) as usize / BLOCK {
|
||||
buf.iter_mut().for_each(|c| c.fill(0.5));
|
||||
p.process(&mut buf, BLOCK);
|
||||
}
|
||||
assert_near(position(&buf, BLOCK - 1), 0.25, 0.01, "600 s into a 960 s cycle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_waveforms_take_their_shapes() {
|
||||
let shape = |waveform: f32, t: f32| {
|
||||
let mut p = auto_pan(&[("rate", 60.0), ("duration", 2.0), ("waveform", waveform)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
position(&out, at(t))
|
||||
};
|
||||
assert_near(shape(1.0, 0.25), 0.0, 0.05, "triangle at a quarter cycle");
|
||||
assert_near(shape(1.0, 0.125), -0.5, 0.05, "triangle at an eighth of a cycle");
|
||||
assert_near(shape(2.0, 0.2), -1.0, 0.02, "square in its first half");
|
||||
assert_near(shape(2.0, 0.7), 1.0, 0.02, "square in its second half");
|
||||
assert_near(shape(3.0, 0.25), -0.5, 0.05, "saw up");
|
||||
assert_near(shape(4.0, 0.25), 0.5, 0.05, "saw down");
|
||||
assert_near(shape(5.0, 1.0 / 6.0), 0.0, 0.05, "half sine reaches the center at a sixth of a cycle");
|
||||
assert_near(shape(6.0, 0.1), -1.0, 0.02, "4 step triangle, first step");
|
||||
assert_near(shape(6.0, 0.2), -1.0 / 3.0, 0.02, "4 step triangle, second step");
|
||||
assert_near(shape(6.0, 0.45), 1.0, 0.02, "4 step triangle, top step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_rate_follows_the_session_tempo_and_duration() {
|
||||
// Linked to a 120 BPM session with a Duration of 1 beat: two cycles per second.
|
||||
let mut p = auto_pan(&[("link_tempo", 1.0), ("rate", 30.0), ("duration", 2.0)]);
|
||||
p.set_transport(&Transport { tempo: 120.0, numerator: 4, denominator: 4, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 0.5);
|
||||
assert_near(position(&out, at(0.25)), 1.0, 0.05, "linked to 120 BPM, 1 beat");
|
||||
|
||||
// Not linked: Rate 120 BPM and the default Duration of 1 bar (4/4) is one cycle in 2 s.
|
||||
let mut p = auto_pan(&[]);
|
||||
p.set_transport(&Transport { tempo: 60.0, numerator: 4, denominator: 4, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 1.5);
|
||||
assert_near(position(&out, at(1.0)), 1.0, 0.05, "Rate 120 BPM, 1 bar");
|
||||
|
||||
// A 3/4 bar at 120 BPM lasts 1.5 s.
|
||||
let mut p = auto_pan(&[("link_tempo", 1.0)]);
|
||||
p.set_transport(&Transport { tempo: 120.0, numerator: 3, denominator: 4, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
assert_near(position(&out, at(0.75)), 1.0, 0.05, "1 bar of 3/4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_beat_clock_follows_the_transport_position() {
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("duration", 2.0)]);
|
||||
// Half a beat into the song, the LFO is half way through its cycle.
|
||||
p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, quarters: 7.5, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 0.01);
|
||||
assert_near(position(&out, 0), 1.0, 0.05, "half a beat in");
|
||||
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("duration", 2.0)]);
|
||||
p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, quarters: 3.0, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 0.2);
|
||||
assert_near(position(&out, 0), -1.0, 0.05, "on the beat");
|
||||
assert_near(position(&out, at(0.125)), 0.0, 0.05, "a quarter of a beat later");
|
||||
|
||||
// Not linked, Rate 60 BPM in a 120 BPM session: the LFO restarts on every beat (0.5 s) and
|
||||
// runs at 1 Hz in between, so it reaches the right and then starts again from the left.
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("rate", 60.0), ("duration", 2.0)]);
|
||||
p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, quarters: 3.0, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 1.0);
|
||||
assert_near(position(&out, at(0.25)), 0.0, 0.05, "a quarter cycle at 1 Hz");
|
||||
assert_near(position(&out, at(0.49)), 1.0, 0.05, "on the right at the end of the beat");
|
||||
assert_near(position(&out, at(0.51)), -1.0, 0.05, "restarted on the next beat");
|
||||
assert_near(position(&out, at(0.75)), 0.0, 0.05, "a quarter cycle after the restart");
|
||||
|
||||
// 2 Beats in 3/4, with the host updating the transport every block: the cycle runs on across
|
||||
// the bar line at quarter 3 instead of restarting there.
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("waveform", 3.0), ("duration", 3.0)]);
|
||||
let mut buf = vec![vec![0.5f32; 100]; 2];
|
||||
let mut positions = Vec::new();
|
||||
for block in 0..480 {
|
||||
let quarters = 2.0 + block as f64 * 100.0 * 2.0 / f64::from(SR);
|
||||
let bar = (quarters / 3.0).floor();
|
||||
p.set_transport(&Transport {
|
||||
playing: true,
|
||||
tempo: 120.0,
|
||||
numerator: 3,
|
||||
denominator: 4,
|
||||
quarters,
|
||||
bar_start: bar * 3.0,
|
||||
bar: bar as i64,
|
||||
..Transport::default()
|
||||
});
|
||||
buf.iter_mut().for_each(|c| c.fill(0.5));
|
||||
p.process(&mut buf, 100);
|
||||
positions.push((quarters, position(&buf, 0)));
|
||||
}
|
||||
for (q, want) in [(2.5, -0.5), (3.0, 0.0), (3.5, 0.5)] {
|
||||
let got = positions.iter().find(|(quarters, _)| (quarters - q).abs() < 1e-9).map(|(_, pos)| *pos).unwrap();
|
||||
assert_near(got, want, 0.02, &format!("2 beats in 3/4 at quarter {q}"));
|
||||
}
|
||||
|
||||
// Bar-based cycles start on the bar line after a change from 4/4 to 3/4 at quarter 4.
|
||||
let three_four = |quarters: f64| Transport {
|
||||
playing: true,
|
||||
tempo: 120.0,
|
||||
numerator: 3,
|
||||
denominator: 4,
|
||||
quarters,
|
||||
bar_start: 4.0,
|
||||
bar: 1,
|
||||
..Transport::default()
|
||||
};
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("waveform", 3.0)]);
|
||||
p.set_transport(&three_four(4.0));
|
||||
let out = run_level(p.as_mut(), 0.5, 0.01);
|
||||
assert_near(position(&out, 0), -1.0, 0.02, "1 bar starts on the 3/4 bar line");
|
||||
let mut p = auto_pan(&[("trigger", 2.0), ("link_tempo", 1.0), ("waveform", 3.0), ("duration", 5.0)]);
|
||||
p.set_transport(&three_four(5.5));
|
||||
let out = run_level(p.as_mut(), 0.5, 0.01);
|
||||
assert_near(position(&out, 0), 0.5, 0.02, "half way through the second bar of 2 bars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_single_runs_one_cycle_and_holds() {
|
||||
let mut p = auto_pan(&[("trigger", 1.0), ("rate", 60.0), ("duration", 2.0), ("waveform", 3.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 2.0);
|
||||
assert_near(position(&out, at(0.5)), 0.0, 0.05, "half way through the one cycle");
|
||||
assert_near(position(&out, at(1.5)), 1.0, 0.02, "holds at the end of the path");
|
||||
assert_near(position(&out, at(1.99)), 1.0, 0.02, "still holding");
|
||||
|
||||
// Starting the transport runs the cycle again.
|
||||
p.set_transport(&Transport { playing: true, tempo: 120.0, numerator: 4, denominator: 4, ..Transport::default() });
|
||||
let out = run_level(p.as_mut(), 0.5, 0.5);
|
||||
assert_near(position(&out, at(0.25)), -0.5, 0.05, "restarted when playback starts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_envelope_source_pans_by_level() {
|
||||
let settled = |level: f32| {
|
||||
let mut p = auto_pan(&[("source", 1.0), ("threshold", -30.0)]);
|
||||
let out = run_level(p.as_mut(), level, 0.5);
|
||||
position(&out, at(0.45))
|
||||
};
|
||||
assert_near(settled(db_to_gain(-40.0)), -1.0, 0.02, "below the threshold");
|
||||
assert_near(settled(db_to_gain(-15.0)), 0.0, 0.05, "half way to full scale");
|
||||
assert_near(settled(1.0), 1.0, 0.02, "full scale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_envelope_and_manual_triggers_start_a_cycle() {
|
||||
let mut p = auto_pan(&[("trigger", 3.0), ("rate", 60.0), ("duration", 2.0), ("waveform", 3.0), ("threshold", -30.0)]);
|
||||
let quiet = run_level(p.as_mut(), db_to_gain(-40.0), 0.5);
|
||||
assert_near(position(&quiet, at(0.4)), -1.0, 0.02, "waits at the start below the threshold");
|
||||
let loud = run_level(p.as_mut(), 1.0, 1.5);
|
||||
assert_near(position(&loud, at(0.25)), -0.5, 0.05, "a quarter cycle after the level crosses the threshold");
|
||||
assert_near(position(&loud, at(1.4)), 1.0, 0.02, "holds after one cycle while the level stays up");
|
||||
|
||||
let mut p = auto_pan(&[("trigger", 4.0), ("rate", 60.0), ("duration", 2.0), ("waveform", 3.0)]);
|
||||
let before = run_level(p.as_mut(), 0.5, 0.5);
|
||||
assert_near(position(&before, at(0.4)), -1.0, 0.02, "waits for the manual trigger");
|
||||
assert!(p.set_param("retrigger", 1.0));
|
||||
let after = run_level(p.as_mut(), 0.5, 0.5);
|
||||
assert_near(position(&after, at(0.25)), -0.5, 0.05, "a quarter cycle after switching the trigger on");
|
||||
let held = run_level(p.as_mut(), 0.5, 1.0);
|
||||
assert!(p.set_param("retrigger", 0.0));
|
||||
let again = run_level(p.as_mut(), 0.5, 0.5);
|
||||
assert_near(position(&held, at(0.9)), 1.0, 0.02, "holds after one cycle");
|
||||
assert_near(position(&again, at(0.25)), -0.5, 0.05, "switching the trigger off starts another cycle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_envelope_trigger_waits_for_the_release() {
|
||||
let mut p = auto_pan(&[
|
||||
("trigger", 3.0),
|
||||
("rate", 60.0),
|
||||
("duration", 2.0),
|
||||
("waveform", 3.0),
|
||||
("threshold", -30.0),
|
||||
("attack", 0.1),
|
||||
("release", 100.0),
|
||||
]);
|
||||
let quiet = db_to_gain(-40.0);
|
||||
let first = run_level(p.as_mut(), 1.0, 1.5);
|
||||
assert_near(position(&first, at(1.4)), 1.0, 0.02, "the first cycle ends and holds");
|
||||
// The envelope falls below the threshold about 0.38 s into the quiet part, so a 0.42 s dip
|
||||
// leaves it below for less than the 100 ms Release.
|
||||
run_level(p.as_mut(), quiet, 0.42);
|
||||
let short = run_level(p.as_mut(), 1.0, 0.5);
|
||||
assert_near(position(&short, at(0.25)), 1.0, 0.02, "no trigger before the release is over");
|
||||
run_level(p.as_mut(), quiet, 1.0);
|
||||
let long = run_level(p.as_mut(), 1.0, 0.5);
|
||||
assert_near(position(&long, at(0.25)), -0.5, 0.05, "a trigger once the release is over");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pan_output_gain_and_mono_tracks() {
|
||||
let mut p = auto_pan(&[("width", 0.0), ("output", 6.0)]);
|
||||
let out = run_level(p.as_mut(), 0.5, 0.1);
|
||||
assert_near(out[0][at(0.05)], 0.5 * db_to_gain(6.0), 1e-4, "+6 dB at the center");
|
||||
|
||||
// A mono insert has nothing to pan to; Output still applies.
|
||||
let mut p = create("auto_pan").unwrap();
|
||||
p.prepare(SR, BLOCK, 1);
|
||||
assert!(p.set_param("output", -6.0));
|
||||
p.reset();
|
||||
let mut buf = vec![vec![0.5f32; BLOCK]];
|
||||
p.process(&mut buf, BLOCK);
|
||||
assert_near(buf[0][BLOCK - 1], 0.5 * db_to_gain(-6.0), 1e-4, "mono output");
|
||||
}
|
||||
@@ -46,6 +46,9 @@ proptest! {
|
||||
let _ = offline::detect_transients(&ch, a, a);
|
||||
let _ = offline::time_stretch(&ch, r, a);
|
||||
let _ = offline::pitch_shift(&ch, a, a);
|
||||
let change = if n % 2 == 0 { offline::SpeedRampChange::SpeedUp } else { offline::SpeedRampChange::SlowDown };
|
||||
let selection = if n % 3 == 0 { offline::SpeedRampSelection::Extend } else { offline::SpeedRampSelection::FitTo };
|
||||
let _ = offline::speed_ramp(&ch, change, selection, n % 5 == 0);
|
||||
let _ = offline::resample(&ch, n as u32, (n as u32).wrapping_mul(3) % 200_000);
|
||||
let mut m = ch.clone();
|
||||
offline::normalize(&mut m, a, n % 2 == 0);
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
mod auto_pan;
|
||||
mod fuzz;
|
||||
mod offline_meters;
|
||||
mod offline_normalize;
|
||||
mod plugins;
|
||||
mod speed_ramp;
|
||||
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);
|
||||
}
|
||||
@@ -513,6 +513,103 @@ fn saturator_adds_harmonics_and_stays_bounded() {
|
||||
assert!(spec[third - 1..=third + 1].iter().cloned().fold(f32::MIN, f32::max) > -30.0);
|
||||
}
|
||||
|
||||
/// Amplitude of the `freq` component of `x` (a whole number of cycles keeps it exact).
|
||||
fn tone_level(x: &[f32], freq: f32) -> f32 {
|
||||
let (mut re, mut im) = (0.0f64, 0.0f64);
|
||||
for (i, v) in x.iter().enumerate() {
|
||||
let w = f64::from(TAU * freq / SR) * i as f64;
|
||||
re += f64::from(*v) * w.cos();
|
||||
im += f64::from(*v) * w.sin();
|
||||
}
|
||||
(2.0 * (re * re + im * im).sqrt() / x.len() as f64) as f32
|
||||
}
|
||||
|
||||
/// A Subharmonic with only the wet path and wide filters, so a band's octave reaches the output.
|
||||
fn subharmonic_wet() -> Box<dyn Plugin> {
|
||||
let mut p = prepared("subharmonic");
|
||||
p.set_param("mix", 100.0);
|
||||
p.set_param("hp_freq", 5.0);
|
||||
p.set_param("lp_freq", 1000.0);
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subharmonic_adds_the_octave_below_each_range_band() {
|
||||
for (range, lower, upper) in [(0.0, 120.0, 180.0), (1.0, 80.0, 120.0), (2.0, 60.0, 90.0), (3.0, 40.0, 60.0)] {
|
||||
for (band, freq) in [("lower", lower), ("upper", upper)] {
|
||||
let mut p = subharmonic_wet();
|
||||
p.set_param("range", range);
|
||||
p.set_param("lower_gain", if band == "lower" { 0.0 } else { -120.0 });
|
||||
p.set_param("upper_gain", if band == "upper" { 0.0 } else { -120.0 });
|
||||
let out = run(p.as_mut(), &[sine(freq, 0.5, 96_000)]);
|
||||
// One second after the filters settle: a whole number of cycles of both tones.
|
||||
let tail = &out[0][48_000..];
|
||||
let sub = tone_level(tail, freq / 2.0);
|
||||
assert!(sub > 0.2, "range {range} {band} band: {sub} at {} Hz", freq / 2.0);
|
||||
assert!(sub > 4.0 * tone_level(tail, freq), "range {range} {band} band keeps its input tone");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subharmonic_mix_keeps_one_path_at_full_level() {
|
||||
// A 1 kHz tone is far above every band, so the output is the dry path alone.
|
||||
for (mix, dry) in [(0.0, 1.0), (25.0, 1.0), (50.0, 1.0), (75.0, 0.5), (100.0, 0.0)] {
|
||||
let mut p = prepared("subharmonic");
|
||||
p.set_param("mix", mix);
|
||||
let out = run(p.as_mut(), &[sine(1000.0, 0.5, 48_000)]);
|
||||
let level = tone_level(&out[0][24_000..], 1000.0);
|
||||
assert!((level - 0.5 * dry).abs() < 0.01, "mix {mix}: dry level {level}");
|
||||
}
|
||||
// The wet path is at full level from 50 % up and at half level at 25 %.
|
||||
let wet = |mix: f32| {
|
||||
let mut p = prepared("subharmonic");
|
||||
p.set_param("mix", mix);
|
||||
p.set_param("wet_solo", 1.0);
|
||||
let out = run(p.as_mut(), &[sine(60.0, 0.5, 96_000)]);
|
||||
tone_level(&out[0][48_000..], 30.0)
|
||||
};
|
||||
let full = wet(50.0);
|
||||
assert!(full > 0.1, "wet level {full}");
|
||||
assert!((wet(100.0) - full).abs() < 0.01 * full);
|
||||
assert!((wet(25.0) - 0.5 * full).abs() < 0.01 * full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subharmonic_solo_plays_one_band_and_mutes_the_dry_signal() {
|
||||
let level = |solo: &str, gain: f32| {
|
||||
let mut p = prepared("subharmonic");
|
||||
p.set_param("lower_gain", gain);
|
||||
p.set_param(solo, 1.0);
|
||||
let x = sine(60.0, 0.5, 96_000).iter().zip(sine(1000.0, 0.5, 96_000)).map(|(a, b)| a + b).collect();
|
||||
let out = run(p.as_mut(), &[x]);
|
||||
let tail = &out[0][48_000..];
|
||||
(tone_level(tail, 30.0), tone_level(tail, 1000.0))
|
||||
};
|
||||
let (sub, dry) = level("lower_solo", 0.0);
|
||||
assert!(sub > 0.1 && dry < 0.01, "lower solo: sub {sub}, dry {dry}");
|
||||
// With the soloed band turned off nothing else plays, though the upper band is on.
|
||||
let (sub, dry) = level("lower_solo", -120.0);
|
||||
assert!(sub < 0.01 && dry < 0.01, "silent lower solo: sub {sub}, dry {dry}");
|
||||
let (sub, dry) = level("upper_solo", -120.0);
|
||||
assert!(sub > 0.05 && dry < 0.01, "upper solo: sub {sub}, dry {dry}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subharmonic_drive_keeps_every_character_bounded() {
|
||||
for character in 0..6 {
|
||||
let mut p = subharmonic_wet();
|
||||
p.set_param("input", 18.0);
|
||||
p.set_param("lower_gain", 24.0);
|
||||
p.set_param("upper_gain", 24.0);
|
||||
p.set_param("drive", 18.0);
|
||||
p.set_param("character", character as f32);
|
||||
let out = run(p.as_mut(), &[sine(60.0, 1.0, 48_000)]);
|
||||
let top = peak(&out[0][24_000..]);
|
||||
assert!(top > 0.5 && top < 1.5, "character {character}: peak {top}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lofi_quantizes() {
|
||||
let mut p = prepared("lofi");
|
||||
@@ -524,3 +621,95 @@ fn lofi_quantizes() {
|
||||
levels.dedup();
|
||||
assert!(levels.len() <= 5, "{levels:?}");
|
||||
}
|
||||
|
||||
fn ring_mod(params: &[(&str, f32)]) -> Box<dyn Plugin> {
|
||||
let mut p = prepared("ring_mod");
|
||||
for &(id, v) in params {
|
||||
assert!(p.set_param(id, v), "ring_mod.{id}");
|
||||
}
|
||||
p.reset();
|
||||
p
|
||||
}
|
||||
|
||||
/// Loudest bin within two bins of `hz` in an 8192-point spectrum.
|
||||
fn band_db(spec: &[f32], hz: f32) -> f32 {
|
||||
let bin = (hz / (SR / 8192.0)).round() as usize;
|
||||
spec[bin - 2..=bin + 2].iter().cloned().fold(f32::MIN, f32::max)
|
||||
}
|
||||
|
||||
fn zero_crossings(x: &[f32]) -> usize {
|
||||
x.windows(2).filter(|w| (w[0] < 0.0) != (w[1] < 0.0)).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_mod_makes_sum_and_difference_tones() {
|
||||
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 300.0), ("mod_depth", 0.0), ("mix", 100.0)]);
|
||||
let out = run(p.as_mut(), &[sine(1000.0, 0.5, 48_000)]);
|
||||
let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][8192..], 8192);
|
||||
let (low, high, input) = (band_db(&spec, 700.0), band_db(&spec, 1300.0), band_db(&spec, 1000.0));
|
||||
// The two tones fall at different places between bins, so their bin peaks differ a little.
|
||||
assert!((low - high).abs() < 2.0, "700 Hz {low} dB, 1300 Hz {high} dB");
|
||||
assert!(low - input > 40.0, "the 1000 Hz input is left at {input} dB against {low} dB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_mod_frequency_mod_adds_sidebands() {
|
||||
let x = sine(1000.0, 0.5, 48_000);
|
||||
let dry = soundcraft_dsp::spectrum::magnitude_db(&x[8192..], 8192);
|
||||
assert!(band_db(&dry, 1000.0) - band_db(&dry, 1200.0) > 40.0);
|
||||
let mut p = ring_mod(&[("effect", 1.0), ("frequency", 200.0), ("mix", 100.0)]);
|
||||
let out = run(p.as_mut(), &[x]);
|
||||
let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][8192..], 8192);
|
||||
for hz in [800.0, 1200.0] {
|
||||
assert!(band_db(&spec, 1000.0) - band_db(&spec, hz) < 20.0, "no sideband at {hz} Hz");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_mod_resonators_ring_at_the_frequency_with_their_sign() {
|
||||
// 480 Hz at 48 kHz is a 100-sample period.
|
||||
for (effect, sign) in [(2.0, 1.0), (3.0, -1.0)] {
|
||||
let mut p = ring_mod(&[("effect", effect), ("frequency", 480.0), ("resonance", 90.0), ("mix", 100.0)]);
|
||||
let mut x = vec![0.0f32; 9600];
|
||||
x[0] = 1.0;
|
||||
let out = run(p.as_mut(), &[x]);
|
||||
let y = &out[0];
|
||||
assert!((y[0] - 0.1).abs() < 1e-4, "effect {effect}: direct {}", y[0]);
|
||||
assert!((y[100] - sign * 0.09).abs() < 1e-4, "effect {effect}: first echo {}", y[100]);
|
||||
assert!((y[200] - 0.081).abs() < 1e-4, "effect {effect}: second echo {}", y[200]);
|
||||
assert!(y[50].abs() < 1e-6 && y[150].abs() < 1e-6, "effect {effect}: sound between the echoes");
|
||||
let last = y.iter().rposition(|v| v.abs() > 0.1e-3).unwrap();
|
||||
assert!(last < p.tail_samples(), "effect {effect}: ringing at {last}, tail {}", p.tail_samples());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_mod_modulation_sources_move_the_frequency() {
|
||||
// A constant input through Ring gives the carrier itself.
|
||||
let dc = vec![1.0f32; 48_000];
|
||||
|
||||
// The envelope of a full-scale input lifts 100 Hz by one octave at 50 % depth.
|
||||
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 1.0), ("mod_depth", 50.0), ("mix", 100.0)]);
|
||||
let out = run(p.as_mut(), std::slice::from_ref(&dc));
|
||||
let n = zero_crossings(&out[0][24_000..]);
|
||||
assert!((195..=205).contains(&n), "envelope: {n} crossings in 0.5 s, 200 expected for 200 Hz");
|
||||
|
||||
// A 1 Hz LFO raises the frequency for its first half cycle and lowers it for the second.
|
||||
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 0.0), ("mod_rate", 1.0), ("mod_depth", 100.0), ("mix", 100.0)]);
|
||||
let out = run(p.as_mut(), std::slice::from_ref(&dc));
|
||||
let (up, down) = (zero_crossings(&out[0][..12_000]), zero_crossings(&out[0][24_000..36_000]));
|
||||
assert!(up > 3 * down, "LFO: {up} crossings rising, {down} falling");
|
||||
|
||||
// Sample and hold at 4 Hz keeps one frequency for each 12,000-sample step.
|
||||
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 2.0), ("mod_rate", 4.0), ("mod_depth", 100.0), ("mix", 100.0)]);
|
||||
let out = run(p.as_mut(), std::slice::from_ref(&dc));
|
||||
let mut steps = Vec::new();
|
||||
for s in out[0].chunks(12_000) {
|
||||
let (a, b) = (zero_crossings(&s[..6000]), zero_crossings(&s[6000..]));
|
||||
assert!(a.abs_diff(b) <= 2, "sample and hold changed within a step: {a} then {b}");
|
||||
steps.push(a);
|
||||
}
|
||||
steps.sort_unstable();
|
||||
steps.dedup_by(|a, b| a.abs_diff(*b) <= 2);
|
||||
assert!(steps.len() >= 3, "sample and hold steps: {steps:?}");
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use soundcraft_dsp::offline::{self, SpeedRampChange, SpeedRampSelection};
|
||||
|
||||
/// A channel whose samples rise from 0 to 1, so each output sample shows where it was read.
|
||||
fn ramp(len: usize) -> Vec<f32> {
|
||||
(0..len).map(|i| i as f32 / len as f32).collect()
|
||||
}
|
||||
|
||||
/// The fraction of the played input reached at `u` (0 to 1) of the result.
|
||||
fn slow_down_pos(u: f64) -> f64 {
|
||||
(3.0 * u - u * u * u) / 2.0
|
||||
}
|
||||
|
||||
fn speed_up_pos(u: f64) -> f64 {
|
||||
1.0 - slow_down_pos(1.0 - u)
|
||||
}
|
||||
|
||||
fn near(got: f32, want: f64, what: &str) {
|
||||
assert!((f64::from(got) - want).abs() < 2e-3, "{what}: got {got}, want {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_fit_to_keeps_the_length_and_plays_two_thirds() {
|
||||
let len = 30_000;
|
||||
let x = vec![ramp(len)];
|
||||
let slow = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::FitTo, false);
|
||||
assert_eq!(slow[0].len(), len);
|
||||
// Slow Down plays the first two thirds: it starts at normal speed at the start and stops at the end.
|
||||
for u in [0.0, 0.25, 0.5, 0.75, 0.9] {
|
||||
let n = (u * len as f64) as usize;
|
||||
near(slow[0][n], 2.0 / 3.0 * slow_down_pos(u), &format!("slow down at {u}"));
|
||||
}
|
||||
// The speed over 50 samples: a one-sample step inside the input also shows the interpolation's ripple.
|
||||
let step = |a: &[f32], n: usize| f64::from(a[n + 50] - a[n]) * len as f64 / 50.0;
|
||||
assert!((step(&slow[0], 100) - 1.0).abs() < 0.01, "slow down starts at normal speed: {}", step(&slow[0], 100));
|
||||
assert!(step(&slow[0], len - 200) < 0.02, "slow down ends near a stop: {}", step(&slow[0], len - 200));
|
||||
|
||||
// Speed Up plays the last two thirds: it starts from a stop and reaches normal speed at the end.
|
||||
let fast = offline::speed_ramp(&x, SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, false);
|
||||
assert_eq!(fast[0].len(), len);
|
||||
for u in [0.1, 0.25, 0.5, 0.75, 0.99] {
|
||||
let n = (u * len as f64) as usize;
|
||||
near(fast[0][n], 1.0 / 3.0 + 2.0 / 3.0 * speed_up_pos(u), &format!("speed up at {u}"));
|
||||
}
|
||||
assert!(step(&fast[0], 200) < 0.02, "speed up starts near a stop: {}", step(&fast[0], 200));
|
||||
assert!((step(&fast[0], len - 100) - 1.0).abs() < 0.01, "speed up ends at normal speed: {}", step(&fast[0], len - 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_extend_plays_everything_over_150_percent() {
|
||||
let len = 30_000;
|
||||
let x = vec![ramp(len), ramp(len)];
|
||||
for change in [SpeedRampChange::SlowDown, SpeedRampChange::SpeedUp] {
|
||||
let out = offline::speed_ramp(&x, change, SpeedRampSelection::Extend, false);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(out[0].len(), 45_000, "{change:?}");
|
||||
assert_eq!(out[0], out[1], "{change:?}: both channels are read at the same positions");
|
||||
for u in [0.1, 0.25, 0.5, 0.75, 0.97] {
|
||||
let n = (u * 45_000.0) as usize;
|
||||
let pos = if change == SpeedRampChange::SlowDown { slow_down_pos(u) } else { speed_up_pos(u) };
|
||||
near(out[0][n], pos, &format!("{change:?} at {u}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_fades_follow_the_speed() {
|
||||
let len = 30_000;
|
||||
let x = vec![vec![0.5; len]];
|
||||
let slow = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::FitTo, true);
|
||||
let fast = offline::speed_ramp(&x, SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, true);
|
||||
for u in [0.25, 0.5, 0.75] {
|
||||
let n = (u * len as f64) as usize;
|
||||
near(slow[0][n], 0.5 * (1.0 - u * u), &format!("slow down fades out at {u}"));
|
||||
near(fast[0][n], 0.5 * (1.0 - (1.0 - u) * (1.0 - u)), &format!("speed up fades in at {u}"));
|
||||
}
|
||||
assert!(slow[0][len - 1].abs() < 1e-3 && fast[0][0].abs() < 1e-3, "the fades reach silence at the stop");
|
||||
// Fades Off keeps the level.
|
||||
let off = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::FitTo, false);
|
||||
for u in [0.25, 0.5, 0.75, 0.9] {
|
||||
near(off[0][(u * len as f64) as usize], 0.5, &format!("slow down without fades at {u}"));
|
||||
}
|
||||
// ... at the stop too, where the reads reach either end of the input.
|
||||
let up = offline::speed_ramp(&x, SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, false);
|
||||
near(up[0][0], 0.5, "speed up without fades from the stop");
|
||||
near(off[0][len - 1], 0.5, "slow down without fades at the stop");
|
||||
let off = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::Extend, false);
|
||||
near(off[0][off[0].len() - 1], 0.5, "slow down with Extend and without fades at the stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_pitch_falls_as_the_tape_slows() {
|
||||
let sr = 48_000.0;
|
||||
let len = 96_000;
|
||||
let x = vec![(0..len).map(|i| (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / sr).sin() * 0.5).collect::<Vec<f32>>()];
|
||||
let out = offline::speed_ramp(&x, SpeedRampChange::SlowDown, SpeedRampSelection::Extend, false);
|
||||
// Upward zero crossings per second in a 0.1 s window starting at `u`.
|
||||
let rate = |u: f64| {
|
||||
let a = (u * out[0].len() as f64) as usize;
|
||||
let w = &out[0][a..a + 4800];
|
||||
w.windows(2).filter(|p| p[0] < 0.0 && p[1] >= 0.0).count() as f64 * 10.0
|
||||
};
|
||||
assert!((rate(0.0) - 1000.0).abs() < 20.0, "starts at the original pitch: {}", rate(0.0));
|
||||
let half = 1000.0 * 0.75;
|
||||
assert!((rate(0.5) - half).abs() < 30.0, "three quarters speed halfway: {}", rate(0.5));
|
||||
assert!(rate(0.9) < 250.0, "low near the stop: {}", rate(0.9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_handles_empty_and_uneven_channels() {
|
||||
assert!(offline::speed_ramp(&[], SpeedRampChange::SlowDown, SpeedRampSelection::Extend, true).is_empty());
|
||||
let out = offline::speed_ramp(&[vec![], vec![]], SpeedRampChange::SpeedUp, SpeedRampSelection::FitTo, true);
|
||||
assert!(out.iter().all(Vec::is_empty));
|
||||
// A shorter channel is read as silence past its end, and every channel gets the same length.
|
||||
let out = offline::speed_ramp(&[vec![0.5; 300], vec![0.5; 30]], SpeedRampChange::SlowDown, SpeedRampSelection::Extend, false);
|
||||
assert_eq!((out[0].len(), out[1].len()), (450, 450));
|
||||
assert!(out.iter().flatten().all(|v| v.is_finite()));
|
||||
near(out[0][449], 0.5, "the longer channel at the stop");
|
||||
assert!(out[1][200..].iter().all(|v| v.abs() < 1e-3), "the shorter channel is silent past its end");
|
||||
}
|
||||
@@ -176,11 +176,30 @@ 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.
|
||||
#[cfg_attr(not(unix), allow(unused_mut))]
|
||||
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 +229,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 +412,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 +456,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 +490,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 +549,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 +672,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 +695,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();
|
||||
@@ -166,6 +176,8 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
|
||||
c.sync_point = scale(c.sync_point);
|
||||
c.gain_env.iter_mut().for_each(|(at, _)| *at = scale(*at));
|
||||
c.group = Some(group_id);
|
||||
// The file does not hold the Auto Rename schemes of the session it came from.
|
||||
c.auto_name = None;
|
||||
c.sanitize();
|
||||
placed.push(c.id);
|
||||
prepared.push(c);
|
||||
@@ -221,6 +233,21 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_clips_carry_no_auto_rename_scheme() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-names-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("named.scgrp").to_string_lossy().to_string();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let kick = e.session().track_by_name("Kick").unwrap().clips()[0].id.0;
|
||||
e.execute("clip.auto_rename", &json!({"clips": [kick], "name": "Hit"})).unwrap();
|
||||
e.execute("file.export_clip_groups", &json!({"path": path, "clips": [kick]})).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 30_000_000, "track": "Bass"})).unwrap();
|
||||
let placed = soundcraft_model::ClipId(r["clips"][0].as_u64().unwrap());
|
||||
assert_eq!(e.session().find_clip(placed).unwrap().1.auto_name, None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_in_a_group_file_are_kept_within_reach() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-far-{}", 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,9 +1,11 @@
|
||||
//! 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_dsp::offline::{SpeedRampChange, SpeedRampSelection};
|
||||
use soundcraft_model::{Clip, ClipContent, TrackKind};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -12,7 +14,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"AudioSuite Process",
|
||||
[],
|
||||
None,
|
||||
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks)",
|
||||
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | speed_ramp | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)",
|
||||
has_selection,
|
||||
process
|
||||
)]
|
||||
@@ -30,7 +32,16 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let known = soundcraft_dsp::plugin_info(&name).is_some()
|
||||
|| matches!(
|
||||
name.as_str(),
|
||||
"normalize" | "reverse" | "invert" | "gain" | "duplicate" | "time_stretch" | "pitch_shift" | "varispeed" | "dc_offset_removal"
|
||||
"normalize"
|
||||
| "reverse"
|
||||
| "invert"
|
||||
| "gain"
|
||||
| "duplicate"
|
||||
| "time_stretch"
|
||||
| "pitch_shift"
|
||||
| "varispeed"
|
||||
| "speed_ramp"
|
||||
| "dc_offset_removal"
|
||||
);
|
||||
if !known {
|
||||
return Err(bad("audiosuite.process", format!("unknown process `{name}`")));
|
||||
@@ -49,12 +60,16 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ratio = f64::from(get("ratio", 1.0).clamp(0.25, 4.0));
|
||||
let semitones = get("semitones", 0.0).clamp(-24.0, 24.0);
|
||||
let to = (sr.as_f64() / f64::from(get("speed", 1.0).clamp(0.25, 4.0))).round().max(1.0) as u32;
|
||||
let change = if get("change", 0.0) >= 0.5 { SpeedRampChange::SpeedUp } else { SpeedRampChange::SlowDown };
|
||||
let selection = if get("selection", 0.0) >= 0.5 { SpeedRampSelection::Extend } else { SpeedRampSelection::FitTo };
|
||||
let fades = get("fades", 1.0) >= 0.5;
|
||||
// These processes cut their output at MAX_OFFLINE_SAMPLES frames.
|
||||
let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize;
|
||||
let most = match name.as_str() {
|
||||
"time_stretch" => longest(ratio),
|
||||
"pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)),
|
||||
"varispeed" => longest(f64::from(to) / sr.as_f64()),
|
||||
"speed_ramp" => longest(if selection == SpeedRampSelection::Extend { 1.5 } else { 1.0 }),
|
||||
_ => usize::MAX,
|
||||
};
|
||||
let mut done = 0;
|
||||
@@ -100,6 +115,7 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
"time_stretch" => soundcraft_dsp::offline::time_stretch(&audio, ratio, srf),
|
||||
"pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, semitones, srf),
|
||||
"varispeed" => soundcraft_dsp::offline::resample(&audio, sr.hz(), to),
|
||||
"speed_ramp" => soundcraft_dsp::offline::speed_ramp(&audio, change, selection, fades),
|
||||
plugin => {
|
||||
apply(plugin, ¶ms, &mut audio, srf)?;
|
||||
audio
|
||||
@@ -139,9 +155,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 {
|
||||
@@ -233,6 +257,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_ramp_ramps_the_speed_and_extend_lengthens_the_clip() {
|
||||
// A mono clip at 480 whose samples rise from 0 to 1, so each output sample shows where it was read.
|
||||
let run = |params: Value| {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Ramp"));
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![(0..4800).map(|i| i as f32 / 4800.0).collect()] };
|
||||
let src = crate::io::add_source(&mut s, "ramp", buf, None, FileFormat::Wav);
|
||||
let id = s.new_clip_id();
|
||||
crate::edit::place_clip(&mut s, t, Clip::audio(id, "ramp", src, 0, 480, 4800));
|
||||
let mut e = crate::Engine::new(s);
|
||||
e.execute("audiosuite.process", &json!({"process": "speed_ramp", "params": params, "clips": [id.0]})).unwrap();
|
||||
let c = e.session().tracks[0].clips()[0].clone();
|
||||
let audio = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0].clone();
|
||||
(c.start, c.length, c.name, audio)
|
||||
};
|
||||
// The defaults: Slow Down, Fit To, Fades On. Halfway through, the read position is 11/16 of the
|
||||
// way through the first two thirds, and the gain is the speed there, 3/4.
|
||||
let (start, length, name, audio) = run(json!({}));
|
||||
assert_eq!((start, length, name.as_str()), (480, 4800, "ramp-speeramp"));
|
||||
let want = (2.0 / 3.0 * 11.0 / 16.0) * 0.75;
|
||||
assert!((audio[2400] - want).abs() < 2e-3, "slow down halfway: got {}, want {want}", audio[2400]);
|
||||
assert!(audio[4790].abs() < 0.01, "the fade-out ends near silence, got {}", audio[4790]);
|
||||
// Speed Up with Extend and no fades: all of the clip is played over 150 % of its length, and the
|
||||
// clip still starts at 480.
|
||||
let (start, length, _, audio) = run(json!({"change": 1, "selection": 1, "fades": 0}));
|
||||
assert_eq!((start, length), (480, 7200));
|
||||
assert!((audio[3600] - 5.0 / 16.0).abs() < 2e-3, "speed up halfway: got {}", audio[3600]);
|
||||
let r = 1.0 - 7100.0 / 7200.0;
|
||||
let want = 1.0 - (3.0 * r - r * r * r) / 2.0;
|
||||
assert!((audio[7100] - want).abs() < 2e-3, "speed up near the end: got {}, want {want}", audio[7100]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_generator_fills_the_selection_with_a_clip() {
|
||||
let (mut e, _) = gained_clip();
|
||||
@@ -256,4 +313,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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Clip List › Auto Rename: the names that editing gives the clips it separates from a clip.
|
||||
use super::{CommandSpec, bad};
|
||||
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::ClipId;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const AUTO_RENAME: &str = "clip.auto_rename";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"clip.auto_rename",
|
||||
"Auto Rename...",
|
||||
[],
|
||||
None,
|
||||
"{clips?: [id], name?, number?: 0..999999999, zeros?: 0..9, suffix?}: sets the names that editing gives the clips it separates from these clips (default: the Clip List's selected clips): the piece after each cut is named `name`, then `zeros` zeros, then a number counting up from `number` (default 1), then `suffix`, in one number sequence for all of them. Without a name, the clips keep their names when they are separated",
|
||||
has_clip_selection,
|
||||
auto_rename
|
||||
)]
|
||||
}
|
||||
|
||||
fn has_clip_selection(e: &Engine) -> std::result::Result<(), String> {
|
||||
if e.session().edit.selected_clips.is_empty() { Err("select a clip in the Clip List".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn auto_rename(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session();
|
||||
let clips: BTreeSet<ClipId> = match p.get("clips") {
|
||||
None => s.edit.selected_clips.iter().copied().collect(),
|
||||
Some(v) => BTreeSet::deserialize(v).map_err(|_| bad(AUTO_RENAME, "`clips` must be an array of ids"))?,
|
||||
};
|
||||
if clips.is_empty() {
|
||||
return Err(bad(AUTO_RENAME, "select a clip in the Clip List, or pass `clips`"));
|
||||
}
|
||||
let known: BTreeSet<ClipId> = s.all_clips().map(|c| c.id).collect();
|
||||
if let Some(c) = clips.iter().find(|c| !known.contains(c)) {
|
||||
return Err(bad(AUTO_RENAME, format!("no clip with id {c}")));
|
||||
}
|
||||
let names = super::edit::strip_names(AUTO_RENAME, p)?;
|
||||
let s = e.session_mut();
|
||||
let key = names.map(|n| {
|
||||
let (key, id) = (s.alloc(), crate::new_scheme_id(s));
|
||||
s.auto_names.insert(key, soundcraft_model::AutoName { id, ..n.into() });
|
||||
key
|
||||
});
|
||||
for c in s.tracks.iter_mut().flat_map(|t| t.playlists.iter_mut()).flat_map(|pl| pl.clips.iter_mut()) {
|
||||
if clips.contains(&c.id) {
|
||||
c.auto_name = key;
|
||||
}
|
||||
}
|
||||
// Earlier schemes stay while the session is open: copies on the clipboard can still use them.
|
||||
Ok(json!({"clips": clips.len(), "named": key.is_some()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The first Kick clip: its track, id, start and length.
|
||||
fn first_kick(e: &Engine) -> (soundcraft_model::TrackId, ClipId, i64, i64) {
|
||||
let s = e.session();
|
||||
let t = s.track_by_name("Kick").unwrap();
|
||||
let c = &t.clips()[0];
|
||||
(t.id, c.id, c.start, c.length)
|
||||
}
|
||||
|
||||
/// The names of the clips on `t` that lie inside `[start, end)`, in time order.
|
||||
fn names_in(e: &Engine, t: soundcraft_model::TrackId, start: i64, end: i64) -> Vec<String> {
|
||||
e.session().track(t).unwrap().clips().iter().filter(|c| c.start >= start && c.end() <= end).map(|c| c.name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separating_an_auto_renamed_clip_names_each_new_piece() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (t, id, start, len) = first_kick(&e);
|
||||
let old = e.session().find_clip(id).unwrap().1.name.clone();
|
||||
let r = e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX", "number": 23, "zeros": 1, "suffix": ".Reel1"})).unwrap();
|
||||
assert_eq!(r, json!({"clips": 1, "named": true}));
|
||||
assert_eq!(e.undo_label(), Some("Auto Rename..."));
|
||||
assert_eq!(names_in(&e, t, start, start + len), [old.as_str()], "setting the scheme leaves the clip's name");
|
||||
// Cut the middle, then each half: every piece after a cut is a new clip with the next name.
|
||||
for at in [start + len / 2, start + len / 4, start + len * 3 / 4] {
|
||||
e.execute("edit.separate", &json!({"tracks": [t], "at": at})).unwrap();
|
||||
}
|
||||
assert_eq!(names_in(&e, t, start, start + len), [old.as_str(), "SFX024.Reel1", "SFX023.Reel1", "SFX025.Reel1"]);
|
||||
assert!(e.undo());
|
||||
assert_eq!(names_in(&e, t, start, start + len).len(), 3);
|
||||
assert_eq!(e.session().auto_names.values().next().map(|a| a.number), Some(25), "undo takes the number back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_rename_uses_the_clip_list_selection_and_one_number_sequence() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let s = e.session();
|
||||
let t = s.track_by_name("Kick").unwrap();
|
||||
let (track, a, b) = (t.id, t.clips()[0].clone(), t.clips()[1].clone());
|
||||
e.execute("edit.select", &json!({"clips": [a.id, b.id]})).unwrap();
|
||||
e.execute("clip.auto_rename", &json!({"name": "Hit"})).unwrap();
|
||||
e.execute("edit.separate", &json!({"tracks": [track], "at": b.start + b.length / 2})).unwrap();
|
||||
e.execute("edit.separate", &json!({"tracks": [track], "at": a.start + a.length / 2})).unwrap();
|
||||
assert_eq!(names_in(&e, track, b.start, b.end())[1], "Hit1");
|
||||
assert_eq!(names_in(&e, track, a.start, a.end())[1], "Hit2", "both clips count up one sequence");
|
||||
assert_eq!(e.session().auto_names.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_rename_without_a_name_removes_the_scheme() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (t, id, start, len) = first_kick(&e);
|
||||
let old = e.session().find_clip(id).unwrap().1.name.clone();
|
||||
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap();
|
||||
let r = e.execute("clip.auto_rename", &json!({"clips": [id], "name": " "})).unwrap();
|
||||
assert_eq!(r["named"], false);
|
||||
assert_eq!(e.session().find_clip(id).unwrap().1.auto_name, None);
|
||||
e.execute("edit.separate", &json!({"tracks": [t], "at": start + len / 2})).unwrap();
|
||||
assert_eq!(names_in(&e, t, start, start + len), [old.clone(), old]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_copy_keeps_the_scheme_it_was_copied_with() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (t, id, start, len) = first_kick(&e);
|
||||
let old = e.session().find_clip(id).unwrap().1.name.clone();
|
||||
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap();
|
||||
e.execute("edit.copy", &json!({"tracks": [t], "start": start, "end": start + len})).unwrap();
|
||||
e.execute("clip.auto_rename", &json!({"clips": [id]})).unwrap();
|
||||
let at = e.session().content_end() + 48_000;
|
||||
e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap();
|
||||
e.execute("edit.separate", &json!({"tracks": [t], "at": at + len / 2})).unwrap();
|
||||
assert_eq!(names_in(&e, t, at, at + len)[1], "SFX1", "the copy's scheme outlives the original clip's");
|
||||
// Pasted into another session, the copy brings its scheme along and counts on from it.
|
||||
e.execute("edit.copy", &json!({"tracks": [t], "start": at, "end": at + len})).unwrap();
|
||||
e.replace_session(crate::demo::demo_engine().session().clone());
|
||||
let t = first_kick(&e).0;
|
||||
e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap();
|
||||
e.execute("edit.separate", &json!({"tracks": [t], "at": at + len / 4})).unwrap();
|
||||
assert_eq!(names_in(&e, t, at, at + len), [old.as_str(), "SFX2", "SFX1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_keeps_its_scheme_when_undo_hands_the_key_to_another() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (t, id, start, len) = first_kick(&e);
|
||||
let bass = e.session().track_by_name("Bass").unwrap().clips()[0].id;
|
||||
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap();
|
||||
e.execute("edit.copy", &json!({"tracks": [t], "start": start, "end": start + len})).unwrap();
|
||||
assert!(e.undo());
|
||||
// The same name under the key the undo freed is still another scheme.
|
||||
e.execute("clip.auto_rename", &json!({"clips": [bass], "name": "SFX", "number": 100})).unwrap();
|
||||
let at = e.session().content_end() + 48_000;
|
||||
e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap();
|
||||
e.execute("edit.separate", &json!({"tracks": [t], "at": at + len / 2})).unwrap();
|
||||
assert_eq!(names_in(&e, t, at, at + len)[1], "SFX1");
|
||||
let s = e.session();
|
||||
assert_eq!(s.auto_names.len(), 2);
|
||||
let key = s.find_clip(bass).unwrap().1.auto_name.unwrap();
|
||||
assert_eq!(s.auto_names[&key].number, 100, "the other scheme's count is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_keeps_its_scheme_when_another_session_holds_its_id_with_other_text() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (t, id, start, len) = first_kick(&e);
|
||||
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap();
|
||||
let copied = e.session().auto_names.values().next().unwrap().id;
|
||||
e.execute("edit.copy", &json!({"tracks": [t], "start": start, "end": start + len})).unwrap();
|
||||
// Another session holds a scheme under the same id with another name.
|
||||
e.replace_session(crate::demo::demo_engine().session().clone());
|
||||
let bass = e.session().track_by_name("Bass").unwrap().clips()[0].id;
|
||||
let s = e.session_mut();
|
||||
let key = s.alloc();
|
||||
s.auto_names.insert(key, soundcraft_model::AutoName { name: "Other".into(), number: 100, zeros: 0, suffix: String::new(), id: copied });
|
||||
s.tracks
|
||||
.iter_mut()
|
||||
.flat_map(|t| t.playlists.iter_mut())
|
||||
.flat_map(|pl| pl.clips.iter_mut())
|
||||
.filter(|c| c.id == bass)
|
||||
.for_each(|c| c.auto_name = Some(key));
|
||||
let t = first_kick(&e).0;
|
||||
let at = e.session().content_end() + 48_000;
|
||||
e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap();
|
||||
e.execute("edit.separate", &json!({"tracks": [t], "at": at + len / 2})).unwrap();
|
||||
assert_eq!(names_in(&e, t, at, at + len)[1], "SFX1");
|
||||
let s = e.session();
|
||||
assert_eq!(s.auto_names.len(), 2);
|
||||
assert_eq!(s.auto_names[&key].number, 100, "the other scheme's count is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_auto_rename_params_are_refused() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (_, id, _, _) = first_kick(&e);
|
||||
for p in [
|
||||
json!({"name": "SFX"}),
|
||||
json!({"clips": [], "name": "SFX"}),
|
||||
json!({"clips": [999_999], "name": "SFX"}),
|
||||
json!({"clips": "Kick", "name": "SFX"}),
|
||||
json!({"clips": [id], "name": 5}),
|
||||
json!({"clips": [id], "name": "SFX", "zeros": 10}),
|
||||
json!({"clips": [id], "name": "SFX", "number": 1_000_000_000}),
|
||||
json!({"clips": [id], "number": 3}),
|
||||
] {
|
||||
assert!(e.execute("clip.auto_rename", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(e.session().auto_names.is_empty());
|
||||
assert!(e.session().find_clip(id).unwrap().1.auto_name.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_rename_schemes_are_saved_and_repaired_on_open() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let (t, id, start, len) = first_kick(&e);
|
||||
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX", "number": 7})).unwrap();
|
||||
let mut v: Value = serde_json::from_str(&e.session().to_json().unwrap()).unwrap();
|
||||
let key = *e.session().auto_names.keys().next().unwrap();
|
||||
v["auto_names"][key.to_string()]["zeros"] = json!(usize::MAX);
|
||||
v["auto_names"]["999999999"] = json!({"name": "Unused", "number": 1, "zeros": 0, "suffix": ""});
|
||||
let mut s = soundcraft_model::Session::from_json(&v.to_string()).unwrap();
|
||||
assert_eq!(s.auto_names.len(), 1, "a scheme no clip uses is dropped");
|
||||
assert_eq!(s.auto_names[&key].zeros, soundcraft_model::AutoName::MAX_ZEROS);
|
||||
assert!(s.next_id > key);
|
||||
crate::edit::separate_at(&mut s, t, start + len / 2);
|
||||
let names: Vec<String> =
|
||||
s.track(t).unwrap().clips().iter().filter(|c| c.start >= start && c.end() <= start + len).map(|c| c.name.clone()).collect();
|
||||
assert_eq!(names[1], "SFX0000000007");
|
||||
}
|
||||
}
|
||||
@@ -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![
|
||||
@@ -65,7 +67,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("clip.loop", "Loop...", ["Clip"], Some("Cmd+Alt+L"), "{clips?, count?: n, length?: samples}", has_selection, loop_clips),
|
||||
cmd!(
|
||||
"clip.loop",
|
||||
"Loop...",
|
||||
["Clip"],
|
||||
Some("Cmd+Alt+L"),
|
||||
"{clips?, count?: n, length?: samples, fill?: bool}: count is the clip plus its loops; length is the whole looped clip, with the last loop cut short to fit; fill loops up to the next clip on the track, or else to the session end",
|
||||
has_selection,
|
||||
loop_clips
|
||||
),
|
||||
cmd!("clip.rename", "Rename...", ["Clip"], Some("Cmd+Alt+Shift+R"), "{clip?, name}", has_selection, rename),
|
||||
cmd!("clip.gain", "Clip Gain", [], None, "{clips?, db | delta_db}", has_selection, gain),
|
||||
cmd!("clip.gain_nudge_up", "Nudge Clip Gain Up", [], Some("Ctrl+Shift+Up"), "{clips?}", has_selection, |e, p| nudge_gain(e, p, 1.0)),
|
||||
@@ -285,15 +295,46 @@ fn layer(e: &mut Engine, p: &Value, front: bool) -> Result<Value> {
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
/// The most loops `clip.loop` makes of one clip.
|
||||
const MAX_LOOPS: i64 = 1000;
|
||||
|
||||
fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let count = i64_or(p, "count", 4).clamp(1, 512);
|
||||
let total = position_param(e, "clip.loop", p, "length")?;
|
||||
let fill = p.get("fill").and_then(Value::as_bool).unwrap_or(false);
|
||||
let s = e.session_mut();
|
||||
let session_end = s.content_end();
|
||||
let mut made = 0;
|
||||
for id in ids {
|
||||
let Some((tid, mut c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
|
||||
let reps = total.map_or(count - 1, |l| (l / c.length.max(1)).max(1) - 1).clamp(0, 1000);
|
||||
if c.length <= 0 {
|
||||
continue;
|
||||
}
|
||||
let len = c.length;
|
||||
// Where the looped clip ends: the next clip or the session end, or `length` after the start.
|
||||
let end = if fill {
|
||||
let next = s.track(tid).and_then(|t| t.clips().iter().filter(|x| x.id != c.id && x.start >= c.end()).map(|x| x.start).min());
|
||||
Some(next.unwrap_or(session_end))
|
||||
} else {
|
||||
total.map(|l| c.start.saturating_add(l))
|
||||
};
|
||||
// Whole iterations, then one cut short to reach `end` exactly.
|
||||
let (reps, last) = match end {
|
||||
Some(end) => {
|
||||
let span = end.saturating_sub(c.end()).max(0);
|
||||
let (whole, rest) = (span / len, span % len);
|
||||
let reps = whole.saturating_add(i64::from(rest > 0));
|
||||
if reps > MAX_LOOPS {
|
||||
return Err(bad(
|
||||
"clip.loop",
|
||||
format!("“{}” would need {reps} loops to fill that; a clip loops at most {MAX_LOOPS} times", c.name),
|
||||
));
|
||||
}
|
||||
(reps, if rest > 0 { rest } else { len })
|
||||
}
|
||||
None => ((count - 1).clamp(0, MAX_LOOPS), len),
|
||||
};
|
||||
if reps > 0 && c.loop_id.is_none() {
|
||||
let lid = s.alloc();
|
||||
if let Some(src) = s.find_clip_mut(id) {
|
||||
@@ -305,10 +346,15 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
for k in 1..=reps {
|
||||
let mut nc = c.clone();
|
||||
nc.start = 0;
|
||||
if k == reps && last < c.length {
|
||||
// `start` is 0 here, so the end is the length; the trim also shortens the fades to fit and removes a sync point past the cut.
|
||||
nc.trim_end_to(last);
|
||||
}
|
||||
copies.push((c.start + c.length * k, nc));
|
||||
}
|
||||
for (at, nc) in copies {
|
||||
let _ = crate::edit::paste(s, tid, at, &[nc], c.length, false);
|
||||
let length = nc.length;
|
||||
let _ = crate::edit::paste(s, tid, at, &[nc], length, false);
|
||||
made += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -99,24 +99,40 @@ fn regroup(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"group": g, "clips": n}))
|
||||
}
|
||||
|
||||
fn same_material(a: &Clip, b: &Clip) -> bool {
|
||||
a.length == b.length
|
||||
&& (a.loop_id.is_some() && a.loop_id == b.loop_id
|
||||
|| match (&a.content, &b.content) {
|
||||
(ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
|
||||
(ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2,
|
||||
(ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
|
||||
_ => false,
|
||||
})
|
||||
fn same_content(a: &Clip, b: &Clip) -> bool {
|
||||
match (&a.content, &b.content) {
|
||||
(ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
|
||||
(ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2,
|
||||
(ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The loop a clip belongs to: back-to-back copies of the same material. Origin first.
|
||||
fn same_material(a: &Clip, b: &Clip) -> bool {
|
||||
a.length == b.length && (a.loop_id.is_some() && a.loop_id == b.loop_id || same_content(a, b))
|
||||
}
|
||||
|
||||
/// Whether `short` is the last iteration of `full`'s loop, cut short at its end. It shares the loop,
|
||||
/// it is shorter, and no clip of the loop starts where it ends, as the other half of a split clip
|
||||
/// does. When `full` is a whole copy (an iteration of the same length ends where it starts), that is
|
||||
/// enough. When `full` is the loop's origin alone, `short` also has to hold `full`'s material.
|
||||
fn cut_short(clips: &[Clip], full: &Clip, short: &Clip) -> bool {
|
||||
let whole_copy = || clips.iter().any(|x| x.id != full.id && x.end() == full.start && same_material(x, full));
|
||||
full.loop_id.is_some()
|
||||
&& full.loop_id == short.loop_id
|
||||
&& short.length < full.length
|
||||
&& !clips.iter().any(|x| x.loop_id == short.loop_id && x.start == short.end())
|
||||
&& (whole_copy() || same_content(full, short))
|
||||
}
|
||||
|
||||
/// The loop a clip belongs to: back-to-back copies of the same material, the last of which can be
|
||||
/// cut short. Origin first.
|
||||
fn loop_chain(s: &Session, t: TrackId, id: ClipId) -> Vec<ClipId> {
|
||||
let Some(tr) = s.track(t) else { return Vec::new() };
|
||||
let clips = tr.clips();
|
||||
let Some(mut cur) = clips.iter().find(|c| c.id == id) else { return Vec::new() };
|
||||
let mut guard = 0;
|
||||
while let Some(prev) = clips.iter().find(|x| x.id != cur.id && x.end() == cur.start && same_material(x, cur)) {
|
||||
while let Some(prev) = clips.iter().find(|x| x.id != cur.id && x.end() == cur.start && (same_material(x, cur) || cut_short(clips, x, cur))) {
|
||||
cur = prev;
|
||||
guard += 1;
|
||||
if guard > clips.len() {
|
||||
@@ -128,6 +144,9 @@ fn loop_chain(s: &Session, t: TrackId, id: ClipId) -> Vec<ClipId> {
|
||||
chain.push(next.id);
|
||||
cur = next;
|
||||
}
|
||||
if let Some(last) = clips.iter().find(|x| !chain.contains(&x.id) && x.start == cur.end() && cut_short(clips, cur, x)) {
|
||||
chain.push(last.id);
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
@@ -342,6 +361,101 @@ mod tests {
|
||||
assert!(e.session().track(t).unwrap().clips().iter().all(|c| c.loop_id.is_none() && c.group.is_none()));
|
||||
}
|
||||
|
||||
fn spans(e: &Engine, t: TrackId) -> Vec<(Samples, Samples)> {
|
||||
e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.length)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_length_cuts_the_last_iteration_short() {
|
||||
let (mut e, t, id) = midi_engine();
|
||||
assert_eq!(e.execute("clip.loop", &json!({"clip": id.0, "length": 250})).unwrap()["loops"], 2);
|
||||
assert_eq!(spans(&e, t), vec![(0, 100), (100, 100), (200, 50)]);
|
||||
// A length that is a whole number of loops leaves no short one.
|
||||
let (mut e, t, id) = midi_engine();
|
||||
assert_eq!(e.execute("clip.loop", &json!({"clip": id.0, "length": 300})).unwrap()["loops"], 2);
|
||||
assert_eq!(spans(&e, t), vec![(0, 100), (100, 100), (200, 100)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_last_iteration_fits_its_fades_and_drops_a_sync_point_past_the_cut() {
|
||||
let (mut e, t, id) = midi_engine();
|
||||
if let Some(c) = e.session_mut().find_clip_mut(id) {
|
||||
c.sync_point = 80;
|
||||
c.fade_out.len = 70;
|
||||
}
|
||||
e.execute("clip.loop", &json!({"clip": id.0, "length": 150})).unwrap();
|
||||
let clips = e.session().track(t).unwrap().clips();
|
||||
let last = clips.last().unwrap();
|
||||
assert_eq!((last.start, last.length, last.sync_point, last.fade_out.len), (100, 50, 0, 50));
|
||||
assert_eq!((clips[0].sync_point, clips[0].fade_out.len), (80, 70));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloop_removes_a_short_last_iteration_too() {
|
||||
let (mut e, t, id) = midi_engine();
|
||||
e.execute("clip.loop", &json!({"clip": id.0, "length": 250})).unwrap();
|
||||
e.execute("clip.unloop", &json!({"clip": id.0})).unwrap();
|
||||
assert_eq!(spans(&e, t), vec![(0, 100)]);
|
||||
// An edit to the source note or to the short loop's note.
|
||||
for edited in [0, 2] {
|
||||
let (mut e, t, id) = midi_engine();
|
||||
e.execute("clip.loop", &json!({"clip": id.0, "length": 250})).unwrap();
|
||||
let target = e.session().track(t).unwrap().clips()[edited].id;
|
||||
if let Some(c) = e.session_mut().find_clip_mut(target)
|
||||
&& let ClipContent::Midi { sequence } = &mut c.content
|
||||
{
|
||||
sequence.notes[0].length = 1100;
|
||||
}
|
||||
e.execute("clip.unloop", &json!({"clip": id.0})).unwrap();
|
||||
assert_eq!(spans(&e, t), vec![(0, 100)], "edited loop {edited}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloop_keeps_both_halves_of_a_split_clip() {
|
||||
let (mut e, t, ids) = engine_with(&[(0, 100, 0)]);
|
||||
e.execute("clip.loop", &json!({"clip": ids[0].0, "count": 3})).unwrap();
|
||||
crate::edit::separate_at(e.session_mut(), t, 230);
|
||||
e.execute("clip.unloop", &json!({"clip": ids[0].0})).unwrap();
|
||||
assert_eq!(spans(&e, t), vec![(0, 100), (200, 30), (230, 70)]);
|
||||
// A split loop origin is not a loop.
|
||||
let (mut e, t, ids) = engine_with(&[(0, 100, 0)]);
|
||||
e.execute("clip.loop", &json!({"clip": ids[0].0, "count": 2})).unwrap();
|
||||
crate::edit::separate_at(e.session_mut(), t, 30);
|
||||
assert!(e.execute("clip.unloop", &json!({"clip": ids[0].0})).is_err());
|
||||
assert_eq!(spans(&e, t), vec![(0, 30), (30, 70), (100, 100)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_fill_stops_at_the_next_clip() {
|
||||
let (mut e, t, ids) = engine_with(&[(0, 100, 0), (430, 100, 0)]);
|
||||
assert_eq!(e.execute("clip.loop", &json!({"clip": ids[0].0, "fill": true})).unwrap()["loops"], 4);
|
||||
assert_eq!(spans(&e, t), vec![(0, 100), (100, 100), (200, 100), (300, 100), (400, 30), (430, 100)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_fill_without_a_next_clip_runs_to_the_session_end() {
|
||||
let (mut e, t, ids) = engine_with(&[(0, 100, 0)]);
|
||||
let other = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let far = e.session_mut().new_clip_id();
|
||||
crate::edit::place_clip(e.session_mut(), other, Clip::audio(far, "far", SourceId(9), 0, 200, 150));
|
||||
assert_eq!(e.execute("clip.loop", &json!({"clip": ids[0].0, "fill": true})).unwrap()["loops"], 3);
|
||||
assert_eq!(spans(&e, t), vec![(0, 100), (100, 100), (200, 100), (300, 50)]);
|
||||
// The last clip in the session has nothing to fill up to.
|
||||
assert_eq!(e.execute("clip.loop", &json!({"clip": far.0, "fill": true})).unwrap()["loops"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_or_length_past_the_loop_limit_is_refused_without_an_edit() {
|
||||
let (mut e, t, ids) = engine_with(&[(0, 100, 0), (100_150, 100, 0)]);
|
||||
let err = e.execute("clip.loop", &json!({"clip": ids[0].0, "fill": true})).unwrap_err().to_string();
|
||||
assert!(err.contains("1001 loops"), "{err}");
|
||||
assert!(e.execute("clip.loop", &json!({"clip": ids[0].0, "length": 100_200})).is_err());
|
||||
assert_eq!(spans(&e, t), vec![(0, 100), (100_150, 100)]);
|
||||
// 1000 loops still fit.
|
||||
assert_eq!(e.execute("clip.loop", &json!({"clip": ids[0].0, "length": 100_100})).unwrap()["loops"], 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regroup_reuses_group() {
|
||||
let (mut e, _, ids) = engine_with(&[(0, 100, 0), (500, 100, 0), (900, 100, 0)]);
|
||||
|
||||
+123
-19
@@ -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![
|
||||
@@ -117,10 +121,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Strip Silence",
|
||||
["Edit"],
|
||||
Some("Cmd+U"),
|
||||
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20}",
|
||||
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, mode?: strip|extract|separate, name?, number?: 1, zeros?: 0, suffix?} (strip removes the silence; extract removes the audio and keeps the silence; separate separates the clips at the edges of the silence and does not remove any; with a name, the clips left in the selection are named name, zeros, a number counting up from number, suffix)",
|
||||
has_range,
|
||||
strip_silence
|
||||
),
|
||||
cmd!(
|
||||
query "edit.strip_silence_preview",
|
||||
"Strip Silence Rectangles",
|
||||
[],
|
||||
None,
|
||||
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, tracks?, start?, end?} (the ranges edit.strip_silence finds with the same settings, padded, on each track: what Strip keeps and the Strip Silence window draws as rectangles; changes nothing)",
|
||||
has_range,
|
||||
strip_silence_preview
|
||||
),
|
||||
cmd!(
|
||||
"edit.fades_create",
|
||||
"Create...",
|
||||
@@ -276,6 +289,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
|
||||
.unwrap_or_default();
|
||||
cb.automation.push(lanes);
|
||||
}
|
||||
cb.keep_auto_names(e.session());
|
||||
cb.markers = e
|
||||
.session()
|
||||
.markers
|
||||
@@ -312,10 +326,21 @@ fn cut_track(s: &mut Session, t: TrackId, r: soundcraft_time::Range, sh: bool, f
|
||||
ops::clear_range(s, t, r, sh);
|
||||
}
|
||||
|
||||
/// The clipboard for a paste, with the Auto Rename schemes of its clips put into the session first
|
||||
/// ([`Clipboard::adopt_auto_names`]).
|
||||
pub(super) fn clipboard_for_paste(e: &mut Engine) -> Clipboard {
|
||||
let mut cb = e.clipboard.clone();
|
||||
if cb.tracks.iter().flatten().any(|c| c.auto_name.is_some()) {
|
||||
cb.adopt_auto_names(e.session_mut());
|
||||
e.clipboard = cb.clone();
|
||||
}
|
||||
cb
|
||||
}
|
||||
|
||||
fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.paste", p)?;
|
||||
let given = position_param(e, "edit.paste", p, "at")?;
|
||||
let cb = e.clipboard.clone();
|
||||
let cb = clipboard_for_paste(e);
|
||||
let s = e.session();
|
||||
let at = match given {
|
||||
Some(at) => at,
|
||||
@@ -413,7 +438,7 @@ fn repeat_to_fill(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let range = range_param(e, "edit.paste_repeat_to_fill", p)?;
|
||||
let len = e.clipboard.length.max(1);
|
||||
let tracks = tracks_required(e, "edit.paste_repeat_to_fill", p)?;
|
||||
let cb = e.clipboard.clone();
|
||||
let cb = clipboard_for_paste(e);
|
||||
let s = e.session_mut();
|
||||
let mut at = range.start;
|
||||
let mut n = 0;
|
||||
@@ -785,25 +810,94 @@ fn copy_to_new_playlist(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"tracks": tracks.len()}))
|
||||
}
|
||||
|
||||
fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.strip_silence", p)?;
|
||||
let r = range_param(e, "edit.strip_silence", p)?;
|
||||
/// Strip Silence's tracks, range, threshold, minimum strip duration and clip start and end pads.
|
||||
type StripSettings = (Vec<TrackId>, soundcraft_time::Range, f32, Samples, (Samples, Samples));
|
||||
|
||||
/// The [`StripSettings`] in Strip Silence's params, with their defaults and limits. `cmd` names the
|
||||
/// command in errors.
|
||||
fn strip_settings(e: &Engine, cmd: &str, p: &Value) -> Result<StripSettings> {
|
||||
let tracks = tracks_required(e, cmd, p)?;
|
||||
let r = range_param(e, cmd, p)?;
|
||||
let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0);
|
||||
let sr = e.session().sample_rate;
|
||||
let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0);
|
||||
let (min_len, pre, post) = (ms("min_length_ms", 50.0), ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0));
|
||||
Ok((tracks, r, thr, ms("min_length_ms", 50.0), (ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0))))
|
||||
}
|
||||
|
||||
/// The scan `edit.strip_silence_preview` runs with params `p`, on the session as it is now. The Edit
|
||||
/// window runs it while the Strip Silence window or its Rename Selected Clips dialog is open: on a
|
||||
/// worker thread outside the web build, and on the frame thread in the web build or when no thread
|
||||
/// can be started.
|
||||
pub fn strip_silence_scan(e: &Engine, p: &Value) -> Result<crate::io::StripScan> {
|
||||
let (tracks, range, threshold_db, min_len, pad) = strip_settings(e, "edit.strip_silence_preview", p)?;
|
||||
Ok(crate::io::StripScan { session: e.session_arc(), tracks, range, threshold_db, min_len, pad, stop: Default::default() })
|
||||
}
|
||||
|
||||
fn strip_silence_preview(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let found = strip_silence_scan(e, p)?.run()?;
|
||||
let tracks: Vec<Value> =
|
||||
found.iter().map(|(t, ranges)| json!({"track": t.0, "ranges": ranges.iter().map(|x| [x.start, x.end]).collect::<Vec<_>>()})).collect();
|
||||
Ok(json!({"tracks": tracks}))
|
||||
}
|
||||
|
||||
fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let (tracks, r, thr, min_len, (pre, post)) = strip_settings(e, "edit.strip_silence", p)?;
|
||||
let mode = match p.get("mode") {
|
||||
None => crate::io::StripMode::Strip,
|
||||
Some(Value::String(m)) => crate::io::StripMode::from_id(m).ok_or_else(|| bad("edit.strip_silence", format!("unknown mode `{m}`")))?,
|
||||
Some(_) => return Err(bad("edit.strip_silence", "mode must be strip, extract or separate")),
|
||||
};
|
||||
let names = strip_names("edit.strip_silence", p)?;
|
||||
let s = e.session_mut();
|
||||
let mut kept = 0;
|
||||
let mut named = 0;
|
||||
for t in &tracks {
|
||||
kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post)?;
|
||||
kept += crate::io::strip_silence(s, *t, r, thr, min_len, (pre, post), mode, names.is_some())?;
|
||||
if let Some(names) = &names {
|
||||
named += crate::io::name_stripped_clips(s, *t, r, names, named);
|
||||
}
|
||||
}
|
||||
Ok(json!({"clips": kept}))
|
||||
Ok(json!({"clips": kept, "named": named}))
|
||||
}
|
||||
|
||||
/// The naming scheme in the `name`, `number`, `zeros` and `suffix` params of `cmd`
|
||||
/// (`edit.strip_silence`, `clip.auto_rename`), or `None` when `name` is missing or blank.
|
||||
pub(super) fn strip_names(cmd: &str, p: &Value) -> Result<Option<crate::io::StripNames>> {
|
||||
use crate::io::StripNames;
|
||||
let text = |k: &str| match p.get(k) {
|
||||
None => Ok(None),
|
||||
Some(Value::String(v)) => Ok(Some(v.clone())),
|
||||
Some(_) => Err(bad(cmd, format!("`{k}` must be text"))),
|
||||
};
|
||||
let whole = |k: &str, max: u64| match p.get(k) {
|
||||
None => Ok(None),
|
||||
Some(v) => v.as_u64().filter(|n| *n <= max).map(Some).ok_or_else(|| bad(cmd, format!("`{k}` must be a whole number from 0 to {max}"))),
|
||||
};
|
||||
let (name, suffix) = (text("name")?, text("suffix")?);
|
||||
let (number, zeros) = (whole("number", StripNames::MAX_NUMBER)?, whole("zeros", StripNames::MAX_ZEROS as u64)?);
|
||||
let Some(name) = name.filter(|n| !n.trim().is_empty()) else {
|
||||
if suffix.is_some() || number.is_some() || zeros.is_some() {
|
||||
return Err(bad(cmd, "`number`, `zeros` and `suffix` need a `name`"));
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
let d = StripNames::default();
|
||||
let suffix = suffix.unwrap_or(d.suffix);
|
||||
let max_text = soundcraft_model::AutoName::MAX_TEXT_CHARS;
|
||||
if name.chars().count() + suffix.chars().count() > max_text {
|
||||
return Err(bad(cmd, format!("`name` and `suffix` together must be at most {max_text} characters")));
|
||||
}
|
||||
Ok(Some(StripNames { name, number: number.unwrap_or(d.number), zeros: zeros.and_then(|z| usize::try_from(z).ok()).unwrap_or(d.zeros), suffix }))
|
||||
}
|
||||
|
||||
fn fades_create(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let shape = str_param(p, "shape")
|
||||
.map_or(Some(FadeShape::EqualPower), FadeShape::from_id)
|
||||
.ok_or_else(|| bad("edit.fades_create", "unknown fade shape"))?;
|
||||
let shape = match p.get("shape") {
|
||||
None => FadeShape::EqualPower,
|
||||
Some(v) => v
|
||||
.as_str()
|
||||
.and_then(FadeShape::from_id)
|
||||
.ok_or_else(|| bad("edit.fades_create", "shape must be linear|equal power|s-curve|exponential|logarithmic"))?,
|
||||
};
|
||||
if p.get("clip").is_some() || p.get("side").is_some() {
|
||||
let curvature = match p.get("curvature") {
|
||||
None => 0.0,
|
||||
@@ -831,9 +925,6 @@ fn fades_create(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
if available <= 0 {
|
||||
return Err(bad("edit.fades_create", "clip has no length"));
|
||||
}
|
||||
if p.get("shape").is_some_and(|v| !v.is_string()) {
|
||||
return Err(bad("edit.fades_create", "shape must be a fade shape name"));
|
||||
}
|
||||
let fade = Fade { len: length.min(available), shape, curvature };
|
||||
let clip = e.session_mut().find_clip_mut(id).ok_or_else(|| bad("edit.fades_create", "clip not found"))?;
|
||||
if fade_in {
|
||||
@@ -1070,6 +1161,19 @@ mod tests {
|
||||
assert_eq!(e.session(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_fades_refuse_a_shape_that_is_not_a_shape_name() {
|
||||
for shape in [json!("bad"), json!(null), json!(true), json!(1)] {
|
||||
let (mut e, t, _) = trim_session(&[(0, 10_000, 0), (10_000, 10_000, 10_000)]);
|
||||
let before = e.session().clone();
|
||||
let history = e.undo_history();
|
||||
let params = json!({"tracks": [t], "start": 9_500, "end": 10_500, "shape": shape});
|
||||
assert!(e.execute("edit.fades_create", ¶ms).is_err(), "{params}");
|
||||
assert_eq!(e.session(), &before, "{params}");
|
||||
assert_eq!(e.undo_history(), history, "{params}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targeted_fades_support_one_sample_clips() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -450,7 +452,7 @@ fn paste_merge_midi(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "edit.paste_merge_midi";
|
||||
let tracks = tracks_required(e, id, p)?;
|
||||
let at = position_param(e, id, p, "at")?.unwrap_or(e.session().edit.selection.start).max(0);
|
||||
let cb = e.clipboard.clone();
|
||||
let cb = super::edit::clipboard_for_paste(e);
|
||||
let s = e.session_mut();
|
||||
let tempo = s.tempo.clone();
|
||||
let sr = s.sample_rate;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -782,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();
|
||||
|
||||
@@ -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> {
|
||||
@@ -85,7 +88,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!(noundo "file.bounce_mix", "Bounce Mix...", ["File"], Some("Cmd+Alt+B"), "{path, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, source?: main|bus name, normalize?: false, dither?: true, fold_down?: stereo}", always, bounce),
|
||||
cmd!(noundo "file.bounce_stems", "Bounce Stems...", [], None, "{dir, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, fold_down?: stereo}", has_tracks, |e, p| {
|
||||
cmd!(noundo "file.bounce_stems", "Bounce Stems...", [], None, "{dir, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, dither?: true, fold_down?: stereo}", has_tracks, |e, p| {
|
||||
let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string();
|
||||
let r = range_param(e, "file.bounce_stems", p)?;
|
||||
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
|
||||
@@ -100,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
_ => soundcraft_audio_io::BitDepth::Int24,
|
||||
};
|
||||
let fold = fold_down_param(p, "file.bounce_stems")?;
|
||||
let files = crate::io::bounce_stems_with(e, &dir, r, &soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None }, fold)?;
|
||||
let files = crate::io::bounce_stems_with(e, &dir, r, &soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None }, fold)?;
|
||||
Ok(json!({"files": files}))
|
||||
}),
|
||||
cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| {
|
||||
@@ -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}))
|
||||
@@ -237,9 +241,17 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let target = import_audio_target(e, p)?;
|
||||
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 r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?;
|
||||
let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Audio").to_string();
|
||||
// Audio the app decoded on a worker thread (`Engine::offer_import`) is only placed here;
|
||||
// the path is checked the same way either way.
|
||||
crate::file_access::check_read(std::path::Path::new(path)).map_err(crate::EngineError::Io)?;
|
||||
let r = match e.take_import(std::path::Path::new(path)) {
|
||||
Some(d) => crate::io::place_decoded(e, &name, d, Some(path.as_str()), target, at)?,
|
||||
None => {
|
||||
let bytes = crate::io::read_file(path)?;
|
||||
crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?
|
||||
}
|
||||
};
|
||||
out.push(r);
|
||||
}
|
||||
Ok(json!({"imported": out}))
|
||||
@@ -299,7 +311,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 +362,64 @@ mod tests {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// Issue #597: the app decodes on a worker thread and hands the audio over; the command then
|
||||
/// only places it. Audio decoded for another rate is ignored and the file is read again.
|
||||
#[test]
|
||||
fn import_audio_places_audio_offered_by_a_worker() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-import-offered-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let wav = tone_wav(&dir, "tone.wav");
|
||||
|
||||
let mut e = Engine::default();
|
||||
let rate = e.session().sample_rate.hz();
|
||||
let decoded = std::thread::spawn({
|
||||
let wav = wav.clone();
|
||||
move || crate::io::decode_import(&wav, rate)
|
||||
})
|
||||
.join()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(decoded.frames(), 4_800);
|
||||
e.offer_import(decoded);
|
||||
// The file changes after decoding: the offered audio is placed, not the new bytes.
|
||||
std::fs::write(&wav, b"not audio").unwrap();
|
||||
let r = e.execute("file.import_audio", &json!({"path": wav, "at": 960})).unwrap();
|
||||
assert_eq!(r["imported"][0]["frames"], 4_800);
|
||||
assert_eq!(e.session().tracks.len(), 1);
|
||||
assert_eq!(e.session().tracks[0].clips()[0].start, 960);
|
||||
assert_eq!(e.session().sources[0].path, wav);
|
||||
// Taken once: the next import reads the (now broken) file.
|
||||
assert!(e.execute("file.import_audio", &json!({"path": wav})).is_err());
|
||||
|
||||
// Decoded for another rate: ignored, so the broken file is read and refused.
|
||||
let good = tone_wav(&dir, "good.wav");
|
||||
let other = crate::io::decode_import(&good, rate / 2).unwrap();
|
||||
std::fs::write(&good, b"not audio").unwrap();
|
||||
e.offer_import(other);
|
||||
assert!(e.execute("file.import_audio", &json!({"path": good})).is_err());
|
||||
e.drop_offered_imports();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[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;
|
||||
@@ -130,8 +130,19 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
n.pitch = i64::from(n.pitch).saturating_add(ds).clamp(0, 127) as u8;
|
||||
}
|
||||
}
|
||||
let moved: Vec<Note> = seq.notes.iter().enumerate().filter(|(i, _)| idx.contains(i)).map(|(_, n)| *n).collect();
|
||||
seq.sort();
|
||||
Ok(json!({"moved": idx.len()}))
|
||||
// Where the moved notes ended up after sorting, so the editor can keep them selected.
|
||||
let mut taken = vec![false; seq.notes.len()];
|
||||
let mut indices = Vec::with_capacity(moved.len());
|
||||
for m in &moved {
|
||||
if let Some(j) = seq.notes.iter().enumerate().position(|(j, n)| !taken[j] && n == m) {
|
||||
taken[j] = true;
|
||||
indices.push(j);
|
||||
}
|
||||
}
|
||||
indices.sort_unstable();
|
||||
Ok(json!({"moved": idx.len(), "indices": indices}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -172,6 +183,20 @@ mod tests {
|
||||
assert_eq!(n["notes"][0]["pitch"], 127);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_move_reports_where_the_moved_notes_went() {
|
||||
let mut e = Engine::default();
|
||||
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
|
||||
let clip = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap()["clip"].as_u64().unwrap();
|
||||
for start in [0, 480, 960] {
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": start})).unwrap();
|
||||
}
|
||||
let r = e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "ticks": 720})).unwrap();
|
||||
assert_eq!(r["indices"], json!([1]));
|
||||
let notes = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
|
||||
assert_eq!(notes["notes"][1]["start"], 720);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_audio_tracks() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
+113
-12
@@ -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> {
|
||||
@@ -444,7 +446,7 @@ fn send(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?;
|
||||
let explicit = optional_slot(p, "mix.send", SEND_SLOTS)?;
|
||||
let s = e.session_mut();
|
||||
let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo);
|
||||
let bid = s.bus_by_name(&bus).map(|b| b.id).ok_or_else(|| bad("mix.send", format!("no bus named \"{bus}\"")))?;
|
||||
let tr = s.track_mut(t).ok_or_else(|| bad("mix.send", "no track"))?;
|
||||
let slot = explicit.or_else(|| tr.mixer.sends.iter().position(Option::is_none)).ok_or_else(|| bad("mix.send", "all send slots are in use"))?;
|
||||
// Re-targeting an existing send keeps its level, pan, mute and pre/post setting.
|
||||
@@ -695,23 +697,35 @@ fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
for t in &tracks {
|
||||
let Some(tr) = s.track_mut(*t) else { continue };
|
||||
let base = tr.mixer.volume_db;
|
||||
// An existing lane overrides the fader, so the clip gain rides on top of the lane value.
|
||||
let old = tr.lane(&AutoParam::Volume).cloned();
|
||||
let level = |at: i64| old.as_ref().map_or(base, |l| l.value_at(at, base));
|
||||
let mut pins = Vec::new();
|
||||
let mut pts = Vec::new();
|
||||
if let Some(pl) = tr.playlist_mut() {
|
||||
for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) {
|
||||
if c.gain_env.is_empty() {
|
||||
pts.push((c.start, base + c.gain_db));
|
||||
pts.push((c.end() - 1, base + c.gain_db));
|
||||
} else {
|
||||
for (o, db) in &c.gain_env {
|
||||
pts.push((c.start + o, base + c.gain_db + db));
|
||||
}
|
||||
// Pin the unchanged level just outside the clip so the neighbouring segments keep their shape.
|
||||
if c.start > 0 {
|
||||
pins.push((c.start - 1, level(c.start - 1)));
|
||||
}
|
||||
pins.push((c.end(), level(c.end())));
|
||||
// The clip edges always get a point, so the envelope's first and last values hold out to them.
|
||||
let mut ats = vec![c.start, c.end() - 1];
|
||||
ats.extend(c.gain_env.iter().map(|&(o, _)| c.start + o).filter(|&at| at > c.start && at < c.end() - 1));
|
||||
// The lane's own points inside the clip carry the clip gain too.
|
||||
if let Some(l) = &old {
|
||||
ats.extend(l.points.iter().map(|pt| pt.at).filter(|&at| at > c.start && at < c.end() - 1));
|
||||
}
|
||||
for at in ats {
|
||||
pts.push((at, level(at) + c.gain_db + env_db(&c.gain_env, at - c.start)));
|
||||
}
|
||||
c.gain_db = 0.0;
|
||||
c.gain_env.clear();
|
||||
}
|
||||
}
|
||||
let lane = tr.lane_mut(&AutoParam::Volume);
|
||||
for (at, v) in pts {
|
||||
// Pins first, so a neighbouring clip's converted points win where they meet.
|
||||
for (at, v) in pins.into_iter().chain(pts) {
|
||||
lane.set_point(at, v);
|
||||
}
|
||||
}
|
||||
@@ -870,6 +884,77 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_gain_to_volume_keeps_the_existing_lane() {
|
||||
let clip_gain_to_volume = |points: &[(i64, f32)], env: Vec<(i64, f32)>| {
|
||||
let (mut e, t) = tone_session(points);
|
||||
let clip = e.session().track(t).unwrap().clips()[0].id;
|
||||
let c = e.session_mut().find_clip_mut(clip).unwrap();
|
||||
c.gain_db = 6.0;
|
||||
c.gain_env = env;
|
||||
let before = rendered(e.session());
|
||||
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": 8192})).unwrap();
|
||||
let after = rendered(e.session());
|
||||
assert!(e.session().find_clip(clip).unwrap().1.gain_db == 0.0);
|
||||
(before, after)
|
||||
};
|
||||
let (before, after) = clip_gain_to_volume(&[(0, -6.0), (8192, -6.0)], Vec::new());
|
||||
let delta = rms_db(&before, &after);
|
||||
assert!(delta.abs() < 0.02, "level changed by {delta} dB");
|
||||
|
||||
// A moving lane keeps its shape, and its points inside the clip carry the gain.
|
||||
let (before, after) = clip_gain_to_volume(&[(0, -12.0), (4096, -6.0), (8192, -12.0)], Vec::new());
|
||||
for (start, end) in [(0, 2048), (2048, 4096), (4096, 6144), (6144, 8192)] {
|
||||
let delta = rms_db(&before[start..end], &after[start..end]);
|
||||
assert!(delta.abs() < 0.1, "{start}..{end} changed by {delta} dB");
|
||||
}
|
||||
|
||||
// The clip's own gain envelope rides on the lane value as well.
|
||||
let (before, after) = clip_gain_to_volume(&[(0, -6.0), (8192, -6.0)], vec![(2000, 0.0), (6000, -10.0)]);
|
||||
for (start, end) in [(3900, 4100), (5000, 5200)] {
|
||||
let delta = rms_db(&before[start..end], &after[start..end]);
|
||||
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_gain_to_volume_holds_the_envelope_ends_out_to_the_clip_edges() {
|
||||
let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]);
|
||||
let clip = e.session().track(t).unwrap().clips()[0].id;
|
||||
let c = e.session_mut().find_clip_mut(clip).unwrap();
|
||||
c.gain_db = 6.0;
|
||||
c.gain_env = vec![(2000, 0.0), (6000, -10.0)];
|
||||
let before = rendered(e.session());
|
||||
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": 8192})).unwrap();
|
||||
let after = rendered(e.session());
|
||||
for (start, end) in [(0, 1000), (1000, 2000), (6000, 7000), (7000, 8192)] {
|
||||
let delta = rms_db(&before[start..end], &after[start..end]);
|
||||
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_gain_to_volume_leaves_audio_outside_the_clip_alone() {
|
||||
// Three clips in a row; only the middle one is converted, and the lane extends past it on both sides.
|
||||
let (mut e, t) = tone_session(&[(0, -6.0), (16384, -6.0)]);
|
||||
let (middle, last) = (e.session_mut().new_clip_id(), e.session_mut().new_clip_id());
|
||||
let pl = e.session_mut().track_mut(t).unwrap().playlist_mut().unwrap();
|
||||
if let Some(c) = pl.clips.first_mut() {
|
||||
c.length = 2048;
|
||||
}
|
||||
pl.clips.push(Clip::audio(middle, "mid", SourceId(910), 2048, 2048, 4096));
|
||||
pl.clips.push(Clip::audio(last, "end", SourceId(910), 6144, 6144, 2048));
|
||||
e.session_mut().find_clip_mut(middle).unwrap().gain_db = 6.0;
|
||||
let before = rendered(e.session());
|
||||
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 2048, "end": 6144})).unwrap();
|
||||
let after = rendered(e.session());
|
||||
assert!(e.session().find_clip(middle).unwrap().1.gain_db == 0.0);
|
||||
for (start, end) in [(0, 2040), (2056, 6136), (6152, 8192)] {
|
||||
let delta = rms_db(&before[start..end], &after[start..end]);
|
||||
assert!(delta.abs() < 0.1, "{start}..{end} changed by {delta} dB");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_params_rejects_unknown_and_non_numeric_keys() {
|
||||
let mut e = Engine::default();
|
||||
@@ -930,8 +1015,10 @@ mod tests {
|
||||
fn retargeting_a_send_keeps_its_settings() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
|
||||
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
|
||||
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap();
|
||||
e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap();
|
||||
e.execute("mix.new_bus", &json!({"name": "Delay"})).unwrap();
|
||||
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap();
|
||||
let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap();
|
||||
let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id;
|
||||
@@ -985,10 +1072,24 @@ mod tests {
|
||||
assert_eq!(count(&e), 0, "absent param must still clear every lane");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_rejects_unknown_bus() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
|
||||
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
|
||||
let err = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Nowhere"})).unwrap_err();
|
||||
assert!(err.to_string().contains("no bus named \"Nowhere\""), "{err}");
|
||||
assert_eq!(e.session().busses.len(), 1);
|
||||
assert!(e.session().tracks[0].mixer.sends.iter().all(Option::is_none));
|
||||
e.execute("mix.send", &json!({"track": "Gtr", "bus": "verb"})).unwrap();
|
||||
assert_eq!(e.session().busses.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_send_reject_invalid_slot() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
|
||||
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
|
||||
for slot in [json!(12), json!("k"), json!("x1")] {
|
||||
let r = e.execute("mix.insert", &json!({"track": "Gtr", "slot": slot, "plugin": "compressor"}));
|
||||
assert!(r.is_err(), "insert slot {slot} accepted: {r:?}");
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::sync::OnceLock;
|
||||
|
||||
mod audio_midi;
|
||||
mod audiosuite;
|
||||
mod auto_rename;
|
||||
mod clap;
|
||||
mod clip;
|
||||
mod clip_list;
|
||||
@@ -38,9 +39,31 @@ 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;
|
||||
pub use edit::strip_silence_scan;
|
||||
|
||||
pub type Run = fn(&mut Engine, &Value) -> Result<Value>;
|
||||
/// `Err(reason)` = disabled.
|
||||
@@ -81,7 +104,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(),
|
||||
@@ -126,6 +149,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(track_more::specs());
|
||||
v.extend(clip_more::specs());
|
||||
v.extend(clip_list::specs());
|
||||
v.extend(auto_rename::specs());
|
||||
v.extend(event_more::specs());
|
||||
v.extend(setup_more::specs());
|
||||
v.extend(clap::specs());
|
||||
@@ -136,6 +160,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 +250,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;
|
||||
@@ -461,6 +461,8 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
s.sources.push(ns);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
// The donor's Auto Rename schemes come along under new keys, shared as they were.
|
||||
let mut names: std::collections::BTreeMap<u64, u64> = Default::default();
|
||||
for mut tr in picked {
|
||||
tr.id = TrackId(s.alloc());
|
||||
tr.name = s.unique_track_name(&tr.name);
|
||||
@@ -474,6 +476,14 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
{
|
||||
*source = *n;
|
||||
}
|
||||
c.auto_name = c.auto_name.and_then(|k| {
|
||||
let scheme = other.auto_names.get(&k)?;
|
||||
Some(*names.entry(k).or_insert_with(|| {
|
||||
let (key, id) = (s.alloc(), crate::new_scheme_id(s));
|
||||
s.auto_names.insert(key, soundcraft_model::AutoName { id, ..scheme.clone() });
|
||||
key
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
// The donor's source ids mean nothing here: remap the sample, or drop it when it didn't come along.
|
||||
@@ -503,7 +513,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();
|
||||
@@ -602,6 +611,29 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_session_data_brings_auto_rename_schemes_under_new_keys() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-import-names-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("donor.scraft").to_string_lossy().into_owned();
|
||||
let mut donor = crate::demo::demo_engine();
|
||||
let kick: Vec<_> = donor.session().track_by_name("Kick").unwrap().clips().iter().map(|c| c.id.0).collect();
|
||||
donor.execute("clip.auto_rename", &json!({"clips": kick, "name": "Hit", "number": 5})).unwrap();
|
||||
crate::io::save_session(&mut donor, &path).unwrap();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let bass = e.session().track_by_name("Bass").unwrap().clips()[0].id;
|
||||
e.execute("clip.auto_rename", &json!({"clips": [bass], "name": "Low"})).unwrap();
|
||||
e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Kick"]})).unwrap();
|
||||
let s = e.session();
|
||||
assert_eq!(s.auto_names.len(), 2, "the donor's scheme comes in beside this session's");
|
||||
let imported = s.tracks.iter().rfind(|t| t.name.starts_with("Kick")).unwrap();
|
||||
let keys: std::collections::BTreeSet<_> = imported.clips().iter().map(|c| c.auto_name).collect();
|
||||
assert_eq!(keys.len(), 1, "the imported clips still share one scheme");
|
||||
let key = keys.into_iter().next().flatten().unwrap();
|
||||
assert_eq!((s.auto_names[&key].name.as_str(), s.auto_names[&key].number), ("Hit", 5));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_session_data_brings_a_samplers_sample() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-import-sampler-{}", std::process::id()));
|
||||
|
||||
@@ -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, ClipContent, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -412,6 +414,8 @@ fn split_mono(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.split_into_mono", p)?;
|
||||
let s = e.session_mut();
|
||||
let mut out = Vec::new();
|
||||
let mut mono_sources = std::collections::BTreeMap::new();
|
||||
let mut copied_samples = 0usize;
|
||||
for t in tracks {
|
||||
let Some(src) = s.track(t).cloned() else { continue };
|
||||
if src.channels() < 2 || src.kind != TrackKind::Audio {
|
||||
@@ -427,12 +431,46 @@ fn split_mono(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let mut clips = src.clips().to_vec();
|
||||
for c in &mut clips {
|
||||
c.id = s.new_clip_id();
|
||||
// Mono tracks read channel 0 of the source; record the channel in the name for now.
|
||||
if let ClipContent::Audio { source, .. } = &mut c.content {
|
||||
let audio =
|
||||
s.pool.get(*source).ok_or_else(|| bad("track.split_into_mono", format!("audio unavailable for clip `{}`", c.name)))?;
|
||||
let channels = audio.buffer.channels.len();
|
||||
if channels == 0 {
|
||||
return Err(bad("track.split_into_mono", format!("audio has no channels for clip `{}`", c.name)));
|
||||
}
|
||||
// Match the renderer's channel wrapping when a track is wider than its source.
|
||||
// An already-mono source can be shared without copying it.
|
||||
if channels > 1 {
|
||||
let key = (*source, ch % channels);
|
||||
let mono = if let Some(&id) = mono_sources.get(&key) {
|
||||
id
|
||||
} else {
|
||||
let samples =
|
||||
audio.buffer.channels.get(key.1).ok_or_else(|| bad("track.split_into_mono", "source channel unavailable"))?;
|
||||
copied_samples = copied_samples
|
||||
.checked_add(samples.len())
|
||||
.filter(|&n| n <= crate::io::MAX_IMPORT_SAMPLES)
|
||||
.ok_or_else(|| bad("track.split_into_mono", "split would copy too much audio"))?;
|
||||
// Keep the full channel: the cloned clips retain their offsets, stretch,
|
||||
// gain and fades, and every reader sees genuine mono media (including saves).
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![samples.clone()] };
|
||||
let original = s.source(*source);
|
||||
let name = format!("{}.{suffix}.wav", original.map_or(c.name.as_str(), |source| source.name.as_str()));
|
||||
let time_reference = original.map_or(0, |source| source.time_reference);
|
||||
let id = crate::io::add_source(s, &name, buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
if let Some(source) = s.sources.iter_mut().find(|source| source.id == id) {
|
||||
source.time_reference = time_reference;
|
||||
}
|
||||
mono_sources.insert(key, id);
|
||||
id
|
||||
};
|
||||
*source = mono;
|
||||
}
|
||||
}
|
||||
c.name = format!("{}.{suffix}", c.name);
|
||||
}
|
||||
if let Some(tr) = s.track_mut(id) {
|
||||
tr.mixer.pan = vec![if ch == 0 { -1.0 } else { 1.0 }];
|
||||
tr.comments = format!("source_channel={ch}");
|
||||
if let Some(pl) = tr.playlist_mut() {
|
||||
pl.clips = clips;
|
||||
}
|
||||
@@ -702,15 +740,26 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn rename(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let t = track_param(e, "track.rename", p, "track")?.ok_or_else(|| bad("track.rename", "`track` required"))?;
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("track.rename", "`name` required"))?;
|
||||
let s = e.session_mut();
|
||||
// Validate the stored name, including its existing 64-character limit.
|
||||
let name: String = name.chars().take(64).collect();
|
||||
let s = e.session();
|
||||
if s.tracks.iter().any(|x| x.name == name && x.id != t) {
|
||||
return Err(bad("track.rename", format!("a track named `{name}` already exists")));
|
||||
}
|
||||
let name: String = name.chars().take(64).collect();
|
||||
let bus = s.track(t).and_then(|track| match (track.kind, &track.mixer.input) {
|
||||
(TrackKind::Folder, Route::Bus(bus)) => Some(*bus),
|
||||
_ => None,
|
||||
});
|
||||
let track = s.track(t).ok_or_else(|| bad("track.rename", "unknown track"))?;
|
||||
if track.name == name
|
||||
&& bus.is_none_or(|id| {
|
||||
let expected = unique_folder_bus_name(s, &format!("{name} Bus"), Some(id));
|
||||
s.bus(id).is_none_or(|bus| bus.name == expected)
|
||||
})
|
||||
{
|
||||
return Ok(json!({"name": name}));
|
||||
}
|
||||
let s = e.session_mut();
|
||||
if let Some(track) = s.track_mut(t) {
|
||||
track.name.clone_from(&name);
|
||||
}
|
||||
@@ -944,7 +993,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();
|
||||
|
||||
@@ -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;
|
||||
@@ -547,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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
+232
-10
@@ -78,8 +78,16 @@ fn rebase_midi_start(clip: &mut Clip, old_start: Samples, t: TimeCtx<'_>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Split every clip on a track's active playlist that spans `at`.
|
||||
/// Split every clip on a track's active playlist that spans `at`. The piece before `at` keeps the
|
||||
/// clip's name. The piece after it is a new clip, and takes the next name of the clip's Auto Rename
|
||||
/// scheme when it has one.
|
||||
pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
separate_at_if(s, track, at, |_| true)
|
||||
}
|
||||
|
||||
/// [`separate_at`], giving the piece after `at` the next Auto Rename name only when `auto_name`
|
||||
/// returns true for it. Any other piece keeps the clip's name, and its scheme does not count up.
|
||||
pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: impl Fn(&Clip) -> bool) -> usize {
|
||||
let tc_tempo = s.tempo.clone();
|
||||
let sr = s.sample_rate;
|
||||
let t = TimeCtx { tempo: &tc_tempo, sr };
|
||||
@@ -88,15 +96,22 @@ pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
for _ in 0..n_split {
|
||||
ids.push(s.new_clip_id());
|
||||
}
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut schemes = std::mem::take(&mut s.auto_names);
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else {
|
||||
s.auto_names = schemes;
|
||||
return 0;
|
||||
};
|
||||
let mut out = Vec::with_capacity(pl.clips.len() + ids.len());
|
||||
let mut count = 0;
|
||||
for c in pl.clips.drain(..) {
|
||||
if at > c.start
|
||||
&& at < c.end()
|
||||
&& let Some(id) = ids.pop()
|
||||
&& let Some((l, r)) = split_clip(&c, at, id, t)
|
||||
&& let Some((l, mut r)) = split_clip(&c, at, id, t)
|
||||
{
|
||||
if auto_name(&r) {
|
||||
take_auto_name(&mut schemes, &mut r);
|
||||
}
|
||||
out.push(l);
|
||||
out.push(r);
|
||||
count += 1;
|
||||
@@ -106,9 +121,34 @@ pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
|
||||
}
|
||||
pl.clips = out;
|
||||
pl.sort();
|
||||
s.auto_names = schemes;
|
||||
count
|
||||
}
|
||||
|
||||
/// Gives `c` the next name of its Auto Rename scheme, when it has one, and counts the scheme up.
|
||||
fn take_auto_name(schemes: &mut std::collections::BTreeMap<u64, soundcraft_model::AutoName>, c: &mut Clip) {
|
||||
if let Some(scheme) = c.auto_name.and_then(|k| schemes.get_mut(&k)) {
|
||||
c.name = crate::io::StripNames::from(&*scheme).clip_name(0);
|
||||
scheme.number = scheme.number.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives each clip on a track's active playlist whose id is not in `old`, and for which `auto_name`
|
||||
/// returns true, the next name of its Auto Rename scheme, in time order. Pieces cut with
|
||||
/// [`separate_at_if`] and `|_| false` take their names here once the caller has removed the ones it
|
||||
/// does not keep.
|
||||
pub fn auto_name_new_clips(s: &mut Session, track: TrackId, old: &HashSet<ClipId>, auto_name: impl Fn(&Clip) -> bool) {
|
||||
let mut schemes = std::mem::take(&mut s.auto_names);
|
||||
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
|
||||
let mut new: Vec<&mut Clip> = pl.clips.iter_mut().filter(|c| !old.contains(&c.id) && auto_name(c)).collect();
|
||||
new.sort_by_key(|c| (c.start, c.id.0));
|
||||
for c in new {
|
||||
take_auto_name(&mut schemes, c);
|
||||
}
|
||||
}
|
||||
s.auto_names = schemes;
|
||||
}
|
||||
|
||||
/// Remove material inside `range` on a track. In Shuffle mode later clips move left, up to the
|
||||
/// first locked clip (see [`ripple`]). Returns the removed material as clips positioned relative
|
||||
/// to `range.start`.
|
||||
@@ -149,9 +189,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 +447,13 @@ 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.
|
||||
/// Source samples covered by `length` timeline samples of a clip with the given `stretch`.
|
||||
fn source_len(length: Samples, stretch: f64) -> Samples {
|
||||
if stretch.is_finite() && stretch > 0.0 && (stretch - 1.0).abs() >= 1e-9 { (length as f64 / stretch).round() as Samples } else { length }
|
||||
}
|
||||
|
||||
/// 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 +461,20 @@ 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(source_len(prev.length.saturating_add(gap), prev.stretch)) == *ob
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if contiguous && same && (prev.gain_db - c.gain_db).abs() < 1e-6 {
|
||||
prev.length += c.length;
|
||||
let same_playback = prev.muted == c.muted && (prev.stretch - c.stretch).abs() < 1e-9 && (prev.gain_db - c.gain_db).abs() < 1e-6;
|
||||
if contiguous && same && same_playback {
|
||||
if !prev.gain_env.is_empty() || !c.gain_env.is_empty() {
|
||||
join_gain_env(prev, &c);
|
||||
}
|
||||
prev.length = c.end().saturating_sub(prev.start);
|
||||
prev.fade_out = c.fade_out;
|
||||
healed += 1;
|
||||
continue;
|
||||
@@ -431,6 +486,24 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
healed
|
||||
}
|
||||
|
||||
/// Give `l` the gain envelope of `l` followed by `r`, which starts at or after where `l` ends, so
|
||||
/// every sample keeps its gain. Call it before `l.length` grows to cover `r`.
|
||||
fn join_gain_env(l: &mut Clip, r: &Clip) {
|
||||
let cut = l.length;
|
||||
let seam = r.start.saturating_sub(l.start);
|
||||
let last = cut.saturating_sub(1);
|
||||
let (end_db, start_db) = (l.env_db_at(last), r.env_db_at(0));
|
||||
l.gain_env.retain(|&(at, _)| at < cut);
|
||||
l.gain_env.extend(r.gain_env.iter().filter(|&&(at, _)| at >= 0).map(|&(at, db)| (at.saturating_add(seam), db)));
|
||||
// Pin both sides of the seam where the joined points would interpolate across it.
|
||||
for (at, db) in [(seam, start_db), (last, end_db)] {
|
||||
if (l.env_db_at(at) - db).abs() > 1e-4 {
|
||||
let i = l.gain_env.partition_point(|&(t, _)| t < at);
|
||||
l.gain_env.insert(i, (at, db));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move clips by `delta`, overwriting what they land on; `dest` sends whole tracks of them
|
||||
/// elsewhere (see [`destinations`]).
|
||||
pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> usize {
|
||||
@@ -812,8 +885,10 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh
|
||||
pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
|
||||
let mut n = 0;
|
||||
let mut touched: HashSet<ClipId> = HashSet::new();
|
||||
for c in &mut pl.clips {
|
||||
if c.range().overlaps(&range) || (range.is_empty() && c.range().contains(range.start)) {
|
||||
touched.insert(c.id);
|
||||
if c.fade_in.len > 0 || c.fade_out.len > 0 {
|
||||
n += 1;
|
||||
}
|
||||
@@ -821,11 +896,12 @@ pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize {
|
||||
c.fade_out = Fade::default();
|
||||
}
|
||||
}
|
||||
// Overlapping (crossfaded) neighbours are trimmed back to the midpoint.
|
||||
// Overlapping (crossfaded) neighbours in the range are trimmed back to the midpoint; other overlaps stay as they are.
|
||||
pl.sort();
|
||||
for i in 0..pl.clips.len().saturating_sub(1) {
|
||||
let (Some(a_end), Some(b_start)) = (pl.clips.get(i).map(Clip::end), pl.clips.get(i + 1).map(|c| c.start)) else { continue };
|
||||
if a_end > b_start {
|
||||
let in_range = pl.clips.get(i).is_some_and(|a| touched.contains(&a.id)) || pl.clips.get(i + 1).is_some_and(|b| touched.contains(&b.id));
|
||||
if in_range && a_end > b_start {
|
||||
let mid = b_start + (a_end - b_start) / 2;
|
||||
if let Some(a) = pl.clips.get_mut(i) {
|
||||
a.trim_end_to(mid);
|
||||
@@ -835,6 +911,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 +946,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 +970,103 @@ 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 heal_skips_clips_with_different_playback() {
|
||||
for tweak in [(|c: &mut Clip| c.muted = true) as fn(&mut Clip), |c: &mut Clip| c.stretch = 1.5] {
|
||||
let (mut s, t) = session_with_clip();
|
||||
separate_at(&mut s, t, 3000);
|
||||
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
|
||||
tweak(&mut pl.clips[1]);
|
||||
}
|
||||
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
|
||||
assert_eq!(s.track(t).unwrap().clips().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heal_keeps_each_side_gain_envelope() {
|
||||
let cases: [(Vec<(Samples, f32)>, Vec<(Samples, f32)>); 4] = [
|
||||
(vec![(500, -3.0), (2500, -12.0)], vec![]),
|
||||
(vec![], vec![(0, -6.0), (1000, 0.0)]),
|
||||
(vec![(0, 0.0), (1999, -20.0)], vec![(-100, 4.0), (300, -9.0)]),
|
||||
(vec![(500, -3.0), (2500, -12.0)], vec![(0, -9.75), (500, -12.0)]),
|
||||
];
|
||||
for (left_env, right_env) in cases {
|
||||
let (mut s, t) = session_with_clip();
|
||||
separate_at(&mut s, t, 3000);
|
||||
let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap();
|
||||
pl.clips[0].gain_env = left_env;
|
||||
pl.clips[1].gain_env = right_env;
|
||||
let (l, r) = (pl.clips[0].clone(), pl.clips[1].clone());
|
||||
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 1);
|
||||
let healed = &s.track(t).unwrap().clips()[0];
|
||||
for rel in 0..4000 {
|
||||
let want = if rel < 2000 { l.gain_at(rel) } else { r.gain_at(rel - 2000) };
|
||||
assert!((healed.gain_at(rel) - want).abs() < 1e-4, "sample {rel}: {} != {want}", healed.gain_at(rel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heal_keeps_gain_envelopes_across_a_gap() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
clear_range(&mut s, t, Range::new(2000, 3000), false);
|
||||
let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap();
|
||||
pl.clips[0].gain_env = vec![(0, 0.0), (999, -6.0)];
|
||||
pl.clips[1].gain_env = vec![(0, -12.0), (1999, -3.0)];
|
||||
let (l, r) = (pl.clips[0].clone(), pl.clips[1].clone());
|
||||
assert_eq!(heal(&mut s, t, Range::new(1500, 3500)), 1);
|
||||
let healed = &s.track(t).unwrap().clips()[0];
|
||||
assert_eq!(healed.length, 4000);
|
||||
for rel in (0..1000).chain(2000..4000) {
|
||||
let want = if rel < 1000 { l.gain_at(rel) } else { r.gain_at(rel - 2000) };
|
||||
assert!((healed.gain_at(rel) - want).abs() < 1e-4, "sample {rel}: {} != {want}", healed.gain_at(rel));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_then_heal_rejoins_stretched_clip() {
|
||||
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.stretch = 2.0;
|
||||
}
|
||||
assert_eq!(separate_at(&mut s, t, 3000), 1);
|
||||
assert_eq!(clips(&s, t), vec![(1000, 2000, 1000), (3000, 2000, 2000)]);
|
||||
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 1);
|
||||
assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_slip_and_shuffle() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
@@ -1041,4 +1230,37 @@ 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:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_fades_leaves_unrelated_overlaps_alone() {
|
||||
let (mut s, t) = session_with_clip();
|
||||
let ids: Vec<ClipId> = (0..4).map(|_| s.new_clip_id()).collect();
|
||||
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips.clear();
|
||||
for (id, start, len) in [(ids[0], 0, 1100), (ids[1], 1000, 1000), (ids[2], 5000, 1100), (ids[3], 6000, 1000)] {
|
||||
let mut c = Clip::audio(id, "c", SourceId(500), start, start, len);
|
||||
c.fade_in = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
|
||||
c.fade_out = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
|
||||
pl.clips.push(c);
|
||||
}
|
||||
}
|
||||
assert_eq!(delete_fades(&mut s, t, Range::new(900, 1200)), 2);
|
||||
assert_eq!(clips(&s, t), vec![(0, 1050, 0), (1050, 950, 1050), (5000, 1100, 5000), (6000, 1000, 6000)]);
|
||||
let c = s.track(t).unwrap().clips();
|
||||
assert_eq!((c[1].fade_in.len, c[2].fade_out.len, c[3].fade_in.len), (0, 100, 100));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user