From b193b78ef07573936963d384ee9751374143afb8 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Mon, 5 Oct 2026 20:19:07 +0100
Subject: [PATCH 001/300] feat(desktop): ask the OS for microphone and camera
access
Handle the WebView media permission requests in the Tauri shell, and add
the macOS Info.plist usage strings and entitlements that recording needs.
---
desktop/src-tauri/Cargo.lock | 3 +
desktop/src-tauri/Cargo.toml | 11 ++
desktop/src-tauri/Entitlements.plist | 18 ++
desktop/src-tauri/Info.plist | 17 ++
desktop/src-tauri/src/main.rs | 16 ++
desktop/src-tauri/src/mediaperm.rs | 259 +++++++++++++++++++++++++++
desktop/src-tauri/tauri.conf.json | 1 +
7 files changed, 325 insertions(+)
create mode 100644 desktop/src-tauri/Entitlements.plist
create mode 100644 desktop/src-tauri/Info.plist
create mode 100644 desktop/src-tauri/src/mediaperm.rs
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index 0a8becdf..68eaf888 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -3432,6 +3432,9 @@ dependencies = [
"tauri-plugin-store",
"tempfile",
"time",
+ "webkit2gtk",
+ "webview2-com",
+ "windows-core 0.61.2",
"zip",
"zstd",
]
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 154d59f4..54c2ecbb 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -47,5 +47,16 @@ time = "0.3"
zip = { version = "2", default-features = false, features = ["deflate"] }
zstd = "0.13"
+# Answering the webview's microphone and camera requests for StemDeck's own
+# page (src/mediaperm.rs). All three are already in the tree through wry, at
+# these versions; named here because mediaperm.rs calls them directly.
+[target.'cfg(windows)'.dependencies]
+webview2-com = "0.38"
+windows-core = "0.61"
+
+[target.'cfg(target_os = "linux")'.dependencies]
+# Pinned exactly, with the feature wry 0.55.1 uses: wry requires =2.0.2.
+webkit2gtk = { version = "=2.0.2", features = ["v2_38"] }
+
[dev-dependencies]
tempfile = "3"
diff --git a/desktop/src-tauri/Entitlements.plist b/desktop/src-tauri/Entitlements.plist
new file mode 100644
index 00000000..0d6b9770
--- /dev/null
+++ b/desktop/src-tauri/Entitlements.plist
@@ -0,0 +1,18 @@
+
+
+
+
+
+ com.apple.security.device.audio-input
+
+ com.apple.security.device.camera
+
+
+
diff --git a/desktop/src-tauri/Info.plist b/desktop/src-tauri/Info.plist
new file mode 100644
index 00000000..0c2e1593
--- /dev/null
+++ b/desktop/src-tauri/Info.plist
@@ -0,0 +1,17 @@
+
+
+
+
+
+ NSMicrophoneUsageDescription
+ StemDeck records your voice or instrument while you play along with the stems.
+ NSCameraUsageDescription
+ StemDeck records your camera while you play along with the stems, to make a performance video.
+
+
diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs
index 735bdaa2..95c32c78 100644
--- a/desktop/src-tauri/src/main.rs
+++ b/desktop/src-tauri/src/main.rs
@@ -1,6 +1,7 @@
mod certs;
mod dragout;
mod dropin;
+mod mediaperm;
#[cfg(any(not(target_os = "macos"), test))]
mod torchswap;
@@ -178,6 +179,15 @@ struct BackendState {
inner: Mutex,
}
+/// The URL of the backend this process is running, if any. Read by the media
+/// permission handlers each time a page asks, because a restart can move the
+/// backend to another port.
+fn current_backend_url(app: &tauri::AppHandle) -> Option {
+ let state = app.try_state::()?;
+ let inner = state.inner.lock().ok()?;
+ inner.handles.as_ref().map(|h| h.url.clone())
+}
+
impl Default for BackendState {
fn default() -> Self {
BackendState {
@@ -412,6 +422,12 @@ fn main() {
// recoverable from inside the app.
fit_min_size_to_screen(&window);
+ // Microphone and camera for the studio page, which records takes
+ // and performance videos. Granted to the running backend's origin
+ // only; see mediaperm.rs for what each webview needs.
+ let handle = app.handle().clone();
+ mediaperm::install(&window, move || current_backend_url(&handle));
+
let data_dir = match local_data_dir() {
Ok(d) => d,
Err(e) => {
diff --git a/desktop/src-tauri/src/mediaperm.rs b/desktop/src-tauri/src/mediaperm.rs
new file mode 100644
index 00000000..78f64ff9
--- /dev/null
+++ b/desktop/src-tauri/src/mediaperm.rs
@@ -0,0 +1,259 @@
+//! Microphone and camera for StemDeck's own page, and for nothing else.
+//!
+//! Recording a take and recording a performance video both call
+//! `getUserMedia` from the studio page, which the Python backend serves at
+//! `http://127.0.0.1:`. Each webview needs something different before
+//! that call can succeed, and wry 0.55.1 (the version in Cargo.lock) supplies
+//! only part of it:
+//!
+//! - Windows, WebView2: wry registers a `PermissionRequested` handler for the
+//! clipboard only (`wry-0.55.1/src/webview2/mod.rs:497-515`). Microphone and
+//! camera fall through to WebView2's own prompt, and a "Block" there is saved
+//! in the profile and never asked again (tauri-apps/tauri#5042). The handler
+//! below answers both kinds itself, without saving the answer.
+//! - Linux, WebKitGTK: wry turns on WebGL and Web Audio but not media streams,
+//! and connects no `permission-request` handler
+//! (`wry-0.55.1/src/webkitgtk/mod.rs:427-449`). WebKitGTK then refuses every
+//! `getUserMedia` call. The block below turns media streams on and answers
+//! user-media requests.
+//! - macOS, WKWebView: wry already grants media capture in its UI delegate
+//! (`wry-0.55.1/src/wkwebview/class/wry_web_view_ui_delegate.rs:126-136`),
+//! so the operating system is the only gate. That gate needs the usage texts
+//! in `Info.plist` and, once the app is signed with the hardened runtime, the
+//! entitlements in `Entitlements.plist`. No Rust is involved.
+//!
+//! Only one origin is ever granted: the loopback backend this process started,
+//! scheme, host and port all equal. The port is read when the request
+//! arrives, not when the window opens, because the backend can restart on a
+//! different port (a runtime swap) and the page follows it there. Any other
+//! page that ends up in the window, including the bundled setup page, is
+//! refused.
+
+/// Whether a page at `request_uri` may use the microphone and camera, given
+/// the backend this process is running at `backend_url`.
+///
+/// A pure function of two strings so the rule itself is tested on every host,
+/// although the two handlers that call it each compile on one platform only.
+#[cfg(any(windows, target_os = "linux", test))]
+pub(crate) fn origin_allowed(request_uri: &str, backend_url: &str) -> bool {
+ let (Ok(request), Ok(backend)) = (
+ reqwest::Url::parse(request_uri),
+ reqwest::Url::parse(backend_url),
+ ) else {
+ return false;
+ };
+ // The backend is always plain http on 127.0.0.1 (start_backend). Anything
+ // else in that slot means the state is not what this rule was written
+ // for, so grant nothing rather than guess.
+ if backend.scheme() != "http" || backend.host_str() != Some("127.0.0.1") {
+ return false;
+ }
+ request.scheme() == backend.scheme()
+ && request.host_str() == backend.host_str()
+ && request.port_or_known_default() == backend.port_or_known_default()
+}
+
+/// The lookup the handlers use for the backend's current URL. `None` while no
+/// backend is running, which denies every request.
+#[cfg(any(windows, target_os = "linux"))]
+type BackendUrl = std::sync::Arc Option + Send + Sync>;
+
+/// Attaches the media permission handler to `window`.
+///
+/// Best effort: a failure is logged and the window keeps working, with
+/// recording left to the webview's default behaviour.
+#[cfg(any(windows, target_os = "linux"))]
+pub(crate) fn install(window: &tauri::WebviewWindow, backend_url: F)
+where
+ F: Fn() -> Option + Send + Sync + 'static,
+{
+ let backend_url: BackendUrl = std::sync::Arc::new(backend_url);
+ if let Err(e) = window.with_webview(move |webview| attach(webview, backend_url)) {
+ eprintln!("[stemdeck] could not reach the webview for media permissions: {e}");
+ }
+}
+
+/// macOS needs no handler: wry grants media capture and the operating system
+/// asks the user, using the texts in Info.plist.
+#[cfg(target_os = "macos")]
+pub(crate) fn install(_window: &tauri::WebviewWindow, _backend_url: F)
+where
+ F: Fn() -> Option + Send + Sync + 'static,
+{
+}
+
+#[cfg(windows)]
+fn attach(webview: tauri::webview::PlatformWebview, backend_url: BackendUrl) {
+ use webview2_com::{
+ take_pwstr,
+ Microsoft::Web::WebView2::Win32::{
+ ICoreWebView2PermissionRequestedEventArgs3, COREWEBVIEW2_PERMISSION_KIND,
+ COREWEBVIEW2_PERMISSION_KIND_CAMERA, COREWEBVIEW2_PERMISSION_KIND_MICROPHONE,
+ COREWEBVIEW2_PERMISSION_STATE_ALLOW, COREWEBVIEW2_PERMISSION_STATE_DENY,
+ },
+ PermissionRequestedEventHandler,
+ };
+ use windows_core::{Interface, PWSTR};
+
+ let handler = PermissionRequestedEventHandler::create(Box::new(move |_, args| {
+ let Some(args) = args else { return Ok(()) };
+ let mut kind = COREWEBVIEW2_PERMISSION_KIND::default();
+ // SAFETY: `args` is the live event argument WebView2 handed to this
+ // callback, and `kind` is a valid out pointer for its duration.
+ unsafe { args.PermissionKind(&mut kind)? };
+ if kind != COREWEBVIEW2_PERMISSION_KIND_MICROPHONE
+ && kind != COREWEBVIEW2_PERMISSION_KIND_CAMERA
+ {
+ // Every other kind keeps WebView2's default, as before this
+ // handler existed. wry's own clipboard handler still runs.
+ return Ok(());
+ }
+ let mut uri = PWSTR::null();
+ // SAFETY: as above; WebView2 allocates the string with CoTaskMemAlloc
+ // and take_pwstr frees it.
+ unsafe { args.Uri(&mut uri)? };
+ let uri = take_pwstr(uri);
+ let allowed = backend_url().is_some_and(|backend| origin_allowed(&uri, &backend));
+ // Never save the answer in the profile. A saved state is what turned
+ // one "Block" into a permanent denial (tauri#5042), and the origin's
+ // port changes between launches anyway. Older runtimes without the
+ // third interface simply keep their default.
+ if let Ok(args3) = args.cast::() {
+ // SAFETY: a successful cast of the same live argument object.
+ unsafe { args3.SetSavesInProfile(false)? };
+ }
+ let state = if allowed {
+ COREWEBVIEW2_PERMISSION_STATE_ALLOW
+ } else {
+ COREWEBVIEW2_PERMISSION_STATE_DENY
+ };
+ // SAFETY: as above.
+ unsafe { args.SetState(state) }
+ }));
+
+ let mut token = 0i64;
+ // SAFETY: the controller belongs to this window's webview and is used on
+ // the thread with_webview runs its closure on (the main thread).
+ let added = unsafe {
+ webview
+ .controller()
+ .CoreWebView2()
+ .and_then(|core| core.add_PermissionRequested(&handler, &mut token))
+ };
+ if let Err(e) = added {
+ eprintln!("[stemdeck] could not install the media permission handler: {e}");
+ }
+}
+
+#[cfg(target_os = "linux")]
+fn attach(webview: tauri::webview::PlatformWebview, backend_url: BackendUrl) {
+ use webkit2gtk::{
+ glib::{prelude::*, translate::ToGlibPtr},
+ PermissionRequestExt, SettingsExt, UserMediaPermissionRequest,
+ UserMediaPermissionRequestExt, WebViewExt,
+ };
+
+ let view = webview.inner();
+ // Off by default in WebKitGTK and not turned on by wry. Without it
+ // navigator.mediaDevices.getUserMedia does not work at all.
+ if let Some(settings) = WebViewExt::settings(&view) {
+ settings.set_enable_media_stream(true);
+ }
+
+ view.connect_permission_request(move |view, request| {
+ let Some(media) = request.downcast_ref::() else {
+ // Not a microphone or camera request: leave it to WebKitGTK's
+ // default, exactly as before this handler existed.
+ return false;
+ };
+ // Screen capture arrives as the same request type. Recording never
+ // asks for it, so it is never granted.
+ // SAFETY: `media` is a live WebKitUserMediaPermissionRequest borrowed
+ // for the duration of this signal; the call only reads it. Available
+ // since WebKitGTK 2.34; this crate is built with v2_38, as wry is.
+ let display = unsafe {
+ webkit2gtk::ffi::webkit_user_media_permission_is_for_display_device(
+ media.to_glib_none().0,
+ )
+ } != 0;
+ let device = media.is_for_audio_device() || media.is_for_video_device();
+ let allowed = !display
+ && device
+ && view
+ .uri()
+ .zip(backend_url())
+ .is_some_and(|(uri, backend)| origin_allowed(&uri, &backend));
+ if allowed {
+ request.allow();
+ } else {
+ request.deny();
+ }
+ true
+ });
+}
+
+#[cfg(test)]
+mod tests {
+ use super::origin_allowed;
+
+ const BACKEND: &str = "http://127.0.0.1:8123";
+
+ #[test]
+ fn the_backend_page_is_allowed() {
+ assert!(origin_allowed("http://127.0.0.1:8123/", BACKEND));
+ assert!(origin_allowed(
+ "http://127.0.0.1:8123/studio?job=abc#lane",
+ BACKEND
+ ));
+ }
+
+ #[test]
+ fn a_different_port_is_refused() {
+ // Another local server, or the backend from an earlier launch.
+ assert!(!origin_allowed("http://127.0.0.1:8124/", BACKEND));
+ assert!(!origin_allowed("http://127.0.0.1/", BACKEND));
+ }
+
+ #[test]
+ fn https_and_other_hosts_are_refused() {
+ // The LAN https listener serves phones, never this window.
+ assert!(!origin_allowed("https://127.0.0.1:8123/", BACKEND));
+ assert!(!origin_allowed("http://localhost:8123/", BACKEND));
+ assert!(!origin_allowed("http://192.168.1.10:8123/", BACKEND));
+ assert!(!origin_allowed("http://example.com:8123/", BACKEND));
+ }
+
+ #[test]
+ fn userinfo_cannot_disguise_another_host() {
+ assert!(!origin_allowed("http://127.0.0.1:8123@evil.test/", BACKEND));
+ }
+
+ #[test]
+ fn the_bundled_setup_page_is_refused() {
+ assert!(!origin_allowed(
+ "http://tauri.localhost/index.html",
+ BACKEND
+ ));
+ assert!(!origin_allowed("tauri://localhost/index.html", BACKEND));
+ }
+
+ #[test]
+ fn garbage_is_refused() {
+ assert!(!origin_allowed("", BACKEND));
+ assert!(!origin_allowed("not a url", BACKEND));
+ assert!(!origin_allowed("about:blank", BACKEND));
+ assert!(!origin_allowed("http://127.0.0.1:8123/", "not a url"));
+ }
+
+ #[test]
+ fn a_backend_that_is_not_loopback_http_grants_nothing() {
+ assert!(!origin_allowed(
+ "https://127.0.0.1:8123/",
+ "https://127.0.0.1:8123"
+ ));
+ assert!(!origin_allowed(
+ "http://example.com:8123/",
+ "http://example.com:8123"
+ ));
+ }
+}
diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json
index 8ad86950..fdb49f9c 100644
--- a/desktop/src-tauri/tauri.conf.json
+++ b/desktop/src-tauri/tauri.conf.json
@@ -33,6 +33,7 @@
"icon": ["icons/icon.icns", "icons/icon.png", "icons/icon.ico"],
"macOS": {
"minimumSystemVersion": "13.0",
+ "entitlements": "Entitlements.plist",
"dmg": {
"windowSize": {
"width": 660,
From a31456a7c9d17deee6fb34ebb96fbe6a62b3c156 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Mon, 5 Oct 2026 20:21:49 +0100
Subject: [PATCH 002/300] feat(takes): store recorded takes and tracks, and
render take videos
Add the takes API: chunked upload of audio and camera parts, recording
tracks that own takes, alignment from the capture sidecar, and takes as
lanes in the mix export. Exports now pass a limiter at -1 dBFS so summed
lanes cannot clip. A take with a camera recording can be rendered to MP4
over the backing mix.
---
app/api/jobs.py | 3 +
app/api/router.py | 2 +
app/api/stems.py | 119 +++-
app/api/takes.py | 1199 +++++++++++++++++++++++++++++++++++
app/core/config.py | 33 +
app/core/models.py | 10 +
app/core/registry.py | 22 +
app/core/takes.py | 230 +++++++
app/pipeline/collect.py | 19 +-
app/pipeline/take_render.py | 442 +++++++++++++
tests/test_takes_api.py | 629 ++++++++++++++++++
tests/test_takes_render.py | 408 ++++++++++++
tests/test_tracks_api.py | 387 +++++++++++
13 files changed, 3491 insertions(+), 12 deletions(-)
create mode 100644 app/api/takes.py
create mode 100644 app/core/takes.py
create mode 100644 app/pipeline/take_render.py
create mode 100644 tests/test_takes_api.py
create mode 100644 tests/test_takes_render.py
create mode 100644 tests/test_tracks_api.py
diff --git a/app/api/jobs.py b/app/api/jobs.py
index af132f5b..260d90ee 100644
--- a/app/api/jobs.py
+++ b/app/api/jobs.py
@@ -18,6 +18,7 @@
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, Field, StrictBool, field_validator
+from app.api.takes import cancel_job_renders
from app.core.config import (
DISCOGS_LOOKUP_BUDGET_SEC,
JOB_ID_RE,
@@ -1855,6 +1856,8 @@ def delete_job(job_id: str) -> dict[str, str]:
if not wait_run_ended(job_id, RETIME_DELETE_WAIT_SEC):
logger.warning("[%s] lyrics timing still stopping; removing files anyway", job_id)
forget_run(job_id)
+ # A video render of one of its takes writes into the folder about to go.
+ cancel_job_renders(job_id)
# A fingerprint of its audio may still have a file open (Windows).
release_source(job_id)
removed = _rmtree_job(job_id)
diff --git a/app/api/router.py b/app/api/router.py
index f44d7639..518bceaa 100644
--- a/app/api/router.py
+++ b/app/api/router.py
@@ -11,10 +11,12 @@
from app.api.queue import router as queue_router
from app.api.search import router as search_router
from app.api.stems import router as stems_router
+from app.api.takes import router as takes_router
router = APIRouter()
router.include_router(config_router, tags=["config"])
router.include_router(jobs_router, prefix="/jobs", tags=["jobs"])
+router.include_router(takes_router, prefix="/jobs", tags=["takes"])
router.include_router(events_router, tags=["events"])
router.include_router(stems_router, tags=["stems"])
router.include_router(qr_router, tags=["qr"])
diff --git a/app/api/stems.py b/app/api/stems.py
index b031ee8f..a5e0c4fa 100644
--- a/app/api/stems.py
+++ b/app/api/stems.py
@@ -24,14 +24,18 @@
JOB_ID_RE,
JOBS_DIR,
STEM_NAMES,
+ TAKE_ID_RE,
TIMEOUT_FFMPEG,
+ TRACK_ID_RE,
ffmpeg_executable,
)
from app.core.registry import get as registry_get
from app.core.settings import get_export_sample_rate
+from app.core.takes import take_dir
from app.pipeline.audio_stats import vocal_envelope
from app.pipeline.click_render import cache_key as click_cache_key
from app.pipeline.click_render import count_in_beats, render_click_wav, render_count_in_wav
+from app.pipeline.take_render import EXPORT_LIMITER
logger = logging.getLogger("stemdeck.api")
@@ -50,6 +54,10 @@
# endpoint replaces. Gains are linear; the studio caps a lane at 2.0, so this
# generous bound just rejects abusive values.
_MIXDOWN_NAMES = frozenset(STEM_NAMES) | frozenset(EXTRA_STEM_NAMES) | {"original"}
+# A recorded take is a lane too, named by its id (^t[0-9a-f]{8}$, never a stem
+# name) and resolved to takes//audio.wav, which is already in the
+# stems' format, aligned to song 0 and the song's length: export applies no
+# offset and no resample to it.
_MIXDOWN_MAX_GAIN = 4.0
# lead_vocals/backing_vocals are a decomposition of vocals, not an independent
# signal -- summing vocals alongside either would double-count the vocal
@@ -109,7 +117,10 @@
# is still reachable, and _prune_mixdown_cache evicts by age rather than
# validity -- so without this a user who exported before upgrading would be
# handed the same broken file back for the same parameters indefinitely.
-_RENDER_CACHE_VERSION = "2"
+#
+# "3": every mixdown now ends in the export limiter (EXPORT_LIMITER), so a mix
+# that summed above -1 dBFS renders differently from the cached clipped file.
+_RENDER_CACHE_VERSION = "3"
def _mixdown_cache_key(
@@ -121,6 +132,7 @@ def _mixdown_cache_key(
end: float | None,
click_lane: tuple[Path, float] | None = None,
pitches: list[int] | None = None,
+ lane_versions: list[str] | None = None,
) -> str:
"""Every render input is in the key, so a different mixer state, region,
or a Settings change (export sample rate) misses cleanly -- never a stale
@@ -145,6 +157,10 @@ def _mixdown_cache_key(
# export made before #592 keeps the exact key it already had and
# the cache survives the change.
"" if not (pitches and any(pitches)) else "p" + ",".join(str(p) for p in pitches),
+ # A take is not immutable like a stem: a nudge re-renders its
+ # audio.wav under the same name. Its size and mtime pin the file
+ # the render read, so a nudged take misses the cache.
+ ",".join(lane_versions or []),
]
)
# Cache key, not a security context -- usedforsecurity=False documents
@@ -192,6 +208,60 @@ def _prune_mixdown_cache(cache_dir: Path, keep: Path | None = None) -> None:
logger.debug("mixdown cache prune: could not remove %s", oldest, exc_info=True)
+def _is_recorded_lane(name: str) -> bool:
+ """A take id or a recording track id: lanes resolved to a take's file."""
+ return bool(TAKE_ID_RE.match(name) or TRACK_ID_RE.match(name))
+
+
+def _lane_take_id(job_id: str, name: str) -> str:
+ """The take a recorded lane plays: a take id is itself, a track id is its
+ active take. A track with no take yet is a 422, like an unknown name."""
+ if not JOB_ID_RE.match(job_id):
+ raise HTTPException(status_code=404, detail="job not found")
+ job = registry_get(job_id)
+ if job is None or job.status != "done":
+ raise HTTPException(status_code=404, detail="job not ready")
+ if TRACK_ID_RE.match(name):
+ track = next((t for t in job.tracks if t.get("id") == name), None)
+ if track is None:
+ raise HTTPException(status_code=422, detail="unknown stem requested")
+ if not track.get("active_take_id"):
+ raise HTTPException(status_code=422, detail="track has no take")
+ return str(track["active_take_id"])
+ return name
+
+
+def _take_lane_path(job_id: str, name: str) -> Path:
+ """A take or track lane's audio.wav, with the same guards as a stem. Only
+ a finished take (one listed in job.takes) is a lane; anything else is the
+ same 422 as an unknown stem name."""
+ name = _lane_take_id(job_id, name)
+ job = registry_get(job_id)
+ if not TAKE_ID_RE.match(name) or not any(t.get("id") == name for t in job.takes):
+ raise HTTPException(status_code=422, detail="unknown stem requested")
+ folder = take_dir(job_id, name, JOBS_DIR)
+ if folder is None:
+ raise HTTPException(status_code=404, detail="stem not found")
+ path = (folder / "audio.wav").resolve()
+ if not path.is_file() or not path.is_relative_to(JOBS_DIR.resolve()):
+ raise HTTPException(status_code=404, detail="stem not found")
+ return path
+
+
+def _take_versions(job_id: str, names: list[str]) -> list[str]:
+ """Cache-key fragments for the recorded lanes in `names`, the only lanes
+ whose file can change in place. A track's fragment names its active take,
+ so switching the active take misses the cache."""
+ out: list[str] = []
+ for name in names:
+ if not _is_recorded_lane(name):
+ continue
+ take_id = _lane_take_id(job_id, name)
+ st = _take_lane_path(job_id, name).stat()
+ out.append(f"{name}={take_id}@{st.st_size}:{st.st_mtime_ns}")
+ return out
+
+
def _validate_stem_path(job_id: str, name: str):
"""Shared guard: validate job_id, name, job state, and path. Returns resolved Path."""
if not JOB_ID_RE.match(job_id):
@@ -514,7 +584,7 @@ def _parse_lane_gains(stems: str, gains: str) -> tuple[list[str], list[float]]:
raise HTTPException(status_code=422, detail="gains must be numbers") from None
if any(g < 0 or g > _MIXDOWN_MAX_GAIN for g in parsed_gains):
raise HTTPException(status_code=422, detail="gain out of range")
- if not set(names) <= _MIXDOWN_NAMES:
+ if not all(n in _MIXDOWN_NAMES or _is_recorded_lane(n) for n in names):
raise HTTPException(status_code=422, detail="unknown stem requested")
if "vocals" in names and _VOCAL_DECOMPOSITION_NAMES & set(names):
raise HTTPException(
@@ -567,6 +637,9 @@ def _expand_render_lanes(
# never produced would 404 untransposed and be synthesised from
# components when transposed, so the same URL was valid or not
# depending on the pitch attached to it.
+ if _is_recorded_lane(name):
+ lanes.append(_RenderLane(_take_lane_path(job_id, name), gain, pitch))
+ continue
path = _validate_stem_path(job_id, name)
if name != "original" or pitch == 0:
lanes.append(_RenderLane(path, gain, pitch))
@@ -1184,7 +1257,15 @@ async def get_mixdown(
groups=_parse_groups(click_groups, click_accent),
)
cache_key = _mixdown_cache_key(
- job_id, ext, names, parsed_gains, start, end, click_lane, parsed_pitches
+ job_id,
+ ext,
+ names,
+ parsed_gains,
+ start,
+ end,
+ click_lane,
+ parsed_pitches,
+ _take_versions(job_id, names),
)
cache_path = _MIXDOWN_CACHE_DIR / f"{cache_key}.{ext}"
if cache_path.is_file():
@@ -1224,6 +1305,11 @@ async def get_mixdown(
# sum with amix (normalize=0 keeps levels faithful, matching collect.py). The
# click lane is never delayed -- its lead-in is baked in. A single audible
# lane skips amix (a 1-input amix is a no-op).
+ #
+ # Then the export limiter, always. Six stems at unity summed to +0.9 dBFS
+ # on a real song and the encoder clamped 77 samples to full scale; the
+ # limiter holds the peak at -1 dBFS and is bit-transparent below it, so an
+ # export that never reached the ceiling is unchanged (EXPORT_LIMITER).
filters = []
for i, lane in enumerate(render_lanes):
delay = f"adelay={delay_ms}:all=1," if delay_ms > 0 else ""
@@ -1235,10 +1321,10 @@ async def get_mixdown(
n = stem_count + (1 if click_lane is not None else 0)
if n > 1:
labels = "".join(f"[a{i}]" for i in range(n))
- filters.append(f"{labels}amix=inputs={n}:normalize=0[mix]")
- out_label = "[mix]"
+ filters.append(f"{labels}amix=inputs={n}:normalize=0,{EXPORT_LIMITER}[mix]")
else:
- out_label = "[a0]"
+ filters.append(f"[a0]{EXPORT_LIMITER}[mix]")
+ out_label = "[mix]"
codec = MIXDOWN_CODECS[ext]
# Resample to the user's chosen export rate (default 44.1 kHz = the stem rate,
# so a no-op unless changed). Applies to every audio container -- some hardware
@@ -1362,12 +1448,14 @@ async def get_video_mixdown(
for i, lane in enumerate(render_lanes)
]
n = len(paths)
+ # Same limiter as get_mixdown: this is the same mix, so it must not clip
+ # where the audio export no longer does.
if n > 1:
labels = "".join(f"[a{i}]" for i in range(n))
- filters.append(f"{labels}amix=inputs={n}:normalize=0[mix]")
- out_label = "[mix]"
+ filters.append(f"{labels}amix=inputs={n}:normalize=0,{EXPORT_LIMITER}[mix]")
else:
- out_label = "[a0]"
+ filters.append(f"[a0]{EXPORT_LIMITER}[mix]")
+ out_label = "[mix]"
cmd += [
"-filter_complex",
";".join(filters),
@@ -1463,9 +1551,13 @@ async def get_all_stems_zip(
raise HTTPException(status_code=404, detail="job not ready")
# Resolve the requested subset (whitelisted) or fall back to all stems.
+ # Takes go in only when asked for by id: "all stems" means the separation.
all_names = (*STEM_NAMES, *EXTRA_STEM_NAMES)
+ take_names: list[str] = []
if stems:
requested = {s for s in stems.split(",") if s}
+ take_names = sorted(n for n in requested if _is_recorded_lane(n))
+ requested -= set(take_names)
if not requested <= set(all_names):
raise HTTPException(status_code=422, detail="unknown stem requested")
if "vocals" in requested and _VOCAL_DECOMPOSITION_NAMES & requested:
@@ -1487,6 +1579,15 @@ async def get_all_stems_zip(
p = (stems_dir / f"{name}.wav").resolve()
if p.is_file() and p.is_relative_to(jobs_root):
sources.append((name, p))
+ labels = {t.get("id"): t.get("label") for t in job.takes}
+ labels.update({t.get("id"): t.get("name") for t in job.tracks})
+ for lane in take_names:
+ path = _take_lane_path(job_id, lane)
+ # Named for the label (take) or name (track) the user gave it, with
+ # the id to keep two called "Take 1" apart. _title_slug keeps it to
+ # [A-Za-z0-9_].
+ label = _title_slug(labels.get(lane))
+ sources.append((f"{label}_{lane}" if label else lane, path))
if not sources:
raise HTTPException(status_code=404, detail="no stems found")
diff --git a/app/api/takes.py b/app/api/takes.py
new file mode 100644
index 00000000..43339392
--- /dev/null
+++ b/app/api/takes.py
@@ -0,0 +1,1199 @@
+"""Recorded takes: upload, finish, edit, delete, serve, and the video render.
+
+Contract (agreed with the recording and video frontends):
+
+ POST /api/jobs/{id}/takes create, 201 {take_id, label}
+ PUT /api/jobs/{id}/takes/{take_id}/parts/{kind}?seq=n raw bytes, kind audio|camera
+ GET /api/jobs/{id}/takes/{take_id}/parts upload state, to resync
+ POST /api/jobs/{id}/takes/{take_id}/finish sidecar -> audio.wav, job.takes
+ PATCH /api/jobs/{id}/takes/{take_id} label, user_nudge_ms, camera_offset_ms
+ DELETE /api/jobs/{id}/takes/{take_id}
+ GET /api/jobs/{id}/takes/{take_id}/audio.wav (and HEAD)
+ GET /api/jobs/{id}/takes/{take_id}/camera
+ POST /api/jobs/{id}/takes/{take_id}/render 202, background video render
+ GET /api/jobs/{id}/takes/{take_id}/render progress
+ POST /api/jobs/{id}/takes/{take_id}/render/cancel
+ GET /api/jobs/{id}/takes/{take_id}/render.mp4
+
+Placement: the sidecar's timeline.first_sample_song_sec already has the
+round-trip latency folded in (the browser knows it); the server applies the
+user's nudge, and is the only place that does, so changing it later can
+re-render from the raw capture:
+
+ placement_sec = first_sample_song_sec - user_nudge_ms / 1000
+
+Positive means late, subtract: the same sign as every latency in the capture
+layer.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import os
+import re
+import secrets
+import shutil
+import subprocess
+import threading
+import time
+import uuid
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Literal
+
+from fastapi import APIRouter, HTTPException, Query, Request
+from fastapi.responses import FileResponse, JSONResponse
+from pydantic import BaseModel, ConfigDict, Field, StrictBool, ValidationError, field_validator
+
+from app.api import stems as stems_api
+from app.core.config import (
+ EXTRA_STEM_NAMES,
+ JOB_ID_RE,
+ JOBS_DIR,
+ STEM_NAMES,
+ TAKE_AUDIO_MAX_BYTES_PER_SEC,
+ TAKE_CAMERA_MAX_BYTES_PER_SEC,
+ TAKE_ID_RE,
+ TAKE_MAX_PER_JOB,
+ TAKE_MAX_SEQ,
+ TAKE_PART_MAX_BYTES,
+ TAKE_SLACK_SEC,
+ TAKES_DIRNAME,
+ TIMEOUT_VIDEO_RENDER,
+ TRACK_ID_RE,
+ TRACK_MAX_PER_JOB,
+)
+from app.core.models import Job, _set
+from app.core.registry import get as registry_get
+from app.core.registry import persist as registry_persist
+from app.core.settings import get_export_sample_rate
+from app.core.takes import (
+ camera_file,
+ make_track,
+ new_track_id,
+ normalise_tracks,
+ take_dir,
+ take_entry,
+ write_tracks,
+)
+from app.pipeline.collect import merge_stem_peaks
+from app.pipeline.runner import _pipeline_lock
+from app.pipeline.take_render import (
+ TAKE_RATE,
+ AudioLane,
+ RenderCancelled,
+ TakeRenderError,
+ aac_available,
+ assemble_raw_wav,
+ build_video_render_cmd,
+ libx264_available,
+ render_take_audio,
+ run_video_render,
+ sniff_container,
+ wav_frames,
+)
+
+logger = logging.getLogger("stemdeck.api")
+
+router = APIRouter(tags=["takes"])
+
+PartKind = Literal["audio", "camera"]
+_PART_KINDS = ("audio", "camera")
+_STATE_FILE = "parts.json"
+_SIDECAR_FILE = "take.json"
+_RENDER_FILE = "render.mp4"
+_LABEL_MAX = 80
+_SESSION_MAX_BYTES = 8 * 1024
+_NUDGE_MAX_MS = 2000.0
+_CAMERA_OFFSET_MAX_MS = 5000.0
+_RENDER_MAX_LANES = 32
+_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
+
+# One lock per take: parts, finish, edit and delete of the same take never
+# interleave, so the append offset and parts.json always agree. Keyed by
+# "/"; at most TAKE_MAX_PER_JOB per job, dropped on delete.
+_take_locks: dict[str, asyncio.Lock] = {}
+
+
+def _lock_for(job_id: str, take_id: str) -> asyncio.Lock:
+ key = f"{job_id}/{take_id}"
+ lock = _take_locks.get(key)
+ if lock is None:
+ lock = _take_locks[key] = asyncio.Lock()
+ return lock
+
+
+# ---------------------------------------------------------------------------
+# Validation helpers. Order, per the security skill: job id, then take id and
+# kind whitelist, then the resolved path inside JOBS_DIR, then any I/O.
+
+
+def _job_or_404(job_id: str) -> Job:
+ if not JOB_ID_RE.match(job_id):
+ raise HTTPException(status_code=404, detail="job not found")
+ job = registry_get(job_id)
+ if job is None or job.status != "done":
+ raise HTTPException(status_code=404, detail="job not ready")
+ return job
+
+
+def _take_or_404(job_id: str, take_id: str) -> tuple[Job, Path]:
+ job = _job_or_404(job_id)
+ if not TAKE_ID_RE.match(take_id):
+ raise HTTPException(status_code=404, detail="take not found")
+ folder = take_dir(job_id, take_id, JOBS_DIR)
+ if folder is None or not folder.is_dir():
+ raise HTTPException(status_code=404, detail="take not found")
+ return job, folder
+
+
+def _entry(job: Job, take_id: str) -> dict[str, Any] | None:
+ return next((t for t in job.takes if t.get("id") == take_id), None)
+
+
+def _finished_or_404(job_id: str, take_id: str) -> tuple[Job, Path, dict[str, Any]]:
+ job, folder = _take_or_404(job_id, take_id)
+ entry = _entry(job, take_id)
+ if entry is None:
+ raise HTTPException(status_code=404, detail="take not finished")
+ return job, folder, entry
+
+
+def _clean_label(value: str | None) -> str | None:
+ if value is None:
+ return None
+ value = value.strip()
+ if not value or len(value) > _LABEL_MAX or _CONTROL_CHARS.search(value):
+ raise ValueError(f"label must be 1 to {_LABEL_MAX} printable characters")
+ return value
+
+
+def _reject_constant(token: str) -> float:
+ raise ValueError(f"{token} is not a number")
+
+
+async def _parse_body(request: Request, model: type[BaseModel], *, empty_ok: bool = False):
+ """Parse a JSON body into `model`, after the ids have been checked.
+
+ Not a FastAPI body parameter, for two reasons. Order: a body parameter is
+ validated before the handler runs, so a bad body on an unknown job was a
+ 422 instead of a 404. And NaN: json accepts the NaN and Infinity tokens,
+ and FastAPI's 422 then echoes the offending input back, which it cannot
+ serialise, so a NaN in a sidecar came back as a 500. Refused here, at
+ parse time, and the 422 detail never repeats the input."""
+ raw = await request.body()
+ if not raw.strip():
+ if empty_ok:
+ return model()
+ raise HTTPException(status_code=422, detail="request body required")
+ try:
+ data = json.loads(raw, parse_constant=_reject_constant)
+ except ValueError:
+ raise HTTPException(
+ status_code=422, detail="body must be JSON with finite numbers"
+ ) from None
+ try:
+ return model.model_validate(data)
+ except ValidationError as exc:
+ detail = "; ".join(
+ f"{'.'.join(str(p) for p in err['loc']) or 'body'}: {err['msg']}"
+ for err in exc.errors(include_input=False, include_url=False)[:5]
+ )
+ raise HTTPException(status_code=422, detail=detail[:500]) from None
+
+
+# ---------------------------------------------------------------------------
+# Small file helpers, all blocking.
+
+
+def _write_json_atomic(path: Path, data: dict) -> None:
+ tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
+ try:
+ tmp.write_text(json.dumps(data, indent=2, allow_nan=False) + "\n", encoding="utf-8")
+ os.replace(tmp, path)
+ finally:
+ tmp.unlink(missing_ok=True)
+
+
+def _read_json(path: Path) -> dict[str, Any]:
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ logger.warning("could not read %s", path, exc_info=True)
+ raise HTTPException(status_code=500, detail="take is unreadable") from None
+ if not isinstance(data, dict):
+ raise HTTPException(status_code=500, detail="take is unreadable")
+ return data
+
+
+def _new_state(label: str) -> dict[str, Any]:
+ return {
+ "label": label,
+ "created_at": time.time(),
+ "finished": False,
+ **{kind: {"next_seq": 0, "bytes": 0} for kind in _PART_KINDS},
+ }
+
+
+def _reference_stem(job_id: str) -> Path | None:
+ """A stem whose length every take is cut to. Any of them: the pipeline
+ writes all of them from one Demucs pass, so they share a frame count."""
+ stems_dir = (JOBS_DIR / job_id / "stems").resolve()
+ if not stems_dir.is_relative_to(JOBS_DIR.resolve()):
+ return None
+ for name in (*STEM_NAMES, *EXTRA_STEM_NAMES, "original"):
+ path = stems_dir / f"{name}.wav"
+ if path.is_file():
+ return path
+ return None
+
+
+def _song_frames(job_id: str) -> int:
+ ref = _reference_stem(job_id)
+ if ref is None:
+ raise HTTPException(status_code=404, detail="stems not found")
+ return wav_frames(ref)
+
+
+def _size_cap(job: Job, kind: str) -> int:
+ seconds = (job.duration_sec or 3600.0) + TAKE_SLACK_SEC
+ per_sec = TAKE_AUDIO_MAX_BYTES_PER_SEC if kind == "audio" else TAKE_CAMERA_MAX_BYTES_PER_SEC
+ return int(seconds * per_sec)
+
+
+def _placement_sec(sidecar: dict[str, Any]) -> float:
+ timeline = sidecar.get("timeline") or {}
+ latency = sidecar.get("latency") or {}
+ return float(timeline.get("first_sample_song_sec") or 0.0) - (
+ float(latency.get("user_nudge_ms") or 0.0) / 1000.0
+ )
+
+
+def _refresh_peaks(job_id: str, take_id: str, folder: Path, *, remove: bool = False) -> None:
+ stems_dir = (JOBS_DIR / job_id / "stems").resolve()
+ if not stems_dir.is_dir() or not stems_dir.is_relative_to(JOBS_DIR.resolve()):
+ return
+ if remove:
+ merge_stem_peaks(stems_dir, [], remove=[take_id])
+ else:
+ merge_stem_peaks(stems_dir, [take_id], sources={take_id: folder / "audio.wav"})
+
+
+def _commit(job: Job, takes: list[dict[str, Any]], tracks: list[dict[str, Any]]) -> None:
+ """Write tracks and takes together: tracks.json first (the file wins on
+ load), then the job, through _set so the SSE stream sees one change, then
+ the registry. Synchronous and free of awaits on purpose: every read and
+ write of job.tracks happens inside one event-loop step, so two requests
+ can never interleave a read-modify-write of the track list."""
+ tracks = normalise_tracks(tracks, takes)
+ if tracks != job.tracks:
+ write_tracks(JOBS_DIR / job.id, tracks)
+ _set(job, takes=takes, tracks=tracks)
+ registry_persist(JOBS_DIR)
+
+
+def _replace_entry(
+ job: Job, entry: dict[str, Any] | None, take_id: str, *, activate: bool = False
+) -> None:
+ """Swap one take's record in job.takes (None removes it) and persist.
+
+ With `activate`, the take becomes its track's active take, creating the
+ track when it is missing (a take opened before tracks existed). Removing
+ the active take falls back to the track's newest remaining one."""
+ takes = [t for t in job.takes if t.get("id") != take_id]
+ tracks = [dict(t) for t in job.tracks]
+ if entry is not None:
+ takes.append(entry)
+ takes.sort(key=lambda t: t.get("created_at") or 0.0)
+ if activate:
+ track = next((t for t in tracks if t["id"] == entry.get("track_id")), None)
+ if track is None:
+ track = make_track(entry["track_id"], entry["label"], len(tracks))
+ tracks.append(track)
+ track["active_take_id"] = take_id
+ _commit(job, takes, tracks)
+
+
+def _track_or_404(job: Job, track_id: str) -> dict[str, Any]:
+ if not TRACK_ID_RE.match(track_id):
+ raise HTTPException(status_code=404, detail="track not found")
+ track = next((t for t in job.tracks if t.get("id") == track_id), None)
+ if track is None:
+ raise HTTPException(status_code=404, detail="track not found")
+ return track
+
+
+# ---------------------------------------------------------------------------
+# Create
+
+
+class CreateTakeBody(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ track_id: str | None = Field(default=None, max_length=16)
+ label: str | None = Field(default=None, max_length=_LABEL_MAX * 4)
+
+ @field_validator("label")
+ @classmethod
+ def _label(cls, v: str | None) -> str | None:
+ return _clean_label(v)
+
+
+@router.post("/{job_id}/takes", status_code=201)
+async def create_take(job_id: str, request: Request) -> dict[str, str]:
+ """Open a new take folder on a recording track: {"track_id", "label"?}."""
+ job = _job_or_404(job_id)
+ body = await _parse_body(request, CreateTakeBody, empty_ok=True)
+ if body.track_id is None:
+ raise HTTPException(status_code=422, detail="track_id required")
+ if not TRACK_ID_RE.match(body.track_id) or not any(
+ t.get("id") == body.track_id for t in job.tracks
+ ):
+ raise HTTPException(status_code=422, detail="unknown track")
+ track_id = body.track_id
+ on_track = sum(1 for t in job.takes if t.get("track_id") == track_id)
+
+ root = (JOBS_DIR / job_id / TAKES_DIRNAME).resolve()
+ if not root.is_relative_to(JOBS_DIR.resolve()):
+ raise HTTPException(status_code=404, detail="job not found")
+
+ def make() -> tuple[str, str]:
+ root.mkdir(parents=True, exist_ok=True)
+ existing = [p for p in root.iterdir() if p.is_dir() and TAKE_ID_RE.match(p.name)]
+ if len(existing) >= TAKE_MAX_PER_JOB:
+ raise HTTPException(status_code=409, detail="too many takes for this song")
+ label = body.label or f"Take {on_track + 1}"
+ for _ in range(8):
+ take_id = "t" + secrets.token_hex(4)
+ folder = root / take_id
+ try:
+ folder.mkdir()
+ except FileExistsError:
+ continue
+ _write_json_atomic(folder / _STATE_FILE, {**_new_state(label), "track_id": track_id})
+ return take_id, label
+ raise HTTPException(status_code=500, detail="could not create take")
+
+ take_id, label = await asyncio.to_thread(make)
+ logger.info("[%s] take %s created on track %s", job.id, take_id, track_id)
+ return {"take_id": take_id, "label": label, "track_id": track_id}
+
+
+# ---------------------------------------------------------------------------
+# Parts
+
+
+async def _read_body_capped(request: Request) -> bytes:
+ """The raw body, refused past TAKE_PART_MAX_BYTES while it streams in.
+ The middleware already refuses a declared length over 4 MB; this covers a
+ body longer than the length it declared."""
+ chunks: list[bytes] = []
+ total = 0
+ async for chunk in request.stream():
+ total += len(chunk)
+ if total > TAKE_PART_MAX_BYTES:
+ raise HTTPException(status_code=413, detail="part too large")
+ chunks.append(chunk)
+ return b"".join(chunks)
+
+
+@router.put("/{job_id}/takes/{take_id}/parts/{kind}")
+async def put_part(
+ job_id: str,
+ take_id: str,
+ kind: str,
+ request: Request,
+ seq: int = Query(..., ge=0, le=TAKE_MAX_SEQ),
+) -> dict[str, Any]:
+ """Append one part. seq must be exactly the next one; repeating the last
+ one is a no-op, so a retry after a lost response is safe."""
+ job, folder = _take_or_404(job_id, take_id)
+ if kind not in _PART_KINDS:
+ raise HTTPException(status_code=404, detail="unknown part kind")
+ data = await _read_body_capped(request)
+ if not data:
+ raise HTTPException(status_code=422, detail="empty part")
+ if kind == "audio" and len(data) % 4:
+ raise HTTPException(status_code=422, detail="audio part must be whole float32 samples")
+
+ async with _lock_for(job_id, take_id):
+ state_path = folder / _STATE_FILE
+ state = await asyncio.to_thread(_read_json, state_path)
+ if state.get("finished"):
+ raise HTTPException(status_code=409, detail="take already finished")
+ slot = state.setdefault(kind, {"next_seq": 0, "bytes": 0})
+ next_seq = int(slot.get("next_seq", 0))
+ received = int(slot.get("bytes", 0))
+ if next_seq > 0 and seq == next_seq - 1:
+ return {
+ "take_id": take_id,
+ "kind": kind,
+ "seq": seq,
+ "bytes": received,
+ "duplicate": True,
+ }
+ if seq != next_seq:
+ raise HTTPException(status_code=409, detail="unexpected part sequence")
+ if received + len(data) > _size_cap(job, kind):
+ raise HTTPException(status_code=413, detail="take too large for this song")
+
+ def append() -> None:
+ part = folder / f"{kind}.part"
+ with part.open("ab") as fh:
+ # Cut back to what parts.json says arrived: a crash between
+ # the write and the state update must not leave the part
+ # that will be retried in the file twice.
+ fh.truncate(received)
+ fh.seek(received)
+ fh.write(data)
+ slot["next_seq"] = seq + 1
+ slot["bytes"] = received + len(data)
+ _write_json_atomic(state_path, state)
+
+ await asyncio.to_thread(append)
+ return {
+ "take_id": take_id,
+ "kind": kind,
+ "seq": seq,
+ "bytes": received + len(data),
+ "duplicate": False,
+ }
+
+
+@router.get("/{job_id}/takes/{take_id}/parts")
+async def get_parts(job_id: str, take_id: str) -> dict[str, Any]:
+ _job, folder = _take_or_404(job_id, take_id)
+ state = await asyncio.to_thread(_read_json, folder / _STATE_FILE)
+ return {
+ **{
+ kind: {
+ "next_seq": int((state.get(kind) or {}).get("next_seq", 0)),
+ "bytes": int((state.get(kind) or {}).get("bytes", 0)),
+ }
+ for kind in _PART_KINDS
+ },
+ "finished": bool(state.get("finished")),
+ }
+
+
+# ---------------------------------------------------------------------------
+# Finish: the sidecar
+
+
+def _finite(lo: float, hi: float, default: float | None = None) -> Any:
+ return Field(default=default, ge=lo, le=hi, allow_inf_nan=False)
+
+
+class TimelineModel(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+ first_sample_song_sec: float = Field(ge=-600, le=86_400, allow_inf_nan=False)
+ start_song_sec: float | None = _finite(-86_400, 86_400)
+ end_song_sec: float | None = _finite(-86_400, 86_400)
+ tempo: float = _finite(0.01, 100, 1.0)
+ count_in_sec: float | None = _finite(0, 600)
+ ctx_time_at_frame0: float | None = _finite(-1e12, 1e12)
+ perf_ms_at_frame0: float | None = _finite(-1e15, 1e15)
+
+
+class LatencyModel(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+ round_trip_ms: float | None = _finite(-10_000, 10_000)
+ source: Literal["calibrated", "estimated"] | None = None
+ output_latency_ms: float | None = _finite(-10_000, 10_000)
+ base_latency_ms: float | None = _finite(-10_000, 10_000)
+ input_latency_ms_reported: float | None = _finite(-10_000, 10_000)
+ pipeline_latency_ms: float | None = _finite(-10_000, 10_000)
+ user_nudge_ms: float = _finite(-_NUDGE_MAX_MS, _NUDGE_MAX_MS, 0.0)
+ drift_ppm: float | None = _finite(-10_000, 10_000)
+
+
+class CaptureModel(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+ rate: int = Field(ge=8000, le=192_000)
+ channels: Literal[1, 2]
+ device_label: str | None = Field(default=None, max_length=200)
+ dropped_quanta: int | None = Field(default=None, ge=0, le=10**12)
+
+
+class VideoModel(BaseModel):
+ """The camera block (06-video-recording 5.5). Absent fields take their
+ defaults: the browser omits what it did not report."""
+
+ model_config = ConfigDict(extra="ignore")
+ file: str | None = Field(default=None, max_length=100) # ignored, set by the server
+ mime: str | None = Field(default=None, max_length=100)
+ w: int | None = Field(default=None, ge=1, le=4096)
+ h: int | None = Field(default=None, ge=1, le=4096)
+ fps_nominal: float | None = _finite(1, 240)
+ deviceLabel: str | None = Field(default=None, max_length=200) # noqa: N815 -- agreed wire name
+ cameraOffsetMs: float = _finite(-_CAMERA_OFFSET_MAX_MS, _CAMERA_OFFSET_MAX_MS, 0.0) # noqa: N815
+ mirrorPreview: StrictBool | None = None # noqa: N815
+ startedAtAudioCtx: float | None = _finite(-1e12, 1e12) # noqa: N815
+ startedAtPerf: float | None = _finite(-1e15, 1e15) # noqa: N815
+ firstFrameAtMs: float = _finite(-600_000, 600_000, 0.0) # noqa: N815
+ parts: int | None = Field(default=None, ge=0, le=TAKE_MAX_SEQ + 1)
+
+
+class FinishBody(BaseModel):
+ """take.json, version 1 (05-audio-recording 5.6). id and created_at are
+ the server's and ignored when sent."""
+
+ model_config = ConfigDict(extra="ignore")
+ v: Literal[1]
+ label: str | None = Field(default=None, max_length=_LABEL_MAX * 4)
+ timeline: TimelineModel
+ latency: LatencyModel = Field(default_factory=LatencyModel)
+ capture: CaptureModel
+ video: VideoModel | None = None
+ session: dict[str, Any] | None = None
+
+ @field_validator("label")
+ @classmethod
+ def _label(cls, v: str | None) -> str | None:
+ return _clean_label(v)
+
+ @field_validator("session")
+ @classmethod
+ def _bounded_session(cls, v: dict[str, Any] | None) -> dict[str, Any] | None:
+ # Agent 7's block: stored, not interpreted here, so only its size and
+ # JSON-ness are checked (no NaN, nothing json cannot write back).
+ if v is None:
+ return v
+ try:
+ encoded = json.dumps(v, allow_nan=False)
+ except (TypeError, ValueError):
+ raise ValueError("session must be plain JSON") from None
+ if len(encoded.encode("utf-8")) > _SESSION_MAX_BYTES:
+ raise ValueError("session block too large")
+ return v
+
+
+@router.post("/{job_id}/takes/{take_id}/finish")
+async def finish_take(job_id: str, take_id: str, request: Request) -> dict[str, Any]:
+ """Close the take: build audio.raw.wav from the audio parts, name the
+ camera file, render audio.wav aligned and padded to the stems, merge its
+ peaks, write take.json, and add the lane to the job. Synchronous: a
+ 5-minute take renders in a second or two."""
+ job, folder = _take_or_404(job_id, take_id)
+ body: FinishBody = await _parse_body(request, FinishBody)
+ if body.timeline.tempo != 1.0:
+ raise HTTPException(status_code=422, detail="takes recorded at a tempo other than 1.0")
+ frames = await asyncio.to_thread(_song_frames, job_id)
+ song_sec = frames / TAKE_RATE
+ if body.timeline.first_sample_song_sec > song_sec + 600:
+ raise HTTPException(status_code=422, detail="take starts after the song")
+
+ async with _lock_for(job_id, take_id):
+ state_path = folder / _STATE_FILE
+ state = await asyncio.to_thread(_read_json, state_path)
+ if state.get("finished"):
+ raise HTTPException(status_code=409, detail="take already finished")
+ channels = body.capture.channels
+ rate = body.capture.rate
+ audio_part = folder / "audio.part"
+ raw_wav = folder / "audio.raw.wav"
+ camera_part = folder / "camera.part"
+
+ def prepare() -> str | None:
+ """Validate every input before changing any file, so a refused
+ finish can be corrected and sent again."""
+ if audio_part.is_file():
+ size = audio_part.stat().st_size
+ if size == 0:
+ raise HTTPException(status_code=422, detail="take has no audio")
+ if size % (4 * channels):
+ raise HTTPException(
+ status_code=422, detail="audio size does not match the channel count"
+ )
+ elif not raw_wav.is_file():
+ raise HTTPException(status_code=422, detail="take has no audio")
+ camera_ext = None
+ # Without the video block the camera file is not used: the
+ # frontend sends the block only when every camera part arrived,
+ # so a missing one means a file with a hole or an unknown start,
+ # which would render out of sync. It is discarded in build().
+ if body.video is not None and camera_part.is_file() and camera_part.stat().st_size > 0:
+ camera_ext = sniff_container(camera_part)
+ if camera_ext is None:
+ raise HTTPException(status_code=422, detail="camera file is not WebM or MP4")
+ return camera_ext
+
+ camera_ext = await asyncio.to_thread(prepare)
+
+ sidecar = body.model_dump(exclude_none=True)
+ sidecar["id"] = take_id
+ sidecar["created_at"] = float(state.get("created_at") or time.time())
+ sidecar["label"] = body.label or str(state.get("label") or take_id)
+ # The track it was opened on; a take opened before tracks existed, or
+ # whose track was deleted meanwhile, gets a new track of its own.
+ track_id = str(state.get("track_id") or "")
+ if not any(t.get("id") == track_id for t in job.tracks):
+ track_id = new_track_id({t["id"] for t in job.tracks})
+ sidecar["track_id"] = track_id
+ if camera_ext is not None:
+ sidecar["video"]["file"] = f"camera.{camera_ext}"
+ else:
+ sidecar.pop("video", None)
+ placement = _placement_sec(sidecar)
+
+ def build() -> None:
+ if audio_part.is_file():
+ assemble_raw_wav(audio_part, raw_wav, rate, channels)
+ audio_part.unlink()
+ if camera_ext is not None:
+ os.replace(camera_part, folder / f"camera.{camera_ext}")
+ else:
+ camera_part.unlink(missing_ok=True)
+ render_take_audio(raw_wav, folder / "audio.wav", placement, frames, rate, channels)
+ _write_json_atomic(folder / _SIDECAR_FILE, sidecar)
+ _refresh_peaks(job_id, take_id, folder)
+ state["finished"] = True
+ _write_json_atomic(state_path, state)
+
+ try:
+ await asyncio.to_thread(build)
+ except TakeRenderError:
+ logger.warning("[%s] take %s render failed", job_id, take_id, exc_info=True)
+ raise HTTPException(status_code=500, detail="take render failed") from None
+
+ entry = take_entry(job_id, sidecar, camera_ext is not None)
+ _replace_entry(job, entry, take_id, activate=True)
+ logger.info("[%s] take %s finished (placement %.3f s)", job_id, take_id, placement)
+ return entry
+
+
+# ---------------------------------------------------------------------------
+# Edit, delete
+
+
+class PatchTakeBody(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ label: str | None = Field(default=None, max_length=_LABEL_MAX * 4)
+ user_nudge_ms: float | None = _finite(-_NUDGE_MAX_MS, _NUDGE_MAX_MS)
+ camera_offset_ms: float | None = _finite(-_CAMERA_OFFSET_MAX_MS, _CAMERA_OFFSET_MAX_MS)
+
+ @field_validator("label")
+ @classmethod
+ def _label(cls, v: str | None) -> str | None:
+ return _clean_label(v)
+
+
+@router.patch("/{job_id}/takes/{take_id}")
+async def patch_take(job_id: str, take_id: str, request: Request) -> dict[str, Any]:
+ """Rename, nudge (re-renders audio.wav from the raw capture), or set the
+ camera offset. A changed nudge or offset drops an earlier video render:
+ it no longer matches what the user hears."""
+ job, folder = _take_or_404(job_id, take_id)
+ body: PatchTakeBody = await _parse_body(request, PatchTakeBody)
+ if body.label is None and body.user_nudge_ms is None and body.camera_offset_ms is None:
+ raise HTTPException(status_code=422, detail="nothing to change")
+ if _entry(job, take_id) is None:
+ raise HTTPException(status_code=409, detail="take not finished")
+ if _render_busy(job_id, take_id):
+ raise HTTPException(status_code=409, detail="video render running")
+
+ async with _lock_for(job_id, take_id):
+ sidecar = await asyncio.to_thread(_read_json, folder / _SIDECAR_FILE)
+ has_camera = await asyncio.to_thread(camera_file, folder)
+ if body.camera_offset_ms is not None and has_camera is None:
+ raise HTTPException(status_code=422, detail="take has no camera")
+ latency = sidecar.setdefault("latency", {})
+ old_nudge = float(latency.get("user_nudge_ms") or 0.0)
+ video = sidecar.get("video") or {}
+ old_offset = float(video.get("cameraOffsetMs") or 0.0)
+
+ if body.label is not None:
+ sidecar["label"] = body.label
+ nudged = body.user_nudge_ms is not None and body.user_nudge_ms != old_nudge
+ if body.user_nudge_ms is not None:
+ latency["user_nudge_ms"] = body.user_nudge_ms
+ offset_moved = body.camera_offset_ms is not None and body.camera_offset_ms != old_offset
+ if body.camera_offset_ms is not None:
+ sidecar.setdefault("video", {})["cameraOffsetMs"] = body.camera_offset_ms
+
+ def apply() -> None:
+ if nudged:
+ frames = _song_frames(job_id)
+ capture = sidecar.get("capture") or {}
+ render_take_audio(
+ folder / "audio.raw.wav",
+ folder / "audio.wav",
+ _placement_sec(sidecar),
+ frames,
+ int(capture.get("rate") or TAKE_RATE),
+ int(capture.get("channels") or 2),
+ )
+ _refresh_peaks(job_id, take_id, folder)
+ if nudged or offset_moved:
+ (folder / _RENDER_FILE).unlink(missing_ok=True)
+ _write_json_atomic(folder / _SIDECAR_FILE, sidecar)
+
+ try:
+ await asyncio.to_thread(apply)
+ except TakeRenderError:
+ logger.warning("[%s] take %s re-render failed", job_id, take_id, exc_info=True)
+ raise HTTPException(status_code=500, detail="take render failed") from None
+ if nudged or offset_moved:
+ _renders.pop((job_id, take_id), None)
+ entry = take_entry(job_id, sidecar, has_camera is not None)
+ _replace_entry(job, entry, take_id)
+ return entry
+
+
+@router.delete("/{job_id}/takes/{take_id}")
+async def delete_take(job_id: str, take_id: str) -> dict[str, str]:
+ """Remove a take, finished or not: folder, lane and waveform."""
+ job, folder = _take_or_404(job_id, take_id)
+ if _render_busy(job_id, take_id):
+ raise HTTPException(status_code=409, detail="video render running")
+ async with _lock_for(job_id, take_id):
+ removed = await asyncio.to_thread(_remove_take_folder, job_id, take_id, folder)
+ _renders.pop((job_id, take_id), None)
+ if _entry(job, take_id) is not None:
+ _replace_entry(job, None, take_id)
+ _take_locks.pop(f"{job_id}/{take_id}", None)
+ if not removed:
+ raise HTTPException(status_code=500, detail="take files could not be deleted")
+ return {"take_id": take_id, "status": "deleted"}
+
+
+def _remove_take_folder(job_id: str, take_id: str, folder: Path) -> bool:
+ """Blocking. Retried briefly: on Windows a render ffmpeg that was just
+ killed can hold camera.* open for a moment after it exits."""
+ for attempt in range(10):
+ try:
+ shutil.rmtree(folder)
+ break
+ except FileNotFoundError:
+ break
+ except OSError:
+ if attempt == 9:
+ logger.warning("could not remove take %s", folder, exc_info=True)
+ time.sleep(0.2)
+ _refresh_peaks(job_id, take_id, folder, remove=True)
+ return not folder.exists()
+
+
+# ---------------------------------------------------------------------------
+# Tracks
+
+
+class TrackInput(BaseModel):
+ """Which input a track records from. Only stored for the UI: capture runs
+ in the browser and the take's sidecar says what was actually captured."""
+
+ model_config = ConfigDict(extra="forbid")
+ device_id: str | None = Field(default=None, max_length=256)
+ device_label: str | None = Field(default=None, max_length=200)
+ channels: list[int] = Field(min_length=1, max_length=2)
+
+ @field_validator("channels")
+ @classmethod
+ def _channels(cls, v: list[int]) -> list[int]:
+ if any(isinstance(c, bool) or not 0 <= c <= 63 for c in v):
+ raise ValueError("channel index must be 0..63")
+ if len(set(v)) != len(v):
+ raise ValueError("a stereo pair needs two different channels")
+ return v
+
+ @field_validator("device_id", "device_label")
+ @classmethod
+ def _printable(cls, v: str | None) -> str | None:
+ if v is not None and _CONTROL_CHARS.search(v):
+ raise ValueError("must be printable")
+ return v
+
+
+class CreateTrackBody(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ name: str | None = Field(default=None, max_length=_LABEL_MAX * 4)
+ input: TrackInput | None = None
+
+ @field_validator("name")
+ @classmethod
+ def _name(cls, v: str | None) -> str | None:
+ return _clean_label(v)
+
+
+class PatchTrackBody(CreateTrackBody):
+ active_take_id: str | None = Field(default=None, max_length=16)
+ order: int | None = Field(default=None, ge=0, le=TRACK_MAX_PER_JOB)
+
+
+def _track_view(job: Job, track_id: str) -> dict[str, Any]:
+ return next(t for t in job.tracks if t["id"] == track_id)
+
+
+@router.post("/{job_id}/tracks", status_code=201)
+async def create_track(job_id: str, request: Request) -> dict[str, Any]:
+ """Add a recording track: {} or {"name"?, "input"?}."""
+ job = _job_or_404(job_id)
+ root = (JOBS_DIR / job_id / TAKES_DIRNAME).resolve()
+ if not root.is_relative_to(JOBS_DIR.resolve()):
+ raise HTTPException(status_code=404, detail="job not found")
+ body = await _parse_body(request, CreateTrackBody, empty_ok=True)
+ # No await from here to the commit: see _commit.
+ if len(job.tracks) >= TRACK_MAX_PER_JOB:
+ raise HTTPException(status_code=409, detail="too many tracks for this song")
+ used = {t["id"] for t in job.tracks}
+ track_id = new_track_id(used)
+ names = {t.get("name") for t in job.tracks}
+ n = len(job.tracks) + 1
+ while f"Track {n}" in names:
+ n += 1
+ track = make_track(
+ track_id,
+ body.name or f"Track {n}",
+ len(job.tracks),
+ body.input.model_dump() if body.input else None,
+ )
+ _commit(job, list(job.takes), [*job.tracks, track])
+ logger.info("[%s] track %s created", job_id, track_id)
+ return _track_view(job, track_id)
+
+
+@router.patch("/{job_id}/tracks/{track_id}")
+async def patch_track(job_id: str, track_id: str, request: Request) -> dict[str, Any]:
+ """Rename, set the input (null clears it), switch the active take, or
+ move the track to another position."""
+ job = _job_or_404(job_id)
+ _track_or_404(job, track_id)
+ body = await _parse_body(request, PatchTrackBody)
+ fields = body.model_fields_set
+ if not fields:
+ raise HTTPException(status_code=422, detail="nothing to change")
+ # Re-read after the await, then no await until the commit.
+ track = dict(_track_or_404(job, track_id))
+ if "name" in fields:
+ if body.name is None:
+ raise HTTPException(status_code=422, detail="name cannot be empty")
+ track["name"] = body.name
+ if "input" in fields:
+ track["input"] = body.input.model_dump() if body.input else None
+ if "active_take_id" in fields:
+ take = next(
+ (
+ t
+ for t in job.takes
+ if t.get("id") == body.active_take_id and t.get("track_id") == track_id
+ ),
+ None,
+ )
+ if take is None:
+ raise HTTPException(status_code=422, detail="not a finished take of this track")
+ track["active_take_id"] = take["id"]
+ others = [dict(t) for t in job.tracks if t["id"] != track_id]
+ position = track["order"]
+ if "order" in fields and body.order is not None:
+ position = min(body.order, len(others))
+ others.insert(position, track)
+ for i, t in enumerate(others):
+ t["order"] = i
+ _commit(job, list(job.takes), others)
+ return _track_view(job, track_id)
+
+
+@router.delete("/{job_id}/tracks/{track_id}")
+async def delete_track(job_id: str, track_id: str) -> dict[str, Any]:
+ """Delete a track and every take on it, finished or not, stopping any
+ video render of them first."""
+ job = _job_or_404(job_id)
+ _track_or_404(job, track_id)
+ root = (JOBS_DIR / job_id / TAKES_DIRNAME).resolve()
+ if not root.is_relative_to(JOBS_DIR.resolve()):
+ raise HTTPException(status_code=404, detail="job not found")
+
+ def owned() -> list[str]:
+ ids = {t["id"] for t in job.takes if t.get("track_id") == track_id}
+ if root.is_dir():
+ for folder in root.iterdir():
+ if not folder.is_dir() or not TAKE_ID_RE.match(folder.name):
+ continue
+ state = folder / _STATE_FILE
+ try:
+ if json.loads(state.read_text(encoding="utf-8")).get("track_id") == track_id:
+ ids.add(folder.name)
+ except (OSError, ValueError, AttributeError):
+ continue
+ return sorted(ids)
+
+ take_ids = await asyncio.to_thread(owned)
+ # The track goes first, so a take finishing meanwhile cannot make it
+ # active again; its takes follow.
+ _commit(
+ job,
+ [t for t in job.takes if t.get("id") not in take_ids],
+ [t for t in job.tracks if t["id"] != track_id],
+ )
+ failed = False
+ for take_id in take_ids:
+ run = _renders.pop((job_id, take_id), None)
+ if run is not None:
+ _stop_run(run)
+ folder = take_dir(job_id, take_id, JOBS_DIR)
+ if folder is None:
+ continue
+ async with _lock_for(job_id, take_id):
+ if not await asyncio.to_thread(_remove_take_folder, job_id, take_id, folder):
+ failed = True
+ _take_locks.pop(f"{job_id}/{take_id}", None)
+ if any(t.get("id") in take_ids for t in job.takes):
+ _commit(job, [t for t in job.takes if t.get("id") not in take_ids], list(job.tracks))
+ logger.info("[%s] track %s deleted with %d takes", job_id, track_id, len(take_ids))
+ if failed:
+ raise HTTPException(status_code=500, detail="take files could not be deleted")
+ return {"track_id": track_id, "status": "deleted", "takes_deleted": take_ids}
+
+
+# ---------------------------------------------------------------------------
+# Serve
+
+
+def _download_name(job: Job, label: str, ext: str) -> str:
+ parts = [p for p in (stems_api._title_slug(job.title), stems_api._title_slug(label)) if p]
+ return f"{'_'.join(parts) or 'take'}.{ext}"
+
+
+@router.api_route(
+ "/{job_id}/takes/{take_id}/audio.wav", methods=["GET", "HEAD"], response_model=None
+)
+async def get_take_audio(job_id: str, take_id: str) -> FileResponse:
+ job, folder, entry = _finished_or_404(job_id, take_id)
+ path = (folder / "audio.wav").resolve()
+ if not path.is_file() or not path.is_relative_to(JOBS_DIR.resolve()):
+ raise HTTPException(status_code=404, detail="take not found")
+ return FileResponse(
+ path, media_type="audio/wav", filename=_download_name(job, entry["label"], "wav")
+ )
+
+
+@router.get("/{job_id}/takes/{take_id}/camera", response_model=None)
+async def get_take_camera(job_id: str, take_id: str) -> FileResponse:
+ _job, folder, _entry_ = _finished_or_404(job_id, take_id)
+ path = camera_file(folder)
+ if path is None or not path.resolve().is_relative_to(JOBS_DIR.resolve()):
+ raise HTTPException(status_code=404, detail="take has no camera")
+ media = "video/webm" if path.suffix == ".webm" else "video/mp4"
+ return FileResponse(path.resolve(), media_type=media)
+
+
+# ---------------------------------------------------------------------------
+# Video render
+
+
+@dataclass
+class _RenderRun:
+ status: str = "queued" # queued | running | done | error
+ progress: float = 0.0
+ error: str | None = None
+ cancel: threading.Event = field(default_factory=threading.Event)
+ proc: subprocess.Popen | None = None
+
+
+_renders: dict[tuple[str, str], _RenderRun] = {}
+_render_tasks: set[asyncio.Task] = set()
+
+
+def _render_busy(job_id: str, take_id: str) -> bool:
+ run = _renders.get((job_id, take_id))
+ return run is not None and run.status in ("queued", "running")
+
+
+def _stop_run(run: _RenderRun) -> None:
+ run.cancel.set()
+ proc = run.proc
+ if proc is not None and proc.poll() is None:
+ proc.kill()
+
+
+def cancel_job_renders(job_id: str) -> None:
+ """Stop every render of a job, for the job's deletion: its folder is
+ about to go, and on Windows a running ffmpeg would hold files open."""
+ for (jid, tid), run in list(_renders.items()):
+ if jid == job_id:
+ _stop_run(run)
+ _renders.pop((jid, tid), None)
+
+
+def _render_view(job_id: str, take_id: str, folder: Path) -> dict[str, Any]:
+ run = _renders.get((job_id, take_id))
+ url = f"/api/jobs/{job_id}/takes/{take_id}/render.mp4"
+ if run is not None and run.status != "done":
+ return {"status": run.status, "progress": run.progress, "error": run.error, "url": None}
+ if (folder / _RENDER_FILE).is_file():
+ return {"status": "done", "progress": 1.0, "error": None, "url": url}
+ return {"status": "none", "progress": 0.0, "error": None, "url": None}
+
+
+class RenderBody(BaseModel):
+ """Single-tile subset of the layout descriptor (06-video-recording 5.3)."""
+
+ model_config = ConfigDict(extra="forbid")
+ stems: list[str] = Field(default_factory=list, max_length=_RENDER_MAX_LANES)
+ gains: list[float] = Field(default_factory=list, max_length=_RENDER_MAX_LANES)
+ pitches: list[int] | None = Field(default=None, max_length=_RENDER_MAX_LANES)
+ take_gain: float = Field(default=1.0, ge=0, le=4, allow_inf_nan=False)
+ camera_offset_ms: float | None = _finite(-_CAMERA_OFFSET_MAX_MS, _CAMERA_OFFSET_MAX_MS)
+ canvas: Literal["16:9", "9:16"] = "16:9"
+ fit: Literal["cover", "contain"] = "cover"
+ mirror: StrictBool = False
+
+ @field_validator("stems")
+ @classmethod
+ def _names(cls, v: list[str]) -> list[str]:
+ for name in v:
+ if len(name) > 32:
+ raise ValueError("unknown stem requested")
+ return v
+
+
+def _render_lanes(job_id: str, take_id: str, body: RenderBody) -> list[AudioLane]:
+ """Validate and resolve the backing lanes with the export's own rules."""
+ if len(body.gains) != len(body.stems):
+ raise HTTPException(status_code=422, detail="stems and gains must be equal length")
+ if any(not (0 <= g <= stems_api._MIXDOWN_MAX_GAIN) for g in body.gains):
+ raise HTTPException(status_code=422, detail="gain out of range")
+ active = {t["id"]: t.get("active_take_id") for t in registry_get(job_id).tracks}
+ if take_id in body.stems or any(active.get(n) == take_id for n in body.stems):
+ raise HTTPException(status_code=422, detail="the take is already in the video")
+ if not body.stems:
+ return []
+ if len(set(body.stems)) != len(body.stems):
+ raise HTTPException(status_code=422, detail="a lane is listed twice")
+ names, gains = stems_api._parse_lane_gains(
+ ",".join(body.stems), ",".join(f"{g:.6f}" for g in body.gains)
+ )
+ pitches = stems_api._parse_pitches(
+ ",".join(str(p) for p in body.pitches) if body.pitches else "", names
+ )
+ if any(pitches) and not stems_api._rubberband_available():
+ raise HTTPException(
+ status_code=422, detail="this ffmpeg build cannot transpose (no rubberband filter)"
+ )
+ lanes, _unpitched = stems_api._expand_render_lanes(job_id, names, gains, pitches)
+ return [AudioLane(lane.path, lane.gain, stems_api._pitch_filter(lane.pitch)) for lane in lanes]
+
+
+async def _render_task(
+ run: _RenderRun, job: Job, cmd: list[str], dest: Path, duration: float
+) -> None:
+ def cancelled() -> bool:
+ return run.cancel.is_set() or registry_get(job.id) is not job
+
+ def report(share: float) -> None:
+ run.progress = share
+
+ def on_proc(proc: subprocess.Popen | None) -> None:
+ run.proc = proc
+
+ try:
+ # Behind the pipeline lock like every other heavy on-demand run: an
+ # x264 encode and a Demucs pass on the same CPU slow each other down
+ # for no gain. "queued" until an import in progress has finished.
+ async with _pipeline_lock:
+ if cancelled():
+ raise RenderCancelled()
+ run.status = "running"
+ await asyncio.to_thread(
+ run_video_render,
+ cmd,
+ dest,
+ duration,
+ cancelled=cancelled,
+ report=report,
+ on_proc=on_proc,
+ timeout=TIMEOUT_VIDEO_RENDER,
+ )
+ run.status = "done"
+ run.progress = 1.0
+ except RenderCancelled:
+ run.status = "none"
+ except TakeRenderError:
+ logger.warning("[%s] video render failed", job.id, exc_info=True)
+ run.status = "error"
+ run.error = "video render failed"
+ except Exception:
+ logger.exception("[%s] video render failed", job.id)
+ run.status = "error"
+ run.error = "video render failed"
+
+
+@router.post("/{job_id}/takes/{take_id}/render", status_code=202)
+async def start_render(job_id: str, take_id: str, request: Request) -> JSONResponse:
+ """Render the take's camera over the backing mix plus the take to MP4,
+ in the background: 202, then GET .../render follows it."""
+ job, folder, entry = _finished_or_404(job_id, take_id)
+ body: RenderBody = await _parse_body(request, RenderBody, empty_ok=True)
+ camera = await asyncio.to_thread(camera_file, folder)
+ if camera is None:
+ raise HTTPException(status_code=404, detail="take has no camera")
+ if not await asyncio.to_thread(libx264_available):
+ raise HTTPException(
+ status_code=422, detail="this ffmpeg build cannot encode H.264 video (no libx264)"
+ )
+ if not await asyncio.to_thread(aac_available):
+ raise HTTPException(
+ status_code=422, detail="this ffmpeg build cannot encode H.264 video (no AAC encoder)"
+ )
+ lanes = await asyncio.to_thread(_render_lanes, job_id, take_id, body)
+ if _render_busy(job_id, take_id):
+ raise HTTPException(status_code=409, detail="video render already running")
+
+ sidecar = await asyncio.to_thread(_read_json, folder / _SIDECAR_FILE)
+ video = sidecar.get("video") or {}
+ offset_ms = (
+ body.camera_offset_ms
+ if body.camera_offset_ms is not None
+ else float(video.get("cameraOffsetMs") or 0.0)
+ )
+ camera_start = (
+ _placement_sec(sidecar) + float(video.get("firstFrameAtMs") or 0.0) / 1000.0
+ ) - offset_ms / 1000.0
+ frames = await asyncio.to_thread(_song_frames, job_id)
+ duration = frames / TAKE_RATE
+ cmd = build_video_render_cmd(
+ lanes,
+ folder / "audio.wav",
+ body.take_gain,
+ camera,
+ camera_start,
+ duration,
+ body.canvas,
+ body.fit,
+ body.mirror,
+ get_export_sample_rate(),
+ )
+ run = _RenderRun()
+ _renders[(job_id, take_id)] = run
+ task = asyncio.create_task(_render_task(run, job, cmd, folder / _RENDER_FILE, duration))
+ _render_tasks.add(task)
+ task.add_done_callback(_render_tasks.discard)
+ logger.info("[%s] take %s video render queued (%d lanes)", job_id, take_id, len(lanes))
+ return JSONResponse(_render_view(job_id, take_id, folder), status_code=202)
+
+
+@router.get("/{job_id}/takes/{take_id}/render")
+async def get_render(job_id: str, take_id: str) -> dict[str, Any]:
+ _job, folder, _entry_ = _finished_or_404(job_id, take_id)
+ return _render_view(job_id, take_id, folder)
+
+
+@router.post("/{job_id}/takes/{take_id}/render/cancel")
+async def cancel_render(job_id: str, take_id: str) -> dict[str, Any]:
+ _job, folder, _entry_ = _finished_or_404(job_id, take_id)
+ run = _renders.pop((job_id, take_id), None)
+ if run is not None:
+ _stop_run(run)
+ return _render_view(job_id, take_id, folder)
+
+
+@router.get("/{job_id}/takes/{take_id}/render.mp4", response_model=None)
+async def get_render_file(job_id: str, take_id: str) -> FileResponse:
+ job, folder, entry = _finished_or_404(job_id, take_id)
+ path = (folder / _RENDER_FILE).resolve()
+ if not path.is_file() or not path.is_relative_to(JOBS_DIR.resolve()):
+ raise HTTPException(status_code=404, detail="no video render")
+ return FileResponse(
+ path, media_type="video/mp4", filename=_download_name(job, entry["label"], "mp4")
+ )
diff --git a/app/core/config.py b/app/core/config.py
index 63ebf7c4..1ffda273 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -352,6 +352,39 @@ def js_solver_available() -> bool:
# so it gets its own, larger budget.
TIMEOUT_BEATGRID = _env_int("STEMDECK_TIMEOUT_BEATGRID", 300)
+# Recorded takes (docs: audio capture). A take is a folder under
+# jobs//takes// and lives and dies with the job folder.
+TAKE_ID_RE = re.compile(r"^t[0-9a-f]{8}$")
+TAKES_DIRNAME = "takes"
+# One job can hold this many takes, finished or not. A bound on disk use by a
+# client that creates takes in a loop, not a musical limit.
+TAKE_MAX_PER_JOB = 50
+# Recording tracks (DAW-style): a track is one mixer lane that owns takes and
+# plays its active one. Stored in takes/tracks.json beside the take folders.
+TRACK_ID_RE = re.compile(r"^k[0-9a-f]{8}$")
+TRACKS_FILE = "tracks.json"
+TRACK_MAX_PER_JOB = 32
+# A part body is capped by the 4 MB JSON body middleware in app/main.py; the
+# route checks the same number while it streams the body, because the header
+# can be absent or wrong.
+TAKE_PART_MAX_BYTES = 4 * 1024 * 1024
+TAKE_MAX_SEQ = 100_000
+# Total size per kind is bounded by the song: its duration plus this much
+# slack (count-in, pickup notes, a late Stop), at the largest capture format.
+TAKE_SLACK_SEC = 120
+TAKE_AUDIO_MAX_BYTES_PER_SEC = 48000 * 2 * 4 # float32 stereo at 48 kHz
+TAKE_CAMERA_MAX_BYTES_PER_SEC = 20_000_000 // 8 # 20 Mbit/s, 2.5x the 8 Mbit/s recorder
+# Video render of a take (libx264 + AAC). Slower than any audio export: a
+# 5-minute 1080p30 encode runs about 1x to 3x realtime on a desktop CPU.
+TIMEOUT_VIDEO_RENDER = _env_int("STEMDECK_TIMEOUT_VIDEO_RENDER", 3600)
+
+# Ceiling of the limiter at the end of every mixdown (export clipping fix).
+# Stems summed at unity measured +0.9 dBFS with 77 clipped samples on a real
+# song (docs/plans 01-mastering 3.3). -1 dBFS is the "Prevent clipping"
+# ceiling the mastering plan uses, and leaves room for the 0.7 dB a lossy
+# encoder adds on top (same document, 4.1). Below it the limiter is idle.
+EXPORT_LIMIT_DBFS = -1.0
+
# Reading an upload's tags (artist, title, album, lyrics) with ffprobe for the
# Lyrics tab and the artist box (#699). Tags are a nicety, so a probe that
# takes longer than this is abandoned and the upload goes ahead without them.
diff --git a/app/core/models.py b/app/core/models.py
index 4e0a1b85..c73f1846 100644
--- a/app/core/models.py
+++ b/app/core/models.py
@@ -250,6 +250,14 @@ class Job:
sections_source: Literal["automatic", "manual"] | None = None
tags: list[str] | None = None # YouTube tags + categories, lowercased, max 8
stems: list[dict[str, str]] = field(default_factory=list)
+ # Recorded takes, finished ones only, in creation order: the lanes the
+ # studio adds after the stems. Each is app.core.takes.take_entry's shape.
+ # The take itself (raw capture, sidecar, camera) stays in
+ # takes// beside the stems; this is the index the UI reads.
+ takes: list[dict[str, Any]] = field(default_factory=list)
+ # Recording tracks, sorted by order: each a mixer lane that plays its
+ # active take. Mirrors takes/tracks.json (app.core.takes).
+ tracks: list[dict[str, Any]] = field(default_factory=list)
# Subset of stems the user chose at submit. The pipeline produces all
# 6 regardless (Demucs htdemucs_6s is fixed), but after collect we
# mix down only the selected ones into mix.wav so the user can
@@ -389,6 +397,8 @@ def to_state(self) -> dict[str, Any]:
"sections_source": self.sections_source,
"tags": self.tags,
"stems": self.stems,
+ "takes": self.takes,
+ "tracks": self.tracks,
"selected_stems": self.selected_stems,
"mix_url": self.mix_url,
"source_url": self.source_url,
diff --git a/app/core/registry.py b/app/core/registry.py
index 69cffe21..d5c4c618 100644
--- a/app/core/registry.py
+++ b/app/core/registry.py
@@ -11,6 +11,7 @@
from app.core.config import DEMUCS_MODEL, EXTRA_STEM_NAMES, JOB_ID_RE, LYRICS_FILE, STEM_NAMES
from app.core.models import Job, clean_artist, clean_audio_tags, clean_identity, clean_work
+from app.core.takes import reconcile_tracks, recover_takes
logger = logging.getLogger("stemdeck.registry")
@@ -254,6 +255,9 @@ def restore(jobs_dir: Path) -> None:
changed = True
if recovered.status in _RESUMABLE:
resume.append(recovered)
+ for job in to_add.values():
+ if _sync_tracks(job, jobs_dir / job.id):
+ changed = True
with _lock:
_jobs.update(to_add)
# The user's order first, then oldest first. queue_position is
@@ -289,6 +293,7 @@ def restore(jobs_dir: Path) -> None:
continue
recovered = _recover_done_job(job_dir)
if recovered is not None:
+ _sync_tracks(recovered, job_dir)
with _lock:
_jobs[recovered.id] = recovered
changed = True
@@ -363,6 +368,20 @@ def take_pending_resume() -> list[str]:
return ids
+def _sync_tracks(job: Job, job_dir: Path) -> bool:
+ """Reconcile a loaded job's recording tracks with its takes on disk, so
+ takes recorded before tracks existed come back as a track each. Never
+ fatal: restore() runs at import time."""
+ if not job.takes and not job.tracks and not (job_dir / "takes").is_dir():
+ return False
+ try:
+ job.tracks, job.takes, changed = reconcile_tracks(job_dir, job.tracks, job.takes)
+ except Exception:
+ logger.warning("could not reconcile tracks for %s", job.id, exc_info=True)
+ return False
+ return changed
+
+
def _recover_done_job(job_dir: Path) -> Job | None:
stems_dir = job_dir / "stems"
if not stems_dir.is_dir():
@@ -433,6 +452,9 @@ def _recover_done_job(job_dir: Path) -> Job | None:
# The file is the record: metadata.json predates it for most jobs.
has_lyrics=(job_dir / LYRICS_FILE).is_file(),
vocal_split=vocal_split,
+ # Takes are files first: their folders survive whatever the registry
+ # lost, so the lanes come back with the job.
+ takes=recover_takes(job_dir),
)
diff --git a/app/core/takes.py b/app/core/takes.py
new file mode 100644
index 00000000..c186b4c1
--- /dev/null
+++ b/app/core/takes.py
@@ -0,0 +1,230 @@
+"""Recorded takes: where they live and what the job record says about them.
+
+A take is a folder, jobs//takes//, holding
+
+ audio.part / camera.part bytes received so far, while recording
+ parts.json upload state: next sequence number and size per kind
+ audio.raw.wav exactly what was captured (float32, capture rate)
+ audio.wav the playable take, in the stems' own format
+ camera.webm | camera.mp4 the raw camera recording, when there is one
+ take.json the sidecar
+ render.mp4 the rendered video, once asked for
+
+Takes belong to recording tracks (takes/tracks.json): a track is one mixer
+lane that owns takes and plays its active one.
+
+It lives and dies with the job folder: deleting the job removes it, putting
+the job in the Trash leaves it alone, as it does every other file.
+
+Kept free of FastAPI and ffmpeg so the registry can rebuild entries from disk
+after a restart without importing the API layer.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import secrets
+import time
+import uuid
+from pathlib import Path
+from typing import Any
+
+from app.core.config import JOB_ID_RE, JOBS_DIR, TAKE_ID_RE, TAKES_DIRNAME, TRACK_ID_RE, TRACKS_FILE
+
+logger = logging.getLogger("stemdeck.takes")
+
+CAMERA_EXTS = ("webm", "mp4")
+
+
+def take_dir(job_id: str, take_id: str, jobs_dir: Path | None = None) -> Path | None:
+ """The take's folder, resolved, or None when either id is malformed or
+ the path would leave the jobs directory. Does not check that it exists."""
+ if not JOB_ID_RE.match(job_id) or not TAKE_ID_RE.match(take_id):
+ return None
+ root = (jobs_dir or JOBS_DIR).resolve()
+ path = (root / job_id / TAKES_DIRNAME / take_id).resolve()
+ if not path.is_relative_to(root):
+ return None
+ return path
+
+
+def camera_file(folder: Path) -> Path | None:
+ """The take's camera recording, whichever container it turned out to be."""
+ for ext in CAMERA_EXTS:
+ path = folder / f"camera.{ext}"
+ if path.is_file():
+ return path
+ return None
+
+
+def take_entry(job_id: str, sidecar: dict[str, Any], has_camera: bool) -> dict[str, Any]:
+ """The record a finished take gets in job.takes, built from its sidecar.
+
+ This is what the studio reads to add the lane, so it carries everything
+ the lane needs and nothing it does not: the full sidecar stays in
+ take.json."""
+ take_id = str(sidecar["id"])
+ latency = sidecar.get("latency") or {}
+ video = sidecar.get("video") or {}
+ base = f"/api/jobs/{job_id}/takes/{take_id}"
+ track_id = str(sidecar.get("track_id") or "")
+ return {
+ "id": take_id,
+ "track_id": track_id if TRACK_ID_RE.match(track_id) else None,
+ "label": str(sidecar.get("label") or take_id),
+ "created_at": float(sidecar.get("created_at") or 0.0),
+ "url": f"{base}/audio.wav",
+ "user_nudge_ms": float(latency.get("user_nudge_ms") or 0.0),
+ "has_camera": has_camera,
+ "camera_offset_ms": float(video.get("cameraOffsetMs") or 0.0) if has_camera else 0.0,
+ "camera_url": f"{base}/camera" if has_camera else None,
+ }
+
+
+def recover_takes(job_dir: Path) -> list[dict[str, Any]]:
+ """Entries for every finished take on disk, oldest first.
+
+ Used when a job is rebuilt from its folder (registry restore without a
+ record), so a crash between finishing a take and persisting the registry
+ does not lose the lane: the files are the truth. A take counts as
+ finished once it has both its sidecar and its playable audio."""
+ root = job_dir / TAKES_DIRNAME
+ if not root.is_dir():
+ return []
+ entries: list[dict[str, Any]] = []
+ for folder in sorted(root.iterdir()):
+ if not folder.is_dir() or not TAKE_ID_RE.match(folder.name):
+ continue
+ sidecar_path = folder / "take.json"
+ if not sidecar_path.is_file() or not (folder / "audio.wav").is_file():
+ continue
+ try:
+ sidecar = json.loads(sidecar_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ logger.warning("could not read %s", sidecar_path, exc_info=True)
+ continue
+ if not isinstance(sidecar, dict):
+ continue
+ sidecar["id"] = folder.name
+ entries.append(take_entry(job_dir.name, sidecar, camera_file(folder) is not None))
+ entries.sort(key=lambda e: e["created_at"])
+ return entries
+
+
+# ---------------------------------------------------------------------------
+# Tracks
+
+
+def new_track_id(existing: set[str]) -> str:
+ while True:
+ track_id = "k" + secrets.token_hex(4)
+ if track_id not in existing:
+ return track_id
+
+
+def make_track(track_id: str, name: str, order: int, input_: dict | None = None) -> dict[str, Any]:
+ return {
+ "id": track_id,
+ "name": name,
+ "order": order,
+ "created_at": time.time(),
+ "active_take_id": None,
+ "input": input_,
+ }
+
+
+def write_json_atomic(path: Path, data: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
+ try:
+ tmp.write_text(json.dumps(data, indent=2, allow_nan=False) + "\n", encoding="utf-8")
+ os.replace(tmp, path)
+ finally:
+ tmp.unlink(missing_ok=True)
+
+
+def read_tracks(job_dir: Path) -> list[dict[str, Any]] | None:
+ """tracks.json, or None when there is none (or it cannot be read)."""
+ path = job_dir / TAKES_DIRNAME / TRACKS_FILE
+ if not path.is_file():
+ return None
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ logger.warning("could not read %s", path, exc_info=True)
+ return None
+ tracks = data.get("tracks") if isinstance(data, dict) else None
+ if not isinstance(tracks, list):
+ return None
+ return [t for t in tracks if isinstance(t, dict) and TRACK_ID_RE.match(str(t.get("id")))]
+
+
+def write_tracks(job_dir: Path, tracks: list[dict[str, Any]]) -> None:
+ write_json_atomic(job_dir / TAKES_DIRNAME / TRACKS_FILE, {"v": 1, "tracks": tracks})
+
+
+def normalise_tracks(
+ tracks: list[dict[str, Any]], takes: list[dict[str, Any]]
+) -> list[dict[str, Any]]:
+ """Sorted by order, order renumbered 0..n-1, and every active_take_id a
+ finished take of its own track (else its newest one, else None)."""
+ out = sorted(
+ (dict(t) for t in tracks),
+ key=lambda t: (t.get("order", 0), t.get("created_at", 0.0)),
+ )
+ for i, track in enumerate(out):
+ track["order"] = i
+ own = [t for t in takes if t.get("track_id") == track["id"]]
+ if track.get("active_take_id") not in {t["id"] for t in own}:
+ newest = max(own, key=lambda t: t.get("created_at") or 0.0, default=None)
+ track["active_take_id"] = newest["id"] if newest else None
+ return out
+
+
+def reconcile_tracks(
+ job_dir: Path, tracks: list[dict[str, Any]] | None, takes: list[dict[str, Any]]
+) -> tuple[list[dict[str, Any]], list[dict[str, Any]], bool]:
+ """Bring a job's tracks and takes into agreement, on load.
+
+ tracks.json wins over the registry record when both exist: it is written
+ first. A finished take with no track (recorded before tracks existed, or
+ whose track entry was lost) gets a track of its own, named after it and
+ playing it, and its take.json learns the track id so the next load
+ agrees. Returns (tracks, takes, changed)."""
+ on_disk = read_tracks(job_dir)
+ current = on_disk if on_disk is not None else [dict(t) for t in tracks or []]
+ known = {t["id"] for t in current}
+ new_takes: list[dict[str, Any]] = []
+ changed = on_disk is None and bool(current)
+ for entry in sorted(takes, key=lambda e: e.get("created_at") or 0.0):
+ entry = dict(entry)
+ if entry.get("track_id") not in known:
+ track_id = new_track_id(known)
+ known.add(track_id)
+ track = make_track(track_id, str(entry.get("label") or "Track"), len(current))
+ track["created_at"] = float(entry.get("created_at") or 0.0)
+ track["active_take_id"] = entry["id"]
+ current.append(track)
+ entry["track_id"] = track_id
+ _stamp_sidecar(job_dir, entry["id"], track_id)
+ changed = True
+ new_takes.append(entry)
+ normalised = normalise_tracks(current, new_takes)
+ if normalised != current:
+ changed = True
+ if changed:
+ write_tracks(job_dir, normalised)
+ return normalised, new_takes, changed
+
+
+def _stamp_sidecar(job_dir: Path, take_id: str, track_id: str) -> None:
+ path = job_dir / TAKES_DIRNAME / take_id / "take.json"
+ try:
+ sidecar = json.loads(path.read_text(encoding="utf-8"))
+ if isinstance(sidecar, dict):
+ sidecar["track_id"] = track_id
+ write_json_atomic(path, sidecar)
+ except (OSError, ValueError):
+ logger.warning("could not record the track of take %s", path, exc_info=True)
diff --git a/app/pipeline/collect.py b/app/pipeline/collect.py
index f995ac3c..a5a9a154 100644
--- a/app/pipeline/collect.py
+++ b/app/pipeline/collect.py
@@ -249,7 +249,12 @@ def compute_stem_peaks(stems_dir: Path, stem_names: list[str]) -> dict[str, floa
return rms_values
-def merge_stem_peaks(stems_dir: Path, new_names: list[str]) -> dict[str, float]:
+def merge_stem_peaks(
+ stems_dir: Path,
+ new_names: list[str],
+ sources: dict[str, Path] | None = None,
+ remove: list[str] | None = None,
+) -> dict[str, float]:
"""Add peaks/RMS for newly-produced stems (e.g. the on-demand lead/backing
vocal split, #275) into the existing peaks.json instead of recomputing
every stem. Best-effort, same as compute_stem_peaks: a failure here only
@@ -257,7 +262,12 @@ def merge_stem_peaks(stems_dir: Path, new_names: list[str]) -> dict[str, float]:
Returns each scanned stem's RMS, from the same pass, so the caller can
extend stem presence without decoding anything twice (see
- presence_for_split)."""
+ presence_for_split).
+
+ `sources` maps a name to a WAV outside stems_dir: a recorded take lives in
+ takes//audio.wav but its waveform is served from the same
+ peaks.json as the stems, so the chunked engine needs no fallback. `remove`
+ drops entries, for a deleted take."""
path = stems_dir / "peaks.json"
peaks: dict[str, list[list[float]]] = {}
rms_values: dict[str, float] = {}
@@ -267,8 +277,11 @@ def merge_stem_peaks(stems_dir: Path, new_names: list[str]) -> dict[str, float]:
except (OSError, json.JSONDecodeError):
logger.warning("could not read existing peaks.json in %s", stems_dir, exc_info=True)
+ for name in remove or []:
+ peaks.pop(name, None)
+
for name in new_names:
- wav = stems_dir / f"{name}.wav"
+ wav = (sources or {}).get(name) or stems_dir / f"{name}.wav"
if not wav.is_file():
continue
try:
diff --git a/app/pipeline/take_render.py b/app/pipeline/take_render.py
new file mode 100644
index 00000000..ac0de4ae
--- /dev/null
+++ b/app/pipeline/take_render.py
@@ -0,0 +1,442 @@
+"""ffmpeg work for recorded takes: the playable audio and the video render.
+
+Everything here is blocking and runs in a thread (asyncio.to_thread) from
+app/api/takes.py. The command builders are pure functions so the filter
+graphs can be tested without running ffmpeg.
+
+Sign convention, shared with the capture layer in the browser: a positive
+latency or offset means the material arrived late, so it is subtracted.
+"""
+
+from __future__ import annotations
+
+import logging
+import math
+import os
+import re
+import shutil
+import struct
+import subprocess
+import threading
+import time
+import uuid
+from collections import deque
+from collections.abc import Callable
+from pathlib import Path
+from typing import NamedTuple
+
+import soundfile as sf
+
+from app.core.config import EXPORT_LIMIT_DBFS, TIMEOUT_FFMPEG, ffmpeg_executable
+
+logger = logging.getLogger("stemdeck.takes")
+
+# The stems' own format (app/pipeline/collect.py writes pcm_s16le; Demucs
+# output is 44.1 kHz stereo). A take in exactly this format needs no
+# take-specific code in the chunked engine, the peaks, the mixdown or the zip.
+TAKE_RATE = 44100
+TAKE_CHANNELS = 2
+
+# alimiter's limit is linear. level=disabled stops it re-normalising the
+# output to 0 dB (its default does, measured -12.8 instead of -14 LUFS in the
+# mastering study) and latency=1 removes its look-ahead delay, so a lane
+# keeps its timing to the sample. Measured on the bundled ffmpeg 8.1: with a
+# signal under the limit the output is bit-identical to the input, same
+# length; two 0.7 sines summed (52152 samples at full scale without it) peak
+# at exactly -1.0 dBFS with none at full scale.
+EXPORT_LIMITER = f"alimiter=limit={10 ** (EXPORT_LIMIT_DBFS / 20):.6f}:level=disabled:latency=1"
+
+
+class TakeRenderError(RuntimeError):
+ """ffmpeg failed. The message is for the log, never for the client."""
+
+
+def write_float_wav_header(fh, data_bytes: int, rate: int, channels: int) -> None:
+ """A WAVE_FORMAT_IEEE_FLOAT (3) header for 32-bit interleaved samples.
+
+ The stdlib wave module writes integer PCM only, and the raw capture is
+ float32: kept bit-exact so a later re-render (a nudge, an import on
+ another machine) starts from what the microphone delivered."""
+ block_align = channels * 4
+ fh.write(b"RIFF")
+ fh.write(struct.pack(" None:
+ """Wrap the uploaded float32 parts in a WAV header, atomically."""
+ size = pcm.stat().st_size
+ tmp = dest.with_name(f".{dest.name}.{uuid.uuid4().hex}.tmp")
+ try:
+ with tmp.open("wb") as out, pcm.open("rb") as src:
+ write_float_wav_header(out, size, rate, channels)
+ shutil.copyfileobj(src, out, 1024 * 1024)
+ os.replace(tmp, dest)
+ finally:
+ tmp.unlink(missing_ok=True)
+
+
+def wav_frames(path: Path) -> int:
+ return int(sf.info(str(path)).frames)
+
+
+def sniff_container(path: Path) -> str | None:
+ """ "webm" or "mp4" from the first bytes, or None.
+
+ The browser picks the container (WebM on Chromium and Firefox, MP4 on
+ Safari/WKWebView), and a declared MIME type is only a claim. The file
+ name decides which demuxer the render trusts, so it comes from the bytes."""
+ try:
+ with path.open("rb") as fh:
+ head = fh.read(12)
+ except OSError:
+ return None
+ if head[:4] == b"\x1a\x45\xdf\xa3":
+ return "webm"
+ if len(head) >= 8 and head[4:8] == b"ftyp":
+ return "mp4"
+ return None
+
+
+def take_audio_filter(placement_sec: float, frames: int, source_channels: int) -> str:
+ """The -af chain that turns a raw take into a playable one.
+
+ Resampled to 44.1 kHz first, then placed in output samples, so the
+ placement is rounded once at the output rate (at most half a sample out)
+ rather than at the capture rate and again by the resampler. A take that
+ starts before song 0 (count-in, pickup notes, the latency it is
+ compensated for) is trimmed; one that starts after is delayed. Padded with
+ silence and cut to exactly the stems' frame count, so every lane has the
+ same length.
+ """
+ parts: list[str] = []
+ if source_channels == 1:
+ # Explicit, rather than -ac 2: the mono capture goes to both channels
+ # at unity, with no up-mix coefficients in between.
+ parts.append("pan=stereo|c0=c0|c1=c0")
+ parts.append(f"aresample={TAKE_RATE}")
+ offset = int(round(placement_sec * TAKE_RATE))
+ if offset < 0:
+ parts.append(f"atrim=start_sample={-offset}")
+ parts.append("asetpts=PTS-STARTPTS")
+ elif offset > 0:
+ parts.append(f"adelay=delays={offset}S:all=1")
+ parts.append(f"apad=whole_len={frames}")
+ parts.append(f"atrim=end_sample={frames}")
+ return ",".join(parts)
+
+
+def _run(cmd: list[str], timeout: int, context: str) -> None:
+ proc = subprocess.run( # noqa: S603 -- list args, no shell, trusted ffmpeg
+ cmd,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ timeout=timeout,
+ )
+ if proc.returncode != 0:
+ tail = proc.stderr[-2000:].decode("utf-8", "replace")
+ raise TakeRenderError(f"ffmpeg failed [{context}]: {tail}")
+
+
+def render_take_audio(
+ raw: Path,
+ dest: Path,
+ placement_sec: float,
+ frames: int,
+ source_rate: int,
+ source_channels: int,
+) -> None:
+ """Render audio.wav from audio.raw.wav. Blocking; written atomically.
+
+ `placement_sec` is the song time of raw frame 0, everything already
+ folded in (round trip, nudge). `frames` is the stems' frame count."""
+ tmp = dest.with_name(f".{dest.name}.{uuid.uuid4().hex}.tmp.wav")
+ raw_end = placement_sec + wav_frames(raw) / source_rate
+ try:
+ if raw_end <= 0 or placement_sec * TAKE_RATE >= frames:
+ # Nothing of the take lands inside the song: an all-silent lane
+ # of the right length rather than an ffmpeg graph with no frames.
+ cmd = [
+ ffmpeg_executable(),
+ "-nostdin",
+ "-loglevel",
+ "error",
+ "-f",
+ "lavfi",
+ "-i",
+ f"anullsrc=r={TAKE_RATE}:cl=stereo",
+ "-af",
+ f"atrim=end_sample={frames}",
+ ]
+ else:
+ cmd = [
+ ffmpeg_executable(),
+ "-nostdin",
+ "-loglevel",
+ "error",
+ "-i",
+ str(raw),
+ "-af",
+ take_audio_filter(placement_sec, frames, source_channels),
+ ]
+ cmd += [
+ "-ac",
+ str(TAKE_CHANNELS),
+ "-ar",
+ str(TAKE_RATE),
+ "-c:a",
+ "pcm_s16le",
+ "-f",
+ "wav",
+ "-y",
+ str(tmp),
+ ]
+ _run(cmd, TIMEOUT_FFMPEG, f"take audio {raw.parent.name}")
+ os.replace(tmp, dest)
+ finally:
+ tmp.unlink(missing_ok=True)
+
+
+# ---------------------------------------------------------------------------
+# Video render
+
+
+_encoders_cached: str | None = None
+_encoders_lock = threading.Lock()
+
+
+def _encoders() -> str:
+ """`ffmpeg -encoders`, probed once per process (the binary cannot change
+ mid-run). Any probe failure reads as an empty list, so the render says
+ the encoder is missing rather than starting and failing later."""
+ global _encoders_cached
+ with _encoders_lock:
+ if _encoders_cached is None:
+ try:
+ probe = subprocess.run( # noqa: S603 -- fixed args, trusted ffmpeg
+ [ffmpeg_executable(), "-hide_banner", "-encoders"],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ _encoders_cached = probe.stdout
+ except (OSError, subprocess.SubprocessError):
+ logger.debug(
+ "encoder probe failed; treating video render as unavailable", exc_info=True
+ )
+ _encoders_cached = ""
+ return _encoders_cached
+
+
+def libx264_available() -> bool:
+ """libx264 is GPL and left out of LGPL builds (some distro and Homebrew
+ variants), so it is probed like rubberband rather than assumed."""
+ return bool(re.search(r"^\s*V\S*\s+libx264\b", _encoders(), re.MULTILINE))
+
+
+def aac_available() -> bool:
+ """ffmpeg's native AAC encoder; present in every build seen, probed anyway."""
+ return bool(re.search(r"^\s*A\S*\s+aac\b", _encoders(), re.MULTILINE))
+
+
+class AudioLane(NamedTuple):
+ path: Path
+ gain: float
+ pitch_filter: str # "" or "rubberband=...," as stems._pitch_filter builds it
+
+
+CANVAS_SIZES = {"16:9": (1920, 1080), "9:16": (1080, 1920)}
+VIDEO_FPS = 30
+
+
+def camera_filter(
+ start_sec: float, width: int, height: int, fit: str, mirror: bool, label_in: str
+) -> str:
+ """Scale, crop or letterbox one camera into the canvas and place it in
+ song time. `start_sec` is the song time of the camera's first frame: when
+ negative, the front is cut; when positive, the frames are shifted later
+ and the canvas shows black until they arrive."""
+ chain: list[str] = []
+ if start_sec < 0:
+ chain.append(f"trim=start={-start_sec:.6f}")
+ chain.append("setpts=PTS-STARTPTS")
+ else:
+ chain.append(f"setpts=PTS-STARTPTS+{start_sec:.6f}/TB")
+ if fit == "contain":
+ chain.append(f"scale={width}:{height}:force_original_aspect_ratio=decrease")
+ chain.append(f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color=black")
+ else:
+ chain.append(f"scale={width}:{height}:force_original_aspect_ratio=increase")
+ chain.append(f"crop={width}:{height}")
+ if mirror:
+ chain.append("hflip")
+ chain.append("setsar=1")
+ return f"{label_in}{','.join(chain)}[cam]"
+
+
+def build_video_render_cmd(
+ lanes: list[AudioLane],
+ take_audio: Path,
+ take_gain: float,
+ camera: Path,
+ camera_start_sec: float,
+ duration_sec: float,
+ canvas: str,
+ fit: str,
+ mirror: bool,
+ audio_rate: int,
+) -> list[str]:
+ """One ffmpeg command: backing mix + take audio + one camera tile -> MP4.
+
+ The audio graph is the export's (per-lane pitch and gain, amix with
+ normalize=0, the export limiter), with the take as one more lane at no
+ offset: audio.wav is already aligned to song 0. The output has no path;
+ the caller appends -progress and the destination."""
+ width, height = CANVAS_SIZES[canvas]
+ cmd: list[str] = [ffmpeg_executable(), "-nostdin", "-loglevel", "error"]
+ for lane in lanes:
+ cmd += ["-i", str(lane.path)]
+ take_idx = len(lanes)
+ cam_idx = take_idx + 1
+ cmd += ["-i", str(take_audio), "-i", str(camera)]
+
+ filters = [
+ f"[{i}:a]{lane.pitch_filter}volume={lane.gain:.6f}[a{i}]" for i, lane in enumerate(lanes)
+ ]
+ filters.append(f"[{take_idx}:a]volume={take_gain:.6f}[a{take_idx}]")
+ n = take_idx + 1
+ if n > 1:
+ labels = "".join(f"[a{i}]" for i in range(n))
+ filters.append(f"{labels}amix=inputs={n}:normalize=0,{EXPORT_LIMITER}[aout]")
+ else:
+ filters.append(f"[a0]{EXPORT_LIMITER}[aout]")
+
+ filters.append(f"color=c=black:s={width}x{height}:r={VIDEO_FPS}:d={duration_sec:.6f}[bg]")
+ filters.append(camera_filter(camera_start_sec, width, height, fit, mirror, f"[{cam_idx}:v]"))
+ # eof_action=pass: once the camera ends the black canvas carries on, so
+ # the video is always the song's length and never freezes on a last frame.
+ filters.append(
+ f"[bg][cam]overlay=eof_action=pass:shortest=0,fps={VIDEO_FPS},format=yuv420p[vout]"
+ )
+ cmd += [
+ "-filter_complex",
+ ";".join(filters),
+ "-map",
+ "[vout]",
+ "-map",
+ "[aout]",
+ "-c:v",
+ "libx264",
+ "-crf",
+ "20",
+ "-pix_fmt",
+ "yuv420p",
+ "-r",
+ str(VIDEO_FPS),
+ "-c:a",
+ "aac",
+ "-b:a",
+ "192k",
+ "-ar",
+ str(audio_rate),
+ "-t",
+ f"{duration_sec:.6f}",
+ # moov before mdat: the file plays while it downloads and every
+ # upload site takes it. Needs a seekable output, so this renders to a
+ # file, never a pipe (unlike get_video_mixdown's fragmented stream).
+ "-movflags",
+ "+faststart",
+ ]
+ return cmd
+
+
+class RenderCancelled(Exception):
+ pass
+
+
+def run_video_render(
+ cmd: list[str],
+ dest: Path,
+ duration_sec: float,
+ *,
+ cancelled: Callable[[], bool],
+ report: Callable[[float], None],
+ on_proc: Callable[[subprocess.Popen | None], None],
+ timeout: int,
+) -> None:
+ """Run the render to a temp file beside `dest`, reporting progress from
+ ffmpeg's -progress stream, then rename it into place. Blocking.
+
+ Raises RenderCancelled when `cancelled()` turns true (polled about every
+ half second), TakeRenderError on any ffmpeg failure or timeout. Never
+ leaves a partial file at `dest`."""
+ tmp = dest.with_name(f".{dest.name}.{uuid.uuid4().hex}.tmp.mp4")
+ full = [*cmd, "-progress", "pipe:1", "-nostats", "-f", "mp4", "-y", str(tmp)]
+ proc = subprocess.Popen( # noqa: S603 -- list args, no shell, trusted ffmpeg
+ full,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+ on_proc(proc)
+ stderr_tail: deque[str] = deque(maxlen=40)
+
+ def drain_stderr() -> None:
+ for line in proc.stderr: # type: ignore[union-attr]
+ stderr_tail.append(line.rstrip())
+
+ drain = threading.Thread(target=drain_stderr, daemon=True)
+ drain.start()
+ stop = threading.Event()
+
+ def watchdog() -> None:
+ # Cancellation and the overall timeout, checked off the reading
+ # thread: the progress pipe can sit silent for a long time on a
+ # slow encode, and a blocked readline must not delay either.
+ deadline = time.monotonic() + timeout
+ while not stop.wait(0.5):
+ if cancelled() or time.monotonic() > deadline:
+ if proc.poll() is None:
+ proc.kill()
+ return
+
+ guard = threading.Thread(target=watchdog, daemon=True)
+ guard.start()
+ ok = False
+ try:
+ for line in proc.stdout: # type: ignore[union-attr]
+ key, _, value = line.strip().partition("=")
+ # out_time_us is microseconds; out_time_ms is too, despite its name.
+ if key == "out_time_us" and value.lstrip("-").isdigit() and duration_sec > 0:
+ report(max(0.0, min(0.99, int(value) / 1e6 / duration_sec)))
+ proc.wait()
+ drain.join(timeout=5)
+ if cancelled():
+ raise RenderCancelled()
+ if proc.returncode != 0:
+ raise TakeRenderError(
+ f"video render exit {proc.returncode}: " + " | ".join(list(stderr_tail)[-8:])
+ )
+ os.replace(tmp, dest)
+ ok = True
+ report(1.0)
+ finally:
+ stop.set()
+ if proc.poll() is None:
+ proc.kill()
+ proc.wait()
+ on_proc(None)
+ if not ok:
+ tmp.unlink(missing_ok=True)
+
+
+def is_finite_number(value: object) -> bool:
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
diff --git a/tests/test_takes_api.py b/tests/test_takes_api.py
new file mode 100644
index 00000000..16fa18e8
--- /dev/null
+++ b/tests/test_takes_api.py
@@ -0,0 +1,629 @@
+"""Recorded takes: storage, upload, alignment, edit, delete, and takes as
+export lanes.
+
+The alignment tests are the ones that matter most: a take that is placed a
+few milliseconds wrong sounds late, and nothing else in the suite would
+notice. They put a click at a known raw frame and assert where it lands in
+audio.wav, to the sample.
+"""
+
+from __future__ import annotations
+
+import io
+import json
+import struct
+
+import httpx
+import numpy as np
+import pytest
+import soundfile as sf
+
+from app.api import stems as stems_mod
+from app.api import takes as takes_mod
+from app.core.models import Job
+from app.core.registry import _jobs
+from tests.ffmpeg_probe import skip_without_ffmpeg
+
+JOB = "abcdefabcdef"
+SR = 44100
+SONG_SEC = 3.0
+SONG_FRAMES = int(SR * SONG_SEC)
+
+
+def _app():
+ from app.main import app
+
+ return app
+
+
+@pytest.fixture
+def jobs_dir(tmp_path, monkeypatch):
+ monkeypatch.setattr(stems_mod, "_MIXDOWN_CACHE_DIR", tmp_path / "cache" / "mixdown")
+ monkeypatch.setattr(stems_mod, "_CLICK_CACHE_DIR", tmp_path / "cache" / "click")
+ from app.core import config
+
+ return config.JOBS_DIR
+
+
+@pytest.fixture
+def client(jobs_dir):
+ return httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=_app()), base_url="http://testserver"
+ )
+
+
+def _make_job(jobs_dir, amplitude=0.0, names=("vocals", "drums", "bass")) -> Job:
+ job = Job(id=JOB, status="done", title="Song Title", duration_sec=SONG_SEC)
+ _jobs[job.id] = job
+ stems = jobs_dir / JOB / "stems"
+ stems.mkdir(parents=True, exist_ok=True)
+ t = np.arange(SONG_FRAMES) / SR
+ for i, name in enumerate(names):
+ x = amplitude * np.sin(2 * np.pi * (440 + 3 * i) * t)
+ sf.write(stems / f"{name}.wav", np.stack([x, x], 1), SR, subtype="PCM_16")
+ job.stems.append({"name": name, "url": f"/api/jobs/{JOB}/stems/{name}.wav"})
+ return job
+
+
+def _click_take(rate: int, seconds: float, click_frame: int, channels: int = 1) -> bytes:
+ """float32 LE interleaved PCM, silent but for one full-scale sample."""
+ x = np.zeros((int(rate * seconds), channels), dtype=" str:
+ r = await c.post(f"/api/jobs/{JOB}/tracks", json=body)
+ assert r.status_code == 201, r.text
+ return r.json()["id"]
+
+
+async def _create(c, track_id: str | None = None) -> str:
+ """A take on `track_id`, or on a fresh track of its own."""
+ track_id = track_id or await _track(c)
+ r = await c.post(f"/api/jobs/{JOB}/takes", json={"track_id": track_id})
+ assert r.status_code == 201, r.text
+ return r.json()["take_id"]
+
+
+async def _upload(c, take_id: str, data: bytes, kind: str = "audio", part: int = 1 << 20):
+ for seq, start in enumerate(range(0, len(data), part)):
+ r = await c.put(
+ f"/api/jobs/{JOB}/takes/{take_id}/parts/{kind}?seq={seq}",
+ content=data[start : start + part],
+ headers={"content-type": "application/octet-stream"},
+ )
+ assert r.status_code == 200, r.text
+
+
+def _click_at(path) -> int:
+ y, rate = sf.read(path, dtype="float64")
+ assert rate == SR
+ return int(np.argmax(np.abs(y[:, 0])))
+
+
+# ─── Create ──────────────────────────────────────────────────────────────
+
+
+async def test_create_returns_an_id_and_a_default_label(jobs_dir, client):
+ _make_job(jobs_dir)
+ async with client as c:
+ track = await _track(c)
+ r = await c.post(f"/api/jobs/{JOB}/takes", json={"track_id": track})
+ assert r.status_code == 201
+ body = r.json()
+ assert takes_mod.TAKE_ID_RE.match(body["take_id"])
+ assert body["label"] == "Take 1" and body["track_id"] == track
+ r = await c.post(f"/api/jobs/{JOB}/takes", json={"track_id": track, "label": "Guitar"})
+ assert r.json()["label"] == "Guitar"
+ assert (await c.post(f"/api/jobs/{JOB}/takes", json={})).status_code == 422
+ r = await c.post(f"/api/jobs/{JOB}/takes", json={"track_id": "k00000000"})
+ assert r.status_code == 422 and r.json()["detail"] == "unknown track"
+ assert (jobs_dir / JOB / "takes" / body["take_id"] / "parts.json").is_file()
+
+
+async def test_create_refuses_unknown_job_and_unfinished_job(jobs_dir, client):
+ async with client as c:
+ assert (await c.post("/api/jobs/000000000000/takes")).status_code == 404
+ assert (await c.post("/api/jobs/not-a-job/takes")).status_code == 404
+ job = _make_job(jobs_dir)
+ job.status = "running"
+ assert (await c.post(f"/api/jobs/{JOB}/takes")).status_code == 404
+
+
+async def test_create_refuses_a_bad_label(jobs_dir, client):
+ _make_job(jobs_dir)
+ async with client as c:
+ track = await _track(c)
+ for label in ("", "x" * 81, "bad\nlabel"):
+ r = await c.post(f"/api/jobs/{JOB}/takes", json={"track_id": track, "label": label})
+ assert r.status_code == 422, label
+
+
+async def test_create_stops_at_the_take_limit(jobs_dir, client, monkeypatch):
+ monkeypatch.setattr(takes_mod, "TAKE_MAX_PER_JOB", 2)
+ _make_job(jobs_dir)
+ async with client as c:
+ track = await _track(c)
+ await _create(c, track)
+ await _create(c, track)
+ r = await c.post(f"/api/jobs/{JOB}/takes", json={"track_id": track})
+ assert r.status_code == 409
+
+
+# ─── Parts ───────────────────────────────────────────────────────────────
+
+
+async def test_parts_must_arrive_in_order_and_a_repeat_is_a_no_op(jobs_dir, client):
+ _make_job(jobs_dir)
+ url = f"/api/jobs/{JOB}/takes/{{}}/parts/audio?seq={{}}"
+ async with client as c:
+ tid = await _create(c)
+ assert (await c.put(url.format(tid, 0), content=b"\0" * 8)).json()["bytes"] == 8
+ dup = await c.put(url.format(tid, 0), content=b"\0" * 8)
+ assert dup.status_code == 200 and dup.json()["duplicate"] is True
+ assert dup.json()["bytes"] == 8
+ assert (await c.put(url.format(tid, 2), content=b"\0" * 8)).status_code == 409
+ assert (await c.put(url.format(tid, 1), content=b"\0" * 8)).json()["bytes"] == 16
+ state = (await c.get(f"/api/jobs/{JOB}/takes/{tid}/parts")).json()
+ assert state["audio"] == {"next_seq": 2, "bytes": 16}
+ assert state["camera"] == {"next_seq": 0, "bytes": 0}
+ assert (jobs_dir / JOB / "takes" / tid / "audio.part").stat().st_size == 16
+
+
+async def test_part_validation(jobs_dir, client):
+ _make_job(jobs_dir)
+ async with client as c:
+ tid = await _create(c)
+ base = f"/api/jobs/{JOB}/takes/{tid}/parts"
+ assert (await c.put(f"{base}/video?seq=0", content=b"x")).status_code == 404
+ assert (await c.put(f"{base}/audio?seq=0", content=b"")).status_code == 422
+ assert (await c.put(f"{base}/audio?seq=0", content=b"123")).status_code == 422
+ assert (await c.put(f"{base}/audio?seq=-1", content=b"1234")).status_code == 422
+ assert (await c.put(f"{base}/audio?seq=100001", content=b"1234")).status_code == 422
+ assert (await c.put(f"{base}/audio", content=b"1234")).status_code == 422
+
+
+async def test_part_over_4_mb_is_refused(jobs_dir, client):
+ _make_job(jobs_dir)
+ async with client as c:
+ tid = await _create(c)
+ r = await c.put(
+ f"/api/jobs/{JOB}/takes/{tid}/parts/camera?seq=0", content=b"\0" * (4 * 1024 * 1024 + 4)
+ )
+ assert r.status_code == 413
+
+
+async def test_total_size_is_bounded_by_the_song(jobs_dir, client, monkeypatch):
+ monkeypatch.setattr(takes_mod, "TAKE_SLACK_SEC", 0)
+ monkeypatch.setattr(takes_mod, "TAKE_CAMERA_MAX_BYTES_PER_SEC", 10)
+ _make_job(jobs_dir) # 3 s song -> camera cap 30 bytes
+ async with client as c:
+ tid = await _create(c)
+ base = f"/api/jobs/{JOB}/takes/{tid}/parts/camera"
+ assert (await c.put(f"{base}?seq=0", content=b"\0" * 20)).status_code == 200
+ assert (await c.put(f"{base}?seq=1", content=b"\0" * 20)).status_code == 413
+
+
+@pytest.mark.parametrize(
+ "path",
+ [
+ "/api/jobs/{job}/takes/../../etc/parts/audio?seq=0",
+ "/api/jobs/{job}/takes/..%2F..%2Fetc/parts/audio?seq=0",
+ "/api/jobs/{job}/takes/t1234/parts/audio?seq=0",
+ "/api/jobs/{job}/takes/T0000000A/parts/audio?seq=0",
+ "/api/jobs/..%2F..%2F/takes/t00000000/parts/audio?seq=0",
+ ],
+)
+async def test_traversal_and_malformed_ids_are_404(jobs_dir, client, path):
+ _make_job(jobs_dir)
+ async with client as c:
+ r = await c.put(path.format(job=JOB), content=b"1234")
+ # Dots, literal or with an encoded slash, are collapsed or split into
+ # extra segments before routing, so those land on no take route at all
+ # (405); the malformed ids reach the take routes and are refused by the
+ # id checks. Never 2xx and never 500.
+ assert r.status_code in ((404, 405) if ".." in path else (404,))
+ assert not list(jobs_dir.parent.rglob("*.part"))
+
+
+async def test_unknown_take_is_404_everywhere(jobs_dir, client):
+ _make_job(jobs_dir)
+ tid = "t00000000"
+ async with client as c:
+ assert (
+ await c.put(f"/api/jobs/{JOB}/takes/{tid}/parts/audio?seq=0", content=b"1234")
+ ).status_code == 404
+ assert (
+ await c.post(f"/api/jobs/{JOB}/takes/{tid}/finish", json=_sidecar(0))
+ ).status_code == 404
+ assert (
+ await c.patch(f"/api/jobs/{JOB}/takes/{tid}", json={"label": "x"})
+ ).status_code == 404
+ assert (await c.delete(f"/api/jobs/{JOB}/takes/{tid}")).status_code == 404
+ assert (await c.get(f"/api/jobs/{JOB}/takes/{tid}/audio.wav")).status_code == 404
+
+
+# ─── Finish and alignment ────────────────────────────────────────────────
+
+
+async def test_finish_renders_the_stem_format_and_lands_the_click_on_its_sample(jobs_dir, client):
+ """Golden alignment test (05 section 10). A 48 kHz mono take whose raw
+ frame 0 sits 0.5 s before song 0 (count-in), click at raw frame 72000
+ (1.5 s into the capture): the click belongs at song 1.0 s, sample 44100."""
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ tid = await _create(c)
+ await _upload(c, tid, _click_take(48000, 4.0, 72000))
+ r = await c.post(f"/api/jobs/{JOB}/takes/{tid}/finish", json=_sidecar(-0.5))
+ assert r.status_code == 200, r.text
+ entry = r.json()
+ state = (await c.get(f"/api/jobs/{JOB}")).json()
+ assert entry["id"] == tid and entry["label"] == "Take 1"
+ assert entry["url"] == f"/api/jobs/{JOB}/takes/{tid}/audio.wav"
+ assert entry["user_nudge_ms"] == 0 and entry["has_camera"] is False
+ assert state["takes"] == [entry]
+
+ folder = jobs_dir / JOB / "takes" / tid
+ info = sf.info(str(folder / "audio.wav"))
+ assert (info.samplerate, info.channels, info.subtype, info.frames) == (
+ SR,
+ 2,
+ "PCM_16",
+ SONG_FRAMES,
+ )
+ assert abs(_click_at(folder / "audio.wav") - 44100) <= 1
+ y, _ = sf.read(folder / "audio.wav")
+ assert np.allclose(y[:, 0], y[:, 1]) # mono copied to both channels
+
+ raw = sf.info(str(folder / "audio.raw.wav"))
+ assert (raw.samplerate, raw.channels, raw.subtype, raw.frames) == (48000, 1, "FLOAT", 192000)
+ sidecar = json.loads((folder / "take.json").read_text(encoding="utf-8"))
+ assert sidecar["id"] == tid and sidecar["v"] == 1 and "created_at" in sidecar
+ assert not (folder / "audio.part").exists()
+ peaks = json.loads((jobs_dir / JOB / "stems" / "peaks.json").read_text(encoding="utf-8"))
+ assert tid in peaks
+
+
+async def test_a_take_that_starts_late_is_delayed_and_padded(jobs_dir, client):
+ """Stereo, 44.1 kHz, raw frame 0 at song 0.25 s, click at raw frame 4410:
+ song 0.35 s, sample 15435. The take ends before the song: padded."""
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ tid = await _create(c)
+ await _upload(c, tid, _click_take(44100, 1.0, 4410, channels=2))
+ r = await c.post(
+ f"/api/jobs/{JOB}/takes/{tid}/finish", json=_sidecar(0.25, rate=44100, channels=2)
+ )
+ assert r.status_code == 200, r.text
+ wav = jobs_dir / JOB / "takes" / tid / "audio.wav"
+ assert sf.info(str(wav)).frames == SONG_FRAMES
+ assert abs(_click_at(wav) - 15435) <= 1
+
+
+async def test_nudge_is_subtracted_and_re_renders_from_the_raw_capture(jobs_dir, client):
+ """Positive nudge = the take was late, so it moves earlier."""
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ tid = await _create(c)
+ await _upload(c, tid, _click_take(48000, 4.0, 72000))
+ await c.post(f"/api/jobs/{JOB}/takes/{tid}/finish", json=_sidecar(-0.5))
+ r = await c.patch(f"/api/jobs/{JOB}/takes/{tid}", json={"user_nudge_ms": 100})
+ assert r.status_code == 200, r.text
+ assert r.json()["user_nudge_ms"] == 100
+ wav = jobs_dir / JOB / "takes" / tid / "audio.wav"
+ assert abs(_click_at(wav) - (44100 - 4410)) <= 1
+ r = await c.patch(f"/api/jobs/{JOB}/takes/{tid}", json={"user_nudge_ms": -100})
+ assert abs(_click_at(wav) - (44100 + 4410)) <= 1
+ # A nudge sent at finish is applied the same way.
+ tid2 = await _create(c)
+ await _upload(c, tid2, _click_take(48000, 4.0, 72000))
+ body = _sidecar(-0.5)
+ body["latency"]["user_nudge_ms"] = 100
+ await c.post(f"/api/jobs/{JOB}/takes/{tid2}/finish", json=body)
+ assert abs(_click_at(jobs_dir / JOB / "takes" / tid2 / "audio.wav") - (44100 - 4410)) <= 1
+
+
+async def test_a_take_entirely_outside_the_song_is_silence_of_the_right_length(jobs_dir, client):
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ tid = await _create(c)
+ await _upload(c, tid, _click_take(48000, 0.5, 100))
+ r = await c.post(f"/api/jobs/{JOB}/takes/{tid}/finish", json=_sidecar(-2.0))
+ assert r.status_code == 200, r.text
+ y, _ = sf.read(jobs_dir / JOB / "takes" / tid / "audio.wav")
+ assert len(y) == SONG_FRAMES and not y.any()
+
+
+@pytest.mark.parametrize(
+ "mutate",
+ [
+ lambda b: b.update(v=2),
+ lambda b: b["timeline"].update(tempo=1.25),
+ lambda b: b["timeline"].pop("first_sample_song_sec"),
+ lambda b: b["timeline"].update(first_sample_song_sec=1e9),
+ lambda b: b["capture"].update(channels=3),
+ lambda b: b["capture"].update(rate=1000),
+ lambda b: b["latency"].update(user_nudge_ms=5000),
+ lambda b: b["latency"].update(source="guessed"),
+ lambda b: b.update(session={"blob": "x" * 9000}),
+ lambda b: b.update(video={"cameraOffsetMs": 99999}),
+ lambda b: b.update(label="a\x00b"),
+ ],
+)
+async def test_finish_refuses_a_bad_sidecar(jobs_dir, client, mutate):
+ _make_job(jobs_dir)
+ body = _sidecar(0.0)
+ mutate(body)
+ async with client as c:
+ tid = await _create(c)
+ await _upload(c, tid, _click_take(48000, 0.1, 0))
+ r = await c.post(f"/api/jobs/{JOB}/takes/{tid}/finish", json=body)
+ assert r.status_code == 422, r.text
+
+
+async def test_finish_refuses_non_finite_numbers(jobs_dir, client):
+ _make_job(jobs_dir)
+ async with client as c:
+ tid = await _create(c)
+ await _upload(c, tid, _click_take(48000, 0.1, 0))
+ raw = json.dumps(_sidecar(0.0)).replace(
+ '"first_sample_song_sec": 0.0', '"first_sample_song_sec": NaN'
+ )
+ assert "NaN" in raw
+ r = await c.post(
+ f"/api/jobs/{JOB}/takes/{tid}/finish",
+ content=raw,
+ headers={"content-type": "application/json"},
+ )
+ assert r.status_code == 422
+
+
+async def test_finish_refuses_missing_or_ragged_audio_and_can_be_retried(jobs_dir, client):
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ tid = await _create(c)
+ url = f"/api/jobs/{JOB}/takes/{tid}/finish"
+ assert (await c.post(url, json=_sidecar(0.0))).status_code == 422 # no audio
+ await _upload(c, tid, b"\0" * 12) # 3 samples: not whole stereo frames
+ assert (await c.post(url, json=_sidecar(0.0, channels=2))).status_code == 422
+ # The same parts are still there; a corrected sidecar finishes.
+ assert (await c.post(url, json=_sidecar(0.0, channels=1))).status_code == 200
+ assert (await c.post(url, json=_sidecar(0.0))).status_code == 409
+ r = await c.put(f"/api/jobs/{JOB}/takes/{tid}/parts/audio?seq=1", content=b"1234")
+ assert r.status_code == 409
+
+
+async def test_camera_container_is_sniffed_from_the_bytes(jobs_dir, client):
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ tid = await _create(c)
+ await _upload(c, tid, _click_take(48000, 0.5, 0))
+ await _upload(c, tid, b"\x1a\x45\xdf\xa3" + b"\0" * 60, kind="camera")
+ body = _sidecar(0.0, video={"mime": "video/mp4", "file": "../evil", "cameraOffsetMs": 40})
+ r = await c.post(f"/api/jobs/{JOB}/takes/{tid}/finish", json=body)
+ assert r.status_code == 200, r.text
+ entry = r.json()
+ cam = await c.get(f"/api/jobs/{JOB}/takes/{tid}/camera")
+ bad = await _create(c)
+ await _upload(c, bad, _click_take(48000, 0.5, 0))
+ await _upload(c, bad, b"not a video at all", kind="camera")
+ refused = await c.post(f"/api/jobs/{JOB}/takes/{bad}/finish", json=_sidecar(0.0, video={}))
+ # Without a video block the camera upload was incomplete: discarded.
+ partial = await _create(c)
+ await _upload(c, partial, _click_take(48000, 0.5, 0))
+ await _upload(c, partial, b"\x1a\x45\xdf\xa3" + b"\0" * 60, kind="camera")
+ no_video = await c.post(f"/api/jobs/{JOB}/takes/{partial}/finish", json=_sidecar(0.0))
+ folder = jobs_dir / JOB / "takes" / tid
+ assert (folder / "camera.webm").is_file() and not (folder / "camera.part").exists()
+ assert entry["has_camera"] is True and entry["camera_offset_ms"] == 40
+ assert entry["camera_url"] == f"/api/jobs/{JOB}/takes/{tid}/camera"
+ assert json.loads((folder / "take.json").read_text())["video"]["file"] == "camera.webm"
+ assert cam.status_code == 200 and cam.headers["content-type"] == "video/webm"
+ assert refused.status_code == 422
+ assert no_video.status_code == 200 and no_video.json()["has_camera"] is False
+ assert not list((jobs_dir / JOB / "takes" / partial).glob("camera*"))
+
+
+# ─── Edit, delete, serve ─────────────────────────────────────────────────
+
+
+async def _finished_take(c, jobs_dir, first=0.0) -> str:
+ tid = await _create(c)
+ await _upload(c, tid, _click_take(48000, 1.0, 24000))
+ r = await c.post(f"/api/jobs/{JOB}/takes/{tid}/finish", json=_sidecar(first))
+ assert r.status_code == 200, r.text
+ return tid
+
+
+async def test_patch_renames_and_validates(jobs_dir, client):
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ tid = await _finished_take(c, jobs_dir)
+ r = await c.patch(f"/api/jobs/{JOB}/takes/{tid}", json={"label": "Solo"})
+ assert r.status_code == 200 and r.json()["label"] == "Solo"
+ assert (await c.get(f"/api/jobs/{JOB}")).json()["takes"][0]["label"] == "Solo"
+ assert (await c.patch(f"/api/jobs/{JOB}/takes/{tid}", json={})).status_code == 422
+ assert (await c.patch(f"/api/jobs/{JOB}/takes/{tid}", json={"bogus": 1})).status_code == 422
+ r = await c.patch(f"/api/jobs/{JOB}/takes/{tid}", json={"user_nudge_ms": 2001})
+ assert r.status_code == 422
+ r = await c.patch(f"/api/jobs/{JOB}/takes/{tid}", json={"camera_offset_ms": 10})
+ assert r.status_code == 422 # no camera
+ unfinished = await _create(c)
+ r = await c.patch(f"/api/jobs/{JOB}/takes/{unfinished}", json={"label": "x"})
+ assert r.status_code == 409
+
+
+async def test_delete_removes_folder_lane_and_peaks(jobs_dir, client):
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ tid = await _finished_take(c, jobs_dir)
+ unfinished = await _create(c)
+ r = await c.delete(f"/api/jobs/{JOB}/takes/{tid}")
+ assert r.status_code == 200 and r.json() == {"take_id": tid, "status": "deleted"}
+ assert (await c.delete(f"/api/jobs/{JOB}/takes/{unfinished}")).status_code == 200
+ assert (await c.get(f"/api/jobs/{JOB}")).json()["takes"] == []
+ assert (await c.get(f"/api/jobs/{JOB}/takes/{tid}/audio.wav")).status_code == 404
+ assert not (jobs_dir / JOB / "takes" / tid).exists()
+ assert not (jobs_dir / JOB / "takes" / unfinished).exists()
+ peaks = json.loads((jobs_dir / JOB / "stems" / "peaks.json").read_text(encoding="utf-8"))
+ assert tid not in peaks
+
+
+async def test_audio_is_served_with_range_support_only_when_finished(jobs_dir, client):
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ unfinished = await _create(c)
+ assert (await c.get(f"/api/jobs/{JOB}/takes/{unfinished}/audio.wav")).status_code == 404
+ tid = await _finished_take(c, jobs_dir)
+ full = await c.get(f"/api/jobs/{JOB}/takes/{tid}/audio.wav")
+ head = await c.head(f"/api/jobs/{JOB}/takes/{tid}/audio.wav")
+ part = await c.get(
+ f"/api/jobs/{JOB}/takes/{tid}/audio.wav", headers={"range": "bytes=0-99"}
+ )
+ assert full.status_code == 200 and full.headers["content-type"] == "audio/wav"
+ assert "Song_Title_Take_1.wav" in full.headers["content-disposition"]
+ assert head.status_code == 200
+ assert part.status_code == 206 and len(part.content) == 100
+
+
+async def test_takes_survive_a_registry_rebuild_from_disk(jobs_dir, client):
+ """_recover_done_job rebuilds a job from its folder; the takes are files
+ first, so they come back with it."""
+ skip_without_ffmpeg()
+ from app.core.registry import _recover_done_job
+
+ _make_job(jobs_dir)
+ async with client as c:
+ tid = await _finished_take(c, jobs_dir)
+ await _create(c) # unfinished: not a lane
+ entry = (await c.get(f"/api/jobs/{JOB}")).json()["takes"][0]
+ recovered = _recover_done_job(jobs_dir / JOB)
+ assert recovered is not None and recovered.takes == [entry]
+ assert Job.from_record(_jobs[JOB].to_record()).takes == [entry]
+ assert entry["id"] == tid
+
+
+async def test_deleting_the_job_removes_its_takes(jobs_dir, client):
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ await _finished_take(c, jobs_dir)
+ assert (await c.delete(f"/api/jobs/{JOB}")).status_code == 200
+ assert not (jobs_dir / JOB).exists()
+
+
+# ─── Takes as export lanes ───────────────────────────────────────────────
+
+
+async def test_take_is_a_mixdown_lane_and_a_nudge_misses_the_cache(jobs_dir, client):
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ tid = await _finished_take(c, jobs_dir)
+ url = f"/api/jobs/{JOB}/mixdown.wav?stems=vocals,{tid}&gains=1,0.5"
+ r1 = await c.get(url)
+ assert r1.status_code == 200, r1.text
+ y, _ = sf.read(io.BytesIO(r1.content))
+ # Click at song 0.5 s at gain 0.5 (vocals are silent in this fixture).
+ assert abs(int(np.argmax(np.abs(y[:, 0]))) - 22050) <= 1
+ assert 0.4 < np.abs(y).max() <= 0.5 + 1e-3
+ await c.patch(f"/api/jobs/{JOB}/takes/{tid}", json={"user_nudge_ms": 100})
+ r2 = await c.get(url)
+ y2, _ = sf.read(io.BytesIO(r2.content))
+ assert abs(int(np.argmax(np.abs(y2[:, 0]))) - (22050 - 4410)) <= 1
+ assert (
+ await c.get(f"/api/jobs/{JOB}/mixdown.wav?stems=t0000aaaa&gains=1")
+ ).status_code == 422
+ unfinished = await _create(c)
+ r = await c.get(f"/api/jobs/{JOB}/mixdown.wav?stems={unfinished}&gains=1")
+ assert r.status_code == 422
+
+
+async def test_take_goes_in_the_zip_by_label(jobs_dir, client):
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ tid = await _finished_take(c, jobs_dir)
+ r = await c.get(f"/api/jobs/{JOB}/stems/all.zip?format=wav&stems=drums,{tid}")
+ default = await c.get(f"/api/jobs/{JOB}/stems/all.zip?format=wav")
+ import zipfile
+
+ names = zipfile.ZipFile(io.BytesIO(r.content)).namelist()
+ assert f"Song_Title_Take_1_{tid}.wav" in names and "Song_Title_drums.wav" in names
+ assert not any(tid in n for n in zipfile.ZipFile(io.BytesIO(default.content)).namelist())
+
+
+def test_cache_key_carries_take_versions():
+ a = stems_mod._mixdown_cache_key(JOB, "wav", ["t00000000"], [1.0], None, None)
+ b = stems_mod._mixdown_cache_key(
+ JOB, "wav", ["t00000000"], [1.0], None, None, lane_versions=["t00000000@1:2"]
+ )
+ assert a != b
+
+
+# ─── Export clipping ─────────────────────────────────────────────────────
+
+
+async def test_a_loud_sum_no_longer_clips(jobs_dir, client):
+ """Three sines at 0.7 sum to about 2.1: before the limiter the WAV held
+ tens of thousands of samples at full scale (the mastering study measured
+ 77 on a real song). Now none, and the peak is at the -1 dBFS ceiling."""
+ skip_without_ffmpeg()
+ _make_job(jobs_dir, amplitude=0.7)
+ async with client as c:
+ r = await c.get(f"/api/jobs/{JOB}/mixdown.wav?stems=vocals,drums,bass&gains=1,1,1")
+ one = await c.get(f"/api/jobs/{JOB}/mixdown.wav?stems=vocals&gains=4")
+ for resp in (r, one):
+ assert resp.status_code == 200
+ y, _ = sf.read(io.BytesIO(resp.content), dtype="int16")
+ assert int((np.abs(y.astype(int)) >= 32767).sum()) == 0
+ peak_db = 20 * np.log10(np.abs(y).max() / 32768)
+ assert -1.1 < peak_db <= -0.99
+
+
+async def test_a_mix_under_the_ceiling_is_unchanged(jobs_dir, client):
+ """The limiter is idle below -1 dBFS: the export equals the plain sum."""
+ skip_without_ffmpeg()
+ _make_job(jobs_dir, amplitude=0.2)
+ async with client as c:
+ r = await c.get(f"/api/jobs/{JOB}/mixdown.wav?stems=vocals,drums&gains=1,1")
+ y, _ = sf.read(io.BytesIO(r.content), dtype="int16")
+ stems = jobs_dir / JOB / "stems"
+ a, _ = sf.read(stems / "vocals.wav", dtype="int16")
+ b, _ = sf.read(stems / "drums.wav", dtype="int16")
+ expected = a.astype(int) + b.astype(int)
+ assert len(y) == len(expected)
+ assert np.abs(y.astype(int) - expected).max() <= 1
+
+
+def test_float_wav_header_is_what_soundfile_reads(tmp_path):
+ from app.pipeline.take_render import assemble_raw_wav
+
+ pcm = tmp_path / "a.part"
+ data = np.array([[0.5, -0.25], [1.0, 0.0]], dtype=" MP4.
+
+The end-to-end tests run the real ffmpeg on a white camera clip and read
+frames back, so they check placement and the offset sign, not just that a
+file appeared.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import subprocess
+import time
+
+import httpx
+import numpy as np
+import pytest
+import soundfile as sf
+
+from app.api import stems as stems_mod
+from app.api import takes as takes_mod
+from app.core.config import ffmpeg_executable, ffprobe_executable
+from app.core.models import Job
+from app.core.registry import _jobs
+from app.pipeline import take_render
+from app.pipeline.take_render import AudioLane, build_video_render_cmd, camera_filter
+from tests.ffmpeg_probe import skip_without_ffmpeg
+
+JOB = "abcdefabcdef"
+SR = 44100
+SONG_SEC = 3.0
+
+
+def _app():
+ from app.main import app
+
+ return app
+
+
+@pytest.fixture
+def jobs_dir(tmp_path, monkeypatch):
+ monkeypatch.setattr(stems_mod, "_MIXDOWN_CACHE_DIR", tmp_path / "cache" / "mixdown")
+ from app.core import config
+
+ takes_mod._renders.clear()
+ return config.JOBS_DIR
+
+
+@pytest.fixture
+def client(jobs_dir):
+ return httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=_app()), base_url="http://testserver"
+ )
+
+
+def _make_job(jobs_dir) -> Job:
+ job = Job(id=JOB, status="done", title="Song", duration_sec=SONG_SEC)
+ _jobs[job.id] = job
+ stems = jobs_dir / JOB / "stems"
+ stems.mkdir(parents=True, exist_ok=True)
+ t = np.arange(int(SR * SONG_SEC)) / SR
+ for name in ("vocals", "drums"):
+ x = 0.2 * np.sin(2 * np.pi * 440 * t)
+ sf.write(stems / f"{name}.wav", np.stack([x, x], 1), SR, subtype="PCM_16")
+ return job
+
+
+def _white_camera(path, seconds=1.0, container="mp4") -> bytes:
+ """A white 1 s clip. WebM is written to a pipe, as MediaRecorder writes
+ it: no seek-back, so no duration, no cues and clusters of unknown size."""
+ if container == "webm":
+ out = subprocess.run(
+ [
+ ffmpeg_executable(),
+ "-nostdin",
+ "-loglevel",
+ "error",
+ "-f",
+ "lavfi",
+ "-i",
+ f"color=c=white:s=320x240:r=30:d={seconds}",
+ "-c:v",
+ "libvpx",
+ "-f",
+ "webm",
+ "pipe:1",
+ ],
+ capture_output=True,
+ check=True,
+ timeout=60,
+ ).stdout
+ path.write_bytes(out)
+ return out
+ subprocess.run(
+ [
+ ffmpeg_executable(),
+ "-nostdin",
+ "-loglevel",
+ "error",
+ "-y",
+ "-f",
+ "lavfi",
+ "-i",
+ f"color=c=white:s=320x240:r=30:d={seconds}",
+ "-c:v",
+ "libx264",
+ "-pix_fmt",
+ "yuv420p",
+ str(path),
+ ],
+ check=True,
+ timeout=60,
+ )
+ return path.read_bytes()
+
+
+async def _take_with_camera(
+ c, tmp_path, first_frame_ms=1000.0, camera_offset_ms=0.0, container="mp4"
+) -> str:
+ track = (await c.post(f"/api/jobs/{JOB}/tracks", json={})).json()["id"]
+ r = await c.post(f"/api/jobs/{JOB}/takes", json={"track_id": track})
+ tid = r.json()["take_id"]
+ audio = np.zeros((48000, 1), dtype=" dict:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ view = (await c.get(f"/api/jobs/{JOB}/takes/{tid}/render")).json()
+ if view["status"] in ("done", "error", "none"):
+ return view
+ await asyncio.sleep(0.2)
+ raise AssertionError("render did not finish")
+
+
+def _brightness(path, t: float) -> float:
+ out = subprocess.run(
+ [
+ ffmpeg_executable(),
+ "-nostdin",
+ "-loglevel",
+ "error",
+ "-ss",
+ f"{t}",
+ "-i",
+ str(path),
+ "-frames:v",
+ "1",
+ "-vf",
+ "scale=16:16,format=gray",
+ "-f",
+ "rawvideo",
+ "pipe:1",
+ ],
+ capture_output=True,
+ check=True,
+ timeout=60,
+ ).stdout
+ return float(np.frombuffer(out, dtype=np.uint8).mean())
+
+
+def _probe(path) -> dict:
+ out = subprocess.run(
+ [
+ ffprobe_executable(),
+ "-v",
+ "error",
+ "-show_streams",
+ "-show_format",
+ "-of",
+ "json",
+ str(path),
+ ],
+ capture_output=True,
+ check=True,
+ timeout=60,
+ ).stdout
+ return json.loads(out)
+
+
+def _needs_x264():
+ skip_without_ffmpeg()
+ take_render._encoders_cached = None
+ if not take_render.libx264_available():
+ pytest.skip("this ffmpeg has no libx264")
+
+
+# ─── Command builder (pure) ──────────────────────────────────────────────
+
+
+def test_command_has_the_agreed_encode_settings(tmp_path):
+ cmd = build_video_render_cmd(
+ [AudioLane(tmp_path / "drums.wav", 1.0, "")],
+ tmp_path / "audio.wav",
+ 0.8,
+ tmp_path / "camera.webm",
+ 0.5,
+ 3.0,
+ "16:9",
+ "cover",
+ False,
+ 44100,
+ )
+ joined = " ".join(cmd)
+ for part in ("-c:v libx264", "-crf 20", "-pix_fmt yuv420p", "-r 30", "-c:a aac", "+faststart"):
+ assert part in joined
+ graph = cmd[cmd.index("-filter_complex") + 1]
+ assert "amix=inputs=2:normalize=0,alimiter" in graph
+ assert "[1:a]volume=0.800000[a1]" in graph
+ assert "s=1920x1080" in graph and "overlay=eof_action=pass" in graph
+ assert cmd[cmd.index("-t") + 1] == "3.000000"
+
+
+def test_camera_filter_places_and_fits():
+ late = camera_filter(1.25, 1080, 1920, "contain", True, "[2:v]")
+ assert "setpts=PTS-STARTPTS+1.250000/TB" in late
+ assert "pad=1080:1920" in late and "hflip" in late
+ early = camera_filter(-0.5, 1920, 1080, "cover", False, "[2:v]")
+ assert "trim=start=0.500000" in early and "crop=1920:1080" in early
+ assert "hflip" not in early
+
+
+# ─── Endpoint ────────────────────────────────────────────────────────────
+
+
+@pytest.mark.parametrize("container", ["mp4", "webm"])
+async def test_render_places_the_camera_and_writes_a_faststart_mp4(
+ jobs_dir, client, tmp_path, container
+):
+ """Camera frame 0 at song 1.0 s (firstFrameAtMs), offset +500 ms (video
+ late, subtract): the white clip shows from 0.5 s to 1.5 s, black around
+ it, and the file is the song's length."""
+ _needs_x264()
+ _make_job(jobs_dir)
+ async with client as c:
+ tid = await _take_with_camera(c, tmp_path, first_frame_ms=1000.0, container=container)
+ assert (jobs_dir / JOB / "takes" / tid / f"camera.{container}").is_file()
+ r = await c.post(
+ f"/api/jobs/{JOB}/takes/{tid}/render",
+ json={"stems": ["drums"], "gains": [1.0], "camera_offset_ms": 500},
+ )
+ assert r.status_code == 202, r.text
+ assert r.json()["status"] in ("queued", "running")
+ view = await _wait(c, tid)
+ assert view == {
+ "status": "done",
+ "progress": 1.0,
+ "error": None,
+ "url": f"/api/jobs/{JOB}/takes/{tid}/render.mp4",
+ }
+ dl = await c.get(view["url"])
+ assert dl.status_code == 200 and dl.headers["content-type"] == "video/mp4"
+ out = jobs_dir / JOB / "takes" / tid / "render.mp4"
+ info = _probe(out)
+ video = next(s for s in info["streams"] if s["codec_type"] == "video")
+ audio = next(s for s in info["streams"] if s["codec_type"] == "audio")
+ assert (video["codec_name"], video["pix_fmt"], video["width"], video["height"]) == (
+ "h264",
+ "yuv420p",
+ 1920,
+ 1080,
+ )
+ assert video["r_frame_rate"] == "30/1"
+ assert audio["codec_name"] == "aac"
+ assert abs(float(info["format"]["duration"]) - SONG_SEC) < 0.1
+ data = out.read_bytes()
+ assert 0 <= data.find(b"moov") < data.find(b"mdat") # +faststart
+ assert _brightness(out, 0.25) < 20
+ assert _brightness(out, 1.0) > 200
+ assert _brightness(out, 2.5) < 20
+
+
+async def test_render_uses_the_stored_offset_and_portrait_canvas(jobs_dir, client, tmp_path):
+ _needs_x264()
+ _make_job(jobs_dir)
+ async with client as c:
+ tid = await _take_with_camera(c, tmp_path, first_frame_ms=0.0, camera_offset_ms=0.0)
+ r = await c.patch(f"/api/jobs/{JOB}/takes/{tid}", json={"camera_offset_ms": -1500})
+ assert r.json()["camera_offset_ms"] == -1500
+ r = await c.post(f"/api/jobs/{JOB}/takes/{tid}/render", json={"canvas": "9:16"})
+ assert r.status_code == 202, r.text
+ assert (await _wait(c, tid))["status"] == "done"
+ out = jobs_dir / JOB / "takes" / tid / "render.mp4"
+ video = next(s for s in _probe(out)["streams"] if s["codec_type"] == "video")
+ assert (video["width"], video["height"]) == (1080, 1920)
+ # Offset -1500 ms: the camera was early, so it is shown 1.5 s later.
+ assert _brightness(out, 1.0) < 20
+ assert _brightness(out, 2.0) > 200
+
+
+async def test_render_refusals(jobs_dir, client, tmp_path, monkeypatch):
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ track = (await c.post(f"/api/jobs/{JOB}/tracks", json={})).json()["id"]
+ r = await c.post(f"/api/jobs/{JOB}/takes", json={"track_id": track})
+ plain = r.json()["take_id"]
+ await c.put(
+ f"/api/jobs/{JOB}/takes/{plain}/parts/audio?seq=0",
+ content=np.zeros(4800, dtype=" Job:
+ job = Job(id=JOB, status="done", title="Song Title", duration_sec=2.0)
+ _jobs[job.id] = job
+ stems = jobs_dir / JOB / "stems"
+ stems.mkdir(parents=True, exist_ok=True)
+ silence = np.zeros((SONG_FRAMES, 2))
+ for name in ("vocals", "drums"):
+ sf.write(stems / f"{name}.wav", silence, SR, subtype="PCM_16")
+ return job
+
+
+async def _track(c, **body) -> dict:
+ r = await c.post(f"/api/jobs/{JOB}/tracks", json=body)
+ assert r.status_code == 201, r.text
+ return r.json()
+
+
+async def _take(c, track_id: str, click_sec: float) -> str:
+ """A finished take on the track with one click at song time `click_sec`."""
+ r = await c.post(f"/api/jobs/{JOB}/takes", json={"track_id": track_id})
+ assert r.status_code == 201, r.text
+ tid = r.json()["take_id"]
+ x = np.zeros(SONG_FRAMES, dtype=" int:
+ y, _ = sf.read(io.BytesIO(content))
+ return int(np.argmax(np.abs(y[:, 0])))
+
+
+async def _state(c) -> dict:
+ return (await c.get(f"/api/jobs/{JOB}")).json()
+
+
+# ─── Create, list, patch ─────────────────────────────────────────────────
+
+
+async def test_create_lists_and_persists_tracks(jobs_dir, client):
+ _make_job(jobs_dir)
+ async with client as c:
+ a = await _track(c)
+ b = await _track(c, name="Guitar", input={"device_id": "abc", "channels": [2, 3]})
+ state = await _state(c)
+ assert a["name"] == "Track 1" and a["order"] == 0 and a["active_take_id"] is None
+ assert a["input"] is None and takes_mod.TRACK_ID_RE.match(a["id"])
+ assert b["name"] == "Guitar" and b["order"] == 1
+ assert b["input"] == {"device_id": "abc", "device_label": None, "channels": [2, 3]}
+ assert state["tracks"] == [a, b]
+ on_disk = json.loads((jobs_dir / JOB / "takes" / "tracks.json").read_text(encoding="utf-8"))
+ assert on_disk == {"v": 1, "tracks": [a, b]}
+
+
+@pytest.mark.parametrize(
+ "body",
+ [
+ {"name": ""},
+ {"name": "x" * 81},
+ {"name": "a\nb"},
+ {"armed": True},
+ {"input": {"channels": []}},
+ {"input": {"channels": [0, 1, 2]}},
+ {"input": {"channels": [1, 1]}},
+ {"input": {"channels": [64]}},
+ {"input": {"channels": [0], "device_id": "x" * 257}},
+ {"input": {"channels": [0], "gain": 2}},
+ ],
+)
+async def test_create_refuses_bad_bodies(jobs_dir, client, body):
+ _make_job(jobs_dir)
+ async with client as c:
+ r = await c.post(f"/api/jobs/{JOB}/tracks", json=body)
+ assert r.status_code == 422, body
+
+
+async def test_unknown_job_track_and_traversal_are_404(jobs_dir, client):
+ async with client as c:
+ assert (await c.post("/api/jobs/000000000000/tracks", json={})).status_code == 404
+ assert (await c.post("/api/jobs/not-a-job/tracks", json={})).status_code == 404
+ _make_job(jobs_dir)
+ for tid in ("k00000000", "t00000000", "K0000000a", "..%2F..%2Fetc"):
+ url = f"/api/jobs/{JOB}/tracks/{tid}"
+ # An encoded slash splits the path before routing: no route, 405.
+ allowed = (404, 405) if "%2F" in tid else (404,)
+ assert (await c.patch(url, json={"name": "x"})).status_code in allowed, tid
+ assert (await c.delete(url)).status_code in allowed, tid
+ # The id is checked before the body: a bad body on an unknown track is 404.
+ r = await c.patch(f"/api/jobs/{JOB}/tracks/k00000000", content=b"{nope")
+ assert r.status_code == 404
+
+
+async def test_track_limit(jobs_dir, client, monkeypatch):
+ monkeypatch.setattr(takes_mod, "TRACK_MAX_PER_JOB", 2)
+ _make_job(jobs_dir)
+ async with client as c:
+ await _track(c)
+ await _track(c)
+ assert (await c.post(f"/api/jobs/{JOB}/tracks", json={})).status_code == 409
+
+
+async def test_patch_renames_sets_input_and_reorders(jobs_dir, client):
+ _make_job(jobs_dir)
+ async with client as c:
+ a, b, d = await _track(c), await _track(c), await _track(c)
+ url = f"/api/jobs/{JOB}/tracks/{d['id']}"
+ r = await c.patch(url, json={"name": "Bass", "order": 0, "input": {"channels": [1]}})
+ assert r.status_code == 200, r.text
+ assert r.json()["name"] == "Bass" and r.json()["order"] == 0
+ order = [t["id"] for t in (await _state(c))["tracks"]]
+ assert order == [d["id"], a["id"], b["id"]]
+ r = await c.patch(url, json={"input": None})
+ assert r.json()["input"] is None
+ r = await c.patch(url, json={"order": 31})
+ assert r.json()["order"] == 2 # past the end: moved to the end
+ assert (await c.patch(url, json={"order": 99})).status_code == 422
+ assert (await c.patch(url, json={})).status_code == 422
+ assert (await c.patch(url, json={"name": None})).status_code == 422
+ assert (await c.patch(url, json={"order": -1})).status_code == 422
+
+
+# ─── Takes on tracks ─────────────────────────────────────────────────────
+
+
+async def test_a_new_take_becomes_active_and_the_old_one_can_be_chosen(jobs_dir, client):
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ track = (await _track(c))["id"]
+ first = await _take(c, track, 0.5)
+ assert (await _state(c))["tracks"][0]["active_take_id"] == first
+ second = await _take(c, track, 1.0)
+ state = await _state(c)
+ assert state["tracks"][0]["active_take_id"] == second
+ assert [t["id"] for t in state["takes"] if t["track_id"] == track] == [first, second]
+ assert [t["label"] for t in state["takes"]] == ["Take 1", "Take 2"]
+
+ url = f"/api/jobs/{JOB}/tracks/{track}"
+ r = await c.patch(url, json={"active_take_id": first})
+ assert r.status_code == 200 and r.json()["active_take_id"] == first
+ other = (await _track(c))["id"]
+ foreign = await _take(c, other, 0.2)
+ r = await c.patch(url, json={"active_take_id": foreign})
+ assert r.status_code == 422
+ r = await c.patch(url, json={"active_take_id": "t00000000"})
+ assert r.status_code == 422
+
+
+async def test_deleting_the_active_take_falls_back_to_the_newest(jobs_dir, client):
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ track = (await _track(c))["id"]
+ first = await _take(c, track, 0.5)
+ second = await _take(c, track, 1.0)
+ await c.patch(f"/api/jobs/{JOB}/tracks/{track}", json={"active_take_id": second})
+ assert (await c.delete(f"/api/jobs/{JOB}/takes/{second}")).status_code == 200
+ assert (await _state(c))["tracks"][0]["active_take_id"] == first
+ assert (await c.delete(f"/api/jobs/{JOB}/takes/{first}")).status_code == 200
+ assert (await _state(c))["tracks"][0]["active_take_id"] is None
+
+
+async def test_deleting_a_track_deletes_its_takes_and_stops_renders(jobs_dir, client):
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ track = (await _track(c))["id"]
+ keep = (await _track(c))["id"]
+ done = await _take(c, track, 0.5)
+ kept = await _take(c, keep, 0.5)
+ r = await c.post(f"/api/jobs/{JOB}/takes", json={"track_id": track})
+ unfinished = r.json()["take_id"]
+ run = takes_mod._RenderRun(status="running")
+ takes_mod._renders[(JOB, done)] = run
+ r = await c.delete(f"/api/jobs/{JOB}/tracks/{track}")
+ assert r.status_code == 200, r.text
+ assert r.json() == {
+ "track_id": track,
+ "status": "deleted",
+ "takes_deleted": sorted([done, unfinished]),
+ }
+ state = await _state(c)
+ assert run.cancel.is_set() and (JOB, done) not in takes_mod._renders
+ assert [t["id"] for t in state["tracks"]] == [keep]
+ assert [t["id"] for t in state["takes"]] == [kept]
+ assert state["tracks"][0]["order"] == 0
+ takes = jobs_dir / JOB / "takes"
+ assert not (takes / done).exists() and not (takes / unfinished).exists()
+ assert (takes / kept).is_dir()
+ peaks = json.loads((jobs_dir / JOB / "stems" / "peaks.json").read_text(encoding="utf-8"))
+ assert done not in peaks and kept in peaks
+
+
+# ─── Export ──────────────────────────────────────────────────────────────
+
+
+async def test_a_track_lane_plays_its_active_take_and_switching_misses_the_cache(jobs_dir, client):
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ track = (await _track(c))["id"]
+ first = await _take(c, track, 0.5)
+ await _take(c, track, 1.0)
+ url = f"/api/jobs/{JOB}/mixdown.wav?stems=drums,{track}&gains=1,1"
+ r = await c.get(url)
+ assert r.status_code == 200, r.text
+ assert abs(_click(r.content) - SR) <= 1 # the second (active) take
+ await c.patch(f"/api/jobs/{JOB}/tracks/{track}", json={"active_take_id": first})
+ r = await c.get(url)
+ assert abs(_click(r.content) - SR // 2) <= 1
+ # Take-id lanes still work beside track lanes.
+ r = await c.get(f"/api/jobs/{JOB}/mixdown.wav?stems={first}&gains=1")
+ assert r.status_code == 200
+
+
+async def test_a_track_without_a_take_is_refused(jobs_dir, client):
+ _make_job(jobs_dir)
+ async with client as c:
+ track = (await _track(c))["id"]
+ r = await c.get(f"/api/jobs/{JOB}/mixdown.wav?stems={track}&gains=1")
+ assert r.status_code == 422 and r.json()["detail"] == "track has no take"
+ r = await c.get(f"/api/jobs/{JOB}/mixdown.wav?stems=k00000000&gains=1")
+ assert r.status_code == 422
+
+
+async def test_a_track_goes_in_the_zip_by_name(jobs_dir, client):
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ async with client as c:
+ track = (await _track(c, name="Lead Guitar"))["id"]
+ await _take(c, track, 0.5)
+ r = await c.get(f"/api/jobs/{JOB}/stems/all.zip?format=wav&stems=drums,{track}")
+ import zipfile
+
+ names = zipfile.ZipFile(io.BytesIO(r.content)).namelist()
+ assert f"Song_Title_Lead_Guitar_{track}.wav" in names
+
+
+def test_cache_key_names_the_active_take(jobs_dir, monkeypatch):
+ job = _make_job(jobs_dir)
+ folder = jobs_dir / JOB / "takes"
+ for tid in ("t00000001", "t00000002"):
+ (folder / tid).mkdir(parents=True)
+ (folder / tid / "audio.wav").write_bytes(b"RIFF")
+ job.takes.append({"id": tid, "track_id": "k00000001"})
+ job.tracks = [{"id": "k00000001", "active_take_id": "t00000001"}]
+ a = stems_mod._take_versions(JOB, ["drums", "k00000001"])
+ job.tracks = [{"id": "k00000001", "active_take_id": "t00000002"}]
+ b = stems_mod._take_versions(JOB, ["drums", "k00000001"])
+ assert a != b and a[0].startswith("k00000001=t00000001@")
+
+
+async def test_the_render_refuses_the_track_that_plays_the_take(jobs_dir, client, monkeypatch):
+ skip_without_ffmpeg()
+ _make_job(jobs_dir)
+ monkeypatch.setattr(takes_mod, "camera_file", lambda folder: folder / "audio.wav")
+ monkeypatch.setattr(takes_mod, "libx264_available", lambda: True)
+ monkeypatch.setattr(takes_mod, "aac_available", lambda: True)
+ async with client as c:
+ track = (await _track(c))["id"]
+ tid = await _take(c, track, 0.5)
+ r = await c.post(
+ f"/api/jobs/{JOB}/takes/{tid}/render", json={"stems": [track], "gains": [1]}
+ )
+ assert r.status_code == 422
+
+
+# ─── Migration and restart ───────────────────────────────────────────────
+
+
+def _legacy_take(jobs_dir, tid: str, created: float) -> dict:
+ """A finished take written before tracks existed: no track_id anywhere."""
+ folder = jobs_dir / JOB / "takes" / tid
+ folder.mkdir(parents=True)
+ sf.write(folder / "audio.wav", np.zeros((SONG_FRAMES, 2)), SR, subtype="PCM_16")
+ sidecar = {"v": 1, "id": tid, "label": f"Take {tid[-1]}", "created_at": created}
+ (folder / "take.json").write_text(json.dumps(sidecar), encoding="utf-8")
+ return {
+ "id": tid,
+ "label": sidecar["label"],
+ "created_at": created,
+ "url": f"/api/jobs/{JOB}/takes/{tid}/audio.wav",
+ "user_nudge_ms": 0.0,
+ "has_camera": False,
+ "camera_offset_ms": 0.0,
+ "camera_url": None,
+ }
+
+
+def test_legacy_takes_become_a_track_each_on_restart(jobs_dir):
+ job = _make_job(jobs_dir)
+ job.takes = [_legacy_take(jobs_dir, "t00000001", 1.0), _legacy_take(jobs_dir, "t00000002", 2.0)]
+ registry.persist(jobs_dir)
+ _jobs.clear()
+
+ registry.restore(jobs_dir)
+ restored = _jobs[JOB]
+ assert [t["name"] for t in restored.tracks] == ["Take 1", "Take 2"]
+ assert [t["active_take_id"] for t in restored.tracks] == ["t00000001", "t00000002"]
+ assert [t["track_id"] for t in restored.takes] == [t["id"] for t in restored.tracks]
+ sidecar = json.loads(
+ (jobs_dir / JOB / "takes" / "t00000001" / "take.json").read_text(encoding="utf-8")
+ )
+ assert sidecar["track_id"] == restored.tracks[0]["id"]
+
+ # A second restart changes nothing: the migration is done once.
+ tracks = restored.tracks
+ _jobs.clear()
+ registry.restore(jobs_dir)
+ assert _jobs[JOB].tracks == tracks
+
+
+def test_tracks_come_back_from_disk_without_a_registry_record(jobs_dir):
+ job = _make_job(jobs_dir)
+ _legacy_take(jobs_dir, "t00000001", 1.0)
+ (jobs_dir / JOB / "metadata.json").write_text('{"title": "Song Title"}', encoding="utf-8")
+ del job
+ _jobs.clear()
+ registry.registry_path(jobs_dir).unlink(missing_ok=True)
+
+ registry.restore(jobs_dir)
+ restored = _jobs[JOB]
+ assert len(restored.tracks) == 1
+ assert restored.tracks[0]["active_take_id"] == "t00000001"
+ assert restored.takes[0]["track_id"] == restored.tracks[0]["id"]
From 70bf7e71d125aff40dd9817e262eaa710473f6b0 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Mon, 5 Oct 2026 20:22:14 +0100
Subject: [PATCH 003/300] feat(recording): record takes over the song in the
studio
Add the Record button and recording panel, recording tracks with a voice
or instrument input each, take folders with one lane per take, a live
waveform while recording, and a Recording tab in Settings for the input,
camera and latency test. Stems that were not extracted are hidden from
the mixer. Includes the e2e and node tests for these flows.
---
static/css/daw.css | 30 +-
static/css/recording.css | 536 +++++++++
static/css/variables.css | 2 +
static/index.html | 47 +
static/js/audioEngine.js | 12 +
static/js/capture/captureDevices.js | 98 ++
static/js/capture/captureGraph.js | 346 ++++++
static/js/capture/capturePermission.js | 147 +++
static/js/capture/captureSettings.js | 57 +
static/js/capture/latencyCalibration.js | 124 ++
static/js/capture/liveWave.js | 482 ++++++++
static/js/capture/liveWaveMath.js | 232 ++++
static/js/capture/recordUi.js | 760 ++++++++++++
static/js/capture/recorder-worklet.js | 99 ++
static/js/capture/recordingSettings.js | 343 ++++++
static/js/capture/takeFolderModel.js | 105 ++
static/js/capture/takeLanes.js | 849 +++++++++++++
static/js/capture/takeMath.js | 215 ++++
static/js/capture/takeRecorder.js | 333 ++++++
static/js/capture/takeUpload.js | 197 +++
static/js/capture/trackInputs.js | 324 +++++
static/js/catalog.js | 43 +-
static/js/chunkedAudioEngine.js | 10 +
static/js/constants.js | 38 +-
static/js/helpMode.js | 16 +
static/js/i18n.js | 1463 +++++++++++++++++++++++
static/js/mixer.js | 7 +-
static/js/player.js | 235 +++-
static/js/transport.js | 2 +
tests/e2e/footer-fit.spec.mjs | 6 +-
tests/e2e/live-wave.spec.mjs | 317 +++++
tests/e2e/partial-stems.spec.mjs | 99 ++
tests/e2e/recording-helpers.mjs | 84 ++
tests/e2e/recording-real-audio.spec.mjs | 469 ++++++++
tests/e2e/recording-tracks.spec.mjs | 367 ++++++
tests/e2e/recording.spec.mjs | 343 ++++++
tests/e2e/take-folders-live.spec.mjs | 222 ++++
tests/e2e/take-folders.spec.mjs | 442 +++++++
tests/js/live-wave-math.test.mjs | 233 ++++
tests/js/take-folders.test.mjs | 109 ++
tests/js/take-math.test.mjs | 225 ++++
tests/js/track-inputs.test.mjs | 107 ++
42 files changed, 10103 insertions(+), 72 deletions(-)
create mode 100644 static/css/recording.css
create mode 100644 static/js/capture/captureDevices.js
create mode 100644 static/js/capture/captureGraph.js
create mode 100644 static/js/capture/capturePermission.js
create mode 100644 static/js/capture/captureSettings.js
create mode 100644 static/js/capture/latencyCalibration.js
create mode 100644 static/js/capture/liveWave.js
create mode 100644 static/js/capture/liveWaveMath.js
create mode 100644 static/js/capture/recordUi.js
create mode 100644 static/js/capture/recorder-worklet.js
create mode 100644 static/js/capture/recordingSettings.js
create mode 100644 static/js/capture/takeFolderModel.js
create mode 100644 static/js/capture/takeLanes.js
create mode 100644 static/js/capture/takeMath.js
create mode 100644 static/js/capture/takeRecorder.js
create mode 100644 static/js/capture/takeUpload.js
create mode 100644 static/js/capture/trackInputs.js
create mode 100644 tests/e2e/live-wave.spec.mjs
create mode 100644 tests/e2e/partial-stems.spec.mjs
create mode 100644 tests/e2e/recording-helpers.mjs
create mode 100644 tests/e2e/recording-real-audio.spec.mjs
create mode 100644 tests/e2e/recording-tracks.spec.mjs
create mode 100644 tests/e2e/recording.spec.mjs
create mode 100644 tests/e2e/take-folders-live.spec.mjs
create mode 100644 tests/e2e/take-folders.spec.mjs
create mode 100644 tests/js/live-wave-math.test.mjs
create mode 100644 tests/js/take-folders.test.mjs
create mode 100644 tests/js/take-math.test.mjs
create mode 100644 tests/js/track-inputs.test.mjs
diff --git a/static/css/daw.css b/static/css/daw.css
index 6131e9fc..1d161abe 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -1103,6 +1103,9 @@ input, textarea { font-family: inherit; }
.library-editor-empty { color: var(--muted); text-align: center; padding: 22px 10px !important; }
/* Settings → tabs */
.settings-tabs { display: flex; gap: 2px; margin-bottom: 12px; border-bottom: 1px solid var(--border); }
+/* Seven tabs since Recording joined: on a phone-width window the row wraps
+ instead of pushing the last tabs out of the dialog. */
+@media (max-width: 600px) { .settings-tabs { flex-wrap: wrap; } }
.settings-tab { background: none; border: none; border-bottom: 2px solid transparent; color: var(--muted); font-family: var(--font-mono); font-size: 12px; font-weight: 600; padding: 5px 12px 9px; cursor: pointer; margin-bottom: -1px; }
.settings-tab:hover { color: var(--fg-2); }
.settings-tab.active { color: var(--fg); border-bottom-color: var(--accent); }
@@ -2305,6 +2308,7 @@ input, textarea { font-family: inherit; }
/* M/S buttons (JS-built) */
.lane-icon-toggle.mx-btn,
.ms-btn.mx-btn,
+.track-arm.mx-btn,
.lane-dl.mx-btn {
width: 22px; height: 20px;
background: transparent;
@@ -2323,7 +2327,8 @@ input, textarea { font-family: inherit; }
opacity: 1;
}
.lane-icon-toggle.mx-btn:hover,
-.ms-btn.mx-btn:hover { color: var(--fg); border-color: #3a4550; }
+.ms-btn.mx-btn:hover,
+.track-arm.mx-btn:hover { color: var(--fg); border-color: #3a4550; }
/* Mute button: "active" = NOT muted (normal). Engaged (muted) lights up blue,
mirroring the solo button's own lit-up treatment below. */
@@ -2337,6 +2342,22 @@ input, textarea { font-family: inherit; }
/* Solo button: "active" = soloed */
.ms-btn.mx-btn.active { background: rgba(244,183,64,0.18); color: var(--accent); border-color: rgba(244,183,64,0.4); }
+/* Arm (record-enable) on a recording track, the M/S button's twin: a red
+ dot, hollow when idle, filled and glowing when armed. Red is --danger, the
+ Record button's colour, so the two read as one control. */
+.track-arm.mx-btn { color: var(--danger); }
+.track-arm.mx-btn::before {
+ content: "";
+ width: 8px; height: 8px;
+ border-radius: 50%;
+ border: 1.5px solid currentColor;
+ box-sizing: border-box;
+ transition: background var(--t-fast), box-shadow var(--t-fast);
+}
+.track-arm.mx-btn:hover { color: var(--danger); }
+.track-arm.mx-btn.on { background: rgba(214,90,74,0.14); border-color: rgba(214,90,74,0.35); }
+.track-arm.mx-btn.on::before { background: currentColor; box-shadow: 0 0 6px currentColor; }
+
/* Download button */
.lane-dl.mx-btn {
color: var(--muted);
@@ -2345,9 +2366,6 @@ input, textarea { font-family: inherit; }
.lane-dl.mx-btn:hover { color: var(--fg); border-color: var(--border); }
.lane-dl.mx-btn svg { width: 13px; height: 13px; }
-/* Unavailable stems — gray entire mixer row and waveform row */
-.lane-header.unavailable { opacity: 0.25; pointer-events: none; }
-.stem-waveform-row.unavailable { opacity: 0.2; pointer-events: none; }
.mx-fader-input { touch-action: pan-y; }
/* ── Waveform panel (right) ── */
@@ -5549,8 +5567,8 @@ input, textarea { font-family: inherit; }
/* The click-track options with nothing to act on.
*
* They stay on the row and go quiet rather than leaving it, which is how the
- * metronome button beside them and every unavailable lane in the mixer already
- * behave. Half a control strip appearing and vanishing reads as a fault. */
+ * metronome button beside them and the empty studio's placeholder lanes
+ * already behave. Half a control strip appearing and vanishing reads as a fault. */
.footer-metro-panel.unavailable {
opacity: 0.4;
pointer-events: none;
diff --git a/static/css/recording.css b/static/css/recording.css
new file mode 100644
index 00000000..d5eded1b
--- /dev/null
+++ b/static/css/recording.css
@@ -0,0 +1,536 @@
+/* ── Recording a take (static/js/capture/recordUi.js, takeLanes.js) ──
+ Loaded between waves.css and daw.css. Only rec-*, take-* and capture-*
+ classes live here, so nothing in daw.css depends on the order. */
+
+/* Record button: a red dot, solid red while recording. */
+.rec-btn { color: var(--danger); }
+.rec-btn.recording {
+ background: var(--danger);
+ border-color: var(--danger);
+ color: var(--fg);
+ animation: rec-pulse 1.2s var(--easing) infinite;
+}
+.rec-btn.saving { opacity: 0.6; cursor: progress; }
+@keyframes rec-pulse {
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(214, 90, 74, 0.55); }
+ 50% { box-shadow: 0 0 0 4px rgba(214, 90, 74, 0); }
+}
+
+.rec-opts-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 28px;
+ margin-left: -4px;
+ padding: 0;
+ border: 0;
+ border-radius: var(--radius-xs);
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ transition: color var(--t-fast), background var(--t-fast);
+}
+.rec-opts-btn:hover,
+.rec-opts-btn[aria-expanded="true"] { color: var(--fg); background: var(--panel-2); }
+
+/* The panel: a popover over the footer, placed by JS above the arrow. */
+.rec-panel {
+ position: fixed;
+ z-index: 60;
+ width: min(340px, calc(100vw - 16px));
+ max-height: calc(100vh - 16px);
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 12px 14px;
+ background: var(--panel);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-panel);
+ color: var(--fg);
+ font-size: 12.5px;
+ box-sizing: border-box;
+}
+.rec-panel[hidden] { display: none; }
+.rec-panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
+.rec-panel-title { font-weight: 600; font-size: 13px; }
+
+/* A text button that reads as a link: it opens Settings > Recording. */
+.rec-link {
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--accent);
+ font: inherit;
+ font-size: 11.5px;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ cursor: pointer;
+}
+.rec-link:hover { color: var(--fg); }
+.rec-link:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
+.rec-permission .rec-link { display: inline-block; margin-top: 6px; }
+
+.rec-permission {
+ padding: 8px 10px;
+ border: 1px solid var(--danger);
+ border-radius: var(--radius-sm);
+ background: var(--panel-2);
+}
+.rec-permission[hidden] { display: none; }
+.capture-reason, .capture-hint { margin: 0; line-height: 1.45; }
+.capture-hint { margin-top: 6px; color: var(--fg-2); overflow-wrap: anywhere; }
+
+.rec-meter, .rec-cal-progress {
+ height: 6px;
+ border-radius: 3px;
+ background: var(--panel-3);
+ overflow: hidden;
+}
+.rec-meter-fill, .rec-cal-bar {
+ height: 100%;
+ width: 100%;
+ transform: scaleX(0);
+ transform-origin: left center;
+ background: var(--guitar);
+}
+.rec-meter-fill.clip { background: var(--danger); }
+.rec-cal-bar { background: var(--accent); transition: transform var(--t-base) linear; }
+
+.rec-small-btn {
+ padding: 4px 10px;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-xs);
+ background: var(--panel-2);
+ color: var(--fg);
+ font: inherit;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: background var(--t-fast), border-color var(--t-fast);
+}
+.rec-small-btn:hover { background: var(--panel-3); }
+.rec-small-btn.primary { border-color: var(--accent-2); color: var(--accent); }
+.rec-small-btn:disabled { opacity: 0.5; cursor: default; }
+
+.rec-cal-status { margin: 0; min-height: 1em; }
+.rec-actions { display: flex; gap: 8px; flex-wrap: wrap; }
+
+/* ── Settings > Recording (static/js/capture/recordingSettings.js) ──
+ Rows, titles and switches come from the shared .settings-* rules in
+ daw.css; only what is specific to this tab is here. */
+.rec-settings .settings-section { display: flex; flex-direction: column; gap: 8px; }
+.rec-settings .settings-row { align-items: center; }
+/* Device names are long ("Microphone (Scarlett 2i2 USB)"), so these selects
+ get the wide column rather than the shared 84px one. */
+.rec-settings .rec-settings-select { width: 220px; max-width: 45vw; text-overflow: ellipsis; }
+.rec-settings .rec-small-btn { font-size: 12px; flex-shrink: 0; }
+.rec-settings-meter { margin-top: 2px; }
+.rec-settings .rec-settings-latency { margin: 0; font-size: 11.5px; font-weight: 400; color: var(--fg-2); font-family: var(--font-mono); }
+.rec-settings .rec-note,
+.rec-settings .rec-cal-status { font-size: 11px; }
+.rec-settings .rec-cal-status:empty { display: none; }
+.rec-settings-preview { width: min(320px, 100%); align-self: flex-start; }
+
+.rec-check { display: flex; align-items: center; gap: 8px; cursor: pointer; }
+.rec-note { margin: 0; color: var(--muted); line-height: 1.45; }
+.rec-monitor-delay { display: none; }
+.rec-panel.monitoring .rec-monitor-delay { display: block; color: var(--fg-2); }
+
+.rec-mute {
+ margin: 0;
+ padding: 6px 10px 8px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+}
+.rec-mute legend { padding: 0 4px; color: var(--muted); font-size: 11.5px; }
+.rec-mute-list { display: flex; flex-wrap: wrap; gap: 4px 12px; }
+.rec-mute-item { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; }
+
+.rec-review {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 8px 10px;
+ border: 1px solid var(--accent-2);
+ border-radius: var(--radius-sm);
+}
+.rec-review[hidden] { display: none; }
+.rec-review-text { margin: 0; }
+.rec-status { margin: 0; min-height: 1em; color: var(--fg-2); }
+.rec-status:empty { display: none; }
+
+/* ── Take lanes in the mixer ── */
+.lane-header.take-lane .mx-name { font-style: italic; }
+.take-menu-btn {
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ font-size: 16px;
+ line-height: 1;
+ cursor: pointer;
+}
+.take-menu-btn:hover,
+.take-menu-btn[aria-expanded="true"] { color: var(--fg); }
+
+.take-menu {
+ position: fixed;
+ z-index: 70;
+ width: 240px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 10px 12px;
+ background: var(--panel);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-panel);
+ color: var(--fg);
+ font-size: 12.5px;
+}
+.take-menu-field { display: flex; flex-direction: column; gap: 4px; }
+.take-menu-field > span { color: var(--muted); font-size: 11.5px; }
+.take-name-input, .take-nudge-input {
+ min-width: 0;
+ padding: 4px 6px;
+ background: var(--panel-2);
+ color: var(--fg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-xs);
+ font: inherit;
+}
+.take-nudge-row { display: flex; gap: 4px; }
+.take-nudge-input { flex: 1; font-family: var(--font-mono); text-align: right; }
+.take-nudge-step {
+ width: 28px;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-xs);
+ background: var(--panel-2);
+ color: var(--fg);
+ cursor: pointer;
+}
+.take-menu-hint { margin: 0; color: var(--muted); line-height: 1.4; }
+.take-menu-actions { display: flex; justify-content: space-between; gap: 8px; }
+.take-apply, .take-delete {
+ padding: 4px 10px;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-xs);
+ background: var(--panel-2);
+ color: var(--fg);
+ font: inherit;
+ cursor: pointer;
+}
+.take-delete { color: var(--danger); }
+.take-delete.armed { background: var(--danger); color: var(--fg); border-color: var(--danger); }
+.take-menu-status { margin: 0; min-height: 1em; color: var(--fg-2); }
+.take-menu-status:empty { display: none; }
+
+/* ── Recording tracks (static/js/capture/takeLanes.js) ── */
+/* "+ Track" in the mixer header. */
+.add-track-btn {
+ margin-left: auto;
+ margin-right: 8px;
+ padding: 2px 8px;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-xs);
+ background: var(--panel-2);
+ color: var(--fg-2);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ cursor: pointer;
+ transition: background var(--t-fast), color var(--t-fast);
+}
+.add-track-btn:hover:not(:disabled) { background: var(--panel-3); color: var(--fg); }
+.add-track-btn:disabled { opacity: 0.4; cursor: default; }
+.add-track-btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
+/* Why other tracks were disarmed: a short note under the mixer header,
+ placed by takeLanes.showTrackNote. */
+.track-note {
+ position: fixed;
+ z-index: 60;
+ margin: 0;
+ box-sizing: border-box;
+ padding: 6px 8px;
+ border: 1px solid var(--accent-2);
+ border-radius: var(--radius-sm);
+ background: var(--panel);
+ color: var(--fg);
+ font-size: 11px;
+ line-height: 1.4;
+ box-shadow: var(--shadow-panel);
+}
+.track-note[hidden] { display: none; }
+
+/* Right-align the strip so arm, M, S and the menu line up with the stem rows'
+ K, M, S and download buttons above. */
+.lane-header.mx-row .track-arm.mx-btn { margin-left: auto; }
+.track-arm.mx-btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
+
+/* The input picker is a line of its own along the row's bottom edge, from
+ the fader to the end of the buttons, so a device and its input read in full
+ ("MG-XU · Input 1+2"). The control strip moves up by half the picker's line
+ to make room; the row keeps its height, so it stays level with its lane. */
+.lane-header.mx-row.track-lane { padding-bottom: 10px; }
+.track-input-wrap {
+ position: absolute;
+ left: calc(10px + 58px + 5px);
+ right: 10px;
+ bottom: 5px;
+ display: flex;
+ min-width: 0;
+ color: var(--fg-2);
+}
+.track-input-wrap::after {
+ content: "";
+ position: absolute;
+ right: 6px;
+ top: 50%;
+ width: 4px;
+ height: 4px;
+ margin-top: -3px;
+ border-right: 1.5px solid currentColor;
+ border-bottom: 1.5px solid currentColor;
+ transform: rotate(45deg);
+ pointer-events: none;
+}
+.track-input {
+ appearance: none;
+ -webkit-appearance: none;
+ width: 100%;
+ min-width: 0;
+ height: 20px;
+ padding: 0 16px 0 6px;
+ background: transparent;
+ color: var(--fg-2);
+ border: 1px solid var(--border-strong);
+ border-radius: 5px;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ cursor: pointer;
+ text-overflow: ellipsis;
+ transition: color var(--t-fast), border-color var(--t-fast);
+}
+.track-input:hover { color: var(--fg); }
+.track-input-wrap:hover { color: var(--fg); }
+.track-input option, .track-input optgroup { background: var(--panel); color: var(--fg); }
+.track-input:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
+/* The track's device is not connected. */
+.track-input.missing { color: var(--danger); border-color: var(--danger); }
+/* An armed track's meter shows its input (recordUi.startArmMeter), on a
+ decibel scale, in place of its playback level. */
+.lane-header.track-lane.armed .lane-vu .lane-vu-bar.mx-meter-fill { width: var(--vu-in, 0%); }
+.lane-header.track-empty .mx-name { opacity: 0.7; }
+/* Beats daw.css's .lane-dl.mx-btn, which loads later and sets display. */
+.lane-header .lane-dl.track-dl-off { display: none; }
+
+.take-menu-takes {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin: 0;
+ padding: 4px 8px 6px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ max-height: 160px;
+ overflow-y: auto;
+}
+.take-menu-takes legend { padding: 0 4px; color: var(--muted); font-size: 11.5px; }
+.take-menu-take { display: flex; align-items: center; gap: 6px; cursor: pointer; }
+
+/* ── Take folders (takeLanes.js, player.js renderTakeFolderLanes) ──
+ A recording track is a folder of its takes. The arrow on the track row
+ opens one row per take under it, newest on top, and one waveform lane per
+ take beside them. Take rows are .lane-header.mx-row like any lane, so they
+ take their height from --lane-h and stay level with their lanes. */
+
+/* The disclosure arrow, left of the track name, with the number of takes. */
+.lane-header.mx-row .take-folder-toggle {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ width: 16px;
+ height: 30px;
+ margin: 0 -2px 0 -6px;
+ padding: 0;
+ border: 0;
+ border-radius: var(--radius-xs);
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: color var(--t-fast), background var(--t-fast);
+}
+.lane-header.mx-row .take-folder-toggle:hover { color: var(--fg); background: var(--panel-2); }
+.lane-header.mx-row .take-folder-toggle:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
+/* No takes, or the streaming path: the arrow keeps its place, unseen. */
+.lane-header.mx-row .take-folder-toggle.off { visibility: hidden; }
+.take-folder-arrow {
+ width: 6px;
+ height: 6px;
+ border-right: 1.5px solid currentColor;
+ border-bottom: 1.5px solid currentColor;
+ transform: rotate(-45deg);
+ transition: transform var(--t-base) var(--easing);
+}
+.take-folder-toggle[aria-expanded="true"] .take-folder-arrow { transform: translateY(-2px) rotate(45deg); }
+.take-folder-toggle[aria-expanded="true"] { color: var(--take); }
+.take-folder-count {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ font-weight: 700;
+ line-height: 1;
+}
+
+/* A take's row in the mixer: indented under its track, on the darker ground
+ of the folder, with a bar in the take colour on the one that plays. */
+.lane-header.mx-row.take-folder-row {
+ background: var(--bg);
+ cursor: pointer;
+ gap: 8px;
+}
+.lane-header.mx-row.take-folder-row::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+ background: transparent;
+}
+.lane-header.mx-row.take-folder-row.playing::before { background: var(--take); }
+.lane-header.mx-row.take-folder-row:hover { background: var(--bg-2); }
+.lane-header.mx-row.take-folder-row.choosing { opacity: 0.6; cursor: progress; }
+.take-folder-indent { width: 4px; flex-shrink: 0; }
+
+/* Play this take: a radio dot, filled in the take colour when it plays. */
+.lane-header.mx-row .take-play.mx-btn {
+ width: 22px;
+ height: 20px;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ background: transparent;
+ border: 1px solid var(--border-strong);
+ border-radius: 5px;
+ color: var(--muted);
+ cursor: pointer;
+ transition: color var(--t-fast), border-color var(--t-fast), background var(--t-fast);
+}
+.lane-header.mx-row .take-play.mx-btn::before {
+ content: "";
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ border: 1.5px solid currentColor;
+ box-sizing: border-box;
+}
+.lane-header.mx-row .take-play.mx-btn:hover { color: var(--fg); }
+.lane-header.mx-row .take-play.mx-btn[aria-pressed="true"] {
+ color: var(--take);
+ border-color: var(--take);
+ background: color-mix(in srgb, var(--take) 14%, transparent);
+ cursor: default;
+}
+.lane-header.mx-row .take-play.mx-btn[aria-pressed="true"]::before { background: currentColor; }
+.lane-header.mx-row .take-play.mx-btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
+
+.take-folder-text {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 0;
+ flex: 1 1 auto;
+}
+.lane-header.mx-row .take-folder-name {
+ color: var(--fg-2);
+ font-size: 12.5px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.lane-header.mx-row.take-folder-row.playing .take-folder-name { color: var(--take); }
+.take-folder-meta {
+ color: var(--muted);
+ font-family: var(--font-mono);
+ font-size: 10px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.take-folder-meta:empty { display: none; }
+
+/* The take's menu: the track menu's twin, in the same column. */
+.lane-header.mx-row .take-folder-menu-btn.mx-btn {
+ margin-left: auto;
+ width: 22px;
+ height: 20px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ font-size: 16px;
+ line-height: 1;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+.lane-header.mx-row .take-folder-menu-btn.mx-btn:hover,
+.lane-header.mx-row .take-folder-menu-btn.mx-btn[aria-expanded="true"] { color: var(--fg); }
+
+/* A take's waveform lane. Only the take that plays is drawn at full
+ strength; a click on another one makes that the take that plays, so those
+ lanes take the pointer (the overlay around them does not). */
+.stem-waveform-row.take-folder-lane { background: color-mix(in srgb, var(--bg) 70%, transparent); }
+.stem-waveform-row.take-folder-lane:not(.playing) { pointer-events: auto; cursor: pointer; }
+.stem-waveform-row.take-folder-lane:not(.playing) .stem-waveform-svg rect { opacity: 0.38; }
+.stem-waveform-row.take-folder-lane:not(.playing):hover .stem-waveform-svg rect { opacity: 0.6; }
+.stem-waveform-row.take-folder-lane.playing { box-shadow: inset 3px 0 0 var(--take); }
+.stem-waveform-row.take-folder-lane.choosing { cursor: progress; }
+.stem-waveform-row.take-folder-lane.choosing .stem-waveform-svg rect { opacity: 0.7; }
+.take-folder-lane-name {
+ position: absolute;
+ top: 4px;
+ left: 8px;
+ color: var(--muted);
+ font-family: var(--font-mono);
+ font-size: 10px;
+ pointer-events: none;
+}
+.take-folder-lane.playing .take-folder-lane-name { color: var(--take); }
+
+/* Where the live waveform (liveWave.js) anchors while a track records. It
+ covers the track's own lane and never takes the pointer. */
+.take-live-slot {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+
+/* The live waveform of a take being recorded (capture/liveWave.js): a canvas
+ laid over the track's lane in .waves-column, above the overview bars
+ (.stem-waveform-layer is z-index 3 and comes earlier), stacked the same
+ way as those bars, under the playhead. It scrolls and zooms with the column: full width,
+ top and height set from the lane. */
+.live-wave-lane {
+ position: absolute;
+ left: 0;
+ width: 100%;
+ z-index: 3;
+ pointer-events: none;
+}
+
+.live-wave-canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+/* A track that is recording shows the take being recorded (the live lane,
+ liveWave.js), not the take it is about to replace. */
+.stem-waveform-row.take-recording > .stem-waveform-svg { visibility: hidden; }
diff --git a/static/css/variables.css b/static/css/variables.css
index b5ae03f3..5db6a3b0 100644
--- a/static/css/variables.css
+++ b/static/css/variables.css
@@ -24,6 +24,8 @@
--guitar: #22c55e;
--piano: #a855f7;
--other: #9ca3af;
+ /* Recorded takes (constants.js TAKE_COLOR must match) */
+ --take: #5ec8c0;
/* Width shared by the composer's two end segments (Song structure and
Extract stems) so they match. See .daw-process-btn / .structure-toggle. */
diff --git a/static/index.html b/static/index.html
index c863c3af..924664c4 100644
--- a/static/index.html
+++ b/static/index.html
@@ -9,6 +9,7 @@
+
@@ -601,7 +602,11 @@
Lyrics
Mixer
+
+
Drag fader · M/S
+
@@ -838,6 +843,18 @@
Lyrics
+
+
+
@@ -1244,6 +1261,36 @@
Something failed
+
+
+
+ Record a take
+
+
+
+
+
+
+
+
Use headphones. With speakers the song bleeds into the take, and hearing yourself can feed back.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/js/audioEngine.js b/static/js/audioEngine.js
index d2616ed4..fa089edc 100644
--- a/static/js/audioEngine.js
+++ b/static/js/audioEngine.js
@@ -440,6 +440,18 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
ctxTimeToSourceTime,
getScheduleEpoch: () => _epoch,
isClockReady: () => playing,
+ // The heard-playhead mapping, frozen at one instant, so a recorder can
+ // place a captured frame in song time with the same formula as now():
+ // song = (ctx - startCtxTime - pipelineLatencySeconds) * rate + startOffset.
+ // Read once isClockReady() is true; the epoch says when it stops holding.
+ getOutputMapping: () => ({
+ startCtxTime,
+ startOffset,
+ rate: _playbackRate,
+ pipelineLatencySeconds: pipelineLatencySeconds(),
+ epoch: _epoch,
+ playing,
+ }),
// The click shares the unpitched input with drums. It still passes through
// the common tempo stage and master gain.
getMasterNode: () => buses[ZERO_INPUT],
diff --git a/static/js/capture/captureDevices.js b/static/js/capture/captureDevices.js
new file mode 100644
index 00000000..34a252d6
--- /dev/null
+++ b/static/js/capture/captureDevices.js
@@ -0,0 +1,98 @@
+// Input device list, picker and the remembered choice, for microphones and
+// cameras alike. The audio input picker is trackInputs.buildInputPicker
+// (voice and instrument, friendly names); this one is the camera's. `kind` here is a MediaDeviceInfo kind: "audioinput" or
+// "videoinput".
+//
+// The remembered deviceId lives in localStorage, per browser and per machine,
+// which is what a deviceId is scoped to anyway. A missing or blocked store
+// just means "the default device".
+
+import { t } from "../i18n.js";
+
+const KEY_PREFIX = "stemdeck:capture:device:";
+
+export function getRememberedDevice(kind) {
+ try {
+ return localStorage.getItem(KEY_PREFIX + kind) || "";
+ } catch (e) {
+ console.warn("[capture] could not read the remembered device:", e);
+ return "";
+ }
+}
+
+export function rememberDevice(kind, deviceId) {
+ try {
+ if (deviceId) localStorage.setItem(KEY_PREFIX + kind, deviceId);
+ else localStorage.removeItem(KEY_PREFIX + kind);
+ } catch (e) {
+ console.warn("[capture] could not remember the device:", e);
+ }
+}
+
+/** Devices of one kind. Labels are empty until permission has been granted. */
+export async function listDevices(kind) {
+ if (!navigator.mediaDevices?.enumerateDevices) return [];
+ try {
+ const all = await navigator.mediaDevices.enumerateDevices();
+ return all.filter((d) => d.kind === kind);
+ } catch (e) {
+ console.warn("[capture] enumerateDevices failed:", e);
+ return [];
+ }
+}
+
+function labelFor(device, index, kind) {
+ // Without the USB id Chromium appends on Windows: "(046d:0893)".
+ const label = (device.label || "").replace(/\s*\([0-9a-f]{4}:[0-9a-f]{4}\)\s*$/i, "");
+ if (label) return label;
+ return t(kind === "videoinput" ? "capture.cameraN" : "capture.inputN", { n: index + 1 });
+}
+
+/**
+ * Keep a
@@ -5031,6 +5061,8 @@ function openLibraryEditor() {
+
+
@@ -5106,6 +5138,9 @@ function openLibraryEditor() {
overlay.querySelectorAll(".settings-pane").forEach((p) => p.classList.toggle("hidden", p.dataset.pane !== name));
if (name === "registry") loadRegistryView(overlay);
if (name === "logs") loadLogsView(overlay);
+ // The Recording tab holds the microphone and camera open while it shows.
+ if (name === "recording") enterRecordingPane(overlay);
+ else recordingPane?.leave();
});
});
overlay.querySelector(".settings-registry-refresh:not(.settings-logs-refresh)")
@@ -5259,6 +5294,12 @@ export async function initCatalog() {
wireSupportersDialog();
wireReleaseDialog();
wireSettingsMenu();
+ // A take was kept, nudged, renamed or deleted: the server re-rendered, so
+ // the open job's lanes are rebuilt from fresh state, as after a vocal split.
+ document.addEventListener("stemdeck:takes-changed", (e) => {
+ const id = e.detail?.jobId;
+ if (id && id === _currentTrackId) loadTrackIntoStudio(id);
+ });
setDisplayedVersion(currentVersion);
render();
diff --git a/static/js/chunkedAudioEngine.js b/static/js/chunkedAudioEngine.js
index a85475b0..739b46c7 100644
--- a/static/js/chunkedAudioEngine.js
+++ b/static/js/chunkedAudioEngine.js
@@ -833,6 +833,16 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {
// `playing` flips before the async chunk fetch resolves, so the clock is
// only trustworthy once _audioStarted is set.
isClockReady: () => playing && _audioStarted,
+ // Same contract as audioEngine.getOutputMapping: the inputs of
+ // _getCurrentTime, frozen at one instant, for placing recorded frames.
+ getOutputMapping: () => ({
+ startCtxTime: _startCtxTime,
+ startOffset: _startOffset,
+ rate: _playbackRate,
+ pipelineLatencySeconds: _pipelineLatencySeconds(),
+ epoch: _epoch,
+ playing: playing && _audioStarted,
+ }),
getMasterNode: () => buses[ZERO_INPUT],
/**
* Put one mixer lane in a given key.
diff --git a/static/js/constants.js b/static/js/constants.js
index 5de23c60..c0e848ce 100644
--- a/static/js/constants.js
+++ b/static/js/constants.js
@@ -35,7 +35,42 @@ export async function syncStemNamesFromAPI() {
// decide what to render and in what sequence (waveform stacking, mixer rows).
export function effectiveStemOrder(presentNames) {
const splitDone = presentNames.has("lead_vocals") && presentNames.has("backing_vocals");
- return STEM_NAMES.flatMap((n) => (n === "vocals" && splitDone ? EXTRA_STEM_NAMES : [n]));
+ const stems = STEM_NAMES.flatMap((n) => (n === "vocals" && splitDone ? EXTRA_STEM_NAMES : [n]));
+ // Recording tracks follow the stems, in the user's track order.
+ return [...stems, ...TAKE_NAMES.filter((n) => presentNames.has(n))];
+}
+
+// Recording tracks are lanes like any stem. The lane name is the track id
+// (`k` plus 8 hex digits, the backend's track id), the label is the user's
+// own text, and the audio is the track's active take. Set per job by
+// static/js/capture/takeLanes.js. TAKE_ID_RE is the take id inside a track.
+export const TAKE_ID_RE = /^t[0-9a-f]{8}$/;
+export const TRACK_ID_RE = /^k[0-9a-f]{8}$/;
+export const TAKE_COLOR = "#5ec8c0";
+export let TAKE_NAMES = [];
+const _takeLabels = new Map();
+
+/** True for a recording-track lane name. */
+export function isTakeName(name) {
+ return typeof name === "string" && TRACK_ID_RE.test(name);
+}
+
+/** Replace the current job's track lanes: [{id, label}] in track order. */
+export function setTakeLanes(takes) {
+ for (const id of TAKE_NAMES) delete STEM_COLORS[id];
+ _takeLabels.clear();
+ TAKE_NAMES = [];
+ for (const take of takes || []) {
+ if (!isTakeName(take?.id)) continue;
+ TAKE_NAMES.push(take.id);
+ _takeLabels.set(take.id, take.label || take.id);
+ STEM_COLORS[take.id] = TAKE_COLOR;
+ }
+}
+
+/** Display name of any lane: a stem's translated name, or a take's label. */
+export function laneLabel(name) {
+ return STEM_DISPLAY[name] || name;
}
// A Proxy, not a plain object, so every lookup resolves through the CURRENT
@@ -48,6 +83,7 @@ export function effectiveStemOrder(presentNames) {
export const STEM_DISPLAY = new Proxy({}, {
get(_target, prop) {
if (typeof prop !== "string") return undefined;
+ if (_takeLabels.has(prop)) return _takeLabels.get(prop);
const key = `stem.${prop}`;
return key in TRANSLATIONS.en ? t(key) : undefined;
},
diff --git a/static/js/helpMode.js b/static/js/helpMode.js
index 7749d4fe..26d467e3 100644
--- a/static/js/helpMode.js
+++ b/static/js/helpMode.js
@@ -47,6 +47,15 @@ const HELP = [
["#catalogList .cat-item.in-trash", "help.trashedSong"],
["#catalogList .cat-item", "help.song"],
["#sectionsAddBtn", "help.addSection"],
+ ["#t-rec", "help.record"],
+ ["#t-rec-opts", "help.recOptions"],
+ ["#addTrackBtn", "help.addTrack"],
+ [".track-arm", "help.trackArm", laneName],
+ [".track-input", "help.trackInput", laneName],
+ [".track-lane .take-menu-btn", "help.trackMenu", laneName],
+ [".take-folder-toggle", "help.takeFolder", laneName],
+ [".take-folder-row .take-play", "help.takePlay", laneName],
+ [".take-folder-row .take-folder-menu-btn", "help.takeMenu", laneName],
[".mx-fader", "help.volume", laneName],
[".lane-key-step.up", "help.laneKeyUp", laneName],
[".lane-key-step.down", "help.laneKeyDown", laneName],
@@ -62,6 +71,8 @@ const HELP = [
['.daw-meta-card[data-meta="stability"]', "help.meta.stability"],
[".stem-presence-panel .stem-card[data-stem]", "help.presence", presenceName],
["#ruler-time", "help.waves", null, "help.wavesTitle"],
+ // Inside .waves-column, so before it: a take lane is its own control.
+ [".take-folder-lane", "help.takeLane", null, "rec.folder.play"],
[".waves-column", "help.waves", null, "help.wavesTitle"],
["#t-stop", "help.stop"],
["#t-play", "help.play"],
@@ -109,6 +120,11 @@ const SETTINGS = [
["settings.export.sampleRate.title", "help.set.sampleRate"],
["settings.export.videoQuality.title", "help.set.videoQuality"],
]],
+ ["settings.tab.recording", [
+ ["settings.rec.input.title", "help.set.recInput"],
+ ["settings.rec.latency.title", "help.set.recLatency"],
+ ["settings.rec.camera.title", "help.set.recCamera"],
+ ]],
["settings.tab.logs", [[null, "help.set.logs"]]],
["settings.tab.registry", [[null, "help.set.registry"]]],
];
diff --git a/static/js/i18n.js b/static/js/i18n.js
index 015dcfe9..99570888 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -218,6 +218,145 @@ function applyStemRowAriaLabels(scope) {
// ─────────────────────────────────────────────────────────────────────────
const en = {
+ // Recording a take, and the shared capture messages (static/js/capture/).
+ "rec.track.add": "Track",
+ "rec.track.addTitle": "Add a recording track",
+ "rec.track.armAria": "Arm {name} for recording",
+ "rec.track.armTitle": "Arm for recording",
+ "rec.track.inputAria": "Input for {name}",
+ "rec.track.inputMono": "Input {n}",
+ "rec.track.inputPair": "Input {a}+{b}",
+ "rec.track.noInput": "No input found",
+ "rec.input.voiceGroup": "Voice (microphone)",
+ "rec.input.instrumentGroup": "Instrument (audio interface)",
+ "rec.input.voiceKind": "Voice",
+ "rec.input.instrumentKind": "Instrument",
+ "rec.input.missing": "{name} (not connected)",
+ "rec.input.unknownDevice": "Input",
+ "rec.input.instrumentChoice": "{name} · {input}",
+ "rec.track.menu": "Track options",
+ "rec.track.menuAria": "Options for {name}",
+ "rec.track.takes": "Takes",
+ "rec.track.noTakes": "No takes yet. Arm the track and press Record.",
+ "rec.track.rename": "Track name",
+ "rec.track.delete": "Delete track",
+ "rec.track.deleteConfirm": "Delete the track and all its takes? Press again",
+ "rec.track.otherDevice": "Only tracks on the same input device can record together: separate devices run on separate clocks. The other tracks were disarmed.",
+ "rec.track.failed": "The track could not be updated.",
+ "rec.track.tooMany": "This song already has the most tracks it can hold.",
+ "rec.status.addTrack": "Add a recording track to record into.",
+ "rec.status.armTrack": "Arm the tracks to record into: the red dot on each track.",
+ "rec.status.trackAdded": "{name} added. Press Record to record into it.",
+ "rec.status.trackInputMissing": "The input chosen for {name} is not connected. Choose another in the track's input selector.",
+ "rec.status.someFailed": "Some tracks could not be saved.",
+ "rec.review.textTrack": "{track}: {take} is saved. Listen to it, then keep it or discard it.",
+ "rec.review.textMany": "The new takes are saved. Listen to them, then keep or discard them.",
+ "help.record": "Records into the armed tracks while the song plays. Press again to stop.",
+ "help.recOptions": "Monitoring, which lanes to mute while recording, the camera, and takes with video.",
+ "help.addTrack": "Adds an empty recording track below the stems.",
+ "help.trackArm": "Arms {name}: Record records into every armed track.",
+ "help.trackInput": "Which input {name} records from: one channel, or a stereo pair.",
+ "help.trackMenu": "Rename {name}, or delete it with all its takes.",
+ "help.takeFolder": "Shows or hides the takes of {name}: one lane per take, newest on top.",
+ "help.takePlay": "Makes {name} the take its track plays. The track's own lane then shows it.",
+ "help.takeMenu": "Rename {name}, nudge it in time, or delete it.",
+ "help.takeLane": "One take of this track. Click it to make it the take that plays.",
+ "settings.tab.recording": "Recording",
+ "settings.rec.input.title": "Input",
+ "settings.rec.input.desc": "The microphone or audio interface input that takes are recorded from. The meter shows its level.",
+ "settings.rec.latency.title": "Latency test",
+ "settings.rec.latency.desc": "Plays five clicks and listens for them, so takes line up with the song. Run it once for each input and output. About 20 seconds.",
+ "settings.rec.latency.run": "Test latency",
+ "settings.rec.camera.title": "Camera",
+ "settings.rec.camera.desc": "The camera used when a take is filmed. The preview below shows its framing.",
+ "settings.rec.quality.title": "Camera quality",
+ "settings.rec.quality.desc": "Capture size for filmed takes. 720p is lighter on the computer while it plays the song.",
+ "settings.rec.mirror.title": "Mirror preview",
+ "settings.rec.mirror.desc": "Shows the preview like a mirror. The finished video is never mirrored.",
+ "settings.rec.stopFirst": "Stop playback first, then test the latency.",
+ "rec.cal.needInputSettings": "The input could not be opened, so the latency was not tested.",
+ "rec.openSettings": "Open Settings",
+ "rec.settingsLink": "Input and camera settings",
+ "help.set.recInput": "The input that takes are recorded from, with a level meter.",
+ "help.set.recLatency": "Measures how late the input hears the song, so takes line up. Run it once per input.",
+ "help.set.recCamera": "The camera for filmed takes, its quality, and whether the preview is mirrored.",
+ "capture.defaultDevice": "Default device",
+ "capture.inputN": "Input {n}",
+ "capture.cameraN": "Camera {n}",
+ "capture.hint.https": "Open {url} instead.",
+ "capture.hint.linuxPlugins": "On Linux, install the GStreamer pulse or PipeWire plugins (gstreamer1.0-plugins-good or gstreamer1.0-pipewire), then restart StemDeck.",
+ "capture.reason.insecure-context.audio": "The microphone needs a secure connection. Open StemDeck over https, or on this computer at 127.0.0.1.",
+ "capture.reason.insecure-context.video": "The camera needs a secure connection. Open StemDeck over https, or on this computer at 127.0.0.1.",
+ "capture.reason.unsupported.audio": "This browser cannot record from a microphone.",
+ "capture.reason.unsupported.video": "This browser cannot record from a camera.",
+ "capture.reason.linux-webview.audio": "This build of the Linux app cannot reach the microphone. Update StemDeck.",
+ "capture.reason.linux-webview.video": "This build of the Linux app cannot reach the camera. Update StemDeck.",
+ "capture.reason.denied.audio": "Microphone access was blocked. Allow it in the browser's site settings, then try again.",
+ "capture.reason.denied.video": "Camera access was blocked. Allow it in the browser's site settings, then try again.",
+ "capture.reason.os-blocked-macos.audio": "macOS is blocking the microphone. Allow StemDeck in System Settings > Privacy & Security > Microphone, then restart StemDeck.",
+ "capture.reason.os-blocked-macos.video": "macOS is blocking the camera. Allow StemDeck in System Settings > Privacy & Security > Camera, then restart StemDeck.",
+ "capture.reason.os-blocked-windows.audio": "Windows is blocking the microphone. Turn on \"Let desktop apps access your microphone\" in Settings > Privacy & security > Microphone.",
+ "capture.reason.os-blocked-windows.video": "Windows is blocking the camera. Turn on \"Let desktop apps access your camera\" in Settings > Privacy & security > Camera.",
+ "capture.reason.no-device.audio": "No microphone was found. Connect one, then try again.",
+ "capture.reason.no-device.video": "No camera was found. Connect one, then try again.",
+ "capture.reason.device-busy.audio": "The microphone is in use by another app, or the system would not open it.",
+ "capture.reason.device-busy.video": "The camera is in use by another app, or the system would not open it.",
+ "capture.reason.unknown.audio": "The microphone could not be opened.",
+ "capture.reason.unknown.video": "The camera could not be opened.",
+ "capture.reason.rate-mismatch": "The microphone runs at a different sample rate from playback. Set both to 48 kHz in your system sound settings, then try again.",
+ "rec.record": "Record",
+ "rec.recordTitle": "Record a take over the song",
+ "rec.stopRecording": "Stop recording",
+ "rec.optionsTitle": "Recording options",
+ "rec.panelTitle": "Record a take",
+ "rec.latency.unknown": "Latency: open an input to measure it",
+ "rec.latency.calibrated": "Latency {ms} ms, calibrated",
+ "rec.latency.estimated": "Latency about {ms} ms, estimated. Calibrate for a tight take.",
+ "rec.cal.instructions": "Turn the volume up. Hold the microphone close to the speaker or to a headphone cup, or connect the interface's output to its input. Five clicks play and are measured.",
+ "rec.cal.running": "Listening…",
+ "rec.cal.done": "Round trip {ms} ms. Saved for this input.",
+ "rec.cal.tooQuiet": "No clicks were heard. Turn the volume up or move the microphone closer, then try again.",
+ "rec.cal.inconsistent": "The clicks did not line up. Reduce background noise, then try again.",
+ "rec.monitor": "Hear myself through StemDeck",
+ "rec.monitorDelay": "You hear yourself about {ms} ms late.",
+ "rec.headphonesNote": "Use headphones. With speakers the song bleeds into the take, and hearing yourself can feed back.",
+ "rec.muteDuring": "Mute while recording",
+ "rec.review.keep": "Keep",
+ "rec.review.discard": "Discard",
+ "rec.status.opening": "Opening the input…",
+ "rec.status.recording": "Recording…",
+ "rec.status.saving": "Saving the take…",
+ "rec.status.tooShort": "Stopped before the song started, so nothing was saved.",
+ "rec.status.failed": "The take could not be saved.",
+ "rec.status.tooMany": "This song already has the most takes it can hold. Delete one first.",
+ "rec.status.tempo": "Recording works at normal speed only. Set the speed to 1x first.",
+ "rec.status.noEngine": "Open a finished track in the studio to record over it.",
+ "rec.status.deviceLost": "The input was disconnected. The take is kept up to that point.",
+ "rec.status.inputLost": "The input was disconnected.",
+ "rec.status.kept": "Take kept.",
+ "rec.status.discarded": "Take discarded.",
+ "rec.take.nudge": "Nudge (ms)",
+ "rec.take.nudgeEarlier": "Move the take 1 ms earlier",
+ "rec.take.nudgeLater": "Move the take 1 ms later",
+ "rec.take.nudgeHint": "Positive numbers move the take earlier: use them when it sounds late. Shift steps by 10 ms.",
+ "rec.take.apply": "Apply",
+ "rec.take.saving": "Saving…",
+ "rec.take.failed": "The take could not be updated.",
+ "rec.take.delete": "Delete take",
+ "rec.folder.open": "Show takes",
+ "rec.folder.close": "Hide takes",
+ "rec.folder.openAria": "Show the takes of {name}",
+ "rec.folder.closeAria": "Hide the takes of {name}",
+ "rec.folder.play": "Play this take",
+ "rec.folder.playing": "Playing",
+ "rec.folder.playAria": "Play {take} on {name}",
+ "rec.folder.playingAria": "{take} is playing on {name}",
+ "rec.folder.nudged": "nudge {ms}",
+ "rec.folder.menu": "Take options",
+ "rec.folder.menuAria": "Options for {take}",
+ "rec.folder.laneTitle": "{take}: click to play this take",
+ "rec.take.rename": "Take name",
+ "rec.take.deleteConfirm": "Delete? Press again",
"sections.kind.intro": "Intro",
"sections.kind.outro": "Outro",
"sections.kind.break": "Break",
@@ -1111,9 +1250,149 @@ const en = {
"resetConfirm.resetting": "Resetting…",
"resetConfirm.failed": "Reset failed.",
"resetConfirm.failedConnection": "Reset failed — check your connection.",
+ "video.previewAria": "Camera preview",
};
const pl = {
+ // Recording a take, and the shared capture messages (static/js/capture/).
+ "rec.track.add": "Ścieżka",
+ "rec.track.addTitle": "Dodaj ścieżkę nagrania",
+ "rec.track.armAria": "Uzbrój {name} do nagrywania",
+ "rec.track.armTitle": "Uzbrój do nagrywania",
+ "rec.track.inputAria": "Wejście dla: {name}",
+ "rec.track.inputMono": "Wejście {n}",
+ "rec.track.inputPair": "Wejście {a}+{b}",
+ "rec.track.noInput": "Nie znaleziono wejścia",
+ "rec.input.voiceGroup": "Głos (mikrofon)",
+ "rec.input.instrumentGroup": "Instrument (interfejs audio)",
+ "rec.input.voiceKind": "Głos",
+ "rec.input.instrumentKind": "Instrument",
+ "rec.input.missing": "{name} (niepodłączone)",
+ "rec.input.unknownDevice": "Wejście",
+ "rec.input.instrumentChoice": "{name} · {input}",
+ "rec.track.menu": "Opcje ścieżki",
+ "rec.track.menuAria": "Opcje: {name}",
+ "rec.track.takes": "Ujęcia",
+ "rec.track.noTakes": "Brak ujęć. Uzbrój ścieżkę i naciśnij Nagrywaj.",
+ "rec.track.rename": "Nazwa ścieżki",
+ "rec.track.delete": "Usuń ścieżkę",
+ "rec.track.deleteConfirm": "Usunąć ścieżkę i wszystkie jej ujęcia? Naciśnij ponownie",
+ "rec.track.otherDevice": "Razem mogą nagrywać tylko ścieżki z tego samego urządzenia wejściowego: różne urządzenia mają różne zegary. Pozostałe ścieżki rozbrojono.",
+ "rec.track.failed": "Nie udało się zaktualizować ścieżki.",
+ "rec.track.tooMany": "Ten utwór ma już maksymalną liczbę ścieżek.",
+ "rec.status.addTrack": "Dodaj ścieżkę nagrania, aby na nią nagrywać.",
+ "rec.status.armTrack": "Uzbrój ścieżki, na które chcesz nagrywać: czerwona kropka na każdej ścieżce.",
+ "rec.status.trackAdded": "Dodano: {name}. Naciśnij Nagrywaj, aby na nią nagrywać.",
+ "rec.status.trackInputMissing": "Wejście wybrane dla: {name} nie jest podłączone. Wybierz inne w selektorze wejścia ścieżki.",
+ "rec.status.someFailed": "Nie udało się zapisać niektórych ścieżek.",
+ "rec.review.textTrack": "{track}: zapisano {take}. Odsłuchaj, a potem zachowaj lub odrzuć.",
+ "rec.review.textMany": "Nowe ujęcia zapisano. Odsłuchaj je, a potem zachowaj lub odrzuć.",
+ "help.record": "Nagrywa na uzbrojone ścieżki podczas odtwarzania utworu. Naciśnij ponownie, aby zatrzymać.",
+ "help.recOptions": "Odsłuch, ścieżki wyciszane podczas nagrywania, kamera i ujęcia z wideo.",
+ "help.addTrack": "Dodaje pustą ścieżkę nagrania pod stemami.",
+ "help.trackArm": "Uzbraja: {name}. Nagrywaj nagrywa na każdą uzbrojoną ścieżkę.",
+ "help.trackInput": "Z którego wejścia nagrywa {name}: jeden kanał albo para stereo.",
+ "help.trackMenu": "Zmień nazwę ścieżki {name} albo usuń ją razem ze wszystkimi ujęciami.",
+ "help.takeFolder": "Pokazuje lub ukrywa ujęcia ścieżki {name}: jeden pas na ujęcie, najnowsze na górze.",
+ "help.takePlay": "Ustawia {name} jako ujęcie odtwarzane przez ścieżkę. Pas ścieżki pokazuje je wtedy.",
+ "help.takeMenu": "Zmień nazwę ujęcia {name}, przesuń je w czasie lub usuń.",
+ "help.takeLane": "Jedno ujęcie tej ścieżki. Kliknij, aby to ono było odtwarzane.",
+ "settings.tab.recording": "Nagrywanie",
+ "settings.rec.input.title": "Wejście",
+ "settings.rec.input.desc": "Mikrofon lub wejście interfejsu audio, z którego nagrywane są ujęcia. Wskaźnik pokazuje jego poziom.",
+ "settings.rec.latency.title": "Test opóźnienia",
+ "settings.rec.latency.desc": "Odtwarza pięć kliknięć i nasłuchuje ich, aby ujęcia pokrywały się z utworem. Uruchom go raz dla każdego wejścia i wyjścia. Około 20 sekund.",
+ "settings.rec.latency.run": "Testuj opóźnienie",
+ "settings.rec.camera.title": "Kamera",
+ "settings.rec.camera.desc": "Kamera używana, gdy ujęcie jest filmowane. Podgląd poniżej pokazuje kadr.",
+ "settings.rec.quality.title": "Jakość kamery",
+ "settings.rec.quality.desc": "Rozmiar obrazu filmowanych ujęć. 720p mniej obciąża komputer podczas odtwarzania utworu.",
+ "settings.rec.mirror.title": "Odbicie lustrzane podglądu",
+ "settings.rec.mirror.desc": "Pokazuje podgląd jak w lustrze. Gotowe wideo nigdy nie jest odbite.",
+ "settings.rec.stopFirst": "Najpierw zatrzymaj odtwarzanie, a potem przetestuj opóźnienie.",
+ "rec.cal.needInputSettings": "Nie udało się otworzyć wejścia, więc opóźnienie nie zostało zmierzone.",
+ "rec.openSettings": "Otwórz ustawienia",
+ "rec.settingsLink": "Ustawienia wejścia i kamery",
+ "help.set.recInput": "Wejście, z którego nagrywane są ujęcia, ze wskaźnikiem poziomu.",
+ "help.set.recLatency": "Mierzy, z jakim opóźnieniem wejście słyszy utwór, aby ujęcia się pokrywały. Uruchom raz dla każdego wejścia.",
+ "help.set.recCamera": "Kamera do filmowanych ujęć, jej jakość i to, czy podgląd jest odbity.",
+ "capture.defaultDevice": "Urządzenie domyślne",
+ "capture.inputN": "Wejście {n}",
+ "capture.cameraN": "Kamera {n}",
+ "capture.hint.https": "Otwórz zamiast tego {url}.",
+ "capture.hint.linuxPlugins": "W systemie Linux zainstaluj wtyczki GStreamer pulse lub PipeWire (gstreamer1.0-plugins-good lub gstreamer1.0-pipewire), a potem uruchom ponownie StemDeck.",
+ "capture.reason.insecure-context.audio": "Mikrofon wymaga bezpiecznego połączenia. Otwórz StemDeck przez https albo na tym komputerze pod adresem 127.0.0.1.",
+ "capture.reason.insecure-context.video": "Kamera wymaga bezpiecznego połączenia. Otwórz StemDeck przez https albo na tym komputerze pod adresem 127.0.0.1.",
+ "capture.reason.unsupported.audio": "Ta przeglądarka nie może nagrywać z mikrofonu.",
+ "capture.reason.unsupported.video": "Ta przeglądarka nie może nagrywać z kamery.",
+ "capture.reason.linux-webview.audio": "Ta wersja aplikacji dla systemu Linux nie ma dostępu do mikrofonu. Zaktualizuj StemDeck.",
+ "capture.reason.linux-webview.video": "Ta wersja aplikacji dla systemu Linux nie ma dostępu do kamery. Zaktualizuj StemDeck.",
+ "capture.reason.denied.audio": "Dostęp do mikrofonu został zablokowany. Zezwól na niego w ustawieniach witryny w przeglądarce i spróbuj ponownie.",
+ "capture.reason.denied.video": "Dostęp do kamery został zablokowany. Zezwól na niego w ustawieniach witryny w przeglądarce i spróbuj ponownie.",
+ "capture.reason.os-blocked-macos.audio": "macOS blokuje mikrofon. Zezwól StemDeck w Ustawieniach systemowych > Prywatność i ochrona > Mikrofon, a potem uruchom ponownie StemDeck.",
+ "capture.reason.os-blocked-macos.video": "macOS blokuje kamerę. Zezwól StemDeck w Ustawieniach systemowych > Prywatność i ochrona > Kamera, a potem uruchom ponownie StemDeck.",
+ "capture.reason.os-blocked-windows.audio": "Windows blokuje mikrofon. Włącz „Zezwalaj aplikacjom klasycznym na dostęp do mikrofonu” w Ustawieniach > Prywatność i zabezpieczenia > Mikrofon.",
+ "capture.reason.os-blocked-windows.video": "Windows blokuje kamerę. Włącz „Zezwalaj aplikacjom klasycznym na dostęp do kamery” w Ustawieniach > Prywatność i zabezpieczenia > Kamera.",
+ "capture.reason.no-device.audio": "Nie znaleziono mikrofonu. Podłącz go i spróbuj ponownie.",
+ "capture.reason.no-device.video": "Nie znaleziono kamery. Podłącz ją i spróbuj ponownie.",
+ "capture.reason.device-busy.audio": "Mikrofon jest używany przez inną aplikację albo system nie pozwolił go otworzyć.",
+ "capture.reason.device-busy.video": "Kamera jest używana przez inną aplikację albo system nie pozwolił jej otworzyć.",
+ "capture.reason.unknown.audio": "Nie udało się otworzyć mikrofonu.",
+ "capture.reason.unknown.video": "Nie udało się otworzyć kamery.",
+ "capture.reason.rate-mismatch": "Mikrofon działa z inną częstotliwością próbkowania niż odtwarzanie. Ustaw oba na 48 kHz w systemowych ustawieniach dźwięku i spróbuj ponownie.",
+ "rec.record": "Nagrywaj",
+ "rec.recordTitle": "Nagraj ujęcie do utworu",
+ "rec.stopRecording": "Zatrzymaj nagrywanie",
+ "rec.optionsTitle": "Opcje nagrywania",
+ "rec.panelTitle": "Nagraj ujęcie",
+ "rec.latency.unknown": "Opóźnienie: otwórz wejście, aby je zmierzyć",
+ "rec.latency.calibrated": "Opóźnienie {ms} ms, skalibrowane",
+ "rec.latency.estimated": "Opóźnienie około {ms} ms, szacowane. Skalibruj, aby ujęcie było równe.",
+ "rec.cal.instructions": "Zwiększ głośność. Trzymaj mikrofon blisko głośnika lub muszli słuchawki albo połącz wyjście interfejsu z jego wejściem. Zabrzmi pięć kliknięć, które zostaną zmierzone.",
+ "rec.cal.running": "Nasłuchiwanie…",
+ "rec.cal.done": "Czas w obie strony {ms} ms. Zapisano dla tego wejścia.",
+ "rec.cal.tooQuiet": "Nie usłyszano kliknięć. Zwiększ głośność lub przysuń mikrofon i spróbuj ponownie.",
+ "rec.cal.inconsistent": "Kliknięcia się nie zgadzały. Ogranicz hałas w tle i spróbuj ponownie.",
+ "rec.monitor": "Słysz siebie przez StemDeck",
+ "rec.monitorDelay": "Słyszysz siebie z opóźnieniem około {ms} ms.",
+ "rec.headphonesNote": "Używaj słuchawek. Przy głośnikach utwór przenika do ujęcia, a odsłuch może wywołać sprzężenie.",
+ "rec.muteDuring": "Wycisz podczas nagrywania",
+ "rec.review.keep": "Zachowaj",
+ "rec.review.discard": "Odrzuć",
+ "rec.status.opening": "Otwieranie wejścia…",
+ "rec.status.recording": "Nagrywanie…",
+ "rec.status.saving": "Zapisywanie ujęcia…",
+ "rec.status.tooShort": "Zatrzymano przed początkiem utworu, więc nic nie zapisano.",
+ "rec.status.failed": "Nie udało się zapisać ujęcia.",
+ "rec.status.tooMany": "Ten utwór ma już maksymalną liczbę ujęć. Najpierw usuń jedno.",
+ "rec.status.tempo": "Nagrywanie działa tylko przy normalnej prędkości. Najpierw ustaw prędkość 1x.",
+ "rec.status.noEngine": "Otwórz w studiu gotowy utwór, aby nagrywać do niego.",
+ "rec.status.deviceLost": "Wejście zostało odłączone. Ujęcie zachowano do tego momentu.",
+ "rec.status.inputLost": "Wejście zostało odłączone.",
+ "rec.status.kept": "Ujęcie zachowane.",
+ "rec.status.discarded": "Ujęcie odrzucone.",
+ "rec.take.nudge": "Przesunięcie (ms)",
+ "rec.take.nudgeEarlier": "Przesuń ujęcie o 1 ms wcześniej",
+ "rec.take.nudgeLater": "Przesuń ujęcie o 1 ms później",
+ "rec.take.nudgeHint": "Liczby dodatnie przesuwają ujęcie wcześniej: użyj ich, gdy brzmi z opóźnieniem. Z klawiszem Shift krok wynosi 10 ms.",
+ "rec.take.apply": "Zastosuj",
+ "rec.take.saving": "Zapisywanie…",
+ "rec.take.failed": "Nie udało się zaktualizować ujęcia.",
+ "rec.take.delete": "Usuń ujęcie",
+ "rec.folder.open": "Pokaż ujęcia",
+ "rec.folder.close": "Ukryj ujęcia",
+ "rec.folder.openAria": "Pokaż ujęcia ścieżki {name}",
+ "rec.folder.closeAria": "Ukryj ujęcia ścieżki {name}",
+ "rec.folder.play": "Odtwarzaj to ujęcie",
+ "rec.folder.playing": "Odtwarzane",
+ "rec.folder.playAria": "Odtwarzaj {take} na ścieżce {name}",
+ "rec.folder.playingAria": "{take} jest odtwarzane na ścieżce {name}",
+ "rec.folder.nudged": "przesunięcie {ms}",
+ "rec.folder.menu": "Opcje ujęcia",
+ "rec.folder.menuAria": "Opcje: {take}",
+ "rec.folder.laneTitle": "{take}: kliknij, aby odtwarzać to ujęcie",
+ "rec.take.rename": "Nazwa ujęcia",
+ "rec.take.deleteConfirm": "Usunąć? Naciśnij ponownie",
"sections.kind.intro": "Intro",
"sections.kind.outro": "Zakończenie",
"sections.kind.break": "Przerwa",
@@ -1997,9 +2276,149 @@ const pl = {
"resetConfirm.resetting": "Resetowanie…",
"resetConfirm.failed": "Resetowanie nie powiodło się.",
"resetConfirm.failedConnection": "Resetowanie nie powiodło się — sprawdź połączenie.",
+ "video.previewAria": "Podgląd kamery",
};
const ja = {
+ // Recording a take, and the shared capture messages (static/js/capture/).
+ "rec.track.add": "トラック",
+ "rec.track.addTitle": "録音トラックを追加",
+ "rec.track.armAria": "{name} を録音待機にする",
+ "rec.track.armTitle": "録音待機",
+ "rec.track.inputAria": "{name} の入力",
+ "rec.track.inputMono": "入力 {n}",
+ "rec.track.inputPair": "入力 {a}+{b}",
+ "rec.track.noInput": "入力が見つかりません",
+ "rec.input.voiceGroup": "ボーカル(マイク)",
+ "rec.input.instrumentGroup": "楽器(オーディオインターフェース)",
+ "rec.input.voiceKind": "ボーカル",
+ "rec.input.instrumentKind": "楽器",
+ "rec.input.missing": "{name}(未接続)",
+ "rec.input.unknownDevice": "入力",
+ "rec.input.instrumentChoice": "{name} · {input}",
+ "rec.track.menu": "トラックのオプション",
+ "rec.track.menuAria": "{name} のオプション",
+ "rec.track.takes": "テイク",
+ "rec.track.noTakes": "まだテイクがありません。トラックを録音待機にして録音を押してください。",
+ "rec.track.rename": "トラック名",
+ "rec.track.delete": "トラックを削除",
+ "rec.track.deleteConfirm": "トラックとすべてのテイクを削除しますか? もう一度押してください",
+ "rec.track.otherDevice": "同時に録音できるのは同じ入力デバイスのトラックだけです。デバイスが違うとクロックも違うためです。ほかのトラックの録音待機を解除しました。",
+ "rec.track.failed": "トラックを更新できませんでした。",
+ "rec.track.tooMany": "この曲のトラック数は上限に達しています。",
+ "rec.status.addTrack": "録音先の録音トラックを追加してください。",
+ "rec.status.armTrack": "録音するトラックを録音待機にしてください: 各トラックの赤い点です。",
+ "rec.status.trackAdded": "{name} を追加しました。録音を押すとこのトラックに録音します。",
+ "rec.status.trackInputMissing": "{name} に選んだ入力が接続されていません。トラックの入力セレクターで別の入力を選んでください。",
+ "rec.status.someFailed": "一部のトラックを保存できませんでした。",
+ "rec.review.textTrack": "{track}: {take} を保存しました。聞いてから、残すか破棄するかを選んでください。",
+ "rec.review.textMany": "新しいテイクを保存しました。聞いてから、残すか破棄するかを選んでください。",
+ "help.record": "曲の再生中、録音待機のトラックに録音します。もう一度押すと止まります。",
+ "help.recOptions": "モニタリング、録音中にミュートするレーン、カメラ、動画付きのテイクです。",
+ "help.addTrack": "ステムの下に空の録音トラックを追加します。",
+ "help.trackArm": "{name} を録音待機にします。録音を押すと、録音待機のすべてのトラックに録音します。",
+ "help.trackInput": "{name} が録音する入力です: 1 チャンネル、またはステレオペア。",
+ "help.trackMenu": "{name} の名前を変更するか、すべてのテイクごと削除します。",
+ "help.takeFolder": "{name} のテイクを表示または非表示にします。テイクごとに1レーンで、新しいものが上です。",
+ "help.takePlay": "{name} をトラックで再生するテイクにします。トラックのレーンにこのテイクが表示されます。",
+ "help.takeMenu": "{name} の名前の変更、タイミングのずらし、削除ができます。",
+ "help.takeLane": "このトラックのテイクの1つです。クリックすると、このテイクを再生します。",
+ "settings.tab.recording": "録音",
+ "settings.rec.input.title": "入力",
+ "settings.rec.input.desc": "テイクを録音するマイクまたはオーディオインターフェースの入力です。メーターでレベルを確認できます。",
+ "settings.rec.latency.title": "レイテンシーテスト",
+ "settings.rec.latency.desc": "クリックを 5 回鳴らして聞き取り、テイクが曲と揃うようにします。入力と出力の組み合わせごとに 1 回行ってください。約 20 秒です。",
+ "settings.rec.latency.run": "レイテンシーをテスト",
+ "settings.rec.camera.title": "カメラ",
+ "settings.rec.camera.desc": "テイクを撮影するときに使うカメラです。下のプレビューで画角を確認できます。",
+ "settings.rec.quality.title": "カメラの画質",
+ "settings.rec.quality.desc": "撮影するテイクのサイズです。720p は曲の再生中もコンピューターへの負荷が軽くなります。",
+ "settings.rec.mirror.title": "プレビューを反転",
+ "settings.rec.mirror.desc": "プレビューを鏡のように表示します。完成した動画は反転しません。",
+ "settings.rec.stopFirst": "先に再生を止めてから、レイテンシーをテストしてください。",
+ "rec.cal.needInputSettings": "入力を開けなかったため、レイテンシーをテストできませんでした。",
+ "rec.openSettings": "設定を開く",
+ "rec.settingsLink": "入力とカメラの設定",
+ "help.set.recInput": "テイクを録音する入力と、そのレベルメーターです。",
+ "help.set.recLatency": "入力が曲をどれだけ遅れて聞くかを測り、テイクを揃えます。入力ごとに 1 回行ってください。",
+ "help.set.recCamera": "撮影用のカメラ、その画質、プレビューを反転するかどうかです。",
+ "capture.defaultDevice": "既定のデバイス",
+ "capture.inputN": "入力 {n}",
+ "capture.cameraN": "カメラ {n}",
+ "capture.hint.https": "代わりに {url} を開いてください。",
+ "capture.hint.linuxPlugins": "Linux では GStreamer の pulse または PipeWire プラグイン(gstreamer1.0-plugins-good または gstreamer1.0-pipewire)をインストールしてから、StemDeck を再起動してください。",
+ "capture.reason.insecure-context.audio": "マイクには安全な接続が必要です。StemDeck を https で開くか、このコンピューターで 127.0.0.1 から開いてください。",
+ "capture.reason.insecure-context.video": "カメラには安全な接続が必要です。StemDeck を https で開くか、このコンピューターで 127.0.0.1 から開いてください。",
+ "capture.reason.unsupported.audio": "このブラウザーはマイクから録音できません。",
+ "capture.reason.unsupported.video": "このブラウザーはカメラから録画できません。",
+ "capture.reason.linux-webview.audio": "この Linux 版アプリはマイクにアクセスできません。StemDeck を更新してください。",
+ "capture.reason.linux-webview.video": "この Linux 版アプリはカメラにアクセスできません。StemDeck を更新してください。",
+ "capture.reason.denied.audio": "マイクへのアクセスがブロックされました。ブラウザーのサイト設定で許可してから、もう一度お試しください。",
+ "capture.reason.denied.video": "カメラへのアクセスがブロックされました。ブラウザーのサイト設定で許可してから、もう一度お試しください。",
+ "capture.reason.os-blocked-macos.audio": "macOS がマイクをブロックしています。システム設定 > プライバシーとセキュリティ > マイク で StemDeck を許可し、StemDeck を再起動してください。",
+ "capture.reason.os-blocked-macos.video": "macOS がカメラをブロックしています。システム設定 > プライバシーとセキュリティ > カメラ で StemDeck を許可し、StemDeck を再起動してください。",
+ "capture.reason.os-blocked-windows.audio": "Windows がマイクをブロックしています。設定 > プライバシーとセキュリティ > マイク で「デスクトップ アプリがマイクにアクセスできるようにする」をオンにしてください。",
+ "capture.reason.os-blocked-windows.video": "Windows がカメラをブロックしています。設定 > プライバシーとセキュリティ > カメラ で「デスクトップ アプリがカメラにアクセスできるようにする」をオンにしてください。",
+ "capture.reason.no-device.audio": "マイクが見つかりません。接続してから、もう一度お試しください。",
+ "capture.reason.no-device.video": "カメラが見つかりません。接続してから、もう一度お試しください。",
+ "capture.reason.device-busy.audio": "マイクが別のアプリで使用中か、システムが開けませんでした。",
+ "capture.reason.device-busy.video": "カメラが別のアプリで使用中か、システムが開けませんでした。",
+ "capture.reason.unknown.audio": "マイクを開けませんでした。",
+ "capture.reason.unknown.video": "カメラを開けませんでした。",
+ "capture.reason.rate-mismatch": "マイクのサンプルレートが再生と異なります。システムのサウンド設定で両方を 48 kHz にしてから、もう一度お試しください。",
+ "rec.record": "録音",
+ "rec.recordTitle": "曲に合わせてテイクを録音",
+ "rec.stopRecording": "録音を停止",
+ "rec.optionsTitle": "録音オプション",
+ "rec.panelTitle": "テイクを録音",
+ "rec.latency.unknown": "レイテンシー: 入力を開くと測定します",
+ "rec.latency.calibrated": "レイテンシー {ms} ms(キャリブレーション済み)",
+ "rec.latency.estimated": "レイテンシー 約 {ms} ms(推定)。正確に揃えるにはキャリブレーションしてください。",
+ "rec.cal.instructions": "音量を上げてください。マイクをスピーカーかヘッドホンのカップに近づけるか、オーディオインターフェースの出力を入力につないでください。クリックが 5 回鳴り、測定されます。",
+ "rec.cal.running": "聞き取り中…",
+ "rec.cal.done": "往復 {ms} ms。この入力用に保存しました。",
+ "rec.cal.tooQuiet": "クリックが聞こえませんでした。音量を上げるかマイクを近づけて、もう一度お試しください。",
+ "rec.cal.inconsistent": "クリックが揃いませんでした。周囲の雑音を減らして、もう一度お試しください。",
+ "rec.monitor": "StemDeck で自分の音を聞く",
+ "rec.monitorDelay": "自分の音は約 {ms} ms 遅れて聞こえます。",
+ "rec.headphonesNote": "ヘッドホンを使ってください。スピーカーでは曲がテイクに混ざり、自分の音を聞くとハウリングすることがあります。",
+ "rec.muteDuring": "録音中にミュート",
+ "rec.review.keep": "残す",
+ "rec.review.discard": "破棄",
+ "rec.status.opening": "入力を開いています…",
+ "rec.status.recording": "録音中…",
+ "rec.status.saving": "テイクを保存中…",
+ "rec.status.tooShort": "曲が始まる前に停止したため、何も保存されませんでした。",
+ "rec.status.failed": "テイクを保存できませんでした。",
+ "rec.status.tooMany": "この曲のテイク数は上限に達しています。先にひとつ削除してください。",
+ "rec.status.tempo": "録音は通常速度でのみ使えます。先に速度を 1x にしてください。",
+ "rec.status.noEngine": "録音するには、完了したトラックをスタジオで開いてください。",
+ "rec.status.deviceLost": "入力が切断されました。テイクはその時点まで保存されます。",
+ "rec.status.inputLost": "入力が切断されました。",
+ "rec.status.kept": "テイクを残しました。",
+ "rec.status.discarded": "テイクを破棄しました。",
+ "rec.take.nudge": "ずらし (ms)",
+ "rec.take.nudgeEarlier": "テイクを 1 ms 早める",
+ "rec.take.nudgeLater": "テイクを 1 ms 遅らせる",
+ "rec.take.nudgeHint": "正の値はテイクを早めます。遅れて聞こえるときに使ってください。Shift で 10 ms ずつ動きます。",
+ "rec.take.apply": "適用",
+ "rec.take.saving": "保存中…",
+ "rec.take.failed": "テイクを更新できませんでした。",
+ "rec.take.delete": "テイクを削除",
+ "rec.folder.open": "テイクを表示",
+ "rec.folder.close": "テイクを隠す",
+ "rec.folder.openAria": "{name} のテイクを表示",
+ "rec.folder.closeAria": "{name} のテイクを隠す",
+ "rec.folder.play": "このテイクを再生",
+ "rec.folder.playing": "再生中",
+ "rec.folder.playAria": "{name} で {take} を再生",
+ "rec.folder.playingAria": "{name} で {take} を再生中",
+ "rec.folder.nudged": "ずらし {ms}",
+ "rec.folder.menu": "テイクのオプション",
+ "rec.folder.menuAria": "{take} のオプション",
+ "rec.folder.laneTitle": "{take}: クリックしてこのテイクを再生",
+ "rec.take.rename": "テイク名",
+ "rec.take.deleteConfirm": "削除しますか? もう一度押してください",
"sections.kind.intro": "イントロ",
"sections.kind.outro": "アウトロ",
"sections.kind.break": "ブレイク",
@@ -2858,9 +3277,149 @@ const ja = {
"resetConfirm.resetting": "リセット中…",
"resetConfirm.failed": "リセットに失敗しました。",
"resetConfirm.failedConnection": "リセットに失敗しました — 接続を確認してください。",
+ "video.previewAria": "カメラのプレビュー",
};
const zhHans = {
+ // Recording a take, and the shared capture messages (static/js/capture/).
+ "rec.track.add": "音轨",
+ "rec.track.addTitle": "添加录音音轨",
+ "rec.track.armAria": "让 {name} 准备录音",
+ "rec.track.armTitle": "准备录音",
+ "rec.track.inputAria": "{name} 的输入",
+ "rec.track.inputMono": "输入 {n}",
+ "rec.track.inputPair": "输入 {a}+{b}",
+ "rec.track.noInput": "未找到输入",
+ "rec.input.voiceGroup": "人声(麦克风)",
+ "rec.input.instrumentGroup": "乐器(音频接口)",
+ "rec.input.voiceKind": "人声",
+ "rec.input.instrumentKind": "乐器",
+ "rec.input.missing": "{name}(未连接)",
+ "rec.input.unknownDevice": "输入",
+ "rec.input.instrumentChoice": "{name} · {input}",
+ "rec.track.menu": "音轨选项",
+ "rec.track.menuAria": "{name} 的选项",
+ "rec.track.takes": "录音",
+ "rec.track.noTakes": "还没有录音。让音轨准备录音,然后按“录音”。",
+ "rec.track.rename": "音轨名称",
+ "rec.track.delete": "删除音轨",
+ "rec.track.deleteConfirm": "删除音轨及其所有录音?请再按一次",
+ "rec.track.otherDevice": "只有使用同一输入设备的音轨才能一起录音:不同设备的时钟不同。其他音轨已取消准备。",
+ "rec.track.failed": "无法更新音轨。",
+ "rec.track.tooMany": "这首歌的音轨数量已达上限。",
+ "rec.status.addTrack": "请添加一条录音音轨来录音。",
+ "rec.status.armTrack": "请让要录音的音轨准备录音:每条音轨上的红点。",
+ "rec.status.trackAdded": "已添加 {name}。按“录音”即可录到这条音轨。",
+ "rec.status.trackInputMissing": "为 {name} 选择的输入未连接。请在音轨的输入选择器中选择其他输入。",
+ "rec.status.someFailed": "部分音轨无法保存。",
+ "rec.review.textTrack": "{track}:{take} 已保存。先试听,再选择保留或丢弃。",
+ "rec.review.textMany": "新的录音已保存。先试听,再选择保留或丢弃。",
+ "help.record": "在歌曲播放时录到准备好的音轨。再按一次停止。",
+ "help.recOptions": "监听、录音时静音的音轨、摄像头,以及带视频的录音。",
+ "help.addTrack": "在分轨下方添加一条空的录音音轨。",
+ "help.trackArm": "让 {name} 准备录音:“录音”会录到每条准备好的音轨。",
+ "help.trackInput": "{name} 录音所用的输入:一个声道,或一对立体声。",
+ "help.trackMenu": "重命名 {name},或连同所有录音一起删除。",
+ "help.takeFolder": "显示或隐藏 {name} 的录音:每条录音占一行,最新的在最上方。",
+ "help.takePlay": "让 {name} 成为音轨播放的录音,音轨自己的那一行会显示它。",
+ "help.takeMenu": "重命名 {name}、在时间上微调,或删除它。",
+ "help.takeLane": "这条音轨的一条录音。点击它即可播放这条录音。",
+ "settings.tab.recording": "录音",
+ "settings.rec.input.title": "输入",
+ "settings.rec.input.desc": "录制时使用的麦克风或音频接口输入。电平表显示它的音量。",
+ "settings.rec.latency.title": "延迟测试",
+ "settings.rec.latency.desc": "播放五次咔嗒声并收听,让录音与歌曲对齐。每种输入和输出的组合运行一次即可。大约 20 秒。",
+ "settings.rec.latency.run": "测试延迟",
+ "settings.rec.camera.title": "摄像头",
+ "settings.rec.camera.desc": "拍摄录音时使用的摄像头。下方预览可查看取景。",
+ "settings.rec.quality.title": "摄像头画质",
+ "settings.rec.quality.desc": "拍摄录音的画面尺寸。720p 在播放歌曲时对电脑的负担更轻。",
+ "settings.rec.mirror.title": "镜像预览",
+ "settings.rec.mirror.desc": "像镜子一样显示预览。最终视频不会被镜像。",
+ "settings.rec.stopFirst": "请先停止播放,再测试延迟。",
+ "rec.cal.needInputSettings": "无法打开输入,因此没有测试延迟。",
+ "rec.openSettings": "打开设置",
+ "rec.settingsLink": "输入和摄像头设置",
+ "help.set.recInput": "录音所用的输入,以及它的电平表。",
+ "help.set.recLatency": "测量输入听到歌曲的延迟,让录音对齐。每个输入运行一次。",
+ "help.set.recCamera": "拍摄录音所用的摄像头、画质,以及预览是否镜像。",
+ "capture.defaultDevice": "默认设备",
+ "capture.inputN": "输入 {n}",
+ "capture.cameraN": "摄像头 {n}",
+ "capture.hint.https": "请改为打开 {url}。",
+ "capture.hint.linuxPlugins": "在 Linux 上,请安装 GStreamer 的 pulse 或 PipeWire 插件(gstreamer1.0-plugins-good 或 gstreamer1.0-pipewire),然后重新启动 StemDeck。",
+ "capture.reason.insecure-context.audio": "麦克风需要安全连接。请通过 https 打开 StemDeck,或在本机通过 127.0.0.1 打开。",
+ "capture.reason.insecure-context.video": "摄像头需要安全连接。请通过 https 打开 StemDeck,或在本机通过 127.0.0.1 打开。",
+ "capture.reason.unsupported.audio": "此浏览器无法从麦克风录音。",
+ "capture.reason.unsupported.video": "此浏览器无法从摄像头录制。",
+ "capture.reason.linux-webview.audio": "此版本的 Linux 应用无法访问麦克风。请更新 StemDeck。",
+ "capture.reason.linux-webview.video": "此版本的 Linux 应用无法访问摄像头。请更新 StemDeck。",
+ "capture.reason.denied.audio": "麦克风访问被阻止。请在浏览器的网站设置中允许,然后重试。",
+ "capture.reason.denied.video": "摄像头访问被阻止。请在浏览器的网站设置中允许,然后重试。",
+ "capture.reason.os-blocked-macos.audio": "macOS 正在阻止麦克风。请在“系统设置 > 隐私与安全性 > 麦克风”中允许 StemDeck,然后重新启动 StemDeck。",
+ "capture.reason.os-blocked-macos.video": "macOS 正在阻止摄像头。请在“系统设置 > 隐私与安全性 > 摄像头”中允许 StemDeck,然后重新启动 StemDeck。",
+ "capture.reason.os-blocked-windows.audio": "Windows 正在阻止麦克风。请在“设置 > 隐私和安全性 > 麦克风”中打开“允许桌面应用访问你的麦克风”。",
+ "capture.reason.os-blocked-windows.video": "Windows 正在阻止摄像头。请在“设置 > 隐私和安全性 > 摄像头”中打开“允许桌面应用访问你的相机”。",
+ "capture.reason.no-device.audio": "未找到麦克风。请连接后重试。",
+ "capture.reason.no-device.video": "未找到摄像头。请连接后重试。",
+ "capture.reason.device-busy.audio": "麦克风正被其他应用使用,或系统无法打开它。",
+ "capture.reason.device-busy.video": "摄像头正被其他应用使用,或系统无法打开它。",
+ "capture.reason.unknown.audio": "无法打开麦克风。",
+ "capture.reason.unknown.video": "无法打开摄像头。",
+ "capture.reason.rate-mismatch": "麦克风的采样率与播放不同。请在系统声音设置中将两者都设为 48 kHz,然后重试。",
+ "rec.record": "录音",
+ "rec.recordTitle": "跟随歌曲录制一条录音",
+ "rec.stopRecording": "停止录音",
+ "rec.optionsTitle": "录音选项",
+ "rec.panelTitle": "录制一条录音",
+ "rec.latency.unknown": "延迟:打开输入后即可测量",
+ "rec.latency.calibrated": "延迟 {ms} 毫秒,已校准",
+ "rec.latency.estimated": "延迟约 {ms} 毫秒,为估计值。校准后录音会更准。",
+ "rec.cal.instructions": "调高音量。把麦克风靠近扬声器或耳机罩,或把音频接口的输出连到它的输入。将播放并测量五次咔嗒声。",
+ "rec.cal.running": "正在收听…",
+ "rec.cal.done": "往返 {ms} 毫秒。已为此输入保存。",
+ "rec.cal.tooQuiet": "没有听到咔嗒声。请调高音量或把麦克风靠近一些,然后重试。",
+ "rec.cal.inconsistent": "咔嗒声没有对齐。请减少背景噪音,然后重试。",
+ "rec.monitor": "通过 StemDeck 听到自己",
+ "rec.monitorDelay": "你听到自己的声音大约晚 {ms} 毫秒。",
+ "rec.headphonesNote": "请使用耳机。使用扬声器时,歌曲会渗入录音,听自己的声音还可能产生啸叫。",
+ "rec.muteDuring": "录音时静音",
+ "rec.review.keep": "保留",
+ "rec.review.discard": "丢弃",
+ "rec.status.opening": "正在打开输入…",
+ "rec.status.recording": "正在录音…",
+ "rec.status.saving": "正在保存录音…",
+ "rec.status.tooShort": "在歌曲开始前就停止了,因此没有保存任何内容。",
+ "rec.status.failed": "无法保存这条录音。",
+ "rec.status.tooMany": "这首歌的录音数量已达上限。请先删除一条。",
+ "rec.status.tempo": "只能在正常速度下录音。请先把速度设为 1x。",
+ "rec.status.noEngine": "请在工作室中打开一首已完成的歌曲,然后再录音。",
+ "rec.status.deviceLost": "输入已断开。录音保留到断开的那一刻。",
+ "rec.status.inputLost": "输入已断开。",
+ "rec.status.kept": "已保留录音。",
+ "rec.status.discarded": "已丢弃录音。",
+ "rec.take.nudge": "微调(毫秒)",
+ "rec.take.nudgeEarlier": "把录音提前 1 毫秒",
+ "rec.take.nudgeLater": "把录音推后 1 毫秒",
+ "rec.take.nudgeHint": "正数会让录音提前:听起来偏晚时使用。按住 Shift 每次调整 10 毫秒。",
+ "rec.take.apply": "应用",
+ "rec.take.saving": "正在保存…",
+ "rec.take.failed": "无法更新这条录音。",
+ "rec.take.delete": "删除录音",
+ "rec.folder.open": "显示录音",
+ "rec.folder.close": "隐藏录音",
+ "rec.folder.openAria": "显示 {name} 的录音",
+ "rec.folder.closeAria": "隐藏 {name} 的录音",
+ "rec.folder.play": "播放这条录音",
+ "rec.folder.playing": "正在播放",
+ "rec.folder.playAria": "在 {name} 上播放 {take}",
+ "rec.folder.playingAria": "{name} 正在播放 {take}",
+ "rec.folder.nudged": "微调 {ms}",
+ "rec.folder.menu": "录音选项",
+ "rec.folder.menuAria": "{take} 的选项",
+ "rec.folder.laneTitle": "{take}:点击播放这条录音",
+ "rec.take.rename": "录音名称",
+ "rec.take.deleteConfirm": "确定删除?请再按一次",
"sections.kind.intro": "前奏",
"sections.kind.outro": "尾奏",
"sections.kind.break": "间奏",
@@ -3719,9 +4278,149 @@ const zhHans = {
"resetConfirm.resetting": "正在重置…",
"resetConfirm.failed": "重置失败。",
"resetConfirm.failedConnection": "重置失败 — 请检查你的网络连接。",
+ "video.previewAria": "摄像头预览",
};
const de = {
+ // Recording a take, and the shared capture messages (static/js/capture/).
+ "rec.track.add": "Spur",
+ "rec.track.addTitle": "Aufnahmespur hinzufügen",
+ "rec.track.armAria": "{name} für die Aufnahme scharf schalten",
+ "rec.track.armTitle": "Für die Aufnahme scharf schalten",
+ "rec.track.inputAria": "Eingang für {name}",
+ "rec.track.inputMono": "Eingang {n}",
+ "rec.track.inputPair": "Eingang {a}+{b}",
+ "rec.track.noInput": "Kein Eingang gefunden",
+ "rec.input.voiceGroup": "Stimme (Mikrofon)",
+ "rec.input.instrumentGroup": "Instrument (Audio-Interface)",
+ "rec.input.voiceKind": "Stimme",
+ "rec.input.instrumentKind": "Instrument",
+ "rec.input.missing": "{name} (nicht verbunden)",
+ "rec.input.unknownDevice": "Eingang",
+ "rec.input.instrumentChoice": "{name} · {input}",
+ "rec.track.menu": "Spuroptionen",
+ "rec.track.menuAria": "Optionen für {name}",
+ "rec.track.takes": "Takes",
+ "rec.track.noTakes": "Noch keine Takes. Schalte die Spur scharf und drück Aufnehmen.",
+ "rec.track.rename": "Spurname",
+ "rec.track.delete": "Spur löschen",
+ "rec.track.deleteConfirm": "Spur und alle ihre Takes löschen? Noch einmal drücken",
+ "rec.track.otherDevice": "Nur Spuren am selben Eingangsgerät können zusammen aufnehmen: Verschiedene Geräte laufen mit verschiedenen Takten. Die anderen Spuren wurden entschärft.",
+ "rec.track.failed": "Die Spur konnte nicht aktualisiert werden.",
+ "rec.track.tooMany": "Dieser Song hat bereits die maximale Anzahl an Spuren.",
+ "rec.status.addTrack": "Füge eine Aufnahmespur hinzu, um darauf aufzunehmen.",
+ "rec.status.armTrack": "Schalte die Spuren scharf, auf die aufgenommen werden soll: der rote Punkt auf jeder Spur.",
+ "rec.status.trackAdded": "{name} hinzugefügt. Drück Aufnehmen, um darauf aufzunehmen.",
+ "rec.status.trackInputMissing": "Der für {name} gewählte Eingang ist nicht angeschlossen. Wähle einen anderen in der Eingangsauswahl der Spur.",
+ "rec.status.someFailed": "Einige Spuren konnten nicht gespeichert werden.",
+ "rec.review.textTrack": "{track}: {take} ist gespeichert. Hör es dir an und behalte oder verwirf es dann.",
+ "rec.review.textMany": "Die neuen Takes sind gespeichert. Hör sie dir an und behalte oder verwirf sie dann.",
+ "help.record": "Nimmt während der Wiedergabe auf die scharf geschalteten Spuren auf. Erneut drücken zum Stoppen.",
+ "help.recOptions": "Mithören, beim Aufnehmen stummgeschaltete Spuren, Kamera und Takes mit Video.",
+ "help.addTrack": "Fügt unter den Stems eine leere Aufnahmespur hinzu.",
+ "help.trackArm": "Schaltet {name} scharf: Aufnehmen nimmt auf jede scharf geschaltete Spur auf.",
+ "help.trackInput": "Von welchem Eingang {name} aufnimmt: ein Kanal oder ein Stereopaar.",
+ "help.trackMenu": "{name} umbenennen oder mit allen Takes löschen.",
+ "help.takeFolder": "Zeigt oder verbirgt die Takes von {name}: eine Zeile pro Take, der neueste oben.",
+ "help.takePlay": "Macht {name} zum Take, den die Spur spielt. Die Zeile der Spur zeigt ihn dann.",
+ "help.takeMenu": "{name} umbenennen, zeitlich verschieben oder löschen.",
+ "help.takeLane": "Ein Take dieser Spur. Ein Klick macht ihn zum Take, der spielt.",
+ "settings.tab.recording": "Aufnahme",
+ "settings.rec.input.title": "Eingang",
+ "settings.rec.input.desc": "Das Mikrofon oder der Interface-Eingang, von dem Takes aufgenommen werden. Die Anzeige zeigt seinen Pegel.",
+ "settings.rec.latency.title": "Latenztest",
+ "settings.rec.latency.desc": "Spielt fünf Klicks ab und hört auf sie, damit Takes zum Song passen. Einmal pro Ein- und Ausgang ausführen. Etwa 20 Sekunden.",
+ "settings.rec.latency.run": "Latenz testen",
+ "settings.rec.camera.title": "Kamera",
+ "settings.rec.camera.desc": "Die Kamera, die beim Filmen eines Takes verwendet wird. Die Vorschau unten zeigt den Bildausschnitt.",
+ "settings.rec.quality.title": "Kameraqualität",
+ "settings.rec.quality.desc": "Aufnahmegröße für gefilmte Takes. 720p belastet den Computer weniger, während der Song läuft.",
+ "settings.rec.mirror.title": "Vorschau spiegeln",
+ "settings.rec.mirror.desc": "Zeigt die Vorschau wie einen Spiegel. Das fertige Video wird nie gespiegelt.",
+ "settings.rec.stopFirst": "Stoppe zuerst die Wiedergabe und teste dann die Latenz.",
+ "rec.cal.needInputSettings": "Der Eingang konnte nicht geöffnet werden, daher wurde die Latenz nicht getestet.",
+ "rec.openSettings": "Einstellungen öffnen",
+ "rec.settingsLink": "Eingang und Kamera einstellen",
+ "help.set.recInput": "Der Eingang, von dem Takes aufgenommen werden, mit Pegelanzeige.",
+ "help.set.recLatency": "Misst, wie verzögert der Eingang den Song hört, damit Takes passen. Einmal pro Eingang ausführen.",
+ "help.set.recCamera": "Die Kamera für gefilmte Takes, ihre Qualität und ob die Vorschau gespiegelt wird.",
+ "capture.defaultDevice": "Standardgerät",
+ "capture.inputN": "Eingang {n}",
+ "capture.cameraN": "Kamera {n}",
+ "capture.hint.https": "Öffne stattdessen {url}.",
+ "capture.hint.linuxPlugins": "Unter Linux die GStreamer-Plugins für Pulse oder PipeWire installieren (gstreamer1.0-plugins-good oder gstreamer1.0-pipewire) und StemDeck dann neu starten.",
+ "capture.reason.insecure-context.audio": "Das Mikrofon braucht eine sichere Verbindung. Öffne StemDeck über https oder auf diesem Computer unter 127.0.0.1.",
+ "capture.reason.insecure-context.video": "Die Kamera braucht eine sichere Verbindung. Öffne StemDeck über https oder auf diesem Computer unter 127.0.0.1.",
+ "capture.reason.unsupported.audio": "Dieser Browser kann nicht vom Mikrofon aufnehmen.",
+ "capture.reason.unsupported.video": "Dieser Browser kann nicht von der Kamera aufnehmen.",
+ "capture.reason.linux-webview.audio": "Diese Version der Linux-App kann nicht auf das Mikrofon zugreifen. Aktualisiere StemDeck.",
+ "capture.reason.linux-webview.video": "Diese Version der Linux-App kann nicht auf die Kamera zugreifen. Aktualisiere StemDeck.",
+ "capture.reason.denied.audio": "Der Zugriff auf das Mikrofon wurde blockiert. Erlaube ihn in den Website-Einstellungen des Browsers und versuche es erneut.",
+ "capture.reason.denied.video": "Der Zugriff auf die Kamera wurde blockiert. Erlaube ihn in den Website-Einstellungen des Browsers und versuche es erneut.",
+ "capture.reason.os-blocked-macos.audio": "macOS blockiert das Mikrofon. Erlaube StemDeck unter Systemeinstellungen > Datenschutz & Sicherheit > Mikrofon und starte StemDeck dann neu.",
+ "capture.reason.os-blocked-macos.video": "macOS blockiert die Kamera. Erlaube StemDeck unter Systemeinstellungen > Datenschutz & Sicherheit > Kamera und starte StemDeck dann neu.",
+ "capture.reason.os-blocked-windows.audio": "Windows blockiert das Mikrofon. Aktiviere „Desktop-Apps Zugriff auf Ihr Mikrofon erlauben“ unter Einstellungen > Datenschutz und Sicherheit > Mikrofon.",
+ "capture.reason.os-blocked-windows.video": "Windows blockiert die Kamera. Aktiviere „Desktop-Apps Zugriff auf Ihre Kamera erlauben“ unter Einstellungen > Datenschutz und Sicherheit > Kamera.",
+ "capture.reason.no-device.audio": "Kein Mikrofon gefunden. Schließe eins an und versuche es erneut.",
+ "capture.reason.no-device.video": "Keine Kamera gefunden. Schließe eine an und versuche es erneut.",
+ "capture.reason.device-busy.audio": "Das Mikrofon wird von einer anderen App verwendet, oder das System konnte es nicht öffnen.",
+ "capture.reason.device-busy.video": "Die Kamera wird von einer anderen App verwendet, oder das System konnte sie nicht öffnen.",
+ "capture.reason.unknown.audio": "Das Mikrofon konnte nicht geöffnet werden.",
+ "capture.reason.unknown.video": "Die Kamera konnte nicht geöffnet werden.",
+ "capture.reason.rate-mismatch": "Das Mikrofon läuft mit einer anderen Abtastrate als die Wiedergabe. Stelle beide in den Soundeinstellungen des Systems auf 48 kHz und versuche es erneut.",
+ "rec.record": "Aufnehmen",
+ "rec.recordTitle": "Einen Take zum Song aufnehmen",
+ "rec.stopRecording": "Aufnahme stoppen",
+ "rec.optionsTitle": "Aufnahmeoptionen",
+ "rec.panelTitle": "Einen Take aufnehmen",
+ "rec.latency.unknown": "Latenz: Öffne einen Eingang, um sie zu messen",
+ "rec.latency.calibrated": "Latenz {ms} ms, kalibriert",
+ "rec.latency.estimated": "Latenz etwa {ms} ms, geschätzt. Kalibriere für einen tighten Take.",
+ "rec.cal.instructions": "Dreh die Lautstärke hoch. Halte das Mikrofon nah an den Lautsprecher oder an eine Kopfhörermuschel, oder verbinde den Ausgang des Interfaces mit seinem Eingang. Fünf Klicks werden abgespielt und gemessen.",
+ "rec.cal.running": "Hört zu…",
+ "rec.cal.done": "Umlaufzeit {ms} ms. Für diesen Eingang gespeichert.",
+ "rec.cal.tooQuiet": "Es waren keine Klicks zu hören. Dreh die Lautstärke hoch oder halte das Mikrofon näher und versuche es erneut.",
+ "rec.cal.inconsistent": "Die Klicks passten nicht zusammen. Verringere Hintergrundgeräusche und versuche es erneut.",
+ "rec.monitor": "Mich selbst über StemDeck hören",
+ "rec.monitorDelay": "Du hörst dich etwa {ms} ms verzögert.",
+ "rec.headphonesNote": "Nutze Kopfhörer. Mit Lautsprechern gelangt der Song in den Take, und das Mithören kann rückkoppeln.",
+ "rec.muteDuring": "Beim Aufnehmen stummschalten",
+ "rec.review.keep": "Behalten",
+ "rec.review.discard": "Verwerfen",
+ "rec.status.opening": "Eingang wird geöffnet…",
+ "rec.status.recording": "Aufnahme läuft…",
+ "rec.status.saving": "Take wird gespeichert…",
+ "rec.status.tooShort": "Vor dem Songbeginn gestoppt, daher wurde nichts gespeichert.",
+ "rec.status.failed": "Der Take konnte nicht gespeichert werden.",
+ "rec.status.tooMany": "Dieser Song hat bereits die maximale Anzahl an Takes. Lösche zuerst einen.",
+ "rec.status.tempo": "Aufnahme funktioniert nur bei normaler Geschwindigkeit. Stell die Geschwindigkeit zuerst auf 1x.",
+ "rec.status.noEngine": "Öffne einen fertigen Track im Studio, um dazu aufzunehmen.",
+ "rec.status.deviceLost": "Der Eingang wurde getrennt. Der Take bleibt bis zu diesem Punkt erhalten.",
+ "rec.status.inputLost": "Der Eingang wurde getrennt.",
+ "rec.status.kept": "Take behalten.",
+ "rec.status.discarded": "Take verworfen.",
+ "rec.take.nudge": "Verschieben (ms)",
+ "rec.take.nudgeEarlier": "Take 1 ms früher setzen",
+ "rec.take.nudgeLater": "Take 1 ms später setzen",
+ "rec.take.nudgeHint": "Positive Werte setzen den Take früher: Nutze sie, wenn er zu spät klingt. Mit Umschalt in 10-ms-Schritten.",
+ "rec.take.apply": "Anwenden",
+ "rec.take.saving": "Wird gespeichert…",
+ "rec.take.failed": "Der Take konnte nicht aktualisiert werden.",
+ "rec.take.delete": "Take löschen",
+ "rec.folder.open": "Takes zeigen",
+ "rec.folder.close": "Takes ausblenden",
+ "rec.folder.openAria": "Takes von {name} zeigen",
+ "rec.folder.closeAria": "Takes von {name} ausblenden",
+ "rec.folder.play": "Diesen Take spielen",
+ "rec.folder.playing": "Spielt",
+ "rec.folder.playAria": "{take} auf {name} spielen",
+ "rec.folder.playingAria": "{take} spielt auf {name}",
+ "rec.folder.nudged": "verschoben {ms}",
+ "rec.folder.menu": "Take-Optionen",
+ "rec.folder.menuAria": "Optionen für {take}",
+ "rec.folder.laneTitle": "{take}: klicken, um diesen Take zu spielen",
+ "rec.take.rename": "Take-Name",
+ "rec.take.deleteConfirm": "Löschen? Noch einmal drücken",
"sections.kind.intro": "Intro",
"sections.kind.outro": "Outro",
"sections.kind.break": "Break",
@@ -4591,9 +5290,149 @@ const de = {
"resetConfirm.resetting": "Wird zurückgesetzt…",
"resetConfirm.failed": "Zurücksetzen fehlgeschlagen.",
"resetConfirm.failedConnection": "Zurücksetzen fehlgeschlagen — Verbindung prüfen.",
+ "video.previewAria": "Kameravorschau",
};
const pt = {
+ // Recording a take, and the shared capture messages (static/js/capture/).
+ "rec.track.add": "Faixa",
+ "rec.track.addTitle": "Adicionar faixa de gravação",
+ "rec.track.armAria": "Armar {name} para gravação",
+ "rec.track.armTitle": "Armar para gravação",
+ "rec.track.inputAria": "Entrada de {name}",
+ "rec.track.inputMono": "Entrada {n}",
+ "rec.track.inputPair": "Entrada {a}+{b}",
+ "rec.track.noInput": "Nenhuma entrada encontrada",
+ "rec.input.voiceGroup": "Voz (microfone)",
+ "rec.input.instrumentGroup": "Instrumento (interface de áudio)",
+ "rec.input.voiceKind": "Voz",
+ "rec.input.instrumentKind": "Instrumento",
+ "rec.input.missing": "{name} (não conectado)",
+ "rec.input.unknownDevice": "Entrada",
+ "rec.input.instrumentChoice": "{name} · {input}",
+ "rec.track.menu": "Opções da faixa",
+ "rec.track.menuAria": "Opções de {name}",
+ "rec.track.takes": "Takes",
+ "rec.track.noTakes": "Ainda sem takes. Arme a faixa e pressione Gravar.",
+ "rec.track.rename": "Nome da faixa",
+ "rec.track.delete": "Excluir faixa",
+ "rec.track.deleteConfirm": "Excluir a faixa e todos os takes dela? Pressione de novo",
+ "rec.track.otherDevice": "Só faixas no mesmo dispositivo de entrada podem gravar juntas: dispositivos diferentes têm relógios diferentes. As outras faixas foram desarmadas.",
+ "rec.track.failed": "Não foi possível atualizar a faixa.",
+ "rec.track.tooMany": "Esta música já tem o número máximo de faixas.",
+ "rec.status.addTrack": "Adicione uma faixa de gravação para gravar nela.",
+ "rec.status.armTrack": "Arme as faixas em que quer gravar: o ponto vermelho em cada faixa.",
+ "rec.status.trackAdded": "{name} adicionada. Pressione Gravar para gravar nela.",
+ "rec.status.trackInputMissing": "A entrada escolhida para {name} não está conectada. Escolha outra no seletor de entrada da faixa.",
+ "rec.status.someFailed": "Não foi possível salvar algumas faixas.",
+ "rec.review.textTrack": "{track}: {take} foi salvo. Ouça e depois mantenha ou descarte.",
+ "rec.review.textMany": "Os novos takes foram salvos. Ouça e depois mantenha ou descarte.",
+ "help.record": "Grava nas faixas armadas enquanto a música toca. Pressione de novo para parar.",
+ "help.recOptions": "Retorno, faixas silenciadas durante a gravação, câmera e takes com vídeo.",
+ "help.addTrack": "Adiciona uma faixa de gravação vazia abaixo dos stems.",
+ "help.trackArm": "Arma {name}: Gravar grava em cada faixa armada.",
+ "help.trackInput": "De qual entrada {name} grava: um canal ou um par estéreo.",
+ "help.trackMenu": "Renomeie {name} ou exclua a faixa com todos os takes.",
+ "help.takeFolder": "Mostra ou oculta os takes de {name}: uma linha por take, o mais novo em cima.",
+ "help.takePlay": "Faz de {name} o take que a faixa toca. A linha da faixa passa a mostrá-lo.",
+ "help.takeMenu": "Renomeie {name}, ajuste-o no tempo ou exclua-o.",
+ "help.takeLane": "Um take desta faixa. Clique para que ele seja o take que toca.",
+ "settings.tab.recording": "Gravação",
+ "settings.rec.input.title": "Entrada",
+ "settings.rec.input.desc": "O microfone ou a entrada da interface de áudio de onde os takes são gravados. O medidor mostra o nível.",
+ "settings.rec.latency.title": "Teste de latência",
+ "settings.rec.latency.desc": "Toca cinco cliques e os escuta, para que os takes fiquem alinhados com a música. Faça uma vez para cada entrada e saída. Cerca de 20 segundos.",
+ "settings.rec.latency.run": "Testar latência",
+ "settings.rec.camera.title": "Câmera",
+ "settings.rec.camera.desc": "A câmera usada quando um take é filmado. A prévia abaixo mostra o enquadramento.",
+ "settings.rec.quality.title": "Qualidade da câmera",
+ "settings.rec.quality.desc": "Tamanho de captura dos takes filmados. 720p pesa menos no computador enquanto a música toca.",
+ "settings.rec.mirror.title": "Espelhar prévia",
+ "settings.rec.mirror.desc": "Mostra a prévia como um espelho. O vídeo final nunca é espelhado.",
+ "settings.rec.stopFirst": "Pare a reprodução primeiro e depois teste a latência.",
+ "rec.cal.needInputSettings": "Não foi possível abrir a entrada, então a latência não foi testada.",
+ "rec.openSettings": "Abrir configurações",
+ "rec.settingsLink": "Configurações de entrada e câmera",
+ "help.set.recInput": "A entrada de onde os takes são gravados, com um medidor de nível.",
+ "help.set.recLatency": "Mede o atraso com que a entrada ouve a música, para alinhar os takes. Uma vez por entrada.",
+ "help.set.recCamera": "A câmera dos takes filmados, a qualidade e se a prévia é espelhada.",
+ "capture.defaultDevice": "Dispositivo padrão",
+ "capture.inputN": "Entrada {n}",
+ "capture.cameraN": "Câmera {n}",
+ "capture.hint.https": "Abra {url} em vez disso.",
+ "capture.hint.linuxPlugins": "No Linux, instale os plugins pulse ou PipeWire do GStreamer (gstreamer1.0-plugins-good ou gstreamer1.0-pipewire) e reinicie o StemDeck.",
+ "capture.reason.insecure-context.audio": "O microfone precisa de uma conexão segura. Abra o StemDeck por https, ou neste computador em 127.0.0.1.",
+ "capture.reason.insecure-context.video": "A câmera precisa de uma conexão segura. Abra o StemDeck por https, ou neste computador em 127.0.0.1.",
+ "capture.reason.unsupported.audio": "Este navegador não consegue gravar de um microfone.",
+ "capture.reason.unsupported.video": "Este navegador não consegue gravar de uma câmera.",
+ "capture.reason.linux-webview.audio": "Esta versão do app para Linux não consegue acessar o microfone. Atualize o StemDeck.",
+ "capture.reason.linux-webview.video": "Esta versão do app para Linux não consegue acessar a câmera. Atualize o StemDeck.",
+ "capture.reason.denied.audio": "O acesso ao microfone foi bloqueado. Permita-o nas configurações do site no navegador e tente novamente.",
+ "capture.reason.denied.video": "O acesso à câmera foi bloqueado. Permita-o nas configurações do site no navegador e tente novamente.",
+ "capture.reason.os-blocked-macos.audio": "O macOS está bloqueando o microfone. Permita o StemDeck em Ajustes do Sistema > Privacidade e Segurança > Microfone e reinicie o StemDeck.",
+ "capture.reason.os-blocked-macos.video": "O macOS está bloqueando a câmera. Permita o StemDeck em Ajustes do Sistema > Privacidade e Segurança > Câmera e reinicie o StemDeck.",
+ "capture.reason.os-blocked-windows.audio": "O Windows está bloqueando o microfone. Ative \"Permitir que aplicativos da área de trabalho acessem seu microfone\" em Configurações > Privacidade e segurança > Microfone.",
+ "capture.reason.os-blocked-windows.video": "O Windows está bloqueando a câmera. Ative \"Permitir que aplicativos da área de trabalho acessem sua câmera\" em Configurações > Privacidade e segurança > Câmera.",
+ "capture.reason.no-device.audio": "Nenhum microfone encontrado. Conecte um e tente novamente.",
+ "capture.reason.no-device.video": "Nenhuma câmera encontrada. Conecte uma e tente novamente.",
+ "capture.reason.device-busy.audio": "O microfone está sendo usado por outro app, ou o sistema não conseguiu abri-lo.",
+ "capture.reason.device-busy.video": "A câmera está sendo usada por outro app, ou o sistema não conseguiu abri-la.",
+ "capture.reason.unknown.audio": "Não foi possível abrir o microfone.",
+ "capture.reason.unknown.video": "Não foi possível abrir a câmera.",
+ "capture.reason.rate-mismatch": "O microfone usa uma taxa de amostragem diferente da reprodução. Ajuste ambos para 48 kHz nas configurações de som do sistema e tente novamente.",
+ "rec.record": "Gravar",
+ "rec.recordTitle": "Gravar um take sobre a música",
+ "rec.stopRecording": "Parar a gravação",
+ "rec.optionsTitle": "Opções de gravação",
+ "rec.panelTitle": "Gravar um take",
+ "rec.latency.unknown": "Latência: abra uma entrada para medi-la",
+ "rec.latency.calibrated": "Latência {ms} ms, calibrada",
+ "rec.latency.estimated": "Latência de cerca de {ms} ms, estimada. Calibre para um take bem no tempo.",
+ "rec.cal.instructions": "Aumente o volume. Segure o microfone perto do alto-falante ou de um fone, ou ligue a saída da interface à entrada dela. Cinco cliques tocam e são medidos.",
+ "rec.cal.running": "Ouvindo…",
+ "rec.cal.done": "Ida e volta {ms} ms. Salvo para esta entrada.",
+ "rec.cal.tooQuiet": "Nenhum clique foi ouvido. Aumente o volume ou aproxime o microfone e tente novamente.",
+ "rec.cal.inconsistent": "Os cliques não bateram. Reduza o ruído de fundo e tente novamente.",
+ "rec.monitor": "Me ouvir pelo StemDeck",
+ "rec.monitorDelay": "Você se ouve com cerca de {ms} ms de atraso.",
+ "rec.headphonesNote": "Use fones de ouvido. Com alto-falantes a música vaza para o take, e ouvir a si mesmo pode causar microfonia.",
+ "rec.muteDuring": "Silenciar durante a gravação",
+ "rec.review.keep": "Manter",
+ "rec.review.discard": "Descartar",
+ "rec.status.opening": "Abrindo a entrada…",
+ "rec.status.recording": "Gravando…",
+ "rec.status.saving": "Salvando o take…",
+ "rec.status.tooShort": "Parado antes de a música começar, então nada foi salvo.",
+ "rec.status.failed": "Não foi possível salvar o take.",
+ "rec.status.tooMany": "Esta música já tem o número máximo de takes. Exclua um primeiro.",
+ "rec.status.tempo": "A gravação só funciona na velocidade normal. Ajuste a velocidade para 1x primeiro.",
+ "rec.status.noEngine": "Abra uma faixa concluída no estúdio para gravar sobre ela.",
+ "rec.status.deviceLost": "A entrada foi desconectada. O take foi mantido até esse ponto.",
+ "rec.status.inputLost": "A entrada foi desconectada.",
+ "rec.status.kept": "Take mantido.",
+ "rec.status.discarded": "Take descartado.",
+ "rec.take.nudge": "Ajuste fino (ms)",
+ "rec.take.nudgeEarlier": "Adiantar o take em 1 ms",
+ "rec.take.nudgeLater": "Atrasar o take em 1 ms",
+ "rec.take.nudgeHint": "Números positivos adiantam o take: use-os quando ele soar atrasado. Com Shift, passos de 10 ms.",
+ "rec.take.apply": "Aplicar",
+ "rec.take.saving": "Salvando…",
+ "rec.take.failed": "Não foi possível atualizar o take.",
+ "rec.take.delete": "Excluir take",
+ "rec.folder.open": "Mostrar takes",
+ "rec.folder.close": "Ocultar takes",
+ "rec.folder.openAria": "Mostrar os takes de {name}",
+ "rec.folder.closeAria": "Ocultar os takes de {name}",
+ "rec.folder.play": "Tocar este take",
+ "rec.folder.playing": "Tocando",
+ "rec.folder.playAria": "Tocar {take} em {name}",
+ "rec.folder.playingAria": "{take} está tocando em {name}",
+ "rec.folder.nudged": "ajuste {ms}",
+ "rec.folder.menu": "Opções do take",
+ "rec.folder.menuAria": "Opções de {take}",
+ "rec.folder.laneTitle": "{take}: clique para tocar este take",
+ "rec.take.rename": "Nome do take",
+ "rec.take.deleteConfirm": "Excluir? Pressione de novo",
"sections.kind.intro": "Introdução",
"sections.kind.outro": "Final",
"sections.kind.break": "Pausa",
@@ -5465,9 +6304,149 @@ const pt = {
"resetConfirm.resetting": "Redefinindo…",
"resetConfirm.failed": "Falha ao redefinir.",
"resetConfirm.failedConnection": "Falha ao redefinir — verifique sua conexão.",
+ "video.previewAria": "Prévia da câmera",
};
const id = {
+ // Recording a take, and the shared capture messages (static/js/capture/).
+ "rec.track.add": "Trek",
+ "rec.track.addTitle": "Tambah trek rekaman",
+ "rec.track.armAria": "Siapkan {name} untuk merekam",
+ "rec.track.armTitle": "Siapkan untuk merekam",
+ "rec.track.inputAria": "Input untuk {name}",
+ "rec.track.inputMono": "Input {n}",
+ "rec.track.inputPair": "Input {a}+{b}",
+ "rec.track.noInput": "Input tidak ditemukan",
+ "rec.input.voiceGroup": "Vokal (mikrofon)",
+ "rec.input.instrumentGroup": "Instrumen (antarmuka audio)",
+ "rec.input.voiceKind": "Vokal",
+ "rec.input.instrumentKind": "Instrumen",
+ "rec.input.missing": "{name} (tidak terhubung)",
+ "rec.input.unknownDevice": "Input",
+ "rec.input.instrumentChoice": "{name} · {input}",
+ "rec.track.menu": "Opsi trek",
+ "rec.track.menuAria": "Opsi untuk {name}",
+ "rec.track.takes": "Take",
+ "rec.track.noTakes": "Belum ada take. Siapkan trek lalu tekan Rekam.",
+ "rec.track.rename": "Nama trek",
+ "rec.track.delete": "Hapus trek",
+ "rec.track.deleteConfirm": "Hapus trek dan semua take-nya? Tekan lagi",
+ "rec.track.otherDevice": "Hanya trek pada perangkat input yang sama yang bisa merekam bersamaan: perangkat berbeda memakai jam berbeda. Trek lain dibatalkan siapnya.",
+ "rec.track.failed": "Trek tidak dapat diperbarui.",
+ "rec.track.tooMany": "Lagu ini sudah mencapai jumlah trek maksimal.",
+ "rec.status.addTrack": "Tambahkan trek rekaman untuk direkam.",
+ "rec.status.armTrack": "Siapkan trek yang akan direkam: titik merah di setiap trek.",
+ "rec.status.trackAdded": "{name} ditambahkan. Tekan Rekam untuk merekam ke trek ini.",
+ "rec.status.trackInputMissing": "Input yang dipilih untuk {name} tidak tersambung. Pilih input lain di pemilih input trek.",
+ "rec.status.someFailed": "Beberapa trek tidak dapat disimpan.",
+ "rec.review.textTrack": "{track}: {take} tersimpan. Dengarkan, lalu simpan atau buang.",
+ "rec.review.textMany": "Take baru tersimpan. Dengarkan, lalu simpan atau buang.",
+ "help.record": "Merekam ke trek yang disiapkan selama lagu diputar. Tekan lagi untuk berhenti.",
+ "help.recOptions": "Monitor, trek yang dibisukan saat merekam, kamera, dan take dengan video.",
+ "help.addTrack": "Menambahkan trek rekaman kosong di bawah stem.",
+ "help.trackArm": "Menyiapkan {name}: Rekam merekam ke setiap trek yang disiapkan.",
+ "help.trackInput": "Input yang direkam {name}: satu kanal atau pasangan stereo.",
+ "help.trackMenu": "Ganti nama {name}, atau hapus beserta semua take-nya.",
+ "help.takeFolder": "Menampilkan atau menyembunyikan take dari {name}: satu lajur per take, yang terbaru di atas.",
+ "help.takePlay": "Menjadikan {name} take yang diputar trek. Lajur trek lalu menampilkannya.",
+ "help.takeMenu": "Ganti nama {name}, geser waktunya, atau hapus.",
+ "help.takeLane": "Satu take dari trek ini. Klik untuk menjadikannya take yang diputar.",
+ "settings.tab.recording": "Rekaman",
+ "settings.rec.input.title": "Input",
+ "settings.rec.input.desc": "Mikrofon atau input interface audio tempat take direkam. Meter menunjukkan levelnya.",
+ "settings.rec.latency.title": "Tes latensi",
+ "settings.rec.latency.desc": "Memutar lima klik dan mendengarkannya, agar take sejajar dengan lagu. Jalankan sekali untuk setiap input dan output. Sekitar 20 detik.",
+ "settings.rec.latency.run": "Tes latensi",
+ "settings.rec.camera.title": "Kamera",
+ "settings.rec.camera.desc": "Kamera yang dipakai saat take direkam dengan video. Pratinjau di bawah menunjukkan bingkainya.",
+ "settings.rec.quality.title": "Kualitas kamera",
+ "settings.rec.quality.desc": "Ukuran tangkapan untuk take video. 720p lebih ringan bagi komputer saat lagu diputar.",
+ "settings.rec.mirror.title": "Cerminkan pratinjau",
+ "settings.rec.mirror.desc": "Menampilkan pratinjau seperti cermin. Video akhir tidak pernah dicerminkan.",
+ "settings.rec.stopFirst": "Hentikan pemutaran dulu, lalu tes latensinya.",
+ "rec.cal.needInputSettings": "Input tidak dapat dibuka, jadi latensi tidak dites.",
+ "rec.openSettings": "Buka Pengaturan",
+ "rec.settingsLink": "Pengaturan input dan kamera",
+ "help.set.recInput": "Input tempat take direkam, dengan meter level.",
+ "help.set.recLatency": "Mengukur seberapa terlambat input mendengar lagu, agar take sejajar. Sekali untuk setiap input.",
+ "help.set.recCamera": "Kamera untuk take video, kualitasnya, dan apakah pratinjau dicerminkan.",
+ "capture.defaultDevice": "Perangkat bawaan",
+ "capture.inputN": "Input {n}",
+ "capture.cameraN": "Kamera {n}",
+ "capture.hint.https": "Buka {url} sebagai gantinya.",
+ "capture.hint.linuxPlugins": "Di Linux, pasang plugin GStreamer pulse atau PipeWire (gstreamer1.0-plugins-good atau gstreamer1.0-pipewire), lalu mulai ulang StemDeck.",
+ "capture.reason.insecure-context.audio": "Mikrofon memerlukan koneksi aman. Buka StemDeck lewat https, atau di komputer ini di 127.0.0.1.",
+ "capture.reason.insecure-context.video": "Kamera memerlukan koneksi aman. Buka StemDeck lewat https, atau di komputer ini di 127.0.0.1.",
+ "capture.reason.unsupported.audio": "Browser ini tidak dapat merekam dari mikrofon.",
+ "capture.reason.unsupported.video": "Browser ini tidak dapat merekam dari kamera.",
+ "capture.reason.linux-webview.audio": "Versi aplikasi Linux ini tidak dapat menjangkau mikrofon. Perbarui StemDeck.",
+ "capture.reason.linux-webview.video": "Versi aplikasi Linux ini tidak dapat menjangkau kamera. Perbarui StemDeck.",
+ "capture.reason.denied.audio": "Akses mikrofon diblokir. Izinkan di pengaturan situs browser, lalu coba lagi.",
+ "capture.reason.denied.video": "Akses kamera diblokir. Izinkan di pengaturan situs browser, lalu coba lagi.",
+ "capture.reason.os-blocked-macos.audio": "macOS memblokir mikrofon. Izinkan StemDeck di Pengaturan Sistem > Privasi & Keamanan > Mikrofon, lalu mulai ulang StemDeck.",
+ "capture.reason.os-blocked-macos.video": "macOS memblokir kamera. Izinkan StemDeck di Pengaturan Sistem > Privasi & Keamanan > Kamera, lalu mulai ulang StemDeck.",
+ "capture.reason.os-blocked-windows.audio": "Windows memblokir mikrofon. Aktifkan \"Izinkan aplikasi desktop mengakses mikrofon Anda\" di Pengaturan > Privasi & keamanan > Mikrofon.",
+ "capture.reason.os-blocked-windows.video": "Windows memblokir kamera. Aktifkan \"Izinkan aplikasi desktop mengakses kamera Anda\" di Pengaturan > Privasi & keamanan > Kamera.",
+ "capture.reason.no-device.audio": "Mikrofon tidak ditemukan. Sambungkan satu, lalu coba lagi.",
+ "capture.reason.no-device.video": "Kamera tidak ditemukan. Sambungkan satu, lalu coba lagi.",
+ "capture.reason.device-busy.audio": "Mikrofon sedang dipakai aplikasi lain, atau sistem tidak dapat membukanya.",
+ "capture.reason.device-busy.video": "Kamera sedang dipakai aplikasi lain, atau sistem tidak dapat membukanya.",
+ "capture.reason.unknown.audio": "Mikrofon tidak dapat dibuka.",
+ "capture.reason.unknown.video": "Kamera tidak dapat dibuka.",
+ "capture.reason.rate-mismatch": "Mikrofon berjalan pada laju sampel yang berbeda dari pemutaran. Atur keduanya ke 48 kHz di pengaturan suara sistem, lalu coba lagi.",
+ "rec.record": "Rekam",
+ "rec.recordTitle": "Rekam take di atas lagu",
+ "rec.stopRecording": "Hentikan rekaman",
+ "rec.optionsTitle": "Opsi rekaman",
+ "rec.panelTitle": "Rekam take",
+ "rec.latency.unknown": "Latensi: buka input untuk mengukurnya",
+ "rec.latency.calibrated": "Latensi {ms} md, terkalibrasi",
+ "rec.latency.estimated": "Latensi sekitar {ms} md, perkiraan. Kalibrasi agar take pas.",
+ "rec.cal.instructions": "Naikkan volume. Dekatkan mikrofon ke speaker atau ke cup headphone, atau sambungkan output interface ke inputnya. Lima klik diputar dan diukur.",
+ "rec.cal.running": "Mendengarkan…",
+ "rec.cal.done": "Pulang-pergi {ms} md. Disimpan untuk input ini.",
+ "rec.cal.tooQuiet": "Tidak ada klik yang terdengar. Naikkan volume atau dekatkan mikrofon, lalu coba lagi.",
+ "rec.cal.inconsistent": "Klik tidak sejajar. Kurangi kebisingan latar, lalu coba lagi.",
+ "rec.monitor": "Dengar diri sendiri lewat StemDeck",
+ "rec.monitorDelay": "Anda mendengar diri sendiri terlambat sekitar {ms} md.",
+ "rec.headphonesNote": "Gunakan headphone. Dengan speaker, lagu ikut masuk ke take, dan mendengar diri sendiri bisa menimbulkan feedback.",
+ "rec.muteDuring": "Bisukan saat merekam",
+ "rec.review.keep": "Simpan",
+ "rec.review.discard": "Buang",
+ "rec.status.opening": "Membuka input…",
+ "rec.status.recording": "Merekam…",
+ "rec.status.saving": "Menyimpan take…",
+ "rec.status.tooShort": "Dihentikan sebelum lagu dimulai, jadi tidak ada yang disimpan.",
+ "rec.status.failed": "Take tidak dapat disimpan.",
+ "rec.status.tooMany": "Lagu ini sudah mencapai jumlah take maksimal. Hapus satu terlebih dahulu.",
+ "rec.status.tempo": "Rekaman hanya berfungsi pada kecepatan normal. Atur kecepatan ke 1x terlebih dahulu.",
+ "rec.status.noEngine": "Buka trek yang sudah selesai di studio untuk merekam di atasnya.",
+ "rec.status.deviceLost": "Input terputus. Take disimpan sampai titik itu.",
+ "rec.status.inputLost": "Input terputus.",
+ "rec.status.kept": "Take disimpan.",
+ "rec.status.discarded": "Take dibuang.",
+ "rec.take.nudge": "Geser (md)",
+ "rec.take.nudgeEarlier": "Majukan take 1 md",
+ "rec.take.nudgeLater": "Mundurkan take 1 md",
+ "rec.take.nudgeHint": "Angka positif memajukan take: gunakan saat take terdengar terlambat. Shift untuk langkah 10 md.",
+ "rec.take.apply": "Terapkan",
+ "rec.take.saving": "Menyimpan…",
+ "rec.take.failed": "Take tidak dapat diperbarui.",
+ "rec.take.delete": "Hapus take",
+ "rec.folder.open": "Tampilkan take",
+ "rec.folder.close": "Sembunyikan take",
+ "rec.folder.openAria": "Tampilkan take dari {name}",
+ "rec.folder.closeAria": "Sembunyikan take dari {name}",
+ "rec.folder.play": "Putar take ini",
+ "rec.folder.playing": "Diputar",
+ "rec.folder.playAria": "Putar {take} di {name}",
+ "rec.folder.playingAria": "{take} diputar di {name}",
+ "rec.folder.nudged": "geser {ms}",
+ "rec.folder.menu": "Opsi take",
+ "rec.folder.menuAria": "Opsi untuk {take}",
+ "rec.folder.laneTitle": "{take}: klik untuk memutar take ini",
+ "rec.take.rename": "Nama take",
+ "rec.take.deleteConfirm": "Hapus? Tekan lagi",
"sections.kind.intro": "Intro",
"sections.kind.outro": "Outro",
"sections.kind.break": "Jeda",
@@ -6326,9 +7305,149 @@ const id = {
"resetConfirm.resetting": "Mengatur ulang…",
"resetConfirm.failed": "Gagal mengatur ulang.",
"resetConfirm.failedConnection": "Gagal mengatur ulang — periksa koneksi Anda.",
+ "video.previewAria": "Pratinjau kamera",
};
const fr = {
+ // Recording a take, and the shared capture messages (static/js/capture/).
+ "rec.track.add": "Piste",
+ "rec.track.addTitle": "Ajouter une piste d'enregistrement",
+ "rec.track.armAria": "Armer {name} pour l'enregistrement",
+ "rec.track.armTitle": "Armer pour l'enregistrement",
+ "rec.track.inputAria": "Entrée de {name}",
+ "rec.track.inputMono": "Entrée {n}",
+ "rec.track.inputPair": "Entrée {a}+{b}",
+ "rec.track.noInput": "Aucune entrée trouvée",
+ "rec.input.voiceGroup": "Voix (micro)",
+ "rec.input.instrumentGroup": "Instrument (interface audio)",
+ "rec.input.voiceKind": "Voix",
+ "rec.input.instrumentKind": "Instrument",
+ "rec.input.missing": "{name} (non connecté)",
+ "rec.input.unknownDevice": "Entrée",
+ "rec.input.instrumentChoice": "{name} · {input}",
+ "rec.track.menu": "Options de la piste",
+ "rec.track.menuAria": "Options de {name}",
+ "rec.track.takes": "Prises",
+ "rec.track.noTakes": "Aucune prise. Armez la piste et appuyez sur Enregistrer.",
+ "rec.track.rename": "Nom de la piste",
+ "rec.track.delete": "Supprimer la piste",
+ "rec.track.deleteConfirm": "Supprimer la piste et toutes ses prises ? Appuyez encore",
+ "rec.track.otherDevice": "Seules les pistes sur le même périphérique d'entrée peuvent enregistrer ensemble : des périphériques différents ont des horloges différentes. Les autres pistes ont été désarmées.",
+ "rec.track.failed": "La piste n'a pas pu être mise à jour.",
+ "rec.track.tooMany": "Ce morceau a déjà le nombre maximal de pistes.",
+ "rec.status.addTrack": "Ajoutez une piste d'enregistrement pour enregistrer dessus.",
+ "rec.status.armTrack": "Armez les pistes à enregistrer : le point rouge sur chaque piste.",
+ "rec.status.trackAdded": "{name} ajoutée. Appuyez sur Enregistrer pour enregistrer dessus.",
+ "rec.status.trackInputMissing": "L'entrée choisie pour {name} n'est pas branchée. Choisissez-en une autre dans le sélecteur d'entrée de la piste.",
+ "rec.status.someFailed": "Certaines pistes n'ont pas pu être enregistrées.",
+ "rec.review.textTrack": "{track} : {take} est enregistrée. Écoutez-la, puis gardez-la ou supprimez-la.",
+ "rec.review.textMany": "Les nouvelles prises sont enregistrées. Écoutez-les, puis gardez-les ou supprimez-les.",
+ "help.record": "Enregistre sur les pistes armées pendant la lecture du morceau. Appuyez encore pour arrêter.",
+ "help.recOptions": "Retour, pistes coupées pendant l'enregistrement, caméra et prises avec vidéo.",
+ "help.addTrack": "Ajoute une piste d'enregistrement vide sous les stems.",
+ "help.trackArm": "Arme {name} : Enregistrer enregistre sur chaque piste armée.",
+ "help.trackInput": "L'entrée qu'enregistre {name} : un canal ou une paire stéréo.",
+ "help.trackMenu": "Renommer {name}, ou la supprimer avec toutes ses prises.",
+ "help.takeFolder": "Affiche ou masque les prises de {name} : une ligne par prise, la plus récente en haut.",
+ "help.takePlay": "Fait de {name} la prise que la piste lit. La ligne de la piste l'affiche alors.",
+ "help.takeMenu": "Renommer {name}, la décaler dans le temps ou la supprimer.",
+ "help.takeLane": "Une prise de cette piste. Un clic en fait la prise lue.",
+ "settings.tab.recording": "Enregistrement",
+ "settings.rec.input.title": "Entrée",
+ "settings.rec.input.desc": "Le micro ou l'entrée de l'interface audio d'où les prises sont enregistrées. L'indicateur montre son niveau.",
+ "settings.rec.latency.title": "Test de latence",
+ "settings.rec.latency.desc": "Joue cinq clics et les écoute, pour que les prises soient calées sur le morceau. Lancez-le une fois par entrée et sortie. Environ 20 secondes.",
+ "settings.rec.latency.run": "Tester la latence",
+ "settings.rec.camera.title": "Caméra",
+ "settings.rec.camera.desc": "La caméra utilisée quand une prise est filmée. L'aperçu ci-dessous montre le cadrage.",
+ "settings.rec.quality.title": "Qualité de la caméra",
+ "settings.rec.quality.desc": "Taille de capture des prises filmées. 720p sollicite moins l'ordinateur pendant la lecture du morceau.",
+ "settings.rec.mirror.title": "Aperçu en miroir",
+ "settings.rec.mirror.desc": "Affiche l'aperçu comme un miroir. La vidéo finale n'est jamais inversée.",
+ "settings.rec.stopFirst": "Arrêtez d'abord la lecture, puis testez la latence.",
+ "rec.cal.needInputSettings": "L'entrée n'a pas pu être ouverte, la latence n'a donc pas été testée.",
+ "rec.openSettings": "Ouvrir les paramètres",
+ "rec.settingsLink": "Paramètres d'entrée et de caméra",
+ "help.set.recInput": "L'entrée d'où les prises sont enregistrées, avec un indicateur de niveau.",
+ "help.set.recLatency": "Mesure le retard avec lequel l'entrée entend le morceau, pour caler les prises. Une fois par entrée.",
+ "help.set.recCamera": "La caméra des prises filmées, sa qualité, et si l'aperçu est en miroir.",
+ "capture.defaultDevice": "Périphérique par défaut",
+ "capture.inputN": "Entrée {n}",
+ "capture.cameraN": "Caméra {n}",
+ "capture.hint.https": "Ouvrez plutôt {url}.",
+ "capture.hint.linuxPlugins": "Sous Linux, installez les plugins GStreamer pulse ou PipeWire (gstreamer1.0-plugins-good ou gstreamer1.0-pipewire), puis redémarrez StemDeck.",
+ "capture.reason.insecure-context.audio": "Le micro a besoin d'une connexion sécurisée. Ouvrez StemDeck en https, ou sur cet ordinateur à l'adresse 127.0.0.1.",
+ "capture.reason.insecure-context.video": "La caméra a besoin d'une connexion sécurisée. Ouvrez StemDeck en https, ou sur cet ordinateur à l'adresse 127.0.0.1.",
+ "capture.reason.unsupported.audio": "Ce navigateur ne peut pas enregistrer depuis un micro.",
+ "capture.reason.unsupported.video": "Ce navigateur ne peut pas enregistrer depuis une caméra.",
+ "capture.reason.linux-webview.audio": "Cette version de l'application Linux ne peut pas accéder au micro. Mettez StemDeck à jour.",
+ "capture.reason.linux-webview.video": "Cette version de l'application Linux ne peut pas accéder à la caméra. Mettez StemDeck à jour.",
+ "capture.reason.denied.audio": "L'accès au micro a été bloqué. Autorisez-le dans les paramètres du site du navigateur, puis réessayez.",
+ "capture.reason.denied.video": "L'accès à la caméra a été bloqué. Autorisez-le dans les paramètres du site du navigateur, puis réessayez.",
+ "capture.reason.os-blocked-macos.audio": "macOS bloque le micro. Autorisez StemDeck dans Réglages Système > Confidentialité et sécurité > Microphone, puis redémarrez StemDeck.",
+ "capture.reason.os-blocked-macos.video": "macOS bloque la caméra. Autorisez StemDeck dans Réglages Système > Confidentialité et sécurité > Caméra, puis redémarrez StemDeck.",
+ "capture.reason.os-blocked-windows.audio": "Windows bloque le micro. Activez « Autoriser les applications de bureau à accéder à votre microphone » dans Paramètres > Confidentialité et sécurité > Microphone.",
+ "capture.reason.os-blocked-windows.video": "Windows bloque la caméra. Activez « Autoriser les applications de bureau à accéder à votre caméra » dans Paramètres > Confidentialité et sécurité > Caméra.",
+ "capture.reason.no-device.audio": "Aucun micro trouvé. Branchez-en un, puis réessayez.",
+ "capture.reason.no-device.video": "Aucune caméra trouvée. Branchez-en une, puis réessayez.",
+ "capture.reason.device-busy.audio": "Le micro est utilisé par une autre application, ou le système n'a pas pu l'ouvrir.",
+ "capture.reason.device-busy.video": "La caméra est utilisée par une autre application, ou le système n'a pas pu l'ouvrir.",
+ "capture.reason.unknown.audio": "Impossible d'ouvrir le micro.",
+ "capture.reason.unknown.video": "Impossible d'ouvrir la caméra.",
+ "capture.reason.rate-mismatch": "Le micro tourne à une fréquence d'échantillonnage différente de la lecture. Réglez les deux sur 48 kHz dans les paramètres son du système, puis réessayez.",
+ "rec.record": "Enregistrer",
+ "rec.recordTitle": "Enregistrer une prise sur le morceau",
+ "rec.stopRecording": "Arrêter l'enregistrement",
+ "rec.optionsTitle": "Options d'enregistrement",
+ "rec.panelTitle": "Enregistrer une prise",
+ "rec.latency.unknown": "Latence : ouvrez une entrée pour la mesurer",
+ "rec.latency.calibrated": "Latence {ms} ms, calibrée",
+ "rec.latency.estimated": "Latence d'environ {ms} ms, estimée. Calibrez pour une prise bien en place.",
+ "rec.cal.instructions": "Montez le volume. Tenez le micro près du haut-parleur ou d'un écouteur, ou reliez la sortie de l'interface à son entrée. Cinq clics sont joués et mesurés.",
+ "rec.cal.running": "Écoute…",
+ "rec.cal.done": "Aller-retour {ms} ms. Enregistré pour cette entrée.",
+ "rec.cal.tooQuiet": "Aucun clic n'a été entendu. Montez le volume ou rapprochez le micro, puis réessayez.",
+ "rec.cal.inconsistent": "Les clics ne concordaient pas. Réduisez le bruit ambiant, puis réessayez.",
+ "rec.monitor": "M'entendre dans StemDeck",
+ "rec.monitorDelay": "Vous vous entendez avec environ {ms} ms de retard.",
+ "rec.headphonesNote": "Utilisez un casque. Avec des haut-parleurs, le morceau passe dans la prise, et vous entendre peut provoquer un larsen.",
+ "rec.muteDuring": "Couper pendant l'enregistrement",
+ "rec.review.keep": "Garder",
+ "rec.review.discard": "Supprimer",
+ "rec.status.opening": "Ouverture de l'entrée…",
+ "rec.status.recording": "Enregistrement…",
+ "rec.status.saving": "Enregistrement de la prise…",
+ "rec.status.tooShort": "Arrêté avant le début du morceau, rien n'a donc été enregistré.",
+ "rec.status.failed": "La prise n'a pas pu être enregistrée.",
+ "rec.status.tooMany": "Ce morceau a déjà le nombre maximal de prises. Supprimez-en une d'abord.",
+ "rec.status.tempo": "L'enregistrement ne fonctionne qu'à vitesse normale. Réglez d'abord la vitesse sur 1x.",
+ "rec.status.noEngine": "Ouvrez un morceau terminé dans le studio pour enregistrer dessus.",
+ "rec.status.deviceLost": "L'entrée a été déconnectée. La prise est conservée jusqu'à ce moment.",
+ "rec.status.inputLost": "L'entrée a été déconnectée.",
+ "rec.status.kept": "Prise conservée.",
+ "rec.status.discarded": "Prise supprimée.",
+ "rec.take.nudge": "Décalage (ms)",
+ "rec.take.nudgeEarlier": "Avancer la prise de 1 ms",
+ "rec.take.nudgeLater": "Retarder la prise de 1 ms",
+ "rec.take.nudgeHint": "Les valeurs positives avancent la prise : utilisez-les quand elle sonne en retard. Maj pour avancer de 10 ms.",
+ "rec.take.apply": "Appliquer",
+ "rec.take.saving": "Enregistrement…",
+ "rec.take.failed": "La prise n'a pas pu être mise à jour.",
+ "rec.take.delete": "Supprimer la prise",
+ "rec.folder.open": "Afficher les prises",
+ "rec.folder.close": "Masquer les prises",
+ "rec.folder.openAria": "Afficher les prises de {name}",
+ "rec.folder.closeAria": "Masquer les prises de {name}",
+ "rec.folder.play": "Lire cette prise",
+ "rec.folder.playing": "En lecture",
+ "rec.folder.playAria": "Lire {take} sur {name}",
+ "rec.folder.playingAria": "{take} est lue sur {name}",
+ "rec.folder.nudged": "décalage {ms}",
+ "rec.folder.menu": "Options de la prise",
+ "rec.folder.menuAria": "Options de {take}",
+ "rec.folder.laneTitle": "{take} : cliquer pour lire cette prise",
+ "rec.take.rename": "Nom de la prise",
+ "rec.take.deleteConfirm": "Supprimer ? Appuyez encore",
"sections.kind.intro": "Intro",
"sections.kind.outro": "Outro",
"sections.kind.break": "Pause",
@@ -7200,12 +8319,75 @@ const fr = {
"resetConfirm.resetting": "Réinitialisation…",
"resetConfirm.failed": "Échec de la réinitialisation.",
"resetConfirm.failedConnection": "Échec de la réinitialisation — vérifiez votre connexion.",
+ "video.previewAria": "Aperçu de la caméra",
};
// European Portuguese. Overrides only: anything not listed here resolves
// through pt (see FALLBACK), so the two variants cannot drift and a key
// added to pt later is picked up here rather than reverting to English.
const ptPT = {
+ "rec.input.missing": "{name} (não ligado)",
+ "rec.track.delete": "Eliminar faixa",
+ "rec.track.deleteConfirm": "Eliminar a faixa e todos os takes dela? Prima novamente",
+ "rec.track.noTakes": "Ainda sem takes. Arme a faixa e prima Gravar.",
+ "rec.status.trackAdded": "{name} adicionada. Prima Gravar para gravar nela.",
+ "rec.status.trackInputMissing": "A entrada escolhida para {name} não está ligada. Escolha outra no seletor de entrada da faixa.",
+ "rec.status.someFailed": "Não foi possível guardar algumas faixas.",
+ "rec.review.textTrack": "{track}: {take} foi guardado. Ouça e depois mantenha ou descarte.",
+ "rec.review.textMany": "Os novos takes foram guardados. Ouça e depois mantenha ou descarte.",
+ "help.record": "Grava nas faixas armadas enquanto a música toca. Prima novamente para parar.",
+ "help.recOptions": "Retorno, faixas silenciadas durante a gravação, câmara e takes com vídeo.",
+ "help.trackMenu": "Mude o nome de {name} ou elimine a faixa com todos os takes.",
+ "help.takeFolder": "Mostra ou oculta os takes de {name}: uma linha por take, o mais recente em cima.",
+ "help.takeMenu": "Mude o nome de {name}, ajuste-o no tempo ou elimine-o.",
+ "rec.track.failed": "Não foi possível atualizar a faixa.",
+ "settings.rec.input.desc": "O microfone ou a entrada da interface de áudio a partir da qual os takes são gravados. O medidor mostra o nível.",
+ "settings.rec.latency.desc": "Toca cinco cliques e ouve-os, para que os takes fiquem alinhados com a música. Faça-o uma vez para cada entrada e saída. Cerca de 20 segundos.",
+ "settings.rec.camera.title": "Câmara",
+ "settings.rec.camera.desc": "A câmara usada quando um take é filmado. A pré-visualização abaixo mostra o enquadramento.",
+ "settings.rec.quality.title": "Qualidade da câmara",
+ "settings.rec.quality.desc": "Tamanho de captura dos takes filmados. 720p pesa menos no computador enquanto a música toca.",
+ "settings.rec.mirror.title": "Pré-visualização em espelho",
+ "settings.rec.mirror.desc": "Mostra a pré-visualização como um espelho. O vídeo final nunca é espelhado.",
+ "settings.rec.stopFirst": "Pare primeiro a reprodução e depois teste a latência.",
+ "rec.openSettings": "Abrir definições",
+ "rec.settingsLink": "Definições de entrada e câmara",
+ "help.set.recCamera": "A câmara dos takes filmados, a qualidade e se a pré-visualização é espelhada.",
+ "capture.reason.unsupported.video": "Este navegador não consegue gravar a partir de uma câmara.",
+ "capture.reason.insecure-context.video": "A câmara precisa de uma ligação segura. Abra o StemDeck por https, ou neste computador em 127.0.0.1.",
+ "capture.reason.insecure-context.audio": "O microfone precisa de uma ligação segura. Abra o StemDeck por https, ou neste computador em 127.0.0.1.",
+ "capture.cameraN": "Câmara {n}",
+ "capture.reason.linux-webview.video": "Esta versão da app para Linux não consegue aceder à câmara. Atualize o StemDeck.",
+ "capture.reason.linux-webview.audio": "Esta versão da app para Linux não consegue aceder ao microfone. Atualize o StemDeck.",
+ "capture.reason.denied.video": "O acesso à câmara foi bloqueado. Permita-o nas definições do site no navegador e tente novamente.",
+ "capture.reason.denied.audio": "O acesso ao microfone foi bloqueado. Permita-o nas definições do site no navegador e tente novamente.",
+ "capture.reason.os-blocked-macos.video": "O macOS está a bloquear a câmara. Permita o StemDeck em Definições do Sistema > Privacidade e segurança > Câmara e reinicie o StemDeck.",
+ "capture.reason.os-blocked-macos.audio": "O macOS está a bloquear o microfone. Permita o StemDeck em Definições do Sistema > Privacidade e segurança > Microfone e reinicie o StemDeck.",
+ "capture.reason.os-blocked-windows.video": "O Windows está a bloquear a câmara. Ative \"Permitir que as aplicações de ambiente de trabalho acedam à câmara\" em Definições > Privacidade e segurança > Câmara.",
+ "capture.reason.os-blocked-windows.audio": "O Windows está a bloquear o microfone. Ative \"Permitir que as aplicações de ambiente de trabalho acedam ao microfone\" em Definições > Privacidade e segurança > Microfone.",
+ "capture.reason.no-device.video": "Nenhuma câmara encontrada. Ligue uma e tente novamente.",
+ "capture.reason.no-device.audio": "Nenhum microfone encontrado. Ligue um e tente novamente.",
+ "capture.reason.device-busy.video": "A câmara está a ser usada por outra app, ou o sistema não a conseguiu abrir.",
+ "capture.reason.device-busy.audio": "O microfone está a ser usado por outra app, ou o sistema não o conseguiu abrir.",
+ "capture.reason.unknown.video": "Não foi possível abrir a câmara.",
+ "rec.cal.running": "A ouvir…",
+ "rec.status.opening": "A abrir a entrada…",
+ "rec.status.recording": "A gravar…",
+ "rec.status.saving": "A guardar o take…",
+ "rec.take.saving": "A guardar…",
+ "rec.headphonesNote": "Use auscultadores. Com colunas a música passa para o take, e ouvir-se a si próprio pode causar microfonia.",
+ "rec.cal.instructions": "Aumente o volume. Segure o microfone perto da coluna ou de um auscultador, ou ligue a saída da interface à entrada dela. Tocam cinco cliques que são medidos.",
+ "rec.monitor": "Ouvir-me através do StemDeck",
+ "rec.cal.done": "Ida e volta {ms} ms. Guardado para esta entrada.",
+ "rec.status.failed": "Não foi possível guardar o take.",
+ "rec.status.tooShort": "Parado antes de a música começar, por isso nada foi guardado.",
+ "rec.take.deleteConfirm": "Eliminar? Prima novamente",
+ "rec.take.delete": "Eliminar take",
+ "rec.folder.playing": "A tocar",
+ "rec.folder.playingAria": "{take} está a tocar em {name}",
+ "rec.status.tooMany": "Esta música já tem o número máximo de takes. Elimine um primeiro.",
+ "capture.reason.rate-mismatch": "O microfone usa uma taxa de amostragem diferente da reprodução. Defina ambos para 48 kHz nas definições de som do sistema e tente novamente.",
+ "rec.take.nudgeHint": "Números positivos adiantam o take: use-os quando soar atrasado. Com Shift, passos de 10 ms.",
"speed.slowTitle": "Velocidade lenta para praticar. Desloque a roda do rato sobre ela ou prima as setas para cima e para baixo para a ajustar entre 0.50x e 0.99x.",
"key.mode.harmonicMinor": "menor harmónica",
"lyrics.align.hint": "Coloque o cursor de reprodução onde a primeira linha é cantada e prima Começar a letra aqui. Para usar outra linha, clique nela primeiro e depois leve o cursor até onde é cantada.",
@@ -7372,9 +8554,149 @@ const ptPT = {
"friends.role.beltr": "Software de karaoke local, sem subscrição",
"friends.role.seratone": "Sistema de karaoke para TV",
"friends.role.slashcam": "Media sobre câmara, vídeo e pós-produção",
+ "video.previewAria": "Pré-visualização da câmara",
};
const es = {
+ // Recording a take, and the shared capture messages (static/js/capture/).
+ "rec.track.add": "Pista",
+ "rec.track.addTitle": "Añadir pista de grabación",
+ "rec.track.armAria": "Armar {name} para grabar",
+ "rec.track.armTitle": "Armar para grabar",
+ "rec.track.inputAria": "Entrada de {name}",
+ "rec.track.inputMono": "Entrada {n}",
+ "rec.track.inputPair": "Entrada {a}+{b}",
+ "rec.track.noInput": "No se encontró ninguna entrada",
+ "rec.input.voiceGroup": "Voz (micrófono)",
+ "rec.input.instrumentGroup": "Instrumento (interfaz de audio)",
+ "rec.input.voiceKind": "Voz",
+ "rec.input.instrumentKind": "Instrumento",
+ "rec.input.missing": "{name} (no conectado)",
+ "rec.input.unknownDevice": "Entrada",
+ "rec.input.instrumentChoice": "{name} · {input}",
+ "rec.track.menu": "Opciones de la pista",
+ "rec.track.menuAria": "Opciones de {name}",
+ "rec.track.takes": "Tomas",
+ "rec.track.noTakes": "Aún no hay tomas. Arma la pista y pulsa Grabar.",
+ "rec.track.rename": "Nombre de la pista",
+ "rec.track.delete": "Eliminar pista",
+ "rec.track.deleteConfirm": "¿Eliminar la pista y todas sus tomas? Pulsa otra vez",
+ "rec.track.otherDevice": "Solo las pistas del mismo dispositivo de entrada pueden grabar juntas: los dispositivos distintos tienen relojes distintos. Se desarmaron las demás pistas.",
+ "rec.track.failed": "No se pudo actualizar la pista.",
+ "rec.track.tooMany": "Esta canción ya tiene el máximo de pistas.",
+ "rec.status.addTrack": "Añade una pista de grabación para grabar en ella.",
+ "rec.status.armTrack": "Arma las pistas en las que quieres grabar: el punto rojo de cada pista.",
+ "rec.status.trackAdded": "Se añadió {name}. Pulsa Grabar para grabar en ella.",
+ "rec.status.trackInputMissing": "La entrada elegida para {name} no está conectada. Elige otra en el selector de entrada de la pista.",
+ "rec.status.someFailed": "No se pudieron guardar algunas pistas.",
+ "rec.review.textTrack": "{track}: {take} se guardó. Escúchala y luego consérvala o descártala.",
+ "rec.review.textMany": "Las nuevas tomas se guardaron. Escúchalas y luego consérvalas o descártalas.",
+ "help.record": "Graba en las pistas armadas mientras suena la canción. Pulsa otra vez para detener.",
+ "help.recOptions": "Escucha, pistas silenciadas al grabar, cámara y tomas con video.",
+ "help.addTrack": "Añade una pista de grabación vacía debajo de los stems.",
+ "help.trackArm": "Arma {name}: Grabar graba en cada pista armada.",
+ "help.trackInput": "Desde qué entrada graba {name}: un canal o un par estéreo.",
+ "help.trackMenu": "Renombrar {name} o eliminarla con todas sus tomas.",
+ "help.takeFolder": "Muestra u oculta las tomas de {name}: una fila por toma, la más reciente arriba.",
+ "help.takePlay": "Hace de {name} la toma que suena en la pista. La fila de la pista la muestra entonces.",
+ "help.takeMenu": "Renombrar {name}, ajustarla en el tiempo o eliminarla.",
+ "help.takeLane": "Una toma de esta pista. Haz clic para que sea la toma que suena.",
+ "settings.tab.recording": "Grabación",
+ "settings.rec.input.title": "Entrada",
+ "settings.rec.input.desc": "El micrófono o la entrada de la interfaz de audio desde la que se graban las tomas. El medidor muestra su nivel.",
+ "settings.rec.latency.title": "Prueba de latencia",
+ "settings.rec.latency.desc": "Reproduce cinco clics y los escucha, para que las tomas cuadren con la canción. Hazla una vez por cada entrada y salida. Unos 20 segundos.",
+ "settings.rec.latency.run": "Probar latencia",
+ "settings.rec.camera.title": "Cámara",
+ "settings.rec.camera.desc": "La cámara que se usa cuando se filma una toma. La vista previa de abajo muestra el encuadre.",
+ "settings.rec.quality.title": "Calidad de la cámara",
+ "settings.rec.quality.desc": "Tamaño de captura de las tomas filmadas. 720p carga menos el equipo mientras suena la canción.",
+ "settings.rec.mirror.title": "Vista previa en espejo",
+ "settings.rec.mirror.desc": "Muestra la vista previa como un espejo. El video final nunca se invierte.",
+ "settings.rec.stopFirst": "Detén primero la reproducción y luego prueba la latencia.",
+ "rec.cal.needInputSettings": "No se pudo abrir la entrada, así que no se probó la latencia.",
+ "rec.openSettings": "Abrir ajustes",
+ "rec.settingsLink": "Ajustes de entrada y cámara",
+ "help.set.recInput": "La entrada desde la que se graban las tomas, con un medidor de nivel.",
+ "help.set.recLatency": "Mide con cuánto retraso la entrada oye la canción, para cuadrar las tomas. Una vez por entrada.",
+ "help.set.recCamera": "La cámara de las tomas filmadas, su calidad y si la vista previa va en espejo.",
+ "capture.defaultDevice": "Dispositivo predeterminado",
+ "capture.inputN": "Entrada {n}",
+ "capture.cameraN": "Cámara {n}",
+ "capture.hint.https": "Abre {url} en su lugar.",
+ "capture.hint.linuxPlugins": "En Linux, instala los plugins pulse o PipeWire de GStreamer (gstreamer1.0-plugins-good o gstreamer1.0-pipewire) y reinicia StemDeck.",
+ "capture.reason.insecure-context.audio": "El micrófono necesita una conexión segura. Abre StemDeck por https, o en este equipo en 127.0.0.1.",
+ "capture.reason.insecure-context.video": "La cámara necesita una conexión segura. Abre StemDeck por https, o en este equipo en 127.0.0.1.",
+ "capture.reason.unsupported.audio": "Este navegador no puede grabar desde un micrófono.",
+ "capture.reason.unsupported.video": "Este navegador no puede grabar desde una cámara.",
+ "capture.reason.linux-webview.audio": "Esta versión de la app para Linux no puede acceder al micrófono. Actualiza StemDeck.",
+ "capture.reason.linux-webview.video": "Esta versión de la app para Linux no puede acceder a la cámara. Actualiza StemDeck.",
+ "capture.reason.denied.audio": "Se bloqueó el acceso al micrófono. Permítelo en la configuración del sitio del navegador y vuelve a intentarlo.",
+ "capture.reason.denied.video": "Se bloqueó el acceso a la cámara. Permítelo en la configuración del sitio del navegador y vuelve a intentarlo.",
+ "capture.reason.os-blocked-macos.audio": "macOS está bloqueando el micrófono. Permite StemDeck en Ajustes del Sistema > Privacidad y seguridad > Micrófono y reinicia StemDeck.",
+ "capture.reason.os-blocked-macos.video": "macOS está bloqueando la cámara. Permite StemDeck en Ajustes del Sistema > Privacidad y seguridad > Cámara y reinicia StemDeck.",
+ "capture.reason.os-blocked-windows.audio": "Windows está bloqueando el micrófono. Activa \"Permitir que las aplicaciones de escritorio accedan al micrófono\" en Configuración > Privacidad y seguridad > Micrófono.",
+ "capture.reason.os-blocked-windows.video": "Windows está bloqueando la cámara. Activa \"Permitir que las aplicaciones de escritorio accedan a la cámara\" en Configuración > Privacidad y seguridad > Cámara.",
+ "capture.reason.no-device.audio": "No se encontró ningún micrófono. Conecta uno y vuelve a intentarlo.",
+ "capture.reason.no-device.video": "No se encontró ninguna cámara. Conecta una y vuelve a intentarlo.",
+ "capture.reason.device-busy.audio": "El micrófono lo está usando otra app, o el sistema no pudo abrirlo.",
+ "capture.reason.device-busy.video": "La cámara la está usando otra app, o el sistema no pudo abrirla.",
+ "capture.reason.unknown.audio": "No se pudo abrir el micrófono.",
+ "capture.reason.unknown.video": "No se pudo abrir la cámara.",
+ "capture.reason.rate-mismatch": "El micrófono funciona a una frecuencia de muestreo distinta de la reproducción. Ajusta ambos a 48 kHz en la configuración de sonido del sistema y vuelve a intentarlo.",
+ "rec.record": "Grabar",
+ "rec.recordTitle": "Grabar una toma sobre la canción",
+ "rec.stopRecording": "Detener la grabación",
+ "rec.optionsTitle": "Opciones de grabación",
+ "rec.panelTitle": "Grabar una toma",
+ "rec.latency.unknown": "Latencia: abre una entrada para medirla",
+ "rec.latency.calibrated": "Latencia {ms} ms, calibrada",
+ "rec.latency.estimated": "Latencia de unos {ms} ms, estimada. Calibra para una toma ajustada.",
+ "rec.cal.instructions": "Sube el volumen. Acerca el micrófono al altavoz o a un auricular, o conecta la salida de la interfaz a su entrada. Suenan cinco clics que se miden.",
+ "rec.cal.running": "Escuchando…",
+ "rec.cal.done": "Ida y vuelta {ms} ms. Guardado para esta entrada.",
+ "rec.cal.tooQuiet": "No se oyó ningún clic. Sube el volumen o acerca el micrófono y vuelve a intentarlo.",
+ "rec.cal.inconsistent": "Los clics no coincidieron. Reduce el ruido de fondo y vuelve a intentarlo.",
+ "rec.monitor": "Escucharme a través de StemDeck",
+ "rec.monitorDelay": "Te oyes con unos {ms} ms de retraso.",
+ "rec.headphonesNote": "Usa auriculares. Con altavoces la canción se cuela en la toma, y escucharte puede acoplarse.",
+ "rec.muteDuring": "Silenciar mientras grabas",
+ "rec.review.keep": "Conservar",
+ "rec.review.discard": "Descartar",
+ "rec.status.opening": "Abriendo la entrada…",
+ "rec.status.recording": "Grabando…",
+ "rec.status.saving": "Guardando la toma…",
+ "rec.status.tooShort": "Se detuvo antes de que empezara la canción, así que no se guardó nada.",
+ "rec.status.failed": "No se pudo guardar la toma.",
+ "rec.status.tooMany": "Esta canción ya tiene el máximo de tomas. Elimina una primero.",
+ "rec.status.tempo": "La grabación solo funciona a velocidad normal. Ajusta primero la velocidad a 1x.",
+ "rec.status.noEngine": "Abre una pista terminada en el estudio para grabar sobre ella.",
+ "rec.status.deviceLost": "La entrada se desconectó. La toma se conserva hasta ese momento.",
+ "rec.status.inputLost": "La entrada se desconectó.",
+ "rec.status.kept": "Toma conservada.",
+ "rec.status.discarded": "Toma descartada.",
+ "rec.take.nudge": "Ajuste (ms)",
+ "rec.take.nudgeEarlier": "Adelantar la toma 1 ms",
+ "rec.take.nudgeLater": "Retrasar la toma 1 ms",
+ "rec.take.nudgeHint": "Los números positivos adelantan la toma: úsalos cuando suene tarde. Con Mayús, pasos de 10 ms.",
+ "rec.take.apply": "Aplicar",
+ "rec.take.saving": "Guardando…",
+ "rec.take.failed": "No se pudo actualizar la toma.",
+ "rec.take.delete": "Eliminar toma",
+ "rec.folder.open": "Mostrar tomas",
+ "rec.folder.close": "Ocultar tomas",
+ "rec.folder.openAria": "Mostrar las tomas de {name}",
+ "rec.folder.closeAria": "Ocultar las tomas de {name}",
+ "rec.folder.play": "Reproducir esta toma",
+ "rec.folder.playing": "Sonando",
+ "rec.folder.playAria": "Reproducir {take} en {name}",
+ "rec.folder.playingAria": "{take} suena en {name}",
+ "rec.folder.nudged": "ajuste {ms}",
+ "rec.folder.menu": "Opciones de la toma",
+ "rec.folder.menuAria": "Opciones de {take}",
+ "rec.folder.laneTitle": "{take}: haz clic para reproducir esta toma",
+ "rec.take.rename": "Nombre de la toma",
+ "rec.take.deleteConfirm": "¿Eliminar? Pulsa otra vez",
"sections.kind.intro": "Intro",
"sections.kind.outro": "Outro",
"sections.kind.break": "Pausa",
@@ -8258,6 +9580,7 @@ const es = {
"resetConfirm.resetting": "Restableciendo…",
"resetConfirm.failed": "No se pudo restablecer.",
"resetConfirm.failedConnection": "No se pudo restablecer — revisa tu conexión.",
+ "video.previewAria": "Vista previa de la cámara",
};
// Korean. Like ja, zhHans and id, Korean does not inflect nouns for
@@ -8269,6 +9592,145 @@ const es = {
// 루프) stay loanwords; translating those would read as odd to anyone who
// has opened a DAW.
const ko = {
+ // Recording a take, and the shared capture messages (static/js/capture/).
+ "rec.track.add": "트랙",
+ "rec.track.addTitle": "녹음 트랙 추가",
+ "rec.track.armAria": "{name} 녹음 대기",
+ "rec.track.armTitle": "녹음 대기",
+ "rec.track.inputAria": "{name} 입력",
+ "rec.track.inputMono": "입력 {n}",
+ "rec.track.inputPair": "입력 {a}+{b}",
+ "rec.track.noInput": "입력을 찾을 수 없어요",
+ "rec.input.voiceGroup": "보컬 (마이크)",
+ "rec.input.instrumentGroup": "악기 (오디오 인터페이스)",
+ "rec.input.voiceKind": "보컬",
+ "rec.input.instrumentKind": "악기",
+ "rec.input.missing": "{name} (연결 안 됨)",
+ "rec.input.unknownDevice": "입력",
+ "rec.input.instrumentChoice": "{name} · {input}",
+ "rec.track.menu": "트랙 옵션",
+ "rec.track.menuAria": "{name} 옵션",
+ "rec.track.takes": "테이크",
+ "rec.track.noTakes": "아직 테이크가 없어요. 트랙을 녹음 대기로 두고 녹음을 누르세요.",
+ "rec.track.rename": "트랙 이름",
+ "rec.track.delete": "트랙 삭제",
+ "rec.track.deleteConfirm": "트랙과 모든 테이크를 삭제할까요? 한 번 더 누르세요",
+ "rec.track.otherDevice": "같은 입력 장치의 트랙만 함께 녹음할 수 있어요. 장치가 다르면 클럭도 달라요. 다른 트랙의 녹음 대기를 해제했어요.",
+ "rec.track.failed": "트랙을 업데이트할 수 없어요.",
+ "rec.track.tooMany": "이 곡은 이미 트랙 수가 최대예요.",
+ "rec.status.addTrack": "녹음할 녹음 트랙을 추가하세요.",
+ "rec.status.armTrack": "녹음할 트랙을 녹음 대기로 두세요: 각 트랙의 빨간 점이에요.",
+ "rec.status.trackAdded": "{name}을(를) 추가했어요. 녹음을 누르면 이 트랙에 녹음해요.",
+ "rec.status.trackInputMissing": "{name}에 선택한 입력이 연결되어 있지 않아요. 트랙의 입력 선택기에서 다른 입력을 고르세요.",
+ "rec.status.someFailed": "일부 트랙을 저장할 수 없어요.",
+ "rec.review.textTrack": "{track}: {take}을(를) 저장했어요. 들어 본 뒤 유지하거나 삭제하세요.",
+ "rec.review.textMany": "새 테이크를 저장했어요. 들어 본 뒤 유지하거나 삭제하세요.",
+ "help.record": "곡이 재생되는 동안 녹음 대기 트랙에 녹음해요. 다시 누르면 멈춰요.",
+ "help.recOptions": "모니터링, 녹음 중 음소거할 트랙, 카메라, 영상이 있는 테이크예요.",
+ "help.addTrack": "스템 아래에 빈 녹음 트랙을 추가해요.",
+ "help.trackArm": "{name}을(를) 녹음 대기로 둬요. 녹음을 누르면 녹음 대기인 모든 트랙에 녹음해요.",
+ "help.trackInput": "{name}이(가) 녹음할 입력이에요: 한 채널 또는 스테레오 쌍.",
+ "help.trackMenu": "{name}의 이름을 바꾸거나, 모든 테이크와 함께 삭제해요.",
+ "help.takeFolder": "{name}의 테이크를 보이거나 숨겨요. 테이크마다 한 줄이고, 최신 테이크가 위에 있어요.",
+ "help.takePlay": "{name}을(를) 트랙이 재생할 테이크로 정해요. 트랙의 줄에 이 테이크가 보여요.",
+ "help.takeMenu": "{name}의 이름을 바꾸거나, 타이밍을 조정하거나, 삭제해요.",
+ "help.takeLane": "이 트랙의 테이크 하나예요. 클릭하면 이 테이크를 재생해요.",
+ "settings.tab.recording": "녹음",
+ "settings.rec.input.title": "입력",
+ "settings.rec.input.desc": "테이크를 녹음할 마이크 또는 오디오 인터페이스 입력이에요. 미터에 레벨이 표시돼요.",
+ "settings.rec.latency.title": "지연 시간 테스트",
+ "settings.rec.latency.desc": "클릭 다섯 번을 재생하고 들어서 테이크가 곡에 맞도록 해요. 입력과 출력 조합마다 한 번 실행하세요. 약 20초 걸려요.",
+ "settings.rec.latency.run": "지연 시간 테스트",
+ "settings.rec.camera.title": "카메라",
+ "settings.rec.camera.desc": "테이크를 촬영할 때 쓰는 카메라예요. 아래 미리 보기에서 구도를 확인할 수 있어요.",
+ "settings.rec.quality.title": "카메라 화질",
+ "settings.rec.quality.desc": "촬영 테이크의 캡처 크기예요. 720p는 곡을 재생하는 동안 컴퓨터 부담이 적어요.",
+ "settings.rec.mirror.title": "미리 보기 좌우 반전",
+ "settings.rec.mirror.desc": "미리 보기를 거울처럼 보여 줘요. 완성된 동영상은 반전되지 않아요.",
+ "settings.rec.stopFirst": "먼저 재생을 멈춘 뒤 지연 시간을 테스트하세요.",
+ "rec.cal.needInputSettings": "입력을 열 수 없어서 지연 시간을 테스트하지 못했어요.",
+ "rec.openSettings": "설정 열기",
+ "rec.settingsLink": "입력 및 카메라 설정",
+ "help.set.recInput": "테이크를 녹음할 입력과 레벨 미터예요.",
+ "help.set.recLatency": "입력이 곡을 얼마나 늦게 듣는지 측정해 테이크를 맞춰요. 입력마다 한 번 실행하세요.",
+ "help.set.recCamera": "촬영 테이크용 카메라, 화질, 미리 보기 반전 여부예요.",
+ "capture.defaultDevice": "기본 장치",
+ "capture.inputN": "입력 {n}",
+ "capture.cameraN": "카메라 {n}",
+ "capture.hint.https": "대신 {url}을(를) 여세요.",
+ "capture.hint.linuxPlugins": "Linux에서는 GStreamer pulse 또는 PipeWire 플러그인(gstreamer1.0-plugins-good 또는 gstreamer1.0-pipewire)을 설치한 뒤 StemDeck을 다시 시작하세요.",
+ "capture.reason.insecure-context.audio": "마이크를 사용하려면 보안 연결이 필요합니다. StemDeck을 https로 열거나 이 컴퓨터에서 127.0.0.1로 여세요.",
+ "capture.reason.insecure-context.video": "카메라를 사용하려면 보안 연결이 필요합니다. StemDeck을 https로 열거나 이 컴퓨터에서 127.0.0.1로 여세요.",
+ "capture.reason.unsupported.audio": "이 브라우저는 마이크로 녹음할 수 없습니다.",
+ "capture.reason.unsupported.video": "이 브라우저는 카메라로 녹화할 수 없습니다.",
+ "capture.reason.linux-webview.audio": "이 버전의 Linux 앱은 마이크에 접근할 수 없습니다. StemDeck을 업데이트하세요.",
+ "capture.reason.linux-webview.video": "이 버전의 Linux 앱은 카메라에 접근할 수 없습니다. StemDeck을 업데이트하세요.",
+ "capture.reason.denied.audio": "마이크 접근이 차단되었습니다. 브라우저의 사이트 설정에서 허용한 뒤 다시 시도하세요.",
+ "capture.reason.denied.video": "카메라 접근이 차단되었습니다. 브라우저의 사이트 설정에서 허용한 뒤 다시 시도하세요.",
+ "capture.reason.os-blocked-macos.audio": "macOS가 마이크를 차단하고 있습니다. 시스템 설정 > 개인정보 보호 및 보안 > 마이크에서 StemDeck을 허용한 뒤 StemDeck을 다시 시작하세요.",
+ "capture.reason.os-blocked-macos.video": "macOS가 카메라를 차단하고 있습니다. 시스템 설정 > 개인정보 보호 및 보안 > 카메라에서 StemDeck을 허용한 뒤 StemDeck을 다시 시작하세요.",
+ "capture.reason.os-blocked-windows.audio": "Windows가 마이크를 차단하고 있습니다. 설정 > 개인 정보 및 보안 > 마이크에서 \"데스크톱 앱이 마이크에 액세스하도록 허용\"을 켜세요.",
+ "capture.reason.os-blocked-windows.video": "Windows가 카메라를 차단하고 있습니다. 설정 > 개인 정보 및 보안 > 카메라에서 \"데스크톱 앱이 카메라에 액세스하도록 허용\"을 켜세요.",
+ "capture.reason.no-device.audio": "마이크를 찾을 수 없습니다. 연결한 뒤 다시 시도하세요.",
+ "capture.reason.no-device.video": "카메라를 찾을 수 없습니다. 연결한 뒤 다시 시도하세요.",
+ "capture.reason.device-busy.audio": "다른 앱이 마이크를 사용 중이거나 시스템이 마이크를 열지 못했습니다.",
+ "capture.reason.device-busy.video": "다른 앱이 카메라를 사용 중이거나 시스템이 카메라를 열지 못했습니다.",
+ "capture.reason.unknown.audio": "마이크를 열 수 없습니다.",
+ "capture.reason.unknown.video": "카메라를 열 수 없습니다.",
+ "capture.reason.rate-mismatch": "마이크의 샘플 레이트가 재생과 다릅니다. 시스템 사운드 설정에서 둘 다 48 kHz로 맞춘 뒤 다시 시도하세요.",
+ "rec.record": "녹음",
+ "rec.recordTitle": "곡에 맞춰 테이크 녹음",
+ "rec.stopRecording": "녹음 중지",
+ "rec.optionsTitle": "녹음 옵션",
+ "rec.panelTitle": "테이크 녹음",
+ "rec.latency.unknown": "지연 시간: 입력을 열면 측정합니다",
+ "rec.latency.calibrated": "지연 시간 {ms} ms, 보정됨",
+ "rec.latency.estimated": "지연 시간 약 {ms} ms, 추정값. 정확한 테이크를 위해 보정하세요.",
+ "rec.cal.instructions": "볼륨을 올리세요. 마이크를 스피커나 헤드폰 컵 가까이에 대거나, 오디오 인터페이스의 출력을 입력에 연결하세요. 클릭 다섯 번이 재생되고 측정됩니다.",
+ "rec.cal.running": "듣는 중…",
+ "rec.cal.done": "왕복 {ms} ms. 이 입력에 저장했습니다.",
+ "rec.cal.tooQuiet": "클릭이 들리지 않았습니다. 볼륨을 올리거나 마이크를 더 가까이 둔 뒤 다시 시도하세요.",
+ "rec.cal.inconsistent": "클릭이 일치하지 않았습니다. 주변 소음을 줄인 뒤 다시 시도하세요.",
+ "rec.monitor": "StemDeck으로 내 소리 듣기",
+ "rec.monitorDelay": "내 소리가 약 {ms} ms 늦게 들립니다.",
+ "rec.headphonesNote": "헤드폰을 사용하세요. 스피커를 쓰면 곡이 테이크에 섞이고, 내 소리를 들으면 하울링이 생길 수 있습니다.",
+ "rec.muteDuring": "녹음 중 음소거",
+ "rec.review.keep": "유지",
+ "rec.review.discard": "삭제",
+ "rec.status.opening": "입력을 여는 중…",
+ "rec.status.recording": "녹음 중…",
+ "rec.status.saving": "테이크 저장 중…",
+ "rec.status.tooShort": "곡이 시작되기 전에 멈춰서 아무것도 저장되지 않았습니다.",
+ "rec.status.failed": "테이크를 저장할 수 없습니다.",
+ "rec.status.tooMany": "이 곡은 이미 테이크 수가 최대입니다. 먼저 하나를 삭제하세요.",
+ "rec.status.tempo": "녹음은 기본 속도에서만 됩니다. 먼저 속도를 1x로 맞추세요.",
+ "rec.status.noEngine": "녹음하려면 완료된 트랙을 스튜디오에서 여세요.",
+ "rec.status.deviceLost": "입력 연결이 끊겼습니다. 테이크는 그 시점까지 유지됩니다.",
+ "rec.status.inputLost": "입력 연결이 끊겼습니다.",
+ "rec.status.kept": "테이크를 유지했습니다.",
+ "rec.status.discarded": "테이크를 삭제했습니다.",
+ "rec.take.nudge": "미세 조정 (ms)",
+ "rec.take.nudgeEarlier": "테이크를 1 ms 앞당기기",
+ "rec.take.nudgeLater": "테이크를 1 ms 늦추기",
+ "rec.take.nudgeHint": "양수는 테이크를 앞당깁니다. 늦게 들릴 때 사용하세요. Shift를 누르면 10 ms씩 움직입니다.",
+ "rec.take.apply": "적용",
+ "rec.take.saving": "저장 중…",
+ "rec.take.failed": "테이크를 업데이트할 수 없습니다.",
+ "rec.take.delete": "테이크 삭제",
+ "rec.folder.open": "테이크 보기",
+ "rec.folder.close": "테이크 숨기기",
+ "rec.folder.openAria": "{name}의 테이크 보기",
+ "rec.folder.closeAria": "{name}의 테이크 숨기기",
+ "rec.folder.play": "이 테이크 재생",
+ "rec.folder.playing": "재생 중",
+ "rec.folder.playAria": "{name}에서 {take} 재생",
+ "rec.folder.playingAria": "{name}에서 {take} 재생 중",
+ "rec.folder.nudged": "미세 조정 {ms}",
+ "rec.folder.menu": "테이크 옵션",
+ "rec.folder.menuAria": "{take} 옵션",
+ "rec.folder.laneTitle": "{take}: 클릭하면 이 테이크를 재생해요",
+ "rec.take.rename": "테이크 이름",
+ "rec.take.deleteConfirm": "삭제할까요? 한 번 더 누르세요",
"sections.kind.intro": "인트로",
"sections.kind.outro": "아웃트로",
@@ -9128,6 +10590,7 @@ const ko = {
"resetConfirm.resetting": "초기화하는 중…",
"resetConfirm.failed": "초기화하지 못했어요.",
"resetConfirm.failedConnection": "초기화하지 못했어요. 연결을 확인해 주세요.",
+ "video.previewAria": "카메라 미리보기",
};
export const TRANSLATIONS = { en, pl, ja, ko, "zh-Hans": zhHans, de, fr, es, pt, "pt-PT": ptPT, id };
diff --git a/static/js/mixer.js b/static/js/mixer.js
index 10b84314..ed05a9a1 100644
--- a/static/js/mixer.js
+++ b/static/js/mixer.js
@@ -1,5 +1,5 @@
import {
- STEM_NAMES, TRACK_NAMES, EXTRA_STEM_NAMES, STEM_COLORS, STEM_DISPLAY, LANE_VOLUME_MAX,
+ STEM_NAMES, TRACK_NAMES, EXTRA_STEM_NAMES, TAKE_NAMES, STEM_COLORS, STEM_DISPLAY, LANE_VOLUME_MAX,
} from "./constants.js";
// Every lane name this session might ever need mixer state for, including
@@ -10,7 +10,8 @@ import {
// syncStemNamesFromAPI() resolves -- a const array here would freeze the
// fallback values from before that happens.
function allTrackNames() {
- return [...TRACK_NAMES, ...EXTRA_STEM_NAMES];
+ // TAKE_NAMES: the open job's recorded takes, which mix like any stem.
+ return [...TRACK_NAMES, ...EXTRA_STEM_NAMES, ...TAKE_NAMES];
}
import {
mixerState, mixerEl, stemListEl, currentJobId, multitrack, trackIndex,
@@ -471,7 +472,7 @@ const PREVIEW_FONT = '700 30px system-ui, -apple-system, "Segoe UI", sans-serif'
export function laneDragIcon(stemName) {
try {
- const label = t(`stem.${stemName}`) || stemName;
+ const label = STEM_DISPLAY[stemName] || stemName;
const canvas = document.createElement("canvas");
let ctx = canvas.getContext("2d");
ctx.font = PREVIEW_FONT;
diff --git a/static/js/player.js b/static/js/player.js
index e1c1c9db..ed759f32 100644
--- a/static/js/player.js
+++ b/static/js/player.js
@@ -8,8 +8,13 @@ import { showPlaybackError, clearPlaybackError, resolvePlaybackSuccess } from ".
import {
STEM_NAMES, TRACK_NAMES, EXTRA_STEM_NAMES, STEM_COLORS, PROGRESS_COLOR,
LOOP_DEFAULT_START_FRAC, LOOP_DEFAULT_END_FRAC, LANE_VOLUME_MAX,
- effectiveStemOrder,
+ effectiveStemOrder, STEM_DISPLAY, isTakeName,
} from "./constants.js";
+import {
+ syncTakeLanes, withTrackPeaks, setTakeFoldersEnabled, takeFolderLanes, takeFolderRowCount,
+ syncTakeFolderOrder, selectTake,
+} from "./capture/takeLanes.js";
+import { laneOrder, takeLaneOrder } from "./capture/takeFolderModel.js";
import {
mixerEl, multitrackContainer, bpmChip, keyChip, stemsChip, timeEl,
titleEl, npThumb, rulerTime, wavesGrid, playBtn,
@@ -95,33 +100,36 @@ const _STEM_ROW_SELECTORS = [
".presence-labels span",
];
+/**
+ * The lanes a loaded job shows, in order: the stems it actually has, in
+ * STEM_NAMES order with "vocals" swapped for lead_vocals + backing_vocals once
+ * the on-demand split (#275) produced both, then its recorded takes.
+ *
+ * Stems that were not extracted get no lane at all, so the ones that were sit
+ * together at the top. They used to keep a greyed, empty row each, which left
+ * a two-stem song spread over six lanes. "original" is not in this list; it
+ * goes first when present.
+ */
+function visibleStemOrder(presentNames) {
+ return effectiveStemOrder(presentNames).filter((name) => presentNames.has(name));
+}
+
function applyStemSelectionFilter(presentNames) {
- // The order for THIS job: STEM_NAMES, with "vocals" swapped for
- // lead_vocals + backing_vocals when the on-demand split (#275) produced
- // both. Rows for a name outside `order` (the vocals-family row not in
- // play for this job) are hidden outright, not just grayed "unavailable" --
- // unlike the base 6, lead_vocals/backing_vocals aren't part of every job's
- // contract, so there's no "not selected this time" state for them to be in.
- const order = effectiveStemOrder(presentNames);
+ // The order for THIS job (see visibleStemOrder). Every row outside it is
+ // hidden: a stem that was not extracted, and whichever vocals-family row
+ // the split state does not call for (vocals.wav is never deleted by the
+ // split, so "present" alone cannot decide that one).
+ const order = visibleStemOrder(presentNames);
const isVocalFamily = (s) => s === "vocals" || s === "lead_vocals" || s === "backing_vocals";
+ const shown = (stem) => (stem === "original" ? presentNames.has(stem) : order.includes(stem));
- // Waveform rows: original hides if absent; order rows always show, grayed if absent
for (const el of document.querySelectorAll(".stem-waveform-row[data-stem]")) {
- const stem = el.dataset.stem;
- if (stem === "original") {
- el.classList.toggle("hidden", !presentNames.has(stem));
- el.classList.remove("unavailable");
- } else if (isVocalFamily(stem)) {
- const inOrder = order.includes(stem);
- el.classList.toggle("hidden", !inOrder);
- if (inOrder) el.classList.toggle("unavailable", !presentNames.has(stem));
- } else {
- el.classList.remove("hidden");
- el.classList.toggle("unavailable", !presentNames.has(stem));
- }
+ el.classList.toggle("hidden", !shown(el.dataset.stem));
}
const originalRow = presentNames.has("original") ? 1 : 0;
- const visibleTrackCount = originalRow + order.length;
+ _baseVisibleCount = originalRow + order.length;
+ // The open take folders add their take lanes to the rows that are visible.
+ const visibleTrackCount = _baseVisibleCount + takeFolderRowCount();
const app = document.querySelector(".app");
app?.style.setProperty("--visible-track-count", String(visibleTrackCount));
app?.style.setProperty(
@@ -136,35 +144,24 @@ function applyStemSelectionFilter(presentNames) {
// given job (#275) -- whichever the split state doesn't call for stays
// hidden even if presentNames still has it (vocals.wav is never
// deleted by the split, so "present" alone can't decide this one).
- const hidden = isVocalFamily(stem) ? !order.includes(stem) : !presentNames.has(stem);
- el.classList.toggle("hidden", hidden);
+ el.classList.toggle("hidden", !shown(stem));
}
}
- const visibleMixerNames = [];
- if (presentNames.has("original")) visibleMixerNames.push("original");
- for (const name of order) {
- if (presentNames.has(name)) visibleMixerNames.push(name);
- }
- const mixerCap = order.length + (presentNames.has("original") ? 1 : 0);
- for (const name of order) {
- if (visibleMixerNames.length >= mixerCap) break;
- if (!visibleMixerNames.includes(name)) visibleMixerNames.push(name);
- }
- const visibleMixerSet = new Set(visibleMixerNames);
for (const row of document.querySelectorAll(".mixer-column .lane-header[data-stem]")) {
const stem = row.dataset.stem;
- const inOrder = stem === "original" ? presentNames.has("original") : order.includes(stem);
- const available = presentNames.has(stem);
+ const available = shown(stem);
// Mixer rows are built once in renderEmptyShell (original, the base 6,
// then lead_vocals/backing_vocals tacked on at the end) and reused across
// every job load -- DOM order alone would always put lead_vocals/
// backing_vocals last. CSS order (.mixer-column is a column flexbox)
// repositions them to match `order` (right after "original") per job.
- row.style.order = stem === "original" ? "-1" : String(order.indexOf(stem));
- row.classList.toggle("hidden", !inOrder || !visibleMixerSet.has(stem));
- row.classList.toggle("unavailable", !available);
- row.setAttribute("aria-disabled", String(!available));
+ //
+ // In steps of LANE_ORDER_STEP, so an open take folder's lanes fit between
+ // its track and the next lane (syncTakeFolderOrder, below).
+ row.style.order = stem === "original" ? "-1" : String(laneOrder(order.indexOf(stem)));
+ row.classList.toggle("hidden", !available);
+ row.removeAttribute("aria-disabled");
for (const el of row.querySelectorAll("button, .lane-knob, .lane-dl")) {
el.classList.toggle("disabled", !available);
if (available) {
@@ -178,11 +175,9 @@ function applyStemSelectionFilter(presentNames) {
if ("disabled" in el) el.disabled = !available;
}
}
+ syncTakeFolderOrder();
for (const row of document.querySelectorAll(".energy-row[data-stem]")) {
- const stem = row.dataset.stem;
- const available = presentNames.has(stem);
- row.classList.toggle("unavailable", !available);
- row.classList.toggle("hidden", isVocalFamily(stem) ? !order.includes(stem) : false);
+ row.classList.toggle("hidden", !shown(row.dataset.stem));
}
// Presence cards, top row of the track header. Only the vocals family moves:
// the single "Global Vocals" card and the lead/backing pair are alternatives,
@@ -208,11 +203,9 @@ function clearStemSelectionFilter() {
}
for (const el of document.querySelectorAll(".stem-waveform-row[data-stem]")) {
el.classList.remove("hidden");
- el.classList.remove("unavailable");
}
for (const row of document.querySelectorAll(".mixer-column .lane-header[data-stem], .energy-row[data-stem]")) {
row.classList.remove("hidden");
- row.classList.remove("unavailable");
row.removeAttribute("aria-disabled");
}
}
@@ -263,6 +256,8 @@ const STEM_VU_FPS = 30;
const WAVEFORM_LANE_HEIGHT = 70;
const WAVEFORM_SEPARATOR_HEIGHT = 2;
let visualRenderToken = 0;
+// Rows in the stack before any take folder opens (stems, original, tracks).
+let _baseVisibleCount = 0;
let visualAudioContext = null;
let stemVuRafId = null;
let _footerWavePeaks = null;
@@ -416,7 +411,11 @@ function renderOverviewWaveformPath(stemName, peaks, norm, color, barCount, orde
layer.appendChild(row);
}
row.style.setProperty("--stem-color", color);
- row.style.order = String(orderIndex);
+ row.style.order = String(laneOrder(orderIndex));
+ // A recording track's lane keeps one element across redraws for the live
+ // waveform to anchor to (static/js/capture/liveWave.js): the rewrite below
+ // would otherwise replace it on every zoom step.
+ const slot = isTakeName(stemName) ? liveSlot(row, stemName) : null;
// A row is created for every mixer lane, including stems with no audio (e.g.
// a subset extraction). The rows are flex-distributed across the lane stack,
// so they only stay 1:1 with the mixer lanes when their count matches; an
@@ -425,6 +424,7 @@ function renderOverviewWaveformPath(stemName, peaks, norm, color, barCount, orde
// tracks.
if (!peaks?.length) {
row.innerHTML = "";
+ if (slot) row.appendChild(slot);
return;
}
const bars = barCount || overviewBarCount();
@@ -434,6 +434,71 @@ function renderOverviewWaveformPath(stemName, peaks, norm, color, barCount, orde
`;
addLaneDragNugget(row, stemName);
+ if (slot) row.appendChild(slot);
+}
+
+/** The live-waveform slot of a track lane, made once per row. */
+function liveSlot(row, trackId) {
+ let slot = row.querySelector(":scope > .take-live-slot");
+ if (!slot) {
+ slot = document.createElement("div");
+ slot.className = "take-live-slot";
+ slot.dataset.trackId = trackId;
+ }
+ return slot;
+}
+
+/**
+ * The lanes of the open take folders: one per take, newest on top, right
+ * under the track's own lane, drawn like any lane and on the same amplitude
+ * scale. The take that plays is marked; a click on another one makes that the
+ * take that plays (takeLanes.selectTake), and the track's lane follows on the
+ * reload.
+ */
+function renderTakeFolderLanes(layer, laneNames, peaksData, norm, bars) {
+ const want = new Set();
+ for (const folder of takeFolderLanes()) {
+ const index = laneNames.indexOf(folder.trackId);
+ let after = layer.querySelector(`.stem-waveform-row[data-stem="${folder.trackId}"]`);
+ if (index < 0 || !after) continue;
+ folder.takes.forEach(({ take, playing }, k) => {
+ want.add(take.id);
+ let row = layer.querySelector(`.take-folder-lane[data-take-id="${take.id}"]`);
+ if (!row) {
+ row = document.createElement("div");
+ row.className = "stem-waveform-row take-folder-lane";
+ row.dataset.takeId = take.id;
+ row.dataset.folderOf = folder.trackId;
+ // The waves column starts a seek or a loop drag on pointerdown and
+ // captures the pointer, which would take the click from this lane.
+ // A take lane that does not play is a button, not a place to seek.
+ row.addEventListener("pointerdown", (e) => {
+ if (!row.classList.contains("playing")) e.stopPropagation();
+ });
+ row.addEventListener("click", () => selectTake(row.dataset.folderOf, row.dataset.takeId));
+ }
+ // DOM order follows the stack too, so anything reading rows in document
+ // order (the alignment tests do) sees them where they are drawn.
+ if (after.nextElementSibling !== row) after.after(row);
+ after = row;
+ row.classList.toggle("playing", playing);
+ row.classList.remove("choosing");
+ row.title = playing ? t("rec.folder.playing") : t("rec.folder.laneTitle", { take: take.label || take.id });
+ row.style.setProperty("--stem-color", STEM_COLORS[folder.trackId] || "#a0a0a0");
+ row.style.order = String(takeLaneOrder(index, k));
+ const pts = peaksData?.[take.id];
+ row.innerHTML = pts?.length
+ ? ``
+ : "";
+ const name = document.createElement("span");
+ name.className = "take-folder-lane-name";
+ name.textContent = take.label || take.id;
+ row.appendChild(name);
+ });
+ }
+ for (const row of layer.querySelectorAll(".take-folder-lane")) {
+ if (!want.has(row.dataset.takeId)) row.remove();
+ }
}
// The handle for dragging this lane's slice of the loop out. One per lane, so
@@ -452,18 +517,16 @@ function addLaneDragNugget(row, stemName) {
nugget.className = "lane-drag-nugget";
nugget.dataset.laneDragOut = stemName;
nugget.draggable = true;
- nugget.title = t("loop.dragOutStem", { name: t(`stem.${stemName}`) });
+ nugget.title = t("loop.dragOutStem", { name: STEM_DISPLAY[stemName] || stemName });
row.appendChild(nugget);
}
// The lane set must mirror the mixer/multitrack lanes (orderedNames in
// wireUpAudio): "original" plus the stems when an original lane is present,
-// otherwise just the stems. Rendering a row for every lane keeps the overlay
-// aligned even when only a subset of stems was extracted. Swaps "vocals" for
-// lead_vocals + backing_vocals when this job's on-demand split (#275) ran.
+// otherwise just the stems, and only the stems this job has (visibleStemOrder).
function overviewLaneNames(stems) {
const present = new Set(stems.map((s) => s.name));
- const order = effectiveStemOrder(present);
+ const order = visibleStemOrder(present);
return present.has("original") ? ["original", ...order] : order;
}
@@ -473,6 +536,8 @@ function overviewLaneNames(stems) {
// with more of them -- redrawing is what keeps the art identical, where
// stretching the same SVG would smear it.
let _overviewSource = null;
+// Loudest peak per drawn lane, from the last overview render.
+const _overviewLaneMax = new Map();
function rerenderOverviewWaveforms() {
if (!_overviewSource) return;
@@ -489,14 +554,18 @@ function renderAllOverviewWaveformsFromPeaks(stems, peaksData) {
// non-selected lanes just render empty.
const present = new Set(stems.map((s) => s.name));
let globalMax = 0;
+ _overviewLaneMax.clear();
for (const name of laneNames) {
if (!present.has(name)) continue;
const pts = peaksData[name];
if (!pts?.length) continue;
+ let laneMax = 0;
for (const [mn, mx] of pts) {
- if (mx > globalMax) globalMax = mx;
- if (-mn > globalMax) globalMax = -mn;
+ if (mx > laneMax) laneMax = mx;
+ if (-mn > laneMax) laneMax = -mn;
}
+ _overviewLaneMax.set(name, laneMax);
+ if (laneMax > globalMax) globalMax = laneMax;
}
const norm = globalMax > 0 ? 1 / globalMax : 0;
const bars = overviewBarCount();
@@ -504,6 +573,21 @@ function renderAllOverviewWaveformsFromPeaks(stems, peaksData) {
const pts = present.has(name) ? peaksData[name] : null;
renderOverviewWaveformPath(name, pts, norm, STEM_COLORS[name] || "#a0a0a0", bars, i);
});
+ renderTakeFolderLanes(ensureOverviewWaveformLayer(), laneNames, peaksData, norm, bars);
+ // The live waveform of a take being recorded (capture/liveWave.js) re-reads
+ // its lane's place and hands over to these bars on this event.
+ document.dispatchEvent(new CustomEvent("stemdeck:overview-drawn"));
+}
+
+/**
+ * The loudest peak of every drawn lane except `exclude`: the shared
+ * normalisation a live take waveform needs, so it is drawn at the height the
+ * finished lane will have (capture/liveWave.js).
+ */
+export function overviewPeakMax(exclude) {
+ let max = 0;
+ for (const [name, v] of _overviewLaneMax) if (name !== exclude && v > max) max = v;
+ return max;
}
// Normalize all stems to a single shared max so the overview waveforms
@@ -924,6 +1008,7 @@ export function renderEmptyShell() {
document.querySelector(".app")?.classList.remove("is-import");
document.querySelector(".app")?.classList.add("no-track");
stopStemVuLoop();
+ syncTakeLanes(null, []); // no track, no takes
ensureMixerStateDefaults();
mixerEl.innerHTML = "";
// lead_vocals/backing_vocals (#275) get rows too, built once here like the
@@ -1056,7 +1141,25 @@ function _watchLaneFit(count) {
_laneFitObs.observe(panel);
}
+function _syncVisibleCount() {
+ document.querySelector(".app")?.style.setProperty(
+ "--visible-track-count", String(_baseVisibleCount + takeFolderRowCount()),
+ );
+ syncTakeFolderOrder();
+}
+
+// A take folder opened or closed: the stack has more or fewer rows, so both
+// columns are laid out again and the lanes redrawn, without reloading audio.
+document.addEventListener("stemdeck:take-folders-changed", () => {
+ if (!currentJobId) return;
+ _syncVisibleCount();
+ if (_laneCount > 0) _applyLaneHeight(_laneCount);
+ rerenderOverviewWaveforms();
+});
+
function _applyLaneHeight(count) {
+ // Open take folders are rows like any lane, in both columns.
+ count += takeFolderRowCount();
const wavePanel = document.querySelector(".daw-wave-panel");
const panelH = wavePanel?.clientHeight ?? 0;
// One row is a lane plus its separator, and BOTH columns have to agree on
@@ -1080,6 +1183,10 @@ function _applyLaneHeight(count) {
export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, title = "", peaksPromise = null, hasVideo = false, videoStatus = null) {
const rawStems = stems.filter((stem) => stem?.name && stem?.url);
+ // Recorded takes ride along in `stems` as lanes named by take id. They must
+ // be registered before the mix state loads, so their volume and mute are
+ // restored with everything else.
+ syncTakeLanes(jobId, stems);
const app = document.querySelector(".app");
app?.classList.remove("is-import");
app?.classList.remove("no-track");
@@ -1150,6 +1257,7 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti
const wantsVocals = selectedStems.has("vocals");
stems = stems.filter((s) => {
if (s.name === "original") return true;
+ if (isTakeName(s.name)) return true;
if (s.name === "vocals") return wantsVocals && !splitDone;
if (s.name === "lead_vocals" || s.name === "backing_vocals") return wantsVocals && splitDone;
return selectedStems.has(s.name);
@@ -1181,7 +1289,8 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti
// origin window, preventing stem WAV queuing that causes buffer underruns.
let precomputedPeaks = {};
const _footerStemName = stems.find((s) => s.name === "original") ? "original" : stems[0]?.name;
- const _peaksPromise = peaksPromise ?? (jobId
+ // Recording-track lanes borrow their active take's peaks (keyed by take id).
+ const _peaksPromise = (peaksPromise ?? (jobId
? (() => {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 3000);
@@ -1190,7 +1299,7 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti
.catch(() => ({}))
.finally(() => clearTimeout(timer));
})()
- : Promise.resolve({}));
+ : Promise.resolve({}))).then(withTrackPeaks);
// Beat grid for the click track. Fetched alongside peaks; a 404 is the
// normal answer for jobs separated before the beat-grid stage existed, and
@@ -1210,7 +1319,8 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti
const row = mixerEl.querySelector(`.lane-header[data-stem="${stem.name}"]`);
if (!row) continue;
const dl = row.querySelector(".lane-dl");
- if (dl) {
+ // A recording track with no take yet has nothing to download.
+ if (dl && stem.url) {
dl.href = stem.url;
dl.download = _stemFilename(stem.name);
}
@@ -1237,7 +1347,7 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti
// mixer lanes stay aligned -- lead_vocals/backing_vocals in place of vocals
// when this job's on-demand split (#275) produced them.
const stemsByName = Object.fromEntries(stems.map((s) => [s.name, s]));
- const order = effectiveStemOrder(new Set(stems.map((s) => s.name)));
+ const order = visibleStemOrder(new Set(stems.map((s) => s.name)));
const orderedNames = [...(stemsByName["original"] ? ["original"] : []), ...order];
setTrackIndex(Object.fromEntries(orderedNames.map((name, i) => [name, i])));
multitrackContainer.innerHTML = "";
@@ -1258,6 +1368,9 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti
// The null-URL multitrack (engine path) has no WaveSurfer canvas, so reveal the
// SVG overview layer as the visible waveform (CSS hides it on the streaming path).
document.querySelector(".app")?.classList.toggle("engine-waveforms", useEngine);
+ // Take folders open only where their lanes can be drawn: the SVG overlay.
+ setTakeFoldersEnabled(useEngine);
+ _syncVisibleCount();
if (engineTooLarge) {
console.warn(
`[player] track too large for Web Audio engine `
@@ -1845,6 +1958,8 @@ function _effectiveMixGains() {
const pitches = [];
for (const s of _currentStems) {
if (s.name === "mix") continue;
+ // A recording track with no take has no audio for the server to mix.
+ if (!s.url) continue;
const m = mixerState[s.name];
if (!m) continue;
const g = m.muted ? 0 : (anySolo && !m.soloed ? 0 : m.volume);
@@ -1984,7 +2099,7 @@ export function downloadCurrentVideo(onTransferStart) {
export function downloadAllStemsZip(format = "wav", onTransferStart) {
if (!currentJobId) return false;
// Only the active (selected) stems loaded in the DAW — not all 6.
- const names = _currentStems.filter((s) => s.name !== "original").map((s) => s.name);
+ const names = _currentStems.filter((s) => s.name !== "original" && s.url).map((s) => s.name);
if (!names.length) return false;
const safe = _currentTitle
.replace(/[^a-zA-Z0-9]+/g, "_")
diff --git a/static/js/transport.js b/static/js/transport.js
index 7d9fa694..4c426452 100644
--- a/static/js/transport.js
+++ b/static/js/transport.js
@@ -32,6 +32,7 @@ import { computeCountIn, defaultGrouping, normaliseGrouping } from "./metronome.
import { t, onLanguageChange } from "./i18n.js";
import { pitchBlockedKey } from "./pitchBus.js";
import { refitFooter } from "./footerFit.js";
+import { initRecordUi } from "./capture/recordUi.js";
// Zoom range. 1 is the whole track fitted to the panel; there is nothing below
// it to show, so it is the floor rather than a soft default.
@@ -960,6 +961,7 @@ export function wireTransportButtons() {
wireSpeedControl();
wireMetronomeControl();
wirePitchControl();
+ initRecordUi();
}
// Three buttons rather than a dial -- practice speeds for slowing a part
diff --git a/tests/e2e/footer-fit.spec.mjs b/tests/e2e/footer-fit.spec.mjs
index 2e064f4a..47e58da7 100644
--- a/tests/e2e/footer-fit.spec.mjs
+++ b/tests/e2e/footer-fit.spec.mjs
@@ -65,7 +65,11 @@ test.describe("footer fit", () => {
// Asserts the property rather than the collapse class. Whether this width
// needs to wrap at all depends on the content, and the seeded fixture's
// strip is narrower than a real six-stem track's.
- await page.setViewportSize({ width: 1728, height: 900 });
+ //
+ // 1792 rather than 1728 since Record and its options arrow joined the
+ // transport cluster (62px): the same "a little short" row, one control
+ // wider.
+ await page.setViewportSize({ width: 1792, height: 900 });
await openStudio(page, { tauri: true });
await waitForClickTrack(page, { revealOptions: false });
diff --git a/tests/e2e/live-wave.spec.mjs b/tests/e2e/live-wave.spec.mjs
new file mode 100644
index 00000000..c7c33b25
--- /dev/null
+++ b/tests/e2e/live-wave.spec.mjs
@@ -0,0 +1,317 @@
+// The live waveform of a take while it records (static/js/capture/liveWave.js).
+//
+// Chromium's fake capture device plays a beep, so the lane must show bars
+// that grow while recording, ending where the playhead is (less the batch and
+// round-trip lag), at every zoom; and must be replaced by the server's bars
+// after Stop without a gap, and be gone on discard or a failed take.
+//
+// The fixture song is 6 seconds, which bounds how long a take can run here.
+import { test, expect } from "@playwright/test";
+import { openStudio } from "./helpers.mjs";
+import {
+ recBtn, trackRows, removeTracks, addTrack, waitForEngine, pressRecord, watchTakes,
+} from "./recording-helpers.mjs";
+
+test.use({
+ launchOptions: {
+ args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"],
+ },
+ permissions: ["microphone"],
+});
+
+test.afterEach(async ({ request }) => removeTracks(request));
+
+// How far the live edge may trail the playhead: a batch (85 ms), the round
+// trip estimate, a frame, and the scheduling of a headless run.
+const MAX_LAG_SEC = 0.45;
+
+function watchConsole(page) {
+ const errors = [];
+ // Network refusals the recording flow expects (a part the server already
+ // has answers 409) are logged by the browser as resource errors; they are
+ // not script errors.
+ page.on("console", (m) => { if (m.type() === "error" && !/^Failed to load resource/.test(m.text())) errors.push(m.text()); });
+ page.on("pageerror", (e) => errors.push(String(e)));
+ return errors;
+}
+
+/** What the live lane of `trackId` shows right now, measured from its pixels. */
+async function sample(page, trackId) {
+ return page.evaluate(async (id) => {
+ const st = await import("/js/state.js");
+ const lane = document.querySelector(`.live-wave-lane[data-track-id="${id}"]`);
+ const canvas = lane?.querySelector("canvas");
+ if (!canvas || !canvas.width) {
+ const lw = await import("/js/capture/liveWave.js");
+ // Why there is nothing to measure, for the failure message.
+ return {
+ missing: JSON.stringify({
+ stats: lw.getLiveWaveStats(),
+ duration: st.totalDuration,
+ now: st.audioEngine?.getCurrentTime(),
+ lanes: document.querySelectorAll(".live-wave-lane").length,
+ }),
+ };
+ }
+ const g = canvas.getContext("2d");
+ const { data, width, height } = g.getImageData(0, 0, canvas.width, canvas.height);
+ let pixels = 0;
+ let right = -1;
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ if (data[(y * width + x) * 4 + 3] > 0) {
+ pixels++;
+ if (x > right) right = x;
+ }
+ }
+ }
+ const cssW = lane.clientWidth;
+ const scale = width / cssW;
+ const row = document.querySelector(`.stem-waveform-row[data-stem="${id}"]`);
+ const lr = lane.getBoundingClientRect();
+ const rr = row?.getBoundingClientRect();
+ const marker = document.querySelector(".playhead-marker");
+ const markerPct = parseFloat(marker?.style.left || "NaN");
+ const now = st.audioEngine.getCurrentTime();
+ return {
+ pixels,
+ rightCss: right < 0 ? -1 : (right + 1) / scale,
+ cssW,
+ duration: st.totalDuration,
+ now,
+ playheadX: (now / st.totalDuration) * cssW,
+ markerX: (markerPct / 100) * cssW,
+ laneTop: lr.top,
+ laneH: lr.height,
+ rowTop: rr ? rr.top : NaN,
+ rowH: rr ? row.clientHeight * (rr.height / row.offsetHeight) : NaN,
+ liveStats: (await import("/js/capture/liveWave.js")).getLiveWaveStats(),
+ };
+ }, trackId);
+}
+
+/** The live edge sits on the playhead, within the lag and one bar slot. */
+function expectAligned(s, label) {
+ const slot = s.cssW / Math.max(40, Math.round(s.cssW / 5));
+ const pxPerSec = s.cssW / s.duration;
+ const gap = s.playheadX - s.rightCss;
+ expect(gap, `${label}: live edge ${s.rightCss.toFixed(1)} px vs playhead ${s.playheadX.toFixed(1)} px`)
+ .toBeGreaterThanOrEqual(-slot - 1);
+ expect(gap, `${label}: live edge trails the playhead by too much`)
+ .toBeLessThanOrEqual(MAX_LAG_SEC * pxPerSec + slot + 1);
+ // The ruler's playhead marker says the same as the engine.
+ expect(Math.abs(s.markerX - s.playheadX), `${label}: marker vs engine`).toBeLessThanOrEqual(pxPerSec * 0.1 + 1);
+ // Over the track's own lane.
+ expect(Math.abs(s.laneTop - s.rowTop), `${label}: lane top`).toBeLessThanOrEqual(1);
+ expect(Math.abs(s.laneH - s.rowH), `${label}: lane height`).toBeLessThanOrEqual(1);
+}
+
+/** Add a track and wait until the reload it causes has fully settled. */
+async function addSettledTrack(page) {
+ const id = await addTrack(page);
+ await waitForEngine(page);
+ await expect(page.locator(`.stem-waveform-row[data-stem="${id}"]`)).toBeAttached({ timeout: 20000 });
+ await expect(page.locator("#waveLoadingOverlay")).toBeHidden({ timeout: 20000 });
+ return id;
+}
+
+// waitForFunction does not await an async predicate (a Promise is truthy),
+// so the state module is put on window once and read synchronously.
+async function exposeState(page) {
+ await page.evaluate(async () => { window.__e2eState = await import("/js/state.js"); });
+}
+
+async function untilPlaying(page) {
+ await exposeState(page);
+ await page.waitForFunction(() => window.__e2eState.audioEngine?.isPlaying(), null, { timeout: 10000 });
+}
+
+async function untilSongTime(page, sec) {
+ await page.waitForFunction((t) => window.__e2eState.audioEngine?.getCurrentTime() >= t, sec, { timeout: 10000 });
+}
+
+test("the take's waveform grows in its lane while recording, on the playhead, then hands over to the server's", async ({ page }, testInfo) => {
+ test.setTimeout(90_000);
+ await page.setViewportSize({ width: 1600, height: 1000 });
+ const errors = watchConsole(page);
+ const log = watchTakes(page);
+ await openStudio(page);
+ const trackId = await addSettledTrack(page);
+
+ // Record whether the server's bars were already drawn at the moment the
+ // live lane went (no gap), and what the live bars were then (no jump).
+ await page.evaluate(() => {
+ window.__live = { removedWithBars: null, handoff: null };
+ document.addEventListener("stemdeck:live-wave-handoff", (e) => { window.__live.handoff = e.detail; });
+ new MutationObserver((muts) => {
+ for (const m of muts) {
+ for (const n of m.removedNodes) {
+ if (n.classList?.contains("live-wave-lane")) {
+ const id = n.dataset.trackId;
+ const row = document.querySelector(`.stem-waveform-row[data-stem="${id}"]`);
+ const rects = row ? [...row.querySelectorAll("rect")] : [];
+ window.__live.removedWithBars = rects.length;
+ // The server's bars as drawn at that moment, in CSS px.
+ window.__live.server = {
+ bars: rects.length,
+ heights: rects.map((r) => (parseFloat(r.getAttribute("height")) / 48) * row.clientHeight),
+ };
+ }
+ }
+ }
+ }).observe(document.querySelector(".waves-column"), { childList: true });
+ });
+
+ await pressRecord(page);
+ await untilPlaying(page);
+ const shots = process.env.LIVEWAVE_SHOT_DIR || testInfo.outputDir;
+
+ await untilSongTime(page, 1.4);
+ const a = await sample(page, trackId);
+ await page.screenshot({ path: `${shots}/live-wave-1.png` });
+ await untilSongTime(page, 2.6);
+ const b = await sample(page, trackId);
+ await page.screenshot({ path: `${shots}/live-wave-2.png` });
+
+ expect(a.missing, "a live canvas exists while recording").toBeUndefined();
+ expect(a.pixels).toBeGreaterThan(0);
+ expect(b.pixels, "the waveform grows").toBeGreaterThan(a.pixels);
+ expect(b.rightCss, "the right edge moves on").toBeGreaterThan(a.rightCss);
+ expectAligned(a, "zoom 1, first sample");
+ expectAligned(b, "zoom 1, second sample");
+
+ // Zoom in while recording: the bars are redrawn at the new width, still on
+ // the playhead.
+ await page.evaluate(async () => (await import("/js/transport.js")).setWaveZoomLevel(3));
+ await untilSongTime(page, 3.6);
+ const c = await sample(page, trackId);
+ await page.screenshot({ path: `${shots}/live-wave-zoom3.png` });
+ expect(c.cssW).toBeGreaterThan(b.cssW * 2);
+ expectAligned(c, "zoom 3");
+
+ // Cost: a frame redraws one or two bars.
+ const cost = c.liveStats;
+ const mean = cost.drawMs / Math.max(1, cost.frames);
+ testInfo.annotations.push({ type: "live-wave cost", description: `frames ${cost.frames}, mean ${mean.toFixed(3)} ms, max ${cost.maxDrawMs.toFixed(3)} ms, full redraws ${cost.fullRedraws}, batches ${cost.batches}, batch mean ${(cost.batchMs / Math.max(1, cost.batches)).toFixed(3)} ms, batch max ${cost.maxBatchMs.toFixed(3)} ms` });
+ console.log(`live-wave cost: ${testInfo.annotations.at(-1).description}`);
+ expect(cost.frames).toBeGreaterThan(10);
+ expect(mean, "mean draw per frame").toBeLessThan(2);
+ expect(cost.maxDrawMs, "worst draw per frame").toBeLessThan(8);
+
+ // Stop: the live lane stays until the reload has drawn the server's bars.
+ await recBtn(page).click();
+ await expect(recBtn(page)).not.toHaveClass(/recording/);
+ await expect.poll(() => log.finishStatus.at(-1), { timeout: 20000 }).toBe(200);
+ await expect(page.locator("#recKeep")).toBeVisible({ timeout: 20000 });
+ await expect(page.locator(".live-wave-lane")).toHaveCount(0, { timeout: 20000 });
+ const live = await page.evaluate(() => window.__live);
+ expect(live.removedWithBars, "the server's bars were drawn when the live lane went").toBeGreaterThan(0);
+
+ // No jump: bar for bar, the live heights match the server's.
+ expect(live.handoff).not.toBeNull();
+ // Compared with the server's bars as they were at that moment: the reload
+ // redraws them again later at its own zoom.
+ const { server } = live;
+ expect(server.bars).toBe(live.handoff.bars);
+ const heights = Object.values(live.handoff.heights);
+ // The first and last bar of the take are partly filled on one side and
+ // partly padded on the other, so compare the bars inside it.
+ const drawn = heights.map((v, i) => (v >= 0 ? i : -1)).filter((i) => i >= 0).slice(1, -1);
+ const meanDiff = (shift) => {
+ let sum = 0;
+ let n = 0;
+ for (const i of drawn) {
+ const j = i + shift;
+ if (j < 0 || j >= server.heights.length) continue;
+ sum += Math.abs(heights[i] - server.heights[j]);
+ n++;
+ }
+ return sum / Math.max(1, n);
+ };
+ const close = drawn.filter((i) => Math.abs(heights[i] - server.heights[i]) <= 2).length;
+ console.log(`live-wave handoff: ${drawn.length} bars, ${close} within 2 px, mean diff ${meanDiff(0).toFixed(2)} px (shift -1: ${meanDiff(-1).toFixed(2)}, +1: ${meanDiff(1).toFixed(2)}) on a ${live.handoff.height} px lane`);
+ expect(drawn.length).toBeGreaterThan(20);
+ // In time: no other bar offset matches better than none.
+ expect(meanDiff(0)).toBeLessThan(meanDiff(-1));
+ expect(meanDiff(0)).toBeLessThan(meanDiff(1));
+ // In height: the live points are cut on the server's own grid, so the
+ // bars match; a beep edge may still land a sample apart after the take is
+ // resampled to the stems' rate.
+ expect(close / drawn.length, "share of bars within 2 px").toBeGreaterThan(0.95);
+ expect(meanDiff(0), "mean live vs server bar height, px").toBeLessThan(0.5);
+
+ // Keep: the server's waveform stays, no live lane comes back.
+ await page.locator("#recKeep").click();
+ await expect(page.locator(".live-wave-lane")).toHaveCount(0);
+ await expect(page.locator(`.stem-waveform-row[data-stem="${trackId}"] rect`).first()).toBeAttached();
+ expect(errors, errors.join("\n")).toEqual([]);
+});
+
+test("discard leaves no live waveform and an empty lane", async ({ page }) => {
+ test.setTimeout(60_000);
+ const errors = watchConsole(page);
+ const log = watchTakes(page);
+ await openStudio(page);
+ const trackId = await addSettledTrack(page);
+ await pressRecord(page);
+ await untilPlaying(page);
+ await untilSongTime(page, 1.2);
+ await expect(page.locator(`.live-wave-lane[data-track-id="${trackId}"]`)).toHaveCount(1);
+ await recBtn(page).click();
+ await expect.poll(() => log.finishStatus.at(-1), { timeout: 20000 }).toBe(200);
+ await expect(page.locator("#recDiscard")).toBeVisible({ timeout: 20000 });
+ await page.locator("#recDiscard").click();
+ await expect.poll(() => log.deletes.length, { timeout: 10000 }).toBe(1);
+ await waitForEngine(page);
+ await expect(page.locator(".live-wave-lane")).toHaveCount(0);
+ // The track has no take again: its lane draws nothing.
+ await expect.poll(() => page.locator(`.stem-waveform-row[data-stem="${trackId}"] rect`).count(), { timeout: 10000 }).toBe(0);
+ expect(errors, errors.join("\n")).toEqual([]);
+});
+
+test("a take that fails takes its live waveform with it at once", async ({ page }) => {
+ test.setTimeout(60_000);
+ await openStudio(page);
+ const trackId = await addSettledTrack(page);
+ // The server refuses to finish the take.
+ await page.route(/\/takes\/t[0-9a-f]{8}\/finish$/, (route) => route.fulfill({ status: 500, body: "{}" }));
+ await pressRecord(page);
+ await untilPlaying(page);
+ await untilSongTime(page, 1.2);
+ await expect(page.locator(`.live-wave-lane[data-track-id="${trackId}"]`)).toHaveCount(1);
+ await recBtn(page).click();
+ await expect(page.locator(".live-wave-lane")).toHaveCount(0, { timeout: 20000 });
+ await expect(page.locator("#recKeep")).toBeHidden();
+});
+
+test("two armed tracks each get their own live waveform, over their own lane", async ({ page }) => {
+ test.setTimeout(60_000);
+ const log = watchTakes(page);
+ await openStudio(page);
+ const a = await addSettledTrack(page);
+ const b = await addSettledTrack(page);
+ // Input 1 and Input 2 of the same stereo device.
+ for (const [i, label] of [[0, "Input 1"], [1, "Input 2"]]) {
+ const sel = trackRows(page).nth(i).locator(".track-input");
+ await expect.poll(() => sel.locator("option").count(), { timeout: 10000 }).toBeGreaterThan(2);
+ const value = await sel.locator("option", { hasText: new RegExp(`^Fake Audio Input 1 · ${label}$`) }).getAttribute("value");
+ await sel.selectOption(value);
+ }
+ await trackRows(page).nth(0).locator(".track-arm").click();
+ await trackRows(page).nth(1).locator(".track-arm").click();
+ await recBtn(page).click();
+ await expect(recBtn(page)).toHaveClass(/recording/);
+ await untilPlaying(page);
+ await untilSongTime(page, 1.5);
+ const sa = await sample(page, a);
+ const sb = await sample(page, b);
+ for (const [s, label] of [[sa, "track 1"], [sb, "track 2"]]) {
+ expect(s.missing, label).toBeUndefined();
+ expect(s.pixels, label).toBeGreaterThan(0);
+ expectAligned(s, label);
+ }
+ expect(sb.laneTop).toBeGreaterThan(sa.laneTop);
+ await recBtn(page).click();
+ await expect.poll(() => log.finishStatus.length, { timeout: 20000 }).toBe(2);
+ await expect(page.locator(".live-wave-lane")).toHaveCount(0, { timeout: 20000 });
+});
diff --git a/tests/e2e/partial-stems.spec.mjs b/tests/e2e/partial-stems.spec.mjs
new file mode 100644
index 00000000..3267fa2a
--- /dev/null
+++ b/tests/e2e/partial-stems.spec.mjs
@@ -0,0 +1,99 @@
+// A song with only some stems shows only those stems.
+//
+// The fixture job has four stems (tests/e2e/seed.py: vocals, drums, bass,
+// other), so guitar and piano were never extracted. They used to keep a
+// greyed, empty mixer row and waveform lane each, which spread four stems
+// over six lanes. Now they have no row at all, and the four sit together at
+// the top, still aligned row for row with their waveforms.
+//
+// The empty studio, with no song open, keeps its six placeholder rows.
+import { test, expect } from "@playwright/test";
+import { openStudio, seedLibrary, stubUpdateCheck, waitForClickTrack } from "./helpers.mjs";
+
+const PRESENT = ["vocals", "drums", "bass", "other"];
+const ABSENT = ["guitar", "piano"];
+
+async function lanes(page) {
+ return page.evaluate(() => {
+ const visible = (sel) => [...document.querySelectorAll(sel)]
+ .filter((e) => !e.classList.contains("hidden") && e.getBoundingClientRect().height > 0);
+ const mix = visible(".mixer-column .lane-header[data-stem]")
+ .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
+ const wave = visible(".stem-waveform-row[data-stem]")
+ .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
+ const box = (e) => { const r = e.getBoundingClientRect(); return { top: Math.round(r.top), h: Math.round(r.height) }; };
+ return {
+ mixNames: mix.map((e) => e.dataset.stem),
+ waveNames: wave.map((e) => e.dataset.stem),
+ mixBoxes: mix.map(box),
+ waveBoxes: wave.map(box),
+ stemList: visible(".stem-list span[data-stem]").map((e) => e.dataset.stem),
+ count: getComputedStyle(document.querySelector(".app")).getPropertyValue("--visible-track-count").trim(),
+ };
+ });
+}
+
+test.describe("a song with only some stems", () => {
+ test("shows only the extracted stems, together at the top and aligned with their lanes", async ({ page }) => {
+ await page.setViewportSize({ width: 1600, height: 900 });
+ await openStudio(page, { tauri: true });
+ await waitForClickTrack(page, { revealOptions: false });
+ // The waveform rows arrive with the peaks.
+ await expect.poll(async () => (await lanes(page)).waveNames.length, { timeout: 10000 }).toBe(PRESENT.length);
+
+ const g = await lanes(page);
+ expect(g.mixNames).toEqual(PRESENT);
+ expect(g.waveNames).toEqual(PRESENT);
+ expect(g.count).toBe(String(PRESENT.length));
+ for (const name of ABSENT) {
+ await expect(page.locator(`.mixer-column .lane-header[data-stem="${name}"]`)).toBeHidden();
+ await expect(page.locator(`.stem-waveform-row[data-stem="${name}"]`)).toHaveCount(0);
+ expect(g.stemList).not.toContain(name);
+ }
+ // Row for row: each mixer row and its waveform lane start on the same line.
+ for (let i = 0; i < PRESENT.length; i++) {
+ expect(Math.abs(g.mixBoxes[i].top - g.waveBoxes[i].top)).toBeLessThanOrEqual(1);
+ }
+
+ // The engine and the mixer know only these lanes.
+ const names = await page.evaluate(async () => {
+ const st = await import("/js/state.js");
+ return Object.entries(st.trackIndex).sort((a, b) => a[1] - b[1]).map(([n]) => n);
+ });
+ expect(names).toEqual(PRESENT);
+
+ // Solo and export still work on what is there.
+ await page.locator('.mixer-column .lane-header[data-stem="other"] .solo').click();
+ expect(await page.evaluate(async () => (await import("/js/state.js")).mixerState.other.soloed)).toBe(true);
+ await page.locator('.mixer-column .lane-header[data-stem="other"] .solo').click();
+ await page.locator("#t-export-btn").click();
+ await page.locator("#t-export-mix").click();
+ await page.evaluate(() => window.__e2e.choosePath());
+ await expect.poll(() => page.evaluate(() => window.__e2e.callsFor("download_to_path").length)).toBe(1);
+ const url = await page.evaluate(() => window.__e2e.callsFor("download_to_path")[0].args.url);
+ expect(new URL(url).searchParams.get("stems").split(",")).toEqual(PRESENT);
+ });
+
+ test("stays aligned at other window heights", async ({ page }) => {
+ await page.setViewportSize({ width: 1600, height: 900 });
+ await openStudio(page, { tauri: true });
+ await waitForClickTrack(page, { revealOptions: false });
+ await expect.poll(async () => (await lanes(page)).waveNames.length, { timeout: 10000 }).toBe(PRESENT.length);
+ for (const height of [720, 1013, 1300]) {
+ await page.setViewportSize({ width: 1600, height });
+ await page.waitForTimeout(450);
+ const g = await lanes(page);
+ const drift = g.mixBoxes.map((b, i) => Math.abs(b.top - (g.waveBoxes[i]?.top ?? b.top)));
+ expect(Math.max(...drift), `height ${height}`).toBeLessThanOrEqual(1);
+ }
+ });
+
+ test("the empty studio keeps its six placeholder rows", async ({ page }) => {
+ await seedLibrary(page);
+ await stubUpdateCheck(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await expect(page.locator(".app.no-track")).toHaveCount(1);
+ await expect.poll(async () => (await lanes(page)).mixNames)
+ .toEqual(["vocals", "drums", "bass", "guitar", "piano", "other"]);
+ });
+});
diff --git a/tests/e2e/recording-helpers.mjs b/tests/e2e/recording-helpers.mjs
new file mode 100644
index 00000000..0faa48dc
--- /dev/null
+++ b/tests/e2e/recording-helpers.mjs
@@ -0,0 +1,84 @@
+// Shared steps for the recording specs (recording, recording-tracks,
+// video-take). The backend is real: takes and tracks go through
+// app/api/takes.py, and every test removes what it recorded so the fixture
+// job looks the same to the rest of the suite.
+import { expect } from "@playwright/test";
+import { JOB_ID } from "./helpers.mjs";
+
+export const recBtn = (page) => page.locator("#t-rec");
+export const trackRows = (page) => page.locator(".mixer-column .lane-header.track-lane:not(.hidden)");
+
+/** Delete every recording track (and so every take) of the fixture job. */
+export async function removeTracks(request) {
+ const res = await request.get(`/api/jobs/${JOB_ID}`);
+ if (!res.ok()) return;
+ const job = await res.json();
+ for (const tr of job.tracks || []) await request.delete(`/api/jobs/${JOB_ID}/tracks/${tr.id}`);
+ for (const tk of job.takes || []) await request.delete(`/api/jobs/${JOB_ID}/takes/${tk.id}`);
+}
+
+export async function jobState(page) {
+ return (await page.request.get(`/api/jobs/${JOB_ID}`)).json();
+}
+
+/** Press "+ Track" and wait for the new row. Returns its track id. */
+export async function addTrack(page) {
+ const before = await trackRows(page).count();
+ await page.locator("#addTrackBtn").click();
+ await expect(trackRows(page)).toHaveCount(before + 1, { timeout: 20000 });
+ return trackRows(page).nth(before).getAttribute("data-take");
+}
+
+/** Wait until the engine is up again (a track change reloads the lanes). */
+export async function waitForEngine(page) {
+ await page.waitForFunction(async () => {
+ const st = await import("/js/state.js");
+ return !!st.audioEngine && !document.querySelector(".app")?.classList.contains("no-track");
+ }, null, { timeout: 20000 });
+}
+
+/**
+ * Press Record and wait until it records. With no track yet, one is added
+ * first: Record arms the only track there is.
+ */
+export async function pressRecord(page) {
+ if (!(await trackRows(page).count())) await addTrack(page);
+ await waitForEngine(page);
+ await recBtn(page).click();
+ await expect(recBtn(page)).toHaveClass(/recording/, { timeout: 10000 });
+}
+
+export async function recordForAWhile(page, ms = 2500) {
+ await pressRecord(page);
+ // The transport starts once the recorder has its first frame.
+ await page.waitForFunction(async () => (await import("/js/state.js")).audioEngine?.isPlaying(), null, { timeout: 10000 });
+ await page.waitForTimeout(ms);
+ await recBtn(page).click();
+ await expect(recBtn(page)).not.toHaveClass(/recording/);
+}
+
+/** Record what the page sends to the takes API, from the real requests. */
+export function watchTakes(page) {
+ const log = { created: [], parts: [], camera: [], finishes: [], finishStatus: [], patches: [], deletes: [], renders: [], trackPatches: [] };
+ page.on("request", (req) => {
+ const url = new URL(req.url());
+ const m = url.pathname.match(/\/api\/jobs\/[^/]+\/(takes|tracks)(?:\/([tk][0-9a-f]{8}))?(\/.*)?$/);
+ if (!m) return;
+ const [, kind, id, rest = ""] = m;
+ if (kind === "tracks") {
+ if (req.method() === "PATCH") log.trackPatches.push(req.postDataJSON());
+ return;
+ }
+ if (!id && req.method() === "POST") log.created.push(req.postDataJSON());
+ else if (rest === "/parts/audio") log.parts.push({ take: id, seq: Number(url.searchParams.get("seq")), bytes: req.postDataBuffer()?.length ?? 0 });
+ else if (rest === "/parts/camera") log.camera.push({ take: id, seq: Number(url.searchParams.get("seq")), bytes: req.postDataBuffer()?.length ?? 0, type: req.headers()["content-type"] });
+ else if (rest === "/finish" && req.method() === "POST") log.finishes.push({ take: id, body: req.postDataJSON() });
+ else if (rest === "" && req.method() === "PATCH") log.patches.push(req.postDataJSON());
+ else if (rest === "" && req.method() === "DELETE") log.deletes.push(id);
+ else if (rest === "/render" && req.method() === "POST") log.renders.push(req.postDataJSON());
+ });
+ page.on("response", (res) => {
+ if (/\/takes\/t[0-9a-f]{8}\/finish$/.test(new URL(res.url()).pathname)) log.finishStatus.push(res.status());
+ });
+ return log;
+}
diff --git a/tests/e2e/recording-real-audio.spec.mjs b/tests/e2e/recording-real-audio.spec.mjs
new file mode 100644
index 00000000..0be4553e
--- /dev/null
+++ b/tests/e2e/recording-real-audio.spec.mjs
@@ -0,0 +1,469 @@
+// Recording from real hardware: skipped unless STEMDECK_REAL_AUDIO=1.
+//
+// CI has no audio interface, so these run only on a machine with one, by
+// hand: an audio interface (here a Yamaha MG12XU, "MG-XU" on Windows) with a
+// signal on its channel 1 (panned hard left, so Windows' stereo input carries
+// it on the LEFT channel only), and a microphone in a quiet room.
+//
+// STEMDECK_REAL_AUDIO=1 npx playwright test recording-real-audio
+//
+// Optional: STEMDECK_REAL_BROWSER=msedge, STEMDECK_REAL_AUDIO_KEEP=1 (leave the
+// takes on disk for ffprobe), STEMDECK_REAL_INTERFACE (default "MG-XU") and STEMDECK_REAL_MIC
+// (default "StreamCam") match the device names the app shows.
+//
+// The browser is launched with --use-fake-ui-for-media-stream (permission
+// granted without a prompt) but WITHOUT --use-fake-device-for-media-stream,
+// so getUserMedia opens the real devices. --mute-audio keeps the speakers
+// silent: the context still runs and every node is still measurable, only
+// the output device gets nothing. What reaches the speakers is measured by a
+// tap on every connection to the destination.
+//
+// Every number is printed (dBFS, RMS and peak, per channel) so a run doubles
+// as a measurement.
+import { test, expect } from "@playwright/test";
+import fs from "node:fs";
+import path from "node:path";
+import { openStudio, JOB_ID } from "./helpers.mjs";
+import { recBtn, trackRows, removeTracks, jobState, addTrack, waitForEngine, watchTakes } from "./recording-helpers.mjs";
+
+const REAL = process.env.STEMDECK_REAL_AUDIO === "1";
+const IFACE = process.env.STEMDECK_REAL_INTERFACE || "MG-XU";
+const MIC = process.env.STEMDECK_REAL_MIC || "StreamCam";
+const E2E_DIR = process.env.STEMDECK_E2E_DIR || "";
+
+test.skip(!REAL, "needs real audio hardware: set STEMDECK_REAL_AUDIO=1");
+
+test.use({
+ // STEMDECK_REAL_BROWSER=msedge runs the same checks in Edge.
+ ...(process.env.STEMDECK_REAL_BROWSER ? { channel: process.env.STEMDECK_REAL_BROWSER } : {}),
+ launchOptions: { args: ["--use-fake-ui-for-media-stream", "--mute-audio", "--autoplay-policy=no-user-gesture-required"] },
+ permissions: ["microphone"],
+});
+
+// STEMDECK_REAL_AUDIO_KEEP=1 leaves the takes on disk, for ffprobe.
+test.afterEach(async ({ request }) => { if (process.env.STEMDECK_REAL_AUDIO_KEEP !== "1") await removeTracks(request); });
+test.beforeEach(async ({ request }) => removeTracks(request));
+
+// ─── Measuring ───
+
+const dB = (x) => (x > 0 ? 20 * Math.log10(x) : -200);
+const fmt = (x) => `${x.toFixed(1)} dBFS`;
+
+/** Per-channel RMS and peak (dBFS) of a WAV file: PCM16 or float32. */
+function wavStats(buf) {
+ let p = 12;
+ let fmtInfo = null;
+ let data = null;
+ while (p + 8 <= buf.length) {
+ const id = buf.toString("ascii", p, p + 4);
+ const size = buf.readUInt32LE(p + 4);
+ if (id === "fmt ") fmtInfo = { tag: buf.readUInt16LE(p + 8), ch: buf.readUInt16LE(p + 10), rate: buf.readUInt32LE(p + 12), bits: buf.readUInt16LE(p + 22) };
+ if (id === "data") data = buf.subarray(p + 8, p + 8 + size);
+ p += 8 + size + (size & 1);
+ }
+ const { ch, bits, tag, rate } = fmtInfo;
+ const bytes = bits / 8;
+ const frames = Math.floor(data.length / (bytes * ch));
+ const out = [];
+ for (let c = 0; c < ch; c++) {
+ let sum = 0;
+ let peak = 0;
+ for (let i = 0; i < frames; i++) {
+ const o = (i * ch + c) * bytes;
+ const v = tag === 3 ? data.readFloatLE(o) : data.readInt16LE(o) / 32768;
+ sum += v * v;
+ peak = Math.max(peak, Math.abs(v));
+ }
+ out.push({ rms: dB(Math.sqrt(sum / frames)), peak: dB(peak) });
+ }
+ return { rate, channels: ch, frames, seconds: frames / rate, perChannel: out };
+}
+
+/** Samples of channel 0 of a WAV, for comparing two signals. */
+function wavChannel(buf, c = 0) {
+ let p = 12;
+ let f = null;
+ let data = null;
+ while (p + 8 <= buf.length) {
+ const id = buf.toString("ascii", p, p + 4);
+ const size = buf.readUInt32LE(p + 4);
+ if (id === "fmt ") f = { tag: buf.readUInt16LE(p + 8), ch: buf.readUInt16LE(p + 10), bits: buf.readUInt16LE(p + 22) };
+ if (id === "data") data = buf.subarray(p + 8, p + 8 + size);
+ p += 8 + size + (size & 1);
+ }
+ const bytes = f.bits / 8;
+ const frames = Math.floor(data.length / (bytes * f.ch));
+ const s = new Float32Array(frames);
+ for (let i = 0; i < frames; i++) {
+ const o = (i * f.ch + c) * bytes;
+ s[i] = f.tag === 3 ? data.readFloatLE(o) : data.readInt16LE(o) / 32768;
+ }
+ return s;
+}
+
+function log(...a) { console.log("[real-audio]", ...a); }
+
+const SHOTS = process.env.STEMDECK_REAL_AUDIO_SHOTS || "";
+const shot = (name) => (SHOTS ? path.join(SHOTS, name) : test.info().outputPath(name));
+
+/** The mixer rows of the recording tracks, for a screenshot. */
+async function rowClip(page) {
+ const box = await page.evaluate(() => {
+ const rows = [...document.querySelectorAll(".mixer-column .lane-header.track-lane")];
+ const r = rows.map((e) => e.getBoundingClientRect());
+ const top = Math.min(...r.map((b) => b.top));
+ const bottom = Math.max(...r.map((b) => b.bottom));
+ return { x: r[0].left, y: top - 4, width: r[0].width, height: bottom - top + 8 };
+ });
+ return box;
+}
+
+/** The raw capture (float32, every recorded channel) of a take, from disk. */
+function rawTake(takeId) {
+ if (!E2E_DIR) return null;
+ const file = path.join(E2E_DIR, "jobs", JOB_ID, "takes", takeId, "audio.raw.wav");
+ return fs.existsSync(file) ? fs.readFileSync(file) : null;
+}
+
+/**
+ * Tap everything that is connected to an AudioContext's destination, before
+ * the app builds its graph: window.__outLevel(ms) is the RMS and peak (dBFS)
+ * of what the speakers would get.
+ */
+async function installOutputTap(page) {
+ await page.addInitScript(() => {
+ const orig = AudioNode.prototype.connect;
+ const taps = new WeakMap();
+ AudioNode.prototype.connect = function (dest, ...rest) {
+ const r = orig.call(this, dest, ...rest);
+ if (typeof AudioDestinationNode !== "undefined" && dest instanceof AudioDestinationNode) {
+ let tap = taps.get(dest.context);
+ if (!tap) {
+ tap = dest.context.createAnalyser();
+ tap.fftSize = 2048;
+ tap.channelCount = 2;
+ tap.channelCountMode = "explicit";
+ taps.set(dest.context, tap);
+ }
+ window.__tapCtx = dest.context;
+ window.__tap = tap;
+ try { orig.call(this, tap, rest[0] ?? 0); } catch { /* not connectable */ }
+ }
+ return r;
+ };
+ window.__outLevel = async (ms = 1500) => {
+ const tap = window.__tap;
+ if (!tap) return { rms: -200, peak: -200 };
+ const buf = new Float32Array(tap.fftSize);
+ let sum = 0;
+ let n = 0;
+ let peak = 0;
+ const t0 = performance.now();
+ while (performance.now() - t0 < ms) {
+ await new Promise((r) => setTimeout(r, 40));
+ tap.getFloatTimeDomainData(buf);
+ for (const v of buf) { sum += v * v; n++; peak = Math.max(peak, Math.abs(v)); }
+ }
+ const d = (x) => (x > 0 ? 20 * Math.log10(x) : -200);
+ return { rms: d(Math.sqrt(sum / Math.max(1, n))), peak: d(peak) };
+ };
+ });
+}
+
+// ─── Driving the app ───
+
+/**
+ * Pick a track's input: an instrument option reads "MG-XU · Input 1", a
+ * microphone's is its name (one input, so `channelText` is null).
+ */
+async function chooseTrackInput(page, row, deviceMatch, channelText) {
+ const sel = row.locator(".track-input");
+ await expect.poll(async () => (await sel.locator("option").allTextContents()).length, { timeout: 15000 }).toBeGreaterThan(0);
+ const value = await sel.evaluate((s, { dev, ch }) => {
+ for (const o of s.options) {
+ const text = o.textContent.trim();
+ if (o.disabled || !text.includes(dev)) continue;
+ if (ch == null ? o.dataset.kind === "voice" : text.endsWith(` · ${ch}`)) return o.value;
+ }
+ return null;
+ }, { dev: deviceMatch, ch: channelText });
+ expect(value, `${deviceMatch} ${channelText ?? ""} in the track's input list`).not.toBeNull();
+ await sel.selectOption(value);
+}
+
+/** Record `ms` with the armed tracks; returns the finished takes' ids in order. */
+async function recordArmed(page, takesLog, ms = 5000) {
+ const before = takesLog.finishStatus.length;
+ await page.evaluate(async () => {
+ const st = await import("/js/state.js");
+ st.audioEngine?.setTime?.(0);
+ });
+ await recBtn(page).click();
+ await expect(recBtn(page)).toHaveClass(/recording/, { timeout: 15000 });
+ await page.waitForTimeout(ms);
+ if (await recBtn(page).evaluate((b) => b.classList.contains("recording"))) await recBtn(page).click();
+ await expect(recBtn(page)).not.toHaveClass(/recording/, { timeout: 15000 });
+ const armedCount = await trackRows(page).locator('.track-arm[aria-pressed="true"]').count();
+ await expect.poll(() => takesLog.finishStatus.length - before, { timeout: 30000 }).toBe(Math.max(1, armedCount));
+ expect(takesLog.finishStatus.slice(before).every((s) => s === 200)).toBe(true);
+ const job = await jobState(page);
+ return job.takes.slice(-Math.max(1, armedCount));
+}
+
+test.describe("real audio hardware", () => {
+ test.setTimeout(180_000);
+
+ test("the interface: Input 1 carries the loop, Input 2 is silent, 1+2 is left only", async ({ page }) => {
+ await installOutputTap(page);
+ const takesLog = watchTakes(page);
+ await openStudio(page);
+ await addTrack(page);
+ await waitForEngine(page);
+ const row = trackRows(page).first();
+
+ const results = {};
+ for (const ch of ["Input 1", "Input 2", "Input 1+2"]) {
+ await chooseTrackInput(page, row, IFACE, ch);
+ await waitForEngine(page);
+ const arm = trackRows(page).first().locator(".track-arm");
+ if ((await arm.getAttribute("aria-pressed")) !== "true") await arm.click();
+ const [take] = await recordArmed(page, takesLog);
+ await page.locator("#recKeep").click();
+ await page.keyboard.press("Escape");
+ const raw = rawTake(take.id);
+ const wav = Buffer.from(await (await page.request.get(take.url)).body());
+ results[ch] = { take: take.id, raw: raw && wavStats(raw), wav: wavStats(wav), finish: takesLog.finishes.at(-1).body.capture };
+ log(ch, "capture:", JSON.stringify(results[ch].finish));
+ if (results[ch].raw) log(ch, "audio.raw.wav:", results[ch].raw.channels, "ch", results[ch].raw.perChannel.map((c, i) => `ch${i + 1} rms ${fmt(c.rms)} peak ${fmt(c.peak)}`).join(" | "));
+ log(ch, "audio.wav:", results[ch].wav.perChannel.map((c, i) => `${i ? "R" : "L"} rms ${fmt(c.rms)} peak ${fmt(c.peak)}`).join(" | "));
+ await waitForEngine(page);
+ }
+
+ // Levels are compared with each other, not with a fixed number: the
+ // mixer's faders set the absolute level. Input 1: the loop, well above
+ // the converter's floor. Input 2: the floor (about -105 dBFS here).
+ const in1 = results["Input 1"].raw.perChannel[0];
+ const in2 = results["Input 2"].raw.perChannel[0];
+ expect(results["Input 1"].raw.channels).toBe(1);
+ expect(results["Input 2"].raw.channels).toBe(1);
+ expect(in1.rms).toBeGreaterThan(-80);
+ expect(in2.rms).toBeLessThan(-85);
+ expect(in1.rms - in2.rms).toBeGreaterThan(25);
+ // Input 1+2: a stereo take, left the loop and right silent.
+ const pair = results["Input 1+2"].raw;
+ expect(pair.channels).toBe(2);
+ expect(pair.perChannel[0].rms - pair.perChannel[1].rms).toBeGreaterThan(25);
+ expect(pair.perChannel[1].rms).toBeLessThan(-85);
+ // The saved, aligned take (mono written to both sides, padded to the
+ // song) holds the loop on both sides.
+ for (const c of results["Input 1"].wav.perChannel) expect(c.rms - in2.rms).toBeGreaterThan(20);
+ });
+
+ test("the microphone records the room, not the loop", async ({ page }) => {
+ await installOutputTap(page);
+ const takesLog = watchTakes(page);
+ await openStudio(page);
+ await addTrack(page);
+ await waitForEngine(page);
+ const row = trackRows(page).first();
+ await chooseTrackInput(page, row, IFACE, "Input 1");
+ await waitForEngine(page);
+ await trackRows(page).first().locator(".track-arm").click();
+ const [loopTake] = await recordArmed(page, takesLog);
+ await page.locator("#recKeep").click();
+ await page.keyboard.press("Escape");
+ await waitForEngine(page);
+
+ await chooseTrackInput(page, trackRows(page).first(), MIC, null);
+ await waitForEngine(page);
+ const arm = trackRows(page).first().locator(".track-arm");
+ if ((await arm.getAttribute("aria-pressed")) !== "true") await arm.click();
+ const [micTake] = await recordArmed(page, takesLog);
+ await page.locator("#recKeep").click();
+ const loop = rawTake(loopTake.id);
+ const mic = rawTake(micTake.id);
+ const ls = wavStats(loop);
+ const ms = wavStats(mic);
+ log("interface Input 1 raw:", ls.perChannel.map((c) => `rms ${fmt(c.rms)} peak ${fmt(c.peak)}`).join(" | "));
+ log("microphone Input 1 raw:", ms.perChannel.map((c) => `rms ${fmt(c.rms)} peak ${fmt(c.peak)}`).join(" | "));
+ // Different signals: the normalised cross-correlation of the two takes,
+ // searched over +-50 ms of lag, stays low.
+ const a = wavChannel(loop);
+ const b = wavChannel(mic);
+ const n = Math.min(a.length, b.length) - 4410;
+ let best = 0;
+ const energy = (x, off) => { let s = 0; for (let i = 0; i < n; i++) s += x[i + off] * x[i + off]; return Math.sqrt(s); };
+ const ea = energy(a, 2205);
+ for (let lag = -2205; lag <= 2205; lag += 21) {
+ let s = 0;
+ for (let i = 0; i < n; i++) s += a[i + 2205] * b[i + 2205 + lag];
+ best = Math.max(best, Math.abs(s) / (ea * energy(b, 2205 + lag) || 1));
+ }
+ log("loop vs microphone: peak normalised cross-correlation", best.toFixed(3));
+ expect(best).toBeLessThan(0.5);
+ expect(micTake.id).not.toBe(loopTake.id);
+ });
+
+ test("two tracks on Input 1 and Input 2 of the interface record together", async ({ page }) => {
+ const takesLog = watchTakes(page);
+ await openStudio(page);
+ const a = await addTrack(page);
+ await waitForEngine(page);
+ const b = await addTrack(page);
+ await waitForEngine(page);
+ await chooseTrackInput(page, trackRows(page).nth(0), IFACE, "Input 1");
+ await waitForEngine(page);
+ await chooseTrackInput(page, trackRows(page).nth(1), IFACE, "Input 2");
+ await waitForEngine(page);
+ await trackRows(page).nth(0).locator(".track-arm").click();
+ await trackRows(page).nth(1).locator(".track-arm").click();
+ const takes = await recordArmed(page, takesLog, 4000);
+ expect(takes.length).toBe(2);
+ const byTrack = Object.fromEntries(takes.map((t) => [t.track_id, wavStats(rawTake(t.id))]));
+ log("track on Input 1:", fmt(byTrack[a].perChannel[0].rms), "peak", fmt(byTrack[a].perChannel[0].peak));
+ log("track on Input 2:", fmt(byTrack[b].perChannel[0].rms), "peak", fmt(byTrack[b].perChannel[0].peak));
+ expect(byTrack[a].perChannel[0].rms - byTrack[b].perChannel[0].rms).toBeGreaterThan(25);
+ expect(byTrack[b].perChannel[0].rms).toBeLessThan(-85);
+ await page.locator("#recKeep").click();
+ });
+});
+
+test.describe("real audio hardware: what the user sees and hears", () => {
+ test.setTimeout(180_000);
+
+ test("armed track on the interface: meter, monitoring, take, playback", async ({ page }) => {
+ await installOutputTap(page);
+ const takesLog = watchTakes(page);
+ await openStudio(page);
+ await addTrack(page);
+ await waitForEngine(page);
+ await chooseTrackInput(page, trackRows(page).first(), IFACE, "Input 1");
+ await waitForEngine(page);
+ await trackRows(page).first().locator(".track-arm").click();
+ await page.waitForTimeout(1500);
+
+ // (a) The armed track's own meter shows the input.
+ const vu = trackRows(page).first().locator(".lane-vu");
+ const vuLevels = [];
+ for (let i = 0; i < 10; i++) {
+ vuLevels.push(await vu.evaluate((e) => parseFloat(e.style.getPropertyValue("--vu-in")) || 0));
+ await page.waitForTimeout(100);
+ }
+ log("(a) armed track meter, input level samples (% of a -60..0 dBFS scale):", vuLevels.join(","));
+ // -60 dBFS is an empty meter: the loop shows.
+ expect(Math.max(...vuLevels)).toBeGreaterThan(3);
+ await page.screenshot({ path: shot("track-row-armed.png"), clip: await rowClip(page) });
+
+ // (b) Monitoring: what reaches the speakers with the transport stopped.
+ const silent = await page.evaluate(() => window.__outLevel(1000));
+ await page.locator("#t-rec-opts").click();
+ await expect(page.locator("#recPanel")).toBeVisible();
+ await page.waitForTimeout(1500);
+ const panelMeter = await page.locator("#recMeterFill").evaluate((e) => e.style.transform);
+ log("(a) recording panel meter:", panelMeter);
+ expect(parseFloat(panelMeter.replace(/[^0-9.]/g, ""))).toBeGreaterThan(0.03);
+ await page.locator("#recMonitor").check();
+ await page.waitForTimeout(500);
+ const monitored = await page.evaluate(() => window.__outLevel(1500));
+ log("(b) output, monitor off:", fmt(silent.rms), "| monitor on:", fmt(monitored.rms), "peak", fmt(monitored.peak));
+ expect(silent.rms).toBeLessThan(-90);
+ expect(monitored.rms - silent.rms).toBeGreaterThan(20);
+ await page.locator("#recMonitor").uncheck();
+ await page.keyboard.press("Escape");
+
+ // (c) The take.
+ const [take] = await recordArmed(page, takesLog);
+ const raw = wavStats(rawTake(take.id));
+ log("(c) take raw:", raw.perChannel.map((c) => `rms ${fmt(c.rms)} peak ${fmt(c.peak)}`).join(" | "));
+ expect(raw.perChannel[0].rms).toBeGreaterThan(-80);
+ await page.locator("#recKeep").click();
+ await page.keyboard.press("Escape");
+ await waitForEngine(page);
+
+ // (d) The kept take plays in its lane: solo it and play.
+ await trackRows(page).first().locator(".solo").click();
+ await page.evaluate(async () => {
+ const st = await import("/js/state.js");
+ st.audioEngine?.setTime?.(0);
+ });
+ await page.locator("#t-play").click();
+ await page.waitForTimeout(800);
+ const played = await page.evaluate(() => window.__outLevel(2500));
+ await page.locator("#t-play").click();
+ log("(d) output playing the soloed take:", fmt(played.rms), "peak", fmt(played.peak));
+ expect(played.rms - silent.rms).toBeGreaterThan(20);
+ });
+});
+
+test.describe("real audio hardware: the track rows", () => {
+ test("a voice track and an instrument track, as the user sees them", async ({ page }) => {
+ await page.setViewportSize({ width: 1600, height: 1000 });
+ await openStudio(page);
+ await addTrack(page);
+ await waitForEngine(page);
+ await addTrack(page);
+ await waitForEngine(page);
+ const options = await trackRows(page).first().locator(".track-input").evaluate((s) => [...s.querySelectorAll("optgroup")].map((g) => ({
+ label: g.label, options: [...g.querySelectorAll("option")].map((o) => o.textContent),
+ })));
+ log("track input list:", JSON.stringify(options));
+ expect(options.map((g) => g.options.length)).toEqual([1, 3]);
+ await chooseTrackInput(page, trackRows(page).nth(0), IFACE, "Input 1");
+ await waitForEngine(page);
+ await chooseTrackInput(page, trackRows(page).nth(1), MIC, null);
+ await waitForEngine(page);
+ await trackRows(page).nth(0).locator(".track-arm").click();
+ await page.waitForTimeout(1500);
+ // Both choices read in full: nothing is cut to an ellipsis.
+ for (const i of [0, 1]) {
+ const fits = await trackRows(page).nth(i).locator(".track-input").evaluate((s) => s.scrollWidth <= s.clientWidth + 1);
+ expect(fits, `track ${i + 1}'s input reads in full`).toBe(true);
+ }
+ const job = await jobState(page);
+ log("stored inputs:", JSON.stringify(job.tracks.map((t) => t.input)));
+ expect(job.tracks[0].input).toMatchObject({ device_label: IFACE, channels: [0] });
+ expect(job.tracks[1].input.channels).toEqual([0]);
+ await page.screenshot({ path: shot("track-rows.png"), clip: await rowClip(page) });
+ // Survives a reload: the inputs come back from the server.
+ await page.reload();
+ await openStudio(page);
+ await expect.poll(() => trackRows(page).nth(0).locator(".track-input option:checked").textContent(), { timeout: 15000 }).toBe(`${IFACE} · Input 1`);
+ await expect(trackRows(page).nth(1).locator(".track-input option:checked")).toContainText(MIC);
+ });
+});
+
+test.describe("real audio hardware: Settings > Recording", () => {
+ test("one input list, voice and instrument, and the meter moves on the interface", async ({ page }) => {
+ await page.setViewportSize({ width: 1400, height: 900 });
+ await openStudio(page);
+ await page.locator("#settingsBtn").click();
+ await page.locator('.settings-tab[data-tab="recording"]').click();
+ const sel = page.locator("#setRecInput");
+ await expect.poll(async () => sel.locator("optgroup").count(), { timeout: 15000 }).toBe(2);
+ const groups = await sel.evaluate((s) => [...s.querySelectorAll("optgroup")].map((g) => ({
+ label: g.label, options: [...g.querySelectorAll("option")].map((o) => o.textContent),
+ })));
+ log("Settings input list:", JSON.stringify(groups));
+ // No alias rows, no USB ids, each device once.
+ const all = groups.flatMap((g) => g.options);
+ expect(all.some((o) => /default|communications|\([0-9a-f]{4}:[0-9a-f]{4}\)/i.test(o))).toBe(false);
+ expect(new Set(all).size).toBe(all.length);
+ expect(groups[0].options.some((o) => o.includes(MIC))).toBe(true);
+ expect(groups[1].options.some((o) => o.includes(IFACE))).toBe(true);
+
+ const value = await sel.evaluate((s, dev) => [...s.options].find((o) => o.textContent.includes(dev))?.value, IFACE);
+ await sel.selectOption(value);
+ await page.waitForTimeout(2000);
+ let best = 0;
+ for (let i = 0; i < 15; i++) {
+ const tr = await page.locator(".rec-settings-meter .rec-meter-fill").evaluate((e) => e.style.transform);
+ best = Math.max(best, parseFloat(tr.replace(/[^0-9.]/g, "")) || 0);
+ await page.waitForTimeout(100);
+ }
+ log("Settings meter on the interface, best of 15 samples:", best);
+ expect(best).toBeGreaterThan(0.03);
+ await page.locator(".library-editor").first().screenshot({ path: shot("settings-recording.png") }).catch(async () => {
+ await page.screenshot({ path: shot("settings-recording.png") });
+ });
+ });
+});
diff --git a/tests/e2e/recording-tracks.spec.mjs b/tests/e2e/recording-tracks.spec.mjs
new file mode 100644
index 00000000..ddeea6f9
--- /dev/null
+++ b/tests/e2e/recording-tracks.spec.mjs
@@ -0,0 +1,367 @@
+// Recording tracks, DAW style (static/js/capture/takeLanes.js).
+//
+// "+ Track" adds an empty track below the stems; each track has an arm
+// button, an input (one channel or a stereo pair of a device), a menu, and a
+// folder of its takes (take-folders.spec.mjs covers the folder itself). Record records into every armed track; each recording is a new
+// take that becomes the track's active take, and earlier takes stay
+// selectable. Export sends the track, which the server resolves to its active
+// take.
+//
+// Chromium's fake capture devices ("Fake Audio Input 1" and 2) are stereo and
+// have no microphone in their name, so they are listed as instruments:
+// "Fake Audio Input 1 · Input 1", "· Input 2" and "· Input 1+2". A device is
+// added to the device list where a test needs another one.
+import { test, expect } from "@playwright/test";
+import { openStudio, JOB_ID } from "./helpers.mjs";
+import {
+ recBtn, trackRows, removeTracks, jobState, addTrack, waitForEngine, recordForAWhile, watchTakes,
+} from "./recording-helpers.mjs";
+
+test.use({
+ launchOptions: {
+ args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"],
+ },
+ permissions: ["microphone"],
+});
+
+test.afterEach(async ({ request }) => removeTracks(request));
+
+/** Every visible mixer row and waveform lane, top to bottom. */
+async function lanes(page) {
+ return page.evaluate(() => {
+ const visible = (sel) => [...document.querySelectorAll(sel)]
+ .filter((e) => !e.classList.contains("hidden") && e.getBoundingClientRect().height > 0)
+ .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
+ const top = (e) => Math.round(e.getBoundingClientRect().top);
+ const mix = visible(".mixer-column .lane-header[data-stem]");
+ const wave = visible(".stem-waveform-row[data-stem]");
+ return {
+ mixNames: mix.map((e) => e.dataset.stem),
+ waveNames: wave.map((e) => e.dataset.stem),
+ mixTops: mix.map(top),
+ waveTops: wave.map(top),
+ };
+ });
+}
+
+test("+ Track adds an empty track below the stems, aligned with its lane", async ({ page }) => {
+ await page.setViewportSize({ width: 1600, height: 1000 });
+ await openStudio(page);
+ await expect(page.locator("#addTrackBtn")).toBeEnabled();
+ const id1 = await addTrack(page);
+ await waitForEngine(page);
+ const id2 = await addTrack(page);
+ await waitForEngine(page);
+ expect(id1).toMatch(/^k[0-9a-f]{8}$/);
+
+ await expect(trackRows(page).locator(".mx-name")).toHaveText(["Track 1", "Track 2"]);
+ await expect(trackRows(page).first()).toHaveClass(/track-empty/);
+ const job = await jobState(page);
+ expect(job.tracks.map((t) => t.name)).toEqual(["Track 1", "Track 2"]);
+
+ // Below the stems, in track order, and row for row with the waveform lanes.
+ await expect.poll(async () => (await lanes(page)).waveNames.length, { timeout: 10000 }).toBe(6);
+ const g = await lanes(page);
+ expect(g.mixNames).toEqual(["vocals", "drums", "bass", "other", id1, id2]);
+ expect(g.waveNames).toEqual(g.mixNames);
+ for (let i = 0; i < g.mixTops.length; i++) {
+ expect(Math.abs(g.mixTops[i] - g.waveTops[i]), `row ${i}`).toBeLessThanOrEqual(1);
+ }
+
+ // Rename from the track menu.
+ await trackRows(page).nth(1).locator(".take-menu-btn").click();
+ await page.locator(".take-menu .take-name-input").fill("Guitar");
+ await page.locator(".take-menu .take-apply").click();
+ await expect(trackRows(page).nth(1).locator(".mx-name")).toHaveText("Guitar", { timeout: 20000 });
+});
+
+test("the input selector lists each channel and the stereo pair", async ({ page }) => {
+ await openStudio(page);
+ await addTrack(page);
+ await waitForEngine(page);
+ const select = trackRows(page).first().locator(".track-input");
+ const dev = "Fake Audio Input 1";
+ await expect.poll(() => select.locator("option").allTextContents(), { timeout: 10000 })
+ .toEqual(expect.arrayContaining([`${dev} · Input 1`, `${dev} · Input 2`, `${dev} · Input 1+2`]));
+ // Grouped by kind; no alias row ("Default", "Communications") and no repeat.
+ const texts = await select.locator("option").allTextContents();
+ expect(new Set(texts).size).toBe(texts.length);
+ expect(texts.some((x) => /default|communications/i.test(x))).toBe(false);
+ await expect(select.locator("optgroup")).toHaveAttribute("label", "Instrument (audio interface)");
+ // A stereo interface defaults to the pair.
+ await expect(select.locator("option:checked")).toHaveText(`${dev} · Input 1+2`);
+
+ // Choosing a single channel is stored with the track and records mono.
+ const log = watchTakes(page);
+ const value = await select.locator("option", { hasText: /^Fake Audio Input 1 · Input 2$/ }).getAttribute("value");
+ await select.selectOption(value);
+ await expect.poll(async () => (await jobState(page)).tracks[0].input?.channels).toEqual([1]);
+ await recordForAWhile(page, 1500);
+ await expect.poll(() => log.finishStatus.at(-1), { timeout: 20000 }).toBe(200);
+ expect(log.finishes.at(-1).body.capture.channels).toBe(1);
+});
+
+test("a second take becomes active, the first stays selectable, export sends the active one", async ({ page }) => {
+ test.setTimeout(90_000);
+ const log = watchTakes(page);
+ await openStudio(page, { tauri: true });
+ const trackId = await addTrack(page);
+ await waitForEngine(page);
+
+ await recordForAWhile(page, 1500);
+ await expect.poll(() => log.finishStatus.length, { timeout: 20000 }).toBe(1);
+ await page.locator("#recKeep").click();
+ await page.keyboard.press("Escape");
+ await waitForEngine(page);
+ // Longer than the first, so the two takes cannot render to the same bytes:
+ // the fake device plays the same beep each time, and two takes of one
+ // length from one start point can come out identical.
+ await recordForAWhile(page, 3500);
+ await expect.poll(() => log.finishStatus.length, { timeout: 20000 }).toBe(2);
+ await page.locator("#recKeep").click();
+ await page.keyboard.press("Escape");
+
+ // Both takes went to the same track; the second is active.
+ expect(log.created.map((c) => c.track_id)).toEqual([trackId, trackId]);
+ let job = await jobState(page);
+ const [take1, take2] = job.takes.map((t) => t.id);
+ expect(job.tracks[0].active_take_id).toBe(take2);
+
+ // The track's take folder lists both, newest on top, the second playing;
+ // playing the first makes it the track's take.
+ await waitForEngine(page);
+ const takeRows = page.locator(`.take-folder-row[data-folder-of="${trackId}"]`);
+ await expect(takeRows).toHaveCount(2, { timeout: 15000 });
+ await expect(takeRows.locator(".take-folder-name")).toHaveText(["Take 2", "Take 1"]);
+ await expect(takeRows.nth(0).locator(".take-play")).toHaveAttribute("aria-pressed", "true");
+ const reloaded = page.waitForRequest((r) => r.url().includes(`/takes/${take1}/audio.wav`), { timeout: 20000 });
+ await takeRows.nth(1).locator(".take-play").click();
+ await reloaded;
+ job = await jobState(page);
+ expect(job.tracks[0].active_take_id).toBe(take1);
+
+ // Export names the track, not a take; the server mixes the active take.
+ await waitForEngine(page);
+ await page.locator("#t-export-btn").click();
+ await page.locator("#t-export-mix").click();
+ await page.evaluate(() => window.__e2e.choosePath());
+ await expect.poll(() => page.evaluate(() => window.__e2e.callsFor("download_to_path").length)).toBe(1);
+ const url = new URL(await page.evaluate(() => window.__e2e.callsFor("download_to_path")[0].args.url));
+ const stems = url.searchParams.get("stems").split(",");
+ expect(stems).toContain(trackId);
+ expect(stems).not.toContain(take1);
+ expect(stems).not.toContain(take2);
+ // What the track lane renders is the active take, and only that one.
+ const mix = async (lane) => (await page.request.get(`/api/jobs/${JOB_ID}/mixdown.wav?stems=${lane}&gains=1`)).body();
+ const viaTrack = await mix(trackId);
+ expect(viaTrack.equals(await mix(take1))).toBe(true);
+ expect(viaTrack.equals(await mix(take2))).toBe(false);
+});
+
+test("tracks on two different devices cannot be armed together", async ({ page }) => {
+ // A microphone next to Chromium's fake devices.
+ await page.addInitScript(() => {
+ const real = navigator.mediaDevices.enumerateDevices.bind(navigator.mediaDevices);
+ navigator.mediaDevices.enumerateDevices = async () => {
+ const list = await real();
+ const extra = {
+ deviceId: "e2e-second-device", groupId: "e2e", kind: "audioinput", label: "Microphone (E2E Webcam) (046d:0893)",
+ getCapabilities: () => ({ channelCount: { min: 1, max: 2 } }),
+ toJSON() { return this; },
+ };
+ return [...list, extra];
+ };
+ // It opens as Chromium's fake device: arming opens the track's input.
+ const gum = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
+ navigator.mediaDevices.getUserMedia = (c) => {
+ if (c?.audio?.deviceId?.exact === "e2e-second-device") {
+ const { deviceId: _d, ...rest } = c.audio;
+ return gum({ ...c, audio: rest });
+ }
+ return gum(c);
+ };
+ });
+ await openStudio(page);
+ await addTrack(page);
+ await waitForEngine(page);
+ await addTrack(page);
+ await waitForEngine(page);
+ const [row1, row2] = [trackRows(page).nth(0), trackRows(page).nth(1)];
+
+ // Track 2 records from the microphone: a voice input, one option, named
+ // without the "Microphone (...)" wrapper and the USB id.
+ const second = row2.locator('.track-input optgroup[label="Voice (microphone)"] option');
+ await expect(second).toHaveText(["E2E Webcam"], { timeout: 10000 });
+ await row2.locator(".track-input").selectOption(await second.first().getAttribute("value"));
+
+ // Same device: both stay armed. Another device: the first is disarmed, and
+ // the note says why.
+ await row1.locator(".track-arm").click();
+ await expect(row1.locator(".track-arm")).toHaveAttribute("aria-pressed", "true");
+ await row2.locator(".track-arm").click();
+ await expect(row2.locator(".track-arm")).toHaveAttribute("aria-pressed", "true");
+ await expect(row1.locator(".track-arm")).toHaveAttribute("aria-pressed", "false");
+ await expect(page.locator("#trackNote")).toContainText("same input device");
+
+ // Back to the first device: both can be armed.
+ const firstDev = await row2.locator(".track-input option", { hasText: /^Fake Audio Input 1 · Input 1\+2$/ }).getAttribute("value");
+ await row2.locator(".track-input").selectOption(firstDev);
+ await row1.locator(".track-arm").click();
+ await expect(row1.locator(".track-arm")).toHaveAttribute("aria-pressed", "true");
+ await expect(row2.locator(".track-arm")).toHaveAttribute("aria-pressed", "true");
+});
+
+test("two armed tracks on one device record together, one take each", async ({ page }) => {
+ const log = watchTakes(page);
+ await openStudio(page);
+ const a = await addTrack(page);
+ await waitForEngine(page);
+ const b = await addTrack(page);
+ await waitForEngine(page);
+ // Track 1 on input 1, track 2 on input 2 of the same stereo device.
+ for (const [i, label] of [[0, "Input 1"], [1, "Input 2"]]) {
+ const sel = trackRows(page).nth(i).locator(".track-input");
+ await expect.poll(() => sel.locator("option").count(), { timeout: 10000 }).toBeGreaterThan(2);
+ const value = await sel.locator("option", { hasText: new RegExp(`^Fake Audio Input 1 · ${label}$`) }).getAttribute("value");
+ await sel.selectOption(value);
+ }
+ await trackRows(page).nth(0).locator(".track-arm").click();
+ await trackRows(page).nth(1).locator(".track-arm").click();
+
+ await recBtn(page).click();
+ await expect(recBtn(page)).toHaveClass(/recording/);
+ await page.waitForTimeout(1500);
+ await recBtn(page).click();
+ await expect.poll(() => log.finishStatus.length, { timeout: 20000 }).toBe(2);
+ expect(log.finishStatus).toEqual([200, 200]);
+ expect(log.created.map((c) => c.track_id).sort()).toEqual([a, b].sort());
+ for (const f of log.finishes) expect(f.body.capture.channels).toBe(1);
+ await expect(page.locator("#recReviewText")).toContainText("new takes are saved");
+ const job = await jobState(page);
+ expect(job.tracks.every((t) => t.active_take_id)).toBe(true);
+});
+
+test("Record with no track offers to add one; with one track it arms it", async ({ page }) => {
+ await openStudio(page);
+ await recBtn(page).click();
+ await expect(page.locator("#recStatus")).toContainText("Add a recording track");
+ await expect(recBtn(page)).not.toHaveClass(/recording/);
+ await page.locator("#recStatus .rec-link").click();
+ await expect(trackRows(page)).toHaveCount(1, { timeout: 20000 });
+ await waitForEngine(page);
+ await recBtn(page).click();
+ await expect(recBtn(page)).toHaveClass(/recording/, { timeout: 10000 });
+ await expect(trackRows(page).first().locator(".track-arm")).toHaveAttribute("aria-pressed", "true");
+ await recBtn(page).click();
+});
+
+test("Record with several tracks and none armed asks to arm one", async ({ page }) => {
+ const log = watchTakes(page);
+ await openStudio(page);
+ await addTrack(page);
+ await waitForEngine(page);
+ await addTrack(page);
+ await waitForEngine(page);
+ await recBtn(page).click();
+ await expect(page.locator("#recStatus")).toContainText("Arm the tracks to record into");
+ expect(log.created.length).toBe(0);
+});
+
+test("delete track removes it and its takes", async ({ page }) => {
+ await openStudio(page);
+ await recordForAWhile(page, 1200);
+ await expect(page.locator("#recReview")).toBeVisible({ timeout: 20000 });
+ await page.locator("#recKeep").click();
+ await page.keyboard.press("Escape");
+ await waitForEngine(page);
+ await trackRows(page).first().locator(".take-menu-btn").click();
+ await page.locator(".take-menu .take-delete-track").click();
+ await page.locator(".take-menu .take-delete-track").click();
+ await expect(trackRows(page)).toHaveCount(0, { timeout: 20000 });
+ const job = await jobState(page);
+ expect(job.tracks.length).toBe(0);
+ expect(job.takes.length).toBe(0);
+});
+
+test("arming opens the input: the armed track's meter shows it, disarming closes it", async ({ page }) => {
+ await page.addInitScript(() => {
+ // Count live microphone streams, to see the input close again.
+ window.__liveMics = 0;
+ const gum = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
+ navigator.mediaDevices.getUserMedia = async (c) => {
+ const s = await gum(c);
+ for (const tr of s.getAudioTracks()) {
+ window.__liveMics++;
+ const stop = tr.stop.bind(tr);
+ tr.stop = () => { if (tr.readyState === "live") window.__liveMics--; stop(); };
+ }
+ return s;
+ };
+ });
+ await openStudio(page);
+ await addTrack(page);
+ await waitForEngine(page);
+ const row = trackRows(page).first();
+ const vuIn = () => row.locator(".lane-vu").evaluate((e) => parseFloat(e.style.getPropertyValue("--vu-in")) || 0);
+
+ await row.locator(".track-arm").click();
+ await expect(row).toHaveClass(/armed/);
+ // Chromium's fake device beeps; the meter catches a beep within a second or two.
+ await expect.poll(async () => {
+ let best = 0;
+ for (let i = 0; i < 10; i++) { best = Math.max(best, await vuIn()); await page.waitForTimeout(50); }
+ return best;
+ }, { timeout: 10000 }).toBeGreaterThan(0);
+ expect(await page.evaluate(() => window.__liveMics)).toBe(1);
+
+ await row.locator(".track-arm").click();
+ await expect(row).not.toHaveClass(/armed/);
+ await expect.poll(() => page.evaluate(() => window.__liveMics)).toBe(0);
+ await expect.poll(vuIn).toBe(0);
+});
+
+test("a track whose device is not connected says so, and Record refuses it", async ({ page }) => {
+ const log = watchTakes(page);
+ await openStudio(page);
+ const id = await addTrack(page);
+ await page.request.patch(`/api/jobs/${JOB_ID}/tracks/${id}`, {
+ data: { input: { device_id: "unplugged-id", device_label: "Scarlett 2i2", channels: [0] } },
+ });
+ await page.reload();
+ await openStudio(page);
+ const select = trackRows(page).first().locator(".track-input");
+ await expect(select.locator("option:checked")).toHaveText("Scarlett 2i2 (not connected)", { timeout: 15000 });
+ await expect(select).toHaveClass(/missing/);
+ await waitForEngine(page);
+ await recBtn(page).click();
+ await expect(page.locator("#recStatus, #trackNote")).toContainText(["not connected"], { timeout: 10000 });
+ await expect(recBtn(page)).not.toHaveClass(/recording/);
+ expect(log.created.length).toBe(0);
+
+ // Choosing a connected input clears it.
+ const value = await select.locator("option", { hasText: /^Fake Audio Input 1 · Input 1$/ }).getAttribute("value");
+ await select.selectOption(value);
+ await expect.poll(async () => (await jobState(page)).tracks[0].input?.device_label).toBe("Fake Audio Input 1");
+});
+
+test("a track keeps its input when the device id changes (another browser), by name", async ({ page }) => {
+ await openStudio(page);
+ const id = await addTrack(page);
+ await page.request.patch(`/api/jobs/${JOB_ID}/tracks/${id}`, {
+ data: { input: { device_id: "id-from-another-browser", device_label: "Fake Audio Input 2", channels: [1] } },
+ });
+ await page.reload();
+ await openStudio(page);
+ await expect(trackRows(page).first().locator(".track-input option:checked"))
+ .toHaveText("Fake Audio Input 2 · Input 2", { timeout: 15000 });
+});
+
+test("Settings > Recording lists each input once, by kind, without the default aliases", async ({ page }) => {
+ await openStudio(page);
+ await page.locator("#settingsBtn").click();
+ await page.locator('.settings-tab[data-tab="recording"]').click();
+ const sel = page.locator("#setRecInput");
+ await expect.poll(() => sel.locator("option").allTextContents(), { timeout: 10000 })
+ .toEqual(["Fake Audio Input 1", "Fake Audio Input 2"]);
+ await expect(sel.locator("optgroup")).toHaveAttribute("label", "Instrument (audio interface)");
+});
diff --git a/tests/e2e/recording.spec.mjs b/tests/e2e/recording.spec.mjs
new file mode 100644
index 00000000..cd605f81
--- /dev/null
+++ b/tests/e2e/recording.spec.mjs
@@ -0,0 +1,343 @@
+// Recording a take over the song (static/js/capture/).
+//
+// Chromium's fake capture device stands in for a microphone, so the whole
+// browser side runs for real: permission, the capture graph in the playback
+// AudioContext, the recorder worklet, the chunked part upload, the sidecar,
+// and the take coming back as its recording track's audio, which mixes and
+// exports like a stem. The takes and tracks API is the real backend
+// (app/api/takes.py); every test removes what it recorded.
+//
+// Recording tracks themselves (adding, arming, inputs, several takes) are
+// covered in recording-tracks.spec.mjs.
+//
+// What this cannot check: alignment by ear, real device latency, and the
+// desktop webviews' permission prompts (no Tauri in the loop).
+import { test, expect } from "@playwright/test";
+import { openStudio, waitForClickTrack } from "./helpers.mjs";
+import {
+ recBtn, trackRows, removeTracks, jobState, addTrack, waitForEngine, pressRecord, recordForAWhile, watchTakes,
+} from "./recording-helpers.mjs";
+
+test.use({
+ launchOptions: {
+ args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"],
+ },
+ permissions: ["microphone"],
+});
+
+test.afterEach(async ({ request }) => removeTracks(request));
+
+const lastFinish = (log) => log.finishes.at(-1)?.body ?? null;
+
+test.describe("recording a take", () => {
+ test("record, stop, keep: the take plays on its track, mixes and exports", async ({ page }) => {
+ const log = watchTakes(page);
+ await openStudio(page, { tauri: true });
+
+ // The panel opens the input: a live meter needs it.
+ await page.locator("#t-rec-opts").click();
+ await expect(page.locator("#recPanel")).toBeVisible();
+ await expect(page.locator("#recPermission")).toBeHidden();
+ // The input and the latency test live in Settings now; the panel links there.
+ await expect(page.locator("#recSettingsLink")).toBeVisible();
+ await expect(page.locator("#recPanel select, #recPanel .rec-cal-progress")).toHaveCount(0);
+ // Choose a lane to mute while recording.
+ await page.locator('#recMuteList input[value="drums"]').check();
+ await page.keyboard.press("Escape");
+
+ await recordForAWhile(page);
+
+ // Parts went up as raw float32, in order, from seq 0, under 4 MB.
+ await expect.poll(() => log.finishStatus.at(-1), { timeout: 20000 }).toBe(200);
+ const sc = lastFinish(log);
+ const audio = log.parts;
+ expect(audio.length).toBeGreaterThan(0);
+ expect(audio.map((p) => p.seq)).toEqual(audio.map((_, i) => i));
+ for (const p of audio) {
+ expect(p.bytes).toBeLessThanOrEqual(4 * 1024 * 1024);
+ expect(p.bytes % (4 * sc.capture.channels)).toBe(0);
+ }
+
+ // The sidecar: the take began before the song (it was armed first), at
+ // the context rate, with the estimate labelled as such.
+ expect(sc.v).toBe(1);
+ expect([44100, 48000]).toContain(sc.capture.rate);
+ expect([1, 2]).toContain(sc.capture.channels);
+ expect(sc.timeline.tempo).toBe(1);
+ expect(sc.timeline.first_sample_song_sec).toBeLessThanOrEqual(0);
+ expect(sc.timeline.first_sample_song_sec).toBeGreaterThan(-1);
+ expect(sc.latency.source).toBe("estimated");
+ expect(sc.latency.user_nudge_ms).toBe(0);
+ expect(sc.latency.round_trip_ms).toBeGreaterThan(0);
+ const totalBytes = audio.reduce((s, p) => s + p.bytes, 0);
+ const seconds = totalBytes / (4 * sc.capture.channels * sc.capture.rate);
+ expect(seconds).toBeGreaterThan(2);
+ expect(sc.timeline.end_song_sec - sc.timeline.first_sample_song_sec).toBeCloseTo(seconds, 2);
+
+ // Review: the track already plays the take.
+ await expect(page.locator("#recReview")).toBeVisible();
+ await expect(page.locator("#recReviewText")).toContainText("Track 1: Take 1");
+ const job = await jobState(page);
+ const track = job.tracks[0];
+ expect(track.active_take_id).toBe(job.takes[0].id);
+ await expect(trackRows(page)).toHaveCount(1);
+ await expect(trackRows(page).first().locator(".mx-name")).toHaveText("Track 1");
+ await page.waitForFunction(
+ async (id) => !!(await import("/js/state.js")).audioEngine?.getAnalyser(id),
+ track.id,
+ { timeout: 20000 },
+ );
+ // The mute for the take did not stick: the lanes reloaded from the saved mix.
+ expect(await page.evaluate(async () => (await import("/js/state.js")).mixerState.drums.muted)).toBe(false);
+
+ await page.locator("#recKeep").click();
+ await expect(page.locator("#recReview")).toBeHidden();
+ await expect(page.locator("#recStatus")).toHaveText("Take kept.");
+ // The panel stays open after Keep; close it, as clicking away does, so
+ // it is not over the lanes.
+ await page.keyboard.press("Escape");
+ await expect(page.locator("#recPanel")).toBeHidden();
+
+ // Mute and solo work on the track lane like on a stem.
+ const row = trackRows(page).first();
+ await row.locator(".solo").click();
+ expect(await page.evaluate(async (id) => (await import("/js/state.js")).mixerState[id].soloed, track.id)).toBe(true);
+ await row.locator(".solo").click();
+ await row.locator(".mute").click();
+ expect(await page.evaluate(async (id) => (await import("/js/state.js")).mixerState[id].muted, track.id)).toBe(true);
+ await row.locator(".mute").click();
+
+ // Export: the track is a lane in the mixdown request.
+ await page.locator("#t-export-btn").click();
+ await page.locator("#t-export-mix").click();
+ await page.evaluate(() => window.__e2e.choosePath());
+ await expect.poll(() => page.evaluate(() => window.__e2e.callsFor("download_to_path").length)).toBe(1);
+ const url = await page.evaluate(() => window.__e2e.callsFor("download_to_path")[0].args.url);
+ expect(new URL(url).searchParams.get("stems").split(",")).toContain(track.id);
+ });
+
+ test("with a count-in, the take starts before the song and keeps the count", async ({ page }) => {
+ const log = watchTakes(page);
+ await openStudio(page);
+ await addTrack(page);
+ await waitForEngine(page);
+ await waitForClickTrack(page);
+ await page.locator("#t-metro-countin").selectOption("1");
+ await page.evaluate(async () => {
+ const st = await import("/js/state.js");
+ window.__countIns = 0;
+ const m = st.metronome;
+ const handOver = m.playCountIn.bind(m);
+ m.playCountIn = (clicks) => { window.__countIns++; return handOver(clicks); };
+ });
+ await recordForAWhile(page, 3500);
+ await expect.poll(() => log.finishStatus.at(-1), { timeout: 20000 }).toBe(200);
+ // The transport's own count-in ran, unchanged.
+ expect(await page.evaluate(() => window.__countIns)).toBe(1);
+ const sc = lastFinish(log);
+ const tl = sc.timeline;
+ expect(tl.count_in_sec).toBeGreaterThan(0.3);
+ // Frame 0 sits one count-in (plus the round trip and the worklet's
+ // latency, both small) before song time 0.
+ const lat = (sc.latency.round_trip_ms + sc.latency.pipeline_latency_ms) / 1000;
+ expect(tl.first_sample_song_sec).toBeCloseTo(-tl.count_in_sec - lat, 1);
+ });
+
+ test("the full-decode engine records and places a take too", async ({ page }) => {
+ // Both engines expose getOutputMapping(); this runs the other one.
+ await page.addInitScript(() => localStorage.setItem("stemdeck.audioEngine", "fulldecode"));
+ const log = watchTakes(page);
+ await openStudio(page);
+ await recordForAWhile(page, 2000);
+ await expect.poll(() => log.finishStatus.at(-1), { timeout: 20000 }).toBe(200);
+ const sc = lastFinish(log);
+ expect(sc.timeline.first_sample_song_sec).toBeLessThanOrEqual(0);
+ expect(sc.timeline.first_sample_song_sec).toBeGreaterThan(-1);
+ await expect(trackRows(page).first()).not.toHaveClass(/track-empty/, { timeout: 20000 });
+ });
+
+ test("discard deletes the take and the track is empty again", async ({ page }) => {
+ const log = watchTakes(page);
+ await openStudio(page);
+ await recordForAWhile(page, 1500);
+ await expect(page.locator("#recReview")).toBeVisible({ timeout: 20000 });
+ await expect(trackRows(page).first()).not.toHaveClass(/track-empty/, { timeout: 20000 });
+ await page.locator("#recDiscard").click();
+ await expect.poll(() => log.deletes.length).toBe(1);
+ await expect(page.locator("#recStatus")).toHaveText("Take discarded.");
+ // The track stays, with nothing on it.
+ await expect(trackRows(page)).toHaveCount(1);
+ await expect(trackRows(page).first()).toHaveClass(/track-empty/, { timeout: 20000 });
+ const job = await jobState(page);
+ expect(job.takes.length).toBe(0);
+ expect(job.tracks[0].active_take_id).toBeNull();
+ });
+
+ test("a nudge from the take's menu is sent to the server and the lane reloads", async ({ page }) => {
+ const log = watchTakes(page);
+ await openStudio(page);
+ await recordForAWhile(page, 1500);
+ await expect(trackRows(page).first()).not.toHaveClass(/track-empty/, { timeout: 20000 });
+ await page.locator("#recKeep").click();
+ await page.keyboard.press("Escape");
+ await expect(page.locator("#recPanel")).toBeHidden();
+ const takeId = (await jobState(page)).takes[0].id;
+
+ // One take: its folder starts closed; the arrow opens it.
+ await trackRows(page).first().locator(".take-folder-toggle").click();
+ await page.locator(`.take-folder-row[data-take-id="${takeId}"] .take-folder-menu-btn`).click();
+ const menu = page.locator(".take-menu");
+ await expect(menu).toBeVisible();
+ await menu.locator(".take-nudge-input").fill("12");
+ const reloaded = page.waitForRequest((r) => r.url().includes(`/takes/${takeId}/audio.wav?n=12`), { timeout: 20000 });
+ await menu.locator(".take-apply").click();
+ await expect.poll(() => log.patches.length).toBe(1);
+ expect(log.patches[0]).toEqual({ user_nudge_ms: 12 });
+ // The reloaded lane asks for the re-rendered file, not the cached one.
+ await reloaded;
+ });
+
+ test("Record refuses a tempo other than 1x and creates nothing", async ({ page }) => {
+ const log = watchTakes(page);
+ await openStudio(page);
+ await addTrack(page);
+ await waitForEngine(page);
+ await page.evaluate(async () => (await import("/js/state.js")).setPlaybackSpeed(0.5));
+ await recBtn(page).click();
+ await expect(page.locator("#recStatus")).toHaveText(/normal speed/);
+ expect(log.created.length).toBe(0);
+ await expect(recBtn(page)).not.toHaveClass(/recording/);
+ });
+
+ test("a stop before the song starts saves nothing", async ({ page }) => {
+ const log = watchTakes(page);
+ await openStudio(page);
+ await addTrack(page);
+ await waitForEngine(page);
+ // Hold the engine's clock: the take can never be placed against the song.
+ await page.evaluate(async () => {
+ const st = await import("/js/state.js");
+ st.audioEngine.isClockReady = () => false;
+ });
+ await pressRecord(page);
+ await page.waitForTimeout(600);
+ await recBtn(page).click();
+ await expect(page.locator("#recStatus")).toHaveText(/nothing was saved/, { timeout: 10000 });
+ expect(log.finishes.length).toBe(0);
+ await expect.poll(() => log.deletes.length).toBe(1);
+ });
+});
+
+test.describe("recording without a microphone", () => {
+ test("a refused microphone says so, in the panel", async ({ page }) => {
+ await page.addInitScript(() => {
+ navigator.mediaDevices.getUserMedia = () => Promise.reject(new DOMException("denied", "NotAllowedError"));
+ });
+ const log = watchTakes(page);
+ await openStudio(page);
+ await addTrack(page);
+ await waitForEngine(page);
+ await recBtn(page).click();
+ await expect(page.locator("#recPanel")).toBeVisible();
+ await expect(page.locator("#recPermission .capture-reason")).toHaveAttribute("data-reason", "denied");
+ await expect(page.locator("#recPermission")).toContainText("Microphone access was blocked");
+ expect(log.created.length).toBe(0);
+ });
+
+ test("no device found says so", async ({ page }) => {
+ await page.addInitScript(() => {
+ navigator.mediaDevices.getUserMedia = () => Promise.reject(new DOMException("none", "NotFoundError"));
+ });
+ await openStudio(page);
+ await addTrack(page);
+ await waitForEngine(page);
+ await recBtn(page).click();
+ await expect(page.locator("#recPermission .capture-reason")).toHaveAttribute("data-reason", "no-device");
+ // And points at where an input is chosen.
+ await page.locator("#recPermission .rec-link").click();
+ await expect(page.locator('.settings-tab[data-tab="recording"]')).toHaveClass(/active/);
+ });
+});
+
+// Settings > Recording: the default input, the latency test and the camera
+// are set there, once, and the recorder reads the same stored choices.
+test.describe("Settings, Recording tab", () => {
+ const openTab = async (page) => {
+ await page.locator("#settingsBtn").click();
+ await page.locator('.settings-tab[data-tab="recording"]').click();
+ await expect(page.locator('.settings-pane[data-pane="recording"]')).toBeVisible();
+ };
+
+ test("picks an input, runs the latency test, and a stored calibration reaches the take", async ({ page }) => {
+ const log = watchTakes(page);
+ await openStudio(page);
+ await openTab(page);
+
+ // Picking an input stores it under the key new tracks default to.
+ const sel = page.locator("#setRecInput");
+ await expect.poll(() => sel.locator("option").count(), { timeout: 10000 }).toBeGreaterThan(1);
+ const id = await sel.locator("option").nth(1).getAttribute("value");
+ await sel.selectOption(id);
+ expect(await page.evaluate(() => localStorage.getItem("stemdeck:capture:device:audioinput"))).toBe(id);
+ await expect(page.locator("#setRecLatency")).toHaveText(/estimated/, { timeout: 10000 });
+
+ // The latency test plays its clicks and finishes. The fake device hears
+ // no loopback, so the outcome here is one of the three answers, not a
+ // number: the measurement itself is pinned by tests/js/take-math.test.mjs.
+ await page.locator("#setRecCalibrate").click();
+ await expect(page.locator("#setRecCalStatus")).toHaveText(/Listening/);
+ await expect(page.locator("#setRecCalStatus")).toHaveText(
+ /Round trip|No clicks were heard|did not line up/, { timeout: 15000 },
+ );
+ await expect(page.locator("#setRecCalibrate")).toBeEnabled();
+
+ // One source of truth: a calibration stored for this input and rate (as
+ // a successful test stores it) is what Settings shows and what a take on
+ // a track using that input uses.
+ await page.evaluate(async (deviceId) => {
+ const { createPlaybackContext } = await import("/js/audioContext.js");
+ const ctx = createPlaybackContext(window.AudioContext);
+ const rate = ctx.sampleRate;
+ ctx.close();
+ const s = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: { exact: deviceId } } });
+ const real = s.getAudioTracks()[0].getSettings().deviceId || deviceId;
+ s.getTracks().forEach((t) => t.stop());
+ localStorage.setItem(`stemdeck:capture:latency:${real}@${rate}`,
+ JSON.stringify({ roundTripMs: 12.3, driftPpm: 0, rate, measuredAt: Date.now() }));
+ }, id);
+ await page.locator('.settings-tab[data-tab="general"]').click();
+ await page.locator('.settings-tab[data-tab="recording"]').click();
+ await expect(page.locator("#setRecLatency")).toHaveText(/12\.3 ms, calibrated/, { timeout: 10000 });
+ await page.locator(".settings-done").click();
+
+ // A new track starts on that input.
+ await recordForAWhile(page, 1500);
+ await expect.poll(() => log.finishStatus.at(-1), { timeout: 20000 }).toBe(200);
+ const sc = lastFinish(log);
+ expect(sc.latency.source).toBe("calibrated");
+ expect(sc.latency.round_trip_ms).toBeCloseTo(12.3, 1);
+ expect((await jobState(page)).tracks[0].input.device_id).toBe(id);
+ });
+
+ test("leaving the tab or closing Settings releases the microphone", async ({ page }) => {
+ await page.addInitScript(() => {
+ window.__liveMics = [];
+ const real = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
+ navigator.mediaDevices.getUserMedia = async (c) => {
+ const s = await real(c);
+ if (c?.audio) window.__liveMics.push(s);
+ return s;
+ };
+ });
+ await openStudio(page);
+ const live = () => page.evaluate(() => window.__liveMics.filter((s) => s.getTracks().some((t) => t.readyState === "live")).length);
+ await openTab(page);
+ await expect.poll(live, { timeout: 10000 }).toBe(1);
+ await page.locator('.settings-tab[data-tab="export"]').click();
+ await expect.poll(live).toBe(0);
+ await page.locator('.settings-tab[data-tab="recording"]').click();
+ await expect.poll(live, { timeout: 10000 }).toBe(1);
+ await page.keyboard.press("Escape");
+ await expect.poll(live).toBe(0);
+ });
+});
diff --git a/tests/e2e/take-folders-live.spec.mjs b/tests/e2e/take-folders-live.spec.mjs
new file mode 100644
index 00000000..9c0d47e9
--- /dev/null
+++ b/tests/e2e/take-folders-live.spec.mjs
@@ -0,0 +1,222 @@
+// The live waveform (capture/liveWave.js) inside a take folder
+// (capture/takeLanes.js): recording on a track that already has takes.
+//
+// While it records, the live bars draw over the track's own lane, anchored to
+// its .take-live-slot, with the folder open or closed, and the previous take's
+// bars are hidden there. After Stop the new take is the newest take lane and
+// the one that plays, and the live bars hand over to the server's with no
+// jump: bar for bar the same heights, measured at the moment of the switch in
+// the folder's layout (which gains a row, so every lane changes height).
+import { test, expect } from "@playwright/test";
+import { openStudio, JOB_ID } from "./helpers.mjs";
+import { recBtn, removeTracks, jobState, waitForEngine, watchTakes } from "./recording-helpers.mjs";
+
+test.use({
+ launchOptions: {
+ args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"],
+ },
+ permissions: ["microphone"],
+});
+
+test.afterEach(async ({ request }) => removeTracks(request));
+
+const RATE = 48000;
+
+function tone(seconds, amp) {
+ const n = Math.round(RATE * seconds);
+ const buf = Buffer.alloc(n * 4);
+ for (let i = 0; i < n; i++) buf.writeFloatLE(amp * Math.sin((2 * Math.PI * 220 * i) / RATE), i * 4);
+ return buf;
+}
+
+/** A track with two finished takes made through the takes API. */
+async function trackWithTwoTakes(request) {
+ const r = await request.post(`/api/jobs/${JOB_ID}/tracks`, { data: {} });
+ expect(r.status()).toBe(201);
+ const trackId = (await r.json()).id;
+ const takes = [];
+ for (const amp of [0.3, 0.6]) {
+ const c = await request.post(`/api/jobs/${JOB_ID}/takes`, { data: { track_id: trackId } });
+ const takeId = (await c.json()).take_id;
+ await request.put(`/api/jobs/${JOB_ID}/takes/${takeId}/parts/audio?seq=0`, {
+ data: tone(3, amp),
+ headers: { "content-type": "application/octet-stream" },
+ });
+ const fin = await request.post(`/api/jobs/${JOB_ID}/takes/${takeId}/finish`, {
+ data: { v: 1, timeline: { first_sample_song_sec: 0.5, tempo: 1.0 }, capture: { rate: RATE, channels: 1 } },
+ });
+ expect(fin.status(), await fin.text()).toBe(200);
+ takes.push(takeId);
+ }
+ return { trackId, takes };
+}
+
+/** Every visible mixer row and waveform lane, top to bottom. */
+async function stack(page) {
+ return page.evaluate(() => {
+ const visible = (sel) => [...document.querySelectorAll(sel)]
+ .filter((e) => !e.classList.contains("hidden") && e.getBoundingClientRect().height > 0)
+ .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
+ const id = (e) => e.dataset.takeId || e.dataset.stem;
+ const top = (e) => Math.round(e.getBoundingClientRect().top);
+ const mix = visible(".mixer-column .lane-header");
+ const wave = visible(".stem-waveform-layer > .stem-waveform-row");
+ return { mix: mix.map((e) => [id(e), top(e)]), wave: wave.map((e) => [id(e), top(e)]) };
+ });
+}
+
+function expectAligned(g) {
+ expect(g.wave.map((r) => r[0])).toEqual(g.mix.map((r) => r[0]));
+ for (let i = 0; i < g.mix.length; i++) {
+ expect(Math.abs(g.mix[i][1] - g.wave[i][1]), `row ${i} (${g.mix[i][0]})`).toBeLessThanOrEqual(1);
+ }
+}
+
+/** The live lane against the track's main lane, while recording. */
+async function liveOverMain(page, trackId) {
+ return page.evaluate((id) => {
+ const lane = document.querySelector(`.live-wave-lane[data-track-id="${id}"]`);
+ const row = document.querySelector(`.stem-waveform-row[data-stem="${id}"]`);
+ const slot = row?.querySelector(":scope > .take-live-slot");
+ const canvas = lane?.querySelector("canvas");
+ let pixels = 0;
+ if (canvas?.width) {
+ const { data } = canvas.getContext("2d").getImageData(0, 0, canvas.width, canvas.height);
+ for (let i = 3; i < data.length; i += 4) if (data[i] > 0) pixels++;
+ }
+ const lr = lane?.getBoundingClientRect();
+ const sr = slot?.getBoundingClientRect();
+ const svg = row?.querySelector(".stem-waveform-svg");
+ return {
+ lane: !!lane,
+ slot: !!slot,
+ pixels,
+ dTop: lr && sr ? Math.abs(lr.top - sr.top) : NaN,
+ dH: lr && sr ? Math.abs(lr.height - sr.height) : NaN,
+ oldBarsHidden: !svg || getComputedStyle(svg).visibility === "hidden",
+ };
+ }, trackId);
+}
+
+async function untilSongTime(page, sec) {
+ await page.evaluate(async () => { window.__e2eState = await import("/js/state.js"); });
+ await page.waitForFunction((t) => window.__e2eState.audioEngine?.isPlaying()
+ && window.__e2eState.audioEngine.getCurrentTime() >= t, sec, { timeout: 15000 });
+}
+
+/** Note what the live lane was, and the server's bars, when it went. */
+async function watchHandoff(page) {
+ await page.evaluate(() => {
+ window.__live = { removedWithBars: null, handoff: null, server: null };
+ document.addEventListener("stemdeck:live-wave-handoff", (e) => { window.__live.handoff = e.detail; });
+ new MutationObserver((muts) => {
+ for (const m of muts) {
+ for (const n of m.removedNodes) {
+ if (!n.classList?.contains("live-wave-lane")) continue;
+ const row = document.querySelector(`.stem-waveform-row[data-stem="${n.dataset.trackId}"]`);
+ const rects = row ? [...row.querySelectorAll(":scope > .stem-waveform-svg rect")] : [];
+ window.__live.removedWithBars = rects.length;
+ window.__live.server = {
+ bars: rects.length,
+ heights: rects.map((r) => (parseFloat(r.getAttribute("height")) / 48) * row.clientHeight),
+ };
+ }
+ }
+ }).observe(document.querySelector(".waves-column"), { childList: true });
+ });
+}
+
+function expectNoJump(live) {
+ expect(live.removedWithBars, "the server's bars were drawn when the live lane went").toBeGreaterThan(0);
+ expect(live.handoff).not.toBeNull();
+ const { server } = live;
+ expect(server.bars).toBe(live.handoff.bars);
+ const heights = Object.values(live.handoff.heights);
+ const drawn = heights.map((v, i) => (v >= 0 ? i : -1)).filter((i) => i >= 0).slice(1, -1);
+ let sum = 0;
+ let close = 0;
+ for (const i of drawn) {
+ const d = Math.abs(heights[i] - server.heights[i]);
+ sum += d;
+ if (d <= 2) close++;
+ }
+ const mean = sum / Math.max(1, drawn.length);
+ console.log(`folder handoff: ${drawn.length} bars, ${close} within 2 px, mean diff ${mean.toFixed(2)} px on a ${live.handoff.height} px lane`);
+ expect(drawn.length).toBeGreaterThan(20);
+ expect(close / drawn.length, "share of bars within 2 px").toBeGreaterThan(0.95);
+ expect(mean, "mean live vs server bar height, px").toBeLessThan(0.5);
+}
+
+for (const folder of ["open", "closed"]) {
+ test(`recording on a track with two takes, folder ${folder}: live bars on the main lane, then the newest take lane, no jump`, async ({ page, request }, testInfo) => {
+ test.setTimeout(90_000);
+ const log = watchTakes(page);
+ const { trackId, takes } = await trackWithTwoTakes(request);
+ if (folder === "closed") {
+ await page.addInitScript((id) => {
+ localStorage.setItem("stemdeck.takeFolders", JSON.stringify({ [id]: false }));
+ }, trackId);
+ }
+ await page.setViewportSize({ width: 1600, height: 1000 });
+ await openStudio(page);
+ await waitForEngine(page);
+ await expect(page.locator(`.stem-waveform-row[data-stem="${trackId}"] .stem-waveform-svg`)).toBeAttached({ timeout: 15000 });
+ await expect(page.locator("#waveLoadingOverlay")).toBeHidden({ timeout: 20000 });
+ const toggle = page.locator(`.take-folder-toggle[data-track="${trackId}"]`);
+ const takeRows = page.locator(`.mixer-column .take-folder-row[data-folder-of="${trackId}"]`);
+ await expect(toggle).toHaveAttribute("aria-expanded", folder === "open" ? "true" : "false");
+ await expect(takeRows).toHaveCount(folder === "open" ? 2 : 0);
+ await watchHandoff(page);
+
+ // Record: the only track is armed by Record.
+ await recBtn(page).click();
+ await expect(recBtn(page)).toHaveClass(/recording/, { timeout: 10000 });
+ await untilSongTime(page, 1.6);
+ const during = await liveOverMain(page, trackId);
+ if (folder === "open") {
+ await page.screenshot({ path: `${process.env.SHOT_DIR || testInfo.outputDir}/recording-folder-open-2-takes.png` });
+ }
+ expect(during.slot, "the main lane has its slot").toBe(true);
+ expect(during.lane, "a live lane while recording").toBe(true);
+ expect(during.pixels, "live bars drawn").toBeGreaterThan(0);
+ expect(during.dTop, "live lane top on the main lane").toBeLessThanOrEqual(1);
+ expect(during.dH, "live lane height is the main lane's").toBeLessThanOrEqual(1);
+ expect(during.oldBarsHidden, "the previous take's bars are hidden on the main lane").toBe(true);
+ // The take lanes keep their own bars and stay aligned while recording.
+ expectAligned(await stack(page));
+ if (folder === "open") {
+ await expect(page.locator(`.take-folder-lane[data-folder-of="${trackId}"] .stem-waveform-svg`)).toHaveCount(2);
+ }
+
+ await untilSongTime(page, 2.8);
+ await recBtn(page).click();
+ await expect(recBtn(page)).not.toHaveClass(/recording/);
+ await expect.poll(() => log.finishStatus.at(-1), { timeout: 20000 }).toBe(200);
+ await expect(page.locator("#recKeep")).toBeVisible({ timeout: 20000 });
+ await expect(page.locator(".live-wave-lane")).toHaveCount(0, { timeout: 20000 });
+ expectNoJump(await page.evaluate(() => window.__live));
+
+ // The new take plays, and is the newest in the folder.
+ const job = await jobState(page);
+ const recorded = job.tracks[0].active_take_id;
+ expect(takes).not.toContain(recorded);
+ await waitForEngine(page);
+ await expect(toggle.locator(".take-folder-count")).toHaveText("3", { timeout: 15000 });
+ if (folder === "open") {
+ await expect(takeRows).toHaveCount(3);
+ const ids = await takeRows.evaluateAll((els) => els.map((e) => e.dataset.takeId));
+ expect(ids).toEqual([recorded, takes[1], takes[0]]);
+ await expect(takeRows.first()).toHaveClass(/playing/);
+ await expect(page.locator(`.take-folder-lane[data-take-id="${recorded}"]`)).toHaveClass(/playing/);
+ } else {
+ await expect(takeRows).toHaveCount(0);
+ await expect(toggle).toHaveAttribute("aria-expanded", "false");
+ }
+ await expect(page.locator(`.stem-waveform-row[data-stem="${trackId}"]`)).not.toHaveClass(/take-recording/);
+ await expect.poll(async () => (await stack(page)).wave.length, { timeout: 10000 })
+ .toBe(folder === "open" ? 8 : 5);
+ expectAligned(await stack(page));
+ await page.locator("#recKeep").click();
+ await expect(page.locator(".live-wave-lane")).toHaveCount(0);
+ });
+}
diff --git a/tests/e2e/take-folders.spec.mjs b/tests/e2e/take-folders.spec.mjs
new file mode 100644
index 00000000..d484422d
--- /dev/null
+++ b/tests/e2e/take-folders.spec.mjs
@@ -0,0 +1,442 @@
+// Take folders (static/js/capture/takeLanes.js, takeFolderModel.js, and the
+// take lanes player.js draws). A recording track is a folder of its takes: the
+// main row plays one of them, and the arrow on the row opens one lane per take
+// under the track, newest on top, each with its own mixer row and waveform.
+//
+// The takes are made through the real takes API (create, one audio part,
+// finish), not by recording: three recordings would take most of the test's
+// time and prove nothing more about the folder. One test does record, to show
+// a fresh recording lands in the folder. Each take is a tone of a different
+// level, so their waveforms differ and "the main row follows" can be seen in
+// the drawing, not only in the request log.
+import { test, expect } from "@playwright/test";
+import { openStudio, JOB_ID } from "./helpers.mjs";
+import { trackRows, removeTracks, jobState, waitForEngine, recordForAWhile, watchTakes } from "./recording-helpers.mjs";
+
+test.use({
+ launchOptions: {
+ args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"],
+ },
+ permissions: ["microphone"],
+});
+
+test.afterEach(async ({ request }) => removeTracks(request));
+
+const RATE = 48000;
+
+/** A mono float32 tone, `seconds` long at amplitude `amp`, as the recorder sends it. */
+function tone(seconds, amp) {
+ const n = Math.round(RATE * seconds);
+ const buf = Buffer.alloc(n * 4);
+ for (let i = 0; i < n; i++) buf.writeFloatLE(amp * Math.sin((2 * Math.PI * 220 * i) / RATE), i * 4);
+ return buf;
+}
+
+async function makeTrack(request) {
+ const r = await request.post(`/api/jobs/${JOB_ID}/tracks`, { data: {} });
+ expect(r.status()).toBe(201);
+ return (await r.json()).id;
+}
+
+/** One finished take on `trackId`; it becomes the track's active take. */
+async function makeTake(request, trackId, { amp = 0.5, seconds = 3 } = {}) {
+ const r = await request.post(`/api/jobs/${JOB_ID}/takes`, { data: { track_id: trackId } });
+ expect(r.status()).toBe(201);
+ const takeId = (await r.json()).take_id;
+ const part = await request.put(`/api/jobs/${JOB_ID}/takes/${takeId}/parts/audio?seq=0`, {
+ data: tone(seconds, amp),
+ headers: { "content-type": "application/octet-stream" },
+ });
+ expect(part.status()).toBe(200);
+ const fin = await request.post(`/api/jobs/${JOB_ID}/takes/${takeId}/finish`, {
+ data: {
+ v: 1,
+ timeline: { first_sample_song_sec: 0.5, tempo: 1.0 },
+ latency: { round_trip_ms: 0, source: "estimated", user_nudge_ms: 0 },
+ capture: { rate: RATE, channels: 1, device_label: "E2E tone" },
+ },
+ });
+ expect(fin.status(), await fin.text()).toBe(200);
+ return takeId;
+}
+
+/** A track with `levels.length` takes, oldest first. Returns ids. */
+async function trackWithTakes(request, levels) {
+ const trackId = await makeTrack(request);
+ const takes = [];
+ for (const amp of levels) takes.push(await makeTake(request, trackId, { amp }));
+ return { trackId, takes };
+}
+
+const toggle = (page, trackId) => page.locator(`.take-folder-toggle[data-track="${trackId}"]`);
+const takeRows = (page, trackId) => page.locator(`.mixer-column .take-folder-row[data-folder-of="${trackId}"]`);
+const takeLanes = (page, trackId) => page.locator(`.stem-waveform-row.take-folder-lane[data-folder-of="${trackId}"]`);
+
+/**
+ * Every visible mixer row and waveform lane, top to bottom, with what each
+ * one is (a stem, a track, or a take).
+ */
+async function stack(page) {
+ return page.evaluate(() => {
+ const visible = (sel) => [...document.querySelectorAll(sel)]
+ .filter((e) => !e.classList.contains("hidden") && e.getBoundingClientRect().height > 0)
+ .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
+ const box = (e) => {
+ const b = e.getBoundingClientRect();
+ return { top: Math.round(b.top), h: Math.round(b.height) };
+ };
+ const id = (e) => e.dataset.takeId || e.dataset.stem;
+ const mix = visible(".mixer-column .lane-header");
+ const wave = visible(".stem-waveform-layer > .stem-waveform-row");
+ return {
+ mix: mix.map((e) => ({ id: id(e), ...box(e) })),
+ wave: wave.map((e) => ({ id: id(e), ...box(e) })),
+ count: Number(getComputedStyle(document.querySelector(".app")).getPropertyValue("--visible-track-count")),
+ };
+ });
+}
+
+function expectAligned(g) {
+ expect(g.wave.map((r) => r.id)).toEqual(g.mix.map((r) => r.id));
+ expect(g.count).toBe(g.mix.length);
+ for (let i = 0; i < g.mix.length; i++) {
+ expect(Math.abs(g.mix[i].top - g.wave[i].top), `row ${i} (${g.mix[i].id}) top`).toBeLessThanOrEqual(1);
+ }
+}
+
+/** The bars of a lane's waveform, as drawn (the heights of its rects). */
+async function bars(page, sel) {
+ return page.locator(sel).first().evaluate((row) => [...row.querySelectorAll(".stem-waveform-svg rect")]
+ .map((r) => Number(r.getAttribute("height"))));
+}
+
+/** Open the studio on the fixture job and wait for the lanes to be drawn. */
+async function openWithLanes(page, trackId, opts) {
+ await page.setViewportSize({ width: 1600, height: 1000 });
+ await openStudio(page, opts);
+ await waitForEngine(page);
+ await expect(page.locator(`.stem-waveform-row[data-stem="${trackId}"]`)).toHaveCount(1, { timeout: 15000 });
+}
+
+test("one take: the folder starts closed, the arrow opens and closes it, aligned both ways", async ({ page, request }) => {
+ const { trackId, takes } = await trackWithTakes(request, [0.5]);
+ await openWithLanes(page, trackId);
+
+ await expect(toggle(page, trackId)).toHaveAttribute("aria-expanded", "false");
+ await expect(toggle(page, trackId)).toBeEnabled();
+ await expect(takeRows(page, trackId)).toHaveCount(0);
+ await expect(takeLanes(page, trackId)).toHaveCount(0);
+ let g = await stack(page);
+ expect(g.mix.map((r) => r.id)).toEqual(["vocals", "drums", "bass", "other", trackId]);
+ expectAligned(g);
+
+ await toggle(page, trackId).click();
+ await expect(toggle(page, trackId)).toHaveAttribute("aria-expanded", "true");
+ await expect(takeRows(page, trackId)).toHaveCount(1);
+ await expect(takeLanes(page, trackId)).toHaveCount(1);
+ await expect.poll(async () => (await stack(page)).wave.length).toBe(6);
+ g = await stack(page);
+ expect(g.mix.map((r) => r.id)).toEqual(["vocals", "drums", "bass", "other", trackId, takes[0]]);
+ expectAligned(g);
+ // Every row is one lane high, the take rows too.
+ expect(new Set(g.mix.map((r) => r.h)).size).toBe(1);
+
+ await toggle(page, trackId).click();
+ await expect(takeRows(page, trackId)).toHaveCount(0);
+ await expect(takeLanes(page, trackId)).toHaveCount(0);
+ expectAligned(await stack(page));
+});
+
+test("three takes: open by default, newest on top, the playing one marked, every row on its lane", async ({ page, request }) => {
+ const { trackId, takes } = await trackWithTakes(request, [0.2, 0.5, 0.9]);
+ const [t1, t2, t3] = takes;
+ await openWithLanes(page, trackId);
+
+ await expect(toggle(page, trackId)).toHaveAttribute("aria-expanded", "true");
+ await expect(toggle(page, trackId).locator(".take-folder-count")).toHaveText("3");
+ await expect(takeRows(page, trackId)).toHaveCount(3);
+ const rowIds = await takeRows(page, trackId).evaluateAll((els) => els.map((e) => e.dataset.takeId));
+ expect(rowIds).toEqual([t3, t2, t1]);
+ await expect(takeRows(page, trackId).locator(".take-folder-name")).toHaveText(["Take 3", "Take 2", "Take 1"]);
+ // The newest is the take that plays: it was finished last.
+ await expect(takeRows(page, trackId).nth(0)).toHaveClass(/playing/);
+ await expect(takeRows(page, trackId).nth(0).locator(".take-play")).toHaveAttribute("aria-pressed", "true");
+ await expect(takeRows(page, trackId).nth(1).locator(".take-play")).toHaveAttribute("aria-pressed", "false");
+
+ await expect.poll(async () => (await stack(page)).wave.length, { timeout: 10000 }).toBe(8);
+ const g = await stack(page);
+ expect(g.mix.map((r) => r.id)).toEqual(["vocals", "drums", "bass", "other", trackId, t3, t2, t1]);
+ expectAligned(g);
+ // Each lane draws its own take: three levels, three different drawings.
+ const drawn = await Promise.all([t3, t2, t1].map((id) => bars(page, `.take-folder-lane[data-take-id="${id}"]`)));
+ expect(Math.max(...drawn[0])).toBeGreaterThan(Math.max(...drawn[1]));
+ expect(Math.max(...drawn[1])).toBeGreaterThan(Math.max(...drawn[2]));
+ await expect(page.locator(`.take-folder-lane[data-take-id="${t3}"]`)).toHaveClass(/playing/);
+
+ // Aligned at other window heights too, including one where the stack overflows.
+ for (const height of [720, 853, 1150]) {
+ await page.setViewportSize({ width: 1600, height });
+ await page.waitForTimeout(400);
+ expectAligned(await stack(page));
+ }
+});
+
+test("open or closed is remembered per track across a reload", async ({ page, request }) => {
+ const a = await trackWithTakes(request, [0.3, 0.6]);
+ const b = await trackWithTakes(request, [0.4, 0.8]);
+ await openWithLanes(page, b.trackId);
+ await expect(toggle(page, a.trackId)).toHaveAttribute("aria-expanded", "true");
+ await expect(toggle(page, b.trackId)).toHaveAttribute("aria-expanded", "true");
+ await toggle(page, a.trackId).click();
+ await expect(takeRows(page, a.trackId)).toHaveCount(0);
+ expectAligned(await stack(page));
+
+ await page.reload();
+ await openWithLanes(page, b.trackId);
+ await expect(toggle(page, a.trackId)).toHaveAttribute("aria-expanded", "false");
+ await expect(toggle(page, b.trackId)).toHaveAttribute("aria-expanded", "true");
+ await expect(takeRows(page, b.trackId)).toHaveCount(2);
+ await expect.poll(async () => (await stack(page)).wave.length, { timeout: 10000 }).toBe(8);
+ const g = await stack(page);
+ expect(g.mix.map((r) => r.id)).toEqual(["vocals", "drums", "bass", "other", a.trackId, b.trackId, ...[...b.takes].reverse()]);
+ expectAligned(g);
+});
+
+test("a folder still opens and closes when storage is blocked", async ({ page, request }) => {
+ await page.addInitScript(() => {
+ const blocked = (k) => String(k).startsWith("stemdeck.takeFolders");
+ const get = Storage.prototype.getItem;
+ const set = Storage.prototype.setItem;
+ Storage.prototype.getItem = function (k) { if (blocked(k)) throw new Error("storage blocked"); return get.call(this, k); };
+ Storage.prototype.setItem = function (k, v) { if (blocked(k)) throw new Error("storage blocked"); return set.call(this, k, v); };
+ });
+ const { trackId } = await trackWithTakes(request, [0.3, 0.6]);
+ await openWithLanes(page, trackId);
+ await expect(takeRows(page, trackId)).toHaveCount(2);
+ await toggle(page, trackId).click();
+ await expect(takeRows(page, trackId)).toHaveCount(0);
+ expectAligned(await stack(page));
+ await toggle(page, trackId).click();
+ await expect(takeRows(page, trackId)).toHaveCount(2);
+});
+
+test("clicking a take lane makes it the take that plays, and the main row follows", async ({ page, request }) => {
+ const log = watchTakes(page);
+ const { trackId, takes } = await trackWithTakes(request, [0.2, 0.5, 0.9]);
+ const [t1, t2] = takes;
+ await openWithLanes(page, trackId, { tauri: true });
+ const main = `.stem-waveform-row[data-stem="${trackId}"]`;
+ const lane1 = `.take-folder-lane[data-take-id="${t1}"]`;
+ await expect.poll(() => bars(page, lane1), { timeout: 10000 }).not.toEqual([]);
+ const before = await bars(page, main);
+
+ // A click on the oldest take's waveform.
+ const reloaded = page.waitForRequest((r) => r.url().includes(`/takes/${t1}/audio.wav`), { timeout: 20000 });
+ await page.locator(lane1).click();
+ await expect.poll(() => log.trackPatches.length).toBe(1);
+ expect(log.trackPatches[0]).toEqual({ active_take_id: t1 });
+ await reloaded;
+ await waitForEngine(page);
+ await expect(page.locator(`.take-folder-row[data-take-id="${t1}"]`)).toHaveClass(/playing/, { timeout: 15000 });
+ await expect(page.locator(lane1)).toHaveClass(/playing/);
+ // The track's own lane now draws the take that plays, bar for bar.
+ await expect.poll(async () => JSON.stringify(await bars(page, main)), { timeout: 10000 })
+ .toBe(JSON.stringify(await bars(page, lane1)));
+ expect(await bars(page, main)).not.toEqual(before);
+ // The folder stayed open through the reload, still newest on top.
+ await expect(takeRows(page, trackId)).toHaveCount(3);
+ expectAligned(await stack(page));
+
+ // The play control on a row does the same.
+ await page.locator(`.take-folder-row[data-take-id="${t2}"] .take-play`).click();
+ await expect.poll(async () => (await jobState(page)).tracks[0].active_take_id, { timeout: 10000 }).toBe(t2);
+ await expect(page.locator(`.take-folder-row[data-take-id="${t2}"] .take-play`)).toHaveAttribute("aria-pressed", "true", { timeout: 15000 });
+
+ // Export still names the track, never a take.
+ await waitForEngine(page);
+ await page.locator("#t-export-btn").click();
+ await page.locator("#t-export-mix").click();
+ await page.evaluate(() => window.__e2e.choosePath());
+ await expect.poll(() => page.evaluate(() => window.__e2e.callsFor("download_to_path").length)).toBe(1);
+ const url = new URL(await page.evaluate(() => window.__e2e.callsFor("download_to_path")[0].args.url));
+ const stems = url.searchParams.get("stems").split(",");
+ expect(stems).toContain(trackId);
+ for (const id of takes) expect(stems).not.toContain(id);
+});
+
+test("take rows carry no mute or solo; the track's M and S work as before", async ({ page, request }) => {
+ const { trackId, takes } = await trackWithTakes(request, [0.3, 0.6]);
+ await openWithLanes(page, trackId);
+ await expect(takeRows(page, trackId)).toHaveCount(2);
+ await expect(takeRows(page, trackId).locator(".mute, .solo, .mx-fader, .lane-dl")).toHaveCount(0);
+ // Only the track is a playing lane; its takes are not.
+ const lanes = await page.evaluate(async () => Object.keys((await import("/js/state.js")).trackIndex));
+ expect(lanes).toContain(trackId);
+ for (const id of takes) expect(lanes).not.toContain(id);
+
+ const row = page.locator(`.mixer-column .lane-header[data-take="${trackId}"]`);
+ const flag = (key) => page.evaluate(async ([id, k]) => (await import("/js/state.js")).mixerState[id]?.[k], [trackId, key]);
+ await row.locator(".mute").click();
+ await expect.poll(() => flag("muted")).toBe(true);
+ await row.locator(".mute").click();
+ await expect.poll(() => flag("muted")).toBe(false);
+ await row.locator(".solo").click();
+ await expect.poll(() => flag("soloed")).toBe(true);
+ // Soloing the track did not touch the folder.
+ await expect(takeRows(page, trackId)).toHaveCount(2);
+ expectAligned(await stack(page));
+});
+
+test("the track menu keeps only track actions; a take's own menu has rename, nudge and delete", async ({ page, request }) => {
+ const { trackId } = await trackWithTakes(request, [0.3, 0.6]);
+ await openWithLanes(page, trackId);
+ await page.locator(`.lane-header[data-take="${trackId}"] .take-menu-btn`).click();
+ const menu = page.locator(".take-menu");
+ await expect(menu.locator(".take-name-input")).toHaveValue("Track 1");
+ await expect(menu.locator(".take-delete-track")).toBeVisible();
+ await expect(menu.locator(".take-menu-takes, .take-nudge-input, .take-delete-take")).toHaveCount(0);
+ await page.keyboard.press("Escape");
+
+ await takeRows(page, trackId).nth(1).locator(".take-folder-menu-btn").click();
+ await expect(menu.locator(".take-name-input")).toHaveValue("Take 1");
+ await expect(menu.locator(".take-nudge-input")).toBeVisible();
+ await expect(menu.locator(".take-delete-take")).toBeVisible();
+ await expect(menu.locator(".take-delete-track")).toHaveCount(0);
+});
+
+test("nudge and rename apply to the take on that row, not the one that plays", async ({ page, request }) => {
+ const log = watchTakes(page);
+ const { trackId, takes } = await trackWithTakes(request, [0.2, 0.5, 0.9]);
+ const [t1, , t3] = takes;
+ await openWithLanes(page, trackId);
+
+ const patched = page.waitForRequest((r) => r.method() === "PATCH" && r.url().endsWith(`/takes/${t1}`));
+ await page.locator(`.take-folder-row[data-take-id="${t1}"] .take-folder-menu-btn`).click();
+ const menu = page.locator(".take-menu");
+ await menu.locator(".take-name-input").fill("Keeper");
+ await menu.locator(".take-nudge-input").fill("12");
+ await menu.locator(".take-apply").click();
+ await patched;
+ expect(log.patches).toEqual([{ label: "Keeper", user_nudge_ms: 12 }]);
+
+ await expect.poll(async () => (await jobState(page)).takes.find((tk) => tk.id === t1)?.user_nudge_ms).toBe(12);
+ const job = await jobState(page);
+ const byId = Object.fromEntries(job.takes.map((tk) => [tk.id, tk]));
+ expect(byId[t1].label).toBe("Keeper");
+ expect(byId[t3].user_nudge_ms).toBe(0);
+ expect(job.tracks[0].active_take_id).toBe(t3);
+ await waitForEngine(page);
+ const row = page.locator(`.take-folder-row[data-take-id="${t1}"]`);
+ await expect(row.locator(".take-folder-name")).toHaveText("Keeper", { timeout: 15000 });
+ await expect(row.locator(".take-folder-meta")).toHaveText("nudge +12 ms");
+ await expect(row).not.toHaveClass(/playing/);
+});
+
+test("delete a take from its row: the folder and its lanes lose that take, the rest stay aligned", async ({ page, request }) => {
+ const log = watchTakes(page);
+ const { trackId, takes } = await trackWithTakes(request, [0.2, 0.5, 0.9]);
+ const [t1, t2, t3] = takes;
+ await openWithLanes(page, trackId);
+
+ await page.locator(`.take-folder-row[data-take-id="${t2}"] .take-folder-menu-btn`).click();
+ const del = page.locator(".take-menu .take-delete-take");
+ await del.click();
+ await expect(del).toHaveClass(/armed/);
+ await del.click();
+ await expect.poll(() => log.deletes).toEqual([t2]);
+ await expect(takeRows(page, trackId)).toHaveCount(2, { timeout: 15000 });
+ await expect(takeLanes(page, trackId)).toHaveCount(2, { timeout: 10000 });
+ const ids = await takeRows(page, trackId).evaluateAll((els) => els.map((e) => e.dataset.takeId));
+ expect(ids).toEqual([t3, t1]);
+ expectAligned(await stack(page));
+
+ // Deleting the take that plays hands the track to the newest one left.
+ await waitForEngine(page);
+ await page.locator(`.take-folder-row[data-take-id="${t3}"] .take-folder-menu-btn`).click();
+ await del.click();
+ await del.click();
+ await expect.poll(async () => (await jobState(page)).tracks[0].active_take_id, { timeout: 10000 }).toBe(t1);
+ // One take left, after the folder was open in this page: it stays open.
+ await expect(takeRows(page, trackId)).toHaveCount(1, { timeout: 15000 });
+ await expect(takeRows(page, trackId).first()).toHaveClass(/playing/, { timeout: 15000 });
+ await expect.poll(async () => (await stack(page)).wave.length, { timeout: 10000 }).toBe(6);
+ expectAligned(await stack(page));
+});
+
+test("a new recording lands in the folder as the newest take, and plays", async ({ page, request }) => {
+ test.setTimeout(60_000);
+ const log = watchTakes(page);
+ const { trackId, takes } = await trackWithTakes(request, [0.4]);
+ await openWithLanes(page, trackId);
+ // The live waveform's anchor is in the track's own lane.
+ const slot = page.locator(`.stem-waveform-row[data-stem="${trackId}"] > .take-live-slot[data-track-id="${trackId}"]`);
+ await expect(slot).toHaveCount(1);
+
+ await recordForAWhile(page, 1500);
+ await expect.poll(() => log.finishStatus.length, { timeout: 20000 }).toBe(1);
+ await page.locator("#recKeep").click();
+ await page.keyboard.press("Escape");
+ await waitForEngine(page);
+ const job = await jobState(page);
+ const recorded = job.tracks[0].active_take_id;
+ expect(recorded).not.toBe(takes[0]);
+
+ // Two takes now: the folder opens, the recording on top, playing.
+ await expect(toggle(page, trackId)).toHaveAttribute("aria-expanded", "true", { timeout: 15000 });
+ const ids = await takeRows(page, trackId).evaluateAll((els) => els.map((e) => e.dataset.takeId));
+ expect(ids).toEqual([recorded, takes[0]]);
+ await expect(takeRows(page, trackId).first()).toHaveClass(/playing/);
+ await expect.poll(async () => (await stack(page)).wave.length, { timeout: 10000 }).toBe(7);
+ expectAligned(await stack(page));
+ await expect(slot).toHaveCount(1);
+});
+
+test("the live waveform slot survives a redraw of the lanes", async ({ page, request }) => {
+ const { trackId } = await trackWithTakes(request, [0.3, 0.6]);
+ await openWithLanes(page, trackId);
+ const slot = `.take-live-slot[data-track-id="${trackId}"]`;
+ await page.locator(slot).evaluate((el) => { el.dataset.e2eMark = "kept"; });
+ // Closing the folder redraws every lane.
+ await toggle(page, trackId).click();
+ await expect(takeRows(page, trackId)).toHaveCount(0);
+ await expect(page.locator(slot)).toHaveAttribute("data-e2e-mark", "kept");
+ await expect(page.locator(slot)).toHaveCSS("pointer-events", "none");
+});
+
+test("Guide explains the arrow, a take's controls and a take lane, and a click there explains instead of acting", async ({ page, request }) => {
+ const log = watchTakes(page);
+ const { trackId, takes } = await trackWithTakes(request, [0.3, 0.6]);
+ await openWithLanes(page, trackId);
+ await page.locator("#helpModeBtn").click();
+ await expect(page.locator("body")).toHaveClass(/help-mode/);
+ const tip = page.locator("#helpTip");
+
+ await toggle(page, trackId).hover();
+ await expect(tip).toContainText("Shows or hides the takes of Track 1");
+ await page.locator(`.take-folder-row[data-take-id="${takes[0]}"] .take-play`).hover();
+ await expect(tip).toContainText("Makes Take 1 the take its track plays");
+ await page.locator(`.take-folder-row[data-take-id="${takes[0]}"] .take-folder-menu-btn`).hover();
+ await expect(tip).toContainText("Rename Take 1, nudge it in time, or delete it.");
+ const lane = page.locator(`.take-folder-lane[data-take-id="${takes[0]}"]`);
+ await lane.hover();
+ await expect(tip).toContainText("One take of this track");
+ await lane.click();
+ await toggle(page, trackId).click();
+ // Explained, not acted on: the folder is still open and no take was switched.
+ await expect(takeRows(page, trackId)).toHaveCount(2);
+ expect(log.trackPatches).toEqual([]);
+});
+
+test("on the streaming path the folder stays shut and the track menu lists the takes", async ({ page, request }) => {
+ await page.addInitScript(() => {
+ try { localStorage.setItem("stemdeck.audioEngine", "0"); } catch (e) { console.warn(e); }
+ });
+ const { trackId } = await trackWithTakes(request, [0.3, 0.6]);
+ await page.setViewportSize({ width: 1600, height: 1000 });
+ await openStudio(page);
+ await expect(toggle(page, trackId)).toHaveClass(/off/);
+ await expect(takeRows(page, trackId)).toHaveCount(0);
+ await page.locator(`.lane-header[data-take="${trackId}"] .take-menu-btn`).click();
+ await expect(page.locator(".take-menu .take-menu-take")).toHaveText(["Take 1", "Take 2"]);
+});
+
diff --git a/tests/js/live-wave-math.test.mjs b/tests/js/live-wave-math.test.mjs
new file mode 100644
index 00000000..89373f30
--- /dev/null
+++ b/tests/js/live-wave-math.test.mjs
@@ -0,0 +1,233 @@
+// Pure logic behind the live waveform of a take being recorded
+// (static/js/capture/liveWaveMath.js): min/max per point in song time, and
+// the bar geometry the lane shares with the server's waveform.
+//
+// The grids here are chosen to be exact in binary (a point of 0.125 s, a rate
+// of 16 Hz, two samples per point), so every expected value is exact.
+//
+// Run: node tests/js/live-wave-math.test.mjs
+
+import {
+ createPeaks, addBlock, takeDirty, barCount, barStartPoint, barEndPoint, barsForPoints,
+ barAmplitude, barRect, liveNorm, songSecToX, serverPointSec, parseWavFormat, PEAK_POINTS, MIN_BARS,
+} from "../../static/js/capture/liveWaveMath.js";
+import { songTimeAtCtx } from "../../static/js/capture/takeMath.js";
+
+let pass = 0;
+let fail = 0;
+const check = (name, cond, detail = "") => {
+ if (cond) { pass++; console.log(`PASS ${name}`); }
+ else { fail++; console.log(`FAIL ${name}${detail ? " -- " + detail : ""}`); }
+};
+const f32 = (a) => Float32Array.from(a);
+const same = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
+const SR = 16;
+
+// ─── Slicing: a click ───
+{
+ // 1 s of song, 8 points of 0.125 s, 2 samples per point.
+ const p = createPeaks(1, 1, 8);
+ check("point length is duration / points", p.pointSec === 0.125);
+ const x = new Float32Array(16);
+ x[5] = 0.75; // point 2
+ x[11] = -0.5; // point 5
+ const placed = addBlock(p, [x], 0, SR);
+ check("every sample inside the song is placed", placed === 16);
+ check("click lands in its point's max", p.max[0][2] === 0.75 && p.min[0][2] === 0);
+ check("negative click lands in its point's min", p.min[0][5] === -0.5 && p.max[0][5] === 0);
+ check("silent points stay [0, 0]", [0, 1, 3, 4, 6, 7].every((j) => p.min[0][j] === 0 && p.max[0][j] === 0));
+ check("the level is the loudest |sample|", p.level === 0.75);
+ check("every point is filled", p.filledTo === 8);
+ check("the take ends at song 1.0", p.lastSongSec === 1);
+}
+
+// ─── Slicing: a sine, exact per point ───
+{
+ // 2 Hz at 16 Hz: 8 samples a period. Float32 values, as the worklet sends.
+ const s = f32(Array.from({ length: 16 }, (_, k) => Math.sin((2 * Math.PI * 2 * k) / SR)));
+ const p = createPeaks(1, 1, 8);
+ addBlock(p, [s], 0, SR);
+ const wantMin = [];
+ const wantMax = [];
+ for (let j = 0; j < 8; j++) {
+ wantMin.push(Math.min(0, s[2 * j], s[2 * j + 1]));
+ wantMax.push(Math.max(0, s[2 * j], s[2 * j + 1]));
+ }
+ check("sine: min per point is exact", same([...p.min[0]], f32(wantMin)), `${[...p.min[0]]}`);
+ check("sine: max per point is exact", same([...p.max[0]], f32(wantMax)), `${[...p.max[0]]}`);
+ // Point 1 holds samples 2 and 3: sin(pi/2) = 1 and sin(3pi/4).
+ check("sine: the crest is in point 1", p.max[0][1] === 1);
+ check("sine: the trough is in point 3", p.min[0][3] === -1);
+}
+
+// ─── Slicing: one block split over two calls gives the same points ───
+{
+ const s = f32(Array.from({ length: 16 }, (_, k) => Math.sin((2 * Math.PI * 3 * k) / SR) * 0.8));
+ const whole = createPeaks(1, 1, 8);
+ addBlock(whole, [s], 0, SR);
+ const split = createPeaks(1, 1, 8);
+ addBlock(split, [s.subarray(0, 7)], 0, SR);
+ addBlock(split, [s.subarray(7)], 7 / SR, SR);
+ check("batches split mid-point give the same min", same([...whole.min[0]], [...split.min[0]]));
+ check("batches split mid-point give the same max", same([...whole.max[0]], [...split.max[0]]));
+}
+
+// ─── Song-time placement ───
+{
+ // A count-in: the block starts at song -0.25, so its first 4 samples (the
+ // count-in) are not drawn and sample 4 is song 0.
+ const p = createPeaks(1, 1, 8);
+ const x = new Float32Array(8);
+ x[3] = 0.9; // song -0.0625: dropped
+ x[4] = 0.4; // song 0: point 0
+ x[7] = -0.3; // song 0.1875: point 1
+ const placed = addBlock(p, [x], -0.25, SR);
+ check("count-in samples are not placed", placed === 4);
+ check("sample at song 0 is point 0", p.max[0][0] === Math.fround(0.4));
+ check("the dropped count-in click is not in the level", p.level === Math.fround(0.4));
+ check("song 0.1875 is point 1", p.min[0][1] === Math.fround(-0.3));
+ check("drawn extent covers points 0 and 1", p.filledTo === 2);
+ check("last song time is the block end", p.lastSongSec === 0.25);
+}
+{
+ // Recording started from 0.5 s in: the first point drawn is point 4, and
+ // nothing earlier counts as filled.
+ const p = createPeaks(1, 1, 8);
+ const x = new Float32Array(4).fill(0.25);
+ addBlock(p, [x], 0.5, SR);
+ check("start offset places the first sample at point 4", p.max[0][4] === 0.25 && p.max[0][3] === 0);
+ check("filledTo is one past the last point", p.filledTo === 6);
+ check("bars before the take start are blank", barAmplitude(p, 0, 4) === 0);
+ check("bars past the take are blank (-1)", barAmplitude(p, 3, 4) === -1);
+}
+{
+ // Past the end of the song: placed up to the end, then stops.
+ const p = createPeaks(1, 1, 8);
+ const placed = addBlock(p, [new Float32Array(8).fill(0.1)], 0.75, SR);
+ check("samples past the song end are not placed", placed === 4);
+ check("last song time is clamped to the song end", p.lastSongSec === 1);
+}
+{
+ // The engine mapping that places the take (takeMath.songTimeAtCtx): song
+ // started at ctx 10, round trip 1/16 s. A batch stamped at context frame
+ // 160 (ctx 10.0) starts at song -0.0625: its first sample is dropped.
+ const m = { startCtxTime: 10, startOffset: 0, rate: 1, pipelineLatencySeconds: 0 };
+ const start = songTimeAtCtx(160 / SR, m, 1 / SR);
+ check("batch start from the engine mapping", start === -0.0625);
+ const p = createPeaks(1, 1, 8);
+ const x = new Float32Array(4);
+ x[1] = 0.6;
+ addBlock(p, [x], start, SR);
+ check("the sample one round trip late lands on song 0", p.max[0][0] === Math.fround(0.6));
+}
+
+// ─── Channels ───
+{
+ const p = createPeaks(1, 2, 8);
+ const l = new Float32Array(4);
+ const r = new Float32Array(4);
+ l[0] = 0.2;
+ r[0] = -0.9;
+ addBlock(p, [l, r], 0, SR);
+ check("each channel has its own points", p.max[0][0] === Math.fround(0.2) && p.min[1][0] === Math.fround(-0.9) && p.min[0][0] === 0);
+ check("the level follows channel 0 (the server's peaks are channel 0)", p.level === Math.fround(0.2));
+}
+
+// ─── Dirty range ───
+{
+ const p = createPeaks(1, 1, 8);
+ const out = [0, 0];
+ check("nothing dirty at first", takeDirty(p, out) === null);
+ addBlock(p, [new Float32Array(3)], 0.25, SR);
+ check("dirty range is the points written", takeDirty(p, out) === out && out[0] === 2 && out[1] === 3);
+ check("dirty range resets after reading", takeDirty(p, out) === null);
+}
+
+// ─── Bars, the same grid as player.js barsWaveformSvg ───
+{
+ check("bar count is width / 5", barCount(1000) === 200);
+ check("bar count has a floor", barCount(100) === MIN_BARS);
+ check("bar count before layout", barCount(0) === 220);
+ check("bar 1 of 4 over 8 points is points 2..3", barStartPoint(1, 4, 8) === 2 && barEndPoint(1, 4, 8) === 4);
+ check("more bars than points: each bar has at least one point", barEndPoint(5, 16, 8) === 3 && barStartPoint(5, 16, 8) === 2);
+
+ const p = createPeaks(1, 1, 8);
+ const x = new Float32Array(16);
+ x[2] = 0.3; // point 1, bar 0
+ x[5] = -0.6; // point 2, bar 1
+ addBlock(p, [x], 0, SR);
+ check("bar amplitude is max(-min, max) of its points", barAmplitude(p, 0, 4) === f32([0.3])[0] && barAmplitude(p, 1, 4) === f32([0.6])[0]);
+
+ const r = [0, 0, 0, 0];
+ barRect(1, 4, 20, 48, 0.5, 2, r);
+ check("bar x is (i + 0.2) slots, width 0.6 of a slot", r[0] === 6 && r[2] === 3);
+ check("full-scale bar is 44 of 48, centred", r[1] === 2 && r[3] === 44);
+ barRect(0, 4, 20, 96, 0, 2, r);
+ check("a silent bar keeps the 0.7 unit baseline", r[3] === 1.4 && r[1] === 47.3);
+ barRect(0, 4, 20, 48, 0.25, 2, r);
+ check("half scale is 22 units", r[3] === 22 && r[1] === 13);
+ barRect(0, 4, 20, 48, 2, 2, r);
+ check("over full scale is clamped", r[3] === 44);
+}
+
+// ─── Every changed point is redrawn ───
+{
+ let missed = 0;
+ const out = [0, 0];
+ for (const bars of [7, 40, 333, 2999, 3000, 4100]) {
+ for (let lo = 0; lo < PEAK_POINTS; lo += 97) {
+ const hi = Math.min(PEAK_POINTS - 1, lo + (lo % 5));
+ barsForPoints(lo, hi, bars, PEAK_POINTS, out);
+ for (let i = 0; i < bars; i++) {
+ const s = barStartPoint(i, bars, PEAK_POINTS);
+ const e = barEndPoint(i, bars, PEAK_POINTS);
+ const touches = s <= hi && e > lo;
+ if (touches && (i < out[0] || i > out[1])) missed++;
+ }
+ }
+ }
+ check("barsForPoints covers every bar holding a changed point", missed === 0, `${missed} missed`);
+}
+
+// ─── Normalisation and x ───
+{
+ check("norm is over the loudest of the other lanes and the take", liveNorm(0.5, 0.25) === 2 && liveNorm(0.25, 0.5) === 2);
+ check("norm is 0 when everything is silent", liveNorm(0, 0) === 0);
+ check("song time to x is linear over the lane", songSecToX(1.5, 6, 1200) === 300);
+}
+
+// ─── The server's grid ───
+{
+ // The e2e fixture: 6 s at 44.1 kHz is 264600 frames, blocks of 88 (not 88.2).
+ check("server point is floor(frames / points) samples", serverPointSec(264600, 44100) === 88 / 44100);
+ check("a short file still has one-sample points", serverPointSec(100, 48000) === 1 / 48000);
+ check("no format, no grid", serverPointSec(0, 44100) === 0);
+ // On that grid, song 3.0 s is point 1503, not 1500.
+ const p = createPeaks(6, 1, PEAK_POINTS, serverPointSec(264600, 44100));
+ addBlock(p, [Float32Array.of(0.5)], 3, 44100);
+ check("song 3 s lands where the server puts it", p.max[0][1503] === 0.5);
+}
+{
+ const wav = (extra) => {
+ const buf = new ArrayBuffer(44 + extra);
+ const v = new DataView(buf);
+ const put = (off, str) => { for (let i = 0; i < 4; i++) v.setUint8(off + i, str.charCodeAt(i)); };
+ put(0, "RIFF"); put(8, "WAVE");
+ put(12, "fmt "); v.setUint32(16, 16, true);
+ v.setUint16(20, 1, true); v.setUint16(22, 2, true);
+ v.setUint32(24, 48000, true); v.setUint32(28, 192000, true);
+ v.setUint16(32, 4, true); v.setUint16(34, 16, true);
+ let off = 36;
+ if (extra) { put(off, "LIST"); v.setUint32(off + 4, extra - 8, true); off += extra; }
+ put(off, "data"); v.setUint32(off + 4, 4 * 1000, true);
+ return buf;
+ };
+ const a = parseWavFormat(wav(0));
+ check("WAV format: rate and frames", a && a.sampleRate === 48000 && a.frames === 1000, JSON.stringify(a));
+ const b = parseWavFormat(wav(26));
+ check("WAV format: a chunk before data is skipped", b && b.sampleRate === 48000 && b.frames === 1000, JSON.stringify(b));
+ check("not a WAV", parseWavFormat(new ArrayBuffer(64)) === null);
+}
+
+console.log(`\n${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);
diff --git a/tests/js/take-folders.test.mjs b/tests/js/take-folders.test.mjs
new file mode 100644
index 00000000..e428a671
--- /dev/null
+++ b/tests/js/take-folders.test.mjs
@@ -0,0 +1,109 @@
+// Pure logic behind take folders (static/js/capture/takeFolderModel.js): which
+// takes a track's folder lists and in what order, when the folder is open,
+// how the stored choice is read and kept bounded, and where the take lanes sit
+// in the lane stack.
+//
+// Run: node tests/js/take-folders.test.mjs
+
+import {
+ takesNewestFirst, parseFolderStore, isFolderOpen, withFolderChoice, laneOrder, takeLaneOrder,
+ openFolders, folderRowCount, nudgeText, FOLDER_STORE_MAX, LANE_ORDER_STEP,
+} from "../../static/js/capture/takeFolderModel.js";
+
+let pass = 0;
+let fail = 0;
+const check = (name, cond, detail = "") => {
+ if (cond) { pass++; console.log(`PASS ${name}`); }
+ else { fail++; console.log(`FAIL ${name}${detail ? " -- " + detail : ""}`); }
+};
+const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
+
+// ─── Order ───
+{
+ const takes = [
+ { id: "t00000001", created_at: 100 },
+ { id: "t00000002", created_at: 300 },
+ { id: "t00000003", created_at: 200 },
+ ];
+ const ids = takesNewestFirst(takes).map((t) => t.id);
+ check("newest take first, by created_at", same(ids, ["t00000002", "t00000003", "t00000001"]), ids.join(","));
+ check("the input list is not reordered", takes[0].id === "t00000001");
+ const tied = takesNewestFirst([{ id: "ta", created_at: 5 }, { id: "tb", created_at: 5 }]).map((t) => t.id);
+ check("equal times: the later one in the list is newer", same(tied, ["tb", "ta"]), tied.join(","));
+ const legacy = takesNewestFirst([{ id: "ta" }, { id: "tb" }]).map((t) => t.id);
+ check("no created_at (older takes): list order, reversed", same(legacy, ["tb", "ta"]));
+ check("not a list: nothing", takesNewestFirst(null).length === 0);
+ check("entries without an id are dropped", takesNewestFirst([null, {}, { id: "tc" }]).length === 1);
+}
+
+// ─── Open or closed ───
+{
+ const open = (takeCount, store = {}, session = {}) => isFolderOpen({ trackId: "k1", takeCount, store, session });
+ check("no takes: closed", open(0) === false);
+ check("no takes: closed even if stored open", open(0, { k1: true }) === false);
+ check("one take: closed by default", open(1) === false);
+ check("one take: a stored choice does not open it", open(1, { k1: true }) === false);
+ check("one take: open when opened in this page", open(1, {}, { k1: true }) === true);
+ check("two takes: open by default", open(2) === true);
+ check("two takes: the stored choice wins (closed)", open(2, { k1: false }) === false);
+ check("two takes: the stored choice wins (open)", open(5, { k1: true }) === true);
+ check("another track's choice does not apply", open(3, { k2: false }) === true);
+}
+
+// ─── Stored choices ───
+{
+ check("empty or missing: nothing stored", same(parseFolderStore(null), {}) && same(parseFolderStore(""), {}));
+ check("bad JSON: nothing stored", same(parseFolderStore("{oops"), {}));
+ check("an array: nothing stored", same(parseFolderStore("[true]"), {}));
+ check("non-boolean values are dropped", same(parseFolderStore('{"k1":true,"k2":"yes","k3":false}'), { k1: true, k3: false }));
+
+ const s1 = withFolderChoice({ k1: true }, "k2", false);
+ check("a choice is added", same(s1, { k1: true, k2: false }));
+ const s2 = withFolderChoice(s1, "k1", false);
+ check("a changed choice moves to the newest place", same(Object.keys(s2), ["k2", "k1"]) && s2.k1 === false);
+ check("the old store is not changed", s1.k1 === true);
+
+ let big = {};
+ for (let i = 0; i < FOLDER_STORE_MAX + 25; i++) big = withFolderChoice(big, `k${i}`, i % 2 === 0);
+ const keys = Object.keys(big);
+ check("the store is bounded", keys.length === FOLDER_STORE_MAX, String(keys.length));
+ check("the oldest choices go first", !("k0" in big) && `k${FOLDER_STORE_MAX + 24}` in big);
+}
+
+// ─── Place in the stack ───
+{
+ check("lanes step by LANE_ORDER_STEP", laneOrder(0) === 0 && laneOrder(3) === 3 * LANE_ORDER_STEP);
+ check("a take lane sits right after its track", takeLaneOrder(4, 0) === laneOrder(4) + 1);
+ check("take lanes keep their order", takeLaneOrder(4, 0) < takeLaneOrder(4, 1) && takeLaneOrder(4, 1) < takeLaneOrder(4, 2));
+ check("the last take lane is still before the next track", takeLaneOrder(4, 49) < laneOrder(5));
+ check("even past the cap, it never reaches the next track", takeLaneOrder(4, 500) < laneOrder(5));
+}
+
+// ─── Open folders and row count ───
+{
+ const tA = { id: "ta1", created_at: 1 };
+ const tB = { id: "ta2", created_at: 2 };
+ const tracks = [
+ { id: "kA", takes: [tA, tB], active: tA },
+ { id: "kB", takes: [], active: null },
+ { id: "kC", takes: [{ id: "tc1", created_at: 1 }], active: { id: "tc1" } },
+ ];
+ const all = openFolders(tracks, () => true);
+ check("tracks with no takes have no folder", same(all.map((f) => f.trackId), ["kA", "kC"]));
+ check("a folder lists its takes newest first", same(all[0].takes.map((x) => x.take.id), ["ta2", "ta1"]));
+ check("the active take is the one marked playing", same(all[0].takes.map((x) => x.playing), [false, true]));
+ check("rows: one per take of every open folder", folderRowCount(all) === 3);
+ const onlyA = openFolders(tracks, (tr) => tr.id === "kA");
+ check("closed folders add no rows", folderRowCount(onlyA) === 2);
+ check("nothing open: no rows", folderRowCount(openFolders(tracks, () => false)) === 0);
+}
+
+// ─── Nudge text ───
+{
+ check("no nudge: no text", nudgeText(0) === "" && nudgeText(undefined) === "");
+ check("positive: plus sign", nudgeText(12) === "+12 ms");
+ check("negative: minus sign", nudgeText(-3.4) === "−3 ms", nudgeText(-3.4));
+}
+
+console.log(`\n${pass} passed, ${fail} failed`);
+if (fail) process.exit(1);
diff --git a/tests/js/take-math.test.mjs b/tests/js/take-math.test.mjs
new file mode 100644
index 00000000..f06f77a1
--- /dev/null
+++ b/tests/js/take-math.test.mjs
@@ -0,0 +1,225 @@
+// Pure logic behind recording a take (static/js/capture/takeMath.js,
+// takeRecorder.buildSidecar, takeUpload.AudioPartQueue).
+//
+// The placement numbers are the contract with the backend's renderer: a frame
+// stamped at context time c lands at song time
+// (c - roundTrip - startCtxTime - pipelineLatency) * rate + startOffset,
+// and the nudge is NOT folded in here (the server applies it). Calibration is
+// checked on synthetic captures with a known delay and noise.
+//
+// Run: node tests/js/take-math.test.mjs
+
+import {
+ songTimeAtCtx, firstSampleSongSec, endSongSec, placementSec, perfMsAtCtx,
+ estimateRoundTripSec, median, makeBurst, findBurst, measureRoundTrips,
+ summariseCalibration, interleave, maxFramesPerPart, recordBlockReason,
+ CAL_CLICKS, CAL_SPACING_SEC, PART_MAX_BYTES,
+} from "../../static/js/capture/takeMath.js";
+import { buildSidecar } from "../../static/js/capture/takeRecorder.js";
+import { AudioPartQueue, uploadPart } from "../../static/js/capture/takeUpload.js";
+
+let pass = 0;
+let fail = 0;
+const check = (name, cond, detail = "") => {
+ if (cond) { pass++; console.log(`PASS ${name}`); }
+ else { fail++; console.log(`FAIL ${name}${detail ? " -- " + detail : ""}`); }
+};
+const approx = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
+
+// ─── Placement ───
+{
+ // Song started at ctx 10.0 from song 0, no worklet latency, tempo 1.
+ const m = { startCtxTime: 10, startOffset: 0, rate: 1, pipelineLatencySeconds: 0 };
+ check("frame at the start, no latency, is song 0", approx(songTimeAtCtx(10, m, 0), 0));
+ check("round trip is subtracted (late input moves earlier)", approx(songTimeAtCtx(10.02, m, 0.02), 0));
+ // Count-in: recording began 2 s before the song; frame 0 is at song -2.
+ check("count-in frames are negative song time", approx(firstSampleSongSec(8, m, 0), -2));
+ check("count-in plus round trip", approx(firstSampleSongSec(8, m, 0.025), -2.025));
+ // Started from 30 s into the song.
+ const m30 = { ...m, startOffset: 30 };
+ check("start offset is added", approx(songTimeAtCtx(11, m30, 0), 31));
+ // The tempo worklet delays what is heard by its latency.
+ const mp = { ...m, pipelineLatencySeconds: 0.0029 };
+ check("pipeline latency is subtracted", approx(songTimeAtCtx(10.0029, mp, 0), 0));
+ check("pipeline and round trip together", approx(songTimeAtCtx(10.0329, mp, 0.03), 0));
+ // Same formula as the engine's now(): at a time after the start, both agree.
+ const engineNow = (c, mm) => Math.max(0, c - mm.startCtxTime - mm.pipelineLatencySeconds) * mm.rate + mm.startOffset;
+ check("matches the engine's heard playhead after start", approx(songTimeAtCtx(14.5, mp, 0), engineNow(14.5, mp)));
+ check("end song time from frame count", approx(endSongSec(-2, 48000 * 10, 48000), 8));
+ check("placement applies the nudge (positive = earlier)", approx(placementSec(1.0, 20), 0.98));
+ check("perf time of a ctx time", approx(perfMsAtCtx(5.5, { contextTime: 5, performanceTime: 1000 }), 1500));
+ check("perf time is null without a timestamp", perfMsAtCtx(5, null) === null);
+}
+
+// ─── Estimate and median ───
+{
+ check("estimate sums output, base and input", approx(estimateRoundTripSec({ outputLatency: 0.01, baseLatency: 0.005, trackLatency: 0.01 }), 0.025));
+ check("estimate defaults input to 10 ms", approx(estimateRoundTripSec({ baseLatency: 0.005 }), 0.015));
+ check("estimate ignores junk", approx(estimateRoundTripSec({ outputLatency: NaN, baseLatency: -1 }), 0.01));
+ check("median odd", median([3, 1, 2]) === 2);
+ check("median even", median([4, 1, 3, 2]) === 2.5);
+ check("median skips non-finite", median([1, NaN, 3]) === 2);
+ check("median of nothing is NaN", Number.isNaN(median([])));
+}
+
+// ─── Calibration on synthetic captures ───
+function synth({ rate = 48000, delaySec = 0.0234, noise = 0.01, gain = 0.5, missing = [], jitter = [], seed = 7 } = {}) {
+ const burst = makeBurst(rate);
+ const startFrame = 1000; // the capture begins at context frame 1000
+ const t0 = startFrame + Math.round(0.3 * rate);
+ const clickFrames = Array.from({ length: CAL_CLICKS }, (_, k) => t0 + Math.round(k * CAL_SPACING_SEC * rate));
+ const len = Math.round(4 * rate);
+ const sig = new Float32Array(len);
+ let s = seed;
+ const rnd = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff * 2 - 1; };
+ for (let i = 0; i < len; i++) sig[i] = rnd() * noise;
+ clickFrames.forEach((cf, k) => {
+ if (missing.includes(k)) return;
+ const at = cf - startFrame + Math.round((delaySec + (jitter[k] || 0)) * rate);
+ for (let i = 0; i < burst.length && at + i < len; i++) sig[at + i] += burst[i] * gain;
+ });
+ return { sig, rate, startFrame, clickFrames, burst };
+}
+
+{
+ const b = makeBurst(48000);
+ check("burst is 2 ms", b.length === 96);
+ check("burst is deterministic", makeBurst(48000)[10] === b[10]);
+ const sig = new Float32Array(2000);
+ sig.set(b, 700);
+ const hit = findBurst(sig, b, 0, 1800);
+ check("findBurst finds an exact copy", hit && hit.lag === 700, JSON.stringify(hit));
+}
+{
+ const { sig, rate, startFrame, clickFrames, burst } = synth({ delaySec: 0.0234 });
+ const rts = measureRoundTrips(sig, rate, startFrame, clickFrames, burst);
+ check("all five clicks found", rts.every((r) => r !== null), JSON.stringify(rts));
+ const res = summariseCalibration(rts);
+ check("calibration accepted", res.ok, JSON.stringify(res));
+ check("round trip within 1 sample of 23.4 ms", Math.abs(res.roundTripSec - 0.0234) <= 1 / rate + 1e-9, String(res.roundTripSec));
+ check("reported in ms to 0.1", Math.abs(res.roundTripMs - 23.4) <= 0.05, String(res.roundTripMs));
+ check("no drift on a steady loop", res.driftPpm === 0, String(res.driftPpm));
+}
+{
+ const { sig, rate, startFrame, clickFrames, burst } = synth({ rate: 44100, delaySec: 0.0612, noise: 0.03, gain: 0.3 });
+ const res = summariseCalibration(measureRoundTrips(sig, rate, startFrame, clickFrames, burst));
+ check("44.1 kHz, noisier, 61.2 ms", res.ok && Math.abs(res.roundTripSec - 0.0612) <= 1 / rate + 1e-9, JSON.stringify(res));
+}
+{
+ // One click lost and one far off: four agree, so it is still accepted.
+ const { sig, rate, startFrame, clickFrames, burst } = synth({ missing: [2] });
+ const res = summariseCalibration(measureRoundTrips(sig, rate, startFrame, clickFrames, burst));
+ check("one missing click still calibrates", res.ok && res.agreeing === 4, JSON.stringify(res));
+}
+{
+ const { sig, rate, startFrame, clickFrames, burst } = synth({ gain: 0 });
+ const res = summariseCalibration(measureRoundTrips(sig, rate, startFrame, clickFrames, burst));
+ check("silence is too quiet", !res.ok && res.reason === "too-quiet", JSON.stringify(res));
+}
+{
+ // Spread far apart: no four agree within 2 ms.
+ const res = summariseCalibration([0.020, 0.030, 0.040, 0.050, 0.060]);
+ check("disagreeing clicks are inconsistent", !res.ok && res.reason === "inconsistent", JSON.stringify(res));
+ const ok = summariseCalibration([0.0230, 0.0231, 0.0229, 0.0230, 0.0400]);
+ check("an outlier is dropped", ok.ok && Math.abs(ok.roundTripSec - 0.0230) < 1e-9, JSON.stringify(ok));
+ const drift = summariseCalibration([0.0230, 0.02301, 0.02302, 0.02303, 0.02304]);
+ check("drift is the slope in ppm", drift.ok && drift.driftPpm === 17, String(drift.driftPpm));
+}
+
+// ─── PCM packing ───
+{
+ const l = Float32Array.from([1, 2, 3]);
+ const r = Float32Array.from([-1, -2, -3]);
+ const st = interleave([l, r], 3);
+ check("stereo interleaves L R L R", st.join(",") === "1,-1,2,-2,3,-3");
+ check("mono passes through", interleave([l], 2).join(",") === "1,2");
+ check("frames per part keep under 4 MB", maxFramesPerPart(2) * 8 <= PART_MAX_BYTES && (maxFramesPerPart(2) + 1) * 8 > PART_MAX_BYTES);
+ check("Record refuses other tempos", recordBlockReason({ hasEngine: true, rate: 0.75 }) === "tempo");
+ check("Record needs the engine", recordBlockReason({ hasEngine: false, rate: 1 }) === "no-engine");
+ check("Record allowed at 1x", recordBlockReason({ hasEngine: true, rate: 1 }) === null);
+}
+
+// ─── Sidecar ───
+{
+ const capture = {
+ roundTripSeconds: 0.0234, calibrated: true, sampleRate: 48000, channels: 1,
+ deviceLabel: "Scarlett 2i2", calibration: { driftPpm: 3 },
+ estimate: { outputLatencyMs: 10, baseLatencyMs: 5.3, inputLatencyMs: null },
+ };
+ const mapping = { startCtxTime: 43.62, startOffset: 0, rate: 1, pipelineLatencySeconds: 0.0029, epoch: 4 };
+ const sc = buildSidecar({
+ label: "", mapping, ctxTimeAtFrame0: 41.27, perfMsAtFrame0: 123456.7, frames: 48000 * 60,
+ capture, dropped: 0, video: null,
+ });
+ check("sidecar v1", sc.v === 1);
+ check("first sample folds the round trip and pipeline",
+ approx(sc.timeline.first_sample_song_sec, 41.27 - 0.0234 - 43.62 - 0.0029, 1e-6), String(sc.timeline.first_sample_song_sec));
+ check("nudge starts at 0 and is not folded in", sc.latency.user_nudge_ms === 0);
+ check("tempo is 1", sc.timeline.tempo === 1);
+ check("count-in length", approx(sc.timeline.count_in_sec, 2.35, 1e-6), String(sc.timeline.count_in_sec));
+ check("calibrated source", sc.latency.source === "calibrated");
+ check("unknown input latency is absent, not null", !("input_latency_ms_reported" in sc.latency));
+ check("no label key when empty", !("label" in sc));
+ check("no video block without a camera", !("video" in sc));
+ check("capture block", sc.capture.rate === 48000 && sc.capture.channels === 1 && sc.capture.device_label === "Scarlett 2i2");
+ const withVideo = buildSidecar({ label: "Take 2", mapping, ctxTimeAtFrame0: 41.27, perfMsAtFrame0: 1, frames: 10, capture: { ...capture, calibrated: false }, dropped: 2, video: { mime: "video/webm" } });
+ check("video block passes through", withVideo.video?.mime === "video/webm");
+ check("estimated source when not calibrated", withVideo.latency.source === "estimated");
+ check("label passes through", withVideo.label === "Take 2");
+}
+
+// ─── Upload queue against a fake server ───
+async function uploads() {
+ const log = [];
+ let failNext = 0;
+ let conflictOnce = false;
+ let serverNext = 0;
+ globalThis.fetch = async (url, opts) => {
+ const m = /parts\/(\w+)\?seq=(\d+)/.exec(url);
+ if (opts.method === "GET" && /\/parts$/.test(url)) {
+ return new Response(JSON.stringify({ audio: { next_seq: serverNext }, camera: { next_seq: serverNext } }), { status: 200 });
+ }
+ if (!m) return new Response("{}", { status: 404 });
+ const seq = Number(m[2]);
+ const size = opts.body.byteLength;
+ log.push({ seq, size });
+ if (failNext > 0) { failNext--; return new Response("{}", { status: 503 }); }
+ if (conflictOnce) {
+ conflictOnce = false;
+ serverNext = seq + 1; // the server stored it; the reply was lost
+ return new Response(JSON.stringify({ detail: "unexpected part sequence" }), { status: 409 });
+ }
+ serverNext = seq + 1;
+ return new Response(JSON.stringify({ seq, bytes: size, duplicate: false }), { status: 200 });
+ };
+
+ const q = new AudioPartQueue({ channels: 2, flushMs: 100000 });
+ const block = new Float32Array(48000 * 2); // 1 s stereo, 384 KB
+ q.push(block); // before the take id: held
+ check("nothing sent before the take id", log.length === 0);
+ q.setTake("e2e0deadbeef", "t0011aabb");
+ for (let i = 0; i < 12; i++) q.push(block); // 13 s total, about 5 MB
+ failNext = 1; // the next request fails once and is retried
+ const sent = await q.drain();
+ const seqs = log.map((l) => l.seq);
+ check("every part under 4 MB", log.every((l) => l.size <= PART_MAX_BYTES), JSON.stringify(log));
+ check("parts are a whole number of frames", log.every((l) => l.size % 8 === 0));
+ check("all bytes acknowledged", sent === 13 * block.byteLength, `${sent}`);
+ check("seq goes up by one, retry repeats the same seq",
+ seqs.filter((v, i) => seqs.indexOf(v) === i).every((v, i) => v === i), seqs.join(","));
+
+ log.length = 0;
+ serverNext = 0;
+ conflictOnce = true;
+ const ack = await uploadPart("e2e0deadbeef", "t0011aabb", "camera", 0, new Uint8Array(10));
+ check("a 409 for a part the server already has counts as acknowledged", ack.duplicate === true, JSON.stringify(ack));
+
+ let threw = false;
+ try { await uploadPart("e2e0deadbeef", "t0011aabb", "camera", 0, new Uint8Array(PART_MAX_BYTES + 1)); } catch { threw = true; }
+ check("a part over 4 MB is refused before sending", threw);
+}
+
+await uploads();
+
+console.log(`\n${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);
diff --git a/tests/js/track-inputs.test.mjs b/tests/js/track-inputs.test.mjs
new file mode 100644
index 00000000..7bb9cc77
--- /dev/null
+++ b/tests/js/track-inputs.test.mjs
@@ -0,0 +1,107 @@
+// Input choices for recording tracks (static/js/capture/trackInputs.js).
+//
+// A track records one channel of a device or an adjacent stereo pair, and
+// tracks armed together must share a device. A device is a voice input (a
+// microphone: one input) or an instrument input (an interface: each channel
+// and each pair), named the way the user knows it. These are the pure rules
+// behind the input pickers and the arm button. The device labels are the
+// ones Chromium and Edge report on Windows for a Logitech StreamCam and a
+// Yamaha MG12XU mixer.
+//
+// Run: node tests/js/track-inputs.test.mjs
+
+import {
+ channelChoices, defaultChannels, sameDevice, channelsNeeded, friendlyName, inputKind, choicesFor,
+ normaliseDevices, findDevice, resolveInput, pickDefaultDevice,
+} from "../../static/js/capture/trackInputs.js";
+
+let pass = 0;
+let fail = 0;
+const check = (name, cond, detail = "") => {
+ if (cond) { pass++; console.log(`PASS ${name}`); }
+ else { fail++; console.log(`FAIL ${name}${detail ? " -- " + detail : ""}`); }
+};
+const keys = (max) => channelChoices(max).map((c) => c.key).join(",");
+
+check("mono device: one choice", keys(1) === "1", keys(1));
+check("stereo device: 1, 2, 1+2", keys(2) === "1,2,1+2", keys(2));
+check("4 inputs: each, then 1+2 and 3+4", keys(4) === "1,2,3,4,1+2,3+4", keys(4));
+check("odd count: no pair for the last", keys(3) === "1,2,3,1+2", keys(3));
+check("pairs are 0-based channel indexes", JSON.stringify(channelChoices(4).at(-1).channels) === "[2,3]");
+check("junk counts as one channel", keys(0) === "1" && keys(NaN) === "1");
+check("capped at 64 channels", channelChoices(500).filter((c) => c.channels.length === 1).length === 64);
+check("default on a stereo count is the pair", JSON.stringify(defaultChannels(2)) === "[0,1]");
+check("default on mono is channel 1", JSON.stringify(defaultChannels(1)) === "[0]");
+check("same device by id", sameDevice({ device_id: "a" }, { device_id: "a" }));
+check("different devices", !sameDevice({ device_id: "a" }, { device_id: "b" }));
+check("null and empty are both the default input", sameDevice(null, { device_id: "" }));
+check("channels needed covers the highest channel", channelsNeeded([{ channels: [0] }, { channels: [2, 3] }]) === 4);
+check("channels needed is at least 1", channelsNeeded([]) === 1);
+
+// ── Names and kinds, from real Windows labels ──
+const caps = (max) => () => ({ channelCount: { min: 1, max } });
+const dev = (deviceId, label, groupId, max = 2) => ({ kind: "audioinput", deviceId, label, groupId, getCapabilities: caps(max) });
+const EDGE = [
+ dev("default", "Default - Microphone (Logitech StreamCam) (046d:0893)", "g-cam"),
+ dev("communications", "Communications - Microphone (Logitech StreamCam) (046d:0893)", "g-cam"),
+ dev("id-mgxu", "Line (2- MG-XU) (0499:1703)", "g-mg"),
+ dev("id-cam", "Microphone (Logitech StreamCam) (046d:0893)", "g-cam"),
+];
+// Playwright's Chromium has no "Default" string: the alias reads " - Microphone (...)".
+const CHROMIUM = [dev("default", " - Microphone (Logitech StreamCam) (046d:0893)", "g-cam"), ...EDGE.slice(1)];
+
+check("microphone name", friendlyName("Microphone (Logitech StreamCam) (046d:0893)") === "Logitech StreamCam", friendlyName("Microphone (Logitech StreamCam) (046d:0893)"));
+check("mixer name: no 'Line', no instance number, no USB id", friendlyName("Line (2- MG-XU) (0499:1703)") === "MG-XU", friendlyName("Line (2- MG-XU) (0499:1703)"));
+check("alias prefix stripped", friendlyName("Default - Microphone (Logitech StreamCam) (046d:0893)") === "Logitech StreamCam");
+check("Chromium's empty alias prefix stripped", friendlyName(" - Microphone (Logitech StreamCam) (046d:0893)") === "Logitech StreamCam");
+check("a name without a type word is kept", friendlyName("Scarlett 2i2 USB") === "Scarlett 2i2 USB");
+check("macOS microphone name kept", friendlyName("MacBook Pro Microphone") === "MacBook Pro Microphone");
+check("a bracket that is not a type word is kept", friendlyName("Analogue 1 + 2 (Focusrite USB Audio)") === "Analogue 1 + 2 (Focusrite USB Audio)");
+check("microphone is voice", inputKind("Microphone (Logitech StreamCam) (046d:0893)") === "voice");
+check("headset is voice", inputKind("Headset Microphone (Jabra)") === "voice");
+check("mixer line input is instrument", inputKind("Line (2- MG-XU) (0499:1703)") === "instrument");
+check("interface is instrument", inputKind("Analogue 1 + 2 (Focusrite USB Audio)") === "instrument");
+check("Chromium's fake device is instrument (stereo, so 1, 2, 1+2)", inputKind("Fake Default Audio Input") === "instrument");
+
+for (const [browser, list] of [["Edge", EDGE], ["Chromium", CHROMIUM]]) {
+ const n = normaliseDevices(list);
+ check(`${browser}: aliases dropped, two devices`, n.length === 2, JSON.stringify(n.map((d) => d.deviceId)));
+ check(`${browser}: the mixer is an instrument named MG-XU`, n.some((d) => d.deviceId === "id-mgxu" && d.name === "MG-XU" && d.kind === "instrument" && d.channels === 2));
+ check(`${browser}: the webcam mic is voice`, n.some((d) => d.deviceId === "id-cam" && d.name === "Logitech StreamCam" && d.kind === "voice"));
+ check(`${browser}: the system default is marked`, n.find((d) => d.systemDefault)?.deviceId === "id-cam");
+}
+const twoEndpoints = normaliseDevices([
+ dev("a", "Analogue 1 + 2 (Focusrite USB Audio)", "f"),
+ dev("b", "Analogue 3 + 4 (Focusrite USB Audio)", "f"),
+]);
+check("two endpoints of one interface keep distinct names", new Set(twoEndpoints.map((d) => d.name)).size === 2, JSON.stringify(twoEndpoints.map((d) => d.name)));
+
+const devices = normaliseDevices(EDGE);
+const mg = devices.find((d) => d.kind === "instrument");
+const cam = devices.find((d) => d.kind === "voice");
+check("a microphone offers one input", JSON.stringify(choicesFor(cam).map((c) => c.channels)) === "[[0]]");
+check("the mixer offers 1, 2 and 1+2", choicesFor(mg).map((c) => c.key).join(",") === "1,2,1+2");
+check("a new microphone track records its one input", JSON.stringify(defaultChannels(cam)) === "[0]");
+check("a new interface track records the pair", JSON.stringify(defaultChannels(mg)) === "[0,1]");
+check("Settings default: remembered first", pickDefaultDevice(devices, "id-mgxu") === mg);
+check("Settings default: else the system default", pickDefaultDevice(devices, "") === cam);
+check("Settings default: a forgotten id falls to the system default", pickDefaultDevice(devices, "gone") === cam);
+
+check("found by id", findDevice({ device_id: "id-mgxu" }, devices) === mg);
+check("found by name when the id changed (another browser)", findDevice({ device_id: "other-browser-id", device_label: "MG-XU" }, devices) === mg);
+check("found by a stored raw label", findDevice({ device_id: "x", device_label: "Line (2- MG-XU) (0499:1703)" }, devices) === mg);
+check("unplugged: not found", findDevice({ device_id: "x", device_label: "Scarlett 2i2" }, devices) === null);
+
+const r1 = resolveInput({ device_id: "other-browser-id", device_label: "MG-XU", channels: [0] }, devices, "");
+check("resolve: a moved id records from the device found by name", r1.device_id === "id-mgxu" && !r1.missing && JSON.stringify(r1.channels) === "[0]");
+const r2 = resolveInput({ device_id: "gone", device_label: "Scarlett 2i2", channels: [1] }, devices, "");
+check("resolve: an unplugged device is missing, its id kept", r2.missing && r2.device_id === "gone" && r2.device_label === "Scarlett 2i2");
+const r3 = resolveInput({ device_id: "id-cam", device_label: "Logitech StreamCam", channels: [0, 1] }, devices, "");
+check("resolve: a microphone records one channel even if a pair was stored", JSON.stringify(r3.channels) === "[0]" && r3.kind === "voice");
+const r4 = resolveInput(null, devices, "id-mgxu");
+check("resolve: no input of its own is the Settings device", r4.device_id === "id-mgxu" && JSON.stringify(r4.channels) === "[0,1]");
+const r5 = resolveInput({ device_id: "gone", channels: [0] }, [], "");
+check("resolve: no device list yet is not reported missing", !r5.missing);
+
+console.log(`\n${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);
From 76330c691ae1790774e18812386d29c55568c55f Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Mon, 5 Oct 2026 20:22:34 +0100
Subject: [PATCH 004/300] feat(video): film a take with the camera and make a
video of it
Add the camera to the record flow, upload the camera recording with the
take, and a "Make video" action that renders the take over the backing
mix and downloads the MP4. Includes the camera delay nudge, the video
strings in every language, and the e2e and node tests.
---
static/css/video.css | 124 ++++++++
static/index.html | 2 +
static/js/i18n.js | 287 ++++++++++++++++++
static/js/main.js | 4 +
static/js/player.js | 4 +
static/js/video/cameraRecorder.js | 115 ++++++++
static/js/video/captureAdapter.js | 169 +++++++++++
static/js/video/videoCodec.js | 146 ++++++++++
static/js/video/videoRender.js | 85 ++++++
static/js/video/videoTake.js | 463 ++++++++++++++++++++++++++++++
tests/e2e/video-take.spec.mjs | 341 ++++++++++++++++++++++
tests/js/video-codec.test.mjs | 105 +++++++
12 files changed, 1845 insertions(+)
create mode 100644 static/css/video.css
create mode 100644 static/js/video/cameraRecorder.js
create mode 100644 static/js/video/captureAdapter.js
create mode 100644 static/js/video/videoCodec.js
create mode 100644 static/js/video/videoRender.js
create mode 100644 static/js/video/videoTake.js
create mode 100644 tests/e2e/video-take.spec.mjs
create mode 100644 tests/js/video-codec.test.mjs
diff --git a/static/css/video.css b/static/css/video.css
new file mode 100644
index 00000000..c243b348
--- /dev/null
+++ b/static/css/video.css
@@ -0,0 +1,124 @@
+/* Camera in the record flow, and "Make video" for takes with a camera file.
+ Markup is built by static/js/video/videoTake.js inside #videoPanel. Every
+ value is a token from variables.css, so reduced motion and the palette
+ apply without anything here. */
+
+.video-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 8px 12px;
+ color: var(--fg-2);
+ font-size: 12px;
+}
+.video-panel[hidden] { display: none; }
+
+.vid-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+
+.vid-toggle,
+.vid-make,
+.vid-download,
+.vid-step {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 28px;
+ padding: 0 10px;
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--fg-2);
+ font: inherit;
+ cursor: pointer;
+ transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast);
+}
+.vid-toggle:hover:not(:disabled),
+.vid-make:hover:not(:disabled),
+.vid-download:hover,
+.vid-step:hover:not(:disabled) { background: var(--panel-3); color: var(--fg); border-color: var(--border-strong); }
+.vid-toggle:disabled,
+.vid-make:disabled,
+.vid-step:disabled { opacity: 0.5; cursor: default; }
+.vid-toggle.on { color: var(--accent); border-color: var(--accent); }
+.vid-toggle:focus-visible,
+.vid-make:focus-visible,
+.vid-download:focus-visible,
+.vid-step:focus-visible,
+.vid-select:focus-visible,
+.vid-offset:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
+.vid-step { width: 28px; padding: 0; justify-content: center; }
+.vid-make { color: var(--fg); }
+.vid-download[hidden],
+.vid-progress[hidden] { display: none; }
+
+.vid-select,
+.vid-offset {
+ height: 28px;
+ max-width: 220px;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--fg);
+ font: inherit;
+ padding: 0 6px;
+}
+.vid-offset { width: 72px; font-family: var(--font-mono); text-align: right; }
+
+.vid-check { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
+
+/* 16:9 tile at most 320 px wide: enough to check framing, small enough to
+ keep the lanes in view while playing. */
+.vid-preview-wrap {
+ position: relative;
+ width: min(320px, 100%);
+ aspect-ratio: 16 / 9;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+}
+.vid-preview-wrap[hidden] { display: none; }
+.vid-preview-wrap.recording { border-color: var(--danger); }
+.vid-preview { width: 100%; height: 100%; object-fit: cover; display: block; }
+.vid-preview.vid-mirrored { transform: scaleX(-1); }
+.vid-rec-badge {
+ position: absolute;
+ top: 6px;
+ left: 6px;
+ padding: 2px 6px;
+ border-radius: var(--radius-xs);
+ background: var(--danger);
+ color: var(--fg);
+ font-size: 11px;
+ font-weight: 600;
+}
+.vid-rec-badge[hidden] { display: none; }
+
+.vid-status { margin: 0; color: var(--muted); }
+.vid-status[hidden] { display: none; }
+
+.vid-takes { display: flex; flex-direction: column; gap: 6px; }
+.vid-takes[hidden] { display: none; }
+.vid-takes-head { color: var(--fg); font-weight: 600; }
+.vid-take {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 8px;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+}
+.vid-take-name { color: var(--fg); min-width: 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
+.vid-nudge { display: inline-flex; align-items: center; gap: 4px; }
+.vid-nudge-label,
+.vid-unit { color: var(--muted); }
+.vid-progress { width: 120px; accent-color: var(--accent); }
+.vid-state { color: var(--muted); }
+.vid-note { margin: 0; color: var(--muted-2); font-size: 11px; }
diff --git a/static/index.html b/static/index.html
index 924664c4..473d3bf8 100644
--- a/static/index.html
+++ b/static/index.html
@@ -11,6 +11,7 @@
+
@@ -1281,6 +1282,7 @@
diff --git a/static/js/i18n.js b/static/js/i18n.js
index c69ed66e..c66f496c 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -861,6 +861,8 @@ const en = {
"about.instagram": "Instagram",
"about.x": "X",
"about.credits": "Song details from AcoustID, MusicBrainz (CC0), Wikipedia (CC BY-SA) and Wikidata (CC0). Band profiles from Discogs, with a Discogs token set. Lyrics from LRCLIB. Transcription by OpenAI Whisper (MIT).",
+ "about.license": "StemDeck is free software under the GNU AGPL-3.0.",
+ "about.source": "Source code",
"friends.title": "We Recommend",
"friends.tagline": "StemDeck is free and does not accept any money, sponsorship, or funding from anyone listed below. I share these makers and artists and communities purely for the joy of pointing you toward wonderful people doing beautiful work. Go meet them ❤️",
@@ -2187,6 +2189,8 @@ const pl = {
"about.instagram": "Instagram",
"about.x": "X",
"about.credits": "Informacje o utworach: AcoustID, MusicBrainz (CC0), Wikipedia (CC BY-SA) i Wikidane (CC0). Profile zespołów: Discogs, gdy ustawiony jest token Discogs. Teksty: LRCLIB. Transkrypcja: OpenAI Whisper (MIT).",
+ "about.license": "StemDeck to wolne oprogramowanie na licencji GNU AGPL-3.0.",
+ "about.source": "Kod źródłowy",
"friends.title": "Polecamy",
"friends.tagline": "StemDeck jest darmowy i nie przyjmuje żadnych pieniędzy, sponsoringu ani finansowania od nikogo z wymienionych poniżej. Dzielę się tymi twórcami, artystami i społecznościami wyłącznie dla radości wskazania Ci wspaniałych ludzi tworzących piękne rzeczy. Poznaj ich ❤️",
@@ -3504,6 +3508,8 @@ const ja = {
"about.instagram": "Instagram",
"about.x": "X",
"about.credits": "曲の情報: AcoustID、MusicBrainz (CC0)、Wikipedia (CC BY-SA)、Wikidata (CC0)。バンドのプロフィール: Discogs (Discogs トークン設定時)。歌詞: LRCLIB。文字起こし: OpenAI Whisper (MIT)。",
+ "about.license": "StemDeck は GNU AGPL-3.0 で公開されているフリーソフトウェアです。",
+ "about.source": "ソースコード",
"friends.title": "おすすめ",
"friends.tagline": "StemDeck は無料で、以下に挙げた方々から金銭・スポンサー・資金提供を一切受け取っていません。素晴らしい仕事をする方々を紹介する喜びだけのために、これらの作り手やアーティスト、コミュニティを共有しています。ぜひ会いに行ってみてください ❤️",
@@ -4797,6 +4803,8 @@ const zhHans = {
"about.instagram": "Instagram",
"about.x": "X",
"about.credits": "歌曲信息来自 AcoustID、MusicBrainz (CC0)、维基百科 (CC BY-SA) 和维基数据 (CC0)。乐队简介来自 Discogs(需设置 Discogs 令牌)。歌词来自 LRCLIB。转写由 OpenAI Whisper (MIT) 完成。",
+ "about.license": "StemDeck 是采用 GNU AGPL-3.0 许可证的自由软件。",
+ "about.source": "源代码",
"friends.title": "推荐",
"friends.tagline": "StemDeck 是免费的,也不接受下列任何人的金钱、赞助或资助。分享这些创作者、艺术家和社区,纯粹是为了把你引向那些做着美好事情的了不起的人。去认识他们吧 ❤️",
@@ -6091,6 +6099,8 @@ const de = {
"about.instagram": "Instagram",
"about.x": "X",
"about.credits": "Songdaten von AcoustID, MusicBrainz (CC0), Wikipedia (CC BY-SA) und Wikidata (CC0). Bandprofile von Discogs, wenn ein Discogs-Token gesetzt ist. Songtexte von LRCLIB. Transkription mit OpenAI Whisper (MIT).",
+ "about.license": "StemDeck ist freie Software unter der GNU AGPL-3.0.",
+ "about.source": "Quellcode",
"friends.title": "Wir empfehlen",
"friends.tagline": "StemDeck ist kostenlos und nimmt von niemandem auf dieser Liste Geld, Sponsoring oder Förderung an. Ich teile diese Macher, Künstler und Communities allein aus Freude daran, dich auf wunderbare Menschen hinzuweisen, die schöne Arbeit leisten. Lern sie kennen ❤️",
@@ -7395,6 +7405,8 @@ const pt = {
"about.instagram": "Instagram",
"about.x": "X",
"about.credits": "Dados das músicas: AcoustID, MusicBrainz (CC0), Wikipédia (CC BY-SA) e Wikidata (CC0). Perfis de bandas: Discogs, com um token do Discogs definido. Letras: LRCLIB. Transcrição: OpenAI Whisper (MIT).",
+ "about.license": "O StemDeck é software livre sob a licença GNU AGPL-3.0.",
+ "about.source": "Código-fonte",
"friends.title": "Recomendamos",
"friends.tagline": "O StemDeck é gratuito e não aceita dinheiro, patrocínio ou financiamento de ninguém listado abaixo. Compartilho estes criadores, artistas e comunidades apenas pela alegria de apontar você para pessoas maravilhosas fazendo um trabalho lindo. Conheça-as ❤️",
@@ -8700,6 +8712,8 @@ const id = {
"about.instagram": "Instagram",
"about.x": "X",
"about.credits": "Info lagu dari AcoustID, MusicBrainz (CC0), Wikipedia (CC BY-SA), dan Wikidata (CC0). Profil band dari Discogs, jika token Discogs diatur. Lirik dari LRCLIB. Transkripsi oleh OpenAI Whisper (MIT).",
+ "about.license": "StemDeck adalah perangkat lunak bebas dengan lisensi GNU AGPL-3.0.",
+ "about.source": "Kode sumber",
"friends.title": "Rekomendasi",
"friends.tagline": "StemDeck gratis dan tidak menerima uang, sponsor, atau pendanaan dari siapa pun yang tercantum di bawah. Saya membagikan para pembuat, seniman, dan komunitas ini semata-mata demi kesenangan menunjukkan orang-orang hebat yang melakukan pekerjaan indah. Temui mereka ❤️",
@@ -9994,6 +10008,8 @@ const fr = {
"about.instagram": "Instagram",
"about.x": "X",
"about.credits": "Informations sur les morceaux : AcoustID, MusicBrainz (CC0), Wikipédia (CC BY-SA) et Wikidata (CC0). Profils de groupes : Discogs, si un jeton Discogs est défini. Paroles : LRCLIB. Transcription : OpenAI Whisper (MIT).",
+ "about.license": "StemDeck est un logiciel libre sous licence GNU AGPL-3.0.",
+ "about.source": "Code source",
"friends.title": "Nos recommandations",
"friends.tagline": "StemDeck est gratuit et n'accepte aucun argent, parrainage ni financement de la part des personnes citées ci-dessous. Je partage ces créateurs, artistes et communautés pour le seul plaisir de vous orienter vers des gens formidables qui font de belles choses. Allez les rencontrer ❤️",
@@ -11597,6 +11613,8 @@ const es = {
"about.instagram": "Instagram",
"about.x": "X",
"about.credits": "Datos de las canciones: AcoustID, MusicBrainz (CC0), Wikipedia (CC BY-SA) y Wikidata (CC0). Perfiles de grupos: Discogs, si hay un token de Discogs configurado. Letras: LRCLIB. Transcripción: OpenAI Whisper (MIT).",
+ "about.license": "StemDeck es software libre con licencia GNU AGPL-3.0.",
+ "about.source": "Código fuente",
"friends.title": "Recomendamos",
"friends.tagline": "StemDeck es gratuito y no acepta dinero, patrocinio ni financiación de nadie de los que aparecen abajo. Comparto a estos creadores, artistas y comunidades solo por el gusto de señalarte gente maravillosa haciendo un trabajo precioso. Ve a saludarlos ❤️",
@@ -12923,6 +12941,8 @@ const ko = {
"about.instagram": "Instagram",
"about.x": "X",
"about.credits": "곡 정보: AcoustID, MusicBrainz (CC0), 위키백과 (CC BY-SA), 위키데이터 (CC0). 밴드 프로필: Discogs (Discogs 토큰 설정 시). 가사: LRCLIB. 받아쓰기: OpenAI Whisper (MIT).",
+ "about.license": "StemDeck은 GNU AGPL-3.0 라이선스를 따르는 자유 소프트웨어입니다.",
+ "about.source": "소스 코드",
"friends.title": "추천",
"friends.tagline": "StemDeck은 무료이며, 아래에 있는 누구에게서도 돈이나 후원, 자금을 받지 않습니다. 좋은 일을 하는 멋진 사람들을 소개하는 즐거움만으로 이 창작자와 아티스트, 커뮤니티를 공유합니다. 한번 만나 보세요 ❤️",
diff --git a/tests/e2e/about-license.spec.mjs b/tests/e2e/about-license.spec.mjs
new file mode 100644
index 00000000..2be68783
--- /dev/null
+++ b/tests/e2e/about-license.spec.mjs
@@ -0,0 +1,31 @@
+// The About box states StemDeck's own license and links to its source.
+//
+// StemDeck is AGPL-3.0-only and its server version is used over a network, so
+// the About box is where a user finds the source (AGPL section 13). The link
+// goes to the same repository the GitHub button already opens.
+
+import { test, expect } from "@playwright/test";
+import { openStudio } from "./helpers.mjs";
+
+const REPO = "https://github.com/stemdeckapp/stemdeck";
+
+test("the About box names the AGPL and links to the source code", async ({ page }) => {
+ await openStudio(page);
+ await page.locator("#aboutBtn").click();
+ const line = page.locator("#aboutDialog .about-license");
+ await expect(line).toBeVisible();
+ await expect(line).toContainText("GNU AGPL-3.0");
+ const link = line.getByRole("link", { name: "Source code" });
+ await expect(link).toHaveAttribute("href", REPO);
+ await expect(link).toHaveAttribute("target", "_blank");
+ await expect(link).toHaveAttribute("rel", /noopener/);
+});
+
+test("the license line follows the interface language", async ({ page }) => {
+ await page.addInitScript(() => localStorage.setItem("stemdeck.language", JSON.stringify("de")));
+ await openStudio(page);
+ await page.locator("#aboutBtn").click();
+ const line = page.locator("#aboutDialog .about-license");
+ await expect(line).toContainText("freie Software");
+ await expect(line.getByRole("link", { name: "Quellcode" })).toHaveAttribute("href", REPO);
+});
From 1af1f0434a5f4ad7d876321689ac320508913c67 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 6 Oct 2026 17:12:36 +0100
Subject: [PATCH 047/300] test(e2e): run the browser suite in parallel shards
on separate ports and libraries
npm run test:e2e:parallel starts N Playwright shards (default 3, ports from 8601), each with its own backend port and library under the temp folder, and prints one combined summary.
---
package.json | 1 +
scripts/test-e2e-parallel.mjs | 131 ++++++++++++++++++++++++++++++++++
2 files changed, 132 insertions(+)
create mode 100644 scripts/test-e2e-parallel.mjs
diff --git a/package.json b/package.json
index da605793..72c3e9ae 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
"description": "Browser tests for static/js. The app itself has no build step and no runtime dependencies; this exists only so CI can drive a real browser (#339).",
"scripts": {
"test:e2e": "playwright test",
+ "test:e2e:parallel": "node scripts/test-e2e-parallel.mjs",
"test:e2e:headed": "playwright test --headed",
"test:acceptance": "playwright test -c tests/acceptance/playwright.acceptance.config.mjs",
"test:desktop": "playwright test -c tests/desktop/playwright.desktop.config.mjs",
diff --git a/scripts/test-e2e-parallel.mjs b/scripts/test-e2e-parallel.mjs
new file mode 100644
index 00000000..75334be6
--- /dev/null
+++ b/scripts/test-e2e-parallel.mjs
@@ -0,0 +1,131 @@
+#!/usr/bin/env node
+// Run the Playwright browser suite as N shards at the same time, each with its
+// own backend port and its own throwaway library, so they cannot share state.
+//
+// npm run test:e2e:parallel -- [--shards 3] [--base-port 8601] [extra playwright args]
+//
+// Why this is safe: playwright.config.mjs starts one backend per run from
+// STEMDECK_E2E_PORT, and tests/e2e/serve.sh keeps everything it writes under
+// STEMDECK_E2E_DIR. Shard i gets port base+i-1 and its own directory under the
+// OS temp folder, so there is nothing to share. Files stay whole inside a shard
+// (fullyParallel is off), same as the CI split in .github/workflows/ci.yml.
+//
+// The environment is passed through unchanged (PATH, UV_NO_SYNC,
+// UV_PROJECT_ENVIRONMENT, STEMDECK_FFMPEG_DIR), so `uv run` in serve.sh reuses
+// the environment you already have and never installs into a shared one.
+
+import { spawn } from "node:child_process";
+import { createRequire } from "node:module";
+import { mkdirSync, createWriteStream, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const require = createRequire(import.meta.url);
+
+export function parseArgs(argv) {
+ const opts = { shards: 3, basePort: 8601, extra: [] };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === "--shards") opts.shards = Number(argv[++i]);
+ else if (a.startsWith("--shards=")) opts.shards = Number(a.slice(9));
+ else if (a === "--base-port") opts.basePort = Number(argv[++i]);
+ else if (a.startsWith("--base-port=")) opts.basePort = Number(a.slice(12));
+ else opts.extra.push(a);
+ }
+ if (!Number.isInteger(opts.shards) || opts.shards < 1) throw new Error("--shards must be a whole number of 1 or more");
+ if (!Number.isInteger(opts.basePort) || opts.basePort < 1024 || opts.basePort + opts.shards > 65535) throw new Error("--base-port is out of range");
+ return opts;
+}
+
+// Pull the counts and the flaky and failed names out of a Playwright list-reporter log.
+export function parseSummary(text) {
+ const out = { passed: 0, skipped: 0, failed: 0, flaky: 0, flakyNames: [], failedNames: [] };
+ let section = null;
+ for (const raw of text.split(/\r?\n/)) {
+ const line = raw.replace(/\x1b\[[0-9;]*m/g, "");
+ let m = /^\s+(\d+) (passed|skipped|failed|flaky|did not run|interrupted)\b/.exec(line);
+ if (m) {
+ const key = m[2];
+ if (key in out) out[key] = Number(m[1]);
+ section = key === "flaky" || key === "failed" ? key : null;
+ continue;
+ }
+ m = /^\s+\[[^\]]+\] .*/.exec(line);
+ if (m && section) (section === "flaky" ? out.flakyNames : out.failedNames).push(line.trim());
+ }
+ return out;
+}
+
+const fmt = (ms) => `${Math.floor(ms / 60000)}m${String(Math.round((ms % 60000) / 1000)).padStart(2, "0")}s`;
+
+async function main() {
+ const opts = parseArgs(process.argv.slice(2));
+ const cli = require.resolve("@playwright/test/cli");
+ const root = join(tmpdir(), `stemdeck-e2e-parallel-${process.pid}`);
+ mkdirSync(root, { recursive: true });
+ const t0 = Date.now();
+ console.log(`running ${opts.shards} shards on ports ${opts.basePort}-${opts.basePort + opts.shards - 1}, logs in ${root}`);
+
+ const state = [];
+ const runs = [];
+ for (let i = 1; i <= opts.shards; i++) {
+ const port = opts.basePort + i - 1;
+ const dir = join(root, `shard${i}`).split(String.fromCharCode(92)).join("/");
+ const st = { i, port, total: 0, done: 0, bad: 0, buf: "", code: null, start: Date.now(), end: null };
+ state.push(st);
+ const log = createWriteStream(join(root, `shard${i}.log`));
+ // Each shard gets its own output folder: Playwright empties it at start, so a
+ // shared test-results folder would let one shard delete another's screenshots.
+ const args = [cli, "test", `--shard=${i}/${opts.shards}`, "--reporter=list", `--output=${join(root, `results${i}`)}`, ...opts.extra];
+ const child = spawn(process.execPath, args, {
+ env: { ...process.env, STEMDECK_E2E_PORT: String(port), STEMDECK_E2E_DIR: dir, FORCE_COLOR: "0" },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ const onData = (chunk) => {
+ log.write(chunk);
+ st.buf += chunk.toString();
+ let nl;
+ while ((nl = st.buf.indexOf("\n")) >= 0) {
+ const line = st.buf.slice(0, nl);
+ st.buf = st.buf.slice(nl + 1);
+ const t = /^Running (\d+) tests?/.exec(line);
+ if (t) st.total = Number(t[1]);
+ const r = /^\s*(\S+)\s+(\d+) \[[^\]]+\] /.exec(line);
+ if (r) {
+ st.done++;
+ if (/^(x|✘|×)$/.test(r[1])) st.bad++;
+ }
+ }
+ };
+ child.stdout.on("data", onData);
+ child.stderr.on("data", onData);
+ runs.push(new Promise((resolve) => child.on("close", (code) => { st.code = code; st.end = Date.now(); log.end(); resolve(); })));
+ }
+
+ const tick = setInterval(() => {
+ const parts = state.map((s) => `s${s.i} ${s.code === null ? `${s.done}/${s.total || "?"}` : `done(${s.code})`}${s.bad ? ` bad ${s.bad}` : ""}`);
+ console.log(`[${fmt(Date.now() - t0)}] ${parts.join(" | ")}`);
+ }, 15000);
+ await Promise.all(runs);
+ clearInterval(tick);
+
+ const { readFileSync } = await import("node:fs");
+ const sum = { passed: 0, skipped: 0, failed: 0, flaky: 0, flakyNames: [], failedNames: [] };
+ for (const s of state) {
+ const p = parseSummary(readFileSync(join(root, `shard${s.i}.log`), "utf8"));
+ for (const k of ["passed", "skipped", "failed", "flaky"]) sum[k] += p[k];
+ sum.flakyNames.push(...p.flakyNames);
+ sum.failedNames.push(...p.failedNames);
+ console.log(`shard ${s.i}: exit ${s.code}, ${p.passed} passed, ${p.skipped} skipped, ${p.failed} failed, ${p.flaky} flaky, ${fmt(s.end - s.start)}`);
+ }
+ console.log(`TOTAL: ${sum.passed} passed, ${sum.skipped} skipped, ${sum.failed} failed, ${sum.flaky} flaky of ${sum.passed + sum.skipped + sum.failed + sum.flaky}, wall time ${fmt(Date.now() - t0)}`);
+ for (const n of sum.failedNames) console.log(` FAILED ${n}`);
+ for (const n of sum.flakyNames) console.log(` FLAKY ${n}`);
+ const bad = state.some((s) => s.code !== 0);
+ if (!bad) { try { rmSync(root, { recursive: true, force: true }); } catch { /* keep logs */ } }
+ else console.log(`logs kept in ${root}`);
+ process.exit(bad ? 1 : 0);
+}
+
+import { pathToFileURL } from "node:url";
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
From 6e97a4ec7a17b2b435877ba79fd3a768d9653f90 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 6 Oct 2026 17:12:37 +0100
Subject: [PATCH 048/300] test: changed-areas helper says which suites a diff
needs
Prints browser, pytest, rust, node and i18n flags as JSON, so docs-only and Rust-only changes can skip the browser suite.
---
scripts/changed-areas.mjs | 64 +++++++++++++++++++++++++++++++++
tests/js/changed-areas.test.mjs | 46 ++++++++++++++++++++++++
2 files changed, 110 insertions(+)
create mode 100644 scripts/changed-areas.mjs
create mode 100644 tests/js/changed-areas.test.mjs
diff --git a/scripts/changed-areas.mjs b/scripts/changed-areas.mjs
new file mode 100644
index 00000000..8cb94d3a
--- /dev/null
+++ b/scripts/changed-areas.mjs
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+// Which test suites does a change need? Prints JSON, for example
+// {"base":"big-release","files":4,"browser":true,"pytest":false,"rust":false,"node":true,"i18n":true}
+//
+// node scripts/changed-areas.mjs [--base big-release] [--files a b c]
+//
+// The files are the diff of HEAD against the merge base with , plus
+// uncommitted and untracked changes. With --files the list is taken as given
+// (used by the tests, and handy for "what if I touch X").
+//
+// The rule (docs/plans/0.21.0/30-overnight-brief.md, "Test rule update"):
+// browser static/, templates/, tests/e2e/, playwright.config.mjs, the backend
+// routes the specs hit (app/api/, app/main.py, app/core/), package files
+// pytest any .py file, pyproject.toml, uv.lock, tests/ python
+// rust desktop/src-tauri/
+// node static/js, tests/js, scripts/*.mjs, package.json
+// i18n static/js/i18n.js
+// Pure docs, licence and metadata change nothing here: every flag is false.
+
+import { execFileSync } from "node:child_process";
+import { pathToFileURL } from "node:url";
+
+export function classify(files) {
+ const f = files.map((p) => p.split(String.fromCharCode(92)).join("/"));
+ const any = (test) => f.some(test);
+ const under = (...prefixes) => (p) => prefixes.some((x) => p.startsWith(x));
+ return {
+ browser: any(
+ (p) =>
+ under("static/", "templates/", "tests/e2e/", "app/api/", "app/core/")(p) ||
+ ["playwright.config.mjs", "app/main.py", "package.json", "package-lock.json"].includes(p),
+ ),
+ pytest: any((p) => p.endsWith(".py") || ["pyproject.toml", "uv.lock"].includes(p)),
+ rust: any(under("desktop/src-tauri/")),
+ node: any((p) => under("static/js/", "tests/js/")(p) || /^scripts\/.*\.mjs$/.test(p) || p === "package.json"),
+ i18n: any((p) => p === "static/js/i18n.js"),
+ };
+}
+
+function git(args) {
+ return execFileSync("git", args, { encoding: "utf8" }).split(/\r?\n/).filter(Boolean);
+}
+
+export function changedFiles(base) {
+ const mb = git(["merge-base", base, "HEAD"])[0];
+ const committed = git(["diff", "--name-only", `${mb}..HEAD`]);
+ const dirty = git(["diff", "--name-only", "HEAD"]);
+ const untracked = git(["ls-files", "--others", "--exclude-standard"]);
+ return [...new Set([...committed, ...dirty, ...untracked])];
+}
+
+function main() {
+ const argv = process.argv.slice(2);
+ let base = "big-release";
+ let files = null;
+ for (let i = 0; i < argv.length; i++) {
+ if (argv[i] === "--base") base = argv[++i];
+ else if (argv[i] === "--files") files = argv.slice(i + 1);
+ }
+ const list = files ?? changedFiles(base);
+ console.log(JSON.stringify({ base, files: list.length, ...classify(list) }));
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
diff --git a/tests/js/changed-areas.test.mjs b/tests/js/changed-areas.test.mjs
new file mode 100644
index 00000000..5dc96ae2
--- /dev/null
+++ b/tests/js/changed-areas.test.mjs
@@ -0,0 +1,46 @@
+// Which suites a diff needs: the skip rule for the browser suite and pytest.
+//
+// Run: node tests/js/changed-areas.test.mjs
+
+import { classify } from "../../scripts/changed-areas.mjs";
+import { parseArgs, parseSummary } from "../../scripts/test-e2e-parallel.mjs";
+
+let pass = 0;
+let fail = 0;
+const check = (name, cond, detail = "") => {
+ if (cond) {
+ pass++;
+ console.log(`PASS ${name}`);
+ } else {
+ fail++;
+ console.log(`FAIL ${name}${detail ? " -- " + detail : ""}`);
+ }
+};
+const flags = (files) => Object.entries(classify(files)).filter(([, v]) => v).map(([k]) => k).join(",");
+
+check("docs only needs nothing", flags(["README.md", "docs/plans/x.md", "LICENSE"]) === "");
+check("rust only", flags(["desktop/src-tauri/src/main.rs"]) === "rust");
+check("pipeline python is pytest only", flags(["app/pipeline/runner.py", "tests/test_pipeline_runner.py"]) === "pytest");
+check("api python needs browser and pytest", flags(["app/api/jobs.py"]) === "browser,pytest");
+check("main.py needs browser", classify(["app/main.py"]).browser);
+check("core needs browser", classify(["app/core/config.py"]).browser);
+check("static js needs browser and node", flags(["static/js/eq.js"]) === "browser,node");
+check("i18n table flags i18n", classify(["static/js/i18n.js"]).i18n);
+check("e2e spec needs browser", flags(["tests/e2e/search.spec.mjs"]) === "browser");
+check("node test needs node only", flags(["tests/js/vu-scale.test.mjs"]) === "node");
+const BS = String.fromCharCode(92);
+check("windows separators", classify(["static" + BS + "css" + BS + "app.css"]).browser);
+check("empty diff needs nothing", flags([]) === "");
+
+const o = parseArgs(["--shards", "2", "--base-port=9000", "tests/e2e/search.spec.mjs"]);
+check("parallel args", o.shards === 2 && o.basePort === 9000 && o.extra.length === 1);
+check("parallel defaults", parseArgs([]).shards === 3 && parseArgs([]).basePort === 8601);
+let threw = false;
+try { parseArgs(["--shards", "0"]); } catch { threw = true; }
+check("zero shards rejected", threw);
+const s = parseSummary(" 1 failed\n [chromium] > a.spec.mjs:3 > x\n 2 flaky\n [chromium] > b.spec.mjs:4 > y\n 8 skipped\n 539 passed (10.0m)\n");
+check("summary counts", s.passed === 539 && s.skipped === 8 && s.failed === 1 && s.flaky === 2);
+check("summary names", s.failedNames.length === 1 && s.flakyNames.length === 1);
+
+console.log(`\n${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);
From b3a3d9b26a32245f15b8ad541b2559375957b7aa Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 6 Oct 2026 18:54:12 +0100
Subject: [PATCH 049/300] license: replace the Apache-2.0 text with the
AGPL-3.0 text
The LICENSE file still held Apache-2.0 while the README, the notices and
every package manifest already said AGPL-3.0-only. The text is the
verbatim GNU AGPL v3 (19 November 2007), from GitHub's licenses API, as
gnu.org was unreachable. Also the beat detector's note on madmom, which
still called StemDeck an Apache-2.0 project.
---
LICENSE | 862 +++++++++++++++++++++++++++---------
app/pipeline/beat_detect.py | 2 +-
2 files changed, 662 insertions(+), 202 deletions(-)
diff --git a/LICENSE b/LICENSE
index 261eeb9e..0ad25db4 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,201 +1,661 @@
- Apache License
- Version 2.0, January 2004
- http://www.apache.org/licenses/
-
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
- 1. Definitions.
-
- "License" shall mean the terms and conditions for use, reproduction,
- and distribution as defined by Sections 1 through 9 of this document.
-
- "Licensor" shall mean the copyright owner or entity authorized by
- the copyright owner that is granting the License.
-
- "Legal Entity" shall mean the union of the acting entity and all
- other entities that control, are controlled by, or are under common
- control with that entity. For the purposes of this definition,
- "control" means (i) the power, direct or indirect, to cause the
- direction or management of such entity, whether by contract or
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
- outstanding shares, or (iii) beneficial ownership of such entity.
-
- "You" (or "Your") shall mean an individual or Legal Entity
- exercising permissions granted by this License.
-
- "Source" form shall mean the preferred form for making modifications,
- including but not limited to software source code, documentation
- source, and configuration files.
-
- "Object" form shall mean any form resulting from mechanical
- transformation or translation of a Source form, including but
- not limited to compiled object code, generated documentation,
- and conversions to other media types.
-
- "Work" shall mean the work of authorship, whether in Source or
- Object form, made available under the License, as indicated by a
- copyright notice that is included in or attached to the work
- (an example is provided in the Appendix below).
-
- "Derivative Works" shall mean any work, whether in Source or Object
- form, that is based on (or derived from) the Work and for which the
- editorial revisions, annotations, elaborations, or other modifications
- represent, as a whole, an original work of authorship. For the purposes
- of this License, Derivative Works shall not include works that remain
- separable from, or merely link (or bind by name) to the interfaces of,
- the Work and Derivative Works thereof.
-
- "Contribution" shall mean any work of authorship, including
- the original version of the Work and any modifications or additions
- to that Work or Derivative Works thereof, that is intentionally
- submitted to Licensor for inclusion in the Work by the copyright owner
- or by an individual or Legal Entity authorized to submit on behalf of
- the copyright owner. For the purposes of this definition, "submitted"
- means any form of electronic, verbal, or written communication sent
- to the Licensor or its representatives, including but not limited to
- communication on electronic mailing lists, source code control systems,
- and issue tracking systems that are managed by, or on behalf of, the
- Licensor for the purpose of discussing and improving the Work, but
- excluding communication that is conspicuously marked or otherwise
- designated in writing by the copyright owner as "Not a Contribution."
-
- "Contributor" shall mean Licensor and any individual or Legal Entity
- on behalf of whom a Contribution has been received by Licensor and
- subsequently incorporated within the Work.
-
- 2. Grant of Copyright License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- copyright license to reproduce, prepare Derivative Works of,
- publicly display, publicly perform, sublicense, and distribute the
- Work and such Derivative Works in Source or Object form.
-
- 3. Grant of Patent License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- (except as stated in this section) patent license to make, have made,
- use, offer to sell, sell, import, and otherwise transfer the Work,
- where such license applies only to those patent claims licensable
- by such Contributor that are necessarily infringed by their
- Contribution(s) alone or by combination of their Contribution(s)
- with the Work to which such Contribution(s) was submitted. If You
- institute patent litigation against any entity (including a
- cross-claim or counterclaim in a lawsuit) alleging that the Work
- or a Contribution incorporated within the Work constitutes direct
- or contributory patent infringement, then any patent licenses
- granted to You under this License for that Work shall terminate
- as of the date such litigation is filed.
-
- 4. Redistribution. You may reproduce and distribute copies of the
- Work or Derivative Works thereof in any medium, with or without
- modifications, and in Source or Object form, provided that You
- meet the following conditions:
-
- (a) You must give any other recipients of the Work or
- Derivative Works a copy of this License; and
-
- (b) You must cause any modified files to carry prominent notices
- stating that You changed the files; and
-
- (c) You must retain, in the Source form of any Derivative Works
- that You distribute, all copyright, patent, trademark, and
- attribution notices from the Source form of the Work,
- excluding those notices that do not pertain to any part of
- the Derivative Works; and
-
- (d) If the Work includes a "NOTICE" text file as part of its
- distribution, then any Derivative Works that You distribute must
- include a readable copy of the attribution notices contained
- within such NOTICE file, excluding those notices that do not
- pertain to any part of the Derivative Works, in at least one
- of the following places: within a NOTICE text file distributed
- as part of the Derivative Works; within the Source form or
- documentation, if provided along with the Derivative Works; or,
- within a display generated by the Derivative Works, if and
- wherever such third-party notices normally appear. The contents
- of the NOTICE file are for informational purposes only and
- do not modify the License. You may add Your own attribution
- notices within Derivative Works that You distribute, alongside
- or as an addendum to the NOTICE text from the Work, provided
- that such additional attribution notices cannot be construed
- as modifying the License.
-
- You may add Your own copyright statement to Your modifications and
- may provide additional or different license terms and conditions
- for use, reproduction, or distribution of Your modifications, or
- for any such Derivative Works as a whole, provided Your use,
- reproduction, and distribution of the Work otherwise complies with
- the conditions stated in this License.
-
- 5. Submission of Contributions. Unless You explicitly state otherwise,
- any Contribution intentionally submitted for inclusion in the Work
- by You to the Licensor shall be under the terms and conditions of
- this License, without any additional terms or conditions.
- Notwithstanding the above, nothing herein shall supersede or modify
- the terms of any separate license agreement you may have executed
- with Licensor regarding such Contributions.
-
- 6. Trademarks. This License does not grant permission to use the trade
- names, trademarks, service marks, or product names of the Licensor,
- except as required for reasonable and customary use in describing the
- origin of the Work and reproducing the content of the NOTICE file.
-
- 7. Disclaimer of Warranty. Unless required by applicable law or
- agreed to in writing, Licensor provides the Work (and each
- Contributor provides its Contributions) on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
- implied, including, without limitation, any warranties or conditions
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
- PARTICULAR PURPOSE. You are solely responsible for determining the
- appropriateness of using or redistributing the Work and assume any
- risks associated with Your exercise of permissions under this License.
-
- 8. Limitation of Liability. In no event and under no legal theory,
- whether in tort (including negligence), contract, or otherwise,
- unless required by applicable law (such as deliberate and grossly
- negligent acts) or agreed to in writing, shall any Contributor be
- liable to You for damages, including any direct, indirect, special,
- incidental, or consequential damages of any character arising as a
- result of this License or out of the use or inability to use the
- Work (including but not limited to damages for loss of goodwill,
- work stoppage, computer failure or malfunction, or any and all
- other commercial damages or losses), even if such Contributor
- has been advised of the possibility of such damages.
-
- 9. Accepting Warranty or Additional Liability. While redistributing
- the Work or Derivative Works thereof, You may choose to offer,
- and charge a fee for, acceptance of support, warranty, indemnity,
- or other liability obligations and/or rights consistent with this
- License. However, in accepting such obligations, You may act only
- on Your own behalf and on Your sole responsibility, not on behalf
- of any other Contributor, and only if You agree to indemnify,
- defend, and hold each Contributor harmless for any liability
- incurred by, or claims asserted against, such Contributor by reason
- of your accepting any such warranty or additional liability.
-
- END OF TERMS AND CONDITIONS
-
- APPENDIX: How to apply the Apache License to your work.
-
- To apply the Apache License to your work, attach the following
- boilerplate notice, with the fields enclosed by brackets "[]"
- replaced with your own identifying information. (Don't include
- the brackets!) The text should be enclosed in the appropriate
- comment syntax for the file format. We also recommend that a
- file or class name and description of purpose be included on the
- same "printed page" as the copyright notice for easier
- identification within third-party archives.
-
- Copyright [yyyy] [name of copyright owner]
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ 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), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/app/pipeline/beat_detect.py b/app/pipeline/beat_detect.py
index 38ccf766..d2bb4d06 100644
--- a/app/pipeline/beat_detect.py
+++ b/app/pipeline/beat_detect.py
@@ -23,7 +23,7 @@
STEMDECK_BEAT_DETECTOR=librosa.
Deliberately *not* madmom: its pretrained models are CC BY-NC-SA 4.0, which
-would quietly make an Apache-2.0 project non-commercial for everyone who
+would quietly make an AGPL-3.0 project non-commercial for everyone who
installs it. Its DBN post-processor is BSD and model-free, and beat_this can use
it, but doing so needs a git install with an undeclared Cython build step on
three platforms. The interior-gap fill in beatgrid.py recovers what the DBN was
From 39eea4b8aefc9e382678416de8257bf91e5df600 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 6 Oct 2026 18:58:09 +0100
Subject: [PATCH 050/300] fix(eq): no click on every EQ knob step
Each knob step started its ramp with cancelAndHoldAtTime, which in
Chromium jumped the parameter: rendering tones through the EQ slot while
sweeping a bell band, the largest second difference was 0.17 to 0.30
against 0.001 for the steady signal. The ramp now starts from the
parameter's current value: 0.001 for every sweep, through 0 dB or not.
Board: EQ MVP.
---
static/js/fxChain.js | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/static/js/fxChain.js b/static/js/fxChain.js
index fef54cba..bb058d4a 100644
--- a/static/js/fxChain.js
+++ b/static/js/fxChain.js
@@ -36,13 +36,13 @@ function rampTo(param, value, ctx, immediate, time = PARAM_RAMP) {
param.value = value;
return;
}
- if (typeof param.cancelAndHoldAtTime === "function") {
- param.cancelAndHoldAtTime(now);
- } else {
- const current = param.value;
- param.cancelScheduledValues?.(now);
- param.setValueAtTime?.(current, now);
- }
+ // Not cancelAndHoldAtTime: measured in Chromium, holding a ramp that is
+ // still running and starting the next from it clicked on every knob step
+ // (a 0.22 jump against 0.001 for the same sweep this way). The value the
+ // param has now is where the next ramp starts.
+ const current = param.value;
+ param.cancelScheduledValues?.(now);
+ param.setValueAtTime?.(current, now);
if (typeof param.linearRampToValueAtTime === "function") {
param.linearRampToValueAtTime(value, now + time);
} else {
From 811f8e115bac6d745bae89ef9ea8ed9b4790178c Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 6 Oct 2026 18:58:20 +0100
Subject: [PATCH 051/300] feat(eq): live spectrum behind the Bass, Guitar and
Keys EQ curves
Only the graphical EQ (Drums, Original, Other, tracks) drew the lane's
spectrum; the knob panels drew their curve on an empty background. The
knob panels now draw it too, from the same analyser tap and the same
Off/Pre/Post choice, which moves to eqPanelCommon.js so both panels read
it. Checked on a playing song: the spectrum fills all three curves.
Board: Bass EQ, Guitar EQ, Keys EQ.
---
static/js/eq/eqGraphPanel.js | 25 +------
static/js/eq/eqPanelCommon.js | 122 ++++++++++++++++++++++++++++++++--
static/js/eq/eqPedalPanel.js | 9 ++-
3 files changed, 126 insertions(+), 30 deletions(-)
diff --git a/static/js/eq/eqGraphPanel.js b/static/js/eq/eqGraphPanel.js
index 5f395dfe..e1f40671 100644
--- a/static/js/eq/eqGraphPanel.js
+++ b/static/js/eq/eqGraphPanel.js
@@ -22,16 +22,11 @@ import {
} from "./eqGraphMath.js";
import {
el, button, readColors, containKeys, createEditState, buildToolbar, bindUndoKeys, exportRateDiffers,
+ ANALYZER_MODES, SPEC_FALL_DB, readAnalyzerMode, saveAnalyzerMode,
} from "./eqPanelCommon.js";
import { graphPresets } from "./eqPresets.js";
import { createSuggest } from "./eqSuggest.js";
-const ANALYZER_KEY = "stemdeck.eqAnalyzer";
-const ANALYZER_MODES = ["off", "pre", "post"];
-// The spectrum falls back this many dB per frame at most (about 18 dB/s at
-// 60 frames a second): a separated drum stem is near silence between hits,
-// and a faster fall made it flicker out of sight.
-const SPEC_FALL_DB = 0.3;
// One option per type, cuts once per slope: the list's single type menu.
const TYPE_OPTIONS = [
["bell", 12], ["lowshelf", 12], ["highshelf", 12],
@@ -40,24 +35,6 @@ const TYPE_OPTIONS = [
["notch", 12], ["bandpass", 12], ["tilt", 12],
];
-function readAnalyzerMode() {
- try {
- const v = localStorage.getItem(ANALYZER_KEY);
- return ANALYZER_MODES.includes(v) ? v : "post";
- } catch (e) {
- console.warn("[eq] reading the analyser choice failed:", e);
- return "post";
- }
-}
-
-function saveAnalyzerMode(v) {
- try {
- localStorage.setItem(ANALYZER_KEY, v);
- } catch (e) {
- console.warn("[eq] saving the analyser choice failed:", e);
- }
-}
-
function typeLabel(band) {
const name = t(`eq.type.${band.type}`);
return hasSlope(band.type) ? `${name} ${t("eq.slope", { n: band.slope })}` : name;
diff --git a/static/js/eq/eqPanelCommon.js b/static/js/eq/eqPanelCommon.js
index 39c4bb71..eb137241 100644
--- a/static/js/eq/eqPanelCommon.js
+++ b/static/js/eq/eqPanelCommon.js
@@ -1,13 +1,42 @@
// What every EQ panel shares: the edit state of one lane (undo and redo, A/B
// compare, presets, reset), the toolbar in the panel's mixer-side row, a
-// small response curve, the canvas colours from the design tokens, and the
+// small response curve with the lane's live spectrum behind it, the analyser
+// choice, the canvas colours from the design tokens, and the
// keyboard containment that keeps panel keys away from the studio's global
// shortcuts (Space plays, Delete moves the song to the Trash).
import * as eq from "../eqEngine.js";
import { eqResponseDb } from "../eqMath.js";
import { t } from "../i18n.js";
-import { createHistory, fmtDb } from "./eqGraphMath.js";
+import {
+ createHistory, fmtDb, SPEC_MIN_DB, SPEC_MAX_DB, SPEC_TILT_DB_OCT, columnBins, spectrumFrame,
+} from "./eqGraphMath.js";
+
+const ANALYZER_KEY = "stemdeck.eqAnalyzer";
+export const ANALYZER_MODES = ["off", "pre", "post"];
+// The spectrum falls back this many dB per frame at most (about 18 dB/s at
+// 60 frames a second): a separated drum stem is near silence between hits,
+// and a faster fall made it flicker out of sight.
+export const SPEC_FALL_DB = 0.3;
+
+/** The analyser choice (Off, Pre, Post), one for every panel; "post" when none is saved. */
+export function readAnalyzerMode() {
+ try {
+ const v = localStorage.getItem(ANALYZER_KEY);
+ return ANALYZER_MODES.includes(v) ? v : "post";
+ } catch (e) {
+ console.warn("[eq] reading the analyser choice failed:", e);
+ return "post";
+ }
+}
+
+export function saveAnalyzerMode(v) {
+ try {
+ localStorage.setItem(ANALYZER_KEY, v);
+ } catch (e) {
+ console.warn("[eq] saving the analyser choice failed:", e);
+ }
+}
export function el(tag, cls, text) {
const e = document.createElement(tag);
@@ -224,17 +253,20 @@ export function bindUndoKeys(root, state, after) {
/**
* A small response curve (pedal and keys panels): the lane's EQ drawn from
- * the descriptor at the live rate, with a +-15 dB scale. Not animated: it is
- * drawn when the EQ changes.
+ * the descriptor at the live rate, with a +-15 dB scale, over the lane's
+ * live spectrum when one is given (createLiveSpectrum redraws it each frame).
*/
export function createMiniCurve(canvas, lane) {
const freqs = new Float64Array(160);
for (let i = 0; i < freqs.length; i++) freqs[i] = 20 * 1000 ** (i / (freqs.length - 1));
let colors = null;
let ghost = null;
+ let spectrum = null;
return {
/** A descriptor to draw dashed over the lane's curve (a suggestion), or null. */
setGhost(d) { ghost = d; },
+ /** dB per pixel column across the width (createLiveSpectrum), or null for none. */
+ setSpectrum(vals) { spectrum = vals; },
draw() {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
@@ -258,6 +290,22 @@ export function createMiniCurve(canvas, lane) {
const x = Math.round((Math.log(f / 20) / Math.log(1000)) * w) + 0.5;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
}
+ if (spectrum?.length > 1) {
+ const span = SPEC_MAX_DB - SPEC_MIN_DB;
+ const step = w / (spectrum.length - 1);
+ ctx.beginPath();
+ ctx.moveTo(0, h);
+ for (let c = 0; c < spectrum.length; c++) {
+ const v = (spectrum[c] - SPEC_MIN_DB) / span;
+ ctx.lineTo(c * step, h - Math.max(0, Math.min(1, v)) * h);
+ }
+ ctx.lineTo(w, h);
+ ctx.closePath();
+ ctx.fillStyle = colors.spectrum;
+ ctx.fill();
+ ctx.strokeStyle = colors.spectrumLine;
+ ctx.stroke();
+ }
ctx.strokeStyle = colors.zero;
ctx.beginPath(); ctx.moveTo(0, Math.round(h / 2) + 0.5); ctx.lineTo(w, Math.round(h / 2) + 0.5); ctx.stroke();
ctx.fillStyle = colors.label;
@@ -300,6 +348,72 @@ export function createMiniCurve(canvas, lane) {
};
}
+/**
+ * The lane's live spectrum for a mini curve: one dB value per pixel column
+ * from 20 Hz to 20 kHz, handed to `onFrame` every animation frame while the
+ * page is visible. It follows the analyser choice of the graph panels (Off,
+ * Pre, Post) and draws nothing when that is Off. `columns()` is the curve's
+ * width in px. The analyser tap is taken lazily, retried once a second until
+ * the audio engine is up, and given back on destroy.
+ */
+export function createLiveSpectrum(lane, { columns, onFrame }) {
+ const mode = readAnalyzerMode();
+ let node = null;
+ let data = null;
+ let smooth = null;
+ let bins = null;
+ let rate = 0;
+ let raf = 0;
+ let lastTry = -1e9;
+ let destroyed = false;
+
+ function frame(ts) {
+ raf = 0;
+ if (destroyed || document.hidden) return;
+ if (!node && ts - lastTry > 1000) {
+ lastTry = ts;
+ node = eq.getAnalyser(lane, mode, { fftSize: 8192, smoothing: 0.8 });
+ if (node) { data = new Float32Array(node.frequencyBinCount); bins = null; }
+ }
+ const cols = Math.round(columns());
+ if (node && cols > 1) {
+ if (!bins || bins.lo.length !== cols || rate !== node.context.sampleRate) {
+ rate = node.context.sampleRate;
+ bins = columnBins(cols, node.frequencyBinCount, rate, { x: 0, y: 0, w: cols, h: 1 }, SPEC_TILT_DB_OCT);
+ smooth = new Float32Array(cols).fill(SPEC_MIN_DB);
+ }
+ node.getFloatFrequencyData(data);
+ onFrame(spectrumFrame(bins, data, smooth, SPEC_FALL_DB));
+ }
+ raf = requestAnimationFrame(frame);
+ }
+
+ function start() {
+ if (raf || destroyed || mode === "off" || document.hidden) return;
+ raf = requestAnimationFrame(frame);
+ }
+
+ const onVisibility = () => {
+ if (document.hidden) { cancelAnimationFrame(raf); raf = 0; } else start();
+ };
+ document.addEventListener("visibilitychange", onVisibility);
+ // A song (re)load rebuilt the engine: its analyser taps are new.
+ const offChange = eq.onChange(({ laneId }) => { if (laneId === null) node = null; });
+ start();
+
+ return {
+ destroy() {
+ destroyed = true;
+ if (raf) cancelAnimationFrame(raf);
+ raf = 0;
+ document.removeEventListener("visibilitychange", onVisibility);
+ offChange();
+ if (node) eq.releaseAnalyser(lane, mode);
+ node = null;
+ },
+ };
+}
+
/** Whether exports can differ from what plays (the live rate is not 44.1 kHz). */
export function exportRateDiffers() {
const rate = eq.liveSampleRate();
diff --git a/static/js/eq/eqPedalPanel.js b/static/js/eq/eqPedalPanel.js
index 07e3fe28..f2783b61 100644
--- a/static/js/eq/eqPedalPanel.js
+++ b/static/js/eq/eqPedalPanel.js
@@ -1,6 +1,6 @@
// The Bass EQ and Guitar EQ panels (and, with another layout, the Keys EQ):
// an original pedal-like faceplate of knobs with a small live response curve
-// beside it. The controls and their ranges follow the reference pedals'
+// beside it, over the lane's live spectrum. The controls and their ranges follow the reference pedals'
// published manuals (eqPedalMath.js has the sources and the mapping); the
// artwork, names and layout are StemDeck's own.
//
@@ -20,7 +20,7 @@ import {
import { controlPresets } from "./eqPresets.js";
import { createSuggest } from "./eqSuggest.js";
import {
- el, containKeys, createEditState, buildToolbar, bindUndoKeys, createMiniCurve,
+ el, containKeys, createEditState, buildToolbar, bindUndoKeys, createMiniCurve, createLiveSpectrum,
} from "./eqPanelCommon.js";
const round1 = (v) => Math.round(v * 10) / 10;
@@ -138,6 +138,10 @@ export function mountPanel({ lane, kind, body, controls }) {
body.append(root);
containKeys(body);
const curve = createMiniCurve(curveCv, lane);
+ const spectrum = createLiveSpectrum(lane, {
+ columns: () => curveCv.clientWidth,
+ onFrame: (vals) => { curve.setSpectrum(vals); curve.draw(); },
+ });
// ─── Mixer side ───
const toolbar = buildToolbar(controls, state, {
@@ -235,6 +239,7 @@ export function mountPanel({ lane, kind, body, controls }) {
suggest,
destroy() {
suggest.destroy();
+ spectrum.destroy();
offChange();
ro?.disconnect();
if (queued) cancelAnimationFrame(queued);
From 56b47cbac406f9196c2bb8cbb05c2c9e7f384527 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 6 Oct 2026 18:58:20 +0100
Subject: [PATCH 052/300] feat(eq): Drum EQ band list on the left, like the
knob panels
A lane's graphical panel put its band list right of the graph, while the
Bass, Guitar and Keys panels put their controls on the left. The list
(or the Suggest card in its place) now comes first, in the same boxed
faceplate as the knob panels. The master's panel keeps its own layout.
Board: Drum EQ and general EQ.
---
static/css/eq.css | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/static/css/eq.css b/static/css/eq.css
index ceafddcc..2c096a92 100644
--- a/static/css/eq.css
+++ b/static/css/eq.css
@@ -787,3 +787,23 @@
color: var(--muted);
line-height: 1.4;
}
+
+/* A lane's graphical panel reads like the knob panels: the controls (the
+ band list, or the Suggest card in its place) on the left, the graph on the
+ right. The master's panel keeps the list on the right. */
+.eq-panel-lane .eq-graph-panel .eq-band-list,
+.eq-panel-lane .eq-graph-panel .eq-suggest-card {
+ order: -1;
+ margin: 8px 0 8px 10px;
+}
+/* The band list in the knob panels' faceplate box, so the two panels match. */
+.eq-panel-lane .eq-graph-panel .eq-band-list {
+ border: 1px solid var(--eq-plate-edge);
+ border-radius: var(--radius-sm);
+ background:
+ linear-gradient(180deg, color-mix(in srgb, var(--eq-plate) 100%, var(--fg) 3%), var(--eq-plate));
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 2px 10px rgba(0, 0, 0, 0.35);
+ padding: 6px 10px 8px;
+ box-sizing: border-box;
+}
+.eq-panel-lane .eq-graph-panel .eq-graph-wrap { margin: 8px 12px 8px 12px; }
From d4d0aa823c35f1ed72e507445261f35e5e03b5df Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 6 Oct 2026 18:58:33 +0100
Subject: [PATCH 053/300] feat(mixer): side-rail track header (#776)
Each mixer row is now a card and two columns the full height of the row:
- the card: the lane's colour swatch and name, a short fader with its
level in dB beside it, and one meter line (level, peak hold behind it);
- a key column level with the rail: +, KEY, the offset, minus;
- the rail: M, S, the EQ tab and download, in the same place on every row.
Rows stay compact (at most 90 px, four 22 px rail cells), so a tall window
ends the stack early instead of stretching every row; laneLayout takes the
cap and both columns keep the same total.
A recording track uses the same card: its menu on the name's line, the arm
button and input picker on the bottom line, its name aligned with the
stems'. The add-track box moves from the Mixer header to a dashed box under
the last extracted stem, with an empty row as tall at the same place in the
waveform column, so new tracks land below it and the lanes stay level.
Solo and mute show on both columns: a muted lane fades, and while any lane
is soloed every other lane goes dark and blurred. The row's contents fade,
not the row, so the separators stay.
Specs updated for the new selectors (not run: e2e is paused);
track-header.spec.mjs still describes the previous header.
Board: Track header (#776), EQ tab.
---
static/css/daw.css | 274 +++++++++++++++++++++++++++++++++
static/css/recording.css | 31 ++--
static/index.html | 5 +-
static/js/capture/takeLanes.js | 46 ++++--
static/js/eq/eqPanelKinds.js | 9 +-
static/js/i18n.js | 10 ++
static/js/mixer.js | 123 +++++++++++----
static/js/player.js | 42 ++++-
tests/e2e/eq-panels.spec.mjs | 4 +-
tests/e2e/transpose.spec.mjs | 12 +-
tests/js/eq-panels.test.mjs | 3 +
11 files changed, 484 insertions(+), 75 deletions(-)
diff --git a/static/css/daw.css b/static/css/daw.css
index 16d3ce08..7dbc0864 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -2417,6 +2417,280 @@ input, textarea { font-family: inherit; }
.mx-fader-input { touch-action: pan-y; }
+/* ── The side-rail header (mixer.js renderMixerRow) ──
+ A card on the left and a rail on the right edge. The card: the lane's
+ swatch and name, the fader with its level, two thin meter bars under the
+ fader, then the key. The rail: mute, solo, the EQ tab and download
+ stacked, the full height of the row, so they sit in the same place on every
+ row. Colours are the app's own: the lane's colour (--lane-color, set by
+ mixer.js from STEM_COLORS) and the existing M/S/EQ state colours. */
+.lane-header.mx-row.mx-row-rail {
+ position: relative;
+ align-items: stretch;
+ gap: 10px;
+ padding: 0 0 0 12px;
+}
+.mx-card {
+ flex: 1;
+ min-width: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-areas:
+ "head head"
+ "fader fader"
+ "meter meter"
+ "actions actions";
+ align-content: center;
+ row-gap: 7px;
+ padding: 8px 0;
+}
+.mx-head {
+ grid-area: head;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+.mx-swatch {
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+ background: var(--lane-color, var(--muted));
+ flex-shrink: 0;
+}
+.mx-card .mx-name {
+ font-size: calc(var(--mx-name-font) + 3px);
+ font-weight: 700;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.mx-faderline {
+ grid-area: fader;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ height: var(--mx-ctl-h);
+}
+/* The fader and its meter are short, enough travel to set a level, and the
+ level reads right beside the fader. */
+.mx-faderline .lane-knob { flex: 0 0 84px; width: 84px; }
+.mx-faderline .mx-val {
+ width: auto;
+ min-width: 58px;
+ display: inline-flex;
+ align-items: baseline;
+ justify-content: flex-start;
+ gap: 3px;
+}
+.mx-val-unit {
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--muted);
+}
+/* The meter: one thin line under the fader, as long as the fader's track.
+ The level and its peak hold share it, the hold fainter behind the level. */
+.mx-card .lane-vu.mx-meter {
+ grid-area: meter;
+ height: auto;
+ width: 84px;
+ display: grid;
+ background: none;
+ border: 0;
+ border-radius: 0;
+ overflow: visible;
+}
+.mx-meter-track {
+ grid-area: 1 / 1;
+ height: 3px;
+ border-radius: 2px;
+ background: rgba(255, 255, 255, 0.06);
+ overflow: hidden;
+}
+.mx-card .lane-vu-bar.mx-meter-fill,
+.mx-card .lane-vu-bar.mx-meter-hold {
+ display: block;
+ position: static;
+ height: 100%;
+ border-radius: 2px;
+ background: linear-gradient(90deg, #22c55e 0%, #86efac 80%, #f97316 95%, #ef4444 100%);
+ transition: width 40ms linear;
+}
+.mx-card .lane-vu-bar.mx-meter-fill { width: var(--vu-level, 0%); }
+.mx-card .lane-vu-bar.mx-meter-hold { width: var(--vu-peak, 0%); opacity: 0.55; }
+.mx-meter-track:has(.mx-meter-fill) { background: transparent; position: relative; z-index: 1; }
+
+.mx-actions {
+ grid-area: actions;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+/* The key: a column of its own beside the rail, in four equal cells level
+ with the rail's M, S, EQ and download: plus, KEY, the offset, minus. The
+ reading's wrapper steps aside (display: contents) so its label and number
+ are cells of their own; `order` sets the top-to-bottom sequence, since the
+ DOM keeps minus, reading, plus for the horizontal stepper. */
+.mx-keyrail {
+ display: flex;
+ width: 34px;
+ flex-shrink: 0;
+ border-left: 1px solid var(--border);
+}
+.mx-keyrail .lane-key {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ height: auto;
+}
+.mx-keyrail .lane-key-value { display: contents; }
+.mx-keyrail .lane-key-step,
+.mx-keyrail .lane-key-label,
+.mx-keyrail .lane-key-num {
+ flex: 1 1 0;
+ min-height: 0;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 1;
+}
+.mx-keyrail .lane-key-step.up { order: 1; }
+.mx-keyrail .lane-key-label { order: 2; }
+.mx-keyrail .lane-key-num { order: 3; }
+.mx-keyrail .lane-key-step.down { order: 4; }
+.mx-keyrail .lane-key-step {
+ height: auto;
+ padding: 0;
+ border-radius: 0;
+ font-size: var(--mx-ctl-font);
+ color: var(--fg-2);
+}
+.mx-keyrail .lane-key-step:hover { background: var(--panel-2); color: var(--fg); }
+.mx-keyrail .lane-key-label { font-size: 8px; letter-spacing: 0.06em; text-indent: 0.06em; }
+.mx-keyrail .lane-key-num {
+ font-family: var(--font-mono);
+ font-size: var(--mx-ctl-font);
+ font-weight: 700;
+}
+.lane-key-label {
+ font-weight: 600;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+.lane-key-num { color: var(--fg-2); }
+.mx-keyrail .lane-key.active .lane-key-value { background: transparent; border: 0; }
+.mx-keyrail .lane-key.active .lane-key-num { color: var(--lane-color, var(--accent)); }
+/* Drums: the control stays whole, dimmed, explaining itself on hover. */
+.mx-keyrail .lane-key.locked { opacity: 0.45; }
+
+/* The rail: four equal cells the full height of the row. */
+.mx-rail {
+ display: flex;
+ flex-direction: column;
+ width: 46px;
+ flex-shrink: 0;
+ border-left: 1px solid var(--border);
+}
+.mx-rail > .mx-btn {
+ flex: 1;
+ width: 100%;
+ height: auto;
+ min-height: 0;
+ border: 0;
+ border-radius: 0;
+}
+.mx-rail > .mx-btn:hover { background: var(--panel-2); }
+/* The EQ tab as a rail cell: the M/S face, its dot after the label, no
+ chevron. The slot keeps its cell while the tab is not there yet. */
+.mx-rail > .mx-eq-slot {
+ flex: 1;
+ width: 100%;
+ min-height: 0;
+}
+.mx-rail .eq-tab {
+ height: 100%;
+ padding: 0;
+ gap: 3px;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+}
+.mx-rail .eq-tab:hover { background: var(--panel-2); }
+.mx-rail .eq-tab::after { display: none; }
+.mx-rail .eq-tab:not(.active):not(.bypassed) .eq-tab-dot { display: none; }
+.mx-rail .eq-tab-dot { width: 6px; height: 6px; }
+
+/* What the ear hears, on both columns (mixer.js paintLaneSilence): a muted
+ lane fades; while any lane is soloed, every other lane goes dark and
+ blurred. The row's contents fade, never the row itself: each row draws the
+ separator under it, and fading the row took the line between it and the
+ next lane with it. Hovering a dimmed mixer row, or reaching it with the
+ keyboard, brings it back into focus so its M and S stay easy to find. Not
+ :focus-within: a clicked S keeps focus, so the row it just un-soloed stayed
+ lit after the pointer left. */
+.mx-row-rail > *,
+.stem-waveform-row > * {
+ transition: opacity var(--t-base) var(--easing), filter var(--t-base) var(--easing);
+}
+.lane-header.mx-row.mx-row-rail.muted { opacity: 1; }
+.mx-row-rail.muted > * { opacity: 0.45; }
+.stem-waveform-row.lane-muted > * { opacity: 0.3; }
+.mx-row-rail.solo-dimmed > *,
+.stem-waveform-row.solo-dimmed > * {
+ opacity: 0.35;
+ filter: blur(1.5px) brightness(0.6);
+}
+.mx-row-rail.solo-dimmed:hover > *,
+.mx-row-rail.solo-dimmed:has(:focus-visible) > * {
+ opacity: 0.8;
+ filter: none;
+}
+
+/* Short rows (when many lanes share the height): the same parts on fewer
+ lines. The EQ tab and the key go beside the name, the fader and its meter
+ under them. */
+@container mxrow (max-height: 107px) {
+ .mx-card {
+ row-gap: 3px;
+ padding: 4px 0;
+ }
+ .mx-card .mx-name { font-size: var(--mx-name-font); }
+ .mx-faderline { height: 22px; }
+ .mx-meter-track { height: 2px; }
+ .mx-actions { gap: 5px; }
+}
+/* A stem has nothing on the actions line; it takes no room. */
+.mx-actions:empty { display: none; }
+
+/* A recording track's take folder arrow sits in the row's left gutter, so
+ the track's name lines up with every stem's. */
+.lane-header.mx-row.mx-row-rail > .take-folder-toggle {
+ position: absolute;
+ left: 0;
+ top: 50%;
+ width: 12px;
+ margin: 0;
+ transform: translateY(-50%);
+}
+
+/* A recording track in the same card (capture/takeLanes.js): the arm button
+ and the input picker where a stem has its key, the track menu at the end of
+ the name's line. */
+.mx-head .take-menu-btn { margin-left: auto; flex-shrink: 0; }
+.mx-actions .track-input-wrap { flex: 1 1 auto; min-width: 0; }
+@container mxrow (max-height: 107px) {
+ /* A track's arm and picker on the card's bottom line, low enough that the
+ four lines fit the row. */
+ .track-lane .mx-card { row-gap: 2px; }
+ .track-lane .mx-faderline { height: 18px; }
+ .track-lane .mx-actions { height: 22px; }
+ .track-lane .mx-actions > * { max-height: 22px; }
+}
+
/* ── Waveform panel (right) ── */
/* Layout only — waves.css handles all visual styling inside (.wave-scroll bg,
golden scrollbar, .loop-region, .waves-grid, .stem-waveform-layer etc.) */
diff --git a/static/css/recording.css b/static/css/recording.css
index 41c65548..a89cd6fe 100644
--- a/static/css/recording.css
+++ b/static/css/recording.css
@@ -242,20 +242,33 @@
/* ── Recording tracks (static/js/capture/takeLanes.js) ── */
/* "+ Track" in the mixer header. */
+/* The add-track box: a dashed box under the last extracted stem, above the
+ recording tracks (player.js placeAddTrackRow sets its CSS order; the
+ default keeps it last until then). 28px plus 6px above and below:
+ player.js ADD_TRACK_ROW_HEIGHT counts these 40px into the stack, and the
+ waveform column has an empty row as tall at the same place. */
.add-track-btn {
- margin-left: auto;
- margin-right: 8px;
- padding: 2px 8px;
- border: 1px solid var(--border-strong);
- border-radius: var(--radius-xs);
- background: var(--panel-2);
- color: var(--fg-2);
+ order: 2147483000;
+ flex: 0 0 28px;
+ height: 28px;
+ margin: 6px 12px;
+ padding: 0 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ border: 1px dashed var(--border-strong);
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--muted);
font-family: var(--font-mono);
font-size: 11px;
+ font-weight: 600;
cursor: pointer;
- transition: background var(--t-fast), color var(--t-fast);
+ transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast);
}
-.add-track-btn:hover:not(:disabled) { background: var(--panel-3); color: var(--fg); }
+.add-track-spacer { flex: 0 0 40px; height: 40px; }
+.add-track-btn:hover:not(:disabled) { background: var(--panel-2); color: var(--fg); border-color: var(--muted-2); }
.add-track-btn:disabled { opacity: 0.4; cursor: default; }
.add-track-btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
/* Why other tracks were disarmed: a short note under the mixer header,
diff --git a/static/index.html b/static/index.html
index 849b5c1d..cd1eb752 100644
--- a/static/index.html
+++ b/static/index.html
@@ -606,8 +606,9 @@
Lyrics
Mixer
-
+ (static/js/capture/takeLanes.js). The button is moved to the
+ end of the mixer's lanes once a song is open. -->
+
Drag fader · M/S
diff --git a/static/js/capture/takeLanes.js b/static/js/capture/takeLanes.js
index a6ce59b6..4436b21a 100644
--- a/static/js/capture/takeLanes.js
+++ b/static/js/capture/takeLanes.js
@@ -49,6 +49,19 @@ export const NUDGE_MAX_MS = 2000;
let _jobId = null;
let _tracks = [];
+// The add-track box at the end of the mixer's lanes. Held here because a
+// song load empties the mixer, and the button with its listener goes back in
+// on every sync.
+let _addBtn = null;
+// Its place in the stack: right after the last extracted stem, before the
+// tracks (player.js placeAddTrackRow sets it).
+let _addOrder = "";
+
+/** The CSS order of the add-track box, the same as its spacer in the waveform column. */
+export function setAddTrackOrder(order) {
+ _addOrder = String(order);
+ if (_addBtn) _addBtn.style.order = _addOrder;
+}
const _armed = new Set();
let _devices = [];
// Take folders need the SVG lane overlay (player.js, the Web Audio engine
@@ -197,11 +210,15 @@ export function syncTakeLanes(jobId, stems) {
setTakeLanes(_tracks.map((tr) => ({ id: tr.id, label: tr.name })));
ensureMixerStateDefaults();
closeTakeMenu();
- const add = document.getElementById("addTrackBtn");
- if (add) add.disabled = !jobId;
+ if (_addBtn) _addBtn.disabled = !jobId;
if (!mixerEl) return;
for (const row of mixerEl.querySelectorAll(".lane-header[data-take], .take-folder-row")) row.remove();
for (const track of _tracks) mixerEl.appendChild(buildRow(track));
+ if (_addBtn) {
+ _addBtn.hidden = !jobId;
+ _addBtn.style.order = _addOrder;
+ mixerEl.appendChild(_addBtn);
+ }
renderFolderRows();
renderArmButtons();
refreshTrackInputs();
@@ -249,21 +266,17 @@ function buildRow(track) {
openTrackMenu(menuBtn, track);
});
- // The stem rows' two lines (mixer.js renderMixerRow), with a track's own
- // controls in them, all the same size as M and S. The first line: the EQ
- // tab, the fader, the track menu and arm, where a stem shows its level.
- // The second: the input picker, then M, S and download, in the same
- // columns as the stems' M, S and download above. On a row tall enough the
- // picker takes a line of its own under the buttons (recording.css), so
- // "MG-XU · Input 1+2" or a microphone's name reads in full. The wrapper
- // draws the chevron, so the select stays a native, keyboard-friendly
- // control.
+ // The stem rows' side-rail card (mixer.js renderMixerRow), with a track's
+ // own controls in it: the arm button and the input picker on the card's
+ // bottom line, the track menu at the end of the name's line. The key column
+ // stays, empty, so the fader and the rail line up with the stems'. The
+ // wrapper draws the picker's chevron, so the select stays a native,
+ // keyboard-friendly control.
const picker = document.createElement("span");
picker.className = "track-input-wrap";
picker.append(input);
- row.querySelector(".mx-val")?.remove();
- row.querySelector(".mx-line-a")?.append(menuBtn, arm);
- row.querySelector(".mx-line-b")?.prepend(picker);
+ row.querySelector(".mx-actions")?.append(arm, picker);
+ row.querySelector(".mx-head")?.append(menuBtn);
row.insertBefore(buildFolderToggle(track), row.querySelector(".mx-icon"));
return row;
}
@@ -659,7 +672,7 @@ async function onInputChange(track, select) {
export async function addTrack() {
const jobId = _jobId || currentJobId;
if (!jobId) return null;
- const btn = document.getElementById("addTrackBtn");
+ const btn = _addBtn;
if (btn) btn.disabled = true;
try {
const input = await defaultTrackInput();
@@ -676,7 +689,8 @@ export async function addTrack() {
}
export function initTrackControls() {
- document.getElementById("addTrackBtn")?.addEventListener("click", (e) => {
+ _addBtn = document.getElementById("addTrackBtn");
+ _addBtn?.addEventListener("click", (e) => {
e.stopPropagation();
addTrack();
});
diff --git a/static/js/eq/eqPanelKinds.js b/static/js/eq/eqPanelKinds.js
index 5b5636ea..db1d43ed 100644
--- a/static/js/eq/eqPanelKinds.js
+++ b/static/js/eq/eqPanelKinds.js
@@ -74,12 +74,15 @@ export function openPanelsHeight(lanes, isOpen, kindOf) {
/**
* Lane height when the panel stack shares the wave panel with the lanes: the
- * lanes divide what the open panels leave, never under `minRow`.
+ * lanes divide what the open panels leave, never under `minRow` and, when
+ * `maxRow` is given, never over it (the stack then ends above the panel's
+ * bottom instead of stretching every row).
* Returns {rowH, stackH} where stackH = count * rowH + panelsPx.
*/
-export function laneLayout(panelH, count, panelsPx, minRow) {
+export function laneLayout(panelH, count, panelsPx, minRow, maxRow = Infinity) {
const free = Math.max(0, (panelH || 0) - (panelsPx || 0));
- const rowH = panelH > 0 && count > 0 ? Math.max(minRow, Math.floor(free / count)) : minRow;
+ const fill = panelH > 0 && count > 0 ? Math.floor(free / count) : minRow;
+ const rowH = Math.max(minRow, Math.min(maxRow, fill));
return { rowH, stackH: count * rowH + (panelsPx || 0) };
}
diff --git a/static/js/i18n.js b/static/js/i18n.js
index c69ed66e..94e634b6 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -559,6 +559,7 @@ const en = {
"aria.laneKeyUp": "Transpose {name} up one semitone",
"aria.laneKeyDown": "Transpose {name} down one semitone",
"mixer.key.title": "Transpose this lane: {n} semitones",
+ "mixer.key.label": "Key",
"mixer.key.drumsLocked": "Drums are never transposed",
"aria.soloOnly": "Solo only {name}",
"aria.download": "Download {name}",
@@ -1887,6 +1888,7 @@ const pl = {
"aria.laneKeyUp": "Transponuj {name} o półton w górę",
"aria.laneKeyDown": "Transponuj {name} o półton w dół",
"mixer.key.title": "Transpozycja tej ścieżki: {n} półtonu",
+ "mixer.key.label": "Tonacja",
"mixer.key.drumsLocked": "Perkusja nigdy nie jest transponowana",
"aria.soloOnly": "Tylko solo: {name}",
"aria.download": "Pobierz: {name}",
@@ -3205,6 +3207,7 @@ const ja = {
"aria.laneKeyUp": "{name}を半音上げる",
"aria.laneKeyDown": "{name}を半音下げる",
"mixer.key.title": "このトラックの移調: {n} 半音",
+ "mixer.key.label": "キー",
"mixer.key.drumsLocked": "ドラムは移調されません",
"aria.soloOnly": "{name}のみソロ",
"aria.download": "{name}をダウンロード",
@@ -4498,6 +4501,7 @@ const zhHans = {
"aria.laneKeyUp": "将{name}升高半音",
"aria.laneKeyDown": "将{name}降低半音",
"mixer.key.title": "此音轨移调:{n} 个半音",
+ "mixer.key.label": "调",
"mixer.key.drumsLocked": "鼓声部不会被移调",
"aria.soloOnly": "仅独奏{name}",
"aria.download": "下载{name}",
@@ -5791,6 +5795,7 @@ const de = {
"aria.laneKeyUp": "{name} um einen Halbton höher transponieren",
"aria.laneKeyDown": "{name} um einen Halbton tiefer transponieren",
"mixer.key.title": "Transposition dieser Spur: {n} Halbtöne",
+ "mixer.key.label": "Tonart",
"mixer.key.drumsLocked": "Schlagzeug wird nie transponiert",
"aria.soloOnly": "Nur {name} solo",
"aria.download": "{name} herunterladen",
@@ -7095,6 +7100,7 @@ const pt = {
"aria.laneKeyUp": "Transpor {name} um semitom para cima",
"aria.laneKeyDown": "Transpor {name} um semitom para baixo",
"mixer.key.title": "Transposição desta faixa: {n} semitons",
+ "mixer.key.label": "Tom",
"mixer.key.drumsLocked": "A bateria nunca é transposta",
"aria.soloOnly": "Apenas solo {name}",
"aria.download": "Baixar {name}",
@@ -8401,6 +8407,7 @@ const id = {
"aria.laneKeyUp": "Naikkan {name} satu semiton",
"aria.laneKeyDown": "Turunkan {name} satu semiton",
"mixer.key.title": "Transposisi trek ini: {n} semiton",
+ "mixer.key.label": "Nada",
"mixer.key.drumsLocked": "Drum tidak pernah ditransposisi",
"aria.soloOnly": "Hanya solo {name}",
"aria.download": "Unduh {name}",
@@ -9694,6 +9701,7 @@ const fr = {
"aria.laneKeyUp": "Transposer {name} d'un demi-ton vers le haut",
"aria.laneKeyDown": "Transposer {name} d'un demi-ton vers le bas",
"mixer.key.title": "Transposition de cette piste : {n} demi-tons",
+ "mixer.key.label": "Ton",
"mixer.key.drumsLocked": "La batterie n'est jamais transposée",
"aria.soloOnly": "Solo uniquement {name}",
"aria.download": "Télécharger {name}",
@@ -11297,6 +11305,7 @@ const es = {
"aria.laneKeyUp": "Transponer {name} un semitono hacia arriba",
"aria.laneKeyDown": "Transponer {name} un semitono hacia abajo",
"mixer.key.title": "Transposición de esta pista: {n} semitonos",
+ "mixer.key.label": "Tono",
"mixer.key.drumsLocked": "La batería nunca se transpone",
"aria.soloOnly": "Solo únicamente {name}",
"aria.download": "Descargar {name}",
@@ -12624,6 +12633,7 @@ const ko = {
"aria.laneKeyUp": "{name} 반음 올리기",
"aria.laneKeyDown": "{name} 반음 내리기",
"mixer.key.title": "이 트랙 조옮김: {n} 반음",
+ "mixer.key.label": "키",
"mixer.key.drumsLocked": "드럼은 조옮김하지 않아요",
"aria.soloOnly": "{name}만 솔로",
"aria.download": "{name} 다운로드",
diff --git a/static/js/mixer.js b/static/js/mixer.js
index 66683e17..378fbf84 100644
--- a/static/js/mixer.js
+++ b/static/js/mixer.js
@@ -145,10 +145,10 @@ export function refreshLaneKeyVisual(name) {
if (!wrap) return;
const n = mixerState[name]?.pitch ?? 0;
const signed = n > 0 ? `+${n}` : String(n);
- const valueEl = wrap.querySelector(".lane-key-value");
- // "K" while the lane is in its own key, so the control reads as a label
- // until it is doing something, and as a number the moment it is.
- if (valueEl) valueEl.textContent = n === 0 ? "K" : signed;
+ // The number beside the "KEY" label: 0 in the lane's own key, the signed
+ // offset once it is moved.
+ const numEl = wrap.querySelector(".lane-key-num");
+ if (numEl) numEl.textContent = n === 0 ? "0" : signed;
wrap.classList.toggle("active", n !== 0);
const locked = wrap.classList.contains("locked");
@@ -231,7 +231,12 @@ export function updateLaneKnobVisual(knobEl, v) {
const val = knobEl.closest(".lane-header")?.querySelector(".mx-val");
if (val) {
const db = v <= 0 ? "-∞" : (20 * Math.log10(v)).toFixed(1);
- val.textContent = db === "-∞" ? "-∞" : `${parseFloat(db) > 0 ? "+" : ""}${db}`;
+ const text = db === "-∞" ? "-∞" : `${parseFloat(db) > 0 ? "+" : ""}${db}`;
+ // The side-rail header keeps the unit in its own span; other rows (the
+ // recording tracks) still carry the bare number.
+ const num = val.querySelector(".mx-val-num");
+ if (num) num.textContent = text;
+ else val.textContent = text;
}
}
@@ -245,7 +250,31 @@ export function setLaneVolume(name, v) {
saveMix();
}
+/**
+ * What the ear hears, shown on both columns: a muted lane fades, and while
+ * any lane is soloed every lane that is not fades darker and blurs. The
+ * mixer row and the lane's waveform row get the same two classes (daw.css).
+ * The waveform rows are redrawn on their own schedule, so this runs again on
+ * every "stemdeck:overview-drawn".
+ */
+function paintLaneSilence() {
+ const names = allTrackNames();
+ const anySolo = names.some((n) => trackIndex[n] !== undefined && mixerState[n]?.soloed);
+ for (const name of names) {
+ const state = mixerState[name];
+ if (!state) continue;
+ const dimmed = anySolo && !state.soloed && !state.muted;
+ const sel = `[data-stem="${CSS.escape(name)}"]`;
+ for (const node of document.querySelectorAll(`.lane-header.mx-row${sel}, .stem-waveform-row${sel}`)) {
+ node.classList.toggle("lane-muted", state.muted);
+ node.classList.toggle("solo-dimmed", dimmed);
+ }
+ }
+}
+document.addEventListener("stemdeck:overview-drawn", paintLaneSilence);
+
export function refreshMixerVisuals() {
+ paintLaneSilence();
for (const name of allTrackNames()) {
const state = mixerState[name];
if (!state) continue;
@@ -565,13 +594,19 @@ export function renderMixerRow(stem) {
const vu = document.createElement("div");
vu.className = "lane-vu mx-meter";
vu.dataset.stem = stem.name;
- vu.innerHTML = '';
-
- // Col 5: value label
+ // Two thin bars under the fader: the level, and its peak hold (the loop in
+ // player.js already sets --vu-level and --vu-peak). The analysers are mono,
+ // so the second bar is the hold, not a right channel.
+ vu.innerHTML =
+ '
' +
+ '
';
+
+ // Col 5: value label, the number and its unit
const val = document.createElement("span");
val.className = "mx-val";
+ val.innerHTML = 'dB';
const initFrac = Math.max(0, Math.min(1, (state?.volume ?? 1) / LANE_VOLUME_MAX));
- val.textContent = String(Math.round(initFrac * 100));
+ val.querySelector(".mx-val-num").textContent = String(Math.round(initFrac * 100));
// Col 6: this lane's key, just left of the mute button: minus, the
// reading, plus, along the line like M and S beside it.
@@ -603,32 +638,46 @@ export function renderMixerRow(stem) {
dl.title = t("aria.download", { name: display });
dl.appendChild(downloadIcon());
- // Wrap name + VU in a column so VU appears below the name
- const nameVuCol = document.createElement("div");
- nameVuCol.className = "lane-name-vu";
- nameVuCol.append(nameEl, vu);
-
- // The controls, on two lines beside the name (#776: large enough to read
- // from across a room, in the same 300px column and the same row height).
- // The first line is the signal path, left to right: the EQ tab
- // (eq/eqRows.js puts it in the slot), the fader, the level it sets. The
- // second is the lane's switches: key, mute, solo, download. The two lines
- // are centred on the row's middle, which is where the name sits, level
- // with its waveform.
+ // The side-rail header (#776: readable from across a room, in the same
+ // 300px column and the same row height). A card on the left: the name with
+ // the lane's colour swatch, the fader and the level it sets, the meter under
+ // the fader, and a line for a recording track's own controls
+ // (capture/takeLanes.js). On the right two columns the full height of the
+ // row: the key (+, KEY and the offset, minus) and the rail of mute, solo,
+ // the EQ tab (eq/eqRows.js fills the slot) and download, so they sit in the
+ // same place on every row.
+ const swatch = document.createElement("span");
+ swatch.className = "mx-swatch";
+ swatch.setAttribute("aria-hidden", "true");
+ const head = document.createElement("div");
+ head.className = "mx-head";
+ head.append(swatch, nameEl);
+
+ const faderLine = document.createElement("div");
+ faderLine.className = "mx-faderline";
+ faderLine.append(fader, val);
+
const eqSlot = document.createElement("div");
eqSlot.className = "mx-eq-slot";
eqSlot.dataset.stem = stem.name;
- const lineA = document.createElement("div");
- lineA.className = "mx-line mx-line-a";
- lineA.append(eqSlot, fader, val);
- const lineB = document.createElement("div");
- lineB.className = "mx-line mx-line-b";
- lineB.append(key, muteBtn, soloBtn, dl);
- const strip = document.createElement("div");
- strip.className = "mx-strip";
- strip.append(lineA, lineB);
-
- row.append(iconCell, nameVuCol, strip);
+ const actions = document.createElement("div");
+ actions.className = "mx-actions";
+
+ const card = document.createElement("div");
+ card.className = "mx-card";
+ card.append(head, faderLine, vu, actions);
+
+ const keyRail = document.createElement("div");
+ keyRail.className = "mx-keyrail";
+ keyRail.append(key);
+
+ const rail = document.createElement("div");
+ rail.className = "mx-rail";
+ rail.append(muteBtn, soloBtn, eqSlot, dl);
+
+ row.classList.add("mx-row-rail");
+ row.style.setProperty("--lane-color", color);
+ row.append(iconCell, card, keyRail, rail);
muteBtn.addEventListener("click", () => toggleStemMute(stem.name));
soloBtn.addEventListener("click", () => toggleStemSolo(stem.name));
@@ -661,9 +710,17 @@ function makeLaneKey(stemName, display) {
up.textContent = "+";
up.setAttribute("aria-label", t("aria.laneKeyUp", { name: display }));
+ // The middle cell: a small "KEY" label and the lane's offset in semitones.
const valueEl = document.createElement("span");
valueEl.className = "lane-key-value";
- valueEl.textContent = "K";
+ const keyLabel = document.createElement("span");
+ keyLabel.className = "lane-key-label";
+ keyLabel.dataset.i18n = "mixer.key.label";
+ keyLabel.textContent = t("mixer.key.label");
+ const keyNum = document.createElement("span");
+ keyNum.className = "lane-key-num";
+ keyNum.textContent = "0";
+ valueEl.append(keyLabel, keyNum);
const down = document.createElement("button");
down.type = "button";
diff --git a/static/js/player.js b/static/js/player.js
index 3c4562e6..5d437756 100644
--- a/static/js/player.js
+++ b/static/js/player.js
@@ -11,7 +11,7 @@ import {
effectiveStemOrder, STEM_DISPLAY, isTakeName,
} from "./constants.js";
import {
- syncTakeLanes, withTrackPeaks, setTakeFoldersEnabled, takeFolderLanes, takeFolderRowCount,
+ syncTakeLanes, withTrackPeaks, setAddTrackOrder, setTakeFoldersEnabled, takeFolderLanes, takeFolderRowCount,
syncTakeFolderOrder, selectTake,
} from "./capture/takeLanes.js";
import { laneOrder, takeLaneOrder } from "./capture/takeFolderModel.js";
@@ -183,6 +183,7 @@ function applyStemSelectionFilter(presentNames) {
}
syncTakeFolderOrder();
syncEqRowOrder();
+ placeAddTrackRow(laneOrder(order.filter((n) => !isTakeName(n)).length) - 1);
for (const row of document.querySelectorAll(".energy-row[data-stem]")) {
row.classList.toggle("hidden", !shown(row.dataset.stem));
}
@@ -262,6 +263,14 @@ const OVERVIEW_WAVE_POINTS = 1500;
const STEM_VU_FPS = 30;
const WAVEFORM_LANE_HEIGHT = 70;
const WAVEFORM_SEPARATOR_HEIGHT = 2;
+// The tallest a row grows: the mixer's rail of four 22px cells (M, S, EQ,
+// download) plus the separator. A tall window ends the stack early rather
+// than stretching every row.
+const LANE_ROW_MAX_HEIGHT = 4 * 22 + WAVEFORM_SEPARATOR_HEIGHT;
+// The add-track box under the last lane (takeLanes.js, daw.css .add-track-btn
+// in the mixer): its height with its margins. The stack counts it in both
+// columns, so the two scroll the same distance.
+const ADD_TRACK_ROW_HEIGHT = 40;
let visualRenderToken = 0;
// Rows in the stack before any take folder opens (stems, original, tracks).
let _baseVisibleCount = 0;
@@ -310,6 +319,29 @@ function ensureOverviewWaveformLayer() {
return layer;
}
+// The add-track box sits after the last extracted stem and above the
+// recording tracks, so new tracks land under it. The mixer has the box
+// (takeLanes.js) and the waveform column an empty row of the same height at
+// the same place, so the lanes under it stay level in both columns.
+let _addTrackRowOrder = "";
+function placeAddTrackRow(order) {
+ _addTrackRowOrder = String(order);
+ setAddTrackOrder(order);
+ ensureAddTrackSpacer();
+}
+
+function ensureAddTrackSpacer() {
+ const layer = ensureOverviewWaveformLayer();
+ let spacer = layer.querySelector(":scope > .add-track-spacer");
+ if (!spacer) {
+ spacer = document.createElement("div");
+ spacer.className = "add-track-spacer";
+ spacer.setAttribute("aria-hidden", "true");
+ layer.appendChild(spacer);
+ }
+ spacer.style.order = _addTrackRowOrder;
+}
+
// Standard DAW-style waveform: track min and max raw sample values per
// pixel column. The signed peaks let us render the natural mirror-
// symmetric shape (top edge follows max, bottom follows min) and keeps
@@ -581,6 +613,7 @@ function renderAllOverviewWaveformsFromPeaks(stems, peaksData) {
renderOverviewWaveformPath(name, pts, norm, STEM_COLORS[name] || "#a0a0a0", bars, i);
});
renderTakeFolderLanes(ensureOverviewWaveformLayer(), laneNames, peaksData, norm, bars);
+ ensureAddTrackSpacer();
// The EQ tabs live in the lane rows just rewritten, and an open EQ panel
// goes right under its lane.
syncEqRows();
@@ -1192,12 +1225,13 @@ function _applyLaneHeight(count) {
// separator short. Two pixels over six lanes, which is why a stem name and
// its waveform ended up on different lines by the bottom of the mixer.
//
- // So the row is the unit. Divide the panel by the row count, and give the
- // mixer and the waveform column exactly the same total.
+ // So the row is the unit. Divide the panel by the row count, up to
+ // LANE_ROW_MAX_HEIGHT, and give the mixer and the waveform column exactly
+ // the same total.
//
// An open EQ panel is a row of its own fixed height in both columns
// (eq/eqRows.js); the lanes share what the panels leave.
- const { rowH, stackH } = laneLayout(panelH, count, eqPanelsHeight(), WAVEFORM_LANE_HEIGHT + WAVEFORM_SEPARATOR_HEIGHT);
+ const { rowH, stackH } = laneLayout(panelH, count, eqPanelsHeight() + ADD_TRACK_ROW_HEIGHT, WAVEFORM_LANE_HEIGHT + WAVEFORM_SEPARATOR_HEIGHT, LANE_ROW_MAX_HEIGHT);
const appEl = document.querySelector(".app");
appEl?.style.setProperty("--lane-h", `${rowH}px`);
appEl?.style.setProperty("--wave-widget-track-stack-h", `${stackH}px`);
diff --git a/tests/e2e/eq-panels.spec.mjs b/tests/e2e/eq-panels.spec.mjs
index 36ed477d..35fe0d50 100644
--- a/tests/e2e/eq-panels.spec.mjs
+++ b/tests/e2e/eq-panels.spec.mjs
@@ -127,8 +127,8 @@ test("every lane has an EQ tab that opens its own panel, and the columns stay al
await openStudio(page);
for (const lane of ["vocals", "drums", "bass", "other"]) {
await expect(tab(page, lane)).toBeVisible();
- // In the lane's own header, on the fader's line, before the fader.
- await expect(page.locator(`.mixer-column .lane-header[data-stem="${lane}"] .mx-line-a > .mx-eq-slot > .eq-tab`)).toHaveAttribute("data-eq-lane", lane);
+ // In the lane's own header, in the rail under M and S.
+ await expect(page.locator(`.mixer-column .lane-header[data-stem="${lane}"] .mx-rail > .mx-eq-slot > .eq-tab`)).toHaveAttribute("data-eq-lane", lane);
}
// The waveform lanes carry only their waveforms.
await expect(page.locator(".waves-column .eq-tab")).toHaveCount(0);
diff --git a/tests/e2e/transpose.spec.mjs b/tests/e2e/transpose.spec.mjs
index e19ea64e..62e6f08f 100644
--- a/tests/e2e/transpose.spec.mjs
+++ b/tests/e2e/transpose.spec.mjs
@@ -17,7 +17,7 @@ const reset = (page) => page.locator("#t-pitch-reset");
const laneKey = (page, stem) => page.locator(`.lane-key[data-stem="${stem}"]`);
const laneUp = (page, stem) => laneKey(page, stem).locator(".lane-key-step.up");
const laneDown = (page, stem) => laneKey(page, stem).locator(".lane-key-step.down");
-const laneValue = (page, stem) => laneKey(page, stem).locator(".lane-key-value");
+const laneValue = (page, stem) => laneKey(page, stem).locator(".lane-key-num");
// Input 6 is semitone 0, so input (6 + n) is a shift of n semitones.
const ZERO_INPUT = 6;
@@ -202,7 +202,7 @@ test.describe("per-lane transpose", () => {
await openStudio(page);
await expect(laneKey(page, "vocals")).toBeVisible();
// Reads as a label until it is doing something, then as a number.
- await expect(laneValue(page, "vocals")).toHaveText("K");
+ await expect(laneValue(page, "vocals")).toHaveText("0");
await expect(laneKey(page, "vocals")).not.toHaveClass(/active/);
});
@@ -228,7 +228,7 @@ test.describe("per-lane transpose", () => {
await expect(laneValue(page, "vocals")).toHaveText("-1");
await laneUp(page, "vocals").click();
- await expect(laneValue(page, "vocals")).toHaveText("K");
+ await expect(laneValue(page, "vocals")).toHaveText("0");
await expect(laneKey(page, "vocals")).not.toHaveClass(/active/);
});
@@ -252,7 +252,7 @@ test.describe("per-lane transpose", () => {
await expect(laneValue(page, "vocals")).toHaveText("+2");
await expect(laneValue(page, "bass")).toHaveText("+2");
// Except drums, which never move.
- await expect(laneValue(page, "drums")).toHaveText("K");
+ await expect(laneValue(page, "drums")).toHaveText("0");
});
test("a lane keeps its distance when the global key moves", async ({ page }) => {
@@ -311,8 +311,8 @@ test.describe("per-lane transpose", () => {
// individually, stepping the global back to zero cannot undo them.
await expect(value(page)).toHaveText("0");
await expect(value(page)).not.toHaveClass(/active/);
- await expect(laneValue(page, "vocals")).toHaveText("K");
- await expect(laneValue(page, "bass")).toHaveText("K");
+ await expect(laneValue(page, "vocals")).toHaveText("0");
+ await expect(laneValue(page, "bass")).toHaveText("0");
await expect(laneKey(page, "vocals")).not.toHaveClass(/active/);
});
diff --git a/tests/js/eq-panels.test.mjs b/tests/js/eq-panels.test.mjs
index ae608498..c737cda1 100644
--- a/tests/js/eq-panels.test.mjs
+++ b/tests/js/eq-panels.test.mjs
@@ -61,6 +61,9 @@ const dbAt = (eq, f) => eqResponseDb(eq, [f], 44100)[0];
const tight = laneLayout(500, 6, 304, 72);
check("lanes never shrink under the minimum (the stack scrolls)", tight.rowH === 72 && tight.stackH === 6 * 72 + 304);
check("no panel: the old rule (floor of panel / count)", laneLayout(901, 5, 0, 72).rowH === 180);
+ const capped = laneLayout(901, 5, 0, 72, 90);
+ check("a cap keeps rows compact on a tall panel", capped.rowH === 90 && capped.stackH === 450, JSON.stringify(capped));
+ check("the cap never lifts a row over what fits", laneLayout(400, 5, 0, 72, 90).rowH === 80);
}
// ─── Remembered open state ───
From 164ffa1336904f060bc93314dc8ac1fb29fd93a7 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 6 Oct 2026 18:58:45 +0100
Subject: [PATCH 054/300] fix(master): four mastering bugs and the drawer
layout
From the exploratory test of the Master drawer:
1. Closing the drawer while the 30 s excerpt rendered left it to play,
hidden. The render is aborted on close.
2. Match loudness used the server's before/after difference, but the
live master only approximates that render: on Louder the master played
0.42 to 0.70 dB louder than the original. While the song plays with the
drawer open, the match now learns the live master's own in/out loudness
(measured: -0.42, then -0.21 dB).
3. Target and Ceiling kept what was typed, not the clamped value, and a
number field in an English locale took "-12,5" as -125. The fields
parse comma or dot and show the value the master uses.
4. Switching the master on made a new compressor, which starts out
compressing hard: a 4 dB dip for about 200 ms and a false -16 to -22 dB
of gain reduction. The effect now runs silent and settled while the
drawer is open; switching on is a 20 ms crossfade with no dip. The
reduction reads 0 while the song is stopped.
Also: Space on the Master button opens the drawer instead of playing;
the master's ramps drop cancelAndHoldAtTime like the EQ's. Layout: the
drawer takes a third of the window (250 to 340 px, was 364 fixed, which
left no lane on a 720 px window), and the master EQ's controls are a bar
over its graph: the graph is 450 px wide at 1280 (was 2) and 300 px with
the band list at 1440 (was 92).
Board: Mastering MVP.
---
static/css/mastering.css | 51 +++++++++++++++----
static/css/variables.css | 4 +-
static/js/fxChain.js | 91 ++++++++++++++++++++++-----------
static/js/masterEngine.js | 63 ++++++++++++++++++++++-
static/js/masterFx.js | 11 ++--
static/js/masterPanel.js | 103 +++++++++++++++++++++++++++++++-------
6 files changed, 257 insertions(+), 66 deletions(-)
diff --git a/static/css/mastering.css b/static/css/mastering.css
index 3e066507..cd8b1144 100644
--- a/static/css/mastering.css
+++ b/static/css/mastering.css
@@ -249,31 +249,60 @@
/* ── Master EQ (E2's general panel on the MASTER lane) ── */
-.master-eq-side {
- flex: 0 0 210px;
- width: 210px;
+.master-eq-area {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+/* The EQ's controls as one bar over the graph. */
+.master-eq-side.eq-side {
+ flex: 0 0 auto;
+ flex-direction: row;
+ width: auto;
box-sizing: border-box;
- padding: 8px 10px;
- border-right: 1px solid var(--border);
- overflow: hidden;
+ padding: 5px 10px;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
}
-.master-eq-title { color: var(--accent); flex: 1; }
+.master-eq-side .eq-side-head { flex: 0 0 auto; margin: 0; gap: 8px; }
+.master-eq-side .eq-side-controls {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px 14px;
+}
+.master-eq-side .eq-side-controls > * { margin: 0; flex: 0 0 auto; width: auto; }
+.master-eq-side .eq-side-controls select { width: 150px; }
+.master-eq-side .eq-out input[type="range"] { width: 90px; }
+.master-eq-title { color: var(--accent); }
.master-eq-body {
flex: 1;
min-width: 0;
- height: 100%;
+ min-height: 0;
overflow: hidden;
position: relative;
+ container-type: inline-size;
}
/* Narrower than in a lane row: the drawer shares the width with the master's
- own columns. The band list's fixed columns still fit. */
+ own columns. On a narrow window the band list gives way to the graph, which
+ edits the same bands by dragging. */
.master-eq-body .eq-band-list { width: 300px; }
+@container (max-width: 640px) {
+ .master-eq-body .eq-band-list { display: none; }
+}
/* ── Meters (approximate) ── */
.master-meters {
- flex: 0 0 120px;
- width: 120px;
+ flex: 0 0 104px;
+ width: 104px;
box-sizing: border-box;
padding: 8px 10px;
border-left: 1px solid var(--border);
diff --git a/static/css/variables.css b/static/css/variables.css
index ca42d7d8..ea6aad39 100644
--- a/static/css/variables.css
+++ b/static/css/variables.css
@@ -110,7 +110,9 @@
--eq-band-6: #d49cf0;
/* The Master drawer (mastering.css): its settings column fits without
scrolling at this height. */
- --master-drawer-h: 364px;
+ /* A third of the window, so the lanes keep room above it (364 px fixed left
+ no lane at all on a 720 px tall window). The settings column scrolls. */
+ --master-drawer-h: clamp(250px, 34vh, 340px);
/* Motion */
--t-fast: 80ms;
diff --git a/static/js/fxChain.js b/static/js/fxChain.js
index bb058d4a..7bd6d022 100644
--- a/static/js/fxChain.js
+++ b/static/js/fxChain.js
@@ -200,8 +200,44 @@ export function createEngineFx(ctx, membersOf) {
master.output.connect(around);
around.connect(tail);
tail.connect(ctx.destination);
- let mfx = null; // { effect, wet }
+ let mfx = null; // { effect, wet, active }
let mfxDrop = 0;
+ // A master effect made the moment it is switched on starts out compressing
+ // hard: measured in Chromium, the level dipped 4 dB and took about 200 ms
+ // to come back, and the compressors reported up to -22 dB of reduction
+ // that was not there. So while the Master drawer is open the effect is kept
+ // running, silent (wet at 0), and switching it on only crossfades to a
+ // signal that has settled. The straight path stays at exactly 1 meanwhile,
+ // so the output is still bit-identical to the master EQ's.
+ let warmWanted = false;
+
+ function makeMfx(params) {
+ const effect = createMasterEffect(ctx);
+ const wet = ctx.createGain();
+ wet.gain.value = 0;
+ master.output.connect(effect.input);
+ effect.output.connect(wet);
+ wet.connect(tail);
+ effect.set(params, { immediate: true });
+ return { effect, wet, active: false };
+ }
+
+ function dropMfx(old) {
+ if (mfx !== old) return;
+ try { master.output.disconnect(old.effect.input); } catch { /* gone */ }
+ old.effect.dispose();
+ try { old.wet.disconnect(); } catch { /* gone */ }
+ mfx = null;
+ }
+
+ function scheduleDrop(immediate) {
+ if (!mfx || mfx.active || warmWanted) return;
+ const old = mfx;
+ if (mfxDrop) clearTimeout(mfxDrop);
+ const drop = () => { mfxDrop = 0; if (!old.active && !warmWanted) dropMfx(old); };
+ if (immediate) drop();
+ else mfxDrop = setTimeout(drop, RELEASE_MS);
+ }
return {
/** Wire gain -> slot -> analyser for one source and give it its EQ. */
@@ -241,44 +277,42 @@ export function createEngineFx(ctx, membersOf) {
setMasterFx(params, { immediate = false } = {}) {
if (params) {
if (mfxDrop) { clearTimeout(mfxDrop); mfxDrop = 0; }
- if (!mfx) {
- const effect = createMasterEffect(ctx);
- const wet = ctx.createGain();
- wet.gain.value = 0;
- master.output.connect(effect.input);
- effect.output.connect(wet);
- wet.connect(tail);
- mfx = { effect, wet };
- effect.set(params, { immediate: true });
- } else {
- mfx.effect.set(params, { immediate });
- }
+ if (!mfx) mfx = makeMfx(params);
+ else mfx.effect.set(params, { immediate });
+ mfx.active = true;
rampTo(around.gain, 0, ctx, immediate, XFADE);
rampTo(mfx.wet.gain, 1, ctx, immediate, XFADE);
return true;
}
if (!mfx) return false;
- const old = mfx;
+ mfx.active = false;
rampTo(around.gain, 1, ctx, immediate, XFADE);
- rampTo(old.wet.gain, 0, ctx, immediate, XFADE);
- const drop = () => {
- mfxDrop = 0;
- if (mfx !== old) return;
- try { master.output.disconnect(old.effect.input); } catch { /* gone */ }
- old.effect.dispose();
- try { old.wet.disconnect(); } catch { /* gone */ }
- mfx = null;
- };
- if (immediate) drop();
- else mfxDrop = setTimeout(drop, RELEASE_MS);
+ rampTo(mfx.wet.gain, 0, ctx, immediate, XFADE);
+ scheduleDrop(immediate);
+ return true;
+ },
+ /**
+ * Keep the master effect running, silent, ready to be switched on (true),
+ * or let it go once it is off (false). `params` are what it should
+ * settle on: the master the drawer would switch on.
+ */
+ warmMasterFx(on, params = null) {
+ warmWanted = !!on;
+ if (warmWanted) {
+ if (mfxDrop) { clearTimeout(mfxDrop); mfxDrop = 0; }
+ if (!mfx) mfx = makeMfx(params || {});
+ else if (params && !mfx.active) mfx.effect.set(params);
+ return true;
+ }
+ scheduleDrop(false);
return true;
},
/** The live master's In and Out nodes (export level), or null when off. */
getMasterFxNodes() {
- return mfx && !mfxDrop ? { input: mfx.effect.inTap, output: mfx.effect.outTap } : null;
+ return mfx?.active ? { input: mfx.effect.inTap, output: mfx.effect.outTap } : null;
},
- masterFxReduction: () => (mfx ? mfx.effect.reduction() : null),
- masterFxLatency: () => (mfx && !mfxDrop ? mfx.effect.latencySec : 0),
+ masterFxReduction: () => (mfx?.active ? mfx.effect.reduction() : null),
+ masterFxLatency: () => (mfx?.active ? mfx.effect.latencySec : 0),
getLaneTap(name, which, opts) {
const nodes = membersOf(name)
.filter((s) => s.fx)
@@ -369,6 +403,7 @@ export function engineFxApi(fx) {
getMasterOutputNode: () => fx.masterOutput,
supportsMaster: true,
setMasterFx: (params, opts) => fx.setMasterFx(params, opts),
+ warmMasterFx: (on, params) => fx.warmMasterFx(on, params),
getMasterFxNodes: () => fx.getMasterFxNodes(),
masterFxReduction: () => fx.masterFxReduction(),
masterFxLatency: () => fx.masterFxLatency(),
diff --git a/static/js/masterEngine.js b/static/js/masterEngine.js
index 9c2be83c..fa007863 100644
--- a/static/js/masterEngine.js
+++ b/static/js/masterEngine.js
@@ -41,6 +41,35 @@ let watchTimer = 0;
let settleTimer = 0;
let inflight = null; // AbortController
let planJob = null;
+// Match loudness, measured on the live preview itself. The server's plan
+// gives the exact export's before/after difference, but the live master is an
+// approximation of that render: on Louder for a quiet song the two differed
+// by 0.53 to 0.7 dB, past the 0.5 dB a match must hold. So while the song
+// plays with the drawer open, the panel's meters feed the live master's own
+// In and Out loudness here, and after 3 s of sound the match uses that.
+const LIVE_MATCH_MIN_READS = 30; // meter reads, 100 ms apart
+let live = { sumIn: 0, sumOut: 0, n: 0, diff: NaN };
+
+function resetLiveMatch() {
+ live = { sumIn: 0, sumOut: 0, n: 0, diff: NaN };
+}
+
+/**
+ * One meter read of the live master's momentary loudness at its input and
+ * output (LUFS, export level). Silence is skipped.
+ */
+export function noteLiveLoudness(inLufs, outLufs) {
+ if (!isMasterOn() || !Number.isFinite(inLufs) || !Number.isFinite(outLufs)) return;
+ if (inLufs < -60 || outLufs < -60) return;
+ live.sumIn += 10 ** (inLufs / 10);
+ live.sumOut += 10 ** (outLufs / 10);
+ live.n += 1;
+ if (live.n < LIVE_MATCH_MIN_READS) return;
+ const diff = 10 * Math.log10(live.sumOut / live.sumIn);
+ if (Number.isFinite(live.diff) && Math.abs(diff - live.diff) < 0.05) return;
+ live.diff = diff;
+ if (match) applyToEngine();
+}
function readMatch() {
try {
@@ -87,6 +116,7 @@ export function setMaster(desc, { save = true } = {}) {
const e = entry();
// The master EQ stays first in the slot; the master follows it.
e.fx = [...e.fx.filter((x) => x?.id !== MASTER_ID), m];
+ resetLiveMatch();
if (save) persistMix();
applyToEngine();
emit("master");
@@ -197,6 +227,7 @@ export async function measure() {
if (inflight !== ctl) return null;
plan = body;
planSig = sig;
+ resetLiveMatch();
planJob = src.jobId;
planState = "ready";
} catch (err) {
@@ -221,6 +252,7 @@ export function resetForSong() {
wantedSig = "";
planState = "idle";
listen = "master";
+ resetLiveMatch();
stopWatch();
emit(null);
}
@@ -240,10 +272,37 @@ export function liveParams() {
fader: masterVolume,
listen,
match,
- diffLu: p && Number.isFinite(p.post?.i) && Number.isFinite(p.pre?.i) ? p.post.i - p.pre.i : NaN,
+ diffLu: Number.isFinite(live.diff) ? live.diff
+ : p && Number.isFinite(p.post?.i) && Number.isFinite(p.pre?.i) ? p.post.i - p.pre.i : NaN,
};
}
+/**
+ * Keep the live master warm while the drawer is open (fxChain warmMasterFx),
+ * settled on the master the power button would switch on.
+ */
+let warm = false;
+export function warmLive(on) {
+ warm = !!on;
+ if (!audioEngine?.supportsMaster || !audioEngine.warmMasterFx) return;
+ try {
+ const m = stored() ? getMaster() : applyPreset(null, "streaming");
+ const p = planJob === currentJobId ? plan : null;
+ audioEngine.warmMasterFx(on, on ? {
+ gainDb: p ? p.gain_db : 0,
+ comp: m.params.comp,
+ compThresholdDb: p ? p.comp_threshold_db : null,
+ ceiling: m.params.ceiling_dbtp,
+ fader: masterVolume,
+ listen: "master",
+ match,
+ diffLu: NaN,
+ } : null);
+ } catch (err) {
+ console.warn("[master] could not warm the live master:", err);
+ }
+}
+
/** Push the master to the engine (the player calls it when an engine is up). */
export function applyToEngine({ immediate = false } = {}) {
if (!audioEngine?.supportsMaster) return;
@@ -258,6 +317,8 @@ export function applyToEngine({ immediate = false } = {}) {
eq.onChange(({ laneId }) => {
if (laneId !== null) return;
applyToEngine({ immediate: true });
+ // A rebuilt engine has no warm master yet.
+ if (warm) warmLive(true);
emit(null);
watch();
});
diff --git a/static/js/masterFx.js b/static/js/masterFx.js
index f151d651..4528db76 100644
--- a/static/js/masterFx.js
+++ b/static/js/masterFx.js
@@ -43,12 +43,11 @@ function ramp(param, value, ctx, immediate) {
return;
}
const now = ctx.currentTime;
- if (typeof param.cancelAndHoldAtTime === "function") param.cancelAndHoldAtTime(now);
- else {
- const cur = param.value;
- param.cancelScheduledValues?.(now);
- param.setValueAtTime?.(cur, now);
- }
+ // Not cancelAndHoldAtTime: it clicked on every knob step in Chromium
+ // (fxChain.js rampTo has the measurement).
+ const cur = param.value;
+ param.cancelScheduledValues?.(now);
+ param.setValueAtTime?.(cur, now);
param.linearRampToValueAtTime(value, now + RAMP);
}
diff --git a/static/js/masterPanel.js b/static/js/masterPanel.js
index cd14049d..8c171668 100644
--- a/static/js/masterPanel.js
+++ b/static/js/masterPanel.js
@@ -3,8 +3,8 @@
// layout (the lanes give it room), and holds four columns:
//
// settings (on/off, preset, target, ceiling, glue, listen, readout, excerpt)
-// | master EQ toolbar | master EQ graph (E2's general panel on the MASTER lane)
-// | meters (approximate)
+// | master EQ: its toolbar as a bar over the graph (E2's general panel on
+// the MASTER lane) | meters (approximate)
//
// The truth lives in masterEngine.js and eqEngine.js; this module only draws
// and forwards. Every string goes through i18n.
@@ -29,6 +29,10 @@ let eqPanel = null;
let meters = null;
let meterTimer = 0;
let excerpt = null; // { master: HTMLAudioElement, original: HTMLAudioElement, start, end }
+// The excerpt being rendered. Closing the drawer (or Stop) aborts it: before,
+// the render finished after the drawer had closed and played with nothing on
+// screen to stop it.
+let excerptCtl = null;
function el(tag, cls, text) {
const e = document.createElement(tag);
@@ -76,11 +80,23 @@ function build() {
const field = (cls, min, max, step, unit) => {
const lab = el("label", `master-field ${cls}`);
const name = el("span", "master-field-name");
+ // Text, not type=number: a number field in an English-locale browser took
+ // "-12,5" as -125. The value is parsed here, comma or dot alike, and the
+ // arrow keys step it.
const input = el("input", "master-num");
- input.type = "number";
- input.min = String(min);
- input.max = String(max);
- input.step = String(step);
+ input.type = "text";
+ input.inputMode = "decimal";
+ input.setAttribute("aria-valuemin", String(min));
+ input.setAttribute("aria-valuemax", String(max));
+ input.addEventListener("keydown", (e) => {
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
+ e.preventDefault();
+ const cur = parseNum(input.value);
+ const base = Number.isFinite(cur) ? cur : min;
+ const next = Math.round((base + (e.key === "ArrowUp" ? step : -step)) / step) * step;
+ input.value = String(Math.max(min, Math.min(max, +next.toFixed(2))));
+ input.dispatchEvent(new Event("change"));
+ });
const u = el("span", "master-unit", unit);
lab.append(name, input, u);
fields.append(lab);
@@ -174,7 +190,11 @@ function build() {
const gr = el("span", "master-gr");
meterCol.append(meterHead, bars, lufsRow, gr);
- root.append(side, eqSide, eqBody, meterCol);
+ // The master EQ's controls run as a bar above its graph, so the graph gets
+ // the drawer's width (beside them it was 2 px wide at 1280 px).
+ const eqArea = el("div", "master-eq-area");
+ eqArea.append(eqSide, eqBody);
+ root.append(side, eqArea, meterCol);
drawer.replaceChildren(root);
// ─── Events ───
@@ -182,12 +202,17 @@ function build() {
close.addEventListener("click", () => setOpen(false));
const onNum = (which) => () => {
const m = master.getMaster();
- const v = parseFloat(which === "target" ? target.input.value : ceiling.input.value);
- if (!Number.isFinite(v)) { paint(); return; }
- const params = { ...m.params, preset: "custom" };
- if (which === "target") params.target_lufs = v;
- else params.ceiling_dbtp = v;
- master.setMaster({ ...m, bypass: false, params });
+ const v = parseNum(which === "target" ? target.input.value : ceiling.input.value);
+ if (Number.isFinite(v)) {
+ const params = { ...m.params, preset: "custom" };
+ if (which === "target") params.target_lufs = v;
+ else params.ceiling_dbtp = v;
+ master.setMaster({ ...m, bypass: false, params });
+ }
+ // Show what was kept, clamped, even while the field has focus: paint()
+ // leaves a focused field alone, so a typed -40 stayed on screen as -40
+ // while the master used the limit, and an unreadable entry left it blank.
+ writeFields();
};
target.input.addEventListener("change", onNum("target"));
ceiling.input.addEventListener("change", onNum("ceiling"));
@@ -297,6 +322,20 @@ function presetSummary(m) {
return `${tgt}, ${fmtDb(p.ceiling_dbtp)} dBTP`;
}
+/** "-12,5" and "-12.5" alike; NaN for anything else. */
+function parseNum(s) {
+ const v = Number(String(s).trim().replace(",", "."));
+ return String(s).trim() === "" ? NaN : v;
+}
+
+function writeFields() {
+ if (!ui) return;
+ const p = master.getMaster().params;
+ ui.target.input.value = p.target_lufs === null ? "" : String(p.target_lufs);
+ ui.target.input.placeholder = p.target_lufs === null ? t("master.targetNone") : "";
+ ui.ceiling.input.value = String(p.ceiling_dbtp);
+}
+
function paint() {
paintChip();
if (!ui) return;
@@ -386,8 +425,11 @@ function tickMeters() {
const ceiling = master.getMaster().params.ceiling_dbtp;
paintBar(ui.inBar, r?.in, 0);
paintBar(ui.outBar, r?.out, ceiling);
- // "-0.0" reads as a fault: a reduction under 0.05 dB is shown as 0.0.
- const red = r && r.reduction < -0.05 ? r.reduction : 0;
+ // "-0.0" reads as a fault: a reduction under 0.05 dB is shown as 0.0. A
+ // stopped song has nothing being reduced, whatever the compressor last held.
+ const playing = !!transport()?.isPlaying?.();
+ const red = r && playing && r.reduction < -0.05 ? r.reduction : 0;
+ if (r && playing) master.noteLiveLoudness(r.in.lufs, r.out.lufs);
ui.gr.textContent = r ? `${t("master.meterGr")} ${fmtDb(red)} dB` : "";
}
@@ -439,7 +481,7 @@ function syncExcerptSource() {
async function startExcerpt() {
const span = excerptSpan();
- if (!span || !ui) return;
+ if (!span || !ui || excerptCtl) return;
// MP3 for a lossy export (its own ceiling), WAV otherwise: the excerpt is
// what that export will be.
const ext = ["mp3", "ogg", "mp4"].includes(exportFormat()) ? "mp3" : "wav";
@@ -447,14 +489,17 @@ async function startExcerpt() {
const oUrl = excerptUrl(ext, span.start, span.end, false);
if (!mUrl || !oUrl) return;
if (transport()?.isPlaying?.()) togglePlayPause();
+ const ctl = new AbortController();
+ excerptCtl = ctl;
ui.exBtn.disabled = true;
ui.exState.textContent = t("master.excerptRendering");
try {
const [mBlob, oBlob] = await Promise.all([mUrl, oUrl].map(async (u) => {
- const r = await fetch(u);
+ const r = await fetch(u, { signal: ctl.signal });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.blob();
}));
+ if (ctl.signal.aborted || !open) return;
const mk = (blob) => {
const a = new Audio(URL.createObjectURL(blob));
a.preload = "auto";
@@ -469,11 +514,22 @@ async function startExcerpt() {
drawer.dataset.excerpt = "playing";
syncExcerptSource();
} catch (err) {
+ if (ctl.signal.aborted) return;
console.warn("[master] rendering the excerpt failed:", err);
ui.exState.textContent = t("master.excerptFailed");
excerpt = null;
+ } finally {
+ if (excerptCtl === ctl) excerptCtl = null;
+ if (ui) paint();
}
- paint();
+}
+
+/** Stop a render in flight: the fetches end and nothing plays. */
+function cancelExcerptRender() {
+ if (!excerptCtl) return;
+ excerptCtl.abort();
+ excerptCtl = null;
+ if (ui) ui.exState.textContent = "";
}
function stopExcerpt() {
@@ -508,8 +564,11 @@ export function setOpen(next) {
if (master.isMasterOn() && !master.getPlan()) master.measure();
} else {
stopMeters();
+ cancelExcerptRender();
stopExcerpt();
}
+ // Open: the live master runs silently, settled, ready to be switched on.
+ master.warmLive(open);
paintChip();
// The lanes give the drawer its room: lay them out again.
document.dispatchEvent(new CustomEvent("stemdeck:master-drawer", { detail: { open } }));
@@ -525,6 +584,10 @@ export function initMasterPanel() {
e.stopPropagation();
setOpen(!open);
});
+ // The studio's Space shortcut plays; on this button Space is the button's.
+ chip.addEventListener("keydown", (e) => {
+ if (e.code === "Space" || e.key === "Enter") e.stopPropagation();
+ });
const row = document.getElementById("t-export-master");
row?.addEventListener("click", (e) => {
e.stopPropagation();
@@ -534,7 +597,9 @@ export function initMasterPanel() {
ui?.power.focus();
});
master.onChange((what) => {
- if (what === null) stopExcerpt();
+ if (what === null) { cancelExcerptRender(); stopExcerpt(); }
+ // Off, the warm master follows the preset the power button would use.
+ if (open && what === "master" && !master.isMasterOn()) master.warmLive(true);
paint();
});
eq.onChange(({ laneId }) => { if (laneId === eq.MASTER || laneId === null) paint(); });
From 4cbb6bcda62de21e14135b224ea2033a3a7ad773 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 6 Oct 2026 18:58:46 +0100
Subject: [PATCH 055/300] feat(library): no Tags section under the collections
Removed at the maintainer's request. A tag still filters the library when
typed into search as #tag, with the suggestions under the search box.
Spec rewritten for that (not run: e2e is paused).
---
static/js/catalog.js | 58 +++------------------------------
tests/e2e/library-tags.spec.mjs | 46 ++++++--------------------
2 files changed, 15 insertions(+), 89 deletions(-)
diff --git a/static/js/catalog.js b/static/js/catalog.js
index 188d2bff..ed1786f1 100644
--- a/static/js/catalog.js
+++ b/static/js/catalog.js
@@ -227,7 +227,6 @@ let tracks = {};
let _deletedJobIds = new Set();
let _currentTrackId = null;
// The open track's tags as the last render() saw them. See setCurrentTrack().
-let _renderedTagsKey = "";
let _loadTrackToken = 0;
// Imports submitted from the form, mapped to the load token at that moment. A
// finished one opens in the studio only if nothing has been opened since (#747).
@@ -1212,12 +1211,8 @@ export function setResplitTarget(track) {
export function setCurrentTrack(trackId) {
_currentTrackId = trackId;
- // The library's Tags section shows the open track's tags, so it is rebuilt
- // when those are not what the last render() drew. Compared by tags rather
- // than by id: loadTrackIntoStudio() can bring a track's tags in from the
- // server while that same track is already the open one. Anything else is
- // patched in place, since a full render() rebuilds the whole sidebar.
- if (openTrackTags().join("\n") !== _renderedTagsKey) { render(); return; }
+ // The open track is marked in place, since a full render() rebuilds the
+ // whole sidebar.
for (const el of document.querySelectorAll(".cat-item.active")) el.classList.remove("active");
for (const el of document.querySelectorAll(`.cat-item[data-id="${trackId}"]`)) el.classList.add("active");
for (const el of document.querySelectorAll(".strip-thumb.active")) el.classList.remove("active");
@@ -1783,12 +1778,6 @@ function wireLibraryDeleteKeys() {
// ─── Rendering helpers ───
/** The open track's tags, deduplicated, or none when it is in the Trash. */
-function openTrackTags() {
- const track = tracks[_currentTrackId];
- if (!track || getTrashFolder()?.items.includes(_currentTrackId)) return [];
- return [...new Set(track.tags ?? [])];
-}
-
function getAllTags(trashIds) {
const counts = {};
for (const [id, track] of Object.entries(tracks)) {
@@ -2226,8 +2215,6 @@ function render() {
const catalog = document.getElementById("catalogPanel");
const searchInput = document.getElementById("catalogSearch");
if (!list) return;
- const openTags = openTrackTags();
- _renderedTagsKey = openTags.join("\n");
list.innerHTML = "";
if (strip) strip.innerHTML = "";
@@ -2347,44 +2334,9 @@ function render() {
return;
}
- // Tags section: the open track's tags only. Every tag in the library was a
- // wall of chips with nothing to say which song they came from. The count is
- // still library-wide, since it is how many tracks the chip's filter finds.
- // setCurrentTrack() re-renders when the open track's tags change.
- if (openTags.length) {
- const counts = new Map(getAllTags(trashIds));
- const section = makeSectionEl(i18nT("library.tags"));
- section.classList.add("lib-tags-section");
- const row = document.createElement("div");
- row.className = "lib-tags-row";
- const activeTag = catalogSearchQuery.startsWith("#") ? catalogSearchQuery.slice(1) : null;
- for (const tag of openTags) {
- const count = counts.get(tag) ?? 1;
- const chip = document.createElement("button");
- chip.className = `lib-tag-chip${activeTag === tag ? " active" : ""}`;
- chip.type = "button";
- chip.dataset.tag = tag;
- chip.textContent = tag;
- const countSpan = document.createElement("span");
- countSpan.className = "lib-tag-count";
- countSpan.textContent = String(count);
- chip.appendChild(countSpan);
- chip.addEventListener("click", () => {
- const input = document.getElementById("catalogSearch");
- if (catalogSearchQuery === `#${tag}`) {
- catalogSearchQuery = "";
- if (input) input.value = "";
- } else {
- catalogSearchQuery = `#${tag}`;
- if (input) input.value = `#${tag}`;
- }
- render();
- });
- row.appendChild(chip);
- }
- section.appendChild(row);
- list.appendChild(section);
- }
+ // No Tags section under the collections: the maintainer asked for it gone.
+ // A tag still filters the library when typed into search as #tag, with the
+ // suggestions under the search box (showTagSuggestions).
renderStrip(strip, nonTrash);
updateQueueBadge();
diff --git a/tests/e2e/library-tags.spec.mjs b/tests/e2e/library-tags.spec.mjs
index 56976463..7f85afbf 100644
--- a/tests/e2e/library-tags.spec.mjs
+++ b/tests/e2e/library-tags.spec.mjs
@@ -1,7 +1,5 @@
-// The library's Tags section shows the open track's tags, not every tag in the
-// library. With every tag from every track it was a wall of chips with nothing
-// to say which song they came from. A chip still filters the library by its
-// #tag, and its count is still how many tracks that filter finds.
+// The library has no Tags section under the collections (the maintainer asked
+// for it gone). Tags still filter the library when typed into search as #tag.
import { test, expect } from "@playwright/test";
import {
JOB_ID,
@@ -14,12 +12,6 @@ import {
stubUpdateCheck,
} from "./helpers.mjs";
-const FIRST_TAGS = ["rock", "live", "shared"];
-const SIBLING_TAGS = ["jazz", "shared"];
-
-const chips = (page) => page.locator(".lib-tags-section .lib-tag-chip");
-const chipTags = (page) => chips(page).evaluateAll((els) => els.map((el) => el.dataset.tag));
-
test.beforeEach(async ({ page }) => {
// Both fixture jobs, as seedLibrary has them, each with tags of its own.
// Seeding only one would not work: the startup sync adopts the other from
@@ -31,8 +23,8 @@ test.beforeEach(async ({ page }) => {
{ id: "trash", name: "Trash", items: [], color: null },
],
tracks: {
- [JOB_ID]: { ...fixtureTrack(JOB_ID, TRACK_TITLE), tags: FIRST_TAGS },
- [SIBLING_JOB_ID]: { ...fixtureTrack(SIBLING_JOB_ID, SIBLING_TITLE), tags: SIBLING_TAGS },
+ [JOB_ID]: { ...fixtureTrack(JOB_ID, TRACK_TITLE), tags: ["rock", "shared"] },
+ [SIBLING_JOB_ID]: { ...fixtureTrack(SIBLING_JOB_ID, SIBLING_TITLE), tags: ["jazz", "shared"] },
},
});
await stubExportEndpoints(page);
@@ -41,34 +33,16 @@ test.beforeEach(async ({ page }) => {
await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().waitFor({ timeout: 20000 });
});
-test("no track open, no Tags section", async ({ page }) => {
+test("no Tags section, with or without a track open", async ({ page }) => {
await expect(page.locator(".lib-tags-section")).toHaveCount(0);
- await expect(page.locator(".lib-tag-chip")).toHaveCount(0);
-});
-
-test("only the open track's tags show, and they follow the open track", async ({ page }) => {
await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().click();
- await expect.poll(() => chipTags(page), { timeout: 20000 }).toEqual(FIRST_TAGS);
- // A tag on both tracks counts both, since that is what its filter finds.
- await expect(chips(page).nth(2).locator(".lib-tag-count")).toHaveText("2");
- await expect(chips(page).first().locator(".lib-tag-count")).toHaveText("1");
-
- await page.locator(`.cat-item[data-id="${SIBLING_JOB_ID}"]`).first().click();
- await expect.poll(() => chipTags(page), { timeout: 20000 }).toEqual(SIBLING_TAGS);
+ await expect(page.locator(`.cat-item[data-id="${JOB_ID}"].active`).first()).toBeVisible();
+ await expect(page.locator(".lib-tags-section")).toHaveCount(0);
+ await expect(page.locator(".lib-tag-chip")).toHaveCount(0);
});
-test("a chip still filters the library by its tag", async ({ page }) => {
- await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().click();
- await expect.poll(() => chipTags(page), { timeout: 20000 }).toEqual(FIRST_TAGS);
-
- await chips(page).first().click();
- await expect(page.locator("#catalogSearch")).toHaveValue("#rock");
+test("a #tag typed into search still filters the library", async ({ page }) => {
+ await page.locator("#catalogSearch").fill("#rock");
await expect(page.locator(`.cat-item[data-id="${JOB_ID}"]`)).not.toHaveCount(0);
await expect(page.locator(`.cat-item[data-id="${SIBLING_JOB_ID}"]`)).toHaveCount(0);
- await expect(chips(page).first()).toHaveClass(/active/);
-
- // Clicking the active chip again clears the filter.
- await chips(page).first().click();
- await expect(page.locator("#catalogSearch")).toHaveValue("");
- await expect(page.locator(`.cat-item[data-id="${SIBLING_JOB_ID}"]`)).not.toHaveCount(0);
});
From eae36bc11db81c9d16e7d8d9adc6116c568c2e4e Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 6 Oct 2026 19:11:23 +0100
Subject: [PATCH 056/300] feat(tracks): audio and video tracks; Record films a
video track's take
Test V2 found Record with the camera doing nothing visible: the camera
films only while the Camera toggle inside the recording panel is on, and
nothing on a track said so. A track now has a kind:
- the add-track row offers + Audio track and + Video track;
- a video track shows a camera beside its name;
- Record with a video track armed opens the panel, switches the camera
on and films that track's take (it records first, so the camera follows
it); if the camera cannot open, nothing records and the panel says why.
The server stores the kind ("audio" or "video", audio when absent or
cleared) on create and patch; older tracks read as audio. Checked with
simulated devices: + Video track, Rec, Stop gave a take with a 267 KB
camera MP4. pytest tests/test_tracks_api.py: 27 passed. Ten languages.
Board: Track kinds (test V2 feedback), Video recording MVP.
---
app/api/takes.py | 8 ++++-
app/core/takes.py | 11 +++++-
static/css/recording.css | 32 +++++++++++++----
static/index.html | 10 ++++--
static/js/capture/recordUi.js | 21 +++++++++--
static/js/capture/takeLanes.js | 65 ++++++++++++++++++++++++----------
static/js/i18n.js | 60 +++++++++++++++++++++++++++++++
static/js/video/videoTake.js | 11 ++++++
tests/test_tracks_api.py | 19 ++++++++++
9 files changed, 204 insertions(+), 33 deletions(-)
diff --git a/app/api/takes.py b/app/api/takes.py
index ab8ae096..e1c6309d 100644
--- a/app/api/takes.py
+++ b/app/api/takes.py
@@ -831,6 +831,9 @@ class CreateTrackBody(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, max_length=_LABEL_MAX * 4)
input: TrackInput | None = None
+ # A video track films its takes with the camera as well as recording
+ # sound. Only stored for the UI, like the input.
+ kind: Literal["audio", "video"] | None = None
@field_validator("name")
@classmethod
@@ -849,7 +852,7 @@ def _track_view(job: Job, track_id: str) -> dict[str, Any]:
@router.post("/{job_id}/tracks", status_code=201)
async def create_track(job_id: str, request: Request) -> dict[str, Any]:
- """Add a recording track: {} or {"name"?, "input"?}."""
+ """Add a recording track: {} or {"name"?, "input"?, "kind"?}."""
job = _job_or_404(job_id)
root = (JOBS_DIR / job_id / TAKES_DIRNAME).resolve()
if not root.is_relative_to(JOBS_DIR.resolve()):
@@ -869,6 +872,7 @@ async def create_track(job_id: str, request: Request) -> dict[str, Any]:
body.name or f"Track {n}",
len(job.tracks),
body.input.model_dump() if body.input else None,
+ body.kind or "audio",
)
_commit(job, list(job.takes), [*job.tracks, track])
logger.info("[%s] track %s created", job_id, track_id)
@@ -893,6 +897,8 @@ async def patch_track(job_id: str, track_id: str, request: Request) -> dict[str,
track["name"] = body.name
if "input" in fields:
track["input"] = body.input.model_dump() if body.input else None
+ if "kind" in fields:
+ track["kind"] = body.kind or "audio"
if "active_take_id" in fields:
take = next(
(
diff --git a/app/core/takes.py b/app/core/takes.py
index c186b4c1..abb45fab 100644
--- a/app/core/takes.py
+++ b/app/core/takes.py
@@ -124,7 +124,15 @@ def new_track_id(existing: set[str]) -> str:
return track_id
-def make_track(track_id: str, name: str, order: int, input_: dict | None = None) -> dict[str, Any]:
+def make_track(
+ track_id: str,
+ name: str,
+ order: int,
+ input_: dict | None = None,
+ kind: str = "audio",
+) -> dict[str, Any]:
+ """A new track. `kind` is "audio" or "video": a video track films its
+ takes with the camera as well (static/js/video/videoTake.js)."""
return {
"id": track_id,
"name": name,
@@ -132,6 +140,7 @@ def make_track(track_id: str, name: str, order: int, input_: dict | None = None)
"created_at": time.time(),
"active_take_id": None,
"input": input_,
+ "kind": kind,
}
diff --git a/static/css/recording.css b/static/css/recording.css
index a89cd6fe..cc9641de 100644
--- a/static/css/recording.css
+++ b/static/css/recording.css
@@ -242,17 +242,28 @@
/* ── Recording tracks (static/js/capture/takeLanes.js) ── */
/* "+ Track" in the mixer header. */
-/* The add-track box: a dashed box under the last extracted stem, above the
- recording tracks (player.js placeAddTrackRow sets its CSS order; the
- default keeps it last until then). 28px plus 6px above and below:
- player.js ADD_TRACK_ROW_HEIGHT counts these 40px into the stack, and the
- waveform column has an empty row as tall at the same place. */
-.add-track-btn {
+/* The add-track row: two dashed boxes, Audio track and Video track, under
+ the last extracted stem, above the recording tracks (player.js
+ placeAddTrackRow sets its CSS order; the default keeps it last until
+ then). 28px plus 6px above and below: player.js ADD_TRACK_ROW_HEIGHT
+ counts these 40px into the stack, and the waveform column has an empty row
+ as tall at the same place. */
+.add-track-row {
order: 2147483000;
flex: 0 0 28px;
height: 28px;
margin: 6px 12px;
- padding: 0 10px;
+ display: flex;
+ gap: 6px;
+}
+.add-track-row[hidden] { display: none; }
+.add-track-btn {
+ flex: 1 1 0;
+ min-width: 0;
+ height: 28px;
+ padding: 0 8px;
+ white-space: nowrap;
+ overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
@@ -271,6 +282,13 @@
.add-track-btn:hover:not(:disabled) { background: var(--panel-2); color: var(--fg); border-color: var(--muted-2); }
.add-track-btn:disabled { opacity: 0.4; cursor: default; }
.add-track-btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
+.add-track-cam { flex-shrink: 0; }
+/* A video track's camera mark, beside its name. */
+.track-kind-icon {
+ display: inline-flex;
+ flex-shrink: 0;
+ color: var(--lane-color, var(--muted));
+}
/* Why other tracks were disarmed: a short note under the mixer header,
placed by takeLanes.showTrackNote. */
.track-note {
diff --git a/static/index.html b/static/index.html
index a1a0c255..78b42063 100644
--- a/static/index.html
+++ b/static/index.html
@@ -606,9 +606,13 @@
Lyrics
Mixer
-
+ (static/js/capture/takeLanes.js). An audio track records
+ sound; a video track also films with the camera. The row is
+ moved under the last extracted stem once a song is open. -->
+
+
+
+
Drag fader · M/S
diff --git a/static/js/capture/recordUi.js b/static/js/capture/recordUi.js
index fa59f0cd..db7f92da 100644
--- a/static/js/capture/recordUi.js
+++ b/static/js/capture/recordUi.js
@@ -542,8 +542,25 @@ async function startRecording() {
eng.audioContext.resume().catch((e) => console.warn("[rec] resume failed:", e));
}
- const tracks = tracksToRecord();
- if (!tracks) return;
+ const armed = tracksToRecord();
+ if (!armed) return;
+ // A video track records first: the camera films the first track's take.
+ const tracks = [...armed].sort((a, b) => (b.kind === "video") - (a.kind === "video"));
+ if (tracks[0].kind === "video") {
+ // The camera's preview and its REC badge are in the panel.
+ openPanel();
+ let cameraReady = false;
+ try {
+ cameraReady = await (await import("../video/videoTake.js")).ensureCameraOn();
+ } catch (e) {
+ console.warn("[rec] opening the camera failed:", e);
+ }
+ if (!cameraReady) {
+ setStatus("rec.status.cameraFailed");
+ return;
+ }
+ if (audioEngine !== eng) return;
+ }
const inputs = tracks.map(trackInput);
// A track's device is not connected: say which, rather than record from
// whatever the browser would open instead.
diff --git a/static/js/capture/takeLanes.js b/static/js/capture/takeLanes.js
index 4436b21a..041b1552 100644
--- a/static/js/capture/takeLanes.js
+++ b/static/js/capture/takeLanes.js
@@ -49,10 +49,11 @@ export const NUDGE_MAX_MS = 2000;
let _jobId = null;
let _tracks = [];
-// The add-track box at the end of the mixer's lanes. Held here because a
-// song load empties the mixer, and the button with its listener goes back in
-// on every sync.
-let _addBtn = null;
+// The add-track row (Audio track, Video track) in the mixer's lanes. Held
+// here because a song load empties the mixer, and the row with its listeners
+// goes back in on every sync.
+let _addRow = null;
+let _addBtns = [];
// Its place in the stack: right after the last extracted stem, before the
// tracks (player.js placeAddTrackRow sets it).
let _addOrder = "";
@@ -60,7 +61,11 @@ let _addOrder = "";
/** The CSS order of the add-track box, the same as its spacer in the waveform column. */
export function setAddTrackOrder(order) {
_addOrder = String(order);
- if (_addBtn) _addBtn.style.order = _addOrder;
+ if (_addRow) _addRow.style.order = _addOrder;
+}
+
+function setAddEnabled(on) {
+ for (const b of _addBtns) b.disabled = !on;
}
const _armed = new Set();
let _devices = [];
@@ -210,14 +215,14 @@ export function syncTakeLanes(jobId, stems) {
setTakeLanes(_tracks.map((tr) => ({ id: tr.id, label: tr.name })));
ensureMixerStateDefaults();
closeTakeMenu();
- if (_addBtn) _addBtn.disabled = !jobId;
+ setAddEnabled(!!jobId);
if (!mixerEl) return;
for (const row of mixerEl.querySelectorAll(".lane-header[data-take], .take-folder-row")) row.remove();
for (const track of _tracks) mixerEl.appendChild(buildRow(track));
- if (_addBtn) {
- _addBtn.hidden = !jobId;
- _addBtn.style.order = _addOrder;
- mixerEl.appendChild(_addBtn);
+ if (_addRow) {
+ _addRow.hidden = !jobId;
+ _addRow.style.order = _addOrder;
+ mixerEl.appendChild(_addRow);
}
renderFolderRows();
renderArmButtons();
@@ -229,6 +234,7 @@ function buildRow(track) {
row.dataset.take = track.id;
row.classList.add("take-lane", "track-lane");
row.classList.toggle("track-empty", !track.url);
+ row.classList.toggle("track-video", track.kind === "video");
// A recorded part is not transposed per lane, so the key stepper goes, and
// so does the dB readout: the row's width goes to the fader and the picker.
row.querySelector(".lane-key")?.remove();
@@ -277,6 +283,16 @@ function buildRow(track) {
picker.append(input);
row.querySelector(".mx-actions")?.append(arm, picker);
row.querySelector(".mx-head")?.append(menuBtn);
+ // A video track: a camera beside the name, so it reads as one at a glance.
+ if (track.kind === "video") {
+ const cam = document.createElement("span");
+ cam.className = "track-kind-icon";
+ cam.title = t("rec.track.videoBadge");
+ cam.setAttribute("role", "img");
+ cam.setAttribute("aria-label", t("rec.track.videoBadge"));
+ cam.innerHTML = '';
+ row.querySelector(".mx-swatch")?.after(cam);
+ }
row.insertBefore(buildFolderToggle(track), row.querySelector(".mx-icon"));
return row;
}
@@ -668,15 +684,19 @@ async function onInputChange(track, select) {
document.dispatchEvent(new CustomEvent("stemdeck:tracks-armed"));
}
-/** Create an empty track on the default input and show it. */
-export async function addTrack() {
+/**
+ * Create an empty track on the default input and show it. `kind` "video"
+ * makes a video track: Record films its takes with the camera as well.
+ */
+export async function addTrack(kind = "audio") {
const jobId = _jobId || currentJobId;
if (!jobId) return null;
- const btn = _addBtn;
- if (btn) btn.disabled = true;
+ setAddEnabled(false);
try {
const input = await defaultTrackInput();
- const track = await createTrack(jobId, input ? { input } : {});
+ const fields = input ? { input } : {};
+ if (kind === "video") fields.kind = "video";
+ const track = await createTrack(jobId, fields);
requestTakesReload(jobId);
return track;
} catch (e) {
@@ -684,15 +704,22 @@ export async function addTrack() {
showError(t(e?.status === 409 ? "rec.track.tooMany" : "rec.track.failed"));
return null;
} finally {
- if (btn) btn.disabled = !_jobId;
+ setAddEnabled(!!_jobId);
}
}
export function initTrackControls() {
- _addBtn = document.getElementById("addTrackBtn");
- _addBtn?.addEventListener("click", (e) => {
+ _addRow = document.getElementById("addTrackRow");
+ const audio = document.getElementById("addTrackBtn");
+ const video = document.getElementById("addVideoTrackBtn");
+ _addBtns = [audio, video].filter(Boolean);
+ audio?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ addTrack("audio");
+ });
+ video?.addEventListener("click", (e) => {
e.stopPropagation();
- addTrack();
+ addTrack("video");
});
navigator.mediaDevices?.addEventListener?.("devicechange", () => { refreshTrackInputs(); });
}
diff --git a/static/js/i18n.js b/static/js/i18n.js
index ff46914d..f8c0af84 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -221,6 +221,12 @@ const en = {
// Recording a take, and the shared capture messages (static/js/capture/).
"rec.track.add": "Track",
"rec.track.addTitle": "Add a recording track",
+ "rec.track.addAudio": "Audio track",
+ "rec.track.addVideo": "Video track",
+ "rec.track.addAudioTitle": "Add a track that records sound",
+ "rec.track.addVideoTitle": "Add a track that films with the camera and records sound",
+ "rec.track.videoBadge": "Video track: films with the camera",
+ "rec.status.cameraFailed": "The camera did not open, so nothing was recorded. The camera box in this panel says why.",
"rec.track.armAria": "Arm {name} for recording",
"rec.track.armTitle": "Arm for recording",
"rec.track.inputAria": "Input for {name}",
@@ -1552,6 +1558,12 @@ const pl = {
// Recording a take, and the shared capture messages (static/js/capture/).
"rec.track.add": "Ścieżka",
"rec.track.addTitle": "Dodaj ścieżkę nagrania",
+ "rec.track.addAudio": "Ścieżka audio",
+ "rec.track.addVideo": "Ścieżka wideo",
+ "rec.track.addAudioTitle": "Dodaj ścieżkę, która nagrywa dźwięk",
+ "rec.track.addVideoTitle": "Dodaj ścieżkę, która filmuje kamerą i nagrywa dźwięk",
+ "rec.track.videoBadge": "Ścieżka wideo: filmuje kamerą",
+ "rec.status.cameraFailed": "Kamera się nie otworzyła, więc nic nie nagrano. Pole kamery w tym panelu podaje powód.",
"rec.track.armAria": "Uzbrój {name} do nagrywania",
"rec.track.armTitle": "Uzbrój do nagrywania",
"rec.track.inputAria": "Wejście dla: {name}",
@@ -2873,6 +2885,12 @@ const ja = {
// Recording a take, and the shared capture messages (static/js/capture/).
"rec.track.add": "トラック",
"rec.track.addTitle": "録音トラックを追加",
+ "rec.track.addAudio": "オーディオトラック",
+ "rec.track.addVideo": "ビデオトラック",
+ "rec.track.addAudioTitle": "音を録音するトラックを追加",
+ "rec.track.addVideoTitle": "カメラで撮影し、音も録音するトラックを追加",
+ "rec.track.videoBadge": "ビデオトラック: カメラで撮影します",
+ "rec.status.cameraFailed": "カメラが開かなかったため、何も録音されませんでした。理由はこのパネルのカメラ欄に表示されます。",
"rec.track.armAria": "{name} を録音待機にする",
"rec.track.armTitle": "録音待機",
"rec.track.inputAria": "{name} の入力",
@@ -4169,6 +4187,12 @@ const zhHans = {
// Recording a take, and the shared capture messages (static/js/capture/).
"rec.track.add": "音轨",
"rec.track.addTitle": "添加录音音轨",
+ "rec.track.addAudio": "音频音轨",
+ "rec.track.addVideo": "视频音轨",
+ "rec.track.addAudioTitle": "添加一条录制声音的音轨",
+ "rec.track.addVideoTitle": "添加一条用摄像头拍摄并录制声音的音轨",
+ "rec.track.videoBadge": "视频音轨:用摄像头拍摄",
+ "rec.status.cameraFailed": "摄像头没有打开,所以没有录下任何内容。原因见此面板中的摄像头区域。",
"rec.track.armAria": "让 {name} 准备录音",
"rec.track.armTitle": "准备录音",
"rec.track.inputAria": "{name} 的输入",
@@ -5465,6 +5489,12 @@ const de = {
// Recording a take, and the shared capture messages (static/js/capture/).
"rec.track.add": "Spur",
"rec.track.addTitle": "Aufnahmespur hinzufügen",
+ "rec.track.addAudio": "Audiospur",
+ "rec.track.addVideo": "Videospur",
+ "rec.track.addAudioTitle": "Eine Spur hinzufügen, die Ton aufnimmt",
+ "rec.track.addVideoTitle": "Eine Spur hinzufügen, die mit der Kamera filmt und Ton aufnimmt",
+ "rec.track.videoBadge": "Videospur: filmt mit der Kamera",
+ "rec.status.cameraFailed": "Die Kamera ließ sich nicht öffnen, daher wurde nichts aufgenommen. Das Kamerafeld in diesem Bereich nennt den Grund.",
"rec.track.armAria": "{name} für die Aufnahme scharf schalten",
"rec.track.armTitle": "Für die Aufnahme scharf schalten",
"rec.track.inputAria": "Eingang für {name}",
@@ -6772,6 +6802,12 @@ const pt = {
// Recording a take, and the shared capture messages (static/js/capture/).
"rec.track.add": "Faixa",
"rec.track.addTitle": "Adicionar faixa de gravação",
+ "rec.track.addAudio": "Faixa de áudio",
+ "rec.track.addVideo": "Faixa de vídeo",
+ "rec.track.addAudioTitle": "Adicionar uma faixa que grava som",
+ "rec.track.addVideoTitle": "Adicionar uma faixa que filma com a câmera e grava som",
+ "rec.track.videoBadge": "Faixa de vídeo: filma com a câmera",
+ "rec.status.cameraFailed": "A câmera não abriu, então nada foi gravado. A área da câmera neste painel diz o motivo.",
"rec.track.armAria": "Armar {name} para gravação",
"rec.track.armTitle": "Armar para gravação",
"rec.track.inputAria": "Entrada de {name}",
@@ -8081,6 +8117,12 @@ const id = {
// Recording a take, and the shared capture messages (static/js/capture/).
"rec.track.add": "Trek",
"rec.track.addTitle": "Tambah trek rekaman",
+ "rec.track.addAudio": "Trek audio",
+ "rec.track.addVideo": "Trek video",
+ "rec.track.addAudioTitle": "Tambah trek yang merekam suara",
+ "rec.track.addVideoTitle": "Tambah trek yang merekam video dengan kamera dan merekam suara",
+ "rec.track.videoBadge": "Trek video: merekam dengan kamera",
+ "rec.status.cameraFailed": "Kamera tidak terbuka, jadi tidak ada yang direkam. Kotak kamera di panel ini menjelaskan alasannya.",
"rec.track.armAria": "Siapkan {name} untuk merekam",
"rec.track.armTitle": "Siapkan untuk merekam",
"rec.track.inputAria": "Input untuk {name}",
@@ -9377,6 +9419,12 @@ const fr = {
// Recording a take, and the shared capture messages (static/js/capture/).
"rec.track.add": "Piste",
"rec.track.addTitle": "Ajouter une piste d'enregistrement",
+ "rec.track.addAudio": "Piste audio",
+ "rec.track.addVideo": "Piste vidéo",
+ "rec.track.addAudioTitle": "Ajouter une piste qui enregistre le son",
+ "rec.track.addVideoTitle": "Ajouter une piste qui filme avec la caméra et enregistre le son",
+ "rec.track.videoBadge": "Piste vidéo : filme avec la caméra",
+ "rec.status.cameraFailed": "La caméra ne s'est pas ouverte, donc rien n'a été enregistré. Le cadre caméra de ce panneau en donne la raison.",
"rec.track.armAria": "Armer {name} pour l'enregistrement",
"rec.track.armTitle": "Armer pour l'enregistrement",
"rec.track.inputAria": "Entrée de {name}",
@@ -10983,6 +11031,12 @@ const es = {
// Recording a take, and the shared capture messages (static/js/capture/).
"rec.track.add": "Pista",
"rec.track.addTitle": "Añadir pista de grabación",
+ "rec.track.addAudio": "Pista de audio",
+ "rec.track.addVideo": "Pista de vídeo",
+ "rec.track.addAudioTitle": "Añadir una pista que graba sonido",
+ "rec.track.addVideoTitle": "Añadir una pista que graba con la cámara y graba sonido",
+ "rec.track.videoBadge": "Pista de vídeo: graba con la cámara",
+ "rec.status.cameraFailed": "La cámara no se abrió, así que no se grabó nada. El recuadro de la cámara en este panel indica el motivo.",
"rec.track.armAria": "Armar {name} para grabar",
"rec.track.armTitle": "Armar para grabar",
"rec.track.inputAria": "Entrada de {name}",
@@ -12312,6 +12366,12 @@ const ko = {
// Recording a take, and the shared capture messages (static/js/capture/).
"rec.track.add": "트랙",
"rec.track.addTitle": "녹음 트랙 추가",
+ "rec.track.addAudio": "오디오 트랙",
+ "rec.track.addVideo": "비디오 트랙",
+ "rec.track.addAudioTitle": "소리를 녹음하는 트랙 추가",
+ "rec.track.addVideoTitle": "카메라로 촬영하고 소리도 녹음하는 트랙 추가",
+ "rec.track.videoBadge": "비디오 트랙: 카메라로 촬영합니다",
+ "rec.status.cameraFailed": "카메라가 열리지 않아 아무것도 녹음되지 않았습니다. 이유는 이 패널의 카메라 영역에 나옵니다.",
"rec.track.armAria": "{name} 녹음 대기",
"rec.track.armTitle": "녹음 대기",
"rec.track.inputAria": "{name} 입력",
diff --git a/static/js/video/videoTake.js b/static/js/video/videoTake.js
index f13d674b..784abe46 100644
--- a/static/js/video/videoTake.js
+++ b/static/js/video/videoTake.js
@@ -197,6 +197,17 @@ function cameraOn() {
return Boolean(stream && stream.getVideoTracks().some((tr) => tr.readyState === "live"));
}
+/**
+ * Record calls this before a video track's take: the camera on, ready to
+ * film. True when it is live; false when it could not open (the camera box
+ * in the panel then says why).
+ */
+export async function ensureCameraOn() {
+ if (!ui.toggle) return false;
+ if (cameraOn()) return true;
+ return _startStream();
+}
+
// ── Take lifecycle ──────────────────────────────────────────────────────
function _onTake(ev) {
diff --git a/tests/test_tracks_api.py b/tests/test_tracks_api.py
index b91e5bd0..04c20def 100644
--- a/tests/test_tracks_api.py
+++ b/tests/test_tracks_api.py
@@ -107,6 +107,7 @@ async def test_create_lists_and_persists_tracks(jobs_dir, client):
state = await _state(c)
assert a["name"] == "Track 1" and a["order"] == 0 and a["active_take_id"] is None
assert a["input"] is None and takes_mod.TRACK_ID_RE.match(a["id"])
+ assert a["kind"] == "audio" and b["kind"] == "audio"
assert b["name"] == "Guitar" and b["order"] == 1
assert b["input"] == {"device_id": "abc", "device_label": None, "channels": [2, 3]}
assert state["tracks"] == [a, b]
@@ -127,6 +128,8 @@ async def test_create_lists_and_persists_tracks(jobs_dir, client):
{"input": {"channels": [64]}},
{"input": {"channels": [0], "device_id": "x" * 257}},
{"input": {"channels": [0], "gain": 2}},
+ {"kind": "midi"},
+ {"kind": 1},
],
)
async def test_create_refuses_bad_bodies(jobs_dir, client, body):
@@ -181,6 +184,22 @@ async def test_patch_renames_sets_input_and_reorders(jobs_dir, client):
assert (await c.patch(url, json={"order": -1})).status_code == 422
+async def test_a_track_can_be_a_video_track(jobs_dir, client):
+ _make_job(jobs_dir)
+ async with client as c:
+ v = await _track(c, kind="video")
+ assert v["kind"] == "video"
+ url = f"/api/jobs/{JOB}/tracks/{v['id']}"
+ r = await c.patch(url, json={"kind": "audio"})
+ assert r.status_code == 200 and r.json()["kind"] == "audio"
+ r = await c.patch(url, json={"kind": None})
+ assert r.json()["kind"] == "audio" # cleared means the default
+ assert (await c.patch(url, json={"kind": "midi"})).status_code == 422
+ r = await c.patch(url, json={"kind": "video"})
+ state = await _state(c)
+ assert [t["kind"] for t in state["tracks"]] == ["video"]
+
+
# ─── Takes on tracks ─────────────────────────────────────────────────────
From fb3b6780b7e619e5cc603f093f840364a363dc65 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Wed, 7 Oct 2026 17:52:24 +0100
Subject: [PATCH 057/300] feat: Detect lyrics toggle, Practice and Advanced
modes, video tracks, two inputs
Detect lyrics
- A Detect lyrics switch beside Detect structure, off by default and kept
until changed. A song imported with it off looks nothing up: not the
pipeline, and not the tags backfill when the song is opened. Its Lyrics
tab says so and offers Look up lyrics. The Experimental label is gone.
Modes
- A Mode control in the rail (a brain): Practice (default) and Advanced,
with Streaming and Karaoke shown as coming. Practice hides recording,
tracks, EQ and the master, and bypasses EQ and master live and in
exports; Advanced brings them back as they were.
Video tracks
- A video track records the camera only, no sound, and shows its frames
along its lane. Its row has Preview (a draggable, resizable window, X in
the top bar, kept in time with the song) and Delete (two presses).
- A video track arms beside an audio track on any device (a guitar on the
interface and the camera, together).
- The playhead draws over a video track's frames, stops at an open EQ
panel, and ends at the last lane.
- Record is red while recording.
Two inputs
- Settings > Recording has a Microphone and an Audio interface, each with
its own picker and meter; the latency test asks which to measure.
Choices are found again by name when a device id changes.
- New tracks record one input, not the pair. An armed one-input track
whose input is silent while another input of the device has signal
moves to it once, with a note; never one picked by hand.
Ten languages throughout.
---
app/api/jobs.py | 15 +-
app/api/playlist.py | 9 +-
app/api/takes.py | 158 +++++++++
app/core/config.py | 5 +
app/core/models.py | 5 +
app/core/settings.py | 24 ++
app/core/takes.py | 14 +-
app/main.py | 7 +
app/pipeline/runner.py | 18 +-
static/css/daw.css | 149 +++++++--
static/css/eq.css | 4 +
static/css/recording.css | 159 +++++++++
static/css/waves.css | 21 ++
static/index.html | 33 +-
static/js/capture/captureGraph.js | 126 ++++++++
static/js/capture/recordUi.js | 170 +++++++---
static/js/capture/recordingSettings.js | 148 ++++++---
static/js/capture/takeLanes.js | 126 +++++++-
static/js/capture/trackInputs.js | 143 +++++++-
static/js/eq/eqRows.js | 17 +-
static/js/eqEngine.js | 21 +-
static/js/helpMode.js | 2 +
static/js/i18n.js | 430 ++++++++++++++++++++++---
static/js/lyrics.js | 33 ++
static/js/main.js | 38 +++
static/js/masterEngine.js | 12 +-
static/js/masterPanel.js | 3 +
static/js/mode.js | 70 ++++
static/js/modeUi.js | 157 +++++++++
static/js/player.js | 29 +-
static/js/transport.js | 21 ++
static/js/video/videoLane.js | 109 +++++++
static/js/video/videoPreview.js | 213 ++++++++++++
tests/e2e/helpers.mjs | 6 +
tests/e2e/lyrics-toggle.spec.mjs | 60 ++++
tests/e2e/modes.spec.mjs | 46 +++
tests/js/track-inputs.test.mjs | 6 +-
tests/test_audio_tags_api.py | 22 ++
tests/test_jobs_api.py | 30 ++
tests/test_pipeline_runner.py | 26 ++
tests/test_takes_render.py | 126 ++++++++
41 files changed, 2604 insertions(+), 207 deletions(-)
create mode 100644 static/js/mode.js
create mode 100644 static/js/modeUi.js
create mode 100644 static/js/video/videoLane.js
create mode 100644 static/js/video/videoPreview.js
create mode 100644 tests/e2e/lyrics-toggle.spec.mjs
create mode 100644 tests/e2e/modes.spec.mjs
diff --git a/app/api/jobs.py b/app/api/jobs.py
index a0857216..8e4e92e5 100644
--- a/app/api/jobs.py
+++ b/app/api/jobs.py
@@ -58,6 +58,7 @@
from app.core.registry import set_trashed as registry_set_trashed
from app.core.settings import (
get_acoustid_api_key,
+ get_auto_lyrics,
get_auto_sections,
get_discogs_token,
get_max_duration_sec,
@@ -272,6 +273,7 @@ async def _create_youtube_job(request: Request) -> dict[str, str]:
# last thing the pipeline does, and the toggle clears itself as soon
# as the user opens another song.
auto_sections=get_auto_sections(),
+ auto_lyrics=get_auto_lyrics(),
)
if not registry_register_if_capacity(job, MAX_PENDING_URL_JOBS):
raise HTTPException(status_code=503, detail=_URL_QUEUE_FULL_DETAIL)
@@ -371,6 +373,7 @@ async def _create_local_job(request: Request) -> dict[str, str]:
source_format=ext.removeprefix("."),
audio_tags=audio_tags,
auto_sections=get_auto_sections(),
+ auto_lyrics=get_auto_lyrics(),
)
if not registry_register_if_capacity(job, MAX_PENDING_UPLOAD_JOBS):
shutil.rmtree(job_dir, ignore_errors=True)
@@ -738,6 +741,9 @@ async def resplit_job(job_id: str, body: ResplitBody) -> dict:
work=job.work,
has_lyrics=has_lyrics,
auto_sections=get_auto_sections(),
+ # The song's lyrics came along with it: keep looking for them only if
+ # Detect lyrics is on.
+ auto_lyrics=get_auto_lyrics(),
)
if not registry_register_if_capacity(new_job, MAX_PENDING_UPLOAD_JOBS):
shutil.rmtree(new_dir, ignore_errors=True)
@@ -997,7 +1003,7 @@ async def refresh_audio_tags(job_id: str) -> dict:
if (
job.audio_tags
and (job.artist or not tagged_artist_name(job.audio_tags.get("artist")))
- and lyrics_settled(job_dir)
+ and (lyrics_settled(job_dir) or job.auto_lyrics is False)
and not _wants_identity(job, job.audio_tags)
and not _wants_work(job, job.identity, job.audio_tags)
):
@@ -1078,8 +1084,11 @@ async def refresh_audio_tags(job_id: str) -> dict:
logger.info("[%s] work lookup timed out", job_id)
lyrics = None
query = build_query(job, audio_tags=tags, band=artist, identity=identity)
- # Not asked again while LRCLIB's last "nothing" is recent.
- if query is not None and not lyrics_settled(job_dir):
+ # Not asked again while LRCLIB's last "nothing" is recent, and never
+ # for a track imported with Detect lyrics off: its Lyrics tab offers
+ # the lookup instead (POST .../lyrics/lookup), so opening the track
+ # does not fetch what the user turned off.
+ if job.auto_lyrics is not False and query is not None and not lyrics_settled(job_dir):
# find_lyrics never raises, and starts no request past its budget.
try:
lyrics = await asyncio.wait_for(
diff --git a/app/api/playlist.py b/app/api/playlist.py
index 6a2fd7cd..45618f08 100644
--- a/app/api/playlist.py
+++ b/app/api/playlist.py
@@ -23,7 +23,12 @@
from app.core.registry import pending_count as registry_pending_count
from app.core.registry import persist as registry_persist
from app.core.registry import register_if_capacity as registry_register_if_capacity
-from app.core.settings import get_auto_sections, get_max_duration_sec, get_playlist_max_items
+from app.core.settings import (
+ get_auto_lyrics,
+ get_auto_sections,
+ get_max_duration_sec,
+ get_playlist_max_items,
+)
from app.core.stems_location import is_relocating
from app.pipeline import jobqueue
from app.pipeline.download import InvalidPlaylistURL, expand_playlist
@@ -142,6 +147,7 @@ async def create_playlist_jobs(request: Request) -> dict[str, Any]:
# Read once for the batch, so every job in one playlist import agrees, and
# captured now rather than when each job reaches its sections stage.
auto_sections = get_auto_sections()
+ auto_lyrics = get_auto_lyrics()
created: list[dict[str, Any]] = []
for item in items:
job = Job(
@@ -153,6 +159,7 @@ async def create_playlist_jobs(request: Request) -> dict[str, Any]:
title=item["title"] or None,
thumbnail=item.get("thumbnail"),
auto_sections=auto_sections,
+ auto_lyrics=auto_lyrics,
)
if not registry_register_if_capacity(job, MAX_PENDING_URL_JOBS):
break # queue filled up mid-loop; report what did land
diff --git a/app/api/takes.py b/app/api/takes.py
index e1c6309d..13ad80b4 100644
--- a/app/api/takes.py
+++ b/app/api/takes.py
@@ -36,6 +36,7 @@
import asyncio
import json
import logging
+import math
import os
import re
import secrets
@@ -55,6 +56,8 @@
from app.api import stems as stems_api
from app.core.config import (
EXTRA_STEM_NAMES,
+ FILMSTRIP_FRAME_HEIGHT,
+ FILMSTRIP_MAX_FRAMES,
JOB_ID_RE,
JOBS_DIR,
STEM_NAMES,
@@ -66,9 +69,12 @@
TAKE_PART_MAX_BYTES,
TAKE_SLACK_SEC,
TAKES_DIRNAME,
+ TIMEOUT_FILMSTRIP,
TIMEOUT_VIDEO_RENDER,
TRACK_ID_RE,
TRACK_MAX_PER_JOB,
+ ffmpeg_executable,
+ ffprobe_executable,
)
from app.core.models import Job, _set
from app.core.registry import get as registry_get
@@ -1006,6 +1012,158 @@ async def get_take_camera(job_id: str, take_id: str) -> FileResponse:
return FileResponse(path.resolve(), media_type=media)
+# ---------------------------------------------------------------------------
+# Filmstrip: the frames a video track draws along its lane
+
+
+_FILMSTRIP_IMAGE = "filmstrip.jpg"
+_FILMSTRIP_META = "filmstrip.json"
+_filmstrip_locks: dict[str, threading.Lock] = {}
+_filmstrip_locks_guard = threading.Lock()
+
+
+def _probe_video(path: Path) -> tuple[float, int, int]:
+ """(duration in seconds, width, height) of the first video stream."""
+ proc = subprocess.run(
+ [
+ ffprobe_executable(),
+ "-v",
+ "error",
+ "-select_streams",
+ "v:0",
+ "-show_entries",
+ "stream=width,height:format=duration",
+ "-of",
+ "json",
+ str(path),
+ ],
+ capture_output=True,
+ text=True,
+ timeout=TIMEOUT_FILMSTRIP,
+ )
+ if proc.returncode != 0:
+ raise RuntimeError(proc.stderr.strip()[-400:] or "ffprobe failed")
+ data = json.loads(proc.stdout or "{}")
+ stream = (data.get("streams") or [{}])[0]
+ try:
+ duration = float((data.get("format") or {}).get("duration") or 0.0)
+ except ValueError:
+ duration = 0.0
+ if duration <= 0:
+ # A WebM written as MediaRecorder streams it has no duration in its
+ # header: decode it and read how far the decoder got.
+ duration = _decoded_duration(path)
+ return duration, int(stream.get("width") or 0), int(stream.get("height") or 0)
+
+
+_DECODE_TIME_RE = re.compile(r"time=(\d+):(\d+):(\d+(?:\.\d+)?)")
+
+
+def _decoded_duration(path: Path) -> float:
+ proc = subprocess.run(
+ [ffmpeg_executable(), "-nostdin", "-i", str(path), "-map", "0:v:0", "-f", "null", "-"],
+ capture_output=True,
+ text=True,
+ timeout=TIMEOUT_FILMSTRIP,
+ )
+ times = _DECODE_TIME_RE.findall(proc.stderr or "")
+ if not times:
+ return 0.0
+ h, m, sec = times[-1]
+ return int(h) * 3600 + int(m) * 60 + float(sec)
+
+
+def build_filmstrip(camera: Path, folder: Path) -> dict[str, Any]:
+ """The take's filmstrip, built once and kept beside the camera file
+ until the camera file changes: {interval, count, frame_width,
+ frame_height, duration}. One JPEG row of `count` frames, one every
+ `interval` seconds from the first frame."""
+ image = folder / _FILMSTRIP_IMAGE
+ meta_path = folder / _FILMSTRIP_META
+ with _filmstrip_locks_guard:
+ lock = _filmstrip_locks.setdefault(str(folder), threading.Lock())
+ with lock:
+ cam_mtime = camera.stat().st_mtime
+ if image.is_file() and meta_path.is_file() and image.stat().st_mtime >= cam_mtime:
+ try:
+ return json.loads(meta_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ logger.warning("unreadable %s, building it again", meta_path)
+ duration, width, height = _probe_video(camera)
+ if duration <= 0 or width <= 0 or height <= 0:
+ raise RuntimeError("the camera file has no readable video")
+ frame_h = FILMSTRIP_FRAME_HEIGHT
+ frame_w = max(2, round(width * frame_h / height / 2) * 2)
+ interval = max(1.0, duration / FILMSTRIP_MAX_FRAMES)
+ # Whole intervals only: ffmpeg gives no frame for a last part-interval,
+ # and the tile filter would pad its place in black.
+ count = max(1, min(FILMSTRIP_MAX_FRAMES, math.floor(duration / interval)))
+ tmp = folder / f".{_FILMSTRIP_IMAGE}.{uuid.uuid4().hex}.jpg"
+ try:
+ proc = subprocess.run(
+ [
+ ffmpeg_executable(),
+ "-v",
+ "error",
+ "-y",
+ "-i",
+ str(camera),
+ "-vf",
+ f"fps=1/{interval:.4f},scale={frame_w}:{frame_h},tile={count}x1",
+ "-frames:v",
+ "1",
+ "-q:v",
+ "5",
+ str(tmp),
+ ],
+ capture_output=True,
+ text=True,
+ timeout=TIMEOUT_FILMSTRIP,
+ )
+ if proc.returncode != 0 or not tmp.is_file():
+ raise RuntimeError(proc.stderr.strip()[-400:] or "ffmpeg failed")
+ os.replace(tmp, image)
+ finally:
+ tmp.unlink(missing_ok=True)
+ meta = {
+ "interval": round(interval, 4),
+ "count": count,
+ "frame_width": frame_w,
+ "frame_height": frame_h,
+ "duration": round(duration, 4),
+ }
+ meta_path.write_text(json.dumps(meta), encoding="utf-8")
+ return meta
+
+
+@router.get("/{job_id}/takes/{take_id}/filmstrip")
+async def get_take_filmstrip(job_id: str, take_id: str) -> dict[str, Any]:
+ """How to draw the take's frames along its lane: the filmstrip's layout
+ and where it is (filmstrip.jpg). Built on first request."""
+ _job, folder, _entry_ = _finished_or_404(job_id, take_id)
+ camera = camera_file(folder)
+ if camera is None or not camera.resolve().is_relative_to(JOBS_DIR.resolve()):
+ raise HTTPException(status_code=404, detail="take has no camera")
+ try:
+ meta = await asyncio.to_thread(build_filmstrip, camera.resolve(), folder)
+ except subprocess.TimeoutExpired as err:
+ logger.warning("[%s] filmstrip for %s timed out", job_id, take_id)
+ raise HTTPException(status_code=504, detail="the filmstrip took too long") from err
+ except (OSError, RuntimeError, ValueError):
+ logger.exception("[%s] filmstrip for %s failed", job_id, take_id)
+ raise HTTPException(status_code=500, detail="could not read the camera file") from None
+ return {**meta, "url": f"/api/jobs/{job_id}/takes/{take_id}/filmstrip.jpg"}
+
+
+@router.get("/{job_id}/takes/{take_id}/filmstrip.jpg", response_model=None)
+async def get_take_filmstrip_image(job_id: str, take_id: str) -> FileResponse:
+ _job, folder, _entry_ = _finished_or_404(job_id, take_id)
+ path = (folder / _FILMSTRIP_IMAGE).resolve()
+ if not path.is_relative_to(JOBS_DIR.resolve()) or not path.is_file():
+ raise HTTPException(status_code=404, detail="no filmstrip yet")
+ return FileResponse(path, media_type="image/jpeg", headers={"Cache-Control": "no-cache"})
+
+
# ---------------------------------------------------------------------------
# Video render
diff --git a/app/core/config.py b/app/core/config.py
index 2c0db129..c5b5231a 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -364,6 +364,11 @@ def js_solver_available() -> bool:
TRACK_ID_RE = re.compile(r"^k[0-9a-f]{8}$")
TRACKS_FILE = "tracks.json"
TRACK_MAX_PER_JOB = 32
+# A video take's filmstrip (GET .../takes/{id}/filmstrip): one sprite of
+# frames, this tall, at most this many, from ffmpeg within this long.
+FILMSTRIP_FRAME_HEIGHT = 72
+FILMSTRIP_MAX_FRAMES = 240
+TIMEOUT_FILMSTRIP = _env_int("STEMDECK_TIMEOUT_FILMSTRIP", 120)
# A part body is capped by the 4 MB JSON body middleware in app/main.py; the
# route checks the same number while it streams the body, because the header
# can be absent or wrong.
diff --git a/app/core/models.py b/app/core/models.py
index 9252680b..7373ce7b 100644
--- a/app/core/models.py
+++ b/app/core/models.py
@@ -257,6 +257,10 @@ class Job:
# submit, and the toggle is a per-import choice that clears itself: reading
# it late let a job lose a pass the user had asked and waited for.
auto_sections: bool = False
+ # Whether this job looks for lyrics (LRCLIB, the transcript, the retime),
+ # captured from the Detect lyrics setting when the job is created. None
+ # for a job made before the setting existed, which looks as it always did.
+ auto_lyrics: bool | None = None
sections_source: Literal["automatic", "manual"] | None = None
tags: list[str] | None = None # YouTube tags + categories, lowercased, max 8
stems: list[dict[str, str]] = field(default_factory=list)
@@ -406,6 +410,7 @@ def to_state(self) -> dict[str, Any]:
"stem_presence": self.stem_presence,
"sections": self.sections,
"sections_source": self.sections_source,
+ "auto_lyrics": self.auto_lyrics,
"tags": self.tags,
"stems": self.stems,
"takes": self.takes,
diff --git a/app/core/settings.py b/app/core/settings.py
index c30aa8f7..b2bc14ae 100644
--- a/app/core/settings.py
+++ b/app/core/settings.py
@@ -300,6 +300,30 @@ def _default_auto_sections() -> bool:
return os.environ.get("STEMDECK_AUTO_SECTIONS", "").strip() == "1"
+def _default_auto_lyrics() -> bool:
+ """Lyrics found while a song is imported (LRCLIB, then a Whisper
+ transcript of the vocals), off until the user turns Detect lyrics on.
+
+ Unlike Detect structure this is a standing choice, kept until changed.
+ STEMDECK_AUTO_LYRICS=1 turns it on for a deployment that wants it from
+ first boot.
+ """
+ return os.environ.get("STEMDECK_AUTO_LYRICS", "").strip() == "1"
+
+
+def get_auto_lyrics() -> bool:
+ with _LOCK:
+ v = _ensure().get("auto_lyrics")
+ return v if isinstance(v, bool) else _default_auto_lyrics()
+
+
+def set_auto_lyrics(value: bool) -> bool:
+ with _LOCK:
+ _ensure()["auto_lyrics"] = bool(value)
+ _save()
+ return bool(value)
+
+
def get_auto_sections() -> bool:
with _LOCK:
v = _ensure().get("auto_sections")
diff --git a/app/core/takes.py b/app/core/takes.py
index abb45fab..5a0f49e7 100644
--- a/app/core/takes.py
+++ b/app/core/takes.py
@@ -68,6 +68,17 @@ def take_entry(job_id: str, sidecar: dict[str, Any], has_camera: bool) -> dict[s
take_id = str(sidecar["id"])
latency = sidecar.get("latency") or {}
video = sidecar.get("video") or {}
+ timeline = sidecar.get("timeline") or {}
+ camera_offset_ms = float(video.get("cameraOffsetMs") or 0.0) if has_camera else 0.0
+ # Where the camera's first frame falls in the song, as the video render
+ # places it (app/api/takes.py): the take's placement, the first frame's
+ # time in the take, less the sync nudge.
+ camera_start_sec = (
+ float(timeline.get("first_sample_song_sec") or 0.0)
+ - float(latency.get("user_nudge_ms") or 0.0) / 1000.0
+ + float(video.get("firstFrameAtMs") or 0.0) / 1000.0
+ - camera_offset_ms / 1000.0
+ )
base = f"/api/jobs/{job_id}/takes/{take_id}"
track_id = str(sidecar.get("track_id") or "")
return {
@@ -78,8 +89,9 @@ def take_entry(job_id: str, sidecar: dict[str, Any], has_camera: bool) -> dict[s
"url": f"{base}/audio.wav",
"user_nudge_ms": float(latency.get("user_nudge_ms") or 0.0),
"has_camera": has_camera,
- "camera_offset_ms": float(video.get("cameraOffsetMs") or 0.0) if has_camera else 0.0,
+ "camera_offset_ms": camera_offset_ms,
"camera_url": f"{base}/camera" if has_camera else None,
+ "camera_start_sec": round(camera_start_sec, 4) if has_camera else None,
}
diff --git a/app/main.py b/app/main.py
index 238d576b..6207a883 100644
--- a/app/main.py
+++ b/app/main.py
@@ -60,6 +60,7 @@
get_allow_network,
get_auto_delete_days,
get_auto_delete_jobs,
+ get_auto_lyrics,
get_auto_sections,
get_cookies_file,
get_demucs_device,
@@ -76,6 +77,7 @@
set_allow_network,
set_auto_delete_days,
set_auto_delete_jobs,
+ set_auto_lyrics,
set_auto_sections,
set_cookies_file,
set_demucs_device,
@@ -361,6 +363,9 @@ def _settings_payload() -> dict[str, object]:
# job and produces suggestions rather than ground truth, so the user
# decides whether to pay for it.
"auto_sections": get_auto_sections(),
+ # Lyrics on import (LRCLIB and the transcript). Off until the user
+ # turns Detect lyrics on; kept until changed.
+ "auto_lyrics": get_auto_lyrics(),
"auto_delete_days_min": AUTO_DELETE_DAYS_MIN,
"auto_delete_days_max": AUTO_DELETE_DAYS_MAX,
"max_duration_sec": get_max_duration_sec(),
@@ -450,6 +455,8 @@ async def update_settings(request: Request) -> dict[str, object]:
set_auto_delete_jobs(bool(body["auto_delete_jobs"]))
if "auto_sections" in body:
set_auto_sections(bool(body["auto_sections"]))
+ if "auto_lyrics" in body:
+ set_auto_lyrics(bool(body["auto_lyrics"]))
for key, setter in (
("auto_delete_days", set_auto_delete_days),
("max_duration_sec", set_max_duration_sec),
diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py
index 484a1dfd..3bc5d148 100644
--- a/app/pipeline/runner.py
+++ b/app/pipeline/runner.py
@@ -282,13 +282,21 @@ def _run_with_band_lookup(job: Job, source: Path, job_dir: Path) -> None:
failure raises out of _run_common, past finish(), and nothing is written.
"""
lookup = IdentifyLookup.start(job, [source])
+ # Lyrics only when Detect lyrics was on for this job (False skips all
+ # three stages below; None, a job from before the setting, looks as it
+ # always did).
+ want_lyrics = job.auto_lyrics is not False
# The lyrics, on the same terms: LRCLIB beside separation, lyrics.json
# written only by a pipeline that got to the end. Looked up by the
# identity once the identification has it, else by the tags.
- lyrics = LyricsLookup.start(
- job,
- job_dir,
- identity=(lambda: lookup.wait_identity(LYRICS_IDENTITY_WAIT_SEC)) if lookup else None,
+ lyrics = (
+ LyricsLookup.start(
+ job,
+ job_dir,
+ identity=(lambda: lookup.wait_identity(LYRICS_IDENTITY_WAIT_SEC)) if lookup else None,
+ )
+ if want_lyrics
+ else None
)
_run_common(job, source, job_dir)
if lookup is not None:
@@ -300,6 +308,8 @@ def _run_with_band_lookup(job: Job, source: Path, job_dir: Path) -> None:
mark = time.monotonic()
lyrics.finish(job, job_dir, LYRICS_LOOKUP_GRACE_SEC)
_lap(job, "lyrics_wait", mark)
+ if not want_lyrics:
+ return
# Lyrics from the vocals stem, only when the lookup found none: last,
# because it needs both the stem and the lookup's answer. Never raises
# but for a cancel.
diff --git a/static/css/daw.css b/static/css/daw.css
index 6f8348ff..930f80dc 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -310,15 +310,6 @@ input, textarea { font-family: inherit; }
line-height: 1.15;
}
.structure-toggle-main { display: inline-flex; align-items: center; gap: 6px; }
-/* Says what the feature is, quietly and where the choice is actually made,
- rather than as a badge on the results after the fact. */
-.structure-toggle-note {
- font-size: 8.5px;
- font-weight: 700;
- letter-spacing: 0.06em;
- text-transform: uppercase;
- opacity: 0.62;
-}
/* "All" toggle */
.stem-choice-all {
@@ -3240,6 +3231,15 @@ input, textarea { font-family: inherit; }
color: var(--fg-2);
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
}
+/* Record keeps its own colours inside the transport group, whose rule above
+ (three classes) outranked .rec-btn.recording in recording.css: the button
+ showed neither its red dot nor the solid red of a take being recorded. */
+.footer-group-transport .daw-iconbtn.btn-transport.rec-btn { color: var(--danger); }
+.footer-group-transport .daw-iconbtn.btn-transport.rec-btn.recording {
+ background: var(--danger);
+ border-color: var(--danger);
+ color: var(--fg);
+}
.footer-group-transport .daw-iconbtn.btn-transport:hover {
background: var(--panel-3);
color: var(--fg);
@@ -5333,55 +5333,66 @@ input, textarea { font-family: inherit; }
background: var(--border-strong);
}
-/* Detect structure reads as a switch rather than a word you press. The button
- keeps its markup and its aria-pressed, so nothing about how it works
- changes; the pill and its knob are drawn from the two pseudo elements.
- Grid because the switch sits left of the label and spans both the label and
- the EXPERIMENTAL note under it. */
-.daw-composer > .structure-toggle {
+/* Detect structure and Detect lyrics read as switches rather than words you
+ press, stacked in the one segment above Extract stems. Each button keeps
+ its aria-pressed, so nothing about how it works changes; the pill and its
+ knob are drawn from the two pseudo elements, the label beside them. */
+.daw-composer > .detect-toggles {
grid-column: 2;
grid-row: 1;
+ align-self: center;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: var(--composer-action-w);
+}
+.detect-toggles > .structure-toggle {
position: relative;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
- column-gap: 9px;
- padding: 0 14px;
+ column-gap: 8px;
+ min-width: 0;
+ width: 100%;
+ height: 18px;
+ min-height: 0;
+ padding: 0 10px;
+ border: none;
+ border-radius: 6px;
text-align: left;
+ font-size: 11.5px;
}
-.daw-composer > .structure-toggle::before {
+.detect-toggles > .structure-toggle::before {
content: "";
- grid-row: 1 / 3;
- width: 34px;
- height: 19px;
+ width: 26px;
+ height: 14px;
border-radius: 999px;
background: var(--panel-3);
border: 1px solid var(--border-strong);
+ box-sizing: border-box;
transition: background var(--t-fast);
}
-.daw-composer > .structure-toggle::after {
+.detect-toggles > .structure-toggle::after {
content: "";
position: absolute;
- left: 17px;
- width: 15px;
- height: 15px;
+ left: 12px;
+ top: 50%;
+ width: 10px;
+ height: 10px;
+ margin-top: -5px;
border-radius: 50%;
background: var(--fg-2);
transition: transform var(--t-fast), background var(--t-fast);
}
-.daw-composer > .structure-toggle[aria-pressed="true"]::before {
+.detect-toggles > .structure-toggle[aria-pressed="true"]::before {
background: var(--accent);
border-color: var(--accent);
}
-.daw-composer > .structure-toggle[aria-pressed="true"]::after {
- transform: translateX(15px);
+.detect-toggles > .structure-toggle[aria-pressed="true"]::after {
+ transform: translateX(12px);
background: #1a1a1a;
}
-.daw-composer > .structure-toggle .structure-toggle-main { grid-column: 2; }
-.daw-composer > .structure-toggle .structure-toggle-note {
- grid-column: 2;
- text-align: left;
-}
+.detect-toggles > .structure-toggle .structure-toggle-main { grid-column: 2; }
/* Row two, and the only thing on it. A 100% basis is what forces the wrap. */
.daw-composer > .daw-stem-section {
@@ -5390,8 +5401,7 @@ input, textarea { font-family: inherit; }
min-width: 0;
}
-/* The two actions keep their shared width and stop being full-bleed. */
-.daw-composer > .structure-toggle,
+/* The action keeps the toggles' width and stops being full-bleed. */
.daw-composer > .daw-process-btn {
align-self: center;
min-height: 40px;
@@ -6080,3 +6090,72 @@ input, textarea { font-family: inherit; }
.help-tip-h { break-after: avoid; margin-top: 8px; color: var(--accent); font-size: 10px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
.help-tip-row { break-inside: avoid; margin-top: 3px; }
.help-tip-row span { color: var(--fg); font-weight: 600; }
+
+/* ── Modes (static/js/mode.js) ──
+ The DAW controls show only with mode-daw on : hidden by default, so a
+ page in Practice never shows them while it loads. Practice is the simple
+ practice studio: no Record, no adding tracks (a song's existing tracks
+ still play, without their arm and input controls), no EQ, no master. */
+html:not(.mode-daw) #t-rec,
+html:not(.mode-daw) #t-rec-opts,
+html:not(.mode-daw) #recPanel,
+html:not(.mode-daw) .add-track-row,
+html:not(.mode-daw) .add-track-spacer,
+html:not(.mode-daw) .mx-rail > .mx-eq-slot,
+html:not(.mode-daw) .track-lane .mx-actions,
+html:not(.mode-daw) #t-master-btn,
+html:not(.mode-daw) #t-export-master,
+html:not(.mode-daw) #master-drawer {
+ display: none !important;
+}
+
+.rail-mode[aria-expanded="true"] { background: var(--panel); color: var(--fg-2); }
+
+.mode-menu {
+ position: fixed;
+ z-index: 70;
+ width: 250px;
+ padding: 6px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ background: var(--panel);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-sm);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
+}
+.mode-menu[hidden] { display: none; }
+.mode-item {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+ padding: 7px 9px;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--fg-2);
+ font-family: var(--font-sans);
+ text-align: left;
+ cursor: pointer;
+}
+.mode-item:hover:not([aria-disabled="true"]),
+.mode-item:focus-visible { background: var(--panel-2); color: var(--fg); outline: none; }
+.mode-item[aria-disabled="true"] { cursor: default; opacity: 0.55; }
+.mode-item-head { display: flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; }
+.mode-item-check { width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid var(--muted-2); box-sizing: border-box; }
+.mode-item[aria-checked="true"] .mode-item-check { background: var(--accent); border-color: var(--accent); }
+.mode-item[aria-checked="true"] .mode-item-name { color: var(--accent); }
+.mode-item-soon {
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ padding: 1px 5px;
+ border-radius: 4px;
+ border: 1px solid var(--border-strong);
+ color: var(--muted);
+}
+.mode-item-desc { padding-left: 15px; font-size: 11px; line-height: 1.35; color: var(--muted); }
+.mode-note { margin: 4px 6px 2px; font-size: 11px; color: var(--accent); }
+.mode-note[hidden] { display: none; }
diff --git a/static/css/eq.css b/static/css/eq.css
index 2c096a92..1a448b85 100644
--- a/static/css/eq.css
+++ b/static/css/eq.css
@@ -76,6 +76,10 @@
left edge, as wide as the scroller's viewport (--eq-view-w, eqRows.js). */
.eq-panel-body {
position: sticky;
+ /* Above the lanes' playhead line (waves.css), which stops at the panel:
+ opaque, so the line does not show through. */
+ z-index: 2;
+ background: var(--eq-panel-bg);
left: 0;
width: var(--eq-view-w, 100%);
max-width: 100%;
diff --git a/static/css/recording.css b/static/css/recording.css
index cc9641de..9160297d 100644
--- a/static/css/recording.css
+++ b/static/css/recording.css
@@ -595,3 +595,162 @@
/* A track that is recording shows the take being recorded (the live lane,
liveWave.js), not the take it is about to replace. */
.stem-waveform-row.take-recording > .stem-waveform-svg { visibility: hidden; }
+
+/* ── A track's tools in its key column (takeLanes.js) ──
+ Preview (a video track) and Delete, as cells like the rail's. */
+.mx-keyrail-tools {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+.track-tool {
+ flex: 1 1 0;
+ min-height: 0;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ color: var(--fg-2);
+ cursor: pointer;
+ transition: background var(--t-fast), color var(--t-fast);
+}
+.track-tool:hover { background: var(--panel-2); color: var(--fg); }
+.track-tool:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }
+.track-tool.track-delete:hover,
+.track-tool.track-delete.armed { color: var(--danger); }
+.track-tool.track-delete.armed { background: color-mix(in srgb, var(--danger) 18%, transparent); }
+html:not(.mode-daw) .mx-keyrail-tools { display: none; }
+
+/* ── A video track's frames along its lane (video/videoLane.js) ── */
+.lane-filmstrip {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ height: 100%;
+ pointer-events: none;
+ opacity: 0.9;
+ border-radius: 3px;
+}
+
+/* ── The video preview window (video/videoPreview.js) ── */
+.video-preview {
+ position: fixed;
+ z-index: 65;
+ display: flex;
+ flex-direction: column;
+ min-width: 220px;
+ min-height: 150px;
+ max-width: calc(100vw - 16px);
+ max-height: calc(100vh - 16px);
+ background: #000;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-sm);
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55);
+ overflow: hidden;
+ resize: both;
+}
+.video-preview[hidden] { display: none; }
+.video-preview:focus { outline: none; }
+.video-preview:focus-visible { outline: 2px solid var(--focus-ring); }
+.video-preview-bar {
+ position: relative;
+ flex: 0 0 26px;
+ display: flex;
+ align-items: center;
+ padding: 0 8px;
+ background: var(--panel);
+ border-bottom: 1px solid var(--border);
+ cursor: grab;
+ user-select: none;
+ touch-action: none;
+}
+.video-preview-bar:active { cursor: grabbing; }
+/* Left of the X, never under it. */
+.video-preview-title {
+ flex: 0 1 calc(50% - 22px);
+ min-width: 0;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--fg-2);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+/* The X in the middle of the top bar. */
+.video-preview-close {
+ position: absolute;
+ left: 50%;
+ top: 2px;
+ transform: translateX(-50%);
+ width: 26px;
+ height: 22px;
+ padding: 0;
+ border: 0;
+ border-radius: 5px;
+ background: transparent;
+ color: var(--fg-2);
+ font-size: 18px;
+ line-height: 1;
+ cursor: pointer;
+}
+.video-preview-close:hover { background: var(--panel-2); color: var(--fg); }
+.video-preview-close:focus-visible { outline: 2px solid var(--focus-ring); }
+.video-preview-stage {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.video-preview-video {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ background: #000;
+}
+.video-preview-video[hidden] { display: none; }
+.video-preview-empty {
+ margin: 0;
+ padding: 0 16px;
+ font-size: 12px;
+ color: var(--muted);
+ text-align: center;
+}
+.video-preview-empty[hidden] { display: none; }
+
+/* Settings > Recording: which input the latency test measures. */
+.rec-settings-test {
+ display: inline-flex;
+ margin: 2px 0 6px;
+ border: 1px solid var(--border-strong);
+ border-radius: 6px;
+ overflow: hidden;
+}
+.rec-seg-btn {
+ padding: 4px 10px;
+ border: 0;
+ background: transparent;
+ color: var(--fg-2);
+ font-size: 11.5px;
+ cursor: pointer;
+}
+.rec-seg-btn + .rec-seg-btn { border-left: 1px solid var(--border-strong); }
+.rec-seg-btn.active { background: var(--panel-3); color: var(--accent); }
+.rec-seg-btn:disabled { opacity: 0.45; cursor: default; }
+.rec-seg-btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }
+
+/* A video track is picture only (its take's audio is silence, kept for
+ timing): no waveform on its lane, and no fader, level, M, S, EQ or
+ download in its row. Its rail keeps its column, empty, so the rows line up. */
+.stem-waveform-row.video-lane .stem-waveform-svg,
+.stem-waveform-row.video-lane .lane-drag-nugget { display: none; }
+.lane-header.track-video .mx-faderline,
+.lane-header.track-video .mx-rail > * { display: none; }
+/* The level keeps its height so the name sits where it does on other rows.
+ As specific as daw.css's .mx-card .lane-vu.mx-meter, which loads later. */
+.lane-header.track-video .lane-vu.mx-meter { visibility: hidden; }
diff --git a/static/css/waves.css b/static/css/waves.css
index d32b0f61..65cf946f 100644
--- a/static/css/waves.css
+++ b/static/css/waves.css
@@ -706,7 +706,28 @@
pointer-events: none;
display: flex;
flex-direction: column;
+ --lane-playhead: 0%;
+}
+/* The playhead over the lanes. The ruler's line runs under this layer (z 3),
+ so a solid lane (a video track's frames) hid it; this one is drawn in the
+ layer, above the lanes' waveforms and frames, and below an open EQ panel
+ (.eq-panel-body, z 2), which it must not cross. Placed by
+ updatePlayheadMarker (transport.js). */
+.stem-waveform-layer::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ /* Down to the last lane's bottom, not into the + track row under it. */
+ height: var(--lane-playhead-h, 100%);
+ left: var(--lane-playhead);
+ width: 1.5px;
+ transform: translateX(-50%);
+ background: #e54e4e;
+ box-shadow: 0 0 7px rgba(229, 78, 78, 0.34);
+ pointer-events: none;
+ z-index: 1;
}
+.app.is-import .stem-waveform-layer::before { display: none; }
.stem-waveform-row {
/* Fixed 48px height matches the multitrack lane height
diff --git a/static/index.html b/static/index.html
index 78b42063..a7fb367a 100644
--- a/static/index.html
+++ b/static/index.html
@@ -163,12 +163,21 @@
broken: nothing moved, because nothing it controls belongs to a
track that has already been separated (#634). The bar has its
own control, "Sections" in the Collapse row. -->
-
+
+
+
+
+
+
-
${favButtonHtml(track)}
`;
- wireFavButton(el, trackId);
wireTrackDragAndLoad(el, trackId);
return el;
}
@@ -1932,9 +1907,29 @@ function makeStripItem({ className = "", id, title, html, color, trackId }) {
// as it was, and this is the one way to run it again: the user decides when it
// gets the GPU, never Restore and never a reload.
function extractButtonHtml(track) {
- const label = i18nT("track.extractAgainOf", { title: displayTitle(track.title) });
- return `${esc(i18nT("track.extractAgain"))}`;
+ // A failed import says Try again; a stopped one, Extract. Both run it again.
+ const failed = track.status === "error";
+ const text = i18nT(failed ? "job.tryAgain" : "track.extractAgain");
+ const label = i18nT(failed ? "track.tryAgainOf" : "track.extractAgainOf", { title: displayTitle(track.title) });
+ return `${esc(text)}`;
+}
+
+/**
+ * A failed link import can be tried again: the server quarantines a failed
+ * job and forgets it on restart, so this is a new import of the same link,
+ * which takes over the failed row (addTrackToLibrary, by source). A failed
+ * upload has no file left to try with.
+ */
+function canRetryImport(track) {
+ return track.status === "error" && isReimportableSource(track.sourceUrl);
+}
+
+function retryFailedImport(trackId) {
+ const track = tracks[trackId];
+ if (!track || !canRetryImport(track)) return;
+ dismissFailuresByJobId(trackId);
+ importFromUrl(track.sourceUrl, { title: track.title, stems: track.selectedStems });
}
async function extractStoppedTrack(trackId) {
@@ -1964,7 +1959,7 @@ function renderTrackItem(trackId, { inTrash = false } = {}) {
const el = document.createElement("div");
const isUnavailable = track.status === "unavailable";
- el.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}${!inTrash && track.favorite ? " is-fav" : ""}${!inTrash && track.status === "stopped" ? " is-stopped" : ""}${inTrash ? " in-trash" : ""}${inTrash && _trashSelection.has(trackId) ? " selected" : ""}`;
+ el.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}${!inTrash && track.favorite ? " is-fav" : ""}${!inTrash && track.status === "stopped" ? " is-stopped" : ""}${!inTrash && canRetryImport(track) ? " can-retry" : ""}${inTrash ? " in-trash" : ""}${inTrash && _trashSelection.has(trackId) ? " selected" : ""}`;
el.dataset.id = trackId;
el.innerHTML = `
@@ -1974,7 +1969,7 @@ function renderTrackItem(trackId, { inTrash = false } = {}) {