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..491e78b 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; @@ -1540,7 +1536,7 @@ input, textarea { font-family: inherit; } border-bottom: 1px solid var(--border); } .daw-wave-label { - width: 300px; + width: var(--daw-col-w); flex-shrink: 0; background: var(--bg-2); border-right: 1px solid var(--border); @@ -1585,9 +1581,9 @@ input, textarea { font-family: inherit; } overflow: hidden; } -/* ── Stems panel (left 300px) ── */ +/* ── Stems panel (the studio's left column) ── */ .daw-stems-panel { - width: 300px; + width: var(--daw-col-w); flex-shrink: 0; border-right: 1px solid var(--border); display: flex; @@ -1925,12 +1921,20 @@ input, textarea { font-family: inherit; } /* ── Transport footer ── */ .daw-footer { - height: 200px; + /* min-height, not height: the click-track cluster can wrap onto more than + one line in a narrow window, and a rigid height would clip it. */ + min-height: 200px; flex-shrink: 0; border-top: 1px solid var(--border); background: var(--bg-2); + /* Two columns on the studio's own grid: the track identity sits under the + stems/mixer panel and carries its width, everything time-related sits + under the lanes and starts exactly where the lane waveforms start, so a + position reads at the same x in both. The top border still spans the full + width -- that edge belongs to the footer/lanes boundary, not the grid. */ display: flex !important; - flex-direction: column; + flex-direction: row; + align-items: stretch; } .daw-footer-left { @@ -2013,31 +2017,140 @@ input, textarea { font-family: inherit; } /* ── Footer redesign ── */ -/* ── Footer layout ── */ -.footer-content { - display: flex; flex-direction: row; align-items: center; - padding: 0 20px; flex: 1; min-height: 0; - justify-content: space-between; +/* ── Footer layout: three tiers (design 1b) ── */ +/* Tier 1 -- what is loaded and what leaves the app (track identity, favourite, + Export Mix). Tier 2 -- every playback and click control, in labelled clusters + separated by hairline dividers. Tier 3 -- the timeline those clusters act on. + Each tier owns its own full-width budget instead of everything competing for + room in one line. */ + +.footer-main { + flex: 1; min-width: 0; + display: flex; flex-direction: column; } -.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-chips { - flex: 1; - display: flex; align-items: center; justify-content: flex-end; gap: 8px; + +/* ── Left column: track identity ── */ +/* Padded like the mixer rows above it (14px), so title, stem names and the + "Mixer" heading all share one left edge down the column. */ +.footer-track { + width: var(--daw-col-w); flex: none; + box-sizing: border-box; + padding: 12px 14px 14px; + /* Centred, not top-aligned: the identity block is shorter than the control + column beside it, and centring splits the leftover height instead of + leaving it all as a void under the buttons. Stays balanced when the + controls wrap to another line and the footer grows. */ + display: flex; flex-direction: column; justify-content: center; gap: 8px; + /* No overflow clipping: the export menu is absolutely positioned inside this + column and opens upward past its top edge. The title clamps itself and the + meta line has its own overflow rule, so nothing here needs clipping. */ + min-width: 0; +} +.footer-track-head { + display: flex; align-items: flex-start; gap: 10px; min-width: 0; +} +.footer-row-actions { + display: flex; align-items: center; gap: 8px; + margin-top: 2px; +} +/* Boxed like the Export button beside it: both are actions on the track, and + a bare icon next to a filled button reads as decoration. */ +.footer-row-actions .daw-fav-btn { + width: 32px; height: 32px; padding: 0; + justify-content: center; + background: var(--panel-2); border: 1px solid var(--border-strong); + border-radius: 8px; + transition: background var(--t-fast), color var(--t-fast); +} +.footer-row-actions .daw-fav-btn:hover { background: var(--panel-3); } +.footer-row-actions .footer-chip { height: 32px; } + +/* Hairline cluster separator: fades out at both ends so it reads as a seam + between groups rather than a hard rule across the footer. */ +.footer-divider { + width: 1px; flex: none; + background: linear-gradient( + 180deg, transparent, var(--border-strong) 22%, var(--border-strong) 78%, transparent + ); +} +.footer-row-controls .footer-divider { height: 44px; } +/* Stranded at the end of a wrapped line, separating nothing (marked by + syncFooterDividers). Hidden rather than removed: taking its box out would + change the very wrap that was measured. */ +.footer-divider.is-orphan { visibility: hidden; } + +.footer-row-controls { + display: flex; align-items: flex-end; flex-wrap: wrap; + gap: 10px 16px; + padding: 12px 18px 14px 0; +} +.footer-group { + display: flex; flex-direction: column; gap: 6px; min-width: 0; } -/* Wave bar at the bottom of the footer */ +.footer-group-label { + display: flex; align-items: center; gap: 7px; + font-size: 10px; font-weight: 600; letter-spacing: 0.11em; text-transform: uppercase; + color: var(--muted); +} +/* One control height across every cluster, so the row reads as a single band + of controls rather than a ragged stack. */ +.footer-group-body { + display: flex; align-items: center; flex-wrap: wrap; gap: 8px; + min-height: 34px; +} +/* Click track carries the most controls -- a wide basis so it either shares + the line with the other three clusters or takes a line of its own, rather + than squeezing into a leftover gap and wrapping into a ragged block. */ +.footer-group-click { flex: 1 1 520px; } + +.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; +} + +/* Tier 3: the timeline. Full-bleed rather than an inset rounded panel -- its + left edge has to land exactly on the lane waveforms' left edge, and a side + border would offset the canvas by its own width. Top and bottom rules only, + the way the ruler area above the lanes is drawn. */ +.footer-wave-region { + padding: 0 0 14px; flex-shrink: 0; +} +.footer-wave-panel { + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + /* Closes the strip's open left edge and lands on the same x as the mixer + panel's right border, so that seam runs on down the page. Drawn as an + outset shadow rather than a border: a border would sit inside the box and + push the canvas a pixel off the lane waveforms it has to line up with. */ + box-shadow: -1px 0 0 var(--border); + background: var(--panel); + overflow: hidden; +} +/* Ticks at the same times as the lanes ruler (buildFooterWaveTicks shares its + step), positioned by percentage so both rulers mark a time at the same x. */ +.footer-wave-ticks { + height: 18px; position: relative; +} +/* No track, no timeline to label -- an empty ruler strip above the placeholder + waveform would be measuring nothing. */ +.footer-wave-ticks:empty { display: none; } +.footer-wave-ticks .tick { + position: absolute; top: 0; bottom: 0; + border-left: 1px solid var(--border-strong); + padding-left: 4px; + display: flex; align-items: flex-start; + pointer-events: none; +} +.footer-wave-ticks .tick-label { + font-family: var(--font-mono); font-size: 9px; line-height: 1.6; + color: var(--muted-2); + font-variant-numeric: tabular-nums; +} .footer-wave-bar { - height: 40px; flex-shrink: 0; + height: 52px; flex-shrink: 0; position: relative; overflow: hidden; } #footer-waveform { @@ -2051,33 +2164,30 @@ input, textarea { font-family: inherit; } } .footer-scrub-fill { display: none; } -/* Track info (art + title + meta) */ -.footer-track { - flex: 1; min-width: 0; 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-info { + flex: 1; min-width: 0; + display: flex; flex-direction: column; gap: 3px; } -.footer-track-title-row .daw-fav-btn { margin-left: 0; } +/* Two lines, then clipped: a column this narrow would otherwise cut most + titles after three or four words. */ .footer-track-title { - min-width: 0; flex-shrink: 1; - font-size: 14px; font-weight: 600; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + min-width: 0; + font-size: 13px; font-weight: 600; line-height: 1.35; + /* Overrides the nowrap/ellipsis on .daw-track-title, which is written for + single-line contexts. */ + white-space: normal; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + overflow: hidden; overflow-wrap: anywhere; color: var(--fg); } -.footer-track-body { - display: flex; align-items: flex-start; gap: 10px; min-width: 0; -} .footer-cover { - width: 56px !important; height: 56px !important; + width: 40px !important; height: 40px !important; + border-radius: 7px; 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; } +/* Wraps freely here -- the column is narrow and the whole block is stacked, + so the meta reads as a short paragraph rather than a clipped line. */ +.footer-track .daw-track-meta-line { row-gap: 3px; line-height: 1.45; } .footer-art { width: 44px; height: 44px; border-radius: 6px; background: var(--panel-3); flex-shrink: 0; overflow: hidden; @@ -2102,18 +2212,16 @@ 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, then the loop controls that act on + that position (design 1b groups them together, not with the transport). */ +.footer-group-position .footer-group-body { gap: 4px; } .footer-elapsed { - font-size: 17px; font-weight: 700; letter-spacing: -0.03em; color: var(--fg); + font-size: 18px; font-weight: 500; letter-spacing: -0.02em; color: var(--fg); } .footer-total { - font-size: 13px; font-weight: 400; color: var(--muted); + font-size: 12px; font-weight: 400; color: var(--muted-2); } -.footer-time-sep { font-size: 13px; color: var(--muted); } +.footer-time-sep { font-size: 12px; color: var(--muted-2); } /* Exact loop start/end inputs (inline, right of the loop button) */ .footer-loop-times { @@ -2132,79 +2240,100 @@ 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: stop square + a play key wide enough to be the obvious + primary, both on the shared 34px control height. */ +.footer-group-transport .daw-iconbtn.btn-transport { + width: 34px; height: 34px; border-radius: 8px; 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); } +.footer-group-transport .daw-play-btn { + width: 44px; height: 34px; border-radius: 8px; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.18); +} +.footer-group-transport .daw-play-btn:hover { + box-shadow: 0 2px 12px rgba(244,183,64,0.35), inset 0 1px 0 rgba(255,255,255,0.22); +} +.footer-group-transport .daw-play-btn.playing:hover { + box-shadow: 0 2px 12px rgba(76,175,125,0.45), inset 0 1px 0 rgba(255,255,255,0.24); +} /* 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 { +/* Loop sits in the Position group and reads as a modifier on the readout next + to it, so it is a short labelled pill rather than a full-height control. */ +.footer-group-position .daw-iconbtn.btn-transport.loop { + width: auto; height: 26px; padding: 0 9px; gap: 6px; margin-left: 6px; + border-radius: 7px; + background: var(--panel-2); border: 1px solid var(--border-strong); + color: var(--fg-2); + transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); +} +.footer-group-position .btn-transport.loop:hover { + background: var(--panel-3); color: var(--fg); +} +.loop-btn-label { font-size: 11px; font-weight: 500; white-space: nowrap; } +.footer-group-position .btn-transport.loop.active, +.footer-group-position .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; +/* ── Segmented controls (Speed, click rate) ── */ +/* One joined track rather than separate pills: the options are exclusive, and + butting them together says so (design 1b). */ +.footer-seg { + display: flex; height: 34px; overflow: hidden; + background: var(--panel); border: 1px solid var(--border-strong); + border-radius: 8px; } -.exp-pill { +.footer-seg > button { + border: 0; background: transparent; + padding: 0 11px; cursor: pointer; + color: var(--muted); + font-family: var(--font-mono); font-size: 11px; font-weight: 500; line-height: 1; + white-space: nowrap; + transition: background var(--t-fast), color var(--t-fast); +} +.footer-seg > button:hover { background: var(--panel-3); color: var(--fg); } +.footer-seg > button + button { border-left: 1px solid var(--border); } +.speed-btn.active, +.speed-btn.active:hover { + background: rgba(232,200,64,0.16); color: var(--accent); +} +.metro-mult-btn.active, +.metro-mult-btn.active:hover { + background: rgba(74,140,255,0.16); color: #4a8cff; +} + +/* Click track on/off toggle. Lit gold while the click is running, so its + state is visible without any other affordance. */ +.click-toggle-btn { 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; + height: 34px; padding: 0 10px; + background: var(--panel); border: 1px solid var(--border-strong); + border-radius: 8px; color: var(--fg-2); cursor: pointer; + font-family: inherit; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; 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); } +.click-metro-icon { flex-shrink: 0; } /* Beat grid editor: canvas overlay + toolbar */ .beatgrid-canvas { @@ -2254,103 +2383,68 @@ 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 in the Click track cluster, always visible once + a track has a beat grid (#269 follow-up -- no popover, no click to reveal + them). Everything shares the 34px control height of the clusters beside it; + .daw-footer is min-height (not a fixed height) so a wrap 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 count-in switch, accent select, grid button, rate segment, + volume) to be direct flex items of .footer-group-body, as peers of the + on/off 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-vol-row { width: 140px; } +.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-val { - font-size: 11px; color: var(--fg-2); width: 4ch; text-align: right; + font-family: var(--font-mono); font-size: 11px; color: var(--muted); + width: 4ch; text-align: right; font-variant-numeric: tabular-nums; flex-shrink: 0; } +/* Native select, restyled to the same pill as the buttons around it: the + caret is drawn by the wrapper so no appearance-dependent arrow shows. */ +.metro-select-wrap { position: relative; display: inline-flex; } +.metro-select-wrap::after { + content: ""; position: absolute; right: 10px; top: 50%; + margin-top: -2px; pointer-events: none; + border-left: 4px solid transparent; border-right: 4px solid transparent; + border-top: 5px solid var(--muted); +} .metro-select { - flex: 1; padding: 3px 6px; + appearance: none; -webkit-appearance: none; + height: 34px; padding: 0 26px 0 10px; max-width: 168px; 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; + border-radius: 8px; color: var(--fg); cursor: pointer; + font-family: inherit; font-size: 12px; font-weight: 500; outline: none; } +.metro-select:hover { background: var(--panel-3); } .metro-select:focus { border-color: var(--accent); } -.metro-mult { display: flex; flex: 1; gap: 4px; } -.metro-mult-btn { - flex: 1; padding: 3px 0; - 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; - 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); } -.metro-mult-btn.active { - background: rgba(74,140,255,0.16); border-color: rgba(74,140,255,0.55); color: #4a8cff; -} -.metro-edit-btn { - padding: 4px 8px; - 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; - transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); +/* Detection confidence / unavailability note: its own line under the + waveform, where it reads as a caption on the timeline it describes (design + 1b). Hidden until it has text, clipped to one line with the full text as a + native tooltip. */ +.metro-note { + margin-top: 8px; padding-right: 18px; + font-size: 11px; line-height: 1.3; color: var(--muted); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.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); } .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; @@ -2375,7 +2469,10 @@ input, textarea { font-family: inherit; } } .footer-chip-panel { - position: absolute; bottom: calc(100% + 6px); right: 0; z-index: 30; + /* Opens up and to the right: the button now lives in the left column, and a + right-aligned panel would hang past the footer's left edge over the + sidebar. */ + position: absolute; bottom: calc(100% + 6px); left: 0; z-index: 30; background: var(--panel-2); border: 1px solid var(--border-strong); border-radius: 10px; padding: 4px; min-width: 120px; diff --git a/static/css/variables.css b/static/css/variables.css index a6f2366..c36a4f2 100644 --- a/static/css/variables.css +++ b/static/css/variables.css @@ -49,6 +49,10 @@ /* Layout */ --header-w: 300px; + /* Width of the studio's left column: the stems/mixer panel, the label cell + above it, and the footer's blank gutter. One value, so the footer's + waveform strip stays aligned with the lane waveforms above it. */ + --daw-col-w: 300px; --lane-h: 86px; --radius: 10px; --radius-sm: 8px; diff --git a/static/index.html b/static/index.html index 09a9ca8..b43b3bb 100644 --- a/static/index.html +++ b/static/index.html @@ -529,25 +529,16 @@ - diff --git a/static/js/audioEngine.js b/static/js/audioEngine.js index a8b52f3..2be4782 100644 --- a/static/js/audioEngine.js +++ b/static/js/audioEngine.js @@ -109,7 +109,18 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) { return tracks.size > 0; })(); - const now = () => (playing ? (ctx.currentTime - startCtxTime) * _playbackRate + startOffset : startOffset); + // Clamped so the reported playhead never reads before the start position. + // During a count-in the sources are scheduled to begin in the future + // (startCtxTime > ctx.currentTime), which would otherwise make this go + // negative -- the playhead must sit still at the start until the audio enters. + // A no-op for a normal start, where startCtxTime == the moment play() ran. + const now = () => + playing ? Math.max(startOffset, (ctx.currentTime - startCtxTime) * _playbackRate + startOffset) : startOffset; + + // Extra headroom folded into a count-in's lead so every count click lands + // safely in the future even after the small gap between scheduling the + // sources and handing the clicks to the audio clock. + const COUNT_IN_MARGIN = 0.06; function stopSources() { for (const t of tracks.values()) { @@ -121,8 +132,7 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) { } } - function startSources(offset) { - const when = ctx.currentTime; + function startSources(offset, when = ctx.currentTime) { for (const t of tracks.values()) { const src = ctx.createBufferSource(); src.buffer = t.buffer; @@ -174,13 +184,20 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) { rafId = requestAnimationFrame(tick); } - function play() { + // `leadIn` (source seconds, default 0) delays the moment the stems begin so a + // count-in can sound in the gap first. The sources are scheduled at a future + // ctx time; the count-in clicks (negative source time) map into `[now, when]` + // through the same sourceTimeToCtxTime the metronome uses, so they stay locked + // to the audio. See transport.togglePlayPause + metronome.playCountIn. + function play(leadIn = 0) { if (playing || destroyed || !tracks.size) return; // Safari: resume the context fire-and-forget within the user-gesture tick. if (ctx.state === "suspended") ctx.resume().catch(() => {}); let off = startOffset; if (off >= duration) off = 0; - startSources(off); + const lead = Math.max(0, leadIn); + const when = ctx.currentTime + (lead > 0 ? (lead + COUNT_IN_MARGIN) / srcRate() : 0); + startSources(off, when); playing = true; rafId = requestAnimationFrame(tick); } @@ -233,6 +250,9 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) { seek, setTime: seek, // alias to match the multitrack interface used by transport.js isPlaying: () => playing, + // This engine honours play(leadIn) for a count-in; the streaming/chunked + // paths do not, so the transport checks this before scheduling one. + supportsCountIn: true, getCurrentTime: now, getDuration: () => duration, setLoop: (enabled, start, end) => { loop = { enabled, start, end }; }, diff --git a/static/js/beatgridUi.js b/static/js/beatgridUi.js index ae1de55..da6ab59 100644 --- a/static/js/beatgridUi.js +++ b/static/js/beatgridUi.js @@ -5,7 +5,7 @@ import { bgToolbar, bgUndoBtn, bgRedoBtn, bgResetBtn, bgDoneBtn, - bgRippleEl, bgSnapEl, bgBarLenEl, bgHintEl, metronome, metroEditBtn, metroPanel, + bgRippleEl, bgSnapEl, bgBarLenEl, bgHintEl, metronome, metroEditBtn, } from "./state.js"; import { setBeatGridEditing, isBeatGridEditing, setBeatGridTool, getBeatGridTool, @@ -58,6 +58,13 @@ export function toggleBeatGridEditor(force) { if (next && !_available) return; setBeatGridEditing(next); bgToolbar.classList.toggle("hidden", !next); + // The Grid button is a press-to-open toggle like the click and count-in + // buttons beside it, so its lit state is synced here -- the one place every + // open and close runs through, including Done, Escape and losing the grid. + if (metroEditBtn) { + metroEditBtn.classList.toggle("active", next); + metroEditBtn.setAttribute("aria-pressed", next ? "true" : "false"); + } if (next) { _syncTools(); _syncBarLen(); @@ -93,10 +100,7 @@ export function wireBeatGridUi() { } }); - metroEditBtn?.addEventListener("click", () => { - metroPanel?.classList.add("hidden"); - toggleBeatGridEditor(true); - }); + metroEditBtn?.addEventListener("click", () => toggleBeatGridEditor()); bgUndoBtn?.addEventListener("click", () => { undoBeatGrid(); syncBeatGridButtons(); }); bgRedoBtn?.addEventListener("click", () => { redoBeatGrid(); syncBeatGridButtons(); }); diff --git a/static/js/catalog.js b/static/js/catalog.js index d0f1e96..8f3828f 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -350,8 +350,11 @@ function stateMetadataToTrack(state, fallbackTrack) { function fmtExtracted(ts) { if (!ts) return "—"; + // Short form ("Aug 14, 11:43 AM") -- this now lives in the footer's single + // compact meta line (#269 follow-up rebuild), which has no room for the + // long month name and year the summary panel's date affords. return new Date(ts * 1000).toLocaleString("en-US", { - month: "long", day: "numeric", year: "numeric", + month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }); } @@ -442,6 +445,11 @@ function applyTrackInfoToPanel(track) { const trackSource = document.getElementById("track-source"); const trackQuality = document.getElementById("track-quality"); const favBtn = document.getElementById("fav-btn"); + // Static duration for the footer's compact meta line -- deliberately not + // #t-time, which live-updates during playback and would duplicate the + // Position group in the transport row below it. + const metaDuration = document.getElementById("t-meta-duration"); + if (metaDuration) metaDuration.textContent = track.duration ? fmtTime(track.duration) : "—"; if (trackExtracted) trackExtracted.textContent = fmtExtracted(track.createdAt); if (trackSource) trackSource.textContent = deriveSource(track.sourceUrl); if (trackQuality) trackQuality.textContent = deriveQuality(track.sourceUrl); diff --git a/static/js/chunkedAudioEngine.js b/static/js/chunkedAudioEngine.js index d766260..919d894 100644 --- a/static/js/chunkedAudioEngine.js +++ b/static/js/chunkedAudioEngine.js @@ -17,6 +17,10 @@ const CHUNK_SEC = 5; // seconds of audio per chunk const LOOKAHEAD_SEC = 12; // schedule next chunk this far ahead of playhead +// Extra headroom folded into a count-in's lead so every count click lands +// safely in the future even after the small gap between scheduling the first +// chunk and handing the clicks to the audio clock. Mirrors audioEngine.js. +const COUNT_IN_MARGIN = 0.06; // First probe covers the common case: a 44-byte canonical header, or one with a // modest LIST/INFO block. Anything larger costs a second round trip rather than @@ -249,7 +253,13 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { function _getCurrentTime() { if (!playing || !_audioStarted) return _startOffset; - return Math.min((ctx.currentTime - _startCtxTime) * _playbackRate + _startOffset, _duration); + // Clamped to >= _startOffset: during a count-in the first chunk is + // scheduled at a future ctx time (_startCtxTime > ctx.currentTime), which + // would otherwise read as negative before the audio actually starts. + return Math.max( + _startOffset, + Math.min((ctx.currentTime - _startCtxTime) * _playbackRate + _startOffset, _duration), + ); } // Rate at which source nodes consume their buffers -- 1.0 whenever SoundTouch @@ -462,20 +472,28 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { // --- public API --- - function play() { + // `leadIn` (source seconds, default 0) delays the moment the stems begin so + // a count-in can sound in the gap first -- see audioEngine.js's play() for + // the full contract. Only takes effect on the common cached-chunk-0 path + // (see the sync/async branch below); by the time count-in can even be + // armed the track has been loaded long enough that chunk 0 is virtually + // always already cached. + function play(leadIn = 0) { if (playing || destroyed) return; if (ctx.state === "suspended") ctx.resume().catch(() => {}); playing = true; const chunkIdx = Math.floor(_startOffset / CHUNK_SEC); const offsetWithin = _startOffset - chunkIdx * CHUNK_SEC; + const countInLead = leadIn > 0 ? leadIn / _srcRate() + COUNT_IN_MARGIN : 0; // `lead` = scheduling safety margin. The cached (sync) path uses 10 ms — // tight enough that loop jumps are near-seamless — while the async path - // keeps 50 ms headroom since a fetch/decode just finished. + // keeps 50 ms headroom since a fetch/decode just finished. A count-in's + // lead overrides either when it asks for more room than that. const startWith = (buffers, lead) => { if (!playing || destroyed) return; - const when = ctx.currentTime + lead; + const when = ctx.currentTime + Math.max(lead, countInLead); _startCtxTime = when; const dur = _scheduleChunk(buffers, when, offsetWithin); _scheduledTo = _startOffset + dur; @@ -592,6 +610,9 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = { seek, setTime: seek, isPlaying: () => playing, + // This engine honours play(leadIn) for a count-in (see play() above); the + // transport checks this before scheduling one. + supportsCountIn: true, getCurrentTime: _getCurrentTime, getDuration: () => _duration, setLoop: (enabled, start, end) => { loop = { enabled, start, end }; }, diff --git a/static/js/main.js b/static/js/main.js index b9d493d..5e56b55 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -6,7 +6,7 @@ import { STEM_NAMES, syncStemNamesFromAPI } from "./constants.js"; import { renderEmptyShell, buildStripStems, downloadCurrentMix, downloadCurrentVideo, downloadAllStemsZip, downloadRegionMix, drawFooterPlaceholder } from "./player.js"; import { wireJobForm, showError } from "./job.js"; import { wireTransportButtons } from "./transport.js"; -import { wireBeatGridUi, toggleBeatGridEditor } from "./beatgridUi.js"; +import { wireBeatGridUi } from "./beatgridUi.js"; import { togglePlayPause, updateLoopRegionVisual, toggleMetronome } from "./transport.js"; import { wireStemListControls, wireMixerToolbar } from "./mixer.js"; import { initCatalog } from "./catalog.js"; @@ -335,6 +335,12 @@ function wireFooterControls() { } // ── Close panels on outside click ── + // Inside the menu is not "away": ticking an option must not dismiss it. The + // export panel carries two checkboxes (click track, count-in) that a user + // may well want both of, and without this the first tick closed the menu and + // the second needed it reopened. Rows that *should* close the menu do it + // themselves -- the export actions via enterBusy() -> closePanel(). + exportPanel?.addEventListener("click", (e) => e.stopPropagation()); document.addEventListener("click", closeAllChipPanels); } @@ -492,9 +498,6 @@ document.addEventListener("keydown", (e) => { } else if (e.code === "KeyK") { e.preventDefault(); toggleMetronome(); - } else if (e.code === "KeyG") { - e.preventDefault(); - toggleBeatGridEditor(); } else if (e.code === "KeyI" && loopEnabled && multitrack) { e.preventDefault(); setLoopStart(Math.min(multitrack.getCurrentTime(), loopEnd - 0.5)); diff --git a/static/js/metronome.js b/static/js/metronome.js index 7bd2229..8d0addd 100644 --- a/static/js/metronome.js +++ b/static/js/metronome.js @@ -33,6 +33,83 @@ const ACCENT_FREQ = 1500; const CLICK_DECAY = 0.035; const CLICK_ATTACK = 0.001; +// ─── Count-in (issue #269) ─────────────────────────────────────────────── +// +// A count-in is one bar of click *before* playback, leading into the start +// position. The maths below is a literal mirror of count_in_beats() in +// app/pipeline/click_render.py so the live count-in and the exported one agree +// beat-for-beat -- the same parity discipline that pins the click voice across +// the two files. Kept pure and module-level so it can be unit-tested and reused +// by the export URL builder without a live AudioContext. + +function _rescaleForCount(beats, mult) { + if (mult === 2) { + const out = []; + for (let i = 0; i < beats.length - 1; i++) out.push(beats[i], (beats[i] + beats[i + 1]) / 2); + if (beats.length) out.push(beats[beats.length - 1]); + return out; + } + if (mult === 0.5) return beats.filter((_, i) => i % 2 === 0); + return beats.slice(); +} + +function _countInBeatsPerBar(bars, accentMode, startIndex) { + if (accentMode > 0) return accentMode; + // Auto / off: the detected meter in force at the start beat, else 4. A + // count-in always needs a bar length, even with accents switched off. + let mark = null; + for (const b of bars) { + if (Number.isInteger(b.beat) && b.beat <= startIndex) mark = b; + else break; + } + if (mark && Number.isInteger(mark.beats_per_bar) && mark.beats_per_bar >= 1) { + return mark.beats_per_bar; + } + return 4; +} + +function _intervalNear(grid, start, span) { + if (grid.length < 2) return null; + let i = 0; + while (i < grid.length && grid[i] < start) i++; + i = Math.min(i, grid.length - 2); + const diffs = []; + for (let k = i; k < Math.min(i + Math.max(1, span), grid.length - 1); k++) { + const d = grid[k + 1] - grid[k]; + if (d > 0) diffs.push(d); + } + if (!diffs.length) return null; + diffs.sort((a, b) => a - b); + return diffs[diffs.length >> 1]; +} + +/** + * The count-in that leads into playback at `start`. + * @returns {{leadIn:number, clicks:{offset:number, accent:boolean}[]}} + * `leadIn` seconds of pre-roll, and clicks at offsets in `[0, leadIn)`. + */ +export function computeCountIn( + beats, + bars, + { countBars = 1, multiplier = 1, accentMode = -1, start = 0 } = {}, +) { + if (countBars < 1) return { leadIn: 0, clicks: [] }; + const clean = Array.isArray(beats) ? beats.filter((b) => Number.isFinite(b)) : []; + const grid = _rescaleForCount(clean, multiplier); + let startIndex = 0; + for (let k = 0; k < clean.length; k++) { + if (clean[k] <= start) startIndex = k; + else break; + } + const bpb = _countInBeatsPerBar(Array.isArray(bars) ? bars : [], accentMode, startIndex); + const interval = _intervalNear(grid, start, bpb); + if (interval === null) return { leadIn: 0, clicks: [] }; + const n = countBars * bpb; + const clicks = []; + for (let j = 0; j < n; j++) clicks.push({ offset: j * interval, accent: j % bpb === 0 }); + return { leadIn: n * interval, clicks }; +} + /** * @param {object} engine Audio engine exposing sourceTimeToCtxTime, * ctxTimeToSourceTime, getScheduleEpoch, @@ -110,6 +187,12 @@ export function createMetronome(engine, beats, { volume = 0.6, beatsPerBar = 0 } let epoch = -1; /** @type {{osc:OscillatorNode, env:GainNode}[]} */ let queued = []; + // Count-in one-shots are tracked separately from the running click's `queued`: + // they are scheduled outside the _tick loop (which may not even be running when + // the click track is off) and must survive until they sound or the transport + // cancels them. See playCountIn / cancelCountIn. + /** @type {{osc:OscillatorNode, env:GainNode}[]} */ + let countInQueued = []; // First beat at or after `t`. Binary search rather than a scan: tracks run to // thousands of beats and this runs on every seek. @@ -132,8 +215,18 @@ export function createMetronome(engine, beats, { volume = 0.6, beatsPerBar = 0 } queued = []; } - // Schedule one click to sound at AudioContext time `when`. - function _scheduleClick(when, accent) { + function _cancelCountIn() { + for (const { osc, env } of countInQueued) { + try { osc.stop(); } catch { /* already stopped */ } + try { env.disconnect(); } catch { /* noop */ } + } + countInQueued = []; + } + + // Schedule one click to sound at AudioContext time `when`. `sink` is the list + // it registers itself in so the right batch can be torn down independently + // (the running click's `queued`, or the count-in's `countInQueued`). + function _scheduleClick(when, accent, sink = queued) { const osc = ctx.createOscillator(); const env = ctx.createGain(); osc.type = "sine"; @@ -152,11 +245,11 @@ export function createMetronome(engine, beats, { volume = 0.6, beatsPerBar = 0 } osc.stop(when + CLICK_DECAY + 0.01); const entry = { osc, env }; - queued.push(entry); + sink.push(entry); osc.onended = () => { try { env.disconnect(); } catch { /* noop */ } - const i = queued.indexOf(entry); - if (i >= 0) queued.splice(i, 1); + const i = sink.indexOf(entry); + if (i >= 0) sink.splice(i, 1); }; } @@ -233,6 +326,27 @@ export function createMetronome(engine, beats, { volume = 0.6, beatsPerBar = 0 } if (destroyed) return; gain.gain.setTargetAtTime(Math.max(0, v), ctx.currentTime, 0.01); }, + /** + * Play a one-shot count-in: clicks at the given *source* times (typically + * negative -- before the audio), routed through the same voice and bus as + * the running click so they inherit its volume and the SoundTouch path. + * Independent of `enabled`, so a count-in can precede a clean (click-off) + * playback. The engine must already have been told to start late (see + * audioEngine.play(leadIn)) so these map into the silent lead-in gap. + * @param {{time:number, accent:boolean}[]} clicks + */ + playCountIn(clicks) { + if (destroyed || !Array.isArray(clicks) || !clicks.length) return; + _cancelCountIn(); + for (const c of clicks) { + const when = engine.sourceTimeToCtxTime(c.time); + if (when > ctx.currentTime) _scheduleClick(when, !!c.accent, countInQueued); + } + }, + /** Tear down a count-in already handed to the audio clock (pause/stop). */ + cancelCountIn() { + if (!destroyed) _cancelCountIn(); + }, /** 0 disables accents; otherwise accent every Nth beat from the grid start. */ setBeatsPerBar(n) { _beatsPerBar = Number.isFinite(n) && n > 0 ? Math.round(n) : 0; @@ -284,6 +398,7 @@ export function createMetronome(engine, beats, { volume = 0.6, beatsPerBar = 0 } destroy() { destroyed = true; _stop(); + _cancelCountIn(); try { gain.disconnect(); } catch { /* noop */ } }, }; diff --git a/static/js/player.js b/static/js/player.js index 9846f74..8f3eac0 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -23,7 +23,7 @@ import { footerTitle, footerMeta, footerThumb, setFooterWaveDrawFn, metronome, setMetronome, metronomeEnabled, metronomeVolume, metronomeBeatsPerBar, - exportClickEl, exportClickWrap, + exportClickEl, exportClickWrap, exportCountInEl, exportCountInWrap, setMetronomeHasBars, } from "./state.js"; import { createAudioEngine, estimateDecodedBytes } from "./audioEngine.js"; @@ -38,7 +38,7 @@ import { } from "./mixer.js"; import { buildRuler, updatePlayheadMarker, updateLoopRegionVisual, - applyWaveZoom, buildPresenceRuler, updateFooterTimes, + applyWaveZoom, buildPresenceRuler, buildFooterWaveTicks, updateFooterTimes, updatePresencePlayhead, resetSpeed, updateMetronomeAvailability, applyMetronomeAccent, } from "./transport.js"; import { stopVuLoop } from "./audio.js"; @@ -787,6 +787,7 @@ export function destroyPlayer() { setTrackIndex({}); applyWaveZoom(); buildPresenceRuler(0); + buildFooterWaveTicks(0); updateFooterTimes(0); updatePresencePlayhead(0); if (waveScroll) waveScroll.scrollLeft = 0; @@ -1152,6 +1153,7 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti timeEl.textContent = `00:00 / ${fmtTime(totalDuration)}`; buildRuler(totalDuration); buildPresenceRuler(totalDuration); + buildFooterWaveTicks(totalDuration); updateFooterTimes(0); updatePresencePlayhead(0); setMasterVolume(masterFader ? parseFloat(masterFader.value) : masterVolume); @@ -1623,12 +1625,31 @@ function _clickParams(q) { q.set("click_gain", metronomeVolume.toFixed(3)); } -/** Whether this track can export a click at all (needs a beat grid). */ +// Count-in export param (issue #269). Independent of the running click track: +// a clean backing track can still be counted in. One bar of the detected meter, +// prepended ahead of the audio by the backend. Audio exports only -- the MP4 +// video path leaves it off, since prepending it would desync the picture. +function _countInParam(q) { + if (!exportCountInEl?.checked || exportCountInEl.disabled) return; + q.set("count_in", "1"); + // The count-in's tempo/meter follow the same rate and accent the click uses, + // so pass them even when the click itself is not being baked in. + q.set("click_mult", String(metronome?.getMultiplier?.() ?? 1)); + q.set("click_accent", String(metronomeBeatsPerBar)); + q.set("click_gain", metronomeVolume.toFixed(3)); +} + +/** Whether this track can export a click / count-in at all (needs a beat grid). */ export function setExportClickAvailable(on) { - if (!exportClickEl) return; - exportClickEl.disabled = !on; - if (!on) exportClickEl.checked = false; - exportClickWrap?.classList.toggle("disabled", !on); + for (const [el, wrap] of [ + [exportClickEl, exportClickWrap], + [exportCountInEl, exportCountInWrap], + ]) { + if (!el) continue; + el.disabled = !on; + if (!on) el.checked = false; + wrap?.classList.toggle("disabled", !on); + } } // Dynamic mixdown URL for the current mixer state. Returns null (no download) @@ -1646,6 +1667,7 @@ function _mixdownUrl(ext, region) { q.set("end", loopEnd.toFixed(3)); } _clickParams(q); + _countInParam(q); return `/api/jobs/${currentJobId}/mixdown.${ext}?${q}`; } diff --git a/static/js/state.js b/static/js/state.js index 5db67e5..f7f5b24 100644 --- a/static/js/state.js +++ b/static/js/state.js @@ -17,8 +17,7 @@ export const keyChip = $("t-key"); export const stemsChip = $("t-stems-chip"); export const timeEl = $("t-time"); export const masterFader = $("t-master"); -export const speedEl = $("t-speed"); -export const speedLabelEl = $("t-speed-label"); +export const speedBtns = ["t-speed-025", "t-speed-05", "t-speed-1"].map($); export const npArt = $("np-art"); export const npThumb = $("np-thumb"); @@ -43,11 +42,11 @@ export const presenceRulerEl = $("presence-ruler"); export const presencePlayheadEl = $("presence-playhead"); export const footerTimeElapsed = $("footer-time-elapsed"); export const footerTimeTotal = $("footer-time-total"); +export const footerWaveTicks = $("footer-wave-ticks"); export const loopStartInput = $("t-loop-start"); export const loopEndInput = $("t-loop-end"); export const metroBtn = $("t-metro"); export const metroPanel = $("t-metro-panel"); -export const metroWrap = $("t-metro-wrap"); export const metroVolEl = $("t-metro-vol"); export const metroVolLabel = $("t-metro-vol-label"); export const metroBarEl = $("t-metro-bar"); @@ -55,9 +54,12 @@ export const metroNoteEl = $("t-metro-note"); export const metroHalfBtn = $("t-metro-half"); export const metroOneBtn = $("t-metro-one"); export const metroDoubleBtn = $("t-metro-double"); +export const metroCountInEl = $("t-metro-countin"); export const metroEditBtn = $("t-metro-edit"); export const exportClickEl = $("t-export-click"); export const exportClickWrap = $("t-export-click-wrap"); +export const exportCountInEl = $("t-export-count-in"); +export const exportCountInWrap = $("t-export-count-in-wrap"); export const bgToolbar = $("beatgrid-toolbar"); export const bgCanvas = $("beatgrid-canvas"); export const bgUndoBtn = $("bg-undo"); @@ -195,3 +197,7 @@ export function setMetronomeBeatsPerBar(v) { metronomeBeatsPerBar = v; } // them "Auto" has nothing to follow and behaves as no accent. export let metronomeHasBars = false; export function setMetronomeHasBars(v) { metronomeHasBars = !!v; } +// Count me in on play: one bar of click before the audio (issue #269). +// Independent of the running click track above. +export let metronomeCountIn = false; +export function setMetronomeCountIn(v) { metronomeCountIn = !!v; } diff --git a/static/js/transport.js b/static/js/transport.js index 84243f1..a246a1e 100644 --- a/static/js/transport.js +++ b/static/js/transport.js @@ -1,22 +1,24 @@ import { fmtTime, fmtTickLabel, fmtTimeMs, parseTimecode, storeGet, storeSet } from "./utils.js"; import { playBtn, playMiniBtn, stopBtn, loopBtn, timeEl, masterFader, - speedEl, speedLabelEl, + speedBtns, rulerTime, wavesGrid, loopRegionEl, playheadMarker, multitrack, audioEngine, totalDuration, loopEnabled, loopStart, loopEnd, masterVolume, waveScroll, waveCanvas, multitrackContainer, presenceRulerEl, presencePlayheadEl, - footerTimeElapsed, footerTimeTotal, npScrubFill, footerWaveDrawFn, + footerTimeElapsed, footerTimeTotal, footerWaveTicks, npScrubFill, footerWaveDrawFn, loopStartInput, loopEndInput, - metroBtn, metroPanel, metroWrap, metroVolEl, metroVolLabel, metroBarEl, metroNoteEl, - metroHalfBtn, metroOneBtn, metroDoubleBtn, + metroBtn, metroPanel, metroVolEl, metroVolLabel, metroBarEl, metroNoteEl, + metroHalfBtn, metroOneBtn, metroDoubleBtn, metroCountInEl, metronome, metronomeEnabled, metronomeVolume, metronomeBeatsPerBar, metronomeHasBars, + metronomeCountIn, setMetronomeCountIn, setMetronomeHasBars, setMetronomeEnabled, setMetronomeVolume, setMetronomeBeatsPerBar, setLoopEnabled, setLoopStart, setLoopEnd, setMasterVolume, setPlaybackSpeed, } from "./state.js"; import { applyMix } from "./mixer.js"; -import { isDownbeatIndex } from "./beatgrid.js"; +import { isDownbeatIndex, getBeats as getGridBeats, getBars as getGridBars } from "./beatgrid.js"; +import { computeCountIn } from "./metronome.js"; const MIN_LOOP_SEC = 0.2; // Below this visible width the waveform stops compressing to fit and instead @@ -62,6 +64,13 @@ function setPlayheadTime(sec) { updatePresencePlayhead(next); } +// Spacing of the timeline's labelled ticks. Shared by the ruler above the +// lanes and the one on the footer waveform: the two strips are the same width +// and start at the same x, so a time has to land at the same place in both. +function tickStep(durationSec) { + return durationSec < 90 ? 15 : durationSec < 300 ? 30 : 60; +} + export function buildRuler(durationSec) { rulerTime.innerHTML = ""; wavesGrid.innerHTML = ""; @@ -73,7 +82,7 @@ export function buildRuler(durationSec) { rulerTime.appendChild(marker); if (!durationSec || durationSec <= 0) return; - const step = durationSec < 90 ? 15 : durationSec < 300 ? 30 : 60; + const step = tickStep(durationSec); for (let t = 0; t <= durationSec; t += step) { const leftPct = (t / durationSec) * 100; const tick = document.createElement("div"); @@ -117,6 +126,23 @@ export function updateFooterTimes(currentSec) { footerWaveDrawFn?.(pct / 100); } +// Time labels above the footer waveform. Same ticks as the ruler over the +// lanes, positioned the same way (percent of duration), because the footer +// strip is now indented to share that ruler's left edge and width. +export function buildFooterWaveTicks(durationSec) { + if (!footerWaveTicks) return; + footerWaveTicks.innerHTML = ""; + if (!durationSec || durationSec <= 0) return; + const step = tickStep(durationSec); + for (let t = 0; t <= durationSec; t += step) { + const tick = document.createElement("div"); + tick.className = "tick"; + tick.style.left = `${(t / durationSec) * 100}%`; + tick.innerHTML = `${fmtTickLabel(t)}`; + footerWaveTicks.appendChild(tick); + } +} + // Build the presence-panel ruler labels from the actual track duration. // The HTML ships 8 placeholder tags ("0:00 ... 3:38"); we replace // each label's text with a tick at evenly-spaced fractions of the song. @@ -280,12 +306,45 @@ function _playWhenReady() { window.setTimeout(fire, 1500); } +// The live beat grid to count against: the editor's copy when it holds one +// (reflects unsaved drags), else the grid last handed to the metronome UI. +function _currentGrid() { + const edited = getGridBeats?.() ?? []; + if (edited.length) return { beats: edited, bars: getGridBars?.() ?? [] }; + if (_lastGrid?.beats?.length) return { beats: _lastGrid.beats, bars: _lastGrid.bars ?? [] }; + return null; +} + +// Arm a count-in when it is enabled and the engine + grid can support one. +// Starts the audio late (engine.play(leadIn)) and schedules the count clicks in +// the gap, whether or not the running click is on. Returns true when it took +// over starting playback, so the caller does not also start it immediately. +function _armCountIn(eng, startPos) { + if (!metronomeCountIn || !eng?.supportsCountIn || !metronome) return false; + const grid = _currentGrid(); + if (!grid) return false; + const { leadIn, clicks } = computeCountIn(grid.beats, grid.bars, { + countBars: 1, + multiplier: metronome.getMultiplier?.() ?? 1, + accentMode: metronomeBeatsPerBar, + start: startPos, + }); + if (leadIn <= 0 || !clicks.length) return false; + // Clicks sit in source time, leading into the start position: the last lands + // one beat before the audio, so the song enters on the next downbeat. + const sourceClicks = clicks.map((c) => ({ time: startPos - leadIn + c.offset, accent: c.accent })); + eng.play(leadIn); // sets the (future) clock the clicks are scheduled against + metronome.playCountIn(sourceClicks); + return true; +} + export function togglePlayPause() { const eng = audioEngine; const tx = eng ?? multitrack; if (!tx) return; if (tx.isPlaying()) { tx.pause(); + metronome?.cancelCountIn?.(); // drop a count-in if paused before the audio enters // The engine emits no play/pause events (the multitrack stays silent), so // the play-button visual that the ws "pause" handler normally toggles must // be driven here directly. @@ -304,7 +363,11 @@ export function togglePlayPause() { tx.setTime(loopStart); } if (eng) { - eng.play(); + // Match the engine's own end-of-track reset so the count-in leads into the + // same position playback will actually start from. + let startPos = eng.getCurrentTime?.() ?? 0; + if (totalDuration > 0 && startPos >= totalDuration) startPos = 0; + if (!_armCountIn(eng, startPos)) eng.play(); playBtn.classList.add("playing"); stopBtn.classList.remove("stopped"); } else { @@ -317,6 +380,7 @@ export function stopTransport() { const tx = eng ?? multitrack; if (!tx) return; tx.pause(); + metronome?.cancelCountIn?.(); // a count-in in progress must not outlive Stop tx.setTime(loopEnabled ? loopStart : 0); // engine: setTime → onTime → stop visual if (eng) playBtn.classList.remove("playing"); } @@ -485,6 +549,31 @@ function wireLaneScrollSync() { link(waveScroll, mixer); } +// The control clusters wrap when the window is too narrow to hold them side +// by side, which can leave a divider stranded at the end of a line with +// nothing after it to separate. Mark those so CSS can hide them. +// +// Compares bottom edges, not tops: the row is bottom-aligned, so a divider +// and the cluster beside it share a baseline but start at different heights. +function syncFooterDividers() { + const row = document.querySelector(".footer-row-controls"); + if (!row) return; + const bottom = (el) => Math.round(el.getBoundingClientRect().bottom); + for (const divider of row.querySelectorAll(".footer-divider")) { + const next = divider.nextElementSibling; + divider.classList.toggle("is-orphan", !next || bottom(next) !== bottom(divider)); + } +} + +function wireFooterDividers() { + const row = document.querySelector(".footer-row-controls"); + if (!row) return; + // Observing the row catches both window resizes and the controls changing + // width (a track with a click track has more of them than one without). + new ResizeObserver(syncFooterDividers).observe(row); + syncFooterDividers(); +} + // ─── Wire transport buttons ─── export function wireTransportButtons() { @@ -494,6 +583,7 @@ export function wireTransportButtons() { loopBtn.addEventListener("click", toggleLoop); wireLoopDrag(); wireLoopInputs(); + wireFooterDividers(); wireZoomButtons(); wireLaneScrollSync(); masterFader?.addEventListener("input", () => { @@ -509,16 +599,24 @@ export function wireTransportButtons() { wireMetronomeControl(); } +// Fixed presets, not a continuous dial -- practice speeds for slowing a part +// down, not a general-purpose tempo control (issue #269 follow-up). +const SPEED_PRESETS = [0.25, 0.5, 1]; + function applySpeed(rate) { - const clamped = Math.max(0.25, Math.min(2, rate)); + // Snap to the nearest preset rather than clamping continuously: every + // caller (button click, resetSpeed on track load) already passes one of + // SPEED_PRESETS, but snapping keeps this correct even if that changes. + const clamped = SPEED_PRESETS.reduce((best, p) => + Math.abs(p - rate) < Math.abs(best - rate) ? p : best + ); setPlaybackSpeed(clamped); - if (speedEl) { - speedEl.value = String(clamped); - // range is 0-2; 1.0 sits at exactly 50% - const pct = (clamped / 2) * 100; - speedEl.style.setProperty("--speed-pct", `${pct.toFixed(1)}%`); + for (const btn of speedBtns) { + if (!btn) continue; + const on = parseFloat(btn.dataset.speed) === clamped; + btn.classList.toggle("active", on); + btn.setAttribute("aria-checked", on ? "true" : "false"); } - if (speedLabelEl) speedLabelEl.textContent = `${clamped % 1 === 0 ? clamped.toFixed(1) : clamped}x`; audioEngine?.setPlaybackRate?.(clamped); if (multitrack) { for (const a of (multitrack.audios ?? [])) { @@ -532,14 +630,9 @@ export function resetSpeed() { } function wireSpeedControl() { - if (!speedEl) return; - speedEl.addEventListener("input", () => applySpeed(parseFloat(speedEl.value))); - speedEl.addEventListener("dblclick", () => applySpeed(1.0)); - speedEl.addEventListener("wheel", (e) => { - e.preventDefault(); - const delta = e.deltaY < 0 ? 0.25 : -0.25; - applySpeed(parseFloat(speedEl.value) + delta); - }, { passive: false }); + for (const btn of speedBtns) { + btn?.addEventListener("click", () => applySpeed(parseFloat(btn.dataset.speed))); + } } // ─── Click track ──────────────────────────────────────────── @@ -551,6 +644,7 @@ function _saveMetroPrefs() { enabled: metronomeEnabled, volume: metronomeVolume, beatsPerBar: metronomeBeatsPerBar, + countIn: metronomeCountIn, }).catch((e) => console.warn("[transport] failed to save metronome prefs:", e)); } @@ -573,8 +667,20 @@ export function applyMetronomeAccent() { } function _renderMetroVolume() { - if (metroVolEl) metroVolEl.value = String(metronomeVolume); - if (metroVolLabel) metroVolLabel.textContent = `${Math.round(metronomeVolume * 100)}%`; + const pct = `${Math.round(metronomeVolume * 100)}%`; + if (metroVolEl) { metroVolEl.value = String(metronomeVolume); metroVolEl.title = `Click volume: ${pct}`; } + // Readout sits next to the slider (design 1b): a click level you can only + // learn by hovering is one you cannot match between sessions. + if (metroVolLabel) metroVolLabel.textContent = pct; +} + +// Count-in is a press-to-arm toggle like the click on/off beside it, not a +// switch: both are "is this on for the next play?", and two different widgets +// for the same question read as two different kinds of setting. +function _renderCountIn() { + if (!metroCountInEl) return; + metroCountInEl.classList.toggle("active", metronomeCountIn); + metroCountInEl.setAttribute("aria-pressed", metronomeCountIn ? "true" : "false"); } export function toggleMetronome(force) { @@ -632,9 +738,11 @@ function _renderMetroNote(grid) { } else if (metronomeBeatsPerBar > 0) { parts.push(`accenting every ${metronomeBeatsPerBar} beats`); } - metroNoteEl.textContent = parts.length + const text = parts.length ? `${parts.join(" -- ")}. Use /2 or x2 if the click feels half or double speed.` : ""; + metroNoteEl.textContent = text; + metroNoteEl.title = text; // clipped to one line; the full text is a hover away metroNoteEl.className = Number.isFinite(conf) && conf < 60 ? "metro-note warn" : "metro-note"; } @@ -643,9 +751,7 @@ export function updateMetronomeAvailability(grid, reason = "") { _lastGrid = grid || null; const available = !!(grid && Array.isArray(grid.beats) && grid.beats.length); metroBtn.disabled = !available; - metroBtn.title = available - ? "Click track (K) -- right-click for volume, accent and rate" - : (reason || "Click track unavailable"); + metroBtn.title = available ? "Click track (K)" : (reason || "Click track unavailable"); if (!available) { // Keep the stored preference so the click returns on the next track that @@ -659,6 +765,7 @@ export function updateMetronomeAvailability(grid, reason = "") { metroBtn.classList.toggle("active", metronomeEnabled); metroBtn.setAttribute("aria-pressed", metronomeEnabled ? "true" : "false"); + metroPanel?.classList.remove("hidden"); // undo a previous track's "unavailable" hide setMetronomeHasBars(Array.isArray(grid.bars) && grid.bars.length > 0); const autoOpt = metroBarEl?.querySelector('option[value="-1"]'); if (autoOpt) { @@ -680,26 +787,21 @@ function wireMetronomeControl() { if (typeof prefs.volume === "number") setMetronomeVolume(Math.max(0, Math.min(1, prefs.volume))); if (typeof prefs.beatsPerBar === "number") setMetronomeBeatsPerBar(prefs.beatsPerBar); if (typeof prefs.enabled === "boolean") setMetronomeEnabled(prefs.enabled); + if (typeof prefs.countIn === "boolean") setMetronomeCountIn(prefs.countIn); } _renderMetroVolume(); if (metroBarEl) metroBarEl.value = String(metronomeBeatsPerBar); + _renderCountIn(); if (metronomeEnabled && !metroBtn.disabled) { metroBtn.classList.add("active"); metroBtn.setAttribute("aria-pressed", "true"); } }).catch((e) => console.warn("[transport] failed to load metronome prefs:", e)); - // Click toggles; right-click / long-press opens the options panel. + // Toggles the click on/off; also bound to the K key elsewhere. Volume, + // accent, rate and count-in sit inline next to it, always visible once a + // track has a beat grid -- no click needed to reveal them (#269 follow-up). metroBtn.addEventListener("click", () => toggleMetronome()); - metroBtn.addEventListener("contextmenu", (e) => { - e.preventDefault(); - if (!metroBtn.disabled) metroPanel?.classList.toggle("hidden"); - }); - - document.addEventListener("click", (e) => { - if (!metroPanel || metroPanel.classList.contains("hidden")) return; - if (!metroWrap?.contains(e.target)) metroPanel.classList.add("hidden"); - }); metroVolEl?.addEventListener("input", () => { const v = Math.max(0, Math.min(1, parseFloat(metroVolEl.value))); @@ -717,6 +819,12 @@ function wireMetronomeControl() { }); } + metroCountInEl?.addEventListener("click", () => { + setMetronomeCountIn(!metronomeCountIn); + _renderCountIn(); + _saveMetroPrefs(); + }); + metroBarEl?.addEventListener("change", () => { const raw = parseInt(metroBarEl.value, 10); setMetronomeBeatsPerBar(Number.isFinite(raw) ? raw : -1); diff --git a/tests/js/count-in.test.mjs b/tests/js/count-in.test.mjs new file mode 100644 index 0000000..88b0326 --- /dev/null +++ b/tests/js/count-in.test.mjs @@ -0,0 +1,106 @@ +// Parity + behaviour test for computeCountIn (issue #269). +// +// The live count-in (metronome.js) and the exported one (app/pipeline/ +// click_render.py::count_in_beats) MUST agree beat-for-beat, or a player hears +// one thing while monitoring and gets another in the file. This pins the JS +// side; the Python side is pinned by tests/test_click_render.py. The expected +// values below are the shared spec both implementations are held to -- keep the +// two files in lockstep when either changes. +// +// Run: node tests/js/count-in.test.mjs + +import { computeCountIn } from "../../static/js/metronome.js"; + +let pass = 0, + fail = 0; +const check = (name, cond, detail = "") => { + if (cond) { + pass++; + console.log(`PASS ${name}`); + } else { + fail++; + console.log(`FAIL ${name}${detail ? " -- " + detail : ""}`); + } +}; + +const approx = (a, b, eps = 1e-6) => Math.abs(a - b) < eps; +const shape = (clicks) => clicks.map((c) => [Number(c.offset.toFixed(4)), c.accent]); + +// 120 BPM from 0.5 s -- the same grid the Python parity tests use. +const STEADY = Array.from({ length: 16 }, (_, i) => 0.5 + i * 0.5); + +{ + // PI po po po: one bar of four, downbeat accented, half-second spacing. + const { leadIn, clicks } = computeCountIn(STEADY, [{ beat: 0, beats_per_bar: 4 }]); + check("4/4: lead-in is one bar (2.0 s)", approx(leadIn, 2.0), `got ${leadIn}`); + check( + "4/4: PI po po po", + JSON.stringify(shape(clicks)) === + JSON.stringify([ + [0, true], + [0.5, false], + [1, false], + [1.5, false], + ]), + JSON.stringify(shape(clicks)), + ); +} + +{ + const { leadIn, clicks } = computeCountIn(STEADY, [{ beat: 0, beats_per_bar: 3 }]); + check("3/4: lead-in 1.5 s, three clicks", approx(leadIn, 1.5) && clicks.length === 3); +} + +{ + const { clicks } = computeCountIn(STEADY, [], { accentMode: 4 }); + check("explicit accent sets the bar length", clicks.length === 4); +} + +{ + const { clicks } = computeCountIn(STEADY, [], { accentMode: -1 }); + check("no marks defaults to four", clicks.length === 4); +} + +{ + const { leadIn, clicks } = computeCountIn(STEADY, [{ beat: 0, beats_per_bar: 4 }], { + countBars: 2, + }); + check( + "two bars accents each downbeat", + approx(leadIn, 4.0) && + JSON.stringify(clicks.map((c) => c.accent)) === + JSON.stringify([true, false, false, false, true, false, false, false]), + ); +} + +{ + const { clicks } = computeCountIn(STEADY, [{ beat: 0, beats_per_bar: 4 }], { accentMode: 0 }); + check("accents-off count-in still marks its downbeat", clicks[0].accent === true); +} + +{ + const x2 = computeCountIn(STEADY, [{ beat: 0, beats_per_bar: 4 }], { multiplier: 2 }); + const half = computeCountIn(STEADY, [{ beat: 0, beats_per_bar: 4 }], { multiplier: 0.5 }); + check("x2: one bar of the doubled grid, 1.0 s", x2.clicks.length === 4 && approx(x2.leadIn, 1.0)); + check("half: one bar of the halved grid, 4.0 s", half.clicks.length === 4 && approx(half.leadIn, 4.0)); +} + +{ + // 60 BPM then 150 BPM: the count-in must take the tempo where playback begins. + const varied = [0.0, 1.0, 2.0, 3.0, 3.4, 3.8, 4.2, 4.6]; + const bars = [{ beat: 0, beats_per_bar: 4 }]; + const slow = computeCountIn(varied, bars, { start: 0.0 }); + const fast = computeCountIn(varied, bars, { start: 3.4 }); + check("tempo tracks the start position", approx(slow.leadIn, 4.0) && approx(fast.leadIn, 1.6), + `slow=${slow.leadIn} fast=${fast.leadIn}`); +} + +{ + const empty = computeCountIn([0.5], [{ beat: 0, beats_per_bar: 4 }]); + const disabled = computeCountIn(STEADY, [], { countBars: 0 }); + check("empty when grid too short", empty.leadIn === 0 && empty.clicks.length === 0); + check("empty when disabled", disabled.leadIn === 0 && disabled.clicks.length === 0); +} + +console.log(`\n${pass}/${pass + fail} checks passed`); +process.exit(fail ? 1 : 0); diff --git a/tests/test_click_render.py b/tests/test_click_render.py index 0b8bca3..f665458 100644 --- a/tests/test_click_render.py +++ b/tests/test_click_render.py @@ -15,8 +15,11 @@ ACCENT_OFF, CLICK_FREQ, cache_key, + count_in_beats, + count_in_beats_per_bar, is_downbeat, render_click_wav, + render_count_in_wav, rescale_beats, source_index, ) @@ -131,6 +134,82 @@ def test_accent_ignores_inserted_midpoints(): assert not is_downbeat(source_index(1, 2.0), [{"beat": 0, "beats_per_bar": 4}], ACCENT_AUTO) +# --- count-in -------------------------------------------------------------- + + +_STEADY = [0.5 + i * 0.5 for i in range(16)] # 120 BPM from 0.5 s + + +def test_count_in_one_bar_of_four(): + """PI po po po: four clicks, accent on the downbeat, ending one beat before + the audio so the song enters on the next downbeat.""" + lead_in, clicks = count_in_beats(_STEADY, [{"beat": 0, "beats_per_bar": 4}]) + assert lead_in == pytest.approx(2.0) + assert [a for _, a in clicks] == [True, False, False, False] + assert [round(o, 3) for o, _ in clicks] == [0.0, 0.5, 1.0, 1.5] + + +def test_count_in_follows_detected_meter(): + lead_in, clicks = count_in_beats(_STEADY, [{"beat": 0, "beats_per_bar": 3}]) + assert lead_in == pytest.approx(1.5) + assert [a for _, a in clicks] == [True, False, False] + + +def test_count_in_explicit_accent_sets_bar_length(): + _, clicks = count_in_beats(_STEADY, [], accent_mode=4) + assert len(clicks) == 4 + + +def test_count_in_defaults_to_four_without_marks(): + _, clicks = count_in_beats(_STEADY, [], accent_mode=ACCENT_AUTO) + assert len(clicks) == 4 + + +def test_count_in_two_bars_accents_each_downbeat(): + lead_in, clicks = count_in_beats(_STEADY, [{"beat": 0, "beats_per_bar": 4}], count_bars=2) + assert lead_in == pytest.approx(4.0) + assert [a for _, a in clicks] == [True, False, False, False, True, False, False, False] + + +def test_count_in_still_marks_the_downbeat_when_click_accents_are_off(): + """A count-in without a '1' is useless, so it accents its downbeat even when + the running click has accents switched off.""" + _, clicks = count_in_beats(_STEADY, [{"beat": 0, "beats_per_bar": 4}], accent_mode=ACCENT_OFF) + assert clicks[0][1] is True + + +def test_count_in_follows_the_rate_multiplier(): + """x2 corrects a half-time grid, so one bar is bpb clicks of the doubled + grid -- denser and shorter in wall time, still one musical bar.""" + x2_lead, x2 = count_in_beats(_STEADY, [{"beat": 0, "beats_per_bar": 4}], multiplier=2.0) + half_lead, half = count_in_beats(_STEADY, [{"beat": 0, "beats_per_bar": 4}], multiplier=0.5) + assert len(x2) == 4 and x2_lead == pytest.approx(1.0) + assert len(half) == 4 and half_lead == pytest.approx(4.0) + + +def test_count_in_empty_when_grid_too_short(): + assert count_in_beats([0.5], [{"beat": 0, "beats_per_bar": 4}]) == (0.0, []) + + +def test_count_in_empty_when_disabled(): + assert count_in_beats(_STEADY, [], count_bars=0) == (0.0, []) + + +def test_count_in_tempo_tracks_the_start_position(): + """A track that speeds up counts in at the local tempo, not the average.""" + beats = [0.0, 1.0, 2.0, 3.0, 3.4, 3.8, 4.2, 4.6] # 60 BPM then 150 BPM + slow, _ = count_in_beats(beats, [{"beat": 0, "beats_per_bar": 4}], start=0.0) + fast, _ = count_in_beats(beats, [{"beat": 0, "beats_per_bar": 4}], start=3.4) + assert slow == pytest.approx(4.0) # 4 x 1.0 s + assert fast == pytest.approx(1.6) # 4 x 0.4 s + + +def test_count_in_beats_per_bar_prefers_explicit_then_meter_then_four(): + assert count_in_beats_per_bar([{"beat": 0, "beats_per_bar": 3}], ACCENT_AUTO) == 3 + assert count_in_beats_per_bar([{"beat": 0, "beats_per_bar": 3}], 6) == 6 + assert count_in_beats_per_bar([], ACCENT_AUTO) == 4 + + # --- rendering ------------------------------------------------------------- @@ -217,6 +296,78 @@ def test_render_is_deterministic(tmp_path): assert a.read_bytes() == b.read_bytes() +# --- count-in render ------------------------------------------------------- + + +def test_count_in_render_prepends_lead_in(tmp_path): + """The count-in clicks occupy the front of the file and the song clicks + follow, shifted by the lead-in.""" + beats = [0.5 + i * 0.5 for i in range(8)] # 120 BPM + out = render_count_in_wav( + tmp_path / "ci.wav", + beats, + [{"beat": 0, "beats_per_bar": 4}], + duration=6.0, + count_in_bars=1, + include_click=True, + ) + assert out is not None + path, lead_in = out + assert lead_in == pytest.approx(2.0) # 4 beats x 0.5 s + y, sr = _read(path) + # File spans lead-in + song. + assert abs(len(y) / sr - (lead_in + 6.0)) < 0.02 + starts = _click_starts(y, sr) + # Four count-in clicks in [0, lead_in), then the song grid shifted by lead_in. + count_in_hits = starts[starts < lead_in - 0.05] + assert len(count_in_hits) == 4 + assert count_in_hits[0] < 0.01 # first click at the very start + # The song's first beat now sounds one lead-in later. + assert np.any(np.abs(starts - (beats[0] + lead_in)) < 0.01) + + +def test_count_in_render_downbeat_is_accented(tmp_path): + beats = [0.5 + i * 0.5 for i in range(8)] + out = render_count_in_wav( + tmp_path / "ci.wav", + beats, + [{"beat": 0, "beats_per_bar": 4}], + duration=6.0, + count_in_bars=1, + include_click=False, + ) + path, _ = out + y, sr = _read(path) + assert abs(_dominant_freq(y, sr, 0.0) - ACCENT_FREQ) < 150 + + +def test_count_in_render_without_song_click_is_only_the_lead_in(tmp_path): + """count-in on, click off: the export gets a count-in and nothing else on + top of the (clean) stems -- the user's Reaper workflow, in one step.""" + beats = [0.5 + i * 0.5 for i in range(8)] + out = render_count_in_wav( + tmp_path / "ci.wav", + beats, + [{"beat": 0, "beats_per_bar": 4}], + duration=6.0, + count_in_bars=1, + include_click=False, + ) + path, lead_in = out + y, sr = _read(path) + # Only the four count-in clicks; silence over the song body. + assert len(_click_starts(y, sr)) == 4 + + +def test_count_in_render_none_when_grid_too_short(tmp_path): + assert ( + render_count_in_wav( + tmp_path / "ci.wav", [0.5], [], duration=6.0, count_in_bars=1, include_click=False + ) + is None + ) + + # --- cache key ------------------------------------------------------------- @@ -305,9 +456,10 @@ def test_click_lane_renders_when_enabled(client, tmp_path): _setup_job(tmp_path) lane = stems_mod._click_lane(JOB, True, 1.0, ACCENT_AUTO, 0.6) assert lane is not None - path, gain = lane - assert path.is_file() - assert gain == pytest.approx(0.6) + assert lane.path.is_file() + assert lane.gain == pytest.approx(0.6) + assert lane.lead_in == 0.0 + assert lane.count_in is False def test_click_lane_is_none_without_a_beat_grid(client, tmp_path): @@ -348,9 +500,49 @@ def test_mixdown_cache_key_separates_click_from_clean(client, tmp_path): assert clean != clicked, "a click export must never reuse a clean render" -@pytest.mark.parametrize("bad", ["click_accent=99", "click_accent=-2"]) +@pytest.mark.parametrize("bad", ["click_accent=99", "click_accent=-2", "count_in=3", "count_in=-1"]) def test_mixdown_rejects_out_of_range_click_params(client, tmp_path, bad): _setup_job(tmp_path) (tmp_path / JOB / "stems" / "drums.wav").write_bytes(b"RIFF") r = client.get(f"/api/jobs/{JOB}/mixdown.wav?stems=drums&gains=1.0&click=1&{bad}") assert r.status_code == 422 + + +def test_click_lane_count_in_bakes_the_lead_in(client, tmp_path): + from app.api import stems as stems_mod + + _setup_job(tmp_path) # beats 0.5..2.0 at 0.5 s, 4/4 + lane = stems_mod._click_lane(JOB, True, 1.0, ACCENT_AUTO, 0.6, count_in_bars=1) + assert lane is not None + assert lane.count_in is True + assert lane.lead_in == pytest.approx(2.0) # 4 beats x 0.5 s + assert lane.path.is_file() + + +def test_click_lane_count_in_without_click(client, tmp_path): + """count-in on, click off still yields a lane -- the count-in only.""" + from app.api import stems as stems_mod + + _setup_job(tmp_path) + lane = stems_mod._click_lane(JOB, False, 1.0, ACCENT_AUTO, 0.6, count_in_bars=1) + assert lane is not None + assert lane.count_in is True + assert lane.lead_in == pytest.approx(2.0) + + +def test_click_lane_count_in_is_none_without_a_beat_grid(client, tmp_path): + from app.api import stems as stems_mod + + _setup_job(tmp_path, with_grid=False) + assert stems_mod._click_lane(JOB, False, 1.0, ACCENT_AUTO, 0.6, count_in_bars=1) is None + + +def test_mixdown_cache_key_separates_count_in_from_plain_click(client, tmp_path): + from app.api import stems as stems_mod + + _setup_job(tmp_path) + plain = stems_mod._click_lane(JOB, True, 1.0, ACCENT_AUTO, 0.6) + counted = stems_mod._click_lane(JOB, True, 1.0, ACCENT_AUTO, 0.6, count_in_bars=1) + a = stems_mod._mixdown_cache_key(JOB, "wav", ["drums"], [1.0], None, None, plain) + b = stems_mod._mixdown_cache_key(JOB, "wav", ["drums"], [1.0], None, None, counted) + assert a != b, "a count-in export must never reuse a plain click render"