Skip to content
Back to skills

Sound Design

ASecurity

Score a film to its beat grid: measure a track (beats.mjs) or synthesize one (score.mjs), cue SFX from the film, add voice, mix to -14 LUFS / -1 dBTP at exact length. Use for 'add music', 'sync to the beat', 'SFX', 'loudness'.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 7, 2026
designpythongonodedebugginggitapi

Works with

  • cursor
  • cli
  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 7, 2026

npx -y skills add yazzang-homelab/motion-studio --skill sound-design --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Sound Design?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Sound Design
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/yazzang-homelab-sound-design/badge)](https://www.skillsdirectory.com/skills/yazzang-homelab-sound-design)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: sound-design
description: "Score a film to its beat grid: measure a track (beats.mjs) or synthesize one (score.mjs), cue SFX from the film, add voice, mix to -14 LUFS / -1 dBTP at exact length. Use for 'add music', 'sync to the beat', 'SFX', 'loudness'."
argument-hint: "[track path | synthesize] [style]"
---

# Sound design (course step 09)

Sound is where a code-drawn clip starts to feel like a film. One timeline drives three lanes: downbeats carry scene
changes, beats carry SFX, and measured onset peaks place the accents exactly. Two paths: if the user supplies a track,
measure it and use it unchanged; if not, synthesize original music on the same grid as the picture.

Work in the film project root. Arguments, shown once:

<request>
$ARGUMENTS
</request>

The mixing details live in
`${CLAUDE_SKILL_DIR}/references/mixing.md` ([references/mixing.md](references/mixing.md)).

## 1. Music: measure or synthesize

| Situation | Command | Result |
|---|---|---|
| user supplied a track | `npm run beats -- audio/track.wav` (any format ffmpeg decodes) | `audio/beats.json` with measured `bpm`, `beats`, `downbeats`, `hits` |
| ambiguous tempo (half or double) | `npm run beats -- audio/track.wav --bpm-hint 120` | tempo search biased to the hint (30 to 300). Without a hint the JS engine searches 70 to 180 BPM, so a 60 BPM track reads as 120; with a hint the window widens (hints of 45, 60, 65, 200 and 250 recover the real tempo) |
| no Python / librosa | `npm run beats -- audio/track.wav --engine js` | built-in JS analysis (spectral flux, autocorrelation, beat tracking). `--engine auto` (the default) falls back to it by itself, also when `MOTION_PYTHON` points at a broken interpreter (it logs `MOTION_PYTHON=... is not usable` and continues); `--engine librosa` stops with that message |
| no track: synthesize | `npm run score -- --style pulse --bpm 120 --dur 12` | `audio/music.wav` + an analytic `audio/beats.json` (source `grid`) |

For a supplied track, set `studio.json` `audio.music` to its path (for example `"audio/track.wav"`) and `bpm` to the
measured value. Then never run plain `npm run score`: it writes to `audio.music` (overwriting the track) and replaces
`beats.json` with a synthetic grid. `npm run build` is safe because it calls `score.mjs --if-missing`, which keeps a
supplied or replaced track (its hash does not match `audio/music.meta.json`).
Confirm the user has the right to use the track (license or ownership) and note it in `docs/production.json` notes.

Score options (`node tools/score.mjs`):

| Flag | Values | Notes |
|---|---|---|
| `--style` | `pulse` (kick 4/4, claps, hats, sub bass, pluck arp) · `piano` (felt piano, sparse percussion) · `minimal` (ticks, clicks, soft kick; UI films) · `cinematic` (taiko-like hits, bowed ostinato, downbeat booms) | no static generic pads in any style |
| `--bpm`, `--dur` | numbers | default from `studio.json` |
| `--key`, `--mode` | `A`, `F#`, `Bb` ... (a trailing `m` means minor), `minor` / `major` | minor i-VI-III-VII, major I-V-vi-IV |
| `--drop BAR` | bar index | default about 40% of the length, riser into it |
| `--loop` | flag | seamless loop: no intro or ending, no drop, tails wrap to the start; default from `studio.json` `loop`. Use a duration that is a whole number of bars, or the tool warns that the seam misses a downbeat |
| `--seed N` | integer | same seed, same bytes |
| `--if-missing` | flag | used by `npm run build`: keep an existing WAV that is a supplied track, or a score whose settings still match `audio/music.meta.json`; regenerate (WAV, `beats.json`, sidecar) a score that `score.mjs` made when `duration`, `bpm`, style, key, mode, seed, loop or drop bar changed, and say what changed (`regenerated` in the JSON) |

Every run also writes `audio/music.meta.json` (the resolved settings plus the sha256 of the WAV): that is how
`--if-missing` tells a score it made from a track you supplied or replaced, which it never touches.

Optional `studio.json` `audio` keys fill the same options when the flag is absent (a flag wins): `style` (the template
ships `"pulse"`), `key`, `mode`, `seed`; `voiceId` is the default ElevenLabs voice for `voice.mjs`. Example:
`"audio": { "style": "cinematic", "key": "D", "mode": "minor", "seed": 7 }`.

The four styles are `pulse`, `piano`, `minimal` and `cinematic`. The arrangement opens with a sparse bar, builds,
drops on a downbeat and lands a final hit on the last downbeat that ends at least 0.5 s before the film ends, then a
decay tail; with `--loop` it is a steady groove that folds back onto itself instead. Length is exactly the film
duration.

`beats.json` notes: results are cached by the audio's sha256 in `audio/.cache/`, so re-runs on the same audio are
instant (`--no-cache` to force). Librosa has a cold start: the first run in a fresh Python environment takes about a
minute (numba compiles), later runs are quick. Do not read that minute as a hang; `--engine js` skips Python.
Downbeats are chosen by scoring the four beat phases on low-band onsets, because "every 4th beat" is one beat off when
a song starts with a pickup. Silence gives an empty beat list with a warning; the page then falls back to the BPM grid.

## 2. Picture on the grid

The film receives the grid as `ctx.grid` (factory) and `c.grid` (scene draw). Measured when `audio/beats.json` exists,
otherwise uniform at `studio.json` `bpm`.

| Call | Returns |
|---|---|
| `grid.beat(n)` | time of beat `n` (fractional `n` allowed: `beat(4.5)` is an eighth note later) |
| `grid.bar(n)` | time of downbeat `n` (bar starts) |
| `grid.index(t)` / `grid.nearest(t)` | beat index at `t` / time of the nearest beat |
| `grid.beatsIn(a, b)` / `grid.barsIn(a, b)` | beat or bar times inside `[a, b)` |
| `grid.isOnBeat(t, tol = 0.02)` | whether `t` is within `tol` seconds of a beat |
| `grid.hits` | measured accent peaks in seconds: frozen, sorted, deduplicated, inside `[0, duration)`; `[]` when the track has none |
| `grid.hitsIn(a, b)` | the hits inside `[a, b)`, the same window as `beatsIn` |

Rules: scene changes and state changes on downbeats (`grid.bar(n)`); UI actions, pops and clicks on beats; big accents
on measured hits when a track is supplied. Derive every time from the grid; never type a time the grid can give you,
or the picture drifts when the track or tempo changes. With a measured track `grid.bar(0)` is the first measured
downbeat, which can sit well after 0 s (a pickup or an intro), so spans written as bars move with it.

Measured hits (onset peaks, `hits` in `audio/beats.json`) reach the film as `grid.hits` and `grid.hitsIn(a, b)`, in the
factory (`ctx.grid`) and in every scene draw (`c.grid`). They are available even when the track has no tracked beats.
Do not fetch `audio/beats.json` yourself; the grid already carries it:

```js
// in the factory's cues: measured accents between seconds 8 and 10
cues: (grid) => [...grid.hitsIn(8, 10).map((t) => ({ t, type: 'snap', gain: 0.5 }))],
```

A synthesized score writes only its main accents to `hits` (for example the drop and the final hit).

## 3. Cues come from the film

Declare cues next to the picture they belong to, in the factory's `cues` (an array or a function of the grid):

```js
export default defineFilm((ctx) => ({
  scenes: [/* ... */],
  cues: (grid) => [
    ...[0, 1, 2, 3, 4].map((n) => ({ t: grid.bar(n), type: 'thump' })),       // scene changes on downbeats
    { t: grid.bar(1), type: 'whoosh', gain: 0.6 },                             // transition into scene 2: the peak lands on the cut
    { t: grid.beat(6), type: 'click', pan: 0.2 },                              // cursor press on a beat
    ...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({ t: grid.beat(8) + i * 0.06, type: 'pop', gain: 0.5, pitch: 1 + i * 0.03 })),
    { t: grid.bar(4), type: 'riser' },                                         // a riser STARTS on its cue ...
    { t: grid.bar(5), type: 'hit' },                                           // ... and arrives on the next hit/thump/snap
  ],
}));
```

Cue fields: `t` (seconds), `type`, `gain` (1), `pan` (-1 to 1, 0), `pitch` (playback-rate multiplier, 1). Aliases `sfx`
for `type` and `vol` for `gain` are accepted. Cues at negative or non-finite times are dropped. Types (`SFX_TYPES`):
`click tick pop thump whoosh swoosh riser hit chime type glitch snap`.

Anchor semantics: `t` is the moment the sound lands, not where the sample starts. `node tools/sfx.mjs --list` prints
each voice with its length and anchor.

| Type | What lands on `t` | Use |
|---|---|---|
| `click`, `tick`, `pop`, `thump`, `hit`, `chime`, `type`, `snap` | the transient (the sound starts a few milliseconds earlier) | the visual event: a press, an entrance, a lockup |
| `glitch` | its first fragment: it starts on `t` | a digital break, on the frame it appears |
| `whoosh`, `swoosh` | the pass-by peak (0.31 s and 0.20 s into the sound), so the sweep builds before `t` | put it on the cut or the move it carries |
| `riser` | nothing lands: it starts on `t` and swells into the next `hit`, `thump` or `snap` cue 0.4 to 4 s later, so it arrives on that impact; with no such cue in range it lasts 1.6 s | put it a bar or a beat before the impact, never on it |

A riser on the same time as its `hit` finds no landing cue (it needs one more than 0.35 s later) and runs 1.6 s past the
impact instead of into it. Put the riser cue earlier, at `grid.bar(n - 1)` for a hit on `grid.bar(n)`.

Staggered pops like the tile example above are intentional off-beat cues; the sync metric lists them, which is fine
as long as each one sits on its visual event.

## 4. SFX

```
npm run sfx                         # opens the film headless, reads __studio.cues(), writes audio/cues.json + audio/sfx.wav
node tools/sfx.mjs --dump-cues      # only write audio/cues.json (check times before synthesizing)
node tools/sfx.mjs --cues my.json   # synthesize from a hand-edited cue file instead of the film
```

Every voice is synthesized deterministically (per-cue seed from the cue's type, time and its rank among identical cues, plus
`--seed`, so adding, removing or reordering other cues leaves this cue's sound unchanged), panned with equal power,
summed on a float bus, limited to -1 dBFS, and the file is exactly the film duration (empty cues give silence of that
length; in a loop film a tail that runs past the end folds back to the start). To use recorded samples instead, put `audio/samples/<type>.wav` (also `.flac`, `.mp3`, `.ogg`) in the project: it
replaces the synthesized voice for that type (`--samples none` or `--no-samples` ignores the folder for one run). The loudest peak of the file is its anchor, and a sampled riser is
timed so that this peak lands on the arrival. Samples need a commercial-use license: your own recordings, CC0, or a
license you hold for a published film. Attribution-only licenses (CC BY) need the credit in the post;
NonCommercial and "personal use" packs are out. Record the source and license in `docs/production.json` notes.

## 5. Voice (optional, ElevenLabs)

1. The key goes in `.env` as `ELEVENLABS_API_KEY=...` (never in a prompt, a file you commit, or a screenshot). Refer
   to it by name: "the ElevenLabs key is ELEVENLABS_API_KEY in .env".
2. Write `audio/voice.json`: `[{ "t": 0.5, "text": "Meet Driftnote." }, { "t": 4.0, "text": "Notes that file themselves." }]`
   (optional per-line `voiceId`). Place lines on downbeats and leave the picture room for them.
3. `node tools/voice.mjs --dry-run` prints the plan and character count (no network, no cost). Show it to the user if
   the budget matters. A line with the same text, voice id and model as an earlier one is synthesized and billed once (the
   dry run marks it `repeat`; `billableChars` counts unique characters only).
4. `node tools/voice.mjs --voice-id <id>` synthesizes (cached per line in `audio/.cache/`), places each line at its
   `t` and writes `audio/voice.wav` at the exact film length.
5. Set `studio.json` `audio.voice` to `"audio/voice.wav"` after `voice.mjs` has written the file (`voice.mjs` prints
   the reminder but does not edit `studio.json`). While `audio.voice` is `null` the mix ignores the voice; with it
   set, `mix.mjs` ducks the music under the voice (`duck: true`). `audio.voiceId` is the default voice for lines
   without their own `voiceId`.

## 6. Mix and verify

```
npm run mix                                   # all formats: out/score.wav + out/<fmt>/final.mp4 + out/final.mp4
node tools/critique.mjs --format 9x16 --video out/9x16/final.mp4    # evidence in out/review/9x16/video/
```

`mix` takes 40 to 80 s: start it in the background and poll the log when the film is long or the machine busy (never wait
in the foreground for more than about 2 minutes).

Before it mixes, `mix.mjs` decodes and measures every stem. A music, voice or sfx stem that is shorter than the film by more
than 0.25 s, empty, or silent (peak below -80 dBFS) stops the mix with `refusing to mix: ... stem problem(s) do not fit the
N s film` and writes nothing: a stale `music.wav` from an earlier duration used to mix in as a silent tail. Regenerate the
stem (`npm run score`, `npm run sfx`, `npm run voice`); for a voice-over or jingle that is meant to be short, pass
`--allow-short-stems` (the missing part is silence). A silent sfx stem only warns, because a film without cues renders
silence; a stem longer than the film warns that its end is cut with no fade. The JSON result carries `stemInfo` (decoded
duration and peak per stem).

Targets: integrated -14 LUFS +-0.5, true peak at or below -1 dBTP, audio length equal to video length (never
`-shortest`). `--lufs` (or `audio.lufs`) must be -70 to -5, `--tp` (or `audio.truePeak`) -20 to 0; below -9 dBTP the
limiter path enforces the ceiling, and a peaky mix that cannot reach the loudness at a low ceiling ends in the
`master misses the target` warning instead of a crash. `mix.mjs` uses two-pass loudnorm and keeps its linear result only when the master lands within half the
tolerance of the target (+-0.25 LU by default); otherwise it falls back to gain plus an oversampled limiter that aims
at +-0.1 LU, then re-measures the muxed AAC. When a number is off, the report table in
[references/mixing.md](references/mixing.md) says what to change.

Sync check: `metrics.md` from `critique.mjs` (in both `out/review/<fmt>/` and `video/`) reports the share of cues within 25 ms of a
beat and lists the others.
Every listed cue must be an intentional hit on a visual event; move the rest onto `grid.beat(n)`.

Silence check: loudness cannot see a soundtrack that drops out for seconds (a stem that ends early still measures -14 LUFS after
the mix). `critique.mjs --video` therefore runs ffmpeg `silencedetect` (-50 dB, 1.0 s) on the mix and lists every silent span as a
P1 `audio-gap` (`silence 3.2 s at 00:08.1`), and `npm run deliver` fails the same span as check `silence`. Not a gap: a silent
intro (a span starting in the first 0.3 s), a fade-out that ends in silence (a span starting in the last 1.0 s) and intervals listed in
`studio.json` `critique.allowSilence: [[from, to], ...]` (seconds, `from < to`, each padded by 0.15 s). Fix the stem that ends early (`npm run score`, `sfx`, `voice`) and mix again.

Claude cannot listen. Before delivery, ask the user to play `out/final.mp4` once and log anything they hear in the next
critique round. For deeper work (stems, sync debugging), delegate to the `sound-designer` subagent.

Next: `/motion-studio:critique-loop` (the sound axis), then `/motion-studio:ship-formats`.

Files in this skill

  • SKILL.md15.2 KB
  • references/mixing.md9.1 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.