oskarolivier.nl: the source of this page. Cratarr's web/src at the tag preview-65.1 (commit fe9d70fa), plus showcase.html, src/showcase*.ts and this build. AGPL-3.0-or-later. Svelte (MIT) is not included. ===== README.md ===== # oskarolivier.nl Cratarr's visuals on their own: fractal flames moved by four tracks' stored analyses, without the music. Built from Cratarr's `web/` at the tag `preview-65.1` (commit fe9d70fa), whose visuals are used unchanged. `src/showcase.ts` plays the analyses back on a clock and answers the visuals' server calls from `showcase-public/signals/`; the page's CSS hides what needs a server or a player. The Microphone switch has the visuals hear the room instead, as Cratarr's visuals hear a track the server has no analyses for (`src/showcase-mic.ts`; the build hands the visuals' music `src/showcase-tap.ts` for the player, so Cratarr's files stay as they are). AGPL-3.0-or-later. Build: `cd web && pnpm exec vite build -c vite.showcase.config.ts` (output in `dist/`). Deploy: `./deploy.sh`. ===== deploy.sh ===== #!/bin/sh # Copies dist/ to the NAS's nginx site (the files are served as they are). set -e cd "$(dirname "$0")" tar -C dist -cf - . | ssh nas 'cd /Volume1/docker/nginx/sites/oskarolivier.nl && tar --no-same-owner -xf - && chmod -R a+rX .' ===== showcase.html ===== Oskar Olivier
===== src/app.css ===== /* The player's colour from the cover (decision 0018), registered so it can fade between tracks. */ @property --art-accent { syntax: ''; inherits: true; initial-value: #3ddc97; } /* The playing cover's colour for the whole page (App sets it on the root from lib/remote's art), for the floating controls; it fades between tracks as the mini-player's does. */ @property --now-accent { syntax: ''; inherits: true; initial-value: #3ddc97; } :root { --bg: #0f1115; --surface: #181b22; --border: #2a2f3a; --text: #e8eaf0; --muted: #9097a5; --accent: #3ddc97; --accent-text: #06281a; --warn: #f5b942; --danger: #ff6b6b; /* The floating controls' surface (the pill, back, ✕, the mark, the select bars): the colour of the mini-player's unplayed part (the playing cover's at 25 % over the page), a little see-through, what is under it blurred. */ --float-bg: color-mix(in srgb, color-mix(in srgb, var(--now-accent) 25%, var(--bg)) 70%, transparent); --float-blur: blur(16px) saturate(1.4); transition: --now-accent 1s linear; color-scheme: dark; } /* Light: when the system asks for it (unless the page is set to dark), or when the page is set to light. */ @media (prefers-color-scheme: light) { :root:not([data-theme='dark']) { --bg: #f5f6f8; --surface: #ffffff; --border: #d9dde5; --text: #1a1d24; --muted: #5d6573; --accent: #12a36a; --accent-text: #ffffff; --warn: #a86a00; --danger: #c62d2d; color-scheme: light; } } :root[data-theme='light'] { --bg: #f5f6f8; --surface: #ffffff; --border: #d9dde5; --text: #1a1d24; --muted: #5d6573; --accent: #12a36a; --accent-text: #ffffff; --warn: #a86a00; --danger: #c62d2d; color-scheme: light; } * { box-sizing: border-box; } html, body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-tap-highlight-color: transparent; /* No pull-to-refresh (nor the bounce at the ends): a reload drops what plays in this browser and the open panel's place, so a swipe down at the top must never reload the app. */ overscroll-behavior-y: none; } button, input { font: inherit; color: inherit; } /* A button whose action is under way (docs/ui-conventions.md → Feedback): its label gives way to a small spinner in the same place, so nothing moves. Set aria-busy on the button that was tapped, with disabled. */ button[aria-busy='true'] { position: relative; color: transparent !important; cursor: progress; } button[aria-busy='true']::after { content: ''; position: absolute; inset: 0; width: 1.1em; height: 1.1em; margin: auto; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: busy-spin 0.7s linear infinite; } @keyframes busy-spin { to { transform: rotate(1turn); } } @media (prefers-reduced-motion: reduce) { button[aria-busy='true']::after { animation-duration: 2s; } } button[aria-busy='true']:disabled { opacity: 1; } /* On a filled button the spinner takes the button's text colour. */ button.primary[aria-busy='true']::after, button.solid[aria-busy='true']::after { border-color: color-mix(in srgb, var(--accent-text) 30%, transparent); border-top-color: var(--accent-text); } /* An edge's handle (decisions 0018 and 0042): the edge's line itself, 1 px, so what is on either side fills right up to it; the handle in its middle that shows it can be dragged, and the room to grab it, reach over both sides. The edge between page and panel (App's .grip) and the navigation's edge (App's .nav-grip) wear the same; each places it (sticky, absolute). The handle's size is the page's (--grip-w, --grip-h): the panel cuts itself away around it. Its width is odd, so it sits exactly centred on the 1 px line, the same whole pixels on each side. */ :root { --grip-w: 5px; --grip-h: 44px; } .edge-grip { z-index: 10; width: 1px; cursor: col-resize; touch-action: none; background: var(--border); } .edge-grip::before { content: ''; position: absolute; inset: 0 -6px; } .edge-grip::after { content: ''; position: absolute; /* Placed by arithmetic, not a translate: a translate lands it on half pixels. */ top: calc(50% - var(--grip-h) / 2); left: calc(50% - var(--grip-w) / 2); width: var(--grip-w); height: var(--grip-h); border-radius: calc(var(--grip-w) / 2); background: var(--muted); opacity: 0.6; } .edge-grip:hover::after, .edge-grip:focus-visible::after { background: var(--accent); opacity: 1; } ===== src/lib/Icon.svelte ===== ===== src/lib/MenuSheet.svelte =====
===== src/lib/SheetArea.svelte ===== {@render children()} ===== src/lib/api.ts ===== import { track } from './progress.svelte'; import { coverKey, coverRounds, localCovers } from './localCovers.svelte'; import { asksSignIn, notReached, reached, refused, refusedFor, toSignIn, unreached } from './reach.svelte'; /** What a page says while Cratarr is out of reach (lib/reach.svelte.ts). */ export const UNREACHED = 'Cannot reach the Cratarr server'; export type Playlist = { id: string; name: string; member: boolean; /** Tracks that resolve to a file (what players show). */ count: number; /** Entries that point at a missing file. */ dead: number; /** Where it is kept in step (decision 0014). */ links: Link[]; /** Other names it is spoken as, for voice assistants (decision 0015). */ spoken: string[]; }; /** A playlist's sync: "shared" (with everyone), or its copy in an account. */ export type Link = { /** "shared", or "user//". */ target: string; label: string; status: { at: number; error: string | null } | null; /** The account's copy exists (false while it waits to be made). */ made: boolean; }; export type NowPlaying = { /** The source it came from (covers are looked up there). */ source: string; id: string; name: string; artist: string | null; album: string | null; /** The source's album id (used to mark the playing album on the Incoming page). */ album_id: string | null; /** False when the track is outside the curated library, so it cannot be added to a playlist. */ addable: boolean; /** Null when nothing reports a position. */ progress: Progress | null; /** The player it plays on ("Finamp on Lenovo Tab"); the controls act on it. */ player: string | null; /** Whether the player's account has it as a favourite; null when not known. */ favorite: boolean | null; /** What plays next, in order, as far as the player reports. */ upcoming: Upcoming[]; /** What the server knows about the track's file and plays (the player's info chips, decision 0019). */ info?: TrackInfo | null; }; /** Each field null when the server does not know it. */ export type TrackInfo = { play_count: number | null; codec: string | null; bitrate_kbps: number | null; bit_depth: number | null; sample_rate_hz: number | null; explicit: boolean | null; /** Its tempo (decision 0035); absent from a server before it. */ bpm?: number | null; }; /** A track the player plays after the current one. */ export type Upcoming = { id: string; name: string; artist: string | null; album: string | null; album_id: string | null; addable: boolean; duration_secs: number | null; /** `next`: added by hand, ahead of the album or playlist (decision 0033's two lanes); `radio`: Radio's inserts; * `autoplay`: Autoplay's, after everything (decision 0036, amended). */ lane?: 'next' | 'radio' | 'autoplay'; }; export type Progress = { position_secs: number; duration_secs: number | null; paused: boolean; /** No player reports this track; the position is time since it started (wrong after a pause or seek). */ estimated: boolean; }; /** An enabled, connected source; `playing` is only filled by `getState`. */ export type SourceTab = { id: string; label: string; playing: boolean }; export type State = { now_playing: NowPlaying | null; playlists: Playlist[]; sources: SourceTab[]; }; /** `generic`: no words from the server, only its status; callers say what failed instead (see toast.fail). `status`: * the server's answer, where it gave one (a caller that tells a 404 from a 409 reads it). */ export class ApiError extends Error { constructor( message: string, readonly generic = false, readonly status?: number, ) { super(message); } } /** A request the user is waiting for: counted for the progress bar (lib/progress.svelte.ts). */ function request(path: string, init?: RequestInit): Promise { return track(fetchJson(path, init)); } /** A request in the background (polling, checks while typing): no progress bar. */ function quietly(path: string, init?: RequestInit): Promise { // Not while a refusal holds them back (lib/reach): each would be one more knock on a ban. if (refusedFor() > 0) return Promise.reject(new ApiError(UNREACHED)); return fetchJson(path, init); } async function fetchJson(path: string, init?: RequestInit): Promise { let res: Response; try { res = await fetch(path, init); } catch { unreached(); throw new ApiError(UNREACHED); } if (asksSignIn(res)) { toSignIn(); throw new ApiError(UNREACHED); } if (!res.ok) { let message: string | null = null; try { const body = await res.json(); if (typeof body.error === 'string') message = body.error; } catch { /* no words from the server */ } if (!message && (res.status === 403 || res.status === 401)) refused(); if (!message && notReached(res.status)) { unreached(); throw new ApiError(UNREACHED); } reached(); throw message ? new ApiError(message, false, res.status) : new ApiError(`Server returned ${res.status}`, true, res.status); } reached(); if (res.status === 204) return undefined as T; return res.json() as Promise; } /** `source`: follow that source; absent: whichever started playing most recently. */ const follow = (source?: string) => (source ? `?source=${encodeURIComponent(source)}` : ''); export const getState = (source?: string) => quietly(`/api/state${follow(source)}`); /** A track as Cratarr's players hold it (decision 0031): the local player's own description. */ export type QueueTrack = { source: string; id: string; name: string; artist: string | null; album: string | null; album_id: string | null }; /** One of Cratarr's own players (Play On, decision 0031): the Android app on a tablet or a phone. */ export type PlayOnPlayer = { name: string; /** Connected now: it takes commands. */ online: boolean; /** Last heard from, epoch seconds. */ seen: number; playing: boolean; index: number; position_ms: number; duration_ms: number | null; volume: number; ducked: boolean; queue_length: number; /** Goes up when the queue changes: read the player by name for the new one. */ queue_version: number; /** Off, one or all (decision 0033, amended). */ repeat?: 'off' | 'one' | 'all'; shuffled?: boolean; /** Its sleep timer, while one is set: it pauses at `at_ms` (epoch ms) or when the playing track ends. */ sleep?: { at_ms: number | null; end_of_track: boolean } | null; /** Radio's switch (decision 0036, amended), what its inserts are like and how many follow each own track. */ radio?: boolean; radio_by?: 'track' | 'artist' | 'era'; radio_count?: number; /** Radio's and Autoplay's picks leave the owner's favourites out. */ skip_favourites?: boolean; /** Autoplay while on: its mode and switches. */ autoplay?: { mode: string; albums: boolean; restart: boolean } | null; /** The group it plays in, by its leader (decision 0037): a player of a group shows what the group plays. */ group?: string | null; /** How late its speaker sounds (ms): in a group it plays that much earlier. */ delay_ms?: number; /** `app` (the Android app), `kodi` (a Kodi Cratarr drives: it follows a group, never leads one) or `browser` (a page * that plays in a browser, a player while it is open: never in a group). */ kind?: 'app' | 'kodi' | 'browser'; /** Its link to Cratarr (decision 0045). */ link?: PlayerLink; track: QueueTrack | null; queue?: QueueTrack[]; }; /** A player's link as Cratarr knows it, and as the player last told (decision 0045); times epoch seconds, the * player's own epoch ms. */ export type PlayerLink = { offline_since: number | null; ended: 'replaced' | 'gone quiet' | 'closed' | 'server stopping' | null; ended_at: number | null; own: { connected_since?: number; failed: number; last_error?: string; address?: string; wait_until?: number; wait_why?: string } | null; }; export const getPlayers = () => quietly<{ players: PlayOnPlayer[] }>('/api/players'); export const getPlayer = (name: string) => quietly(`/api/players/${encodeURIComponent(name)}`); /** A row of Cratarr's diagnostics log (decision 0045): `at` and `last` epoch ms, `count` how often it came. */ export type LogRow = { id: number; at: number; last: number; count: number; level: 'error' | 'warn' | 'info' | 'debug' | 'trace'; area: string; place: string; what: string; fields: Record; origin: 'server' | 'device'; }; /** Which rows, newest first: a place (a player, or "server"), an area, the least serious level, the page before a row. */ export type LogQuery = { place?: string; area?: string; level?: string; before?: number; limit?: number }; export const getLog = (q: LogQuery) => { const p = new URLSearchParams(); for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== '') p.set(k, String(v)); return quietly<{ rows: LogRow[] }>(`/api/diagnostics?${p}`); }; /** What is logged by area now: the areas, the levels, each area raised for a while (`now`), until when (epoch ms). */ export type LogLevels = { areas: string[]; levels: string[]; now: Record; until: number | null }; export const getLogLevels = () => quietly('/api/diagnostics/levels'); export const setLogLevels = (areas: Record, hours: number) => request('/api/diagnostics/levels', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ areas, hours }) }); /** A track's ReplayGain (decision 0035): gains in dB, peaks linear; what is not known is left out. */ export type TrackGain = { trackGain?: number; albumGain?: number; trackPeak?: number; albumPeak?: number }; export const getTrackGain = (source: string, id: string) => quietly(`/api/track/${encodeURIComponent(source)}/${encodeURIComponent(id)}/gain`); /** A Kodi Cratarr plays on: its web server, its login (the password never comes back), Cratarr's address for it. */ export type KodiSetting = { name: string; url: string; user: string; password?: string; password_set?: boolean; stream_from: string }; export type PlayOnSettings = { webhooks: string[]; kodi: KodiSetting[]; stream_default: string | null }; export const getPlayOnSettings = () => request('/api/players/settings'); export const savePlayOnSettings = (s: { webhooks: string[]; kodi: KodiSetting[] }) => request('/api/players/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(s) }); /** A player's speaker delay (sync play, decision 0037), 0 to 500 ms. */ export const setPlayerDelay = (name: string, ms: number) => request<{ ms: number }>(`/api/players/${encodeURIComponent(name)}/delay`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ms }), }); /** A command for one of Cratarr's players (`{ command: 'play', tracks, index, position_ms }`, `{ command: 'pause' }`…). */ export const playerCommand = (name: string, command: Record) => request(`/api/players/${encodeURIComponent(name)}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(command), }); export const coverUrl = (np: { source: string; id: string }) => `/api/cover/${encodeURIComponent(np.source)}/${encodeURIComponent(np.id)}`; /** play: in file order · shuffle: random order · next: after the current track · last: at the end of the queue. */ export type PlayMode = 'play' | 'shuffle' | 'next' | 'last'; /** sent ≤ 240 of total playable tracks; unknown = lines left out (missing, or not known to the server yet). */ export type PlayResult = { player: string; sent: number; total: number; unknown: number }; export const playPlaylist = (playlistId: string, mode: PlayMode, source?: string) => post(`/api/playlists/${encodeURIComponent(playlistId)}/play${follow(source)}`, { mode }); // The queue (decision 0019): one track after the current one or at the end, playing on from an upcoming track, and a // new playlist made of what plays and what comes next (tracks in the library only). export const queueTrack = (id: string, mode: 'next' | 'last', source?: string) => post<{ player: string }>(`/api/queue/add${follow(source)}`, { id, mode }); /** Several tracks, in order (a crate's Play next / Add to queue, decision 0024). */ export const queueTracks = (ids: string[], mode: 'next' | 'last', source?: string) => post<{ player: string }>(`/api/queue/add${follow(source)}`, { ids, mode }); /** A crate (decision 0024): a permanent one or one's own; `count` null when not known (the server's favourites could * not be read). */ /** `favourites`: its toggle's setting; `counts`: its size with each setting (null: the favourites are not known). */ export type Crate = { id: string; name: string; favourites: CrateFavourites; counts: Record; permanent: boolean; /** What its card shows, as its favourites toggle is set; absent where that is not known (favourites not read). */ stats?: CrateStats; /** A picture of its own was set (crateImageUrl). */ image: boolean; }; /** A crate's card (decided 2026-10-16): its size and length, how much was never played (null: no owner account to ask * about), what came this week, its top three genres and up to four of its albums (most of its tracks first). */ export type CrateStats = { tracks: number; seconds: number; unplayed: number | null; new_week: number; genres: string[]; covers: { source: string; album_id: string }[]; }; /** One condition of a crate: an axis, what it is tested against, negated or not. */ export type CrateValue = { kind: 'yes' } | { kind: 'any_of'; values: string[] } | { kind: 'range'; min: number | null; max: number | null }; export type Condition = { axis: string; not: boolean; value: CrateValue }; export type CrateDef = { id: string; name: string; conditions: Condition[] }; /** An axis the builder draws a control for, with its domain in this library. */ export type Axis = { key: string; label: string; kind: 'yes_no' | 'values' | 'membership' | 'range'; values: string[]; min: number | null; max: number | null; known: number; }; export const getCrateAxes = (source?: string) => quietly(`/api/crates/axes${follow(source)}`); export const getCrate = (id: string) => quietly(`/api/crates/${encodeURIComponent(id)}`); /** Every change answers with its undo token (`undo`, decision 0026), for `withUndo`. */ type Undo = { undo: string | null }; export const createCrate = (name: string, conditions: Condition[]) => post<{ id: string } & Undo>('/api/crates', { name, conditions }); export const updateCrate = (id: string, name: string, conditions: Condition[]) => request<{ id: string } & Undo>(`/api/crates/${encodeURIComponent(id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, conditions }), }); export const deleteCrate = (id: string) => request<{ deleted: string } & Undo>(`/api/crates/${encodeURIComponent(id)}`, { method: 'DELETE' }); /** The builder's live preview: how many tracks unsaved conditions select, and the first of them. */ export const previewCrate = (conditions: Condition[], limit: number, source?: string) => { const q = new URLSearchParams({ limit: String(limit) }); if (source) q.set('source', source); return quietly<{ source: string; total: number; rows: PlaylistRow[] }>(`/api/crates/preview?${q}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conditions }), }); }; export const getCrates = (source?: string) => quietly(`/api/crates${follow(source)}`); /** A page of a crate's rows (newest first) and the crate's size. */ /** A crate's own picture (kept as JPEG within 800 px); `round` reads it again after a change. */ export const crateImageUrl = (id: string, round = 0) => `/api/crates/${encodeURIComponent(id)}/image${round ? `?v=${round}` : ''}`; /** Sets it: any JPEG, PNG, GIF or WebP, up to 32 MB. */ export const setCrateImage = (id: string, file: Blob) => request(`/api/crates/${encodeURIComponent(id)}/image`, { method: 'PUT', headers: { 'Content-Type': file.type || 'application/octet-stream' }, body: file }); export const removeCrateImage = (id: string) => request(`/api/crates/${encodeURIComponent(id)}/image`, { method: 'DELETE' }); /** The crate panel's favourites toggle (decision 0024, amended): all its tracks, only the favourites, or none. */ export type CrateFavourites = 'all' | 'only' | 'none'; /** `favourites`: the toggle's setting as the page shows it (a tap may be on its way); the saved one without. */ export const crateRows = (id: string, offset: number, limit: number, source?: string, favourites?: CrateFavourites) => { const q = new URLSearchParams({ offset: String(offset), limit: String(limit) }); if (favourites) q.set('favourites', favourites); if (source) q.set('source', source); return quietly<{ source: string; total: number; rows: PlaylistRow[]; genres: string[] }>(`/api/crates/${encodeURIComponent(id)}/rows?${q}`); }; /** A crate's tempo spread (decision 0041): its tracks' 5-BPM bands, 60 to 180, counted on the server. */ export type TempoBands = { bands: number[]; with_tempo: number; total: number }; export const crateTempo = (id: string, source?: string, favourites?: CrateFavourites) => { const q = new URLSearchParams(); if (favourites) q.set('favourites', favourites); if (source) q.set('source', source); return quietly(`/api/crates/${encodeURIComponent(id)}/tempo?${q}`); }; /** A playlist's facts for the Playlists page's table (decision 0042): its length, top genres, how much the owner never * played (null: no owner account) and when they last played any of it, and when its file last changed (epoch s). */ export type PlaylistStats = { seconds: number; genres: string[]; unplayed: number | null; last_played: number | null; changed: number }; export const playlistStats = () => quietly>('/api/playlists/stats'); /** Make playlist: the crate's tracks now, as its favourites toggle is set, as a new ordinary playlist (a snapshot). */ export const crateToPlaylist = (id: string, name: string, source?: string) => post<{ playlist: string; added: number } & Undo>(`/api/crates/${encodeURIComponent(id)}/playlist${follow(source)}`, { name }); /** A crate's favourites toggle, kept on the server for every device. */ export const setCrateFavourites = (id: string, favourites: CrateFavourites) => request<{ crate: string; favourites: CrateFavourites }>(`/api/crates/${encodeURIComponent(id)}/favourites`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ favourites }), }); export const jumpTo = (id: string, source?: string) => post<{ player: string }>(`/api/queue/jump${follow(source)}`, { id }); /** `ids`: the queue to make it from (the local player's); without, the server's player's queue. `repeats`: a track * the queue plays twice is in it twice (the page asked), else once (decision 0025, amended 2026-10-16). */ export const playlistFromQueue = (name: string, source?: string, ids?: string[], repeats = false) => post<{ playlist: string; added: number; left_out: number } & Undo>(`/api/queue/playlist${follow(source)}`, ids ? { name, ids, repeats } : { name, repeats }); /** Mark the playing track a favourite or not. Says the state wanted, so a retried request changes nothing more. */ export const setFavorite = (on: boolean, source?: string) => { const q = new URLSearchParams({ on: String(on) }); if (source) q.set('source', source); return quietly(`/api/favorite?${q}`, { method: 'POST' }); // shown at once (optimistic) }; /** Put the playing track in the playlist or take it out. Says the state wanted, so a retried request changes nothing more. */ export const setMember = (playlistId: string, member: boolean, source?: string) => { const q = new URLSearchParams({ member: String(member) }); if (source) q.set('source', source); return quietly(`/api/playlists/${encodeURIComponent(playlistId)}/toggle?${q}`, { method: 'POST' }); // shown at once }; export type Repair = { playlist: string; from: string; to: string }; export type Repairs = { safe: Repair[]; review: Repair[] }; export const getRepairs = () => request('/api/repairs'); const post = (path: string, body: unknown) => request(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); /** The radio's next tracks (decision 0036), already in the radio's lane: like `seed` (the radio's seed, or the queue's * own album or playlist), none of `exclude` (the queue), after `last` (the playing track). */ export const radioNext = (ask: { mode: string; seed: string[]; exclude: string[]; last: string | null; count: number; within?: string | null; albums?: boolean; /** Radio's inserts: what they are like (the track, its artist, its era). */ by?: string; /** Leave the owner's favourites out. */ skip_favourites?: boolean; }) => post<{ tracks: QueueTrack[] }>('/api/radio/next', ask); /** Apply every safe repair, or only those of these playlists (the panel's Repair tab, decision 0025). */ export const applySafe = (playlists?: string[]) => post<{ playlists: string[] } & Undo>('/api/repairs/apply', playlists ? { kind: 'safe', playlists } : { kind: 'safe' }); /** Apply one offered repair (used for the ones that need a look). */ export const applyOne = (r: Repair) => post<{ playlists: string[] } & Undo>('/api/repairs/apply', { kind: 'one', ...r }); /** The one undo (decision 0026): an action's tokens, undone newest first; the redo's tokens come back (none for * Incoming's, whose albums' titles come back instead). */ export const undo = (tokens: string[]) => post<{ redo: string[]; restored: string[] }>('/api/undo', { tokens }); export const createPlaylist = (name: string) => post<{ playlist: string } & Undo>('/api/playlists', { name }); /** What to export (decision 0007): playlists by name, with their tracks or not, the tracks in one folder or the * library's, and the playlists as they are or combined into one of this name. */ export type ExportChoice = { playlists: string[]; tracks: boolean; flat: boolean; combine: string | null }; /** What an export will hold, before starting: `bytes` only with tracks; missing tracks are left out. */ export type ExportPreview = { tracks: number; missing: number; bytes: number | null }; export const exportPreview = (c: ExportChoice) => quietly('/api/export/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(c) }); /** The download itself: a link, so the browser shows its progress and can cancel it. */ export function exportUrl(c: ExportChoice): string { const q = new URLSearchParams(); for (const p of c.playlists) q.append('playlist', p); if (c.tracks) q.set('tracks', '1'); if (c.tracks && c.flat) q.set('flat', '1'); if (c.combine !== null) q.set('combine', c.combine); return `/api/export?${q}`; } /** A playlist's first albums (up to four, in playlist order), for the mosaic in its menu (decision 0021). */ /** One line of a playlist for its view (decision 0023): what the catalogue knows of its file; `id` null when not matched * to the server, `missing` when the file is not in the Music folder. */ export type PlaylistRow = { entry: string; file: string; id: string | null; name: string | null; artist: string | null; album: string | null; duration_secs: number | null; missing: boolean; /** A playlist's rows, for the panel's orders: from the file's tags, */ album_artist?: string; disc?: number; track?: number; year?: number; /** its tempo (the BPM tag, else the analysis', decision 0035), */ bpm?: number; /** the user's plays of the track and when it was last played (epoch seconds), */ play_count?: number; last_played?: number; /** and when the entry came (none for one held before Cratarr kept that). */ added?: number; /** Its album's id, where the rows are of several albums (an artist's). */ album_id?: string; }; /** Take a playlist's repeats out, the first copy of each track staying (decision 0025, amended 2026-10-16). */ export const removeRepeats = (id: string) => request<{ playlist: string } & Undo>(`/api/playlists/${encodeURIComponent(id)}/repeats`, { method: 'DELETE' }); /** Add tracks (ids on `source`, in order) at a playlist's end, whether or not it holds them already: Add again. */ export const appendTracks = (id: string, source: string, ids: string[]) => post<{ playlist: string; added: number } & Undo>(`/api/playlists/${encodeURIComponent(id)}/append`, { source, ids }); export const playlistRows = (id: string, source?: string) => quietly<{ source: string; rows: PlaylistRow[] }>( `/api/playlists/${encodeURIComponent(id)}/rows${source ? `?source=${encodeURIComponent(source)}` : ''}`, ); /** Saves a playlist edited in its view (decision 0023): its lines reordered or some left out (`entries`), replacing * `expected` (the lines as the view showed them); refused when it changed since. */ export const editPlaylist = (id: string, expected: string[], entries: string[]) => request<{ playlist: string; entries: number } & Undo>(`/api/playlists/${encodeURIComponent(id)}/entries`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expected, entries }), }); export const playlistCovers = (id: string) => quietly<{ source: string; album_id: string }[]>(`/api/playlists/${encodeURIComponent(id)}/covers`); export const renamePlaylist = (id: string, to: string) => post<{ playlist: string } & Undo>(`/api/playlists/${encodeURIComponent(id)}/rename`, { to }); export const deletePlaylist = (id: string) => request<{ deleted: string } & Undo>(`/api/playlists/${encodeURIComponent(id)}`, { method: 'DELETE' }); /** The other names a playlist is spoken as, exactly these. */ /** The names a playlist is also spoken as, from what was typed (comma-separated). */ export const spokenNames = (text: string) => text .split(',') .map((s) => s.trim()) .filter(Boolean); export const setSpoken = (id: string, names: string[]) => request(`/api/playlists/${encodeURIComponent(id)}/spoken`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ names }), }); const linkPath = (id: string, target: string, extra: Record = {}) => `/api/playlists/${encodeURIComponent(id)}/link?${new URLSearchParams({ target, ...extra })}`; /** Link a playlist to a target, or change how it is kept (the state wanted: safe to repeat). */ export const linkPlaylist = (id: string, target: string) => request(linkPath(id, target), { method: 'PUT' }); /** Take a link off; its copy on the target is deleted. */ export const unlinkPlaylist = (id: string, target: string) => request(linkPath(id, target), { method: 'DELETE' }); export type Status = { connected: boolean; /** The server's name with one source; "n of m sources" with several. */ server_name: string | null; /** A Music library is set and its folder found: playlists can be read and made. */ music: boolean; /** Some enabled server has an Incoming library. */ incoming: boolean; sources: SourceTab[]; version: string; /** What is wrong with the servers, one line each (a server that stopped processing changes). */ problems: string[]; }; export const getStatus = () => quietly('/api/status'); export type FieldKind = 'url' | 'text' | 'secret' | 'choice' | 'multi_choice'; /** A setting a source plugin asks for; the Settings page draws its form from these. */ export type Field = { key: string; label: string; kind: FieldKind; required: boolean; help: string | null; /** Secrets: changing one of these fields needs the secret entered again. */ bound_to: string[]; }; export type Choice = { value: string; label: string }; export type SourceStatus = { connected: boolean; server_name: string | null; version: string | null; detail: string | null; error: string | null; }; export type Values = Record; /** What is saved for a source. Secrets left blank keep the saved ones (while their bound fields stay the same). */ /** What a server's libraries are used for, by library id (decision 0009). Libraries in neither list are not used. */ /** A library's own settings (decision 0034): what a move into it does to the files, and its dot's colour. */ export type LibraryPolicy = { format?: 'cd' | 'as_is'; colour?: string | null }; export type Roles = { music: string[]; incoming: string[]; policies?: Record }; export type LibraryUse = 'music' | 'incoming' | 'unused'; /** A server's music library and where Cratarr sees its files. */ export type ServerLibrary = { id: string; name: string; /** Cratarr's folders its files were found in; empty when Cratarr can't see them. */ folders: string[]; /** Its folders as the server describes them, to say what to mount. */ paths: string[]; /** Cratarr sees its files but cannot write there. */ read_only: boolean; }; export type SourceConfig = { id: string; kind: string; enabled: boolean; libraries: Roles; values: Values }; export type SourceView = SourceConfig & { label: string; /** Secrets are never sent; these are the ones that are set. */ secrets_set: string[]; status: SourceStatus; choices: Record; server_libraries: ServerLibrary[]; error: string | null; /** Each used library's settings as they apply (its own, else its use's and the palette's). */ policies?: Record; }; export type Kind = { id: string; label: string; fields: Field[] }; export type Settings = { sources: SourceView[]; kinds: Kind[]; /** Whether the sources come from the settings file or (first run) the container's environment. */ origin: 'file' | 'env'; fixed: { bind: string }; /** Null when the server has no data folder: settings can be looked at, not saved. */ settings_file: string | null; playlists: number; version: string; /** Epoch seconds. */ started: number; /** Where the Music libraries' files are, as Cratarr sees them: the playlists live there. */ music_folder: string | null; /** Null when no Lidarr is set. */ lidarr: LidarrView | null; tag_rules: TagRules; /** How long albums deleted from Incoming stay in the trash. */ trash_days: number; catalogue: CatalogueStatus | null; /** The saved naming template of Music; null: the one the library already follows. */ naming: string | null; /** The libraries' colours to choose from (decision 0034). */ palette: string[]; }; /** A naming template measured against Music (decision 0013). */ export type NamingView = { template: string; folders: { matched: number; total: number }; files: { matched: number; total: number }; /** An album of the library named by it: folder/first file. */ example: string | null; }; /** Measure a template as typed; without one, the template in use. */ export const checkNaming = (template?: string) => quietly(`/api/settings/naming${template === undefined ? '' : `?template=${encodeURIComponent(template)}`}`); /** How tag values are split when read; the files are never changed. */ export type TagRules = { artist_separators: string[]; genre_separators: string[]; /** Names containing a separator that are one artist (AC/DC). */ artist_keep: string[]; /** Genres containing a separator that are one genre ("Funk / Soul"). */ genre_keep: string[]; }; export type CatalogueStatus = { folders: { path: string; files: number }[]; files: number; tags_pending: number; tags_failed: number; /** Files with a known id, per server id. */ linked: Record; /** Files with a problem, per kind (see FileProblem). */ issues: Partial>; progress: { doing: string | null; last_pass: number | null; problems: string[] }; }; export const getSettings = () => request('/api/settings'); export const getCatalogue = () => quietly('/api/catalogue'); /** Folders, chosen and shown (decision 0044): a folder in the browser, with how much audio is in it (`more`: past the * count) and whether Cratarr can write there. */ export type FolderEntry = { name: string; path: string; audio: number; more: boolean; writable: boolean }; /** What the browser shows at a place (`path` null: the top, Cratarr's mounts); `parent` null: up is the top. */ export type FolderListing = { path: string | null; parent: string | null; folders: FolderEntry[] }; export const browseFolders = (path?: string | null) => request(`/api/folders${path ? `?path=${encodeURIComponent(path)}` : ''}`); /** A chosen folder's row: whether it is there and writable, its disk's free bytes, and the tracks Cratarr knows in it. */ export type FolderStatus = { exists: boolean; writable: boolean; free: number | null; tracks: number | null }; export const folderStatus = (path: string) => quietly(`/api/folders/status?path=${encodeURIComponent(path)}`); /** Music folders Cratarr found that are not chosen yet. */ export const suggestedFolders = () => quietly<{ folders: string[] }>('/api/folders/suggested'); /** A file the catalogue could not read properly. */ export type FileProblem = { folder: string; rel: string; /** broken: cannot be read · damaged_tag: one field unreadable, rest fine · no_tags · no_length */ kind: 'broken' | 'damaged_tag' | 'no_tags' | 'no_length'; detail: string | null; }; export const getFileProblems = () => request('/api/catalogue/problems'); /** The Subsonic accounts (decision 0030): names only, passwords are never sent back. */ export type SubsonicAccounts = { accounts: string[] }; export const getSubsonicAccounts = () => quietly('/api/subsonic/accounts'); /** The first account; refused once one exists. */ export const createSubsonicAccount = (username: string, password: string) => post('/api/subsonic/accounts', { username, password }); export const changeSubsonicPassword = (username: string, current: string, password: string) => post('/api/subsonic/password', { username, current, password }); /** A device signed in on Cratarr's sign-in page (decision 0039); `current`: the one this page runs on. */ export type SignedIn = { id: string; user: string; device: string; created: number; last_used: number; current: boolean }; export const getSignedIn = () => quietly<{ sessions: SignedIn[] }>('/api/auth/sessions'); /** Signs that device out: its next request goes to the sign-in page. */ export const signOutDevice = (id: string) => request(`/api/auth/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }); /** Sign-in everywhere (decision 0043): whether every request needs a sign-in, and switching it (the password again). */ export const getSignInRequired = () => quietly<{ required: boolean }>('/api/auth/required'); export const setSignInRequired = (on: boolean, password: string) => request<{ required: boolean }>('/api/auth/required', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ on, password }) }); /** An API key (decision 0043), for what cannot sign in on a page (Home Assistant, scripts): never the key itself. */ export type ApiKey = { id: string; name: string; created: number; last_used: number | null }; export const getApiKeys = () => quietly<{ keys: ApiKey[] }>('/api/auth/keys'); /** A new key: `key` is shown in this answer only, never again. */ export const newApiKey = (name: string, password: string) => post<{ key: string; id: string; name: string }>('/api/auth/keys', { name, password }); export const revokeApiKey = (id: string) => request(`/api/auth/keys/${encodeURIComponent(id)}`, { method: 'DELETE' }); /** A directory (LDAP) asked first at sign-in (decision 0040): set by the operator in `ldap.json`, shown read-only. */ export type DirectoryConfig = { enabled: boolean; url: string; starttls: boolean; base_dn: string; user_attribute: string; group: string; bind_dn: string; bind_password_file: string; accounts: Record; }; export type DirectorySettings = { config: DirectoryConfig; bind_password: 'set' | 'not set' | 'unreadable' }; export const getDirectory = () => quietly('/api/auth/ldap'); /** Importing an account's plays, favourites, ratings and added dates from a server into a Subsonic account. */ export type ImportWhat = { plays: boolean; favorites: boolean; ratings: boolean; added: boolean }; /** A server that can import: its accounts (`id` is the server's), and the choices made last time. */ export type ImportSource = { id: string; users: { id: string; name: string }[]; user?: string | null; account?: string | null; what?: ImportWhat; error?: string; }; export type ImportSources = { accounts: string[]; sources: ImportSource[] }; export type ImportRequest = { source: string; user: string; account: string; what: ImportWhat; preview: boolean }; export type ImportCounts = { plays: number; songs: number; starred: number; unstarred: number; rated: number; added: number; skipped: number }; export type ImportJob = ImportRequest & { started: number; finished: number | null; step: { name: 'tracks' | 'groups' | 'added'; read: number; total: number | null } | null; counts: ImportCounts; error: string | null; note: string | null; }; export const getImportSources = () => quietly('/api/subsonic/import/sources'); /** The import running now, or the last one. */ export const getImport = () => quietly<{ job: ImportJob | null }>('/api/subsonic/import'); export const startImport = (r: ImportRequest) => post<{ job: ImportJob }>('/api/subsonic/import', r); /** Lidarr on the Settings page; the key is never sent. */ export type LidarrView = { url: string; key_set: boolean; version: string | null; error: string | null }; /** What the page sends for Lidarr: a blank key keeps the saved one while the address stays; a blank address removes it. */ export type LidarrDraft = { url: string; api_key: string }; /** Check a Lidarr as filled in; nothing is saved. Null for a blank address. */ export const previewLidarr = (draft: LidarrDraft) => post('/api/settings/lidarr', draft); /** `lidarr` left out: keep the saved one. */ export const saveSettings = ( sources: SourceConfig[], extra: { tag_rules: TagRules; lidarr?: LidarrDraft; trash_days: number; naming?: string }, ) => request('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sources, ...extra }), }); /** One source as it is being filled in: status, choices and libraries, nothing saved. */ export const previewSource = (source: SourceConfig) => post('/api/settings/preview', { source }); export type AlbumTrack = { file: string; key: string | null; bytes: number; seconds: number | null; kbps: number | null; plays: number; favourite: boolean; }; export type Album = { folder: string; tracks: AlbumTrack[]; total_bytes: number; cover_bytes: number | null; plays: number; favourites: number; playlist_lines: [string, number][]; }; export type Comparison = { same_track_count: boolean; matched: number; same_length: number; same_size: number; verdict: 'same' | 'similar' | 'different'; }; export type AlbumGroup = { albums: Album[]; comparisons: Comparison[]; suggestion: { keep: string; reasons: string[] }; }; export const getAlbumDuplicates = () => request<{ groups: AlbumGroup[] }>('/api/albums/duplicates'); /** An album in a server's library list (Incoming, Collection). */ export type ListedAlbum = { id: string; title: string; artist: string | null; year: number | null; /** When the source added it, epoch seconds. */ added: number | null; path: string | null; tracks: number; /** e.g. "FLAC", "MP3"; null when mixed or unknown. */ format: string | null; /** The lowest track bitrate. */ bitrate_kbps: number | null; /** The source's own tags, as they are. */ tags: string[]; /** The library it was listed from (the filter's library switches). */ library?: string; /** Its artists one by one, when the source credits several; absent: `artist` alone. */ artists?: string[]; }; /** One server's album list. */ export type ShelfSource = { id: string; label: string; /** False when this kind of source cannot list albums at all. */ supported: boolean; albums: ListedAlbum[]; error: string | null; /** When the server read this list from the source, epoch seconds. */ as_of: number; /** The libraries it was read from (the filter's library switches). */ libraries?: string[]; /** Those libraries' names, uses, colours and formats (the Library's list, decision 0034). */ library_info?: LibraryInfo[]; }; /** A used library of a server (decision 0034): the dot on its albums (none for the primary, Music), and what a move * into it does to the files. */ export type LibraryInfo = { id: string; name: string; use: 'music' | 'incoming'; colour: string | null; format: 'cd' | 'as_is' }; /** An album of a library other than Music, as it stands to Music: an upgrade of an album there, or a duplicate. */ export type Verdict = { kind: 'upgrade' | 'duplicate'; library: string; why: string }; /** A server's albums of every used library in one list (decision 0034), with what holds between them. */ export type AlbumsSource = ShelfSource & { library_info: LibraryInfo[]; verdicts: Record; /** Per Music album: an album of another library that upgrades or duplicates it. */ waiting: Record; deletable: string[]; }; export type AlbumsView = { sources: AlbumsSource[]; trash_days: number; music: string | null }; /** Every album of each server's used libraries (the Library page). `fresh` asks past the service worker's copy (which * answers at once, see sw.js) for the server's latest. */ export const getAlbums = (source?: string, fresh = false) => quietly(`/api/albums${follow(source)}`, fresh ? { cache: 'no-cache' } : undefined); /** Play an album of a connected server on the followed player; answers with the player's label. */ export const playAlbum = (source: string, album: string) => request<{ player: string }>(`/api/albums/${encodeURIComponent(source)}/${encodeURIComponent(album)}/play`, { method: 'POST' }); /** Make an album a favourite or not (the album panel's heart). */ export const setAlbumFavorite = (source: string, album: string, on: boolean) => request<{ favorite: boolean }>(`/api/albums/${encodeURIComponent(source)}/${encodeURIComponent(album)}/favorite?on=${on}`, { method: 'POST' }); /** The album's cover as the server has it (no picture chosen on this device, no reading again). */ export const serverCoverUrl = (source: string, album: string) => `/api/albums/${encodeURIComponent(source)}/${encodeURIComponent(album)}/cover`; /** The album's cover as this device shows it: a picture chosen here, else the server's (read again: a new address). */ export const albumCoverUrl = (source: string, album: string) => { const key = coverKey(source, album); const round = coverRounds[key]; return localCovers[key] ?? `${serverCoverUrl(source, album)}${round ? `?v=${round}` : ''}`; }; /** How the owner used an album (decided 2026-10-16: the Library's orders and filters): its plays, when last played * (epoch seconds), its rating (0: none). */ export type AlbumUse = { plays: number; played?: number; rating: number }; /** Every album of a server played or rated, by id; read fresh each time (plays change). */ export const getAlbumUses = (source: string) => quietly>(`/api/album-uses/${encodeURIComponent(source)}`); /** An album's rating, 0 to 5; null when its server keeps none. */ export const getAlbumRating = (source: string, album: string) => quietly<{ rating: number | null }>(`/api/albums/${encodeURIComponent(source)}/${encodeURIComponent(album)}/rating`); export const setAlbumRating = (source: string, album: string, stars: number) => request<{ rating: number }>(`/api/albums/${encodeURIComponent(source)}/${encodeURIComponent(album)}/rating?stars=${stars}`, { method: 'POST' }); export type Transport = 'previous' | 'play-pause' | 'next' | 'stop'; // The local player (decision 0022): a playlist's track ids, a track's details, and handing a queue to the server's // player (from a track and a position). export const playlistTrackIds = (id: string, source?: string) => request<{ source: string; ids: string[]; unknown: number }>(`/api/playlists/${encodeURIComponent(id)}/tracks${follow(source)}`); const trackPath = (source: string, id: string) => `/api/track/${encodeURIComponent(source)}/${encodeURIComponent(id)}`; export const trackDetails = (source: string, id: string) => quietly<{ id: string; name: string; artist: string | null; album: string | null; album_id: string | null }>(trackPath(source, id)); /** A given track's playlists and favourite: for the local player's track (decision 0022), what `State` is for the * server's player's. `favorite` null: not known. */ export type TrackState = { addable: boolean; album_id: string | null; favorite: boolean | null; playlists: Playlist[]; info: TrackInfo | null }; export const trackState = (source: string, id: string) => quietly(`${trackPath(source, id)}/playlists`); /** A track's file as it is (the track menu's Details): where it lies (`path` within `folder`), its audio, its size, * when it was changed and came in (epoch s), and every tag in the file's order. */ export type TrackFile = { path: string; folder: string | null; format: string | null; bitrate_kbps: number | null; sample_rate_hz: number | null; bit_depth: number | null; channels: number | null; duration_ms: number | null; /** Its tempo (decision 0035): the BPM tag, else the analysis'; none without a beat. */ bpm?: number | null; size: number | null; modified: number | null; added: number | null; /** Its genres as a genre in the panel matches them. */ genres: string[]; tags: { name: string; value: string }[]; }; export const trackFile = (source: string, id: string) => quietly(`${trackPath(source, id)}/details`); /** Put the track in the playlist or take it out (the state wanted, as `setMember`). */ export const setTrackMember = (source: string, id: string, playlistId: string, member: boolean) => quietly(`${trackPath(source, id)}/playlists/${encodeURIComponent(playlistId)}?member=${member}`, { method: 'POST' }); export const setTrackFavorite = (source: string, id: string, on: boolean) => quietly(`${trackPath(source, id)}/favorite?on=${on}`, { method: 'POST' }); /** Which of these tracks (ids on `source`) are favourites, in one request: the hearts on a list's rows (decision 0023). * Tracks the server cannot tell about are left out. */ export const getFavorites = (source: string, ids: string[]) => quietly>(`/api/favorites/${encodeURIComponent(source)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }), }); /** The favourite albums of a server (their ids): the Library's Favourites only and the albums' hearts. */ export const getFavoriteAlbums = (source: string) => quietly(`/api/favorites/${encodeURIComponent(source)}/albums`); /** Which playlists (by name) are favourites, as Finamp's playlist hearts (decision 0023). */ export const getPlaylistFavorites = (source?: string) => quietly>(`/api/playlists/favorites${follow(source)}`); export const setPlaylistFavorite = (id: string, on: boolean, source?: string) => { const q = new URLSearchParams({ on: String(on) }); if (source) q.set('source', source); return quietly<{ playlist: string; favorite: boolean }>(`/api/playlists/${encodeURIComponent(id)}/favorite?${q}`, { method: 'POST' }); }; /** The local player played most of the track: one play counts in the media server (decision 0022). */ export const trackPlayed = (source: string, id: string) => quietly<{ counted: boolean }>(`${trackPath(source, id)}/played`, { method: 'POST' }); /** An album as its view in the panel shows it: its tracks in play order, as a playlist's rows (decision 0023). */ /** `id`: the album's, another than asked for when it moved (Keep, Replace) and the server followed it. */ export type AlbumView = { source: string; id: string; title: string; artist: string | null; year: number | null; genres: string[]; /** "FLAC", "MP3"…; null when mixed or unknown. */ format: string | null; /** Its weakest track's bitrate. */ bitrate_kbps: number | null; rows: PlaylistRow[]; /** When it came and when it was last played (epoch seconds), and how often (the panel's About); absent where the * server cannot say. */ added?: number; played?: number; plays?: number; }; export const albumView = (source: string, album: string) => quietly(`/api/albums/${encodeURIComponent(source)}/${encodeURIComponent(album)}`); /** An artist as their panel shows them (decided 2026-10-16, as Symfonium's), by name as the albums credit them: their * albums and the albums they appear on, newest first, and their tracks album by album with the plays. */ export type ArtistView = { source: string; name: string; albums: ListedAlbum[]; appears_on: ListedAlbum[]; rows: PlaylistRow[]; /** Null where the server keeps no favourite artists. */ favorite?: boolean; }; export const artistView = (source: string, name: string) => quietly(`/api/artists/${encodeURIComponent(source)}?name=${encodeURIComponent(name)}`); /** Whether an artist is a favourite (the heart on an album's artist chip); null where the server keeps none. */ export const artistFavorite = (source: string, name: string) => quietly<{ favorite: boolean | null }>(`/api/artists/${encodeURIComponent(source)}/favorite?name=${encodeURIComponent(name)}`); export const setArtistFavorite = (source: string, name: string, on: boolean) => request<{ favorite: boolean }>(`/api/artists/${encodeURIComponent(source)}/favorite?name=${encodeURIComponent(name)}&on=${on}`, { method: 'POST' }); /** Where an album of Cratarr's own library was left (Resume): a track and a position, from the bookmarks apps keep * (decision 0030); `track` null when there is none. */ export const albumResume = (album: string) => quietly<{ track: string | null; position_secs?: number }>(`/api/library/albums/${encodeURIComponent(album)}/resume`); /** The album's resume point gone. */ export const forgetAlbumResume = (album: string) => request(`/api/library/albums/${encodeURIComponent(album)}/resume`, { method: 'DELETE' }); export const handover = (ids: string[], index: number, positionSecs: number, source?: string) => post<{ player: string }>(`/api/player/handover${follow(source)}`, { ids, index, position_secs: positionSecs }); /** Previous / play-pause / next on the player that plays now. */ export const transport = (command: Transport, source?: string) => quietly<{ player: string }>(`/api/player/${command}${follow(source)}`, { method: 'POST' }); // shown at once /** What a move would do with an album: its new folder in the library, or why not (`needs_year`: ask for the year). */ export type KeepPreview = { title: string; folder: string | null; files: number; refused: { kind: 'needs_year' } | { kind: 'other'; detail: string } | null; /** Hi-res files converted to 16-bit/44.1 kHz first (the move then waits in the queue). */ convert: number; /** Whether a Lidarr is set to be told to stop fetching it; without one it may fetch the album again. */ acquisition: boolean; }; /** What a move or Replace did: done (`undo`), or queued until its hi-res files are converted (`queued`). */ export type AlbumMoved = { undo: string | null; title: string; folder: string; queued: boolean }; const albumPath = (source: string, album: string) => `/api/albums/${encodeURIComponent(source)}/${encodeURIComponent(album)}`; /** What moving an album into library `to` would do (decision 0034): its folder there, or why it can't. */ export const movePreview = (source: string, album: string, to: string, year?: string) => request(`${albumPath(source, album)}/move?to=${encodeURIComponent(to)}${year ? `&year=${encodeURIComponent(year)}` : ''}`); /** Move an album into another used library of its server, named and converted that library's way (Undo brings it * back); into a library of CD format with hi-res files it is queued. */ export const moveAlbum = (source: string, album: string, to: string, year?: string) => request(`${albumPath(source, album)}/move`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(year ? { to, year } : { to }), }); /** Delete an album of any library, into that library's trash (Undo brings it back). */ export const deleteAlbum = (source: string, album: string) => request<{ undo: string; title: string }>(`${albumPath(source, album)}/delete`, { method: 'POST' }); /** What Replace would do with an upgrade: the album in Music it replaces, how many of its tracks the new files take * over, new tracks, and where it goes; or why not (`unmatched`: old tracks without a match, by title). */ export type ReplacePreview = { title: string; old: string; folder: string | null; tracks: number; added: number; refused: { kind: 'needs_year' } | { kind: 'unmatched'; detail: string[] } | { kind: 'other'; detail: string } | null; convert: number; /** Whether a Lidarr is set to be told to stop fetching it; without one it may fetch the album again. */ acquisition: boolean; }; const replacePath = (source: string, album: string) => `/api/incoming/replace/${encodeURIComponent(source)}/${encodeURIComponent(album)}`; export const replacePreview = (source: string, album: string, year?: string) => request(`${replacePath(source, album)}${year ? `?year=${encodeURIComponent(year)}` : ''}`); /** Replace the album in Music with an upgrade: the old files go to the trash (Undo brings them back). */ export const replaceIncoming = (source: string, album: string, year?: string) => request(replacePath(source, album), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(year ? { year } : {}), }); /** An album in the conversion queue: waiting, converting (`done` of `total` files), or just finished. */ export type Job = { id: number; source: string; album_id: string; title: string; action: 'keep' | 'replace'; state: 'waiting' | 'converting' | 'done' | 'failed'; done: number; /** How far the file being converted is, 0 to 1. */ part: number; total: number; folder: string; undo: string | null; why: string | null; }; export const getJobs = () => quietly('/api/incoming/jobs'); /** Take an album out of the queue while it waits or converts. */ export const cancelJob = (id: number) => request(`/api/incoming/jobs/${id}/cancel`, { method: 'POST' }); /** A name with a count: "Downtempo", 120 albums. */ export type Count = { name: string; n: number }; export type Insights = { library: { tracks: number; albums: number; artists: number; duration_ms: number; bytes: number; /** Tracks per format. */ formats: Count[]; lossless_albums: number; below_320_albums: number; /** Albums per decade, oldest first ("1970s"). */ decades: Count[]; /** Albums per genre / album artist, most first. */ genres: Count[]; top_artists: Count[]; }; playlists: { playlists: number; distinct_tracks: number; in_one_playlist: number; missing: number; library_tracks_in_playlists: number; albums_without_playlist: number; most_shared: { title: string; artist: string | null; playlists: number }[]; }; proximity: Proximity; }; /** Playlists as points and how close each two are, by three signals (0..1 each). */ export type Proximity = { /** `focus`: how concentrated its genres are, 0..1 (null without genre tags). */ playlists: { name: string; tracks: number; genres: string[]; focus: number | null }[]; links: { a: number; b: number; tracks: number; genre: number; artists: number; shared_tracks: number; shared_artists: number; genres: string[]; }[]; }; export type Signal = 'tracks' | 'genre' | 'artists'; export const getInsights = () => request('/api/insights'); /** A sync group's visuals (decision 0046, amended): the settings that shape the picture, and the version the server * gave them. None yet: a 404. */ export type GroupVisuals = { version: number; settings: Record }; export const getGroupVisuals = (group: string) => quietly(`/api/groups/${encodeURIComponent(group)}/visuals`); /** Settings into a group's visuals (the server merges them key by key, the routes route by route): `base` 0 only where * the group has none yet (else a 409); `writer` names this page, so it knows its own change when it comes back. */ export const putGroupVisuals = (group: string, body: { base: number; settings: Record; writer: string }) => quietly<{ version: number }>(`/api/groups/${encodeURIComponent(group)}/visuals`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); /** What the visuals' governor learned from the owner's thumbs (learning.ts): each limit's value, the step its next * change takes and which way it last went, as of `at` (ms); per user. `version` 0: nothing learned yet. */ export type VisualsLimits = { version: number; limits: Record; at: number }; export const getVisualsLimits = () => quietly('/api/visuals/limits'); /** What was learned, over the version `base` (0 where there is none yet; another a 409: load and apply it again). */ export const putVisualsLimits = (body: { base: number; limits: VisualsLimits['limits']; at: number }) => quietly<{ version: number }>('/api/visuals/limits', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); /** What was learned, forgotten: the limits where they started. */ export const forgetVisualsLimits = () => quietly('/api/visuals/limits', { method: 'DELETE' }); /** A thumb, with what the visuals did over the 20 seconds before it and what it changed, for the periodic fit. */ export const postVisualsThumb = (body: Record) => quietly('/api/visuals/thumbs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); /** A saved look of the visuals: the settings that shape the picture, named (after the track it was saved on). */ export type VisualsSnapshot = { id: string; name: string; settings: Record; /** Epoch seconds. */ created: number; /** A small picture of the look (a JPEG data URL); none for the first ones saved. */ picture?: string | null; }; export const getVisualsSnapshots = () => quietly('/api/visuals/snapshots'); export const saveVisualsSnapshot = (name: string, settings: Record, picture: string | null = null) => request('/api/visuals/snapshots', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(picture ? { name, settings, picture } : { name, settings }), }); export const renameVisualsSnapshot = (id: string, name: string) => request(`/api/visuals/snapshots/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); /** A snapshot saved over: the visuals' settings now and a new picture, its name (changed, where given) and time kept. */ export const saveOverVisualsSnapshot = (id: string, settings: Record, picture: string | null, name?: string) => request(`/api/visuals/snapshots/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ settings, ...(picture ? { picture } : {}), ...(name ? { name } : {}) }), }); export const deleteVisualsSnapshot = (id: string) => request(`/api/visuals/snapshots/${encodeURIComponent(id)}`, { method: 'DELETE' }); ===== src/lib/app.svelte.ts ===== // The Android app (decision 0031): on the server's own pages inside it, `window.CratarrApp` carries JSON messages both // ways (android/README.md). There the music plays in the app itself; the page is its face (localPlayer.svelte.ts). type Port = { postMessage(message: string): void; onmessage: ((event: { data: string }) => void) | null }; type Message = { type: string; [key: string]: unknown }; const port = typeof window === 'undefined' ? null : ((window as unknown as { CratarrApp?: Port }).CratarrApp ?? null); /** Whether this page runs in the Android app. */ export const inApp = !!port; /** What the app told about itself: its version, and this device's settings (the Settings page's "This device"). */ export const appInfo = $state({ version: null as string | null, /** This player's name, as Home Assistant and the other players see it. */ player: null as string | null, /** Network (after Finamp's): the public address; the local network address, used while preferred and answering; the * address in use. `addresses`: the app knows two (an app from before knew one, its `server`). */ publicAddress: null as string | null, localAddress: null as string | null, preferLocal: false, inUse: null as string | null, addresses: false, /** An address typed in Network where Cratarr does not answer (not kept); null after any other answer. */ refused: null as string | null, /** Kept awake and listening for Play On while idle. */ stayReady: false, /** Android lets the app run in the background (left out of battery optimisation). */ batteryAllowed: true, /** Downloads as the files are, or as Cratarr's smaller copies (decision 0033, step 4). */ downloadQuality: 'original' as 'original' | 'smaller', /** Downloads over mobile data too, not only over Wi-Fi. */ downloadOverMobile: false, /** Kept on this device (decision 0038): the storage budget in GB, 0 for no limit; syncing only while charging. */ syncBudgetGb: 20, syncOnlyCharging: false, /** Levelling (decision 0035): the app's player plays every track about as loud as the next. */ levelling: true, }); /** A track kept on the device: as the page described it when it was downloaded, and how far the download is. */ export type DownloadedTrack = { source: string; id: string; name: string; artist: string | null; album: string | null; album_id: string | null; download: { id: string; state: 'done' | 'downloading' | 'waiting' | 'failed'; bytes: number; percent: number }; }; /** The owner's downloads on this device, as the app last told them (`known` once it has). */ export const downloads = $state({ tracks: [] as DownloadedTrack[], bytes: 0, known: false }); const handlers = new Map void>(); /** Calls `handle` with each message of `type` from the app. */ export function onApp(type: string, handle: (m: Message) => void) { handlers.set(type, handle); } export function toApp(message: Message) { port?.postMessage(JSON.stringify(message)); } /** "Test both connections": whether each address answered (null: not set), once the app has tried. */ export const networkTest = $state({ testing: false, local: null as boolean | null, public: null as boolean | null, signedOut: false, done: false }); /** Tries both addresses (the app answers with a `network-test`). */ export function testNetwork() { networkTest.testing = true; networkTest.done = false; toApp({ type: 'network-test' }); } /** Changes this device's settings; the app answers with all of them. */ export const setDevice = (change: { player?: string; /** The public address (`server` too, for an app from before there were two). */ public?: string; server?: string; local?: string; preferLocal?: boolean; stayReady?: boolean; downloadQuality?: string; downloadOverMobile?: boolean; syncBudgetGb?: number; syncOnlyCharging?: boolean; levelling?: boolean; }) => toApp({ type: 'device-set', ...change }); /** Keeps these tracks on the device, in the quality the settings say. */ export const download = (tracks: { source: string; id: string; name: string; artist: string | null; album: string | null; album_id: string | null }[]) => toApp({ type: 'download', tracks: tracks.map(({ source, id, name, artist, album, album_id }) => ({ source, id, name, artist, album, album_id })) }); /** Asks the app for its downloads; it tells them again on every change. */ export const askDownloads = () => toApp({ type: 'downloads' }); /** Takes downloads off the device: these (by their download id), or all. */ export const removeDownloads = (ids: string[] | 'all') => toApp(ids === 'all' ? { type: 'download-remove', all: true } : { type: 'download-remove', ids }); /** Opens Android's dialog that lets the app run in the background. */ export const allowBackground = () => toApp({ type: 'battery' }); /** Asks the app for its settings again. */ export const askDevice = () => toApp({ type: 'device' }); if (port) { port.onmessage = (event) => { let m: Message; try { m = JSON.parse(event.data); } catch { return; } if (m.type === 'app') { appInfo.version = typeof m.version === 'string' ? m.version : null; appInfo.player = typeof m.player === 'string' ? m.player : null; appInfo.addresses = typeof m.public === 'string'; appInfo.publicAddress = typeof m.public === 'string' ? m.public : typeof m.server === 'string' ? m.server : null; appInfo.localAddress = typeof m.local === 'string' && m.local ? m.local : null; appInfo.preferLocal = m.preferLocal === true; appInfo.inUse = typeof m.inUse === 'string' && m.inUse ? m.inUse : appInfo.publicAddress; appInfo.refused = typeof m.refused === 'string' && m.refused ? m.refused : null; appInfo.stayReady = m.stayReady === true; appInfo.batteryAllowed = m.batteryAllowed !== false; appInfo.downloadQuality = m.downloadQuality === 'smaller' ? 'smaller' : 'original'; appInfo.downloadOverMobile = m.downloadOverMobile === true; appInfo.syncBudgetGb = typeof m.syncBudgetGb === 'number' ? m.syncBudgetGb : 20; appInfo.syncOnlyCharging = m.syncOnlyCharging === true; appInfo.levelling = m.levelling !== false; } if (m.type === 'network-test') { networkTest.local = typeof m.local === 'boolean' ? m.local : null; networkTest.public = typeof m.public === 'boolean' ? m.public : null; // `publicRefused`: an app from before the sign-in page (its stored login refused). networkTest.signedOut = m.signedOut === true || m.publicRefused === true; networkTest.testing = false; networkTest.done = true; } if (m.type === 'downloads') { downloads.tracks = Array.isArray(m.tracks) ? (m.tracks as DownloadedTrack[]) : []; downloads.bytes = typeof m.bytes === 'number' ? m.bytes : 0; downloads.known = true; } handlers.get(m.type)?.(m); }; toApp({ type: 'hello' }); } ===== src/lib/events.svelte.ts ===== // The page's live channel (server-sent events; the server's app/events.rs): `state` (what plays and which playlists // hold it), `jobs` (the conversion queue) and `changed` ({ area }: a list to read again), each sent when it changes. // One stream for the whole app, opened by live.svelte.ts. While it is down the pollers carry on: they check // `stream.connected`. // // A tab that is not being looked at closes its stream and opens it again when it is: over plain HTTP a browser opens // at most six connections to a server, shared by all its tabs, and every open stream holds one for good (seen // 2026-10-13: six background tabs, and the Settings page never loaded). import { reach, refusedFor } from './reach.svelte'; type Handler = (data: unknown) => void; export const stream = $state({ connected: false }); const handlers = new Map>(); let source: EventSource | null = null; let url = ''; let retry: ReturnType | undefined; /** The source followed, while the stream is wanted (between `open` and `close`). */ let wanted: { follow?: string } | null = null; document.addEventListener('visibilitychange', () => { if (!wanted) return; if (document.hidden) { clearTimeout(retry); source?.close(); source = null; stream.connected = false; } else { connect(wanted.follow); } }); function listen(es: EventSource, name: string) { es.addEventListener(name, (e) => { let data: unknown; try { data = JSON.parse((e as MessageEvent).data); } catch { return; } for (const h of handlers.get(name) ?? []) h(data); }); } /** Call `handler` with each `name` event's data; returns the function that stops it. */ export function on(name: string, handler: (data: T) => void): () => void { let set = handlers.get(name); if (!set) { set = new Set(); handlers.set(name, set); if (source) listen(source, name); } const h = handler as Handler; set.add(h); return () => set.delete(h); } /** Open the stream for the source followed (`undefined`: whichever plays). Opening it again with another one * replaces it. */ export function open(follow?: string) { wanted = { follow }; if (!document.hidden) connect(follow); } function connect(follow?: string) { // Refused by the proxy a moment ago: again once that has passed (lib/reach), not before. if (refusedFor() > 0) { clearTimeout(retry); retry = setTimeout(() => wanted && connect(follow), refusedFor()); return; } const next = `/api/events${follow ? `?source=${encodeURIComponent(follow)}` : ''}`; if (source && url === next && source.readyState !== EventSource.CLOSED) return; clearTimeout(retry); source?.close(); url = next; const es = new EventSource(url); source = es; for (const name of handlers.keys()) listen(es, name); es.onopen = () => (stream.connected = true); es.onerror = () => { stream.connected = false; // The browser retries by itself, unless it gave up (the server answered with an error): then try again later, less // often while Cratarr is out of reach (lib/reach). if (es.readyState === EventSource.CLOSED && source === es) { clearTimeout(retry); retry = setTimeout(() => source === es && connect(follow), reach.away ? 30_000 : 10_000); } }; } export function close() { wanted = null; clearTimeout(retry); source?.close(); source = null; stream.connected = false; } ===== src/lib/favourites.svelte.ts ===== // The hearts on lists' rows (Finamp's, decision 0023): which tracks are favourites, as last asked in one request per // list, and the long-press that flips one. Shared by the queue sheet and the playlist view. import { getFavoriteAlbums, getFavorites, getPlaylistFavorites, setPlaylistFavorite, setTrackFavorite } from './api'; import { prefs } from './prefs.svelte'; import { toast } from './toast.svelte'; /** `source/id` → favourite. */ const favs = $state>({}); const k = (source: string, id: string) => `${source}/${id}`; export const isFavorite = (source: string, id: string) => favs[k(source, id)] ?? false; export function setFavorite(source: string, id: string, on: boolean) { favs[k(source, id)] = on; } /** Asks which of these tracks are favourites; the hearts stay as they were when the server cannot tell. */ export async function loadFavorites(source: string, ids: string[]) { if (!ids.length) return; try { const found = await getFavorites(source, ids); for (const [id, on] of Object.entries(found)) favs[k(source, id)] = on; } catch { /* as they were */ } } // Albums' hearts: a server's whole list at once (an album's star, the one Subsonic apps give it), in the same map as // the tracks' (an album's id is not a track's). The last list per server, so an album no longer on it loses its heart. const albumLists: Record> = {}; const albumAsks: Record> = {}; /** Asks for a server's favourite albums (one request at a time per server); `ids`: albums to say about either way. * False when the server cannot tell; the hearts stay as they were. */ export function loadAlbumFavorites(source: string, ids: string[] = []): Promise { albumAsks[source] ??= getFavoriteAlbums(source) .then((list) => { const now = new Set(list); for (const id of albumLists[source] ?? []) if (!now.has(id)) favs[k(source, id)] = false; for (const id of now) favs[k(source, id)] = true; albumLists[source] = now; return true; }) .catch(() => false) .finally(() => delete albumAsks[source]); return albumAsks[source].then((ok) => { if (ok) for (const id of ids) favs[k(source, id)] = albumLists[source].has(id); return ok; }); } // Playlists' hearts (Finamp's, decision 0023): which playlists are favourites, asked for once; a change shows at once. const playlistFavs = $state>({}); let playlistFavsAsked = false; export const isPlaylistFavorite = (name: string) => playlistFavs[name] ?? false; export async function loadPlaylistFavorites() { if (playlistFavsAsked) return; playlistFavsAsked = true; try { Object.assign(playlistFavs, await getPlaylistFavorites(prefs.source || undefined)); } catch { playlistFavsAsked = false; } } /** Makes a playlist a favourite or not (every copy the server has of it). */ export async function togglePlaylistFavorite(name: string) { const on = !isPlaylistFavorite(name); playlistFavs[name] = on; navigator.vibrate?.(8); try { await setPlaylistFavorite(name, on, prefs.source || undefined); } catch (e) { playlistFavs[name] = !on; toast.fail(e, "The favourite didn't change"); } } /** Several playlists at once (the selection's ⋮): all favourites, or none. */ export async function setPlaylistFavorites(names: string[], on: boolean) { const was = names.map((n) => [n, isPlaylistFavorite(n)] as const); for (const n of names) playlistFavs[n] = on; navigator.vibrate?.(8); try { for (const [n, before] of was) if (before !== on) await setPlaylistFavorite(n, on, prefs.source || undefined); } catch (e) { for (const [n, before] of was) playlistFavs[n] = before; toast.fail(e, "The favourites didn't change"); } } /** A renamed playlist keeps its heart (the server's copies follow the new name). */ export function playlistFavoriteRenamed(from: string, to: string) { if (!(from in playlistFavs)) return; playlistFavs[to] = playlistFavs[from]; delete playlistFavs[from]; } /** The long-press on a row's heart: a favourite or not (a heavy tick, as Finamp's). */ export async function flipFavorite(source: string, id: string) { navigator.vibrate?.(25); try { const s = await setTrackFavorite(source, id, !isFavorite(source, id)); if (s.favorite !== null) setFavorite(source, id, s.favorite); } catch (e) { toast.fail(e, "The favourite didn't change"); } } ===== src/lib/live.svelte.ts ===== // One poll of /api/state for the whole app: what is playing, and which playlists hold it. The shell polls it and shows // the now-playing header on every page; pages read `live` instead of polling themselves. // // Player commands are shown at once (`expect`): the header shows what the command should lead to until the server // agrees, says otherwise, or does not move at all. The server always wins: a different outcome is shown quietly, no // change at all as an error. Playlist taps made meanwhile wait (`queued`) and go to the track they were made on once // the server confirms it; otherwise they are dropped, never added to another track. import { untrack } from 'svelte'; import { ApiError, getState, setMember, type NowPlaying, type State } from './api'; import { prefs } from './prefs.svelte'; import { canPlayHere, local } from './localPlayer.svelte'; import { panel } from './panel.svelte'; import { remember } from './sources.svelte'; import { toast } from './toast.svelte'; import { on, open, close, stream } from './events.svelte'; import { track } from './progress.svelte'; import { reach } from './reach.svelte'; /** How a server answer relates to the expected result: it, something else, or not yet. */ export type Settled = 'yes' | 'no' | 'wait'; type Expectation = { np: NowPlaying; settled: (server: NowPlaying | null) => Settled; until: number; /** No answer in time: show the server's state without an error (Previous may restart the track instead). */ quiet: boolean; }; let expecting: Expectation | null = null; /** After a play command whose first track is not known: the track shown when it was sent (see `untilNewTrack`). */ let holding: { from: string | null; done: () => void } | null = null; /** Since when the server says nothing plays, while the header still shows a track (see `take`). */ let emptySince = 0; /** "Nothing plays" is believed only after this long: between tracks and while a player switches, servers briefly say * so. */ const EMPTY_MS = 3000; /** After a play command (a playlist, an album): keep showing what plays, with the progress bar running, until the * server reports another track; then show it, once. The states a player passes through on the way (nothing, the * old track again) are never shown. Gives up after `ms`, with a toast. */ export function untilNewTrack(ms = 10_000): Promise { const from = live.view?.now_playing?.id ?? null; return track( new Promise((resolve) => { const h = { from, done: resolve }; holding = h; setTimeout(() => { if (holding !== h) return; holding = null; toast.show({ text: "The player didn't follow.", tone: 'error' }); resolve(); refresh(); }, ms); refresh(); }), ); } /** The tracks a run of commands passes through, from the one shown before the first: the server may still show any * of them while it catches up (Next pressed twice: the first skip not yet done when the second is shown). */ let passing: string[] = []; export const live = $state<{ view: State | null; error: string | null; /** A player command is shown before the server confirmed it. */ pending: boolean; /** Playlists tapped while pending, for the expected track. */ queued: string[]; /** The track shown before the current one (what Previous goes back to). */ before: NowPlaying | null; }>({ view: null, error: null, pending: false, queued: [], before: null }); /** Where the player shows (decision 0018). `roomy` (set by the shell): the content area has room for it beside the * page. Then it shows there by itself while something plays, unless the browser chose the page-covering player * (`prefs.sidebar`, Maximise) or it was folded to the mini-player for now (`min`, not remembered). Otherwise the * mini-player shows, and `full` opens the player over everything. */ export const player = $state({ full: false, min: false, roomy: false }); // Closing what is viewed (✕, Esc, Back) closes the panel (decision 0025, amending 0023's stack): the player does not // show in its place but folds to the mini-player, whose tap brings it back. $effect.root(() => { let was = panel.viewed; $effect(() => { const now = panel.viewed; if (was !== null && now === null) player.min = true; was = now; }); }); /** Something plays, where the output is. */ const somethingPlays = () => (prefs.output === 'local' && canPlayHere) || prefs.output.startsWith('player:') ? local.index >= 0 : !!live.view?.now_playing; /** The panel shows beside the page (decisions 0018, 0023): with room for it, a viewed playlist, or the player while * something plays and it was not folded. */ export const inSidebar = () => player.roomy && prefs.sidebar && (panel.viewed !== null || (!player.min && somethingPlays())); /** The panel can show beside the page (room, and not the page-covering player): Select then shows the selection there * as it is ticked (decision 0025). */ export const sidePanel = () => player.roomy && prefs.sidebar; /** The panel shows the player (not a viewed playlist): nothing is viewed, or the player was brought over it. */ export const panelShowsPlayer = () => panel.viewed === null || panel.playerTop; /** Show `np` now as the result of a player command, until the server settles it (or `ms` passes). */ export function expect(np: NowPlaying, settled: (server: NowPlaying | null) => Settled, { ms = 8000, quiet = false } = {}) { const shown = live.view?.now_playing ?? null; if (shown && shown.id !== np.id) live.before = shown; if (!expecting) passing = []; if (shown && !passing.includes(shown.id)) passing.push(shown.id); const e: Expectation = { np, // A track along the way is not an answer yet; only the command's own result, or another track, is. settled: (s) => (s && s.id !== np.id && passing.includes(s.id) ? 'wait' : settled(s)), until: Date.now() + ms, quiet, }; expecting = e; live.pending = true; if (live.view) live.view = { ...live.view, now_playing: np }; // The live channel only speaks when something changed: when nothing does, ask once the time is up. setTimeout(() => expecting === e && refresh(), ms + 100); } /** The command failed: back to what the server shows. */ export function expectFailed(text: string) { end(`${text}${live.queued.length ? ', so nothing was added' : ''}.`); refresh(); } /** Tap a playlist while a command is pending: it waits for the expected track (a second tap takes it back). */ export function queueTap(playlist: string) { live.queued = live.queued.includes(playlist) ? live.queued.filter((p) => p !== playlist) : [...live.queued, playlist]; } function end(error?: string) { expecting = null; passing = []; live.pending = false; live.queued = []; if (error) toast.show({ text: error, tone: 'error' }); } /** The server confirmed the expected track: the waiting taps go to it, each flipping what the server now says. */ async function flush(view: State) { const taps = live.queued; live.queued = []; let last: State | null = null; for (const id of taps) { const p = view.playlists.find((x) => x.id === id); if (!p) continue; try { last = await setMember(id, !p.member, prefs.source || undefined); } catch (e) { toast.fail(e, `Could not change ${p.name}`); } } if (last && !expecting) live.view = last; } /** Ask the server now (pages call it after an action that changed playlists; the shell's poll while the live channel * is down). */ export async function refresh() { try { take(await getState(prefs.source || undefined)); if (unreachable) { // Back: the toast that said otherwise goes. if (toast.current?.text === unreachable) toast.dismiss(); unreachable = null; } } catch (e) { const text = e instanceof ApiError ? e.message : 'Unexpected error'; if (!live.view) { // Nothing to show yet: the page says so in place of its content. live.error = text; } else if (unreachable !== text) { // Already showing: it stays as it is, and one toast says the server does not answer (not one per try). unreachable = text; toast.show({ text, tone: 'error' }); } } } /** The error a toast showed while the page kept what it had; cleared when the server answers again. */ let unreachable: string | null = null; /** A state from the server, by the live channel or asked for: settles a pending command, then shows it. */ function take(fresh: State) { let view = fresh; { remember(view.sources); const shown = live.view?.now_playing ?? null; const keep = () => (view = { ...view, now_playing: shown, playlists: live.view?.playlists ?? view.playlists }); if (holding) { const np = view.now_playing; if (np && np.id !== holding.from) { const h = holding; holding = null; h.done(); } else { keep(); live.view = view; return; } } // Nothing plays: believed once it lasts (the live channel only speaks again when something changes, so look again). if (!view.now_playing && shown && !expecting) { emptySince ||= Date.now(); if (Date.now() - emptySince < EMPTY_MS) { setTimeout(refresh, EMPTY_MS + 100 - (Date.now() - emptySince)); keep(); live.view = view; return; } } else { emptySince = 0; } if (expecting) { const e = expecting; const verdict = e.settled(view.now_playing); if (verdict === 'yes') { expecting = null; passing = []; live.pending = false; if (live.queued.length) flush(view); } else if (verdict === 'no') { const lost = live.queued.length ? `Not added: the player didn't move on to ${e.np.name}.` : undefined; end(lost); } else if (Date.now() > e.until) { end(e.quiet ? undefined : `The player didn't follow${live.queued.length ? ', so nothing was added' : ''}.`); } else { view = { ...view, now_playing: e.np }; } } else if (shown && view.now_playing && shown.id !== view.now_playing.id) { live.before = shown; } live.view = view; live.error = null; } } /** Keep `live` current: from the live channel (events.svelte.ts) while it is open, asking every 2 s (5 s while the * page is hidden) while it is not, and every 30 s regardless. Every 30 s while Cratarr is out of reach (lib/reach), and * at once when the network comes back. Returns a stop function. * * Untracked: started in an effect, what it reads on the way (whether the channel is open) must not become the * effect's dependency. It was (until 0.5.3): the channel opening re-ran the effect, whose cleanup closed the channel * and whose run opened it again and asked for the state, some 200 times a second; one phone's tab took the server and * the media server down (2026-10-14). */ export function poll(): () => void { return untrack(follow); } function follow(): () => void { let stopped = false; let timer: ReturnType; let last = 0; const off = on('state', (view) => { last = Date.now(); take(view); }); open(prefs.source || undefined); // A tab not being looked at asks nothing (its stream is closed too, see events.svelte.ts); back in view, it asks at once. async function tick() { const quiet = Date.now() - last; if (!document.hidden && (!stream.connected || quiet > 30_000)) await refresh(); if (!stopped) timer = setTimeout(tick, reach.away ? 30_000 : stream.connected ? 5000 : 2000); } const back = () => { if (document.hidden || stopped) return; clearTimeout(timer); tick(); }; document.addEventListener('visibilitychange', back); addEventListener('online', back); tick(); return () => { stopped = true; clearTimeout(timer); document.removeEventListener('visibilitychange', back); removeEventListener('online', back); off(); close(); }; } ===== src/lib/localCovers.svelte.ts ===== // An album's cover on this device (Symfonium's "Change local thumbnail", decided 2026-10-16): a picture chosen here, // kept in this browser (IndexedDB) and shown wherever the album's cover is (lib/api's albumCoverUrl); and a cover read // again from the server (Symfonium's "Remove cached images"). Neither reaches the server or another device. const DB = 'cratarr-covers'; const STORE = 'covers'; /** The longest side a chosen picture is kept at. */ const MAX_PX = 1000; /** `source/album` → the picture chosen here (an object URL). */ export const localCovers = $state>({}); /** `source/album` → how often its cover was read again here: a new address each time, so every image takes it. */ export const coverRounds = $state>({}); export const coverKey = (source: string, album: string) => `${source}/${album}`; function open(): Promise { return new Promise((resolve, reject) => { const r = indexedDB.open(DB, 1); r.onupgradeneeded = () => r.result.createObjectStore(STORE); r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error); }); } async function write(key: string, blob: Blob | null) { const db = await open(); await new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite'); if (blob) tx.objectStore(STORE).put(blob, key); else tx.objectStore(STORE).delete(key); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); db.close(); } // The pictures chosen before, as the page opens. async function load() { const db = await open(); const tx = db.transaction(STORE, 'readonly'); const cursor = tx.objectStore(STORE).openCursor(); cursor.onsuccess = () => { const c = cursor.result; if (!c) return db.close(); if (c.value instanceof Blob) localCovers[String(c.key)] = URL.createObjectURL(c.value); c.continue(); }; } if (typeof indexedDB !== 'undefined') load().catch(() => {}); /** A picture no larger than it needs to be (JPEG, its longest side at most MAX_PX); as it is when it cannot be read. */ async function shrink(file: Blob): Promise { try { const img = await createImageBitmap(file); const scale = Math.min(1, MAX_PX / Math.max(img.width, img.height)); const canvas = document.createElement('canvas'); canvas.width = Math.round(img.width * scale); canvas.height = Math.round(img.height * scale); canvas.getContext('2d')!.drawImage(img, 0, 0, canvas.width, canvas.height); img.close(); return await new Promise((resolve) => canvas.toBlob((b) => resolve(b ?? file), 'image/jpeg', 0.9)); } catch { return file; } } /** Shows `file` as the album's cover on this device from now on. */ export async function setLocalCover(source: string, album: string, file: Blob) { const key = coverKey(source, album); // Read whole first: a picked file is only a reference to it on the device, which IndexedDB may not keep. const small = await shrink(new Blob([await file.arrayBuffer()], { type: file.type })); await write(key, small); if (localCovers[key]) URL.revokeObjectURL(localCovers[key]); localCovers[key] = URL.createObjectURL(small); } /** The server's cover again. */ export async function removeLocalCover(source: string, album: string) { const key = coverKey(source, album); await write(key, null); if (localCovers[key]) URL.revokeObjectURL(localCovers[key]); delete localCovers[key]; } /** Reads the album's cover from the server again, past the browser's copy (`url`: its plain address). */ export async function refreshCover(source: string, album: string, url: string) { await fetch(url, { cache: 'reload' }); const key = coverKey(source, album); coverRounds[key] = (coverRounds[key] ?? 0) + 1; } ===== src/lib/localPlayer.svelte.ts ===== // The local player (decision 0022): Cratarr plays in the browser, gapless, through one continuous MSE stream. Each // track comes from the server (/api/stream: FLAC repackaged as fragmented MP4, MP3 as it is) and is appended to the // same stream right after the one before: MP3 trimmed by its LAME header's encoder delay at the start and to its exact // length at the end (measured to the sample with tools/gapless-lab), FLAC placed at its own sample positions. The next // track is appended while the current one plays; what has been played is dropped from the buffer again. A track is // read only as far as the playback needs (LEAD), so the browser's small audio buffer never has to hold one whole. // // The stream's time is mapped back to "which track, how far in" through the segments: one per appended track, from // where its sample 0 sits in the stream. Skipping to an appended track moves within the stream (no gap, no reload); // anything else (a jump back, a seek outside what is loaded) starts a fresh stream at that point. // // Inside the Android app the app plays instead, natively (decision 0031, amended), and the page can also drive another // of Cratarr's players (Play On, `drive`): then every call below is handed to that player (app.svelte.ts, or the // server's /api/players), what it reports is shown in `local`, and the stream here is not used. import { inApp, onApp, toApp } from './app.svelte'; import { getTrackGain, playerCommand, radioNext, type TrackGain } from './api'; import { prefs } from './prefs.svelte'; import { asksSignIn, toSignIn } from './reach.svelte'; export type LocalTrack = { /** Its entry in a player's queue (decision 0033), once Cratarr or the app has given it one. */ entry?: string; /** `next`: added by hand (Play next, Add to queue), not part of the album or playlist that plays (decision 0033's * two lanes). `radio`: Radio's inserts, right after the own track they are like (and its hand-added run); * `autoplay`: Autoplay's, after everything (decision 0036, amended). */ lane?: 'next' | 'radio' | 'autoplay'; /** Its place in the album or playlist as it was played, before any shuffle: turning shuffle off orders by it. */ n?: number; source: string; id: string; name: string; artist: string | null; album: string | null; album_id: string | null; /** False for a track no playlist line can name (outside every library Cratarr sees). */ addable?: boolean; /** Its length (s), where the list it came from said: the queue's lengths and when it ends. */ duration_secs?: number | null; }; export type Repeat = 'off' | 'one' | 'all'; /** A sleep timer: pause at `atMs` (epoch ms), or when the playing track ends. */ export type Sleep = { atMs: number | null; endOfTrack: boolean }; /** Autoplay's modes (decision 0036, amended); off is null. */ export type AutoplayMode = 'similar' | 'artist' | 'era' | 'random'; export const AUTOPLAY_MODES: readonly AutoplayMode[] = ['similar', 'artist', 'era', 'random']; /** What Radio's inserts are like (decision 0036, amended): the track (its artist weighed double), its artist, its era. */ export type RadioBy = 'track' | 'artist' | 'era'; export const RADIO_BYS: readonly RadioBy[] = ['track', 'artist', 'era']; /** How many Radio inserts after each own track: the choices offered, and the most the server keeps. */ export const RADIO_COUNTS: readonly number[] = [3, 5, 10]; export const RADIO_MAX = 10; /** Autoplay while on: its mode; whole albums (`albums`); a playlist that ends starts again instead (`restart`); the * tracks it stays close to when started from an item, else the queue's own (`seed`); the crate it plays inside * ("Play as radio": only the crate's tracks), when it does. */ export type Autoplay = { mode: AutoplayMode; albums: boolean; restart: boolean; seed: string[]; within: string | null }; /** One appended track: its queue index, where its sample 0 sits in the stream (s), its length (s; null while * unknown), and the length the server read from the file (s), shown while the exact one is unknown. */ /** `level`: the track's levelling, fixed once known (a queue change mid-track does not change it). */ type Segment = { index: number; start: number; length: number | null; told: number | null; level?: number }; export const local = $state({ queue: [] as LocalTrack[], /** The playing track's index in `queue`; -1 when nothing is loaded. */ index: -1, paused: true, /** Seconds into the playing track. */ position: 0, /** The playing track's length (s), when known. */ duration: null as number | null, /** The last thing that failed, in plain words. */ error: null as string | null, /** What comes of the album or playlist plays shuffled (the player's shuffle switch). */ shuffled: false, /** Repeat (decision 0033, amended): off, one (the playing track), all (the album or playlist). */ repeat: 'off' as Repeat, /** The sleep timer of the player that plays elsewhere (the app, Cratarr's player), as it reports it; null when none * is set or it fired. */ sleep: null as Sleep | null, /** Radio (decision 0036, amended): while on, `radioCount` tracks like each of the queue's own tracks (`radioBy`: the * track, its artist or its era) play right after it. Choice and count stay while off. */ radio: false, radioBy: 'track' as RadioBy, radioCount: 5, /** Radio's and Autoplay's picks leave the owner's favourites out (the More music sheet's "Ignore favourites"). */ skipFavourites: false, /** Autoplay, null while off: the music goes on when the queue runs out, chosen from the library. */ autoplay: null as Autoplay | null, /** What the queue was started from ("Chill", an album), for the queue sheet's "Playing from"; null when unknown. */ from: null as string | null, }); const MP4 = 'audio/mp4; codecs="flac"'; const MPEG = 'audio/mpeg'; /** Keep this much ahead in the stream before fetching the next track (s). */ const AHEAD = 30; /** Within a track, read no further than this ahead of the playing position (s): the rest of the fetch waits for the * playback, so a long track never has to fit the browser's buffer whole (Chrome takes 12 MiB of audio, about two * minutes of FLAC; on a phone 5, 2 or even 1 MiB). */ const LEAD = 60; /** Bytes appended at a time (a phone's whole audio buffer can be 1 MiB). */ const SLICE = 256 * 1024; /** Drop played audio older than this behind the playing position (s). */ const BEHIND = 30; /** Previous restarts the track after this many seconds (Finamp: 5). */ const RESTART_AFTER = 5; // The stream this browser offers: MediaSource, or on iPhone and iPad (Safari 17.1 on) only ManagedMediaSource, which // works the same but lets the browser say when to fetch (startstreaming, endstreaming) and drop buffered audio itself // (bufferedchange). Neither: the local player is not offered. const Managed = typeof window === 'undefined' ? undefined : (window as unknown as { ManagedMediaSource?: typeof MediaSource }).ManagedMediaSource; const Source = Managed ?? (typeof MediaSource === 'undefined' ? undefined : MediaSource); /** Whether this browser can play here at all (FLAC or MP3 in its stream), or the app plays for it. */ export const canPlayHere = inApp || (!!Source && (Source.isTypeSupported(MP4) || Source.isTypeSupported(MPEG))); const audio = typeof Audio === 'undefined' || !canPlayHere || inApp ? null : new Audio(); // Safari opens a managed stream only for an element that cannot be sent to AirPlay (the stream cannot go there). if (audio && Managed) audio.disableRemotePlayback = true; let ms: MediaSource | null = null; let sb: SourceBuffer | null = null; let mime: string | null = null; let segments: Segment[] = []; /** Stream time where the next appended track's sample 0 goes. */ let streamEnd = 0; /** Bumped by every fresh stream; work for an older one stops. */ let generation = 0; let fetching: AbortController | null = null; let appending = false; /** The queue index being fetched and appended (while `appending`). */ let appendingIndex = -1; /** The last queue index appended to the stream. */ let lastAppended = -1; /** The queue index `local.error` is about: it goes once a later track plays. */ let errorIndex = -1; // --- MP3: the LAME/Xing header (the measured rule: only the encoder delay precedes the first real sample) --- /** `at`: where the first frame starts, after the ID3 tag (the tag, covers and all, never goes into the stream). */ type Mp3Head = { rate: number; delay: number; samples: number | null; at: number }; /** Parses the first frame of an MP3 for its sample rate and LAME gapless information; null when more bytes are needed. */ function mp3Head(b: Uint8Array): Mp3Head | null | 'invalid' { let i = 0; if (b.length < 10) return null; if (b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33) i = 10 + ((b[6] << 21) | (b[7] << 14) | (b[8] << 7) | b[9]); const tagEnd = i; // the ID3 tag can be large (embedded covers of several MB): the limit below counts from its end while (i + 4 <= b.length && !(b[i] === 0xff && (b[i + 1] & 0xe0) === 0xe0)) i++; if (i + 200 > b.length) return b.length > tagEnd + (1 << 22) ? 'invalid' : null; // wait for the first frame and its header const version = (b[i + 1] >> 3) & 3; // 3 = MPEG1, 2 = MPEG2, 0 = MPEG2.5 const rates = version === 3 ? [44100, 48000, 32000] : version === 2 ? [22050, 24000, 16000] : [11025, 12000, 8000]; const rate = rates[(b[i + 2] >> 2) & 3] ?? 44100; const mono = (b[i + 3] & 0xc0) === 0xc0; const side = version === 3 ? (mono ? 17 : 32) : mono ? 9 : 17; const x = i + 4 + side; const tag = String.fromCharCode(b[x], b[x + 1], b[x + 2], b[x + 3]); if (tag !== 'Xing' && tag !== 'Info') return { rate, delay: 0, samples: null, at: i }; const flags = (b[x + 4] << 24) | (b[x + 5] << 16) | (b[x + 6] << 8) | b[x + 7]; let p = x + 8; let frames: number | null = null; if (flags & 1) { frames = ((b[p] << 24) | (b[p + 1] << 16) | (b[p + 2] << 8) | b[p + 3]) >>> 0; p += 4; } if (flags & 2) p += 4; if (flags & 4) p += 100; if (flags & 8) p += 4; const isLame = String.fromCharCode(b[p], b[p + 1], b[p + 2], b[p + 3]) === 'LAME' || String.fromCharCode(b[p], b[p + 1], b[p + 2], b[p + 3]) === 'Lavc'; const d = p + 21; const delay = isLame ? (b[d] << 4) | (b[d + 1] >> 4) : 0; const padding = isLame ? ((b[d + 1] & 0x0f) << 8) | b[d + 2] : 0; const perFrame = version === 3 ? 1152 : 576; return { rate, delay, samples: frames === null ? null : frames * perFrame - delay - padding, at: i }; } // --- the stream --- async function waitUpdate() { if (sb?.updating) await new Promise((r) => sb!.addEventListener('updateend', r, { once: true })); } /** Seconds of audio buffered from the playing position on. */ function ahead() { if (!audio || !sb) return 0; const t = audio.currentTime; const b = sb.buffered; for (let i = 0; i < b.length; i++) if (b.start(i) <= t + 0.1 && b.end(i) > t) return b.end(i) - t; return 0; } /** Seconds of audio buffered in all. */ function held() { if (!sb) return 0; let s = 0; for (let i = 0; i < sb.buffered.length; i++) s += sb.buffered.end(i) - sb.buffered.start(i); return s; } /** Resolves once the playback moves on (the element's next timeupdate), or at once when `signal` aborts (a new * stream, a cut, a stop). A paused player keeps it waiting: nothing more is needed until it plays. */ function playbackMoves(signal: AbortSignal) { return new Promise((resolve) => { if (signal.aborted || !audio) return resolve(); const done = new AbortController(); const go = () => { done.abort(); resolve(); }; audio.addEventListener('timeupdate', go, { signal: AbortSignal.any([signal, done.signal]) }); signal.addEventListener('abort', go, { signal: done.signal }); }); } /** Appends bytes when there is reason and room for them, as developer.chrome.com/blog/quotaexceedederror advises: * no further than LEAD ahead of the playing position, and when the browser's buffer is full (QuotaExceededError) * once played audio is dropped or, with none left to drop, once the playback has used up some of what is ahead. * Holding back holds the fetch back too (its reader is not read), and the server's stream waits with it. */ async function appendBytes(bytes: Uint8Array, gen: number, signal: AbortSignal) { // In slices: the browser wants room for a whole append before it takes any of it. for (let at = 0; at < bytes.length; at += SLICE) if (!(await appendSlice(bytes.subarray(at, at + SLICE), gen, signal))) return; } /** One slice of `appendBytes`; false when the stream moved on meanwhile. */ async function appendSlice(bytes: Uint8Array, gen: number, signal: AbortSignal) { // Cut meanwhile (the queue changed after this track): nothing more of it goes in. const gone = () => gen !== generation || !sb || signal.aborted; for (;;) { while (!gone() && ahead() > LEAD) { await evict(); await playbackMoves(signal); } if (gone()) return false; await waitUpdate(); if (gone()) return false; try { sb!.appendBuffer(bytes); await waitUpdate(); return true; } catch (e) { if ((e as DOMException).name !== 'QuotaExceededError') throw e; } const before = held(); await evict(5); if (held() < before) continue; // Nothing behind to drop and next to nothing ahead: the browser takes less than these bytes, waiting won't help. if (ahead() < 1) throw new Error('The browser has no room for more audio.'); await playbackMoves(signal); } } /** Drops played audio more than `keep` seconds behind the playing position. */ async function evict(keep = BEHIND) { if (!sb || !audio || !sb.buffered.length) return; const until = audio.currentTime - keep; if (until <= sb.buffered.start(0) + 1) return; await waitUpdate(); try { sb.remove(sb.buffered.start(0), until); await waitUpdate(); } catch { /* the stream moved on; nothing to drop */ } } /** Fetches queue track `index` and appends it to the stream, starting `from` seconds into it (a seek). */ async function appendTrack(index: number, from: number, gen: number) { const t = local.queue[index]; if (!t) return; wantGain(index); appending = true; appendingIndex = index; const ctl = (fetching = new AbortController()); /** This track's segment, once placed in the stream. */ let placedSegment = null as Segment | null; // set inside place(), which TypeScript does not follow try { const res = await fetch(`/api/stream/${encodeURIComponent(t.source)}/${encodeURIComponent(t.id)}${from > 0 ? `?from_ms=${Math.floor(from * 1000)}` : ''}`, { signal: ctl.signal, }); if (!res.ok) { if (asksSignIn(res)) toSignIn(); let text = ''; try { text = (await res.json()).error ?? ''; } catch { /* no words */ } throw new Error(`${t.name || 'A track'}: ${text || 'it could not be loaded.'}`); } const type = res.headers.get('content-type') ?? ''; const kind = type.startsWith('audio/mp4') ? MP4 : MPEG; if (!Source?.isTypeSupported(kind)) throw new Error(`${t.name}: this browser cannot play ${kind === MP4 ? 'FLAC' : 'MP3'} here.`); const reader = res.body!.getReader(); // The track's sample 0 goes to `start` in the stream (before the stream's 0 when starting mid-track). const start = streamEnd - from; let head = new Uint8Array(0); let placed = false; let length: number | null = null; const told = Number(res.headers.get('x-duration-ms')) / 1000 || null; const segment: Segment = { index, start, length: null, told }; const place = async (rate: number, skip: number, samples: number | null) => { if (!sb) { sb = ms!.addSourceBuffer(kind); if (Managed) sb.addEventListener('bufferedchange', purged); mime = kind; } else if (mime !== kind) { await waitUpdate(); sb.changeType(kind); mime = kind; } await waitUpdate(); // The track before can end mid-frame (an MP3's last bytes are no whole frame, or its fetch was cut): the parser // then still waits for the rest, and neither the mode nor the offset may change ("PARSING_MEDIA_SEGMENT"). // abort() drops that partial frame, which lies past that track's end (appendWindowEnd) anyway. A stream that was // ended (the queue's last track was in; more were added since) must be reopened first: removing nothing does it. if (ms?.readyState === 'ended') sb.remove(ms.duration, Infinity); // Until every removal is done (this one, a cut's, played audio dropped): nothing below may run while one does. while (sb.updating) await waitUpdate(); if (ms?.readyState === 'open') sb.abort(); // FLAC's fragments carry their own sample times (tfdt; after a seek they start at the frame holding the sample // asked for), so it runs in segments mode with the offset at the track's sample 0. MP3 has no times of its own: // sequence mode, the offset at its first decoded sample (the encoder delay before sample 0). const mode: AppendMode = kind === MP4 ? 'segments' : 'sequence'; if (sb.mode !== mode) sb.mode = mode; length = samples === null ? null : samples / rate; // An MP3 header that disagrees with the length Cratarr read is trusted no more than that length (one header said // 5:27 for 4:24; an early-stopping decode once said 7 s for 7:48): the track's own audio, once in, says where it // ends, so it is neither cut short nor followed by silence. FLAC's own sample count is exact. if (kind !== MP4 && length !== null && told !== null && Math.abs(length - told) > 1.5) length = null; sb.appendWindowEnd = Infinity; sb.appendWindowStart = Math.max(0, streamEnd); if (length !== null) sb.appendWindowEnd = start + length; sb.timestampOffset = kind === MP4 ? start : start - skip / rate; segment.length = length; segments.push(segment); placedSegment = segment; // Its level from its first sample. levelSchedule(); placed = true; }; if (kind === MP4) { const rate = Number(res.headers.get('x-sample-rate')) || 44100; const total = Number(res.headers.get('x-total-samples')) || null; await place(rate, 0, total); } for (;;) { const { value, done } = await reader.read(); if (gen !== generation) { reader.cancel().catch(() => {}); return; } if (done) break; let chunk = value as Uint8Array; if (!placed) { const joined = new Uint8Array(head.length + chunk.length); joined.set(head); joined.set(chunk, head.length); const h = mp3Head(joined); if (h === null) { head = joined; continue; } if (h === 'invalid') throw new Error(`${t.name} is not a playable MP3.`); await place(h.rate, h.delay, h.samples); chunk = joined.subarray(h.at); } await appendBytes(chunk, gen, ctl.signal); } if (!placed && head.length) { const h = mp3Head(head); if (h && h !== 'invalid') { await place(h.rate, h.delay, h.samples); await appendBytes(head.subarray(h.at), gen, ctl.signal); } } // A new stream, or the queue changed after this track (cutFrom): it no longer counts as appended. if (gen !== generation || ctl.signal.aborted) return; // A length the header did not give: what the stream now holds. if (segment.length === null && sb && sb.buffered.length) segment.length = sb.buffered.end(sb.buffered.length - 1) - start; streamEnd = start + (segment.length ?? 0); lastAppended = index; if (index === local.queue.length - 1 && ms?.readyState === 'open') { await waitUpdate(); ms.endOfStream(); } } catch (e) { if ((e as DOMException).name === 'AbortError' || gen !== generation) return; local.error = e instanceof Error ? e.message : String(e); errorIndex = index; // Skip what cannot play, as a player does, unless it was the only thing. if (index < local.queue.length - 1) { lastAppended = index; // Placed already (it failed midway): that segment becomes the empty one, not a second segment for the track. if (placedSegment) placedSegment.length = 0; else segments.push({ index, start: streamEnd, length: 0, told: null }); } } finally { // Not when a newer append is under way (this one was cut). if (gen === generation && fetching === ctl) appending = false; } } /** Starts a fresh stream at queue track `index`, `from` seconds in. */ async function fresh(index: number, from = 0, play = true) { if (!audio) return; const gen = ++generation; fetching?.abort(); appending = false; segments = []; streamEnd = 0; lastAppended = index - 1; sb = null; mime = null; local.index = index; local.position = from; local.error = null; wantGain(index); wantGain(index + 1); ms = new Source!(); const url = URL.createObjectURL(ms); audio.src = url; if (play) audio.play().catch(() => {}); await new Promise((r) => ms!.addEventListener('sourceopen', r, { once: true })); // The element holds the stream now; the URL is no longer needed (each seek or jump makes a new one). URL.revokeObjectURL(url); if (gen !== generation) return; await appendTrack(index, from, gen); } /** Whether the browser wants audio fetched now: always, except a managed stream between its endstreaming and * startstreaming (the tick then waits; what is being fetched finishes). */ function streaming() { return !Managed || (ms as unknown as { streaming?: boolean } | null)?.streaming !== false; } /** A managed stream's browser dropped buffered audio (bufferedchange). Behind the playing position that is what the * player does itself; ahead of it the track it was in is fetched again: the one playing from where it is (a fresh * stream), a later one by cutting the stream there, after which the tick appends it again. */ function purged() { if (!audio || !sb) return; const t = audio.currentTime; let end = t; // where the audio that plays now runs out for (let i = 0; i < sb.buffered.length; i++) if (sb.buffered.start(i) <= t + 0.1) end = Math.max(end, sb.buffered.end(i)); if (end >= streamEnd - 0.5) return; // all appended audio ahead is still there const seg = segments.find((s) => s.start + (s.length ?? 0) > end + 0.5); if (!seg) return; if (seg.index <= local.index) fresh(local.index, local.position, !audio.paused); else cutFrom(seg.index); } /** Which segment the stream is in now, and how far into its track. */ function where(): { seg: Segment; offset: number } | null { if (!audio) return null; const t = audio.currentTime; for (let i = segments.length - 1; i >= 0; i--) { const s = segments[i]; if (t >= s.start - 0.001) return { seg: s, offset: t - s.start }; } return segments[0] ? { seg: segments[0], offset: 0 } : null; } // The clock: the playing track and position, the next track fetched ahead, played audio dropped, the lock screen kept // up to date. Four times a second is plenty for the display; the audio itself runs on its own. let lastShown = -1; function tick() { // Played elsewhere (another of Cratarr's players): this page's own, idle audio says nothing about it. if (!audio || local.index < 0 || handed()) return; local.paused = audio.paused; const w = where(); if (w) { if (w.seg.index !== local.index) { // Repeat one: the stream went on to the next track by itself; back to the start of this one. (Next and previous // set the index themselves, so they still move on.) if (local.repeat === 'one' && w.seg.index === local.index + 1) return jump(local.index); local.index = w.seg.index; nextRound(); fill(); } local.position = Math.max(0, w.offset); local.duration = w.seg.length ?? w.seg.told; } if (local.error && local.index > errorIndex && !audio.paused) local.error = null; if (local.index !== lastShown) { lastShown = local.index; showOnLockScreen(); } const gen = generation; if (!appending && lastAppended >= local.index && lastAppended < local.queue.length - 1 && streamEnd - audio.currentTime < AHEAD && streaming()) { appendTrack(lastAppended + 1, 0, gen); } if (!appending) evict(); if ('mediaSession' in navigator && local.duration && Number.isFinite(local.duration)) { try { navigator.mediaSession.setPositionState({ duration: local.duration, position: Math.min(local.position, local.duration), playbackRate: 1 }); } catch { /* not supported */ } } } // Levelling (decision 0035): this browser's player plays every track about as loud as the next, by its ReplayGain (the // album's while an album plays in its own order, else the track's), never past its peak nor above full volume, a // typical gain where none is known (or not yet). Through a gain node, set as each track of the stream begins (the // stream holds the tracks one after the other) and then kept for that track; each track's gain is asked for as the // track is fetched, so it is there when the track begins. The node goes between the element and the speakers only // once its audio context runs (a browser starts one only after a press): an element routed into a context that cannot // start plays silence. Not in Safari (the managed stream's browser): WebKit suspends an audio context when the page // goes to the background or the screen locks, which would silence the element routed into it; it plays unlevelled. const UNKNOWN_DB = -6; const UNKNOWN = 10 ** (UNKNOWN_DB / 20); const canLevel = !!audio && !Managed && typeof AudioContext !== 'undefined'; const gains = new Map(); let level: { ctx: AudioContext; gain: GainNode; source: MediaElementAudioSourceNode } | null = null; /** An audio context made, not yet running. */ let starting: AudioContext | null = null; /** `tap`: the route for the visuals' tap (`audioTap`), made even where levelling is off (its gain then stays 1). */ function levelRoute(tap = false) { if (level || starting || !canLevel || (!prefs.levelling && !tap)) return; let ctx: AudioContext; try { ctx = new AudioContext(); } catch { return; } starting = ctx; // resume() answers once the context runs: at once after a press, else at the next one. ctx.resume().then( () => { starting = null; if (level || ctx.state !== 'running' || !audio) return void ctx.close(); try { const gain = ctx.createGain(); const source = ctx.createMediaElementSource(audio); source.connect(gain).connect(ctx.destination); level = { ctx, gain, source }; } catch { return void ctx.close(); } // Suspended by the browser while playing: the element would fall silent. ctx.addEventListener('statechange', () => { if (ctx.state === 'suspended' && audio && !audio.paused) void ctx.resume(); }); levelSchedule(); }, () => { starting = null; void ctx.close(); }, ); } /** This page's own audio as Web Audio has it, for the visuals' live analysis (decision 0046): the levelling's route, made * now where levelling is off (at no change of level; an element can feed only one route). Null while it is being made * (an audio context starts after a press: the visuals open on one), and where there is nothing to tap: Safari (the * managed stream; its audio context would silence the music in the background, see above), and while another player * plays (the app's, or one of Cratarr's). */ export function audioTap(): { context: AudioContext; node: AudioNode } | null { if (!canLevel || handed()) return null; if (!level) levelRoute(true); return level && level.ctx.state === 'running' ? { context: level.ctx, node: level.source } : null; } /** Asks for a queue place's gain, once per track. */ function wantGain(index: number) { const t = local.queue[index]; if (!t || !canLevel || !prefs.levelling) return; const key = `${t.source}/${t.id}`; if (gains.has(key)) return; gains.set(key, null); getTrackGain(t.source, t.id) .then((g) => gains.set(key, g), () => gains.set(key, {})) .finally(levelSchedule); } /** A queue place's level (0 to 1); null while its gain is being asked for. */ function levelOf(index: number): number | null { const t = local.queue[index]; if (!t) return 1; wantGain(index); const g = gains.get(`${t.source}/${t.id}`); if (!g) return null; const sameAlbum = (j: number) => !!t.album_id && local.queue[j]?.album_id === t.album_id && !local.queue[j]?.lane; const albumWise = !local.shuffled && (sameAlbum(index - 1) || sameAlbum(index + 1)); const gainDb = (albumWise ? g.albumGain : undefined) ?? g.trackGain ?? UNKNOWN_DB; const peak = (albumWise ? g.albumPeak : undefined) ?? g.trackPeak ?? 1; return Math.min(10 ** (gainDb / 20), peak > 0 ? 1 / peak : 1, 1); } /** A segment's level: its track's, kept once known; the typical one until then. */ function segmentLevel(s: Segment): number { if (!prefs.levelling) return 1; if (s.level === undefined) { const l = levelOf(s.index); if (l === null) return UNKNOWN; s.level = l; } return s.level; } /** What the gain node was last told: the playing segment and its level, the next track's (null: none yet, or paused) * and when it starts (on the audio context's clock). */ let told: { seg: Segment | undefined; level: number; next: number | null; at: number } | null = null; /** A change within a playing track glides (about 0.15 s): a step there could click. Elsewhere (a new track, a seek, * before any sound) the sound jumps anyway, and it is a step. */ const GLIDE = 0.05; /** The playing track's level now, the next one's from where it starts in the stream. Only what changed is told * again: cancelling a glide under way would jump back. */ function levelSchedule() { if (!level || !audio) return; const p = level.gain.gain; const t = level.ctx.currentTime; const now = audio.currentTime; const cur = [...segments].reverse().find((s) => s.start <= now); const next = segments.find((s) => s.start > now); const f = cur ? segmentLevel(cur) : prefs.levelling ? UNKNOWN : 1; // Paused: the stream's clock stands still while the context's runs on, so no next step is set. const nf = next && !audio.paused ? segmentLevel(next) : null; const at = next ? t + (next.start - now) : 0; if (told?.level !== f || told.seg !== cur) { // As when a track's gain comes late, or the switch is flipped. const within = !!cur && !audio.paused && now > cur.start + 0.3 && (!told || told.seg === cur); if (within) { // From where it is now. if (typeof p.cancelAndHoldAtTime === 'function') p.cancelAndHoldAtTime(t); else { p.cancelScheduledValues(t); p.setValueAtTime(p.value, t); } p.setTargetAtTime(f, t, GLIDE); } else { p.cancelScheduledValues(t); p.setValueAtTime(f, t); } } else if (told.next !== nf || (nf !== null && Math.abs(told.at - at) > 0.02)) { // Only the next step moved (it comes, goes, or the clocks drifted apart): what lies ahead, again. p.cancelScheduledValues(t + 0.001); } else return; if (nf !== null) p.setValueAtTime(nf, at); told = { seg: cur, level: f, next: nf, at }; } /** Levelling on or off for this browser's player, the playing track too. */ export function setLevelling(on: boolean) { prefs.levelling = on; // The switch is a press: an audio context can start now. if (audio && !audio.paused) levelRoute(); levelSchedule(); } if (audio) { audio.addEventListener('play', () => { levelRoute(); void level?.ctx.resume(); levelSchedule(); }); audio.addEventListener('seeked', levelSchedule); audio.addEventListener('pause', levelSchedule); audio.addEventListener('timeupdate', levelSchedule); } if (audio) { setInterval(tick, 250); audio.addEventListener('play', tick); audio.addEventListener('pause', tick); audio.addEventListener('ended', () => { local.paused = true; }); } function showOnLockScreen() { if (!('mediaSession' in navigator)) return; const t = local.queue[local.index]; if (!t) return; navigator.mediaSession.metadata = new MediaMetadata({ title: t.name, artist: t.artist ?? '', album: t.album ?? '', artwork: [{ src: `/api/cover/${encodeURIComponent(t.source)}/${encodeURIComponent(t.id)}` }], }); } /** The engine's audio element, for measuring (tools/gapless-lab, web/engine-lab.html); never for playback control. */ export const engineAudio = () => audio; // --- played elsewhere: the app's own player, or another of Cratarr's players; what it plays, as it reports it --- /** One of Cratarr's players the page drives (Play On), by name; null: this browser or the app plays. */ let driven: string | null = null; /** Whether the queue plays elsewhere than in this page's stream. */ const handed = () => inApp || driven !== null; /** From now on the queue plays on one of Cratarr's players (by name), or here again (`null`). What `local` holds is * then that player's, as it reports it (`reported`). */ export function drive(name: string | null) { driven = name; // The next player's own, from its first report. local.sleep = null; reported = { positionMs: 0, at: performance.now() }; } /** The last report: the position then (ms), and when it came; the position in between is counted on here. */ let reported = { positionMs: 0, at: 0 }; const sameQueue = (a: LocalTrack[], b: LocalTrack[]) => a.length === b.length && a.every((t, i) => t.source === b[i].source && t.id === b[i].id && t.entry === b[i].entry); /** The player that plays: what it plays now (`queue` only when it changed). */ export function reportedBy(m: { queue?: unknown; index?: unknown; positionMs?: unknown; durationMs?: unknown; paused?: unknown; error?: unknown; repeat?: unknown; sleep?: unknown; radio?: unknown; radioBy?: unknown; radioCount?: unknown; skipFavourites?: unknown; autoplay?: unknown; }) { if (m.repeat === 'off' || m.repeat === 'one' || m.repeat === 'all') local.repeat = m.repeat; // Left out: not said (a report of the queue alone); null: none. if (m.sleep !== undefined) local.sleep = sleepOf(m.sleep); if (typeof m.radio === 'boolean') local.radio = m.radio; // An app from before the amendment reports its Autoplay as `radio`, an object. else if (m.radio && typeof m.radio === 'object') local.autoplay = autoplayOf(fromOld(m.radio as Record)); if (RADIO_BYS.includes(m.radioBy as RadioBy)) local.radioBy = m.radioBy as RadioBy; if (typeof m.radioCount === 'number' && m.radioCount > 0) local.radioCount = m.radioCount; if (typeof m.skipFavourites === 'boolean') local.skipFavourites = m.skipFavourites; if (m.autoplay !== undefined) local.autoplay = autoplayOf(m.autoplay); if (Array.isArray(m.queue) && !sameQueue(m.queue as LocalTrack[], local.queue)) { // A queue the player changed (moved, added to) keeps the names this page already knows of its tracks. const known = new Map(local.queue.filter((t) => t.name).map((t) => [`${t.source}/${t.id}`, t])); local.queue = (m.queue as LocalTrack[]).map((t) => (t.name ? t : (known.get(`${t.source}/${t.id}`) ?? t))); // A queue the page did not order itself (a page opened while it played): shuffled when its order says so. local.shuffled = looksShuffled(local.queue, typeof m.index === 'number' ? m.index : -1); } local.index = typeof m.index === 'number' ? m.index : -1; local.paused = m.paused !== false; local.duration = typeof m.durationMs === 'number' && m.durationMs > 0 ? m.durationMs / 1000 : null; local.error = typeof m.error === 'string' ? m.error : null; reported = { positionMs: Number(m.positionMs) || 0, at: performance.now() }; local.position = reported.positionMs / 1000; } function sleepOf(s: unknown): Sleep | null { if (!s || typeof s !== 'object') return null; const { atMs, endOfTrack } = s as Record; const timer = { atMs: typeof atMs === 'number' ? atMs : null, endOfTrack: endOfTrack === true }; return timer.atMs === null && !timer.endOfTrack ? null : timer; } /** A reported Autoplay (the app's whole setting, or a player view's mode and switches); null while off. */ function autoplayOf(a: unknown): Autoplay | null { if (!a || typeof a !== 'object') return null; const { mode, albums, restart, seed, within } = a as Record; return { mode: AUTOPLAY_MODES.includes(mode as AutoplayMode) ? (mode as AutoplayMode) : 'similar', albums: albums === true, restart: restart === true, seed: Array.isArray(seed) ? seed.filter((x): x is string => typeof x === 'string') : [], within: typeof within === 'string' && within ? within : null, }; } /** A radio from before the amendment (`{on, mode, seed, within}`), as Autoplay. */ function fromOld(r: Record): Record | null { if (r.on === false) return null; const mode = r.mode === 'artist' || r.mode === 'era' || r.mode === 'random' ? r.mode : 'similar'; return { ...r, mode, albums: r.mode === 'album_mix', restart: r.mode === 'reshuffle' }; } /** The sleep timer, for the player that plays elsewhere (the app, Cratarr's player), which keeps it: it is sure to be * awake when it fires. `null` clears it. Its reports set `local.sleep`. */ export function handSleep(s: Sleep | null) { if (!handed()) return; local.sleep = s; hand('sleep', { atMs: s?.atMs ?? null, endOfTrack: s?.endOfTrack ?? false }); } /** A call for the player that plays: the app's own, or the one driven (its command, as /api/players takes it). */ function hand(op: string, args: Record = {}) { if (driven === null) return toApp({ type: 'player', op, ...args }); const command: Record = op === 'queue' ? { command: 'play', tracks: args.tracks, index: args.index, position_ms: args.positionMs } : op === 'seek' ? { command: 'seek', position_ms: args.positionMs } : op === 'play' ? { command: 'resume' } : op === 'sleep' ? { command: 'sleep', at_ms: args.atMs, ...(args.endOfTrack ? { end_of_track: true } : {}) } : { command: op, ...args }; playerCommand(driven, command).catch((e) => (local.error = e instanceof Error ? e.message : String(e))); } /** The position now, to the frame (the visuals' beats, decision 0046), where `local.position` moves four times a * second: counted on from the playing player's last report, or read from this page's own audio. */ export function positionNow(): number { if (!handed()) return where()?.offset ?? local.position; const p = (reported.positionMs + (local.paused ? 0 : performance.now() - reported.at)) / 1000; return local.duration ? Math.min(p, local.duration) : p; } if (inApp) onApp('player', (m) => driven === null && reportedBy(m as Parameters[0])); if (typeof window !== 'undefined') { setInterval(() => { if (!handed() || local.paused || local.index < 0) return; const p = (reported.positionMs + performance.now() - reported.at) / 1000; local.position = local.duration ? Math.min(p, local.duration) : p; }, 250); } // --- what the player and the lock screen ask of it --- /** The tracks added by hand still to come: the run of them right after the playing track (decision 0033). */ export function addedAhead(queue: LocalTrack[], index: number): LocalTrack[] { const run: LocalTrack[] = []; for (let i = index + 1; i < queue.length && queue[i].lane === 'next'; i++) run.push(queue[i]); return run; } /** The album's or playlist's own order kept on its tracks (`n`), for turning shuffle off later. */ const numbered = (tracks: LocalTrack[]) => tracks.map((t, i) => (t.lane || t.n !== undefined ? t : { ...t, n: i })); /** In a random order (Fisher-Yates). */ function shuffledCopy(items: T[]): T[] { const a = [...items]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } /** What comes of the album or playlist in its own order again, from the playing track on; those from before it last, * so nothing is lost. Tracks without a place keep theirs, at the end. */ function inOrder(rest: LocalTrack[], from: number): LocalTrack[] { const placed = rest.filter((t) => t.n !== undefined).sort((a, b) => a.n! - b.n!); return [...placed.filter((t) => t.n! > from), ...placed.filter((t) => t.n! <= from), ...rest.filter((t) => t.n === undefined)]; } /** The place in the album or playlist the queue has reached: the playing track's, else the last one's before it. */ function reached(queue: LocalTrack[], index: number): number { for (let i = index; i >= 0; i--) if (queue[i]?.n !== undefined) return queue[i].n!; return -1; } /** Whether what comes of the album or playlist is out of its own order: more than the one turn back that starting * partway in gives. */ function looksShuffled(queue: LocalTrack[], index: number): boolean { const ns = queue.slice(index + 1).flatMap((t) => (t.lane || t.n === undefined ? [] : [t.n])); let back = 0; for (let i = 1; i < ns.length; i++) if (ns[i] < ns[i - 1]) back++; return back > 1; } /** Shuffle on (what comes of the album or playlist, mixed) or off (back in its own order, from the playing track on). * What was added by hand stays ahead, in its order (decision 0033). */ export function setShuffle(on: boolean) { local.shuffled = on; if (local.index < 0) return; // A player of Cratarr's: Cratarr orders its queue (it holds it, decision 0033), not this page from what it last saw. if (driven !== null) return hand('shuffle', { on }); const ahead = addedAhead(local.queue, local.index); const after = local.queue.slice(local.index + 1 + ahead.length); // Only the queue's own tracks are mixed (decision 0036, amended): Radio's inserts for the playing track stay right // after it, Autoplay's tail stays last, each in its order. let run = 0; while (run < after.length && after[run].lane === 'radio') run++; const rest = after.slice(run); const own = rest.filter((t) => !t.lane); const others = rest.filter((t) => t.lane && t.lane !== 'autoplay'); const tail = rest.filter((t) => t.lane === 'autoplay'); replaceUpcoming([...ahead, ...after.slice(0, run), ...(on ? shuffledCopy(own) : inOrder(own, reached(local.queue, local.index))), ...others, ...tail]); } /** Repeat off, one or all: the player that plays applies it (here, the app, or Cratarr's player). Repeat all turns * Autoplay off: both say what comes when the queue ends (decision 0036, amended). Radio lives with both. */ export function setRepeat(mode: Repeat) { local.repeat = mode; if (mode === 'all' && local.autoplay) { local.autoplay = null; dropAhead('autoplay'); } if (handed()) return hand('repeat', { mode }); nextRound(); } /** Radio on or off (decision 0036, amended). While on, the player that plays inserts `count` tracks like each of the * queue's own tracks (`by` the track, its artist or its era) right after it as it starts (this page for its own * stream, else the app or Cratarr's player). Off, or another choice or count while on, takes the inserts still to come * out; on, the playing track gets them anew. What is not given stays. */ export function setRadio(on: boolean, opts: { by?: RadioBy; count?: number } = {}) { const count = opts.count === undefined ? undefined : Math.min(Math.max(Math.round(opts.count), 1), RADIO_MAX); const other = (opts.by !== undefined && opts.by !== local.radioBy) || (count !== undefined && count !== local.radioCount); const anew = local.radio && on && other; if (opts.by !== undefined) local.radioBy = opts.by; if (count !== undefined) local.radioCount = count; local.radio = on; if (!on || anew) dropAhead('radio'); if (handed()) { return hand('radio', { on, ...(opts.by !== undefined ? { by: opts.by } : {}), ...(count !== undefined ? { count } : {}) }); } if (anew) insertedFor.clear(); if (on) insertRadio(); } /** Radio's and Autoplay's picks leave the owner's favourites out, or not (decision 0036, amended). A change takes their * picks still to come out, and the player that plays picks anew: Radio for the playing track, Autoplay when the queue * runs low. */ export function setSkipFavourites(on: boolean) { if (on === local.skipFavourites) return; local.skipFavourites = on; dropAhead('radio'); dropAhead('autoplay'); if (handed()) return hand('skip_favourites', { on }); insertedFor.clear(); fill(); } /** Autoplay in a mode, or 'stop' (decision 0036, amended): the player that plays appends its tracks when the queue runs * out. What is not given stays as it was; another mode, albums or crate lets its earlier picks make way. On turns * repeat all off. */ export function setAutoplay(mode: AutoplayMode | 'stop', opts: { albums?: boolean; restart?: boolean; seed?: string[]; within?: string | null } = {}) { if (mode === 'stop') { local.autoplay = null; dropAhead('autoplay'); if (handed()) hand('autoplay', { mode: 'stop' }); return; } const was = local.autoplay; const next: Autoplay = { mode, albums: opts.albums ?? was?.albums ?? false, restart: opts.restart ?? was?.restart ?? false, seed: opts.seed ?? was?.seed ?? [], within: opts.within === undefined ? (was?.within ?? null) : opts.within, }; if (was && (was.mode !== next.mode || was.albums !== next.albums || was.within !== next.within)) dropAhead('autoplay'); local.autoplay = next; if (local.repeat === 'all') local.repeat = 'off'; // What is given only: the player keeps the rest (a player's view does not show the seed here). if (handed()) { return hand('autoplay', { mode, ...(opts.albums !== undefined ? { albums: opts.albums } : {}), ...(opts.restart !== undefined ? { restart: opts.restart } : {}), ...(opts.seed ? { seed: opts.seed } : {}), ...(opts.within !== undefined ? { within: opts.within ?? '' } : {}), }); } fillAutoplay(); } /** A lane's tracks still to come, out here (the player that plays elsewhere does the same with what it is told). */ function dropAhead(lane: 'radio' | 'autoplay') { if (local.index < 0) return; const kept = local.queue.slice(local.index + 1).filter((t) => t.lane !== lane); if (kept.length === local.queue.length - local.index - 1) return; if (handed()) local.queue = [...local.queue.slice(0, local.index + 1), ...kept]; else replaceUpcoming(kept); } /** "Start radio" from an item (decision 0036; amended: it sets Autoplay): tracks like `seed` (its own left out) play, * and Autoplay goes on with that seed. `within`: a crate's id, to play only the crate's tracks ("Play as radio"; its * own then may play). `from`: its name, for the queue sheet. */ export async function startRadio(seed: string[], from: string | null, within: string | null = null) { const mode = local.autoplay?.mode ?? 'similar'; const albums = local.autoplay?.albums ?? false; const skip_favourites = local.skipFavourites; const { tracks } = await radioNext({ mode, albums, seed, exclude: within ? [] : seed, last: null, count: BATCH, within, skip_favourites }); if (!tracks.length) throw new Error('Nothing like it was found.'); // Set before the play: a play of Autoplay's tracks only keeps its seed. local.autoplay = { mode, albums, restart: false, seed, within }; if (local.repeat === 'all') local.repeat = 'off'; if (handed()) hand('autoplay', { mode, albums, restart: false, seed, within: within ?? '' }); playQueue(tracks as LocalTrack[], 0, from); } /** How many tracks are asked for at a time, and how few may be left ahead before Autoplay asks for more. */ const BATCH = 5; const LOW = 3; let filling = false; /** The own tracks Radio has asked inserts for (by entry, or place and id): one ask per track. */ const insertedFor = new Set(); /** This page's own stream: Radio's inserts for the playing own track, and Autoplay's tracks when the queue runs low. */ function fill() { insertRadio(); fillAutoplay(); } /** Radio, this page's own stream: about five tracks like the own track that plays, right after it and its hand-added * run, unless they are there already. */ function insertRadio() { if (!local.radio || handed() || local.index < 0) return; const cur = local.queue[local.index]; if (!cur || cur.lane) return; const key = cur.entry ?? `${local.index}:${cur.id}`; const at = local.index + 1 + addedAhead(local.queue, local.index).length; if (insertedFor.has(key) || local.queue[at]?.lane === 'radio') return; insertedFor.add(key); const { radioBy: by, radioCount: count, skipFavourites: skip_favourites } = local; radioNext({ mode: 'radio', by, seed: [cur.id], exclude: local.queue.map((t) => t.id), last: cur.id, count, within: null, skip_favourites }) .then(({ tracks }) => { // Still on as asked, still that track, nothing inserted meanwhile: right after it and its hand-added run. if (!local.radio || local.radioBy !== by || local.radioCount !== count || local.skipFavourites !== skip_favourites) return; if (handed() || !tracks.length || local.queue[local.index] !== cur) return; const here = local.index + 1 + addedAhead(local.queue, local.index).length; if (local.queue[here]?.lane === 'radio') return; local.queue = [...local.queue.slice(0, here), ...(tracks as LocalTrack[]), ...local.queue.slice(here)]; cutFrom(here); }) .catch((e) => { insertedFor.delete(key); local.error = e instanceof Error ? e.message : String(e); }); } /** Autoplay, this page's own stream: when fewer than three tracks are left ahead, the queue's own tracks again * (`restart`, once none of them is ahead) or Autoplay's picks, appended after everything. */ function fillAutoplay() { const auto = local.autoplay; if (!auto || handed() || filling || local.index < 0 || local.queue.length - local.index - 1 >= LOW) return; if (auto.restart) { if (local.queue.slice(local.index + 1).some((t) => !t.lane)) return; restartOwn(); return; } const ask = { mode: auto.mode, albums: auto.albums, seed: auto.seed.length ? auto.seed : local.queue.filter((t) => !t.lane).map((t) => t.id), exclude: local.queue.map((t) => t.id), last: local.queue[local.index]?.id ?? null, count: BATCH, within: auto.within, skip_favourites: local.skipFavourites, }; filling = true; radioNext(ask) .then(({ tracks }) => { // Still on as asked, still here: appended after everything, so the stream goes on into them. if (!local.autoplay || handed() || !tracks.length || local.skipFavourites !== ask.skip_favourites) return; local.queue = [...local.queue, ...(tracks as LocalTrack[])]; }) .catch((e) => (local.error = e instanceof Error ? e.message : String(e))) .finally(() => (filling = false)); } /** Restart playlists: the queue's own tracks again, in their own order (shuffled when the queue plays shuffled, never * starting with the one that just played), as own tracks. */ function restartOwn() { const byPlace = new Map(); for (const t of local.queue) if (!t.lane && t.n !== undefined) byPlace.set(t.n, t); let round = byPlace.size ? [...byPlace.entries()].sort((a, b) => a[0] - b[0]).map(([, t]) => t) : local.queue.filter((t, i, all) => !t.lane && all.findIndex((x) => x.source === t.source && x.id === t.id) === i); if (!round.length) return; if (local.shuffled) { round = shuffledCopy(round); const last = local.queue[local.index]; if (round.length > 1 && round[0].source === last.source && round[0].id === last.id) round.push(round.shift()!); } local.queue = [...local.queue, ...round.map(({ entry: _, ...t }) => t)]; } /** Under repeat all, while the last track plays here: the next round of the album or playlist after it (decision 0033, * amended: those added by hand play once; shuffled anew when the queue plays shuffled, not starting with the track * that just played). The stream then goes on into it without a gap. */ function nextRound() { if (local.repeat !== 'all' || local.index < 0 || local.index !== local.queue.length - 1) return; const byPlace = new Map(); for (const t of local.queue) if (!t.lane && t.n !== undefined) byPlace.set(t.n, t); let round = byPlace.size ? [...byPlace.entries()].sort((a, b) => a[0] - b[0]).map(([, t]) => t) : local.queue.filter((t, i, all) => !t.lane && all.findIndex((x) => x.source === t.source && x.id === t.id) === i); if (!round.length) return; if (local.shuffled) { round = shuffledCopy(round); const last = local.queue[local.index]; if (round.length > 1 && round[0].source === last.source && round[0].id === last.id) round.push(round.shift()!); } // New entries for the new round's tracks; their places stay. local.queue = [...local.queue, ...round.map(({ entry: _, ...t }) => t)]; } /** Everything after the playing track, replaced; the playing track plays on. */ function replaceUpcoming(upcoming: LocalTrack[]) { local.queue = [...local.queue.slice(0, local.index + 1), ...upcoming]; if (handed()) return hand('upcoming', { tracks: upcoming, shuffled: local.shuffled }); cutFrom(local.index + 1); } /** Takes out everything after the playing track (the queue sheet's clear button); returns it, for its Undo. Autoplay, * when on, fills again at the next track. */ export function clearUpcoming(): LocalTrack[] { const removed = local.queue.slice(local.index + 1); if (removed.length) replaceUpcoming([]); return removed; } /** Puts back what `clearUpcoming` took out (its Undo), in place of what came meanwhile (Autoplay's). */ export const restoreUpcoming = (tracks: LocalTrack[]) => replaceUpcoming(tracks); /** Plays `tracks` from `index`, `position` seconds into it; `shuffle`: all of them mixed, from the first. Something new * replaces the album or playlist that played, not what was added by hand: those come right after its first track, as * before (decision 0033's two lanes). */ export function playQueue(tracks: LocalTrack[], index = 0, from: string | null = null, position = 0, shuffle = false) { tracks = numbered(tracks); if (shuffle) [tracks, index, position] = [shuffledCopy(tracks), 0, 0]; // A queue moved here whole keeps its shuffle. local.shuffled = shuffle || looksShuffled(tracks, index); // Something new turns repeat one off; repeat all stays (decision 0033, amended). if (local.repeat === 'one') local.repeat = 'off'; // Radio and Autoplay stay on; Autoplay now after the new album or playlist, which is its seed. Autoplay's tracks only // are "Start radio", which brings its own seed (decision 0036, amended). if (local.autoplay && !tracks.every((t) => t.lane === 'autoplay')) local.autoplay = { ...local.autoplay, seed: [], within: null }; // Those the new queue holds already (a queue moved here whole) are not added twice. const holds = (t: LocalTrack) => tracks.some((x) => x === t || (!!t.entry && x.entry === t.entry)); const ahead = local.index < 0 ? [] : addedAhead(local.queue, local.index).filter((t) => !holds(t)); if (ahead.length) tracks = [...tracks.slice(0, index + 1), ...ahead, ...tracks.slice(index + 1)]; local.queue = tracks; local.from = from; if (handed()) { Object.assign(local, { index, position, paused: false, error: null }); reported = { positionMs: position * 1000, at: performance.now() }; return hand('queue', { tracks, index, positionMs: Math.round(position * 1000), play: true, shuffled: local.shuffled }); } fresh(index, position); fill(); } /** Adds tracks by hand (decision 0033's two lanes): right after the playing one (`next`, so the newest come first), or * after those added before (`last`), ahead of the rest of the album or playlist either way. */ export function enqueue(tracks: LocalTrack[], how: 'next' | 'last') { tracks = tracks.map((t) => ({ ...t, lane: 'next' as const })); if (local.index < 0) return playQueue(tracks); const at = local.index + 1 + (how === 'next' ? 0 : addedAhead(local.queue, local.index).length); const after = at === 0 ? '' : local.queue[at - 1].entry; local.queue = [...local.queue.slice(0, at), ...tracks, ...local.queue.slice(at)]; if (handed()) return hand('add', { at, tracks, ...(after === undefined ? {} : { after }) }); cutFrom(at); } /** Moves upcoming track `from` to place `to` (queue indexes, both after the playing track): the queue sheet's drag. */ export function move(from: number, to: number) { const n = local.queue.length; if (from === to || from <= local.index || to <= local.index || from >= n || to >= n) return; const q = [...local.queue]; const [t] = q.splice(from, 1); q.splice(to, 0, t); local.queue = q; // By entry where the tracks have them (decision 0033): the player's queue may have moved on since. const after = to === 0 ? '' : q[to - 1].entry; if (handed()) return hand('move', { from, to, ...(t.entry && after !== undefined ? { entry: t.entry, after } : {}) }); cutFrom(Math.min(from, to)); } /** Takes upcoming track `index` (a queue index after the playing track) out of the queue: the queue sheet's swipe. */ export function remove(index: number) { if (index <= local.index || index >= local.queue.length) return; const entry = local.queue[index].entry; local.queue = local.queue.filter((_, i) => i !== index); if (handed()) return hand('remove', { index, ...(entry ? { entry } : {}) }); cutFrom(index); } /** As `remove`, counted in what comes next. */ export const removeUpcoming = (i: number) => remove(local.index + 1 + i); /** As `move`, counted in what comes next (0 = the track after the playing one), as the queue sheet lists it. */ export const moveUpcoming = (from: number, to: number) => move(local.index + 1 + from, local.index + 1 + to); /** The queue changed from index `at` on: what the stream holds (or is fetching) from there is no longer what comes * next. It is dropped, and the tick appends the new next track gaplessly after the last one kept. */ function cutFrom(at: number) { if (appending && appendingIndex >= at) { fetching?.abort(); appending = false; } if (at > lastAppended) return; const keep = segments.filter((s) => s.index < at); // An ended stream counts too (the queue's last track was in): the removal below reopens it. if (!keep.length || !sb || !ms || ms.readyState === 'closed') return; const last = keep[keep.length - 1]; const end = last.start + (last.length ?? 0); segments = keep; streamEnd = end; lastAppended = at - 1; (async () => { // An append that was under way finishes first; the aborted one adds nothing after it. while (sb?.updating) await waitUpdate(); try { sb?.remove(end, Infinity); } catch { /* nothing appended there */ } })(); } export function togglePlay() { if (handed()) { local.paused = !local.paused; return hand('toggle'); } if (!audio) return; if (audio.paused) audio.play().catch(() => {}); else audio.pause(); } /** To queue track `index`: within the stream when it is already appended (gapless), else a fresh stream. */ export function jump(index: number) { if (handed() && index >= 0 && index < local.queue.length) { const entry = local.queue[index].entry; Object.assign(local, { index, position: 0, paused: false }); return hand('jump', { index, ...(entry ? { entry } : {}) }); } if (!audio || index < 0 || index >= local.queue.length) return; const seg = segments.find((s) => s.index === index); if (seg && audio.buffered.length && seg.start >= audio.buffered.start(0)) { audio.currentTime = seg.start; local.index = index; if (audio.paused) audio.play().catch(() => {}); } else { fresh(index, 0, true); } } export const next = () => jump(local.index + 1); export function previous() { if (handed()) return hand('previous'); // Repeat all: previous on the first track goes to the last. if (local.repeat === 'all' && local.index === 0 && local.position <= RESTART_AFTER && local.queue.length > 1) return jump(local.queue.length - 1); if (local.position > RESTART_AFTER) seek(0); else jump(Math.max(0, local.index - 1)); } /** To `secs` into the playing track: within what is loaded, else a fresh stream from there. */ export function seek(secs: number) { if (handed()) { local.position = secs; reported = { positionMs: secs * 1000, at: performance.now() }; return hand('seek', { positionMs: Math.round(secs * 1000) }); } if (!audio) return; const w = where(); const target = w ? w.seg.start + secs : secs; for (let i = 0; i < audio.buffered.length; i++) { if (target >= audio.buffered.start(i) && target <= audio.buffered.end(i)) { audio.currentTime = target; return; } } fresh(local.index, secs, !audio.paused); } /** Stops and forgets the queue (switching output to elsewhere). Returns where it was, for the handover. */ export function stop(): { queue: LocalTrack[]; index: number; position: number } { const was = { queue: local.queue, index: local.index, position: local.position }; if (handed()) hand('stop'); generation++; fetching?.abort(); audio?.pause(); if (audio) audio.removeAttribute('src'); local.queue = []; local.index = -1; local.paused = true; return was; } // --- Play On to this page (decision 0033: a page is a Play On target while it is open; 0031 amended) --- /** Its volume as Cratarr sets it (0 to 100), and whether it is ducked (an announcement): the element's, times a duck. */ export const pageVolume = $state({ level: 100, ducked: false }); const DUCKED = 0.25; function applyVolume() { if (audio) audio.volume = (pageVolume.level / 100) * (pageVolume.ducked ? DUCKED : 1); } /** Cratarr's track as this player keeps it. */ function trackOf(t: Record): LocalTrack | null { if (typeof t.source !== 'string' || typeof t.id !== 'string') return null; const lane = t.lane === 'next' || t.lane === 'radio' || t.lane === 'autoplay' ? t.lane : undefined; return { ...(typeof t.entry === 'string' ? { entry: t.entry } : {}), ...(lane ? { lane } : {}), source: t.source, id: t.id, name: typeof t.name === 'string' ? t.name : '', artist: typeof t.artist === 'string' ? t.artist : null, album: typeof t.album === 'string' ? t.album : null, album_id: typeof t.album_id === 'string' ? t.album_id : null, }; } const tracksOf = (v: unknown) => (Array.isArray(v) ? v.map((t) => trackOf(t as Record)).filter((t): t is LocalTrack => !!t) : []); /** A queue place by entry (decision 0033), else by number; -1 when neither is there. */ function placeOf(m: Record, entryKey: string, indexKey: string): number { const e = m[entryKey]; if (typeof e === 'string') { const at = local.queue.findIndex((t) => t.entry === e); if (at >= 0) return at; } const i = m[indexKey]; return typeof i === 'number' && i >= 0 && i < local.queue.length ? i : -1; } /** One of Cratarr's commands, on this page's own player, as the app's player takes them (PlayOnLink.kt). What a page * cannot do is left: sync play (it joins no group), the measurements. Whether it wants sound from it (a play that a * browser may hold back until the page has been touched once). */ export function playOnCommand(m: Record): boolean { if (!audio) return false; switch (m.command) { case 'play': { const tracks = numbered(tracksOf(m.tracks)); if (!tracks.length) return false; const index = Math.min(Math.max(0, typeof m.index === 'number' ? m.index : 0), tracks.length - 1); // Cratarr's queue as it is (shuffled already, its hand-added ones in it): this page keeps none of its own. local.queue = tracks; local.shuffled = m.shuffle === true; if (local.repeat === 'one') local.repeat = 'off'; local.from = null; fresh(index, (typeof m.position_ms === 'number' ? m.position_ms : 0) / 1000, m.paused !== true); fill(); return m.paused !== true; } case 'add': { const tracks = tracksOf(m.tracks); if (!tracks.length) return false; const after = typeof m.after === 'string' ? (m.after === '' ? 0 : local.queue.findIndex((t) => t.entry === m.after) + 1 || -1) : -1; const at = after >= 0 ? after : typeof m.at === 'number' ? Math.min(Math.max(0, m.at), local.queue.length) : local.queue.length; local.queue = [...local.queue.slice(0, at), ...tracks, ...local.queue.slice(at)]; if (local.index < 0) fresh(0, 0, false); else cutFrom(at); return false; } case 'move': { const from = placeOf(m, 'entry', 'from'); const afterAt = typeof m.after === 'string' ? (m.after === '' ? 0 : local.queue.findIndex((t) => t.entry === m.after)) : -1; const to = afterAt < 0 ? (typeof m.to === 'number' ? m.to : -1) : m.after === '' ? 0 : afterAt > from ? afterAt : afterAt + 1; move(from, to); return false; } case 'remove': remove(placeOf(m, 'entry', 'index')); return false; case 'upcoming': { if (typeof m.shuffled === 'boolean') local.shuffled = m.shuffled; const keep = local.index + 1; local.queue = [...local.queue.slice(0, keep), ...tracksOf(m.tracks)]; cutFrom(keep); return false; } case 'jump': { const at = placeOf(m, 'entry', 'index'); if (at >= 0) jump(at); return at >= 0; } case 'pause': audio.pause(); return false; case 'resume': audio.play().catch(() => {}); return true; case 'toggle': togglePlay(); return !audio.paused; case 'stop': stop(); return false; case 'next': next(); return false; case 'previous': previous(); return false; case 'seek': if (typeof m.position_ms === 'number') seek(m.position_ms / 1000); return false; case 'repeat': if (m.mode === 'off' || m.mode === 'one' || m.mode === 'all') setRepeat(m.mode); return false; case 'radio': setRadio(m.on === true, { ...(m.by === 'track' || m.by === 'artist' || m.by === 'era' ? { by: m.by as RadioBy } : {}), ...(typeof m.count === 'number' ? { count: m.count } : {}), }); return false; case 'autoplay': if (typeof m.mode === 'string') setAutoplay(m.mode as AutoplayMode | 'stop', { ...(typeof m.albums === 'boolean' ? { albums: m.albums } : {}), ...(typeof m.restart === 'boolean' ? { restart: m.restart } : {}), ...(Array.isArray(m.seed) ? { seed: m.seed as string[] } : {}), ...(typeof m.within === 'string' || m.within === null ? { within: m.within as string | null } : {}), }); return false; case 'skip_favourites': setSkipFavourites(m.on === true); return false; case 'volume': pageVolume.level = Math.min(100, Math.max(0, typeof m.level === 'number' ? m.level : 100)); applyVolume(); return false; case 'duck': case 'unduck': pageVolume.ducked = m.command === 'duck'; applyVolume(); return false; default: // The sleep timer is this page's own (lib/sleep); sync play and its measurements are not a page's. return false; } } if (typeof navigator !== 'undefined' && 'mediaSession' in navigator) { const ms_ = navigator.mediaSession; ms_.setActionHandler('play', () => audio?.play()); ms_.setActionHandler('pause', () => audio?.pause()); ms_.setActionHandler('nexttrack', () => next()); ms_.setActionHandler('previoustrack', () => previous()); try { ms_.setActionHandler('seekto', (d) => d.seekTime !== undefined && seek(d.seekTime)); } catch { /* older browsers */ } } ===== src/lib/motion.ts ===== // Finamp's motion (decision 0018, read in its source 2026-10-14): the values the player and the menus use, so the // port moves as Finamp does. Reduced motion turns off what Finamp turns off (the player's open and close, the colour // and background fades, the cover fade). /** Flutter's `Easing.legacyDecelerate` (bottom sheets), as a CSS curve and a Svelte easing. */ export const DECELERATE_CSS = 'cubic-bezier(0, 0, 0.2, 1)'; export const decelerate = (t: number) => { // cubic-bezier(0, 0, 0.2, 1) solved for y at x = t (Newton's method on x). let u = t; for (let i = 0; i < 6; i++) { const x = 3 * (1 - u) * u * u * 0.2 + u * u * u - t + 3 * (1 - u) * (1 - u) * u * 0; const dx = 6 * (1 - u) * u * 0.2 - 3 * u * u * 0.2 + 3 * u * u; if (Math.abs(dx) < 1e-6) break; u -= x / dx; } u = Math.min(1, Math.max(0, u)); return 3 * (1 - u) * u * u * 1 + u * u * u + 3 * (1 - u) * (1 - u) * u * 0; }; export const reducedMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches; /** Bottom sheets (menus, the queue): in 250 ms, out 200 ms (Flutter's showModalBottomSheet). */ export const SHEET_IN = 250; export const SHEET_OUT = 200; ===== src/lib/output.svelte.ts ===== // Where music plays (decisions 0019, 0022, 0031): on a media server's player (remote: Finamp on the tablet), in this // browser or app (local, lib/localPlayer.svelte.ts), or on one of Cratarr's own players (Play On, lib/playOn.svelte.ts), // which the page drives as it drives the local player. The player shows each the same way: the queue the page holds is // presented as a now-playing view like the server's. Switching moves the music: the queue and the position go to the // other side, which plays from there, and the side that played stops. import { albumView, handover, playPlaylist, playlistRows, playlistTrackIds, queueTracks, setMember, setTrackMember, trackDetails, trackPlayed, trackState, transport, type NowPlaying, type PlayMode, type Playlist, type TrackState, } from './api'; import { untrack } from 'svelte'; import { live, refresh, untilNewTrack } from './live.svelte'; import { canPlayHere, local, playQueue, enqueue, stop, togglePlay, type LocalTrack } from './localPlayer.svelte'; import { inApp } from './app.svelte'; import { drivenName, follow, outputName, playOn } from './playOn.svelte'; import { prefs } from './prefs.svelte'; import { toast } from './toast.svelte'; // Whether the page holds the queue: it plays here, or on one of Cratarr's players it drives. A browser that cannot play // here (no stream for audio) plays on a player, whatever was chosen elsewhere. export const isLocal = () => (prefs.output === 'local' && canPlayHere) || drivenName() !== null; // "The server's player" while one of Cratarr's own players plays: that player, driven. Cratarr's own source has no // server-side player; such a page only showed what that player played, without seek or queue edits, while its output // read like the player's (2026-10-16). if (typeof window !== 'undefined') { $effect.root(() => { $effect(() => { if (prefs.output !== 'remote') return; const name = live.view?.now_playing?.player; if (name && playOn.players.some((p) => p.name === name && p.online)) { untrack(() => { prefs.output = `player:${name}`; follow(name); }); } }); }); } /** Whether the music plays in this page (its stream, or the app it runs in). */ export const playsHere = () => prefs.output === 'local' && canPlayHere; // Leaving the page stops what plays in this browser, so a reload or closing the tab asks first (the browser's own // "Leave site?"), but only while this browser is playing. addEventListener('beforeunload', (e) => { if (!playsHere() || local.index < 0 || local.paused) return; e.preventDefault(); e.returnValue = ''; }); /** The local queue's playing track as a now-playing view (the shape the player shows). */ export function localNowPlaying(): NowPlaying | null { const t = local.queue[local.index]; if (!t) return null; return { source: t.source, id: t.id, name: t.name || '…', artist: t.artist, album: t.album, album_id: t.album_id, addable: localState.view?.addable ?? t.addable ?? true, progress: { position_secs: local.position, duration_secs: local.duration, paused: local.paused, estimated: false }, player: outputName(), favorite: localState.view?.favorite ?? null, // As the heart, the chips stay until the new track's arrive (no row coming and going between tracks). info: localState.view?.info ?? null, upcoming: local.queue.slice(local.index + 1).map((u) => ({ id: u.id, name: u.name || '…', artist: u.artist, album: u.album, album_id: u.album_id, addable: u.addable ?? true, duration_secs: u.duration_secs ?? null, lane: u.lane, })), }; } // The local track's playlists and favourite: the server's state covers only its own player's track, so they are // asked for this one (on each new track, and when the playlists change). // `loading`: the playing track's are asked for. Meanwhile the track before's stay shown (the heart, the playlists), // disabled: the same answer, as between tracks of one album, then shows no change at all. export const localState = $state<{ key: string | null; view: TrackState | null; loading: boolean }>({ key: null, view: null, loading: false, }); const keyOf = (t: LocalTrack) => `${t.source}/${t.id}`; async function loadState(t: LocalTrack) { try { const view = await trackState(t.source, t.id); if (localState.key !== keyOf(t)) return; localState.view = view; } catch { /* shown as in no playlist, without a heart */ if (localState.key === keyOf(t)) localState.view = null; } finally { if (localState.key === keyOf(t)) localState.loading = false; } } $effect.root(() => { $effect(() => { const t = local.queue[local.index]; const key = t ? keyOf(t) : null; if (key === localState.key) return; localState.key = key; if (!t) localState.view = null; localState.loading = !!t; if (t) loadState(t); }); // Playlists made, renamed or changed elsewhere: the server's state says so (by their counts and names). let seen = ''; $effect(() => { const sig = (live.view?.playlists ?? []).map((p) => `${p.id}:${p.count}`).join('|'); const t = local.queue[local.index]; if (sig === seen) return; const first = !seen; seen = sig; if (!first && t && isLocal()) loadState(t); }); }); /** The playlists as shown with what plays: membership of the server's player's track, or of the local one. */ export function shownPlaylists(): Playlist[] { if (!isLocal()) return live.view?.playlists ?? []; return localState.view?.playlists ?? (live.view?.playlists ?? []).map((p) => ({ ...p, member: false })); } /** Puts what plays in the playlist or takes it out, where the output is. */ export async function setPlayingMember(playlistId: string, member: boolean) { if (!isLocal()) { live.view = await setMember(playlistId, member, prefs.source || undefined); return; } const t = local.queue[local.index]; if (!t) throw new Error('Nothing is playing.'); const view = await setTrackMember(t.source, t.id, playlistId, member); if (localState.key === keyOf(t)) localState.view = view; } /** The local favourite changed: the answer, if it is still about the playing track. */ export function tookLocal(source: string, id: string, view: TrackState) { if (localState.key === `${source}/${id}`) localState.view = view; } // A track the engine could not play is skipped (as a player does); it says which and why, once. The line goes once a // later track plays (the engine clears the error), unless another toast replaced it meanwhile. let errorToast: number | null = null; $effect.root(() => { $effect(() => { const e = local.error; untrack(() => { if (e) { toast.show({ text: e, tone: 'error' }); errorToast = toast.current?.id ?? null; } else if (errorToast !== null) { if (toast.current?.id === errorToast) toast.dismiss(); errorToast = null; } }); }); }); // Plays count (decision 0022): once a track has played 90% through (Jellyfin's own "played" mark), once per time it // is reached in the queue; the server counts it for the account it plays as. const PLAYED_AT = 0.9; /** The queue place (index and track) counted; forgotten once the queue moves elsewhere, so coming back counts again. */ let counted: string | null = null; $effect.root(() => { $effect(() => { const t = local.queue[local.index]; const at = `${local.index}/${t ? keyOf(t) : ''}`; // Counted where it plays: here; a player of Cratarr's counts its own, and so does the app this page may run in // (decision 0033: offline and behind a locked screen too). if (!t || !playsHere() || inApp) return; if (counted !== at) counted = null; const d = local.duration; if (counted === at || !d || local.position < d * PLAYED_AT) return; counted = at; trackPlayed(t.source, t.id).catch(() => { /* not counted; playing goes on */ }); }); }); // Track details (title, artist, album) arrive lazily: a playlist starts with ids only; the playing track and the next // few are filled in as the queue moves. const asked = new Set(); async function fill(from: number) { for (let i = Math.max(0, from); i < Math.min(local.queue.length, from + 4); i++) { const t = local.queue[i]; const key = `${t.source}/${t.id}`; if (t.name || asked.has(key)) continue; asked.add(key); try { const d = await trackDetails(t.source, t.id); const j = local.queue.findIndex((q) => q.source === t.source && q.id === t.id && !q.name); if (j >= 0) local.queue[j] = { ...local.queue[j], name: d.name, artist: d.artist, album: d.album, album_id: local.queue[j].album_id ?? d.album_id }; } catch { asked.delete(key); } } } $effect.root(() => { $effect(() => { void local.index; void local.queue.length; fill(local.index); }); }); /** Plays a playlist where the output is: the server's player, or here (its tracks fetched, then the details). */ export async function playPlaylistHere(id: string, mode: PlayMode): Promise { if (!isLocal()) return false; // With their names: a player elsewhere (the app, a Cratarr player) shows them at once, on its lock screen too. const { source, rows } = await playlistRows(id, prefs.source || undefined); const named: LocalTrack[] = rows .filter((r) => r.id && !r.missing) .map((r) => ({ source, id: r.id!, name: r.name ?? '', artist: r.artist, album: r.album, album_id: null })); if (!named.length) throw new Error('None of its tracks can be found.'); if (mode === 'next' || mode === 'last') enqueue(named, mode); else playQueue(named, 0, id, 0, mode === 'shuffle'); return true; } /** Plays a playlist where the output is (its menu's Play, Shuffle, Play next and Add to queue; its view's buttons): * here, or on the server's player, showing the new track once it is there for Play and Shuffle. */ export async function startPlaylist(id: string, mode: PlayMode) { if (await playPlaylistHere(id, mode)) return; await playPlaylist(id, mode, prefs.source || undefined); if (mode === 'play' || mode === 'shuffle') await untilNewTrack(); else refresh(); } /** Several playlists (the selection's ⋮, decision 0025): their tracks one playlist after another, in the order given, * each once, here or on the server's player. */ export async function startPlaylists(ids: string[], mode: PlayMode) { if (ids.length === 1) return startPlaylist(ids[0], mode); let source = ''; const seen = new Set(); const all: string[] = []; for (const id of ids) { const r = await playlistTrackIds(id, prefs.source || undefined); source = r.source; for (const t of r.ids) if (!seen.has(t)) seen.add(t) && all.push(t); } if (!all.length) throw new Error('None of their tracks can be found.'); const order = mode === 'shuffle' ? [...all].sort(() => Math.random() - 0.5) : all; if (isLocal()) { const tracks: LocalTrack[] = all.map((id) => ({ source, id, name: '', artist: null, album: null, album_id: null })); if (mode === 'next' || mode === 'last') enqueue(tracks, mode); else playQueue(tracks, 0, null, 0, mode === 'shuffle'); return; } if (mode === 'next' || mode === 'last') await queueTracks(order, mode, source); else await handover(order, 0, 0, source); setTimeout(refresh, 700); } /** Plays a playlist from one of its tracks (a tap on a row of its view, decision 0023): these tracks from `index`, here * or on the server's player. */ export async function playPlaylistFrom(name: string, source: string, tracks: LocalTrack[], index: number, position = 0) { if (isLocal()) return playQueue(tracks, index, name, position); await handover( tracks.map((t) => t.id), index, position, source, ); setTimeout(refresh, 700); } /** Plays an album here when the output is this browser. Whether its tracks can go in a playlist (in Music, not * Incoming) the server says with each track's state. */ export async function playAlbumHere(source: string, album: string, title?: string): Promise { if (!isLocal()) return false; const r = await albumView(source, album); if (!r.rows.length) throw new Error('That album has no tracks.'); playQueue( r.rows.map((x) => ({ source: r.source, id: x.id!, name: x.name ?? x.file, artist: x.artist, album: x.album, album_id: album, duration_secs: x.duration_secs })), 0, title ?? r.title, ); return true; } /** What plays where the output is now, to move it: the queue, the place in it and the position (s). */ function current(): { tracks: LocalTrack[]; index: number; position: number } | null { if (isLocal()) return local.index < 0 ? null : { tracks: [...local.queue], index: local.index, position: local.position }; const np = live.view?.now_playing ?? null; if (!np) return null; const tracks: LocalTrack[] = [np, ...np.upcoming].map((t) => ({ source: np.source, id: t.id, name: t.name, artist: t.artist, album: t.album, album_id: t.album_id, })); return { tracks, index: 0, position: np.progress?.position_secs ?? 0 }; } /** Plays on `output` from now on, and moves what plays there: the queue from the playing track, at its position; the * side that played stops. */ export async function moveTo(output: typeof prefs.output) { if (output === prefs.output) return; const was = current(); if (isLocal()) { stop(); } else if (was) { try { await transport('stop', was.tracks[0].source); } catch { /* it may have stopped already */ } } prefs.output = output; follow(output.startsWith('player:') ? output.slice('player:'.length) : null); if (!was) return; if (output === 'remote') { await handover(was.tracks.map((t) => t.id), was.index, was.position, was.tracks[0].source); setTimeout(refresh, 700); return; } playQueue(was.tracks, was.index, null, was.position > 1 ? was.position : 0); } /** Whether music plays (or waits, paused) where the music plays now: what a move would take along. */ export const hasMusic = () => current() !== null; /** Plays on `output` from now on without moving anything there: what plays there plays on, and what played here pauses * (the owner chose not to take that player over). */ export async function joinTo(output: typeof prefs.output) { if (output === prefs.output) return; if (isLocal()) { if (local.index >= 0 && !local.paused) togglePlay(); } else { const np = live.view?.now_playing ?? null; if (np && !np.progress?.paused) { try { await transport('play-pause', np.source); } catch { /* it may have stopped already */ } } } prefs.output = output; follow(output.startsWith('player:') ? output.slice('player:'.length) : null); } /** Moves the music here. */ export const toLocal = () => moveTo('local'); /** Moves the music to the server's player. */ export const toRemote = () => moveTo('remote'); ===== src/lib/pageView.svelte.ts ===== // What the page shown now hands the pill's sheets (lib/TopPill, decided 2026-10-15): a page's view settings (order, // layout, size) live in the View sheet (lib/ViewSheet) and its own filters in the Filter sheet (lib/FilterSheet), not // on the page. The page measures its grid and keeps its list as these say; the sheets read and change them (most are // prefs, remembered by the browser). import { untrack } from 'svelte'; import type { IconName } from './Icon.svelte'; import type { AlbumLayout, CollectionOrder, CrateOrder, PlaylistOrder } from './prefs.svelte'; /** The page's own button at the pill's end: a + where it makes something, or with things ticked their menu (⋮). */ /** `progress`: a ring around the icon, 0 to 1, while work goes on (the Downloads page's sync, decision 0038). */ export type PageAction = { icon: IconName; label: string; run: () => void; progress?: number | null }; /** A playlist chip's least width per size step (prefs.zoom). */ export const PLAYLIST_WIDTHS: readonly number[] = [110, 140, 170, 210, 260, 340]; /** A menu's playlists' least width per size step (Add to playlist, lib/PlaylistToggles): on a phone one column or two, * never three (the owner: a toggle card narrower than that cannot show its name); three on a wider sheet. */ export const LIST_WIDTHS: readonly number[] = [160, 220, 340]; /** An album's least width per size step (prefs.albumZoom): rows need room for their lines, covers less. A table has one * album a line, and no size steps (decision 0042). */ export const ALBUM_WIDTHS: Record = { rows: [240, 280, 340, 420, 520, 680], grid: [110, 140, 170, 210, 260, 340], table: [], }; export const ALBUM_LAYOUTS = [ { value: 'rows' as const, label: 'Rows', icon: 'list' as const }, { value: 'grid' as const, label: 'Covers', icon: 'layoutGrid' as const }, { value: 'table' as const, label: 'Table', icon: 'table' as const }, ]; // Each order ascending as named (A–Z, oldest first, fewest first); a list's arrow turns it round. Plays, Last played // and Rating (how you used them, decided 2026-10-16) start the other way round: most first. export const ALBUM_ORDERS: { value: CollectionOrder; label: string }[] = [ { value: 'artist', label: 'Artist' }, { value: 'title', label: 'Title' }, { value: 'year', label: 'Year' }, { value: 'added', label: 'Added' }, { value: 'plays', label: 'Plays' }, { value: 'played', label: 'Last played' }, { value: 'rating', label: 'Rating' }, ]; /** The orders by how you used the albums (lib/albums albumUses): read when chosen, most first when chosen. */ export const USE_ORDERS: readonly CollectionOrder[] = ['plays', 'played', 'rating']; /** The Playlists page's layouts: its chips, or a table (decision 0042). */ export const PLAYLIST_LAYOUTS = [ { value: 'chips' as const, label: 'Chips', icon: 'layoutGrid' as const }, { value: 'table' as const, label: 'Table', icon: 'table' as const }, ]; export const PLAYLIST_ORDERS: { value: PlaylistOrder; label: string }[] = [ { value: 'name', label: 'Name' }, { value: 'tracks', label: 'Tracks' }, { value: 'playing', label: 'Playing first' }, ]; /** A menu's Add to playlist (lib/PlaylistToggles): the same orders, those the item is in ticked first. */ export const LIST_ORDERS: { value: PlaylistOrder; label: string }[] = PLAYLIST_ORDERS.map((o) => o.value === 'playing' ? { ...o, label: 'Ticked first' } : o, ); /** The playlists' order (the Playlists page's, a menu's Add to playlist): by name, by size, or those holding the item * first (the playing track, or the menu's: `holds`); equals by name. */ export function playlistBy(order: PlaylistOrder, holds: (p: T) => boolean) { const byName = (a: T, b: T) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true }); return (a: T, b: T) => order === 'tracks' ? a.count - b.count || byName(a, b) : order === 'playing' ? Number(holds(b)) - Number(holds(a)) || byName(a, b) : byName(a, b); } export const CRATE_ORDERS: { value: CrateOrder; label: string }[] = [ { value: 'own', label: 'As made' }, { value: 'name', label: 'Name' }, ]; /** View's Dates, on the album lists: how long ago, or the date. */ export const DATES = [ { value: true, label: 'Relative' }, { value: false, label: 'Absolute' }, ]; export const DIRECTIONS = [ { value: false, label: 'Ascending', icon: 'arrowUp' as const }, { value: true, label: 'Descending', icon: 'arrowDown' as const }, ]; export const pageView = $state({ /** The page's grid width, which its size steps depend on (lib/SizeControl). */ width: 0, /** Playlists, Library: favourites only; not remembered, the list's own (off again on another page). */ favouritesOnly: false, /** Library: only Incoming's albums that would upgrade one in Music; not remembered either. */ upgradesOnly: false, /** Library: only albums never played, and only those rated 4 stars or 5 (decided 2026-10-16); not remembered. */ unplayedOnly: false, topRatedOnly: false, /** The page's search (the pill's field): what its list is filtered by; empty again on another page. */ query: '', /** A library of the page's list is switched off (lib/AlbumShelf): the pill's funnel is lit. */ narrowed: false, /** The page's own button at the pill's end (usePageAction), while it shows. */ action: null as PageAction | null, }); /** Gives the pill's last button to the calling page while it shows (an effect: it follows what `get` reads). */ export function usePageAction(get: () => PageAction | null) { $effect(() => { const mine = get(); pageView.action = mine; // Gone with the page, unless the next page has set its own already. return () => { if (untrack(() => pageView.action?.run) === mine?.run) pageView.action = null; }; }); } /** The pill's search field's words on a page that has one. */ export const searchFor = (page: string) => page === 'playlists' ? 'Filter playlists' : page === 'library' || page === 'downloads' ? 'Filter albums' : null; /** The pages whose view settings the View sheet holds. */ export const hasView = (page: string) => ['playlists', 'library', 'crates', 'downloads'].includes(page); /** How many, in words. */ export const howMany = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString()} ${n === 1 ? one : many}`; ===== src/lib/panel.svelte.ts ===== // The panel's subject (decisions 0023, 0025): the player, or what is viewed lying under it: a selection of one or more // playlists in tabs (Tracks, Info, Export, Repair, Sync), a crate (`crate:…`, its builder `crate-new`, `crate-edit:…`), // or an album (`album:/`). // It is in the address as `?view=…` (repeated for each playlist) and `&tab=…` on whichever page shows (Back closes // it, a reload brings it back), and it stays viewed across pages: the navigation keeps it in its links (`keepViewed`). export type Tab = 'tracks' | 'info' | 'export' | 'repair' | 'sync'; export const TABS: { tab: Tab; label: string }[] = [ { tab: 'tracks', label: 'Tracks' }, { tab: 'info', label: 'Info' }, { tab: 'export', label: 'Export' }, { tab: 'repair', label: 'Repair' }, { tab: 'sync', label: 'Sync' }, ]; const isTab = (t: string | null): t is Tab => TABS.some((x) => x.tab === t); /** An album in the address, and back. */ const ALBUM = 'album:'; export const albumSubject = (source: string, id: string) => `${ALBUM}${source}/${id}`; export function albumOf(viewed: string | null): { source: string; id: string } | null { if (!viewed?.startsWith(ALBUM)) return null; const rest = viewed.slice(ALBUM.length); const at = rest.lastIndexOf('/'); return at > 0 && at < rest.length - 1 ? { source: rest.slice(0, at), id: rest.slice(at + 1) } : null; } /** An artist in the address (`artist:/`, decided 2026-10-16), by name as the albums credit them; and all * their tracks, most played first (`artist-tracks:`, their Top tracks' ›). */ const ARTIST = 'artist:'; const ARTIST_TRACKS = 'artist-tracks:'; export const artistSubject = (source: string, name: string, tracks = false) => `${tracks ? ARTIST_TRACKS : ARTIST}${source}/${name}`; export function artistOf(viewed: string | null): { source: string; name: string; tracks: boolean } | null { const tracks = !!viewed?.startsWith(ARTIST_TRACKS); if (!tracks && !viewed?.startsWith(ARTIST)) return null; const rest = viewed!.slice((tracks ? ARTIST_TRACKS : ARTIST).length); // A server's id has no slash; a name may. const at = rest.indexOf('/'); return at > 0 && at < rest.length - 1 ? { source: rest.slice(0, at), name: rest.slice(at + 1), tracks } : null; } /** A crate, its builder, an album or an artist: a subject of one, without tabs (decision 0025). */ /** A playlist being made: the panel's edit mode, with no playlist yet (New playlist, as `crate-new` is New crate). */ export const NEW_PLAYLIST = 'playlist-new'; /** A part of the settings (`settings:libraries`), opened from the Settings page's cards (lib/SettingsPanel). */ export const SETTINGS = 'settings:'; export const settingsOf = (viewed: string | null) => (viewed?.startsWith(SETTINGS) ? viewed.slice(SETTINGS.length) : null); export const isSingleSubject = (viewed: string | null) => !!viewed && (viewed.startsWith('crate:') || viewed === 'crate-new' || viewed.startsWith('crate-edit:') || viewed === NEW_PLAYLIST || viewed.startsWith(SETTINGS) || viewed.startsWith(ALBUM) || artistOf(viewed) !== null); export const panel = $state({ /** What is viewed: the (first) playlist's id, a crate's or an album's key; null when the panel shows the player only. */ viewed: null as string | null, /** The playlists selected, in the order ticked; empty for a crate. */ selection: [] as string[], tab: 'tracks' as Tab, /** The player shows over what is viewed (the mini-player was tapped); folding the player shows it again. */ playerTop: false, /** Bumped when the viewed playlist is viewed again: its view scrolls back to the top. */ again: 0, /** The filter's Hide missing tracks, for what is viewed only: off again when something else is (not remembered, * as no filter but the server is, decided 2026-10-16). */ hideMissing: false, /** The filter's tempo range (decision 0041), band edges in BPM; null keeps every track. For what is viewed only, as * Hide missing tracks. */ tempo: null as [number, number] | null, }); const query = () => new URLSearchParams(location.hash.split('?')[1] ?? ''); const base = () => location.hash.split('?')[0] || '#/'; function address(names: string[], tab: Tab) { const q = new URLSearchParams(); for (const n of names) q.append('view', n); if (tab !== 'tracks') q.set('tab', tab); return `${base()}?${q}`; } // The history entries the panel added in this visit carry how many there are: closing goes Back over all of them, so // the history stays tidy (viewing adds one; another view from the menu adds one more). const depth = (): number => (history.state && typeof history.state.panel === 'number' ? history.state.panel : 0); /** How many of the panel's own views (and tab steps) this visit went through to here: back goes to the one before. */ export const panelDepth = depth; function push(url: string) { history.pushState({ panel: depth() + 1 }, '', url); } function replace(url: string) { history.replaceState(history.state, '', url); } function read() { const q = query(); const viewed = q.getAll('view'); const first = viewed[0] ?? null; if (first !== panel.viewed || viewed.join('\n') !== panel.selection.join('\n')) { panel.playerTop = false; panel.hideMissing = false; panel.tempo = null; } panel.viewed = first; panel.selection = first && !isSingleSubject(first) ? viewed : []; const tab = q.get('tab'); panel.tab = panel.selection.length && isTab(tab) ? tab : 'tracks'; } read(); addEventListener('hashchange', read); addEventListener('popstate', read); /** Views playlists (one, or a selection), a crate or an album in the panel, on `tab`. What is already viewed is replaced, * without a step in the history (no stack: ✕ and Back then close it rather than going to the one before). */ export function viewSelection(names: string[], tab: Tab = 'tracks') { if (!names.length) return closePlaylist(); const t = isSingleSubject(names[0]) ? 'tracks' : tab; panel.playerTop = false; if (names.join('\n') === (panel.selection.length ? panel.selection : [panel.viewed]).join('\n') && t === panel.tab) { panel.again++; return; } if (panel.viewed) replace(address(names, t)); else push(address(names, t)); read(); } /** Views one playlist (or a crate, or an album) on its Tracks. */ export const viewPlaylist = (name: string) => viewSelection([name]); /** Views a subject over the one viewed, going deeper (an album's artist, an artist's album, as Symfonium's): a step of * its own, which back returns from. With nothing viewed, as viewPlaylist. */ export function viewOver(name: string) { if (!panel.viewed) return viewPlaylist(name); panel.playerTop = false; if (name === panel.viewed && !panel.selection.length) { panel.again++; return; } push(address([name], 'tracks')); read(); } /** One of the playlists' other views (Info, Export, Repair, Sync: from their menu, decided 2026-10-16). Over their * tracks it is a step of its own, which back returns from (backToTracks); playlists not viewed yet open on it. */ export function openTab(names: string[], tab: Tab) { if (names.join('\n') !== panel.selection.join('\n')) return viewSelection(names, tab); if (tab === panel.tab) return; history.pushState({ panel: depth() + 1, tab: true }, '', address(panel.selection, tab)); read(); } /** Back from another view to the tracks: the step it was opened as, or else the tracks in its place. */ export function backToTracks() { if (history.state?.tab) return history.back(); replace(address(panel.selection, 'tracks')); read(); } /** Closes what is viewed (✕, Esc): the player shows if something plays, else the panel goes. `then` runs once it * is closed (going Back is not immediate). */ export function closePlaylist(then?: () => void) { panel.playerTop = false; const d = depth(); if (d > 0) { if (then) addEventListener('popstate', () => then(), { once: true }); history.go(-d); } else { replace(base()); read(); then?.(); } } /** A renamed playlist stays viewed under its new name; a deleted one leaves the selection. */ export function playlistRenamed(from: string, to: string) { if (!panel.selection.includes(from)) return; replace(address(panel.selection.map((x) => (x === from ? to : x)), panel.tab)); read(); } export function playlistDeleted(id: string) { if (!panel.selection.includes(id)) return; const rest = panel.selection.filter((x) => x !== id); if (!rest.length) return closePlaylist(); replace(address(rest, panel.tab)); read(); } /** An album that moved (kept or replaced into Music: a new id on the server) stays viewed as itself there. */ export function albumMoved(source: string, from: string, to: string) { if (panel.viewed !== albumSubject(source, from)) return; replace(address([albumSubject(source, to)], 'tracks')); read(); } /** An album that left Incoming (kept, replaced or deleted) closes. */ export function albumGone(source: string, id: string) { if (panel.viewed === albumSubject(source, id)) closePlaylist(); } /** A page's link that keeps what is viewed viewed. */ export const keepViewed = (href: string) => { const q = location.hash.split('?')[1]; return panel.viewed && q ? `${href}?${q}` : href; }; ===== src/lib/playOn.svelte.ts ===== // Cratarr's own players (Play On, decision 0031): the Android app on a tablet or a phone. The live channel says // what each plays (`players`); the page can play on one (prefs.output `player:`), which it then drives as it // drives the local player (localPlayer.svelte.ts, `drive`): the queue, the now-playing view and the controls are the // same, the music plays there. import { getPlayer, getPlayers, playerCommand, type PlayOnPlayer } from './api'; import { appInfo, inApp, toApp } from './app.svelte'; import { on } from './events.svelte'; import { drive, reportedBy, type LocalTrack } from './localPlayer.svelte'; import { prefs } from './prefs.svelte'; export const playOn = $state({ players: [] as PlayOnPlayer[] }); /** The player chosen as the output, by name; null when the output is not one of Cratarr's players. */ export const drivenName = () => (prefs.output.startsWith('player:') ? prefs.output.slice('player:'.length) : null); /** The players that can be chosen: connected now, and not the app this page runs in (that one is "This device"). */ export const choosable = () => playOn.players.filter((p) => p.online && !(inApp && p.name === appInfo.player) && !(!inApp && !!prefs.browserName && p.name === prefs.browserName)); /** In the app, while this device follows a group (decision 0037): the group's leader. The device plays no queue of its * own then, so the page shows the leader's and its controls act there (the owner, 2026-10-16: the follower showed no * player at all). Null otherwise. */ export const followedLeader = () => { if (!inApp || prefs.output !== 'local' || !appInfo.player) return null; const group = playOn.players.find((p) => p.name === appInfo.player)?.group; return group && group !== appInfo.player ? group : null; }; /** The player whose reports the page shows and drives: the one chosen, else the leader this device follows. */ const shownName = () => drivenName() ?? followedLeader(); /** What the local player was last told to drive. */ let driving: string | null = null; function driveShown() { const name = shownName(); if (name === driving) return; const was = driving; driving = name; shownVersion = -1; drive(name); // Out of the group again: the app's own player says what it holds now. if (!name && was && inApp) toApp({ type: 'hello' }); } /** The one of Cratarr's players the page plays on: the one chosen, or the app this page runs in playing here. */ export const head = () => drivenName() ?? (inApp && prefs.output === 'local' ? appInfo.player : null); /** The players of `name`'s group (decision 0037), its leader first; none while it plays alone. */ export function groupOf(name: string | null): string[] { const leader = name ? playOn.players.find((p) => p.name === name)?.group : null; if (!leader) return []; const rest = playOn.players.filter((p) => p.group === leader && p.name !== leader).map((p) => p.name); return [leader, ...rest]; } /** Where the music plays, as the device button says it: a group by its players ("Salon tablet + Phone"). */ export function outputName(): string { const shown = (n: string) => (inApp && n === appInfo.player ? 'This device' : n); const g = groupOf(head()); return g.length > 1 ? g.map(shown).join(' + ') : (drivenName() ?? 'This device'); } /** A player held in the device sheet: into the group of the one played on, or out of it. */ export async function toggleGroup(name: string) { const h = head(); if (!h || name === h) return; if (groupOf(h).includes(name)) await playerCommand(name, { command: 'leave' }); else await playerCommand(name, { command: 'join', to: h }); } /** The group played on ends (a tap plays on one player alone): its players but the leader leave it. */ export async function endGroup() { const [, ...rest] = groupOf(head()); for (const n of rest) await playerCommand(n, { command: 'leave' }); } /** The queue version shown, per player: a newer one is read whole. */ let shownVersion = -1; function take(players: PlayOnPlayer[]) { playOn.players = players; driveShown(); const name = shownName(); const p = name ? players.find((x) => x.name === name) : undefined; if (!p) return; const sleep = p.sleep ? { atMs: p.sleep.at_ms, endOfTrack: p.sleep.end_of_track } : null; reportedBy({ index: p.index, positionMs: p.position_ms, durationMs: p.duration_ms ?? -1, paused: !p.playing, error: null, repeat: p.repeat, sleep, radio: p.radio ?? false, radioBy: p.radio_by, radioCount: p.radio_count, skipFavourites: p.skip_favourites, autoplay: p.autoplay ?? null, }); if (p.queue_version !== shownVersion) { const version = p.queue_version; getPlayer(p.name) .then((full) => { if (shownName() !== full.name) return; shownVersion = version; reportedBy({ queue: (full.queue ?? []) as LocalTrack[], index: full.index, positionMs: full.position_ms, durationMs: full.duration_ms ?? -1, paused: !full.playing, error: null, }); }) .catch(() => { /* asked again with the next change */ }); } } /** Plays on player `name` from now on (or here again: `null`), as the output says. */ export function follow(name: string | null) { driving = name; drive(name); shownVersion = -1; take(playOn.players); } if (typeof window !== 'undefined') { on('players', take); getPlayers() .then((r) => take(r.players)) .catch(() => { /* the live channel brings them */ }); // A page opened with a player chosen before: drive it from the start. driveShown(); } ===== src/lib/portal.ts ===== // Moves an element to the end of the page, so a sheet opened inside a layer (the playlist layer, decision 0023) is not // held under that layer's stacking: it shows over the mini-player and everything else, as a sheet should. Svelte no // longer finds it where its block was, so it is removed here when the block goes (after its out transition). export function portal(node: HTMLElement) { document.body.appendChild(node); return { destroy() { node.remove(); }, }; } ===== src/lib/prefs.svelte.ts ===== // Display preferences: per browser, kept in localStorage (which may be unavailable; then defaults apply and nothing // is remembered). Server-wide settings live on the server, see Settings.svelte. import { inApp, toApp } from './app.svelte'; import { LIST_WIDTHS } from './pageView.svelte'; export type Theme = 'auto' | 'light' | 'dark'; export type Prefs = { theme: Theme; /** Index into the playlist chip widths (Playlists.svelte). */ zoom: number; /** Index into a menu's playlists' widths (LIST_WIDTHS, Add to playlist): one column by default. */ listZoom: number; /** A menu's playlists' order (its header, LIST_ORDERS), either way round. */ listOrder: PlaylistOrder; listDown: boolean; /** Playlists the track is in come first. */ showProgress: boolean; /** Levelling (decision 0035): this browser's player plays every track about as loud as the next. */ /** This browser's name as a Play On player, while a page plays here (lib/playOnTarget); '' until it has one. */ browserName: string; levelling: boolean; /** View's Dates: an album's date added as how long ago ("3 days ago"), not the date. */ relativeDates: boolean; /** Source this browser follows when several are active; '' = whichever started playing most recently. */ source: string; /** Libraries switched off in the filter (`source/library`): remembered, as the server is (decided 2026-10-16). */ hiddenLibraries: string[]; /** Insights: the open tab, and which signals bring playlists together in Related. */ insightsTab: 'statistics' | 'related'; signals: { tracks: boolean; genre: boolean; artists: boolean }; /** The player's width when it shows beside the page (decision 0018). */ playerWidth: number; /** Wide windows show the player beside the page (true) or the mini-player with the player over the page (false, * after Maximise). */ sidebar: boolean; /** The navigation folded to a strip of icons on a wide window (decision 0042). */ navFolded: boolean; /** Each kind of list's columns in the owner's order (decision 0042, lib/columns), by id. */ columns: Partial>; /** Where music plays (decisions 0019, 0022, 0031): on a server's player (`remote`), in this browser or app (`local`), * or on one of Cratarr's own players by name (`player:Salon tablet`). */ output: 'remote' | 'local' | `player:${string}`; /** Playlists pinned to the top of the Playlists page, in the order pinned (ids). */ pinned: string[]; /** The Playlists page's filter section (the filter and the order) is shown. */ filterOpen: boolean; /** Insights → Related: its filter section (what brings playlists together) is shown. */ relatedFilterOpen: boolean; /** The album lists (Incoming, Collection): rows or a grid of covers, and the size (an index into their widths). */ albumLayout: AlbumLayout; albumZoom: number; /** The Library's order (decision 0034), and each page list's order turned round (descending), and Playlists' and * Crates' own order. */ libraryOrder: CollectionOrder; libraryDown: boolean; /** Downloads' order (the Library's orders), and turned round. */ downloadsOrder: CollectionOrder; downloadsDown: boolean; playlistOrder: PlaylistOrder; playlistDown: boolean; /** The Playlists page as chips or as a table (decision 0042). */ playlistLayout: 'chips' | 'table'; crateOrder: CrateOrder; crateDown: boolean; }; export type AlbumLayout = 'rows' | 'grid' | 'table'; export type CollectionOrder = 'artist' | 'title' | 'year' | 'added' | 'plays' | 'played' | 'rating'; const COLLECTION_ORDERS: readonly CollectionOrder[] = ['artist', 'title', 'year', 'added', 'plays', 'played', 'rating']; export type PlaylistOrder = 'name' | 'tracks' | 'playing'; export type CrateOrder = 'own' | 'name'; const KEY = 'cratarr-prefs'; export const ZOOM_STEPS = 6; export const DEFAULTS: Prefs = { theme: 'auto', zoom: 2, listZoom: LIST_WIDTHS.length - 1, listOrder: 'playing', listDown: false, showProgress: true, levelling: true, browserName: '', relativeDates: false, source: '', hiddenLibraries: [], insightsTab: 'statistics', signals: { tracks: true, genre: true, artists: true }, playerWidth: 400, sidebar: true, navFolded: false, columns: {}, // This browser (it falls back to a player where a browser cannot play): Cratarr's own source has no server-side // player, which a page's first play went to, and failed (2026-10-16). output: 'local', pinned: [], filterOpen: true, relatedFilterOpen: true, albumLayout: 'rows', albumZoom: 2, // Newest first, as Incoming always showed, so a new arrival is on top. libraryOrder: 'added', libraryDown: true, downloadsOrder: 'artist', downloadsDown: false, playlistOrder: 'name', playlistDown: false, playlistLayout: 'chips', crateOrder: 'own', crateDown: false, }; function read(): Prefs { const p = { ...DEFAULTS, signals: { ...DEFAULTS.signals }, pinned: [] as string[], hiddenLibraries: [] as string[] }; try { const saved = JSON.parse(localStorage.getItem(KEY) ?? 'null'); if (saved && typeof saved === 'object') { if (['auto', 'light', 'dark'].includes(saved.theme)) p.theme = saved.theme; if (Number.isInteger(saved.zoom) && saved.zoom >= 0 && saved.zoom < ZOOM_STEPS) p.zoom = saved.zoom; if (Number.isInteger(saved.listZoom) && saved.listZoom >= 0 && saved.listZoom < LIST_WIDTHS.length) p.listZoom = saved.listZoom; if (typeof saved.filterOpen === 'boolean') p.filterOpen = saved.filterOpen; if (typeof saved.relatedFilterOpen === 'boolean') p.relatedFilterOpen = saved.relatedFilterOpen; if (saved.albumLayout === 'rows' || saved.albumLayout === 'grid' || saved.albumLayout === 'table') p.albumLayout = saved.albumLayout; if (Number.isInteger(saved.albumZoom) && saved.albumZoom >= 0 && saved.albumZoom < ZOOM_STEPS) p.albumZoom = saved.albumZoom; if (COLLECTION_ORDERS.includes(saved.libraryOrder)) p.libraryOrder = saved.libraryOrder; if (COLLECTION_ORDERS.includes(saved.downloadsOrder)) p.downloadsOrder = saved.downloadsOrder; if (['name', 'tracks', 'playing'].includes(saved.playlistOrder)) p.playlistOrder = saved.playlistOrder; if (saved.playlistLayout === 'chips' || saved.playlistLayout === 'table') p.playlistLayout = saved.playlistLayout; if (['name', 'tracks', 'playing'].includes(saved.listOrder)) p.listOrder = saved.listOrder; if (typeof saved.listDown === 'boolean') p.listDown = saved.listDown; // Before the list header: "✓ first" was the playlists' only other order. else if (saved.membersFirst === true) p.playlistOrder = 'playing'; if (['own', 'name'].includes(saved.crateOrder)) p.crateOrder = saved.crateOrder; for (const k of ['libraryDown', 'downloadsDown', 'playlistDown', 'crateDown'] as const) { if (typeof saved[k] === 'boolean') p[k] = saved[k]; } if (typeof saved.showProgress === 'boolean') p.showProgress = saved.showProgress; if (typeof saved.levelling === 'boolean') p.levelling = saved.levelling; if (typeof saved.browserName === 'string') p.browserName = saved.browserName; if (typeof saved.relativeDates === 'boolean') p.relativeDates = saved.relativeDates; if (typeof saved.source === 'string') p.source = saved.source; if (Number.isFinite(saved.playerWidth) && saved.playerWidth >= 275) p.playerWidth = saved.playerWidth; if (typeof saved.sidebar === 'boolean') p.sidebar = saved.sidebar; if (typeof saved.navFolded === 'boolean') p.navFolded = saved.navFolded; if (saved.columns && typeof saved.columns === 'object') for (const [k, v] of Object.entries(saved.columns)) if (Array.isArray(v)) p.columns[k] = v.filter((x: unknown): x is string => typeof x === 'string'); if (saved.output === 'remote' || saved.output === 'local' || (typeof saved.output === 'string' && saved.output.startsWith('player:'))) p.output = saved.output; if (Array.isArray(saved.pinned)) p.pinned = saved.pinned.filter((x: unknown): x is string => typeof x === 'string'); if (Array.isArray(saved.hiddenLibraries)) p.hiddenLibraries = saved.hiddenLibraries.filter((x: unknown): x is string => typeof x === 'string'); if (saved.insightsTab === 'statistics' || saved.insightsTab === 'related') p.insightsTab = saved.insightsTab; if (saved.signals && typeof saved.signals === 'object') { for (const k of ['tracks', 'genre', 'artists'] as const) { if (typeof saved.signals[k] === 'boolean') p.signals[k] = saved.signals[k]; } } } else { // Before there were preferences, only the zoom was remembered. const z = Number(localStorage.getItem('cratarr-zoom') ?? NaN); if (Number.isInteger(z) && z >= 0 && z < ZOOM_STEPS) p.zoom = z; } } catch { /* defaults */ } return p; } export const prefs = $state(read()); /** Back to the default look; the followed source and the pins are not display matters and stay. */ export function resetPrefs() { Object.assign(prefs, { ...DEFAULTS, signals: { ...DEFAULTS.signals }, source: prefs.source, hiddenLibraries: prefs.hiddenLibraries, pinned: prefs.pinned }); } /** Pin a playlist to the top of the Playlists page (after those pinned before it), or unpin it. */ export function togglePin(id: string) { prefs.pinned = prefs.pinned.includes(id) ? prefs.pinned.filter((p) => p !== id) : [...prefs.pinned, id]; } // Remember every change and apply the theme (app.css reads data-theme; no attribute = follow the system). $effect.root(() => { $effect(() => { const snapshot = JSON.stringify(prefs); try { localStorage.setItem(KEY, snapshot); } catch { /* not remembered */ } // In the app the same settings go to it too: it hands them to its pages on either of its two addresses (Network). if (inApp) toApp({ type: 'page-prefs', prefs: snapshot }); if (prefs.theme === 'auto') document.documentElement.removeAttribute('data-theme'); else document.documentElement.dataset.theme = prefs.theme; }); }); ===== src/lib/progress.svelte.ts ===== // What the user is waiting for: requests they caused that have not answered yet (api.ts counts them; background // polling does not count). lib/ProgressBar.svelte shows it as a thin bar at the top (docs/ui-conventions.md → Feedback). import { untrack } from 'svelte'; export const loading = $state({ count: 0 }); /** Count `work` as loading until it settles. Not a dependency of the caller: a request started in an effect would * otherwise read and write the count there, and run again without end. */ export async function track(work: Promise): Promise { untrack(() => loading.count++); try { return await work; } finally { untrack(() => loading.count--); } } ===== src/lib/reach.svelte.ts ===== // Whether Cratarr answers. Away from home the proxy in front of it refuses with 403 (Cratarr itself never does); a // stopped Cratarr gets the proxy's empty 502 to 504; without a network the request fails. All three are out of reach: // the pages say so in one line, the pollers ask less often, and the app opens on its downloads (decision 0033, step 4). // Without a session away from home, Cratarr asks for its sign-in instead: the page goes to the sign-in page. export const reach = $state({ /** The last request did not reach Cratarr. */ away: false, /** Cratarr answered at least once since the page opened. */ answered: false, }); /** A status with no words from Cratarr in it that means it was not reached, rather than that it refused. */ export const notReached = (status: number) => status === 403 || status === 502 || status === 503 || status === 504; export function reached() { reach.away = false; reach.answered = true; } export function unreached() { reach.away = true; } /** After the proxy refused (403 without Cratarr's words: its intrusion blocker banned this address; 401: not signed * in), this long without any background request or reconnect: knocking on through a ban keeps it alive (2026-10-16: * the 403s the polling met while banned got the owner's phone banned again, for hours). */ const REFUSED_MS = 5 * 60_000; let refusedUntil = 0; /** Whether a refusal holds the background requests back; how long still (ms). */ export const refusedFor = () => Math.max(0, refusedUntil - Date.now()); export function refused() { refusedUntil = Date.now() + REFUSED_MS; reach.away = true; } /** Whether an answer is Cratarr asking for its sign-in (decision 0039: away from home, no session, or one that ended). */ export const asksSignIn = (res: Response) => res.status === 401 && /^Cookie\b.*realm="Cratarr"/i.test(res.headers.get('WWW-Authenticate') ?? ''); let leaving = false; /** To Cratarr's sign-in page, once; it comes back here after (the #route too). The background requests wait meanwhile. */ export function toSignIn() { if (leaving) return; leaving = true; refusedUntil = Date.now() + REFUSED_MS; location.assign(`/signin?next=${encodeURIComponent(location.pathname + location.search + location.hash)}`); } ===== src/lib/remote.svelte.ts ===== // The remote for what plays, shared by the mini-player and the full player (decision 0018): the commands (shown at // once, see live.svelte.ts `expect`), the running clock, the favourite, the cover and the colour taken from it. import { untrack } from 'svelte'; import { ApiError, coverUrl, albumCoverUrl, playerCommand, setFavorite, setTrackFavorite, transport, type NowPlaying, type Transport, type Upcoming } from './api'; import { expect, expectFailed, live, refresh } from './live.svelte'; import { local, jump as localJump, next as localNext, previous as localPrevious, togglePlay as localToggle, seek as localSeek } from './localPlayer.svelte'; import { isLocal, localNowPlaying, localState, tookLocal } from './output.svelte'; import { setFavorite as setRowFavorite } from './favourites.svelte'; import { toast } from './toast.svelte'; import { playOn } from './playOn.svelte'; /** What plays where the output is: the server's player, or this browser (decision 0022). */ export const playing = () => (isLocal() ? localNowPlaying() : (live.view?.now_playing ?? null)); /** Actions on what plays wait: a server's player command is shown before it was confirmed, or the local track's * playlists and favourite are still being asked for. */ export const pending = () => (isLocal() ? localState.loading : live.pending); /** A command to the server's player is not confirmed yet: what plays may still turn out otherwise. */ export const moving = () => !isLocal() && live.pending; // Cover: the library's cover file for a library track; otherwise (or when there is none) the album's image from the // server, which also covers music outside the library, such as Incoming. Candidates that fail are skipped. const failed = $state>({}); export function coverSrc(): string | null { const np = playing(); if (!np) return null; const library = coverUrl(np); const album = np.album_id ? albumCoverUrl(np.source, np.album_id) : null; const candidates = (np.addable ? [library, album] : [album, library]).filter((u): u is string => !!u); return candidates.find((u) => !failed[u]) ?? null; } export const coverFailed = (src: string) => (failed[src] = true); // The cover shown: a new one takes the old one's place only once it is loaded and decoded, so a track change never // shows a frame without a cover, and the same picture at another track's address shows no change at all. A cover // that fails is skipped (`coverSrc` offers the next candidate). const shown = $state<{ src: string | null }>({ src: null }); $effect.root(() => { $effect(() => { const want = coverSrc(); if (!want) { shown.src = null; return; } if (want === untrack(() => shown.src)) return; let current = true; const img = new Image(); img.src = want; img.decode().then( () => current && (shown.src = want), () => current && coverFailed(want), ); return () => (current = false); }); }); export const shownCover = () => shown.src; // The clock: between polls the position runs locally. It is re-anchored to the server only when the server disagrees // with it (track, pause state, or more than 1.5 s off: a seek or drift), so it moves smoothly. type Anchor = { id: string; pos: number; at: number; paused: boolean }; const clock = $state<{ anchor: Anchor | null; tick: number }>({ anchor: null, tick: performance.now() }); $effect.root(() => { $effect(() => { const np = playing(); const p = np?.progress; if (!np || !p) { clock.anchor = null; return; } const now = performance.now(); const a = untrack(() => clock.anchor); const predicted = a ? a.pos + (a.paused ? 0 : (now - a.at) / 1000) : 0; if (!a || a.id !== np.id || a.paused !== p.paused || Math.abs(predicted - p.position_secs) > 1.5) { clock.anchor = { id: np.id, pos: p.position_secs, at: now, paused: p.paused }; } }); $effect(() => { const a = clock.anchor; if (!a || a.paused) return; // Finamp's position stream (its audio_service fork, `AudioService.position`): 800 updates per track, every // length / 800, kept between 16 and 200 ms; the bar moves with each (no smoothing in between). Every 200 ms for a // track over 160 s, more often for shorter ones; each step is a fraction of a pixel. const length = untrack(() => duration()); const every = Math.min(200, Math.max(16, length ? (length * 1000) / 800 : 200)); const t = setInterval(() => { if (!document.hidden) clock.tick = performance.now(); }, every); return () => clearInterval(t); }); }); export const duration = (): number | null => playing()?.progress?.duration_secs ?? null; export function position(): number { if (isLocal()) return local.position; const a = clock.anchor; if (!a) return 0; const p = a.paused ? a.pos : a.pos + Math.max(0, clock.tick - a.at) / 1000; const d = duration(); return d ? Math.min(p, d) : p; } export const paused = (): boolean => playing()?.progress?.paused ?? false; export function clockText(secs: number) { const s = Math.floor(secs); const mm = String(Math.floor((s % 3600) / 60)); const ss = String(s % 60).padStart(2, '0'); return s >= 3600 ? `${Math.floor(s / 3600)}:${mm.padStart(2, '0')}:${ss}` : `${mm}:${ss}`; } // Commands. What they lead to is shown at once and the server confirms it; only a failure says something, in a toast. /** Within this many seconds of the start, Previous goes to the track before; later it restarts the track. */ const RESTART_SECS = 3; /** What is shown when `next` starts playing. */ function starting(next: { id: string; name: string; artist: string | null; album: string | null; album_id: string | null; addable: boolean }, duration: number | null, upcoming: Upcoming[]): NowPlaying { const at = playing()!; return { ...at, ...next, progress: { position_secs: 0, duration_secs: duration, paused: at.progress?.paused ?? false, estimated: false }, // The heart stays as it was until the server tells (it waits, disabled, meanwhile): no blink between tracks. favorite: at.favorite, upcoming, }; } function show(command: Transport) { const was = playing(); if (!was) return; const at = position(); if (command === 'next' && was.upcoming.length) { const [next, ...rest] = was.upcoming; expect(starting(next, next.duration_secs, rest), (s) => (!s ? 'wait' : s.id === next.id ? 'yes' : s.id === was.id ? 'wait' : 'no')); } else if (command === 'previous' && at > RESTART_SECS) { expect({ ...was, progress: was.progress && { ...was.progress, position_secs: 0 } }, (s) => !s ? 'wait' : s.id !== was.id ? 'no' : (s.progress?.position_secs ?? 0) < Math.max(RESTART_SECS, at - 2) ? 'yes' : 'wait', ); } else if (command === 'previous' && live.before && live.before.id !== was.id) { const back = live.before; const here: Upcoming = { ...was, duration_secs: was.progress?.duration_secs ?? null }; expect( starting(back, back.progress?.duration_secs ?? null, [here, ...was.upcoming]), (s) => (!s ? 'wait' : s.id === back.id ? 'yes' : s.id === was.id ? 'wait' : 'no'), // The player may restart the track instead: then it stays, without an error. { ms: 4000, quiet: true }, ); } else if (command === 'play-pause' && was.progress) { const want = !was.progress.paused; expect({ ...was, progress: { ...was.progress, paused: want, position_secs: at } }, (s) => !s ? 'no' : s.id !== was.id ? 'no' : s.progress?.paused === want ? 'yes' : 'wait', ); } } export async function send(command: Transport) { if (isLocal()) { if (command === 'play-pause') localToggle(); else if (command === 'next') localNext(); else if (command === 'previous') localPrevious(); return; } const np = playing(); if (!np) return; show(command); try { await transport(command, np.source); setTimeout(refresh, 700); // the player reports back within a moment } catch (e) { expectFailed(e instanceof ApiError && !e.generic ? e.message : "The player didn't respond"); } } /** Plays upcoming track `i` (the queue view's tap): within the local stream, or the server's player jumps. */ export async function playUpcoming(i: number) { if (isLocal()) return localJump(local.index + 1 + i); const np = playing(); const t = np?.upcoming[i]; if (!np || !t) return; const { jumpTo } = await import('./api'); await jumpTo(t.id, np.source); setTimeout(refresh, 700); } /** Seeks the local player (the remote one cannot be dragged yet, decision 0019). */ export const seekTo = (secs: number) => isLocal() && localSeek(secs); /** Moves what plays `secs` on (back, below 0): the local player at once, within its track; one of Cratarr's players * through the server, which moves it from where it is (and keeps it within its track). Others (a Jellyfin client's * report names a player Play On does not know) are not moved. */ export async function seekBy(secs: number) { if (isLocal()) { const end = duration(); localSeek(Math.max(0, Math.min(end ? end - 1 : Infinity, position() + secs))); return; } const np = playing(); if (!np?.player || !playOn.players.some((p) => p.name === np.player)) return; try { await playerCommand(np.player, { command: 'seek', by_ms: Math.round(secs * 1000) }); setTimeout(refresh, 700); // the player reports back within a moment } catch (e) { expectFailed(e instanceof ApiError && !e.generic ? e.message : "The player didn't respond"); } } // Favourite: shown as the server reports it; a tap shows the new state at once and asks for it. const fav = $state<{ wanted: { id: string; on: boolean } | null }>({ wanted: null }); export function favorite(): boolean { const np = playing(); return np && fav.wanted?.id === np.id ? fav.wanted.on : (np?.favorite ?? false); } // The lists' rows show the playing track's heart as the player does (a change from the player, its menu or elsewhere). // Only when it changes: a row's own flip is not undone by a player that has not heard of it yet. let mirrored = ''; $effect.root(() => { $effect(() => { const np = playing(); if (!np || np.favorite === null) return; const on = favorite(); const now = `${np.source}/${np.id}/${on}`; if (now === mirrored) return; mirrored = now; setRowFavorite(np.source, np.id, on); }); }); export async function toggleFavorite() { const np = playing(); if (!np || np.favorite === null || pending()) return; const want = { id: np.id, on: !favorite() }; fav.wanted = want; try { if (isLocal()) tookLocal(np.source, np.id, await setTrackFavorite(np.source, np.id, want.on)); else live.view = await setFavorite(want.on, np.source); } catch (e) { toast.fail(e, "The favourite didn't change"); } finally { // By value: the state proxy is never `===` the plain object it was given. if (fav.wanted?.id === want.id && fav.wanted.on === want.on) fav.wanted = null; } } // The colour taken from the cover (decision 0018, as Finamp's player): its most vivid colour, made readable against // the player's background (the cover under a dark veil, or a light one in the light theme). null: a grey cover, or // not known yet; the theme's accent applies then. export const art = $state<{ accent: string | null }>({ accent: null }); let artFor: string | null = null; $effect.root(() => { $effect(() => { const src = shownCover(); if (src === artFor) return; artFor = src; if (!src) { art.accent = null; return; } const img = new Image(); img.decoding = 'async'; img.onload = () => { if (artFor !== src) return; try { art.accent = accentFrom(img); } catch { art.accent = null; // an image the page may not read } }; img.onerror = () => artFor === src && (art.accent = null); img.src = src; }); }); /** The accent for any cover (the menus, decision 0021); null for a grey cover or one that does not load. */ export function accentOf(src: string): Promise { return new Promise((resolve) => { const img = new Image(); img.decoding = 'async'; img.onload = () => { try { resolve(accentFrom(img)); } catch { resolve(null); } }; img.onerror = () => resolve(null); img.src = src; }); } function dark() { const t = document.documentElement.dataset.theme; return t ? t === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches; } /** RGB 0..255 to HSL with h in 0..360, s and l in 0..1. */ function hsl(r: number, g: number, b: number): [number, number, number] { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const l = (max + min) / 2; if (max === min) return [0, 0, l]; const d = max - min; const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); const h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4; return [h * 60, s, l]; } function rgbOf(h: number, s: number, l: number): [number, number, number] { const k = (n: number) => (n + h / 30) % 12; const a = s * Math.min(l, 1 - l); const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); return [f(0) * 255, f(8) * 255, f(4) * 255]; } function luminance([r, g, b]: [number, number, number]) { const c = (v: number) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b); } const contrast = (a: [number, number, number], b: [number, number, number]) => { const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); }; /** The cover's most vivid hue (hue buckets weighted by saturation, away from black and white), its lightness moved * until it reads at 5.5:1 against the veiled cover. */ function accentFrom(img: HTMLImageElement): string | null { const N = 40; const canvas = document.createElement('canvas'); canvas.width = canvas.height = N; const ctx = canvas.getContext('2d', { willReadFrequently: true })!; ctx.drawImage(img, 0, 0, N, N); const px = ctx.getImageData(0, 0, N, N).data; const bins = Array.from({ length: 24 }, () => ({ w: 0, r: 0, g: 0, b: 0 })); const avg = [0, 0, 0]; for (let i = 0; i < px.length; i += 4) { const [r, g, b] = [px[i], px[i + 1], px[i + 2]]; avg[0] += r; avg[1] += g; avg[2] += b; const [h, s, l] = hsl(r, g, b); const w = s * s * (1 - Math.abs(l - 0.5) * 2); const bin = bins[Math.floor(h / 15) % 24]; bin.w += w; bin.r += r * w; bin.g += g * w; bin.b += b * w; } const best = bins.reduce((a, b) => (b.w > a.w ? b : a)); if (best.w < N * N * 0.01) return null; // a grey cover: no colour to take const [h, s] = hsl(best.r / best.w, best.g / best.w, best.b / best.w); // The background the accent sits on: the cover's average under the veil (black 67.5 %, or white 75 % when light). const isDark = dark(); const veil = isDark ? 0 : 255; const share = isDark ? 0.675 : 0.75; const n = px.length / 4; const surface = avg.map((v) => (v / n) * (1 - share) + veil * share) as [number, number, number]; const sat = Math.max(0.45, s); let l = isDark ? 0.5 : 0.45; for (let i = 0; i < 40 && contrast(rgbOf(h, sat, l), surface) < 5.5; i++) l = isDark ? Math.min(0.95, l + 0.02) : Math.max(0.05, l - 0.02); const [r, g, b] = rgbOf(h, sat, l).map(Math.round); return `rgb(${r} ${g} ${b})`; } ===== src/lib/sheetDrag.ts ===== // Closing a sheet by dragging it down (up, for one that comes from the top), from anywhere on it (the owner, // 2026-10-16): a press on its card, a button or a list scrolled to its top, moved mostly downward, drags the whole // sheet. Past a third of its height (at most 120 px) or with a flick it closes; less springs back. Text fields and // sliders keep their own gestures, as does anything marked `data-sheet-drag="off"` (a reorder grip); a list not at its // top scrolls; a sideways move (a row's swipe) is left alone. A drag cancels the click it began on. (Not "whatever // captured the pointer": on a touch screen every press is captured by the element it began on.) export type SheetDragOptions = { onclose: () => void; /** The offset to show while dragging (px, signed: down is positive); 0 when it springs back. */ ondrag: (dy: number) => void; /** A sheet that comes from the top: dragged up to close. */ top?: boolean; enabled?: boolean; }; /** How far (px) the press moves before it counts as a drag or not. */ const SLOP = 8; export function sheetDrag(node: HTMLElement, options: SheetDragOptions) { let opts = options; let press: { id: number; x: number; y: number; t: number; scroller: HTMLElement | null } | null = null; let dragging = false; let offset = 0; /** The drag's recent moves (time, offset), for a flick's speed. */ let recent: { t: number; o: number }[] = []; const dir = () => (opts.top ? -1 : 1); /** The nearest scrollable element between the target and the sheet. */ const scrollerOf = (el: Element | null): HTMLElement | null => { for (let e = el; e && e !== node; e = e.parentElement) { if (e instanceof HTMLElement && e.scrollHeight > e.clientHeight + 1) { const y = getComputedStyle(e).overflowY; if (y === 'auto' || y === 'scroll') return e; } } return null; }; /** Whether the list under the press is at the end the sheet is dragged away from (the top, for a sheet below). */ const atEdge = (s: HTMLElement | null) => !s || (opts.top ? s.scrollTop + s.clientHeight >= s.scrollHeight - 1 : s.scrollTop <= 0); function down(e: PointerEvent) { if (opts.enabled === false || !e.isPrimary || e.button !== 0) return; const target = e.target as Element; if (target.closest('input, select, textarea, [contenteditable="true"], [data-sheet-drag="off"]')) return; press = { id: e.pointerId, x: e.clientX, y: e.clientY, t: performance.now(), scroller: scrollerOf(target) }; dragging = false; } function move(e: PointerEvent) { if (!press || e.pointerId !== press.id) return; const dx = e.clientX - press.x; const dy = (e.clientY - press.y) * dir(); if (!dragging) { // Sideways, or the other way: not a drag. if ((Math.abs(dx) > SLOP && Math.abs(dx) > Math.abs(dy)) || dy < -SLOP) { press = null; return; } if (dy < SLOP) return; if (!atEdge(press.scroller)) { press = null; return; } dragging = true; node.setPointerCapture(e.pointerId); } offset = Math.max(0, dy); const now = performance.now(); recent = [...recent.filter((r) => now - r.t < 100), { t: now, o: offset }]; opts.ondrag(offset * dir()); } function up(e: PointerEvent) { if (!press || e.pointerId !== press.id) return; press = null; if (!dragging) return; dragging = false; // The click the press began on (a button under it) does not happen. const swallow = (c: Event) => { c.stopPropagation(); c.preventDefault(); }; addEventListener('click', swallow, { capture: true, once: true }); setTimeout(() => removeEventListener('click', swallow, { capture: true }), 0); // A flick: the speed over the last 100 ms of the drag (px/ms), not since the press began. const first = recent[0]; const speed = first ? (offset - first.o) / Math.max(1, performance.now() - first.t) : 0; recent = []; const far = Math.min(120, node.offsetHeight / 3); if (e.type !== 'pointercancel' && (offset > far || (offset > 20 && speed > 0.7))) opts.onclose(); else opts.ondrag(0); offset = 0; } // On a touch screen the browser pans a list (or the page behind) unless its move is cancelled: once the press goes // the closing way from a list at its edge, it is the sheet's. function touchMove(e: TouchEvent) { if (!press || !e.cancelable) return; const t = e.touches[0]; if (!t) return; const dx = t.clientX - press.x; const dy = (t.clientY - press.y) * dir(); if (dragging || (dy > 0 && Math.abs(dy) >= Math.abs(dx) && atEdge(press.scroller))) e.preventDefault(); } node.addEventListener('pointerdown', down); node.addEventListener('pointermove', move); node.addEventListener('pointerup', up); node.addEventListener('pointercancel', up); node.addEventListener('touchmove', touchMove, { passive: false }); return { update(next: SheetDragOptions) { opts = next; }, destroy() { node.removeEventListener('pointerdown', down); node.removeEventListener('pointermove', move); node.removeEventListener('pointerup', up); node.removeEventListener('pointercancel', up); node.removeEventListener('touchmove', touchMove); }, }; } ===== src/lib/sources.svelte.ts ===== // The sources the server has connected, for the filter's server cards (lib/FilterSheet: which server a browser follows // filters what it shows, decided 2026-10-16; no dropdown on the pages). Filled by the shell's status call and by the // playlist poll (which also says which are playing). import type { SourceTab } from './api'; import { prefs } from './prefs.svelte'; export const known = $state<{ list: SourceTab[] }>({ list: [] }); /** Cratarr's own source (its own library). No choice means it, where the server has it (the owner, 2026-10-16); only * without it does no choice leave the server to follow whichever started playing most recently. */ export const OWN = 'cratarr'; /** What following no source in particular follows: Cratarr's own where the server has it. */ export const noChoice = () => (known.list.some((s) => s.id === OWN) ? OWN : ''); /** Whether a server's library shows on the album pages (the filter's library switches, remembered). */ export const libraryShown = (source: string, library: string) => !prefs.hiddenLibraries.includes(`${source}/${library}`); /** Switches a library on or off. */ export function showLibrary(source: string, library: string, on: boolean) { const key = `${source}/${library}`; prefs.hiddenLibraries = on ? prefs.hiddenLibraries.filter((k) => k !== key) : [...prefs.hiddenLibraries, key]; } export function remember(list: SourceTab[]) { known.list = list; // No choice, or a choice the server no longer has (a source switched off: it would list nothing), is Cratarr's own. if (!list.some((s) => s.id === prefs.source)) prefs.source = noChoice(); } ===== src/lib/toast.svelte.ts ===== // The app's one toast (docs/ui-conventions.md → Feedback): what an action did, or why it failed. Any page or component // calls `toast.show` or `toast.fail`; App.svelte renders it (lib/Toasts.svelte), outside the page layout. An action's // toast with Undo goes through lib/undo.svelte.ts (decision 0026). import { ApiError } from './api'; /** `act`: its one button (Undo, Redo): tapping it closes the toast and runs it. */ export type Toast = { text: string; tone?: 'done' | 'warn' | 'error'; act?: { label: string; run: () => void } }; class Toasts { /** The one shown; a new one replaces it. `id` restarts its timer, also when the same text comes again. */ current = $state<(Toast & { id: number }) | null>(null); #seq = 0; show(t: Toast) { this.current = { ...t, id: ++this.#seq }; } /** An action failed: the server's own words when it gave some, else `fallback` (never a bare status code). */ fail(e: unknown, fallback: string) { this.show({ text: e instanceof ApiError && !e.generic ? e.message : fallback, tone: 'error' }); } dismiss() { this.current = null; } } export const toast = new Toasts(); ===== src/lib/visuals/CurveEditor.svelte =====
{#if envelope}{/if} {#if live}{/if} {#each [['h1', curve.h1[0], curve.h1[1]], ['h2', curve.h2[0], curve.h2[1]], ['end', 1, curve.h]] as const as [which, x, y] (which)} grab(which, e)} onpointermove={(e) => moved(e)} onpointerup={letGo} onpointercancel={letGo} /> {/each}
{curve.h > 0 ? '+' : ''}{Math.round(curve.h * 100)}%
===== src/lib/visuals/Help.svelte ===== {#if helpShown.open} {/if} ===== src/lib/visuals/RateMode.svelte ===== {#if phase !== 'stopped'}
{phase === 'a' ? 'A' : phase === 'b' ? 'B' : ''} {#if phase === 'a' || phase === 'b'} {/if} {pair.n + 1}
{#if phase === 'ask' || phase === 'saving'}
{#if failed}

Not saved

{/if}
{/if}
{/if} ===== src/lib/visuals/SnapshotsSheet.svelte =====
{#each list as snap (snap.id)} {#if editing === snap.id}
{#if snap.picture}{:else}{/if} { if (e.key === 'Enter') rename(snap); else if (e.key === 'Escape') { e.stopPropagation(); editing = null; } }} onblur={() => rename(snap)} />
{:else} {/if} {/each}
===== src/lib/visuals/Visuals.svelte ===== {#snippet bandPinOf(key: 'lighting' | 'evolution' | 'continuity', label: string, b: number)} {#if s.pins.includes(bandPin(key, b))} {:else} {/if} {/snippet} {#snippet routeRow(signal: Signal)} {SIGNAL_LABEL[signal]} {#each ASPECTS as aspect (aspect)} {@const a = cellOf(signal, aspect)} {/each} {/snippet} ===== src/lib/visuals/aspects.ts ===== // The visuals' mapping (decision 0046): from the music now and the settings to what is drawn this frame, each aspect as // its routes give it. Pure and stateful, with no GL and no flame: the visuals' loop steps it each frame, and the // behaviour's precheck (recipe.ts) steps it over a track's signals to see what a recipe would do before it is shown. import { PARTS, type Part } from './flame'; import { WAVE_BEND } from './fluid'; import { DIVE_MOST, TUNNEL_MOST } from './zoom'; import type { Levers } from './governor'; import type { Music } from './music.svelte'; import { A, AMOUNT_ASPECTS, ASPECTS, B, BAND_SIGNALS, COLUMN_SCALES, compile, give, given, KNOB_ASPECTS, MOMENT_ASPECTS, T, TRACK_SIGNALS, type Given, type Matrix, type RouteValue } from './routes'; import { BANDS, type VisualSettings } from './settings.svelte'; /** The columns scaled here, each its aspect's place and its setting (the fluid's stirring is scaled where it stirs: * Visuals.svelte's `gain`). */ const SCALED = (Object.entries(COLUMN_SCALES) as [keyof typeof COLUMN_SCALES, (typeof COLUMN_SCALES)[keyof typeof COLUMN_SCALES]][]) .filter(([a]) => a !== 'stir' && a !== 'flow' && a !== 'swirl') .map(([a, k]) => [A[a], k] as const); /** How fast the slow giant waves move the picture where nothing flows (the show pass bends it by `WAVE_BEND` of the * screen's height, once round their cycle in eight seconds: stirring.ts), as a turn a second of the flame's parts * (whose points lie about 0.4 of the screen's height out): what the motion meter counts for them. Where the fluid * flows they are part of its flow, which the meters do not see (the governor's pace slows it all the same). */ export const wavesMotion = (amount: number, fluid: number) => (fluid > 0 ? 0 : (WAVE_BEND * Math.abs(amount) * ((2 * Math.PI) / 8)) / 0.4); /** How fast the zooms stream the picture out (zoom.ts: each speed its log a second): a point at radius r moves at that * many e-folds a second times r, as a turn of as many radians a second moves it, so the motion meter counts the Tunnel's * and the Dive's zoom (the Dive's turn beside it) as that much motion. `tunnel`, `dive`: their levels now (0..1). */ export const zoomsMotion = (tunnel: number, dive: number) => TUNNEL_MOST * Math.max(0, tunnel) + DIVE_MOST * Math.max(0, dive); /** Nothing held back (the governor's levers all open). */ const OPEN: Levers = { swing: 1, pace: 1, evolution: 1, hue: 1, size: 1, gap: 1 }; /** A beat's turn of the transforms: once around in 32 beats. */ export const STEP = (2 * Math.PI) / 32; /** The Flash setting's slow course: followed twice over this long (seconds). */ const SLOW_S = 0.3; /** A moment comes again no sooner than this (ms). */ const MOMENT_GAP_MS = 2000; const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); type Moment = { armed: boolean; at: number }; export type AspectsState = { // How intense the show is (the loudness as heard, followed over a fifth of a second; `lit`: as far as the Intensity // setting lets it count), and paused or not. intensity: number; lit: number; glow: number; // The turning's clock: a route's full amount turns a part once around in 16 beats (the default routes, once in 32 on // average), at the Speed setting; paused, it slows to a stop. The whole picture's turn. clock: number; turn: number; // The signals now (the loudness's as the Intensity setting lets it count), the bands' stand-ins while none are known // (each half way), and what the routes give each aspect. track: Float32Array; standIn: Float32Array[]; got: Given; routes: Record | null; matrix: Matrix | null; // The track playing, and when it started (ms): the Track signal. trackKey: string | null | undefined; trackAt: number; // Each part: how far it has turned, and its evolving aspects (shape, hue, spread) as followed over its band's // Evolution time. turned: number[]; evolving: number[][]; // Each part's steps: its band's last hit seen (or beat, where its hits are not known yet), what its evolving aspects // were given then (what a stepping part evolves to), and the turn of its steps still to come (each eased in over // 80 ms). lastHit: (number | null)[]; held: number[][]; pending: number[]; rates: number[]; // Each part's presence as followed (quick to come, slower to go: a hit's weight eased in, not jumped to). presence: number[]; // The moments: ready again once what they are given falls under a third, and not within two seconds. moments: Record; // The Flash setting: each quickly changing aspect (each part's lights, the glow, size and saturation) as its slow // course (followed twice over a third of a second: a beat's or a hit's swing all but gone from it, a passage's change // kept) and the setting's share of its swing about that course. courses: { lights: number[][]; glow: number[]; size: number[]; saturation: number[] }; }; /** What is drawn this frame, as the music and the settings give it. */ export type AspectsNow = { /** What the whole track's routes give each aspect now (`ASPECTS` order): a route's full amount is 1. */ given: Float32Array; /** The whole track's signals now as routed (`TRACK_SIGNALS` order: the loudness's as Intensity lets it count, the * beats' halved with reduced motion), and each band's (their stand-ins while none are known). */ track: Float32Array; bands: Float32Array[]; /** The loudness as far as Intensity lets it count (0..1), and the glow (1 playing, dimmer paused). */ lit: number; glow: number; /** The whole picture's turn (radians). */ turn: number; /** Each part's turning rate (radians a second), its evolving shape as drawn (0..1.25), and its drawn aspects. */ rates: number[]; shape: number[]; parts: Part[]; /** Each part's lights (-1..3), after the Flash setting. */ hits: number[]; /** The picture's size (0.25..1.4), the share of each frame kept (trails), the display's gamma (depth), its glow, its * saturation, the smoothing (pixels), the floor, the fluid (of the fluid's own) and its warp. */ scale: number; keep: number; gamma: number; flash: number; saturation: number; smoothing: number; floor: number; fluid: number; warp: number; /** The infinite zooms' speeds (0..1 of their most: zoom.ts), as their routes let their sliders through. */ tunnel: number; /** The look's effects' share now (0..1): the Effects setting as its routes let it through. */ effects: number; /** Facets now (0..1): the look's, as its routes let it through. */ facets: number; dive: number; /** How far the look and the behaviour walk (drift.ts): what the Look and Behaviour columns' routes give them, but * the Track row's (its move at a new track is the track's own, not a walk). */ look: number; behaviour: number; /** Each level's share of its setting its routes let through now (1: all of it). */ levels: { fluid: number; smoothing: number; haze: number; tunnel: number; dive: number }; /** The moments that come this frame (none with reduced motion): another symmetry, lens, form… (routes.ts * `MOMENT_ASPECTS`; Visuals.svelte moves the settings). */ moments: Record; /** The effects as their routes have them now (65.1; effects.ts `routed`): each engine's and the Loop's Amount, the * share its routes let through (a level), and each control's swing about where it is set (−1..1 added, then 0..1). */ routed: { amounts: number[]; knobs: number[] }; }; type MomentAspect = (typeof MOMENT_ASPECTS)[number]; export function newAspects(): AspectsState { return { intensity: 0.5, lit: 0.5, glow: 1, clock: 0, turn: 0, track: new Float32Array(TRACK_SIGNALS.length), standIn: Array.from({ length: BANDS }, () => { const v = new Float32Array(BAND_SIGNALS.length); v[B.level] = 0.5; return v; }), got: given(BANDS), routes: null, matrix: null, trackKey: undefined, trackAt: 0, turned: Array(PARTS).fill(0), evolving: Array.from({ length: PARTS }, () => [0.5, 0, 0]), lastHit: Array(PARTS).fill(null), held: Array.from({ length: PARTS }, () => [0.5, 0, 0]), pending: Array(PARTS).fill(0), rates: Array(PARTS).fill(0), presence: Array(PARTS).fill(0), moments: Object.fromEntries(MOMENT_ASPECTS.map((a) => [a, { armed: true, at: -Infinity }])) as Record, courses: { lights: Array.from({ length: PARTS }, () => [] as number[]), glow: [], size: [], saturation: [] }, }; } /** A level (Smoothing, Floor, Depth, Fluid: 0..1) as its routes let it through: the setting the most it gets * (routes.ts `LEVEL_ASPECTS`). */ export const levelOf = (g: Given, setting: number, aspect: number) => clamp(setting * g.level[aspect], 0, 1); /** Floor: none at 0, then from 0.002 of the display's scale (a stray speck's) up to 1 (the flame's mean), evenly in its * log. */ export const floorOf = (v: number) => (v > 0 ? 0.002 * 500 ** v : 0); /** Haze (−1..1, 65, decision 0050) as the display shows it: Depth's and Floor's own curves on one axis, 0 as their * defaults were (gamma 3, no floor). More haze lifts the faint mist (the gamma up to 6); less cuts it (the gamma down * to 1.5, a floor up to Floor's 75 %: a fifth of the flame's mean; its 100 %, the mean, blacked out half the flame). */ export const hazeOf = (h: number) => ({ gamma: 3 * 2 ** h, floor: h < 0 ? floorOf(0.75 * -h) : 0 }); /** One frame of the mapping, `dt` seconds on. `still`: reduced motion (a quarter of the turning, all of it steady, half * the beats' and hits' share, no moments); `now`: the clock in ms (the moments' gap); `matrix`: the routes compiled * (else compiled here, again whenever `s.routes` is another object); `fluidFull`: the fluid's share at Fluid's 100 %; * `levers`: what the governor holds back (governor.ts; none by default). */ export function stepAspects( st: AspectsState, m: Music, s: VisualSettings, dt: number, opts: { still: boolean; now: number; matrix?: Matrix; fluidFull: number; levers?: Levers; routeWalk?: Float32Array }, ): AspectsNow { const { still, now } = opts; const L = opts.levers ?? OPEN; let matrix = opts.matrix; if (!matrix) { if (st.routes !== s.routes || !st.matrix) { st.routes = s.routes; st.matrix = compile(s.routes, BANDS); } matrix = st.matrix; } // The show's intensity: the loudness as heard, followed over a fifth of a second, as far as the Intensity setting // lets it count. It is the loudness's signal, and the envelope of every other one's routes (none while paused). // Dimmer while paused. st.intensity += (m.intensity - st.intensity) * Math.min(1, dt * 5); st.lit = 1 - s.intensity * (1 - st.intensity); st.glow += ((m.playing ? 1 : 0.55) - st.glow) * Math.min(1, dt * 2); const track = st.track; track.set(m.track); track[T.loudness] = st.lit; if (still) for (const t of [T.beat, T.downbeat, T.section]) track[t] *= 0.5; // Track: all of it as a track starts (the visuals opening on one too), gone over its first beat. if (m.key !== st.trackKey) { st.trackKey = m.key; st.trackAt = now; } track[T.track] = Math.max(0, 1 - ((now - st.trackAt) / 1000) * m.bps); const bands = m.bands ?? st.standIn; const envelope = m.playing ? st.lit : 0; give(matrix, track, bands, envelope, st.got, opts.routeWalk); // Each column as far as its scale lets its routes move it (64.2): the whole track's and each band's. for (const [a, k] of SCALED) { const v = s[k]; if (v === 1) continue; st.got.whole[a] *= v; for (const own of st.got.band) own[a] *= v; } const W = st.got.whole; // The courses: followed more slowly while the governor holds the flashes back, so they no longer follow each pulse. const follow2 = 1 - Math.exp((-dt * L.swing) / SLOW_S); // The swing about the course: the Flash setting's share, as much of it as the governor lets through (and of the // size's, as much as its pumping is let). const flashed = (v: number, course: number[], held = 1) => { if (!course.length) course.push(v, v); course[0] += (v - course[0]) * follow2; course[1] += (course[0] - course[1]) * follow2; return course[1] + s.flash * L.swing * held * (v - course[1]); }; // The turning's clock, at the track's tempo (its beats' where they are known, 90 BPM without one); the whole picture // turns with the whole track's routes to Rotation. const b = m.playing ? m.beat : null; const want = m.playing ? STEP * m.bps * (still ? 0.25 : 1) * s.speed * L.pace : 0; st.clock += (want - st.clock) * Math.min(1, dt * 1.5); st.turn += st.clock * 0.6 * W[A.rotation] * dt; // Each part, given the whole track's routes and its band's: Shape evolves it (larger, its variations given over to its // band's own), Hue moves its colour round, Spread draws it out from the middle or in (all three followed over its // band's Evolution time), Rotation turns it, Lights light its colour. Its band's Continuity shares its motion between // steady (turning at its rate, evolving as it is given) and steps on its band's own hits (or the beats, where those // are not known yet): a step turns it by as much as it would have turned steadily since the band's last, on average // (a strong hit further, a weak one less), and evolves it to what it was given at that moment, held until the next. const hits: number[] = []; const shapes: number[] = []; const parts: Part[] = []; for (let i = 0; i < PARTS; i++) { const k = Math.floor((i * BANDS) / PARTS); const own = st.got.band[k]; const c = still ? 1 : s.continuity[k]; const wants = [W[A.shape] + own[A.shape], (W[A.hue] + own[A.hue]) * L.hue, W[A.spread] + own[A.spread]]; const rate = st.clock * 2 * (W[A.rotation] + own[A.rotation]); st.rates[i] = rate; const step = m.playing && m.hits ? m.hits[k] : null; const at = step ? step.index : b ? b.index : null; if (at !== st.lastHit[i]) { const was = st.lastHit[i]; // Backwards or by more than three: a seek or another track, not stepped for. if (at !== null && was !== null && at > was && at - was <= 3) { st.held[i] = wants; const often = Math.max(0.5, step ? step.perSecond : m.bps); st.pending[i] += ((1 - c) * rate * (step ? step.weight : 1)) / often; } st.lastHit[i] = at; } const eased = st.pending[i] * Math.min(1, dt / 0.08); st.turned[i] += c * rate * dt + eased; st.pending[i] -= eased; const follow = Math.min(1, dt / (s.evolution[k] * L.evolution)); const ev = st.evolving[i]; for (let e = 0; e < 3; e++) ev[e] += (c * wants[e] + (1 - c) * st.held[i][e] - ev[e]) * follow; const [shape, hue, spread] = ev; const grown = clamp(shape, 0, 1.25); shapes.push(grown); // Presence: its share of the points, doubling and a half at all of it given (a route's full amount), as drawn at // none, about a third at all of it taken away; followed over 0.1 s as it comes, 0.4 s as it goes, slower as the // governor holds shape churn back (the points moving between the parts count as it: meters.ts). const wantP = clamp(W[A.presence] + own[A.presence], -2, 2); st.presence[i] += (wantP - st.presence[i]) * Math.min(1, dt / ((wantP > st.presence[i] ? 0.1 : 0.4) * L.evolution)); parts.push({ turn: st.turned[i], size: 0.94 + 0.12 * grown, morph: (still ? 0.15 : 0.35) * grown, hue, spread: 2 ** clamp(spread, -2, 2), weight: 2 ** (1.5 * st.presence[i]), }); // Lights: its colour's points lit up, or dimmed where its routes take it below nothing. hits.push(clamp(flashed(clamp(W[A.lights] + own[A.lights], -1, 3) * (still ? 0.5 : 1), st.courses.lights[i]), -1, 3)); } // The moments: what their routes give rising past a half. const moment = (mo: Moment, v: number) => { if (v < 1 / 3) mo.armed = true; if (!(v > 0.5 && mo.armed && now - mo.at > MOMENT_GAP_MS * L.gap)) return false; mo.armed = false; mo.at = now; return true; }; const moments = Object.fromEntries(MOMENT_ASPECTS.map((a) => [a, !still && moment(st.moments[a], W[A[a]])])) as Record; const walkOf = (a: number) => (W[a] - track[T.track] * envelope * matrix.track[T.track * ASPECTS.length + a]) / L.evolution; const fluid = opts.fluidFull * levelOf(st.got, s.fluid, A.fluid); // Haze either way from 0, its routes taking it toward 0 (a level both ways). const haze = hazeOf(clamp(s.haze * st.got.level[A.haze], -1, 1)); return { given: W, track, bands, lit: st.lit, glow: st.glow, turn: st.turn, rates: st.rates, shape: shapes, parts, hits, // Size: half its size at rest, all of it given all (by default the loudness: smaller when quiet, and a beat's // swell). scale: clamp(flashed(0.5 + 0.5 * W[A.size], st.courses.size, L.size), 0.25, 1.4), // Trails: each frame keeps this much of the last (longer trails given more, shorter given less). keep: clamp(1 - 0.07 * 2 ** (-1.5 * W[A.trails]), 0.5, 0.985), // Haze: the display's gamma (higher lifts more of the faint detail) and its floor, `hazeOf`. gamma: haze.gamma, // Glow: as bright at rest (dimmer only while paused), brighter given more (by default a beat's flash). flash: st.glow * clamp(flashed(1 + 0.5 * W[A.brightness], st.courses.glow), 0.15, 3), // Saturation: a third of its colour at rest, all of it given all (by default the loudness: paler when quiet). saturation: clamp(flashed(0.35 + 0.65 * W[A.saturation], st.courses.saturation), 0, 1.25), // Smoothing: up to 12 pixels (3 as before at its middle). smoothing: 12 * levelOf(st.got, s.smoothing, A.smoothing) ** 2, floor: haze.floor, // Each level's share of its setting the routes let through now (the sliders' live marks). levels: { fluid: st.got.level[A.fluid], smoothing: st.got.level[A.smoothing], haze: st.got.level[A.haze], tunnel: st.got.level[A.tunnel], dive: st.got.level[A.dive], }, // Fluid: the light carried along a flow (none at 0), read back along it a little as it shows. fluid, warp: 0.12 * fluid, tunnel: levelOf(st.got, s.tunnel, A.tunnel), effects: levelOf(st.got, s.effects, A.effects), facets: levelOf(st.got, s.facets, A.facets), dive: levelOf(st.got, s.dive, A.dive), look: walkOf(A.look), behaviour: walkOf(A.behaviour), moments, routed: { amounts: AMOUNT_ASPECTS.map((a) => st.got.level[A[a]]), knobs: KNOB_ASPECTS.map((a) => clamp(W[A[a]], -1, 1)) }, }; } ===== src/lib/visuals/beats.ts ===== // A track's beats (decision 0046): every beat's time and how strongly it starts, from the track's analysis on the // server, which works them out the first time they are asked for (about a second). Read once per track and kept while // the page is open; the visuals ask for the playing track's and the next one's. import { asksSignIn, toSignIn } from '../reach.svelte'; export type Beats = { /** Each beat's time, seconds from the start of the track, in order. */ at: Float64Array; /** How strongly each starts, 0..1 (1: the track's strong beats). */ strength: Float32Array; }; /** Where a moment falls among the beats. */ export type Beat = { /** The beat at or before it. */ index: number; /** Seconds since that beat. */ since: number; /** Seconds from that beat to the next. */ length: number; strength: number; }; /** The server could not work them out then (busy, or the decode took too long): asked again after this long, at most * this many times. */ const AGAIN_MS = 15_000; const TRIES = 3; const kept = new Map(); const asking = new Map(); /** A track's beats: undefined while they are asked for, null where it has none (no tempo, or not a file Cratarr keeps). */ export function beatsOf(source: string, id: string): Beats | null | undefined { const key = `${source}/${id}`; if (kept.has(key)) return kept.get(key); const now = performance.now(); const was = asking.get(key); if (was && (was.busy || now - was.at < AGAIN_MS)) return undefined; if (was && was.tries >= TRIES) { kept.set(key, null); return null; } const tries = (was?.tries ?? 0) + 1; asking.set(key, { at: now, tries, busy: true }); const done = () => asking.set(key, { at: performance.now(), tries, busy: false }); fetch(`/api/track/${encodeURIComponent(source)}/${encodeURIComponent(id)}/beats`) .then(async (r) => { if (asksSignIn(r)) toSignIn(); if (r.ok) kept.set(key, decode(await r.arrayBuffer())); else if (r.status !== 503) kept.set(key, null); }) .then(done, done); return undefined; } /** Five bytes a beat: its time in milliseconds (u32, little-endian), then its strength (0..=255). None: no tempo. */ function decode(b: ArrayBuffer): Beats | null { const n = Math.floor(b.byteLength / 5); if (!n) return null; const v = new DataView(b); const at = new Float64Array(n); const strength = new Float32Array(n); for (let i = 0; i < n; i++) { at[i] = v.getUint32(i * 5, true) / 1000; strength[i] = v.getUint8(i * 5 + 4) / 255; } return { at, strength }; } /** Where `t` (seconds into the track) falls among its beats; null before the first and more than a beat after the last. */ export function beatAt(b: Beats, t: number): Beat | null { const { at } = b; // The last beat at or before t. let lo = 0; let hi = at.length - 1; if (!(t >= at[0])) return null; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (at[mid] <= t) lo = mid; else hi = mid - 1; } const length = lo + 1 < at.length ? at[lo + 1] - at[lo] : lo > 0 ? at[lo] - at[lo - 1] : 0.5; const since = t - at[lo]; if (since > length * 1.5) return null; return { index: lo, since, length, strength: b.strength[lo] }; } ===== src/lib/visuals/budget.ts ===== // The governor's budgets (decision 0048): the meters that have one, and what a limit allows as the Calm ↔ Wild setting // and the music have it. The one scale the governor, the dice's precheck, the ratings and the thumbs all judge on: a // meter over its budget at Wild's middle (`budgetOf(1, 0.5, activity)`) is how far it is from a limit, whatever the // music was doing. Apart from governor.ts so the precheck (whose limits the governor stands on) can take it too. import { USUAL_MOST } from './learning'; /** The meters with a budget. */ export const BUDGETED = ['motion', 'shapeChurn', 'colourChurn', 'zoomPumping', 'looksPerMinute'] as const; export type Budgeted = (typeof BUDGETED)[number]; /** A budget as the Calm ↔ Wild setting (0..1: half as much to twice, as much at its middle) and the music (its * activity now, 0..1: half as much when quiet, one and a half times when loud) have it. */ export const budgetOf = (limit: number, wild: number, activity: number) => limit * 2 ** (2 * (wild - 0.5)) * (0.5 + Math.min(1, Math.max(0, activity))); /** The limits a recipe is held to (the governor's, at the Calm ↔ Wild setting). */ export type Limits = Partial>; /** Until the owner's ratings fit them (fit.ts), on the governor's scale: a little above (a quarter more, up to the next * 0.05) the most the flame itself did as Standard over 11 varied tracks, at a quarter and three fifths of each, three * track looks a track (65, 2026-10-18, 506 runs: motion's most 0.47, shape's 0.64 (64: 0.42, before the meters saw * Presence move the points between the parts), colour's 0.35, pumping's 0.35; flashes 3), so today's behaviour always * passes, quiet passage or loud. The looks' own effects are drawn within the room these leave above that most * (`DRAWN_ROOM`), so a track's look never needs holding; the limits are not raised for them (the wildest of * 65's first draws doubled motion's most); held to it, the looks with effects peak where the flame does. The dice's * recipes: 83 of 440 refused (motion 79: the zooms they roll count as the motion they are; flash 9). * Flashes: three a second over a quarter of the screen, the rule. */ export const DEFAULT_LIMITS: Required = { flash: 3, motion: 0.6, shapeChurn: 0.8, colourChurn: 0.45, zoomPumping: 0.45, // New looks a minute (the dice's and the moments', a track's own not counted): Standard has none of its own; four // keeps a moment-driven recipe from turning into a slideshow. looksPerMinute: 4, }; /** The room a drawn look's effects have (65): what the limits leave above the most the flame itself does * (learning.ts `USUAL_MOST`), at the quietest passage's budget (Wild's middle, no activity: `budgetOf`'s least), raw a * second as the effects report it (effects.ts). A look drawn for a track, by Another look or the dice is held to it * (flame.ts), so it never needs the governor; effects chosen by hand are not. */ export const DRAWN_ROOM: Readonly> = { motion: (DEFAULT_LIMITS.motion - USUAL_MOST.motion) * budgetOf(1, 0.5, 0), shapeChurn: (DEFAULT_LIMITS.shapeChurn - USUAL_MOST.shapeChurn) * budgetOf(1, 0.5, 0), colourChurn: (DEFAULT_LIMITS.colourChurn - USUAL_MOST.colourChurn) * budgetOf(1, 0.5, 0), zoomPumping: (DEFAULT_LIMITS.zoomPumping - USUAL_MOST.zoomPumping) * budgetOf(1, 0.5, 0), }; ===== src/lib/visuals/curves.ts ===== // A route's curve (decision 0046, amended for 64.1; the owner: "Yes, every route"): what its signal (across, 0..1) gives // its aspect (up, −1..1), as a cubic Bézier from (0, 0) through two handles to its end at (1, h). A plain number is the // straight one ending at it, a route's amount as it always was, so every route kept, shared or saved before still reads // the same. Pure. // // With both handles' x within 0..1 the curve's x only ever grows along it (the derivative's Bernstein coefficients x1, // x2 − x1 and 1 − x2 make a quadratic that never dips below 0), so each signal has one height; and the curve stays // within its control points' hull, so within −1..1. /** `walks`: it walks with the behaviour (the Behaviour column's walk), only ever at or under itself (the owner: "behavior * should also affect those matrix routes that subscribe to it … as an attenuating walk"); off unless set. */ export type Curve = { h1: [number, number]; h2: [number, number]; h: number; walks?: boolean }; /** A route as kept: its amount (straight), or its curve. */ export type RouteValue = number | Curve; const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); /** The straight curve ending at `a`. */ export const straight = (a: number): Curve => ({ h1: [1 / 3, a / 3], h2: [2 / 3, (2 * a) / 3], h: a }); /** A route's curve, a number as the straight one. */ export const curveOf = (v: RouteValue): Curve => (typeof v === 'number' ? straight(v) : v); /** Where a route ends (its amount, as the grid shows it). */ export const endOf = (v: RouteValue) => (typeof v === 'number' ? v : v.h); /** Whether a curve is the straight one to its end (handles where a straight line's are, within a hair). */ export function isStraight(c: Curve): boolean { const s = straight(c.h); const near = (a: [number, number], b: [number, number]) => Math.abs(a[0] - b[0]) < 1e-4 && Math.abs(a[1] - b[1]) < 1e-4; return near(c.h1, s.h1) && near(c.h2, s.h2); } /** A curve as kept: a number where it is straight and does not walk. */ export const valueOf = (c: Curve): RouteValue => (isStraight(c) && !c.walks ? c.h : c); /** Whether a route walks with the behaviour. */ export const walksOf = (v: RouteValue) => typeof v !== 'number' && !!v.walks; /** A curve brought into its ranges (handles' x 0..1, heights −1..1), or null where it is not one. */ export function curveFrom(v: unknown): Curve | null { if (!v || typeof v !== 'object') return null; const o = v as Record; const pt = (p: unknown): [number, number] | null => Array.isArray(p) && p.length === 2 && p.every((x) => typeof x === 'number' && Number.isFinite(x)) ? [clamp(p[0], 0, 1), clamp(p[1], -1, 1)] : null; const h1 = pt(o.h1); const h2 = pt(o.h2); if (!h1 || !h2 || typeof o.h !== 'number' || !Number.isFinite(o.h)) return null; return { h1, h2, h: clamp(o.h, -1, 1), ...(o.walks === true ? { walks: true } : {}) }; } /** A curve ending elsewhere, its shape kept (its heights scaled; the straight one where it ended at 0). */ export const withEnd = (c: Curve, h: number): Curve => Math.abs(c.h) < 1e-6 ? { ...straight(h), ...(c.walks ? { walks: true } : {}) } : { h1: [c.h1[0], clamp((c.h1[1] * h) / c.h, -1, 1)], h2: [c.h2[0], clamp((c.h2[1] * h) / c.h, -1, 1)], h, ...(c.walks ? { walks: true } : {}) }; /** How many steps a table has across (its points: one more). */ export const TABLE_STEPS = 32; /** A curve's heights at the signals 0, 1/32 … 1 (`table`), and the most it gives (`peak`: its highest, and never under * 0). Its points along t, finely, then each signal's height between the two it falls between. */ export function tableOf(c: Curve): { table: Float32Array; peak: number } { const N = 512; const xs = new Float64Array(N + 1); const ys = new Float64Array(N + 1); let peak = 0; for (let i = 0; i <= N; i++) { const t = i / N; const u = 1 - t; const b1 = 3 * u * u * t; const b2 = 3 * u * t * t; const b3 = t * t * t; xs[i] = b1 * c.h1[0] + b2 * c.h2[0] + b3; ys[i] = b1 * c.h1[1] + b2 * c.h2[1] + b3 * c.h; peak = Math.max(peak, ys[i]); } const table = new Float32Array(TABLE_STEPS + 1); let j = 0; for (let k = 0; k <= TABLE_STEPS; k++) { const x = k / TABLE_STEPS; while (j < N - 1 && xs[j + 1] < x) j++; const span = xs[j + 1] - xs[j]; const f = span > 1e-12 ? clamp((x - xs[j]) / span, 0, 1) : 0; table[k] = ys[j] + (ys[j + 1] - ys[j]) * f; } return { table, peak }; } /** A table's height at signal `v` (0..1). */ export function heightAt(table: Float32Array, v: number): number { const p = clamp(v, 0, 1) * TABLE_STEPS; const i = Math.min(TABLE_STEPS - 1, Math.floor(p)); return table[i] + (table[i + 1] - table[i]) * (p - i); } ===== src/lib/visuals/drift.ts ===== // Drift (decision 0046, amended): the look walking with the music, in place of Reseed's jumps. The owner: "The reseed // route target should essentially only affect looks, and it should walk proportionally instead of randomizing", "So // random direction proportional distance", and a Track row to steer what a new track does to the look. // // - The walk moves only the look's smooth settings (Detail, Twist, Warp, Hue, Lightness, Vividness, Spread, Purity) // that are not pinned, in a direction drawn anew each bar (from the track and the bar's number, so every device of a // sync group walks alike), as far as the routed signal reaches: Drift at +100% for a second of a full signal is a // step of `STEP`. Below 0 it walks back toward home instead. // - Home is where the track put the look; the walk stays within `TETHER` of it, so it never leaves ground the track's // own look has checked. // - A new track moves the look by the Track row's routed amount into Drift: −100% all the way to the new track's own // look (as before Drift), −50% halfway, 0 not at all, above 0 away from it by as much of the tether. The choice // settings (Form, Symmetry, Mirror, Lens, Family) change only past halfway. // // Pure: `stepDrift` returns the settings to change this frame (none where nothing moves). import { PRESET_KEYS, type Preset } from './flame'; /** The settings that walk, and the choices that only a new track changes. */ export const SMOOTH = ['detail', 'twist', 'warp', 'hue', 'lightness', 'vividness', 'spread', 'purity'] as const; const CHOICES = PRESET_KEYS.filter((k) => !(SMOOTH as readonly string[]).includes(k)); /** How far the walk goes in a second of full signal at +100% (in the settings' 0..1 ranges, all together). */ export const STEP = 0.5; /** How far from home the walk may go (the same measure). */ export const TETHER = 0.3; /** Spread's range: walked in its logarithm, so a step is the same ratio wherever it is. */ const SPREAD: [number, number] = [0.25, 2.5]; export type DriftInput = { /** The Drift column's routed sum now (signed). */ amount: number; /** The Track row's routed amount into Drift this frame (signed; 0 where there is none). */ trackEvent: number; dt: number; /** The track's seed, and the bar now (each bar a new direction). */ seed: number; bar: number; pins: string[]; /** The track's own look, and the settings now. */ own: Preset; now: Preset; }; export type Drift = { seed: number | null; /** The track's move done (its event taken). */ moved: boolean; home: Record | null; bar: number | null; direction: Record; }; export function newDrift(): Drift { return { seed: null, moved: false, home: null, bar: null, direction: {} }; } /** A setting on its 0..1 walking scale, and back. */ const toUnit = (k: string, v: number) => (k === 'spread' ? Math.log(v / SPREAD[0]) / Math.log(SPREAD[1] / SPREAD[0]) : v); const fromUnit = (k: string, u: number) => (k === 'spread' ? SPREAD[0] * (SPREAD[1] / SPREAD[0]) ** u : u); /** The way from `a` to `b` on a setting's scale: Hue the short way round. */ const toward = (k: string, a: number, b: number) => (k === 'hue' ? ((((b - a) % 1) + 1.5) % 1) - 0.5 : b - a); const settle = (k: string, u: number) => (k === 'hue' ? ((u % 1) + 1) % 1 : Math.min(1, Math.max(0, u))); /** Random numbers from a seed (mulberry32). */ function randomFrom(seed: number): () => number { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** A direction over `keys`, of length one, the same for the same track and bar. */ function directionFor(seed: number, bar: number, keys: readonly string[]): Record { const rnd = randomFrom(Math.imul(seed ^ 0x9e3779b9, 31) ^ Math.imul(bar + 1, 0x85ebca6b)); const d: Record = {}; let len = 0; for (const k of keys) { // Gaussian (Box–Muller): every way as likely as another. const g = Math.sqrt(-2 * Math.log(1 - rnd())) * Math.cos(2 * Math.PI * rnd()); d[k] = g; len += g * g; } len = Math.sqrt(len) || 1; for (const k of keys) d[k] /= len; return d; } export function stepDrift(s: Drift, i: DriftInput): Partial { const free = SMOOTH.filter((k) => !i.pins.includes(k)); const out: Partial = {}; const unitNow = (k: string) => toUnit(k, i.now[k as keyof Preset]); const set = (k: string, u: number) => { const v = fromUnit(k, settle(k, u)); if (Math.abs(v - i.now[k as keyof Preset]) > 1e-5) (out as Record)[k] = v; }; // A new track: home is where the look stands until the Track row says otherwise. if (s.seed !== i.seed) { s.seed = i.seed; s.moved = false; s.home = Object.fromEntries(SMOOTH.map((k) => [k, unitNow(k)])); s.bar = null; } // The track's move, once: toward its own look (below 0) or away (above), then home is where it lands. if (!s.moved && i.trackEvent !== 0) { s.moved = true; const f = Math.min(1, Math.max(-1, -i.trackEvent)); const home: Record = { ...s.home! }; if (f > 0) { for (const k of free) home[k] = unitNow(k) + f * toward(k, unitNow(k), toUnit(k, i.own[k])); if (f > 0.5) for (const k of CHOICES) if (!i.pins.includes(k) && i.own[k] !== i.now[k]) (out as Record)[k] = i.own[k]; } else { const d = directionFor(i.seed, -1, free); for (const k of free) home[k] = unitNow(k) + -f * TETHER * d[k]; } for (const k of free) { home[k] = settle(k, home[k]); set(k, home[k]); } s.home = home; return out; } // The walk: this bar's direction out (Drift above 0), or back home (below), as far as the signal reaches. if (!(i.amount !== 0 && i.dt > 0 && free.length && s.home)) return out; if (s.bar !== i.bar) { s.bar = i.bar; s.direction = directionFor(i.seed, i.bar, free); } const far = Math.abs(i.amount) * STEP * i.dt; const at: Record = Object.fromEntries(free.map((k) => [k, unitNow(k)])); if (i.amount > 0) { for (const k of free) at[k] += far * (s.direction[k] ?? 0); } else { const back = Object.fromEntries(free.map((k) => [k, toward(k, at[k], s.home![k])])); const gap = Math.hypot(...free.map((k) => back[k])); const go = Math.min(1, gap > 1e-9 ? far / gap : 0); for (const k of free) at[k] += go * back[k]; } // The tether: no further from home than TETHER (along the way back to it). const off = Object.fromEntries(free.map((k) => [k, toward(k, s.home![k], at[k])])); const dist = Math.hypot(...free.map((k) => off[k])); if (dist > TETHER) for (const k of free) at[k] = s.home[k] + (off[k] * TETHER) / dist; for (const k of free) set(k, at[k]); return out; } /** The behaviour's walk (64.1; the owner: "behavior not walking is an oversight", and "The other sliders are simply * limits"): its sliders walk too, but only ever under what is set, each as a factor on its set value between * `1 − TETHER` and 1, reflected at the top. The Behaviour column's routes walk it as the Look column's walk the look, * each bar another way (from the track and the bar, so every device walks alike; not the look's way); below 0 back * to the set values. The Track row into Behaviour, once a track: below 0 back toward the set values by its share (at * −100 % all the way: each track starts as set), above 0 further under them. Pinned sliders do not walk. Not kept: * the set values are the owner's, the walk only the moment's. */ export type BehaviourWalk = { seed: number | null; moved: boolean; bar: number | null; direction: Record; /** Each slider's way under its set value (0 to −TETHER). */ under: Record; }; export function newBehaviourWalk(): BehaviourWalk { return { seed: null, moved: false, bar: null, direction: {}, under: {} }; } /** The behaviour's own ways, apart from the look's. */ const BEHAVIOUR_SALT = 0x5bd1e995; /** Under the set value, reflected back in at both ends. */ const within = (u: number) => { let v = u > 0 ? -u : u; if (v < -TETHER) v = -2 * TETHER - v; return Math.min(0, Math.max(-TETHER, v)); }; export type BehaviourInput = { /** The Behaviour column's routed sum now (signed), and the Track row's routed amount into it (signed). */ amount: number; trackEvent: number; dt: number; seed: number; bar: number; /** The sliders that walk, and the pins. */ keys: readonly string[]; pins: string[]; }; /** One step: each slider's factor on its set value (1: as set). */ export function stepBehaviourWalk(w: BehaviourWalk, i: BehaviourInput): Record { const free = i.keys.filter((k) => !i.pins.includes(k)); for (const k of i.keys) if (i.pins.includes(k)) w.under[k] = 0; if (w.seed !== i.seed) { w.seed = i.seed; w.moved = false; w.bar = null; } if (!w.moved && i.trackEvent !== 0) { w.moved = true; const f = Math.min(1, Math.max(-1, -i.trackEvent)); if (f > 0) for (const k of free) w.under[k] = (w.under[k] ?? 0) * (1 - f); else { const d = directionFor(i.seed ^ BEHAVIOUR_SALT, -1, free); for (const k of free) w.under[k] = within((w.under[k] ?? 0) - -f * TETHER * Math.abs(d[k])); } } if (i.amount !== 0 && i.dt > 0 && free.length) { if (w.bar !== i.bar) { w.bar = i.bar; w.direction = directionFor(i.seed ^ BEHAVIOUR_SALT, i.bar, free); } const far = Math.abs(i.amount) * STEP * i.dt; if (i.amount > 0) for (const k of free) w.under[k] = within((w.under[k] ?? 0) + far * (w.direction[k] ?? 0)); else { const gap = Math.hypot(...free.map((k) => w.under[k] ?? 0)); const go = Math.min(1, gap > 1e-9 ? far / gap : 0); for (const k of free) w.under[k] = (w.under[k] ?? 0) * (1 - go); } } return Object.fromEntries(i.keys.map((k) => [k, 1 + (w.under[k] ?? 0)])); } ===== src/lib/visuals/effects.ts ===== // The effects stage (decision 0050; the owner: "the Hydra effect layer"): effects after Hydra (hydra-synth, AGPL-3.0 // like Cratarr), ported into the visuals' own shaders rather than embedded, so one renderer, one GL context, and the // flash meter and the governor see everything. // // Two slots: // - the picture: after the show pass, on the finished picture: shapes (where each pixel reads from), then one read of // the picture, then colours, then echoes (the last finished picture blended back in, as Hydra's src(o0)); // - the loop: shapes only, on where the light kept from frame to frame is read back (the fade's, the tunnel's, the // fluid's), so the flame's own trails are bent, turned, folded as they fade. // // A chain is a few effects' ids with their amounts and knobs; it is compiled into one shader a slot, rebuilt only when // its ids change: the amounts and knobs are uniforms. Each effect says what it adds to the meters (meters.ts), so the // governor holds it back as it holds the flame back. Pure: no GL here. import type { Levers } from './governor'; import type { VisualSettings } from './settings.svelte'; /** The engines (the owner, 65: "a kaleidoscope engine, exyedo engine, and maybe one more? And then have multiple * subeffects"): the picture's three slots, run in this order, each one of its sub-effects at a time. Kaleidoscope: * symmetry and tiling; EYEDO (after EYESY): on the beat and in rhythm; Hydra: flow, feedback and colour. */ export type Engine = 'kaleidoscope' | 'eyedo' | 'hydra'; export const ENGINES: readonly { key: Engine; label: string }[] = [ { key: 'kaleidoscope', label: 'Kaleidoscope' }, { key: 'eyedo', label: 'EYEDO' }, { key: 'hydra', label: 'Hydra' }, ]; /** The meters an effect adds to, each a second at its amount (the meters' own units). */ export type Report = Partial>; /** One of an effect's own controls (the owner: "one or two of the most relevant controls per chosen subeffect"): its * label in the panel, and its value (0..1, the half way a middle setting) as the panel shows it. */ export type Knob = { label: string; text: (v: number) => string }; /** An effect's two knobs (0..1 each; one it has no knob for is ignored). */ export type Knobs = readonly [number, number]; export type Effect = { /** Stable, kept in settings: never reused. */ id: number; /** The panel's label. */ name: string; /** shape: `vec2 fx_(vec2 p, float a, vec2 t, vec2 k)`, where to read instead; colour: `vec3 fx_(vec3 c, * vec2 p, float a, vec2 t, vec2 k)`; echo: `vec3 fx_(vec3 c, vec2 p, float a, vec2 t, vec2 k, sampler2D * prev)` (sample it with `fx_prev(prev, q)`). p: centred, aspect-true (y −1..1, x ±`fx_aspect()`); a: its amount * (0: none, it must change nothing, but for one that `holds`); t: its own time (`speed`'s; x, and y for one that * runs two); k: its knobs (0..1, the half way a middle setting). * * Both slots give `fx_moved(t)` (or `fx_moved(t.x)`): how far its time has moved it, on the picture all of it, in * the loop (which makes it again every frame, so the frames add up) this frame's step of it; `fx_step()`: how much * of a displacement to make, on the picture all of it, in the loop a sixtieth of a second's worth; `fx_frames()`: * this frame's length in sixtieths of a second, for what feeds back frame by frame. On the picture, `fx_beat()` * gives the beat: x where in it (0..1), y how long since it fell (seconds; long ago where there is none), z its * strength (0..1), w where in its bar (0..1). */ kind: 'shape' | 'colour' | 'echo'; /** Its engine (the picture's three, each one of its sub-effects at a time: `ENGINES`). */ engine: Engine; /** Where it may go: the loop takes shapes only. */ slots: readonly ('picture' | 'loop')[]; /** Its own controls, at most two, in `k`'s order. */ knobs: readonly Knob[]; /** How fast its own time runs (its units a second; two for one that runs two) at its amount and knobs; none: it has * none. Run a frame at a time (`EffectClocks`), so a changing amount or knob (the routes, the governor, a hand) * speeds it up or slows it down from where it is: a time multiplied out (the track's time times a speed) jumps at * every change, the further the later in the track. */ speed?: (amount: number, k: Knobs) => number | readonly [number, number]; /** Where its time comes round to the same (kept below it, so it stays small); none: it never does. */ wrap?: number; /** Its amount is its speed (Rotate, Scroll): at none it rests where its time took it, so it is still drawn there * (dropped, the picture would jump back), until another is chosen in its place. */ holds?: true; /** One GLSL ES 3.00 function `fx_`, and helpers named `fx__…`. */ glsl: string; report: (amount: number, k: Knobs) => Report; }; const between = (lo: number, hi: number, v: number) => lo + (hi - lo) * v; const TAU = 2 * Math.PI; const pct = (v: number) => `${Math.round(v * 100)}%`; const fixed = (lo: number, hi: number, digits: number, unit = '') => (v: number) => `${between(lo, hi, v).toFixed(digits)}${unit}`; const signedPct = (v: number) => (Math.abs(v - 0.5) < 0.01 ? 'None' : `${v > 0.5 ? '+' : '−'}${Math.round(Math.abs(v - 0.5) * 200)}%`); const HSV = (name: string) => `vec3 fx_${name}_hsv(vec3 c) { vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); vec4 q = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g)); vec4 r = mix(vec4(q.xyw, c.r), vec4(c.r, q.yzx), step(q.x, c.r)); float d = r.x - min(r.w, r.y); // Kept off nothing by 1e-4, not less: where a device runs this at half precision, less is nothing. return vec3(abs(r.z + (r.w - r.y) / (6.0 * d + 1e-4)), d / (r.x + 1e-4), r.x); } vec3 fx_${name}_rgb(vec3 c) { vec3 p = abs(fract(c.xxx + vec3(1.0, 2.0 / 3.0, 1.0 / 3.0)) * 6.0 - 3.0); return c.z * mix(vec3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y); }`; export const EFFECTS: readonly Effect[] = [ { // ported from hydra-synth (AGPL-3.0), kaleid id: 1, name: 'kaleid', engine: 'kaleidoscope', kind: 'shape', slots: ['picture', 'loop'], knobs: [ { label: 'Sides', text: (v) => `${Math.floor(2 + v * 10)}` }, { label: 'Spin', text: signedPct }, ], glsl: `vec2 fx_kaleid(vec2 p, float a, vec2 t, vec2 k) { if (a <= 0.0) return p; // Its sides (2 to 12), turning either way (none at the half way); its amount how far the picture is folded into it. float n = floor(2.0 + k.x * 10.0); float r = length(p); if (r < 1e-6) return p; float th = atan(p.y, p.x) + fx_moved(t.x); float side = 6.28318530718 / n; th = mod(th, side); th = min(th, side - th); return mix(p, r * vec2(cos(th), sin(th)), a); }`, speed: (a, k) => (k[1] - 0.5) * 0.4, wrap: TAU, report: (a, k) => ({ motion: Math.abs(k[1] - 0.5) * 0.4 * a, shapeChurn: 0.1 * a }), }, { // ported from hydra-synth (AGPL-3.0), posterize id: 2, name: 'posterize', engine: 'hydra', kind: 'colour', slots: ['picture'], knobs: [{ label: 'Steps', text: (v) => `${Math.round(between(16, 2, v))}` }], glsl: `vec3 fx_posterize(vec3 c, vec2 p, float a, vec2 t, vec2 k) { if (a <= 0.0) return c; // From many steps (none to see) down to its own (16 to 2 a channel) at all of it. float steps = mix(64.0, mix(16.0, 2.0, k.x), a); return mix(c, floor(c * steps + 0.5) / steps, min(1.0, a * 2.0)); }`, report: () => ({}), }, { // ported from hydra-synth (AGPL-3.0), repeat id: 3, name: 'repeat', engine: 'kaleidoscope', kind: 'shape', slots: ['picture'], knobs: [{ label: 'Tiles', text: fixed(1, 6, 1, '×') }], glsl: `// Itself within -1..1, mirrored back into it beyond. float fx_repeat_fold(float x) { return 1.0 - abs(mod(x + 1.0, 4.0) - 2.0); } vec2 fx_repeat(vec2 p, float a, vec2 t, vec2 k) { if (a <= 0.0) return p; // From one picture to six across (each the whole picture, its own shape), mirrored at each seam so none shows. float n = 1.0 + 5.0 * k.x; float w = fx_aspect(); return mix(p, vec2(w * fx_repeat_fold(p.x * n / w), fx_repeat_fold(p.y * n)), a); }`, report: (a) => ({ shapeChurn: 0.05 * a }), }, { // ported from hydra-synth (AGPL-3.0), pixelate id: 4, name: 'pixelate', engine: 'kaleidoscope', kind: 'shape', slots: ['picture', 'loop'], knobs: [{ label: 'Size', text: (v) => `${Math.round(between(64, 6, v))} cells` }], glsl: `vec2 fx_pixelate(vec2 p, float a, vec2 t, vec2 k) { if (a <= 0.0) return p; // From fine (two hundred cells across the height) to its own size (64 to 6) at all of it. float cells = 0.5 * mix(200.0, mix(64.0, 6.0, k.x), a * a); return (floor(p * cells) + 0.5) / cells; }`, report: () => ({}), }, { // ported from hydra-synth (AGPL-3.0), colorama id: 5, name: 'colorama', engine: 'hydra', kind: 'colour', slots: ['picture'], knobs: [ { label: 'Spread', text: pct }, { label: 'Cycle', text: pct }, ], glsl: `${HSV('colorama')} vec3 fx_colorama(vec3 c, vec2 p, float a, vec2 t, vec2 k) { if (a <= 0.0) return c; // Each colour's hue moved by its own brightness (its spread) and round with its time (its cycle: up to a tenth of a // turn a second at all of it): the colours shift in bands. Come in over its first tenth, so at none the turn it // rests at shows nothing. vec3 h = fx_colorama_hsv(c); h.x = fract(h.x + 1.2 * a * k.x * h.z + fx_moved(t.x)); return mix(c, fx_colorama_rgb(h), smoothstep(0.0, 0.1, a)); }`, speed: (a, k) => 0.1 * k[1] * a, wrap: 1, // Its cycle, and its spread as the light under it changes (taken as a sixth of the way from dark to light a // second: an estimate, the flame's own light changing as it does). report: (a, k) => ({ colourChurn: 0.1 * k[1] * a + 0.2 * k[0] * a }), }, { // ported from hydra-synth (AGPL-3.0), hue id: 6, name: 'hue', engine: 'hydra', kind: 'colour', slots: ['picture'], knobs: [], glsl: `${HSV('hue')} vec3 fx_hue(vec3 c, vec2 p, float a, vec2 t, vec2 k) { if (a <= 0.0) return c; // Every colour turned round the wheel by as much as half a turn; held, not cycling. vec3 h = fx_hue_hsv(c); h.x = fract(h.x + 0.5 * a); return fx_hue_rgb(h); }`, report: () => ({}), }, { // ported from hydra-synth (AGPL-3.0), modulate (by noise) id: 7, name: 'ripple', engine: 'hydra', kind: 'shape', slots: ['picture', 'loop'], knobs: [ { label: 'Scale', text: fixed(0.5, 5, 1) }, { label: 'Speed', text: pct }, ], glsl: `// Dave Hoskins' hash without sine (MIT): a sine of a large number loses its precision on some GPUs, and its // time only grows. float fx_ripple_hash(vec2 q) { vec3 h = fract(vec3(q.xyx) * 0.1031); h += dot(h, h.yzx + 33.33); return fract((h.x + h.y) * h.z); } float fx_ripple_noise(vec2 q) { vec2 i = floor(q); vec2 f = fract(q); vec2 u = f * f * (3.0 - 2.0 * f); return mix(mix(fx_ripple_hash(i), fx_ripple_hash(i + vec2(1.0, 0.0)), u.x), mix(fx_ripple_hash(i + vec2(0.0, 1.0)), fx_ripple_hash(i + vec2(1.0, 1.0)), u.x), u.y); } vec2 fx_ripple(vec2 p, float a, vec2 t, vec2 k) { if (a <= 0.0) return p; // Moved by a noise of its scale, drifting with its time (its speed), as far as a twentieth of the height at all of // it. vec2 q = p * mix(0.5, 5.0, k.x) + vec2(0.0, t.x); vec2 n = vec2(fx_ripple_noise(q), fx_ripple_noise(q + vec2(5.2, 1.3))) - 0.5; return p + 0.2 * a * n * fx_step(); }`, speed: (a, k) => 0.4 * k[1], report: (a, k) => ({ shapeChurn: 0.3 * k[1] * a }), }, { // ported from hydra-synth (AGPL-3.0), modulate (by the visuals' own flow) id: 8, name: 'stream', engine: 'hydra', kind: 'shape', slots: ['picture', 'loop'], knobs: [{ label: 'Length', text: (v) => `${Math.round(between(0.05, 0.5, v) * 1000)} ms` }], glsl: `vec2 fx_stream(vec2 p, float a, vec2 t, vec2 k) { if (a <= 0.0) return p; // Read from upstream: the picture drawn along the fluid's flow (nothing where it does not flow), as much of it as // its length (a twentieth to a half of a second). return p - mix(0.05, 0.5, k.x) * a * fx_flow(p) * fx_step(); }`, report: (a, k) => ({ shapeChurn: 0.36 * between(0.05, 0.5, k[0]) * a }), }, { // ported from hydra-synth (AGPL-3.0), src(o0) fed back, scaled and turned id: 9, name: 'echo', engine: 'hydra', kind: 'echo', slots: ['picture'], knobs: [ { label: 'Zoom', text: pct }, { label: 'Turn', text: pct }, ], glsl: `vec3 fx_echo(vec3 c, vec2 p, float a, vec2 t, vec2 k, sampler2D prev) { if (a <= 0.0) return c; // The last picture, larger by its zoom (up to six in a hundred a sixtieth of a second) and turned by its turn (up to // 0.72 of a radian a second), dimmed as it ages, kept where it is brighter: trails that stream out and round, alike // at any frame rate. float f = fx_frames(); float s = pow(1.0 + 0.06 * k.x * a, f); float r = 0.012 * k.y * a * f; vec2 q = mat2(cos(r), -sin(r), sin(r), cos(r)) * p / s; return max(c, fx_prev(prev, q) * pow(0.75 + 0.2 * a, f)); }`, // Its turn, and its zoom: a stream outward at 60 ln(1 + 0.06 zoom a) e-folds a second (about 3.5 zoom a) moves a // point as fast as a turn of that many radians a second does. report: (a, k) => ({ motion: 0.72 * k[1] * a + 60 * Math.log(1 + 0.06 * k[0] * a) }), }, { // ported from hydra-synth (AGPL-3.0), rotate id: 10, name: 'rotate', engine: 'kaleidoscope', kind: 'shape', slots: ['picture', 'loop'], knobs: [ { label: 'Speed', text: pct }, { label: 'Way', text: (v) => (v < 0.5 ? 'Left' : 'Right') }, ], glsl: `vec2 fx_rotate(vec2 p, float a, vec2 t, vec2 k) { // The whole turning with its time, up to a tenth of a turn a second at all of it, its way left or right. float r = fx_moved(t.x); return mat2(cos(r), -sin(r), sin(r), cos(r)) * p; }`, speed: (a, k) => 0.628318 * k[0] * a * (k[1] < 0.5 ? 1 : -1), wrap: TAU, holds: true, report: (a, k) => ({ motion: 0.628318 * k[0] * a }), }, { // ported from hydra-synth (AGPL-3.0), scroll id: 11, name: 'scroll', engine: 'kaleidoscope', kind: 'shape', slots: ['picture', 'loop'], knobs: [ { label: 'Speed', text: pct }, { label: 'Way', text: (v) => `${Math.round(v * 360)}°` }, ], glsl: `// Itself within -1..1, mirrored back into it beyond. float fx_scroll_fold(float x) { return 1.0 - abs(mod(x + 1.0, 4.0) - 2.0); } vec2 fx_scroll(vec2 p, float a, vec2 t, vec2 k) { // Drifting its way with its time (up to a tenth of the height a second at all of it), mirrored where it wraps. vec2 q = p + fx_moved(t); float w = fx_aspect(); return vec2(w * fx_scroll_fold(q.x / w), fx_scroll_fold(q.y)); }`, // Its time the way it has come, across and up: a change of way turns it from where it is. speed: (a, k) => [0.2 * k[0] * a * Math.cos(TAU * k[1]), 0.2 * k[0] * a * Math.sin(TAU * k[1])], holds: true, report: (a, k) => ({ motion: 0.2 * k[0] * a }), }, { // ported from hydra-synth (AGPL-3.0), saturate id: 12, name: 'saturate', engine: 'hydra', kind: 'colour', slots: ['picture'], knobs: [], glsl: `vec3 fx_saturate(vec3 c, vec2 p, float a, vec2 t, vec2 k) { if (a <= 0.0) return c; // Its colour drawn out from its grey, up to twice as far. float grey = dot(c, vec3(0.2126, 0.7152, 0.0722)); return clamp(mix(vec3(grey), c, 1.0 + a), 0.0, 1.0); }`, report: () => ({}), }, { // After EYESY's Magnify Cloud mode (Critter & Guitari): the idea only, our own shader. id: 13, name: 'magnify', engine: 'kaleidoscope', kind: 'shape', slots: ['picture'], knobs: [ { label: 'Size', text: pct }, { label: 'Power', text: (v) => `${(1 / (1 - between(0.3, 0.9, v))).toFixed(1)}×` }, ], glsl: `vec2 fx_magnify(vec2 p, float a, vec2 t, vec2 k) { if (a <= 0.0) return p; // A lens of its size (a quarter to nine tenths of the height across) wandering slowly about the middle, magnifying // what is under it by its power at its heart. vec2 m = 0.45 * vec2(sin(0.13 * t.x), cos(0.17 * t.x)); float r = mix(0.25, 0.9, k.x); float d = length(p - m) / r; if (d >= 1.0) return p; return m + (p - m) * (1.0 - mix(0.3, 0.9, k.y) * a * (1.0 - d * d)); }`, speed: () => 1, wrap: 200 * Math.PI, // What is under its heart moves as many times as fast as it is magnified. report: (a, k) => ({ shapeChurn: (0.08 * a) / (1 - between(0.3, 0.9, k[1]) * a) }), }, { // After EYESY's Concentric and Radiating Square modes (Critter & Guitari): the idea only, our own shader. id: 14, name: 'rings', engine: 'eyedo', kind: 'shape', slots: ['picture', 'loop'], knobs: [ { label: 'Count', text: (v) => `${Math.round(between(6, 30, v))}` }, { label: 'Speed', text: pct }, ], glsl: `vec2 fx_rings(vec2 p, float a, vec2 t, vec2 k) { if (a <= 0.0) return p; // Ripples running out from the middle (6 to 30 to the height, outward at one to eight a second), pushing what is // read in and out along the way. float r = length(p); if (r < 1e-4) return p; return p + (p / r) * 0.05 * a * sin(mix(6.0, 30.0, k.x) * r - t.x) * fx_step(); }`, speed: (a, k) => between(1, 8, k[1]), wrap: TAU, report: (a, k) => ({ shapeChurn: 0.025 * between(1, 8, k[1]) * a }), }, { // After EYESY's Interference mode (Critter & Guitari): the idea only, our own shader. id: 15, name: 'interference', engine: 'eyedo', kind: 'colour', slots: ['picture'], knobs: [ { label: 'Scale', text: (v) => `${Math.round(between(10, 40, v))}` }, { label: 'Drift', text: pct }, ], glsl: `vec3 fx_interference(vec3 c, vec2 p, float a, vec2 t, vec2 k) { if (a <= 0.0) return c; // Two sets of rings (their scale 10 to 40) from two points drifting at its drift, laid over each other: their moiré // darkens the picture in bands (never brightens it, so it cannot flash). vec2 c1 = 0.6 * vec2(sin(t.x), cos(0.7 * t.x)); vec2 c2 = -c1; float s = mix(10.0, 40.0, k.x); float m = 0.5 + 0.5 * cos(s * length(p - c1)) * cos(s * length(p - c2)); return c * mix(1.0, m, 0.7 * a); }`, speed: (a, k) => 0.14 * k[1], wrap: 20 * Math.PI, report: (a, k) => ({ shapeChurn: 0.08 * k[1] * a }), }, { // After EYESY's trigger modes (Critter & Guitari): the idea only, our own shader. id: 16, name: 'pulse', engine: 'eyedo', kind: 'shape', slots: ['picture'], knobs: [ { label: 'Swell', text: (v) => `${Math.round(between(0.03, 0.25, v) * 100)}%` }, { label: 'Decay', text: (v) => `${Math.round(between(0.06, 0.4, v) * 1000)} ms` }, ], glsl: `vec2 fx_pulse(vec2 p, float a, vec2 t, vec2 k) { if (a <= 0.0) return p; // On each beat the picture swells (by its swell, as strong as the beat) and eases back over its decay. vec4 b = fx_beat(); return p / (1.0 + mix(0.03, 0.25, k.x) * a * b.z * exp(-b.y / mix(0.06, 0.4, k.y))); }`, report: (a, k) => ({ zoomPumping: 3 * between(0.03, 0.25, k[0]) * a }), }, { // After EYESY's trigger modes (Critter & Guitari): the idea only, our own shader. id: 17, name: 'burst', engine: 'eyedo', kind: 'shape', slots: ['picture'], knobs: [ { label: 'Speed', text: pct }, { label: 'Width', text: pct }, ], glsl: `vec2 fx_burst(vec2 p, float a, vec2 t, vec2 k) { if (a <= 0.0) return p; // On each beat a ring of motion (its width) runs out from the middle (one to four heights a second, its speed), // pushing what it passes outward, fading over half a second. vec4 b = fx_beat(); float r = length(p); if (r < 1e-4) return p; float d = (r - mix(1.0, 4.0, k.x) * b.y) / mix(0.04, 0.2, k.y); float ring = exp(-d * d); return p - (p / r) * 0.07 * a * b.z * ring * exp(-b.y / 0.5); }`, report: (a) => ({ shapeChurn: 0.12 * a }), }, { // After EYESY's trigger modes (Critter & Guitari): the idea only, our own shader. id: 18, name: 'halo', engine: 'eyedo', kind: 'colour', slots: ['picture'], knobs: [ { label: 'Speed', text: pct }, { label: 'Width', text: pct }, ], glsl: `vec3 fx_halo(vec3 c, vec2 p, float a, vec2 t, vec2 k) { if (a <= 0.0) return c; // On each beat a dark ring (its width) runs out from the middle (its speed) and fades: it only ever darkens, so it // cannot flash. vec4 b = fx_beat(); float d = (length(p) - mix(0.8, 3.0, k.x) * b.y) / mix(0.03, 0.15, k.y); float ring = exp(-d * d); return c * (1.0 - 0.6 * a * b.z * ring * exp(-b.y / 0.7)); }`, report: (a) => ({ shapeChurn: 0.05 * a }), }, ]; const byId = new Map(EFFECTS.map((e) => [e.id, e])); /** Each engine's sub-effects in the order its setting counts them (1 the first, 0 none; a new one goes at the end, so * a kept setting keeps its effect), and the loop's likewise. */ export const ENGINE_EFFECTS: Record = { kaleidoscope: [1, 3, 10, 11, 4, 13].map((id) => byId.get(id)!), eyedo: [16, 17, 18, 14, 15].map((id) => byId.get(id)!), hydra: [9, 7, 8, 5, 6, 12, 2].map((id) => byId.get(id)!), }; export const LOOP_EFFECTS: readonly Effect[] = [1, 4, 7, 8, 10, 11, 14].map((id) => byId.get(id)!); /** An engine's sub-effect at `place` (1..), or the loop's: its id (0: none). */ export const engineEffect = (engine: Engine, place: number) => ENGINE_EFFECTS[engine][Math.round(place) - 1]?.id ?? 0; export const loopEffect = (place: number) => LOOP_EFFECTS[Math.round(place) - 1]?.id ?? 0; /** The label of control `n` (0, 1) of the sub-effect at `place` in an engine (or the loop), for its column in the * routes; null where none is chosen there or it has no such control. */ export const knobLabel = (slot: Engine | 'loop', place: number, n: 0 | 1): string | null => (slot === 'loop' ? LOOP_EFFECTS : ENGINE_EFFECTS[slot])[Math.round(place) - 1]?.knobs[n]?.label ?? null; /** An effect by its id (none for 0 or one not known: a setting from a later version shows nothing, rather than fails). */ export const effectOf = (id: number) => byId.get(id) ?? null; /** The most effects in the picture's chain. */ export const MOST_PICTURE = 3; /** A chain's ids as kept (0: none), its effects in order, those not known or not for the slot left out. */ export function chainOf(ids: readonly number[], slot: 'picture' | 'loop'): Effect[] { return ids.map(effectOf).filter((e): e is Effect => !!e && e.slots.includes(slot)); } const HALF: Knobs = [0.5, 0.5]; /** What a chain adds to the meters a second at its amounts and knobs (each id's by place). */ export function reportOf(ids: readonly number[], amounts: readonly number[], slot: 'picture' | 'loop', knobs: readonly Knobs[] = []): Report { const out: Report = {}; ids.forEach((id, k) => { const e = effectOf(id); if (!e || !e.slots.includes(slot) || !(amounts[k] > 0)) return; for (const [m, v] of Object.entries(e.report(amounts[k], knobs[k] ?? HALF)) as [keyof Report, number][]) out[m] = (out[m] ?? 0) + v; }); return out; } const COMMON = `// Its time's: all of it here (the loop's: a frame's step). float fx_moved(float t) { return t; } vec2 fx_moved(vec2 t) { return t; } float fx_step() { return 1.0; } float fx_frames() { return uFxFrames; } float fx_aspect() { return uAspect; } vec3 fx_prev(sampler2D s, vec2 q) { return textureLod(s, vec2(q.x / uAspect, q.y) * 0.5 + 0.5, 0.0).rgb; } // The beat now: where in it, how long since it fell (s), its strength, where in its bar (4/4). vec4 fx_beat() { return uFxBeat; } // The fluid's flow where p is (p's units a second); none where nothing flows. vec2 fx_flow(vec2 p) { if (uFlowScale.x == 0.0) return vec2(0.0); vec2 v = textureLod(uFlow, vec2(p.x / uAspect, p.y) * 0.5 + 0.5, 0.0).xy * uFlowScale; return v * 2.0 * vec2(uAspect, 1.0); }`; /** The picture slot's fragment shader for a chain (its ids by place, as `uFxA`'s and `uFxK`'s), or null where nothing * is in it: the picture (`uPicture`) read where its shapes say, then its colours and echoes (`uPrev`: the last * finished one). */ export function pictureShader(ids: readonly number[]): string | null { const placed = ids.map((id, k) => ({ e: effectOf(id), k })).filter((x): x is { e: Effect; k: number } => !!x.e && x.e.slots.includes('picture')); if (!placed.length) return null; const once = [...new Map(placed.map(({ e }) => [e.id, e.glsl])).values()].join('\n'); const call = ({ e, k }: { e: Effect; k: number }) => e.kind === 'shape' ? ` p = fx_${e.name}(p, uFxA[${k}], uFxT[${k}], uFxK[${k}]);` : e.kind === 'colour' ? ` c = fx_${e.name}(c, p, uFxA[${k}], uFxT[${k}], uFxK[${k}]);` : ` c = fx_${e.name}(c, p, uFxA[${k}], uFxT[${k}], uFxK[${k}], uPrev);`; return `#version 300 es precision highp float; uniform sampler2D uPicture; uniform sampler2D uPrev; uniform vec2 uView; uniform float uAspect; uniform float uFxA[${MOST_PICTURE}]; uniform vec2 uFxK[${MOST_PICTURE}]; uniform vec2 uFxT[${MOST_PICTURE}]; uniform float uFxFrames; uniform vec4 uFxBeat; uniform sampler2D uFlow; uniform vec2 uFlowScale; out vec4 o; ${COMMON} ${once} void main() { vec2 p = (gl_FragCoord.xy / uView - 0.5) * 2.0 * vec2(uAspect, 1.0); ${placed.filter(({ e }) => e.kind === 'shape').map(call).join('\n')} vec3 c = textureLod(uPicture, vec2(p.x / uAspect, p.y) * 0.5 + 0.5, 0.0).rgb; ${placed.filter(({ e }) => e.kind !== 'shape').map(call).join('\n')} o = vec4(clamp(c, 0.0, 1.0), 1.0); }`; } /** `fx_flow` where nothing flows (the loop's carry pass): none. The fluid's light pass gives its own (fluid.ts). */ export const NO_FLOW = `vec2 fx_flow(vec2 p) { return vec2(0.0); }`; /** The loop slot's GLSL for one effect: `vec2 fx_loop(vec2 uv, float a, vec4 time, float frames, float aspect, vec2 * k)` on a buffer's coordinates (0..1), for the passes that read the kept light back (`time`: its own time, and what * that moved this frame; `frames`: this frame's length in sixtieths of a second); none (the identity) where it is not * a shape. The passes make it again every frame, on what they made last frame: a time's whole would add up frame on * frame, so its effect is given this frame's step of it, and a displacement a frame's worth. */ export const NO_LOOP = `vec2 fx_loop(vec2 uv, float a, vec4 time, float frames, float aspect, vec2 k) { return uv; }`; export function loopFunction(id: number, flow = NO_FLOW): string { const e = effectOf(id); if (!e || !e.slots.includes('loop') || e.kind !== 'shape') return NO_LOOP; return `${flow} vec2 fxLoopMoved; float fxLoopFrames; float fxLoopAspect; float fx_moved(float t) { return fxLoopMoved.x; } vec2 fx_moved(vec2 t) { return fxLoopMoved; } float fx_step() { return fxLoopFrames; } float fx_frames() { return fxLoopFrames; } float fx_aspect() { return fxLoopAspect; } ${e.glsl} vec2 fx_loop(vec2 uv, float a, vec4 time, float frames, float aspect, vec2 k) { fxLoopMoved = time.zw; fxLoopFrames = frames; fxLoopAspect = aspect; vec2 p = (uv - 0.5) * 2.0 * vec2(aspect, 1.0); p = fx_${e.name}(p, a, time.xy, k); return vec2(p.x / aspect, p.y) * 0.5 + 0.5; }`; } /** What the clocks run on: the look's effects as `effectsNow` gives them. */ export type ClockedEffects = { ids: readonly number[]; amounts: readonly number[]; knobs?: readonly Knobs[]; loop: number; loopAmount: number; loopKnobs?: Knobs; /** The track's own time (s). */ t: number; }; /** Each place's own time (the picture's three, then the loop's), run a frame at a time by its effect's `speed` at * its amount and knobs then, on the track's own time (paused, it rests). A place's time starts again at nothing when * another effect takes it. */ export class EffectClocks { readonly times: [number, number][] = [ [0, 0], [0, 0], [0, 0], [0, 0], ]; /** What each moved in the last frame (the loop's passes take this). */ readonly moved: [number, number][] = [ [0, 0], [0, 0], [0, 0], [0, 0], ]; private ids = [0, 0, 0, 0]; private last: number | null = null; run(fx: ClockedEffects): this { // The track's time since the last frame: none backwards (a seek back, the next track), at most a quarter of a // second (a seek on). const step = this.last === null ? 0 : Math.min(0.25, Math.max(0, fx.t - this.last)); this.last = fx.t; const places: [number, number, Knobs][] = [0, 1, 2].map((k) => [fx.ids[k] ?? 0, fx.amounts[k] ?? 0, fx.knobs?.[k] ?? HALF]); places.push([fx.loop, fx.loopAmount, fx.loopKnobs ?? HALF]); places.forEach(([id, a, k], i) => { if (id !== this.ids[i]) [this.ids[i], this.times[i]] = [id, [0, 0]]; const e = effectOf(id); const v = e?.speed?.(Math.max(0, a), k) ?? 0; const [vx, vy] = typeof v === 'number' ? [v, 0] : v; this.moved[i] = [vx * step, vy * step]; this.times[i] = this.times[i].map((x, j) => { const y = x + this.moved[i][j]; return e?.wrap ? y - e.wrap * Math.floor(y / e.wrap) : y; }) as [number, number]; }); return this; } /** Whether one of the picture's that `holds` rests away from where it began: it is drawn even at none. */ holding(): boolean { return this.ids.slice(0, MOST_PICTURE).some((id, i) => !!effectOf(id)?.holds && (this.times[i][0] !== 0 || this.times[i][1] !== 0)); } } /** The look's effects as a setting holds them (settings.svelte.ts). */ export type EffectSettings = Pick< VisualSettings, | 'fx1' | 'fx2' | 'fx3' | 'fx1Amount' | 'fx2Amount' | 'fx3Amount' | 'fxLoop' | 'fxLoopAmount' | 'fx1K1' | 'fx1K2' | 'fx2K1' | 'fx2K2' | 'fx3K1' | 'fx3K2' | 'fxLoopK1' | 'fxLoopK2' >; /** Nothing held back. */ const FREE: Levers = { swing: 1, pace: 1, evolution: 1, hue: 1, size: 1, gap: 1 }; /** How far the governor lets an effect through (governor.ts): the flashes' swing always (any effect can flash), and the * lever of every meter it counts toward (its report's), as each holds the flame back: motion the pace, shape churn the * evolving (stretched: 1/evolution), colour churn the hues, zoom pumping the size. Its reports are in step with its * amount, so each lever lowers just what its meter counts. */ export function heldBack(id: number, amount: number, k: Knobs, levers: Levers): number { const e = effectOf(id); if (!e || !(amount > 0)) return 1; const r = e.report(amount, k); let f = levers.swing; if (r.motion) f *= levers.pace; if (r.shapeChurn) f /= Math.max(1, levers.evolution); if (r.colourChurn) f *= levers.hue; if (r.zoomPumping) f *= levers.size; return Math.max(0, Math.min(1, f)); } /** Whether an effect changes the picture over time at its knobs (its report at all of it counts toward a meter): * under reduced motion it rests; one that only recolours or reshapes it, steady, stays. */ export const moves = (id: number, k: Knobs) => Object.values(effectOf(id)?.report(1, k) ?? {}).some((v) => v !== 0); /** What the routes make of each engine's and the loop's own settings now (65.1, the matrix's effect columns; none: as * set): `amounts`, levels (0..1) the Kaleidoscope's, EYEDO's, Hydra's and the loop's amounts are taken down to, as * Fluid's; `knobs`, swings (−1..1) each control is moved by from where it is set, as Hue's, in the engines' order * then the loop's, two each. */ export type Routed = { amounts: readonly number[]; knobs: readonly number[] }; /** The look's effects for the renderer (render.ts `Shown.effects`): each amount times what the Effects setting's routes * let through (`level`), its own routes' level (`routed`) and what the governor lets through (`levers`), none for * one that `moves` under reduced motion (`still`); each one's knobs as routed (the GLSL's, the clocks' and the * reports' alike, so the governor sees what is drawn); `t` the track's own time (s); the beat. */ export const effectsNow = (how: EffectSettings, level: number, t: number, beat: Beat | null = null, levers: Levers = FREE, still = false, routed?: Routed) => { const unit = (v: number) => Math.max(0, Math.min(1, v)); const swung = (v: number, i: number) => unit(v + (routed?.knobs[i] ?? 0)); const levelOf = (k: number) => unit(routed?.amounts[k] ?? 1); // Each engine's sub-effect (its setting the place in the engine's list), in the engines' order. const ids = [how.fx1, how.fx2, how.fx3].map((place, k) => engineEffect(ENGINES[k].key, place)); const knobs = [ [swung(how.fx1K1, 0), swung(how.fx1K2, 1)], [swung(how.fx2K1, 2), swung(how.fx2K2, 3)], [swung(how.fx3K1, 4), swung(how.fx3K2, 5)], ] as Knobs[]; const loop = loopEffect(how.fxLoop); const loopKnobs = [swung(how.fxLoopK1, 6), swung(how.fxLoopK2, 7)] as Knobs; const set = [how.fx1Amount, how.fx2Amount, how.fx3Amount].map((a, k) => a * level * levelOf(k)); const on = (id: number, k: Knobs) => (still && moves(id, k) ? 0 : 1); const loopSet = how.fxLoopAmount * level * levelOf(3); return { ids, amounts: set.map((a, k) => a * heldBack(ids[k], a, knobs[k], levers) * on(ids[k], knobs[k])), knobs, loop, loopAmount: loopSet * heldBack(loop, loopSet, loopKnobs, levers) * on(loop, loopKnobs), loopKnobs, t, beat: beatOf(beat), }; }; /** The beat as the visuals know it (music.svelte.ts): its number, how long since it fell, how long it lasts (s), its * strength. None (paused, reduced motion, a track without beats): long ago, so a beat's effect rests. */ export type Beat = { index: number; since: number; length: number; strength: number }; export const beatOf = (b: Beat | null): [number, number, number, number] => b && b.length > 0 ? [Math.min(1, Math.max(0, b.since / b.length)), Math.max(0, b.since), Math.min(1, Math.max(0, b.strength)), (((b.index % 4) + 4) % 4 + Math.min(1, b.since / b.length)) / 4] : [0, 1000, 0, 0]; /** What the look's effects add to the meters a second, the picture's and the loop's together: the same for the visuals' * meters and the precheck's, so the governor and the dice hold an effect alike. */ export function settingsReport(how: EffectSettings, level: number, levers: Levers = FREE, still = false, routed?: Routed): Report { const fx = effectsNow(how, level, 0, null, levers, still, routed); const out: Report = { ...reportOf(fx.ids, fx.amounts, 'picture', fx.knobs) }; for (const [k, v] of Object.entries(reportOf([fx.loop], [fx.loopAmount], 'loop', [fx.loopKnobs])) as [keyof Report, number][]) out[k] = (out[k] ?? 0) + v; return out; } ===== src/lib/visuals/envelope.ts ===== // Each band's Envelope (65, decision 0050, claude-5f's definition the owner agreed): how present the band's sound is now. // Pure, so the visuals and the precheck (both stepping music.svelte.ts's `musicOf` state) follow it alike. import { B } from './routes'; /** At most this share of a band's level while its sound holds: a pad glows at it, a hit's flare stands out above it. */ export const SUSTAIN = 0.6; /** How fast a band's hold rises to what its sound holds (s). */ export const RISE_S = 0.05; /** What the envelopes keep from frame to frame: each band's signals (`B` order; its Envelope written there), each band's * hold, and when they were last followed (ms; −1 never). */ export type Envelopes = { bands: Float32Array[]; held: Float32Array; heldAt: number }; /** Each band's Envelope at `at` (ms): its Hits signal (each hit's flare, risen into where it is known ahead, ringing * down over the band's Linger) or what the sound still holds, whichever is more. The hold follows `SUSTAIN` × the * band's level: up over `RISE_S`, down over the band's Linger (`lighting`, s), as a hit's light rings down. So a struck * drum flares and is gone as its level drops; a pad, never struck, glows at its level; a struck chord flares, then * holds while it rings. The first call, or one after a jump back, starts each hold where its sound is. */ export function followEnvelopes(st: Envelopes, at: number, lighting: readonly number[]) { const dt = st.heldAt < 0 ? -1 : Math.min(0.25, (at - st.heldAt) / 1000); for (let b = 0; b < st.bands.length; b++) { const v = st.bands[b]; const target = SUSTAIN * Math.min(1, Math.max(0, v[B.level])); if (dt < 0) st.held[b] = target; else { const held = st.held[b]; const time = target > held ? RISE_S : Math.max(0.02, lighting[b] ?? 0.15); st.held[b] = held + (target - held) * (1 - Math.exp(-dt / time)); } v[B.envelope] = Math.min(1, Math.max(v[B.hits], st.held[b])); } st.heldAt = at; } ===== src/lib/visuals/flame.ts ===== // Fractal flames (decision 0046), written from Draves and Reckase, "The Fractal Flame Algorithm" (2003; revised // 2008): an iterated function system whose transforms are an affine map followed by a weighted sum of nonlinear // "variations", drawn by the chaos game with a colour coordinate per point. This file holds a flame's description: // made from a preset of the look's settings alone (a track's id gives the same preset every time, checked on the CPU // before it is shown: one whose flame collapses to a point or smears into noise is passed over), framed, evolved by the // music, and blended into the next. The drawing is lib/visuals/render.ts. import { DRAWN_ROOM } from './budget'; import { ENGINE_EFFECTS, ENGINES, LOOP_EFFECTS, settingsReport } from './effects'; /** A flame's parts: its first transforms, one for each of the music's six bands, low to high (decision 0046, amended). */ export const PARTS = 6; /** The variations, by the paper's numbers (its appendix). The shader (render.ts) implements the same list. */ export const VARIATIONS = { linear: 0, sinusoidal: 1, spherical: 2, swirl: 3, horseshoe: 4, polar: 5, handkerchief: 6, heart: 7, disc: 8, spiral: 9, hyperbolic: 10, diamond: 11, ex: 12, julia: 13, bent: 14, waves: 15, fisheye: 16, popcorn: 17, exponential: 18, power: 19, cosine: 20, rings: 21, fan: 22, eyefish: 27, bubble: 28, cylinder: 29, noise: 31, blur: 34, arch: 41, tangent: 42, square: 43, rays: 44, blade: 45, secant2: 46, twintrian: 47, cross: 48, // Cratarr's own (not flam3's): its mark (the Cratarr lens, `MARK`). cratarr: 99, } as const; export type Variation = (typeof VARIATIONS)[keyof typeof VARIATIONS]; export type Affine = [number, number, number, number, number, number]; /** One transform: x' = a·x + b·y + c, y' = d·x + e·y + f, then its variations summed by weight, then (flam3's "post") * another affine map, where it has one. */ export type Xform = { /** a, b, c, d, e, f. */ affine: Affine; post?: Affine; /** Variation number to its weight. */ variations: Map; /** How often it is chosen, against the others' weights. */ weight: number; /** Its hue, a turn of the colour wheel (0..1), and how far a point's colour moves towards it each time (0..1). A * point's colour is a place in the colour plane (OKLab's a and b), not on a line: one moving between two hues goes * paler on the way, never through a third. */ color: number; colorSpeed: number; /** How far out its colour is (0..1 of the flame's chroma): two parts of one hue told apart by it (a palette family's * pairs). */ colorReach?: number; /** Turned by the animation (a symmetry's rotations and mirrors are not). */ turns: boolean; }; /** The lightness and chroma of a flame's colours (OKLCH); each transform has its own hue. `lights`: each part's own * lightness, where they differ (a cover's colours, the fire's), in place of `light`. */ export type Colours = { light: number; chroma: number; lights?: number[] }; export type Flame = { xforms: Xform[]; /** flam3's final transform: applied to every point as it is drawn, not as it goes on (a warp of the whole picture). */ final?: Xform; /** Its symmetry, as made (the visuals' Symmetry and Mirror settings may ask another): rotations of this order, and * mirrors across as many lines. */ symmetry: { order: number; mirror: boolean }; colours: Colours; /** Where its points fall, for the camera: centre and the half-size that holds most of them. */ center: [number, number]; size: number; }; // --- A seeded random source (the same seed, the same flame) --- /** A string to a 32-bit seed (FNV-1a). */ export function seedOf(text: string): number { let h = 0x811c9dc5; for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; } /** Mulberry32: small, fast, and good enough to choose shapes. */ export function randomFrom(seed: number): () => number { let s = seed >>> 0; return () => { s = (s + 0x6d2b79f5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // --- Making a flame --- // The variations a random flame picks from, with how often: nearly evenly (three times as often as the rest only for the // plainest, and a third for the ones that tear the picture apart or scatter it into dust). const PICKS: [Variation, number][] = [ [VARIATIONS.linear, 4], [VARIATIONS.sinusoidal, 3], [VARIATIONS.spherical, 3], [VARIATIONS.swirl, 3], [VARIATIONS.horseshoe, 3], [VARIATIONS.polar, 3], [VARIATIONS.handkerchief, 3], [VARIATIONS.heart, 3], [VARIATIONS.disc, 3], [VARIATIONS.spiral, 3], [VARIATIONS.hyperbolic, 3], [VARIATIONS.diamond, 3], [VARIATIONS.ex, 3], [VARIATIONS.julia, 3], [VARIATIONS.bent, 3], [VARIATIONS.waves, 3], [VARIATIONS.fisheye, 3], [VARIATIONS.popcorn, 2], [VARIATIONS.exponential, 2], [VARIATIONS.power, 3], [VARIATIONS.cosine, 2], [VARIATIONS.rings, 3], [VARIATIONS.fan, 3], [VARIATIONS.eyefish, 3], [VARIATIONS.bubble, 3], [VARIATIONS.cylinder, 3], [VARIATIONS.noise, 1], [VARIATIONS.blur, 1], [VARIATIONS.arch, 2], [VARIATIONS.tangent, 1], [VARIATIONS.square, 1], [VARIATIONS.rays, 1], [VARIATIONS.blade, 2], [VARIATIONS.secant2, 1], [VARIATIONS.twintrian, 2], [VARIATIONS.cross, 1], ]; const PICK_TOTAL = PICKS.reduce((n, [, w]) => n + w, 0); /** A variation by `u` (0..1) through the picks' weights. */ function pickVariation(u: number): Variation { let r = u * PICK_TOTAL; for (const [v, w] of PICKS) if ((r -= w) < 0) return v; return VARIATIONS.linear; } /** An affine map turned by `angle`, scaled by `sx` and `sy`, sheared, and moved by (c, f). */ function affineOf(angle: number, sx: number, sy: number, shear: number, c: number, f: number): Affine { const cos = Math.cos(angle); const sin = Math.sin(angle); return [sx * cos, sy * (shear * cos - sin), c, sx * sin, sy * (shear * sin + cos), f]; } /** How many numbers each part draws from its form: always as many, so a slider moved changes no other part's draws. */ const DRAWS = 22; /** A band's part: its form's draws (`r`, 0..1 each) about the preset's Detail, Twist and Warp. */ function partOf(r: number[], p: Preset): Xform { // Detail: how far it contracts (0.3, tight, to 1, open, as the random ones ranged), each part a quarter either side. const scale = 0.3 + 0.7 * p.detail; const sx = Math.min(1, Math.max(0.15, scale + (r[1] - 0.5) * 0.5)); const sy = Math.min(1, Math.max(0.15, scale + (r[2] - 0.5) * 0.5)); // Twist: how far it turns, each part its own way (at the most any way round, as the random ones did); sheared and // moved off the middle by its form. const affine = affineOf((r[0] * 2 - 1) * Math.PI * p.twist, sx, sy, (r[3] - 0.5) * 0.8, (r[4] - 0.5) * 1.6, (r[5] - 0.5) * 1.6); // Its variations: one to three, by its form, their weights adding up to Warp's share (so the part keeps its size), the // rest plain. const kinds = new Map(); const n = r[6] < 0.4 ? 1 : r[7] < 0.6 ? 2 : 3; for (let i = 0; i < n; i++) { const v = pickVariation(r[8 + i]); kinds.set(v, (kinds.get(v) ?? 0) + 0.3 + r[11 + i] * 0.7); } const sum = [...kinds.values()].reduce((s, w) => s + w, 0); const variations = new Map(); for (const [v, w] of kinds) variations.set(v, (w / sum) * p.warp); if (p.warp < 1) variations.set(VARIATIONS.linear, (variations.get(VARIATIONS.linear) ?? 0) + 1 - p.warp); // A third of them a post map: a turn (by Twist) and a little scaling after the variations, which bends their shapes // round. const post = r[14] < 0.35 ? affineOf((r[15] * 2 - 1) * Math.PI * p.twist, 0.8 + r[16] * 0.4, 0.8 + r[17] * 0.4, 0, (r[18] - 0.5) * 0.4, (r[19] - 0.5) * 0.4) : undefined; return { affine, post, variations, weight: 0.25 + r[20], color: 0, colorSpeed: 0.3 + r[21] * 0.5, turns: true }; } // The variations a final transform takes: the ones that warp the whole picture into a whole (a sphere, a swirl, a lens), // not the ones that scatter it. const FINALS: Variation[] = [ VARIATIONS.spherical, VARIATIONS.swirl, VARIATIONS.julia, VARIATIONS.fisheye, VARIATIONS.eyefish, VARIATIONS.bubble, VARIATIONS.polar, VARIATIONS.disc, VARIATIONS.sinusoidal, VARIATIONS.horseshoe, VARIATIONS.handkerchief, VARIATIONS.heart, VARIATIONS.cratarr, ]; /** The finals a preset draws among: the ones before Cratarr's, so every track's own look stays as it was drawn (the * Cratarr lens is one to pick). */ const DRAWN_FINALS = FINALS.indexOf(VARIATIONS.cratarr); /** Cratarr's mark (lib/Mark.svelte, tools/brand/record.py's `full`), the Cratarr lens's shape: the record's rings (inner * and outer radius, of its whole) outside its slice, the sixth about the right, and in the slice the thin rings that * are gaps elsewhere. `share`: the slice's share of the mark's area. The lens sends each point into the slice or the * rest by its angle, as their areas share, and its distance into their rings by area, so both are filled evenly. */ export const MARK = (() => { const of = (rings: number[][]) => rings.map(([a, b]) => [a / 24, b / 24] as [number, number]); const rings = of([[0, 3], [8, 12.4], [13.6, 16.4], [17.6, 20.4], [21.6, 24]]); const slice = of([[12.4, 13.6], [16.4, 17.6], [20.4, 21.6]]); const area = (list: [number, number][]) => list.reduce((s, [a, b]) => s + b * b - a * a, 0); const outside = (area(rings) * 5) / 6; const inside = area(slice) / 6; return { rings, slice, share: inside / (outside + inside) }; })(); /** A distance into `rings` by `u` (0..1), as their areas share it. */ function ringAt(rings: [number, number][], u: number): number { const total = rings.reduce((s, [a, b]) => s + b * b - a * a, 0); let left = Math.min(1, Math.max(0, u)) * total; for (const [a, b] of rings) { const area = b * b - a * a; if (left < area) return Math.sqrt(a * a + left); left -= area; } return rings[rings.length - 1][1]; } /** The Cratarr lens on a point (its distance `r`): into the mark. */ export function markOf(x: number, y: number, r: number): [number, number] { const t = (Math.atan2(y, x) + Math.PI) / (2 * Math.PI); const u = (r / (1 + r)) ** 2; const inSlice = t < MARK.share; const to = inSlice ? -Math.PI / 6 + (t / MARK.share) * (Math.PI / 3) : Math.PI / 6 + ((t - MARK.share) / (1 - MARK.share)) * ((5 * Math.PI) / 3); const out = ringAt(inSlice ? MARK.slice : MARK.rings, u); return [out * Math.cos(to), out * Math.sin(to)]; } /** The finals' names, for the Lens setting (1 is the first). */ export const LENSES = FINALS.map((v) => { const name = Object.entries(VARIATIONS).find(([, n]) => n === v)![0]; return name[0].toUpperCase() + name.slice(1); }); /** A final transform (the Lens setting's `lens`, one of `FINALS`), by its form's draws (`f`): near the identity, then * the warp, up to half of it the plain picture. Cratarr's all of it: any plain picture added would fill its slice. */ function finalOf(f: number[], lens: number): Xform { const warp = FINALS[(lens - 1) % FINALS.length]; return { affine: affineOf(f[0] * Math.PI * 2, 0.8 + f[1] * 0.5, 0.8 + f[2] * 0.5, (f[3] - 0.5) * 0.3, (f[4] - 0.5) * 0.3, (f[5] - 0.5) * 0.3), variations: warp === VARIATIONS.cratarr ? new Map([[warp, 1]]) : new Map([ [warp, 0.5 + f[6] * 0.5], [VARIATIONS.linear, f[7] * 0.5], ]), weight: 0, color: 0, colorSpeed: 0, turns: false, }; } /** Symmetry as flam3 adds it: order − 1 pure rotations, and with `mirror` (dihedral symmetry) as many reflections, across * lines `order` apart; they keep a point's colour and do not turn. */ function symmetry(order: number, mirror: boolean, weight: number): Xform[] { const out: Xform[] = []; const plain = (affine: Affine): Xform => ({ affine, variations: new Map([[VARIATIONS.linear, 1]]), weight, color: 0, colorSpeed: 0, turns: false }); for (let k = 1; k < order; k++) { const t = (2 * Math.PI * k) / order; out.push(plain([Math.cos(t), -Math.sin(t), 0, Math.sin(t), Math.cos(t), 0])); } if (mirror) for (let k = 0; k < order; k++) { const t = (2 * Math.PI * k) / order; out.push(plain([Math.cos(t), Math.sin(t), 0, Math.sin(t), -Math.cos(t), 0])); } return out; } /** The flame with another symmetry: its parts as they are, the rotations and mirrors made anew (the visuals' Symmetry and * Mirror settings; `order` 1 and no mirror: none). */ export function withSymmetry(flame: Flame, order: number, mirror: boolean): Flame { const parts = flame.xforms.filter((x) => x.turns); const mean = parts.reduce((s, x) => s + x.weight, 0) / Math.max(1, parts.length); return { ...flame, xforms: [...parts, ...symmetry(order, mirror, mean)], symmetry: { order, mirror } }; } /** The parts' hues and reaches (OKLCH) by the Family setting, every family keeping the bands told apart: the spectrum * (six hues evenly round the wheel), an arc (the six spread over a third to over half of it, a warm or a cool sweep), * two tones (two hues across the wheel, the bands taking them in turn, near and far), three (each for two neighbouring * bands, near and far). From `hue` (the Hue setting); the arc's span, the tones' distance and each part's own hair of * difference from the form's draws (`f`). */ function huesOf(family: number, hue: number, f: number[]): { color: number; reach: number }[] { return Array.from({ length: PARTS }, (_, i) => { let h: number; let reach = 1; if (family === 0) h = hue + i / PARTS; else if (family === 1) h = hue + (i / (PARTS - 1)) * (0.33 + f[0] * 0.22); else if (family === 2) { h = hue + (i % 2) * (0.42 + f[1] * 0.08); reach = i % 4 < 2 ? 1 : 0.55; } else { h = hue + Math.floor(i / 2) / 3; reach = i % 2 ? 0.55 : 1; } return { color: (((h + (f[2 + i] - 0.5) * 0.03) % 1) + 1) % 1, reach }; }); } /** A flame's look, all of it as the visuals' settings show it (decision 0046, amended): a track's key or the dice give * one (`presetOf`) and the flame is made from it alone (`flameOf`), so every slider moves what is shown and nothing about * the look is hidden in a seed. * - form: which variations each part is made of, and each part's own difference from the sliders' values (a choice, * not a dial: neighbouring numbers are unrelated forms, 0 to `FORMS` - 1); * - detail, twist, warp (0..1): how far the parts contract (tight, dense cores to large, wispy forms), how far they * turn (straight arms to spirals), how much of each is its variations rather than plain (gentle to wild); * - symmetry (1 to 8 arms), mirror (0 or 1), final (the Lens: 0 none, else one of `LENSES`, 1 the first); * - family (0 to 3: spectrum, arc, two tones, three; 4 and 5, the Colours' Cover and Fire, chosen only by hand), hue (where the colours start, a turn of the wheel), lightness and * vividness (0..1: OKLCH lightness 0.55 to 0.85, chroma 0.06 to 0.26); * - spread and purity, as `evolved` takes them. */ export type Preset = { form: number; detail: number; twist: number; warp: number; symmetry: number; mirror: number; final: number; family: number; hue: number; lightness: number; vividness: number; spread: number; purity: number; /** The effects (65, effects.ts): the picture's three engines (Kaleidoscope, EYEDO, Hydra) and the loop, each its * sub-effect (its place in the engine's list, 0: none) and its amount (0..1). */ fx1: number; fx1Amount: number; fx2: number; fx2Amount: number; fx3: number; fx3Amount: number; fxLoop: number; fxLoopAmount: number; /** Each engine's (and the loop's) sub-effect's two knobs (0..1, the half way a middle setting). */ fx1K1: number; fx1K2: number; fx2K1: number; fx2K2: number; fx3K1: number; fx3K2: number; fxLoopK1: number; fxLoopK2: number; /** Facets (65): how much each cell of a lattice takes on one light where the light flows (0..1: none to hard tiles), * and the lattice (0 square, 1 hex). */ facets: number; lattice: number; }; export const PRESET_KEYS = [ 'form', 'detail', 'twist', 'warp', 'symmetry', 'mirror', 'final', 'family', 'hue', 'lightness', 'vividness', 'spread', 'purity', 'fx1', 'fx1Amount', 'fx2', 'fx2Amount', 'fx3', 'fx3Amount', 'fxLoop', 'fxLoopAmount', 'fx1K1', 'fx1K2', 'fx2K1', 'fx2K2', 'fx3K1', 'fx3K2', 'fxLoopK1', 'fxLoopK2', 'facets', 'lattice', ] as const; /** No effects. */ const NO_EFFECTS = { fx1: 0, fx1Amount: 0, fx2: 0, fx2Amount: 0, fx3: 0, fx3Amount: 0, fxLoop: 0, fxLoopAmount: 0, fx1K1: 0.5, fx1K2: 0.5, fx2K1: 0.5, fx2K2: 0.5, fx3K1: 0.5, fx3K2: 0.5, fxLoopK1: 0.5, fxLoopK2: 0.5, }; export const FORMS = 1000; /** A preset that always draws something, for when no draw from a seed passes the check. */ const STANDBY: Preset = { form: 0, detail: 0.5, twist: 0.7, warp: 0.8, symmetry: 4, mirror: 0, final: 0, family: 0, hue: 0, lightness: 0.5, vividness: 0.5, spread: 1, purity: 0.75, ...NO_EFFECTS, facets: 0, lattice: 0 }; /** Drawn effects held within the room the flame leaves under each limit (budget.ts `DRAWN_ROOM`), so a track's own * look never trips the governor, quiet or loud: every amount (the picture's and the loop's) by one factor, the least * any meter needs (made again until it holds, for the reports not quite in step with their amounts: Echo's zoom, * Magnify's power). The owner's own effects are not held to it. */ export function withinRoom>(fx: T): T { const scaled = (f: number): T => ({ ...fx, fx1Amount: fx.fx1Amount * f, fx2Amount: fx.fx2Amount * f, fx3Amount: fx.fx3Amount * f, fxLoopAmount: fx.fxLoopAmount * f }); let f = 1; for (let i = 0; i < 6; i++) { const r = settingsReport(scaled(f), 1); const g = Math.min(1, ...(Object.keys(DRAWN_ROOM) as (keyof typeof DRAWN_ROOM)[]).map((m) => (r[m]! > 0 ? DRAWN_ROOM[m] / r[m]! : 1))); if (g > 1 - 1e-9) break; f *= g; } return scaled(f); } /** A look's effects (effects.ts), drawn from a stream of their own, so a seed's other draws (its form, its colours …) * stay as they were: one look in three one engine or two on the picture, one in five an effect on the loop, at no more * than four fifths of their amount, then held within the room the flame leaves (`withinRoom`). */ export function effectsDrawn(seed: number): Pick { const rnd = randomFrom((seed ^ 0x5eedf00d) >>> 0); // One engine or two (never the same twice), each one of its sub-effects. const place = (n: number) => 1 + Math.floor(rnd() * n); const n = rnd() < 0.67 ? 0 : rnd() < 0.6 ? 1 : 2; const out = { ...NO_EFFECTS }; const first = Math.floor(rnd() * ENGINES.length); const engines = n === 0 ? [] : n === 1 ? [first] : [first, (first + 1 + Math.floor(rnd() * (ENGINES.length - 1))) % ENGINES.length]; for (const k of engines) { const key = (['fx1', 'fx2', 'fx3'] as const)[k]; out[key] = place(ENGINE_EFFECTS[ENGINES[k].key].length); out[`${key}Amount` as 'fx1Amount'] = 0.3 + rnd() * 0.5; } if (rnd() < 0.2) [out.fxLoop, out.fxLoopAmount] = [place(LOOP_EFFECTS.length), 0.2 + rnd() * 0.4]; // Their knobs, drawn last (so the draws above stay as they were): somewhere in their middle half. for (const k of ['fx1K1', 'fx1K2', 'fx2K1', 'fx2K2', 'fx3K1', 'fx3K2', 'fxLoopK1', 'fxLoopK2'] as const) out[k] = 0.25 + rnd() * 0.5; return withinRoom(out); } /** A look's Facets, from a stream of their own too: one look in five, at a third to nine tenths, square or hex alike. */ function facetsDrawn(seed: number): Pick { const rnd = randomFrom((seed ^ 0x0fac37ed) >>> 0); return rnd() < 0.2 ? { facets: 0.3 + rnd() * 0.6, lattice: rnd() < 0.5 ? 1 : 0 } : { facets: 0, lattice: 0 }; } /** The flame of a preset, unframed. */ function build(p: Preset): Flame { const rnd = randomFrom(seedOf(`form ${Math.round(p.form)}`)); const parts = Array.from({ length: PARTS }, () => partOf(Array.from({ length: DRAWS }, rnd), p)); // The Colours' Cover and Fire (65: family 4 and 5) colour over the bands' own hues: Spectrum's, the most apart. huesOf(p.family > 3.5 ? 0 : Math.round(p.family), p.hue, Array.from({ length: 2 + PARTS }, rnd)).forEach((h, i) => { parts[i].color = h.color; parts[i].colorReach = h.reach; }); const f = Array.from({ length: 8 }, rnd); const lens = Math.round(p.final); const order = Math.max(1, Math.round(p.symmetry)); const mirror = Math.round(p.mirror) === 1; const colours = { light: 0.55 + 0.3 * p.lightness, chroma: 0.06 + 0.2 * p.vividness }; return withSymmetry({ xforms: parts, final: lens > 0 ? finalOf(f, lens) : undefined, symmetry: { order, mirror }, colours, center: [0, 0], size: 1 }, order, mirror); } /** The flame of a preset, framed; or not (`framed` false), where whatever draws it frames it again anyway (the * visuals do, as it moves: `measuring`), a sixth of a second on a slow tablet saved each time the look changes. */ export function flameOf(p: Preset, framed = true): Flame { const flame = build(p); const m = framed ? measure(flame) : null; if (m) { flame.center = m.center; flame.size = m.size; } return flame; } /** A track's preset (from its key's seed), or the dice's: drawn until its flame passes the check (`survey`), the best * of 24 draws where none does. `fixed`: settings pinned, kept as they are (the check sees them as they are); `own`: * the draw without them, what a pin let go of returns to. A third without symmetry, two fifths turned round (2 to 8 * arms), the rest mirrored too (1 to 6); a lens on three in ten (it warps the whole picture, at a third more of the GPU's * time a step); the families as often as they came before. */ export function presetOf(seed: number, fixed: Partial = {}): Drawn { const draws = drawing(seed, fixed); for (;;) { const next = draws.next(); if (next.done) return next.value; } } export type Drawn = { preset: Preset; own: Preset }; /** `presetOf` a draw at a time: it yields after each one checked (each a few milliseconds on a slow tablet), so the * draws can be spread over frames or idle time. */ export function* drawing(seed: number, fixed: Partial = {}): Generator { const rnd = randomFrom(seed); const effects = effectsDrawn(seed); const facets = facetsDrawn(seed); let best: { preset: Preset; own: Preset; score: number } | null = null; for (let attempt = 0; attempt < 24; attempt++) { const kind = rnd(); const family = rnd(); const own: Preset = { form: Math.floor(rnd() * FORMS), detail: 0.15 + rnd() * 0.7, twist: 0.3 + rnd() * 0.7, warp: 0.55 + rnd() * 0.45, symmetry: kind < 0.35 ? 1 : kind < 0.75 ? 2 + Math.floor(rnd() * 7) : 1 + Math.floor(rnd() * 6), mirror: kind >= 0.75 ? 1 : 0, final: rnd() < 0.3 ? 1 + Math.floor(rnd() * DRAWN_FINALS) : 0, family: family < 0.4 ? 0 : family < 0.65 ? 1 : family < 0.85 ? 2 : 3, hue: rnd(), lightness: 0.23 + rnd() * 0.54, vividness: 0.3 + rnd() * 0.4, spread: 0.8 + rnd() * 0.5, purity: 0.6 + rnd() * 0.35, ...effects, ...facets, }; const preset = { ...own, ...fixed }; if (attempt > 0) yield; const look = survey(build(preset), seed + attempt); if (!look) continue; if (look.score > 0.5) return { preset, own }; if (!best || look.score > best.score) best = { preset, own, score: look.score }; } return best ?? { preset: { ...STANDBY, ...fixed }, own: STANDBY }; } /** The flame of a seed, as a track's preset makes it. */ export const flameFrom = (seed: number): Flame => flameOf(presetOf(seed).preset); /** The visuals' Colours setting: the preset's own (its family), the playing track's cover's, or a fire's. */ export type ColourMode = 'rainbow' | 'cover' | 'fire'; /** The flame's parts coloured by `mode`; its shape as it is. Cover: the cover's colours (`cover`, OKLab, darkest first: * palette.ts) given to the bands in order, the darkest to the sub and the lightest to the air, their lightness brought * into what shows on black (0.58 to 0.86, in the cover's order); the seed's own where there is no cover. Fire: from a * deep red at the sub through orange to a yellow at the air, darker to lighter (the display adds a blackbody's heat * where the points are dense: render.ts). */ export function coloured(flame: Flame, mode: ColourMode, cover: { L: number; a: number; b: number }[] | null): Flame { if (mode === 'rainbow' || (mode === 'cover' && !cover?.length)) return flame; let n = 0; const lights: number[] = []; const hues: { hue: number; chroma: number }[] = []; for (let i = 0; i < PARTS; i++) { if (mode === 'fire') { const k = i / (PARTS - 1); lights.push(0.56 + 0.3 * k); hues.push({ hue: 0.07 + 0.2 * k, chroma: 0.17 - 0.03 * k }); } else { const c = cover![i % cover!.length]; lights.push(0.58 + 0.28 * Math.min(1, Math.max(0, c.L))); // A third more chroma than the cover's own (up to 0.26): its colours are seen against each other, the flame's // against black and through each other, where they went pale (The Low End Theory's reds and greens, 2026-10-18). hues.push({ hue: (((Math.atan2(c.b, c.a) / (2 * Math.PI)) % 1) + 1) % 1, chroma: Math.min(0.26, 1.35 * Math.hypot(c.a, c.b)) }); } } const chroma = Math.max(0.05, ...hues.map((h) => h.chroma)); return { ...flame, xforms: flame.xforms.map((t) => { if (!t.turns) return t; const h = hues[n++ % PARTS]; return { ...t, color: h.hue, colorReach: h.chroma / chroma }; }), colours: { light: lights.reduce((a, b) => a + b, 0) / PARTS, chroma, lights }, }; } // --- The CPU's look at a flame: where its points fall, and whether it is worth showing --- /** Runs the chaos game on the CPU and measures it: its frame (centre, size) and a score, 0..1 (null: it escapes). */ function survey(flame: Flame, seed: number): { center: [number, number]; size: number; score: number } | null { const rnd = randomFrom(seed ^ 0x9e3779b9); const pick = picker(flame.xforms); const xs: number[] = []; const ys: number[] = []; let x = rnd() * 2 - 1; let y = rnd() * 2 - 1; let lost = 0; for (let i = 0; i < 12000; i++) { const p = apply(flame.xforms[pick(rnd())], x, y, rnd); if (!Number.isFinite(p[0]) || !Number.isFinite(p[1]) || Math.abs(p[0]) > 1e4 || Math.abs(p[1]) > 1e4) { lost++; x = rnd() * 2 - 1; y = rnd() * 2 - 1; continue; } [x, y] = p; if (i >= 20) { const shown = flame.final ? apply(flame.final, x, y, rnd) : p; if (Number.isFinite(shown[0]) && Number.isFinite(shown[1]) && Math.abs(shown[0]) < 1e4 && Math.abs(shown[1]) < 1e4) { xs.push(shown[0]); ys.push(shown[1]); } } } if (lost > 600 || xs.length < 5000) return null; const sorted = (v: number[]) => [...v].sort((a, b) => a - b); const sx = sorted(xs); const sy = sorted(ys); const q = (s: number[], f: number) => s[Math.floor(f * (s.length - 1))]; const x0 = q(sx, 0.02); const x1 = q(sx, 0.98); const y0 = q(sy, 0.02); const y1 = q(sy, 0.98); const size = Math.max(x1 - x0, y1 - y0) / 2; if (!(size > 1e-3)) return null; // Coverage of a 40 × 40 grid over the frame: a point or a thin line covers almost nothing, noise covers it all. const cells = new Uint32Array(40 * 40); const cx = (x0 + x1) / 2; const cy = (y0 + y1) / 2; for (let i = 0; i < xs.length; i++) { const gx = Math.floor(((xs[i] - cx) / size / 2 + 0.5) * 40); const gy = Math.floor(((ys[i] - cy) / size / 2 + 0.5) * 40); if (gx >= 0 && gx < 40 && gy >= 0 && gy < 40) cells[gy * 40 + gx]++; } let hit = 0; for (const c of cells) if (c > 0) hit++; const coverage = hit / cells.length; // Structure: how unevenly the points spread over the cells they reach (a flat fill has none). const mean = xs.length / Math.max(1, hit); let spread = 0; for (const c of cells) if (c > 0) spread += Math.abs(c - mean); const unevenness = spread / xs.length; // Haze: the share of the cells reached that hold only a trickle of points, an even scatter over the background // that shows as grain however long it is drawn. let thin = 0; for (const c of cells) if (c > 0 && c < mean * 0.2) thin++; const haze = thin / Math.max(1, hit); // Electric Sheep's flames leave dark around them: a flame filling more than 70 % of its frame scores less (and one // under 4 %, a point or a thread). const fits = coverage < 0.04 ? coverage / 0.04 : coverage > 0.7 ? Math.max(0, (0.95 - coverage) / 0.25) : 1; const structured = Math.min(1, unevenness / 0.5); // Haze stays as strict as it was: it is grain however long the flame is drawn. const clear = haze > 0.35 ? Math.max(0, (0.6 - haze) / 0.25) : 1; return { center: [cx, cy], size: size * 1.1, score: fits * structured * clear }; } /** Where a flame's points fall now (as it is drawn: turned, evolved), on the CPU: the middle and half-size of the * frame that holds 96 % of them, and how much of that frame's 32 x 32 cells they reach. The visuals measure their flame * so twice a second and follow it slowly with their framing and exposure: a flame whose transforms turn passes through * shapes three times as large as its own and back (measured: 0.63 to 3.1, 2026-10-18), which showed as flares the * music had no part in. */ export function measure(flame: Flame, steps = 6000): Seen | null { const m = measuring(flame, steps, steps); for (;;) { const r = m.next(); if (r.done) return r.value; } } export type Seen = { center: [number, number]; size: number; coverage: number }; /** `measure` a slice at a time: `slice` steps each time it is moved on, its value the measure once all `steps` are * taken. All at once it was 28 ms of a tablet's processor (Helio G88, 2026-10-18), which made a frame late twice a * second; the visuals take a slice a frame. */ export function* measuring(flame: Flame, steps = 6000, slice = 1500): Generator { const rnd = randomFrom(0x51ced); const pick = picker(flame.xforms); const xs: number[] = []; const ys: number[] = []; let x = rnd() * 2 - 1; let y = rnd() * 2 - 1; for (let i = 0; i < steps; i++) { if (i > 0 && i % slice === 0) yield; const p = apply(flame.xforms[pick(rnd())], x, y, rnd); if (!Number.isFinite(p[0]) || !Number.isFinite(p[1]) || Math.abs(p[0]) > 1e4 || Math.abs(p[1]) > 1e4) { x = rnd() * 2 - 1; y = rnd() * 2 - 1; continue; } [x, y] = p; if (i >= 20) { const shown = flame.final ? apply(flame.final, x, y, rnd) : p; if (Number.isFinite(shown[0]) && Number.isFinite(shown[1]) && Math.abs(shown[0]) < 1e4 && Math.abs(shown[1]) < 1e4) { xs.push(shown[0]); ys.push(shown[1]); } } } if (xs.length < steps / 4) return null; const sx = [...xs].sort((a, b) => a - b); const sy = [...ys].sort((a, b) => a - b); const q = (s: number[], f: number) => s[Math.floor(f * (s.length - 1))]; const center: [number, number] = [(q(sx, 0.02) + q(sx, 0.98)) / 2, (q(sy, 0.02) + q(sy, 0.98)) / 2]; const size = (Math.max(q(sx, 0.98) - q(sx, 0.02), q(sy, 0.98) - q(sy, 0.02)) / 2) * 1.1; if (!(size > 1e-4)) return null; const cells = new Uint8Array(32 * 32); for (let i = 0; i < xs.length; i++) { const gx = Math.floor(((xs[i] - center[0]) / size / 2 + 0.5) * 32); const gy = Math.floor(((ys[i] - center[1]) / size / 2 + 0.5) * 32); if (gx >= 0 && gx < 32 && gy >= 0 && gy < 32) cells[gy * 32 + gx] = 1; } return { center, size, coverage: cells.reduce((s, c) => s + c, 0) / cells.length }; } /** Where each band's part lies as drawn (its final transform taken, before the symmetry's copies carry it round): the * middle of the points its transform takes, on the CPU over `steps` of the chaos game; null for a part with none. * The fluid is pushed there (decision 0047). */ export function partsAt(flame: Flame, steps = 1200): ([number, number] | null)[] { const rnd = randomFrom(0x9a27); const pick = picker(flame.xforms); const sums = Array.from({ length: PARTS }, () => [0, 0, 0]); let x = rnd() * 2 - 1; let y = rnd() * 2 - 1; for (let i = 0; i < steps; i++) { const k = pick(rnd()); const p = apply(flame.xforms[k], x, y, rnd); if (!Number.isFinite(p[0]) || !Number.isFinite(p[1]) || Math.abs(p[0]) > 1e4 || Math.abs(p[1]) > 1e4) { x = rnd() * 2 - 1; y = rnd() * 2 - 1; continue; } [x, y] = p; if (i < 20 || k >= PARTS || !flame.xforms[k].turns) continue; const shown = flame.final ? apply(flame.final, x, y, rnd) : p; if (!Number.isFinite(shown[0]) || !Number.isFinite(shown[1]) || Math.abs(shown[0]) > 1e4 || Math.abs(shown[1]) > 1e4) continue; sums[k][0] += shown[0]; sums[k][1] += shown[1]; sums[k][2]++; } return sums.map(([sx, sy, n]) => (n > 0 ? [sx / n, sy / n] : null)); } /** Chooses a transform by weight, given a number in 0..1. */ export function picker(xforms: Xform[]): (r: number) => number { const total = xforms.reduce((s, x) => s + x.weight, 0); const sums: number[] = []; let run = 0; for (const x of xforms) sums.push((run += x.weight / total)); return (r) => { for (let i = 0; i < sums.length; i++) if (r < sums[i]) return i; return sums.length - 1; }; } /** One transform on one point, on the CPU (the shader in render.ts does the same). */ export function apply(t: Xform, px: number, py: number, rnd: () => number): [number, number] { const [a, b, c, d, e, f] = t.affine; const x = a * px + b * py + c; const y = d * px + e * py + f; let ox = 0; let oy = 0; const r2 = x * x + y * y + 1e-12; const r = Math.sqrt(r2); const theta = Math.atan2(x, y); for (const [v, w] of t.variations) { let vx = x; let vy = y; switch (v) { case VARIATIONS.sinusoidal: vx = Math.sin(x); vy = Math.sin(y); break; case VARIATIONS.spherical: vx = x / r2; vy = y / r2; break; case VARIATIONS.swirl: vx = x * Math.sin(r2) - y * Math.cos(r2); vy = x * Math.cos(r2) + y * Math.sin(r2); break; case VARIATIONS.horseshoe: vx = ((x - y) * (x + y)) / r; vy = (2 * x * y) / r; break; case VARIATIONS.polar: vx = theta / Math.PI; vy = r - 1; break; case VARIATIONS.handkerchief: vx = r * Math.sin(theta + r); vy = r * Math.cos(theta - r); break; case VARIATIONS.heart: vx = r * Math.sin(theta * r); vy = -r * Math.cos(theta * r); break; case VARIATIONS.cratarr: [vx, vy] = markOf(x, y, r); break; case VARIATIONS.disc: vx = (theta / Math.PI) * Math.sin(Math.PI * r); vy = (theta / Math.PI) * Math.cos(Math.PI * r); break; case VARIATIONS.spiral: vx = (Math.cos(theta) + Math.sin(r)) / r; vy = (Math.sin(theta) - Math.cos(r)) / r; break; case VARIATIONS.hyperbolic: vx = Math.sin(theta) / r; vy = r * Math.cos(theta); break; case VARIATIONS.diamond: vx = Math.sin(theta) * Math.cos(r); vy = Math.cos(theta) * Math.sin(r); break; case VARIATIONS.ex: { const p0 = Math.sin(theta + r) ** 3; const p1 = Math.cos(theta - r) ** 3; vx = r * (p0 + p1); vy = r * (p0 - p1); break; } case VARIATIONS.julia: { const om = rnd() < 0.5 ? 0 : Math.PI; const sr = Math.sqrt(r); vx = sr * Math.cos(theta / 2 + om); vy = sr * Math.sin(theta / 2 + om); break; } case VARIATIONS.bent: vx = x < 0 ? 2 * x : x; vy = y < 0 ? y / 2 : y; break; case VARIATIONS.fisheye: vx = (2 / (r + 1)) * y; vy = (2 / (r + 1)) * x; break; case VARIATIONS.exponential: { const k = Math.exp(x - 1); vx = k * Math.cos(Math.PI * y); vy = k * Math.sin(Math.PI * y); break; } case VARIATIONS.power: { const k = r ** Math.sin(theta); vx = k * Math.cos(theta); vy = k * Math.sin(theta); break; } case VARIATIONS.cosine: vx = Math.cos(Math.PI * x) * Math.cosh(y); vy = -Math.sin(Math.PI * x) * Math.sinh(y); break; case VARIATIONS.eyefish: vx = (2 / (r + 1)) * x; vy = (2 / (r + 1)) * y; break; case VARIATIONS.bubble: vx = (4 / (r2 + 4)) * x; vy = (4 / (r2 + 4)) * y; break; case VARIATIONS.cylinder: vx = Math.sin(x); vy = y; break; case VARIATIONS.tangent: vx = Math.sin(x) / Math.cos(y); vy = Math.tan(y); break; case VARIATIONS.cross: { const k = 1 / Math.abs(x * x - y * y + 1e-12); vx = k * x; vy = k * y; break; } case VARIATIONS.waves: vx = x + b * Math.sin(y / (c * c + 1e-6)); vy = y + e * Math.sin(x / (f * f + 1e-6)); break; case VARIATIONS.popcorn: vx = x + c * Math.sin(Math.tan(3 * y)); vy = y + f * Math.sin(Math.tan(3 * x)); break; case VARIATIONS.rings: { const k = c * c + 1e-6; const m = ((((r + k) % (2 * k)) + 2 * k) % (2 * k)) - k + r * (1 - k); vx = m * Math.cos(theta); vy = m * Math.sin(theta); break; } case VARIATIONS.fan: { const k = Math.PI * (c * c + 1e-6); const a2 = ((((theta + f) % k) + k) % k) > k / 2 ? theta - k / 2 : theta + k / 2; vx = r * Math.cos(a2); vy = r * Math.sin(a2); break; } case VARIATIONS.noise: { const p1 = rnd(); const p2 = 2 * Math.PI * rnd(); vx = p1 * x * Math.cos(p2); vy = p1 * y * Math.sin(p2); break; } case VARIATIONS.blur: { const p1 = rnd(); const p2 = 2 * Math.PI * rnd(); vx = p1 * Math.cos(p2); vy = p1 * Math.sin(p2); break; } case VARIATIONS.arch: { const a2 = rnd() * Math.PI; vx = Math.sin(a2); vy = (Math.sin(a2) * Math.sin(a2)) / Math.cos(a2); break; } case VARIATIONS.square: vx = rnd() - 0.5; vy = rnd() - 0.5; break; case VARIATIONS.rays: { const k = Math.tan(rnd() * Math.PI) / (r2 + 1e-6); vx = k * Math.cos(x); vy = k * Math.sin(y); break; } case VARIATIONS.blade: { const a2 = rnd() * r; vx = x * (Math.cos(a2) + Math.sin(a2)); vy = x * (Math.cos(a2) - Math.sin(a2)); break; } case VARIATIONS.secant2: { const cr = Math.cos(r); vx = x; vy = 1 / cr + (cr < 0 ? 1 : -1); break; } case VARIATIONS.twintrian: { const a2 = rnd() * r; const s = Math.sin(a2); let k = Math.log10(s * s + 1e-12) + Math.cos(a2); if (!Number.isFinite(k)) k = -30; vx = x * k; vy = x * (k - s * Math.PI); break; } } ox += w * vx; oy += w * vy; } if (t.post) { const [pa, pb, pc, pd, pe, pf] = t.post; return [pa * ox + pb * oy + pc, pd * ox + pe * oy + pf]; } return [ox, oy]; } // --- Animation and blending --- /** The variation each band's part evolves toward (decision 0046, amended; a flame's first six transforms are the * bands'): round and heavy for the sub (bubble), a bulge for the bass (eyefish), a twist for the low mids (swirl), a * bend for the mids (horseshoe), a wave across for the presence (cylinder), a fine ripple for the air (sinusoidal). Six until * 2026-10-18: the three bands' three left the upper three parts evolving toward none (linear). */ const BAND_VARIATIONS: Variation[] = [ VARIATIONS.bubble, VARIATIONS.eyefish, VARIATIONS.swirl, VARIATIONS.horseshoe, VARIATIONS.cylinder, VARIATIONS.sinusoidal, ]; /** How a band's part has evolved: turned by `turn` (radians, as Electric Sheep's loops turn the linear part), that * part scaled by `size`, `morph` (0..1) of its variations given over to its band's own, its hue moved by `hue` (a * turn of the wheel), and its offset from the middle scaled by `spread` (1: as it is). */ /** `weight`: its share of the points against its own (Presence, 65: 1 as drawn). */ export type Part = { turn: number; size: number; morph: number; hue: number; spread?: number; weight?: number }; /** The most a transform's linear part may stretch anything (its larger singular value): under 1, every transform pulls * points together, so turning and growing never carry the flame through a shape that flies apart over the screen. */ const STRETCH = 0.95; /** The flame with each band's transform evolved by its part (the symmetry's rotations stay as they are). `purity` (0..1): * how far each band's transform takes a point's colour all the way to its own (1: every point its last transform's * colour, so each band's part is its colour alone). `spread`: the parts' offsets from the middle scaled (1: the seed's * own), drawing them together into one shape (under 1) or apart into clusters (over 1). */ export function evolved(flame: Flame, parts: Part[], purity = 0, spread = 1): Flame { return { ...flame, xforms: flame.xforms.map((t, i) => { const p = parts[i]; if (!t.turns || !p) return t; const cos = Math.cos(p.turn) * p.size; const sin = Math.sin(p.turn) * p.size; const [a, b, c, d, e, f] = t.affine; let variations = t.variations; if (p.morph > 0) { variations = new Map([...t.variations].map(([v, w]) => [v, w * (1 - p.morph)])); const own = BAND_VARIATIONS[i]; variations.set(own, (variations.get(own) ?? 0) + p.morph); } const linear = contracted(cos * a - sin * d, cos * b - sin * e, sin * a + cos * d, sin * b + cos * e); // Spread: the parts' offsets scaled apart (separate clusters) or together (one shape). const out = spread * (p.spread ?? 1); return { ...t, affine: [linear[0], linear[1], c * out, linear[2], linear[3], f * out], variations, color: (((t.color + p.hue) % 1) + 1) % 1, colorSpeed: t.colorSpeed + purity * (1 - t.colorSpeed), weight: t.weight * (p.weight ?? 1), }; }), }; } /** A flame between two, `k` of the way from `from` to `to` (0..1). The transforms are paired in order (a missing one * weighs nothing); each affine column is blended by angle and by the log of its length, which keeps a transform from * folding flat on the way (flam3's "log" interpolation does this). */ export function between(from: Flame, to: Flame, k: number): Flame { const n = Math.max(from.xforms.length, to.xforms.length); const none = (t: Xform): Xform => ({ ...t, weight: 0 }); const xforms: Xform[] = []; for (let i = 0; i < n; i++) { const p = from.xforms[i] ?? none(to.xforms[i]); const q = to.xforms[i] ?? none(from.xforms[i]); const variations = new Map(); for (const v of new Set([...p.variations.keys(), ...q.variations.keys()])) { variations.set(v, mix(p.variations.get(v) ?? 0, q.variations.get(v) ?? 0, k)); } xforms.push({ affine: blendAffine(p.affine, q.affine, k), variations, weight: mix(p.weight, q.weight, k), color: mixTurn(p.color, q.color, k), colorSpeed: mix(p.colorSpeed, q.colorSpeed, k), colorReach: mix(p.colorReach ?? 1, q.colorReach ?? 1, k), post: p.post || q.post ? blendAffine(p.post ?? IDENTITY, q.post ?? IDENTITY, k) : undefined, turns: k < 0.5 ? p.turns : q.turns, }); } // The final transforms blend as the others do, the plain picture standing in for a missing one. const plain: Xform = { affine: IDENTITY, variations: new Map([[VARIATIONS.linear, 1]]), weight: 0, color: 0, colorSpeed: 0, turns: false }; const final = from.final || to.final ? (() => { const p = from.final ?? plain; const q = to.final ?? plain; const variations = new Map(); for (const v of new Set([...p.variations.keys(), ...q.variations.keys()])) variations.set(v, mix(p.variations.get(v) ?? 0, q.variations.get(v) ?? 0, k)); return { ...plain, affine: blendAffine(p.affine, q.affine, k), variations }; })() : undefined; return { xforms, final, symmetry: k < 0.5 ? from.symmetry : to.symmetry, colours: { light: mix(from.colours.light, to.colours.light, k), chroma: mix(from.colours.chroma, to.colours.chroma, k), lights: from.colours.lights || to.colours.lights ? Array.from({ length: PARTS }, (_, i) => mix(from.colours.lights?.[i] ?? from.colours.light, to.colours.lights?.[i] ?? to.colours.light, k)) : undefined, }, center: [mix(from.center[0], to.center[0], k), mix(from.center[1], to.center[1], k)], size: Math.exp(mix(Math.log(from.size), Math.log(to.size), k)), }; } const mix = (a: number, b: number, k: number) => a + (b - a) * k; const IDENTITY: Affine = [1, 0, 0, 0, 1, 0]; /** Between two turns of the wheel, the short way round. */ const mixTurn = (a: number, b: number, k: number) => { const d = b - a - Math.round(b - a); return (((a + d * k) % 1) + 1) % 1; }; /** A 2 x 2 linear part (a, b; d, e), scaled down where it stretches anything more than `STRETCH`. */ function contracted(a: number, b: number, d: number, e: number): [number, number, number, number] { // The larger singular value, from the sums and differences of the matrix's entries. const s = Math.hypot(a + e, d - b) / 2 + Math.hypot(a - e, d + b) / 2; const k = s > STRETCH ? STRETCH / s : 1; return [a * k, b * k, d * k, e * k]; } function blendAffine(p: Xform['affine'], q: Xform['affine'], k: number): Xform['affine'] { // Columns (a, d) and (b, e) in polar form; the offset (c, f) straight. const col = (x0: number, y0: number, x1: number, y1: number): [number, number] => { const a0 = Math.atan2(y0, x0); let a1 = Math.atan2(y1, x1); if (a1 - a0 > Math.PI) a1 -= 2 * Math.PI; if (a0 - a1 > Math.PI) a1 += 2 * Math.PI; const l = Math.exp(mix(Math.log(Math.hypot(x0, y0) + 1e-9), Math.log(Math.hypot(x1, y1) + 1e-9), k)); const a = mix(a0, a1, k); return [l * Math.cos(a), l * Math.sin(a)]; }; const [a, d] = col(p[0], p[3], q[0], q[3]); const [b, e] = col(p[1], p[4], q[1], q[4]); return [a, b, mix(p[2], q[2], k), d, e, mix(p[5], q[5], k)]; } /** A colour (sRGB, 0..1 each) from OKLCH: its lightness, chroma and hue (a turn of the wheel). The shader in render.ts * does the same. */ export function colourOf(light: number, chroma: number, hue: number): [number, number, number] { const a = chroma * Math.cos(2 * Math.PI * hue); const b = chroma * Math.sin(2 * Math.PI * hue); const l = (light + 0.3963377774 * a + 0.2158037573 * b) ** 3; const m = (light - 0.1055613458 * a - 0.0638541728 * b) ** 3; const s = (light - 0.0894841775 * a - 1.291485548 * b) ** 3; const linear = [ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, ]; return linear.map((v) => Math.min(1, Math.max(0, v)) ** (1 / 2.2)) as [number, number, number]; } ===== src/lib/visuals/flashmeter.ts ===== // The picture as shown, read back by tiles for the flash meters (meters.ts): after each frame the screen is copied // small (a blit: the screen cannot be sampled), each tile's mean colour worked out in linear light (relative luminance, // CIE 1976 u'v', share of red), and read back a frame or two later without waiting for the GPU (a pixel buffer and a // fence), so measuring never stalls a frame. // // Written to the Mali rules (decision 0047): no array constructors, no bits picking an answer. Each `capture` changes // the framebuffers bound (read and draw), the viewport, the program, the vertex array and texture unit 0; the pixel // pack buffer is left unbound. The renderer sets its own again after. import type { Tiles } from './meters'; /** Each tile is sampled 16 x 16 times, each sample (bilinear) the mean of four pixels of the small copy. */ const SAMPLES = 16; /** Readings in flight at once (a browser may hold one back ten frames); with all of them pending, a frame goes * unmeasured rather than waiting. */ const RING = 8; const VERTEX = `#version 300 es void main() { vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); }`; // One fragment a tile: its mean linear colour, then its luminance, chromaticity and red share. 8 bits each is plenty // for steps of 0.1 and shifts of 0.2. const TILES = `#version 300 es precision highp float; uniform sampler2D uSmall; uniform vec2 uTiles; out vec4 o; vec3 linear(vec3 c) { return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c)); } void main() { vec2 tile = floor(gl_FragCoord.xy); vec3 sum = vec3(0.0); for (int j = 0; j < ${SAMPLES}; j++) { for (int i = 0; i < ${SAMPLES}; i++) { vec2 uv = (tile + (vec2(float(i), float(j)) + 0.5) / ${SAMPLES}.0) / uTiles; sum += linear(texture(uSmall, uv).rgb); } } vec3 c = sum / ${SAMPLES * SAMPLES}.0; float l = dot(c, vec3(0.2126, 0.7152, 0.0722)); float x = dot(c, vec3(0.4124, 0.3576, 0.1805)); float z = dot(c, vec3(0.0193, 0.1192, 0.9505)); float d = x + 15.0 * l + 3.0 * z; vec2 uv = d > 0.000001 ? vec2(4.0 * x, 9.0 * l) / d : vec2(0.1978, 0.4683); float s = c.r + c.g + c.b; o = vec4(l, uv / 0.65, s > 0.000001 ? c.r / s : 0.0); }`; type Pending = { buffer: WebGLBuffer; sync: WebGLSync | null; t: number; cols: number; rows: number }; export class FlashMeter { private gl: WebGL2RenderingContext; private program: WebGLProgram; private empty: WebGLVertexArrayObject; private small: { texture: WebGLTexture; fbo: WebGLFramebuffer; w: number; h: number } | null = null; private tiles: { texture: WebGLTexture; fbo: WebGLFramebuffer } | null = null; private ring: Pending[] = []; private uTiles: WebGLUniformLocation | null; private uSmall: WebGLUniformLocation | null; readonly cols: number; rows = 0; /** `cols` tiles across; as many down as keep them square. */ constructor(gl: WebGL2RenderingContext, cols = 16) { this.gl = gl; this.cols = cols; this.program = program(gl, VERTEX, TILES); this.uTiles = gl.getUniformLocation(this.program, 'uTiles'); this.uSmall = gl.getUniformLocation(this.program, 'uSmall'); this.empty = gl.createVertexArray()!; this.ring = Array.from({ length: RING }, () => { const buffer = gl.createBuffer()!; return { buffer, sync: null, t: 0, cols: 0, rows: 0 }; }); } /** The screen (`viewW` x `viewH`, just drawn) measured as of `t` seconds; read with `poll` a frame or two later. */ capture(viewW: number, viewH: number, t = performance.now() / 1000) { const gl = this.gl; const slot = this.ring.find((p) => !p.sync); if (!slot || viewW < 1 || viewH < 1) return; const rows = Math.max(2, Math.min(32, Math.round((this.cols * viewH) / viewW))); this.ensure(rows); const small = this.small!; gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null); gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, small.fbo); gl.blitFramebuffer(0, 0, viewW, viewH, 0, 0, small.w, small.h, gl.COLOR_BUFFER_BIT, gl.LINEAR); gl.bindFramebuffer(gl.FRAMEBUFFER, this.tiles!.fbo); gl.viewport(0, 0, this.cols, rows); gl.disable(gl.BLEND); gl.useProgram(this.program); gl.bindVertexArray(this.empty); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, small.texture); gl.uniform1i(this.uSmall, 0); gl.uniform2f(this.uTiles, this.cols, rows); gl.drawArrays(gl.TRIANGLES, 0, 3); gl.bindBuffer(gl.PIXEL_PACK_BUFFER, slot.buffer); gl.bufferData(gl.PIXEL_PACK_BUFFER, this.cols * rows * 4, gl.STREAM_READ); gl.readPixels(0, 0, this.cols, rows, gl.RGBA, gl.UNSIGNED_BYTE, 0); gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); slot.sync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0); gl.flush(); Object.assign(slot, { t, cols: this.cols, rows }); } /** The readings the GPU has finished, the oldest first (none: an empty list). */ poll(): Tiles[] { const gl = this.gl; const ready = this.ring .filter((p) => p.sync && gl.getSyncParameter(p.sync, gl.SYNC_STATUS) === gl.SIGNALED) .sort((a, b) => a.t - b.t); return ready.map((p) => { const n = p.cols * p.rows; const bytes = new Uint8Array(n * 4); gl.bindBuffer(gl.PIXEL_PACK_BUFFER, p.buffer); gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, bytes); gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); gl.deleteSync(p.sync); p.sync = null; const tiles: Tiles = { t: p.t, cols: p.cols, rows: p.rows, luminance: new Float32Array(n), u: new Float32Array(n), v: new Float32Array(n), red: new Float32Array(n), }; for (let i = 0; i < n; i++) { tiles.luminance[i] = bytes[i * 4] / 255; tiles.u[i] = (bytes[i * 4 + 1] / 255) * 0.65; tiles.v[i] = (bytes[i * 4 + 2] / 255) * 0.65; tiles.red[i] = bytes[i * 4 + 3] / 255; } return tiles; }); } dispose() { const gl = this.gl; for (const p of this.ring) { if (p.sync) gl.deleteSync(p.sync); gl.deleteBuffer(p.buffer); } this.freeTargets(); gl.deleteProgram(this.program); gl.deleteVertexArray(this.empty); } private ensure(rows: number) { if (rows === this.rows && this.small) return; this.freeTargets(); this.rows = rows; const w = this.cols * SAMPLES * 2; const h = rows * SAMPLES * 2; this.small = { ...this.target(w, h), w, h }; this.tiles = this.target(this.cols, rows); } private target(w: number, h: number): { texture: WebGLTexture; fbo: WebGLFramebuffer } { const gl = this.gl; const texture = gl.createTexture()!; gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); const fbo = gl.createFramebuffer()!; gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); return { texture, fbo }; } private freeTargets() { const gl = this.gl; for (const t of [this.small, this.tiles]) { if (!t) continue; gl.deleteFramebuffer(t.fbo); gl.deleteTexture(t.texture); } this.small = this.tiles = null; } } function program(gl: WebGL2RenderingContext, vs: string, fs: string): WebGLProgram { const shader = (type: number, src: string) => { const s = gl.createShader(type)!; gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) ?? 'The flash meter’s shader did not compile.'); return s; }; const p = gl.createProgram()!; const [v, f] = [shader(gl.VERTEX_SHADER, vs), shader(gl.FRAGMENT_SHADER, fs)]; gl.attachShader(p, v); gl.attachShader(p, f); gl.linkProgram(p); gl.deleteShader(v); gl.deleteShader(f); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p) ?? 'The flash meter’s program did not link.'); return p; } ===== src/lib/visuals/fluid.ts ===== // A fluid the visuals' light can flow in (decision 0047): the flame's trails, instead of only fading where they were // drawn, are carried along by a flow the music stirs, so they drift, curl and fold like smoke. The owner: "another way // of having such dancing movement is through fluid dynamics", and in the next version "only if we can keep it // development properly separate": this file is the fluid alone; the renderer calls it through the few methods below // (decision 0047's seam) and the visuals turn the music into its forces. // // Stam's stable fluids on the GPU ("Real-Time Fluid Dynamics for Games", 2003; as in GPU Gems 1, ch. 38): a velocity // field on a coarse grid of its own (a hundred cells or so across, whatever the screen), each step pushed by the // forces, given back its small whirls (vorticity confinement, Fedkiw et al. 2001), made free of divergence (pressure by // Jacobi iteration, then its gradient taken away) and carried along by itself; the light then carried along by it at // the buffer's own size. In as few passes as that takes (eight a step): on a Mali tablet a pass cost more than its sums // (seventeen were 5 ms a frame on a G52, the grid's size hardly counting; 2026-10-18). Velocities are in grid cells per second (`uvPerCell` turns them into texture coordinates). // // Written for the Mali GPUs Cratarr's devices have (2026-10-18): no array constructors in a shader; a value's bits only // say whether it is a number, never what to answer with; no `isnan`; 16-bit float textures throughout (a G52 cannot // filter 32-bit ones), drawn into with EXT_color_buffer_float. // // Each call changes the framebuffer bound, the program, the vertex array, the viewport, blending (off) and the // textures bound to the first units: the renderer sets its own again after. `advect` uses the last `step`'s time, so // a frame is `push`, `step`, then `advect`. /** A push on the fluid: where (texture coordinates of the buffer, 0..1 from its bottom left), which way and how fast * (texture coordinates per second, at strength 1), how far it reaches (in heights of the buffer) and how hard. */ export type Force = { x: number; y: number; dx: number; dy: number; radius: number; strength: number }; /** How the fluid moves now: `energy` 0..1 (how long its motion lasts: a calm passage settles within a second, a loud * one flows on), `swirl` 0.. (how strongly its small whirls are kept). */ /** `wave`: the slow giant waves (65): how high (the Waves column's routes), which way they roll (radians) and where in * their cycle (radians). */ export type Flow = { energy: number; swirl: number; wave?: Wave }; export type Wave = { amount: number; angle: number; phase: number }; /** The waves: wavelengths across the screen's height (2π each), how hard they push at their fullest (screen heights a * second, each second), and how far the picture is bent by them where nothing flows (of the screen's height). */ export const WAVE_K = 2 * Math.PI * 0.8; export const WAVE_PUSH = 0.35; export const WAVE_BEND = 0.025; /** The light the renderer keeps: its colour and density, and its two band-share buffers. */ export type Light = { acc: WebGLTexture; bandsA: WebGLTexture; bandsB: WebGLTexture }; /** The most forces taken a step; more are dropped (the strongest kept). */ export const MOST_FORCES = 16; /** Jacobi iterations a step: enough for smoke that looks right, few enough for a tablet (each is a pass). */ const ITERATIONS = 6; /** The fastest the fluid moves (cells a second): beyond it, a bad step could blow up. */ const FASTEST = 400; /** What is left of the last step's pressure (a warm start for the iteration). */ const PRESSURE_KEPT = 0.8; /** Vorticity confinement at swirl 1 (Dobryakov's WebGL fluid uses 30 at this scale). */ const SWIRL = 30; const VERTEX = `#version 300 es precision highp float; out vec2 vUv; // One triangle over the whole target: no buffers, only the vertex's number. void main() { vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); vUv = p; gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); }`; // Whether a value is not a number or infinite, from its bits (which no compiler's fast arithmetic assumes away). const BAD = `bool bad(float x) { return (floatBitsToUint(x) & 0x7f800000u) == 0x7f800000u; }`; const HEAD = `#version 300 es precision highp float; precision highp sampler2D; in vec2 vUv; ${BAD} `; // The forces, and the small whirls given back (vorticity confinement): one pass, the curl worked out where it is // needed (a pass of its own costs a Mali GPU more than the sums: each one is a flush of its tiles). const FORCE = `${HEAD} uniform sampler2D uVelocity; uniform vec2 uTexel; uniform float uAspect; uniform vec2 uGrid; uniform int uCount; uniform vec4 uAt[${MOST_FORCES}]; uniform vec2 uWay[${MOST_FORCES}]; uniform float uSwirl; uniform float uDt; uniform vec4 uWave; uniform float uWaveK; out vec4 o; float curlAt(vec2 uv) { float l = texture(uVelocity, uv - vec2(uTexel.x, 0.0)).y; float r = texture(uVelocity, uv + vec2(uTexel.x, 0.0)).y; float b = texture(uVelocity, uv - vec2(0.0, uTexel.y)).x; float t = texture(uVelocity, uv + vec2(0.0, uTexel.y)).x; return 0.5 * (r - l - t + b); } void main() { vec2 v = texture(uVelocity, vUv).xy; float l = curlAt(vUv - vec2(uTexel.x, 0.0)); float r = curlAt(vUv + vec2(uTexel.x, 0.0)); float b = curlAt(vUv - vec2(0.0, uTexel.y)); float t = curlAt(vUv + vec2(0.0, uTexel.y)); float c = curlAt(vUv); vec2 f = 0.5 * vec2(abs(t) - abs(b), abs(r) - abs(l)); f /= length(f) + 0.0001; v += f * uSwirl * c * vec2(1.0, -1.0) * uDt; // The waves: a push across their way, as their crest passes (sideways to the way it rolls, so it neither presses // the flow together nor pulls it apart: the pressure leaves it). if (uWave.x != 0.0) { vec2 n = uWave.yz; vec2 p = (vUv - 0.5) * vec2(uAspect, 1.0); v += vec2(-n.y / uAspect, n.x) * uWave.x * sin(uWaveK * dot(p, n) - uWave.w) * uGrid.y * uDt; } for (int i = 0; i < ${MOST_FORCES}; i++) { if (i >= uCount) break; vec2 d = vUv - uAt[i].xy; d.x *= uAspect; float radius = max(uAt[i].z, 0.001); v += uWay[i] * uGrid * uAt[i].w * exp(-dot(d, d) / (radius * radius)); } o = bad(v.x + v.y) ? vec4(0.0) : vec4(clamp(v, -${FASTEST}.0, ${FASTEST}.0), 0.0, 1.0); }`; // One Jacobi iteration of the pressure, the velocity's divergence worked out in it (the edges are walls: what flows // into one comes back); `uWarm` scales the pressure it starts from (the last step's, a little of it kept). const JACOBI = `${HEAD} uniform sampler2D uPressure; uniform sampler2D uVelocity; uniform vec2 uTexel; uniform float uWarm; out vec4 o; void main() { vec2 c = texture(uVelocity, vUv).xy; float vl = texture(uVelocity, vUv - vec2(uTexel.x, 0.0)).x; float vr = texture(uVelocity, vUv + vec2(uTexel.x, 0.0)).x; float vb = texture(uVelocity, vUv - vec2(0.0, uTexel.y)).y; float vt = texture(uVelocity, vUv + vec2(0.0, uTexel.y)).y; if (vUv.x - uTexel.x < 0.0) vl = -c.x; if (vUv.x + uTexel.x > 1.0) vr = -c.x; if (vUv.y - uTexel.y < 0.0) vb = -c.y; if (vUv.y + uTexel.y > 1.0) vt = -c.y; float d = 0.5 * (vr - vl + vt - vb); float l = texture(uPressure, vUv - vec2(uTexel.x, 0.0)).x; float r = texture(uPressure, vUv + vec2(uTexel.x, 0.0)).x; float b = texture(uPressure, vUv - vec2(0.0, uTexel.y)).x; float t = texture(uPressure, vUv + vec2(0.0, uTexel.y)).x; o = vec4(((l + r + b + t) * uWarm - d) * 0.25, 0.0, 0.0, 1.0); }`; // The pressure's gradient taken away (the flow free of divergence), and the result carried along by itself, a little // of it lost: one pass, the free velocity worked out where it is sampled. const CARRY = `${HEAD} uniform sampler2D uVelocity; uniform sampler2D uPressure; uniform vec2 uTexel; uniform float uDt; uniform float uKeep; out vec4 o; vec2 free(vec2 uv) { float l = texture(uPressure, uv - vec2(uTexel.x, 0.0)).x; float r = texture(uPressure, uv + vec2(uTexel.x, 0.0)).x; float b = texture(uPressure, uv - vec2(0.0, uTexel.y)).x; float t = texture(uPressure, uv + vec2(0.0, uTexel.y)).x; return texture(uVelocity, uv).xy - vec2(r - l, t - b); } void main() { vec2 back = vUv - uDt * free(vUv) * uTexel; vec2 v = free(back) * uKeep; o = bad(v.x + v.y) ? vec4(0.0) : vec4(clamp(v, -${FASTEST}.0, ${FASTEST}.0), 0.0, 1.0); }`; // The light carried along by the velocity, at the buffer's own size, and as much of it kept as the fade would have. A // sample that is not a number (anywhere in it) is nothing: carried, it would spread to its neighbours each frame. /** The light pass, with the loop slot's shape (effects.ts `loopFunction`: `fx_loop`) on where it reads back. */ const lightSource = (loop: string) => `${HEAD} uniform sampler2D uVelocity; uniform sampler2D uAcc; uniform sampler2D uBandsA; uniform sampler2D uBandsB; uniform vec2 uCell; uniform float uDt; uniform float uKeep; uniform float uZoom; uniform float uLoopA; uniform vec4 uLoopTime; uniform float uLoopFrames; uniform float uLoopAspect; uniform vec2 uLoopK; uniform float uFacets; uniform float uLattice; layout(location = 0) out vec4 o; layout(location = 1) out vec4 bandA; layout(location = 2) out vec4 bandB; vec4 kept(vec4 v) { return bad(dot(v, vec4(1.0))) ? vec4(0.0) : v * uKeep; } ${loop} // Facets: where the light is read back, pulled toward its cell's middle (the fluid's cells, square on the screen; or // hexagons a cell across), so each cell carries one light: softly at a little, as hard tiles at all of it. vec2 facet(vec2 uv) { if (uFacets <= 0.0) return uv; vec2 c = uv / uCell; vec2 centre; if (uLattice < 0.5) centre = floor(c) + 0.5; else { const float S = 0.57735027; float q = (0.57735027 * c.x - c.y / 3.0) / S; float r = (2.0 / 3.0 * c.y) / S; vec3 cube = vec3(q, r, -q - r); vec3 rc = round(cube); vec3 d = abs(rc - cube); if (d.x > d.y && d.x > d.z) rc.x = -rc.y - rc.z; else if (d.y > d.z) rc.y = -rc.x - rc.z; centre = S * vec2(1.7320508 * rc.x + 0.8660254 * rc.y, 1.5 * rc.y); } return mix(uv, centre * uCell, uFacets); } // Read at the light's own level, never a coarser one: where the flow squeezes, an implicit level blends in some of // the coarser levels (made for a wide smoothing, stale otherwise), stepping at every cell's edge, and the steps pile up // frame on frame into blocks (2026-10-18: Mali takes 7% of the next level for a 5% squeeze; desktops round it away). void main() { // The tunnel (zoom.ts): read nearer the middle, so what was drawn is drawn larger. vec2 at = 0.5 + (vUv - 0.5) * uZoom; at = fx_loop(at, uLoopA, uLoopTime, uLoopFrames, uLoopAspect, uLoopK); vec2 back = facet(at - uDt * texture(uVelocity, at).xy * uCell); o = kept(textureLod(uAcc, back, 0.0)); bandA = kept(textureLod(uBandsA, back, 0.0)); bandB = kept(textureLod(uBandsB, back, 0.0)); }`; type Target = { texture: WebGLTexture; fbo: WebGLFramebuffer }; /** `fx_flow` in the light pass, for a loop effect that reads the flow (effects.ts Stream): the velocity where p is, in * p's units a second (centred, aspect-true), as the picture slot's own. */ export const FLOW_IN_LIGHT = `vec2 fx_flow(vec2 p) { vec2 uv = vec2(p.x / uLoopAspect, p.y) * 0.5 + 0.5; return textureLod(uVelocity, uv, 0.0).xy * uCell * 2.0 * vec2(uLoopAspect, 1.0); }`; /** The loop slot's shape for the light pass (effects.ts `loopFunction`): its program's key and GLSL, its amount, its * own time and what that moved this frame, this frame's length in sixtieths of a second, its knobs. */ export type Loop = { key: string; glsl: string; amount: number; time: readonly [number, number, number, number]; frames: number; knobs?: readonly [number, number] }; const NO_LOOP = `vec2 fx_loop(vec2 uv, float a, vec4 time, float frames, float aspect, vec2 k) { return uv; }`; /** The fluid's grid for a buffer of `width` x `height`: about `cells` cells, as the buffer is shaped. */ export function gridFor(width: number, height: number, cells = 96 * 54): [number, number] { const aspect = Math.max(0.1, width / Math.max(1, height)); const h = Math.max(8, Math.round(Math.sqrt(cells / aspect))); return [Math.max(8, Math.round(h * aspect)), h]; } export class Fluid { private gl: WebGL2RenderingContext; private empty: WebGLVertexArrayObject; private programs = new Map(); private locations = new Map>(); private velocity2: [Target, Target] | null = null; private pressure2: [Target, Target] | null = null; private pending: Force[] = []; private lastDt = 0; width = 0; height = 0; /** Whether `gl` can hold the fluid: it draws into 16-bit float textures. */ static supported(gl: WebGL2RenderingContext): boolean { return !!gl.getExtension('EXT_color_buffer_float'); } /** The fluid on a grid of `gridW` x `gridH` cells (`gridFor`); throws where the device cannot hold it. */ constructor(gl: WebGL2RenderingContext, gridW: number, gridH: number) { if (!Fluid.supported(gl)) throw new Error('This device cannot draw into float textures.'); this.gl = gl; this.empty = gl.createVertexArray()!; const programs = { FORCE, JACOBI, CARRY, LIGHT: lightSource(NO_LOOP) }; for (const [name, source] of Object.entries(programs)) this.programs.set(name, program(gl, VERTEX, source)); this.resize(gridW, gridH); } /** The velocity now, a texture of the grid (RG: cells a second); `uvPerCell` turns it into texture coordinates. */ get velocity(): WebGLTexture { return this.velocity2![0].texture; } /** Texture coordinates of the buffer per cell of the grid (x, y). */ get uvPerCell(): [number, number] { return [1 / this.width, 1 / this.height]; } /** The grid made anew at another size (still). */ resize(gridW: number, gridH: number) { gridW = Math.max(8, Math.round(gridW)); gridH = Math.max(8, Math.round(gridH)); if (gridW === this.width && gridH === this.height) return; this.freeGrid(); this.width = gridW; this.height = gridH; const gl = this.gl; this.velocity2 = [this.target(gl.RG16F, gl.RG), this.target(gl.RG16F, gl.RG)]; this.pressure2 = [this.target(gl.R16F, gl.RED), this.target(gl.R16F, gl.RED)]; gl.bindFramebuffer(gl.FRAMEBUFFER, null); } /** Forces for the next step (added to any not taken yet; the strongest `MOST_FORCES` are). */ push(forces: Force[]) { this.pending.push(...forces.filter((f) => [f.x, f.y, f.dx, f.dy, f.radius, f.strength].every(Number.isFinite))); } /** One step of the flow, `dt` seconds on (at most a twentieth: a long pause does not throw it): two passes and the * pressure's iterations, on the grid. */ step(dt: number, flow: Flow) { const gl = this.gl; dt = Math.min(0.05, Math.max(0, dt)); this.lastDt = dt; gl.disable(gl.BLEND); gl.bindVertexArray(this.empty); gl.viewport(0, 0, this.width, this.height); const texel = [1 / this.width, 1 / this.height] as const; const v = this.velocity2!; const p = this.pressure2!; const forces = this.pending.sort((a, b) => Math.abs(b.strength) - Math.abs(a.strength)).slice(0, MOST_FORCES); this.pending = []; const at = new Float32Array(MOST_FORCES * 4); const way = new Float32Array(MOST_FORCES * 2); forces.forEach((f, i) => { at.set([f.x, f.y, f.radius, f.strength], i * 4); way.set([f.dx, f.dy], i * 2); }); this.draw('FORCE', v[1], { uVelocity: v[0].texture }, (u) => { gl.uniform2f(u('uTexel'), ...texel); gl.uniform1f(u('uAspect'), this.width / this.height); gl.uniform2f(u('uGrid'), this.width, this.height); gl.uniform1i(u('uCount'), forces.length); gl.uniform4fv(u('uAt'), at); gl.uniform2fv(u('uWay'), way); gl.uniform1f(u('uSwirl'), SWIRL * Math.max(0, flow.swirl)); gl.uniform1f(u('uDt'), dt); const w = flow.wave; gl.uniform4f(u('uWave'), w ? WAVE_PUSH * w.amount : 0, w ? Math.cos(w.angle) : 1, w ? Math.sin(w.angle) : 0, w ? w.phase : 0); gl.uniform1f(u('uWaveK'), WAVE_K); }); v.reverse(); for (let i = 0; i < ITERATIONS; i++) { this.draw('JACOBI', p[1], { uPressure: p[0].texture, uVelocity: v[0].texture }, (u) => { gl.uniform2f(u('uTexel'), ...texel); gl.uniform1f(u('uWarm'), i === 0 ? PRESSURE_KEPT : 1); }); p.reverse(); } // How long the motion lasts: about three seconds at full energy, a third of one at none. const energy = Math.min(1, Math.max(0, flow.energy)); const keep = Math.exp(-dt * (0.3 + 2.7 * (1 - energy))); this.draw('CARRY', v[1], { uVelocity: v[0].texture, uPressure: p[0].texture }, (u) => { gl.uniform2f(u('uTexel'), ...texel); gl.uniform1f(u('uDt'), dt); gl.uniform1f(u('uKeep'), keep); }); v.reverse(); } /** The light carried along by the flow into `dst` (a framebuffer of three targets as `light`'s, `width` x `height`), * `keep` of it kept (as the fade keeps it), in place of the renderer's fade. Leaves blending off and `dst` bound. */ advect(light: Light, dst: WebGLFramebuffer, keep: number, width: number, height: number, zoom = 1, loop?: Loop, facets?: { amount: number; hex: boolean }) { const gl = this.gl; gl.disable(gl.BLEND); gl.bindVertexArray(this.empty); gl.bindFramebuffer(gl.FRAMEBUFFER, dst); gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1, gl.COLOR_ATTACHMENT2]); gl.viewport(0, 0, width, height); const name = loop ? `LIGHT:${loop.key}` : 'LIGHT'; if (loop && !this.programs.has(name)) { // One that does not compile on this device: the light pass without it, rather than no frame. try { this.programs.set(name, program(gl, VERTEX, lightSource(loop.glsl))); } catch (e) { console.warn('Visuals: a loop effect did not compile here', loop.key, e); this.programs.set(name, this.programs.get('LIGHT')!); } } const prog = this.use(name); const u = (name: string) => this.location(prog, name); this.bind(prog, { uVelocity: this.velocity, uAcc: light.acc, uBandsA: light.bandsA, uBandsB: light.bandsB }); gl.uniform2f(u('uCell'), ...this.uvPerCell); gl.uniform1f(u('uDt'), this.lastDt); gl.uniform1f(u('uKeep'), keep); gl.uniform1f(u('uZoom'), zoom); gl.uniform1f(u('uLoopA'), loop?.amount ?? 0); gl.uniform4f(u('uLoopTime'), ...(loop?.time ?? ([0, 0, 0, 0] as const))); gl.uniform1f(u('uLoopFrames'), loop?.frames ?? 1); gl.uniform1f(u('uLoopAspect'), width / height); gl.uniform2f(u('uLoopK'), loop?.knobs?.[0] ?? 0.5, loop?.knobs?.[1] ?? 0.5); gl.uniform1f(u('uFacets'), facets?.amount ?? 0); gl.uniform1f(u('uLattice'), facets?.hex ? 1 : 0); gl.drawArrays(gl.TRIANGLES, 0, 3); } dispose() { const gl = this.gl; this.freeGrid(); for (const p of this.programs.values()) gl.deleteProgram(p); this.programs.clear(); gl.deleteVertexArray(this.empty); } private freeGrid() { const gl = this.gl; for (const t of [...(this.velocity2 ?? []), ...(this.pressure2 ?? [])]) { if (!t) continue; gl.deleteFramebuffer(t.fbo); gl.deleteTexture(t.texture); } this.velocity2 = this.pressure2 = null; } private target(internal: number, format: number): Target { const gl = this.gl; const texture = gl.createTexture()!; gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texImage2D(gl.TEXTURE_2D, 0, internal, this.width, this.height, 0, format, gl.HALF_FLOAT, null); const fbo = gl.createFramebuffer()!; gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) throw new Error('This device cannot draw into the fluid’s textures.'); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); return { texture, fbo }; } private use(name: string): WebGLProgram { const p = this.programs.get(name)!; this.gl.useProgram(p); return p; } private location(p: WebGLProgram, name: string): WebGLUniformLocation | null { let m = this.locations.get(p); if (!m) this.locations.set(p, (m = new Map())); if (!m.has(name)) m.set(name, this.gl.getUniformLocation(p, name)); return m.get(name) ?? null; } private bind(p: WebGLProgram, textures: Record) { const gl = this.gl; Object.entries(textures).forEach(([name, texture], unit) => { gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, texture); gl.uniform1i(this.location(p, name), unit); }); } private draw(name: string, into: Target, textures: Record, set: (u: (n: string) => WebGLUniformLocation | null) => void) { const gl = this.gl; const p = this.use(name); gl.bindFramebuffer(gl.FRAMEBUFFER, into.fbo); this.bind(p, textures); set((n) => this.location(p, n)); gl.drawArrays(gl.TRIANGLES, 0, 3); } } function program(gl: WebGL2RenderingContext, vs: string, fs: string): WebGLProgram { const shader = (type: number, src: string) => { const s = gl.createShader(type)!; gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) ?? 'A fluid shader did not compile.'); return s; }; const p = gl.createProgram()!; const [v, f] = [shader(gl.VERTEX_SHADER, vs), shader(gl.FRAGMENT_SHADER, fs)]; gl.attachShader(p, v); gl.attachShader(p, f); gl.linkProgram(p); gl.deleteShader(v); gl.deleteShader(f); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p) ?? 'A fluid program did not link.'); return p; } ===== src/lib/visuals/governor.ts ===== // The governor (decision 0046, amended): the last of the behaviour's three safety layers (a recipe's caps, its precheck, // then this, as it plays). Each kind of busyness has its meter (meters.ts) and a lever that holds it back while it goes // over its budget, and lets go again once it is back under: // - flashes over a quarter of the screen (WCAG's three a second, over the area the owner chose: a fixed safety rule, // always on) hold back the Flash setting's swing; // - motion, the turning's pace (and the fluid's); // - shape churn, the parts' evolving (their Evolution times longer) and Drift's walk; // - colour churn, the hues' swing; // - zoom pumping, the size's swing; // - new looks a minute, the moments (further apart). // The other budgets are the standing limits (fitted to the owner's ratings, fit.ts), scaled by the Calm ↔ Wild setting // and by how busy the music is (a quiet passage gets less). While the rating page runs a trial they are off; the // flashes' rule stays on. Pure: the loop steps it with the meters now. import { BUDGETED, budgetOf, type Budgeted } from './budget'; import type { MeterValues } from './meters'; import { DEFAULT_LIMITS } from './precheck'; export { BUDGETED, budgetOf, type Budgeted }; /** What the governor holds back: each lever's share of what the settings ask (1: none held back). `evolution` and `gap` * stretch (the evolving's times, the moments' gap: 1 and up); the rest scale down (1 and under). */ export type Levers = { swing: number; pace: number; evolution: number; hue: number; size: number; gap: number }; export const FREE: Readonly = { swing: 1, pace: 1, evolution: 1, hue: 1, size: 1, gap: 1 }; /** Each budgeted meter's lever, and how far that lever may go. */ export const LEVERS: Readonly> = { motion: { lever: 'pace', most: 0.25 }, shapeChurn: { lever: 'evolution', most: 4 }, colourChurn: { lever: 'hue', most: 0.2 }, zoomPumping: { lever: 'size', most: 0.2 }, looksPerMinute: { lever: 'gap', most: 6 }, }; /** The standing limits: the precheck's, a little above the most today's defaults did over eleven varied tracks * (precheck.ts), until the owner's ratings fit them (fit.ts). */ export const STANDING: Readonly> = Object.fromEntries(BUDGETED.map((k) => [k, DEFAULT_LIMITS[k]])) as Record; /** The flashes' rule: held back above this many a second (WCAG's three, with room to act before it), let go again at * it or under (the meter reads in halves; a lower mark held a beat a second at 120 BPM, 2, back for good). */ const FLASH_HOLD = 2.5; /** After flashes over the rule, the swing stays held this long (s) before it opens again: the meter tells of a burst a * second late, and a passage that flashed comes back (a lull between two left the next one's start unheld). */ const FLASH_REST = 8; /** How fast a lever closes while its meter is over (a share a second: a half in about a second), and opens again once * under four fifths of its budget (all of the way in about ten seconds). */ const CLOSE = 0.7; const OPEN = 0.1; /** The levers, the governor's own clock (s), and when the flashes last went over the rule on it. */ export type Governor = { levers: Levers; t: number; flashedAt: number }; export function newGovernor(): Governor { return { levers: { ...FREE }, t: 0, flashedAt: -Infinity }; } /** One step, `dt` seconds on, with the meters now: the levers as they hold back. `budgets` false (a trial): only the * flashes' rule. */ export function stepGovernor( g: Governor, m: MeterValues, opts: { dt: number; limits: Readonly>; wild: number; activity: number; budgets: boolean }, ): Levers { const l = g.levers; const { dt } = opts; const flashing = Math.max(m.flash, m.redFlash); g.t += dt; if (flashing > FLASH_HOLD) { g.flashedAt = g.t; l.swing = Math.max(0.1, l.swing * Math.exp(-2 * CLOSE * dt)); } else if (g.t - g.flashedAt > FLASH_REST) l.swing = Math.min(1, l.swing + OPEN * dt); for (const k of BUDGETED) { const { lever, most } = LEVERS[k]; const limit = opts.budgets ? budgetOf(opts.limits[k], opts.wild, opts.activity) : Infinity; const v = m[k]; const stretches = most > 1; if (v > limit) l[lever] = stretches ? Math.min(most, l[lever] * Math.exp(CLOSE * dt)) : Math.max(most, l[lever] * Math.exp(-CLOSE * dt)); else if (v < 0.8 * limit) l[lever] = stretches ? Math.max(1, l[lever] - OPEN * (most - 1) * dt) : Math.min(1, l[lever] + OPEN * dt); } return l; } /** Whether a lever holds anything back (enough to show). */ export const holding = (l: Levers, lever: keyof Levers) => (lever === 'evolution' || lever === 'gap' ? l[lever] > 1.05 : l[lever] < 0.95); ===== src/lib/visuals/held.svelte.ts ===== // A setting's label held down shows what the setting does (help.ts), in a popover by it (Help.svelte): held for // `HOLD_MS` on a touch screen or with the mouse, right-clicked, or `?` pressed on its control. A tap anywhere else, // Escape or a scroll puts it away. The owner: "each setting should be explained by you in a popover on long press on // label". Nothing shows otherwise: the panel stays labels and values. import type { Action } from 'svelte/action'; import { HELP } from './help'; /** How long a press is held before the help shows (ms), and how far it may wander (px) and still be held. */ const HOLD_MS = 450; const SLOP = 8; /** The help showing now: its text, and where its label is (the viewport's pixels). */ export const helpShown = $state({ open: false, text: '', left: 0, top: 0, bottom: 0 }); let owner: HTMLElement | null = null; export function hideHelp() { helpShown.open = false; owner = null; } /** `use:held={key}` on a label: holding it shows `HELP[key]`. */ export const held: Action = (node, initial) => { let key = initial; let timer = 0; let from: { x: number; y: number } | null = null; // A hold that showed the help: the click that ends it does nothing else (a label would focus its control). let shown = false; // A held label is not text to select, nor a link to call a menu up for. node.style.userSelect = 'none'; node.style.setProperty('-webkit-user-select', 'none'); node.style.setProperty('-webkit-touch-callout', 'none'); const show = () => { const text = HELP[key]; if (!text) return; const r = node.getBoundingClientRect(); owner = node; Object.assign(helpShown, { open: true, text, left: r.left, top: r.top, bottom: r.bottom }); }; const cancel = () => { clearTimeout(timer); timer = 0; from = null; }; const down = (e: PointerEvent) => { if (e.button !== 0) return; cancel(); shown = false; from = { x: e.clientX, y: e.clientY }; timer = window.setTimeout(() => { timer = 0; shown = true; show(); }, HOLD_MS); }; const move = (e: PointerEvent) => { if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > SLOP) cancel(); }; const click = (e: MouseEvent) => { if (!shown) return; shown = false; e.preventDefault(); e.stopPropagation(); }; // A right click, and on Android the long press's own menu: the help instead. const menu = (e: MouseEvent) => { e.preventDefault(); cancel(); show(); }; // `?` on the label's control (its slider, focused from the keyboard). const control: HTMLElement = node.closest('label') ?? node; const ask = (e: KeyboardEvent) => { if (e.key !== '?') return; e.preventDefault(); show(); }; node.addEventListener('pointerdown', down); node.addEventListener('pointermove', move); node.addEventListener('pointerup', cancel); node.addEventListener('pointercancel', cancel); node.addEventListener('pointerleave', cancel); node.addEventListener('click', click, true); node.addEventListener('contextmenu', menu); control.addEventListener('keydown', ask); return { update(next: string) { key = next; }, destroy() { cancel(); node.removeEventListener('pointerdown', down); node.removeEventListener('pointermove', move); node.removeEventListener('pointerup', cancel); node.removeEventListener('pointercancel', cancel); node.removeEventListener('pointerleave', cancel); node.removeEventListener('click', click, true); node.removeEventListener('contextmenu', menu); control.removeEventListener('keydown', ask); if (owner === node) hideHelp(); }, }; }; ===== src/lib/visuals/help.ts ===== // What each of the visuals' settings does: shown in a popover when its label is held down (held.svelte.ts), never // otherwise (the panel itself stays labels and values). Each stands on its own for someone who has never seen the // visuals explained: it names what it acts on in plain words (the picture, the flame's six parts, the bands of the // sound, a row or a column of the routes) rather than words that only meant something while it was being built, says // what its slider's two ends look like (never a middle as "usual": a track's preset sets the look, not a default), // and what it costs where that matters. Keys: a setting's own key (settings.svelte.ts), `aspect:` and `signal:` with // a route column's or row's key (routes.ts), `band:` with a band's place (or `all`), and the panel's other parts. export const HELP: Record = { // How much the device draws. quality: 'How much the device may draw at most: how many points make the picture, how many times each jumps per frame, and how sharp it is against the screen. At 60% as the visuals have always drawn; at 100% four times the points, twice the jumps and all of the screen’s sharpness, the most work for the graphics chip; at 0 coarse and grainy, but light on the device. A device that cannot keep up draws less by itself, never more than this.', fps: 'The most frames a second. At 10 the movement is choppy but light on the device; at 60 it is smoothest. If the device cannot keep up, the visuals draw less by themselves (fewer jumps per frame, then less sharpness, then fewer points; never more than Quality allows) and draw more again when there is room.', // How the picture responds and looks. intensity: 'How much the music’s loudness controls how strongly the picture responds to it. At 0 loudness plays no part: the picture always responds in full. At 100% a quiet passage gives a smaller, calmer, paler picture and a loud one a bigger, livelier one: every route answers less in a quiet passage, the fluid’s stirring aside.', flash: 'How much the picture jumps on each beat and hit: its sudden flashes of light and glow, and its sudden changes of size and colour. At 0 nothing jumps and the picture only changes gradually, as when a passage gets louder. At 100% each jump is as large as the routes below set it; at 200%, twice that.', speed: 'How fast the flame turns, and the fluid’s flow with it. It follows the track’s tempo, beat by beat, so faster music turns it faster; that is built in, not a route. At 0 it stands still; at 200% it turns twice as fast as at 100%.', brightness: 'The brightest the picture can ever get, however it flashes or glows. At 10% it stays very dim, for a dark room; at 100% it can reach the screen’s full brightness.', purity: 'The flame has six parts, one for each band of the sound from the deepest bass to the highest treble, each with its own colour. At 0 the parts’ colours mix where they overlap; at 100% each part keeps only its own colour, so you can tell the bands apart.', spread: 'How far apart the flame’s six parts (one for each band of the sound) are drawn. At 25% they are pulled together into one shape; at 250% they are drawn far apart into separate clusters.', shape: 'Shape: how much of what the routes into the Shape column give reaches the picture. At 100% all of it; lower, every swing as much smaller, keeping its shape; at 0 the music leaves the parts’ form as it is.', rotation: 'Rotation: how much of what the routes into the Rotation column give reaches the picture. At 100% all of it; lower, every swing as much smaller, keeping its shape; at 0 the music turns nothing.', lights: 'Lights: how much of what the routes into the Lights column give reaches the picture. At 100% all of it; lower, every swing as much smaller, keeping its shape; at 0 no part lights up or dims with the music.', glow: 'Glow: how much of what the routes into the Glow column give reaches the picture. At 100% all of it; lower, every swing as much smaller, keeping its shape; at 0 the glow stays even.', size: 'Size: how much of what the routes into the Size column give reaches the picture. At 100% all of it; lower, every swing as much smaller, keeping its shape; at 0 the picture stays at its resting size.', saturation: 'Saturation: how much of what the routes into the Saturation column give reaches the picture. At 100% all of it; lower, every swing as much smaller, keeping its shape; at 0 the colours stay as rich as at rest.', trails: 'Trails: how much of what the routes into the Trails column give reaches the picture. At 100% all of it; lower, every swing as much smaller, keeping its shape; at 0 the trails keep their usual length.', stir: 'Stir: how much of what the routes into the Stir column give reaches the picture. At 100% all of it; lower, every swing as much smaller, keeping its shape; at 0 nothing pushes the fluid.', flow: 'Flow: how much of what the routes into the Flow column give reaches the picture. At 100% all of it; lower, every swing as much smaller, keeping its shape; at 0 the music gives the flow no life.', swirl: 'Swirl: how much of what the routes into the Swirl column give reaches the picture. At 100% all of it; lower, every swing as much smaller, keeping its shape; at 0 nothing sets it whirling.', presence: 'Presence: how much of what the routes into the Presence column give reaches the picture. At 100% all of it; lower, every swing as much smaller, keeping its shape; at 0 every part keeps its usual share of the picture, whatever its sound.', waves: 'Waves: how much of what the routes into the Waves column give reaches the picture. At 100% all of it; lower, every swing as much smaller, keeping its shape; at 0 no waves run.', smoothing: 'Where few points have landed, the picture looks grainy. Smoothing softens those sparse areas into a soft blur, while the dense areas stay sharp. At 0 you see the raw grain of single points; at 100% the sparse areas blur up to twelve pixels wide. What you set here is the most it reaches: routes into it, and Behaviour’s walk, only ever take it lower.', haze: 'Haze: the faint mist and grain around the flame. At 0 it shows as the visuals always showed it. Towards +100% more of it is lifted into view, until it is almost as bright as the flame; towards −100% less, and the faintest areas fade to black, leaving the bright structure clean. What you set here is the most it reaches either way: routes into it, and Behaviour’s walk, only ever take it toward 0.', tunnel: 'Tunnel: the picture streams outward from the middle for ever, as if flying into it, while the flame itself keeps its size. This is how fast it goes at most: at 0 it is off, at 100% fastest. It can run with Dive. Routes into it, and Behaviour’s walk, only ever take it slower.', dive: 'Dive: the view zooms for ever into one part of the flame that is a small, turned copy of the whole, so the zoom never reaches an end. This is how fast it goes at most: at 0 it is off, at 100% fastest. It can run with Tunnel. Routes into it, and Behaviour’s walk, only ever take it slower.', effects: 'Effects: how strongly the look’s effects show (Effect 1 to 3 and Loop, in the Look). At 100% each as strongly as its Amount; at 0 none of them. Routes into it, and Behaviour’s walk, only ever take it lower.', fluid: 'How much the picture flows like smoke. At 0 the flame’s trails simply fade where they were drawn, and nothing of the fluid runs. Up to 100% they drift and curl more and more in a flow the music stirs, gently by design: at 100% a strong flow, still a flame, not all smoke. How the music stirs it is in the routes’ Stir, Flow and Swirl columns: by default each band’s hits kick its part, beats push out from the centre in a pattern that turns with the tempo, downbeats shove harder and a new section sets a whirl going. Above 0 it costs a little more of the graphics chip. What you set here is the most it reaches: routes into it, and Behaviour’s walk, only ever take it lower.', family: 'Where the colours of the flame’s six parts come from. Spectrum: spread round the whole colour wheel. Arc: along part of it, warm or cool. Two tones: two colours from opposite sides of the wheel, the bands taking them in turn. Three: three colours, each for two neighbouring bands. Each track’s look picks one of these four, with Hue, Lightness and Vividness. Cover: six colours from the playing album’s cover, the darkest for the bass and the lightest for the treble. Fire: deep red for the bass to yellow for the treble, the densest areas glowing white-hot. Choose one and it is pinned.', symmetry: 'How many times the flame repeats around its centre, like the arms of a star. At 1 it does not repeat; at 8 it has eight arms.', mirror: 'Off: the arms are turned copies of each other, all curling the same way. On: each arm also gets a mirror image, like in a kaleidoscope.', // The look a track or the dice sets (the preset); each can be moved, and then stays as you set it. look: 'The settings that decide what the flame looks like: its shape and its colours. When a track starts it sets them to that track’s own look, the same each time it plays; the dice set them at random. A setting you move stays where you put it (it is pinned) until you tap its pin.', behaviour: 'Behaviour: how the visuals move with the music. Each of these settings is the most it goes to: Behaviour’s walk (a route into the Behaviour column) moves them a little below what you set and back, never above, and the routes into Smoothing, Haze, Fluid, Tunnel and Dive only ever take those lower (Haze toward 0). Below Speed, each column of the routes has its slider, in the grid’s order; the mark under each slider shows where it is now. Move one and it is pinned: the dice leave it and it no longer walks; tap the pin to let go.', form: 'The flame’s basic shape, from 0 to 999. Each number gives the six parts their own kinds of curve, and neighbouring numbers are not alike. Detail, Twist and Warp then shape whichever form it is.', detail: 'How the flame’s parts fold into themselves. At 0 each part is a dense, compact core; at 100% it is large, open and wispy, with more fine detail.', twist: 'How much the flame’s parts turn as they repeat into themselves. At 0 they reach straight outwards; at 100% they wind into tight spirals.', warp: 'How much the flame’s parts bend. At 0 their lines stay straight and their edges sharp; at 100% they curl into swirls, bubbles and loops.', hue: 'Turns all the colours round the colour wheel together, keeping how they relate (not with Cover or Fire). 0° and 360° are the same colours: a full turn.', lightness: 'How light the colours are (not with Cover or Fire). At 0 they are deep and rich; at 100% light and pastel.', vividness: 'How vivid the colours are (not with Cover or Fire). At 0 they are soft and muted; at 100% as vivid as they go.', facets: 'Facets: where the picture flows like smoke (Fluid above 0), it is carried along in cells, each holding one light, like a mosaic moving with the flow. At 0 there are none; towards 100% the cells’ edges grow harder, until they are hard tiles. Without Fluid it does nothing.', lattice: 'The shape of the cells Facets makes: Square or Hex.', fx1: 'Kaleidoscope: one of its effects at a time over the whole picture: Kaleid (mirrored slices round the middle), Repeat (tiles), Rotate, Scroll, Pixelate or Magnify; None for none. The three engines run in their order, Kaleidoscope, EYEDO, Hydra; a track’s look may bring one or two, and one you choose is pinned.', fx1Amount: 'How strongly the Kaleidoscope’s effect shows. At 0 not at all; at 100% fully. The Effects setting in Behaviour, its routes, and the governor when it holds the picture back take it lower.', fx2: 'EYEDO: one of its effects at a time, after EYESY’s: Pulse, Burst and Halo fire on the beat; Rings and Interference ring on. None for none. The three engines run in their order, Kaleidoscope, EYEDO, Hydra; a track’s look may bring one or two, and one you choose is pinned.', fx2Amount: 'How strongly EYEDO’s effect shows. At 0 not at all; at 100% fully. The Effects setting in Behaviour, its routes, and the governor when it holds the picture back take it lower.', fx3: 'Hydra: one of its effects at a time, after Hydra’s: Echo (the last picture fed back in), Ripple, Stream (drawn along the fluid’s flow), Colorama, Hue, Saturate or Posterize; None for none. The three engines run in their order, Kaleidoscope, EYEDO, Hydra; a track’s look may bring one or two, and one you choose is pinned.', fx3Amount: 'How strongly Hydra’s effect shows. At 0 not at all; at 100% fully. The Effects setting in Behaviour, its routes, and the governor when it holds the picture back take it lower.', fxLoop: 'Loop: an effect inside the flame’s trails rather than over the picture. It acts each frame on the light as it is carried on, so it builds up into new shapes: Kaleid, Pixelate, Ripple, Stream, Rotate, Scroll or Rings; None for none.', fxLoopAmount: 'How strongly the Loop effect acts. At 0 not at all; at 100% fully. The Effects setting in Behaviour, its routes, and the governor when it holds the picture back take it lower.', fx1K1: 'One of the chosen effect’s own controls: its label says what it sets. At 50% the effect is as it was made.', fx1K2: 'One of the chosen effect’s own controls: its label says what it sets. At 50% the effect is as it was made.', fx2K1: 'One of the chosen effect’s own controls: its label says what it sets. At 50% the effect is as it was made.', fx2K2: 'One of the chosen effect’s own controls: its label says what it sets. At 50% the effect is as it was made.', fx3K1: 'One of the chosen effect’s own controls: its label says what it sets. At 50% the effect is as it was made.', fx3K2: 'One of the chosen effect’s own controls: its label says what it sets. At 50% the effect is as it was made.', fxLoopK1: 'One of the Loop effect’s own controls: its label says what it sets. At 50% the effect is as it was made.', fxLoopK2: 'One of the Loop effect’s own controls: its label says what it sets. At 50% the effect is as it was made.', final: 'A lens over the whole picture, such as a sphere, a bubble or a swirl, each bending the flame as a whole. Off shows the flame without one.', pin: 'You moved this setting, so it keeps your value when the next track starts, and Another look and the dice leave it alone. Tap the pin to let each track set it again.', snapshots: 'Snapshots: the visuals saved with every setting and a small picture. Tap one to bring it back: every setting returns as it was saved, and its Look settings are pinned, so the next track keeps that look. Hold one to rename or delete it. They are kept on the server, so every device sees them.', 'snapshots:standard': 'The visuals as Cratarr made them before snapshots existed, kept so they can always be brought back: every setting to its default, every pin let go, and each track its own look again. It cannot be renamed or deleted.', 'snapshots:save': 'Saves the visuals as they are now: every setting, from the Look and the routes to Quality and Brightness, with a small picture, named after the playing track and the time (tap the name to change it). In a sync group, bringing it back sets the shared settings on every device, and Quality, FPS and Brightness only on the device you bring it back on.', 'snapshots:over': 'Save over: puts the visuals as they are now into this snapshot, every setting and a new small picture, keeping its name and its place among the others.', another: 'Another look: every Look setting you have not pinned moves to a new random value.', randomise: 'Randomise. A tap: everything at random, a new look (every Look setting you have not pinned) and new values for how the visuals move with the music: the routes and every Behaviour setting, each column’s slider among them, and each band’s times. A double tap: only how they move, the routes into Shape, Lights, Rotation and Size and those four sliders, Speed, Flash and the bands’ times; the look, the colours, the grain and smoke and every other route stay, and until the next roll, an undo or a Reset it may be as lively as Wild at 100% allows. Either way each roll is first tried against the next half minute of the playing track and passed over if it would be livelier than allowed or flash over a quarter of the screen more than three times a second (after eight tries, the one closest to fitting is taken). What you pinned (settings and routes), the Track row, Wild, Quality, FPS and Brightness stay. In a sync group, every device gets it.', 'thumbs-up': 'Thumbs up: you like the visuals as they are now. If they are being held back just then (the amber dot), the limit doing it is eased a step, so they get more room. Otherwise your thumb is only kept, with how lively the last 20 seconds were, to tune the limits later. The limit on flashes never changes.', 'thumbs-down': 'Thumbs down: you don’t like the visuals as they are now. If one kind of liveliness stood out over the last 20 seconds (turning, changes of shape or colour, pumping in size, new looks), its limit is tightened a step at once, to just under what it was then. If nothing stood out, your thumb is only kept, to tune the limits later: it was probably the look, not the liveliness. What the thumbs teach drifts halfway back in a month. The limit on flashes never changes.', forget: 'Forget: lets go of everything the thumbs taught. The limits go back to where they started, on all your devices. Your thumbs themselves stay kept.', rate: 'Rate: your answers set the limits Wild works within. The visuals show two ways of moving with the music, A for 20 seconds and then B, and you choose the one you liked better, or Same, or Neither. Each answer is kept on the server with how lively A and B were, and the next pair starts. About twenty pairs, a quarter of an hour, are enough to begin with. The cross stops it, and the next time it carries on from where you stopped on this device. What you see while rating never changes your own settings.', undo: 'Undo: puts the visuals back as they were before the last roll of the dice: the look, the pins and how they move with the music, on every device in the sync group.', wild: 'How lively the visuals may get. They are kept within limits on how fast they turn, how quickly their shape and colours change, how much they pump in size and how often a new look comes. At 0 they stay within half of those limits, calm; at 100% they may go to twice them, wild. Loud music gets a little more room than quiet. Flashes over a quarter of the screen stay at most three a second either way.', held: 'Held back: the visuals are doing more than Wild allows just now (or flashing over a large area too often), so this is turned down for a moment. It eases back by itself.', // The bands, as headings of their own settings and as the routes' choice of band. 'band:0': 'Sub: the deepest bass, below 60 Hz. Kick drums and sub-bass, felt more than heard.', 'band:1': 'Bass: 60 to 150 Hz. Bass lines and the body of kick drums.', 'band:2': 'Low mid: 150 to 400 Hz. The warmth of voices, guitars and pianos.', 'band:3': 'Mid: 400 Hz to 2 kHz. Most of voices and melodies.', 'band:4': 'Upper mid: 2 to 5 kHz. The edge and clarity of voices and snares.', 'band:5': 'Air: above 5 kHz. Cymbals, hi-hats and breath.', 'band:all': 'Every band at once: a square set here sets that row for all six bands.', // Each band's times. lighting: 'Linger: how long this band’s part of the flame keeps glowing after each hit in this band, such as a drum or a plucked note, and how fast its Envelope lets go when the sound stops. At 20 ms it flashes for an instant; at 1 s it glows and fades over a second.', evolution: 'How quickly this band’s part of the flame changes shape when what moves it changes (at first, this band getting louder or quieter). At 20 ms it changes at once and can look nervous; at 2 s it changes smoothly, taking two seconds to catch up.', continuity: 'How this band’s part of the flame turns and changes. At 0 only in steps, one on each hit in this band; at 100% only smoothly; in between, some of each.', // The routes. routes: 'Routes connect the music to the picture. Each row is something measured in the music, each column something in the picture, and each square a curve: how that row moves that column, as the row goes from nothing to full. Tap a square to shape it. The top rows measure the whole track and act on every part of the flame. The rows below measure one band each, picked above them, and act on that band’s part; in the columns for the whole picture, the bands count together. Each square shows its curve, scaled to fit, and fills from the bottom as far as its route gives right now, green the one way, pink the other. A solid outline means pinned (the dice leave it), a dashed one the square you picked, and a border of long dashes on the move a route that walks with Behaviour.', 'route:curve': 'How this row moves this column. Across: the row, from nothing at the left to full at the right. Up: what the route does, from −100% at the bottom (the opposite way, or for Smoothing, Haze, Fluid, Tunnel and Dive, taking it lower; Haze toward 0) through 0 on the middle line to +100% at the top. Drag the two round handles to shape the curve, and the one at the right edge to scale the whole of it; green above the middle line, pink below. The white dot is where the row is now, and the faint shape behind the curve is where it has been over the last half minute of music: put the curve’s bend where it usually is. With All bands picked, the curve is the six bands’ together, dashed where they differ, and shaping it sets all six. Shaping a square pins it.', 'route:walks': 'Walks with Behaviour: this route’s effect walks a little below its curve and back as Behaviour’s walk goes, never above it, even while it is pinned. Its square gets a border of long dashes that moves as the walk does.', 'route:zero': 'Zero: this route does nothing any more, its curve flat on the middle line and its square empty (with All bands picked, for all six). Like any change to a square, it pins the route, so the dice leave it at nothing.', 'route:pin': 'Pinned: the dice leave this route as it is, on or off, whatever else they change; its square has a solid outline. Shaping a square pins it; tap to let go. A pinned route still walks if it walks with Behaviour.', reset: 'Puts every setting back to its default, the routes too; every pin is let go and the playing track’s own look comes back.', 'routes:reset': 'Puts every square back to its default: loudness sets the picture’s size, colour and turning; beats make it flash and swell; each band’s loudness shapes, turns and tints its part, and its hits light it up.', // Route columns: what a row moves. 'aspect:shape': 'As the row rises, its part of the flame grows and changes form (with the whole-track rows, every part).', 'aspect:rotation': 'As the row rises, its part of the flame turns (with the whole-track rows, the whole picture). Below 0 it turns the other way.', 'aspect:lights': 'As the row rises, its part of the flame lights up in its own colour. Below 0 the part dims instead.', 'aspect:hue': 'As the row rises, its part’s colour moves round the colour wheel; below 0, the other way round.', 'aspect:spread': 'As the row rises, its part of the flame is drawn out from the centre; below 0, in towards it.', 'aspect:brightness': 'As the row rises, the whole picture glows brighter; below 0, dimmer.', 'aspect:size': 'As the row rises, the whole picture is drawn larger; below 0, smaller.', 'aspect:saturation': 'As the row rises, the colours get richer; below 0, paler.', 'aspect:trails': 'As the row rises, the movement leaves longer trails; below 0, shorter ones.', 'aspect:smoothing': 'As the row rises, Smoothing comes up to what its slider sets; below 0, the row takes it down instead. Never above the slider.', 'aspect:haze': 'As the row rises, Haze comes up to what its slider sets, more haze or less; below 0, the row takes it toward none instead. Never past the slider.', 'aspect:fluid': 'As the row rises, Fluid comes up to what its slider sets and the picture flows more like smoke; below 0, the row takes it down. Never above the slider.', 'aspect:stir': 'How hard this row pushes the fluid. By default each band’s hits kick its part of the flame, the beats push out from the centre in a pattern that turns with the tempo, downbeats shove harder, a new section sets a whirl going, and each band’s level stirs it steadily. Across the curve is the push’s own strength (for a hit, how hard it is); below 0 it pushes the other way. Loudness does not scale it.', 'aspect:flow': 'How much life the flow has. By default the loudness sets its energy and the beat makes it breathe with the pulse. Below 0 the row calms it instead. Loudness does not scale it beyond its own row.', 'aspect:swirl': 'How much the flow whirls. By default a swell sets it turning and a new section starts a whirl. Below 0 it whirls the other way. Loudness does not scale it.', 'aspect:presence': 'As the row rises, its part of the flame takes more of the picture (more of the points are drawn as it); below 0, less. In the bands’ rows, each part follows its own sound.', 'aspect:waves': 'As the row rises, slow giant waves run across the picture, higher the more it gives; below 0 they run the other way. With Fluid the light rides on them as a broad current; without it the picture bends along them.', 'aspect:tunnel': 'As the row rises, Tunnel comes up to the speed its slider sets; below 0, the row slows it instead. Never faster than the slider.', 'aspect:dive': 'As the row rises, Dive comes up to the speed its slider sets; below 0, the row slows it instead. Never faster than the slider.', 'aspect:effects': 'As the row rises, the look’s effects come up to what Effects sets; below 0, the row takes them down instead. Never above the slider.', 'aspect:facets': 'As the row rises, Facets comes up to what its slider sets; below 0, the row takes it down. Never above the slider.', 'aspect:mirror': 'Each time the row rises past half way, Mirror turns the other way (on or off), unless you have pinned it. Each such moment is a new look: the governor keeps them a few seconds apart, more when the picture is busy.', 'aspect:final': 'Each time the row rises past half way, the Lens moves to another lens, or to none, unless you have pinned it. Each such moment is a new look: the governor keeps them a few seconds apart, more when the picture is busy.', 'aspect:form': 'Each time the row rises past half way, the flame takes another Form, unless you have pinned it. Each such moment is a new look: the governor keeps them a few seconds apart, more when the picture is busy.', 'aspect:family': 'Each time the row rises past half way, the Colours move to another of the look’s four (Spectrum, Arc, Two tones, Three); Cover and Fire, chosen by hand, stay, unless you have pinned it. Each such moment is a new look: the governor keeps them a few seconds apart, more when the picture is busy.', 'aspect:lattice': 'Each time the row rises past half way, the Facets’ cells turn from square to hex or back, unless you have pinned it. Each such moment is a new look: the governor keeps them a few seconds apart, more when the picture is busy.', 'aspect:fx1': 'Each time the row rises past half way, the Kaleidoscope moves to another of its effects, its controls back to their middle; where it is set to None it stays off (its Amount’s routes bring it in), unless you have pinned it. Each such moment is a new look: the governor keeps them a few seconds apart, more when the picture is busy.', 'aspect:fx1Amount': 'As the row rises, the Kaleidoscope’s Amount comes up to what its slider sets; below 0, the row takes it down instead. Never above the slider.', 'aspect:fx1K1': 'As the row rises, the Kaleidoscope’s first control (named above for the chosen effect) moves up from where its slider sets it, across its whole range at a route’s full amount; below 0, down. With no such control (—), the row does nothing until an effect has one.', 'aspect:fx1K2': 'As the row rises, the Kaleidoscope’s second control (named above for the chosen effect) moves up from where its slider sets it, across its whole range at a route’s full amount; below 0, down. With no such control (—), the row does nothing until an effect has one.', 'aspect:fx2': 'Each time the row rises past half way, EYEDO moves to another of its effects, its controls back to their middle; where it is set to None it stays off (its Amount’s routes bring it in), unless you have pinned it. Each such moment is a new look: the governor keeps them a few seconds apart, more when the picture is busy.', 'aspect:fx2Amount': 'As the row rises, the EYEDO’s Amount comes up to what its slider sets; below 0, the row takes it down instead. Never above the slider.', 'aspect:fx2K1': 'As the row rises, the EYEDO’s first control (named above for the chosen effect) moves up from where its slider sets it, across its whole range at a route’s full amount; below 0, down. With no such control (—), the row does nothing until an effect has one.', 'aspect:fx2K2': 'As the row rises, the EYEDO’s second control (named above for the chosen effect) moves up from where its slider sets it, across its whole range at a route’s full amount; below 0, down. With no such control (—), the row does nothing until an effect has one.', 'aspect:fx3': 'Each time the row rises past half way, the Hydra moves to another of its effects, its controls back to their middle; where it is set to None it stays off (its Amount’s routes bring it in), unless you have pinned it. Each such moment is a new look: the governor keeps them a few seconds apart, more when the picture is busy.', 'aspect:fx3Amount': 'As the row rises, the Hydra’s Amount comes up to what its slider sets; below 0, the row takes it down instead. Never above the slider.', 'aspect:fx3K1': 'As the row rises, the Hydra’s first control (named above for the chosen effect) moves up from where its slider sets it, across its whole range at a route’s full amount; below 0, down. With no such control (—), the row does nothing until an effect has one.', 'aspect:fx3K2': 'As the row rises, the Hydra’s second control (named above for the chosen effect) moves up from where its slider sets it, across its whole range at a route’s full amount; below 0, down. With no such control (—), the row does nothing until an effect has one.', 'aspect:fxLoop': 'Each time the row rises past half way, the Loop moves to another of its effects, its controls back to their middle; where it is set to None it stays off (its Amount’s routes bring it in), unless you have pinned it. Each such moment is a new look: the governor keeps them a few seconds apart, more when the picture is busy.', 'aspect:fxLoopAmount': 'As the row rises, the Loop’s Amount comes up to what its slider sets; below 0, the row takes it down instead. Never above the slider.', 'aspect:fxLoopK1': 'As the row rises, the Loop’s first control (named above for the chosen effect) moves up from where its slider sets it, across its whole range at a route’s full amount; below 0, down. With no such control (—), the row does nothing until an effect has one.', 'aspect:fxLoopK2': 'As the row rises, the Loop’s second control (named above for the chosen effect) moves up from where its slider sets it, across its whole range at a route’s full amount; below 0, down. With no such control (—), the row does nothing until an effect has one.', 'aspect:look': 'As the row rises, the flame’s look walks: its Detail, Twist, Warp, colours, Spread and Purity move a little further in a direction that changes every bar, never far from where the track set them. Below 0 the look walks back. Pinned settings stay where they are.', 'aspect:behaviour': 'As the row rises, the behaviour walks: the Behaviour settings, and the routes set to walk with it, move a little below what you set, in a direction that changes every bar, never above it and never far below. Below 0 they come back up to what you set. Pinned ones stay where they are.', 'aspect:symmetry': 'Each time the row rises past half way, the Symmetry setting moves to another of its eight, unless you have pinned it. Each such moment is a new look: the governor keeps them a few seconds apart, more when the picture is busy.', // Route rows: what is measured in the music. The whole track's: 'signal:loudness': 'How loud the music sounds right now, as you hear it.', 'signal:beat': 'A pulse on every beat, strongest on the strong beats.', 'signal:downbeat': 'A pulse on the first beat of every bar.', 'signal:tempo': 'How fast the track is: half at 60 beats a minute, full at 120, and further past full for faster tracks.', 'signal:section': 'A pulse where a new part of the track starts, such as a verse, a chorus or a drop.', 'signal:harmony': 'Rises when the chords change, and stays low while they hold.', 'signal:centroid': 'How bright the sound is: low for dark, muffled sounds, high for bright, sharp ones.', 'signal:key': 'Which key the music is in, as a place on a circle where related keys sit side by side. It changes slowly, so it suits slow drifts such as Hue.', 'signal:swell': 'Rises while the music grows louder over several seconds, and falls back when it stops growing.', 'signal:buildUp': 'Rises through a build-up, as the music climbs towards a drop.', 'signal:track': 'A pulse as each new track starts. Into Look it decides what a new track does to the look: at −100% the look becomes the new track’s own, at −50% it goes halfway there, at 0 it stays as it is, and above 0 it moves away from the track’s own. Into Behaviour: at −100% the behaviour starts from what you set again, at 0 it walks on from where it was, and above 0 it starts further below.', // Each band's: 'signal:level': 'How loud this band is right now.', 'signal:flux': 'How much this band’s sound is changing: high on new notes and hits, low on held sounds.', 'signal:hits': 'A pulse on each hit in this band, such as a drum or a plucked note, fading over the band’s Lighting setting.', 'signal:percussive': 'How much of this band is drums and other short, struck sounds.', 'signal:harmonic': 'How much of this band is held notes and chords.', 'signal:percussiveHits': 'A pulse on each drum hit in this band, leaving held notes out, fading over the band’s Lighting setting.', 'signal:crest': 'How punchy this band is: how far its sharp peaks stand out above its average loudness.', 'signal:width': 'How wide this band sounds between the left and right speakers: low when it comes from the middle, high when it is spread out.', 'signal:bandSwell': 'Rises while this band grows louder over several seconds.', 'signal:envelope': 'How present this band’s sound is now: on each hit it flares to the hit’s strength and rings down over the band’s Linger, and while the sound holds it stays at up to 60% of the band’s level. A struck drum flares and is gone; a held pad glows; a struck chord flares, then glows while it rings.', }; ===== src/lib/visuals/history.ts ===== // Where each of the music's signals has been lately, for the curve editor's envelope (decision 0046, amended for 64.1; // the owner: "this gives us the best way to tune the curve to the signal"): the last half minute at ten a second, the // whole track's signals and each band's. Pure. /** How many of a signal's values are kept (half a minute at ten a second), and the bins across its 0..1. */ export const HISTORY = 300; export const BINS = 60; export type History = { tracks: number; bandSignals: number; bands: number; /** Where the next values go, and how many there are (at most `HISTORY`). */ at: number; count: number; /** A signal's values one after the other: the track's by signal, the bands' by band, then signal. */ track: Float32Array; band: Float32Array; }; export const newHistory = (tracks: number, bandSignals: number, bands: number): History => ({ tracks, bandSignals, bands, at: 0, count: 0, track: new Float32Array(tracks * HISTORY), band: new Float32Array(bands * bandSignals * HISTORY), }); /** The signals now, remembered (the oldest let go of). */ export function remember(h: History, track: ArrayLike, bands: ArrayLike[]) { for (let s = 0; s < h.tracks; s++) h.track[s * HISTORY + h.at] = track[s] ?? 0; for (let b = 0; b < h.bands; b++) for (let s = 0; s < h.bandSignals; s++) h.band[(b * h.bandSignals + s) * HISTORY + h.at] = bands[b]?.[s] ?? 0; h.at = (h.at + 1) % HISTORY; h.count = Math.min(HISTORY, h.count + 1); } /** How often a signal has been at each bin (a track's signal, or a band's: one band's, or with `band` null all six's), * lightly smoothed (1-2-3-2-1), the most 1; null while nothing is remembered. */ export function densityOf(h: History, which: { track: number } | { signal: number; band: number | null }): Float32Array | null { if (!h.count) return null; const counts = new Float32Array(BINS); const add = (from: Float32Array, start: number) => { for (let i = 0; i < h.count; i++) counts[Math.min(BINS - 1, Math.max(0, Math.floor(from[start + i] * BINS)))]++; }; if ('track' in which) add(h.track, which.track * HISTORY); else for (let b = 0; b < h.bands; b++) if (which.band === null || which.band === b) add(h.band, (b * h.bandSignals + which.signal) * HISTORY); const KERNEL = [1, 2, 3, 2, 1]; const out = new Float32Array(BINS); let most = 0; for (let i = 0; i < BINS; i++) { let sum = 0; let weight = 0; for (let k = -2; k <= 2; k++) if (i + k >= 0 && i + k < BINS) { sum += KERNEL[k + 2] * counts[i + k]; weight += KERNEL[k + 2]; } out[i] = sum / weight; most = Math.max(most, out[i]); } if (most > 0) for (let i = 0; i < BINS; i++) out[i] /= most; return out; } ===== src/lib/visuals/learning.ts ===== // What the governor learns from the owner's thumbs (decision 0048, amended): its budgets' limits, nudged as an up-down // staircase nudges a threshold (Levitt 1971; Kaernbach 1991's weighted up-down). The owner: "I could get a general // thumbs up or thumbs down for any visualization that I currently look at, and it could then decide to tune", and // "Simple is good, but we do need to handle the caveats": // // - A thumbs down tightens the limit of the one kind of busyness that stood out most over the 20 seconds before it // (how far past what the defaults typically do, as a share of how far the defaults themselves go at most: the kinds // range very differently, colour churn's most is ten times its typical, motion's not twice), to a step under what it // was then, so it acts at once. All of it in the // governor's own terms: each meter against its budget at a limit of 1 (as Calm ↔ Wild and the music's activity // scale it, governor.ts `budgetOf`), so a limit learned is one the governor holds the picture to as it is. // Where nothing stood out, it only counts toward the periodic fit: dislikes are pressed more often than likes, and // most are of the look rather than its busyness, so each one tightening something would ratchet everything calm. // - A thumbs up loosens, a step, the limit the governor is holding the picture back by then (its amber dot): the way // back out once a limit is tight, without which a system learning only from what it shows narrows for good. // - Each limit's step halves each time it turns the other way (from a tenth to a fortieth), so it settles. // - Each drifts back toward where it started, half way in thirty days: taste moves with mood and familiarity. // - None goes under half or over twice where it started. The flash rule is not learned. // // Pure: the visuals load what was learned (the server keeps it per user), drift it, and save each change. import type { Budgeted } from './budget'; /** One limit as learned: its value, the step its next change takes, and which way it last went. */ export type LearnedLimit = { value: number; step: number; dir: -1 | 0 | 1 }; export type Learned = Record; /** What the defaults typically do, in the governor's terms (each meter against its budget at a limit of 1): Standard's * median mean over eleven varied tracks at a quarter and three fifths of each, three track looks a track as tracks draw * them (claude-5f's calibration for 65, 2026-10-18, 506 runs; precheck.ts), against which a meter stands out. New looks a minute: half the limit (Standard has none of * its own). */ export const TYPICAL: Readonly> = { motion: 0.266, shapeChurn: 0.279, colourChurn: 0.036, zoomPumping: 0.069, looksPerMinute: 2, }; /** The most the defaults do (the same runs' most, the flame's own: the looks without effects, as the limits take it), * the other end of their usual range; new looks a minute: the limit. */ export const USUAL_MOST: Readonly> = { motion: 0.466, shapeChurn: 0.638, colourChurn: 0.354, zoomPumping: 0.351, looksPerMinute: 4, }; export const FIRST_STEP = 0.1; export const LEAST_STEP = 0.025; /** Half of a limit's way back to where it started, in this many days. */ export const HALF_LIFE_DAYS = 30; /** How far a limit may go from where it started: this share of it at least, this many times it at most. */ export const LEAST = 0.5; export const MOST = 2; /** Nothing learned: each limit where it starts. */ export function fresh(base: Readonly>): Learned { return Object.fromEntries((Object.keys(base) as Budgeted[]).map((k) => [k, { value: base[k], step: FIRST_STEP, dir: 0 }])) as Learned; } /** What was learned, `days` later: each limit that far back toward where it started (evenly in its logarithm). */ export function drifted(learned: Learned, base: Readonly>, days: number): Learned { const keep = 2 ** (-Math.max(0, days) / HALF_LIFE_DAYS); return Object.fromEntries( (Object.keys(base) as Budgeted[]).map((k) => { const l = learned[k] ?? { value: base[k], step: FIRST_STEP, dir: 0 }; const value = Number.isFinite(base[k]) && l.value > 0 ? base[k] * (l.value / base[k]) ** keep : base[k]; return [k, { ...l, value }]; }), ) as Learned; } /** The 20 seconds before a thumb: each meter's mean against its budget at a limit of 1 (`budgetOf(1, wild, activity)` * each reading), the governor's terms. */ export type Window = { relative: Partial> }; /** What a thumb does: the limits after it, and the one it changed (none where it only counts toward the fit). * `held`: the limit the governor holds the picture back by now, if any. */ export function thumbed( learned: Learned, base: Readonly>, thumb: 'up' | 'down', window: Window, held: Budgeted | null, ): { learned: Learned; changed: { meter: Budgeted; from: number; to: number } | null } { let meter: Budgeted | null = null; let to = 0; if (thumb === 'down') { // The one that stood out most, if any stood out: past the defaults' typical, as a share of their usual range. let most = 0; for (const k of Object.keys(base) as Budgeted[]) { const v = window.relative[k]; if (v === undefined || !Number.isFinite(base[k])) continue; const out = (v - TYPICAL[k]) / (USUAL_MOST[k] - TYPICAL[k]); if (out > most) { most = out; meter = k; } } if (meter) { const l = learned[meter]; const step = l.dir === 1 ? Math.max(LEAST_STEP, l.step / 2) : l.step; to = Math.min(l.value, window.relative[meter]!) * (1 - step); learned = { ...learned, [meter]: { value: l.value, step, dir: -1 } }; } } else if (held && Number.isFinite(base[held])) { meter = held; const l = learned[held]; const step = l.dir === -1 ? Math.max(LEAST_STEP, l.step / 2) : l.step; to = l.value * (1 + step); learned = { ...learned, [held]: { value: l.value, step, dir: 1 } }; } if (!meter) return { learned, changed: null }; const from = learned[meter].value; const value = Math.min(base[meter] * MOST, Math.max(base[meter] * LEAST, to)); return { learned: { ...learned, [meter]: { ...learned[meter], value } }, changed: { meter, from, to: value } }; } /** The limits to hold the picture within, from what was learned. */ export const limitsOf = (learned: Learned): Record => Object.fromEntries(Object.entries(learned).map(([k, l]) => [k, l.value])) as Record; ===== src/lib/visuals/live.ts ===== // The sound as it plays, for the visuals while the server's signals for the track are not there yet (decision 0046, // amended: the server works them out the first time a track is asked for, or ahead of it in a queue; until then, here). // Listens beside the page player's own route (`audioTap`), adds nothing to what is heard, and gives what format 3's // band sections give, from running statistics instead of the whole track: each band's level (dB over the 30 under its // running loudest) and flux (against its running loudest), its hits (picked as the server picks them: a peak of the // flux over the median around it), and the loudness as heard (EBU R128 momentary, through its K-weighting filters). // Only what can be heard as it plays: novelty, sections, bars and harmonic change wait for the server. /** Each band's edges (Hz; 0 open), as the server's section 0 has them. */ export const LIVE_EDGES: readonly (readonly [number, number])[] = [ [0, 60], [60, 150], [150, 400], [400, 2000], [2000, 5000], [5000, 0], ]; export type LiveBand = { /** 0..1: dB over the 30 under the band's loudest of the last half minute. */ level: number; /** 0..1: against the band's loudest flux of the last half minute. */ flux: number; /** How many hits the band has had: a new one arrives as this goes up. */ hits: number; /** The last hit's strength, 0..1 (1: as strong as the band's strong hits lately). */ lastHitStrength: number; }; export type LiveNow = { /** LUFS, momentary (the last 400 ms or so); null before there is enough sound. */ loudness: number | null; bands: LiveBand[]; }; /** Listened this often (ms): about the server's frame rate, whatever the screen's. */ const TICK_MS = 12; /** The running statistics' memory (s), and how often their tops are worked out again. */ const MEMORY_S = 30; const TOPS_EVERY_MS = 1000; /** Magnitudes compressed as ln(1 + γ·|X|) before the flux, as on the server. */ const GAMMA = 100; const RANGE_DB = 30; /** Hits closer than this to the last one in their band are one (ms), as on the server. */ const GAP_MS = [100, 100, 80, 70, 60, 50]; /** A ring of the last values, with its 98th percentile (or another) worked out on demand. */ class Recent { private values: Float32Array; private at = 0; private filled = 0; constructor(size: number) { this.values = new Float32Array(size); } push(v: number) { this.values[this.at] = v; this.at = (this.at + 1) % this.values.length; this.filled = Math.min(this.filled + 1, this.values.length); } percentile(p: number, above = -Infinity): number | null { const v = Array.from(this.values.subarray(0, this.filled)).filter((x) => x > above); if (v.length === 0) return null; v.sort((a, b) => a - b); return v[Math.round((v.length - 1) * p)]; } /** The last `n` values, oldest first. */ last(n: number): number[] { const out: number[] = []; const len = this.values.length; for (let i = Math.min(n, this.filled); i > 0; i--) out.push(this.values[(this.at - i + len) % len]); return out; } } const median = (v: number[]) => { if (v.length === 0) return 0; const s = [...v].sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; }; /** * Listens to `source` (the page player's route, before its gain) in `context`. `now()` is what it has heard lately; * `stop()` lets go of it. */ export function startLive(source: AudioNode, context: AudioContext): { now(): LiveNow; stop(): void } { const rate = context.sampleRate; // The spectrum: about 46 ms a window, as on the server, without the analyser's own smoothing. const spectral = context.createAnalyser(); spectral.fftSize = rate > 64_000 ? 4096 : 2048; spectral.smoothingTimeConstant = 0; // The loudness: R128's K-weighting (a high shelf, then a high pass), then about 400 ms of the sound. const shelf = context.createBiquadFilter(); shelf.type = 'highshelf'; shelf.frequency.value = 1681.97; shelf.gain.value = 3.99984; const pass = context.createBiquadFilter(); pass.type = 'highpass'; pass.frequency.value = 38.135; pass.Q.value = 0.5003; const temporal = context.createAnalyser(); temporal.fftSize = 2 ** Math.round(Math.log2(rate * 0.4)); source.connect(spectral); source.connect(shelf).connect(pass).connect(temporal); const stereo = source.channelCount >= 2; const bins = spectral.frequencyBinCount; const db = new Float32Array(bins); const wave = new Float32Array(temporal.fftSize); const band = new Int8Array(bins).fill(-1); for (let k = 1; k < bins; k++) { const hz = (k * rate) / spectral.fftSize; band[k] = LIVE_EDGES.findIndex(([lo, hi]) => hz >= lo && (hi === 0 || hz < hi)); } const previous = new Float32Array(bins); const ticksOf = (ms: number) => Math.max(1, Math.round(ms / TICK_MS)); const memory = ticksOf(MEMORY_S * 1000); const levels = LIVE_EDGES.map(() => new Recent(memory)); const fluxes = LIVE_EDGES.map(() => new Recent(memory)); const strengths = LIVE_EDGES.map(() => new Recent(64)); const tops = LIVE_EDGES.map(() => ({ level: -100, flux: 1e-6, strong: 1e-6 })); const bands: LiveBand[] = LIVE_EDGES.map(() => ({ level: 0, flux: 0, hits: 0, lastHitStrength: 0 })); const lastHit = LIVE_EDGES.map(() => -Infinity); let loudness: number | null = null; let toppedAt = 0; let tick = 0; // The analyser's dB as a magnitude (its own scale: only how the flux compresses depends on it). const toMagnitude = (d: number) => (d === -Infinity ? 0 : 10 ** (d / 20)); const listen = () => { tick++; spectral.getFloatFrequencyData(db); const energy = new Float64Array(LIVE_EDGES.length); const flux = new Float64Array(LIVE_EDGES.length); for (let k = 1; k < bins; k++) { const b = band[k]; const m = toMagnitude(db[k]); const y = Math.log1p(GAMMA * m); const rise = Math.max(0, y - previous[k]); previous[k] = y; if (b < 0) continue; energy[b] += m * m; flux[b] += rise; } const now = performance.now(); if (now - toppedAt > TOPS_EVERY_MS) { toppedAt = now; tops.forEach((t, b) => { t.level = levels[b].percentile(0.98, -100) ?? -100; t.flux = Math.max(fluxes[b].percentile(0.98, 0) ?? 1e-6, 1e-6); t.strong = Math.max(strengths[b].percentile(0.9, 0) ?? 1e-6, 1e-6); }); } for (let b = 0; b < LIVE_EDGES.length; b++) { const level = 10 * Math.log10(energy[b] + 1e-12); levels[b].push(level); fluxes[b].push(flux[b]); const t = tops[b]; bands[b].level = level > -100 ? Math.min(1, Math.max(0, (level - (t.level - RANGE_DB)) / RANGE_DB)) : 0; bands[b].flux = Math.min(1, flux[b] / t.flux); // A hit, three ticks late (about 35 ms): the flux then was the largest around it, over the median of the 120 ms // before and the 35 ms after it by a tenth of the band's top, and not under a sixth of it. const near = 3; const window = fluxes[b].last(ticksOf(120) + near + 1); const at = window.length - 1 - near; if (at < near) continue; const f = window[at]; const around = window.slice(Math.max(0, at - near), at).concat(window.slice(at + 1)); if (f < t.flux / 6 || around.some((x, i) => (i < near ? x >= f : x > f))) continue; if (f < median(window) + t.flux / 10) continue; const when = now - near * TICK_MS; if (when - lastHit[b] < GAP_MS[b]) continue; lastHit[b] = when; strengths[b].push(f); bands[b].hits++; bands[b].lastHitStrength = Math.min(1, f / t.strong); } // The loudness, about every 100 ms. if (tick % ticksOf(100) === 0) { temporal.getFloatTimeDomainData(wave); let sum = 0; for (const x of wave) sum += x * x; const meanSquare = sum / wave.length; // Two channels mixed to one by the analyser: as if both carried it (R128 sums the channels). loudness = meanSquare > 1e-10 ? -0.691 + 10 * Math.log10(meanSquare * (stereo ? 2 : 1)) : null; } }; const timer = setInterval(listen, TICK_MS); return { now: () => ({ loudness, bands: bands.map((b) => ({ ...b })) }), stop: () => { clearInterval(timer); for (const node of [spectral, shelf]) { try { source.disconnect(node); } catch { // Already let go of. } } shelf.disconnect(); pass.disconnect(); }, }; } ===== src/lib/visuals/longPress.ts ===== // A long press on an element (half a second, held still), or a right click or the context-menu key: `then` runs, and the // click that ends the press does nothing else. The visuals' saved looks: held, one can be renamed or deleted. export function longPress(node: HTMLElement, then: () => void) { let run = then; let timer: ReturnType | undefined; let from: { x: number; y: number } | null = null; // Whether this press has run `then` (its click is swallowed); a browser that also sends a context menu for a long // press runs it once. let fired = false; const fire = () => { clearTimeout(timer); if (fired) return; fired = true; run(); }; const down = (e: PointerEvent) => { fired = false; from = { x: e.clientX, y: e.clientY }; clearTimeout(timer); timer = setTimeout(fire, 500); }; const move = (e: PointerEvent) => { if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > 10) clearTimeout(timer); }; const up = () => clearTimeout(timer); const click = (e: MouseEvent) => { if (!fired) return; e.preventDefault(); e.stopImmediatePropagation(); fired = false; }; const menu = (e: MouseEvent) => { e.preventDefault(); fire(); }; node.addEventListener('pointerdown', down); node.addEventListener('pointermove', move); node.addEventListener('pointerup', up); node.addEventListener('pointercancel', up); node.addEventListener('click', click, true); node.addEventListener('contextmenu', menu); return { update(next: () => void) { run = next; }, destroy() { clearTimeout(timer); node.removeEventListener('pointerdown', down); node.removeEventListener('pointermove', move); node.removeEventListener('pointerup', up); node.removeEventListener('pointercancel', up); node.removeEventListener('click', click, true); node.removeEventListener('contextmenu', menu); }, }; } ===== src/lib/visuals/meters.ts ===== // What the visuals do, measured (decision 0046, amended): how much they flash, move, change shape and colour, pump // and start new looks, each as a number the governor holds within its budget and the calibration compares with the // owner's ratings. One definition for both: the runtime fills `FrameStats` each frame; the precheck of a recipe fills // them from a simulation (no picture: its flashes from the glow it would show, one tile for the screen). // // Flashes are WCAG 2.2's (success criterion 2.3.1, "Three Flashes or Below Threshold"): a general flash is a pair of // opposing changes in relative luminance of 0.1 or more where the darker is below 0.8; a red flash a pair of opposing // transitions into or out of a saturated red (R / (R + G + B) at least 0.8) that move its chromaticity more than 0.2 // in CIE 1976 u'v'; no more than three of either in any second. A tile flashes at its own rate. Over how much of the // screen is the owner's: "Full screen flashes at most 3 times per second, but if there is some minor flashing deep // inside a cloud, it shouldn't be limited", and of the choices, a quarter of the whole screen (2026-10-18): the rate a // quarter of all the tiles reach is `flash`, which the governor holds to three. WCAG's own area (a quarter of any 10° // field of view, which it estimates as a third of the screen across and down) is `flashStrict`, measured and reported, // not held: on rhythmic music the visuals' beat-lit parts pass three by it (4 to 8 a second, 2026-10-18). /** One reading of the picture by tiles (flashmeter.ts): each tile's relative luminance, chromaticity (CIE 1976 u', * v') and share of red (R / (R + G + B), linear light), `cols` x `rows` from the bottom left, at `t` seconds. */ export type Tiles = { t: number; cols: number; rows: number; luminance: Float32Array; u: Float32Array; v: Float32Array; red: Float32Array }; /** What the frame did, as the visuals' loop knows it (or the precheck's simulation). */ export type FrameStats = { /** Seconds, rising. */ t: number; /** Each part's turning now (radians a second, signed). */ turnRates: number[]; /** Each part's evolving shape, hue (turns) and spread, as drawn. */ shape: number[]; hue: number[]; spread: number[]; /** The picture's size as drawn (its scale times a beat's swell). */ scale: number; /** Its colourfulness as drawn (0..1.25). */ saturation: number; /** A new look started this frame, and why (a track's own does not count against the budget). */ look: 'track' | 'dice' | 'moment' | null; /** A new symmetry this frame (a moment). */ symmetry: boolean; /** Each part's weight in the drawing (Presence, 65: its share of the points against the others'); none: all alike. */ weights?: number[]; /** What the effects add (effects.ts `reportOf`), each a second, beside the flame's own. */ extra?: Partial>; /** The picture's tiles, where a reading arrived this frame (one frame may bring several, or none). */ tiles: Tiles[]; }; /** The meters now. */ export type MeterValues = { /** General flashes a second over a quarter of the screen (the owner's area; held to 3). */ flash: number; /** Red flashes a second over a quarter of the screen (held to 3). */ redFlash: number; /** Either over three a second (over a quarter of the screen). */ flashFails: boolean; /** General and red flashes a second by WCAG's own area (a quarter of a third-by-third window): reported, not held. */ flashStrict: number; redFlashStrict: number; /** How fast the parts turn: their mean |turning| (radians a second). */ motion: number; /** How fast the parts change shape and spread: their mean |change| a second. */ shapeChurn: number; /** How fast the colours change: the parts' mean |hue change| (turns a second) and the saturation's. */ colourChurn: number; /** How much the picture pumps: |change in log size| a second. */ zoomPumping: number; /** New looks (the dice's and the moments', not a track's own) in the last minute. */ looksPerMinute: number; }; export const FLASH_STEP = 0.1; export const FLASH_DARK = 0.8; export const RED_SHARE = 0.8; export const RED_SHIFT = 0.2; /** WCAG's most flashes a second. */ export const MOST_FLASHES = 3; /** The meters other than the flashes follow over this long (seconds). */ const FOLLOW_S = 1; /** D65's chromaticity: where black (no colour to speak of) is put. */ const WHITE_U = 0.1978; const WHITE_V = 0.4683; /** CIE 1976 u'v' of linear sRGB (0..1 each); black as white. */ export function chromaticity(r: number, g: number, b: number): [number, number] { const x = 0.4124 * r + 0.3576 * g + 0.1805 * b; const y = 0.2126 * r + 0.7152 * g + 0.0722 * b; const z = 0.0193 * r + 0.1192 * g + 0.9505 * b; const d = x + 15 * y + 3 * z; return d > 1e-6 ? [(4 * x) / d, (9 * y) / d] : [WHITE_U, WHITE_V]; } /** The transitions of one tile, general and red, and their times. */ class TileTrack { private ext = NaN; private dir = 0; private wasRed: boolean | null = null; private lastU = WHITE_U; private lastV = WHITE_V; private redU = WHITE_U; private redV = WHITE_V; general: number[] = []; red: number[] = []; add(t: number, l: number, u: number, v: number, share: number) { // General: a turn of at least FLASH_STEP from the last extreme, the darker side under FLASH_DARK. const turns = Math.abs(l - this.ext) >= FLASH_STEP && Math.min(l, this.ext) < FLASH_DARK; if (Number.isNaN(this.ext)) this.ext = l; else if (this.dir !== 0 && (this.dir > 0 ? l >= this.ext : l <= this.ext)) this.ext = l; else if (turns) { this.dir = l > this.ext ? 1 : -1; this.ext = l; this.general.push(t); } // Red: into or out of a saturated red, its chromaticity moved more than RED_SHIFT. const isRed = share >= RED_SHARE && l > 0.001; if (this.wasRed !== null && isRed !== this.wasRed) { const [fu, fv] = isRed ? [this.lastU, this.lastV] : [this.redU, this.redV]; if (Math.hypot(u - fu, v - fv) > RED_SHIFT) this.red.push(t); } if (isRed) [this.redU, this.redV] = [u, v]; else [this.lastU, this.lastV] = [u, v]; this.wasRed = isRed; } /** Transitions within the last second before `t` (older ones let go). */ count(t: number): [number, number] { const keep = (a: number[]) => { while (a.length && a[0] <= t - 1) a.shift(); return a.length; }; return [keep(this.general), keep(this.red)]; } } /** Every tile's flashes, and the screen's rate as WCAG measures it (a quarter of a third-by-third window). */ export class Flashes { private tracks: TileTrack[] = []; private cols = 0; private rows = 0; add(tiles: Tiles) { if (tiles.cols !== this.cols || tiles.rows !== this.rows) { this.cols = tiles.cols; this.rows = tiles.rows; this.tracks = Array.from({ length: tiles.cols * tiles.rows }, () => new TileTrack()); } this.tracks.forEach((track, i) => track.add(tiles.t, tiles.luminance[i], tiles.u[i], tiles.v[i], tiles.red[i])); } /** General and red flashes a second at `t`: over a quarter of the screen (the owner's area), and by WCAG's window. */ rates(t: number): { general: number; red: number; strictGeneral: number; strictRed: number } { if (!this.tracks.length) return { general: 0, red: 0, strictGeneral: 0, strictRed: 0 }; const counts = this.tracks.map((track) => track.count(t)); const general = counts.map((c) => c[0] / 2); const red = counts.map((c) => c[1] / 2); return { general: quarterOf(general), red: quarterOf(red), strictGeneral: worstWindow(general, this.cols, this.rows), strictRed: worstWindow(red, this.cols, this.rows), }; } } /** The rate at least a quarter of all the tiles reach. */ export function quarterOf(rates: number[]): number { if (!rates.length) return 0; const sorted = [...rates].sort((a, b) => b - a); return sorted[Math.max(0, Math.ceil(sorted.length / 4) - 1)]; } /** WCAG's area: the rate at least a quarter of some window of a third of the tiles across and down reaches. */ export function worstWindow(rates: number[], cols: number, rows: number): number { const wc = Math.max(1, Math.ceil(cols / 3)); const wr = Math.max(1, Math.ceil(rows / 3)); let worst = 0; for (let y = 0; y + wr <= rows; y++) { for (let x = 0; x + wc <= cols; x++) { const inside: number[] = []; for (let j = 0; j < wr; j++) for (let i = 0; i < wc; i++) inside.push(rates[(y + j) * cols + x + i]); inside.sort((a, b) => b - a); worst = Math.max(worst, inside[Math.max(0, Math.ceil(inside.length / 4) - 1)]); } } return worst; } /** How much of the points moves between parts from weights `b` to `a`: half the summed change of each one's share. */ function sharesMoved(a: number[], b: number[]): number { const sa = a.reduce((x, w) => x + w, 0); const sb = b.reduce((x, w) => x + w, 0); if (!(sa > 0 && sb > 0) || a.length !== b.length) return 0; return a.reduce((x, w, i) => x + Math.abs(w / sa - b[i] / sb), 0) / 2; } /** All the meters, frame by frame. */ export class Meters { private flashes = new Flashes(); private last: FrameStats | null = null; private looks: number[] = []; private motion = 0; private shape = 0; private colour = 0; private zoom = 0; private flash = { general: 0, red: 0, strictGeneral: 0, strictRed: 0 }; add(s: FrameStats) { for (const tiles of s.tiles) this.flashes.add(tiles); this.flash = this.flashes.rates(s.t); if (s.look === 'dice' || s.look === 'moment') this.looks.push(s.t); while (this.looks.length && this.looks[0] <= s.t - 60) this.looks.shift(); const was = this.last; this.last = s; if (!was) return; const dt = s.t - was.t; if (!(dt > 0)) return; const k = 1 - Math.exp(-dt / FOLLOW_S); const mean = (v: number[]) => (v.length ? v.reduce((a, x) => a + x, 0) / v.length : 0); const change = (a: number[], b: number[]) => mean(a.map((x, i) => Math.abs(x - (b[i] ?? x)))) / dt; // A hue that comes round is no change: its shortest way. const hueChange = mean(s.hue.map((h, i) => Math.abs((((h - (was.hue[i] ?? h)) % 1) + 1.5) % 1 - 0.5))) / dt; const x = s.extra ?? {}; this.motion += (mean(s.turnRates.map(Math.abs)) + (x.motion ?? 0) - this.motion) * k; // A new look jumps by design: that frame's change is the look's, counted in looksPerMinute, not churn. const jumped = s.look !== null; // Presence (65) moves the picture's points between its parts: the share that moves counts as a part's shape // changing by as much (a part flaring from a sixth of the points to a third, against the others, as much as one // part's shape changing by a sixth). const moved = s.weights && was.weights ? sharesMoved(s.weights, was.weights) / dt : 0; this.shape += ((jumped ? 0 : change(s.shape, was.shape) + change(s.spread, was.spread) + moved) + (x.shapeChurn ?? 0) - this.shape) * k; this.colour += ((jumped ? 0 : hueChange + Math.abs(s.saturation - was.saturation) / dt) + (x.colourChurn ?? 0) - this.colour) * k; const pump = s.scale > 0 && was.scale > 0 ? Math.abs(Math.log(s.scale / was.scale)) / dt : 0; this.zoom += (pump + (x.zoomPumping ?? 0) - this.zoom) * k; } now(): MeterValues { return { flash: this.flash.general, redFlash: this.flash.red, flashFails: Math.floor(this.flash.general) > MOST_FLASHES || Math.floor(this.flash.red) > MOST_FLASHES, flashStrict: this.flash.strictGeneral, redFlashStrict: this.flash.strictRed, motion: this.motion, shapeChurn: this.shape, colourChurn: this.colour, zoomPumping: this.zoom, looksPerMinute: this.looks.length, }; } } /** One tile standing for the whole screen, from a luminance (and colour) the picture would show: the precheck's, * which simulates without drawing. */ export function screenTile(t: number, luminance: number, rgb: [number, number, number] = [luminance, luminance, luminance]): Tiles { const [u, v] = chromaticity(...rgb); const sum = rgb[0] + rgb[1] + rgb[2]; return { t, cols: 1, rows: 1, luminance: Float32Array.of(luminance), u: Float32Array.of(u), v: Float32Array.of(v), red: Float32Array.of(sum > 1e-6 ? rgb[0] / sum : 0), }; } ===== src/lib/visuals/music.svelte.ts ===== // What the visuals follow (decision 0046, amended): the playing track's signals now, each 0..1, in the routes' order // (routes.ts): the whole track's (its loudness as heard, beats and downbeats, tempo, sections, harmonic change, timbre, // key, swells and build-ups) and each of its six bands' (level, flux, hits, percussive and harmonic parts, punch, width, // swells). From the track's signals, worked out on the server (signals.ts); until the server has them, and where it has // none, from the sound as it plays where the page plays it (live.ts); in the app the music plays natively, out of the // page's reach, and on another player it plays elsewhere: there the loudness comes from the waveform until the server's // signals are in. Read once per frame by the visuals' loop. import { getTrackGain, trackState, type TrackGain } from '../api'; import { appInfo, inApp } from '../app.svelte'; import { prefs } from '../prefs.svelte'; import { audioTap, positionNow } from '../localPlayer.svelte'; import { isLocal } from '../output.svelte'; import { paused, playing } from '../remote.svelte'; import { loadWaveform, waveformOf } from '../waveforms.svelte'; import { beatAt, beatsOf, type Beat, type Beats } from './beats'; import { followEnvelopes } from './envelope'; import { startLive, type LiveNow } from './live'; import { B, BAND_SIGNALS, T, TRACK_SIGNALS } from './routes'; import { ID, lastOf, lufsAt, seriesAt, signalsOf, type Events, type Signals } from './signals'; import { BANDS, visualSettings } from './settings.svelte'; /** A band's last hit, for the steps the Continuity setting asks for: its place among the band's hits (-1 before the * first), its strength against the band's mean (1: as strong as its hits are on average), and the band's hits a * second; and how soon its next one comes (s), where that is known ahead (the server's; null as heard). */ export type Steps = { index: number; weight: number; perSecond: number; nextIn: number | null }; export type Music = { /** The playing track (`source/id`), or null. */ key: string | null; /** The loudness as heard here, from near nothing at -45 LUFS (or silence) to all at -10 (0..1); from the waveform * where it is not known (yet). */ intensity: number; /** Beats a second: the beats' own where they are known, else the tempo's, else 90 a minute. */ bps: number; /** The beat it is at, where it has beats (a tempo) and they are known here yet. */ beat: Beat | null; /** The whole track's signals now, in `TRACK_SIGNALS` order (the loudness's is `intensity`). */ track: Float32Array; /** Each band's signals now, in `BAND_SIGNALS` order; null where none are known here (yet). */ bands: Float32Array[] | null; hits: Steps[] | null; /** Where the bands' signals come from. */ from: 'server' | 'live' | null; playing: boolean; /** Where in the track the picture is drawn (seconds, a little ahead of the sound: `AHEAD`); 0 with none. */ position: number; }; /** The picture is drawn this far ahead of the sound (seconds): a frame reaches the screen a frame or two after it is * drawn, and a flash a little early reads as on the beat, a little late as after it. The sound heard as it plays * cannot be got ahead of. */ const AHEAD = 0.05; const tempos = new Map(); /** The loudness as heard that makes the show's intensity nothing, and all (LUFS): a quiet room's music to a loud * master's chorus, in equal steps of dB. */ const QUIET = -45; const LOUD = -10; /** A section's start: a mark of its strength fading over this long (s). */ const SECTION_S = 1; /** The key follows the chroma over this long (s): a chord is not a key. */ const KEY_S = 2; /** The gain the player plays a track at, as the players level it (decision 0035: never louder than as it is, nor past * its peak), in dB; 0 where it does not level, or the gain is not known yet. */ const gains = new Map(); function levelDb(source: string, id: string): number { if (!(inApp ? appInfo.levelling : prefs.levelling)) return 0; const key = `${source}/${id}`; if (!gains.has(key)) { gains.set(key, null); getTrackGain(source, id).then( (g) => gains.set(key, g), () => gains.delete(key), ); } const g = gains.get(key); if (!g || g.trackGain === undefined) return 0; const peak = g.trackPeak && g.trackPeak > 0 ? -20 * Math.log10(g.trackPeak) : 0; return Math.min(g.trackGain, peak, 0); } /** What the music's signals keep from frame to frame (a frame's `Music` is read within its frame): the signals now, * each band's last hit, and the key (the chroma's direction on the circle of fifths, followed as a vector, so it turns * the short way round). */ export type MusicState = { track: Float32Array; bands: Float32Array[]; hits: Steps[]; fifths: { x: number; y: number; at: number }; /** Each band's envelope's hold (what its sound still holds, followed), and when it was last followed (ms; −1 never). */ held: Float32Array; heldAt: number; }; export function newMusicState(): MusicState { return { track: new Float32Array(TRACK_SIGNALS.length), bands: Array.from({ length: BANDS }, () => new Float32Array(BAND_SIGNALS.length)), hits: Array.from({ length: BANDS }, () => ({ index: -1, weight: 1, perSecond: 0, nextIn: null })), fifths: { x: 0, y: 0, at: 0 }, held: new Float32Array(BANDS), heldAt: -1, }; } // The visuals' own. const own = newMusicState(); const { track, bands, hits } = own; // The circle of fifths' place of each pitch class (C, C#, ... B), as an angle. const FIFTH_ANGLE = Array.from({ length: 12 }, (_, k) => (2 * Math.PI * ((7 * k) % 12)) / 12); /** A hit's light: as strong as the hit (a weak one weak), fading over `ring` seconds. */ const ringOf = (since: number, strength: number, ring: number) => (since > ring * 5 ? 0 : strength * Math.exp(-since / ring)); /** A beat's pulse: on at the beat (most on the strong ones), gone within `most` seconds (sooner at a fast tempo). */ const pulseOf = (b: Beat, most: number, share: number) => (0.35 + 0.65 * b.strength) * Math.exp(-b.since / Math.min(most, share * b.length)); /** Where the hits and beats are known ahead (the server's), a light or pulse rises into each over this long (s) rather * than coming on all at once, its most still on the moment: a light on in one frame and gone in the next was a strobe * (2026-10-18). The sound heard as it plays cannot be got ahead of, so its hits still come on at once. */ const ATTACK = 0.04; /** How far a light has risen with `until` seconds to go (0..1, smoothly). */ function risen(until: number): number { const x = 1 - until / ATTACK; return x <= 0 ? 0 : x >= 1 ? 1 : x * x * (3 - 2 * x); } /** A list of events' light at `pos`: the last one's fading over `ring` seconds, and the next one's rising. */ function lightOf(e: Events | undefined, pos: number, ring: number): number { const last = lastOf(e, pos); const next = lastOf(e, pos + ATTACK); const on = last.index < 0 ? 0 : ringOf(last.since, last.value, ring); return next.index > last.index ? Math.max(on, next.value * risen(ATTACK - next.since)) : on; } /** A beat's pulse at `pos` (`beat`: the one at or before it), rising into the next where it is near; `counts`: whether * a beat pulses at all (a downbeat's only those that start a bar), and how much. */ function pulseAt(beats: Beats, beat: Beat | null, pos: number, most: number, share: number, counts: (b: Beat) => number): number { const on = beat ? pulseOf(beat, most, share) * counts(beat) : 0; const next = beatAt(beats, pos + ATTACK); if (!next || (beat && next.index <= beat.index)) return on; return Math.max(on, (0.35 + 0.65 * next.strength) * counts(next) * risen(ATTACK - next.since)); } /** The music now. `at` is the frame's time (performance.now()), to move the position on between the player's reports; * `lighting`: each band's hits' ring (s; by default the settings'). */ export function musicNow(at: number, lighting: readonly number[] = visualSettings.lighting): Music { const np = playing(); track.fill(0); if (!np) { stopLive(); return { key: null, intensity: 0, bps: 1.5, beat: null, track, bands: null, hits: null, from: null, playing: false, position: 0 }; } const key = `${np.source}/${np.id}`; loadWaveform(np.source, np.id); if (!tempos.has(key)) { tempos.set(key, null); trackState(np.source, np.id).then( (s) => tempos.set(key, s.info?.bpm ?? null), () => tempos.delete(key), ); } const on = !paused(); const pos = positionAt(key, on, at) + (on ? AHEAD : 0); const beats = beatsOf(np.source, np.id); const signals = signalsOf(np.source, np.id); // The next track's too, so it pulses from its first beat. if (np.upcoming[0]) { beatsOf(np.source, np.upcoming[0].id); signalsOf(np.source, np.upcoming[0].id); } const bpm = tempos.get(key) ?? null; const wave = waveformOf(np.source, np.id); const length = np.progress?.duration_secs ?? null; const energy = () => (wave && length ? loudnessAt(wave, pos / length) : null); // The bands: the server's, else heard here. if (signals) { stopLive(); return musicOf(signals, beats ?? null, pos, on, { state: own, at, key, lighting, gainDb: levelDb(np.source, np.id), bpm, energy: energy() }); } const beat = beats ? beatAt(beats, pos) : null; const bps = beat ? 1 / beat.length : (bpm ?? 90) / 60; let heard: number | null = null; let from: Music['from'] = null; const now = listen(); if (now) { heard = now.loudness; fromLive(now, at, lighting); from = 'live'; } const intensity = intensityOf(heard, levelDb(np.source, np.id), energy()); finish(own, intensity, bps, beats ?? null, beat, pos); return { key, intensity, bps, beat, track, bands: from ? bands : null, hits: from ? hits : null, from, playing: on, position: pos }; } /** The show's intensity from the loudness heard (LUFS, null where it is not known) at the player's gain (dB), else * from the waveform's energy there (0..1, null where that is not known either). */ function intensityOf(heard: number | null, gainDb: number, energy: number | null): number { return heard !== null ? Math.min(1, Math.max(0, (heard + gainDb - QUIET) / (LOUD - QUIET))) : 0.25 + 0.6 * (energy ?? 0.6); } /** The whole track's signals every source gives: the loudness, the tempo, the beat, the key. */ function finish(st: MusicState, intensity: number, bps: number, beats: Beats | null, beat: Beat | null, pos: number) { st.track[T.loudness] = intensity; st.track[T.tempo] = bps / 2; if (beats) st.track[T.beat] = pulseAt(beats, beat, pos, 0.2, 0.35, () => 1); st.track[T.key] = Math.atan2(st.fifths.y, st.fifths.x) / (2 * Math.PI) + (st.fifths.y < 0 ? 1 : 0); } /** The music at `position` (seconds) of a track whose signals the server has, playing or not: what the visuals' loop * takes each frame (`musicNow`), and what the behaviour's precheck steps through a track with (recipe.ts) without it * playing. `state`: what follows from frame to frame (a new one for each track stepped through; without one, a new one * each call, so the key does not follow); `at`: the frame's clock (ms; by default the position's); `lighting`: each * band's hits' ring (s; by default the settings'); `gainDb`: the player's levelling; `bpm`: the track's tempo where it * has no beats; `energy`: the waveform's loudness there (0..1), where the signals' is not known. */ export function musicOf( signals: Signals, beats: Beats | null, position: number, playing: boolean, opts: { state?: MusicState; at?: number; key?: string | null; lighting?: readonly number[]; gainDb?: number; bpm?: number | null; energy?: number | null } = {}, ): Music { const st = opts.state ?? newMusicState(); st.track.fill(0); const beat = beats ? beatAt(beats, position) : null; const bps = beat ? 1 / beat.length : (opts.bpm ?? 90) / 60; fromServer(st, signals, position, beats, beat, opts.at ?? position * 1000, opts.lighting ?? visualSettings.lighting); const intensity = intensityOf(lufsAt(signals, position), opts.gainDb ?? 0, opts.energy ?? null); finish(st, intensity, bps, beats, beat, position); return { key: opts.key ?? null, intensity, bps, beat, track: st.track, bands: st.bands, hits: st.hits, from: 'server', playing, position }; } /** The server's signals at `pos`, into `st`. */ function fromServer(st: MusicState, s: Signals, pos: number, beats: Beats | null, beat: Beat | null, at: number, lighting: readonly number[]) { const { track, bands, hits, fifths } = st; const one = (id: number) => seriesAt(s, id, pos)?.[0] ?? 0; // A downbeat: a beat that starts its bar, as surely as the server is of where bars start. const bar = s.series.get(ID.bar); if (beats && bar) { const surely = Math.min(1, (2 * bar.scale) / 255); const starts = (b: Beat) => (Math.round((seriesAt(s, ID.bar, pos, b.index)?.[0] ?? 1) * 255) === 0 ? surely : 0); track[T.downbeat] = pulseAt(beats, beat, pos, 0.35, 0.6, starts); } track[T.section] = lightOf(s.marks.get(ID.boundaries), pos, SECTION_S); track[T.harmony] = one(ID.harmonicChange); track[T.centroid] = one(ID.centroid); track[T.swell] = one(ID.swell); track[T.buildUp] = one(ID.buildUp); const chroma = seriesAt(s, ID.chroma, pos); if (chroma) { let x = 0; let y = 0; for (let k = 0; k < 12; k++) { x += chroma[k] * Math.cos(FIFTH_ANGLE[k]); y += chroma[k] * Math.sin(FIFTH_ANGLE[k]); } const k = 1 - Math.exp(-Math.min(0.1, Math.max(0, (at - fifths.at) / 1000)) / KEY_S); st.fifths = { x: fifths.x + (x - fifths.x) * k, y: fifths.y + (y - fifths.y) * k, at }; } else fifths.at = at; const rows = (id: number) => seriesAt(s, id, pos); const level = rows(ID.level); const flux = rows(ID.flux); const percussive = rows(ID.percussive); const harmonic = rows(ID.harmonic); const crest = rows(ID.crest); const width = rows(ID.width); const swell = rows(ID.bandSwell); const all = s.hits.get(ID.hits); const drums = s.hits.get(ID.percussiveHits); for (let b = 0; b < BANDS; b++) { const v = bands[b]; v[B.level] = level?.[b] ?? 0; v[B.flux] = flux?.[b] ?? 0; v[B.percussive] = percussive?.[b] ?? 0; v[B.harmonic] = harmonic?.[b] ?? 0; v[B.crest] = crest?.[b] ?? 0; v[B.width] = width?.[b] ?? 0; v[B.bandSwell] = swell?.[b] ?? 0; const ring = lighting[b]; v[B.hits] = lightOf(all?.[b], pos, ring); v[B.percussiveHits] = lightOf(drums?.[b], pos, ring); const hit = lastOf(all?.[b], pos); const next = all?.[b]?.at[hit.index + 1]; hits[b] = { index: hit.index, weight: hit.value / Math.max(0.05, s.meanStrength[b] ?? 1), perSecond: s.perSecond[b] ?? 0, nextIn: next === undefined ? null : next - pos }; } followEnvelopes(st, at, lighting); } // The sound heard as it plays: listened to while the visuals ask and the page's own player plays (on the tap it has // while it plays); each band's hits as they arrive. let live: { now(): LiveNow; stop(): void } | null = null; let liveNode: AudioNode | null = null; const heardHits = Array.from({ length: BANDS }, () => ({ count: 0, at: 0, strength: 0, mean: 0.6, since: 0, first: 0 })); /** What has been heard lately, where the page plays the music; null elsewhere. */ function listen(): LiveNow | null { const tap = isLocal() ? audioTap() : null; if (!tap) { stopLive(); return null; } if (!live || liveNode !== tap.node) { stopLive(); live = startLive(tap.node, tap.context); liveNode = tap.node; const now = performance.now(); for (const h of heardHits) Object.assign(h, { count: 0, at: 0, strength: 0, since: now, first: 0 }); } return live.now(); } function fromLive(now: LiveNow, at: number, lighting: readonly number[]) { for (let b = 0; b < BANDS; b++) { const heard = now.bands[b]; const v = bands[b]; v.fill(0); if (!heard) continue; const h = heardHits[b]; if (heard.hits !== h.count) { if (!h.first) h.first = heard.hits; h.count = heard.hits; h.at = at; h.strength = heard.lastHitStrength; h.mean += (heard.lastHitStrength - h.mean) * 0.05; } v[B.level] = heard.level; v[B.flux] = heard.flux; v[B.hits] = h.count ? ringOf((at - h.at) / 1000, h.strength, lighting[b]) : 0; const secs = (at - h.since) / 1000; hits[b] = { index: h.count - 1, weight: h.strength / Math.max(0.05, h.mean), perSecond: secs > 5 ? (h.count - h.first) / secs : 2, nextIn: null }; } followEnvelopes(own, at, lighting); } function stopLive() { live?.stop(); live = null; liveNode = null; } /** Lets go of the sound (the visuals closed). */ export function stopMusic() { stopLive(); } // The position at the frame. This device's player (the app's, or the page's own audio) says it to the frame. Another // player reports it now and then, through the server: between reports it moves on with the frame clock, and it is // eased toward each report rather than set to it, since a jump back or forth would be a beat played twice or skipped; // a report far off (a seek) is taken as it is. let clock = { key: '', pos: 0, at: 0, heard: Number.NaN }; function positionAt(key: string, on: boolean, at: number): number { if (isLocal()) return positionNow(); const heard = playing()?.progress?.position_secs ?? 0; if (clock.key !== key) clock = { key, pos: heard, at, heard }; if (on) clock.pos += (at - clock.at) / 1000; clock.at = at; if (heard !== clock.heard) { const off = heard - clock.pos; clock.pos += Math.abs(off) > 0.3 ? off : off / 2; clock.heard = heard; } return clock.pos; } /** The waveform's loudness at `fraction` of the track, between its bins, against its loudest bin. */ function loudnessAt(bins: Uint8Array, fraction: number): number { let top = 1; for (const b of bins) if (b > top) top = b; const x = Math.min(Math.max(fraction, 0), 1) * (bins.length - 1); const i = Math.floor(x); const a = bins[i]; const b = bins[Math.min(i + 1, bins.length - 1)]; return (a + (b - a) * (x - i)) / top; } ===== src/lib/visuals/palette.ts ===== // The colours of a track's cover, for the visuals' Cover colours (decision 0046, amended): the cover drawn small, its // pixels as OKLab, gathered into a few colours (k-means), each pixel weighted by how coloured it is, so a grey, white or // black background does not take a band from the colours the cover is known by. A cover with hardly any colour gives // its greys. export type PaletteColour = { /** OKLab: the perceptual lightness (0..1) and the colour plane's two axes. */ L: number; a: number; b: number; /** Its weight in the cover (0..1, all of them summing to one): its pixels, each counted by how coloured it is. */ share: number; }; /** A cover's colours, darkest first. */ export type Palette = { colours: PaletteColour[] }; /** The cover is drawn this many pixels a side: enough for its colours, little to gather. */ const SIZE = 48; const ROUNDS = 12; /** Under this chroma a pixel counts as grey: weighed `GREY_WEIGHT`, against 1 for one of `FULL` chroma or more; a grey * darker than `DARK` or lighter than `LIGHT` (a cover's black or white ground, and black is nothing on the visuals' * black) only `GROUND_WEIGHT`. */ const GREY = 0.02; const FULL = 0.1; const GREY_WEIGHT = 0.08; const DARK = 0.25; const LIGHT = 0.9; const GROUND_WEIGHT = 0.005; /** A colour of some tint, but under `LIFT`, is lifted to it, so the cover's tint shows on the black around it. */ const LIFT = 0.05; /** A cover whose coloured pixels weigh less than this share of it is grey: its greys are its colours. */ const GREY_COVER = 0.02; const linear = Array.from({ length: 256 }, (_, i) => { const c = i / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }); /** OKLab from sRGB (0..255 each). */ export function oklab(r: number, g: number, b: number): [number, number, number] { const R = linear[r]; const G = linear[g]; const B = linear[b]; const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B); const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B); const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B); return [ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, ]; } /** The colours of an image's pixels (RGBA, 0..255 each; the transparent ones left out): `n` of them, darkest first, or * null where none is opaque. */ export function paletteOf(rgba: ArrayLike, n = 6): Palette | null { const count = Math.floor(rgba.length / 4); const L = new Float64Array(count); const A = new Float64Array(count); const B = new Float64Array(count); const w = new Float64Array(count); let used = 0; let coloured = 0; for (let i = 0; i < count; i++) { if (rgba[i * 4 + 3] < 128) continue; const [l, a, b] = oklab(rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2]); const c = Math.hypot(a, b); L[used] = l; A[used] = a; B[used] = b; w[used] = c >= GREY ? GREY_WEIGHT + (1 - GREY_WEIGHT) * Math.min(1, (c - GREY) / (FULL - GREY)) : l < DARK || l > LIGHT ? GROUND_WEIGHT : GREY_WEIGHT; if (c >= GREY) coloured++; used++; } if (used === 0) return null; // A grey cover: every pixel as much as another. if (coloured < used * GREY_COVER) w.fill(1, 0, used); // The first centre the weighted mean, each next the pixel furthest from those there are (by weight): the same // colours each time for the same cover. const k = Math.max(1, Math.min(n, used)); const centres: [number, number, number][] = []; const near = new Float64Array(used).fill(Infinity); const furthest = () => { let best = 0; let most = -1; for (let i = 0; i < used; i++) { const d = w[i] * near[i]; if (d > most) { most = d; best = i; } } return best; }; const closer = (c: [number, number, number]) => { for (let i = 0; i < used; i++) near[i] = Math.min(near[i], (L[i] - c[0]) ** 2 + (A[i] - c[1]) ** 2 + (B[i] - c[2]) ** 2); }; { let sw = 0; const m: [number, number, number] = [0, 0, 0]; for (let i = 0; i < used; i++) { sw += w[i]; m[0] += w[i] * L[i]; m[1] += w[i] * A[i]; m[2] += w[i] * B[i]; } centres.push([m[0] / sw, m[1] / sw, m[2] / sw]); closer(centres[0]); } while (centres.length < k) { const i = furthest(); centres.push([L[i], A[i], B[i]]); closer(centres[centres.length - 1]); } const of = new Int32Array(used); const weight = new Float64Array(k); for (let round = 0; round < ROUNDS; round++) { for (let i = 0; i < used; i++) { let best = 0; let d = Infinity; for (let j = 0; j < k; j++) { const c = centres[j]; const e = (L[i] - c[0]) ** 2 + (A[i] - c[1]) ** 2 + (B[i] - c[2]) ** 2; if (e < d) { d = e; best = j; } } of[i] = best; near[i] = d; } const sums = Array.from({ length: k }, () => [0, 0, 0]); weight.fill(0); for (let i = 0; i < used; i++) { const j = of[i]; weight[j] += w[i]; sums[j][0] += w[i] * L[i]; sums[j][1] += w[i] * A[i]; sums[j][2] += w[i] * B[i]; } let moved = false; for (let j = 0; j < k; j++) { // A centre no pixel chose: to the pixel furthest from its own. const c: [number, number, number] = weight[j] > 0 ? [sums[j][0] / weight[j], sums[j][1] / weight[j], sums[j][2] / weight[j]] : (() => { const i = furthest(); near[i] = 0; return [L[i], A[i], B[i]] as [number, number, number]; })(); if (c.some((v, e) => Math.abs(v - centres[j][e]) > 1e-5)) moved = true; centres[j] = c; } if (!moved) break; } const total = weight.reduce((s, x) => s + x, 0) || 1; const colours = centres.map(([l, a, b], j) => { const c = Math.hypot(a, b); const lift = c >= GREY && c < LIFT ? LIFT / c : 1; return { L: l, a: a * lift, b: b * lift, share: weight[j] / total }; }); colours.sort((x, y) => x.L - y.L); return { colours }; } /** The covers' colours already worked out (or being), by address; the oldest let go past `KEPT`. */ const known = new Map>(); const KEPT = 64; /** The colours of the cover at `url` (the page's own, as `coverUrl` gives it): null where it has none, or it does not * load. Each cover's are worked out once. */ export function coverPalette(url: string, n = 6): Promise { const key = `${n}:${url}`; const had = known.get(key); if (had) return had; const p = drawn(url).then( (pixels) => (pixels ? paletteOf(pixels, n) : null), () => null, ); known.set(key, p); if (known.size > KEPT) known.delete(known.keys().next().value!); return p; } /** The image at `url` drawn `SIZE` pixels a side: its pixels, or null where it does not load. */ async function drawn(url: string): Promise { const image = new Image(); image.decoding = 'async'; image.src = url; try { await image.decode(); } catch { return null; } const canvas = typeof OffscreenCanvas === 'function' ? new OffscreenCanvas(SIZE, SIZE) : Object.assign(document.createElement('canvas'), { width: SIZE, height: SIZE }); const g = canvas.getContext('2d', { willReadFrequently: true }) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null; if (!g) return null; g.imageSmoothingQuality = 'high'; g.drawImage(image, 0, 0, SIZE, SIZE); return g.getImageData(0, 0, SIZE, SIZE).data; } ===== src/lib/visuals/precheck.ts ===== // A recipe's precheck (decision 0046, amended): before the dice show a complete randomisation, it is run against the // playing track's own signals for half a minute (from where it plays), through the visuals' own mapping (aspects.ts, // the music as musicOf gives it), and measured (meters.ts); one that would go over the governor's limits is refused, // and the dice draw another. No picture is drawn: the flashes are judged from the brightness the whole picture would // pulse with (the glow, and the parts' lights), one tile for the screen, as the runtime's limit judges a quarter of it. // // Done in slices (`prechecking`), so a slow tablet keeps its frames: a step is a few hundredths of a millisecond. import { newAspects, stepAspects, wavesMotion, zoomsMotion } from './aspects'; import type { Beats } from './beats'; import { BUDGETED, budgetOf } from './budget'; import { settingsReport, type Report } from './effects'; import { flameOf, PRESET_KEYS } from './flame'; import { Meters, screenTile, type MeterValues } from './meters'; import { musicOf, newMusicState } from './music.svelte'; import { settingsOf, type Recipe, type Scope } from './recipe'; import { A } from './routes'; import { FLUID_FULL, type VisualSettings } from './settings.svelte'; import type { Signals } from './signals'; import { DIVE_MOST, diveTurnOf } from './zoom'; // The limits (budget.ts), here too for those that took them from the precheck. export { DEFAULT_LIMITS, type Limits } from './budget'; import { DEFAULT_LIMITS, type Limits } from './budget'; export type Checked = { ok: boolean; /** Each meter's most and mean over the run (after its first seconds, while the meters settle): the budgeted ones on * the governor's scale (budget.ts), the flashes as counted. */ worst: Partial; mean: Partial; /** The meters over their limits, by name. */ over: string[]; }; /** How long a recipe is run (s), at how many frames a second, and how long the meters settle first. */ export const SECONDS = 30; const FPS = 30; const SETTLE_S = 3; /** How bright the flame's dense parts are on the screen, about (relative luminance): the flashes' scale. */ const BRIGHT = 0.35; /** Steps a slice: about 1 ms on a PC (a run is 25 ms there, 2026-10-18), a few on a slow tablet. */ const SLICE = 30; /** The dive's turn a second at a dive rate of 1 for a look (zoom.ts `diveTurnOf`: the turn is in proportion to the * rate), kept by the look: fitting the part to dive into takes a few milliseconds on a PC, several times that on a * tablet, and a roll's eight recipes share their base's look. */ const diveTurns = new Map(); function diveTurnPerRate(settings: VisualSettings): number { const look = PRESET_KEYS.map((k) => settings[k]).join('|'); let turn = diveTurns.get(look); if (turn === undefined) { if (diveTurns.size > 64) diveTurns.clear(); turn = diveTurnOf(flameOf(settings), 1); diveTurns.set(look, turn); } return turn; } /** A report with `motion` more (a second). */ const withMotion = (r: Report, motion: number): Report => (motion > 0 ? { ...r, motion: (r.motion ?? 0) + motion } : r); /** The run, a slice at a time; its value the verdict. */ export function* prechecking( recipe: Recipe, base: VisualSettings, signals: Signals, beats: Beats | null, from: number, limits: Limits, seconds = SECONDS, scope: Scope = 'all', ): Generator { const settings = settingsOf(recipe, base, scope); // The dive's turn, as the visuals count it (the renderer's, beside each part's own); none where it cannot dive. const divePerRate = settings.dive > 0 ? diveTurnPerRate(settings) : 0; const aspects = newAspects(); const state = newMusicState(); const meters = new Meters(); const worst: Record = {}; const sum: Record = {}; let counted = 0; const dt = 1 / FPS; const frames = Math.round(seconds * FPS); for (let k = 0; k < frames; k++) { if (k > 0 && k % SLICE === 0) yield; const t = from + k * dt; const m = musicOf(signals, beats, t, true, { state, at: t * 1000, lighting: settings.lighting }); const a = stepAspects(aspects, m, settings, dt, { still: false, now: t * 1000, fluidFull: FLUID_FULL }); const lights = a.hits.reduce((s, h) => s + Math.max(0, h), 0) / Math.max(1, a.hits.length); const luminance = Math.min(1, BRIGHT * a.flash * (1 + 0.6 * lights)); meters.add({ t: k * dt, turnRates: divePerRate && a.dive ? a.rates.map((r) => r + DIVE_MOST * a.dive * divePerRate) : a.rates, shape: a.shape, weights: a.parts.map((p) => p.weight ?? 1), hue: a.parts.map((p) => p.hue), spread: a.parts.map((p) => p.spread ?? 1), scale: a.scale, saturation: a.saturation, // A moment (another symmetry, lens, effect…) is a new look, as the visuals count it (65.1: it was never counted). look: Object.values(a.moments).some(Boolean) ? 'moment' : null, symmetry: a.moments.symmetry, tiles: [screenTile(k * dt, luminance)], // What the look's effects add, the waves where nothing flows and the zooms' streaming out, as the visuals' meters // take them (65): so a recipe's effects, waves and zooms are held to the limits. extra: withMotion(settingsReport(settings, a.effects, undefined, false, a.routed), wavesMotion(a.given[A.waves], a.fluid) + zoomsMotion(a.tunnel, a.dive)), }); if (k * dt < SETTLE_S) continue; // The budgeted meters over their budget at the music's activity now (Wild at its middle), as the governor holds // them; so a limit means the same here as there, loud passage or quiet. const budget = budgetOf(1, 0.5, a.lit); const now = meters.now(); counted++; for (const [key, v] of Object.entries(now)) { if (typeof v !== 'number') continue; const x = (BUDGETED as readonly string[]).includes(key) ? v / budget : v; worst[key] = Math.max(worst[key] ?? 0, x); sum[key] = (sum[key] ?? 0) + x; } } const over = Object.entries(limits) .filter(([key, limit]) => limit !== undefined && (worst[key] ?? 0) > limit) .map(([key]) => key); const mean = Object.fromEntries(Object.entries(sum).map(([key, v]) => [key, v / Math.max(1, counted)])); return { ok: over.length === 0, worst: worst as Partial, mean: mean as Partial, over }; } /** The run all at once (for tests and the calibration). */ export function precheck(recipe: Recipe, base: VisualSettings, signals: Signals, beats: Beats | null, from: number, limits: Limits, seconds = SECONDS, scope: Scope = 'all'): Checked { const run = prechecking(recipe, base, signals, beats, from, limits, seconds, scope); for (;;) { const r = run.next(); if (r.done) return r.value; } } ===== src/lib/visuals/quality.ts ===== // Quality (decision 0050): one ceiling in place of Points, Steps and Resolution, on the same ladder the device's pace // walks (steps, resolution, points), so the two never disagree. 60 % is exactly what the defaults were before 65 // (2^19 points, 3 steps, the screen's default resolution); the pace takes it lower where frames come late and climbs // back no higher. Pure. /** Quality before anyone moves it: today's defaults. */ export const QUALITY = 0.6; /** The pixels the display pass draws by default: about a megapixel. The points fall into a buffer of at most 0.6 MP * anyway; drawing that out to every pixel of a large screen cost the most of a frame on integrated graphics (5.5 ms of * 22 at 2560 x 1600, measured 2026-10-17) and showed little more. */ const AUTO_PIXELS = 1.2e6; /** This screen's default resolution: up to `AUTO_PIXELS` of its own pixels (0.25..1 of them). */ export function autoResolution(devicePixels: number): number { return Math.min(1, Math.max(0.25, Math.sqrt(AUTO_PIXELS / Math.max(1, devicePixels)))); } /** What Quality `q` (0..1) lets the device draw at most on a screen of `devicePixels`: * - points (log2): 16 + 5q, so 64k at 0, 512k at 60 %, 2M at all of it; * - steps: 1 at 0, 3 at 60 %, 6 at all of it, whole; * - resolution (of the screen's own pixels): 60 % of its default at 0 (the pace's own lowest), the default at 60 %, * then on to all of the screen's pixels at 100 % (beyond the default only where the screen has them to give). */ export function ceilingOf(q: number, devicePixels: number): { points: number; steps: number; resolution: number } { const v = Math.min(1, Math.max(0, q)); const auto = autoResolution(devicePixels); const below = Math.min(1, v / QUALITY); const above = Math.max(0, (v - QUALITY) / (1 - QUALITY)); return { points: 16 + 5 * v, steps: Math.round(1 + 2 * below + 3 * above), resolution: v <= QUALITY ? auto * (0.6 + 0.4 * below) : auto + (1 - auto) * above, }; } ===== src/lib/visuals/rating.ts ===== // The rating page's pairs (decision 0046, amended): what the owner compares, two behaviours at a time on the music // that plays, for the governor's calibration. Four kinds of pair, in turn, so the ratings say both which behaviours // the owner likes and how far along each axis (Flash, Speed, the fluid, Intensity, how long lights ring) they like it: // // 0. a random recipe against Standard (production as it is); // 1. one recipe at the low and at the high end of one axis; // 2. two random recipes; // 3. Standard at the low and at the high end of one axis. // // Which of a pair is shown first is drawn too (A is always the first shown). Pure: the n-th pair of a series is the same // each time, so a series can be taken up again where it was left. import { recipeFor, settingsOf } from './recipe'; import type { VisualSettings } from './settings.svelte'; export type Axis = 'flash' | 'speed' | 'fluid' | 'intensity' | 'lighting'; /** Each axis's low and high end: an absolute setting, or (lighting) a factor on the bands' times. */ export const AXES: Record = { flash: [0.4, 1.6], speed: [0.5, 1.6], fluid: [0, 0.6], intensity: [0.4, 1], lighting: [0.5, 2], }; export type Shown = { kind: 'standard' } | { kind: 'recipe'; seed: number } | { kind: 'variant'; of: 'standard' | number; axis: Axis; end: 'low' | 'high' }; export type Pair = { n: number; kind: 0 | 1 | 2 | 3; a: Shown; b: Shown; swapped: boolean }; /** A number of `x` and `n` (a series' base and the pair's place), spread over 32 bits. */ function mix(x: number, n: number): number { let h = (x ^ Math.imul(n + 1, 0x9e3779b1)) >>> 0; h = Math.imul(h ^ (h >>> 16), 0x85ebca6b) >>> 0; h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0; return (h ^ (h >>> 16)) >>> 0; } /** The `n`-th pair of the series `base`. */ export function pairFor(n: number, base: number): Pair { const h = mix(base, n); const kind = (n % 4) as Pair['kind']; const seed = mix(h, 1); const axes = Object.keys(AXES) as Axis[]; // The axes in turn (every second pair has one): each comes up within ten pairs. const axis = axes[Math.floor(n / 2) % axes.length]; let a: Shown; let b: Shown; if (kind === 0) [a, b] = [{ kind: 'recipe', seed }, { kind: 'standard' }]; else if (kind === 1) [a, b] = [{ kind: 'variant', of: seed, axis, end: 'low' }, { kind: 'variant', of: seed, axis, end: 'high' }]; else if (kind === 2) [a, b] = [{ kind: 'recipe', seed }, { kind: 'recipe', seed: mix(h, 3) }]; else [a, b] = [{ kind: 'variant', of: 'standard', axis, end: 'low' }, { kind: 'variant', of: 'standard', axis, end: 'high' }]; const swapped = (mix(h, 4) & 1) === 1; return swapped ? { n, kind, a: b, b: a, swapped } : { n, kind, a, b, swapped }; } /** The settings `shown` stands for: on `standard` (production's behaviour on the device's own look and settings), a * recipe for `aspects` (routes.ts's ASPECTS). */ export function settingsFor(shown: Shown, standard: VisualSettings, aspects: readonly string[]): VisualSettings { if (shown.kind === 'standard') return standard; if (shown.kind === 'recipe') return settingsOf(recipeFor(shown.seed, aspects, standard.lighting.length), standard); const on = shown.of === 'standard' ? standard : settingsOf(recipeFor(shown.of, aspects, standard.lighting.length), standard); const v = AXES[shown.axis][shown.end === 'low' ? 0 : 1]; if (shown.axis === 'lighting') return { ...on, lighting: on.lighting.map((t) => Math.min(1, Math.max(0.02, t * v))) }; return { ...on, [shown.axis]: v }; } /** What a rating says of one of its pair: what it was (its recipe's extras, where it is one) and its meters. */ export function described(shown: Shown, aspects: readonly string[]): Record { const seed = shown.kind === 'recipe' ? shown.seed : shown.kind === 'variant' && shown.of !== 'standard' ? shown.of : null; return { ...shown, extras: seed === null ? [] : recipeFor(seed, aspects).extras }; } ===== src/lib/visuals/recipe.ts ===== // A complete randomisation's recipe (decision 0046, amended): not only another look (the stars), but another way of // following the music. The owner wanted it beside the look-only dice, kept apart from them; the design they agreed // with: a fixed backbone (what makes the visuals follow the music at all: loudness sets the size and colour, each band's // level shapes and turns its part, its hits light it, the beats swell it), each of its amounts varied a little, and 3 to // 6 more routes drawn from ones that make musical sense, each within its own cap; the settings that shape the motion // (Flash, Speed, Intensity, the fluid, the grain, the bands' times, each column's scale) varied within ranges that // keep it watchable. Then // a precheck runs the recipe against the track's own signals and refuses one that would flash or churn past the // governor's limits (`precheck`, with the frame's mapping the visuals use). // // Pure: the same seed gives the same recipe (every device of a sync group alike). import { BAND_SIGNALS as BAND_SIGNAL_LIST, LOOK_COLUMNS, routeKey, SCALE_KEYS, type Aspect, type Signal } from './routes'; import type { BEHAVIOUR_PINS, VisualSettings } from './settings.svelte'; export type RecipeSettings = { flash: number; speed: number; intensity: number; fluid: number; smoothing: number; haze: number; lighting: number[]; evolution: number[]; continuity: number[]; /** Each column's scale (64.2; settings.svelte.ts `COLUMN_SCALES`). */ scales: Record<(typeof SCALE_KEYS)[number], number>; /** The infinite zooms (65): most rolls neither. */ tunnel?: number; dive?: number; }; export type Recipe = { seed: number; /** Every route it sets (all others 0). */ routes: Record; settings: RecipeSettings; /** The extra routes it drew, by name (for the log and the rating page). */ extras: string[]; }; /** A route that makes musical sense, its amounts' range, and its family (no two of one family in a recipe). */ type Extra = { signal: Signal; aspect: string; lo: number; hi: number; family: string }; // Track signals go to the whole picture or every part; band signals to every band alike. const EXTRAS: Extra[] = [ { signal: 'beat', aspect: 'brightness', lo: 0.1, hi: 0.35, family: 'glow' }, { signal: 'downbeat', aspect: 'brightness', lo: 0.15, hi: 0.4, family: 'glow' }, { signal: 'swell', aspect: 'brightness', lo: 0.2, hi: 0.5, family: 'glow' }, { signal: 'downbeat', aspect: 'spread', lo: 0.1, hi: 0.4, family: 'spread' }, { signal: 'width', aspect: 'spread', lo: 0.2, hi: 0.6, family: 'spread' }, { signal: 'percussive', aspect: 'spread', lo: 0.1, hi: 0.4, family: 'spread' }, { signal: 'buildUp', aspect: 'size', lo: 0.1, hi: 0.35, family: 'size' }, { signal: 'beat', aspect: 'size', lo: 0.05, hi: 0.15, family: 'size' }, { signal: 'buildUp', aspect: 'trails', lo: 0.2, hi: 0.6, family: 'trails' }, { signal: 'loudness', aspect: 'trails', lo: -0.5, hi: -0.2, family: 'trails' }, { signal: 'harmony', aspect: 'hue', lo: 0.1, hi: 0.3, family: 'hue' }, { signal: 'key', aspect: 'hue', lo: 0.2, hi: 0.6, family: 'hue' }, { signal: 'bandSwell', aspect: 'hue', lo: 0.05, hi: 0.2, family: 'hue' }, { signal: 'centroid', aspect: 'saturation', lo: 0.1, hi: 0.4, family: 'colour' }, { signal: 'harmonic', aspect: 'saturation', lo: 0.1, hi: 0.3, family: 'colour' }, { signal: 'tempo', aspect: 'rotation', lo: 0.1, hi: 0.4, family: 'turn' }, { signal: 'flux', aspect: 'shape', lo: 0.2, hi: 0.5, family: 'shape' }, { signal: 'percussiveHits', aspect: 'lights', lo: 0.4, hi: 1, family: 'lights' }, { signal: 'crest', aspect: 'lights', lo: 0.2, hi: 0.6, family: 'lights' }, { signal: 'loudness', aspect: 'smoothing', lo: -0.4, hi: -0.1, family: 'grain' }, { signal: 'centroid', aspect: 'haze', lo: 0.1, hi: 0.3, family: 'grain' }, { signal: 'swell', aspect: 'fluid', lo: 0.2, hi: 0.8, family: 'fluid' }, { signal: 'section', aspect: 'look', lo: 0.3, hi: 1, family: 'look' }, { signal: 'downbeat', aspect: 'look', lo: 0.1, hi: 0.4, family: 'look' }, { signal: 'section', aspect: 'behaviour', lo: 0.2, hi: 0.6, family: 'behaviour' }, { signal: 'swell', aspect: 'behaviour', lo: 0.1, hi: 0.4, family: 'behaviour' }, ]; /** The most all of a recipe's routes into light and glow may add up to (flashes are the governor's first worry). */ const MOST_FLASHING = 1.6; /** Today's bands' times, which a recipe varies. */ const LIGHTING = [0.2, 0.18, 0.15, 0.13, 0.1, 0.08]; const EVOLUTION = [0.25, 0.25, 0.25, 0.25, 0.25, 0.25]; /** The columns' scales a recipe rolls (routes.ts `SCALE_KEYS`, in its order; drawn after everything else, so a seed's * routes and other settings are what they were before 64.2), each between this and all of it. 64.2 rolled from a half, * and the dice came out about a fifth calmer than 64.1's; the owner kept 64.1's liveliness (64.3: "Yes"). */ const LEAST_SCALE = 0.75; const BAND_SIGNALS = new Set(BAND_SIGNAL_LIST); /** Random numbers from a seed (mulberry32). */ function randomFrom(seed: number): () => number { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** The recipe of `seed`, for `bands` bands and the aspects there are (`aspects`: routes.ts's ASPECTS, so a route to an * aspect a version does not have is never drawn). */ export function recipeFor(seed: number, aspects: readonly string[], bands = 6): Recipe { const rnd = randomFrom(seed); const between = (lo: number, hi: number) => lo + (hi - lo) * rnd(); const r: Record = {}; // Aspects by name: a route to one this version lacks (Drift before 64) is not drawn. const put = (signal: Signal, aspect: string, v: number) => { if (!aspects.includes(aspect)) return; const a = aspect as Aspect; if (BAND_SIGNALS.has(signal)) for (let b = 0; b < bands; b++) r[routeKey(signal, b, a)] = v; else r[routeKey(signal, null, a)] = v; }; // The backbone, each amount within a range around today's. put('loudness', 'size', between(0.7, 1)); put('loudness', 'saturation', between(0.6, 1)); put('loudness', 'rotation', between(0.1, 0.4)); put('beat', 'brightness', between(0.1, 0.3)); put('beat', 'size', between(0.04, 0.1)); put('level', 'shape', between(0.6, 1)); put('level', 'rotation', between(0.3, 0.8)); put('level', 'hue', between(0.04, 0.12)); put('hits', 'lights', between(0.6, 1)); // 3 to 6 extras, no two of one family, none the backbone already has. const extras: string[] = []; const families = new Set(); const want = 3 + Math.floor(rnd() * 4); const pool = EXTRAS.filter((e) => aspects.includes(e.aspect)); for (let tries = 0; extras.length < want && tries < 40; tries++) { const e = pool[Math.floor(rnd() * pool.length)]; const key = routeKey(e.signal, BAND_SIGNALS.has(e.signal) ? 0 : null, e.aspect as Aspect); if (families.has(e.family) || r[key] !== undefined) continue; families.add(e.family); put(e.signal, e.aspect, between(e.lo, e.hi)); extras.push(`${e.signal} → ${e.aspect}`); } // The light and glow all together under their cap: scaled down as one where they would be more. const flashing = Object.entries(r).filter(([k]) => /\|(lights|brightness)$/.test(k)); const sum = (signalOf: (k: string) => string) => { // A band signal's routes count once (they act on their own bands), a track signal's once. const seen = new Set(); let total = 0; for (const [k, v] of flashing) { const s = signalOf(k); if (seen.has(s)) continue; seen.add(s); total += Math.max(0, v); } return total; }; const total = sum((k) => `${k.split('|')[0]}|${k.split('|')[2]}`); if (total > MOST_FLASHING) for (const [k, v] of flashing) r[k] = (v * MOST_FLASHING) / total; const settings: RecipeSettings = { flash: between(0.6, 1.4), speed: between(0.6, 1.5), intensity: between(0.6, 1), // Fluid's slider is the most a route gives it (64.1): where a route goes to it, some to give; else half the time // none. fluid: Object.keys(r).some((k) => k.endsWith('|fluid')) || rnd() < 0.5 ? between(0.2, 0.7) : 0, smoothing: between(0.3, 0.7), // Haze about the defaults' (65; in Depth's and Floor's old ranges, the floor's least). haze: between(-0.3, 0.3), lighting: LIGHTING.map((v) => v * 2 ** between(-0.7, 1)), evolution: EVOLUTION.map((v) => v * 2 ** between(-0.7, 1.3)), continuity: Array.from({ length: bands }, () => between(0.2, 0.9)), scales: Object.fromEntries(SCALE_KEYS.map((k) => [k, between(LEAST_SCALE, 1)])) as RecipeSettings['scales'], // The zooms, drawn last so a seed's other draws stay as they were: about one roll in three a tunnel, one in four a // dive, at no more than their middle. tunnel: rnd() < 0.7 ? 0 : between(0.2, 0.6), dive: rnd() < 0.75 ? 0 : between(0.2, 0.6), }; return { seed, routes: r, settings, extras }; } /** What the dice's double tap randomises (the owner: "double tap becomes this randomize 1 to 5"): how the visuals move * with the music, the routes into the flame's form, its lights, its turning and its size and those four columns' * scales, the turning's pace and the swing, and the bands' times; the look, its colours, the grain and smoke, * Intensity and every other route and scale stay. */ /** The columns of the fluid's stirring, which a roll leaves as they are. */ export const STIRRING = ['stir', 'flow', 'swirl'] as const; export const MOVEMENT = { columns: ['shape', 'lights', 'rotation', 'size'], settings: ['flash', 'speed', 'lighting', 'evolution', 'continuity', 'shape', 'lights', 'rotation', 'size'], } as const; /** The settings a recipe makes of `base`: its routes whole (every other route off) and its settings; `base`'s look, * pins, colours and what the device can afford (Quality, FPS, Brightness) kept, and of the rest * what the owner pinned: a behaviour slider, a route ('route:', on or off as it is). The Track row (what a new track does to the look and the behaviour) is base's: a * recipe is how the visuals move with the music, not what a new track brings. */ /** What a roll changes: everything, or how it moves (`MOVEMENT`). */ export type Scope = 'all' | 'movement'; export function settingsOf(recipe: Recipe, base: VisualSettings, scope: Scope = 'all'): VisualSettings { const within = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); const r = recipe.settings; const pinned = new Set(base.pins); // Of a movement roll, only the movement's columns are the recipe's. const moving = (key: string) => MOVEMENT.columns.some((c) => key.endsWith(`|${c}`)); // The Track row and the fluid's stirring (Stir, Flow, Swirl: how the music pushes the fluid, its defaults today's // stirring) are base's: a roll that dropped them would leave the fluid unstirred. const stirring = (key: string) => STIRRING.some((c) => key.endsWith(`|${c}`)); // The look's own columns (65.1) are base's too: what the look is, not how it follows the music; no recipe draws them. const lookColumn = (key: string) => (LOOK_COLUMNS as readonly string[]).includes(key.slice(key.lastIndexOf('|') + 1)); const kept = (key: string) => key.startsWith('track|') || stirring(key) || lookColumn(key) || pinned.has(`route:${key}`) || (scope === 'movement' && !moving(key)); const routes: VisualSettings['routes'] = {}; for (const [k, v] of Object.entries(recipe.routes)) if (!kept(k)) routes[k] = v; for (const [k, v] of Object.entries(base.routes)) if (kept(k)) routes[k] = v; // A behaviour slider pinned, or not the movement's in a movement roll, is base's. const takes = (k: string) => scope === 'all' || (MOVEMENT.settings as readonly string[]).includes(k); const one = (k: K, v: number) => (pinned.has(k) || !takes(k) ? base[k] : v); return { ...base, routes, flash: one('flash', within(r.flash, 0, 2)), speed: one('speed', within(r.speed, 0, 2)), intensity: one('intensity', within(r.intensity, 0, 1)), fluid: one('fluid', within(r.fluid, 0, 1)), smoothing: one('smoothing', within(r.smoothing, 0, 1)), haze: one('haze', within(r.haze ?? 0, -1, 1)), // Each band's times as the recipe has them, but a pinned one (`lighting:2`). lighting: takes('lighting') ? r.lighting.map((v, b) => (pinned.has(`lighting:${b}`) ? base.lighting[b] : within(v, 0.02, 1))) : base.lighting, evolution: takes('evolution') ? r.evolution.map((v, b) => (pinned.has(`evolution:${b}`) ? base.evolution[b] : within(v, 0.02, 2))) : base.evolution, continuity: takes('continuity') ? r.continuity.map((v, b) => (pinned.has(`continuity:${b}`) ? base.continuity[b] : within(v, 0, 1))) : base.continuity, // Each column's scale (a recipe from before 64.2 has none: all of each). ...(Object.fromEntries(SCALE_KEYS.map((k) => [k, one(k, within(r.scales?.[k] ?? 1, 0, 1))])) as RecipeSettings['scales']), // The zooms (a recipe from before 65 has none: off). tunnel: one('tunnel', within(r.tunnel ?? 0, 0, 1)), dive: one('dive', within(r.dive ?? 0, 0, 1)), }; } ===== src/lib/visuals/render.ts ===== // Drawing fractal flames on the GPU (decision 0046; the flames themselves are lib/visuals/flame.ts), in WebGL2: // - points run the chaos game in a vertex shader, one step per pass, their state (position, colour: a place in the // colour plane, random seed) carried from pass to pass by transform feedback; // - each pass also draws them, one pixel each, adding their colour into a half-float buffer that fades a // little every frame: the picture is the last second or so of points, which gives the many samples a flame needs // without drawing them all every frame, and a little motion blur; // - the screen shows that buffer by the paper's log-density display: brightness from the log of how many points fell // on a pixel, colour from their average, then gamma and vibrancy (its section 4), with a little blur where few fell; // - beside the colours, a second buffer counts how many of a pixel's points are of each of the music's bands' colours // (decision 0046, amended), so a band's transient lights its own colour where it lies; // - every two seconds both buffers are copied over through a pass that clears anything not a number or infinite: a // fade only multiplies, so one such value, once in, would stay a black speck for good. import { MARK, colourOf, PARTS, type Flame } from './flame'; import { FLOW_IN_LIGHT, Fluid, gridFor, WAVE_BEND, WAVE_K, type Flow, type Force, type Loop } from './fluid'; import { diveCamera, diveFit, diveTarget, type DiveFit } from './zoom'; import { EffectClocks, loopFunction, MOST_PICTURE, pictureShader } from './effects'; import { FlashMeter } from './flashmeter'; import type { Tiles } from './meters'; // The six parts, rotations up to eight arms and as many mirrors. const MAX_XFORMS = 22; const SLOTS = 8; /** The fluid as shown follows the Fluid setting (as its routes move it) over these (s) rising and falling, and is let go * under the least (of the fluid's own: a fiftieth of Fluid's whole range). */ const FLUID_RISE = 0.3; const FLUID_FALL = 1.5; const FLUID_LEAST = 0.002; // The Cratarr lens (flame.ts's `markOf`, `MARK`): a distance into a set of rings by an area share, written out ring by // ring for the shader; then the point into the mark's slice or the rest by its angle. const ringsGlsl = (name: string, rings: [number, number][]) => { const total = rings.reduce((s, [a, b]) => s + b * b - a * a, 0); const steps = rings.map(([a, b]) => { const area = b * b - a * a; return ` if (left < ${area.toFixed(6)}) return sqrt(${(a * a).toFixed(6)} + left);\n left -= ${area.toFixed(6)};`; }); return `float ${name}(float u) {\n float left = clamp(u, 0.0, 1.0) * ${total.toFixed(6)};\n${steps.join('\n')}\n return ${rings[rings.length - 1][1].toFixed(6)};\n}`; }; const MARK_GLSL = `${ringsGlsl('markRings', MARK.rings)} ${ringsGlsl('markSlice', MARK.slice)} vec2 mark(vec2 p, float r) { float t = (atan(p.y, p.x) + PI) / (2.0 * PI); float u = r / (1.0 + r); u *= u; float share = ${MARK.share.toFixed(6)}; bool inSlice = t < share; float to = inSlice ? -PI / 6.0 + (t / share) * (PI / 3.0) : PI / 6.0 + ((t - share) / (1.0 - share)) * (5.0 * PI / 3.0); float far = inSlice ? markSlice(u) : markRings(u); return far * vec2(cos(to), sin(to)); }`; /** Passes a point added to the running ones settles for, unseen, before it is drawn (flam3's fuse). */ const SETTLE = 30; const ITERATE = `#version 300 es precision highp float; precision highp int; layout(location = 0) in vec4 aState; layout(location = 1) in uint aSeed; out vec4 vState; flat out uint vSeed; out vec3 vColor; out vec4 vBandA; out vec2 vBandB; uniform int uCount; uniform vec3 uAbc[${MAX_XFORMS}]; uniform vec3 uDef[${MAX_XFORMS}]; uniform vec4 uMeta[${MAX_XFORMS}]; uniform ivec4 uVarId[${MAX_XFORMS * 2}]; uniform vec4 uVarW[${MAX_XFORMS * 2}]; // The parts' post maps (flam3's "post"), where uPostMask has their bit. uniform vec3 uPostAbc[${PARTS}]; uniform vec3 uPostDef[${PARTS}]; uniform int uPostMask; // The final transform, applied to where a point is drawn (not to where it goes on), where uHasFinal. uniform int uHasFinal; uniform vec3 uFinalAbc; uniform vec3 uFinalDef; uniform ivec4 uFinalVarId[2]; uniform vec4 uFinalVarW[2]; uniform vec4 uCamera; uniform vec2 uTurn; // The dive (zoom.ts): its part, how far into the loop (0..1: also the share of points drawn as that part's copy), and // the camera's zoom and turn about the part's fixed point (a complex factor, then the point). uniform int uDive; uniform int uDiveI; uniform float uDiveT; uniform vec4 uDiveZ; // The flame's colours: one chroma (OKLCH); each band's transform's colour as a point in the colour plane, and its // lightness (a point's is its bands', by how near its colour is to each). uniform float uChroma; uniform vec2 uBandHue[${PARTS}]; uniform float uBandLight[${PARTS}]; const float PI = 3.14159265358979; // Not a number, or infinite: from the bits, which no compiler's fast arithmetic can assume away (isnan can be). bool bad(float x) { return (floatBitsToUint(x) & 0x7f800000u) == 0x7f800000u; } // OKLab to sRGB (each 0..1), as flame.ts's colourOf. vec3 srgb(vec3 lab) { float l = lab.x + 0.3963377774 * lab.y + 0.2158037573 * lab.z; float m = lab.x - 0.1055613458 * lab.y - 0.0638541728 * lab.z; float s = lab.x - 0.0894841775 * lab.y - 1.2914855480 * lab.z; l = l * l * l; m = m * m * m; s = s * s * s; vec3 lin = vec3(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s); return pow(clamp(lin, 0.0, 1.0), vec3(1.0 / 2.2)); } uint next(inout uint s) { s ^= s << 13; s ^= s >> 17; s ^= s << 5; return s; } float rnd(inout uint s) { return float(next(s) >> 8) * (1.0 / 16777216.0); } // A variation of the transform's affine output p (abc, def: its coefficients, which some variations use). vec2 variation(int v, vec2 p, float r2, float r, float th, vec3 abc, vec3 def, inout uint s) { switch (v) { case 1: return sin(p); case 2: return p / r2; case 3: return vec2(p.x * sin(r2) - p.y * cos(r2), p.x * cos(r2) + p.y * sin(r2)); case 4: return vec2((p.x - p.y) * (p.x + p.y), 2.0 * p.x * p.y) / r; case 5: return vec2(th / PI, r - 1.0); case 6: return r * vec2(sin(th + r), cos(th - r)); case 7: return r * vec2(sin(th * r), -cos(th * r)); case 8: return (th / PI) * vec2(sin(PI * r), cos(PI * r)); case 9: return vec2(cos(th) + sin(r), sin(th) - cos(r)) / r; case 10: return vec2(sin(th) / r, r * cos(th)); case 11: return vec2(sin(th) * cos(r), cos(th) * sin(r)); case 12: { float p0 = pow(sin(th + r), 3.0); float p1 = pow(cos(th - r), 3.0); return r * vec2(p0 + p1, p0 - p1); } case 13: { float om = rnd(s) < 0.5 ? 0.0 : PI; return sqrt(r) * vec2(cos(th * 0.5 + om), sin(th * 0.5 + om)); } case 14: return vec2(p.x < 0.0 ? 2.0 * p.x : p.x, p.y < 0.0 ? p.y * 0.5 : p.y); case 16: return (2.0 / (r + 1.0)) * p.yx; case 18: return exp(p.x - 1.0) * vec2(cos(PI * p.y), sin(PI * p.y)); case 19: return pow(r, sin(th)) * vec2(cos(th), sin(th)); case 20: return vec2(cos(PI * p.x) * cosh(p.y), -sin(PI * p.x) * sinh(p.y)); case 27: return (2.0 / (r + 1.0)) * p; case 28: return (4.0 / (r2 + 4.0)) * p; case 29: return vec2(sin(p.x), p.y); case 42: return vec2(sin(p.x) / cos(p.y), tan(p.y)); case 48: return p / abs(p.x * p.x - p.y * p.y + 1e-12); case 15: return vec2(p.x + abc.y * sin(p.y / (abc.z * abc.z + 1e-6)), p.y + def.y * sin(p.x / (def.z * def.z + 1e-6))); case 17: return vec2(p.x + abc.z * sin(tan(3.0 * p.y)), p.y + def.z * sin(tan(3.0 * p.x))); case 21: { float k = abc.z * abc.z + 1e-6; return (mod(r + k, 2.0 * k) - k + r * (1.0 - k)) * vec2(cos(th), sin(th)); } case 22: { float k = PI * (abc.z * abc.z + 1e-6); float a = mod(th + def.z, k) > k * 0.5 ? th - k * 0.5 : th + k * 0.5; return r * vec2(cos(a), sin(a)); } case 31: { float p1 = rnd(s); float p2 = 2.0 * PI * rnd(s); return p1 * vec2(p.x * cos(p2), p.y * sin(p2)); } case 34: { float p1 = rnd(s); float p2 = 2.0 * PI * rnd(s); return p1 * vec2(cos(p2), sin(p2)); } case 41: { float a = rnd(s) * PI; return vec2(sin(a), sin(a) * sin(a) / cos(a)); } case 43: return vec2(rnd(s) - 0.5, rnd(s) - 0.5); case 44: return tan(rnd(s) * PI) / (r2 + 1e-6) * vec2(cos(p.x), sin(p.y)); case 45: { float a = rnd(s) * r; return p.x * vec2(cos(a) + sin(a), cos(a) - sin(a)); } case 46: { float cr = cos(r); return vec2(p.x, 1.0 / cr + (cr < 0.0 ? 1.0 : -1.0)); } case 47: { float a = rnd(s) * r; float sn = sin(a); float k = log(sn * sn + 1e-12) / log(10.0) + cos(a); if (bad(k)) k = -30.0; return p.x * vec2(k, k - sn * PI); } default: return p; } } // The final transform's warps only (flame.ts's FINALS): a second copy of the whole switch above for every point cost // the Intel a third more of its time a step. ${MARK_GLSL} vec2 warp(int v, vec2 p, float r2, float r, float th) { switch (v) { case 1: return sin(p); case 2: return p / r2; case 3: return vec2(p.x * sin(r2) - p.y * cos(r2), p.x * cos(r2) + p.y * sin(r2)); case 4: return vec2((p.x - p.y) * (p.x + p.y), 2.0 * p.x * p.y) / r; case 5: return vec2(th / PI, r - 1.0); case 6: return r * vec2(sin(th + r), cos(th - r)); case 7: return r * vec2(sin(th * r), -cos(th * r)); case 8: return (th / PI) * vec2(sin(PI * r), cos(PI * r)); case 13: return sqrt(r) * vec2(cos(th * 0.5), sin(th * 0.5)); case 16: return (2.0 / (r + 1.0)) * p.yx; case 27: return (2.0 / (r + 1.0)) * p; case 28: return (4.0 / (r2 + 4.0)) * p; case 99: return mark(p, r); default: return p; } } void main() { uint s = aSeed; // The transform, by the running sums of their weights in uMeta.x. float u = rnd(s); int i = uCount - 1; for (int k = 0; k < ${MAX_XFORMS}; k++) { if (k >= uCount) break; if (u < uMeta[k].x) { i = k; break; } } vec3 h = vec3(aState.xy, 1.0); vec2 q = vec2(dot(uAbc[i], h), dot(uDef[i], h)); float r2 = dot(q, q) + 1e-12; float r = sqrt(r2); float th = atan(q.x, q.y); vec2 p = vec2(0.0); for (int k = 0; k < ${SLOTS}; k++) { float w = uVarW[i * 2 + k / 4][k % 4]; if (w == 0.0) continue; p += w * variation(uVarId[i * 2 + k / 4][k % 4], q, r2, r, th, uAbc[i], uDef[i], s); } if (i < ${PARTS} && ((uPostMask >> i) & 1) == 1) p = vec2(dot(uPostAbc[i], vec3(p, 1.0)), dot(uPostDef[i], vec3(p, 1.0))); // The colour moves towards the transform's hue (uMeta.zw) by its speed (uMeta.y). vec2 c = mix(aState.zw, uMeta[i].zw, uMeta[i].y); bool lost = bad(p.x) || bad(p.y) || abs(p.x) > 1e5 || abs(p.y) > 1e5; if (lost) { // Back in at random; not drawn this time, as flam3 lets a new point settle before plotting it. p = vec2(rnd(s), rnd(s)) * 2.0 - 1.0; c = vec2(0.0); } // A colour that is not a number would stay one, and in the picture for good: grey again. if (bad(c.x) || bad(c.y) || dot(c, c) > 4.0) c = vec2(0.0); vState = vec4(p, c); vSeed = s; // How much of each band's colour the point is: by how near its colour is to each band's, shares of one. float near[${PARTS}]; float sum = 0.0; float light = 0.0; for (int k = 0; k < ${PARTS}; k++) { vec2 d = c - uBandHue[k]; // A bell of nearness, as exp(-12 d²) but without its cost (six a point a step). float n = 1.0 / (1.0 + 12.0 * dot(d, d)); near[k] = n * n * n; sum += near[k]; light += near[k] * uBandLight[k]; } sum = max(sum, 1e-6); vColor = srgb(vec3(light / sum, uChroma * c)); vBandA = vec4(near[0], near[1], near[2], near[3]) / sum; vBandB = vec2(near[4], near[5]) / sum; // The dive: some points drawn as the dived-to part's copy of them (that copy is the whole, smaller: drawn larger as // the loop goes on, it becomes the whole again), all through the camera's zoom about the part's fixed point. vec2 drawn = p; if (uDive == 1 && !lost) { uint s2 = s ^ 0x9e3779b9u; if (rnd(s2) < uDiveT) { int j = uDiveI; vec3 hj = vec3(p, 1.0); vec2 qj = vec2(dot(uAbc[j], hj), dot(uDef[j], hj)); float rj2 = dot(qj, qj) + 1e-12; float rj = sqrt(rj2); float thj = atan(qj.x, qj.y); vec2 pj = vec2(0.0); for (int k = 0; k < ${SLOTS}; k++) { float w = uVarW[j * 2 + k / 4][k % 4]; if (w == 0.0) continue; pj += w * variation(uVarId[j * 2 + k / 4][k % 4], qj, rj2, rj, thj, uAbc[j], uDef[j], s2); } if (j < ${PARTS} && ((uPostMask >> j) & 1) == 1) pj = vec2(dot(uPostAbc[j], vec3(pj, 1.0)), dot(uPostDef[j], vec3(pj, 1.0))); if (!(bad(pj.x) || bad(pj.y))) drawn = pj; } vec2 d = drawn - uDiveZ.zw; drawn = uDiveZ.zw + vec2(uDiveZ.x * d.x - uDiveZ.y * d.y, uDiveZ.y * d.x + uDiveZ.x * d.y); } // Where it is drawn: through the final transform, where the flame has one. vec2 shown = drawn; if (uHasFinal == 1 && !lost) { vec3 fh = vec3(drawn, 1.0); vec2 fq = vec2(dot(uFinalAbc, fh), dot(uFinalDef, fh)); float fr2 = dot(fq, fq) + 1e-12; float fr = sqrt(fr2); float fth = atan(fq.x, fq.y); // Its warp and the plain picture (flame.ts's randomFinal: two variations, the warp first). shown = uFinalVarW[0].x * warp(uFinalVarId[0].x, fq, fr2, fr, fth) + uFinalVarW[0].y * fq; if (bad(shown.x) || bad(shown.y)) lost = true; } // The camera: centred, the flame's size to the shorter side, turned slowly as a whole. vec2 v = (shown - uCamera.xy) * uCamera.z; v = vec2(uTurn.x * v.x - uTurn.y * v.y, uTurn.y * v.x + uTurn.x * v.y); if (uCamera.w > 1.0) v.x /= uCamera.w; else v.y *= uCamera.w; gl_Position = lost ? vec4(2.0, 2.0, 0.0, 1.0) : vec4(v, 0.0, 1.0); gl_PointSize = 1.0; }`; const PLOT = `#version 300 es precision mediump float; in vec3 vColor; in vec4 vBandA; in vec2 vBandB; layout(location = 0) out vec4 o; layout(location = 1) out vec4 bandA; layout(location = 2) out vec4 bandB; void main() { o = vec4(vColor, 1.0); bandA = vBandA; bandB = vec4(vBandB, 0.0, 0.0); }`; const FULL = `#version 300 es void main() { vec2 p = vec2(gl_VertexID == 1 ? 3.0 : -1.0, gl_VertexID == 2 ? 3.0 : -1.0); gl_Position = vec4(p, 0.0, 1.0); }`; const FADE = `#version 300 es precision mediump float; uniform float uKeep; layout(location = 0) out vec4 o; layout(location = 1) out vec4 bandA; layout(location = 2) out vec4 bandB; void main() { o = vec4(0.0, 0.0, 0.0, uKeep); bandA = o; bandB = o; }`; // The tunnel (zoom.ts), where nothing flows: the light kept, read nearer the middle (drawn larger), in place of the fade. const tunnelSource = (loop: string) => `#version 300 es precision highp float; uniform sampler2D uAcc; uniform sampler2D uBandsA; uniform sampler2D uBandsB; uniform vec2 uSize; uniform float uZoom; uniform float uKeep; uniform float uLoopA; uniform vec4 uLoopTime; uniform float uLoopFrames; uniform vec2 uLoopK; layout(location = 0) out vec4 o; layout(location = 1) out vec4 bandA; layout(location = 2) out vec4 bandB; bool bad(float x) { return (floatBitsToUint(x) & 0x7f800000u) == 0x7f800000u; } vec4 kept(vec4 v) { return bad(dot(v, vec4(1.0))) ? vec4(0.0) : v * uKeep; } ${loop} void main() { vec2 at = 0.5 + (gl_FragCoord.xy / uSize - 0.5) * uZoom; at = fx_loop(at, uLoopA, uLoopTime, uLoopFrames, uSize.x / uSize.y, uLoopK); o = kept(textureLod(uAcc, at, 0.0)); bandA = kept(textureLod(uBandsA, at, 0.0)); bandB = kept(textureLod(uBandsB, at, 0.0)); }`; /** The effects stage's targets: the two finished pictures and the picture (2), the buffer's size. */ type FxTargets = { tex: WebGLTexture[]; fbo: WebGLFramebuffer[]; w: number; h: number; cur: number; last: number }; // The picture slot's copy onto the screen: the finished picture (the buffer's size), scaled up as the show pass was. const COPY = `#version 300 es precision highp float; uniform sampler2D uOut; uniform vec2 uView; out vec4 o; void main() { o = vec4(textureLod(uOut, gl_FragCoord.xy / uView, 0.0).rgb, 1.0); }`; // Not a number, or infinite, from the bits (see ITERATE's). // A value not a number is nothing; an infinite one, the most a 16-bit float holds short of infinity (by its sign: a // comparison is false for one not a number). The Mali-G615's compiler gets a fragment shader wrong that tests the bits // and picks its answer from them too (0 came out as 60000, 0.12 as 0: a phone's picture white, with coloured noise; // measured 2026-10-18), so the bits only answer whether. const BAD = `bool bad(float x) { return (floatBitsToUint(x) & 0x7f800000u) == 0x7f800000u; } float clean1(float x) { return bad(x) ? (x > 0.0 ? 60000.0 : 0.0) : clamp(x, 0.0, 60000.0); } vec4 clean(vec4 v) { return vec4(clean1(v.x), clean1(v.y), clean1(v.z), clean1(v.w)); }`; // The scrub: both buffers copied, each value not a number or infinite made nothing (and the largest kept under what a // 16-bit float holds). const SCRUB = `#version 300 es precision highp float; uniform sampler2D uAcc; uniform sampler2D uBandsA; uniform sampler2D uBandsB; layout(location = 0) out vec4 o; layout(location = 1) out vec4 bandA; layout(location = 2) out vec4 bandB; ${BAD} void main() { ivec2 at = ivec2(gl_FragCoord.xy); o = clean(texelFetch(uAcc, at, 0)); bandA = clean(texelFetch(uBandsA, at, 0)); bandB = clean(texelFetch(uBandsB, at, 0)); }`; // The carry: the picture so far drawn into buffers of another size (the step-down's changes of resolution, a window // resized), each value as many old pixels' worth as a new one covers, so it shows as it did instead of starting over. const CARRY = `#version 300 es precision highp float; uniform sampler2D uAcc; uniform sampler2D uBandsA; uniform sampler2D uBandsB; uniform vec2 uSize; uniform float uArea; layout(location = 0) out vec4 o; layout(location = 1) out vec4 bandA; layout(location = 2) out vec4 bandB; ${BAD} void main() { vec2 uv = gl_FragCoord.xy / uSize; o = clean(textureLod(uAcc, uv, 0.0)) * uArea; bandA = clean(textureLod(uBandsA, uv, 0.0)) * uArea; bandB = clean(textureLod(uBandsB, uv, 0.0)) * uArea; }`; // Finds whether a buffer holds any such value: only those pixels pass (counted by an occlusion query). const FIND = `#version 300 es precision highp float; uniform sampler2D uAcc; uniform sampler2D uBandsA; uniform sampler2D uBandsB; out vec4 o; ${BAD} bool any4(vec4 v) { return bad(v.x) || bad(v.y) || bad(v.z) || bad(v.w); } void main() { ivec2 at = ivec2(gl_FragCoord.xy); if (!(any4(texelFetch(uAcc, at, 0)) || any4(texelFetch(uBandsA, at, 0)) || any4(texelFetch(uBandsB, at, 0)))) discard; o = vec4(1.0); }`; const SHOW = `#version 300 es precision highp float; uniform sampler2D uAcc; uniform vec2 uView; uniform float uK1; uniform float uK2; uniform float uGamma; uniform float uVibrancy; uniform float uZoom; uniform float uFlash; uniform sampler2D uBandsA; uniform sampler2D uBandsB; // Each band's light now (-1..3: above 0 its transients', below it dimmed), and its colour. uniform float uHit[${PARTS}]; uniform vec3 uBandRgb[${PARTS}]; uniform float uCeiling; uniform float uSaturation; // Smoothing: the most a pixel where few points fell is spread over (pixels of the buffer), and the buffer's levels // above its first that it may take that from (none where it has none). Floor: the faintest density shown (on the // display's scale, a.a * uK2; 0 for all). The fire's share: a blackbody's heat where the points are dense. uniform float uSmooth; uniform float uLevels; uniform float uFloor; uniform float uFire; // The fluid's flow (cells a second, its grid's), the seconds of it the picture is read back along (0: none), and a // cell's size in texture coordinates. uniform sampler2D uVelocity; uniform float uWarp; // The waves where nothing flows (fluid.ts): the picture bent across their way as their crest passes. uniform vec4 uWave; uniform float uWaveK; uniform vec2 uUvPerCell; out vec4 o; ${BAD} // A sample of the buffer, none of it anything but a number (whatever a driver did to it before the scrub came by). vec4 at(vec2 uv, float lod) { return clean(textureLod(uAcc, uv, lod)); } vec4 raw(vec2 uv, float lod) { return textureLod(uAcc, uv, lod); } // A blackbody's colours by its heat (0..1): black, a deep red, orange, yellow, white only at the very hottest. vec3 heat(float t) { return clamp(vec3(2.2 * t, 2.2 * t - 0.8, 4.0 * t - 3.3), 0.0, 1.0); } void main() { // A beat's swell: the picture drawn a little larger about its middle. vec2 uv = 0.5 + (gl_FragCoord.xy / uView - 0.5) / uZoom; if (uWarp > 0.0) uv -= uWarp * textureLod(uVelocity, uv, 0.0).xy * uUvPerCell; if (uWave.x != 0.0) { float aspect = uView.x / uView.y; vec2 n = uWave.yz; vec2 p = (uv - 0.5) * vec2(aspect, 1.0); uv += vec2(-n.y / aspect, n.x) * uWave.x * sin(uWaveK * dot(p, n) - uWave.w); } vec2 px = 1.0 / vec2(textureSize(uAcc, 0)); vec4 a = at(uv, 0.0); // The pixel's points by band: shares of one. vec4 shareA = clamp(clean(textureLod(uBandsA, uv, 0.0)) / max(a.a, 1e-6), 0.0, 1.0); vec2 shareB = clamp(clean(textureLod(uBandsB, uv, 0.0)).xy / max(a.a, 1e-6), 0.0, 1.0); // Element by element: the Mali-G615's compiler refuses an array constructor here ("no default precision defined for // variable 'float[6]'", 2026-10-18), which left that phone without visuals. float share[${PARTS}]; share[0] = shareA.x; share[1] = shareA.y; share[2] = shareA.z; share[3] = shareA.w; share[4] = shareB.x; share[5] = shareB.y; // The paper's density estimation, cheaply: where few points fell, the pixel is the mean of a wider ring around it // (up to uSmooth pixels out; past three, from a coarser level of the buffer, so the ring is a haze rather than // eight copies), where many fell it stays sharp. The ring is cleaned once, as a whole: a value not a number in it // costs that pixel its ring for a frame (the scrub clears it from the buffer), and cleaning each of its eight was // a third of a tablet's frame (Mali-G52: 13.6 ms to 9.3 ms, 2026-10-18). float radius = uSmooth / (1.0 + sqrt(a.a * uK2) * 2.0); if (radius > 0.4) { float lod = min(uLevels, max(0.0, log2(radius / 3.0))); vec2 d = px * radius; vec4 ring = raw(uv + vec2(d.x, 0.0), lod) + raw(uv - vec2(d.x, 0.0), lod) + raw(uv + vec2(0.0, d.y), lod) + raw(uv - vec2(0.0, d.y), lod) + raw(uv + d * 0.7071, lod) + raw(uv - d * 0.7071, lod) + raw(uv + vec2(d.x, -d.y) * 0.7071, lod) + raw(uv + vec2(-d.x, d.y) * 0.7071, lod); a = (a + clean(ring)) / 9.0; } if (a.a <= 0.0) { o = vec4(0.0, 0.0, 0.0, 1.0); return; } float x = a.a * uK2; float ls = uK1 * log(1.0 + x) / a.a; vec3 c = a.rgb * ls; float alpha = a.a * ls; // flam3's gamma threshold: below it the curve is a straight line, so the faintest pixels are not lifted into noise. const float T = 0.02; float g = alpha < T ? alpha / T * pow(T, 1.0 / uGamma) : pow(min(alpha, 1.0), 1.0 / uGamma); vec3 vib = c * (g / max(alpha, 1e-6)); vec3 flat_ = pow(max(c, 0.0), vec3(1.0 / uGamma)); // A beat's flash, after the gamma, so it shows as much in the faint parts as in the bright ones; a band's light // brightens the points of its colour and adds a glow of that colour, or (below 0) dims them, to 0.4 at -1. The // shares are of one, so the bands' lights are a mean here, never a sum. float lit = 0.0; vec3 glow = vec3(0.0); for (int k = 0; k < ${PARTS}; k++) { float h = uHit[k] * share[k]; lit += h; glow += max(h, 0.0) * uBandRgb[k]; } vec3 shown = mix(flat_, vib, uVibrancy); // Fire: the denser, the hotter, over the bands' own colours (which still tell them apart). shown = mix(shown, heat(g), 0.65 * uFire); shown = shown * uFlash * (1.0 + 0.6 * lit) + 0.2 * g * glow; // Floor: fainter than it, black (from half of it to twice it, so its edge does not crawl with the grain). if (uFloor > 0.0) shown *= smoothstep(-1.0, 1.0, log2(x / uFloor)); // Less colour where the music is quiet (towards its grey of the same brightness). shown = mix(vec3(dot(shown, vec3(0.299, 0.587, 0.114))), shown, uSaturation); // The most brightness shown, last: no flash or light goes past it. o = vec4(clamp(shown, 0.0, 1.0) * uCeiling, 1.0); }`; export type Shown = { /** Brightness, around 1. */ brightness: number; /** How much of the picture stays from one frame to the next (0..1). */ keep: number; gamma: number; vibrancy: number; /** The whole picture's turn, radians. */ turn: number; /** How large the flame is drawn: 1 as framed (smaller with more dark around it). */ scale: number; /** How much larger it shows about its middle (a beat's swell): 1 for none, never less. */ zoom: number; /** Brighter by this much as shown (a beat's flash): 1 for none. */ flash: number; /** Each part's light now, low to high (-1..3): its colour's points light up, or below 0 dim. */ hits: number[]; /** The most brightness shown (0..1), applied after everything else. */ ceiling: number; /** The display's exposure against the points' mean density (1: as the paper has it): the visuals' auto-exposure. */ exposure: number; /** How much of its colour it shows (0: grey, 1: all). */ saturation: number; /** The most a pixel where few points fell is spread over, in pixels of the buffer (0: none, the raw grain). */ smoothing: number; /** The faintest density shown, on the display's scale (0: all of it; 1: about the flame's mean). */ floor: number; /** How much of a fire's heat shows (0..1): the densest points yellow-white, the faintest a deep red. */ fire: number; /** How much the light flows (0..1, decision 0047): 0 fades it where it was drawn, as before; above, it is carried * along by the fluid (fluid.ts), pushed by `forces` and moving as `flow` says, `dt` seconds a frame, and the picture * is read back along the flow by `warp` seconds of it. */ fluid: number; /** The effects (effects.ts): the picture's chain (its ids and amounts by place, each times the Effects level), the * loop's effect and amount, and the track's own time they move with. None: as before. */ effects?: { ids: number[]; amounts: number[]; knobs?: readonly (readonly [number, number])[]; loop: number; loopAmount: number; loopKnobs?: readonly [number, number]; t: number; beat?: [number, number, number, number]; }; /** Facets (where the light flows: fluid.ts): how much (0..1), and on hexagons rather than squares. */ facets?: { amount: number; hex: boolean }; /** The infinite zooms (zoom.ts): how fast, each its log a second (0: off). */ tunnel: number; dive: number; forces: Force[]; flow: Flow; dt: number; warp: number; }; export class FlameRenderer { private gl: WebGL2RenderingContext; // The flash meter (flashmeter.ts), made the first time the screen is measured; out where it cannot be made. private meter: FlashMeter | null = null; private meterOut = false; private iterate: WebGLProgram; private plot: WebGLProgram; private fade: WebGLProgram; private show: WebGLProgram; private buffers: WebGLBuffer[] = []; private vaos: WebGLVertexArrayObject[] = []; private feedback: WebGLTransformFeedback; /** The buffers, two sets: the one in use (`cur`) and the one the next scrub copies into. */ private accs: WebGLTexture[] = []; private bandSets: WebGLTexture[] = []; private bandSetsB: WebGLTexture[] = []; private fbos: WebGLFramebuffer[] = []; private cur = 0; private scrubbing: WebGLProgram; /** The carry pass (the tunnel, the loop slot) by its loop effect's id (0: none). */ private carrying2 = new Map(); /** The effects stage: its programs by chain, its targets (the picture and the last two finished, the buffer's size, * made when a chain first asks, let go of ten seconds after the last), and the copy onto the screen. */ private fxPrograms = new Map(); private fx: FxTargets | null = null; /** The effects' own times (effects.ts), and this frame's length in sixtieths of a second (a stall counted as at most * four, so nothing that feeds back lurches). */ private clocks = new EffectClocks(); private fxFrames = 1; /** The effects stage's and the carry's uniforms' locations, by program. */ private locs = new WeakMap>(); /** When an effect's program was last made (`settling`), and the loop effects the fluid's light pass has had. */ private madeAt = -Infinity; private lightLoops = new Set(); private copying: WebGLProgram; /** The dive now (zoom.ts): its part and that part's fit, how far into the loop, when the fit was made and for which * flame's parts. */ private dive: { j: number; fit: DiveFit; t: number; at: number; parts: number } | null = null; /** How fast the dive turns the picture now (radians a second): the motion meter counts it with the parts' own. */ diveTurnRate = 0; private finding: WebGLProgram; private query: WebGLQuery | null = null; private frames = 0; /** How many scrubs found anything not a number or infinite in the buffers (the black specks' cause, if they come). */ scrubbed = 0; private acc: WebGLTexture | null = null; /** Beside `acc`: each pixel's points by band. */ private bands: WebGLTexture | null = null; private bandsB: WebGLTexture | null = null; private bandRgb = new Float32Array(PARTS * 3); /** The colours buffer's levels above its first (0 where they cannot be filtered). */ private levels = 0; /** The fluid the light flows in (decision 0047): made the first time the Fluid setting asks for it (nothing of it * before), let go after ten seconds unasked, and not made again where this device cannot (`fluidOut`). */ private fluid: Fluid | null = null; private fluidOut = false; private fluidUnwanted = 0; // The fluid as shown, and its read-back: followed rather than switched (2026-10-18: Fluid's routes crossing 0 stopped // the flow on the spot, its warp gone with it, and the smeared light fell apart into specks every time the music // tipped it). Rising over a third of a second, falling over a second and a half; while any is left the fluid keeps // flowing (its pushes stopped, its motion dying away), and only once it is all but nothing is the light faded as // without one, the same as a flow at rest. When the last frame was drawn, for the following's clock. private fluidShown = 0; private warpShown = 0; private shownAt = 0; private accFbo: WebGLFramebuffer | null = null; private empty: WebGLVertexArrayObject; private flip = 0; /** The points: how many run the chaos game (the first `count` of buffers holding `capacity`), and how many of those * are on the flame and drawn (`ready`): ones added later settle unseen for `settleLeft` more passes first. Fewer * points only draw fewer; more only add new ones (2026-10-18: each change started every point again at random, the * whole flame rebuilding, and on a tablet near its limit the step-down changed them every few seconds). */ private count = 0; private capacity = 0; private ready = 0; private settleLeft = 0; private carrying: WebGLProgram; /** The buffers being replaced at a resize, their picture carried into the new ones. */ private carryFrom: { accs: WebGLTexture[]; bands: WebGLTexture[]; bandsB: WebGLTexture[]; fbos: WebGLFramebuffer[]; cur: number; width: number; height: number } | null = null; private flame: Flame | null = null; private loc = new Map(); /** The buffer's kind: 32-bit floats where they can be blended into and filtered, else 16-bit (a pixel stops counting * near 2048 points, only in the densest cores, where the log display hardly shows it), else 8 bits (saturating * soon). 16-bit ones were the default in preview 56 (4 to 20 % quicker): the densest pixels reach some 60,000 points * (measured in 32-bit ones, 2026-10-18), against a 16-bit float's 65,504, and a GPU whose blending rounds up * (Direct3D allows it) carries them over into infinity, black for good: a GTX 1650 showed such specks from 56 on, a * tablet's Mali-G52 (16-bit then, a GPU that rounds to nearest, stuck at 2048) never did. 32-bit ones that cannot be * filtered (that tablet's Mali-G52 blends them but has no OES_texture_float_linear, 2026-10-18; a phone's Mali-G615 * has it) were read pixel by pixel and scaled up to the screen blocky: the tablet's picture grainier in 59, and no * coarser levels for the smoothing. */ precision: 32 | 16 | 8; get halfFloat(): boolean { return this.precision !== 8; } /** Steps every point takes unseen before it is drawn (flam3's fuse): a new point is not on the flame yet. */ private fuse = 30; width = 0; height = 0; /** `points`: how many carry the chaos game at first. `power`: which GPU a computer with two should draw on (the * fast one: by default a browser may take the one that saves power, several times slower). */ constructor(canvas: HTMLCanvasElement, points: number, power: WebGLPowerPreference = 'high-performance') { const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, depth: false, stencil: false, preserveDrawingBuffer: false, powerPreference: power }); if (!gl) throw new Error('WebGL2 is not available.'); this.gl = gl; const floats = !!gl.getExtension('EXT_color_buffer_float'); this.precision = floats && gl.getExtension('EXT_float_blend') && gl.getExtension('OES_texture_float_linear') ? 32 : floats || gl.getExtension('EXT_color_buffer_half_float') ? 16 : 8; this.iterate = program(gl, ITERATE, PLOT, ['vState', 'vSeed']); this.plot = this.iterate; this.fade = program(gl, FULL, FADE); this.show = program(gl, FULL, SHOW); this.scrubbing = program(gl, FULL, SCRUB); this.copying = program(gl, FULL, COPY); this.finding = program(gl, FULL, FIND); this.carrying = program(gl, FULL, CARRY); this.empty = gl.createVertexArray()!; this.feedback = gl.createTransformFeedback()!; this.seed(points); } /** How many points carry the chaos game: fewer where the device cannot keep up (they start again at random). */ setPoints(n: number) { if (n === this.count) return; if (n > this.capacity) this.reserve(n); if (n < this.count) { this.count = n; this.ready = Math.min(this.ready, n); return; } // The new ones settle unseen over the next passes (a frame's own, not thirty at once), then are drawn. this.count = n; this.settleLeft = SETTLE; } /** Whether points added lately are still settling, or a program was just made (an effect's, as it is first chosen: * making it makes a frame or two late, some while after): the frames meanwhile are not the device's usual pace. */ get settling(): boolean { return this.ready < this.count || performance.now() - this.madeAt < 600; } /** Buffers for `n` points: the ones running kept where they are, the rest new at random. */ private reserve(n: number) { if (n <= this.capacity) return; const gl = this.gl; const old = this.buffers; const latest = old[this.flip]; const data = new ArrayBuffer(n * 20); const f = new Float32Array(data); const u = new Uint32Array(data); for (let i = this.count; i < n; i++) { f[i * 5] = Math.random() * 2 - 1; f[i * 5 + 1] = Math.random() * 2 - 1; u[i * 5 + 4] = (Math.random() * 0xfffffffe + 1) >>> 0; } for (const v of this.vaos) gl.deleteVertexArray(v); this.buffers = []; this.vaos = []; for (let k = 0; k < 2; k++) { const b = gl.createBuffer()!; gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_COPY); if (latest && this.count) { gl.bindBuffer(gl.COPY_READ_BUFFER, latest); gl.copyBufferSubData(gl.COPY_READ_BUFFER, gl.ARRAY_BUFFER, 0, 0, this.count * 20); } this.buffers.push(b); this.vaos.push(this.vaoOf(b)); } gl.bindBuffer(gl.COPY_READ_BUFFER, null); gl.bindBuffer(gl.ARRAY_BUFFER, null); gl.bindVertexArray(null); for (const b of old) gl.deleteBuffer(b); this.capacity = n; } private vaoOf(b: WebGLBuffer): WebGLVertexArrayObject { const gl = this.gl; const vao = gl.createVertexArray()!; gl.bindVertexArray(vao); gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 20, 0); gl.enableVertexAttribArray(1); gl.vertexAttribIPointer(1, 1, gl.UNSIGNED_INT, 20, 16); return vao; } get pointCount(): number { return this.count; } /** The points, at random in the square, each with its own seed. */ private seed(n: number) { const gl = this.gl; // Five numbers a point: its place, its colour (grey to start with), its random seed. const data = new ArrayBuffer(n * 20); const f = new Float32Array(data); const u = new Uint32Array(data); for (let i = 0; i < n; i++) { f[i * 5] = Math.random() * 2 - 1; f[i * 5 + 1] = Math.random() * 2 - 1; u[i * 5 + 4] = (Math.random() * 0xfffffffe + 1) >>> 0; } for (let k = 0; k < 2; k++) { const b = gl.createBuffer()!; gl.bindBuffer(gl.ARRAY_BUFFER, b); gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_COPY); this.buffers.push(b); this.vaos.push(this.vaoOf(b)); } gl.bindVertexArray(null); gl.bindBuffer(gl.ARRAY_BUFFER, null); this.count = n; this.capacity = n; this.ready = 0; } /** The drawing size in pixels (the buffer the points fall into; the screen scales it). */ resize(width: number, height: number) { if (width === this.width && height === this.height) return; const gl = this.gl; this.fluid?.resize(...gridFor(width, height)); // The old buffers kept until the new ones hold their picture (a failed try at a kind a driver refused: dropped). if (!this.carryFrom && this.accs.length && this.width && this.height) { this.carryFrom = { accs: this.accs, bands: this.bandSets, bandsB: this.bandSetsB, fbos: this.fbos, cur: this.cur, width: this.width, height: this.height }; } else { for (const x of [...this.accs, ...this.bandSets, ...this.bandSetsB]) gl.deleteTexture(x); for (const f of this.fbos) gl.deleteFramebuffer(f); } this.width = width; this.height = height; this.accs = []; this.bandSets = []; this.bandSetsB = []; this.fbos = []; // 32-bit float textures filter only with an extension; without it the screen reads them nearest. const smooth = this.precision !== 32 || !!gl.getExtension('OES_texture_float_linear'); // The colours in the buffer's kind; the bands' shares (only ever weighed against the count beside them) in 16-bit // floats at most: each point writes all three, and 32-bit ones for all cost the Intel two of its three steps. const texture = (bits: 32 | 16 | 8 = this.precision) => { const tex = gl.createTexture()!; gl.bindTexture(gl.TEXTURE_2D, tex); if (bits === 32) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, width, height, 0, gl.RGBA, gl.FLOAT, null); else if (bits === 16) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, width, height, 0, gl.RGBA, gl.HALF_FLOAT, null); else gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, smooth ? gl.LINEAR : gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, smooth ? gl.LINEAR : gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); return tex; }; for (let k = 0; k < 2; k++) { const shares = this.precision === 32 ? 16 : this.precision; this.accs.push(texture()); this.bandSets.push(texture(shares)); this.bandSetsB.push(texture(shares)); const fbo = gl.createFramebuffer()!; gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.accs[k], 0); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT1, gl.TEXTURE_2D, this.bandSets[k], 0); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT2, gl.TEXTURE_2D, this.bandSetsB[k], 0); gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1, gl.COLOR_ATTACHMENT2]); // A driver may still refuse the kind it offered: one step down, and again. if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE && this.precision !== 8) { gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.deleteFramebuffer(fbo); this.precision = this.precision === 32 ? 16 : 8; const kept = this.carryFrom; this.width = 0; this.carryFrom = kept; this.resize(width, height); return; } this.fbos.push(fbo); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); } this.cur = 0; this.use(); this.carry(); gl.bindFramebuffer(gl.FRAMEBUFFER, null); // The colours' coarser levels, for a wide smoothing (made each frame it is asked for; here only so the texture is // complete with a filter that reads them), where they can be filtered. this.levels = smooth ? Math.floor(Math.log2(Math.max(width, height))) : 0; if (this.levels) for (const acc of this.accs) { gl.bindTexture(gl.TEXTURE_2D, acc); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR); gl.generateMipmap(gl.TEXTURE_2D); } } /** The old buffers' picture into the new set in use, then the old ones gone. */ private carry() { const old = this.carryFrom; this.carryFrom = null; if (!old) return; const gl = this.gl; gl.bindFramebuffer(gl.FRAMEBUFFER, this.accFbo); gl.viewport(0, 0, this.width, this.height); gl.disable(gl.BLEND); gl.useProgram(this.carrying); const at = (n: string) => gl.getUniformLocation(this.carrying, n); [old.accs[old.cur], old.bands[old.cur], old.bandsB[old.cur]].forEach((t, i) => { gl.activeTexture(gl.TEXTURE0 + i); gl.bindTexture(gl.TEXTURE_2D, t); }); gl.uniform1i(at('uAcc'), 0); gl.uniform1i(at('uBandsA'), 1); gl.uniform1i(at('uBandsB'), 2); gl.uniform2f(at('uSize'), this.width, this.height); gl.uniform1f(at('uArea'), (old.width * old.height) / (this.width * this.height)); gl.bindVertexArray(this.empty); gl.drawArrays(gl.TRIANGLES, 0, 3); gl.bindVertexArray(null); gl.activeTexture(gl.TEXTURE0); for (const x of [...old.accs, ...old.bands, ...old.bandsB]) gl.deleteTexture(x); for (const f of old.fbos) gl.deleteFramebuffer(f); } /** The set in use as `acc`, `bands` and `accFbo`. */ private use() { this.acc = this.accs[this.cur]; this.bands = this.bandSets[this.cur]; this.bandsB = this.bandSetsB[this.cur]; this.accFbo = this.fbos[this.cur]; } /** Copies the buffers into the other set, clearing anything not a number or infinite; first asks the GPU whether * there is any (answered a few frames later). */ private scrub() { const gl = this.gl; const other = 1 - this.cur; gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbos[other]); gl.viewport(0, 0, this.width, this.height); gl.bindVertexArray(this.empty); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.accs[this.cur]); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this.bandSets[this.cur]); gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, this.bandSetsB[this.cur]); if (!this.query) { this.query = gl.createQuery(); gl.useProgram(this.finding); gl.uniform1i(gl.getUniformLocation(this.finding, 'uAcc'), 0); gl.uniform1i(gl.getUniformLocation(this.finding, 'uBandsA'), 1); gl.uniform1i(gl.getUniformLocation(this.finding, 'uBandsB'), 2); gl.colorMask(false, false, false, false); gl.beginQuery(gl.ANY_SAMPLES_PASSED_CONSERVATIVE, this.query!); gl.drawArrays(gl.TRIANGLES, 0, 3); gl.endQuery(gl.ANY_SAMPLES_PASSED_CONSERVATIVE); gl.colorMask(true, true, true, true); } gl.useProgram(this.scrubbing); gl.uniform1i(gl.getUniformLocation(this.scrubbing, 'uAcc'), 0); gl.uniform1i(gl.getUniformLocation(this.scrubbing, 'uBandsA'), 1); gl.uniform1i(gl.getUniformLocation(this.scrubbing, 'uBandsB'), 2); gl.drawArrays(gl.TRIANGLES, 0, 3); gl.activeTexture(gl.TEXTURE0); this.cur = other; this.use(); } /** The answer to the last scrub's question, once the GPU has it. */ private heard() { const gl = this.gl; if (!this.query || !gl.getQueryParameter(this.query, gl.QUERY_RESULT_AVAILABLE)) return; if (gl.getQueryParameter(this.query, gl.QUERY_RESULT)) this.scrubbed++; gl.deleteQuery(this.query); this.query = null; } /** The flame to draw from now on (its transforms and colours as uniforms). */ setFlame(flame: Flame) { const gl = this.gl; this.flame = flame; const xs = flame.xforms.slice(0, MAX_XFORMS); const total = xs.reduce((s, x) => s + x.weight, 0) || 1; const abc = new Float32Array(MAX_XFORMS * 3); const def = new Float32Array(MAX_XFORMS * 3); const meta = new Float32Array(MAX_XFORMS * 4); const ids = new Int32Array(MAX_XFORMS * SLOTS); const ws = new Float32Array(MAX_XFORMS * SLOTS); let run = 0; xs.forEach((x, i) => { const [a, b, c, d, e, f] = x.affine; abc.set([a, b, c], i * 3); def.set([d, e, f], i * 3); run += x.weight / total; const reach = x.colorReach ?? 1; meta.set([i === xs.length - 1 ? 1.0001 : run, x.colorSpeed, reach * Math.cos(2 * Math.PI * x.color), reach * Math.sin(2 * Math.PI * x.color)], i * 4); [...x.variations].slice(0, SLOTS).forEach(([v, w], k) => { ids[i * SLOTS + k] = v; ws[i * SLOTS + k] = w; }); }); gl.useProgram(this.iterate); gl.uniform1i(this.u(this.iterate, 'uCount'), xs.length); gl.uniform3fv(this.u(this.iterate, 'uAbc'), abc); gl.uniform3fv(this.u(this.iterate, 'uDef'), def); gl.uniform4fv(this.u(this.iterate, 'uMeta'), meta); gl.uniform4iv(this.u(this.iterate, 'uVarId'), ids); gl.uniform4fv(this.u(this.iterate, 'uVarW'), ws); // The parts' post maps. const postAbc = new Float32Array(PARTS * 3); const postDef = new Float32Array(PARTS * 3); let mask = 0; xs.slice(0, PARTS).forEach((x, i) => { if (!x.post) return; mask |= 1 << i; postAbc.set([x.post[0], x.post[1], x.post[2]], i * 3); postDef.set([x.post[3], x.post[4], x.post[5]], i * 3); }); gl.uniform1i(this.u(this.iterate, 'uPostMask'), mask); gl.uniform3fv(this.u(this.iterate, 'uPostAbc'), postAbc); gl.uniform3fv(this.u(this.iterate, 'uPostDef'), postDef); // The final transform. const final = flame.final; gl.uniform1i(this.u(this.iterate, 'uHasFinal'), final ? 1 : 0); if (final) { gl.uniform3f(this.u(this.iterate, 'uFinalAbc'), final.affine[0], final.affine[1], final.affine[2]); gl.uniform3f(this.u(this.iterate, 'uFinalDef'), final.affine[3], final.affine[4], final.affine[5]); // The shader takes one warp and the plain picture: the strongest warp (while two finals blend, the other one is // left out until it is the stronger), and the linear weight. const fid = new Int32Array(8); const fw = new Float32Array(8); let strongest: [number, number] = [0, 0]; for (const [v, w] of final.variations) if (v !== 0 && w > strongest[1]) strongest = [v, w]; fid[0] = strongest[0]; fw[0] = strongest[1]; fw[1] = final.variations.get(0) ?? 0; gl.uniform4iv(this.u(this.iterate, 'uFinalVarId'), fid); gl.uniform4fv(this.u(this.iterate, 'uFinalVarW'), fw); } // The bands' transforms are the first six (flame.ts): each one's colour, a point in the colour plane. const parts = Array.from({ length: PARTS }, (_, b) => xs[b] ?? { color: b / PARTS, colorReach: 1 }); const lights = parts.map((_, b) => flame.colours.lights?.[b] ?? flame.colours.light); gl.uniform1f(this.u(this.iterate, 'uChroma'), flame.colours.chroma); gl.uniform2fv( this.u(this.iterate, 'uBandHue'), parts.flatMap((x) => [(x.colorReach ?? 1) * Math.cos(2 * Math.PI * x.color), (x.colorReach ?? 1) * Math.sin(2 * Math.PI * x.color)]), ); gl.uniform1fv(this.u(this.iterate, 'uBandLight'), lights); parts.forEach((x, b) => this.bandRgb.set(colourOf(lights[b], flame.colours.chroma * (x.colorReach ?? 1), x.color), b * 3)); } /** One frame: the old picture fades by `look.keep`, `passes` more steps of every point fall into it, and it shows. */ frame(passes: number, look: Shown, viewWidth: number, viewHeight: number) { const [screenWidth, screenHeight] = [viewWidth, viewHeight]; const gl = this.gl; const flame = this.flame; if (!flame || !this.acc || !this.accFbo) return; // Every 120 frames (two to four seconds), the scrub. this.heard(); if (++this.frames % 120 === 0) this.scrub(); gl.bindFramebuffer(gl.FRAMEBUFFER, this.accFbo); gl.viewport(0, 0, this.width, this.height); gl.enable(gl.BLEND); // Fade what is there; with the Fluid setting, carry it along the flow as it fades, into the other set of buffers // (as the scrub copies), and draw on there. const at = performance.now(); const since = this.shownAt ? Math.min(0.25, (at - this.shownAt) / 1000) : 0; this.shownAt = at; const follow = (shown: number, to: number) => shown + (to - shown) * (1 - Math.exp(-since / (to > shown ? FLUID_RISE : FLUID_FALL))); this.fluidShown = follow(this.fluidShown, Math.max(0, look.fluid)); this.warpShown = follow(this.warpShown, Math.max(0, look.warp)); if (this.fluidShown < FLUID_LEAST && look.fluid < FLUID_LEAST) this.fluidShown = this.warpShown = 0; const fluid = this.fluidFor(this.fluidShown); // The loop slot's effect, where there is one. const fxl = look.effects; this.fxFrames = Math.min(4, since * 60); if (fxl) this.clocks.run(fxl); const loop = fxl && fxl.loop && fxl.loopAmount > 0 ? { id: fxl.loop, amount: fxl.loopAmount, time: [...this.clocks.times[3], ...this.clocks.moved[3]] as Loop['time'], frames: this.fxFrames, knobs: fxl.loopKnobs } : null; if (fluid) { if (loop && !this.lightLoops.has(loop.id)) { this.lightLoops.add(loop.id); this.madeAt = performance.now(); } fluid.push(look.forces); fluid.step(look.dt, look.flow); const other = 1 - this.cur; fluid.advect({ acc: this.acc!, bandsA: this.bands!, bandsB: this.bandsB! }, this.fbos[other], look.keep, this.width, this.height, this.tunnelZoom(look.tunnel, since), loop ? { key: String(loop.id), glsl: loopFunction(loop.id, FLOW_IN_LIGHT), ...loop } : undefined, look.facets); this.cur = other; this.use(); gl.bindFramebuffer(gl.FRAMEBUFFER, this.accFbo); gl.viewport(0, 0, this.width, this.height); gl.enable(gl.BLEND); } else if (look.tunnel > 0 || loop) { this.tunnel(look.keep, this.tunnelZoom(look.tunnel, since), loop); gl.bindFramebuffer(gl.FRAMEBUFFER, this.accFbo); gl.viewport(0, 0, this.width, this.height); gl.enable(gl.BLEND); } else { gl.useProgram(this.fade); gl.uniform1f(this.u(this.fade, 'uKeep'), look.keep); gl.blendFunc(gl.ZERO, gl.SRC_ALPHA); gl.bindVertexArray(this.empty); gl.drawArrays(gl.TRIANGLES, 0, 3); } // The points: each pass one step of the chaos game for every point, drawn where it lands. gl.useProgram(this.iterate); gl.uniform4f(this.u(this.iterate, 'uCamera'), flame.center[0], flame.center[1], look.scale / flame.size, this.width / this.height); gl.uniform2f(this.u(this.iterate, 'uTurn'), Math.cos(look.turn), Math.sin(look.turn)); this.diveStep(look.dive, since); gl.blendFunc(gl.ONE, gl.ONE); gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, this.feedback); const unseen = this.fuse; this.fuse = 0; if (unseen) gl.enable(gl.RASTERIZER_DISCARD); for (let p = 0; p < unseen + passes; p++) { if (p === unseen && unseen) { gl.disable(gl.RASTERIZER_DISCARD); this.ready = this.count; } const from = this.flip; const to = 1 - from; gl.bindVertexArray(this.vaos[from]); // The points on the flame, drawn (all of them unseen while the whole set first settles), then any still settling, // unseen; each written to its own part of the other buffer. const drawn = p < unseen ? 0 : this.ready; if (drawn) { gl.bindBufferRange(gl.TRANSFORM_FEEDBACK_BUFFER, 0, this.buffers[to], 0, drawn * 20); gl.beginTransformFeedback(gl.POINTS); gl.drawArrays(gl.POINTS, 0, drawn); gl.endTransformFeedback(); } const rest = this.count - drawn; if (rest) { if (drawn) gl.enable(gl.RASTERIZER_DISCARD); gl.bindBufferRange(gl.TRANSFORM_FEEDBACK_BUFFER, 0, this.buffers[to], drawn * 20, rest * 20); gl.beginTransformFeedback(gl.POINTS); gl.drawArrays(gl.POINTS, drawn, rest); gl.endTransformFeedback(); if (drawn) gl.disable(gl.RASTERIZER_DISCARD); } gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, null); this.flip = to; if (p >= unseen && this.ready < this.count && --this.settleLeft <= 0) this.ready = this.count; } // Drawn again whatever the passes were (a first frame of none would leave every later draw discarded). gl.disable(gl.RASTERIZER_DISCARD); gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null); gl.bindVertexArray(null); gl.disable(gl.BLEND); // Show it. The points held at a time: each frame's, kept 1/(1 − keep) frames; their mean per pixel sets k2. Where a // chain of effects is set, into the picture at the buffer's size, for the chain's pass to finish. const chain = this.chainFor(look.effects); const into = chain ? this.fxTargets() : null; gl.bindFramebuffer(gl.FRAMEBUFFER, into ? into.fbo[2] : null); if (into) viewWidth = this.width, viewHeight = this.height; gl.viewport(0, 0, viewWidth, viewHeight); gl.useProgram(this.show); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.acc); gl.uniform1i(this.u(this.show, 'uAcc'), 0); gl.uniform2f(this.u(this.show, 'uView'), viewWidth, viewHeight); const held = (Math.max(1, this.ready) * passes) / Math.max(0.02, 1 - look.keep); const perPixel = held / (this.width * this.height); gl.uniform1f(this.u(this.show, 'uK1'), look.brightness); gl.uniform1f(this.u(this.show, 'uK2'), ((this.precision === 8 ? 255 : 4) * look.exposure) / perPixel); gl.uniform1f(this.u(this.show, 'uGamma'), look.gamma); gl.uniform1f(this.u(this.show, 'uVibrancy'), look.vibrancy); gl.uniform1f(this.u(this.show, 'uZoom'), look.zoom); gl.uniform1f(this.u(this.show, 'uFlash'), look.flash); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this.bands); gl.uniform1i(this.u(this.show, 'uBandsA'), 1); gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, this.bandsB); gl.uniform1i(this.u(this.show, 'uBandsB'), 2); gl.activeTexture(gl.TEXTURE0); gl.uniform1fv(this.u(this.show, 'uHit'), Float32Array.from({ length: PARTS }, (_, k) => look.hits[k] ?? 0)); gl.uniform3fv(this.u(this.show, 'uBandRgb'), this.bandRgb); gl.uniform1f(this.u(this.show, 'uCeiling'), look.ceiling); gl.uniform1f(this.u(this.show, 'uSaturation'), look.saturation); // A smoothing wider than three pixels takes its ring from the buffer's coarser levels, made now (only then: the // levels cost a pass over the buffer). const levels = look.smoothing > 3 ? this.levels : 0; if (levels) { gl.bindTexture(gl.TEXTURE_2D, this.acc); gl.generateMipmap(gl.TEXTURE_2D); } gl.uniform1f(this.u(this.show, 'uSmooth'), look.smoothing); gl.uniform1f(this.u(this.show, 'uLevels'), levels); gl.uniform1f(this.u(this.show, 'uFloor'), look.floor); gl.uniform1f(this.u(this.show, 'uFire'), look.fire); const warp = fluid ? this.warpShown : 0; gl.uniform1f(this.u(this.show, 'uWarp'), warp); // Where the fluid flows it carries the waves itself; else the picture is bent by them. const wave = fluid ? undefined : look.flow?.wave; gl.uniform4f(this.u(this.show, 'uWave'), wave ? WAVE_BEND * wave.amount : 0, wave ? Math.cos(wave.angle) : 1, wave ? Math.sin(wave.angle) : 0, wave ? wave.phase : 0); gl.uniform1f(this.u(this.show, 'uWaveK'), WAVE_K); if (fluid && warp) { gl.activeTexture(gl.TEXTURE3); gl.bindTexture(gl.TEXTURE_2D, fluid.velocity); gl.activeTexture(gl.TEXTURE0); gl.uniform1i(this.u(this.show, 'uVelocity'), 3); gl.uniform2fv(this.u(this.show, 'uUvPerCell'), fluid.uvPerCell); } gl.bindVertexArray(this.empty); gl.drawArrays(gl.TRIANGLES, 0, 3); gl.bindVertexArray(null); if (chain && into) this.finish(chain, look.effects!, into, screenWidth, screenHeight); else if (this.fx && performance.now() - this.fx.last > 10_000) this.freeFx(); } /** The fluid, where the Fluid setting asks for one (`amount` over 0) and this device can make it; let go after ten * seconds unasked, so a passing 0 does not make it again and again. */ /** The screen just shown (`viewWidth` x `viewHeight`) read into tiles for the flash meter (meters.ts), as of `t` * seconds; read back a frame or two later with `tiles`. None where this device cannot (it is then never tried again). */ measure(viewWidth: number, viewHeight: number, t: number) { if (this.meterOut) return; try { this.meter ??= new FlashMeter(this.gl); this.meter.capture(viewWidth, viewHeight, t); } catch (e) { console.warn('Visuals: no flash meter on this device', e); this.meter?.dispose(); this.meter = null; this.meterOut = true; } } /** The screen's readings the GPU has finished, the oldest first. */ tiles(): Tiles[] { return this.meter?.poll() ?? []; } /** The picture chain's program for these effects, or null where none would show (none set, all at nothing, or one * that would not compile here: then the stage stands aside for good for that chain). */ private chainFor(fx: Shown['effects']): WebGLProgram | null { if (!fx || !(fx.amounts.some((a, k) => a > 0 && fx.ids[k]) || this.clocks.holding())) return null; const ids = fx.ids.slice(0, MOST_PICTURE); const key = ids.join(','); if (!this.fxPrograms.has(key)) { const source = pictureShader(ids); let p: WebGLProgram | null = null; try { p = source ? program(this.gl, FULL, source) : null; } catch (e) { console.warn('Visuals: an effects chain did not compile here', ids, e); } this.fxPrograms.set(key, p); this.madeAt = performance.now(); } return this.fxPrograms.get(key) ?? null; } /** The stage's targets at the buffer's size: two finished pictures (one written, one the last) and the picture. */ private fxTargets() { const gl = this.gl; if (this.fx && (this.fx.w !== this.width || this.fx.h !== this.height)) this.freeFx(); if (!this.fx) { const tex: WebGLTexture[] = []; const fbo: WebGLFramebuffer[] = []; for (let i = 0; i < 3; i++) { const t = gl.createTexture()!; gl.bindTexture(gl.TEXTURE_2D, t); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, this.width, this.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); // Read past its edge (a kaleidoscope reaching into the corners), the picture is mirrored, not smeared. gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT); const f = gl.createFramebuffer()!; gl.bindFramebuffer(gl.FRAMEBUFFER, f); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0); tex.push(t); fbo.push(f); } gl.bindTexture(gl.TEXTURE_2D, null); this.fx = { tex, fbo, w: this.width, h: this.height, cur: 0, last: 0 }; } this.fx.last = performance.now(); return this.fx; } private freeFx() { const gl = this.gl; if (!this.fx) return; for (const t of this.fx.tex) gl.deleteTexture(t); for (const f of this.fx.fbo) gl.deleteFramebuffer(f); this.fx = null; } /** The picture slot after the show pass: the chain's pass from the picture (and the last finished one, for echoes, * and the flow, for what modulates by it) into the next finished one, then that one onto the screen. */ private finish(p: WebGLProgram, fx: NonNullable, into: FxTargets, screenWidth: number, screenHeight: number) { const gl = this.gl; const next = 1 - into.cur; gl.bindFramebuffer(gl.FRAMEBUFFER, into.fbo[next]); gl.viewport(0, 0, into.w, into.h); gl.useProgram(p); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, into.tex[2]); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, into.tex[into.cur]); const fluid = this.fluid; gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, fluid ? fluid.velocity : null); gl.activeTexture(gl.TEXTURE0); const u = (n: string) => this.uniform(p, n); gl.uniform1i(u('uPicture'), 0); gl.uniform1i(u('uPrev'), 1); gl.uniform1i(u('uFlow'), 2); gl.uniform2f(u('uFlowScale'), fluid ? fluid.uvPerCell[0] : 0, fluid ? fluid.uvPerCell[1] : 0); gl.uniform2f(u('uView'), into.w, into.h); gl.uniform1f(u('uAspect'), into.w / into.h); gl.uniform1fv(u('uFxA'), Float32Array.from({ length: MOST_PICTURE }, (_, k) => fx.amounts[k] ?? 0)); gl.uniform2fv(u('uFxT'), Float32Array.from({ length: MOST_PICTURE * 2 }, (_, i) => this.clocks.times[i >> 1][i & 1])); gl.uniform1f(u('uFxFrames'), this.fxFrames); gl.uniform2fv(u('uFxK'), Float32Array.from({ length: MOST_PICTURE * 2 }, (_, i) => fx.knobs?.[i >> 1]?.[i & 1] ?? 0.5)); gl.uniform4f(u('uFxBeat'), ...(fx.beat ?? ([0, 1000, 0, 0] as const))); gl.bindVertexArray(this.empty); gl.drawArrays(gl.TRIANGLES, 0, 3); into.cur = next; // Onto the screen, scaled up. gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, screenWidth, screenHeight); gl.useProgram(this.copying); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, into.tex[next]); gl.uniform1i(this.uniform(this.copying, 'uOut'), 0); gl.uniform2f(this.uniform(this.copying, 'uView'), screenWidth, screenHeight); gl.drawArrays(gl.TRIANGLES, 0, 3); gl.bindVertexArray(null); } /** The tunnel's factor this frame at `rate`: read this much nearer the middle (under 1: drawn larger). */ private tunnelZoom(rate: number, since: number) { return rate > 0 ? Math.exp(-rate * since) : 1; } /** The tunnel's pass where nothing flows: the buffers copied into the other set, read `zoom` nearer the middle and * kept as the fade keeps them. */ private tunnel(keep: number, zoom: number, loop: { id: number; amount: number; time: Loop['time']; frames: number; knobs?: readonly [number, number] } | null = null) { const gl = this.gl; const other = 1 - this.cur; gl.disable(gl.BLEND); gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbos[other]); gl.viewport(0, 0, this.width, this.height); gl.bindVertexArray(this.empty); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.accs[this.cur]); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this.bandSets[this.cur]); gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, this.bandSetsB[this.cur]); const id = loop?.id ?? 0; let p = this.carrying2.get(id); if (!p) { // One that does not compile on this device: the carry without it, rather than no frame. try { p = program(gl, FULL, tunnelSource(loopFunction(id))); } catch (e) { console.warn('Visuals: a loop effect did not compile here', id, e); p = this.carrying2.get(0) ?? program(gl, FULL, tunnelSource(loopFunction(0))); } this.carrying2.set(id, p); this.madeAt = performance.now(); } gl.useProgram(p); const u = (n: string) => this.uniform(p!, n); gl.uniform1f(u('uLoopA'), loop?.amount ?? 0); gl.uniform4f(u('uLoopTime'), ...(loop?.time ?? ([0, 0, 0, 0] as const))); gl.uniform1f(u('uLoopFrames'), loop?.frames ?? 1); gl.uniform2f(u('uLoopK'), loop?.knobs?.[0] ?? 0.5, loop?.knobs?.[1] ?? 0.5); gl.uniform1i(u('uAcc'), 0); gl.uniform1i(u('uBandsA'), 1); gl.uniform1i(u('uBandsB'), 2); gl.uniform2f(u('uSize'), this.width, this.height); gl.uniform1f(u('uZoom'), zoom); gl.uniform1f(u('uKeep'), keep); gl.drawArrays(gl.TRIANGLES, 0, 3); gl.activeTexture(gl.TEXTURE0); this.cur = other; this.use(); } /** The dive's camera for this frame at `rate` (the points' program in use). Its part is chosen as a loop starts (and * for a flame of other parts), its fit made again four times a second as the flame moves. Let go of (`rate` 0) * mid-loop, it finishes the loop gently and rests at the whole, so the picture never stops zoomed in. */ private diveStep(rate: number, since: number) { const gl = this.gl; const flame = this.flame; const off = () => { gl.uniform1i(this.u(this.iterate, 'uDive'), 0); this.dive = null; this.diveTurnRate = 0; }; if (!flame || (rate <= 0 && !this.dive)) return off(); const now = performance.now(); let d = this.dive; if (!d || d.parts !== flame.xforms.length) { const target = diveTarget(flame); if (!target) return off(); d = this.dive = { j: target.j, fit: target, t: 0, at: now, parts: flame.xforms.length }; } else if (now - d.at > 250) { const fit = diveFit(flame, d.j); if (fit) d.fit = fit; d.at = now; } const speed = rate > 0 ? rate : 0.15; const camera = diveCamera(d.fit, d.t, speed); d.t += since / Math.max(0.5, Math.log(1 / Math.hypot(d.fit.a[0], d.fit.a[1])) / speed); if (d.t >= 1) { // A loop done: the part become the whole. Let go of, the dive rests here; else the next loop's part is chosen. if (rate <= 0) return off(); const target = diveTarget(flame); if (!target) return off(); this.dive = { j: target.j, fit: target, t: d.t % 1, at: now, parts: flame.xforms.length }; } this.diveTurnRate = camera.turnRate; gl.uniform1i(this.u(this.iterate, 'uDive'), 1); gl.uniform1i(this.u(this.iterate, 'uDiveI'), d.j); gl.uniform1f(this.u(this.iterate, 'uDiveT'), d.t); gl.uniform4f(this.u(this.iterate, 'uDiveZ'), camera.zoom[0], camera.zoom[1], camera.at[0], camera.at[1]); } private fluidFor(amount: number): Fluid | null { const now = performance.now(); if (!(amount > 0)) { if (this.fluid && now - this.fluidUnwanted > 10_000) { this.fluid.dispose(); this.fluid = null; } return null; } this.fluidUnwanted = now; if (this.fluid || this.fluidOut) return this.fluid; try { if (!Fluid.supported(this.gl)) throw new Error('not supported'); this.fluid = new Fluid(this.gl, ...gridFor(this.width, this.height)); } catch (e) { console.warn('Visuals: no fluid on this device', e); this.fluidOut = true; } return this.fluid; } /** Whether the GPU took the context away (the page should stop and drop the renderer). */ get lost(): boolean { return this.gl.isContextLost(); } dispose() { const gl = this.gl; this.fluid?.dispose(); this.fluid = null; this.meter?.dispose(); this.meter = null; for (const b of this.buffers) gl.deleteBuffer(b); for (const v of this.vaos) gl.deleteVertexArray(v); gl.deleteVertexArray(this.empty); gl.deleteTransformFeedback(this.feedback); for (const x of [...this.accs, ...this.bandSets, ...this.bandSetsB]) gl.deleteTexture(x); for (const f of this.fbos) gl.deleteFramebuffer(f); if (this.query) gl.deleteQuery(this.query); for (const p of new Set([this.iterate, this.plot, this.fade, this.show, this.scrubbing, this.finding, this.carrying])) gl.deleteProgram(p); gl.getExtension('WEBGL_lose_context')?.loseContext(); } private uniform(p: WebGLProgram, name: string): WebGLUniformLocation | null { let m = this.locs.get(p); if (!m) this.locs.set(p, (m = new Map())); if (!m.has(name)) m.set(name, this.gl.getUniformLocation(p, name)); return m.get(name) ?? null; } private u(p: WebGLProgram, name: string): WebGLUniformLocation | null { const key = `${this.programs.indexOf(p)}:${name}`; if (!this.loc.has(key)) this.loc.set(key, this.gl.getUniformLocation(p, name)); return this.loc.get(key) ?? null; } private get programs() { return [this.iterate, this.fade, this.show]; } } function program(gl: WebGL2RenderingContext, vs: string, fs: string, feedback?: string[]): WebGLProgram { const shader = (type: number, src: string) => { const s = gl.createShader(type)!; gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) ?? 'The shader did not compile.'); return s; }; const p = gl.createProgram()!; gl.attachShader(p, shader(gl.VERTEX_SHADER, vs)); gl.attachShader(p, shader(gl.FRAGMENT_SHADER, fs)); if (feedback) gl.transformFeedbackVaryings(p, feedback, gl.INTERLEAVED_ATTRIBS); gl.linkProgram(p); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p) ?? 'The program did not link.'); return p; } ===== src/lib/visuals/routes.ts ===== // The visuals' routes (decision 0046, amended): every signal the music gives can drive every main aspect of the // picture, each by an amount from -100 to +100 %, as a synthesiser's modulation matrix does. The owner: "For each aspect // of the music we can get a signal for, I want to be able to control its effect on each of the main aspects of the // visualization." An aspect adds up what its routes give it (each signal, 0..1, times its amount) and moves from its // rest by that (Visuals.svelte has each one's range). The loudness as heard stays the envelope: every other signal's // routes are scaled by it, so a quiet passage is a small show whatever its parts do. import { curveFrom, heightAt, isStraight, tableOf, type RouteValue } from './curves'; import type { VisualSettings } from './settings.svelte'; export type { Curve, RouteValue } from './curves'; /** Signals of the whole track. */ export const TRACK_SIGNALS = ['loudness', 'beat', 'downbeat', 'tempo', 'section', 'harmony', 'centroid', 'key', 'swell', 'buildUp', 'track'] as const; /** Signals of each band. */ export const BAND_SIGNALS = ['level', 'flux', 'hits', 'percussive', 'harmonic', 'percussiveHits', 'crest', 'width', 'bandSwell', 'envelope'] as const; export type TrackSignal = (typeof TRACK_SIGNALS)[number]; export type BandSignal = (typeof BAND_SIGNALS)[number]; export type Signal = TrackSignal | BandSignal; export const SIGNAL_LABEL: Record = { loudness: 'Loudness', beat: 'Beat', downbeat: 'Downbeat', tempo: 'Tempo', section: 'Section', harmony: 'Harmony', centroid: 'Timbre', key: 'Key', swell: 'Swell', buildUp: 'Build-up', track: 'Track', level: 'Level', flux: 'Flux', hits: 'Hits', percussive: 'Percussive', harmonic: 'Harmonic', percussiveHits: 'Drum hits', crest: 'Punch', width: 'Width', bandSwell: 'Swell', envelope: 'Envelope', }; /** The picture's aspects. Stir, Flow and Swirl are the fluid's (64.1; the owner asked that what moves it be routes, * not hidden): read by stirring.ts at its own moments (`heightOn`), not added up each frame. */ export const ASPECTS = [ 'shape', 'rotation', 'lights', 'hue', 'spread', 'presence', 'brightness', 'size', 'saturation', 'trails', 'smoothing', 'haze', 'fluid', 'stir', 'flow', 'swirl', 'waves', 'tunnel', 'dive', 'effects', 'facets', 'look', 'behaviour', // The look's own (65.1; the owner: "I want matrix targets for all effect controls", "Also for lens and other // discrete"), each named for the setting it moves, in the Look's order: its discrete settings at moments, each // engine's choice at moments, its Amount a level and its two controls swinging about where they are set. 'family', 'form', 'symmetry', 'mirror', 'final', 'lattice', 'fx1', 'fx1Amount', 'fx1K1', 'fx1K2', 'fx2', 'fx2Amount', 'fx2K1', 'fx2K2', 'fx3', 'fx3Amount', 'fx3K1', 'fx3K2', 'fxLoop', 'fxLoopAmount', 'fxLoopK1', 'fxLoopK2', ] as const; export type Aspect = (typeof ASPECTS)[number]; /** Aspects of each part of the flame: a band's routes act on its own part, the whole track's on every part. */ export const PART_ASPECTS: readonly Aspect[] = ['shape', 'rotation', 'lights', 'hue', 'spread', 'presence']; /** Aspects that act at moments: as what their routes give rises past a half, the setting moves on (a new symmetry, a * new lens, another of an engine's effects…). */ export const MOMENT_ASPECTS = ['family', 'form', 'symmetry', 'mirror', 'final', 'lattice', 'fx1', 'fx2', 'fx3', 'fxLoop'] as const satisfies readonly Aspect[]; /** The look's columns (65.1), which the dice leave as they are (recipe.ts): they move what the look is, not how it * follows the music, and no recipe draws routes into them. */ export const LOOK_COLUMNS: readonly Aspect[] = [...MOMENT_ASPECTS.filter((a) => a !== 'symmetry'), 'fx1Amount', 'fx1K1', 'fx1K2', 'fx2Amount', 'fx2K1', 'fx2K2', 'fx3Amount', 'fx3K1', 'fx3K2', 'fxLoopAmount', 'fxLoopK1', 'fxLoopK2']; /** The effects' controls, swinging about where they are set (each engine's two, then the Loop's), in effects.ts's * `routed.knobs` order. */ export const KNOB_ASPECTS = ['fx1K1', 'fx1K2', 'fx2K1', 'fx2K2', 'fx3K1', 'fx3K2', 'fxLoopK1', 'fxLoopK2'] as const; /** The effects' Amounts, as levels (each engine's, then the Loop's), in `routed.amounts` order. */ export const AMOUNT_ASPECTS = ['fx1Amount', 'fx2Amount', 'fx3Amount', 'fxLoopAmount'] as const; export const ASPECT_LABEL: Record = { shape: 'Shape', rotation: 'Rotation', lights: 'Lights', hue: 'Hue', spread: 'Spread', presence: 'Presence', brightness: 'Glow', size: 'Size', saturation: 'Saturation', trails: 'Trails', smoothing: 'Smoothing', haze: 'Haze', fluid: 'Fluid', stir: 'Stir', flow: 'Flow', swirl: 'Swirl', waves: 'Waves', tunnel: 'Tunnel', dive: 'Dive', effects: 'Effects', facets: 'Facets', look: 'Look', behaviour: 'Behaviour', family: 'Colours', form: 'Form', symmetry: 'Symmetry', mirror: 'Mirror', final: 'Lens', lattice: 'Lattice', fx1: 'Kaleidoscope', fx1Amount: 'Kaleidoscope amount', fx1K1: 'Kaleidoscope control 1', fx1K2: 'Kaleidoscope control 2', fx2: 'EYEDO', fx2Amount: 'EYEDO amount', fx2K1: 'EYEDO control 1', fx2K2: 'EYEDO control 2', fx3: 'Hydra', fx3Amount: 'Hydra amount', fx3K1: 'Hydra control 1', fx3K2: 'Hydra control 2', fxLoop: 'Loop', fxLoopAmount: 'Loop amount', fxLoopK1: 'Loop control 1', fxLoopK2: 'Loop control 2', }; /** The columns that have no setting of their own otherwise, each its scale's setting (64.2; the owner: "is it * impossible to have each column their own slider?"): what its routes give it, times that (1 as before, 0 at rest). * The Glow column's is `glow`: `brightness` is the display's. */ export const COLUMN_SCALES = { shape: 'shape', rotation: 'rotation', lights: 'lights', brightness: 'glow', size: 'size', saturation: 'saturation', trails: 'trails', stir: 'stir', flow: 'flow', swirl: 'swirl', presence: 'presence', waves: 'waves', } as const satisfies Partial>; export const SCALE_KEYS = Object.values(COLUMN_SCALES); /** Each one's place in its list, for the dense arrays below. */ const places = (list: readonly T[]) => Object.fromEntries(list.map((x, i) => [x, i])) as Record; export const T = places(TRACK_SIGNALS); export const B = places(BAND_SIGNALS); export const A = places(ASPECTS); const NA = ASPECTS.length; const isPart = ASPECTS.map((a) => PART_ASPECTS.includes(a)); const isMoment = ASPECTS.map((a) => (MOMENT_ASPECTS as readonly Aspect[]).includes(a)); /** A route's key: the signal, its band (`-` for the whole track's), the aspect. */ export const routeKey = (signal: Signal, band: number | null, aspect: Aspect) => `${signal}|${band ?? '-'}|${aspect}`; /** A route's key as kept before, as it is now: Reseed's column became Drift's (64), and Drift's is Look's (64.1: the * owner, "Instead of having drift as a target, we should have looks and behavior as a target"). */ export const keyNow = (key: string) => key.endsWith('|reseed') ? `${key.slice(0, -'reseed'.length)}look` : key.endsWith('|drift') ? `${key.slice(0, -'drift'.length)}look` : key; /** Whether `key` names a route there is: a track signal with `-`, a band signal with a band under `bands`. */ export function isRoute(key: string, bands: number): boolean { const [signal, band, aspect] = key.split('|'); if (!(ASPECTS as readonly string[]).includes(aspect)) return false; if ((TRACK_SIGNALS as readonly string[]).includes(signal)) return band === '-'; return (BAND_SIGNALS as readonly string[]).includes(signal) && /^\d$/.test(band) && Number(band) < bands; } /** The routes that make the picture as it was before them: the loudness sets the size and the colour (and, as the * envelope, everything else) and turns the whole, the beat flashes and swells it; each band's level evolves, turns * and tints its part, its hits light it; a new track brings its own look (Track into Drift at -100 %: all the way * back to it). Every other route is 0. */ export function defaultRoutes(bands: number): Record { const r: Record = { [routeKey('track', null, 'look')]: -1, [routeKey('track', null, 'behaviour')]: -1, [routeKey('loudness', null, 'size')]: 1, [routeKey('loudness', null, 'saturation')]: 1, [routeKey('loudness', null, 'rotation')]: 0.2, [routeKey('beat', null, 'brightness')]: 0.2, [routeKey('beat', null, 'size')]: 0.08, // The fluid's stirring as it always was (stirring.ts): the beats', the downbeats' and a new section's pushes; the // flow as free as the loudness, breathing with the beat; the swells' and a new section's whirls. [routeKey('beat', null, 'stir')]: 1, [routeKey('downbeat', null, 'stir')]: 1, [routeKey('section', null, 'stir')]: 1, [routeKey('loudness', null, 'flow')]: 1, [routeKey('beat', null, 'flow')]: 1, [routeKey('swell', null, 'swirl')]: 1, [routeKey('section', null, 'swirl')]: 1, // Slow giant waves as the music swells (65). [routeKey('swell', null, 'waves')]: 1, }; for (let b = 0; b < bands; b++) { r[routeKey('level', b, 'shape')] = 1; r[routeKey('level', b, 'rotation')] = 0.6; r[routeKey('level', b, 'hue')] = 0.08; r[routeKey('hits', b, 'lights')] = 1; // Each band's part as present as its sound (65): its envelope, a hit's flare and what the sound holds. r[routeKey('envelope', b, 'presence')] = 1; // Each band's hits kick its part; its level stirs it all the while (as its Continuity shares them). r[routeKey('hits', b, 'stir')] = 1; r[routeKey('level', b, 'stir')] = 1; } return r; } /** The routes compiled (decision 0046, amended for 64.1): `track[signal * aspects + aspect]` and `bands[band][…]` each * route's end (its amount, as a straight one gives it); where a route is curved, its table of heights by signal * (`curves.ts`), else null; each one's peak (the most it gives, never under 0: what a level's route can let through); * and the routes there are, so a frame passes over none of the others. */ export type Matrix = { track: Float32Array; bands: Float32Array[]; trackTable: (Float32Array | null)[]; bandTable: (Float32Array | null)[][]; trackPeak: Float32Array; bandPeak: Float32Array[]; trackOn: Int32Array; bandOn: Int32Array[]; /** The routes that walk with the behaviour, by key, and each cell's place among them (−1: it does not walk). */ walks: string[]; trackWalk: Int16Array; bandWalk: Int16Array[]; }; export function compile(routes: Record, bands: number): Matrix { const cells = (signals: number) => ({ end: new Float32Array(signals * NA), table: Array(signals * NA).fill(null), peak: new Float32Array(signals * NA), on: [] as number[], walk: new Int16Array(signals * NA).fill(-1), }); const walks: string[] = []; const t = cells(TRACK_SIGNALS.length); const per = Array.from({ length: bands }, () => cells(BAND_SIGNALS.length)); for (const [key, value] of Object.entries(routes)) { if (!isRoute(key, bands)) continue; const curve = typeof value === 'number' ? null : curveFrom(value); const end = curve ? curve.h : typeof value === 'number' && Number.isFinite(value) ? value : 0; const bent = curve && !isStraight(curve) ? tableOf(curve) : null; if (!end && !bent) continue; const [signal, band, aspect] = key.split('|'); const into = band === '-' ? t : per[Number(band)]; const cell = (band === '-' ? T[signal as TrackSignal] : B[signal as BandSignal]) * NA + A[aspect as Aspect]; into.end[cell] = end; into.table[cell] = bent ? bent.table : null; into.peak[cell] = bent ? bent.peak : Math.max(0, end); into.on.push(cell); if (curve?.walks) into.walk[cell] = walks.push(key) - 1; } return { track: t.end, bands: per.map((b) => b.end), trackTable: t.table, bandTable: per.map((b) => b.table), trackPeak: t.peak, bandPeak: per.map((b) => b.peak), trackOn: Int32Array.from(t.on), bandOn: per.map((b) => Int32Array.from(b.on)), walks, trackWalk: t.walk, bandWalk: per.map((b) => b.walk), }; } /** What each aspect is given now: `whole` the whole track's routes (and, for the aspects of the whole picture, the * bands' too: their mean, or for a moment their largest), `band[b]` band b's own routes (for its part's aspects); * `level` each level's share of its setting the routes let through (1: all of it; `LEVEL_ASPECTS`). */ export type Given = { whole: Float32Array; band: Float32Array[]; level: Float32Array }; export const given = (bands: number): Given => ({ whole: new Float32Array(NA), band: Array.from({ length: bands }, () => new Float32Array(NA)), level: new Float32Array(NA).fill(1), }); /** The levels (64.1; the owner: "The other sliders are simply limits"): Smoothing, Haze, Fluid, Tunnel and Dive are each * the most its setting gives (Haze, either way from 0: the most of it, more or less), and each of its routes a condition for reaching it: a route lets through all of it where its * curve is at its peak, less as it falls short (as much less as the envelope says), never under nothing; they * multiply. A straight one of amount a gives 1 − e·a·(1 − s) rising with its signal s, of −a 1 − e·a·s falling; quiet * (e → 0) every route lets all through. The bands' routes into one: their geometric mean, as the swings take the bands' * mean. */ export const LEVEL_ASPECTS: readonly Aspect[] = ['smoothing', 'haze', 'fluid', 'tunnel', 'dive', 'effects', 'facets', ...AMOUNT_ASPECTS]; const isLevel = ASPECTS.map((a) => LEVEL_ASPECTS.includes(a)); /** What a route gives at its signal `v`: its curve's height, a straight one's amount times the signal. */ const heightOf = (table: Float32Array | null, end: number, v: number) => (table ? heightAt(table, v) : end * v); /** What a route gives at `x` at a moment of its own, not each frame through `give` (the fluid's pushes, stirring.ts): * its curve's height, a straight one's amount times `x`, none where there is no route; walking, its walk's share * (`walk`: `Matrix.walks` order). `band` null: the whole track's. */ export function heightOn(m: Matrix, signal: Signal, band: number | null, aspect: Aspect, x: number, walk?: Float32Array): number { const a = A[aspect]; if (band === null) { const s = T[signal as TrackSignal]; if (s === undefined) return 0; const cell = s * NA + a; const w = m.trackWalk[cell]; return heightOf(m.trackTable[cell], m.track[cell], x) * (w < 0 || !walk ? 1 : (walk[w] ?? 1)); } const s = B[signal as BandSignal]; if (s === undefined || !m.bands[band]) return 0; const cell = s * NA + a; const w = m.bandWalk[band][cell]; return heightOf(m.bandTable[band][cell], m.bands[band][cell], x) * (w < 0 || !walk ? 1 : (walk[w] ?? 1)); } /** A level's route's share let through; walking (`w` under 1), its pull as much less. */ const letThrough = (peak: number, y: number, e: number, w = 1) => 1 - w * (1 - Math.min(1, Math.max(0, 1 - e * (peak - y)))); /** Adds up the routes into `out`: `track` the whole track's signals now (`T` order, 0..1), `bands[b]` band b's (`B` * order); `envelope` (0..1) scales every signal's routes but the loudness's own. The swings add up what each route's * curve gives at its signal, times the envelope; the levels multiply what each lets through. `walk`: each walking * route's factor now (`Matrix.walks` order, 1: as set), its share of what it gives. */ export function give(m: Matrix, track: Float32Array, bands: Float32Array[], envelope: number, out: Given, walk?: Float32Array): Given { const walkOf = (i: number) => (i < 0 || !walk ? 1 : (walk[i] ?? 1)); const { whole, band, level } = out; whole.fill(0); level.fill(1); for (let i = 0; i < m.trackOn.length; i++) { const cell = m.trackOn[i]; const s = (cell / NA) | 0; const a = cell - s * NA; const e = s === T.loudness ? 1 : envelope; const y = heightOf(m.trackTable[cell], m.track[cell], track[s]); const w = walkOf(m.trackWalk[cell]); if (isLevel[a]) level[a] *= letThrough(m.trackPeak[cell], y, e, w); else whole[a] += e * y * w; } const n = Math.min(band.length, bands.length, m.bands.length); const bandLevel = levelScratch(n); for (let b = 0; b < band.length; b++) { const to = band[b]; to.fill(0); if (b >= n) continue; const lv = bandLevel[b]; lv.fill(1); const on = m.bandOn[b]; for (let i = 0; i < on.length; i++) { const cell = on[i]; const s = (cell / NA) | 0; const a = cell - s * NA; const y = heightOf(m.bandTable[b][cell], m.bands[b][cell], bands[b][s]); const w = walkOf(m.bandWalk[b][cell]); if (isLevel[a]) lv[a] *= letThrough(m.bandPeak[b][cell], y, envelope, w); else to[a] += envelope * y * w; } } if (n) { for (let a = 0; a < NA; a++) { if (isLevel[a]) { let product = 1; for (let b = 0; b < n; b++) product *= bandLevel[b][a]; level[a] *= product ** (1 / n); continue; } if (isPart[a]) continue; let sum = 0; let most = -Infinity; for (let b = 0; b < n; b++) { sum += band[b][a]; most = Math.max(most, band[b][a]); } whole[a] += isMoment[a] ? most : sum / n; } } return out; } // Each band's levels, kept from call to call (no garbage each frame). let scratch: Float32Array[] = []; function levelScratch(n: number): Float32Array[] { while (scratch.length < n) scratch.push(new Float32Array(NA)); return scratch; } ===== src/lib/visuals/settings.svelte.ts ===== // The visuals' settings (decision 0046, amended; 0050): how much the device draws (Quality and the frame rate: ceilings // the automatic step-down stays under), how the show behaves (intensity, flash, speed, brightness, the grain and haze), the look (the preset: the flame's form, detail, twist, warp, symmetry, mirror, lens, colour family, hue, // lightness, vividness, spread and purity, which a track's key or the dice set and the picture follows; one the owner // moved is pinned), each band's times (how long its light rings, how quickly its part evolves, and how much of its // motion comes in steps on its own transients), and the routes: how much each of the music's signals drives each aspect // of the picture (routes.ts). // Changed live from the visuals' panel and kept on this device (a phone and a tablet want different ones); a browser // that keeps nothing (a private window) starts from the defaults each time. import { FORMS, LENSES, PRESET_KEYS, type Preset } from './flame'; import { ENGINE_EFFECTS, LOOP_EFFECTS } from './effects'; import { defaultRoutes, isRoute, keyNow, SCALE_KEYS, type RouteValue } from './routes'; import { curveFrom, valueOf } from './curves'; import { QUALITY } from './quality'; const KEY = 'cratarr-visuals'; /** The kept settings' form: 3 since 65 (Quality, Haze, Family as Colours: what they replace is dropped, not converted, * by the owner's choice); 2 since 64 (Fluid on its finer scale); none before. */ const FORM = 3; /** Fluid's whole range: this much of the fluid's own. In 63 the slider went to all of it, and the owner wanted only its * first few percent ("between 0 and 5% fluid dynamics is about all I want … or maybe 10% should be 100%", 2026-10-18). * 64.1 tried a fifth of that (its top washed the owner's phone out: a route took Fluid past it on every punch); the * owner kept 64's ("restore 64's full range, otherwise it'll probably not be good"): with the slider now the most a * route gives Fluid, and Stir, Flow and Swirl as routes, the washouts are tamed where they come from. */ export const FLUID_FULL = 0.1; /** A Fluid kept or saved before 64, on its finer scale. */ export const fluidFrom63 = (v: number) => Math.min(1, v / FLUID_FULL); /** The bands the routes are kept for (the server's six). */ export const BANDS = 6; export type VisualSettings = Preset & { /** How much the device may draw at most (0..1, 65): its points, steps and resolution, as quality.ts `ceilingOf` maps * it (60 % the defaults before 65); the device's pace takes it lower where frames come late. */ quality: number; /** The most frames a second. */ fps: number; /** How much the music's loudness sets the show's intensity (0: always all, 1: as loud as it is). */ intensity: number; /** How much of the picture's quick changes show (its lights, glow, size and colour on hits and beats): 0 only their * slow course, 1 all of them, 2 twice as much. */ flash: number; /** Motion: the turning and its steps (0..2). */ speed: number; /** Calm ↔ Wild (0..1): how much busier than its budgets the governor lets the show be (governor.ts), half as much to * twice as much; as the standing limits at its middle. */ wild: number; /** The most brightness shown, after everything (0.1..1). */ brightness: number; /** The grain: how far the sparse parts are blurred (0: the raw grain; 0.5: three pixels, as before; 0..1). */ smoothing: number; /** Haze (−1..1, 65, decision 0050): more of the faint mist lifted into view (the display's gamma from 3 up to 6), or * less, the faint cut to black (the gamma down to 1.5, a floor up to a fifth of the flame's mean); 0 as the defaults * were before 65. Its routes take it toward 0, never past what is set. aspects.ts. */ haze: number; /** How much the light flows (0..1, decision 0047): 0 its trails only fade where they were drawn, as before (nothing of * the fluid made); above it they are carried along by a flow the music stirs, like smoke. Its route moves it. Its * whole range is `FLUID_FULL` of the fluid's own. */ fluid: number; /** The infinite zooms' speeds (0..1 of their most, 65; zoom.ts): Tunnel streams the light outward for ever, Dive * zooms into one part of the flame for ever; 0 off, both may run. Levels, as Fluid is: their routes the conditions. */ tunnel: number; /** The most of the look's effects (0..1, 65): the Effects column's routes are its conditions, as Fluid's. */ effects: number; /** A double tap's roll stands (1): the group governs at Wild's most, up to twice the limits, till the next roll, an * undo, Reset or a recall (65; the owner: "sync the group properly"). Not a slider: the dice set it. */ loose: number; dive: number; /** Each band's part's weight in the drawing, the scale of its Presence column (65; routes.ts `COLUMN_SCALES`), and * the slow giant waves' (the Waves column). */ presence: number; waves: number; /** Each column of the routes that has no setting of its own, its scale (0..1, 64.2; the owner: "is it impossible to * have each column their own slider?"): what its routes give it, times this. 1 as before; 0 the music leaves it at * rest. routes.ts `COLUMN_SCALES`. */ shape: number; rotation: number; lights: number; glow: number; size: number; saturation: number; trails: number; stir: number; flow: number; swirl: number; /** The look's settings the owner moved: kept as they are when a track or the dice load another preset, until * unpinned. */ pins: string[]; /** Each band's (sub, bass, low mid, mid, upper mid, air) light after a transient: how long it rings (seconds). */ lighting: number[]; /** Each band's loudness evolving its transform: how long it takes to follow (seconds). */ evolution: number[]; /** Each band's motion and evolution: 0 in steps on its own transients, 1 steady, between them a share of each. */ continuity: number[]; /** Each route's amount (-1..1) by its key (`routeKey`); one not here is 0. */ routes: Record; }; const touch = typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches; // Many points in few steps: each step is a transform feedback, which ANGLE on Direct3D 11 (Chrome and Edge on Windows) // flushes after; the same points a frame in 3 steps rather than 12 took 4.0 ms against 4.8 on an RTX 4060 (2026-10-17). export const DEFAULTS: VisualSettings = { quality: QUALITY, fps: touch ? 30 : 60, intensity: 1, flash: 1, speed: 1, wild: 0.5, brightness: 1, smoothing: 0.5, haze: 0, fluid: 0, tunnel: 0, effects: 1, loose: 0, dive: 0, presence: 1, waves: 1, shape: 1, rotation: 1, lights: 1, glow: 1, size: 1, saturation: 1, trails: 1, stir: 1, flow: 1, swirl: 1, // The look: the playing track's preset replaces these (those not pinned) as soon as the visuals show. form: 0, detail: 0.5, twist: 0.7, warp: 0.8, symmetry: 1, mirror: 0, final: 0, family: 0, hue: 0, lightness: 0.5, vividness: 0.5, spread: 1, // Six parts mixing their colours go grey: mostly each its band's own. purity: 0.75, fx1: 0, fx1Amount: 0, fx2: 0, fx2Amount: 0, fx3: 0, fx3Amount: 0, fxLoop: 0, fxLoopAmount: 0, fx1K1: 0.5, fx1K2: 0.5, fx2K1: 0.5, fx2K2: 0.5, fx3K1: 0.5, fx3K2: 0.5, fxLoopK1: 0.5, fxLoopK2: 0.5, facets: 0, lattice: 0, pins: [], // A kick rings longer than a hat. lighting: [0.2, 0.18, 0.15, 0.13, 0.1, 0.08], evolution: [0.25, 0.25, 0.25, 0.25, 0.25, 0.25], continuity: [0.5, 0.5, 0.5, 0.5, 0.5, 0.5], routes: defaultRoutes(BANDS), }; type Ranged = Exclude; /** Each setting's range, as its slider has it (the bands' times in seconds, though their sliders double). A kept value * outside it (from a version whose range was another) is brought into it. */ export const RANGES: Record = { quality: [0, 1], fps: [10, 60], intensity: [0, 1], flash: [0, 2], speed: [0, 2], wild: [0, 1], brightness: [0.1, 1], smoothing: [0, 1], haze: [-1, 1], fluid: [0, 1], tunnel: [0, 1], dive: [0, 1], presence: [0, 1], waves: [0, 1], shape: [0, 1], rotation: [0, 1], lights: [0, 1], glow: [0, 1], size: [0, 1], saturation: [0, 1], trails: [0, 1], stir: [0, 1], flow: [0, 1], swirl: [0, 1], form: [0, FORMS - 1], detail: [0, 1], twist: [0, 1], warp: [0, 1], symmetry: [1, 8], mirror: [0, 1], final: [0, LENSES.length], // Family is the Colours setting (65): the preset's four (Spectrum, Arc, Two tones, Three), then Cover and Fire. family: [0, 5], hue: [0, 1], lightness: [0, 1], vividness: [0, 1], spread: [0.25, 2.5], purity: [0, 1], fx1: [0, ENGINE_EFFECTS.kaleidoscope.length], fx1Amount: [0, 1], fx2: [0, ENGINE_EFFECTS.eyedo.length], fx2Amount: [0, 1], fx3: [0, ENGINE_EFFECTS.hydra.length], fx3Amount: [0, 1], fxLoop: [0, LOOP_EFFECTS.length], fxLoopAmount: [0, 1], fx1K1: [0, 1], fx1K2: [0, 1], fx2K1: [0, 1], fx2K2: [0, 1], fx3K1: [0, 1], fx3K2: [0, 1], fxLoopK1: [0, 1], fxLoopK2: [0, 1], facets: [0, 1], lattice: [0, 1], effects: [0, 1], loose: [0, 1], lighting: [0.02, 1], evolution: [0.02, 2], continuity: [0, 1], routes: [-1, 1], }; const within = (key: Ranged, v: number) => Math.min(RANGES[key][1], Math.max(RANGES[key][0], v)); /** The defaults, their lists their own (the settings change in place). */ const fresh = (): VisualSettings => ({ ...DEFAULTS, pins: [], lighting: [...DEFAULTS.lighting], evolution: [...DEFAULTS.evolution], continuity: [...DEFAULTS.continuity], routes: { ...DEFAULTS.routes }, }); const isPresetKey = (k: string): k is keyof Preset => (PRESET_KEYS as readonly string[]).includes(k); /** The behaviour's sliders that pin as the Look's do, moved by hand (the owner, 2026-10-18: "I try to pin the behavior * instead of the look"): a pinned one the dice and Drift leave as it is. The columns' scales are among them. */ export const BEHAVIOUR_PINS = ['flash', 'speed', 'intensity', 'fluid', 'smoothing', 'haze', 'tunnel', 'dive', 'effects', ...SCALE_KEYS] as const; /** Each band's times, pinned one by one (`lighting:2`: the Low mid's Lighting): the dice leave a pinned one. */ export const BAND_TIMES = ['lighting', 'evolution', 'continuity'] as const; export const bandPin = (key: (typeof BAND_TIMES)[number], band: number) => `${key}:${band}`; export const BAND_PINS = BAND_TIMES.flatMap((k) => Array.from({ length: BANDS }, (_, b) => bandPin(k, b))); /** Whether `k` can be pinned: the Look's settings, the behaviour's sliders, each band's times and a route (`route:` * and its key; a pin lives on a route): what is pinned the dice, Drift and a track leave as it is (the owner: "I also * want to implement pin for the matrix"). */ export const isPinnable = (k: string) => isPresetKey(k) || (BEHAVIOUR_PINS as readonly string[]).includes(k) || BAND_PINS.includes(k) || (k.startsWith('route:') && isRoute(k.slice(6), BANDS)); function load(): VisualSettings { try { const kept = JSON.parse(localStorage.getItem(KEY) ?? '{}') as Record; // Kept by 63 (the only one with a Fluid before the form was kept): its Fluid on the finer scale. if (kept.keptForm === undefined && typeof kept.fluid === 'number') kept.fluid = fluidFrom63(kept.fluid); return keptFrom(kept); } catch { return fresh(); } } /** Settings as kept (on this device, or come from another: shared.svelte.ts), over the defaults: each value brought * into its range, anything not a setting left out. */ export function keptFrom(kept: Record): VisualSettings { const saved = { ...kept }; const out = fresh(); // Kept before the look was a preset (2026-10-18): Symmetry 0 and Mirror 0 were Auto, Mirror 1 Off and 2 On; // Spread and Purity were settings of their own. One set away from its default then is pinned now. const before = !Array.isArray(saved.pins); if (before) { const pins: string[] = []; if (typeof saved.symmetry === 'number' && saved.symmetry >= 1) pins.push('symmetry'); else delete saved.symmetry; if (saved.mirror === 1 || saved.mirror === 2) { saved.mirror = saved.mirror === 2 ? 1 : 0; pins.push('mirror'); } else delete saved.mirror; if (typeof saved.spread === 'number' && saved.spread !== 1) pins.push('spread'); if (typeof saved.purity === 'number' && saved.purity !== 0.75) pins.push('purity'); saved.pins = pins; } for (const k of Object.keys(DEFAULTS) as (keyof VisualSettings)[]) { const v = saved[k]; if (k === 'pins') { if (Array.isArray(v)) out.pins = v.filter((x): x is string => typeof x === 'string' && isPinnable(x)); } else if (typeof v === 'number' && Number.isFinite(v)) (out as Record)[k] = within(k, v); // Six bands' times; three (kept before the six bands, 2026-10-18) each for two of them. else if (Array.isArray(v) && (v.length === 6 || v.length === 3) && v.every((x) => typeof x === 'number' && Number.isFinite(x))) { const six = v.length === 6 ? v : v.flatMap((x) => [x, x]); (out as Record)[k] = six.map((x) => within(k, x)); } } // The routes kept: each one there is, over the defaults (a 0 kept turns a default off). if (saved.routes && typeof saved.routes === 'object') { for (const [kept, v] of Object.entries(saved.routes as Record)) { const key = keyNow(kept); if (!isRoute(key, BANDS)) continue; // An amount (a straight route), or a curve (64.1) brought into its ranges and kept as a number where straight. if (typeof v === 'number' && Number.isFinite(v)) out.routes[key] = within('routes', v); else { const c = curveFrom(v); if (c) out.routes[key] = valueOf(c); } } } return out; } export const visualSettings = $state(load()); /** Keeps them on this device. */ export function saveVisualSettings() { try { localStorage.setItem(KEY, JSON.stringify({ ...visualSettings, keptForm: FORM })); } catch { /* kept for this visit only */ } } export function resetVisualSettings() { Object.assign(visualSettings, fresh()); try { localStorage.removeItem(KEY); } catch { /* nothing kept */ } } /** The routes back to the defaults, the other settings as they are. */ export function resetRoutes() { visualSettings.routes = defaultRoutes(BANDS); saveVisualSettings(); } /** The look's settings as they are pinned. */ export function pinned(): Partial { const out: Partial = {}; for (const k of visualSettings.pins) if (isPresetKey(k)) out[k] = visualSettings[k]; return out; } /** A preset into the look's settings not pinned. */ export function loadPreset(p: Preset) { for (const k of PRESET_KEYS) if (!visualSettings.pins.includes(k)) visualSettings[k] = p[k]; saveVisualSettings(); } ===== src/lib/visuals/shared.svelte.ts ===== // The visuals shared by a sync play group (decision 0046, amended; the server keeps each group's settings: claude-5f's // /api/groups/{group}/visuals). Everything that shapes the picture is shared, so a slider moved on one device moves on // all of them: the look (its colours among it) and its pins, intensity, flash, speed, the grain and haze, the routes and // each band's times. What each device can afford (Quality, frame rate) and its brightness (a bright room, a dark one) // stay its own. // A device joining a group adopts the group's settings (a group with none yet takes the joiner's); one leaving keeps // what it was showing. What the owner changes goes to the server as it is made, a drag's many changes once every 150 ms // and only the settings changed (the server merges them, the routes route by route, so two people moving different // sliders do not undo each other), and comes back to every device on the live channel's `visuals` event, with all of // the group's settings; a device knows its own changes by `writer` and takes none older than the last it took. // A track's preset and the music's moments are not sent: every device works out the same ones. So the look's settings // not pinned are the group's only on the track they were sent on (`lookFor`: the dice's, a Reset's, a recall's); on // another track each device has that track's own, as every other device does. // The saved looks (snapshots, on the server for every device) hold the same settings. import { ApiError, getGroupVisuals, putGroupVisuals } from '../api'; import { on } from '../events.svelte'; import { head, playOn } from '../playOn.svelte'; import { PRESET_KEYS, type Preset } from './flame'; import { SCALE_KEYS } from './routes'; import { keptFrom, saveVisualSettings, visualSettings, type VisualSettings } from './settings.svelte'; /** The settings shared, and saved in a snapshot. */ export const SHARED_KEYS = [ ...PRESET_KEYS, 'pins', 'wild', 'intensity', 'flash', 'speed', 'smoothing', 'haze', 'fluid', 'tunnel', 'loose', 'effects', 'dive', ...SCALE_KEYS, 'routes', 'lighting', 'evolution', 'continuity', ] as const satisfies readonly (keyof VisualSettings)[]; export type Shared = Partial>; /** What each device keeps its own in a group (what it can afford, its room's brightness), but a snapshot keeps too: * recalled, they come back on the device that recalls it, and are never sent to the group. */ export const DEVICE_KEYS = ['quality', 'fps', 'brightness'] as const satisfies readonly (keyof VisualSettings)[]; /** A snapshot's form, kept with it so a later one can be read: 1, the shared settings (the first ones, from 63); 2, * every slider (2026-10-18); 3, 65's (Quality, Haze, the Colours as the look's: what they replaced is not read, by the * owner's choice). */ export const SNAPSHOT_V = 3; /** Every slider as it is now, for a snapshot. */ export function snapshotNow(): Record { return { ...sharedNow(), ...Object.fromEntries(DEVICE_KEYS.map((k) => [k, $state.snapshot(visualSettings[k])])), v: SNAPSHOT_V }; } /** A snapshot's own settings of this device into it (only those it has: the first snapshots have none); null for * Resolution or FPS is this device's default. */ export function applyDevice(settings: Record) { const given = Object.fromEntries(DEVICE_KEYS.filter((k) => k in settings).map((k) => [k, settings[k]])); if (!Object.keys(given).length) return; const clean = keptFrom({ ...$state.snapshot(visualSettings), ...given }); for (const k of Object.keys(given)) (visualSettings as Record)[k] = clean[k as keyof VisualSettings]; saveVisualSettings(); } /** This page, as its changes come back from the server. */ const writer = (globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`).slice(0, 64); /** The shared settings as they are now. */ export function sharedNow(): Shared { return Object.fromEntries(SHARED_KEYS.map((k) => [k, $state.snapshot(visualSettings[k])])); } /** Shared settings into this device's (each brought into its range): of the look's, those pinned, and those not pinned * where they were sent for the track showing. */ export function applyShared(settings: Record) { const given = { ...settings }; const pins = Array.isArray(given.pins) ? (given.pins as string[]) : visualSettings.pins; if (given.lookFor !== track) for (const k of PRESET_KEYS) if (!pins.includes(k)) delete given[k]; const clean = keptFrom({ ...$state.snapshot(visualSettings), ...given, pins }); for (const k of SHARED_KEYS) if (k in given) (visualSettings as Record)[k] = clean[k]; saveVisualSettings(); } // The group followed (its leader's name), the last version taken, its settings as last heard (and as this page has // changed them since), the changes waiting to be sent and the ones on their way, and the track the page shows. let group: string | null = null; let version = 0; let known: Record = {}; let outgoing: Shared = {}; let sending: Shared = {}; let pending: ReturnType | undefined; let track: string | null = null; /** Settings merged into others, the routes route by route. */ const merged = (into: Record, more: Record) => ({ ...into, ...more, ...(more.routes && typeof more.routes === 'object' ? { routes: { ...((into.routes as object) ?? {}), ...(more.routes as object) } } : {}), }); /** The track the visuals show now (`source/id`). */ export function showing(key: string | null) { track = key; } /** The look the group set for track `key` (the dice's, a Reset's, a recall's), where it set one: taken in place of the * track's own when it starts (a device whose track changed after the group's look was sent). */ export function groupLook(key: string | null): Partial | null { if (!group || !key || known.lookFor !== key) return null; return Object.fromEntries(PRESET_KEYS.filter((k) => k in known).map((k) => [k, known[k]])) as Partial; } /** Whose visuals this page shares (the server's key): its player's group's (the leader's name) where the player is in * one, else the player's own name, so a page driving a player shares them with that player's own page (the owner, * 2026-10-18: "My phone is most definitely controlling my salon tablet … So they are in a group to some extent"). * The player is the app's own, or the one the page plays on. */ const groupNow = () => { const name = head(); return name ? playOn.players.find((p) => p.name === name)?.group || name : null; }; /** Follows the group the page's player is in while the visuals show; returns the function that stops it. */ export function followGroup(): () => void { const stopEvents = on<{ group: string; version: number; settings: Record; writer: string }>('visuals', (e) => { if (!group || e.group !== group || e.writer === writer || !(e.version > version)) return; version = e.version; known = merged(known, e.settings); // Not over a change of this page's still to be sent or on its way: it is the later one, and the server will have // it. const mine = merged(sending, outgoing); const settings = { ...e.settings }; for (const k of Object.keys(mine)) if (k !== 'routes' && k !== 'lookFor') delete settings[k]; if (mine.routes && settings.routes && typeof settings.routes === 'object') settings.routes = { ...(settings.routes as object), ...(mine.routes as object) }; applyShared(settings); }); const stopWatch = $effect.root(() => { $effect(() => { const g = groupNow(); if (g !== group) void joined(g); }); }); return () => { stopEvents(); stopWatch(); clearTimeout(pending); outgoing = {}; sending = {}; known = {}; group = null; version = 0; }; } const status = (e: unknown) => (e instanceof ApiError ? e.status : undefined); /** Joined `g` (or left a group, with null: what shows stays, as kept on this device already). */ async function joined(g: string | null) { group = g; version = 0; known = {}; outgoing = {}; if (!g) return; const take = (got: { version: number; settings: Record }) => { version = got.version; known = { ...got.settings }; applyShared(got.settings); }; try { const got = await getGroupVisuals(g); if (group === g) take(got); } catch (e) { if (status(e) !== 404 || group !== g) return; // A group with none yet takes this device's; where another got there first, its are taken. const mine = { ...sharedNow(), lookFor: track }; try { const made = await putGroupVisuals(g, { base: 0, settings: mine, writer }); if (group !== g) return; version = Math.max(version, made.version); known = merged(known, mine); } catch (e2) { if (status(e2) !== 409 || group !== g) return; const got = await getGroupVisuals(g).catch(() => null); if (got && group === g) take(got); } } } /** The owner changed shared settings (`changes`: the settings changed; the routes only those changed): sent to the * group, if the page is in one. Look settings go with the track they are for. */ export function share(changes: Shared) { if (!group) return; const sent: Shared = PRESET_KEYS.some((k) => k in changes) ? { ...changes, lookFor: track } : changes; outgoing = merged(outgoing, sent); known = merged(known, sent); clearTimeout(pending); pending = setTimeout(flush, 150); } async function flush() { const g = group; const settings = outgoing; outgoing = {}; if (!g || !Object.keys(settings).length) return; sending = settings; try { const done = await putGroupVisuals(g, { base: version, settings, writer }); if (group === g) version = Math.max(version, done.version); } catch { // Not sent (the server not reached): the next change sends its own; what shows here stays. } finally { if (sending === settings) sending = {}; } } /** The visuals as Cratarr made them on 2026-10-18 (preview 62), kept as data so they can always be brought back, however * the defaults change later: each track's own look (nothing pinned), and everything else as it was then (Resolution * and FPS each device's own default). Listed first * among the saved looks; it cannot be renamed or deleted. A recall puts back its routes whole: any route it does not * have is turned off. */ export const STANDARD = { id: 'standard', name: 'Standard', created: 0, settings: { v: 3, // 62's Points, Steps and Resolution (19, 3 and the screen's default) are Quality's 60 %; its Floor 0 and Depth 50 % // Haze's 0; its Colours (the preset's own) the look's, nothing pinned. quality: 0.6, fps: null, brightness: 1, pins: [], intensity: 1, flash: 1, speed: 1, smoothing: 0.5, haze: 0, fluid: 0, tunnel: 0, loose: 0, dive: 0, lighting: [0.2, 0.18, 0.15, 0.13, 0.1, 0.08], evolution: [0.25, 0.25, 0.25, 0.25, 0.25, 0.25], continuity: [0.5, 0.5, 0.5, 0.5, 0.5, 0.5], routes: { 'loudness|-|size': 1, 'loudness|-|saturation': 1, 'loudness|-|rotation': 0.2, 'beat|-|brightness': 0.2, 'beat|-|size': 0.08, ...Object.fromEntries( [0, 1, 2, 3, 4, 5].flatMap((b) => [ [`level|${b}|shape`, 1], [`level|${b}|rotation`, 0.6], [`level|${b}|hue`, 0.08], [`hits|${b}|lights`, 1], ]), ), }, }, } as const; ===== src/lib/visuals/shown.svelte.ts ===== // Whether the full-screen visuals show (decision 0046): opened from the full player, drawn by the shell (App.svelte), // so the player moving beside or over the page (the window resizes as it goes full screen) does not close them. // `side`: beside the page, in the panel at the right where the player goes (on a window roomy enough), rather than over // the whole page; remembered on this browser, as the player's own place is. const SIDE = 'cratarr-visuals-side'; const keptSide = () => { try { return localStorage.getItem(SIDE) === '1'; } catch { return false; } }; /** The visuals open as the page reloaded for a new build (update.svelte.ts, sessionStorage): they open again. */ export const REOPEN = 'cratarr-visuals-reopen'; const reopened = (() => { try { const open = sessionStorage.getItem(REOPEN) === '1'; sessionStorage.removeItem(REOPEN); return open; } catch { return false; } })(); export const visuals = $state({ open: reopened, side: keptSide() }); /** The visuals beside the page (true) or over it. */ export function setVisualsSide(side: boolean) { visuals.side = side; try { localStorage.setItem(SIDE, side ? '1' : '0'); } catch { /* for this visit only */ } } ===== src/lib/visuals/signals.ts ===== // A track's signals (decision 0046, amended; format 3, `GET /api/track/{source}/{id}/bands`): what the server hears in it, // in one decode the first time they are asked for or ahead of a queue. Six bands' level, flux, hits, percussive and // harmonic parts, crest and width; the whole track's loudness as heard, spectral centroid, chroma, harmonic change, // section novelty and boundaries, bar position, swells and build-up. Read once per track and kept while the page is // open; the visuals ask for the playing track's and the next one's. import { asksSignIn, toSignIn } from '../reach.svelte'; /** The sections, by the server's ids (analysis/signals.rs). */ export const ID = { table: 0, loudness: 1, level: 2, flux: 3, hits: 4, percussive: 5, harmonic: 6, percussiveHits: 7, crest: 8, width: 9, centroid: 10, chroma: 11, harmonicChange: 12, harmonicChanges: 13, novelty: 14, boundaries: 15, bar: 16, swell: 17, bandSwell: 18, buildUp: 19, } as const; const KIND = { series: 0, hits: 1, marks: 2, table: 3 } as const; /** Series whose frame i is the moment i/rate (the loudness, each window by its middle, and what is worked out from it; * novelty two a second where a track has no beats). Every other series' frame i holds what fell in [i/rate, * (i+1)/rate) (analysis/signals.rs, `bucket`): its moment is that frame's middle. */ const AT_MOMENTS = new Set([ID.loudness, ID.novelty, ID.swell, ID.buildUp]); /** A series: `rate` values a second (0: a value a beat), `channels` bytes a frame. */ export type Series = { rate: number; channels: number; scale: number; data: Uint8Array }; /** Times (seconds, in order) with a byte each, 0..1. */ export type Events = { at: Float64Array; value: Float32Array }; export type Signals = { /** The bands' edges (Hz; 0 open), low to high. */ edges: [number, number][]; series: Map; /** Hits sections, a list a band. */ hits: Map; marks: Map; /** Each band's hits a second over the track, and their mean strength (for steps as large on average as steady * motion would be). */ perSecond: number[]; meanStrength: number[]; }; /** The server could not work them out then: asked again after this long, at most this many times. */ const AGAIN_MS = 15_000; const TRIES = 3; const kept = new Map(); const asking = new Map(); /** A track's signals: undefined while they are asked for (or worked out), null where it has none (a file Cratarr does * not keep, or one that does not decode whole). */ export function signalsOf(source: string, id: string): Signals | null | undefined { const key = `${source}/${id}`; if (kept.has(key)) return kept.get(key); const now = performance.now(); const was = asking.get(key); if (was && (was.busy || now - was.at < AGAIN_MS)) return undefined; if (was && was.tries >= TRIES) { kept.set(key, null); return null; } const tries = (was?.tries ?? 0) + 1; asking.set(key, { at: now, tries, busy: true }); const done = () => asking.set(key, { at: performance.now(), tries, busy: false }); // `v`: the form read here; a browser keeps an older one under another address. fetch(`/api/track/${encodeURIComponent(source)}/${encodeURIComponent(id)}/bands?v=3`) .then(async (r) => { if (asksSignIn(r)) toSignIn(); if (r.ok) kept.set(key, decode(await r.arrayBuffer())); else if (r.status !== 503) kept.set(key, null); }) .then(done, done); return undefined; } /** The server's form: a format byte (3), the number of sections, then each section's header (id, kind, rate in tenths * of Hz, channels, count, scale: 10 bytes, little-endian) and its bytes. Anything else: none. */ export function decode(buffer: ArrayBuffer): Signals | null { const v = new DataView(buffer); const n = buffer.byteLength; if (n < 2 || v.getUint8(0) !== 3) return null; const out: Signals = { edges: [], series: new Map(), hits: new Map(), marks: new Map(), perSecond: [], meanStrength: [] }; let at = 2; const events = (count: number): Events | null => { if (n < at + count * 5) return null; const e: Events = { at: new Float64Array(count), value: new Float32Array(count) }; for (let i = 0; i < count; i++, at += 5) { e.at[i] = v.getUint32(at, true) / 1000; e.value[i] = v.getUint8(at + 4) / 255; } return e; }; for (let s = v.getUint8(1); s > 0; s--) { if (n < at + 10) return null; const id = v.getUint8(at); const kind = v.getUint8(at + 1); const rate = v.getUint16(at + 2, true) / 10; const channels = v.getUint8(at + 4); const count = v.getUint32(at + 5, true); const scale = v.getUint8(at + 9); at += 10; if (kind === KIND.series) { const len = count * channels; if (n < at + len) return null; out.series.set(id, { rate, channels, scale, data: new Uint8Array(buffer, at, len) }); at += len; } else if (kind === KIND.hits) { const lists: Events[] = []; for (let c = 0; c < channels; c++) { if (n < at + 4) return null; const k = v.getUint32(at, true); at += 4; const e = events(k); if (!e) return null; lists.push(e); } out.hits.set(id, lists); } else if (kind === KIND.marks) { const e = events(count); if (!e) return null; out.marks.set(id, e); } else if (kind === KIND.table) { if (n < at + count * 4) return null; for (let i = 0; i < count; i++, at += 4) out.edges.push([v.getUint16(at, true), v.getUint16(at + 2, true)]); } else return null; } if (at !== n) return null; // The track's length, from its loudness (10 a second): each band's hits a second over it. const loud = out.series.get(ID.loudness); const secs = loud && loud.rate > 0 ? loud.data.length / loud.channels / loud.rate : 0; const hits = out.hits.get(ID.hits) ?? []; out.perSecond = hits.map((h) => (secs > 0 ? h.at.length / secs : 0)); out.meanStrength = hits.map((h) => (h.value.length ? h.value.reduce((a, b) => a + b, 0) / h.value.length : 1)); return out; } /** A series' channels at `t` seconds (each 0..1, between its frames), or at beat `beat` for a series of a value a beat; * null where the track has none. */ export function seriesAt(s: Signals, id: number, t: number, beat: number | null = null): Float32Array | null { const x = s.series.get(id); if (!x || !x.channels) return null; const frames = x.data.length / x.channels; if (!frames) return null; const out = new Float32Array(x.channels); if (x.rate === 0) { if (beat === null) return null; const f = Math.min(Math.max(beat, 0), frames - 1); for (let c = 0; c < x.channels; c++) out[c] = x.data[f * x.channels + c] / 255; return out; } const pos = Math.min(Math.max(t * x.rate - (AT_MOMENTS.has(id) ? 0 : 0.5), 0), frames - 1); const f = Math.floor(pos); const g = Math.min(f + 1, frames - 1); for (let c = 0; c < x.channels; c++) { const a = x.data[f * x.channels + c]; out[c] = (a + (x.data[g * x.channels + c] - a) * (pos - f)) / 255; } return out; } /** The loudness as heard at `t` (LUFS), or null. */ export function lufsAt(s: Signals, t: number): number | null { const v = seriesAt(s, ID.loudness, t); return v ? -70 + (v[0] * 255) / 3 : null; } /** The last of `e` at or before `t`: its place (-1 before the first), seconds since, and its value. */ export function lastOf(e: Events | undefined, t: number): { index: number; since: number; value: number } { if (!e || !e.at.length || !(t >= e.at[0])) return { index: -1, since: Infinity, value: 0 }; let lo = 0; let hi = e.at.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (e.at[mid] <= t) lo = mid; else hi = mid - 1; } return { index: lo, since: t - e.at[lo], value: e.value[lo] }; } ===== src/lib/visuals/stirring.ts ===== // How the music stirs the fluid (decision 0047, amended): as timed as the flames, with no new settings. The owner asked // how; the answer, all here: // // - Each hit and beat is a kick, short and strong, not a push for as long as a light rings; and a kick lands about 60 ms // before its moment where the moment is known ahead (the server's signals), so the motion peaks on it, not after. // - The flow breathes with the pulse: it runs freer just after each beat and is held tighter between. // - A downbeat shoves harder, from the middle; a new section sets a whirl going round it. // - Each band's Continuity steers its part: stepped (0) kicks only on the band's own hits, steady (1) stirs all the // time with its level, between them some of each. // - The beats' pushes turn a little each beat, so their pattern rotates in tempo. // - What each push is worth is a route's (64.1; the owner asked that nothing moving the fluid be hidden): Hits, Beat, // Downbeat, Section and Level into Stir, Loudness and Beat into Flow, Swell and Section into Swirl. Across a route's // curve is the push's own strength as it always was (a hit's, a beat's, a band's level, the loudness, the pulse, the // swell, the whirl; a downbeat and a section are 1); straight at 1, the defaults, each gives it unchanged. Not // scaled by the loudness, as the routes the flame's aspects take are. // // Pure and deterministic (no randomness: every device of a sync group stirs alike). `stir` each frame the fluid steps. import type { Flow, Force } from './fluid'; import type { Signal } from './routes'; /** A route into the fluid's `aspect` at `x` (routes.ts `heightOn`); none given: straight at 1, `x` itself. */ export type Gain = (signal: Signal, band: number | null, aspect: 'stir' | 'flow' | 'swirl', x: number) => number; const AS_EVER: Gain = (_signal, _band, _aspect, x) => x; /** Kicks land this long before a moment known ahead (s). */ export const LEAD = 0.06; /** A kick fades over this long (s), and is gone after three of them. */ const KICK_S = 0.05; /** A kick's strength against a light's push: about a ringing light's whole push, given at once. */ const KICK = 1.5; /** How fast a kick moves the fluid (texture coordinates a second at strength 1), and how far it reaches. */ const KICK_SPEED = 0.5; const KICK_RADIUS = 0.12; /** The beats' pushes: four, this far out from the middle, turning this far each beat (radians). */ const BEAT_OUT = 0.12; const BEAT_TURN = 0.15; /** A steady stir's strength at a band's full level. */ const STIR = 0.25; export type StirInput = { t: number; dt: number; amount: number; parts: ([number, number] | null)[]; turns: number[]; lights: number[]; level: number[]; continuity: number[]; hits: ({ index: number; strength: number; nextIn: number | null } | null)[]; beat: { index: number; since: number; length: number; strength: number; nextIn: number | null } | null; downbeat: number; section: number; loudness: number; swell: number; bps: number; /** The Waves column now (its routes, scaled), and the track's own time (seconds): the waves' height, way and phase. */ waves?: number; position?: number; center: [number, number]; gain?: Gain; }; type Kick = { x: number; y: number; dx: number; dy: number; radius: number; strength: number; born: number }; export type Stirring = { kicks: Kick[]; /** Per band, the hit (index) last kicked for: the next one ahead, or the one just come. */ kickedHit: number[]; kickedBeat: number; /** Whether the downbeat's and the section's pulses were up last frame (a kick on each rise; up when first seen is no * rise, as below). */ downUp: boolean; sectionUp: boolean; /** The last section's whirl, for the swirl it adds (its time). */ whirlAt: number; }; export function newStirring(): Stirring { return { kicks: [], kickedHit: [], kickedBeat: NaN, downUp: true, sectionUp: true, whirlAt: -Infinity }; } const unit = (x: number, y: number): [number, number] => { const l = Math.hypot(x, y); return l > 1e-6 ? [x / l, y / l] : [1, 0]; }; /** This frame's forces on the fluid and how it flows. */ export function stir(s: Stirring, i: StirInput): { forces: Force[]; flow: Flow } { // The flow breathes: freer just after a beat, held tighter between (its energy, how long its motion lasts). const pulse = i.beat ? Math.exp(-i.beat.since / Math.max(0.05, 0.3 * i.beat.length)) : 0; const whirl = Math.exp(-(i.t - s.whirlAt) / 2); const g = i.gain ?? AS_EVER; const flow: Flow = { energy: Math.min(1, Math.max(0, g('loudness', null, 'flow', i.loudness) * (0.7 + 0.6 * g('beat', null, 'flow', pulse)))), swirl: Math.max(0, 1 + 2 * g('swell', null, 'swirl', Math.max(0, i.swell)) + 2 * g('section', null, 'swirl', whirl)), // The waves: once round their cycle in eight seconds, their way turning once in two minutes, both with the track // (each kept within a turn: a sine of a large number loses its precision on some GPUs). ...(i.waves ? { wave: { amount: i.waves, angle: (((i.position ?? i.t) / 120) % 1) * 2 * Math.PI, phase: (((i.position ?? i.t) / 8) % 1) * 2 * Math.PI } } : {}), }; if (!(i.amount > 0)) { s.kicks = []; return { forces: [], flow }; } const kick = (x: number, y: number, dx: number, dy: number, strength: number, radius = KICK_RADIUS) => s.kicks.push({ x, y, dx, dy, radius, strength, born: i.t }); // Each band's hits: a kick at its part, out from the middle and round the way it turns, as strong as the hit and as // its band is stepped; ahead of the hit where it is known, else as it comes. i.hits.forEach((h, b) => { const at = i.parts[b]; if (!h || !at) return; const stepped = 1 - Math.min(1, Math.max(0, i.continuity[b] ?? 0.5)); // First seen: what came before is not kicked for. if (s.kickedHit[b] === undefined) { s.kickedHit[b] = h.index; return; } let due: number | null = null; if (h.nextIn !== null && h.nextIn <= LEAD && s.kickedHit[b] !== h.index + 1) due = h.index + 1; else if (s.kickedHit[b] !== h.index && s.kickedHit[b] !== h.index + 1) due = h.index; if (due === null) return; s.kickedHit[b] = due; if (stepped <= 0) return; const worth = g('hits', b, 'stir', 0.3 + 0.7 * Math.min(1, Math.max(0, h.strength))); if (!worth) return; const [ox, oy] = unit(at[0] - i.center[0], at[1] - i.center[1]); const round = Math.sign(i.turns[b] ?? 0) * 0.6; const [dx, dy] = unit(ox - oy * round, oy + ox * round); kick(at[0], at[1], dx * KICK_SPEED, dy * KICK_SPEED, KICK * stepped * worth); }); // The beats: four pushes out from the middle, their pattern turned a little further each beat; a downbeat's harder. const b = i.beat; if (b && Number.isNaN(s.kickedBeat)) s.kickedBeat = b.index; else if (b) { let due: number | null = null; if (b.nextIn !== null && b.nextIn <= LEAD && s.kickedBeat !== b.index + 1) due = b.index + 1; else if (s.kickedBeat !== b.index && s.kickedBeat !== b.index + 1) due = b.index; if (due !== null) { s.kickedBeat = due; const strength = KICK * 0.6 * g('beat', null, 'stir', 0.35 + 0.65 * Math.min(1, Math.max(0, b.strength))); if (strength) for (let k = 0; k < 4; k++) { const a = due * BEAT_TURN + (k * Math.PI) / 2; const [cx, cy] = [Math.cos(a), Math.sin(a)]; kick(i.center[0] + BEAT_OUT * cx, i.center[1] + BEAT_OUT * cy, cx * 0.4, cy * 0.4, strength, 0.1); } } } const downUp = i.downbeat > 0.5; const down = downUp && !s.downUp ? KICK * 1.2 * g('downbeat', null, 'stir', 1) : 0; if (down) { for (let k = 0; k < 4; k++) { const a = (k * Math.PI) / 2 + Math.PI / 4; const [cx, cy] = [Math.cos(a), Math.sin(a)]; kick(i.center[0] + BEAT_OUT * cx, i.center[1] + BEAT_OUT * cy, cx * 0.6, cy * 0.6, down, 0.14); } } s.downUp = downUp; // A new section: six kicks round the middle, all the same way round, and the whirls kept stronger for a while. const sectionUp = i.section > 0.5; if (sectionUp && !s.sectionUp) { s.whirlAt = i.t; const worth = KICK * g('section', null, 'stir', 1); if (worth) for (let k = 0; k < 6; k++) { const a = (k * Math.PI) / 3; const [cx, cy] = [Math.cos(a), Math.sin(a)]; kick(i.center[0] + 0.2 * cx, i.center[1] + 0.2 * cy, -cy * 0.5, cx * 0.5, worth, 0.12); } } s.sectionUp = sectionUp; // What is pushed this frame: each kick fading (a frame's share, so the same at any frame rate), and each steady // band's stir with its level. const frames = i.dt * 30; s.kicks = s.kicks.filter((k) => i.t - k.born < 3 * KICK_S); const forces: Force[] = s.kicks.map((k) => ({ x: k.x, y: k.y, dx: k.dx, dy: k.dy, radius: k.radius, strength: k.strength * i.amount * Math.exp(-(i.t - k.born) / KICK_S) * frames, })); i.parts.forEach((at, p) => { if (!at) return; const steady = Math.min(1, Math.max(0, i.continuity[p] ?? 0.5)); const level = steady > 0 ? g('level', p, 'stir', Math.min(1, Math.max(0, i.level[p] ?? 0))) : 0; if (!level) return; const [ox, oy] = unit(at[0] - i.center[0], at[1] - i.center[1]); const round = Math.sign(i.turns[p] ?? 0) * 0.6; const [dx, dy] = unit(ox - oy * round, oy + ox * round); forces.push({ x: at[0], y: at[1], dx: dx * KICK_SPEED, dy: dy * KICK_SPEED, radius: KICK_RADIUS, strength: STIR * steady * level * i.amount * frames }); }); return { forces, flow }; } ===== src/lib/visuals/trial.svelte.ts ===== // The rating page's trial (decision 0046, amended): while `settings` is set, the visuals draw with it instead of the // saved settings, and nothing of it is saved or shared; `meters` is the loop's meters now, and `loop` what else the // loop knows that a rating must be read against (the music's own activity, whether the flash rule held the picture // back, the device and its pace), for the page to record. `rating.run` is whether the page runs (0: not), a new number // for each start; kept here rather than in the visuals, as they are drawn anew where they move (beside the page or over // it) and a run goes on there. import type { MeterValues } from './meters'; import type { VisualSettings } from './settings.svelte'; /** What the loop knows now, beside the meters (every 250 ms). */ export type TrialLoop = { /** The music: its intensity as heard (0..1), beats a second, hits a second (all bands). */ intensity: number; bps: number; hitsPerSecond: number; /** The music's activity as the governor's budgets take it (0..1: `budgetOf`). */ activity: number; /** The flash rule's lever: 1 open, less where it held Flash's swing back. */ swing: number; /** How often the look drawn changed other than by how it behaves (a track's look, a recalled one, one from the * group, another colour mode or the cover's colours: not Drift's walk nor the Symmetry moments), and whether such a * change is still blending in. */ looks: number; settling: boolean; /** The picture's size on the screen (CSS pixels). */ view: [number, number]; /** What the device draws: points, steps, frames a second; and which device it is. */ points: number; steps: number; fps: number; device: string; }; export const trial = $state<{ settings: VisualSettings | null; meters: MeterValues | null; loop: TrialLoop | null }>({ settings: null, meters: null, loop: null }); export const rating = $state({ run: 0 }); ===== src/lib/visuals/zoom.ts ===== // The infinite zooms (decision 0050; the owner, after trying them: "I want both"). Pure: the renderer (render.ts) runs // them, the visuals (aspects.ts) say how fast. // // - The tunnel: the light kept from frame to frame is read a little nearer the middle each frame, so what was drawn is // drawn larger and streams outward for ever; the flame itself keeps its size. // - The dive: the camera zooms toward the fixed point of the flame's part that is most nearly a turned, shrunk copy of // the whole, by that part's own shrink and turn. A flame is the union of its parts' copies of itself, so after one // loop the part has become the whole again: the zoom never ends. A share of the points, growing through the loop, is // drawn as that part's copy of them, so the part is as densely drawn at the end of the loop as the whole at its // start. Where the part is not exactly a turn and shrink (its variations bend it), the picture morphs a little as a // loop starts again: the fit's misfit. import { apply, picker, randomFrom, type Flame } from './flame'; /** The fastest each zoom goes at its slider's top (its log a second: 0.5 is 65 % larger a second). */ export const TUNNEL_MOST = 0.5; export const DIVE_MOST = 0.5; /** A part fit as a turn and shrink of the plane, `w ≈ a·z + b` in complex numbers; `error`: how far off (the misfit * against the sample's spread). */ export type DiveFit = { a: [number, number]; b: [number, number]; error: number }; /** How nearly part `j` of a flame is a turn and shrink of the plane: the a, b best mapping a sample of the flame's * points onto that part's copies of them (least squares). Null where there is no such part or too few points. */ export function diveFit(flame: Flame, j: number, n = 1200, seed = 4242): DiveFit | null { const xf = flame.xforms[j]; if (!xf) return null; const rnd = randomFrom(seed); const pick = picker(flame.xforms); let x = 0.1; let y = 0.1; const zs: number[] = []; const ws: number[] = []; for (let i = 0; i < n + 30; i++) { const p = apply(flame.xforms[pick(rnd())], x, y, rnd); if (!Number.isFinite(p[0]) || !Number.isFinite(p[1]) || Math.abs(p[0]) > 1e5 || Math.abs(p[1]) > 1e5) { x = rnd() * 2 - 1; y = rnd() * 2 - 1; continue; } [x, y] = p; if (i < 30) continue; const w = apply(xf, x, y, rnd); if (!Number.isFinite(w[0]) || !Number.isFinite(w[1])) continue; zs.push(x, y); ws.push(w[0], w[1]); } const m = zs.length / 2; if (m < 100) return null; let zx = 0; let zy = 0; let wx = 0; let wy = 0; for (let i = 0; i < m; i++) { zx += zs[2 * i]; zy += zs[2 * i + 1]; wx += ws[2 * i]; wy += ws[2 * i + 1]; } zx /= m; zy /= m; wx /= m; wy /= m; let nr = 0; let ni = 0; let den = 0; for (let i = 0; i < m; i++) { const dzx = zs[2 * i] - zx; const dzy = zs[2 * i + 1] - zy; const dwx = ws[2 * i] - wx; const dwy = ws[2 * i + 1] - wy; nr += dwx * dzx + dwy * dzy; ni += dwy * dzx - dwx * dzy; den += dzx * dzx + dzy * dzy; } if (den < 1e-12) return null; const a: [number, number] = [nr / den, ni / den]; const b: [number, number] = [wx - (a[0] * zx - a[1] * zy), wy - (a[1] * zx + a[0] * zy)]; let miss = 0; for (let i = 0; i < m; i++) { const px = a[0] * zs[2 * i] - a[1] * zs[2 * i + 1] + b[0]; const py = a[1] * zs[2 * i] + a[0] * zs[2 * i + 1] + b[1]; miss += (ws[2 * i] - px) ** 2 + (ws[2 * i + 1] - py) ** 2; } return { a, b, error: Math.sqrt(miss / den) }; } /** The part to dive into: of those shrinking the whole to between a fifth and nine tenths, the one fitting best (the * least morph as a loop starts again). */ export function diveTarget(flame: Flame): ({ j: number } & DiveFit) | null { let best: ({ j: number } & DiveFit) | null = null; for (let j = 0; j < flame.xforms.length; j++) { const fit = diveFit(flame, j, 600); if (!fit) continue; const shrink = Math.hypot(fit.a[0], fit.a[1]); if (shrink < 0.2 || shrink > 0.9) continue; if (!best || fit.error < best.error) best = { j, ...fit }; } if (!best) return null; const fine = diveFit(flame, best.j); return fine ? { j: best.j, ...fine } : best; } /** How long one loop takes at `rate` (the zoom's log a second): as long as the zoom takes to grow by the part's shrink. */ export const divePeriod = (fit: DiveFit, rate: number) => Math.log(1 / Math.hypot(fit.a[0], fit.a[1])) / Math.max(1e-3, rate); /** The camera at `t` into the loop (0..1): the zoom (a complex factor, a^{-t}, growing by 1/shrink over the loop and * turning back the part's turn) about the part's fixed point (b / (1 − a)); and how fast it turns (radians a second * at `rate`). */ export function diveCamera(fit: DiveFit, t: number, rate: number): { zoom: [number, number]; at: [number, number]; turnRate: number } { const shrink = Math.hypot(fit.a[0], fit.a[1]); const turn = Math.atan2(fit.a[1], fit.a[0]); const m = Math.exp(-t * Math.log(shrink)); const den = (1 - fit.a[0]) ** 2 + fit.a[1] ** 2; return { zoom: [m * Math.cos(-t * turn), m * Math.sin(-t * turn)], at: [(fit.b[0] * (1 - fit.a[0]) - fit.b[1] * fit.a[1]) / den, (fit.b[1] * (1 - fit.a[0]) + fit.b[0] * fit.a[1]) / den], turnRate: -turn / divePeriod(fit, rate), }; } /** How fast a dive at `rate` would turn the picture for this flame (radians a second, unsigned): the dive's turn, * foreseen from the part it would dive into, for the precheck to count as the live governor does * (`renderer.diveTurnRate`). 0 where there is no dive or no part to dive into. */ export function diveTurnOf(flame: Flame, rate: number): number { if (!(rate > 0)) return 0; const target = diveTarget(flame); return target ? Math.abs(diveCamera(target, 0, rate).turnRate) : 0; } ===== src/lib/waveforms.svelte.ts ===== // A track's waveform (decision 0035): its analysis' 512 bins, each its RMS (0..=255 of the track's loudest bin), read // once per track (the browser keeps it a month, with an ETag) and kept here while the page is open. Undefined while it // loads, null where the track has none (not analysed yet, or its server is not Cratarr's own). import { asksSignIn, toSignIn } from './reach.svelte'; const waves = $state>({}); const asked = new Set(); /** Reads a track's waveform, once. */ export function loadWaveform(source: string, id: string) { const key = `${source}/${id}`; if (!asked.has(key)) { asked.add(key); fetch(`/api/track/${encodeURIComponent(source)}/${encodeURIComponent(id)}/waveform`) .then((r) => { if (asksSignIn(r)) toSignIn(); return r.ok ? r.arrayBuffer() : null; }) .then( (b) => (waves[key] = b && b.byteLength ? new Uint8Array(b) : null), () => { // Asked again the next time it shows. asked.delete(key); }, ); } } export const waveformOf = (source: string, id: string): Uint8Array | null | undefined => waves[`${source}/${id}`]; /** The bins as `n` bars, 0..1 each: the loudest bin of each stretch, against the track's loudest (its shape, however * loud it was mastered). */ export function bars(bins: Uint8Array, n: number): number[] { const top = Math.max(1, ...bins); return Array.from({ length: n }, (_, i) => { const from = Math.floor((i * bins.length) / n); const to = Math.max(from + 1, Math.floor(((i + 1) * bins.length) / n)); let m = 0; for (let j = from; j < to; j++) m = Math.max(m, bins[j]); return m / top; }); } ===== src/showcase-first.ts ===== // Runs before the visuals' modules load (src/showcase.ts imports it first): they read their settings and may ask the // server as they load. import { LENSES } from './lib/visuals/flame'; import { defaultRoutes } from './lib/visuals/routes'; /** The analyses (/signals/.bin and .beats) and their lengths in seconds. */ export const TRACKS = [ { id: 't1', secs: 155.9 }, { id: 't2', secs: 344.5 }, { id: 't3', secs: 301.4 }, { id: 't4', secs: 104.7 }, ]; // The server's two answers the visuals need, from the page's own files; every other API call: none. const fetched = window.fetch.bind(window); window.fetch = (input: RequestInfo | URL, init?: RequestInit) => { const url = new URL(input instanceof Request ? input.url : String(input), location.href); if (url.origin !== location.origin || !url.pathname.startsWith('/api/')) return fetched(input, init); const m = url.pathname.match(/^\/api\/track\/cratarr\/(t\d)\/(bands|beats)$/); if (m && TRACKS.some((t) => t.id === m[1])) return fetched(`/signals/${m[1]}.${m[2] === 'bands' ? 'bin' : 'beats'}`); return Promise.resolve(new Response(null, { status: 404 })); }; // ?rate=1 would start the visuals' rating page, which needs Cratarr's server. if (new URLSearchParams(location.search).has('rate')) { const url = new URL(location.href); url.searchParams.delete('rate'); history.replaceState(history.state, '', url); } // A first visit's look: the Cratarr lens, kept whatever a track's preset says, in a fire's colours. const KEY = 'cratarr-visuals'; // The page's own changes to Cratarr's defaults, and how far a visitor's kept settings have had them (`MARK`). const MARK = 'oskarolivier-look'; const LOOK = 3; /** Colours: Fire (65's sixth, settings.svelte.ts `family`). */ const FIRE = 5; /** The fluid's routes (Stir, Flow and Swirl) at this share of Cratarr's: its stirring calmer (the owner, 2026-10-18). */ const FLUID_ROUTES = 0.4; const fluidRoutes = () => Object.fromEntries( Object.entries(defaultRoutes(6)) .filter(([key]) => /\|(stir|flow|swirl)$/.test(key)) .map(([key, v]) => [key, v * FLUID_ROUTES]), ); try { const kept = localStorage.getItem(KEY); const had = Number(localStorage.getItem(MARK) ?? (kept ? 1 : 0)); if (!kept) localStorage.setItem(KEY, JSON.stringify({ final: LENSES.length, family: FIRE, pins: ['final', 'family'], routes: fluidRoutes() })); else if (had < LOOK) { const s = JSON.parse(kept); // Kept from before the calmer fluid: its routes calmer too. if (had < 2) s.routes = { ...(s.routes ?? {}), ...fluidRoutes() }; // Kept from before 65, whose colours are the Colours setting's (65 drops the old one): the fire's, pinned. s.family = FIRE; s.pins = [...new Set([...(Array.isArray(s.pins) ? s.pins : []), 'family'])]; localStorage.setItem(KEY, JSON.stringify(s)); } localStorage.setItem(MARK, String(LOOK)); // The page's own zoom switch, before 65 had Tunnel and Dive as settings. localStorage.removeItem('oskarolivier-zoom'); } catch { // No storage (a private window): the visuals start from their own defaults. } ===== src/showcase-mic.ts ===== // The microphone, for the visuals to hear the room instead of the stored analyses: what Cratarr's visuals hear live // while the server has no signals for a track (lib/visuals/live.ts), listened to from the microphone instead of the // page's own audio. Nothing of it is played back, kept or sent: no route goes to the speakers. let heard: { context: AudioContext; node: AudioNode; stream: MediaStream } | null = null; /** Whether this browser can be asked for a microphone (a secure page only). */ export const canHear = typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia && typeof AudioContext !== 'undefined'; export const hearing = () => heard !== null; /** The microphone as Web Audio has it, where the visuals listen (in place of the page player's, see src/showcase-tap.ts); * null while it is off. */ export function audioTap(): { context: AudioContext; node: AudioNode } | null { return heard && heard.context.state === 'running' ? { context: heard.context, node: heard.node } : null; } /** Asks for the microphone (after a press: an audio context starts on one) and listens to it; `ended`: it went away * (unplugged, or its permission taken back). Throws where it is refused. */ export async function hear(ended: () => void): Promise { // The sound as it is: echo cancelling, noise suppression and gain control are for voices, and would take the beat. const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }, }); const context = new AudioContext(); await context.resume().catch(() => {}); heard = { context, node: context.createMediaStreamSource(stream), stream }; for (const track of stream.getAudioTracks()) track.addEventListener('ended', () => { if (heard?.stream === stream) { stopHearing(); ended(); } }); } export function stopHearing() { if (!heard) return; for (const track of heard.stream.getTracks()) track.stop(); void heard.context.close(); heard = null; } ===== src/showcase-tap.ts ===== // The page player as lib/visuals/music.svelte.ts sees it here (vite.showcase.config.ts gives it this module for // '../localPlayer.svelte'): Cratarr's own, but the sound it listens to is the microphone's (src/showcase-mic.ts). export * from './lib/localPlayer.svelte'; export { audioTap } from './showcase-mic'; ===== src/showcase.ts ===== // oskarolivier.nl: Cratarr's visuals (web/src/lib/visuals at Cratarr's tag preview-65.1, unchanged) shown on their own. // No music plays and no Cratarr server answers: a few tracks' stored analyses (their bands and beats, as Cratarr's // server serves them) are played back on a clock, one after another, the way the visuals lab simulates a player; or // the room is heard through the microphone, as Cratarr's visuals hear a track the server has no analyses for yet. // Copyright the Cratarr authors. AGPL-3.0-or-later; this page's source: /source.txt. import { TRACKS } from './showcase-first'; import './app.css'; import { mount, unmount } from 'svelte'; import { visuals } from './lib/visuals/shown.svelte'; import Visuals from './lib/visuals/Visuals.svelte'; import { local } from './lib/localPlayer.svelte'; import { canHear, hear, hearing, stopHearing } from './showcase-mic'; // The player: the tracks in turn from a random one, each played to its end, without audio; or, with the microphone // on, one "track" as long as a day with no stored analyses, so the visuals hear the room instead. const MIC = [{ id: 'mic', secs: 24 * 3600 }]; let tracks = TRACKS; let started = 0; function play(list: typeof TRACKS, index: number) { tracks = list; local.queue = list.map((t) => ({ source: 'cratarr', id: t.id, name: '', artist: '', album: null, album_id: null }) as never); local.index = index; local.duration = list[index].secs; local.position = 0; local.paused = false; started = performance.now(); } const shuffled = () => play(TRACKS, Math.floor(Math.random() * TRACKS.length)); shuffled(); setInterval(() => { const at = (performance.now() - started) / 1000; if (at < tracks[local.index].secs) { local.position = at; return; } local.index = (local.index + 1) % tracks.length; local.duration = tracks[local.index].secs; local.position = 0; started = performance.now(); }, 4); // The microphone's switch. const micSwitch = document.querySelector('.mic')!; const switched = (on: boolean) => micSwitch.setAttribute('aria-pressed', String(on)); if (canHear) micSwitch.hidden = false; micSwitch.addEventListener('click', async () => { if (hearing()) { stopHearing(); switched(false); shuffled(); return; } micSwitch.disabled = true; try { await hear(() => { switched(false); shuffled(); }); switched(true); play(MIC, 0); } catch { micSwitch.textContent = 'No microphone'; setTimeout(() => (micSwitch.textContent = 'Microphone'), 3000); } finally { micSwitch.disabled = false; } }); // The visuals take a step of the history as they open and close when it is gone back from. Here they are the page: // Escape or a swipe down (Close is hidden) leaves full screen and starts them afresh, once their own step back is // done; the browser's Back goes on back, out of the page. visuals.open = true; const target = document.getElementById('app')!; let shown = mount(Visuals, { target, props: { onclose } }); function restart() { unmount(shown); shown = mount(Visuals, { target, props: { onclose } }); } function onclose() { if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); if (history.state?.visuals) addEventListener('popstate', () => setTimeout(restart), { once: true }); else { // Out of the page; where there is nothing before it, the visuals afresh. history.back(); setTimeout(restart, 400); } } ===== vite.showcase.config.ts ===== // The oskarolivier.nl build: showcase.html (as index.html), the signals, and /source.txt, the source of every module // of this repository's own that the page is built from (the AGPL's offer of the source to its visitors). import { defineConfig, type Plugin } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; import { readFileSync } from 'node:fs'; import { relative, resolve } from 'node:path'; const root = import.meta.dirname; function sourcePage(): Plugin { return { name: 'source-page', enforce: 'post', generateBundle(_options, bundle) { const files = new Set(['showcase.html', 'vite.showcase.config.ts', '../README.md', '../deploy.sh']); for (const id of this.getModuleIds()) { const path = id.split('?')[0].replace(/\0/g, ''); if (path.startsWith(resolve(root, 'src')) || path.startsWith(resolve(root, 'src').replace(/\\/g, '/'))) files.add(relative(root, path).replace(/\\/g, '/')); } const parts = [...files].sort().map((f) => `===== ${f.replace(/^\.\.\//, '')} =====\n${readFileSync(resolve(root, f), 'utf8')}`); const head = 'oskarolivier.nl: the source of this page.\n' + "Cratarr's web/src at the tag preview-65.1 (commit fe9d70fa), plus showcase.html, src/showcase*.ts and this\n" + 'build. AGPL-3.0-or-later. Svelte (MIT) is not included.\n\n'; this.emitFile({ type: 'asset', fileName: 'source.txt', source: head + parts.join('\n') }); const html = bundle['showcase.html']; if (html) html.fileName = 'index.html'; }, }; } /** The visuals' music (lib/visuals/music.svelte.ts) gets src/showcase-tap.ts for the page player: Cratarr's own, but * listening to the microphone (src/showcase-mic.ts). Cratarr's files stay as they are. */ function microphone(): Plugin { return { name: 'microphone', enforce: 'pre', resolveId(source, importer) { if (source === '../localPlayer.svelte' && importer && /[\\/]lib[\\/]visuals[\\/]music\.svelte\.ts$/.test(importer)) return resolve(root, 'src/showcase-tap.ts'); }, }; } export default defineConfig({ plugins: [microphone(), svelte(), sourcePage()], publicDir: 'showcase-public', build: { outDir: '../dist', emptyOutDir: true, rollupOptions: { input: resolve(root, 'showcase.html') }, }, });