Endless background music in a browser tab. Pick one or more styles (Metroid Vibes, Retro Games, Lo-fi Hip Hop, Jazz Piano Trio, Minimalist Piano, Monkey Island Vibes, Electro Swing, Synthwave, Bossa Nova, 70s Funk, Spaghetti Western, Celtic Tavern, Café Musette, Liquid Drum & Bass, Castlevania Vibes, 16-bit JRPG) and it plays forever, never repeating, without streaming anything: every note is arranged and synthesized live in the page.
Live: https://mindless-midi.pages.dev/ (the old GitHub Pages address redirects here)
- LLM-seeded. At build time Claude writes each style's corpus: chord progressions, motifs, bass and arpeggio ostinatos, comping rhythms, drum grooves, song forms, instrumentation. The corpora are checked in, so builds are hermetic and need no API key.
- Algorithmically arranged. In the browser, a seeded arranger picks key, tempo and form for each piece, binds material to sections, develops lead motifs (sequence, inversion, mutation, cadences), voice-leads the chords, swings and humanizes, and rotates between your selected styles.
- Played on an FM chip, or not. By default notes go to libADLMIDI (Nuked OPL3 emulation, WebAssembly) in an AudioWorklet, with sample-accurate timing. Each style uses a classic DOS-era FM patch bank. The Sound chips switch to three other engines that render per-instrument stems in a worker and mix them through a per-style studio bus (reverb, echo, ducking, tape): FM + studio (the same chip, one stem per instrument), Synth (modelled instruments, nothing to download) and Samples (recorded instruments, downloaded from the site on demand).
- Bazel all the way. Corpus validation, loudness calibration, bundling, the static site, the dev server and deployment are all Bazel targets.
Requires Bazelisk (Bazel 9.2 is pinned
in .bazelversion). Everything else, including Node, is fetched by Bazel.
bazel run //site:serveThen open http://localhost:8080. Other entry points:
bazel test //...bazel build //sitebazel run //tools/render -- --styles lofi,jazz_trio --seconds 120 --out /tmp/mix.wav --midi /tmp/mix.midIn the page: Space plays/pauses, N skips to the next piece, and
⤓ MIDI downloads the last 10 minutes as a .mid. ↻ Reseed starts over with a
new random seed. Under the score, ◂ past and future ▸ (or ← and
→) step through a piece's segments: its sections, then its ending, after which
comes the next piece. Going back first restarts the segment you are in if it has been
playing a few seconds. The number between them is the place: 3.4 is piece 3, segment 4.
While a jump waits for its samples to download, it pulses. The progress bar is the piece
cut into the same segments (the gaps are the joins): drag along it to pick one, and let go
to jump there.
The URL is the session: it carries the seed, the place in it (at=13.4 is piece 13,
bar 4, updated every bar), the style selection and the sound engine, so a link reproduces
the same music from the same spot (a link without styles means all of them). Opening the
page without a link picks up the last session you played, from where you left off; browsers
won't start audio on their own, so press Play to continue. A position is replayed from the
seed, so it lands on the same piece as long as the style selection hasn't changed along the
way; within a session, going back always replays what actually played.
The system's play/pause keys, and its previous/next keys (which step by piece; previous
restarts the current piece first if it has been playing a few seconds), work through
the Media Session API, as does the now-playing widget; because browsers only route them
to pages with a media element playing, a silent looping clip plays alongside the music
(src/app/media_anchor.ts).
bazel run //src/bench:serveA separate app (http://localhost:8090) that renders one score through every
engine offline (FM as shipped, FM through the bus, synths, samples, and an
experimental Magenta DDSP neural lead), loudness-matches them and lets you A/B
them at the same playhead, blind if you like, with per-instrument solos and a
spectrogram. CI deploys it to its own Cloudflare Pages project (see
Deploying); bazel run //src/bench:deploy publishes it to a bench-pages
branch by hand instead.
src/theory: seeded RNG, modes, roman-numeral chord parsing, chord-scales, voice-leading.src/corpus: the corpus schema (zod). Its field descriptions double as the generation instructions sent to Claude. Also the validator, andsound.ts, which declares the instruments each style can sound and the key range of each part (the arranger takes its registers from it, and a test keeps it inside). Forms can be areas (e.g. Metroid's Crateria, Brinstar, Norfair, Maridia, Wrecked Ship) with their own tempo, keys and instrument swaps, using material tagged with the same palette.src/engine:Conductorstrings pieces together forever;Pieceplans one piece (key, tempo, form, repeats, orchestration changes) and renders it bar by bar;parts.tswrites drums, ostinatos, chords and developed melodies. It is deterministic: the same seed gives the same music.src/audio: the sample-accurate event queue, the output leveler (calibrated style gain, slow ±6 dB auto-gain, soft limiter) and the AudioWorklet processor. Scheduling is driven by ticks from the audio thread rather than timers, so playback keeps going in background tabs.src/sound: the other engines. DSP voices (modal piano, FM e-piano, Karplus-Strong, PolyBLEP subtractive, analog kit), the sampler (SFZ and GM soundfont instruments, per-style drum kits), the schedulable mix bus, the render worker and the stem worklet.src/bench: the synthesis bench.src/app: the page.tools/generate: regenerates corpora with Claude.tools/stylec: the build-time style compiler used by the rules.tools/samples: the build-time sample fetcher (seestyle_samples).tools/render: offline WAV/MIDI rendering and loudness calibration (Node + the same WASM synth).tools/social: the link-preview card;tools/social/render.shre-renderssrc/app/social.png(headless Chrome) after you editcard.html.site: assembles the static site; dev server; deploy target.
Custom rules live in bazel/:
| Rule / macro | File | What it does |
|---|---|---|
music_style |
bazel/music.bzl |
Validates a corpus (an invalid corpus fails the build), binds the FM bank and UI colour, and calibrates per-instrument CC7 levels plus a style gain by rendering through the synth. --output_groups=+calibration emits a report. |
style_pack |
bazel/music.bzl |
Merges styles into the styles.json the app fetches. |
style_samples |
bazel/music.bzl |
Fetches the sample files the styles' declarations (sound.json, from music_style) say they can play. The site serves them as samples/. The action needs network access and reruns only when a declaration changes. Downloads are pinned by sha256 in styles/samples.lock.json: a changed or unlisted file fails the build, and bazel run //styles:samples_lock refreshes the pin after a style or upstream change. --output_groups=+report lists the files. |
gh_pages_deploy |
bazel/deploy.bzl |
bazel run target that commits the built site to a branch and pushes it. |
stamp_build_info |
bazel/stamp.bzl |
Fills the footer's build line (commit hash linked to GitHub) from workspace status. Needs --config=stamp. |
ts_lib, ts_test, ts_binary |
bazel/ts.bzl |
Repo conventions over rules_ts/rules_js (browser vs Node tsconfig, node:test tests). |
Third-party rules: aspect_rules_js, aspect_rules_ts, aspect_rules_esbuild,
bazel_lib, rules_nodejs.
bazel run //styles:regen -- styles/metroidThis sends styles/metroid/brief.md to Claude (claude-opus-5-5, adaptive
thinking, structured JSON output against the corpus schema), runs the same
validator the build uses, feeds any errors back for up to two repair rounds,
and overwrites styles/metroid/corpus.json. Several directories can be passed
at once. Credentials come from ANTHROPIC_API_KEY or an ant auth login
profile. Use --dry-run to print the prompts without calling the API, and
--model <id> to pick another model. Review the diff, rebuild (which
re-calibrates loudness), listen, commit.
The corpora currently checked in were written by Claude (Opus 5.5) directly during development rather than through this tool. Running the tool replaces them with API-generated ones.
- Create
styles/<id>/brief.mddescribing the music. - Run
bazel run //styles:regen -- styles/<id>. - Add
styles/<id>/BUILD.bazel(copy another style's) and pick an FMbank(libADLMIDI embedded bank number; e.g. 1 = Bisqwit, 14 = Doom's Bobby Prince set, 58 = The Fat Man, 68 = Nguyen/Wohlstand 4-op GM, 72 = DMXOPL3) and acolor. Check the calibration report (bazel build //styles/<id>:style --output_groups=+calibration): an instrument stuck at CC7 127 is too quiet in that bank. - Add the id to
STYLESinstyles/BUILD.bazel.
The workflow in .github/workflows/pages.yml runs bazel test //... and
bazel build --config=stamp //site on every push and PR. From main it
publishes only a small redirect (.github/pages-redirect/) to GitHub Pages,
which forwards old links, with their path and session query, to Cloudflare.
This needs Settings → Pages → Source: GitHub Actions.
.github/workflows/cloudflare.yml deploys the real site: it builds //site
and publishes to Cloudflare Pages (production from main, a preview URL per
same-repo PR). It does nothing until you set it up once:
- Create the project:
npx wrangler pages project create <name> --production-branch main(or Workers & Pages → Create → Pages → Direct Upload in the dashboard). - Create an API token with the Cloudflare Pages: Edit permission, and note your account ID.
- In the GitHub repo, add secrets
CLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_ID, and the variableCLOUDFLARE_PAGES_PROJECT=<name>. - Optional: enable Web Analytics for the project in the Cloudflare dashboard (cookieless, no consent banner needed).
The same workflow deploys the synthesis bench (//src/bench:site) as a
separate Pages project, <project>-bench, which it creates on first run.
To publish by hand to a gh-pages branch instead:
bazel run --config=stamp //site:deploy -- --dry-run--config=stamp bakes the commit hash into the footer; without it the build
line is left out. Drop --dry-run to push. The site is fully static and uses relative URLs, so
it works from any sub-path or static host. It needs a secure context (HTTPS or
localhost) for AudioWorklet.
- Synthesis uses 4 emulated OPL3 chips (72 two-operator voices) with Nuked OPL3, at roughly 4% of one CPU core. Offline it renders about 25x faster than realtime.
- Licensing: this project is MIT. The bundled synth is not: libadlmidi-js is LGPL-3.0, and upstream libADLMIDI mixes GPL and LGPL by component (only the Nuked OPL3 profile is bundled here).
- The Samples engine plays recordings from danigb/samples
and gleitz/midi-js-soundfonts.
The build fetches them (
//styles:samples) and the site serves its own copies undersamples/, so the page makes no requests to other hosts. Only what the styles can play is fetched: each style declares its instruments and key ranges, so a soundfont program contributes just the notes its parts can reach (about 2,700 files, 85 MB for all sixteen styles; a listener downloads the slice a piece needs). The files are pinned by hash and not checked in. The recordings: Splendid Grand Piano (public domain), VCSL (CC0), D. Smolken double bass (CC0), Greg Sullivan's Wurlitzer (CC BY 3.0), MusyngKite (CC BY-SA 3.0), plus drum-machine and Sonic Pi kits. The page credits whatever it has played. The bench still streams its samples from the upstream hosts, and additionally loads the Magenta DDSP checkpoints (Apache 2.0). - Dev console:
mindlessMidi.playerandmindlessMidi.stylesare exposed for tinkering.