Source & licences
The studio on this site is Vibey 0.1.0-demo.1, a browser adaptation of Vibey 0.1.0 (local commit ec2c511, as archived with its uncommitted working-tree edits). It is licensed under the GNU Affero General Public License, version 3 only (AGPL-3.0-only), and comes with no warranty.
Corresponding source. The zip below is the complete corresponding source of this demo: every adapted source file, the build configuration, package.json and package-lock.json with exact dependency versions, the licence text and the notices.
- Vibey © Jarran Tay · AGPL-3.0-only (see
license.txtandNOTICE.mdin the zip). - Built on Strudel by the Strudel contributors (AGPL-3.0-or-later; source). Packages used:
@strudel/codemirror1.3.0,@strudel/core1.2.6,@strudel/draw1.2.6,@strudel/mini1.2.6,@strudel/tonal1.2.6,@strudel/transpiler1.2.6,@strudel/webaudio1.3.0. - Editor: CodeMirror (MIT). Other third-party licences ship inside each package in
node_modulesafternpm ci. - Drum samples are not part of this package. If you press Load sound library, Strudel downloads the tidalcycles/uzu-drumkit list (public domain, Unlicense) and each sample the first time it plays.
Build it yourself
- Install Node.js 26 (built and tested with Node 26.0.0; the pinned Vite 8.3 and Vitest 5 also declare Node 22.12+ and 24+, which were not tested).
- Unzip, then run
npm cito install the exact locked versions from the public npm registry. - Run
npm run build(type-check, Vite build, then this page). The demo is written todist/; serve that folder over HTTP. - Optional:
npm testruns the unit tests;npm run devstarts a local dev server.
What changed from the archived app
All 21 Bases and 84 Codebank parts are restored verbatim, with their roles, and Help keeps the original First drop recipe (adapted to mute layers in code, because the Loop view and Record are not included). Three Bases and 61 parts use synths only; the other 18 Bases and 23 parts (Drums, Builds, Drops) use drum samples, which load only when you press Load sound library. The desktop app bridge, AI provider settings, file import/export, recording, loop view, note editing and mute/solo are omitted, as are Hydra, MIDI and soundfonts. Previewing a Base never changes your code; Open does, and asks first if you have edits. Random Base (new in this demo) picks one of the 21 Bases other than the open one, shows its preview and opens it the same way: it asks first over edits and never starts playback. The demo saves to its own browser storage key. Each file below starts with a comment listing its changes.
src/audio.ts
Strudel editor and playback: synth prebake, the explicit sound-library loader, Stop and exit handling.
/*
* Adapted from Vibey 0.1.0 (commit ec2c511 as archived, incl. its uncommitted edits), src/audio.ts.
* Vibey © Jarran Tay. Licensed AGPL-3.0-only; see license.txt and NOTICE.md.
* Built on Strudel (https://strudel.cc, AGPL-3.0), by the Strudel contributors.
*
* Changes for the browser demo:
* - Prebake registers only the built-in synth waveforms and ZZFX sounds; nothing is
* downloaded at startup. The archive requested six remote sample manifests in the
* background on every launch. Here the drum manifest that supplies the catalogue's
* bd / sd / hh / cp (tidalcycles/uzu-drumkit, one of those six, loaded with the same
* Strudel `samples(url)` call) is requested only by loadSoundLibrary(), which the page
* calls on an explicit "Load sound library" press. The other five manifests (piano,
* VCSL, mridangam, Dirt-Samples, tidal-drum-machines) are not used by any Base or part
* and are not requested. @strudel/hydra, @strudel/soundfonts and @strudel/midi are
* not loaded (no remote soundfonts, no MIDI permission).
* - The AudioContext is created and resumed only inside an explicit Play/Update
* (browser autoplay rules). Until then volume changes are only remembered.
* - Added silence(): Stop plus AudioContext.suspend(), used when the page is hidden
* or unloaded so no voice keeps ringing.
* - The editor uses Strudel's githubDark theme (backgrounds restyled in style.css)
* to sit in the site's dark frame; the archive used githubLight.
* - select(from, to) is kept (the original highlighted a newly added Codebank part).
* - Removed recording, the loop/timeline analyser, mute/solo output filtering,
* selections and the undo/redo wrappers (CodeMirror's own Ctrl/⌘+Z still works).
* - Evaluation sequencing, Stop-during-evaluation handling and the output reset
* that cuts ringing voices are kept from the original.
*/
import { silence, evalScope } from '@strudel/core';
import * as core from '@strudel/core';
import { StrudelMirror } from '@strudel/codemirror';
import { isolateHistory } from '@codemirror/commands';
import { transpiler } from '@strudel/transpiler';
import { getAudioContext, getSuperdoughAudioController, initAudio, registerSynthSounds, registerZZFXSounds, samples, webaudioOutput } from '@strudel/webaudio';
import type { EditorOptions, MusicEditor, MusicState } from './types';
type ReplState = { code?: string; pending?: boolean; started?: boolean; error?: Error | string };
type UpstreamEditor = InstanceType<typeof StrudelMirror>;
type View = { state: { doc: { length: number } }; dispatch(spec: unknown): void; focus(): void };
const clamp = (value: number) => Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0));
const message = (error: unknown) => error instanceof Error ? error.message : String(error ?? '');
/** The archive's drum manifest (one of its six). Its samples download from GitHub when first played. */
export const SOUND_LIBRARY = 'https://raw.githubusercontent.com/tidalcycles/uzu-drumkit/main/strudel.json';
const LIBRARY_TIMEOUT = 20_000;
let libraryLoaded = false;
/**
* Registers the drum samples with Strudel (fetches the manifest only; no AudioContext
* is created). Rejects with a readable message when offline, blocked or too slow, so
* the page can offer Retry. Call it only from an explicit user action.
*/
export async function loadSoundLibrary(load: (url: string) => Promise<unknown> = samples, timeout = LIBRARY_TIMEOUT): Promise<void> {
if (libraryLoaded) return;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
Promise.resolve().then(() => load(SOUND_LIBRARY)),
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timed out')), timeout); }),
]);
libraryLoaded = true;
} catch (error) {
const offline = typeof navigator !== 'undefined' && navigator.onLine === false;
throw new Error(offline ? 'You appear to be offline.' : `The drum sample list could not be downloaded (${message(error) || 'network error'}).`);
} finally { clearTimeout(timer); }
}
/** Strudel's modules and synths work offline. Sample banks are fetched only by loadSoundLibrary(). */
export async function prebake(): Promise<void> {
await Promise.all([
evalScope(core, import('@strudel/draw'), import('@strudel/mini'), import('@strudel/tonal'), import('@strudel/webaudio'), import('@strudel/codemirror')),
registerSynthSounds(),
registerZZFXSounds(),
]);
}
/** Mounts upstream StrudelMirror, including its CodeMirror shortcuts and live highlighting. */
export async function mountEditor(host: HTMLElement, options: EditorOptions): Promise<MusicEditor> {
host.replaceChildren();
const state: MusicState = { ready: false, playing: false, pending: false, error: '' };
let destroyed = false;
let stopped = false;
let sequence = 0;
let evaluationTail: Promise<void> = Promise.resolve();
let currentVolume = clamp(options.volume);
let runtimeError = '';
let activeEditor: UpstreamEditor | null = null;
let audioInit: Promise<void> | null = null;
let audioStarted = false;
const applyVolume = () => {
if (!audioStarted) return;
getSuperdoughAudioController().output.destinationGain.gain.setValueAtTime(currentVolume, getAudioContext().currentTime);
};
const ensureAudio = async () => {
const context = getAudioContext();
await context.resume();
audioInit ??= Promise.resolve(initAudio()).catch((error) => {
audioInit = null;
throw error;
});
await audioInit;
audioStarted = true;
applyVolume();
};
const emit = () => { if (!destroyed) options.onState({ ...state }); };
const onRuntimeLog = (event: Event) => {
const detail = (event as CustomEvent<{ message?: string; type?: string }>).detail;
if (detail?.type !== 'error' && !/\] error:/i.test(detail?.message ?? '')) return;
runtimeError = detail.message ?? 'Strudel playback failed.';
state.error = runtimeError;
emit();
};
document.addEventListener('strudel.log', onRuntimeLog);
const editor = new StrudelMirror({
root: host,
initialCode: options.code,
pattern: silence,
defaultOutput: webaudioOutput,
getTime: () => getAudioContext().currentTime,
transpiler,
prebake,
onUpdateState: (upstream: ReplState) => {
if (destroyed) return;
// The core REPL starts with an internal placeholder before CodeMirror is ready.
if (activeEditor && typeof upstream.code === 'string' && upstream.code === activeEditor.code) {
options.onChange(upstream.code);
}
state.pending = Boolean(upstream.pending) && !stopped;
state.playing = Boolean(upstream.started) && !stopped;
state.error = upstream.error ? message(upstream.error) : runtimeError;
if (stopped && upstream.started) {
void originalStop();
resetOutput();
}
emit();
},
}) as UpstreamEditor;
activeEditor = editor;
editor.setTheme('githubDark');
editor.setFontSize(typeof matchMedia === 'function' && matchMedia('(max-width: 600px)').matches ? 14 : 15);
editor.setLineNumbersDisplayed(true);
editor.setLineWrappingEnabled(true);
const originalEvaluate = editor.evaluate.bind(editor);
const originalStop = editor.stop.bind(editor);
const resetOutput = () => {
if (!audioStarted) return;
// Upstream scheduler.stop() prevents future notes but leaves long voices ringing.
// Reset disconnects those voices and creates a fresh master for the next play.
const controller = getSuperdoughAudioController();
controller.reset();
applyVolume();
};
const evaluate = (): Promise<void> => {
if (destroyed) return Promise.reject(new Error('Strudel editor has been closed.'));
const ticket = ++sequence;
const run = async () => {
if (destroyed || ticket !== sequence) return;
stopped = false;
state.pending = true;
state.error = '';
runtimeError = '';
emit();
try {
await ensureAudio();
if (destroyed || stopped || ticket !== sequence) return;
await originalEvaluate(true);
const upstream = editor.repl.state;
if (upstream.error) state.error = message(upstream.error);
if (stopped || ticket !== sequence || destroyed) {
await originalStop();
resetOutput();
state.playing = false;
} else {
state.playing = Boolean(upstream.started);
}
} catch (error) {
state.error = message(error);
} finally {
if (ticket === sequence && !destroyed) {
state.pending = false;
emit();
}
}
};
const result = evaluationTail.then(run, run);
evaluationTail = result.catch(() => {});
return result;
};
const stop = () => {
const wasRunning = state.playing || state.pending;
stopped = true;
sequence++;
void originalStop();
if (wasRunning) resetOutput();
state.playing = false;
state.pending = false;
emit();
};
// CodeMirror's Ctrl/Alt+Enter and Ctrl/Alt+. call these instance methods.
// The demo routes them through the page controller so status text stays in step.
editor.evaluate = () => options.onShortcut ? Promise.resolve(options.onShortcut('play')) : evaluate();
editor.stop = () => { if (options.onShortcut) options.onShortcut('stop'); else stop(); };
const setVolume = (value: number) => {
currentVolume = clamp(value);
applyVolume();
};
const setTempo = (bpm: number) => {
if (!Number.isFinite(bpm) || bpm < 40 || bpm > 240 || destroyed) return;
editor.repl.scheduler.setCps(bpm / 240);
};
state.ready = true;
emit();
const view = () => editor.editor as unknown as View;
return {
getCode: () => editor.code,
isDirty: () => editor.code !== editor.repl.state.activeCode,
setCode: (code) => {
const current = view();
current.dispatch({
changes: { from: 0, to: current.state.doc.length, insert: code },
annotations: isolateHistory.of('full'),
});
},
select: (from, to = from) => {
const current = view();
const length = current.state.doc.length;
current.dispatch({ selection: { anchor: Math.min(from, length), head: Math.min(to, length) }, scrollIntoView: true });
},
evaluate,
stop,
silence: () => {
stop();
if (audioStarted) {
try { void getAudioContext().suspend().catch(() => {}); } catch { /* Already closed. */ }
}
},
setVolume,
setTempo,
focus: () => view().focus(),
destroy: () => {
if (destroyed) return;
stop();
destroyed = true;
editor.clear();
editor.editor.destroy();
document.removeEventListener('strudel.log', onRuntimeLog);
host.replaceChildren();
},
};
}
src/main.ts
Page controller: Play, Update, Stop, Tempo, Volume, Reset, Bases (Open and Random Base), Codebank, sound library, storage and stop-on-exit.
/*
* Adapted from Vibey 0.1.0 (commit ec2c511 as archived, incl. its uncommitted edits), src/main.ts.
* Vibey © Jarran Tay. Licensed AGPL-3.0-only; see license.txt and NOTICE.md.
*
* Changes for the browser demo:
* - Browser mode only: the desktop bridge (window.desktop), AI generation and
* provider settings, file open/save, recording, layer menus, note editing,
* mute/solo and legacy pad-project export are omitted.
* - Actions kept: Play, Update, Stop, Tempo (BPM), Volume, Bases (Open, as in the
* original, never evaluates) and Codebank (addPart, then select the new part).
* New: Reset restores the open Base's original code (undoable with Ctrl/⌘+Z);
* opening a Base over edited code first asks "Replace my code?"; Random Base
* previews a random Base (never the open one) and opens it through that same
* path, so it asks over edits and never evaluates or plays.
* - Sound library: drum samples are registered only after an explicit "Load sound
* library" press (idle → loading → loaded, or failed with Retry). Play/Update of
* code that names samples is refused with a clear message until it has loaded,
* and Strudel's "sound … not found" / sample download errors are reworded.
* - Sample recovery: a drum sample that fails to download or decode is reported as a
* network/decode problem, not a code error. Strudel caches that failed download for the
* rest of the page's life, so the fix offered is an explicit "Save & reload": the
* editor's code is saved and read back first, and the page reloads only if that worked.
* If it can't be saved, the editor is kept and Copy / Download are offered instead.
* Nothing reloads or plays by itself.
* - Saves to the demo's own key (see model.ts); storage failures only change the
* "saved" label and never block playback.
* - Stops all audio on pagehide, on visibilitychange→hidden (background timers are
* throttled, which makes Strudel's clock stutter) and when another page of the
* site announces music on the `jt-audio-focus` BroadcastChannel.
*/
import './style.css';
import { loadSoundLibrary, mountEditor } from './audio';
import { createUI } from './ui';
import { BASES, PARTS, PRESET, STORAGE, addPart, browserStore, findBase, loadSession, needsSamples, pickRandomBase, sampleSounds, saveSession, withTempo, type Base } from './model';
import type { Action, MusicEditor, ViewState } from './types';
const store = browserStore();
const initial = loadSession(store);
const state: ViewState = {
session: initial.session, ready: false, playing: false, pending: false,
error: '', errorSource: 'code', saved: store ? 'saved' : 'off', dirty: false,
status: 'Loading the Strudel editor…', library: 'idle', confirmBase: '',
sampleError: '', exportOffer: false,
};
const ui = createUI(document.getElementById('app')!, action => { void act(action); });
let editor: MusicEditor | undefined;
let saveTimer: ReturnType<typeof setTimeout> | undefined;
let pendingSave = false;
/** Random source for Random Base; read at call time so tests can stub Math.random. */
const random = () => Math.random();
const FOCUS_SOURCE = 'vibey';
let focusChannel: BroadcastChannel | null = null;
try { focusChannel = typeof BroadcastChannel === 'function' ? new BroadcastChannel('jt-audio-focus') : null; } catch { focusChannel = null; }
/** Strudel logs runtime errors as "[eval] error: …"; keep the readable part. */
const readable = (text: string) => text.replace(/^\s*\[[^\]]+\]\s*error:\s*/i, '').trim();
const render = () => { state.dirty = !!editor && editor.isDirty(); ui.render(state); };
const message = (text: string) => { state.status = text; state.error = ''; };
const fail = (text: string) => { state.error = text; state.errorSource = 'control'; };
const clearControlError = () => { if (state.errorSource === 'control') state.error = ''; };
const SYNTH_BASES = BASES.filter(base => !needsSamples(base.code)).map(base => base.name);
/** True when the editor holds anything other than an untouched Base. */
const isEdited = () => !BASES.some(base => base.code === state.session.code);
/** Why this code cannot play yet ('' when it can): it names samples and the library is not loaded. */
function sampleBlock(code: string): string {
const sounds = sampleSounds(code);
if (!sounds.length || state.library === 'loaded') return '';
const list = `drum samples (${sounds.slice(0, 4).join(', ')}${sounds.length > 4 ? '…' : ''})`;
if (state.library === 'loading') return `This code uses ${list}. The sound library is still loading; try again when it says Loaded.`;
if (state.library === 'failed') return `This code uses ${list}, but the sound library could not load. Press Retry, or open a synth-only Base (${SYNTH_BASES.join(', ')}).`;
return `This code uses ${list}. Press Load sound library first, or open a synth-only Base (${SYNTH_BASES.join(', ')}).`;
}
const sampleNote = (code: string) => needsSamples(code) && state.library !== 'loaded' ? ' It uses drum samples, so load the sound library before playing it.' : '';
const RELOAD_HINT = 'This browser keeps a failed sample until the page reloads. Press Save & reload to try the sounds again; your code is saved first and nothing plays until you press Play.';
/** Rewords Strudel's sample errors; other messages are code errors. */
function describeError(text: string): { text: string; code: boolean; sample?: 'network' | 'decode' } {
const missing = text.match(/sound "?([^\s"!]+)"? not found/i);
if (missing) return { code: false, text: state.library === 'loaded'
? `The sound "${missing[1]}" is not in the sound library (it has drum samples only).`
: `The sound "${missing[1]}" needs the sound library. Press Load sound library, then Update.` };
// Decoding: the file arrived but is not playable audio.
if (/decodeaudiodata|unable to decode|encodingerror|decod(e|ing) (audio|failed)/i.test(text))
return { code: false, sample: 'decode', text: `A drum sample downloaded but could not be decoded as audio. The other layers keep playing. ${RELOAD_HINT}` };
// Downloading: Strudel's lazy sample fetch (reported as "[getTrigger] error: Failed to fetch") or its sampler.
if (/failed to fetch|networkerror|network error|load failed|err_internet|\[sampler\] could not load|could not load sample/i.test(text))
return { code: false, sample: 'network', text: `A drum sample could not download (the network request failed). The other layers keep playing. ${RELOAD_HINT}` };
return { text: readable(text), code: true };
}
/** Open a Base (from its preview or Random Base). Over edited code it asks first, unless confirmed. Never evaluates. */
function openBase(editor: MusicEditor, base: Base, confirmed: boolean, prefix = ''): void {
if (isEdited() && !confirmed) {
state.confirmBase = base.id;
state.status = `${prefix ? `${prefix}${base.name}. ` : ''}Your code has edits. Replace it with ${base.name}, or keep your code?`;
return;
}
state.confirmBase = '';
editor.setCode(base.code);
state.session = { ...state.session, name: base.name, code: base.code }; scheduleSave();
state.error = '';
message(`${prefix}${base.name} opened. ${state.playing ? 'Press Update to hear it' : 'Press Play to hear it'}; Ctrl/⌘+Z in the editor undoes.${sampleNote(base.code)}`);
}
/** Saves the editor's exact code and reads it back. True only if storage now holds it. */
function saveAndVerify(): boolean {
if (!store || !editor) return false;
const code = editor.getCode();
state.session.code = code;
try {
saveSession(store, state.session);
const back = JSON.parse(store.getItem(STORAGE) ?? 'null') as { code?: unknown; name?: unknown } | null;
if (!back || back.code !== code || back.name !== state.session.name) throw new Error('Saved code did not match.');
} catch { state.saved = 'error'; return false; }
clearTimeout(saveTimer); pendingSave = false; state.saved = 'saved';
return true;
}
const reloadPage = () => { window.location.reload(); };
function saveNow(): void {
clearTimeout(saveTimer);
if (!pendingSave || !store) return;
try {
saveSession(store, state.session);
pendingSave = false; state.saved = 'saved';
} catch {
state.saved = 'error';
}
}
function scheduleSave(): void {
if (!store) { state.saved = 'off'; return; }
pendingSave = true; state.saved = 'saving'; clearTimeout(saveTimer);
saveTimer = setTimeout(() => { saveNow(); render(); }, 250);
}
function announcePlaying(): void {
try { focusChannel?.postMessage({ source: FOCUS_SOURCE, playing: true }); } catch { /* Optional. */ }
}
function codeChanged(code: string): void {
if (state.session.code === code) return;
state.session.code = code; scheduleSave();
clearControlError();
if (state.playing) state.status = 'Edited. Press Update to hear the change.';
render();
}
function silence(reason: string): void {
if (!editor) return;
const wasPlaying = state.playing || state.pending;
editor.silence();
if (wasPlaying) state.status = reason;
}
async function act(action: Action): Promise<void> {
try {
if (action.type !== 'tempo') clearControlError();
switch (action.type) {
case 'play':
case 'update': {
if (!editor) throw new Error('The editor is still loading.');
const updating = action.type === 'update' || state.playing;
const blocked = sampleBlock(state.session.code);
if (blocked) {
state.status = state.playing ? 'Not updated. The last working pattern is still playing.' : 'Not playing.';
throw new Error(blocked);
}
state.error = '';
await editor.evaluate();
if (state.playing) {
announcePlaying();
state.status = state.error ? 'Code error. The last working pattern is still playing.'
: updating ? 'Updated. Keep editing, then press Update again.' : 'Playing. Edit the code, then press Update.';
} else if (state.error) state.status = 'Not playing.';
break;
}
case 'stop': editor?.stop(); state.status = 'Stopped.'; break;
case 'volume': {
const value = Number(action.value);
if (!Number.isFinite(value)) break;
state.session.volume = Math.min(1, Math.max(0, value)); editor?.setVolume(state.session.volume); scheduleSave(); break;
}
case 'tempo': {
const bpm = Number(action.value);
if (!Number.isInteger(bpm) || bpm < 40 || bpm > 240) {
ui.resetTempoField(state.session.code);
throw new Error('Choose a whole-number tempo from 40 to 240 BPM.');
}
if (!editor) throw new Error('The editor is still loading.');
const code = withTempo(state.session.code, bpm);
editor.setCode(code);
state.session.code = code; scheduleSave();
editor.setTempo(bpm);
message(state.playing ? `Tempo ${bpm} BPM. The code's setcpm line was updated too.` : `Tempo ${bpm} BPM. Press Play to hear it.`);
break;
}
case 'reset': {
if (!editor) throw new Error('The editor is still loading.');
const base = BASES.find(item => item.name === state.session.name) ?? PRESET;
editor.setCode(base.code);
state.session = { ...state.session, name: base.name, code: base.code }; scheduleSave();
state.error = ''; state.confirmBase = '';
if (state.playing && !sampleBlock(base.code)) {
await editor.evaluate();
if (!state.error) message(`${base.name} restored and playing. Ctrl/⌘+Z in the editor undoes the reset.`);
} else message(`${base.name} restored. Ctrl/⌘+Z in the editor undoes the reset.${sampleNote(base.code)}`);
break;
}
case 'base': {
if (!editor) throw new Error('The editor is still loading.');
const base = findBase(action.id);
if (base) openBase(editor, base, action.confirm === true);
break;
}
case 'random-base': {
// Same path as Open: preview it, ask first over edits, never evaluate or play.
if (!editor) throw new Error('The editor is still loading.');
const open = BASES.find(item => item.name === state.session.name);
const base = pickRandomBase([open?.id, action.previewing], () => random());
ui.previewBase(base.id);
openBase(editor, base, false, 'Random Base: ');
break;
}
case 'base-cancel': state.confirmBase = ''; message('Kept your code.'); break;
case 'part': {
if (!editor) throw new Error('The editor is still loading.');
const part = PARTS.find(item => item.id === action.id);
const code = addPart(state.session.code, String(action.id));
editor.setCode(code);
state.session.code = code; scheduleSave();
editor.select(Math.max(0, code.lastIndexOf('// ')), code.length);
message(`${part?.name ?? 'Part'} added at the end. ${state.playing ? 'Press Update to hear it.' : 'Press Play to hear it.'}${part ? sampleNote(part.code) : ''}`);
break;
}
case 'reload-sounds': {
if (!editor) throw new Error('The editor is still loading.');
if (!saveAndVerify()) {
state.exportOffer = true;
state.status = 'Not reloaded. Your code is still in the editor.';
throw new Error(store
? 'Your code could not be saved in this browser, so the page was not reloaded. Copy or download your code first, then use Reload without saving.'
: 'This browser is not saving the demo, so reloading would lose your code. Copy or download it first, then use Reload without saving.');
}
editor.stop();
state.status = 'Saved. Reloading to try the sounds again…'; render();
reloadPage();
return;
}
case 'reload-unsaved': {
// Explicit second step after Copy / Download; never offered while saving works.
if (!state.exportOffer) break;
editor?.stop();
reloadPage();
return;
}
case 'copy-code': {
const code = editor?.getCode() ?? state.session.code;
try {
await navigator.clipboard.writeText(code);
message('Code copied. Paste it somewhere safe before reloading.');
} catch { throw new Error('Copying was blocked by the browser. Use Download code, or select the editor text and copy it.'); }
break;
}
case 'download-code': {
const code = editor?.getCode() ?? state.session.code;
const url = URL.createObjectURL(new Blob([code], { type: 'text/plain' }));
const a = document.createElement('a');
a.href = url; a.download = 'vibey-code.txt'; a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
message('Code downloaded as vibey-code.txt.');
break;
}
case 'load-library': {
if (state.library === 'loading' || state.library === 'loaded') break;
state.library = 'loading'; state.status = 'Loading the sound library…'; render();
try {
await loadSoundLibrary();
state.library = 'loaded';
message('Sound library loaded. Drum Bases and parts can play now; each sample downloads the first time it plays.');
} catch (e) {
state.library = 'failed';
state.status = 'Sound library not loaded. Synths still work.';
throw new Error(`The sound library could not load. ${(e as Error).message} Press Retry to try again.`);
}
break;
}
}
} catch (e) { fail((e as Error).message || 'Something went wrong.'); }
render();
}
render();
void mountEditor(ui.editor, {
code: state.session.code, volume: state.session.volume,
onChange: codeChanged,
onShortcut: type => { void act({ type }); },
onState: music => {
const controlError = state.errorSource === 'control' ? state.error : '';
Object.assign(state, music);
if (music.error) {
const described = describeError(music.error);
state.error = described.text; state.errorSource = described.code ? 'code' : 'control';
if (described.sample) { state.sampleError = described.sample; state.status = 'A drum sample failed. Save & reload to try it again.'; }
}
else state.error = controlError;
render();
},
}).then(instance => {
editor = instance;
state.ready = true;
state.status = initial.error || 'Press Play to start. Sound begins only when you press Play.';
if (document.visibilityState === 'hidden') editor.stop();
render();
}).catch(e => {
state.error = `Strudel could not start: ${(e as Error).message}`; state.errorSource = 'control';
state.status = 'Reload the page to try again.'; render();
});
focusChannel?.addEventListener('message', event => {
const data = event.data as { source?: unknown; playing?: unknown } | null;
if (data && data.source !== FOCUS_SOURCE && data.playing === true) { silence('Stopped because other music started on this site.'); render(); }
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'hidden') return;
silence('Stopped because the page was hidden. Press Play to start again.');
saveNow(); render();
});
window.addEventListener('pagehide', () => { silence('Stopped because you left the page.'); saveNow(); render(); });
window.addEventListener('pageshow', event => {
// Restored from the back/forward cache: audio was suspended on pagehide.
if (event.persisted && editor) { editor.stop(); state.status = 'Stopped. Press Play to start again.'; render(); }
});
document.addEventListener('keydown', event => {
// Upstream provides Ctrl/Alt shortcuts; add their familiar Mac equivalents.
if (event.metaKey && ui.editor.contains(event.target as Node)) {
if (event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); void act({ type: 'play' }); }
if (event.key === '.') { event.preventDefault(); event.stopPropagation(); void act({ type: 'stop' }); }
}
}, true);
src/model.ts
Roles, drum-sample detection, the Random Base picker, adding parts, tempo rewriting and the demo-only storage keys.
/*
* Adapted from Vibey 0.1.0 (commit ec2c511 as archived, incl. its uncommitted edits), src/model.ts.
* Vibey © Jarran Tay. Licensed AGPL-3.0-only; see license.txt and NOTICE.md.
*
* Changes for the browser demo:
* - The 21 Bases and 84 Codebank parts live in catalog.ts, copied verbatim from the
* archive. New here: ROLES (the archive's part groups in order), findBase and
* sampleSounds/needsSamples, which spot drum-sample names (bd, sd, hh, cp…) so the
* page can label those items and ask for the sound library before playing them.
* Also new: pickRandomBase, for the Random Base button (the random source is a
* parameter, as in the archive's own refresh() helper in edit.ts).
* - addPart and getTempo / withTempo are unchanged.
* - Storage keys are the demo's own (`jt-demo-vibey.code` and its `.recovery`
* copy), never the archived app's `vibey.code` / `vibey.code.recovery` /
* `vibey.project` keys. Every storage call is wrapped so a blocked or full
* store can never break the studio.
* - File import/export, AI reply parsing and legacy pad-project detection are omitted.
*/
import type { Session } from './types';
import { parse, type Expression } from 'acorn';
import { BASES, PARTS } from './catalog';
export { BASES, PARTS };
export const STORAGE = 'jt-demo-vibey.code';
export const RECOVERY = 'jt-demo-vibey.code.recovery';
export const MAX_CODE = 50_000;
export type Store = Pick<Storage, 'getItem' | 'setItem'>;
export type Base = (typeof BASES)[number];
export type Part = (typeof PARTS)[number];
/** The archive's Codebank groups, in the order they first appear. */
export const ROLES = ['Drums', 'Bass', 'Melody', 'Chords', 'Texture', 'Builds', 'Drops'] as const;
/** The default and Reset fallback: Warm synth, the archive's first Base (synth only). */
export const PRESET = BASES[0];
export const findBase = (id: unknown) => BASES.find(base => base.id === id);
/**
* Picks a random Base for the Random Base button, never one whose id is in `exclude`
* (the open Base, and the one already previewed). `random` returns a number in [0, 1),
* like Math.random, and can be replaced so the choice is deterministic in tests.
*/
export function pickRandomBase(exclude: readonly unknown[] = [], random: () => number = Math.random): Base {
const choices = BASES.filter(base => !exclude.includes(base.id));
const pool = choices.length ? choices : BASES.filter(base => base.id !== exclude[0]);
const value = Number(random());
const index = Math.floor((Number.isFinite(value) ? value : 0) * pool.length);
return pool[Math.min(pool.length - 1, Math.max(0, index))];
}
/** Sounds registered by Strudel's built-in synths and ZZFX (superdough 1.3.0); everything else is a sample. */
const SYNTH_SOUNDS = new Set(['sine', 'square', 'triangle', 'sawtooth', 'sin', 'sqr', 'tri', 'saw', 'user', 'one',
'sbd', 'supersaw', 'bytebeat', 'pulse', 'bus', 'pink', 'white', 'brown', 'crackle',
'zzfx', 'z_sine', 'z_sawtooth', 'z_triangle', 'z_square', 'z_tan', 'z_noise']);
/** Code with its comments blanked, so `// s("bd")` is not mistaken for a drum layer. */
function withoutComments(code: string): string {
const ranges: Array<[number, number]> = [];
try { parse(code, { ecmaVersion: 2022, allowAwaitOutsideFunction: true, onComment: (_block, _text, start, end) => { ranges.push([start, end]); } }); }
catch { return code; }
for (const [start, end] of ranges.reverse()) code = `${code.slice(0, start)}${' '.repeat(end - start)}${code.slice(end)}`;
return code;
}
/**
* Sample names used by literal `s("…")` / `sound("…")` calls, e.g. ['bd', 'sd'].
* Synth waveforms (sine, sawtooth…) are not samples. Sounds chosen from variables are not seen.
*/
export function sampleSounds(code: string): string[] {
const names = new Set<string>();
for (const call of withoutComments(code).matchAll(/(?<![\w$])(?:s|sound)\(\s*(["'`])((?:\\.|(?!\1)[\s\S])*?)\1/g)) {
for (const token of call[2].match(/[A-Za-z_]\w*/g) ?? []) {
const name = token.toLowerCase();
if (!SYNTH_SOUNDS.has(name)) names.add(name);
}
}
return [...names];
}
export const needsSamples = (code: string) => sampleSounds(code).length > 0;
type TempoCall = { start: number; end: number; name: string; value: number | null };
function tempoNumber(node: Expression): number | null {
if (node.type === 'Literal' && typeof node.value === 'number') return node.value;
if (node.type === 'UnaryExpression' && (node.operator === '-' || node.operator === '+')) {
const value = tempoNumber(node.argument);
return value === null ? null : node.operator === '-' ? -value : value;
}
if (node.type === 'BinaryExpression' && node.operator === '/') {
if (node.left.type === 'PrivateIdentifier') return null;
const left = tempoNumber(node.left), right = tempoNumber(node.right);
return left === null || right === null || right === 0 ? null : left / right;
}
return null;
}
function tempoCalls(code: string): TempoCall[] {
const body = parse(code, { ecmaVersion: 2022, allowAwaitOutsideFunction: true }).body;
return body.flatMap(node => {
if (node.type !== 'ExpressionStatement' || node.expression.type !== 'CallExpression'
|| node.expression.callee.type !== 'Identifier'
|| !/^(setcpm|setCpm|setcps|setCps)$/.test(node.expression.callee.name)) return [];
const name = node.expression.callee.name;
const call = node.expression;
const argument = call.arguments.length === 1 && call.arguments[0].type !== 'SpreadElement'
? call.arguments[0] : null;
return [{ start: call.start, end: call.end, name,
value: argument ? tempoNumber(argument) : null }];
});
}
export function getTempo(code: string): number | null {
try {
const last = tempoCalls(code).at(-1);
if (!last || last.value === null) return null;
const bpm = last.value * (/cps$/i.test(last.name) ? 240 : 4);
return Number.isFinite(bpm) && bpm > 0 ? bpm : null;
} catch { return null; }
}
export function withTempo(code: string, bpm: number): string {
if (!Number.isInteger(bpm) || bpm < 40 || bpm > 240) throw new Error('Choose a tempo from 40 to 240 BPM.');
let calls: TempoCall[];
try { calls = tempoCalls(code); }
catch { throw new Error('Finish or fix the current code before changing tempo.'); }
const replacement = `setcpm(${bpm}/4)`;
if (!calls.length) return source(`${replacement}\n${code}`);
for (const call of calls.reverse()) code = `${code.slice(0, call.start)}${replacement}${code.slice(call.end)}`;
return source(code);
}
function registersTrack(node: Expression): boolean {
if (node.type === 'CallExpression') return node.callee.type !== 'Super' && registersTrack(node.callee);
if (node.type !== 'MemberExpression') return false;
const key = !node.computed && node.property.type === 'Identifier' ? node.property.name
: node.property.type === 'Literal' ? node.property.value : '';
return (typeof key === 'string' && /^(p|p[1-9]|d[1-9])$/.test(key))
|| (node.object.type !== 'Super' && registersTrack(node.object));
}
export function addPart(code: string, id: string): string {
const part = PARTS.find(item => item.id === id);
if (!part) throw new Error('Choose a musical part.');
let body;
try { body = parse(code, { ecmaVersion: 2022, allowAwaitOutsideFunction: true }).body; }
catch { throw new Error('Finish or fix the current code before adding a part.'); }
const last = body.at(-1);
const registered = body.some(node => node.type === 'LabeledStatement'
|| (node.type === 'ExpressionStatement' && registersTrack(node.expression))
|| (node.type === 'VariableDeclaration' && node.declarations.some(item => item.init && registersTrack(item.init))));
if (!registered && last?.type === 'ExpressionStatement') {
const expression = last.expression;
const call = expression.type === 'CallExpression' ? expression.callee : null;
const setup = call?.type === 'Identifier' && /^(setcpm|setcps|samples|all|each|hush)$/i.test(call.name);
// Strudel uses registered tracks as soon as a $: line exists. Keep a bare final pattern in that mix.
if (!setup) code = `${code.slice(0, last.start)}$: ${code.slice(last.start)}`;
}
return source(`${code.trimEnd()}${code.trim() ? '\n\n' : ''}// ${part.name}\n${part.code}\n`);
}
export function createSession(): Session {
return { version: 2, name: PRESET.name, code: PRESET.code, volume: .7 };
}
function source(value: unknown, nonempty = false): string {
if (typeof value !== 'string' || value.includes('\0')) throw new Error('Choose a plain text Strudel program.');
if (value.length > MAX_CODE) throw new Error('Keep the program below 50,000 characters.');
if (nonempty && !value.trim()) throw new Error('The program is empty.');
return value;
}
export function validateSession(value: unknown): Session {
if (!value || typeof value !== 'object') throw new Error('Invalid session.');
const s = value as Partial<Session>;
if (s.version !== 2) throw new Error('Unknown saved format.');
if (typeof s.name !== 'string' || !s.name.trim() || s.name.length > 80) throw new Error('Invalid session name.');
if (typeof s.volume !== 'number' || !Number.isFinite(s.volume) || s.volume < 0 || s.volume > 1) throw new Error('Invalid volume.');
return { version: 2, name: s.name, code: source(s.code), volume: s.volume };
}
/** Returns the browser's localStorage, or null when it is missing or blocked. */
export function browserStore(): Store | null {
try {
const store = window.localStorage;
return store ?? null;
} catch { return null; }
}
export function loadSession(store: Store | null): { session: Session; error: string } {
let session = createSession(), error = '';
if (!store) return { session, error };
try {
const saved = store.getItem(STORAGE);
if (saved !== null) {
try { session = validateSession(JSON.parse(saved)); }
catch {
const previous = store.getItem(RECOVERY);
if (previous === null) throw new Error('Your saved demo code could not be read, so the Warm synth Base was loaded.');
try { session = validateSession(JSON.parse(previous)); }
catch { throw new Error('Your saved demo code could not be read, so the Warm synth Base was loaded.'); }
error = 'Recovered your last good save.';
}
}
} catch (e) { error = (e as Error).message; }
return { session, error };
}
/** Saves the session. Throws only if the store itself fails; callers treat that as non-fatal. */
export function saveSession(store: Store, session: Session): void {
const next = JSON.stringify(validateSession(session));
const previous = store.getItem(STORAGE);
if (previous !== null && previous !== next) {
let valid = false;
try { validateSession(JSON.parse(previous)); valid = true; } catch { /* Keep a good recovery. */ }
if (valid) store.setItem(RECOVERY, previous);
}
store.setItem(STORAGE, next);
}
src/ui.ts
Studio markup: transport, sound library, Bases preview and Random Base button, Codebank, Help, rendering.
/*
* Adapted from Vibey 0.1.0 (commit ec2c511 as archived, incl. its uncommitted edits), src/ui.ts.
* Vibey © Jarran Tay. Licensed AGPL-3.0-only; see license.txt and NOTICE.md.
*
* Changes for the browser demo:
* - Kept: Play, Stop, BPM, Volume, the code editor, the Bases browser (a preview
* expands beneath the clicked row; only "Open" changes the editor), the one-click
* Codebank grouped by role, and Help with the original First drop recipe and
* control explanations (wording adapted where the browser version lacks a feature).
* - Added: separate Play and Update buttons (the original relabelled Play as Update
* while playing), Reset, a "Sound library" bar with Load / Retry, sample badges,
* a "Replace my code?" step when opening a Base over edits, a Random Base button
* (previews a random Base and opens it through the same step), and a role filter
* instead of the text search. Unavailable buttons use aria-disabled so keyboard
* focus never drops. Help closes with Escape and returns focus to its button.
* - Removed: Open/Save files, AI and AI settings, Record, the loop timeline and its
* mute/solo, Undo/Redo/Refresh icons, the layer menu and the session-name field.
*/
import type { Action, ViewState } from './types';
import { BASES, PARTS, ROLES, findBase, getTempo, sampleSounds } from './model';
type Dispatch = (action: Action) => void;
// Control explanations from the archive's Help, verbatim.
const controls = [
['decay', 'Time to fall from the initial peak. Shorter feels snappier.'],
['sustain', 'Level held during a note. 0 makes a pluck; higher holds the sound.'],
['gain', 'This layer’s volume. Lower it when adding more layers.'],
['attack', 'Fade-in time. Higher values give a softer entrance.'],
['release', 'Fade-out time after a note ends.'],
['lpf', 'Filter cutoff. Lower values make the sound darker.'],
['room', 'Reverb amount. Higher values add more space.'],
['delay', 'Echo amount. Higher values make repeats more audible.'],
];
const esc = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const genre = (detail: string) => detail.replace(/ · C minor · \d+ BPM$/, '');
const bpmOf = (detail: string) => detail.match(/(\d+) BPM$/)?.[1] ?? '';
const SAMPLE_BADGE = '<span class="badge badge-samples">Drum samples</span>';
function button(action: string, label: string, className = '', extra = ''): string {
return `<button type="button" data-action="${action}" class="${className}" ${extra}>${label}</button>`;
}
function helpMarkup(): string {
return `
<div class="help-heading"><h2 id="vibey-help-title" tabindex="-1">Help</h2>${button('help', 'Close', 'pill pill-quiet help-close', 'aria-label="Close help"')}</div>
<p>Write Strudel code, then press Play. While it plays, edit and press Update to hear the change.</p>
<dl class="shortcut-list"><div><dt>Ctrl / ⌘ + Enter</dt><dd>Play or update</dd></div><div><dt>Ctrl / ⌘ + .</dt><dd>Stop</dd></div><div><dt>Tab</dt><dd>Leave the editor</dd></div><div><dt>Esc</dt><dd>Close Help</dd></div></dl>
<p>Try <code>s("bd sd")</code> for a beat or <code>note("c3 e3 g3").s("sine")</code> for notes. <strong>Bases</strong> are complete starting pieces: select one to preview it, then Open it into the editor (Ctrl / ⌘ + Z undoes). <strong>Random Base</strong> opens one for you the same way, and asks first if you have edits. <strong>Codebank</strong> adds a part to the end of your code. BPM changes the whole piece's tempo.</p>
<p>Drum sounds (bd, sd, hh, cp) need the <strong>sound library</strong>. It downloads from GitHub only when you press Load sound library. Synth sounds work without it.</p>
<h3>First drop</h3>
<p>Open Bases → First drop, then in Codebank add Snare build. The original app had Loop controls to mute (M) and solo (S) layers while recording. This browser version has no Loop view, solo or Record, so you switch layers in the code instead: type <code>_</code> before a layer's <code>$:</code> to mute it (<code>_$:</code>), delete the <code>_</code> to bring it back. Each layer is labelled with a comment (<code>// Kick</code>, <code>// Snare</code>…). <strong>After every change, press Update</strong> (or Ctrl / ⌘ + Enter).</p>
<ol class="recipe-steps">
<li><strong>Set up the intro first.</strong> Before pressing Play, mute Kick, Snare, Hats, Bass and Snare build, so only Chords is left. Then press Play.</li>
<li><strong>Intro · 8 bars.</strong> Chords only.</li>
<li><strong>Groove · 8 bars.</strong> Unmute Kick, Snare, Hats and Bass; press Update. Five layers play: Kick, Snare, Hats, Bass and Chords. Snare build stays muted.</li>
<li><strong>Build · 4 bars.</strong> Mute Kick, Snare, Hats, Bass and Chords, unmute Snare build, and press Update as the loop returns to bar 1. Only Snare build plays (this is the original's “solo Snare build”).</li>
<li><strong>Drop · 16 bars.</strong> Mute Snare build and unmute the five Groove layers; press Update. The full beat comes back.</li>
<li><strong>Break and repeat.</strong> Back to Chords only for 8 bars, repeat the build and drop, then finish with 8 bars of Chords.</li>
</ol>
<p>You make these switches by hand, and Update takes effect straight away rather than waiting for the next bar, so press it near the start of a bar. Builds repeat until muted. Use one full drum kit at a time; adding House drop on top of another kit doubles the drums.</p>
<h3>Harmony</h3>
<p>Parts share C minor: C, D, E♭, F, G, A♭, B♭. Try Cm → A♭ → E♭ → B♭. A shared key helps parts fit; use one chord progression at a time and listen to how the bass sits with it.</p>
<h3>Controls</h3>
<dl class="control-list">${controls.map(([name, hint]) => `<div><dt><code>${name}</code></dt><dd>${hint}</dd></div>`).join('')}</dl>
<p>Attack, decay and release use seconds. Sustain is a level from 0 to 1. Decay is audible when sustain is below 1.</p>`;
}
export function createUI(root: HTMLElement, dispatch: Dispatch) {
const roleCounts = ROLES.map(role => [role, PARTS.filter(part => part.group === role).length] as const);
root.innerHTML = `
<section class="studio" aria-label="Vibey studio">
<div class="transport" role="group" aria-label="Playback controls">
<div class="transport-buttons">
${button('play', 'Play', 'pill pill-primary', 'aria-describedby="studio-shortcuts"')}
${button('update', 'Update', 'pill', 'aria-describedby="studio-shortcuts"')}
${button('stop', 'Stop', 'pill')}
</div>
<label class="tempo-control"><span>Tempo</span><input name="tempo" type="number" inputmode="numeric" min="40" max="240" step="1" placeholder="Auto" aria-describedby="tempo-hint" /><span>BPM</span></label>
<label class="volume-control"><span>Volume</span><input name="volume" type="range" min="0" max="1" step="0.01" /><output class="volume-value" aria-hidden="true"></output></label>
<span class="transport-spacer"></span>
<div class="transport-buttons">
${button('reset', 'Reset', 'pill pill-quiet', 'aria-describedby="reset-hint"')}
${button('help', 'Help', 'pill pill-quiet help-toggle', 'aria-expanded="false" aria-controls="vibey-help"')}
</div>
</div>
<p class="sr-only" id="tempo-hint">40 to 240. Changes the setcpm line in the code and the live clock.</p>
<p class="sr-only" id="reset-hint"></p>
<div class="library-bar" data-library="idle">
<p class="library-text"><strong>Sound library</strong> <span class="library-state" aria-live="polite"></span><span class="library-detail">Drum Bases and parts use samples (bd, sd, hh, cp) from the Tidal uzu-drumkit on GitHub. Synths need nothing.</span></p>
${button('load-library', 'Load sound library', 'pill library-button')}
<div class="library-recovery" role="group" aria-label="Retry sounds" hidden>
${button('reload-sounds', 'Save & reload', 'pill pill-primary')}
<span class="export-actions" hidden>${button('copy-code', 'Copy code', 'pill')}${button('download-code', 'Download code', 'pill')}${button('reload-unsaved', 'Reload without saving', 'pill pill-quiet')}</span>
</div>
</div>
<section class="help-panel" id="vibey-help" aria-labelledby="vibey-help-title" hidden>${helpMarkup()}</section>
<div class="error-banner" role="alert" hidden><strong class="error-title">Code error</strong><span class="error-text"></span><span class="error-help"></span></div>
<div class="studio-body">
<section class="catalog" aria-label="Bases and Codebank">
<div class="catalog-tabs" role="group" aria-label="Show">
<button type="button" class="tab" data-tab="bases" aria-pressed="true" aria-controls="panel-bases">Bases <span class="count">${BASES.length}</span></button>
<button type="button" class="tab" data-tab="codebank" aria-pressed="false" aria-controls="panel-codebank">Codebank <span class="count">${PARTS.length}</span></button>
</div>
<div class="catalog-panel" id="panel-bases" aria-label="Bases">
<div class="panel-head">
<p class="panel-note">Complete starting pieces, all in C minor. Select one to preview it; only Open changes your code.</p>
${button('random-base', 'Random Base', 'pill pill-small random-base', 'aria-describedby="random-hint"')}
</div>
<p class="sr-only" id="random-hint">Opens a random Base other than the current one. If your code has edits, it asks first. It never starts playback.</p>
<div class="base-list">${BASES.map(base => `<div class="base-row"><button type="button" class="base-option" data-base="${esc(base.id)}" aria-expanded="false" aria-controls="base-preview"><span class="row-main"><strong>${esc(base.name)}</strong><span>${esc(genre(base.detail))} · ${bpmOf(base.detail)} BPM</span></span>${sampleSounds(base.code).length ? SAMPLE_BADGE : ''}</button></div>`).join('')}</div>
<section class="base-preview" id="base-preview" role="region" aria-label="Base preview" hidden>
<p class="preview-meta"><strong class="preview-name"></strong><span class="preview-bpm"></span><span class="preview-badge badge"></span></p>
<p class="preview-note"></p>
<pre class="preview-code" tabindex="0" aria-label="Base code"><code></code></pre>
<div class="preview-actions">${button('base', 'Open in editor', 'pill pill-primary preview-open')}</div>
<div class="preview-confirm" role="group" aria-label="Replace your code?" hidden>
<p class="confirm-text"></p>
<div class="preview-actions">${button('base-confirm', 'Replace my code', 'pill pill-primary')}${button('base-cancel', 'Keep my code', 'pill')}</div>
</div>
</section>
</div>
<div class="catalog-panel" id="panel-codebank" aria-label="Codebank" hidden>
<p class="panel-note">Add a part to the end of your code, then press Play or Update to hear it. Everything shares C minor.</p>
<div class="role-filter" role="group" aria-label="Filter parts by role">
<button type="button" class="chip" data-role="" aria-pressed="true">All <span class="count">${PARTS.length}</span></button>
${roleCounts.map(([role, count]) => `<button type="button" class="chip" data-role="${role}" aria-pressed="false">${role} <span class="count">${count}</span></button>`).join('')}
</div>
<ul class="part-list">${PARTS.map(part => `<li class="part-row" data-role="${esc(part.group)}"><span class="row-main"><strong>${esc(part.name)}</strong><span>${esc(part.group)} · ${esc(part.detail)}</span></span>${sampleSounds(part.code).length ? SAMPLE_BADGE : ''}${button('part', 'Add', 'pill pill-small', `data-id="${esc(part.id)}" aria-label="Add ${esc(part.name)} (${esc(part.group)})"`)}</li>`).join('')}</ul>
</div>
</section>
<section class="editor-section" aria-label="Music code">
<div class="editor-heading"><span class="editor-title"></span><span class="edited-badge" hidden>Edited · press Update to hear it</span><span class="editor-hint" id="studio-shortcuts">Ctrl / ⌘ + Enter plays or updates · Ctrl / ⌘ + . stops · Tab leaves the editor</span></div>
<div class="editor-host" id="music-editor"></div>
</section>
</div>
<footer class="studio-footer"><span class="playback-state"></span><span class="status-text" role="status" aria-live="polite"></span><span class="saved-state"></span></footer>
</section>`;
const $ = <T extends HTMLElement = HTMLElement>(selector: string) => root.querySelector<T>(selector)!;
const editor = $('.editor-host');
const volume = $<HTMLInputElement>('[name="volume"]');
const tempo = $<HTMLInputElement>('[name="tempo"]');
const preview = $('#base-preview');
const help = $('#vibey-help');
const helpToggle = $<HTMLButtonElement>('.help-toggle');
let previewId = '';
let lastState: ViewState | null = null;
let confirming = '';
const setHelp = (open: boolean, returnFocus = true) => {
help.hidden = !open;
helpToggle.setAttribute('aria-expanded', String(open));
if (open) $('#vibey-help-title').focus();
else if (returnFocus) helpToggle.focus();
};
help.addEventListener('keydown', event => {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); setHelp(false); }
});
const showTab = (tab: string) => {
root.querySelectorAll<HTMLButtonElement>('[data-tab]').forEach(item => item.setAttribute('aria-pressed', String(item.dataset.tab === tab)));
$('#panel-bases').hidden = tab !== 'bases';
$('#panel-codebank').hidden = tab !== 'codebank';
};
const filterRole = (role: string) => {
root.querySelectorAll<HTMLButtonElement>('[data-role].chip').forEach(chip => chip.setAttribute('aria-pressed', String(chip.dataset.role === role)));
root.querySelectorAll<HTMLElement>('.part-row').forEach(row => { row.hidden = !!role && row.dataset.role !== role; });
};
const showPreview = (id: string, scroll = false) => {
root.querySelectorAll('.base-option').forEach(row => row.setAttribute('aria-expanded', 'false'));
const base = findBase(id);
previewId = base ? id : '';
preview.hidden = !base;
if (!base) return;
const row = root.querySelector<HTMLButtonElement>(`.base-option[data-base="${id}"]`)!;
row.setAttribute('aria-expanded', 'true');
row.parentElement!.after(preview);
if (scroll) row.scrollIntoView?.({ block: 'nearest' });
preview.setAttribute('aria-label', `${base.name} preview`);
$('.preview-name').textContent = base.name;
$('.preview-bpm').textContent = `${genre(base.detail)} · ${getTempo(base.code) ?? bpmOf(base.detail)} BPM`;
preview.querySelector('code')!.textContent = base.code;
root.querySelectorAll<HTMLButtonElement>('#base-preview [data-action]').forEach(item => { item.dataset.id = base.id; });
if (lastState) renderPreview(lastState);
};
const renderPreview = (state: ViewState) => {
const base = findBase(previewId);
if (!base) return;
const sounds = sampleSounds(base.code);
const badge = $('.preview-badge');
badge.textContent = sounds.length ? 'Drum samples' : 'Synth only';
badge.classList.toggle('badge-samples', sounds.length > 0);
$('.preview-note').textContent = !sounds.length ? 'Plays straight away: no downloads needed.'
: state.library === 'loaded' ? `Uses ${sounds.join(', ')} from the sound library, which is loaded.`
: state.library === 'loading' ? `Uses ${sounds.join(', ')}. The sound library is loading.`
: `Uses ${sounds.join(', ')}. Load the sound library before playing it.`;
const asking = state.confirmBase === base.id;
const confirm = $('.preview-confirm');
const open = $<HTMLButtonElement>('.preview-open');
confirm.hidden = !asking;
open.parentElement!.hidden = asking;
$('.confirm-text').textContent = asking ? `Your code has edits. Replace it with ${base.name}? Ctrl / ⌘ + Z in the editor brings it back.` : '';
if (asking && confirming !== base.id) $<HTMLButtonElement>('[data-action="base-confirm"]').focus();
else if (!asking && confirming === base.id && (!document.activeElement || document.activeElement === document.body || preview.contains(document.activeElement))) open.focus();
confirming = asking ? base.id : '';
};
root.addEventListener('click', event => {
const target = (event.target as Element).closest<HTMLButtonElement>('button');
if (!target || !root.contains(target) || target.getAttribute('aria-disabled') === 'true') return;
if (target.dataset.tab) { showTab(target.dataset.tab); return; }
if (target.dataset.role !== undefined && target.classList.contains('chip')) { filterRole(target.dataset.role); return; }
if (target.dataset.base) { showPreview(target.getAttribute('aria-expanded') === 'true' ? '' : target.dataset.base); return; }
const action = target.dataset.action;
if (!action) return;
if (action === 'help') { setHelp(Boolean(help.hidden)); return; }
if (action === 'base-confirm') { dispatch({ type: 'base', id: target.dataset.id, confirm: true }); return; }
if (action === 'random-base') { dispatch({ type: 'random-base', previewing: previewId }); return; }
dispatch({ type: action, ...(target.dataset.id ? { id: target.dataset.id } : {}) });
});
volume.addEventListener('input', () => dispatch({ type: 'volume', value: Number(volume.value) }));
tempo.addEventListener('change', () => dispatch({ type: 'tempo', value: tempo.value === '' ? NaN : Number(tempo.value) }));
tempo.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); tempo.blur(); tempo.focus(); } });
const syncInput = (input: HTMLInputElement, value: string) => {
if (document.activeElement !== input && input.value !== value) input.value = value;
};
const setAvailable = (action: string, available: boolean) => {
root.querySelectorAll<HTMLButtonElement>(`[data-action="${action}"]`).forEach(element => element.setAttribute('aria-disabled', String(!available)));
};
return {
editor,
/** Forces the tempo field to show the code's tempo even while focused (after a rejected value). */
resetTempoField(code: string) { const bpm = getTempo(code); if (bpm !== null) tempo.value = String(bpm); },
/** Shows a Base's preview under its row and scrolls the row into view (used by Random Base). */
previewBase(id: string) { showTab('bases'); showPreview(id, true); },
render(state: ViewState) {
lastState = state;
syncInput(volume, String(state.session.volume));
volume.setAttribute('aria-valuetext', `${Math.round(state.session.volume * 100)}%`);
// While the code cannot be parsed, keep showing the last tempo instead of blanking the field.
const bpm = getTempo(state.session.code);
if (bpm !== null || !tempo.value) syncInput(tempo, String(bpm ?? ''));
setAvailable('play', state.ready && !state.pending && !state.playing);
setAvailable('update', state.ready && !state.pending && state.playing);
setAvailable('stop', true);
setAvailable('reset', state.ready);
setAvailable('base', state.ready);
setAvailable('base-confirm', state.ready);
setAvailable('random-base', state.ready);
setAvailable('part', state.ready);
$('#reset-hint').textContent = `Restores the original code of ${state.session.name}. Ctrl or Command plus Z in the editor undoes it.`;
$('.volume-value').textContent = `${Math.round(state.session.volume * 100)}%`;
const playback = $('.playback-state');
playback.textContent = state.pending ? 'Updating…' : state.playing ? 'Playing' : state.ready ? 'Stopped' : 'Loading…';
playback.dataset.state = state.pending ? 'pending' : state.playing ? 'playing' : 'stopped';
$('.status-text').textContent = state.status;
$('.saved-state').textContent = state.saved === 'saving' ? 'Saving…' : state.saved === 'error' ? 'Not saved (storage unavailable)' : state.saved === 'off' ? 'Storage off · not saved' : 'Saved in this browser';
$('.edited-badge').hidden = !(state.playing && state.dirty);
$('.editor-title').textContent = state.session.name;
const bar = $('.library-bar');
bar.dataset.library = state.library;
$('.library-state').textContent = state.sampleError === 'network' ? 'A sample could not download'
: state.sampleError === 'decode' ? 'A sample could not be decoded'
: state.library === 'loaded' ? 'Loaded' : state.library === 'loading' ? 'Loading…'
: state.library === 'failed' ? 'Could not load. Check your connection, then Retry.' : 'Not loaded';
const load = $<HTMLButtonElement>('.library-button');
load.textContent = state.library === 'failed' ? 'Retry' : state.library === 'loading' ? 'Loading…' : 'Load sound library';
load.hidden = state.library === 'loaded' || !!state.sampleError;
bar.dataset.sampleError = state.sampleError;
$('.library-recovery').hidden = !state.sampleError && !state.exportOffer;
$('.export-actions').hidden = !state.exportOffer;
load.setAttribute('aria-disabled', String(state.library === 'loading'));
renderPreview(state);
const error = $('.error-banner');
error.hidden = !state.error;
error.dataset.source = state.errorSource;
const code = state.errorSource === 'code';
$('.error-title').textContent = code ? 'Code error' : 'Check this';
$('.error-text').textContent = state.error;
$('.error-help').textContent = state.error && code
? state.playing ? 'The last working pattern keeps playing. Fix the code and press Update, or press Reset or Stop.'
: 'Nothing is playing. Fix the code and press Play, or press Reset.'
: '';
},
};
}
src/catalog.ts
The 21 Bases and 84 Codebank parts, copied verbatim from the archived app.
/*
* Vibey's Bases and Codebank parts, copied verbatim from the archived Vibey 0.1.0
* working tree (commit ec2c511 plus its uncommitted edits), src/model.ts lines 9–424
* (`export const BASES = [...]` and `export const PARTS = [...]`; that archived file's
* SHA-256 is 62b5437fb72d48bc77f189cef0f31afb89bdb7dee9487a12de834b54d20e33c5).
* Vibey © Jarran Tay. Licensed AGPL-3.0-only; see license.txt and NOTICE.md.
*
* Nothing below was edited, reordered or added: 21 Bases and 84 parts, with their
* names, details, roles (`group`) and code exactly as archived. The demo's
* catalog.test.ts compares this block with the archive when it is available.
* Bases and parts that call drum samples (bd, sd, hh, cp) need the sound library;
* see model.ts (sampleSounds) and audio.ts (loadSoundLibrary).
*/
export const BASES = [
{ id: 'synth', name: 'Warm synth', detail: 'Soft synth · C minor · 90 BPM', code: `// A little room to play. Ctrl + Enter to start.
setcpm(90/4)
// Pulse
$: note("c5 ~ c5 ~").s("sine")
.decay(.06).sustain(0).gain(.08)
// Warm lead
$: note("<c3 eb3 g3 bb3>")
.s("sawtooth").lpf(800)
.attack(.02).decay(.2).sustain(.25).release(.3)
.gain(.25)
// Bass
$: note("c2 [~ c2] eb2 g1")
.s("triangle").decay(.2).sustain(0)
.gain(.4)
// Try changing the notes, or the filter above.
` },
{ id: 'night', name: 'Night loop', detail: 'Late-night groove · C minor · 92 BPM', code: `// Drum samples load from the internet.
setcpm(92/4)
// Kick
$: s("bd ~ [~ bd] ~").gain(.7)
// Snare
$: s("~ sd ~ sd").gain(.45)
// Hats
$: s("hh*8").gain(".25 .12")
// Bass
$: note("c2 ~ eb2 g1")
.s("sawtooth").lpf(500)
.decay(.18).sustain(0).gain(.3)
` },
{ id: 'breaks', name: 'Breaks', detail: 'Breakbeat · C minor · 130 BPM', code: `// Try changing the subdivisions inside the quotes.
setcpm(130/4)
// Kick
$: s("bd [~ bd] ~ bd").gain(.65)
// Snare
$: s("~ sd ~ [sd sd]").gain(.35)
// Hats
$: s("hh*16").gain(".2 .08 .12 .08")
.sometimes(x => x.fast(2))
// Bass
$: note("<c2 c2 eb2 bb1>")
.s("sawtooth").lpf(700)
.decay(.12).sustain(0).gain(.25)
` },
{ id: 'house', name: 'House lights', detail: 'House · C minor · 122 BPM', code: `setcpm(122/4)
// Kick
$: s("bd*4").gain(.65)
// Clap
$: s("~ cp ~ cp").gain(.38)
// Hats
$: s("~ hh ~ hh ~ hh ~ hh").gain(.2)
// Bass
$: note("c2 ~ c2 g1").s("triangle").decay(.15).sustain(0).gain(.32)
// Chords
$: note("<[c4,eb4,g4] [ab3,c4,eb4]>").s("sawtooth")
.lpf(1100).attack(.04).release(.2).gain(.12)
` },
{ id: 'lofi', name: 'Lo-fi afternoon', detail: 'Lo-fi · C minor · 76 BPM', code: `setcpm(76/4)
// Kick
$: s("bd ~ ~ bd").gain(.55)
// Snare
$: s("~ sd ~ sd").gain(.3)
// Hats
$: s("hh*8").gain(".12 .05")
// Bass
$: note("c2 ~ eb2 ~").s("sine").decay(.3).sustain(0).gain(.3)
// Chords
$: note("<[c4,eb4,g4] [ab3,c4,eb4]>").s("triangle")
.slow(2).attack(.08).release(.5).room(.25).gain(.16)
` },
{ id: 'techno', name: 'After hours', detail: 'Techno · C minor · 128 BPM', code: `setcpm(128/4)
// Kick
$: s("bd*4").gain(.72)
// Hats
$: s("~ hh ~ hh ~ hh ~ hh").gain(.19)
// Clap
$: s("~ ~ cp ~").gain(.28)
// Bass
$: note("c2 c2 eb2 g1").s("sawtooth")
.lpf(550).decay(.12).sustain(0).gain(.27)
// Lead
$: note("~ c4 ~ eb4 g4 ~ eb4 ~").s("square")
.lpf(1200).decay(.08).sustain(0).gain(.1)
` },
{ id: 'garage', name: 'Garage skip', detail: 'Garage · C minor · 132 BPM', code: `setcpm(132/4)
// Kick
$: s("bd ~ [~ bd] ~").gain(.68)
// Clap
$: s("~ cp ~ [~ cp]").gain(.37)
// Hats
$: s("hh*8").gain(".1 .2 .08 .17")
// Bass
$: note("c2 ~ [c2 eb2] ~ g1 ~ eb2 ~").s("triangle")
.decay(.13).sustain(0).gain(.34)
// Melody
$: note("~ [g4 bb4] ~ eb4").s("sine")
.decay(.14).sustain(0).gain(.15)
` },
{ id: 'ambient', name: 'Open sky', detail: 'Ambient · C minor · 68 BPM', code: `setcpm(68/4)
// Chords
$: note("<[c3,eb3,g3] [ab2,c3,eb3] [bb2,d3,f3] [g2,bb2,d3]>")
.s("sine").slow(2).attack(.5).release(1.2).room(.6).gain(.18)
// Melody
$: note("c4 ~ g4 ~ eb4 ~ bb4 ~").s("triangle")
.slow(2).attack(.2).release(.8).delay(.25).gain(.12)
// Bass
$: note("c2 ~ ~ ~ ab1 ~ ~ ~").s("sine")
.slow(2).attack(.1).release(.7).gain(.24)
` },
{ id: 'dnb', name: 'Night drive', detail: 'Drum and bass · C minor · 172 BPM', code: `setcpm(172/4)
// Kick
$: s("bd ~ ~ [bd ~] ~ bd ~ ~").gain(.67)
// Snare
$: s("~ ~ sd ~ ~ ~ sd ~").gain(.4)
// Hats
$: s("hh*16").gain(".13 .05 .1 .05")
// Bass
$: note("c2 ~ c2 eb2 ~ g1 ~ bb1").s("sawtooth")
.lpf(650).decay(.11).sustain(0).gain(.25)
// Melody
$: note("~ g4 ~ eb4 ~ bb4 ~ g4").s("sine")
.decay(.12).sustain(0).gain(.13)
` },
{ id: 'hiphop', name: 'Pocket beat', detail: 'Hip-hop · C minor · 88 BPM', code: `setcpm(88/4)
// Kick
$: s("bd ~ ~ [~ bd]").gain(.65)
// Snare
$: s("~ sd ~ sd").gain(.35)
// Hats
$: s("hh*8").gain(".15 .07")
// Bass
$: note("c2 ~ g1 ~ eb2 ~ g1 ~").s("triangle")
.decay(.22).sustain(0).gain(.35)
// Melody
$: note("~ eb4 g4 ~ bb4 ~ g4 ~").s("sine")
.decay(.18).sustain(0).room(.2).gain(.16)
` },
{ id: 'disco', name: 'Mirrorball', detail: 'Disco · C minor · 116 BPM', code: `setcpm(116/4)
// Kick
$: s("bd*4").gain(.65)
// Clap
$: s("~ cp ~ cp").gain(.4)
// Hats
$: s("hh*8").gain(".08 .2")
// Bass
$: note("c2 ~ c3 ~ eb2 ~ g2 ~").s("triangle")
.decay(.14).sustain(0).gain(.32)
// Chords
$: note("<[c4,eb4,g4] [f3,ab3,c4]>").s("sawtooth")
.lpf(1500).attack(.02).release(.22).gain(.12)
` },
{ id: 'jazzhop', name: 'Blue hour', detail: 'Jazz-hop · C minor · 82 BPM', code: `setcpm(82/4)
// Kick
$: s("bd ~ [~ bd] ~").gain(.58)
// Snare
$: s("~ sd ~ sd").gain(.3)
// Hats
$: s("hh*8").gain(".1 .05 .13 .05")
// Bass
$: note("c2 ~ eb2 ~ g1 ~ bb1 ~").s("triangle")
.decay(.22).sustain(0).gain(.28)
// Chords
$: note("<[c4,eb4,g4,bb4,d5] [ab3,c4,eb4,g4]>").s("sine")
.slow(2).attack(.08).release(.6).room(.3).gain(.12)
` },
{ id: 'minimal', name: 'Bare minimum', detail: 'Minimal house · C minor · 124 BPM', code: `setcpm(124/4)
// Kick
$: s("bd*4").gain(.62)
// Hats
$: s("~ hh ~ hh ~ hh ~ hh").gain(.14)
// Clap
$: s("~ ~ ~ cp").gain(.21)
// Bass
$: note("c2 ~ ~ g1 ~ c2 ~ ~").s("sine")
.decay(.11).sustain(0).gain(.29)
// Chord stab
$: note("~ ~ [eb4,g4,bb4] ~").s("triangle")
.decay(.12).sustain(0).gain(.11)
` },
{ id: 'halftime', name: 'Heavy air', detail: 'Half-time · C minor · 140 BPM', code: `setcpm(140/4)
// Kick
$: s("bd ~ ~ [~ bd]").gain(.7)
// Snare
$: s("~ ~ sd ~").gain(.38)
// Hats
$: s("hh*8").gain(".09 .04 .12 .04")
// Sub bass
$: note("c2 ~ ~ ~ ab1 ~ g1 ~").s("sine")
.decay(.35).sustain(0).gain(.35)
// Melody
$: note("~ g4 ~ eb4 ~ bb4 ~ g4").s("triangle")
.decay(.14).sustain(0).delay(.22).gain(.13)
` },
{ id: 'dub', name: 'Deep echo', detail: 'Dub · C minor · 90 BPM', code: `setcpm(90/4)
// Kick
$: s("bd ~ ~ ~").gain(.64)
// Snare
$: s("~ ~ sd ~").gain(.3)
// Hats
$: s("~ hh ~ hh").gain(.12)
// Sub bass
$: note("c2 ~ ~ ~ ab1 ~ ~ ~").s("sine")
.decay(.5).sustain(0).gain(.36)
// Chord echo
$: note("~ [c4,eb4,g4] ~ [ab3,c4,eb4]").s("triangle")
.decay(.17).sustain(0).delay(.5).room(.35).gain(.12)
` },
{ id: 'acid', name: 'Acid line', detail: 'Acid house · C minor · 126 BPM', code: `setcpm(126/4)
// Kick
$: s("bd*4").gain(.67)
// Clap
$: s("~ cp ~ cp").gain(.31)
// Hats
$: s("hh*8").gain(".07 .16")
// Acid bass
$: note("c2 c2 eb2 ~ g1 bb1 g1 eb2").s("square")
.lpf(700).decay(.1).sustain(0).gain(.21)
// Chord stab
$: note("~ [c4,eb4,g4] ~ ~").s("sawtooth")
.lpf(1100).decay(.12).sustain(0).gain(.1)
` },
{ id: 'synthwave', name: 'Neon street', detail: 'Synthwave · C minor · 104 BPM', code: `setcpm(104/4)
// Kick
$: s("bd*4").gain(.59)
// Snare
$: s("~ sd ~ sd").gain(.34)
// Hats
$: s("hh*8").gain(.1)
// Bass
$: note("c2*4").s("sawtooth")
.lpf(600).decay(.15).sustain(0).gain(.25)
// Chords
$: note("<[c4,eb4,g4] [ab3,c4,eb4]>").s("triangle")
.slow(2).attack(.15).release(.7).room(.35).gain(.13)
// Melody
$: note("~ g4 bb4 ~ c5 ~ bb4 g4").s("sine")
.decay(.19).sustain(0).delay(.2).gain(.12)
` },
{ id: 'trance', name: 'First light', detail: 'Trance · C minor · 138 BPM', code: `setcpm(138/4)
// Kick
$: s("bd*4").gain(.65)
// Clap
$: s("~ cp ~ cp").gain(.27)
// Hats
$: s("~ hh ~ hh ~ hh ~ hh").gain(.15)
// Bass
$: note("c2 ~ c2 ~ ab1 ~ g1 ~").s("sine")
.decay(.16).sustain(0).gain(.29)
// Arpeggio
$: note("c4 eb4 g4 bb4 g4 eb4 c4 g3").s("sawtooth")
.lpf(1400).decay(.12).sustain(0).delay(.2).gain(.1)
// Pad
$: note("<[c3,eb3,g3] [ab2,c3,eb3]>").s("sine")
.slow(2).attack(.35).release(.8).gain(.1)
` },
{ id: 'cinematic', name: 'Slow horizon', detail: 'Cinematic ambient · C minor · 72 BPM', code: `setcpm(72/4)
// Bass
$: note("<c2 ab1 eb2 bb1>").s("sine")
.slow(2).attack(.2).release(.9).gain(.24)
// Chords
$: note("<[c3,eb3,g3] [ab2,c3,eb3] [eb3,g3,bb3] [bb2,d3,f3]>")
.s("sine").slow(2).attack(.6).release(1.1).room(.6).gain(.13)
// Melody
$: note("~ c5 ~ g4 ~ eb5 ~ bb4").s("triangle")
.slow(2).decay(.24).sustain(0).delay(.35).gain(.1)
` },
{ id: 'dreampop', name: 'Soft focus', detail: 'Dream pop · C minor · 96 BPM', code: `setcpm(96/4)
// Kick
$: s("bd ~ ~ bd").gain(.51)
// Snare
$: s("~ sd ~ sd").gain(.24)
// Hats
$: s("hh*8").gain(.07)
// Bass
$: note("c2 ~ eb2 ~ ab1 ~ g1 ~").s("triangle")
.decay(.23).sustain(0).gain(.27)
// Chords
$: note("<[c4,eb4,g4,bb4] [ab3,c4,eb4,g4]>").s("sine")
.slow(2).attack(.3).release(.9).room(.5).gain(.12)
// Melody
$: note("g4 ~ bb4 ~ c5 ~ g4 ~").s("sine")
.decay(.2).sustain(0).delay(.3).gain(.11)
` },
{ id: 'firstdrop', name: 'First drop', detail: 'House · C minor · 128 BPM', code: `// A four-bar starting point. Add a build or drop from Codebank.
setcpm(128/4)
// Kick
$: s("bd*4").gain(.64)
// Snare
$: s("~ sd ~ sd").gain(.32)
// Hats
$: s("hh*8").gain(".08 .16")
// Bass
$: note("<[~ c2 ~ c2] [~ ab1 ~ ab1] [~ eb2 ~ eb2] [~ bb1 ~ bb1]>").s("triangle")
.decay(.16).sustain(0).gain(.28)
// Chords
$: note("<[c3,eb3,g3] [ab2,c3,eb3] [eb3,g3,bb3] [bb2,d3,f3]>")
.s("sine").attack(.08).release(.42).gain(.11)
` },
];
export const PARTS = [
{ id: 'drums', name: 'Drums', group: 'Drums', detail: 'Steady kick, snare and hats', code: '$: s("bd*4").gain(.6)\n$: s("~ sd ~ sd").gain(.35)\n$: s("hh*8").gain(.12)' },
{ id: 'backbeat', name: 'Backbeat', group: 'Drums', detail: 'Loose kick with snare on two and four', code: '$: s("bd ~ ~ bd").gain(.6)\n$: s("~ sd ~ sd").gain(.35)' },
{ id: 'breakbeat', name: 'Breakbeat', group: 'Drums', detail: 'Extra kick and double snare', code: '$: s("bd [~ bd] ~ bd").gain(.6)\n$: s("~ sd ~ [sd sd]").gain(.32)' },
{ id: 'hats', name: 'Hats', group: 'Drums', detail: 'Alternating eighth-note hats', code: '$: s("hh*8").gain(".17 .07")' },
{ id: 'claps', name: 'Claps', group: 'Drums', detail: 'Claps on two and four', code: '$: s("~ cp ~ cp").gain(.3)' },
{ id: 'bass', name: 'Bass', group: 'Bass', detail: 'Filtered saw bass, broken four-note line', code: '$: note("c2 ~ eb2 g1").s("sawtooth")\n .lpf(500).decay(.18).sustain(0).gain(.25)' },
{ id: 'subbass', name: 'Sub bass', group: 'Bass', detail: 'Round sine bass, spaced eighth notes', code: '$: note("c2 ~ c2 ~ ab1 ~ g1 ~").s("sine")\n .decay(.3).sustain(0).gain(.32)' },
{ id: 'pulsebass', name: 'Pulse bass', group: 'Bass', detail: 'Triangle bass on every beat', code: '$: note("c2*4").s("triangle")\n .decay(.12).sustain(0).gain(.28)' },
{ id: 'walkingbass', name: 'Walking bass', group: 'Bass', detail: 'Triangle bass climbing quarter notes', code: '$: note("c2 eb2 g2 bb1").s("triangle")\n .decay(.2).sustain(0).gain(.28)' },
{ id: 'melody', name: 'Melody', group: 'Melody', detail: 'Sine lead rising through C minor', code: '$: note("c4 eb4 g4 bb4").s("sine")\n .decay(.2).sustain(0).delay(.25).gain(.18)' },
{ id: 'arpeggio', name: 'Arpeggio', group: 'Melody', detail: 'Triangle notes up and down Cm7', code: '$: note("c4 eb4 g4 bb4 g4 eb4").s("triangle")\n .decay(.12).sustain(0).gain(.14)' },
{ id: 'hook', name: 'Short hook', group: 'Melody', detail: 'Sine hook with rests in C minor', code: '$: note("~ g4 bb4 ~ eb4 ~ g4 ~").s("sine")\n .decay(.16).sustain(0).gain(.17)' },
{ id: 'chords', name: 'Chords', group: 'Chords', detail: 'Cm to Ab, two soft chords', code: '$: note("<[c3,eb3,g3] [ab2,c3,eb3]>").s("triangle")\n .slow(2).attack(.1).release(.5).room(.4).gain(.15)' },
{ id: 'stabs', name: 'Chord stabs', group: 'Chords', detail: 'Offbeat Cm and Ab stabs', code: '$: note("~ [c4,eb4,g4] ~ [ab3,c4,eb4]").s("sawtooth")\n .lpf(1200).decay(.13).sustain(0).gain(.13)' },
{ id: 'pad', name: 'Soft pad', group: 'Chords', detail: 'Slow Cm to Fm pad', code: '$: note("<[c3,eb3,g3] [f3,ab3,c4]>").s("sine")\n .slow(2).attack(.5).release(.9).room(.5).gain(.16)' },
{ id: 'sparkle', name: 'Sparkle', group: 'Texture', detail: 'High delayed sine notes', code: '$: note("c5 ~ g4 ~ eb5 ~ bb4 ~").s("sine")\n .slow(2).decay(.12).sustain(0).delay(.3).gain(.1)' },
{ id: 'drone', name: 'Drone', group: 'Texture', detail: 'Quiet sustained C tone', code: '$: note("c3").s("sine")\n .attack(.5).release(1).room(.5).gain(.12)' },
{ id: 'echo', name: 'Echo notes', group: 'Texture', detail: 'Sparse triangle notes with delay', code: '$: note("~ c5 ~ eb5 ~ g4 ~ eb5").s("triangle")\n .decay(.15).sustain(0).delay(.4).gain(.1)' },
{ id: 'fourkick', name: 'Four kicks', group: 'Drums', detail: 'Even kick on each beat', code: '$: s("bd*4").gain(.62)' },
{ id: 'brokenkick', name: 'Broken kicks', group: 'Drums', detail: 'Kick with a late pickup', code: '$: s("bd ~ [~ bd] bd").gain(.6)' },
{ id: 'halftime', name: 'Half-time kit', group: 'Drums', detail: 'Heavy kick and slow snare', code: '$: s("bd ~ ~ bd").gain(.62)\n$: s("~ ~ sd ~").gain(.38)' },
{ id: 'shuffledhats', name: 'Shuffled hats', group: 'Drums', detail: 'Hats with uneven accents', code: '$: s("hh*8").gain(".08 .18 .06 .14")' },
{ id: 'snareroll', name: 'Snare fill', group: 'Drums', detail: 'Short snare roll at bar end', code: '$: s("~ ~ ~ [sd sd sd sd]").gain(.25)' },
{ id: 'offbeatbass', name: 'Offbeat bass', group: 'Bass', detail: 'Short triangle hits between beats', code: '$: note("~ c2 ~ c2 ~ ab1 ~ g1").s("triangle")\n .decay(.11).sustain(0).gain(.28)' },
{ id: 'syncbass', name: 'Syncopated bass', group: 'Bass', detail: 'Filtered saw bass with a pickup', code: '$: note("c2 ~ [~ c2] eb2 ~ g1 ~").s("sawtooth")\n .lpf(650).decay(.12).sustain(0).gain(.24)' },
{ id: 'octavebass', name: 'Octave bass', group: 'Bass', detail: 'Round sine bass alternating C octaves', code: '$: note("c2 ~ c3 ~ c2 ~ c3 ~").s("sine")\n .decay(.18).sustain(0).gain(.28)' },
{ id: 'dubbass', name: 'Dub bass', group: 'Bass', detail: 'Deep sine hits with long gaps', code: '$: note("c2 ~ ~ ~ ab1 ~ ~ ~").s("sine")\n .decay(.5).sustain(0).gain(.34)' },
{ id: 'gritbass', name: 'Grit bass', group: 'Bass', detail: 'Low-pass square notes on every beat', code: '$: note("c2 c2 eb2 g1").s("square")\n .lpf(450).decay(.1).sustain(0).gain(.2)' },
{ id: 'bouncebass', name: 'Bounce bass', group: 'Bass', detail: 'Triangle bass in a two-step rhythm', code: '$: note("c2 ~ c2 [~ eb2] g1 ~ bb1 ~").s("triangle")\n .decay(.14).sustain(0).gain(.28)' },
{ id: 'longsub', name: 'Long sub', group: 'Bass', detail: 'Sustained sine roots, C to Ab', code: '$: note("<c2 ab1>").s("sine")\n .attack(.04).release(.35).gain(.3)' },
{ id: 'descendingbass', name: 'Descending bass', group: 'Bass', detail: 'Saw bass walking down Bb to C', code: '$: note("bb1 ab1 g1 c2").s("sawtooth")\n .lpf(550).decay(.18).sustain(0).gain(.24)' },
{ id: 'chordcm', name: 'Cm', group: 'Chords', detail: 'C minor: C, Eb, G', code: '$: note("[c3,eb3,g3]").s("triangle")\n .attack(.06).release(.4).gain(.14)' },
{ id: 'chordcm7', name: 'Cm7', group: 'Chords', detail: 'C minor 7: C, Eb, G, Bb', code: '$: note("[c3,eb3,g3,bb3]").s("triangle")\n .attack(.06).release(.4).gain(.12)' },
{ id: 'chordcm9', name: 'Cm9', group: 'Chords', detail: 'C minor 9: C, Eb, G, Bb, D', code: '$: note("[c3,eb3,g3,bb3,d4]").s("sine")\n .attack(.08).release(.5).gain(.11)' },
{ id: 'chordab', name: 'Ab', group: 'Chords', detail: 'A-flat major: Ab, C, Eb', code: '$: note("[ab2,c3,eb3]").s("triangle")\n .attack(.06).release(.4).gain(.14)' },
{ id: 'chordabmaj7', name: 'Abmaj7', group: 'Chords', detail: 'A-flat major 7: Ab, C, Eb, G', code: '$: note("[ab2,c3,eb3,g3]").s("sine")\n .attack(.08).release(.5).gain(.12)' },
{ id: 'chordeb', name: 'Eb', group: 'Chords', detail: 'E-flat major: Eb, G, Bb', code: '$: note("[eb3,g3,bb3]").s("triangle")\n .attack(.06).release(.4).gain(.14)' },
{ id: 'chordebmaj7', name: 'Ebmaj7', group: 'Chords', detail: 'E-flat major 7: Eb, G, Bb, D', code: '$: note("[eb3,g3,bb3,d4]").s("sine")\n .attack(.08).release(.5).gain(.12)' },
{ id: 'chordbb', name: 'Bb', group: 'Chords', detail: 'B-flat major: Bb, D, F', code: '$: note("[bb2,d3,f3]").s("triangle")\n .attack(.06).release(.4).gain(.14)' },
{ id: 'chordbbsus4', name: 'Bbsus4', group: 'Chords', detail: 'B-flat suspended 4: Bb, Eb, F', code: '$: note("[bb2,eb3,f3]").s("triangle")\n .attack(.06).release(.4).gain(.14)' },
{ id: 'chordfm7', name: 'Fm7', group: 'Chords', detail: 'F minor 7: F, Ab, C, Eb', code: '$: note("[f3,ab3,c4,eb4]").s("sine")\n .attack(.08).release(.5).gain(.12)' },
{ id: 'chordgm7', name: 'Gm7', group: 'Chords', detail: 'G minor 7: G, Bb, D, F', code: '$: note("[g2,bb2,d3,f3]").s("sine")\n .attack(.08).release(.5).gain(.12)' },
{ id: 'proganthem', name: 'Cm–Ab–Eb–Bb', group: 'Chords', detail: 'Four-chord C minor anthem', code: '$: note("<[c3,eb3,g3] [ab2,c3,eb3] [eb3,g3,bb3] [bb2,d3,f3]>")\n .s("triangle").attack(.05).release(.35).gain(.12)' },
{ id: 'progmoon', name: 'Cm7–Abmaj7', group: 'Chords', detail: 'Warm two-chord minor 7 loop', code: '$: note("<[c3,eb3,g3,bb3] [ab2,c3,eb3,g3]>")\n .s("sine").attack(.1).release(.5).gain(.12)' },
{ id: 'progsoul', name: 'Fm7–Bb–Ebmaj7', group: 'Chords', detail: 'Soulful minor to major resolution', code: '$: note("<[f3,ab3,c4,eb4] [bb2,d3,f3] [eb3,g3,bb3,d4]>")\n .s("triangle").attack(.06).release(.4).gain(.11)' },
{ id: 'progcircle', name: 'Cm–Fm7–Bb–Eb', group: 'Chords', detail: 'Moving minor-to-major progression', code: '$: note("<[c3,eb3,g3] [f3,ab3,c4,eb4] [bb2,d3,f3] [eb3,g3,bb3]>")\n .s("sine").attack(.08).release(.45).gain(.12)' },
{ id: 'progdusk', name: 'Ab–Bb–Cm', group: 'Chords', detail: 'Rising three-chord minor lift', code: '$: note("<[ab2,c3,eb3] [bb2,d3,f3] [c3,eb3,g3]>")\n .s("triangle").attack(.05).release(.4).gain(.12)' },
{ id: 'progsus', name: 'Cm–Bbsus4–Bb', group: 'Chords', detail: 'Suspended B-flat resolving to major', code: '$: note("<[c3,eb3,g3] [bb2,eb3,f3] [bb2,d3,f3]>")\n .s("sine").attack(.08).release(.45).gain(.12)' },
{ id: 'progressiondescent', name: 'Cm–Bb–Ab–Bb', group: 'Chords', detail: 'Dark four-bar descent with a return', code: '$: note("<[c3,eb3,g3] [bb2,d3,f3] [ab2,c3,eb3] [bb2,d3,f3]>")\n .s("triangle").attack(.07).release(.4).gain(.12)' },
{ id: 'progressionlift', name: 'Eb–Bb–Cm–Ab', group: 'Chords', detail: 'Uplifting four-bar major-to-minor lift', code: '$: note("<[eb3,g3,bb3] [bb2,d3,f3] [c3,eb3,g3] [ab2,c3,eb3]>")\n .s("triangle").attack(.07).release(.4).gain(.12)' },
{ id: 'progressionwide', name: 'Ab–Eb–Bb–Cm', group: 'Chords', detail: 'Open four-bar rise toward Cm', code: '$: note("<[ab2,c3,eb3] [eb3,g3,bb3] [bb2,d3,f3] [c3,eb3,g3]>")\n .s("sine").attack(.12).release(.5).gain(.12)' },
{ id: 'progressionreturn', name: 'Cm–Gm7–Ab–Bb', group: 'Chords', detail: 'Restless four-bar minor journey', code: '$: note("<[c3,eb3,g3] [g2,bb2,d3,f3] [ab2,c3,eb3] [bb2,d3,f3]>")\n .s("triangle").attack(.06).release(.4).gain(.11)' },
{ id: 'progressionnight', name: 'Cm9–Fm7', group: 'Chords', detail: 'Smoky two-bar jazz colour', code: '$: note("<[c3,eb3,g3,bb3,d4] [f3,ab3,c4,eb4]>")\n .s("sine").attack(.12).release(.55).gain(.1)' },
{ id: 'progressionlight', name: 'Cm7–Ebmaj7', group: 'Chords', detail: 'Dreamy two-bar minor-to-major shift', code: '$: note("<[c3,eb3,g3,bb3] [eb3,g3,bb3,d4]>")\n .s("sine").attack(.15).release(.6).gain(.11)' },
{ id: 'progressionsoulful', name: 'Fm7–Abmaj7', group: 'Chords', detail: 'Soulful two-bar warm suspension', code: '$: note("<[f3,ab3,c4,eb4] [ab2,c3,eb3,g3]>")\n .s("triangle").attack(.1).release(.5).gain(.11)' },
{ id: 'progressionopen', name: 'Cm–Eb', group: 'Chords', detail: 'Simple two-bar hopeful turn', code: '$: note("<[c3,eb3,g3] [eb3,g3,bb3]>")\n .s("triangle").attack(.08).release(.45).gain(.13)' },
{ id: 'progressionbright', name: 'Ebmaj7–Bb', group: 'Chords', detail: 'Bright two-bar open ending', code: '$: note("<[eb3,g3,bb3,d4] [bb2,d3,f3]>")\n .s("sine").attack(.11).release(.5).gain(.11)' },
{ id: 'progressionrise', name: 'Abmaj7–Bb–Cm9–Cm9', group: 'Chords', detail: 'Cinematic four-bar rise and hold', code: '$: note("<[ab2,c3,eb3,g3] [bb2,d3,f3] [c3,eb3,g3,bb3,d4] [c3,eb3,g3,bb3,d4]>")\n .s("sine").attack(.14).release(.6).gain(.1)' },
{ id: 'progressionfall', name: 'Cm–Bbsus4–Ab–Gm7', group: 'Chords', detail: 'Tense four-bar step downward', code: '$: note("<[c3,eb3,g3] [bb2,eb3,f3] [ab2,c3,eb3] [g2,bb2,d3,f3]>")\n .s("triangle").attack(.06).release(.4).gain(.11)' },
{ id: 'progressionbuild', name: 'Gm7–Abmaj7–Bb–Cm', group: 'Chords', detail: 'Building four-bar climb into Cm', code: '$: note("<[g2,bb2,d3,f3] [ab2,c3,eb3,g3] [bb2,d3,f3] [c3,eb3,g3]>")\n .s("triangle").attack(.07).release(.4).gain(.11)' },
{ id: 'progressionwistful', name: 'Eb–Fm7–Cm–Bb', group: 'Chords', detail: 'Wistful four-bar detour', code: '$: note("<[eb3,g3,bb3] [f3,ab3,c4,eb4] [c3,eb3,g3] [bb2,d3,f3]>")\n .s("sine").attack(.1).release(.5).gain(.11)' },
{ id: 'progressiontender', name: 'Cm–Abmaj7–Fm7–Gm7', group: 'Chords', detail: 'Tender four-bar minor movement', code: '$: note("<[c3,eb3,g3] [ab2,c3,eb3,g3] [f3,ab3,c4,eb4] [g2,bb2,d3,f3]>")\n .s("sine").attack(.12).release(.55).gain(.11)' },
{ id: 'progressionlow', name: 'Bb–Ab–Cm–Cm', group: 'Chords', detail: 'Grounded four-bar landing on Cm', code: '$: note("<[bb2,d3,f3] [ab2,c3,eb3] [c3,eb3,g3] [c3,eb3,g3]>")\n .s("triangle").attack(.07).release(.45).gain(.12)' },
{ id: 'progressionturn', name: 'Fm7–Gm7–Cm7–Abmaj7', group: 'Chords', detail: 'Soulful four-bar turn to a warm finish', code: '$: note("<[f3,ab3,c4,eb4] [g2,bb2,d3,f3] [c3,eb3,g3,bb3] [ab2,c3,eb3,g3]>")\n .s("sine").attack(.11).release(.55).gain(.11)' },
{ id: 'lowhook', name: 'Low hook', group: 'Melody', detail: 'Triangle hook near middle C', code: '$: note("~ c4 eb4 ~ g4 ~ eb4 ~").s("triangle")\n .decay(.17).sustain(0).gain(.16)' },
{ id: 'highhook', name: 'High hook', group: 'Melody', detail: 'Bright sine notes with rests', code: '$: note("g4 ~ bb4 ~ c5 ~ g4 ~").s("sine")\n .decay(.15).sustain(0).gain(.13)' },
{ id: 'answer', name: 'Call and answer', group: 'Melody', detail: 'Two short C minor phrases', code: '$: note("c4 eb4 ~ ~ g4 bb4 ~ ~").s("triangle")\n .decay(.18).sustain(0).gain(.15)' },
{ id: 'falling', name: 'Falling notes', group: 'Melody', detail: 'Sine line descending from C', code: '$: note("c5 bb4 g4 eb4").s("sine")\n .decay(.16).sustain(0).gain(.15)' },
{ id: 'highticks', name: 'High ticks', group: 'Texture', detail: 'Quiet high staccato accents', code: '$: note("~ ~ c6 ~ ~ g5 ~ ~").s("sine")\n .decay(.06).sustain(0).gain(.07)' },
{ id: 'fifthdrone', name: 'Fifth drone', group: 'Texture', detail: 'Sustained C and G bed', code: '$: note("[c3,g3]").s("sine")\n .attack(.4).release(.8).gain(.09)' },
{ id: 'softbells', name: 'Soft bells', group: 'Texture', detail: 'Slow upper notes with room', code: '$: note("~ eb5 ~ g5 ~ bb4 ~ g5").s("sine")\n .slow(2).decay(.25).sustain(0).room(.45).gain(.09)' },
{ id: 'kickdeep', name: 'Deep kick', group: 'Drums', detail: 'Lower, rounded four-on-floor kick', code: '$: s("bd*4").speed(.85).lpf(250).gain(.68)' },
{ id: 'kickpunch', name: 'Punchy kick', group: 'Drums', detail: 'Brighter, tight four-on-floor kick', code: '$: s("bd*4").speed(1.15).gain(.72)' },
{ id: 'kickoffbeat', name: 'Offbeat kick', group: 'Drums', detail: 'Kick between the main beats', code: '$: s("~ bd ~ bd").gain(.65)' },
{ id: 'kicksync', name: 'Syncopated kick', group: 'Drums', detail: 'Kick pickups and a double finish', code: '$: s("bd [~ bd] ~ [bd bd]").gain(.67)' },
{ id: 'groovegarage', name: 'Garage groove', group: 'Drums', detail: 'Skipping kick, claps and shuffled hats', code: '$: s("bd ~ [~ bd] ~").gain(.62)\n$: s("~ cp ~ [~ cp]").gain(.31)\n$: s("hh*8").gain(".08 .16 .06 .14")' },
{ id: 'groovednb', name: 'Fast break groove', group: 'Drums', detail: 'Rolling kick, snare and fine hats', code: '$: s("bd ~ ~ [bd ~] ~ bd ~ ~").gain(.63)\n$: s("~ ~ sd ~ ~ ~ sd ~").gain(.36)\n$: s("hh*16").gain(".09 .04 .11 .04")' },
{ id: 'buildsnare', name: 'Snare build', group: 'Builds', detail: 'Four bars from two to sixteen snare hits', code: '$: s("<sd*2 sd*4 sd*8 sd*16>").gain("<.12 .17 .22 .28>")' },
{ id: 'buildhats', name: 'Hat build', group: 'Builds', detail: 'Four bars of rising hat density', code: '$: s("<hh*4 hh*8 hh*12 hh*16>").gain("<.08 .1 .12 .15>")' },
{ id: 'fillkick', name: 'Kick fill', group: 'Builds', detail: 'Three quiet bars, then a kick triplet', code: '$: s("<~ ~ ~ [bd bd bd]>").gain(.51)' },
{ id: 'fillsnare', name: 'Ending snare', group: 'Builds', detail: 'Three quiet bars, then four snare hits', code: '$: s("<~ ~ ~ [sd sd sd sd]>").gain(.28)' },
{ id: 'drophouse', name: 'House drop', group: 'Drops', detail: 'Firm four-beat kick, claps and bright hats', code: '$: s("bd*4").gain(.7)\n$: s("~ cp ~ cp").gain(.35)\n$: s("hh*8").gain(".1 .18")' },
{ id: 'dropbreak', name: 'Break drop', group: 'Drops', detail: 'Broken kick, doubled snare and fast hats', code: '$: s("bd [~ bd] ~ bd").gain(.68)\n$: s("~ sd ~ [sd sd]").gain(.36)\n$: s("hh*16").gain(".1 .04 .13 .04")' },
{ id: 'drophalftime', name: 'Half-time drop', group: 'Drops', detail: 'Heavy kick, sparse snare and quick hats', code: '$: s("bd ~ ~ [~ bd]").gain(.73)\n$: s("~ ~ sd ~").gain(.4)\n$: s("hh*16").gain(".08 .03 .11 .03")' },
];
package.json
Exact pinned dependencies.
{
"name": "vibey-browser-demo",
"version": "0.1.0-demo.1",
"private": true,
"type": "module",
"description": "Vibey studio: a browser adaptation of Vibey 0.1.0 (ec2c511 as archived), a small Strudel music-through-code studio with its 21 Bases, 84 Codebank parts and Help.",
"author": "Jarran Tay",
"license": "AGPL-3.0-only",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build && node scripts/source-page.mjs",
"test": "vitest run src",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@codemirror/commands": "6.8.0",
"@strudel/codemirror": "1.3.0",
"@strudel/core": "1.2.6",
"@strudel/draw": "1.2.6",
"@strudel/mini": "1.2.6",
"@strudel/tonal": "1.2.6",
"@strudel/transpiler": "1.2.6",
"@strudel/webaudio": "1.3.0",
"acorn": "8.18.0"
},
"devDependencies": {
"@types/node": "^26.0.0",
"happy-dom": "20.14.5",
"typescript": "7.0.2",
"vite": "8.3.0",
"vitest": "5.0.1"
}
}
Also in the zip: src/style.css, src/types.ts, src/strudel.d.ts, the tests (src/audio.test.ts, src/catalog.test.ts, src/main.test.ts, src/model.test.ts), index.html, vite.config.ts, tsconfig.json, this page's generator (scripts/source-page.mjs), package-lock.json, license.txt, .gitignore, README.md and NOTICE.md.