Skip to content

Instantly share code, notes, and snippets.

@saxenauts
Last active March 20, 2026 01:43
Show Gist options
  • Select an option

  • Save saxenauts/56f54ac5fd0a31bf153f8c10dee745d3 to your computer and use it in GitHub Desktop.

Select an option

Save saxenauts/56f54ac5fd0a31bf153f8c10dee745d3 to your computer and use it in GitHub Desktop.
cold signal — berlin school × deep space radio × numbers station × probe telemetry
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>cold signal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cormorant:ital,wght@0,300;1,300;1,400&family=JetBrains+Mono:wght@300;400&display=swap" rel="stylesheet">
<script src="https://unpkg.com/@strudel/repl@1.0.2"></script>
<style>
:root {
--void: #000000;
--dim: #1A1A20;
--muted: #2A2A35;
--amber: #B8A878;
--gold: #7A6018;
--glow: #D4C080;
/* override strudel theme vars */
--background: #000000 !important;
--lineBackground: #00000000 !important;
--foreground: #B8A878 !important;
--caret: #D4C080 !important;
--selection: rgba(184, 168, 120, 0.15) !important;
--selectionMatch: rgba(184, 168, 120, 0.08) !important;
--lineHighlight: rgba(184, 168, 120, 0.04) !important;
--gutterBackground: transparent !important;
--gutterForeground: #7A6018 !important;
--gutterBorder: transparent !important;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
background: var(--void);
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
font-family: 'JetBrains Mono', monospace;
color: var(--amber);
position: relative;
}
/* pure black — no gradients */
/* ─── HEADER (vertical stack) ─── */
header {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 24px 0 16px;
width: 75%;
margin: 0 auto;
position: relative;
z-index: 2;
}
.title-block {
display: flex;
flex-direction: column;
gap: 7px;
}
h1 {
font-family: 'Cormorant', Georgia, serif;
font-weight: 300;
font-style: italic;
font-size: 30px;
letter-spacing: 0.05em;
color: var(--amber);
line-height: 1;
}
.sub {
font-size: 8.5px;
letter-spacing: 0.25em;
color: var(--gold);
text-transform: uppercase;
}
/* ─── CONTROLS (horizontal row below title) ─── */
.controls {
display: flex;
align-items: center;
gap: 14px;
}
.transport {
display: flex;
gap: 6px;
align-items: center;
}
.btn-t {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
letter-spacing: 0.2em;
background: none;
border: 1px solid rgba(122, 96, 24, 0.5);
color: rgba(184, 168, 120, 0.6);
padding: 5px 12px;
cursor: pointer;
transition: border-color .2s, color .2s;
}
.btn-t:hover {
border-color: var(--amber);
color: var(--glow);
}
.btn-t.active {
border-color: var(--amber);
color: var(--amber);
}
.btn-t:disabled {
opacity: .3;
cursor: default;
}
.sep {
width: 1px;
height: 14px;
background: rgba(122, 96, 24, 0.3);
}
.status-row {
display: flex;
align-items: center;
gap: 6px;
}
.dot {
width: 5px; height: 5px;
border-radius: 50%;
background: rgba(184, 168, 120, 0.3);
flex-shrink: 0;
transition: background 0.6s ease;
}
.status-row.playing .dot {
background: var(--amber);
animation: blink 1.4s ease-in-out infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: .15; }
}
#statusText {
font-size: 8px;
letter-spacing: 0.18em;
color: rgba(184, 168, 120, 0.55);
transition: color 0.6s ease;
}
.status-row.playing #statusText {
color: var(--amber);
}
.freq {
font-size: 8px;
letter-spacing: 0.12em;
color: var(--gold);
margin-left: auto;
transition: color 0.15s, text-shadow 0.15s;
}
.freq.burst {
color: var(--amber);
text-shadow: 0 0 6px rgba(184, 168, 120, 0.4);
}
/* ─── EDITOR WRAP ─── */
.editor-wrap {
flex: 1;
min-height: 0;
position: relative;
z-index: 1;
padding: 0 0 20px;
display: flex;
flex-direction: column;
}
/* @strudel/repl renders CM as a SIBLING div next to strudel-editor, not inside it */
strudel-editor {
display: none; /* hide the inert element */
}
/* the sibling div that repl inserts IS the editor — make it fill the wrap */
.editor-wrap > div {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.editor-wrap > div .cm-editor {
flex: 1;
min-height: 0;
max-width: 100vw !important;
background: transparent !important;
}
.editor-wrap > div .cm-editor .cm-scroller {
font-family: 'JetBrains Mono', monospace !important;
font-size: 13px !important;
padding: 0 12.5% !important;
overflow-x: hidden !important;
overflow-wrap: anywhere !important;
}
.cm-editor .cm-content {
white-space: pre-wrap !important;
word-break: break-all !important;
overflow-wrap: anywhere !important;
max-width: 100% !important;
}
.cm-editor .cm-line {
white-space: pre-wrap !important;
word-break: break-all !important;
overflow-wrap: anywhere !important;
}
/* force line wrapping mode */
.cm-editor .cm-lineWrapping { white-space: pre-wrap !important; }
/* CodeMirror overrides for void aesthetic */
.cm-editor { background: transparent !important; }
.cm-editor .cm-gutters { display: none !important; }
.cm-editor .cm-content { caret-color: var(--glow) !important; }
.cm-editor .cm-cursor { border-left-color: var(--glow) !important; }
.cm-editor .cm-selectionBackground { background: rgba(184, 168, 120, 0.12) !important; }
.cm-editor.cm-focused .cm-selectionBackground { background: rgba(184, 168, 120, 0.18) !important; }
.cm-editor .cm-activeLine { background: rgba(184, 168, 120, 0.03) !important; }
.cm-editor .cm-matchingBracket { color: var(--glow) !important; background: rgba(212, 192, 128, 0.12) !important; }
.cm-editor .cm-scroller { overflow-y: hidden !important; scrollbar-width: none !important; }
.cm-editor .cm-scroller::-webkit-scrollbar { display: none !important; }
/* monochrome gold — nuke all syntax colors */
.cm-editor .cm-line,
.cm-editor .cm-line *,
.cm-editor .cm-content,
.cm-editor .cm-gutterElement {
color: var(--amber) !important;
}
/* comments slightly dimmer */
.cm-editor .cm-line .tok-comment,
.cm-editor .cm-line .tok-lineComment,
.cm-editor .cm-line .tok-blockComment {
color: rgba(184, 168, 120, 0.35) !important;
}
.cm-editor .cm-slider input[type="range"] { accent-color: var(--gold) !important; }
/* hide any strudel-injected panels/toolbars */
.cm-panels { display: none !important; }
/* ─── PHASE DISC (session timer) ─── */
.phase-disc {
width: 160px;
margin: 8px auto 0;
text-align: center;
opacity: 0.4;
transition: opacity 0.6s ease;
}
.phase-disc.active { opacity: 1; }
.phase-disc svg { width: 160px; height: 160px; }
.pd-ring { fill: none; stroke: var(--gold); stroke-width: 1; opacity: 0.5; }
.pd-arc {
fill: none;
stroke: var(--amber);
stroke-width: 3;
stroke-linecap: round;
transition: opacity 0.3s;
}
.pd-sweep {
stroke: var(--glow);
stroke-width: 1;
filter: drop-shadow(0 0 3px var(--gold));
opacity: 0;
transition: opacity 0.3s;
}
.phase-disc.active .pd-sweep { opacity: 1; }
.pd-time {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
fill: var(--amber);
text-anchor: middle;
dominant-baseline: central;
}
.pd-tick { stroke: var(--gold); stroke-width: 1; opacity: 0.4; }
.pd-duration {
font-family: 'JetBrains Mono', monospace;
font-size: 8px;
letter-spacing: 0.15em;
color: var(--gold);
cursor: pointer;
margin-top: 4px;
user-select: none;
transition: color 0.2s;
}
.pd-duration:hover { color: var(--amber); }
.phase-disc.ending .pd-arc,
.phase-disc.ending .pd-time {
animation: pd-pulse 0.6s ease-in-out infinite;
}
@keyframes pd-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* bottom vignette so editor fades into the void */
.editor-wrap::after {
content: '';
position: absolute;
bottom: 20px; left: 0; right: 0;
height: 28px;
background: linear-gradient(to top, #000, transparent);
pointer-events: none;
z-index: 2;
}
</style>
</head>
<body>
<header>
<div class="title-block">
<h1>cold signal</h1>
<span class="sub">cm9 · a♭maj7 · e♭maj7 · g7 &nbsp;·&nbsp; 67 bpm</span>
</div>
<div class="controls">
<div class="transport">
<button class="btn-t" id="playBtn">▶ play</button>
<button class="btn-t" id="stopBtn">■ stop</button>
</div>
<span class="sep"></span>
<div class="status-row" id="statusRow">
<span class="dot"></span>
<span id="statusText">ctrl+enter to play</span>
</div>
<span class="freq" id="freq">— — —.— kHz</span>
</div>
<!-- phase disc — session timer / telemetry gauge -->
<div class="phase-disc" id="phaseDisc">
<svg viewBox="0 0 90 90">
<circle cx="45" cy="45" r="42" class="pd-ring"/>
<g id="pdTicks"></g>
<circle cx="45" cy="45" r="36" class="pd-arc" id="pdArc"
transform="rotate(-90 45 45)"/>
<line x1="45" y1="45" x2="45" y2="9" class="pd-sweep" id="pdSweep"/>
<text x="45" y="45" class="pd-time" id="pdTime">05:00</text>
</svg>
<div class="pd-duration" id="pdDuration">05:00</div>
</div>
</header>
<div class="editor-wrap" id="editorWrap"></div>
<script>
// ─── composition ─────────────────────────────────────────────────────────────
// "cold signal" — berlin school × deep space radio × numbers station × probe telemetry
// Feel: Klaus Schulze × shortwave radio × analog sequencer × glacial drift
// NO DRUMS — rhythm comes from the machine sequencer pulse in the harmony itself
//
// Layers: SEQUENCE (casio machine pulse) · PAD (juno glacial wash) ·
// TEXTURE (trump radio signal) · BLIPS (arpy telemetry) ·
// SUB (jvbass drone) · ATMOSPHERE (wind static)
//
// CRITICAL: Semicolons required after top-level statements. The strudel-editor
// web component strips newlines from textContent, collapsing code to one line.
// Without semicolons, `samples(...)setcps(...)` causes "Unexpected token (1:90)".
// ─────────────────────────────────────────────────────────────────────────────
const CODE = `samples('https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/strudel.json'); setcps(.56); let chords = chord("<Cm9 Ab^7 Eb^7 G7>/4").dict('ireal'); stack(chords.n("[0 <2 1>*2](<3 5>,8)").anchor("C4").voicing().s("casio").lpf(sine.range(180,650).slow(16)).lpq(7).delay(.4).delaytime(".75").gain(perlin.range(.35,.65)).mask("<1 1 0 1 1 0 1 0>/8").room(.85).size(5), chords.n("[0,2,4]").anchor("C3").voicing().s("juno").lpf(sine.range(200,800).slow(32)).lpq(3).gain(perlin.range(.25,.55)).mask("<1 1 1 1 1 1 1 0>/16").room(.9).size(8).attack(.5).release(2), chords.n("[0 <3 2 <1 4>>*2](<3 5>,8)").anchor("C5").voicing().s("trump").speed(perlin.range(.93,1.07)).lpf(sine.range(400,1200).slow(24)).lpq(5).mask("<0 0 0 1>/16").delay(.5).delaytime(".75").gain(perlin.range(.25,.6)).room(.95).size(8), n("<0!5 <2 3> 0 1>").s("arpy").mask("<0 0 0 0 0 0 0 1>/16").speed(perlin.range(.95,1.05)).delay(.6).delaytime("1.5").gain(.2).room(.95).size(9).lpf(800), n("<0!7 1>").set(chords).mode("root:c2").voicing().s("jvbass").lpf(sine.range(80,350).slow(24)).lpq(4).gain(.7).shape(.25).mask("<1!14 0 1>/16").room(.3).size(3), s("wind").mask("<0 0 0 1>/16").lpf(300).lpq(2).gain(.06).room(.95).size(10), chords.n("[0 <2 4> <4 6> <6 0>]*4").anchor("C6").voicing().s("glockenspiel").lpf(sine.range(1500,4000).slow(8)).lpq(3).gain(perlin.range(.18,.42)).mask("<1 0 1 1 0 1 0 1>/8").delay(.35).delaytime(".125").room(.9).size(7)).late("[0 .01]*4").late("[0 .01]*2")`;
// ─── mount the editor ─────────────────────────────────────────────────────
const editor = document.createElement('strudel-editor');
editor.setAttribute('code', CODE); // repl reads 'code' attr directly — safe for <> brackets
editor.setAttribute('auto-focus', '');
document.getElementById('editorWrap').appendChild(editor);
// ─── kill strudel's theme injection ─────────────────────────────────────
// @strudel/web injects <style id="strudel-theme-vars"> with !important
// CSS vars that override ours. Remove it and force our palette inline.
const nukeTheme = () => {
const st = document.getElementById('strudel-theme-vars');
if (st) st.remove();
const r = document.documentElement.style;
r.setProperty('--background', '#030304', 'important');
r.setProperty('--lineBackground', '#03030400', 'important');
r.setProperty('--foreground', '#B8A878', 'important');
r.setProperty('--caret', '#D4C080', 'important');
r.setProperty('--selection', 'rgba(184,168,120,0.15)', 'important');
r.setProperty('--selectionMatch', 'rgba(184,168,120,0.08)', 'important');
r.setProperty('--lineHighlight', 'rgba(184,168,120,0.04)', 'important');
r.setProperty('--gutterBackground', 'transparent', 'important');
r.setProperty('--gutterForeground', '#7A6018', 'important');
r.setProperty('--gutterBorder', 'transparent', 'important');
};
nukeTheme();
// also catch any late re-injection
new MutationObserver(nukeTheme).observe(document.head, { childList: true });
// ─── state ────────────────────────────────────────────────────────────────
const playBtn = document.getElementById('playBtn');
const stopBtn = document.getElementById('stopBtn');
const statusRow = document.getElementById('statusRow');
const statusText = document.getElementById('statusText');
// ─── burst mode state ──────────────────────────────────────────────────
let burstActive = false;
let burstInterval = null;
function setPlaying(on) {
if (on) {
statusText.textContent = 'transmitting';
statusRow.classList.add('playing');
playBtn.classList.add('active');
playBtn.textContent = '▶ play';
pdStart();
// start burst cycle: fire immediately, then ~3s burst every ~12s
if (burstInterval) clearInterval(burstInterval);
// fire first burst immediately
burstActive = true;
freqEl.classList.add('burst');
setTimeout(() => {
burstActive = false;
freqEl.classList.remove('burst');
}, 3000);
// then recur every ~12s
burstInterval = setInterval(() => {
burstActive = true;
freqEl.classList.add('burst');
setTimeout(() => {
burstActive = false;
freqEl.classList.remove('burst');
}, 3000);
}, 12000);
} else {
statusText.textContent = 'signal lost';
statusRow.classList.remove('playing');
playBtn.classList.remove('active');
playBtn.textContent = '▶ play';
pdStop();
// clear burst cycle
if (burstInterval) { clearInterval(burstInterval); burstInterval = null; }
burstActive = false;
freqEl.classList.remove('burst');
}
}
// ─── transport ────────────────────────────────────────────────────────────
// @strudel/repl defines strudel-editor and stores StrudelMirror as editor.editor.
// Initialization is async — poll until it's ready.
let strudelMirror = null;
const pollEditor = setInterval(() => {
if (editor.editor?.evaluate) {
strudelMirror = editor.editor;
clearInterval(pollEditor);
}
}, 100);
playBtn.addEventListener('mousedown', e => e.preventDefault());
stopBtn.addEventListener('mousedown', e => e.preventDefault());
playBtn.addEventListener('click', () => {
if (!strudelMirror) { statusText.textContent = 'loading…'; return; }
statusText.textContent = 'loading…';
strudelMirror.evaluate();
});
stopBtn.addEventListener('click', () => {
strudelMirror?.stop();
});
// @strudel/repl fires 'update' events, not strudel:play/stop
editor.addEventListener('update', (e) => {
if (e.detail?.started) setPlaying(true);
else if (e.detail?.started === false) setPlaying(false);
if (e.detail?.error) {
statusText.textContent = 'error — check console';
statusRow.classList.remove('playing');
playBtn.classList.remove('active');
}
});
// ─── frequency scanner ────────────────────────────────────────────────────
const freqEl = document.getElementById('freq');
let freqVal = 8502.3;
let freqDir = 1;
setInterval(() => {
if (burstActive) {
freqVal += freqDir * 0.8 * (0.7 + Math.random() * 1.2);
if (Math.random() < 0.3) freqVal += (Math.random() - 0.5) * 200;
} else {
freqVal += freqDir * 0.1 * (0.7 + Math.random() * 0.6);
if (Math.random() < 0.04) freqVal += (Math.random() - 0.5) * 90;
}
if (freqVal > 9800) freqDir = -1;
if (freqVal < 7200) freqDir = 1;
freqEl.textContent = freqVal.toFixed(1) + ' kHz';
}, 180);
// ─── phase disc (session timer) ─────────────────────────────────────────
const phaseDisc = document.getElementById('phaseDisc');
const pdArc = document.getElementById('pdArc');
const pdSweep = document.getElementById('pdSweep');
const pdTime = document.getElementById('pdTime');
const pdTicks = document.getElementById('pdTicks');
const pdDurLabel = document.getElementById('pdDuration');
const PD_R = 36;
const PD_C = 2 * Math.PI * PD_R; // ~226.19
const PD_PRE = [180, 300, 600, 900, Infinity]; // 3m 5m 10m 15m ∞
let pdIdx = 1; // default 5:00
let pdDur = PD_PRE[pdIdx];
let pdT0 = null; // performance.now() at play
let pdRaf = null;
pdArc.setAttribute('stroke-dasharray', PD_C);
pdArc.setAttribute('stroke-dashoffset', 0);
function pdFmt(sec) {
if (!isFinite(sec)) return '\u221E'; // ∞
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
}
function pdDrawTicks() {
pdTicks.innerHTML = '';
if (!isFinite(pdDur)) return;
const n = pdDur / 60;
for (let i = 0; i < n; i++) {
const a = (i / n) * 360 - 90;
const r = a * Math.PI / 180;
const tick = document.createElementNS('http://www.w3.org/2000/svg', 'line');
tick.setAttribute('x1', 45 + 40 * Math.cos(r));
tick.setAttribute('y1', 45 + 40 * Math.sin(r));
tick.setAttribute('x2', 45 + 44 * Math.cos(r));
tick.setAttribute('y2', 45 + 44 * Math.sin(r));
tick.setAttribute('class', 'pd-tick');
pdTicks.appendChild(tick);
}
}
function pdFrame() {
if (!pdT0) return;
const elapsed = (performance.now() - pdT0) / 1000;
const remaining = isFinite(pdDur) ? Math.max(0, pdDur - elapsed) : Infinity;
const progress = isFinite(pdDur) ? 1 - remaining / pdDur : 0;
// arc drains from full → empty
pdArc.setAttribute('stroke-dashoffset', PD_C * progress);
// sweep line tracks drain edge
pdSweep.setAttribute('transform',
'rotate(' + (-90 + progress * 360) + ' 45 45)');
// center readout
pdTime.textContent = isFinite(pdDur) ? pdFmt(remaining) : pdFmt(elapsed);
// final 30 s warning
if (isFinite(pdDur) && remaining <= 30 && remaining > 0) {
phaseDisc.classList.add('ending');
} else {
phaseDisc.classList.remove('ending');
}
// auto-stop at zero
if (isFinite(pdDur) && remaining <= 0) {
pdTime.textContent = '00:00';
phaseDisc.classList.remove('ending');
sendKey('.');
setPlaying(false);
statusText.textContent = 'transmission complete';
return; // don't request next frame
}
pdRaf = requestAnimationFrame(pdFrame);
}
function pdStart() {
if (pdRaf) cancelAnimationFrame(pdRaf);
pdArc.setAttribute('stroke-dashoffset', 0);
pdSweep.setAttribute('transform', 'rotate(-90 45 45)');
pdTime.textContent = pdFmt(pdDur);
pdT0 = performance.now();
phaseDisc.classList.add('active');
phaseDisc.classList.remove('ending');
pdRaf = requestAnimationFrame(pdFrame);
}
function pdStop() {
if (pdRaf) cancelAnimationFrame(pdRaf);
pdRaf = null;
pdT0 = null;
phaseDisc.classList.remove('active', 'ending');
// freeze at current position — don't reset arc/sweep/time
}
// duration selector — click to cycle presets
pdDurLabel.addEventListener('click', () => {
if (pdT0) return; // locked while playing
pdIdx = (pdIdx + 1) % PD_PRE.length;
pdDur = PD_PRE[pdIdx];
pdDurLabel.textContent = pdFmt(pdDur);
pdTime.textContent = pdFmt(pdDur);
// reset arc to full when switching presets
pdArc.setAttribute('stroke-dashoffset', 0);
pdSweep.setAttribute('transform', 'rotate(-90 45 45)');
pdDrawTicks();
});
// initialize
pdDrawTicks();
pdTime.textContent = pdFmt(pdDur);
pdDurLabel.textContent = pdFmt(pdDur);
</script>
</body>
</html>
name strudel-composer
description Create standalone Strudel music compositions as self-contained HTML files. Covers the correct package, DOM patterns, transport, chord notation, and valid sounds — all hard-won from debugging sessions.

Create a Strudel ambient composition as a standalone HTML file the user can open in any browser and share with anyone.

The correct package

Always use @strudel/web@1.0.3. Never use @strudel/repl.

<script src="https://unpkg.com/@strudel/web@1.0.3"></script>

@strudel/repl@1.0.2 has a known AudioWorklet bug (Codeberg issue #1721) — audio fails on first play. @strudel/web@1.0.3 fixes it.

The correct HTML shell

<!-- 1. Create the element in JS, set textContent, then append -->
<div id="editorWrap" style="flex:1;min-height:0;display:flex;flex-direction:column;"></div>

<script>
  const CODE = `...your strudel code here...`;

  const editor = document.createElement('strudel-editor');
  editor.textContent = CODE;          // textContent not innerHTML — avoids entity-encoding <> in mini-notation
  editor.setAttribute('auto-focus', '');
  document.getElementById('editorWrap').appendChild(editor);
</script>

Critical CSS for the editor:

strudel-editor {
  flex: 1;
  display: block;    /* MUST be block — do NOT hide or display:none this element */
  min-height: 0;
}

@strudel/web renders CodeMirror INSIDE <strudel-editor>. @strudel/repl renders it as a sibling. If you hide strudel-editor the editor goes blank. Always keep it visible and sized.

Transport buttons

@strudel/web does NOT expose .editor (StrudelMirror) on the element. Do NOT use window.strudel.evaluate() — that's a different pipeline that lacks the eval scope (samples, setcps, chord, stack are all undefined in it).

Correct approach: simulate keyboard shortcuts

const isMac = /Mac|iPhone|iPad/.test(navigator.platform);

function sendKey(key) {
  const target = document.querySelector('.cm-content');
  if (!target) return;
  target.dispatchEvent(new KeyboardEvent('keydown', {
    key, ctrlKey: !isMac, metaKey: isMac, bubbles: true, cancelable: true,
  }));
}

playBtn.addEventListener('click', () => sendKey('Enter'));  // Ctrl/Cmd+Enter = evaluate
stopBtn.addEventListener('click', () => sendKey('.'));       // Ctrl/Cmd+. = stop

// Note: @strudel/web does NOT expose .editor (StrudelMirror) — sendKey is the only working approach.
// @strudel/repl DOES expose .editor but has the AudioWorklet bug. Do not use repl.

Listen for state changes via events:

editor.addEventListener('strudel:play',  () => { /* update UI */ });
editor.addEventListener('strudel:stop',  () => { /* update UI */ });
editor.addEventListener('strudel:error', () => { /* handle */ });

Theme overrides

Strudel injects <style id="strudel-theme-vars"> with !important CSS vars that override your palette. Kill it:

const nukeTheme = () => {
  const st = document.getElementById('strudel-theme-vars');
  if (st) st.remove();
  const r = document.documentElement.style;
  r.setProperty('--background', '#000000', 'important');
  r.setProperty('--foreground', '#B8A878', 'important');
  // etc.
};
nukeTheme();
new MutationObserver(nukeTheme).observe(document.head, { childList: true }); // catch re-injection

ireal chord notation

The ireal dictionary uses caret notation for major 7ths. Do NOT use maj7.

Wrong Correct
Abmaj7 Ab^7
Ebmaj7 Eb^7
Bbmaj7 Bb^7
Dbmaj7 Db^7
Cmaj7 C^7
let chords = chord("<Cm9 Ab^7 Eb^7 G7>/4").dict('ireal');

Also invalid: G7alt — ireal dict doesn't recognise it. Use G7.

Valid sounds (Dirt-Samples)

Only these are confirmed in the Dirt-Samples strudel.json manifest:

Category Valid names
Drums bd, hh, ho (open hat — NOT oh), rm (rimshot — NOT rim), cb, cp
Bass bass0, jvbass
Pads juno (NOT supersaw — not in manifest)
Lead/texture arpy, casio, trump, pluck
Oscillators sawtooth, square, triangle (WebAudio, no sample needed)
Atmosphere wind
Mallet glockenspiel

gm_* sounds (General MIDI) are NOT in Dirt-Samples — don't use them.

Composition architecture (multi-layer ambient)

samples('https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/strudel.json');
setcps(.56);  // 67 BPM = setcps(bpm/4/15) or just setcps(bpm/60/4)

let chords = chord("<Cm9 Ab^7 Eb^7 G7>/4").dict('ireal');

stack(
  // SEQUENCE — machine pulse (rhythm from harmony, not drums)
  chords.n("[0 <2 1>*2](<3 5>,8)").anchor("C4").voicing()
    .s("casio")
    .lpf(sine.range(180, 650).slow(16)).lpq(7)
    .delay(.4).delaytime(".75")
    .gain(perlin.range(.35, .65))
    .mask("<1 1 0 1 1 0 1 0>/8")
    .room(.85).size(5),

  // PAD — glacial wash
  chords.n("[0,2,4]").anchor("C3").voicing()
    .s("juno")
    .lpf(sine.range(200, 800).slow(32)).lpq(3)
    .gain(perlin.range(.25, .55))
    .mask("<1 1 1 1 1 1 1 0>/16")
    .room(.9).size(8).attack(.5).release(2),

  // TEXTURE — radio signal
  chords.n("[0 <3 2 <1 4>>*2](<3 5>,8)").anchor("C5").voicing()
    .s("trump")
    .speed(perlin.range(.93, 1.07))
    .lpf(sine.range(400, 1200).slow(24)).lpq(5)
    .mask("<0 0 0 1>/16")
    .delay(.5).delaytime(".75")
    .gain(perlin.range(.25, .6))
    .room(.95).size(8),

  // SUB — drone
  n("<0!7 1>").set(chords).mode("root:c2").voicing()
    .s("jvbass")
    .lpf(sine.range(80, 350).slow(24)).lpq(4)
    .gain(.7).shape(.25)
    .mask("<1!14 0 1>/16")
    .room(.3).size(3),

  // ATMOSPHERE
  s("wind").mask("<0 0 0 1>/16").lpf(300).lpq(2).gain(.06).room(.95).size(10),
)
.late("[0 .01]*4").late("[0 .01]*2")  // humanize timing

Key modulation tools:

  • sine.range(min, max).slow(n) — slow LFO for filter sweeps
  • perlin.range(min, max) — organic gain variation
  • .mask("<0 1 0 0>/8") — rhythmic dropout gating
  • .rarely(ply("2")) — occasional note doubling
  • .chunk(4, fast(2)) — periodically speed up a section
  • .degradeBy(.25) — random note dropout (NOT confirmed working in @strudel/web — use .mask() instead)

Delay time constraint: .delaytime() value must be ≤ 1.0. Values like "1.5" throw: Delay.delayTime.setValueAtTime value 1.5 outside nominal range [0, 1] Use ".75" for long echoes, ".375" for medium, ".125" for tight stutters.

Semicolons in CODE string

strudel-editor strips newlines from textContent, collapsing to one line. All top-level statements need semicolons:

const CODE = `samples('...'); setcps(.56); let chords = chord(...); stack(...)`

Without semicolons: samples(...)setcps(...) → parse error.

What we built

  • strudel-studio.html — full DAW-style UI with preset browser, multiple pattern slots
  • still-motion.html — minimal ambient player, Dm9 progression, 87 BPM, blue void aesthetic
  • cold-signal.html — Berlin school × deep space radio, Cm9, 67 BPM, phase disc session timer, frequency scanner, burst mode. The flagship composition.
  • tap-recorder.html — rhythm input tool (no Strudel dependency), tap BPM → generates stack() code

All files at: /Users/saxenauts/Documents/personal/strudel-experiments/

# cold-signal
Berlin school × deep space radio. Ambient composition built entirely in the browser using Strudel — a live coding music environment.
**Listen:** https://setcps.surge.sh
**Source:** this gist — `cold-signal.html` is the entire thing, one file, open it anywhere.
---
## what it is
Five layered voices running simultaneously:
- `casio` — machine pulse, filtered through a slow sine LFO, delay echo
- `juno` — glacial pad wash, slow attack, long release
- `trump` — high texture, sparse, phase-shifted
- `jvbass` — sub drone, barely moves
- `wind` — atmospheric bleed, barely audible
Chord progression: `Cm9 → Ab^7 → Eb^7 → G7` cycling over 4 bars at 67 BPM (`setcps(.56)`).
Gain on every voice driven by `perlin` noise — nothing is static.
---
## how it was built
Written as a Claude skill (`SKILL.md` in this gist) — a set of hard-won rules for generating Strudel compositions that actually work in a browser. The main discoveries:
- `@strudel/repl@1.0.2` is the only package that defines the `strudel-editor` custom element. `@strudel/web` looks right but the element is inert.
- CodeMirror renders as a sibling div, not inside the element — CSS targeting matters
- Transport via polling `editor.editor?.evaluate` — synthetic keyboard events are blocked by CodeMirror 6
- ireal chord notation uses `^` for major 7ths: `Ab^7` not `Abmaj7`
- `delaytime` must be ≤ 1.0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment