mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 10:36:46 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58d7d48d47 | ||
|
|
8c2c9aadb3 |
@@ -1,6 +0,0 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -1,30 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -1,23 +0,0 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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
|
||||
@@ -1,20 +0,0 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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
|
||||
Generated
+18
-24
@@ -846,11 +846,6 @@ dependencies = [
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -3572,7 +3567,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3590,7 +3585,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3600,7 +3595,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -3612,7 +3607,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -3622,7 +3617,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading",
|
||||
@@ -3635,7 +3630,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
@@ -3646,7 +3641,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -3657,9 +3652,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"log",
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3680,7 +3674,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3690,7 +3684,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -3706,7 +3700,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3719,7 +3713,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
@@ -3734,7 +3728,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -3744,7 +3738,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -3770,7 +3764,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"proptest",
|
||||
@@ -3781,7 +3775,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
"log",
|
||||
@@ -3797,7 +3791,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5354,7 +5348,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-4
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.1"
|
||||
version = "0.7.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -42,9 +42,6 @@ rustfft = "6"
|
||||
zip = { version = "9", default-features = false }
|
||||
cpal = "0.16"
|
||||
|
||||
# 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" }
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
egui = "0.36"
|
||||
|
||||
@@ -7,14 +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">
|
||||
<b>Recording, editing and mixing audio; an open-source, modern digital audio workstation (DAW), built in pure Rust.</b>
|
||||
<b>Recording, editing and mixing audio; an open-source, clean-room reimplementation of Avid Pro Tools, rebuilt in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,9 +25,6 @@
|
||||
<img alt="Platforms" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-0b7385?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue?style=flat-square">
|
||||
<img alt="Status: pre-alpha" src="https://img.shields.io/badge/status-pre--alpha-orange?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_soundcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/soundcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -382,6 +375,10 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and SoundCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Avid and Pro Tools are trademarks or registered trademarks of Avid Technology, Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Avid Technology, Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+27
-13
@@ -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-10 · **Last updated:** 2026-10-12 · **Change:** minor (shared Windows installer UI; existing folder/shortcut/launch options retained; install and uninstall completion) · **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,6 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change.
|
||||
- **2026-10-16:** Master fader volume automation uses each render block's actual last sample, matching track faders. Breakpoints in the next block no longer pull the preceding block's level toward them, and ramps reach their last-sample value. Regression tests cover nonzero starts, one-sample callbacks and several block sizes. No readiness percentage change.
|
||||
- **2026-10-16:** Aux, folder and master latency calculations exclude inserts already taken out for producing NaN or infinity, matching audio tracks. Subsequent blocks no longer compensate for an insert that is bypassed after failure. Regression tests check impulse timing, healthy insert latency and reset/retry at 64- and 512-sample blocks. No readiness percentage change.
|
||||
- **2026-10-14:** MCP `batch` rejects more than 1000 calls before executing any command, instead of silently running only the first 1000 (#460). No readiness percentage change.
|
||||
- **2026-10-13:** WAV: an empty data chunk followed by metadata (LIST, bext) reads as no audio; a recording that was never finalised (sizes left at 0) still gives what was written (#466)
|
||||
- **2026-10-13:** Sampler: a ping-pong loop keeps its phase and direction when a step is wider than the loop (a narrow loop with high transposition), instead of reflecting once and dropping the rest of the travel (#463)
|
||||
- **2026-10-13:** `mix.instrument` takes the built-in instrument's parameters in `params` (validated, clamped) and refuses any other field, so a synth setting written beside `plugin` is an error instead of being accepted and ignored (#265)
|
||||
- **2026-10-13:** A bounce of a bus that an aux returns onto lines up with the timeline: the aux insert's lookahead is compensated like the main mix's (it left the bus late and cut the end off), and tracks routed to that bus wait for it too (#246)
|
||||
- **2026-10-13:** Delay compensation covers the inserts of the master faders: a bounce of the main mix with a Maximizer on the master lines up with the same mix without it (it came out 71 samples late) (#351)
|
||||
@@ -161,12 +155,6 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged |
|
||||
| 2026-10-16 | trivial | Master volume automation block boundaries corrected; readiness estimates unchanged |
|
||||
| 2026-10-16 | trivial | Failed inserts no longer contribute aux, folder or master latency after containment; readiness estimates unchanged |
|
||||
| 2026-10-14 | trivial | Progress log: oversized MCP batches rejected before execution (#460) |
|
||||
| 2026-10-13 | trivial | Progress log: empty WAV data chunk followed by metadata (#466) |
|
||||
| 2026-10-13 | trivial | Progress log: sampler ping-pong phase for wide steps (#463) |
|
||||
| 2026-10-13 | trivial | Progress log: mix.instrument params, unknown fields refused (#265) |
|
||||
| 2026-10-13 | trivial | Progress log: bus bounce compensates aux returns (#246) |
|
||||
| 2026-10-13 | trivial | Progress log: master insert latency compensated (#351) |
|
||||
@@ -179,3 +167,29 @@ mutated and truncated movies. Field reports from the first public week are track
|
||||
| 2026-10-10 | major | Rewritten to the progress-docs standard: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md |
|
||||
| 2026-10-07 | major | Menu parity 94 %, ~65 % estimated feature parity, alpha list |
|
||||
| 2026-10-06 | minor | Alpha and parity estimates |
|
||||
|
||||
## Packaging update — 2026-10-10
|
||||
|
||||
Windows setup now uses the shared `craft-installer` native UI source, with install/repair/remove
|
||||
completion, cancellation and failure screens. Existing MSI ownership and upgrade identities are
|
||||
preserved; unattended modes remain unattended. Source/generator checks pass; native Windows
|
||||
installer acceptance with real release payloads is still pending. See [setup validation](packaging/windows/README.md).
|
||||
|
||||
## Packaging update (2026-10-11)
|
||||
|
||||
Windows setup now uses the suite's shared `craft-installer` UI, preserving existing folder,
|
||||
shortcut and launch options and app-owned installation policy. Install, uninstall and repair
|
||||
have explicit completion screens; cancellation and failure have separate endings. Source/profile
|
||||
checks, WiX UI library compilation for x64/x86/ARM64, PowerShell parsing and package identity
|
||||
comparisons pass. Native fixture MSI linking/UI tables pass on all three architectures, and
|
||||
Windows full UI install/repair/uninstall, x86 install/remove, folder upgrades, desktop choices
|
||||
and launch checks pass with fixtures. Native evidence and real-release acceptance limits
|
||||
are linked from
|
||||
[Windows packaging](packaging/windows/README.md).
|
||||
|
||||
Windows Settings removal now opens the shared completion screen. Quiet install/removal
|
||||
remains unattended; Settings labels preserve compatibility with existing Launchers, and
|
||||
previous Modify policy is retained. Windows 11 fixture verification covers both installation
|
||||
scopes and x64/x86 removal, with full MSI validation on x64/x86/ARM64.
|
||||
|
||||
- **2026-10-15** · Recovery retains autosaves whose timestamps equal the saved project. New autosave folders are created atomically and retry occupied names, preserving existing recovery data when runs collide. Regression tests cover timestamp ties and occupied folders. No readiness percentage change.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The browser shell: the eframe web runner around [`SoundApp`].
|
||||
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::{Engine, io};
|
||||
use soundcraft_ui_egui::dialogs::Dialog;
|
||||
use soundcraft_ui_egui::sampler_window::SampleRead;
|
||||
@@ -165,23 +166,30 @@ impl WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self, purpose: Purpose, name: String, bytes: Vec<u8>) {
|
||||
fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) {
|
||||
if matches!(purpose, Purpose::Session) && soundcraft_ui_egui::bounce::refuses_session_switch(&mut self.app) {
|
||||
return;
|
||||
}
|
||||
let e = &mut self.app.engine;
|
||||
let at = e.session().edit.selection.start.max(0);
|
||||
let result = match purpose {
|
||||
// Asks first when the open session has changes: the bytes wait for the answer.
|
||||
Purpose::Session => return soundcraft_ui_egui::dialogs::open_session_bytes(&mut self.app, name, bytes),
|
||||
Purpose::Session => {
|
||||
let _ = self.app.run("transport.stop", json!({}));
|
||||
io::open_session_bytes(&mut self.app.engine, name, bytes).map(|missing| {
|
||||
if missing.is_empty() { format!("Opened {name}") } else { format!("Opened {name} ({} missing media)", missing.len()) }
|
||||
})
|
||||
}
|
||||
// Imports are edits: one undo step each, like the import commands.
|
||||
Purpose::Audio => {
|
||||
let before = e.session_arc();
|
||||
io::import_audio_bytes(e, &name, &bytes, None, None, at).map(|_| {
|
||||
io::import_audio_bytes(e, name, bytes, None, None, at).map(|_| {
|
||||
e.push_undo("Import Audio", before);
|
||||
format!("Imported {name}")
|
||||
})
|
||||
}
|
||||
Purpose::Midi => {
|
||||
let before = e.session_arc();
|
||||
io::import_midi_bytes(e, &bytes, at, true).map(|_| {
|
||||
io::import_midi_bytes(e, bytes, at, true).map(|_| {
|
||||
e.push_undo("Import MIDI", before);
|
||||
format!("Imported {name}")
|
||||
})
|
||||
@@ -197,7 +205,7 @@ impl eframe::App for WebShell {
|
||||
self.take_path_prompt(ctx);
|
||||
let pending = std::mem::take(&mut *self.inbox.borrow_mut());
|
||||
for (purpose, name, bytes) in pending {
|
||||
self.handle(purpose, name, bytes);
|
||||
self.handle(purpose, &name, &bytes);
|
||||
}
|
||||
let samples = std::mem::take(&mut *self.samples.borrow_mut());
|
||||
for (read, bytes) in samples {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
//! Per-user settings and automation profile directories.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Portable mode (#268): this file beside the executable keeps settings, autosaves, presets and
|
||||
/// logs in [`PORTABLE_DATA_DIR`] next to it instead of the user profile.
|
||||
const PORTABLE_MARKER: &str = "portable.txt";
|
||||
const PORTABLE_DATA_DIR: &str = "SoundCraftData";
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Platform {
|
||||
@@ -18,18 +13,7 @@ enum Platform {
|
||||
/// SoundCraft's settings folder. A nonempty `$SOUNDCRAFT_CONFIG_DIR` overrides the platform default.
|
||||
/// The override is the complete directory; no application-name suffix is appended. An explicitly
|
||||
/// empty value disables the settings directory instead of falling back to a personal profile.
|
||||
/// Otherwise `portable.txt` beside the executable selects `<exe dir>/SoundCraftData`, when that
|
||||
/// folder can be written (a read-only medium falls back to the platform default).
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
static DIR: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let exe = std::env::current_exe().ok();
|
||||
resolve(exe.as_deref().and_then(Path::parent))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn resolve(exe_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
let platform = if cfg!(target_os = "macos") {
|
||||
Platform::Mac
|
||||
} else if cfg!(windows) {
|
||||
@@ -37,20 +21,13 @@ fn resolve(exe_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
} else {
|
||||
Platform::Other
|
||||
};
|
||||
config_dir_with(platform, |name| std::env::var_os(name), exe_dir)
|
||||
config_dir_with(platform, |name| std::env::var_os(name))
|
||||
}
|
||||
|
||||
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
if let Some(dir) = env("SOUNDCRAFT_CONFIG_DIR") {
|
||||
return (!dir.is_empty()).then(|| PathBuf::from(dir));
|
||||
}
|
||||
if let Some(exe_dir) = exe_dir.filter(|d| d.join(PORTABLE_MARKER).is_file()) {
|
||||
let data = exe_dir.join(PORTABLE_DATA_DIR);
|
||||
match writable(&data) {
|
||||
Ok(()) => return Some(data),
|
||||
Err(e) => log::warn!("portable mode: {} can't be written ({e}); using the user profile instead", data.display()),
|
||||
}
|
||||
}
|
||||
match platform {
|
||||
Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/SoundCraft")),
|
||||
Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("SoundCraft")),
|
||||
@@ -60,14 +37,6 @@ fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>, e
|
||||
}
|
||||
}
|
||||
|
||||
/// Create `dir` if needed and prove a file can be written in it.
|
||||
fn writable(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let probe = dir.join(format!(".write-test-{}", std::process::id()));
|
||||
std::fs::write(&probe, b"")?;
|
||||
std::fs::remove_file(probe)
|
||||
}
|
||||
|
||||
/// Read bounded settings, recovering unreadable docking metadata independently of other settings.
|
||||
pub fn load_ui(path: &std::path::Path) -> Result<Option<soundcraft_ui_egui::UiState>, String> {
|
||||
read_ui(path, true)
|
||||
@@ -226,20 +195,16 @@ mod config_tests {
|
||||
}
|
||||
|
||||
fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option<PathBuf> {
|
||||
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))), None)
|
||||
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_override_wins_without_reading_user_profile() {
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
let actual = config_dir_with(
|
||||
platform,
|
||||
|name| {
|
||||
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
|
||||
Some(OsString::from("isolated/settings"))
|
||||
},
|
||||
None,
|
||||
);
|
||||
let actual = config_dir_with(platform, |name| {
|
||||
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
|
||||
Some(OsString::from("isolated/settings"))
|
||||
});
|
||||
assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
|
||||
}
|
||||
}
|
||||
@@ -248,14 +213,10 @@ mod config_tests {
|
||||
fn config_empty_override_disables_profile_without_reading_defaults() {
|
||||
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
|
||||
assert_eq!(
|
||||
config_dir_with(
|
||||
platform,
|
||||
|name| {
|
||||
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
|
||||
Some(OsString::new())
|
||||
},
|
||||
None
|
||||
),
|
||||
config_dir_with(platform, |name| {
|
||||
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
|
||||
Some(OsString::new())
|
||||
}),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -273,34 +234,11 @@ mod config_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_txt_beside_the_exe_keeps_settings_next_to_it() {
|
||||
let exe = std::env::temp_dir().join(format!("soundcraft-portable-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&exe);
|
||||
std::fs::create_dir_all(&exe).unwrap();
|
||||
let vars = [("APPDATA", "/roaming")];
|
||||
assert_eq!(resolve_with_exe(&vars, &exe), Some(PathBuf::from("/roaming/SoundCraft")), "no marker: the profile");
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
assert_eq!(resolve_with_exe(&vars, &exe), Some(exe.join("SoundCraftData")));
|
||||
assert!(exe.join("SoundCraftData").is_dir());
|
||||
// The explicit override still wins.
|
||||
assert_eq!(resolve_with_exe(&[("SOUNDCRAFT_CONFIG_DIR", "/custom")], &exe), Some(PathBuf::from("/custom")));
|
||||
// A data folder that can't be made (a file is in the way) falls back to the profile.
|
||||
std::fs::remove_dir_all(exe.join("SoundCraftData")).unwrap();
|
||||
std::fs::write(exe.join("SoundCraftData"), b"").unwrap();
|
||||
assert_eq!(resolve_with_exe(&vars, &exe), Some(PathBuf::from("/roaming/SoundCraft")));
|
||||
std::fs::remove_dir_all(exe).unwrap();
|
||||
}
|
||||
|
||||
fn resolve_with_exe(vars: &[(&str, &str)], exe: &Path) -> Option<PathBuf> {
|
||||
config_dir_with(Platform::Windows, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))), Some(exe))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn config_override_preserves_non_unicode_paths() {
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
let value = OsString::from_vec(b"isolated/\xff".to_vec());
|
||||
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone()), None), Some(PathBuf::from(value)));
|
||||
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +50,3 @@ sync_github_name = false
|
||||
|
||||
[people.Momentum96]
|
||||
sync_github_name = true
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -185,9 +185,6 @@ pub(crate) struct Writer<W> {
|
||||
quantizers: Vec<Quantizer>,
|
||||
frames: u64,
|
||||
bytes: Vec<u8>,
|
||||
/// Set when a block write has failed: the sink may hold a prefix of that block while
|
||||
/// `frames` does not count it, so the writer refuses every later call.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Writer<W> {
|
||||
@@ -196,7 +193,7 @@ impl<W: Write + Seek> Writer<W> {
|
||||
let start = out.stream_position()?;
|
||||
out.write_all(&header(channels, sample_rate, depth, 0)?)?;
|
||||
let quantizers = Quantizer::per_channel(u32::from(sample_format(depth).0), dither, channels);
|
||||
Ok(Writer { out, start, channels, sample_rate, depth, quantizers, frames: 0, bytes: Vec::new(), failed: false })
|
||||
Ok(Writer { out, start, channels, sample_rate, depth, quantizers, frames: 0, bytes: Vec::new() })
|
||||
}
|
||||
|
||||
/// The frame count after `frames` more, or an error when AIFF can't hold that many (4 GiB).
|
||||
@@ -209,22 +206,13 @@ impl<W: Write + Seek> Writer<W> {
|
||||
/// Appends `frames` frames of planar audio (missing samples are silence), unless they don't
|
||||
/// fit (see [`Writer::check_room`]).
|
||||
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let total = self.check_room(frames as u64)?;
|
||||
if let Err(e) = write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, sample_format(self.depth), true) {
|
||||
self.failed = true;
|
||||
return Err(AudioError::from(e));
|
||||
}
|
||||
write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, sample_format(self.depth), true)?;
|
||||
self.frames = total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut self) -> Result<W> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let data_len = u64::from(sample_format(self.depth).0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames);
|
||||
if data_len % 2 == 1 {
|
||||
self.out.write_all(&[0])?;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! partitioned Rice-coded residuals. Decoding is symphonia's, after [`check_metadata`].
|
||||
|
||||
use crate::pcm::{Quantizer, be_u32, le_u32, validate_buffer};
|
||||
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat};
|
||||
use crate::{AudioBuffer, AudioError, BitDepth, Result};
|
||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||
|
||||
const BLOCK_SIZE: usize = 4096;
|
||||
@@ -318,10 +318,6 @@ pub(crate) struct Writer<W> {
|
||||
pending: Vec<Vec<i64>>,
|
||||
frames: u64,
|
||||
frame_no: u64,
|
||||
/// Set when a frame write has failed: `write_frame` clears the pending samples before
|
||||
/// writing, so a failed frame is lost while `frames` still counts it; the writer refuses
|
||||
/// every later call.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Writer<W> {
|
||||
@@ -340,7 +336,6 @@ impl<W: Write + Seek> Writer<W> {
|
||||
pending: (0..channels).map(|_| Vec::with_capacity(BLOCK_SIZE)).collect(),
|
||||
frames: 0,
|
||||
frame_no: 0,
|
||||
failed: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -356,9 +351,6 @@ impl<W: Write + Seek> Writer<W> {
|
||||
/// Appends `frames` frames of planar audio (missing samples are silence), unless they don't
|
||||
/// fit (see [`Writer::check_room`]).
|
||||
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let total = self.check_room(frames as u64)?;
|
||||
let mut done = 0;
|
||||
while done < frames {
|
||||
@@ -369,11 +361,8 @@ impl<W: Write + Seek> Writer<W> {
|
||||
p.extend((0..n).map(|i| i64::from(q.quantize(src.get(i).copied().unwrap_or(0.0)))));
|
||||
}
|
||||
done += n;
|
||||
if held + n == BLOCK_SIZE
|
||||
&& let Err(e) = self.write_frame()
|
||||
{
|
||||
self.failed = true;
|
||||
return Err(e);
|
||||
if held + n == BLOCK_SIZE {
|
||||
self.write_frame()?;
|
||||
}
|
||||
}
|
||||
self.frames = total;
|
||||
@@ -419,9 +408,6 @@ impl<W: Write + Seek> Writer<W> {
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut self) -> Result<W> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
self.write_frame()?;
|
||||
let info = stream_info(self.channels, self.sample_rate, self.bps, self.frames);
|
||||
self.out.seek(SeekFrom::Start(self.start.saturating_add(4)))?;
|
||||
@@ -468,49 +454,6 @@ pub(crate) fn check_metadata(b: &[u8], at: usize) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The info of a FLAC stream with no audio frames at all: STREAMINFO declares 0 samples and
|
||||
/// nothing follows the metadata blocks. Symphonia's reader fails on these, as it looks for a first
|
||||
/// frame, so the caller answers them itself.
|
||||
pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option<AudioInfo> {
|
||||
let mut pos = at.checked_add(4)?;
|
||||
let mut first = true;
|
||||
loop {
|
||||
let head = be_u32(b, pos)?;
|
||||
let (body, len) = (pos.checked_add(4)?, (head & 0x00ff_ffff) as usize);
|
||||
let end = body.checked_add(len)?;
|
||||
if end > b.len() {
|
||||
return None;
|
||||
}
|
||||
if first && ((head >> 24) & 0x7f != 0 || len != 34) {
|
||||
return None;
|
||||
}
|
||||
first = false;
|
||||
if head >> 31 == 1 {
|
||||
if end != b.len() {
|
||||
return None;
|
||||
}
|
||||
break;
|
||||
}
|
||||
pos = end;
|
||||
}
|
||||
// STREAMINFO, from the sample rate on: 20 bits rate, 3 channels - 1, 5 bits per sample - 1, 36 bits total.
|
||||
let si = at.checked_add(4 + 4 + 10)?;
|
||||
let packed = u64::from_be_bytes(b.get(si..si.checked_add(8)?)?.try_into().ok()?);
|
||||
let sample_rate = (packed >> 44) as u32;
|
||||
let channels = ((packed >> 41) & 0x7) as u16 + 1;
|
||||
let bps = ((packed >> 36) & 0x1f) as u32 + 1;
|
||||
if packed & 0xf_ffff_ffff != 0 || sample_rate == 0 {
|
||||
return None;
|
||||
}
|
||||
let sample_format = match bps {
|
||||
1..=8 => SampleFormat::Int8,
|
||||
9..=16 => SampleFormat::Int16,
|
||||
17..=24 => SampleFormat::Int24,
|
||||
_ => SampleFormat::Int32,
|
||||
};
|
||||
Some(AudioInfo { format: FileFormat::Flac, sample_format, sample_rate, channels, frames: 0, bwf: None })
|
||||
}
|
||||
|
||||
/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds.
|
||||
/// The vendor string is skipped unread.
|
||||
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
|
||||
|
||||
@@ -294,13 +294,6 @@ pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u
|
||||
}
|
||||
}
|
||||
|
||||
/// The error a stream encoder returns for every call after one of its writes has failed: the
|
||||
/// sink may already hold a prefix of the failed block, so the encoder's counters can no longer
|
||||
/// describe the bytes on the sink, and later writes or `finish` must not claim success.
|
||||
pub(crate) fn failed_write_error() -> AudioError {
|
||||
AudioError::Encode("the stream encoder already failed on an earlier write; its output is incomplete".into())
|
||||
}
|
||||
|
||||
/// Writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32` or FLAC block by block, producing
|
||||
/// the same bytes [`encode_with_channel_mask`] would for the whole buffer.
|
||||
pub struct FileEncoder<W: Write + Seek>(Writer<W>);
|
||||
@@ -336,9 +329,7 @@ impl<W: Write + Seek> FileEncoder<W> {
|
||||
}
|
||||
|
||||
/// Appends the first `frames` frames of `block`, one `Vec` per channel (missing samples are
|
||||
/// silence), unless they don't fit (see [`FileEncoder::check_room`]). Once a write has
|
||||
/// failed, the encoder is unusable: the sink may hold a prefix of the failed block, so
|
||||
/// every later `write` and `finish` fails instead of completing an inconsistent file.
|
||||
/// silence), unless they don't fit (see [`FileEncoder::check_room`]).
|
||||
pub fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
match &mut self.0 {
|
||||
Writer::Wav(w) => w.write(block, frames),
|
||||
|
||||
@@ -36,15 +36,9 @@ fn guarded<T>(f: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into())))
|
||||
}
|
||||
|
||||
/// What [`open`] found: a reader, or a stream that holds no audio frames and has no reader.
|
||||
enum Opened {
|
||||
Reader(Box<dyn FormatReader>),
|
||||
Empty(AudioInfo),
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
fn open(bytes: &[u8]) -> Result<Box<dyn FormatReader>> {
|
||||
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
|
||||
let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() };
|
||||
loop {
|
||||
@@ -56,16 +50,11 @@ fn open(bytes: &[u8]) -> Result<Opened> {
|
||||
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"fLaC") => crate::flac::check_metadata(bytes, at)?,
|
||||
Some(b"RIFF") => crate::wav::check_info(bytes, at)?,
|
||||
_ => {}
|
||||
}
|
||||
return reader(mss, &fmt_opts).map(Opened::Reader).map_err(map_err);
|
||||
return reader(mss, &fmt_opts).map_err(map_err);
|
||||
}
|
||||
// Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims: skip it.
|
||||
(Instantiate::Metadata(_), Some([b'I', b'D', b'3', _, _, _, size @ ..])) => {
|
||||
@@ -152,13 +141,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)? {
|
||||
Opened::Reader(reader) => reader,
|
||||
Opened::Empty(info) => {
|
||||
let buf = AudioBuffer::new(info.sample_rate, usize::from(info.channels), 0);
|
||||
return Ok((info, buf));
|
||||
}
|
||||
};
|
||||
let mut reader = open(bytes)?;
|
||||
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) {
|
||||
let total = n.saturating_mul(u64::from(info.channels.max(1)));
|
||||
@@ -254,10 +237,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)? {
|
||||
Opened::Reader(reader) => reader,
|
||||
Opened::Empty(info) => return Ok(info),
|
||||
};
|
||||
let reader = open(bytes)?;
|
||||
let (_, info) = info_of(reader.as_ref(), detected)?;
|
||||
if info.frames > 0 && info.channels > 0 {
|
||||
return Ok(info);
|
||||
|
||||
@@ -140,30 +140,6 @@ pub(crate) fn is_wav(b: &[u8]) -> bool {
|
||||
matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE")
|
||||
}
|
||||
|
||||
/// True when everything from `from` to the end of the file is whole RIFF chunks (four printable
|
||||
/// ASCII id bytes, a size that fits, the pad byte after an odd size; the last chunk's pad may be
|
||||
/// missing): the metadata of a finished file, not samples.
|
||||
fn only_chunks_follow(b: &[u8], from: usize) -> bool {
|
||||
let mut pos = from;
|
||||
while pos < b.len() {
|
||||
let (Some(id), Some(size)) = (tag(b, pos), le_u32(b, pos + 4)) else { return false };
|
||||
if !id.iter().all(|c| (0x20..0x7f).contains(c)) {
|
||||
return false;
|
||||
}
|
||||
let size = size as usize;
|
||||
let Some(end) = pos.checked_add(8).and_then(|body| body.checked_add(size)) else { return false };
|
||||
if end > b.len() {
|
||||
return false;
|
||||
}
|
||||
let next = end + (size & 1);
|
||||
if end == b.len() || next == b.len() {
|
||||
return true;
|
||||
}
|
||||
pos = next;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
|
||||
if !is_wav(b) {
|
||||
return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into()));
|
||||
@@ -192,12 +168,7 @@ pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
|
||||
if &id == b"data" {
|
||||
// Lenient: a truncated recording (or a streaming writer's 0 / 0xFFFFFFFF size) still
|
||||
// yields whatever audio is actually present.
|
||||
// A size of 0 in a file whose RIFF size is right and that has only chunks after it is an
|
||||
// empty data chunk followed by metadata (LIST, bext, …); otherwise it is a recording
|
||||
// that was never finalised, and what follows is audio.
|
||||
let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64;
|
||||
let empty = size32 == 0 && !is_rf64 && finished && only_chunks_follow(b, body);
|
||||
let len = if size32 == 0 && !is_rf64 && !empty { remaining } else { declared.min(remaining) };
|
||||
let len = if size32 == 0 && !is_rf64 { remaining } else { declared.min(remaining) };
|
||||
if data.is_none() {
|
||||
data = Some((body, len));
|
||||
}
|
||||
@@ -350,9 +321,6 @@ pub(crate) struct Writer<W> {
|
||||
quantizers: Vec<Quantizer>,
|
||||
frames: u64,
|
||||
bytes: Vec<u8>,
|
||||
/// Set when a block write has failed: the sink may hold a prefix of that block while
|
||||
/// `frames` does not count it, so the writer refuses every later call.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Writer<W> {
|
||||
@@ -371,20 +339,7 @@ impl<W: Write + Seek> Writer<W> {
|
||||
let start = out.stream_position()?;
|
||||
out.write_all(&header(channels, sample_rate, format, bwf, mask, 0, force_rf64)?)?;
|
||||
let quantizers = Quantizer::per_channel(u32::from(format.0), dither, channels);
|
||||
Ok(Writer {
|
||||
out,
|
||||
start,
|
||||
channels,
|
||||
sample_rate,
|
||||
format,
|
||||
bwf: bwf.cloned(),
|
||||
mask,
|
||||
force_rf64,
|
||||
quantizers,
|
||||
frames: 0,
|
||||
bytes: Vec::new(),
|
||||
failed: false,
|
||||
})
|
||||
Ok(Writer { out, start, channels, sample_rate, format, bwf: bwf.cloned(), mask, force_rf64, quantizers, frames: 0, bytes: Vec::new() })
|
||||
}
|
||||
|
||||
fn header(&self, frames: u64) -> Result<Vec<u8>> {
|
||||
@@ -400,22 +355,13 @@ impl<W: Write + Seek> Writer<W> {
|
||||
|
||||
/// Appends `frames` frames of planar audio (missing samples are silence).
|
||||
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let total = self.check_room(frames as u64)?;
|
||||
if let Err(e) = write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, self.format, false) {
|
||||
self.failed = true;
|
||||
return Err(AudioError::from(e));
|
||||
}
|
||||
write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, self.format, false)?;
|
||||
self.frames = total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut self) -> Result<W> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let data_len = u64::from(self.format.0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames);
|
||||
if data_len % 2 == 1 {
|
||||
self.out.write_all(&[0])?;
|
||||
@@ -496,10 +442,7 @@ pub(crate) fn header(
|
||||
if is_float {
|
||||
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
|
||||
}
|
||||
let total = ((4 + (8 + rf64_body_len) + meta.len() + 8) as u64)
|
||||
.checked_add(data_len)
|
||||
.and_then(|t| t.checked_add(data_len & 1))
|
||||
.ok_or_else(|| AudioError::TooLarge("WAV size overflow".into()))?;
|
||||
let total = (4 + (8 + rf64_body_len) + meta.len() + 8) as u64 + data_len + (data_len & 1);
|
||||
let rf64 = force_rf64 || u32::try_from(total).is_err();
|
||||
|
||||
if rf64 {
|
||||
|
||||
@@ -284,35 +284,3 @@ fn float_wav_header_is_rewritten_in_place_as_the_file_grows() {
|
||||
assert_eq!((&big[..4], big.len()), (&b"RF64"[..], len));
|
||||
assert!(wav_float_header(0, 48_000, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_frame_flac_round_trips_like_wav_and_aiff() {
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||
for depth in [BitDepth::Int16, BitDepth::Int24] {
|
||||
for channels in [1, 2] {
|
||||
let opts = EncodeOptions { format, bit_depth: depth, ..EncodeOptions::default() };
|
||||
let bytes = encode(&AudioBuffer::new(48_000, channels, 0), &opts).unwrap();
|
||||
let info = probe(&bytes, None).unwrap();
|
||||
assert_eq!((info.format, info.sample_format, info.sample_rate, info.frames), (format, expected_sf(depth), 48_000, 0));
|
||||
assert_eq!(usize::from(info.channels), channels);
|
||||
let (info, buf) = decode(&bytes, None).unwrap();
|
||||
assert_eq!((info.frames, buf.frames(), buf.num_channels()), (0, 0, channels));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_claiming_frames_but_holding_none_still_errors() {
|
||||
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, ..EncodeOptions::default() };
|
||||
let mut bytes = encode(&AudioBuffer::new(48_000, 2, 0), &opts).unwrap();
|
||||
bytes[25] = 1; // STREAMINFO total samples (low byte of the 36-bit field) = 1
|
||||
assert!(decode(&bytes, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() {
|
||||
// 4 bytes per frame: the data length fits in u64, but adding the header to it does not.
|
||||
let frames = u64::MAX / 4;
|
||||
assert!(matches!(wav_float_header(1, 48_000, frames), Err(soundcraft_audio_io::AudioError::TooLarge(_))));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, BwfInfo, EncodeOptions, FileEncoder, FileFormat, encode, encode_with_channel_mask};
|
||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||
use std::io::Cursor;
|
||||
|
||||
fn tone(channels: usize, frames: usize) -> AudioBuffer {
|
||||
let channels = (0..channels).map(|c| (0..frames).map(|i| ((i * (c + 3)) as f32 * 0.013).sin() * 1.2).collect()).collect();
|
||||
@@ -49,62 +49,16 @@ fn streamed_files_match_whole_buffer_encodes() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Write + Seek` sink that accepts `budget` bytes in total and then fails every write, so
|
||||
/// a `write_all` on it appends a prefix of the block and only then reports the error.
|
||||
struct FailAfter {
|
||||
inner: Cursor<Vec<u8>>,
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
impl Write for FailAfter {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
if self.budget == 0 {
|
||||
return Err(std::io::Error::other("sink is full"));
|
||||
}
|
||||
let n = buf.len().min(self.budget);
|
||||
self.budget -= n;
|
||||
self.inner.write(&buf[..n])
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for FailAfter {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
self.inner.seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_poisons_the_encoder() {
|
||||
let buf = tone(2, 20_001);
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||
let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
let sink = FailAfter { inner: Cursor::new(Vec::new()), budget: 256 };
|
||||
let mut enc = FileEncoder::new(sink, &opts, 2, buf.sample_rate, None).unwrap();
|
||||
// The sink accepts a prefix of the first block, then fails.
|
||||
assert!(enc.write(&buf.channels, buf.frames()).is_err(), "{format:?}");
|
||||
// Later calls must not claim success: the bytes on the sink no longer match the
|
||||
// encoder's counters (and for FLAC the failed frame's samples are already gone).
|
||||
assert!(matches!(enc.write(&buf.channels, 16), Err(AudioError::Encode(_))), "{format:?}");
|
||||
assert!(matches!(enc.finish(), Err(AudioError::Encode(_))), "{format:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_past_a_format_limit_are_refused_before_anything_is_written() {
|
||||
let buf = tone(2, 100);
|
||||
for (format, limit) in [(FileFormat::Aiff, 1u64 << 30), (FileFormat::Flac, 1u64 << 36)] {
|
||||
for (format, limit) in [(FileFormat::Aiff, 1usize << 30), (FileFormat::Flac, 1 << 36)] {
|
||||
let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
let mut enc = FileEncoder::new(Cursor::new(Vec::new()), &opts, 2, buf.sample_rate, None).unwrap();
|
||||
enc.write(&buf.channels, 100).unwrap();
|
||||
assert!(enc.check_room(limit / 2).is_ok(), "{format:?}");
|
||||
assert!(matches!(enc.check_room(limit), Err(AudioError::TooLarge(_))), "{format:?}");
|
||||
if let Ok(frames) = usize::try_from(limit) {
|
||||
assert!(matches!(enc.write(&[], frames), Err(AudioError::TooLarge(_))), "{format:?}");
|
||||
}
|
||||
assert!(enc.check_room(limit as u64 / 2).is_ok(), "{format:?}");
|
||||
assert!(matches!(enc.check_room(limit as u64), Err(AudioError::TooLarge(_))), "{format:?}");
|
||||
assert!(matches!(enc.write(&[], limit), Err(AudioError::TooLarge(_))), "{format:?}");
|
||||
assert_eq!(enc.finish().unwrap().into_inner(), encode(&buf, &opts).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
//! A WAV whose data chunk is empty and followed by metadata decodes to no audio; a recording
|
||||
//! that was never finalised (RIFF and data sizes left at 0) still yields what was written (#466).
|
||||
|
||||
use soundcraft_audio_io::{decode, probe};
|
||||
|
||||
fn fmt16(channels: u16, sr: u32) -> Vec<u8> {
|
||||
let align = channels * 2;
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(&1u16.to_le_bytes());
|
||||
f.extend_from_slice(&channels.to_le_bytes());
|
||||
f.extend_from_slice(&sr.to_le_bytes());
|
||||
f.extend_from_slice(&(sr * u32::from(align)).to_le_bytes());
|
||||
f.extend_from_slice(&align.to_le_bytes());
|
||||
f.extend_from_slice(&16u16.to_le_bytes());
|
||||
f
|
||||
}
|
||||
|
||||
fn chunk(id: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut c = id.to_vec();
|
||||
c.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
c.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
c.push(0);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// A finished file: correct RIFF size, chunks as given.
|
||||
fn riff(chunks: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut body = b"WAVE".to_vec();
|
||||
for c in chunks {
|
||||
body.extend_from_slice(c);
|
||||
}
|
||||
let mut out = b"RIFF".to_vec();
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out
|
||||
}
|
||||
|
||||
fn list_info() -> Vec<u8> {
|
||||
let mut l = b"INFO".to_vec();
|
||||
l.extend_from_slice(&chunk(b"INAM", b"Silence"));
|
||||
chunk(b"LIST", &l)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_data_chunk_followed_by_metadata_has_no_frames() {
|
||||
let file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[]), list_info(), chunk(b"bext", &[0u8; 602])]);
|
||||
let info = probe(&file, Some("wav")).unwrap();
|
||||
assert_eq!(info.frames, 0, "the LIST and bext chunks are not samples");
|
||||
let (info, buf) = decode(&file, Some("wav")).unwrap();
|
||||
assert_eq!((info.frames, buf.frames()), (0, 0));
|
||||
// The same with the metadata first.
|
||||
let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), list_info(), chunk(b"data", &[])]);
|
||||
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0);
|
||||
// And with a single trailing chunk of odd size and no pad byte at the very end.
|
||||
let mut file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &[]), chunk(b"note", b"abc")]);
|
||||
file.pop();
|
||||
let riff_size = (file.len() - 8) as u32;
|
||||
file[4..8].copy_from_slice(&riff_size.to_le_bytes());
|
||||
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_that_was_never_finalised_still_gives_its_audio() {
|
||||
let samples: Vec<u8> = (0..400i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
|
||||
// RIFF size and data size both left at 0, as a recorder writes them until it finishes.
|
||||
let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]);
|
||||
file.extend_from_slice(&samples);
|
||||
file[4..8].copy_from_slice(&0u32.to_le_bytes());
|
||||
let (info, buf) = decode(&file, Some("wav")).unwrap();
|
||||
assert_eq!(info.frames, 200);
|
||||
assert_eq!(buf.frames(), 200);
|
||||
// Even if the RIFF size is the file's, audio that is not a run of chunks is still audio.
|
||||
let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]);
|
||||
file.extend_from_slice(&samples);
|
||||
let riff_size = (file.len() - 8) as u32;
|
||||
file[4..8].copy_from_slice(&riff_size.to_le_bytes());
|
||||
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_data_chunk_with_audio_is_unchanged() {
|
||||
let samples: Vec<u8> = (0..100i16).flat_map(|i| i.to_le_bytes()).collect();
|
||||
let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &samples), list_info()]);
|
||||
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 100);
|
||||
}
|
||||
@@ -106,48 +106,37 @@ impl Remote {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One request/reply exchange. On failure the flag says whether the request line was already
|
||||
/// written to the socket (so the app may have executed it).
|
||||
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, (String, bool)> {
|
||||
self.connect().map_err(|e| (e, false))?;
|
||||
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, String> {
|
||||
self.connect()?;
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let line = json!({"id": id, "method": method, "params": params}).to_string();
|
||||
let (w, r) = self.conn.as_mut().ok_or_else(|| ("not connected".to_string(), false))?;
|
||||
writeln!(w, "{line}").map_err(|e| (e.to_string(), false))?;
|
||||
let (w, r) = self.conn.as_mut().ok_or("not connected")?;
|
||||
writeln!(w, "{line}").map_err(|e| e.to_string())?;
|
||||
let mut buf = String::new();
|
||||
r.read_line(&mut buf).map_err(|e| (e.to_string(), true))?;
|
||||
r.read_line(&mut buf).map_err(|e| e.to_string())?;
|
||||
if buf.is_empty() {
|
||||
return Err(("connection closed".into(), true));
|
||||
return Err("connection closed".into());
|
||||
}
|
||||
let v: Value = serde_json::from_str(&buf).map_err(|e| (e.to_string(), true))?;
|
||||
let v: Value = serde_json::from_str(&buf).map_err(|e| e.to_string())?;
|
||||
if v.get("ok").and_then(Value::as_bool).unwrap_or(false) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err((v.get("error").and_then(Value::as_str).unwrap_or("error").to_string(), true))
|
||||
Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Methods that only read state, so repeating them after a lost reply is harmless.
|
||||
fn is_read_only(method: &str) -> bool {
|
||||
matches!(method, "session.inspect" | "document.inspect" | "engine.commands" | "engine.parity" | "ui.inspect" | "ui.menu.list")
|
||||
}
|
||||
|
||||
impl Backend for Remote {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
match self.roundtrip(method, ¶ms) {
|
||||
Ok(v) => Ok(v),
|
||||
Err((e, sent)) if e.contains("closed") || e.contains("Broken pipe") || e.contains("reset") => {
|
||||
self.conn = None;
|
||||
if sent && !is_read_only(method) {
|
||||
// The app may already have executed the request; repeating it could apply it twice.
|
||||
return Err(format!("{e}; `{method}` may or may not have been executed, check the session before repeating it"));
|
||||
}
|
||||
Err(e) if e.contains("closed") || e.contains("Broken pipe") || e.contains("reset") => {
|
||||
// Retry once on a fresh connection.
|
||||
self.roundtrip(method, ¶ms).map_err(|(e, _)| e)
|
||||
self.conn = None;
|
||||
self.roundtrip(method, ¶ms)
|
||||
}
|
||||
Err((e, _)) => Err(e),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
@@ -178,43 +167,4 @@ mod tests {
|
||||
let mut r = Remote::new("127.0.0.1:1");
|
||||
assert!(r.call("session.inspect", json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Fake app: accepts one connection per entry in `replies`, counts the request line it reads,
|
||||
/// then either drops the connection (`false`) or answers ok (`true`).
|
||||
fn fake_app(replies: Vec<bool>) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let seen = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counter = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
for answer in replies {
|
||||
let Ok((mut sock, _)) = listener.accept() else { return };
|
||||
let mut line = String::new();
|
||||
if BufReader::new(sock.try_clone().unwrap()).read_line(&mut line).unwrap_or(0) > 0 {
|
||||
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
if answer {
|
||||
let _ = writeln!(sock, "{}", json!({"id": 1, "ok": true, "result": {"done": true}}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
(addr, seen)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_does_not_repeat_a_mutation_after_a_lost_reply() {
|
||||
let (addr, seen) = fake_app(vec![false, true]);
|
||||
let mut r = Remote::new(&addr);
|
||||
let err = r.call("engine.execute", json!({"command": "track.new"})).unwrap_err();
|
||||
assert!(err.contains("may or may not have been executed"), "{err}");
|
||||
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_retries_a_read_after_a_lost_reply() {
|
||||
let (addr, seen) = fake_app(vec![false, true]);
|
||||
let mut r = Remote::new(&addr);
|
||||
assert!(r.call("session.inspect", json!({})).is_ok());
|
||||
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ use std::io::{BufRead, Write};
|
||||
|
||||
pub const PROTOCOL_VERSION: &str = "2025-06-18";
|
||||
const SUPPORTED: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
|
||||
const MAX_BATCH_CALLS: usize = 1000;
|
||||
|
||||
const INSTRUCTIONS: &str = "SoundCraft is a digital audio workstation (Pro Tools-class). Everything a user can do is a \
|
||||
command: call `list_commands` to discover ids and their params, then `execute` (or `batch`). Positions are samples \
|
||||
@@ -38,8 +37,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
),
|
||||
tool(
|
||||
"batch",
|
||||
"Run up to 1000 commands in order; stops at the first error unless keep_going. Larger batches are rejected before any command runs.",
|
||||
json!({"calls": {"type": "array", "maxItems": MAX_BATCH_CALLS, "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}}}, "keep_going": {"type": "boolean"}}),
|
||||
"Run several commands in order; stops at the first error unless keep_going.",
|
||||
json!({"calls": {"type": "array", "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}}}, "keep_going": {"type": "boolean"}}),
|
||||
&["calls"],
|
||||
),
|
||||
tool(
|
||||
@@ -136,12 +135,9 @@ impl<B: Backend> Server<B> {
|
||||
let Some(calls) = args.get("calls").and_then(Value::as_array) else {
|
||||
return error_result("`calls` must be an array");
|
||||
};
|
||||
if calls.len() > MAX_BATCH_CALLS {
|
||||
return error_result(&format!("`calls` must contain at most {MAX_BATCH_CALLS} commands; no commands were executed"));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut failed = false;
|
||||
for c in calls {
|
||||
for c in calls.iter().take(1000) {
|
||||
let Some(cmd) = c.get("command").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else {
|
||||
out.push(json!({"command": "", "ok": false, "error": "`command` required"}));
|
||||
failed = true;
|
||||
|
||||
@@ -31,8 +31,6 @@ fn handshake_and_tool_list() {
|
||||
assert_eq!(r["result"]["serverInfo"]["name"], "soundcraft");
|
||||
let r = rpc(&mut s, 2, "tools/list", json!({}));
|
||||
assert!(r["result"]["tools"].as_array().unwrap().len() >= 10);
|
||||
let batch = r["result"]["tools"].as_array().unwrap().iter().find(|tool| tool["name"] == "batch").unwrap();
|
||||
assert_eq!(batch["inputSchema"]["properties"]["calls"]["maxItems"], 1000);
|
||||
assert!(s.handle(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).is_none());
|
||||
let r = rpc(&mut s, 3, "nope", json!({}));
|
||||
assert_eq!(r["error"]["code"], -32601);
|
||||
@@ -173,35 +171,3 @@ fn batch_rejects_non_array_calls() {
|
||||
assert!(text.contains("`calls` must be an array"), "{text}");
|
||||
assert_eq!(session(&mut s)["tracks"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_rejects_oversize_calls_before_any_mutation() {
|
||||
for keep_going in [false, true] {
|
||||
let mut s = Server::new(Headless::new(Engine::default()));
|
||||
let before = session(&mut s);
|
||||
let mut calls = vec![json!({"command": "engine.history"}); 1001];
|
||||
calls[0] = json!({"command": "track.new", "params": {"name": "Must not be created"}});
|
||||
let r = rpc(&mut s, 1, "tools/call", json!({"name": "batch", "arguments": {"calls": calls, "keep_going": keep_going}}));
|
||||
assert_eq!(session(&mut s), before, "oversize batch mutated the session (keep_going={keep_going})");
|
||||
assert!(s.backend.engine.journal.is_empty());
|
||||
assert!(!s.backend.engine.can_undo());
|
||||
assert_eq!(r["result"]["isError"], true);
|
||||
let text = r["result"]["content"][0]["text"].as_str().unwrap();
|
||||
assert!(text.contains("1000"), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_accepts_empty_and_limit_sized_calls_in_order() {
|
||||
for count in [0, 1000] {
|
||||
let mut s = Server::new(Headless::new(Engine::default()));
|
||||
let calls: Vec<_> = (0..count).map(|at| json!({"command": "session.format_time", "params": {"at": at, "format": "samples"}})).collect();
|
||||
let results = tool(&mut s, "batch", json!({"calls": calls}));
|
||||
let results = results.as_array().unwrap();
|
||||
assert_eq!(results.len(), count);
|
||||
for (at, result) in results.iter().enumerate() {
|
||||
assert_eq!(result["ok"], true);
|
||||
assert_eq!(result["result"]["text"], at.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1241,38 +1241,25 @@ impl Instance {
|
||||
ok.then(|| read_name(&buf)).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// The plugin's audio ports in one direction. A layout beyond `MAX_PORTS` ports or
|
||||
/// `MAX_PORT_CHANNELS` channels per port is an error: we never hand a plugin fewer ports or
|
||||
/// channels than it advertised.
|
||||
pub fn audio_ports(&self, is_input: bool) -> Result<Vec<PortInfo>, ClapError> {
|
||||
pub fn audio_ports(&self, is_input: bool) -> Vec<PortInfo> {
|
||||
// SAFETY: see `params`.
|
||||
let Some(p) = (unsafe { self.ports.as_ref() }) else { return Ok(Vec::new()) };
|
||||
let (Some(count), Some(get)) = (p.count, p.get) else { return Ok(Vec::new()) };
|
||||
let dir = if is_input { "input" } else { "output" };
|
||||
let Some(p) = (unsafe { self.ports.as_ref() }) else { return Vec::new() };
|
||||
let (Some(count), Some(get)) = (p.count, p.get) else { return Vec::new() };
|
||||
// SAFETY: vtable call with its instance.
|
||||
let n = unsafe { count(self.plugin, is_input) };
|
||||
if n > MAX_PORTS {
|
||||
return Err(ClapError::Process(format!("{n} audio {dir} ports (at most {MAX_PORTS} supported)")));
|
||||
}
|
||||
let n = unsafe { count(self.plugin, is_input) }.min(MAX_PORTS);
|
||||
let mut out = Vec::new();
|
||||
for i in 0..n {
|
||||
// SAFETY: plain data; all-zero (null port_type pointer) is valid.
|
||||
let mut info: clap_audio_port_info = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: index below the count; `info` is writable.
|
||||
if unsafe { get(self.plugin, i, is_input, &mut info) } {
|
||||
if info.channel_count > MAX_PORT_CHANNELS {
|
||||
return Err(ClapError::Process(format!(
|
||||
"audio {dir} port {i} has {} channels (at most {MAX_PORT_CHANNELS} supported)",
|
||||
info.channel_count
|
||||
)));
|
||||
}
|
||||
out.push(PortInfo { channels: info.channel_count, main: info.flags & CLAP_AUDIO_PORT_IS_MAIN != 0 });
|
||||
out.push(PortInfo { channels: info.channel_count.min(MAX_PORT_CHANNELS), main: info.flags & CLAP_AUDIO_PORT_IS_MAIN != 0 });
|
||||
} else {
|
||||
// Keep port indices aligned: an unreadable port still needs a (silent) buffer slot.
|
||||
out.push(PortInfo { channels: 0, main: false });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
out
|
||||
}
|
||||
|
||||
pub fn note_port(&self) -> Option<NotePort> {
|
||||
|
||||
@@ -113,21 +113,8 @@ impl Plugin for ClapPlugin {
|
||||
self.channels = channels.clamp(1, MAX_CHANNELS);
|
||||
self.inst.deactivate();
|
||||
self.inst.service_callback();
|
||||
let ports = self.inst.audio_ports(true).and_then(|ins| Ok((ins, self.inst.audio_ports(false)?)));
|
||||
let (ins, outs) = match ports {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
// Unsupported layout: stay inactive (`process` passes audio through untouched).
|
||||
log::warn!("{}: {e}", self.info.id);
|
||||
self.inputs = PortBuffers::new(&[], 0);
|
||||
self.outputs = PortBuffers::new(&[], 0);
|
||||
self.in_main = None;
|
||||
self.out_main = None;
|
||||
self.max_block = 0;
|
||||
self.latency = 0;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let ins = self.inst.audio_ports(true);
|
||||
let outs = self.inst.audio_ports(false);
|
||||
self.in_main = pick_main(&ins);
|
||||
self.out_main = pick_main(&outs);
|
||||
self.inputs = PortBuffers::new(&ins.iter().map(|p| p.channels).collect::<Vec<_>>(), mb);
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn fixture_dir() -> &'static Path {
|
||||
std::fs::create_dir_all(dir.join("vendor")).unwrap();
|
||||
std::fs::copy(&built, dir.join("vendor/Fixture.clap")).unwrap();
|
||||
}
|
||||
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 5);
|
||||
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 2);
|
||||
dir
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,11 +6,6 @@
|
||||
//! to the velocity of the held note (0 when none), switching at the event's sample offset. A
|
||||
//! MIDI CC 7 (volume) sets the constant to its value / 127.
|
||||
//!
|
||||
//! - `org.soundcraft.test.wide16`, `.wide17ports`, `.wide65ch`: effects advertising wide layouts
|
||||
//! (16 ports of 64 channels, 17 ports of 2 channels, one port of 65 channels per direction).
|
||||
//! `process` writes 1.0 into every output channel it was given when the host's buffers match the
|
||||
//! advertised layout, -1.0 when they do not.
|
||||
//!
|
||||
//! The gain plugin also implements `clap.state` (gain and mode as two little-endian f64s) and a
|
||||
//! floating-only `clap.gui` without a real window: showing it "edits" the gain to 0.25 in the GUI,
|
||||
//! reported as an output parameter event by the next `process`.
|
||||
@@ -41,27 +36,6 @@ unsafe impl<const N: usize> Sync for Features<N> {}
|
||||
static GAIN_FEATURES: Features<3> = Features([c"audio-effect".as_ptr(), c"stereo".as_ptr(), std::ptr::null()]);
|
||||
static SYNTH_FEATURES: Features<3> = Features([c"instrument".as_ptr(), c"synthesizer".as_ptr(), std::ptr::null()]);
|
||||
|
||||
static WIDE_FEATURES: Features<2> = Features([c"audio-effect".as_ptr(), std::ptr::null()]);
|
||||
|
||||
const fn wide_desc(id: &'static CStr, name: &'static CStr) -> clap_plugin_descriptor {
|
||||
clap_plugin_descriptor {
|
||||
clap_version: CLAP_VERSION,
|
||||
id: id.as_ptr(),
|
||||
name: name.as_ptr(),
|
||||
vendor: c"SoundCraft tests".as_ptr(),
|
||||
url: std::ptr::null(),
|
||||
manual_url: std::ptr::null(),
|
||||
support_url: std::ptr::null(),
|
||||
version: c"1.0.0".as_ptr(),
|
||||
description: std::ptr::null(),
|
||||
features: WIDE_FEATURES.0.as_ptr(),
|
||||
}
|
||||
}
|
||||
|
||||
static WIDE16_DESC: clap_plugin_descriptor = wide_desc(c"org.soundcraft.test.wide16", c"Fixture Wide 16x64");
|
||||
static WIDE17_DESC: clap_plugin_descriptor = wide_desc(c"org.soundcraft.test.wide17ports", c"Fixture Wide 17 ports");
|
||||
static WIDE65_DESC: clap_plugin_descriptor = wide_desc(c"org.soundcraft.test.wide65ch", c"Fixture Wide 65 channels");
|
||||
|
||||
static GAIN_DESC: clap_plugin_descriptor = clap_plugin_descriptor {
|
||||
clap_version: CLAP_VERSION,
|
||||
id: c"org.soundcraft.test.gain".as_ptr(),
|
||||
@@ -92,8 +66,6 @@ struct Fx {
|
||||
plugin: clap_plugin,
|
||||
host: *const clap_host,
|
||||
synth: bool,
|
||||
/// `(ports per direction, channels per port)` for the wide fixtures.
|
||||
wide: Option<(u32, u32)>,
|
||||
gain: f64,
|
||||
mode: f64,
|
||||
level: f64,
|
||||
@@ -196,22 +168,6 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
|
||||
let n = pr.frames_count as usize;
|
||||
let ev = unsafe { &*pr.in_events };
|
||||
let count = unsafe { (ev.size.unwrap())(pr.in_events) };
|
||||
if let Some((ports, channels)) = f.wide {
|
||||
let fits = |bufs: *const clap_audio_buffer, n: u32| {
|
||||
n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels)
|
||||
};
|
||||
let ok = fits(pr.audio_inputs, pr.audio_inputs_count) && fits(pr.audio_outputs, pr.audio_outputs_count);
|
||||
if pr.audio_outputs_count > 0 {
|
||||
let out = unsafe { &*pr.audio_outputs };
|
||||
for c in 0..out.channel_count as usize {
|
||||
let o = unsafe { *out.data32.add(c) };
|
||||
for i in 0..n {
|
||||
unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } };
|
||||
}
|
||||
}
|
||||
}
|
||||
return CLAP_PROCESS_CONTINUE;
|
||||
}
|
||||
if pr.audio_outputs_count != 1 {
|
||||
return CLAP_PROCESS_ERROR;
|
||||
}
|
||||
@@ -368,26 +324,10 @@ static PARAMS: clap_plugin_params = clap_plugin_params {
|
||||
};
|
||||
|
||||
unsafe extern "C" fn ports_count(p: *const clap_plugin, is_input: bool) -> u32 {
|
||||
if let Some((ports, _)) = unsafe { fx(p) }.wide {
|
||||
return ports;
|
||||
}
|
||||
if is_input && unsafe { fx(p) }.synth { 0 } else { 1 }
|
||||
}
|
||||
|
||||
unsafe extern "C" fn ports_get(p: *const clap_plugin, index: u32, is_input: bool, info: *mut clap_audio_port_info) -> bool {
|
||||
if let Some((ports, channels)) = unsafe { fx(p) }.wide {
|
||||
if index >= ports {
|
||||
return false;
|
||||
}
|
||||
let info = unsafe { &mut *info };
|
||||
info.id = index * 2 + u32::from(is_input);
|
||||
write_name(&mut info.name, c"wide");
|
||||
info.flags = if index == 0 { CLAP_AUDIO_PORT_IS_MAIN } else { 0 };
|
||||
info.channel_count = channels;
|
||||
info.port_type = std::ptr::null();
|
||||
info.in_place_pair = clap_sys::id::CLAP_INVALID_ID;
|
||||
return true;
|
||||
}
|
||||
if index != 0 || (is_input && unsafe { fx(p) }.synth) {
|
||||
return false;
|
||||
}
|
||||
@@ -558,32 +498,23 @@ unsafe extern "C" fn on_main_thread(_p: *const clap_plugin) {}
|
||||
// ---- factory + entry ----
|
||||
|
||||
unsafe extern "C" fn factory_count(_f: *const clap_plugin_factory) -> u32 {
|
||||
5
|
||||
2
|
||||
}
|
||||
|
||||
unsafe extern "C" fn factory_desc(_f: *const clap_plugin_factory, i: u32) -> *const clap_plugin_descriptor {
|
||||
match i {
|
||||
0 => &GAIN_DESC,
|
||||
1 => &SYNTH_DESC,
|
||||
2 => &WIDE16_DESC,
|
||||
3 => &WIDE17_DESC,
|
||||
4 => &WIDE65_DESC,
|
||||
_ => std::ptr::null(),
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const clap_host, id: *const c_char) -> *const clap_plugin {
|
||||
let id = unsafe { CStr::from_ptr(id) };
|
||||
let (desc, synth, wide) = if id == c"org.soundcraft.test.gain" {
|
||||
(&GAIN_DESC as *const _, false, None)
|
||||
let (desc, synth) = if id == c"org.soundcraft.test.gain" {
|
||||
(&GAIN_DESC as *const _, false)
|
||||
} else if id == c"org.soundcraft.test.synth" {
|
||||
(&SYNTH_DESC as *const _, true, None)
|
||||
} else if id == c"org.soundcraft.test.wide16" {
|
||||
(&WIDE16_DESC as *const _, false, Some((16, 64)))
|
||||
} else if id == c"org.soundcraft.test.wide17ports" {
|
||||
(&WIDE17_DESC as *const _, false, Some((17, 2)))
|
||||
} else if id == c"org.soundcraft.test.wide65ch" {
|
||||
(&WIDE65_DESC as *const _, false, Some((1, 65)))
|
||||
(&SYNTH_DESC as *const _, true)
|
||||
} else {
|
||||
return std::ptr::null();
|
||||
};
|
||||
@@ -604,7 +535,6 @@ unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const
|
||||
},
|
||||
host,
|
||||
synth,
|
||||
wide,
|
||||
gain: 1.0,
|
||||
mode: 0.0,
|
||||
level: 0.0,
|
||||
|
||||
@@ -13,7 +13,7 @@ const SYNTH: &str = "clap:org.soundcraft.test.synth";
|
||||
fn scan_lists_both_fixture_plugins() {
|
||||
let dir = fixture_dir();
|
||||
let found = soundcraft_clap_host::scan_paths(&[dir.to_path_buf()]);
|
||||
assert_eq!(found.len(), 5);
|
||||
assert_eq!(found.len(), 2);
|
||||
let g = found.iter().find(|d| d.id == GAIN).unwrap();
|
||||
assert_eq!(g.name, "Fixture Gain");
|
||||
assert_eq!(g.vendor, "SoundCraft tests");
|
||||
@@ -110,27 +110,6 @@ fn synth_plays_notes_at_their_offsets() {
|
||||
assert!(io[0][19].abs() < 1e-6 && (io[0][20] - 64.0 / 127.0).abs() < 1e-6, "controllers arrive as MIDI at their offset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
|
||||
fixture_dir();
|
||||
// Control: exactly at the limits (16 ports of 64 channels) activates and gets its full layout.
|
||||
let mut p = soundcraft_clap_host::create("clap:org.soundcraft.test.wide16").unwrap();
|
||||
p.prepare(44_100.0, 64, 2);
|
||||
assert_eq!(p.latency(), 3);
|
||||
let mut io = vec![vec![0.5f32; 64]; 2];
|
||||
p.process(&mut io, 64);
|
||||
assert!(io.iter().flatten().all(|&x| x == 1.0), "16x64 layout delivered as advertised: {:?}", &io[0][..4]);
|
||||
// 17 ports, or a 65-channel port: never activated, never processed with a cut-down layout.
|
||||
for id in ["clap:org.soundcraft.test.wide17ports", "clap:org.soundcraft.test.wide65ch"] {
|
||||
let mut p = soundcraft_clap_host::create(id).unwrap();
|
||||
p.prepare(44_100.0, 64, 2);
|
||||
assert_eq!(p.latency(), 0, "{id}: not activated");
|
||||
let mut io = vec![vec![0.5f32; 64]; 2];
|
||||
p.process(&mut io, 64);
|
||||
assert!(io.iter().flatten().all(|&x| x == 0.5), "{id}: the plugin never ran: {:?}", &io[0][..4]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_instances_create_and_drop_cleanly() {
|
||||
fixture_dir();
|
||||
|
||||
@@ -275,12 +275,6 @@ pub trait PluginEditor: Send {
|
||||
/// Tells the editor that a parameter changed elsewhere (generic editor, automation), so the
|
||||
/// GUI shows it. Values are in SoundCraft units.
|
||||
fn set_param(&mut self, _id: &str, _value: f32) {}
|
||||
/// The plugin's own text for a parameter value (SoundCraft units) when SoundCraft's
|
||||
/// [`ParamInfo::format`] would be wrong for it. Main thread only; `None` means "format it
|
||||
/// yourself".
|
||||
fn value_text(&mut self, _id: &str, _value: f32) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a plugin's state on the main (UI) thread while the instance processes on the audio
|
||||
|
||||
@@ -65,29 +65,19 @@ 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 {
|
||||
scale_to(c.iter_mut(), target, p);
|
||||
let g = target / p;
|
||||
c.iter_mut().for_each(|s| *s *= g);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let p = peak(ch);
|
||||
if p > 0.0 {
|
||||
scale_to(ch.iter_mut().flatten(), target, p);
|
||||
let g = target / p;
|
||||
ch.iter_mut().flatten().for_each(|s| *s *= g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scales samples whose peak is `p` to `target`. A subnormal `p` makes the f32 gain overflow to
|
||||
/// infinity, so that case scales in f64.
|
||||
fn scale_to<'a>(samples: impl Iterator<Item = &'a mut f32>, target: f32, p: f32) {
|
||||
let g = target / p;
|
||||
if g.is_finite() {
|
||||
samples.for_each(|s| *s *= g);
|
||||
} else {
|
||||
let g = f64::from(target) / f64::from(p);
|
||||
samples.for_each(|s| *s = (f64::from(*s) * g) as f32);
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a gain change in dB.
|
||||
pub fn gain(ch: &mut [Vec<f32>], db: f32) {
|
||||
let g = db_to_gain(if db.is_nan() { 0.0 } else { db.min(96.0) });
|
||||
|
||||
@@ -331,26 +331,15 @@ impl Sampler {
|
||||
/// Moves a voice's read position one output frame on, looping or ending it.
|
||||
#[inline]
|
||||
fn advance(v: &mut Voice, len: f64, region: Option<(f64, f64)>, mode: LoopMode) {
|
||||
let was_forward = v.forward;
|
||||
v.pos = if was_forward { v.pos + v.step } else { v.pos - v.step };
|
||||
v.pos = if v.forward { v.pos + v.step } else { v.pos - v.step };
|
||||
match region {
|
||||
Some((ls, le)) if mode == LoopMode::PingPong => {
|
||||
// How far past the edge it was heading for, measured from the edge it left. A step of
|
||||
// more than the loop's width crosses it more than once: fold the distance into the
|
||||
// loop (even crossings keep the direction, odd ones turn it round) instead of
|
||||
// reflecting once and dropping the rest of the travel.
|
||||
let width = le - ls;
|
||||
let dist = if was_forward { v.pos - ls } else { le - v.pos };
|
||||
if width > 0.0 && dist >= width && dist.is_finite() {
|
||||
let crossings = (dist / width).floor();
|
||||
let into = dist - crossings * width;
|
||||
let odd = crossings % 2.0 == 1.0;
|
||||
(v.pos, v.forward) = match (was_forward, odd) {
|
||||
(true, false) => (ls + into, true),
|
||||
(true, true) => (le - into, false),
|
||||
(false, false) => (le - into, false),
|
||||
(false, true) => (ls + into, true),
|
||||
};
|
||||
if v.forward && v.pos >= le {
|
||||
v.pos = (le - (v.pos - le)).max(ls);
|
||||
v.forward = false;
|
||||
} else if !v.forward && v.pos <= ls {
|
||||
v.pos = (ls + (ls - v.pos)).min(le);
|
||||
v.forward = true;
|
||||
}
|
||||
}
|
||||
Some((ls, le)) => {
|
||||
@@ -714,52 +703,6 @@ mod tests {
|
||||
assert!(out[0].iter().all(|x| x.is_finite() && *x <= 1.0));
|
||||
}
|
||||
|
||||
/// Where a ping-pong voice is after `n` steps, by walking a triangle wave over the loop.
|
||||
fn triangle(start: f64, forward: bool, step: f64, n: u32, ls: f64, le: f64) -> (f64, bool) {
|
||||
let width = le - ls;
|
||||
// Unfold the walk onto one line, then fold it back: phase 0..width rising, width..2 width falling.
|
||||
let signed = if forward { start - ls } else { (le - start) + width };
|
||||
let t = (signed + f64::from(n) * step).rem_euclid(2.0 * width);
|
||||
if t < width { (ls + t, true) } else { (le - (t - width), false) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ping_pong_step_wider_than_the_loop_keeps_the_phase() {
|
||||
let (ls, le) = (100.0, 110.0);
|
||||
// Steps below, equal to and above the loop's width, with fractions, both ways round.
|
||||
for step in [0.37, 3.0, 9.99, 10.0, 10.5, 23.7, 25.0, 31.3, 99.9] {
|
||||
for forward in [true, false] {
|
||||
let mut v = Voice::new();
|
||||
v.pos = if forward { 102.5 } else { 107.25 };
|
||||
v.forward = forward;
|
||||
v.step = step;
|
||||
let start = v.pos;
|
||||
for n in 1..=40u32 {
|
||||
advance(&mut v, 1000.0, Some((ls, le)), LoopMode::PingPong);
|
||||
let (want, want_forward) = triangle(start, forward, step, n, ls, le);
|
||||
assert!(v.pos >= ls - 1e-9 && v.pos <= le + 1e-9, "step {step} n {n}: {} left the loop", v.pos);
|
||||
// At an exact end the direction is where the position is heading next.
|
||||
let at_end = (want - ls).abs() < 1e-6 || (want - le).abs() < 1e-6;
|
||||
assert!((v.pos - want).abs() < 1e-6, "step {step} {forward} n {n}: at {} want {want}", v.pos);
|
||||
assert!(at_end || v.forward == want_forward, "step {step} {forward} n {n}: direction");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ping_pong_step_below_the_loop_width_moves_as_before() {
|
||||
let mut v = Voice::new();
|
||||
v.pos = 108.0;
|
||||
v.step = 5.0;
|
||||
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
|
||||
assert_eq!((v.pos, v.forward), (107.0, false), "one reflection off the end");
|
||||
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
|
||||
assert_eq!((v.pos, v.forward), (102.0, false));
|
||||
advance(&mut v, 1000.0, Some((100.0, 110.0)), LoopMode::PingPong);
|
||||
assert_eq!((v.pos, v.forward), (103.0, true), "and one off the start");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_too_short_to_loop_plays_once() {
|
||||
let mut s = sampler(mono(SR, vec![0.5; 1000]));
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -23,7 +23,6 @@ serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
zip.workspace = true
|
||||
craft-docpath.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
||||
+117
-19
@@ -1,7 +1,7 @@
|
||||
//! Crash-recovery copies of open sessions, one folder each, written in the background, and the
|
||||
//! recovery of the ones left behind with unsaved work.
|
||||
|
||||
use crate::io::{Recording, open_session_linked, sanitize_name};
|
||||
use crate::io::{Recording, open_session, sanitize_name};
|
||||
use crate::{Engine, EngineError, Result};
|
||||
use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat};
|
||||
use soundcraft_model::{Session, SourceAudio, SourceId};
|
||||
@@ -179,10 +179,27 @@ impl Autosave {
|
||||
/// A new folder under `root` for the session called `name`.
|
||||
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))?;
|
||||
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 })
|
||||
Self::create_with_stamp(root, name, stamp)
|
||||
}
|
||||
|
||||
fn create_with_stamp(root: &Path, name: &str, stamp: u128) -> Result<Autosave> {
|
||||
std::fs::create_dir_all(root).map_err(|err| io_error(root, err))?;
|
||||
let prefix = format!("{} {stamp}-{}", sanitize_name(name), std::process::id());
|
||||
// Several sessions can close in one clock tick. Claim a fresh directory atomically;
|
||||
// reopening an existing one could overwrite work another session left for recovery.
|
||||
for suffix in 0..1024 {
|
||||
let dir = root.join(format!("{prefix}-{suffix}"));
|
||||
match std::fs::create_dir(&dir) {
|
||||
Ok(()) => {
|
||||
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())))?;
|
||||
return Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1, recording: false });
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(err) => return Err(io_error(&dir, err)),
|
||||
}
|
||||
}
|
||||
Err(EngineError::Io(format!("{}: could not allocate a new autosave folder", root.display())))
|
||||
}
|
||||
|
||||
/// Write `session`, opened from the session file `origin`, and the plan of the `recording`
|
||||
@@ -197,7 +214,7 @@ impl Autosave {
|
||||
let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) };
|
||||
let audio = s.pool.get(src.id);
|
||||
if let Some(f) = &file
|
||||
&& (audio.is_none() || !src.unsaved && f.is_file())
|
||||
&& (!src.unsaved && f.is_file() || audio.is_none())
|
||||
{
|
||||
src.path = f.to_string_lossy().into_owned();
|
||||
continue;
|
||||
@@ -257,7 +274,9 @@ pub struct Leftover {
|
||||
}
|
||||
|
||||
/// Leftover autosaves under `root`, newest first: folders no open session holds, written after the
|
||||
/// session file they came from or holding a recording. Empty and outdated ones are deleted.
|
||||
/// session file they came from or holding a recording. Equal timestamps are ambiguous, so those
|
||||
/// copies are retained too: a filesystem clock tick can cover both writes. Empty and provably
|
||||
/// outdated ones are deleted.
|
||||
pub fn leftovers(root: &Path) -> Vec<Leftover> {
|
||||
let mut out = Vec::new();
|
||||
for dir in std::fs::read_dir(root).into_iter().flatten().flatten().map(|d| d.path()) {
|
||||
@@ -271,7 +290,7 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
|
||||
let recording = dir.join(RECORDING).is_file();
|
||||
drop(lock);
|
||||
match saved {
|
||||
Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => {
|
||||
Ok(saved) if recording || origin_saved.is_none_or(|o| saved >= o) => {
|
||||
let text = std::fs::read_to_string(dir.join(SESSION)).unwrap_or_default();
|
||||
let named = serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|v| v.get("name")?.as_str().map(str::to_string));
|
||||
let name = named.unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned());
|
||||
@@ -291,9 +310,7 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
|
||||
/// media that couldn't be loaded.
|
||||
pub fn recover(e: &mut Engine, left: &Leftover) -> Result<Vec<String>> {
|
||||
let mut r = Engine::default();
|
||||
let session = left.dir.join(SESSION);
|
||||
let links = left.origin.as_deref().map_or(session.as_path(), Path::new);
|
||||
let mut missing = open_session_linked(&mut r, &session.to_string_lossy(), links)?;
|
||||
let mut missing = open_session(&mut r, &left.dir.join(SESSION).to_string_lossy())?;
|
||||
let mut s = r.session().clone();
|
||||
let unfinished = unfinished_takes(left, &s, &mut missing);
|
||||
if let Some(home) = left.origin.as_deref().and_then(|o| Path::new(o).parent()) {
|
||||
@@ -317,8 +334,7 @@ pub fn recover(e: &mut Engine, left: &Leftover) -> Result<Vec<String>> {
|
||||
/// is left untouched and added to `skipped`.
|
||||
fn unfinished_takes(left: &Leftover, s: &Session, skipped: &mut Vec<String>) -> Option<(Recording, Vec<(soundcraft_model::TrackId, PathBuf)>)> {
|
||||
let plan: Recording = serde_json::from_str(&std::fs::read_to_string(left.dir.join(RECORDING)).ok()?).ok()?;
|
||||
let known: Vec<PathBuf> =
|
||||
s.sources.iter().filter(|src| s.pool.get(src.id).is_some()).filter_map(|src| std::fs::canonicalize(&src.path).ok()).collect();
|
||||
let known: Vec<PathBuf> = s.sources.iter().filter_map(|src| std::fs::canonicalize(&src.path).ok()).collect();
|
||||
let folders = take_folders(left);
|
||||
let mut takes = Vec::new();
|
||||
for t in &plan.takes {
|
||||
@@ -416,7 +432,6 @@ fn io_error(path: &Path, err: std::io::Error) -> EngineError {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::io::open_session;
|
||||
use serde_json::json;
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
|
||||
@@ -427,6 +442,11 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
fn set_modified(path: &Path, time: SystemTime) {
|
||||
std::fs::OpenOptions::new().write(true).open(path).unwrap().set_modified(time).unwrap();
|
||||
assert_eq!(std::fs::metadata(path).unwrap().modified().unwrap(), time);
|
||||
}
|
||||
|
||||
fn record(e: &mut Engine, level: f32) {
|
||||
let before = e.session_arc();
|
||||
let s = e.session_mut();
|
||||
@@ -468,6 +488,82 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_timestamps_keep_unsaved_audio_for_recovery() {
|
||||
let dir = folder("autosave-equal-timestamps");
|
||||
let path = dir.join("Song/Song.scraft");
|
||||
let root = dir.join("Autosave");
|
||||
let mut e = Engine::default();
|
||||
record(&mut e, 0.5);
|
||||
crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap();
|
||||
record(&mut e, -0.25);
|
||||
let mut a = Autosave::create(&root, "Song").unwrap();
|
||||
a.write(e.session(), e.path.as_deref(), None).unwrap();
|
||||
let saved = a.dir.join(SESSION);
|
||||
let same_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
|
||||
set_modified(&path, same_time);
|
||||
set_modified(&saved, same_time);
|
||||
assert!(leftovers(&root).is_empty(), "a live session still owns its recovery copy");
|
||||
drop(a);
|
||||
let left = leftovers(&root);
|
||||
assert_eq!(left.len(), 1, "equal timestamps cannot prove unsaved work obsolete");
|
||||
assert!(saved.is_file());
|
||||
let mut recovered = Engine::default();
|
||||
assert!(recover(&mut recovered, &left[0]).unwrap().is_empty());
|
||||
assert_eq!(levels(&recovered), [0.5, -0.25]);
|
||||
recovered.execute("session.save", &json!({})).unwrap();
|
||||
set_modified(&path, same_time + Duration::from_secs(1));
|
||||
assert!(leftovers(&root).is_empty());
|
||||
assert!(!left[0].dir.exists());
|
||||
let mut reopened = Engine::default();
|
||||
open_session(&mut reopened, &path.to_string_lossy()).unwrap();
|
||||
assert_eq!(levels(&reopened), [0.5, -0.25]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_tick_autosaves_preserve_each_closed_session() {
|
||||
let root = folder("autosave-same-tick");
|
||||
let mut first = Engine::default();
|
||||
record(&mut first, 0.5);
|
||||
let mut a = Autosave::create_with_stamp(&root, "Untitled", 1_000_000).unwrap();
|
||||
a.write(first.session(), None, None).unwrap();
|
||||
let first_dir = a.dir.clone();
|
||||
let first_session = std::fs::read(first_dir.join(SESSION)).unwrap();
|
||||
let first_audio = std::fs::read_dir(first_dir.join("Audio Files")).unwrap().next().unwrap().unwrap().path();
|
||||
let first_samples = std::fs::read(&first_audio).unwrap();
|
||||
drop(a);
|
||||
let mut second = Engine::default();
|
||||
record(&mut second, -0.25);
|
||||
let mut b = Autosave::create_with_stamp(&root, "Untitled", 1_000_000).unwrap();
|
||||
b.write(second.session(), None, None).unwrap();
|
||||
let second_dir = b.dir.clone();
|
||||
drop(b);
|
||||
assert_ne!(first_dir, second_dir, "a new autosave must not reopen an earlier session's folder");
|
||||
assert_eq!(std::fs::read(first_dir.join(SESSION)).unwrap(), first_session);
|
||||
assert_eq!(std::fs::read(first_audio).unwrap(), first_samples);
|
||||
let left = leftovers(&root);
|
||||
assert_eq!(left.len(), 2);
|
||||
for (dir, expected) in [(first_dir, 0.5), (second_dir, -0.25)] {
|
||||
let copy = left.iter().find(|copy| copy.dir == dir).unwrap();
|
||||
let mut restored = Engine::default();
|
||||
assert!(recover(&mut restored, copy).unwrap().is_empty());
|
||||
assert_eq!(levels(&restored), [expected]);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_tick_autosaves_do_not_reuse_a_live_folder() {
|
||||
let root = folder("autosave-live-same-tick");
|
||||
let first = Autosave::create_with_stamp(&root, "Untitled", 1_000_000).unwrap();
|
||||
let second = Autosave::create_with_stamp(&root, "Untitled", 1_000_000).unwrap();
|
||||
assert_ne!(first.dir, second.dir);
|
||||
assert!(first.lock.is_some() && second.lock.is_some());
|
||||
drop((first, second));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_take_recorded_after_undo_gets_its_own_copy() {
|
||||
let dir = folder("autosave-undo");
|
||||
@@ -561,15 +657,15 @@ mod tests {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
// Only the symlink case below adds to it, and symlinks are a Unix-only part of the test.
|
||||
#[cfg_attr(not(unix), allow(unused_mut))]
|
||||
let mut paths: Vec<PathBuf> = files.iter().map(|(p, _)| p.clone()).collect();
|
||||
let paths: Vec<PathBuf> = files.iter().map(|(p, _)| p.clone()).collect();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let paths = {
|
||||
let mut paths = paths;
|
||||
let link = takes.join("Vox_03.wav");
|
||||
std::os::unix::fs::symlink(&files[1].0, &link).unwrap();
|
||||
paths.push(link);
|
||||
}
|
||||
paths
|
||||
};
|
||||
let plan = Recording {
|
||||
start: 0,
|
||||
pass: None,
|
||||
@@ -609,6 +705,8 @@ mod tests {
|
||||
recover(&mut r, &left[0]).unwrap();
|
||||
assert!(r.is_dirty());
|
||||
r.execute("session.save", &json!({})).unwrap();
|
||||
let autosaved = std::fs::metadata(left[0].dir.join(SESSION)).unwrap().modified().unwrap();
|
||||
set_modified(Path::new(&path), autosaved + Duration::from_secs(1));
|
||||
assert!(leftovers(&root).is_empty());
|
||||
assert!(!left[0].dir.exists());
|
||||
let mut reopened = Engine::default();
|
||||
|
||||
@@ -149,7 +149,6 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
|
||||
s.add_track(kind, fmt, Some("Clip Group"))
|
||||
}
|
||||
};
|
||||
let mut prepared = Vec::new();
|
||||
for mut c in lane {
|
||||
if let ClipContent::Audio { source, offset } = &mut c.content {
|
||||
let Some(n) = map.get(&source.0) else { continue };
|
||||
@@ -161,23 +160,10 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
|
||||
c.id = s.new_clip_id();
|
||||
c.start = at.saturating_add(scale(c.start.max(0)));
|
||||
c.length = scale(c.length);
|
||||
c.fade_in.len = scale(c.fade_in.len);
|
||||
c.fade_out.len = scale(c.fade_out.len);
|
||||
c.sync_point = scale(c.sync_point);
|
||||
c.gain_env.iter_mut().for_each(|(at, _)| *at = scale(*at));
|
||||
c.group = Some(group_id);
|
||||
c.sanitize();
|
||||
placed.push(c.id);
|
||||
prepared.push(c);
|
||||
}
|
||||
// Clear what was already on the track first, then add the whole lane at once, so clips of
|
||||
// the group that overlap each other (crossfades) keep their overlap.
|
||||
for c in &prepared {
|
||||
crate::edit::clear_range(s, target, c.range(), false);
|
||||
}
|
||||
if let Some(pl) = s.track_mut(target).and_then(|t| t.playlist_mut()) {
|
||||
pl.clips.extend(prepared);
|
||||
pl.sort();
|
||||
crate::edit::place_clip(s, target, c);
|
||||
}
|
||||
}
|
||||
Ok(placed)
|
||||
@@ -243,51 +229,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_clips_of_a_group_keep_their_overlap_on_import() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-overlap-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let p = dir.join("overlap.scgrp");
|
||||
let note = json!({"pitch": 60, "velocity": 100, "release_velocity": 64, "channel": 0, "start": 0, "length": 100});
|
||||
let content = json!({"Midi": {"sequence": {"notes": [note], "ctrls": []}}});
|
||||
let a = json!({"id": 1, "name": "a", "start": 0, "length": 1000, "content": content});
|
||||
let b = json!({"id": 2, "name": "b", "start": 600, "length": 1000, "content": content});
|
||||
let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": 48000, "length": 1600, "lanes": [[a, b]], "audio": {}});
|
||||
std::fs::write(&p, file.to_string()).unwrap();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap();
|
||||
let ids: Vec<_> = r["clips"].as_array().unwrap().iter().filter_map(serde_json::Value::as_u64).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
let spans: Vec<_> =
|
||||
ids.iter().filter_map(|id| e.session().find_clip(soundcraft_model::ClipId(*id)).map(|(_, c)| (c.start, c.length))).collect();
|
||||
// The group lands at the insertion point; its clips keep their relative positions and overlap.
|
||||
let at = spans.first().map_or(0, |s| s.0);
|
||||
assert_eq!(spans, vec![(at, 1000), (at + 600, 1000)]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fades_sync_point_and_envelope_follow_the_sample_rate() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-rate-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let p = dir.join("rate.scgrp");
|
||||
let mut e = crate::demo::demo_engine();
|
||||
// The file was saved at half the session rate, so every position doubles.
|
||||
let src_rate = e.session().sample_rate.hz() / 2;
|
||||
let fade = |len: i64| json!({"len": len, "shape": "Linear"});
|
||||
let clip = json!({
|
||||
"id": 1, "name": "m", "start": 0, "length": 10_000, "content": {"Midi": {"sequence": {"notes": [], "ctrls": []}}},
|
||||
"fade_in": fade(1000), "fade_out": fade(2000), "sync_point": 500, "gain_env": [[100, -6.0], [4000, 0.0]]
|
||||
});
|
||||
let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": src_rate, "length": 10_000, "lanes": [[clip]], "audio": {}});
|
||||
std::fs::write(&p, file.to_string()).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap();
|
||||
let c = e.session().find_clip(soundcraft_model::ClipId(r["clips"][0].as_u64().unwrap())).unwrap().1;
|
||||
assert_eq!((c.length, c.fade_in.len, c.fade_out.len, c.sync_point), (20_000, 2000, 4000, 1000));
|
||||
assert_eq!(c.gain_env.iter().map(|&(at, _)| at).collect::<Vec<_>>(), vec![200, 8000]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_named_by_many_ids_becomes_one_source() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-shared-{}", std::process::id()));
|
||||
|
||||
@@ -117,8 +117,6 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
nc.name = new_name;
|
||||
nc.content = ClipContent::Audio { source: src, offset: 0 };
|
||||
nc.length = i64::try_from(frames).unwrap_or(nc.length);
|
||||
// The render already has the clip's stretch applied; keeping it would stretch the new file again.
|
||||
nc.stretch = 1.0;
|
||||
// Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain,
|
||||
// the same way its fades stay editable. Other processes bake a new file at unity gain.
|
||||
if !matches!(name.as_str(), "invert" | "duplicate") {
|
||||
@@ -218,21 +216,6 @@ mod tests {
|
||||
assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processed_clip_does_not_keep_the_stretch_it_baked() {
|
||||
for stretch in [0.5, 2.0] {
|
||||
for process in ["invert", "duplicate"] {
|
||||
let (mut e, id) = gained_clip();
|
||||
e.session_mut().find_clip_mut(id).unwrap().stretch = stretch;
|
||||
e.execute("audiosuite.process", &json!({"process": process, "clips": [id.0]})).unwrap();
|
||||
let c = e.session().tracks[0].clips()[0].clone();
|
||||
let frames = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0].len();
|
||||
assert_eq!(c.stretch, 1.0, "{process} at {stretch}");
|
||||
assert_eq!(c.length, i64::try_from(frames).unwrap(), "{process} at {stretch}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_generator_fills_the_selection_with_a_clip() {
|
||||
let (mut e, _) = gained_clip();
|
||||
|
||||
@@ -102,7 +102,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("clip.remove_sync_point", "Remove Sync Point", ["Clip"], None, "{clips?}", has_sync_point, remove_sync_point),
|
||||
cmd!("clip.quantize_to_grid", "Quantize to Grid", ["Clip"], Some("Cmd+0"), "{clips?}", has_selection, quantize_to_grid),
|
||||
cmd!("clip.color", "Clip Color", [], None, "{clips?, color: [r,g,b] | null}", has_selection, |e, p| {
|
||||
let ids = clip_ids_param(e, p);
|
||||
@@ -349,31 +348,6 @@ fn nudge_gain(e: &mut Engine, p: &Value, db: f64) -> Result<Value> {
|
||||
gain(e, &json!({"clips": clips, "delta_db": db}))
|
||||
}
|
||||
|
||||
/// Sync point 0 is "none" (edit_window only draws one when > 0).
|
||||
fn has_sync_point(e: &Engine) -> std::result::Result<(), String> {
|
||||
let s = e.session();
|
||||
if clip_ids_param(e, &json!({})).iter().any(|id| s.find_clip(*id).is_some_and(|(_, c)| c.sync_point != 0)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("no selected clip has a sync point".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_sync_point(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session();
|
||||
// Only clips that have a point are touched, so a call that changes nothing leaves no undo step.
|
||||
let ids: Vec<ClipId> = clip_ids_param(e, p).into_iter().filter(|id| s.find_clip(*id).is_some_and(|(_, c)| c.sync_point != 0)).collect();
|
||||
if !ids.is_empty() {
|
||||
let s = e.session_mut();
|
||||
for id in &ids {
|
||||
if let Some(c) = s.find_clip_mut(*id) {
|
||||
c.sync_point = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"clips": ids.len()}))
|
||||
}
|
||||
|
||||
fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session();
|
||||
@@ -478,72 +452,4 @@ mod tests {
|
||||
e.execute("clip.gain_nudge_up", &json!({})).unwrap();
|
||||
assert_eq!(gain(&e), 36.0);
|
||||
}
|
||||
|
||||
/// Three 10 000-sample clips side by side on one track; sync points are set by each test.
|
||||
fn three_clips() -> (crate::Engine, [ClipId; 3]) {
|
||||
use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind};
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Dialog"));
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.1; 10_000]] };
|
||||
let src = crate::io::add_source(&mut s, "Line", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let ids = [0, 1, 2].map(|k| {
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(&mut s, t, Clip::audio(cid, "Line", src, 0, k * 12_000, 10_000));
|
||||
cid
|
||||
});
|
||||
(crate::Engine::new(s), ids)
|
||||
}
|
||||
|
||||
fn sync_of(e: &crate::Engine, id: ClipId) -> i64 {
|
||||
e.session().find_clip(id).unwrap().1.sync_point
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_sync_point_clears_the_point_identify_set() {
|
||||
let (mut e, [a, ..]) = three_clips();
|
||||
e.execute("edit.select", &json!({"clips": [a.0]})).unwrap();
|
||||
e.execute("clip.identify_sync_point", &json!({"at": 4_000})).unwrap();
|
||||
assert_eq!(sync_of(&e, a), 4_000);
|
||||
e.execute("clip.remove_sync_point", &json!({})).unwrap();
|
||||
assert_eq!(sync_of(&e, a), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_sync_point_clears_every_selected_clip_and_only_those() {
|
||||
let (mut e, [a, b, c]) = three_clips();
|
||||
for (id, at) in [(a, 1_000), (b, 2_000), (c, 3_000)] {
|
||||
e.session_mut().find_clip_mut(id).unwrap().sync_point = at;
|
||||
}
|
||||
e.execute("edit.select", &json!({"clips": [a.0, b.0]})).unwrap();
|
||||
let r = e.execute("clip.remove_sync_point", &json!({})).unwrap();
|
||||
assert_eq!(r["clips"], 2);
|
||||
assert_eq!([sync_of(&e, a), sync_of(&e, b), sync_of(&e, c)], [0, 0, 3_000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_sync_point_is_disabled_unless_a_selected_clip_has_one() {
|
||||
let (mut e, [a, b, c]) = three_clips();
|
||||
e.session_mut().find_clip_mut(c).unwrap().sync_point = 3_000;
|
||||
let enabled = |e: &crate::Engine| crate::find_command("clip.remove_sync_point").unwrap().info(e).enabled;
|
||||
e.execute("edit.select", &json!({"clips": [a.0, b.0]})).unwrap();
|
||||
assert!(!enabled(&e), "neither selected clip has a sync point (the unselected one does)");
|
||||
e.execute("edit.select", &json!({"clips": [b.0, c.0]})).unwrap();
|
||||
assert!(enabled(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_sync_point_is_one_undo_step_that_puts_the_points_back() {
|
||||
let (mut e, [a, b, _]) = three_clips();
|
||||
for (id, at) in [(a, 1_000), (b, 2_000)] {
|
||||
e.session_mut().find_clip_mut(id).unwrap().sync_point = at;
|
||||
}
|
||||
e.execute("edit.select", &json!({"clips": [a.0, b.0]})).unwrap();
|
||||
let steps = e.undo_history().len();
|
||||
e.execute("clip.remove_sync_point", &json!({})).unwrap();
|
||||
assert_eq!(e.undo_history().len(), steps + 1);
|
||||
assert_eq!(e.undo_label(), Some("Remove Sync Point"));
|
||||
assert_eq!([sync_of(&e, a), sync_of(&e, b)], [0, 0]);
|
||||
assert!(e.undo());
|
||||
assert_eq!([sync_of(&e, a), sync_of(&e, b)], [1_000, 2_000]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
add_marker
|
||||
),
|
||||
cmd!("markers.delete", "Delete Memory Location", [], None, "{number | id}", always, delete_marker),
|
||||
cmd!("markers.edit", "Edit Memory Location", [], None, "{number | id, name?, comments?, at?, color?: [r,g,b] | null}", always, edit_marker),
|
||||
cmd!("markers.edit", "Edit Memory Location", [], None, "{number | id, name?, comments?, at?, color?}", always, edit_marker),
|
||||
cmd!(noundo "markers.recall", "Recall Memory Location", [], Some("Period + n + Period"), "{number}", always, recall_marker),
|
||||
cmd!(noundo "markers.next", "Go to Next Marker", [], None, "{}", always, |e, _| step_marker(e, 1)),
|
||||
cmd!(noundo "markers.previous", "Go to Previous Marker", [], None, "{}", always, |e, _| step_marker(e, -1)),
|
||||
@@ -300,7 +300,6 @@ fn add_marker(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let (a, b) = match (at, st, en) {
|
||||
(Some(a), _, _) => (a, a),
|
||||
(None, Some(a), Some(b)) => (a, b),
|
||||
(None, Some(p), None) | (None, None, Some(p)) => (p, p),
|
||||
_ if kind == MarkerKind::Selection => (sel.start, sel.end),
|
||||
_ => (if e.transport.playing { e.transport.position } else { sel.start }, sel.start),
|
||||
};
|
||||
@@ -338,23 +337,8 @@ fn delete_marker(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn edit_marker(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let i = marker_index(e, p, "markers.edit")?;
|
||||
let at = position_param(e, "markers.edit", p, "at")?;
|
||||
let color = match p.get("color") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::Array(a)) if a.len() == 3 => {
|
||||
let ch = |i: usize| a.get(i).and_then(Value::as_u64).and_then(|v| u8::try_from(v).ok());
|
||||
match (ch(0), ch(1), ch(2)) {
|
||||
(Some(r), Some(g), Some(b)) => Some(Some([r, g, b])),
|
||||
_ => return Err(bad("markers.edit", "`color` components must be integers 0..=255")),
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(bad("markers.edit", "`color` must be [r,g,b] or null")),
|
||||
};
|
||||
let s = e.session_mut();
|
||||
let Some(m) = s.markers.get_mut(i) else { return Err(bad("markers.edit", "gone")) };
|
||||
if let Some(c) = color {
|
||||
m.color = c;
|
||||
}
|
||||
// `name` is the documented new name; when it was the selector it already matches, so this is a no-op.
|
||||
if let Some(n) = str_param(p, "rename").or_else(|| str_param(p, "name")) {
|
||||
m.name = n.to_string();
|
||||
@@ -459,44 +443,6 @@ mod tests {
|
||||
assert_eq!((bridge.name.as_str(), bridge.comments.as_str()), ("Bridge", "last"));
|
||||
}
|
||||
|
||||
/// `markers.add` with only `start` or only `end` places the marker there, not at the edit selection.
|
||||
#[test]
|
||||
fn markers_add_start_or_end_alone_is_used() {
|
||||
let mut e = demo_engine();
|
||||
assert_ne!(e.session().edit.selection.start, 96_000);
|
||||
let a = e.execute("markers.add", &json!({"name": "A", "start": 96_000})).unwrap();
|
||||
let b = e.execute("markers.add", &json!({"name": "B", "end": 96_000})).unwrap();
|
||||
let c = e.execute("markers.add", &json!({"name": "C", "start": 1000, "end": 2000})).unwrap();
|
||||
let find = |r: &serde_json::Value| e.session().markers.iter().find(|m| Some(m.id.0) == r["id"].as_u64()).map(|m| (m.start, m.end));
|
||||
assert_eq!(find(&a), Some((96_000, 96_000)));
|
||||
assert_eq!(find(&b), Some((96_000, 96_000)));
|
||||
assert_eq!(find(&c), Some((1000, 2000)));
|
||||
}
|
||||
|
||||
/// `markers.edit` documents `color`: it sets the memory location's RGB colour, undoably; `null` clears it.
|
||||
#[test]
|
||||
fn edit_marker_sets_documented_color() {
|
||||
let mut e = demo_engine();
|
||||
let n = e.session().markers.first().unwrap().number;
|
||||
let color = |e: &crate::Engine| e.session().markers.iter().find(|m| m.number == n).unwrap().color;
|
||||
assert_eq!(color(&e), None);
|
||||
|
||||
e.execute("markers.edit", &json!({"number": n, "color": [255, 0, 0]})).unwrap();
|
||||
assert_eq!(color(&e), Some([255, 0, 0]));
|
||||
assert!(e.undo());
|
||||
assert_eq!(color(&e), None);
|
||||
assert!(e.redo());
|
||||
assert_eq!(color(&e), Some([255, 0, 0]));
|
||||
|
||||
e.execute("markers.edit", &json!({"number": n, "color": null})).unwrap();
|
||||
assert_eq!(color(&e), None);
|
||||
|
||||
for bad in [json!([1, 2]), json!([1, 2, 300]), json!("red"), json!([1, 2, -3])] {
|
||||
assert!(e.execute("markers.edit", &json!({"number": n, "color": bad})).is_err());
|
||||
assert_eq!(color(&e), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Constant replaces the tempo over [start, end) only; the tempo at the old end tick carries on after it.
|
||||
#[test]
|
||||
fn tempo_constant_is_bounded_by_its_range() {
|
||||
|
||||
@@ -414,9 +414,7 @@ fn step_input(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let end = s.tempo.tick_to_samples(end_tick, sr);
|
||||
// Continue the clip under (or ending at) the insertion point, else start a new one.
|
||||
let host = s.track(t).and_then(|tr| {
|
||||
let mut midi = tr.clips().iter().filter(|c| matches!(c.content, ClipContent::Midi { .. }));
|
||||
// A clip starting at `at` wins over one that merely ends there.
|
||||
midi.clone().find(|c| c.range().contains(at)).or_else(|| midi.find(|c| c.end() == at)).map(|c| c.id)
|
||||
tr.clips().iter().filter(|c| matches!(c.content, ClipContent::Midi { .. })).find(|c| c.range().contains(at) || c.end() == at).map(|c| c.id)
|
||||
});
|
||||
let host = match host {
|
||||
Some(h) => h,
|
||||
@@ -578,8 +576,8 @@ fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
|
||||
let end = s.tempo.samples_to_ticks(c.end(), s.sample_rate);
|
||||
for n in &sequence.notes {
|
||||
let a = base.saturating_add(n.start);
|
||||
let b = base.saturating_add(n.end()).min(end);
|
||||
let a = base + n.start;
|
||||
let b = (base + n.end()).min(end);
|
||||
if b > a {
|
||||
notes.push((a, b, n.pitch));
|
||||
}
|
||||
@@ -732,16 +730,13 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let t = midi_target(e, p, id)?;
|
||||
let s = e.session();
|
||||
let sr = s.sample_rate;
|
||||
// Latest start tick whose longest note still ends within MAX_POSITION.
|
||||
let max_tick = s.tempo.samples_to_ticks(MAX_POSITION, sr).saturating_sub(TICKS_PER_QUARTER * 1024).max(0);
|
||||
let mut raw: Vec<(i64, Note)> = Vec::new();
|
||||
for v in list.iter().take(100_000) {
|
||||
let tick = match (v.get("start_ticks").and_then(Value::as_i64), v.get("start")) {
|
||||
(Some(tk), _) => tk.max(0),
|
||||
(None, Some(_)) => s.tempo.samples_to_ticks(position_param(e, id, v, "start")?.unwrap_or(0).max(0), sr),
|
||||
_ => return Err(bad(id, "each note needs `start` or `start_ticks`")),
|
||||
}
|
||||
.min(max_tick);
|
||||
};
|
||||
let note = Note {
|
||||
pitch: i64_or(v, "pitch", 60).clamp(0, 127) as u8,
|
||||
velocity: i64_or(v, "velocity", 100).clamp(1, 127) as u8,
|
||||
@@ -755,7 +750,7 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let first = raw.iter().map(|x| x.0).min().unwrap_or(0);
|
||||
// Start the clip on the bar line so the quantize grid lines up with the music.
|
||||
let clip_tick = s.tempo.bar_start_tick(s.tempo.bar_beat_at_tick(first).bar).min(first).max(0);
|
||||
let end_tick = raw.iter().map(|(tk, n)| tk.saturating_add(n.length)).max().unwrap_or_else(|| first.saturating_add(1));
|
||||
let end_tick = raw.iter().map(|(tk, n)| tk.saturating_add(n.length)).max().unwrap_or(first + 1);
|
||||
let mut seq = soundcraft_midi::Sequence::default();
|
||||
for (tk, mut n) in raw {
|
||||
n.start = tk - clip_tick;
|
||||
@@ -769,7 +764,7 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
seq.sort();
|
||||
}
|
||||
let start = s.tempo.tick_to_samples(clip_tick, sr);
|
||||
let end = s.tempo.tick_to_samples(end_tick, sr).min(MAX_POSITION);
|
||||
let end = s.tempo.tick_to_samples(end_tick, sr);
|
||||
let n = seq.notes.len();
|
||||
let s = e.session_mut();
|
||||
let cid = s.new_clip_id();
|
||||
@@ -867,25 +862,6 @@ mod tests {
|
||||
assert_eq!(e.session().edit.selection.start, 3 * 24_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_input_at_clip_boundary_uses_next_clip() {
|
||||
let (mut e, t) = midi_engine();
|
||||
let sr = 48_000;
|
||||
e.execute("midi.new_clip", &json!({"track": t.0, "start": 0, "end": sr})).unwrap();
|
||||
let second = e.execute("midi.new_clip", &json!({"track": t.0, "start": sr, "end": 2 * sr})).unwrap()["clip"].as_u64().unwrap();
|
||||
e.execute("edit.select", &json!({"tracks": [t.0], "start": sr})).unwrap();
|
||||
let r = e.execute("event.step_input", &json!({"track": t.0, "pitch": 60, "duration": "1/4"})).unwrap();
|
||||
assert_eq!(r["clip"].as_u64(), Some(second));
|
||||
let mut ranges: Vec<(i64, i64)> = e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect();
|
||||
ranges.sort();
|
||||
assert_eq!(ranges, vec![(0, sr), (sr, 2 * sr)]);
|
||||
let c = e.session().find_clip(soundcraft_model::ClipId(second)).map(|(_, c)| c.clone()).unwrap();
|
||||
match &c.content {
|
||||
ClipContent::Midi { sequence } => assert_eq!(sequence.notes.iter().map(|n| n.start).collect::<Vec<_>>(), vec![0]),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tempo_curves() {
|
||||
let mut e = Engine::default();
|
||||
@@ -967,29 +943,6 @@ mod tests {
|
||||
assert!(r["bpm"].as_f64().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_chords_survives_huge_note_start() {
|
||||
let (mut e, t) = midi_engine();
|
||||
let r = e.execute("midi.new_clip", &json!({"track": t.0, "start": 48_000, "end": 96_000})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": i64::MAX, "length_ticks": 480})).unwrap();
|
||||
e.execute("edit.select", &json!({"tracks": [t.0], "start": 0, "end": 96_000})).unwrap();
|
||||
let r = e.execute("event.extract_chords", &json!({}));
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retro_record_huge_start_ticks_stay_in_range() {
|
||||
for tk in [i64::MAX, 1_000_000_000_000_000] {
|
||||
let (mut e, t) = midi_engine();
|
||||
let notes = json!([{"pitch": 60, "start_ticks": tk, "length_ticks": TICKS_PER_QUARTER * 1024}]);
|
||||
e.execute("event.retrospective_record", &json!({"track": t.0, "notes": notes})).unwrap();
|
||||
let c = e.session().track(t).unwrap().clips()[0].clone();
|
||||
assert!(c.start >= 0 && c.length >= 1, "{tk}: {} {}", c.start, c.length);
|
||||
assert!(c.start.saturating_add(c.length) <= MAX_POSITION, "{tk}: start {} len {}", c.start, c.length);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_song_start_moves_tick_material() {
|
||||
let (mut e, t) = midi_engine();
|
||||
|
||||
@@ -126,8 +126,8 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let seq = seq_mut(e, id, "midi.notes_move")?;
|
||||
for (i, n) in seq.notes.iter_mut().enumerate() {
|
||||
if idx.contains(&i) {
|
||||
n.start = n.start.saturating_add(dt).max(0);
|
||||
n.pitch = i64::from(n.pitch).saturating_add(ds).clamp(0, 127) as u8;
|
||||
n.start = (n.start + dt).max(0);
|
||||
n.pitch = (i64::from(n.pitch) + ds).clamp(0, 127) as u8;
|
||||
}
|
||||
}
|
||||
seq.sort();
|
||||
@@ -157,21 +157,6 @@ mod tests {
|
||||
assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_move_huge_params_do_not_overflow() {
|
||||
let mut e = Engine::default();
|
||||
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
|
||||
let r = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 480})).unwrap();
|
||||
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "ticks": i64::MAX})).unwrap();
|
||||
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
|
||||
assert_eq!(n["notes"][0]["start"], i64::MAX);
|
||||
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "semitones": i64::MAX})).unwrap();
|
||||
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
|
||||
assert_eq!(n["notes"][0]["pitch"], 127);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_audio_tracks() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
+14
-151
@@ -73,8 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
)),
|
||||
cmd!("mix.trim", "Input Trim", [], None, "{tracks?, db}", has_selection, |e, p| {
|
||||
let tracks = tracks_required(e, "mix.trim", p)?;
|
||||
let db = (p.get("db").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("mix.trim", "`db` (number) required"))? as f32)
|
||||
.clamp(-144.0, 24.0);
|
||||
let db = f32_or(p, "db", 0.0).clamp(-144.0, 24.0);
|
||||
let s = e.session_mut();
|
||||
for t in tracks {
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
@@ -182,24 +181,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// `t` plus the members of its active mix groups that share `attr`.
|
||||
fn grouped_ids(s: &Session, t: TrackId, attr: &str) -> Vec<TrackId> {
|
||||
let mut ids = vec![t];
|
||||
/// Apply a change to `t` and its active mix-group siblings.
|
||||
fn set_grouped(s: &mut Session, t: TrackId, attr: &str, f: impl Fn(&mut soundcraft_model::Track)) {
|
||||
let mut targets = vec![t];
|
||||
for g in &s.groups {
|
||||
if g.active && g.mix && g.members.contains(&t) && g.attributes.iter().any(|a| a == attr) {
|
||||
for m in &g.members {
|
||||
if !ids.contains(m) {
|
||||
ids.push(*m);
|
||||
if !targets.contains(m) {
|
||||
targets.push(*m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// Apply a change to `t` and its active mix-group siblings.
|
||||
fn set_grouped(s: &mut Session, t: TrackId, attr: &str, f: impl Fn(&mut soundcraft_model::Track)) {
|
||||
for id in grouped_ids(s, t, attr) {
|
||||
for id in targets {
|
||||
if let Some(tr) = s.track_mut(id) {
|
||||
f(tr);
|
||||
}
|
||||
@@ -224,13 +218,7 @@ fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
return Err(bad("mix.volume", "`db` or `delta_db` required"));
|
||||
}
|
||||
let s = e.session_mut();
|
||||
// Tracks already moved as a group sibling of an earlier target must not move again.
|
||||
let mut done: Vec<TrackId> = Vec::new();
|
||||
for t in &tracks {
|
||||
if done.contains(t) {
|
||||
continue;
|
||||
}
|
||||
done.extend(grouped_ids(s, *t, "volume"));
|
||||
let cur = s.track(*t).map_or(0.0, |tr| tr.mixer.volume_db);
|
||||
let new = match (abs, delta) {
|
||||
(Some(a), _) => a as f32,
|
||||
@@ -246,8 +234,7 @@ fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
|
||||
fn pan(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "mix.pan", p)?;
|
||||
let v = (p.get("pan").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("mix.pan", "`pan` (number) required"))? as f32)
|
||||
.clamp(-1.0, 1.0);
|
||||
let v = f32_or(p, "pan", 0.0).clamp(-1.0, 1.0);
|
||||
let idx = i64_or(p, "index", 0).clamp(0, 1) as usize;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
@@ -300,30 +287,17 @@ fn slot_of(p: &Value, key: &str, max: usize) -> Option<usize> {
|
||||
(i < max).then_some(i)
|
||||
}
|
||||
|
||||
fn slot_error(cmd: &str, max: usize) -> EngineError {
|
||||
bad(cmd, format!("`slot` must be 0..{} or a..{}", max - 1, (b'a' + max as u8 - 1) as char))
|
||||
}
|
||||
|
||||
fn track_slot(e: &Engine, p: &Value, cmd: &str, max: usize) -> Result<(TrackId, usize)> {
|
||||
let t = tracks_required(e, cmd, p)?.first().copied().ok_or_else(|| bad(cmd, "no track"))?;
|
||||
let slot = slot_of(p, "slot", max).ok_or_else(|| slot_error(cmd, max))?;
|
||||
let slot = slot_of(p, "slot", max).ok_or_else(|| bad(cmd, format!("`slot` must be 0..{} or a..{}", max - 1, (b'a' + max as u8 - 1) as char)))?;
|
||||
Ok((t, slot))
|
||||
}
|
||||
|
||||
/// An optional `slot`: absent (or null) means "first free"; a present but invalid value is
|
||||
/// an error, never silently treated as absent.
|
||||
fn optional_slot(p: &Value, cmd: &str, max: usize) -> Result<Option<usize>> {
|
||||
match p.get("slot") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(_) => slot_of(p, "slot", max).map(Some).ok_or_else(|| slot_error(cmd, max)),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let plugin = str_param(p, "plugin").ok_or_else(|| bad("mix.insert", "`plugin` required"))?.to_string();
|
||||
let info = super::clap::plugin_info(&plugin).ok_or_else(|| bad("mix.insert", format!("unknown plugin `{plugin}`")))?;
|
||||
let t = tracks_required(e, "mix.insert", p)?.first().copied().ok_or_else(|| bad("mix.insert", "no track"))?;
|
||||
let explicit = optional_slot(p, "mix.insert", INSERT_SLOTS)?;
|
||||
let explicit = slot_of(p, "slot", INSERT_SLOTS);
|
||||
let s = e.session_mut();
|
||||
let tr = s.track_mut(t).ok_or_else(|| bad("mix.insert", "no track"))?;
|
||||
let slot =
|
||||
@@ -442,9 +416,9 @@ fn insert_move(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn send(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let bus = str_param(p, "bus").ok_or_else(|| bad("mix.send", "`bus` required"))?.to_string();
|
||||
let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?;
|
||||
let explicit = optional_slot(p, "mix.send", SEND_SLOTS)?;
|
||||
let explicit = slot_of(p, "slot", SEND_SLOTS);
|
||||
let s = e.session_mut();
|
||||
let bid = s.bus_by_name(&bus).map(|b| b.id).ok_or_else(|| bad("mix.send", format!("no bus named \"{bus}\"")))?;
|
||||
let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo);
|
||||
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.
|
||||
@@ -539,7 +513,7 @@ pub(crate) fn current_value(tr: &soundcraft_model::Track, param: &AutoParam) ->
|
||||
|
||||
fn clear_lane(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "automation.clear", p)?;
|
||||
let param = if str_param(p, "param").is_some() { Some(lane_param(p, "automation.clear")?) } else { None };
|
||||
let param = str_param(p, "param").and_then(AutoParam::parse);
|
||||
let r = range_param(e, "automation.clear", p)?;
|
||||
let (a, b) = if r.is_empty() { (i64::MIN, i64::MAX) } else { (r.start, r.end) };
|
||||
let s = e.session_mut();
|
||||
@@ -720,13 +694,7 @@ fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
|
||||
fn copy_to_send(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "automation.copy_to_send", p)?;
|
||||
// No `send` means Send A (the menu item passes none); a given one must be valid.
|
||||
let slot = match p.get("send") {
|
||||
None | Some(Value::Null) => 0,
|
||||
Some(_) => slot_of(p, "send", SEND_SLOTS).ok_or_else(|| {
|
||||
bad("automation.copy_to_send", format!("`send` must be 0..{} or a..{}", SEND_SLOTS - 1, (b'a' + SEND_SLOTS as u8 - 1) as char))
|
||||
})?,
|
||||
} as u8;
|
||||
let slot = slot_of(p, "send", SEND_SLOTS).unwrap_or(0) as u8;
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
@@ -906,121 +874,16 @@ mod tests {
|
||||
assert_eq!(e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().preset, "Saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_to_send_rejects_an_invalid_send_slot() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let history = e.undo_history().len();
|
||||
let lanes = |e: &Engine| e.session().tracks.iter().filter(|t| t.lane(&AutoParam::SendLevel(0)).is_some_and(|l| !l.points.is_empty())).count();
|
||||
let before = lanes(&e);
|
||||
for bad_params in [json!({"track": "Kick", "send": 99}), json!({"track": "Kick", "send": "zz"})] {
|
||||
let r = e.execute("automation.copy_to_send", &bad_params);
|
||||
assert!(r.is_err(), "{bad_params} accepted: {r:?}");
|
||||
assert_eq!(e.undo_history().len(), history, "{bad_params} pushed an undo step");
|
||||
assert_eq!(lanes(&e), before, "{bad_params} edited Send A");
|
||||
}
|
||||
// No `send` (the menu item's call) still means Send A.
|
||||
e.execute("automation.copy_to_send", &json!({"track": "Kick"})).unwrap();
|
||||
assert_eq!(lanes(&e), before + 1);
|
||||
// A valid slot still copies.
|
||||
e.execute("automation.copy_to_send", &json!({"track": "Kick", "send": "a"})).unwrap();
|
||||
assert_eq!(lanes(&e), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retargeting_a_send_keeps_its_settings() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
|
||||
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
|
||||
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap();
|
||||
e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap();
|
||||
e.execute("mix.new_bus", &json!({"name": "Delay"})).unwrap();
|
||||
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap();
|
||||
let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap();
|
||||
let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id;
|
||||
assert_eq!(sl.target, soundcraft_model::Route::Bus(delay));
|
||||
assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true));
|
||||
}
|
||||
|
||||
fn grouped_demo() -> Engine {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
e.execute("mix.volume", &json!({"tracks": [3], "db": 0.0})).unwrap();
|
||||
e.execute("mix.volume", &json!({"tracks": [6], "db": 0.0})).unwrap();
|
||||
e.execute("track.group", &json!({"tracks": [3, 6]})).unwrap();
|
||||
e
|
||||
}
|
||||
|
||||
fn volumes(e: &Engine) -> Vec<f32> {
|
||||
[3, 6].iter().map(|id| e.session().track(TrackId(*id)).unwrap().mixer.volume_db).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_delta_on_a_whole_group_applies_once() {
|
||||
let mut e = grouped_demo();
|
||||
e.execute("mix.volume", &json!({"tracks": [3, 6], "delta_db": -3.0})).unwrap();
|
||||
assert_eq!(volumes(&e), [-3.0, -3.0]);
|
||||
|
||||
let mut e = grouped_demo();
|
||||
e.execute("mix.volume", &json!({"tracks": [3], "delta_db": -3.0})).unwrap();
|
||||
assert_eq!(volumes(&e), [-3.0, -3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_absolute_on_a_whole_group_keeps_offsets() {
|
||||
let mut e = grouped_demo();
|
||||
e.session_mut().track_mut(TrackId(6)).unwrap().mixer.volume_db = -2.0;
|
||||
e.execute("mix.volume", &json!({"tracks": [3, 6], "db": -10.0})).unwrap();
|
||||
assert_eq!(volumes(&e), [-10.0, -12.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_with_unknown_param_keeps_lanes() {
|
||||
let (mut e, t) = tone_session(&[(0, -6.0), (100, -3.0)]);
|
||||
e.session_mut().track_mut(t).unwrap().lane_mut(&AutoParam::Pan(0)).set_point(0, 0.5);
|
||||
e.session_mut().edit.selection = Range::new(0, 0);
|
||||
let count = |e: &Engine| e.session().track(t).unwrap().automation.iter().map(|l| l.points.len()).sum::<usize>();
|
||||
let r = e.execute("automation.clear", &json!({"track": t.0, "param": "volum"}));
|
||||
assert!(r.is_err(), "unknown param succeeded: {r:?}");
|
||||
assert_eq!(count(&e), 3, "unknown param changed the lanes");
|
||||
e.execute("automation.clear", &json!({"track": t.0, "param": "volume"})).unwrap();
|
||||
assert_eq!(count(&e), 1, "named param cleared the wrong lanes");
|
||||
e.execute("automation.clear", &json!({"track": t.0})).unwrap();
|
||||
assert_eq!(count(&e), 0, "absent param must still clear every lane");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_rejects_unknown_bus() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
|
||||
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
|
||||
let err = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Nowhere"})).unwrap_err();
|
||||
assert!(err.to_string().contains("no bus named \"Nowhere\""), "{err}");
|
||||
assert_eq!(e.session().busses.len(), 1);
|
||||
assert!(e.session().tracks[0].mixer.sends.iter().all(Option::is_none));
|
||||
e.execute("mix.send", &json!({"track": "Gtr", "bus": "verb"})).unwrap();
|
||||
assert_eq!(e.session().busses.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_send_reject_invalid_slot() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
|
||||
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
|
||||
for slot in [json!(12), json!("k"), json!("x1")] {
|
||||
let r = e.execute("mix.insert", &json!({"track": "Gtr", "slot": slot, "plugin": "compressor"}));
|
||||
assert!(r.is_err(), "insert slot {slot} accepted: {r:?}");
|
||||
}
|
||||
for slot in [json!(12), json!("k")] {
|
||||
let r = e.execute("mix.send", &json!({"track": "Gtr", "slot": slot, "bus": "Verb"}));
|
||||
assert!(r.is_err(), "send slot {slot} accepted: {r:?}");
|
||||
}
|
||||
let mixer = &e.session().tracks[0].mixer;
|
||||
assert!(mixer.inserts.iter().all(Option::is_none));
|
||||
assert!(mixer.sends.iter().all(Option::is_none));
|
||||
|
||||
// Leaving `slot` out still picks the first free slot.
|
||||
let r = e.execute("mix.insert", &json!({"track": "Gtr", "plugin": "compressor"})).unwrap();
|
||||
assert_eq!(r["slot"], 0);
|
||||
let r = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Verb"})).unwrap();
|
||||
assert_eq!(r["slot"], 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, SourceId, TrackId, TrackKind};
|
||||
use soundcraft_time::Timecode;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
macro_rules! option {
|
||||
($id:literal, $label:literal, $flag:literal) => {
|
||||
@@ -407,6 +407,7 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(id, "`path` required"))?.to_string();
|
||||
let text_v = crate::io::read_text(&path)?;
|
||||
let other = Session::from_json(&text_v).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let dir = Path::new(&path).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let names: Option<Vec<String>> =
|
||||
p.get("tracks").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
let picked: Vec<soundcraft_model::Track> = match &names {
|
||||
@@ -434,27 +435,20 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let mut map: std::collections::BTreeMap<u64, SourceId> = Default::default();
|
||||
let mut missing = Vec::new();
|
||||
for src in needed.iter().filter_map(|sid| other.source(*sid)) {
|
||||
let abs = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
|
||||
let mut ns = src.clone();
|
||||
ns.id = SourceId(s.alloc());
|
||||
match crate::io::stored_path(&src.path, Path::new(&path)) {
|
||||
Ok(abs) => {
|
||||
ns.path = abs.to_string_lossy().into_owned();
|
||||
match crate::io::decode_file(&mut files, &abs, sr) {
|
||||
Ok(audio) => {
|
||||
ns.frames = audio.buffer.frames() as u64;
|
||||
ns.sample_rate = sr;
|
||||
ns.unsaved = true;
|
||||
s.pool.insert(ns.id, audio);
|
||||
}
|
||||
Err(_) => {
|
||||
ns.unsaved = false;
|
||||
missing.push(ns.path.clone());
|
||||
}
|
||||
}
|
||||
ns.path = abs.to_string_lossy().into_owned();
|
||||
match crate::io::decode_file(&mut files, &abs, sr) {
|
||||
Ok(audio) => {
|
||||
ns.frames = audio.buffer.frames() as u64;
|
||||
ns.sample_rate = sr;
|
||||
ns.unsaved = true;
|
||||
s.pool.insert(ns.id, audio);
|
||||
}
|
||||
Err(why) => {
|
||||
Err(_) => {
|
||||
ns.unsaved = false;
|
||||
missing.push(format!("{}: {why}", ns.path));
|
||||
missing.push(ns.path.clone());
|
||||
}
|
||||
}
|
||||
map.insert(src.id.0, ns.id);
|
||||
|
||||
@@ -184,7 +184,6 @@ 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();
|
||||
|
||||
@@ -19,7 +19,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("track.group", "Group...", ["Track"], Some("Cmd+G"), "{name?, tracks?, edit?: true, mix?: true}", has_selection, group),
|
||||
cmd!("track.ungroup", "Delete Group", [], None, "{group: id|name}", always, delete_group),
|
||||
cmd!("track.group_toggle", "Toggle Group Active", [], None, "{group: id|name}", always, toggle_group),
|
||||
cmd!("track.group_modify", "Modify Group", [], None, "{group: id|name, name?, tracks?, edit?, mix?}", always, modify_group),
|
||||
cmd!(
|
||||
"track.duplicate",
|
||||
"Duplicate...",
|
||||
@@ -321,55 +320,6 @@ fn toggle_group(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"active": active}))
|
||||
}
|
||||
|
||||
/// Change an existing group's name, membership and Edit/Mix linking. Omitted fields keep their value.
|
||||
fn modify_group(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "track.group_modify";
|
||||
let g = find_group(e, p, CMD)?;
|
||||
// Absent `tracks` leaves membership alone; present, it replaces it. Only an array counts, so a
|
||||
// mistyped param can't silently swap the members for whatever happens to be selected.
|
||||
let members = match p.get("tracks") {
|
||||
None => None,
|
||||
Some(Value::Array(_)) => {
|
||||
let mut t = tracks_param(e, CMD, p)?;
|
||||
// A track listed twice would take every linked move twice over.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
t.retain(|id| seen.insert(*id));
|
||||
if t.is_empty() {
|
||||
return Err(bad(CMD, "a group needs at least one track"));
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
Some(_) => return Err(bad(CMD, "`tracks` must be an array of track ids or names")),
|
||||
};
|
||||
let name = match str_param(p, "name") {
|
||||
None => None,
|
||||
Some(n) => {
|
||||
let n = n.trim();
|
||||
if n.is_empty() {
|
||||
return Err(bad(CMD, "`name` must not be empty"));
|
||||
}
|
||||
// `find_group` resolves by name, so duplicates would make a group unaddressable.
|
||||
if e.session().groups.iter().any(|x| x.name == n && x.id != g) {
|
||||
return Err(bad(CMD, format!("a group named `{n}` already exists")));
|
||||
}
|
||||
let mut n = n.to_string();
|
||||
soundcraft_model::truncate_chars(&mut n, soundcraft_model::MAX_NAME_CHARS);
|
||||
Some(n)
|
||||
}
|
||||
};
|
||||
let s = e.session_mut();
|
||||
let Some(gr) = s.groups.iter_mut().find(|x| x.id == g) else { return Err(bad(CMD, "unknown `group`")) };
|
||||
if let Some(n) = name {
|
||||
gr.name = n;
|
||||
}
|
||||
if let Some(m) = members {
|
||||
gr.members = m;
|
||||
}
|
||||
gr.edit = bool_or(p, "edit", gr.edit);
|
||||
gr.mix = bool_or(p, "mix", gr.mix);
|
||||
Ok(json!({"group": g, "name": gr.name, "tracks": gr.members, "edit": gr.edit, "mix": gr.mix}))
|
||||
}
|
||||
|
||||
fn duplicate(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.duplicate", p)?;
|
||||
let (pl, auto, ins, snd) =
|
||||
@@ -543,15 +493,6 @@ fn folder_bus(s: &Session, folder: TrackId) -> Option<Route> {
|
||||
s.track(folder).map(|folder| folder.mixer.input.clone()).filter(|route| matches!(route, Route::Bus(_)))
|
||||
}
|
||||
|
||||
/// The summing bus owned by a routing folder.
|
||||
fn owned_folder_bus(s: &Session, folder: TrackId) -> Option<soundcraft_model::BusId> {
|
||||
let folder = s.track(folder).filter(|track| track.kind == TrackKind::Folder)?;
|
||||
match folder.mixer.input {
|
||||
Route::Bus(id) => Some(id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep only the selected roots so selecting a folder and one of its children preserves the subtree.
|
||||
fn root_tracks(s: &Session, tracks: Vec<TrackId>) -> Vec<TrackId> {
|
||||
tracks.iter().copied().filter(|track| !tracks.iter().any(|root| root != track && in_folder(s, *track, *root))).collect()
|
||||
@@ -636,20 +577,6 @@ fn change_width(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "track.delete", p)?;
|
||||
let s = e.session_mut();
|
||||
let removed_busses: Vec<_> = tracks
|
||||
.iter()
|
||||
.filter_map(|folder| {
|
||||
let bus = owned_folder_bus(s, *folder)?;
|
||||
let mut parent = s.track(*folder).and_then(|folder| folder.folder);
|
||||
for _ in 0..64 {
|
||||
match parent {
|
||||
Some(candidate) if tracks.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Some((bus, parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main)))
|
||||
})
|
||||
.collect();
|
||||
// Promote children of deleted folders and repair only routes that used the deleted
|
||||
// folder's own bus. Custom outputs remain untouched.
|
||||
let mut promoted = Vec::new();
|
||||
@@ -680,22 +607,6 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
for g in &mut s.groups {
|
||||
g.members.retain(|t| !tracks.contains(t));
|
||||
}
|
||||
for (bus, replacement) in removed_busses {
|
||||
s.busses.retain(|candidate| candidate.id != bus);
|
||||
for track in &mut s.tracks {
|
||||
if track.mixer.input == Route::Bus(bus) {
|
||||
track.mixer.input = Route::None;
|
||||
}
|
||||
if track.mixer.output == Route::Bus(bus) {
|
||||
track.mixer.output = replacement.clone();
|
||||
}
|
||||
for send in &mut track.mixer.sends {
|
||||
if send.as_ref().is_some_and(|send| send.target == Route::Bus(bus)) {
|
||||
*send = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"deleted": tracks.len()}))
|
||||
}
|
||||
|
||||
@@ -961,35 +872,6 @@ mod tests {
|
||||
assert!(e.execute("track.playlist_promote", &json!({"track": kick, "playlist": 1, "start": 0, "end": 10})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_modify_changes_name_membership_and_linking() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let g = e.execute("track.group", &json!({"tracks": ["Kick", "Snare"], "name": "Drums"})).unwrap()["group"].as_u64().unwrap();
|
||||
// Every field at once, the way the dialog submits it.
|
||||
let r = e.execute("track.group_modify", &json!({"group": g, "name": "Rhythm", "tracks": ["Kick", "Bass"], "edit": false})).unwrap();
|
||||
assert_eq!(r["name"], "Rhythm");
|
||||
let gr = e.session().groups.iter().find(|x| x.id.0 == g).unwrap().clone();
|
||||
assert_eq!(gr.name, "Rhythm");
|
||||
assert_eq!(gr.members, vec![e.session().track_by_name("Kick").unwrap().id, e.session().track_by_name("Bass").unwrap().id]);
|
||||
assert!(!gr.edit);
|
||||
assert!(gr.mix, "an omitted field keeps its value");
|
||||
assert_eq!(gr.letter, 'a', "the letter is the group's stable id and never changes");
|
||||
// Omitting `tracks` leaves membership alone rather than falling back to the selection.
|
||||
e.execute("edit.select", &json!({"tracks": ["Pad"], "exact": true})).unwrap();
|
||||
e.execute("track.group_modify", &json!({"group": g, "name": "Rhythm Section"})).unwrap();
|
||||
assert_eq!(e.session().groups.iter().find(|x| x.id.0 == g).unwrap().members, gr.members);
|
||||
// Resolving the group by its new name keeps working, so names stay unique and non-empty.
|
||||
e.execute("track.group", &json!({"tracks": ["Pad"], "name": "Pads"})).unwrap();
|
||||
assert!(e.execute("track.group_modify", &json!({"group": "Pads", "name": "Rhythm Section"})).is_err());
|
||||
assert!(e.execute("track.group_modify", &json!({"group": g, "name": " "})).is_err());
|
||||
assert!(e.execute("track.group_modify", &json!({"group": g, "tracks": []})).is_err());
|
||||
assert!(e.execute("track.group_modify", &json!({"group": g, "tracks": "Kick"})).is_err(), "`tracks` must be an array");
|
||||
assert!(e.execute("track.group_modify", &json!({"group": g, "tracks": ["Kick", "Kick"]})).is_ok());
|
||||
assert_eq!(e.session().groups.iter().find(|x| x.id.0 == g).unwrap().members.len(), 1, "a repeated track is listed once");
|
||||
assert!(e.execute("track.group_modify", &json!({"group": 9999, "name": "x"})).is_err());
|
||||
assert_eq!(e.session().groups.iter().find(|x| x.id.0 == g).unwrap().name, "Rhythm Section", "a rejected call changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_routes_members_through_its_bus() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
@@ -1038,14 +920,6 @@ mod tests {
|
||||
|
||||
e.execute("track.move_to_folder", &json!({"tracks": ["Kick"], "folder": inner.0})).unwrap();
|
||||
e.execute("track.move_to_folder", &json!({"tracks": [inner.0], "folder": outer.0})).unwrap();
|
||||
let inner_bus = match e.session().track(inner).unwrap().mixer.input {
|
||||
Route::Bus(bus) => bus,
|
||||
_ => panic!("folder did not receive a bus"),
|
||||
};
|
||||
let outer_bus = match e.session().track(outer).unwrap().mixer.input {
|
||||
Route::Bus(bus) => bus,
|
||||
_ => panic!("folder did not receive a bus"),
|
||||
};
|
||||
assert!(e.execute("track.move_to_folder", &json!({"tracks": [outer.0], "folder": inner.0})).is_err());
|
||||
|
||||
let kick = e.session().track_by_name("Kick").unwrap();
|
||||
@@ -1072,8 +946,6 @@ mod tests {
|
||||
let kick = e.session().track_by_name("Kick").unwrap();
|
||||
assert_eq!(kick.folder, None);
|
||||
assert_eq!(kick.mixer.output, Route::Main);
|
||||
assert!(e.session().bus(inner_bus).is_none(), "the deleted folder's owned bus must be removed");
|
||||
assert!(e.session().bus(outer_bus).is_some(), "the surviving parent folder's bus must remain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -383,10 +383,6 @@ fn write_rtp(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
n.start = n.start.saturating_add(r.delay_ticks).max(0);
|
||||
notes += 1;
|
||||
}
|
||||
// Playback delays controller events too, but ignores those before tick 0 (hidden by a start trim).
|
||||
for c in sequence.ctrls.iter_mut().filter(|c| c.tick >= 0) {
|
||||
c.tick = c.tick.saturating_add(r.delay_ticks).max(0);
|
||||
}
|
||||
sequence.sort();
|
||||
}
|
||||
let pre = format!("rtp.{}.", t.0);
|
||||
@@ -639,31 +635,6 @@ mod tests {
|
||||
assert!(!e.session().edit.values.keys().any(|k| k.starts_with(&format!("rtp.{}.", keys.0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtp_delay_shifts_controller_events() {
|
||||
use soundcraft_midi::{CtrlEvent, CtrlKind};
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let keys = e.session().track_by_name("Keys").unwrap().id;
|
||||
let s = e.session_mut();
|
||||
if let Some(pl) = s.track_mut(keys).and_then(Track::playlist_mut)
|
||||
&& let ClipContent::Midi { sequence } = &mut pl.clips[0].content
|
||||
{
|
||||
sequence.ctrls.clear();
|
||||
sequence.ctrls.push(CtrlEvent { tick: 100, channel: 0, kind: CtrlKind::Cc(7), value: 90 });
|
||||
sequence.ctrls.push(CtrlEvent { tick: 200, channel: 0, kind: CtrlKind::PitchBend, value: 10 });
|
||||
sequence.ctrls.push(CtrlEvent { tick: -30, channel: 0, kind: CtrlKind::Cc(1), value: 5 });
|
||||
}
|
||||
s.edit.values.insert(track_key("rtp", keys, "delay"), 50.0);
|
||||
e.execute("edit.select", &json!({"tracks": [keys.0]})).unwrap();
|
||||
e.execute("track.write_midi_rtp", &json!({})).unwrap();
|
||||
let ClipContent::Midi { sequence } = &e.session().track(keys).unwrap().clips()[0].content else { panic!() };
|
||||
let tick_of = |k: CtrlKind| sequence.ctrls.iter().find(|c| c.kind == k).map(|c| c.tick);
|
||||
assert_eq!(tick_of(CtrlKind::Cc(7)), Some(150), "playback delayed the CC by 50 ticks; baked result must match");
|
||||
assert_eq!(tick_of(CtrlKind::PitchBend), Some(250));
|
||||
// Hidden by a start trim: playback ignores it, so baking leaves it alone.
|
||||
assert_eq!(tick_of(CtrlKind::Cc(1)), Some(-30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_and_vca_coalesce() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
offset
|
||||
),
|
||||
cmd!(query "video.list", "Movies", [], None, "{} — movies used by Video tracks (path, size, rate, codec, online file, why it is offline)", always, |e, _| {
|
||||
cmd!(query "video.list", "Movies", [], None, "{} — movies used by Video tracks (path, size, rate, codec, online file)", always, |e, _| {
|
||||
let s = e.session();
|
||||
let rows: Vec<Value> = s
|
||||
.videos
|
||||
@@ -36,8 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.map(|v| {
|
||||
let clips = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| c.video_source() == Some(v.id)).count();
|
||||
json!({"id": v.id, "name": v.name, "path": v.path, "width": v.width, "height": v.height, "frame_rate": v.frame_rate,
|
||||
"duration": v.duration, "codec": v.codec, "clips": clips,
|
||||
"file_found": v.offline.is_none() && crate::io::file_found(&v.path), "offline": v.offline})
|
||||
"duration": v.duration, "codec": v.codec, "clips": clips, "file_found": crate::io::file_found(&v.path)})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"videos": rows, "online": !s.edit.flag(OFFLINE_FLAG)}))
|
||||
@@ -93,7 +92,6 @@ mod tests {
|
||||
frame_rate: 24.0,
|
||||
duration: 2.0,
|
||||
codec: "h264".into(),
|
||||
offline: None,
|
||||
});
|
||||
let c = s.new_clip_id();
|
||||
crate::edit::place_clip(s, t, Clip::video(c, "m", vid, 0, 1000, 96_000));
|
||||
|
||||
@@ -835,8 +835,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -1043,18 +1041,4 @@ 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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+29
-282
@@ -60,23 +60,6 @@ pub fn file_found(path: impl AsRef<Path>) -> bool {
|
||||
crate::file_access::check_read(path).is_ok() && path.is_file()
|
||||
}
|
||||
|
||||
/// The file a path stored in the session file `session` refers to, or why it is not opened.
|
||||
///
|
||||
/// Every path a session names (audio sources, movies) goes through here before it reaches the
|
||||
/// file system. A relative path is taken from the session's folder, and only files on this
|
||||
/// computer or on the network share the session itself is on are opened (`craft_docpath`).
|
||||
/// Paths the person picks in this session (imports) are not stored paths and skip this.
|
||||
pub fn stored_path(stored: &str, session: &Path) -> std::result::Result<PathBuf, String> {
|
||||
let session = std::path::absolute(session).unwrap_or_else(|_| session.to_path_buf());
|
||||
let path = craft_docpath::resolve(stored, Some(&session)).map_err(|why| format!("not opened: {why}"))?;
|
||||
// A path that is still relative would be taken from the working directory, which the session
|
||||
// does not choose.
|
||||
if path.is_relative() {
|
||||
return Err("not opened: the path is not absolute".into());
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Create `dir` and its parents, inside the automation write root when there is one.
|
||||
pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> {
|
||||
let dir = dir.as_ref();
|
||||
@@ -223,7 +206,6 @@ pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec<u8>, at: Sample
|
||||
frame_rate: info.frame_rate,
|
||||
duration: info.duration,
|
||||
codec: info.codec.clone(),
|
||||
offline: None,
|
||||
});
|
||||
// Reuse the first Video track when the new clip does not overlap its clips; else add one at the top.
|
||||
let range = Range::new(at, at.saturating_add(len));
|
||||
@@ -253,35 +235,22 @@ pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec<u8>, at: Sample
|
||||
}))
|
||||
}
|
||||
|
||||
/// Resolve each movie referenced by the session file `session` ([`stored_path`]): keep the
|
||||
/// resolved path when its file exists, else look for a file of the same name next to the session
|
||||
/// (moved projects) and relink. A movie whose stored path is refused and has no such copy is
|
||||
/// marked offline. Returns the movies still missing.
|
||||
pub fn relink_videos(s: &mut Session, session: &Path) -> Vec<String> {
|
||||
let session_dir = session.parent().unwrap_or(Path::new(""));
|
||||
/// Resolve each referenced movie: keep it when its path exists, else look for a file of the same
|
||||
/// name next to the session (moved projects) and relink. Returns the movies still missing.
|
||||
pub fn relink_videos(s: &mut Session, session_dir: &Path) -> Vec<String> {
|
||||
let mut missing = Vec::new();
|
||||
for v in &mut s.videos {
|
||||
v.offline = None;
|
||||
let p = stored_path(&v.path, session);
|
||||
// The UI opens `path` as it is: keep the checked path, not one relative to the working
|
||||
// directory.
|
||||
if let Ok(found) = &p
|
||||
&& file_found(found)
|
||||
{
|
||||
v.path = found.to_string_lossy().into_owned();
|
||||
let p = if Path::new(&v.path).is_absolute() { PathBuf::from(&v.path) } else { session_dir.join(&v.path) };
|
||||
if file_found(&p) {
|
||||
continue;
|
||||
}
|
||||
let name = Path::new(&v.path).file_name().map(PathBuf::from).unwrap_or_default();
|
||||
let near = [session_dir.join(&name), session_dir.join("Video Files").join(&name)]
|
||||
.into_iter()
|
||||
.find(|c| !name.as_os_str().is_empty() && file_found(c));
|
||||
match (near, p) {
|
||||
(Some(c), _) => v.path = c.to_string_lossy().into_owned(),
|
||||
(None, Ok(p)) => missing.push(format!("{} (video)", p.display())),
|
||||
(None, Err(why)) => {
|
||||
missing.push(format!("{} (video): {why}", v.path));
|
||||
v.offline = Some(why);
|
||||
}
|
||||
match near {
|
||||
Some(c) => v.path = c.to_string_lossy().into_owned(),
|
||||
None => missing.push(format!("{} (video)", p.display())),
|
||||
}
|
||||
}
|
||||
missing
|
||||
@@ -309,19 +278,17 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b
|
||||
let base_tick = s.tempo.samples_to_ticks(at, s.sample_rate);
|
||||
let mut out = Vec::new();
|
||||
for (i, tr) in smf.tracks.iter().enumerate() {
|
||||
if tr.sequence.notes.is_empty() && tr.sequence.ctrls.is_empty() {
|
||||
if tr.sequence.notes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut name = if tr.name.trim().is_empty() { format!("MIDI {}", i + 1) } else { tr.name.clone() };
|
||||
soundcraft_model::truncate_chars(&mut name, soundcraft_model::MAX_NAME_CHARS);
|
||||
let id = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some(&name));
|
||||
let drums = !tr.sequence.notes.is_empty() && tr.sequence.notes.iter().all(|n| n.channel == 9);
|
||||
let drums = tr.sequence.notes.iter().all(|n| n.channel == 9);
|
||||
if let Some(t) = s.track_mut(id) {
|
||||
t.instrument = Some(soundcraft_model::Insert::new(if drums { "drum_synth" } else { "subtractive_synth" }));
|
||||
}
|
||||
// A controller on the clip's last tick would not play, so the clip runs one tick past the last one.
|
||||
let past_ctrls = tr.sequence.ctrls.iter().map(|c| c.tick.saturating_add(1)).max().unwrap_or(0);
|
||||
let end_tick = base_tick.saturating_add(tr.sequence.end_tick().max(past_ctrls).max(1));
|
||||
let end_tick = base_tick.saturating_add(tr.sequence.end_tick().max(1));
|
||||
let len = s.tempo.tick_to_samples(end_tick, s.sample_rate).saturating_sub(at);
|
||||
if len > s.sample_rate.samples(86_400.0) {
|
||||
return Err(EngineError::Io(format!("MIDI track {}: longer than 24 hours", i + 1)));
|
||||
@@ -1043,18 +1010,6 @@ fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
f.sync_all()
|
||||
}
|
||||
|
||||
/// Copies `from` to `to`, which must not exist yet; an existing entry, even a link, is never followed.
|
||||
fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
let mut src = std::fs::File::open(from)?;
|
||||
let mut dst = std::fs::OpenOptions::new().write(true).create_new(true).open(to)?;
|
||||
if let Err(err) = std::io::copy(&mut src, &mut dst) {
|
||||
drop(dst);
|
||||
let _ = std::fs::remove_file(to);
|
||||
return Err(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy the session file about to be replaced to `Backups/<name> backup <n>.<ext>`, keeping the
|
||||
/// newest ten.
|
||||
fn back_up(file: &Path) {
|
||||
@@ -1075,16 +1030,9 @@ fn back_up(file: &Path) {
|
||||
.flatten()
|
||||
.filter_map(|f| f.file_name().to_str()?.strip_prefix(&prefix)?.strip_suffix(&suffix)?.parse().ok())
|
||||
.collect();
|
||||
let Some(next) = numbers.iter().max().map_or(Some(1), |n| n.checked_add(1)) else {
|
||||
log::warn!("no backup of {}: backup numbers are exhausted", file.display());
|
||||
return;
|
||||
};
|
||||
let dest = backups.join(format!("{prefix}{next}{suffix}"));
|
||||
if let Err(err) = crate::file_access::check_write(&dest) {
|
||||
log::warn!("no backup of {}: {err}", file.display());
|
||||
return;
|
||||
}
|
||||
if let Err(err) = std::fs::create_dir_all(&backups).and_then(|()| copy_new(file, &dest)) {
|
||||
let next = numbers.iter().max().map_or(1, |n| n.saturating_add(1));
|
||||
let copied = std::fs::create_dir_all(&backups).and_then(|()| std::fs::copy(file, backups.join(format!("{prefix}{next}{suffix}"))));
|
||||
if let Err(err) = copied {
|
||||
log::warn!("no backup of {}: {err}", file.display());
|
||||
return;
|
||||
}
|
||||
@@ -1148,26 +1096,19 @@ fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> {
|
||||
|
||||
/// Open a `.scraft` session and decode its audio files (missing files are reported, not fatal).
|
||||
pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
|
||||
open_session_linked(e, path, Path::new(path))
|
||||
}
|
||||
|
||||
/// [`open_session`], following the media paths in it as if they were stored in the session file
|
||||
/// `links` (crash recovery: the session written beside the autosave, its paths from the original).
|
||||
pub(crate) fn open_session_linked(e: &mut Engine, path: &str, links: &Path) -> Result<Vec<String>> {
|
||||
let text = read_text(path)?;
|
||||
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let mut missing = Vec::new();
|
||||
let mut files = DecodedFiles::new();
|
||||
for src in &s.sources {
|
||||
let decoded =
|
||||
stored_path(&src.path, links).map_err(|why| format!("{}: {why}", src.path)).and_then(|p| decode_file(&mut files, &p, s.sample_rate.hz()));
|
||||
match decoded {
|
||||
let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
|
||||
match decode_file(&mut files, &p, s.sample_rate.hz()) {
|
||||
Ok(audio) => s.pool.insert(src.id, audio),
|
||||
Err(err) => missing.push(err),
|
||||
}
|
||||
}
|
||||
missing.extend(relink_videos(&mut s, links));
|
||||
missing.extend(relink_videos(&mut s, &dir));
|
||||
s.folder = Some(dir);
|
||||
e.replace_session(s);
|
||||
e.path = Some(path.to_string());
|
||||
@@ -1307,32 +1248,14 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
|
||||
};
|
||||
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
|
||||
let mut missing = Vec::new();
|
||||
// Several sources may share one file (native sessions do): decode each path once and share the result.
|
||||
let mut decoded: std::collections::HashMap<String, Option<Arc<SourceAudio>>> = std::collections::HashMap::new();
|
||||
for src in &s.sources {
|
||||
if let Some(audio) = decoded.get(&src.path) {
|
||||
if let Some(audio) = audio {
|
||||
s.pool.insert(src.id, Arc::clone(audio));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let audio = match files.remove(&src.path) {
|
||||
Some(bytes) => match decode_source(&bytes, Path::new(&src.path).extension().and_then(|x| x.to_str()), s.sample_rate.hz()) {
|
||||
Ok(audio) => Some(audio),
|
||||
Err(err) => {
|
||||
missing.push(format!("{}: {err}", src.path));
|
||||
None
|
||||
}
|
||||
match files.remove(&src.path) {
|
||||
Some(audio) => match decode_source(&audio, Path::new(&src.path).extension().and_then(|x| x.to_str()), s.sample_rate.hz()) {
|
||||
Ok(audio) => s.pool.insert(src.id, audio),
|
||||
Err(err) => missing.push(format!("{}: {err}", src.path)),
|
||||
},
|
||||
None => {
|
||||
missing.push(src.path.clone());
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(audio) = &audio {
|
||||
s.pool.insert(src.id, Arc::clone(audio));
|
||||
None => missing.push(src.path.clone()),
|
||||
}
|
||||
decoded.insert(src.path.clone(), audio);
|
||||
}
|
||||
// Movies are referenced by path, never copied into a session: none can be found here.
|
||||
missing.extend(s.videos.iter().map(|v| v.path.clone()));
|
||||
@@ -1427,25 +1350,18 @@ pub struct Take {
|
||||
|
||||
/// Place recorded take files as `plan` says, as one undo step ("Record").
|
||||
pub fn place_recording(e: &mut Engine, plan: &Recording, takes: Vec<(TrackId, PathBuf)>) -> Vec<soundcraft_model::ClipId> {
|
||||
add_recording_in(e, plan.start, takes, plan.pass, plan.punch)
|
||||
let ids = add_recording(e, plan.start, takes, plan.pass);
|
||||
if let Some(p) = plan.punch {
|
||||
let clips: Vec<u64> = ids.iter().map(|c| c.0).collect();
|
||||
let _ = e.execute_merged("edit.trim_to_fill_selection", &json!({"clips": clips, "start": p.start, "end": p.end}), "record");
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// Turn recorded take files into clips at `start`, as one undo step ("Record"); each file is the
|
||||
/// source of its clips. With `pass` (loop recording), a take becomes one clip per pass (at most
|
||||
/// 1000), each pass after the first on a new playlist.
|
||||
pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBuf)>, pass: Option<Samples>) -> Vec<soundcraft_model::ClipId> {
|
||||
add_recording_in(e, start, takes, pass, None)
|
||||
}
|
||||
|
||||
/// [`add_recording`] limited to the `punch` range: each clip is cut to it before it's placed, so
|
||||
/// the material around the punch isn't overwritten.
|
||||
fn add_recording_in(
|
||||
e: &mut Engine,
|
||||
start: Samples,
|
||||
takes: Vec<(TrackId, PathBuf)>,
|
||||
pass: Option<Samples>,
|
||||
punch: Option<Range>,
|
||||
) -> Vec<soundcraft_model::ClipId> {
|
||||
let before = e.session_arc();
|
||||
let mut out = Vec::new();
|
||||
for (tid, path) in takes {
|
||||
@@ -1468,13 +1384,6 @@ fn add_recording_in(
|
||||
if offset > 0 && n < pass / 8 {
|
||||
break;
|
||||
}
|
||||
let (clip_start, cut, clip_len) = match punch {
|
||||
Some(p) => {
|
||||
let Some(kept) = Range::new(start, start.saturating_add(n)).intersect(&p) else { continue };
|
||||
(kept.start, kept.start.saturating_sub(start), kept.len())
|
||||
}
|
||||
None => (start, 0, n),
|
||||
};
|
||||
if offset > 0
|
||||
&& let Some(t) = s.track_mut(tid)
|
||||
{
|
||||
@@ -1482,7 +1391,7 @@ fn add_recording_in(
|
||||
t.active_playlist = t.playlists.len() - 1;
|
||||
}
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(s, tid, Clip::audio(cid, name, src, offset.saturating_add(cut), clip_start, clip_len));
|
||||
crate::edit::place_clip(s, tid, Clip::audio(cid, name, src, offset, start, n));
|
||||
out.push(cid);
|
||||
}
|
||||
}
|
||||
@@ -1564,30 +1473,6 @@ mod record_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn punching_in_keeps_the_material_around_the_punch_range() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-punch-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut e = Engine::default();
|
||||
let vox = armed(&mut e, "Vox", "mono", "In 1");
|
||||
let base = dir.join("Audio Files/Base.wav");
|
||||
let take = dir.join("Audio Files/Take.wav");
|
||||
write_take(&base, 3_000);
|
||||
write_take(&take, 1_000);
|
||||
add_recording(&mut e, 0, vec![(vox, base)], None);
|
||||
let plan = Recording {
|
||||
start: 1_000,
|
||||
pass: None,
|
||||
punch: Some(Range::new(1_400, 1_600)),
|
||||
rate: 48_000,
|
||||
takes: vec![Take { track: vox, path: take.clone(), channels: 1 }],
|
||||
};
|
||||
place_recording(&mut e, &plan, vec![(vox, take)]);
|
||||
let clips: Vec<_> = e.session().track(vox).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect();
|
||||
assert_eq!(clips, vec![(0, 1_400, 0), (1_400, 200, 400), (1_600, 1_400, 1_600)]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_take_shows_the_pass_under_way_within_the_punch_while_it_records() {
|
||||
assert_eq!(live_span(1_000, None, None, 0), None);
|
||||
@@ -1763,43 +1648,6 @@ mod save_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_never_overwrites_an_existing_entry_when_numbers_run_out() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-backups-full-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("Backups")).unwrap();
|
||||
let path = dir.join("Song.scraft");
|
||||
std::fs::write(&path, b"session").unwrap();
|
||||
let last = dir.join(format!("Backups/Song backup {}.scraft", u32::MAX));
|
||||
std::fs::write(&last, b"sentinel").unwrap();
|
||||
back_up(&path);
|
||||
assert_eq!(std::fs::read(&last).unwrap(), b"sentinel");
|
||||
let dest = dir.join("Backups/Song backup 1.scraft");
|
||||
std::fs::write(&dest, b"taken").unwrap();
|
||||
assert!(copy_new(&path, &dest).is_err());
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"taken");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn backup_never_writes_through_a_link_to_an_outside_file() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-backups-link-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("Project/Backups")).unwrap();
|
||||
let outside = dir.join("outside.txt");
|
||||
std::fs::write(&outside, b"sentinel").unwrap();
|
||||
let path = dir.join("Project/Song.scraft");
|
||||
std::fs::write(&path, b"session").unwrap();
|
||||
std::os::unix::fs::symlink(&outside, dir.join(format!("Project/Backups/Song backup {}.scraft", u32::MAX))).unwrap();
|
||||
back_up(&path);
|
||||
let link = dir.join("Project/Backups/Song backup 1.scraft");
|
||||
std::os::unix::fs::symlink(&outside, &link).unwrap();
|
||||
assert!(copy_new(&path, &link).is_err());
|
||||
assert_eq!(std::fs::read(&outside).unwrap(), b"sentinel");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_gives_sources_that_share_a_path_their_own_files() {
|
||||
let mut s = Session::default();
|
||||
@@ -1883,60 +1731,6 @@ mod open_tests {
|
||||
assert_eq!(std::fs::read_dir(dir.join("copy/Audio Files")).unwrap().count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
#[test]
|
||||
fn stored_media_paths_are_followed_only_on_this_computer() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-stored-paths-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] };
|
||||
std::fs::write(dir.join("k.wav"), soundcraft_audio_io::encode(&audio, &EncodeOptions::default()).unwrap()).unwrap();
|
||||
let mut saved = Session::default();
|
||||
let t = saved.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let src = add_source(&mut saved, "k", audio, Some("../k.wav"), FileFormat::Wav);
|
||||
let id = saved.new_clip_id();
|
||||
crate::edit::place_clip(&mut saved, t, Clip::audio(id, "k", src, 0, 0, 480));
|
||||
// In a folder of its own, so a movie named `k.wav` is not relinked to a file beside it.
|
||||
std::fs::create_dir_all(dir.join("s")).unwrap();
|
||||
let path = dir.join("s").join("Song.scraft").to_string_lossy().into_owned();
|
||||
let mut stored = vec![r"\\192.0.2.1\share\x.wav".to_string(), "file://192.0.2.1/s/x.wav".to_string()];
|
||||
// Linux reads `//tmp/…` as the local `/tmp/…`, so only the path check keeps it from being read.
|
||||
if cfg!(unix) {
|
||||
stored.push(format!("/{}", dir.join("k.wav").display()));
|
||||
}
|
||||
let refused = |m: &str| m.contains("not opened: the path is on another computer");
|
||||
for p in stored {
|
||||
let mut s = saved.clone();
|
||||
s.sources[0].path.clone_from(&p);
|
||||
let id = SourceId(s.alloc());
|
||||
s.videos.push(soundcraft_model::VideoSource {
|
||||
id,
|
||||
name: "x".into(),
|
||||
path: p.clone(),
|
||||
width: 0,
|
||||
height: 0,
|
||||
frame_rate: 0.0,
|
||||
duration: 0.0,
|
||||
codec: String::new(),
|
||||
offline: None,
|
||||
});
|
||||
std::fs::write(&path, s.to_json().unwrap()).unwrap();
|
||||
let mut r = Engine::default();
|
||||
let missing = open_session(&mut r, &path).unwrap();
|
||||
assert_eq!(missing.iter().filter(|m| refused(m)).count(), 2, "{p}: {missing:?}");
|
||||
assert!(r.session().pool.get(r.session().sources[0].id).is_none(), "{p}");
|
||||
assert!(r.session().videos[0].offline.is_some(), "{p}");
|
||||
let mut into = Engine::default();
|
||||
let imported = into.execute("file.import_session_data", &json!({"path": path})).unwrap();
|
||||
assert!(imported["missing"].as_array().unwrap().iter().any(|m| m.as_str().is_some_and(refused)), "{p}: {imported}");
|
||||
assert!(into.session().pool.get(into.session().sources[0].id).is_none(), "{p}");
|
||||
}
|
||||
// A path relative to the session opens.
|
||||
std::fs::write(&path, saved.to_json().unwrap()).unwrap();
|
||||
let mut r = Engine::default();
|
||||
assert!(open_session(&mut r, &path).unwrap().is_empty());
|
||||
assert!(r.session().pool.get(r.session().sources[0].id).is_some());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1954,28 +1748,6 @@ mod name_tests {
|
||||
assert_eq!((track.name.len(), track.clips()[0].name.len()), (soundcraft_model::MAX_NAME_CHARS, soundcraft_model::MAX_NAME_CHARS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_controller_only_midi_track_is_imported_with_its_events() {
|
||||
let ctrls = vec![
|
||||
soundcraft_midi::CtrlEvent { tick: 0, channel: 0, kind: soundcraft_midi::CtrlKind::Cc(7), value: 100 },
|
||||
soundcraft_midi::CtrlEvent { tick: 480, channel: 0, kind: soundcraft_midi::CtrlKind::Cc(7), value: 64 },
|
||||
soundcraft_midi::CtrlEvent { tick: 960, channel: 0, kind: soundcraft_midi::CtrlKind::Cc(7), value: 20 },
|
||||
];
|
||||
let sequence = soundcraft_midi::Sequence { ctrls: ctrls.clone(), ..Default::default() };
|
||||
let smf = soundcraft_midi::Smf { tracks: vec![soundcraft_midi::SmfTrack { name: "Vol".into(), sequence }], ..Default::default() };
|
||||
let mut e = Engine::default();
|
||||
let ids = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, false).unwrap();
|
||||
assert_eq!(ids.len(), 1);
|
||||
let track = e.session().track(ids[0]).unwrap();
|
||||
assert_eq!(track.instrument.as_ref().map(|i| i.plugin.as_str()), Some("subtractive_synth"));
|
||||
// The clip must reach past the last event, or playback skips it.
|
||||
assert!(track.clips()[0].end() > e.session().tempo.tick_to_samples(960, e.session().sample_rate));
|
||||
match &track.clips()[0].content {
|
||||
ClipContent::Midi { sequence } => assert_eq!(sequence.ctrls, ctrls),
|
||||
_ => panic!("expected a MIDI clip"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midi_longer_than_a_day_is_refused() {
|
||||
let held = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: (1 << 28) - 1 };
|
||||
@@ -2114,31 +1886,6 @@ mod bundle_tests {
|
||||
assert_eq!(r.path.as_deref(), Some("Midnight Groove.scraftz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_sharing_one_file_all_load_from_a_bundle() {
|
||||
use std::io::Write as _;
|
||||
let e = crate::demo::demo_engine();
|
||||
let (bytes, _) = session_bundle(e.session()).unwrap();
|
||||
let (text, files) = read_bundle(&bytes).unwrap();
|
||||
let mut s = Session::from_json(&text).unwrap();
|
||||
s.sources[1].path = s.sources[0].path.clone();
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for path in s.sources.iter().map(|x| &x.path).collect::<std::collections::BTreeSet<_>>() {
|
||||
w.start_file(path.as_str(), opts).unwrap();
|
||||
w.write_all(&files[path]).unwrap();
|
||||
}
|
||||
w.start_file("Song.scraft", opts).unwrap();
|
||||
w.write_all(s.to_json().unwrap().as_bytes()).unwrap();
|
||||
let bundle = w.finish().unwrap().into_inner();
|
||||
let mut r = Engine::default();
|
||||
let missing = open_session_bytes(&mut r, "Song.scraftz", &bundle).unwrap();
|
||||
assert!(missing.is_empty(), "{missing:?}");
|
||||
for src in &r.session().sources {
|
||||
assert!(r.session().pool.get(src.id).is_some(), "{} has no audio", src.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_named_sources_get_distinct_files() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
||||
@@ -369,15 +369,7 @@ impl Engine {
|
||||
let key = self.merge_hold.clone().unwrap_or_else(|| key.to_string());
|
||||
let continuing = self.merge_key.as_ref() == Some(&key);
|
||||
let before_len = self.undo.len();
|
||||
// The step pushed for a continuing tick is dropped again below, so it must not trim the
|
||||
// oldest entry first when the history is full.
|
||||
let limit = self.history_limit;
|
||||
if continuing && before_len > 0 {
|
||||
self.history_limit = limit.max(before_len.saturating_add(1));
|
||||
}
|
||||
let r = self.execute(id, params);
|
||||
self.history_limit = limit;
|
||||
let r = r?;
|
||||
let r = self.execute(id, params)?;
|
||||
if continuing && self.undo.len() == before_len + 1 && before_len > 0 {
|
||||
// Drop the step just pushed; the earlier one still holds the pre-gesture document.
|
||||
self.undo.pop();
|
||||
|
||||
@@ -40,7 +40,7 @@ fn install_fixture_now() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::copy(target.join("debug").join(lib), dir.join("Fixture.clap")).unwrap();
|
||||
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 5);
|
||||
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_model::{ChannelFormat, TrackKind};
|
||||
|
||||
fn engine_with_full_history(limit: usize) -> (Engine, u64) {
|
||||
let mut e = Engine::default();
|
||||
let t = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, None).0;
|
||||
e.history_limit = limit;
|
||||
for i in 0..limit + 1 {
|
||||
e.execute("mix.pan", &json!({"track": t, "pan": 0.1 * (i as f64 + 1.0)})).unwrap();
|
||||
}
|
||||
assert_eq!(e.undo_history().len(), limit);
|
||||
(e, t)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fader_gesture_is_one_undo_step_when_the_history_is_full() {
|
||||
let (mut e, t) = engine_with_full_history(3);
|
||||
for db in [-1.0, -2.0, -3.0, -4.0, -5.0] {
|
||||
e.execute_merged("mix.volume", &json!({"track": t, "db": db}), "fader").unwrap();
|
||||
}
|
||||
e.end_merge();
|
||||
let volume = |e: &Engine| e.session().tracks.iter().find(|x| x.id.0 == t).map(|x| x.mixer.volume_db);
|
||||
assert_eq!(volume(&e), Some(-5.0));
|
||||
// The gesture costs one step; it must not push further real edits out of the history.
|
||||
assert_eq!(e.undo_history().len(), 3, "history: {:?}", e.undo_history());
|
||||
assert_eq!(e.undo_history().iter().filter(|l| l.as_str() == "Set Volume").count(), 1, "history: {:?}", e.undo_history());
|
||||
assert!(e.undo());
|
||||
assert_eq!(volume(&e), Some(0.0), "one undo should undo the whole fader gesture; history: {:?}", e.undo_history());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_history_limit_keeps_no_undo_entry_for_a_gesture() {
|
||||
let mut e = Engine::default();
|
||||
let t = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, None).0;
|
||||
e.history_limit = 0;
|
||||
for db in [-1.0, -2.0, -3.0] {
|
||||
e.execute_merged("mix.volume", &json!({"track": t, "db": db}), "fader").unwrap();
|
||||
}
|
||||
e.end_merge();
|
||||
assert!(e.undo_history().is_empty(), "history: {:?}", e.undo_history());
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::{Engine, EngineError};
|
||||
|
||||
fn engine_with_kick() -> Engine {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"name": "Kick", "format": "stereo"})).unwrap();
|
||||
e
|
||||
}
|
||||
|
||||
fn kick_pan(e: &Engine) -> Vec<f32> {
|
||||
e.session().tracks.iter().find(|t| t.name == "Kick").unwrap().mixer.pan.to_vec()
|
||||
}
|
||||
|
||||
fn kick_trim(e: &Engine) -> f32 {
|
||||
e.session().tracks.iter().find(|t| t.name == "Kick").unwrap().mixer.trim_db
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_pan_rejects_a_missing_or_non_numeric_pan_and_keeps_the_pan() {
|
||||
let mut e = engine_with_kick();
|
||||
e.execute("mix.pan", &json!({"tracks": ["Kick"], "pan": -1.0})).unwrap();
|
||||
assert_eq!(kick_pan(&e)[0], -1.0);
|
||||
|
||||
for params in [json!({"tracks": ["Kick"]}), json!({"tracks": ["Kick"], "pan": "left"}), json!({"tracks": ["Kick"], "pan": null})] {
|
||||
let r = e.execute("mix.pan", ¶ms);
|
||||
assert!(matches!(r, Err(EngineError::BadParams(..))), "{params}: expected BadParams, got {r:?}");
|
||||
assert_eq!(kick_pan(&e)[0], -1.0, "{params}: the pan must be untouched");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_trim_rejects_a_missing_or_non_numeric_db_and_keeps_the_trim() {
|
||||
let mut e = engine_with_kick();
|
||||
e.execute("mix.trim", &json!({"tracks": ["Kick"], "db": -6.0})).unwrap();
|
||||
assert_eq!(kick_trim(&e), -6.0);
|
||||
|
||||
for params in [json!({"tracks": ["Kick"]}), json!({"tracks": ["Kick"], "db": "loud"}), json!({"tracks": ["Kick"], "db": null})] {
|
||||
let r = e.execute("mix.trim", ¶ms);
|
||||
assert!(matches!(r, Err(EngineError::BadParams(..))), "{params}: expected BadParams, got {r:?}");
|
||||
assert_eq!(kick_trim(&e), -6.0, "{params}: the trim must be untouched");
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,3 @@
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 1dc212f0026723d05bfcfbb29fcff476eda7f491e9bea9cd5d26dccf006a81f6 # shrinks to ops = [(9, 0, 2420494, 1), (3, 0, 2396495, 1)]
|
||||
cc 87a0afb56f43d6ad377071e28bd6ba94c3d67485fc69dbefa9a2c7de3f698ed6 # shrinks to ops = [(10, 2215386, 0, 0), (0, 844227, 213110, 1), (3, 0, 443078, 0), (0, 443079, 443079, 0), (11, 0, 0, 0)]
|
||||
|
||||
@@ -122,7 +122,7 @@ impl TickConv {
|
||||
let [hi, lo] = division.to_be_bytes();
|
||||
let fps = -i128::from(hi as i8);
|
||||
let tpf = i128::from(lo);
|
||||
if !matches!(fps, 24 | 25 | 29 | 30) || tpf == 0 {
|
||||
if fps <= 0 || tpf == 0 {
|
||||
return Err(MidiError::Invalid { offset, reason: "invalid SMPTE division" });
|
||||
}
|
||||
let two_ppq = 2 * i128::from(PPQ);
|
||||
@@ -485,20 +485,20 @@ pub fn write_smf(smf: &Smf) -> Result<Vec<u8>, MidiError> {
|
||||
}
|
||||
for n in &track.sequence.notes {
|
||||
let ch = n.channel & 0x0F;
|
||||
let pitch = n.pitch.min(127);
|
||||
let pitch = n.pitch & 0x7F;
|
||||
let start = clamp_tick(n.start);
|
||||
let end = clamp_tick(n.end()).max(start.saturating_add(1));
|
||||
ev.push(WEvent { tick: start, order: 3, bytes: vec![0x90 | ch, pitch, n.velocity.clamp(1, 127)] });
|
||||
ev.push(WEvent { tick: end, order: 1, bytes: vec![0x80 | ch, pitch, n.release_velocity.min(127)] });
|
||||
ev.push(WEvent { tick: end, order: 1, bytes: vec![0x80 | ch, pitch, n.release_velocity & 0x7F] });
|
||||
}
|
||||
for c in &track.sequence.ctrls {
|
||||
let ch = c.channel & 0x0F;
|
||||
let v7 = c.value.clamp(0, 127) as u8;
|
||||
let bytes = match c.kind {
|
||||
CtrlKind::Cc(num) => vec![0xB0 | ch, num.min(127), v7],
|
||||
CtrlKind::Cc(num) => vec![0xB0 | ch, num & 0x7F, v7],
|
||||
CtrlKind::Program => vec![0xC0 | ch, v7],
|
||||
CtrlKind::ChannelPressure => vec![0xD0 | ch, v7],
|
||||
CtrlKind::PolyPressure(p) => vec![0xA0 | ch, p.min(127), v7],
|
||||
CtrlKind::PolyPressure(p) => vec![0xA0 | ch, p & 0x7F, v7],
|
||||
CtrlKind::PitchBend => {
|
||||
let v = (c.value.saturating_add(8192)).clamp(0, 16383);
|
||||
vec![0xE0 | ch, (v & 0x7F) as u8, ((v >> 7) & 0x7F) as u8]
|
||||
|
||||
@@ -148,29 +148,6 @@ fn division_conversion_96_and_smpte() {
|
||||
assert_eq!(smf.tracks[0].sequence.notes[0].length, 480);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smpte_division_rejects_undefined_frame_rates() {
|
||||
let body = vec![0, 0x90, 60, 100, 40, 0x80, 60, 64, 0, 0xFF, 0x2F, 0];
|
||||
for high in 0x80..=0xFF {
|
||||
if [(-24i8) as u8, (-25i8) as u8, (-29i8) as u8, (-30i8) as u8].contains(&high) {
|
||||
continue;
|
||||
}
|
||||
let division = u16::from_be_bytes([high, 40]);
|
||||
assert!(matches!(read_smf(&file(0, division, std::slice::from_ref(&body))), Err(MidiError::Invalid { offset: 12, .. })), "{division:#06x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smpte_division_keeps_all_four_standard_frame_rates() {
|
||||
for (fps, file_ticks, model_ticks) in [(24i8, 960, 1920), (25, 1000, 1920), (29, 1200, 1922), (30, 1200, 1920)] {
|
||||
let body = [vec![0, 0x90, 60, 100], vlq(file_ticks), vec![0x80, 60, 64, 0, 0xFF, 0x2F, 0]].concat();
|
||||
let division = u16::from_be_bytes([(-fps) as u8, 40]);
|
||||
let smf = read_smf(&file(0, division, std::slice::from_ref(&body))).unwrap();
|
||||
assert_eq!(smf.tracks[0].sequence.notes, vec![note(60, 0, model_ticks)], "{fps} fps");
|
||||
assert!(read_smf(&file(0, u16::from_be_bytes([(-fps) as u8, 0]), std::slice::from_ref(&body))).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_status_and_velocity_zero_note_off() {
|
||||
// Note-on with running status for both on and off (velocity 0).
|
||||
@@ -355,21 +332,3 @@ proptest! {
|
||||
prop_assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
/// #472: out-of-range pitches, release velocities and controller numbers clamp to 127 instead of
|
||||
/// wrapping into other notes and controllers (128 & 0x7F would be 0).
|
||||
#[test]
|
||||
fn writer_clamps_out_of_range_data_bytes() {
|
||||
for raw in [127u8, 128, 255] {
|
||||
let mut seq = Sequence::default();
|
||||
seq.notes.push(Note { pitch: raw, velocity: 100, release_velocity: raw, channel: 0, start: 0, length: 960 });
|
||||
seq.ctrls.push(CtrlEvent { tick: 0, channel: 0, kind: CtrlKind::Cc(raw), value: 64 });
|
||||
seq.ctrls.push(CtrlEvent { tick: 0, channel: 0, kind: CtrlKind::PolyPressure(raw), value: 64 });
|
||||
let smf = Smf { tracks: vec![SmfTrack { name: String::new(), sequence: seq }], ..Smf::default() };
|
||||
let back = read_smf(&write_smf(&smf).unwrap()).unwrap();
|
||||
let s = &back.tracks.last().unwrap().sequence;
|
||||
assert_eq!((s.notes[0].pitch, s.notes[0].release_velocity), (127, 127), "input {raw}");
|
||||
let kinds: Vec<CtrlKind> = s.ctrls.iter().map(|c| c.kind).collect();
|
||||
assert!(kinds.contains(&CtrlKind::Cc(127)) && kinds.contains(&CtrlKind::PolyPressure(127)), "input {raw}: {kinds:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-151
@@ -515,9 +515,6 @@ impl MixEngine {
|
||||
s.align.clear();
|
||||
s.align_pre.clear();
|
||||
s.out_extra.clear();
|
||||
for fx in s.clip_fx.values_mut() {
|
||||
fx.reset();
|
||||
}
|
||||
}
|
||||
self.meters.clear();
|
||||
self.main_meter = StripMeter::default();
|
||||
@@ -768,14 +765,12 @@ impl MixEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Match track faders: the next block's first sample must not ramp this one.
|
||||
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
|
||||
let v0 = volume_db_at(t, pos);
|
||||
let v1 = volume_db_at(t, last);
|
||||
let v1 = volume_db_at(t, pos + frames as i64);
|
||||
for (ch, src) in strip.buf.iter().enumerate().take(self.main.len()) {
|
||||
if let Some(m) = self.main.get_mut(ch) {
|
||||
for i in 0..frames {
|
||||
let g = lerp_gain(v0, v1, i, frames.saturating_sub(1));
|
||||
let g = lerp_gain(v0, v1, i, frames);
|
||||
if let (Some(d), Some(x)) = (m.get_mut(i), src.get(i)) {
|
||||
*d = x * g;
|
||||
}
|
||||
@@ -1154,12 +1149,6 @@ impl ClipFx {
|
||||
fn new(sr: f32, ch: usize) -> ClipFx {
|
||||
ClipFx { eq: None, comp: None, gain_db: 0.0, sr, ch }
|
||||
}
|
||||
/// Clear filter, detector and lookahead history (transport stop / locate).
|
||||
fn reset(&mut self) {
|
||||
for p in self.eq.iter_mut().chain(self.comp.iter_mut()) {
|
||||
p.reset();
|
||||
}
|
||||
}
|
||||
fn make(&self, id: &str) -> Option<Box<dyn Plugin>> {
|
||||
soundcraft_dsp::create(id).map(|mut p| {
|
||||
p.prepare(self.sr, 16_384, self.ch.max(1));
|
||||
@@ -1649,7 +1638,7 @@ fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) {
|
||||
let mut latency = 0usize;
|
||||
for (i, ins) in t.mixer.inserts.iter().enumerate() {
|
||||
let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue };
|
||||
if !ins.active || ins.bypass || slot.failed {
|
||||
if !ins.active || ins.bypass {
|
||||
continue;
|
||||
}
|
||||
apply_params(slot, ins, t, i, pos);
|
||||
@@ -2484,52 +2473,6 @@ mod tests {
|
||||
assert!(out[0][30_000].abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_volume_breakpoint_does_not_ramp_the_previous_block() {
|
||||
for block in [1, 2, 37, 512, 1024] {
|
||||
let start = 37;
|
||||
let boundary = start + block as i64;
|
||||
let end = boundary + block as i64;
|
||||
let (mut s, t) = session_with_dc(0.5, end as usize);
|
||||
s.track_mut(t).unwrap().mixer.pan = vec![-1.0];
|
||||
let master = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None);
|
||||
let lane = s.track_mut(master).unwrap().lane_mut(&AutoParam::Volume);
|
||||
lane.set_point(start, 0.0);
|
||||
lane.set_point(boundary - 1, 0.0);
|
||||
lane.set_point(boundary, -60.0);
|
||||
lane.set_point(end - 1, -60.0);
|
||||
|
||||
let mut mixer = MixEngine::new(48_000.0, block);
|
||||
let mut out = vec![vec![0.0; block]; 2];
|
||||
for (pos, expected) in [(start, 0.5), (boundary, 0.0005)] {
|
||||
mixer.render(&s, pos, block, &mut out);
|
||||
for (i, &sample) in out[0].iter().enumerate() {
|
||||
assert!((sample - expected).abs() < 1e-6, "block {block}, position {pos}, sample {i}: {sample} vs {expected}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_volume_ramp_reaches_the_last_sample_of_the_block() {
|
||||
for block in [2, 37, 512, 1024] {
|
||||
let start = 37;
|
||||
let end = start + block as i64;
|
||||
let (mut s, t) = session_with_dc(0.5, end as usize);
|
||||
s.track_mut(t).unwrap().mixer.pan = vec![-1.0];
|
||||
let master = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None);
|
||||
let lane = s.track_mut(master).unwrap().lane_mut(&AutoParam::Volume);
|
||||
lane.set_point(start, 0.0);
|
||||
lane.set_point(end - 1, -12.0);
|
||||
|
||||
let out = render_range(&s, Range::new(start, end), block);
|
||||
assert_eq!(out[0].len(), block);
|
||||
assert!((out[0][0] - 0.5).abs() < 1e-6);
|
||||
let expected = 0.5 * db_to_gain(-12.0);
|
||||
assert!((out[0][block - 1] - expected).abs() < 1e-6, "block {block}: {} vs {expected}", out[0][block - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_process_and_survive_unknown_plugins() {
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
@@ -2797,9 +2740,6 @@ mod tests {
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
io.iter_mut().for_each(|c| c.iter_mut().take(frames).for_each(|x| *x = f32::NAN));
|
||||
}
|
||||
fn latency(&self) -> usize {
|
||||
self.0.latency()
|
||||
}
|
||||
}
|
||||
|
||||
fn nan(track: TrackId, slot: usize, ch: usize) -> Vec<PreparedInstance> {
|
||||
@@ -2846,71 +2786,6 @@ mod tests {
|
||||
assert_eq!(render_block(&mut eng, &s, 1024), only_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_inserts_stop_contributing_latency_until_reset() {
|
||||
for kind in [TrackKind::Audio, TrackKind::Aux, TrackKind::Folder, TrackKind::Master] {
|
||||
for block in [64, 512] {
|
||||
let mut s = Session::default();
|
||||
s.edit.delay_compensation = true;
|
||||
let source = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
|
||||
let mut impulse = vec![0.0; block * 6];
|
||||
for at in [block, block * 2, block * 4] {
|
||||
impulse[at] = 0.25;
|
||||
}
|
||||
s.pool
|
||||
.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![impulse.clone(), impulse] })));
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(source).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(
|
||||
cid,
|
||||
"impulses",
|
||||
SourceId(900),
|
||||
0,
|
||||
0,
|
||||
(block * 6) as i64,
|
||||
));
|
||||
let target = if kind == TrackKind::Audio { source } else { s.add_track(kind, ChannelFormat::Stereo, None) };
|
||||
if matches!(kind, TrackKind::Aux | TrackKind::Folder) {
|
||||
let bus = s.add_bus("Return", ChannelFormat::Stereo);
|
||||
s.track_mut(source).unwrap().mixer.output = Route::Bus(bus);
|
||||
s.track_mut(target).unwrap().mixer.input = Route::Bus(bus);
|
||||
}
|
||||
let mut failed = Insert::new("clap:test.nan");
|
||||
failed.params.insert("lookahead".into(), 5.0);
|
||||
let mut healthy = Insert::new("compressor");
|
||||
healthy.params.insert("lookahead".into(), 1.0);
|
||||
healthy.params.insert("ratio".into(), 1.0);
|
||||
let tr = s.track_mut(target).unwrap();
|
||||
tr.mixer.inserts[0] = Some(failed);
|
||||
tr.mixer.inserts[1] = Some(healthy);
|
||||
let mut plugin = soundcraft_dsp::create("compressor").unwrap();
|
||||
plugin.prepare(48_000.0, block, 2);
|
||||
let mut eng = MixEngine::new(48_000.0, block);
|
||||
eng.set_external_instances(true);
|
||||
eng.adopt(vec![PreparedInstance { track: target, slot: 0, id: "clap:test.nan".into(), ch: 2, plugin: Box::new(Nan(plugin)) }]);
|
||||
let mut out = vec![vec![0.0; block]; 2];
|
||||
eng.render(&s, 0, block, &mut out);
|
||||
assert!(out.iter().flatten().all(|x| *x == 0.0));
|
||||
assert_eq!(eng.meters[&target].plugin_failures.count, 1);
|
||||
|
||||
for b in [1, 2, 4] {
|
||||
if b == 4 {
|
||||
// Reset retries the failed plugin once; the next block must drop its
|
||||
// latency again, while keeping the healthy insert's 48-sample delay.
|
||||
eng.reset();
|
||||
eng.render(&s, (block * 3) as i64, block, &mut out);
|
||||
assert_eq!(eng.meters[&target].plugin_failures.count, 2);
|
||||
}
|
||||
eng.render(&s, (block * b) as i64, block, &mut out);
|
||||
assert_eq!(eng.latency, 48, "{kind:?}, block {block}, pass {b}: only the healthy insert delays audio");
|
||||
for channel in &out {
|
||||
assert_eq!(impulse_at(channel), vec![48], "{kind:?}, block {block}, pass {b}: no compensation for the failed insert");
|
||||
assert!((channel[48] - 0.25).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_instance_replaces_a_matching_one_in_its_slot() {
|
||||
// The host removed and re-added the plugin, and both session updates reached the audio
|
||||
@@ -3027,29 +2902,6 @@ mod tests {
|
||||
assert!(out.iter().flatten().all(|x| *x == 0.0), "old aligned audio after a reset");
|
||||
}
|
||||
|
||||
/// A reset (stop, locate) must clear the cached clip EQ/compressor history too.
|
||||
#[test]
|
||||
fn reset_clears_clip_effect_history() {
|
||||
// Compressor lookahead delay, then EQ high-pass filter state, each primed with DC.
|
||||
for (param, value) in [("comp.lookahead", 5.0), ("eq.hpf_on", 1.0)] {
|
||||
let (mut s, t) = session_with_dc(0.5, 9600);
|
||||
s.edit.click = false;
|
||||
let cid = s.track(t).unwrap().clips()[0].id.0;
|
||||
s.edit.values.insert(format!("clip_fx.{cid}.{param}"), value);
|
||||
let mut out = vec![vec![0.0f32; 512]; 2];
|
||||
let mut eng = MixEngine::new(48_000.0, 512);
|
||||
for b in 0..4 {
|
||||
eng.render(&s, b * 512, 512, &mut out);
|
||||
}
|
||||
eng.reset();
|
||||
eng.render(&s, 4096, 512, &mut out);
|
||||
let mut fresh = MixEngine::new(48_000.0, 512);
|
||||
let mut expect = vec![vec![0.0f32; 512]; 2];
|
||||
fresh.render(&s, 4096, 512, &mut expect);
|
||||
assert_eq!(out, expect, "{param}: clip effect state survived the reset");
|
||||
}
|
||||
}
|
||||
|
||||
fn impulse_at(ch: &[f32]) -> Vec<usize> {
|
||||
ch.iter().enumerate().filter(|(_, v)| v.abs() > 1e-4).map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ impl AutoParam {
|
||||
match self {
|
||||
AutoParam::Volume => "volume".into(),
|
||||
AutoParam::Pan(0) => "pan".into(),
|
||||
AutoParam::Pan(i) => format!("pan {}", u16::from(*i) + 1),
|
||||
AutoParam::Pan(i) => format!("pan {}", i + 1),
|
||||
AutoParam::Mute => "mute".into(),
|
||||
AutoParam::SendLevel(i) => format!("send {} level", slot_letter(*i)),
|
||||
AutoParam::SendPan(i) => format!("send {} pan", slot_letter(*i)),
|
||||
@@ -199,17 +199,12 @@ impl AutomationLane {
|
||||
}
|
||||
self.set_point(start, value);
|
||||
self.set_point(end, value);
|
||||
let anchor = end.saturating_add(1);
|
||||
// A real breakpoint already at `end + 1` keeps its own value.
|
||||
if self.points.binary_search_by(|p| p.at.cmp(&anchor)).is_err() {
|
||||
self.set_point(anchor, after);
|
||||
}
|
||||
self.set_point(end.saturating_add(1), after);
|
||||
}
|
||||
|
||||
/// Thin: remove points whose removal changes the curve by less than `tolerance`.
|
||||
pub fn thin(&mut self, tolerance: f32) -> usize {
|
||||
// Linear thinning would move the transitions of step lanes.
|
||||
if matches!(self.param, AutoParam::Mute | AutoParam::SendMute(_)) || self.points.len() < 3 || tolerance.is_nan() {
|
||||
if self.points.len() < 3 || tolerance.is_nan() {
|
||||
return 0;
|
||||
}
|
||||
let tolerance = f64::from(tolerance);
|
||||
@@ -255,11 +250,6 @@ impl AutomationLane {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pan_label_max_does_not_overflow() {
|
||||
assert_eq!(AutoParam::Pan(255).label(), "pan 256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolation_and_steps() {
|
||||
let mut l = AutomationLane::new(AutoParam::Volume);
|
||||
@@ -275,15 +265,6 @@ mod tests {
|
||||
assert_eq!(m.value_at(100, 0.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_range_keeps_point_right_after_end() {
|
||||
let mut l = AutomationLane::new(AutoParam::Volume);
|
||||
l.set_point(0, 0.0);
|
||||
l.set_point(11, -100.0);
|
||||
l.write_range(2, 10, -20.0, 0.0);
|
||||
assert_eq!(l.value_at(11, 0.0), -100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_range_preserves_outside() {
|
||||
let mut l = AutomationLane::new(AutoParam::Volume);
|
||||
@@ -322,19 +303,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_keeps_mute_changes_in_place() {
|
||||
for param in [AutoParam::Mute, AutoParam::SendMute(0)] {
|
||||
let mut lane = AutomationLane::new(param);
|
||||
for (at, value) in [(0, 0.0), (9000, 1.0), (10000, 1.0)] {
|
||||
lane.set_point(at, value);
|
||||
}
|
||||
lane.thin(0.1);
|
||||
assert_eq!(lane.value_at(8999, 0.0), 0.0);
|
||||
assert_eq!(lane.value_at(9000, 0.0), 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_ids() {
|
||||
assert_eq!(AutoParam::parse("send_b_level"), Some(AutoParam::SendLevel(1)));
|
||||
|
||||
+2
-19
@@ -37,19 +37,15 @@ fn value(c: u8) -> Option<u32> {
|
||||
/// Decodes padded (or unpadded) standard base64. `None` for malformed input or a result larger
|
||||
/// than [`MAX_DECODED_BYTES`].
|
||||
pub fn decode(text: &str) -> Option<Vec<u8>> {
|
||||
if text.bytes().filter(|c| !c.is_ascii_whitespace()).count() > MAX_DECODED_BYTES.div_ceil(3).saturating_mul(4) {
|
||||
return None;
|
||||
}
|
||||
let syms: Vec<u8> = text.bytes().filter(|c| !c.is_ascii_whitespace()).collect();
|
||||
let body = syms.strip_suffix(b"==").or_else(|| syms.strip_suffix(b"=")).unwrap_or(&syms);
|
||||
if body.contains(&b'=') || (syms.len() != body.len() && !syms.len().is_multiple_of(4)) || body.len() % 4 == 1 {
|
||||
return None;
|
||||
}
|
||||
let decoded_len = body.len() / 4 * 3 + body.len() % 4 * 3 / 4;
|
||||
if decoded_len > MAX_DECODED_BYTES {
|
||||
if body.len() / 4 * 3 > MAX_DECODED_BYTES {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(decoded_len);
|
||||
let mut out = Vec::with_capacity(body.len() / 4 * 3 + 2);
|
||||
for chunk in body.chunks(4) {
|
||||
let mut n = 0u32;
|
||||
for (i, &c) in chunk.iter().enumerate() {
|
||||
@@ -85,17 +81,4 @@ mod tests {
|
||||
assert!(decode(bad).is_none(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_cap_counts_the_partial_quartet() {
|
||||
for (extra, ok) in [(-1, true), (0, true), (1, false), (2, false)] {
|
||||
let data = vec![7u8; MAX_DECODED_BYTES.saturating_add_signed(extra)];
|
||||
let padded = encode(&data);
|
||||
let unpadded = padded.trim_end_matches('=');
|
||||
assert_eq!(decode(&padded).is_some(), ok, "padded, {extra}");
|
||||
assert_eq!(decode(unpadded).is_some(), ok, "unpadded, {extra}");
|
||||
let spaced = format!("{}\n {}", &padded[..8], &padded[8..]);
|
||||
assert_eq!(decode(&spaced).is_some(), ok, "whitespace, {extra}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,7 +695,6 @@ mod tests {
|
||||
frame_rate: 23.976,
|
||||
duration: 12.5,
|
||||
codec: "prores".into(),
|
||||
offline: None,
|
||||
});
|
||||
let cid = s.new_clip_id();
|
||||
if let Some(p) = s.track_mut(t).and_then(Track::playlist_mut) {
|
||||
@@ -750,17 +749,6 @@ mod tests {
|
||||
assert_eq!((s.sample_rate, s.tempo), (SampleRate::HZ_48000, TempoMap::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_meter_change_far_beyond_the_position_range_is_dropped_on_load() {
|
||||
let mut v = serde_json::to_value(Session::default()).unwrap();
|
||||
v["tempo"]["meters"] = serde_json::json!([
|
||||
{"tick": 0, "numerator": 4, "denominator": 4},
|
||||
{"tick": i64::MAX, "numerator": 3, "denominator": 4}
|
||||
]);
|
||||
let s = Session::from_json(&v.to_string()).unwrap();
|
||||
assert_eq!(s.parse_position("2|1|000", TimeFormat::BarsBeats), Ok(96_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_garbage_and_future_versions() {
|
||||
assert!(Session::from_json("not json").is_err());
|
||||
|
||||
@@ -46,9 +46,6 @@ pub struct VideoSource {
|
||||
/// Codec id (`h264`, `prores`, `mjpeg`, `hevc`, …).
|
||||
#[serde(default)]
|
||||
pub codec: String,
|
||||
/// Why the movie is not opened, when its stored path was refused on opening the session.
|
||||
#[serde(skip)]
|
||||
pub offline: Option<String>,
|
||||
}
|
||||
|
||||
impl VideoSource {
|
||||
|
||||
+54
-241
@@ -9,8 +9,8 @@
|
||||
//!
|
||||
//! Surround: the device is opened with as many output channels as it offers, up to the width of
|
||||
//! the session's main format. Main channels (SMPTE/WAV order) go to device channels in order; when
|
||||
//! the device has fewer channels than the main mix, surround is folded down to stereo (ITU-R
|
||||
//! BS.775). A mono device receives the average of the stereo channels, before output clipping.
|
||||
//! the device has fewer channels than the main mix, the mix is folded down to stereo (ITU-R
|
||||
//! BS.775) on the first two device channels.
|
||||
//!
|
||||
//! Plugins and threads: hosted third-party plugins (CLAP/VST3) are created, given their stored
|
||||
//! state and prepared on the thread that calls [`Player::update_session`] (the UI thread), and
|
||||
@@ -365,26 +365,18 @@ impl AudioState {
|
||||
let b = ch.and_then(|v| v.get(i0 + 1)).copied().unwrap_or(a);
|
||||
a + (b - a) * fr
|
||||
};
|
||||
if channels < nch {
|
||||
// Fold surround to stereo first; a mono device averages the two sides.
|
||||
if nch > 2 && channels < nch {
|
||||
// Fewer device channels than the main mix: ITU fold-down to stereo.
|
||||
let (mut l, mut r) = (0.0f32, 0.0f32);
|
||||
if sounding {
|
||||
if nch == 2 {
|
||||
(l, r) = (sample(0), sample(1));
|
||||
} else {
|
||||
for (c, (gl, gr)) in self.fold.iter().enumerate().take(nch) {
|
||||
let v = sample(c);
|
||||
l += v * gl;
|
||||
r += v * gr;
|
||||
}
|
||||
for (c, (gl, gr)) in self.fold.iter().enumerate().take(nch) {
|
||||
let v = sample(c);
|
||||
l += v * gl;
|
||||
r += v * gr;
|
||||
}
|
||||
}
|
||||
for (c, o) in frame.iter_mut().enumerate() {
|
||||
*o = match c {
|
||||
0 if channels == 1 => {
|
||||
let mono = (0.5 * l + 0.5 * r).clamp(-1.0, 1.0);
|
||||
if mono.is_finite() { mono } else { 0.0 }
|
||||
}
|
||||
0 => l.clamp(-1.0, 1.0),
|
||||
1 => r.clamp(-1.0, 1.0),
|
||||
_ => 0.0,
|
||||
@@ -392,6 +384,7 @@ impl AudioState {
|
||||
}
|
||||
} else {
|
||||
for (c, o) in frame.iter_mut().enumerate() {
|
||||
// Stereo mixes on a mono device play the left channel (as before).
|
||||
*o = if c < nch.max(1) && sounding { sample(c).clamp(-1.0, 1.0) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
@@ -526,58 +519,54 @@ impl Player {
|
||||
if config.buffer_size != cpal::BufferSize::Default {
|
||||
attempts.push(cpal::StreamConfig { buffer_size: cpal::BufferSize::Default, ..config });
|
||||
}
|
||||
let opened = first_started(
|
||||
attempts,
|
||||
|config: &cpal::StreamConfig| -> Result<Opened, cpal::BuildStreamError> {
|
||||
let rate = config.sample_rate.0;
|
||||
let channels = usize::from(config.channels);
|
||||
let (tx, rx) = channel();
|
||||
let (gtx, grx) = channel();
|
||||
let state = AudioState::new(rx, Arc::clone(&shared), Arc::clone(&session), f64::from(rate), gtx);
|
||||
let fault = Arc::new(Fault::default());
|
||||
let stream = match format {
|
||||
cpal::SampleFormat::I32 => build_output::<i32>(&device, config, state, channels, &fault, &shared),
|
||||
cpal::SampleFormat::I16 => build_output::<i16>(&device, config, state, channels, &fault, &shared),
|
||||
_ => build_output::<f32>(&device, config, state, channels, &fault, &shared),
|
||||
}?;
|
||||
Ok(Opened { stream, tx, grx, fault })
|
||||
},
|
||||
|opened| start_stream(&opened.stream),
|
||||
);
|
||||
match opened {
|
||||
Some((config, Opened { stream, tx, grx, fault })) => {
|
||||
let rate = config.sample_rate.0;
|
||||
let channels = usize::from(config.channels);
|
||||
let buffer = match config.buffer_size {
|
||||
cpal::BufferSize::Fixed(n) => Some(n),
|
||||
cpal::BufferSize::Default => None,
|
||||
};
|
||||
log::info!(
|
||||
"audio device: {name} @ {rate} Hz, {channels} ch, {format}, buffer {}",
|
||||
buffer.map_or("default".to_string(), |n| n.to_string())
|
||||
);
|
||||
Player {
|
||||
shared,
|
||||
fault,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
watch: Mutex::new(None),
|
||||
tx,
|
||||
side: new_side(grx),
|
||||
_stream: Some(stream),
|
||||
device_name: name,
|
||||
device_rate: rate,
|
||||
device_channels: config.channels,
|
||||
device_buffer: buffer,
|
||||
silent: false,
|
||||
for config in attempts {
|
||||
let rate = config.sample_rate.0;
|
||||
let channels = usize::from(config.channels);
|
||||
let (tx, rx) = channel();
|
||||
let (gtx, grx) = channel();
|
||||
let state = AudioState::new(rx, Arc::clone(&shared), Arc::clone(&session), f64::from(rate), gtx);
|
||||
let fault = Arc::new(Fault::default());
|
||||
use cpal::traits::StreamTrait;
|
||||
let built = match format {
|
||||
cpal::SampleFormat::I32 => build_output::<i32>(&device, &config, state, channels, &fault, &shared),
|
||||
cpal::SampleFormat::I16 => build_output::<i16>(&device, &config, state, channels, &fault, &shared),
|
||||
_ => build_output::<f32>(&device, &config, state, channels, &fault, &shared),
|
||||
};
|
||||
match built {
|
||||
Ok(stream) => {
|
||||
if let Err(e) = stream.play() {
|
||||
log::warn!("audio stream failed to start: {e}");
|
||||
}
|
||||
let buffer = match config.buffer_size {
|
||||
cpal::BufferSize::Fixed(n) => Some(n),
|
||||
cpal::BufferSize::Default => None,
|
||||
};
|
||||
log::info!(
|
||||
"audio device: {name} @ {rate} Hz, {channels} ch, {format}, buffer {}",
|
||||
buffer.map_or("default".to_string(), |n| n.to_string())
|
||||
);
|
||||
return Player {
|
||||
shared,
|
||||
fault,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
watch: Mutex::new(None),
|
||||
tx,
|
||||
side: new_side(grx),
|
||||
_stream: Some(stream),
|
||||
device_name: name,
|
||||
device_rate: rate,
|
||||
device_channels: config.channels,
|
||||
device_buffer: buffer,
|
||||
silent: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
None => {
|
||||
log::warn!("no usable audio stream; using a silent clock");
|
||||
let (tx, rx) = channel();
|
||||
let (gtx, grx) = channel();
|
||||
Player::silent_clock(shared, tx, rx, want, new_side(grx), gtx)
|
||||
Err(e) => log::warn!("cannot open audio stream ({:?}): {e}", config.buffer_size),
|
||||
}
|
||||
}
|
||||
log::warn!("no usable audio stream; using a silent clock");
|
||||
let (tx, rx) = channel();
|
||||
let (gtx, grx) = channel();
|
||||
Player::silent_clock(shared, tx, rx, want, new_side(grx), gtx)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("no audio output: {e}; using a silent clock");
|
||||
@@ -737,12 +726,6 @@ impl Player {
|
||||
}
|
||||
}
|
||||
|
||||
/// The plugin's own text for a parameter value when SoundCraft's format would be wrong for it
|
||||
/// (see `PluginEditor::value_text`). Main thread only.
|
||||
pub fn editor_value_text(&self, track: TrackId, slot: usize, id: &str, value: f32) -> Option<String> {
|
||||
self.side().editors.get_mut(&(track, slot))?.value_text(id, value)
|
||||
}
|
||||
|
||||
/// Start playback at `from`, stopping at `end`, or looping `looped`.
|
||||
pub fn play(&self, from: Samples, end: Option<Samples>, looped: Option<Range>) {
|
||||
self.shared.position.store(from, Ordering::Relaxed);
|
||||
@@ -879,44 +862,6 @@ fn fold_gains(s: &Session) -> [(f32, f32); MAX_CHANNELS] {
|
||||
g
|
||||
}
|
||||
|
||||
/// Start a freshly built output stream. `false` when the device refuses to start it, so the caller
|
||||
/// treats it like a stream that failed to build and tries the next configuration.
|
||||
fn start_stream(stream: &impl cpal::traits::StreamTrait) -> bool {
|
||||
match stream.play() {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("audio stream failed to start: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A built output stream and the channels and fault flag its callback shares with the player.
|
||||
struct Opened {
|
||||
stream: cpal::Stream,
|
||||
tx: Sender<Cmd>,
|
||||
grx: Receiver<Vec<Box<dyn Plugin>>>,
|
||||
fault: Arc<Fault>,
|
||||
}
|
||||
|
||||
/// Build and start the attempts in order; the first that builds and starts wins. A build error or
|
||||
/// a stream that will not start is logged, and the next attempt is tried. `None` when none works.
|
||||
fn first_started<A: std::fmt::Debug, S, E: std::fmt::Display>(
|
||||
attempts: impl IntoIterator<Item = A>,
|
||||
mut build: impl FnMut(&A) -> Result<S, E>,
|
||||
start: impl Fn(&S) -> bool,
|
||||
) -> Option<(A, S)> {
|
||||
for attempt in attempts {
|
||||
match build(&attempt) {
|
||||
Ok(built) if start(&built) => return Some((attempt, built)),
|
||||
// `start` has already logged why the stream would not start.
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("cannot open audio stream ({attempt:?}): {e}"),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The output stream: the mix renders f32 into a scratch buffer allocated here (so the callback
|
||||
/// never allocates), converted to the device's sample type.
|
||||
fn build_output<T>(
|
||||
@@ -1022,64 +967,6 @@ fn open_device(want_rate: u32, want_ch: u16, cfg: &AudioConfig) -> Result<(cpal:
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A stream that builds; `starts` says whether the device lets it play (it can refuse after a
|
||||
/// reconnect).
|
||||
struct Fake {
|
||||
starts: bool,
|
||||
}
|
||||
|
||||
impl cpal::traits::StreamTrait for Fake {
|
||||
fn play(&self) -> Result<(), cpal::PlayStreamError> {
|
||||
if self.starts { Ok(()) } else { Err(cpal::PlayStreamError::DeviceNotAvailable) }
|
||||
}
|
||||
fn pause(&self) -> Result<(), cpal::PauseStreamError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stream_that_will_not_start_is_reported_as_failed() {
|
||||
assert!(!start_stream(&Fake { starts: false }));
|
||||
assert!(start_stream(&Fake { starts: true }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_config_that_starts_wins_and_later_ones_are_not_tried() {
|
||||
let mut tried = Vec::new();
|
||||
let won = first_started(
|
||||
[1u32, 2, 3],
|
||||
|&n| {
|
||||
tried.push(n);
|
||||
Ok::<_, String>(Fake { starts: n != 1 })
|
||||
},
|
||||
start_stream,
|
||||
);
|
||||
assert_eq!(won.map(|(n, _)| n), Some(2));
|
||||
assert_eq!(tried, vec![1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_that_fails_to_build_falls_through_to_the_next() {
|
||||
let won =
|
||||
first_started([1u32, 2], |&n| if n == 1 { Err("no such buffer size".to_string()) } else { Ok(Fake { starts: true }) }, start_stream);
|
||||
assert_eq!(won.map(|(n, _)| n), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_config_that_starts_yields_none_so_the_caller_falls_back_to_a_silent_clock() {
|
||||
let mut tried = 0;
|
||||
let won = first_started(
|
||||
[1u32, 2],
|
||||
|_| {
|
||||
tried += 1;
|
||||
Ok::<_, String>(Fake { starts: false })
|
||||
},
|
||||
start_stream,
|
||||
);
|
||||
assert!(won.is_none());
|
||||
assert_eq!(tried, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reusable_meter_snapshot_updates_and_drops_stale_tracks() {
|
||||
let keep = TrackId(1);
|
||||
@@ -1170,80 +1057,6 @@ mod tests {
|
||||
assert_eq!(shared.position.load(Ordering::Relaxed), 64);
|
||||
}
|
||||
|
||||
fn audio_output_state(format: soundcraft_model::ChannelFormat, channels: Vec<Vec<f32>>) -> AudioState {
|
||||
use soundcraft_model::{Clip, SourceAudio, SourceId, TrackKind};
|
||||
let mut s = Session::default();
|
||||
s.edit.click = false;
|
||||
s.outputs[0].format = format;
|
||||
let t = s.add_track(TrackKind::Audio, format, None);
|
||||
let frames = channels[0].len();
|
||||
s.pool.insert(SourceId(5), Arc::new(SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels })));
|
||||
let id = s.new_clip_id();
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "output", SourceId(5), 0, 0, frames as i64));
|
||||
let shared = Arc::new(Shared {
|
||||
position: AtomicI64::new(0),
|
||||
playing: AtomicBool::new(false),
|
||||
speed: AtomicU32::new(1000),
|
||||
meters: Mutex::new(MeterSnapshot::default()),
|
||||
});
|
||||
let (tx, rx) = channel();
|
||||
let st = AudioState::new(rx, shared, Arc::new(s), 48_000.0, channel().0);
|
||||
tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();
|
||||
st
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mono_device_averages_stereo_before_clipping() {
|
||||
use soundcraft_model::ChannelFormat;
|
||||
// Right only, left only, identical, opposite polarity, and levels that must be averaged
|
||||
// before clipping either side to the device's range.
|
||||
let left = [0.0, 0.8, 0.8, 0.8, 2.0, -2.0, 2.0];
|
||||
let right = [0.8, 0.0, 0.8, -0.8, -0.5, 0.5, 2.0];
|
||||
let expected = [0.4, 0.4, 0.8, 0.0, 0.75, -0.75, 1.0];
|
||||
for callback in [1, 37, 512] {
|
||||
let channels = [left, right].map(|pattern| (0..2048).map(|i| pattern[i % pattern.len()]).collect());
|
||||
let mut st = audio_output_state(ChannelFormat::Stereo, channels.into());
|
||||
let mut out = vec![0.0; callback];
|
||||
for block in 0..3 {
|
||||
st.fill(&mut out, 1);
|
||||
for (i, actual) in out.iter().enumerate() {
|
||||
let at = block * callback + i;
|
||||
assert!((actual - expected[at % expected.len()]).abs() < 1e-6, "callback {callback}, sample {at}: {actual}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mono_device_preserves_centered_mono_level_and_includes_surround_right_channels() {
|
||||
use soundcraft_model::ChannelFormat;
|
||||
let h = std::f32::consts::FRAC_1_SQRT_2;
|
||||
let mut mono = audio_output_state(ChannelFormat::Mono, vec![vec![0.8; 512]]);
|
||||
let mut out = [0.0; 128];
|
||||
mono.fill(&mut out, 1);
|
||||
let centered = 0.8 * soundcraft_dsp::db_to_gain(-3.0);
|
||||
assert!(out.iter().all(|x| (*x - centered).abs() < 1e-6), "a centered mono track keeps its rendered level under the -3 dB pan law");
|
||||
|
||||
// Independently exercise each SMPTE 5.1 channel: L, R, C, LFE, Ls, Rs.
|
||||
let channels = (0..6).map(|c| (0..512).map(|i| if i % 6 == c { 0.8 } else { 0.0 }).collect()).collect();
|
||||
let mut surround = audio_output_state(ChannelFormat::Surround51, channels);
|
||||
surround.fill(&mut out, 1);
|
||||
let expected = [0.4, 0.4, 0.8 * h, 0.0, 0.4 * h, 0.4 * h];
|
||||
for (i, actual) in out.iter().enumerate() {
|
||||
assert!((actual - expected[i % expected.len()]).abs() < 1e-6, "surround sample {i}: {actual}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mono_device_contains_overflow_from_opposite_surround_sides() {
|
||||
use soundcraft_model::ChannelFormat;
|
||||
let channels = [f32::MAX, -f32::MAX, 0.0, 0.0, f32::MAX, -f32::MAX].map(|v| vec![v; 128]);
|
||||
let mut st = audio_output_state(ChannelFormat::Surround51, channels.into());
|
||||
let mut out = [0.5; 128];
|
||||
st.fill(&mut out, 1);
|
||||
assert!(out.iter().all(|x| *x == 0.0), "overflow in the stereo fold-down must not send NaN to a mono device");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surround_main_maps_to_device_channels_or_folds_down() {
|
||||
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
|
||||
|
||||
@@ -43,8 +43,7 @@ impl TimeFormat {
|
||||
|
||||
fn min_secs(s: Samples, sr: SampleRate) -> String {
|
||||
let neg = s < 0;
|
||||
// Integer maths: a float `seconds * 1000` lands just below exact millisecond positions.
|
||||
let ms_total = i64::try_from(i128::from(s).abs() * 1000 / i128::from(sr.hz().max(1))).unwrap_or(i64::MAX);
|
||||
let ms_total = (sr.seconds(s.saturating_abs()) * 1000.0).floor() as i64;
|
||||
let mins = ms_total / 60_000;
|
||||
let secs = (ms_total / 1000) % 60;
|
||||
let ms = ms_total % 1000;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Tempo and meter maps: the conversion between musical time (ticks) and samples.
|
||||
|
||||
use crate::{MAX_POSITION, SampleRate, Samples, TimeError, to_samples};
|
||||
use crate::{SampleRate, Samples, TimeError, to_samples};
|
||||
|
||||
/// Ticks per quarter note (Pro Tools' resolution).
|
||||
pub const TICKS_PER_QUARTER: i64 = 960;
|
||||
@@ -74,17 +74,17 @@ impl TempoMap {
|
||||
&self.meters
|
||||
}
|
||||
|
||||
/// Repair a map read from a file: only valid tempos and meters within `0..=MAX_POSITION` ticks, in
|
||||
/// tick order, with one of each at tick 0.
|
||||
/// Repair a map read from a file: only valid tempos and meters, in tick order, with one of each
|
||||
/// at tick 0.
|
||||
pub fn sanitize(&mut self) {
|
||||
self.tempos.retain(|e| (0..=MAX_POSITION).contains(&e.tick) && valid_bpm(e.bpm).is_ok());
|
||||
self.tempos.retain(|e| e.tick >= 0 && valid_bpm(e.bpm).is_ok());
|
||||
self.tempos.sort_by_key(|e| e.tick);
|
||||
self.tempos.dedup_by_key(|e| e.tick);
|
||||
if self.tempos.first().is_none_or(|e| e.tick > 0) {
|
||||
let bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
|
||||
self.tempos.insert(0, TempoEvent { tick: 0, bpm });
|
||||
}
|
||||
self.meters.retain(|m| (0..=MAX_POSITION).contains(&m.tick) && valid_meter(m.numerator, m.denominator));
|
||||
self.meters.retain(|m| m.tick >= 0 && valid_meter(m.numerator, m.denominator));
|
||||
self.meters.sort_by_key(|m| m.tick);
|
||||
self.meters.dedup_by_key(|m| m.tick);
|
||||
if self.meters.first().is_none_or(|m| m.tick > 0) {
|
||||
@@ -268,12 +268,9 @@ impl TempoMap {
|
||||
let first = self.meter_at_tick(0);
|
||||
if tick < 0 {
|
||||
let tpb = first.ticks_per_bar();
|
||||
let abs = tick.unsigned_abs();
|
||||
let tpb_u = tpb.unsigned_abs();
|
||||
let bars_back_u = abs / tpb_u + u64::from(!abs.is_multiple_of(tpb_u));
|
||||
let bars_back = i64::try_from(bars_back_u).unwrap_or(i64::MAX);
|
||||
// Offset of `tick` inside its bar, without negating `i64::MIN`.
|
||||
let within = ((tpb_u - abs % tpb_u) % tpb_u) as i64;
|
||||
let bars_back = (-tick + tpb - 1) / tpb;
|
||||
let bar_tick = -bars_back * tpb;
|
||||
let within = tick - bar_tick;
|
||||
let beat_t = first.ticks_per_beat();
|
||||
return BarBeat { bar: 1 - bars_back, beat: within / beat_t + 1, tick: within % beat_t };
|
||||
}
|
||||
@@ -344,15 +341,6 @@ mod tests {
|
||||
assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_beat_at_min_tick_does_not_overflow() {
|
||||
let m = TempoMap::default();
|
||||
let bb = m.bar_beat_at_tick(i64::MIN);
|
||||
assert!(bb.bar < 1);
|
||||
assert!(bb.beat >= 1 && bb.tick >= 0);
|
||||
assert_eq!(m.bar_beat_at_tick(-1).bar, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tempo_change_is_integrated() {
|
||||
let mut m = TempoMap::default();
|
||||
@@ -398,17 +386,6 @@ mod tests {
|
||||
assert_eq!(empty, TempoMap::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_events_beyond_the_position_range() {
|
||||
let mut m = TempoMap {
|
||||
tempos: vec![TempoEvent { tick: 0, bpm: 120.0 }, TempoEvent { tick: i64::MAX, bpm: 90.0 }],
|
||||
meters: vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }, MeterEvent { tick: i64::MAX, numerator: 3, denominator: 4 }],
|
||||
};
|
||||
m.sanitize();
|
||||
assert_eq!(m, TempoMap::default());
|
||||
assert_eq!(m.bar_start_tick(2), TICKS_PER_QUARTER * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_values() {
|
||||
let mut m = TempoMap::default();
|
||||
|
||||
@@ -133,10 +133,8 @@ impl Timecode {
|
||||
/// Label for an absolute frame count.
|
||||
pub fn from_frames(frames: i64, rate: FrameRate) -> Timecode {
|
||||
let negative = frames < 0;
|
||||
// Drop-frame labels can exceed i64::MAX even when the frame count fits. Widen before
|
||||
// taking the magnitude so i64::MIN is represented exactly too.
|
||||
let mut f = i128::from(frames).abs();
|
||||
let base = i128::from(rate.nominal().max(1));
|
||||
let mut f = frames.saturating_abs();
|
||||
let base = rate.nominal().max(1);
|
||||
if rate.is_drop() {
|
||||
// Drop 2 (or 4) labels each minute except every tenth.
|
||||
let drop = if base >= 60 { 4 } else { 2 };
|
||||
@@ -149,13 +147,7 @@ impl Timecode {
|
||||
}
|
||||
let frames_l = f % base;
|
||||
let total_secs = f / base;
|
||||
Timecode {
|
||||
negative,
|
||||
hours: i64::try_from(total_secs / 3600).unwrap_or(i64::MAX),
|
||||
minutes: i64::try_from((total_secs / 60) % 60).unwrap_or(0),
|
||||
seconds: i64::try_from(total_secs % 60).unwrap_or(0),
|
||||
frames: i64::try_from(frames_l).unwrap_or(0),
|
||||
}
|
||||
Timecode { negative, hours: total_secs / 3600, minutes: (total_secs / 60) % 60, seconds: total_secs % 60, frames: frames_l }
|
||||
}
|
||||
|
||||
/// Absolute frame count of this label.
|
||||
@@ -255,28 +247,6 @@ mod tests {
|
||||
assert_eq!(tc.to_samples(FrameRate::Fps30, sr), 178_710_400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_frame_counts_round_trip_at_every_rate() {
|
||||
for rate in FrameRate::ALL {
|
||||
for frames in [i64::MIN, i64::MIN + 1, -1, 0, 1, i64::MAX - 1, i64::MAX] {
|
||||
let tc = Timecode::from_frames(frames, rate);
|
||||
assert_eq!(tc.to_frames(rate), frames, "{rate:?}: {frames}");
|
||||
assert_eq!(tc.negative, frames < 0);
|
||||
assert!((0..60).contains(&tc.minutes));
|
||||
assert!((0..60).contains(&tc.seconds));
|
||||
assert!((0..rate.nominal()).contains(&tc.frames));
|
||||
assert_eq!(Timecode::parse(&tc.format(rate)).unwrap(), tc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_drop_frame_labels_do_not_overflow() {
|
||||
for rate in [FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] {
|
||||
assert_eq!(Timecode::from_frames(i64::MAX, rate).to_frames(rate), i64::MAX);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_variants() {
|
||||
let tc = Timecode::parse("01:02:03:04").unwrap();
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
//! Min:Secs shows exact millisecond positions exactly (no float-floor off-by-one).
|
||||
|
||||
use soundcraft_time::{FrameRate, SampleRate, TempoMap, TimeFormat, format_position};
|
||||
|
||||
fn min_secs(s: i64, sr: SampleRate) -> String {
|
||||
format_position(s, TimeFormat::MinSecs, sr, &TempoMap::default(), FrameRate::default(), 0, 0)
|
||||
}
|
||||
|
||||
fn expected(ms_total: i64) -> String {
|
||||
format!("{}:{:02}.{:03}", ms_total / 60_000, (ms_total / 1000) % 60, ms_total % 1000)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_positions_at_44100() {
|
||||
assert_eq!(min_secs(88_641, SampleRate::HZ_44100), "0:02.010");
|
||||
assert_eq!(min_secs(89_523, SampleRate::HZ_44100), "0:02.030");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_position_at_44100() {
|
||||
assert_eq!(min_secs(-88_641, SampleRate::HZ_44100), "-0:02.010");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_exact_millisecond_displays_exactly() {
|
||||
for sr in [SampleRate::HZ_44100, SampleRate::HZ_48000] {
|
||||
let hz = i64::from(sr.hz());
|
||||
for ms in 0..=600_000_i64 {
|
||||
if (ms * hz) % 1000 != 0 {
|
||||
continue;
|
||||
}
|
||||
let s = ms * hz / 1000;
|
||||
assert_eq!(min_secs(s, sr), expected(ms), "{hz} Hz, sample {s}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use crate::theme::{Tokens, bold};
|
||||
use egui::{Align2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_model::{ClipId, GroupId, Session, SourceId, TrackId};
|
||||
use soundcraft_model::{ClipId, Session, SourceId, TrackId};
|
||||
use soundcraft_time::Samples;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -23,8 +23,7 @@ pub enum Dialog {
|
||||
Spot(crate::spot::Spot),
|
||||
Fades { shape: String },
|
||||
StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 },
|
||||
// `id: None` creates (Track › Group…); `id: Some` modifies that group (Groups list › Modify Group…).
|
||||
Group { id: Option<GroupId>, name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> },
|
||||
Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> },
|
||||
Session { frame_rate: String, bit_depth: String },
|
||||
ScoreSetup { title: String, composer: String, bars_per_system: u32, show_track_names: bool },
|
||||
SaveChanges { then: (String, Value) },
|
||||
@@ -35,8 +34,6 @@ pub struct Dialogs {
|
||||
pub open: Option<Dialog>,
|
||||
/// Runs once the session has been saved from `SaveChanges`.
|
||||
after_save: Option<(String, Value)>,
|
||||
/// A session the browser read while the open one had changes, waiting for `SaveChanges`.
|
||||
pending_session: Option<(String, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl Dialogs {
|
||||
@@ -55,8 +52,7 @@ impl Dialogs {
|
||||
Dialog::Spot(_) => "spot",
|
||||
Dialog::Fades { .. } => "fades",
|
||||
Dialog::StripSilence { .. } => "strip_silence",
|
||||
Dialog::Group { id: None, .. } => "group",
|
||||
Dialog::Group { id: Some(_), .. } => "modify_group",
|
||||
Dialog::Group { .. } => "group",
|
||||
Dialog::Session { .. } => "session",
|
||||
Dialog::ScoreSetup { .. } => "score_setup",
|
||||
Dialog::SaveChanges { .. } => "save_changes",
|
||||
@@ -75,16 +71,6 @@ impl Dialogs {
|
||||
self.open = Some(Dialog::RenameMarker { id, name: name.to_string() });
|
||||
}
|
||||
|
||||
/// Groups list › Modify Group…: the creation dialog, pre-filled from an existing group. False
|
||||
/// when the group is gone (deleted between the click and the menu).
|
||||
pub fn open_modify_group(&mut self, e: &Engine, id: GroupId) -> bool {
|
||||
let s = e.session();
|
||||
let Some(g) = s.group(id) else { return false };
|
||||
let members = s.tracks.iter().map(|t| (t.id.0, t.name.clone(), g.members.contains(&t.id))).collect();
|
||||
self.open = Some(Dialog::Group { id: Some(id), name: g.name.clone(), edit: g.edit, mix: g.mix, members });
|
||||
true
|
||||
}
|
||||
|
||||
/// Clip List › Clear: confirm before clips and files leave the session. False when there is nothing to clear.
|
||||
pub fn open_clear(&mut self, e: &Engine, clips: Vec<ClipId>, sources: Vec<SourceId>) -> bool {
|
||||
let Some(message) = clear_message(e.session(), &clips, &sources) else { return false };
|
||||
@@ -145,7 +131,7 @@ impl Dialogs {
|
||||
"track.group" => {
|
||||
let s = e.session();
|
||||
let members = s.tracks.iter().map(|t| (t.id.0, t.name.clone(), s.edit.selected_tracks.contains(&t.id))).collect();
|
||||
Dialog::Group { id: None, name: format!("Group {}", s.groups.len() + 1), edit: true, mix: true, members }
|
||||
Dialog::Group { name: format!("Group {}", s.groups.len() + 1), edit: true, mix: true, members }
|
||||
}
|
||||
"setup.session" => Dialog::Session { frame_rate: e.session().frame_rate.label().into(), bit_depth: "24".into() },
|
||||
"file.score_setup" => {
|
||||
@@ -169,7 +155,7 @@ impl Dialogs {
|
||||
/// When `id` would close a session with unsaved changes, ask first; true when the question is
|
||||
/// open instead. A take in progress is stopped first, so it counts as a change.
|
||||
pub fn ask_to_save(app: &mut SoundApp, id: &str, params: &Value) -> bool {
|
||||
if !matches!(id, "session.new" | "session.open" | "session.close" | "session.revert" | "app.quit" | OPEN_PENDING)
|
||||
if !matches!(id, "session.new" | "session.open" | "session.close" | "session.revert" | "app.quit")
|
||||
|| soundcraft_engine::find_command(id).is_some_and(|c| (c.enabled)(&app.engine).is_err())
|
||||
{
|
||||
return false;
|
||||
@@ -192,34 +178,6 @@ pub fn run_or_ask(app: &mut SoundApp, id: &str, params: Value) -> Result<Value,
|
||||
app.run(id, params)
|
||||
}
|
||||
|
||||
/// What `SaveChanges` carries on with to open the session the browser read.
|
||||
const OPEN_PENDING: &str = "ui.open_pending_session";
|
||||
|
||||
/// Opens a session the browser read as bytes (picked or dropped), asking first when the open one
|
||||
/// has changes. The read finishes later than the pick, so the question is asked only now.
|
||||
pub fn open_session_bytes(app: &mut SoundApp, name: String, bytes: Vec<u8>) {
|
||||
if crate::bounce::refuses_session_switch(app) {
|
||||
return;
|
||||
}
|
||||
app.dialogs.pending_session = Some((name, bytes));
|
||||
if !ask_to_save(app, OPEN_PENDING, &json!({})) {
|
||||
open_pending_session(app);
|
||||
}
|
||||
}
|
||||
|
||||
fn open_pending_session(app: &mut SoundApp) {
|
||||
let Some((name, bytes)) = app.dialogs.pending_session.take() else { return };
|
||||
if crate::bounce::refuses_session_switch(app) {
|
||||
return;
|
||||
}
|
||||
let _ = app.run("transport.stop", json!({}));
|
||||
app.ui.status = match soundcraft_engine::io::open_session_bytes(&mut app.engine, &name, &bytes) {
|
||||
Ok(missing) if missing.is_empty() => format!("Opened {name}"),
|
||||
Ok(missing) => format!("Opened {name} ({} missing media)", missing.len()),
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
/// A path prompt for any command whose parameters start with a required `path` or `dir` (plus
|
||||
/// Print Score, whose path is optional), with a default file name from the command.
|
||||
pub fn takes_path(id: &str) -> bool {
|
||||
@@ -325,8 +283,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
Dialog::Spot(_) => "Spot",
|
||||
Dialog::Fades { .. } => "Fades",
|
||||
Dialog::StripSilence { .. } => "Strip Silence",
|
||||
Dialog::Group { id: None, .. } => "Create Group",
|
||||
Dialog::Group { id: Some(_), .. } => "Modify Group",
|
||||
Dialog::Group { .. } => "Create Group",
|
||||
Dialog::Session { .. } => "Session Setup",
|
||||
Dialog::ScoreSetup { .. } => "Score Setup",
|
||||
Dialog::SaveChanges { then } if then.0 == "session.revert" => "Revert to Saved",
|
||||
@@ -551,7 +508,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
));
|
||||
}
|
||||
}
|
||||
Dialog::Group { id, name, edit, mix, members } => {
|
||||
Dialog::Group { name, edit, mix, members } => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name");
|
||||
ui.text_edit_singleline(name);
|
||||
@@ -566,18 +523,9 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
crate::widgets::checkbox(ui, on, n.as_str());
|
||||
}
|
||||
});
|
||||
let ids: Vec<u64> = members.iter().filter(|m| m.2).map(|m| m.0).collect();
|
||||
// Both commands reject an empty group, so don't offer an OK that can only fail.
|
||||
if buttons_enabled(ui, "OK", enter, !ids.is_empty()) {
|
||||
let mut params = json!({"name": name, "edit": edit, "mix": mix, "tracks": ids});
|
||||
let cmd = match id {
|
||||
Some(g) => {
|
||||
params["group"] = json!(g.0);
|
||||
"track.group_modify"
|
||||
}
|
||||
None => "track.group",
|
||||
};
|
||||
action = Some((cmd.into(), params));
|
||||
if buttons(ui, "OK", enter) {
|
||||
let ids: Vec<u64> = members.iter().filter(|m| m.2).map(|m| m.0).collect();
|
||||
action = Some(("track.group".into(), json!({"name": name, "edit": edit, "mix": mix, "tracks": ids})));
|
||||
}
|
||||
}
|
||||
Dialog::Session { frame_rate, bit_depth } => {
|
||||
@@ -646,7 +594,6 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
if esc || ctx.memory(|m| m.data.get_temp::<bool>(egui::Id::new("dlg_cancel")).unwrap_or(false)) {
|
||||
keep = false;
|
||||
app.dialogs.after_save = None;
|
||||
app.dialogs.pending_session = None;
|
||||
ctx.memory_mut(|m| m.data.remove::<bool>(egui::Id::new("dlg_cancel")));
|
||||
}
|
||||
if let Some(then) = save_first {
|
||||
@@ -666,7 +613,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let saving = action.as_ref().is_some_and(|(cmd, _)| cmd.starts_with("session.save"));
|
||||
if let Some((cmd, params)) = action {
|
||||
keep = false;
|
||||
match run_then(app, &cmd, params) {
|
||||
match app.run(&cmd, params) {
|
||||
Ok(v) if cmd == "file.export_clips" => {
|
||||
app.ui.status = format!("Exported {} clip(s) to {}", v["written"], v["dir"].as_str().unwrap_or_default());
|
||||
}
|
||||
@@ -677,7 +624,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
if saving
|
||||
&& let Some((then, params)) = app.dialogs.after_save.take()
|
||||
&& !app.engine.is_dirty()
|
||||
&& let Err(e) = run_then(app, &then, params)
|
||||
&& let Err(e) = app.run(&then, params)
|
||||
{
|
||||
app.ui.status = e;
|
||||
}
|
||||
@@ -686,30 +633,15 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a dialog's command, or what `SaveChanges` carries on with.
|
||||
fn run_then(app: &mut SoundApp, cmd: &str, params: Value) -> Result<Value, String> {
|
||||
if cmd == OPEN_PENDING {
|
||||
open_pending_session(app);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
app.run(cmd, params)
|
||||
}
|
||||
|
||||
/// Cancel/OK row; returns true when OK was pressed (or Enter).
|
||||
fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
|
||||
buttons_enabled(ui, ok, enter, true)
|
||||
}
|
||||
|
||||
/// `buttons`, but a greyed-out OK when the dialog's values can't be submitted yet. Cancel always works.
|
||||
fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> bool {
|
||||
let mut pressed = enter && ready;
|
||||
let mut pressed = enter;
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Cancel").clicked() {
|
||||
ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::new("dlg_cancel"), true));
|
||||
}
|
||||
let b = egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::current().accent);
|
||||
if ui.add_enabled(ready, b).clicked() {
|
||||
if ui.add(egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::current().accent)).clicked() {
|
||||
pressed = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1798,13 +1798,23 @@ fn crossfade_partner<'a>(track: &'a Track, clip: &Clip) -> Option<&'a Clip> {
|
||||
|
||||
fn draw_automation(painter: &egui::Painter, s: &Session, track: &Track, p: &AutoParam, tl: Rect, lane: Rect) {
|
||||
let t = Tokens::current();
|
||||
let range = lane_range(track, p);
|
||||
let (lo, hi, _) = p.range();
|
||||
let (lo, hi) = if matches!(p, AutoParam::Volume | AutoParam::SendLevel(_)) {
|
||||
(-60.0f32, 12.0f32)
|
||||
} else if matches!(p, AutoParam::Plugin { .. }) {
|
||||
plugin_range(track, p)
|
||||
} else {
|
||||
(lo, hi)
|
||||
};
|
||||
let def = match p {
|
||||
AutoParam::Volume => track.mixer.volume_db,
|
||||
AutoParam::Pan(i) => track.mixer.pan.get(usize::from(*i)).copied().unwrap_or(0.0),
|
||||
_ => 0.0,
|
||||
};
|
||||
let yv = |v: f32| auto_y(lane, range, v);
|
||||
let yv = |v: f32| -> f32 {
|
||||
let k = ((v.clamp(lo, hi) - lo) / (hi - lo).max(1e-6)).clamp(0.0, 1.0);
|
||||
lane.max.y - 4.0 - k * (lane.height() - 8.0)
|
||||
};
|
||||
let lanes = track.lane(p);
|
||||
let pts: Vec<(f32, f32)> = match lanes {
|
||||
Some(l) if !l.points.is_empty() => {
|
||||
@@ -1834,47 +1844,6 @@ fn draw_automation(painter: &egui::Painter, s: &Session, track: &Track, p: &Auto
|
||||
painter.text(pos2(lane.min.x + 6.0, lane.min.y + 9.0), Align2::LEFT_CENTER, p.label(), regular(10.0), t.text_dim);
|
||||
}
|
||||
|
||||
/// The values an automation lane spans, bottom to top.
|
||||
fn lane_range(track: &Track, p: &AutoParam) -> (f32, f32) {
|
||||
if matches!(p, AutoParam::Volume | AutoParam::SendLevel(_)) {
|
||||
(-60.0, 12.0)
|
||||
} else if matches!(p, AutoParam::Plugin { .. }) {
|
||||
plugin_range(track, p)
|
||||
} else {
|
||||
let (lo, hi, _) = p.range();
|
||||
(lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_y(lane: Rect, (lo, hi): (f32, f32), v: f32) -> f32 {
|
||||
let k = ((v.clamp(lo, hi) - lo) / (hi - lo).max(1e-6)).clamp(0.0, 1.0);
|
||||
lane.max.y - 4.0 - k * (lane.height() - 8.0)
|
||||
}
|
||||
|
||||
fn auto_value(lane: Rect, (lo, hi): (f32, f32), y: f32) -> f32 {
|
||||
let k = ((lane.max.y - 4.0 - y) / (lane.height() - 8.0)).clamp(0.0, 1.0);
|
||||
lo + (hi - lo) * k
|
||||
}
|
||||
|
||||
/// The automation a track's lane draws: its view's, or volume on the tracks that always show it.
|
||||
fn shown_automation(track: &Track) -> Option<AutoParam> {
|
||||
AutoParam::parse(&track.view).or_else(|| matches!(track.kind, TrackKind::Master | TrackKind::Aux | TrackKind::Vca).then_some(AutoParam::Volume))
|
||||
}
|
||||
|
||||
/// The drawn breakpoint nearest `pos`, within grabbing distance.
|
||||
fn breakpoint_at(s: &Session, tl: Rect, lane: Rect, track: &Track, p: &AutoParam, pos: Pos2) -> Option<Samples> {
|
||||
let range = lane_range(track, p);
|
||||
let dist = |at: Samples, v: f32| pos2(x_of(s, tl, at), auto_y(lane, range, v)).distance(pos);
|
||||
track
|
||||
.lane(p)?
|
||||
.points
|
||||
.iter()
|
||||
.map(|pt| (pt.at, dist(pt.at, pt.value)))
|
||||
.filter(|(_, d)| *d <= 6.0)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(at, _)| at)
|
||||
}
|
||||
|
||||
fn plugin_range(track: &Track, p: &AutoParam) -> (f32, f32) {
|
||||
if let AutoParam::Plugin { slot, param } = p
|
||||
&& let Some(Some(ins)) = track.mixer.inserts.get(usize::from(*slot))
|
||||
@@ -2008,11 +1977,6 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
|
||||
let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(p) } else { p };
|
||||
let at_origin = sample_at(&s, tl, origin.x).max(0);
|
||||
let auto_view = AutoParam::parse(&track.view);
|
||||
let point_hit = if matches!(tool, Tool::Smart | Tool::Grabber | Tool::Pencil) {
|
||||
shown_automation(track).and_then(|param| breakpoint_at(&s, tl, lane, track, ¶m, origin).map(|at| (param, at)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let body_hit = clip_at(track, at_origin);
|
||||
// Check fade endpoints before clip bodies: a full-length fade can end exactly
|
||||
// at the next clip's start, where `clip_at` would otherwise choose that neighbour.
|
||||
@@ -2044,9 +2008,8 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
|
||||
None
|
||||
}
|
||||
};
|
||||
// Effective tool under the Smart tool; on a breakpoint every tool that edits one grabs it.
|
||||
// Effective tool under the Smart tool.
|
||||
let eff = match tool {
|
||||
_ if point_hit.is_some() => Tool::Grabber,
|
||||
Tool::Smart => {
|
||||
if let Some(c) = &hit {
|
||||
if near_edge(c).is_some() {
|
||||
@@ -2073,7 +2036,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
|
||||
};
|
||||
ui.ctx().set_cursor_icon(icon);
|
||||
}
|
||||
let fade_corner = if tool == Tool::Smart && point_hit.is_none() {
|
||||
let fade_corner = if tool == Tool::Smart {
|
||||
fade_handle.map(|(_, side)| side).or_else(|| {
|
||||
hit.as_ref().and_then(|c| {
|
||||
let xs = x_of(&s, tl, c.start);
|
||||
@@ -2111,11 +2074,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
|
||||
place_insertion(app, ui, &s, track.id, at, mods.shift);
|
||||
}
|
||||
let gain_drag = if resp.drag_started() { gain_icon_at(origin) } else { None };
|
||||
if resp.drag_started()
|
||||
&& let Some((param, at)) = &point_hit
|
||||
{
|
||||
app.gesture = Some(Gesture::Breakpoint { track: track.id, param: param.clone(), at: *at });
|
||||
} else if let Some(c) = gain_drag {
|
||||
if let Some(c) = gain_drag {
|
||||
// `clip.gain` refuses to run with nothing selected.
|
||||
if s.edit.selected_tracks.is_empty() && s.edit.selected_clips.is_empty() {
|
||||
let _ = app.engine.execute("edit.select", &json!({"clips": [c.id.0]}));
|
||||
@@ -2182,15 +2141,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
|
||||
clip_gain_popup(ui.ctx(), *fader_bottom, *pos);
|
||||
}
|
||||
if resp.clicked()
|
||||
&& let Some((param, at)) = &point_hit
|
||||
{
|
||||
// Alt-click deletes a breakpoint, as in Pro Tools.
|
||||
if mods.alt {
|
||||
let _ =
|
||||
app.run("automation.clear", json!({"track": track.id.0, "param": automation_id(param), "start": at, "end": at.saturating_add(1)}));
|
||||
}
|
||||
} else if resp.clicked() && curve_handle.is_none() {
|
||||
if resp.clicked() && curve_handle.is_none() {
|
||||
match eff {
|
||||
Tool::Grabber => {
|
||||
if let Some(c) = &hit {
|
||||
@@ -2236,7 +2187,6 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
|
||||
}
|
||||
if resp.double_clicked()
|
||||
&& curve_handle.is_none()
|
||||
&& point_hit.is_none()
|
||||
&& eff != Tool::Pencil
|
||||
&& let Some(c) = &hit
|
||||
{
|
||||
@@ -2350,29 +2300,6 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
*last = at;
|
||||
let _ = app.engine.execute("transport.locate", &json!({"at": at}));
|
||||
}
|
||||
Some(Gesture::Breakpoint { track, param, at: from }) => {
|
||||
if let Some(t) = s.track(*track)
|
||||
&& let Some((_, row)) = rows.iter().find(|(i, _)| *i == t.id.0)
|
||||
&& let Some(points) = t.lane(param).map(|l| l.points.as_slice())
|
||||
&& let Some(i) = points.iter().position(|pt| pt.at == *from)
|
||||
{
|
||||
let lane = lane_rect(t, rect(*row), tl);
|
||||
let after = i.checked_sub(1).and_then(|j| points.get(j)).map_or(0, |pt| pt.at.saturating_add(1));
|
||||
let before = points.get(i + 1).map_or(Samples::MAX, |pt| pt.at.saturating_sub(1));
|
||||
let to = snap(&s, at).clamp(after, before.max(after));
|
||||
let value = auto_value(lane, lane_range(t, param), p.y);
|
||||
if to != *from || points.get(i).is_some_and(|pt| (pt.value - value).abs() > 1e-6) {
|
||||
let (key, pid) = (format!("breakpoint:{}", track.0), automation_id(param));
|
||||
if to != *from {
|
||||
let clear = json!({"track": track.0, "param": pid, "start": *from, "end": from.saturating_add(1)});
|
||||
let _ = app.engine.execute_merged("automation.clear", &clear, &key);
|
||||
}
|
||||
let _ =
|
||||
app.engine.execute_merged("automation.set_point", &json!({"track": track.0, "param": pid, "at": to, "value": value}), &key);
|
||||
*from = to;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Gesture::Pencil { track, points }) => {
|
||||
if let Some(t) = s.track(*track)
|
||||
&& let Some(param) = AutoParam::parse(&t.view)
|
||||
@@ -3966,69 +3893,4 @@ mod tests {
|
||||
assert_eq!(points.len(), 1, "status: {}", h.state().ui.status);
|
||||
assert!((points[0].value + 12.0).abs() < 0.01, "{}", points[0].value);
|
||||
}
|
||||
|
||||
/// A volume lane over a clip, with two breakpoints low enough that the Smart tool would grab
|
||||
/// the clip there: the screen position of each, and the clips as they were.
|
||||
fn breakpoints_over_a_clip(tool: &str) -> (Harness<'static, SoundApp>, TrackId, [Pos2; 2], Vec<Clip>) {
|
||||
let mut engine = soundcraft_engine::demo::demo_engine();
|
||||
engine.execute("edit.tool", &json!({"tool": tool})).unwrap();
|
||||
engine.execute("edit.mode", &json!({"mode": "shuffle"})).unwrap();
|
||||
let mut h = harness_with(engine);
|
||||
let tl = timeline(&h);
|
||||
let (clip, _) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 200.0);
|
||||
let track = h.state().engine.session().find_clip(clip.id).unwrap().0;
|
||||
let (xs, xe) = (x_of(h.state().engine.session(), tl, clip.start), x_of(h.state().engine.session(), tl, clip.end()));
|
||||
let xs_at = [xs + (xe - xs) * 0.3, xs + (xe - xs) * 0.6];
|
||||
for x in xs_at {
|
||||
let at = sample_at(h.state().engine.session(), tl, x);
|
||||
h.state_mut().engine.execute("automation.set_point", &json!({"track": track.0, "param": "volume", "at": at, "value": -40.0})).unwrap();
|
||||
}
|
||||
h.state_mut().engine.execute("track.view", &json!({"track": track.0, "view": "volume"})).unwrap();
|
||||
h.run_steps(2);
|
||||
let s = h.state().engine.session_arc();
|
||||
let t = s.track(track).unwrap();
|
||||
let i = h.state().edit_layout.rows.iter().position(|(id, _)| *id == track.0).unwrap();
|
||||
let lane = lane_rect(t, row(&h, i), tl);
|
||||
let y = lane.max.y - 4.0 - (lane.height() - 8.0) * (20.0 / 72.0);
|
||||
assert!(y > lane.center().y, "the breakpoints sit where the Smart tool grabs");
|
||||
let at = t.lane(&AutoParam::Volume).unwrap().points.iter().map(|p| pos2(x_of(&s, tl, p.at), y)).collect::<Vec<_>>();
|
||||
(h, track, [at[0], at[1]], t.clips().to_vec())
|
||||
}
|
||||
|
||||
fn volume_points(h: &Harness<SoundApp>, track: TrackId) -> Vec<(Samples, f32)> {
|
||||
let s = h.state().engine.session();
|
||||
s.track(track).and_then(|t| t.lane(&AutoParam::Volume)).map(|l| l.points.iter().map(|p| (p.at, p.value)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_breakpoint_moves_it_not_the_clip_under_it() {
|
||||
for tool in ["smart", "grabber"] {
|
||||
let (mut h, track, [a, b], clips) = breakpoints_over_a_clip(tool);
|
||||
let before = volume_points(&h, track);
|
||||
let to = b + vec2(-40.0, -15.0);
|
||||
drag(&mut h, b, to);
|
||||
let s = h.state().engine.session_arc();
|
||||
assert_eq!(s.track(track).unwrap().clips(), clips.as_slice(), "{tool}: the clip stays");
|
||||
let points = volume_points(&h, track);
|
||||
assert_eq!(points.len(), 2, "{tool}: {points:?}");
|
||||
assert_eq!(points[0], before[0], "{tool}: the other breakpoint stays");
|
||||
let want = sample_at(&s, timeline(&h), to.x);
|
||||
assert!((points[1].0 - want).abs() as f64 <= s.edit.zoom.samples_per_px, "{tool}: {} vs {want}", points[1].0);
|
||||
assert!(points[1].1 > -40.0 + 1.0, "{tool}: dragged up, {}", points[1].1);
|
||||
assert!(x_of(&s, timeline(&h), points[1].0) > a.x, "{tool}");
|
||||
h.state_mut().engine.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(volume_points(&h, track), before, "{tool}: one undo step");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_on_a_breakpoint_deletes_it() {
|
||||
for tool in ["smart", "grabber", "pencil"] {
|
||||
let (mut h, track, [_, b], clips) = breakpoints_over_a_clip(tool);
|
||||
let before = volume_points(&h, track);
|
||||
click_with(&mut h, b, egui::Modifiers::ALT);
|
||||
assert_eq!(volume_points(&h, track), before[..1], "{tool}");
|
||||
assert_eq!(h.state().engine.session().track(track).unwrap().clips(), clips.as_slice(), "{tool}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,8 @@ const REGULAR: &[(&str, u32)] = &[
|
||||
("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 0),
|
||||
("/usr/share/fonts/TTF/DejaVuSans.ttf", 0),
|
||||
("/usr/share/fonts/dejavu/DejaVuSans.ttf", 0),
|
||||
("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf", 0),
|
||||
("/usr/local/share/fonts/dejavu/DejaVuSans.ttf", 0),
|
||||
("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", 0),
|
||||
("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Regular.ttf", 0),
|
||||
];
|
||||
const BOLD: &[(&str, u32)] = &[
|
||||
("/System/Library/Fonts/HelveticaNeue.ttc", 1),
|
||||
@@ -24,10 +22,8 @@ const BOLD: &[(&str, u32)] = &[
|
||||
("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 0),
|
||||
("/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", 0),
|
||||
("/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", 0),
|
||||
("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf", 0),
|
||||
("/usr/local/share/fonts/dejavu/DejaVuSans-Bold.ttf", 0),
|
||||
("/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", 0),
|
||||
("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf", 0),
|
||||
];
|
||||
/// Hangul fallback, appended after every family so Korean session, track and clip names draw
|
||||
/// whatever the interface language. The Noto Sans CJK collection's face 1 is its Korean face.
|
||||
|
||||
@@ -240,7 +240,6 @@ pub enum Gesture {
|
||||
Fader { track: TrackId, start_db: f32 },
|
||||
ClipGain { clip: soundcraft_model::ClipId, track: TrackId, pos: f32, anchor: (f32, f32), fine: bool, fader_bottom: egui::Pos2 },
|
||||
Pencil { track: TrackId, points: Vec<(Samples, f32)> },
|
||||
Breakpoint { track: TrackId, param: soundcraft_model::AutoParam, at: Samples },
|
||||
MidiClip { track: TrackId, from: Samples, to: Samples },
|
||||
ZoomBox { start: f32 },
|
||||
}
|
||||
@@ -302,8 +301,6 @@ pub struct SoundApp {
|
||||
synthetic_grace: u32,
|
||||
control_rx: Option<Receiver<ControlRequest>>,
|
||||
pending_shots: Vec<control::PendingShot>,
|
||||
/// An insert just picked from its menu, opened once the audio engine has its instance.
|
||||
pub(crate) pending_open: Option<(TrackId, usize)>,
|
||||
last_rev: u64,
|
||||
fonts_ready: bool,
|
||||
fonts_installed: bool,
|
||||
@@ -387,7 +384,6 @@ impl SoundApp {
|
||||
synthetic_grace: 0,
|
||||
control_rx: None,
|
||||
pending_shots: Vec::new(),
|
||||
pending_open: None,
|
||||
last_rev: 0,
|
||||
fonts_ready: false,
|
||||
fonts_installed: false,
|
||||
@@ -621,27 +617,6 @@ impl SoundApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the insert just picked from its menu: a hosted plugin's own editor, else (a built-in
|
||||
/// plugin, no audio engine, or an editor that can't open here) its plugin window.
|
||||
fn open_inserted(&mut self) {
|
||||
let Some((track, slot)) = self.pending_open.take() else { return };
|
||||
let Some(plugin) = self.engine.session().track(track).and_then(|t| t.mixer.inserts.get(slot).cloned().flatten()).map(|i| i.plugin) else {
|
||||
return;
|
||||
};
|
||||
if soundcraft_mix::is_third_party(&plugin)
|
||||
&& let Some(p) = &self.player
|
||||
&& p.has_editor(track, slot)
|
||||
{
|
||||
match p.open_editor(track, slot) {
|
||||
Ok(()) => return,
|
||||
Err(e) => self.ui.status = format!("{}: {e}", mix_window::plugin_info(&plugin).map_or(plugin.as_str(), |i| i.name)),
|
||||
}
|
||||
}
|
||||
if !self.ui.plugin_windows.contains(&(track, slot)) {
|
||||
self.ui.plugin_windows.push((track, slot));
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameter edits made in plugin editors go into the session (one undo step per gesture).
|
||||
fn apply_editor_edits(&mut self) {
|
||||
let Some(p) = &self.player else { return };
|
||||
@@ -1178,7 +1153,6 @@ impl SoundApp {
|
||||
p.update_session(self.engine.session_arc());
|
||||
}
|
||||
}
|
||||
self.open_inserted();
|
||||
self.autosave_changes();
|
||||
self.write_automation(ctx);
|
||||
self.autosave();
|
||||
|
||||
@@ -202,11 +202,6 @@ pub fn tree() -> Vec<MenuNode> {
|
||||
insert_path(&mut roots, line);
|
||||
}
|
||||
insert_path(&mut roots, "Window > Reset Panel Layout");
|
||||
// Not in the observed catalog either; Pro Tools lists it right under Identify Sync Point.
|
||||
if let Some(clip) = roots.iter_mut().find(|n| n.label == "Clip") {
|
||||
let at = clip.children.iter().position(|n| n.label == "Identify Sync Point").map_or(clip.children.len(), |i| i + 1);
|
||||
clip.children.insert(at, MenuNode { label: "Remove Sync Point".into(), path: "Clip > Remove Sync Point".into(), children: Vec::new() });
|
||||
}
|
||||
// Not in the incumbent catalog, but the only UI path to an empty MIDI clip (#282).
|
||||
// It creates a clip rather than acting on the selected ones, so it leads the Clip menu.
|
||||
insert_path(&mut roots, "Clip > New MIDI Clip");
|
||||
@@ -248,9 +243,6 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
|
||||
if c.path == "File > Open" {
|
||||
crate::recent::menu(app, ui);
|
||||
}
|
||||
if c.path == "Track > New..." {
|
||||
instrument_menu(app, ui);
|
||||
}
|
||||
}
|
||||
if root.label == "AudioSuite" {
|
||||
ui.separator();
|
||||
@@ -284,23 +276,6 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Track › Instrument: the instrument picker for the selected instrument track (#374).
|
||||
fn instrument_menu(app: &mut SoundApp, ui: &mut egui::Ui) {
|
||||
match instrument_track(app.engine.session()) {
|
||||
Some(id) => {
|
||||
ui.menu_button("Instrument", |ui| crate::mix_window::instrument_menu(app, ui, id));
|
||||
}
|
||||
None => {
|
||||
ui.add_enabled(false, egui::Button::new("Instrument")).on_disabled_hover_text("Select an instrument track");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first selected instrument track, in track order.
|
||||
fn instrument_track(s: &soundcraft_model::Session) -> Option<soundcraft_model::TrackId> {
|
||||
s.tracks.iter().find(|t| t.kind == soundcraft_model::TrackKind::Instrument && s.edit.selected_tracks.contains(&t.id)).map(|t| t.id)
|
||||
}
|
||||
|
||||
fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str, &str)]) {
|
||||
if !n.children.is_empty() {
|
||||
ui.menu_button(&n.label, |ui| {
|
||||
@@ -745,16 +720,4 @@ mod tests {
|
||||
assert!(!app.engine.is_dirty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_instrument_menu_targets_the_selected_instrument_track() {
|
||||
let mut e = soundcraft_engine::Engine::default();
|
||||
let audio = e.execute("track.new", &json!({"kind": "audio"})).unwrap()["tracks"][0].as_u64().unwrap();
|
||||
assert_eq!(instrument_track(e.session()), None);
|
||||
let inst = e.execute("track.new", &json!({"kind": "instrument"})).unwrap()["tracks"][0].as_u64().unwrap();
|
||||
e.execute("edit.select", &json!({"tracks": [audio]})).unwrap();
|
||||
assert_eq!(instrument_track(e.session()), None, "an audio track has no instrument");
|
||||
e.execute("edit.select", &json!({"tracks": [audio, inst]})).unwrap();
|
||||
assert_eq!(instrument_track(e.session()).map(|t| t.0), Some(inst));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,7 +620,7 @@ pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, oc
|
||||
let search = MenuSearch::show(ui, egui::Id::new(("plugin-search", id.0, slot)), "Search plugins…");
|
||||
if search.typing() {
|
||||
if let Some(plugin) = plugin_search(ui, &search, false) {
|
||||
pick_insert(app, ui, id, slot, &plugin);
|
||||
run_pick(app, ui, "mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -643,13 +643,13 @@ pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, oc
|
||||
ui.menu_button(format!("{c:?}"), |ui| {
|
||||
for p in soundcraft_dsp::plugins().iter().filter(|p| p.category == c && !p.is_instrument) {
|
||||
if ui.button(p.name).clicked() {
|
||||
pick_insert(app, ui, id, slot, p.id);
|
||||
run_pick(app, ui, "mix.insert", json!({"track": id.0, "slot": slot, "plugin": p.id}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(plugin) = hosted_menus(ui, false) {
|
||||
pick_insert(app, ui, id, slot, &plugin);
|
||||
run_pick(app, ui, "mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,16 +707,6 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts the plugin picked in an insert's menu and, as DAWs do, opens it once the audio engine has
|
||||
/// it (`SoundApp::open_inserted`).
|
||||
fn pick_insert(app: &mut SoundApp, ui: &Ui, id: TrackId, slot: usize, plugin: &str) {
|
||||
match app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin})) {
|
||||
Ok(_) => app.pending_open = Some((id, slot)),
|
||||
Err(e) => app.ui.status = e,
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
|
||||
/// Runs a menu's pick, shows its error in the status line and closes the menu.
|
||||
fn run_pick(app: &mut SoundApp, ui: &Ui, cmd: &str, params: serde_json::Value) {
|
||||
if let Err(e) = app.run(cmd, params) {
|
||||
@@ -1247,14 +1237,10 @@ fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, cu
|
||||
ui.separator();
|
||||
if ui.button("new bus...").clicked() {
|
||||
let mut n = busses.len() + 1;
|
||||
while busses.iter().any(|bus| bus.name.eq_ignore_ascii_case(&format!("Bus {n}")))
|
||||
|| app.engine.session().track_by_name(&format!("Bus {n}")).is_some()
|
||||
{
|
||||
while busses.iter().any(|bus| bus.name == format!("Bus {n}")) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() {
|
||||
n += 1;
|
||||
}
|
||||
if app.run("mix.new_bus", json!({"name": format!("Bus {n}")})).is_ok() {
|
||||
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")}));
|
||||
}
|
||||
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,6 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let out = lists.show(ui, |ui| {
|
||||
for (id, name, shown, color, sel) in tracks {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click_and_drag());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, &name));
|
||||
if sel {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
}
|
||||
@@ -95,7 +94,6 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::hover());
|
||||
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, "<ALL>", regular(11.5).clone(), t.text);
|
||||
let all = ui.interact(r, ui.id().with("group_all"), Sense::click());
|
||||
all.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "<ALL>"));
|
||||
if all.double_clicked() {
|
||||
let _ = app.run("edit.select_all", json!({}));
|
||||
}
|
||||
@@ -107,7 +105,6 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
|
||||
});
|
||||
for (id, letter, name, active, color) in groups {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, active, &name));
|
||||
if active {
|
||||
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 4.0, r.min.y + 2.0), vec2(14.0, 13.0)), 1.0, rgb(color));
|
||||
}
|
||||
@@ -136,10 +133,6 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let _ = app.run("track.group_toggle", json!({"group": id}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Modify Group…").clicked() {
|
||||
let _ = app.dialogs.open_modify_group(&app.engine, soundcraft_model::GroupId(id));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Delete Group").clicked() {
|
||||
let _ = app.run("track.ungroup", json!({"group": id}));
|
||||
ui.close();
|
||||
@@ -551,11 +544,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
let _ = resp;
|
||||
let shown = if instrument {
|
||||
crate::sampler_window::format_param(&ins.plugin, p, x)
|
||||
} else {
|
||||
app.player.as_ref().and_then(|pl| pl.editor_value_text(tid, slot, p.id, x)).unwrap_or_else(|| p.format(x))
|
||||
};
|
||||
let shown = if instrument { crate::sampler_window::format_param(&ins.plugin, p, x) } else { p.format(x) };
|
||||
ui.label(egui::RichText::new(shown).font(mono(11.0)));
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
@@ -162,11 +162,8 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
|
||||
let _ = app.run("edit.tool", json!({"tool": tool}));
|
||||
true
|
||||
}
|
||||
// `=` is what a bare press of the unlabeled +/- key reports (it has no shift held, so it
|
||||
// can never be `Key::Plus`); `Key::Plus` is kept for a keyboard that does send it (e.g. a
|
||||
// numpad +). `!m.alt` leaves Alt+= to `window.event_list`'s registered shortcut.
|
||||
Key::Plus | Key::Equals | Key::Minus if !m.command && !m.alt && app.ui.window == MainWindow::Edit => {
|
||||
let dir = if key == Key::Minus { -1 } else { 1 };
|
||||
Key::Plus | Key::Minus if !m.command && app.ui.window == MainWindow::Edit => {
|
||||
let dir = if key == Key::Plus { 1 } else { -1 };
|
||||
let _ = app.run("edit.nudge", json!({"direction": dir}));
|
||||
true
|
||||
}
|
||||
|
||||
@@ -404,9 +404,8 @@ pub fn movie_secs(s: &Session, clip_start: Samples, offset: Samples, at: Samples
|
||||
(at.saturating_sub(clip_start).saturating_add(offset) as f64 - sync) / s.sample_rate.as_f64().max(1.0)
|
||||
}
|
||||
|
||||
/// The picture at `at`: the topmost active Video track with an unmuted clip there. The path is
|
||||
/// the movie's, or why it is offline.
|
||||
pub fn picture_at(s: &Session, at: Samples) -> Option<(MovieKey, Result<String, String>, f64, f64)> {
|
||||
/// The picture at `at`: the topmost active Video track with an unmuted clip there.
|
||||
pub fn picture_at(s: &Session, at: Samples) -> Option<(MovieKey, String, f64, f64)> {
|
||||
for t in s.tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Video && !t.inactive) {
|
||||
for c in t.clips() {
|
||||
if c.muted || !c.range().contains(at) {
|
||||
@@ -415,8 +414,7 @@ pub fn picture_at(s: &Session, at: Samples) -> Option<(MovieKey, Result<String,
|
||||
if let ClipContent::Video { source, offset } = c.content
|
||||
&& let Some(v) = s.video(source)
|
||||
{
|
||||
let path = v.offline.clone().map_or_else(|| Ok(v.path.clone()), Err);
|
||||
return Some((MovieKey::new(source, &v.path), path, movie_secs(s, c.start, offset, at), v.frame_rate));
|
||||
return Some((MovieKey::new(source, &v.path), v.path.clone(), movie_secs(s, c.start, offset, at), v.frame_rate));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,10 +450,6 @@ pub fn draw_lane(app: &mut SoundApp, painter: &Painter, s: &Session, track: &Tra
|
||||
p.text(vis.center(), egui::Align2::CENTER_CENTER, "Video Track Offline", regular(10.0), Color32::from_gray(170));
|
||||
continue;
|
||||
}
|
||||
if let Some(why) = &v.offline {
|
||||
p.text(vis.center(), egui::Align2::CENTER_CENTER, format!("media offline: {why}"), regular(10.0), Color32::from_rgb(220, 120, 120));
|
||||
continue;
|
||||
}
|
||||
let key = MovieKey::new(source, &v.path);
|
||||
if let Some(Err(e)) = app.video.info(key) {
|
||||
p.text(vis.center(), egui::Align2::CENTER_CENTER, format!("media offline: {e}"), regular(10.0), Color32::from_rgb(220, 120, 120));
|
||||
|
||||
@@ -24,7 +24,7 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let s = app.engine.session_arc();
|
||||
let pic = picture_at(&s, pos);
|
||||
let on = online(&s);
|
||||
if on && let Some((key, Ok(path), secs, fps)) = &pic {
|
||||
if on && let Some((key, path, secs, fps)) = &pic {
|
||||
app.video.request_frame(ctx, *key, path, *secs, *fps);
|
||||
}
|
||||
let aspect = pic
|
||||
@@ -39,7 +39,6 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
ui.horizontal(|ui| {
|
||||
crate::widgets::checkbox(ui, &mut burn, "Timecode burn-in");
|
||||
let text = match &pic {
|
||||
Some((_, Err(why), ..)) => why.clone(),
|
||||
Some((k, ..)) => match app.video.info(*k) {
|
||||
Some(Ok(i)) => format!("{}x{} {:.3} fps {}", i.width, i.height, i.frame_rate, i.codec_detail),
|
||||
Some(Err(e)) => e.clone(),
|
||||
@@ -65,7 +64,6 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
} else {
|
||||
match &pic {
|
||||
None => None,
|
||||
Some((_, Err(why), ..)) => Some(why.clone()),
|
||||
Some((k, ..)) => match (app.video.current(*k), app.video.current_error(*k)) {
|
||||
(Some(tex), _) => {
|
||||
painter.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
@@ -132,7 +130,7 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let key = MovieKey::new(source, &v.path);
|
||||
let cr = Rect::from_min_max(pos2(x_at(c.start), r.min.y + 4.0), pos2(x_at(c.end()).max(x_at(c.start) + 2.0), r.max.y - 4.0));
|
||||
painter.rect_filled(cr, 0.0, Color32::from_rgb(30, 30, 34));
|
||||
if !on || v.offline.is_some() {
|
||||
if !on {
|
||||
continue;
|
||||
}
|
||||
let tw = (cr.height() * v.aspect() as f32).max(8.0);
|
||||
|
||||
@@ -11,6 +11,7 @@ use std::path::Path;
|
||||
/// Known gaps: commands with no menu, shortcut or literal reference in the UI source today.
|
||||
/// Give each one a UI path and remove it from this list; don't add to it.
|
||||
const KNOWN_UNREACHED: &[&str] = &[
|
||||
"automation.clear",
|
||||
"edit.paste_clip_effects",
|
||||
"edit.paste_clip_gain",
|
||||
"event.midi_note",
|
||||
@@ -102,21 +103,3 @@ fn new_midi_clip_is_in_the_clip_menu() {
|
||||
let clip = tree.iter().find(|n| n.label == "Clip").unwrap();
|
||||
assert_eq!(clip.children[0].label, "New MIDI Clip", "creates rather than edits, so it comes first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_sync_point_follows_identify_and_is_greyed_out_without_a_point() {
|
||||
let tree = menus::tree();
|
||||
let clip = tree.iter().find(|n| n.label == "Clip").unwrap();
|
||||
let labels: Vec<&str> = clip.children.iter().map(|n| n.label.as_str()).collect();
|
||||
let at = labels.iter().position(|l| *l == "Identify Sync Point").unwrap();
|
||||
assert_eq!(labels.get(at + 1), Some(&"Remove Sync Point"));
|
||||
let path = &clip.children[at + 1].path;
|
||||
let id = menus::command_for_path(path, &menus::ui_aliases());
|
||||
assert_eq!(id.as_deref(), Some("clip.remove_sync_point"));
|
||||
|
||||
let mut app = soundcraft_ui_egui::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, soundcraft_ui_egui::Services::default());
|
||||
app.engine.execute("edit.select", &serde_json::json!({"tracks": ["Kick"], "start": {"seconds": 12.0}})).unwrap();
|
||||
assert!(!menus::item_state(&app, path, id.as_deref()).0, "the selected Kick clip has no sync point yet");
|
||||
app.engine.execute("clip.identify_sync_point", &serde_json::json!({})).unwrap();
|
||||
assert!(menus::item_state(&app, path, id.as_deref()).0);
|
||||
}
|
||||
|
||||
@@ -42,44 +42,3 @@ fn dragging_down_the_group_dialogs_track_list_adds_each_track() {
|
||||
let members: Vec<u64> = session.groups.last().map(|g| g.members.iter().map(|m| m.0).collect()).unwrap_or_default();
|
||||
assert_eq!(members, tracks[..4].iter().map(|t| t.0).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// The Groups list's Modify Group… reopens the creation dialog on an existing group: it comes up
|
||||
/// pre-filled, and OK edits that group in place instead of adding a second one.
|
||||
#[test]
|
||||
fn modify_group_dialog_edits_the_existing_group() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let tracks: Vec<(u64, String)> = app.engine.session().tracks.iter().map(|t| (t.id.0, t.name.clone())).collect();
|
||||
assert!(tracks.len() >= 2);
|
||||
let g = app.run("track.group", json!({"tracks": [tracks[0].0], "name": "Drums"})).unwrap()["group"].as_u64().unwrap();
|
||||
|
||||
assert!(app.dialogs.open_modify_group(&app.engine, soundcraft_model::GroupId(g)));
|
||||
assert_eq!(app.dialogs.open_name(), Some("modify_group"));
|
||||
// Pre-filled from the group, not from the track selection.
|
||||
let Some(soundcraft_ui_egui::dialogs::Dialog::Group { id, name, edit, mix, members }) = &app.dialogs.open else {
|
||||
panic!("Modify Group… did not open the group dialog");
|
||||
};
|
||||
assert_eq!((id.map(|i| i.0), name.as_str(), *edit, *mix), (Some(g), "Drums", true, true));
|
||||
assert_eq!(members.iter().filter(|m| m.2).map(|m| m.0).collect::<Vec<_>>(), vec![tracks[0].0]);
|
||||
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
// Add a second track and submit.
|
||||
h.get_by_role_and_label(Role::CheckBox, &tracks[1].1).click();
|
||||
h.run_steps(2);
|
||||
h.get_by_role_and_label(Role::Button, "OK").click();
|
||||
h.run_steps(2);
|
||||
|
||||
let session = h.state().engine.session();
|
||||
assert_eq!(session.groups.len(), 1, "Modify edits the group in place rather than creating another");
|
||||
let gr = &session.groups[0];
|
||||
assert_eq!(gr.id.0, g);
|
||||
assert_eq!(gr.name, "Drums");
|
||||
assert_eq!(gr.members.iter().map(|m| m.0).collect::<Vec<_>>(), vec![tracks[0].0, tracks[1].0]);
|
||||
}
|
||||
|
||||
@@ -107,9 +107,6 @@ fn picks_without_the_search_still_close_the_menu() {
|
||||
assert_eq!(h.state().engine.session().tracks[0].mixer.inserts[2].as_ref().unwrap().plugin, plugin.id);
|
||||
assert!(h.query_by_label(&category).is_none(), "a plugin from a category closes the menu");
|
||||
}
|
||||
// The picks opened the plugin's window (#486), whose Bypass button would match the menu's.
|
||||
h.state_mut().ui.plugin_windows.clear();
|
||||
h.run_steps(2);
|
||||
|
||||
for (item, bypassed) in [("Bypass", Some(true)), ("no insert", None)] {
|
||||
h.get_by_label("Insert C").click_secondary();
|
||||
@@ -143,7 +140,6 @@ fn send_and_output_menus_search_buses_as_soon_as_they_open() {
|
||||
let mut engine = vox();
|
||||
let vox = engine.session().tracks[0].id;
|
||||
for (slot, bus) in [(0, "Verb"), (1, "Delay")] {
|
||||
engine.execute("mix.new_bus", &json!({"name": bus})).unwrap();
|
||||
engine.execute("mix.send", &json!({"track": vox.0, "slot": slot, "bus": bus})).unwrap();
|
||||
}
|
||||
let mut h = harness(engine);
|
||||
|
||||
@@ -177,7 +177,7 @@ fn populated_mix_instrument_uses_readable_palette_in_real_frames() {
|
||||
harness.step();
|
||||
}
|
||||
harness.step(); // Let the popup finish its initial sizing pass.
|
||||
harness.get_by_label("Built-in ⏵").hover();
|
||||
harness.get_by_label_contains("Built-in").hover();
|
||||
harness.step();
|
||||
harness.step();
|
||||
harness.get_by_label("Drum Synth").click();
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
//! #486: picking a plugin for an insert slot opens it straight away, as DAWs do, from the Mix
|
||||
//! window's slots and the Edit window's Inserts column alike.
|
||||
|
||||
use egui::{Key, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
use soundcraft_model::TrackId;
|
||||
use soundcraft_ui_egui::{MainWindow, Services, SoundApp};
|
||||
|
||||
fn harness(engine: soundcraft_engine::Engine, window: MainWindow) -> Harness<'static, SoundApp> {
|
||||
let mut app = SoundApp::new(engine, None, Services::default());
|
||||
app.ui.window = window;
|
||||
app.run("view.edit_inserts_ae", json!({})).unwrap();
|
||||
app.run("track.height", json!({"tracks": ["Vox"], "height": "medium"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1400.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn vox() -> soundcraft_engine::Engine {
|
||||
let mut engine = soundcraft_engine::Engine::default();
|
||||
engine.execute("track.new", &json!({"name": "Vox"})).unwrap();
|
||||
engine
|
||||
}
|
||||
|
||||
fn vox_id(h: &Harness<SoundApp>) -> TrackId {
|
||||
h.state().engine.session().tracks[0].id
|
||||
}
|
||||
|
||||
/// Two built-in effects from one category, and the category's menu label.
|
||||
fn two_effects() -> (String, &'static soundcraft_dsp::PluginInfo, &'static soundcraft_dsp::PluginInfo) {
|
||||
let first = soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap();
|
||||
let other = soundcraft_dsp::plugins().iter().find(|p| p.category == first.category && p.id != first.id).unwrap();
|
||||
(format!("{:?} ⏵", first.category), first, other)
|
||||
}
|
||||
|
||||
/// Opens `slot`'s menu (a click on an empty slot, a right-click on a filled one) and picks
|
||||
/// `plugin` from its category.
|
||||
fn pick(h: &mut Harness<SoundApp>, slot: &str, filled: bool, category: &str, plugin: &str) {
|
||||
let node = h.get_by_label(slot);
|
||||
if filled {
|
||||
node.click_secondary();
|
||||
} else {
|
||||
node.click();
|
||||
}
|
||||
h.run_steps(3);
|
||||
h.get_by_label(category).hover();
|
||||
h.run_steps(3);
|
||||
h.get_by_label(plugin).click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn inserted(h: &Harness<SoundApp>) -> Option<String> {
|
||||
h.state().engine.session().tracks[0].mixer.inserts[0].as_ref().map(|i| i.plugin.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picking_a_plugin_for_an_empty_insert_opens_it() {
|
||||
let (category, plugin, _) = two_effects();
|
||||
let mut h = harness(vox(), MainWindow::Mix);
|
||||
pick(&mut h, "Insert A", false, &category, plugin.name);
|
||||
assert_eq!(inserted(&h).as_deref(), Some(plugin.id));
|
||||
let id = vox_id(&h);
|
||||
assert_eq!(h.state().ui.plugin_windows, vec![(id, 0)], "the new insert's window opened");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picking_from_the_search_opens_it_too() {
|
||||
let mut h = harness(vox(), MainWindow::Mix);
|
||||
h.get_by_label("Insert A").click();
|
||||
h.run_steps(3);
|
||||
h.event(egui::Event::Text("reverb".into()));
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert!(inserted(&h).is_some(), "Enter inserted a reverb");
|
||||
let id = vox_id(&h);
|
||||
assert_eq!(h.state().ui.plugin_windows, vec![(id, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_filled_insert_opens_the_new_plugin() {
|
||||
let (category, first, second) = two_effects();
|
||||
let mut engine = vox();
|
||||
let track = engine.session().tracks[0].id;
|
||||
engine.execute("mix.insert", &json!({"track": track.0, "slot": 0, "plugin": first.id})).unwrap();
|
||||
let mut h = harness(engine, MainWindow::Mix);
|
||||
assert!(h.state().ui.plugin_windows.is_empty(), "nothing opens before a pick");
|
||||
pick(&mut h, "Insert A", true, &category, second.name);
|
||||
assert_eq!(inserted(&h).as_deref(), Some(second.id));
|
||||
assert_eq!(h.state().ui.plugin_windows, vec![(track, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_edit_windows_inserts_column_opens_it_too() {
|
||||
let (category, plugin, _) = two_effects();
|
||||
let mut h = harness(vox(), MainWindow::Edit);
|
||||
pick(&mut h, "Insert A", false, &category, plugin.name);
|
||||
assert_eq!(inserted(&h).as_deref(), Some(plugin.id));
|
||||
let id = vox_id(&h);
|
||||
assert_eq!(h.state().ui.plugin_windows, vec![(id, 0)]);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::dialogs::Dialog;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, dialogs, menus};
|
||||
use soundcraft_ui_egui::{Services, SoundApp, menus};
|
||||
|
||||
fn harness() -> Harness<'static, SoundApp> {
|
||||
let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
@@ -95,59 +95,3 @@ fn save_in_the_question_saves_before_closing() {
|
||||
assert!(!h.state().engine.session().track_by_name("Kick").unwrap().mixer.mute);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A session the browser read: an empty one, so opening it is easy to see.
|
||||
fn session_bytes() -> Vec<u8> {
|
||||
soundcraft_engine::Engine::default().session().to_json().unwrap().into_bytes()
|
||||
}
|
||||
|
||||
fn open_bytes(h: &mut Harness<'_, SoundApp>) {
|
||||
dialogs::open_session_bytes(h.state_mut(), "Other.scraft".into(), session_bytes());
|
||||
steps(h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_read_in_the_browser_asks_before_replacing_a_modified_one() {
|
||||
let mut h = harness();
|
||||
open_bytes(&mut h);
|
||||
assert!(h.state().dialogs.open.is_none(), "an unmodified session is replaced without asking");
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
|
||||
let mut h = harness();
|
||||
let tracks = h.state().engine.session().tracks.len();
|
||||
mute_kick(&mut h);
|
||||
open_bytes(&mut h);
|
||||
assert_eq!(h.state().dialogs.open_name(), Some("save_changes"));
|
||||
click(&mut h, "Cancel");
|
||||
assert!(h.state().dialogs.open.is_none());
|
||||
assert_eq!(h.state().engine.session().tracks.len(), tracks);
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert!(!h.state().engine.session().track_by_name("Kick").unwrap().mixer.mute, "Cancel keeps the undo history");
|
||||
|
||||
mute_kick(&mut h);
|
||||
open_bytes(&mut h);
|
||||
click(&mut h, "Don't Save");
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_in_the_question_saves_before_opening_a_session_read_in_the_browser() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-save-changes-bytes-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
|
||||
|
||||
let mut h = harness();
|
||||
mute_kick(&mut h);
|
||||
open_bytes(&mut h);
|
||||
click(&mut h, "Save");
|
||||
match &mut h.state_mut().dialogs.open {
|
||||
Some(Dialog::PathPrompt { cmd, path: p, .. }) if cmd == "session.save_as" => p.clone_from(&path),
|
||||
other => panic!("expected Save As, got {other:?}"),
|
||||
}
|
||||
steps(&mut h);
|
||||
click(&mut h, "OK");
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
h.state_mut().run("session.open", json!({"path": path})).unwrap();
|
||||
assert!(h.state().engine.session().track_by_name("Kick").unwrap().mixer.mute);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ fn harness() -> (Harness<'static, SoundApp>, soundcraft_model::TrackId) {
|
||||
engine.execute("track.new", &json!({"name": "Vox"})).unwrap();
|
||||
let vox = engine.session().tracks[0].id;
|
||||
for (slot, bus) in [(0, "Verb"), (1, "Delay")] {
|
||||
engine.execute("mix.new_bus", &json!({"name": bus})).unwrap();
|
||||
engine.execute("mix.send", &json!({"track": vox.0, "slot": slot, "bus": bus, "level_db": -10.0})).unwrap();
|
||||
}
|
||||
let mut app = SoundApp::new(engine, None, Services::default());
|
||||
|
||||
@@ -56,44 +56,6 @@ fn tab_leaves_the_keys_to_the_shortcuts() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_plus_and_minus_nudge_the_selected_clip_in_edit() {
|
||||
// A laptop keyboard has no dedicated "+": typing it needs Shift, which is already claimed by
|
||||
// the Grid-value shortcut (#125 above). So nudge must fire on the bare, unshifted key the "+"
|
||||
// symbol sits on, same as "-" already does; `Key::Plus` stays bound too for a keyboard (e.g. a
|
||||
// numpad) that does send it on its own.
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id;
|
||||
app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let start = |app: &SoundApp| app.engine.session().find_clip(kick).unwrap().1.start;
|
||||
let original = start(&app);
|
||||
|
||||
press(&ctx, &mut app, Key::Minus, Modifiers::NONE);
|
||||
let after_minus = start(&app);
|
||||
assert!(after_minus < original, "bare Minus must nudge left");
|
||||
|
||||
press(&ctx, &mut app, Key::Equals, Modifiers::NONE);
|
||||
assert_eq!(start(&app), original, "bare Equals must nudge back right, same step as Minus");
|
||||
|
||||
press(&ctx, &mut app, Key::Plus, Modifiers::NONE);
|
||||
assert!(start(&app) > original, "Key::Plus (e.g. a numpad +) must still nudge right");
|
||||
|
||||
// Shift+Equals is the pre-existing, separate Grid-value shortcut: it must not also nudge.
|
||||
let before_shift = start(&app);
|
||||
let grid = app.engine.session().edit.grid;
|
||||
press(&ctx, &mut app, Key::Equals, Modifiers::SHIFT);
|
||||
assert_eq!(start(&app), before_shift, "Shift+Equals steps the Grid value, not the clip");
|
||||
assert_ne!(app.engine.session().edit.grid, grid, "Shift+Equals must still step the Grid value");
|
||||
|
||||
// Alt+Equals is the registered `window.event_list` shortcut: it must not also nudge.
|
||||
let before_alt = start(&app);
|
||||
assert!(!app.ui.show_event_list);
|
||||
press(&ctx, &mut app, Key::Equals, Modifiers::ALT);
|
||||
assert_eq!(start(&app), before_alt, "Alt+Equals opens the MIDI Event List, not a nudge");
|
||||
assert!(app.ui.show_event_list, "Alt+Equals must still open the MIDI Event List");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_equals_toggles_mix_and_edit_without_zooming() {
|
||||
let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
//! #651: the Tracks and Groups lists publish their rows to AccessKit, by name, so screen readers
|
||||
//! can read them and tests can reach them by label instead of by geometry.
|
||||
|
||||
use egui::accesskit::{Role, Toggled};
|
||||
use egui::vec2;
|
||||
use egui_kittest::{
|
||||
Harness,
|
||||
kittest::{NodeT, Queryable},
|
||||
};
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
|
||||
fn harness() -> Harness<'static, SoundApp> {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let first = app.engine.session().tracks.first().map(|t| t.id.0).unwrap_or_default();
|
||||
app.run("track.group", json!({"name": "Rhythm", "tracks": [first]})).unwrap();
|
||||
app.engine.session_mut().edit.selected_tracks.clear();
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_list_rows_are_named_and_report_their_selection() {
|
||||
let mut h = harness();
|
||||
let tracks: Vec<(u64, String)> = h.state().engine.session().tracks.iter().map(|t| (t.id.0, t.name.clone())).collect();
|
||||
assert!(tracks.len() >= 2);
|
||||
for (_, name) in &tracks {
|
||||
assert_eq!(h.get_by_role_and_label(Role::Button, name).accesskit_node().toggled(), Some(Toggled::False), "{name}");
|
||||
}
|
||||
let (id, name) = &tracks[1];
|
||||
h.get_by_role_and_label(Role::Button, name).click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().engine.session().edit.selected_tracks.iter().map(|t| t.0).collect::<Vec<_>>(), vec![*id]);
|
||||
assert_eq!(h.get_by_role_and_label(Role::Button, name).accesskit_node().toggled(), Some(Toggled::True));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_list_rows_are_named_and_report_whether_the_group_is_active() {
|
||||
let mut h = harness();
|
||||
h.get_by_label("<ALL>");
|
||||
assert_eq!(h.get_by_role_and_label(Role::CheckBox, "Rhythm").accesskit_node().toggled(), Some(Toggled::True));
|
||||
h.get_by_role_and_label(Role::CheckBox, "Rhythm").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().engine.session().groups.iter().all(|g| !g.active));
|
||||
assert_eq!(h.get_by_role_and_label(Role::CheckBox, "Rhythm").accesskit_node().toggled(), Some(Toggled::False));
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// Regression test for #264: the Edit toolbar clipped its tools in a narrow window; it now scrolls horizontally.
|
||||
use egui::{Event, Pos2, Shape, vec2};
|
||||
use serde_json::json;
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
|
||||
const WIDTH: f32 = 700.0;
|
||||
|
||||
/// The toolbar's tempo text (the last group but one) and the clip rect it is painted in, once painted.
|
||||
fn tempo(h: &egui_kittest::Harness<'_, SoundApp>) -> Option<(egui::Rect, egui::Rect)> {
|
||||
h.output().shapes.iter().find_map(|c| match &c.shape {
|
||||
Shape::Text(t) if t.galley.text() == "120.0000" => Some((t.galley.rect.translate(t.pos.to_vec2()), c.clip_rect)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_narrow_window_scrolls_the_toolbar_to_reach_the_tempo() {
|
||||
let mut engine = soundcraft_engine::Engine::default();
|
||||
engine.execute("track.new", &json!({})).unwrap();
|
||||
let app = SoundApp::new(engine, None, Services::default());
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(WIDTH, 700.0)).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
// Off the right edge, so egui culls it (or paints it beyond the window).
|
||||
assert!(tempo(&h).is_none_or(|(r, _)| r.min.x > WIDTH), "the tempo should start off-screen in a {WIDTH} pt window");
|
||||
let y = 40.0;
|
||||
|
||||
// Wheel over the toolbar (a vertical wheel does not scroll a horizontal-only area, a horizontal one does).
|
||||
for _ in 0..12 {
|
||||
h.event(Event::PointerMoved(Pos2::new(WIDTH / 2.0, y)));
|
||||
h.event(Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(-300.0, 0.0),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
h.step();
|
||||
}
|
||||
let at = tempo(&h);
|
||||
assert!(
|
||||
at.is_some_and(|(r, clip)| r.min.x >= 0.0 && r.max.x <= WIDTH && clip.contains_rect(r)),
|
||||
"scrolling the toolbar must bring the whole tempo into the window, unclipped: (text, clip) = {at:?}"
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,6 @@ pub enum BitError {
|
||||
Eof,
|
||||
/// An Exp-Golomb code longer than 32 leading zeros.
|
||||
InvalidExpGolomb,
|
||||
/// A NAL length-prefix width outside the AVCC/HVCC range of 1..=4 bytes.
|
||||
InvalidLengthSize,
|
||||
}
|
||||
|
||||
impl fmt::Display for BitError {
|
||||
@@ -22,7 +20,6 @@ impl fmt::Display for BitError {
|
||||
match self {
|
||||
BitError::Eof => f.write_str("unexpected end of bitstream"),
|
||||
BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"),
|
||||
BitError::InvalidLengthSize => f.write_str("invalid NAL length size"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,37 +302,24 @@ pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1..=4 bytes) into NAL units.
|
||||
///
|
||||
/// The width comes from the stream's configuration record (`(byte & 3) + 1`), so anything
|
||||
/// outside 1..=4 is a programming or parsing error and is rejected up front: a width of 0
|
||||
/// would never advance the cursor, looping forever and appending empty NALs until the
|
||||
/// process runs out of memory, and a wider prefix cannot occur in a real sample.
|
||||
/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1/2/4 bytes) into NAL units.
|
||||
pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result<Vec<&[u8]>> {
|
||||
if !(1..=4).contains(&length_size) {
|
||||
return Err(BitError::InvalidLengthSize);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
let Some(start) = i.checked_add(length_size) else {
|
||||
return Err(BitError::Eof);
|
||||
};
|
||||
if start > data.len() {
|
||||
if i + length_size > data.len() {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
let mut len = 0usize;
|
||||
for &b in &data[i..start] {
|
||||
len = (len << 8) | b as usize;
|
||||
for k in 0..length_size {
|
||||
len = (len << 8) | data[i + k] as usize;
|
||||
}
|
||||
let Some(end) = start.checked_add(len) else {
|
||||
return Err(BitError::Eof);
|
||||
};
|
||||
if end > data.len() {
|
||||
i += length_size;
|
||||
if i + len > data.len() {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
out.push(&data[start..end]);
|
||||
i = end;
|
||||
out.push(&data[i..i + len]);
|
||||
i += len;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -408,34 +392,6 @@ mod tests {
|
||||
assert_eq!(length_prefixed_nals(&l, 4).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_prefixed_rejects_bad_length_size() {
|
||||
// Regression test for #469: with a width of 0 neither the prefix nor a
|
||||
// zero-length payload advanced the cursor, so any non-empty sample
|
||||
// looped forever appending empty NALs. Widths above 4 cannot come
|
||||
// from an AVCC/HVCC record either.
|
||||
let d = [0, 0, 0, 2, 0x67, 1];
|
||||
assert_eq!(length_prefixed_nals(&d, 0), Err(BitError::InvalidLengthSize));
|
||||
assert_eq!(length_prefixed_nals(&d, 5), Err(BitError::InvalidLengthSize));
|
||||
assert_eq!(length_prefixed_nals(&d, usize::MAX), Err(BitError::InvalidLengthSize));
|
||||
assert_eq!(length_prefixed_nals(&[], 0), Err(BitError::InvalidLengthSize));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_prefixed_widths() {
|
||||
// 1-byte prefixes.
|
||||
assert_eq!(length_prefixed_nals(&[2, 0x67, 1, 1, 0x68], 1).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]);
|
||||
// 2-byte prefixes.
|
||||
assert_eq!(length_prefixed_nals(&[0, 2, 0x67, 1, 0, 1, 0x68], 2).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]);
|
||||
// 3-byte prefixes (lengthSizeMinusOne == 2 in the configuration record).
|
||||
assert_eq!(length_prefixed_nals(&[0, 0, 2, 0x67, 1], 3).unwrap(), vec![&[0x67, 1][..]]);
|
||||
// A truncated prefix or payload is Eof, not a panic or a short read.
|
||||
assert_eq!(length_prefixed_nals(&[0, 0], 4), Err(BitError::Eof));
|
||||
assert_eq!(length_prefixed_nals(&[0, 0, 0, 9, 0x67], 4), Err(BitError::Eof));
|
||||
// Empty input splits to no NALs.
|
||||
assert!(length_prefixed_nals(&[], 4).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_rbsp() {
|
||||
let d = [0b1100_0000];
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::sync::Arc;
|
||||
pub type SharedSource = Arc<dyn ByteSource + Send + Sync>;
|
||||
|
||||
/// Largest picture we decode (bounds every allocation derived from the file).
|
||||
pub(crate) const MAX_PIXELS: u64 = 8192 * 8192;
|
||||
const MAX_PIXELS: u64 = 8192 * 8192;
|
||||
|
||||
/// A decoded picture, packed RGBA (8 bits per channel, `width * height * 4` bytes).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -395,11 +395,6 @@ impl Movie {
|
||||
self.decoded += 1;
|
||||
match self.codec {
|
||||
Codec::ProRes => {
|
||||
let (hdr, ..) = crate::prores::FrameHeader::parse(&data).map_err(|e| VideoError::Decode(e.to_string()))?;
|
||||
let (w, h) = (u32::from(hdr.width), u32::from(hdr.height));
|
||||
if (w, h) != (self.info.width, self.info.height) {
|
||||
return Err(VideoError::Decode(format!("{w}x{h} frame in a {}x{} track", self.info.width, self.info.height)));
|
||||
}
|
||||
let opts = crate::prores::DecodeOptions { bit_depth: Some(8), threads: false };
|
||||
let f = crate::prores::decode_frame_with(&data, &opts).map_err(|e| VideoError::Decode(e.to_string()))?;
|
||||
if u64::from(f.width) * u64::from(f.height) > MAX_PIXELS {
|
||||
|
||||
@@ -109,10 +109,6 @@ pub fn decode_frame_with(data: &[u8], opts: &DecodeOptions) -> Result<Frame> {
|
||||
/// a sequence). On error the contents of `out` are unspecified.
|
||||
pub fn decode_frame_into(data: &[u8], opts: &DecodeOptions, out: &mut Frame) -> Result<()> {
|
||||
let (hdr, mut off, frame_size) = FrameHeader::parse(data)?;
|
||||
// The planes are sized from the header: refuse a size no movie may have before allocating.
|
||||
if u64::from(hdr.width) * u64::from(hdr.height) > crate::movie::MAX_PIXELS {
|
||||
return Err(Error::Unsupported("frame size"));
|
||||
}
|
||||
let data = &data[..frame_size];
|
||||
let depth = opts.bit_depth.unwrap_or(match hdr.chroma {
|
||||
ChromaFormat::Yuv422 => 10,
|
||||
|
||||
@@ -96,28 +96,6 @@ fn prores_and_mjpeg_decode() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 64x48 ProRes frame whose header claims `w`x`h`.
|
||||
fn prores_frame_claiming(w: u16, h: u16) -> Vec<u8> {
|
||||
use crate::prores::{ChromaFormat, Encoder, Frame, Profile};
|
||||
let mut data = Encoder::new(Profile::Hq, 64, 48).encode(&Frame::new(64, 48, ChromaFormat::Yuv422, 10, false)).unwrap();
|
||||
data[16..18].copy_from_slice(&w.to_be_bytes());
|
||||
data[18..20].copy_from_slice(&h.to_be_bytes());
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prores_frame_size_is_checked_before_decoding() {
|
||||
use crate::prores::{DecodeOptions, Error, decode_frame_with};
|
||||
let huge = decode_frame_with(&prores_frame_claiming(65535, 65535), &DecodeOptions::default());
|
||||
assert!(matches!(huge, Err(Error::Unsupported("frame size"))), "{huge:?}");
|
||||
// A frame that differs from the track's declared size is refused, not decoded at its own size.
|
||||
let mut mw = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap();
|
||||
let t = mw.add_track(TrackConfig::new(SampleEntry::prores(crate::isobmff::FourCc(*b"apch"), 64, 48), 25)).unwrap();
|
||||
mw.write_sample(t, WriteSample { data: &prores_frame_claiming(56, 48), duration: 1, composition_offset: 0, is_sync: true }).unwrap();
|
||||
let mut m = Movie::open(mw.finish().unwrap().into_inner()).unwrap();
|
||||
assert!(matches!(m.frame(0), Err(VideoError::Decode(e)) if e.contains("56x48 frame in a 64x48 track")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_codec_opens_but_does_not_decode() {
|
||||
let mut mw = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
|
||||
|
||||
@@ -1788,20 +1788,6 @@ impl EditorLink {
|
||||
v.is_finite().then(|| v.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// The plugin's own text for a normalized value (`getParamStringByValue`). Main thread only
|
||||
/// (`None` elsewhere, and once the instance is gone).
|
||||
pub fn value_text(&self, id: u32, n: f64) -> Option<String> {
|
||||
if !window::on_main_thread() || !n.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let g = self.lock();
|
||||
let c = g.controller.as_ref()?;
|
||||
let mut s: sv::String128 = [0; 128];
|
||||
// SAFETY: valid controller (the instance clears it under this lock before it terminates),
|
||||
// main thread; `s` is a writable String128.
|
||||
ok(unsafe { c.getParamStringByValue(id, n.clamp(0.0, 1.0), &mut s) }).then(|| read_c16(&s)).filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// Shows a value change in the editor (`setParamNormalized`, main thread).
|
||||
pub fn set_normalized(&self, id: u32, n: f64) {
|
||||
if !window::on_main_thread() || !n.is_finite() {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use crate::ffi::{AFTERTOUCH, BusBuffers, EVENT_CAP, EditorLink, HostContext, Instance, NoteEvent, PARAM_QUEUE_CAP, PITCH_BEND};
|
||||
use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport, Unit};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use vst3::ComWrapper;
|
||||
@@ -400,16 +399,7 @@ impl Plugin for Vst3Plugin {
|
||||
|
||||
fn editor(&mut self) -> Option<Box<dyn PluginEditor>> {
|
||||
let link = self.inst.editor()?;
|
||||
let mut units: HashMap<u32, String> = self.inst.params().into_iter().map(|p| (p.id, p.units)).collect();
|
||||
Some(Box::new(Vst3Editor {
|
||||
link,
|
||||
host: self.inst.host_ref(),
|
||||
info: self.info,
|
||||
index: self.info.params.iter().enumerate().map(|(i, p)| (p.id, i)).collect(),
|
||||
units: self.ids.iter().map(|id| units.remove(id).unwrap_or_default()).collect(),
|
||||
ids: self.ids.clone(),
|
||||
maps: self.maps.clone(),
|
||||
}))
|
||||
Some(Box::new(Vst3Editor { link, host: self.inst.host_ref(), info: self.info, ids: self.ids.clone(), maps: self.maps.clone() }))
|
||||
}
|
||||
|
||||
fn state_reader(&mut self) -> Option<Box<dyn soundcraft_dsp::PluginStateReader>> {
|
||||
@@ -433,10 +423,6 @@ struct Vst3Editor {
|
||||
link: Arc<EditorLink>,
|
||||
host: ComWrapper<HostContext>,
|
||||
info: &'static PluginInfo,
|
||||
/// Position of each SoundCraft param id in `info.params` (looked up once per row and frame).
|
||||
index: HashMap<&'static str, usize>,
|
||||
/// The plugin's own unit label of each param (same order as `ids`).
|
||||
units: Vec<String>,
|
||||
ids: Vec<u32>,
|
||||
maps: Vec<Mapping>,
|
||||
}
|
||||
@@ -501,19 +487,4 @@ impl PluginEditor for Vst3Editor {
|
||||
self.link.set_normalized(vid, n);
|
||||
}
|
||||
}
|
||||
|
||||
fn value_text(&mut self, id: &str, value: f32) -> Option<String> {
|
||||
let &i = self.index.get(id)?;
|
||||
let pi = self.info.params.get(i)?;
|
||||
// A plugin whose plain mapping is the identity (JUCE, the SDK's default `Parameter`) leaves
|
||||
// a 0..1 range with the units of its real one: only its own text says what the value is.
|
||||
if !matches!(self.maps.get(i)?, Mapping::Plain) || pi.min != 0.0 || pi.max != 1.0 {
|
||||
return None;
|
||||
}
|
||||
let text = self.link.value_text(*self.ids.get(i)?, self.normalized(i, value)?)?;
|
||||
// VST3 keeps the units apart from the text (JUCE's label, the SDK's `RangeParameter`),
|
||||
// but some plugins put them in both.
|
||||
let units = self.units.get(i).map_or("", |u| u.trim());
|
||||
if units.is_empty() || text.trim_end().to_lowercase().ends_with(&units.to_lowercase()) { Some(text) } else { Some(format!("{text} {units}")) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,16 +23,7 @@ fn main() {
|
||||
ed.set_param("7", 1.5);
|
||||
ed.set_param("nope", 1.0);
|
||||
assert!(ed.idle().is_empty(), "set_param is not reported back as an edit");
|
||||
// #599: an identity-mapped (JUCE-style) parameter shows the plugin's own text, verbatim, with
|
||||
// its units appended once; correctly mapped parameters and choices keep SoundCraft's format.
|
||||
assert_eq!(ed.value_text("13", 0.375).as_deref(), Some("\u{2212}6.0 dB"));
|
||||
assert_eq!(ed.value_text("13", 1.0).as_deref(), Some("24.0 dB"));
|
||||
assert_eq!(ed.value_text("14", 0.25).as_deref(), Some("3.0 dB"), "units already in the text");
|
||||
assert_eq!(ed.value_text("7", 1.5), None);
|
||||
assert_eq!(ed.value_text("3", 1.0), None);
|
||||
assert_eq!(ed.value_text("nope", 0.5), None);
|
||||
drop(p);
|
||||
assert_eq!(ed.value_text("13", 0.375), None, "the plugin is gone");
|
||||
assert!(!ed.is_open());
|
||||
assert!(ed.open().is_err(), "the plugin is gone");
|
||||
println!("editor_main_thread: ok");
|
||||
|
||||
@@ -298,17 +298,12 @@ impl IConnectionPointTrait for GainController {
|
||||
}
|
||||
}
|
||||
|
||||
const GAIN_PARAMS: [(u32, &str, &str, i32, f64, i32); 7] = [
|
||||
const GAIN_PARAMS: [(u32, &str, &str, i32, f64, i32); 5] = [
|
||||
(7, "Gain", "dB", 0, 0.5, ParameterInfo_::ParameterFlags_::kCanAutomate),
|
||||
(9, "Secret", "", 0, 0.0, ParameterInfo_::ParameterFlags_::kIsHidden),
|
||||
(3, "Mode", "", 2, 0.0, ParameterInfo_::ParameterFlags_::kIsList),
|
||||
(11, "Bypass", "", 1, 0.0, ParameterInfo_::ParameterFlags_::kIsBypass),
|
||||
(12, "Freq", "Hz", 0, 0.5, 0),
|
||||
// Like JUCE and the SDK's default `Parameter`: no plain mapping (identity), so the real range
|
||||
// is only in the plugin's own text (-24..+24, the default 0.375 is -6), without the units.
|
||||
(13, "Level", "dB", 0, 0.375, ParameterInfo_::ParameterFlags_::kCanAutomate),
|
||||
// Identity too, but its text already carries the units (0..12 dB).
|
||||
(14, "Drive", "dB", 0, 0.0, ParameterInfo_::ParameterFlags_::kCanAutomate),
|
||||
];
|
||||
|
||||
impl IEditControllerTrait for GainController {
|
||||
@@ -356,8 +351,6 @@ impl IEditControllerTrait for GainController {
|
||||
let s = unsafe { &mut *string };
|
||||
let text = match id {
|
||||
3 => ["A", "B", "C"][((n * 2.0).round() as usize).min(2)].to_string(),
|
||||
13 => format!("{:.1}", 48.0 * n - 24.0).replace('-', "\u{2212}"),
|
||||
14 => format!("{:.1} dB", 12.0 * n),
|
||||
_ => format!("{:.2}", unsafe { self.normalizedParamToPlain(id, n) }),
|
||||
};
|
||||
copy_c16(&text, s);
|
||||
|
||||
@@ -42,7 +42,7 @@ fn info_exposes_visible_params_with_plain_ranges() {
|
||||
assert_eq!(info.id, gain);
|
||||
assert_eq!(info.short_name, "Fixture ");
|
||||
let ids: Vec<&str> = info.params.iter().map(|p| p.id).collect();
|
||||
assert_eq!(ids, vec!["7", "3", "12", "13", "14"], "hidden 9 and bypass 11 are skipped");
|
||||
assert_eq!(ids, vec!["7", "3", "12"], "hidden 9 and bypass 11 are skipped");
|
||||
let g = info.param("7").unwrap();
|
||||
assert_eq!((g.name, g.min, g.max, g.default, g.unit, g.taper), ("Gain", 0.0, 2.0, 1.0, Unit::Db, Taper::Linear));
|
||||
let mode = info.param("3").unwrap();
|
||||
@@ -55,23 +55,6 @@ fn info_exposes_visible_params_with_plain_ranges() {
|
||||
assert!(std::ptr::eq(info, soundcraft_vst3_host::plugin_info(&gain).unwrap()));
|
||||
}
|
||||
|
||||
/// #599: a plugin whose `normalizedParamToPlain` is the identity (JUCE, the SDK's default
|
||||
/// `Parameter`) gives SoundCraft a 0..1 range; its real values are only in its own text, which the
|
||||
/// editor handle reads from the controller on the main thread only (see `editor_main_thread`).
|
||||
#[test]
|
||||
fn identity_mapped_param_text_is_read_on_the_main_thread_only() {
|
||||
let id = id_of("Fixture Gain");
|
||||
let level = soundcraft_vst3_host::plugin_info(&id).unwrap().param("13").unwrap();
|
||||
assert_eq!((level.min, level.max, level.unit), (0.0, 1.0, Unit::Db));
|
||||
let mut p = soundcraft_vst3_host::instantiate_plugin(&id).unwrap();
|
||||
let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle");
|
||||
if cfg!(any(target_os = "macos", windows)) {
|
||||
assert_eq!(ed.value_text("13", 0.375), None, "test threads are not the main thread");
|
||||
} else {
|
||||
assert_eq!(ed.value_text("13", 0.375).as_deref(), Some("\u{2212}6.0 dB"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gain_processes_audio_and_follows_params() {
|
||||
let mut p = soundcraft_vst3_host::create(&id_of("Fixture Gain")).unwrap();
|
||||
|
||||
@@ -15,7 +15,7 @@ input), and every command is reachable from:
|
||||
## Isolated automation profiles
|
||||
|
||||
Set `SOUNDCRAFT_CONFIG_DIR` to a fresh directory to isolate UI preferences, `Autosave`, `Presets`, and `logs`.
|
||||
The nonempty value is used as the complete settings directory, even in portable mode (`portable.txt` beside the executable, which otherwise uses `SoundCraftData` next to it). Unset retains the normal platform paths;
|
||||
The nonempty value is used as the complete settings directory. Unset retains the normal platform paths;
|
||||
an explicit empty value disables the settings directory instead of falling back to a personal profile. Save synthetic sessions to explicit scratch paths. Do not use `SOUNDCRAFT_NO_PREFS`
|
||||
when testing preference persistence across restarts.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (WAV: empty data chunk with trailing metadata (#466); previously minor: continuous fade curvature in SoundCraft sessions) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (continuous fade curvature in SoundCraft sessions) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
|
||||
Every format Pro Tools reads or writes, and ours. The Pro Tools list comes from the document types
|
||||
its bundle declares (`CFBundleDocumentTypes` in Info.plist: names only, nothing else read) and its
|
||||
@@ -33,7 +33,7 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli
|
||||
|
||||
| Format | Pro Tools | Read | Write | Tests / notes |
|
||||
|---|---|---|---|---|
|
||||
| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; also 8-bit and float 64 read. An empty data chunk followed by metadata reads as no audio; an unfinalised recording's audio is still read (#466). |
|
||||
| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; also 8-bit and float 64 read |
|
||||
| RF64 / BW64 (> 4 GiB) | read/write | ✅ | ✅ | `encode_wav_rf64` |
|
||||
| WAV `WAVE_FORMAT_EXTENSIBLE` multichannel with channel mask | read/write | ✅ | ✅ | `encode_with_channel_mask`; default 5.1 import creates a stereo track (#89, PR #128) |
|
||||
| AIFF / AIFF-C | native | ✅ | ✅ (`fl32`) | Native |
|
||||
@@ -80,6 +80,5 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | trivial | WAV: empty data chunk with trailing metadata (#466) |
|
||||
| 2026-10-13 | minor | Fade curvature round-trips in sessions with a zero default for older files. Format version and readiness estimate unchanged |
|
||||
| 2026-10-10 | major | Created: format inventory against Pro Tools 2026.4.1's declared document types and import/bounce options |
|
||||
|
||||
+1
-19
@@ -1,6 +1,6 @@
|
||||
# Where SoundCraft falls short of Pro Tools
|
||||
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (G-04: mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G-01: audio device settings and opt-in ASIO) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing
|
||||
real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker**
|
||||
@@ -80,21 +80,6 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t
|
||||
|
||||
### G-04. Correctness bugs in "done" features
|
||||
|
||||
**Fixed (2026-10-18):** Mono device playback averages the stereo channels before output clipping,
|
||||
**Fixed (2026-10-16):** Master fader volume automation uses the first and last samples of each
|
||||
**Fixed (2026-10-16):** Aux, folder and master latency calculations now skip inserts already
|
||||
render block, matching track faders. A breakpoint at the next block no longer ramps the preceding
|
||||
block, and ramps reach their last-sample value. Regression tests cover nonzero render starts,
|
||||
one-sample callbacks and several block sizes. Interior automation breakpoints remain block-dependent.
|
||||
|
||||
disabled for producing NaN or infinity. Subsequent blocks compensate only for healthy inserts;
|
||||
regression tests verify rendered impulse timing, retained healthy latency and reset/retry at
|
||||
64- and 512-sample blocks, with audio tracks as the control.
|
||||
|
||||
instead of discarding the right side. Surround mixes average both sides of the ITU stereo
|
||||
fold-down. Regression tests cover right-only and left-only audio, equal and opposite-polarity
|
||||
channels, clipping order, centered mono tracks, each 5.1 channel and 1-, 37- and 512-frame callbacks.
|
||||
|
||||
Open on 2026-10-10 (several have PRs open):
|
||||
editing #3, #5, #140, #144; clip/session #86 (PR #123), #88 (PR #130), #91 (PR #124), #92 (PR #132);
|
||||
automation #104 (PR #134); mix/bounce #59 (PR #131), #103 (PR #133), #152; playback #101 (PR #129);
|
||||
@@ -214,9 +199,6 @@ missing. Out of scope unless the owner decides otherwise.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged |
|
||||
| 2026-10-16 | trivial | G-04: master volume automation block boundaries fixed and regression-tested; estimates unchanged |
|
||||
| 2026-10-16 | trivial | G-04: failed aux/folder/master inserts excluded from latency compensation after containment; estimates unchanged |
|
||||
| 2026-10-12 | minor | G-01 shrunk: audio device, rate and buffer choice; opt-in ASIO |
|
||||
| 2026-10-10 | minor | Marked the alpha blockers (G-01, G-02, G-03; parts of G-05 and G-06) from the core-workflow gate |
|
||||
| 2026-10-10 | major | Created from the full re-measure against Pro Tools Ultimate 2026.4.1: 22 ranked gaps with evidence and estimates |
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ files it was given rather than guarding against other software on the computer.
|
||||
|---|---|
|
||||
| `list_commands` | Discover every command (id, label, menu path, shortcut, params doc, enabled). |
|
||||
| `execute` | Run one command with JSON params. |
|
||||
| `batch` | Run up to 1000 commands in order (`keep_going` optional). Larger batches return `isError: true` before executing any command. Any failed call makes the tool result `isError: true`; with `keep_going` the remaining calls still run and every result is listed. |
|
||||
| `batch` | Run several commands in order (`keep_going` optional). Any failed call makes the tool result `isError: true`; with `keep_going` the remaining calls still run and every result is listed. |
|
||||
| `inspect_session` | Verify work: tracks, clips, mixer, plugins, sends, automation, markers, selection; each `sources[]` entry includes `loaded` (decoded audio in the pool). |
|
||||
| `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). `open_session` returns `missing`: the media files that could not be loaded (empty when all are found). `save_session` won't save over another existing file unless `replace` is true. |
|
||||
| `import_audio` | WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF onto a new or existing track. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Plugin parity: built-in processors and plugin hosting
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
|
||||
Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins
|
||||
cover the rest. This doc tracks both. Our plugins have our own names and are written from
|
||||
@@ -21,7 +21,7 @@ scratch (clean-room rule): Avid plugin names are not used here, only categories.
|
||||
| Harmonic | lo-fi, rectifier, amp simulation, saturation, tape | `lofi`, `rectifier`, `saturator` | Amp and cabinet models, tape |
|
||||
| Pitch and time | pitch shift, varispeed effects, time shift | `pitch_shifter`, `time_shift` | Formant control, varispeed effect, pitch correction |
|
||||
| Utility | trim, gain, invert, DC offset, signal generator, dither, time adjuster | `trim`, `gain`, `invert`, `dc_offset_removal`, `signal_generator`, `dither` | Time adjuster, meter/analyzer plugins, noise shaping options |
|
||||
| Instruments | several virtual instruments (multi-sampler, piano, drum machine, organ, synths, sample player; Massive X Player added in 2026.4) | `subtractive_synth`, `drum_synth`; `mix.instrument` takes the instrument's parameters in `params` and refuses unknown fields (#265). The sampler's ping-pong loop keeps its phase when a step is wider than the loop (#463). | Sampler (PR #108 open), piano, organ, wavetable, content library |
|
||||
| Instruments | several virtual instruments (multi-sampler, piano, drum machine, organ, synths, sample player; Massive X Player added in 2026.4) | `subtractive_synth`, `drum_synth`; `mix.instrument` takes the instrument's parameters in `params` and refuses unknown fields (#265) | Sampler (PR #108 open), piano, organ, wavetable, content library |
|
||||
| **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **26 processors + 2 instruments** | |
|
||||
|
||||
AudioSuite: every catalog AudioSuite leaf maps to one of our processes (`AUDIOSUITE` in
|
||||
@@ -58,7 +58,6 @@ User report #152: a saved instrument level does not reach the bounce.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | trivial | Sampler ping-pong loop phase (#463) |
|
||||
| 2026-10-13 | trivial | mix.instrument sets built-in instrument parameters in params (#265) |
|
||||
| 2026-10-12 | minor | Editors: VST3 and embedded CLAP editors on Windows |
|
||||
| 2026-10-10 | major | Created: built-in processor inventory by category and third-party hosting checklist |
|
||||
|
||||
+17
-5
@@ -1,6 +1,6 @@
|
||||
# SoundCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (M8 batch limit validation) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared Windows installer UI; existing folder/shortcut/launch options retained; install and uninstall completion) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
|
||||
Forward-looking plan. Status and numbers: [`../ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the ranked work list: [`gaps.md`](gaps.md).
|
||||
@@ -54,9 +54,6 @@ Status: ✅ done · 🟡 in progress · ⬜ not started.
|
||||
| M16 | Built-in plugin depth, sidechain, ARA | ⬜ | 110–170 |
|
||||
| M17 | Immersive delivery (ADM BWF, object renderer, binaural) and video depth | ⬜ | 80–130 |
|
||||
|
||||
M8 follow-up (2026-10-14): MCP batches above 1000 calls are rejected before execution (#460).
|
||||
The limit is advertised in the tool schema and description; stage and readiness estimates are unchanged.
|
||||
|
||||
## To beta (~350–570 h, about 60–70 % parallelizable)
|
||||
|
||||
Beta needs ~75 % ready for real work and no blocking gap in session interchange.
|
||||
@@ -89,6 +86,21 @@ There is no `cargo xtask scorecard` in this repo; the measured number is `cargo
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | trivial | M8: reject oversized MCP batches before execution (#460) |
|
||||
| 2026-10-10 | minor | Added the alpha gate (3 of 6 core workflows fail → pre-alpha) |
|
||||
| 2026-10-10 | major | Created from ROADMAP.md's milestones and alpha list; added M12–M17 and the beta plan with estimates |
|
||||
|
||||
## Packaging update — 2026-10-10
|
||||
|
||||
Windows setup now uses the shared `craft-installer` native UI source, with install/repair/remove
|
||||
completion, cancellation and failure screens. Existing MSI ownership and upgrade identities are
|
||||
preserved; unattended modes remain unattended. Source/generator checks pass; native Windows
|
||||
installer acceptance is still pending. See [setup validation](../packaging/windows/README.md).
|
||||
|
||||
## Packaging update (2026-10-11)
|
||||
|
||||
Windows setup now uses the suite's shared `craft-installer` UI, preserving existing folder,
|
||||
shortcut and launch options and app-owned installation policy. Install, uninstall and repair
|
||||
have explicit completion screens; cancellation and failure have separate endings. Source/profile
|
||||
checks, WiX UI library compilation for x64/x86/ARM64, PowerShell parsing and package identity
|
||||
comparisons pass. Full MSI linking and interactive Windows acceptance remain pending; see
|
||||
[Windows packaging](../packaging/windows/README.md).
|
||||
|
||||
+1
-3
@@ -1,6 +1,6 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (drag across checkboxes, #399; waveforms preview fades during drag; a plugin opens when inserted, #486) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (drag across checkboxes, #399; waveforms preview fades during drag) · **Target:** Avid Pro Tools Ultimate 2026.4.1
|
||||
|
||||
How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts,
|
||||
windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command
|
||||
@@ -51,7 +51,6 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
|
||||
| Preferences | 🟡 | Many settings stored, fewer honoured |
|
||||
| Window configurations, Tile/Cascade | 🟡 | Configurations ✅; Tile/Cascade in PR #151 |
|
||||
| Plugin windows | 🟡 | Generic parameter sliders; Pro Tools plugins have bespoke GUIs (#138 asks for skeuomorphic design); multi-page plugin views (#6) |
|
||||
| A plugin opens when it's inserted | ✅ | #486; a hosted plugin's own editor, else its plugin window |
|
||||
| Command palette (Search) | ✅ | Over every command |
|
||||
|
||||
## Look and meters
|
||||
@@ -69,7 +68,6 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | A plugin opens when it's inserted (#486) |
|
||||
| 2026-10-13 | minor | Drag across checkboxes (#399) |
|
||||
| 2026-10-13 | minor | Waveforms and fade curves share temporary envelopes during drag, without rebuilding media or editing the document before release. Readiness estimate unchanged |
|
||||
| 2026-10-13 | minor | Fade curves use bounded physical-pixel sampling; midpoint handles stay visible and inside the clip, with repeat-drag coverage after extreme bends. Readiness estimate unchanged |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user