@homie-rocks/studio 0.48.4 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/app-starters/gathering/README.md +4 -0
  3. package/app-starters/gathering/app.json +162 -0
  4. package/app-starters/gathering/index.html +3 -0
  5. package/app-starters/gathering/map/main.json +1 -0
  6. package/app-starters/gathering/src/move.ts +3 -0
  7. package/app-starters/gathering/src/rules.ts +21 -0
  8. package/app-starters/gathering/src/view.ts +74 -0
  9. package/app-starters/giving/README.md +5 -0
  10. package/app-starters/giving/app.json +162 -0
  11. package/app-starters/giving/index.html +3 -0
  12. package/app-starters/giving/map/main.json +1 -0
  13. package/app-starters/giving/src/move.ts +3 -0
  14. package/app-starters/giving/src/rules.ts +21 -0
  15. package/app-starters/giving/src/view.ts +74 -0
  16. package/app-starters/jobs/README.md +4 -0
  17. package/app-starters/jobs/app.json +162 -0
  18. package/app-starters/jobs/index.html +3 -0
  19. package/app-starters/jobs/map/main.json +1 -0
  20. package/app-starters/jobs/src/move.ts +3 -0
  21. package/app-starters/jobs/src/rules.ts +21 -0
  22. package/app-starters/jobs/src/view.ts +74 -0
  23. package/app-starters/quiz/README.md +4 -0
  24. package/app-starters/quiz/app.json +162 -0
  25. package/app-starters/quiz/index.html +3 -0
  26. package/app-starters/quiz/map/main.json +1 -0
  27. package/app-starters/quiz/src/move.ts +3 -0
  28. package/app-starters/quiz/src/rules.ts +21 -0
  29. package/app-starters/quiz/src/view.ts +74 -0
  30. package/app-starters/welcome/app.json +25 -2
  31. package/app-starters/welcome/index.html +1 -1
  32. package/app-starters/welcome/src/view.ts +20 -8
  33. package/bin/homie-studio.mjs +20 -16
  34. package/functions/FUNCTIONS.md +1 -1
  35. package/guides/app/SKILL.md +178 -0
  36. package/guides/art/SKILL.md +154 -0
  37. package/guides/art/references/RIGHTS.md +20 -0
  38. package/guides/art/scripts/art.mjs +308 -0
  39. package/guides/models/SKILL.md +198 -0
  40. package/guides/models/references/RIGHTS.md +46 -0
  41. package/guides/models/references/models.json +175 -0
  42. package/guides/models/scripts/models.mjs +483 -0
  43. package/guides/music/SKILL.md +197 -0
  44. package/guides/music/references/COMPOSITION.md +59 -0
  45. package/guides/music/references/GAME-AUDIO.md +50 -0
  46. package/guides/music/references/RIGHTS.md +43 -0
  47. package/guides/music/scripts/lib/audio.mjs +258 -0
  48. package/guides/music/scripts/lib/eleven.mjs +260 -0
  49. package/guides/music/scripts/lib/studio.mjs +147 -0
  50. package/guides/music/scripts/music.mjs +644 -0
  51. package/guides/parts/SKILL.md +158 -0
  52. package/guides/plan/SKILL.md +126 -0
  53. package/guides/plan/references/CODEX.md +105 -0
  54. package/guides/plan/references/INTERVIEW.md +86 -0
  55. package/guides/playtest/SKILL.md +205 -0
  56. package/guides/playtest/references/METHOD.md +124 -0
  57. package/guides/playtest/references/REVIEWER.md +78 -0
  58. package/guides/playtest/scripts/lib/exit.mjs +31 -0
  59. package/guides/playtest/scripts/lib/judge.mjs +502 -0
  60. package/guides/playtest/scripts/lib/pixels.mjs +128 -0
  61. package/guides/playtest/scripts/lib/preflight.mjs +69 -0
  62. package/guides/playtest/scripts/playtest.mjs +870 -0
  63. package/guides/shop/SKILL.md +153 -0
  64. package/guides/sound/SKILL.md +156 -0
  65. package/guides/sound/references/GAME-SOUND.md +100 -0
  66. package/guides/sound/references/SCORE.md +131 -0
  67. package/guides/sound/references/TRAPS.md +93 -0
  68. package/guides/sound/scripts/lib/measure.mjs +252 -0
  69. package/guides/sound/scripts/lib/score.mjs +415 -0
  70. package/guides/sound/scripts/lib/sfx.mjs +222 -0
  71. package/guides/sound/scripts/lib/synth.mjs +509 -0
  72. package/guides/sound/scripts/sound.mjs +520 -0
  73. package/guides/studio-setup/SKILL.md +419 -0
  74. package/guides/style/SKILL.md +105 -0
  75. package/lib/app-needs.mjs +63 -0
  76. package/lib/authoring-guides.mjs +5 -4
  77. package/lib/build.mjs +5 -4
  78. package/lib/check-site.mjs +6 -6
  79. package/lib/check.mjs +18 -5
  80. package/lib/codex.mjs +95 -18
  81. package/lib/decisions.mjs +9 -7
  82. package/lib/dev.mjs +4 -2
  83. package/lib/doctor.mjs +1 -1
  84. package/lib/handoff.mjs +13 -8
  85. package/lib/mcp-tools.mjs +19 -17
  86. package/lib/mcp.mjs +1 -1
  87. package/lib/parts.mjs +6 -6
  88. package/lib/scaffold.mjs +45 -11
  89. package/lib/shop-links.mjs +2 -2
  90. package/lib/shop.mjs +5 -3
  91. package/lib/studio.mjs +7 -6
  92. package/lib/template-history.json +84 -0
  93. package/package.json +4 -4
  94. package/shop/SHOP.md +4 -0
  95. package/shop/shop.ts +4 -4
  96. package/worker/app-format.mjs +1 -1
  97. package/worker/build-id.mjs +1 -1
  98. package/worker/functions-schema.mjs +15 -0
  99. package/worker/index.mjs +2 -1
  100. package/worker/parts.mjs +4 -4
  101. package/worker/shop-page.mjs +22 -16
  102. package/worker/shop-rules.mjs +4 -3
  103. package/worker/shop.mjs +6 -2
  104. package/worker/site.mjs +4 -2
  105. package/worker/version.mjs +1 -1
@@ -0,0 +1,197 @@
1
+ ---
2
+ name: music
3
+ description: Make songs, themes and game scores for a Homie studio with ElevenLabs Music, through the creator's OWN ElevenLabs account — a composition plan for anything sung, a transcription check that every line is actually sung, mastering to a loudness target, seamless loops and stems for games — then a song page on the studio's site. It tells the person the plan, the rights and the credit cost before anything is rendered, and never spends past the budget they set. Use when someone in a studio asks for a song, a theme song, a jingle, background music, a game score, loops or stems.
4
+ compatibility: Node 22 and ffmpeg. ElevenLabs' own CLI (elevenlabs, signed in with elevenlabs auth login) or ELEVENLABS_API_KEY, on the creator's own ElevenLabs account.
5
+ metadata:
6
+ providers: elevenlabs
7
+ ---
8
+
9
+ # Music for a studio
10
+
11
+ A studio is the folder with `studio.json` (the `studio-setup` skill makes one). Songs live in
12
+ `music/<slug>/`, listed in `music/manifest.json`; a published entry is a page at
13
+ `/music/<slug>/` on the studio's own site. Everything below runs through one script in this
14
+ skill's folder: `scripts/music.mjs` (in Claude Code:
15
+ `node "${CLAUDE_PLUGIN_ROOT}/skills/music/scripts/music.mjs" <command>`; elsewhere, the path next
16
+ to this file). Run it from inside the studio. It needs Node 22 and ffmpeg. Every command takes
17
+ `--json`.
18
+
19
+ Finish in this turn: a render is one call that usually returns in a minute or two. Only stop early to
20
+ ask the person something (the go-ahead on the cost, a budget, an install).
21
+
22
+ ## 1. The provider, only now
23
+
24
+ A studio that never makes music is never asked about ElevenLabs. **Free first:** sound effects, a
25
+ chiptune or synth theme, a game score from chords and patterns, loops and stems that cost nothing and
26
+ need no account are the `sound` skill (synthesized on this computer). Use this skill for sung songs and
27
+ produced music from a model, when the person wants that and has (or will make) an ElevenLabs account.
28
+ Check when this skill starts:
29
+
30
+ ```sh
31
+ node <music.mjs> check
32
+ ```
33
+
34
+ It reports the road (the `elevenlabs` CLI signed in, or `ELEVENLABS_API_KEY` in the
35
+ environment), the plan, the credits left and when they reset, whether the account can go over,
36
+ the rights that plan gives, ffmpeg, and whether the studio's site has song pages.
37
+
38
+ Not connected? Offer ElevenLabs' own tools, never a copy of anyone's key or code:
39
+
40
+ - **The official CLI** (what this skill uses for music): `brew install elevenlabs/tap/elevenlabs`
41
+ (or `npm install -g @elevenlabs/cli`), then `elevenlabs auth login`. It opens ElevenLabs in the
42
+ browser; the person signs in once and the sign-in stays in the OS keychain. Nobody pastes a key.
43
+ Install the CLI yourself as part of the requested music work. An older CLI works; `brew upgrade elevenlabs` keeps it current
44
+ (1.4.0 on 2026-09-25).
45
+ - An existing API key in the environment still works, but never send a new person to find or set one.
46
+ Use the browser sign-in above.
47
+ - **ElevenLabs' plugin** for Claude Code (`/plugin marketplace add elevenlabs/plugin`, then
48
+ `/plugin install elevenlabs@elevenlabs`; in Codex `codex plugin marketplace add elevenlabs/plugin`)
49
+ brings their own skills and their hosted MCP (`https://api.elevenlabs.io/v1/mcp`, signed in on
50
+ ElevenLabs' page: speech, voices, transcription, agents). Their skills alone: `npx skills add
51
+ elevenlabs/skills`. It is optional here, and their MCP has no music tools: songs, stems and the
52
+ lyric check go through this skill's script, which keeps the budget, the receipts and the rights.
53
+ Their speech and sound-effect tools spend the same credits: tell the person the cost first, and the
54
+ Homie mod (Claude Code) and Homie's hooks (Codex) hold a generating tool (an `estimate_only` call is free).
55
+
56
+ Prefer the CLI to a raw API call or a pasted key; never call ElevenLabs' API with `curl` for a song
57
+ (the same request twice is twice the bill, and nothing records it).
58
+
59
+ No ffmpeg: offer `brew install ffmpeg` (macOS) or the system package; the person approves.
60
+
61
+ ## 2. Say the plan and the rights, plainly, before anything is made
62
+
63
+ From `check`, tell the person in two or three lines: the plan (tier), the credits left, and what
64
+ the plan allows. ElevenLabs' terms: **the free plan has no commercial licence** (no ads, sales or
65
+ monetised channels, and published copies credit "Eleven Music"); **paid plans include a
66
+ commercial licence** (not for Beta features) under their Terms and Prohibited Use Policy. The plan
67
+ **at render time** is what counts, and every render records it. Details:
68
+ `references/RIGHTS.md`.
69
+
70
+ ## 3. Write the song
71
+
72
+ Write `music/<slug>/spec.json` (shape and a full example: `references/COMPOSITION.md`): tempo,
73
+ key, global styles and what to avoid, and sections in whole bars, each either instrumental or
74
+ with its sung lines. Then:
75
+
76
+ ```sh
77
+ node <music.mjs> plan <slug> --spec music/<slug>/spec.json
78
+ ```
79
+
80
+ It builds the composition plan (every section a whole number of bars, so section starts are bar
81
+ lines that loops and video cuts can land on), checks ElevenLabs' limits (each section 3 s to
82
+ 120 s, lines up to 200 characters) and quotes the cost.
83
+
84
+ - **Anything sung goes through the plan's sections.** A prompt with lyrics beside it has come
85
+ back with nothing sung; `render --prompt` refuses words for that reason.
86
+ - A theme or a song: the hook inside the first 10 seconds, one or two sung lines per bar at
87
+ most, simple words that sing clearly.
88
+ - A game score: instrumental, a steady tempo, sections of 4, 8 or 16 bars that can each loop.
89
+ - Describe sound, not artists: no "in the style of" a real singer or band, no real person's name
90
+ or voice (ElevenLabs refuses them, and they are not ours to use).
91
+
92
+ ## 4. Price it, get the go-ahead, render once
93
+
94
+ ```sh
95
+ node <music.mjs> quote --seconds <s> [--vocals] [--budget <credits>]
96
+ ```
97
+
98
+ About **27.5 credits per second** of `music_v2` (measured on a paid account; every render then
99
+ records what it really cost, read off the account before and after). Tell the person the cost and
100
+ ask for the go-ahead and a cap. Then:
101
+
102
+ ```sh
103
+ node <music.mjs> budget <slug> --cap <credits they agreed to>
104
+ node <music.mjs> render <slug> --dry-run # free: the CLI validates the request and sends nothing
105
+ node <music.mjs> render <slug> --yes # the one paid render
106
+ ```
107
+
108
+ - `render` refuses with no budget, refuses anything that would pass the cap, and asks again
109
+ without `--yes`. If the ask costs more than the cap, say so and offer what fits: the longest
110
+ length within it (`quote --budget` works it out), or a shorter section that loops honestly to
111
+ length (a loop, called a loop). Never raise a cap yourself.
112
+ - ElevenLabs' balance can take a minute to show a render (measured: unchanged right after,
113
+ +445 a minute later). `render` waits up to 40 s for it; if it has not moved, the quote is
114
+ counted against the cap and the receipt is marked pending. `node <music.mjs> reconcile <slug>`
115
+ a minute later writes the real number. Never report a pending cost as zero.
116
+ - Never re-send a failed render on your own: the same request twice is twice the bill. A render
117
+ that was sent and never answered leaves `work/render-<n>.pending`; the script refuses to send
118
+ again until the person has looked at their ElevenLabs history and agrees (`--again`).
119
+ - Never buy credits, never change the plan, never turn on usage-based billing.
120
+
121
+ ## 5. Check that every line is sung
122
+
123
+ ```sh
124
+ node <music.mjs> lyrics <slug>
125
+ ```
126
+
127
+ ElevenLabs' own speech-to-text (Scribe) listens to the render, and every planned line is
128
+ matched against what it heard, in order. A line passes at 75 % of its words (sung contractions
129
+ drift: "I'll" comes back "I"). A **MISS** is a line the model did not sing, or sang as something
130
+ else. Tell the person; the fix is a simpler line (the key word on a strong beat, fewer syllables)
131
+ and another paid render, which needs their go-ahead. Update the lyrics shown on the page to what
132
+ is actually sung.
133
+
134
+ ## 6. Master it
135
+
136
+ ```sh
137
+ node <music.mjs> master <slug> # -14 LUFS integrated, -1 dBTP: the web and streaming
138
+ node <music.mjs> master <slug> --lufs -18 --tp -1.5 # a bed under game sound effects
139
+ node <music.mjs> analyze music/<slug>/<slug>.mp3 # duration, loudness, tempo, first beat
140
+ ```
141
+
142
+ Two-pass loudness, then a limiter, measured after on both the WAV master (kept off the site) and
143
+ the mp3 the page plays. Listen-worthy numbers go in your report, not adjectives.
144
+
145
+ ## 7. Loops and stems for games
146
+
147
+ ```sh
148
+ node <music.mjs> loop <slug> --bars 8 --from-bar 4 # a seamless loop cut on bar lines, WAV + OGG
149
+ node <music.mjs> stems <slug> --yes # paid, priced by measurement; ask first
150
+ ```
151
+
152
+ The loop is cut at the song's measured bar lines, the tail crossfaded into the head, and checked:
153
+ the jump at the wrap against the music's own sample steps, and the beat grid of the loop played
154
+ three times. Never ship a loop as mp3 (encoders pad the ends with silence). Using loops in a
155
+ game: `references/GAME-AUDIO.md`.
156
+
157
+ ## 8. The page
158
+
159
+ ```sh
160
+ node <music.mjs> add <slug> --title "<Title>" --blurb "<one line>" [--for-game <game id>] --publish
161
+ node <music.mjs> publish <slug>
162
+ ```
163
+
164
+ `add` writes the manifest entry from what is in `music/<slug>/` (the mp3 the page plays, the
165
+ master kept off the site, loops and stems offered for download, the lyrics, the plan and rights
166
+ at render time, the credits line). `publish` rebuilds and redeploys the site and checks the
167
+ page answers 200 and the audio answers a byte range. Without storage the site serves the song
168
+ itself (a song is a few MB; the limit is 25 MiB a file). A studio that has storage (`storage add`)
169
+ keeps its big media in its own R2: the deploy uploads the song (over 1 MiB, or left out of git),
170
+ reads it back, checks it by SHA-256 and serves it from R2 at the same address. If the studio is
171
+ not online yet, use the `publish` skill first. If `check` says the studio's `@homie-rocks/studio`
172
+ predates song pages, say so and update it.
173
+
174
+ ## 9. Tell the person
175
+
176
+ Four or five lines: the page link, the length, the credits it cost (measured) and what is left,
177
+ the plan and whether it may be used commercially, the lyric check result, the loudness. Commit
178
+ `music/` in the studio (the big audio files stay out of git by the studio's `.gitignore`; the
179
+ manifest, spec, plan and receipts go in).
180
+
181
+ ## Never
182
+
183
+ - Never print, paste or store a key; never ask for one in chat.
184
+ - Never render without the person's go-ahead on the cost, and never past their cap.
185
+ - Never claim rights the plan did not give, and never drop the "Eleven Music" credit on a
186
+ free-plan render.
187
+ - Never imitate a real artist, voice or person.
188
+
189
+ ### Existing provider credentials
190
+
191
+ The tools read `FAL_KEY` or `ELEVENLABS_API_KEY` first, then the plain single-line
192
+ key in `~/.homie/keys/fal-key` or `~/.homie/keys/elevenlabs-key`. They also accept
193
+ `~/.homie/workspace/fal-key` and `~/.homie/workspace/elevenlabs-key` from earlier
194
+ installs. Checks name the source, never the key. Keep these files private (mode
195
+ 0600); never copy them into a studio. ElevenLabs also accepts its CLI browser
196
+ sign-in and the existing Stripe Projects connection. Run the tool’s `check`
197
+ before declaring a provider disconnected.
@@ -0,0 +1,59 @@
1
+ # Composition plans
2
+
3
+ ElevenLabs Music (`music_v2`) takes either a short prompt or a **composition plan**: a list of
4
+ chunks, each with its own length, text, and styles. Anything with words goes through a plan.
5
+
6
+ ## The spec `plan` reads
7
+
8
+ ```json
9
+ {
10
+ "title": "Paper Lanterns",
11
+ "bpm": 118,
12
+ "key": "D minor",
13
+ "beatsPerBar": 4,
14
+ "styles": ["bright indie synth-pop", "punchy live drums", "warm female lead vocal, clear diction, close to the mic", "big hook"],
15
+ "avoid": ["rap", "heavy autotune", "spoken word"],
16
+ "sections": [
17
+ { "name": "Intro", "bars": 4, "instrumental": true, "styles": ["filtered arpeggio, no drums yet"] },
18
+ { "name": "Hook", "bars": 8, "lines": ["We light the paper lanterns", "and let the whole night see"], "styles": ["full drums", "stacked harmonies"] },
19
+ { "name": "Verse", "bars": 8, "lines": ["Ten more minutes on the rooftop", "the city humming under me", "every window is a story", "and none of them are asleep"] },
20
+ { "name": "Outro", "bars": 4, "instrumental": true, "styles": ["drums fall away", "ends on a held chord"] }
21
+ ]
22
+ }
23
+ ```
24
+
25
+ What `plan` does with it:
26
+
27
+ - **Whole bars.** A section of `bars` bars at `bpm` lasts `bars × beatsPerBar × 60 / bpm`
28
+ seconds; the boundaries are rounded to the millisecond so the total is exact. For `music_v2`
29
+ ElevenLabs holds every section to its length, so section starts are bar lines in the render.
30
+ - **Limits.** Each section 3 s to 120 s (at 120 BPM a bar is 2 s, so the shortest section is 2
31
+ bars); the whole song 3 s to 10 min; a line up to 200 characters.
32
+ - **Styles.** The first chunk carries the global styles, the tempo and the key (they set the whole
33
+ song); every later chunk repeats the tempo and adds its own. `avoid` becomes each chunk's
34
+ negative styles; an instrumental section also avoids vocals and says `{instrumental, no vocals}`
35
+ in its text (text in curly braces is a direction, not a lyric).
36
+ - **Lines.** A sung section's text is `[Name]` and its lines. About one line a bar at pop tempos,
37
+ two at most; more comes back rushed or skipped.
38
+
39
+ ## Traps, measured
40
+
41
+ - **A prompt with `lyrics_text` has come back with nothing sung.** Words go in the plan's
42
+ sections. `render --prompt` refuses words for this reason.
43
+ - **`output_format` is a query option, not a body field.** Put in the JSON body it is rejected
44
+ before anything is sent. The script passes `--output-format mp3_48000_192`.
45
+ - **Short hooks drift.** In one render a three-word hook came back as a different three words;
46
+ making the key word unambiguous (and on a strong beat) fixed it in the next render. The lyric
47
+ check (Scribe) is what catches this, not listening once.
48
+ - **Contractions drift.** "I'll wake up" came back "I wake up": the check passes lines at 75 % of
49
+ their words, and the page should show what is actually sung.
50
+ - **The cost is about 27.5 credits per second** of `music_v2` on a paid plan (1,015 credits for
51
+ 37 s and 5,204 for 189 s, measured). The quote uses this; the render records the real number off
52
+ the account.
53
+
54
+ ## What a render leaves
55
+
56
+ `music/<slug>/work/render-<n>.mp3` (the audio as rendered), `render-<n>.json` (the plan
57
+ ElevenLabs used, song metadata, and word timestamps: the model's own times for every sung
58
+ word, which the `video` skill uses for lyric type), `music/<slug>/render.json` (the latest),
59
+ `budget.json` (the cap and every call), and a line in `music/receipts.jsonl`.
@@ -0,0 +1,50 @@
1
+ # Music in a game
2
+
3
+ ## Loops
4
+
5
+ `music.mjs loop <slug> --bars <n> --from-bar <k>` cuts `n` bars starting at bar `k` of the
6
+ mastered song, on the song's measured bar lines, and makes the wrap seamless: the music that
7
+ would follow the loop's last sample is crossfaded (30 ms, equal power) into its first. It writes a
8
+ 16-bit WAV (exact length everywhere) and an Ogg (Vorbis, or Opus where ffmpeg has no Vorbis
9
+ encoder; smaller). Both decode to exactly `bars × beats × 60 / bpm` seconds.
10
+
11
+ Play it in WebAudio, never with an `<audio loop>` element (it gaps on the wrap):
12
+
13
+ ```js
14
+ const ctx = new AudioContext();
15
+ const buf = await ctx.decodeAudioData(await (await fetch('/music/theme/theme-loop-8bars.wav')).arrayBuffer());
16
+ const src = ctx.createBufferSource();
17
+ src.buffer = buf;
18
+ src.loop = true; // loopStart 0 and loopEnd 0 mean the whole buffer
19
+ const gain = ctx.createGain();
20
+ gain.gain.value = 0.6; // under the game's own sound effects
21
+ src.connect(gain).connect(ctx.destination);
22
+ src.start(); // after the player's first tap or key: browsers need a gesture
23
+ ```
24
+
25
+ - **Never an mp3 for a loop.** mp3 encoders add silence at both ends; the wrap clicks or gaps.
26
+ - Test the Ogg on an iPhone before relying on it; the WAV plays everywhere.
27
+ - To change intensity with the game (calm, chase, final seconds), make the song in sections of
28
+ the same tempo and key, loop each, and crossfade between loops on a bar line: the next bar
29
+ starts at `start + ceil((ctx.currentTime - start) / bar) * bar`.
30
+
31
+ ## Levels
32
+
33
+ - A menu or title theme: master to -14 LUFS.
34
+ - A bed under gameplay sound effects: -18 to -20 LUFS (`master --lufs -18 --tp -1.5`), so hits
35
+ and pickups stay on top without the game turning its own sounds up.
36
+ - Stingers (a win, a round starting): a separate short instrumental render (3 to 5 s) at the
37
+ same tempo and key as the bed.
38
+
39
+ ## Stems
40
+
41
+ `music.mjs stems <slug> --yes` asks ElevenLabs to split the master into stems (vocals, drums,
42
+ bass, other). It is paid and its price is measured off the account, so it needs the person's
43
+ go-ahead and fits inside the song's budget. Stems let a game drop the vocals under dialogue or
44
+ bring the drums in when the action starts.
45
+
46
+ ## The page
47
+
48
+ A song entry with `--for-game <id>` is a score: its page links the game, and its loops and stems
49
+ are listed for download so another studio's game can use them (under the song's licence: say
50
+ what it is in `credits`).
@@ -0,0 +1,43 @@
1
+ # Plans, rights and receipts
2
+
3
+ ## What ElevenLabs allows (read 2026-09-29)
4
+
5
+ From ElevenLabs' help centre, "Can I publish the content I generate on the platform?":
6
+
7
+ - **Free plan:** no commercial licence. Nothing made on it may be used commercially (ads, sales,
8
+ monetised channels, a paid game). If it is published at all, it credits ElevenLabs by naming
9
+ "Eleven Music".
10
+ - **Paid plans (Starter and up):** a commercial licence is included, except for Beta services,
11
+ provided the person has the rights to what they put in (their lyrics, their references) and
12
+ follows the Terms of Service and the Prohibited Use Policy.
13
+
14
+ Terms change: when a person is about to publish commercially, read the current page
15
+ (https://help.elevenlabs.io/hc/en-us/articles/13313564601361) and say what it says today.
16
+
17
+ ## The plan at render time is what counts
18
+
19
+ `render` reads the plan before it sends anything and writes it into the render's receipt and
20
+ into the manifest entry (`rights.plan`, `rights.commercial`, `rights.attribution`). The song page
21
+ shows it in plain words. A render made on the free plan stays under free-plan terms after an
22
+ upgrade: to use it commercially, render it again on the paid plan.
23
+
24
+ ## What never goes in
25
+
26
+ - A real person's name, voice or likeness, or "in the style of" a real artist.
27
+ - Lyrics the person does not have the rights to (someone else's song).
28
+ - A key, token or password, anywhere in the studio.
29
+
30
+ ## Receipts
31
+
32
+ Every paid call leaves three records:
33
+
34
+ - `music/<slug>/budget.json`: the cap the person agreed to, who agreed, and every call with its
35
+ cost;
36
+ - `music/receipts.jsonl`: one line per call (provider, model, the song id, credits quoted and
37
+ measured, the plan and rights, the file it made);
38
+ - optionally a team ledger: set `HOMIE_SPEND_LEDGER=<file>` (and `HOMIE_SPEND_ATTRIBUTION`) and
39
+ each call is also appended there.
40
+
41
+ Credits are measured off the account (used before and after the call). Another job spending on
42
+ the same account at the same moment shows up in that number; the receipt keeps the quote beside
43
+ it.
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Audio facts measured off the file, with ffmpeg doing the decoding and plain
3
+ * JavaScript doing the arithmetic (no Python, no native add-ons): loudness, an
4
+ * onset envelope, a beat grid, the lag between two recordings.
5
+ */
6
+ import { spawnSync } from 'node:child_process';
7
+
8
+ export function run(cmd, args, { input, maxBuffer = 1024 * 1024 * 1024, timeout = 10 * 60_000 } = {}) {
9
+ const r = spawnSync(cmd, args, { input: typeof input === 'string' ? Buffer.from(input) : input, maxBuffer, timeout });
10
+ if (r.error) throw new Error(`${cmd} could not run: ${r.error.message}`);
11
+ return { code: r.status ?? 1, stdout: r.stdout ?? Buffer.alloc(0), stderr: (r.stderr ?? Buffer.alloc(0)).toString('utf8') };
12
+ }
13
+
14
+ export function need(cmd) {
15
+ const r = spawnSync(cmd, ['-version'], { encoding: 'utf8' });
16
+ return !r.error && r.status === 0;
17
+ }
18
+
19
+ /** Container and stream facts: duration (s), audio rate/channels, video size/fps/frames. */
20
+ export function probe(file) {
21
+ const r = run('ffprobe', ['-v', 'error', '-show_entries', 'format=duration:stream=codec_type,codec_name,sample_rate,channels,width,height,r_frame_rate,nb_frames', '-of', 'json', file]);
22
+ if (r.code !== 0) throw new Error(`ffprobe could not read ${file}: ${r.stderr.trim().split('\n').pop()}`);
23
+ const j = JSON.parse(r.stdout.toString('utf8'));
24
+ const a = (j.streams ?? []).find((s) => s.codec_type === 'audio');
25
+ const v = (j.streams ?? []).find((s) => s.codec_type === 'video');
26
+ const [fn, fd] = String(v?.r_frame_rate ?? '0/1').split('/').map(Number);
27
+ return {
28
+ duration: Number(j.format?.duration ?? NaN),
29
+ audio: a ? { codec: a.codec_name, rate: Number(a.sample_rate), channels: Number(a.channels) } : null,
30
+ video: v ? { codec: v.codec_name, width: v.width, height: v.height, fps: fd ? fn / fd : null, frames: v.nb_frames ? Number(v.nb_frames) : null } : null,
31
+ };
32
+ }
33
+
34
+ /** Mono float samples at `rate` (optionally a window and a filter chain). */
35
+ export function decodeMono(file, { rate = 22050, start = null, duration = null, filter = null } = {}) {
36
+ const args = ['-hide_banner', '-loglevel', 'error'];
37
+ if (start !== null) args.push('-ss', String(start));
38
+ if (duration !== null) args.push('-t', String(duration));
39
+ args.push('-i', file, '-vn', '-ac', '1', '-ar', String(rate));
40
+ if (filter) args.push('-af', filter);
41
+ args.push('-f', 'f32le', '-');
42
+ const r = run('ffmpeg', args);
43
+ if (r.code !== 0) throw new Error(`ffmpeg could not decode ${file}: ${r.stderr.trim().split('\n').pop()}`);
44
+ const b = r.stdout;
45
+ return new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength - (b.byteLength % 4)));
46
+ }
47
+
48
+ /** EBU R128: integrated loudness (LUFS), loudness range (LU) and true peak (dBTP). */
49
+ export function loudness(file) {
50
+ const r = run('ffmpeg', ['-hide_banner', '-nostats', '-i', file, '-af', 'ebur128=peak=true', '-f', 'null', '-']);
51
+ const text = r.stderr;
52
+ const sum = text.slice(text.lastIndexOf('Summary:'));
53
+ const num = (re) => { const m = re.exec(sum); return m ? Number(m[1]) : null; };
54
+ return { lufs: num(/I:\s+(-?[\d.]+|-inf) LUFS/), lra: num(/LRA:\s+(-?[\d.]+) LU/), truePeak: num(/Peak:\s+(-?[\d.]+|-inf) dBFS/) };
55
+ }
56
+
57
+ /* ---------------------------------------------------------------- FFT */
58
+
59
+ /** In-place radix-2 FFT (re, im of a power-of-two length). */
60
+ export function fft(re, im, inverse = false) {
61
+ const n = re.length;
62
+ for (let i = 1, j = 0; i < n; i++) {
63
+ let bit = n >> 1;
64
+ for (; j & bit; bit >>= 1) j ^= bit;
65
+ j ^= bit;
66
+ if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; }
67
+ }
68
+ for (let len = 2; len <= n; len <<= 1) {
69
+ const ang = (2 * Math.PI / len) * (inverse ? 1 : -1);
70
+ const wr = Math.cos(ang); const wi = Math.sin(ang);
71
+ for (let i = 0; i < n; i += len) {
72
+ let cr = 1; let ci = 0;
73
+ for (let k = 0; k < len / 2; k++) {
74
+ const ar = re[i + k]; const ai = im[i + k];
75
+ const br = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci;
76
+ const bi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr;
77
+ re[i + k] = ar + br; im[i + k] = ai + bi;
78
+ re[i + k + len / 2] = ar - br; im[i + k + len / 2] = ai - bi;
79
+ const t = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = t;
80
+ }
81
+ }
82
+ }
83
+ if (inverse) for (let i = 0; i < n; i++) { re[i] /= n; im[i] /= n; }
84
+ }
85
+
86
+ const pow2 = (n) => 1 << Math.ceil(Math.log2(Math.max(2, n)));
87
+
88
+ /**
89
+ * Spectral-flux onset envelope: log-magnitude rises summed over a band, the
90
+ * slow trend removed. Returns { env, rate } (envelope values per second).
91
+ */
92
+ export function onsetEnvelope(x, sr, { hop = 256, nfft = 1024, lo = 30, hi = 8000 } = {}) {
93
+ const frames = Math.max(0, 1 + Math.floor((x.length - nfft) / hop));
94
+ const win = new Float32Array(nfft).map((_, i) => 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / nfft));
95
+ const kLo = Math.max(1, Math.floor((lo * nfft) / sr)); const kHi = Math.min(nfft / 2, Math.ceil((hi * nfft) / sr));
96
+ const env = new Float32Array(frames);
97
+ let prev = new Float32Array(kHi - kLo + 1);
98
+ const re = new Float64Array(nfft); const im = new Float64Array(nfft);
99
+ for (let f = 0; f < frames; f++) {
100
+ for (let i = 0; i < nfft; i++) { re[i] = x[f * hop + i] * win[i]; im[i] = 0; }
101
+ fft(re, im);
102
+ const cur = new Float32Array(kHi - kLo + 1);
103
+ let flux = 0;
104
+ for (let k = kLo; k <= kHi; k++) {
105
+ const m = Math.log1p(20 * Math.hypot(re[k], im[k]));
106
+ cur[k - kLo] = m;
107
+ if (f > 0) flux += Math.max(0, m - prev[k - kLo]);
108
+ }
109
+ env[f] = flux; prev = cur;
110
+ }
111
+ // Frame f describes the window centred at (f * hop + nfft / 2) samples.
112
+ // Remove a half-second moving average so sustained loud passages don't read as onsets.
113
+ const rate = sr / hop; const w = Math.max(1, Math.round(rate / 2));
114
+ const out = new Float32Array(frames);
115
+ let acc = 0;
116
+ for (let i = 0; i < frames; i++) {
117
+ acc += env[i]; if (i >= w) acc -= env[i - w];
118
+ const mean = acc / Math.min(i + 1, w);
119
+ out[i] = Math.max(0, env[i] - mean);
120
+ }
121
+ return { env: out, rate, offset: nfft / 2 / sr };
122
+ }
123
+
124
+ /**
125
+ * A sharper onset envelope for WHERE the hits are (spectral flux is steadier for
126
+ * HOW FAST they come): the rise of log energy in short windows, centred.
127
+ */
128
+ export function energyOnsets(x, sr, { win = 0.012, hop = 0.004 } = {}) {
129
+ const w = Math.max(8, Math.round(win * sr)); const h = Math.max(1, Math.round(hop * sr));
130
+ const frames = Math.max(0, 1 + Math.floor((x.length - w) / h));
131
+ const e = new Float32Array(frames);
132
+ for (let f = 0; f < frames; f++) {
133
+ let s = 0; for (let i = f * h; i < f * h + w; i++) s += x[i] * x[i];
134
+ e[f] = Math.log10(s / w + 1e-10);
135
+ }
136
+ const env = new Float32Array(frames);
137
+ for (let f = 1; f < frames; f++) env[f] = Math.max(0, e[f] - e[f - 1]);
138
+ // Credit the rise to the window it completes in: the hit sits near the newer window's centre.
139
+ return { env, rate: sr / h, offset: w / 2 / sr };
140
+ }
141
+
142
+ /**
143
+ * A beat grid off an onset envelope: tempo by autocorrelation (or a given
144
+ * `bpm`), phase by summing the envelope on a comb. Confidence is how far the
145
+ * best comb stands above the average one (1 = no preference, 2+ = clear).
146
+ */
147
+ export function beatGrid(env, rate, { bpm = null, lo = 70, hi = 180, from = 0, to = null, offset = 0 } = {}) {
148
+ const n = env.length;
149
+ const end = to === null ? n / rate + offset : to;
150
+ const at = (t) => Math.round((t - offset) * rate);
151
+ const comb = (period, ph) => {
152
+ let sum = 0; let count = 0;
153
+ for (let t = from + ph; t < end; t += period) {
154
+ const i = at(t);
155
+ if (i >= 0 && i < n) { sum += env[i] + 0.5 * ((env[i - 1] ?? 0) + (env[i + 1] ?? 0)); count++; }
156
+ }
157
+ return count ? sum / count : 0;
158
+ };
159
+ let period;
160
+ if (!bpm) {
161
+ const minLag = Math.floor((60 / hi) * rate); const maxLag = Math.ceil((60 / lo) * rate);
162
+ let best = -Infinity; let bestLag = minLag;
163
+ const mean = env.reduce((a, b) => a + b, 0) / Math.max(1, n);
164
+ for (let lag = minLag; lag <= Math.min(maxLag, n - 1); lag++) {
165
+ let s = 0;
166
+ for (let i = 0; i + lag < n; i++) s += (env[i] - mean) * (env[i + lag] - mean);
167
+ const bpmHere = 60 / (lag / rate);
168
+ const prior = Math.exp(-0.5 * (Math.log2(bpmHere / 120) / 1.0) ** 2); // octave errors lean toward ~120
169
+ const score = s * (0.6 + 0.4 * prior);
170
+ if (score > best) { best = score; bestLag = lag; }
171
+ }
172
+ // The integer lag is coarse (one envelope frame); settle the period on the comb itself, within that frame.
173
+ let bestP = bestLag / rate; let bestScore = -Infinity;
174
+ for (let k = -20; k <= 20; k++) {
175
+ const p = (bestLag + k / 20) / rate;
176
+ let top = 0;
177
+ for (let s = 0; s < 48; s++) top = Math.max(top, comb(p, (s / 48) * p));
178
+ if (top > bestScore) { bestScore = top; bestP = p; }
179
+ }
180
+ period = bestP;
181
+ bpm = 60 / period;
182
+ } else period = 60 / bpm;
183
+ const steps = 96; let bestPhase = 0; let bestSum = -Infinity; let total = 0;
184
+ for (let s = 0; s < steps; s++) {
185
+ const ph = (s / steps) * period;
186
+ const v = comb(period, ph);
187
+ total += v;
188
+ if (v > bestSum) { bestSum = v; bestPhase = ph; }
189
+ }
190
+ const avg = total / steps;
191
+ const beats = [];
192
+ for (let t = from + bestPhase; t < end; t += period) beats.push(+t.toFixed(4));
193
+ return { bpm: +bpm.toFixed(3), period, phase: from + bestPhase, beats, confidence: +(bestSum / (avg || 1)).toFixed(2) };
194
+ }
195
+
196
+ /** The beat grid of a file, with an optional low band (the kick) to settle half-beat ambiguity. */
197
+ export function beatsOf(file, { bpm = null, kick = true, start = null, duration = null } = {}) {
198
+ const sr = 22050;
199
+ const x = decodeMono(file, { rate: sr, start, duration });
200
+ // Tempo off spectral flux (steady); phase off short-window energy rises (sharp), full band and the kick band.
201
+ const full = onsetEnvelope(x, sr);
202
+ const tempo = beatGrid(full.env, full.rate, { bpm, offset: full.offset });
203
+ // Music made to a stated tempo is almost always a whole number of BPM: a measurement within 0.15 of one is that number.
204
+ if (!bpm && Math.abs(tempo.bpm - Math.round(tempo.bpm)) < 0.15) tempo.bpm = Math.round(tempo.bpm);
205
+ const sharp = energyOnsets(x, sr);
206
+ const grid = beatGrid(sharp.env, sharp.rate, { bpm: tempo.bpm, offset: sharp.offset });
207
+ if (!kick) return { ...grid, tempoConfidence: tempo.confidence, duration: x.length / sr };
208
+ const lowX = decodeMono(file, { rate: sr, start, duration, filter: 'lowpass=f=160,lowpass=f=160' });
209
+ const low = energyOnsets(lowX, sr, { win: 0.02, hop: 0.004 });
210
+ const kg = beatGrid(low.env, low.rate, { bpm: tempo.bpm, offset: low.offset });
211
+ // Prefer the kick's phase when it is clearer than the full-band one (it settles half-beat doubt).
212
+ const useKick = kg.confidence >= grid.confidence;
213
+ return { ...(useKick ? kg : grid), tempoConfidence: tempo.confidence, fullConfidence: grid.confidence, kickConfidence: kg.confidence, phaseFrom: useKick ? 'kick' : 'full', duration: x.length / sr };
214
+ }
215
+
216
+ /**
217
+ * The lag of `b` against `a` in seconds (positive: b is late), by GCC-PHAT on
218
+ * band-limited signals, with the peak's height over the median (above ~8 is a
219
+ * clear answer; a pass-through copy reads 40+).
220
+ */
221
+ export function lagBetween(a, b, sr, { maxLagS = 0.6 } = {}) {
222
+ const n = Math.min(a.length, b.length);
223
+ const N = pow2(2 * n);
224
+ const ar = new Float64Array(N); const ai = new Float64Array(N); const br = new Float64Array(N); const bi = new Float64Array(N);
225
+ for (let i = 0; i < n; i++) { ar[i] = a[i]; br[i] = b[i]; }
226
+ fft(ar, ai); fft(br, bi);
227
+ const rr = new Float64Array(N); const ri = new Float64Array(N);
228
+ const kLo = Math.floor((200 * N) / sr); const kHi = Math.ceil((4000 * N) / sr);
229
+ for (let k = 0; k < N; k++) {
230
+ const f = k <= N / 2 ? k : N - k;
231
+ if (f < kLo || f > kHi) continue;
232
+ // B * conj(A): its peak sits at the lag of b behind a.
233
+ const xr = br[k] * ar[k] + bi[k] * ai[k]; const xi = bi[k] * ar[k] - br[k] * ai[k];
234
+ const m = Math.hypot(xr, xi) + 1e-12;
235
+ rr[k] = xr / m; ri[k] = xi / m;
236
+ }
237
+ fft(rr, ri, true);
238
+ const maxLag = Math.floor(maxLagS * sr);
239
+ let best = -Infinity; let bestLag = 0; const mags = [];
240
+ for (let l = -maxLag; l <= maxLag; l++) {
241
+ const v = rr[(l + N) % N];
242
+ mags.push(Math.abs(v));
243
+ if (v > best) { best = v; bestLag = l; }
244
+ }
245
+ mags.sort((x, y) => x - y);
246
+ const median = mags[Math.floor(mags.length / 2)] || 1e-12;
247
+ return { lagS: bestLag / sr, peak: +(best / median).toFixed(1) };
248
+ }
249
+
250
+ /** RMS in dBFS over windows of `win` seconds. */
251
+ export function rmsDb(x, sr, win = 0.05) {
252
+ const w = Math.max(1, Math.round(win * sr)); const out = [];
253
+ for (let i = 0; i + w <= x.length; i += w) {
254
+ let s = 0; for (let j = i; j < i + w; j++) s += x[j] * x[j];
255
+ out.push(10 * Math.log10(s / w + 1e-12));
256
+ }
257
+ return out;
258
+ }