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

+ + + 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 with the inputs, grouped as voice (microphones) and + * instrument (interfaces). `withChannels` lists an interface's channels (the + * track picker); without it each device is one option (Settings). + * `missing` (a device name) adds a disabled "not connected" entry first. + * Returns a map of option value -> {device, channels}. + */ +export function fillInputSelect(select, devices, { withChannels = false, missing = null } = {}) { + const map = new Map(); + select.replaceChildren(); + if (missing != null) { + // The track's device is not connected: say so in the picker itself, so + // nothing records from another input without a word. + const o = document.createElement("option"); + o.value = MISSING; + o.disabled = true; + o.textContent = t("rec.input.missing", { name: missing || t("rec.input.unknownDevice") }); + select.append(o); + } + for (const [kind, key] of [["voice", "rec.input.voiceGroup"], ["instrument", "rec.input.instrumentGroup"]]) { + const devs = devices.filter((d) => d.kind === kind); + if (!devs.length) continue; + const group = document.createElement("optgroup"); + group.label = t(key); + for (const dev of devs) { + const choices = withChannels ? choicesFor(dev) : [{ channels: defaultChannels(dev) }]; + for (const choice of choices) { + const o = document.createElement("option"); + o.value = withChannels ? `${dev.deviceId}\u0000${choice.channels.join(",")}` : dev.deviceId; + o.textContent = withChannels ? choiceText(dev, choice.channels) : (dev.name || t("capture.inputN", { n: dev.index + 1 })); + o.dataset.kind = kind; + map.set(o.value, { device: dev, channels: choice.channels }); + group.append(o); + } + } + select.append(group); + } + return map; +} + +export const MISSING = "__missing__"; + +/** + * Settings > Recording's input picker: one list, voice and instrument, the + * remembered choice selected, following plugs and unplugs. `onChange(deviceId)` + * runs when the user picks another device (not on refreshes). + */ +export function buildInputPicker(selectEl, onChange) { + let disposed = false; + async function refresh() { + const devices = await listInputDevices(); + if (disposed) return devices; + fillInputSelect(selectEl, devices); + const dev = pickDefaultDevice(devices, getRememberedDevice("audioinput")); + if (dev) selectEl.value = dev.deviceId; + if (!devices.length) { + const o = document.createElement("option"); + o.textContent = t("rec.track.noInput"); + selectEl.append(o); + } + selectEl.disabled = devices.length === 0; + return devices; + } + const onPick = () => { + rememberDevice("audioinput", selectEl.value); + onChange?.(selectEl.value); + }; + selectEl.addEventListener("change", onPick); + const onDeviceChange = () => { refresh(); }; + navigator.mediaDevices?.addEventListener?.("devicechange", onDeviceChange); + refresh(); + return { + refresh, + dispose() { + disposed = true; + selectEl.removeEventListener("change", onPick); + navigator.mediaDevices?.removeEventListener?.("devicechange", onDeviceChange); + }, + }; +} diff --git a/static/js/catalog.js b/static/js/catalog.js index e153d7a1..188d2bff 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -13,6 +13,7 @@ import { startQueueStream, } from "./queue.js"; import { fmtTime, isReimportableSource, storeGet, storeSet } from "./utils.js"; +import { takeStemsFromJob } from "./capture/takeLanes.js"; import { notifyFailure, setReleasePending, dismissFailuresByJobId, dismissFailuresByKind } from "./notifications.js"; // Aliased (not the bare "t") -- this file already uses "t"/"tr" as local // variable names for track objects and table rows in several scopes. @@ -748,6 +749,8 @@ function stateMetadataToTrack(state, fallbackTrack) { stems: state.selected_stems || fallbackTrack.stems, selectedStems: state.selected_stems || fallbackTrack.selectedStems, audioStems: state.stems || fallbackTrack.audioStems || [], + takes: state.takes ?? fallbackTrack.takes ?? [], + tracks: state.tracks ?? fallbackTrack.tracks ?? [], duration: state.duration || fallbackTrack.duration, status: state.status || fallbackTrack.status, bpm: state.bpm ?? fallbackTrack.bpm, @@ -1165,7 +1168,7 @@ async function loadTrackIntoStudio(trackId) { setResplitTarget(resplittable ? track : null); applyTrackInfoToPanel(track); - wireUpAudio(trackId, track.audioStems, track.duration || 0, track.thumb, track.mixUrl ?? null, track.title || "", peaksPromise, track.hasVideo ?? false, track.videoStatus ?? null); + wireUpAudio(trackId, [...track.audioStems, ...takeStemsFromJob(track.takes, track.tracks)], track.duration || 0, track.thumb, track.mixUrl ?? null, track.title || "", peaksPromise, track.hasVideo ?? false, track.videoStatus ?? null); initSections(trackId, track.sections, track.duration || 0); // Not awaited: the track is already loading, and the answer arrives as a // "tracktags" event. @@ -3819,6 +3822,28 @@ function settleStaleProcessingTracks() { // ─── Settings menu + Library editor ─── let libraryEditor = null; +// Settings > Recording (static/js/capture/recordingSettings.js), built on first +// visit. Loaded on demand: the capture modules are only needed once someone +// opens the tab, and importing them here would tie catalog.js into the +// transport's module graph at load time. +let recordingPane = null; +let recordingPaneToken = 0; + +async function enterRecordingPane(overlay) { + if (recordingPane) { recordingPane.enter(); return; } + const token = ++recordingPaneToken; + try { + const { buildRecordingPane } = await import("./capture/recordingSettings.js"); + // Settings closed, or another tab picked, while the module loaded. + if (token !== recordingPaneToken || libraryEditor !== overlay) return; + recordingPane = buildRecordingPane(overlay.querySelector('[data-pane="recording"]')); + if (!overlay.querySelector('.settings-pane[data-pane="recording"]')?.classList.contains("hidden")) { + recordingPane.enter(); + } + } catch (e) { + console.warn("[catalog] recording settings failed to load:", e); + } +} let libraryEditorOnKey = null; // The settings modal has a lot of server-fetched, dynamically-computed text @@ -3867,6 +3892,10 @@ function refreshLibrarySyncSummary() { } function closeLibraryEditor() { + // Release the microphone and camera the Recording tab may hold. + recordingPane?.dispose(); + recordingPane = null; + recordingPaneToken++; if (libraryEditorOnKey) { document.removeEventListener("keydown", libraryEditorOnKey); libraryEditorOnKey = null; @@ -4810,6 +4839,7 @@ function openLibraryEditor() { +
@@ -5031,6 +5061,8 @@ function openLibraryEditor() { + +
- ${inTrash ? `` : `
${track.status === "stopped" ? extractButtonHtml(track) : ""}${favButtonHtml(track)}
`} `; @@ -1987,9 +1982,9 @@ function renderTrackItem(trackId, { inTrash = false } = {}) { el.querySelector(".cat-extract")?.addEventListener("click", (e) => { e.stopPropagation(); e.currentTarget.disabled = true; - extractStoppedTrack(trackId); + if (track.status === "error") retryFailedImport(trackId); + else extractStoppedTrack(trackId); }); - wireFavButton(el, trackId); if (inTrash) { el.title = i18nT("trash.holdToRestore"); diff --git a/static/js/i18n.js b/static/js/i18n.js index d8dc47d3..72b7febb 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -1081,6 +1081,7 @@ const en = { "track.stopped": "Stopped", "track.extractAgain": "Extract", "track.extractAgainOf": "Extract {title} again", + "track.tryAgainOf": "Try importing {title} again", "track.moveToTrash": "Move to Trash", "track.moveTitleToTrash": "Move {title} to Trash", "track.favorite": "Add to Favorites", @@ -2455,6 +2456,7 @@ const pl = { "track.stopped": "Zatrzymano", "track.extractAgain": "Wyodrębnij", "track.extractAgainOf": "Wyodrębnij ponownie {title}", + "track.tryAgainOf": "Spróbuj ponownie zaimportować {title}", "track.moveToTrash": "Przenieś do kosza", "track.moveTitleToTrash": "Przenieś {title} do kosza", "track.favorite": "Dodaj do ulubionych", @@ -3808,6 +3810,7 @@ const ja = { "track.stopped": "停止済み", "track.extractAgain": "抽出", "track.extractAgainOf": "{title} を再抽出", + "track.tryAgainOf": "{title} をもう一度読み込む", "track.moveToTrash": "ゴミ箱に移動", "track.moveTitleToTrash": "{title}をゴミ箱に移動", "track.favorite": "お気に入りに追加", @@ -5145,6 +5148,7 @@ const zhHans = { "track.stopped": "已停止", "track.extractAgain": "提取", "track.extractAgainOf": "重新提取 {title}", + "track.tryAgainOf": "重新尝试导入 {title}", "track.moveToTrash": "移到回收站", "track.moveTitleToTrash": "将 {title} 移到回收站", "track.favorite": "添加到收藏", @@ -6487,6 +6491,7 @@ const de = { "track.stopped": "Gestoppt", "track.extractAgain": "Extrahieren", "track.extractAgainOf": "{title} erneut extrahieren", + "track.tryAgainOf": "{title} erneut importieren", "track.moveToTrash": "In den Papierkorb verschieben", "track.moveTitleToTrash": "{title} in den Papierkorb verschieben", "track.favorite": "Zu Favoriten hinzufügen", @@ -7835,6 +7840,7 @@ const pt = { "track.stopped": "Interrompida", "track.extractAgain": "Extrair", "track.extractAgainOf": "Extrair {title} novamente", + "track.tryAgainOf": "Tentar importar {title} novamente", "track.moveToTrash": "Mover para a lixeira", "track.moveTitleToTrash": "Mover {title} para a lixeira", "track.favorite": "Adicionar aos favoritos", @@ -9180,6 +9186,7 @@ const id = { "track.stopped": "Dihentikan", "track.extractAgain": "Ekstrak", "track.extractAgainOf": "Ekstrak ulang {title}", + "track.tryAgainOf": "Coba impor {title} lagi", "track.moveToTrash": "Pindahkan ke Sampah", "track.moveTitleToTrash": "Pindahkan {title} ke Sampah", "track.favorite": "Tambahkan ke favorit", @@ -10522,6 +10529,7 @@ const fr = { "track.stopped": "Arrêté", "track.extractAgain": "Extraire", "track.extractAgainOf": "Extraire à nouveau {title}", + "track.tryAgainOf": "Réessayer d'importer {title}", "track.moveToTrash": "Mettre à la corbeille", "track.moveTitleToTrash": "Mettre {title} à la corbeille", "track.favorite": "Ajouter aux favoris", @@ -12170,6 +12178,7 @@ const es = { "track.stopped": "Detenida", "track.extractAgain": "Extraer", "track.extractAgainOf": "Volver a extraer {title}", + "track.tryAgainOf": "Volver a intentar importar {title}", "track.moveToTrash": "Mover a la papelera", "track.moveTitleToTrash": "Mover {title} a la papelera", "track.favorite": "Añadir a favoritos", @@ -13535,6 +13544,7 @@ const ko = { "track.stopped": "중지됨", "track.extractAgain": "추출", "track.extractAgainOf": "{title} 다시 추출", + "track.tryAgainOf": "{title} 다시 가져오기", "track.moveToTrash": "휴지통으로 옮기기", "track.moveTitleToTrash": "{title}을(를) 휴지통으로 옮기기", "track.favorite": "즐겨찾기에 추가", diff --git a/tests/e2e/favorite-reach.spec.mjs b/tests/e2e/favorite-reach.spec.mjs index 4c1c02d6..0a10e676 100644 --- a/tests/e2e/favorite-reach.spec.mjs +++ b/tests/e2e/favorite-reach.spec.mjs @@ -1,74 +1,58 @@ // A track can be favourited at any window width (#724). // // The only heart used to be the Now Playing card's, and daw.css hid that whole -// card below 1460 px. A maximised laptop window is under that, so there was no -// way to favourite at all. Library and Favorites rows have one now, and every -// heart goes through one toggle, so they cannot disagree. The card stays in a -// compact form at narrow widths too since #735, so both hearts are on screen. +// card below 1460 px, so library rows got a heart of their own. The card stays +// in a compact form at narrow widths since #735, and the maintainer asked for +// the rows' hearts to go: the Now Playing heart is the one way to favourite, and +// a row shows a favourite only by its mark (.is-fav). import { test, expect } from "@playwright/test"; -import { JOB_ID, SIBLING_JOB_ID, openStudio, readCatalogState } from "./helpers.mjs"; +import { JOB_ID, openStudio, readCatalogState } from "./helpers.mjs"; const row = (page, id) => page.locator(`#catalogList .cat-item[data-id="${id}"]`).first(); -const rowHeart = (page, id) => row(page, id).locator(".cat-fav"); const favoriteIn = async (page, id) => Boolean((await readCatalogState(page))?.tracks?.[id]?.favorite); -test.describe("favourites at a narrow window", () => { - test.use({ viewport: { width: 1280, height: 800 } }); - - test("a library row can favourite a track that is not the one playing", async ({ page }) => { - await openStudio(page); - - await row(page, SIBLING_JOB_ID).hover(); - await expect(rowHeart(page, SIBLING_JOB_ID)).toBeVisible(); - await rowHeart(page, SIBLING_JOB_ID).click(); - - expect(await favoriteIn(page, SIBLING_JOB_ID)).toBe(true); - await expect(rowHeart(page, SIBLING_JOB_ID)).toHaveAttribute("aria-pressed", "true"); - await expect(row(page, SIBLING_JOB_ID)).toHaveClass(/\bis-fav\b/); - // The heart is a button on the row, not a way of opening the track. - await expect(row(page, JOB_ID)).toHaveClass(/\bactive\b/); - await expect(row(page, SIBLING_JOB_ID)).not.toHaveClass(/\bactive\b/); - }); - - test("the Favorites view can take a track back out", async ({ page }) => { - await openStudio(page); - await row(page, SIBLING_JOB_ID).hover(); - await rowHeart(page, SIBLING_JOB_ID).click(); - - await page.locator(".rail-favorites").click(); - await expect(row(page, SIBLING_JOB_ID)).toBeVisible(); - await row(page, SIBLING_JOB_ID).hover(); - await rowHeart(page, SIBLING_JOB_ID).click(); - - await expect(row(page, SIBLING_JOB_ID)).toHaveCount(0); - expect(await favoriteIn(page, SIBLING_JOB_ID)).toBe(false); +for (const [name, viewport] of [ + ["a narrow window", { width: 1280, height: 800 }], + ["a wide window", { width: 1600, height: 900 }], +]) { + test.describe(`favourites at ${name}`, () => { + test.use({ viewport }); + + test("the Now Playing heart favourites the open track and its row shows it", async ({ page }) => { + await openStudio(page); + const npHeart = page.locator("#fav-btn"); + await expect(npHeart).toBeVisible(); + await expect(npHeart).toHaveAttribute("aria-pressed", "false"); + + await npHeart.click(); + + await expect(npHeart).toHaveAttribute("aria-pressed", "true"); + expect(await favoriteIn(page, JOB_ID)).toBe(true); + await expect(row(page, JOB_ID)).toHaveClass(/\bis-fav\b/); + }); + + test("library rows carry no heart", async ({ page }) => { + await openStudio(page); + await row(page, JOB_ID).hover(); + await expect(page.locator("#catalogList .cat-fav")).toHaveCount(0); + }); }); +} - test("keyboard focus reveals the row heart", async ({ page }) => { - await openStudio(page); - await rowHeart(page, SIBLING_JOB_ID).focus(); - // Polled: the reveal fades in over a short transition. - await expect.poll(() => row(page, SIBLING_JOB_ID).locator(".cat-actions") - .evaluate((el) => Number(getComputedStyle(el).opacity))).toBeGreaterThan(0.9); - }); -}); - -test.describe("favourites at a wide window", () => { - test.use({ viewport: { width: 1600, height: 900 } }); +test.describe("the Favorites view", () => { + test.use({ viewport: { width: 1280, height: 800 } }); - test("the row heart and the Now Playing heart stay in step", async ({ page }) => { + test("the Now Playing heart takes the open track back out", async ({ page }) => { await openStudio(page); const npHeart = page.locator("#fav-btn"); - await expect(npHeart).toBeVisible(); - await expect(npHeart).toHaveAttribute("aria-pressed", "false"); - - await row(page, JOB_ID).hover(); - await rowHeart(page, JOB_ID).click(); - await expect(npHeart).toHaveAttribute("aria-pressed", "true"); + await npHeart.click(); + await page.locator(".rail-favorites").click(); + await expect(row(page, JOB_ID)).toBeVisible(); await npHeart.click(); - await expect(rowHeart(page, JOB_ID)).toHaveAttribute("aria-pressed", "false"); + + await expect(row(page, JOB_ID)).toHaveCount(0); expect(await favoriteIn(page, JOB_ID)).toBe(false); }); }); diff --git a/tests/e2e/favorite-sync.spec.mjs b/tests/e2e/favorite-sync.spec.mjs index 0b340592..49d90ca1 100644 --- a/tests/e2e/favorite-sync.spec.mjs +++ b/tests/e2e/favorite-sync.spec.mjs @@ -15,9 +15,8 @@ test.describe("studio", () => { test("a heart pressed in the studio is saved on the server", async ({ page }) => { const { favoriteWrites } = await openStudio(page); - await row(page, SIBLING_JOB_ID).hover(); - await row(page, SIBLING_JOB_ID).locator(".cat-fav").click(); - await expect.poll(() => favoriteWrites).toContainEqual({ id: SIBLING_JOB_ID, favorite: true }); + await page.locator("#fav-btn").click(); + await expect.poll(() => favoriteWrites).toContainEqual({ id: JOB_ID, favorite: true }); }); test("a favourite set on the phone shows in the studio", async ({ page }) => { From e7a8fe0c35cd9a5c29efa6317863487c4bc38c84 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 7 Oct 2026 19:11:34 +0100 Subject: [PATCH 059/300] fix(recording): place takes recorded with the live master on 12 ms earlier With the live master on, the song reaches the speakers 12 ms later (two 6 ms compressor look-aheads), so a take played against what was heard landed 12 ms late. Both playback engines now add the master's look-ahead to the mapping a take is placed by. With the master off it adds nothing. Exports were never affected. --- static/js/audioEngine.js | 5 ++++- static/js/chunkedAudioEngine.js | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/static/js/audioEngine.js b/static/js/audioEngine.js index e390246b..18b153b7 100644 --- a/static/js/audioEngine.js +++ b/static/js/audioEngine.js @@ -455,11 +455,14 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) { // 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. + // The live master's look-ahead is in it too: with the master on, the song + // reaches the speakers 12 ms later, and a take played against what was + // heard would land 12 ms late without it (the export's master has none). getOutputMapping: () => ({ startCtxTime, startOffset, rate: _playbackRate, - pipelineLatencySeconds: pipelineLatencySeconds(), + pipelineLatencySeconds: pipelineLatencySeconds() + fx.masterFxLatency(), epoch: _epoch, playing, }), diff --git a/static/js/chunkedAudioEngine.js b/static/js/chunkedAudioEngine.js index 2817c793..45b2442a 100644 --- a/static/js/chunkedAudioEngine.js +++ b/static/js/chunkedAudioEngine.js @@ -855,12 +855,13 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { // 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. + // _getCurrentTime, frozen at one instant, for placing recorded frames, + // with the live master's look-ahead (audioEngine.js says why). getOutputMapping: () => ({ startCtxTime: _startCtxTime, startOffset: _startOffset, rate: _playbackRate, - pipelineLatencySeconds: _pipelineLatencySeconds(), + pipelineLatencySeconds: _pipelineLatencySeconds() + fx.masterFxLatency(), epoch: _epoch, playing: playing && _audioStarted, }), From ed299e3f5668b16cae428915cd81240e99e6fb59 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 7 Oct 2026 22:30:53 +0100 Subject: [PATCH 060/300] fix(guide): show the key tip again; bring stale e2e specs up to date - The Guide skips a control with no box of its own, and the side-rail header made a lane's key value display: contents, so hovering a key number showed no tip on any lane. A display: contents element now counts as visible when what it holds is. - Recording specs: Settings has a Microphone and an Audio interface picker (each starting with None) instead of one input list, the choice is stored per role, and a new track records one input, not the pair. - Lane alignment: since the side-rail header the name sits a fixed distance above its waveform's centre line. The checks now require that distance to be the same on every row and at every window height, which still catches the drift they were written for. --- static/js/helpMode.js | 5 ++- tests/e2e/help-mode.spec.mjs | 3 +- tests/e2e/lane-alignment.spec.mjs | 49 ++++++++++++++++--------- tests/e2e/recording-real-audio.spec.mjs | 7 ++-- tests/e2e/recording-tracks.spec.mjs | 17 ++++++--- tests/e2e/recording.spec.mjs | 7 ++-- 6 files changed, 57 insertions(+), 31 deletions(-) diff --git a/static/js/helpMode.js b/static/js/helpMode.js index 81cb6ba9..a506a2ec 100644 --- a/static/js/helpMode.js +++ b/static/js/helpMode.js @@ -186,7 +186,10 @@ function findEntry(target) { function isVisible(el) { const r = el.getBoundingClientRect(); - return r.width > 0 && r.height > 0; + if (r.width > 0 && r.height > 0) return true; + // display: contents draws no box of its own (a lane's key value in the + // side-rail header): it is visible when what it holds is. + return getComputedStyle(el).display === "contents" && [...el.children].some(isVisible); } /** The control's own label, which the app already translates. Visible text diff --git a/tests/e2e/help-mode.spec.mjs b/tests/e2e/help-mode.spec.mjs index a11ebc4e..ba6fcd4c 100644 --- a/tests/e2e/help-mode.spec.mjs +++ b/tests/e2e/help-mode.spec.mjs @@ -65,7 +65,8 @@ test.describe("help mode", () => { // Headed by what the button does, not by its one letter. await expect(tip(page).locator("b")).toHaveText("Mute Drums"); - const drumsKey = page.locator(".lane-key").filter({ has: page.locator('[aria-label*="Drums"]') }).locator(".lane-key-value").first(); + // The number: its value wrapper is display: contents and has no box. + const drumsKey = page.locator(".lane-key").filter({ has: page.locator('[aria-label*="Drums"]') }).locator(".lane-key-num").first(); await drumsKey.hover({ force: true }); await expect(tip(page).locator("b")).toHaveText("Key of Drums"); }); diff --git a/tests/e2e/lane-alignment.spec.mjs b/tests/e2e/lane-alignment.spec.mjs index 65be01d8..b83d3e6e 100644 --- a/tests/e2e/lane-alignment.spec.mjs +++ b/tests/e2e/lane-alignment.spec.mjs @@ -6,6 +6,11 @@ // error accumulated down the stack, so the bottom lane was the worst. // // These tests are all measurements of that disagreement. +// +// Since the side-rail header (#776) the name sits above its fader, so it is a +// fixed distance above the waveform's centre line rather than on it. What +// must not happen is that distance changing from row to row or with the +// window: that is the drift these tests exist for. import { test, expect } from "@playwright/test"; import { openStudio, waitForClickTrack } from "./helpers.mjs"; @@ -15,6 +20,14 @@ import { openStudio, waitForClickTrack } from "./helpers.mjs"; // 853 here) -- that transition is where the drift used to appear. const HEIGHTS = [720, 768, 800, 853, 900, 947, 1000, 1013, 1080, 1150, 1237, 1300]; +// How far each name sits from its waveform's centre line (signed, px), and +// how much that distance varies across the rows. Rows with no name (an EQ +// panel) are left out. +function nameOffsets(g) { + const off = g.nameMids.flatMap((m, i) => (m === null || g.waveMids[i] === undefined ? [] : [m - g.waveMids[i]])); + return { off, spread: off.length ? Math.max(...off) - Math.min(...off) : 0 }; +} + async function geometry(page) { return page.evaluate(() => { const mid = (el) => { @@ -47,6 +60,7 @@ test.describe("lane alignment", () => { await waitForClickTrack(page); const offenders = []; + const perHeight = []; for (const height of HEIGHTS) { await page.setViewportSize({ width: 1600, height }); await page.waitForTimeout(450); @@ -56,16 +70,18 @@ test.describe("lane alignment", () => { // One pixel of rounding is fine; a row is 72px or more, so anything the // eye can see is far larger. const worstRow = Math.max(...rowDrift); - // The name and its waveform share a centre line. Two pixels covers the - // rounding in both measurements. - const nameDrift = g.nameMids.map((m, i) => (m === null ? 0 : Math.abs((g.waveMids[i] ?? m) - m))); - const worstName = Math.max(...nameDrift); + // Every name the same distance from its waveform's centre line. Two + // pixels covers the rounding in both measurements. + const { off, spread } = nameOffsets(g); + perHeight.push(off[0]); - if (worstRow > 1 || worstName > 2) { - offenders.push({ height, worstRow, worstName, rowDrift, nameDrift }); + if (worstRow > 1 || spread > 2) { + offenders.push({ height, worstRow, nameSpread: spread, rowDrift, nameOffsets: off }); } } expect(offenders).toEqual([]); + // And the same distance at every window height. + expect(Math.max(...perHeight) - Math.min(...perHeight)).toBeLessThanOrEqual(2); }); // An open EQ panel is a row of its own in both columns (static/js/eq/), @@ -86,9 +102,9 @@ test.describe("lane alignment", () => { await page.waitForTimeout(450); const g = await geometry(page); const worstRow = Math.max(...g.mixTops.map((t, i) => Math.abs((g.waveTops[i] ?? t) - t))); - const worstName = Math.max(...g.nameMids.map((m, i) => (m === null ? 0 : Math.abs((g.waveMids[i] ?? m) - m)))); - if (g.mixTops.length !== g.waveTops.length || worstRow > 1 || worstName > 2) { - offenders.push({ height, rows: [g.mixTops.length, g.waveTops.length], worstRow, worstName }); + const { spread } = nameOffsets(g); + if (g.mixTops.length !== g.waveTops.length || worstRow > 1 || spread > 2) { + offenders.push({ height, rows: [g.mixTops.length, g.waveTops.length], worstRow, nameSpread: spread }); } expect(`${g.column}px`).toBe(g.stackVar); } @@ -111,19 +127,18 @@ test.describe("lane alignment", () => { } }); - test("a stem name sits on its waveform centre line, not above it", async ({ page }) => { + test("every stem name sits the same distance from its waveform centre line", async ({ page }) => { await page.setViewportSize({ width: 1600, height: 1013 }); await openStudio(page, { tauri: true }); await waitForClickTrack(page); const g = await geometry(page); - // The name and its meter are stacked and centred as a pair, which put the - // name half a meter above the row's centre while the waveform beside it was - // centred on that line. Invisible at 72px rows, obvious once a lane grows. - for (const [i, nameMid] of g.nameMids.entries()) { - if (nameMid === null) continue; - expect(Math.abs(nameMid - g.waveMids[i])).toBeLessThanOrEqual(2); - } + // #665 put the name on the centre line; the side-rail header (#776) puts + // it above its fader instead. Either way it is one offset for every row, + // so a name never wanders off its own lane as the lanes grow. + const { off, spread } = nameOffsets(g); + expect(off.length).toBeGreaterThan(1); + expect(spread).toBeLessThanOrEqual(2); }); // The first track of a session is the one whose lanes are built before the // click-track hint has arrived under the footer. That hint is text from the diff --git a/tests/e2e/recording-real-audio.spec.mjs b/tests/e2e/recording-real-audio.spec.mjs index 0be4553e..080058b0 100644 --- a/tests/e2e/recording-real-audio.spec.mjs +++ b/tests/e2e/recording-real-audio.spec.mjs @@ -433,12 +433,12 @@ test.describe("real audio hardware: the track rows", () => { }); test.describe("real audio hardware: Settings > Recording", () => { - test("one input list, voice and instrument, and the meter moves on the interface", async ({ page }) => { + test("the interface picker lists voice and instrument, and its meter moves", 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"); + const sel = page.locator("#setRecInterface"); 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), @@ -456,7 +456,8 @@ test.describe("real audio hardware: Settings > Recording", () => { 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); + // The second meter: the microphone's comes first. + const tr = await page.locator(".rec-settings-meter .rec-meter-fill").nth(1).evaluate((e) => e.style.transform); best = Math.max(best, parseFloat(tr.replace(/[^0-9.]/g, "")) || 0); await page.waitForTimeout(100); } diff --git a/tests/e2e/recording-tracks.spec.mjs b/tests/e2e/recording-tracks.spec.mjs index d07182a4..42f7f12b 100644 --- a/tests/e2e/recording-tracks.spec.mjs +++ b/tests/e2e/recording-tracks.spec.mjs @@ -88,8 +88,9 @@ test("the input selector lists each channel and the stereo pair", async ({ page 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`); + // A new track records one input, the first: a one-cable source recorded + // as the pair played on one speaker. The pair is chosen by hand. + await expect(select.locator("option:checked")).toHaveText(`${dev} · Input 1`); // Choosing a single channel is stored with the track and records mono. const log = watchTakes(page); @@ -360,8 +361,12 @@ test("Settings > Recording lists each input once, by kind, without the default a 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)"); + // Two pickers, the microphone and the audio interface: each offers None, + // then every input once. + for (const id of ["#setRecMic", "#setRecInterface"]) { + const sel = page.locator(id); + await expect.poll(() => sel.locator("option").allTextContents(), { timeout: 10000 }) + .toEqual(["None", "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 index 05e68ff9..99d1c4df 100644 --- a/tests/e2e/recording.spec.mjs +++ b/tests/e2e/recording.spec.mjs @@ -276,12 +276,13 @@ test.describe("Settings, Recording tab", () => { await openStudio(page); await openTab(page); - // Picking an input stores it under the key new tracks default to. - const sel = page.locator("#setRecInput"); + // Picking the audio interface stores it under the key new audio tracks + // default to. The first option is None. + const sel = page.locator("#setRecInterface"); 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); + expect(await page.evaluate(() => localStorage.getItem("stemdeck:capture:device:audioinput:instrument"))).toBe(id); await expect(page.locator("#setRecLatency")).toHaveText(/estimated/, { timeout: 10000 }); // The latency test plays its clicks and finishes. The fake device hears From 44dc9d65a27ae2f7f4e2ddcc6e6648774fed6203 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Thu, 8 Oct 2026 11:57:12 +0100 Subject: [PATCH 061/300] feat(karaoke): Karaoke Mode with its own saved mix per mode and a drag-in queue Karaoke opens over the studio with synced lyrics, speed and key controls, and cards for Original (always plays) and the voice (off on every entry). No instrument cards. Each mode (Practice, Advanced, Karaoke) keeps its own saved mix, speed and key of a song: stemdeck:mix::. The older single record seeds Practice and Advanced and is never rewritten. This also fixes the key counter after switching songs. The queue takes songs dragged in from the library, reorders by drag or Alt+arrows, and plays the next song when one ends. --- static/css/karaoke.css | 587 +++++++++++++++ static/index.html | 13 + static/js/catalog.js | 28 +- static/js/helpMode.js | 1 + static/js/i18n.js | 340 +++++++++ static/js/karaoke/karaoke.js | 1100 +++++++++++++++++++++++++++++ static/js/karaoke/karaokeModel.js | 215 ++++++ static/js/karaoke/karaokeQueue.js | 243 +++++++ static/js/lyrics.js | 51 ++ static/js/main.js | 4 + static/js/mixMode.js | 55 ++ static/js/mixer.js | 87 ++- static/js/modeUi.js | 33 +- static/js/player.js | 14 +- static/js/transport.js | 68 +- static/js/utils.js | 19 +- tests/e2e/eq-engine.spec.mjs | 4 +- tests/e2e/eq-panels.spec.mjs | 2 +- tests/e2e/karaoke.spec.mjs | 326 +++++++++ tests/e2e/master-live.spec.mjs | 2 +- tests/e2e/recording.spec.mjs | 3 +- tests/js/karaoke-model.test.mjs | 82 +++ 22 files changed, 3248 insertions(+), 29 deletions(-) create mode 100644 static/css/karaoke.css create mode 100644 static/js/karaoke/karaoke.js create mode 100644 static/js/karaoke/karaokeModel.js create mode 100644 static/js/karaoke/karaokeQueue.js create mode 100644 static/js/mixMode.js create mode 100644 tests/e2e/karaoke.spec.mjs create mode 100644 tests/js/karaoke-model.test.mjs diff --git a/static/css/karaoke.css b/static/css/karaoke.css new file mode 100644 index 00000000..ba1f1d1b --- /dev/null +++ b/static/css/karaoke.css @@ -0,0 +1,587 @@ +/* Karaoke Mode (static/js/karaoke/karaoke.js): a view over the workspace, + from the design "StemDeck Karaoke Mode" (docs/karaoke-reference). Sizes are + the design's at an 1800 x 1020 window; colours are the app's tokens where it + has one (--accent, --fg, --muted), the design's own otherwise. + The layer's width depends on the sidebar, not the window, so the narrow + layouts are container queries on the layer itself. */ + +.karaoke, .kk-fs { + --kk-yellow: var(--accent); + --kk-ws: #0b0f14; + --kk-panel: #0f151c; + --kk-card: #10161d; + --kk-card-on: #141b24; + --kk-divider: #161c24; + --kk-line: #1f2832; + --kk-strong: #2b3542; + --kk-text: var(--fg); + --kk-text-2: #c3c9d1; + --kk-muted: var(--muted); + --kk-disabled: #5b6570; +} +.karaoke { + --kk-current: 64px; + position: fixed; + z-index: 25; /* over everything in the lanes (up to 20), under every menu and dialog */ + display: flex; + flex-direction: column; + gap: 12px; + box-sizing: border-box; + padding: 14px 16px 12px; + background: var(--kk-ws); + color: var(--kk-text); + font-family: var(--font-sans); + overflow: hidden; + container-type: size; + container-name: karaoke; +} +.karaoke[hidden] { display: none; } + +.kk-top { flex: 1; min-height: 0; display: flex; gap: 12px; position: relative; } + +/* ── Stage ── */ +.kk-stage { + flex: 1; + min-width: 0; + position: relative; + /* The lyric sizes below are capped by the stage's height too (cqh), so a + short window shrinks them instead of overlapping the title and the bar. */ + container-type: size; + container-name: kkstage; + border-radius: 12px; + overflow: hidden; + border: 1px solid #1c232c; + isolation: isolate; +} +.kk-bg, .kk-veil { position: absolute; inset: 0; pointer-events: none; } +.kk-bg-lights { + position: absolute; + inset: 0; + filter: blur(2px); + background: + radial-gradient(ellipse 14% 22% at 22% 12%, rgba(255, 176, 90, .55), rgba(255, 176, 90, 0) 70%), + radial-gradient(ellipse 10% 18% at 41% 6%, rgba(255, 196, 120, .45), rgba(255, 196, 120, 0) 70%), + radial-gradient(ellipse 12% 20% at 68% 10%, rgba(255, 170, 80, .5), rgba(255, 170, 80, 0) 70%), + radial-gradient(ellipse 9% 16% at 86% 18%, rgba(255, 150, 70, .4), rgba(255, 150, 70, 0) 70%), + radial-gradient(ellipse 30% 30% at 12% 70%, rgba(170, 70, 30, .35), rgba(170, 70, 30, 0) 70%), + radial-gradient(ellipse 30% 30% at 88% 65%, rgba(150, 60, 30, .32), rgba(150, 60, 30, 0) 70%), + radial-gradient(ellipse 60% 50% at 50% 45%, rgba(90, 45, 25, .55), rgba(90, 45, 25, 0) 80%), + linear-gradient(180deg, #24150d 0%, #140d09 55%, #0c0a09 100%); +} +/* The song's artwork, blurred over the stage-light fallback. */ +.kk-bg-art { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + filter: blur(40px) saturate(1.1); + transform: scale(1.1); +} +.kk-veil { background: radial-gradient(ellipse 80% 75% at 50% 45%, rgba(8, 10, 14, .25), rgba(8, 10, 14, .78) 100%); } + +.kk-stage-inner { + position: relative; + height: 100%; + display: flex; + flex-direction: column; + box-sizing: border-box; + padding: 22px 26px 18px; +} +.kk-stage-head { display: flex; align-items: center; gap: 16px; } +.kk-art, .kk-fs-art { + flex: none; + border-radius: 6px; + overflow: hidden; + box-shadow: 0 4px 18px rgba(0, 0, 0, .45); + background: radial-gradient(circle at 30% 30%, #d98a3c, #5b2a14 60%, #1c0f09); +} +.kk-art { width: 62px; height: 62px; } +.kk-art-img { width: 100%; height: 100%; object-fit: cover; display: block; } +.kk-art-img[hidden] { display: none; } +.kk-titles { flex: 1; min-width: 0; } +.kk-title { font-size: 26px; font-weight: 700; letter-spacing: -.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.kk-artist { font-size: 16px; color: var(--kk-text-2); margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.kk-icon-btn { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border: 1px solid var(--kk-strong); + border-radius: 8px; + background: rgba(11, 15, 20, .72); + color: var(--kk-text); + cursor: pointer; + padding: 0; +} +.kk-icon-btn:hover { border-color: #3a4552; color: #fff; } +.kk-icon-btn:focus-visible, .kk-btn:focus-visible, .kk-preset:focus-visible, +.kk-switch:focus-visible, .kk-bar:focus-visible, .kk-fs-play:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +/* ── Lyrics ── */ +.kk-lyrics { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 0 40px; + overflow: hidden; +} +.kk-lyrics > * { max-width: 1100px; width: 100%; overflow-wrap: anywhere; } +.kk-prev { + font-size: min(22px, 4.2cqh); + font-weight: 500; + color: rgba(232, 235, 239, .32); + min-height: 1.35em; + margin-bottom: min(34px, 5cqh); +} +.kk-current { + font-size: min(var(--kk-current), 11.5cqh); + line-height: 1.14; + font-weight: 800; + letter-spacing: -.02em; + color: #f4f5f7; + text-shadow: 0 2px 24px rgba(0, 0, 0, .5); +} +.kk-current[hidden], .kk-intro[hidden], .kk-empty[hidden] { display: none; } +.kk-w { transition: color .28s ease; } +.kk-w.on { color: var(--kk-yellow); } +.kk-next { + margin-top: min(52px, 7cqh); + font-size: min(38px, 7cqh); + line-height: 1.28; + font-weight: 500; + color: rgba(232, 235, 239, .58); +} +.kk-intro { display: flex; flex-direction: column; align-items: center; } +.kk-intro-title { font-size: 24px; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; color: var(--kk-yellow); } +.kk-intro-count { font-size: 18px; color: var(--kk-text-2); margin-top: 12px; font-variant-numeric: tabular-nums; } +.kk-dots { display: flex; gap: 10px; margin-top: 22px; } +.kk-dot { width: 12px; height: 12px; border-radius: 50%; background: rgba(232, 235, 239, .25); } +.kk-dot.on { background: var(--kk-yellow); } +.kk-empty { display: flex; flex-direction: column; align-items: center; gap: 14px; } +.kk-empty-text { margin: 0; font-size: 22px; font-weight: 600; color: var(--kk-text-2); } + +/* ── Progress ── */ +.kk-progress { padding: 0 120px; } +.kk-bar { position: relative; height: 18px; cursor: pointer; touch-action: none; } +.kk-bar-track, .kk-bar-fill { position: absolute; left: 0; top: 7px; height: 4px; border-radius: 2px; } +.kk-bar-track { right: 0; background: rgba(232, 235, 239, .22); } +.kk-bar-fill { width: 0; background: var(--kk-yellow); } +.kk-bar-thumb { + position: absolute; + top: 1px; + left: 0; + width: 16px; + height: 16px; + margin-left: -8px; + border-radius: 50%; + background: var(--kk-yellow); + box-shadow: 0 0 0 4px rgba(245, 183, 42, .18); +} +.kk-times { + display: flex; + justify-content: space-between; + margin-top: 8px; + font-family: var(--font-mono); + font-size: 15px; + color: var(--kk-text); + font-variant-numeric: tabular-nums; +} + +/* ── Side panel: Queue | Sections ── */ +.kk-side { + width: 330px; + flex: none; + display: flex; + flex-direction: column; + gap: 12px; + border-left: 1px solid var(--kk-divider); + padding-left: 12px; + min-height: 0; +} +.kk-side-title { height: 46px; display: flex; align-items: center; padding: 0 4px; font: 600 15px var(--font-sans); color: var(--kk-text-2); } +.kk-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; padding-right: 2px; } +.kk-list[hidden] { display: none; } +.kk-up-next { margin: 4px 2px; } + +/* ── The queue ── */ +.kk-queue { border-radius: 10px; } +.kk-queue.kk-drop { outline: 1px dashed var(--kk-yellow); outline-offset: 2px; } +.kk-q-items { display: flex; flex-direction: column; gap: 8px; } +.kk-q-row { + position: relative; + display: flex; + align-items: center; + gap: 12px; + border: 1px solid var(--kk-line); + border-radius: 10px; + padding: 10px 10px 10px 12px; + background: var(--kk-card); + cursor: pointer; +} +.kk-q-row:hover { border-color: #3a4552; } +.kk-q-row:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; } +.kk-q-row.kk-q-dragging { opacity: .4; } +.kk-q-art { + width: 44px; + height: 44px; + flex: none; + border-radius: 6px; + overflow: hidden; + background: radial-gradient(circle at 30% 30%, #d98a3c, #5b2a14 60%, #1c0f09); +} +.kk-q-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; } +.kk-q-title { font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.kk-q-sub { font-size: 13px; color: var(--kk-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.kk-q-remove { + flex: none; + width: 28px; + height: 28px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--kk-muted); + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 0; +} +.kk-q-remove:hover { background: rgba(255, 255, 255, .08); color: var(--kk-text); } +.kk-q-remove:focus-visible { outline: 2px solid var(--focus-ring); } +/* Where a drop would land: a line above that row, or above the hint at the end. */ +.kk-q-row.kk-q-before::before, .kk-q-hint.kk-q-before::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: -6px; + height: 2px; + border-radius: 1px; + background: var(--kk-yellow); +} +.kk-q-hint { + position: relative; + margin-top: 8px; + border: 1px dashed var(--kk-strong); + border-radius: 10px; + padding: 18px 14px; + text-align: center; + font-size: 14px; + line-height: 1.35; + color: var(--kk-text-2); +} +.kk-queue.kk-drop .kk-q-hint { border-color: var(--kk-yellow); } +.kk-side-strip { display: none; } + +/* ── Bottom controls: Key, Speed, Stems ── */ +.kk-controls { flex: none; height: 132px; display: flex; gap: 12px; } +.kk-panel { + box-sizing: border-box; + border: 1px solid var(--kk-line); + border-radius: 10px; + background: var(--kk-panel); + padding: 14px 18px; + display: flex; + flex-direction: column; + justify-content: space-between; + min-width: 0; +} +.kk-label { font-size: 11px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--kk-muted); } +.kk-key { width: 240px; flex: none; } +.kk-speed { width: 380px; flex: none; } +.kk-stems { flex: 1; padding: 14px 16px; } +.kk-key-row, .kk-speed-row { display: flex; align-items: center; gap: 10px; } +.kk-speed-row { gap: 14px; } +.kk-key-name { font-size: 24px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.kk-badge { + min-width: 28px; + height: 28px; + padding: 0 4px; + box-sizing: border-box; + border-radius: 14px; + border: 1.5px solid var(--kk-yellow); + color: var(--kk-yellow); + font: 700 11px var(--font-mono); + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; +} +.kk-key-btns { display: flex; gap: 8px; } +.kk-btn { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid #27303b; + background: #121820; + color: #c9cfd7; + border-radius: 8px; + font: 500 13px var(--font-sans); + cursor: pointer; + padding: 0 12px; +} +.kk-btn:hover:not(:disabled) { border-color: #3a4552; color: #fff; } +.kk-btn:disabled { opacity: .4; cursor: default; } +.kk-sq { width: 44px; height: 36px; padding: 0; } +.kk-reset { flex: 1; height: 36px; } +.kk-speed-value { font-size: 24px; font-weight: 600; width: 84px; font-variant-numeric: tabular-nums; } +.kk-joined { display: flex; } +.kk-joined .kk-sq { height: 32px; } +.kk-joined .kk-sq:first-child { border-radius: 8px 0 0 8px; } +.kk-joined .kk-sq:last-child { border-radius: 0 8px 8px 0; margin-left: -1px; } +.kk-presets { display: flex; } +.kk-preset { + flex: 1; + height: 34px; + font: 13px var(--font-sans); + border: 1px solid #27303b; + background: #121820; + color: #c9cfd7; + cursor: pointer; + border-radius: 0; + padding: 0; +} +.kk-preset + .kk-preset { margin-left: -1px; } +.kk-preset:first-child { border-radius: 8px 0 0 8px; } +.kk-preset:last-child { border-radius: 0 8px 8px 0; } +.kk-preset.active { position: relative; border-color: #6b5317; background: rgba(245, 183, 42, .18); color: var(--kk-yellow); font-weight: 700; } + +.kk-stem-row { display: flex; gap: 8px; height: 64px; } +.kk-stem { + flex: 1 1 0; + min-width: 0; + display: flex; + align-items: stretch; + padding: 0 10px; + border-radius: 10px; + container-type: inline-size; + container-name: kkstem; + border: 1px solid #1c232c; + background: #0d1218; + cursor: pointer; + user-select: none; +} +.kk-stem.on { border-color: var(--kk-strong); background: var(--kk-card-on); } +.kk-stem.fixed { cursor: default; } +.kk-stem-body { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; } +/* A narrow card (seven stems in the design's width leave about 97 px each): + icon and switch on one row, the label under them on one line. */ +@container kkstem (max-width: 150px) { + .kk-stem-body { + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: "icon switch" "label label"; + align-content: center; + row-gap: 4px; + column-gap: 4px; + } + .kk-stem-icon { grid-area: icon; } + .kk-switch { grid-area: switch; } + .kk-stem-label { grid-area: label; font-size: 12px; } +} +.kk-stem-icon { display: inline-flex; flex: none; color: var(--kk-disabled); } +.kk-stem.on .kk-stem-icon { color: var(--kk-stem); } +/* Lead vocals off: the design's red mic with a slash, not the grey of the rest. */ +.kk-stem[data-stem="lead_vocals"].off .kk-stem-icon, +.kk-stem[data-stem="vocals"].off .kk-stem-icon { color: #e5566b; } +.kk-stem-label { + flex: 1; + min-width: 0; + font-size: 12.5px; + line-height: 1.15; + font-weight: 500; + color: #7e8894; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.kk-stem.on .kk-stem-label { color: var(--kk-text); } +.kk-switch { + flex: none; + position: relative; + width: 40px; + height: 22px; + border-radius: 11px; + border: 0; + padding: 0; + background: #2a333e; + cursor: pointer; +} +.kk-stem.on .kk-switch { background: var(--kk-yellow); } +.kk-knob { + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #c9cfd7; + transition: left .18s ease; +} +.kk-stem.on .kk-knob { left: 21px; background: #fff; } + +/* No song open. */ +.kk-no-track { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + text-align: center; + font-size: 18px; + color: var(--kk-text-2); + background: var(--kk-ws); + z-index: 2; +} +.kk-no-track[hidden] { display: none; } + +/* A note beside the rail button (opening refused while recording). */ +.kk-note { + position: fixed; + z-index: 300; + max-width: 280px; + padding: 8px 12px; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--panel-2); + color: var(--fg); + font: 13px var(--font-sans); + box-shadow: 0 8px 24px rgba(0, 0, 0, .4); +} +.kk-note[hidden] { display: none; } + +/* ── Narrower workspaces (design 7) ── */ +@container karaoke (max-width: 1299px) { + /* cqw here is the stage's width: the stage is the nearest container. */ + .kk-current { --kk-current: clamp(40px, 5.6cqw, 64px); } + .kk-side { width: 280px; } + .kk-key { width: 220px; } + .kk-speed { width: 340px; } +} +@container karaoke (max-width: 1099px) { + .kk-side { display: none; } + .kk-side-strip { + display: flex; + flex-direction: column; + gap: 8px; + width: 56px; + flex: none; + align-items: center; + border-left: 1px solid var(--kk-divider); + padding-left: 12px; + } + /* The panel as a drawer over the stage. */ + .kk-side.kk-drawer-open { + display: flex; + position: absolute; + top: 0; + right: 68px; + bottom: 0; + width: 330px; + z-index: 3; + padding: 12px; + border: 1px solid var(--kk-strong); + border-radius: 12px; + background: var(--kk-ws); + box-shadow: 0 12px 32px rgba(0, 0, 0, .5); + } + .kk-stem-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } + .kk-stem { justify-content: center; } +} +@container karaoke (max-width: 899px) { + .kk-controls { height: auto; flex-wrap: wrap; } + .kk-key, .kk-speed { flex: 1 1 calc(50% - 6px); width: auto; min-height: 120px; } + .kk-stems { flex: 1 1 100%; } + .kk-stem-row { overflow-x: auto; } + .kk-stem { min-width: 120px; } + .kk-progress { padding: 0 24px; } + .kk-next { font-size: min(28px, 6cqh); margin-top: min(32px, 5cqh); } +} +/* A short window (design 7: under 760 px tall). */ +@media (max-height: 759px) { + .kk-prev { display: none; } + .kk-next-line + .kk-next-line { display: none; } + .kk-next { margin-top: min(28px, 5cqh); font-size: min(30px, 6.5cqh); } +} + +/* ── Fullscreen (design "Fullscreen", 1920 x 1080), sized from the viewport ── */ +.kk-fs { + position: fixed; + inset: 0; + z-index: 9000; + background: #07090c; + color: #f4f5f7; + font-family: var(--font-sans); +} +.kk-fs[hidden] { display: none; } +.kk-fs:fullscreen { width: 100vw; height: 100vh; } +.kk-fs .kk-stage { position: absolute; inset: 0; border: 0; border-radius: 0; } +.kk-fs .kk-veil { background: radial-gradient(ellipse 80% 75% at 50% 45%, rgba(8, 10, 14, .3), rgba(8, 10, 14, .82) 100%); } +.kk-fs-inner { + position: relative; + height: 100%; + display: flex; + flex-direction: column; + box-sizing: border-box; + padding: clamp(24px, 2.3vw, 64px) clamp(24px, 3.3vw, 88px) clamp(20px, 2.1vw, 56px); +} +.kk-fs-top { display: flex; align-items: center; gap: 18px; transition: opacity .3s ease; } +.kk-fs-art { width: 54px; height: 54px; } +.kk-fs-title { font-size: 22px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.kk-fs-sub { font-size: 16px; color: rgba(232, 235, 239, .7); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.kk-pill { + display: inline-flex; + align-items: center; + font-size: 14px; + color: rgba(232, 235, 239, .72); + border: 1px solid rgba(255, 255, 255, .14); + border-radius: 999px; + padding: 9px 16px; + background: rgba(8, 10, 14, .45); + white-space: nowrap; +} +.kk-fs-exit { width: 48px; height: 48px; border-radius: 10px; border-color: rgba(255, 255, 255, .16); background: rgba(8, 10, 14, .45); } +.kk-lyrics-fs { padding: 0; } +.kk-lyrics-fs > * { max-width: 85vw; } +.kk-lyrics-fs .kk-prev { font-size: clamp(18px, 1.8vw, 44px); color: rgba(232, 235, 239, .3); min-height: 1.3em; margin-bottom: clamp(28px, 2.9vw, 72px); } +.kk-lyrics-fs .kk-current { font-size: clamp(40px, 5.4vw, 140px); line-height: 1.12; text-shadow: 0 4px 40px rgba(0, 0, 0, .55); } +.kk-lyrics-fs .kk-next { font-size: clamp(24px, 3vw, 76px); line-height: 1.26; color: rgba(232, 235, 239, .52); margin-top: clamp(40px, 4.2vw, 104px); } +.kk-lyrics-fs .kk-intro-title { font-size: clamp(22px, 1.8vw, 40px); } +.kk-lyrics-fs .kk-intro-count { font-size: clamp(16px, 1.3vw, 30px); } +.kk-fs-bottom { display: flex; align-items: center; gap: 20px; transition: opacity .3s ease; } +.kk-fs-play { + width: 44px; + height: 44px; + flex: none; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, .18); + background: rgba(8, 10, 14, .45); + color: #f4f5f7; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; +} +.kk-fs-time { font-family: var(--font-mono); font-size: 15px; color: rgba(232, 235, 239, .6); font-variant-numeric: tabular-nums; } +.kk-fs-line { flex: 1; position: relative; height: 4px; border-radius: 2px; background: rgba(232, 235, 239, .16); } +.kk-fs-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; border-radius: 2px; background: var(--kk-yellow); } +/* After 3 s without the pointer: the controls fade and the cursor goes. */ +.kk-fs.kk-idle { cursor: none; } +.kk-fs.kk-idle .kk-fs-top, .kk-fs.kk-idle .kk-fs-bottom { opacity: 0; } + +@media (prefers-reduced-motion: reduce) { + .kk-w, .kk-knob, .kk-fs-top, .kk-fs-bottom { transition: none; } +} diff --git a/static/index.html b/static/index.html index a7fb367a..6931a6d2 100644 --- a/static/index.html +++ b/static/index.html @@ -14,6 +14,7 @@ + @@ -340,6 +341,14 @@ Lyrics + + +{{posLabel}} +
+03:06 + + + + + + + + diff --git a/docs/karaoke-reference/fullscreen/README.md b/docs/karaoke-reference/fullscreen/README.md new file mode 100644 index 00000000..96b0cfc4 --- /dev/null +++ b/docs/karaoke-reference/fullscreen/README.md @@ -0,0 +1,21 @@ +# Karaoke Mode · Fullscreen (TV projector) — design reference + +This is a design mockup created in a visual design tool, exported as a +standalone page. Treat it as a REFERENCE MOCKUP, not production code: +the markup and inline styles carry the design's precise values — colors, +font sizes, spacing, radii, shadows, layout — which an implementation +should replicate faithfully in its own components and styling system +rather than copy wholesale. + +## Contents + +- `Fullscreen.dc.html` — the artboard (a Design Component: an `` + template + a small logic class). The values to replicate live in its + inline `style="…"` attributes and the ` + +
+ + + + +
+ + +
+
+
+
+ +floorlight_master.wav 38.2 MB × +
+
+Extract +All +Vocals +Combined +Lead + Backing +Drums +Bass +Guitar +Piano +Other +
+
+
+
+ +Detect structure
EXPERIMENTAL
+
+ +
+
+Collapse +Analysis +Sections +
+
+
+ +
+
+
+
+
+
Now playing
+
Floorlight · The Late Arrivals
+
03:067 stemsUploadHighExtracted Oct 7, 10:14 AM
+
+ +
+
+
+ +
+ + + + +
+ + +
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+
+
+
Floorlight
+
The Late Arrivals
+
+ + + +
+ + + +
+
{{prevLine}}
+ +
Instrumental
+
Vocals start in {{countdown}}
+
+
+ +
+
{{wd.w}}
+
{{wd.w}}
+
+
+
+
{{next1}}
+
{{next2}}
+
+
+
+ + +
+
+
+
+
+
+
{{posLabel}}03:06
+
+ +
+
+ + + +
+ + +
+ +
+Key +
{{keyName}}{{semisLabel}}
+
+ + + +
+
+ +
+Speed +
+{{speedPct}} +
+ + +
+
+
+ +
+
+ +
+Stems +
+ +
+ + + + + + + + + + +{{s.label}} + +
+
+
+
+ +
+
+ + +
+
+
Transport +
+ + +
+
+
Position +
{{posLabel}}/ 03:06 + +{{posSecs}}-{{posSecs}} +
+
+
Speed +
+ +
+
+
Global key +
+ +{{semisPlain}} + + +
+
+
Click track +
+ + +Count-in: off ▾ +Auto ▾ +Grid +/21xx2 +
+
+ +
+
Clicking 112.0 BPM — 91% of beats sit on a drum hit, accenting 8 detected meter regions. Use /2 or x2 if the click feels half or double speed.
+
+ +
+
+
+
+
+ + + diff --git a/docs/karaoke-reference/karaoke-mode/README.md b/docs/karaoke-reference/karaoke-mode/README.md new file mode 100644 index 00000000..822bc921 --- /dev/null +++ b/docs/karaoke-reference/karaoke-mode/README.md @@ -0,0 +1,21 @@ +# Karaoke Mode — design reference + +This is a design mockup created in a visual design tool, exported as a +standalone page. Treat it as a REFERENCE MOCKUP, not production code: +the markup and inline styles carry the design's precise values — colors, +font sizes, spacing, radii, shadows, layout — which an implementation +should replicate faithfully in its own components and styling system +rather than copy wholesale. + +## Contents + +- `Main.dc.html` — the artboard (a Design Component: an `` + template + a small logic class). The values to replicate live in its + inline `style="…"` attributes and the `