From b56079d8ef87f312a65f21947eb79fbdb575d05f Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sun, 16 Aug 2026 01:39:32 +0100 Subject: [PATCH 1/7] feat(playback): count-in before playback and exports, redesign transport footer Count-in (#269): one bar of click count-in leads into playback and into audio exports, independent of the running click track (a clean backing track can still get a count-in). The lead-in math is defined once and mirrored between metronome.js and click_render.py, pinned by parity tests on both sides. - Playback: audioEngine schedules stem playback on a future ctx-time start so the count-in clicks land in the silent gap before the song begins; the metronome schedules them through the same clock mapping the running click already uses. - Export: stems are delayed via ffmpeg's adelay and the click WAV is rendered in output coordinates when a count-in is requested, so it isn't re-trimmed by the region -ss like a plain click. Also rebuilds the transport footer around labelled control groups (Transport, Position, Speed, Click Track) instead of a right-click popover: playback speed collapses to three practice presets (0.25x / 0.5x / 1x), the click track gets an on/off toggle and a count-in switch, and the track-info block collapses from four stacked detail rows to one compact line. --- app/api/stems.py | 143 +++++++++++--- app/pipeline/click_render.py | 217 ++++++++++++++++++--- static/css/daw.css | 353 +++++++++++++++++++---------------- static/index.html | 250 +++++++++++++------------ static/js/audioEngine.js | 30 ++- static/js/beatgridUi.js | 7 +- static/js/catalog.js | 10 +- static/js/metronome.js | 125 ++++++++++++- static/js/player.js | 32 +++- static/js/state.js | 11 +- static/js/transport.js | 121 ++++++++---- tests/js/count-in.test.mjs | 106 +++++++++++ tests/test_click_render.py | 200 +++++++++++++++++++- 13 files changed, 1209 insertions(+), 396 deletions(-) create mode 100644 tests/js/count-in.test.mjs diff --git a/app/api/stems.py b/app/api/stems.py index 3b9982a..403bb4a 100644 --- a/app/api/stems.py +++ b/app/api/stems.py @@ -12,6 +12,7 @@ import zipfile from collections import deque from pathlib import Path +from typing import NamedTuple from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse, Response, StreamingResponse @@ -28,7 +29,7 @@ from app.core.registry import get as registry_get from app.core.settings import get_export_sample_rate from app.pipeline.click_render import cache_key as click_cache_key -from app.pipeline.click_render import render_click_wav +from app.pipeline.click_render import count_in_beats, render_click_wav, render_count_in_wav logger = logging.getLogger("stemdeck.api") @@ -146,22 +147,41 @@ def _validate_stem_path(job_id: str, name: str): return path +class _ClickLane(NamedTuple): + """The extra click/count-in audio input handed to the ffmpeg graph. + + `lead_in` is the seconds of count-in baked into the front of the WAV (0 for + a plain click). `count_in` marks the file as living in *output* coordinates + -- already region-trimmed and lead-in-prefixed -- so the graph must not + `-ss` it and must delay the stems by `lead_in` to match. + """ + + path: Path + gain: float + lead_in: float + count_in: bool + + def _click_lane( job_id: str, enabled: bool, multiplier: float, accent_mode: int, gain: float, -) -> tuple[Path, float] | None: - """Render (or reuse) the click track for this job and return it as an extra - ffmpeg input, or None when it is off or the job has no beat grid. + count_in_bars: int = 0, + start: float | None = None, + end: float | None = None, +) -> _ClickLane | None: + """Render (or reuse) the click / count-in track for this job as an extra + ffmpeg input, or None when both are off or the job has no beat grid. The click is synthesised in the browser during playback and never reaches - the server, so an export can only include it by rendering an equivalent - WAV here. It spans the whole track, which means the region trim (`-ss` - before every input) lines it up with the stems with no special-casing. + the server, so an export can only include it by rendering an equivalent WAV + here. A plain click spans the whole track and is lined up by the region trim + (`-ss` before every input); a count-in is baked in output coordinates + instead (see render_count_in_wav) and the caller delays the stems to match. """ - if not enabled: + if not enabled and count_in_bars <= 0: return None grid = _read_beat_grid(job_id) if grid is None: @@ -169,25 +189,62 @@ def _click_lane( beats = grid.get("beats") or [] if not beats: return None - + bars = grid.get("bars") or [] + duration = float(grid.get("duration") or 0.0) sample_rate = get_export_sample_rate() + g = max(0.0, min(4.0, gain)) + key = click_cache_key( job_id, beats, - grid.get("bars") or [], - float(grid.get("duration") or 0.0), + bars, + duration, sample_rate, multiplier, accent_mode, + count_in_bars=count_in_bars, + include_click=enabled, + start=start, + end=end, ) path = _CLICK_CACHE_DIR / f"{key}.wav" + + if count_in_bars > 0: + # lead_in is a pure function of the grid; recompute it even on a cache + # hit so the caller can delay the stems without re-reading the WAV. + lead_in, _ = count_in_beats( + beats, bars, count_in_bars, multiplier, accent_mode, start=start or 0.0 + ) + if not path.is_file(): + try: + rendered = render_count_in_wav( + path, + beats, + bars, + duration, + sample_rate=sample_rate, + multiplier=multiplier, + accent_mode=accent_mode, + count_in_bars=count_in_bars, + include_click=enabled, + start=start or 0.0, + end=end, + ) + except Exception: + logger.exception("count-in render failed for %s", job_id) + return None + if rendered is None: + return None + _prune_mixdown_cache(_CLICK_CACHE_DIR) + return _ClickLane(path, g, lead_in, True) + if not path.is_file(): try: rendered = render_click_wav( path, beats, - grid.get("bars") or [], - float(grid.get("duration") or 0.0), + bars, + duration, sample_rate=sample_rate, multiplier=multiplier, accent_mode=accent_mode, @@ -198,7 +255,7 @@ def _click_lane( if rendered is None: return None _prune_mixdown_cache(_CLICK_CACHE_DIR) - return path, max(0.0, min(4.0, gain)) + return _ClickLane(path, g, 0.0, False) def _read_beat_grid(job_id: str) -> dict | None: @@ -560,6 +617,9 @@ async def get_mixdown( click_mult: float = Query(default=1.0, description="Click rate: 0.5, 1 or 2"), click_accent: int = Query(default=-1, ge=-1, le=32, description="-1 auto, 0 off, N per bar"), click_gain: float = Query(default=0.6, ge=0, le=4, description="Click level"), + count_in: int = Query( + default=0, ge=0, le=2, description="Count-in bars before the audio (0 off)" + ), ) -> FileResponse | StreamingResponse: """Render a mixdown of the given lanes at the given gains, streamed as WAV, MP3, FLAC, or OGG. Mirrors the studio mixer (per-stem volume, mute, solo) so the @@ -567,6 +627,11 @@ async def get_mixdown( applied -- it is a monitoring level, not part of the mix. Optional ?start=&end= trims to a loop region. + `count_in` prepends N bars of click before the audio (issue #269): the stems + are delayed and the count-in is baked into the click WAV, so the exported + file leads in like a drummer's count. It works with or without the running + click track (`click`), so a clean backing track can still carry a count-in. + Identical params (including start/end and the current export sample rate) hit a render cache instead of re-running ffmpeg (#290) -- a cheap win on a shared server where the same export gets re-downloaded.""" @@ -587,7 +652,16 @@ async def get_mixdown( paths = [_validate_stem_path(job_id, name) for name in names] media_type = MIXDOWN_MEDIA_TYPES[ext] - click_lane = _click_lane(job_id, click, click_mult, click_accent, click_gain) + click_lane = _click_lane( + job_id, + click, + click_mult, + click_accent, + click_gain, + count_in_bars=count_in, + start=start, + end=end, + ) cache_key = _mixdown_cache_key(job_id, ext, names, parsed_gains, start, end, click_lane) cache_path = _MIXDOWN_CACHE_DIR / f"{cache_key}.{ext}" if cache_path.is_file(): @@ -602,21 +676,38 @@ async def get_mixdown( ) pre_seek = ["-ss", str(start)] if start is not None else [] - post_seek = ["-t", str(end - start)] if start is not None else [] - - # The click is one more input; the filter graph below is generic over the - # list, so it needs no special case beyond its own gain. - if click_lane is not None: - paths = [*paths, click_lane[0]] - parsed_gains = [*parsed_gains, click_lane[1]] + # A count-in shifts the whole timeline: the stems are delayed by the lead-in + # and the click WAV already carries it, in output coordinates, so it is not + # `-ss`-trimmed like a plain click. Everything else is generic over the input + # list. lead_in is 0 for a plain click, collapsing this to the old graph. + lead_in = click_lane.lead_in if click_lane else 0.0 + count_in_mode = bool(click_lane and click_lane.count_in) + delay_ms = int(round(lead_in * 1000)) + # Output length is the region plus the lead-in prepended in front of it. + post_seek = ["-t", str(lead_in + (end - start))] if start is not None else [] + + stem_count = len(paths) cmd: list[str] = [ffmpeg_executable(), "-nostdin", "-loglevel", "error"] for p in paths: cmd += [*pre_seek, "-i", str(p)] - # Apply each lane's gain, then sum with amix (normalize=0 keeps levels faithful, - # matching collect.py). A single audible lane skips amix (a 1-input amix is a no-op). - filters = [f"[{i}:a]volume={g:.6f}[a{i}]" for i, g in enumerate(parsed_gains)] - n = len(paths) + if click_lane is not None: + # The count-in WAV is already in output coordinates; a plain click is + # full-length and lines up under the same -ss as the stems. + click_pre = [] if count_in_mode else pre_seek + cmd += [*click_pre, "-i", str(click_lane.path)] + + # Delay each stem by the lead-in (silent front-padding), apply its gain, then + # 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). + filters = [] + for i, g in enumerate(parsed_gains): + delay = f"adelay={delay_ms}:all=1," if delay_ms > 0 else "" + filters.append(f"[{i}:a]{delay}volume={g:.6f}[a{i}]") + if click_lane is not None: + filters.append(f"[{stem_count}:a]volume={click_lane.gain:.6f}[a{stem_count}]") + 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]") diff --git a/app/pipeline/click_render.py b/app/pipeline/click_render.py index 70b5f64..0db9194 100644 --- a/app/pipeline/click_render.py +++ b/app/pipeline/click_render.py @@ -94,6 +94,96 @@ def is_downbeat(index: int | None, bars: list[dict], accent_mode: int) -> bool: return (index - mark["beat"]) % per_bar == 0 +def count_in_beats_per_bar(bars: list[dict], accent_mode: int, start_index: int = 0) -> int: + """How many clicks make one count-in bar. + + An explicit accent count wins; otherwise the detected meter in force at the + start position; otherwise 4. Always >= 1, because a count-in needs a bar + length even on a track with no bar marks and accents switched off -- unlike + the running click, "no accent" must not mean "no bar" here. + """ + if accent_mode > 0: + return accent_mode + # Auto / off: follow the meter in force at the start beat, if the detector + # found one. bars index the detected grid, so the search is in index space. + mark = None + for b in bars: + beat = b.get("beat") + if isinstance(beat, int) and beat <= start_index: + mark = b + else: + break + if mark is not None: + per_bar = mark.get("beats_per_bar") + if isinstance(per_bar, int) and per_bar >= 1: + return per_bar + return 4 + + +def _interval_near(grid: list[float], start: float, span: int) -> float | None: + """Median beat interval of the (rescaled) grid around `start`. + + The count-in tempo is the song's tempo where playback begins, not its + average: taking the median of one bar's worth of intervals from the first + beat at or after `start` follows a track that speeds up or slows down. + Returns None when the grid is too short to measure an interval. + """ + if len(grid) < 2: + return None + i = 0 + while i < len(grid) and grid[i] < start: + i += 1 + # Anchor on the beat at/after start, but never past the last interval. + i = min(i, len(grid) - 2) + diffs = [grid[k + 1] - grid[k] for k in range(i, min(i + max(1, span), len(grid) - 1))] + diffs = [d for d in diffs if d > 0] + if not diffs: + return None + diffs.sort() + return diffs[len(diffs) // 2] + + +def count_in_beats( + beats: list[float], + bars: list[dict], + count_bars: int = 1, + multiplier: float = 1.0, + accent_mode: int = ACCENT_AUTO, + start: float = 0.0, +) -> tuple[float, list[tuple[float, bool]]]: + """Compute the count-in that leads into playback at `start`. + + Returns `(lead_in, clicks)` where `lead_in` is the seconds of pre-roll to + prepend and `clicks` is `[(offset, accent), ...]` with each offset in + `[0, lead_in)`. One bar of the detected meter counts in by default: + `PI po po po` on 4/4, the final click landing one beat before the audio so + the song enters on the next downbeat. + + Pure and side-effect free so playback (metronome.js) and export + (render_click_wav) can share one definition -- pinned by + tests/test_click_render.py, exactly like rescale/source_index/is_downbeat. + """ + if count_bars < 1: + return 0.0, [] + grid = rescale_beats([float(b) for b in beats], multiplier) + # Meter lookup uses the detected grid (bars index it); interval uses the + # rescaled grid so the count matches the click rate the user hears. + start_index = 0 + for k, t in enumerate(beats): + if t <= start: + start_index = k + else: + break + bpb = count_in_beats_per_bar(bars, accent_mode, start_index) + interval = _interval_near(grid, start, bpb) + if interval is None: + return 0.0, [] + n = count_bars * bpb + lead_in = n * interval + clicks = [(j * interval, (j % bpb) == 0) for j in range(n)] + return lead_in, clicks + + def _voice(peak: float, freq: float, sample_rate: int): """One click as a float array: a sine under the scheduler's two exponential gain ramps (RAMP_FLOOR -> peak over the attack, then back down over the rest @@ -118,39 +208,52 @@ def cache_key( sample_rate: int, multiplier: float, accent_mode: int, + count_in_bars: int = 0, + include_click: bool = True, + start: float | None = None, + end: float | None = None, ) -> str: """Every input to the render is in the key. Beats are included by digest rather than by job id alone: an edited grid must not hit a cache entry - rendered from the detected one.""" + rendered from the detected one. + + The count-in suffix is appended only when a count-in is present, so a plain + click export keeps the exact key it always had (a stable cache across the + change). With a count-in the render is region-specific -- the lead-in tempo + comes from the beats at `start` and the song clicks are trimmed to the + region -- so the region bounds and whether the song click is included both + enter the key.""" grid = hashlib.sha1( ("|".join(f"{b:.6f}" for b in beats)).encode("utf-8"), usedforsecurity=False ).hexdigest() bar_sig = ",".join(f"{b.get('beat')}:{b.get('beats_per_bar')}" for b in bars) raw = f"{job_id}|{grid}|{bar_sig}|{duration:.3f}|{sample_rate}|{multiplier}|{accent_mode}" + if count_in_bars > 0: + seg = f"{'' if start is None else f'{start:.3f}'}:{'' if end is None else f'{end:.3f}'}" + raw += f"|ci{count_in_bars}|clk{int(include_click)}|{seg}" return hashlib.sha1(raw.encode("utf-8"), usedforsecurity=False).hexdigest() -def render_click_wav( - dest: Path, - beats: list[float], - bars: list[dict], - duration: float, - sample_rate: int = 44100, - multiplier: float = 1.0, - accent_mode: int = ACCENT_AUTO, -) -> Path | None: - """Write a mono WAV of the click track spanning the whole track. - - Full length regardless of where the beats start, so the export's region trim - (`-ss` before every ffmpeg input) lines the click up with the stems without - any special-casing. Returns the path, or None when there is nothing to - render. - """ - if multiplier not in _VALID_MULTIPLIERS: - multiplier = 1.0 +def _song_click_events( + beats: list[float], bars: list[dict], multiplier: float, accent_mode: int +) -> list[tuple[float, bool]]: + """The (time, accent) pair for every beat of the running click track, in + source time. Shared by the plain click render and the count-in render so the + click sounds identical whether or not a count-in precedes it.""" grid = rescale_beats([float(b) for b in beats], multiplier) + return [ + (t, is_downbeat(source_index(i, multiplier), bars, accent_mode)) for i, t in enumerate(grid) + ] + + +def _render_events( + dest: Path, events: list[tuple[float, bool]], duration: float, sample_rate: int +) -> Path | None: + """Stamp a list of (time, accent) clicks into a mono WAV of `duration` + seconds. The single place clicks become audio, so playback parity only has + to be maintained against the two voices, not against two render paths.""" total = int(round(duration * sample_rate)) - if not grid or total <= 0: + if not events or total <= 0: return None import numpy as np @@ -160,11 +263,11 @@ def render_click_wav( plain = _voice(CLICK_PEAK, CLICK_FREQ, sample_rate) accented = _voice(ACCENT_PEAK, ACCENT_FREQ, sample_rate) - for i, t in enumerate(grid): + for t, accent in events: start = int(round(t * sample_rate)) if start >= total or start < 0: continue - voice = accented if is_downbeat(source_index(i, multiplier), bars, accent_mode) else plain + voice = accented if accent else plain n = min(len(voice), total - start) # Clicks can overlap at very fast tempos; summing matches the graph, # where every click is its own node into the same gain. @@ -181,5 +284,73 @@ def render_click_wav( w.setframerate(sample_rate) w.writeframes(pcm.tobytes()) tmp.replace(dest) - logger.info("click render: %d beats, %.1f s -> %s", len(grid), duration, dest.name) return dest + + +def render_click_wav( + dest: Path, + beats: list[float], + bars: list[dict], + duration: float, + sample_rate: int = 44100, + multiplier: float = 1.0, + accent_mode: int = ACCENT_AUTO, +) -> Path | None: + """Write a mono WAV of the click track spanning the whole track. + + Full length regardless of where the beats start, so the export's region trim + (`-ss` before every ffmpeg input) lines the click up with the stems without + any special-casing. Returns the path, or None when there is nothing to + render. + """ + if multiplier not in _VALID_MULTIPLIERS: + multiplier = 1.0 + events = _song_click_events(beats, bars, multiplier, accent_mode) + out = _render_events(dest, events, duration, sample_rate) + if out is not None: + logger.info("click render: %d beats, %.1f s -> %s", len(events), duration, dest.name) + return out + + +def render_count_in_wav( + dest: Path, + beats: list[float], + bars: list[dict], + duration: float, + sample_rate: int = 44100, + multiplier: float = 1.0, + accent_mode: int = ACCENT_AUTO, + count_in_bars: int = 1, + include_click: bool = True, + start: float = 0.0, + end: float | None = None, +) -> tuple[Path, float] | None: + """Render the click WAV for a count-in export, in *output* coordinates. + + Unlike render_click_wav (source-time, trimmed by ffmpeg's `-ss`), this bakes + the lead-in into the file: the count-in clicks occupy `[0, lead_in)` and, + when `include_click`, the region's song clicks follow shifted by `lead_in`. + The stems are delayed by the same lead_in in the ffmpeg graph, so the file + and the stems share one origin. Returns `(path, lead_in)`, or None when + there is nothing to render (grid too short and no song click requested). + """ + if multiplier not in _VALID_MULTIPLIERS: + multiplier = 1.0 + seg_start = start or 0.0 + seg_end = duration if end is None else end + seg_len = max(0.0, seg_end - seg_start) + + lead_in, count_clicks = count_in_beats( + beats, bars, count_in_bars, multiplier, accent_mode, start=seg_start + ) + events: list[tuple[float, bool]] = list(count_clicks) + if include_click: + for t, accent in _song_click_events(beats, bars, multiplier, accent_mode): + if seg_start <= t < seg_end: + events.append((t - seg_start + lead_in, accent)) + + out = _render_events(dest, events, lead_in + seg_len, sample_rate) + if out is None: + return None + logger.info("count-in render: %.3f s lead-in, %d clicks -> %s", lead_in, len(events), dest.name) + return out, lead_in diff --git a/static/css/daw.css b/static/css/daw.css index 4f0df53..2f6cf9f 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -1177,21 +1177,22 @@ input, textarea { font-family: inherit; } .daw-track-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; } .daw-track-title { font-size: 16px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.daw-track-sub { font-size: 11px; color: var(--muted); } -.daw-track-time-row { - display: flex; - align-items: center; - gap: 6px; +/* One compact line -- duration · stems · source · quality · extracted date -- + instead of a duration/stems row plus three further label:value rows below + it. The four-line version was still the pre-redesign layout; this row was + the one part of the footer rebuild (#269 follow-up) I'd described matching + the reference but never actually rebuilt. */ +.daw-track-meta-line { + display: flex; align-items: center; gap: 6px; flex-wrap: wrap; + font-size: 11px; color: var(--muted); + min-width: 0; overflow: hidden; } -.daw-track-sep { color: var(--border); font-size: 12px; } -.daw-track-stems-label { - font-size: 11px; - color: var(--muted); - display: flex; - align-items: center; - gap: 4px; +.daw-track-meta-line .num { color: var(--fg-2); } +.daw-track-sep { color: var(--border); font-size: 12px; flex-shrink: 0; } +#t-stems-chip { + display: inline-flex; align-items: center; gap: 4px; } -.daw-track-stems-label::before { +#t-stems-chip::before { content: ''; display: inline-block; width: 7px; height: 7px; @@ -1200,7 +1201,6 @@ input, textarea { font-family: inherit; } flex-shrink: 0; } .daw-fav-btn { - margin-left: auto; background: none; border: none; cursor: pointer; @@ -1213,10 +1213,6 @@ input, textarea { font-family: inherit; } .daw-fav-btn:hover { color: var(--fg); } .daw-fav-btn.active { color: #e54e4e; } .daw-fav-btn.active svg { fill: #e54e4e; stroke: #e54e4e; } -.daw-track-details { display: flex; flex-direction: column; gap: 3px; margin-top: 2px; } -.daw-detail-row { display: flex; gap: 8px; align-items: baseline; } -.daw-detail-label { font-size: 11px; color: var(--muted); width: 64px; flex-shrink: 0; } -.daw-detail-val { font-size: 11px; color: var(--fg-2); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .daw-chip { display: inline-flex; align-items: center; padding: 2px 8px; @@ -1925,7 +1921,10 @@ input, textarea { font-family: inherit; } /* ── Transport footer ── */ .daw-footer { - height: 200px; + /* min-height, not height: the click-track panel between the transport + controls and Export Mix (#269 follow-up) can wrap onto more than one + line in that narrow gap, and a rigid height would clip it. */ + min-height: 200px; flex-shrink: 0; border-top: 1px solid var(--border); background: var(--bg-2); @@ -2014,26 +2013,60 @@ input, textarea { font-family: inherit; } /* ── Footer redesign ── */ /* ── Footer layout ── */ +/* Two stacked rows: track info + actions, then the four labelled control + groups. Previously one wide row (#269 follow-up rebuild -- everything used + to compete for width in a single line; stacking gives each row its own + full-width budget instead). */ .footer-content { - display: flex; flex-direction: row; align-items: center; - padding: 0 20px; flex: 1; min-height: 0; - justify-content: space-between; + display: flex; flex-direction: column; + padding: 10px 20px 6px; flex: 1; min-height: 0; + gap: 10px; } -.footer-center { - flex: 0 0 auto; - display: flex; flex-direction: column; align-items: center; gap: 6px; - padding: 0 24px; - /* Wide enough that the transport grid below can give its two side tracks - equal width. Without a definite width the column shrink-wraps its content, - the empty spacer track collapses to zero, and the buttons end up offset by - the width of the loop-time fields (measured 109 px left of the tempo bar's - centre). */ - min-width: min(30rem, 100%); + +.footer-row-top { + display: flex; align-items: flex-start; justify-content: space-between; + gap: 16px; +} +.footer-row-actions { + display: flex; align-items: center; gap: 8px; flex-shrink: 0; +} + +.footer-row-controls { + display: flex; align-items: flex-start; flex-wrap: wrap; + gap: 8px 28px; +} +.footer-group { + display: flex; flex-direction: column; gap: 6px; + min-width: 0; +} +.footer-group-label { + display: flex; align-items: center; gap: 6px; + font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; + color: var(--muted); +} +.footer-group-body { + display: flex; align-items: center; flex-wrap: wrap; gap: 8px; +} +/* Click track carries the most controls -- give it more room to lay them out + in fewer wrapped lines than a group sized like its neighbours would. */ +.footer-group-click { flex: 1 1 320px; max-width: 640px; } +.footer-group-click .footer-group-body { gap: 6px 10px; } + +.alpha-badge { + padding: 1px 5px; border-radius: 4px; + background: rgba(74,140,255,0.16); border: 1px solid rgba(74,140,255,0.4); + color: #4a8cff; font-size: 8.5px; font-weight: 700; letter-spacing: 0.05em; + text-transform: uppercase; } + .footer-chips { - flex: 1; + /* Shrink-to-content rather than splitting the remaining row equally with + .footer-track -- the track info needs the space more. flex-shrink stays + on (with a floor) rather than 0: if the row is ever genuinely too narrow + for everything, this should compress a little before Export Mix is + pushed past the visible edge. */ + flex: 0 1 auto; min-width: 110px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; - min-width: 0; } /* Wave bar at the bottom of the footer */ .footer-wave-bar { @@ -2053,14 +2086,13 @@ input, textarea { font-family: inherit; } /* Track info (art + title + meta) */ .footer-track { - flex: 1; min-width: 0; overflow: hidden; + flex: 1; min-width: 200px; overflow: hidden; display: flex; flex-direction: column; justify-content: center; gap: 6px; } .footer-track-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; } -.footer-track-title-row .daw-fav-btn { margin-left: 0; } .footer-track-title { min-width: 0; flex-shrink: 1; font-size: 14px; font-weight: 600; @@ -2071,13 +2103,12 @@ input, textarea { font-family: inherit; } display: flex; align-items: flex-start; gap: 10px; min-width: 0; } .footer-cover { - width: 56px !important; height: 56px !important; + /* Smaller than the old 56px: the info block next to it is one title line + plus one compact meta line now, not a title plus four rows of detail. */ + width: 40px !important; height: 40px !important; flex-shrink: 0; } -.footer-track-body .daw-track-meta { flex: 1; min-width: 0; gap: 4px; } -.footer-track .daw-track-details { gap: 2px; } -.footer-track .daw-detail-label { font-size: 10px; width: 58px; } -.footer-track .daw-detail-val { font-size: 10px; } +.footer-track-body .daw-track-meta { flex: 1; min-width: 0; gap: 4px; justify-content: center; } .footer-art { width: 44px; height: 44px; border-radius: 6px; background: var(--panel-3); flex-shrink: 0; overflow: hidden; @@ -2102,11 +2133,8 @@ input, textarea { font-family: inherit; } white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -/* Time display below transport buttons */ -.footer-time-row { - display: flex; align-items: baseline; gap: 4px; - justify-content: center; -} +/* Position group: elapsed / total time */ +.footer-group-position .footer-group-body { align-items: baseline; gap: 4px; } .footer-elapsed { font-size: 17px; font-weight: 700; letter-spacing: -0.03em; color: var(--fg); } @@ -2132,78 +2160,107 @@ input, textarea { font-family: inherit; } .loop-time-input:focus { border-color: var(--accent); } .loop-time-input:disabled { opacity: 0.45; cursor: not-allowed; } -/* Transport buttons */ -/* Three tracks: an empty spacer, the buttons, and the loop-time fields. The - two 1fr tracks take equal shares of the free space, so the middle track -- - and therefore the play button -- lands exactly on the column's centre line, - in line with the tempo slider below it. */ -.footer-transport { - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - gap: 8px; - width: 100%; -} -.footer-transport-spacer { min-width: 0; } -.footer-transport-main { - display: flex; align-items: center; gap: 8px; - justify-self: center; -} -.footer-transport .footer-loop-times { justify-self: end; min-width: 0; } -.footer-transport .daw-iconbtn.btn-transport { +/* Transport group */ +.footer-group-transport .daw-iconbtn.btn-transport { background: var(--panel-2); border-color: var(--border-strong); color: var(--fg-2); transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); } -.footer-transport .daw-iconbtn.btn-transport:hover { +.footer-group-transport .daw-iconbtn.btn-transport:hover { background: var(--panel-3); color: var(--fg); } /* Active transport states: stop = red while stopped, loop = blue while looping. */ -.footer-transport .btn-transport.stopped, -.footer-transport .btn-transport.stopped:hover { +.footer-group-transport .btn-transport.stopped, +.footer-group-transport .btn-transport.stopped:hover { background: rgba(214,90,74,0.16); border-color: rgba(214,90,74,0.55); color: var(--danger); } -.footer-transport .btn-transport.loop.active, -.footer-transport .btn-transport.loop.active:hover { +/* The loop button carries a text label now ("Loop position"), not just an + icon -- widen it from the fixed 30px square every other transport icon + button uses. */ +.footer-group-transport .btn-transport.loop { + width: auto; height: 30px; padding: 0 10px; gap: 6px; +} +.loop-btn-label { font-size: 11px; font-weight: 600; white-space: nowrap; } +.footer-group-transport .btn-transport.loop.active, +.footer-group-transport .btn-transport.loop.active:hover { background: rgba(74,140,255,0.16); border-color: rgba(74,140,255,0.55); color: #4a8cff; } -/* Experimental features pill, centred above the transport. Deliberately set - apart from the transport buttons so it does not read as core playback. */ -.footer-experimental { - position: relative; - display: flex; justify-content: center; - margin-bottom: 2px; +/* Speed group: fixed practice-speed presets, not a continuous dial (#269 + follow-up). */ +.speed-group { display: flex; gap: 4px; } +.speed-btn { + padding: 4px 8px; + background: var(--panel); border: 1px solid var(--border-strong); + border-radius: 6px; color: var(--fg-2); cursor: pointer; + font-family: inherit; font-size: 11px; font-weight: 700; + transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); } -.exp-pill { - display: inline-flex; align-items: center; gap: 7px; - padding: 5px 12px 5px 10px; - background: var(--panel-2); - border: 1px solid var(--border-strong); - border-radius: 8px; - color: var(--fg-2); cursor: pointer; - font-family: inherit; font-size: 10.5px; font-weight: 700; - letter-spacing: 0.09em; text-transform: uppercase; - white-space: nowrap; user-select: none; +.speed-btn:hover { background: var(--panel-3); color: var(--fg); } +.speed-btn.active { + background: rgba(232,200,64,0.16); border-color: rgba(232,200,64,0.5); color: var(--accent); +} + +/* Click track on/off toggle. Lit gold while the click is running, so its + state is visible without any other affordance. */ +.click-toggle-btn { + padding: 4px 12px; + background: var(--panel); border: 1px solid var(--border-strong); + border-radius: 6px; color: var(--fg-2); cursor: pointer; + font-family: inherit; font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em; transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); } -.exp-pill:hover:not(:disabled) { background: var(--panel-3); color: var(--fg); } -.exp-pill:disabled { opacity: 0.4; cursor: not-allowed; } -.exp-flask { flex-shrink: 0; color: #4a8cff; } -.exp-pill:disabled .exp-flask { color: currentColor; } -/* Lit while the click is running, so its state is visible without opening - the panel. */ -.exp-pill.active, -.exp-pill.active:hover { - background: rgba(74,140,255,0.16); - border-color: rgba(74,140,255,0.55); - color: #4a8cff; +.click-toggle-btn:hover:not(:disabled) { background: var(--panel-3); color: var(--fg); } +.click-toggle-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.click-toggle-btn.active, +.click-toggle-btn.active:hover { + background: rgba(232,200,64,0.18); border-color: rgba(232,200,64,0.55); color: var(--accent); +} + +/* Count-in toggle switch (replaces a plain checkbox -- this one and the + click on/off above are the two states a player checks at a glance). */ +.metro-switch { + display: flex; align-items: center; gap: 6px; + cursor: pointer; user-select: none; white-space: nowrap; + font-size: 10.5px; font-weight: 600; color: var(--fg-2); +} +.metro-switch input[type="checkbox"] { + position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; +} +.metro-switch-track { + position: relative; flex-shrink: 0; + width: 28px; height: 16px; border-radius: 999px; + background: var(--panel); border: 1px solid var(--border-strong); + transition: background var(--t-fast), border-color var(--t-fast); +} +.metro-switch-track::after { + content: ""; position: absolute; top: 1px; left: 1px; + width: 12px; height: 12px; border-radius: 50%; + background: var(--fg-2); + transition: transform var(--t-fast), background var(--t-fast); +} +.metro-switch input:checked + .metro-switch-track { + background: rgba(232,200,64,0.22); border-color: var(--accent); +} +.metro-switch input:checked + .metro-switch-track::after { + transform: translateX(12px); + background: var(--accent); +} +.metro-switch input:focus-visible + .metro-switch-track { + outline: 2px solid var(--accent); outline-offset: 2px; +} + +/* Screen-reader-only: content still reaches assistive tech and the native + tooltip chain without taking visible space (the volume % readout). */ +.sr-only { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } /* Beat grid editor: canvas overlay + toolbar */ @@ -2254,40 +2311,50 @@ input, textarea { font-family: inherit; } .bg-spacer { flex: 1; min-width: 8px; } .bg-hint { font-size: 10.5px; color: var(--muted); flex-basis: 100%; } -/* Click track: toggle button + its options popover */ -.footer-metro-wrap { position: relative; display: flex; align-items: center; } +/* Click track options: inline, always visible in the gap between the + transport controls and Export Mix once a track has a beat grid (#269 + follow-up -- no popover, no click to reveal them). Everything is sized to + fit that narrow gap and wraps onto more than one line there; .daw-footer + is min-height (not a fixed height) so wrapping never clips. */ .footer-metro-panel { - position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); - z-index: 60; min-width: 210px; - display: flex; flex-direction: column; gap: 9px; - padding: 11px 12px; - background: var(--panel-2); border: 1px solid var(--border-strong); - border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.38); + /* display:contents removes this element's own box and promotes its + children (the volume row, accent select, rate group, count-in, grid + button, note) to be direct flex items of .footer-experimental, as peers + of the pill -- one flat wrap group the browser can line-break wherever + the available width actually allows, instead of the pill being forced + alone onto its own row and everything else confined to a second, + artificially narrow one (#269 follow-up). .hidden below still works: it + carries !important, which overrides this when the panel has no grid to + show options for. */ + display: contents; } .footer-metro-panel.hidden { display: none; } -.metro-row { display: flex; align-items: center; gap: 9px; } +.metro-row { display: flex; align-items: center; gap: 5px; } +.metro-vol-icon { color: var(--muted); flex-shrink: 0; } .metro-label { - font-size: 10px; font-weight: 700; letter-spacing: 0.08em; - color: var(--muted); flex-shrink: 0; width: 52px; + font-size: 9.5px; font-weight: 700; letter-spacing: 0.06em; + color: var(--muted); flex-shrink: 0; } -.metro-row input[type="range"] { flex: 1; min-width: 0; accent-color: var(--accent); } +.metro-row input[type="range"] { width: 48px; accent-color: var(--accent); } .metro-val { - font-size: 11px; color: var(--fg-2); width: 4ch; text-align: right; + font-size: 10px; color: var(--fg-2); width: 3ch; text-align: right; font-variant-numeric: tabular-nums; flex-shrink: 0; } .metro-select { - flex: 1; padding: 3px 6px; + /* Wide enough for "Auto (none found)", the longest option text -- it was + clipping mid-word at 108px. */ + padding: 2px 4px; max-width: 132px; background: var(--panel); border: 1px solid var(--border-strong); border-radius: 5px; color: var(--fg); - font-family: inherit; font-size: 12px; font-weight: 600; outline: none; + font-family: inherit; font-size: 10px; font-weight: 600; outline: none; } .metro-select:focus { border-color: var(--accent); } -.metro-mult { display: flex; flex: 1; gap: 4px; } +.metro-mult { display: flex; gap: 3px; } .metro-mult-btn { - flex: 1; padding: 3px 0; + padding: 2px 5px; background: var(--panel); border: 1px solid var(--border-strong); border-radius: 5px; color: var(--fg-2); cursor: pointer; - font-family: inherit; font-size: 11px; font-weight: 700; + font-family: inherit; font-size: 9.5px; font-weight: 700; transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); } .metro-mult-btn:hover { background: var(--panel-3); color: var(--fg); } @@ -2296,61 +2363,27 @@ input, textarea { font-family: inherit; } } .metro-edit-btn { - padding: 4px 8px; + padding: 2px 7px; background: var(--panel); border: 1px solid var(--border-strong); border-radius: 5px; color: var(--fg-2); cursor: pointer; - font-family: inherit; font-size: 11px; font-weight: 600; + font-family: inherit; font-size: 9.5px; font-weight: 600; transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); } .metro-edit-btn:hover { background: var(--panel-3); color: var(--fg); } .metro-edit-btn:disabled { opacity: 0.4; cursor: not-allowed; } -/* Detection confidence / unavailability note. Hidden until it has text so the - panel does not carry an empty row in the normal case. */ -.metro-note { font-size: 10.5px; line-height: 1.35; color: var(--muted); } +/* Detection confidence / unavailability note: its own full-width row at the + bottom of the footer (a direct child of .footer-content, below the control + groups -- #269 follow-up), hidden until it has text, clipped to one line + with the full text as a native tooltip. */ +.metro-note { + font-size: 10px; line-height: 1.3; color: var(--muted); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} .metro-note:empty { display: none; } .metro-note.warn { color: var(--danger); } -/* Tempo bar */ -.tempo-bar { - display: flex; align-items: center; gap: 12px; - width: 100%; padding: 8px 4px 0; - border-top: 1px solid var(--border-strong); - margin-top: 6px; cursor: default; user-select: none; -} -.tempo-bar-label { - font-size: 10px; font-weight: 700; letter-spacing: 0.08em; - text-transform: uppercase; color: var(--fg-2); flex-shrink: 0; - white-space: nowrap; -} -.tempo-bar-divider { - width: 1px; height: 16px; background: var(--border-strong); flex-shrink: 0; -} -.tempo-bar-val { - font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums; - color: var(--fg-2); white-space: nowrap; flex-shrink: 0; min-width: 34px; - text-align: right; -} -#t-speed { - -webkit-appearance: none; appearance: none; flex: 1; - height: 4px; border-radius: 999px; outline: none; cursor: pointer; - background: linear-gradient( - 90deg, - var(--gold) 0 var(--speed-pct, 50%), - rgba(148,163,184,0.18) var(--speed-pct, 50%) 100% - ); -} -#t-speed::-webkit-slider-thumb { - -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; - background: var(--gold-bright); cursor: pointer; - box-shadow: 0 2px 8px rgba(216,168,74,0.35); -} -#t-speed::-moz-range-thumb { - width: 16px; height: 16px; border-radius: 50%; border: none; - background: var(--gold-bright); cursor: pointer; -} - -/* Speed + export chip buttons */ +/* Export chip button */ .footer-chip-wrap { position: relative; flex-shrink: 0; display: flex; align-items: center; diff --git a/static/index.html b/static/index.html index 09a9ca8..528f018 100644 --- a/static/index.html +++ b/static/index.html @@ -529,7 +529,6 @@ - @@ -538,135 +537,50 @@