Current soundtrack — 2026-09-07
2026-09-25 iOS had never played a single licensed file. Capacitor's iOS
asset handler (WebViewAssetHandler.swift, isMediaExtension) answers mp3,
wav, m4a and friends with a plain URLResponse, so WebKit's fetch() reports
status 0 and r.ok === false while the body is fine. Every loader keyed on
r.ok, so the store build dropped songs, VO, the bell and the boss and combat
cues and ran the procedural fallback score for the whole night. Symptom Chris
heard: "the same music I don't recognise in every room". Desktop never showed
it (a real HTTP 200). Fix: src/audio-load.js is the one door for audio
bytes (status 0 with a body is success, XHR second, failures logged), and
Options > Audio prints the running version, the current track and that log.
stage-<room> songs are no longer requested (none exist). Shipped as OTA
1.0.6. Rule: never key an asset fetch on r.ok in the native shell.
2026-09-23 stock bed retired: drive (Envato "Night Drive Synthwave",
the placeholder named below) was still in the shuffled night rotation and
Chris heard it in iOS build 7: "not my uploaded game music". STOCK_BEDS in
src/audio.js keeps it out of NIGHT_BEDS; it stays in the catalog for the
audition lab. The rotation is now the seven Between Bells beds.
Same day, the boss side: the nine Envato "Devil On The Run" cues
(boss-01..09) and the boss fallback left the rotation too. Bosses now
rotate WOW then Boss 2 through 8 (eight cues), and a fight whose cue is
missing falls back to boss-wow (BOSS_FALLBACK). The Envato files stay in
the catalog and boss-score for the audition lab.
Current soundtrack — 2026-09-07
2026-09-19 bitrate: the songs ship at 128k (three boss songs at 192k,
the nine publisher loops at 256k) — see MECHANICS "Delivery". Any re-bake
now picks the lowest bitrate that passes the wrap guards per track. After a
re-bake run bake-boss-score.py --relock: it carries the measured events
and refreshes only the hash locks; a full analysis is only for changed
arrangements.
2026-09-14 reactive boss score: all nine boss identities now have a
pressure layer below 40% HP and a short defeat ending. Bosses choose the
percussion/impact texture; the actually playing song chooses the timing.
assets/music/boss-score.json v2 covers all 17 rotating cues and the generic
boss fallback with measured sample positions, song revision/hash locks and
exact loop bounds. src/boss-score.js follows the existing MusicPlayhead;
it never derives a runtime grid from rounded BPM. Songs, first passes and
catalog loop points are unchanged. The original Janitor/WOW pilot's three
WAVs and 150 event positions are preserved; Chris authorized expansion before
listening to that pilot, so this is not a listener-approved soundtrack mix.
Eight small original mono WAVs cost about 389 KB downloaded and 1.5 MiB decoded at 48 kHz. They preload through the serialized decode queue at audio init; there is no extra full-song buffer or boss-arrival decode. Boss profiles vary drum, paper, knock, clap, metal and ending textures through asset/rate/gain. New impact endings avoid a sustained chord; the original tonal Janitor tail uses a per-track bass-pitch transposition when played over another song. One palette serves the whole rotation, with at most 12 active/retiring voices and audio-clock retirement adjusted for each clip's playback rate.
The last living boss's defeat starts one ending under the existing exorcism,
including when several bosses fall in the same simulation frame. An overlapping
fight keeps its pressure until then. The ending can finish across the ordinary
return to level music; input, loot and room progression never wait for a beat.
Pause, turntable, mute, audition and explicit stop cancel queued effects;
manual pause leaves the existing full-volume song playing. setBoss(false)
and ordinary track handoffs preserve the ending, while title/stop cancels it.
Changed boss focus or source cancels old queued accents. Missing optional
songs can use the generic fallback's own timing, while mismatched revisions
or edited loop bounds disable the layer rather than playing out of sync.
scripts/bake-boss-score.py recreates the clips and manifest offline from
hash-locked shipped songs; normal builds never run it. Spoken boss entrance/
death casting remains optional. node --test test/boss-score.test.js checks
the timing and lifecycle contract; node test/boss-score.browser.mjs exercises
Chrome decoding at 44.1/48 kHz, song wraps, boss profiles, cancellation and
defeat handoff. Physical iPhone output and subjective mixing need listener QA.
Expansion verification: 1,063 Node tests, 192 Chrome audio cases and nine actual
Game boss transitions passed, including a held-bell defeat followed by Push On.
The audio matrix retained no cue nodes and fetched no assets during combat;
its peak concurrent cue count was two. Nine short listening previews and the
raw receipts are archived under ../_hallpass-archive/reports/boss-score-all-20260914/.
2026-09-09 boss additions: Boss 2 through Boss 8 are imported as
boss-new-2 through boss-new-8. Encounter rotation is now WOW, these seven
tracks in numerical order, then the nine original publisher cues, wrapping
after 17 encounters. Each preserves its opening and uses an eight-beat prepared
return into a beat-tracked repeating phrase. The same catalog automatically
exposes them in Options and the loop lab (?track=boss-new-2, etc.). Private
masters and hash-locked recipes preserve the edits; only baked MP3s ship.
The existing two-buffer cache and boss preload path apply to every new track.
2026-09-09 boss experiment: boss-wow (Boss Experiments - WOW) is the
first cue in the boss rotation. Its opening
plays through 126.641625s, then repeats 106.486708–126.641625s (20.154917s).
The lab opens directly with loop-lab.html?track=boss-wow. The same catalog
arrangement serves the lab, Options and game. The source master remains private;
its SHA-256 and phrase edit are recorded in music-arrangements.json.
This section supersedes the historical investigations below. The soundtrack now has eight night beds, the menu song, a fallback boss song, and 17 rotating boss cues. Between Bells 5 and 6 are restored as Annex and Stairwell.
| Game track | Master | Previous first wrap | New first pass | Repeating bed | PCM MiB at 48k |
|---|---|---|---|---|---|
| night | Between Bells | 122.5s | 182.1s | 41.6s | 66.8 |
| drive | Night Drive Synthwave | 130.8s | 144.0s | 76.8s | 52.8 |
| detention | Between Bells 2 | 67.1s | 136.0s | 62.6s | 49.9 |
| curfew | Between Bells 3 | 121.2s | 154.4s | 38.4s | 56.6 |
| lockers | Between Bells 4 | 123.9s | 132.3s | 38.4s | 62.6 |
| annex | Between Bells 5 | excluded | 120.8s | 57.6s | 44.3 |
| stairwell | Between Bells 6 | excluded | 128.9s | 38.4s | 61.4 |
| rooftop | Between Bells 7 | 65.0s | 122.8s | 64.1s | 45.1 |
| title | Gangsta | 71.1s | 139.0s | 25.1s | 51.0 |
| boss | Devil On The Run | 148.0s | 120.7s | 43.9s | 44.3 |
The first pass preserves the opening and later song sections. A prepared return phrase then feeds a repeatable bed. For most tracks that bed is already inside the first-pass file; Lockers and Stairwell append a prepared passage from earlier in the song. This avoids duplicating PCM unless the ending really needs a different sustain passage. Original authored breakdowns can still occur during the first pass. They are not playback stalls.
assets/music/catalog.json is the one runtime/lab catalog: exact frame-derived
loop points, source titles, role, revision/hash, decoded size, and encoded join
measurements. scripts/music-arrangements.json records the private source path
and every edit decision, with a SHA-256 lock on each master. Regenerate with python scripts/bake-music.py, or use
--only annex stairwell for a scoped bake. Requires ffmpeg, numpy and scipy;
these are offline tools, not game dependencies. Builds never regenerate music
or the public sound library. bake-audio.py now handles SFX/VO only, so it cannot
silently replace reviewed music with obsolete library cuts.
The former envelope-only planner could infer harmonics as tempo (including a 414 BPM result for BB3). These edits use spectral beat tracking, ordered chroma similarity across return phrases, and actual encoded seam measurements. Beat counts and BPM in the recipe document that analysis; sample positions are the playback authority. These generated performances drift and must not be rebuilt by multiplying a rounded BPM. A fractional-beat pickup offset keeps the join away from isolated drum gaps while preserving the entire selected beat count. The old music-cut.py remains historical tooling, not the current authoring path.
The old 0.4s bake replaced the HEAD with the END's continuation, so the first entry could be wrong even when the wrap looked good. The current bake blends the incoming passage's PREROLL into the outgoing TAIL over a musical phrase, with smooth correlation-aware constant-power gains. It preserves the native loop head. Measures reject large level steps, quiet junction holes and excessive sample discontinuity relative to the native passage. These are engineering checks, not a claim that a human listener has approved every harmonic edit.
Music ships as 256kbps stereo 48k MP3, one version for every device. A static whole-file gain aligns bed RMS around -16dBFS (short boss cues around -18), with peak headroom; it does not flatten every musical swell. Encoded peaks are checked for clipping. The final MP3 packet can add a few trailing samples, so the baker checks signal correlation for zero leading shift and keeps a 250ms cyclic guard AFTER the loop end. Never trim a guessed 30/50/80ms from Web Audio loop points. The publisher boss loops keep all 526629 decoded frames (10.9714375s). A 2ms edge de-click softens their nonzero boundary samples without shortening them.
Runtime changes in src/audio.js and src/music-playhead.js:
- Boss interruptions and stopped music remember the actual passage, rather than restarting the intro or always jumping to loopStart. The playhead integrates the turntable's linear playback-rate ramps, including parked reading time.
- A shuffled bag visits all eight beds before repeating and avoids repeating at bag boundaries. A URL pin or audition can still choose one explicitly.
- Late downloads cannot overwrite a later selection, stop or preview. Missing optional stage/boss files are remembered and fall back through the same guarded fetch-then-play path. Preview mode ends when the run resumes, restoring bosses. Music decodes are serialized, and obsolete previews skip decoding, so a fast sweep through the chooser cannot allocate eight full PCM songs at once.
- Fading sources stop on the audio clock and disconnect on
ended, even when background tabs throttle JavaScript timers. - The title screen loads no night bed. Opening the roster releases title music
and preloads the chosen bed. The cache keeps two music buffers on all devices;
scans skip protected playing entries instead of abandoning eviction. A fading
source may briefly retain a buffer outside the cache. Regular boss cues cost
about 4.1 MiB each.
audioReport()includes SFX/VO as well as music, so its total will exceed the single-bed numbers in the table. - Asset URLs contain the catalog content revision. Persisted loop overrides must match that revision; an old tester's loop tweak cannot damage a newly baked cut.
- Options > Music lists all 19 tracks, waits for loading, and offers FIRST HANDOFF and LOOP JOIN auditions. The loop lab loads the same catalog and exact points; its numerical indicator deliberately does not claim a subjective listening verdict.
Verification commands:
node --test test/music.test.js test/music-playhead.test.js
npm run test:music
npm test
npm run build:prod
The completion pass ran 380 Node tests, 38 decoder/track cases (three wraps each), three built-game music/viewport flows plus the loop lab, and 408 menu layout states. All passed. The built-game checks recorded about 73 MiB decoded audio at boot and 84 MiB while auditioning Stairwell, including SFX/VO/ambience.
The browser test uses the actual Sound class, Chrome's MP3 decoder and
OfflineAudioContext at 48k and 44.1k, with three wraps per track. It checks exact
loop endpoints, repeated-cycle stability, silence and clipping. Set
PLAYWRIGHT_MODULE to the installed Playwright module if it is not locally
installed. TARGET_ORIGIN=https://hallpass.cc repeats the audio checks against
production bytes. Local receipts are in .chk/music-review/ and JSON browser
reports in .chk/; they are deliberately not committed. Use
https://hallpass.cc/loop-lab.html or Options > Music for listening. Physical
Safari/iPhone output and subjective musical taste still require listener QA;
Chrome emulation and numerical continuity do not establish those.
Historical investigation (superseded where it conflicts with the above)
Music: do we need a music engine?
Researched 2026-08-30, after four failed rounds of loop-point surgery on
night.mp3. Chris's question was the right one to ask: "we must be
reinventing something that already exists."
Short answer: partly. The looping primitive is already solved and we are already using it correctly. What we do not have is MUSICAL TIME — and that is about 80 lines of our own code, not a dependency. The thing that actually keeps biting us is the ASSET, and no library on earth fixes that.
1. What is NOT broken
AudioBufferSourceNode.loopStart / loopEnd is sample-accurate, and both
are live on a playing node — which is why the in-engine loop tuner works at
all. That is the same primitive every web audio library ultimately calls.
Measured against the deployed build (Chrome 148, OfflineAudioContext):
| container duration (ffprobe) | 89.950 s |
| decoded duration | 89.950 s |
| decoded frames | 4,317,600 @ 48 kHz |
| extra frames from the decoder | 0 |
| leading / trailing silence | 0 / 0 |
So there is no decoder padding drift on this file in Chrome. A DAW timestamp
and a loopStart value refer to the same instant.
2. The one real cross-browser hazard (not yet bitten)
MP3 and AAC carry encoder delay and padding, and browsers disagree about
whether decodeAudioData strips it. Safari removes priming samples; Chrome,
Firefox and Edge historically did not — Jake Archibald measured ~45 ms of
inserted silence on AAC, and Apple documents encoders adding "at least 1024
samples of silence before the first true audio sample."
Chrome is clean for night.mp3 today (table above). iOS Safari has not been
measured — the browser pane cannot test it. If a loop ever sounds right on
desktop and wrong on the iPhone build, this is the first thing to check: decode
the buffer on the device and compare buffer.duration to the container's.
3. The measurement that explains the slip
scripts/loop-bake.py's beat detector on the shipping file:
beat period 0.6500 s -> 92.31 BPM
current loop 67.55 - 52.00 = 15.55 s = 23.92 beats
24 beats (6 bars of 4/4) would be = 15.60 s
The loop is ~0.08 of a beat short of a whole number of bars — about 50 ms of rush on every wrap. That is exactly the artefact Chris kept describing and could not place.
Caveat, stated honestly: the detector works on 10 ms hops, so 0.6500 s carries roughly ±0.8% error — over 24 beats that is ±0.12 s, which is wider than the 50 ms discrepancy it is being used to prove. This measurement says "the loop is not on a bar line", it does NOT say "move the end to 67.60". The fix is to get the true BPM and downbeat from the source (the licensed track's own metadata, or bar lines in a DAW) and cut on exact bars — not to nudge by ear again.
4. What actually exists, for the WEB
| Option | Buys us | Weight | Verdict |
|---|---|---|---|
| Raw Web Audio (today) | sample-accurate loops + scheduling | 0 | Already correct |
| Tone.js 15.1.22, MIT, published 2026‑08‑07 | musical time (bars/beats), Transport, Player.sync(), scheduled sections |
340 KB raw / 78 KB gzip full build; tree-shakes hard | The only serious candidate |
| Howler.js 2.2.4 | playback convenience, sprites | 36 KB / 9 KB gzip | Last published Sept 2023. Gives us less control than we already have. No. |
| FMOD Studio HTML5 | real adaptive middleware, FSB banks, free under $500k budget | ~640 KB–2.5 MB of Emscripten | Payload is comparable to our entire 901 KB bundle. No. |
| Wwise | industry standard | no web target | Not applicable |
| iMusicXML, libJAME | adaptive-music research frameworks | small | 6 stars, no releases, explicitly experimental. Not a dependency. |
Worth knowing: there is no dominant open-source adaptive-music engine for the
web. The middleware that owns this problem on console and PC (Wwise, FMOD)
either does not target browsers or arrives as a multi-megabyte Emscripten blob.
On the web, everyone is building on the same AudioBufferSourceNode we are.
5. What we genuinely lack
Three of the four things Chris named are real gaps, and none is about looping:
- Musical time.
_playTrackcrossfades the moment a boss spawns, not on the next downbeat. Every transition in the game lands wherever it lands. - Intro → loop → outro as scheduled sections. We bake an intro into the head of one file and hope.
- Vertical layering (stems). Calm bed + combat layer, sample-locked, faded
by intensity.
setIntensity()exists and currently drives nothing musical.
6. Recommendation
Do not adopt middleware. Do not adopt Howler. Tone.js is a legitimate
option but the cost is not the 78 KB — it is that it brings its own clock and
context abstraction that would have to coexist with the 858 lines of oscillator
SFX in audio.js. That interop is the real bill.
Order of value, highest first:
- Author the music loop-native. Ask for (or cut) the bed as
intro / loop / outroat a stated BPM with the loop an exact number of bars. This deletes the whole loop-tuner apparatus, the bake script's crossfade guesswork, and every round of "is it better now?". - Add bar-aware scheduling ourselves — roughly 80 lines (estimate, not a
measurement): store
{bpm, beatsPerBar, downbeat}per track, addnextBar(t), and route_playTrack,setBossand the domain stinger through it.src.start(when, offset)is already sample-accurate, so this is arithmetic, not DSP. - Only then consider Tone.js, and only if we want stems and a real Transport. Revisit at that point, not before.
Sources
- Web Audio gapless looping and decoder priming — Jake Archibald
- WebAudio/web-audio-api discussion #2505: can't play a loop without gaps
- Gapless playback — Hydrogenaudio
- Tone.js Transport · Tone.Player
- FMOD Studio API for HTML5
- iMusicXML · libJAME
RESOLVED — 2026-08-30
The research above was right that no library would fix this. It was wrong about the cause twice before it was right, and both wrong turns are worth keeping, because they are the ones a future agent will take.
Wrong turn 1: decoder padding. Measured and cleared — Chrome decodes the file with zero drift.
Wrong turn 2: THE WRONG FILE. The analysis was run against the Envato pack
in assets-src, on the assumption that it was the source of night.mp3. It is
not. The Envato synthwave is placeholder. The house track is Between Bells,
a Suno generation at assets-src/incoming/audio/suno/Between Bells.wav, and
night.mp3 was cut from .chk/bb-v2.wav. A whole 100-BPM grid analysis, a
bake and a production deploy went out against the wrong song before Chris
caught it. Confirm which master a shipped file came from before analysing
it — .chk/*.json and the loop-bake.py docstring both name it.
The actual fault: the loop was not a whole number of bars
Between Bells is 122.21 BPM — beat 0.490943s, bar 1.963770s. Measured, not guessed, and the method matters (Pipeline B below).
shipped loop 52.00 -> 67.55 = 15.55s = 7.92 BARS
Every wrap moved the music about a twelfth of a bar — roughly 100 ms of rhythmic phase error, every 15 seconds. That is the "skip". The seam itself measured FINE: −0.6 dB level step, 4.6% splice. The seam was never the problem; the length was, and ear-tuning could not find it because every point on the tuner's dial was off-grid too.
The second complaint — "we're losing half the OST" — was the same mistake from the other end. Between Bells is 199.8s. We shipped a 90s cut of it and looped 15.55s of that: 7.8% of the song.
Two pipelines, because there are two kinds of source
scripts/music-cut.py plan <file> [--pack DIR] derives the grid, maps the song
and ranks candidate loops. Everything downstream depends on one number — the
bar — so it is derived two different ways.
Pipeline A — library packs (Envato and friends): the grid is KNOWN. The
pack ships loops/*.wav that are whole phrases by construction. A 9.60s pack
loop is 4 bars, so the bar is 2.400s and the tempo is exactly 100 BPM. Pass
--pack DIR and the tool reads it off the files. Never detect what the
publisher has already told you.
Pipeline B — generated (Suno): the grid must be MEASURED. No metadata, no pack loops. Onset-flux autocorrelation, but refined against a peak ~60 seconds out rather than one beat out: that peak sits at k pulses, so dividing by k divides the error by k. It takes a 5 ms hop from 0.5% error to 0.008% — the difference between "about 122 BPM" and a number you can cut 52 bars against. The detected pulse is often 2 beats rather than 1, so the tool picks the multiple that lands the tempo in a believable range.
What ships
The bed ROTATES per night: sound.pickBed(), called from showRoster.
Only one bed is ever decoded; _playTrack sweeps the others once the new one
is on air.
| file | source | loop | bars | tempo | decoded |
|---|---|---|---|---|---|
night.mp3 |
Between Bells (Suno) | 36.054 → 138.170 (102.1s) | 52 | 122.21 | 54 MB |
night-lo.mp3 |
" | 32.130 → 81.220 (49.1s) | 25 | " | 32 MB |
drive.mp3 |
Night Drive (pack) | 22.819 → 130.819 (108.0s) | 45 | 100.00 | 51 MB |
drive-lo.mp3 |
" | 20.419 → 75.619 (55.2s) | 23 | " | 30 MB |
-lo is the phone pair (lowMem); both are lighter than the 90s file they
replace. The loop is 6.6× longer than what shipped before.
Three traps, all of which bit
streamingwasBoolean(this.tracks.night). Fine with one bed, silently fatal the moment the boot roll can land on another: whendrivewas picked the game decided licensed music was unavailable and fell back to oscillators for the whole session. It now tests the roster.- Roll the bed at the ROSTER, not at
startRun. A bed is several MB to fetch and decode. Rolled at the run it lands after the music has already started on the old bed, nothing re-triggers the switch, and the pick silently does nothing while both beds sit in memory. - Evict on switch, not on pick. The outgoing bed is usually still playing,
and
resetLoop/hearWraprebuild sources fromthis.tracks. Dropping the buffer at pick time either breaks those or, with a guard for the playing track, never evicts at all — measured as 118 MB of resident audio.
Each file keeps 2s past its loop end (loop-bake.py --tail), because
_playTrack clamps loopEnd to duration - 0.05, and a file ending exactly
at its loop point gets the loop shaved 50 ms straight back off the grid.
Verified
Eight consecutive nights on the deployed build: both beds appear, every run
plays a licensed bed (streaming true), every loop measures a whole number of
bars (52.000 / 45.000), never more than one bed resident, peak resident audio
90.8 MB including title, ambience and VO.
Still open
- The stems are unused — three for Between Bells, three for the pack. Stems
are what
setIntensity()has always wanted: calm bed and combat layer, started together and sample-locked, faded against each other. - The in-engine loop tuner is a debugging tool now, not a workflow. Loop
points come from
music-cut.py; the slider is for confirming, not finding.
ADDENDUM — 2026-08-30, round six
The 52-bar cut still skipped. Chris proposed two things: that the cut was half a measure early, and that he should hand over TWO FILES per song — a start that never repeats and a loop that does — instead of us splicing.
The half-measure hypothesis was tested and is not it. Shifting both points by ±half a measure, ±one beat and ±a full measure scored no better than the shipping cut (all near zero). The real finding came from scanning ALL 3,003 bar-aligned pairs in the song:
- Between Bells is through-composed. The median continuation similarity across every possible splice is ~0.0 — the song almost never repeats itself at the waveform level. Grid-perfect bar counts cannot make a seamless loop out of material that does not recur. This is characteristic of generated (Suno) tracks and is why the library-pack pipeline cannot simply be pointed at them.
- But its rhythmic structure repeats at exactly 72.660s (every strong pair in the scan is that same interval apart: 25→62, 8→45, 32→69…). Fine alignment peaks at 0ms offset — the interval is sample-tight.
What ships now (best that exists in this render)
| file | loop | interval | continuation corr |
|---|---|---|---|
night.mp3 |
49.800 → 122.460 | 72.660s | 0.81 |
night-lo.mp3 |
16.416 → 89.076 | 72.660s | 0.82 |
The old cut scored 0.03 on the same metric. This should be dramatically better — but it is a rhythm-level match, not a waveform match, so the melodic layer may still audibly hand off at the wrap. If it does, stop tuning: no better splice exists in this song. That is measured, not guessed.
The real fix: the two-file pipeline (scripts/music-stitch.py)
Chris's proposal is the correct one and the tool is ready:
python scripts/music-stitch.py night-start.wav night-loop.wav --out assets/music/night.mp3
Export from the DAW, both 48kHz WAV, no fades, no padding:
<name>-start.wav— plays once, from the top to the exact moment the loop begins.<name>-loop.wav— downbeat to downbeat, one full loop. Trusted sample-for-sample: if it cycle-loops cleanly in the DAW, it loops cleanly in the game.
The tool concatenates [start][loop][first 2s of loop], reports the
TRACK_LOOPS entry, and needs no crossfade — a crossfade is a bandage for a
cut made in the wrong place, and an authored loop does not need one. The tail
means the duration - 0.05 clamp can never shave the loop, and because what
follows the loop end IS the loop head, even the clamp boundary plays identical
material.
Pipeline summary, final form:
| source | tool | why |
|---|---|---|
| library pack | music-cut.py --pack |
grid is stated by the publisher |
| generated, unauthored | music-cut.py similarity scan |
best splice that exists, may not be seamless |
| generated, authored | music-stitch.py |
the answer — the cut is the author's |
THE IMPORT SOP — adding a Suno track to the game
Validated on Between Bells ("night sounds fine" — Chris, 2026-08-30).
Route 1 — authored (best). Chris exports two 48kHz WAVs from the DAW:
<name>-start.wav (plays once, top of song to the exact loop-in point) and
<name>-loop.wav (downbeat to downbeat, trusted sample-for-sample). Then:
python scripts/music-stitch.py <name>-start.wav <name>-loop.wav --out assets/music/<name>.mp3
No detection, no crossfade, no rounds of listening.
Route 2 — automatic. Drop the WAV in assets-src/incoming/audio/suno/:
python scripts/music-cut.py import "assets-src/incoming/audio/suno/Track.wav" --name=<name>
One command: measures the grid, scans every bar-aligned pair for continuation
similarity, fine-aligns the winner, bakes <name>.mp3 (desktop, ≤55MB
decoded) and <name>-lo.mp3 (phone, ≤36MB), and prints the exact
TRACK_LOOPS lines and the NIGHT_BEDS name to paste into src/audio.js.
If the scan reports NO USABLE SPLICE (best continuation < 0.3), the render
never repeats itself — do not tune, use Route 1.
Then: paste the printed lines into src/audio.js, npm test, build,
deploy. The rotation, the phone budget, the eviction and the streaming flag
all key off those two tables — nothing else to touch.
Do NOT use plan (no --pack) for Suno tracks: its level-step ranking is
what produced the through-composed 15.55s cut. import supersedes it.
Auditioning a bed (?bed= and hearWrap)
The beds rotate, so loading the game gives you whichever one the dice picked and no indication which. To hear a specific one:
https://hallpass.cc/?bed=night Between Bells (the Suno track)
https://hallpass.cc/?bed=drive Night Drive Synthwave (the library pack)
Takes the base name; the -lo variant is chosen automatically on phones.
To hear the WRAP without waiting out the loop, open the console mid-run:
__HALLPASS__.sound.hearWrap(3) // restart 3s before the loop end
__HALLPASS__.sound.loopInfo() // {name, start, end} of what is playing
hearWrap rebuilds the source at loopEnd - lead, so the join arrives in
about three seconds and can be repeated as often as needed. This is the only
honest way to judge a loop — waiting for a 72s cycle to come round makes it
impossible to compare two candidates.
The bake-off (why import bakes before it chooses)
Between Bells 2 settled this. Ranking candidates by continuation similarity
picked a 0.79-correlation pair; baked, it spliced at 5.0%. A pair ranked
lower at 0.55 baked at 0.3% — cleaner than the approved night bed
(2.0%). Similarity and hole scores are proxies, and on real material they
disagree with the ear.
So import now shortlists ~4 candidates per budget, bakes each one and
measures the actual wrap (level step, deepest dip, sample splice), and keeps
the winner. A handful of bakes costs under a minute and removes the guesswork
entirely. If the phone cut measures cleaner than the desktop one it is used
for BOTH — a smaller file is never a reason to reject a better join.
Reference numbers, for judging any future import:
| bed | step | dip | splice |
|---|---|---|---|
night (approved by ear) |
1.2 dB | 5.0 dB | 2.0% |
detention |
0.1 dB | 1.6 dB | 0.3% |
Naming
Beds are named for the school at night, not for the source track, so the
roster reads as a soundtrack rather than a filename dump: night
(Between Bells), drive (Night Drive Synthwave), detention (Between Bells
2). Chris does not need to supply names — pick one from the world and record
the mapping in the TRACK_LOOPS comment.
Unclaimed, for the tracks still to come: curfew, lockers, boiler,
gymnasium, chapel, library, stairwell, rooftop, cafeteria,
furnace, annex, courtyard, infirmary, basement, assembly.
THE ROSTER — 2026-08-30
Six night beds in rotation, plus a menu track. Numbers are the measured join
on the BAKED file (step / dip / splice); the night bed Chris approved by ear
measures 1.2 / 5.0 / 2.0%, which is the bar to clear.
| bed | source | loop | measured |
|---|---|---|---|
night |
Between Bells | 49.80 → 122.46 (72.7s) | 1.2 / 5.0 / 2.0% |
drive |
Night Drive (pack) | 22.82 → 130.82 (108.0s) | library grid |
detention |
Between Bells 2 | 19.65 → 67.08 (47.4s) | 0.1 / 1.6 / 0.3% |
curfew |
Between Bells 3 | 10.54 → 121.18 (110.6s) | 0.1 / 0.3 / 1.4% |
lockers |
Between Bells 4 | 42.34 → 123.94 (81.6s) | 0.1 / 3.7 / 0.5% |
rooftop |
Between Bells 7 | 22.32 → 65.00 (42.7s) | 0.5 / 2.5 / 0.4% |
title |
Gangsta | 26.07 → 71.08 (45.0s) | 2.2 / 3.5 / 2.3% — MENU |
title is NOT in NIGHT_BEDS: it plays on the title screen through
titleAmbience, which now honours TRACK_LOOPS.title instead of looping the
whole file end-to-start (that wrapped through the outro into the intro).
Held back, deliberately
Between Bells 5 and 6 are not shipped. Neither has a clean join anywhere:
the widest search (12 baked candidates each) still left an 8–14 dB hole at
every wrap, heard as the music dropping out. music-cut.py now prints a
!! WEAK JOIN warning above 5 dB step or 8 dB dip so this cannot ship
quietly. Do not try to fix them by tuning loop points — that search has
already been run. They need an authored -start / -loop pair through
scripts/music-stitch.py.
OPTIONS ▸ MUSIC — the audio chooser
On both the title and pause options screens. Lists every bed plus the menu and
boss cues, plays one on tap, shows the live loop points, and ▶ HEAR THE LOOP JOIN jumps to the wrap so a join can be judged in three seconds instead of
waiting out a two-minute cycle. Auditioning pins the bed (so the rotation does
not undo the choice) and lifts the music mute rather than silently doing
nothing.
?bed=name still works for pinning across a reload.
Naming
Beds are named for the school at night, never for the source track, so Chris
never has to name a song. Used: night, drive, detention, curfew,
lockers, rooftop. Free: boiler, stairwell, gymnasium, chapel,
library, cafeteria, furnace, annex, courtyard, infirmary,
basement, assembly, bleachers, janitor, parking.
ONE TIER — 2026-08-31
The -lo beds are gone. They were shorter cuts of the same songs, keyed
off lowMem — which is set from pointer coarseness at boot, so any touch
device (including touchscreen laptops) silently got less of the music. The
graphify graph surfaced the coupling (Sound bridging into Input & Sticks
through one line in main.js), Chris ruled: no small music for mobile.
Every device now decodes the full beds (~26-51MB each). The phone-side safety
is the 2-deep music LRU in _touchMusic — at most the night bed plus the
boss cue resident (~50 + 54MB peak) — not a shorter version of the song.
music-cut.py import bakes ONE cut per track now; --max-mb-lo is gone.
If field reports ever show iOS memory kills during boss fights, the levers are: shorter boss cue, or re-introducing a device gate keyed on something honest (deviceMemory where available) — never pointer coarseness.
The boss handoff releases the silent track (2026-08-31)
Decoded PCM is 384KB/s regardless of source format — MP3 only saves the
download, never the memory. So setBoss now releases whichever big buffer is
silent: engaging a boss drops the night bed (~48MB back) once the cue is on
air; the kill re-decodes the bed (a ~3MB refetch the HTTP cache answers),
fades back, and then drops the boss cue. Steady state is ONE music track
resident; bed+boss overlap only for the ~2s crossfades. The switch back is
promise-shaped — the boss cue keeps looping until the bed has landed, so a
kill never buys a beat of silence. Same pattern as releaseMenuAudio.
THE SECOND SEAM: re-entry replayed the intro (2026-08-31)
Every round of loop-point work assumed one seam per bed — the wrap. There are two, and the second one was a plain bug.
_playTrack called src.start(t) with no offset, so playback always began
at file position 0. On a first hearing that is right: the intro plays once and
the body cycles after it. But the same function runs every time a bed is
re-entered — the night bed returning after a boss dies, an unpause, a
re-fetch after an LRU eviction — and each of those restarted the song from
its intro, mid-run. At 3 AM, with music already established, the opening bars
came crashing back in. Chris heard it as "the intro clip smashes the loop", and
he was right; no amount of loop-point tuning could have fixed it, because the
loop points were never involved.
Fix: _playTrack(name, fade, fromLoop) keeps a _introHeard Set. First
hearing enters at 0; every later entry enters at loopStart — a downbeat by
construction — so the bed drops straight back into the groove under its normal
fade. fromLoop: false forces the intro back on for the dev audition, where
hearing the whole song IS the point.
Verified live: pass 1 offset 0.000, pass 2 offset 49.800 (= night loopStart),
audition offset 0.000.
The lesson to keep: when a loop "sounds wrong", establish WHICH seam is
being heard before touching numbers. The wrap (tail→head) and the entry
(silence/fade→body) are different events with different fixes, and the entry
one is not visible anywhere in TRACK_LOOPS.
THE LOOP LAB — loop-lab.html (hallpass.cc/loop-lab.html)
A Final-Cut-shaped editor for both seams, built because loop work by ear alone had burned four rounds. Standalone page, no game code, noindexed.
- WHOLE TRACK — full waveform, draggable loopStart/loopEnd handles.
- THE JOIN — the money panel. TAIL (audio approaching
loopEnd) is drawn right-aligned to a centre line; HEAD (audio fromloopStart) left-aligned from it; the other side is ghosted behind each so transients can be lined up by eye. Dragging a lane slides that end of the loop — the Final Cut gesture. A SUM lane shows the spliced waveform that actually plays. - THE ENTRY — the intro handing over to the body, with the RE-ENTRY audition that reproduces the fix above.
- LISTEN — auditions through the SAME primitive as the game (one
AudioBufferSourceNodewithloop/loopStart/loopEnd), so what is heard in the lab is what ships. REHEARSE THE WRAP starts 2s beforeloopEndso the seam repeats immediately instead of once every two minutes. - CROSSFADE (experiment) — bakes an equal-power blend of the loop tail
against the material before
loopStartand plays that instead. The shipping engine hard-cuts; this exists to answer "would a blend rescue this cut?" with evidence before anyone changes the engine. - MEASURED — step / dip / splice, ported from
music-cut.py wrap_quality()so the numbers are directly comparable to the ones in theTRACK_LOOPScomments, live-updating as the handles move. Thenightbed Chris approved by ear is printed as the bar to clear. - SHIP IT — prints the exact
TRACK_LOOPSline to paste, with a copy button, plus REVERT TO SHIPPED.
Sanity check on the port: the lab measures night at 0.78 / 4.91 / 0.6%
against the pipeline's 1.2 / 5.0 / 2.0% on the baked mp3 — same ballpark, and
dragging 120ms off the good cut moves splice 0.6% → 4.5% and the verdict from
CLEAN to CLOSE, which is the feedback loop the ear alone could not provide.