通行証 · HALL PASS DOCS · PLAY

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:

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:

  1. Musical time. _playTrack crossfades the moment a boss spawns, not on the next downbeat. Every transition in the game lands wherever it lands.
  2. Intro → loop → outro as scheduled sections. We bake an intro into the head of one file and hope.
  3. 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:

  1. Author the music loop-native. Ask for (or cut) the bed as intro / loop / outro at 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?".
  2. Add bar-aware scheduling ourselves — roughly 80 lines (estimate, not a measurement): store {bpm, beatsPerBar, downbeat} per track, add nextBar(t), and route _playTrack, setBoss and the domain stinger through it. src.start(when, offset) is already sample-accurate, so this is arithmetic, not DSP.
  3. Only then consider Tone.js, and only if we want stems and a real Transport. Revisit at that point, not before.

Sources


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

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


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:

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:

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.

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.

HALL PASS by Very Bad Games · Solo Developer Chris Sands · All documentation