Skip to content

Commit bf21184

Browse files
authored
Merge pull request #710 from PipFoweraker/fable/music-session-1
feat(music): Fable music session 1 -- composed adaptive score, wired + capture pipeline (carries #682)
2 parents 1e0a3a0 + ca69058 commit bf21184

89 files changed

Lines changed: 6127 additions & 12 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.pre-commit-config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ repos:
1313
- id: check-json
1414
- id: check-added-large-files
1515
args: ['--maxkb=1000']
16+
# Music tracks are legitimately >1MB; committed game/tool audio is
17+
# deliberate (storage-is-cheap policy). Guard stays for everything else.
18+
exclude: '^(godot/assets/audio/|tools/music/captures/game/).*\.(ogg|mp3|wav)$'
1619
- id: check-case-conflict
1720
- id: check-merge-conflict
1821
- id: detect-private-key

CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ runtime — the old Python bridge is gone). Python exists only for CI/tooling in
4545
- **ASCII-only:** no non-ASCII chars in `.py/.md/.json/.yaml/.txt/.cfg/.sh`
4646
(no smart quotes, em-dashes as `--`, no emoji in source/docs). `enforce-standards`
4747
blocks the commit otherwise.
48+
- **Agents: commit in the FOREGROUND and make zero tool calls while hooks
49+
run.** The session harness rewrites `.claude/settings.local.json` on tool
50+
activity; pre-commit has that file stashed during the run, sees a tracked
51+
file change mid-hook, reports "Stashed changes conflicted with hook
52+
auto-fixes... Rolling back", and aborts -- and the rollback also DISCARDS
53+
any working-tree edits made during the run. Background commits + parallel
54+
work = repeated mystery failures (cost 4 attempts on 2026-07-19).
4855

4956
## Git workflow
5057
- Branch from **freshly-fetched** `origin/main`, not a stale local ref:

dev-blog/music-session-1.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Music session 1 -- from hummed leitmotif to a wired adaptive score
2+
3+
> Dev-history capture (2026-07-17/19, the Fable music session). How P(Doom)1
4+
> went from "no composed music" to a five-tier adaptive score playing in-game,
5+
> in one long human-AI listening loop. Working artifacts live in `tools/music/`;
6+
> taste law in `tools/music/DIRECTION_NOTES.md`; raw judgment exports in
7+
> `tools/music/jukebox_notes*.json`.
8+
9+
## The method: judgment rounds
10+
11+
The whole session ran on one loop: Claude composes Strudel patches (browser
12+
live-coding, hearable in seconds) -> Pip listens on headphones and writes
13+
unfiltered notes -> the notes are decoded into RULINGS (logged, append-only)
14+
-> the rulings drive the next iteration. Pip is the taste authority; the
15+
model is the instrument-builder and secretariat. Two full jukebox judgment
16+
rounds happened in a single evening, and the second round's notes were
17+
audibly more precise than the first -- the listening skill builds with the
18+
score.
19+
20+
Design rulings that came out of ROUND 1: doom's weight lives in dark and
21+
middle registers (its heralds may sing anywhere); harsh discordance is an
22+
EVENT (miniboss flavour), not a bed; the tempo-ratchet (the live-band
23+
*phwar*, everyone dances faster); victory might not be fanfare at all.
24+
ROUND 2: layers must taper or ring out, never hard-cut; elements may
25+
germinate over very long spans ("seeds of my own destruction"); respite
26+
states need "A-team solidity" -- no stressors, but structural reassurance;
27+
and M4 grew a metal trajectory ("the bassist catches the train... we all
28+
gain GRUNGE together") that independently converged on the Mick Gordon
29+
lineage the design docs had already flagged.
30+
31+
## The discoveries
32+
33+
- **The beloved reference decoded.** Pip's most-listened track ("People Of
34+
The Drifting Houses", Master Musicians of Bukkake) hides a 7-beat bar
35+
grouped 3+2+2 at ~129 bpm. Found by Demucs drum-stem isolation + folding
36+
53 s of onsets at candidate meters: contrast for 7 was double any other
37+
grouping. Pip had hummed the groove from memory first; the analysis
38+
matched his transcription. That cell now seeds the taiko/catastrophe
39+
rhythm vocabulary.
40+
- **The score is a cast.** Six characters with hard timbre boundaries
41+
(CASTING_SHEET.md): humans are organic and micro-timed; the rivals are
42+
the ONLY purely synthetic voice and never harmonize with us; doom has no
43+
theme of its own -- it corrupts ours. Victory is the green cell allowed
44+
to play once, pure.
45+
- **Pip is a performer.** Deep chanting background -> the math-chant
46+
conceit (Greek-letter litanies, Latin machine-liturgy, math-phoneme
47+
vocables) has a voice, with a hard respect line: real sutras and personal
48+
practice stay out of the game.
49+
50+
## The pipeline surprise: no OBS needed
51+
52+
The plan was OBS system-audio capture. The shipped result is better: a
53+
Playwright script drives strudel.cc in a muted offscreen browser and records
54+
the WebAudio graph itself (every connect() to the destination is duplicated
55+
into a MediaRecorder tap). Captures are digitally clean, and the rig runs
56+
happily while the machine is being used for a voice call -- nothing leaks in
57+
either direction. One trap cost a full run: navigating to a strudel URL that
58+
differs only in the #hash is a same-document navigation, so eleven takes
59+
silently recorded the same patch. The tell was eleven identical volumedetect
60+
readings; the fix verifies editor content per take.
61+
62+
Post-processing is exact rather than eyeballed: the WebAudio clock is
63+
sample-accurate, so a 32-bar loop at 104 bpm is cut to precisely 73.846 s
64+
from the onset-detected downbeat, the loop tail's reverb spill is baked into
65+
the loop head with an equal-power crossfade (the file boundary IS the loop
66+
point, per the drop-in kit), and everything lands at -16 LUFS ogg.
67+
68+
## Where it ended up
69+
70+
`music_manager.gd` now plays composed beds in all five adaptive tier slots
71+
(C major at 104 souring through C dorian, then D dorian at 96 for the
72+
endgame), the previously-silent VICTORY slot has "The off switch worked
73+
(quiet dawn)", DEFEAT has the papers-please trudge Pip loved on first
74+
listen, and the menu ducks into the "Checkpoint saved" respite cue. The
75+
engine side (PR #682's AudioStreamInteractive tier crossfades) needed zero
76+
code changes -- wiring the score was a data edit, as designed.
77+
78+
Still ahead: real stems from real players (COMMISSION_LIST.md -- taiko,
79+
shakuhachi, upright bass, atarigane, Pip's voice), per-tier BASE/PULSE/
80+
WEIRD/FIRE layer splits, the trailer long-form, and the metal question:
81+
when the humans finally go loud, what instrument are they allowed to be?

docs/audio/MUSIC_DESIGN.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# P(Doom)1 -- Music design spec (adaptive doom-band music)
2+
3+
> Music is the AUDIO expression of the existing doom-intensity spec
4+
> (`docs/art/PALETTE_AND_DOOM_INTENSITY.md`). Same two axes, same five bands, same
5+
> "cozy competence sliding toward existential dread" arc. This document is the reference
6+
> for the musicians composing stems and for anyone touching
7+
> `godot/autoload/music_manager.gd`. Companion docs: `STEM_CATALOGUE.md` (what audio we
8+
> own today), `TRACK_ANALYSIS.md` (the taste reference method).
9+
10+
## 1. The model in one paragraph
11+
12+
Mick-Gordon / DOOM-style adaptive music: a small set of MUSIC TIERS (moods), each built
13+
from LAYERED STEMS that add/subtract as doom rises. The game's doom value (0-100) is
14+
read -- never written -- by the music system, quantized to the canonical doom bands, and
15+
mapped to a tier. Tier changes crossfade horizontally (mood shift); within a tier, stems
16+
stack vertically (intensity texture). Players should be able to close their eyes and
17+
know roughly where the doom meter sits.
18+
19+
## 2. Axes -> music mapping
20+
21+
The art spec defines two independent axes plus combined bands. Each gets a musical
22+
dimension:
23+
24+
| Axis (art spec) | Visual expression | MUSICAL expression |
25+
|---|---|---|
26+
| A. Catastrophe (amber -> red -> fire) | glow sours, strobes, literal fire | RHYTHM: percussive density and drive. Cosy = sparse/ambient pulse; alarmed = insistent percussion; fire = a dedicated "things are on fire" layer (alarm-adjacent textures, driving low end) entering at high tiers only |
27+
| B. Weirdness (green -> blue -> violet) | clean -> glitchy -> eldritch glow | TIMBRE: clean/tonal instruments -> detuned, bit-crushed, glitch-stuttered -> dissonant clusters, wrong-scale intervals, reversed/smeared textures |
28+
| Doom bands (0-4) | which register the office renders in | WHICH LAYERS ARE LIVE + overall mood/key territory |
29+
30+
Discipline rule, borrowed from the art spec's lighting rule ("at most ONE ambient +
31+
ONE weird glow source"): at most ONE rhythmic driver and ONE weird/timbral accent
32+
prominent at a time. Do not stack every layer at full volume even at terminal doom --
33+
terminal reads as oppressive SPACE plus the fire layer, not as maximum loudness.
34+
Purple is expensive; so is dissonance. Reserve overt eldritch harmony for tiers 3-4.
35+
36+
## 3. Music tiers (the five bands, made audible)
37+
38+
The game's canonical thresholds live in `ThemeManager.DOOM_STATUS_BANDS` (7 UI bands).
39+
Music collapses them onto the art spec's 5 doom-intensity bands:
40+
41+
| Music tier | Name | UI bands (doom %) | Mood target | Rhythm (axis A) | Timbre (axis B) |
42+
|---|---|---|---|---|---|
43+
| M0 | cosy | NOMINAL (<15) | warm competence, focus | soft pulse or none | clean, tonal, warm (amber) |
44+
| M1 | uneasy | ELEVATED + HIGH (15-52) | something is off | pulse gains weight, occasional stumble | faint detune/flicker creeping in |
45+
| M2 | spooky | SEVERE + EXTREME (52-80) | shadows lengthen | insistent percussion, darker low end | glitch artifacts, blue-arcing synth |
46+
| M3 | eldritch | CATASTROPHIC (80-92) | it has gone wrong in a wrong way | percussion fractures/polyrhythm | dissonant, wrong-scale, violet |
47+
| M4 | terminal | TERMINAL (92+) | apocalyptic; the end is audible | FIRE layer in; driving or collapsed | full eldritch smear + alarms |
48+
49+
Numbers come from `ThemeManager` at runtime -- the music system holds NO thresholds of
50+
its own, only the band->tier table `MUSIC_TIER_BY_BAND = [0,1,1,2,2,3,4]`. If Pip
51+
retunes the bands, music follows automatically.
52+
53+
## 4. Layer/stem model (what the musicians compose)
54+
55+
Each tier is a STEM GROUP played in sync. Target stems per tier (a tier may omit some):
56+
57+
| Stem slot | Carries | Axis | Notes |
58+
|---|---|---|---|
59+
| BASE | harmony/bed, the mood itself | bands | always on within its tier; must loop seamlessly |
60+
| PULSE | percussion/rhythm | A (catastrophe) | density scales with tier; absent at M0 is fine |
61+
| WEIRD | timbral accent (detune/glitch/dissonance) | B (weirdness) | the "one weird glow" -- keep it a single identifiable voice |
62+
| FIRE | the things-are-on-fire topper | A, high end | ONLY M3/M4 (like purple in the palette: expensive) |
63+
64+
Composition constraints so stems can be swapped/stacked mechanically:
65+
- Every stem in a tier: same tempo, same key (or deliberate fixed relationship), same
66+
length or integer multiple, seamless loop, no reverb tails crossing the loop point
67+
(bake tails into the loop musically or keep them short).
68+
- Adjacent tiers should share tempo or have a planned tempo relationship (e.g. M0-M2 at
69+
one tempo, M3-M4 at another) so crossfades between tiers do not churn.
70+
- Deliver as separate files (wav or ogg preferred; mp3 acceptable), loudness roughly
71+
matched (about -16 LUFS integrated per stem; final balance happens in-engine).
72+
- Names stay in the ML/AI-safety pun family (see `STEM_CATALOGUE.md`).
73+
74+
## 5. Runtime architecture (what is built, Godot 4.5.1 native)
75+
76+
No FMOD/Wwise. Two native primitives, already implemented in
77+
`godot/autoload/music_manager.gd`:
78+
79+
- **Horizontal (tier moods): `AudioStreamInteractive`** -- one clip per tier, named
80+
`cosy/uneasy/spooky/eldritch/terminal`, with an any-to-any crossfade transition
81+
(8 beats at a nominal 120 BPM = 4 s). Tier changes call
82+
`AudioStreamPlaybackInteractive.switch_to_clip()`.
83+
- **Vertical (stems within a tier): `AudioStreamSynchronized`** -- each clip's stream is
84+
a synchronized group of that tier's stems with per-stem volume offsets. Today most
85+
tiers hold a single placeholder track; the `terminal` tier already layers two tracks
86+
to keep the synchronized path exercised.
87+
88+
Signal flow (read-only, view-layer):
89+
90+
```
91+
GameManager.game_state_updated(state) # existing broadcast, emitted per turn
92+
-> MusicManager._on_game_state_for_music # reads state["doom"], nothing else
93+
-> ThemeManager.get_doom_band_index(doom) # canonical band (0..6)
94+
-> MUSIC_TIER_BY_BAND[band] # music tier (0..4)
95+
-> switch_to_clip(tier) # native crossfade if tier changed
96+
```
97+
98+
### Hard rules (ADR-0006: the replay string is the canonical run artifact)
99+
- Audio is a PURE view-layer side-effect. `MusicManager` only LISTENS to
100+
`game_state_updated`; it never calls into game code, never touches the seeded RNG,
101+
the turn loop, replay, or any state that could perturb determinism.
102+
- Never block or crash on missing audio: a missing stem is skipped, an empty tier
103+
inherits its neighbour's stream, a fully-empty build falls back to the legacy
104+
playlist, and no music at all is silence, not an error.
105+
- `randi()` use for playlist shuffle is the GLOBAL RNG, not the seeded game RNG --
106+
keep it that way; never "fix" music shuffle by borrowing the game's RNG.
107+
108+
## 6. Transition rules
109+
110+
Current prototype: single any-to-any rule -- switch immediately, crossfade over 8 beats
111+
(4 s), restart target clip from its start. Deliberately simple; placeholder tracks share
112+
no tempo grid, so beat-aligned transitions would be false precision.
113+
114+
Target rules once composed stems exist (all native `AudioStreamInteractive` features):
115+
- UP-tier (doom rose): transition at NEXT BEAT, short crossfade (1-2 bars). Rising dread
116+
should feel prompt -- the ratchet.
117+
- DOWN-tier (doom fell -- rare, earned): transition at NEXT BAR or phrase end, long lazy
118+
crossfade. Relief arrives slower than fear.
119+
- M3 -> M4 (terminal): consider a FILLER CLIP sting (a one-shot "it is happening"
120+
hit) via the native filler-clip transition option.
121+
- Hysteresis: band quantization already gives dead zones; if doom oscillates across one
122+
boundary each turn and audibly churns, add a 1-turn dwell before down-transitions
123+
(do NOT add one for up-transitions; the ratchet must feel immediate).
124+
125+
## 7. Where composed tracks slot in
126+
127+
One place: `MUSIC_TIER_STEMS` in `godot/autoload/music_manager.gd` -- an array of five
128+
stem lists, `{"path": ..., "volume_db": ...}` per stem. Replacing a placeholder track
129+
with real per-tier stems is a data edit, no logic change. Contexts outside GAMEPLAY
130+
(menu / victory / defeat) stay on the existing `music_library` playlists; a future
131+
victory track and a composed defeat sting slot in there.
132+
133+
Menu music stays NON-adaptive on purpose: the menu has no doom.
134+
135+
## 8. Out of scope / anti-goals
136+
137+
- No AI-generated finished music (rights are murky; Pip values owning rights).
138+
- No per-action stingers wired to game logic (would tempt writes into the turn loop;
139+
any future stinger hooks must also be listen-only).
140+
- No rewrite of the audio bus layout; the Music bus (index 2) is the only one touched.

0 commit comments

Comments
 (0)