mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-06 10:20:26 -06:00
Compare commits
14
Commits
release
...
house-rules
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bf178835f | ||
|
|
e66a5e7c67 | ||
|
|
87f46cf23b | ||
|
|
54cb247e12 | ||
|
|
73eeb692fe | ||
|
|
bd75fe691b | ||
|
|
6c8a9dd300 | ||
|
|
d393730b02 | ||
|
|
4240ffda42 | ||
|
|
c046c3bb1b | ||
|
|
e6cbf76148 | ||
|
|
1db9563379 | ||
|
|
93ac4f2913 | ||
|
|
352d90a24a |
@@ -0,0 +1,28 @@
|
||||
name: House rules
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: house-rules-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
house-rules:
|
||||
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
|
||||
with:
|
||||
rust: true
|
||||
@@ -1,11 +1,62 @@
|
||||
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
|
||||
## House rules
|
||||
|
||||
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
|
||||
|
||||
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
|
||||
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
|
||||
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
|
||||
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
|
||||
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
|
||||
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
|
||||
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
|
||||
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
|
||||
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
|
||||
10. **Keep secrets and personal details out** of the repository, logs and requests.
|
||||
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
|
||||
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
|
||||
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
|
||||
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
|
||||
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
|
||||
<!-- craft-house-rules:end -->
|
||||
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
|
||||
## Rust Craft repositories
|
||||
|
||||
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
|
||||
|
||||
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
|
||||
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
|
||||
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
|
||||
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
|
||||
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
|
||||
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
|
||||
|
||||
## Licence allowlist
|
||||
|
||||
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
|
||||
|
||||
- `ISC`
|
||||
- `MIT`
|
||||
- `BSD-2-Clause`
|
||||
- `BSD-3-Clause`
|
||||
- `Apache-2.0`
|
||||
- `OFL-1.1`
|
||||
- `Ubuntu-font-1.0`
|
||||
- `Bitstream-Vera`
|
||||
- `CC0-1.0`
|
||||
- `CC-BY-4.0`
|
||||
- `CC-BY-SA-4.0`
|
||||
- `LicenseRef-Public-Domain`
|
||||
|
||||
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
|
||||
<!-- craft-house-rules:end -->
|
||||
|
||||
# SoundCraft — instructions for agents
|
||||
|
||||
SoundCraft is a clean-room, open-source, pure-Rust digital audio workstation targeting Avid Pro Tools parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed).
|
||||
2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs are `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them.
|
||||
3. Work autonomously; don't stop to ask unless a decision is genuinely the user's.
|
||||
Read `ROADMAP.md` for stage, status and the parity numbers, and pick work from `docs/gaps.md`. The progress docs are `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them. `plan/` is an optional gitignored notebook (`plan/protools/` holds an observed menu tree and screenshots, never committed). Do not ask for it and do not stop when it is absent.
|
||||
|
||||
## ⚠️ Assets: the absolute rule
|
||||
**No Avid, Pro Tools, Adobe or other proprietary iconography, images, sounds, presets or artwork — ever.** This is not negotiable.
|
||||
@@ -30,7 +81,6 @@ People trust SoundCraft with their recordings; a crash loses takes. **This outra
|
||||
- The audio callback never blocks, allocates in steady state, or panics.
|
||||
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
- `Engine::execute` catches escaped panics and restores the document. Every crash fix lands with a regression test.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
|
||||
|
||||
## Architecture (layers enforced by `cargo xtask layers`)
|
||||
@@ -89,3 +139,5 @@ Push to the `release` branch to build signed installers for macOS (universal DMG
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
New workspace crates include `[lints] workspace = true` in `Cargo.toml`. Production crate roots declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
|
||||
|
||||
@@ -1,10 +1 @@
|
||||
Read `AGENTS.md`: it is the entry point for agents in this repo.
|
||||
|
||||
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
|
||||
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
|
||||
before modules or other crate items; crate-level `//!` documentation may precede them.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
@AGENTS.md
|
||||
|
||||
Generated
+34
-5
@@ -1422,6 +1422,19 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"itertools 0.15.0",
|
||||
"log",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
@@ -1475,6 +1488,26 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -4490,6 +4523,7 @@ dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"log",
|
||||
@@ -6510,11 +6544,6 @@ name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
|
||||
@@ -60,6 +60,8 @@ craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-l
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
|
||||
egui = { version = "0.37" }
|
||||
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
# Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent).
|
||||
web-time = "1.1"
|
||||
|
||||
@@ -228,7 +228,8 @@ soundcraft-cli mcp --automation-read-root DIR --automation-write-root DIR
|
||||
|
||||
See [`docs/cli.md`](docs/cli.md) for the command-line tool (subcommands, exit codes, environment
|
||||
variables, man pages and completions), [`docs/control-protocol.md`](docs/control-protocol.md) and
|
||||
[`docs/mcp.md`](docs/mcp.md). `soundcraft-cli commands` lists every command.
|
||||
[`docs/mcp.md`](docs/mcp.md). `soundcraft-cli commands` lists every command (`--json` for each
|
||||
one's parameters and details, as JSON).
|
||||
|
||||
Open **SoundCraft › Session Audio Health** to check loaded audio availability, sample-rate
|
||||
mismatches and clip source bounds across all playlists, including alternate takes. The report
|
||||
|
||||
@@ -168,6 +168,12 @@ pub(crate) struct CommandsArgs {
|
||||
/// Only the commands whose id, or label in any case, contains this text
|
||||
#[arg(value_parser = NonEmptyStringValueParser::new())]
|
||||
pub(crate) filter: Option<String>,
|
||||
/// Print a JSON array of each command's details, as describe prints them
|
||||
#[arg(long)]
|
||||
pub(crate) json: bool,
|
||||
/// One line of JSON instead of pretty-printed
|
||||
#[arg(long, requires = "json")]
|
||||
pub(crate) compact: bool,
|
||||
}
|
||||
|
||||
/// Print one engine command's details as JSON
|
||||
|
||||
@@ -7,11 +7,15 @@ use std::io::Write;
|
||||
pub(crate) fn commands(args: &CommandsArgs, out: &mut dyn Write) {
|
||||
let filter = args.filter.clone().unwrap_or_default();
|
||||
let e = Engine::default();
|
||||
for c in soundcraft_engine::command_specs() {
|
||||
if !filter.is_empty() && !c.id.contains(&filter) && !c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
let i = c.info(&e);
|
||||
let infos = soundcraft_engine::command_specs()
|
||||
.iter()
|
||||
.filter(|c| filter.is_empty() || c.id.contains(&filter) || c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()))
|
||||
.map(|c| c.info(&e));
|
||||
if args.json {
|
||||
print_json(out, &infos.collect::<Vec<_>>(), args.compact);
|
||||
return;
|
||||
}
|
||||
for i in infos {
|
||||
outln!(out, "{:<36} {:<34} {}", i.id, i.label, i.menu.join(" > "));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,3 +253,31 @@ fn script_bounce_without_range_covers_the_session() {
|
||||
assert!((secs - 19.2).abs() < 0.01, "bounced {secs} s: {stdout}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `commands --json` lists every command with the details `describe` prints for one (#744).
|
||||
#[test]
|
||||
fn commands_json_lists_what_describe_prints() {
|
||||
let json = |o: &Output| -> Value {
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
serde_json::from_slice(&o.stdout).unwrap()
|
||||
};
|
||||
let all = json(&run(&["commands", "--json"]));
|
||||
let all = all.as_array().unwrap();
|
||||
let table = run(&["commands"]);
|
||||
assert_eq!(all.len(), String::from_utf8_lossy(&table.stdout).lines().count());
|
||||
let bounce = json(&run(&["describe", "file.bounce_mix"]));
|
||||
assert!(all.contains(&bounce), "{bounce}");
|
||||
assert!(all.iter().all(|c| c["id"].is_string() && c["params"].is_string() && c["menu"].is_array()));
|
||||
// The filter applies, and --compact prints one line.
|
||||
let compact = run(&["commands", "bounce", "--json", "--compact"]);
|
||||
assert_eq!(String::from_utf8_lossy(&compact.stdout).lines().count(), 1);
|
||||
let some = json(&compact);
|
||||
let some = some.as_array().unwrap();
|
||||
assert!(some.contains(&bounce) && some.len() < all.len(), "{some:?}");
|
||||
// No match is an empty list, still JSON.
|
||||
assert_eq!(json(&run(&["commands", "zzzzqq", "--json"])), Value::Array(Vec::new()));
|
||||
// --compact shapes JSON output only.
|
||||
let bad = run(&["commands", "--compact"]);
|
||||
assert_eq!(bad.status.code(), Some(2));
|
||||
assert!(String::from_utf8_lossy(&bad.stderr).contains("--json"), "{}", String::from_utf8_lossy(&bad.stderr));
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ fn every_subcommand_has_help_listing_its_arguments() {
|
||||
),
|
||||
("run", "run [OPTIONS]", &["--cmd <ID[=JSON]>"]),
|
||||
("script", "script [OPTIONS] <FILE>", &["<FILE>", "--keep-going"]),
|
||||
("commands", "commands [FILTER]", &["[FILTER]"]),
|
||||
("commands", "commands [OPTIONS] [FILTER]", &["[FILTER]", "--json", "--compact"]),
|
||||
("describe", "describe [OPTIONS] <ID>", &["<ID>", "--compact"]),
|
||||
(
|
||||
"app",
|
||||
|
||||
@@ -33,6 +33,7 @@ impl NativeMenu {
|
||||
let mut native = Self { menu, entries: Vec::new(), events, last_sync: None };
|
||||
let app_menu = Submenu::new("SoundCraft", true);
|
||||
native.add_item(app, &app_menu, "About SoundCraft", "", Some("window.about".into()))?;
|
||||
native.add_item(app, &app_menu, "Preferences...", "", Some("setup.preferences".into()))?;
|
||||
native.add_item(app, &app_menu, "Session Info", "", Some("window.session_info".into()))?;
|
||||
native.add_item(app, &app_menu, "Session Audio Health", "", Some("window.audio_health".into()))?;
|
||||
app_menu.append(&PredefinedMenuItem::separator())?;
|
||||
@@ -94,8 +95,8 @@ impl NativeMenu {
|
||||
&& let Some(command) = &entry.command
|
||||
&& menus::item_state(app, &entry.path, Some(command)).0
|
||||
{
|
||||
if entry.path.is_empty() && command.starts_with("window.") {
|
||||
// The application menu's About / Session Info open their window (never close it).
|
||||
if entry.path.is_empty() && (command.starts_with("window.") || command == "setup.preferences") {
|
||||
// Application-menu window entries open their window rather than toggling it.
|
||||
let _ = app.run(command, serde_json::json!({"value": true}));
|
||||
} else {
|
||||
menus::invoke_menu(app, command, &entry.path);
|
||||
|
||||
@@ -17,9 +17,11 @@ pub(crate) fn on_main_thread() -> bool {
|
||||
unsafe { pthread_main_np() == 1 }
|
||||
}
|
||||
|
||||
/// True on the process's main thread: Rust names it "main", and the app runs its UI there (the
|
||||
/// rule `hostwin` and the VST3 host use). CLAP makes `clap.gui` main-thread only on every platform.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(crate) fn on_main_thread() -> bool {
|
||||
true
|
||||
std::thread::current().name() == Some("main")
|
||||
}
|
||||
|
||||
/// The windowing API of this platform.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
mod common;
|
||||
|
||||
use soundcraft_dsp::Plugin;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const GAIN: &str = "clap:org.soundcraft.test.gain";
|
||||
|
||||
@@ -14,12 +15,31 @@ fn block(p: &mut Box<dyn Plugin>) -> Vec<Vec<f32>> {
|
||||
io
|
||||
}
|
||||
|
||||
/// The warnings logged, to tell which way the host went.
|
||||
static WARNINGS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
|
||||
struct Warnings;
|
||||
|
||||
impl log::Log for Warnings {
|
||||
fn enabled(&self, m: &log::Metadata) -> bool {
|
||||
m.level() <= log::Level::Warn
|
||||
}
|
||||
fn log(&self, r: &log::Record) {
|
||||
if self.enabled(r.metadata()) {
|
||||
WARNINGS.lock().unwrap().push(r.args().to_string());
|
||||
}
|
||||
}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// nextest lists custom-harness tests with `--list`; each binary here is one test.
|
||||
if std::env::args().any(|a| a == "--list") {
|
||||
println!("editor_main_thread: test");
|
||||
return;
|
||||
}
|
||||
log::set_logger(&Warnings).expect("logger");
|
||||
log::set_max_level(log::LevelFilter::Warn);
|
||||
common::fixture_dir();
|
||||
let mut p = soundcraft_clap_host::create(GAIN).expect("fixture");
|
||||
p.prepare(48_000.0, 64, 2);
|
||||
@@ -32,6 +52,26 @@ fn main() {
|
||||
// block and is reported (in SoundCraft units) by idle.
|
||||
let io = block(&mut p);
|
||||
assert!((io[0][10] - 0.25).abs() < 1e-6, "{}", io[0][10]);
|
||||
// Only the main thread drives the editor, on every platform (#901). Another thread, named or
|
||||
// not, is refused: it neither takes the edit idle reports nor closes or reopens the editor.
|
||||
for name in [None, Some("soundcraft-ui")] {
|
||||
let mut other = p.editor().expect("editor handle");
|
||||
let thread = match name {
|
||||
Some(n) => std::thread::Builder::new().name(n.to_string()),
|
||||
None => std::thread::Builder::new(),
|
||||
};
|
||||
let seen = thread
|
||||
.spawn(move || {
|
||||
let edits = other.idle();
|
||||
other.close();
|
||||
(edits, other.open().map_err(|e| e.contains("main thread")))
|
||||
})
|
||||
.expect("spawn")
|
||||
.join()
|
||||
.expect("no panic");
|
||||
assert_eq!(seen, (Vec::new(), Err(true)), "{name:?}");
|
||||
assert!(ed.is_open(), "{name:?}");
|
||||
}
|
||||
assert_eq!(ed.idle(), vec![("7".to_string(), 0.25)]);
|
||||
assert!(ed.idle().is_empty(), "drained");
|
||||
ed.close();
|
||||
@@ -49,5 +89,17 @@ fn main() {
|
||||
assert!(ed.open().is_err());
|
||||
assert!(ed.idle().is_empty());
|
||||
ed.close();
|
||||
// #901: a plugin dropped on another thread with its editor open (an audio callback dropped
|
||||
// by the device's thread) does not destroy the editor there, clap.gui being main-thread only:
|
||||
// it warns and leaves it.
|
||||
let mut p = soundcraft_clap_host::create(GAIN).expect("fixture");
|
||||
p.prepare(48_000.0, 64, 2);
|
||||
let mut ed = p.editor().expect("editor handle");
|
||||
ed.open().expect("open on the main thread");
|
||||
std::thread::Builder::new().name("audio".into()).spawn(move || drop(p)).expect("spawn").join().expect("no panic");
|
||||
let left = "clap: editor still open while its plugin is destroyed off the main thread";
|
||||
assert!(WARNINGS.lock().unwrap().iter().any(|w| w == left), "{:?}", WARNINGS.lock().unwrap());
|
||||
assert!(!ed.is_open());
|
||||
assert!(ed.open().is_err());
|
||||
println!("editor_main_thread: ok");
|
||||
}
|
||||
|
||||
@@ -102,6 +102,8 @@ struct Fx {
|
||||
active: bool,
|
||||
processing: bool,
|
||||
gui_created: bool,
|
||||
/// The thread that created the GUI (the host's main thread).
|
||||
gui_thread: Option<std::thread::ThreadId>,
|
||||
gui_visible: bool,
|
||||
gui_edit_pending: bool,
|
||||
/// `clap.render` set the offline mode (wide fixtures only).
|
||||
@@ -130,7 +132,10 @@ unsafe extern "C" fn init(p: *const clap_plugin) -> bool {
|
||||
}
|
||||
|
||||
unsafe extern "C" fn destroy(p: *const clap_plugin) {
|
||||
assert!(!unsafe { fx(p) }.gui_created, "plugin destroyed with its GUI alive");
|
||||
let f = unsafe { fx(p) };
|
||||
// A host destroying the plugin off the GUI's thread can't destroy the GUI there (clap.gui is
|
||||
// main-thread only), so it may leave it; on the GUI's thread it must destroy it first.
|
||||
assert!(!f.gui_created || f.gui_thread != Some(std::thread::current().id()), "plugin destroyed with its GUI alive");
|
||||
let data = unsafe { (*p).plugin_data } as *mut Fx;
|
||||
drop(unsafe { Box::from_raw(data) });
|
||||
}
|
||||
@@ -492,6 +497,7 @@ unsafe extern "C" fn gui_create(p: *const clap_plugin, _api: *const c_char, floa
|
||||
let f = unsafe { fx(p) };
|
||||
assert!(floating && !f.gui_created, "create");
|
||||
f.gui_created = true;
|
||||
f.gui_thread = Some(std::thread::current().id());
|
||||
true
|
||||
}
|
||||
|
||||
@@ -638,6 +644,7 @@ unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const
|
||||
active: false,
|
||||
processing: false,
|
||||
gui_created: false,
|
||||
gui_thread: None,
|
||||
gui_visible: false,
|
||||
gui_edit_pending: false,
|
||||
offline: false,
|
||||
|
||||
@@ -215,14 +215,12 @@ fn state_reader_reads_while_another_thread_processes() {
|
||||
#[test]
|
||||
fn editors_refuse_to_open_off_the_main_thread() {
|
||||
fixture_dir();
|
||||
// Test threads are never the process main thread.
|
||||
// Test threads are never the process main thread, on any platform (#901).
|
||||
let mut p = soundcraft_clap_host::create(GAIN).unwrap();
|
||||
let mut ed = p.editor().expect("the gain fixture has clap.gui");
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(ed.open().unwrap_err().contains("main thread"));
|
||||
assert!(p.open_editor().is_err());
|
||||
assert!(!ed.is_open());
|
||||
}
|
||||
assert!(ed.open().unwrap_err().contains("main thread"));
|
||||
assert!(p.open_editor().is_err());
|
||||
assert!(!ed.is_open());
|
||||
ed.close();
|
||||
p.close_editor();
|
||||
}
|
||||
|
||||
+117
-10
@@ -215,6 +215,8 @@ impl Autosave {
|
||||
// Copies made in this write, by the audio they hold: sources sharing one decoded buffer
|
||||
// (a session may name one file under many ids) share one file.
|
||||
let mut written: BTreeMap<*const SourceAudio, String> = BTreeMap::new();
|
||||
// Copies still to make, written once the size is checked; their numbers are taken then too.
|
||||
let (mut fresh, mut next) = (Vec::new(), self.next);
|
||||
for src in &mut s.sources {
|
||||
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);
|
||||
@@ -225,15 +227,13 @@ impl Autosave {
|
||||
continue;
|
||||
}
|
||||
let Some(audio) = audio else { continue };
|
||||
let name = match self.copies.remove(&src.id) {
|
||||
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name,
|
||||
let name = match self.copies.get(&src.id) {
|
||||
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name.clone(),
|
||||
_ if written.contains_key(&Arc::as_ptr(audio)) => written.get(&Arc::as_ptr(audio)).cloned().unwrap_or_default(),
|
||||
_ => {
|
||||
let name = format!("{} {}.wav", sanitize_name(&src.name), self.next);
|
||||
self.next += 1;
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
create_folder(&audio_dir).and_then(|()| write_synced(&audio_dir.join(&name), &bytes)).map_err(|err| io_error(&audio_dir, err))?;
|
||||
let name = format!("{} {}.wav", sanitize_name(&src.name), next);
|
||||
next += 1;
|
||||
fresh.push((name.clone(), Arc::clone(audio)));
|
||||
name
|
||||
}
|
||||
};
|
||||
@@ -243,6 +243,15 @@ impl Autosave {
|
||||
copies.insert(src.id, (Arc::downgrade(audio), name));
|
||||
}
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
// An autosave too large to recover is not written, nor its new copies: the last autosave
|
||||
// stays as it was, and the failure is reported (#892).
|
||||
crate::io::session_size("the autosave", text.len() as u64, true)?;
|
||||
self.next = next;
|
||||
for (name, audio) in &fresh {
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
create_folder(&audio_dir).and_then(|()| write_synced(&audio_dir.join(name), &bytes)).map_err(|err| io_error(&audio_dir, err))?;
|
||||
}
|
||||
write_atomically(&self.dir.join(ORIGIN), origin.unwrap_or_default().as_bytes())?;
|
||||
write_atomically(&self.dir.join(SESSION), text.as_bytes())?;
|
||||
for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() {
|
||||
@@ -297,9 +306,7 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
|
||||
drop(lock);
|
||||
match saved {
|
||||
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());
|
||||
let name = saved_name(&dir.join(SESSION)).unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned());
|
||||
out.push(Leftover { dir, name, origin, saved, recording });
|
||||
}
|
||||
_ => {
|
||||
@@ -311,6 +318,21 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The name of the session saved in `file`, read without keeping the rest of it; `None` when the
|
||||
/// file is missing, not a session, or too large to open (#892).
|
||||
fn saved_name(file: &Path) -> Option<String> {
|
||||
use std::io::Read as _;
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Named {
|
||||
name: String,
|
||||
}
|
||||
if std::fs::metadata(file).ok()?.len() > crate::io::session_limit() {
|
||||
return None;
|
||||
}
|
||||
let read = std::fs::File::open(file).ok()?.take(crate::io::session_limit());
|
||||
serde_json::from_reader::<_, Named>(std::io::BufReader::new(read)).ok().map(|n| n.name)
|
||||
}
|
||||
|
||||
/// Open `left` as an unsaved version of the session it came from, so Save writes it back there,
|
||||
/// with the takes of a recording that never finished placed as Stop would have. Returns the
|
||||
/// media that couldn't be loaded.
|
||||
@@ -458,6 +480,27 @@ mod tests {
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
|
||||
|
||||
/// Recovery loads document edits without re-enabling live inputs from an autosave.
|
||||
#[test]
|
||||
fn recovered_live_input_states_start_disabled() {
|
||||
let root = folder("live-input-recovery");
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
let track = e.session().tracks[0].id;
|
||||
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": true})).unwrap();
|
||||
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -6.0})).unwrap();
|
||||
let mut autosave = Autosave::create(&root, "Live inputs").unwrap();
|
||||
autosave.write(e.session(), None, None).unwrap();
|
||||
drop(autosave);
|
||||
let mut recovered = Engine::default();
|
||||
recover(&mut recovered, &leftovers(&root)[0]).unwrap();
|
||||
let mixer = &recovered.session().track(track).unwrap().mixer;
|
||||
assert!(!mixer.record_arm && !mixer.input_monitor);
|
||||
assert_eq!(mixer.volume_db, -6.0);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
fn folder(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -699,6 +742,70 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A test folder, removed when the test ends, passed or not.
|
||||
struct Gone(PathBuf);
|
||||
|
||||
impl Drop for Gone {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_autosave_too_large_to_open_writes_nothing_and_keeps_the_last() {
|
||||
// #892, with the limit lowered for this test: a refused autosave makes no audio copies
|
||||
// and keeps the last autosave and its copies as they were.
|
||||
let dir = Gone(folder("autosave-too-large"));
|
||||
let mut e = Engine::default();
|
||||
record(&mut e, 0.5);
|
||||
let mut a = Autosave::create(&dir.0.join("Autosave"), "Song").unwrap();
|
||||
a.write(e.session(), None, None).unwrap();
|
||||
let state = |a: &Autosave| {
|
||||
let mut files: Vec<String> =
|
||||
std::fs::read_dir(a.dir.join("Audio Files")).unwrap().map(|f| f.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
files.sort();
|
||||
let copies: Vec<(SourceId, String)> = a.copies.iter().map(|(id, (_, name))| (*id, name.clone())).collect();
|
||||
(files, copies, a.next, std::fs::read_to_string(a.dir.join(SESSION)).unwrap())
|
||||
};
|
||||
let before = state(&a);
|
||||
record(&mut e, 0.25);
|
||||
{
|
||||
let _limit = crate::io::SessionLimit::set(before.3.len() as u64);
|
||||
for _ in 0..2 {
|
||||
let err = a.write(e.session(), None, None).unwrap_err().to_string();
|
||||
assert!(err.starts_with("the autosave is too large to save"), "{err}");
|
||||
assert!(state(&a) == before);
|
||||
}
|
||||
// The writer thread reports it as it reports any failed autosave.
|
||||
let failure = Arc::new(Mutex::new(None));
|
||||
let pending = Some((e.session_arc(), None, None));
|
||||
let mut w = Writer { root: dir.0.join("Writer"), failure: Arc::clone(&failure), folder: None, pending, last: None, written: Ok(()) };
|
||||
w.write_pending();
|
||||
let reported = failure.lock().unwrap().take().unwrap_or_default();
|
||||
assert!(reported.starts_with("the autosave is too large to save"), "{reported}");
|
||||
}
|
||||
a.write(e.session(), None, None).unwrap();
|
||||
let after = state(&a);
|
||||
assert_eq!((after.0.len(), after.1.len()), (2, 2));
|
||||
assert!(after.0.contains(&before.0[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leftover_is_named_from_its_session_without_reading_a_huge_one() {
|
||||
// #892: the name is read on its own, and an autosave too large to open is not read.
|
||||
let dir = Gone(folder("autosave-names"));
|
||||
std::fs::create_dir_all(&dir.0).unwrap();
|
||||
let file = dir.0.join(SESSION);
|
||||
let s = Session { name: "Song".into(), ..Session::default() };
|
||||
std::fs::write(&file, s.to_json().unwrap()).unwrap();
|
||||
assert_eq!(saved_name(&file).as_deref(), Some("Song"));
|
||||
std::fs::write(&file, r#"{"name": 5}"#).unwrap();
|
||||
assert_eq!(saved_name(&file), None);
|
||||
std::fs::File::create(&file).unwrap().set_len(crate::io::MAX_SESSION_BYTES + 1).unwrap();
|
||||
assert_eq!(saved_name(&file), None);
|
||||
assert_eq!(saved_name(&dir.0.join("none")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_started_together_get_folders_of_their_own() {
|
||||
// #579: the folder name has millisecond resolution, so two sessions created back to back
|
||||
|
||||
@@ -45,6 +45,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.sources.clear();
|
||||
s.videos.clear();
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
// A template too large to open is not saved (#892).
|
||||
crate::io::session_size(file.display(), text.len() as u64, true)?;
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
crate::io::write_file(&file.to_string_lossy(), text.as_bytes())?;
|
||||
} else {
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::{Engine, EngineError, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{AutoParam, AutomationMode, INSERT_SLOTS, Insert, SEND_SLOTS, SendSlot, Session, TrackId};
|
||||
|
||||
/// Commands for mixer controls, with live input toggles kept outside document history.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("mix.volume", "Set Volume", [], None, "{tracks?, db: -144..12 | delta_db}", has_selection, volume),
|
||||
@@ -42,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!("mix.record_arm", "Record Enable", [], Some("Shift+R"), "{tracks?, value?: bool}", has_selection, |e, p| toggle(
|
||||
cmd!(noundo "mix.record_arm", "Record Enable", [], Some("Shift+R"), "{tracks?, value?: bool}", has_selection, |e, p| toggle_runtime(
|
||||
e,
|
||||
p,
|
||||
"mix.record_arm",
|
||||
@@ -51,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
|tr| tr.mixer.record_arm
|
||||
)),
|
||||
cmd!("mix.input_monitor", "Input Monitoring", [], Some("Shift+I"), "{tracks?, value?: bool}", has_selection, |e, p| toggle(
|
||||
cmd!(noundo "mix.input_monitor", "Input Monitoring", [], Some("Shift+I"), "{tracks?, value?: bool}", has_selection, |e, p| toggle_runtime(
|
||||
e,
|
||||
p,
|
||||
"mix.input_monitor",
|
||||
@@ -218,6 +219,23 @@ fn toggle(e: &mut Engine, p: &Value, cmd: &str, set: fn(&mut Session, TrackId, b
|
||||
Ok(json!({"value": v}))
|
||||
}
|
||||
|
||||
/// Toggle live input flags without marking the saved document as changed.
|
||||
fn toggle_runtime(
|
||||
e: &mut Engine,
|
||||
p: &Value,
|
||||
cmd: &str,
|
||||
set: fn(&mut Session, TrackId, bool),
|
||||
get: fn(&soundcraft_model::Track) -> bool,
|
||||
) -> Result<Value> {
|
||||
let tracks = tracks_required(e, cmd, p)?;
|
||||
let s = e.runtime_session_mut();
|
||||
let v = p.get("value").and_then(Value::as_bool).unwrap_or_else(|| !tracks.iter().all(|t| s.track(*t).is_some_and(get)));
|
||||
for t in &tracks {
|
||||
set(s, *t, v);
|
||||
}
|
||||
Ok(json!({"value": v}))
|
||||
}
|
||||
|
||||
fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "mix.volume", p)?;
|
||||
let abs = p.get("db").and_then(Value::as_f64);
|
||||
|
||||
@@ -378,3 +378,109 @@ fn clip_gain_to_volume_with_a_large_envelope_stays_fast() {
|
||||
let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap();
|
||||
assert!(lane.points.len() as i64 >= n);
|
||||
}
|
||||
|
||||
/// Live input toggles stay outside document history in both enabled and disabled states.
|
||||
#[test]
|
||||
fn live_input_states_are_transient_and_survive_document_history() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
let track = e.session().tracks[0].id;
|
||||
e.mark_clean();
|
||||
let history = e.undo_history();
|
||||
|
||||
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
|
||||
e.execute("track.set_record_to_input_only", &json!({})).unwrap();
|
||||
let mixer = &e.session().track(track).unwrap().mixer;
|
||||
assert!(mixer.record_arm && mixer.input_monitor);
|
||||
assert!(!e.is_dirty(), "live input state is not a document change");
|
||||
assert_eq!(e.undo_history(), history, "live input state has no Undo entry");
|
||||
|
||||
// A later document edit captures a full Session snapshot. Undoing or redoing it must not
|
||||
// resurrect the live state that happened to be present when that snapshot was taken.
|
||||
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -6.0})).unwrap();
|
||||
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": false})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": false})).unwrap();
|
||||
assert!(e.is_dirty());
|
||||
|
||||
assert!(e.undo());
|
||||
let mixer = &e.session().track(track).unwrap().mixer;
|
||||
assert!(!mixer.record_arm && !mixer.input_monitor);
|
||||
assert!(!e.is_dirty(), "undo returned to the saved document");
|
||||
|
||||
assert!(e.redo());
|
||||
let mixer = &e.session().track(track).unwrap().mixer;
|
||||
assert!(!mixer.record_arm && !mixer.input_monitor);
|
||||
assert!(e.is_dirty());
|
||||
|
||||
// Live states that are enabled after another document edit survive both directions too.
|
||||
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -12.0})).unwrap();
|
||||
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": true})).unwrap();
|
||||
|
||||
assert!(e.undo());
|
||||
let mixer = &e.session().track(track).unwrap().mixer;
|
||||
assert!(mixer.record_arm && mixer.input_monitor);
|
||||
|
||||
assert!(e.redo());
|
||||
let mixer = &e.session().track(track).unwrap().mixer;
|
||||
assert!(mixer.record_arm && mixer.input_monitor);
|
||||
}
|
||||
|
||||
/// Returning to a saved point stays clean with independently enabled live flags on each track.
|
||||
#[test]
|
||||
fn live_input_states_keep_saved_history_points_clean() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 2})).unwrap();
|
||||
let (a, b) = (e.session().tracks[0].id, e.session().tracks[1].id);
|
||||
e.mark_clean();
|
||||
e.execute("mix.volume", &json!({"tracks": [a.0], "db": -6.0})).unwrap();
|
||||
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": true})).unwrap();
|
||||
assert!(e.undo());
|
||||
assert!(!e.is_dirty(), "live flags cannot dirty the saved point reached by Undo");
|
||||
for redo in [true, false, true] {
|
||||
assert!(if redo { e.redo() } else { e.undo() });
|
||||
let a = &e.session().track(a).unwrap().mixer;
|
||||
let b = &e.session().track(b).unwrap().mixer;
|
||||
assert!(a.record_arm && !a.input_monitor);
|
||||
assert!(!b.record_arm && b.input_monitor);
|
||||
assert_eq!(e.is_dirty(), redo);
|
||||
}
|
||||
e.mark_clean();
|
||||
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": false})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": false})).unwrap();
|
||||
assert!(!e.is_dirty());
|
||||
assert!(e.undo());
|
||||
assert!(e.is_dirty());
|
||||
assert!(e.redo());
|
||||
assert!(!e.is_dirty(), "Redo to a saved armed snapshot stays clean after disarming");
|
||||
}
|
||||
|
||||
/// Restoring a missing track never resurrects its historical arm or monitoring flags.
|
||||
#[test]
|
||||
fn live_input_states_of_restored_tracks_start_disabled() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 2})).unwrap();
|
||||
let (a, b) = (e.session().tracks[0].id, e.session().tracks[1].id);
|
||||
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": true})).unwrap();
|
||||
e.execute("track.delete", &json!({"tracks": [a.0]})).unwrap();
|
||||
assert!(e.undo());
|
||||
let mixer = &e.session().track(a).unwrap().mixer;
|
||||
assert!(!mixer.record_arm && !mixer.input_monitor);
|
||||
assert!(e.session().track(b).unwrap().mixer.input_monitor);
|
||||
assert!(e.redo());
|
||||
assert!(e.undo());
|
||||
assert!(!e.session().track(a).unwrap().mixer.record_arm);
|
||||
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
let c = e.session().tracks.last().unwrap().id;
|
||||
e.execute("mix.record_arm", &json!({"tracks": [c.0], "value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [c.0], "value": true})).unwrap();
|
||||
assert!(e.undo());
|
||||
assert!(e.session().track(c).is_none());
|
||||
assert!(e.redo());
|
||||
let mixer = &e.session().track(c).unwrap().mixer;
|
||||
assert!(!mixer.record_arm && !mixer.input_monitor);
|
||||
assert!(e.session().track(b).unwrap().mixer.input_monitor);
|
||||
}
|
||||
|
||||
@@ -472,7 +472,7 @@ fn midi_inputs(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "file.import_session_data";
|
||||
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 text_v = crate::io::read_session_text(&path)?;
|
||||
let other = Session::from_json(&text_v).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
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());
|
||||
|
||||
@@ -100,8 +100,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("track.bounce", "Bounce...", ["Track"], Some("Cmd+Alt+Shift+B"), "{tracks?}", has_selection, commit),
|
||||
cmd!("track.bypass_inserts", "All", ["Track", "Bypass Inserts"], None, "{tracks?, bypass?: bool}", has_selection, bypass_inserts),
|
||||
cmd!("track.mute_sends", "All", ["Track", "Mute Sends"], None, "{tracks?, mute?: bool}", has_selection, mute_sends),
|
||||
cmd!("track.set_record_to_input_only", "Set Record Tracks to Input Only", ["Track"], Some("Alt+K"), "{}", always, |e, _| {
|
||||
let s = e.session_mut();
|
||||
cmd!(noundo "track.set_record_to_input_only", "Set Record Tracks to Input Only", ["Track"], Some("Alt+K"), "{}", always, |e, _| {
|
||||
let s = e.runtime_session_mut();
|
||||
let mut n = 0;
|
||||
for t in &mut s.tracks {
|
||||
if t.mixer.record_arm {
|
||||
|
||||
@@ -267,6 +267,7 @@ fn save_preset(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"path": path, "track": first}))
|
||||
}
|
||||
|
||||
/// Load persistent mixer settings while retaining destination routing and live input flags.
|
||||
fn load_preset(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "track.load_preset";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(id, "`path` required"))?.to_string();
|
||||
@@ -283,15 +284,17 @@ fn load_preset(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
// Routing is session-specific: keep the track's own input/output, VCA and arm state.
|
||||
let keep = (tr.mixer.input.clone(), tr.mixer.output.clone(), tr.mixer.vca, tr.mixer.record_arm, tr.mixer.pan.clone());
|
||||
// Routing and live input state are track-specific: a preset must not replace them.
|
||||
let keep =
|
||||
(tr.mixer.input.clone(), tr.mixer.output.clone(), tr.mixer.vca, tr.mixer.record_arm, tr.mixer.input_monitor, tr.mixer.pan.clone());
|
||||
tr.mixer = mixer.clone();
|
||||
tr.mixer.input = keep.0;
|
||||
tr.mixer.output = keep.1;
|
||||
tr.mixer.vca = keep.2;
|
||||
tr.mixer.record_arm = keep.3;
|
||||
if tr.mixer.pan.len() != keep.4.len() {
|
||||
tr.mixer.pan = keep.4;
|
||||
tr.mixer.input_monitor = keep.4;
|
||||
if tr.mixer.pan.len() != keep.5.len() {
|
||||
tr.mixer.pan = keep.5;
|
||||
}
|
||||
if tr.kind == TrackKind::Instrument && instrument.is_some() {
|
||||
tr.instrument = instrument.clone();
|
||||
@@ -602,6 +605,7 @@ mod tests {
|
||||
assert_eq!(e.session().track(pad).unwrap().folder, Some(verb));
|
||||
}
|
||||
|
||||
/// A preset transfers persistent mix settings without replacing live destination states.
|
||||
#[test]
|
||||
fn preset_round_trip() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-preset-{}", std::process::id()));
|
||||
@@ -611,10 +615,13 @@ mod tests {
|
||||
e.execute("edit.select", &json!({"tracks": ["Bass"]})).unwrap();
|
||||
e.execute("track.save_preset", &json!({"path": path})).unwrap();
|
||||
e.execute("edit.select", &json!({"tracks": ["Hats"]})).unwrap();
|
||||
e.execute("mix.record_arm", &json!({"value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"value": true})).unwrap();
|
||||
e.execute("track.load_preset", &json!({"path": path})).unwrap();
|
||||
let hats = e.session().track_by_name("Hats").unwrap();
|
||||
assert_eq!(hats.mixer.inserts[0].as_ref().map(|i| i.plugin.as_str()), Some("compressor"));
|
||||
assert_eq!(hats.mixer.volume_db, -5.0);
|
||||
assert!(hats.mixer.record_arm && hats.mixer.input_monitor, "loading a preset keeps live input state");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
+110
-5
@@ -1,12 +1,15 @@
|
||||
//! Borrowed document equality for the saved-state indicator.
|
||||
|
||||
use soundcraft_model::{EditState, Session};
|
||||
use soundcraft_model::{EditState, Mixer, Session, Track};
|
||||
|
||||
/// Equal apart from the selection, playhead and zoom, without copying document data.
|
||||
/// Equal apart from navigation and live input flags, without copying document data.
|
||||
pub(super) fn same_document(saved: &Session, doc: &Session) -> bool {
|
||||
document_values(saved, &saved.edit) == document_values(doc, &saved.edit)
|
||||
&& saved.tracks.len() == doc.tracks.len()
|
||||
&& saved.tracks.iter().zip(&doc.tracks).all(|(saved, doc)| track_values(saved) == track_values(doc))
|
||||
}
|
||||
|
||||
/// Borrow persistent session fields; track fields are compared separately.
|
||||
fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl PartialEq + 'a {
|
||||
// No `..`: adding a document field must also decide how it affects saved-state equality.
|
||||
let Session {
|
||||
@@ -18,7 +21,7 @@ fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl Partia
|
||||
timecode_start,
|
||||
tempo,
|
||||
key_signatures,
|
||||
tracks,
|
||||
tracks: _,
|
||||
sources,
|
||||
videos,
|
||||
busses,
|
||||
@@ -35,11 +38,66 @@ fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl Partia
|
||||
} = s;
|
||||
// Tuples implement PartialEq up to twelve elements. These contain only borrowed fields.
|
||||
(
|
||||
(format_version, name, sample_rate, bit_depth, frame_rate, timecode_start, tempo, key_signatures, tracks, sources, videos, busses),
|
||||
(format_version, name, sample_rate, bit_depth, frame_rate, timecode_start, tempo, key_signatures, sources, videos, busses),
|
||||
(outputs, markers, groups, auto_names, edit_values(edit, navigation), next_id, comments, midi_originals, pool, folder),
|
||||
)
|
||||
}
|
||||
|
||||
/// Borrow persistent track fields without allocating or copying media.
|
||||
fn track_values(track: &Track) -> impl PartialEq + '_ {
|
||||
let Track {
|
||||
id,
|
||||
name,
|
||||
kind,
|
||||
format,
|
||||
color,
|
||||
height,
|
||||
playlists,
|
||||
active_playlist,
|
||||
mixer,
|
||||
automation,
|
||||
view,
|
||||
hidden,
|
||||
inactive,
|
||||
frozen,
|
||||
comments,
|
||||
folder,
|
||||
folder_open,
|
||||
instrument,
|
||||
ticks_timebase,
|
||||
elastic,
|
||||
pinned,
|
||||
} = track;
|
||||
(
|
||||
(id, name, kind, format, color, height, playlists, active_playlist, mixer_values(mixer), automation, view),
|
||||
(hidden, inactive, frozen, comments, folder, folder_open, instrument, ticks_timebase, elastic, pinned),
|
||||
)
|
||||
}
|
||||
|
||||
/// Borrow persistent mixer fields without allocating or copying media.
|
||||
fn mixer_values(mixer: &Mixer) -> impl PartialEq + '_ {
|
||||
let Mixer {
|
||||
volume_db,
|
||||
pan,
|
||||
mute,
|
||||
solo,
|
||||
solo_safe,
|
||||
record_arm: _,
|
||||
input_monitor: _,
|
||||
phase_invert,
|
||||
input,
|
||||
output,
|
||||
inserts,
|
||||
sends,
|
||||
automation_mode,
|
||||
trim_db,
|
||||
vca,
|
||||
surround,
|
||||
} = mixer;
|
||||
((volume_db, pan, mute, solo, solo_safe, phase_invert, input, output, inserts, sends, automation_mode), (trim_db, vca, surround))
|
||||
}
|
||||
|
||||
/// Borrow edit settings while normalizing transient navigation to the saved values.
|
||||
fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl PartialEq + 'a {
|
||||
let EditState {
|
||||
tool,
|
||||
@@ -154,7 +212,7 @@ mod tests {
|
||||
use soundcraft_time::{Range, TimeFormat};
|
||||
use std::sync::Arc;
|
||||
|
||||
// The previous comparison is an oracle for preserving the saved-state policy.
|
||||
/// The clone-based policy, extended to ignore live input flags, serves as a test oracle.
|
||||
fn previous_same_document(saved: &Session, doc: &Session) -> bool {
|
||||
let mut doc = doc.clone();
|
||||
let (from, to) = (&saved.edit, &mut doc.edit);
|
||||
@@ -164,9 +222,14 @@ mod tests {
|
||||
to.selected_clips.clone_from(&from.selected_clips);
|
||||
to.playhead = from.playhead;
|
||||
to.zoom = from.zoom;
|
||||
for (saved, doc) in saved.tracks.iter().zip(&mut doc.tracks) {
|
||||
doc.mixer.record_arm = saved.mixer.record_arm;
|
||||
doc.mixer.input_monitor = saved.mixer.input_monitor;
|
||||
}
|
||||
*saved == doc
|
||||
}
|
||||
|
||||
/// Check both directions and compare against the normalized clone oracle.
|
||||
fn check(saved: &Session, doc: &Session, expected: bool, field: &str) {
|
||||
assert_eq!(same_document(saved, doc), expected, "{field}");
|
||||
assert_eq!(same_document(doc, saved), expected, "{field}, reversed");
|
||||
@@ -268,6 +331,48 @@ mod tests {
|
||||
assert_eq!(same_document(&doc, &doc), previous_same_document(&doc, &doc), "NaN keeps the previous PartialEq behavior");
|
||||
}
|
||||
|
||||
/// Only arm and monitoring are transient; every other mixer field still contributes to dirtiness.
|
||||
#[test]
|
||||
fn live_flags_do_not_hide_persistent_mixer_changes() {
|
||||
use soundcraft_model::{AutomationMode, BusId, Insert, Route, SendSlot, SurroundPan};
|
||||
let mut saved = Session::default();
|
||||
let track = saved.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let mut live = saved.clone();
|
||||
live.track_mut(track).unwrap().mixer.record_arm = true;
|
||||
live.track_mut(track).unwrap().mixer.input_monitor = true;
|
||||
check(&saved, &live, true, "live input flags");
|
||||
let edits: &[(&str, fn(&mut Mixer))] = &[
|
||||
("volume", |m| m.volume_db = -6.0),
|
||||
("pan", |m| m.pan[0] = 0.5),
|
||||
("mute", |m| m.mute = true),
|
||||
("solo", |m| m.solo = true),
|
||||
("solo_safe", |m| m.solo_safe = true),
|
||||
("phase_invert", |m| m.phase_invert = true),
|
||||
("input", |m| m.input = Route::Hardware("Mic".into())),
|
||||
("output", |m| m.output = Route::Bus(BusId(900))),
|
||||
("inserts", |m| m.inserts[0] = Some(Insert::new("eq7"))),
|
||||
("sends", |m| m.sends[0] = Some(SendSlot::new(Route::Main))),
|
||||
("automation_mode", |m| m.automation_mode = AutomationMode::Touch),
|
||||
("trim_db", |m| m.trim_db = -3.0),
|
||||
("vca", |m| m.vca = Some(900)),
|
||||
("surround", |m| m.surround = Some(SurroundPan::default())),
|
||||
];
|
||||
for &(field, edit) in edits {
|
||||
let mut doc = live.clone();
|
||||
edit(&mut doc.track_mut(track).unwrap().mixer);
|
||||
check(&saved, &doc, false, field);
|
||||
}
|
||||
let mut doc = live.clone();
|
||||
doc.tracks.push(doc.tracks[0].clone());
|
||||
check(&saved, &doc, false, "track count");
|
||||
let mut doc = live.clone();
|
||||
doc.tracks[0].id = TrackId(900);
|
||||
check(&saved, &doc, false, "track identity");
|
||||
let mut doc = live.clone();
|
||||
doc.tracks[0].mixer.volume_db = f32::NAN;
|
||||
assert!(!same_document(&doc, &doc), "persistent NaN keeps PartialEq semantics");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_saved_zoom_keeps_the_previous_partial_eq_behavior() {
|
||||
let mut saved = Session::default();
|
||||
|
||||
+156
-18
@@ -42,19 +42,122 @@ pub use strip::{StripMode, StripNames, StripScan, bounded_to_track, name_strippe
|
||||
/// one. Only a regular file qualifies: a device or a pipe named by a session or a command would
|
||||
/// never end or never answer. Errors name the file.
|
||||
pub fn read_file(path: impl AsRef<Path>) -> Result<Vec<u8>> {
|
||||
let path = path.as_ref();
|
||||
read_file_within(path.as_ref(), false)
|
||||
}
|
||||
|
||||
/// [`read_file`]; a `session` file is refused ([`session_size`]) past [`session_limit`].
|
||||
fn read_file_within(path: &Path, session: bool) -> Result<Vec<u8>> {
|
||||
use std::io::Read as _;
|
||||
crate::file_access::check_read(path).map_err(EngineError::Io)?;
|
||||
let err = |err: std::io::Error| EngineError::Io(format!("{}: {err}", path.display()));
|
||||
if !std::fs::metadata(path).map_err(err)?.is_file() {
|
||||
let meta = std::fs::metadata(path).map_err(err)?;
|
||||
if !meta.is_file() {
|
||||
return Err(EngineError::Io(format!("{}: not a regular file", path.display())));
|
||||
}
|
||||
std::fs::read(path).map_err(err)
|
||||
if !session {
|
||||
return std::fs::read(path).map_err(err);
|
||||
}
|
||||
session_size(path.display(), meta.len(), false)?;
|
||||
// The file may have grown since: never read more than one byte past the limit.
|
||||
let mut data = Vec::new();
|
||||
std::fs::File::open(path).map_err(err)?.take(session_limit().saturating_add(1)).read_to_end(&mut data).map_err(err)?;
|
||||
session_size(path.display(), data.len() as u64, false)?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// [`read_file`] as UTF-8 text.
|
||||
pub fn read_text(path: impl AsRef<Path>) -> Result<String> {
|
||||
let path = path.as_ref();
|
||||
String::from_utf8(read_file(path)?).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
|
||||
utf8(path, read_file(path)?)
|
||||
}
|
||||
|
||||
fn utf8(path: &Path, bytes: Vec<u8>) -> Result<String> {
|
||||
String::from_utf8(bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
|
||||
}
|
||||
|
||||
/// The largest session (`.scraft`) SoundCraft opens or saves. A session is read whole and parsed
|
||||
/// in one go, so a larger one is refused by its size before it is read, and never written (it
|
||||
/// could not be opened again); real sessions are far smaller.
|
||||
pub const MAX_SESSION_BYTES: u64 = 256 << 20;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A smaller limit than [`MAX_SESSION_BYTES`] for the tests on this thread ([`SessionLimit`]).
|
||||
static TEST_SESSION_LIMIT: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// [`MAX_SESSION_BYTES`] (in tests, the limit a [`SessionLimit`] set on this thread).
|
||||
pub(crate) fn session_limit() -> u64 {
|
||||
#[cfg(test)]
|
||||
if let Some(limit) = TEST_SESSION_LIMIT.with(std::cell::Cell::get) {
|
||||
return limit;
|
||||
}
|
||||
MAX_SESSION_BYTES
|
||||
}
|
||||
|
||||
/// While it lives, sessions on this thread are opened and saved up to the limit given to
|
||||
/// [`SessionLimit::set`] instead of [`MAX_SESSION_BYTES`], so tests reach it with small sessions.
|
||||
#[cfg(test)]
|
||||
pub(crate) struct SessionLimit;
|
||||
|
||||
#[cfg(test)]
|
||||
impl SessionLimit {
|
||||
pub(crate) fn set(limit: u64) -> SessionLimit {
|
||||
TEST_SESSION_LIMIT.with(|l| l.set(Some(limit)));
|
||||
SessionLimit
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for SessionLimit {
|
||||
fn drop(&mut self) {
|
||||
TEST_SESSION_LIMIT.with(|l| l.set(None));
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses the session `name` of `len` bytes when it is larger than [`session_limit`]: too large
|
||||
/// to open, or, when `saving`, to save.
|
||||
pub(crate) fn session_size(name: impl std::fmt::Display, len: u64, saving: bool) -> Result<()> {
|
||||
let limit = session_limit();
|
||||
if len <= limit {
|
||||
return Ok(());
|
||||
}
|
||||
let most = if limit >= 1 << 20 { format!("{} MiB", limit >> 20) } else { format!("{limit} bytes") };
|
||||
Err(EngineError::Io(if saving {
|
||||
format!("{name} is too large to save: over {most}, more than SoundCraft opens")
|
||||
} else {
|
||||
format!("{name} is too large to open (over {most})")
|
||||
}))
|
||||
}
|
||||
|
||||
/// [`read_text`] for a session file: refused, unread, past [`session_limit`].
|
||||
pub(crate) fn read_session_text(path: impl AsRef<Path>) -> Result<String> {
|
||||
let path = path.as_ref();
|
||||
utf8(path, read_file_within(path, true)?)
|
||||
}
|
||||
|
||||
/// A bare `.scraft` handed over as bytes (the browser) as text: refused, uncopied, past
|
||||
/// [`session_limit`].
|
||||
pub(crate) fn session_bytes_text(name: &str, bytes: &[u8]) -> Result<String> {
|
||||
session_size(name, bytes.len() as u64, false)?;
|
||||
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))
|
||||
}
|
||||
|
||||
/// How many bytes [`Session::to_json`] makes of `s`, counted without keeping them.
|
||||
fn json_len(s: &Session) -> u64 {
|
||||
struct Count(u64);
|
||||
impl std::io::Write for Count {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.0 = self.0.saturating_add(buf.len() as u64);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut n = Count(0);
|
||||
// A session that cannot be written counts as nothing here: `to_json` reports that error.
|
||||
serde_json::to_writer_pretty(&mut n, s).map_or(0, |()| n.0)
|
||||
}
|
||||
|
||||
/// Is there a file at `path` the automation read root lets us see? A path outside the root is
|
||||
@@ -497,7 +600,6 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
let mut s = session.clone();
|
||||
let from = s.folder.clone();
|
||||
let same_dir = from.as_deref().is_some_and(|f| same_path(f, &dir));
|
||||
let mut written = 0;
|
||||
// A file can stay put for all the sources that name it, unless their audio differs.
|
||||
let shared_paths: Vec<bool> = {
|
||||
let mut audio_by_path: HashMap<&str, HashSet<Option<*const SourceAudio>>> = HashMap::new();
|
||||
@@ -506,8 +608,11 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
}
|
||||
s.sources.iter().map(|src| audio_by_path.get(src.path.as_str()).is_some_and(|a| a.len() > 1)).collect()
|
||||
};
|
||||
// Files written for audio during this save: sources playing the same audio share one.
|
||||
// Files for audio in this save: sources playing the same audio share one. The files still to
|
||||
// write (their stem, audio and the sources naming them) are written once the size is checked.
|
||||
let mut files: HashMap<*const SourceAudio, String> = HashMap::new();
|
||||
let mut queued: HashMap<*const SourceAudio, usize> = HashMap::new();
|
||||
let mut pending: Vec<(String, Arc<SourceAudio>, Vec<usize>)> = Vec::new();
|
||||
for (i, src) in s.sources.iter_mut().enumerate() {
|
||||
if !same_dir
|
||||
&& s.pool.get(src.id).is_none()
|
||||
@@ -533,7 +638,14 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
src.format = FileFormat::Wav;
|
||||
continue;
|
||||
}
|
||||
make_dir(&audio_dir)?;
|
||||
if let Some((stem, _, sources)) = queued.get(&Arc::as_ptr(audio)).and_then(|&k| pending.get_mut(k)) {
|
||||
// As long as any name the file can get, so the size checked is an upper bound.
|
||||
src.path = format!("Audio Files/{stem}_9999.wav");
|
||||
src.unsaved = false;
|
||||
src.format = FileFormat::Wav;
|
||||
sources.push(i);
|
||||
continue;
|
||||
}
|
||||
let mut stem = sanitize_name(&src.name);
|
||||
if overwrite {
|
||||
// One file per source id so autosaves never collide or pile up.
|
||||
@@ -545,23 +657,46 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let (name, new) = store_wav(&audio_dir, &stem, &bytes)?;
|
||||
src.path = format!("Audio Files/{name}");
|
||||
src.path = format!("Audio Files/{stem}_9999.wav");
|
||||
src.unsaved = false;
|
||||
src.format = FileFormat::Wav;
|
||||
files.insert(Arc::as_ptr(audio), src.path.clone());
|
||||
written += usize::from(new);
|
||||
}
|
||||
if written > 0 {
|
||||
sync_dir(&audio_dir);
|
||||
queued.insert(Arc::as_ptr(audio), pending.len());
|
||||
pending.push((stem, Arc::clone(audio), vec![i]));
|
||||
}
|
||||
s.folder = Some(dir.clone());
|
||||
if !overwrite && let Some(stem) = p.file_stem().and_then(OsStr::to_str) {
|
||||
s.name = stem.to_string();
|
||||
}
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
// Before any audio is written: a session too large to open again is not saved (#892). With no
|
||||
// audio to write, the text checked is the text saved.
|
||||
let checked = if pending.is_empty() {
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
session_size(p.display(), text.len() as u64, true)?;
|
||||
Some(text)
|
||||
} else {
|
||||
session_size(p.display(), json_len(&s), true)?;
|
||||
None
|
||||
};
|
||||
let mut written = 0;
|
||||
for (stem, audio, sources) in &pending {
|
||||
make_dir(&audio_dir)?;
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let (name, new) = store_wav(&audio_dir, stem, &bytes)?;
|
||||
for &i in sources {
|
||||
if let Some(src) = s.sources.get_mut(i) {
|
||||
src.path = format!("Audio Files/{name}");
|
||||
}
|
||||
}
|
||||
written += usize::from(new);
|
||||
}
|
||||
if written > 0 {
|
||||
sync_dir(&audio_dir);
|
||||
}
|
||||
let text = match checked {
|
||||
Some(text) => text,
|
||||
None => s.to_json().map_err(|err| EngineError::Io(err.to_string()))?,
|
||||
};
|
||||
make_dir(&dir)?;
|
||||
// Atomic-ish write: temp file then rename.
|
||||
let tmp = p.with_extension("scraft.tmp");
|
||||
@@ -762,10 +897,13 @@ fn read_bundle(bytes: &[u8], limits: BundleLimits) -> Result<(String, std::colle
|
||||
if total > limits.bytes {
|
||||
return Err(EngineError::Io("bundle: too large".into()));
|
||||
}
|
||||
let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(OsStr::to_str) == Some(soundcraft_model::SESSION_EXTENSION);
|
||||
if is_session && text.is_none() {
|
||||
session_size(format_args!("bundle: {rel}"), size, false)?;
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
// The declared size is hostile too: never read past it.
|
||||
f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?;
|
||||
let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(OsStr::to_str) == Some(soundcraft_model::SESSION_EXTENSION);
|
||||
if is_session && text.is_none() {
|
||||
text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?);
|
||||
} else {
|
||||
|
||||
@@ -69,6 +69,8 @@ fn session_bundle_within(session: &Session, limits: BundleLimits) -> Result<(Vec
|
||||
src.format = FileFormat::Wav;
|
||||
}
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
// Its session too: one the reader would refuse is not saved (#892).
|
||||
super::session_size("the session", text.len() as u64, true)?;
|
||||
fits(text.len())?;
|
||||
w.start_file(format!("{}.{}", sanitize_name(&s.name), soundcraft_model::SESSION_EXTENSION), opts).map_err(zerr)?;
|
||||
w.write_all(text.as_bytes()).map_err(ioerr)?;
|
||||
@@ -100,10 +102,7 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
|
||||
let (text, mut files) = if bytes.starts_with(b"PK\x03\x04") {
|
||||
read_bundle(bytes, BUNDLE_LIMITS)?
|
||||
} else {
|
||||
(
|
||||
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))?,
|
||||
std::collections::HashMap::new(),
|
||||
)
|
||||
(super::session_bytes_text(name, bytes)?, std::collections::HashMap::new())
|
||||
};
|
||||
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
|
||||
let mut missing = Vec::new();
|
||||
@@ -149,6 +148,38 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
|
||||
mod bundle_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_session_too_large_to_parse_is_refused_from_bytes() {
|
||||
// #892: a session the browser hands over as bytes has the cap a session file has. The
|
||||
// bytes are zeroed on allocation and never read, so they take no memory.
|
||||
let huge = vec![0u8; usize::try_from(super::super::MAX_SESSION_BYTES).unwrap() + 1];
|
||||
let err = open_session_bytes(&mut Engine::default(), "Huge.scraft", &huge).unwrap_err().to_string();
|
||||
assert_eq!(err, "Huge.scraft is too large to open (over 256 MiB)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundles_and_bare_sessions_keep_to_the_session_limit_both_ways() {
|
||||
// #892, with the limit lowered for this test: a bundle is saved only if its session can
|
||||
// be opened again (#433), and its reader and the bare-bytes reader refuse one past it.
|
||||
let e = crate::demo::demo_engine();
|
||||
let (bundle, fresh) = session_bundle(e.session()).unwrap();
|
||||
let inside = fresh.to_json().unwrap().len() as u64;
|
||||
let bare = e.session().to_json().unwrap();
|
||||
{
|
||||
let _limit = super::super::SessionLimit::set(inside.max(bare.len() as u64));
|
||||
assert!(session_bundle(e.session()).is_ok());
|
||||
assert!(open_session_bytes(&mut Engine::default(), "Song.scraftz", &bundle).is_ok());
|
||||
assert!(open_session_bytes(&mut Engine::default(), "Song.scraft", bare.as_bytes()).is_ok());
|
||||
}
|
||||
let _limit = super::super::SessionLimit::set(inside.min(bare.len() as u64) - 1);
|
||||
let err = session_bundle(e.session()).unwrap_err().to_string();
|
||||
assert!(err.starts_with("the session is too large to save"), "{err}");
|
||||
let err = open_session_bytes(&mut Engine::default(), "Song.scraftz", &bundle).unwrap_err().to_string();
|
||||
assert!(err.starts_with("bundle: ") && err.contains(".scraft is too large to open"), "{err}");
|
||||
let err = open_session_bytes(&mut Engine::default(), "Song.scraft", bare.as_bytes()).unwrap_err().to_string();
|
||||
assert!(err.starts_with("Song.scraft is too large to open"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_round_trips_session_and_audio() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::bundle::save_session_bundle;
|
||||
use super::import::add_source;
|
||||
use super::import::{MAX_IMPORT_SAMPLES, at_rate, relink_videos};
|
||||
use super::{
|
||||
Arc, Engine, EngineError, OsStr, Path, PathBuf, Result, Session, SourceAudio, file_key, read_file, read_text, stored_path, write_session,
|
||||
Arc, Engine, EngineError, OsStr, Path, PathBuf, Result, Session, SourceAudio, file_key, read_file, read_session_text, stored_path, write_session,
|
||||
};
|
||||
|
||||
/// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written
|
||||
@@ -44,7 +44,7 @@ pub fn open_session(e: &mut Engine, path: impl AsRef<Path>) -> Result<Vec<String
|
||||
/// [`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: &Path, links: &Path) -> Result<Vec<String>> {
|
||||
let text = read_text(path)?;
|
||||
let text = read_session_text(path)?;
|
||||
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let mut missing = Vec::new();
|
||||
@@ -98,6 +98,96 @@ mod save_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// A new, empty scratch folder, removed again when the test ends, passed or not.
|
||||
struct Scratch(std::path::PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(tag: &str) -> Scratch {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Scratch(dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_file_too_large_to_parse_is_refused_unread() {
|
||||
// #892: a session file is read whole and parsed; past the cap it is refused by its size.
|
||||
// The file's length is set, not written, so it takes no time or space to make.
|
||||
let dir = Scratch::new("huge-session");
|
||||
let path = dir.0.join("Huge.scraft");
|
||||
std::fs::File::create(&path).unwrap().set_len(crate::io::MAX_SESSION_BYTES + 1).unwrap();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let err = open_session(&mut e, &path).unwrap_err().to_string();
|
||||
assert_eq!(err, format!("{} is too large to open (over 256 MiB)", path.display()));
|
||||
assert_eq!(e.session().tracks.len(), crate::demo::demo_engine().session().tracks.len());
|
||||
let err = e.execute("file.import_session_data", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(err.contains("too large to open"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_too_large_to_open_is_not_saved_and_leaves_nothing_behind() {
|
||||
// #892: what SoundCraft saves, it opens again. A refused save writes no audio and no
|
||||
// session; the limit is lowered for this test so a small session reaches it.
|
||||
let dir = Scratch::new("refused-save");
|
||||
let path = |p: &str| dir.0.join(p).to_string_lossy().into_owned();
|
||||
save_session(&mut with_take(0.5), &path("A/Song.scraft")).unwrap();
|
||||
let saved = std::fs::metadata(path("A/Song.scraft")).unwrap().len();
|
||||
let untouched = |sub: &str| assert!(!dir.0.join(sub).exists(), "{sub} was written");
|
||||
{
|
||||
let _limit = crate::io::SessionLimit::set(saved - 1);
|
||||
let mut e = with_take(0.5);
|
||||
let err = save_session(&mut e, &path("B/Song.scraft")).unwrap_err().to_string();
|
||||
assert_eq!(err, format!("{} is too large to save: over {} bytes, more than SoundCraft opens", path("B/Song.scraft"), saved - 1));
|
||||
assert!(e.is_dirty() && e.path.is_none());
|
||||
let err = e.execute("session.save_copy", &json!({"path": path("C/Song.scraft")})).unwrap_err().to_string();
|
||||
assert!(err.contains("too large to save"), "{err}");
|
||||
for sub in ["B", "C"] {
|
||||
untouched(sub);
|
||||
}
|
||||
// An open refuses the session saved before, now over the limit.
|
||||
let err = open_session(&mut Engine::default(), path("A/Song.scraft")).unwrap_err().to_string();
|
||||
assert!(err.contains("too large to open"), "{err}");
|
||||
}
|
||||
{
|
||||
// A template holds no audio or clips: a smaller limit refuses it.
|
||||
let _limit = crate::io::SessionLimit::set(100);
|
||||
std::fs::create_dir_all(dir.0.join("D")).unwrap();
|
||||
let err = with_take(0.5).execute("session.save_template", &json!({"path": path("D/Song.scraft")})).unwrap_err().to_string();
|
||||
assert!(err.contains("too large to save"), "{err}");
|
||||
untouched("D/Song.scraft");
|
||||
}
|
||||
// Checked before the audio file is named, against the longest name it can get: a
|
||||
// session at the limit plus that margin is saved, and is the same size as before.
|
||||
let _limit = crate::io::SessionLimit::set(saved + 5);
|
||||
save_session(&mut with_take(0.5), &path("B/Song.scraft")).unwrap();
|
||||
assert_eq!(std::fs::metadata(path("B/Song.scraft")).unwrap().len(), saved);
|
||||
assert_eq!(reopen(&path("B/Song.scraft")).session().sources.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_session_parses_as_it_did_through_a_tree() {
|
||||
// #892: parsing the text directly gives the document the `Value` round trip gave.
|
||||
let mut e = crate::demo::demo_engine();
|
||||
e.execute("mix.volume", &json!({"track": "Kick", "db": -7.0})).unwrap();
|
||||
let text = e.session().to_json().unwrap();
|
||||
let mut old: Session = serde_json::from_value(serde_json::from_str(&text).unwrap()).unwrap();
|
||||
old.sanitize();
|
||||
assert_eq!(Session::from_json(&text).unwrap(), old);
|
||||
let mut r = Engine::default();
|
||||
let dir = Scratch::new("reparse");
|
||||
let path = dir.0.join("Song.scraft").to_string_lossy().into_owned();
|
||||
save_session(&mut e, &path).unwrap();
|
||||
assert_eq!(open_session(&mut r, &path).unwrap(), Vec::<String>::new());
|
||||
assert_eq!(r.session().tracks, e.session().tracks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosave_copy_leaves_document_untouched_and_reuses_audio() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
|
||||
@@ -281,6 +281,13 @@ impl Engine {
|
||||
Arc::make_mut(&mut self.doc)
|
||||
}
|
||||
|
||||
/// Mutable access for live state that redraws and reaches playback but is not part of the
|
||||
/// saved document. Callers must also keep the fields out of serialization and Undo/Redo.
|
||||
pub(crate) fn runtime_session_mut(&mut self) -> &mut Session {
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
Arc::make_mut(&mut self.doc)
|
||||
}
|
||||
|
||||
/// Like `session_mut`, for `edit.zoom` only and without bumping `revision`.
|
||||
pub fn zoom_mut(&mut self) -> &mut ZoomState {
|
||||
self.dirty = true;
|
||||
@@ -336,20 +343,34 @@ impl Engine {
|
||||
self.undo.iter().map(|h| h.label.clone()).collect()
|
||||
}
|
||||
|
||||
/// Bound Undo and Redo together, dropping farthest redo entries before oldest undo entries.
|
||||
pub fn set_history_limit(&mut self, limit: usize) {
|
||||
let limit = limit.max(1);
|
||||
self.history_limit = limit;
|
||||
let excess = self.undo.len().saturating_add(self.redo.len()).saturating_sub(limit);
|
||||
let from_redo = excess.min(self.redo.len());
|
||||
self.redo.drain(..from_redo);
|
||||
self.undo.drain(..excess - from_redo);
|
||||
}
|
||||
|
||||
/// Restore the previous document while retaining current edit mode and live input flags.
|
||||
pub fn undo(&mut self) -> bool {
|
||||
let Some(h) = self.undo.pop() else { return false };
|
||||
let cur = std::mem::replace(&mut self.doc, h.doc);
|
||||
self.keep_edit_mode(&cur);
|
||||
self.keep_runtime_track_states(&cur);
|
||||
self.redo.push(HistoryEntry { label: h.label, doc: cur });
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
self.dirty = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// Restore the next document while retaining current edit mode and live input flags.
|
||||
pub fn redo(&mut self) -> bool {
|
||||
let Some(h) = self.redo.pop() else { return false };
|
||||
let cur = std::mem::replace(&mut self.doc, h.doc);
|
||||
self.keep_edit_mode(&cur);
|
||||
self.keep_runtime_track_states(&cur);
|
||||
self.undo.push(HistoryEntry { label: h.label, doc: cur });
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
self.dirty = true;
|
||||
@@ -370,6 +391,25 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo and Redo must not restore live input states captured in document snapshots. A track
|
||||
/// absent from the current document comes back safe rather than inheriting a historical arm.
|
||||
fn keep_runtime_track_states(&mut self, from: &Session) {
|
||||
let states: std::collections::HashMap<_, _> = from
|
||||
.tracks
|
||||
.iter()
|
||||
.filter(|track| track.mixer.record_arm || track.mixer.input_monitor)
|
||||
.map(|track| (track.id, (track.mixer.record_arm, track.mixer.input_monitor)))
|
||||
.collect();
|
||||
let state = |id| states.get(&id).copied().unwrap_or((false, false));
|
||||
// Keep shared snapshots untouched when the restored flags already match.
|
||||
if self.doc.tracks.iter().all(|track| (track.mixer.record_arm, track.mixer.input_monitor) == state(track.id)) {
|
||||
return;
|
||||
}
|
||||
for track in &mut Arc::make_mut(&mut self.doc).tracks {
|
||||
(track.mixer.record_arm, track.mixer.input_monitor) = state(track.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an undo point manually (for interactive gestures committed by the UI).
|
||||
pub fn push_undo(&mut self, label: &str, before: Arc<Session>) {
|
||||
if Arc::ptr_eq(&before, &self.doc) {
|
||||
@@ -686,6 +726,59 @@ mod merge_tests {
|
||||
mod dirty_tests {
|
||||
use super::*;
|
||||
|
||||
/// Lowering the cap immediately removes the oldest retained Undo steps.
|
||||
#[test]
|
||||
fn reducing_the_history_limit_drops_the_oldest_steps_immediately() {
|
||||
let mut e = Engine::default();
|
||||
for _ in 0..4 {
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(e.undo_history().len(), 4);
|
||||
e.set_history_limit(2);
|
||||
assert_eq!(e.undo_history().len(), 2);
|
||||
assert!(e.undo());
|
||||
assert!(e.undo());
|
||||
assert!(!e.undo());
|
||||
}
|
||||
|
||||
/// A shared history cap retains the closest redo steps without letting either direction exceed it.
|
||||
#[test]
|
||||
fn lowering_history_limit_bounds_undo_and_redo_together() {
|
||||
for (requested, past, future) in [(0, 1, 0), (1, 1, 0), (2, 2, 0), (3, 2, 1), (4, 2, 2), (5, 2, 3), (usize::MAX, 2, 3)] {
|
||||
let mut e = Engine::default();
|
||||
for _ in 0..5 {
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
}
|
||||
for _ in 0..3 {
|
||||
assert!(e.undo());
|
||||
}
|
||||
e.mark_clean();
|
||||
e.set_history_limit(requested);
|
||||
let limit = requested.max(1);
|
||||
assert_eq!(e.history_limit, limit);
|
||||
assert_eq!((e.undo.len(), e.redo.len()), (past, future), "requested {requested}");
|
||||
assert!(!e.is_dirty(), "history capacity is not a document edit");
|
||||
assert_eq!(e.session().tracks.len(), 2);
|
||||
for step in 1..=future {
|
||||
assert!(e.redo());
|
||||
assert_eq!(e.session().tracks.len(), 2 + step, "keep the nearest redo entries");
|
||||
assert!(e.undo.len() + e.redo.len() <= limit);
|
||||
}
|
||||
assert!(!e.redo());
|
||||
for step in 1..=past + future {
|
||||
assert!(e.undo());
|
||||
assert_eq!(e.session().tracks.len(), 2 + future - step, "keep the nearest undo entries");
|
||||
assert!(e.undo.len() + e.redo.len() <= limit);
|
||||
}
|
||||
assert!(!e.undo());
|
||||
for _ in 0..past + future {
|
||||
assert!(e.redo());
|
||||
assert!(e.undo.len() + e.redo.len() <= limit);
|
||||
}
|
||||
assert!(!e.redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_locating_and_zooming_leave_the_session_clean() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
@@ -705,6 +798,17 @@ mod dirty_tests {
|
||||
assert!(!e.is_dirty());
|
||||
}
|
||||
|
||||
/// Unchanged runtime flags do not detach a shared document snapshot.
|
||||
#[test]
|
||||
fn matching_live_states_keep_the_document_shared() {
|
||||
let mut e = Engine::default();
|
||||
let track = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None);
|
||||
e.runtime_session_mut().track_mut(track).unwrap().mixer.record_arm = true;
|
||||
let snapshot = e.session_arc();
|
||||
e.keep_runtime_track_states(&snapshot);
|
||||
assert!(Arc::ptr_eq(&snapshot, &e.session_arc()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_state_follows_save_undo_and_redo_after_navigation() {
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -502,9 +502,11 @@ pub struct Mixer {
|
||||
pub solo: bool,
|
||||
#[serde(default)]
|
||||
pub solo_safe: bool,
|
||||
#[serde(default)]
|
||||
/// Live record-enable state. Runtime-only: opening a session must never arm an input.
|
||||
#[serde(skip)]
|
||||
pub record_arm: bool,
|
||||
#[serde(default)]
|
||||
/// Live input-monitor state. Runtime-only: opening a session must never enable monitoring.
|
||||
#[serde(skip)]
|
||||
pub input_monitor: bool,
|
||||
#[serde(default)]
|
||||
pub phase_invert: bool,
|
||||
|
||||
@@ -724,13 +724,36 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Parse a session; decoded audio must be attached separately (`pool`).
|
||||
///
|
||||
/// The text is parsed straight into the document, never into a `serde_json::Value` tree
|
||||
/// first (which takes many times the file's size). Only the version is read before that.
|
||||
/// A field of the document given twice in one object is refused (`duplicate field`), as no
|
||||
/// writer repeats one. Keys of maps (plugin params, edit values, Auto Rename schemes, MIDI
|
||||
/// originals) and unknown keys keep the last value given, as before.
|
||||
pub fn from_json(text: &str) -> Result<Session, SessionError> {
|
||||
let v: serde_json::Value = serde_json::from_str(text).map_err(|e| SessionError::Format(e.to_string()))?;
|
||||
let ver = v.get("format_version").and_then(serde_json::Value::as_u64).ok_or_else(|| SessionError::Format("missing format_version".into()))?;
|
||||
/// The version alone: every other field is skipped, not kept.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Head {
|
||||
format_version: Option<serde_json::Number>,
|
||||
}
|
||||
// `Head` would also take a JSON array as its fields in order: only an object has a version.
|
||||
let object = text.trim_start_matches([' ', '\t', '\n', '\r']).starts_with('{');
|
||||
let version = match object.then(|| serde_json::from_str::<Head>(text)) {
|
||||
Some(Ok(head)) => head.format_version,
|
||||
// A version given twice is that error, not a missing version.
|
||||
Some(Err(e)) if e.to_string().starts_with("duplicate field") => return Err(SessionError::Format(e.to_string())),
|
||||
// Valid JSON of another shape (not an object, or a version that is not a number)
|
||||
// has no version; anything else is not JSON.
|
||||
_ => {
|
||||
serde_json::from_str::<serde::de::IgnoredAny>(text).map_err(|e| SessionError::Format(e.to_string()))?;
|
||||
None
|
||||
}
|
||||
};
|
||||
let ver = version.and_then(|v| v.as_u64()).ok_or_else(|| SessionError::Format("missing format_version".into()))?;
|
||||
if ver > u64::from(Self::FORMAT_VERSION) {
|
||||
return Err(SessionError::Version(u32::try_from(ver).unwrap_or(u32::MAX)));
|
||||
}
|
||||
let mut s: Session = serde_json::from_value(v).map_err(|e| SessionError::Format(e.to_string()))?;
|
||||
let mut s: Session = serde_json::from_str(text).map_err(|e| SessionError::Format(e.to_string()))?;
|
||||
s.sanitize();
|
||||
s.validate()?;
|
||||
Ok(s)
|
||||
@@ -1051,6 +1074,31 @@ mod tests {
|
||||
assert_eq!(back.track(t).map(|tr| tr.height), Some(crate::TrackHeight::Medium));
|
||||
}
|
||||
|
||||
/// Session JSON omits live flags and ignores their values in legacy documents.
|
||||
#[test]
|
||||
fn live_input_states_are_never_saved_or_restored() {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let track = s.track_mut(t).unwrap();
|
||||
track.mixer.mute = true;
|
||||
track.mixer.record_arm = true;
|
||||
track.mixer.input_monitor = true;
|
||||
|
||||
let mut value = serde_json::to_value(&s).unwrap();
|
||||
let mixer = value["tracks"][0]["mixer"].as_object_mut().unwrap();
|
||||
assert!(!mixer.contains_key("record_arm"));
|
||||
assert!(!mixer.contains_key("input_monitor"));
|
||||
|
||||
// Files written by older versions may still contain these keys; ignore them on input.
|
||||
mixer.insert("record_arm".into(), serde_json::Value::Bool(true));
|
||||
mixer.insert("input_monitor".into(), serde_json::Value::Bool(true));
|
||||
let loaded = Session::from_json(&value.to_string()).unwrap();
|
||||
let track = loaded.track(t).unwrap();
|
||||
assert!(track.mixer.mute, "persistent mixer state still loads");
|
||||
assert!(!track.mixer.record_arm);
|
||||
assert!(!track.mixer.input_monitor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_clip_round_trips_and_old_sessions_load() {
|
||||
let mut s = Session::default();
|
||||
@@ -1140,6 +1188,53 @@ mod tests {
|
||||
assert!(matches!(Session::from_json(&v.to_string()), Err(SessionError::Version(999))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_version_is_checked_as_before() {
|
||||
// #892: the version is read without building the whole document as a tree; the errors
|
||||
// stay what they were.
|
||||
let format = |t: &str| match Session::from_json(t) {
|
||||
Err(SessionError::Format(m)) => m,
|
||||
other => panic!("{t}: {other:?}"),
|
||||
};
|
||||
for t in [
|
||||
"{}",
|
||||
"[]",
|
||||
"[5]",
|
||||
" \n[1, {}]",
|
||||
"\"x\"",
|
||||
"5",
|
||||
"null",
|
||||
r#"{"format_version":"1"}"#,
|
||||
r#"{"format_version":-1}"#,
|
||||
r#"{"format_version":1.5}"#,
|
||||
r#"{"format_version":null}"#,
|
||||
r#"{"format_version":[0,0,0]}"#,
|
||||
r#"{"name":"x","other":{"format_version":1}}"#,
|
||||
] {
|
||||
assert_eq!(format(t), "missing format_version", "{t}");
|
||||
}
|
||||
for t in ["not json", r#"{"format_version":1"#, r#"{"format_version":1} x"#, r#"{"format_version":"1", ]"#] {
|
||||
let want = serde_json::from_str::<serde_json::Value>(t).unwrap_err().to_string();
|
||||
assert_eq!(format(t), want, "{t}");
|
||||
}
|
||||
let mut v = serde_json::to_value(Session::default()).unwrap();
|
||||
for (ver, want) in [(serde_json::json!(2), 2), (serde_json::json!(1u64 << 40), u32::MAX)] {
|
||||
v["format_version"] = ver;
|
||||
assert_eq!(Session::from_json(&v.to_string()), Err(SessionError::Version(want)));
|
||||
}
|
||||
// A field given twice is refused by name, the version too.
|
||||
let twice = |key: &str| format(&format!(r#"{{"format_version":1,"{key}":"a","{key}":"b"}}"#));
|
||||
assert!(twice("name").starts_with("duplicate field `name` at line 1 column "), "{}", twice("name"));
|
||||
assert!(twice("format_version").starts_with("duplicate field `format_version` at line 1 column "), "{}", twice("format_version"));
|
||||
// A newer version is refused even when the rest would not parse.
|
||||
assert_eq!(Session::from_json(r#"{"format_version":7,"tracks":5}"#), Err(SessionError::Version(7)));
|
||||
// An older or the current version loads as it did.
|
||||
for ver in [0, 1] {
|
||||
v["format_version"] = serde_json::json!(ver);
|
||||
assert_eq!(Session::from_json(&v.to_string()).map(|s| s.format_version), Ok(ver));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_ids_are_rejected_on_load() {
|
||||
let mut s = Session::default();
|
||||
|
||||
+86
-37
@@ -83,8 +83,7 @@ impl TrackKind {
|
||||
}
|
||||
|
||||
/// Track heights (View › Track Height presets).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(from = "TrackHeightRepr")]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize)]
|
||||
pub enum TrackHeight {
|
||||
/// Any height from Micro to Extreme, in points.
|
||||
Custom(u16),
|
||||
@@ -100,49 +99,69 @@ pub enum TrackHeight {
|
||||
Fit(u16),
|
||||
}
|
||||
|
||||
/// What a saved track height may look like: the current form, or the bare `"Fit"` string older sessions used.
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum TrackHeightRepr {
|
||||
Current(TrackHeightCurrent),
|
||||
LegacyFit(LegacyFit),
|
||||
/// A saved track height: `"Micro"` … `"Extreme"` (or `{"Micro": null}` …), `{"Custom": points}`,
|
||||
/// `{"Fit": points}`, or the bare `"Fit"` older sessions used. Read as it comes, never buffered (as an
|
||||
/// untagged enum would), so anything else in a session file is refused at its first token.
|
||||
impl<'de> serde::Deserialize<'de> for TrackHeight {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
d.deserialize_any(HeightVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
enum LegacyFit {
|
||||
Fit,
|
||||
const HEIGHT_VARIANTS: &[&str] = &["Custom", "Micro", "Mini", "Small", "Medium", "Large", "Jumbo", "Extreme", "Fit"];
|
||||
|
||||
struct HeightVisitor;
|
||||
|
||||
impl HeightVisitor {
|
||||
/// The height a variant without a size names.
|
||||
fn preset(name: &str) -> Option<TrackHeight> {
|
||||
Some(match name {
|
||||
"Micro" => TrackHeight::Micro,
|
||||
"Mini" => TrackHeight::Mini,
|
||||
"Small" => TrackHeight::Small,
|
||||
"Medium" => TrackHeight::Medium,
|
||||
"Large" => TrackHeight::Large,
|
||||
"Jumbo" => TrackHeight::Jumbo,
|
||||
"Extreme" => TrackHeight::Extreme,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror of `TrackHeight` that derives the current serialized form.
|
||||
#[derive(serde::Deserialize)]
|
||||
enum TrackHeightCurrent {
|
||||
Custom(u16),
|
||||
Micro,
|
||||
Mini,
|
||||
Small,
|
||||
Medium,
|
||||
Large,
|
||||
Jumbo,
|
||||
Extreme,
|
||||
Fit(u16),
|
||||
}
|
||||
impl<'de> serde::de::Visitor<'de> for HeightVisitor {
|
||||
type Value = TrackHeight;
|
||||
|
||||
impl From<TrackHeightRepr> for TrackHeight {
|
||||
fn from(repr: TrackHeightRepr) -> Self {
|
||||
match repr {
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Custom(p)) => TrackHeight::Custom(p),
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Micro) => TrackHeight::Micro,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Mini) => TrackHeight::Mini,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Small) => TrackHeight::Small,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Medium) => TrackHeight::Medium,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Large) => TrackHeight::Large,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Jumbo) => TrackHeight::Jumbo,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Extreme) => TrackHeight::Extreme,
|
||||
TrackHeightRepr::Current(TrackHeightCurrent::Fit(p)) => TrackHeight::Fit(p),
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a track height")
|
||||
}
|
||||
|
||||
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<TrackHeight, E> {
|
||||
match v {
|
||||
// Older sessions stored Fit with no size, drawn 88 points tall: the default Medium height.
|
||||
TrackHeightRepr::LegacyFit(LegacyFit::Fit) => TrackHeight::Medium,
|
||||
"Fit" => Ok(TrackHeight::Medium),
|
||||
"Custom" => Err(E::invalid_type(serde::de::Unexpected::UnitVariant, &"a height in points")),
|
||||
other => Self::preset(other).ok_or_else(|| E::unknown_variant(other, HEIGHT_VARIANTS)),
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> Result<TrackHeight, A::Error> {
|
||||
use serde::de::Error as _;
|
||||
let Some(key) = map.next_key::<String>()? else { return Err(A::Error::invalid_length(0, &"one variant")) };
|
||||
let h = match key.as_str() {
|
||||
"Custom" => TrackHeight::Custom(map.next_value()?),
|
||||
// `{"Fit": null}` is the old size-less Fit too, as above.
|
||||
"Fit" => map.next_value::<Option<u16>>()?.map_or(TrackHeight::Medium, TrackHeight::Fit),
|
||||
other => {
|
||||
let h = Self::preset(other).ok_or_else(|| A::Error::unknown_variant(other, HEIGHT_VARIANTS))?;
|
||||
map.next_value::<()>()?;
|
||||
h
|
||||
}
|
||||
};
|
||||
if map.next_key::<serde::de::IgnoredAny>()?.is_some() {
|
||||
return Err(A::Error::invalid_length(2, &"one variant"));
|
||||
}
|
||||
Ok(h)
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackHeight {
|
||||
@@ -383,6 +402,36 @@ mod tests {
|
||||
assert_eq!(serde_json::from_str::<TrackHeight>("{\"Custom\":50}").unwrap(), TrackHeight::Custom(50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_saved_height_form_loads_and_nothing_else() {
|
||||
for h in TrackHeight::ALL.into_iter().chain([TrackHeight::Custom(50), TrackHeight::Fit(120)]) {
|
||||
assert_eq!(serde_json::from_str::<TrackHeight>(&serde_json::to_string(&h).unwrap()).unwrap(), h);
|
||||
assert_eq!(serde_json::from_value::<TrackHeight>(serde_json::to_value(h).unwrap()).unwrap(), h);
|
||||
}
|
||||
for h in TrackHeight::ALL {
|
||||
let null = format!("{{{}:null}}", serde_json::to_string(&h).unwrap());
|
||||
assert_eq!(serde_json::from_str::<TrackHeight>(&null).unwrap(), h, "{null}");
|
||||
}
|
||||
assert_eq!(serde_json::from_str::<TrackHeight>(r#"{"Fit":null}"#).unwrap(), TrackHeight::Medium);
|
||||
assert!(serde_json::from_str::<TrackHeight>(r#"{"Custom":null}"#).is_err());
|
||||
for bad in
|
||||
[r#""Custom""#, r#""Huge""#, "7", "[]", "null", "{}", r#"{"Fit":"x"}"#, r#"{"Fit":70000}"#, r#"{"Large":1}"#, r#"{"Fit":1,"Fit":2}"#]
|
||||
{
|
||||
assert!(serde_json::from_str::<TrackHeight>(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_height_is_refused_at_its_first_token() {
|
||||
// #892: the height was read through an untagged enum, which buffers the whole value
|
||||
// first; a session could hold a huge array there. It is refused where it starts.
|
||||
for (open, rest) in [("[", "0,".repeat(100_000) + "0]"), (r#"{"Fit":["#, "0,".repeat(100_000) + "0]}")] {
|
||||
let text = format!("{open}{rest}");
|
||||
let err = serde_json::from_str::<TrackHeight>(&text).unwrap_err();
|
||||
assert!(err.is_data() && err.line() == 1 && err.column() <= open.len() + 1, "{open}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_track_defaults() {
|
||||
let t = Track::new(TrackId(1), "Gtr", TrackKind::Audio, ChannelFormat::Stereo, TRACK_COLORS[0]);
|
||||
|
||||
@@ -24,6 +24,7 @@ soundcraft-video.workspace = true
|
||||
# 0.37 makes the bundled faces opt-in; the app's own font loader starts from
|
||||
# `FontDefinitions::default()`, and kittest never supplies fonts to this root.
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_extras = { workspace = true }
|
||||
web-time.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::state::MainWindow;
|
||||
use super::{
|
||||
ControlRequest, Engine, Gesture, HashMap, MeterDisplay, OutputTrouble, Player, Range, Receiver, Samples, SoundApp, TrackId, TransportRequest,
|
||||
Trouble, UiState, Value, VecDeque, bounce, dialogs, edit_window, extra_windows, fonts, import, json, menus, midi_editor, mix_window, ops_windows,
|
||||
palette, panels, recovery, sampler_window, score_editor, shortcuts, theme, updates_ui, video_track, video_window, windows,
|
||||
palette, panels, preferences, recovery, sampler_window, score_editor, shortcuts, theme, updates_ui, video_track, video_window, windows,
|
||||
};
|
||||
|
||||
mod audio;
|
||||
@@ -63,6 +63,7 @@ impl SoundApp {
|
||||
ops: ops_windows::OpsState::default(),
|
||||
extra: extra_windows::ExtraState::default(),
|
||||
palette: palette::PaletteState::default(),
|
||||
preferences: preferences::PreferencesState::default(),
|
||||
focus_mix: false,
|
||||
edit_scroll_into_view: None,
|
||||
mix_scroll_into_view: None,
|
||||
@@ -137,6 +138,8 @@ impl SoundApp {
|
||||
/// Each host iteration starts with `raw_input_hook`; its editor-poll admission prevents an
|
||||
/// empty queue on a discarded pass from ending a still-active plugin gesture.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
self.ui.undo_levels = self.ui.undo_levels.clamp(1, 64);
|
||||
self.engine.set_history_limit(self.ui.undo_levels);
|
||||
// Fonts set now take effect next frame, so draw only from the frame after.
|
||||
if !self.fonts_ready {
|
||||
if self.fonts_installed {
|
||||
@@ -333,6 +336,7 @@ impl SoundApp {
|
||||
}
|
||||
panels::floating(self, &ctx);
|
||||
windows::show(self, &ctx);
|
||||
preferences::show(self, &ctx);
|
||||
video_window::show(self, &ctx);
|
||||
ops_windows::show(self, &ctx);
|
||||
extra_windows::show(self, &ctx);
|
||||
|
||||
+322
-53
@@ -262,71 +262,213 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
|
||||
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
|
||||
ui.separator();
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
|
||||
if list.is_empty() {
|
||||
ui.label("No contributor data was built into this copy.");
|
||||
} else if v.table {
|
||||
table(ui, &list, &mut v);
|
||||
} else {
|
||||
if list.is_empty() {
|
||||
ui.label("No contributor data was built into this copy.");
|
||||
} else if v.table {
|
||||
// The table scrolls vertically itself, under a fixed header; it only scrolls sideways
|
||||
// once its columns are dragged wider than the dialog.
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
|
||||
} else {
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
contributors_grab_bag(ui, &list, v.names);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
|
||||
for k in SortKey::ALL {
|
||||
let arrow = if v.key == k { if v.ascending { " ⏶" } else { " ⏷" } } else { "" };
|
||||
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
for c in list {
|
||||
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
|
||||
ui.label(group(c.prs));
|
||||
ui.label(group(c.commits));
|
||||
ui.label(group(c.lines_added));
|
||||
ui.label(group(c.lines_deleted));
|
||||
ui.label(signed(c.lines_delta()));
|
||||
ui.label(group(c.binary_added));
|
||||
ui.label(group(c.binary_deleted));
|
||||
ui.label(day(c.first_commit));
|
||||
ui.label(day(c.last_commit));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
/// How the contributor table's spare width is shared out: all of it to Name.
|
||||
const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
|
||||
/// How the model table's spare width is shared out: to Company and Model, the text columns.
|
||||
const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
|
||||
|
||||
/// The narrowest a column can be dragged.
|
||||
const MIN_COLUMN: f32 = 28.0;
|
||||
|
||||
/// The width a column needs for its header and its widest cell.
|
||||
fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>) -> f32 {
|
||||
let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
|
||||
let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
|
||||
widest + 2.0 * ui.spacing().button_padding.x
|
||||
}
|
||||
|
||||
/// About › Models: AI models credited in Co-Authored-By trailers.
|
||||
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
|
||||
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
|
||||
/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
|
||||
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
|
||||
let spare = room - natural.iter().sum::<f32>();
|
||||
let weight = grow.iter().sum::<f32>();
|
||||
let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
|
||||
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
|
||||
natural
|
||||
.iter()
|
||||
.zip(grow)
|
||||
.map(|(w, g)| {
|
||||
if *g > 0.0 && spare >= 0.0 {
|
||||
w + spare * g / weight
|
||||
} else if *g > 0.0 && flexible > 0.0 {
|
||||
w - (-spare).min(flexible) * slack(*w) / flexible
|
||||
} else {
|
||||
*w
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
|
||||
/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
|
||||
/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
|
||||
/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
|
||||
/// dropping any dragged widths.
|
||||
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
|
||||
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
|
||||
let s = ui.spacing();
|
||||
let gaps = s.item_spacing.x * natural.len() as f32;
|
||||
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
|
||||
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
|
||||
// The table keeps its own column widths, so a change in what they were fitted to has to clear
|
||||
// them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
|
||||
// width, so this compares the natural widths and the clip rect, which scrolling never moves.
|
||||
let fit = egui::Id::unique(("credits_fit", id));
|
||||
let measured = (natural.to_vec(), ui.clip_rect().width());
|
||||
let refit = ui.data_mut(|d| d.get_temp::<(Vec<f32>, f32)>(fit)).as_ref() != Some(&measured);
|
||||
ui.data_mut(|d| d.insert_temp(fit, measured));
|
||||
let mut t =
|
||||
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
|
||||
if refit {
|
||||
t.reset();
|
||||
}
|
||||
for w in widths {
|
||||
t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
|
||||
fn aligned<R>(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
|
||||
ui.with_layout(layout, add).inner
|
||||
}
|
||||
|
||||
fn row_height(ui: &egui::Ui) -> f32 {
|
||||
ui.text_style_height(&egui::TextStyle::Body) + 6.0
|
||||
}
|
||||
|
||||
/// One contributor's table cells after the name, in `SortKey::ALL` order.
|
||||
fn cells(c: &Contributor) -> [String; 9] {
|
||||
[
|
||||
group(c.prs),
|
||||
group(c.commits),
|
||||
group(c.lines_added),
|
||||
group(c.lines_deleted),
|
||||
signed(c.lines_delta()),
|
||||
group(c.binary_added),
|
||||
group(c.binary_deleted),
|
||||
day(c.first_commit).to_string(),
|
||||
day(c.last_commit).to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
/// A contributor-table header. The sorted column is highlighted; its direction shows on the
|
||||
/// toolbar's sort-order button, so no arrow widens the column.
|
||||
fn header_text(k: SortKey) -> RichText {
|
||||
RichText::new(k.label().1).strong()
|
||||
}
|
||||
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
|
||||
let h = row_height(ui);
|
||||
let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
|
||||
let widths: Vec<f32> = SortKey::ALL
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &k)| match i.checked_sub(1) {
|
||||
None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
|
||||
Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
|
||||
})
|
||||
.collect();
|
||||
fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
|
||||
.header(h + 4.0, |mut header| {
|
||||
for k in SortKey::ALL {
|
||||
header.col(|ui| {
|
||||
let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))).on_hover_text(k.label().0);
|
||||
|
||||
if r.clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for (c, cells) in list.iter().zip(rows) {
|
||||
body.row(h, |mut row| {
|
||||
row.col(|ui| {
|
||||
aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
|
||||
.on_hover_text(c.summary());
|
||||
});
|
||||
for text in cells {
|
||||
row.col(|ui| {
|
||||
aligned(ui, true, |ui| ui.label(text));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
|
||||
pub fn models_ui(ui: &mut egui::Ui) {
|
||||
if MODELS.is_empty() {
|
||||
ui.label("No model credits were built into this copy.");
|
||||
return;
|
||||
}
|
||||
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
|
||||
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
|
||||
ui.label(RichText::new(h).strong());
|
||||
}
|
||||
ui.end_row();
|
||||
for m in MODELS {
|
||||
ui.label(m.company);
|
||||
ui.label(m.model);
|
||||
ui.label(m.version);
|
||||
ui.label(group(m.commits));
|
||||
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
|
||||
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
let h = row_height(ui);
|
||||
let titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"];
|
||||
// Company, Model and Version read as text; the counts line up on the right.
|
||||
let right = [false, false, false, true, true, true];
|
||||
let rows: Vec<[String; 6]> = MODELS
|
||||
.iter()
|
||||
.map(|m| {
|
||||
[
|
||||
m.company.to_string(),
|
||||
m.model.to_string(),
|
||||
m.version.to_string(),
|
||||
group(m.commits),
|
||||
format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
|
||||
format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let widths: Vec<f32> = titles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, title)| natural(ui, RichText::new(*title).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
|
||||
.collect();
|
||||
egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
|
||||
fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
|
||||
.header(h + 4.0, |mut header| {
|
||||
for (title, r) in titles.into_iter().zip(right) {
|
||||
header.col(|ui| {
|
||||
aligned(ui, r, |ui| ui.label(RichText::new(title).strong()));
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for cells in rows {
|
||||
body.row(h, |mut row| {
|
||||
for (text, r) in cells.into_iter().zip(right) {
|
||||
row.col(|ui| {
|
||||
aligned(ui, r, |ui| ui.label(text));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -406,4 +548,131 @@ mod tests {
|
||||
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
|
||||
assert_eq!(day(""), "—");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
|
||||
let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
|
||||
assert!((w.iter().sum::<f32>() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_little_room_shrinks_only_growing_columns() {
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
|
||||
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
|
||||
let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
|
||||
assert!((w.iter().sum::<f32>() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
|
||||
assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
|
||||
assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
|
||||
}
|
||||
|
||||
const WINDOW: f32 = 640.5;
|
||||
const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
|
||||
const GROW: [f32; 3] = [1.0, 0.0, 0.0];
|
||||
|
||||
/// One frame of a table long enough to scroll; the rects of its header cells.
|
||||
fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
scrolled_frame(ctx, width, natural, 0.0, events)
|
||||
}
|
||||
|
||||
/// [`table_frame`], scrolled `offset` points sideways.
|
||||
fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec<egui::Event>) -> Vec<egui::Rect> {
|
||||
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
|
||||
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
|
||||
let mut cells = Vec::new();
|
||||
let out = ctx.run_ui(raw, |ui| {
|
||||
cells.clear();
|
||||
egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
|
||||
fitted_table(ui, "test", natural, &GROW)
|
||||
.header(20.0, |mut header| {
|
||||
for _ in natural {
|
||||
header.col(|ui| cells.push(ui.max_rect()));
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
for _ in 0..40 {
|
||||
body.row(20.0, |mut row| {
|
||||
for _ in natural {
|
||||
row.col(|ui| {
|
||||
ui.label("x");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
out.drop_without_applying_deltas();
|
||||
cells
|
||||
}
|
||||
|
||||
/// A new context with the table open and settled, and the rects of its header cells.
|
||||
fn opened_table() -> (egui::Context, Vec<egui::Rect>) {
|
||||
let ctx = egui::Context::default();
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
(ctx, cells)
|
||||
}
|
||||
|
||||
/// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
|
||||
fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
let start = egui::pos2(cell.right() + 4.0, cell.center().y);
|
||||
let halfway = start + egui::vec2(by / 2.0, 0.0);
|
||||
let end = start + egui::vec2(by, 0.0);
|
||||
let moved = Event::PointerMoved;
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
|
||||
table_frame(ctx, WINDOW, &NATURAL, vec![event]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
|
||||
let (_, opened) = opened_table();
|
||||
for (dragged, cell) in opened.iter().enumerate() {
|
||||
let (ctx, _) = opened_table();
|
||||
drag_edge(&ctx, *cell, -30.0);
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
|
||||
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
|
||||
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_fit_replaces_the_column_widths() {
|
||||
let (ctx, opened) = opened_table();
|
||||
drag_edge(&ctx, opened[0], -30.0);
|
||||
let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
|
||||
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
|
||||
let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
|
||||
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
|
||||
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
|
||||
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
|
||||
let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
|
||||
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_sideways_keeps_dragged_widths() {
|
||||
let (ctx, opened) = opened_table();
|
||||
drag_edge(&ctx, opened[0], 100.0);
|
||||
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
|
||||
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
|
||||
for offset in [13.3, 27.77, 41.1, 0.6] {
|
||||
scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
|
||||
let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
|
||||
assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use audiosuite::SPEED_RAMP_CHOICES;
|
||||
use audiosuite::audiosuite_window;
|
||||
pub use files::{NotUnicode, ask_to_save, open_path, open_session_bytes, run_or_ask, takes_path, unicode};
|
||||
use files::{OPEN_PENDING, default_dir, open_pending_session, path_dialog};
|
||||
use strip_silence::{AUTO_NAME_LABELS, STRIP_NAME_LABELS, StripRectangles, auto_rename_action, strip_name_fields, strip_silence_action};
|
||||
use strip_silence::{AUTO_NAME_LABELS, STRIP_NAME_LABELS, StripRectangles, StripValues, auto_rename_action, strip_name_fields, strip_silence_action};
|
||||
pub use strip_silence::{StripRects, strip_silence_rectangles};
|
||||
|
||||
pub mod new_tracks;
|
||||
@@ -78,6 +78,8 @@ pub struct Dialogs {
|
||||
pending_session: Option<(String, Vec<u8>)>,
|
||||
/// The naming scheme Strip Silence gives the clips it leaves, kept from one use to the next.
|
||||
pub strip_names: StripNames,
|
||||
/// The values Strip Silence last had, which it opens with next time.
|
||||
strip_values: StripValues,
|
||||
/// The Strip Silence rectangles the Edit window draws while Strip Silence is open.
|
||||
strip_rectangles: StripRectangles,
|
||||
}
|
||||
@@ -211,7 +213,10 @@ impl Dialogs {
|
||||
Dialog::Number { cmd: id.into(), title: "Quantize strength".into(), key: "strength".into(), value: 100.0, suffix: "%".into() }
|
||||
}
|
||||
"edit.fades_create" => Dialog::Fades { shape: "equal power".into() },
|
||||
"edit.strip_silence" => Dialog::StripSilence { threshold: -48.0, min_ms: 50.0, pre_ms: 5.0, post_ms: 20.0 },
|
||||
"edit.strip_silence" => {
|
||||
let StripValues([threshold, min_ms, pre_ms, post_ms]) = self.strip_values;
|
||||
Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }
|
||||
}
|
||||
"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();
|
||||
@@ -712,6 +717,14 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
});
|
||||
// Strip Silence keeps the values it shows, however it closes: Escape, Cancel or one of its actions.
|
||||
match &d {
|
||||
Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms } => {
|
||||
app.dialogs.strip_values = StripValues([*threshold, *min_ms, *pre_ms, *post_ms])
|
||||
}
|
||||
Dialog::StripSilenceRename { strip, .. } => app.dialogs.strip_values = StripValues(*strip),
|
||||
_ => {}
|
||||
}
|
||||
if esc || ctx.memory(|m| m.data.get_temp::<bool>(egui::Id::unique("dlg_cancel")).unwrap_or(false)) {
|
||||
keep = false;
|
||||
action = None;
|
||||
|
||||
@@ -3,6 +3,17 @@ use super::{Arc, ClipId, Dialog, SoundApp, StripNames, Tokens, TrackId, Value, j
|
||||
/// What Strip keeps on each track: the Strip Silence rectangles.
|
||||
pub type StripRects = Arc<Vec<(TrackId, Vec<soundcraft_time::Range>)>>;
|
||||
|
||||
/// The Strip Silence values (threshold dB, min strip duration, clip start and end pads in ms) the
|
||||
/// window opens with: the ones it last had, or these defaults before its first use.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct StripValues(pub(super) [f64; 4]);
|
||||
|
||||
impl Default for StripValues {
|
||||
fn default() -> Self {
|
||||
Self([-48.0, 50.0, 5.0, 20.0])
|
||||
}
|
||||
}
|
||||
|
||||
/// The session revision and the Strip Silence values (as bits) that rectangles were found for.
|
||||
type StripKey = (u64, [u64; 4]);
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ pub mod menus;
|
||||
pub mod mix_window;
|
||||
pub mod palette;
|
||||
pub mod panels;
|
||||
pub mod preferences;
|
||||
pub mod shortcuts;
|
||||
pub mod theme;
|
||||
pub mod toolbar;
|
||||
@@ -114,6 +115,13 @@ pub struct UiState {
|
||||
pub show_workspace: bool,
|
||||
pub show_configurations: bool,
|
||||
pub show_playback_engine: bool,
|
||||
/// Setup › Preferences. The selected page is kept per machine; the search and staged values
|
||||
/// live in [`preferences::PreferencesState`] and are discarded when the window closes.
|
||||
pub show_preferences: bool,
|
||||
pub preferences_page: preferences::PreferencesPage,
|
||||
pub preferences_window_pos: Option<[f32; 2]>,
|
||||
pub reduce_visual_effects: preferences::ReduceVisualEffects,
|
||||
pub undo_levels: usize,
|
||||
/// The audio device choice (Setup › Playback Engine), kept with the preferences: per machine.
|
||||
pub audio: soundcraft_playback::AudioConfig,
|
||||
/// The Mix window lives in its own native window (Window › Mix in Separate Window); the main
|
||||
@@ -182,6 +190,11 @@ impl Default for UiState {
|
||||
show_workspace: false,
|
||||
show_configurations: false,
|
||||
show_playback_engine: false,
|
||||
show_preferences: false,
|
||||
preferences_page: preferences::PreferencesPage::default(),
|
||||
preferences_window_pos: None,
|
||||
reduce_visual_effects: preferences::ReduceVisualEffects::default(),
|
||||
undo_levels: 64,
|
||||
audio: soundcraft_playback::AudioConfig::default(),
|
||||
mix_separate: false,
|
||||
show_io_setup: false,
|
||||
@@ -302,6 +315,7 @@ pub struct SoundApp {
|
||||
pub ops: ops_windows::OpsState,
|
||||
pub extra: extra_windows::ExtraState,
|
||||
pub palette: palette::PaletteState,
|
||||
pub preferences: preferences::PreferencesState,
|
||||
/// Bring the separate Mix window to the front on the next frame.
|
||||
pub(crate) focus_mix: bool,
|
||||
/// Scroll Into View: the track each window brings to its top (Edit) or left (Mix) when it
|
||||
|
||||
+150
-45
@@ -75,6 +75,8 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
|
||||
("ui.bounce_dialog", "Bounce Mix…", "", None),
|
||||
("ui.set", "Set UI State", "", None),
|
||||
("ui.theme", "Appearance", "", None),
|
||||
("ui.reduce_visual_effects", "Reduce Visual Effects", "", None),
|
||||
("ui.undo_levels", "Levels of Undo", "", None),
|
||||
];
|
||||
|
||||
/// Extra catalog mappings handled by the UI layer.
|
||||
@@ -225,56 +227,65 @@ pub fn tree() -> Vec<MenuNode> {
|
||||
|
||||
pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
|
||||
let t = crate::theme::Tokens::current();
|
||||
let menu_entries_enabled = !app.ui.show_preferences;
|
||||
let preferences_enabled = preferences_menu_enabled(app);
|
||||
egui::Panel::top("menu_bar").exact_size(24.0).frame(egui::Frame::NONE.fill(t.menu_bar).inner_margin(egui::Margin::symmetric(8, 2))).show(
|
||||
ui,
|
||||
|ui| {
|
||||
egui::MenuBar::new().ui(ui, |ui| {
|
||||
ui.menu_button("SoundCraft", |ui| {
|
||||
if ui.button("About SoundCraft").clicked() {
|
||||
app.ui.show_about = true;
|
||||
}
|
||||
if ui.button("Check for Updates…").clicked() {
|
||||
let _ = app.run("app.checkForUpdates", json!({}));
|
||||
}
|
||||
if ui.button("Session Info").clicked() {
|
||||
app.ui.show_session_info = true;
|
||||
}
|
||||
if ui.button("Session Audio Health").clicked() {
|
||||
let _ = app.run("window.audio_health", json!({"value": true}));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Quit").clicked() {
|
||||
let _ = crate::dialogs::run_or_ask(app, "app.quit", json!({}));
|
||||
}
|
||||
ui.add_enabled_ui(menu_entries_enabled, |ui| {
|
||||
if ui.button("About SoundCraft").clicked() {
|
||||
app.ui.show_about = true;
|
||||
}
|
||||
if ui.add_enabled(preferences_enabled, egui::Button::new("Preferences...")).clicked() {
|
||||
let _ = app.run("setup.preferences", json!({"value": true}));
|
||||
}
|
||||
if ui.button("Check for Updates…").clicked() {
|
||||
let _ = app.run("app.checkForUpdates", json!({}));
|
||||
}
|
||||
if ui.button("Session Info").clicked() {
|
||||
app.ui.show_session_info = true;
|
||||
}
|
||||
if ui.button("Session Audio Health").clicked() {
|
||||
let _ = app.run("window.audio_health", json!({"value": true}));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Quit").clicked() {
|
||||
let _ = crate::dialogs::run_or_ask(app, "app.quit", json!({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
let extra = ui_aliases();
|
||||
for root in tree() {
|
||||
ui.menu_button(&root.label, |ui| {
|
||||
for c in &root.children {
|
||||
menu_node(app, ui, c, &extra);
|
||||
if c.path == "File > Open" {
|
||||
crate::recent::menu(app, ui);
|
||||
ui.add_enabled_ui(menu_entries_enabled, |ui| {
|
||||
for c in &root.children {
|
||||
menu_node(app, ui, c, &extra);
|
||||
if c.path == "File > Open" {
|
||||
crate::recent::menu(app, ui);
|
||||
}
|
||||
if c.path == "Track > New..." {
|
||||
instrument_menu(app, ui);
|
||||
}
|
||||
}
|
||||
if c.path == "Track > New..." {
|
||||
instrument_menu(app, ui);
|
||||
if root.label == "AudioSuite" {
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim));
|
||||
}
|
||||
}
|
||||
if root.label == "AudioSuite" {
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim));
|
||||
}
|
||||
if root.label == "Window" {
|
||||
ui.separator();
|
||||
let mut separate = app.ui.mix_separate;
|
||||
if ui
|
||||
.checkbox(&mut separate, "Mix in Separate Window")
|
||||
.on_hover_text("Show Edit and Mix at once, e.g. one per monitor")
|
||||
.changed()
|
||||
{
|
||||
let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate}));
|
||||
ui.close();
|
||||
if root.label == "Window" {
|
||||
ui.separator();
|
||||
let mut separate = app.ui.mix_separate;
|
||||
if ui
|
||||
.checkbox(&mut separate, "Mix in Separate Window")
|
||||
.on_hover_text("Show Edit and Mix at once, e.g. one per monitor")
|
||||
.changed()
|
||||
{
|
||||
let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
let title = format!(
|
||||
@@ -338,18 +349,31 @@ pub fn command_for_path(path: &str, extra: &[(&str, &str)]) -> Option<String> {
|
||||
|
||||
/// Enabled and checked state shared by all menu presentations.
|
||||
pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool) {
|
||||
let enabled = match id {
|
||||
Some(i)
|
||||
if i.starts_with("window.") || i.starts_with("view.mix_section") || matches!(i, "audiosuite.process" | "ui.dock" | "ui.dock.reset") =>
|
||||
{
|
||||
true
|
||||
let enabled = if app.ui.show_preferences {
|
||||
false
|
||||
} else {
|
||||
match id {
|
||||
Some("setup.preferences") => preferences_menu_enabled(app),
|
||||
Some(i)
|
||||
if i.starts_with("window.")
|
||||
|| i.starts_with("view.mix_section")
|
||||
|| matches!(i, "audiosuite.process" | "ui.dock" | "ui.dock.reset") =>
|
||||
{
|
||||
true
|
||||
}
|
||||
Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok() && !app.engine.blocked_while_recording(i)),
|
||||
None => false,
|
||||
}
|
||||
Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok() && !app.engine.blocked_while_recording(i)),
|
||||
None => false,
|
||||
};
|
||||
(enabled, checked_state(app, path, id))
|
||||
}
|
||||
|
||||
/// Preferences cannot replace audio resources while transport is active or another Preferences
|
||||
/// instance is already modal.
|
||||
fn preferences_menu_enabled(app: &SoundApp) -> bool {
|
||||
!app.ui.show_preferences && !app.is_playing() && !app.engine.transport.recording
|
||||
}
|
||||
|
||||
fn checked_state(app: &SoundApp, path: &str, id: Option<&str>) -> bool {
|
||||
let e = &app.engine.session().edit;
|
||||
match id {
|
||||
@@ -469,6 +493,10 @@ pub fn live_label(app: &SoundApp, id: Option<&str>) -> Option<String> {
|
||||
|
||||
/// Menu-style invocation: may open a dialog.
|
||||
pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
|
||||
if id == "setup.preferences" {
|
||||
let _ = app.run(id, json!({"value": true}));
|
||||
return;
|
||||
}
|
||||
if let Some((_, w)) = MENU_WINDOWS.iter().find(|(p, _)| *p == path)
|
||||
&& w.starts_with("window.")
|
||||
{
|
||||
@@ -587,6 +615,15 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
||||
"window.midi_ops" => toggle(&mut app.ui.show_midi_ops),
|
||||
"window.rtp" => toggle(&mut app.ui.show_rtp),
|
||||
"window.playback_engine" => toggle(&mut app.ui.show_playback_engine),
|
||||
// The engine catalog already exposes this command. Handling it here keeps the menu,
|
||||
// control channel and scripted callers on the same public command id.
|
||||
"setup.preferences" => {
|
||||
let result = toggle(&mut app.ui.show_preferences);
|
||||
if !app.ui.show_preferences {
|
||||
app.preferences.discard_draft();
|
||||
}
|
||||
result
|
||||
}
|
||||
"window.io_setup" => toggle(&mut app.ui.show_io_setup),
|
||||
"window.shortcuts" => toggle(&mut app.ui.show_shortcuts),
|
||||
"window.color_palette" => toggle(&mut app.ui.show_color_palette),
|
||||
@@ -640,6 +677,8 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
||||
app.ui.show_undo_history = false;
|
||||
app.ui.show_session_info = false;
|
||||
app.ui.show_audio_health = false;
|
||||
app.ui.show_preferences = false;
|
||||
app.preferences.discard_draft();
|
||||
app.ui.show_automation = false;
|
||||
app.ui.plugin_windows.clear();
|
||||
app.ui.audiosuite = None;
|
||||
@@ -694,6 +733,23 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
||||
app.ui.theme = mode;
|
||||
json!({"mode": mode.id()})
|
||||
}
|
||||
"ui.reduce_visual_effects" => {
|
||||
let Some(mode) = p.get("mode").and_then(Value::as_str) else {
|
||||
return Some(Ok(json!({"mode": app.ui.reduce_visual_effects.id()})));
|
||||
};
|
||||
let Some(mode) = crate::preferences::ReduceVisualEffects::parse(mode) else {
|
||||
return Some(Err(format!("unknown visual-effects mode `{mode}` (never, playback or always)")));
|
||||
};
|
||||
app.ui.reduce_visual_effects = mode;
|
||||
json!({"mode": mode.id()})
|
||||
}
|
||||
"ui.undo_levels" => {
|
||||
if let Some(levels) = p.get("levels").and_then(Value::as_u64) {
|
||||
app.ui.undo_levels = usize::try_from(levels).unwrap_or(64).clamp(1, 64);
|
||||
app.engine.set_history_limit(app.ui.undo_levels);
|
||||
}
|
||||
json!({"levels": app.ui.undo_levels})
|
||||
}
|
||||
"ui.set" => match serde_json::to_value(&app.ui) {
|
||||
Ok(mut cur) => {
|
||||
if let (Some(obj), Some(patch)) = (cur.as_object_mut(), p.as_object()) {
|
||||
@@ -706,8 +762,14 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
||||
if let Err(error) = crate::docking::validate(&ns.docking).and_then(|()| crate::docking::validate_mix(&ns.mix_docking)) {
|
||||
return Some(Err(error));
|
||||
}
|
||||
let closed_preferences = app.ui.show_preferences && !ns.show_preferences;
|
||||
crate::docking::cancel_replaced_state(app, &ns);
|
||||
app.ui = ns;
|
||||
app.ui.undo_levels = app.ui.undo_levels.clamp(1, 64);
|
||||
app.engine.set_history_limit(app.ui.undo_levels);
|
||||
if closed_preferences {
|
||||
app.preferences.discard_draft();
|
||||
}
|
||||
json!({"ui": app.ui})
|
||||
}
|
||||
Err(e) => return Some(Err(format!("bad ui state: {e}"))),
|
||||
@@ -731,6 +793,49 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{Services, SoundApp};
|
||||
|
||||
/// Preferences menu entries follow transport safety and the modal window locks every menu.
|
||||
#[test]
|
||||
fn preferences_menu_state_tracks_transport_and_modality() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
assert!(item_state(&app, "Setup > Preferences...", Some("setup.preferences")).0);
|
||||
|
||||
app.engine.transport.playing = true;
|
||||
assert!(!item_state(&app, "Setup > Preferences...", Some("setup.preferences")).0);
|
||||
app.engine.transport.playing = false;
|
||||
app.engine.transport.recording = true;
|
||||
assert!(!item_state(&app, "Setup > Preferences...", Some("setup.preferences")).0);
|
||||
|
||||
app.engine.transport.recording = false;
|
||||
app.ui.show_preferences = true;
|
||||
assert!(!item_state(&app, "Setup > Preferences...", Some("setup.preferences")).0);
|
||||
assert!(!item_state(&app, "Window > About", Some("window.about")).0);
|
||||
assert!(!item_state(&app, "File > Save Session", Some("session.save")).0);
|
||||
}
|
||||
|
||||
/// Re-selecting Preferences through either menu keeps its modal window open.
|
||||
#[test]
|
||||
fn preferences_menu_always_opens_instead_of_toggling() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
invoke_menu(&mut app, "setup.preferences", "Setup > Preferences...");
|
||||
assert!(app.ui.show_preferences);
|
||||
invoke_menu(&mut app, "setup.preferences", "Setup > Preferences...");
|
||||
assert!(app.ui.show_preferences);
|
||||
}
|
||||
|
||||
/// Generic UI-state updates clamp and immediately synchronize the engine history limit.
|
||||
#[test]
|
||||
fn ui_set_synchronizes_undo_levels_immediately() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
|
||||
let result = app.run("ui.set", json!({"undo_levels": 0})).unwrap();
|
||||
assert_eq!(result["ui"]["undo_levels"], 1);
|
||||
assert_eq!((app.ui.undo_levels, app.engine.history_limit), (1, 1));
|
||||
|
||||
let result = app.run("ui.set", json!({"undo_levels": 100})).unwrap();
|
||||
assert_eq!(result["ui"]["undo_levels"], 64);
|
||||
assert_eq!((app.ui.undo_levels, app.engine.history_limit), (64, 64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_view_aliases_are_allocated_once() {
|
||||
let first = ui_aliases();
|
||||
|
||||
@@ -7,7 +7,7 @@ pub(super) fn about(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
let tab_id = egui::Id::unique("about_tab");
|
||||
egui::Window::new("About SoundCraft").open(&mut open).default_size(vec2(640.0, 420.0)).collapsible(false).show(ctx, |ui| {
|
||||
egui::Window::new("About SoundCraft").open(&mut open).default_size(vec2(820.0, 420.0)).collapsible(false).show(ctx, |ui| {
|
||||
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
|
||||
ui.horizontal(|ui| {
|
||||
for (i, l) in (0u8..).zip(["About", "Contributors", "Models"]) {
|
||||
|
||||
@@ -16,7 +16,6 @@ pub struct ExtraState {
|
||||
pub show_ui_customization: bool,
|
||||
pub auto_update_config: bool,
|
||||
pub ui_scale: f32,
|
||||
pub reduce_motion: bool,
|
||||
pub tooltips: bool,
|
||||
last_auto_update: f64,
|
||||
applied: Option<(f32, bool)>,
|
||||
@@ -29,7 +28,6 @@ impl Default for ExtraState {
|
||||
show_ui_customization: false,
|
||||
auto_update_config: false,
|
||||
ui_scale: 1.0,
|
||||
reduce_motion: false,
|
||||
tooltips: true,
|
||||
last_auto_update: 0.0,
|
||||
applied: None,
|
||||
@@ -59,7 +57,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
}
|
||||
|
||||
fn apply_customization(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let want = (app.extra.ui_scale.clamp(0.75, 1.5), app.extra.reduce_motion);
|
||||
let reduce = match app.ui.reduce_visual_effects {
|
||||
crate::preferences::ReduceVisualEffects::Never => false,
|
||||
crate::preferences::ReduceVisualEffects::Playback => app.is_playing(),
|
||||
crate::preferences::ReduceVisualEffects::Always => true,
|
||||
};
|
||||
let want = (app.extra.ui_scale.clamp(0.75, 1.5), reduce);
|
||||
// Rescaling mid-drag would move the Interface scale slider out from under the pointer.
|
||||
if app.extra.applied == Some(want) || ctx.input(|i| i.pointer.any_down()) {
|
||||
return;
|
||||
@@ -163,7 +166,17 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
.custom_formatter(|v, _| format!("{:.0} %", v * 100.0))
|
||||
.text("Interface scale"),
|
||||
);
|
||||
crate::widgets::checkbox(ui, &mut app.extra.reduce_motion, "Reduce motion");
|
||||
let mut chosen_effects = None;
|
||||
craft_ui::ComboBox::from_label("Reduce visual effects").selected_text(app.ui.reduce_visual_effects.label()).show_ui(ui, |ui| {
|
||||
for mode in crate::preferences::ReduceVisualEffects::ALL {
|
||||
if ui.selectable_label(app.ui.reduce_visual_effects == mode, mode.label()).clicked() {
|
||||
chosen_effects = Some(mode);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(mode) = chosen_effects {
|
||||
let _ = app.run("ui.reduce_visual_effects", json!({"mode": mode.id()}));
|
||||
}
|
||||
let mut chosen = None;
|
||||
craft_ui::ComboBox::from_label("Theme").selected_text(app.ui.theme.label()).show_ui(ui, |ui| {
|
||||
for mode in crate::theme::ThemeMode::ALL {
|
||||
@@ -179,7 +192,7 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
ui.add_space(4.0);
|
||||
if ui.button("Reset").clicked() {
|
||||
app.extra.ui_scale = 1.0;
|
||||
app.extra.reduce_motion = false;
|
||||
let _ = app.run("ui.reduce_visual_effects", json!({"mode": "never"}));
|
||||
let _ = app.run("ui.theme", json!({"mode": crate::theme::ThemeMode::default().id()}));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -408,7 +408,7 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
app.ui.show_configurations = open;
|
||||
}
|
||||
|
||||
/// Restore a saved window configuration, keeping the configuration list and the appearance.
|
||||
/// Restore a window layout while retaining current application preferences and any open draft.
|
||||
/// A configuration saved before the current panel layouts gets the default layouts.
|
||||
pub fn recall_configuration(app: &mut SoundApp, saved: &Value) -> Result<(), String> {
|
||||
let mut saved = saved.clone();
|
||||
@@ -417,8 +417,14 @@ pub fn recall_configuration(app: &mut SoundApp, saved: &Value) -> Result<(), Str
|
||||
crate::docking::validate(&st.docking).and_then(|()| crate::docking::validate_mix(&st.mix_docking))?;
|
||||
st.configurations = app.ui.configurations.clone();
|
||||
st.theme = app.ui.theme;
|
||||
st.show_preferences = app.ui.show_preferences;
|
||||
st.preferences_page = app.ui.preferences_page;
|
||||
st.preferences_window_pos = app.ui.preferences_window_pos;
|
||||
st.reduce_visual_effects = app.ui.reduce_visual_effects;
|
||||
st.undo_levels = app.ui.undo_levels.clamp(1, 64);
|
||||
st.show_configurations = true;
|
||||
crate::docking::cancel_replaced_state(app, &st);
|
||||
app.engine.set_history_limit(st.undo_levels);
|
||||
app.ui = st;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -59,7 +59,7 @@ fn search_chord(m: Modifiers, mac: bool) -> bool {
|
||||
pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
// Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom.
|
||||
ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
||||
if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() {
|
||||
if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() || app.ui.show_preferences {
|
||||
return;
|
||||
}
|
||||
// A scripted press is identified by its exact position in this pass's events, not by matching
|
||||
|
||||
@@ -457,6 +457,8 @@ fn a_preset_changed_in_an_effect_editor_is_stored_once() {
|
||||
fn preferences_saved_before_the_window_was_remembered_keep_full_scale() {
|
||||
let ui: super::UiState = serde_json::from_value(json!({"show_transport": true})).unwrap();
|
||||
assert_eq!((ui.window_size, ui.window_maximized, ui.ui_scale), ([1600.0, 1000.0], false, 1.0));
|
||||
assert_eq!(ui.reduce_visual_effects, crate::preferences::ReduceVisualEffects::Never);
|
||||
assert_eq!(ui.undo_levels, 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,6 +3,7 @@ use egui::{Event, Key, Modifiers, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use serde_json::json;
|
||||
use soundcraft_model::TrackId;
|
||||
use soundcraft_ui_egui::dialogs::Dialog;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, menus};
|
||||
|
||||
/// Selects the whole Kick track and opens Edit > Strip Silence from the menu.
|
||||
@@ -89,3 +90,58 @@ fn strip_silence_strip_removes_the_silence() {
|
||||
assert_eq!(strip, stripped(track));
|
||||
assert!(covered(&strip) < covered(&before));
|
||||
}
|
||||
|
||||
/// Values other than the ones Strip Silence first opens with.
|
||||
const CHANGED: [f64; 4] = [-30.0, 120.0, 12.0, 40.0];
|
||||
|
||||
/// Sets the open Strip Silence window's values, as dragging its sliders would.
|
||||
fn set_values(h: &mut Harness<'_, SoundApp>, values: [f64; 4]) {
|
||||
let Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) = &mut h.state_mut().dialogs.open else {
|
||||
panic!("Strip Silence is not open")
|
||||
};
|
||||
[*threshold, *min_ms, *pre_ms, *post_ms] = values;
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Opens Edit > Strip Silence again from the menu and returns the values it shows.
|
||||
fn reopen(h: &mut Harness<'_, SoundApp>) -> [f64; 4] {
|
||||
assert_eq!(h.state().dialogs.open_name(), None, "Strip Silence is closed before it opens again");
|
||||
menus::invoke_menu(h.state_mut(), "edit.strip_silence", "Edit > Strip Silence");
|
||||
h.run_steps(2);
|
||||
let Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) = h.state().dialogs.open.clone() else {
|
||||
panic!("Strip Silence did not open again")
|
||||
};
|
||||
[threshold, min_ms, pre_ms, post_ms]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_silence_opens_with_the_values_escape_closed_it_with() {
|
||||
let (mut h, _, _) = strip_silence_dialog();
|
||||
set_values(&mut h, CHANGED);
|
||||
h.event(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert_eq!(reopen(&mut h), CHANGED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_silence_opens_with_the_values_strip_used() {
|
||||
let (mut h, _, _) = strip_silence_dialog();
|
||||
set_values(&mut h, CHANGED);
|
||||
h.get_by_role_and_label(Role::Button, "Strip").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(reopen(&mut h), CHANGED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_silence_keeps_its_values_through_rename_and_cancel() {
|
||||
let (mut h, _, _) = strip_silence_dialog();
|
||||
set_values(&mut h, CHANGED);
|
||||
h.get_by_role_and_label(Role::Button, "Rename…").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_role_and_label(Role::Button, "Cancel").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialogs.open_name(), Some("strip_silence"), "Cancel in Rename goes back to Strip Silence");
|
||||
h.get_by_role_and_label(Role::Button, "Cancel").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(reopen(&mut h), CHANGED);
|
||||
}
|
||||
|
||||
+10
-7
@@ -14,7 +14,7 @@ flags, values and defaults. This page does not repeat them.
|
||||
| `convert` | Converts an audio file: resample, change the bit depth, normalize |
|
||||
| `run` | Runs engine commands on a new, demo or saved session; can bounce, save and inspect |
|
||||
| `script` | Runs a file of engine commands, one `ID JSON` per line |
|
||||
| `commands` | Lists the engine commands |
|
||||
| `commands` | Lists the engine commands; with `--json`, each one's details as `describe` prints them |
|
||||
| `describe` | Prints one engine command's details as JSON |
|
||||
| `app` | Calls a running app's control channel (see [the control protocol](control-protocol.md)) |
|
||||
| `mcp` | Serves MCP on stdin and stdout (see [the MCP guide](mcp.md)) |
|
||||
@@ -29,16 +29,19 @@ soundcraft-cli convert in.wav out.flac --bit-depth 16 --rate 44100
|
||||
soundcraft-cli run --demo --cmd 'mix.volume={"track":"Kick","db":-6}' --bounce mix.wav
|
||||
soundcraft-cli script steps.txt --demo --keep-going --save out.scraft
|
||||
soundcraft-cli commands bounce
|
||||
soundcraft-cli commands --json --compact
|
||||
soundcraft-cli describe file.bounce_mix
|
||||
```
|
||||
|
||||
## Output and exit codes
|
||||
|
||||
Results go to standard output, as JSON where a program is meant to read them. `info` (on an audio
|
||||
file), `describe`, `app` and the `--inspect` output of `run` and `script` pretty-print it; `--compact`
|
||||
prints the same JSON on one line. The result lines of `run` and `script` (`ID: JSON`) and the
|
||||
`--bounce` and `--save` results are always one line each. `commands`, `plugins`, `parity` and the
|
||||
`.scraft` summary of `info` are text. Errors go to standard error as `error: <message>`, and so do the warnings and errors SoundCraft and the plugin hosts log (`warning: <message>`, `error: <message>`), such as a plugin that fails to load. `mcp` writes only the protocol
|
||||
file), `describe`, `commands --json`, `app` and the `--inspect` output of `run` and `script`
|
||||
pretty-print it; `--compact` prints the same JSON on one line. The result lines of `run` and
|
||||
`script` (`ID: JSON`) and the `--bounce` and `--save` results are always one line each. `commands`
|
||||
(without `--json`), `plugins`, `parity` and the `.scraft` summary of `info` are text. The
|
||||
`enabled` and `disabled_reason` fields of `describe` and `commands --json` are worked out against
|
||||
an empty session, not a session you have open. Errors go to standard error as `error: <message>`, and so do the warnings and errors SoundCraft and the plugin hosts log (`warning: <message>`, `error: <message>`), such as a plugin that fails to load. `mcp` writes only the protocol
|
||||
to standard output.
|
||||
|
||||
| Exit code | Meaning |
|
||||
@@ -138,8 +141,8 @@ output replacing an input without `--in-place`, and the ast-grep rules in `lint/
|
||||
|
||||
Rules not followed yet:
|
||||
|
||||
- **JSON flag (rule 1.3).** `commands`, `plugins`, `parity` and the `info` summary of a `.scraft`
|
||||
session print text and have no `--json` flag. Whether such listings need one is an open question
|
||||
- **JSON flag (rule 1.3).** `plugins`, `parity` and the `info` summary of a `.scraft` session
|
||||
print text and have no `--json` flag (`commands` has one). Whether such listings need one is an open question
|
||||
in the RFC itself.
|
||||
- **`string-error` rule (section 3).** Its ast-grep rule is on for the CLI code and the shared flags,
|
||||
not yet for the rest of the workspace.
|
||||
|
||||
@@ -72,6 +72,8 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
|
||||
|---|---|---|
|
||||
| 2026-10-25 | minor | Zoom Toggle row: Alt+Shift+E and Alt-click cancel Zoom Toggle and keep the view. Readiness estimate unchanged |
|
||||
| 2026-10-25 | minor | Edit and Mix windows: Tracks and Groups and Clips in full-height dock columns. Readiness estimate unchanged |
|
||||
| 2026-10-20 | minor | Preferences uses one fixed non-scrolling size with compact stacked choices, removes the duplicate meter-type list, and command-backs visual-effects reduction and undo depth alongside the UI theme |
|
||||
| 2026-10-19 | minor | Preferences opens from Setup with nine searchable pages, compact grouped controls, staged apply/cancel, platform tab shortcuts and disabled placeholders for unavailable settings |
|
||||
| 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged |
|
||||
| 2026-10-13 | minor | A plugin opens when it's inserted (#486) |
|
||||
| 2026-10-13 | minor | Drag across checkboxes (#399) |
|
||||
|
||||
Reference in New Issue
Block a user