|
| 1 | +"""Smart cutdowns: 15 / 30 / 60 s edits cut on bar and phrase boundaries with a musical ending. |
| 2 | +
|
| 3 | +Sync buyers always ask for cutdowns. Naive trimming (first N seconds) stops mid-phrase on a random beat. This planner |
| 4 | +works on the beat grid: it groups beats into bars, finds the phrase structure (4/8-bar blocks counted from where the |
| 5 | +track actually starts), and for each target length proposes |
| 6 | + * ``intro`` - from the start of the music, ending on the bar closest to the target with a one-bar fade; |
| 7 | + * ``hook`` - the most energetic phrase-aligned window of that length, which ends where the energy naturally drops. |
| 8 | +Pure planning (``plan_cutdowns``) is unit-tested with synthetic grids; ``beat_grid`` and ``render`` use librosa/ffmpeg. |
| 9 | +""" |
| 10 | +from __future__ import annotations |
| 11 | + |
| 12 | +import subprocess |
| 13 | +from typing import Any, Dict, List, Optional, Sequence |
| 14 | + |
| 15 | +import numpy as np |
| 16 | + |
| 17 | + |
| 18 | +def _bars(beat_times: np.ndarray, beat_energy: np.ndarray, per_bar: int): |
| 19 | + n = (len(beat_times) // per_bar) * per_bar |
| 20 | + starts = beat_times[:n:per_bar] |
| 21 | + energy = beat_energy[:n].reshape(-1, per_bar).mean(axis=1) |
| 22 | + return starts, energy |
| 23 | + |
| 24 | + |
| 25 | +def plan_cutdowns(beat_times: Sequence[float], beat_energy: Sequence[float], duration: float, |
| 26 | + targets: Sequence[int] = (15, 30, 60), per_bar: int = 4) -> Dict[str, Any]: |
| 27 | + bt, be = np.asarray(beat_times, dtype=float), np.asarray(beat_energy, dtype=float) |
| 28 | + if bt.size < per_bar * 4 or duration <= 0: |
| 29 | + return {"ok": False, "reason": "not enough beats to plan cutdowns", "cutdowns": []} |
| 30 | + beat_dur = float(np.median(np.diff(bt))) |
| 31 | + bar_dur = beat_dur * per_bar |
| 32 | + starts, energy = _bars(bt, be, per_bar) |
| 33 | + if starts.size < 4: |
| 34 | + return {"ok": False, "reason": "too short", "cutdowns": []} |
| 35 | + # end time of bar i = start of bar i+1; the last bar ends at the end of the audio |
| 36 | + ends = np.append(starts[1:], min(duration, starts[-1] + bar_dur)) |
| 37 | + med = float(np.median(energy)) or 1e-9 |
| 38 | + body = int(np.argmax(energy > 0.5 * med)) # first bar with real music |
| 39 | + p75 = float(np.percentile(energy, 90)) or 1e-9 # reference level of the loud sections |
| 40 | + out: List[Dict[str, Any]] = [] |
| 41 | + for target in targets: |
| 42 | + n = max(2, int(round(target / bar_dur))) |
| 43 | + if n >= starts.size: |
| 44 | + continue |
| 45 | + # option 1: intro cut |
| 46 | + s0 = body |
| 47 | + e0 = min(starts.size - 1, s0 + n - 1) |
| 48 | + intro = _cut("intro", target, starts, ends, energy, s0, e0, bar_dur, body, p75, duration) |
| 49 | + # option 2: best hook window |
| 50 | + best = None |
| 51 | + for s in range(0, starts.size - n + 1): |
| 52 | + e = s + n - 1 |
| 53 | + c = _cut("hook", target, starts, ends, energy, s, e, bar_dur, body, p75, duration) |
| 54 | + if best is None or c["score"] > best["score"]: |
| 55 | + best = c |
| 56 | + out.append({"target": target, "options": [x for x in (intro, best) if x]}) |
| 57 | + return {"ok": True, "bar_seconds": round(bar_dur, 3), "bpm": round(60 / beat_dur, 1), "first_music_bar": body, "cutdowns": out} |
| 58 | + |
| 59 | + |
| 60 | +def _cut(kind: str, target: int, starts, ends, energy, s: int, e: int, bar_dur: float, body: int, p75: float, duration: float) -> Dict[str, Any]: |
| 61 | + start, end = float(starts[s]), float(ends[e]) |
| 62 | + dur = end - start |
| 63 | + phrase = ((s - body) % 8 == 0) * 1.0 + (((s - body) % 4 == 0) * 0.5) # aligned to 8/4-bar phrases |
| 64 | + prev = energy[max(0, s - 2):s] |
| 65 | + if prev.size and energy[s] > 1.2 * prev.mean(): # the section itself starts here (energy jump) |
| 66 | + phrase = max(phrase, 1.0) |
| 67 | + hook = float(np.clip(energy[s:e + 1].mean() / p75, 0, 1.2)) |
| 68 | + drop_at_end = e + 1 < energy.size and energy[e] > 0 and energy[e + 1] < 0.9 * energy[e] |
| 69 | + natural = bool(e >= energy.size - 1 or drop_at_end) |
| 70 | + length_fit = 1.0 - min(1.0, abs(dur - target) / target) |
| 71 | + score = round(0.45 * hook + 0.25 * phrase + 0.2 * length_fit + 0.1 * (1.0 if natural else 0.0), 3) |
| 72 | + fade = 0.0 if (natural and e >= energy.size - 1) else round(min(bar_dur, 3.0), 2) |
| 73 | + return {"kind": kind, "start": round(start, 3), "end": round(end, 3), "duration": round(dur, 3), "bars": e - s + 1, |
| 74 | + "fadeOut": fade, "naturalEnd": natural, "phraseAligned": phrase >= 1.0, "score": score, |
| 75 | + "reason": ("starts where the music starts, ends on a bar line" if kind == "intro" |
| 76 | + else "most energetic phrase-aligned window" + (", ends where energy drops" if natural else ""))} |
| 77 | + |
| 78 | + |
| 79 | +def align_beats_to_tempo(beats: Sequence[float], bpm: float) -> np.ndarray: |
| 80 | + """Make the beat grid agree with the consensus tempo: a tracker locked to half-time gets midpoints inserted, |
| 81 | + one locked to double-time keeps every second beat.""" |
| 82 | + b = np.asarray(beats, dtype=float) |
| 83 | + if b.size < 4 or not bpm: |
| 84 | + return b |
| 85 | + ratio = float(np.median(np.diff(b))) / (60.0 / bpm) |
| 86 | + if 1.85 <= ratio <= 2.15: # beats are twice as far apart as the tempo says |
| 87 | + mid = (b[:-1] + b[1:]) / 2 |
| 88 | + return np.sort(np.concatenate([b, mid])) |
| 89 | + if 0.45 <= ratio <= 0.55: # beats are twice as dense |
| 90 | + return b[::2] |
| 91 | + return b |
| 92 | + |
| 93 | + |
| 94 | +# ───────────────────────────── audio side ───────────────────────────── |
| 95 | +def beat_grid(path: str, sr: int = 22050) -> Dict[str, Any]: |
| 96 | + import librosa |
| 97 | + |
| 98 | + from . import tempo as tempo_mod |
| 99 | + |
| 100 | + y, _ = librosa.load(path, sr=sr, mono=True) |
| 101 | + duration = y.size / sr |
| 102 | + est = tempo_mod.estimate_file(path) |
| 103 | + beat_times = np.asarray(est.get("beats") or []) |
| 104 | + if beat_times.size >= 16 and est.get("bpm"): |
| 105 | + beat_times = align_beats_to_tempo(beat_times, est["bpm"]) |
| 106 | + else: |
| 107 | + _, beats = librosa.beat.beat_track(y=y, sr=sr) |
| 108 | + beat_times = librosa.frames_to_time(beats, sr=sr) |
| 109 | + rms = librosa.feature.rms(y=y, hop_length=512)[0] |
| 110 | + frame_t = librosa.frames_to_time(np.arange(rms.size), sr=sr, hop_length=512) |
| 111 | + idx = np.searchsorted(frame_t, np.append(beat_times, duration)) |
| 112 | + energy = np.array([rms[idx[i]:max(idx[i] + 1, idx[i + 1])].mean() for i in range(len(beat_times))]) if len(beat_times) else np.array([]) |
| 113 | + return {"beat_times": beat_times, "beat_energy": energy, "duration": duration, "tempo": est} |
| 114 | + |
| 115 | + |
| 116 | +def plan_file(path: str, targets: Sequence[int] = (15, 30, 60)) -> Dict[str, Any]: |
| 117 | + g = beat_grid(path) |
| 118 | + plan = plan_cutdowns(g["beat_times"], g["beat_energy"], g["duration"], targets) |
| 119 | + plan["duration_s"] = round(g["duration"], 2) |
| 120 | + t = g.get("tempo") or {} |
| 121 | + plan["tempo"] = {k: t.get(k) for k in ("bpm", "agreement", "alternatives", "octaveAmbiguous")} |
| 122 | + return plan |
| 123 | + |
| 124 | + |
| 125 | +def render(path: str, start: float, end: float, fade_out: float, out_path: str, bitrate: str = "320k") -> str: |
| 126 | + dur = end - start |
| 127 | + af = f"afade=t=in:st=0:d=0.02,afade=t=out:st={max(0.0, dur - fade_out):.3f}:d={fade_out:.3f}" if fade_out > 0 else "afade=t=in:st=0:d=0.02" |
| 128 | + codec = ["-c:a", "libmp3lame", "-b:a", bitrate] if out_path.lower().endswith(".mp3") else [] |
| 129 | + subprocess.run(["ffmpeg", "-v", "error", "-y", "-ss", f"{start:.3f}", "-t", f"{dur:.3f}", "-i", path, "-af", af, *codec, out_path], |
| 130 | + check=True, capture_output=True, timeout=300) |
| 131 | + return out_path |
0 commit comments