diff --git a/ASSETS_LICENSES.md b/ASSETS_LICENSES.md index 8d95f355..cd797baa 100644 --- a/ASSETS_LICENSES.md +++ b/ASSETS_LICENSES.md @@ -54,7 +54,8 @@ These assets were made for this project, either by hand, by procedure, or with i | Enchanted Repo pack | `enchanted-repo-assets-only/` | Image generation | ⚠️ same | | Code City Brawl pack | `code-city-brawl-assets-only/` | Image generation | ⚠️ same | | Heroes vs. Villains pack | `heroes-vs-villains-assets-only/` | Built-in image generation; exact prompts and masters included. Locomotion supplement prompts in `LOCOMOTION.md`, packed by `tools/build_heroes_villains_locomotion.py`. Eight original synthesized cues generated by `tools/build_heroes_villains_sfx.py`; no third-party samples. | ⚠️ brand sign-off and project asset terms | -| Heroes vs. Villains music | `Audio/Beat'em Up/Hero vs Villian/` | Music by Erik Thorelli, made with Suno v6 for this game. Erik holds the rights and grants their use in the game and in this repository. Prompts, Suno song IDs, loop points and delivery steps in `PROMPTS.md`. | ✅ | +| Heroes vs. Villains music | `Audio/Beat'em Up/Hero vs Villian/` | Stage 2 and boss music by Erik Thorelli, made with Suno v6 for this game. Erik holds the rights and grants their use in the game and in this repository. Prompts, Suno song IDs, loop points and delivery steps in `PROMPTS.md`. | ✅ | +| Heroes vs. Villains stage 1 and 3 music | `Audio/Beat'em Up/Hero vs Villian/heroes_villains_generation_floor.ogg`, `heroes_villains_agentic_core.ogg` | Original, procedurally generated by `tools/build_heroes_villains_stage_music.py`; CC0 1.0. Design and loop notes in `PROMPTS.md`. Erik Thorelli's earlier Suno tracks for these stages were removed from the build and stay in the credits. | ✅ | ## Removed before public release diff --git a/docs/soundtrack.md b/docs/soundtrack.md index 20d4ad20..6e3da8dc 100644 --- a/docs/soundtrack.md +++ b/docs/soundtrack.md @@ -116,9 +116,9 @@ Shared settings: Instrumental on, Variety Off. The lyrics field holds section ta | Cue | Use in game | Length / loop | Styles (add the measured key) | |---|---|---|---| | **Main menu attract loop** | Delivered: `Assets/Audio/Main Menu/hub_chiptune_loop.wav`, a calm, swung lo-fi procedural chiptune (F major, 84 BPM) from `tools/build_hub_chiptune.py`, in normal and booth mode at −15 dB | 32 bars, seamless | `chiptune and FM synthesis arrangement of an upbeat theme, 116 BPM, NES square-wave lead plays the chorus melody, triangle-wave hopping bassline, noise-channel drums, three-note rising chime motif, arcade attract mode, inviting, loopable steady groove, no intro, no outro` | -| **Heroes vs. Villains: The Generation Floor** (delivered: `heroes_villains_generation_floor.ogg`) | Stage 1 | Planned 1:30+ loop; delivered 58.7 s file, 24-bar (49.3 s) loop | `hyperactive 16-bit chiptune variation of the theme, 116 BPM, comically busy bouncy arpeggios, square lead quoting the chorus, triangle bass, noise drums, toy-commercial energy, loopable` | +| **Heroes vs. Villains: The Generation Floor** (delivered: `heroes_villains_generation_floor.ogg`) | Stage 1 | Delivered: an original procedural chiptune (F major, 124 BPM) from `tools/build_heroes_villains_stage_music.py`, replacing the Suno take (issue #98). 69.7 s file: a 4-bar intro, then a 32-bar (61.9 s) loop | Heroic Genesis FM street-brawler groove in Code City Brawl's style, with its own tune: FM slap bass, brass stabs, breakbeat, square sparkle arpeggio, brassy FM lead opening on the rising F-A-C motif. See the folder's `PROMPTS.md`. | | **Heroes vs. Villains: The Review Gauntlet** (delivered: `heroes_villains_review_gauntlet.ogg`) | Stage 2 | Planned 1:30+ loop; delivered 82.5 s file, 32-bar (66.2 s) loop | `grinding heavy 16-bit chiptune, 116 BPM half-time march, motif in minor, stacking tension, FM brass, noise-channel snare rolls, loopable` | -| **Heroes vs. Villains: The Agentic Core** (delivered: `heroes_villains_agentic_core.ogg`) | Stage 3 | Planned 1:30+ loop; delivered 100.3 s file, 36-bar (72.4 s) loop | `tense techno-chiptune, 116 BPM, pulsing square-wave bass, cold FM leads, scanning arpeggios, inverted three-note motif, loopable` | +| **Heroes vs. Villains: The Agentic Core** (delivered: `heroes_villains_agentic_core.ogg`) | Stage 3 | Delivered: an original procedural chiptune (F minor, 134 BPM) from `tools/build_heroes_villains_stage_music.py`, replacing the Suno take (issue #98). 64.5 s file: a 4-bar intro, then a 32-bar (57.3 s) loop | Dark, driving FM brawler for the machine core in Code City Brawl's style: gritty 16th-note FM bass, heavy break with metallic hits, brass hits, scanning stereo arpeggio, cold FM lead on the falling C-A♭-F motif, data-glitch stutters. See the folder's `PROMPTS.md`. | | **Villain boss theme** (delivered as one track per boss, sharing the F-minor motif: `heroes_villains_boss_ai_slop.ogg`, `heroes_villains_boss_agent_army.ogg`, `heroes_villains_boss_hacker.ogg`) | All Heroes vs. Villains bosses | Planned 1:00+ loop, strong hit in the first second; delivered AI SLOP 62.6 s file with a 24-bar (49.1 s) loop, Agent Army 76.4 s with 32 bars (66.2 s), Hacker 59.7 s with 24 bars (49.4 s) | `Saturday-morning cartoon villain battle, 16-bit FM chiptune, 232 BPM double-time feel, minor key, campy menace, orchestra-hit stabs, chromatic runs, motif in minor, huge downbeat in the first second, loopable` | | **Opening fanfare** | Heroes vs. Villains intro | 10–15 s, hard stop | Not 8-bit: the theme's chorus and gang shout, cut from track A. | | **MERGE SUCCESSFUL** | Code City finale, ending and results | 3.5 s one-shot, then a 16.552 s loop | Delivered: original F-major square-wave synthesis at 116 BPM; rising F-A-C motif resolving to F. See the reproducible build below. | diff --git a/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/PROMPTS.md b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/PROMPTS.md index 41da0c44..68f4a5ac 100644 --- a/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/PROMPTS.md +++ b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/PROMPTS.md @@ -1,31 +1,35 @@ # Heroes vs. Villains music -Music by Erik Thorelli, made with Suno v6 on his Premier plan for Hoppy's Adventure Arcade. Erik holds the rights and grants their use in the game and in this repository. These cues belong to the theme family in [`docs/soundtrack.md`](../../../../../docs/soundtrack.md) section B: about 116 BPM, F major for the heroes' first stage and F minor for the villains, with the three-note motif rising for heroes and inverted for villains. The theme itself, "Send in the Rabbit", measured F major at 118.3 BPM (`Assets/Audio/Main Menu/PROMPTS.md`). +Stage 2 and the three boss tracks are music by Erik Thorelli, made with Suno v6 on his Premier plan for Hoppy's Adventure Arcade. Erik holds the rights and grants their use in the game and in this repository. Stages 1 and 3 are original procedural chiptunes, CC0 1.0, built by `tools/build_heroes_villains_stage_music.py` ([below](#stages-1-and-3-procedural)); Erik's earlier takes for those stages are kept in the credits and recorded under [Retired takes](#retired-takes). These cues belong to the theme family in [`docs/soundtrack.md`](../../../../../docs/soundtrack.md) section B: about 116 BPM, F major for the heroes' first stage and F minor for the villains, with the three-note motif rising for heroes and inverted for villains. The theme itself, "Send in the Rabbit", measured F major at 118.3 BPM (`Assets/Audio/Main Menu/PROMPTS.md`). -| File | Plays | Suno song | Measured tempo and key | Loop | +| File | Plays | Source | Measured tempo and key | Loop | |---|---|---|---|---| -| `heroes_villains_generation_floor.ogg` | Stage 1 and the Heroes vs. Villains menu | [`48ee082f-f1e8-4bd3-ad31-aaec80af7b73`](https://suno.com/song/48ee082f-f1e8-4bd3-ad31-aaec80af7b73) | 116.9 BPM; F major, the theme's key | Intro 0–9.404 s plays once, then 24 bars (49.3 s) repeat to the file end at 58.698 s | +| `heroes_villains_generation_floor.ogg` | Stage 1 and the Heroes vs. Villains menu | Procedural: `python3 tools/build_heroes_villains_stage_music.py --stage generation_floor`. Original, CC0 1.0 | 124 BPM; F major, the theme's key | Intro 0–7.742 s (4 bars) plays once, then 32 bars (61.9 s) repeat to the file end at 69.677 s | | `heroes_villains_review_gauntlet.ogg` | Stage 2 | [`66efb6fe-d2df-4880-9da0-395638783b62`](https://suno.com/song/66efb6fe-d2df-4880-9da0-395638783b62) | 116.0 BPM; F minor | Intro 0–16.324 s plays once, then 32 bars (66.2 s) repeat to the file end at 82.492 s | -| `heroes_villains_agentic_core.ogg` | Stage 3 | [`7b1a589b-9856-4def-9615-f4db3d6d0ebd`](https://suno.com/song/7b1a589b-9856-4def-9615-f4db3d6d0ebd) | 119.5 BPM; F minor | Intro 0–27.899 s plays once, then 36 bars (72.4 s) repeat to the file end at 100.253 s | +| `heroes_villains_agentic_core.ogg` | Stage 3 | Procedural: `python3 tools/build_heroes_villains_stage_music.py --stage agentic_core`. Original, CC0 1.0 | 134 BPM; F minor | Intro 0–7.164 s (4 bars) plays once, then 32 bars (57.3 s) repeat to the file end at 64.478 s | | `heroes_villains_boss_ai_slop.ogg` | AI SLOP, stage 1's boss | [`5b0c85b7-6ce7-4e6e-898e-5305512e202e`](https://suno.com/song/5b0c85b7-6ce7-4e6e-898e-5305512e202e) | 117.4 BPM; F minor | Opening 0–13.572 s plays once, then 24 bars (49.0 s) repeat to the file end at 62.622 s | | `heroes_villains_boss_agent_army.ogg` | The Coding Agent Army and the 10,000-Line PR, stage 2's bosses | [`0a633def-4f1b-4f92-9800-bc85ba997204`](https://suno.com/song/0a633def-4f1b-4f92-9800-bc85ba997204) | 116.0 BPM; F (minor and major), on the theme's tonic | Opening 0–10.182 s plays once, then 32 bars (66.2 s) repeat to the file end at 76.382 s | | `heroes_villains_boss_hacker.ogg` | The AI-Augmented Hacker, the final boss | [`650e1d9a-5772-4220-ab3e-3c9ac4779c20`](https://suno.com/song/650e1d9a-5772-4220-ab3e-3c9ac4779c20) | 116.7 BPM; F minor | Opening 0–10.310 s plays once, then 24 bars (49.4 s) repeat to the file end at 59.665 s | -## heroes_villains_generation_floor: "The Generation Floor" +## Stages 1 and 3 (procedural) -Styles: +Issue #98: Erik's Suno takes for these two stages fit them less well than the Review Gauntlet track fits stage 2. Both were replaced with original chiptunes written for their stages. At the user's direction, they take their energy and instruments from Code City Brawl's Genesis FM street-brawler music: FM slap bass, brass-like FM stabs, breakbeat drums and square arpeggios. They keep this game's identity, though: the F major and F minor keys, the heroes' rising and the villains' falling motif, and their own tunes. The two games don't share material. The boss tracks stay at about 116 BPM; the dip-and-swap fades the stage track out before the boss track starts, so the tempo change lands cleanly. -```text -hyperactive 16-bit chiptune, Saturday-morning cartoon brawler stage, instrumental, 116 BPM, F major, comically busy bouncy arpeggios, square-wave lead with a big singable chorus melody, hopping triangle bass in octaves, noise-channel drums, handclap samples, three-note rising glockenspiel-like chime motif, toy-commercial energy, stays in F major, seamless loop -``` +- **The Generation Floor** (stage 1, a bright white and orange expo hall with the easiest enemies), 124 BPM: a heroic brawler groove, not a toy commercial. Syncopated FM slap bass popping the octave, brass stabs (on the one and the anticipation in A, on the offbeats in B), a high 25% square sparkle arpeggio sweeping left to right, and a breakbeat with ghost snares and claps. The brassy FM lead is doubled an octave up on a square and opens every A section on the rising F-A-C motif, with a dotted-eighth ping-pong echo. In the intro, brass hits ring the motif twice, the break kicks in, and a snare roll leads into bar 1. Form: A, A with a harmony, B, B with harmony (F–F–Dm–B♭–F–C–B♭–C; B♭–C–Am–Dm–B♭–C–Gm–C). +- **The Agentic Core** (stage 3, a dark red and green machine core and the fastest stage, with 0.32 s drone tells), 134 BPM: a dark, driving brawler that gets going at once. The old take's 27.9 s intro is now 7.2 s. It has a relentless, gritty 16th-note FM bass with a triangle sub, a heavy break with metallic hits on the "and" of four, brass hits, and a scanning arpeggio that pans across the stereo field. The cold FM lead plays the villains' falling C-A♭-F motif. Short 6-bit data-glitch stutters close the phrases of the last section. Form: A sparse (motif only), A, B with harmony, B with glitches (Fm–Fm–D♭–D♭–B♭m–B♭m–C–C; D♭–E♭–Fm–Fm–D♭–E♭–C–C). -Exclude styles: `vocals, singing, humming, choir, vocal chops, spoken word, fade out, lo-fi hiss, key change` +Both are 44.1 kHz stereo. Every voice, echo and stutter that runs past the file end wraps to the loop start, and the output low-pass starts the body in its settled loop state, with the difference ramped into the intro's last 5 ms. Neither the seam nor the first pass from the intro into the loop has a gap, fade or click: after encoding, the wrap step is 352 and 282, and the intro-to-loop step is 430 and 348, against a 99th-percentile step of about 4,000. A soft limiter above 0.6 holds the peaks. -Settings: model v6, Advanced, Max Mode on, Variety off, Weirdness 35%, Style Influence 75%, duration 2:30, lyrics empty (instrumental). Generated and downloaded as WAV on October 15, 2026. +Rebuild from the repository root, then encode with reference libvorbis (`brew install vorbis-tools`): -Takes compared: this one and `b5f8613e-32cb-4c2f-a925-b35093f2b1aa`. This one lands in F major (the other sits in C major), keeps a tight 116.9 BPM grid, and scored higher on enjoyment (6.90 against 6.69) and production quality (8.12 against 7.83). Audiobox-aesthetics: content enjoyment 6.9, production quality 8.12. +```sh +python3 tools/build_heroes_villains_stage_music.py --stage generation_floor --out /tmp/hvv +python3 tools/build_heroes_villains_stage_music.py --stage agentic_core --out /tmp/hvv +oggenc -q 6 -o "hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_generation_floor.ogg" /tmp/hvv/heroes_villains_generation_floor.wav +oggenc -q 6 -o "hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_agentic_core.ogg" /tmp/hvv/heroes_villains_agentic_core.wav +``` -Delivery: +1.2 dB to about −14 LUFS (linear gain); the OGG measures -14.1 LUFS and -2.8 dBTP. Seam over three decoded cycles: step 0.031 against a 99th-percentile step of 0.115; flux 1.03× the same downbeat heard uncut. +The WAVs are byte-for-byte reproducible (SHA-256 `0164b029…f83c` and `d25faa8d…3973`). The OGGs measure −14.0 and −13.9 LUFS integrated and −1.3 and −1.5 dBTP, and decode to exactly 3,072,774 and 2,843,463 frames. ## heroes_villains_review_gauntlet: "The Review Gauntlet" @@ -43,22 +47,6 @@ Takes compared: this one and `2b7a81e1-004a-4bb2-8879-d4f779b45ea1`. Both hold F Delivery: +0.4 dB to about −14 LUFS (linear gain); the OGG measures -14.1 LUFS and -3.9 dBTP. Seam over three decoded cycles: step 0.000 against a 99th-percentile step of 0.127; flux 1.16× the same downbeat heard uncut. -## heroes_villains_agentic_core: "The Agentic Core" - -Styles: - -```text -tense techno-chiptune, machine core stage, instrumental, 116 BPM, F minor, pulsing square-wave bass, cold FM leads, scanning arpeggios, inverted three-note motif, four-on-the-floor noise kick, data-stream glitches, rising dread, stays in F minor, seamless loop -``` - -Exclude styles: `vocals, singing, humming, choir, vocal chops, spoken word, fade out, lo-fi hiss, key change` - -Settings: model v6, Advanced, Max Mode on, Variety off, Weirdness 40%, Style Influence 75%, duration 2:30, lyrics empty (instrumental). Generated and downloaded as WAV on October 15, 2026. - -Takes compared: this one and `5b24787f-eb75-4003-a378-e4f827d3e450`. The two scored the same on enjoyment and production quality; this one stays in F minor throughout (the other slides into E♭ minor) and has the wider dynamic range for the stage's rising dread. Audiobox-aesthetics: content enjoyment 7.16, production quality 8.12. - -Delivery: +1.0 dB to about −14 LUFS (linear gain); the OGG measures -14.7 LUFS and -2.9 dBTP. Seam over three decoded cycles: step 0.050 against a 99th-percentile step of 0.124; flux 0.98× the same downbeat heard uncut. - ## heroes_villains_boss_ai_slop: "AI SLOP" Styles: @@ -107,8 +95,45 @@ Takes compared: this one and `ab00eb2c-ed61-409b-b068-2560d122916e`. Both hold F Delivery: +1.6 dB to about −14 LUFS (linear gain); the OGG measures -14.3 LUFS and -3.8 dBTP. Seam over three decoded cycles: step 0.015 against a 99th-percentile step of 0.117; flux 1.21× the same downbeat heard uncut. +## Retired takes + +Erik Thorelli's Suno takes for stages 1 and 3 were removed from the build in issue #98 and stay in the credits. Their prompts are kept here for the record. + +### heroes_villains_generation_floor (retired): "The Generation Floor" + +Styles: + +```text +hyperactive 16-bit chiptune, Saturday-morning cartoon brawler stage, instrumental, 116 BPM, F major, comically busy bouncy arpeggios, square-wave lead with a big singable chorus melody, hopping triangle bass in octaves, noise-channel drums, handclap samples, three-note rising glockenspiel-like chime motif, toy-commercial energy, stays in F major, seamless loop +``` + +Exclude styles: `vocals, singing, humming, choir, vocal chops, spoken word, fade out, lo-fi hiss, key change` + +Settings: model v6, Advanced, Max Mode on, Variety off, Weirdness 35%, Style Influence 75%, duration 2:30, lyrics empty (instrumental). Generated and downloaded as WAV on October 15, 2026. + +Takes compared: this one and `b5f8613e-32cb-4c2f-a925-b35093f2b1aa`. This one lands in F major (the other sits in C major), keeps a tight 116.9 BPM grid, and scored higher on enjoyment (6.90 against 6.69) and production quality (8.12 against 7.83). Audiobox-aesthetics: content enjoyment 6.9, production quality 8.12. + +Delivery: +1.2 dB to about −14 LUFS (linear gain); the OGG measures -14.1 LUFS and -2.8 dBTP. Seam over three decoded cycles: step 0.031 against a 99th-percentile step of 0.115; flux 1.03× the same downbeat heard uncut. + + +### heroes_villains_agentic_core (retired): "The Agentic Core" + +Styles: + +```text +tense techno-chiptune, machine core stage, instrumental, 116 BPM, F minor, pulsing square-wave bass, cold FM leads, scanning arpeggios, inverted three-note motif, four-on-the-floor noise kick, data-stream glitches, rising dread, stays in F minor, seamless loop +``` + +Exclude styles: `vocals, singing, humming, choir, vocal chops, spoken word, fade out, lo-fi hiss, key change` + +Settings: model v6, Advanced, Max Mode on, Variety off, Weirdness 40%, Style Influence 75%, duration 2:30, lyrics empty (instrumental). Generated and downloaded as WAV on October 15, 2026. + +Takes compared: this one and `5b24787f-eb75-4003-a378-e4f827d3e450`. The two scored the same on enjoyment and production quality; this one stays in F minor throughout (the other slides into E♭ minor) and has the wider dynamic range for the stage's rising dread. Audiobox-aesthetics: content enjoyment 7.16, production quality 8.12. + +Delivery: +1.0 dB to about −14 LUFS (linear gain); the OGG measures -14.7 LUFS and -2.9 dBTP. Seam over three decoded cycles: step 0.050 against a 99th-percentile step of 0.124; flux 0.98× the same downbeat heard uncut. + ## How these files were made -From Suno's 48 kHz WAV, each track was normalized as a whole to about −14 LUFS integrated and −1 dBTP (gain, plus a lookahead limiter only where peaks would pass the ceiling), resampled to 44.1 kHz, and cut on bar lines. The loop start and length were chosen by matching the bars around the loop end with the bars around the loop start (chroma, MFCC and level), the end sample was aligned to the start by waveform correlation, and an 8 ms equal-power crossfade into the audio just before the loop start was baked into the last samples. Files were encoded with `oggenc -q 6` (reference libvorbis), keeping Suno's embedded comment with the song ID and creation time. +This covers the Suno tracks. From Suno's 48 kHz WAV, each track was normalized as a whole to about −14 LUFS integrated and −1 dBTP (gain, plus a lookahead limiter only where peaks would pass the ceiling), resampled to 44.1 kHz, and cut on bar lines. The loop start and length were chosen by matching the bars around the loop end with the bars around the loop start (chroma, MFCC and level), the end sample was aligned to the start by waveform correlation, and an 8 ms equal-power crossfade into the audio just before the loop start was baked into the last samples. Files were encoded with `oggenc -q 6` (reference libvorbis), keeping Suno's embedded comment with the song ID and creation time. The `.import` files set `loop`, `loop_offset`, `bpm` and `bar_beats`. `beat_count` stays 0 so Godot loops at the sample-accurate file end (see `docs/soundtrack.md`). These shared imports keep `loop` off: `modes/beatem_up/reinvent/catalog.gd` loops a private copy (`Catalog.looped`), which inherits `loop_offset`. diff --git a/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_agentic_core.ogg b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_agentic_core.ogg index 7f17c638..e151c923 100644 Binary files a/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_agentic_core.ogg and b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_agentic_core.ogg differ diff --git a/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_agentic_core.ogg.import b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_agentic_core.ogg.import index 0254afe0..6b1a0f64 100644 --- a/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_agentic_core.ogg.import +++ b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_agentic_core.ogg.import @@ -13,7 +13,7 @@ dest_files=["res://.godot/imported/heroes_villains_agentic_core.ogg-8b8a60baa568 [params] loop=false -loop_offset=27.898776 -bpm=119.488 +loop_offset=7.164172 +bpm=134 beat_count=0 bar_beats=4 diff --git a/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_generation_floor.ogg b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_generation_floor.ogg index 21f59b70..45f53bbf 100644 Binary files a/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_generation_floor.ogg and b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_generation_floor.ogg differ diff --git a/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_generation_floor.ogg.import b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_generation_floor.ogg.import index ed84c3ec..f32ff76f 100644 --- a/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_generation_floor.ogg.import +++ b/hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian/heroes_villains_generation_floor.ogg.import @@ -13,7 +13,7 @@ dest_files=["res://.godot/imported/heroes_villains_generation_floor.ogg-a50c90dd [params] loop=false -loop_offset=9.404082 -bpm=116.851 +loop_offset=7.741927 +bpm=124 beat_count=0 bar_beats=4 diff --git a/hoppys-adventure-arcade/scenes/credits/credits.json b/hoppys-adventure-arcade/scenes/credits/credits.json index a4948f26..06baab39 100644 --- a/hoppys-adventure-arcade/scenes/credits/credits.json +++ b/hoppys-adventure-arcade/scenes/credits/credits.json @@ -65,7 +65,7 @@ }, { "title": "The Generation Floor", - "used_by": "Heroes vs. Villains" + "used_by": "Heroes vs. Villains stage 1 (retired)" }, { "title": "AI SLOP", @@ -81,13 +81,29 @@ }, { "title": "The Agentic Core", - "used_by": "Heroes vs. Villains" + "used_by": "Heroes vs. Villains stage 3 (retired)" }, { "title": "Machine-Speed Hacker", "used_by": "Heroes vs. Villains final boss" } ] + }, + { + "title": "Procedural music", + "lines": [ + "Original chiptunes generated in code for this game, CC0 1.0" + ], + "tracks": [ + { + "title": "The Generation Floor", + "used_by": "Heroes vs. Villains stage 1" + }, + { + "title": "The Agentic Core", + "used_by": "Heroes vs. Villains stage 3" + } + ] } ] } diff --git a/tools/build_heroes_villains_stage_music.py b/tools/build_heroes_villains_stage_music.py new file mode 100644 index 00000000..5427dc03 --- /dev/null +++ b/tools/build_heroes_villains_stage_music.py @@ -0,0 +1,581 @@ +"""Original CC0 stage music for Heroes vs. Villains stages 1 and 3. + +Rebuild from the repository root with +`python3 tools/build_heroes_villains_stage_music.py --stage generation_floor` (or +`agentic_core`), then encode the WAV with `oggenc -q 6` (see the audio folder's +PROMPTS.md). Both take their energy and instruments from Code City Brawl's Genesis FM +street-brawler music (FM slap bass, brass stabs, breakbeat drums, square arpeggios) but +keep the Heroes vs. Villains identity: the heroes' F major for stage 1 and the villains' +F minor for stage 3, with the three-note motif rising (F-A-C) for the heroes and falling +(C-Ab-F) for the core, and their own tunes. + +- The Generation Floor (124 BPM): a heroic brawler groove for the bright white and + orange expo hall. Syncopated FM slap bass, brass stabs, a high square sparkle + arpeggio, a breakbeat with ghost snares and a brassy FM lead that opens on the motif. +- The Agentic Core (134 BPM): a dark, driving brawler for the red and green machine + core, the game's fastest stage. A gritty 16th-note FM bass, a heavy break with + metallic hits, brass hits, a scanning arpeggio that sweeps across the stereo field, + cold FM leads on the falling motif and short data-glitch stutters. + +Each file is a 4-bar intro that plays once, then a 32-bar body that loops to the file +end (the `.import` sets `loop_offset` to the intro length). The engine is a small +NES/Genesis-style synth: anti-aliased pulse waves, a triangle, 2-operator FM, and a +15-bit LFSR noise channel for drums. Every voice, echo and stutter that runs past the +file end wraps around to the loop start, so the seam has no gap, fade or click. +No samples, network, random state or dependencies. +The generated compositions and recordings are dedicated to the public domain under CC0 1.0. +""" + +import argparse +from dataclasses import dataclass +import hashlib +import math +from pathlib import Path +import struct +import wave + + +OUT = ( + Path(__file__).resolve().parents[1] + / "hoppys-adventure-arcade/Assets/Audio/Beat'em Up/Hero vs Villian" +) +RATE = 44100 +METER = 4 +INTRO_BARS = 4 +TAU = 2 * math.pi +MAX_PEAK = 0.84 # about -1.5 dBFS before encoding +KNEE = 0.6 # the soft limiter leaves samples below this untouched + + +def chord(root: int, *tones: int) -> tuple[int, tuple[int, ...]]: + """A bass root (MIDI, octave 2) and its chord tones (MIDI, around middle C).""" + return root, tones + + +# F major. +F = chord(41, 57, 60, 65) +DM = chord(38, 57, 62, 65) +BB = chord(46, 58, 62, 65) +C = chord(36, 55, 60, 64) +AM = chord(45, 57, 60, 64) +GM = chord(43, 55, 58, 62) +# F minor (C major is the harmonic-minor dominant). +FM = chord(41, 56, 60, 65) +DB = chord(37, 56, 61, 65) +BBM = chord(46, 58, 61, 65) +EB = chord(39, 55, 58, 63) +CMAJ = chord(36, 55, 60, 64) + + +@dataclass +class Stage: + """One stage song: chords and lead per 8-bar section, form and arrangement.""" + + name: str + chords: dict[str, list] + melody: dict[str, list] # per bar: (beat, midi, beats) + intro: list # chords for the intro bars + form: list[str] # body sections, with "+harm", "+sparse" or "+glitch" variations + bpm: float + lowpass: float # one-pole coefficient; lower is warmer + rms: float # body loudness before the soft limiter; tuned to about -14 LUFS + + +GENERATION_FLOOR = Stage( + name="generation_floor", + chords={ + "A": [F, F, DM, BB, F, C, BB, C], + "B": [BB, C, AM, DM, BB, C, GM, C], + }, + melody={ + "A": [ + [(0, 65, 0.5), (0.5, 69, 0.5), (1, 72, 1), (2.5, 72, 0.5), (3, 74, 0.5), (3.5, 72, 0.5)], + [(0, 77, 1.5), (1.5, 76, 0.5), (2, 72, 1), (3, 69, 1)], + [(0, 74, 0.5), (0.5, 77, 0.5), (1, 81, 1), (2.5, 79, 0.5), (3, 77, 0.5), (3.5, 74, 0.5)], + [(0, 77, 1.5), (1.5, 74, 0.5), (2, 70, 1.5), (3.5, 72, 0.5)], + [(0, 65, 0.5), (0.5, 69, 0.5), (1, 72, 1), (2.5, 77, 0.5), (3, 76, 0.5), (3.5, 77, 0.5)], + [(0, 79, 1.5), (1.5, 76, 0.5), (2, 72, 1), (3, 79, 0.5), (3.5, 81, 0.5)], + [(0, 82, 1), (1, 81, 0.5), (1.5, 79, 0.5), (2, 77, 1), (3, 74, 1)], + [(0, 76, 2), (2.5, 72, 0.5), (3, 74, 0.5), (3.5, 76, 0.5)], + ], + "B": [ + [(0, 74, 1), (1, 77, 1), (2, 82, 1.5), (3.5, 81, 0.5)], + [(0, 79, 1.5), (1.5, 76, 0.5), (2, 72, 2)], + [(0, 72, 1), (1, 76, 1), (2, 81, 1.5), (3.5, 79, 0.5)], + [(0, 77, 1.5), (1.5, 74, 0.5), (2, 69, 2)], + [(0, 70, 0.5), (0.5, 74, 0.5), (1, 77, 1), (2, 82, 0.5), (2.5, 81, 0.5), (3, 79, 0.5), (3.5, 77, 0.5)], + [(0, 76, 1), (1, 79, 1), (2, 84, 2)], + [(0, 82, 0.5), (0.5, 81, 0.5), (1, 79, 1), (2, 74, 0.5), (2.5, 77, 0.5), (3, 79, 1)], + [(0, 76, 1), (1, 72, 1), (2, 67, 0.5), (2.5, 69, 0.5), (3, 70, 0.5), (3.5, 76, 0.5)], + ], + }, + intro=[F, BB, F, C], + bpm=124, + form=["A", "A+harm", "B", "B+harm"], + lowpass=0.62, + rms=0.181, +) + +AGENTIC_CORE = Stage( + name="agentic_core", + chords={ + "A": [FM, FM, DB, DB, BBM, BBM, CMAJ, CMAJ], + "B": [DB, EB, FM, FM, DB, EB, CMAJ, CMAJ], + }, + melody={ + "A": [ + [(0, 72, 0.5), (0.5, 68, 0.5), (1, 65, 1.5), (3, 67, 0.5), (3.5, 68, 0.5)], + [(0, 72, 1), (1, 75, 0.5), (1.5, 72, 0.5), (2, 77, 2)], + [(0, 77, 0.5), (0.5, 73, 0.5), (1, 68, 1.5), (3, 70, 0.5), (3.5, 72, 0.5)], + [(0, 73, 1.5), (1.5, 72, 0.5), (2, 68, 1), (3, 65, 1)], + [(0, 70, 0.5), (0.5, 73, 0.5), (1, 77, 1), (2, 82, 0.5), (2.5, 80, 0.5), (3, 77, 1)], + [(0, 73, 1.5), (1.5, 77, 0.5), (2, 75, 1), (3, 73, 1)], + [(0, 72, 1), (1, 76, 0.5), (1.5, 79, 0.5), (2, 84, 1), (3, 82, 0.5), (3.5, 79, 0.5)], + [(0, 76, 2), (2, 79, 0.5), (2.5, 76, 0.5), (3, 72, 1)], + ], + "B": [ + [(0, 80, 1.5), (1.5, 77, 0.5), (2, 73, 1), (3, 77, 1)], + [(0, 79, 1.5), (1.5, 75, 0.5), (2, 70, 1), (3, 75, 1)], + [(0, 84, 0.5), (0.5, 80, 0.5), (1, 77, 1.5), (3, 80, 0.5), (3.5, 84, 0.5)], + [(0, 85, 1), (1, 84, 1), (2, 80, 1), (3, 77, 1)], + [(0, 77, 0.5), (0.5, 80, 0.5), (1, 85, 1), (2, 84, 0.5), (2.5, 80, 0.5), (3, 77, 1)], + [(0, 79, 1), (1, 82, 1), (2, 87, 1.5), (3.5, 84, 0.5)], + [(0, 84, 1), (1, 79, 0.5), (1.5, 76, 0.5), (2, 72, 1), (3, 76, 1)], + [(0, 79, 2), (2.5, 76, 0.5), (3, 72, 1)], + ], + }, + intro=[FM, FM, DB, CMAJ], + bpm=134, + form=["A+sparse", "A", "B+harm", "B+glitch"], + lowpass=0.55, + rms=0.199, +) + +STAGES = {stage.name: stage for stage in (GENERATION_FLOOR, AGENTIC_CORE)} + + +def mtof(midi: float) -> float: + """Frequency in Hz of a MIDI note number (A4 = 69 = 440 Hz).""" + return 440 * 2 ** ((midi - 69) / 12) + + +def blep(phase: float, step: float) -> float: + """Smooth the pulse's discontinuities over a sample to tame high-frequency aliasing.""" + if phase < step: + x = phase / step + return x + x - x * x - 1 + if phase > 1 - step: + x = (phase - 1) / step + return x * x + x + x + 1 + return 0.0 + + +def harmony_below(midi: int, tones: tuple[int, ...]) -> int: + """The highest chord tone at least a minor third below `midi`.""" + classes = {tone % 12 for tone in tones} + candidate = midi - 3 + while candidate % 12 not in classes: + candidate -= 1 + return candidate + + +class Score: + """A stereo intro + loop buffer; writes past the end wrap to the loop start.""" + + def __init__(self, body_bars: int, bpm: float) -> None: + """Allocate the 4-bar intro and a `body_bars` loop of silence at `bpm`.""" + self.beat = 60 / bpm + self.loop_start = round(INTRO_BARS * METER * self.beat * RATE) + self.size = round((INTRO_BARS + body_bars) * METER * self.beat * RATE) + self.body = self.size - self.loop_start + self.left = [0.0] * self.size + self.right = [0.0] * self.size + self.send = [0.0] * self.size # the lead's echo send (mono) + self.lfsr = 1 + + def wrap(self, index: int) -> int: + """The buffer index for a sample position, wrapping past the end into the loop.""" + if index < self.size: + return index + return self.loop_start + (index - self.loop_start) % self.body + + def add(self, index: int, value: float, pan: float, send: float) -> None: + """Mix `value` at `index`, panned -1 (left) to 1 (right), plus an echo send.""" + i = self.wrap(index) + angle = (pan + 1) * math.pi / 4 + self.left[i] += value * math.cos(angle) + self.right[i] += value * math.sin(angle) + if send: + self.send[i] += value * send + + def tone( + self, + kind: str, + midi: float, + beat: float, + beats: float, + gain: float, + pan: float = 0.0, + duty: float = 0.5, + vibrato: float = 0.0, + attack: float = 0.006, + release: float = 0.04, + decay: float = 0.5, + sustain: float = 0.6, + send: float = 0.0, + ratio: float = 2.0, + index: float = 1.5, + bright: float = 0.0, + feedback: float = 0.0, + ) -> None: + """Add one note starting on `beat` (from the file start) for `beats` beats. + + `kind` is "pulse" (with `duty`), "tri", or "fm": a Genesis-style pair, a sine + carrier modulated at `ratio` times its pitch by an operator with self-`feedback` + (brassy and gritty as it rises). The modulation `index` follows the note's decay, + or, with `bright` seconds, falls fast to a quarter for a slap or pluck. + The envelope rises over `attack` seconds, decays toward `sustain` with time + constant `decay` and releases over the last `release` seconds. + """ + first = round(beat * self.beat * RATE) + count = round(beats * self.beat * RATE) + base = mtof(midi) + offset = 2 * duty - 1 + phase = modulator = previous = 0.0 + for n in range(count): + t = n / RATE + frequency = base + if vibrato and t > 0.18: + frequency *= 1 + vibrato * min(1.0, (t - 0.18) * 4) * math.sin( + TAU * 5.5 * (t - 0.18) + ) + step = frequency / RATE + phase += step + if phase >= 1.0: + phase -= 1.0 + envelope = min(1.0, t / attack, (count - 1 - n) / (RATE * release)) + shape = sustain + (1 - sustain) * math.exp(-t / decay) + envelope *= shape + if kind == "pulse": + value = (1.0 if phase < duty else -1.0) - offset + value += blep(phase, step) - blep((phase - duty) % 1.0, step) + elif kind == "tri": + value = 4 * abs(phase - 0.5) - 1 + else: + modulator += step * ratio + if modulator >= 1.0: + modulator -= 1.0 + previous = math.sin(TAU * modulator + feedback * previous) + depth = shape if not bright else 0.25 + 0.75 * math.exp(-t / bright) + value = math.sin(TAU * phase + index * depth * previous) + self.add(first + n, value * envelope * gain, pan, send) + + def drum(self, kind: str, beat: float, gain: float, pan: float = 0.0) -> None: + """Add a "kick" (pitch-swept sine), "snare", "clap" or "hat" (LFSR noise).""" + if gain <= 0: + return + first = round(beat * self.beat * RATE) + seconds, fall = { + "kick": (0.3, 0.12), + "snare": (0.2, 0.07), + "clap": (0.22, 0.06), + "hat": (0.05, 0.014), + "open": (0.16, 0.05), + }[kind] + phase = 0.0 + for n in range(round(seconds * RATE)): + t = n / RATE + if kind == "kick": + phase += (46 + 110 * math.exp(-t / 0.025)) / RATE + value = math.sin(TAU * phase) + 0.3 * math.exp(-t / 0.004) + level = math.exp(-t / fall) + else: + tap = 6 if kind in ("hat", "open") else 1 # NES short mode sounds metallic + bit = (self.lfsr ^ (self.lfsr >> tap)) & 1 + self.lfsr = (self.lfsr >> 1) | (bit << 14) + value = 1.0 if self.lfsr & 1 else -1.0 + if kind == "snare": + value = 0.7 * value + 0.5 * math.sin(TAU * 190 * t) * math.exp(-t / 0.04) + level = math.exp(-t / fall) + if kind == "clap": # three quick bursts, then the tail + burst = t % 0.011 + level = math.exp(-burst / 0.003) if t < 0.033 else math.exp(-(t - 0.033) / fall) + level *= min(1.0, t / 0.0015) + self.add(first + n, value * level * gain, pan, 0.0) + + def echo(self, delay_beats: float, feedback: float, wet: float) -> None: + """Ping-pong the lead's echo send, run twice so its tail carries over the seam.""" + delay = round(delay_beats * self.beat * RATE) + size, send = self.size, self.send + echo = [0.0] * size + for _ in range(2): + for i in range(size): + j = i - delay + if i >= self.loop_start and j < self.loop_start: + j += self.body + echo[i] = send[i] + feedback * (echo[j] if j >= 0 else 0.0) + half = delay // 2 + for i in range(size): + j = i - half + if i >= self.loop_start and j < self.loop_start: + j += self.body + late = echo[j] - send[j] if j >= 0 else 0.0 + self.left[i] += wet * (echo[i] - send[i]) + self.right[i] += wet * late + + def stutter(self, beat: float, slice_beats: float, repeats: int) -> None: + """Repeat a short slice of the mix (a data glitch), crushed to 6 bits.""" + first = round(beat * self.beat * RATE) + length = round(slice_beats * self.beat * RATE) + for channel in (self.left, self.right): + grain = [channel[self.wrap(first + n)] for n in range(length)] + for r in range(1, repeats + 1): + for n in range(length): + i = self.wrap(first + r * length + n) + edge = min(1.0, n / 40, (length - 1 - n) / 40) + crushed = round(grain[n] * 32) / 32 + channel[i] = channel[i] * (1 - edge) + crushed * edge + + +def bar_chords(stage: Stage) -> list: + """(section name, variation, bar within the section) for every body bar, in order.""" + bars = [] + for section in stage.form: + name, _, variation = section.partition("+") + for bar in range(8): + bars.append((name, variation, bar)) + return bars + + +def slap(score: Score, midi: int, beat: float, beats: float, gain: float) -> None: + """A Genesis-style FM slap-bass note with a triangle under it for weight.""" + score.tone("fm", midi, beat, beats, gain, ratio=1.0, index=3.2, bright=0.035, + feedback=0.35, decay=0.12, sustain=0.35, release=0.02) + score.tone("tri", midi, beat, beats, gain * 0.5, release=0.02) + + +def brass(score: Score, tones, beat: float, beats: float, gain: float, lift: int = 12) -> None: + """A brass-like FM chord stab, spread across the stereo field.""" + for k, midi in enumerate(tones): + score.tone("fm", midi + lift, beat, beats, gain, pan=(-0.45, 0.0, 0.45)[k], ratio=1.0, + index=1.6, feedback=0.7, attack=0.015, decay=0.18, sustain=0.45, release=0.05) + + +def breakbeat(score: Score, o: float, kicks, last: bool, snare: float, fill_from: float) -> None: + """Punchy brawler drums: `kicks`, a hard backbeat with ghost notes and 16th hats.""" + for beat in kicks: + score.drum("kick", o + beat, 0.72) + score.drum("snare", o + 1, snare, -0.05) + score.drum("clap", o + 1, snare * 0.45, 0.1) + if last: + steps = round((4 - fill_from) * 4) + for k in range(steps): + score.drum("snare", o + fill_from + k * 0.25, 0.07 + 0.16 * k / steps, -0.15 + 0.3 * k / steps) + else: + score.drum("snare", o + 3, snare, -0.05) + score.drum("clap", o + 3, snare * 0.45, 0.1) + for ghost in (1.75, 2.25, 3.75): + score.drum("snare", o + ghost, snare * 0.2, -0.2) + for sixteenth in range(16): + if sixteenth % 2: + score.drum("hat", o + sixteenth * 0.25, 0.022, 0.35) + else: + score.drum("hat", o + sixteenth * 0.25, 0.055 if sixteenth % 4 else 0.04, 0.3) + + +def compose_generation_floor(score: Score, stage: Stage) -> None: + """Stage 1: a heroic F major Genesis FM street-brawler groove for the expo hall.""" + # Intro: brass hits ring the rising F-A-C motif, the break kicks in, a roll leads in. + for bar, (root, tones) in enumerate(stage.intro): + o = bar * METER + if bar in (0, 2): + lift = 0 if bar == 0 else 12 + for k, midi in enumerate((65, 69, 72)): + score.tone("fm", midi + lift, o + k * 0.5, 1.5 if k == 2 else 0.45, 0.13, + pan=0.25 * (k - 1), ratio=1.0, index=1.6, feedback=0.7, + attack=0.015, decay=0.3, sustain=0.5, release=0.08, send=0.5) + score.drum("kick", o, 0.8) + score.drum("open", o, 0.1, -0.3) + if bar >= 1: + breakbeat(score, o, (0, 1.75, 2.5), bar == 3, 0.2, 2.0) + for beat, interval, beats in ((0, 0, 0.35), (0.75, 0, 0.2), (1.5, 12, 0.3), (2.5, 0, 0.3)): + slap(score, root + interval, o + beat, beats, 0.2) + # Body. + for index, (name, variation, bar) in enumerate(bar_chords(stage)): + o = (INTRO_BARS + index) * METER + root, tones = stage.chords[name][bar] + last = bar == 7 + # Slap bass: syncopated 16ths, popping the octave. + for beat, interval, beats in ((0, 0, 0.35), (0.75, 0, 0.2), (1.5, 12, 0.3), (2, 0, 0.3), + (2.5, 0, 0.2), (2.75, 12, 0.2), (3.5, 7, 0.35)): + slap(score, root + interval, o + beat, beats, 0.26) + # Brass stabs: on the one and the anticipation in A, on the offbeats in B. + for beat, beats in ((0, 0.4), (1.5, 0.3)) if name == "A" else ((0.5, 0.3), (2.5, 0.3)): + brass(score, tones, o + beat, beats, 0.04) + # Square arpeggio sparkle high up, sweeping left to right. + arp = [tones[0] + 24, tones[1] + 24, tones[2] + 24, tones[0] + 36] + for sixteenth in range(16): + score.tone("pulse", arp[(0, 1, 2, 3, 2, 1, 0, 1)[sixteenth % 8]], o + sixteenth * 0.25, + 0.18, 0.016, pan=-0.5 + sixteenth / 15, duty=0.25, decay=0.05, sustain=0.1) + breakbeat(score, o, (0, 1.75, 2.5) if bar % 2 == 0 else (0, 0.75, 2.5, 3.25), last, 0.22, 2.5) + if bar == 0: + score.drum("open", o, 0.1, -0.3) + # Heroic lead: brassy FM with a square an octave up, echoing. + for beat, midi, beats in stage.melody[name][bar]: + score.tone("fm", midi, o + beat, beats * 0.9, 0.12, ratio=1.0, index=1.3, feedback=0.6, + vibrato=0.006, attack=0.012, decay=0.4, sustain=0.65, send=0.5) + score.tone("pulse", midi + 12, o + beat, beats * 0.9, 0.03, pan=0.2, duty=0.25, + vibrato=0.006, decay=0.3, sustain=0.4) + if variation == "harm": + score.tone("fm", harmony_below(midi, tones), o + beat, beats * 0.9, 0.07, pan=-0.4, + ratio=1.0, index=1.1, feedback=0.6, vibrato=0.006, decay=0.4, sustain=0.6) + score.echo(0.75, 0.28, 0.25) + + +def compose_agentic_core(score: Score, stage: Stage) -> None: + """Stage 3: a dark, driving F minor FM brawler for the machine core.""" + # Intro: the gritty bass locks in, the falling motif sounds as an alarm, a roll. + for bar, (root, tones) in enumerate(stage.intro): + o = bar * METER + for sixteenth in range(16): + octave = 12 if sixteenth % 4 == 2 else 0 + score.tone("fm", root + octave, o + sixteenth * 0.25, 0.2, 0.1 + 0.02 * bar, ratio=1.0, + index=2.4, feedback=0.9, decay=0.05, sustain=0.35, release=0.015) + for beat in range(4): + score.drum("kick", o + beat, 0.7) + if bar >= 1: + for sixteenth in range(16): + score.drum("hat", o + sixteenth * 0.25, 0.05 if sixteenth % 2 else 0.02, 0.25) + if bar in (0, 2): + for k, midi in enumerate((84, 80, 77)): + score.tone("fm", midi, o + k * 0.75, 0.7, 0.11, pan=0.3 * (1 - k), + ratio=1.0, index=2.5, decay=0.3, sustain=0.3, send=0.7) + if bar == 3: + for k in range(16): + score.drum("snare", o + k * 0.25, 0.05 + 0.011 * k, 0.1) + # Body. + for index, (name, variation, bar) in enumerate(bar_chords(stage)): + o = (INTRO_BARS + index) * METER + root, tones = stage.chords[name][bar] + last = bar == 7 + # Gritty FM bass: relentless 16ths, the octave on each "and-a", a fifth to turn. + for sixteenth in range(16): + interval = (0, 0, 12, 0)[sixteenth % 4] + if sixteenth == 15: + interval = 7 + score.tone("fm", root + interval, o + sixteenth * 0.25, 0.2, 0.13, ratio=1.0, + index=2.4, feedback=0.9, decay=0.05, sustain=0.35, release=0.015) + if sixteenth % 4 == 0: + score.tone("tri", root - 12, o + sixteenth * 0.25, 0.9, 0.2, release=0.03) + # Scanning arpeggio: up two octaves and back, sweeping left to right and back. + arp = sorted({t + 12 * k for t in tones for k in (0, 1)}) + scan = arp + arp[-2:0:-1] + for sixteenth in range(16): + step = index * 16 + sixteenth + pan = math.sin(TAU * step / 32) * 0.7 + score.tone("pulse", scan[step % len(scan)] + 12, o + sixteenth * 0.25, 0.18, + 0.02 if variation == "sparse" else 0.026, pan=pan, duty=0.125, + decay=0.05, sustain=0.15) + # Brass hits: a stab every bar, a second push before the turnaround. + brass(score, tones, o, 0.5, 0.045, lift=0) + if bar % 2: + brass(score, tones, o + 2.75, 0.35, 0.04, lift=0) + # Drums: a heavy break with metallic hits on the "and" of four. + breakbeat(score, o, (0, 0.75, 1.5, 2.5, 2.75) if bar % 2 == 0 else (0, 1.5, 2, 2.75), + last, 0.24, 2.0) + if not last: + score.drum("open", o + 3.5, 0.09, 0.4) + # Lead: cold FM; the sparse first section only quotes the motif. + for beat, midi, beats in stage.melody[name][bar]: + if variation == "sparse" and bar % 2: + continue + score.tone("fm", midi, o + beat, beats * 0.9, 0.12, ratio=2.0, index=1.6, + vibrato=0.003, decay=0.35, sustain=0.55, send=0.55) + score.tone("pulse", midi + 12, o + beat, beats * 0.9, 0.025, pan=0.25, + duty=0.125, decay=0.2, sustain=0.3) + if variation == "harm": + score.tone("fm", harmony_below(midi, tones), o + beat, beats * 0.9, 0.07, + pan=-0.4, ratio=3.0, index=1.0, decay=0.35, sustain=0.5) + score.echo(0.75, 0.35, 0.28) + # Data glitches: stutter the last half-beat of the glitch section's phrases. + for index, (name, variation, bar) in enumerate(bar_chords(stage)): + o = (INTRO_BARS + index) * METER + if variation == "glitch" and bar in (1, 3, 5): + score.stutter(o + 3.5, 0.0625, 7) + + +def render(stage: Stage) -> tuple[list[int], int]: + """Compose `stage`; return interleaved 16-bit samples and the loop start frame.""" + score = Score(8 * len(stage.form), stage.bpm) + if stage.name == "generation_floor": + compose_generation_floor(score, stage) + else: + compose_agentic_core(score, stage) + channels = [score.left, score.right] + # Low-pass: settle on the loop body first so the state at the seam is continuous. + filtered = [] + for channel in channels: + previous = 0.0 + for sample in channel[score.loop_start :]: + previous += stage.lowpass * (sample - previous) + previous_body = previous + out = [0.0] * score.size + previous = 0.0 + for i, sample in enumerate(channel): + if i == score.loop_start: + # The body starts in its settled loop state so the seam is continuous. + # Ramp the difference into the intro's last 5 ms so the first pass + # into the body has no step either. + step = (1 - stage.lowpass) * (previous_body - previous) + ramp = round(0.005 * RATE) + for k in range(ramp): + out[i - ramp + k] += step * (k + 1) / (ramp + 1) + previous = previous_body + previous += stage.lowpass * (sample - previous) + out[i] = previous + filtered.append(out) + body = [x for channel in filtered for x in channel[score.loop_start :]] + rms = math.sqrt(sum(x * x for x in body) / len(body)) + scale = stage.rms / rms + frames = [] + for left, right in zip(*filtered): + frames.append(round(soft_limit(left * scale) * 32767)) + frames.append(round(soft_limit(right * scale) * 32767)) + return frames, score.loop_start + + +def soft_limit(x: float) -> float: + """Pass the sample below KNEE; above it, bend smoothly toward MAX_PEAK.""" + size = abs(x) + if size <= KNEE: + return x + room = MAX_PEAK - KNEE + return math.copysign(KNEE + room * math.tanh((size - KNEE) / room), x) + + +def build(out: Path, name: str) -> None: + """Write stage `name` to `out` as stereo WAV; print its loop offset and SHA-256.""" + out.mkdir(parents=True, exist_ok=True) + frames, loop_start = render(STAGES[name]) + path = out / f"heroes_villains_{name}.wav" + with wave.open(str(path), "wb") as stream: + stream.setparams((2, 2, RATE, 0, "NONE", "not compressed")) + stream.writeframes(struct.pack(f"<{len(frames)}h", *frames)) + print( + f"{path.name}: {len(frames) // 2} frames, {len(frames) / 2 / RATE:.6f}s, " + f"loop_offset {loop_start / RATE:.6f}s, bpm {STAGES[name].bpm}, " + f"SHA256 {hashlib.sha256(path.read_bytes()).hexdigest()}" + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--stage", choices=sorted(STAGES), required=True) + parser.add_argument( + "--out", + type=Path, + default=OUT, + help="Destination directory (encode the WAV to OGG from there)", + ) + arguments = parser.parse_args() + build(arguments.out, arguments.stage)