Compare commits

..
18 Commits
Author SHA1 Message Date
Dylan M. Taylor 088e270a5e refactor: split auto_pan process and audiosuite frame limit into helpers (#1104)
Reduces cognitive complexity flagged by SonarCloud (rust:S3776). Behaviour is unchanged:
per-sample order is envelope update, envelope trigger, LFO phase, modulator, width/manual/position
smoothing, output write.
2026-10-25 21:14:17 -04:00
aiongg 23f90a9164 chore(deps): safe dependency refresh (#1102) 2026-10-25 20:49:42 -04:00
Dylan M. Taylor 1c63b2a5b9 tests(clap-host): answer nextest's --list for the custom-harness editor test (#1100) 2026-10-25 20:14:33 -04:00
Dylan M. Taylor cf0f4d2f6a tests(vst3-host): answer nextest's --list for custom-harness tests (#1099) 2026-10-25 19:49:11 -04:00
Dylan M. Taylor e14cea42b2 Linux: default to XWayland for file drag-and-drop on Wayland desktops (#150)
* Linux: default to XWayland for file drag-and-drop on Wayland desktops

* Ignore an empty DISPLAY before forcing X11
2026-10-25 19:37:13 -04:00
Dylan M. Taylor d683aee3eb refactor: reduce cognitive complexity in audio-io, midi, dsp, engine, playback, plugin hosts (S3776) (#1097)
* refactor: reduce cognitive complexity in audio-io, midi, dsp, engine, playback, plugin hosts (S3776)

Extract helpers; behavior-preserving.

* fix: clippy lints in extracted helpers
2026-10-25 19:32:34 -04:00
Dylan M. Taylor 0197e27fc4 build: pin craft-update to craft-libs main (495e597) (#1096)
Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down.

Part of tracking issue storytold/craft-libs#319
2026-10-25 17:50:34 -04:00
Dylan M. TaylorandCodebuff 5bbefeae62 build: pin craft-update to craft-libs main (495e597) (#1092)
Picks up the update prompt fix that draws its close mark and version pill
without glyphs a font may lack, so the prompt no longer takes the app down
when a bundled face has no ✕ or →.

Part of tracking issue storytold/craft-libs#319

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-25 17:37:38 -04:00
Dylan M. Taylor a44d1b830b Merge pull request #1014 from storytold/fix/sonar-s107
refactor: bundle parameters of long signatures (Sonar S107)
2026-10-25 16:35:58 -04:00
Dylan M. Taylor eec559f057 build: pin craft-update to craft-libs main (db19c37) (#1090)
Picks up the fix that keeps the close request pending while the app holds unsaved work, the executable-name check in the Windows update helper, and the zip 9 / dirs-free build.

Part of tracking issue storytold/craft-libs#319
2026-10-25 15:34:58 -04:00
Dylan M. Taylor b854771251 feat: startup update prompt modal and direct self-updating (#1073)
* feat: startup update prompt modal and direct self-updating

Integrate `craft-update` pinned to storytold/craft-libs fe61243 to check for
updates on startup, display the standard suite update modal with changelog notes,
and support one-click in-app updating and restart.

Ref: storytold/craft-libs#319

* wip

* build: refresh the lock

* refactor(updates): drive the flow with craft-update's Updater

The check at start is opt-in for the desktop app, a successful update closes through the app's own close path, and no update starts over unsaved work.

* fix(updates): register the update command and hold the install back during a recording

Help ▸ Check for Updates… goes through the shared command id, and an active recording counts as work the update must not interrupt.
2026-10-25 14:38:14 -04:00
aiongg 0a95b6dadf Merge pull request #1045 from forwardaudio/meters-accumulate
Meters: keep every block's peak until the UI takes it
2026-10-25 12:12:08 -04:00
aiongg cf96cb888c Follow-ups to #1058: Auto Pan keeps the stereo image, paste and Strip Silence fixes (#1060)
* Auto Pan: balance the left and right channels at constant power instead of panning their mono sum

* Strip Silence rectangles: on the web, rescan when a slider drag ends instead of on every frame of it

* Paste: keep the clipboard's remapped Auto Rename keys only when the paste succeeds

* Subharmonic: test that Q 5 filters after the saturator keep the output finite

* AudioSuite window: drop the constant-speed varispeed controls no menu item opens

* docs: gaps: Clip Looping has no loop crossfade and caps Number of Loops at 512
2026-10-25 12:06:36 -04:00
skylar taylor-barrick e0951aecf9 Migrate SoundCraft to egui 0.37 (#1063)
* fix(ui): preserve host keys during scripted input and clarify mixer labels

* Preserve host input while expiring scripted gestures

* Document migrated audio and UI behavior and regression invariants
2026-10-25 10:48:56 -04:00
Forward AudioandClaude Opus 5.5 7d4ae23202 Meters: a channel-count change takes the new stereo peaks; takes drop deleted tracks
When a strip's channel count changes, the first-two-channel peaks now take the new values
along with the per-channel ones. A take that finds no new block still drops the meters of
deleted tracks from the UI's copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:26:03 +01:00
Forward AudioandClaude Opus 5.5 37e155c828 Meters: keep every block's peak until the UI takes it
The audio thread overwrote the shared meter snapshot every block (~94 blocks/s at 512
frames, 48 kHz) while the UI read it 20-60 times a second, so it only ever saw the latest
block and short transients were under-read. Blocks are now folded together (max of each
channel's peak and of the gain reduction; plugin failures keep the latest count) until the UI
takes them with Player::take_meters, which also starts a new period. Blocks rendered while the
UI holds the lock wait in an audio-side accumulator instead of being lost; the audio thread
still only try_locks.

Stopping, starting and locating now zero the meters in place instead of clearing the maps,
so playing again no longer re-allocates every track's entry on the audio thread. The UI
thread works out which tracks a session update deleted and tells the audio thread, which
drops just their entries.

The Mix window and the Renderer now read the snapshot the app takes once per poll instead of
reading the player themselves, so a period's peaks are not split between readers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:14:52 +02:00
Dylan M. TaylorandClaude Sonnet 5.5 7469834420 refactor: leave timebase_ruler, draw_clip, process_strip for their cognitive-complexity refactor
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 08:44:41 +00:00
Dylan M. TaylorandClaude Sonnet 5.5 e220666546 refactor: group parameters into structs for too-many-args functions (S107)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 08:32:19 +00:00
102 changed files with 9242 additions and 1698 deletions
+11 -1
View File
@@ -22,9 +22,19 @@ are bundled.
## Fonts
SoundCraft bundles no font files. It uses the default fonts shipped inside the `egui` crate
(Ubuntu-Light: Ubuntu Font Licence 1.0; Hack: MIT; Noto Emoji: OFL-1.1; emoji-icon-font: OFL-1.1/MIT)
(Ubuntu-Light: Ubuntu Font Licence 1.0; Hack: MIT; Noto Emoji: OFL-1.1; emoji-icon-font: MIT)
and, at runtime, the operating system's own UI fonts when available (never redistributed).
## Bundled egui font icons
These dependency fonts are embedded by the pinned egui font definitions; no font file is added
here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts.
| Asset | Author | Source | Licence |
|---|---|---|---|
| `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in NOTICE) |
| `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in NOTICE) |
## Files
| Path | Author | Source | License |
Generated
+801 -289
View File
File diff suppressed because it is too large Load Diff
+29 -5
View File
@@ -49,13 +49,19 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
# Video containers and bitstreams, shared with the other craft apps.
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" }
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", features = ["serde"] }
# egui 0.37 needs craft-libs#199 (craft-ui) first; pin 0.36 until it lands.
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
# 650b1c0b058017c4eb3af06d5b39586d6732e07b is the revision produced by the separate craft-libs current-main-0135
# integration: 25da's egui-0.37 port + 6e7f's docking Workspace/DockCommand + final egui/winit
# pins. Raw 6e7f/16fb3d46 contain the older egui 0.36 UI; keep this reviewed
# immutable revision to retain the required docking APIs on egui 0.37.
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "650b1c0b058017c4eb3af06d5b39586d6732e07b", features = ["serde"] }
egui = { version = "0.37" }
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"
egui_kittest = "0.37"
rfd = "0.17"
image = { version = "0.25", default-features = false, features = ["png"] }
@@ -81,3 +87,21 @@ opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
# Keep the complete egui family and native event bridge on reviewed fork commits.
[patch.crates-io]
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+274 -3
View File
@@ -12,6 +12,277 @@ Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
list (author, source, license) is in ATTRIBUTION.md.
None: every bundled asset is original to SoundCraft. Icons are drawn in code; demo audio is
synthesised in code. The egui default fonts (Ubuntu-Light, Hack, Noto Emoji, Emoji Icon Font) ship
inside the egui crate under their own open licenses (see ATTRIBUTION.md).
Original SoundCraft material: icons are drawn in code; demo audio is synthesised in code.
The egui default fonts (Ubuntu-Light, Hack, Noto Emoji, Emoji Icon Font) ship inside the egui crate
under their own open licenses (see ATTRIBUTION.md).
Default egui fonts (dependency assets)
The pinned egui 0.37 dependency embeds these faces through `default_fonts` and
`monochrome_emoji_fonts`:
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
No font file is copied into this repository. The full upstream notices follow.
egui icon font subset (dependency asset)
----------------------------------------
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
uses the same MIT license. Upstream license text follows unchanged.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Noto Emoji font (dependency asset)
----------------------------------
epaint_default_fonts 0.37, pinned with egui 0.37 at
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts,
embeds NotoEmoji-Regular.ttf, the monochrome emoji fallback used when egui's
`monochrome_emoji_fonts` feature is enabled. The font's copyright notice and the
upstream SIL Open Font License 1.1 text follow unchanged.
Copyright 2013 Google Inc. All Rights Reserved.
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Ubuntu Light font (dependency asset)
------------------------------------
epaint_default_fonts 0.37, pinned with egui 0.37 at
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts,
embeds Ubuntu-Light.ttf as a default fallback. The font's copyright notice and the
upstream Ubuntu Font Licence 1.0 text follow unchanged.
Copyright 2011 Canonical Ltd. Licensed under the Ubuntu Font Licence 1.0
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
Hack font (dependency asset)
----------------------------
epaint_default_fonts 0.37, pinned with egui 0.37 at
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts,
embeds Hack-Regular.ttf as the default monospace fallback. The upstream notice and
licences follow unchanged.
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
The work in the DejaVu project was committed to the public domain.
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
MIT License
Copyright (c) 2018 Source Foundry Authors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
BITSTREAM VERA LICENSE
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** trivial (Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
@@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track
## Progress log
- **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change.
- **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change.
- **2026-10-24:** While the Strip Silence window is open, the Edit window draws a rectangle around each range that Strip would keep on the selected tracks, padded by Clip Start Pad and Clip End Pad, and the rectangles follow the sliders as they move. They are removed when the window closes. `edit.strip_silence_preview` returns the same ranges without changing the session. No readiness percentage change.
- **2026-10-24:** The Strip Silence window has a Rename button. It opens Rename Selected Clips, where a name, a starting number, a count of zeros and a suffix set the names of the clips that Strip, Extract and Separate leave in the selection (for example SFX023.Reel1, SFX024.Reel1). The app keeps the scheme for later uses of Strip Silence until the app quits, or until Clear and then OK empty it. No readiness percentage change.
@@ -174,6 +175,7 @@ mutated and truncated movies. Field reports from the first public week are track
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Strip Silence rectangles; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Strip Silence Rename; readiness estimates unchanged |
+2 -2
View File
@@ -15,10 +15,10 @@ path = "src/main.rs"
[target.'cfg(target_arch = "wasm32")'.dependencies]
soundcraft-engine = { workspace = true }
soundcraft-ui-egui = { workspace = true }
soundcraft-ui-egui.workspace = true
soundcraft-playback = { workspace = true }
soundcraft-model = { workspace = true }
egui = { workspace = true }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
log = { workspace = true }
serde_json = { workspace = true }
rfd = { workspace = true }
+7 -4
View File
@@ -137,11 +137,12 @@ impl WebShell {
});
}
/// Read files dropped on the browser window asynchronously: audio for an instrument track or a Sampler goes to
/// that sampler, while other drops are classified by extension and handled on a later frame.
fn take_drops(&mut self, ctx: &egui::Context) {
// In the browser a dropped file's `path()` is only its name: read its bytes instead.
// Audio dropped on an instrument track or a Sampler window goes to its sampler instead.
let dropped = self.app.take_dropped_files(ctx);
ctx.input_mut(|i| i.raw.dropped_files.clear());
let dropped = self.app.take_dropped_files();
for read in self.app.take_sample_reads() {
let (samples, ctx) = (self.samples.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
@@ -208,8 +209,10 @@ impl eframe::App for WebShell {
self.take_path_prompt(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
/// Forward raw input to the shared app hook so scripted gestures and host-key protection behave like the desktop
/// shell.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
+8 -2
View File
@@ -18,12 +18,15 @@ asio = ["soundcraft-playback/asio"]
soundcraft-engine.workspace = true
soundcraft-ui-egui.workspace = true
soundcraft-playback.workspace = true
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui.workspace = true
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "clipboard"] }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
serde_json.workspace = true
log.workspace = true
chrono.workspace = true
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd.workspace = true
@@ -35,3 +38,6 @@ winresource = "0.1"
muda = { version = "0.21.2", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] }
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
+27 -13
View File
@@ -10,7 +10,7 @@ use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
use std::time::Duration;
/// Longest accepted request line (bytes, without the newline). The largest payload a request
@@ -22,9 +22,15 @@ const MAX_LINE: usize = soundcraft_engine::model::b64::MAX_DECODED_BYTES.div_cei
const _: () = assert!(MAX_LINE <= 96 << 20);
/// Connections served at once; further ones get an error line and are closed.
const MAX_CONNECTIONS: usize = 16;
/// Requests that may wait for the UI thread. Each connection handles one request at a time, so
/// this matches [`MAX_CONNECTIONS`] and makes the transport's bound explicit instead of relying
/// on an unbounded channel.
const MAX_QUEUED_REQUESTS: usize = MAX_CONNECTIONS;
/// Bind the loopback control listener and return the request receiver. Port 0 picks a free one and the actual port is
/// printed; bind or spawn failures are logged instead of panicking.
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let (tx, rx) = sync_channel::<ControlRequest>(MAX_QUEUED_REQUESTS);
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
@@ -52,7 +58,9 @@ impl Drop for Slot {
}
}
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
/// Accept until the listener closes, admitting at most [`MAX_CONNECTIONS`] peers; excess peers get one error line.
/// Each admitted connection is served on its own thread, releasing its slot on exit.
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
@@ -94,7 +102,9 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
Ok(true)
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
/// Answer newline-delimited JSON requests for one connection: bound each line, forward it to the UI thread with a 90
/// s reply deadline, and stop at EOF, invalid UTF-8 or a closed request queue. Bad JSON still gets an error reply.
fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
@@ -119,15 +129,19 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
match tx.try_send(req) {
Ok(()) => {
ctx.request_repaint();
let mut r = rrx.recv_timeout(Duration::from_secs(90)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
r
}
// Refuse rather than grow or block: the UI drains a bounded batch each tick.
Err(TrySendError::Full(_)) => json!({"ok": false, "id": id, "error": "control queue full"}),
Err(TrySendError::Disconnected(_)) => break,
}
ctx.request_repaint();
let mut r = rrx.recv_timeout(Duration::from_secs(90)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
};
@@ -175,7 +189,7 @@ mod tests {
fn serve_echo() -> std::net::SocketAddr {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let (tx, rx) = sync_channel::<ControlRequest>(MAX_QUEUED_REQUESTS);
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
std::thread::spawn(move || {
for req in rx {
+24 -4
View File
@@ -27,7 +27,7 @@ struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "mac
/// Sends the title only when it changes, remembered here as X11 and Wayland can't read it back.
fn set_title(ctx: &egui::Context, title: String) {
let id = egui::Id::new("window_title");
let id = egui::Id::unique("window_title");
if ctx.data(|d| d.get_temp::<String>(id)).as_deref() != Some(title.as_str()) {
ctx.data_mut(|d| d.insert_temp(id, title.clone()));
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
@@ -35,6 +35,8 @@ fn set_title(ctx: &egui::Context, title: String) {
}
impl eframe::App for App {
/// Per-frame app logic: report audio-thread logs, run the shared app logic with the macOS menu driven around it,
/// then handle dropped paths, quit/close requests, window geometry, recent-list persistence and the window title.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// What the realtime audio thread logged is written here, off that thread.
if let Some(logger) = self.1 {
@@ -52,7 +54,7 @@ impl eframe::App for App {
// Files dropped on the window: audio on an instrument track or a Sampler window loads
// the sampler; otherwise sessions open and audio/MIDI import.
let dropped: Vec<String> =
self.0.take_dropped_files(ctx).iter().map(|f| f.path().to_string_lossy().into_owned()).filter(|s| !s.is_empty()).collect();
self.0.take_dropped_files().iter().map(|f| f.path().to_string_lossy().into_owned()).filter(|s| !s.is_empty()).collect();
for p in dropped {
open_path(&mut self.0, &p);
}
@@ -86,8 +88,10 @@ impl eframe::App for App {
};
set_title(ctx, title);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
/// Forward raw input to the shared app hook so scripted gestures and host-key protection behave like the desktop
/// shell.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.guarded(|app| app.ui(ui));
@@ -206,6 +210,11 @@ fn app_icon() -> Option<egui::IconData> {
}
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::SOUNDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so every start-up record (and the engine's panic hook, installed with the first
// Engine) is captured; see `logging`.
let logger = logging::install();
@@ -265,6 +274,16 @@ fn main() -> eframe::Result {
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
#[cfg(all(unix, not(target_os = "macos")))]
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; SOUNDCRAFT_WAYLAND=1
// keeps the native Wayland backend.
if std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()) && std::env::var_os("SOUNDCRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
eframe::run_native(
"SoundCraft",
options,
@@ -272,6 +291,7 @@ fn main() -> eframe::Result {
let player =
if no_audio { None } else { Some(soundcraft_playback::Player::with_config(Arc::new(engine.session().clone()), &prefs.audio)) };
let mut app = SoundApp::new(engine, player, services());
app.set_startup_update_check(true);
app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave")));
app.leftovers = app.autosave_dir.as_deref().map(soundcraft_engine::autosave::leftovers).unwrap_or_default();
app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets")));
+1 -1
View File
@@ -14,7 +14,7 @@ fn malformed(what: &str) -> AudioError {
AudioError::Malformed(format!("CAF {what} is larger than the file"))
}
/// Walk the chunks the way symphonia 0.5 reads them: it advances by the bytes it parses, not by
/// Walk the chunks the way the CAF reader reads them: it advances by the bytes it parses, not by
/// the declared size, and after a `data` chunk of unknown size it reads on into the audio.
fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
// Whether the packet table lists each packet's size and frame count (variable per packet).
+13 -3
View File
@@ -137,14 +137,17 @@ impl Default for EncodeOptions {
}
}
/// Size of an ID3v2 tag at the start of `b`, if there is one.
/// Size of an ID3v2 tag at the start of `b`, if there is one. The 10-byte footer flag (`0x10`)
/// exists only in ID3v2.4; in v2.2/v2.3 that bit is reserved and must not extend the skip.
fn id3v2_len(b: &[u8]) -> Option<usize> {
if b.get(..3)? != b"ID3" {
return None;
}
let version = *b.get(3)?;
let flags = *b.get(5)?;
let size = b.get(6..10)?.iter().fold(0usize, |acc, &x| (acc << 7) | usize::from(x & 0x7F));
Some(10 + size + if flags & 0x10 != 0 { 10 } else { 0 })
let footer = version == 4 && flags & 0x10 != 0;
Some(10 + size + if footer { 10 } else { 0 })
}
fn detect_by_magic(b: &[u8], depth: u32) -> Option<FileFormat> {
@@ -318,7 +321,14 @@ impl<W: Write + Seek> FileEncoder<W> {
pub fn new(out: W, opts: &EncodeOptions, channels: usize, sample_rate: u32, mask: Option<u32>) -> Result<Self> {
let (depth, dither) = (opts.bit_depth, opts.dither);
Ok(FileEncoder(match opts.format {
FileFormat::Wav => Writer::Wav(wav::Writer::new(out, channels, sample_rate, depth, dither, opts.bwf.as_ref(), mask, false)?),
FileFormat::Wav => Writer::Wav(wav::Writer::new(
out,
channels,
sample_rate,
depth,
dither,
wav::HeaderExtras { bwf: opts.bwf.as_ref(), mask, force_rf64: false },
)?),
FileFormat::Aiff => Writer::Aiff(aiff::Writer::new(out, channels, sample_rate, depth, dither)?),
FileFormat::Flac => Writer::Flac(flac::Writer::new(out, channels, sample_rate, depth, dither)?),
other => return Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))),
+484 -123
View File
@@ -1,9 +1,8 @@
//! Decode path for everything that isn't native PCM WAV/AIFF, backed by `symphonia`.
use std::io::Cursor;
use std::io::{Cursor, Seek, SeekFrom};
use std::panic::{AssertUnwindSafe, catch_unwind};
use symphonia::core::codecs::CodecParameters;
use symphonia::core::codecs::audio::well_known::{
CODEC_ID_AAC, CODEC_ID_ADPCM_G722, CODEC_ID_ADPCM_G726, CODEC_ID_ADPCM_G726LE, CODEC_ID_ADPCM_IMA_QT, CODEC_ID_ADPCM_IMA_WAV, CODEC_ID_ADPCM_MS,
CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP1, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_OPUS, CODEC_ID_PCM_ALAW, CODEC_ID_PCM_F32BE, CODEC_ID_PCM_F32LE,
@@ -11,9 +10,9 @@ use symphonia::core::codecs::audio::well_known::{
};
use symphonia::core::codecs::audio::{AudioCodecId, AudioCodecParameters, AudioDecoderOptions, CODEC_ID_NULL_AUDIO};
use symphonia::core::errors::Error as SymError;
use symphonia::core::formats::probe::Hint;
use symphonia::core::formats::{FormatOptions, FormatReader};
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ReadBytes};
use symphonia::core::formats::probe::{Hint, ProbeOptions, ProbeableFormat, Score, Scoreable};
use symphonia::core::formats::{FormatOptions, FormatReader, Track};
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ScopedStream};
use symphonia::core::meta::MetadataOptions;
use crate::pcm::{MAX_DECODE_SAMPLES, be_u32};
@@ -23,6 +22,45 @@ use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat
/// takes its frame length from the file (encoders use 4096).
const MAX_PACKET_FRAMES: u64 = 1 << 20;
/// How far [`first_marker`] looks for the first format marker: the same 1 MiB a default symphonia
/// probe scans.
const MAX_PREFIX_SCAN: usize = 1 << 20;
/// How many MPEG/ADTS candidates [`first_marker`] may hand to the readers' expensive `Scoreable`
/// implementations. A crafted 1 MiB prefix of repeated sync markers otherwise asks for hundreds
/// of thousands of scores, each allowed up to symphonia's 16 KiB score depth. Once the budget is
/// spent, the scan stops at the next audio candidate: a container-like byte string later in an
/// unscored audio payload must not become authoritative. A container marker reached first is still
/// checked; an unchecked one the probe selects is refused after probing.
const MAX_AUDIO_SCORE_ATTEMPTS: usize = 256;
/// A per-scan budget for `Scoreable::score` calls. Taking a slot consumes it even when the score
/// rejects the candidate, so a stream of false syncs cannot multiply the work.
struct ScoreBudget {
used: usize,
}
impl ScoreBudget {
/// Start a scan with no expensive score attempts spent.
fn new() -> Self {
Self { used: 0 }
}
/// Spend one score attempt, returning false once the per-scan budget is exhausted.
fn take(&mut self) -> bool {
if self.exhausted() {
return false;
}
self.used = self.used.saturating_add(1);
true
}
/// Whether no attempt is left for this scan.
fn exhausted(&self) -> bool {
self.used >= MAX_AUDIO_SCORE_ATTEMPTS
}
}
/// Turn a symphonia error into the audio layer's error, keeping "unsupported" and "too large"
/// distinct from a malformed file.
fn map_err(e: SymError) -> AudioError {
@@ -44,61 +82,84 @@ enum Opened {
Empty(AudioInfo),
}
/// Skip a leading ID3v2 tag before probing.
///
/// Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims, so the tag is skipped
/// with [`ReadBytes::ignore_bytes`] (which allocates nothing) instead of being handed to the
/// metadata reader. Returns the stream position the container starts at.
fn skip_leading_id3(mss: &mut MediaSourceStream<'_>, bytes: &[u8]) -> Result<usize> {
/// Decoder options for every compressed format. Gapless playback used to be a
/// `FormatOptions` flag in symphonia 0.5; in 0.6 the decoder option (on by default) trims the
/// encoder delay and padding that the demuxer reports on each packet.
fn decoder_options() -> AudioDecoderOptions {
AudioDecoderOptions::default().gapless(true)
}
/// The first byte after any ID3v2 tags at the start of `b`. Symphonia's tag reader allocates
/// whatever a frame claims, so the tags are skipped, not read: they are not used here.
fn skip_id3v2(b: &[u8]) -> usize {
let mut at = 0usize;
loop {
let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX);
let head = bytes.get(at..).unwrap_or_default();
if head.len() < 10 || head.get(..3) != Some(b"ID3") {
return Ok(at);
match b.get(at..).and_then(crate::id3v2_len) {
Some(n) if n > 0 => at = at.saturating_add(n),
_ => return at,
}
let size = head.get(6..10).map(|size| size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f))).unwrap_or(0);
mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?;
}
}
/// How far symphonia's probe scans for a format marker before giving up.
const MAX_PROBE_DEPTH: usize = 1024 * 1024;
/// The container marker each detected format starts with, if its reader needs size checks first.
fn container_markers(detected: FileFormat) -> &'static [&'static [u8; 4]] {
match detected {
// RF64 and BW64 are the >4 GiB RIFF forms.
FileFormat::Wav => &[b"RIFF", b"RF64", b"BW64"],
FileFormat::Flac => &[b"fLaC"],
FileFormat::Caf => &[b"caff"],
// MP4/M4A put `ftyp` after the first atom's size field.
FileFormat::Aac | FileFormat::Alac => &[b"ftyp"],
// Unknown files may carry junk before the container; symphonia's probe scans for markers.
FileFormat::Other => &[b"caff", b"ftyp", b"fLaC", b"RIFF"],
FileFormat::Aiff | FileFormat::Mp3 | FileFormat::Ogg => &[],
}
/// The first format marker the probe can reach from `start`, within its 1 MiB scan limit.
#[derive(Clone, Copy)]
enum FirstMarker {
Flac(usize),
Caf(usize),
Mp4(usize),
Wav(usize),
/// MPEG audio, Ogg, Matroska or AIFF: formats with no separate container size check.
Audio,
}
/// The probe short names a container marker stands for.
fn expected_probe_format(marker: &[u8; 4]) -> &'static [&'static str] {
match marker {
b"caff" => &["caf"],
// The isomp4 reader reads the `ftyp` atom.
b"ftyp" => &["isomp4"],
b"fLaC" => &["flac"],
b"RIFF" | b"RF64" | b"BW64" => &["wave"],
_ => &[],
}
/// What the preflight container checks covered before the probe built a reader.
enum Precheck {
/// A zero-frame FLAC: returned without a reader, because Symphonia 0.6's reader needs a first
/// audio frame to resynchronize to.
Empty(AudioInfo),
/// The size checks ran for the container the probe reports under this short name.
Checked(&'static str),
/// No container size check was needed.
Unchecked,
}
/// Probe short names whose demuxer allocates from sizes in the file, so the matching container
/// check has to run before the stream is decoded.
const SIZE_CHECKED_PROBE_FORMATS: &[&str] = &["caf", "flac", "isomp4", "wave"];
/// Find the first container marker at or after `at`, within symphonia's probe depth. A RIFF form
/// other than WAVE is skipped, as the probe skips it and reads a WAVE behind it.
fn find_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) -> Option<(usize, &'a [u8; 4])> {
let end = bytes.len().min(at.saturating_add(MAX_PROBE_DEPTH));
/// The container markers to check for the family `detected` names. `None` is a raw audio stream
/// (MP3, Ogg, AIFF), whose payload may contain container-like bytes: the unrestricted scan below
/// finds a container only when one starts the stream, before any audio marker.
fn container_markers(detected: FileFormat) -> Option<&'static [&'static [u8; 4]]> {
match detected {
// RF64 and BW64 are the >4 GiB RIFF forms.
FileFormat::Wav => Some(&[b"RIFF", b"RF64", b"BW64"]),
FileFormat::Flac => Some(&[b"fLaC"]),
FileFormat::Caf => Some(&[b"caff"]),
// MP4/M4A put `ftyp` after the first atom's size field.
FileFormat::Aac | FileFormat::Alac => Some(&[b"ftyp"]),
// Unknown files may carry junk before the container; symphonia's probe scans for markers.
FileFormat::Other => Some(&[b"caff", b"ftyp", b"fLaC", b"RIFF"]),
FileFormat::Aiff | FileFormat::Mp3 | FileFormat::Ogg => None,
}
}
/// The probe short name a container marker stands for.
fn expected_probe_format(marker: &[u8; 4]) -> &'static str {
match marker {
b"caff" => "caf",
// The isomp4 reader reads the `ftyp` atom.
b"ftyp" => "isomp4",
b"fLaC" => "flac",
// RIFF, RF64 and BW64.
_ => "wave",
}
}
/// Find the first marker of `markers` at or after `at`, within symphonia's probe depth. A RIFF
/// form other than WAVE is skipped, as the probe skips it and reads a WAVE behind it.
fn find_container_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) -> Option<(usize, &'a [u8; 4])> {
let end = bytes.len().min(at.saturating_add(MAX_PREFIX_SCAN));
for pos in at..end.saturating_sub(3) {
let window = bytes.get(pos..pos + 4)?;
if let Some(marker) = markers.iter().find(|marker| window == marker.as_slice()) {
@@ -111,58 +172,211 @@ fn find_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) ->
None
}
/// Probe for a container, checking the container it finds before its reader allocates whatever the
/// file claims.
///
/// The check has to run on the container the probe actually selects, but the probe reports that
/// container only through the reader it returns. The check therefore runs on the first marker the
/// detected format allows, and the reader is refused if its format is not the checked one: a
/// misleading marker must not let a later container slip through unchecked.
fn open(bytes: &[u8], detected: FileFormat) -> Result<Opened> {
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
let at = skip_leading_id3(&mut mss, bytes)?;
let mut checked: Option<&'static [&'static str]> = None;
if let Some((found, marker)) = find_marker(bytes, at, container_markers(detected)) {
match marker {
b"caff" => {
crate::caf::check(bytes, found)?;
checked = Some(expected_probe_format(marker));
}
b"ftyp" => {
crate::mp4::check(bytes, found.saturating_sub(4))?;
checked = Some(expected_probe_format(marker));
}
b"fLaC" => {
crate::flac::check_metadata(bytes, found)?;
if let Some(info) = crate::flac::empty_stream_info(bytes, found) {
return Ok(Opened::Empty(info));
}
checked = Some(expected_probe_format(marker));
}
b"RIFF" | b"RF64" | b"BW64" => {
crate::wav::check_info(bytes, found)?;
checked = Some(expected_probe_format(marker));
}
_ => {}
}
/// The scan variant for a marker found by [`find_container_marker`], with the probe short name its
/// size check stands for. `ftyp` sits four bytes into its atom, so the MP4 check starts there.
fn marker_kind(at: usize, marker: &[u8; 4]) -> (FirstMarker, &'static str) {
match marker {
b"fLaC" => (FirstMarker::Flac(at), expected_probe_format(marker)),
b"caff" => (FirstMarker::Caf(at), expected_probe_format(marker)),
b"ftyp" => (FirstMarker::Mp4(at.saturating_sub(4)), expected_probe_format(marker)),
_ => (FirstMarker::Wav(at), expected_probe_format(marker)),
}
let reader = symphonia::default::get_probe().probe(&Hint::new(), mss, FormatOptions::default(), MetadataOptions::default()).map_err(map_err)?;
}
/// The probe short name a container scan variant stands for.
fn marker_name(marker: FirstMarker) -> &'static str {
match marker {
FirstMarker::Flac(_) => "flac",
FirstMarker::Caf(_) => "caf",
FirstMarker::Mp4(_) => "isomp4",
FirstMarker::Wav(_) => "wave",
FirstMarker::Audio => "",
}
}
/// Whether `rest` starts with one of `R`'s exact registered probe markers.
fn has_probe_marker<R: ProbeableFormat<'static>>(rest: &[u8]) -> bool {
R::probe_data().iter().any(|data| data.spec.markers.iter().any(|marker| rest.starts_with(marker)))
}
/// Score `R` at `at` exactly as symphonia's private probe does: `Supported` wins, EOF and every
/// other scoring error mean "not this format", while a real I/O error aborts the probe.
fn scores_supported<R: Scoreable>(stream: &mut MediaSourceStream<'_>, at: usize, depth: u16) -> Result<bool> {
stream.seek(SeekFrom::Start(u64::try_from(at).unwrap_or(u64::MAX)))?;
match R::score(ScopedStream::new(stream, u64::from(depth))) {
Ok(Score::Supported(_)) => Ok(true),
Err(SymError::IoError(e)) if e.kind() != std::io::ErrorKind::UnexpectedEof => Err(AudioError::Malformed(e.to_string())),
_ => Ok(false),
}
}
/// Whether `b[at..]` is an MPEG/ADTS candidate the probe would actually select: an exact
/// `probe_data()` marker and a `Supported` score from the same reader. A loose `0xFFEx` pair is
/// not enough: `0xFFE0` is neither a registered marker nor a valid frame, so it must not stop the
/// scan before a later FLAC/CAF/MP4 candidate.
fn audio_sync_at<'a>(b: &'a [u8], at: usize, stream: &mut Option<MediaSourceStream<'a>>, budget: &mut ScoreBudget) -> Result<bool> {
let rest = b.get(at..).unwrap_or_default();
let adts = has_probe_marker::<symphonia::default::formats::AdtsReader<'static>>(rest);
let mpa = has_probe_marker::<symphonia::default::formats::MpaReader<'static>>(rest);
if !adts && !mpa {
return Ok(false);
}
if stream.is_none() {
*stream = Some(MediaSourceStream::new(Box::new(Cursor::new(b)), MediaSourceStreamOptions::default()));
}
let Some(mss) = stream.as_mut() else { return Ok(false) };
let depth = ProbeOptions::default().max_score_depth;
if adts && budget.take() && scores_supported::<symphonia::default::formats::AdtsReader<'static>>(mss, at, depth)? {
return Ok(true);
}
if mpa && budget.take() && scores_supported::<symphonia::default::formats::MpaReader<'static>>(mss, at, depth)? {
return Ok(true);
}
Ok(false)
}
/// Find the first marker that can start a container or audio stream. Symphonia's `Probe::probe`
/// constructs the reader itself and exposes no pre-construction hook, while checking every magic
/// in the scan window would validate bytes the probe never reaches (for example a valid MP3's
/// audio payload). Stopping at the first marker avoids that: it is the candidate the probe selects
/// for FLAC, CAF, MP4, Ogg and Matroska, and for WAV/AIFF once their full form is verified below.
/// MPEG/ADTS candidates are checked with the readers' own `probe_data()`/`Scoreable` APIs, so a
/// false sync byte pair cannot stop the scan. If that scoring budget runs out, the scan stops at
/// the next audio candidate instead of trusting a container-like byte string later in the unscored
/// audio payload. A container marker reached before any such candidate is still preflighted; if
/// the probe selects a size-checked container the scan did not check, `open` refuses it.
///
/// WAV candidates include RF64/BW64 so `check_info` can run, although symphonia 0.6 registers only
/// the RIFF marker. Native PCM RF64/BW64 is parsed by `wav::parse` before this path; a foreign
/// RF64/BW64 stream therefore remains unsupported by the probe rather than misdetected.
fn first_marker(b: &[u8], start: usize) -> Result<Option<FirstMarker>> {
first_marker_with_budget(b, start, &mut ScoreBudget::new())
}
/// [`first_marker`] with an explicit score budget, so a test can prove a prefix crowded with
/// false sync markers performs only the bounded number of reader scores.
fn first_marker_with_budget(b: &[u8], start: usize, budget: &mut ScoreBudget) -> Result<Option<FirstMarker>> {
let end = b.len().min(start.saturating_add(MAX_PREFIX_SCAN));
let mut at = start;
let mut audio_probe = None;
while at.saturating_add(4) <= end {
let rest = b.get(at..).unwrap_or_default();
if rest.get(..4) == Some(b"fLaC".as_slice()) {
return Ok(Some(FirstMarker::Flac(at)));
}
if rest.get(..4) == Some(b"caff".as_slice()) {
return Ok(Some(FirstMarker::Caf(at)));
}
if rest.get(..4) == Some(b"OggS".as_slice()) || rest.get(..4) == Some(b"\x1A\x45\xDF\xA3".as_slice()) || crate::aiff::is_aiff(rest) {
return Ok(Some(FirstMarker::Audio));
}
if crate::wav::is_wav(rest) {
return Ok(Some(FirstMarker::Wav(at)));
}
if let (Some(&b0), Some(&b1)) = (b.get(at), b.get(at + 1))
&& b0 == 0xFF
&& b1 & 0xE0 == 0xE0
{
// A candidate after the cap cannot be scored. Stop rather than let a container-like
// byte string later in an unscored audio payload become preflight-authoritative; the
// probe will select the reader, and `open` refuses an unchecked size-checked one.
if budget.exhausted() {
return Ok(None);
}
if audio_sync_at(b, at, &mut audio_probe, budget)? {
return Ok(Some(FirstMarker::Audio));
}
}
if rest.get(4..8) == Some(b"ftyp".as_slice()) {
return Ok(Some(FirstMarker::Mp4(at)));
}
at += 1;
}
Ok(None)
}
/// Run the early size checks on the container the probe can select. `start` is the first byte
/// after leading ID3v2 tags. A zero-frame FLAC is returned as an empty stream because Symphonia
/// 0.6's reader requires a first audio frame to resynchronize to.
fn precheck(b: &[u8], start: usize, detected: FileFormat) -> Result<Precheck> {
let any_marker = |b: &[u8], start: usize| -> Result<Option<(FirstMarker, &'static str)>> {
Ok(match first_marker(b, start)? {
// Stop at the first marker of any kind, so container magic incidental to an audio
// payload is not mistaken for the stream's container.
Some(FirstMarker::Audio) | None => None,
Some(marker) => Some((marker, marker_name(marker))),
})
};
let found = match container_markers(detected) {
// Follow the detection: check the first marker of the family it names, so a misleading
// marker cannot let a differently sized container reach the probe unchecked. A detection
// is only a hint, though: when the stream holds none of its markers (a FLAC behind a
// `.mp3` name), the probe can still select another container, which is checked here.
Some(markers) => match find_container_marker(b, start, markers) {
Some((at, marker)) => Some(marker_kind(at, marker)),
None => any_marker(b, start)?,
},
None => any_marker(b, start)?,
};
let Some((marker, expected)) = found else { return Ok(Precheck::Unchecked) };
match marker {
FirstMarker::Flac(at) => {
crate::flac::check_metadata(b, at)?;
if let Some(info) = crate::flac::empty_stream_info(b, at) {
return Ok(Precheck::Empty(info));
}
Ok(Precheck::Checked(expected))
}
FirstMarker::Caf(at) => {
crate::caf::check(b, at)?;
Ok(Precheck::Checked(expected))
}
FirstMarker::Mp4(at) => {
crate::mp4::check(b, at)?;
Ok(Precheck::Checked(expected))
}
FirstMarker::Wav(at) => {
crate::wav::check_info(b, at)?;
Ok(Precheck::Checked(expected))
}
FirstMarker::Audio => Ok(Precheck::Unchecked),
}
}
/// Skip leading ID3v2 tags, run the container size preflight, then probe with symphonia; a zero-frame FLAC found by
/// the preflight comes back as [`Opened::Empty`] without probing. A size-checked container is refused when probing
/// selects a different reader or one that was never checked, so incidental payload magic cannot bypass those limits.
fn open(bytes: &[u8], detected: FileFormat) -> Result<Opened> {
let start = skip_id3v2(bytes);
if start > 0 && start >= bytes.len() {
return Err(AudioError::Malformed("ID3v2 tag extends past the end of the file".into()));
}
let checked = precheck(bytes, start, detected)?;
if let Precheck::Empty(info) = checked {
return Ok(Opened::Empty(info));
}
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
// Seek (rather than pre-positioning the cursor) so the stream's absolute position is `start`
// too: container offsets such as MP4's `stco` are absolute file offsets.
mss.seek(SeekFrom::Start(u64::try_from(start).unwrap_or(u64::MAX)))?;
let reader: Box<dyn FormatReader> =
symphonia::default::get_probe().probe(&Hint::new(), mss, FormatOptions::default(), MetadataOptions::default()).map_err(map_err)?;
let probed = reader.format_info().short_name;
// Only containers whose readers allocate from sizes in the file need the check. A stream the
// probe reads with another reader (MP3, Ogg, AIFF, ...) has no such size fields, so a marker
// found in its audio data must not reject it.
if let Some(names) = checked.filter(|_| SIZE_CHECKED_PROBE_FORMATS.contains(&probed)) {
if !names.contains(&probed) {
match checked {
// Only containers whose readers allocate from sizes in the file need the check. A stream
// the probe reads with another reader (MP3, Ogg, AIFF, ...) has no such size fields, so a
// marker found in its audio data must not reject it.
Precheck::Checked(expected) if SIZE_CHECKED_PROBE_FORMATS.contains(&probed) && probed != expected => {
return Err(AudioError::Malformed(format!("the probed container ({probed}) is not the one whose size checks ran")));
}
} else if checked.is_none() && SIZE_CHECKED_PROBE_FORMATS.contains(&probed) {
return Err(AudioError::Malformed(format!("the probed container ({probed}) was not size-checked")));
Precheck::Unchecked if SIZE_CHECKED_PROBE_FORMATS.contains(&probed) => {
return Err(AudioError::Malformed(format!("the probed container ({probed}) was not size-checked")));
}
_ => {}
}
Ok(Opened::Reader(reader))
}
/// The container `detected` stands for: the detection wins when it knows the file, otherwise the
/// decoded codec names the family.
fn format_from_codec(codec: AudioCodecId, detected: FileFormat) -> FileFormat {
if detected != FileFormat::Other {
return detected;
@@ -213,16 +427,19 @@ fn sample_format_of(p: &AudioCodecParameters) -> SampleFormat {
}
}
/// The audio parameters of `track`, if it carries any.
fn audio_params(track: &Track) -> Option<&AudioCodecParameters> {
track.codec_params.as_ref()?.audio()
}
/// The first track with a decodable audio codec, with the stream information the UI shows.
fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, AudioInfo)> {
let (track, p) = reader
let track = reader
.tracks()
.iter()
.find_map(|t| match t.codec_params.as_ref().and_then(CodecParameters::audio) {
Some(p) if p.codec != CODEC_ID_NULL_AUDIO => Some((t, p)),
_ => None,
})
.find(|t| audio_params(t).is_some_and(|p| p.codec != CODEC_ID_NULL_AUDIO))
.ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?;
let p = audio_params(track).ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?;
let sample_rate = p.sample_rate.filter(|&r| r > 0).ok_or_else(|| AudioError::Malformed("unknown sample rate".into()))?;
let channels = p.channels.as_ref().map(|c| c.count()).unwrap_or(0);
let channels = u16::try_from(channels).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
@@ -239,6 +456,39 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi
))
}
/// A demux error ends the decode; it is only an error when nothing was decoded yet.
fn demux_failure(frames: u64, e: SymError) -> Result<()> {
if frames == 0 {
return Err(map_err(e));
}
log::warn!("stopping decode after demux error: {e}");
Ok(())
}
/// Note a corrupt packet (the `errors`-th); a stream that never decodes is malformed.
fn corrupt_packet(errors: usize, frames: u64, msg: &str) -> Result<()> {
log::debug!("skipping corrupt packet: {msg}");
if errors > 1000 && frames == 0 {
return Err(AudioError::Malformed(format!("no decodable packets: {msg}")));
}
Ok(())
}
/// The decoded stream is authoritative for the channel count: record it and allocate the planes.
fn init_planar(info: &mut AudioInfo, ch: usize) -> Result<Vec<Vec<f32>>> {
info.channels = u16::try_from(ch).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
Ok((0..ch).map(|_| Vec::new()).collect())
}
/// Append one interleaved block to the planes, replacing non-finite samples with silence.
fn append_interleaved(planar: &mut [Vec<f32>], interleaved: &[f32], ch: usize) {
for frame in interleaved.chunks_exact(ch) {
for (dst, &s) in planar.iter_mut().zip(frame) {
dst.push(if s.is_finite() { s } else { 0.0 });
}
}
}
/// Decode the whole first audio track. When `count_only`, samples are counted but not stored.
fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
let mut reader = match open(bytes, detected)? {
@@ -249,28 +499,22 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
}
};
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.num_frames) {
let track = reader.tracks().iter().find(|t| t.id == track_id).ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
if let Some(n) = track.num_frames {
let total = n.saturating_mul(u64::from(info.channels.max(1)));
if !count_only && total > MAX_DECODE_SAMPLES {
return Err(AudioError::TooLarge(format!("{total} samples exceeds the decode limit")));
}
}
let params = reader
.tracks()
.iter()
.find(|t| t.id == track_id)
.and_then(|t| t.codec_params.as_ref())
.and_then(CodecParameters::audio)
.cloned()
.ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
let params = audio_params(track).cloned().ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_ID_ALAC).and_then(|d| be_u32(d, 0));
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) {
return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into()));
}
let mut decoder = symphonia::default::get_codecs().make_audio_decoder(&params, &AudioDecoderOptions::default()).map_err(map_err)?;
let mut decoder = symphonia::default::get_codecs().make_audio_decoder(&params, &decoder_options()).map_err(map_err)?;
let mut planar: Vec<Vec<f32>> = Vec::new();
let mut scratch: Vec<Vec<f32>> = Vec::new();
let mut interleaved: Vec<f32> = Vec::new();
let mut frames: u64 = 0;
let mut errors = 0usize;
loop {
@@ -280,10 +524,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(SymError::ResetRequired) => break,
Err(e) => {
if frames == 0 {
return Err(map_err(e));
}
log::warn!("stopping decode after demux error: {e}");
demux_failure(frames, e)?;
break;
}
};
@@ -294,24 +535,20 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
Ok(d) => d,
Err(SymError::DecodeError(m)) => {
errors += 1;
log::debug!("skipping corrupt packet: {m}");
if errors > 1000 && frames == 0 {
return Err(AudioError::Malformed(format!("no decodable packets: {m}")));
}
corrupt_packet(errors, frames, m)?;
continue;
}
Err(SymError::IoError(_)) => break,
Err(e) => return Err(map_err(e)),
};
let spec = decoded.spec();
let ch = spec.channels().count();
// The interleaved copy below is laid out by planes: use their count so a decoder whose
// channel spec disagrees with its planes cannot trip the copy's length assertion.
let ch = decoded.num_planes();
if ch == 0 {
continue;
}
if planar.is_empty() {
// The decoded stream is authoritative for the channel count.
info.channels = u16::try_from(ch).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
planar = (0..ch).map(|_| Vec::new()).collect();
planar = init_planar(&mut info, ch)?;
}
let n = decoded.frames() as u64;
frames = frames.saturating_add(n);
@@ -321,10 +558,16 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
if count_only {
continue;
}
decoded.copy_to_vecs_planar::<f32>(&mut scratch);
for (dst, src) in planar.iter_mut().zip(&scratch) {
dst.extend(src.iter().map(|&s| if s.is_finite() { s } else { 0.0 }));
let needed = usize::try_from(n)
.ok()
.and_then(|n| n.checked_mul(ch))
.ok_or_else(|| AudioError::TooLarge("decoded audio exceeds the 4 GiB limit".into()))?;
if interleaved.len() < needed {
interleaved.try_reserve(needed - interleaved.len()).map_err(|_| AudioError::TooLarge("cannot allocate decoded audio".into()))?;
interleaved.resize(needed, 0.0);
}
decoded.copy_to_slice_interleaved(&mut interleaved[..needed]);
append_interleaved(&mut planar, &interleaved[..needed], ch);
}
if info.channels == 0 {
return Err(AudioError::Malformed("stream contains no audio".into()));
@@ -355,3 +598,121 @@ pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
pub(crate) fn decode(bytes: &[u8], detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> {
guarded(|| run(bytes, detected, false))
}
#[cfg(test)]
mod tests {
use super::*;
use symphonia::core::codecs::audio::well_known::{
CODEC_ID_AAC, CODEC_ID_ADPCM_MS, CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP3, CODEC_ID_PCM_F32LE, CODEC_ID_PCM_F64LE, CODEC_ID_PCM_S16LE,
CODEC_ID_VORBIS,
};
/// Build codec parameters for a test, optionally declaring a bit depth.
fn params(codec: AudioCodecId, bits_per_sample: Option<u32>) -> AudioCodecParameters {
let mut params = AudioCodecParameters::new();
params.for_codec(codec);
if let Some(bits) = bits_per_sample {
params.with_bits_per_sample(bits);
}
params
}
/// Codec ids map to their reported formats, while a format detected from the container wins over the codec.
#[test]
fn codecs_map_to_the_reported_formats() {
assert_eq!(format_from_codec(CODEC_ID_FLAC, FileFormat::Other), FileFormat::Flac);
assert_eq!(format_from_codec(CODEC_ID_MP3, FileFormat::Other), FileFormat::Mp3);
assert_eq!(format_from_codec(CODEC_ID_AAC, FileFormat::Other), FileFormat::Aac);
assert_eq!(format_from_codec(CODEC_ID_ALAC, FileFormat::Other), FileFormat::Alac);
assert_eq!(format_from_codec(CODEC_ID_VORBIS, FileFormat::Other), FileFormat::Ogg);
// The container detected from the bytes wins over the codec.
assert_eq!(format_from_codec(CODEC_ID_ALAC, FileFormat::Aac), FileFormat::Aac);
}
/// PCM and FLAC keep their declared bit depth; lossy codecs report `Compressed` regardless of their decoded
/// width.
#[test]
fn sample_formats_keep_their_depth_and_lossy_marker() {
assert_eq!(sample_format_of(&params(CODEC_ID_PCM_S16LE, Some(16))), SampleFormat::Int16);
assert_eq!(sample_format_of(&params(CODEC_ID_FLAC, Some(24))), SampleFormat::Int24);
assert_eq!(sample_format_of(&params(CODEC_ID_FLAC, Some(8))), SampleFormat::Int8);
assert_eq!(sample_format_of(&params(CODEC_ID_FLAC, Some(32))), SampleFormat::Int32);
assert_eq!(sample_format_of(&params(CODEC_ID_PCM_F32LE, Some(32))), SampleFormat::Float32);
assert_eq!(sample_format_of(&params(CODEC_ID_PCM_F64LE, Some(64))), SampleFormat::Float64);
// Lossy codecs report no fixed bit depth, whatever their decoded sample format is.
assert_eq!(sample_format_of(&params(CODEC_ID_AAC, Some(16))), SampleFormat::Compressed);
assert_eq!(sample_format_of(&params(CODEC_ID_ADPCM_MS, Some(4))), SampleFormat::Compressed);
assert_eq!(sample_format_of(&params(CODEC_ID_ALAC, None)), SampleFormat::Compressed);
}
/// Decoder options keep gapless trimming enabled so the encoder delay and padding reported by the demuxer are
/// removed.
#[test]
fn gapless_decoding_stays_enabled() {
assert!(decoder_options().gapless);
}
/// A complete MPEG frame header is a candidate; loose or unregistered sync pairs are rejected before scoring,
/// while a registered but truncated header is rejected by the reader's score after consuming a budget slot.
#[test]
fn only_the_readers_own_audio_markers_are_candidates() {
/// Whether the preflight would score `b` as an audio candidate at offset 0.
fn candidate(b: &[u8]) -> bool {
let mut stream = None;
let mut budget = ScoreBudget::new();
matches!(audio_sync_at(b, 0, &mut stream, &mut budget), Ok(true))
}
let mut mp3_frame = vec![0u8; 417];
mp3_frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz
assert!(candidate(&[&mp3_frame[..], &mp3_frame].concat()));
for false_sync in [vec![0xFF, 0xE0, 0x00], vec![0xFF, 0xF0, 0x00], vec![0xFF, 0xFB, 0xF0, 0x00]] {
assert!(!candidate(&false_sync), "{false_sync:?} is not a probe candidate");
}
}
/// A prefix made of false MPEG sync markers must not ask the readers to score every byte
/// position: the per-scan budget caps the expensive work. A cheap container marker reached
/// before the next audio candidate is still found, but once the cap is spent an audio
/// candidate stops the scan so later payload magic cannot be preflight-authoritative.
#[test]
fn a_prefix_crowded_with_sync_markers_scores_only_the_budget() {
let mut crowded = vec![0u8; 1 << 16];
for pair in crowded.as_chunks_mut::<2>().0 {
pair.copy_from_slice(&[0xFF, 0xE2]);
}
let mut budget = ScoreBudget::new();
assert!(matches!(first_marker_with_budget(&crowded, 0, &mut budget), Ok(None)));
assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS, "no candidate after the cap is scored");
// At the cap boundary, with no later audio candidate, the cheap marker is still found.
let mut at_cap: Vec<u8> = (0..MAX_AUDIO_SCORE_ATTEMPTS).flat_map(|_| [0xFFu8, 0xE2]).collect();
at_cap.extend_from_slice(b"fLaC");
let mut budget = ScoreBudget::new();
assert!(
matches!(first_marker_with_budget(&at_cap, 0, &mut budget), Ok(Some(FirstMarker::Flac(_)))),
"a container marker reached before the next audio candidate is still found"
);
assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS);
// One more false candidate between the cap and the magic makes the scan stop unchecked:
// the same magic could instead sit in a valid audio payload.
let mut after_candidate = at_cap[..2 * MAX_AUDIO_SCORE_ATTEMPTS].to_vec();
after_candidate.extend_from_slice(&[0xFF, 0xE2]);
after_candidate.extend_from_slice(b"fLaC");
let mut budget = ScoreBudget::new();
assert!(matches!(first_marker_with_budget(&after_candidate, 0, &mut budget), Ok(None)), "an audio candidate after the cap stops the scan");
assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS);
}
/// ID3v2 sizes are read in their 7-bit syncsafe form; consecutive tags are skipped by their declared sizes, and
/// the v2.3 reserved footer bit does not extend a tag.
#[test]
fn leading_id3_tags_are_skipped_by_their_declared_size() {
let tag = [&b"ID3\x04\0\0\0\0\0\x05"[..], b"hello"].concat();
assert_eq!(skip_id3v2(&[&tag[..], &tag[..]].concat()), 30);
assert_eq!(skip_id3v2(b"fLaC"), 0);
// The 10-byte footer flag exists only in v2.4; the same bit in v2.3 is reserved.
assert_eq!(crate::id3v2_len(b"ID3\x03\0\x10\0\0\0\0"), Some(10));
assert_eq!(crate::id3v2_len(b"ID3\x04\0\x10\0\0\0\0"), Some(20));
}
}
+52 -32
View File
@@ -178,6 +178,45 @@ fn only_chunks_follow(b: &[u8], from: usize) -> bool {
false
}
/// A `data` chunk header as found in the file, for [`data_chunk_len`].
struct DataChunk {
body: usize,
size32: u32,
is_rf64: bool,
remaining: usize,
declared: usize,
}
/// Bytes of audio a `data` chunk holds: leniently, whatever is present when the size is wrong or
/// the recording was never finalised.
fn data_chunk_len(b: &[u8], c: DataChunk) -> usize {
let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64;
let empty = c.size32 == 0 && !c.is_rf64 && finished && only_chunks_follow(b, c.body);
if c.size32 == 0 && !c.is_rf64 && !empty { c.remaining } else { c.declared.min(c.remaining) }
}
/// The sample layout of a `fmt` chunk, `None` for a format we do not read natively.
fn pcm_layout(fmt: &Fmt) -> Result<Option<(PcmKind, SampleFormat)>> {
Ok(Some(match (fmt.tag, fmt.bits) {
(WAVE_FORMAT_PCM, bits @ 1..=32) => {
let bytes = usize::from(bits.div_ceil(8));
let sf = match fmt.valid_bits.min(bits) {
0..=8 => SampleFormat::Int8,
9..=16 => SampleFormat::Int16,
17..=24 => SampleFormat::Int24,
_ => SampleFormat::Int32,
};
(PcmKind::Int { bytes, big_endian: false, unsigned: bytes == 1 }, sf)
}
(WAVE_FORMAT_IEEE_FLOAT, 32) => (PcmKind::Float { bytes: 4, big_endian: false }, SampleFormat::Float32),
(WAVE_FORMAT_IEEE_FLOAT, 64) => (PcmKind::Float { bytes: 8, big_endian: false }, SampleFormat::Float64),
(WAVE_FORMAT_PCM | WAVE_FORMAT_IEEE_FLOAT, bits) => {
return Err(AudioError::Unsupported(format!("WAV format {} with {bits} bits per sample", fmt.tag)));
}
_ => return Ok(None),
}))
}
pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
if !is_wav(b) {
return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into()));
@@ -209,9 +248,7 @@ pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
// A size of 0 in a file whose RIFF size is right and that has only chunks after it is an
// empty data chunk followed by metadata (LIST, bext, …); otherwise it is a recording
// that was never finalised, and what follows is audio.
let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64;
let empty = size32 == 0 && !is_rf64 && finished && only_chunks_follow(b, body);
let len = if size32 == 0 && !is_rf64 && !empty { remaining } else { declared.min(remaining) };
let len = data_chunk_len(b, DataChunk { body, size32, is_rf64, remaining, declared });
if data.is_none() {
data = Some((body, len));
}
@@ -246,24 +283,7 @@ pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
return Err(AudioError::Malformed("WAV declares a zero sample rate".into()));
}
let channels = usize::from(fmt.channels);
let (kind, sample_format) = match (fmt.tag, fmt.bits) {
(WAVE_FORMAT_PCM, bits @ 1..=32) => {
let bytes = usize::from(bits.div_ceil(8));
let sf = match fmt.valid_bits.min(bits) {
0..=8 => SampleFormat::Int8,
9..=16 => SampleFormat::Int16,
17..=24 => SampleFormat::Int24,
_ => SampleFormat::Int32,
};
(PcmKind::Int { bytes, big_endian: false, unsigned: bytes == 1 }, sf)
}
(WAVE_FORMAT_IEEE_FLOAT, 32) => (PcmKind::Float { bytes: 4, big_endian: false }, SampleFormat::Float32),
(WAVE_FORMAT_IEEE_FLOAT, 64) => (PcmKind::Float { bytes: 8, big_endian: false }, SampleFormat::Float64),
(WAVE_FORMAT_PCM | WAVE_FORMAT_IEEE_FLOAT, bits) => {
return Err(AudioError::Unsupported(format!("WAV format {} with {bits} bits per sample", fmt.tag)));
}
_ => return Ok(WavParse::Foreign { bwf }),
};
let Some((kind, sample_format)) = pcm_layout(&fmt)? else { return Ok(WavParse::Foreign { bwf }) };
let block = kind.bytes().checked_mul(channels).ok_or_else(|| AudioError::Malformed("block size overflow".into()))?;
if usize::from(fmt.block_align) != block {
log::debug!("WAV block_align {} disagrees with computed {block}; using computed", fmt.block_align);
@@ -369,17 +389,17 @@ pub(crate) struct Writer<W> {
failed: bool,
}
/// The optional header parts of a WAV file: BWF metadata, speaker mask, and whether to force RF64.
#[derive(Clone, Copy)]
pub(crate) struct HeaderExtras<'a> {
pub(crate) bwf: Option<&'a BwfInfo>,
pub(crate) mask: Option<u32>,
pub(crate) force_rf64: bool,
}
impl<W: Write + Seek> Writer<W> {
pub(crate) fn new(
mut out: W,
channels: usize,
sample_rate: u32,
depth: BitDepth,
dither: bool,
bwf: Option<&BwfInfo>,
mask: Option<u32>,
force_rf64: bool,
) -> Result<Self> {
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool, extras: HeaderExtras<'_>) -> Result<Self> {
let HeaderExtras { bwf, mask, force_rf64 } = extras;
validate_layout(channels, sample_rate)?;
let format = sample_format(depth);
let start = out.stream_position()?;
@@ -463,7 +483,7 @@ pub(crate) fn encode(
let mut out = Vec::new();
out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1))
.map_err(|_| AudioError::TooLarge(format!("cannot allocate {data_len} bytes for WAV output")))?;
let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, bwf, mask, force_rf64)?;
let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, HeaderExtras { bwf, mask, force_rf64 })?;
w.write(&buf.channels, frames)?;
Ok(w.finish()?.into_inner())
}
+195
View File
@@ -13,6 +13,17 @@ fn small(format: FileFormat, depth: BitDepth, channels: usize) -> Vec<u8> {
encode(&b, &EncodeOptions { format, bit_depth: depth, dither: false, bwf: None }).unwrap()
}
/// An ID3v2.4 header whose syncsafe size field declares `size` bytes of tag body.
fn id3_header(size: usize) -> [u8; 10] {
let size = size as u32;
[b'I', b'D', b'3', 4, 0, 0, ((size >> 21) & 0x7F) as u8, ((size >> 14) & 0x7F) as u8, ((size >> 7) & 0x7F) as u8, (size & 0x7F) as u8]
}
/// Wrap `body` in an ID3v2.4 tag.
fn id3_tag(body: &[u8]) -> Vec<u8> {
[&id3_header(body.len())[..], body].concat()
}
/// Little helper for building RIFF files by hand.
fn riff(chunks: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
let mut body = b"WAVE".to_vec();
@@ -314,6 +325,190 @@ fn an_id3_tag_before_the_audio_is_skipped_unread() {
assert_eq!(decode(&[tag, flac].concat(), None).unwrap().1.frames(), 300);
}
/// Container magic inside an MP3 frame payload must not change detection or make probing reject the stream.
#[test]
fn a_valid_mp3_with_incidental_container_magic_still_probes() {
let mut frame = vec![0u8; 417];
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz, stereo
let mut f: Vec<u8> = (0..8).flat_map(|_| frame.clone()).collect();
let at = 417 * 2 + 10; // Inside frame 2's payload, leaving every frame header intact.
f[at..at + 8].copy_from_slice(b"caff\0\x01\0\0");
f[at + 8..at + 12].copy_from_slice(b"kuki");
f[at + 12..at + 20].copy_from_slice(&(1u64 << 50).to_be_bytes());
assert_eq!(detect_format(&f, None), FileFormat::Mp3);
let info = probe(&f, None).expect("valid MP3 with incidental container magic");
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2));
}
/// Symphonia 0.6's CAF/FLAC/MP4 `Scoreable` implementations are unconditional
/// (`Supported(255)`), so a container magic before the first audio frame *is* the reader the probe
/// selects; there is no API to ask the probe to skip it. The preflight must therefore keep
/// refusing an oversized CAF cookie instead of ignoring the marker and playing the MPEG frames
/// behind it (which would also let a real malformed container bypass the size guard).
#[test]
fn a_container_marker_before_the_first_audio_frame_is_the_probed_candidate() {
let mut frame = vec![0u8; 417];
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz, stereo
let mp3: Vec<u8> = (0..8).flat_map(|_| frame.clone()).collect();
let malformed = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"kuki", 1i64 << 50, vec![0; 8])]);
for f in [[vec![0u8], malformed.clone(), mp3.clone()].concat(), [vec![0u8; 32], malformed.clone(), mp3.clone()].concat()] {
// The hint still names the raw stream, but the probe sees the CAF marker first.
assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3);
assert!(matches!(probe(&f, Some("mp3")), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
assert!(matches!(decode(&f, Some("mp3")), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
// Plain leading junk without format magic is tolerated, as before.
let f = [vec![0u8; 32], mp3].concat();
assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3);
let info = probe(&f, Some("mp3")).expect("junk before a valid MP3");
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2));
}
/// More false `0xFFEx` pairs than the preflight's scoring budget must not make a later container
/// magic inside the valid audio payload authoritative. Past the cap the scan is unchecked and the
/// probe selects the MPEG reader at the first real frame.
#[test]
fn an_exhausted_audio_score_budget_does_not_reject_the_mp3_behind_it() {
let mut frame = vec![0u8; 417];
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
let mut audio: Vec<u8> = (0..8).flat_map(|_| frame.clone()).collect();
let at = 417 * 2 + 10; // Inside frame 2's payload, leaving every frame header intact.
audio[at..at + 8].copy_from_slice(b"caff\0\x01\0\0");
audio[at + 8..at + 12].copy_from_slice(b"kuki");
audio[at + 12..at + 20].copy_from_slice(&(1u64 << 50).to_be_bytes());
let false_syncs: Vec<u8> = (0..256).flat_map(|_| [0xFFu8, 0xE2]).collect();
let f = [false_syncs, audio].concat();
assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3);
let info = probe(&f, Some("mp3")).expect("valid MP3 after an exhausted audio score budget");
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2));
let (decoded, buf) = decode(&f, Some("mp3")).expect("decode the same MP3");
assert_eq!((decoded.format, decoded.sample_rate, decoded.channels), (FileFormat::Mp3, 44_100, 2));
assert_eq!(decoded.frames, buf.frames() as u64);
}
/// Past the score budget the preflight does not run, so if the probe then selects a container
/// whose reader allocates from sizes in the file, the post-probe refusal still applies.
#[test]
fn a_container_selected_without_its_size_check_is_refused() {
let flac = small(FileFormat::Flac, BitDepth::Int16, 1);
// Exactly the cap of false candidates, then one more that the reader rejects (invalid
// bit-rate), so the preflight stops unchecked before reaching the FLAC marker behind it.
let mut false_syncs: Vec<u8> = (0..256).flat_map(|_| [0xFFu8, 0xE2]).collect();
false_syncs.extend_from_slice(&[0xFF, 0xFB, 0xF0, 0x00]);
let f = [false_syncs, flac].concat();
assert_eq!(detect_format(&f, None), FileFormat::Mp3);
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("was not size-checked")));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("was not size-checked")));
}
/// A zero-frame FLAC after arbitrary bytes or ID3 tags is still detected and reported as an empty stream, not a
/// decode failure.
#[test]
fn zero_frame_flac_after_a_prefix_is_still_empty() {
let flac =
encode(&AudioBuffer::new(48_000, 1, 0), &EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, dither: false, bwf: None })
.unwrap();
let tag = id3_tag(b"tag");
for (f, detected) in [
([vec![0x7A; 64], flac.clone()].concat(), FileFormat::Other),
([tag.clone(), vec![0x7A; 16], flac.clone()].concat(), FileFormat::Mp3),
([tag.clone(), tag, flac.clone()].concat(), FileFormat::Mp3),
] {
assert_eq!(detect_format(&f, None), detected);
let info = probe(&f, None).expect("empty FLAC after a prefix");
assert_eq!((info.format, info.sample_rate, info.channels, info.frames), (FileFormat::Flac, 48_000, 1, 0));
assert_eq!(decode(&f, None).unwrap().1.frames(), 0);
}
}
/// Build one MPEG-4 AAC-LC ADTS frame at 48 kHz stereo around `payload`, with the 13-bit length covering the 7-byte
/// header.
fn adts_frame(payload: &[u8]) -> Vec<u8> {
// MPEG-4 AAC-LC, 48 kHz, stereo; the 13-bit frame length includes this 7-byte header.
let len = 7 + payload.len();
let mut h = [0xFF, 0xF1, 0x4C, 0x00, 0x00, 0x1F, 0xFC];
h[3] = 0x80 | ((len >> 11) & 0x03) as u8; // stereo channel config low bits
h[4] = ((len >> 3) & 0xFF) as u8;
h[5] = (((len & 0x07) as u8) << 5) | 0x1F;
[&h[..], payload].concat()
}
/// False MPEG sync prefixes must not stop a following zero-frame FLAC from probing as empty.
#[test]
fn zero_frame_flac_after_a_false_mpeg_sync_is_still_empty() {
let flac =
encode(&AudioBuffer::new(48_000, 1, 0), &EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, dither: false, bwf: None })
.unwrap();
for junk in [vec![0xFF, 0xE0, 0x00], vec![0xFF, 0xF0, 0x00], vec![0xFF, 0xFB, 0xF0, 0x00]] {
let f = [junk, flac.clone()].concat();
let info = probe(&f, None).expect("empty FLAC after a false sync");
assert_eq!((info.format, info.sample_rate, info.channels, info.frames), (FileFormat::Flac, 48_000, 1, 0));
assert_eq!(decode(&f, None).unwrap().1.frames(), 0);
}
}
/// A false MPEG sync prefix must not let malformed CAF or MP4 size fields bypass the container preflight.
#[test]
fn a_false_sync_does_not_bypass_container_checks() {
let bad_caf = caf(&[
caf_desc(b"aac ", 0, 1024, 2, 0),
(b"pakt", 32, packet_table(1, 1024, &[0x82, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0])),
(b"data", -1, vec![0; 64]),
]);
assert!(matches!(probe(&bad_caf, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
let f = [vec![0xFF, 0xE0, 0x00], bad_caf].concat();
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
let pcm = sample_entry(b"sowt", 0, &[], &[]);
let bad_mp4 = mp4(480, &pcm, |kind, mut a| {
if kind == b"stsz" {
a[12..16].copy_from_slice(&u32::MAX.to_be_bytes());
}
a
});
assert!(probe(&bad_mp4, None).is_err());
let f = [vec![0xFF, 0xE0, 0x00], bad_mp4].concat();
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
/// Container magic inside a valid ADTS payload must not override AAC detection or make probing fail.
#[test]
fn a_valid_adts_stream_with_incidental_container_magic_still_probes() {
let payload = [0u8; 400];
let mut frames: Vec<Vec<u8>> = (0..8).map(|_| adts_frame(&payload)).collect();
let at = 100; // Inside frame 2's payload, leaving every frame header intact.
frames[2][7 + at..7 + at + 12].copy_from_slice(b"caff\0\x01\0\0kuki");
let f: Vec<u8> = frames.concat();
assert_eq!(detect_format(&f, None), FileFormat::Aac);
let info = probe(&f, None).expect("valid ADTS with incidental container magic");
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Aac, 48_000, 2));
}
/// A later valid CAF must not excuse an earlier malformed container that the preflight or probe rejects.
#[test]
fn an_earlier_malformed_container_is_not_excused_by_a_later_valid_one() {
let malformed = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"kuki", 1i64 << 50, vec![0; 8])]);
let pcm: Vec<u8> = (0..32i16).flat_map(|i| (i * 50).to_be_bytes()).collect();
let data = [vec![0; 4], pcm].concat();
let valid = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"data", data.len() as i64, data)]);
let f = [vec![0u8; 32], malformed, valid].concat();
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
/// Truncated ID3 bodies, declared tag sizes beyond the file, and a complete tag with no audio after it are malformed
/// for both probe and decode.
#[test]
fn truncated_or_oversized_id3_tags_are_malformed() {
let cases = vec![[&id3_header(100)[..], b"xx"].concat(), id3_header(0x0FFF_FFFF).to_vec(), id3_tag(b"tag")];
for f in cases {
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(_))));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(_))));
}
}
#[test]
fn wav_info_entries_beyond_the_file_are_refused() {
let alaw = |len: u32| {
+31 -19
View File
@@ -102,6 +102,35 @@ fn note_mode(p: Option<NotePort>) -> NoteMode {
}
}
/// Copy `n` frames from `start` of the host buffers into the plugin's input `port` (mono sources
/// fill every port channel).
fn feed_input(inputs: &mut PortBuffers, port: usize, io: &[Vec<f32>], ch: usize, (start, n): (usize, usize)) {
for c in 0..inputs.channels(port) {
let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n));
if let (Some(src), Some(dst)) = (src, inputs.channel_mut(port, c).and_then(|d| d.get_mut(..n))) {
dst.copy_from_slice(src);
}
}
}
/// Copy `n` frames of the plugin's output `port` into the host buffers from `start`; a mono host
/// buffer takes the average of a stereo port.
fn read_output(outputs: &PortBuffers, port: usize, io: &mut [Vec<f32>], ch: usize, (start, n): (usize, usize)) {
let oc = outputs.channels(port);
for (c, dst) in io.iter_mut().take(ch).enumerate() {
let Some(dst) = dst.get_mut(start..start + n) else { continue };
if ch == 1 && oc >= 2 {
if let (Some(l), Some(r)) = (outputs.channel(port, 0), outputs.channel(port, 1)) {
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
*d = 0.5 * (a + b);
}
}
} else if let Some(src) = outputs.channel(port, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) {
dst.copy_from_slice(src);
}
}
}
impl Plugin for ClapPlugin {
fn info(&self) -> &'static PluginInfo {
self.info
@@ -197,12 +226,7 @@ impl Plugin for ClapPlugin {
let end = u32::try_from(start + n).unwrap_or(u32::MAX);
// Feed the main input port (mono sources fill every port channel).
if let Some(p) = self.in_main {
for c in 0..self.inputs.channels(p) {
let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n));
if let (Some(src), Some(dst)) = (src, self.inputs.channel_mut(p, c).and_then(|d| d.get_mut(..n))) {
dst.copy_from_slice(src);
}
}
feed_input(&mut self.inputs, p, io, ch, (start, n));
}
let k = self.queue.iter().position(|e| e.time() >= end).unwrap_or(self.queue.len());
let offset = u32::try_from(start).unwrap_or(u32::MAX);
@@ -212,19 +236,7 @@ impl Plugin for ClapPlugin {
self.steady = self.steady.saturating_add(i64::try_from(n).unwrap_or(0));
match (r, self.out_main) {
(Ok(_), Some(p)) => {
let oc = self.outputs.channels(p);
for (c, dst) in io.iter_mut().take(ch).enumerate() {
let Some(dst) = dst.get_mut(start..start + n) else { continue };
if ch == 1 && oc >= 2 {
if let (Some(l), Some(r)) = (self.outputs.channel(p, 0), self.outputs.channel(p, 1)) {
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
*d = 0.5 * (a + b);
}
}
} else if let Some(src) = self.outputs.channel(p, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) {
dst.copy_from_slice(src);
}
}
read_output(&self.outputs, p, io, ch, (start, n));
}
(Err(e), _) => log::debug!("{}: {e}", self.info.id),
_ => {}
@@ -15,6 +15,11 @@ fn block(p: &mut Box<dyn Plugin>) -> Vec<Vec<f32>> {
}
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;
}
common::fixture_dir();
let mut p = soundcraft_clap_host::create(GAIN).expect("fixture");
p.prepare(48_000.0, 64, 2);
+57 -47
View File
@@ -189,6 +189,61 @@ fn apply(f: &mut Fx, h: &clap_event_header) {
}
}
/// Fill every output channel with 1.0 when all ports have the wide layout, else -1.0.
unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize) {
let fits = |bufs: *const clap_audio_buffer, n: u32| {
n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels)
};
let ok = fits(pr.audio_inputs, pr.audio_inputs_count) && fits(pr.audio_outputs, pr.audio_outputs_count);
if pr.audio_outputs_count > 0 {
let out = unsafe { &*pr.audio_outputs };
for c in 0..out.channel_count as usize {
let o = unsafe { *out.data32.add(c) };
for i in 0..n {
unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } };
}
}
}
}
/// Push the host-visible parameter edit and an ignorable note event onto the output queue.
unsafe fn push_gui_edit(pr: &clap_process) {
let e = clap_event_param_value {
header: clap_event_header {
size: std::mem::size_of::<clap_event_param_value>() as u32,
time: 0,
space_id: CLAP_CORE_EVENT_SPACE_ID,
type_: CLAP_EVENT_PARAM_VALUE,
flags: 0,
},
param_id: 7,
cookie: std::ptr::null_mut(),
note_id: -1,
port_index: -1,
channel: -1,
key: -1,
value: 0.25,
};
let out = unsafe { &*pr.out_events };
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &e.header) });
// Other event types are accepted and ignored by the host.
let note = clap_event_note {
header: clap_event_header {
size: std::mem::size_of::<clap_event_note>() as u32,
time: 0,
space_id: CLAP_CORE_EVENT_SPACE_ID,
type_: CLAP_EVENT_NOTE_END,
flags: 0,
},
note_id: -1,
port_index: 0,
channel: 0,
key: 60,
velocity: 0.0,
};
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &note.header) });
}
unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) -> clap_process_status {
let f = unsafe { fx(p) };
let pr = unsafe { &*pr };
@@ -197,19 +252,7 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
let ev = unsafe { &*pr.in_events };
let count = unsafe { (ev.size.unwrap())(pr.in_events) };
if let Some((ports, channels)) = f.wide {
let fits = |bufs: *const clap_audio_buffer, n: u32| {
n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels)
};
let ok = fits(pr.audio_inputs, pr.audio_inputs_count) && fits(pr.audio_outputs, pr.audio_outputs_count);
if pr.audio_outputs_count > 0 {
let out = unsafe { &*pr.audio_outputs };
for c in 0..out.channel_count as usize {
let o = unsafe { *out.data32.add(c) };
for i in 0..n {
unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } };
}
}
}
unsafe { fill_wide(pr, ports, channels, n) };
return CLAP_PROCESS_CONTINUE;
}
if pr.audio_outputs_count != 1 {
@@ -230,40 +273,7 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
if f.gui_edit_pending {
f.gui_edit_pending = false;
f.gain = 0.25;
let e = clap_event_param_value {
header: clap_event_header {
size: std::mem::size_of::<clap_event_param_value>() as u32,
time: 0,
space_id: CLAP_CORE_EVENT_SPACE_ID,
type_: CLAP_EVENT_PARAM_VALUE,
flags: 0,
},
param_id: 7,
cookie: std::ptr::null_mut(),
note_id: -1,
port_index: -1,
channel: -1,
key: -1,
value: 0.25,
};
let out = unsafe { &*pr.out_events };
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &e.header) });
// Other event types are accepted and ignored by the host.
let note = clap_event_note {
header: clap_event_header {
size: std::mem::size_of::<clap_event_note>() as u32,
time: 0,
space_id: CLAP_CORE_EVENT_SPACE_ID,
type_: CLAP_EVENT_NOTE_END,
flags: 0,
},
note_id: -1,
port_index: 0,
channel: 0,
key: 60,
velocity: 0.0,
};
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &note.header) });
unsafe { push_gui_edit(pr) };
}
let mut next = 0u32;
let mut last_time = 0u32;
+74 -63
View File
@@ -322,6 +322,77 @@ pub fn resample(ch: &[Vec<f32>], from: u32, to: u32) -> Vec<Vec<f32>> {
// ---------------------------------------------------------------------------------------------
// Time stretch (WSOLA) and pitch shift
/// Start position in the guide signal whose `hop` samples best match the natural continuation at
/// `natural`, searched within `tol` of `nominal` (coarse stride 4, then refined +-3).
fn best_start(g: &impl Fn(i64) -> f32, nominal: i64, natural: i64, tol: i64, hop: usize) -> i64 {
// Similarity of candidate start `p` with the natural continuation (stride 2).
let score = |p: i64| -> f32 {
let (mut xy, mut yy) = (0.0f32, 1e-9f32);
let mut i = 0i64;
while i < hop as i64 {
let a = g(natural + i);
let b = g(p + i);
xy += a * b;
yy += b * b;
i += 2;
}
xy / yy.sqrt()
};
let lo = (nominal - tol).max(0);
let hi = nominal + tol;
let mut best = nominal.max(0);
let mut best_s = f32::NEG_INFINITY;
let mut p = lo;
while p <= hi {
let s = score(p);
if s > best_s {
best_s = s;
best = p;
}
p += 4;
}
let (rlo, rhi) = ((best - 3).max(lo), (best + 3).min(hi));
for p in rlo..=rhi {
let s = score(p);
if s > best_s {
best_s = s;
best = p;
}
}
best
}
/// Add one windowed frame of every channel, read from `at.0`, into `out` at `at.1`, and the window
/// into `wsum`.
fn overlap_add(ch: &[Vec<f32>], out: &mut [Vec<f32>], wsum: &mut [f32], window: &[f32], at: (usize, usize)) {
let (in_pos, out_pos) = at;
for (c, o) in ch.iter().zip(out.iter_mut()) {
for (i, w) in window.iter().enumerate() {
let v = c.get(in_pos + i).copied().unwrap_or(0.0);
if let Some(d) = o.get_mut(out_pos + i) {
*d += v * w;
}
}
}
for (i, w) in window.iter().enumerate() {
if let Some(d) = wsum.get_mut(out_pos + i) {
*d += w;
}
}
}
/// Divide out the accumulated window weights and cut each channel to `out_len`.
fn normalise(out: &mut [Vec<f32>], wsum: &[f32], out_len: usize) {
for o in out.iter_mut() {
for (s, w) in o.iter_mut().zip(wsum) {
if *w > 1e-3 {
*s /= w;
}
}
o.truncate(out_len);
}
}
/// Pitch-preserving time stretch by WSOLA (waveform-similarity overlap-add).
/// `ratio` = output length / input length, clamped to 0.1 ..= 10. All channels share the same
/// segment choices so the stereo image stays coherent.
@@ -354,72 +425,12 @@ pub fn time_stretch(ch: &[Vec<f32>], ratio: f64, sample_rate: f32) -> Vec<Vec<f3
if out_pos >= out_len {
break;
}
let in_pos = if k == 0 {
0
} else {
let nominal = (out_pos as f64 / ratio).round() as i64;
let natural = prev_in + hop as i64;
// Similarity of candidate start `p` with the natural continuation (stride 2).
let score = |p: i64| -> f32 {
let (mut xy, mut yy) = (0.0f32, 1e-9f32);
let mut i = 0i64;
while i < hop as i64 {
let a = g(natural + i);
let b = g(p + i);
xy += a * b;
yy += b * b;
i += 2;
}
xy / yy.sqrt()
};
let lo = (nominal - tol).max(0);
let hi = nominal + tol;
let mut best = nominal.max(0);
let mut best_s = f32::NEG_INFINITY;
let mut p = lo;
while p <= hi {
let s = score(p);
if s > best_s {
best_s = s;
best = p;
}
p += 4;
}
let (rlo, rhi) = ((best - 3).max(lo), (best + 3).min(hi));
for p in rlo..=rhi {
let s = score(p);
if s > best_s {
best_s = s;
best = p;
}
}
best
};
for (c, o) in ch.iter().zip(out.iter_mut()) {
for (i, w) in window.iter().enumerate() {
let src = in_pos as usize + i;
let v = c.get(src).copied().unwrap_or(0.0);
if let Some(d) = o.get_mut(out_pos + i) {
*d += v * w;
}
}
}
for (i, w) in window.iter().enumerate() {
if let Some(d) = wsum.get_mut(out_pos + i) {
*d += w;
}
}
let in_pos = if k == 0 { 0 } else { best_start(&g, (out_pos as f64 / ratio).round() as i64, prev_in + hop as i64, tol, hop) };
overlap_add(ch, &mut out, &mut wsum, &window, (in_pos as usize, out_pos));
prev_in = in_pos;
k += 1;
}
for o in out.iter_mut() {
for (s, w) in o.iter_mut().zip(&wsum) {
if *w > 1e-3 {
*s /= w;
}
}
o.truncate(out_len);
}
normalise(&mut out, &wsum, out_len);
out
}
+94 -64
View File
@@ -94,9 +94,35 @@ fn duration_bars(duration: usize) -> Option<i64> {
}
}
/// Pans the mono sum of the first two channels between them. The pan position is
/// `manual · (1 - width) + modulator · width`, where the modulator is the LFO or the envelope
/// follower in -1..=1. A signal that is the same on both channels keeps its level at the center.
/// Writes one frame: a constant-power pan for stereo and wider, plain gain for mono. Channels past
/// the second only get the output gain.
fn write_frame(io: &mut [Vec<f32>], n: usize, ch: usize, left: f32, right: f32, position: f32, out: f32) {
if ch >= 2 {
// Constant power: sin² + cos² = 1 across the field, and both gains are 1 at the center.
let theta = (position.clamp(-1.0, 1.0) + 1.0) * FRAC_PI_4;
let (l, r) = (theta.cos().max(0.0), theta.sin().max(0.0));
if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
*x = left * l * SQRT_2 * out;
}
if let Some(x) = io.get_mut(1).and_then(|b| b.get_mut(n)) {
*x = right * r * SQRT_2 * out;
}
for c in 2..ch {
if let Some(x) = io.get_mut(c).and_then(|b| b.get_mut(n)) {
*x = if x.is_finite() { *x * out } else { 0.0 };
}
}
} else if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
*x = left * out;
}
}
/// Moves the balance of the first two channels with a constant-power law: as the pan position
/// moves right the left channel's gain falls and the right's rises, and each channel stays on its
/// own side. The pan position is `manual · (1 - width) + modulator · width`, where the modulator is
/// the LFO or the envelope follower in -1..=1. At the center both gains are 1, so a stereo signal
/// passes unchanged; a signal that is the same on both channels pans across the field at a
/// constant level.
pub struct AutoPan {
p: Params,
sr: f32,
@@ -227,6 +253,63 @@ impl AutoPan {
let ratio = hz * dur * 60.0 / t.tempo;
Some((into * ratio).rem_euclid(1.0))
}
/// Advances the envelope follower by one sample. `level` is the louder channel's magnitude.
fn follow_envelope(&mut self, level: f32, attack: f32, release: f32) {
let k = if level > self.env { attack } else { release };
self.env = level + (self.env - level) * k;
if self.env < 1.0e-12 {
self.env = 0.0;
}
}
/// Arms the Envelope trigger on a rising threshold crossing and re-arms it after a Release of silence.
fn envelope_trigger(&mut self, threshold: f32, release_samples: u64) {
if self.armed && self.env >= threshold {
self.armed = false;
self.phase = 0.0;
self.holding = false;
self.below = 0;
} else if !self.armed {
if self.env < threshold {
self.below = self.below.saturating_add(1);
self.armed = self.below >= release_samples;
} else {
self.below = 0;
}
}
}
/// The LFO phase for sample `n` of the block. Advances the phase unless a held or Beat Clock position sets it.
fn lfo_phase(&mut self, n: usize, trigger: usize, hz: f64, inc: f64) -> f64 {
match (trigger == TRIGGER_BEAT_CLOCK).then(|| self.beat_clock_phase(n, hz)).flatten() {
Some(phase) => phase,
None if self.holding => self.phase,
None => {
let current = self.phase;
self.phase += inc;
if self.phase >= 1.0 {
if matches!(trigger, TRIGGER_FREE | TRIGGER_BEAT_CLOCK) {
self.phase -= 1.0;
} else {
self.phase = 1.0;
self.holding = true;
}
}
current
}
}
}
/// The modulator in -1..=1: the envelope level above the threshold, or the LFO shape.
fn modulator(&self, envelope_source: bool, threshold_db: f32, waveform: usize, lfo_phase: f64) -> f32 {
if envelope_source {
let above = (gain_to_db(self.env) - threshold_db) / -threshold_db;
2.0 * above.clamp(0.0, 1.0) - 1.0
} else {
wave_at(waveform, lfo_phase as f32)
}
}
}
impl Plugin for AutoPan {
@@ -277,51 +360,15 @@ impl Plugin for AutoPan {
let release_samples = ms_to_samples(self.p.v(13), self.sr) as u64;
for n in 0..frames {
let sample = |io: &[Vec<f32>], c: usize| io.get(c).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
let mono = if ch >= 2 { 0.5 * (sample(io, 0) + sample(io, 1)) } else { sample(io, 0) };
let level = mono.abs();
let k = if level > self.env { attack } else { release };
self.env = level + (self.env - level) * k;
if self.env < 1.0e-12 {
self.env = 0.0;
}
let left = sample(io, 0);
let right = if ch >= 2 { sample(io, 1) } else { left };
// The louder channel drives the follower, so anti-phase material still has a level.
self.follow_envelope(left.abs().max(right.abs()), attack, release);
if trigger == TRIGGER_ENVELOPE {
if self.armed && self.env >= threshold {
self.armed = false;
self.phase = 0.0;
self.holding = false;
self.below = 0;
} else if !self.armed {
if self.env < threshold {
self.below = self.below.saturating_add(1);
self.armed = self.below >= release_samples;
} else {
self.below = 0;
}
}
self.envelope_trigger(threshold, release_samples);
}
let lfo_phase = match (trigger == TRIGGER_BEAT_CLOCK).then(|| self.beat_clock_phase(n, hz)).flatten() {
Some(phase) => phase,
None if self.holding => self.phase,
None => {
let current = self.phase;
self.phase += inc;
if self.phase >= 1.0 {
if matches!(trigger, TRIGGER_FREE | TRIGGER_BEAT_CLOCK) {
self.phase -= 1.0;
} else {
self.phase = 1.0;
self.holding = true;
}
}
current
}
};
let modulator = if envelope_source {
let above = (gain_to_db(self.env) - threshold_db) / -threshold_db;
2.0 * above.clamp(0.0, 1.0) - 1.0
} else {
wave_at(waveform, lfo_phase as f32)
};
let lfo_phase = self.lfo_phase(n, trigger, hz, inc);
let modulator = self.modulator(envelope_source, threshold_db, waveform, lfo_phase);
let width = self.width.next();
let manual = self.manual.next();
self.position.set(manual * (1.0 - width) + sign * modulator * width);
@@ -331,24 +378,7 @@ impl Plugin for AutoPan {
}
let position = self.position.next();
let out = self.output.next();
if ch >= 2 {
// Constant power: sin² + cos² = 1 across the field.
let theta = (position.clamp(-1.0, 1.0) + 1.0) * FRAC_PI_4;
let (l, r) = (theta.cos().max(0.0), theta.sin().max(0.0));
if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
*x = mono * l * SQRT_2 * out;
}
if let Some(x) = io.get_mut(1).and_then(|b| b.get_mut(n)) {
*x = mono * r * SQRT_2 * out;
}
for c in 2..ch {
if let Some(x) = io.get_mut(c).and_then(|b| b.get_mut(n)) {
*x = if x.is_finite() { *x * out } else { 0.0 };
}
}
} else if let Some(x) = io.get_mut(0).and_then(|b| b.get_mut(n)) {
*x = mono * out;
}
write_frame(io, n, ch, left, right, position, out);
}
self.since_transport = self.since_transport.saturating_add(frames as u64);
}
+60 -55
View File
@@ -135,6 +135,65 @@ impl Default for DrumSynth {
}
}
/// One output sample of a sounding voice of `kind`, advancing its envelopes.
fn voice_sample(v: &mut DrumVoice, kind: Kind, sr: f32, clap_gap: usize) -> f32 {
match kind {
Kind::Kick | Kind::Tom(_) => {
v.pitch_env *= v.pitch_coef;
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
v.phase += f / sr;
v.phase -= v.phase.floor();
let tone = (v.phase * TAU).sin() * v.amp;
let click = v.filt.process_sample(v.rng.bipolar()) * v.noise_amp;
v.amp *= v.amp_coef;
v.noise_amp *= v.noise_coef;
tone + click
}
Kind::Snare => {
v.pitch_env *= v.pitch_coef;
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
v.phase += f / sr;
v.phase -= v.phase.floor();
let tone = (v.phase * TAU).sin() * v.amp * 0.6;
let n = v.filt2.process_sample(v.filt.process_sample(v.rng.bipolar())) * v.noise_amp * 0.7;
v.amp *= v.amp_coef;
v.noise_amp *= v.noise_coef;
tone + n
}
Kind::Clap => {
// Three quick bursts then a diffuse tail.
let burst = if v.t < clap_gap * 3 {
if v.t.is_multiple_of(clap_gap) {
v.amp = 1.0;
}
v.amp
} else {
0.0
};
let env = burst.max(if v.t >= clap_gap * 2 { v.noise_amp * 0.6 } else { 0.0 });
let y = v.filt.process_sample(v.rng.bipolar()) * env * 2.0;
v.amp *= v.amp_coef;
if v.t >= clap_gap * 2 {
v.noise_amp *= v.noise_coef;
}
y
}
Kind::ClosedHat | Kind::OpenHat | Kind::Crash | Kind::Ride => {
let scale = if matches!(kind, Kind::Crash | Kind::Ride) { 1.0 } else { 1.9 };
let mut metal = 0.0;
for (m, f) in v.metal.iter_mut().zip(METAL) {
*m += f * scale / sr;
*m -= m.floor();
metal += if *m < 0.5 { 1.0 } else { -1.0 };
}
let raw = metal / 6.0 * (1.0 - v.noise_amp * 0.5) + v.rng.bipolar() * v.noise_amp;
let y = v.filt2.process_sample(v.filt.process_sample(raw)) * v.amp;
v.amp *= v.amp_coef;
y
}
}
}
impl DrumSynth {
pub fn new() -> Self {
let mut voices = [DrumVoice::new(1); VOICES];
@@ -246,61 +305,7 @@ impl DrumSynth {
let mut out = 0.0;
for v in &mut self.voices {
let Some(kind) = v.kind else { continue };
let y = match kind {
Kind::Kick | Kind::Tom(_) => {
v.pitch_env *= v.pitch_coef;
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
v.phase += f / sr;
v.phase -= v.phase.floor();
let tone = (v.phase * TAU).sin() * v.amp;
let click = v.filt.process_sample(v.rng.bipolar()) * v.noise_amp;
v.amp *= v.amp_coef;
v.noise_amp *= v.noise_coef;
tone + click
}
Kind::Snare => {
v.pitch_env *= v.pitch_coef;
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
v.phase += f / sr;
v.phase -= v.phase.floor();
let tone = (v.phase * TAU).sin() * v.amp * 0.6;
let n = v.filt2.process_sample(v.filt.process_sample(v.rng.bipolar())) * v.noise_amp * 0.7;
v.amp *= v.amp_coef;
v.noise_amp *= v.noise_coef;
tone + n
}
Kind::Clap => {
// Three quick bursts then a diffuse tail.
let burst = if v.t < clap_gap * 3 {
if v.t % clap_gap == 0 {
v.amp = 1.0;
}
v.amp
} else {
0.0
};
let env = burst.max(if v.t >= clap_gap * 2 { v.noise_amp * 0.6 } else { 0.0 });
let y = v.filt.process_sample(v.rng.bipolar()) * env * 2.0;
v.amp *= v.amp_coef;
if v.t >= clap_gap * 2 {
v.noise_amp *= v.noise_coef;
}
y
}
Kind::ClosedHat | Kind::OpenHat | Kind::Crash | Kind::Ride => {
let scale = if matches!(kind, Kind::Crash | Kind::Ride) { 1.0 } else { 1.9 };
let mut metal = 0.0;
for (m, f) in v.metal.iter_mut().zip(METAL) {
*m += f * scale / sr;
*m -= m.floor();
metal += if *m < 0.5 { 1.0 } else { -1.0 };
}
let raw = metal / 6.0 * (1.0 - v.noise_amp * 0.5) + v.rng.bipolar() * v.noise_amp;
let y = v.filt2.process_sample(v.filt.process_sample(raw)) * v.amp;
v.amp *= v.amp_coef;
y
}
};
let y = voice_sample(v, kind, sr, clap_gap);
v.t += 1;
let alive = match kind {
Kind::Clap => v.t < clap_gap * 3 || v.noise_amp > OFF,
+27 -15
View File
@@ -201,6 +201,31 @@ impl Reverb {
}
}
/// Input sample `n` of the left and right channels (a mono input feeds both); non-finite reads as 0.
fn input_pair(io: &[Vec<f32>], ch: usize, n: usize) -> (f32, f32) {
let xl = io.first().and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
let xr = if ch > 1 { io.get(1).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0) } else { xl };
(xl, xr)
}
/// Blend the wet signal `[mid, left, right]` into sample `n` of the first `ch` channels at mix `m`.
fn mix_out(io: &mut [Vec<f32>], ch: usize, n: usize, wet: [f32; 3], m: f32) {
let [mid, wl, wr] = wet;
for (c, b) in io.iter_mut().take(ch).enumerate() {
let wet = if ch == 1 {
mid
} else if c % 2 == 0 {
wl
} else {
wr
};
if let Some(x) = b.get_mut(n) {
let dry = if x.is_finite() { *x } else { 0.0 };
*x = dry * (1.0 - m) + wet * m;
}
}
}
impl Plugin for Reverb {
fn info(&self) -> &'static PluginInfo {
self.info
@@ -279,8 +304,7 @@ impl Plugin for Reverb {
let size = self.size.value();
let base: [f32; LINES] = self.cfg.delays_ms.map(|ms| ms_to_samples(ms * size, sr));
for n in 0..frames {
let xl = io.first().and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
let xr = if ch > 1 { io.get(1).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0) } else { xl };
let (xl, xr) = input_pair(io, ch, n);
let pre = ms_to_samples(self.pre_ms.next(), sr);
let mut ins = [xl, xr];
for (side, x) in ins.iter_mut().enumerate() {
@@ -319,19 +343,7 @@ impl Plugin for Reverb {
let side = 0.5 * (wl - wr) * w;
let (wl, wr) = (mid + side, mid - side);
let m = self.mix.next();
for (c, b) in io.iter_mut().take(ch).enumerate() {
let wet = if ch == 1 {
mid
} else if c % 2 == 0 {
wl
} else {
wr
};
if let Some(x) = b.get_mut(n) {
let dry = if x.is_finite() { *x } else { 0.0 };
*x = dry * (1.0 - m) + wet * m;
}
}
mix_out(io, ch, n, [mid, wl, wr], m);
}
}
+49 -1
View File
@@ -1,4 +1,4 @@
use std::f32::consts::PI;
use std::f32::consts::{FRAC_PI_4, PI, TAU};
use soundcraft_dsp::{Plugin, Transport, create, db_to_gain};
@@ -294,3 +294,51 @@ fn auto_pan_output_gain_and_mono_tracks() {
p.process(&mut buf, BLOCK);
assert_near(buf[0][BLOCK - 1], 0.5 * db_to_gain(-6.0), 1e-4, "mono output");
}
#[test]
fn auto_pan_balances_a_stereo_signal_without_summing_it() {
// A sine on the left, the same sine inverted plus an offset on the right: the channels differ,
// and a mono sum would cancel the sine.
let len = at(0.2);
let left: Vec<f32> = (0..len).map(|i| 0.5 * (TAU * 440.0 * i as f32 / SR).sin()).collect();
let right: Vec<f32> = left.iter().map(|x| 0.25 - x).collect();
let run = |p: &mut dyn Plugin| {
let mut out = vec![Vec::new(), Vec::new()];
for start in (0..len).step_by(BLOCK) {
let end = (start + BLOCK).min(len);
let mut buf = vec![left[start..end].to_vec(), right[start..end].to_vec()];
p.process(&mut buf, end - start);
out[0].extend_from_slice(&buf[0]);
out[1].extend_from_slice(&buf[1]);
}
out
};
let mut p = auto_pan(&[("width", 0.0)]);
let out = run(p.as_mut());
for i in 0..len {
assert_near(out[0][i], left[i], 1e-5, "center passes the left channel");
assert_near(out[1][i], right[i], 1e-5, "center passes the right channel");
}
// Half left: each channel keeps its own content, the left louder and the right quieter.
let mut p = auto_pan(&[("width", 0.0), ("manual", -50.0)]);
let out = run(p.as_mut());
let theta = 0.5 * FRAC_PI_4;
let (gl, gr) = (2f32.sqrt() * theta.cos(), 2f32.sqrt() * theta.sin());
for i in at(0.05)..len {
assert_near(out[0][i], left[i] * gl, 1e-5, "half left, left channel");
assert_near(out[1][i], right[i] * gr, 1e-5, "half left, right channel");
}
// Anti-phase channels still drive the envelope follower to the right.
let mut p = auto_pan(&[("source", 1.0), ("threshold", -30.0)]);
let mut buf = vec![vec![1.0f32; BLOCK], vec![-1.0f32; BLOCK]];
for _ in 0..at(0.5) / BLOCK {
buf[0].fill(1.0);
buf[1].fill(-1.0);
p.process(&mut buf, BLOCK);
}
assert_near(buf[0][BLOCK - 1], 0.0, 1e-3, "anti-phase at full scale pans right: left");
assert_near(buf[1][BLOCK - 1], -(2f32.sqrt()), 1e-3, "anti-phase at full scale pans right: right");
}
+11 -2
View File
@@ -597,16 +597,25 @@ fn subharmonic_solo_plays_one_band_and_mutes_the_dry_signal() {
#[test]
fn subharmonic_drive_keeps_every_character_bounded() {
for character in 0..6 {
let driven = |character: usize, q: f32| {
let mut p = subharmonic_wet();
p.set_param("input", 18.0);
p.set_param("lower_gain", 24.0);
p.set_param("upper_gain", 24.0);
p.set_param("drive", 18.0);
p.set_param("character", character as f32);
let out = run(p.as_mut(), &[sine(60.0, 1.0, 48_000)]);
p.set_param("hp_q", q);
p.set_param("lp_q", q);
run(p.as_mut(), &[sine(60.0, 1.0, 48_000)])
};
for character in 0..6 {
let out = driven(character, 0.707);
let top = peak(&out[0][24_000..]);
assert!(top > 0.5 && top < 1.5, "character {character}: peak {top}");
// Q 5 filters after the saturator ring well above full scale, but never to infinity.
let out = driven(character, 5.0);
assert!(out[0].iter().all(|v| v.is_finite()), "character {character} at Q 5");
assert!(peak(&out[0][24_000..]) > 0.5, "character {character} at Q 5 is silent");
}
}
+13 -9
View File
@@ -20,6 +20,18 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// The most frames a process can take in: these processes cut their output at MAX_OFFLINE_SAMPLES frames.
fn frame_limit(name: &str, ratio: f64, semitones: f32, sr: f64, to: u32, selection: SpeedRampSelection) -> usize {
let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize;
match name {
"time_stretch" => longest(ratio),
"pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)),
"varispeed" => longest(f64::from(to) / sr),
"speed_ramp" => longest(if selection == SpeedRampSelection::Extend { 1.5 } else { 1.0 }),
_ => usize::MAX,
}
}
/// Process each target clip's audio and replace the clip with one backed by a new source.
fn process(e: &mut Engine, p: &Value) -> Result<Value> {
let name = str_param(p, "process").or_else(|| str_param(p, "plugin")).ok_or_else(|| bad("audiosuite.process", "`process` required"))?.to_string();
@@ -63,15 +75,7 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
let change = if get("change", 0.0) >= 0.5 { SpeedRampChange::SpeedUp } else { SpeedRampChange::SlowDown };
let selection = if get("selection", 0.0) >= 0.5 { SpeedRampSelection::Extend } else { SpeedRampSelection::FitTo };
let fades = get("fades", 1.0) >= 0.5;
// These processes cut their output at MAX_OFFLINE_SAMPLES frames.
let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize;
let most = match name.as_str() {
"time_stretch" => longest(ratio),
"pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)),
"varispeed" => longest(f64::from(to) / sr.as_f64()),
"speed_ramp" => longest(if selection == SpeedRampSelection::Extend { 1.5 } else { 1.0 }),
_ => usize::MAX,
};
let most = frame_limit(&name, ratio, semitones, sr.as_f64(), to, selection);
let mut done = 0;
for id in ids {
let Some((track, clip)) = e.session().find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
+20
View File
@@ -188,6 +188,26 @@ mod tests {
assert_eq!(s.auto_names[&key].number, 100, "the other scheme's count is untouched");
}
#[test]
fn a_refused_paste_leaves_the_clipboard_as_it_was() {
let mut e = crate::demo::demo_engine();
let (t, id, start, len) = first_kick(&e);
e.execute("clip.auto_rename", &json!({"clips": [id], "name": "SFX"})).unwrap();
e.execute("edit.copy", &json!({"tracks": [t], "start": start, "end": start + len})).unwrap();
// Without its scheme in the session, a paste puts the scheme back under a new key.
e.session_mut().auto_names.clear();
let copied = e.clipboard.clone();
// A time-locked clip at the paste point leaves Shuffle no room.
e.session_mut().find_clip_mut(id).unwrap().time_locked = true;
e.session_mut().edit.edit_mode = soundcraft_model::EditMode::Shuffle;
assert!(e.execute("edit.paste", &json!({"tracks": [t], "at": start})).is_err());
assert_eq!(e.clipboard, copied);
e.session_mut().edit.edit_mode = soundcraft_model::EditMode::Slip;
let at = e.session().content_end() + 48_000;
e.execute("edit.paste", &json!({"tracks": [t], "at": at})).unwrap();
assert_ne!(e.clipboard, copied, "a paste that succeeds keeps the scheme's new key");
}
#[test]
fn bad_auto_rename_params_are_refused() {
let mut e = crate::demo::demo_engine();
+38 -27
View File
@@ -327,12 +327,12 @@ fn cut_track(s: &mut Session, t: TrackId, r: soundcraft_time::Range, sh: bool, f
}
/// The clipboard for a paste, with the Auto Rename schemes of its clips put into the session first
/// ([`Clipboard::adopt_auto_names`]).
/// ([`Clipboard::adopt_auto_names`]). A paste that succeeds stores it back in `e.clipboard`, so later
/// pastes find its schemes under their session keys; a refused paste leaves the clipboard as it was.
pub(super) fn clipboard_for_paste(e: &mut Engine) -> Clipboard {
let mut cb = e.clipboard.clone();
if cb.tracks.iter().flatten().any(|c| c.auto_name.is_some()) {
cb.adopt_auto_names(e.session_mut());
e.clipboard = cb.clone();
}
cb
}
@@ -373,6 +373,7 @@ fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
}
}
s.edit.selection = soundcraft_time::Range::new(at, at + cb.length);
e.clipboard = cb;
Ok(json!({"pasted": pasted}))
}
@@ -462,6 +463,7 @@ fn repeat_to_fill(e: &mut Engine, p: &Value) -> Result<Value> {
at += len;
n += 1;
}
e.clipboard = cb;
Ok(json!({"repeats": n}))
}
@@ -473,6 +475,38 @@ fn select_all(e: &mut Engine, _: &Value) -> Result<Value> {
Ok(json!({"tracks": s.edit.selected_tracks.len(), "end": end}))
}
/// Adds the members of every active edit group that touches `t`, then puts `t` in track order.
fn add_group_members(s: &Session, t: &mut Vec<TrackId>) {
let extra: Vec<TrackId> =
s.groups.iter().filter(|g| g.active && g.edit && g.members.iter().any(|m| t.contains(m))).flat_map(|g| g.members.clone()).collect();
for m in extra {
if !t.contains(&m) {
t.push(m);
}
}
let order: Vec<TrackId> = s.tracks.iter().map(|x| x.id).collect();
t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
}
/// Selecting clips also selects their range and tracks (Grabber click).
fn select_clip_ids(s: &Session, ed: &mut soundcraft_model::EditState, c: Vec<ClipId>) {
let mut range: Option<soundcraft_time::Range> = None;
let mut trs = Vec::new();
for id in &c {
if let Some((tid, clip)) = s.find_clip(*id) {
range = Some(range.map_or(clip.range(), |r| soundcraft_time::Range::new(r.start.min(clip.start), r.end.max(clip.end()))));
if !trs.contains(&tid) {
trs.push(tid);
}
}
}
if let Some(r) = range {
ed.selection = r;
ed.selected_tracks = trs;
}
ed.selected_clips = c;
}
fn select(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = if p.get("tracks").is_some() || p.get("track").is_some() { Some(tracks_param(e, "edit.select", p)?) } else { None };
let st = position_param(e, "edit.select", p, "start")?;
@@ -488,15 +522,7 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
if let Some(mut t) = tracks {
// Active edit groups select all their members together.
if !exact {
let extra: Vec<soundcraft_model::TrackId> =
s.groups.iter().filter(|g| g.active && g.edit && g.members.iter().any(|m| t.contains(m))).flat_map(|g| g.members.clone()).collect();
for m in extra {
if !t.contains(&m) {
t.push(m);
}
}
let order: Vec<soundcraft_model::TrackId> = s.tracks.iter().map(|x| x.id).collect();
t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
add_group_members(s, &mut t);
}
ed.selected_tracks = t;
}
@@ -506,22 +532,7 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
_ => {}
}
if let Some(c) = clips {
// Selecting clips also selects their range and tracks (Grabber click).
let mut range: Option<soundcraft_time::Range> = None;
let mut trs = Vec::new();
for id in &c {
if let Some((tid, clip)) = s.find_clip(*id) {
range = Some(range.map_or(clip.range(), |r| soundcraft_time::Range::new(r.start.min(clip.start), r.end.max(clip.end()))));
if !trs.contains(&tid) {
trs.push(tid);
}
}
}
if let Some(r) = range {
ed.selection = r;
ed.selected_tracks = trs;
}
ed.selected_clips = c;
select_clip_ids(s, &mut ed, c);
} else if list_changes {
ed.selected_clips.clear();
}
+54 -43
View File
@@ -510,6 +510,7 @@ fn paste_merge_midi(e: &mut Engine, p: &Value) -> Result<Value> {
}
}
}
e.clipboard = cb;
Ok(json!({"merged": merged, "placed": placed}))
}
@@ -856,6 +857,57 @@ fn coalesce_clip_gain_to_volume(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"clips": n}))
}
/// The automation parameters a trim/glide over a range touches on `tr`.
fn trim_params(tr: &soundcraft_model::Track, pan_only: bool) -> Vec<AutoParam> {
let mut params: Vec<AutoParam> = tr
.automation
.iter()
.filter(|l| l.enabled && !matches!(l.param, AutoParam::Mute | AutoParam::SendMute(_)))
.filter(|l| !pan_only || matches!(l.param, AutoParam::Pan(_)))
.map(|l| l.param.clone())
.collect();
if pan_only {
for i in 0..tr.mixer.pan.len().min(2) {
let pp = AutoParam::Pan(i as u8);
if !params.contains(&pp) {
params.push(pp);
}
}
}
params
}
/// Trim (shift the points inside `r` by the change at its end) or glide (ramp across `r`) one lane.
fn trim_lane(tr: &mut soundcraft_model::Track, param: &AutoParam, r: Range, glide: bool) {
let cur = super::mix::current_value(tr, param);
let (lo, hi, _) = param.range();
let l = tr.lane_mut(param);
let before = l.value_at(r.start - 1, cur);
let after = l.value_at(r.end, cur);
let from = l.value_at(r.start, cur);
if glide {
l.clear_range(r.start, r.end.saturating_add(2));
if r.start > 0 {
l.set_point(r.start - 1, before);
}
l.set_point(r.start, from);
l.set_point(r.end, cur);
l.set_point(r.end.saturating_add(1), after);
} else {
let delta = cur - from;
let last = l.value_at(r.end - 1, cur);
if r.start > 0 {
l.set_point(r.start - 1, before);
}
l.set_point(r.start, from);
l.set_point(r.end - 1, last);
l.set_point(r.end, after);
for pt in l.points.iter_mut().filter(|pt| pt.at >= r.start && pt.at < r.end) {
pt.value = (pt.value + delta).clamp(lo, hi);
}
}
}
fn trim_glide(e: &mut Engine, p: &Value, glide: bool, pan_only: bool, id: &str) -> Result<Value> {
let tracks = tracks_required(e, id, p)?;
let r = nonempty_range(e, p, id)?;
@@ -863,49 +915,8 @@ fn trim_glide(e: &mut Engine, p: &Value, glide: bool, pan_only: bool, id: &str)
let mut lanes = 0;
for t in &tracks {
let Some(tr) = s.track_mut(*t) else { continue };
let mut params: Vec<AutoParam> = tr
.automation
.iter()
.filter(|l| l.enabled && !matches!(l.param, AutoParam::Mute | AutoParam::SendMute(_)))
.filter(|l| !pan_only || matches!(l.param, AutoParam::Pan(_)))
.map(|l| l.param.clone())
.collect();
if pan_only {
for i in 0..tr.mixer.pan.len().min(2) {
let pp = AutoParam::Pan(i as u8);
if !params.contains(&pp) {
params.push(pp);
}
}
}
for param in params {
let cur = super::mix::current_value(tr, &param);
let (lo, hi, _) = param.range();
let l = tr.lane_mut(&param);
let before = l.value_at(r.start - 1, cur);
let after = l.value_at(r.end, cur);
let from = l.value_at(r.start, cur);
if glide {
l.clear_range(r.start, r.end.saturating_add(2));
if r.start > 0 {
l.set_point(r.start - 1, before);
}
l.set_point(r.start, from);
l.set_point(r.end, cur);
l.set_point(r.end.saturating_add(1), after);
} else {
let delta = cur - from;
let last = l.value_at(r.end - 1, cur);
if r.start > 0 {
l.set_point(r.start - 1, before);
}
l.set_point(r.start, from);
l.set_point(r.end - 1, last);
l.set_point(r.end, after);
for pt in l.points.iter_mut().filter(|pt| pt.at >= r.start && pt.at < r.end) {
pt.value = (pt.value + delta).clamp(lo, hi);
}
}
for param in trim_params(tr, pan_only) {
trim_lane(tr, &param, r, glide);
lanes += 1;
}
}
+35 -22
View File
@@ -564,18 +564,11 @@ pub fn chord_name(weights: &[f64; 12], bass: usize) -> Option<String> {
const MAX_CHORD_BARS: usize = 100_000;
const MAX_CHORD_NOTES: usize = 10_000_000;
fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "event.extract_chords";
let (_, t0, t1) = selection_ticks(e, p, id)?;
let s = e.session();
let picked = tracks_param(e, id, p)?;
let mut tracks: Vec<TrackId> = picked.into_iter().filter(|t| s.track(*t).is_some_and(|x| x.kind.is_midi())).collect();
if tracks.is_empty() {
tracks = s.tracks.iter().filter(|t| t.kind.is_midi()).map(|t| t.id).collect();
}
// Absolute (tick) notes from every MIDI clip on those tracks, cut to their clip.
/// Absolute `(start, end, pitch)` tick notes from every MIDI clip on `tracks`, cut to their clip
/// and sorted by start.
fn midi_tick_notes(s: &Session, tracks: &[TrackId]) -> Vec<(i64, i64, u8)> {
let mut notes: Vec<(i64, i64, u8)> = Vec::new();
for t in &tracks {
for t in tracks {
for c in s.track(*t).map(|tr| tr.clips()).unwrap_or(&[]) {
let ClipContent::Midi { sequence } = &c.content else { continue };
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
@@ -590,6 +583,36 @@ fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
}
}
notes.sort_unstable_by_key(|n| n.0);
notes
}
/// Duration-weighted pitch classes of the notes sounding in `a..b`, and the lowest pitch among them.
fn pitch_class_weights(sounding: &[(i64, i64, u8)], a: i64, b: i64) -> ([f64; 12], Option<u8>) {
let mut w = [0.0f64; 12];
let mut bass: Option<u8> = None;
for (na, nb, pitch) in sounding {
let ov = (*nb).min(b) - (*na).max(a);
if ov > 0 {
if let Some(x) = w.get_mut(usize::from(*pitch) % 12) {
*x += ov as f64;
}
bass = Some(bass.map_or(*pitch, |bp| bp.min(*pitch)));
}
}
(w, bass)
}
fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "event.extract_chords";
let (_, t0, t1) = selection_ticks(e, p, id)?;
let s = e.session();
let picked = tracks_param(e, id, p)?;
let mut tracks: Vec<TrackId> = picked.into_iter().filter(|t| s.track(*t).is_some_and(|x| x.kind.is_midi())).collect();
if tracks.is_empty() {
tracks = s.tracks.iter().filter(|t| t.kind.is_midi()).map(|t| t.id).collect();
}
// Absolute (tick) notes from every MIDI clip on those tracks, cut to their clip.
let notes = midi_tick_notes(s, &tracks);
let mut bar = s.tempo.bar_beat_at_tick(t0).bar;
let mut sounding: Vec<(i64, i64, u8)> = Vec::new();
let (mut next, mut bars, mut heard) = (0, 0, 0);
@@ -616,17 +639,7 @@ fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
}
bars += 1;
heard += sounding.len();
let mut w = [0.0f64; 12];
let mut bass: Option<u8> = None;
for (na, nb, pitch) in &sounding {
let ov = (*nb).min(b) - (*na).max(a);
if ov > 0 {
if let Some(x) = w.get_mut(usize::from(*pitch) % 12) {
*x += ov as f64;
}
bass = Some(bass.map_or(*pitch, |bp| bp.min(*pitch)));
}
}
let (w, bass) = pitch_class_weights(&sounding, a, b);
if let Some(name) = bass.and_then(|bp| chord_name(&w, usize::from(bp) % 12)) {
let at = s.tempo.tick_to_samples(a, s.sample_rate);
chords.push(json!({"bar": bar, "chord": name, "at": at}));
+72 -62
View File
@@ -612,6 +612,76 @@ fn env_db(env: &[(i64, f32)], at: i64) -> f32 {
prev.map_or(0.0, |(_, v)| v)
}
/// Fold the volume `lane` into the gain envelope of clip `c` over the selection `r`.
fn bake_volume_into_clip(c: &mut soundcraft_model::Clip, lane: &soundcraft_model::AutomationLane, base: f32, r: soundcraft_time::Range) {
let delta = |at: i64| lane.value_at(at, base) - base;
let sel0 = r.start.max(c.start);
let sel1 = r.end.min(c.end());
if sel1 <= sel0 {
return;
}
let rel = |at: i64| at - c.start;
let rel0 = rel(sel0);
let rel1 = rel(sel1);
// Later writes win, so the selection anchors replace any point they land on.
let mut pts: Vec<(i64, f32)> =
c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
if rel0 > 0 {
pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1)));
}
pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0)));
for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) {
let at = rel(pt.at);
pts.push((at, env_db(&c.gain_env, at) + delta(pt.at)));
}
// The clip's own gain points inside the selection keep their shape, offset by the lane.
for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at))));
}
if sel1 - 1 > sel0 {
let at = rel(sel1 - 1);
pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1)));
}
if sel1 < c.end() {
pts.push((rel1, env_db(&c.gain_env, rel1)));
}
pts.sort_by_key(|p| p.0);
let mut env = Vec::new();
for p in pts {
if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
env.pop();
}
env.push(p);
}
c.gain_env = env;
}
/// Clear the volume lane over `r` when the clips all sit inside it, else hold the fader there.
fn reset_volume_lane(tr: &mut soundcraft_model::Track, lane: &soundcraft_model::AutomationLane, base: f32, r: soundcraft_time::Range) {
// Clearing the lane is only safe when no audio plays outside the selection; otherwise the
// held edge values outside would fall back to the fader level.
let clips_inside = tr.clips().iter().all(|c| c.start >= r.start && c.end() <= r.end);
let covers_all = clips_inside && lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end);
let lane_m = tr.lane_mut(&AutoParam::Volume);
if covers_all {
lane_m.clear_range(r.start, r.end);
} else {
// Hold the fader across the selection and anchor the samples on either side, so the
// curve outside does not interpolate through the gap or keep the old level inside.
let before = (r.start > 0).then(|| lane.value_at(r.start - 1, base));
let after = lane.value_at(r.end, base);
lane_m.clear_range(r.start, r.end);
if let Some(v) = before {
lane_m.set_point(r.start - 1, v);
}
lane_m.set_point(r.start, base);
if r.end - 1 > r.start {
lane_m.set_point(r.end - 1, base);
}
lane_m.set_point(r.end, after);
}
}
fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "automation.volume_to_clip_gain", p)?;
let r = range_param(e, "automation.volume_to_clip_gain", p)?;
@@ -620,72 +690,12 @@ fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
let Some(tr) = s.track_mut(*t) else { continue };
let Some(lane) = tr.lane(&AutoParam::Volume).cloned().filter(|l| !l.points.is_empty()) else { continue };
let base = tr.mixer.volume_db;
let delta = |at: i64| lane.value_at(at, base) - base;
if let Some(pl) = tr.playlist_mut() {
for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) {
let sel0 = r.start.max(c.start);
let sel1 = r.end.min(c.end());
if sel1 <= sel0 {
continue;
}
let rel = |at: i64| at - c.start;
let rel0 = rel(sel0);
let rel1 = rel(sel1);
// Later writes win, so the selection anchors replace any point they land on.
let mut pts: Vec<(i64, f32)> =
c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
if rel0 > 0 {
pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1)));
}
pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0)));
for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) {
let at = rel(pt.at);
pts.push((at, env_db(&c.gain_env, at) + delta(pt.at)));
}
// The clip's own gain points inside the selection keep their shape, offset by the lane.
for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at))));
}
if sel1 - 1 > sel0 {
let at = rel(sel1 - 1);
pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1)));
}
if sel1 < c.end() {
pts.push((rel1, env_db(&c.gain_env, rel1)));
}
pts.sort_by_key(|p| p.0);
let mut env = Vec::new();
for p in pts {
if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
env.pop();
}
env.push(p);
}
c.gain_env = env;
bake_volume_into_clip(c, &lane, base, r);
}
}
// Clearing the lane is only safe when no audio plays outside the selection; otherwise the
// held edge values outside would fall back to the fader level.
let clips_inside = tr.clips().iter().all(|c| c.start >= r.start && c.end() <= r.end);
let covers_all = clips_inside && lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end);
let lane_m = tr.lane_mut(&AutoParam::Volume);
if covers_all {
lane_m.clear_range(r.start, r.end);
} else {
// Hold the fader across the selection and anchor the samples on either side, so the
// curve outside does not interpolate through the gap or keep the old level inside.
let before = (r.start > 0).then(|| lane.value_at(r.start - 1, base));
let after = lane.value_at(r.end, base);
lane_m.clear_range(r.start, r.end);
if let Some(v) = before {
lane_m.set_point(r.start - 1, v);
}
lane_m.set_point(r.start, base);
if r.end - 1 > r.start {
lane_m.set_point(r.end - 1, base);
}
lane_m.set_point(r.end, after);
}
reset_volume_lane(tr, &lane, base, r);
}
Ok(json!({}))
}
+18 -13
View File
@@ -221,6 +221,23 @@ fn io_state(s: &Session) -> Value {
json!({"busses": s.busses, "outputs": s.outputs})
}
/// Disconnect every track input, output and send that used bus `bid`.
fn unroute_bus(s: &mut Session, bid: BusId) {
for t in &mut s.tracks {
if t.mixer.input == Route::Bus(bid) {
t.mixer.input = Route::None;
}
if t.mixer.output == Route::Bus(bid) {
t.mixer.output = Route::None;
}
for slot in &mut t.mixer.sends {
if slot.as_ref().is_some_and(|x| x.target == Route::Bus(bid)) {
*slot = None;
}
}
}
}
fn io_setup(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "setup.io";
let action = str_param(p, "action").unwrap_or("list");
@@ -252,19 +269,7 @@ fn io_setup(e: &mut Engine, p: &Value) -> Result<Value> {
"delete_bus" => {
let bid: BusId = s.bus_by_name(&name).map(|b| b.id).ok_or_else(|| bad(id, format!("no bus `{name}`")))?;
s.busses.retain(|b| b.id != bid);
for t in &mut s.tracks {
if t.mixer.input == Route::Bus(bid) {
t.mixer.input = Route::None;
}
if t.mixer.output == Route::Bus(bid) {
t.mixer.output = Route::None;
}
for slot in &mut t.mixer.sends {
if slot.as_ref().is_some_and(|x| x.target == Route::Bus(bid)) {
*slot = None;
}
}
}
unroute_bus(s, bid);
}
"create_output" => {
if s.outputs.iter().any(|o| o.name.eq_ignore_ascii_case(&name)) {
+32 -27
View File
@@ -671,6 +671,35 @@ fn change_width(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"format": f}))
}
/// The nearest ancestor folder of `folder` that is not itself being deleted (walks at most 64 levels).
fn surviving_parent(s: &Session, deleted: &[TrackId], folder: TrackId) -> Option<TrackId> {
let mut parent = s.track(folder).and_then(|folder| folder.folder);
for _ in 0..64 {
match parent {
Some(candidate) if deleted.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder),
_ => break,
}
}
parent
}
/// Clear inputs and sends that used the removed bus; outputs that used it move to `replacement`.
fn reroute_deleted_bus(s: &mut Session, bus: soundcraft_model::BusId, replacement: &Route) {
for track in &mut s.tracks {
if track.mixer.input == Route::Bus(bus) {
track.mixer.input = Route::None;
}
if track.mixer.output == Route::Bus(bus) {
track.mixer.output = replacement.clone();
}
for send in &mut track.mixer.sends {
if send.as_ref().is_some_and(|send| send.target == Route::Bus(bus)) {
*send = None;
}
}
}
}
fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.delete", p)?;
let s = e.session_mut();
@@ -678,13 +707,7 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
.iter()
.filter_map(|folder| {
let bus = owned_folder_bus(s, *folder)?;
let mut parent = s.track(*folder).and_then(|folder| folder.folder);
for _ in 0..64 {
match parent {
Some(candidate) if tracks.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder),
_ => break,
}
}
let parent = surviving_parent(s, &tracks, *folder);
Some((bus, parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main)))
})
.collect();
@@ -696,13 +719,7 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
continue;
}
let Some(folder) = child.folder.filter(|folder| tracks.contains(folder)) else { continue };
let mut parent = s.track(folder).and_then(|folder| folder.folder);
for _ in 0..64 {
match parent {
Some(candidate) if tracks.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder),
_ => break,
}
}
let parent = surviving_parent(s, &tracks, folder);
promoted.push((child.id, folder_bus(s, folder), parent, parent.and_then(|parent| folder_bus(s, parent))));
}
for (child, old_bus, parent, parent_bus) in promoted {
@@ -720,19 +737,7 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
}
for (bus, replacement) in removed_busses {
s.busses.retain(|candidate| candidate.id != bus);
for track in &mut s.tracks {
if track.mixer.input == Route::Bus(bus) {
track.mixer.input = Route::None;
}
if track.mixer.output == Route::Bus(bus) {
track.mixer.output = replacement.clone();
}
for send in &mut track.mixer.sends {
if send.as_ref().is_some_and(|send| send.target == Route::Bus(bus)) {
*send = None;
}
}
}
reroute_deleted_bus(s, bus, &replacement);
}
Ok(json!({"deleted": tracks.len()}))
}
+66 -55
View File
@@ -439,6 +439,70 @@ fn write_track(out: &mut Vec<u8>, mut events: Vec<WEvent>) -> Result<(), MidiErr
Ok(())
}
/// The conductor track: tempos, meters, key signatures and markers.
fn conductor_events(smf: &Smf) -> Result<Vec<WEvent>, MidiError> {
let mut ev = Vec::new();
for &(t, bpm) in &smf.tempos {
if !bpm.is_finite() || bpm <= 0.0 {
return Err(MidiError::Write(format!("invalid tempo {bpm} bpm")));
}
let us = (60_000_000.0 / bpm).round().clamp(1.0, f64::from(0x00FF_FFFFu32)) as u32;
let [_, a, b, c] = us.to_be_bytes();
ev.push(WEvent { tick: clamp_tick(t), order: 0, bytes: meta(0x51, &[a, b, c])? });
}
for &(t, num, den) in &smf.meters {
if den == 0 || !den.is_power_of_two() {
return Err(MidiError::Write(format!("meter denominator {den} is not a power of two")));
}
let dd = den.trailing_zeros() as u8;
ev.push(WEvent { tick: clamp_tick(t), order: 1, bytes: meta(0x58, &[num, dd, 24, 8])? });
}
for &(t, sf, minor) in &smf.key_sigs {
let sf = sf.clamp(-7, 7);
ev.push(WEvent { tick: clamp_tick(t), order: 2, bytes: meta(0x59, &[sf as u8, u8::from(minor)])? });
}
for (t, text) in &smf.markers {
ev.push(WEvent { tick: clamp_tick(*t), order: 3, bytes: meta(0x06, text.as_bytes())? });
}
Ok(ev)
}
/// Trim notes still sounding when the next same-pitch note starts, and drop duplicates.
fn trim_overlaps(notes: &[Note], ends: &mut [u64], dropped: &mut [bool]) {
// The reader pairs offs with ons first-in first-out, so a note still sounding when the next
// same-pitch note starts would swap ends with it: trim it to that start. When two same-pitch
// notes start on the same tick they cannot be told apart, so only the longer one is written.
let mut order: Vec<usize> = (0..notes.len()).collect();
order.sort_by_key(|&i| {
notes.get(i).map_or((0, 0, 0, std::cmp::Reverse(0)), |n| {
(n.channel & 0x0F, n.pitch.min(127), clamp_tick(n.start), std::cmp::Reverse(ends.get(i).copied().unwrap_or(0)))
})
});
let mut prev: Option<usize> = None;
for &b in &order {
let Some(nb) = notes.get(b) else { continue };
if let Some(a) = prev
&& let Some(na) = notes.get(a)
&& na.channel & 0x0F == nb.channel & 0x0F
&& na.pitch.min(127) == nb.pitch.min(127)
{
let (sa, sb) = (clamp_tick(na.start), clamp_tick(nb.start));
if sa == sb {
if let Some(d) = dropped.get_mut(b) {
*d = true;
}
continue;
}
if let Some(ea) = ends.get_mut(a)
&& sb < *ea
{
*ea = sb;
}
}
prev = Some(b);
}
}
/// Writes a format-1 Standard MIDI File at [`PPQ`] (960) ticks per quarter note.
///
/// Track 1 is a conductor track holding tempos, meters, key signatures and markers; every
@@ -463,29 +527,7 @@ pub fn write_smf(smf: &Smf) -> Result<Vec<u8>, MidiError> {
out.extend_from_slice(&u16::try_from(PPQ).unwrap_or(960).to_be_bytes());
// Conductor track.
let mut ev = Vec::new();
for &(t, bpm) in &smf.tempos {
if !bpm.is_finite() || bpm <= 0.0 {
return Err(MidiError::Write(format!("invalid tempo {bpm} bpm")));
}
let us = (60_000_000.0 / bpm).round().clamp(1.0, f64::from(0x00FF_FFFFu32)) as u32;
let [_, a, b, c] = us.to_be_bytes();
ev.push(WEvent { tick: clamp_tick(t), order: 0, bytes: meta(0x51, &[a, b, c])? });
}
for &(t, num, den) in &smf.meters {
if den == 0 || !den.is_power_of_two() {
return Err(MidiError::Write(format!("meter denominator {den} is not a power of two")));
}
let dd = den.trailing_zeros() as u8;
ev.push(WEvent { tick: clamp_tick(t), order: 1, bytes: meta(0x58, &[num, dd, 24, 8])? });
}
for &(t, sf, minor) in &smf.key_sigs {
let sf = sf.clamp(-7, 7);
ev.push(WEvent { tick: clamp_tick(t), order: 2, bytes: meta(0x59, &[sf as u8, u8::from(minor)])? });
}
for (t, text) in &smf.markers {
ev.push(WEvent { tick: clamp_tick(*t), order: 3, bytes: meta(0x06, text.as_bytes())? });
}
let ev = conductor_events(smf)?;
if has_conductor {
write_track(&mut out, ev)?;
}
@@ -498,38 +540,7 @@ pub fn write_smf(smf: &Smf) -> Result<Vec<u8>, MidiError> {
let notes = &track.sequence.notes;
let mut ends: Vec<u64> = notes.iter().map(|n| clamp_tick(n.end()).max(clamp_tick(n.start).saturating_add(1))).collect();
let mut dropped = vec![false; notes.len()];
// The reader pairs offs with ons first-in first-out, so a note still sounding when the next
// same-pitch note starts would swap ends with it: trim it to that start. When two same-pitch
// notes start on the same tick they cannot be told apart, so only the longer one is written.
let mut order: Vec<usize> = (0..notes.len()).collect();
order.sort_by_key(|&i| {
notes.get(i).map_or((0, 0, 0, std::cmp::Reverse(0)), |n| {
(n.channel & 0x0F, n.pitch.min(127), clamp_tick(n.start), std::cmp::Reverse(ends.get(i).copied().unwrap_or(0)))
})
});
let mut prev: Option<usize> = None;
for &b in &order {
let Some(nb) = notes.get(b) else { continue };
if let Some(a) = prev
&& let Some(na) = notes.get(a)
&& na.channel & 0x0F == nb.channel & 0x0F
&& na.pitch.min(127) == nb.pitch.min(127)
{
let (sa, sb) = (clamp_tick(na.start), clamp_tick(nb.start));
if sa == sb {
if let Some(d) = dropped.get_mut(b) {
*d = true;
}
continue;
}
if let Some(ea) = ends.get_mut(a)
&& sb < *ea
{
*ea = sb;
}
}
prev = Some(b);
}
trim_overlaps(notes, &mut ends, &mut dropped);
for (i, n) in notes.iter().enumerate() {
if dropped.get(i).copied().unwrap_or(false) {
continue;
+46 -3
View File
@@ -148,7 +148,9 @@ pub fn create_instance(id: &str, state: Option<&[u8]>, sample_rate: f32, max_blo
Some(NewInstance { plugin, refused_state })
}
/// Peak meter values for one strip (linear, per channel, max over the last block).
/// Peak meter values for one strip (linear, per channel). [`MixEngine`] measures one block; a
/// realtime host folds blocks together with [`StripMeter::accumulate`] so a reader polling less
/// often than blocks arrive sees the highest peak since it last looked, not just the latest block.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct StripMeter {
/// First two channels (mono strips repeat channel 0), for stereo meters.
@@ -186,6 +188,34 @@ impl StripMeter {
self.plugin_failures = o.plugin_failures;
}
/// Fold another (later) measurement in: per-channel peaks and the gain reduction keep their
/// maximum, plugin failures take the later value (the count only grows). Allocation-free once
/// the channel count is stable, so it can run on the audio thread every block.
pub fn accumulate(&mut self, o: &StripMeter) {
if self.peaks.len() != o.peaks.len() {
// The strip's format changed (a document edit): earlier channels no longer line up.
self.peaks.clone_from(&o.peaks);
self.peak = o.peak;
} else {
for (p, q) in self.peaks.iter_mut().zip(&o.peaks) {
*p = max_level(*p, *q);
}
for (p, q) in self.peak.iter_mut().zip(o.peak) {
*p = max_level(*p, q);
}
}
self.gain_reduction_db = max_level(self.gain_reduction_db, o.gain_reduction_db);
self.plugin_failures = o.plugin_failures;
}
/// Zero the levels (peaks and gain reduction) after a reader took them, keeping the channel
/// count and the plugin failure record. Never allocates.
pub fn reset_levels(&mut self) {
self.peak = [0.0; 2];
self.peaks.iter_mut().for_each(|p| *p = 0.0);
self.gain_reduction_db = 0.0;
}
fn measure(&mut self, bufs: &[Vec<f32>], frames: usize, gr: f32) {
if self.peaks.len() != bufs.len() {
self.peaks.resize(bufs.len(), 0.0);
@@ -199,6 +229,16 @@ impl StripMeter {
}
}
/// The larger of two meter levels; a NaN (from a misbehaving plugin) never wins or sticks.
fn max_level(a: f32, b: f32) -> f32 {
match (a.is_finite(), b.is_finite()) {
(true, true) => a.max(b),
(true, false) => a,
(false, true) => b,
(false, false) => 0.0,
}
}
/// A bus: its format and one buffer per channel.
struct BusBuf {
fmt: ChannelFormat,
@@ -544,8 +584,10 @@ impl MixEngine {
fx.reset();
}
}
self.meters.clear();
self.main_meter = StripMeter::default();
// Zeroed in place: rebuilding the entries would allocate on the audio thread at every
// play, stop and locate.
self.meters.values_mut().for_each(StripMeter::reset_levels);
self.main_meter.reset_levels();
}
/// Bring plugin instances in line with the session (creates/destroys as needed). Allocates;
@@ -560,6 +602,7 @@ impl MixEngine {
let mut cx = SlotCtx { sr: self.sample_rate, mb, external: self.external, adopt: &mut self.adopt, retired: &mut self.retired };
let gone: Vec<TrackId> = self.strips.keys().filter(|id| s.track(**id).is_none()).copied().collect();
for id in gone {
self.meters.remove(&id);
if let Some(mut st) = self.strips.remove(&id) {
for p in st.plugins.drain(..) {
cx.retire(p);
+102 -3
View File
@@ -64,11 +64,15 @@ pub struct AudioSystem {
pub inputs: Vec<DeviceInfo>,
}
/// The name the host gives `d`, if it can produce a description for it.
/// The device's user-facing name. cpal 0.18 removed `DeviceTrait::name()` in favour of the
/// structured `description()`; `Display` for a device also formats that name but reports a
/// formatting error (which `to_string` would turn into a panic), so keep the fallible call here.
pub(crate) fn device_name(d: &cpal::Device) -> Option<String> {
d.description().ok().map(|description| description.name().to_owned())
}
/// Describe one cpal device as [`DeviceInfo`], or return `None` when its name or supported configurations cannot be
/// read. Only formats the playback path can convert are considered.
fn describe(d: &cpal::Device, default_name: Option<&str>, output: bool) -> Option<DeviceInfo> {
let name = device_name(d)?;
let ranges: Vec<cpal::SupportedStreamConfigRange> = if output {
@@ -77,8 +81,7 @@ fn describe(d: &cpal::Device, default_name: Option<&str>, output: bool) -> Optio
d.supported_input_configs().ok()?.filter(|c| format_rank(c.sample_format()).is_some()).collect()
};
let max_channels = ranges.iter().map(cpal::SupportedStreamConfigRange::channels).max().unwrap_or(0);
let sample_rates =
STANDARD_RATES.iter().copied().filter(|r| ranges.iter().any(|c| c.min_sample_rate() <= *r && c.max_sample_rate() >= *r)).collect();
let sample_rates = STANDARD_RATES.iter().copied().filter(|r| ranges.iter().any(|c| c.contains_rate(*r))).collect();
let (mut buffer_min, mut buffer_max) = (None::<u32>, None::<u32>);
for c in &ranges {
if let cpal::SupportedBufferSize::Range { min, max } = c.buffer_size() {
@@ -106,6 +109,45 @@ pub fn audio_systems() -> Vec<AudioSystem> {
.collect()
}
/// True when a cpal error means the stream is dead and only a rebuild can restore audio. cpal 0.18
/// reports every operation through one [`cpal::Error`]: `DeviceNotAvailable` means the device went
/// away, and `StreamInvalidated` explicitly requires the stream to be rebuilt. SoundCraft has no
/// in-place rebuild, so both map to the existing actionable "reconnect audio device" trouble
/// instead of leaving a dead stream silently logging. A rerouted default device
/// (`DeviceChanged`) keeps playing, and an xrun is transient, so neither is a lost stream.
/// `HostUnavailable`, `DeviceBusy` and `BackendError` stay log-only until their retry/rebuild
/// policies exist.
pub(crate) fn stream_lost(e: &cpal::Error) -> bool {
// ErrorKind is non-exhaustive; an equality test avoids a wildcard match arm.
let kind = e.kind();
kind == cpal::ErrorKind::DeviceNotAvailable || kind == cpal::ErrorKind::StreamInvalidated
}
/// The device's configuration at `sample_rate` in the best sample format these streams convert.
/// cpal 0.18 ranks `I24` (and `F64`) above `I16` when it picks a default configuration, so a
/// default alone can name a format the callbacks do not decode; this looks for the same rate in a
/// format [`FORMATS`] holds. The exact channel count is preferred; when the device offers none in
/// a usable format at that rate (for example only an 8-channel F32 stream), the closest available
/// channel count is used instead. Both callbacks fold or copy channels, so a wider or narrower
/// device stream is better than falling back to the silent clock. Callers pass a requested count
/// of at least 2 for output; input may ask for mono.
pub(crate) fn supported_format_config(
configs: &[cpal::SupportedStreamConfigRange],
sample_rate: u32,
channels: u16,
) -> Option<cpal::SupportedStreamConfig> {
let usable = || configs.iter().filter(|c| c.contains_rate(sample_rate)).filter_map(|c| format_rank(c.sample_format()).map(|rank| (rank, c)));
// Exact count first, then the nearest count (more channels breaks the tie).
let exact = usable().filter(|(_, c)| c.channels() == channels).min_by_key(|(rank, _)| *rank);
let best = exact.or_else(|| {
usable().min_by_key(|(rank, c)| {
let distance = c.channels().abs_diff(channels);
(*rank, distance, std::cmp::Reverse(c.channels()))
})
});
best.and_then(|(_, c)| c.try_with_sample_rate(sample_rate))
}
/// The chosen audio system, else the default one.
pub(crate) fn host(cfg: &AudioConfig) -> cpal::Host {
cfg.system
@@ -163,6 +205,63 @@ mod tests {
assert_eq!(serde_json::from_str::<AudioConfig>(r#"{"output":"X","unknown":1}"#).unwrap().output.as_deref(), Some("X"));
}
/// Build a cpal configuration range for tests with a 64..1024-frame buffer.
fn range(channels: u16, min: u32, max: u32, format: cpal::SampleFormat) -> cpal::SupportedStreamConfigRange {
cpal::SupportedStreamConfigRange::new(channels, min, max, cpal::SupportedBufferSize::Range { min: 64, max: 1024 }, format)
}
/// When the default sample format has no usable conversion, the same rate opens with a supported format; the
/// nearest channel count keeps the device open, and unsupported rates fail.
#[test]
fn a_default_format_the_streams_cannot_convert_uses_a_supported_one_at_the_same_rate() {
let configs = [
range(2, 44_100, 48_000, cpal::SampleFormat::I24),
range(2, 44_100, 48_000, cpal::SampleFormat::I16),
range(2, 44_100, 48_000, cpal::SampleFormat::F32),
range(2, 96_000, 96_000, cpal::SampleFormat::F32),
];
let c = supported_format_config(&configs, 48_000, 2).unwrap();
assert_eq!((c.sample_rate(), c.channels(), c.sample_format()), (48_000, 2, cpal::SampleFormat::F32));
assert_eq!(supported_format_config(&configs, 96_000, 2).map(|c| c.sample_format()), Some(cpal::SampleFormat::F32));
assert_eq!(
supported_format_config(&configs, 48_000, 1).map(|c| (c.channels(), c.sample_format())),
Some((2, cpal::SampleFormat::F32)),
"with no mono stream, the nearest usable channel count keeps the device open"
);
let i24_only = [range(2, 44_100, 48_000, cpal::SampleFormat::I24)];
assert!(supported_format_config(&i24_only, 48_000, 2).is_none(), "no supported format at this rate");
}
/// cpal 0.18 may name an I24 stereo default while the only format the callbacks decode is a
/// wider F32 stream at another supported rate. The default-rate search finds nothing usable,
/// so the output fallback retries at the session rate over any usable channel count; the mix
/// already folds or copies channels and resamples.
#[test]
fn a_default_format_with_no_usable_same_rate_stream_falls_back_to_the_session_rate() {
let configs = [range(2, 44_100, 44_100, cpal::SampleFormat::I24), range(8, 48_000, 48_000, cpal::SampleFormat::F32)];
assert!(supported_format_config(&configs, 44_100, 2).is_none(), "the default rate has no usable format");
let c = supported_format_config(&configs, 48_000, 2).expect("the wider F32 stream at the session rate");
assert_eq!((c.sample_rate(), c.channels(), c.sample_format()), (48_000, 8, cpal::SampleFormat::F32));
}
/// Only device-not-available and invalidated-stream errors stop playback; other cpal errors are treated as
/// recoverable.
#[test]
fn only_a_dead_stream_is_reported() {
let error = |kind| cpal::Error::with_message(kind, "test");
assert!(stream_lost(&error(cpal::ErrorKind::DeviceNotAvailable)));
assert!(stream_lost(&error(cpal::ErrorKind::StreamInvalidated)), "the stream must be rebuilt");
for kind in [
cpal::ErrorKind::DeviceChanged,
cpal::ErrorKind::DeviceBusy,
cpal::ErrorKind::HostUnavailable,
cpal::ErrorKind::Xrun,
cpal::ErrorKind::BackendError,
] {
assert!(!stream_lost(&error(kind)), "{kind:?} must not stop the stream");
}
}
#[test]
fn buffer_sizes_follow_the_device_range() {
let r = cpal::SupportedBufferSize::Range { min: 64, max: 1024 };
+396 -91
View File
@@ -45,11 +45,53 @@ const OLD_SESSIONS: usize = 16;
/// renders on worker threads; the cpal callbacks here set it.
pub use soundcraft_mix::{mark_audio_thread, on_audio_thread};
/// Meter values for the UI.
/// Meter values for the UI: the highest peak (and gain reduction) of every block rendered since
/// the UI last took them with [`Player::take_meters`]. The audio thread renders blocks faster than
/// the UI draws (about 94 blocks a second at 512 frames and 48 kHz against 20-60 polls), so
/// keeping only the latest block would miss most transients.
#[derive(Debug, Clone, Default)]
pub struct MeterSnapshot {
pub tracks: HashMap<TrackId, StripMeter>,
pub main: StripMeter,
/// Something was written since the UI last took the values.
fresh: bool,
}
impl MeterSnapshot {
/// Fold one rendered block's meters in (max). Allocation-free once the tracks are known.
fn accumulate(&mut self, main: &StripMeter, tracks: &HashMap<TrackId, StripMeter>) {
self.main.accumulate(main);
for (k, v) in tracks {
match self.tracks.get_mut(k) {
Some(d) => d.accumulate(v),
None => {
self.tracks.insert(*k, v.clone());
}
}
}
self.fresh = true;
}
/// Start a new accumulation period, keeping every entry and its storage.
fn reset_levels(&mut self) {
self.main.reset_levels();
self.tracks.values_mut().for_each(StripMeter::reset_levels);
self.fresh = false;
}
/// Nothing is playing or monitored: every meter reads silence. Levels are zeroed in place,
/// so starting again does not allocate the tracks' entries anew.
fn silence(&mut self) {
self.reset_levels();
self.fresh = true;
}
/// Drop the entries of deleted tracks.
fn forget(&mut self, gone: &[TrackId]) {
for id in gone {
self.tracks.remove(id);
}
}
}
/// Plugin states read from the live instances: `(track, slot or INSTRUMENT_SLOT, state)`.
@@ -72,6 +114,8 @@ enum Cmd {
},
Stop,
Locate(Samples),
/// Tracks the latest session no longer has (worked out on the UI thread): their meters go.
Forget(Vec<TrackId>),
}
struct Shared {
@@ -87,7 +131,7 @@ struct Shared {
pub enum OutputTrouble {
/// A block panicked; the output stays silent.
Bug,
/// The device went away.
/// The device went away, or the stream was invalidated and must be rebuilt.
Gone,
/// The device stopped asking for audio while playing.
Stalled,
@@ -98,6 +142,7 @@ pub enum OutputTrouble {
struct Fault {
/// A block panicked: output stays silent from then on.
failed: AtomicBool,
/// The device went away or the stream was invalidated: only a reconnect can restore audio.
gone: AtomicBool,
/// Blocks so far, to tell a device that stopped asking for audio.
beats: std::sync::atomic::AtomicU64,
@@ -171,6 +216,11 @@ struct AudioState {
/// Retired plugin instances go back to the UI thread to be dropped there.
garbage: Sender<Vec<Box<dyn Plugin>>>,
old_sessions: Option<SyncSender<Arc<Session>>>,
/// Meters of blocks rendered while the UI held the shared snapshot (`try_lock` failed), so
/// their peaks reach it with the next block instead of being lost.
unsent: MeterSnapshot,
/// Deleted tracks still to drop from the shared snapshot (it was locked when they went).
forget_shared: Vec<TrackId>,
}
fn new_engine(sr: f32) -> MixEngine {
@@ -202,6 +252,8 @@ impl AudioState {
device_rate,
input: None,
recording: false,
unsent: MeterSnapshot::default(),
forget_shared: Vec::new(),
}
}
@@ -210,6 +262,15 @@ impl AudioState {
while let Ok(c) = self.rx.try_recv() {
match c {
Cmd::Input(r) => self.input = Some(r),
Cmd::Forget(gone) => {
self.unsent.forget(&gone);
if self.forget_shared.is_empty() {
self.forget_shared = gone;
} else {
self.forget_shared.extend_from_slice(&gone);
}
self.forget_meters();
}
Cmd::Recording(r) => self.recording = r,
Cmd::Session(s, instances) => {
changed = true;
@@ -310,23 +371,34 @@ impl AudioState {
self.mix.render(&self.session, self.pos, n, &mut self.pending);
self.send_garbage();
self.pending_len = n;
if let Ok(mut m) = self.shared.meters.try_lock() {
m.main.copy_from(&self.mix.main_meter);
for (k, v) in &self.mix.meters {
match m.tracks.get_mut(k) {
Some(d) => d.copy_from(v),
None => {
m.tracks.insert(*k, v.clone());
}
}
}
}
self.publish_meters();
if self.playing {
self.pos += n as i64;
self.shared.position.store(self.pos, Ordering::Relaxed);
}
}
/// Drop deleted tracks' meters from the shared snapshot, unless the UI holds it right now.
fn forget_meters(&mut self) {
if !self.forget_shared.is_empty()
&& let Ok(mut m) = self.shared.meters.try_lock()
{
m.forget(&self.forget_shared);
self.forget_shared.clear();
}
}
/// Hand this block's meters to the UI without blocking: they are folded (max) into what the
/// UI has not taken yet. If the UI holds the snapshot right now, they wait in `unsent`.
fn publish_meters(&mut self) {
self.unsent.accumulate(&self.mix.main_meter, &self.mix.meters);
self.forget_meters();
if let Ok(mut m) = self.shared.meters.try_lock() {
m.accumulate(&self.unsent.main, &self.unsent.tracks);
self.unsent.reset_levels();
}
}
/// Fill an interleaved device buffer.
fn fill(&mut self, out: &mut [f32], channels: usize) {
self.drain();
@@ -334,9 +406,10 @@ impl AudioState {
let monitoring = self.input.is_some() && self.session.tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm);
if !self.playing && !monitoring {
out.iter_mut().for_each(|x| *x = 0.0);
self.unsent.silence();
self.forget_meters();
if let Ok(mut m) = self.shared.meters.try_lock() {
m.main = StripMeter::default();
m.tracks.clear();
m.silence();
}
return;
}
@@ -366,34 +439,40 @@ impl AudioState {
let b = if i0 + 1 < self.pending_len { ch.and_then(|v| v.get(i0 + 1)).copied().unwrap_or(a) } else { a };
a * (1.0 - fr) + b * fr
};
if channels < nch {
// Fold surround to stereo first; a mono device averages the two sides.
let (mut l, mut r) = (0.0f32, 0.0f32);
if sounding {
if nch == 2 {
(l, r) = (sample(0), sample(1));
} else {
for (c, (gl, gr)) in self.fold.iter().enumerate().take(nch) {
let v = sample(c);
l += v * gl;
r += v * gr;
}
}
}
for (c, o) in frame.iter_mut().enumerate() {
*o = match c {
0 if channels == 1 => out_sample(0.5 * l + 0.5 * r),
0 => out_sample(l),
1 => out_sample(r),
_ => 0.0,
};
}
write_frame(frame, channels, nch, sounding, &self.fold, sample);
self.read += step;
}
}
}
/// Write one device frame from `nch` rendered channels: fold surround to stereo (a mono device
/// averages the two sides), or copy the channels straight through. `sample(c)` is channel `c`.
fn write_frame(frame: &mut [f32], channels: usize, nch: usize, sounding: bool, fold: &[(f32, f32)], sample: impl Fn(usize) -> f32) {
if channels < nch {
// Fold surround to stereo first; a mono device averages the two sides.
let (mut l, mut r) = (0.0f32, 0.0f32);
if sounding {
if nch == 2 {
(l, r) = (sample(0), sample(1));
} else {
for (c, o) in frame.iter_mut().enumerate() {
*o = if c < nch.max(1) && sounding { out_sample(sample(c)) } else { 0.0 };
for (c, (gl, gr)) in fold.iter().enumerate().take(nch) {
let v = sample(c);
l += v * gl;
r += v * gr;
}
}
self.read += step;
}
for (c, o) in frame.iter_mut().enumerate() {
*o = match c {
0 if channels == 1 => out_sample(0.5 * l + 0.5 * r),
0 => out_sample(l),
1 => out_sample(r),
_ => 0.0,
};
}
} else {
for (c, o) in frame.iter_mut().enumerate() {
*o = if c < nch.max(1) && sounding { out_sample(sample(c)) } else { 0.0 };
}
}
}
@@ -418,11 +497,30 @@ struct Side {
refused: HashSet<(TrackId, usize)>,
garbage: Receiver<Vec<Box<dyn Plugin>>>,
old_sessions: Option<Receiver<Arc<Session>>>,
/// The tracks of the session last sent, to tell the audio thread which ones went.
tracks: HashSet<TrackId>,
}
impl Side {
fn new(rate: f32, garbage: Receiver<Vec<Box<dyn Plugin>>>) -> Side {
Side { shadow: HashMap::new(), rate, editors: HashMap::new(), states: BTreeMap::new(), refused: HashSet::new(), garbage, old_sessions: None }
Side {
shadow: HashMap::new(),
rate,
editors: HashMap::new(),
states: BTreeMap::new(),
refused: HashSet::new(),
garbage,
old_sessions: None,
tracks: HashSet::new(),
}
}
/// Note `s`'s tracks; the ones the previous session had and `s` doesn't.
fn gone_tracks(&mut self, s: &Session) -> Vec<TrackId> {
let now: HashSet<TrackId> = s.tracks.iter().map(|t| t.id).collect();
let gone = self.tracks.difference(&now).copied().collect();
self.tracks = now;
gone
}
/// Drops retired instances (closing their editors first, inside the plugin's drop) and
@@ -511,6 +609,9 @@ impl Player {
self.side.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Open the output device for `session`, trying the requested buffer size before the device default, then build
/// the shared player state. If no configuration starts or the device cannot open, fall back to a silent clock at
/// the session rate.
fn open(session: Arc<Session>, cfg: &AudioConfig) -> Player {
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
@@ -521,7 +622,11 @@ impl Player {
let want = session.sample_rate.hz();
let want_ch = u16::try_from(main_channels(&session)).unwrap_or(2);
let side_rate = session.sample_rate.as_f64() as f32;
let new_side = |garbage: Receiver<Vec<Box<dyn Plugin>>>| Mutex::new(Side::new(side_rate, garbage));
let new_side = |garbage: Receiver<Vec<Box<dyn Plugin>>>| {
let mut side = Side::new(side_rate, garbage);
side.gone_tracks(&session);
Mutex::new(side)
};
match open_device(want, want_ch, cfg) {
Ok((device, config, format, name)) => {
// The chosen buffer size first; if the device refuses it, its default.
@@ -539,9 +644,9 @@ impl Player {
let state = AudioState::new(rx, Arc::clone(&shared), Arc::clone(&session), f64::from(rate), gtx);
let fault = Arc::new(Fault::default());
let stream = match format {
cpal::SampleFormat::I32 => build_output::<i32>(&device, config, state, channels, &fault, &shared),
cpal::SampleFormat::I16 => build_output::<i16>(&device, config, state, channels, &fault, &shared),
_ => build_output::<f32>(&device, config, state, channels, &fault, &shared),
cpal::SampleFormat::I32 => build_output::<i32>(&device, *config, state, channels, &fault, &shared),
cpal::SampleFormat::I16 => build_output::<i16>(&device, *config, state, channels, &fault, &shared),
_ => build_output::<f32>(&device, *config, state, channels, &fault, &shared),
}?;
Ok(Opened { stream, tx, grx, fault })
},
@@ -678,8 +783,14 @@ impl Player {
/// Sends a new session snapshot to the audio thread, with freshly created (state-restored,
/// prepared) instances of any third-party plugin it newly needs. Call on the UI thread.
pub fn update_session(&self, s: Arc<Session>) {
let instances = self.side().prepare(&s);
let (instances, gone) = {
let mut side = self.side();
(side.prepare(&s), side.gone_tracks(&s))
};
let _ = self.tx.send(Cmd::Session(s, instances));
if !gone.is_empty() {
let _ = self.tx.send(Cmd::Forget(gone));
}
}
/// Reads the state of every live third-party plugin, for saving the session.
@@ -801,18 +912,35 @@ impl Player {
self.shared.playing.load(Ordering::Relaxed)
}
/// A copy of the meters accumulated since the last [`Player::take_meters`], leaving them in
/// place (for inspection; the UI takes them instead).
pub fn meters(&self) -> MeterSnapshot {
self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
/// Copy meters into reusable UI storage. Once track/channel counts are stable this performs
/// no allocation, unlike cloning a new snapshot every video frame.
pub fn copy_meters(&self, out: &mut MeterSnapshot) {
let source = self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner);
copy_meter_snapshot(&source, out);
/// Take the meters accumulated since the last call into reusable UI storage and start a new
/// period: `out` gets the highest peak of every block rendered in between. If no block was
/// rendered since (a large device buffer renders several blocks per callback), `out` keeps
/// the previous values rather than dropping to silence. Call it from one place only (two
/// readers would split the peaks between them). Once track/channel counts are stable this
/// performs no allocation. The audio thread only ever `try_lock`s, so holding the lock here
/// never blocks it; blocks rendered meanwhile reach the next call.
pub fn take_meters(&self, out: &mut MeterSnapshot) {
let mut source = self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner);
take_meter_snapshot(&mut source, out);
}
}
fn take_meter_snapshot(source: &mut MeterSnapshot, out: &mut MeterSnapshot) {
if !source.fresh {
// No block since the last take: keep the readings, but not those of deleted tracks.
out.tracks.retain(|id, _| source.tracks.contains_key(id));
return;
}
copy_meter_snapshot(source, out);
source.reset_levels();
}
fn copy_meter_snapshot(source: &MeterSnapshot, out: &mut MeterSnapshot) {
out.tracks.retain(|id, _| source.tracks.contains_key(id));
for (id, meter) in &source.tracks {
@@ -953,7 +1081,7 @@ fn first_started<A: std::fmt::Debug, S, E: std::fmt::Display>(
/// never allocates), converted to the device's sample type.
fn build_output<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
config: cpal::StreamConfig,
mut state: AudioState,
channels: usize,
fault: &Arc<Fault>,
@@ -968,7 +1096,7 @@ where
let chunk = scratch.len();
let (failed, guard, gone) = (Arc::clone(fault), Arc::clone(shared), Arc::clone(fault));
device.build_output_stream(
*config,
config,
move |data: &mut [T], _: &cpal::OutputCallbackInfo| {
mark_audio_thread();
for out in data.chunks_mut(chunk) {
@@ -983,7 +1111,11 @@ where
// broken): marked, so the logger hands the record off instead of writing it.
move |e: cpal::Error| {
mark_audio_thread();
if e.kind() == cpal::ErrorKind::DeviceNotAvailable {
// cpal 0.18 uses one error type for every operation. A device that went away, or a
// stream that must be rebuilt (`StreamInvalidated`), stops playback until the user
// reconnects; a rerouted default device (`DeviceChanged`) keeps playing on the new
// route, and an xrun is transient, so neither counts as losing the stream.
if devices::stream_lost(&e) {
gone.gone.store(true, Ordering::Relaxed);
}
log::warn!("audio stream error: {e}");
@@ -1004,51 +1136,67 @@ fn open_device(want_rate: u32, want_ch: u16, cfg: &AudioConfig) -> Result<(cpal:
// Prefer a config at the chosen (else session) rate; else the device default (we resample).
let want_ch = want_ch.max(2);
let rates: Vec<u32> = cfg.sample_rate.into_iter().chain(Some(want_rate)).collect();
let mut chosen: Option<(cpal::StreamConfig, cpal::SupportedBufferSize, cpal::SampleFormat)> = None;
// Better: a preferred format first, then more channels.
let better = |c: &cpal::SupportedStreamConfigRange, x: &(cpal::StreamConfig, cpal::SupportedBufferSize, cpal::SampleFormat)| {
let (rc, rx) = (devices::format_rank(c.sample_format()).unwrap_or(usize::MAX), devices::format_rank(x.2).unwrap_or(usize::MAX));
rc < rx || (rc == rx && c.channels() > x.0.channels)
};
if let Ok(configs) = device.supported_output_configs() {
let configs: Vec<cpal::SupportedStreamConfigRange> = configs.collect();
for rate in rates {
for c in &configs {
if devices::format_rank(c.sample_format()).is_some()
&& c.min_sample_rate() <= rate
&& c.max_sample_rate() >= rate
&& c.channels() >= 2
&& c.channels() <= want_ch
&& chosen.as_ref().is_none_or(|x| better(c, x))
{
chosen = Some((c.with_sample_rate(rate).config(), *c.buffer_size(), c.sample_format()));
}
}
if chosen.is_some() {
break;
}
}
}
let (config, format) = match chosen {
let configs: Vec<cpal::SupportedStreamConfigRange> = device.supported_output_configs().map(|configs| configs.collect()).unwrap_or_default();
let (config, format) = match pick_output_config(&configs, &rates, want_ch) {
Some((mut c, buffers, format)) => {
c.buffer_size = devices::buffer_size(cfg.buffer_size, &buffers);
(c, format)
}
None => {
let d = device.default_output_config().map_err(|e| e.to_string())?;
if devices::format_rank(d.sample_format()).is_none() {
return Err(format!("unsupported device sample format {:?}", d.sample_format()));
}
let buffers = *d.buffer_size();
let format = d.sample_format();
let mut c = d.config();
c.buffer_size = devices::buffer_size(cfg.buffer_size, &buffers);
(c, format)
// cpal 0.18's default can name a format these callbacks do not decode (I24, F64);
// open the same rate and channels in a format they do, rather than lose output.
let c = if devices::format_rank(format).is_some() {
d
} else {
// Same rate and channels first; else any usable channel count at the default
// rate; else the session rate (the mix resamples and folds channels). A device
// whose only decoded format is wider or at another rate would otherwise open no
// stream at all.
devices::supported_format_config(&configs, d.sample_rate(), d.channels())
.or_else(|| devices::supported_format_config(&configs, want_rate, want_ch))
.ok_or_else(|| format!("unsupported device sample format {format:?}"))?
};
let mut stream_config = c.config();
stream_config.buffer_size = devices::buffer_size(cfg.buffer_size, c.buffer_size());
(stream_config, c.sample_format())
}
};
Ok((device, config, format, name))
}
/// Pick the output configuration for the first requested rate the device offers (the configured
/// rate first, then the session rate), preferring the best sample format and then the most
/// channels from stereo up to `want_ch`. `None` when the device offers none of the requested
/// rates in a usable format; the caller then uses the device's default output configuration,
/// which the mix resamples to the session rate.
fn pick_output_config(
configs: &[cpal::SupportedStreamConfigRange],
rates: &[u32],
want_ch: u16,
) -> Option<(cpal::StreamConfig, cpal::SupportedBufferSize, cpal::SampleFormat)> {
for &rate in rates {
let mut best: Option<(cpal::StreamConfig, cpal::SupportedBufferSize, cpal::SampleFormat, usize, u16)> = None;
for c in configs {
let Some(rank) = devices::format_rank(c.sample_format()) else { continue };
if !c.contains_rate(rate) || c.channels() < 2 || c.channels() > want_ch {
continue;
}
let Some(config) = c.try_with_sample_rate(rate) else { continue };
// Better: a preferred format first, then more channels (ties keep the earlier range).
let better = (rank, std::cmp::Reverse(c.channels()));
if best.as_ref().is_none_or(|x| better < (x.3, std::cmp::Reverse(x.4))) {
best = Some((config.config(), *c.buffer_size(), c.sample_format(), rank, c.channels()));
}
}
if let Some((config, buffers, format, _, _)) = best {
return Some((config, buffers, format));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1068,13 +1216,13 @@ mod tests {
fn pause(&self) -> Result<(), cpal::Error> {
Ok(())
}
/// The fake stream claims one fixed buffer.
/// The fake stream has no clock and no fixed buffer; neither is used by these tests.
fn buffer_size(&self) -> Result<cpal::FrameCount, cpal::Error> {
Ok(1024)
Ok(0)
}
/// The fake stream has no clock.
/// The fake stream has no usable clock; these tests do not read it.
fn now(&self) -> cpal::StreamInstant {
cpal::StreamInstant::new(0, 0)
cpal::StreamInstant::from_nanos(0)
}
}
@@ -1106,6 +1254,47 @@ mod tests {
assert_eq!(won.map(|(n, _)| n), Some(2));
}
/// Build a cpal configuration range for tests with a 64..1024-frame buffer.
fn range(channels: u16, min: u32, max: u32, format: cpal::SampleFormat) -> cpal::SupportedStreamConfigRange {
cpal::SupportedStreamConfigRange::new(channels, min, max, cpal::SupportedBufferSize::Range { min: 64, max: 1024 }, format)
}
/// Rates are tried in order, so a stale configured rate falls through to the session rate; when no requested rate
/// is offered the caller uses the device default and resamples.
#[test]
fn output_config_uses_the_first_requested_rate_the_device_offers() {
let configs = [
range(2, 44_100, 48_000, cpal::SampleFormat::I16),
range(2, 44_100, 96_000, cpal::SampleFormat::F32),
range(8, 44_100, 96_000, cpal::SampleFormat::F32),
];
let (config, buffers, format) = pick_output_config(&configs, &[96_000, 48_000], 8).unwrap();
assert_eq!((config.sample_rate, config.channels, config.buffer_size), (96_000, 8, cpal::BufferSize::Default));
assert_eq!(format, cpal::SampleFormat::F32);
assert_eq!(buffers, cpal::SupportedBufferSize::Range { min: 64, max: 1024 });
// 192 kHz is not offered: the session's 48 kHz is tried next.
let (config, _, format) = pick_output_config(&configs, &[192_000, 48_000], 8).unwrap();
assert_eq!((config.sample_rate, config.channels, format), (48_000, 8, cpal::SampleFormat::F32));
// No requested rate is offered: the caller uses the device's default config and resamples.
assert!(pick_output_config(&configs, &[192_000], 8).is_none());
}
/// At a supported rate, a preferred sample format wins over more channels; output stays between stereo and the
/// requested channel cap, and mono-only or wider devices take the default path.
#[test]
fn output_config_prefers_format_over_channels_and_needs_stereo_within_the_cap() {
let configs = [range(8, 44_100, 48_000, cpal::SampleFormat::I16), range(2, 44_100, 48_000, cpal::SampleFormat::F32)];
let (config, _, format) = pick_output_config(&configs, &[48_000], 8).unwrap();
assert_eq!((config.channels, format), (2, cpal::SampleFormat::F32), "a preferred format wins over more channels");
let (config, _, _) = pick_output_config(&configs, &[48_000], 2).unwrap();
assert_eq!(config.channels, 2, "the channel cap applies");
let mono = [range(1, 44_100, 48_000, cpal::SampleFormat::F32)];
assert!(pick_output_config(&mono, &[48_000], 8).is_none(), "a mono-only device takes the default config path");
let surround = [range(6, 44_100, 48_000, cpal::SampleFormat::F32)];
assert!(pick_output_config(&surround, &[48_000], 2).is_none(), "a wider device is not narrowed here");
assert_eq!(pick_output_config(&surround, &[48_000], 6).map(|(c, _, _)| c.channels), Some(6));
}
#[test]
fn no_config_that_starts_yields_none_so_the_caller_falls_back_to_a_silent_clock() {
let mut tried = 0;
@@ -1136,6 +1325,122 @@ mod tests {
assert_eq!(out.main, source.main);
}
/// A mono track playing a quiet signal with one short spike in the block starting at
/// `spike_at`, and an audio thread ready to play it.
fn spike_session(spike_at: usize) -> (Arc<Shared>, AudioState, std::sync::mpsc::Sender<Cmd>, TrackId) {
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
let mut s = soundcraft_model::Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let mut x = vec![0.01f32; 48_000];
x[spike_at..spike_at + 32].fill(0.9);
s.pool.insert(SourceId(5), Arc::new(SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![x] })));
let id = s.new_clip_id();
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "x", SourceId(5), 0, 0, 48_000));
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(false),
speed: AtomicU32::new(1000),
meters: Mutex::new(MeterSnapshot::default()),
});
let (tx, rx) = channel();
let st = AudioState::new(rx, Arc::clone(&shared), Arc::new(s), 48_000.0, channel().0);
tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();
(shared, st, tx, t)
}
#[test]
fn a_spike_in_one_block_among_quiet_ones_reaches_the_ui() {
// Eight 512-frame blocks per callback, as between two UI polls; the spike is in the 3rd.
let (shared, mut st, _tx, t) = spike_session(2 * 512 + 100);
let mut out = vec![0.0f32; 2 * 8 * 512];
st.fill(&mut out, 2);
// The last block alone (what the UI used to see) is quiet.
assert!(st.mix.meters[&t].peak[0] < 0.05);
let mut ui = MeterSnapshot::default();
take_meter_snapshot(&mut shared.meters.lock().unwrap(), &mut ui);
assert!(ui.tracks[&t].peak[0] > 0.85, "{:?}", ui.tracks[&t]);
assert!(ui.tracks[&t].peaks[0] > 0.85);
assert!(ui.main.peak[0] > 0.5 && ui.main.peaks.iter().all(|p| *p > 0.5), "{:?}", ui.main);
// Taking starts a new period: the next quiet blocks read quiet.
st.fill(&mut out, 2);
take_meter_snapshot(&mut shared.meters.lock().unwrap(), &mut ui);
assert!(ui.tracks[&t].peak[0] < 0.05 && ui.main.peak[0] < 0.05);
// No block since the last take: the reading stays rather than dropping to silence.
take_meter_snapshot(&mut shared.meters.lock().unwrap(), &mut ui);
assert!(ui.tracks[&t].peak[0] > 0.005);
}
#[test]
fn a_spike_rendered_while_the_ui_holds_the_meters_arrives_later() {
let (shared, mut st, _tx, t) = spike_session(100);
let mut out = vec![0.0f32; 2 * 512];
{
let _held = shared.meters.lock().unwrap();
st.fill(&mut out, 2);
}
st.fill(&mut out, 2);
let mut ui = MeterSnapshot::default();
take_meter_snapshot(&mut shared.meters.lock().unwrap(), &mut ui);
assert!(ui.tracks[&t].peak[0] > 0.85, "{:?}", ui.tracks[&t]);
}
#[test]
fn stopping_zeroes_meters_in_place_and_removed_tracks_are_pruned() {
let (shared, mut st, tx, t) = spike_session(100);
let mut out = vec![0.0f32; 2 * 512];
st.fill(&mut out, 2);
tx.send(Cmd::Stop).unwrap();
st.fill(&mut out, 2);
{
let m = shared.meters.lock().unwrap();
let e = m.tracks.get(&t).expect("kept while stopped, so playing again does not allocate it");
assert!(e.peak == [0.0; 2] && e.peaks.iter().all(|p| *p == 0.0) && e.peaks.len() == 1, "{e:?}");
assert!(m.fresh, "the UI sees the silence");
}
// The track is deleted: the UI side works out which tracks went and says so.
let mut side = Side::new(48_000.0, channel().1);
side.gone_tracks(&st.session);
let empty = soundcraft_model::Session::default();
let gone = side.gone_tracks(&empty);
assert_eq!(gone, vec![t]);
tx.send(Cmd::Session(Arc::new(empty), Vec::new())).unwrap();
tx.send(Cmd::Forget(gone)).unwrap();
tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();
st.fill(&mut out, 2);
assert!(!shared.meters.lock().unwrap().tracks.contains_key(&t));
assert!(!st.unsent.tracks.contains_key(&t) && !st.mix.meters.contains_key(&t));
}
#[test]
fn a_take_without_new_blocks_still_drops_deleted_tracks() {
let gone = TrackId(7);
let mut source = MeterSnapshot::default();
source.accumulate(&StripMeter::default(), &HashMap::from([(gone, StripMeter::default())]));
let mut ui = MeterSnapshot::default();
take_meter_snapshot(&mut source, &mut ui);
source.forget(&[gone]);
take_meter_snapshot(&mut source, &mut ui);
assert!(ui.tracks.is_empty());
}
#[test]
fn a_channel_count_change_takes_the_new_stereo_peaks() {
let mut acc = StripMeter { peak: [0.9, 0.9], peaks: vec![0.9], ..StripMeter::default() };
acc.accumulate(&StripMeter { peak: [0.9, 0.0], peaks: vec![0.9, 0.0], ..StripMeter::default() });
assert_eq!(acc.peak, [0.9, 0.0]);
}
#[test]
fn gain_reduction_keeps_its_maximum_until_taken() {
let mut acc = StripMeter::default();
for gr in [1.0, 9.0, 2.0, f32::NAN] {
acc.accumulate(&StripMeter { peak: [0.1, 0.1], peaks: vec![0.1], gain_reduction_db: gr, ..StripMeter::default() });
}
assert_eq!(acc.gain_reduction_db, 9.0);
acc.reset_levels();
assert_eq!((acc.gain_reduction_db, acc.peak, acc.peaks.len()), (0.0, [0.0; 2], 1));
}
#[test]
fn a_block_that_panics_goes_silent_and_is_reported_once() {
let shared = Shared {
+65 -17
View File
@@ -142,7 +142,7 @@ pub struct Recorder {
#[cfg(not(target_os = "freebsd"))]
fn build_input<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
config: cpal::StreamConfig,
shared: &Arc<Shared>,
monitor: &Arc<InputRing>,
) -> Result<cpal::Stream, cpal::Error>
@@ -158,7 +158,7 @@ where
let chunk = scratch.len();
let mut owed = 0;
device.build_input_stream(
*config,
config,
move |data: &[T], _: &cpal::InputCallbackInfo| {
crate::mark_audio_thread();
sh.guarded(|| {
@@ -179,7 +179,10 @@ where
move |e: cpal::Error| {
// Possibly the audio thread (see `crate::mark_audio_thread`).
crate::mark_audio_thread();
if e.kind() == cpal::ErrorKind::DeviceNotAvailable {
// cpal 0.18 reports every operation through one error type; a device that went away
// or a stream that must be rebuilt (`StreamInvalidated`) stops capture.
// `DeviceChanged` (a rerouted default device) and an xrun are not faults.
if crate::devices::stream_lost(&e) {
let _ = errors.fault.compare_exchange(0, GONE, Ordering::Relaxed, Ordering::Relaxed);
}
log::warn!("input stream error: {e}");
@@ -188,6 +191,23 @@ where
)
}
/// The input configuration at `rate` when the device offers one: the best sample format first,
/// then the most channels (so a stereo interface is preferred over a mono one). `None` lets the
/// caller use the device's default input configuration (the mix resamples when the rates differ).
fn pick_input_config(configs: &[cpal::SupportedStreamConfigRange], rate: u32) -> Option<cpal::SupportedStreamConfig> {
configs
.iter()
.filter_map(|c| {
let rank = crate::devices::format_rank(c.sample_format())?;
if !c.contains_rate(rate) {
return None;
}
c.try_with_sample_rate(rate).map(|config| (rank, std::cmp::Reverse(c.channels()), config))
})
.min_by_key(|(rank, channels, _)| (*rank, *channels))
.map(|(_, _, config)| config)
}
impl Recorder {
/// Open the default input device. Errors when there is none (the caller shows a message).
pub fn open() -> Result<Recorder, String> {
@@ -205,17 +225,21 @@ impl Recorder {
let name = crate::devices::device_name(&device).unwrap_or_else(|| "Input".into());
// At the chosen rate when the device offers it, so input and output run together: the
// best format first, then the most channels.
let at_rate = audio.sample_rate.and_then(|r| {
device
.supported_input_configs()
.ok()?
.filter(|c| crate::devices::format_rank(c.sample_format()).is_some() && c.min_sample_rate() <= r && c.max_sample_rate() >= r)
.min_by_key(|c| (crate::devices::format_rank(c.sample_format()), std::cmp::Reverse(c.channels())))
.map(|c| c.with_sample_rate(r))
});
let configs: Vec<cpal::SupportedStreamConfigRange> = device.supported_input_configs().map(|configs| configs.collect()).unwrap_or_default();
let at_rate = audio.sample_rate.and_then(|r| pick_input_config(&configs, r));
let cfg = match at_rate {
Some(c) => c,
None => device.default_input_config().map_err(|e| e.to_string())?,
None => {
let d = device.default_input_config().map_err(|e| e.to_string())?;
// cpal 0.18's default can name a format these callbacks do not decode (I24, F64);
// open the same rate and channels in a format they do.
if crate::devices::format_rank(d.sample_format()).is_some() {
d
} else {
crate::devices::supported_format_config(&configs, d.sample_rate(), d.channels())
.ok_or_else(|| format!("unsupported input sample format {:?}", d.sample_format()))?
}
}
};
let format = cfg.sample_format();
if crate::devices::format_rank(format).is_none() {
@@ -227,18 +251,18 @@ impl Recorder {
let ring = CaptureRing::new((config.sample_rate as usize).saturating_mul(channels * RING_SECONDS).min(1 << 25));
let shared = Arc::new(Shared::new(ring));
let monitor = InputRing::new(usize::from(config.channels), config.sample_rate);
let build = |config: &cpal::StreamConfig| match format {
let build = |config: cpal::StreamConfig| match format {
cpal::SampleFormat::I32 => build_input::<i32>(&device, config, &shared, &monitor),
cpal::SampleFormat::I16 => build_input::<i16>(&device, config, &shared, &monitor),
_ => build_input::<f32>(&device, config, &shared, &monitor),
};
// The chosen buffer size first; if the device refuses it, its default.
let stream = match build(&config) {
let stream = match build(config) {
Ok(s) => s,
Err(e) if config.buffer_size != cpal::BufferSize::Default => {
log::warn!("cannot open the input with buffer {:?}: {e}; using the device default", config.buffer_size);
config.buffer_size = cpal::BufferSize::Default;
build(&config).map_err(|e| e.to_string())?
build(config).map_err(|e| e.to_string())?
}
Err(e) => return Err(e.to_string()),
};
@@ -290,7 +314,7 @@ impl Recorder {
}
let name = &self.device_name;
match self.shared.fault.load(Ordering::Relaxed) {
GONE => return Some(Trouble::Device(format!("“{name}” was disconnected"))),
GONE => return Some(Trouble::Device(format!("“{name}” is unavailable"))),
BROKEN => return Some(Trouble::Device("the audio input hit an internal error (see the log)".into())),
_ => {}
}
@@ -462,6 +486,28 @@ mod tests {
assert_eq!(out, [0.5, 0.0, 0.0, 0.0, -0.5]);
}
/// Build a cpal configuration range for tests with a 64..1024-frame buffer.
fn range(channels: u16, min: u32, max: u32, format: cpal::SampleFormat) -> cpal::SupportedStreamConfigRange {
cpal::SupportedStreamConfigRange::new(channels, min, max, cpal::SupportedBufferSize::Range { min: 64, max: 1024 }, format)
}
/// At the chosen rate, input selection prefers the best supported format, then more channels; a rate the device
/// does not offer returns `None` so the caller can fall back.
#[test]
fn input_config_prefers_the_best_format_then_more_channels_at_the_chosen_rate() {
let configs = [
range(1, 44_100, 192_000, cpal::SampleFormat::I32),
range(2, 44_100, 96_000, cpal::SampleFormat::I16),
range(2, 48_000, 96_000, cpal::SampleFormat::F32),
range(8, 44_100, 48_000, cpal::SampleFormat::F32),
];
let c = pick_input_config(&configs, 96_000).unwrap();
assert_eq!((c.sample_rate(), c.channels(), c.sample_format()), (96_000, 2, cpal::SampleFormat::F32));
let c = pick_input_config(&configs, 48_000).unwrap();
assert_eq!((c.sample_rate(), c.channels(), c.sample_format()), (48_000, 8, cpal::SampleFormat::F32), "more channels win in the same format");
assert!(pick_input_config(&configs, 192_001).is_none(), "an unsupported rate falls back to the device default");
}
fn recorder(shared: &Arc<Shared>) -> Recorder {
Recorder {
shared: Arc::clone(shared),
@@ -476,6 +522,8 @@ mod tests {
}
}
/// A stalled armed input, a panicking callback and a lost device all surface as `Trouble`; a quiet input that is
/// not recording does not.
#[test]
fn a_bug_a_stall_or_a_lost_device_is_trouble() {
let shared = Arc::new(Shared::new(CaptureRing::new(64)));
@@ -496,7 +544,7 @@ mod tests {
assert_eq!(r.trouble(), Some(Trouble::Device("the audio input hit an internal error (see the log)".into())));
let gone = Arc::new(Shared::new(CaptureRing::new(64)));
gone.fault.store(GONE, Ordering::Relaxed);
assert_eq!(recorder(&gone).trouble(), Some(Trouble::Device("“Mic In” was disconnected".into())));
assert_eq!(recorder(&gone).trouble(), Some(Trouble::Device("“Mic In” is unavailable".into())));
}
#[test]
+7 -1
View File
@@ -21,7 +21,10 @@ soundcraft-mix.workspace = true
soundcraft-engine.workspace = true
soundcraft-playback.workspace = true
soundcraft-video.workspace = true
egui.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"] }
web-time.workspace = true
serde.workspace = true
serde_json.workspace = true
log.workspace = true
@@ -30,6 +33,9 @@ image.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
zbus = "5"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
+2 -1
View File
@@ -62,7 +62,8 @@ fn main() {
READY.store(true, Ordering::Relaxed);
let step = |h: &mut egui_kittest::Harness<SoundApp>| {
let mut raw = std::mem::take(h.input_mut());
h.state_mut().raw_input_hook(&mut raw);
let ctx = h.ctx.clone();
h.state_mut().raw_input_hook(&ctx, &mut raw);
*h.input_mut() = raw;
h.step();
};
+1 -1
View File
@@ -66,7 +66,7 @@ fn slice() -> impl FnMut() -> bool {
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
let Some(job) = &app.bounce else { return };
let mut cancel = ctx.input(|i| i.key_pressed(egui::Key::Escape));
egui::Modal::new(egui::Id::new("bounce_progress")).show(ctx, |ui| {
egui::Modal::new(egui::Id::unique("bounce_progress")).show(ctx, |ui| {
ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0);
ui.set_width(320.0);
let name = job.path().file_name().unwrap_or_default().to_string_lossy();
File diff suppressed because it is too large Load Diff
+7 -5
View File
@@ -210,6 +210,7 @@ impl Default for View {
}
}
/// Controls for name style, sort key and sort direction above the contributor table.
fn contributors_toolbar(ui: &mut egui::Ui, v: &mut View) {
ui.horizontal_wrapped(|ui| {
ui.label("Show");
@@ -219,8 +220,8 @@ fn contributors_toolbar(ui: &mut egui::Ui, v: &mut View) {
}
}
ui.separator();
ui.label("Sort");
egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
let sort = ui.label("Sort");
let combo = egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
for k in SortKey::ALL {
if ui.selectable_label(v.key == k, k.label().0).clicked() {
v.key = k;
@@ -228,6 +229,7 @@ fn contributors_toolbar(ui: &mut egui::Ui, v: &mut View) {
}
}
});
combo.response.labelled_by(sort.id);
if ui.button(if v.ascending { "⏶" } else { "⏷" }).on_hover_text("Reverse the order").clicked() {
v.ascending = !v.ascending;
}
@@ -252,9 +254,9 @@ fn contributors_grab_bag(ui: &mut egui::Ui, list: &[&Contributor], names: NameMo
});
}
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
/// About › Contributors: a name toggle, a sort, and the list as a grab bag or a table.
pub fn contributors_ui(ui: &mut egui::Ui) {
let id = egui::Id::new("credits_view");
let id = egui::Id::unique("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
contributors_toolbar(ui, &mut v);
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
@@ -302,7 +304,7 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
});
}
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
/// 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.");
+49 -42
View File
@@ -300,6 +300,8 @@ pub fn open_session_bytes(app: &mut SoundApp, name: String, bytes: Vec<u8>) {
}
}
/// Open the session bytes read by the browser after the save prompt, stopping transport first. A successful open
/// marks the document replaced so pending captures cancel and reports any missing media.
fn open_pending_session(app: &mut SoundApp) {
let Some((name, bytes)) = app.dialogs.pending_session.take() else { return };
if crate::bounce::refuses_session_switch(app) {
@@ -307,8 +309,11 @@ fn open_pending_session(app: &mut SoundApp) {
}
let _ = app.run("transport.stop", json!({}));
app.ui.status = match soundcraft_engine::io::open_session_bytes(&mut app.engine, &name, &bytes) {
Ok(missing) if missing.is_empty() => format!("Opened {name}"),
Ok(missing) => format!("Opened {name} ({} missing media)", missing.len()),
Ok(missing) => {
// This path replaces the document without going through `SoundApp::run`.
app.document_replaced();
if missing.is_empty() { format!("Opened {name}") } else { format!("Opened {name} ({} missing media)", missing.len()) }
}
Err(e) => e.to_string(),
};
}
@@ -444,15 +449,16 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
match &mut d {
Dialog::NewTracks { count, format, kind, timebase, name } => {
ui.horizontal(|ui| {
ui.label("Create");
ui.add(egui::DragValue::new(count).range(1..=128));
let create = ui.label("Create");
ui.add(egui::DragValue::new(count).range(1..=128)).labelled_by(create.id);
ui.label("new");
egui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| {
let fmt = egui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| {
for f in soundcraft_model::ChannelFormat::ALL {
ui.selectable_value(format, f.label().to_string(), f.label());
}
});
egui::ComboBox::from_id_salt("nt_kind")
fmt.response.accessible_name("Channel format");
let kind_combo = egui::ComboBox::from_id_salt("nt_kind")
.selected_text(soundcraft_model::TrackKind::from_id(kind).map_or("Audio Track", |k| k.label()))
.show_ui(ui, |ui| {
for k in soundcraft_model::TrackKind::ALL {
@@ -462,18 +468,20 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
}
}
});
kind_combo.response.accessible_name("Track kind");
ui.label("in");
egui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { "Ticks" } else { "Samples" }).show_ui(
let tb = egui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { "Ticks" } else { "Samples" }).show_ui(
ui,
|ui| {
ui.selectable_value(timebase, "samples".to_string(), "Samples");
ui.selectable_value(timebase, "ticks".to_string(), "Ticks");
},
);
tb.response.accessible_name("Timebase");
});
ui.horizontal(|ui| {
ui.label("Name:");
ui.add(egui::TextEdit::singleline(name).desired_width(240.0));
crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(name).desired_width(240.0)), "Name");
});
if buttons(ui, "Create", enter) {
action =
@@ -483,7 +491,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::NewSession { name, sample_rate, demo } => {
ui.horizontal(|ui| {
ui.label("Name");
ui.text_edit_singleline(name);
crate::widgets::named_text_field(ui.text_edit_singleline(name), "Name");
});
egui::ComboBox::from_label("Sample Rate").selected_text(format!("{sample_rate} Hz")).show_ui(ui, |ui| {
for r in soundcraft_time::SampleRate::COMMON {
@@ -501,7 +509,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::Bounce { path, format, bit_depth, normalize, dither } => {
ui.horizontal(|ui| {
ui.label("File");
ui.add(egui::TextEdit::singleline(path).desired_width(420.0));
crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(path).desired_width(420.0)), "File");
if let Some(pick) = &app.services.pick_save
&& ui.button("Browse…").clicked()
&& let Some(p) = pick("file.bounce_mix", path)
@@ -573,7 +581,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
}
}
Dialog::PathPrompt { cmd, path, key, .. } => {
ui.add(egui::TextEdit::singleline(path).desired_width(420.0));
crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(path).desired_width(420.0)), "Path");
if let Some(pick) = &app.services.pick_open
&& cmd != "session.save_as"
&& cmd != "session.save_copy"
@@ -613,7 +621,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
}
Dialog::Number { cmd, key, value, suffix, .. } => {
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(value).speed(0.1));
ui.add(egui::DragValue::new(value).speed(0.1)).accessible_name(format!("Value in {suffix}"));
ui.label(suffix.as_str());
});
if buttons(ui, "OK", enter) {
@@ -624,7 +632,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::TempoChange { at, bpm } => {
ui.horizontal(|ui| {
// Same limits the engine enforces (time crate `valid_bpm`).
ui.add(egui::DragValue::new(bpm).speed(0.1).range(5.0..=1000.0));
ui.add(egui::DragValue::new(bpm).speed(0.1).range(5.0..=1000.0)).accessible_name("Tempo (BPM)");
ui.label("bpm");
});
if buttons(ui, "OK", enter) {
@@ -707,7 +715,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::Group { id, name, edit, mix, members } => {
ui.horizontal(|ui| {
ui.label("Name");
ui.text_edit_singleline(name);
crate::widgets::named_text_field(ui.text_edit_singleline(name), "Name");
});
ui.horizontal(|ui| {
crate::widgets::checkbox(ui, edit, "Edit group");
@@ -751,12 +759,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::Loop { by, count, length, format, start, filled } => {
let s = app.engine.session();
ui.horizontal(|ui| {
ui.radio_value(by, LoopBy::Count, "Number of Loops");
ui.add_enabled(*by == LoopBy::Count, egui::DragValue::new(count).range(1..=512));
let count_label = ui.radio_value(by, LoopBy::Count, "Number of Loops");
ui.add_enabled(*by == LoopBy::Count, egui::DragValue::new(count).range(1..=512)).labelled_by(count_label.id);
});
ui.horizontal(|ui| {
ui.radio_value(by, LoopBy::Length, "Loop Length");
ui.add_enabled(*by == LoopBy::Length, egui::TextEdit::singleline(length).desired_width(140.0));
let length_label = ui.radio_value(by, LoopBy::Length, "Loop Length");
ui.add_enabled(*by == LoopBy::Length, egui::TextEdit::singleline(length).desired_width(140.0)).labelled_by(length_label.id);
ui.label(format.label());
});
ui.radio_value(by, LoopBy::Fill, "Loop Until End of Session or Next Clip");
@@ -768,13 +776,14 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::ScoreSetup { title, composer, bars_per_system, show_track_names } => {
egui::Grid::new("score_setup").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
ui.label("Title");
ui.text_edit_singleline(title);
// The painted label is not an accesskit name; name the field itself.
crate::widgets::named_text_field(ui.text_edit_singleline(title), "Score title");
ui.end_row();
ui.label("Composer");
ui.text_edit_singleline(composer);
crate::widgets::named_text_field(ui.text_edit_singleline(composer), "Score composer");
ui.end_row();
ui.label("Bars per system");
ui.add(egui::DragValue::new(bars_per_system).range(1..=16));
ui.add(egui::DragValue::new(bars_per_system).range(1..=16)).accessible_name("Bars per system");
ui.end_row();
});
crate::widgets::checkbox(ui, show_track_names, "Show track names");
@@ -813,11 +822,11 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
}
}
});
if esc || ctx.memory(|m| m.data.get_temp::<bool>(egui::Id::new("dlg_cancel")).unwrap_or(false)) {
if esc || ctx.memory(|m| m.data.get_temp::<bool>(egui::Id::unique("dlg_cancel")).unwrap_or(false)) {
keep = false;
app.dialogs.after_save = None;
app.dialogs.pending_session = None;
ctx.memory_mut(|m| m.data.remove::<bool>(egui::Id::new("dlg_cancel")));
ctx.memory_mut(|m| m.data.remove::<bool>(egui::Id::unique("dlg_cancel")));
}
// Enter on a focused Cancel also presses OK. In Auto Rename, the dialog then closes without running the command.
if !keep && matches!(d, Dialog::AutoRename { .. }) {
@@ -898,8 +907,9 @@ fn run_then(app: &mut SoundApp, cmd: &str, params: Value) -> Result<Value, Strin
/// with the values of the open Strip Silence window (or of the one Rename Selected Clips goes back
/// to). The Edit window draws them as the Strip Silence rectangles. Empty when neither window is
/// open. A scan runs again only when the session or a value changes. Off the web it runs on a worker
/// thread, one at a time, and the rectangles last found stay until it ends. A scan for older values,
/// or one that runs on after the window closes, stops before its next window of audio.
/// thread, one at a time, and the rectangles last found stay until it ends. On the web it runs on the
/// frame thread once no pointer button is down, so a slider drag scans once, on release. A scan for
/// older values, or one that runs on after the window closes, stops before its next window of audio.
pub fn strip_silence_rectangles(app: &mut SoundApp, ctx: &egui::Context) -> StripRects {
let values = match &app.dialogs.open {
Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) => [*threshold, *min_ms, *pre_ms, *post_ms],
@@ -927,8 +937,9 @@ pub fn strip_silence_rectangles(app: &mut SoundApp, ctx: &egui::Context) -> Stri
let shown = r.shown.as_ref().map(|(k, rects)| (*k == key, Arc::clone(rects)));
#[cfg(not(target_arch = "wasm32"))]
let busy = r.running.is_some();
// The browser's scan blocks the frame, so it waits for the release that ends a slider drag.
#[cfg(target_arch = "wasm32")]
let busy = false;
let busy = ctx.input(|i| i.pointer.any_down());
if let Some((true, rects)) = shown {
return rects;
}
@@ -959,8 +970,6 @@ pub fn strip_silence_rectangles(app: &mut SoundApp, ctx: &egui::Context) -> Stri
return shown.map(|(_, rects)| rects).unwrap_or_default();
}
}
#[cfg(target_arch = "wasm32")]
let _ = ctx;
// The browser has no threads, and without a thread to spare the scan runs on this one.
let rects: StripRects = Arc::new(scan.run().unwrap_or_default());
app.dialogs.strip_rectangles.shown = Some((key, Arc::clone(&rects)));
@@ -1004,17 +1013,17 @@ const AUTO_NAME_LABELS: [&str; 2] = ["Starting Number", "Number of Places"];
fn strip_name_fields(ui: &mut egui::Ui, names: &mut StripNames, labels: [&str; 2]) {
let [number, zeros] = labels;
egui::Grid::new("strip_names").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
ui.label("Name");
ui.text_edit_singleline(&mut names.name);
let name = ui.label("Name");
ui.text_edit_singleline(&mut names.name).labelled_by(name.id);
ui.end_row();
ui.label(number);
ui.add(egui::DragValue::new(&mut names.number).range(0..=StripNames::MAX_NUMBER));
let number = ui.label(number);
ui.add(egui::DragValue::new(&mut names.number).range(0..=StripNames::MAX_NUMBER)).labelled_by(number.id);
ui.end_row();
ui.label(zeros);
ui.add(egui::DragValue::new(&mut names.zeros).range(0..=StripNames::MAX_ZEROS));
let zeros = ui.label(zeros);
ui.add(egui::DragValue::new(&mut names.zeros).range(0..=StripNames::MAX_ZEROS)).labelled_by(zeros.id);
ui.end_row();
ui.label("Suffix");
ui.text_edit_singleline(&mut names.suffix);
let suffix = ui.label("Suffix");
ui.text_edit_singleline(&mut names.suffix).labelled_by(suffix.id);
ui.end_row();
});
let example = if names.name.trim().is_empty() {
@@ -1036,7 +1045,7 @@ fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> boo
ui.separator();
ui.horizontal(|ui| {
if ui.button("Cancel").clicked() {
ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::new("dlg_cancel"), true));
ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::unique("dlg_cancel"), true));
}
let b = egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::current().accent);
if ui.add_enabled(ready, b).clicked() {
@@ -1049,6 +1058,7 @@ fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> boo
/// A name field that takes the focus with its text selected, so typing replaces the old name.
fn name_field(ui: &mut egui::Ui, name: &mut String) {
let mut out = egui::TextEdit::singleline(name).show(ui);
let _ = crate::widgets::named_text_field(out.response.response.clone(), "Name");
if !out.response.has_focus() {
out.response.request_focus();
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(name.chars().count()));
@@ -1069,12 +1079,13 @@ const SPEED_RAMP_CHOICES: [(&str, &str, f32, &[(f32, &str)]); 3] = [
("fades", "Fades", 1.0, &[(1.0, "On"), (0.0, "Off")]),
];
/// Process-parameter window for the chosen AudioSuite process, with its controls and Render action.
fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) {
let Some(process) = app.ui.audiosuite.clone() else { return };
let mut open = true;
let title = soundcraft_dsp::plugin_info(&process).map_or_else(|| process.replace('_', " "), |p| p.name.to_string());
egui::Window::new(format!("AudioSuite · {title}")).open(&mut open).default_width(360.0).show(ctx, |ui| {
let key = egui::Id::new(("as_params", process.clone()));
let key = egui::Id::unique(("as_params", process.clone()));
let mut params: std::collections::BTreeMap<String, f32> = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_default();
if let Some(info) = soundcraft_dsp::plugin_info(&process) {
for p in info.params {
@@ -1095,10 +1106,6 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) {
let v = params.entry("semitones".into()).or_insert(0.0);
ui.add(egui::Slider::new(v, -24.0..=24.0).text("Semitones"));
}
"varispeed" => {
let v = params.entry("speed".into()).or_insert(1.0);
ui.add(egui::Slider::new(v, 0.25..=4.0).text("Speed"));
}
"speed_ramp" => {
for (id, label, default, choices) in SPEED_RAMP_CHOICES {
let v = params.entry(id.to_string()).or_insert(default);
+42 -5
View File
@@ -2,8 +2,8 @@
//! Document contents remain owned by their existing view implementations.
use craft_ui::docking::{
Action, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Floating, Layout, Node, PanelLimits, Permissions, Placement,
UiDock, Zone, ZoneName,
Action, DockArea, DockCommand, DockError, DockOperation, DockStyle, FloatSpace, Floating, Layout, Location, Node, PanelLimits, Permissions,
Placement, UiDock, Zone, ZoneName,
};
use craft_ui::layout::{SplitAxis, SplitSize};
use serde::{Deserialize, Serialize};
@@ -64,7 +64,7 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Pane
/// The dock area that draws the Edit or Mix workspace.
pub fn area_id(window: crate::MainWindow) -> egui::Id {
egui::Id::new(("soundcraft-docking", window == crate::MainWindow::Edit))
egui::Id::unique(("soundcraft-docking", window == crate::MainWindow::Edit))
}
fn area(edit: bool) -> egui::Id {
@@ -169,6 +169,41 @@ fn apply(workspace: &mut Workspace, action: Action<Panel>) -> Result<(), String>
checked(workspace, |next| next.apply(action, permissions).map(drop))
}
/// Whether [`Workspace::set_visible`] can actually put `panel` back at its recorded place.
/// It follows the recorded location and the chain of hidden anchors; when none of that
/// restores, craft-ui falls back to an unanchored open, which since craft-ui 650b1c0b chooses
/// a dock group or right edge instead of reporting the missing anchor. Probe each chain
/// location with the public [`Layout::restore`] on a clone, so a deleted anchor is recognized
/// before calling in; craft-ui still performs the restore and rewrites the chain itself.
fn restores(workspace: &Workspace, panel: Panel) -> bool {
let recorded = |p: &Panel| workspace.hidden.iter().find(|(q, _)| q == p).map(|(_, location)| location.clone());
let mut chain: Vec<Location<Panel>> = Vec::new();
let mut seen = vec![panel];
let mut current = panel;
while let Some(location) = recorded(&current) {
let anchor = location.anchor;
chain.push(location);
let Some(anchor) = anchor.filter(|anchor| !seen.contains(anchor)) else { break };
seen.push(anchor);
current = anchor;
}
// Like the chain itself, the panel's own group/column state applies to every attempt.
let group = chain.first().and_then(|location| location.group.clone());
// Revisit this eligibility probe when updating the pinned craft-ui restore algorithm.
chain
.iter()
.rev()
.find_map(|location| {
let location = Location { group: group.clone().or_else(|| location.group.clone()), ..location.clone() };
let mut probe = workspace.layout.clone();
match probe.restore(panel, &location) {
Err(DockError::MissingTarget) => None,
result => Some(result.is_ok()),
}
})
.unwrap_or(false)
}
/// Show a panel at its recorded place, else beside the document view on its default side.
fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result<(), String> {
if workspace.layout.contains(&panel) == visible {
@@ -178,7 +213,7 @@ fn set_visible(workspace: &mut Workspace, panel: Panel, visible: bool) -> Result
if !visible {
return next.apply(Action::Close { panel }, permissions).map(drop);
}
if next.set_visible(panel, true, None, permissions).is_ok() {
if next.hidden.iter().any(|(p, _)| *p == panel) && restores(next, panel) && next.set_visible(panel, true, None, permissions).is_ok() {
return Ok(());
}
next.apply(Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, permissions).map(drop)
@@ -333,6 +368,8 @@ pub(crate) fn arrange(app: &mut crate::SoundApp) -> Result<(), String> {
Ok(())
}
/// Draw and edit the dock layout for the requested window, falling back to the default layout if validation or panel
/// sync fails, then publish panel visibility, store the workspace and apply the frame's layout actions.
pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWindow) {
app.docking_context = Some(ui.ctx().clone());
let t = crate::theme::Tokens::current();
@@ -376,7 +413,7 @@ pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWin
Panel::Tracks => crate::panels::tracks_and_groups(app, ui),
Panel::Clips => crate::panels::clip_list(app, ui),
Panel::Midi => {
ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("soundcraft-midi-body"), ui.max_rect()));
ui.ctx().data_mut(|data| data.insert_temp(egui::Id::unique("soundcraft-midi-body"), ui.max_rect()));
minimum_body(ui, egui::vec2(420.0, 260.0), |ui| crate::midi_editor::show(app, ui));
}
Panel::Memory => crate::panels::memory_locations(app, ui),
+333 -72
View File
@@ -159,7 +159,7 @@ fn universe_runs(s: &Session, r: Rect) -> Vec<(Rect, Color32)> {
/// [`Session::content_end`], worked out once per document revision: it walks every clip.
fn content_end(app: &SoundApp, ctx: &egui::Context) -> Samples {
let key = egui::Id::new("content_end");
let key = egui::Id::unique("content_end");
let revision = app.engine.revision;
if let Some((at, end)) = ctx.memory(|m| m.data.get_temp::<(u64, Samples)>(key))
&& at == revision
@@ -174,7 +174,7 @@ fn content_end(app: &SoundApp, ctx: &egui::Context) -> Samples {
/// Universe: the whole session in miniature; the box is the visible area (drag or click to move).
fn universe(app: &mut SoundApp, ui: &mut Ui) {
let r = ui.max_rect().shrink2(vec2(8.0, 4.0));
let key = egui::Id::new("universe_runs");
let key = egui::Id::unique("universe_runs");
let revision = app.engine.revision;
let runs = match ui.ctx().memory(|m| m.data.get_temp::<UniverseRuns>(key)).filter(|u| u.revision == revision && u.area == r) {
Some(u) => u.runs,
@@ -199,7 +199,7 @@ fn universe(app: &mut SoundApp, ui: &mut Ui) {
let x = r.min.x + app.position() as f32 / total * r.width();
ui.painter().line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, Tokens::current().playhead));
}
let resp = ui.interact(r, ui.id().with("universe"), Sense::click_and_drag());
let resp = ui.interact(r, ui.scope_id().with("universe"), Sense::click_and_drag());
if let Some(p) = resp.interact_pointer_pos()
&& (resp.clicked() || resp.dragged())
{
@@ -209,6 +209,7 @@ fn universe(app: &mut SoundApp, ui: &mut Ui) {
}
}
/// Three dock toggle tabs for MIDI Editor, Memory Locations and Undo History.
fn dock_tabs(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let r = ui.max_rect();
@@ -224,7 +225,7 @@ fn dock_tabs(app: &mut SoundApp, ui: &mut Ui) {
ui.painter().rect_filled(tr, 3.0, t.accent_dark);
}
ui.painter().galley(pos2(tr.min.x + 8.0, tr.center().y - g.size().y * 0.5), g, Color32::WHITE);
if ui.interact(tr, ui.id().with(("dock", label)), Sense::click()).clicked() {
if ui.interact(tr, ui.scope_id().with(("dock", label)), Sense::click()).clicked() {
let _ = app.run(id, json!({}));
}
x = tr.max.x + 6.0;
@@ -269,6 +270,7 @@ pub(crate) fn main_area(app: &mut SoundApp, ui: &mut Ui) {
crate::docking::minimum_body(ui, minimum, |ui| main_area_content(app, ui));
}
/// Lay out the Edit window: rulers, track headers and lanes, empty-area selection, overlays and both scrollbars.
fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let full = ui.max_rect();
@@ -320,7 +322,7 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {
// Below the tracks a click clears the selection and a double-click opens New Tracks.
let empty = Rect::from_min_max(pos2(full.min.x, y.max(tl.min.y)), tl.max);
if empty.height() > 4.0 {
let resp = ui.interact(empty, ui.id().with("empty"), Sense::click());
let resp = ui.interact(empty, ui.scope_id().with("empty"), Sense::click());
if resp.double_clicked() {
let _ = app.dialogs.open_for_command(&app.engine, "track.new");
} else if resp.clicked() {
@@ -340,7 +342,7 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {
let off = app.edit_layout.scroll_y / content_h;
let thumb = Rect::from_min_size(pos2(vs.min.x + 2.0, vs.min.y + vs.height() * off), vec2(vs.width() - 4.0, (vs.height() * frac).max(20.0)));
ui.painter().rect_filled(thumb, 3.0, t.button_hi);
let resp = ui.interact(vs, ui.id().with("vscroll"), Sense::drag());
let resp = ui.interact(vs, ui.scope_id().with("vscroll"), Sense::drag());
if resp.dragged() {
app.edit_layout.scroll_by(resp.drag_delta().y / frac, tl.height());
}
@@ -447,6 +449,7 @@ fn pan(app: &mut SoundApp, px: f64) {
}
}
/// Draw and drag the horizontal timeline scrollbar; its thumb reflects the visible sample range plus trailing space.
fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) {
let t = Tokens::current();
ui.painter().rect_filled(sb, 0.0, t.panel_bg);
@@ -458,7 +461,7 @@ fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) {
let off = (s.edit.zoom.scroll as f64 / total).clamp(0.0, 1.0) as f32;
let thumb = Rect::from_min_size(pos2(sb.min.x + sb.width() * off, sb.min.y + 3.0), vec2((sb.width() * frac).max(24.0), sb.height() - 6.0));
ui.painter().rect_filled(thumb, 3.0, t.button_hi);
let resp = ui.interact(sb, ui.id().with("hscroll"), Sense::drag());
let resp = ui.interact(sb, ui.scope_id().with("hscroll"), Sense::drag());
if resp.dragged() {
let dx = resp.drag_delta().x / sb.width();
let by_px = f64::from(dx) * total / s.edit.zoom.samples_per_px;
@@ -535,7 +538,7 @@ fn tempo_editor(app: &mut SoundApp, ui: &mut Ui, painter: &egui::Painter, s: &Se
painter.add(Shape::line(pts, Stroke::new(1.5, Color32::from_rgb(140, 230, 160))));
painter.text(pos2(tl.min.x + 4.0, row.min.y + 8.0), Align2::LEFT_CENTER, format!("{hi:.0}"), regular(9.0), Color32::from_rgb(120, 160, 130));
painter.text(pos2(tl.min.x + 4.0, row.max.y - 8.0), Align2::LEFT_CENTER, format!("{lo:.0}"), regular(9.0), Color32::from_rgb(120, 160, 130));
let resp = ui.interact(row, ui.id().with("tempo_editor"), Sense::click());
let resp = ui.interact(row, ui.scope_id().with("tempo_editor"), Sense::click());
if resp.clicked()
&& let Some(p) = resp.interact_pointer_pos()
{
@@ -568,6 +571,7 @@ fn nice_step(spp: f64, sr: f64, min_px: f64) -> i64 {
(3600.0 * sr) as i64
}
/// Draw each enabled ruler and handle its clicks and double-clicks against the visible sample range.
fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[String]) {
let t = Tokens::current();
let s = app.engine.session_arc();
@@ -586,7 +590,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[
match id.as_str() {
"tempo" => {
painter.rect_filled(row.shrink2(vec2(0.0, 1.0)), 0.0, t.tempo_ruler);
let tresp = ui.interact(row, ui.id().with("tempo_ruler"), Sense::click());
let tresp = ui.interact(row, ui.scope_id().with("tempo_ruler"), Sense::click());
if tresp.double_clicked()
&& let Some(p) = tresp.interact_pointer_pos()
{
@@ -640,7 +644,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[
painter.line_segment([pos2(x, row.min.y + 2.0), pos2(x, row.max.y - 2.0)], Stroke::new(2.0, Color32::from_rgb(200, 180, 240)));
painter.text(pos2(x + 5.0, row.center().y), Align2::LEFT_CENTER, k, bold(11.0), Color32::from_rgb(236, 230, 250));
}
let resp = ui.interact(row, ui.id().with("key_ruler"), Sense::click());
let resp = ui.interact(row, ui.scope_id().with("key_ruler"), Sense::click());
if resp.double_clicked()
&& let Some(p) = resp.interact_pointer_pos()
{
@@ -687,7 +691,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[
painter.text(pos2(x + 8.0, row.center().y), Align2::LEFT_CENTER, &m.name, bold(11.0), t.marker_text);
}
// Click on the marker ruler: recall a marker; double-click renames it, or adds one.
let resp = ui.interact(row, ui.id().with(("marker_ruler", lane)), Sense::click());
let resp = ui.interact(row, ui.scope_id().with(("marker_ruler", lane)), Sense::click());
if let Some(p) = resp.interact_pointer_pos() {
let hit = s
.markers
@@ -712,7 +716,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[
if let Some(f) = fmt {
timebase_ruler(ui, &painter, &s, tl, row, view, f, s.frame_rate, i + 1 == rulers.len());
// Clicking a timebase ruler locates.
let resp = ui.interact(row, ui.id().with(("ruler", id.as_str())), Sense::click_and_drag());
let resp = ui.interact(row, ui.scope_id().with(("ruler", id.as_str())), Sense::click_and_drag());
if (resp.clicked() || resp.dragged())
&& let Some(p) = resp.interact_pointer_pos()
{
@@ -865,7 +869,7 @@ fn views_button(app: &mut SoundApp, ui: &mut Ui, rulers: Rect) {
let r = Rect::from_min_size(rulers.min + vec2(6.0, 5.0), vec2(50.0, 18.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r));
let resp = crate::widgets::selector_box(&mut child, r.width(), r.height(), "Views", Tokens::current().text);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, child.is_enabled(), "Views"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, child.is_enabled(), "Views"));
let resp = resp.on_hover_text("Columns beside the track headers: inserts, sends, I/O…");
egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
let shown = edit_columns(app.engine.session());
@@ -992,7 +996,7 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai
for c in pl.clips.iter().filter(|c| c.range().overlaps(&view)) {
draw_clip(app, &painter, &s, track, sorted, c, tl, lane, false);
}
let resp = ui.interact(lane, ui.id().with(("pl_lane", track.id.0, i)), Sense::click());
let resp = ui.interact(lane, ui.scope_id().with(("pl_lane", track.id.0, i)), Sense::click());
if resp.clicked()
&& let Some(p) = resp.interact_pointer_pos()
{
@@ -1012,12 +1016,14 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai
}
}
/// Draw one track header: resize edge, selection and context menu, folder/options control, automation-lane toggle,
/// name and the remaining track controls.
fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, selected: bool, head_bg: Color32) {
let t = Tokens::current();
let id = track.id;
// Drag the bottom edge to resize (snaps to the height presets).
let edge = Rect::from_min_max(pos2(head.min.x, head.max.y - 4.0), pos2(head.max.x, head.max.y + 2.0));
let eresp = ui.interact(edge, ui.id().with(("hedge", id.0)), Sense::drag());
let eresp = ui.interact(edge, ui.scope_id().with(("hedge", id.0)), Sense::drag());
if eresp.hovered() || eresp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
@@ -1045,7 +1051,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
}
let h = head.height();
// Click on header background selects the track.
let bg = ui.interact(head, ui.id().with(("hdr", id.0)), Sense::click());
let bg = ui.interact(head, ui.scope_id().with(("hdr", id.0)), Sense::click());
if bg.clicked() {
click_track(app, id, ui.input(|i| i.modifiers), in_edit_window);
}
@@ -1055,7 +1061,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
let ink = shade(strip, -0.8);
// The strip's round button opens a folder, or any other track's options.
let round = at(2.0, 2.0, 10.0, 10.0);
let rresp = ui.interact(round, ui.id().with(("options", id.0)), Sense::click());
let rresp = ui.interact(round, ui.scope_id().with(("options", id.0)), Sense::click());
ui.painter().circle_filled(round.center(), 5.0, shade(strip, if rresp.hovered() { 0.6 } else { 0.45 }));
arrow(ui.painter(), round.center(), !track.is_folder() || track.folder_open, ink);
if track.is_folder() {
@@ -1067,7 +1073,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
}
if h >= 40.0 && track.kind.has_playlist() {
let ar = at(3.0, h - 12.0, 9.0, 9.0);
let aresp = ui.interact(ar, ui.id().with(("auto_lane", id.0)), Sense::click());
let aresp = ui.interact(ar, ui.scope_id().with(("auto_lane", id.0)), Sense::click());
let showing = AutoParam::parse(&track.view).is_some();
ui.painter().rect_filled(ar, 1.5, shade(strip, if aresp.hovered() || showing { 0.6 } else { 0.45 }));
let (a, b) = (ar.min + vec2(2.5, 2.5), ar.max - vec2(2.5, 2.5));
@@ -1099,7 +1105,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
};
let name_color = if selected { t.text_dark } else { t.text.lerp_to_gamma(t.text_dim, 0.4) };
ui.painter().with_clip_rect(name_r).text(pos2(name_r.min.x + 1.5, name_r.center().y), Align2::LEFT_CENTER, &shown_name, bold(12.5), name_color);
let nresp = ui.interact(name_r, ui.id().with(("name", id.0)), Sense::click_and_drag());
let nresp = ui.interact(name_r, ui.scope_id().with(("name", id.0)), Sense::click_and_drag());
if nresp.double_clicked() {
app.dialogs.open_rename_track(id, &track.name);
} else if nresp.clicked() {
@@ -1120,7 +1126,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
.min_by_key(|y| ((y - p.y).abs() * 10.0) as i64)
.unwrap_or(p.y);
ui.ctx()
.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("reorder")))
.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("reorder")))
.line_segment([pos2(head.min.x, line_y), pos2(head.max.x + 600.0, line_y)], Stroke::new(3.0, Tokens::current().accent));
}
if nresp.drag_stopped()
@@ -1138,7 +1144,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
}
}
let pl_r = at(114.0, 1.0, 11.0, 14.5);
let presp = ui.interact(pl_r, ui.id().with(("pl", id.0)), Sense::click());
let presp = ui.interact(pl_r, ui.scope_id().with(("pl", id.0)), Sense::click());
ui.painter().rect_filled(pl_r, 1.5, shade(head_bg, if presp.hovered() { -0.2 } else { -0.35 }));
arrow(ui.painter(), pos2(pl_r.center().x, pl_r.min.y + 5.75), true, t.text);
egui::Popup::menu(&presp.on_hover_text("Playlists")).show(|ui| {
@@ -1169,19 +1175,27 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
(HeaderKey::Mute, m.mute, implied, "mix.mute", mixes),
];
for (i, (key, lit, implied, cmd, shown)) in keys.into_iter().enumerate() {
if shown && header_toggle(ui, at(23.0 + i as f32 * 26.0, 20.0, 16.0, 16.0), id, key, lit, implied).clicked() {
if shown && header_toggle(ui, at(23.0 + i as f32 * 26.0, 20.0, 16.0, 16.0), id, &track.name, key, lit, implied).clicked() {
let _ = app.run(cmd, json!({"track": id.0}));
}
}
}
if h >= 58.0 {
let vresp = header_selector(ui, at(17.0, 39.0, 105.0, 17.0), ui.id().with(("view", id.0)), &view_label(&track.view), t.text, true);
let vresp = header_selector(
ui,
at(17.0, 39.0, 105.0, 17.0),
ui.scope_id().with(("view", id.0)),
&view_label(&track.view),
t.text,
true,
&format!("Track view for {}", track.name),
);
egui::Popup::menu(&vresp).show(|ui| view_menu(app, ui, track));
}
if h >= 76.0 {
if matches!(track.kind, TrackKind::Audio | TrackKind::Instrument | TrackKind::Aux) {
let r = at(17.0, 57.0, 13.0, 17.0);
let resp = ui.interact(r, ui.id().with(("freeze", id.0)), Sense::click());
let resp = ui.interact(r, ui.scope_id().with(("freeze", id.0)), Sense::click());
raised_key(ui.painter(), r, if track.frozen { t.slot_bypassed } else { t.slot_face }, resp.hovered());
for a in [0.0f32, 60.0, 120.0] {
let d = vec2(a.to_radians().sin(), a.to_radians().cos()) * 3.5;
@@ -1193,7 +1207,8 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
}
if !matches!(track.kind, TrackKind::Master | TrackKind::Vca | TrackKind::Folder) {
let (voice, color) = if track.inactive { ("off", t.text_dim) } else { ("dyn", t.text) };
let resp = header_selector(ui, at(31.0, 57.0, 31.0, 17.0), ui.id().with(("voice", id.0)), voice, color, false);
let voice_name = format!("Voice for {}", track.name);
let resp = header_selector(ui, at(31.0, 57.0, 31.0, 17.0), ui.scope_id().with(("voice", id.0)), voice, color, false, &voice_name);
egui::Popup::menu(&resp.on_hover_text("Voice: dyn plays the track, off makes it inactive")).show(|ui| {
for (label, inactive) in [("dyn", false), ("off", true)] {
if ui.selectable_label(track.inactive == inactive, label).clicked() {
@@ -1210,7 +1225,8 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
soundcraft_model::AutomationMode::Latch => t.auto_latch,
_ => t.text_dim,
};
let aresp = header_selector(ui, at(63.0, 57.0, 59.0, 17.0), ui.id().with(("auto_mode", id.0)), mode.label(), col, true);
let mode_name = format!("Automation mode for {}", track.name);
let aresp = header_selector(ui, at(63.0, 57.0, 59.0, 17.0), ui.scope_id().with(("auto_mode", id.0)), mode.label(), col, true, &mode_name);
egui::Popup::menu(&aresp).show(|ui| {
for m in soundcraft_model::AutomationMode::ALL {
if ui.selectable_label(m == mode, m.label()).clicked() {
@@ -1222,7 +1238,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
if h >= 94.0 {
if track.kind.has_playlist() {
let r = at(17.0, 75.0, 13.0, 17.0);
let resp = ui.interact(r, ui.id().with(("timebase", id.0)), Sense::click());
let resp = ui.interact(r, ui.scope_id().with(("timebase", id.0)), Sense::click());
raised_key(ui.painter(), r, t.slot_face, resp.hovered());
let c = r.center();
if track.ticks_timebase {
@@ -1249,7 +1265,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
}
let field = at(30.0, 75.0, 92.0, 17.0);
if matches!(track.kind, TrackKind::Audio | TrackKind::Instrument) {
let resp = ui.interact(field, ui.id().with(("field", id.0)), Sense::click());
let resp = ui.interact(field, ui.scope_id().with(("field", id.0)), Sense::click());
ui.painter().rect_filled(field, 2.0, shade(head_bg, if resp.hovered() { -0.02 } else { -0.1 }));
if track.kind == TrackKind::Instrument {
let name =
@@ -1291,9 +1307,15 @@ fn arrow(p: &egui::Painter, c: Pos2, down: bool, color: Color32) {
}
/// A header selector (track view, automation mode…): the value centred, and for menus an arrow
/// in the top-right corner.
fn header_selector(ui: &Ui, r: Rect, id: egui::Id, text: &str, color: Color32, menu: bool) -> egui::Response {
/// in the top-right corner. `name` is the accessible meaning of the value (for example
/// "Automation mode"), not the short text painted inside the slot.
fn header_selector(ui: &Ui, r: Rect, id: egui::Id, text: &str, color: Color32, menu: bool, name: &str) -> egui::Response {
let resp = ui.interact(r, id, Sense::click());
resp.widget_info(|| {
let mut info = egui::WidgetInfo::labeled(egui::accesskit::Role::ComboBox, ui.is_enabled(), name);
info.current_text_value = Some(text.to_string());
info
});
raised_key(ui.painter(), r, Tokens::current().slot_face, resp.hovered());
let tr = if menu { r.with_max_x(r.max.x - 7.0) } else { r };
ui.painter().with_clip_rect(tr.shrink(1.0)).text(tr.center(), Align2::CENTER_CENTER, text, bold(10.0), color);
@@ -1313,7 +1335,7 @@ enum HeaderKey {
/// Record, input, solo or mute: `lit` gives the key its colour, `implied` a dark shade of it (a
/// mute that another track's solo implies). Record keys stay dark red when off, as in Pro Tools.
fn header_toggle(ui: &Ui, r: Rect, track: TrackId, key: HeaderKey, lit: bool, implied: bool) -> egui::Response {
fn header_toggle(ui: &Ui, r: Rect, track: TrackId, track_name: &str, key: HeaderKey, lit: bool, implied: bool) -> egui::Response {
let t = Tokens::current();
let (label, color) = match key {
HeaderKey::Rec => ("Record enable", t.rec),
@@ -1321,8 +1343,9 @@ fn header_toggle(ui: &Ui, r: Rect, track: TrackId, key: HeaderKey, lit: bool, im
HeaderKey::Solo => ("Solo", t.solo),
HeaderKey::Mute => ("Mute", t.mute),
};
let resp = ui.interact(r, ui.id().with((label, track.0)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), lit, label));
let name = format!("{label} for {track_name}");
let resp = ui.interact(r, ui.scope_id().with((label, track.0)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), lit, &name));
let rec = key == HeaderKey::Rec;
let (tint, k) = if lit {
(color, 0.85)
@@ -1393,7 +1416,11 @@ fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: R
ui.painter().vline(r.min.x, r.y_range(), Stroke::new(1.0, Tokens::current().border));
let mut c = track.comments.clone();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink(3.0)));
if child.add(egui::TextEdit::multiline(&mut c).desired_width(r.width() - 6.0).font(regular(10.0))).changed() {
let field = crate::widgets::named_text_field(
child.add(egui::TextEdit::multiline(&mut c).desired_width(r.width() - 6.0).font(regular(10.0))),
&format!("Comments for {}", track.name),
);
if field.changed() {
let _ =
app.engine.execute_merged("track.comments", &json!({"track": track.id.0, "comments": c}), &format!("comments:{}", track.id.0));
}
@@ -1410,17 +1437,23 @@ fn io_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {
let ui = column_ui(ui, "io", r);
let row = |y: f32, h: f32| (r.height() >= y + h + 1.0).then_some(Rect::from_min_size(r.min + vec2(7.0, y), vec2(r.width() - 14.0, h)));
if let Some(input) = row(4.0, 15.0) {
let resp = header_selector(&ui, input, ui.id().with(("in", id.0)), &crate::mix_window::input_name(app, track), t.text, true);
let input_name = crate::mix_window::input_name(app, track);
let resp = header_selector(&ui, input, ui.scope_id().with(("in", id.0)), &input_name, t.text, true, &format!("Input for {}", track.name));
crate::mix_window::route_popup(app, &resp.on_hover_text("Input"), id, true);
}
if let Some(out) = row(21.0, 17.0) {
let (sel, fader) = (out.with_max_x(out.max.x - 11.0), out.with_min_x(out.max.x - 11.0));
let resp = ui.interact(sel, ui.id().with(("out", id.0)), Sense::click());
let fresp = ui.interact(fader, ui.id().with(("out_fader", id.0)), Sense::click());
let resp = ui.interact(sel, ui.scope_id().with(("out", id.0)), Sense::click());
let output_name = crate::mix_window::route_name(app, &track.mixer.output);
resp.widget_info(|| {
let mut info = egui::WidgetInfo::labeled(egui::accesskit::Role::ComboBox, ui.is_enabled(), format!("Output for {}", track.name));
info.current_text_value = Some(output_name.clone());
info
});
let fresp = ui.interact(fader, ui.scope_id().with(("out_fader", id.0)), Sense::click());
raised_key(ui.painter(), out, t.slot_face, resp.hovered() || fresp.hovered());
groove(ui.painter(), fader.min.x - 0.5, out.min.y + 1.0..=out.max.y - 1.0, t.slot_face[0]);
let name = crate::mix_window::route_name(app, &track.mixer.output);
ui.painter().with_clip_rect(sel.shrink(1.0)).text(sel.center(), Align2::CENTER_CENTER, name, bold(10.0), t.text);
ui.painter().with_clip_rect(sel.shrink(1.0)).text(sel.center(), Align2::CENTER_CENTER, &output_name, bold(10.0), t.text);
let c = fader.center() + vec2(0.5, 0.0);
ui.painter().vline(c.x, c.y - 5.0..=c.y + 5.0, Stroke::new(1.0, t.text));
ui.painter().rect_filled(Rect::from_center_size(c + vec2(0.0, -1.5), vec2(5.0, 2.5)), 0.5, t.text);
@@ -1679,7 +1712,14 @@ fn draw_live_take(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &
let (body, _, wave) = clip_colors([t.rec.r(), t.rec.g(), t.rec.b()], false);
painter.rect(r, CornerRadius::same(2), body, Stroke::new(1.0, Color32::from_rgb(10, 10, 10)), StrokeKind::Inside);
if track.view != "blocks" {
crate::waveform::draw_live(&painter.with_clip_rect(r.intersect(lane)), s, tl, origin as f64, block_len, &take, r, wave);
crate::waveform::draw_live(
crate::waveform::Canvas { painter: &painter.with_clip_rect(r.intersect(lane)), s, tl },
origin as f64,
block_len,
&take,
r,
wave,
);
}
}
@@ -1745,7 +1785,14 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
preview = Clip { fade_in, fade_out, ..clip.clone() };
&preview
};
crate::waveform::draw(&painter.with_clip_rect(vis), s, tl, shown, audio, track.channels(), body_r, wave);
crate::waveform::draw(
crate::waveform::Canvas { painter: &painter.with_clip_rect(vis), s, tl },
shown,
audio,
track.channels(),
body_r,
wave,
);
let n = track.channels().min(audio.peaks.len()).max(1);
for k in 1..n {
let y = body_r.min.y + body_r.height() / n as f32 * k as f32;
@@ -1799,7 +1846,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
} else {
clip_colors(prev.color.unwrap_or(track.color), false).2.gamma_multiply(0.6)
};
crate::waveform::draw(&painter.with_clip_rect(xf), s, tl, prev, audio, track.channels(), body_r, tail);
crate::waveform::draw(crate::waveform::Canvas { painter: &painter.with_clip_rect(xf), s, tl }, prev, audio, track.channels(), body_r, tail);
}
// Clip gain line.
let show_gain = s.edit.flag("view.clip.gain_line") && (!clip.gain_env.is_empty() || clip.gain_db.abs() > 0.01 || track.view == "clip_gain");
@@ -2076,9 +2123,10 @@ pub fn clip_gain_icon(s: &Session, tl: Rect, lane: Rect, clip: &Clip) -> Option<
(shown && x1 - x0 >= 48.0 && lane.height() >= 30.0).then(|| Rect::from_min_size(pos2(x0 + 4.0, lane.max.y - 16.0), vec2(9.0, 13.0)))
}
/// Draw the read-only clip-gain fader tooltip at `pos`.
fn clip_gain_popup(ctx: &egui::Context, fader_bottom: Pos2, pos: f32) {
let t = Tokens::current();
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("clip_gain_fader")));
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("clip_gain_fader")));
let track = Rect::from_min_max(pos2(fader_bottom.x - 2.0, fader_bottom.y - CLIP_FADER_TRAVEL), pos2(fader_bottom.x + 2.0, fader_bottom.y));
let panel = Rect::from_min_max(pos2(track.min.x - 22.0, track.min.y - 26.0), pos2(track.max.x + 22.0, track.max.y + 8.0));
p.rect(panel, 3.0, Color32::from_rgb(38, 38, 40), Stroke::new(1.0, Color32::from_rgb(10, 10, 10)), StrokeKind::Inside);
@@ -2091,8 +2139,10 @@ fn clip_gain_popup(ctx: &egui::Context, fader_bottom: Pos2, pos: f32) {
p.text(pos2(panel.center().x, panel.min.y + 12.0), Align2::CENTER_CENTER, format!("{db:+.1}"), regular(10.5), t.counter_text);
}
/// Handle one track lane: clip context menus, drag-and-drop sources, sampler drops, and the active tool's selection,
/// trim, fade, automation and clip editing.
fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) {
let id = ui.id().with(("lane", track.id.0));
let id = ui.scope_id().with(("lane", track.id.0));
let resp = ui.interact(lane, id, Sense::click_and_drag());
// The clip menu is attached before the early return below: once the menu is open the pointer
// is over the popup, the lane has no hover position, and the rest of this function bails out.
@@ -2631,7 +2681,7 @@ struct DragPlan {
/// until the document, the Edit mode or the target changes.
fn drag_plan(ctx: &egui::Context, s: &Session, revision: u64, clips: &[ClipId], (from, to): (TrackId, TrackId), grab_at: Samples) -> Arc<DragPlan> {
type Key = (u64, EditMode, Vec<ClipId>, TrackId, TrackId, Samples);
let id = egui::Id::new("clip_drag_plan");
let id = egui::Id::unique("clip_drag_plan");
let key: Key = (revision, s.edit.edit_mode, clips.to_vec(), from, to, grab_at);
if let Some((k, plan)) = ctx.data(|d| d.get_temp::<(Key, Arc<DragPlan>)>(id))
&& k == key
@@ -2760,6 +2810,18 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
}
}
/// One frame's inputs to [`follow_update`].
struct FollowFrame {
playing: bool,
mode_follow: bool,
playhead_visible: bool,
cur: Samples,
last_scroll: Samples,
last_follow_to: Option<Samples>,
hold: bool,
pos: Samples,
}
/// Next follow state after observing one frame: `(hold, follow_to)`.
///
/// The view is ours to move only while nothing else moved it: when the
@@ -2769,16 +2831,8 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
/// jump is issued. Stopping, or scrolling back to the playhead, clears the
/// hold. Only `page`/`continuous`/`center` ever issue follow jumps while
/// playing; `none`/`after_playback` leave the view alone.
fn follow_update(
playing: bool,
mode_follow: bool,
playhead_visible: bool,
cur: Samples,
last_scroll: Samples,
last_follow_to: Option<Samples>,
hold: bool,
pos: Samples,
) -> (bool, Option<Samples>) {
fn follow_update(frame: FollowFrame) -> (bool, Option<Samples>) {
let FollowFrame { playing, mode_follow, playhead_visible, cur, last_scroll, last_follow_to, hold, pos } = frame;
if !playing || playhead_visible {
return (false, None);
}
@@ -2827,7 +2881,7 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
} else if !app.is_playing() {
let x = x_of(s, tl, sel.start);
let now = ui.input(|i| i.time);
let (id, key) = (ui.id().with("insertion_blink"), (sel.start, s.edit.selected_tracks.clone()));
let (id, key) = (ui.scope_id().with("insertion_blink"), (sel.start, s.edit.selected_tracks.clone()));
let since = ui.ctx().data_mut(|d| {
let (last, since) = d.get_temp_mut_or_insert_with(id, || (key.clone(), now));
if *last != key {
@@ -2856,7 +2910,16 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
let mode_follow = matches!(s.edit.scrolling.as_str(), "page" | "continuous" | "center");
let visible = x >= tl.min.x && x <= tl.max.x;
let layout = &mut app.edit_layout;
let (hold, follow_to) = follow_update(true, mode_follow, visible, cur, layout.last_scroll, layout.last_follow_to, layout.follow_hold, pos);
let (hold, follow_to) = follow_update(FollowFrame {
playing: true,
mode_follow,
playhead_visible: visible,
cur,
last_scroll: layout.last_scroll,
last_follow_to: layout.last_follow_to,
hold: layout.follow_hold,
pos,
});
layout.follow_hold = hold;
layout.last_scroll = cur;
layout.last_follow_to = follow_to;
@@ -2967,7 +3030,84 @@ mod universe_tests {
#[cfg(test)]
mod tests {
use super::*;
use egui_kittest::Harness;
use egui::accesskit::Role;
use egui_kittest::{Harness, kittest::Queryable};
/// The painted header selectors are interactive controls; their accessible name is the field
/// they edit, not the short value text ("Read", "Waveform") painted inside them.
#[test]
fn track_header_selectors_have_accessible_names() {
let mut h = Harness::builder().with_context_setup(crate::fonts::install).build_ui(|ui| {
let view = Rect::from_min_size(Pos2::ZERO, vec2(60.0, 17.0));
super::header_selector(ui, view, egui::Id::unique("test-view"), "Waveform", Color32::WHITE, true, "Track view");
let mode = Rect::from_min_size(pos2(0.0, 20.0), vec2(60.0, 17.0));
super::header_selector(ui, mode, egui::Id::unique("test-mode"), "Read", Color32::WHITE, true, "Automation mode");
});
h.run();
assert!(h.query_by_role_and_label(Role::ComboBox, "Track view").is_some());
assert!(h.query_by_role_and_label(Role::ComboBox, "Automation mode").is_some());
}
/// Repeated per-track selectors and toggles must name their track and publish their current
/// value; a screen reader otherwise sees several identical "Solo" or "Track view" controls.
#[test]
fn repeated_track_controls_are_named_and_valued_per_track() {
let mut h = harness_with(soundcraft_engine::demo::demo_engine());
for name in ["Kick", "Snare"] {
let view_label = format!("Track view for {name}");
let view = h.query_by_role_and_label(Role::ComboBox, &view_label).unwrap_or_else(|| panic!("missing track view for {name}"));
assert_eq!(view.value().as_deref(), Some("waveform"), "track view value for {name}");
let voice_label = format!("Voice for {name}");
let voice = h.query_by_role_and_label(Role::ComboBox, &voice_label).unwrap_or_else(|| panic!("missing voice for {name}"));
assert_eq!(voice.value().as_deref(), Some("dyn"), "voice value for {name}");
let mode_label = format!("Automation mode for {name}");
let mode = h.query_by_role_and_label(Role::ComboBox, &mode_label).unwrap_or_else(|| panic!("missing automation mode for {name}"));
assert_eq!(mode.value().as_deref(), Some("read"), "automation value for {name}");
for label in ["Record enable", "Input monitoring", "Solo", "Mute"] {
assert!(h.query_by_label(&format!("{label} for {name}")).is_some(), "missing {label} for {name}");
}
}
assert!(h.query_all_by_label_contains("Track view for").count() >= 2, "both visible tracks expose their own control");
// The Mix window repeats the same controls per strip; those names must be track-qualified
// too, and the automation selector must publish its value rather than only a tooltip.
h.state_mut().ui.window = crate::MainWindow::Mix;
h.run_steps(5);
for name in ["Kick", "Snare"] {
let mode_label = format!("Automation mode for {name}");
let mode = h.query_by_role_and_label(Role::ComboBox, &mode_label).unwrap_or_else(|| panic!("missing mix automation mode for {name}"));
assert_eq!(mode.value().as_deref(), Some("auto read"), "mix automation value for {name}");
for label in ["Input", "Output", "Group", "Record enable", "Input monitoring", "Solo", "Mute", "Insert A", "Send A"] {
assert!(h.query_by_label(&format!("{label} for {name}")).is_some(), "missing mix {label} for {name}");
}
}
// A used send's level is another per-track control; it must name its track too.
assert!(h.query_by_label("Send A level for Snare").is_some(), "missing mix send A level for Snare");
}
/// Every track's comments editor carries its own name, so label queries and screen readers can
/// tell two tracks apart instead of seeing several identical "Comments" fields.
#[test]
fn every_track_comments_field_is_named_for_its_track() {
let engine = soundcraft_engine::demo::demo_engine();
let tracks: Vec<Track> = engine.session().tracks.iter().take(2).cloned().collect();
assert_eq!(tracks.len(), 2);
let names: Vec<String> = tracks.iter().map(|t| format!("Comments for {}", t.name)).collect();
let app = SoundApp::new(engine, None, crate::Services::default());
let mut h = Harness::new_ui_state(
move |ui, app: &mut SoundApp| {
for (i, track) in tracks.iter().enumerate() {
let r = Rect::from_min_size(pos2(i as f32 * 200.0, 0.0), vec2(180.0, 80.0));
header_column(app, ui, track, "comments", r, Color32::BLACK);
}
},
app,
);
h.run();
for name in &names {
assert!(h.query_by_label(name).is_some(), "missing comments field {name}");
}
}
#[test]
fn automation_id_round_trips_for_high_slots() {
@@ -2983,40 +3123,160 @@ mod tests {
#[test]
fn follow_jumps_to_offscreen_playhead_while_playing() {
// Steady view, follow mode, playhead left the screen: jump to it.
assert_eq!(follow_update(true, true, false, 1_000, 1_000, None, false, 96_000), (false, Some(96_000)));
assert_eq!(
follow_update(FollowFrame {
playing: true,
mode_follow: true,
playhead_visible: false,
cur: 1_000,
last_scroll: 1_000,
last_follow_to: None,
hold: false,
pos: 96_000
}),
(false, Some(96_000))
);
}
#[test]
fn follow_leaves_onscreen_playhead_alone() {
assert_eq!(follow_update(true, true, true, 1_000, 1_000, None, false, 96_000), (false, None));
assert_eq!(
follow_update(FollowFrame {
playing: true,
mode_follow: true,
playhead_visible: true,
cur: 1_000,
last_scroll: 1_000,
last_follow_to: None,
hold: false,
pos: 96_000
}),
(false, None)
);
}
#[test]
fn follow_never_scrolls_when_stopped_nor_in_free_modes() {
assert_eq!(follow_update(false, true, false, 1_000, 1_000, None, false, 96_000), (false, None));
assert_eq!(
follow_update(FollowFrame {
playing: false,
mode_follow: true,
playhead_visible: false,
cur: 1_000,
last_scroll: 1_000,
last_follow_to: None,
hold: false,
pos: 96_000
}),
(false, None)
);
// `none`/`after_playback` never follow, even with a steady view.
assert_eq!(follow_update(true, false, false, 1_000, 1_000, None, false, 96_000), (false, None));
assert_eq!(follow_update(true, false, false, 9_000, 1_000, None, false, 96_000), (false, None));
assert_eq!(
follow_update(FollowFrame {
playing: true,
mode_follow: false,
playhead_visible: false,
cur: 1_000,
last_scroll: 1_000,
last_follow_to: None,
hold: false,
pos: 96_000
}),
(false, None)
);
assert_eq!(
follow_update(FollowFrame {
playing: true,
mode_follow: false,
playhead_visible: false,
cur: 9_000,
last_scroll: 1_000,
last_follow_to: None,
hold: false,
pos: 96_000
}),
(false, None)
);
}
#[test]
fn outside_view_move_pauses_the_follow() {
// The view moved and it was not our own jump: pause, no jump.
assert_eq!(follow_update(true, true, false, 9_000, 1_000, None, false, 96_000), (true, None));
assert_eq!(
follow_update(FollowFrame {
playing: true,
mode_follow: true,
playhead_visible: false,
cur: 9_000,
last_scroll: 1_000,
last_follow_to: None,
hold: false,
pos: 96_000
}),
(true, None)
);
// A held view stays held while it sits still elsewhere.
assert_eq!(follow_update(true, true, false, 9_000, 9_000, None, true, 96_000), (true, None));
assert_eq!(
follow_update(FollowFrame {
playing: true,
mode_follow: true,
playhead_visible: false,
cur: 9_000,
last_scroll: 9_000,
last_follow_to: None,
hold: true,
pos: 96_000
}),
(true, None)
);
}
#[test]
fn own_follow_jump_is_not_mistaken_for_a_takeover() {
// The scroll equals the target we issued last frame: keep following.
assert_eq!(follow_update(true, true, false, 96_000, 1_000, Some(96_000), false, 97_000), (false, Some(97_000)));
assert_eq!(
follow_update(FollowFrame {
playing: true,
mode_follow: true,
playhead_visible: false,
cur: 96_000,
last_scroll: 1_000,
last_follow_to: Some(96_000),
hold: false,
pos: 97_000
}),
(false, Some(97_000))
);
}
#[test]
fn hold_clears_when_playhead_returns_or_transport_stops() {
assert_eq!(follow_update(true, true, true, 9_000, 9_000, None, true, 96_000), (false, None));
assert_eq!(follow_update(false, true, false, 9_000, 9_000, None, true, 96_000), (false, None));
assert_eq!(
follow_update(FollowFrame {
playing: true,
mode_follow: true,
playhead_visible: true,
cur: 9_000,
last_scroll: 9_000,
last_follow_to: None,
hold: true,
pos: 96_000
}),
(false, None)
);
assert_eq!(
follow_update(FollowFrame {
playing: false,
mode_follow: true,
playhead_visible: false,
cur: 9_000,
last_scroll: 9_000,
last_follow_to: None,
hold: true,
pos: 96_000
}),
(false, None)
);
}
fn harness(n: usize) -> Harness<'static, SoundApp> {
@@ -4134,13 +4394,14 @@ mod tests {
assert!(s.edit.selection.end > pointed, "the selection did not follow the scroll");
}
/// A pass redrawn by egui must still contribute one frame-time sample, measured from the host clock rather than
/// UI input time.
#[test]
fn a_redrawn_frame_counts_once_in_the_frame_time() {
let mut h = harness(4);
let mut t = 1.0;
let mut frame = |h: &mut Harness<SoundApp>| {
t += 1.0 / 60.0;
h.input_mut().time = Some(t);
// The monotonic host clock, not the UI input time, drives frame statistics.
let frame = |h: &mut Harness<SoundApp>| {
h.state_mut().advance_test_clock(std::time::Duration::from_secs_f64(1.0 / 60.0));
h.step();
};
for _ in 0..120 {
+22 -22
View File
@@ -90,7 +90,7 @@ fn renderer(app: &mut SoundApp, ctx: &egui::Context) {
}
let t = Tokens::current();
let fmt = app.engine.session().main_format();
let peaks: Vec<f32> = app.player.as_ref().map(|p| p.meters().main.peaks).unwrap_or_default();
let peaks: Vec<f32> = if app.player.is_some() { app.meter_snapshot.main.peaks.clone() } else { Vec::new() };
let objects: Vec<(String, f32, f32, f32)> = app
.engine
.session()
@@ -145,6 +145,7 @@ fn renderer(app: &mut SoundApp, ctx: &egui::Context) {
app.extra.show_renderer = open;
}
/// Appearance window: interface scale, reduce-motion, theme, auto-update configuration and Reset.
fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.extra.show_ui_customization;
if !open {
@@ -153,30 +154,29 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) {
egui::Window::new(egui::RichText::new("Appearance").font(bold(13.0))).open(&mut open).default_size(vec2(320.0, 180.0)).resizable(false).show(
ctx,
|ui| {
egui::Grid::new("ui_custom").num_columns(2).spacing(vec2(12.0, 8.0)).show(ui, |ui| {
ui.label("Interface scale");
ui.add(egui::Slider::new(&mut app.extra.ui_scale, 0.75..=1.5).step_by(0.05).custom_formatter(|v, _| format!("{:.0} %", v * 100.0)));
ui.end_row();
ui.label("Reduce motion");
crate::widgets::checkbox(ui, &mut app.extra.reduce_motion, "");
ui.end_row();
ui.label("Theme");
let mut chosen = None;
egui::ComboBox::from_id_salt("theme_mode").selected_text(app.ui.theme.label()).show_ui(ui, |ui| {
for mode in crate::theme::ThemeMode::ALL {
if ui.selectable_label(app.ui.theme == mode, mode.label()).clicked() {
chosen = Some(mode);
}
// Each control carries its own visible label, which is also its accessible name. A
// label in a neighbouring grid cell is only painted text: a screen reader (and the
// kittest accessibility check) would see an unnamed slider, checkbox or combo box.
ui.add(
egui::Slider::new(&mut app.extra.ui_scale, 0.75..=1.5)
.step_by(0.05)
.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 = None;
egui::ComboBox::from_label("Theme").selected_text(app.ui.theme.label()).show_ui(ui, |ui| {
for mode in crate::theme::ThemeMode::ALL {
if ui.selectable_label(app.ui.theme == mode, mode.label()).clicked() {
chosen = Some(mode);
}
});
if let Some(mode) = chosen {
let _ = app.run("ui.theme", json!({"mode": mode.id()}));
}
ui.end_row();
ui.label("Auto-update configuration");
crate::widgets::checkbox(ui, &mut app.extra.auto_update_config, "");
ui.end_row();
});
if let Some(mode) = chosen {
let _ = app.run("ui.theme", json!({"mode": mode.id()}));
}
crate::widgets::checkbox(ui, &mut app.extra.auto_update_config, "Auto-update configuration");
ui.add_space(4.0);
if ui.button("Reset").clicked() {
app.extra.ui_scale = 1.0;
app.extra.reduce_motion = false;
+23
View File
@@ -151,6 +151,29 @@ mod tests {
assert!(HANGUL.iter().filter(|(p, _)| p.ends_with("NotoSansCJK-Regular.ttc")).all(|(_, i)| *i == 1));
}
/// Every literal glyph the UI fixtures paint is covered by the bundled faces in every family,
/// without relying on an installed system font. This is the 0.37 font-feature regression test.
#[test]
fn bundled_faces_cover_the_ui_glyphs_in_every_family() {
let mut defs = FontDefinitions::default();
let proportional = defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
defs.families.insert(FontFamily::Name("bold".into()), proportional);
let ctx = egui::Context::default();
ctx.set_fonts(defs);
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
output.textures_delta.clear();
// Status/menu text, transport/meter symbols, file/audio/search icons, music symbols and
// platform key hints used by the UI. Close/delete controls paint a cross in `icons.rs`
// instead of typesetting U+2715, which no bundled face covers in every family.
let glyphs = ["›", "v", "♩", "𝄞", "⌘", "📁", "🔊", "🔍", "⬆", "−", "∞"];
for family in [FontFamily::Proportional, FontFamily::Monospace, FontFamily::Name("bold".into())] {
let font = egui::FontId::new(13.0, family.clone());
for glyph in glyphs {
assert!(ctx.fonts_mut(|f| f.has_glyphs(&font, glyph)), "{family:?} has no glyph for {glyph:?}");
}
}
}
/// Korean names draw in every family, not as boxes; skipped when the system has no Korean font.
#[test]
fn every_family_draws_hangul_with_a_system_korean_font() {
+5
View File
@@ -143,6 +143,11 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) {
painter.line_segment([c0, c1], Stroke::new(s.width * 1.4, c));
}
}
"close" => {
// A code-drawn close cross: no bundled UI font has U+2715 in every family.
painter.line_segment([p(r, 0.3, 0.3), p(r, 0.7, 0.7)], s);
painter.line_segment([p(r, 0.7, 0.3), p(r, 0.3, 0.7)], s);
}
"lock" => {
painter.rect_filled(Rect::from_min_max(p(r, 0.22, 0.46), p(r, 0.78, 0.88)), r.width() * 0.08, c);
let (x, y, w) = (r.center().x, r.min.y + r.height() * 0.46, r.width() * 0.19);
+1 -1
View File
@@ -137,7 +137,7 @@ fn run_now(app: &mut SoundApp, params: Value) -> bool {
/// A small, non-blocking note while files import: the rest of the app stays usable.
pub fn show(app: &SoundApp, ctx: &egui::Context) {
let Some((name, waiting)) = app.imports.label() else { return };
egui::Area::new(egui::Id::new("audio_import_progress")).anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-16.0, -40.0)).interactable(false).show(
egui::Area::new(egui::Id::unique("audio_import_progress")).anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-16.0, -40.0)).interactable(false).show(
ctx,
|ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
+1668 -51
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -7,6 +7,7 @@ use soundcraft_engine::catalog;
/// UI-layer commands: (id, label, catalog path or "", shortcut).
pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
("app.checkForUpdates", "Check for Updates…", "", None),
("ui.dock", "Arrange Panel", "", None),
("ui.dock.reset", "Reset Panel Layout", "Window > Reset Panel Layout", None),
("window.mix", "Mix", "Window > Mix", Some("Cmd+=")),
@@ -232,6 +233,9 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::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;
}
@@ -519,6 +523,10 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
json!({"value": *v})
};
let r = match id {
"app.checkForUpdates" | "help.checkForUpdates" => {
crate::updates_ui::check(app);
json!({})
}
"window.mix" => {
app.ui.window = MainWindow::Mix;
app.focus_mix = app.ui.mix_separate;
+4 -3
View File
@@ -68,6 +68,7 @@ fn is_black(p: i32) -> bool {
matches!(p.rem_euclid(12), 1 | 3 | 6 | 8 | 10)
}
/// Draw the MIDI editor for the target clip, with an empty-state prompt that adds a clip on the selected MIDI track.
pub fn show(app: &mut SoundApp, ui: &mut Ui) {
// Painter-only contents do not advance the layout. Claim the resized
// space, including the empty state, so the dock keeps its chosen height.
@@ -90,7 +91,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
ui.painter().text(full.center(), Align2::CENTER_CENTER, msg, regular(13.0), t.text_dim);
let body = Rect::from_min_max(pos2(full.min.x, header.max.y), full.max);
if let Some((id, _)) = track
&& ui.interact(body, ui.id().with("new_clip"), Sense::click()).clicked()
&& ui.interact(body, ui.scope_id().with("new_clip"), Sense::click()).clicked()
{
let sel = app.engine.session().edit.selection;
if let Ok(v) = app.run("midi.new_clip", json!({"track": id.0, "start": sel.start, "end": sel.end})) {
@@ -234,7 +235,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
painter.rect(r, 2.0, fill, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside);
}
// Interaction.
let resp = ui.interact(roll, ui.id().with(("roll", cid.0)), Sense::click_and_drag());
let resp = ui.interact(roll, ui.scope_id().with(("roll", cid.0)), Sense::click_and_drag());
let hit = |pos: egui::Pos2| -> Option<(usize, bool)> {
sequence.notes.iter().enumerate().rev().find_map(|(i, n)| {
let r = Rect::from_min_max(
@@ -375,7 +376,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
// Velocity lane.
ui.painter().rect_filled(vel, 0.0, Color32::from_rgb(28, 28, 30));
ui.painter().text(pos2(full.min.x + 6.0, vel.center().y), Align2::LEFT_CENTER, "Velocity", regular(10.0), t.text_dim);
let vresp = ui.interact(vel, ui.id().with(("vel", cid.0)), Sense::click_and_drag());
let vresp = ui.interact(vel, ui.scope_id().with(("vel", cid.0)), Sense::click_and_drag());
for (i, n) in sequence.notes.iter().enumerate() {
let x = x_of(n.start);
let h = (vel.height() - 4.0) * f32::from(n.velocity) / 127.0;
+145 -57
View File
@@ -3,8 +3,8 @@
use crate::SoundApp;
use crate::theme::{Tokens, bold, regular, rgb};
use crate::widgets::{
PannerEdit, PannerSpeaker, db_text, fader, fader_slot, groove, inset, meter, meter_scale, multi_meter, pan_knob, pan_text, raised_key,
rec_toggle, selector_box, selector_box_with_tooltip, shade, surround_panner, text_toggle, vgradient,
PannerEdit, PannerSpeaker, SelectorLabels, db_text, fader, fader_slot, groove, inset, meter, meter_scale, multi_meter, pan_knob, pan_text,
raised_key, rec_toggle, selector_box_named, shade, surround_panner, text_toggle, vgradient,
};
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json;
@@ -43,10 +43,13 @@ pub fn separate_closed(app: &mut SoundApp) {
app.ui.window = crate::MainWindow::Mix;
}
/// Draw the horizontal Mix window strips for visible non-video tracks, scroll one requested strip into view and open
/// New Tracks when the space past the last strip is double-clicked.
pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) {
let ids: Vec<TrackId> = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect();
// Per-channel peaks for multichannel meters (read once per frame).
let snap = app.player.as_ref().filter(|_| app.meters_live()).map(|p| p.meters());
// Per-channel peaks for multichannel meters, from the snapshot `logic` takes from the player
// (taking them here too would split each period's peaks between two readers).
let snap = (app.player.is_some() && app.meters_live()).then(|| app.meter_snapshot.clone());
// Taken for this frame only: a strip that isn't drawn (hidden track) must not scroll the Mix
// whenever it comes back. A request made during this frame (the strip's menu) waits for the next.
let scroll_to = app.mix_scroll_into_view.take();
@@ -57,7 +60,7 @@ pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) {
strip(app, ui, id, snap.as_ref(), scroll_to == Some(id));
}
let rest = Rect::from_min_max(pos2(ui.cursor().min.x, ui.clip_rect().min.y), ui.clip_rect().max);
if rest.width() > 4.0 && ui.interact(rest, ui.id().with("past_strips"), Sense::click()).double_clicked() {
if rest.width() > 4.0 && ui.interact(rest, ui.scope_id().with("past_strips"), Sense::click()).double_clicked() {
let _ = app.dialogs.open_for_command(&app.engine, "track.new");
}
});
@@ -90,7 +93,7 @@ fn panner_speakers(fmt: ChannelFormat) -> Vec<PannerSpeaker> {
/// Ballistic per-channel meter levels for multichannel strips, kept in egui memory.
fn channel_levels(ui: &Ui, key: (&str, u64), peaks: &[f32], n: usize) -> Vec<f32> {
let id = egui::Id::new(key);
let id = egui::Id::unique(key);
let dt = ui.input(|i| i.stable_dt).clamp(0.0, 0.25);
let fall = 10f32.powf(-26.0 * dt / 20.0);
let mut lv: Vec<f32> = ui.data(|d| d.get_temp(id)).unwrap_or_default();
@@ -152,6 +155,8 @@ pub(crate) fn route_popup(app: &mut SoundApp, resp: &egui::Response, id: TrackId
egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, input));
}
/// Draw one mixer channel strip: preamp, instrument, object, insert and send sections, EQ curve, routing, fader and
/// meters, with its interactions.
fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapshot>, scroll_here: bool) {
let t = Tokens::current();
let session = app.engine.session_arc();
@@ -188,7 +193,9 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let kr = Rect::from_min_size(pos2(x0 + 6.0, sec.min.y + 15.0), vec2(inner_w - 12.0, 16.0));
let mut db = track.mixer.trim_db;
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(kr));
let resp = c.add_sized(kr.size(), egui::DragValue::new(&mut db).range(-24.0..=24.0).speed(0.1).suffix(" dB").max_decimals(1));
let resp = c
.add_sized(kr.size(), egui::DragValue::new(&mut db).range(-24.0..=24.0).speed(0.1).suffix(" dB").max_decimals(1))
.accessible_name("Input trim (dB)");
if resp.changed() {
let _ = app.engine.execute_merged("mix.trim", &json!({"tracks": [id.0], "db": db}), &format!("trim:{}", id.0));
}
@@ -282,7 +289,11 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let mut c = track.comments.clone();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(sec));
let hint = if narrow { "" } else { "comments" };
if child.add(egui::TextEdit::multiline(&mut c).desired_width(inner_w).desired_rows(2).hint_text(hint).font(regular(10.0))).changed() {
let field = crate::widgets::named_text_field(
child.add(egui::TextEdit::multiline(&mut c).desired_width(inner_w).desired_rows(2).hint_text(hint).font(regular(10.0))),
&format!("Comments for {}", track.name),
);
if field.changed() {
let _ = app.engine.execute_merged("track.comments", &json!({"track": id.0, "comments": c}), &format!("comments:{}", id.0));
}
y = sec.max.y + 4.0;
@@ -294,12 +305,26 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let in_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0), vec2(inner_w - 4.0, 16.0));
let out_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 33.0), vec2(inner_w - 4.0, 16.0));
let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-input", id.0)).max_rect(in_r));
let resp = selector_box(&mut c, in_r.width(), in_r.height(), &input_name(app, track), t.text);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, "Input"));
let input = input_name(app, track);
let resp = selector_box_named(
&mut c,
in_r.width(),
in_r.height(),
&input,
t.text,
SelectorLabels { name: &format!("Input for {}", track.name), value: &input, tooltip: "Input" },
);
route_popup(app, &resp, id, true);
let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-output", id.0)).max_rect(out_r));
let resp = selector_box(&mut c, out_r.width(), out_r.height(), &route_name(app, &track.mixer.output), t.text);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, "Output"));
let output = route_name(app, &track.mixer.output);
let resp = selector_box_named(
&mut c,
out_r.width(),
out_r.height(),
&output,
t.text,
SelectorLabels { name: &format!("Output for {}", track.name), value: &output, tooltip: "Output" },
);
route_popup(app, &resp, id, false);
// Automation mode.
let am_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 51.0), vec2(inner_w - 4.0, 16.0));
@@ -321,7 +346,14 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
} else {
&full_label
};
let resp = selector_box_with_tooltip(&mut c, am_r.width(), am_r.height(), label, col, &full_label);
let resp = selector_box_named(
&mut c,
am_r.width(),
am_r.height(),
label,
col,
SelectorLabels { name: &format!("Automation mode for {}", track.name), value: &full_label, tooltip: &full_label },
);
egui::Popup::menu(&resp).show(|ui| {
for m in soundcraft_model::AutomationMode::ALL {
if ui.selectable_label(m == mode, m.label()).clicked() {
@@ -336,7 +368,14 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let gname = app.engine.session().groups.iter().find(|g| g.members.contains(&id)).map_or(no_group.to_string(), |g| g.name.clone());
let gr = Rect::from_min_size(pos2(x0 + 2.0, y), vec2(inner_w - 4.0, 16.0));
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(gr));
let _ = selector_box(&mut c, gr.width(), gr.height(), &gname, t.text_dim);
let _ = selector_box_named(
&mut c,
gr.width(),
gr.height(),
&gname,
t.text_dim,
SelectorLabels { name: &format!("Group for {}", track.name), value: &gname, tooltip: "Group" },
);
y += 22.0;
// Pan: a surround panner when the output is multichannel, else the stereo knobs.
let out_fmt = output_format(app, track);
@@ -378,8 +417,18 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let _ = resp;
let pr = Rect::from_center_size(pos2(cx, y + 42.0), vec2(ks + 8.0, 13.0));
inset(ui.painter(), pr, t.counter_bg);
let (_, entered) =
editable_readout(ui, pr, ui.id().with(("pan-value", id.0, i)), &pan_text(v), &format!("{:.0}", v * 100.0), parse_pan, 0.0);
let (_, entered) = editable_readout(
ui,
pr,
ui.scope_id().with(("pan-value", id.0, i)),
Readout {
name: &format!("{} pan {}", track.name, i + 1),
display: &pan_text(v),
edit_initial: &format!("{:.0}", v * 100.0),
parse: parse_pan,
reset: 0.0,
},
);
if let Some(v) = entered {
let _ = app.engine.execute_merged("mix.pan", &json!({"track": id.0, "pan": v, "index": i}), &format!("pan:{}:{i}", id.0));
}
@@ -393,10 +442,12 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
);
row.spacing_mut().item_spacing.x = 6.0;
if track.kind.has_playlist() {
if text_toggle(&mut row, vec2(bw, 18.0), "I", track.mixer.input_monitor, t.input, "Input monitoring").clicked() {
let input_label = format!("Input monitoring for {}", track.name);
if text_toggle(&mut row, vec2(bw, 18.0), "I", track.mixer.input_monitor, t.input, &input_label).clicked() {
let _ = app.run("mix.input_monitor", json!({"track": id.0}));
}
if rec_toggle(&mut row, vec2(bw, 18.0), track.mixer.record_arm, "Record enable").clicked() {
let record_label = format!("Record enable for {}", track.name);
if rec_toggle(&mut row, vec2(bw, 18.0), track.mixer.record_arm, &record_label).clicked() {
let _ = app.run("mix.record_arm", json!({"track": id.0}));
}
}
@@ -406,10 +457,12 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
);
row.spacing_mut().item_spacing.x = 6.0;
if track.kind != TrackKind::Master {
if text_toggle(&mut row, vec2(bw, 18.0), "S", track.mixer.solo, t.solo, "Solo").clicked() {
let solo_label = format!("Solo for {}", track.name);
if text_toggle(&mut row, vec2(bw, 18.0), "S", track.mixer.solo, t.solo, &solo_label).clicked() {
let _ = app.run("mix.solo", json!({"track": id.0}));
}
if text_toggle(&mut row, vec2(bw, 18.0), "M", track.mixer.mute, t.mute, "Mute").clicked() {
let mute_label = format!("Mute for {}", track.name);
if text_toggle(&mut row, vec2(bw, 18.0), "M", track.mixer.mute, t.mute, &mute_label).clicked() {
let _ = app.run("mix.mute", json!({"track": id.0}));
}
}
@@ -419,7 +472,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let fh = (r.max.y - bottom_h - y).max(120.0);
let wide_meter = (if track.kind == TrackKind::Master { app.engine.session().main_format() } else { track.format }).channels() > 2;
let fr = Rect::from_min_size(pos2(x0, y), vec2(inner_w * if wide_meter { 0.45 } else { 0.6 }, fh));
if let Some(db) = fader(ui, fr, track.mixer.volume_db, ui.id().with(("fader", id.0))) {
if let Some(db) = fader(ui, fr, track.mixer.volume_db, ui.scope_id().with(("fader", id.0)), &format!("{} level", track.name)) {
app.gesture = Some(crate::Gesture::Fader { track: id, start_db: track.mixer.volume_db });
let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &format!("fader:{}", id.0));
}
@@ -454,11 +507,14 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let (_, entered) = editable_readout(
ui,
vr,
ui.id().with(("volume-value", id.0)),
&db_text(track.mixer.volume_db),
&format!("{:.1}", track.mixer.volume_db),
parse_db,
0.0,
ui.scope_id().with(("volume-value", id.0)),
Readout {
name: &format!("{} volume", track.name),
display: &db_text(track.mixer.volume_db),
edit_initial: &format!("{:.1}", track.mixer.volume_db),
parse: parse_db,
reset: 0.0,
},
);
if let Some(db) = entered {
let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &format!("fader:{}", id.0));
@@ -483,7 +539,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
StrokeKind::Inside,
);
ui.painter().with_clip_rect(nr).text(nr.center(), Align2::CENTER_CENTER, &track.name, bold(12.0), t.text_dark);
let nresp = ui.interact(nr, ui.id().with(("strip_name", id.0)), Sense::click());
let nresp = ui.interact(nr, ui.scope_id().with(("strip_name", id.0)), Sense::click());
if nresp.clicked() {
crate::edit_window::click_track(app, id, ui.input(|i| i.modifiers), |_, t| !t.hidden && t.kind != TrackKind::Video);
}
@@ -505,8 +561,8 @@ pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r:
let (id, slot) = (track.id, soundcraft_mix::INSTRUMENT_SLOT);
let ins = track.instrument.as_ref();
let info = ins.and_then(|i| plugin_info(&i.plugin));
let resp = ui.interact(r, ui.id().with(("instrument", id.0)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Instrument"));
let resp = ui.interact(r, ui.scope_id().with(("instrument", id.0)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Instrument for {}", track.name)));
match info {
Some(info) => used_slot(ui.painter(), r, t.slot_face, Mark::Dot, info.short_name, t.text, None),
None => empty_slot(ui.painter(), r, resp.hovered(), Mark::Dot),
@@ -592,12 +648,13 @@ fn level_icon(r: Rect) -> Rect {
Rect::from_min_max(pos2(outer.max.x - 9.0, outer.min.y + 2.0), pos2(outer.max.x - 4.0, outer.max.y - 2.0))
}
/// Draw one insert slot. Clicking a filled slot opens its plugin window; Cmd/Ctrl-click toggles bypass.
pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) {
let t = Tokens::current();
let id = track.id;
let ins = track.mixer.inserts.get(slot).cloned().flatten();
let resp = ui.interact(r, ui.id().with(("ins", id.0, slot)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Insert {}", (b'A' + slot as u8) as char)));
let resp = ui.interact(r, ui.scope_id().with(("ins", id.0, slot)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Insert {} for {}", (b'A' + slot as u8) as char, track.name)));
let info = ins.as_ref().and_then(|i| plugin_info(&i.plugin));
let name = ins.as_ref().map(|i| info.map_or(i.plugin.as_str(), |p| p.short_name)).unwrap_or_default();
match &ins {
@@ -626,7 +683,7 @@ pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot:
/// An insert's menu: a search field that takes typing at once, then (until something is typed)
/// Bypass and removal and the plugins by category and format.
pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, occupied: bool) {
let search = MenuSearch::show(ui, egui::Id::new(("plugin-search", id.0, slot)), "Search plugins…");
let search = MenuSearch::show(ui, egui::Id::unique(("plugin-search", id.0, slot)), "Search plugins…");
if search.typing() {
if let Some(plugin) = plugin_search(ui, &search, false) {
pick_insert(app, ui, id, slot, &plugin);
@@ -667,7 +724,7 @@ pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, oc
/// instruments.
pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
let slot = soundcraft_mix::INSTRUMENT_SLOT;
let search = MenuSearch::show(ui, egui::Id::new(("instrument-search", id.0)), "Search instruments…");
let search = MenuSearch::show(ui, egui::Id::unique(("instrument-search", id.0)), "Search instruments…");
if search.typing() {
if let Some(plugin) = plugin_search(ui, &search, true) {
run_pick(app, ui, "mix.instrument", json!({"track": id.0, "plugin": plugin}));
@@ -925,6 +982,7 @@ pub fn plugin_info(id: &str) -> Option<&'static soundcraft_dsp::PluginInfo> {
.or_else(|| soundcraft_au_host::plugin_info(id))
}
/// Draw one send slot: the route area picks a destination, while the level area opens or edits the send level.
pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) {
let t = Tokens::current();
let id = track.id;
@@ -934,8 +992,9 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us
// destination. A short track's square has no icon, so all of it is the level.
let level_r = if compact { r } else { level_icon(r).expand2(vec2(3.0, 0.0)).with_min_y(r.min.y).with_max_y(r.max.y) };
let route_r = Rect::from_min_max(r.min, pos2((level_r.min.x - 1.0).max(r.min.x), r.max.y));
let route_resp = ui.interact(if snd.is_some() { route_r } else { r }, ui.id().with(("snd-route", id.0, slot)), Sense::click());
route_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Send {}", (b'A' + slot as u8) as char)));
let route_resp = ui.interact(if snd.is_some() { route_r } else { r }, ui.scope_id().with(("snd-route", id.0, slot)), Sense::click());
route_resp
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Send {} for {}", (b'A' + slot as u8) as char, track.name)));
let mark = if compact { Mark::Arrow } else { Mark::Letter((b'a' + slot as u8) as char) };
match &snd {
Some(s) => {
@@ -945,8 +1004,8 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us
used_slot(ui.painter(), r, t.slot_face, mark, &name, color, Some(level));
let which = (b'A' + slot as u8) as char;
let shown = app.ui.send_window == Some((id, slot));
let level_resp = ui.interact(level_r, ui.id().with(("send-value", id.0, slot)), Sense::click_and_drag());
level_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Send {which} level")));
let level_resp = ui.interact(level_r, ui.scope_id().with(("send-value", id.0, slot)), Sense::click_and_drag());
level_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Send {which} level for {}", track.name)));
if shown {
ui.painter().rect_stroke(level_r.shrink(0.5), 1.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
}
@@ -1004,7 +1063,7 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us
pub(crate) fn send_window(app: &mut SoundApp, ctx: &egui::Context) {
// egui closes menus and drops text focus on Escape before this runs, so whether this Escape
// was theirs comes from the last frame. A dialog or the search on top takes it too.
let busy_id = egui::Id::new("send_window_escape");
let busy_id = egui::Id::unique("send_window_escape");
let busy = ctx.data(|d| d.get_temp(busy_id)).unwrap_or(false) || app.dialogs.open.is_some() || app.ui.show_search;
if !busy && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.send_window = None;
@@ -1014,6 +1073,7 @@ pub(crate) fn send_window(app: &mut SoundApp, ctx: &egui::Context) {
ctx.data_mut(|d| d.insert_temp(busy_id, busy));
}
/// Draw the floating send window for the remembered track and slot, or close it when that send no longer exists.
fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) {
let Some((id, slot)) = app.ui.send_window else { return };
let Some((name, send)) = app.engine.session().track(id).and_then(|t| Some((t.name.clone(), t.mixer.sends.get(slot).cloned().flatten()?))) else {
@@ -1023,7 +1083,7 @@ fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) {
let t = Tokens::current();
let mut open = true;
egui::Window::new(format!("{name} · Send {}", (b'A' + slot as u8) as char))
.id(egui::Id::new("send_window"))
.id(egui::Id::unique("send_window"))
.open(&mut open)
.auto_sized()
.collapsible(false)
@@ -1053,8 +1113,10 @@ fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) {
}
ui.label(egui::RichText::new(pan_text(send.pan)).color(t.counter_text).small());
let (fr, fresp) = ui.allocate_exact_size(vec2(56.0, 220.0), Sense::hover());
fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Send level"));
if let Some(db) = fader(ui, fr, send.level_db, ui.id().with("send_fader")) {
fresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, true, "Send level"));
// Distinct from the strip's "Send A level for <track>" icon and from the slot's "Send level", so a
// label query while the window is open still picks one node.
if let Some(db) = fader(ui, fr, send.level_db, ui.scope_id().with("send_fader"), "Send level fader") {
let _ = app.engine.execute_merged(
"mix.send_level",
&json!({"track": id.0, "slot": slot, "db": db}),
@@ -1064,7 +1126,18 @@ fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) {
let (rr, _) = ui.allocate_exact_size(vec2(64.0, 15.0), Sense::hover());
ui.painter().rect_filled(rr, 1.0, t.counter_bg);
let shown = db_text(send.level_db);
let (_, typed) = editable_readout(ui, rr, ui.id().with("send_level_value"), &shown, &format!("{:.1}", send.level_db), parse_db, 0.0);
let (_, typed) = editable_readout(
ui,
rr,
ui.scope_id().with("send_level_value"),
Readout {
name: &format!("{name} send level"),
display: &shown,
edit_initial: &format!("{:.1}", send.level_db),
parse: parse_db,
reset: 0.0,
},
);
if let Some(db) = typed {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db}));
}
@@ -1129,18 +1202,24 @@ fn dragged_send_db(db: f32, dy: f32, fine: bool) -> f32 {
soundcraft_model::fader_pos_to_db(soundcraft_model::fader_db_to_pos(db) - dy * per_point)
}
/// What an [`editable_readout`] shows and how a typed value is parsed.
struct Readout<'a> {
/// Accessible name of the text field shown while editing.
name: &'a str,
/// The value painted while not editing.
display: &'a str,
/// The editor's initial text.
edit_initial: &'a str,
parse: fn(&str) -> Option<f32>,
/// The value an Alt-click sets.
reset: f32,
}
/// Paint a value until clicked, then replace it with a focused text field. Enter or clicking away
/// commits a valid value; Escape cancels, and so does the field losing focus while hidden.
/// Alt-click returns `reset`. The interaction response remains available for dragging.
fn editable_readout(
ui: &mut Ui,
r: Rect,
id: egui::Id,
display: &str,
edit_initial: &str,
parse: fn(&str) -> Option<f32>,
reset: f32,
) -> (Option<egui::Response>, Option<f32>) {
fn editable_readout(ui: &mut Ui, r: Rect, id: egui::Id, readout: Readout<'_>) -> (Option<egui::Response>, Option<f32>) {
let Readout { name, display, edit_initial, parse, reset } = readout;
let t = Tokens::current();
let active_id = id.with("active");
let text_id = id.with("text");
@@ -1170,10 +1249,10 @@ fn editable_readout(
let edit = egui::TextEdit::singleline(&mut text)
.id(id.with("editor"))
.frame(egui::Frame::NONE)
.horizontal_align(egui::Align::Center)
.align(egui::Align2::CENTER_TOP)
.font(regular(9.0))
.desired_width(r.width());
let edit_resp = child.add_sized(r.size(), edit);
let edit_resp = crate::widgets::named_text_field(child.add_sized(r.size(), edit), name);
if started {
edit_resp.request_focus();
}
@@ -1241,9 +1320,10 @@ fn send_level_scale(ui: &mut Ui, db: f32) {
ui.painter().line_segment([pos2(x, r.min.y), pos2(x, line_y + 3.0)], Stroke::new(2.0, t.counter_text));
}
/// Searchable bus destination list with a New Bus action for a send.
fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, current: Option<&Route>) {
let busses = app.engine.session().busses.clone();
let search = MenuSearch::show(ui, egui::Id::new(("send-bus-search", id.0, slot)), "Search buses…");
let search = MenuSearch::show(ui, egui::Id::unique(("send-bus-search", id.0, slot)), "Search buses…");
let visible: Vec<_> = busses.iter().filter(|bus| search.matches(&bus.name)).collect();
let picked = search.rows(ui, visible.len(), 260.0, |ui, i, highlighted| {
let current = matches!(current, Some(Route::Bus(bus_id)) if *bus_id == visible[i].id);
@@ -1268,6 +1348,7 @@ fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, cu
}
}
/// Input or output menu: no route and buses on both, plus the main output (outputs) or inputs 1-8 (inputs).
fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
let key = if input { "input" } else { "output" };
let cmd = if input { "track.input" } else { "track.output" };
@@ -1291,7 +1372,7 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
ui.separator();
let busses: Vec<String> = app.engine.session().busses.iter().map(|b| b.name.clone()).collect();
ui.strong("Bus");
let search = MenuSearch::show(ui, egui::Id::new(("route-bus-search", id.0, input)), "Search buses…");
let search = MenuSearch::show(ui, egui::Id::unique(("route-bus-search", id.0, input)), "Search buses…");
let visible: Vec<&String> = busses.iter().filter(|b| search.matches(b)).collect();
if let Some(i) = search.rows(ui, visible.len(), 420.0, |ui, i, highlighted| menu_row(ui, highlighted, visible[i])) {
let _ = app.run(cmd, json!({"track": id.0, key: visible[i]}));
@@ -1309,7 +1390,7 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
#[cfg(test)]
mod tests {
use super::{dragged_send_db, editable_readout, parse_db, parse_pan};
use super::{Readout, dragged_send_db, editable_readout, parse_db, parse_pan};
use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2};
#[test]
@@ -1341,6 +1422,8 @@ mod tests {
assert_eq!(dragged_send_db(12.0, -50.0, false), 12.0);
}
/// Alt-click resets a readout; a click opens its text field, and hiding the widget drops focus so the formatted
/// display returns.
#[test]
fn readouts_reset_on_alt_click_and_drop_a_field_that_lost_focus() {
let ctx = egui::Context::default();
@@ -1355,7 +1438,12 @@ mod tests {
let mut out = (false, None);
let mut full = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| {
if shown {
let (resp, value) = editable_readout(ui, r, egui::Id::new("readout"), "-3.0", "-3.0", parse_db, 0.0);
let (resp, value) = editable_readout(
ui,
r,
egui::Id::unique("readout"),
Readout { name: "Readout", display: "-3.0", edit_initial: "-3.0", parse: parse_db, reset: 0.0 },
);
out = (resp.is_some(), value);
}
});
+12 -6
View File
@@ -88,6 +88,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
rtp(app, ctx);
}
/// Beat Detective window: sensitivity, separate and set-tempo options, Analyze and Apply results, and Identify Beat.
fn beat_detective(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_beat_detective;
if !open {
@@ -114,7 +115,7 @@ fn beat_detective(app: &mut SoundApp, ctx: &egui::Context) {
ui.separator();
ui.horizontal(|ui| {
ui.label("Identify Beat: selection is");
ui.add(egui::DragValue::new(&mut app.ops.identify_bars).range(1..=64));
ui.add(egui::DragValue::new(&mut app.ops.identify_bars).range(1..=64)).accessible_name("Identify beat bars");
ui.label("bar(s)");
if ui.button("Set Tempo").clicked() {
let r = app.run("event.identify_beat", json!({"bars": app.ops.identify_bars}));
@@ -144,6 +145,8 @@ pub(crate) fn tempo_ops_apply(curve: &str, start_bpm: f64, end_bpm: f64) -> (&'s
}
}
/// Tempo Operations window: choose a curve and start/end tempo, then dispatch its command using the edit selection.
/// Constant sends only the start BPM; without a nonempty range, its command defaults to sample zero.
fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_tempo_ops;
if !open {
@@ -155,8 +158,8 @@ fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) {
ui.selectable_value(&mut app.ops.tempo_curve, c.to_string(), c);
}
});
ui.add(egui::DragValue::new(&mut app.ops.tempo_start).range(5.0..=999.0).prefix("start bpm "));
ui.add(egui::DragValue::new(&mut app.ops.tempo_end).range(5.0..=999.0).prefix("end bpm "));
ui.add(egui::DragValue::new(&mut app.ops.tempo_start).range(5.0..=999.0).prefix("start bpm ")).accessible_name("Start tempo (BPM)");
ui.add(egui::DragValue::new(&mut app.ops.tempo_end).range(5.0..=999.0).prefix("end bpm ")).accessible_name("End tempo (BPM)");
ui.label(
egui::RichText::new(
"Applies across the edit selection (constant: at the insertion point; scale: factor = end / start; stretch: factor = start / end).",
@@ -172,6 +175,8 @@ fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) {
app.ui.show_tempo_ops = open;
}
/// Time Operations window: insert a duration in seconds at the selection start, cut the edit selection, change the
/// meter, or move the song start to the selection start.
fn time_ops(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_time_ops;
if !open {
@@ -186,17 +191,18 @@ fn time_ops(app: &mut SoundApp, ctx: &egui::Context) {
match app.ops.time_op.as_str() {
"change meter" => {
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(&mut app.ops.meter_num).range(1..=64));
ui.add(egui::DragValue::new(&mut app.ops.meter_num).range(1..=64)).accessible_name("Meter numerator");
ui.label("/");
egui::ComboBox::from_id_salt("den").selected_text(app.ops.meter_den.to_string()).show_ui(ui, |ui| {
let den = egui::ComboBox::from_id_salt("den").selected_text(app.ops.meter_den.to_string()).show_ui(ui, |ui| {
for d in [2u32, 4, 8, 16] {
ui.selectable_value(&mut app.ops.meter_den, d, d.to_string());
}
});
den.response.accessible_name("Meter denominator");
});
}
"insert" => {
ui.add(egui::DragValue::new(&mut app.ops.time_seconds).range(0.0..=3600.0).suffix(" s"));
ui.add(egui::DragValue::new(&mut app.ops.time_seconds).range(0.0..=3600.0).suffix(" s")).accessible_name("Time (seconds)");
}
_ => {
ui.label(egui::RichText::new("Uses the edit selection.").small());
+54 -28
View File
@@ -23,7 +23,7 @@ fn drag_track(app: &mut SoundApp, ui: &Ui, row: Rect, id: u64, dropped: bool) {
let y = row.min.y + (gap - from as f32) * pitch - spacing / 2.0;
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
ui.ctx()
.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("track_list_reorder")))
.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("track_list_reorder")))
.line_segment([pos2(row.min.x, y), pos2(row.max.x, y)], Stroke::new(2.0, Tokens::current().accent));
}
}
@@ -40,6 +40,7 @@ fn panel_header(ui: &mut Ui, title: &str) {
/// the panel and the window beside it.
const GUTTER_W: f32 = 10.0;
/// Tracks and Groups panels: headers, track and group lists, and their scrollbars in the panel gutter.
pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let full = ui.max_rect();
@@ -61,17 +62,19 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
s.tracks.iter().map(|x| (x.id.0, x.name.clone(), !x.hidden, x.color, s.edit.selected_tracks.contains(&x.id))).collect()
};
let hidden = egui::scroll_area::ScrollBarVisibility::AlwaysHidden;
let lists =
egui::ScrollArea::vertical().id_salt("tracks_scroll").max_height(total * 0.62).auto_shrink([false, false]).scroll_bar_visibility(hidden);
let lists = with_pending_offset(egui::ScrollArea::vertical().id_salt("tracks_scroll"), ui, "tracks_scroll")
.max_height(total * 0.62)
.auto_shrink([false, false])
.scroll_bar_visibility(hidden);
let out = lists.show(ui, |ui| {
for (id, name, shown, color, sel) in tracks {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, &name));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, sel, &name));
if sel {
ui.painter().rect_filled(r, 0.0, t.row_selected);
}
let dot = Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(8.0, 8.0));
let dresp = ui.interact(dot.expand(3.0), ui.id().with(("vis", id)), Sense::click());
let dresp = ui.interact(dot.expand(3.0), ui.scope_id().with(("vis", id)), Sense::click());
ui.painter().circle(dot.center(), 3.5, if shown { t.visible_dot } else { Color32::TRANSPARENT }, Stroke::new(1.0, t.text_dim));
if dresp.clicked() {
let _ = app.run("track.hide", json!({"tracks": [id], "hidden": shown}));
@@ -97,11 +100,12 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
panel_header(ui, "GROUPS");
let groups: Vec<(u64, char, String, bool, [u8; 3])> =
app.engine.session().groups.iter().map(|g| (g.id.0, g.letter, g.name.clone(), g.active, g.color)).collect();
let out = egui::ScrollArea::vertical().id_salt("groups_scroll").auto_shrink([false, false]).scroll_bar_visibility(hidden).show(ui, |ui| {
let groups_area = with_pending_offset(egui::ScrollArea::vertical().id_salt("groups_scroll"), ui, "groups_scroll");
let out = groups_area.auto_shrink([false, false]).scroll_bar_visibility(hidden).show(ui, |ui| {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::hover());
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, "<ALL>", regular(11.5).clone(), t.text);
let all = ui.interact(r, ui.id().with("group_all"), Sense::click());
all.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "<ALL>"));
let all = ui.interact(r, ui.scope_id().with("group_all"), Sense::click());
all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "<ALL>"));
if all.double_clicked() {
let _ = app.run("edit.select_all", json!({}));
}
@@ -113,7 +117,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
});
for (id, letter, name, active, color) in groups {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, active, &name));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, active, &name));
if active {
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 4.0, r.min.y + 2.0), vec2(14.0, 13.0)), 1.0, rgb(color));
}
@@ -156,8 +160,21 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
gutter_scroll(ui, gutter, &out);
}
/// Apply a pending gutter page/drag target to the next frame's `ScrollArea`. `State::offset` is
/// private in egui 0.37, so the target cannot be written directly; it is applied here before layout.
fn with_pending_offset(area: egui::ScrollArea, ui: &Ui, salt: &str) -> egui::ScrollArea {
// Match `ScrollArea` exactly: it hashes an `IdSalt` into the persistent id.
let id = ui.make_persistent_id(egui::IdSalt::new(salt));
let pending = ui.ctx().memory_mut(|m| m.data.remove_temp::<f32>(id.with("gutter_target")));
match pending {
Some(y) => area.vertical_scroll_offset(y),
None => area,
}
}
/// A list's scroll thumb in the gutter beside it, when the list is longer than its space: drag it,
/// or click above or below it to page.
/// or click above or below it to page. The target applies on the next frame, because the public
/// `ScrollArea` API has no way to write the current pass's offset.
fn gutter_scroll<R>(ui: &Ui, gutter: Rect, out: &egui::scroll_area::ScrollAreaOutput<R>) {
let t = Tokens::current();
let (view, content) = (out.inner_rect.height(), out.content_size.y);
@@ -166,12 +183,12 @@ fn gutter_scroll<R>(ui: &Ui, gutter: Rect, out: &egui::scroll_area::ScrollAreaOu
}
let bar = Rect::from_x_y_ranges(gutter.x_range(), out.inner_rect.y_range());
let (max, h) = (content - view, (bar.height() * view / content).max(20.0));
let mut state = out.state;
let thumb = Rect::from_min_size(pos2(bar.min.x + 2.0, bar.min.y + (bar.height() - h) * state.offset.y / max), vec2(bar.width() - 4.0, h));
let offset = out.state.clamped_offset().y;
let thumb = Rect::from_min_size(pos2(bar.min.x + 2.0, bar.min.y + (bar.height() - h) * offset / max), vec2(bar.width() - 4.0, h));
let resp = ui.interact(bar, out.id.with("gutter"), Sense::click_and_drag());
ui.painter().rect_filled(thumb, 3.0, if resp.hovered() || resp.dragged() { t.button_hi.lerp_to_gamma(Color32::WHITE, 0.1) } else { t.button_hi });
let to = if resp.dragged() {
Some(state.offset.y + resp.drag_delta().y * max / (bar.height() - h).max(1.0))
Some(offset + resp.drag_delta().y * max / (bar.height() - h).max(1.0))
} else if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.clicked()) {
let page = if p.y < thumb.min.y {
-view
@@ -180,13 +197,13 @@ fn gutter_scroll<R>(ui: &Ui, gutter: Rect, out: &egui::scroll_area::ScrollAreaOu
} else {
0.0
};
Some(state.offset.y + page)
Some(offset + page)
} else {
None
};
if let Some(y) = to {
state.offset.y = y.clamp(0.0, max);
state.store(ui.ctx(), out.id);
ui.ctx().memory_mut(|m| m.data.insert_temp(out.id.with("gutter_target"), y.clamp(0.0, max)));
ui.ctx().request_repaint();
}
}
@@ -218,15 +235,16 @@ fn clip_rows(s: &soundcraft_model::Session, needle: &str) -> Vec<ClipRow> {
sources.chain(clips).collect()
}
/// Clips panel with a filter and a revision-cached list of audio sources and clips.
pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
panel_header(ui, "CLIPS");
let fkey = egui::Id::new("clip_filter");
let fkey = egui::Id::unique("clip_filter");
let mut filter: String = ui.ctx().memory(|m| m.data.get_temp(fkey)).unwrap_or_default();
ui.add(egui::TextEdit::singleline(&mut filter).hint_text("🔍 Find clips").desired_width(f32::INFINITY));
ui.ctx().memory_mut(|m| m.data.insert_temp(fkey, filter.clone()));
let needle = filter.to_lowercase();
let rkey = egui::Id::new("clip_rows");
let rkey = egui::Id::unique("clip_rows");
let revision = app.engine.revision;
let cached = ui.ctx().memory(|m| m.data.get_temp::<ClipRows>(rkey)).filter(|c| c.revision == revision && c.filter == needle);
let rows = match cached {
@@ -238,7 +256,8 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
}
};
let row_h = 17.0;
egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, shown| {
let clips_area = with_pending_offset(egui::ScrollArea::vertical().id_salt("clips_scroll"), ui, "clips_scroll");
clips_area.auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, shown| {
// Only the rows in view: what they show is read now, before any of them acts on the session.
let items: Vec<(u64, String, bool, [u8; 3], bool)> = {
let s = app.engine.session();
@@ -426,13 +445,14 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) {
app.ui.show_big_counter = open;
}
/// One recallable memory-location row: number button, editable name, time, length, kind and delete.
fn memory_location_row(app: &mut SoundApp, ui: &mut Ui, n: u32, name: &str, loc: &str, len: &str, kind: &str) {
if ui.button(n.to_string()).on_hover_text("Recall").clicked() {
let _ = app.run("markers.recall", json!({"number": n}));
}
let key = egui::Id::new(("mem_name", n));
let key = egui::Id::unique(("mem_name", n));
let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.to_string());
let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0));
let r = crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0)), "Name");
if r.changed() {
ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone()));
}
@@ -443,7 +463,7 @@ fn memory_location_row(app: &mut SoundApp, ui: &mut Ui, n: u32, name: &str, loc:
ui.label(egui::RichText::new(loc).font(mono(11.0)));
ui.label(egui::RichText::new(len).font(mono(11.0)));
ui.label(kind);
if ui.small_button("✕").on_hover_text("Delete").clicked() {
if crate::widgets::close_button(ui, "Delete marker").clicked() {
let _ = app.run("markers.delete", json!({"number": n}));
}
ui.end_row();
@@ -504,6 +524,7 @@ pub(crate) fn undo_history(app: &mut SoundApp, ui: &mut Ui) {
});
}
/// Draw plugin windows for instrument and insert slots that still exist, removing closed ones from the list.
fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
let wins = app.ui.plugin_windows.clone();
let mut keep = Vec::new();
@@ -520,7 +541,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
let place = if instrument { "instrument".to_string() } else { char::from(b'a'.saturating_add(slot as u8)).to_string() };
let mut open = true;
let shown = egui::Window::new(format!("{tname} · {place} · {}", info.name))
.id(egui::Id::new(("plugin", tid.0, slot)))
.id(egui::Id::unique(("plugin", tid.0, slot)))
.open(&mut open)
.default_width(340.0)
.default_pos(egui::pos2(ctx.content_rect().width() - 380.0, 120.0))
@@ -533,7 +554,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
if ui.button(label).clicked() {
let _ = app.run("mix.insert_bypass", json!({"track": tid.0, "slot": slot}));
}
ui.menu_button("Presets ▾", |ui| preset_menu(app, ui, tid, slot, info, &ins));
ui.menu_button("Presets v", |ui| preset_menu(app, ui, tid, slot, info, &ins));
}
if soundcraft_mix::is_third_party(&ins.plugin) {
let open = app.player.as_ref().is_some_and(|p| p.editor_open(tid, slot));
@@ -569,7 +590,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
egui::Grid::new(("pg", tid.0, slot)).num_columns(3).show(ui, |ui| {
for p in info.params {
let v = ins.params.get(p.id).copied().unwrap_or(p.default);
ui.label(p.name);
let label = ui.label(p.name);
let mut x = v;
let resp = if !p.choices.is_empty() {
let mut idx = x.round().max(0.0) as usize;
@@ -592,6 +613,9 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
}
ui.add(sl)
};
// The grid cell's painted label is not an accesskit name; tie the
// combo box or slider to it so screen readers and label queries find it.
let resp = resp.labelled_by(label.id);
if (x - v).abs() > f32::EPSILON && instrument {
let _ = app.engine.execute_merged(
"mix.instrument_param",
@@ -658,12 +682,13 @@ fn eq_curve(ui: &mut Ui, id: &str, ins: &soundcraft_model::Insert) {
ui.painter().add(egui::Shape::line(pts, Stroke::new(2.0, t.counter_text)));
}
/// About window tabs for version information, contributors and models.
fn about(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_about;
if !open {
return;
}
let tab_id = egui::Id::new("about_tab");
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| {
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
ui.horizontal(|ui| {
@@ -710,6 +735,7 @@ fn preset_dir(app: &SoundApp, plugin: &str) -> Option<std::path::PathBuf> {
app.preset_dir.as_ref().map(|d| d.join(crate::preset_folder_name(plugin)))
}
/// Factory default plus user presets for a plugin insert; user presets need a desktop preset directory.
fn preset_menu(
app: &mut SoundApp,
ui: &mut Ui,
@@ -751,10 +777,10 @@ fn preset_menu(
}
}
ui.separator();
let key = egui::Id::new(("preset_name", tid.0, slot));
let key = egui::Id::unique(("preset_name", tid.0, slot));
let mut name: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| format!("{} preset", info.name));
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut name).desired_width(140.0));
crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(&mut name).desired_width(140.0)), "Preset name");
if ui.button("Save").clicked() {
let clean = soundcraft_engine::io::sanitize_name(&name);
let body = json!({"format": "soundcraft-plugin-preset", "plugin": info.id, "params": ins.params});
+10 -31
View File
@@ -56,7 +56,7 @@ pub fn drop_target(app: &mut SoundApp, ctx: &egui::Context, r: Rect, t: TrackId)
app.sample_drop_targets.push((r, t));
let over = ctx.input(|i| !i.raw.hovered_files.is_empty() && i.pointer.latest_pos().is_some_and(|p| r.contains(p)));
if over {
ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new(("sample_drop", t.0)))).rect_stroke(
ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique(("sample_drop", t.0)))).rect_stroke(
r,
2.0,
Stroke::new(2.0, Tokens::current().accent),
@@ -82,38 +82,16 @@ impl SampleRead {
}
impl SoundApp {
/// Takes this frame's dropped files: audio dropped on an instrument track or a Sampler window
/// loads into that track's sampler (the sampler becomes its instrument). A file with a full
/// path loads straight away; one without (the browser) waits in [`SoundApp::take_sample_reads`]
/// for the shell to read. Everything else is returned for the shell to open or import.
pub fn take_dropped_files(&mut self, ctx: &egui::Context) -> Vec<egui::DroppedFileHandle> {
let (files, pos) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.latest_pos()));
if files.is_empty() {
return files;
}
let Some(track) = self.drop_track(pos) else { return files };
let mut rest = Vec::new();
let mut claimed = false;
for f in files {
if claimed || !is_audio_name(&f.path().to_string_lossy()) {
rest.push(f);
continue;
}
claimed = true;
if f.path().is_absolute() {
let path = f.path().to_string_lossy().into_owned();
let r = self.run("mix.sampler_load", json!({"track": track.0, "path": path}));
self.sample_loaded(track, &path, r);
} else {
self.sample_reads.push(SampleRead { track, file: f });
}
}
rest
/// Dropped files this frame's UI pass did not load into a sampler, for the shell to open or
/// import. The capture happened at the start of the UI pass, before the font gate; this only
/// takes the queued result, so it is safe (and needed) on a logic-only tick after a paint.
pub fn take_dropped_files(&mut self) -> Vec<egui::DroppedFileHandle> {
std::mem::take(&mut self.host_drops)
}
/// The track a drop at `pos` loads into. Without a position (some platforms report none
/// during a drag), a single open Sampler window takes it.
fn drop_track(&self, pos: Option<egui::Pos2>) -> Option<TrackId> {
pub(crate) fn drop_track(&self, pos: Option<egui::Pos2>) -> Option<TrackId> {
if let Some(p) = pos {
return self.sample_drop_targets.iter().rev().find(|(r, _)| r.contains(p)).map(|(_, t)| *t);
}
@@ -140,7 +118,8 @@ impl SoundApp {
self.sample_loaded(read.track, &name, r);
}
fn sample_loaded(&mut self, track: TrackId, what: &str, r: Result<serde_json::Value, String>) {
/// Report a sampler load and open its instrument window on success, or show the failure status.
pub(crate) fn sample_loaded(&mut self, track: TrackId, what: &str, r: Result<serde_json::Value, String>) {
match r {
Ok(v) => {
if !self.ui.plugin_windows.contains(&(track, soundcraft_mix::INSTRUMENT_SLOT)) {
@@ -246,7 +225,7 @@ pub fn sample_strip(app: &mut SoundApp, ui: &mut Ui, tid: TrackId, ins: &Insert)
p.text(flag.center(), Align2::CENTER_CENTER, *tag, regular(9.0), t.text_dark);
}
// Dragging the nearest marker sets its parameter.
let grab_id = ui.id().with(("sample_marker", tid.0));
let grab_id = ui.scope_id().with(("sample_marker", tid.0));
let nearest = |x: f32| {
shown
.iter()
+10 -1
View File
@@ -33,6 +33,8 @@ fn diatonic(pitch: u8) -> (i32, bool) {
(oct * 7 + step, sharp)
}
/// Read-only score view for the target MIDI clip, drawing its notes as notation; notes are edited in the MIDI Editor
/// or Event List.
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_score;
if !open {
@@ -119,7 +121,14 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
p.add(Shape::ellipse_filled(head.center(), head.size() * 0.5, ink));
}
if sharp {
p.text(pos2(x - 12.0, y), Align2::CENTER_CENTER, "♯", regular(12.0), ink);
// An original drawn sharp sign: no bundled family covers U+266F.
let cx = x - 12.0;
for dx in [-2.6, 1.2] {
p.line_segment([pos2(cx + dx - 1.0, y - 5.0), pos2(cx + dx + 1.0, y + 5.0)], Stroke::new(1.1, ink));
}
for dy in [-1.8, 1.8] {
p.line_segment([pos2(cx - 5.0, y + dy), pos2(cx + 5.0, y + dy - 1.0)], Stroke::new(1.1, ink));
}
}
if q < 3.5 {
// Stems up below the middle line of each staff, down above it.
+18 -3
View File
@@ -54,17 +54,32 @@ fn search_chord(m: Modifiers, mac: bool) -> bool {
mac && m.command && m.ctrl
}
/// Dispatch global keyboard shortcuts once no text field or dialog wants input, ignoring non-scripted auto-repeats
/// while still running a host press alongside a scripted one.
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() {
return;
}
// A scripted press is identified by its exact position in this pass's events, not by matching
// key/modifier values. A fresh host press with the same chord must still run its own action in
// addition to the scripted one, while a host auto-repeat stays ignored: egui rewrites an
// event's `repeat` flag from the physical key state, which says nothing about whether that
// particular event was injected. Every non-scripted repeat is ignored as before.
let scripted = app.scripted_key_press_indices();
let events: Vec<(Key, Modifiers)> = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } => Some((*key, *modifiers)),
.enumerate()
.filter_map(|(index, e)| match e {
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } => {
if scripted.contains(&index) {
Some((*key, *modifiers))
} else {
(!*repeat).then_some((*key, *modifiers))
}
}
_ => None,
})
.collect()
@@ -241,7 +256,7 @@ fn edit_mode_key(app: &mut SoundApp, pressed: Key) {
let ed = &app.engine.session().edit;
let keep_grid = ed.edit_mode.is_grid() && ed.flag("options.edit_tool_mode_keyboard_lock");
// F4 pressed first in an F4+F1–F3 chord switched the Grid mode on its own; put that back.
let flipped = egui::Id::new("edit_mode_key_grid_before_f4");
let flipped = egui::Id::unique("edit_mode_key_grid_before_f4");
let before =
(ed.edit_mode.is_grid() && !keep_grid).then(|| if ed.flag(soundcraft_engine::edit::RELATIVE_GRID) { "relative_grid" } else { "grid" });
let restore = match &ctx {
+8 -5
View File
@@ -113,15 +113,17 @@ fn recorded_at(s: &Session, time_reference: u64) -> Samples {
i64::try_from(time_reference).unwrap_or(0).saturating_sub(s.timecode_start)
}
/// One editable Spot field: accept the entered position when it parses and lies in `-MAX_POSITION..=MAX_POSITION`,
/// or show the formatted value when read-only.
fn spot_field_row(ui: &mut Ui, s: &Session, d: &mut Spot, i: usize, label: &str) {
let show = |at: Samples, f: TimeFormat| s.format_position(at, f);
ui.label(label);
let lab = ui.label(label);
if d.editable(i) {
let id = Id::new(("spot_field", i));
let id = Id::unique(("spot_field", i));
if !ui.memory(|m| m.has_focus(id)) {
d.text[i] = show(d.value(i), d.format);
}
let r = ui.add(TextEdit::singleline(&mut d.text[i]).id(id).font(mono(13.0)).desired_width(150.0));
let r = ui.add(TextEdit::singleline(&mut d.text[i]).id(id).font(mono(13.0)).desired_width(150.0)).labelled_by(lab.id);
if r.changed()
&& let Ok(v) = s.parse_position(&d.text[i], d.format)
&& (-MAX_POSITION..=MAX_POSITION).contains(&v)
@@ -152,12 +154,13 @@ fn original_time_stamp_row(ui: &mut Ui, s: &Session, d: &mut Spot, at: Samples)
/// The dialog's fields; the caller adds OK and Cancel.
pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) {
egui::Grid::new("spot").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label("Time Scale");
egui::ComboBox::from_id_salt("spot_scale").selected_text(d.format.label()).show_ui(ui, |ui| {
let scale = ui.label("Time Scale");
let time_scale = egui::ComboBox::from_id_salt("spot_scale").selected_text(d.format.label()).show_ui(ui, |ui| {
for f in TimeFormat::ALL {
ui.selectable_value(&mut d.format, f, f.label());
}
});
time_scale.response.labelled_by(scale.id);
ui.end_row();
for (i, label) in FIELDS.into_iter().enumerate() {
if i == 1 && matches!(d.target, Target::Trim { .. }) {
+17 -10
View File
@@ -73,6 +73,7 @@ fn draw_mode_cell(p: &egui::Painter, cell: Rect, resp: &egui::Response, t: &Toke
}
}
/// The Shuffle, Spot, Slip and Grid mode selector with its keyboard hints and relative-grid state.
fn edit_modes(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let ed = &app.engine.session().edit;
@@ -92,10 +93,10 @@ fn edit_modes(app: &mut SoundApp, ui: &mut Ui) {
let ch = inner.height() / 2.0;
for (m, cx, cy, label, fkey) in modes {
let cell = Rect::from_min_size(pos2(inner.min.x + cw * cx as f32, inner.min.y + ch * cy as f32), vec2(cw, ch)).shrink(1.0);
let resp = ui.interact(cell, ui.id().with(("mode", label)), Sense::click());
let resp = ui.interact(cell, ui.scope_id().with(("mode", label)), Sense::click());
draw_mode_cell(ui.painter(), cell, &resp, &t, m, &state, label);
let on = state.cur == m || (m == EditMode::Grid && state.cur == EditMode::GridRelative);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, label));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, on, label));
if resp.clicked() {
mode_click(app, m, ui.input(|i| i.modifiers));
}
@@ -164,6 +165,7 @@ fn zoom_group(app: &mut SoundApp, ui: &mut Ui) {
});
}
/// The tool selector, including Smart-tool membership and Zoom Toggle.
fn tools(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let cur = app.engine.session().edit.tool;
@@ -195,7 +197,7 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) {
let bx0 = rr.min.x + 76.0;
let bx1 = bx0 + 38.0 * 3.0 - 2.0;
let bracket = Rect::from_min_max(pos2(bx0 - 2.0, r.min.y + 1.0), pos2(bx1 + 2.0, r.min.y + 5.0));
let resp = ui.interact(bracket.expand2(vec2(0.0, 2.0)), ui.id().with("smart"), Sense::click());
let resp = ui.interact(bracket.expand2(vec2(0.0, 2.0)), ui.scope_id().with("smart"), Sense::click());
ui.painter().rect_filled(bracket, 1.0, if cur == Tool::Smart { t.accent } else { t.tool_bracket_off });
if resp.on_hover_text("Smart Tool (F6+F7 / F7+F8)").clicked() {
let _ = app.run("edit.tool", json!({"tool": "smart"}));
@@ -230,6 +232,7 @@ fn fmt_pos(app: &SoundApp, at: i64, f: TimeFormat) -> String {
s.format_position(at, f)
}
/// Main and sub position counters, a main-counter format menu, selection readouts and dirty/device status.
fn counters(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let (main_fmt, sub_fmt, sel) = {
@@ -242,7 +245,7 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) {
let main_r = Rect::from_min_max(r.min, pos2(r.min.x + 180.0, r.min.y + 44.0));
let txt = fmt_pos(app, pos, main_fmt);
ui.painter().text(pos2(main_r.max.x - 18.0, main_r.center().y), Align2::RIGHT_CENTER, txt, mono(26.0), t.counter_text);
let resp = ui.interact(main_r, ui.id().with("main_counter"), Sense::click());
let resp = ui.interact(main_r, ui.scope_id().with("main_counter"), Sense::click());
icons::draw(ui.painter(), Rect::from_center_size(pos2(main_r.max.x - 9.0, main_r.center().y), vec2(10.0, 10.0)), "triangle_down", t.text_dim);
egui::Popup::menu(&resp).show(|ui| {
for f in TimeFormat::ALL {
@@ -273,6 +276,7 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) {
ui.painter().text(pos2(r.max.x - 8.0, y), Align2::RIGHT_CENTER, format!("{st} · {dev}"), regular(10.0), t.text_dim);
}
/// Grid-line toggle, grid value menu and nudge value menu.
fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let (grid, nudge, lines) = {
@@ -282,7 +286,7 @@ fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
let (r, _) = ui.allocate_exact_size(vec2(170.0, 64.0), Sense::hover());
ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, t.counter_border), StrokeKind::Inside);
let gl = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 8.0), vec2(48.0, 16.0));
let resp = ui.interact(gl, ui.id().with("grid_lines"), Sense::click());
let resp = ui.interact(gl, ui.scope_id().with("grid_lines"), Sense::click());
key(ui.painter(), gl, &resp, if lines { t.mode_on } else { t.counter_off });
ui.painter().text(gl.center(), Align2::CENTER_CENTER, "Grid", bold(11.0), if lines { t.mode_on_text } else { t.mode_off_text });
if resp.on_hover_text("Show grid lines").clicked() {
@@ -290,12 +294,12 @@ fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
}
let gr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 6.0), pos2(r.max.x - 4.0, r.min.y + 26.0));
ui.painter().text(pos2(gr.max.x - 14.0, gr.center().y), Align2::RIGHT_CENTER, grid.label(), mono(11.0), t.counter_text);
let gresp = ui.interact(gr, ui.id().with("grid_value"), Sense::click());
let gresp = ui.interact(gr, ui.scope_id().with("grid_value"), Sense::click());
value_menu(app, &gresp, "view.grid");
ui.painter().text(pos2(r.min.x + 10.0, r.min.y + 44.0), Align2::LEFT_CENTER, "Nudge", regular(11.0), t.counter_label);
let nr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 34.0), pos2(r.max.x - 4.0, r.min.y + 54.0));
ui.painter().text(pos2(nr.max.x - 14.0, nr.center().y), Align2::RIGHT_CENTER, nudge.label(), mono(11.0), t.counter_text);
let nresp = ui.interact(nr, ui.id().with("nudge_value"), Sense::click());
let nresp = ui.interact(nr, ui.scope_id().with("nudge_value"), Sense::click());
value_menu(app, &nresp, "view.nudge");
for rr in [gr, nr] {
icons::draw(ui.painter(), Rect::from_center_size(pos2(rr.max.x - 6.0, rr.center().y), vec2(10.0, 10.0)), "triangle_down", t.text_dim);
@@ -381,6 +385,8 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) {
});
}
/// Count-off, meter and tempo readouts; the tempo value is editable in place for the tempo event at or before the
/// playhead.
fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let (bpm, meter, countoff, click, bars) = {
@@ -392,7 +398,7 @@ fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) {
let (r, _) = ui.allocate_exact_size(vec2(150.0, 64.0), Sense::hover());
ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, t.counter_border), StrokeKind::Inside);
let co = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 6.0), vec2(64.0, 15.0));
let resp = ui.interact(co, ui.id().with("countoff"), Sense::click());
let resp = ui.interact(co, ui.scope_id().with("countoff"), Sense::click());
key(ui.painter(), co, &resp, if countoff { t.mode_on } else { t.counter_off });
ui.painter().text(co.center(), Align2::CENTER_CENTER, "Count Off", bold(10.0), if countoff { t.mode_on_text } else { t.mode_off_text });
if resp.clicked() {
@@ -406,7 +412,7 @@ fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) {
tempo_value(app, ui, r.max.x - 8.0, r.min.y + 48.0, bpm);
let clk = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 40.0), vec2(18.0, 18.0));
icons::draw(ui.painter(), clk, "click", if click { t.counter_text } else { t.text_dim });
if ui.interact(clk, ui.id().with("click"), Sense::click()).on_hover_text("Click").clicked() {
if ui.interact(clk, ui.scope_id().with("click"), Sense::click()).on_hover_text("Click").clicked() {
let _ = app.run("options.click", json!({}));
}
}
@@ -427,7 +433,8 @@ fn tempo_value(app: &mut SoundApp, ui: &mut Ui, right: f32, y: f32, bpm: f64) {
}
v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.counter_border);
let mut value = bpm;
let resp = c.add_sized(rect.size(), egui::DragValue::new(&mut value).speed(0.1).range(5.0..=1000.0).fixed_decimals(4));
let resp =
c.add_sized(rect.size(), egui::DragValue::new(&mut value).speed(0.1).range(5.0..=1000.0).fixed_decimals(4)).accessible_name("Tempo (BPM)");
if resp.changed() {
let s = app.engine.session();
let tick = s.tempo.samples_to_ticks(app.position(), s.sample_rate);
+122
View File
@@ -0,0 +1,122 @@
//! Software updates, background check at startup, and startup prompt modal.
use crate::SoundApp;
#[cfg(not(target_arch = "wasm32"))]
use craft_update::updater::{Action, Status, Updater};
pub(crate) struct Runtime {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater,
/// The desktop app turns the check at start on; tests and the web build leave it off.
#[cfg(not(target_arch = "wasm32"))]
startup_check: bool,
#[cfg(not(target_arch = "wasm32"))]
check_requested: bool,
pub(crate) manual_window_open: bool,
pub(crate) status_message: Option<String>,
}
impl Default for Runtime {
fn default() -> Self {
Self {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater::new(&craft_update::suite::SOUNDCRAFT, env!("CARGO_PKG_VERSION")),
#[cfg(not(target_arch = "wasm32"))]
startup_check: false,
#[cfg(not(target_arch = "wasm32"))]
check_requested: false,
manual_window_open: false,
status_message: None,
}
}
}
impl SoundApp {
/// Whether the app asks for a newer release when it starts (off until the desktop app turns it on).
#[cfg(not(target_arch = "wasm32"))]
pub fn set_startup_update_check(&mut self, on: bool) {
self.updates_rt.startup_check = on;
}
}
pub(crate) fn check(app: &mut SoundApp) {
#[cfg(not(target_arch = "wasm32"))]
{
app.updates_rt.check_requested = true;
}
#[cfg(target_arch = "wasm32")]
{
app.updates_rt.manual_window_open = true;
app.updates_rt.status_message = Some("Update check is not supported in the web browser.".into());
}
}
pub(crate) fn tick(app: &mut SoundApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
let rt = &mut app.updates_rt;
if std::mem::take(&mut rt.check_requested) {
rt.updater.check_now(ctx);
} else if rt.startup_check {
rt.updater.check_at_start(ctx);
}
rt.updater.tick(ctx);
}
#[cfg(target_arch = "wasm32")]
let _ = (app, ctx);
}
pub(crate) fn show(app: &mut SoundApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
// The restart helper is scheduled before the app closes, so no update starts over unsaved work or a take being recorded.
let clean = !app.has_unsaved_changes() && !app.engine.transport.recording;
match app.updates_rt.updater.show_if(ctx, clean) {
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
Action::None => {}
}
// The window answers Help ▸ Check for Updates, so it follows the updater's status.
let rt = &mut app.updates_rt;
(rt.manual_window_open, rt.status_message) = match rt.updater.status() {
Status::Idle => (false, None),
Status::Checking => (true, None),
Status::UpToDate => (true, Some(format!("SoundCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION")))),
Status::Failed(e) => (true, Some(format!("Could not check for updates:\n{e}"))),
};
}
if app.updates_rt.manual_window_open {
let mut open = true;
let mut close = false;
egui::Window::new("Check for Updates")
.open(&mut open)
.resizable(false)
.collapsible(false)
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
.show(ctx, |ui| {
if let Some(msg) = &app.updates_rt.status_message {
ui.label(msg);
} else {
ui.label("Checking for updates…");
}
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Close").clicked() {
close = true;
}
});
});
if !open || close {
app.updates_rt.manual_window_open = false;
app.updates_rt.status_message = None;
}
}
// Closing the window dismisses the outcome it showed.
#[cfg(not(target_arch = "wasm32"))]
if !app.updates_rt.manual_window_open {
app.updates_rt.updater.dismiss_status();
}
}
+14 -4
View File
@@ -4,10 +4,18 @@ use egui::{Color32, Mesh, Painter, Rect, Shape, Stroke, pos2, vec2};
use soundcraft_model::{Clip, Session, SourceAudio};
use soundcraft_time::Samples;
/// Where to draw: the painter, the session and the timeline rect that maps samples to x.
#[derive(Clone, Copy)]
pub struct Canvas<'a> {
pub painter: &'a Painter,
pub s: &'a Session,
pub tl: Rect,
}
/// Draw a clip's audio across `body`, one band per channel, `channels` of them at most.
pub fn draw(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &SourceAudio, channels: usize, body: Rect, wave: Color32) {
pub fn draw(canvas: Canvas<'_>, clip: &Clip, audio: &SourceAudio, channels: usize, body: Rect, wave: Color32) {
for (ch, band) in bands(body, channels.min(audio.peaks.len()).max(1)).enumerate() {
channel(painter, s, tl, clip, audio, ch, band, wave);
channel(canvas, clip, audio, ch, band, wave);
}
}
@@ -16,7 +24,8 @@ fn bands(body: Rect, n: usize) -> impl Iterator<Item = Rect> {
(0..n).map(move |k| Rect::from_min_size(pos2(body.min.x, body.min.y + h * k as f32), vec2(body.width(), h)))
}
fn channel(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &SourceAudio, ch: usize, band: Rect, wave: Color32) {
fn channel(canvas: Canvas<'_>, clip: &Clip, audio: &SourceAudio, ch: usize, band: Rect, wave: Color32) {
let Canvas { painter, s, tl } = canvas;
let (Some(samples), Some(peaks)) = (audio.channel(ch), audio.peaks.get(ch)) else { return };
let Some(g) = Grid::new(painter, s, tl, band) else { return };
let (rectified, power, outlines) = (s.edit.flag("waveform.rectified"), s.edit.flag("waveform.power"), s.edit.flag("waveform.outlines"));
@@ -59,7 +68,8 @@ fn channel(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &Source
/// Draw a take while it records across `body`, one band per channel: `blocks` holds each channel's
/// (min, max) per block of input frames, the blocks `block_len` timeline samples long from
/// timeline position `origin`.
pub fn draw_live(painter: &Painter, s: &Session, tl: Rect, origin: f64, block_len: f64, blocks: &[Vec<(f32, f32)>], body: Rect, wave: Color32) {
pub fn draw_live(canvas: Canvas<'_>, origin: f64, block_len: f64, blocks: &[Vec<(f32, f32)>], body: Rect, wave: Color32) {
let Canvas { painter, s, tl } = canvas;
let zoom = s.edit.zoom.waveform_zoom;
for (blocks, band) in blocks.iter().zip(bands(body, blocks.len())) {
let Some(g) = Grid::new(painter, s, tl, band) else { continue };
+97 -9
View File
@@ -101,6 +101,16 @@ fn button_style(selected_fill: Color32, normal_icon: Color32, radius: u8) -> Ico
}
}
/// Give a bare text field an accessible name without changing its visible layout.
///
/// `TextEdit` alone has a text-input node but no label; kittest 0.37 panics on a visible unnamed
/// input after a settled step. A hint or a real adjacent label is preferred where the design has
/// one; this covers fields whose name lives in the dialog title or a painted heading.
pub(crate) fn named_text_field(resp: egui::Response, name: &str) -> egui::Response {
let _ = resp.ctx.accesskit_node_builder(resp.id, |node| node.set_label(name));
resp
}
/// A toolbar button with an icon; `on` draws the blue selected state.
pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &str) -> Response {
let t = Tokens::current();
@@ -113,10 +123,32 @@ pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &st
resp.on_hover_text(tip)
}
/// A compact close/delete button whose cross is painted, not typeset.
///
/// U+2715 is missing from egui 0.37's bundled proportional face (and from the shared fallback set
/// until the symbol font lands), so this control must not depend on a text glyph. `label` is both
/// the accessible name and the tooltip.
pub fn close_button(ui: &mut Ui, label: &str) -> Response {
let t = Tokens::current();
let size = Vec2::splat(ui.spacing().interact_size.y.max(14.0));
let style = IconButtonStyle {
normal: ButtonVisuals { fill: Color32::TRANSPARENT, stroke: Stroke::NONE, icon: t.text_dim },
hovered: ButtonVisuals { fill: t.button_hi, stroke: Stroke::new(1.0, t.button_border), icon: t.text },
pressed: Some(ButtonVisuals { fill: shade(t.button_hi, -0.1), stroke: Stroke::new(1.0, t.button_border), icon: t.text }),
selected: ButtonVisuals { fill: t.button, stroke: Stroke::new(1.0, t.button_border), icon: t.text },
corner_radius: CornerRadius::same(3),
focus_stroke: Stroke::new(1.0, t.accent),
};
IconButton::new(label, size, &style)
.show(ui, |ui, r, tint| icons::draw(ui.painter(), r.shrink(size.x * 0.28), "close", tint))
.on_hover_text(label)
}
/// Square text toggle used for S / M / I / record etc.
pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color: Color32, tip: &str) -> Response {
let t = Tokens::current();
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), on, tip));
key(ui.painter(), r, &resp, if on { on_color } else { key_fill(&resp) });
let col = if on { t.text_dark } else { t.text };
ui.painter().text(r.center(), Align2::CENTER_CENTER, text, bold((size.y * 0.62).clamp(8.0, 13.0)), col);
@@ -153,8 +185,29 @@ pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Col
/// A selector with a compact visible label and the complete meaning on hover.
pub(crate) fn selector_box_with_tooltip(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32, tooltip: &str) -> Response {
selector_box_named(ui, width, height, text, color, SelectorLabels { name: tooltip, value: text, tooltip })
}
/// The accessibility strings for a custom selector: its unique name, current value and hover text.
#[derive(Clone, Copy)]
pub(crate) struct SelectorLabels<'a> {
pub name: &'a str,
pub value: &'a str,
pub tooltip: &'a str,
}
/// A selector whose accessible name is unique (for example per track), with its current value
/// published separately in the accessibility tree.
pub(crate) fn selector_box_named(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32, labels: SelectorLabels<'_>) -> Response {
let t = Tokens::current();
let (r, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click());
// The compact visible text can be an abbreviation ("t/l"); the full meaning on hover is the
// accessible name, not the tooltip. `current_text_value` tells a screen reader the selection.
resp.widget_info(|| {
let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, ui.is_enabled(), labels.name);
info.current_text_value = Some(labels.value.to_string());
info
});
key(ui.painter(), r, &resp, key_fill(&resp));
let tr = Rect::from_min_max(pos2(r.max.x - height * 0.8, r.min.y), r.max);
icons::draw(ui.painter(), tr.shrink(height * 0.15), "triangle_down", t.text_dim);
@@ -166,13 +219,14 @@ pub(crate) fn selector_box_with_tooltip(ui: &mut Ui, width: f32, height: f32, te
let galley = ui.painter().layout_job(job);
let pos = text_rect.center() - galley.size() * 0.5;
ui.painter().with_clip_rect(r.shrink(1.0)).galley(pos, galley, color);
resp.on_hover_text(tooltip)
resp.on_hover_text(labels.tooltip)
}
/// Rotary knob for pan / send pan (-1..1). Drag vertically; double-click resets.
pub fn pan_knob(ui: &mut Ui, size: f32, value: &mut f32, tip: &str) -> Response {
let t = Tokens::current();
let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(*value), tip));
if resp.dragged() {
let d = resp.drag_delta();
*value = (*value + (d.x - d.y) * 0.01).clamp(-1.0, 1.0);
@@ -318,10 +372,12 @@ pub fn fader_slot(r: Rect) -> Rect {
Rect::from_center_size(r.center(), vec2(4.0, r.height() - FADER_CAP_H))
}
/// A fader: returns the new dB value when dragged. `db` in -144..12.
pub fn fader(ui: &mut Ui, r: Rect, db: f32, id: egui::Id) -> Option<f32> {
/// A fader: returns the new dB value when dragged. `db` in -144..12. `name` is its accessible
/// name; it must be unique enough that a label query picks one strip's control.
pub fn fader(ui: &mut Ui, r: Rect, db: f32, id: egui::Id, name: &str) -> Option<f32> {
let t = Tokens::current();
let resp = ui.interact(r, id, Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(db), name));
let p = ui.painter();
let slot = fader_slot(r);
let cap_w = (r.width() * 0.4).clamp(16.0, 18.0);
@@ -515,7 +571,7 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: impl Into<egui::WidgetText>)
/// released. Returns whether `on` changed.
fn drag_toggle(ui: &Ui, resp: &Response, on: &mut bool) -> bool {
let ctx = ui.ctx();
let key = egui::Id::new("soundcraft-checkbox-drag-target");
let key = egui::Id::unique("soundcraft-checkbox-drag-target");
if !ctx.input(|i| i.pointer.primary_down()) {
ctx.data_mut(|d| d.remove::<bool>(key));
}
@@ -564,6 +620,38 @@ mod tests {
(h, at)
}
/// Close/delete controls are painted crosses with an accessible name, not a glyph that depends
/// on a font family covering U+2715.
#[test]
fn a_close_button_is_a_named_clickable_icon() {
let mut h = Harness::new_ui_state(
|ui, clicked: &mut bool| {
if super::close_button(ui, "Delete marker").clicked() {
*clicked = true;
}
},
false,
);
h.run();
h.get_by_role_and_label(egui::accesskit::Role::Button, "Delete marker").click();
h.run();
assert!(*h.state(), "the painted close control dispatches its command");
}
/// The compact visible label of a selector is an abbreviation; the full meaning it shows on
/// hover is what a screen reader must hear.
#[test]
fn a_compact_selector_is_named_by_its_full_meaning() {
let mut h = Harness::new_ui(|ui| {
super::selector_box_with_tooltip(ui, 80.0, 16.0, "t/l", egui::Color32::WHITE, "auto touch latch");
});
h.run();
let selector =
h.query_by_role_and_label(egui::accesskit::Role::ComboBox, "auto touch latch").expect("the full automation mode names the selector");
assert_eq!(selector.value().as_deref(), Some("t/l"), "the visible value stays available to a screen reader");
assert!(h.query_by_role_and_label(egui::accesskit::Role::ComboBox, "t/l").is_none(), "the abbreviation is not the name");
}
/// #399: pressing a box and dragging over its neighbours sets them all to the state the first
/// one switched to; the box past the drag stays as it was.
#[test]
@@ -753,6 +841,8 @@ mod selector_visual_regressions {
}
}
/// Write real rendered mixer fixtures when `CRAFT_UI_VISUAL_FIXTURES` is set, covering automation modes, narrow
/// widths and display scales.
#[test]
fn capture_actual_mixer_visual_fixtures() {
let _palette = crate::theme::lock_test_palette();
@@ -783,14 +873,12 @@ mod selector_visual_regressions {
.output()
.shapes
.iter()
// The strip background is a gradient mesh, not a RectShape.
.filter_map(|shape| match &shape.shape {
egui::Shape::Rect(rect)
if (rect.rect.width() - crate::mix_window::strip_width(narrow)).abs() < 0.1 && rect.rect.height() >= 560.0 =>
{
Some(rect.rect)
}
egui::Shape::Mesh(mesh) => Some(mesh.calc_bounds()),
_ => None,
})
.filter(|rect| (rect.width() - crate::mix_window::strip_width(narrow)).abs() < 0.1 && rect.height() >= 560.0)
.fold(Rect::NOTHING, Rect::union);
assert!(strips.is_finite());
std::fs::write(
+30 -20
View File
@@ -230,6 +230,8 @@ fn metadata(app: &mut SoundApp, ctx: &egui::Context) {
app.ui.show_metadata = open;
}
/// MIDI Event List for the target clip: velocity is editable, start/pitch/length are read-only, and notes can be
/// deleted.
fn event_list(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_event_list;
win(ctx, &mut open, "MIDI Event List", vec2(420.0, 360.0), |ui| {
@@ -251,11 +253,11 @@ fn event_list(app: &mut SoundApp, ctx: &egui::Context) {
ui.label(egui::RichText::new(n.start.to_string()).font(mono(11.0)));
ui.label(soundcraft_midi::note_name(n.pitch));
let mut v = i64::from(n.velocity);
if ui.add(egui::DragValue::new(&mut v).range(1..=127)).changed() {
if ui.add(egui::DragValue::new(&mut v).range(1..=127)).accessible_name("Note velocity").changed() {
let _ = app.engine.execute("midi.note_edit", &json!({"clip": cid.0, "index": i, "velocity": v}));
}
ui.label(n.length.to_string());
if ui.small_button("✕").clicked() {
if crate::widgets::close_button(ui, "Delete note").clicked() {
let _ = app.run("midi.note_delete", json!({"clip": cid.0, "indices": [i]}));
}
ui.end_row();
@@ -373,6 +375,8 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
app.ui.show_workspace = open;
}
/// Window Configurations window: capture the current UI state and restore a saved configuration after validating both
/// dock layouts.
fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_configurations;
win(ctx, &mut open, "Window Configurations", vec2(320.0, 240.0), |ui| {
@@ -401,7 +405,7 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
app.ui = st;
}
}
if ui.small_button("✕").clicked() {
if crate::widgets::close_button(ui, "Remove configuration").clicked() {
app.ui.configurations.remove(i);
}
});
@@ -413,7 +417,7 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
/// The audio systems as last enumerated (enumerating probes devices: done when the window opens
/// and on Refresh, not every frame).
fn audio_systems(ctx: &egui::Context, refresh: bool) -> Arc<Vec<AudioSystem>> {
let id = egui::Id::new("audio_systems");
let id = egui::Id::unique("audio_systems");
if !refresh && let Some(s) = ctx.data(|d| d.get_temp::<Arc<Vec<AudioSystem>>>(id)) {
return s;
}
@@ -436,25 +440,29 @@ fn chosen_device<'a>(devices: &'a [DeviceInfo], name: Option<&str>) -> Option<&'
name.and_then(|n| devices.iter().find(|d| d.name == n)).or_else(|| devices.iter().find(|d| d.is_default))
}
fn device_combo(ui: &mut egui::Ui, id: &str, value: &mut Option<String>, devices: &[DeviceInfo]) {
/// Device picker with a Default entry and channel counts, labelled for accessibility.
fn device_combo(ui: &mut egui::Ui, id: &str, name: &str, value: &mut Option<String>, devices: &[DeviceInfo]) {
let default = devices.iter().find(|d| d.is_default).map_or("none".to_string(), |d| d.name.clone());
let shown = value.clone().unwrap_or_else(|| format!("Default ({default})"));
egui::ComboBox::from_id_salt(id).width(300.0).selected_text(shown).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt(id).width(300.0).selected_text(shown).show_ui(ui, |ui| {
ui.selectable_value(value, None, format!("Default ({default})"));
for d in devices {
ui.selectable_value(value, Some(d.name.clone()), format!("{} ({} ch)", d.name, d.max_channels));
}
});
combo.response.accessible_name(name);
}
fn number_combo(ui: &mut egui::Ui, id: &str, value: &mut Option<u32>, options: &[u32], unit: &str) {
/// Optional-number picker with a Device default entry and a unit, labelled for accessibility.
fn number_combo(ui: &mut egui::Ui, id: &str, name: &str, value: &mut Option<u32>, options: &[u32], unit: &str) {
let shown = value.map_or("Device default".to_string(), |v| format!("{v} {unit}"));
egui::ComboBox::from_id_salt(id).width(300.0).selected_text(shown).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt(id).width(300.0).selected_text(shown).show_ui(ui, |ui| {
ui.selectable_value(value, None, "Device default");
for v in options {
ui.selectable_value(value, Some(*v), format!("{v} {unit}"));
}
});
combo.response.accessible_name(name);
}
/// Setup › Playback Engine / Hardware: choose the audio system, devices, rate and buffer size.
@@ -463,7 +471,7 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) {
if !open {
return;
}
let draft_id = egui::Id::new("audio_draft");
let draft_id = egui::Id::unique("audio_draft");
let mut draft = ctx.data(|d| d.get_temp::<AudioConfig>(draft_id)).unwrap_or_else(|| app.ui.audio.clone());
let mut refresh = false;
let mut apply = false;
@@ -471,10 +479,10 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) {
win(ctx, &mut open, "Playback Engine", vec2(460.0, 360.0), |ui| {
let t = Tokens::current();
egui::Grid::new("pe").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
ui.label("Audio system");
let system_label = ui.label("Audio system");
let def = systems.iter().find(|s| s.is_default).map_or("none".to_string(), |s| s.name.clone());
let shown = draft.system.clone().unwrap_or_else(|| format!("Default ({def})"));
egui::ComboBox::from_id_salt("pe-system").width(300.0).selected_text(shown).show_ui(ui, |ui| {
let system = egui::ComboBox::from_id_salt("pe-system").width(300.0).selected_text(shown).show_ui(ui, |ui| {
let before = draft.system.clone();
ui.selectable_value(&mut draft.system, None, format!("Default ({def})"));
for s in systems.iter() {
@@ -486,21 +494,22 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) {
draft.input = None;
}
});
system.response.labelled_by(system_label.id);
ui.end_row();
let sys = chosen_system(&systems, &draft);
ui.label("Output device");
device_combo(ui, "pe-out", &mut draft.output, sys.map_or(&[][..], |s| &s.outputs));
device_combo(ui, "pe-out", "Output device", &mut draft.output, sys.map_or(&[][..], |s| &s.outputs));
ui.end_row();
ui.label("Input device");
device_combo(ui, "pe-in", &mut draft.input, sys.map_or(&[][..], |s| &s.inputs));
device_combo(ui, "pe-in", "Input device", &mut draft.input, sys.map_or(&[][..], |s| &s.inputs));
ui.end_row();
let out = sys.and_then(|s| chosen_device(&s.outputs, draft.output.as_deref()));
ui.label("Sample rate");
number_combo(ui, "pe-rate", &mut draft.sample_rate, out.map_or(&[][..], |d| &d.sample_rates), "Hz");
number_combo(ui, "pe-rate", "Sample rate", &mut draft.sample_rate, out.map_or(&[][..], |d| &d.sample_rates), "Hz");
ui.end_row();
ui.label("Buffer size");
let buffers = out.map(DeviceInfo::buffer_sizes).unwrap_or_else(|| soundcraft_playback::devices::STANDARD_BUFFERS.to_vec());
number_combo(ui, "pe-buffer", &mut draft.buffer_size, &buffers, "samples");
number_combo(ui, "pe-buffer", "Buffer size", &mut draft.buffer_size, &buffers, "samples");
ui.end_row();
});
ui.add_space(4.0);
@@ -564,7 +573,7 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) {
// Closing discards unapplied edits and the device list (re-read on the next open).
ctx.data_mut(|d| {
d.remove_temp::<AudioConfig>(draft_id);
d.remove_temp::<Arc<Vec<AudioSystem>>>(egui::Id::new("audio_systems"));
d.remove_temp::<Arc<Vec<AudioSystem>>>(egui::Id::unique("audio_systems"));
});
}
app.ui.show_playback_engine = open;
@@ -613,9 +622,9 @@ pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) {
let busses: Vec<(String, String)> = app.engine.session().busses.iter().map(|b| (b.name.clone(), b.format.label().to_string())).collect();
egui::Grid::new("busses").num_columns(3).striped(true).show(ui, |ui| {
for (name, fmt) in busses {
let key = egui::Id::new(("bus_name", name.clone()));
let key = egui::Id::unique(("bus_name", name.clone()));
let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone());
let r = ui.text_edit_singleline(&mut edit);
let r = crate::widgets::named_text_field(ui.text_edit_singleline(&mut edit), &format!("Bus name: {name}"));
ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone()));
if r.lost_focus() && edit != name {
let _ = app.run("setup.io", json!({"action": "rename_bus", "name": name, "new_name": edit}));
@@ -636,14 +645,15 @@ pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) {
// Main output format: stereo, or a surround format (the mix and master faders follow).
let main = app.engine.session().main_format();
ui.horizontal(|ui| {
ui.label("Main output");
egui::ComboBox::from_id_salt("main_output_format").selected_text(main.label()).show_ui(ui, |ui| {
let main_label = ui.label("Main output");
let format = egui::ComboBox::from_id_salt("main_output_format").selected_text(main.label()).show_ui(ui, |ui| {
for f in soundcraft_model::ChannelFormat::ALL.into_iter().filter(|f| (2..=16).contains(&f.channels())) {
if ui.selectable_label(f == main, f.label()).clicked() && f != main {
let _ = app.run("setup.main_format", json!({"format": f.label()}));
}
}
});
format.response.labelled_by(main_label.id);
ui.label(egui::RichText::new(format!("{} ch", main.channels())).small());
});
for o in app.engine.session().outputs.clone() {
+13 -8
View File
@@ -4,12 +4,14 @@
use egui::{Event, Modifiers, PointerButton, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use soundcraft_ui_egui::{Services, SoundApp};
/// One UI frame, with queued synthetic pointer input applied first (as the control channel does).
fn step(h: &mut Harness<'_, SoundApp>) {
let mut raw = std::mem::take(h.input_mut());
h.state_mut().raw_input_hook(&mut raw);
let ctx = h.ctx.clone();
h.state_mut().raw_input_hook(&ctx, &mut raw);
*h.input_mut() = raw;
h.step();
}
@@ -27,6 +29,8 @@ fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize {
s.tracks.iter().find(|t| t.name == "Kick").map_or(0, |t| t.clips().len())
}
/// Moving the pointer from a right-clicked clip onto the menu's Delete item keeps the menu open; clicking Delete
/// removes one Kick clip.
#[test]
fn clip_menu_stays_open_while_moving_onto_delete() {
let engine = soundcraft_engine::demo::demo_engine();
@@ -50,14 +54,15 @@ fn clip_menu_stays_open_while_moving_onto_delete() {
for _ in 0..10 {
step(&mut h);
}
// Move (no click) into the menu, as a real pointer would, so the lane loses its hover.
for (x, y) in [(900.0, 300.0), (907.0, 442.0)] {
h.state_mut().synthetic.push(Event::PointerMoved(pos2(x, y)));
for _ in 0..10 {
step(&mut h);
}
// Move (no click) onto the menu's Delete item, as a real pointer would, so the lane loses
// its hover. Ask the newly laid-out menu where the item is instead of pinning coordinates
// that change with egui's item metrics.
let delete = h.get_by_label("Delete").rect().center();
h.state_mut().synthetic.push(Event::PointerMoved(delete));
for _ in 0..10 {
step(&mut h);
}
press(&mut h, 907.0, 442.0, PointerButton::Primary);
press(&mut h, delete.x, delete.y, PointerButton::Primary);
for _ in 0..15 {
step(&mut h);
}
+74 -5
View File
@@ -34,6 +34,9 @@ fn drag(h: &mut Harness<'_, SoundApp>, from: egui::Pos2, to: egui::Pos2) {
h.run_steps(4);
}
/// A real panel drag, float, close and reopen keeps the recorded floating rectangle through a UI-state reload;
/// the Trim tool and record-arm controls still work, and a floating edit panel does not cover the mix strip used
/// after switching windows.
#[test]
fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() {
let mut h = harness(None, 1400.0, 1.0);
@@ -60,10 +63,12 @@ fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() {
assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), expected);
reloaded.state_mut().run("ui.dock", json!({"operation":"move","panel":"clips","target":"tracks","zone":"center"})).unwrap();
reloaded.run_steps(4);
// The narrow Tracks frame shows the active Clips tab and lists Tracks under "…".
reloaded.get_by_label("Panels").click();
reloaded.run_steps(3);
reloaded.get_by_label("Tracks and Groups").click();
// craft-ui 650b squeezes both tabs into the narrow Tracks frame, so the inactive Tracks tab
// is visibly shown (only shown tabs get AccessKit nodes) instead of moving under a "Panels"
// overflow. Activate it through that visible tab with a real pointer click.
let tracks_tab = reloaded.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").rect();
assert!(tracks_tab.width() > 0.0 && tracks_tab.height() > 0.0, "the Tracks tab is visibly laid out: {tracks_tab:?}");
reloaded.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").click();
reloaded.run_steps(3);
assert_eq!(reloaded.state().ui.docking.layout.panel_state(&Panel::Tracks), craft_ui::docking::PanelState::Front);
reloaded.get_by_label("Trim (F6)").click();
@@ -77,7 +82,7 @@ fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() {
reloaded.run_steps(4);
assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), edit);
assert!(!reloaded.state().ui.mix_docking.layout.contains(&Panel::Clips));
let record = reloaded.get_by_label("Record enable");
let record = reloaded.get_by_label_contains("Record enable");
// A real pointer click must remain outside the floating panel's occluding rectangle.
assert!(!egui::Rect::from_min_size(egui::pos2(900.0, 70.0), vec2(220.0, 420.0)).contains(record.rect().center()));
record.click();
@@ -283,3 +288,67 @@ fn a_panel_without_a_recorded_place_opens_beside_the_session_view() {
assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(Zone::Left)));
assert!(h.state().ui.show_tracks_list);
}
/// A hidden panel whose saved anchor has disappeared reopens beside the session view instead of failing to restore.
#[test]
fn a_recorded_place_whose_anchor_is_gone_opens_beside_the_session_view() {
use craft_ui::docking::{Action, Permissions, Placement, Zone};
let allow = |_: &Panel| Permissions::default();
let mut state = UiState::default();
// A stale saved location: its recorded anchor closed without leaving a place to follow.
state.docking.apply(Action::Close { panel: Panel::Clips }, allow).unwrap();
state.docking.apply(Action::Close { panel: Panel::Tracks }, allow).unwrap();
state.docking.hidden.retain(|(panel, _)| *panel != Panel::Tracks);
let location = &mut state.docking.hidden.iter_mut().find(|(panel, _)| *panel == Panel::Clips).unwrap().1;
location.anchor = Some(Panel::Tracks);
location.placement = Placement::Split(Zone::Left);
location.sibling.clear();
location.floating = None;
soundcraft_ui_egui::docking::validate(&state.docking).unwrap();
state.show_clip_list = false;
state.show_tracks_list = false;
assert!(!state.docking.layout.contains(&Panel::Tracks));
let mut h = harness(Some(state), 1400.0, 1.0);
assert!(!h.state().ui.docking.layout.contains(&Panel::Tracks), "app sync keeps the stale anchor absent");
h.state_mut().run("ui.dock", json!({"operation":"open","panel":"clips"})).unwrap();
h.run_steps(4);
let location = h.state().ui.docking.layout.location(&Panel::Clips).unwrap();
assert_eq!((location.anchor, location.placement), (Some(Panel::Canvas), Placement::Split(Zone::Right)));
assert!(h.state().ui.show_clip_list);
}
/// Panels hidden one by one reopen into their original floating window, rebuilding the hidden-anchor chain in the
/// process.
#[test]
fn panels_hidden_one_by_one_reopen_into_their_recorded_window() {
use craft_ui::docking::{Action, Node, Permissions};
let allow = |_: &Panel| Permissions::default();
let mut state = UiState::default();
state.docking.layout.floating.retain(|window| !window.contains(&Panel::History));
let window = state.docking.layout.floating.iter_mut().find(|window| window.contains(&Panel::Memory)).unwrap();
let rect = window.rect;
window.root = Node::Tabs { panels: vec![Panel::Memory, Panel::History], active: 0 };
state.docking.apply(Action::Close { panel: Panel::Memory }, allow).unwrap();
state.docking.apply(Action::Close { panel: Panel::History }, allow).unwrap();
let own = &mut state.docking.hidden.iter_mut().find(|(panel, _)| *panel == Panel::Memory).unwrap().1;
assert_eq!(own.anchor, Some(Panel::History));
// Only the outer hidden anchor retains a floating fallback, so the chain walk is required.
own.floating = None;
let own = own.clone();
assert_eq!(state.docking.layout.clone().restore(Panel::Memory, &own), Err(craft_ui::docking::DockError::MissingTarget));
state.show_memory_locations = false;
state.show_undo_history = false;
let mut h = harness(Some(state), 1400.0, 1.0);
h.state_mut().run("ui.dock", json!({"operation":"open","panel":"memory"})).unwrap();
h.run_steps(4);
let hidden_history = &h.state().ui.docking.hidden.iter().find(|(panel, _)| *panel == Panel::History).unwrap().1;
assert_eq!(hidden_history.anchor, Some(Panel::Memory), "the restored chain reverses its hidden neighbour's anchor");
h.state_mut().run("ui.dock", json!({"operation":"open","panel":"history"})).unwrap();
h.run_steps(4);
let layout = &h.state().ui.docking.layout;
let window = layout.floating.iter().find(|window| window.contains(&Panel::Memory)).expect("Memory reopens in its window");
assert!(window.contains(&Panel::History), "the hidden chain reopens History in Memory's window: {window:?}");
assert_eq!(window.rect, rect);
assert!(h.state().ui.show_memory_locations && h.state().ui.show_undo_history);
soundcraft_ui_egui::docking::validate(&h.state().ui.docking).unwrap();
}
@@ -8,9 +8,11 @@ use soundcraft_ui_egui::{MainWindow, Services, SoundApp};
const CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
/// Feed one harness frame through the raw-input hook before stepping, so scripted input reaches the app.
fn step(h: &mut Harness<'_, SoundApp>) {
let mut raw = std::mem::take(h.input_mut());
h.state_mut().raw_input_hook(&mut raw);
let ctx = h.ctx.clone();
h.state_mut().raw_input_hook(&ctx, &mut raw);
*h.input_mut() = raw;
h.step();
}
@@ -84,6 +86,8 @@ fn header_keys_freeze_silence_and_show_automation() {
assert_eq!(h.state().ui.window, MainWindow::Mix);
}
/// From the Edit window's insert column, clicking a plugin opens it, Cmd-click bypasses it, right-click changes or
/// removes it through the menu and an empty slot adds a plugin.
#[test]
fn edit_window_insert_slots_open_change_and_add_plugins() {
let (mut h, kick, row) = harness("Kick", "medium", "view.edit_inserts_ae");
@@ -104,7 +108,7 @@ fn edit_window_insert_slots_open_change_and_add_plugins() {
let plugin = soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap();
click(&mut h, slot(2));
let category = h.get_by_label(&format!("{:?} ⏵", plugin.category)).rect().center();
let category = h.get_by_label(&format!("{:?}", plugin.category)).rect().center();
h.state_mut().synthetic.push(Event::PointerMoved(category));
for _ in 0..10 {
step(&mut h);
@@ -114,6 +118,7 @@ fn edit_window_insert_slots_open_change_and_add_plugins() {
assert_eq!(insert(&h, 2).as_deref(), Some(plugin.id));
}
/// Short tracks draw insert slots as squares that still open, bypass and open the add-plugin menu.
#[test]
fn short_tracks_show_insert_slots_as_squares() {
let (mut h, kick, row) = harness("Kick", "small", "view.edit_inserts_ae");
@@ -127,7 +132,7 @@ fn short_tracks_show_insert_slots_as_squares() {
click(&mut h, square(4));
let plugin = soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap();
assert!(h.query_by_label(&format!("{:?} ⏵", plugin.category)).is_some());
assert!(h.query_by_label(&format!("{:?}", plugin.category)).is_some());
}
#[test]
+18 -10
View File
@@ -52,12 +52,14 @@ fn vox() -> soundcraft_engine::Engine {
engine
}
/// Typing filters the insert menu immediately, a click in the search keeps it open, and arrow keys plus Enter choose
/// the highlighted plugin.
#[test]
fn an_insert_menu_searches_as_soon_as_it_opens() {
let reverbs = built_in(false, "reverb");
assert!(reverbs.len() >= 2, "the test needs two built-in reverbs");
let mut h = harness(vox());
open(&mut h, "Insert A");
open(&mut h, "Insert A for Vox");
type_text(&mut h, "reverb");
assert!(h.query_by_label(reverbs[0].name).is_some(), "typing went to the search");
assert!(h.query_by_label_contains("Dynamics").is_none(), "the matches replace the categories");
@@ -74,15 +76,16 @@ fn an_insert_menu_searches_as_soon_as_it_opens() {
assert!(h.query_by_label(reverbs[0].name).is_none(), "picking closes the menu");
}
/// Reopening a menu clears its old search, Escape closes it, and Enter with no matches picks nothing.
#[test]
fn a_menu_opens_with_an_empty_search() {
let mut h = harness(vox());
open(&mut h, "Insert B");
open(&mut h, "Insert B for Vox");
type_text(&mut h, "reverb");
press(&mut h, Key::Escape);
assert!(h.query_by_label(built_in(false, "reverb")[0].name).is_none(), "Escape closes the menu");
open(&mut h, "Insert B");
open(&mut h, "Insert B for Vox");
let category = format!("{:?}", soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap().category);
assert!(h.query_by_label_contains(&category).is_some(), "the old search is gone, so the categories show");
type_text(&mut h, "zzzz");
@@ -91,14 +94,16 @@ fn a_menu_opens_with_an_empty_search() {
assert!(h.state().engine.session().tracks[0].mixer.inserts.iter().all(Option::is_none), "Enter without a match picks nothing");
}
/// Choosing a plugin from a category closes the menu, whether it was opened by a primary click on an empty slot or
/// a secondary click on the now-filled slot.
#[test]
fn picks_without_the_search_still_close_the_menu() {
let first = soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap();
let other = soundcraft_dsp::plugins().iter().find(|p| p.category == first.category && p.id != first.id).unwrap();
let category = format!("{:?} ⏵", first.category);
let category = format!("{:?}", first.category);
let mut h = harness(vox());
for (plugin, click) in [(first, egui::PointerButton::Primary), (other, egui::PointerButton::Secondary)] {
h.get_by_label("Insert C").click_button(click);
h.get_by_label("Insert C for Vox").click_button(click);
h.run_steps(3);
h.get_by_label(&category).hover();
h.run_steps(3);
@@ -112,7 +117,7 @@ fn picks_without_the_search_still_close_the_menu() {
h.run_steps(2);
for (item, bypassed) in [("Bypass", Some(true)), ("no insert", None)] {
h.get_by_label("Insert C").click_secondary();
h.get_by_label("Insert C for Vox").click_secondary();
h.run_steps(3);
h.get_by_label(item).click();
h.run_steps(3);
@@ -121,6 +126,7 @@ fn picks_without_the_search_still_close_the_menu() {
}
}
/// Typing filters an instrument menu immediately and Enter inserts the highlighted built-in instrument.
#[test]
fn an_instrument_menu_searches_as_soon_as_it_opens() {
let drums = built_in(true, "drum");
@@ -130,7 +136,7 @@ fn an_instrument_menu_searches_as_soon_as_it_opens() {
let mut h = harness(engine);
h.state_mut().ui.mix_views.push("instruments".into());
h.run_steps(2);
open(&mut h, "Instrument");
open(&mut h, "Instrument for Keys");
type_text(&mut h, "drum");
press(&mut h, Key::Enter);
let instrument = h.state().engine.session().tracks[0].instrument.clone().unwrap();
@@ -138,6 +144,7 @@ fn an_instrument_menu_searches_as_soon_as_it_opens() {
assert!(h.query_by_label(drums[0].name).is_none(), "picking closes the menu");
}
/// Send and output menus filter buses as soon as they open, and Enter routes to the highlighted bus.
#[test]
fn send_and_output_menus_search_buses_as_soon_as_they_open() {
let mut engine = vox();
@@ -149,25 +156,26 @@ fn send_and_output_menus_search_buses_as_soon_as_they_open() {
let mut h = harness(engine);
let bus = |h: &Harness<SoundApp>, name: &str| h.state().engine.session().busses.iter().find(|b| b.name == name).unwrap().id;
open(&mut h, "Send C");
open(&mut h, "Send C for Vox");
type_text(&mut h, "del");
assert!(h.query_by_label("Verb").is_none());
press(&mut h, Key::Enter);
let send = h.state().engine.session().tracks[0].mixer.sends[2].clone().expect("Enter picks the bus");
assert_eq!(send.target, Route::Bus(bus(&h, "Delay")));
open(&mut h, "Output");
open(&mut h, "Output for Vox");
type_text(&mut h, "ver");
press(&mut h, Key::Enter);
assert_eq!(h.state().engine.session().tracks[0].mixer.output, Route::Bus(bus(&h, "Verb")));
}
/// Scripted typing and Enter reach a menu search even when the host window is unfocused.
#[test]
fn scripted_keys_reach_the_search_with_the_window_in_the_background() {
let plates = built_in(false, "plate");
let mut h = harness(vox());
h.input_mut().focused = false;
open(&mut h, "Insert A");
open(&mut h, "Insert A for Vox");
type_text(&mut h, "plate");
press(&mut h, Key::Enter);
let insert = h.state().engine.session().tracks[0].mixer.inserts[0].clone().expect("Enter works without window focus, as typing does");
+2 -1
View File
@@ -12,8 +12,9 @@ fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<Event>) {
.clear();
}
/// The MIDI editor's last laid-out body rectangle, stored by the dock.
fn panel(ctx: &egui::Context) -> Rect {
ctx.data(|data| data.get_temp::<Rect>(Id::new("soundcraft-midi-body"))).unwrap()
ctx.data(|data| data.get_temp::<Rect>(Id::unique("soundcraft-midi-body"))).unwrap()
}
fn button(pos: Pos2, pressed: bool) -> Event {
+2 -1
View File
@@ -122,6 +122,7 @@ fn the_pencil_makes_no_clips_on_audio_tracks() {
assert!(r.clips().is_empty());
}
/// Clicking the empty MIDI editor adds a clip spanning the selected bars at the insertion point and selects it.
#[test]
fn a_click_in_the_empty_midi_editor_adds_a_clip_at_the_insertion_point() {
let mut r = Rig::new("instrument");
@@ -130,7 +131,7 @@ fn a_click_in_the_empty_midi_editor_adds_a_clip_at_the_insertion_point() {
r.app.run("edit.select", json!({"tracks": [track], "start": (r.bar(3) + r.bar(4)) / 2})).unwrap();
r.frames(4);
assert_eq!(midi_editor::target_clip(&r.app), None);
let body = r.ctx.data(|d| d.get_temp::<Rect>(Id::new("soundcraft-midi-body"))).unwrap();
let body = r.ctx.data(|d| d.get_temp::<Rect>(Id::unique("soundcraft-midi-body"))).unwrap();
r.click_at(body.center());
assert_eq!(r.clips(), vec![(r.bar(3), r.bar(4))]);
assert_eq!(midi_editor::target_clip(&r.app), Some(r.app.engine.session().tracks[0].clips()[0].id));
+2 -1
View File
@@ -55,12 +55,13 @@ fn double_click_below_the_last_track_opens_new_tracks() {
}
}
/// Double-clicking to the right of the last Mix strip opens the New Tracks dialog.
#[test]
fn double_click_right_of_the_last_strip_opens_new_tracks() {
let mut h = harness(1);
h.state_mut().ui.window = MainWindow::Mix;
h.run_steps(4);
let strip = h.get_by_label("Record enable").rect();
let strip = h.get_by_label_contains("Record enable").rect();
let at = pos2(strip.max.x + 150.0, strip.center().y);
click(&mut h, at);
click(&mut h, at);
+9 -5
View File
@@ -38,7 +38,7 @@ fn vox_id(h: &Harness<SoundApp>) -> TrackId {
fn two_effects() -> (String, &'static soundcraft_dsp::PluginInfo, &'static soundcraft_dsp::PluginInfo) {
let first = soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap();
let other = soundcraft_dsp::plugins().iter().find(|p| p.category == first.category && p.id != first.id).unwrap();
(format!("{:?} ⏵", first.category), first, other)
(format!("{:?}", first.category), first, other)
}
/// Opens `slot`'s menu (a click on an empty slot, a right-click on a filled one) and picks
@@ -61,20 +61,22 @@ fn inserted(h: &Harness<SoundApp>) -> Option<String> {
h.state().engine.session().tracks[0].mixer.inserts[0].as_ref().map(|i| i.plugin.clone())
}
/// Choosing a plugin for an empty insert inserts it and opens its window.
#[test]
fn picking_a_plugin_for_an_empty_insert_opens_it() {
let (category, plugin, _) = two_effects();
let mut h = harness(vox(), MainWindow::Mix);
pick(&mut h, "Insert A", false, &category, plugin.name);
pick(&mut h, "Insert A for Vox", false, &category, plugin.name);
assert_eq!(inserted(&h).as_deref(), Some(plugin.id));
let id = vox_id(&h);
assert_eq!(h.state().ui.plugin_windows, vec![(id, 0)], "the new insert's window opened");
}
/// A plugin chosen by typing a search and pressing Enter also opens its window.
#[test]
fn picking_from_the_search_opens_it_too() {
let mut h = harness(vox(), MainWindow::Mix);
h.get_by_label("Insert A").click();
h.get_by_label("Insert A for Vox").click();
h.run_steps(3);
h.event(egui::Event::Text("reverb".into()));
h.run_steps(2);
@@ -85,6 +87,7 @@ fn picking_from_the_search_opens_it_too() {
assert_eq!(h.state().ui.plugin_windows, vec![(id, 0)]);
}
/// Replacing a filled insert inserts the new plugin and opens that plugin's window.
#[test]
fn replacing_a_filled_insert_opens_the_new_plugin() {
let (category, first, second) = two_effects();
@@ -93,16 +96,17 @@ fn replacing_a_filled_insert_opens_the_new_plugin() {
engine.execute("mix.insert", &json!({"track": track.0, "slot": 0, "plugin": first.id})).unwrap();
let mut h = harness(engine, MainWindow::Mix);
assert!(h.state().ui.plugin_windows.is_empty(), "nothing opens before a pick");
pick(&mut h, "Insert A", true, &category, second.name);
pick(&mut h, "Insert A for Vox", true, &category, second.name);
assert_eq!(inserted(&h).as_deref(), Some(second.id));
assert_eq!(h.state().ui.plugin_windows, vec![(track, 0)]);
}
/// Choosing a plugin from the Edit window's insert column inserts it and opens its window.
#[test]
fn the_edit_windows_inserts_column_opens_it_too() {
let (category, plugin, _) = two_effects();
let mut h = harness(vox(), MainWindow::Edit);
pick(&mut h, "Insert A", false, &category, plugin.name);
pick(&mut h, "Insert A for Vox", false, &category, plugin.name);
assert_eq!(inserted(&h).as_deref(), Some(plugin.id));
let id = vox_id(&h);
assert_eq!(h.state().ui.plugin_windows, vec![(id, 0)]);
+2 -1
View File
@@ -1,6 +1,7 @@
use egui_kittest::Harness;
use soundcraft_ui_egui::{Services, SoundApp};
/// A plugin window taller than a short screen is clamped so its bottom remains visible.
#[test]
fn a_long_plugin_window_stays_on_a_short_screen() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
@@ -14,6 +15,6 @@ fn a_long_plugin_window_stays_on_a_short_screen() {
app,
);
h.run_steps(4);
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("plugin", kick.0, 0usize)))).unwrap();
let window = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("plugin", kick.0, 0usize)))).unwrap();
assert!(window.bottom() <= 500.0, "{window:?}");
}
+4 -2
View File
@@ -195,6 +195,7 @@ fn window_keys_open_their_windows() {
}
}
/// Modified arrow keys stay with the global shortcuts, while an unmodified arrow still moves the selected note.
#[test]
fn the_piano_roll_leaves_modified_arrows_to_the_shortcuts() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
@@ -213,7 +214,7 @@ fn the_piano_roll_leaves_modified_arrows_to_the_shortcuts() {
for _ in 0..6 {
frame(&mut app, vec![]);
}
let body = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("soundcraft-midi-body"))).unwrap();
let body = ctx.data(|d| d.get_temp::<Rect>(egui::Id::unique("soundcraft-midi-body"))).unwrap();
frame(&mut app, vec![Event::PointerMoved(body.center())]);
app.midi.selected = vec![0];
let pitch = |app: &SoundApp| {
@@ -233,6 +234,7 @@ fn the_piano_roll_leaves_modified_arrows_to_the_shortcuts() {
assert_eq!(pitch(&app), before + 1);
}
/// Shift+Up moves the selection to the same note an octave above, past intervening notes in the chord.
#[test]
fn the_piano_roll_selection_follows_notes_the_arrows_move_past_others() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
@@ -251,7 +253,7 @@ fn the_piano_roll_selection_follows_notes_the_arrows_move_past_others() {
for _ in 0..6 {
frame(&mut app, vec![]);
}
let body = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("soundcraft-midi-body"))).unwrap();
let body = ctx.data(|d| d.get_temp::<Rect>(egui::Id::unique("soundcraft-midi-body"))).unwrap();
frame(&mut app, vec![Event::PointerMoved(body.center())]);
let notes = |app: &SoundApp| {
let clip = soundcraft_ui_egui::midi_editor::target_clip(app).unwrap();
+10 -2
View File
@@ -33,13 +33,17 @@ fn wav(secs: f32) -> Vec<u8> {
soundcraft_audio_io::encode(&buf, &opts).unwrap()
}
/// Feed one harness frame through the raw-input hook before stepping, so dropped files and scripted input are
/// handled.
fn step(h: &mut Harness<'_, SoundApp>) {
let mut raw = std::mem::take(h.input_mut());
h.state_mut().raw_input_hook(&mut raw);
let ctx = h.ctx.clone();
h.state_mut().raw_input_hook(&ctx, &mut raw);
*h.input_mut() = raw;
h.step();
}
/// A demo app with the Keys sampler window open and six settled frames, plus that track's id.
fn harness() -> (Harness<'static, SoundApp>, TrackId) {
let engine = soundcraft_engine::demo::demo_engine();
let keys = engine.session().tracks.iter().find(|t| t.name == "Keys").unwrap().id;
@@ -49,7 +53,6 @@ fn harness() -> (Harness<'static, SoundApp>, TrackId) {
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
let _ = app.take_dropped_files(&ctx);
app.ui(ui);
},
app,
@@ -130,12 +133,17 @@ fn dropping_audio_without_a_path_reads_its_bytes() {
assert_eq!(h.state().engine.session().sources.len(), before);
}
/// A non-audio drop goes to the shell exactly once and does not change the instrument or queue a sample read.
#[test]
fn non_audio_drops_are_left_to_the_shell() {
let (mut h, keys) = harness();
drop_on_window(&mut h, keys, Dropped { path: PathBuf::from("/tmp/song.scraft"), bytes: Vec::new() });
assert_eq!(instrument(&h, keys).plugin, "subtractive_synth");
assert!(h.state_mut().take_sample_reads().is_empty());
let host = h.state_mut().take_dropped_files();
assert_eq!(host.len(), 1, "a session drop is handed to the shell once");
assert!(host.first().is_some_and(|f| f.path().ends_with("song.scraft")));
assert!(h.state_mut().take_dropped_files().is_empty(), "later steps do not replay it");
}
#[test]
+11 -7
View File
@@ -38,10 +38,12 @@ fn press(h: &mut Harness<SoundApp>, at: Pos2, pressed: bool, modifiers: Modifier
h.step();
}
/// A send's level opens its window; Alt-click resets the fader to 0 dB, dragging reaches −∞, and clicking the
/// same send again closes the window.
#[test]
fn a_sends_level_opens_its_send_window() {
let (mut h, vox) = harness();
h.get_by_label("Send A level").click();
h.get_by_label("Send A level for Vox").click();
h.run_steps(3);
assert_eq!(h.state().ui.send_window, Some((vox, 0)));
assert!(h.query_by_label_contains("Vox · Send A").is_some());
@@ -63,33 +65,35 @@ fn a_sends_level_opens_its_send_window() {
assert_eq!(level(&h, 0), -144.0, "the fader goes down to −∞");
assert_eq!(level(&h, 1), -10.0);
h.get_by_label("Send B level").click();
h.get_by_label("Send B level for Vox").click();
h.run_steps(3);
assert_eq!(h.state().ui.send_window, Some((vox, 1)), "another send's level moves the window there");
assert!(h.query_by_label_contains("Vox · Send B").is_some());
h.get_by_label("Send B level").click();
h.get_by_label("Send B level for Vox").click();
h.run_steps(3);
assert_eq!(h.state().ui.send_window, None, "clicking the same send again closes it");
assert!(h.query_by_label_contains("Vox · Send").is_none());
}
/// Escape first closes a send menu or cancels level typing, then a further Escape closes the send window.
#[test]
fn escape_closes_the_send_window_after_menus_and_typing() {
let (mut h, vox) = harness();
h.get_by_label("Send A level").click();
h.get_by_label("Send A level for Vox").click();
h.run_steps(3);
// A right-click menu on top takes the first Escape.
let at = h.get_by_label("Send A level").rect().center();
let at = h.get_by_label("Send A level for Vox").rect().center();
h.hover_at(at);
for pressed in [true, false] {
h.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
}
h.run_steps(3);
assert!(h.query_by_label("Pre-fader").is_some(), "the send's options menu is open");
// The window's own PRE toggle is a button labelled "Pre-fader"; the menu's is a checkbox.
assert!(h.query_by_role_and_label(egui::accesskit::Role::CheckBox, "Pre-fader").is_some(), "the send's options menu is open");
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.query_by_label("Pre-fader").is_none());
assert!(h.query_by_role_and_label(egui::accesskit::Role::CheckBox, "Pre-fader").is_none());
assert_eq!(h.state().ui.send_window, Some((vox, 0)), "that Escape only closed the menu");
// So does typing a level in the window.
+4 -2
View File
@@ -132,6 +132,8 @@ fn toolbar_keyboard_activation_changes_tools_without_transport_or_marker_actions
}
}
/// Clicking record-enable arms the track; Enter and Space on the focused button toggle arming without starting
/// transport or recording.
#[test]
fn track_record_button_arms_the_track_without_starting_transport() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
@@ -144,11 +146,11 @@ fn track_record_button_arms_the_track_without_starting_transport() {
app,
);
h.run_steps(5);
h.get_by_label("Record enable").click();
h.get_by_label_contains("Record enable").click();
h.run_steps(3);
assert!(h.state().engine.session().tracks[0].mixer.record_arm);
for (key, armed) in [(Key::Enter, false), (Key::Space, true)] {
h.get_by_label("Record enable").focus();
h.get_by_label_contains("Record enable").focus();
h.run_steps(2);
h.key_press(key);
h.run_steps(3);
@@ -14,6 +14,7 @@ fn tempo(h: &egui_kittest::Harness<'_, SoundApp>) -> Option<(egui::Rect, egui::R
})
}
/// In a narrow window the toolbar scrolls horizontally far enough to reach the tempo readout.
#[test]
fn a_narrow_window_scrolls_the_toolbar_to_reach_the_tempo() {
let mut engine = soundcraft_engine::Engine::default();
@@ -41,6 +42,7 @@ fn a_narrow_window_scrolls_the_toolbar_to_reach_the_tempo() {
delta: vec2(-300.0, 0.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Unknown,
});
h.step();
}
+2 -1
View File
@@ -13,6 +13,7 @@ fn right_click(h: &mut Harness<SoundApp>, at: Pos2) {
h.run_steps(3);
}
/// Right-clicking a track name opens the track menu in both the Edit window and the Mix strip.
#[test]
fn right_click_on_a_track_name_opens_the_track_menu() {
let mut engine = soundcraft_engine::Engine::default();
@@ -34,7 +35,7 @@ fn right_click_on_a_track_name_opens_the_track_menu() {
h.state_mut().ui.window = MainWindow::Mix;
h.run_steps(4);
assert!(h.query_by_label("Make Inactive").is_none());
let strip = h.get_by_label("Record enable").rect();
let strip = h.get_by_label_contains("Record enable").rect();
right_click(&mut h, pos2(strip.center().x, 900.0 - 17.0));
assert!(h.query_by_label("Make Inactive").is_some(), "the Mix strip's name plate");
}
+11 -1
View File
@@ -12,18 +12,28 @@ struct Rig {
}
impl Rig {
/// A demo app with a control channel, a chosen window width and deterministic non-kinetic scrolling for exact
/// offset assertions.
fn new(width: f32) -> Rig {
let (tx, rx) = channel();
let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()).with_control(rx);
let mut r = Rig { ctx: egui::Context::default(), app, tx, width };
// egui 0.37 scroll areas treat point wheel events as a trackpad: they rubber-band at an
// edge and can coast after the gesture. These tests assert exact offsets, so the rig uses
// deterministic scrolling (production keeps the kinetic feel).
r.ctx.all_styles_mut(|s| {
s.spacing.scroll.kinetic.decay_time = 0.0;
s.spacing.scroll.kinetic.rubber_band = false;
});
r.frames(6);
r
}
/// Run `n` frames through the raw-input hook and the app's logic and UI.
fn frames(&mut self, n: usize) {
for _ in 0..n {
let mut raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 900.0))), ..Default::default() };
self.app.raw_input_hook(&mut raw);
self.app.raw_input_hook(&self.ctx, &mut raw);
self.ctx
.run_ui(raw, |ui| {
self.app.logic(ui.ctx());
+3 -1
View File
@@ -3,9 +3,11 @@ use egui_kittest::Harness;
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, theme::Tokens};
/// Feed one harness frame through the raw-input hook before stepping, so scripted input reaches the app.
fn step(h: &mut Harness<'_, SoundApp>) {
let mut raw = std::mem::take(h.input_mut());
h.state_mut().raw_input_hook(&mut raw);
let ctx = h.ctx.clone();
h.state_mut().raw_input_hook(&ctx, &mut raw);
*h.input_mut() = raw;
h.step();
}
+2 -2
View File
@@ -22,13 +22,13 @@ fn paint(samples_per_px: f64, f: impl Fn(&egui::Painter, &Session, Rect)) -> Vec
fn draw(samples: Vec<f32>, samples_per_px: f64) -> Vec<Shape> {
let clip = Clip::audio(ClipId(1), "c", SourceId(1), 0, 0, samples.len() as i64);
let audio = SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![samples] });
paint(samples_per_px, |p, s, band| waveform::draw(p, s, band, &clip, &audio, 1, band, WAVE))
paint(samples_per_px, |p, s, band| waveform::draw(waveform::Canvas { painter: p, s, tl: band }, &clip, &audio, 1, band, WAVE))
}
#[test]
fn a_take_draws_its_overview_while_it_records() {
let blocks = vec![vec![(-0.5, 0.5); 100]];
let shapes = paint(128.0, |p, s, band| waveform::draw_live(p, s, band, 1280.0, 128.0, &blocks, band, WAVE));
let shapes = paint(128.0, |p, s, band| waveform::draw_live(waveform::Canvas { painter: p, s, tl: band }, 1280.0, 128.0, &blocks, band, WAVE));
let [Shape::Mesh(mesh)] = shapes.as_slice() else { panic!("{shapes:?}") };
let span = |f: fn(&Pos2) -> f32| mesh.vertices.iter().map(|v| f(&v.pos)).fold((f32::MAX, f32::MIN), |(a, b), v| (a.min(v), b.max(v)));
let ((left, right), (top, bottom)) = (span(|p| p.x), span(|p| p.y));
@@ -72,6 +72,8 @@ fn contrast(a: Color32, b: Color32) -> f64 {
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
/// In real frames, populated and empty instrument and insert slots use the active theme's readable palette across
/// light, dark and system modes, and a plugin change leaves the rest of the session and audio pool intact.
#[test]
fn populated_mix_instrument_uses_readable_palette_in_real_frames() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
@@ -177,7 +179,7 @@ fn populated_mix_instrument_uses_readable_palette_in_real_frames() {
harness.step();
}
harness.step(); // Let the popup finish its initial sizing pass.
harness.get_by_label("Built-in ⏵").hover();
harness.get_by_label("Built-in").hover();
harness.step();
harness.step();
harness.get_by_label("Drum Synth").click();
+17 -13
View File
@@ -54,20 +54,24 @@ impl Matrix {
}
}
/// The planes (and their geometry) of an 8-bit planar Y'CbCr picture for [`planar_to_rgba`].
#[derive(Clone, Copy)]
pub struct Planes<'a> {
pub width: usize,
pub height: usize,
pub y: &'a [u8],
pub y_stride: usize,
pub u: &'a [u8],
pub v: &'a [u8],
pub uv_stride: usize,
pub cx: usize,
pub cy: usize,
}
/// Planar 8-bit Y'CbCr with chroma subsampled by `cx` horizontally and `cy` vertically
/// (1 or 2 each) → packed RGBA. Missing samples (short planes) read as black / neutral.
pub fn planar_to_rgba(
width: usize,
height: usize,
y: &[u8],
y_stride: usize,
u: &[u8],
v: &[u8],
uv_stride: usize,
cx: usize,
cy: usize,
m: &Matrix,
) -> Vec<u8> {
pub fn planar_to_rgba(p: &Planes<'_>, m: &Matrix) -> Vec<u8> {
let Planes { width, height, y, y_stride, u, v, uv_stride, cx, cy } = *p;
let (cx, cy) = (cx.max(1), cy.max(1));
let mut out = vec![0u8; width.saturating_mul(height).saturating_mul(4)];
for (row, line) in out.chunks_exact_mut(width.max(1) * 4).enumerate().take(height) {
@@ -164,7 +168,7 @@ mod tests {
#[test]
fn planar_conversion_tolerates_short_planes() {
let m = Matrix::new(true, false);
let out = planar_to_rgba(4, 4, &[235; 3], 4, &[], &[], 2, 2, 2, &m);
let out = planar_to_rgba(&Planes { width: 4, height: 4, y: &[235; 3], y_stride: 4, u: &[], v: &[], uv_stride: 2, cx: 2, cy: 2 }, &m);
assert_eq!(out.len(), 64);
assert_eq!(&out[..4], &[255, 255, 255, 255]);
}
+1 -1
View File
@@ -268,7 +268,7 @@ impl Frame {
/// Copy the w x h window of chroma component `c` at (x0, y0) (clamped) into `out`.
#[inline]
pub fn chroma_window(&self, c: usize, x0: i32, y0: i32, w: usize, h: usize, out: &mut [u8], os: usize) {
pub fn chroma_window(&self, c: usize, (x0, y0): (i32, i32), w: usize, h: usize, out: &mut [u8], os: usize) {
let (cw, ch) = (self.cwidth, self.cheight);
for r in 0..h {
let yy = (y0 + r as i32).clamp(0, ch as i32 - 1) as usize;
+1 -1
View File
@@ -236,7 +236,7 @@ fn predict_block(f: &Frame, mv: [i16; 2], px: usize, py: usize, bw: usize, bh: u
let (fx, fy) = ((mv[0] & 7) as u32, (mv[1] & 7) as u32);
for (comp, out) in c.into_iter().enumerate() {
let mut w = [0u8; 16 * 9];
f.chroma_window(comp, cx, cy, cw + 1, ch + 1, &mut w, 16);
f.chroma_window(comp, (cx, cy), cw + 1, ch + 1, &mut w, 16);
inter::mc_chroma_win(&w, 16, fx, fy, cw, ch, out, cs);
}
}
+16 -3
View File
@@ -1,6 +1,6 @@
//! [`Movie`]: open an MP4/MOV, report its picture format, and decode frames at times to RGBA.
use crate::convert::{Matrix, planar_to_rgba, scale_rgba};
use crate::convert::{Matrix, Planes, planar_to_rgba, scale_rgba};
use crate::isobmff::{self, ByteSource, CodecConfig, Mp4File, TrackKind};
use crate::{Result, VideoError};
use std::collections::VecDeque;
@@ -411,7 +411,7 @@ impl Movie {
let (y, u, v) = (narrow(&f.y), narrow(&f.cb), narrow(&f.cr));
let cx = if cw < w { 2 } else { 1 };
let m = Matrix::for_code(f.color.matrix, f.height, false);
let rgba = planar_to_rgba(w, h, &y, w, &u, &v, cw, cx, 1, &m);
let rgba = planar_to_rgba(&Planes { width: w, height: h, y: &y, y_stride: w, u: &u, v: &v, uv_stride: cw, cx, cy: 1 }, &m);
Ok(Frame { width: f.width, height: f.height, rgba, index, time })
}
Codec::Jpeg => {
@@ -429,7 +429,20 @@ impl Movie {
fn h264_to_frame(&self, p: &crate::h264::Picture, index: usize) -> Frame {
let m = Matrix::for_code(p.color.matrix, p.height, p.color.full_range);
let rgba = planar_to_rgba(p.width as usize, p.height as usize, &p.y, p.y_stride, &p.u, &p.v, p.uv_stride, 2, 2, &m);
let rgba = planar_to_rgba(
&Planes {
width: p.width as usize,
height: p.height as usize,
y: &p.y,
y_stride: p.y_stride,
u: &p.u,
v: &p.v,
uv_stride: p.uv_stride,
cx: 2,
cy: 2,
},
&m,
);
Frame { width: p.width, height: p.height, rgba, index, time: self.times.get(index).copied().unwrap_or(0.0) }
}
+31 -19
View File
@@ -194,6 +194,35 @@ fn keep_releases(notes: &mut Vec<NoteEvent>) {
}
}
/// Copy `n` frames from `start` of the host buffers into the plugin's input `port` (mono sources
/// fill every port channel).
fn feed_input(inputs: &mut BusBuffers, port: usize, io: &[Vec<f32>], ch: usize, (start, n): (usize, usize)) {
for c in 0..inputs.channels(port) {
let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n));
if let (Some(src), Some(dst)) = (src, inputs.channel_mut(port, c).and_then(|d| d.get_mut(..n))) {
dst.copy_from_slice(src);
}
}
}
/// Copy `n` frames of the plugin's output `port` into the host buffers from `start`; a mono host
/// buffer takes the average of a stereo port.
fn read_output(outputs: &BusBuffers, port: usize, io: &mut [Vec<f32>], ch: usize, (start, n): (usize, usize)) {
let oc = outputs.channels(port);
for (c, dst) in io.iter_mut().take(ch).enumerate() {
let Some(dst) = dst.get_mut(start..start + n) else { continue };
if ch == 1 && oc >= 2 {
if let (Some(l), Some(r)) = (outputs.channel(port, 0), outputs.channel(port, 1)) {
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
*d = 0.5 * (a + b);
}
}
} else if let Some(src) = outputs.channel(port, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) {
dst.copy_from_slice(src);
}
}
}
impl Plugin for Vst3Plugin {
fn info(&self) -> &'static PluginInfo {
self.info
@@ -290,12 +319,7 @@ impl Plugin for Vst3Plugin {
let end = u32::try_from(start + n).unwrap_or(u32::MAX);
// Feed the main input bus (mono sources fill every bus channel).
if self.has_input {
for c in 0..self.inputs.channels(0) {
let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n));
if let (Some(src), Some(dst)) = (src, self.inputs.channel_mut(0, c).and_then(|d| d.get_mut(..n))) {
dst.copy_from_slice(src);
}
}
feed_input(&mut self.inputs, 0, io, ch, (start, n));
}
let k = self.notes.iter().position(|e| e.time() >= end).unwrap_or(self.notes.len());
let offset = u32::try_from(start).unwrap_or(u32::MAX);
@@ -316,19 +340,7 @@ impl Plugin for Vst3Plugin {
self.position = self.position.saturating_add(i64::try_from(n).unwrap_or(0));
match r {
Ok(()) if self.has_output => {
let oc = self.outputs.channels(0);
for (c, dst) in io.iter_mut().take(ch).enumerate() {
let Some(dst) = dst.get_mut(start..start + n) else { continue };
if ch == 1 && oc >= 2 {
if let (Some(l), Some(r)) = (self.outputs.channel(0, 0), self.outputs.channel(0, 1)) {
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
*d = 0.5 * (a + b);
}
}
} else if let Some(src) = self.outputs.channel(0, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) {
dst.copy_from_slice(src);
}
}
read_output(&self.outputs, 0, io, ch, (start, n));
}
Err(e) => log::debug!("{}: {e}", self.info.id),
_ => {}
@@ -6,6 +6,11 @@
mod common;
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;
}
let dir = common::fixture_dir();
let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
let mut p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate");
+5
View File
@@ -6,6 +6,11 @@
mod common;
fn main() {
// nextest lists custom-harness tests with `--list`; each binary here is one test.
if std::env::args().any(|a| a == "--list") {
println!("module_exit: test");
return;
}
let dir = common::fixture_dir();
let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
let p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate");
@@ -5,6 +5,11 @@
mod common;
fn main() {
// nextest lists custom-harness tests with `--list`; each binary here is one test.
if std::env::args().any(|a| a == "--list") {
println!("module_exit_live_instance: test");
return;
}
let dir = common::fixture_dir();
let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
let p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate");
@@ -29,6 +29,11 @@ fn run(case: &str) -> Output {
}
fn main() {
// nextest lists custom-harness tests with `--list`; each binary here is one test.
if std::env::args().any(|a| a == "--list") {
println!("module_exit_shutdown: test");
return;
}
match std::env::args().nth(1).as_deref() {
Some("shutdown") => return child(false),
Some("exit-handler") => return child(true),
@@ -6,6 +6,11 @@
mod common;
fn main() {
// nextest lists custom-harness tests with `--list`; each binary here is one test.
if std::env::args().any(|a| a == "--list") {
println!("module_exit_worker_thread: test");
return;
}
std::thread::spawn(|| {
let dir = common::fixture_dir();
let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id;
+7 -5
View File
@@ -47,16 +47,18 @@ Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no tr
| `ui.set` | any `UiState` fields | e.g. `{"window": "Mix", "narrow_mix": true}`. |
| `ui.menu.list` | `{}` | Every catalog menu path and the command that implements it (or null). |
| `ui.menu.invoke` | `{path}` or `{id}` | Like clicking the menu item: may open its dialog (e.g. `"Track > New..."`). |
| `ui.click` | `{x, y, button?, count?, cmd?, shift?, alt?, ctrl?}` | Synthetic click (window points). |
| `ui.click` | `{x, y, button?, count?, cmd?, shift?, alt?, ctrl?}` | Synthetic click (window points). The reply is sent only after a UI pass has applied the press/release. |
| `ui.drag` | `{x, y, to_x, to_y, steps?}` | Synthetic drag with the primary button. |
| `ui.scroll` | `{x, y, dx?, dy?, unit?: "point"\|"line", steps?, cmd?, shift?, alt?, ctrl?}` | A two-finger trackpad swipe over `(x, y)`: `dx`/`dy` points spread over `steps` frames (default 8). Positive values move the content right/down (natural scrolling). `unit: "line"` sends mouse-wheel notches instead. With `cmd` it zooms; with `shift` it scrolls sideways. |
| `ui.scroll` | `{x, y, dx?, dy?, unit?: "point"\|"line", steps?, cmd?, shift?, alt?, ctrl?}` | A two-finger trackpad swipe over `(x, y)`: `dx`/`dy` points spread over `steps` frames (default 8). Positive values move the content right/down (natural scrolling). `unit: "line"` sends mouse-wheel notches instead. With `cmd` it zooms; with `shift` it scrolls sideways. Point scrolling is trackpad-like: egui 0.37 scroll areas may rubber-band at an edge and spring back after the gesture, so a script that needs an exact final offset should use `unit: "line"` or drive the target with a command. |
| `ui.zoom` | `{x, y, factor, steps?}` | A trackpad pinch around `(x, y)`: `factor` > 1 spreads the fingers (zooms in). |
| `ui.move` | `{x, y}` | Move the pointer. |
| `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press (`Space`, `Enter`, `A`, `F7`, `=`…). |
| `ui.text` | `{text}` | Type text into the focused field. |
| `ui.screenshot` | `{path?}` | PNG of the window (base64 in `png_base64` when no path). |
| `ui.text` | `{text}` | Type text into the focused field. At most 64 KiB of UTF-8 text per request. |
| `ui.screenshot` | `{path?}` | PNG of the window (base64 in `png_base64` when no path). Exactly one reply per request. A request is bound to the window geometry, focus state and open session it was admitted for. A geometry or DPI change is retried within its 8 s deadline, then fails `screenshot stale`; a change of focused window or of the open document cancels it (`screenshot canceled: focus changed` / `... the session changed`) because those pixels must not be reported as a capture of the new state. It can also fail `screenshot queue full` (8 pending, 4 in flight), `screenshot too large` (64 MiB per image, 192 MiB aggregate **encoding peak**), `screenshot path longer than 4096 bytes`, or `screenshot unavailable: the window has no capturable size yet` (a first request before any window geometry is known, e.g. minimised before the first paint). Each request reserves its own worst-case encode peak — the captured RGBA image plus the raw copy, PNG buffer and base64 text built during encoding — so several small captures cannot retain or encode full-size images past the aggregate. The callback never writes a file: encoding and writing happen after collection, and a result arriving after the deadline returns `screenshot timed out` without creating the file. A native hidden/minimised window is asked for an explicit paint; browser hidden tabs cannot paint on request and time out. |
| `app.quit` | `{}` | Quit. |
Scripted input (`ui.click`, `ui.drag`, `ui.scroll`, `ui.zoom`, `ui.key`, `ui.text`, `ui.move`) queues at most 4096 waiting events; a request that would exceed that bound fails with `synthetic input queue full` instead of growing the queue. A gesture's reply is delivered only after a UI pass has applied all of its events. Requests that follow it on the same connection (inspect, engine commands, screenshots) are therefore ordered after the gesture; requests already queued behind an undelivered gesture wait as well. If no UI pass can deliver the gesture within 15 s, it is canceled with `synthetic input was not delivered` and the request chain continues. Scripted input is automation, not a clipboard action: copy/selection commands it triggers are not published to the OS clipboard or PRIMARY selection.
## Positions and references
- Tracks: id (number) or name (`"Kick"`).
@@ -99,7 +101,7 @@ Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use mo
Panel IDs: `tracks`, `clips`, `midi`, `memory`, `history`. The protected center is `canvas`.
`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: tab|center|left|right|top|bottom|above|below`; PascalCase names such as `Left` also parse). `open` and `activate` show a hidden panel at its saved location, or beside the session view when it has none, and bring a visible panel to the front. Center and `tab` group with the target; side zones split beside it; `above` and `below` stack the panel in a new group above or below a floating target's group. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: tab|center|left|right|top|bottom|above|below`; PascalCase names such as `Left` also parse). `open` and `activate` show a hidden panel at its saved location, or beside the session view when it has no saved location or its saved anchor cannot be restored, and bring a visible panel to the front. Center and `tab` group with the target; side zones split beside it; `above` and `below` stack the panel in a new group above or below a floating target's group. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tracks","rect":[100,100,320,440]}}`.
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1,6 +1,6 @@
# Where SoundCraft falls short of Pro Tools
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-24 · **Change:** trivial (G-08 and G-20: built-in plugin count, the sampler shipped) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-25 · **Change:** trivial (G-04: Clip Looping crossfade and loop count; previously trivial: G-08 and G-20: built-in plugin count, the sampler shipped) · **Target:** Avid Pro Tools Ultimate 2026.4.1
Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing
real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker**
@@ -100,6 +100,10 @@ channels, clipping order, centered mono tracks, each 5.1 channel and 1-, 37- and
including surround channels, instead of repeating channel zero. Clip edits, source timestamps,
Undo/Redo and save/reopen are covered by regression tests; missing or empty media fails atomically.
**Open (2026-10-25):** the Clip Looping dialog (#1030) has no option to crossfade the loops into
each other, and its Number of Loops field stops at 512, while Loop Length and Loop Until End of
Session or Next Clip make up to 1000 loops.
Open on 2026-10-10 (several have PRs open):
editing #3, #5, #140, #144; clip/session #86 (PR #123), #88 (PR #130), #91 (PR #124), #92 (PR #132);
automation #104 (PR #134); mix/bounce #59 (PR #131), #103 (PR #133), #152; playback #101 (PR #129);
@@ -221,6 +225,7 @@ missing. Out of scope unless the owner decides otherwise.
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | G-04: Clip Looping (#1030) lacks the loop crossfade option, and Number of Loops stops at 512 while length and fill make up to 1000 loops |
| 2026-10-24 | trivial | G-08: 27 processors + 3 instruments, as the plugin registry holds; G-20: the sampler shipped (#108), so it is no longer listed as missing |
| 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained |
| 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged |
+3 -2
View File
@@ -1,6 +1,6 @@
# Plugin parity: built-in processors and plugin hosting
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (AudioSuite speed ramps (speed_ramp); Auto Pan; Subharmonic; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Auto Pan stereo balance; previously minor: AudioSuite speed ramps (speed_ramp); Auto Pan; Subharmonic; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1
Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins
cover the rest. This doc tracks both. Our plugins have our own names and are written from
@@ -17,7 +17,7 @@ scratch (clean-room rule): Avid plugin names are not used here, only categories.
| Dynamics | compressor/limiter, expander/gate, de-esser, maximizer, vintage compressors, multiband | `compressor`, `expander_gate`, `de_esser`, `maximizer` | Vintage/opto models, multiband, sidechain input (no key input anywhere) |
| Reverb | algorithmic room/plate/hall; convolution (paid tier) | `room_reverb`, `plate_reverb` | Hall/chamber, convolution, early-reflection editor |
| Delay | modulated delay, tape/analog delays | `mod_delay` | Tape, ping-pong, multi-tap, tempo-sync depth |
| Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `auto_pan`, `chorus`, `flanger`, `phaser`, `ring_mod` (ring, frequency modulation, positive and negative resonators; LFO, envelope or sample-and-hold modulation). Auto Pan pans the mono sum of a stereo track with an LFO (free, single, beat clock, envelope or manual trigger; rate in BPM or linked to the session tempo) or an envelope follower. | Tremolo, frequency shift; Auto Pan on a mono track (inserts on a mono track have one channel), LCR, quad and 5.0 outputs with Angle, Place and Spread, circular surround paths, output meters, side-chain input |
| Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `auto_pan`, `chorus`, `flanger`, `phaser`, `ring_mod` (ring, frequency modulation, positive and negative resonators; LFO, envelope or sample-and-hold modulation). Auto Pan moves the balance of a stereo track at constant power, keeping each channel on its own side and the track unchanged at the center, with an LFO (free, single, beat clock, envelope or manual trigger; rate in BPM or linked to the session tempo) or an envelope follower. | Tremolo, frequency shift; Auto Pan on a mono track (inserts on a mono track have one channel), LCR, quad and 5.0 outputs with Angle, Place and Spread, circular surround paths, output meters, side-chain input |
| Harmonic | lo-fi, rectifier, amp simulation, saturation, tape, subharmonic synthesis | `lofi`, `rectifier`, `saturator`, `subharmonic` (octave-down bass from a lower and an upper band, direct bass, drive characters, HP and LP filters) | Amp and cabinet models, tape; subharmonic MIDI tuning and surround (LFE) version |
| Pitch and time | pitch shift, varispeed effects, time shift | `pitch_shifter`, `time_shift`; the AudioSuite speed ramp (`speed_ramp`) slows a clip down to a stop or speeds it up from one, fitted to the clip's length or extended to 150 %, with or without a fade. | Formant control, pitch correction |
| Utility | trim, gain, invert, DC offset, signal generator, dither, time adjuster | `trim`, `gain`, `invert`, `dc_offset_removal`, `signal_generator`, `dither` | Time adjuster, meter/analyzer plugins, noise shaping options |
@@ -58,6 +58,7 @@ User report #152: a saved instrument level does not reach the bounce.
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | Auto Pan: constant-power balance of the left and right channels; stereo passes unchanged at the center |
| 2026-10-24 | minor | AudioSuite speed ramps (`speed_ramp`): down to a stop and up from one, Fit To or Extend, fades on or off |
| 2026-10-24 | minor | Auto Pan: LFO and envelope panning of stereo tracks; built-in count 27 processors + 3 instruments |
| 2026-10-24 | trivial | Harmonic: Subharmonic (`subharmonic`); built-in count 26 processors + 3 instruments |
@@ -71,6 +71,7 @@ modules:
build-commands:
- cargo --offline build --release --locked -p soundcraft -p soundcraft-cli
- install -Dm755 target/release/soundcraft /app/bin/soundcraft
- install -Dm644 NOTICE /app/share/doc/soundcraft/NOTICE
- install -Dm755 target/release/soundcraft-cli /app/bin/soundcraft-cli
- install -Dm644 packaging/linux/ai.storyteller.soundcraft.desktop /app/share/applications/ai.storyteller.soundcraft.desktop
- install -Dm644 packaging/linux/ai.storyteller.soundcraft.mime.xml /app/share/mime/packages/ai.storyteller.soundcraft.xml
-1
View File
@@ -125,7 +125,6 @@ if has appimage; then
fi
APPDIR="$WORK/SoundCraft.AppDir"
cp -R "$STAGE" "$APPDIR"
mv "$APPDIR/usr/share/doc" "$WORK/doc-unused"
ln -s usr/bin/soundcraft "$APPDIR/AppRun"
cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop"
cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png"
+2
View File
@@ -117,6 +117,8 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
# Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile).
cp "$WORK/bin/soundcraft" "$APP/Contents/MacOS/SoundCraft"
cp "$ROOT/assets/app-icon/soundcraft.icns" "$APP/Contents/Resources/SoundCraft.icns"
# Required embedded-font notices are independent of optional font inputs.
cp "$ROOT/NOTICE" "$APP/Contents/Resources/NOTICE"
sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \
-e "s/@BUILD_SHA@/${SOUNDCRAFT_BUILD_SHA:-unknown}/g" \
"$HERE/Info.plist.in" >"$APP/Contents/Info.plist"

Some files were not shown because too many files have changed in this diff Show More