Compare commits

Author SHA1 Message Date
teahubxyzandClaude Opus 5.5 bebacfc3a5 docs/v2: DJ prompt optimisation (DSPy/GEPA), RunPod vs Runware, taste-dynamics measures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-23 03:42:27 +00:00
teahubxyzandClaude Opus 5.5 4692350802 docs/v2: hb-5 benchmark result (8 GB M1 can't load ACE-Step 1.5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-22 07:13:35 +00:00
teahubxyzandClaude Opus 5.5 25ba285c5f docs/v2: mark the split brief superseded by the split branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-22 06:17:50 +00:00
teahubxyzandClaude Opus 5.5 1239fceb85 docs/v2: the module split already exists on the split branch; point there instead of the brief
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-22 06:17:11 +00:00
teahubxyzandClaude Opus 5.5 71da0c9a81 docs/v2: brief for splitting index.html into ES modules (no build step)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-22 06:14:24 +00:00
teahubxyzandClaude Opus 5.5 7520b8e037 docs/v2: notes from the POC thread for V2 (direction, auth, ACE-Step/Runware repaint+extend, embeddings, pricing, rights, moderation)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-22 06:13:58 +00:00
gitea-actions[bot] 0786964677 asmr: renders 2026-10-21 19:11:07 +00:00
teahubxyz 9376bd8c70 asmr: optional per-voice label 2026-10-21 19:10:18 +00:00
teahubxyz d8784c0318 asmr: Luna and eve rendered slower natively (pitch kept), to A/B against the 0.9× toggle
asmr / render (push) Successful in 1m10s
2026-10-21 19:10:16 +00:00
teahubxyz f02bc04c98 asmr: show cost per clip and per minute of audio on every voice 2026-10-21 19:01:51 +00:00
teahubxyz 8d200c5988 asmr: Venice TTS prices (USD per 1M input characters, from /models)
asmr / render (push) Successful in 1m0s
2026-10-21 19:01:40 +00:00
gitea-actions[bot] 6ca5c4d70d asmr: renders 2026-10-21 18:45:31 +00:00
gitea-actions[bot] 1cb6d53de1 asmr: renders 2026-10-21 18:43:42 +00:00
gitea-actions[bot] f7acd316ed asmr: renders 2026-10-21 18:42:31 +00:00
teahubxyz 4683129167 asmr: Inworld is wav-only; re-render Gemini and Grok with their whisper lead (first pass went out without it)
asmr / render (push) Successful in 1m20s
2026-10-21 18:41:11 +00:00
teahubxyz 599051c33f asmr: pull before rendering so back-to-back runs don't collide
asmr / render (push) Successful in 1m23s
2026-10-21 18:41:09 +00:00
gitea-actions[bot] c17a45f143 asmr: renders 2026-10-21 18:39:05 +00:00
teahubxyz 87de965fc9 asmr: hide the next-round list when it's empty; absolute favicon for the subdomain 2026-10-21 18:36:44 +00:00
teahubxyz 6803dfb029 asmr: per-voice lead/tail around the script (whisper tags, spoken direction)
asmr / render (push) Failing after 1m32s
2026-10-21 18:36:07 +00:00
teahubxyz 97e246507c asmr: round two — Inworld, Gemini, MiniMax, Grok
asmr / render (push) Successful in 1m40s
2026-10-21 18:35:31 +00:00
gitea-actions[bot] c883da7890 asmr: renders 2026-10-21 18:29:51 +00:00
teahubxyz dc1843c56d asmr: render workflow
asmr / render (push) Successful in 4m30s
2026-10-21 18:25:19 +00:00
teahubxyz f41c7abaac asmr: sampler page 2026-10-21 18:24:48 +00:00
teahubxyz e1e299788a asmr: renderer for the voice bake-off 2026-10-21 18:23:57 +00:00
teahubxyz 5e5b3b9e66 asmr: voice bake-off config 2026-10-21 18:23:46 +00:00
teahubxyzandClaude Fable 5.1 7176eb1f86 tools/harness-safari.py: the loop harness in WebKit, driven by safaridriver with a real click
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 09:37:11 +00:00
teahubxyzandClaude Fable 5.1 234dd00379 harness: safari mode (WebKit over safaridriver, real activation)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 09:36:51 +00:00
teahubxyzandClaude Fable 5.1 b1c8e231d7 handoff: the player rules learned the hard way, and the dev-first lane for player changes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 09:36:43 +00:00
gitea-actions[bot] d0797bc2c3 shots + sw stamp be3bce8aac 2026-10-21 09:32:11 +00:00
teahubxyzandClaude Fable 5.1 be3bce8aac handoff: the playing flag follows play()'s promise; a refused play shows TAP TO PLAY instead of a playing UI with no sound
shots / shots (push) Successful in 2m15s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 09:30:08 +00:00
gitea-actions[bot] c9b89d0b15 shots + sw stamp 36a8d64684 2026-10-21 08:44:16 +00:00
teahubxyzandClaude Fable 5.1 36a8d64684 voice preview: one at a time; a later tap stops the earlier one, even mid-fetch
shots / shots (push) Successful in 2m21s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 08:41:54 +00:00
teahubxyzandClaude Fable 5.1 6071116a26 station sheet: the × stays put while the sheet scrolls
shots / shots (push) Canceled after 42s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 08:41:08 +00:00
teahubxyzandClaude Fable 5.1 12f92bc28f station sheet scrolls (capped at 88vh); the DJ section is a collapsed disclosure
shots / shots (push) Canceled after 34s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 08:40:53 +00:00
gitea-actions[bot] 49010877ec shots + sw stamp d97628d1ad 2026-10-21 08:40:33 +00:00
teahubxyzandClaude Fable 5.1 d97628d1ad hold: the level is baked into the sample (iOS ignores element volume); OFF holds on pure silence
shots / shots (push) Successful in 2m21s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 08:38:29 +00:00
gitea-actions[bot] bc7a16bb34 shots + sw stamp dbe369d2c3 2026-10-21 08:34:34 +00:00
teahubxyzandClaude Fable 5.1 dbe369d2c3 station name: bigger, in ink
shots / shots (push) Successful in 2m19s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 08:32:08 +00:00
teahubxyzandClaude Fable 5.1 1dc150f977 room tone OFF switch next to the slider; off means no bed and a silent hold
shots / shots (push) Canceled after 2m15s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 08:30:07 +00:00
teahubxyzandClaude Fable 5.1 d22117ba55 sw: stats.html offline too
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 08:26:51 +00:00
teahubxyzandClaude Fable 5.1 1261d14945 stats.html: your numbers, from the ledger and the library, on the device only
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 08:26:41 +00:00
teahubxyzandClaude Fable 5.1 cebaead521 a usage ledger in localStorage (cooks, costs, latency, fallbacks, reactions, plays) for stats.html; the station sheet links to it
shots / shots (push) Failing after 2m20s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 08:24:31 +00:00
teahubxyzandClaude Fable 5.1 f0bbb5c95c voices across Venice's TTS models (Qwen3 with a style prompt for whisper/ASMR, Orpheus, ElevenLabs); the persona rides along as the style
shots / shots (push) Canceled after 2m15s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 08:20:42 +00:00
teahubxyzandClaude Fable 5.1 cd1e040688 tones mode: binaural beats (delta..gamma), solfeggio frequencies, isochronic pulses, made on the device in stereo
shots / shots (push) Canceled after 2m15s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 08:18:22 +00:00
teahubxyzandClaude Fable 5.1 5b1f63bd15 noise: made in slices so the page keeps breathing, 16 kHz, brown and crackle a little quieter
shots / shots (push) Canceled after 28s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 08:17:29 +00:00
teahubxyzandClaude Fable 5.1 e281e104dd hold crackle at room-tone level, not music level, and the bed stops the moment the hold starts; station modes: music / freestyle / asmr / noise
shots / shots (push) Canceled after 1m55s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 08:16:15 +00:00
gitea-actions[bot] eaaacfacc8 shots + sw stamp c1606fdf9b 2026-10-21 07:45:04 +00:00
teahubxyzandClaude Fable 5.1 c1606fdf9b welcome: a DJ with a chosen name keeps it instead of picking one
shots / shots (push) Successful in 2m15s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 07:42:43 +00:00
teahubxyzandClaude Fable 5.1 d65505882c DJ personas: per-station name, voice (with preview), speaking speed and a persona brief; presets incl. ASMR; brief feeds the track prompt and the break scripts
shots / shots (push) Canceled after 29s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 07:42:33 +00:00
gitea-actions[bot] 53101a498c shots + sw stamp 045b14b6a6 2026-10-21 07:41:04 +00:00
teahubxyzandClaude Fable 5.1 045b14b6a6 repeat toggle: the on state is a filled ink pill, not a 12% gold tint nobody can see
shots / shots (push) Successful in 2m23s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 07:37:53 +00:00
teahubxyzandClaude Fable 5.1 ec8c563595 handoff: a track started inside ended counts as playing at once, and the bed never starts while the phase is playing (on iOS a Web Audio start right after the swap could interrupt the element)
shots / shots (push) Canceled after 2m17s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 07:35:44 +00:00
teahubxyzandClaude Fable 5.1 74351fefef reaction pills alternate above and below the bar when neighbours are close; tapping a pill seeks to its moment
shots / shots (push) Canceled after 1m48s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 07:34:38 +00:00
gitea-actions[bot] 5a18bb5f44 shots + sw stamp f62ebdc79b 2026-10-21 07:33:38 +00:00
teahubxyzandClaude Fable 5.1 f62ebdc79b room tone default 18% (was 35%); a saved higher level is brought down once
shots / shots (push) Successful in 2m21s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 07:31:27 +00:00
gitea-actions[bot] 4f846dc15a shots + sw stamp 6e3965bf1c 2026-10-21 07:30:51 +00:00
teahubxyzandClaude Fable 5.1 6e3965bf1c reaction ticks: hits close together become one pill reading them in order, recomputed from the hit list each time
shots / shots (push) Successful in 2m25s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 07:28:14 +00:00
gitea-actions[bot] cc6ca29cbe shots + sw stamp 9186dc1be6 2026-10-21 07:28:06 +00:00
teahubxyzandClaude Fable 5.1 9186dc1be6 emoji tray: the last emoji you used always has a slot (one tap puts it in the bar), the other six are the most used
shots / shots (push) Canceled after 2m20s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 07:26:03 +00:00
gitea-actions[bot] 68aa700794 shots + sw stamp 4df29cc14f 2026-10-21 07:25:46 +00:00
teahubxyzandClaude Fable 5.1 4df29cc14f bed: when Web Audio takes over, the element is silenced
shots / shots (push) Successful in 2m20s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 07:22:57 +00:00
teahubxyzandClaude Fable 5.1 7c1992278e the room tone plays through Web Audio (a looping buffer is seamless; the element loop had a gap every pass); the element stays as the fallback and as the player's hold
shots / shots (push) Canceled after 56s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 07:22:25 +00:00
gitea-actions[bot] c9475f62d6 shots + sw stamp cf522e8926 2026-10-21 07:18:28 +00:00
teahubxyzandClaude Fable 5.1 cf522e8926 the player's hold uses a short 4 s crackle (handover waits at most one pass); the room tone keeps the long one
shots / shots (push) Successful in 2m18s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 07:16:08 +00:00
teahubxyzandClaude Fable 5.1 84c7ba1665 room tone decided from the phase, not from the player's paused flag (the unlock clip flipped it and caused an on-off-on fade); the crackle sample is 12 s long
shots / shots (push) Canceled after 34s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 07:15:50 +00:00
gitea-actions[bot] b47f62f8e6 shots + sw stamp 9213647549 2026-10-21 07:10:37 +00:00
teahubxyzandClaude Fable 5.1 9213647549 emoji drawer above the scrim (it was underneath it, so every tap closed it)
shots / shots (push) Successful in 2m15s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 07:08:39 +00:00
gitea-actions[bot] e531760491 shots + sw stamp b11b3b47a2 2026-10-21 07:06:23 +00:00
teahubxyzandClaude Fable 5.1 b11b3b47a2 emoji drawer: opens without grabbing the keyboard; the type-any-emoji box moves to the bottom; taps register on pointerup so a moving drawer can't swallow them
shots / shots (push) Successful in 2m17s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 07:04:21 +00:00
gitea-actions[bot] f01be6f5b2 shots + sw stamp b11ff4b7b6 2026-10-21 06:58:24 +00:00
teahubxyzandClaude Fable 5.1 b11ff4b7b6 reactions: the tray re-ranks after every tap; ticks that land within a second of each other stack upward instead of hiding one another
shots / shots (push) Successful in 2m18s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 06:56:22 +00:00
teahubxyzandClaude Fable 5.1 70c630711b webamp: mixtapes in the Open menu / LOAD LIST, SAVE LIST makes a tape from the playlist, ~40 Winamp Skin Museum skins in Options › Skins plus any museum link
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 06:47:39 +00:00
gitea-actions[bot] d607db3aac shots + sw stamp 6e0259daff 2026-10-21 06:31:36 +00:00
teahubxyzandClaude Fable 5.1 6e0259daff library opens again: the playing tile's class list no longer gets an empty token (which threw and aborted the render)
shots / shots (push) Successful in 2m26s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 06:29:08 +00:00
gitea-actions[bot] c2586c42e4 shots + sw stamp c5991ebc36 2026-10-21 06:27:46 +00:00
teahubxyzandClaude Fable 5.1 c5991ebc36 a little 🦙 in the library's cost line opens Winamp
shots / shots (push) Successful in 2m16s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 06:25:46 +00:00
teahubxyzandClaude Fable 5.1 105609b4b1 webamp.html on main: the library in Winamp with Milkdrop (gandora.lol/webamp.html)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 06:21:50 +00:00
teahubxyzandClaude Fable 5.1 582e5bebf2 typed "no X" / "less X" goes on the avoid list and marks X as a disliked tag; "more X" the reverse; the DJ is told the avoid list is a hard rule that outranks the taste model
shots / shots (push) Failing after 2m21s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 06:20:49 +00:00
teahubxyzandClaude Fable 5.1 a4dab59f21 brain: typed negatives count against the track, warm words never include what the station was told to avoid, a young station gets only a faint region prior from the other stations (no words), and the listener's avoid list wins
shots / shots (push) Canceled after 42s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 06:20:23 +00:00
gitea-actions[bot] d4b4f48b03 shots + sw stamp fc0bfe05a6 2026-10-21 06:16:27 +00:00
teahubxyzandClaude Fable 5.1 fc0bfe05a6 Merge pull request 'playback: one explicit state machine (idle → cooking → holding → playing → ended)' (#1) from playback-state-machine into main
shots / shots (push) Successful in 2m24s
playback as one state machine; brain.js via hooks; db.get; harness resume mode; docs (#1)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 06:14:04 +00:00
39 changed files with 1135 additions and 54 deletions

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
name: asmr
# Render the ASMR voice bake-off (asmr/voices.json) into asmr/audio/ through the shared inference worker and commit.
on:
workflow_dispatch:
push:
branches: [main]
paths: [asmr/voices.json, tools/asmr.py, .gitea/workflows/asmr.yml]
permissions:
contents: write
concurrency:
group: asmr
cancel-in-progress: false
jobs:
render:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 1
- name: ffmpeg
run: command -v ffmpeg || (sudo apt-get update -qq && sudo apt-get install -y -qq ffmpeg) || (apt-get update -qq && apt-get install -y -qq ffmpeg)
- name: render
run: git pull -q origin main && python3 tools/asmr.py
- name: commit
run: |
set -eu
git add asmr/audio
if git diff --cached --quiet; then echo "nothing new"; exit 0; fi
git -c user.name='gitea-actions[bot]' -c user.email='gitea-actions[bot]@users.noreply.teahub.xyz' commit -q -m "asmr: renders"
git push -q origin HEAD:main
- name: tell Teahub pages to pull
run: curl -fsS -X POST "https://gandora.yolopreneur.lol/_hook/gandora/site" || true
+17
View File
@@ -58,6 +58,23 @@ do. Read ARCHITECTURE.md first.
- One static page, no build step, no framework, no keys in the page.
- STORAGE.md's contract (chai's DB adapter targets it).
## Player rules (each one cost a regression on the phone; don't relearn them)
- **Player changes go to `dev` first** (dev.gandora.lol), get tapped through on an iPhone, then merge to `main`.
Anything touching `startNow / toHolding / toPlaying / bed / crackleOn / prime / setSource` counts. Features that
don't touch the player (modes, DJ sheet, stats, covers) may go straight to main.
- Run `sh tools/harness.sh safari` as well as the Chrome modes: WebKit is the iPhone's engine (play() policy, one
audio session) and the Chrome loop mode currently hangs on hb-5.
- iOS ignores `audio.volume`. A level is baked into the sample (`makeCrackle(seconds, gain)`), never set on the element.
- Never set `state.playing = true` before `play()` resolves. The flag follows the promise (`playNow`); a refusal shows
TAP TO PLAY. Setting it early gives a "playing" UI with no sound and a play button that needs two taps.
- Only one `<audio>` carries the station. A second element (previews) can interrupt the first on iOS; after one
plays, expect the next `play()` on the main element to be refused and handled by TAP TO PLAY.
- The hold between tracks must keep `ended` firing: hold on a real looping clip with its own URL (silence when room
tone is OFF), never on `SILENCE`, which the `ended` handler ignores.
- The crackle bed is Web Audio and must never start over the music: `bedStart` refuses while the phase is playing and
the element is playing or a play() is pending.
## What's next (agreed directions, not started)
- Text embeddings for every sheet, audio embeddings (Gemini Embedding 2) for kept/reacted tracks; the taste map
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+158
View File
@@ -0,0 +1,158 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>g*ndora asmr</title>
<meta name="theme-color" content="#0b0b0e">
<link rel="icon" href="https://gandora.lol/favicon.svg">
<style>
:root{--bg:#0b0b0e;--card:#141419;--line:#26262e;--ink:#ecebf2;--dim:#8a8996;--acc:#c9b8ff}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.45 ui-sans-serif,system-ui,-apple-system,sans-serif;padding:24px 16px 120px}
main{max-width:560px;margin:0 auto}
h1{font:600 22px ui-monospace,SFMono-Regular,Menlo,monospace;margin:0 0 4px}
h1 b{color:var(--acc)}
p.lede{color:var(--dim);margin:0 0 20px}
details{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px 14px;margin-bottom:18px;color:var(--dim)}
summary{cursor:pointer;color:var(--ink)}
.fam{margin:22px 0 8px}
.fam h2{font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:var(--dim);margin:0}
.fam small{color:var(--dim)}
.v{display:flex;align-items:center;gap:12px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px 14px;margin:8px 0;cursor:pointer;user-select:none}
.v.on{border-color:var(--acc)}
.v .ic{width:34px;height:34px;border-radius:50%;display:grid;place-items:center;background:#1f1f27;flex:none}
.v.on .ic{background:var(--acc);color:#111}
.v .n{flex:1}
.v .n i{display:block;font-style:normal;color:var(--dim);font-size:12px}
.v .bar{height:3px;background:var(--line);border-radius:2px;margin-top:6px;overflow:hidden}
.v .bar span{display:block;height:100%;width:0;background:var(--acc)}
.v.bad{opacity:.4;cursor:default}
.v .n b{font-weight:500;color:var(--acc);font-size:12px;margin-left:6px}
.stars{display:flex;gap:2px;flex:none}
.stars button{background:none;border:0;color:#3a3a45;font-size:18px;padding:2px;cursor:pointer}
.stars button.y{color:#ffd36b}
.next li{margin:4px 0}
.dock{position:fixed;left:0;right:0;bottom:0;background:rgba(11,11,14,.94);backdrop-filter:blur(8px);border-top:1px solid var(--line);padding:12px 16px calc(12px + env(safe-area-inset-bottom))}
.dock .in{max-width:560px;margin:0 auto;display:flex;gap:14px;align-items:center;flex-wrap:wrap}
.dock label{display:flex;gap:6px;align-items:center;color:var(--dim);font-size:13px}
.dock input[type=range]{width:90px;accent-color:var(--acc)}
.dock input[type=checkbox]{accent-color:var(--acc)}
</style>
</head>
<body>
<main>
<h1>g<b>*</b>ndora asmr</h1>
<p class="lede">Voice bake-off. Same 30-second goodnight script through every whisper-capable voice on the Venice worker. Tap to play, star the ones that land. Headphones.</p>
<details><summary>Costs</summary><p>Venice bills TTS per input character, so a clip's cost is the script length × the model's rate. Per minute / hour is that cost spread over the clip's actual length, which is how long ASMR would run. Rates from Venice's model list, Oct 21 2026.</p></details>
<details><summary>The script</summary><p id="script"></p><p><b>Style direction</b> (Qwen3 only): <span id="style"></span></p></details>
<div id="list"></div>
<div class="fam" id="nexth" hidden><h2>Next round</h2><small>Not on the shared worker's allowlist yet</small></div>
<ul class="next" id="next"></ul>
</main>
<div class="dock"><div class="in">
<label><input type="checkbox" id="rain" checked> rain</label>
<label><input type="range" id="rainv" min="0" max="1" step="0.01" value="0.25"></label>
<label><input type="checkbox" id="mic" checked> close-mic</label>
<label><input type="checkbox" id="slow"> 0.9×</label>
</div></div>
<script>
const $ = (s) => document.querySelector(s);
const store = { get(k){ try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } }, set(k,v){ try { localStorage.setItem(k, JSON.stringify(v)); } catch {} } };
const stars = store.get("asmr-stars") || {};
let ctx, bedSrc, bedGain, cur = null, buffers = {};
function graph() {
if (ctx) return;
ctx = new (window.AudioContext || window.webkitAudioContext)();
bedGain = ctx.createGain(); bedGain.connect(ctx.destination);
}
async function load(url) {
if (buffers[url]) return buffers[url];
const r = await fetch(url); if (!r.ok) throw new Error(r.status);
return (buffers[url] = await ctx.decodeAudioData(await r.arrayBuffer()));
}
async function bed(on) {
if (!ctx) return;
bedGain.gain.setTargetAtTime(on && $("#rain").checked ? +$("#rainv").value : 0, ctx.currentTime, 0.4);
if (on && !bedSrc) {
const b = await load("audio/bed-rain.mp3");
bedSrc = ctx.createBufferSource(); bedSrc.buffer = b; bedSrc.loop = true; bedSrc.connect(bedGain); bedSrc.start();
}
}
function stop() {
if (!cur) return;
try { cur.src.stop(); } catch {}
cur.el.classList.remove("on"); cur.el.querySelector(".ic").textContent = "▶";
cancelAnimationFrame(cur.raf); cur.el.querySelector(".bar span").style.width = "0";
cur = null; bed(false);
}
async function play(v, el) {
graph(); await ctx.resume();
const same = cur && cur.el === el; stop(); if (same) return;
el.querySelector(".ic").textContent = "…";
let b; try { b = await load("audio/" + v.id + ".mp3"); } catch { el.classList.add("bad"); el.querySelector(".ic").textContent = "×"; return; }
const src = ctx.createBufferSource(); src.buffer = b; src.playbackRate.value = $("#slow").checked ? 0.9 : 1;
let node = src;
if ($("#mic").checked) {
const lo = ctx.createBiquadFilter(); lo.type = "lowshelf"; lo.frequency.value = 180; lo.gain.value = 5;
const hi = ctx.createBiquadFilter(); hi.type = "highshelf"; hi.frequency.value = 6500; hi.gain.value = 3;
const cmp = ctx.createDynamicsCompressor(); cmp.threshold.value = -30; cmp.ratio.value = 4; cmp.attack.value = 0.01; cmp.release.value = 0.25;
const g = ctx.createGain(); g.gain.value = 1.5;
src.connect(lo); lo.connect(hi); hi.connect(cmp); cmp.connect(g); node = g;
}
node.connect(ctx.destination);
const t0 = ctx.currentTime, dur = b.duration / src.playbackRate.value;
src.start(t0 + 0.6);
cur = { src, el, raf: 0 };
el.classList.add("on"); el.querySelector(".ic").textContent = "❚❚";
bed(true);
const bar = el.querySelector(".bar span");
const tick = () => { if (!cur || cur.el !== el) return; bar.style.width = Math.min(100, 100 * (ctx.currentTime - t0 - 0.6) / dur) + "%"; cur.raf = requestAnimationFrame(tick); };
tick();
src.onended = () => { if (cur && cur.src === src) stop(); };
}
fetch("voices.json").then((r) => r.json()).then((cfg) => {
$("#script").textContent = cfg.script; $("#style").textContent = cfg.style;
let fam = "";
for (const v of cfg.voices) {
if (v.family !== fam) {
fam = v.family;
const h = document.createElement("div"); h.className = "fam";
h.innerHTML = `<h2></h2>${v.blurb ? "<small></small>" : ""}`;
h.querySelector("h2").textContent = fam; if (v.blurb) h.querySelector("small").textContent = v.blurb;
$("#list").append(h);
}
const el = document.createElement("div"); el.className = "v";
el.innerHTML = `<div class="ic">▶</div><div class="n"><span></span><i></i><div class="bar"><span></span></div></div><div class="stars"></div>`;
el.querySelector(".n > span").textContent = v.label || v.voice;
const rate = (cfg.pricing || {})[v.model];
const text = (v.lead || "") + (v.tags ? cfg.script.replace("Hey...", "Hey... <sigh>") : cfg.script) + (v.tail || "");
const clip = rate ? rate * text.length / 1e6 : null;
const usd = (x) => "$" + (x < 0.01 ? x.toFixed(4) : x < 1 ? x.toFixed(3) : x.toFixed(2));
const info = el.querySelector(".n i");
info.textContent = v.model + (rate ? ` · $${rate}/1M chars` : "");
if (clip != null) {
const tag = document.createElement("b"); tag.textContent = usd(clip) + "/clip"; el.querySelector(".n > span").after(tag);
const a = new Audio(); a.preload = "metadata"; a.src = "audio/" + v.id + ".mp3";
a.onloadedmetadata = () => { if (a.duration > 0) { const pm = clip / a.duration * 60; tag.textContent = `${usd(clip)}/clip · ${usd(pm)}/min · ${usd(pm * 60)}/hr`; } };
}
const st = el.querySelector(".stars");
for (let i = 1; i <= 3; i++) {
const s = document.createElement("button"); s.textContent = "★"; s.ariaLabel = i + " stars";
s.onclick = (e) => { e.stopPropagation(); stars[v.id] = stars[v.id] === i ? 0 : i; store.set("asmr-stars", stars); paint(); };
st.append(s);
}
const paint = () => st.querySelectorAll("button").forEach((b, j) => b.classList.toggle("y", j < (stars[v.id] || 0)));
paint();
el.onclick = () => play(v, el);
$("#list").append(el);
}
if ((cfg.next || []).length) $("#nexth").hidden = false;
for (const n of cfg.next || []) { const li = document.createElement("li"); li.textContent = `${n.family} — ${n.why}`; $("#next").append(li); }
});
$("#rain").onchange = () => bed(!!cur);
$("#rainv").oninput = () => bed(!!cur);
</script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"note": "One script, many voices. tools/asmr.py renders each entry through inference.yolopreneur.lol /v1/speech into asmr/audio/<id>.mp3. Edit and push to re-render (existing files are kept; delete one to redo it).",
"script": "Hey... you're still up. ... Come here, closer. ... Just breathe with me for a second. ... In... ... and out. ... Can you hear that? The rain's started. ... I'll stay right here... until you fall asleep. ... Goodnight.",
"style": "Very soft ASMR whisper, breathy, intimate, close to the microphone, slow and gentle, long pauses.",
"pricing_note": "USD per 1M input characters, from Venice /models?type=tts on 2026-10-21",
"pricing": {"tts-kokoro": 3.5, "tts-inworld-1-5-max": 12.5, "tts-xai-v1": 18.75, "tts-gradium-v1": 47.5, "tts-chatterbox-hd": 50, "tts-orpheus": 62.5, "tts-elevenlabs-turbo-v2-5": 62.5, "tts-qwen3-1-7b": 112.5, "tts-minimax-speech-02-hd": 125, "tts-gemini-3-1-flash": 187.5},
"voices": [
{"id": "kokoro-nicole", "family": "Kokoro", "model": "tts-kokoro", "voice": "af_nicole", "speed": 0.8, "blurb": "Baseline. What gandora used. Nicole is Kokoro's breathiest voice."},
{"id": "kokoro-sky", "family": "Kokoro", "model": "tts-kokoro", "voice": "af_sky", "speed": 0.8},
{"id": "orpheus-tara", "family": "Orpheus", "model": "tts-orpheus", "voice": "tara", "speed": 0.85, "format": "wav", "tags": true, "blurb": "Emotive tags (<sigh>) in the text."},
{"id": "orpheus-mia", "family": "Orpheus", "model": "tts-orpheus", "voice": "mia", "speed": 0.85, "format": "wav", "tags": true},
{"id": "qwen3-serena", "family": "Qwen3 TTS", "model": "tts-qwen3-1-7b", "voice": "Serena", "style": true, "blurb": "Takes a written style direction: the whisper prompt above."},
{"id": "qwen3-vivian", "family": "Qwen3 TTS", "model": "tts-qwen3-1-7b", "voice": "Vivian", "style": true},
{"id": "chatterbox-aurora", "family": "Chatterbox HD", "model": "tts-chatterbox-hd", "voice": "Aurora", "speed": 0.85, "format": "wav", "blurb": "Expressive open model."},
{"id": "chatterbox-siobhan", "family": "Chatterbox HD", "model": "tts-chatterbox-hd", "voice": "Siobhan", "speed": 0.85, "format": "wav"},
{"id": "elevenlabs-lily", "family": "ElevenLabs Turbo 2.5", "model": "tts-elevenlabs-turbo-v2-5", "voice": "Lily", "speed": 0.85, "blurb": "Polished, but no whisper tags in this generation."},
{"id": "elevenlabs-charlotte", "family": "ElevenLabs Turbo 2.5", "model": "tts-elevenlabs-turbo-v2-5", "voice": "Charlotte", "speed": 0.85},
{"id": "inworld-olivia", "family": "Inworld 1.5 Max", "model": "tts-inworld-1-5-max", "voice": "Olivia", "speed": 0.85, "format": "wav", "lead": "[whispering] ", "blurb": "Top of the quality charts. Script led with a [whispering] tag."},
{"id": "inworld-luna", "family": "Inworld 1.5 Max", "model": "tts-inworld-1-5-max", "voice": "Luna", "speed": 0.85, "format": "wav", "lead": "[whispering] "},
{"id": "inworld-luna-slow", "family": "Inworld 1.5 Max", "model": "tts-inworld-1-5-max", "voice": "Luna", "speed": 0.77, "format": "wav", "lead": "[whispering] ", "label": "Luna · slow render"},
{"id": "gemini-aoede-w", "family": "Gemini 3.1 Flash TTS", "model": "tts-gemini-3-1-flash", "voice": "Aoede", "lead": "Whisper this softly and slowly, close to the mic, like ASMR: ", "blurb": "Steered by a spoken-language direction in front of the script."},
{"id": "gemini-leda-w", "family": "Gemini 3.1 Flash TTS", "model": "tts-gemini-3-1-flash", "voice": "Leda", "lead": "Whisper this softly and slowly, close to the mic, like ASMR: "},
{"id": "minimax-calmwoman", "family": "MiniMax Speech 02 HD", "model": "tts-minimax-speech-02-hd", "voice": "CalmWoman", "speed": 0.85, "blurb": "Calm and sweet presets."},
{"id": "minimax-sweetgirl", "family": "MiniMax Speech 02 HD", "model": "tts-minimax-speech-02-hd", "voice": "SweetGirl", "speed": 0.85},
{"id": "xai-eve-w", "family": "Grok TTS", "model": "tts-xai-v1", "voice": "eve", "speed": 0.85, "lead": "<whisper>", "tail": "</whisper>", "blurb": "Wrapped in a whisper tag."},
{"id": "xai-eve-slow", "family": "Grok TTS", "model": "tts-xai-v1", "voice": "eve", "speed": 0.77, "lead": "<whisper>", "tail": "</whisper>", "label": "eve · slow render"},
{"id": "xai-ara-w", "family": "Grok TTS", "model": "tts-xai-v1", "voice": "ara", "speed": 0.85, "lead": "<whisper>", "tail": "</whisper>"}
],
"next": []
}
+10 -5
View File
@@ -7,12 +7,14 @@
============================================================ */
/* ---------- signals: every reaction and every play, as one signed weight per track ---------- */
const NEG = /\b(no|not|less|stop|without|drop|enough|never|skip|hate|too much|lose|kill|cut)\b/i;
function reactionWeight(spec, row) {
const r = String(spec.reaction || "");
let w = 0;
for (const ch of r.replace(/"[^"]*"/g, "")) { if ("🔥❤️⚡🎸🎹🌧️".includes(ch) && ch !== "️") w += 1; if ("🚫😴".includes(ch)) w -= 1; }
if (/\[skipped\]/.test(r)) w -= 1.5;
if (/"[^"]*"/.test(r)) w += 0.3; // words mean engagement, mildly warm
// typed words: engagement, mildly warm, unless they say no ("no violin", "less strings", "stop the classical")
for (const m of r.matchAll(/"([^"]*)"/g)) w += NEG.test(m[1]) ? -0.6 : 0.3;
const plays = (row && row.plays) || spec.plays || 0;
if (plays > 1) w += Math.log2(plays) * 0.6; // on repeat: 4 plays ≈ a 🔥, 100 ≈ four of them
if (row && row.kept) w += 1.5;
@@ -119,10 +121,13 @@ function tasteText() {
const p0 = net.predict({ ...base, tags: "" });
const scored = [...vocab.keys()].filter((w) => vocab.get(w) >= 2).map((w) => [w, net.predict({ ...base, tags: w }) - p0]).sort((a, b) => b[1] - a[1]);
const likes = scored.slice(0, 6).filter((s) => s[1] > 0.02).map((s) => s[0]).join(", "), dislikes = scored.slice(-4).filter((s) => s[1] < -0.02).map((s) => s[0]).join(", ");
const which = brains.station ? "this station" : "all their stations";
return `Taste model (a small net trained on the listener's reactions and play counts across ${which}, ${net.n} signals): most likely to land: ${top}. Least likely: ${low}.` +
(likes ? ` Words it warms to: ${likes}.` : "") + (dislikes ? ` Words it cools on: ${dislikes}.` : "") +
` Aim near the likely region and lean on the warm words unless the listener's own words say otherwise; keep some variety.`;
// never recommend what this station was told to avoid; a young station borrows only the region from the others, not their words
const avoidWords = new Set([...(state.taste.avoid || []), ...(state.taste.notes || []).filter((n) => NEG.test(n))].flatMap((s) => tokens(s)));
const likesList = scored.slice(0, 8).filter((s) => s[1] > 0.02 && !avoidWords.has(s[0])).slice(0, 6).map((s) => s[0]).join(", ");
if (!brains.station) return `Taste prior (from the listener's other stations, ${net.n} signals; a faint hint only — this station's seed and the listener's own words come first): they tend to land around ${top}; less so ${low}.`;
return `Taste model (a small net trained on the listener's reactions and play counts on this station, ${net.n} signals): most likely to land: ${top}. Least likely: ${low}.` +
(likesList ? ` Words it warms to: ${likesList}.` : "") + (dislikes ? ` Words it cools on: ${dislikes}.` : "") +
` Aim near the likely region; keep some variety. The listener's explicit asks above and below outrank this model.`;
}
/* the page's hooks (index.html › hooks): the taste text rides into the DJ's prompt, and a station opening retrains */
hooks.djPrompt = (base) => {
+142
View File
@@ -0,0 +1,142 @@
# Notes for V2 from the POC thread (21–22 Oct 2026)
What came out of a long working session on the POC that the V2 spec doesn't already say, or where the POC
experience suggests a different call. Read alongside the V2 handover spec and `HANDOFF.md` (its **Player rules**
section is the iOS lessons; they apply to any client). Nothing here is decided unless it says so.
## Direction
- **Leaning native-native** (Swift/SwiftUI first) over Expo. The reasons that matter for gandora specifically:
- **The DJ can run on the phone.** iOS 26 Foundation Models does guided generation into a Swift struct; the
struct is the sheet (`title, tags, lyrics, bpm, key, energy, seconds, meter, line`). Free, offline, ~1 s.
Rendering still goes to Runware (ACE-Step doesn't fit on a phone).
- **Testability.** Today's regressions all came from "the only iOS test rig is a phone". XCTest + simulator
exercise real AVFoundation: background audio, interruptions, lock screen, route changes.
- `AVQueuePlayer` pre-enqueues the next track, so most of the POC's hold/crackle/`ended` machinery goes away.
- Covers (`hash32`, `prng`, rings/chord/attractor) port line-for-line; keep integer ops identical for parity.
- The web POC stays as the lab, the Android/desktop fallback and the share-link landing. Features are invented
there and promoted once they've earned it (the spec's parity tracker).
- Alternatives considered: Capacitor (fastest path, wraps the current page), Tauri (early on mobile; good for a
desktop app with local ACE-Step), Flutter, Expo (right if the UI is being rebuilt anyway).
## Backend
- **Auth: Supabase Web3** (`signInWithWeb3`, Solana/Ethereum, EIP-4361). chai's call.
- Put the ToS acceptance in `statement` (Venice ToS §7.3 needs end users bound by terms).
- Wallets are free to mint, so **free tier is per device, not per account**: no login needed to listen;
login is for sync and paying. Enable per-IP rate limits + captcha.
- No recovery for wallet-only accounts: nudge `linkIdentity()` once sync is on.
- iOS PWA + wallets is clunky (deep links / in-wallet browser). Native solves it.
- No nostr provider; a custom-JWT edge function could add NIP-07 later.
- Minimal first slice: one Supabase project, tables `stations`, `tracks`, `events` with RLS on the wallet
identity, the existing Cloudflare worker verifying the Supabase JWT, blobs in R2.
## Rendering: ACE-Step and Runware
- ACE-Step 1.5 is **open weights, MIT** (code and weights). Model card: commercial use of outputs allowed;
training data claimed as licensed + royalty-free/public-domain + synthetic MIDI audio.
- **Runware already exposes repaint and extend** (schema: `runware.ai/docs/models/ace-step-v1-5-xl-turbo/schema.json`):
- `inputs.audio` (URL/UUID/data URI) + `settings.repaintingStart` / `repaintingEnd` (s) = repaint a section;
`repaintingEnd` past the end appends (extend), negative `repaintingStart` prepends (intro).
- `strength` 0–1 with `inputs.audio` = restyle; `numberResults` 1–4 = variations.
- No stems/extract/lego/cover task types on Runware; those need self-hosting.
- `duration` must be omitted when `inputs.audio` is set; `timeSignature` is beats (2/3/4/6), not "3/4".
- Product ideas from this: repaint around a reaction pill ("redo this bit"), ⟶ extend, restyle remixes.
- **Self-hosting is for customization, not cost** (Princess's priority). Runware turbo is $0.0045 per 2-min
track, about what a fully busy GPU costs. Self-hosting buys: LoRAs (per station, per DJ persona, from old
tracks), the extra task modes, the planner LM, XL without paying double.
- VRAM tiers (repo): 8–16 GB = 2B turbo + small LM; 20–24 GB = XL turbo without offload.
- LoRA training: ~1 h for 8 songs on a 12 GB GPU (repo figure).
- **Measured 22 Oct on hb-5 (M1, 8 GB):** fails to load. Even the smallest config (2B turbo, no LM) ran out of
GPU memory loading the VAE after the DiT (9.05 of 9.07 GiB allowed, 3 GB into swap). Realistic floor: a 16 GB
Mac for 2B turbo, 24 GB+ for comfort and near-XL, 36 GB+ for a Studio machine that also trains LoRAs; on GPUs
8–12 GB for 2B turbo, ~20–24 GB for XL. For LoRA/stems experiments, rent a 24 GB GPU for an hour first.
## Taste: embeddings direction
- One multimodal space (Gemini Embedding 2, 768-d): seed, sheet, uploaded audio, rendered track all as points.
Station = region (centroid of kept, shadow of avoided); taste = cosine to that direction; far more
data-efficient than the 54→12→1 net on a few dozen reactions.
- DJ gets **nearest-neighbour exemplars** (RAG for the DJ) instead of `tasteText()`.
- Costs: text embeddings ~free; audio ~$0.01/min (≈4× a turbo render), so audio-embed uploads, kept tracks
and ~30 s around reaction ticks, not every track. Stamp vectors with model/dims/chunking.
- Later, owning the model: **gisting** (compress persona/rules into a few tokens) and **xRAG** (feed retriever
embeddings straight in as tokens) let a local DJ be conditioned on the taste vector directly. Needs an
open-weights DJ (Gemma-class) + a trained projector; every kept track is a training pair.
## Product
- **Market**: no shipping product combines per-listener *generated* songs + a talking DJ + learned taste.
Spotify DJ / Yoodio / Trending Music have the voice on licensed catalog; Endel / Noema generate but have no
voice; Suno Live Radio etc. are shared broadcasts. The window is cost-driven and won't stay open.
- **Pricing sketch** (not decided): Free (≈20 new tracks/day per device, then kept/catalog; noise/tones/ASMR
unlimited; DJ-read sponsor breaks) · Listener ~$7/mo (unlimited new music, soft cap ~3 h/day generation,
no sponsor breaks, all voices, sync) · Studio ~$18/mo (XL, remix/repaint/extend, downloads, own-voice DJ).
Price in time and tracks, never credits. Track **cost per kept track**.
- **Uploads / analyzer**: upload old tracks → local DSP (bpm, key, energy, sections) + Whisper lyrics + DJ
tags → an imported library row → station seeded from it with the file as reference audio.
- **Feedback button**: 🐛 files a Gitea issue with build rev, player state, current sheet, last ledger lines and
console errors attached; dedupe by fingerprint; the factory triages `feedback` issues.
## Rights and moderation
- Runware: outputs owned by the user; model licences are the user's responsibility. Venice: user keeps rights
in outputs; API products need end-user terms at least as protective; no third-party voice/likeness without
consent. Kokoro: Apache-2.0.
- US Copyright Office: purely AI-generated music isn't copyrightable. Don't promise exclusivity.
- **EU AI Act Art. 50** (in force 2 Aug 2026): mark synthetic audio machine-readably (at least ID3 /
C2PA-style tag from the worker) and disclose. Applies to EU listeners regardless of where we are.
- To do before anyone outside the team listens: ToS + AI disclosure; output tagging; written confirmation from
Runware / ACE Studio on commercial streaming; voice-clone policy (own sample + consent only).
- **Moderation**: check the sheet (tags, lyrics, DJ line) in the worker before render/TTS; strip real artist and
people names from tags; check persona text on save; report button on shared content; per-station explicit
setting (hard rules stay on). Venice is permissive, so the guard has to be ours.
## DJ prompt optimisation: DSPy / GEPA (proposed, not started)
- Treat the DJ as a DSPy program: station context + taste + recent reactions -> the sheet (strict schema). The
instructions to optimise are the next-track prompt, the break prompt, `MODE_BRIEF` and persona framing.
- **GEPA** fits because it learns from *textual* feedback via reflection on traces, and gandora has lots of it
(typed reactions, timed emojis, keeps/skips). It keeps a Pareto frontier of instructions, which matches
per-station taste (an ASMR-best prompt and a freestyle-best prompt both survive).
- Dataset: exported station histories (sheets + the reactions that followed; `stats.html` EXPORT JSON has most of it).
- Metric, two layers: (1) cheap checks: schema valid, avoid-list respected, no real artist/person names, variety
vs recent tracks; (2) an LLM judge for "does this sheet follow from these reactions", whose reasoning is the
feedback text GEPA reflects on. Human keep rate stays the final arbiter (judges can be gamed).
- Run offline in Python against the same Venice models via the worker; promote winners as pinned Langfuse prompt
versions (V2) or port back into the DJ prompt code (POC). Hold out stations; A/B on keep rate before switching.
## Renderer economics: Runware vs self-hosted (RunPod), 22 Oct
- Per 2-min turbo track: Runware $0.0045; RunPod serverless roughly the same when warm, worse with cold starts
(~30–60 s loading ~9 GB of weights); an always-on 4090 pod (~$0.34/hr community, ~$245/mo) only beats Runware
above ~75 tracks/hr sustained. XL roughly doubles both sides.
- Functionality is the real difference: Runware has text2music, reference audio, repaint/extend, strength,
variations; self-hosting adds LoRAs, stems/extract/lego/complete, full planner-LM and sampler control.
- Plan: Runware stays default. RunPod pod by the hour for LoRA training; a RunPod/Modal serverless endpoint only for
renders that need a LoRA or stems, selected by the worker from the sheet (e.g. `lora: "..."`).
## Taste dynamics: measurement ideas (research, not product yet)
- **Forward vs time-reversed training:** retrain the brain on a station's reactions in reverse order and compare
predictions; the gap = path dependence (how much is recency vs a stable summary).
- **Signed area of the taste path** (Lévy area of c(t), the station centroid over time): antisymmetric under time
reversal, so it separates a consistent drift/turn (non-zero) from wandering (cancels) and collapse to a default
(little area). Cheap enough to show as a curl indicator on the TASTE view.
- **Telephone loop:** sheet -> render -> (future) audio embedding -> DJ reads it back -> next sheet. Station drift
splits into noise (diffusive), attractor (collapse to generic, a bug) and holonomy (direction-dependent turn,
maybe a feature). Reversing loop order flips holonomy but not the other two.
- Context: a math thread on holonomy / Berry phase across embedding-space charts (xRAG projector as a transition
map; loop products of Procrustes maps between embedding models as discrete holonomy). Explainer video:
`teahubxyz/media` -> `manim/transition-map-narrated.mp4`.
## POC housekeeping that affects V2
- **The module split is done on the `split` branch** (ES modules, no build step: `app.js core.js covers.js dj.js
render.js store.js player.js mashup.js ui/*.js`; `index.html` is markup + CSS). It is current with `main` apart
from these docs, every module parses, and the Safari harness gives the same results as `main`. Not yet merged:
it needs the dev → phone → main pass. It produces the pure pieces a Swift port reads from (covers math, sheet
contract, brain, DJ prompts). `docs/v2/REFACTOR-PROMPT.md` was written before we saw the branch and is
superseded by it.
- `tools/harness.sh safari` runs the loop in WebKit via safaridriver on hb-5 (needs `safaridriver --enable`).
- Player changes go `dev` → phone → `main` (HANDOFF.md).
+104
View File
@@ -0,0 +1,104 @@
# Split gandora's index.html into ES modules — brief for a fresh agent
> **Superseded.** The split already exists on the `split` branch (see `docs/v2/NOTES-FROM-POC.md`). Kept for its
> rules and checklist, which still describe how to review that branch before it merges.
You are refactoring **gandora/site** on teahub.xyz (Gitea; use the Teahub MCP tools: `tea_file_get`, `tea_file_edit`, `tea_file_change`, `tea_commit`, `tea_branch_create`, `tea_pull_create`; there is no git access from the container). The Mac **hb-5** is reachable through `boring_mac_exec` (120 s per call; long jobs with `nohup … &` then poll); a clone lives at `~/scratch/gsite` (`git pull -q` to refresh). Node is at `/opt/homebrew/bin/node`.
Work on the **`dev` branch only.** Do not push to `main`. Open one PR at the end.
## Goal
`index.html` is a ~2,100-line single file holding the player, the DJ, rendering, covers, storage, the UI sheets and the usage ledger in one scope. Split it into ES modules **with no build step**: plain `<script type="module">`, static files served as-is by Pages, view-source still works, the service worker still caches a fixed list. Behaviour must be unchanged. The point is blast radius: after this, the player is the only file that needs the dev-branch-and-iPhone treatment.
## Read first, in this order
1. `HANDOFF.md` — especially **"Player rules"** (iOS invariants learned the hard way) and "What a refactor should keep".
2. `ARCHITECTURE.md` — the state machine and playback invariants.
3. `STORAGE.md` — the `db` adapter contract (chai's adapter targets it; keep the shape).
4. `tools/harness.sh`, `tools/harness.html`, `tools/harness-resume.html`, `tools/harness-cdp.py`, `tools/harness-safari.py` — these are your tests.
5. `index.html`, `brain.js`, `sw.js`, `stats.html`, `webamp.html` (the last two read IndexedDB and localStorage directly; don't change their contracts).
## Target layout
```
index.html markup + CSS only, plus <script type="module" src="app.js"> and <script src="brain.js">
app.js boot: wires modules, sets window.g, hooks for brain.js, URL/share handling, SW + update pill, theme
player.js the state machine and the ONE <audio>: state, setSource, toCooking, toHolding, queueBehindHold,
handOver, startNow, playNow, toPlaying, toEnded, toIdle, playJob, advance, prime, bed/crackle/bedStart,
makeCrackle, SILENCE, Media Session, vizBeat/analyser
dj.js djPrompt, djText, askDJ, askBreak, askMash, DJ_SCHEMA, BREAKS/BREAK_BRIEF, MODES/MODE_BRIEF,
PERSONAS, VOICES/voiceParts/voiceLabel, speak, remixText, stationFrom helpers that only build specs
render.js venice() (as an injectable fetcher), renderTrack, hedge/queue/poll path, makeNoise, noiseSpec,
makeTone, toneSpec, wavBlob, wavBlob2, cost bookkeeping
covers.js pure: hash32, prng, keyHue/palettes, drawCover, drawRings, drawChord, attractor, drawMark,
drawOnAir, tape covers. No DOM lookups, no state — take a canvas and a spec.
store.js db (open/get/put/all/del), stations (saveStation, listStations data), tapes incl. the KEPT virtual
tape, tapeSpec, logEvent (the ledger), LS keys
ui/sheet.js stationSheet (name, look, palette, mode, dj details, sliders, actions)
ui/library.js library view, in-place play, now-playing bar, 🦙 link
ui/player.js cover/title/tags/lyrics rendering, keep/share buttons, scrubbing, drawTicks, markReaction, emoji
tray + picker, react(), toasts, tap-to-play veil
mashup.js mashup() (OfflineAudioContext stitch) and askMash wiring
brain.js unchanged (it is a classic script; keep its globals and the hooks contract)
```
Names are suggestions; the boundaries are not. Hard boundaries:
- **Only `player.js` touches `state.audio`** (or any `Audio` element that carries the station). Everything else calls its exports or listens to its events.
- `covers.js` and the generators in `render.js` are **pure** (no `document`, no `state`), so they can be checked in node.
- `brain.js` keeps working as-is: it reads `hooks`, `state`, `cssv()`, `db`; expose exactly what it needs on `window` from `app.js` (see below).
- The `venice` fetcher must be **injectable** (e.g. `render.setFetcher(fn)`), because the harness fragments replace it. Reassigning a module binding from outside won't work.
## Globals for the harness and brain.js
The harness fragments and `brain.js` reach into `state`, `crackle`, `openStation`, `cookNext`, `venice`, `db`, `hooks`, `wavBlob`, `$`. In `app.js`, after wiring, set
```js
window.g = { state, player, dj, render, store, covers, openStation, cookNext, crackle, hooks, db, wavBlob, $ };
```
and additionally put on `window` the exact names `brain.js` uses today (grep it). Then update the three harness fragments to read through `window.g` (minimal edits; same assertions). `tools/harness-cdp.py` and `tools/harness-safari.py` already serve over http; modules need http, so the `file://` loop mode of `harness.sh` is retired: make `sh tools/harness.sh` run the loop fragment through the CDP path (it already does for `resume`).
## Service worker
`sw.js` precaches a fixed `FILES` list and stamps `REV` from the commit. Add every new module to `FILES`. Make sure a half-updated cache can't mix versions: the update pill / RELOAD path must refresh all of them together (one cache name keyed by REV is enough; check it already is).
## CSP
`index.html` carries a strict CSP (`script-src 'self' 'unsafe-inline'`). Module scripts from `'self'` are fine; don't add inline module code and don't add any CDN.
## Method — in order of distance from the audio element
Do it as a sequence of small commits on `dev`, each leaving the page working and the harnesses green:
1. `covers.js` + the generators (pure) → node check: `node -e` import them and draw/generate once.
2. `store.js` (db, tapes, ledger). `stats.html` and `webamp.html` must still read the same IndexedDB/localStorage shapes.
3. `dj.js` and `render.js` with the injectable fetcher.
4. `ui/*`.
5. `player.js` **last and alone**, in its own commit, touching nothing else. Re-read HANDOFF "Player rules" before this step. Do not "improve" the state machine while moving it.
6. `app.js` wiring, `window.g`, harness fragment edits, `sw.js` list, `HANDOFF.md`/`ARCHITECTURE.md` updated to the new layout (short; keep the invariants section intact and add "file map").
After each step run, on hb-5 in `~/scratch/gsite` (after `git pull -q`):
```sh
/opt/homebrew/bin/node --check app.js player.js dj.js render.js covers.js store.js ui/*.js # syntax
sh tools/harness.sh # loop, Chrome over CDP
sh tools/harness.sh resume # resume with autoplay blocked, expects TAP TO PLAY
sh tools/harness.sh safari # WebKit (iPhone's engine) with a real activation
```
All three must print the same expectations as before the split (compare against a run on `main` first and save that output). A diff in any `expect …` line is a regression; fix or revert the step.
## Rules
- No behaviour changes. No TypeScript, no bundler, no framework, no new dependencies, no reformatting of lines you aren't moving (keep diffs reviewable).
- Preserve every comment that explains an invariant; move it with the code.
- Keep `index.html` the entry URL; keep the share link formats (`/#seed`, `/?t=`), the IndexedDB name/stores/record shapes, and all `localStorage` keys exactly.
- Don't touch `brain.js`, `webamp.html`, `stats.html`, `tools/icons.py`, `.gitea/workflows/shots.yml`, `manifest.json`, icons.
- If something can't be split without a behaviour change, leave it in place and say so in the PR rather than changing behaviour.
- Commit messages: one line saying what moved, with the attribution lines in use on this repo.
## Deliver
A PR `dev → main` with: the file map; the harness outputs before and after (all three modes); a list of every `window` global exposed and who uses it; anything you left unsplit and why; and the exact `sw.js` FILES list. Stop there — a human taps through dev.gandora.lol on an iPhone before merge.
+291 -47
View File
@@ -74,16 +74,18 @@ input,textarea{font:inherit;color:inherit}
/* ---------- player ---------- */
#player{min-height:100dvh;display:grid;grid-template-rows:auto 1fr auto;padding:calc(22px + env(safe-area-inset-top)) 16px calc(16px + env(safe-area-inset-bottom))}
#top{display:flex;align-items:center;justify-content:space-between;font:600 12px var(--font);letter-spacing:.2em;color:var(--dim)}
#stage .station{font:600 11px var(--font);letter-spacing:.22em;color:var(--dim);max-width:min(78vw,380px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;text-transform:uppercase;margin-bottom:-4px}
#stage .station{font:600 16px var(--font);letter-spacing:.18em;color:var(--ink);max-width:min(86vw,420px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;text-transform:uppercase;margin-bottom:-2px}
#stage .station:hover{color:var(--dim)}
#stage .station:hover{color:var(--acc)}
#stations .s{position:relative}
#stations .s .dots{position:absolute;top:-4px;right:2px;width:22px;height:22px;border-radius:50%;background:var(--card);border:1px solid var(--line);color:var(--dim);font-size:12px;line-height:1}
#scrim{position:fixed;inset:0;z-index:5;background:rgba(0,0,0,.25)}
#stationSheet,#picker,#tapePick{z-index:6}
#stationSheet{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(480px,100%);background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:14px 14px calc(14px + env(safe-area-inset-bottom));z-index:6;box-shadow:0 -20px 60px rgba(0,0,0,.6)}
#stationSheet{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(480px,100%);max-height:88vh;max-height:88dvh;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:14px 14px calc(14px + env(safe-area-inset-bottom));z-index:6;box-shadow:0 -20px 60px rgba(0,0,0,.6)}
#stationSheet details{margin-top:10px}#stationSheet summary{cursor:pointer;color:var(--dim);font-size:12px;list-style:none;display:flex;align-items:center;gap:6px}#stationSheet summary::-webkit-details-marker{display:none}#stationSheet summary::before{content:"▸";font-size:10px}#stationSheet details[open] summary::before{content:"▾"}
#stationSheet h4{font:600 10px var(--font);letter-spacing:.2em;color:var(--dim);margin:0 0 10px}
#upd{position:fixed;top:calc(10px + env(safe-area-inset-top));left:50%;transform:translateX(-50%);z-index:9;padding:9px 16px;border-radius:999px;background:var(--ink);color:var(--bg);font:600 11px var(--font);letter-spacing:.16em;box-shadow:0 10px 30px -10px rgba(0,0,0,.6)}
#stationSheet .x{position:absolute;top:8px;right:8px;width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--dim);font:300 22px var(--sans);line-height:1}
#stationSheet .x{position:sticky;top:-8px;float:right;margin:-8px -6px 0 0;z-index:1;background:var(--card);width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--dim);font:300 22px var(--sans);line-height:1}
#stationSheet .x:hover{color:var(--ink);background:var(--bg)}
#stationSheet label{display:block;color:var(--dim);font-size:12px;margin:8px 0 4px}
#stationSheet input{width:100%;background:var(--field);border:1px solid var(--line);border-radius:10px;padding:10px 12px;outline:none}
@@ -144,7 +146,9 @@ input,textarea{font:inherit;color:inherit}
#bar::before{content:"";position:absolute;left:0;right:0;top:-14px;bottom:-14px} /* a fat finger target around a thin line */
#bar i{display:block;height:100%;width:0;background:var(--acc);border-radius:2px;pointer-events:none}
#bar:hover,#bar.drag{height:4px;margin:-1px 0}
#bar em{position:absolute;top:-16px;transform:translateX(-50%);font-size:11px;font-style:normal;line-height:1;pointer-events:none;opacity:.9}
#bar em{position:absolute;top:-20px;transform:translateX(-50%);font-size:11px;font-style:normal;line-height:1;cursor:pointer;opacity:.95;white-space:nowrap;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:2px 5px}
#bar em.below{top:auto;bottom:-20px}
#bar em:hover{border-color:var(--ink)}
#bar b{position:absolute;top:-28px;transform:translateX(-50%);font:11px var(--font);letter-spacing:.1em;color:var(--dim);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:2px 6px;pointer-events:none;display:none;white-space:nowrap}
#bar.drag b{display:block}
#controls{display:flex;align-items:center;gap:18px}
@@ -174,7 +178,7 @@ input,textarea{font:inherit;color:inherit}
#controls #remix:hover{opacity:1}
#controls #shuffle.on{opacity:1;color:var(--acc);background:rgba(233,216,166,.12)}
#controls #loop svg{width:18px;height:18px}
#controls #loop.on{opacity:1;color:var(--acc);background:rgba(233,216,166,.12)}
#controls #loop.on{opacity:1;color:var(--onacc);background:var(--acc);border-radius:999px}
#next{font:11px var(--font);letter-spacing:.14em;color:var(--dim);min-height:1.4em;text-align:center}
@@ -184,7 +188,7 @@ input,textarea{font:inherit;color:inherit}
#emojis button:hover{background:var(--card);border-color:var(--line)}
#emojis button.hit{transform:scale(1.25);border-color:var(--acc)}
#emojis #more{font-size:20px;color:var(--dim);background:var(--card);border:1px solid var(--line)}
#picker{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(560px,100%);max-height:55dvh;background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:12px 12px calc(12px + env(safe-area-inset-bottom));overflow:auto;z-index:5;box-shadow:0 -20px 60px rgba(0,0,0,.6)}
#picker{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(560px,100%);max-height:55dvh;background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:12px 12px calc(12px + env(safe-area-inset-bottom));overflow:auto;z-index:6;box-shadow:0 -20px 60px rgba(0,0,0,.6)}
#picker h4{font:600 10px var(--font);letter-spacing:.2em;color:var(--dim);margin:10px 4px 6px}
#picker .g{display:grid;grid-template-columns:repeat(auto-fill,minmax(42px,1fr));gap:2px}
#picker .g button{font-size:24px;height:42px;border-radius:10px}
@@ -391,6 +395,7 @@ input,textarea{font:inherit;color:inherit}
<div class="grid" id="libGrid"></div>
<div class="empty hidden" id="libEmpty">Nothing played yet. Every track lands here; KEEP one to pin it for good.</div>
<div class="cost" id="libCost"></div>
<div class="cost"><a href="stats.html" title="your numbers" style="text-decoration:none;font-size:16px;opacity:.6;margin-right:10px">📈</a><a id="llama" href="webamp.html" title="it really whips" style="text-decoration:none;font-size:18px;opacity:.6">🦙</a></div>
<div id="libNow" class="hidden"><canvas width="120" height="120"></canvas><div class="t"><b></b><small></small></div><button class="go" id="libPlay" title="play / pause"><svg viewBox="0 0 24 24" style="width:20px;height:20px;fill:currentColor"><path d="M8 5v14l11-7z"/></svg></button><button class="open" id="libOpen" title="back to the player">PLAYER ▸</button></div>
</section>
@@ -635,38 +640,74 @@ function drawRings(canvas, spec, { lit = 1, pulse = null } = {}) {
/* vinyl crackle under the gaps: a few seconds of soft noise and pops, made once, looped quietly in a second <audio>
while a track cooks or between tracks. Not Web Audio, so it behaves on iOS like the music does. */
const crackle = { el: null, on: false };
function makeCrackle() {
const sr = 22050, n = sr * 4, mix = new Float32Array(n), out = new Int16Array(n), rnd = prng(0x5eed);
function makeCrackle(seconds = 12, gain = 1) {
const sr = 22050, n = sr * seconds, mix = new Float32Array(n), out = new Int16Array(n), rnd = prng(0x5eed);
let lp = 0;
for (let i = 0; i < n; i++) {
lp += ((rnd() * 2 - 1) - lp) * 0.08; // dull hiss, around -16 dBFS
mix[i] += lp * 1.6;
if (rnd() < 0.0015) { const len = 20 + Math.floor(rnd() * 80), amp = 0.3 + rnd() * 0.5; for (let k = 0; k < len && i + k < n; k++) mix[i + k] += amp * (1 - k / len) * (rnd() * 2 - 1); } // a pop
}
for (let i = 0; i < n; i++) out[i] = Math.max(-1, Math.min(1, mix[i])) * 32767;
for (let i = 0; i < n; i++) out[i] = Math.max(-1, Math.min(1, mix[i] * gain)) * 32767;
const buf = new ArrayBuffer(44 + n * 2), dv = new DataView(buf);
const str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
str(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 2, true);
new Int16Array(buf, 44).set(out);
const el = new Audio(URL.createObjectURL(new Blob([buf], { type: "audio/wav" }))); el.loop = true; el.volume = crackle.base = parseFloat(localStorage.getItem("gandora.bed") || "0.35"); el.preload = "auto";
const el = new Audio(URL.createObjectURL(new Blob([buf], { type: "audio/wav" }))); el.loop = true; el.volume = crackle.base = Math.min(parseFloat(localStorage.getItem("gandora.bed") || "0.18"), localStorage.getItem("gandora.bed2") ? 1 : 0.18); localStorage.setItem("gandora.bed2", "1"); el.preload = "auto";
el.mix = mix; el.sr = sr; // kept for the Web Audio bed
return el;
}
/* the bed through Web Audio: a looping buffer has no seam (an <audio> loop pauses at the join, badly on Safari).
This is not the music's path, so the iOS rules about the player don't apply; if the context can't start, the
element plays instead. */
const bedAC = { ctx: null, gain: null, src: null, buf: null };
function bedStart(base) {
if (state.phase === "playing" && (state.playing || state.playPending || !state.audio.paused)) return false; // never over the music
const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return false;
try {
bedAC.ctx ||= new AC();
if (bedAC.ctx.state !== "running") bedAC.ctx.resume().catch(() => {});
if (bedAC.ctx.state !== "running") return false;
if (!bedAC.buf) { const el = crackle.el; bedAC.buf = bedAC.ctx.createBuffer(1, el.mix.length, el.sr); bedAC.buf.getChannelData(0).set(el.mix.map((v) => Math.max(-1, Math.min(1, v)))); }
bedStop(0);
const src = bedAC.ctx.createBufferSource(); src.buffer = bedAC.buf; src.loop = true;
const gain = bedAC.ctx.createGain(); const t = bedAC.ctx.currentTime;
gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(base, t + 0.6);
src.connect(gain).connect(bedAC.ctx.destination); src.start();
bedAC.src = src; bedAC.gain = gain;
try { crackle.el.pause(); } catch {}
return true;
} catch { return false; }
}
function bedStop(fade = 0.4) {
const { ctx, gain, src } = bedAC; if (!src) return;
bedAC.src = null; bedAC.gain = null;
try { const t = ctx.currentTime; gain.gain.cancelScheduledValues(t); gain.gain.setValueAtTime(gain.gain.value, t); gain.gain.linearRampToValueAtTime(0, t + fade); src.stop(t + fade + 0.05); } catch {}
}
function bedLevel(v) { if (bedAC.gain && bedAC.ctx) { const t = bedAC.ctx.currentTime; bedAC.gain.gain.cancelScheduledValues(t); bedAC.gain.gain.setValueAtTime(v, t); } }
/* the bed: crackle whenever no music is playing. Decided from state, called whenever state changes. */
function bed() {
clearTimeout(crackle.timer);
crackle.timer = setTimeout(() => { const want = !state.playing && !state.holding && state.audio.paused; if (want) crackleOn(); else crackleOff(); }, 300);
crackle.timer = setTimeout(() => {
// on unless music is actually running or the player itself is holding with the crackle; the unlock clip is neither
const music = state.phase === "playing" && (state.playing || !state.audio.paused || state.playPending) && state.audio.src !== SILENCE;
const want = !music && state.phase !== "holding";
if (want) crackleOn(); else crackleOff();
}, 300);
}
const bedOff = () => localStorage.getItem("gandora.bedoff") === "1";
function crackleOn() {
if (crackle.on || state.holding) return;
if (crackle.on || state.holding || bedOff()) return;
crackle.el ||= makeCrackle(); crackle.on = true;
const base = crackle.base ?? crackle.el.volume, t0 = performance.now();
crackle.el.volume = 0; crackle.el.currentTime = 0;
if (bedStart(base)) { crackle.via = "ac"; return; }
crackle.via = "el"; crackle.el.volume = 0; crackle.el.currentTime = 0;
crackle.el.play().catch(() => { crackle.on = false; clearInterval(crackle.ramp); }); // refused without a gesture: the first tap calls again
clearInterval(crackle.ramp);
crackle.ramp = setInterval(() => { const k = Math.min(1, (performance.now() - t0) / 600); if (!crackle.on) { clearInterval(crackle.ramp); return; } crackle.el.volume = base * k; if (k >= 1) clearInterval(crackle.ramp); }, 40);
}
function crackleOff() { if (!crackle.on) return; crackle.on = false; clearInterval(crackle.ramp); const el = crackle.el, t0 = crackle.base ?? el.volume; let v = el.volume; const fade = setInterval(() => { v -= 0.04; if (v <= 0 || crackle.on) { clearInterval(fade); if (!crackle.on) el.pause(); el.volume = t0; } else el.volume = v; }, 40); }
function crackleOff() { if (!crackle.on) return; crackle.on = false; clearInterval(crackle.ramp); if (crackle.via === "ac") { bedStop(); return; } const el = crackle.el, t0 = crackle.base ?? el.volume; let v = el.volume; const fade = setInterval(() => { v -= 0.04; if (v <= 0 || crackle.on) { clearInterval(fade); if (!crackle.on) el.pause(); el.volume = t0; } else el.volume = v; }, 40); }
/* 50 ms of silence as a wav, built here rather than pasted */
function wavBlob(samples, sr) {
const n = samples.length, buf = new ArrayBuffer(44 + n * 2), dv = new DataView(buf);
@@ -774,7 +815,7 @@ function remixText() {
}
function djPrompt() {
const prev = state.history.slice(-4).map((t, i) => `- ${t.title} [${t.tags}] ${t.bpm}bpm ${t.key} ${t.meter || "4/4"} energy ${t.energy} ${t.seconds || 120}s${t.reaction ? " ← listener: " + t.reaction : ""}${t.hits && t.hits.length ? " ← when: " + t.hits.map((h) => `${h.e} at ${mmss(h.t)}${h.s ? " (" + h.s + ")" : ""}`).join(", ") : ""}`).join("\n");
return `You are the DJ of a one-listener radio station${state.djName ? ` (your name: ${state.djName})` : ""}. The listener typed a seed and the station grows from it.${remixText()}
return `You are the DJ of a one-listener radio station${state.djName ? ` (your name: ${state.djName})` : ""}.${state.djPersona ? ` Your persona: ${state.djPersona}. It colours the on-screen line and the lyrics' voice, not the music's genre.` : ""} The listener typed a seed and the station grows from it.${remixText()}${MODE_BRIEF[state.mode] ? "\n" + MODE_BRIEF[state.mode] : ""}
Seed: "${state.seed}"
${state.station ? `Station name: ${state.station}\n` : ""}Listener asked for more of: ${state.taste.like.join("; ") || "(nothing yet)"}
Listener asked to avoid: ${state.taste.avoid.join("; ") || "(nothing yet)"}
@@ -783,6 +824,7 @@ Recent tracks:
${prev || "- (none yet: this is the first)"}
Write the NEXT track. Keep continuity with the recent tracks unless the listener pushed away; vary something each time so the station moves.
Hard rule: nothing from "Listener asked to avoid" appears in the tags, the instruments, the lyrics or the mood — not even a hint, whatever any taste note above suggests. This is a different station from the listener's others: its seed is the law here, not what they liked elsewhere.
Return JSON:
- title: a short evocative song title (never reuse one)
- tags: the music prompt for a song model, 60-300 characters, comma-separated descriptors: genre, instruments, vocal style or "instrumental", mood, era, production notes. Concrete and musical, no sentences.
@@ -814,6 +856,7 @@ async function askDJ() {
spec.meter = /^(2|3|4|5|6|7)\/(4|8)$/.test(String(spec.meter || "")) ? spec.meter : "4/4";
spec.energy = Math.min(1, Math.max(0, +spec.energy || 0.5));
spec.seed = state.seed; spec.made = Date.now(); spec.look = state.look || "rings"; spec.palette = state.palette || "duo";
if (state.mode === "asmr") { spec.spoken = true; spec.seconds = Math.min(180, spec.seconds); }
if (!state.station && spec.station) { state.station = spec.station; $("#stationName").textContent = state.station; }
return spec;
}
@@ -822,16 +865,101 @@ async function askDJ() {
sometimes stalls a job for minutes, so after HEDGE_AT a second copy is queued and whichever lands first is taken. */
const HEDGE_AT = 60000;
/* the DJ's voice: Venice TTS for the spoken breaks. Each station's DJ keeps one voice, picked from the DJ name. */
const VOICES = ["af_sky", "af_bella", "af_nova", "af_river", "af_sarah", "am_adam", "am_echo", "am_michael", "am_puck", "bf_emma", "bm_george"];
/* station modes. music is the default; freestyle is rap off the dome over a beat (Harry Mack inspo); asmr is spoken
only, no music; noise is made on the device, no DJ and no cost. */
const MODES = ["music", "freestyle", "asmr", "noise", "tones"];
const MODE_BRIEF = {
freestyle: "FREESTYLE MODE: this station is an MC freestyling off the dome, Harry Mack style. tags: a beat prompt (boom bap, trap, lo-fi or jazzy hip-hop; instrumental backing; vocal style 'rap, freestyle MC, conversational flow'). lyrics: one continuous freestyle in [verse] blocks, no chorus, 24-40 bars, internal rhymes, flipping the seed and weaving in the listener's words and recent reactions as they come, talking straight to the listener by the station name; it should sound improvised and in the room, not written. bpm 80-100.",
asmr: "ASMR MODE: no music at all. The whole piece is SPOKEN, whispery, slow, close to the mic. lyrics: an ASMR script of 10-20 short lines, gentle personal attention for the listener, triggers described in soft words (tapping, brushing, page turns, rain on the window), pauses written as '...', no rhyming, no section markers, no stage directions. tags: the scene, for the record. seconds 60-120.",
};
const modeFromSeed = (seed) => { const m = (String(seed).match(/^(asmr|noise|freestyle|tones?|binaural|solfeggio|isochronic|\d{2,4}\s*hz)\b/i) || [])[1]; return !m ? "music" : /^(tone|binaural|solf|iso|\d)/i.test(m) ? "tones" : m.toLowerCase(); };
/* tones: binaural beats need stereo (one ear each), solfeggio is a pure tone, isochronic pulses the tone on and off.
Name one in the seed ("binaural theta", "528 hz", "isochronic alpha") or they cycle. All made here, free. */
const BANDS = { delta: 2.5, theta: 6, alpha: 10, beta: 18, gamma: 40 };
const SOLFEGGIO = [174, 285, 396, 417, 528, 639, 741, 852, 963];
const TONES = [["binaural", "alpha"], ["solfeggio", 528], ["binaural", "theta"], ["isochronic", "beta"], ["solfeggio", 396], ["binaural", "delta"], ["solfeggio", 963], ["isochronic", "gamma"]];
function toneSpec() {
const s = String(state.seed).toLowerCase(), n = state.history.filter((t) => t.tone).length;
const hz = (s.match(/(\d{2,4})\s*hz/) || [])[1], band = (s.match(/\b(delta|theta|alpha|beta|gamma)\b/) || [])[1];
let [kind, what] = TONES[n % TONES.length];
if (/isochronic/.test(s)) kind = "isochronic"; else if (/binaural/.test(s)) kind = "binaural"; else if (/solfeggio/.test(s) || hz) kind = "solfeggio";
if (kind === "solfeggio") what = hz ? +hz : typeof what === "number" ? what : 528; else if (band) what = band; else if (typeof what === "number") what = "alpha";
const carrier = hz && kind !== "solfeggio" ? +hz : 200, beat = kind === "solfeggio" ? 0 : BANDS[what];
const title = kind === "solfeggio" ? `${what} Hz` : `${what} ${kind} · ${beat} Hz`;
return { title, tags: `${kind} ${kind === "solfeggio" ? what + " Hz, pure tone" : what + " " + beat + " Hz on a " + carrier + " Hz carrier"}, made on this device`, lyrics: "", bpm: 60, key: "—", energy: 0.1, seconds: 300, meter: "4/4",
tone: { kind, hz: kind === "solfeggio" ? what : carrier, beat }, station: state.station || "the tone room", line: kind === "binaural" ? "headphones: one ear each" : "breathe with it", seed: state.seed, made: Date.now(), look: state.look || "rings", palette: state.palette || "duo" };
}
async function makeTone({ kind, hz, beat }, seconds) {
const sr = 16000, n = sr * seconds, L = new Int16Array(n), R = new Int16Array(n), amp = 0.28 * 32767;
for (let i = 0; i < n; i++) {
if (i % (sr * 5) === 0 && i) await new Promise((r) => setTimeout(r, 0));
const t = i / sr, fade = Math.min(1, t / 3, (seconds - t) / 3);
let l, r;
if (kind === "binaural") { l = Math.sin(2 * Math.PI * hz * t); r = Math.sin(2 * Math.PI * (hz + beat) * t); }
else if (kind === "isochronic") { const g = 0.5 - 0.5 * Math.cos(2 * Math.PI * beat * t); l = r = Math.sin(2 * Math.PI * hz * t) * (g > 0.5 ? 1 : g * 2) ; }
else { l = r = Math.sin(2 * Math.PI * hz * t) * 0.8 + Math.sin(2 * Math.PI * hz * 2 * t) * 0.08; }
L[i] = l * amp * fade; R[i] = r * amp * fade;
}
return wavBlob2(L, R, sr);
}
function wavBlob2(L, R, sr) {
const n = L.length, buf = new ArrayBuffer(44 + n * 4), dv = new DataView(buf), str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
str(0, "RIFF"); dv.setUint32(4, 36 + n * 4, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 2, true);
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 4, true); dv.setUint16(32, 4, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 4, true);
const out = new Int16Array(buf, 44); for (let i = 0; i < n; i++) { out[2 * i] = L[i]; out[2 * i + 1] = R[i]; }
return new Blob([buf], { type: "audio/wav" });
}
const NOISES = ["brown", "pink", "rain", "white", "crackle"];
function noiseSpec() {
const n = state.history.filter((t) => t.noise).length, kind = (String(state.seed).match(/\b(brown|pink|rain|white|crackle)\b/i) || [])[1]?.toLowerCase() || NOISES[n % NOISES.length];
return { title: `${kind} noise`, tags: `${kind} noise, room tone, made on this device`, lyrics: "", bpm: 60, key: "—", energy: 0.1, seconds: 300, meter: "4/4", noise: kind, station: state.station || "the noise floor", line: "nothing to learn here, just the room", seed: state.seed, made: Date.now(), look: state.look || "rings", palette: state.palette || "duo" };
}
async function makeNoise(kind, seconds) {
const sr = 16000, n = sr * seconds, out = new Int16Array(n), rnd = prng(hash32(kind + seconds) || 1);
let b = 0, b0 = 0, b1 = 0, b2 = 0, lp = 0, drop = 0, dropLen = 0, dropAmp = 0;
for (let i = 0; i < n; i++) {
if (i % (sr * 5) === 0 && i) await new Promise((r) => setTimeout(r, 0)); // a slice at a time: the page keeps breathing
const w = rnd() * 2 - 1; let v;
if (kind === "white") v = w * 0.25;
else if (kind === "brown") { b = (b + 0.02 * w) / 1.02; v = b * 2.2; }
else if (kind === "pink") { b0 = 0.99765 * b0 + w * 0.0990460; b1 = 0.96300 * b1 + w * 0.2965164; b2 = 0.57000 * b2 + w * 1.0526913; v = (b0 + b1 + b2 + w * 0.1848) * 0.12; }
else if (kind === "rain") { lp += (w - lp) * 0.3; v = lp * 0.5; if (drop <= 0 && rnd() < 0.004) { drop = dropLen = 30 + Math.floor(rnd() * 120); dropAmp = 0.15 + rnd() * 0.35; } if (drop > 0) { v += dropAmp * (drop / dropLen) * w; drop--; } }
else { lp += (w - lp) * 0.08; v = lp * 1.0; if (rnd() < 0.0015) { drop = dropLen = 20 + Math.floor(rnd() * 80); dropAmp = 0.3 + rnd() * 0.5; } if (drop > 0) { v += dropAmp * (drop / dropLen) * w; drop--; } }
const fade = Math.min(1, i / (sr * 2), (n - i) / (sr * 2));
out[i] = Math.max(-1, Math.min(1, v * fade)) * 32767;
}
return wavBlob(out, sr);
}
/* voices: "model/voice". Kokoro (plain, cheap, no style), Qwen3 (takes a style prompt: whisper, ASMR, excited),
Orpheus (expressive), ElevenLabs turbo (polished). A bare name is Kokoro. */
const VOICES = ["af_sky", "af_bella", "af_nova", "af_river", "af_sarah", "am_adam", "am_echo", "am_michael", "am_puck", "bf_emma", "bm_george",
"tts-qwen3-1-7b/Serena", "tts-qwen3-1-7b/Vivian", "tts-qwen3-1-7b/Dylan", "tts-orpheus/tara", "tts-orpheus/leo", "tts-orpheus/mia", "tts-elevenlabs-turbo-v2-5/Rachel", "tts-elevenlabs-turbo-v2-5/Aria"];
const voiceParts = (v) => { const i = String(v).indexOf("/"); return i < 0 ? { model: "tts-kokoro", voice: v } : { model: v.slice(0, i), voice: v.slice(i + 1) }; };
/* DJ personas: a name, a voice, a speaking speed and a one-line brief. The brief goes into the track prompt and the
break scripts; the voice and speed go to the TTS. Presets to start from; everything is editable per station. */
const PERSONAS = [
{ id: "asmr", name: "Whisper", voice: "tts-qwen3-1-7b/Serena", speed: 0.85, persona: "an ASMR host: whispery, close to the mic, slow, soothing, tiny personal asides, long pauses, never loud" },
{ id: "latenight", name: "Night Owl", voice: "am_echo", speed: 0.95, persona: "late-night pirate radio: dry, deadpan, unhurried, a little mysterious, speaks in short sentences" },
{ id: "top40", name: "Big Mike", voice: "am_michael", speed: 1.12, persona: "a 90s Top-40 shouter: high energy, catchphrases, station idents, hypes every track like a world premiere" },
{ id: "bbc", name: "Margaret", voice: "bf_emma", speed: 1, persona: "a BBC-style announcer: crisp, warm, precise, polite, gently witty, never slangy" },
{ id: "cosmic", name: "Stardust", voice: "af_nova", speed: 0.9, persona: "a cosmic hippie: dreamy, speaks in images about space and light, calls the listener traveller" },
{ id: "coach", name: "Coach", voice: "am_adam", speed: 1.08, persona: "a motivational coach: punchy, encouraging, counts you in, turns every track into a rep" },
];
const voiceLabel = (v) => { const { model, voice } = voiceParts(v); if (model === "tts-kokoro") return ({ af: "♀ US ", am: "♂ US ", bf: "♀ UK ", bm: "♂ UK " }[voice.slice(0, 2)] || "") + voice.slice(3); return ({ "tts-qwen3-1-7b": "q ", "tts-orpheus": "o ", "tts-elevenlabs-turbo-v2-5": "11 " }[model] || "") + voice; };
async function speak(spec) {
const text = String(spec.lyrics || "").replace(/\[[^\]]*\]/g, " ").replace(/\s+/g, " ").trim().slice(0, 1800);
const voice = state.djVoice || VOICES[hash32(state.djName || state.seed || "dj") % VOICES.length];
const res = await venice(state.key ? "/audio/speech" : "/speech", { model: "tts-kokoro", input: text, voice, response_format: "mp3", speed: 1 }, { raw: true });
spec.cost = 0.001; spec.renderer = "venice:tts-kokoro"; spec.voice = voice;
const picked = state.djVoice || VOICES[hash32(state.djName || state.seed || "dj") % 11], { model, voice } = voiceParts(picked);
const speed = state.mode === "asmr" && (!state.djSpeed || state.djSpeed === 1) ? 0.8 : +state.djSpeed || 1;
const body = { model, input: text, voice, response_format: "mp3", speed: Math.min(1.5, Math.max(0.6, speed)) };
if (model === "tts-qwen3-1-7b") body.prompt = (state.mode === "asmr" ? "Whispering ASMR voice, very soft, slow, breathy, close to the microphone. " : "") + (state.djPersona || "").slice(0, 300);
const res = await venice(state.key ? "/audio/speech" : "/speech", body, { raw: true });
spec.cost = model === "tts-kokoro" ? 0.001 : 0.004; spec.renderer = "venice:" + model; spec.voice = picked;
state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost));
return res.blob();
}
async function renderTrack(spec, onStatus) {
if (spec.noise) return makeNoise(spec.noise, spec.seconds || 300);
if (spec.tone) return makeTone(spec.tone, spec.seconds || 300);
if (spec.spoken) return speak(spec);
if (!state.key) { // shared worker: one call, audio back (Runware when the factory has a key, else Venice)
const started = Date.now(), tick = setInterval(() => onStatus && onStatus(Math.round((Date.now() - started) / 1000), 0, false), 1000);
@@ -845,7 +973,7 @@ async function renderTrack(spec, onStatus) {
if (res) {
const blob = await res.blob();
const cost = parseFloat(res.headers.get("x-cost") || ""), seed = parseInt(res.headers.get("x-seed") || "");
spec.renderer = res.headers.get("x-renderer") || "venice:ace-step-15";
spec.renderer = res.headers.get("x-renderer") || "venice:ace-step-15"; if (res.headers.get("x-fallback")) spec.fallback = res.headers.get("x-fallback");
if (!isNaN(seed)) spec.rseed = seed;
spec.cost = isNaN(cost) ? 0.0005 * (spec.seconds || 120) : cost;
delete spec.refAudio; // the reference did its job; don't keep megabytes on the sheet
@@ -902,7 +1030,7 @@ const db = {
async function saveStation() {
if (!state.stationId) return;
const specs = state.history.slice(-8).map(({ id, title, tags, bpm, key, energy, reaction, made, seconds, meter, hits }) => ({ id, title, tags, bpm, key, energy, reaction, made, seconds, meter, hits }));
await db.put({ id: state.stationId, seed: state.seed, name: state.station, taste: state.taste, history: specs, made: +state.stationId.split("-")[1] || Date.now(), last: Date.now(), played: (state.played || 0), look: state.look || "rings", palette: state.palette || "duo", djName: state.djName || "", djVoice: state.djVoice || "", welcomed: !!state.welcomed }, "stations");
await db.put({ id: state.stationId, seed: state.seed, name: state.station, taste: state.taste, history: specs, made: +state.stationId.split("-")[1] || Date.now(), last: Date.now(), played: (state.played || 0), look: state.look || "rings", palette: state.palette || "duo", djName: state.djName || "", djVoice: state.djVoice || "", djSpeed: state.djSpeed || 1, djPersona: state.djPersona || "", mode: state.mode || "music", welcomed: !!state.welcomed }, "stations");
}
async function listStations() {
const list = await db.all("stations"); const box = $("#stations"), row = $("#stationRow"); row.innerHTML = "";
@@ -922,7 +1050,7 @@ async function listStations() {
/* the station sheet: rename, reset its taste, delete it (kept tracks stay) */
async function stationSheet(id) {
const s = await db.get(id, "stations"); if (!s) return;
const p = $("#stationSheet"); p.innerHTML = "";
let p = $("#stationSheet"); p.innerHTML = ""; p.scrollTop = 0;
p.append(Object.assign(document.createElement("h4"), { textContent: "STATION" }));
const x = Object.assign(document.createElement("button"), { className: "x", textContent: "×", title: "close" }); x.setAttribute("aria-label", "close");
x.addEventListener("click", () => p.classList.add("hidden")); p.append(x);
@@ -956,13 +1084,75 @@ async function stationSheet(id) {
palRow.append(b);
}
p.append(palRow);
const modeRow = document.createElement("div"); modeRow.className = "row"; modeRow.style.marginTop = "8px";
p.append(Object.assign(document.createElement("label"), { textContent: "mode" }));
for (const md of MODES) {
const b = Object.assign(document.createElement("button"), { textContent: md.toUpperCase(), className: (s.mode || "music") === md ? "go" : "", title: { music: "songs", freestyle: "an MC off the dome over a beat", asmr: "spoken only, no music", noise: "brown, pink, rain, white, crackle: made here, free", tones: "binaural beats, solfeggio, isochronic: made here, free" }[md] });
b.addEventListener("click", async () => {
s.mode = md; await db.put(s, "stations");
if (state.stationId === id) { state.mode = md; if (state.nextJob && !state.nextJob.blob) state.nextJob = null; }
for (const x of modeRow.children) x.className = x.textContent === md.toUpperCase() ? "go" : "";
toast(`Mode: ${md}. Takes effect from the next track.`);
});
modeRow.append(b);
}
p.append(modeRow);
// the DJ: name, voice, speed, persona, folded away unless something is set
const dj = document.createElement("details"); dj.open = false;
dj.append(Object.assign(document.createElement("summary"), { textContent: "dj" + (s.djName ? " · " + s.djName : "") + (s.djVoice ? " · " + voiceLabel(s.djVoice) : "") }));
const pAll = p, pDj = dj; p = dj;
const preRow = document.createElement("div"); preRow.className = "row"; preRow.style.marginTop = "4px";
const djName = document.createElement("input"); djName.value = s.djName || ""; djName.placeholder = "name (the DJ picks one at the welcome if blank)"; djName.maxLength = 40;
const djPersona = document.createElement("input"); djPersona.value = s.djPersona || ""; djPersona.placeholder = "persona: how they talk, e.g. whispery ASMR host"; djPersona.maxLength = 240; djPersona.style.marginTop = "6px";
const voiceRow = document.createElement("div"); voiceRow.className = "row"; voiceRow.style.marginTop = "6px";
let voice = s.djVoice || "", speed = s.djSpeed || 1;
const paintVoices = () => { for (const b of voiceRow.children) b.className = b.dataset.v === voice ? "go" : ""; };
for (const v of VOICES) {
const b = Object.assign(document.createElement("button"), { textContent: voiceLabel(v).toUpperCase(), title: "tap to hear " + v }); b.dataset.v = v;
b.addEventListener("click", async () => {
voice = v; paintVoices();
if (crackle.preview) { try { crackle.preview.pause(); } catch {} crackle.preview = null; }
const mine = crackle.previewSeq = (crackle.previewSeq || 0) + 1;
try { const was = { v: state.djVoice, sp: state.djSpeed }; state.djVoice = v; state.djSpeed = speed;
const blob = await speak({ lyrics: `Hi, this is ${djName.value.trim() || "your DJ"}, and you're listening to ${s.name || s.seed}.` });
state.djVoice = was.v; state.djSpeed = was.sp;
if (mine !== crackle.previewSeq) return; // a later tap won
if (crackle.preview) { try { crackle.preview.pause(); } catch {} }
const a = crackle.preview = new Audio(URL.createObjectURL(blob)); a.volume = state.audio.volume; a.play().catch(() => {}); } catch { toast("Couldn't fetch the voice."); }
});
voiceRow.append(b);
}
paintVoices();
for (const pr of PERSONAS) {
const b = Object.assign(document.createElement("button"), { textContent: pr.id.toUpperCase(), title: pr.persona });
b.addEventListener("click", () => { djName.value = pr.name; djPersona.value = pr.persona; voice = pr.voice; speed = pr.speed; spd.value = Math.round(speed * 100); spdOut.textContent = spd.value + "%"; paintVoices(); });
preRow.append(b);
}
const spdRow = document.createElement("div"); spdRow.className = "sl";
const spd = document.createElement("input"); spd.type = "range"; spd.min = 60; spd.max = 150; spd.value = Math.round(speed * 100);
const spdOut = Object.assign(document.createElement("span"), { textContent: spd.value + "%" });
spd.addEventListener("input", () => { speed = spd.value / 100; spdOut.textContent = spd.value + "%"; });
spdRow.append(Object.assign(document.createElement("label"), { textContent: "speed" }), spd, spdOut);
p.append(preRow, djName, djPersona, voiceRow, spdRow);
p = pAll; p.append(pDj);
const applyDj = async () => {
s.djName = djName.value.trim().slice(0, 40); s.djPersona = djPersona.value.trim().slice(0, 240); s.djVoice = voice; s.djSpeed = speed;
if (state.stationId === id) { state.djName = s.djName; state.djPersona = s.djPersona; state.djVoice = s.djVoice; state.djSpeed = s.djSpeed; }
};
// volume and room tone, for everything (not just this station)
for (const [label, key, get, set] of [["volume", "gandora.vol", () => state.audio.volume, (v) => { state.audio.volume = v; }], ["room tone", "gandora.bed", () => (crackle.el ||= makeCrackle()).volume, (v) => { (crackle.el ||= makeCrackle()).volume = v; crackle.base = v; }]]) {
for (const [label, key, get, set] of [["volume", "gandora.vol", () => state.audio.volume, (v) => { state.audio.volume = v; }], ["room tone", "gandora.bed", () => (crackle.el ||= makeCrackle()).volume, (v) => { (crackle.el ||= makeCrackle()).volume = v; crackle.base = v; bedLevel(v); }]]) {
const sl = document.createElement("div"); sl.className = "sl";
const r = document.createElement("input"); r.type = "range"; r.min = 0; r.max = 100; r.value = Math.round(get() * 100);
const out = Object.assign(document.createElement("span"), { textContent: r.value + "%" });
r.addEventListener("input", () => { set(r.value / 100); out.textContent = r.value + "%"; localStorage.setItem(key, String(r.value / 100)); });
sl.append(Object.assign(document.createElement("label"), { textContent: label }), r, out); p.append(sl);
sl.append(Object.assign(document.createElement("label"), { textContent: label }), r, out);
if (key === "gandora.bed") {
const off = Object.assign(document.createElement("button"), { textContent: bedOff() ? "OFF" : "ON", title: "the crackle between tracks" });
off.style.cssText = "padding:6px 10px;border-radius:999px;border:1px solid var(--line);font:600 10px var(--font);letter-spacing:.12em";
off.addEventListener("click", () => { const now = !bedOff(); localStorage.setItem("gandora.bedoff", now ? "1" : "0"); off.textContent = now ? "OFF" : "ON"; if (now) crackleOff(); else bed(); toast(now ? "Room tone off." : "Room tone on."); });
sl.append(off);
}
p.append(sl);
}
if (state.stationId === id) {
const more = document.createElement("div"); more.className = "row"; more.style.marginTop = "10px"; more.style.justifyContent = "flex-start";
@@ -978,7 +1168,7 @@ async function stationSheet(id) {
}
const row = document.createElement("div"); row.className = "row";
const save = Object.assign(document.createElement("button"), { className: "go", textContent: "SAVE" });
save.addEventListener("click", async () => { s.name = name.value.trim().slice(0, 60); await db.put(s, "stations"); if (state.stationId === id) { state.station = s.name; $("#stationName").textContent = s.name || s.seed; } p.classList.add("hidden"); listStations().catch(() => {}); toast("Renamed."); });
save.addEventListener("click", async () => { s.name = name.value.trim().slice(0, 60); await applyDj(); await db.put(s, "stations"); if (state.stationId === id) { state.station = s.name; $("#stationName").textContent = s.name || s.seed; } p.classList.add("hidden"); listStations().catch(() => {}); toast("Saved."); });
const reset = Object.assign(document.createElement("button"), { textContent: "RESET TASTE" });
reset.addEventListener("click", async () => { if (!confirm("Forget what this station has learned? Its tracks stay.")) return; s.taste = { like: [], avoid: [], notes: [] }; s.history = []; await db.put(s, "stations"); if (state.stationId === id) { state.taste = s.taste; state.history = []; } p.classList.add("hidden"); toast("Taste reset."); });
const del = Object.assign(document.createElement("button"), { className: "bad", textContent: "DELETE STATION" });
@@ -1004,8 +1194,9 @@ function shareLink() {
}
function setUrl() { try { history.replaceState(null, "", state.seed ? "/#" + encodeURIComponent(state.seed).replace(/%20/g, "+") : "/"); } catch {} }
async function openStation(seed, opts = {}) { await openStationNow(seed, opts); if (hooks.stationOpened) hooks.stationOpened(seed, opts); }
async function openStationNow(seed, { id, name = "", taste, history, played, first, look, palette: pal, djName = "", djVoice = "", welcomed = false } = {}) {
state.look = look || "rings"; state.palette = pal || "duo"; state.djName = djName; state.djVoice = djVoice; state.welcomed = welcomed; state.sinceAd = 0; state.breaks = 0;
async function openStationNow(seed, { id, name = "", taste, history, played, first, look, palette: pal, djName = "", djVoice = "", djSpeed = 1, djPersona = "", mode, welcomed = false } = {}) {
state.mode = MODES.includes(mode) ? mode : modeFromSeed(seed);
state.look = look || "rings"; state.palette = pal || "duo"; state.djName = djName; state.djVoice = djVoice; state.djSpeed = djSpeed || 1; state.djPersona = djPersona; state.welcomed = welcomed; state.sinceAd = 0; state.breaks = 0;
if (state.stationId !== id) { toIdle(); state.recent = []; state.pos = -1; state.tape = null; }
state.seed = seed; state.station = name; state.stationId = id || `s-${Date.now()}-${hash32(seed)}`;
state.taste = taste || { like: [], avoid: [], notes: [] }; state.history = history || []; state.played = played || 0;
@@ -1059,16 +1250,24 @@ async function remember(job, kept) {
/* ---------- the station loop ---------- */
let seq = 0;
/* the ledger: one line per cook, play and reaction, in localStorage (the track store prunes, this doesn't; capped at
3,000 lines). stats.html reads it. Nothing leaves the device. */
function logEvent(kind, d = {}) {
try { const log = JSON.parse(localStorage.getItem("gandora.log") || "[]"); log.push({ t: Date.now(), k: kind, ...d }); while (log.length > 3000) log.shift(); localStorage.setItem("gandora.log", JSON.stringify(log)); } catch {}
}
function makeJob(spec) {
if (hooks.jobMade) hooks.jobMade(spec);
const job = { id: ++seq, spec, blob: null, err: null, done: null };
job.done = (async () => {
const t0 = Date.now();
try { job.blob = await renderTrack(spec, (el, exp, hedged) => {
const t = `COOKING ${el}s${exp ? " / ~" + exp + "s" : ""}${hedged ? " · SECOND OVEN LIT" : ""}`;
if (state.current === job) $("#cover .veil").textContent = t;
else if (state.nextJob === job && state.current) $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · " + t;
}); }
catch (e) { job.err = e; }
logEvent("cook", { ms: Date.now() - t0, ok: !job.err, err: job.err ? String(job.err.message || job.err).slice(0, 80) : undefined, cost: spec.cost || 0, r: spec.renderer || (spec.noise ? "device:noise" : spec.tone ? "device:tone" : ""), s: spec.seconds || 0,
mode: state.mode || "music", st: state.station || state.seed || "", brk: spec.break || undefined, fb: spec.fallback || undefined, title: spec.title });
})();
return job;
}
@@ -1094,9 +1293,9 @@ async function askBreak(kind) {
venice_parameters: { disable_thinking: true, strip_thinking_response: true, include_venice_system_prompt: false },
response_format: { type: "json_schema", json_schema: { name: "track", strict: true, schema: DJ_SCHEMA } },
messages: [{ role: "user", content: `You are the DJ of g*ndora (say it "gandora"), a radio station that writes its own songs for one listener.
Seed: "${state.seed}"${state.station ? `, station "${state.station}"` : ""}.${state.djName ? ` Your DJ name is ${state.djName}.` : ""} The last tracks: ${prev}.
Seed: "${state.seed}"${state.station ? `, station "${state.station}"` : ""}.${state.djName ? ` Your DJ name is ${state.djName}.` : ""}${state.djPersona ? ` Your persona, and the way you speak: ${state.djPersona}.` : ""} The last tracks: ${prev}.
Listener likes: ${state.taste.like.join("; ") || "(unknown)"}. Avoids: ${state.taste.avoid.join("; ") || "(unknown)"}. Their words: ${state.taste.notes.join(" | ") || "(none)"}.
Write a 30-second INTERMISSION in the station's own sound: ${BREAK_BRIEF[kind]}
Write a 30-second INTERMISSION in the station's own sound: ${state.djName ? BREAK_BRIEF[kind].replace(/Pick a DJ name[^)]*\) and open/, "Open") : BREAK_BRIEF[kind]}
${spoken ? "It is SPOKEN by a voice, not sung: lyrics are exactly what the DJ says, 4-7 short lines of natural speech, no rhyming, no section markers, no stage directions. tags describe the station's sound (for the record), bpm/key/energy match the station." : "4-8 lines of lyrics."}
Return JSON: title (short${kind === "welcome" ? "; for the welcome, the title is your DJ name" : ""}), tags (the music prompt), lyrics, bpm, key, energy, seconds (30), meter, station ("${state.station || state.seed}"), line (one line the DJ says on screen, under 70 characters)` }] };
const d = await venice("/chat/completions", body);
@@ -1107,7 +1306,7 @@ Return JSON: title (short${kind === "welcome" ? "; for the welcome, the title is
spec.bpm = Math.min(180, Math.max(60, parseInt(spec.bpm) || 100)); spec.energy = Math.min(1, Math.max(0, +spec.energy || 0.5));
spec.seconds = 30; spec.meter = /^(2|3|4|5|6|7)\/(4|8)$/.test(String(spec.meter || "")) ? spec.meter : "4/4";
spec.break = kind; spec.sponsored = kind === "sponsor"; spec.seed = state.seed; spec.made = Date.now();
if (kind === "welcome") { state.djName = String(spec.title || "").replace(/^(dj|this is)\s+/i, "").slice(0, 40) || "the DJ"; spec.title = state.djName; state.djVoice = VOICES[hash32(state.djName) % VOICES.length]; state.welcomed = true; saveStation().catch(() => {}); }
if (kind === "welcome") { if (!state.djName) state.djName = String(spec.title || "").replace(/^(dj|this is)\s+/i, "").slice(0, 40) || "the DJ"; spec.title = state.djName; if (!state.djVoice) state.djVoice = VOICES[hash32(state.djName) % VOICES.length]; state.welcomed = true; saveStation().catch(() => {}); }
spec.line = spec.line || { ident: "station ident", recap: "that was…", shoutout: "this one's for you", tease: "coming up", sponsor: "a word from our sponsor: us" }[kind];
return spec;
}
@@ -1118,9 +1317,10 @@ function cookNext() {
try {
const real = state.history.filter((t) => !t.break).length;
const welcome = !state.welcomed && real >= 3 && !state.history.some((t) => t.break === "welcome"); // the DJ pops on once the brain does
const due = welcome || (BREAK_EVERY > 0 && state.sinceAd >= BREAK_EVERY - 1 && real >= 3); // three real tracks first: that's when the brain wakes up too
const local = state.mode === "noise" || state.mode === "tones";
const due = !local && (welcome || (BREAK_EVERY > 0 && state.sinceAd >= BREAK_EVERY - 1 && real >= 3)); // three real tracks first: that's when the brain wakes up too
const kind = welcome ? "welcome" : BREAKS[(state.breaks || 0) % BREAKS.length];
const spec = due ? await askBreak(kind).catch(() => askDJ()) : await askDJ();
const spec = state.mode === "noise" ? noiseSpec() : state.mode === "tones" ? toneSpec() : due ? await askBreak(kind).catch(() => askDJ()) : await askDJ();
if (spec.break) { state.sinceAd = 0; state.breaks = (state.breaks || 0) + 1; } else state.sinceAd = (state.sinceAd || 0) + 1;
state.nextJob = makeJob(spec); if (state.current) $("#next").textContent = (spec.break ? `NEXT · ${spec.break.toUpperCase()} · ` : "NEXT · ") + spec.title.toUpperCase(); return state.nextJob; }
catch (e) { toast(e.message, 5000); return null; }
@@ -1147,8 +1347,9 @@ const srcOf = (job) => job.url || (job.url = URL.createObjectURL(job.blob));
/* every source change goes through here; it drops the old object URL, never the crackle's or the unlock clip's */
function setSource(src, loop) {
const old = state.audio.src;
if (old && old !== SILENCE && !(crackle.el && old === crackle.el.src)) URL.revokeObjectURL(old);
if (old && old !== SILENCE && !(crackle.el && old === crackle.el.src) && !(crackle.hold && old === crackle.hold.src)) URL.revokeObjectURL(old);
state.audio.src = src; state.audio.loop = loop;
if (state.holdVol != null && !(crackle.hold && src === crackle.hold.src)) { state.audio.volume = state.holdVol; state.holdVol = null; } // the hold ran at room-tone level
}
/* → cooking: the sheet goes on screen and the player waits for the render (resolved already for a library track).
A hold in progress stays a hold: the track will queue behind the crackle. A track started inside `ended`
@@ -1160,7 +1361,7 @@ function toCooking(job) {
if (!job.stepping) { state.history.push(spec); state.played = (state.played || 0) + 1; spec.stationId ||= state.stationId; }
keepLabel();
for (const t of $("#bar").querySelectorAll("em")) t.remove();
if (spec.hits) { const dur = spec.seconds || 120; for (const h of spec.hits) { const tick = document.createElement("em"); tick.textContent = h.e; tick.style.left = Math.min(100, h.t / dur * 100) + "%"; tick.title = `${h.e} at ${mmss(h.t)}`; $("#bar").append(tick); } }
drawTicks(spec, spec.seconds || 120);
$("#title").textContent = spec.break ? "✱ " + spec.title : spec.title; $("#tags").textContent = `${spec.bpm} bpm · ${spec.key}${spec.break ? " · " + (spec.sponsored ? "sponsored" : "intermission") : spec.remixOf ? " · remix" : ""}`;
$("#lyric").textContent = spec.line || "";
showTags(spec);
@@ -1172,7 +1373,12 @@ function toCooking(job) {
}
/* → holding, inside `ended`: nothing is cooked, so the player loops the crackle in its own element to stay alive */
function toHolding() {
try { crackle.el ||= makeCrackle(); setSource(crackle.el.src, true); state.phase = "holding"; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); } catch {}
try {
// the level is baked into the sample: iOS ignores an element's volume. OFF holds on four seconds of nothing (its own
// URL, not SILENCE, so `ended` still fires and hands over).
const lvl = bedOff() ? 0 : Math.round((crackle.base ?? 0.18) * 100) / 100;
if (!crackle.hold || crackle.hold.lvl !== lvl) { crackle.hold = lvl ? makeCrackle(4, lvl) : Object.assign(new Audio(URL.createObjectURL(wavBlob(new Int16Array(8000 * 4), 8000))), { loop: true }); crackle.hold.lvl = lvl; }
crackleOff(); setSource(crackle.hold.src, true); state.phase = "holding"; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); } catch {}
}
/* a landed track while the player holds: it queues behind the crackle, the loop is switched off so the pass ends, and
handOver starts it inside that `ended`. Resolves once the track has been handed over (or dropped). */
@@ -1184,13 +1390,13 @@ function handOver(job) {
state.pendingJob = null;
const go = state.onHoldEnd; state.onHoldEnd = null;
if (state.current !== job) { if (go) go(); toEnded(); return; } // dropped meanwhile: treat it as a track ending
try { setSource(srcOf(job), !!state.loop); state.phase = "playing"; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); job.preplayed = true; } catch {}
try { setSource(srcOf(job), !!state.loop); state.phase = "playing"; crackleOff(); playNow(job); job.preplayed = true; logEvent("play", { s: job.spec.seconds || 0, st: state.station || state.seed || "", title: job.spec.title, brk: job.spec.break || undefined }); } catch {}
if (go) go();
}
/* → playing, inside `ended`: the next track is cooked and nothing is queued ahead of it, so its source starts right
here; playJob then finds it preplayed and only does the bookkeeping */
function startNow(job) {
try { setSource(srcOf(job), !!state.loop); state.phase = "playing"; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); job.preplayed = true; return true; }
try { setSource(srcOf(job), !!state.loop); state.phase = "playing"; crackleOff(); playNow(job); job.preplayed = true; logEvent("play", { s: job.spec.seconds || 0, st: state.station || state.seed || "", title: job.spec.title, brk: job.spec.break || undefined }); return true; }
catch { return false; }
}
/* → playing, the ordinary way: the track's audio goes into the player and play() is asked for. Refused (a resume with
@@ -1201,6 +1407,14 @@ async function toPlaying(job) {
try { if (!job.preplayed || state.audio.paused) await state.audio.play(); state.playing = true; state.primed = true; state.needTap = false; $("#cover").classList.remove("tap"); crackleOff(); setPlayIcon(); vizStart(); }
catch { state.needTap = true; $("#cover .veil").textContent = "TAP TO PLAY"; $("#cover").classList.add("tap"); }
}
/* play() inside the swap: the playing flag follows the promise, so a refusal (iOS after a lock, after another element
played) shows TAP TO PLAY instead of a playing UI with no sound; the bed stays off while the answer is pending */
function playNow(job) {
state.playPending = true;
let p; try { p = state.audio.play(); } catch (e) { p = Promise.reject(e); }
Promise.resolve(p).then(() => { state.playPending = false; if (state.current === job) { state.playing = true; state.needTap = false; $("#cover").classList.remove("tap"); setPlayIcon(); } })
.catch(() => { state.playPending = false; if (state.current === job || !state.current) { state.playing = false; state.needTap = true; $("#cover .veil").textContent = "TAP TO PLAY"; $("#cover").classList.add("tap"); setPlayIcon(); bed(); } });
}
/* → ended: the track ran out. Synchronously: start the cooked next track, or begin the hold; then advance() picks what
follows and does the bookkeeping. A tape, a queued shared tape, a remix/after slot, forward history or shuffle count
as "queued ahead": advance() decides those, so nothing is started here. */
@@ -1374,8 +1588,24 @@ const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart
function markReaction(spec, emoji, t) {
const dur = state.audio.duration || spec.seconds || 120;
spec.hits = (spec.hits || []).concat([{ e: emoji, t: Math.round(t), s: sectionAt(spec, t) }]).slice(-24);
const bar = $("#bar"); if (!bar || state.current.spec !== spec) return;
const tick = document.createElement("em"); tick.textContent = emoji; tick.style.left = Math.min(100, t / dur * 100) + "%"; tick.title = `${emoji} at ${mmss(t)}`; bar.append(tick);
if (state.current.spec === spec) drawTicks(spec, dur);
}
/* the ticks above the bar: hits within a few percent of each other become one pill reading them in order */
function drawTicks(spec, dur) {
const bar = $("#bar"); if (!bar) return;
for (const t of bar.querySelectorAll("em")) t.remove();
const hits = (spec.hits || []).slice().sort((a, b) => a.t - b.t), groups = [];
for (const h of hits) { const g = groups[groups.length - 1]; if (g && h.t - g.at <= dur * 0.04) { g.e.push(h.e); g.last = h.t; } else groups.push({ at: h.t, last: h.t, e: [h.e] }); }
let below = false, prevAt = -Infinity;
for (const g of groups) {
const tick = document.createElement("em"); const shown = g.e.slice(0, 3).join("") + (g.e.length > 3 ? "+" + (g.e.length - 3) : "");
tick.textContent = shown; tick.title = `${g.e.join(" ")} at ${mmss(g.at)}${g.last !== g.at ? "–" + mmss(g.last) : ""} · tap to jump there`;
below = g.at - prevAt <= dur * 0.12 ? !below : false; prevAt = g.last; // close neighbours alternate sides
tick.classList.toggle("below", below);
tick.style.left = Math.min(100, (g.at + g.last) / 2 / dur * 100) + "%";
tick.addEventListener("click", (e) => { e.stopPropagation(); if (state.audio.duration) state.audio.currentTime = Math.min(state.audio.duration - 0.5, g.at); });
bar.append(tick);
}
}
async function react(emoji, text) {
const cur = state.current && state.current.spec; if (!cur) return;
@@ -1383,13 +1613,24 @@ async function react(emoji, text) {
const at = state.audio.currentTime || 0, sec = sectionAt(cur, at), when = ` at ${mmss(at)}${sec ? " (" + sec + ")" : ""}`;
const who = `${cur.title} (${cur.tags.split(",").slice(0, 3).join(",")})${when}`;
if (emoji) markReaction(cur, emoji, at);
logEvent("react", { e: emoji || undefined, txt: text ? String(text).slice(0, 60) : undefined, at: Math.round(at), st: state.station || state.seed || "", title: cur.title });
if (m) {
if (m.like) state.taste.like.push(`${m.like} — re ${who}`);
if (m.avoid) state.taste.avoid.push(`${m.avoid} — re ${who}`);
if (m.notes) state.taste.notes.push(`${m.notes} (an emoji, re ${who})`);
cur.reaction = (cur.reaction ? cur.reaction + " " : "") + emoji;
}
if (text) { state.taste.notes.push(`${text} (re ${who})`); cur.reaction = (cur.reaction ? cur.reaction + " " : "") + `"${text}"`; }
if (text) {
state.taste.notes.push(`${text} (re ${who})`); cur.reaction = (cur.reaction ? cur.reaction + " " : "") + `"${text}"`;
// "no violin", "less classical, no strings", "more guitar": the named things go on the lists and on the sheet as tag signals
const parts = text.toLowerCase().split(/[,;.!]|\band\b/).map((s) => s.trim()).filter(Boolean);
for (const p of parts) {
const neg = p.match(/^(?:no|not|less|stop|without|drop|enough|never|skip|hate|lose|kill|cut|no more)\s+(?:the\s+|any\s+)?([a-z][a-z0-9 -]{1,30}?)$/);
const pos = p.match(/^(?:more|love|loving|keep|add|give me|i like|i love)\s+(?:the\s+|this\s+|that\s+)?([a-z][a-z0-9 -]{1,30}?)$/);
if (neg) { state.taste.avoid.push(`${neg[1]} (listener said: "${p}")`); cur.reaction += " -" + neg[1].replace(/\s+/g, "_"); }
else if (pos) { state.taste.like.push(`${pos[1]} (listener said: "${p}")`); cur.reaction += " +" + pos[1].replace(/\s+/g, "_"); }
}
}
for (const k of ["like", "avoid", "notes"]) state.taste[k] = state.taste[k].slice(-8);
// a next track already in the oven was planned before this was said: it still plays (it's paid for), the one after hears it
const later = !!state.nextJob;
@@ -1591,18 +1832,20 @@ const EMOJI_SETS = {
};
const EMOJI_KEY = "gandora.emoji";
function emojiCounts() { try { return JSON.parse(localStorage.getItem(EMOJI_KEY) || "{}"); } catch { return {}; } }
function bumpEmoji(e) { const c = emojiCounts(); c[e] = (c[e] || 0) + 1; localStorage.setItem(EMOJI_KEY, JSON.stringify(c)); }
function bumpEmoji(e) { const c = emojiCounts(); c[e] = (c[e] || 0) + 1; localStorage.setItem(EMOJI_KEY, JSON.stringify(c)); localStorage.setItem(EMOJI_KEY + ".last", e); }
const EMOJI_DEFAULT = ["🔥", "❤️", "😴", "🌧️", "⚡", "🎸", "🚫"];
const EMOJI_TRAY = 7;
function showEmojiRow() {
const c = emojiCounts(), row = $("#emojis"), more = $("#more");
const seeded = EMOJI_DEFAULT.map((e, i) => [e, (c[e] || 0) + (EMOJI_TRAY - i) * 0.01]); // defaults keep their order until use says otherwise
for (const e of Object.keys(c)) if (!EMOJI_DEFAULT.includes(e)) seeded.push([e, c[e]]);
const top = seeded.sort((a, b) => b[1] - a[1]).slice(0, EMOJI_TRAY).map((x) => x[0]);
const ranked = seeded.sort((a, b) => b[1] - a[1]).map((x) => x[0]);
const last = localStorage.getItem(EMOJI_KEY + ".last");
const top = ranked.slice(0, EMOJI_TRAY - 1); if (last && !top.includes(last)) top.push(last); else top.push(...ranked.slice(EMOJI_TRAY - 1, EMOJI_TRAY)); // the last one used always has a slot
for (const b of [...row.querySelectorAll("button[data-e]")]) b.remove();
for (const e of top) { const b = document.createElement("button"); b.dataset.e = e; b.textContent = e; b.title = (EMOJI_MEANING[e] && (EMOJI_MEANING[e].like || EMOJI_MEANING[e].avoid)) || ""; row.insertBefore(b, more); }
}
$("#emojis").addEventListener("click", (e) => { const b = e.target.closest("button[data-e]"); if (!b) return; b.classList.add("hit"); setTimeout(() => b.classList.remove("hit"), 300); bumpEmoji(b.dataset.e); react(b.dataset.e); });
$("#emojis").addEventListener("click", (e) => { const b = e.target.closest("button[data-e]"); if (!b) return; b.classList.add("hit"); setTimeout(() => b.classList.remove("hit"), 300); bumpEmoji(b.dataset.e); react(b.dataset.e); setTimeout(showEmojiRow, 350); });
function openPicker() {
const p = $("#picker"); p.innerHTML = "";
const top = document.createElement("div"); top.className = "top";
@@ -1611,17 +1854,18 @@ function openPicker() {
const send = () => { const v = inp.value.trim(); if (!v) return; pick(v); };
go.addEventListener("click", send); inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); send(); } });
close.addEventListener("click", () => p.classList.add("hidden"));
top.append(inp, go, close); p.append(top);
top.append(inp, go, close);
const pick = (e) => { bumpEmoji(e); react(e); showEmojiRow(); p.classList.add("hidden"); };
const c = emojiCounts(), used = Object.entries(c).sort((a, b) => b[1] - a[1]).slice(0, 16).map((x) => x[0]);
const sets = used.length ? { "MOST USED": used.join(" "), ...EMOJI_SETS } : EMOJI_SETS;
for (const [name, list] of Object.entries(sets)) {
p.append(Object.assign(document.createElement("h4"), { textContent: name }));
const g = document.createElement("div"); g.className = "g";
for (const e of [...new Set(list.split(/\s+/).filter(Boolean))]) { const b = document.createElement("button"); b.textContent = e; b.addEventListener("click", () => pick(e)); g.append(b); }
for (const e of [...new Set(list.split(/\s+/).filter(Boolean))]) { const b = document.createElement("button"); b.type = "button"; b.textContent = e; b.addEventListener("click", (ev) => { ev.preventDefault(); pick(e); }); g.append(b); }
p.append(g);
}
p.classList.remove("hidden"); inp.focus({ preventScroll: true });
p.append(top); // the type-box last, and no autofocus: the drawer must not raise the keyboard
p.classList.remove("hidden");
}
$("#more").addEventListener("click", () => { const p = $("#picker"); if (p.classList.contains("hidden")) openPicker(); else p.classList.add("hidden"); });
showEmojiRow();
@@ -1664,7 +1908,7 @@ $("#libBtn").addEventListener("click", async () => {
fromT.addEventListener("click", (e) => { e.stopPropagation(); $("#lib").classList.add("hidden"); stationFrom(t); });
art.append(addT, fromT);
d.append(art, Object.assign(document.createElement("b"), { textContent: t.spec.title }), Object.assign(document.createElement("small"), { textContent: `${t.spec.bpm} bpm · ${t.spec.key}${(t.plays || 0) > 1 ? " · ×" + t.plays : ""}`, title: t.spec.seed }));
d.dataset.id = t.id; if (state.current && state.current.spec && state.current.spec.id === t.id) d.classList.add("now", state.playing ? "" : "paused");
d.dataset.id = t.id; if (state.current && state.current.spec && state.current.spec.id === t.id) { d.classList.add("now"); if (!state.playing) d.classList.add("paused"); }
d.addEventListener("click", () => {
// the library stays open; the track plays underneath, and the bar at the bottom follows it
if (state.current && state.current.spec && state.current.spec.id === t.id) { $("#play").click(); return; }
+140
View File
@@ -0,0 +1,140 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:">
<title>g*ndora · stats</title>
<link rel="icon" href="favicon.svg">
<meta name="theme-color" content="#0b0b0e">
<style>
:root{
--bg:#0b0b0e; --ink:#ededf0; --dim:#8a8a96; --line:#232329; --card:#121216;
--font:ui-monospace,"SF Mono",Menlo,Consolas,monospace; --sans:-apple-system,"Inter",system-ui,sans-serif; color-scheme:dark;
}
:root[data-theme="light"]{--bg:#f4f4f6; --ink:#17171a; --dim:#6b6b76; --line:#dcdce2; --card:#ffffff; color-scheme:light}
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){--bg:#f4f4f6; --ink:#17171a; --dim:#6b6b76; --line:#dcdce2; --card:#ffffff; color-scheme:light}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.5 var(--font);padding:16px 16px calc(24px + env(safe-area-inset-bottom));max-width:720px;margin-inline:auto}
header{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin:8px 0 18px}
header a{color:var(--dim);text-decoration:none}header a:hover{color:var(--ink)}
h1{font:600 18px var(--font);margin:0;letter-spacing:.02em}
h2{font:600 10px var(--font);letter-spacing:.2em;color:var(--dim);margin:22px 0 8px;text-transform:uppercase}
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
.tile{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:10px 12px}
.tile b{display:block;font:600 20px var(--font);letter-spacing:-.01em}
.tile span{color:var(--dim);font-size:11px}
table{width:100%;border-collapse:collapse;font-size:12px}
td,th{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}
th{color:var(--dim);font-weight:600;font-size:10px;letter-spacing:.14em;text-transform:uppercase}
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}
.bars{display:flex;align-items:flex-end;gap:3px;height:72px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:10px}
.bars i{flex:1;background:var(--ink);opacity:.8;border-radius:2px 2px 0 0;min-height:1px}
.bars i[title]{cursor:default}
.row{display:flex;gap:10px;flex-wrap:wrap;color:var(--dim);font-size:11px;margin-top:6px}
.sw{display:inline-block;width:10px;height:10px;border-radius:2px;background:var(--ink);vertical-align:middle;margin-right:4px}
.muted{color:var(--dim)}
button{font:600 11px var(--font);letter-spacing:.14em;background:none;color:var(--ink);border:1px solid var(--line);border-radius:999px;padding:8px 14px;cursor:pointer}
button:hover{background:var(--card)}
.empty{color:var(--dim);padding:40px 0;text-align:center}
</style>
</head>
<body>
<header><h1>g*ndora · stats</h1><a href="./">← back</a></header>
<div id="out"><div class="empty">reading…</div></div>
<script>
const $ = (s) => document.querySelector(s);
try { const th = localStorage.getItem("gandora.theme"); if (th) document.documentElement.dataset.theme = th; } catch {}
const money = (n) => "$" + (n < 0.1 ? n.toFixed(4) : n.toFixed(2));
const mins = (s) => s < 3600 ? Math.round(s / 60) + " min" : (s / 3600).toFixed(1) + " h";
const pct = (a, b) => b ? Math.round(100 * a / b) + "%" : "—";
const q = (arr, p) => { if (!arr.length) return 0; const a = [...arr].sort((x, y) => x - y); return a[Math.min(a.length - 1, Math.floor(p * a.length))]; };
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
function idb(store) {
return new Promise((ok) => { try { const r = indexedDB.open("gandora", 3); r.onsuccess = () => { try { const tx = r.result.transaction(store, "readonly").objectStore(store).getAll(); tx.onsuccess = () => ok(tx.result || []); tx.onerror = () => ok([]); } catch { ok([]); } }; r.onerror = () => ok([]); r.onupgradeneeded = () => {}; } catch { ok([]); } });
}
function tile(v, label) { return `<div class="tile"><b>${v}</b><span>${label}</span></div>`; }
function bars(series, fmt) {
const max = Math.max(1, ...series.map((x) => x.v));
return `<div class="bars">${series.map((x) => `<i style="height:${Math.max(1, 100 * x.v / max)}%" title="${esc(x.l)}: ${esc(fmt ? fmt(x.v) : x.v)}"></i>`).join("")}</div>`;
}
(async () => {
let log = []; try { log = JSON.parse(localStorage.getItem("gandora.log") || "[]"); } catch {}
const [tracks, stations, tapes] = await Promise.all([idb("tracks"), idb("stations"), idb("tapes")]);
const ledger = parseFloat(localStorage.getItem("gandora.cost") || "0");
const cooks = log.filter((e) => e.k === "cook"), plays = log.filter((e) => e.k === "play"), reacts = log.filter((e) => e.k === "react");
const ok = cooks.filter((e) => e.ok), fails = cooks.filter((e) => !e.ok);
const spend = cooks.reduce((a, e) => a + (e.cost || 0), 0);
const listened = plays.reduce((a, e) => a + (e.s || 0), 0);
const made = ok.reduce((a, e) => a + (e.s || 0), 0);
const paid = ok.filter((e) => e.r && !e.r.startsWith("device"));
const lat = paid.map((e) => e.ms).filter(Boolean);
const fb = cooks.filter((e) => e.fb).length;
const h = [];
if (!log.length && !tracks.length) { $("#out").innerHTML = `<div class="empty">nothing yet: the ledger starts with the next track you play</div>`; return; }
h.push(`<h2>overall</h2><div class="tiles">`,
tile(ok.length, "tracks cooked"), tile(mins(made), "audio made"), tile(mins(listened), "listened (plays × length)"),
tile(money(spend), "spent since the ledger began"), tile(money(ledger), "lifetime cost (all time)"),
tile(ok.length ? money(spend / Math.max(1, paid.length)) : "—", "per rendered track"),
tile(stations.length, "stations"), tile(tracks.filter((t) => t.kept).length + " / " + tracks.length, "kept / in library"), tile(tapes.length, "tapes"),
tile(reacts.length, "reactions"), tile(pct(reacts.length, plays.length), "plays that got a reaction"),
`</div>`);
// by day
const days = {};
for (const e of log) { const d = new Date(e.t).toISOString().slice(0, 10); days[d] ||= { cooks: 0, cost: 0, plays: 0, s: 0 }; if (e.k === "cook") { days[d].cooks++; days[d].cost += e.cost || 0; } if (e.k === "play") { days[d].plays++; days[d].s += e.s || 0; } }
const dk = Object.keys(days).sort().slice(-30);
if (dk.length > 1) {
h.push(`<h2>last ${dk.length} days · listening</h2>`, bars(dk.map((d) => ({ l: d, v: days[d].s / 60 })), (v) => Math.round(v) + " min"),
`<div class="row"><span>${dk[0]}</span><span style="margin-left:auto">${dk[dk.length - 1]}</span></div>`,
`<h2>spend by day</h2>`, bars(dk.map((d) => ({ l: d, v: days[d].cost })), money));
}
// renderers
const byR = {};
for (const e of ok) { const r = e.r || "?"; byR[r] ||= { n: 0, cost: 0, s: 0, ms: [] }; byR[r].n++; byR[r].cost += e.cost || 0; byR[r].s += e.s || 0; if (e.ms) byR[r].ms.push(e.ms); }
h.push(`<h2>renderers</h2><table><tr><th>renderer</th><th class="n">tracks</th><th class="n">audio</th><th class="n">cost</th><th class="n">$/min</th><th class="n">cook p50</th><th class="n">p95</th></tr>`);
for (const [r, v] of Object.entries(byR).sort((a, b) => b[1].n - a[1].n))
h.push(`<tr><td>${esc(r)}</td><td class="n">${v.n}</td><td class="n">${mins(v.s)}</td><td class="n">${money(v.cost)}</td><td class="n">${v.s ? money(v.cost / (v.s / 60)) : "—"}</td><td class="n">${v.ms.length ? Math.round(q(v.ms, 0.5) / 1000) + " s" : "—"}</td><td class="n">${v.ms.length ? Math.round(q(v.ms, 0.95) / 1000) + " s" : "—"}</td></tr>`);
h.push(`</table>`);
h.push(`<div class="tiles" style="margin-top:8px">`, tile(lat.length ? Math.round(q(lat, 0.5) / 1000) + " s" : "—", "cook latency p50 (paid renders)"), tile(lat.length ? Math.round(q(lat, 0.95) / 1000) + " s" : "—", "p95"),
tile(pct(fails.length, cooks.length), `failed cooks (${fails.length})`), tile(pct(fb, cooks.length), `fell back to venice (${fb})`), `</div>`);
if (fails.length) { const last = fails.slice(-5).reverse(); h.push(`<div class="row"><span class="muted">recent failures:</span>${last.map((e) => `<span>${esc(e.err || "?")}</span>`).join("")}</div>`); }
// modes & breaks
const byM = {}; for (const e of ok) { const m = e.brk ? "break:" + e.brk : (e.mode || "music"); byM[m] = (byM[m] || 0) + 1; }
h.push(`<h2>what got made</h2><div class="row">${Object.entries(byM).sort((a, b) => b[1] - a[1]).map(([k, v]) => `<span><span class="sw"></span>${esc(k)} ${v}</span>`).join("")}</div>`);
// stations
const byS = {};
for (const e of log) { const s = e.st || "?"; byS[s] ||= { plays: 0, s: 0, react: 0, cost: 0, cooks: 0 }; if (e.k === "play") { byS[s].plays++; byS[s].s += e.s || 0; } if (e.k === "react") byS[s].react++; if (e.k === "cook") { byS[s].cooks++; byS[s].cost += e.cost || 0; } }
const srows = Object.entries(byS).sort((a, b) => b[1].s - a[1].s).slice(0, 15);
h.push(`<h2>stations</h2><table><tr><th>station</th><th class="n">plays</th><th class="n">listened</th><th class="n">reactions</th><th class="n">cost</th></tr>`);
for (const [s, v] of srows) {
const st = stations.find((x) => x.name === s || x.seed === s);
h.push(`<tr><td>${esc(s)}${st && st.mode && st.mode !== "music" ? ` <span class="muted">· ${esc(st.mode)}</span>` : ""}${st && st.djName ? ` <span class="muted">· dj ${esc(st.djName)}</span>` : ""}</td><td class="n">${v.plays}</td><td class="n">${mins(v.s)}</td><td class="n">${v.react}</td><td class="n">${money(v.cost)}</td></tr>`);
}
h.push(`</table>`);
// reactions
const byE = {}; for (const e of reacts) { const k = e.e || "✎ text"; byE[k] = (byE[k] || 0) + 1; }
const erows = Object.entries(byE).sort((a, b) => b[1] - a[1]);
if (erows.length) {
h.push(`<h2>reactions</h2>`, bars(erows.map(([k, v]) => ({ l: k, v })), (v) => v + "×"), `<div class="row">${erows.map(([k, v]) => `<span>${esc(k)} ${v}</span>`).join("")}</div>`);
const whens = reacts.filter((e) => e.at != null && e.at >= 0).map((e) => e.at);
if (whens.length > 4) { const hist = Array.from({ length: 12 }, () => 0); for (const a of whens) hist[Math.min(11, Math.floor(a / 15))]++; h.push(`<h2>when in a track you react</h2>`, bars(hist.map((v, i) => ({ l: `${i * 15}–${i * 15 + 15} s`, v })), (v) => v + "×"), `<div class="row"><span>0:00</span><span style="margin-left:auto">3:00</span></div>`); }
}
// library
const top = [...tracks].filter((t) => t.spec && !t.spec.break).sort((a, b) => (b.plays || 0) - (a.plays || 0)).slice(0, 8);
if (top.length) { h.push(`<h2>most played in the library</h2><table><tr><th>track</th><th>station</th><th class="n">plays</th><th class="n">kept</th></tr>`); for (const t of top) h.push(`<tr><td>${esc(t.spec.title)}</td><td class="muted">${esc(t.spec.station || t.spec.seed || "")}</td><td class="n">${t.plays || 0}</td><td class="n">${t.kept ? "✓" : ""}</td></tr>`); h.push(`</table>`); }
h.push(`<h2>ledger</h2><div class="row"><span>${log.length} lines, on this device only</span><button id="dl">EXPORT JSON</button><button id="clr">CLEAR LEDGER</button></div>`);
$("#out").innerHTML = h.join("");
$("#dl").addEventListener("click", () => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([JSON.stringify({ log, stations: stations.map((s) => ({ ...s, history: undefined })), tapes }, null, 1)], { type: "application/json" })); a.download = "gandora-stats.json"; a.click(); });
$("#clr").addEventListener("click", () => { if (confirm("Clear the ledger? The library and stations stay.")) { localStorage.removeItem("gandora.log"); location.reload(); } });
})();
</script>
</body>
</html>
+2 -2
View File
@@ -1,9 +1,9 @@
// gandora's service worker: the shell is cached so the app opens and the library plays with no signal.
// Tracks live in IndexedDB already; the DJ and new renders need the network and the page says so.
// REV is stamped by the shots workflow on every push, so an installed app sees each update as a new worker.
const REV = "745348446011";
const REV = "be3bce8aac3e";
const SHELL = "gandora-shell-" + REV;
const FILES = ["/", "/index.html", "/brain.js", "/favicon.svg", "/manifest.json", "/icons/icon.svg"];
const FILES = ["/", "/index.html", "/brain.js", "/stats.html", "/favicon.svg", "/manifest.json", "/icons/icon.svg"];
self.addEventListener("install", (e) => { e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).then(() => self.skipWaiting())); });
self.addEventListener("activate", (e) => { e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== SHELL).map((k) => caches.delete(k)))).then(() => self.clients.claim())); });
// same-origin GETs: network first (so a deploy shows up on the next open), cache as the fallback; everything else untouched
+51
View File
@@ -0,0 +1,51 @@
"""Render asmr/voices.json into asmr/audio/*.mp3 through the shared inference worker, plus a rain bed.
python3 tools/asmr.py
Keyless: calls inference.yolopreneur.lol as the gandora app. Skips files that already exist. Needs ffmpeg.
"""
import json, os, subprocess, sys, urllib.request, urllib.error
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "asmr", "audio")
W = "https://inference.yolopreneur.lol/v1/speech"
H = {"Content-Type": "application/json", "Origin": "https://gandora.yolopreneur.lol", "x-app": "gandora", "User-Agent": "gandora-asmr"}
cfg = json.load(open(os.path.join(ROOT, "asmr", "voices.json")))
os.makedirs(OUT, exist_ok=True)
fail = 0
bed = os.path.join(OUT, "bed-rain.mp3")
if not os.path.exists(bed):
subprocess.run(["ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", "anoisesrc=c=pink:a=0.5:d=60", "-f", "lavfi", "-i", "anoisesrc=c=brown:a=0.6:d=60",
"-filter_complex", "[0]highpass=f=900,lowpass=f=7000,tremolo=f=7:d=0.25[a];[1]lowpass=f=300[b];"
"[a][b]amix=inputs=2:weights=1 0.6,afade=t=in:d=2,afade=t=out:st=58:d=2,volume=0.6", "-b:a", "96k", bed], check=True)
for v in cfg["voices"]:
dst = os.path.join(OUT, v["id"] + ".mp3")
if os.path.exists(dst):
continue
text = cfg["script"].replace("Hey...", "Hey... <sigh>", 1) if v.get("tags") else cfg["script"]
text = v.get("lead", "") + text + v.get("tail", "")
fmt = v.get("format", "mp3")
body = {"model": v["model"], "voice": v["voice"], "input": text, "response_format": fmt}
if v.get("speed"): body["speed"] = v["speed"]
if v.get("style"): body["prompt"] = cfg["style"]
try:
with urllib.request.urlopen(urllib.request.Request(W, json.dumps(body).encode(), H), timeout=110) as r:
data = r.read()
except urllib.error.HTTPError as e:
print("ERR", v["id"], e.code, e.read()[:200]); fail += 1; continue
except Exception as e:
print("ERR", v["id"], e); fail += 1; continue
if fmt == "mp3":
open(dst, "wb").write(data)
else:
tmp = dst + "." + fmt
open(tmp, "wb").write(data)
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", tmp, "-b:a", "128k", dst], check=True)
os.remove(tmp)
print("ok", v["id"])
sys.exit(0)
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""tools/harness-safari.py [FRAGMENT] [TIMEOUT_S]
The loop harness in Safari (WebKit, the iPhone's engine: play() policy, read-only element volume, one audio session),
driven over WebDriver by safaridriver, in real time, over http from a temp server. Prints #diag once it says END.
Needs `safaridriver --enable` once (admin). Stdlib only.
Safari has no autoplay flag: the harness fragment gets a real click first (WebDriver clicks count as user activation),
so what runs is what a tapped phone runs.
"""
import json, pathlib, socket, subprocess, sys, tempfile, time, urllib.request
ROOT = pathlib.Path(__file__).resolve().parent.parent
FRAG = ROOT / (sys.argv[1] if len(sys.argv) > 1 else "tools/harness.html")
TIMEOUT = int(sys.argv[2]) if len(sys.argv) > 2 else 90
out = pathlib.Path(tempfile.mkdtemp())
page = (ROOT / "index.html").read_text()
h = FRAG.read_text()
script = h[h.index("<!-- harness -->"):]
# a click target that starts the harness: WebDriver's click is a user activation in Safari
gate = '<button id="go" style="position:fixed;top:0;left:0;z-index:99;padding:20px">GO</button><script>window.__gate=new Promise(r=>document.getElementById("go").addEventListener("click",()=>{document.getElementById("go").remove();r();}));</script>'
script = script.replace('(async () => {\n document.dispatchEvent(new Event("keydown"));', "(async () => {\n await window.__gate;", 1)
assert "await window.__gate" in script, "the fragment's opening line changed; update the gate"
(out / "run.html").write_text(page.replace("</body>", gate + script + "</body>"))
(out / "brain.js").write_text((ROOT / "brain.js").read_text())
for extra in ("sw.js", "manifest.json", "favicon.svg"):
if (ROOT / extra).exists(): (out / extra).write_text((ROOT / extra).read_text())
s = socket.socket(); s.bind(("127.0.0.1", 0)); port = s.getsockname()[1]; s.close()
srv = subprocess.Popen([sys.executable, "-m", "http.server", str(port), "--bind", "127.0.0.1", "--directory", str(out)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
ds = socket.socket(); ds.bind(("127.0.0.1", 0)); dport = ds.getsockname()[1]; ds.close()
drv = subprocess.Popen(["/usr/bin/safaridriver", "-p", str(dport)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def call(method, path, body=None):
req = urllib.request.Request(f"http://127.0.0.1:{dport}{path}", data=json.dumps(body).encode() if body is not None else None, method=method, headers={"content-type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read())["value"]
try:
for _ in range(40):
try: urllib.request.urlopen(f"http://127.0.0.1:{dport}/status", timeout=2); break
except Exception: time.sleep(0.5)
sess = call("POST", "/session", {"capabilities": {"alwaysMatch": {"browserName": "safari"}}})["sessionId"]
base = f"/session/{sess}"
call("POST", f"{base}/url", {"url": f"http://127.0.0.1:{port}/run.html"})
time.sleep(1.5)
el = call("POST", f"{base}/element", {"using": "css selector", "value": "#go"})
call("POST", f"{base}/element/{list(el.values())[0]}/click", {})
t0 = time.time(); diag = ""
while time.time() - t0 < TIMEOUT:
diag = call("POST", f"{base}/execute/sync", {"script": "const d=document.getElementById('diag');return d?d.textContent:''", "args": []})
if diag.rstrip().endswith("END"): break
time.sleep(1)
print(diag or "no diag: the harness did not run")
call("DELETE", base)
finally:
drv.terminate(); srv.terminate()
+1
View File
@@ -14,6 +14,7 @@ MODE=${1:-loop}
case "$MODE" in
loop) FRAG=tools/harness.html; AUTOPLAY=--autoplay-policy=no-user-gesture-required ;;
resume) FRAG=tools/harness-resume.html; AUTOPLAY= ;;
safari) exec python3 tools/harness-safari.py tools/harness.html "${2:-90}" ;; # WebKit, after `safaridriver --enable` once
*) echo "usage: sh tools/harness.sh [resume]" >&2; exit 2 ;;
esac
CHROME=${CHROME:-}
+99
View File
@@ -0,0 +1,99 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>g*ndora · winamp</title>
<link rel="icon" href="favicon.svg">
<style>
html,body{margin:0;height:100%;background:#0b0b0e;color:#ededf0;font:13px ui-monospace,Menlo,Consolas,monospace}
#note{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);text-align:center;color:#8a8a96;line-height:1.7;max-width:420px;padding:0 16px}
#note a{color:#ededf0}
#bar{position:fixed;left:0;right:0;bottom:0;display:flex;gap:14px;justify-content:center;padding:10px;font-size:11px;letter-spacing:.18em;color:#8a8a96}
#bar a{color:#8a8a96;text-decoration:none}#bar a:hover{color:#ededf0}
#app{position:fixed;inset:0}
#pick{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);background:#121216;border:1px solid #232329;border-radius:14px;padding:14px;min-width:260px;max-width:min(420px,calc(100% - 32px));z-index:100000}
#pick h4{margin:0 0 10px;font-size:10px;letter-spacing:.2em;color:#8a8a96}
#pick button{display:block;width:100%;text-align:left;padding:9px 10px;border-radius:8px;border:1px solid transparent;background:none;color:#ededf0;font:13px ui-monospace,Menlo,monospace;cursor:pointer}
#pick button:hover{background:#1a1a20;border-color:#232329}
#pick input{width:100%;box-sizing:border-box;margin:6px 0;background:#0a0a0d;border:1px solid #232329;border-radius:8px;padding:8px 10px;color:#ededf0;font:13px ui-monospace,Menlo,monospace}
.hidden{display:none}
</style>
</head>
<body>
<div id="app"></div>
<div id="note">loading…</div>
<div id="pick" class="hidden"></div>
<div id="bar"><a href="/">◂ G*NDORA</a><span id="count"></span></div>
<script type="module">
/* g*ndora's library in Winamp. Tracks and tapes come from this origin's IndexedDB. Milkdrop runs Butterchurn's presets
on the real audio. Tapes: the Open menu (the eject button) and LOAD LIST pick a tape; SAVE LIST makes a tape from
the playlist. Skins: a set from the Winamp Skin Museum (skins.webamp.org) under Options › Skins, or paste a museum
link. */
import Webamp from "https://unpkg.com/webamp@2.3.1/built/webamp.butterchurn-bundle.min.mjs";
const note = document.getElementById("note"), pick = document.getElementById("pick");
function open() { return new Promise((ok, no) => { const r = indexedDB.open("gandora", 3); r.onupgradeneeded = () => { const d = r.result; for (const s of ["tracks", "stations", "tapes"]) if (!d.objectStoreNames.contains(s)) d.createObjectStore(s, { keyPath: "id" }); }; r.onsuccess = () => ok(r.result); r.onerror = () => no(r.error); }); }
async function all(store) { const d = await open(); return new Promise((ok, no) => { const r = d.transaction(store).objectStore(store).getAll(); r.onsuccess = () => ok(r.result); r.onerror = () => no(r.error); }); }
async function put(row, store) { const d = await open(); return new Promise((ok, no) => { const tx = d.transaction(store, "readwrite"); tx.objectStore(store).put(row); tx.oncomplete = ok; tx.onerror = () => no(tx.error); }); }
/* a small chooser in place of prompt(): a title, a list of buttons, maybe a text box */
function choose(title, items, { input = "", go = "OK" } = {}) {
return new Promise((ok) => {
pick.innerHTML = ""; pick.append(Object.assign(document.createElement("h4"), { textContent: title }));
const done = (v) => { pick.classList.add("hidden"); ok(v); };
for (const [label, value] of items) { const b = document.createElement("button"); b.textContent = label; b.addEventListener("click", () => done(value)); pick.append(b); }
if (input !== null && input !== false) {
const inp = document.createElement("input"); inp.placeholder = input; pick.append(inp);
const b = document.createElement("button"); b.textContent = go; b.addEventListener("click", () => done(inp.value.trim() ? { text: inp.value.trim() } : null)); pick.append(b);
inp.addEventListener("keydown", (e) => { if (e.key === "Enter") b.click(); });
}
const c = document.createElement("button"); c.textContent = "cancel"; c.style.color = "#8a8a96"; c.addEventListener("click", () => done(null)); pick.append(c);
pick.classList.remove("hidden");
});
}
/* the Winamp Skin Museum: a set from its front page (md5 → the museum's own CDN, which allows cross-origin loads) */
const MUSEUM = [["5e4f10275dcb1fb211d4a8b4f1bda236", "base-2.91"], ["cd251187a5e6ff54ce938d26f1f2de02", "Winamp3 Classified"], ["b0fb83cc20af3abe264291bb17fb2a13", "Winamp5 Classified"], ["d6010aa35bed659bc1311820daa4b341", "Bento Classified"], ["971c1afc05b56f5160903d0748b73239", "Deus Ex Amp"], ["48bbdbbeb03d347e59b1eebda4d352d0", "Zelda Amp 3"], ["03a967351bc69b83a3c063782997d434", "Microchip 2"], ["80fb8e391fc925f297174a8fd9c5b6ad", "Fallout Pip-Boy 2000"], ["5ea2d974dad5507b83a6ddff7003a8ee", "Yufo"], ["7b9df7892503371430aaf99ea7a172c0", "Unreal"], ["eda35a53d88257c88c0dbf7ca9c4a98d", "SoundLair"], ["3e5f4c237cbad07fce684f0e171abe6a", "Super Mario Land (GB)"], ["e06363340d1e7db147b2b61e9c3601a4", "ascii"], ["93220dd1556a346036d200a8df07b35f", "DOOM II Amp"], ["429f17eea62cf15aa88ea39da152c0e3", "Oblivion Amp"], ["670c7a789344732ca0c32b0eb0ad3c90", "Boca Amp"], ["f7ac1decc3785bc34c07a6caa4661ace", "Rei Ayanami"], ["cd90a3a4fd08720d50b16d0a2bcb1ad4", "weezer2"], ["139c3e8f020b24c2bc3562a9ecacb231", "Yuna FFX"], ["cb7102ec7a4f6cefbc63d0dc0aecea16", "Purple Glow"], ["bb7fd9baf2292c81436825248837acc7", "Blue Jeans"], ["3b3b8b07fb7d268f6092d4321f0a9b9c", "Necromech"], ["d632abee67ceb6878aa5ba3770bb7894", "Tenchi Muyo Aeka"], ["546dc4154dfa3e1ec605acf5de18a888", "Bag End's Gardener"], ["f240348986b9e19578b033d639b263e5", "Excel Saga"], ["ebcf7047f0b7dacbe827a0eef3bb4608", "VGCats"], ["ef264ad04547b893eef282aed69df5f9", "Super Mario Land"], ["4e837b2ed5050ad8d10e9ae37313fca1", "The People's Amp"], ["47597ab8e5ffcd39686d455c10c3b436", "Garfield"], ["52fb162d1dcd616659eef2fbad46b238", "Mr Bean"], ["3e0a37f5fcd0e9dedc035eeb33932545", "george"], ["14289db7b3fc18c2de6f4e93297bd6d3", "goku"], ["641297cf9994f5bae1f7a1e50342c52d", "Jackie Chan"], ["b9c6d42793c2276ef05ec2c73d6fc4e6", "Sausage Amp"], ["ffb93ba1425a5f17143fe5c8642c6045", "Morbamp"]];
const skinUrl = (md5) => `https://r2.webampskins.org/skins/${md5}.wsz`;
try {
const [tracks, stations, tapes] = await Promise.all([all("tracks"), all("stations"), all("tapes")]);
const byStation = Object.fromEntries(stations.map((s) => [s.id, s.name || s.seed]));
const rows = tracks.filter((t) => t.blob && t.spec && !t.spec.break).sort((a, b) => (b.kept - a.kept) || (b.made - a.made));
const byId = Object.fromEntries(rows.map((t) => [t.id, t]));
const toTrack = (t) => ({ blob: t.blob, metaData: { title: t.spec.title, artist: byStation[t.spec.stationId] || t.spec.seed || "g*ndora" }, duration: t.spec.seconds || 120 });
const kept = rows.filter((t) => t.kept);
const allTapes = [{ id: "kept", name: "KEPT", ids: kept.map((t) => t.id), virtual: true }, ...tapes];
document.getElementById("count").textContent = rows.length ? `${rows.length} TRACKS · ${allTapes.length} TAPES` : "";
if (!rows.length) note.innerHTML = 'nothing in this library yet. <a href="/">play a station</a> first, then come back.'; else note.remove();
const pickTape = async () => {
const v = await choose("A TAPE", allTapes.map((tp) => [`${tp.name} · ${tp.ids.length}`, tp]), { input: null });
if (!v) return null;
return v.ids.map((id) => byId[id]).filter(Boolean).map(toTrack);
};
const saveTape = async (list) => {
const v = await choose("SAVE THE PLAYLIST AS A TAPE", [], { input: "tape name", go: "SAVE" }); if (!v) return;
const ids = list.map((tr) => { const m = tr.metaData || {}; const r = rows.find((t) => t.spec.title === m.title && (byStation[t.spec.stationId] || t.spec.seed || "g*ndora") === m.artist); return r && r.id; }).filter(Boolean);
if (!ids.length) { alert("Those tracks aren't in the library."); return; }
await put({ id: "tape-" + Date.now(), name: v.text.slice(0, 60), ids, made: Date.now(), last: Date.now() }, "tapes");
alert(`Tape “${v.text}” saved: ${ids.length} tracks. It's in the library.`);
};
const pickSkin = async () => {
const v = await choose("A SKIN FROM THE MUSEUM", [], { input: "paste a skins.webamp.org link or an md5", go: "LOAD" }); if (!v) return;
const m = v.text.match(/[a-f0-9]{32}/i); if (!m) { alert("No skin id in that."); return; }
webamp.setSkinFromUrl(skinUrl(m[0].toLowerCase()));
};
const webamp = new Webamp({
initialTracks: kept.length ? kept.map(toTrack) : rows.map(toTrack),
availableSkins: MUSEUM.map(([md5, name]) => ({ url: skinUrl(md5), name })),
filePickers: [{ contextMenuName: "Mixtape…", filePicker: pickTape, requiresNetwork: false }, { contextMenuName: "Skin from the museum…", filePicker: async () => { await pickSkin(); return []; }, requiresNetwork: true }],
handleLoadListEvent: pickTape,
handleAddUrlEvent: pickTape,
handleSaveListEvent: saveTape,
windowLayout: { main: { position: { top: 0, left: 0 } }, equalizer: { position: { top: 116, left: 0 } }, playlist: { position: { top: 232, left: 0 }, size: { extraHeight: 4, extraWidth: 0 } }, milkdrop: { position: { top: 0, left: 275 }, size: { extraHeight: 12, extraWidth: 7 } } },
enableHotkeys: true,
enableMediaSession: true,
});
await webamp.renderWhenReady(document.getElementById("app"));
webamp.onClose(() => { location.href = "/"; });
} catch (e) { note.textContent = "winamp could not start: " + e.message; }
</script>
</body>
</html>