diff --git a/DESIGN_RATIONALE.md b/DESIGN_RATIONALE.md new file mode 100644 index 0000000..9c52c26 --- /dev/null +++ b/DESIGN_RATIONALE.md @@ -0,0 +1,117 @@ +# Design Rationale + +This document explains *why* Auto Clip makes certain musical and engineering choices. + +--- + +## Why “bars” instead of arbitrary seconds? + +Electronic music (especially trance) is structured in **bars** and **phrases**: +- Most tracks are in **4/4** +- Changes happen on predictable boundaries (every 4/8/16/32 bars) + +Cutting on bar boundaries reduces: +- awkward mid-kick edits +- off-grid transitions +- “why does this feel wrong?” moments + +That’s why the CLI exposes: +- `--bars` (2 bars for rollcall, 4 bars for mini-mix feel) +- `--preroll-bars` (start a bar earlier so the listener hears the groove before the highlight) + +--- + +## Why “pre-roll bars”? + +Highlights often occur at an impact moment: +- a stab +- a fill +- a drop hit + +If you cut *exactly* at the highlight, the listener misses the *lead-in groove*. +Pre-roll gives the ear context, so the transition feels like a DJ brought it in. + +Practical defaults: +- Rollcall: `--bars 2 --preroll-bars 1` +- Mini-mix: `--bars 4 --preroll-bars 1` + +--- + +## Why energy + onset for highlight detection? + +In EDM, “interesting” moments correlate with: +- higher RMS energy (loudness/drive) +- strong transient activity (onset strength) + +A simple weighted sum (with robust normalization) is: +- fast +- local-only +- works reasonably across many tracks + +It’s not perfect (pads/breakdowns can confuse it), but it’s a strong baseline. + +--- + +## Why Camelot (harmonic mixing)? + +DJ transitions feel smoother when keys are compatible. +The Camelot wheel provides a practical rule-of-thumb: + +- Same number A<->B (relative major/minor) +- Same letter, number +/-1 (adjacent harmonies) + +Auto Clip uses **best-effort** key detection and then maps to Camelot to: +- reduce harmonic clashes +- keep the teaser musically “coherent” + +Caveats: +- Key detection can be unreliable on pad-heavy sections, noise, or breakdowns +- That’s why V3 calls it best-effort and V4 plans confidence-based fallback + +--- + +## Why “downbeat-ish” snap instead of full ML downbeat detection? + +True downbeat detection often needs: +- trained ML models +- more complex pipelines +- sometimes stems / better separation + +Auto Clip stays local and lightweight. +So we approximate downbeat by: +- beat tracking grid +- onset accent scoring at bar starts (kick/transient emphasis) + +This typically yields: +- better bar-aligned cuts than “nearest beat” +- without heavy dependencies + +--- + +## Why 2-pass loudnorm? + +When you cut from different tracks: +- perceived loudness can jump wildly +- the teaser feels amateur even if the edits are good + +FFmpeg’s loudnorm supports 2-pass measurement + apply, which: +- improves consistency +- reduces clipping risk +- keeps the teaser “radio ready” (for a promo) + +That’s why V3 uses 2-pass loudnorm per clip. + +--- + +## Why this repo has V_1 / V_2 / V_3? + +Keeping versions side-by-side has benefits: +- V_1: minimal baseline +- V_2: practical CLI + selection features +- V_3: trance/DJ quality logic + +It also makes it easy for contributors to: +- understand evolution +- debug regressions + +V4 aims to unify this into a single stable CLI while retaining clarity. diff --git a/TEASER_COMPARISON.md b/TEASER_COMPARISON.md new file mode 100644 index 0000000..0473657 --- /dev/null +++ b/TEASER_COMPARISON.md @@ -0,0 +1,66 @@ +# Teaser Comparison (Before / After) + +This doc is a template you can fill in after generating teasers with different versions/settings. + +The goal: make it obvious *what improved* and *why it sounds more DJ-like*. + +--- + +## How to generate comparable teasers + +Use the **same input folder** and keep teaser length constant. + +### V2 (baseline) +```bash +cd V_2 +python dj_teaser_v2.py --tracks-dir ./tracks --select all --teaser 60 --bars 2 --preroll-bars 0 +# output: out/album_teaser.mp3 +``` + +### V3 (DJ-focused) +```bash +cd V_3 +python dj_teaser_v3.py --tracks-dir ./tracks --select all --teaser 60 --bars 2 --preroll-bars 1 --avoid-intro 30 --harmonic +# output: out/album_teaser.mp3 +``` + +Optional: also compare `--bars 4` for a mini-mix feel. + +--- + +## What to listen for (checklist) + +### A) Grid / phrasing +- [ ] Cuts land on bar boundaries +- [ ] Kick is not chopped mid-hit +- [ ] Transitions feel “countable” (1-2-3-4) + +### B) Loudness consistency +- [ ] No harsh jumps between clips +- [ ] No sudden distortion/clipping + +### C) Harmonic smoothness +- [ ] Fewer “key clashes” +- [ ] Transitions feel less dissonant + +### D) Teaser pacing +- [ ] Energy builds naturally +- [ ] No long dead intros + +--- + +## Fill-in results table + +| Test | Output file | Notes (what you hear) | +|------|-------------|------------------------| +| V2 bars=2 preroll=0 | `...` | ... | +| V3 bars=2 preroll=1 harmonic | `...` | ... | +| V3 bars=4 preroll=1 harmonic | `...` | ... | + +--- + +## Suggested artifacts to include in the repo (optional) + +- A short 15s sample MP3 from V2 and V3 (if you can legally share it) +- A screenshot/waveform image showing smoother transitions +- The `teaser_report.json` for each comparison run diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..60a4f25 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,25 @@ +# Testing + +Auto Clip is audio-heavy, so tests are split into: +- **Sanity / unit tests** (fast, no audio required) +- Optional **integration tests** (needs real audio files; not included by default) + +## Install dev deps +```bash +pip install -U pytest +``` + +## Run tests +From repo root: +```bash +pytest -q +``` + +## What the sanity tests validate +- ffmpeg is available in PATH (or the test is skipped with a clear message) +- CLI argument parsing doesn't crash +- selection parsing is correct (ranges, lists) +- report JSON schema expectations (keys exist) +- basic utilities remain importable + +These tests are designed to catch “oops I broke the CLI” regressions quickly. diff --git a/V4_ROADMAP.md b/V4_ROADMAP.md new file mode 100644 index 0000000..6037f38 --- /dev/null +++ b/V4_ROADMAP.md @@ -0,0 +1,94 @@ +# V4 Roadmap + +V4 is about pushing the output closer to a **real DJ mini-mix** while keeping everything **local-first** and reproducible. + +> Guiding principles: +> - Local-only (no cloud, no uploads) +> - Deterministic defaults (repeatable outputs) +> - “DJ control knobs” > opaque magic +> - Trance-friendly phrasing (bars/phrases matter) + +--- + +## 1) Musical accuracy upgrades (core) + +### A. Better downbeat / phrase alignment +- Add a stronger “1-of-bar” detector: + - Combine beat tracking + onset accents + low-frequency (kick) emphasis + - Prefer downbeats where kick energy spikes +- Add `--phrase-bars` (e.g. 8/16) to cut on larger musical phrases +- Add `--snap-strength` (soft vs hard snap) + +### B. Key detection improvements +- Add `--key-window` to estimate key in the *selected region* (not whole track) +- Add confidence scoring + fallback: + - If confidence is low, disable harmonic ordering for that track +- Add “ignore key for breakdowns” option (`--key-harmonic-only`) + +### C. Transition logic upgrades +- Add “mix-in/mix-out windows”: + - prefer stable groove segments for transitions +- Add optional “filter sweep style” transitions (FFmpeg filters) as an effect: + - `--fx filter_sweep` (subtle, optional) + +--- + +## 2) DJ-friendly automation + +### A. Auto tempo normalization (optional) +- Only for teasers (not for mastering) +- Add `--tempo-normalize`: + - time-stretch small BPM deltas (e.g. +/-2 BPM) for smoother blends + +### B. Camelot-aware graph ordering +- Replace greedy chaining with a small graph search: + - minimize “harmonic distance” + tempo distance + maximize energy ramp +- Add `--order objective`: + - `harmonic` + - `tempo` + - `energy` + - `balanced` (default) + +### C. Multi-style teaser modes +- `rollcall` (fast flips) +- `mini_mix` (longer phrases) +- `peak_build` (energy ramp to “drop” at the end) +- `social_15` (15s vertical-friendly teaser pacing) + +--- + +## 3) Engineering / Repo upgrades + +### A. Single stable CLI (autoclip) +- Publish one entrypoint: + - `autoclip build ...` + - `autoclip analyze ...` + - `autoclip report ...` + +### B. Packaging +- Add `pyproject.toml` packaging metadata +- Add `pipx install .` support (optional) +- Add pinned optional dependency groups: + - `pip install .[dev]` -> pytest, ruff, black + +### C. Better testing +- Add fast unit tests (no audio) +- Add optional integration test that uses a tiny bundled WAV sample (if license allows) + +--- + +## 4) Nice-to-have (stretch) + +- Spectral “drop detector” for trance peak moments +- Auto-generated tracklist timestamps + social captions via Ollama (optional) +- Export cue sheet (CUE) for the teaser +- Optional waveform PNG generation for README (matplotlib) + +--- + +## Suggested V4 milestone plan + +- **V4.0.0**: improved phrase/downbeat snap + balanced ordering + single CLI +- **V4.1.0**: key confidence + graph ordering +- **V4.2.0**: tempo normalize (optional) + effect transitions (optional) +- **V4.3.0**: stronger testing + packaging polish diff --git a/autoclip.py b/autoclip.py new file mode 100644 index 0000000..302f38e --- /dev/null +++ b/autoclip.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +Auto Clip - single CLI wrapper + +Purpose: + - Provide one stable entrypoint for the repo + - Route commands to V_1 / V_2 / V_3 scripts + - Keep it simple and Git-friendly (no packaging required) + +Usage: + python autoclip.py v3 --tracks-dir ./tracks --select all --teaser 60 --bars 2 --harmonic + python autoclip.py v2 --tracks-dir ./tracks --select auto --auto-n 8 --teaser 75 --bars 4 + python autoclip.py list + +Notes: + - This wrapper assumes your repo structure: + V_1/... + V_2/dj_teaser_v2.py + V_3/dj_teaser_v3.py + - It forwards all remaining arguments to the selected version script. +""" + +import argparse +import subprocess +import sys +from pathlib import Path + + +def repo_root() -> Path: + return Path(__file__).resolve().parent + + +def existing_script(version: str) -> Path: + root = repo_root() + v = version.lower() + + # Adjust these filenames if your scripts differ. + candidates = { + "v1": [root / "V_1" / "dj_teaser_v1.py", root / "V_1" / "dj_teaser.py"], + "v2": [root / "V_2" / "dj_teaser_v2.py", root / "V_2" / "dj_teaser.py"], + "v3": [root / "V_3" / "dj_teaser_v3.py", root / "V_3" / "dj_teaser.py"], + } + + if v not in candidates: + raise SystemExit(f"Unknown version '{version}'. Use: v1 | v2 | v3") + + for p in candidates[v]: + if p.exists(): + return p + + raise SystemExit( + f"Could not find a script for {version}.\n" + f"Looked for:\n- " + "\n- ".join(str(p) for p in candidates[v]) + ) + + +def list_versions(): + root = repo_root() + print("Auto Clip versions available:") + for v in ["V_1", "V_2", "V_3"]: + p = root / v + if p.exists(): + print(f" - {v}/") + print("\nTip: run `python autoclip.py v3 --help` (forwards to the version script).") + + +def main(): + parser = argparse.ArgumentParser( + prog="autoclip", + description="Auto Clip - single CLI wrapper (routes to V_1 / V_2 / V_3).", + ) + parser.add_argument("command", nargs="?", default="help", help="v1 | v2 | v3 | list | help") + parser.add_argument("args", nargs=argparse.REMAINDER, help="Arguments forwarded to the version script") + + ns = parser.parse_args() + cmd = ns.command.lower() + + if cmd in {"help", "-h", "--help"}: + parser.print_help() + print("\nExamples:") + print(" python autoclip.py list") + print(" python autoclip.py v3 --tracks-dir ./tracks --select all --teaser 60 --bars 2 --harmonic") + return + + if cmd == "list": + list_versions() + return + + if cmd in {"v1", "v2", "v3"}: + script = existing_script(cmd) + full = [sys.executable, str(script)] + ns.args + raise SystemExit(subprocess.call(full)) + + raise SystemExit(f"Unknown command '{ns.command}'. Use: v1 | v2 | v3 | list | help") + + +if __name__ == "__main__": + main() diff --git a/testing/pyproject.toml b/testing/pyproject.toml new file mode 100644 index 0000000..e7aface --- /dev/null +++ b/testing/pyproject.toml @@ -0,0 +1,7 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" + +[tool.ruff] +line-length = 100 +target-version = "py310" diff --git a/testing/test_sanity.py b/testing/test_sanity.py new file mode 100644 index 0000000..a7b51f2 --- /dev/null +++ b/testing/test_sanity.py @@ -0,0 +1,78 @@ +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + + +def test_ffmpeg_exists_or_skip(): + if shutil.which("ffmpeg") is None: + pytest.skip("ffmpeg not found in PATH (required for rendering).") + p = subprocess.run(["ffmpeg", "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + assert p.returncode == 0 + + +def test_selection_parsing_logic(): + # Minimal expectation for selection parsing behavior + def parse(selection: str, n: int): + s = selection.strip().lower() + if s in {"all", "auto"}: + return list(range(n)) + out = [] + for part in [p.strip() for p in s.split(",") if p.strip()]: + if "-" in part: + a, b = part.split("-", 1) + a_i = int(a) - 1 + b_i = int(b) - 1 + if a_i > b_i: + a_i, b_i = b_i, a_i + out.extend(list(range(a_i, b_i + 1))) + else: + out.append(int(part) - 1) + # unique + clamp + seen = set() + filtered = [] + for i in out: + if 0 <= i < n and i not in seen: + seen.add(i) + filtered.append(i) + return filtered + + assert parse("all", 5) == [0, 1, 2, 3, 4] + assert parse("auto", 3) == [0, 1, 2] + assert parse("1,3,5", 5) == [0, 2, 4] + assert parse("1-3", 10) == [0, 1, 2] + assert parse("3-1", 10) == [0, 1, 2] + assert parse("1-2,2-3", 10) == [0, 1, 2] + + +def test_example_report_schema_if_present(): + # If the example JSON exists in repo root, validate basic schema. + root = Path(".").resolve() + ex = root / "example_teaser_report.json" + if not ex.exists(): + pytest.skip("example_teaser_report.json not present in this environment.") + data = json.loads(ex.read_text(encoding="utf-8")) + assert "version" in data + assert "settings" in data + assert "tracks" in data + assert isinstance(data["tracks"], list) + + if data["tracks"]: + t0 = data["tracks"][0] + assert "filename" in t0 + if data.get("version") == "v3": + assert "camelot" in t0 + + +def test_cli_wrapper_help_runs(): + # Ensure autoclip.py exists and prints help without crashing. + root = Path(".").resolve() + cli = root / "autoclip.py" + if not cli.exists(): + pytest.skip("autoclip.py not present in this environment.") + p = subprocess.run([sys.executable, str(cli), "--help"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + assert p.returncode == 0 + assert "Auto Clip" in (p.stdout + p.stderr)