nwe files
some files
This commit is contained in:
117
DESIGN_RATIONALE.md
Normal file
117
DESIGN_RATIONALE.md
Normal file
@@ -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.
|
||||||
66
TEASER_COMPARISON.md
Normal file
66
TEASER_COMPARISON.md
Normal file
@@ -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
|
||||||
25
TESTING.md
Normal file
25
TESTING.md
Normal file
@@ -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.
|
||||||
94
V4_ROADMAP.md
Normal file
94
V4_ROADMAP.md
Normal file
@@ -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
|
||||||
98
autoclip.py
Normal file
98
autoclip.py
Normal file
@@ -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()
|
||||||
7
testing/pyproject.toml
Normal file
7
testing/pyproject.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-ra"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py310"
|
||||||
78
testing/test_sanity.py
Normal file
78
testing/test_sanity.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user