diff --git a/assets/models/enemy_adv_dragoon.glb b/assets/models/enemy_adv_dragoon.glb index 64e7158..b88dc65 100644 Binary files a/assets/models/enemy_adv_dragoon.glb and b/assets/models/enemy_adv_dragoon.glb differ diff --git a/assets/models/enemy_adv_marauder.glb b/assets/models/enemy_adv_marauder.glb index 972d852..7619452 100644 Binary files a/assets/models/enemy_adv_marauder.glb and b/assets/models/enemy_adv_marauder.glb differ diff --git a/assets/models/enemy_dragoon.glb b/assets/models/enemy_dragoon.glb index 64e7158..b88dc65 100644 Binary files a/assets/models/enemy_dragoon.glb and b/assets/models/enemy_dragoon.glb differ diff --git a/assets/models/enemy_dretch.glb b/assets/models/enemy_dretch.glb index f6a87ec..432ecb4 100644 Binary files a/assets/models/enemy_dretch.glb and b/assets/models/enemy_dretch.glb differ diff --git a/assets/models/enemy_mantis.glb b/assets/models/enemy_mantis.glb index 2880376..62fa824 100644 Binary files a/assets/models/enemy_mantis.glb and b/assets/models/enemy_mantis.glb differ diff --git a/assets/models/enemy_marauder.glb b/assets/models/enemy_marauder.glb index 5594bfa..5be26b9 100644 Binary files a/assets/models/enemy_marauder.glb and b/assets/models/enemy_marauder.glb differ diff --git a/assets/models/enemy_tyrant.glb b/assets/models/enemy_tyrant.glb index c9c0187..80b05f2 100644 Binary files a/assets/models/enemy_tyrant.glb and b/assets/models/enemy_tyrant.glb differ diff --git a/assets/models/player_bsuit.glb b/assets/models/player_bsuit.glb index 22b1fd1..6757883 100644 Binary files a/assets/models/player_bsuit.glb and b/assets/models/player_bsuit.glb differ diff --git a/assets/music/SUNO-PROMPTS.md b/assets/music/SUNO-PROMPTS.md index 8f7a0bb..89e666a 100644 --- a/assets/music/SUNO-PROMPTS.md +++ b/assets/music/SUNO-PROMPTS.md @@ -2,10 +2,40 @@ Six pieces. Everything the game needs, and nothing it doesn't. -The game's music system is already built and running (`src/audio-mix.ts`, -`updateMusicIntensity`). It is inert only because both intensity states currently -point at the same file, so clearing a wave changes nothing you can hear. Drop the -six WAVs named below into `assets/music/` and it comes alive with no code change. +--- + +## ✅ What actually came back (2026-07-13) — read this before regenerating + +The six tracks are in. **They are mastered and in the game.** Raw Suno renders live +in `src/`; `bun tools/music-master.ts` turns them into the loops the game loads. +Re-run it any time — the sources are never modified. + +Two things Suno did not honour, one of which turned out not to matter: + +- **Length: ignored, comprehensively.** Asked for 60–90 s beds and 2–4 s stingers; + got 162 s / 73 s / 195 s beds and 9 s / 24 s / **49 s** stingers. Handled by the + mastering tool. The victory sting's swell does not even LAND until 0:29, so + "take the first 4 seconds" would have shipped an intro — the windows are read off + the rendered waveforms (`bun tools/music-inspect.ts`), not guessed. + +- **Key: ignored, but consistently — which is the only thing that mattered.** Asked + for D minor; all three beds came back in **A# minor**. That is fine, and it is + fine for exactly the reason the rule below exists: the beds are keyed *to each + other*. Verified, not assumed — `estimateKey()` in `tools/audio.ts` runs a + chroma + Krumhansl-Schmuckler estimate over each track, and calm, combat and menu + all agree. Had they disagreed, the crossfade would have been unfixable and they + would have needed regenerating. + + **So if you regenerate ANY bed, regenerate ALL THREE in the same session**, or the + new one will be in a different key from the other two. + +- Tempo came back at ~120 BPM as asked (combat measures 120.4 with high confidence). + +**Do not hand-edit the files in `assets/music/`.** They are build output. Edit the +window in `tools/music-master.ts` and re-run. + +The rest of this file is the brief the tracks were written to. Keep it: it is what +you regenerate against. --- @@ -152,24 +182,34 @@ resolves. Save it for the one moment that deserves it. --- -## Making it loop (the part Suno won't do for you) - -Suno writes *songs* — they start, they end. The beds must not. After you download: - -1. Open the WAV in Audacity (free) or any editor. -2. Find a chunk from the middle where the arrangement is at full strength and cut - it **on the bar line**. At 120 BPM a bar is exactly **2.0 s**, so cut a length - that is a multiple of 2.0 s — **64.0 s (32 bars)** is a good target. -3. Crossfade the loop seam: copy the last ~1 s, fade it out over the tail, and - mix it over a fade-in at the head. (The wind ambience in - `tools/gen-sfx.ts` does exactly this trick if you want to see it in code.) -4. Play it on repeat for two minutes. If you can hear where it joins, move the cut. - -**Both beds must be the same length**, or they will drift apart over a long run and -the crossfade will land in a different part of the bar every time. - -If you'd rather not do the editing: send me the raw Suno WAVs and I'll cut, loop -and seam them — that part is scriptable and I can verify the seam automatically. +## Making it loop — done, by `tools/music-master.ts` + +You do not need to cut these by hand. Suno writes *songs*: they start, they build, +they end. The beds must not. The tool: + +1. **Searches** for the loop point rather than assuming one. It scores candidate + (start, length) pairs by how similar the music actually *sounds* at `start` and + at `start + length` — which is exactly what makes a crossfade inaudible, and + which self-corrects any error in the tempo estimate. (That matters: at hop 512 + the autocorrelation bins around 120 BPM are 119.7 and 122.3, so two tracks that + are both 120 land in *different bins* and appear to disagree by 2%. I nearly + time-stretched a track that did not need it.) +2. Steps candidates by the **bar**, because a seam anywhere else is mid-bar and no + crossfade will save it. +3. Rejects any loop containing the intro or the outro fade — one that did would get + quieter every time it wrapped. +4. Prefers the **longest** loop whose seam is good enough, rather than trading 30 + seconds of loop length for a 1 dB seam improvement nobody can hear. A short loop + is a cost the player actually notices. +5. Crossfades the seam **equal-power** (`cos`/`sin`), so the join does not dip ~3 dB + the way a linear fade would. + +What it produced: calm **63.1 s**, combat **47.8 s**, menu **55.9 s**. + +**Hear it for yourself:** `bun tools/music-loopcheck.ts` writes +`tools/.testout/music/loopcheck_*.wav` — each loop played through **twice**, with +the join at the exact midpoint. If you can hear where it is, the loop is wrong. +That is the only test that really counts, and it is the one only a human can run. --- @@ -185,4 +225,5 @@ Drop the files in `assets/music/` and play. Then: - Win the last wave → the victory sting. If the crossfade sounds like two songs colliding, the two beds are not in the same -key — that's the failure mode to listen for, and it's fixable only by regenerating. +key — that's the failure mode to listen for, and it's fixable only by regenerating +(**all three beds together**, per the note at the top). diff --git a/assets/music/music_calm.wav b/assets/music/music_calm.wav index 19f9bed..de81fc8 100644 Binary files a/assets/music/music_calm.wav and b/assets/music/music_calm.wav differ diff --git a/assets/music/music_combat.wav b/assets/music/music_combat.wav index 217f81b..79748a9 100644 Binary files a/assets/music/music_combat.wav and b/assets/music/music_combat.wav differ diff --git a/assets/music/music_menu.wav b/assets/music/music_menu.wav index 7ce7d67..0bcdc9f 100644 Binary files a/assets/music/music_menu.wav and b/assets/music/music_menu.wav differ diff --git a/assets/music/src/music_calm.wav b/assets/music/src/music_calm.wav new file mode 100644 index 0000000..19f9bed Binary files /dev/null and b/assets/music/src/music_calm.wav differ diff --git a/assets/music/src/music_combat.wav b/assets/music/src/music_combat.wav new file mode 100644 index 0000000..217f81b Binary files /dev/null and b/assets/music/src/music_combat.wav differ diff --git a/assets/music/src/music_menu.wav b/assets/music/src/music_menu.wav new file mode 100644 index 0000000..7ce7d67 Binary files /dev/null and b/assets/music/src/music_menu.wav differ diff --git a/assets/music/src/sting_death.wav b/assets/music/src/sting_death.wav new file mode 100644 index 0000000..85b6b50 Binary files /dev/null and b/assets/music/src/sting_death.wav differ diff --git a/assets/music/src/sting_victory.wav b/assets/music/src/sting_victory.wav new file mode 100644 index 0000000..3a2ee52 Binary files /dev/null and b/assets/music/src/sting_victory.wav differ diff --git a/assets/music/src/sting_wave_clear.wav b/assets/music/src/sting_wave_clear.wav new file mode 100644 index 0000000..7b12c6c Binary files /dev/null and b/assets/music/src/sting_wave_clear.wav differ diff --git a/assets/music/sting_death.wav b/assets/music/sting_death.wav index 85b6b50..2bc250f 100644 Binary files a/assets/music/sting_death.wav and b/assets/music/sting_death.wav differ diff --git a/assets/music/sting_victory.wav b/assets/music/sting_victory.wav index 3a2ee52..7fc674e 100644 Binary files a/assets/music/sting_victory.wav and b/assets/music/sting_victory.wav differ diff --git a/assets/music/sting_wave_clear.wav b/assets/music/sting_wave_clear.wav index 7b12c6c..c0b59ba 100644 Binary files a/assets/music/sting_wave_clear.wav and b/assets/music/sting_wave_clear.wav differ diff --git a/docs/ASSET-TODO.md b/docs/ASSET-TODO.md index 4b00461..f11f58c 100644 --- a/docs/ASSET-TODO.md +++ b/docs/ASSET-TODO.md @@ -25,7 +25,7 @@ today, it just sounds/looks better once you replace them. | A1 | Footstep SFX — grass ×4, dirt ×4, heavy ×1 | 🟡 | **Synthesised stand-ins shipped** (`bun tools/gen-sfx.ts`). A step is an *impact* plus a *scuff*: grass is nearly all scuff (bright, grainy), dirt is mostly impact (low thud + grit), the tyrant footfall is a 48 Hz body with debris on top. They are audible and they sit right in the mix — but they are synthesis, not recordings. Replace with the Sonniss GDC bundle (a documented source in `SOURCES.md`) as a straight file drop: same names, no code change. | | A2 | Weapon tail layers — rifle, blaster, chaingun, cannon | 🟡 | **Synthesised stand-ins shipped.** The "BOOM" half of crack-BOOM: band-limited decaying noise + a low body, routed to the reverb send by the mixer. Cannon runs 2.4 s and is still audible when the next shot lands. Real recordings would be better; the *system* is no longer silent. | | A3 | Wind / leaf-rustle ambience loop | 🟡 | **Synthesised stand-in shipped** (16 s, seamless — the tail is cross-faded over the head). SH-001 is now wired: three 3D sources placed on the forest's own centroids (computed from the world file, so moving the trees in the editor moves the sound), volume from distance **and from the live wind amplitude**, so the rustle swells with the same vector that bends the grass. | -| A4 | Music stems: calm + combat loops, and 3 stingers | 🔵 | **The one thing I can't fake — but everything around it is now built.** `assets/music/SUNO-PROMPTS.md` has the six prompts, the key/tempo lock (D minor, 120 BPM — the two beds are CROSSFADED, so a key mismatch will sound broken and cannot be fixed in the mix), the loop-cutting recipe, and the exact filenames. The game already loads all six if present and falls back to `game.wav` if not, so **dropping the WAVs in is the whole job** — no code change. Send me the raw Suno WAVs if you'd rather I cut and seam the loops. | +| ~~A4~~ | ~~Music stems: calm + combat loops, and 3 stingers~~ | ✅ | **Done 2026-07-13.** Six tracks generated, mastered and shipped. Suno ignored the lengths (162 s beds, a 49 s "stinger") and the key (came back **A# minor**, not D minor) — the key was fine because all three beds agree with *each other*, which is the only thing that mattered, and that was **verified** rather than assumed (chroma + Krumhansl in `tools/audio.ts`). `bun tools/music-master.ts` finds each loop point by searching for self-similarity, crossfades the seam, and trims the stingers to windows read off the waveform. Raw renders kept in `assets/music/src/`. | | A5 | Reload SFX (start / finish) | 🟡 | **Synthesised stand-ins shipped** — noise clack + two metallic partials; start is lower (mag out), finish is higher and harder (seated). | | A6 | UI click / hover SFX | 🟡 | **Synthesised stand-ins shipped** — soft sine blips. Kept deliberately dull: they fire on every menu move, and anything with a sharp transient becomes torture inside a minute. | diff --git a/docs/asset-sourcing.md b/docs/asset-sourcing.md new file mode 100644 index 0000000..ccda923 --- /dev/null +++ b/docs/asset-sourcing.md @@ -0,0 +1,81 @@ +# Where better models can actually come from + +Researched 2026-07-13, after D3 ("the licence does not matter — this is a demo that +should be as good as it can possibly get"). + +## The constraint that decides everything, and it is not the one D3 answered + +D3 freed us on *licence permissiveness*. It did **not** free us on +**redistribution**, and that is the binding constraint: + +> **Our assets are committed to a git repo. That is redistributing them as +> standalone files.** + +- **Mixamo** — the best free animation library in existence, and the obvious first + answer — permits use in projects but **forbids redistributing the raw character + and animation files as standalone assets**. A public repo full of `.glb` is + exactly that. You also cannot sub-license Adobe's assets under GPLv3. +- So Mixamo is usable as a **tool** (rig/retarget locally, keep outputs out of the + tree or fetch at build time), never as **repo content**. +- Every **CC0** source is free of this problem. CC-BY is fine too (attribution). + +Anything we commit must be CC0, CC-BY, CC-BY-SA, or GPL. That is the filter. + +## The finding worth sitting with + +**The Unvanquished aliens are hard to beat for free.** + +Five *distinct, non-humanoid* creatures, each with a complete game animation set, +at realistic fidelity, is precisely the asset class that essentially **does not +exist for free**. The free ecosystem is rich in: + +- humanoid **animation libraries** (Mixamo, Quaternius UAL2, KayKit), and +- **stylized low-poly** monsters (Quaternius, KayKit, Kenney), + +and thin in exactly what we already have. A PBR/texture uplift of the existing +alien meshes plus a genuinely good new **player character** may buy more visual +improvement per hour than any wholesale replacement. + +## Sources that are real, current, and usable + +| Source | Licence | Animated characters? | Format | How you get it | +|---|---|---|---|---| +| **Sketchfab** | per-model: CC0 / CC-BY / CC-BY-SA | **Yes — the only free source of *realistic* rigged creatures.** Quality varies; cherry-pick. | **glTF/GLB direct** (the Download API serves glTF/GLB/USDZ, not the original FBX) | Web download, or **Download API with OAuth** — filterable by licence + `animated`. Scriptable. | +| **Quaternius** | **CC0** | **Ultimate Monsters — 50 fully animated monsters.** Plus **Universal Animation Library 2** (130+ humanoid anims on a universal rig). | **glTF** | Direct download, no account. | +| **KayKit** (Kay Lousberg) | **CC0** | **161 humanoid animations**; Skeletons pack has 90+. Best free non-Mixamo humanoid anim set. | **glTF + FBX** | itch.io, no account. | +| **Anything World** | you declare `cc0`/`ccby`/`mit` on output | REST API that **auto-rigs AND animates an arbitrary mesh you upload — including non-humanoids.** | glTF | API, free monthly credits. | +| **AccuRig** (Reallusion) | free | Auto-rigger, **runs offline** — the clean alternative to uploading to Adobe. | FBX/USD | Free account to export. | +| **MetaHuman** | **licence changed mid-2025 — no longer Unreal-only**; free under $1M rev | Photoreal humans; **best-in-class player character, free.** Ships **no gameplay animations** — retarget onto its skeleton. | FBX → **needs a glTF conversion pass** | Web/Blender tooling. | +| **Mixamo** | **use yes, REDISTRIBUTION no** | ~100 characters + thousands of anims. Its "monsters" (Mutant, Warrok) are on the **humanoid rig**, so the whole library applies to them. | **FBX/Collada only — no glTF** | Manual web UI. **No API.** | + +**Sketchfab has archive risk.** The Store closed and moved to Epic's Fab, and +**CC0 / CC-BY-SA / NC / ND models cannot migrate**. If we pick models there, +**download and commit them immediately.** + +## Checked and rejected + +- **Poly Haven** — CC0, has an API, but **static props/textures/HDRIs only. No + characters, no rigs.** Excellent for environment; irrelevant here. +- **Kenney** — CC0, glTF, but ~3 animations per character. Below our bar. +- **Blender Studio** (Sprite Fright, Charge, Agent 327) — CC-BY, beautiful, and + **not game-ready**: film rigs in `.blend`, no idle/walk/run/attack/death clips, + no game topology. We would be authoring animation from scratch. +- **Other GPL game sets** (Xonotic, Red Eclipse, 0 A.D., SuperTuxKart) — all + **lower quality than the Unvanquished assets we already ship.** Not an upgrade. +- **Ready Player Me** — **dead.** Domain no longer resolves; sunset Jan 2026. +- **Epic Paragon** — AAA heroes with full anim sets, free, *tempting* — but they + are Epic first-party content historically labelled Unreal-only. **Could not + confirm** non-Unreal use is permitted. Do not build on it without written + clarification. +- **OpenGameArt** — CC0/CC-BY/GPL mix, a few gems, but rigs are often broken or + unweighted. Cherry-pick only. + +## Unverified — do not act on these without checking + +- Whether **Paragon** is usable outside Unreal. +- **Meshy's** exact free-tier commercial terms (its free tier is reportedly + non-commercial; this came from comparison articles, not its own pricing page). +- **Truebones** creature-mocap licence. (And be sceptical anyway: creature mocap is + near-useless unless the monster's skeleton matches theirs.) +- The practical fidelity of a **MetaHuman → glTF** export — specifically that + strand-based groom hair does **not** survive it; you would need hair cards. diff --git a/docs/tickets.md b/docs/tickets.md index 2976fcb..7c61061 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -461,7 +461,35 @@ sizes. --- -## SH-034 — Locomotion & animation blending ✅ *(shipped — EN-028 mixer landed: crossfades, upper-body attack mask, speed-synced walk)* +## SH-034 — Locomotion & animation blending ✅ *(shipped; **thresholds corrected 2026-07-13** — see below)* + +**The bug that shipped with it.** The clip was chosen by crossing a speed +threshold: run above 7.0 m/s, walk below. But `MOVE_SPEED` was **6.0**, so normal +movement *always* took the walk clip — and the walk clip is authored for ~2.6 m/s, +so covering 6 m/s of ground meant playing it at the **2.2x clamp**. Even at the +clamp the stride only covers ~5.7 m/s, so the feet could never keep up with the +floor. That is what read as the player "sliding": the animation was playing the +whole time, at double speed, losing a race with the ground. + +Sprint (9.0 m/s target, ~7.4 actual) only just cleared 7.0, so the run clip +flickered in and out at the boundary rather than being a state you could feel. + +**Fixed two ways, and the second is the real one:** + +1. The clip now follows what the player is *doing* — `isSprinting()` runs, moving + walks, standing idles. No threshold to sit on. (`isSprinting()` was exported by + `player.ts` and imported by nobody, which was the clue.) +2. `MOVE_SPEED` 6.0 → **4.5**. 6.0 m/s is a *run* speed wearing a walk animation; + no clip selection fixes that. `SPRINT_MUL` 1.5 → **2.0** absorbs it, so the top + speed is **still 9.0 m/s** and nothing about outrunning a dragoon pounce + changed. Only the cruise is slower — and now holding shift is the difference + between a walk and a run, which is what it should always have been. + +**The clamp is the canary.** Playback rate is (actual speed / the speed the clip +was authored for). If that ratio ever *reaches* the clamp, the movement speed and +the clip no longer belong together, and the fix is the speed constant — not a wider +clamp. Measured after the change: walk lands at ~1.4x, sprint at ~1.1x. Both well +inside it. **Why:** every animation change is a hard swap; enemies play walk at a fixed rate regardless of actual velocity (foot-sliding); the player @@ -562,7 +590,7 @@ damage audibly ducks the music; no render-thread glitches. --- -## SH-036 — Dynamic music intensity 🟡 *(fully wired 2026-07-13; the only thing missing is the audio itself — ASSET-TODO A4)* +## SH-036 — Dynamic music intensity ✅ *(shipped 2026-07-13 — real music, mastered and looping)* **Why:** one looping combat track from the first frame to the last flattens the pacing the wave director already creates. @@ -1107,3 +1135,93 @@ object before parsing. **Also fixed:** the menu title was nailed to `sh * 0.5 - 200` while the list is centred — so adding rows grew the list upward straight through it. The title is anchored to the top of the list now. + +--- + +## SH-046 — Character fidelity: PBR uplift + a better player model 🟡 *(uplift shipped 2026-07-13; the new player model needs you)* + +**Why:** the characters were the weakest surface in frame. The terrain has +triplanar PBR *and* a detail normal; every alien and the player had a **base colour +texture and nothing else** — no normal map, no roughness map, flat `roughness 0.9, +metallic 0`. They read as matte toys standing on a real world. + +### Part 1 — PBR uplift ✅ *(shipped)* + +**The maps were on disk the whole time.** The Unvanquished source ships authored +`_n` (normal) and `_s` (specular) for **every** character; `tools/convert-aliens-anim.ts` +only ever read the diffuse. Now it reads all three. + +- `_n` → glTF `normalTexture`. (No TANGENT attribute needed — the engine + reconstructs a TBN from screen-space derivatives; SH-014 proved this.) +- `_s` → glTF `metallicRoughnessTexture`. **These are not the same thing.** A + Quake-lineage spec map stores specular *intensity*; glTF wants *roughness* in + green and *metalness* in blue. Mapping intensity straight to gloss — my first + attempt — drove bright texels to roughness ~0 and the battlesuit came out + **looking wet**. The mapping is now deliberately conservative: roughness spans + 1.0 → 0.35, never a mirror. The point is surface *variation*, which there was + none of; it is not to make anything glossy. +- The **battlesuit alone** gets metalness from the spec map. It is armour plate, + and a dielectric with a tight white highlight reads as wet plastic — metalness is + what makes it read as metal, because the reflection gets tinted by the base + colour instead of staying white. The aliens stay fully dielectric: metallic + chitin would look like tinfoil. +- The `_adv` upgrade skins have no `_n`/`_s` of their own (they are recolours of + the base on the same mesh and UVs), so they borrow the base skin's maps. + +Cost: ~0.5 MB per model. All 8 models, every material, now carry both maps. + +### Part 1b — texture resolution: we were downsampling the art by 4-8x ✅ *(2026-07-13)* + +`TEX_MAX` in the converter was **512**. The Unvanquished source art is **2048² +(player)** and **4096² (aliens)**. Every character had been shipped at a fraction of +its authored detail, on models the camera is rarely more than a few metres from. + +Now 1024 for the aliens and **2048 for the player** (it is on screen 100% of the +time and earns more). That is 4x the texels on the aliens and 16x on the player. + +**Cost: none that matters.** 53 fps before, 53 fps after — this was never +fill-bound, it was texture-starved. Models grew 28 MB -> 65 MB on disk. + +Between this and the normal/spec maps (Part 1), the characters now carry roughly +**64x the surface information** they did this morning, and not one byte of it was +downloaded — it was all sitting in `vendor/`. + +### Part 2 — a better player character ❌ *won't-do (searched 2026-07-13, with an API key)* + +Searched Sketchfab properly (authenticated, Download API), across `cc0` / `by` / +`by-sa`, filtered to **downloadable + animated**, over ten character queries. +**43 candidates. None is an upgrade.** The honest tally: + +- The realistic-ish ones ship **3-5 animations** ("Infantryman" 48k verts / 3 anims; + "Animated Astronaut" 31k / 4; "Stylized Sci-Fi Officer" 89k / 5). +- The ones with real animation counts are **low-poly stylized** ("Business Man" 25 + anims / 2.4k verts; "Futuristic soldier (Free)" 18 anims / 3.7k verts). +- The one high-poly, well-animated hit is a **Black Myth: Wukong rip** — someone + else's IP, licence label notwithstanding. Not touching it. + +**The battlesuit has 34 purpose-built clips** — idle, walk, run, four strafes, five +crouch states, jump, land, pain, die, attack, plus per-weapon pose deltas. Swapping +it for a 4-animation astronaut would trade a complete locomotion set for a prettier +T-pose. Retargeting a CC0 animation library (KayKit, Quaternius UAL2) onto a new +mesh is the only way round that, and it is a Blender-rigging project, not an +afternoon. + +This is exactly what `docs/asset-sourcing.md` predicted: the free ecosystem is rich +in humanoid animation *libraries* and stylized low-poly monsters, and thin in +precisely what we already have. **Keep the bsuit.** The uplift above is the better +buy, and it is done. + +The player is on screen 100% of the time and is the model most worth replacing. +Per `docs/asset-sourcing.md`, the plan is a realistic CC0/CC-BY humanoid from +**Sketchfab** (its Download API serves **glTF/GLB directly** — no conversion), +animated from **KayKit** (161 anims) or **Quaternius UAL2** (130+), both **CC0, +glTF, and redistributable in a GPL repo**. + +**What I need from you** — I cannot get past either gate: +- **Sketchfab requires OAuth.** Give me an API token and I can search, filter by + licence, and pull GLBs directly. Otherwise pick a model and drop the `.glb` in. +- **itch.io returns 403 to scripted downloads**, so KayKit needs a manual grab. + +**Reconsider first, honestly:** with the uplift landed, the battlesuit now reads as +real armour. It may no longer be the weakest thing in frame. Look at it before +spending on a replacement. diff --git a/src/main.ts b/src/main.ts index 47fb8d9..ed7a51f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -32,6 +32,7 @@ import { setCloudShadows, setTaaEnabled, setRenderScale, setPresentMode, setSsgiEnabled, setSsaoEnabled, setSsrEnabled, + setPathTracing, isPathTracingSupported, setShadowsEnabled, setBloomEnabled, setShadowsAlwaysFresh, setManualExposure, gamepadRumble, readFile, } from 'bloom/core'; @@ -57,7 +58,7 @@ import { import { initInput, readInput, drawTouchControls, MOBILE, aimAssistScale } from './input'; import { createPlayer, updatePlayerController, playerPosition, - playerGrounded, playerSpeed, startDodge, isDodging, dodgeCooldownFrac, + playerGrounded, playerSpeed, startDodge, isDodging, dodgeCooldownFrac, isSprinting, } from './player'; import * as W from './world-runtime'; import { @@ -106,6 +107,32 @@ initInput(); // SH-037 — settings must load BEFORE anything reads them (input sensitivity, // audio volumes, FOV, shake). A missing file is a first run, not an error. SET.loadSettings(); + +// ---- CLI overrides for A/B-testing graphics settings ----------------------- +// `main --render-scale 0.5|0.75|1` and `main --output-scale 0.5|0.75|1` +// override settings.json for this launch (they land in the live settings +// array, so the video menu shows them — and saving from the pause menu +// persists them). Values are matched against the fixed list instead of +// parsed: string equality on argv is safe (world-runtime does it), numeric +// parsing of FFI strings is not (perry-quirks #5). `--pt off|prog|rt` +// further below starts in that path-tracing mode without F9 taps. +function cliArg(name: string): string { + const n = process.argv.length; + for (let i = 0; i < n - 1; i++) { + if (process.argv[i] === name) return process.argv[i + 1]; + } + return ''; +} +function cliScaleOverride(name: string, idx: number): void { + const v = cliArg(name); + if (v === '0.25') SET.set(idx, 0.25); + else if (v === '0.5' || v === '0.50') SET.set(idx, 0.5); + else if (v === '0.75') SET.set(idx, 0.75); + else if (v === '1' || v === '1.0') SET.set(idx, 1.0); +} +cliScaleOverride('--render-scale', SET.SET_RENDER_SCALE); +cliScaleOverride('--output-scale', SET.SET_OUTPUT_SCALE); + FEEL.setShakeScale(SET.get(SET.SET_SHAKE)); initMenus(); @@ -312,14 +339,15 @@ setWind(0.85, 0.50, WIND_AMP, 1.6); setCloudShadows(0.45, 150, 0.008, 6); // TAA + TSR reconstruction. Setting the scale explicitly opts out of the // legacy TAA coupling (which would otherwise silently halve the internal -// resolution). 0.5 at 4K output = 1920×1080 internal, reconstructed to -// native by the TSR upscale inside the TAA pass — the pixel-bound passes -// (material/G-buffer/GTAO/SSR/SSGI) run at quarter cost while the output -// (and the HUD) stays native-sharp. Measured on the 4K dev box: ~20 fps at -// native internal vs ~45 fps here, with the composite sharpen covering the -// reconstruction softness. +// resolution). 0.75 at 4K output = 2880x1620 internal, reconstructed to +// native by the TSR upscale inside the TAA pass. Re-measured 2026-07-14 +// on the 4K dev box after the perf rounds: 0.5 ~46 fps but visibly +// upscale-soft, 0.75 ~30 fps and dramatically sharper, 1.0 ~19 fps +// (stills territory - selectable in the video menu). The PT realtime +// trace grid is budget-capped engine-side, so this scale does not +// multiply the ray cost. setTaaEnabled(true); -setRenderScale(0.5); +setRenderScale(0.75); // 2026-07-06 fullscreen-lag investigation: the Lumen SW-GI camera-follow // bakes (SDF clipmap + WSRC) used to re-run as single full-volume // dispatches whenever the view moved — a 1-2.4 s GPU stall every ~5 s of @@ -685,6 +713,12 @@ const mdlPlayer = loadModel('assets/models/player_bsuit.glb'); const animPlayer = loadModelAnimation('assets/models/player_bsuit.glb'); // human_bsuit animation indices (IQE declaration order): // 0 idle, 7 attack, 8 run, 12 walk. +// The ground speed each locomotion clip was AUTHORED to travel at. The playback +// rate is (actual speed / this), which is what plants the feet — get these wrong +// and the character skates. +const ANIM_WALK_SPEED = 2.6; +const ANIM_RUN_SPEED = 6.5; + const PLAYER_ANIM_IDLE = 0; const PLAYER_ANIM_WALK = 12; const PLAYER_ANIM_RUN = 8; @@ -1625,6 +1659,19 @@ let perfOverlayOn = false; // docs/shadow-cascade-and-ssao-fixes.md). Kept as a standing debug aid. let dbgSsgi = true; let dbgSsao = true; +// F9 cycles path tracing: 0 off -> 1 progressive -> 2 realtime -> 0. +// Progressive accumulates while the camera is still (stand still and the +// image converges); realtime is the denoised gameplay mode. No-op on +// devices without hardware ray query. +let dbgPtMode = 0; +const ptSupported = isPathTracingSupported(); +// `--pt prog|rt` (or 1|2) starts the run already in that mode. Goes through +// dbgPtMode so the F9 cycle and the HUD tag stay truthful. +const ptArg = cliArg('--pt'); +if (ptSupported) { + if (ptArg === 'prog' || ptArg === '1') { dbgPtMode = 1; setPathTracing(1); } + else if (ptArg === 'rt' || ptArg === '2') { dbgPtMode = 2; setPathTracing(2); } +} let dbgSsr = true; let dbgShadow = true; disableCursor(); @@ -1921,6 +1968,10 @@ while (!windowShouldClose() && !aitestDone) { if (isKeyPressed(Key.F6)) { dbgSsao = !dbgSsao; setSsaoEnabled(dbgSsao); } if (isKeyPressed(Key.F7)) { dbgSsr = !dbgSsr; setSsrEnabled(dbgSsr); } if (isKeyPressed(Key.F8)) { dbgShadow = !dbgShadow; setShadowsEnabled(dbgShadow); } + if (isKeyPressed(Key.F9) && ptSupported) { + dbgPtMode = (dbgPtMode + 1) % 3; + setPathTracing(dbgPtMode); + } const input = readInput(dtReal); testFrame = testFrame + 1; @@ -3356,23 +3407,36 @@ while (!windowShouldClose() && !aitestDone) { // pose and fake recoil + muzzle flash on the weapon. const modelYaw = Math.PI / 2 - camYaw; - // SH-034 / EN-028 — locomotion through the mixer instead of hard clip - // swaps. animPlay is idempotent, so we simply state the clip we want every - // frame and the engine crossfades if that changed. The run clip finally - // gets used (it was loaded and never played), and the playback RATE is - // driven by actual speed, which is what kills the foot-sliding. + // SH-034 / EN-028 — locomotion through the mixer instead of hard clip swaps. + // animPlay is idempotent, so we state the clip we want every frame and the + // engine crossfades if it changed. + // + // The clip is chosen by what the player is DOING, not by crossing a speed + // threshold. The old code switched to the run clip only above 7.0 m/s — but + // the walk speed is 6.0, so normal movement ALWAYS played the walk clip, and + // to cover 6 m/s of ground with a 2.6 m/s stride it ran at the 2.2x clamp. + // That is the "sliding": a walk cycle at double speed whose stride still + // cannot keep up with the floor. Sprint (9.0 m/s target, ~7.4 actual) only + // just cleared 7.0, so the run clip flickered in and out at the boundary. + // + // Now: sprinting runs, moving walks, standing idles. No threshold to sit on. const spd = playerSpeed(); - const runIsh = spd > 7.0; + const sprinting = isSprinting(); const wantClip = spd > 0.4 - ? (runIsh ? PLAYER_ANIM_RUN : PLAYER_ANIM_WALK) + ? (sprinting ? PLAYER_ANIM_RUN : PLAYER_ANIM_WALK) : PLAYER_ANIM_IDLE; - // Authored stride speeds: walk ~2.6 m/s, run ~6.5 m/s. Scaling playback by - // (actual / authored) makes the feet land where the ground is. - const authored = wantClip === PLAYER_ANIM_RUN ? 6.5 - : (wantClip === PLAYER_ANIM_WALK ? 2.6 : 1); + + // Playback rate = actual speed / the speed the clip was authored to travel + // at. That is what plants the feet: the stride then covers exactly the ground + // the character does. Clamp generously — outside this the cycle stops reading + // as a gait at all — but the clamp should never be REACHED in normal play. If + // it is, the movement speed and the clip no longer belong together, and the + // fix is the speed constant, not a wider clamp. + const authored = wantClip === PLAYER_ANIM_RUN ? ANIM_RUN_SPEED + : (wantClip === PLAYER_ANIM_WALK ? ANIM_WALK_SPEED : 1); const rate = wantClip === PLAYER_ANIM_IDLE ? 1 - : Math.max(0.35, Math.min(2.2, spd / authored)); + : Math.max(0.5, Math.min(1.8, spd / authored)); animPlay(animPlayer, wantClip, 0.15, rate, true); animUpdate(animPlayer, dt, PLAYER_SCALE, pp.x, pp.y + PLAYER_MODEL_Y_OFFSET, pp.z, modelYaw); @@ -3731,8 +3795,11 @@ while (!windowShouldClose() && !aitestDone) { const dbgLine = 'F5 SSGI ' + (dbgSsgi ? 'ON ' : 'off') + ' F6 SSAO ' + (dbgSsao ? 'ON ' : 'off') + ' F7 SSR ' + (dbgSsr ? 'ON ' : 'off') - + ' F8 SHADOW ' + (dbgShadow ? 'ON ' : 'off'); - drawRect(6, 6, 620, 30, { r: 0, g: 0, b: 0, a: 170 }); + + ' F8 SHADOW ' + (dbgShadow ? 'ON ' : 'off') + // The vN suffix is a build tag: bump it with each PT engine drop + // so a stale main.exe is identifiable at a glance in the HUD. + + ' F9 PT ' + (!ptSupported ? 'n/a' : (dbgPtMode === 0 ? 'off' : (dbgPtMode === 1 ? 'PROG' : 'RT'))) + ' v10'; + drawRect(6, 6, 760, 30, { r: 0, g: 0, b: 0, a: 170 }); drawText(dbgLine, 14, 12, 18, { r: 255, g: 240, b: 120, a: 255 }); } diff --git a/src/player.ts b/src/player.ts index cc44e97..07a2335 100644 --- a/src/player.ts +++ b/src/player.ts @@ -11,7 +11,16 @@ import { const HALF_HEIGHT = 0.6; const RADIUS = 0.32; // slightly slimmer so player can squeeze between trees const EYE_OFFSET = 0.65; // eye above capsule center -const MOVE_SPEED = 6.0; +// Walking, not running. 6.0 m/s was a RUN speed wearing a walk animation: the +// walk clip is authored at ~2.6 m/s, so covering 6 m/s of ground meant playing it +// at the 2.2x clamp, and even then the stride could not keep up with the floor. +// That is what read as the player "sliding". +// +// Sprint absorbed the difference instead — SPRINT_MUL went 1.5 -> 2.0, so the TOP +// speed is still 9.0 m/s and nothing about escaping a dragoon pounce changed. Only +// the cruise is slower, and now holding shift is the difference between a walk and +// a run, which is what it should always have been. +const MOVE_SPEED = 4.5; const AIR_SPEED = 4.5; const ACCEL = 14.0; // ground horizontal velocity lerp rate const AIR_ACCEL = 4.0; @@ -24,7 +33,7 @@ const CLIMB_ASSIST = 0.45; // SH-032 — sprint + dodge. The enemies (mantis darts, dragoon pounces) were // more mobile than the player, which is exactly backwards for a power fantasy. -const SPRINT_MUL = 1.5; +const SPRINT_MUL = 2.0; // 4.5 -> 9.0 m/s; top speed unchanged from the old 6.0 x 1.5 const DODGE_SPEED = 18.0; // m/s during the burst const DODGE_TIME = 0.25; // seconds const DODGE_COOLDOWN = 1.2; diff --git a/src/settings.ts b/src/settings.ts index 406a6cb..fe575c9 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -66,7 +66,11 @@ function defaults(): void { V[SET_INVERT_Y] = 0; V[SET_FOV] = 70; V[SET_SHAKE] = 1.0; - V[SET_RENDER_SCALE] = 0.5; + // 0.75: re-measured 2026-07-14 — 0.5 reads visibly upscale-soft at 4K + // while 0.75 is dramatically sharper at ~30 fps (1.0 = ~19 fps, kept + // selectable for stills). PT's realtime ray budget is decoupled from + // this engine-side, so raising it does not slow path tracing. + V[SET_RENDER_SCALE] = 0.75; V[SET_AIM_TOGGLE] = 0; V[SET_SPRINT_TOGGLE] = 0; V[SET_COLORBLIND] = 0; diff --git a/tools/audio.ts b/tools/audio.ts new file mode 100644 index 0000000..9c8e76c --- /dev/null +++ b/tools/audio.ts @@ -0,0 +1,288 @@ +// Shared WAV I/O + analysis for the music tools. +// +// PCM16 only — that is what Suno hands back and what the engine streams. Anything +// else is rejected loudly rather than half-decoded into noise. + +import { readFileSync, writeFileSync } from 'node:fs'; + +export interface Wav { + sampleRate: number; + channels: number; + /// Interleaved samples, -1..1. + data: Float32Array; + /// Frames = data.length / channels. + frames: number; +} + +export function readWav(path: string): Wav { + const b = readFileSync(path); + if (b.toString('ascii', 0, 4) !== 'RIFF' || b.toString('ascii', 8, 12) !== 'WAVE') { + throw new Error(path + ': not a RIFF/WAVE file'); + } + let p = 12; + let sampleRate = 0, channels = 0, bits = 0, fmtTag = 0; + let dataOff = -1, dataLen = 0; + while (p + 8 <= b.length) { + const id = b.toString('ascii', p, p + 4); + const len = b.readUInt32LE(p + 4); + if (id === 'fmt ') { + fmtTag = b.readUInt16LE(p + 8); + channels = b.readUInt16LE(p + 10); + sampleRate = b.readUInt32LE(p + 12); + bits = b.readUInt16LE(p + 22); + } else if (id === 'data') { + dataOff = p + 8; + dataLen = len; + } + p += 8 + len + (len & 1); // chunks are word-aligned + } + if (dataOff < 0) throw new Error(path + ': no data chunk'); + if (fmtTag !== 1 || bits !== 16) { + throw new Error(`${path}: expected PCM16, got fmtTag=${fmtTag} bits=${bits}`); + } + const frames = Math.floor(dataLen / (channels * 2)); + const data = new Float32Array(frames * channels); + for (let i = 0; i < frames * channels; i++) { + data[i] = b.readInt16LE(dataOff + i * 2) / 32768; + } + return { sampleRate, channels, data, frames }; +} + +export function writeWav(path: string, w: Wav): void { + const n = w.frames * w.channels; + const bytes = Buffer.alloc(44 + n * 2); + bytes.write('RIFF', 0, 'ascii'); + bytes.writeUInt32LE(36 + n * 2, 4); + bytes.write('WAVE', 8, 'ascii'); + bytes.write('fmt ', 12, 'ascii'); + bytes.writeUInt32LE(16, 16); + bytes.writeUInt16LE(1, 20); // PCM + bytes.writeUInt16LE(w.channels, 22); + bytes.writeUInt32LE(w.sampleRate, 24); + bytes.writeUInt32LE(w.sampleRate * w.channels * 2, 28); + bytes.writeUInt16LE(w.channels * 2, 32); + bytes.writeUInt16LE(16, 34); + bytes.write('data', 36, 'ascii'); + bytes.writeUInt32LE(n * 2, 40); + for (let i = 0; i < n; i++) { + let v = Math.round(w.data[i] * 32767); + if (v > 32767) v = 32767; + if (v < -32768) v = -32768; + bytes.writeInt16LE(v, 44 + i * 2); + } + writeFileSync(path, bytes); +} + +export function toMono(w: Wav): Float32Array { + if (w.channels === 1) return w.data; + const m = new Float32Array(w.frames); + for (let i = 0; i < w.frames; i++) { + let s = 0; + for (let c = 0; c < w.channels; c++) s += w.data[i * w.channels + c]; + m[i] = s / w.channels; + } + return m; +} + +// ---- onset envelope + tempo ------------------------------------------------- + +export const HOP = 512; // ~10.7 ms at 48 kHz + +/// Half-wave-rectified log-energy flux. Not a spectral flux (no FFT here), which +/// makes it weaker on pads and perfectly adequate on anything with a kick drum — +/// which is the track whose tempo we actually need. +export function onsetEnvelope(mono: Float32Array, sr: number): Float32Array { + const frames = Math.floor(mono.length / HOP); + const energy = new Float32Array(frames); + for (let f = 0; f < frames; f++) { + let e = 0; + const off = f * HOP; + for (let i = 0; i < HOP; i++) { const v = mono[off + i]; e += v * v; } + energy[f] = Math.log(1e-8 + e / HOP); + } + const env = new Float32Array(frames); + for (let f = 1; f < frames; f++) { + const d = energy[f] - energy[f - 1]; + env[f] = d > 0 ? d : 0; + } + return env; +} + +export interface Tempo { bpm: number; confidence: number; } + +/// Autocorrelate the onset envelope over a plausible tempo range. +/// +/// Returns the best period AND a confidence, because on an ambient pad with no +/// percussion this is close to guesswork and the caller must be told so rather +/// than handed a confident-looking number. +export function estimateTempo(env: Float32Array, sr: number, + minBpm = 70, maxBpm = 180): Tempo { + const fps = sr / HOP; + const minLag = Math.floor(fps * 60 / maxBpm); + const maxLag = Math.ceil(fps * 60 / minBpm); + + let mean = 0; + for (let i = 0; i < env.length; i++) mean += env[i]; + mean /= env.length; + + let best = -1, bestLag = minLag, sum = 0, count = 0; + for (let lag = minLag; lag <= maxLag; lag++) { + let acc = 0; + for (let i = lag; i < env.length; i++) acc += (env[i] - mean) * (env[i - lag] - mean); + acc /= (env.length - lag); + sum += acc; count++; + if (acc > best) { best = acc; bestLag = lag; } + } + const avg = sum / Math.max(1, count); + + // Parabolic interpolation around the winning lag. WITHOUT this the estimate is + // quantised to integer frame lags, and at hop=512/48kHz the bins around 120 BPM + // are 119.7 and 122.3 — so two tracks that are both 120 land in different bins + // and look like they disagree by 2%. That is an artefact of the estimator, not + // a property of the music, and it nearly sent me time-stretching a track that + // did not need it. + const at = (lag: number): number => { + let acc = 0; + for (let i = lag; i < env.length; i++) acc += (env[i] - mean) * (env[i - lag] - mean); + return acc / (env.length - lag); + }; + let refined = bestLag; + if (bestLag > minLag && bestLag < maxLag) { + const y0 = at(bestLag - 1), y1 = best, y2 = at(bestLag + 1); + const denom = y0 - 2 * y1 + y2; + if (Math.abs(denom) > 1e-12) { + const d = 0.5 * (y0 - y2) / denom; + if (d > -1 && d < 1) refined = bestLag + d; + } + } + const bpm = 60 * fps / refined; + // Confidence = how far the winning lag stands above the average correlation. + const confidence = avg > 0 ? (best / avg) : 0; + return { bpm, confidence }; +} + +/// Find the phase (in frames) of the strongest beat grid at `bpm` — i.e. where the +/// downbeats actually are. Cutting a loop anywhere else puts the seam mid-bar. +export function beatPhase(env: Float32Array, sr: number, bpm: number, beatsPerBar: number): number { + const fps = sr / HOP; + const beatLag = fps * 60 / bpm; + const barLag = beatLag * beatsPerBar; + let best = -Infinity, bestPhase = 0; + const step = Math.max(1, Math.floor(barLag / 96)); + for (let ph = 0; ph < Math.floor(barLag); ph += step) { + let acc = 0; + for (let b = 0; ; b++) { + const f = Math.round(ph + b * barLag); + if (f >= env.length) break; + acc += env[f]; + } + if (acc > best) { best = acc; bestPhase = ph; } + } + return bestPhase * HOP; // frames +} + +/// RMS envelope in dB, one value per `hop` frames. For plotting and for finding +/// where a track is actually at full strength. +export function rmsDb(mono: Float32Array, hop: number): Float32Array { + const n = Math.floor(mono.length / hop); + const out = new Float32Array(n); + for (let i = 0; i < n; i++) { + let e = 0; + for (let j = 0; j < hop; j++) { const v = mono[i * hop + j]; e += v * v; } + out[i] = 20 * Math.log10(Math.sqrt(e / hop) + 1e-6); + } + return out; +} + +// ---- key detection ---------------------------------------------------------- +// +// I told the composer that a KEY MISMATCH between the two beds is the one failure +// the mix cannot rescue — they are crossfaded, not cut, so a 2 s overlap of two +// keys is two songs colliding. It would be negligent to hand back loops without +// checking that claim against the audio that actually came back. + +/// In-place iterative radix-2 FFT. `re`/`im` length must be a power of two. +function fft(re: Float32Array, im: Float32Array): void { + const n = re.length; + for (let i = 1, j = 0; i < n; i++) { + let bit = n >> 1; + for (; j & bit; bit >>= 1) j ^= bit; + j ^= bit; + if (i < j) { + let t = re[i]; re[i] = re[j]; re[j] = t; + t = im[i]; im[i] = im[j]; im[j] = t; + } + } + for (let len = 2; len <= n; len <<= 1) { + const ang = -2 * Math.PI / len; + const wr = Math.cos(ang), wi = Math.sin(ang); + for (let i = 0; i < n; i += len) { + let cr = 1, ci = 0; + for (let k = 0; k < len / 2; k++) { + const ur = re[i + k], ui = im[i + k]; + const vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci; + const vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr; + re[i + k] = ur + vr; im[i + k] = ui + vi; + re[i + k + len / 2] = ur - vr; im[i + k + len / 2] = ui - vi; + const nr = cr * wr - ci * wi; + ci = cr * wi + ci * wr; cr = nr; + } + } + } +} + +const NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + +/// 12-bin pitch-class energy, averaged over the whole track. +export function chroma(mono: Float32Array, sr: number): Float32Array { + const N = 4096; + const re = new Float32Array(N), im = new Float32Array(N); + const out = new Float32Array(12); + const hann = new Float32Array(N); + for (let i = 0; i < N; i++) hann[i] = 0.5 - 0.5 * Math.cos(2 * Math.PI * i / N); + + for (let off = 0; off + N < mono.length; off += N) { + for (let i = 0; i < N; i++) { re[i] = mono[off + i] * hann[i]; im[i] = 0; } + fft(re, im); + for (let k = 1; k < N / 2; k++) { + const f = k * sr / N; + if (f < 55 || f > 2000) continue; // A1..~B6; above that is mostly noise + const mag = Math.sqrt(re[k] * re[k] + im[k] * im[k]); + const midi = 69 + 12 * Math.log2(f / 440); + out[((Math.round(midi) % 12) + 12) % 12] += mag; + } + } + let s = 0; + for (let i = 0; i < 12; i++) s += out[i]; + if (s > 0) for (let i = 0; i < 12; i++) out[i] /= s; + return out; +} + +// Krumhansl-Schmuckler profiles. +const MAJ = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]; +const MIN = [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]; + +function corr(a: number[], b: Float32Array, rot: number): number { + let ma = 0, mb = 0; + for (let i = 0; i < 12; i++) { ma += a[i]; mb += b[(i + rot) % 12]; } + ma /= 12; mb /= 12; + let num = 0, da = 0, db = 0; + for (let i = 0; i < 12; i++) { + const x = a[i] - ma, y = b[(i + rot) % 12] - mb; + num += x * y; da += x * x; db += y * y; + } + return num / (Math.sqrt(da * db) + 1e-12); +} + +export interface Key { name: string; score: number; runnerUp: string; } + +export function estimateKey(mono: Float32Array, sr: number): Key { + const c = chroma(mono, sr); + const cands: { name: string; score: number }[] = []; + for (let r = 0; r < 12; r++) { + cands.push({ name: NOTES[r] + ' major', score: corr(MAJ, c, r) }); + cands.push({ name: NOTES[r] + ' minor', score: corr(MIN, c, r) }); + } + cands.sort((a, b) => b.score - a.score); + return { name: cands[0].name, score: cands[0].score, runnerUp: cands[1].name }; +} diff --git a/tools/convert-aliens-anim.ts b/tools/convert-aliens-anim.ts index 0c08e53..70576ea 100644 --- a/tools/convert-aliens-anim.ts +++ b/tools/convert-aliens-anim.ts @@ -16,10 +16,18 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { resizeMax } from './imgutil'; +import { decodePng, encodePng } from './png'; import { execSync } from 'node:child_process'; import { basename, dirname } from 'node:path'; -const TEX_MAX = 512; +// The SOURCE art is 2048² (player) and 4096² (aliens). This was 512 — we were +// throwing away 4-8x the linear detail on every character, on a model the camera +// is never more than a few metres from. Raised to 1024: 4x the texels, and the +// GLBs stay in the tens of MB rather than the hundreds. +// +// Per-model override below: the player is on screen 100% of the time and earns more. +const TEX_MAX = 1024; +const TEX_MAX_PLAYER = 2048; const IQE_ROOT = 'vendor/unvanquished/pkg/res-players_src.dpkdir/models/players'; const OUT_DIR = 'assets/models'; const CACHE = 'tools/.cache'; @@ -30,7 +38,7 @@ const MAX_INFLUENCES = 4; /// ships its UPGRADE classes: the advanced marauder is the level2 rig wearing /// `body_adv.skin` (its own `level2_adv_*` textures), not a separate model. So /// two new enemies cost two rows here and no new art. -interface AlienSpec { name: string; dir: string; skin?: string } +interface AlienSpec { name: string; dir: string; skin?: string; metalFromSpec?: boolean } const ALIENS: AlienSpec[] = [ { name: 'enemy_dretch', dir: 'level0' }, { name: 'enemy_mantis', dir: 'level1' }, @@ -40,7 +48,8 @@ const ALIENS: AlienSpec[] = [ // SH-042 — the two upgrade classes. { name: 'enemy_adv_marauder', dir: 'level2', skin: 'body_adv.skin' }, { name: 'enemy_adv_dragoon', dir: 'level3', skin: 'body_adv.skin' }, - { name: 'player_bsuit', dir: 'human_bsuit' }, // 3rd-person player model + // The battlesuit is the only METAL character. See specToMetalRough. + { name: 'player_bsuit', dir: 'human_bsuit', metalFromSpec: true }, ]; /// Parse a `.skin` file: lines of `submeshIndex,models/players//`. @@ -329,13 +338,23 @@ function matInverse(m: number[]): number[] { function align4(n: number): number { return (n + 3) & ~3; } -function resizeTexture(src: string, cachePath: string): Uint8Array { +function resizeTexture(src: string, cachePath: string, maxPx: number): Uint8Array { mkdirSync(dirname(cachePath), { recursive: true }); - resizeMax(src, cachePath, TEX_MAX); + resizeMax(src, cachePath, maxPx); return new Uint8Array(readFileSync(cachePath)); } -function buildGlb(p: Parsed, mats: { material: string; imgBytes: Uint8Array }[]): Uint8Array { +/// One material's three maps. `norm` and `mr` are null when the source set has +/// no `_n`/`_s` for this texture — the material then falls back to a flat normal +/// and the roughness factor, i.e. exactly what we shipped before. +interface MatEntry { + material: string; + imgBytes: Uint8Array; + normBytes: Uint8Array | null; + mrBytes: Uint8Array | null; +} + +function buildGlb(p: Parsed, mats: MatEntry[]): Uint8Array { const J = p.joints.length; // 1. Compute per-joint world matrices at rest pose. All TRS values in p @@ -408,12 +427,25 @@ function buildGlb(p: Parsed, mats: { material: string; imgBytes: Uint8Array }[]) animSlots.push({ timeOff, timeLen: frames * 4, timeCount: frames, tOff, tLen, rOff, rLen }); } - // Images trailing. - const imgSlots: Slot[] = []; + // Images trailing. A material now carries up to THREE of them (base colour, + // normal, metallic-roughness), so the image list is no longer 1:1 with + // materials — flatten it and remember which indices each material owns. + const imageBytes: Uint8Array[] = []; + const matImg: { base: number; norm: number; mr: number }[] = []; for (let i = 0; i < mats.length; i++) { + const m = mats[i]; + const base = imageBytes.length; imageBytes.push(m.imgBytes); + let norm = -1; + if (m.normBytes !== null) { norm = imageBytes.length; imageBytes.push(m.normBytes); } + let mr = -1; + if (m.mrBytes !== null) { mr = imageBytes.length; imageBytes.push(m.mrBytes); } + matImg.push({ base, norm, mr }); + } + const imgSlots: Slot[] = []; + for (let i = 0; i < imageBytes.length; i++) { const off = align4(binLen); - binLen = off + mats[i].imgBytes.length; - imgSlots.push({ off, len: mats[i].imgBytes.length }); + binLen = off + imageBytes[i].length; + imgSlots.push({ off, len: imageBytes[i].length }); } binLen = align4(binLen); @@ -463,7 +495,7 @@ function buildGlb(p: Parsed, mats: { material: string; imgBytes: Uint8Array }[]) } } } - for (let i = 0; i < mats.length; i++) bin.set(mats[i].imgBytes, imgSlots[i].off); + for (let i = 0; i < imageBytes.length; i++) bin.set(imageBytes[i], imgSlots[i].off); // 4. Build JSON graph. const bufferViews: { buffer: number; byteOffset: number; byteLength: number; target?: number }[] = []; @@ -540,7 +572,7 @@ function buildGlb(p: Parsed, mats: { material: string; imgBytes: Uint8Array }[]) } const imageBv: number[] = []; - for (let i = 0; i < mats.length; i++) { + for (let i = 0; i < imageBytes.length; i++) { const bv = bufferViews.length; bufferViews.push({ buffer: 0, byteOffset: imgSlots[i].off, byteLength: imgSlots[i].len }); imageBv.push(bv); @@ -583,16 +615,29 @@ function buildGlb(p: Parsed, mats: { material: string; imgBytes: Uint8Array }[]) meshes: [{ primitives }], skins: [{ joints: Array.from({ length: J }, (_, j) => j), inverseBindMatrices: aIbm }], - materials: mats.map((m, i) => ({ - name: basename(m.material), - pbrMetallicRoughness: { - baseColorTexture: { index: i }, - metallicFactor: 0.0, - roughnessFactor: 0.9, - }, - })), - textures: mats.map((_, i) => ({ source: i, sampler: 0 })), - images: mats.map((_, i) => ({ bufferView: imageBv[i], mimeType: 'image/png' })), + // One texture per image, indices matching. The normal and MR textures are + // only referenced when the source set actually had an `_n` / `_s` — a model + // without them renders exactly as it did before this change. + // + // roughnessFactor is 1.0 (not the old flat 0.9) when an MR map exists, so the + // MAP drives roughness instead of being multiplied down to nothing by it. + materials: mats.map((m, i) => { + const im = matImg[i]; + // Both factors go to 1.0 once a map exists — glTF MULTIPLIES factor by + // texture, so leaving metallicFactor at 0 would zero the blue channel and + // silently discard the metalness we just computed. + const pbr: any = { + baseColorTexture: { index: im.base }, + metallicFactor: im.mr >= 0 ? 1.0 : 0.0, + roughnessFactor: im.mr >= 0 ? 1.0 : 0.9, + }; + if (im.mr >= 0) pbr.metallicRoughnessTexture = { index: im.mr }; + const mat: any = { name: basename(m.material), pbrMetallicRoughness: pbr }; + if (im.norm >= 0) mat.normalTexture = { index: im.norm }; + return mat; + }), + textures: imageBytes.map((_, i) => ({ source: i, sampler: 0 })), + images: imageBytes.map((_, i) => ({ bufferView: imageBv[i], mimeType: 'image/png' })), samplers: [{ magFilter: 9729, minFilter: 9987, wrapS: 10497, wrapT: 10497 }], animations: animsJson, buffers: [{ byteLength: binLen }], @@ -632,6 +677,81 @@ function resolveTexture(iqeDir: string, material: string): string { throw new Error('texture not found for ' + material); } +/// Find the authored `_n` (normal) or `_s` (specular) beside a base texture. +/// +/// These shipped with the Unvanquished source all along and the converter simply +/// never read them, so every alien and the player have been rendering with a flat +/// normal and a constant roughness next to a terrain that has both. That was the +/// single largest fidelity gap on the characters, and the fix was already on disk. +/// +/// The `_adv` upgrade skins are the exception: `level2_adv_body.png` has no +/// `_n`/`_s` of its own because it is a RECOLOUR of `level2_body` on the same mesh +/// and the same UVs. So fall back to the base skin's maps — the surface detail is +/// identical, only the colour differs. +function resolveAux(iqeDir: string, material: string, suffix: string): string | null { + const base = basename(material); + const direct = iqeDir + '/' + base + suffix + '.png'; + if (existsSync(direct)) return direct; + if (base.includes('_adv')) { + const fallback = iqeDir + '/' + base.replace('_adv', '') + suffix + '.png'; + if (existsSync(fallback)) return fallback; + } + return null; +} + +/// Unvanquished specular map -> glTF metallic-roughness. +/// +/// These are NOT the same thing and the map cannot simply be swapped in. A +/// Quake-lineage specular map stores *specular intensity* — "how strongly does +/// this texel catch a highlight" — while glTF wants *roughness* in GREEN and +/// *metalness* in BLUE. The two coincide often enough to tempt you into mapping +/// intensity straight to gloss, which is what I did first: bright texels went to +/// roughness ~0 and the whole battlesuit came out looking wet. +/// +/// So the mapping is deliberately CONSERVATIVE. Roughness spans 1.0 (matte) down +/// to `ROUGH_FLOOR` (satin) — never a mirror. The point of this map is to give the +/// surface *variation*, which it had none of; it is not to make anything glossy. +/// +/// `metalFromSpec` is for the battlesuit and nothing else. It is armour plate, and +/// a dielectric with a tight white highlight reads as wet plastic — metalness is +/// what makes it read as metal, because it tints the reflection by the base colour +/// instead of leaving it white. The aliens stay fully dielectric: metallic chitin +/// would look like tinfoil. +const ROUGH_FLOOR = 0.35; +const ROUGH_RANGE = 0.65; + +function specToMetalRough(specPath: string, cachePath: string, metalFromSpec: boolean, maxPx: number): Uint8Array { + const resized = resizeTexture(specPath, cachePath, maxPx); + const img = decodePng(resized); + const px = img.width * img.height; + const out = new Uint8Array(px * 4); + for (let i = 0; i < px; i++) { + const r = img.rgba[i * 4 + 0]; + const g = img.rgba[i * 4 + 1]; + const b = img.rgba[i * 4 + 2]; + const spec = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; + + let rough = 1.0 - ROUGH_RANGE * spec; + if (rough < ROUGH_FLOOR) rough = ROUGH_FLOOR; + if (rough > 1.0) rough = 1.0; + + // Only the bright, plate-like texels become metal; the straps, rubber and + // cloth in the darker parts of the spec map stay dielectric. + let metal = 0.0; + if (metalFromSpec) { + metal = (spec - 0.25) / 0.55; + if (metal < 0.0) metal = 0.0; + if (metal > 1.0) metal = 1.0; + } + + out[i * 4 + 0] = 255; // R — unused by glTF MR + out[i * 4 + 1] = Math.round(rough * 255); // G — roughness + out[i * 4 + 2] = Math.round(metal * 255); // B — metallic + out[i * 4 + 3] = 255; + } + return encodePng(img.width, img.height, out); +} + mkdirSync(OUT_DIR, { recursive: true }); mkdirSync(CACHE, { recursive: true }); @@ -675,8 +795,25 @@ for (let i = 0; i < ALIENS.length; i++) { ? (skinMap.get(s) as string) : sub.material; const texSrc = resolveTexture(iqeDir, wanted); - const cachePath = CACHE + '/' + a.name + '_' + basename(wanted) + '.png'; - mats.push({ material: sub.material, imgBytes: resizeTexture(texSrc, cachePath) }); + const stem = CACHE + '/' + a.name + '_' + basename(wanted); + const cachePath = stem + '.png'; + + const normSrc = resolveAux(iqeDir, wanted, '_n'); + const specSrc = resolveAux(iqeDir, wanted, '_s'); + const maxPx = a.metalFromSpec === true ? TEX_MAX_PLAYER : TEX_MAX; + const normBytes = normSrc !== null ? resizeTexture(normSrc, stem + '_n.png', maxPx) : null; + const mrBytes = specSrc !== null + ? specToMetalRough(specSrc, stem + '_s.png', a.metalFromSpec === true, maxPx) : null; + console.log(' ' + basename(wanted) + + ' normal=' + (normSrc !== null ? 'yes' : 'NO') + + ' spec->mr=' + (specSrc !== null ? 'yes' : 'NO')); + + mats.push({ + material: sub.material, + imgBytes: resizeTexture(texSrc, cachePath, maxPx), + normBytes: normBytes, + mrBytes: mrBytes, + }); } const glb = buildGlb(parsed, mats); diff --git a/tools/music-inspect.ts b/tools/music-inspect.ts new file mode 100644 index 0000000..876005e --- /dev/null +++ b/tools/music-inspect.ts @@ -0,0 +1,93 @@ +// Look at the music before cutting it. +// +// bun tools/music-inspect.ts +// +// Prints tempo/level stats and renders an RMS envelope PNG per track into +// tools/.testout/music/, so the loop points can be chosen by LOOKING at where the +// arrangement is at full strength rather than by guessing at timestamps. + +import { readWav, toMono, onsetEnvelope, estimateTempo, beatPhase, rmsDb, estimateKey, HOP } from './audio'; +import { encodePng } from './png'; +import { writeFileSync, mkdirSync, readdirSync } from 'node:fs'; + +const SRC = 'assets/music'; +const OUT = 'tools/.testout/music'; + +const W = 1600, H = 200; + +function plot(name: string, mono: Float32Array, sr: number, bpm: number, phase: number): void { + const px = new Uint8Array(W * H * 4); + for (let i = 0; i < W * H; i++) { + px[i * 4 + 0] = 18; px[i * 4 + 1] = 20; px[i * 4 + 2] = 26; px[i * 4 + 3] = 255; + } + const set = (x: number, y: number, r: number, g: number, b: number) => { + if (x < 0 || x >= W || y < 0 || y >= H) return; + const i = (y * W + x) * 4; + px[i] = r; px[i + 1] = g; px[i + 2] = b; + }; + + // 10-second gridlines. + const dur = mono.length / sr; + for (let t = 0; t < dur; t += 10) { + const x = Math.floor((t / dur) * W); + for (let y = 0; y < H; y++) set(x, y, 42, 46, 58); + } + // Bar lines, if we have a tempo worth believing. + if (bpm > 0) { + const barSec = 4 * 60 / bpm; + for (let t = phase / sr; t < dur; t += barSec) { + const x = Math.floor((t / dur) * W); + for (let y = H - 12; y < H; y++) set(x, y, 90, 70, 40); + } + } + + // Peak envelope, column by column. + const per = Math.max(1, Math.floor(mono.length / W)); + for (let x = 0; x < W; x++) { + let peak = 0; + const off = x * per; + for (let i = 0; i < per && off + i < mono.length; i++) { + const v = Math.abs(mono[off + i]); + if (v > peak) peak = v; + } + const h = Math.min(H / 2 - 2, peak * (H / 2 - 2)); + for (let y = Math.floor(H / 2 - h); y <= Math.floor(H / 2 + h); y++) { + set(x, y, 110, 200, 130); + } + } + mkdirSync(OUT, { recursive: true }); + writeFileSync(`${OUT}/${name}.png`, encodePng(W, H, px)); +} + +const files = readdirSync(SRC).filter(f => f.endsWith('.wav')).sort(); +for (const f of files) { + const w = readWav(`${SRC}/${f}`); + const mono = toMono(w); + const env = onsetEnvelope(mono, w.sampleRate); + const t = estimateTempo(env, w.sampleRate); + const phase = beatPhase(env, w.sampleRate, t.bpm, 4); + const db = rmsDb(mono, HOP); + + // Where does the track sit at full strength? (Loudest 10 s window.) + const win = Math.floor(10 * w.sampleRate / HOP); + let bestSum = -Infinity, bestAt = 0; + let run = 0; + for (let i = 0; i < db.length; i++) { + run += db[i]; + if (i >= win) run -= db[i - win]; + if (i >= win && run > bestSum) { bestSum = run; bestAt = (i - win) * HOP / w.sampleRate; } + } + let peak = 0; + for (let i = 0; i < w.data.length; i++) { const v = Math.abs(w.data[i]); if (v > peak) peak = v; } + + const dur = w.frames / w.sampleRate; + const barSec = 4 * 60 / t.bpm; + const key = estimateKey(mono, w.sampleRate); + console.log( + `${f.padEnd(22)} ${dur.toFixed(1).padStart(6)}s ` + + `bpm=${t.bpm.toFixed(1).padStart(5)} (conf ${t.confidence.toFixed(1).padStart(4)}) ` + + `key=${key.name.padEnd(9)} (2nd ${key.runnerUp.padEnd(9)}) ` + + `peak=${(20 * Math.log10(peak + 1e-9)).toFixed(1)}dB loud10s@${bestAt.toFixed(0)}s`); + plot(f.replace('.wav', ''), mono, w.sampleRate, t.bpm, phase); +} +console.log(`\nplots -> ${OUT}/`); diff --git a/tools/music-loopcheck.ts b/tools/music-loopcheck.ts new file mode 100644 index 0000000..ed4553d --- /dev/null +++ b/tools/music-loopcheck.ts @@ -0,0 +1,84 @@ +// Prove the loops actually loop. +// +// bun tools/music-loopcheck.ts +// +// For each bed, writes: +// tools/.testout/music/loopcheck_.wav — the loop played TWICE, back to +// back. The join is at the exact midpoint. If you can hear where it is, +// the loop is wrong. This is the only test that really counts, and it is +// the one only a human can run. +// tools/.testout/music/wrap_.png — the 2 s either side of the wrap, +// with the join marked. A level jump or a dead spot shows up here. + +import { readWav, writeWav, toMono, Wav } from './audio'; +import { encodePng } from './png'; +import { writeFileSync, mkdirSync } from 'node:fs'; + +const OUT = 'tools/.testout/music'; +const BEDS = ['music_calm', 'music_combat', 'music_menu']; + +mkdirSync(OUT, { recursive: true }); + +for (const name of BEDS) { + const w = readWav(`assets/music/${name}.wav`); + const ch = w.channels; + + // Twice through, so the join is audible if it is audible at all. + const dbl: Wav = { + sampleRate: w.sampleRate, channels: ch, frames: w.frames * 2, + data: new Float32Array(w.data.length * 2), + }; + dbl.data.set(w.data, 0); + dbl.data.set(w.data, w.data.length); + writeWav(`${OUT}/loopcheck_${name}.wav`, dbl); + + // Plot the 2 s either side of the wrap. + const win = Math.round(2.0 * w.sampleRate); + const mono = toMono(w); + const W = 1200, H = 220; + const px = new Uint8Array(W * H * 4); + for (let i = 0; i < W * H; i++) { + px[i * 4] = 18; px[i * 4 + 1] = 20; px[i * 4 + 2] = 26; px[i * 4 + 3] = 255; + } + const set = (x: number, y: number, r: number, g: number, b: number) => { + if (x < 0 || x >= W || y < 0 || y >= H) return; + const i = (y * W + x) * 4; + px[i] = r; px[i + 1] = g; px[i + 2] = b; + }; + // Left half = the END of the loop; right half = the START. Played back to back + // this is exactly what the ear hears at the wrap. + const per = Math.max(1, Math.floor(win / (W / 2))); + for (let x = 0; x < W; x++) { + const fromEnd = x < W / 2; + const k = fromEnd ? x : x - W / 2; + const base = fromEnd ? (mono.length - win) + k * per : k * per; + let peak = 0; + for (let i = 0; i < per; i++) { + const idx = base + i; + if (idx < 0 || idx >= mono.length) continue; + const v = Math.abs(mono[idx]); + if (v > peak) peak = v; + } + const h = Math.min(H / 2 - 2, peak * (H / 2 - 2)); + for (let y = Math.floor(H / 2 - h); y <= Math.floor(H / 2 + h); y++) { + set(x, y, 110, 200, 130); + } + } + for (let y = 0; y < H; y++) set(Math.floor(W / 2), y, 240, 90, 90); // the join + + writeFileSync(`${OUT}/wrap_${name}.png`, encodePng(W, H, px)); + + // Numbers, so this is not purely a vibe check: level either side of the join. + const rms = (from: number, n: number) => { + let e = 0; + for (let i = 0; i < n; i++) { const v = mono[from + i]; e += v * v; } + return 20 * Math.log10(Math.sqrt(e / n) + 1e-9); + }; + const half = Math.round(0.5 * w.sampleRate); + const before = rms(mono.length - half, half); + const after = rms(0, half); + console.log(`${name.padEnd(14)} ${(w.frames / w.sampleRate).toFixed(1)}s ` + + `level into the wrap ${before.toFixed(1)}dB -> out of it ${after.toFixed(1)}dB ` + + `(delta ${Math.abs(before - after).toFixed(1)}dB ${Math.abs(before - after) < 3 ? 'OK' : '** CHECK **'})`); +} +console.log(`\nlisten: ${OUT}/loopcheck_*.wav (the join is at the exact midpoint)`); diff --git a/tools/music-master.ts b/tools/music-master.ts new file mode 100644 index 0000000..4b67c25 --- /dev/null +++ b/tools/music-master.ts @@ -0,0 +1,303 @@ +// Turn what Suno hands back into what the game needs. +// +// bun tools/music-master.ts +// +// Reads assets/music/src/*.wav (the raw Suno renders) and writes the game's +// assets/music/*.wav. Re-runnable: the sources are never modified. +// +// Two jobs. +// +// BEDS (calm / combat / menu) must LOOP FOREVER. Suno writes songs — they fade in, +// they build, they end. So we cut a chunk out of the middle and crossfade the seam: +// the tail of the loop is blended with the audio that *followed* the cut, so when +// it wraps, the music continues into itself instead of jumping. +// +// The loop point is SEARCHED, not assumed. I do not trust the tempo estimate to +// pick it — at hop 512 the autocorrelation bins around 120 BPM are 119.7 and 122.3, +// so two tracks that are both 120 land in different bins and appear to disagree by +// 2%. Instead we score candidate (start, length) pairs by how similar the music +// actually sounds at `start` and at `start + length`, which is precisely what makes +// a crossfade inaudible, and which self-corrects any tempo error. +// +// STINGERS are one-shots played OVER the bed. They must be short and end in silence. +// Suno returned 9 s, 24 s and 49 s pieces, and the payload is not always at the +// front — the victory swell does not land until 0:29. The windows below were read +// off the rendered waveforms (tools/music-inspect.ts) rather than guessed. + +import { readWav, writeWav, toMono, onsetEnvelope, estimateTempo, rmsDb, Wav, HOP } from './audio'; +import { existsSync, mkdirSync, copyFileSync, readdirSync } from 'node:fs'; + +const SRC = 'assets/music/src'; +const OUT = 'assets/music'; + +/// Crossfade length at the loop seam. About one bar at 120 BPM — long enough to +/// hide a chord change, short enough not to smear the downbeat. +const XFADE_SEC = 1.6; + +/// Leave the outputs a hair below full scale. The game's mixer scales these down +/// anyway; the point is only that no two files differ wildly in loudness. +const PEAK_DBFS = -1.5; + +interface BedSpec { + name: string; + /// Candidate loop lengths, in bars. Longer is less repetitive; the search takes + /// the best-scoring one, biased toward longer. + bars: number[]; + /// Ignore this much at the head and tail — that is where the intro and the + /// outro fade live, and a loop must contain neither. + skipHead: number; + skipTail: number; +} + +const BEDS: BedSpec[] = [ + { name: 'music_calm', bars: [32, 24, 16], skipHead: 8, skipTail: 8 }, + { name: 'music_combat', bars: [24, 16], skipHead: 4, skipTail: 6 }, + { name: 'music_menu', bars: [32, 24, 16], skipHead: 10, skipTail: 10 }, +]; + +interface StingSpec { + name: string; + /// Seconds into the source. Read off the waveform plots. + start: number; + len: number; + /// Fade the tail to silence over this long — a stinger that stops dead clicks, + /// and one that stops *abruptly* sounds like a bug in the game. + fadeOut: number; +} + +const STINGS: StingSpec[] = [ + // Already a stinger: hits at 0, swells, decays to silence by ~7 s. Just shorten it. + { name: 'sting_wave_clear', start: 0.05, len: 5.0, fadeOut: 1.6 }, + // The collapse is right at the front; it decays through ~8 s. + { name: 'sting_death', start: 0.45, len: 4.0, fadeOut: 1.4 }, + // The swell does not LAND until ~0:29. Taking the first 4 s would ship an intro. + { name: 'sting_victory', start: 28.0, len: 5.0, fadeOut: 1.8 }, +]; + +// ---- helpers ---------------------------------------------------------------- + +function slice(w: Wav, startFrame: number, frames: number): Wav { + const ch = w.channels; + const data = new Float32Array(frames * ch); + data.set(w.data.subarray(startFrame * ch, (startFrame + frames) * ch)); + return { sampleRate: w.sampleRate, channels: ch, data, frames }; +} + +function normalize(w: Wav, targetDb: number): void { + let peak = 0; + for (let i = 0; i < w.data.length; i++) { + const v = Math.abs(w.data[i]); + if (v > peak) peak = v; + } + if (peak < 1e-6) return; + const target = Math.pow(10, targetDb / 20); + const g = target / peak; + for (let i = 0; i < w.data.length; i++) w.data[i] *= g; +} + +/// Spectral-ish fingerprint of a window: per-frame RMS (dB) and zero-crossing rate. +/// Cheap, and enough to tell "these two moments sound alike" from "one is a drum +/// break and the other is a pad". +function features(mono: Float32Array, start: number, frames: number): Float32Array { + const n = Math.floor(frames / HOP); + const f = new Float32Array(n * 2); + for (let i = 0; i < n; i++) { + let e = 0, zc = 0; + const off = start + i * HOP; + let prev = mono[off]; + for (let j = 0; j < HOP; j++) { + const v = mono[off + j]; + e += v * v; + if ((v >= 0) !== (prev >= 0)) zc++; + prev = v; + } + f[i * 2 + 0] = 20 * Math.log10(Math.sqrt(e / HOP) + 1e-6); + f[i * 2 + 1] = (zc / HOP) * 100; // scaled into the same rough range as dB + } + return f; +} + +function featDist(a: Float32Array, b: Float32Array): number { + const n = Math.min(a.length, b.length); + let s = 0; + for (let i = 0; i < n; i++) s += Math.abs(a[i] - b[i]); + return s / n; +} + +// ---- bed: find and cut the loop --------------------------------------------- + +function masterBed(spec: BedSpec): void { + const w = readWav(`${SRC}/${spec.name}.wav`); + const sr = w.sampleRate; + const mono = toMono(w); + + const env = onsetEnvelope(mono, sr); + const tempo = estimateTempo(env, sr); + const barFrames = Math.round(4 * 60 / tempo.bpm * sr); + const xf = Math.round(XFADE_SEC * sr); + + const db = rmsDb(mono, HOP); + let meanDb = 0; + for (let i = 0; i < db.length; i++) meanDb += db[i]; + meanDb /= db.length; + + const headF = Math.round(spec.skipHead * sr); + const tailF = Math.round(spec.skipTail * sr); + + // Best cut for EACH candidate length, then choose between the lengths. + // + // Not one combined score with a length bonus — I tried that and it traded a + // 63 s loop for a 31 s one to buy a seam improvement nobody could hear. A short + // loop is a real cost (the player hears the repeat), so the rule is: take the + // LONGEST loop whose seam is not meaningfully worse than the best available. + interface Cand { s: number; L: number; cost: number; bars: number; } + const perLength: Cand[] = []; + + for (const bars of spec.bars) { + const L = bars * barFrames; + // The loop, plus the crossfade tail it steals from the continuation, must fit. + const maxStart = w.frames - tailF - L - xf; + if (maxStart <= headF) continue; + + let bestHere: Cand | null = null; + // Step by a bar: a seam anywhere else is mid-bar and no crossfade will save it. + for (let s2 = headF; s2 <= maxStart; s2 += barFrames) { + // The loop must be at full strength throughout — one containing the intro + // fade would get quieter every time it wrapped. + let quiet = 0; + const f0 = Math.floor(s2 / HOP), f1 = Math.floor((s2 + L) / HOP); + for (let i = f0; i < f1 && i < db.length; i++) if (db[i] < meanDb - 6) quiet++; + const quietFrac = quiet / Math.max(1, f1 - f0); + + // How alike is the music at the cut, and at the point it wraps back from? + const a = features(mono, s2, xf); + const b = features(mono, s2 + L, xf); + const cost = featDist(a, b) + quietFrac * 12; + + if (bestHere === null || cost < bestHere.cost) bestHere = { s: s2, L, cost, bars }; + } + if (bestHere !== null) perLength.push(bestHere); + } + + if (perLength.length === 0) throw new Error(`${spec.name}: no loop fits`); + + console.log(` ${spec.name}: ` + perLength + .map(c => `${c.bars}bars=${(c.L / sr).toFixed(1)}s cost ${c.cost.toFixed(2)}`).join(' | ')); + + let cheapest = perLength[0]; + for (const c of perLength) if (c.cost < cheapest.cost) cheapest = c; + + // Any seam under GOOD_ENOUGH is one the 1.6 s equal-power crossfade will hide — + // the cost is roughly "average dB difference across the seam window", and a + // couple of dB is nothing once it is faded. So do NOT trade loop length for a + // seam improvement nobody can hear: among the acceptable lengths, take the + // longest. A short loop is a cost the player actually notices. + const GOOD_ENOUGH = 3.0; + const limit = Math.max(GOOD_ENOUGH, cheapest.cost * 1.15); + let best: Cand | null = null; + for (const c of perLength) { + if (c.cost <= limit && (best === null || c.bars > best.bars)) best = c; + } + if (best === null) best = cheapest; + + if (best === null) throw new Error(`${spec.name}: no loop fits`); + + // Build it. out[i] = src[s+i], except the first `xf` frames, which are the + // CONTINUATION (src[s+L+i]) fading out under the loop head fading in. So when + // the loop wraps, the audio the ear expected to hear next is already there. + const ch = w.channels; + const out: Wav = { + sampleRate: sr, channels: ch, frames: best.L, + data: new Float32Array(best.L * ch), + }; + for (let i = 0; i < best.L; i++) { + for (let c = 0; c < ch; c++) { + const head = w.data[(best.s + i) * ch + c]; + if (i < xf) { + const t = i / xf; + // Equal power, so the seam does not dip ~3 dB the way a linear fade would. + const gIn = Math.sin(t * Math.PI * 0.5); + const gOut = Math.cos(t * Math.PI * 0.5); + const cont = w.data[(best.s + best.L + i) * ch + c]; + out.data[i * ch + c] = head * gIn + cont * gOut; + } else { + out.data[i * ch + c] = head; + } + } + } + normalize(out, PEAK_DBFS); + writeWav(`${OUT}/${spec.name}.wav`, out); + + // Verify the seam. A click is a sample-to-sample jump at the wrap that is far + // outside the normal range of jumps in the file — so measure both and compare. + let sum = 0, n = 0, p99 = 0; + const deltas: number[] = []; + for (let i = 1; i < out.frames; i += 97) { // sparse sample; this is a check, not a filter + const d = Math.abs(out.data[i * ch] - out.data[(i - 1) * ch]); + deltas.push(d); sum += d; n++; + } + deltas.sort((x, y) => x - y); + p99 = deltas[Math.floor(deltas.length * 0.99)]; + const wrap = Math.abs(out.data[0] - out.data[(out.frames - 1) * ch]); + + const secs = best.L / sr; + console.log( + `${spec.name.padEnd(14)} ${(w.frames / sr).toFixed(1)}s -> ${secs.toFixed(2)}s ` + + `(${best.bars} bars @ ${tempo.bpm.toFixed(1)} bpm, cut @ ${(best.s / sr).toFixed(1)}s) ` + + `seam: wrap-jump ${wrap.toFixed(4)} vs p99 ${p99.toFixed(4)} ` + + `${wrap <= p99 * 1.5 ? 'OK' : '** CHECK **'}`); +} + +// ---- stinger: trim, fade, normalize ----------------------------------------- + +function masterSting(spec: StingSpec): void { + const w = readWav(`${SRC}/${spec.name}.wav`); + const sr = w.sampleRate; + const ch = w.channels; + const start = Math.round(spec.start * sr); + let frames = Math.round(spec.len * sr); + if (start + frames > w.frames) frames = w.frames - start; + + const out = slice(w, start, frames); + + const fadeIn = Math.round(0.015 * sr); // 15 ms — kills the edge click, nothing more + const fadeOut = Math.round(spec.fadeOut * sr); + for (let i = 0; i < frames; i++) { + let g = 1; + if (i < fadeIn) g *= i / fadeIn; + const fromEnd = frames - 1 - i; + if (fromEnd < fadeOut) { + const t = fromEnd / fadeOut; + g *= t * t; // squared: decays to true silence, not a shelf + } + for (let c = 0; c < ch; c++) out.data[i * ch + c] *= g; + } + normalize(out, PEAK_DBFS); + writeWav(`${OUT}/${spec.name}.wav`, out); + + // Last sample must be silent, or the one-shot ends in a click every time. + let tail = 0; + for (let c = 0; c < ch; c++) tail = Math.max(tail, Math.abs(out.data[(frames - 1) * ch + c])); + console.log( + `${spec.name.padEnd(18)} ${(w.frames / sr).toFixed(1)}s -> ${(frames / sr).toFixed(2)}s ` + + `(from ${spec.start.toFixed(2)}s) tail=${tail.toFixed(6)} ${tail < 1e-4 ? 'OK' : '** CLICK **'}`); +} + +// ---- main ------------------------------------------------------------------- + +if (!existsSync(SRC)) { + console.error(`No ${SRC}/. Put the raw Suno renders there (same six filenames).`); + process.exit(1); +} +mkdirSync(OUT, { recursive: true }); + +console.log('beds — loop point is searched, not assumed:'); +for (const b of BEDS) { + if (!existsSync(`${SRC}/${b.name}.wav`)) { console.log(` [skip] ${b.name}`); continue; } + masterBed(b); +} +console.log('\nstingers — windows read off the waveform, not guessed:'); +for (const s of STINGS) { + if (!existsSync(`${SRC}/${s.name}.wav`)) { console.log(` [skip] ${s.name}`); continue; } + masterSting(s); +}