hlassiege/darylpublic Fork
⑂ dd91d90
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/daryl.git
git clone ssh://git@rickub.com/hlassiege/daryl.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

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.
hlassiege committed 2026-10-13T16:29:36+02:00 Browse files
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()