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