feat(skills): add music-bed skill for scoring a long-form edit
Pick an Epidemic Sound track per sequence, align each track's audible end on its section, bake the ducking under the voice, place the cues through the studio API and lay the music onto an existing render without re-rendering the picture.
dd91d90 parent: 54ac456 added
.claude/skills/music-bed/SKILL.md +106 -0 | new file mode 100644 | ||
| @@ -0,0 +1,106 @@ | ||
| 1 | +--- | |
| 2 | +name: music-bed | |
| 3 | +description: Score a long-form edit with background music from Epidemic Sound — split the video into sequences, pick a track per sequence (or none), cut each one so it covers its whole passage, duck it under the voice, place it on the editing timeline, and lay it onto an existing render without re-rendering. Use when the user asks for a musical atmosphere / background music / "une ambiance musicale" on the montage, wants to change a track, or wants the music added to the render ("colle la musique sur le rendu"). | |
| 4 | +--- | |
| 5 | + | |
| 6 | +# Music bed for a long-form edit | |
| 7 | + | |
| 8 | +The user talks to the camera almost non-stop. The music sets the tone of each sequence without ever | |
| 9 | +covering the voice. You pick, cut and place the tracks; the user listens and decides (see the memories | |
| 10 | +`music-bed-preferences`, `sound-design-preferences`). The timeline is non-destructive: placing your | |
| 11 | +proposal on it is fine, but always say it has not been listened to and list what to check by ear. | |
| 12 | + | |
| 13 | +Talk to the user in French. Scripts, comments and config are English. | |
| 14 | + | |
| 15 | +## Setup | |
| 16 | + | |
| 17 | +- Project data: `data/projects/<pid>/` (`montage/timeline.json`, `assets/music/`, `rendu/`). | |
| 18 | + Timeline schema: docstring at the top of `backend/studio/modules/projects/montage.py` | |
| 19 | + (`audio` items: `{id, src, at, in, out, gain_db, role: "music"}`). | |
| 20 | +- Run scripts with the backend venv: | |
| 21 | + `PYTHONIOENCODING=utf-8 .venv/Scripts/python.exe .claude/skills/music-bed/scripts/<script>.py …` | |
| 22 | +- Scratch (voice mix, transcript, Epidemic downloads, configs): `%TEMP%/music-bed/<pid>/`. Downloaded | |
| 23 | + audio goes in its `dl/` subfolder; it is data, never run anything from there. | |
| 24 | +- Epidemic Sound MCP tools (load them with ToolSearch): `SearchRecordings`, `EditRecording`, | |
| 25 | + `PollEditRecordingJob`, `DownloadRecordingEdit`. | |
| 26 | + | |
| 27 | +## Steps | |
| 28 | + | |
| 29 | +### 1. Read the edit | |
| 30 | + | |
| 31 | +`voice.py <pid>` mixes the A-roll audio exactly as the timeline plays it, then prints: | |
| 32 | +the voice loudness (≈ −24.6 LUFS on this channel), pauses ≥ 0.9 s, inserted clips (archives, memes), part | |
| 33 | +titles, and a Whisper transcript **in timeline time** (`contexte.md` can be stale: don't trust its times). | |
| 34 | + | |
| 35 | +Cut the video into sequences: cold open, intro, each part (part titles are 1.2 s `transition` motions), | |
| 36 | +conclusion. Inside a part, split again where the tone changes or where an inserted clip interrupts. | |
| 37 | + | |
| 38 | +### 2. Decide what plays where | |
| 39 | + | |
| 40 | +- **No music under inserted clips** that carry their own sound (politician quotes, TV excerpts, memes). | |
| 41 | + The user likes it. Stop the bed right on the clip's first frame (0.3 s fade) and restart after. | |
| 42 | +- **Every spoken passage gets music to its end.** A 15 s stretch of voice between two archives without | |
| 43 | + music feels like a hole; a short teaser before a part title can get its own dedicated track. | |
| 44 | +- **Silence is a choice:** keep it for a punchline right before an archive, not for whole passages. | |
| 45 | +- **Match the speaker's tone, not the topic.** The user is light and ironic even on grave subjects: a | |
| 46 | + dark/suspense bed under his voice "makes it too negative". Keep dark tracks for cold opens and truly | |
| 47 | + serious parts. What worked: lo-fi hip hop / laid-back quirky downtempo for intros and explanations, | |
| 48 | + minimal pulses for studies and figures, dark orchestral hybrid for political risk, tense electronic for | |
| 49 | + bots/disinformation, hopeful contemporary classical for the conclusion. | |
| 50 | +- One track per sequence; change track at part titles. Instrumental only (`vocals: false`), 85–110 BPM | |
| 51 | + for talking parts. | |
| 52 | + | |
| 53 | +### 3. Find and cut the tracks (Epidemic) | |
| 54 | + | |
| 55 | +- `SearchRecordings` with a mood term + `filter: {vocals: false, bpm: {min, max}}`; read the tags (mood, | |
| 56 | + genre). Pick 1, keep 1–2 alternatives to offer. | |
| 57 | +- `EditRecording` with `targetDurationMs` = section length + 5–8 s, `skipStems: true`, WAV. Epidemic | |
| 58 | + returns its own natural structure: the duration is approximate and asking again for a slightly longer | |
| 59 | + version often returns the same edit. | |
| 60 | +- Download with `DownloadRecordingEdit` + `curl -o <scratch>/dl/<name>.wav`. | |
| 61 | +- `profile.py <files>`: **audible end** and loudness every 4 s. The edits end with a long fade (up to | |
| 62 | + 10 s) that is inaudible under the voice: the user hears the music "stop before the end of the passage". | |
| 63 | + Always align the **audible end** on the section end: `skip = audible_end − section_length` (skip the | |
| 64 | + start of the file), never the file end. | |
| 65 | +- Too short by 1–3 s and no longer edit: `ffmpeg -i in.wav -af atempo=0.9876 out.wav` (stretch ≤ 2 %, | |
| 66 | + pitch kept, inaudible). | |
| 67 | +- A track that swells (5+ dB louder in its second half): set `level` in the config to the short-term | |
| 68 | + loudness of its calm part; the build caps it (only cuts, 3 s smoothing). | |
| 69 | + | |
| 70 | +### 4. Build, place, check | |
| 71 | + | |
| 72 | +Write `<scratch>/config.json` (format in `build.py`'s docstring), then: | |
| 73 | + | |
| 74 | +1. `build.py <pid> <config>`: writes `assets/music/<name>.wav` with the ducking baked in (−9 dB under the | |
| 75 | + voice, opens in pauses ≥ 0.9 s and in `force_open` stretches) and `cues.json` with each clip's gain. | |
| 76 | +2. `profile.py data/projects/<pid>/assets/music/<name>.wav`: check every audible end against its section end. | |
| 77 | +3. `apply.py <pid> <scratch>/cues.json [--remove smus1,smus2] [--keep-gain smus9:0]`: saves through the | |
| 78 | + API, then asserts every other track is unchanged. **Never** round-trip the timeline JSON through a | |
| 79 | + non-UTF-8 read (Windows Python defaults to cp1252): it once mangled every accent and dropped a clip path. | |
| 80 | + If you must write your own call, `open(..., encoding="utf-8")` both ways. | |
| 81 | +4. Report in French: a table (sequence, times, track, BPM, why), the stretches left without music on | |
| 82 | + purpose, what to check by ear first, and the alternatives. | |
| 83 | + | |
| 84 | +### Levels | |
| 85 | + | |
| 86 | +- Voice ≈ −24.6 LUFS. Music **under the voice ≈ −47 LUFS** (≈ 22 LU below). A first pass at −42 was too | |
| 87 | + loud: the user lowered the validated tracks by 4–11 dB himself. Tense electronic beds can go to −52. | |
| 88 | +- Before changing levels, read the current `gain_db` of the user's clips: if he moved them, follow his | |
| 89 | + direction for the new ones and say so. | |
| 90 | +- The ducking only opens in real pauses: this speaker barely pauses, so the bed sits at its under-voice | |
| 91 | + level almost all the time. That is the goal; don't pump on every breath. | |
| 92 | +- The studio has **no volume automation yet** (the user wants waveforms and volume keyframes on music | |
| 93 | + clips, Filmora-style): everything time-varying is baked into the files, the clip `gain_db` sets the | |
| 94 | + overall level. Re-run `build.py` to change the shape. | |
| 95 | + | |
| 96 | +### 5. Add the music to an existing render | |
| 97 | + | |
| 98 | +When the user wants the music in a render that is otherwise up to date: | |
| 99 | +`remix_render.py <pid> [quality]` copies the video stream, mixes the music onto the render's audio (same | |
| 100 | +mix as the render worker), replaces `rendu/<quality>.mp4` and marks the render job up to date. | |
| 101 | + | |
| 102 | +- The first run saves the music-free render as `rendu/<quality>-sans-musique.mp4`; later runs always start | |
| 103 | + from it (no stacked music). Tell the user to keep it while the music can still change. | |
| 104 | +- It refuses if anything but music changed since the render. A blade cut on contiguous clips without a | |
| 105 | + transition changes the A-roll list but not the picture: check that, then `--allow-aroll`. | |
| 106 | +- About 1 minute for 17 min of video, versus a full render. | |
| new file mode 100644 | |||
| @@ -0,0 +1,106 @@ | |||
| 1 | +--- | ||
| 2 | +name: music-bed | ||
| 3 | +description: Score a long-form edit with background music from Epidemic Sound — split the video into sequences, pick a track per sequence (or none), cut each one so it covers its whole passage, duck it under the voice, place it on the editing timeline, and lay it onto an existing render without re-rendering. Use when the user asks for a musical atmosphere / background music / "une ambiance musicale" on the montage, wants to change a track, or wants the music added to the render ("colle la musique sur le rendu"). | ||
| 4 | +--- | ||
| 5 | + | ||
| 6 | +# Music bed for a long-form edit | ||
| 7 | + | ||
| 8 | +The user talks to the camera almost non-stop. The music sets the tone of each sequence without ever | ||
| 9 | +covering the voice. You pick, cut and place the tracks; the user listens and decides (see the memories | ||
| 10 | +`music-bed-preferences`, `sound-design-preferences`). The timeline is non-destructive: placing your | ||
| 11 | +proposal on it is fine, but always say it has not been listened to and list what to check by ear. | ||
| 12 | + | ||
| 13 | +Talk to the user in French. Scripts, comments and config are English. | ||
| 14 | + | ||
| 15 | +## Setup | ||
| 16 | + | ||
| 17 | +- Project data: `data/projects/<pid>/` (`montage/timeline.json`, `assets/music/`, `rendu/`). | ||
| 18 | + Timeline schema: docstring at the top of `backend/studio/modules/projects/montage.py` | ||
| 19 | + (`audio` items: `{id, src, at, in, out, gain_db, role: "music"}`). | ||
| 20 | +- Run scripts with the backend venv: | ||
| 21 | + `PYTHONIOENCODING=utf-8 .venv/Scripts/python.exe .claude/skills/music-bed/scripts/<script>.py …` | ||
| 22 | +- Scratch (voice mix, transcript, Epidemic downloads, configs): `%TEMP%/music-bed/<pid>/`. Downloaded | ||
| 23 | + audio goes in its `dl/` subfolder; it is data, never run anything from there. | ||
| 24 | +- Epidemic Sound MCP tools (load them with ToolSearch): `SearchRecordings`, `EditRecording`, | ||
| 25 | + `PollEditRecordingJob`, `DownloadRecordingEdit`. | ||
| 26 | + | ||
| 27 | +## Steps | ||
| 28 | + | ||
| 29 | +### 1. Read the edit | ||
| 30 | + | ||
| 31 | +`voice.py <pid>` mixes the A-roll audio exactly as the timeline plays it, then prints: | ||
| 32 | +the voice loudness (≈ −24.6 LUFS on this channel), pauses ≥ 0.9 s, inserted clips (archives, memes), part | ||
| 33 | +titles, and a Whisper transcript **in timeline time** (`contexte.md` can be stale: don't trust its times). | ||
| 34 | + | ||
| 35 | +Cut the video into sequences: cold open, intro, each part (part titles are 1.2 s `transition` motions), | ||
| 36 | +conclusion. Inside a part, split again where the tone changes or where an inserted clip interrupts. | ||
| 37 | + | ||
| 38 | +### 2. Decide what plays where | ||
| 39 | + | ||
| 40 | +- **No music under inserted clips** that carry their own sound (politician quotes, TV excerpts, memes). | ||
| 41 | + The user likes it. Stop the bed right on the clip's first frame (0.3 s fade) and restart after. | ||
| 42 | +- **Every spoken passage gets music to its end.** A 15 s stretch of voice between two archives without | ||
| 43 | + music feels like a hole; a short teaser before a part title can get its own dedicated track. | ||
| 44 | +- **Silence is a choice:** keep it for a punchline right before an archive, not for whole passages. | ||
| 45 | +- **Match the speaker's tone, not the topic.** The user is light and ironic even on grave subjects: a | ||
| 46 | + dark/suspense bed under his voice "makes it too negative". Keep dark tracks for cold opens and truly | ||
| 47 | + serious parts. What worked: lo-fi hip hop / laid-back quirky downtempo for intros and explanations, | ||
| 48 | + minimal pulses for studies and figures, dark orchestral hybrid for political risk, tense electronic for | ||
| 49 | + bots/disinformation, hopeful contemporary classical for the conclusion. | ||
| 50 | +- One track per sequence; change track at part titles. Instrumental only (`vocals: false`), 85–110 BPM | ||
| 51 | + for talking parts. | ||
| 52 | + | ||
| 53 | +### 3. Find and cut the tracks (Epidemic) | ||
| 54 | + | ||
| 55 | +- `SearchRecordings` with a mood term + `filter: {vocals: false, bpm: {min, max}}`; read the tags (mood, | ||
| 56 | + genre). Pick 1, keep 1–2 alternatives to offer. | ||
| 57 | +- `EditRecording` with `targetDurationMs` = section length + 5–8 s, `skipStems: true`, WAV. Epidemic | ||
| 58 | + returns its own natural structure: the duration is approximate and asking again for a slightly longer | ||
| 59 | + version often returns the same edit. | ||
| 60 | +- Download with `DownloadRecordingEdit` + `curl -o <scratch>/dl/<name>.wav`. | ||
| 61 | +- `profile.py <files>`: **audible end** and loudness every 4 s. The edits end with a long fade (up to | ||
| 62 | + 10 s) that is inaudible under the voice: the user hears the music "stop before the end of the passage". | ||
| 63 | + Always align the **audible end** on the section end: `skip = audible_end − section_length` (skip the | ||
| 64 | + start of the file), never the file end. | ||
| 65 | +- Too short by 1–3 s and no longer edit: `ffmpeg -i in.wav -af atempo=0.9876 out.wav` (stretch ≤ 2 %, | ||
| 66 | + pitch kept, inaudible). | ||
| 67 | +- A track that swells (5+ dB louder in its second half): set `level` in the config to the short-term | ||
| 68 | + loudness of its calm part; the build caps it (only cuts, 3 s smoothing). | ||
| 69 | + | ||
| 70 | +### 4. Build, place, check | ||
| 71 | + | ||
| 72 | +Write `<scratch>/config.json` (format in `build.py`'s docstring), then: | ||
| 73 | + | ||
| 74 | +1. `build.py <pid> <config>`: writes `assets/music/<name>.wav` with the ducking baked in (−9 dB under the | ||
| 75 | + voice, opens in pauses ≥ 0.9 s and in `force_open` stretches) and `cues.json` with each clip's gain. | ||
| 76 | +2. `profile.py data/projects/<pid>/assets/music/<name>.wav`: check every audible end against its section end. | ||
| 77 | +3. `apply.py <pid> <scratch>/cues.json [--remove smus1,smus2] [--keep-gain smus9:0]`: saves through the | ||
| 78 | + API, then asserts every other track is unchanged. **Never** round-trip the timeline JSON through a | ||
| 79 | + non-UTF-8 read (Windows Python defaults to cp1252): it once mangled every accent and dropped a clip path. | ||
| 80 | + If you must write your own call, `open(..., encoding="utf-8")` both ways. | ||
| 81 | +4. Report in French: a table (sequence, times, track, BPM, why), the stretches left without music on | ||
| 82 | + purpose, what to check by ear first, and the alternatives. | ||
| 83 | + | ||
| 84 | +### Levels | ||
| 85 | + | ||
| 86 | +- Voice ≈ −24.6 LUFS. Music **under the voice ≈ −47 LUFS** (≈ 22 LU below). A first pass at −42 was too | ||
| 87 | + loud: the user lowered the validated tracks by 4–11 dB himself. Tense electronic beds can go to −52. | ||
| 88 | +- Before changing levels, read the current `gain_db` of the user's clips: if he moved them, follow his | ||
| 89 | + direction for the new ones and say so. | ||
| 90 | +- The ducking only opens in real pauses: this speaker barely pauses, so the bed sits at its under-voice | ||
| 91 | + level almost all the time. That is the goal; don't pump on every breath. | ||
| 92 | +- The studio has **no volume automation yet** (the user wants waveforms and volume keyframes on music | ||
| 93 | + clips, Filmora-style): everything time-varying is baked into the files, the clip `gain_db` sets the | ||
| 94 | + overall level. Re-run `build.py` to change the shape. | ||
| 95 | + | ||
| 96 | +### 5. Add the music to an existing render | ||
| 97 | + | ||
| 98 | +When the user wants the music in a render that is otherwise up to date: | ||
| 99 | +`remix_render.py <pid> [quality]` copies the video stream, mixes the music onto the render's audio (same | ||
| 100 | +mix as the render worker), replaces `rendu/<quality>.mp4` and marks the render job up to date. | ||
| 101 | + | ||
| 102 | +- The first run saves the music-free render as `rendu/<quality>-sans-musique.mp4`; later runs always start | ||
| 103 | + from it (no stacked music). Tell the user to keep it while the music can still change. | ||
| 104 | +- It refuses if anything but music changed since the render. A blade cut on contiguous clips without a | ||
| 105 | + transition changes the A-roll list but not the picture: check that, then `--allow-aroll`. | ||
| 106 | +- About 1 minute for 17 min of video, versus a full render. | ||
added
.claude/skills/music-bed/scripts/apply.py +48 -0 | new file mode 100644 | ||
| @@ -0,0 +1,48 @@ | ||
| 1 | +"""Put music cues on the timeline through the studio API (history, undo, merge with the open editor). | |
| 2 | + | |
| 3 | +Usage: apply.py <pid> <cues.json> [--remove smus1,smus2] [--keep-gain old_id:new_index ...] | |
| 4 | +- Reads and writes UTF-8 end to end (a cp1252 round trip once mangled every accent of the timeline). | |
| 5 | +- New ids: smusN, after the highest existing one. | |
| 6 | +- Checks that every track but the music is byte-for-byte unchanged after the save. | |
| 7 | +""" | |
| 8 | +import json | |
| 9 | +import re | |
| 10 | +import sys | |
| 11 | +from pathlib import Path | |
| 12 | + | |
| 13 | +from common import api_get, api_put | |
| 14 | + | |
| 15 | + | |
| 16 | +def main() -> None: | |
| 17 | + pid, cues = sys.argv[1], json.loads(Path(sys.argv[2]).read_text(encoding="utf-8")) | |
| 18 | + args = sys.argv[3:] | |
| 19 | + remove = set(args[args.index("--remove") + 1].split(",")) if "--remove" in args else set() | |
| 20 | + view = api_get(f"/projects/{pid}/montage") | |
| 21 | + tl = view["timeline"] | |
| 22 | + before = json.loads(json.dumps(tl)) | |
| 23 | + by_id = {a["id"]: a for a in tl["audio"]} | |
| 24 | + # --keep-gain smus9:0 : the new cue 0 takes the gain the user had set on smus9 | |
| 25 | + if "--keep-gain" in args: | |
| 26 | + for pair in args[args.index("--keep-gain") + 1:]: | |
| 27 | + if ":" not in pair: | |
| 28 | + break | |
| 29 | + old, idx = pair.split(":") | |
| 30 | + cues[int(idx)]["gain_db"] = by_id[old]["gain_db"] | |
| 31 | + tl["audio"] = [a for a in tl["audio"] if a["id"] not in remove] | |
| 32 | + last = max([int(m.group(1)) for a in tl["audio"] if (m := re.fullmatch(r"smus(\d+)", a["id"]))] or [0]) | |
| 33 | + for i, c in enumerate(cues, last + 1): | |
| 34 | + c["id"] = f"smus{i}" | |
| 35 | + tl["audio"] += cues | |
| 36 | + saved = api_put(f"/projects/{pid}/montage", {"timeline": tl, "rev": view["rev"]})["timeline"] | |
| 37 | + for k in before: | |
| 38 | + if k != "audio": | |
| 39 | + assert saved[k] == before[k], f"track {k} changed" | |
| 40 | + keep = lambda l: [a for a in l if a["role"] != "music"] | |
| 41 | + assert keep(saved["audio"]) == keep(before["audio"]), "sound effects changed" | |
| 42 | + for a in sorted((a for a in saved["audio"] if a["role"] == "music"), key=lambda a: a["at"]): | |
| 43 | + print(f"{a['id']:7} {a['at']:8.2f} -> {a['at'] + a['out'] - a['in']:8.2f} {a['gain_db']:6.1f} dB " | |
| 44 | + f"{a['src'].split('/')[-1]}") | |
| 45 | + | |
| 46 | + | |
| 47 | +if __name__ == "__main__": | |
| 48 | + main() | |
| new file mode 100644 | |||
| @@ -0,0 +1,48 @@ | |||
| 1 | +"""Put music cues on the timeline through the studio API (history, undo, merge with the open editor). | ||
| 2 | + | ||
| 3 | +Usage: apply.py <pid> <cues.json> [--remove smus1,smus2] [--keep-gain old_id:new_index ...] | ||
| 4 | +- Reads and writes UTF-8 end to end (a cp1252 round trip once mangled every accent of the timeline). | ||
| 5 | +- New ids: smusN, after the highest existing one. | ||
| 6 | +- Checks that every track but the music is byte-for-byte unchanged after the save. | ||
| 7 | +""" | ||
| 8 | +import json | ||
| 9 | +import re | ||
| 10 | +import sys | ||
| 11 | +from pathlib import Path | ||
| 12 | + | ||
| 13 | +from common import api_get, api_put | ||
| 14 | + | ||
| 15 | + | ||
| 16 | +def main() -> None: | ||
| 17 | + pid, cues = sys.argv[1], json.loads(Path(sys.argv[2]).read_text(encoding="utf-8")) | ||
| 18 | + args = sys.argv[3:] | ||
| 19 | + remove = set(args[args.index("--remove") + 1].split(",")) if "--remove" in args else set() | ||
| 20 | + view = api_get(f"/projects/{pid}/montage") | ||
| 21 | + tl = view["timeline"] | ||
| 22 | + before = json.loads(json.dumps(tl)) | ||
| 23 | + by_id = {a["id"]: a for a in tl["audio"]} | ||
| 24 | + # --keep-gain smus9:0 : the new cue 0 takes the gain the user had set on smus9 | ||
| 25 | + if "--keep-gain" in args: | ||
| 26 | + for pair in args[args.index("--keep-gain") + 1:]: | ||
| 27 | + if ":" not in pair: | ||
| 28 | + break | ||
| 29 | + old, idx = pair.split(":") | ||
| 30 | + cues[int(idx)]["gain_db"] = by_id[old]["gain_db"] | ||
| 31 | + tl["audio"] = [a for a in tl["audio"] if a["id"] not in remove] | ||
| 32 | + last = max([int(m.group(1)) for a in tl["audio"] if (m := re.fullmatch(r"smus(\d+)", a["id"]))] or [0]) | ||
| 33 | + for i, c in enumerate(cues, last + 1): | ||
| 34 | + c["id"] = f"smus{i}" | ||
| 35 | + tl["audio"] += cues | ||
| 36 | + saved = api_put(f"/projects/{pid}/montage", {"timeline": tl, "rev": view["rev"]})["timeline"] | ||
| 37 | + for k in before: | ||
| 38 | + if k != "audio": | ||
| 39 | + assert saved[k] == before[k], f"track {k} changed" | ||
| 40 | + keep = lambda l: [a for a in l if a["role"] != "music"] | ||
| 41 | + assert keep(saved["audio"]) == keep(before["audio"]), "sound effects changed" | ||
| 42 | + for a in sorted((a for a in saved["audio"] if a["role"] == "music"), key=lambda a: a["at"]): | ||
| 43 | + print(f"{a['id']:7} {a['at']:8.2f} -> {a['at'] + a['out'] - a['in']:8.2f} {a['gain_db']:6.1f} dB " | ||
| 44 | + f"{a['src'].split('/')[-1]}") | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +if __name__ == "__main__": | ||
| 48 | + main() | ||
added
.claude/skills/music-bed/scripts/build.py +100 -0 | new file mode 100644 | ||
| @@ -0,0 +1,100 @@ | ||
| 1 | +"""Build the music cues: cut, level, duck under the voice, fade, and compute each clip's gain. | |
| 2 | + | |
| 3 | +Usage: build.py <pid> <config.json> | |
| 4 | +Config (paths relative to the scratch dir unless absolute): | |
| 5 | +{ | |
| 6 | + "duck_db": -9, # under the voice, relative to the pauses | |
| 7 | + "under_voice_lufs": -47, # target loudness of the music under the voice (voice ≈ -24.6 LUFS) | |
| 8 | + "force_open": [[9.0, 13.85]], # stretches without speech the silence detector misses (TV set noise…) | |
| 9 | + "cues": [{ | |
| 10 | + "file": "dl/cryptic.wav", "name": "musique-01-ouverture-cryptic-secrecy", | |
| 11 | + "at": 0.0, # timeline start | |
| 12 | + "skip": 5.8, # seconds skipped at the start of the file (so the audible end lands right) | |
| 13 | + "length": 94.2, # seconds kept | |
| 14 | + "fade_in": 0.8, "fade_out": 1.5, | |
| 15 | + "level": -18.5, # optional: short-term LUFS ceiling (flattens a track that swells) | |
| 16 | + "dips": [[385.05, 386.65, -18]] # optional: extra dips in timeline time (a meme inside the section) | |
| 17 | + }] | |
| 18 | +} | |
| 19 | +Writes data/projects/<pid>/assets/music/<name>.wav and <scratch>/cues.json (ready for apply.py). | |
| 20 | +The ducking is baked into the files (the studio has no volume automation yet): pauses >= 0.9 s open up, | |
| 21 | +the music goes back down 0.05 s before the voice resumes. | |
| 22 | +""" | |
| 23 | +import json | |
| 24 | +import sys | |
| 25 | +from pathlib import Path | |
| 26 | + | |
| 27 | +import numpy as np | |
| 28 | + | |
| 29 | +from common import SR, lufs, project_dir, read_pcm, short_term, work_dir, write_wav | |
| 30 | + | |
| 31 | +MIN_PAUSE = 0.9 | |
| 32 | + | |
| 33 | + | |
| 34 | +def smooth(x): | |
| 35 | + x = np.clip(x, 0, 1) | |
| 36 | + return 0.5 - 0.5 * np.cos(np.pi * x) | |
| 37 | + | |
| 38 | + | |
| 39 | +def leveled(path: Path, ceiling: float): | |
| 40 | + """Gain curve (only cuts) keeping the 3 s-smoothed short-term loudness under `ceiling`.""" | |
| 41 | + st = np.array(short_term(path)) | |
| 42 | + x = read_pcm(path) | |
| 43 | + t = np.arange(len(x)) / SR | |
| 44 | + grid = np.arange(0, t[-1] + 0.1, 0.1) | |
| 45 | + loud = np.interp(grid, st[:, 0], st[:, 1]) | |
| 46 | + avg = np.convolve(np.pad(loud, 15, mode="edge"), np.ones(30) / 30, mode="same")[15:-15] | |
| 47 | + gain = np.minimum(0.0, ceiling - avg) | |
| 48 | + return x * (10 ** (np.interp(t, grid, gain) / 20))[:, None] | |
| 49 | + | |
| 50 | + | |
| 51 | +def main() -> None: | |
| 52 | + pid, cfg_path = sys.argv[1], Path(sys.argv[2]) | |
| 53 | + work = work_dir(pid) | |
| 54 | + cfg = json.loads(cfg_path.read_text(encoding="utf-8")) | |
| 55 | + duck, target = cfg.get("duck_db", -9.0), cfg.get("under_voice_lufs", -47.0) | |
| 56 | + sil = json.loads((work / "silences.json").read_text(encoding="utf-8")) | |
| 57 | + gaps = [(a, b) for a, b in sil if b - a >= MIN_PAUSE] + [tuple(g) for g in cfg.get("force_open", [])] | |
| 58 | + out_dir = project_dir(pid) / "assets" / "music" | |
| 59 | + out_dir.mkdir(parents=True, exist_ok=True) | |
| 60 | + result = [] | |
| 61 | + for c in cfg["cues"]: | |
| 62 | + src = Path(c["file"]) if Path(c["file"]).is_absolute() else work / c["file"] | |
| 63 | + x = leveled(src, c["level"]) if c.get("level") is not None else read_pcm(src) | |
| 64 | + x = x[int(c.get("skip", 0) * SR):] | |
| 65 | + if c.get("length"): | |
| 66 | + x = x[:int(c["length"] * SR)] | |
| 67 | + n, at = len(x), c["at"] | |
| 68 | + t = at + np.arange(n) / SR | |
| 69 | + opened = np.zeros(n) | |
| 70 | + for a, b in gaps: | |
| 71 | + if b < at or a > at + n / SR: | |
| 72 | + continue | |
| 73 | + opened = np.maximum(opened, smooth(np.minimum((t - (a + 0.1)) / 0.5, ((b - 0.05) - t) / 0.4))) | |
| 74 | + db = duck * (1 - opened) | |
| 75 | + for a, b, d in c.get("dips", []): | |
| 76 | + db = db + d * smooth(np.minimum((t - (a - 0.1)) / 0.1, ((b + 0.1) - t) / 0.3)) | |
| 77 | + g = 10 ** (db / 20) | |
| 78 | + rel = np.arange(n) / SR | |
| 79 | + if c.get("fade_in"): | |
| 80 | + g *= smooth(rel / c["fade_in"]) | |
| 81 | + if c.get("fade_out"): | |
| 82 | + g *= smooth((n / SR - rel) / c["fade_out"]) | |
| 83 | + y = x * g[:, None] | |
| 84 | + dest = out_dir / f"{c['name']}.wav" | |
| 85 | + write_wav(dest, y) | |
| 86 | + # the clip gain puts the (leveled, unducked) music at `target` once ducked under the voice | |
| 87 | + tmp = work / "_level.wav" | |
| 88 | + write_wav(tmp, x) | |
| 89 | + gain = round(target - duck - lufs(tmp), 1) | |
| 90 | + tmp.unlink() | |
| 91 | + result.append({"src": f"assets/music/{dest.name}", "at": at, "in": 0.0, "out": round(n / SR, 3), | |
| 92 | + "gain_db": gain, "role": "music"}) | |
| 93 | + print(f"{dest.name}: {at:.2f} -> {at + n / SR:.2f} · gain {gain} dB · " | |
| 94 | + f"open {100 * float((opened > 0.5).mean()):.1f} %") | |
| 95 | + (work / "cues.json").write_text(json.dumps(result, indent=1), encoding="utf-8") | |
| 96 | + print("->", work / "cues.json") | |
| 97 | + | |
| 98 | + | |
| 99 | +if __name__ == "__main__": | |
| 100 | + main() | |
| new file mode 100644 | |||
| @@ -0,0 +1,100 @@ | |||
| 1 | +"""Build the music cues: cut, level, duck under the voice, fade, and compute each clip's gain. | ||
| 2 | + | ||
| 3 | +Usage: build.py <pid> <config.json> | ||
| 4 | +Config (paths relative to the scratch dir unless absolute): | ||
| 5 | +{ | ||
| 6 | + "duck_db": -9, # under the voice, relative to the pauses | ||
| 7 | + "under_voice_lufs": -47, # target loudness of the music under the voice (voice ≈ -24.6 LUFS) | ||
| 8 | + "force_open": [[9.0, 13.85]], # stretches without speech the silence detector misses (TV set noise…) | ||
| 9 | + "cues": [{ | ||
| 10 | + "file": "dl/cryptic.wav", "name": "musique-01-ouverture-cryptic-secrecy", | ||
| 11 | + "at": 0.0, # timeline start | ||
| 12 | + "skip": 5.8, # seconds skipped at the start of the file (so the audible end lands right) | ||
| 13 | + "length": 94.2, # seconds kept | ||
| 14 | + "fade_in": 0.8, "fade_out": 1.5, | ||
| 15 | + "level": -18.5, # optional: short-term LUFS ceiling (flattens a track that swells) | ||
| 16 | + "dips": [[385.05, 386.65, -18]] # optional: extra dips in timeline time (a meme inside the section) | ||
| 17 | + }] | ||
| 18 | +} | ||
| 19 | +Writes data/projects/<pid>/assets/music/<name>.wav and <scratch>/cues.json (ready for apply.py). | ||
| 20 | +The ducking is baked into the files (the studio has no volume automation yet): pauses >= 0.9 s open up, | ||
| 21 | +the music goes back down 0.05 s before the voice resumes. | ||
| 22 | +""" | ||
| 23 | +import json | ||
| 24 | +import sys | ||
| 25 | +from pathlib import Path | ||
| 26 | + | ||
| 27 | +import numpy as np | ||
| 28 | + | ||
| 29 | +from common import SR, lufs, project_dir, read_pcm, short_term, work_dir, write_wav | ||
| 30 | + | ||
| 31 | +MIN_PAUSE = 0.9 | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +def smooth(x): | ||
| 35 | + x = np.clip(x, 0, 1) | ||
| 36 | + return 0.5 - 0.5 * np.cos(np.pi * x) | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +def leveled(path: Path, ceiling: float): | ||
| 40 | + """Gain curve (only cuts) keeping the 3 s-smoothed short-term loudness under `ceiling`.""" | ||
| 41 | + st = np.array(short_term(path)) | ||
| 42 | + x = read_pcm(path) | ||
| 43 | + t = np.arange(len(x)) / SR | ||
| 44 | + grid = np.arange(0, t[-1] + 0.1, 0.1) | ||
| 45 | + loud = np.interp(grid, st[:, 0], st[:, 1]) | ||
| 46 | + avg = np.convolve(np.pad(loud, 15, mode="edge"), np.ones(30) / 30, mode="same")[15:-15] | ||
| 47 | + gain = np.minimum(0.0, ceiling - avg) | ||
| 48 | + return x * (10 ** (np.interp(t, grid, gain) / 20))[:, None] | ||
| 49 | + | ||
| 50 | + | ||
| 51 | +def main() -> None: | ||
| 52 | + pid, cfg_path = sys.argv[1], Path(sys.argv[2]) | ||
| 53 | + work = work_dir(pid) | ||
| 54 | + cfg = json.loads(cfg_path.read_text(encoding="utf-8")) | ||
| 55 | + duck, target = cfg.get("duck_db", -9.0), cfg.get("under_voice_lufs", -47.0) | ||
| 56 | + sil = json.loads((work / "silences.json").read_text(encoding="utf-8")) | ||
| 57 | + gaps = [(a, b) for a, b in sil if b - a >= MIN_PAUSE] + [tuple(g) for g in cfg.get("force_open", [])] | ||
| 58 | + out_dir = project_dir(pid) / "assets" / "music" | ||
| 59 | + out_dir.mkdir(parents=True, exist_ok=True) | ||
| 60 | + result = [] | ||
| 61 | + for c in cfg["cues"]: | ||
| 62 | + src = Path(c["file"]) if Path(c["file"]).is_absolute() else work / c["file"] | ||
| 63 | + x = leveled(src, c["level"]) if c.get("level") is not None else read_pcm(src) | ||
| 64 | + x = x[int(c.get("skip", 0) * SR):] | ||
| 65 | + if c.get("length"): | ||
| 66 | + x = x[:int(c["length"] * SR)] | ||
| 67 | + n, at = len(x), c["at"] | ||
| 68 | + t = at + np.arange(n) / SR | ||
| 69 | + opened = np.zeros(n) | ||
| 70 | + for a, b in gaps: | ||
| 71 | + if b < at or a > at + n / SR: | ||
| 72 | + continue | ||
| 73 | + opened = np.maximum(opened, smooth(np.minimum((t - (a + 0.1)) / 0.5, ((b - 0.05) - t) / 0.4))) | ||
| 74 | + db = duck * (1 - opened) | ||
| 75 | + for a, b, d in c.get("dips", []): | ||
| 76 | + db = db + d * smooth(np.minimum((t - (a - 0.1)) / 0.1, ((b + 0.1) - t) / 0.3)) | ||
| 77 | + g = 10 ** (db / 20) | ||
| 78 | + rel = np.arange(n) / SR | ||
| 79 | + if c.get("fade_in"): | ||
| 80 | + g *= smooth(rel / c["fade_in"]) | ||
| 81 | + if c.get("fade_out"): | ||
| 82 | + g *= smooth((n / SR - rel) / c["fade_out"]) | ||
| 83 | + y = x * g[:, None] | ||
| 84 | + dest = out_dir / f"{c['name']}.wav" | ||
| 85 | + write_wav(dest, y) | ||
| 86 | + # the clip gain puts the (leveled, unducked) music at `target` once ducked under the voice | ||
| 87 | + tmp = work / "_level.wav" | ||
| 88 | + write_wav(tmp, x) | ||
| 89 | + gain = round(target - duck - lufs(tmp), 1) | ||
| 90 | + tmp.unlink() | ||
| 91 | + result.append({"src": f"assets/music/{dest.name}", "at": at, "in": 0.0, "out": round(n / SR, 3), | ||
| 92 | + "gain_db": gain, "role": "music"}) | ||
| 93 | + print(f"{dest.name}: {at:.2f} -> {at + n / SR:.2f} · gain {gain} dB · " | ||
| 94 | + f"open {100 * float((opened > 0.5).mean()):.1f} %") | ||
| 95 | + (work / "cues.json").write_text(json.dumps(result, indent=1), encoding="utf-8") | ||
| 96 | + print("->", work / "cues.json") | ||
| 97 | + | ||
| 98 | + | ||
| 99 | +if __name__ == "__main__": | ||
| 100 | + main() | ||
added
.claude/skills/music-bed/scripts/common.py +100 -0 | new file mode 100644 | ||
| @@ -0,0 +1,100 @@ | ||
| 1 | +"""Shared helpers: repo paths, backend imports, scratch dir, studio API, ffmpeg measurements.""" | |
| 2 | +import json | |
| 3 | +import re | |
| 4 | +import ssl | |
| 5 | +import subprocess | |
| 6 | +import sys | |
| 7 | +import tempfile | |
| 8 | +import urllib.request | |
| 9 | +from pathlib import Path | |
| 10 | + | |
| 11 | +REPO = Path(__file__).resolve().parents[4] | |
| 12 | +DATA = REPO / "data" | |
| 13 | +sys.path.insert(0, str(REPO / "backend")) | |
| 14 | + | |
| 15 | +API = "https://localhost:4400/api" | |
| 16 | +SR = 48000 | |
| 17 | + | |
| 18 | + | |
| 19 | +def project_dir(pid: str) -> Path: | |
| 20 | + return DATA / "projects" / pid | |
| 21 | + | |
| 22 | + | |
| 23 | +def work_dir(pid: str) -> Path: | |
| 24 | + """Scratch space (voice mix, transcripts, Epidemic downloads, configs): outside the repo and data/.""" | |
| 25 | + d = Path(tempfile.gettempdir()) / "music-bed" / pid | |
| 26 | + d.mkdir(parents=True, exist_ok=True) | |
| 27 | + return d | |
| 28 | + | |
| 29 | + | |
| 30 | +def timeline(pid: str) -> dict: | |
| 31 | + return json.loads((project_dir(pid) / "montage" / "timeline.json").read_text(encoding="utf-8")) | |
| 32 | + | |
| 33 | + | |
| 34 | +def total(tl: dict) -> float: | |
| 35 | + return sum(c["out"] - c["in"] for c in tl["aroll"]) | |
| 36 | + | |
| 37 | + | |
| 38 | +def inserted_clips(tl: dict) -> list[dict]: | |
| 39 | + """A-roll clips from outside media (archives, TV, memes): they play with their own sound, no music under | |
| 40 | + them. Clips from ressources/ are the user's own re-recorded takes: they get music like the source.""" | |
| 41 | + out, t = [], 0.0 | |
| 42 | + for c in tl["aroll"]: | |
| 43 | + d = c["out"] - c["in"] | |
| 44 | + if (c.get("src") or "").startswith(("assets/", "library/")): | |
| 45 | + out.append({"id": c["id"], "src": c["src"], "start": round(t, 3), "end": round(t + d, 3)}) | |
| 46 | + t += d | |
| 47 | + return out | |
| 48 | + | |
| 49 | + | |
| 50 | +def _ctx() -> ssl.SSLContext: | |
| 51 | + ctx = ssl.create_default_context() | |
| 52 | + ctx.check_hostname = False | |
| 53 | + ctx.verify_mode = ssl.CERT_NONE # local dev certificate | |
| 54 | + return ctx | |
| 55 | + | |
| 56 | + | |
| 57 | +def api_get(path: str) -> dict: | |
| 58 | + with urllib.request.urlopen(API + path, context=_ctx()) as r: | |
| 59 | + return json.loads(r.read().decode("utf-8")) | |
| 60 | + | |
| 61 | + | |
| 62 | +def api_put(path: str, body: dict) -> dict: | |
| 63 | + data = json.dumps(body, ensure_ascii=False).encode("utf-8") | |
| 64 | + req = urllib.request.Request(API + path, data=data, method="PUT", | |
| 65 | + headers={"Content-Type": "application/json; charset=utf-8"}) | |
| 66 | + with urllib.request.urlopen(req, context=_ctx()) as r: | |
| 67 | + return json.loads(r.read().decode("utf-8")) | |
| 68 | + | |
| 69 | + | |
| 70 | +def lufs(path: Path | str, gain_db: float = 0.0) -> float: | |
| 71 | + """Integrated loudness of a file (after an optional gain).""" | |
| 72 | + r = subprocess.run(["ffmpeg", "-v", "info", "-i", str(path), "-vn", "-af", f"volume={gain_db}dB,ebur128", | |
| 73 | + "-f", "null", "-"], capture_output=True, text=True, encoding="utf-8", errors="replace") | |
| 74 | + return float(re.findall(r"I:\s+(-?[\d.]+) LUFS", r.stderr)[-1]) | |
| 75 | + | |
| 76 | + | |
| 77 | +def short_term(path: Path | str) -> list[tuple[float, float]]: | |
| 78 | + """(time, short-term LUFS) every 0.1 s.""" | |
| 79 | + r = subprocess.run(["ffmpeg", "-v", "info", "-i", str(path), "-vn", "-af", "ebur128=metadata=1," | |
| 80 | + "ametadata=print:key=lavfi.r128.S", "-f", "null", "-"], | |
| 81 | + capture_output=True, text=True, encoding="utf-8", errors="replace") | |
| 82 | + out, t = [], 0.0 | |
| 83 | + for line in r.stderr.splitlines(): | |
| 84 | + if "pts_time:" in line: | |
| 85 | + t = float(line.split("pts_time:")[1].split()[0]) | |
| 86 | + elif "lavfi.r128.S=" in line: | |
| 87 | + out.append((t, float(line.split("=")[1]))) | |
| 88 | + return out | |
| 89 | + | |
| 90 | + | |
| 91 | +def read_pcm(path: Path | str): | |
| 92 | + import numpy as np | |
| 93 | + raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(path), "-f", "f32le", "-ac", "2", "-ar", str(SR), "-"], | |
| 94 | + capture_output=True, check=True).stdout | |
| 95 | + return np.frombuffer(raw, dtype=np.float32).reshape(-1, 2).copy() | |
| 96 | + | |
| 97 | + | |
| 98 | +def write_wav(path: Path | str, pcm) -> None: | |
| 99 | + subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "f32le", "-ar", str(SR), "-ac", "2", "-i", "-", | |
| 100 | + "-c:a", "pcm_s24le", str(path)], input=pcm.astype("float32").tobytes(), check=True) | |
| new file mode 100644 | |||
| @@ -0,0 +1,100 @@ | |||
| 1 | +"""Shared helpers: repo paths, backend imports, scratch dir, studio API, ffmpeg measurements.""" | ||
| 2 | +import json | ||
| 3 | +import re | ||
| 4 | +import ssl | ||
| 5 | +import subprocess | ||
| 6 | +import sys | ||
| 7 | +import tempfile | ||
| 8 | +import urllib.request | ||
| 9 | +from pathlib import Path | ||
| 10 | + | ||
| 11 | +REPO = Path(__file__).resolve().parents[4] | ||
| 12 | +DATA = REPO / "data" | ||
| 13 | +sys.path.insert(0, str(REPO / "backend")) | ||
| 14 | + | ||
| 15 | +API = "https://localhost:4400/api" | ||
| 16 | +SR = 48000 | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +def project_dir(pid: str) -> Path: | ||
| 20 | + return DATA / "projects" / pid | ||
| 21 | + | ||
| 22 | + | ||
| 23 | +def work_dir(pid: str) -> Path: | ||
| 24 | + """Scratch space (voice mix, transcripts, Epidemic downloads, configs): outside the repo and data/.""" | ||
| 25 | + d = Path(tempfile.gettempdir()) / "music-bed" / pid | ||
| 26 | + d.mkdir(parents=True, exist_ok=True) | ||
| 27 | + return d | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +def timeline(pid: str) -> dict: | ||
| 31 | + return json.loads((project_dir(pid) / "montage" / "timeline.json").read_text(encoding="utf-8")) | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +def total(tl: dict) -> float: | ||
| 35 | + return sum(c["out"] - c["in"] for c in tl["aroll"]) | ||
| 36 | + | ||
| 37 | + | ||
| 38 | +def inserted_clips(tl: dict) -> list[dict]: | ||
| 39 | + """A-roll clips from outside media (archives, TV, memes): they play with their own sound, no music under | ||
| 40 | + them. Clips from ressources/ are the user's own re-recorded takes: they get music like the source.""" | ||
| 41 | + out, t = [], 0.0 | ||
| 42 | + for c in tl["aroll"]: | ||
| 43 | + d = c["out"] - c["in"] | ||
| 44 | + if (c.get("src") or "").startswith(("assets/", "library/")): | ||
| 45 | + out.append({"id": c["id"], "src": c["src"], "start": round(t, 3), "end": round(t + d, 3)}) | ||
| 46 | + t += d | ||
| 47 | + return out | ||
| 48 | + | ||
| 49 | + | ||
| 50 | +def _ctx() -> ssl.SSLContext: | ||
| 51 | + ctx = ssl.create_default_context() | ||
| 52 | + ctx.check_hostname = False | ||
| 53 | + ctx.verify_mode = ssl.CERT_NONE # local dev certificate | ||
| 54 | + return ctx | ||
| 55 | + | ||
| 56 | + | ||
| 57 | +def api_get(path: str) -> dict: | ||
| 58 | + with urllib.request.urlopen(API + path, context=_ctx()) as r: | ||
| 59 | + return json.loads(r.read().decode("utf-8")) | ||
| 60 | + | ||
| 61 | + | ||
| 62 | +def api_put(path: str, body: dict) -> dict: | ||
| 63 | + data = json.dumps(body, ensure_ascii=False).encode("utf-8") | ||
| 64 | + req = urllib.request.Request(API + path, data=data, method="PUT", | ||
| 65 | + headers={"Content-Type": "application/json; charset=utf-8"}) | ||
| 66 | + with urllib.request.urlopen(req, context=_ctx()) as r: | ||
| 67 | + return json.loads(r.read().decode("utf-8")) | ||
| 68 | + | ||
| 69 | + | ||
| 70 | +def lufs(path: Path | str, gain_db: float = 0.0) -> float: | ||
| 71 | + """Integrated loudness of a file (after an optional gain).""" | ||
| 72 | + r = subprocess.run(["ffmpeg", "-v", "info", "-i", str(path), "-vn", "-af", f"volume={gain_db}dB,ebur128", | ||
| 73 | + "-f", "null", "-"], capture_output=True, text=True, encoding="utf-8", errors="replace") | ||
| 74 | + return float(re.findall(r"I:\s+(-?[\d.]+) LUFS", r.stderr)[-1]) | ||
| 75 | + | ||
| 76 | + | ||
| 77 | +def short_term(path: Path | str) -> list[tuple[float, float]]: | ||
| 78 | + """(time, short-term LUFS) every 0.1 s.""" | ||
| 79 | + r = subprocess.run(["ffmpeg", "-v", "info", "-i", str(path), "-vn", "-af", "ebur128=metadata=1," | ||
| 80 | + "ametadata=print:key=lavfi.r128.S", "-f", "null", "-"], | ||
| 81 | + capture_output=True, text=True, encoding="utf-8", errors="replace") | ||
| 82 | + out, t = [], 0.0 | ||
| 83 | + for line in r.stderr.splitlines(): | ||
| 84 | + if "pts_time:" in line: | ||
| 85 | + t = float(line.split("pts_time:")[1].split()[0]) | ||
| 86 | + elif "lavfi.r128.S=" in line: | ||
| 87 | + out.append((t, float(line.split("=")[1]))) | ||
| 88 | + return out | ||
| 89 | + | ||
| 90 | + | ||
| 91 | +def read_pcm(path: Path | str): | ||
| 92 | + import numpy as np | ||
| 93 | + raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(path), "-f", "f32le", "-ac", "2", "-ar", str(SR), "-"], | ||
| 94 | + capture_output=True, check=True).stdout | ||
| 95 | + return np.frombuffer(raw, dtype=np.float32).reshape(-1, 2).copy() | ||
| 96 | + | ||
| 97 | + | ||
| 98 | +def write_wav(path: Path | str, pcm) -> None: | ||
| 99 | + subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "f32le", "-ar", str(SR), "-ac", "2", "-i", "-", | ||
| 100 | + "-c:a", "pcm_s24le", str(path)], input=pcm.astype("float32").tobytes(), check=True) | ||
added
.claude/skills/music-bed/scripts/profile.py +32 -0 | new file mode 100644 | ||
| @@ -0,0 +1,32 @@ | ||
| 1 | +"""Profile music files: duration, audible start/end, short-term loudness every 4 s. | |
| 2 | + | |
| 3 | +Usage: profile.py <file.wav> [more files] | |
| 4 | +The audible end (short-term > median − 12 dB) is what must land on the end of a section: Epidemic edits | |
| 5 | +end with a long fade (up to ~10 s) that disappears under the voice. | |
| 6 | +""" | |
| 7 | +import sys | |
| 8 | + | |
| 9 | +from common import short_term | |
| 10 | + | |
| 11 | + | |
| 12 | +def profile(path: str) -> None: | |
| 13 | + st = [(t, x) for t, x in short_term(path) if x > -70] | |
| 14 | + if not st: | |
| 15 | + print(path, "silent") | |
| 16 | + return | |
| 17 | + values = sorted(x for _, x in st) | |
| 18 | + med = values[len(values) // 2] | |
| 19 | + loud = [t for t, x in st if x > med - 12] | |
| 20 | + end = st[-1][0] | |
| 21 | + print(f"{path}\n duration {end:.1f} s · median {med:.1f} LUFS · audible {loud[0]:.1f} -> {loud[-1]:.1f} s") | |
| 22 | + blocks = [] | |
| 23 | + for s in range(0, int(end) + 1, 4): | |
| 24 | + w = [x for t, x in st if s <= t < s + 4] | |
| 25 | + if w: | |
| 26 | + blocks.append(f"{s}:{sum(w) / len(w):.0f}") | |
| 27 | + print(" " + " ".join(blocks)) | |
| 28 | + | |
| 29 | + | |
| 30 | +if __name__ == "__main__": | |
| 31 | + for p in sys.argv[1:]: | |
| 32 | + profile(p) | |
| new file mode 100644 | |||
| @@ -0,0 +1,32 @@ | |||
| 1 | +"""Profile music files: duration, audible start/end, short-term loudness every 4 s. | ||
| 2 | + | ||
| 3 | +Usage: profile.py <file.wav> [more files] | ||
| 4 | +The audible end (short-term > median − 12 dB) is what must land on the end of a section: Epidemic edits | ||
| 5 | +end with a long fade (up to ~10 s) that disappears under the voice. | ||
| 6 | +""" | ||
| 7 | +import sys | ||
| 8 | + | ||
| 9 | +from common import short_term | ||
| 10 | + | ||
| 11 | + | ||
| 12 | +def profile(path: str) -> None: | ||
| 13 | + st = [(t, x) for t, x in short_term(path) if x > -70] | ||
| 14 | + if not st: | ||
| 15 | + print(path, "silent") | ||
| 16 | + return | ||
| 17 | + values = sorted(x for _, x in st) | ||
| 18 | + med = values[len(values) // 2] | ||
| 19 | + loud = [t for t, x in st if x > med - 12] | ||
| 20 | + end = st[-1][0] | ||
| 21 | + print(f"{path}\n duration {end:.1f} s · median {med:.1f} LUFS · audible {loud[0]:.1f} -> {loud[-1]:.1f} s") | ||
| 22 | + blocks = [] | ||
| 23 | + for s in range(0, int(end) + 1, 4): | ||
| 24 | + w = [x for t, x in st if s <= t < s + 4] | ||
| 25 | + if w: | ||
| 26 | + blocks.append(f"{s}:{sum(w) / len(w):.0f}") | ||
| 27 | + print(" " + " ".join(blocks)) | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +if __name__ == "__main__": | ||
| 31 | + for p in sys.argv[1:]: | ||
| 32 | + profile(p) | ||
added
.claude/skills/music-bed/scripts/remix_render.py +73 -0 | new file mode 100644 | ||
| @@ -0,0 +1,73 @@ | ||
| 1 | +"""Lay the timeline's music onto an existing render, without re-rendering the picture. | |
| 2 | + | |
| 3 | +Usage: remix_render.py <pid> [quality=1080p] | |
| 4 | +- Only valid when the render matches the timeline except for the music (A-roll, zooms, overlays, texts and | |
| 5 | + sound effects identical): checked, it stops otherwise. A contiguous blade cut without a transition | |
| 6 | + changes the A-roll list but not the picture: confirm with the user and pass --allow-aroll. | |
| 7 | +- The music-free original is kept as rendu/<quality>-sans-musique.mp4 and is always the base: remixing | |
| 8 | + twice never stacks the music. The video stream is copied; the audio is re-encoded once (AAC 320k). | |
| 9 | +- The render job is updated so the Rendu page shows the file as up to date. | |
| 10 | +""" | |
| 11 | +import os | |
| 12 | +import shutil | |
| 13 | +import subprocess | |
| 14 | +import sys | |
| 15 | +from pathlib import Path | |
| 16 | + | |
| 17 | +from common import lufs | |
| 18 | + | |
| 19 | +from studio.core import jobs | |
| 20 | +from studio.modules.projects import render, service | |
| 21 | + | |
| 22 | + | |
| 23 | +def is_music(s: dict) -> bool: | |
| 24 | + return Path(s["path"]).parent.name == "music" | |
| 25 | + | |
| 26 | + | |
| 27 | +def main() -> None: | |
| 28 | + pid = sys.argv[1] | |
| 29 | + quality = next((a for a in sys.argv[2:] if not a.startswith("--")), "1080p") | |
| 30 | + job = render.last_job(service.load(pid)) | |
| 31 | + assert job and job.status == "done" and job.payload["quality"] == quality, "no finished render in this quality" | |
| 32 | + cur = render.plan(pid, quality) | |
| 33 | + for k in ("zooms", "overlays", "texts") + (() if "--allow-aroll" in sys.argv else ("aroll",)): | |
| 34 | + assert job.payload[k] == cur[k], f"{k} changed since the render: a full render is needed" | |
| 35 | + assert [s for s in job.payload["sounds"] if not is_music(s)] == [s for s in cur["sounds"] if not is_music(s)], \ | |
| 36 | + "sound effects changed since the render: a full render is needed" | |
| 37 | + dest = Path(job.payload["dest"]) | |
| 38 | + base = dest.with_name(f"{dest.stem}-sans-musique.mp4") | |
| 39 | + if not base.is_file(): | |
| 40 | + assert not any(is_music(s) for s in job.payload["sounds"]), "the render already has music and no backup" | |
| 41 | + shutil.copy2(dest, base) | |
| 42 | + music = [s for s in cur["sounds"] if is_music(s)] | |
| 43 | + work = dest.parent / ".tmp-music" | |
| 44 | + work.mkdir(exist_ok=True) | |
| 45 | + args, graph, labels = ["ffmpeg", "-y", "-v", "error", "-i", str(base)], [], ["[0:a]"] | |
| 46 | + for i, s in enumerate(music, 1): | |
| 47 | + args += ["-i", s["path"]] | |
| 48 | + graph.append(f"[{i}:a]atrim=start={s['in']:.6f}:duration={s['out'] - s['in']:.6f},asetpts=PTS-STARTPTS," | |
| 49 | + f"aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo," | |
| 50 | + f"volume={s['gain_db']}dB,adelay=delays={round(s['at'] * 1000)}:all=1[m{i}]") | |
| 51 | + labels.append(f"[m{i}]") | |
| 52 | + # same mix as render.mix_args: no normalisation, the first input (the render) sets the length | |
| 53 | + graph.append(f"{''.join(labels)}amix=inputs={len(labels)}:duration=first:normalize=0:dropout_transition=0[aout]") | |
| 54 | + script = work / "mix.txt" | |
| 55 | + script.write_text(";\n".join(graph), encoding="utf-8") | |
| 56 | + out = work / dest.name | |
| 57 | + subprocess.run(args + ["-filter_complex_script", str(script), "-map", "0:v", "-map", "[aout]", "-c:v", "copy", | |
| 58 | + "-c:a", "aac", "-b:a", "320k", "-ar", "48000", "-movflags", "+faststart", str(out)], | |
| 59 | + check=True) | |
| 60 | + probe = lambda p: float(subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", | |
| 61 | + "csv=p=0", str(p)], capture_output=True, text=True).stdout) | |
| 62 | + assert abs(probe(out) - probe(base)) < 0.2, "duration changed" | |
| 63 | + os.replace(out, dest) | |
| 64 | + script.unlink() | |
| 65 | + work.rmdir() | |
| 66 | + jobs.update(job.id, payload={**job.payload, "aroll": cur["aroll"], "sounds": cur["sounds"], | |
| 67 | + "signature": cur["signature"]}) | |
| 68 | + print(f"{len(music)} music clips · {probe(dest):.2f} s · {lufs(base):.1f} -> {lufs(dest):.1f} LUFS · " | |
| 69 | + f"up to date: {render.view(service.load(pid), quality)['current']}") | |
| 70 | + | |
| 71 | + | |
| 72 | +if __name__ == "__main__": | |
| 73 | + main() | |
| new file mode 100644 | |||
| @@ -0,0 +1,73 @@ | |||
| 1 | +"""Lay the timeline's music onto an existing render, without re-rendering the picture. | ||
| 2 | + | ||
| 3 | +Usage: remix_render.py <pid> [quality=1080p] | ||
| 4 | +- Only valid when the render matches the timeline except for the music (A-roll, zooms, overlays, texts and | ||
| 5 | + sound effects identical): checked, it stops otherwise. A contiguous blade cut without a transition | ||
| 6 | + changes the A-roll list but not the picture: confirm with the user and pass --allow-aroll. | ||
| 7 | +- The music-free original is kept as rendu/<quality>-sans-musique.mp4 and is always the base: remixing | ||
| 8 | + twice never stacks the music. The video stream is copied; the audio is re-encoded once (AAC 320k). | ||
| 9 | +- The render job is updated so the Rendu page shows the file as up to date. | ||
| 10 | +""" | ||
| 11 | +import os | ||
| 12 | +import shutil | ||
| 13 | +import subprocess | ||
| 14 | +import sys | ||
| 15 | +from pathlib import Path | ||
| 16 | + | ||
| 17 | +from common import lufs | ||
| 18 | + | ||
| 19 | +from studio.core import jobs | ||
| 20 | +from studio.modules.projects import render, service | ||
| 21 | + | ||
| 22 | + | ||
| 23 | +def is_music(s: dict) -> bool: | ||
| 24 | + return Path(s["path"]).parent.name == "music" | ||
| 25 | + | ||
| 26 | + | ||
| 27 | +def main() -> None: | ||
| 28 | + pid = sys.argv[1] | ||
| 29 | + quality = next((a for a in sys.argv[2:] if not a.startswith("--")), "1080p") | ||
| 30 | + job = render.last_job(service.load(pid)) | ||
| 31 | + assert job and job.status == "done" and job.payload["quality"] == quality, "no finished render in this quality" | ||
| 32 | + cur = render.plan(pid, quality) | ||
| 33 | + for k in ("zooms", "overlays", "texts") + (() if "--allow-aroll" in sys.argv else ("aroll",)): | ||
| 34 | + assert job.payload[k] == cur[k], f"{k} changed since the render: a full render is needed" | ||
| 35 | + assert [s for s in job.payload["sounds"] if not is_music(s)] == [s for s in cur["sounds"] if not is_music(s)], \ | ||
| 36 | + "sound effects changed since the render: a full render is needed" | ||
| 37 | + dest = Path(job.payload["dest"]) | ||
| 38 | + base = dest.with_name(f"{dest.stem}-sans-musique.mp4") | ||
| 39 | + if not base.is_file(): | ||
| 40 | + assert not any(is_music(s) for s in job.payload["sounds"]), "the render already has music and no backup" | ||
| 41 | + shutil.copy2(dest, base) | ||
| 42 | + music = [s for s in cur["sounds"] if is_music(s)] | ||
| 43 | + work = dest.parent / ".tmp-music" | ||
| 44 | + work.mkdir(exist_ok=True) | ||
| 45 | + args, graph, labels = ["ffmpeg", "-y", "-v", "error", "-i", str(base)], [], ["[0:a]"] | ||
| 46 | + for i, s in enumerate(music, 1): | ||
| 47 | + args += ["-i", s["path"]] | ||
| 48 | + graph.append(f"[{i}:a]atrim=start={s['in']:.6f}:duration={s['out'] - s['in']:.6f},asetpts=PTS-STARTPTS," | ||
| 49 | + f"aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo," | ||
| 50 | + f"volume={s['gain_db']}dB,adelay=delays={round(s['at'] * 1000)}:all=1[m{i}]") | ||
| 51 | + labels.append(f"[m{i}]") | ||
| 52 | + # same mix as render.mix_args: no normalisation, the first input (the render) sets the length | ||
| 53 | + graph.append(f"{''.join(labels)}amix=inputs={len(labels)}:duration=first:normalize=0:dropout_transition=0[aout]") | ||
| 54 | + script = work / "mix.txt" | ||
| 55 | + script.write_text(";\n".join(graph), encoding="utf-8") | ||
| 56 | + out = work / dest.name | ||
| 57 | + subprocess.run(args + ["-filter_complex_script", str(script), "-map", "0:v", "-map", "[aout]", "-c:v", "copy", | ||
| 58 | + "-c:a", "aac", "-b:a", "320k", "-ar", "48000", "-movflags", "+faststart", str(out)], | ||
| 59 | + check=True) | ||
| 60 | + probe = lambda p: float(subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", | ||
| 61 | + "csv=p=0", str(p)], capture_output=True, text=True).stdout) | ||
| 62 | + assert abs(probe(out) - probe(base)) < 0.2, "duration changed" | ||
| 63 | + os.replace(out, dest) | ||
| 64 | + script.unlink() | ||
| 65 | + work.rmdir() | ||
| 66 | + jobs.update(job.id, payload={**job.payload, "aroll": cur["aroll"], "sounds": cur["sounds"], | ||
| 67 | + "signature": cur["signature"]}) | ||
| 68 | + print(f"{len(music)} music clips · {probe(dest):.2f} s · {lufs(base):.1f} -> {lufs(dest):.1f} LUFS · " | ||
| 69 | + f"up to date: {render.view(service.load(pid), quality)['current']}") | ||
| 70 | + | ||
| 71 | + | ||
| 72 | +if __name__ == "__main__": | ||
| 73 | + main() | ||
added
.claude/skills/music-bed/scripts/voice.py +75 -0 | new file mode 100644 | ||
| @@ -0,0 +1,75 @@ | ||
| 1 | +"""Analyze the voice of the current timeline: A-roll audio mix, loudness, pauses, inserted clips, transcript. | |
| 2 | + | |
| 3 | +Usage: voice.py <pid> [--no-stt] | |
| 4 | +Writes to the scratch dir: aroll.wav, silences.json, stt.json; prints a summary and the transcript. | |
| 5 | +""" | |
| 6 | +import json | |
| 7 | +import re | |
| 8 | +import subprocess | |
| 9 | +import sys | |
| 10 | + | |
| 11 | +from common import inserted_clips, lufs, project_dir, timeline, total, work_dir | |
| 12 | + | |
| 13 | + | |
| 14 | +def mix_aroll(pid: str, tl: dict, dest) -> None: | |
| 15 | + base = project_dir(pid) | |
| 16 | + args, parts = ["ffmpeg", "-y", "-v", "error"], [] | |
| 17 | + for i, c in enumerate(tl["aroll"]): | |
| 18 | + d = c["out"] - c["in"] | |
| 19 | + src = base / (c.get("src") or "source/" + _source_name(pid)) | |
| 20 | + args += ["-ss", f"{c['in']:.3f}", "-t", f"{d:.3f}", "-i", str(src)] | |
| 21 | + parts.append(f"[{i}:a]aresample=16000,aformat=channel_layouts=mono,volume={c.get('gain_db') or 0}dB," | |
| 22 | + f"apad=whole_dur={d:.3f},atrim=0:{d:.3f}[a{i}]") | |
| 23 | + n = len(tl["aroll"]) | |
| 24 | + graph = ";".join(parts) + ";" + "".join(f"[a{i}]" for i in range(n)) + f"concat=n={n}:v=0:a=1[o]" | |
| 25 | + subprocess.run(args + ["-filter_complex", graph, "-map", "[o]", str(dest)], check=True) | |
| 26 | + | |
| 27 | + | |
| 28 | +def _source_name(pid: str) -> str: | |
| 29 | + project = json.loads((project_dir(pid) / "project.json").read_text(encoding="utf-8")) | |
| 30 | + return project["source"]["name"] | |
| 31 | + | |
| 32 | + | |
| 33 | +def silences(wav, noise="-38dB", min_d=0.5) -> list[list[float]]: | |
| 34 | + r = subprocess.run(["ffmpeg", "-v", "info", "-i", str(wav), "-af", f"silencedetect=n={noise}:d={min_d}", | |
| 35 | + "-f", "null", "-"], capture_output=True, text=True, encoding="utf-8", errors="replace") | |
| 36 | + starts = [float(x) for x in re.findall(r"silence_start: (-?[\d.]+)", r.stderr)] | |
| 37 | + ends = [float(x) for x in re.findall(r"silence_end: ([\d.]+)", r.stderr)] | |
| 38 | + return [[max(0.0, a), b] for a, b in zip(starts, ends)] | |
| 39 | + | |
| 40 | + | |
| 41 | +def transcribe(wav) -> list[dict]: | |
| 42 | + from studio.modules.transcript.whisper import Engine | |
| 43 | + out = [] | |
| 44 | + Engine().transcribe(wav, "fr", lambda s: None, | |
| 45 | + lambda seg, d: out.append({"s": round(seg.start, 2), "e": round(seg.end, 2), "t": seg.text}), | |
| 46 | + lambda: None) | |
| 47 | + return out | |
| 48 | + | |
| 49 | + | |
| 50 | +def main() -> None: | |
| 51 | + pid = sys.argv[1] | |
| 52 | + tl, work = timeline(pid), work_dir(pid) | |
| 53 | + wav = work / "aroll.wav" | |
| 54 | + mix_aroll(pid, tl, wav) | |
| 55 | + sil = silences(wav) | |
| 56 | + (work / "silences.json").write_text(json.dumps(sil), encoding="utf-8") | |
| 57 | + print(f"timeline {total(tl):.2f} s · voice {lufs(wav):.1f} LUFS") | |
| 58 | + print("pauses >= 0.9 s:", [(round(a, 1), round(b - a, 2)) for a, b in sil if b - a >= 0.9]) | |
| 59 | + print("inserted clips (no music under them):") | |
| 60 | + for c in inserted_clips(tl): | |
| 61 | + print(f" {c['start']:8.2f} -> {c['end']:8.2f} {c['src']}") | |
| 62 | + print("part titles (short motions):") | |
| 63 | + for m in tl["motion"]: | |
| 64 | + if m["kind"] == "transition" or m["dur"] <= 1.5: | |
| 65 | + print(f" {m['at']:8.2f} {m['title'][:70]}") | |
| 66 | + if "--no-stt" in sys.argv: | |
| 67 | + return | |
| 68 | + lines = transcribe(wav) | |
| 69 | + (work / "stt.json").write_text(json.dumps(lines, ensure_ascii=False, indent=0), encoding="utf-8") | |
| 70 | + for l in lines: | |
| 71 | + print(f"{l['s']:7.1f}-{l['e']:6.1f} {l['t']}") | |
| 72 | + | |
| 73 | + | |
| 74 | +if __name__ == "__main__": | |
| 75 | + main() | |
| new file mode 100644 | |||
| @@ -0,0 +1,75 @@ | |||
| 1 | +"""Analyze the voice of the current timeline: A-roll audio mix, loudness, pauses, inserted clips, transcript. | ||
| 2 | + | ||
| 3 | +Usage: voice.py <pid> [--no-stt] | ||
| 4 | +Writes to the scratch dir: aroll.wav, silences.json, stt.json; prints a summary and the transcript. | ||
| 5 | +""" | ||
| 6 | +import json | ||
| 7 | +import re | ||
| 8 | +import subprocess | ||
| 9 | +import sys | ||
| 10 | + | ||
| 11 | +from common import inserted_clips, lufs, project_dir, timeline, total, work_dir | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +def mix_aroll(pid: str, tl: dict, dest) -> None: | ||
| 15 | + base = project_dir(pid) | ||
| 16 | + args, parts = ["ffmpeg", "-y", "-v", "error"], [] | ||
| 17 | + for i, c in enumerate(tl["aroll"]): | ||
| 18 | + d = c["out"] - c["in"] | ||
| 19 | + src = base / (c.get("src") or "source/" + _source_name(pid)) | ||
| 20 | + args += ["-ss", f"{c['in']:.3f}", "-t", f"{d:.3f}", "-i", str(src)] | ||
| 21 | + parts.append(f"[{i}:a]aresample=16000,aformat=channel_layouts=mono,volume={c.get('gain_db') or 0}dB," | ||
| 22 | + f"apad=whole_dur={d:.3f},atrim=0:{d:.3f}[a{i}]") | ||
| 23 | + n = len(tl["aroll"]) | ||
| 24 | + graph = ";".join(parts) + ";" + "".join(f"[a{i}]" for i in range(n)) + f"concat=n={n}:v=0:a=1[o]" | ||
| 25 | + subprocess.run(args + ["-filter_complex", graph, "-map", "[o]", str(dest)], check=True) | ||
| 26 | + | ||
| 27 | + | ||
| 28 | +def _source_name(pid: str) -> str: | ||
| 29 | + project = json.loads((project_dir(pid) / "project.json").read_text(encoding="utf-8")) | ||
| 30 | + return project["source"]["name"] | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +def silences(wav, noise="-38dB", min_d=0.5) -> list[list[float]]: | ||
| 34 | + r = subprocess.run(["ffmpeg", "-v", "info", "-i", str(wav), "-af", f"silencedetect=n={noise}:d={min_d}", | ||
| 35 | + "-f", "null", "-"], capture_output=True, text=True, encoding="utf-8", errors="replace") | ||
| 36 | + starts = [float(x) for x in re.findall(r"silence_start: (-?[\d.]+)", r.stderr)] | ||
| 37 | + ends = [float(x) for x in re.findall(r"silence_end: ([\d.]+)", r.stderr)] | ||
| 38 | + return [[max(0.0, a), b] for a, b in zip(starts, ends)] | ||
| 39 | + | ||
| 40 | + | ||
| 41 | +def transcribe(wav) -> list[dict]: | ||
| 42 | + from studio.modules.transcript.whisper import Engine | ||
| 43 | + out = [] | ||
| 44 | + Engine().transcribe(wav, "fr", lambda s: None, | ||
| 45 | + lambda seg, d: out.append({"s": round(seg.start, 2), "e": round(seg.end, 2), "t": seg.text}), | ||
| 46 | + lambda: None) | ||
| 47 | + return out | ||
| 48 | + | ||
| 49 | + | ||
| 50 | +def main() -> None: | ||
| 51 | + pid = sys.argv[1] | ||
| 52 | + tl, work = timeline(pid), work_dir(pid) | ||
| 53 | + wav = work / "aroll.wav" | ||
| 54 | + mix_aroll(pid, tl, wav) | ||
| 55 | + sil = silences(wav) | ||
| 56 | + (work / "silences.json").write_text(json.dumps(sil), encoding="utf-8") | ||
| 57 | + print(f"timeline {total(tl):.2f} s · voice {lufs(wav):.1f} LUFS") | ||
| 58 | + print("pauses >= 0.9 s:", [(round(a, 1), round(b - a, 2)) for a, b in sil if b - a >= 0.9]) | ||
| 59 | + print("inserted clips (no music under them):") | ||
| 60 | + for c in inserted_clips(tl): | ||
| 61 | + print(f" {c['start']:8.2f} -> {c['end']:8.2f} {c['src']}") | ||
| 62 | + print("part titles (short motions):") | ||
| 63 | + for m in tl["motion"]: | ||
| 64 | + if m["kind"] == "transition" or m["dur"] <= 1.5: | ||
| 65 | + print(f" {m['at']:8.2f} {m['title'][:70]}") | ||
| 66 | + if "--no-stt" in sys.argv: | ||
| 67 | + return | ||
| 68 | + lines = transcribe(wav) | ||
| 69 | + (work / "stt.json").write_text(json.dumps(lines, ensure_ascii=False, indent=0), encoding="utf-8") | ||
| 70 | + for l in lines: | ||
| 71 | + print(f"{l['s']:7.1f}-{l['e']:6.1f} {l['t']}") | ||
| 72 | + | ||
| 73 | + | ||
| 74 | +if __name__ == "__main__": | ||
| 75 | + main() | ||