⚠️ Legal Notice: This tool processes audio you provide. You are responsible for ensuring you have the rights to use the source material. The authors make no claims about fair use, copyright, or derivative works regarding your use of this tool with copyrighted material. Strudel Music 🎵 Compose, render, deconstruct, and remix music using code. Takes natural language prompts → writes Strudel patterns → renders offline through real Web Audio synthesis → posts audio or streams to Discord VC (via the OpenClaw gateway — no separate credentials needed). Can also reverse-engineer any audio track into…

| head -1000)\n ;;\n *.tar.gz|*.tgz)\n echo \"Extracting tar.gz...\"\n mkdir -p \"$TMP/extracted\"\n # Tar automatically strips leading ../ but verify anyway\n tar xzf \"$TMP/$FILENAME\" -C \"$TMP/extracted\" --no-same-owner 2>&1 | grep -i \"refused\\|absolute\\|\\.\\./\" && {\n echo \"❌ Archive contains suspicious paths. Aborting.\"\n rm -rf \"$TMP\"\n return 1\n } || true\n ;;\n *.tar)\n echo \"Extracting tar...\"\n mkdir -p \"$TMP/extracted\"\n tar xf \"$TMP/$FILENAME\" -C \"$TMP/extracted\" --no-same-owner 2>&1 | grep -i \"refused\\|absolute\\|\\.\\./\" && {\n echo \"❌ Archive contains suspicious paths. Aborting.\"\n rm -rf \"$TMP\"\n return 1\n } || true\n ;;\n *.wav|*.WAV)\n local NAME=\"${FILENAME%.*}\"\n mkdir -p \"$SAMPLES_DIR/$NAME\"\n cp \"$TMP/$FILENAME\" \"$SAMPLES_DIR/$NAME/\"\n echo \"✅ Added single sample: $NAME (1 file)\"\n rm -rf \"$TMP\"\n return 0\n ;;\n *)\n echo \"❌ Unsupported format: $FILENAME (expected .zip, .tar.gz, .tar, or .wav)\"\n rm -rf \"$TMP\"\n return 1\n ;;\n esac\n\n local FOUND=0\n while IFS= read -r wav; do\n local DIR NAME COUNT\n DIR=$(dirname \"$wav\")\n NAME=$(basename \"$DIR\")\n if [ \"$NAME\" != \"extracted\" ]; then\n mkdir -p \"$SAMPLES_DIR/$NAME\"\n cp \"$DIR\"/*.wav \"$DIR\"/*.WAV \"$SAMPLES_DIR/$NAME/\" 2>/dev/null || true\n COUNT=$(find \"$SAMPLES_DIR/$NAME\" -name \"*.wav\" -o -name \"*.WAV\" | wc -l)\n echo \" ✅ $NAME: $COUNT samples\"\n FOUND=$((FOUND + 1))\n fi\n done \u003c \u003c(find \"$TMP/extracted\" -name \"*.wav\" -o -name \"*.WAV\" | head -500)\n\n if [ \"$FOUND\" -eq 0 ]; then\n local NAME=\"${FILENAME%.*}\"\n NAME=\"${NAME%.tar}\"\n mkdir -p \"$SAMPLES_DIR/$NAME\"\n find \"$TMP/extracted\" \\( -name \"*.wav\" -o -name \"*.WAV\" \\) -exec cp {} \"$SAMPLES_DIR/$NAME/\" \\;\n local COUNT\n COUNT=$(find \"$SAMPLES_DIR/$NAME\" -name \"*.wav\" -o -name \"*.WAV\" | wc -l)\n if [ \"$COUNT\" -gt 0 ]; then\n echo \"✅ Added pack: $NAME ($COUNT samples)\"\n else\n echo \"❌ No WAV files found in download\"\n fi\n fi\n\n rm -rf \"$TMP\"\n}\n\ncase \"${1:-help}\" in\n list)\n echo \"=== Installed Sample Packs ===\"\n total=0\n for d in \"$SAMPLES_DIR\"/*/; do\n [ -d \"$d\" ] || continue\n name=$(basename \"$d\")\n count=$(find \"$d\" -maxdepth 1 -name \"*.wav\" -o -name \"*.WAV\" | wc -l)\n echo \" $name: $count samples\"\n total=$((total + count))\n done\n echo \"\"\n echo \"Total: $total samples in $(ls -d \"$SAMPLES_DIR\"/*/ 2>/dev/null | wc -l) packs\"\n echo \"Location: $SAMPLES_DIR\"\n ;;\n\n download)\n exec bash \"$SCRIPT_DIR/download-samples.sh\"\n ;;\n\n add)\n SOURCE=\"${2:-}\"\n if [ -z \"$SOURCE\" ]; then\n echo \"Usage: $0 add \u003curl-or-path>\"\n echo \"\"\n echo \" URL: Downloads and extracts (ZIP/tar.gz) into samples/\"\n echo \" Path: Copies directory into samples/\"\n exit 1\n fi\n\n if [[ \"$SOURCE\" == http* ]]; then\n _add_from_url \"$SOURCE\"\n elif [ -d \"$SOURCE\" ]; then\n NAME=$(basename \"$SOURCE\")\n echo \"Copying $SOURCE → samples/$NAME/\"\n cp -r \"$SOURCE\" \"$SAMPLES_DIR/$NAME\"\n COUNT=$(find \"$SAMPLES_DIR/$NAME\" -name \"*.wav\" -o -name \"*.WAV\" | wc -l)\n echo \"✅ Added $NAME: $COUNT samples\"\n elif [ -f \"$SOURCE\" ]; then\n NAME=\"${SOURCE%.*}\"\n NAME=$(basename \"$NAME\")\n mkdir -p \"$SAMPLES_DIR/$NAME\"\n cp \"$SOURCE\" \"$SAMPLES_DIR/$NAME/\"\n echo \"✅ Added single sample: $NAME\"\n else\n echo \"❌ Not found: $SOURCE\"\n exit 1\n fi\n ;;\n\n remove)\n NAME=\"${2:-}\"\n if [ -z \"$NAME\" ]; then\n echo \"Usage: $0 remove \u003cpack-name>\"\n exit 1\n fi\n # Path traversal protection — reject names with slashes, dots, or special chars\n if [[ \"$NAME\" == */* ]] || [[ \"$NAME\" == ..* ]] || [[ \"$NAME\" == .* ]]; then\n echo \"❌ Invalid pack name: $NAME (no paths, dots, or slashes allowed)\"\n exit 1\n fi\n SAFE_PATH=\"$SAMPLES_DIR/$NAME\"\n # Verify resolved path is still under SAMPLES_DIR\n RESOLVED=$(cd \"$SAMPLES_DIR\" 2>/dev/null && realpath -m \"$NAME\" 2>/dev/null)\n if [[ \"$RESOLVED\" != \"$SAMPLES_DIR/\"* ]]; then\n echo \"❌ Path traversal detected: $NAME\"\n exit 1\n fi\n if [ -d \"$SAFE_PATH\" ]; then\n COUNT=$(find \"$SAFE_PATH\" -name \"*.wav\" -o -name \"*.WAV\" | wc -l)\n rm -rf \"${SAFE_PATH:?}\"\n echo \"✅ Removed $NAME ($COUNT samples)\"\n else\n echo \"❌ Pack not found: $NAME\"\n exit 1\n fi\n ;;\n\n help|*)\n echo \"strudel-music sample manager\"\n echo \"\"\n echo \"Usage: $0 \u003ccommand> [args]\"\n echo \"\"\n echo \"Commands:\"\n echo \" list Show installed sample packs\"\n echo \" download Download/refresh default dirt-samples (idempotent)\"\n echo \" add \u003curl> Download and extract sample pack from URL (.zip/.tar.gz/.wav)\"\n echo \" add \u003cpath> Copy local directory or file into samples/\"\n echo \" remove \u003cname> Remove a sample pack\"\n echo \"\"\n echo \"Sample packs are directories of WAV files in: $SAMPLES_DIR\"\n echo \"Use them in patterns: s(\\\"\u003cdir-name>\\\").n(0)\"\n ;;\nesac\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":8958,"content_sha256":"c5c002c6eff181fcc0a4c23d44fc35dd6a491b1982f04c5ed25aadef4fc6ff15"},{"filename":"scripts/vc-play.mjs","content":"#!/usr/bin/env node\n/**\n * Play an audio file into a Discord Voice Channel.\n * Usage: node scripts/vc-play.mjs \u003caudio-file> [--channel \u003cid>]\n *\n * Uses the same Discord bot token and VC setup as the openclaw-discord-vc bridge.\n * Joins the configured channel, plays the file, then leaves.\n */\nimport { createReadStream } from 'fs';\nimport { join } from 'path';\nimport dotenv from 'dotenv';\nimport {\n AudioPlayerStatus,\n createAudioPlayer,\n createAudioResource,\n entersState,\n joinVoiceChannel,\n VoiceConnectionStatus,\n} from '@discordjs/voice';\nimport { Client, GatewayIntentBits, ChannelType } from 'discord.js';\n\n// Load env\nconst vcEnvPath = process.env.OPENCLAW_DISCORD_VC_ENV_FILE\n || join(process.env.HOME, '.config/openclaw/openclaw-discord-vc.env');\ndotenv.config({ path: vcEnvPath });\n\n// Also load main openclaw env for bot token\nconst mainEnvPath = process.env.OPENCLAW_ENV_FILE\n || join(process.env.HOME, '.config/openclaw/openclaw.env');\ndotenv.config({ path: mainEnvPath });\n\nconst audioFile = process.argv[2];\nif (!audioFile) {\n console.error('Usage: node scripts/vc-play.mjs \u003caudio-file> [--channel \u003cid>]');\n process.exit(1);\n}\n\nconst channelIdx = process.argv.indexOf('--channel');\nconst channelId = channelIdx >= 0 ? process.argv[channelIdx + 1] : process.env.DISCORD_VC_CHANNEL_ID;\nconst botToken = process.env.DISCORD_BOT_TOKEN;\n\nif (!botToken) {\n console.error('No DISCORD_BOT_TOKEN found in env');\n process.exit(1);\n}\nif (!channelId) {\n console.error('No channel ID. Set DISCORD_VC_CHANNEL_ID or use --channel \u003cid>');\n process.exit(1);\n}\n\nconsole.log(`🎵 Playing ${audioFile} in VC ${channelId}`);\n\nconst client = new Client({\n intents: [\n GatewayIntentBits.Guilds,\n GatewayIntentBits.GuildVoiceStates,\n ],\n});\n\nclient.once('ready', async () => {\n console.log(` Bot ready: ${client.user.tag}`);\n \n try {\n const channel = await client.channels.fetch(channelId);\n if (!channel || channel.type !== ChannelType.GuildVoice) {\n console.error(`Channel ${channelId} is not a voice channel`);\n process.exit(1);\n }\n\n const connection = joinVoiceChannel({\n channelId: channel.id,\n guildId: channel.guild.id,\n adapterCreator: channel.guild.voiceAdapterCreator,\n selfDeaf: true, // Music streamer — output only, no listening\n });\n\n await entersState(connection, VoiceConnectionStatus.Ready, 15_000);\n console.log(' ✅ Joined VC');\n\n const player = createAudioPlayer();\n connection.subscribe(player);\n\n const resource = createAudioResource(createReadStream(audioFile));\n player.play(resource);\n\n console.log(' ▶️ Playing...');\n await entersState(player, AudioPlayerStatus.Playing, 15_000);\n await entersState(player, AudioPlayerStatus.Idle, 300_000); // up to 5 min\n\n console.log(' ✅ Done playing');\n connection.destroy();\n client.destroy();\n process.exit(0);\n } catch (err) {\n console.error('Error:', err.message);\n client.destroy();\n process.exit(1);\n }\n});\n\nclient.login(botToken);\n","content_type":"text/javascript","language":"javascript","size":3030,"content_sha256":"26f0e31c3848aed75f205371953eba3b33d029337da9ae1ce1e12df3be053078"},{"filename":"src/runtime/chunked-render.mjs","content":"#!/usr/bin/env node\n/**\n * Chunked offline renderer: Renders Strudel patterns in small cycle chunks\n * to avoid OOM on long/dense compositions.\n *\n * Usage: node src/runtime/chunked-render.mjs \u003cinput.js> [output.wav] [totalCycles] [chunkSize]\n */\n\nimport { readFileSync, writeFileSync, readdirSync, existsSync, appendFileSync, unlinkSync } from 'fs';\nimport { createRequire } from 'module';\nimport path from 'path';\n\nconst require = createRequire(import.meta.url);\n\n// ── Polyfill Web Audio for Node.js ──\nconst nwa = require('node-web-audio-api');\n\n// Browser stubs (minimal)\nglobalThis.window = {\n addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => true,\n location: { href: '', origin: '', protocol: 'https:' },\n navigator: { userAgent: 'node' },\n requestAnimationFrame: cb => setTimeout(cb, 16), cancelAnimationFrame: clearTimeout,\n innerWidth: 800, innerHeight: 600, getComputedStyle: () => ({}),\n};\nglobalThis.document = {\n createElement: () => ({ getContext: () => null, style: {}, setAttribute: () => {}, appendChild: () => {} }),\n body: { appendChild: () => {}, removeChild: () => {} },\n addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => true,\n createEvent: () => ({ initEvent: () => {} }),\n head: { appendChild: () => {} }, querySelectorAll: () => [], querySelector: () => null,\n};\nglobalThis.addEventListener = () => {};\nglobalThis.removeEventListener = () => {};\n\n// Stub AudioContext for Strudel's import-time checks\nlet _sharedCtx = null;\nglobalThis.AudioContext = class {\n constructor() {\n if (!_sharedCtx) {\n _sharedCtx = new nwa.OfflineAudioContext(2, 44100 * 10, 44100);\n _sharedCtx.resume = async () => {};\n _sharedCtx.close = async () => {};\n }\n return _sharedCtx;\n }\n};\nglobalThis.OfflineAudioContext = nwa.OfflineAudioContext;\nglobalThis.AudioBuffer = nwa.AudioBuffer;\nglobalThis.AudioBufferSourceNode = nwa.AudioBufferSourceNode;\nglobalThis.GainNode = nwa.GainNode;\nglobalThis.OscillatorNode = nwa.OscillatorNode;\nglobalThis.BiquadFilterNode = nwa.BiquadFilterNode;\nglobalThis.StereoPannerNode = nwa.StereoPannerNode;\nglobalThis.DynamicsCompressorNode = nwa.DynamicsCompressorNode;\nglobalThis.ConvolverNode = nwa.ConvolverNode;\nglobalThis.DelayNode = nwa.DelayNode;\nglobalThis.WaveShaperNode = nwa.WaveShaperNode;\nglobalThis.AnalyserNode = nwa.AnalyserNode;\n\n// ── Parse args ──\nconst input = process.argv[2];\nconst output = process.argv[3] || 'output.wav';\nconst totalCycles = parseInt(process.argv[4] || '175');\nconst chunkSize = parseInt(process.argv[5] || '8');\n\nif (!input) {\n console.error('Usage: node src/runtime/chunked-render.mjs \u003cinput.js> [output.wav] [totalCycles] [chunkSize]');\n process.exit(1);\n}\n\nconst sampleRate = 44100;\n\n// ── Load Strudel ──\nconsole.log('Loading Strudel...');\nconst core = await import('@strudel/core');\nconst mini = await import('@strudel/mini');\ntry { await import('@strudel/tonal'); } catch (e) {}\n\nif (core.setStringParser && mini.mini) {\n core.setStringParser(mini.mini);\n}\n\nlet cpmValue = 120 / 4; // default\nfor (const [key, val] of Object.entries(core)) {\n globalThis[key] = val;\n}\nglobalThis.setcpm = (v) => { cpmValue = v; };\nglobalThis.setcps = (v) => { cpmValue = v * 60; };\nglobalThis.samples = () => {};\nglobalThis.hush = () => {};\n\n// Strip viz methods\nconst vizMethods = ['pianoroll', '_pianoroll', 'spiral', '_spiral', 'scope', '_scope', 'draw', '_draw'];\nfunction stripVizMethods(code) {\n for (const method of vizMethods) {\n const pattern = new RegExp(`\\\\.(${method})\\\\s*\\\\(`, 'g');\n let match;\n while ((match = pattern.exec(code)) !== null) {\n const dotStart = match.index;\n const parenStart = code.indexOf('(', dotStart + method.length + 1);\n if (parenStart === -1) continue;\n let depth = 1, i = parenStart + 1, inStr = null;\n while (i \u003c code.length && depth > 0) {\n const ch = code[i];\n if (inStr) { if (ch === '\\\\') { i += 2; continue; } if (ch === inStr) inStr = null; }\n else { if (ch === \"'\" || ch === '\"' || ch === '`') inStr = ch; else if (ch === '(') depth++; else if (ch === ')') depth--; }\n i++;\n }\n if (depth === 0) { code = code.slice(0, dotStart) + code.slice(i); pattern.lastIndex = dotStart; }\n }\n }\n return code;\n}\n\nconsole.log(' ✅ Strudel loaded');\n\n// ── Evaluate pattern ──\nconsole.log('Evaluating pattern...');\nlet patternCode = readFileSync(input, 'utf8').replace(/^\\/\\/ @\\w+.*/gm, '').trim();\npatternCode = stripVizMethods(patternCode);\n\nlet pattern;\ntry {\n const lines = patternCode.split('\\n');\n let lastExprStart = -1;\n let depth = 0;\n for (let i = 0; i \u003c lines.length; i++) {\n const line = lines[i].trim();\n if (!line || line.startsWith('//')) continue;\n if (depth === 0 && /^(stack|note|s|n|seq|cat|sequence|arrange|slowcat|fastcat)\\s*\\(/.test(line)) {\n lastExprStart = i;\n }\n for (const ch of line) { if (ch === '(') depth++; if (ch === ')') depth--; }\n }\n if (lastExprStart >= 0) {\n const setup = lines.slice(0, lastExprStart).join('\\n');\n const expr = lines.slice(lastExprStart).join('\\n');\n const fn = new Function(setup + '\\nreturn ' + expr);\n pattern = fn();\n } else {\n try { pattern = new Function(patternCode)(); } catch { pattern = new Function('return ' + patternCode)(); }\n }\n} catch (e) {\n console.error(' ❌ Pattern eval failed:', e.message);\n process.exit(1);\n}\n\nif (!pattern || typeof pattern.queryArc !== 'function') {\n console.error(' ❌ Pattern did not return a queryable pattern.');\n process.exit(1);\n}\n\nconst actualCps = cpmValue / 60;\nconst totalDuration = totalCycles / actualCps;\nconsole.log(` CPS: ${actualCps.toFixed(3)} (${(cpmValue * 4).toFixed(1)} BPM), Total: ${totalCycles} cycles, Duration: ${totalDuration.toFixed(1)}s`);\n\n// ── Load samples ──\nconst SAMPLES_DIR = path.resolve(\n import.meta.dirname || path.dirname(new URL(import.meta.url).pathname),\n '../../samples'\n);\nconst sampleBufferData = new Map(); // sound → {channels, sampleRate, data[]}\n\nfunction loadWavRaw(filePath) {\n const raw = readFileSync(filePath);\n const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);\n if (raw.toString('ascii', 0, 4) !== 'RIFF') return null;\n const channels = view.getUint16(22, true);\n const sr = view.getUint32(24, true);\n const bitsPerSample = view.getUint16(34, true);\n let dataOffset = 12;\n while (dataOffset \u003c raw.length - 8) {\n const chunkId = raw.toString('ascii', dataOffset, dataOffset + 4);\n const chunkSize = view.getUint32(dataOffset + 4, true);\n if (chunkId === 'data') {\n dataOffset += 8;\n const numSamples = Math.floor(chunkSize / (bitsPerSample / 8) / channels);\n const channelData = [];\n for (let ch = 0; ch \u003c channels; ch++) {\n channelData.push(new Float32Array(numSamples));\n }\n for (let ch = 0; ch \u003c channels; ch++) {\n for (let i = 0; i \u003c numSamples; i++) {\n const byteIndex = dataOffset + (i * channels + ch) * (bitsPerSample / 8);\n if (bitsPerSample === 16) {\n channelData[ch][i] = view.getInt16(byteIndex, true) / 32768;\n } else if (bitsPerSample === 24) {\n const s = (view.getUint8(byteIndex) | (view.getUint8(byteIndex+1) \u003c\u003c 8) | (view.getInt8(byteIndex+2) \u003c\u003c 16));\n channelData[ch][i] = s / 8388608;\n }\n }\n }\n return { channels, sampleRate: sr, data: channelData, length: numSamples };\n }\n dataOffset += 8 + chunkSize;\n }\n return null;\n}\n\n// ── strudel.json root note manifest ──\n// Maps bank name → MIDI root note (authoritative when present)\nconst strudelRootNotes = new Map();\n\n/**\n * Parse a note key from strudel.json (e.g. \"cs1\", \"a1\", \"d3\") into MIDI note number.\n * Returns null for non-note keys like \"0\" or numeric indices.\n */\nfunction parseStrudelNoteKey(key) {\n const m = String(key).match(/^([a-gA-G])(s|#|b)?(\\d+)$/);\n if (!m) return null;\n const map = { c:0, d:2, e:4, f:5, g:7, a:9, b:11 };\n let semi = map[m[1].toLowerCase()] ?? 0;\n if (m[2] === 's' || m[2] === '#') semi++;\n if (m[2] === 'b') semi--;\n const oct = parseInt(m[3]);\n return semi + (oct + 1) * 12;\n}\n\nif (existsSync(SAMPLES_DIR)) {\n console.log('Loading samples...');\n let sampleCount = 0;\n\n // Load strudel.json manifest if present\n const strudelJsonPath = path.join(SAMPLES_DIR, 'strudel.json');\n let strudelManifest = null;\n if (existsSync(strudelJsonPath)) {\n try {\n strudelManifest = JSON.parse(readFileSync(strudelJsonPath, 'utf8'));\n console.log(' 📋 Found strudel.json manifest');\n\n // Extract root notes from the manifest\n for (const [bankName, mapping] of Object.entries(strudelManifest)) {\n if (bankName.startsWith('_')) continue; // skip meta keys\n if (typeof mapping !== 'object' || mapping === null) continue;\n const noteKeys = Object.keys(mapping).map(k => parseStrudelNoteKey(k)).filter(n => n !== null);\n if (noteKeys.length > 0) {\n // For multi-sample banks, use the lowest note as root (closest to fundamental)\n // For single-sample banks, use that note\n const rootMidi = Math.min(...noteKeys);\n strudelRootNotes.set(bankName, rootMidi);\n }\n }\n\n if (strudelRootNotes.size > 0) {\n console.log(` 🎹 Root notes from manifest: ${[...strudelRootNotes.entries()].map(([k, v]) => `${k}→MIDI${v}`).join(', ')}`);\n }\n } catch (e) {\n console.warn(' ⚠️ Failed to parse strudel.json:', e.message);\n }\n }\n\n for (const dir of readdirSync(SAMPLES_DIR)) {\n const dirPath = path.join(SAMPLES_DIR, dir);\n try {\n const files = readdirSync(dirPath).filter(f => f.endsWith('.wav') || f.endsWith('.WAV')).sort();\n for (let i = 0; i \u003c files.length; i++) {\n const buf = loadWavRaw(path.join(dirPath, files[i]));\n if (buf) {\n sampleBufferData.set(`${dir}:${i}`, buf);\n if (i === 0) sampleBufferData.set(dir, buf);\n sampleCount++;\n }\n }\n } catch { /* not a directory */ }\n }\n console.log(` ✅ ${sampleCount} samples loaded`);\n}\n\n// ── Waveform generators ──\nconst waveMap = {\n sine: 'sine', triangle: 'triangle', square: 'square',\n sawtooth: 'sawtooth', saw: 'sawtooth', tri: 'triangle',\n};\n\nfunction noteToFreq(note) {\n if (typeof note === 'number') return note;\n const m = String(note).match(/^([a-gA-G])(#|b|s)?(\\d+)?$/);\n if (!m) return 440;\n const map = { c:0, d:2, e:4, f:5, g:7, a:9, b:11 };\n let semi = map[m[1].toLowerCase()] ?? 0;\n if (m[2] === '#' || m[2] === 's') semi++;\n if (m[2] === 'b') semi--;\n const oct = parseInt(m[3] ?? '4');\n return 440 * Math.pow(2, (semi - 9 + (oct - 4) * 12) / 12);\n}\n\nfunction noteToMidi(note) {\n if (typeof note === 'number') return note;\n const m = String(note).match(/^([a-gA-G])(#|b|s)?(\\d+)?$/);\n if (!m) return 60;\n const map = { c:0, d:2, e:4, f:5, g:7, a:9, b:11 };\n let semi = map[m[1].toLowerCase()] ?? 0;\n if (m[2] === '#' || m[2] === 's') semi++;\n if (m[2] === 'b') semi--;\n const oct = parseInt(m[3] ?? '4');\n return semi + (oct + 1) * 12; // C4 = MIDI 60\n}\n\nfunction noteToSemitones(note) {\n return noteToMidi(note) - 60;\n}\n\n// ── Pitch-shift utilities ──\n\n/**\n * Detect root note from sample bank name.\n * \n * Priority:\n * 1. strudel.json manifest (authoritative if present)\n * 2. Filename heuristic: e.g. \"bass_Cs1\" → MIDI 25 (C#1)\n * 3. Default: MIDI 60 (C3)\n */\nfunction detectRootNote(sampleName) {\n // 1. Check strudel.json manifest first (authoritative)\n if (strudelRootNotes.has(sampleName)) {\n return strudelRootNotes.get(sampleName);\n }\n\n // 2. Try to match a trailing note name like _Cs1, _A1, _Fs2 etc.\n const m = String(sampleName).match(/[_-]([A-Ga-g])(s|#|b)?(\\d+)$/);\n if (m) {\n const map = { c:0, d:2, e:4, f:5, g:7, a:9, b:11 };\n let semi = map[m[1].toLowerCase()] ?? 0;\n if (m[2] === 's' || m[2] === '#') semi++;\n if (m[2] === 'b') semi--;\n const oct = parseInt(m[3]);\n return semi + (oct + 1) * 12;\n }\n\n // 3. Default\n return 60; // C3 / MIDI 60\n}\n\n/**\n * Check if a sample name indicates a percussive sound.\n * Percussive sounds get simple resampling (speed change).\n * Tonal sounds get duration-preserving granular pitch shift.\n */\nconst PERC_PATTERNS = /kick|hat|clap|snare|perc|rim|tom|crash|ride|cymbal|808bd|808hc|808oh|808sd|bd|sd|hh|cp|cb|cr|ht|lt|mt|ghost/i;\nfunction isPercussive(sampleName) {\n return PERC_PATTERNS.test(sampleName);\n}\n\n/**\n * Simple resampling by playback rate ratio (percussive mode).\n * ratio > 1 = higher pitch, shorter duration.\n * ratio \u003c 1 = lower pitch, longer duration.\n * Uses linear interpolation.\n */\nfunction resampleBuffer(float32Buf, ratio) {\n if (Math.abs(ratio - 1.0) \u003c 0.001) return float32Buf;\n const outLen = Math.round(float32Buf.length / ratio);\n if (outLen \u003c= 0) return new Float32Array(0);\n const out = new Float32Array(outLen);\n for (let i = 0; i \u003c outLen; i++) {\n const srcIdx = i * ratio;\n const idx0 = Math.floor(srcIdx);\n const frac = srcIdx - idx0;\n const s0 = idx0 \u003c float32Buf.length ? float32Buf[idx0] : 0;\n const s1 = idx0 + 1 \u003c float32Buf.length ? float32Buf[idx0 + 1] : s0;\n out[i] = s0 * (1 - frac) + s1 * frac;\n }\n return out;\n}\n\n/**\n * Duration-preserving pitch shift (tonal mode).\n *\n * Two-step approach:\n * 1. Resample the buffer by the pitch ratio (changes both pitch AND duration)\n * 2. Time-stretch back to original length using WSOLA (Waveform Similarity\n * Overlap-Add) to restore the original duration\n *\n * @param {Float32Array} input - mono audio buffer\n * @param {number} ratio - pitch ratio (>1 = higher, \u003c1 = lower)\n * @param {number} sr - sample rate\n * @returns {Float32Array} pitch-shifted buffer of same length\n */\nfunction granularPitchShift(input, ratio, sr) {\n if (Math.abs(ratio - 1.0) \u003c 0.001) return input;\n \n // Step 1: Resample by pitch ratio (changes pitch + duration)\n const resampled = resampleBuffer(input, ratio);\n \n // Step 2: Time-stretch back to original length using WSOLA\n const targetLen = input.length;\n return wsolaStretch(resampled, targetLen, sr);\n}\n\n/**\n * WSOLA (Waveform Similarity Overlap-Add) time stretching.\n * Stretches or compresses audio to targetLen without changing pitch.\n *\n * @param {Float32Array} input - audio to stretch\n * @param {number} targetLen - desired output length in samples\n * @param {number} sr - sample rate\n * @returns {Float32Array} time-stretched audio\n */\nfunction wsolaStretch(input, targetLen, sr) {\n if (input.length === 0) return new Float32Array(targetLen);\n if (Math.abs(input.length - targetLen) \u003c 2) {\n const out = new Float32Array(targetLen);\n out.set(input.subarray(0, Math.min(input.length, targetLen)));\n return out;\n }\n \n const stretchRatio = targetLen / input.length;\n \n // Fixed grain size tuned for musical content (80ms works well for ≥100 Hz)\n const grainLen = Math.round(sr * 0.08); // 80ms\n const synthHop = Math.round(grainLen / 4); // 75% overlap\n const analysisHop = Math.max(1, Math.round(synthHop / stretchRatio));\n const tolerance = Math.round(grainLen / 4);\n \n const output = new Float32Array(targetLen);\n const normBuf = new Float32Array(targetLen);\n \n // Hanning window\n const win = new Float32Array(grainLen);\n for (let i = 0; i \u003c grainLen; i++) {\n win[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / (grainLen - 1)));\n }\n \n let readPos = 0;\n \n for (let writePos = 0; writePos \u003c targetLen; writePos += synthHop) {\n // WSOLA: find best overlap offset within tolerance\n let bestOffset = 0;\n if (writePos >= synthHop) {\n let bestCorr = -Infinity;\n const minOff = Math.max(-tolerance, -Math.round(readPos));\n const maxOff = Math.min(tolerance, input.length - Math.round(readPos) - grainLen);\n \n for (let off = minOff; off \u003c= maxOff; off++) {\n let corr = 0;\n const ri = Math.round(readPos) + off;\n // Cross-correlate start of this grain with end of previous grain overlap\n const prevStart = writePos - synthHop;\n const checkLen = Math.min(synthHop, grainLen);\n for (let j = 0; j \u003c checkLen; j++) {\n const inIdx = ri + j;\n const outIdx = prevStart + j;\n if (inIdx >= 0 && inIdx \u003c input.length && outIdx >= 0 && outIdx \u003c targetLen && normBuf[outIdx] > 0.001) {\n corr += input[inIdx] * (output[outIdx] / normBuf[outIdx]);\n }\n }\n if (corr > bestCorr) {\n bestCorr = corr;\n bestOffset = off;\n }\n }\n }\n \n const actualRead = Math.round(readPos + bestOffset);\n \n for (let i = 0; i \u003c grainLen; i++) {\n const wi = writePos + i;\n if (wi >= targetLen) break;\n const idx = actualRead + i;\n const sample = (idx >= 0 && idx \u003c input.length) ? input[idx] : 0;\n const w = win[i];\n output[wi] += sample * w;\n normBuf[wi] += w * w;\n }\n \n readPos += analysisHop;\n }\n \n // Normalize\n for (let i = 0; i \u003c targetLen; i++) {\n if (normBuf[i] > 0.001) {\n output[i] /= normBuf[i];\n }\n }\n \n return output;\n}\n\n/**\n * Pitch-shift a multi-channel sample buffer.\n * Returns a new buffer object with shifted data (and possibly different length for percussive mode).\n *\n * @param {Object} sampleBuf - {channels, sampleRate, data[], length}\n * @param {number} semitones - semitone offset (positive = higher pitch)\n * @param {boolean} percussive - use simple resampling (true) or granular (false)\n * @returns {Object} new sample buffer with shifted data\n */\nfunction pitchShiftBuffer(sampleBuf, semitones, percussive) {\n if (Math.abs(semitones) \u003c 0.01) return sampleBuf;\n \n const ratio = Math.pow(2, semitones / 12);\n const newData = [];\n \n if (percussive) {\n // Percussive: simple resampling, changes duration\n for (let ch = 0; ch \u003c sampleBuf.channels; ch++) {\n newData.push(resampleBuffer(sampleBuf.data[ch], ratio));\n }\n return {\n channels: sampleBuf.channels,\n sampleRate: sampleBuf.sampleRate,\n data: newData,\n length: newData[0].length,\n };\n } else {\n // Tonal: granular pitch shift, preserves duration\n for (let ch = 0; ch \u003c sampleBuf.channels; ch++) {\n newData.push(granularPitchShift(sampleBuf.data[ch], ratio, sampleBuf.sampleRate));\n }\n return {\n channels: sampleBuf.channels,\n sampleRate: sampleBuf.sampleRate,\n data: newData,\n length: newData[0].length,\n };\n }\n}\n\n// ── Software mixer: render haps directly into Float32 buffers ──\n// No Web Audio API nodes! Just raw sample mixing.\n\nfunction renderChunk(startCycle, endCycle, pattern, cps) {\n const chunkStart = startCycle / cps;\n const chunkEnd = endCycle / cps;\n const chunkDur = chunkEnd - chunkStart;\n const numSamples = Math.ceil(chunkDur * sampleRate);\n \n const left = new Float32Array(numSamples);\n const right = new Float32Array(numSamples);\n \n const haps = pattern.queryArc(startCycle, endCycle);\n let scheduled = 0;\n \n for (const hap of haps) {\n const hapStartCycle = hap.part?.begin ?? hap.whole?.begin ?? 0;\n const hapEndCycle = hap.part?.end ?? hap.whole?.end ?? hapStartCycle + 0.25;\n const hapStartSec = hapStartCycle / cps;\n const hapEndSec = hapEndCycle / cps;\n \n // Convert to chunk-relative time\n const relStart = hapStartSec - chunkStart;\n const relEnd = hapEndSec - chunkStart;\n \n if (relStart >= chunkDur || relEnd \u003c= 0) continue;\n \n const v = hap.value;\n if (typeof v !== 'object' || v === null) continue;\n \n const gain = Math.min(v.gain ?? 0.3, 1.0);\n if (gain \u003c= 0.001) continue;\n \n const sound = v.s || '';\n const nVal = v.n !== undefined ? Math.round(Number(v.n)) : 0;\n const pan = v.pan ?? 0.5;\n const panL = Math.cos(pan * Math.PI / 2);\n const panR = Math.sin(pan * Math.PI / 2);\n \n const sampleKey = `${sound}:${nVal}`;\n const sampleBuf = sampleBufferData.get(sampleKey) || sampleBufferData.get(sound);\n \n if (sampleBuf) {\n // ── Pitch-shift logic ──\n // Determine if we need to pitch-shift this sample\n let activeBuf = sampleBuf;\n let playbackRate = 1.0;\n \n if (v.note) {\n const targetMidi = noteToMidi(v.note);\n const rootMidi = detectRootNote(sound);\n const semitoneOffset = targetMidi - rootMidi;\n \n if (Math.abs(semitoneOffset) > 0.01) {\n const perc = isPercussive(sound);\n if (perc) {\n // Percussive mode: playback rate resampling (changes duration)\n playbackRate = Math.pow(2, semitoneOffset / 12);\n } else {\n // Tonal mode: duration-preserving pitch shift via resample + WSOLA\n const cacheKey = `${sound}:${nVal}:ps:${semitoneOffset.toFixed(2)}`;\n let cached = sampleBufferData.get(cacheKey);\n if (!cached) {\n cached = pitchShiftBuffer(sampleBuf, semitoneOffset, false);\n sampleBufferData.set(cacheKey, cached);\n if (!renderChunk._loggedTonal) { renderChunk._loggedTonal = {}; }\n const lk = `${sound}:${v.note}`;\n if (!renderChunk._loggedTonal[lk]) {\n console.log(` 🎵 Pitch-shift: ${sound} note=${v.note} (${semitoneOffset > 0 ? '+' : ''}${semitoneOffset} st)`);\n renderChunk._loggedTonal[lk] = true;\n }\n }\n activeBuf = cached;\n }\n }\n }\n \n if (v.speed) playbackRate *= Math.abs(v.speed);\n \n const sampleDur = activeBuf.length / (activeBuf.sampleRate * playbackRate);\n const clipVal = v.clip !== undefined ? Number(v.clip) : 0;\n const effectiveEndSec = clipVal >= 1\n ? relStart + sampleDur\n : Math.min(relEnd, relStart + sampleDur);\n \n const fadeIn = 0.003; // 3ms\n const fadeOut = 0.01; // 10ms\n \n const startIdx = Math.max(0, Math.floor(relStart * sampleRate));\n const endIdx = Math.min(numSamples, Math.ceil(effectiveEndSec * sampleRate));\n \n for (let i = startIdx; i \u003c endIdx; i++) {\n const t = i / sampleRate;\n const relT = t - relStart;\n if (relT \u003c 0) continue;\n \n // Envelope\n let env = gain;\n if (relT \u003c fadeIn) env *= relT / fadeIn;\n const timeToEnd = effectiveEndSec - relStart - relT;\n if (timeToEnd \u003c fadeOut) env *= Math.max(0, timeToEnd / fadeOut);\n \n // Sample position with playback rate\n const samplePos = relT * activeBuf.sampleRate * playbackRate;\n const sIdx = Math.floor(samplePos);\n if (sIdx >= activeBuf.length) break;\n \n // Linear interpolation\n const frac = samplePos - sIdx;\n const s0 = activeBuf.data[0][sIdx];\n const s1 = sIdx + 1 \u003c activeBuf.length ? activeBuf.data[0][sIdx + 1] : s0;\n const sample = s0 + frac * (s1 - s0);\n \n left[i] += sample * env * panL;\n if (activeBuf.channels > 1) {\n const sr0 = activeBuf.data[1][sIdx];\n const sr1 = sIdx + 1 \u003c activeBuf.length ? activeBuf.data[1][sIdx + 1] : sr0;\n right[i] += (sr0 + frac * (sr1 - sr0)) * env * panR;\n } else {\n right[i] += sample * env * panR;\n }\n }\n scheduled++;\n } else if (waveMap[sound]) {\n // Synth oscillator\n let freq = v.freq || (v.note ? noteToFreq(v.note) : 440);\n const oscType = waveMap[sound];\n const attack = v.attack ?? 0.005;\n const decay = v.decay ?? 0.1;\n const sustain = v.sustain ?? 0.7;\n const release = v.release ?? 0.3;\n \n const startIdx = Math.max(0, Math.floor(relStart * sampleRate));\n const endIdx = Math.min(numSamples, Math.ceil((relEnd + 0.01) * sampleRate));\n \n for (let i = startIdx; i \u003c endIdx; i++) {\n const t = i / sampleRate;\n const relT = t - relStart;\n if (relT \u003c 0) continue;\n \n // ADSR\n let env;\n const hapDur = relEnd - relStart;\n if (relT \u003c attack) env = gain * (relT / attack);\n else if (relT \u003c attack + decay) env = gain * (1 - (1 - sustain) * (relT - attack) / decay);\n else if (relT \u003c hapDur - release) env = gain * sustain;\n else env = gain * sustain * Math.max(0, (hapDur - relT) / release);\n \n // Oscillator\n let sample;\n const phase = relT * freq;\n const frac = phase - Math.floor(phase);\n switch (oscType) {\n case 'sine': sample = Math.sin(2 * Math.PI * phase); break;\n case 'triangle': sample = 4 * Math.abs(frac - 0.5) - 1; break;\n case 'sawtooth': sample = 2 * frac - 1; break;\n case 'square': sample = frac \u003c 0.5 ? 1 : -1; break;\n default: sample = Math.sin(2 * Math.PI * phase);\n }\n \n left[i] += sample * env * panL;\n right[i] += sample * env * panR;\n }\n scheduled++;\n }\n // else: unrecognized sound, skip\n }\n \n return { left, right, scheduled, haps: haps.length };\n}\n\n// ── Main: chunk loop ──\n// Two-pass approach: render into float buffers, find peak, then normalize and write.\nconsole.log(`Rendering ${totalCycles} cycles in chunks of ${chunkSize}...`);\n\nconst allFloatChunks = []; // Store raw float data for normalization pass\nlet totalScheduled = 0;\nlet totalHaps = 0;\nlet globalPeak = 0;\n\nfor (let c = 0; c \u003c totalCycles; c += chunkSize) {\n const end = Math.min(c + chunkSize, totalCycles);\n const { left, right, scheduled, haps } = renderChunk(c, end, pattern, actualCps);\n totalScheduled += scheduled;\n totalHaps += haps;\n \n // Track raw peak levels\n for (let i = 0; i \u003c left.length; i++) {\n const al = Math.abs(left[i]);\n const ar = Math.abs(right[i]);\n if (al > globalPeak) globalPeak = al;\n if (ar > globalPeak) globalPeak = ar;\n }\n \n allFloatChunks.push({ left, right });\n \n if ((c / chunkSize) % 5 === 0 || end >= totalCycles) {\n const pct = Math.round(end / totalCycles * 100);\n const memMB = Math.round(process.memoryUsage().heapUsed / 1024 / 1024);\n console.log(` [${pct}%] Cycles ${c}-${end}: ${scheduled} events scheduled (${memMB}MB heap)`);\n }\n}\n\nconsole.log(` Total: ${totalScheduled}/${totalHaps} haps scheduled`);\nconsole.log(` 🔊 Raw peak: ${globalPeak.toFixed(4)} (${(20*Math.log10(globalPeak)).toFixed(1)} dBFS)`);\n\n// ── Normalize and convert to 16-bit PCM ──\n// Target: -3 dBTP (peak at 0.708) to leave headroom for MP3 encoding\nconst targetPeak = 0.708; // -3 dBTP\nconst normGain = globalPeak > 0 ? targetPeak / globalPeak : 1.0;\nconsole.log(` 📐 Normalizing: gain = ${normGain.toFixed(6)} (${(20*Math.log10(normGain)).toFixed(1)} dB)`);\n\nconst allPcmChunks = [];\nfor (const { left, right } of allFloatChunks) {\n const pcm = Buffer.alloc(left.length * 4);\n for (let i = 0; i \u003c left.length; i++) {\n const l = Math.max(-1, Math.min(1, left[i] * normGain));\n const r = Math.max(-1, Math.min(1, right[i] * normGain));\n pcm.writeInt16LE(Math.round(l * 32767), i * 4);\n pcm.writeInt16LE(Math.round(r * 32767), i * 4 + 2);\n }\n allPcmChunks.push(pcm);\n}\n\n// Free float buffers\nallFloatChunks.length = 0;\n\n// ── Concatenate and write WAV ──\nconst pcm = Buffer.concat(allPcmChunks);\n\nconst wav = makeWav(pcm, sampleRate, 2, 16);\nwriteFileSync(output, wav);\nconst durationSec = pcm.length / 4 / sampleRate;\nconsole.log(`✅ ${output} (${(wav.length / 1024 / 1024).toFixed(1)}MB, ${durationSec.toFixed(1)}s)`);\nprocess.exit(0);\n\nfunction makeWav(pcm, sr, ch, bits) {\n const h = Buffer.alloc(44);\n h.write('RIFF', 0);\n h.writeUInt32LE(36 + pcm.length, 4);\n h.write('WAVE', 8);\n h.write('fmt ', 12);\n h.writeUInt32LE(16, 16);\n h.writeUInt16LE(1, 20);\n h.writeUInt16LE(ch, 22);\n h.writeUInt32LE(sr, 24);\n h.writeUInt32LE(sr * ch * bits / 8, 28);\n h.writeUInt16LE(ch * bits / 8, 32);\n h.writeUInt16LE(bits, 34);\n h.write('data', 36);\n h.writeUInt32LE(pcm.length, 40);\n return Buffer.concat([h, pcm]);\n}\n","content_type":"text/javascript","language":"javascript","size":27961,"content_sha256":"738b4e827db1c8061690cd4c733bf2e91ec789ddea9ed5d1b618d08f16dffb52"},{"filename":"src/runtime/offline-render-v2.mjs","content":"#!/usr/bin/env node\n/**\n * Offline render v2: Strudel's real audio engine + node-web-audio-api\n * \n * Evaluates pattern → queries haps → schedules via OfflineAudioContext\n * with proper oscillators, ADSR, filters, panning.\n *\n * Usage: node src/runtime/offline-render-v2.mjs \u003cinput.js> [output.wav] [cycles] [bpm]\n */\n\nimport { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';\nimport { createRequire } from 'module';\nimport path from 'path';\n\nconst require = createRequire(import.meta.url);\n\n// ── Polyfill Web Audio for Node.js ──\nconst nwa = require('node-web-audio-api');\nlet _sharedCtx = null;\nglobalThis.AudioContext = class {\n constructor() {\n if (!_sharedCtx) {\n _sharedCtx = new nwa.OfflineAudioContext(2, 44100 * 600, 44100);\n _sharedCtx.resume = async () => {};\n _sharedCtx.close = async () => {};\n }\n return _sharedCtx;\n }\n};\nglobalThis.OfflineAudioContext = nwa.OfflineAudioContext;\nglobalThis.AudioBuffer = nwa.AudioBuffer;\nglobalThis.AudioBufferSourceNode = nwa.AudioBufferSourceNode;\nglobalThis.GainNode = nwa.GainNode;\nglobalThis.OscillatorNode = nwa.OscillatorNode;\nglobalThis.BiquadFilterNode = nwa.BiquadFilterNode;\nglobalThis.StereoPannerNode = nwa.StereoPannerNode;\nglobalThis.DynamicsCompressorNode = nwa.DynamicsCompressorNode;\nglobalThis.ConvolverNode = nwa.ConvolverNode;\nglobalThis.DelayNode = nwa.DelayNode;\nglobalThis.WaveShaperNode = nwa.WaveShaperNode;\nglobalThis.AnalyserNode = nwa.AnalyserNode;\n\n// Browser stubs\nglobalThis.window = {\n ...globalThis,\n addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => true,\n location: { href: '', origin: '', protocol: 'https:' },\n navigator: { userAgent: 'node' },\n requestAnimationFrame: cb => setTimeout(cb, 16), cancelAnimationFrame: clearTimeout,\n innerWidth: 800, innerHeight: 600, getComputedStyle: () => ({}),\n};\nglobalThis.document = {\n createElement: () => ({ getContext: () => null, style: {}, setAttribute: () => {}, appendChild: () => {} }),\n body: { appendChild: () => {}, removeChild: () => {} },\n addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => true,\n createEvent: () => ({ initEvent: () => {} }),\n head: { appendChild: () => {} }, querySelectorAll: () => [], querySelector: () => null,\n};\nglobalThis.addEventListener = () => {};\nglobalThis.removeEventListener = () => {};\n\n// ── Parse args ──\nconst input = process.argv[2];\nconst output = process.argv[3] || 'output.wav';\nconst cycles = parseInt(process.argv[4] || '8');\nconst bpm = parseInt(process.argv[5] || '120');\n\nif (!input) {\n console.error('Usage: node src/runtime/offline-render-v2.mjs \u003cinput.js> [output.wav] [cycles] [bpm]');\n process.exit(1);\n}\n\nconst cps = bpm / 60 / 4;\nconst duration = cycles / cps;\nconst sampleRate = 44100;\nconst totalSamples = Math.ceil(duration * sampleRate);\n\nconsole.log(`Offline render: ${input} → ${output}`);\nconsole.log(` Cycles: ${cycles}, BPM: ${bpm}, CPS: ${cps.toFixed(3)}, Duration: ${duration.toFixed(1)}s`);\n\n// ── Load Strudel ──\nconsole.log('Loading Strudel...');\nconst core = await import('@strudel/core');\nconst mini = await import('@strudel/mini');\ntry { await import('@strudel/tonal'); } catch (e) { /* optional */ }\n\n// CRITICAL: Manually register mini notation parser on the Pattern class.\n// The dist bundles can have separate module instances, so mini's auto-registration\n// may target a different Pattern. Same class of bug as openclaw/openclaw#22790.\nif (core.setStringParser && mini.mini) {\n core.setStringParser(mini.mini);\n}\n\n// Register ALL Strudel exports (functions, signals, constants) on globalThis\nlet cpmValue = bpm / 4;\nfor (const [key, val] of Object.entries(core)) {\n globalThis[key] = val;\n}\nglobalThis.setcpm = (v) => { cpmValue = v; };\nglobalThis.setcps = (v) => { cpmValue = v * 60; };\nglobalThis.samples = () => {};\nglobalThis.hush = () => {};\n\n// Browser-only methods to strip from patterns before headless evaluation\nconst vizMethods = ['pianoroll', '_pianoroll', 'spiral', '_spiral', 'scope', '_scope', 'draw', '_draw'];\n\n/**\n * Strip browser-only visualization methods using balanced-parenthesis scanning.\n * Handles nested parens, multi-line args, and string literals correctly.\n * e.g. `.pianoroll({ fold: 1, labels: true })` → removed\n *\n * Approach: find `.methodName(` then count balanced parens to find the close.\n * This is more reliable than regex for nested/multi-line args (fixes #4).\n */\nfunction stripVizMethods(code) {\n for (const method of vizMethods) {\n // Match .method( or ._method( — we need to find each occurrence and remove it\n const pattern = new RegExp(`\\\\.(${method})\\\\s*\\\\(`, 'g');\n let match;\n while ((match = pattern.exec(code)) !== null) {\n const dotStart = match.index; // position of the '.'\n const parenStart = code.indexOf('(', dotStart + method.length + 1);\n if (parenStart === -1) continue;\n\n // Scan for balanced close paren, respecting strings\n let depth = 1;\n let i = parenStart + 1;\n let inStr = null; // null, \"'\", '\"', '`'\n while (i \u003c code.length && depth > 0) {\n const ch = code[i];\n if (inStr) {\n if (ch === '\\\\') { i += 2; continue; } // skip escaped chars\n if (ch === inStr) inStr = null;\n } else {\n if (ch === \"'\" || ch === '\"' || ch === '`') inStr = ch;\n else if (ch === '(') depth++;\n else if (ch === ')') depth--;\n }\n i++;\n }\n if (depth === 0) {\n // Remove from dot to closing paren (inclusive)\n code = code.slice(0, dotStart) + code.slice(i);\n // Reset regex since string changed\n pattern.lastIndex = dotStart;\n }\n }\n }\n return code;\n}\n\nconsole.log(' ✅ Strudel loaded');\n\n// ── Evaluate pattern ──\nconsole.log('Evaluating pattern...');\nlet patternCode = readFileSync(input, 'utf8')\n .replace(/^\\/\\/ @\\w+.*/gm, '')\n .trim();\n\n// Strip visualization methods using balanced-paren scanner (fixes #4)\npatternCode = stripVizMethods(patternCode);\n\n// ── Security hardening: scrub sensitive globals before pattern eval ──\n// Patterns are JS evaluated via new Function() in the current process.\n// Remove access to environment variables and child_process to limit\n// damage from malicious patterns. This is NOT a sandbox — patterns can\n// still access fs, network, etc. For untrusted patterns, use a container.\nconst _savedEnv = process.env;\nconst _savedExec = process.execPath;\nprocess.env = Object.freeze({ NODE_ENV: 'production' });\n// Prevent require('child_process') by poisoning the module cache\nconst _savedCpModule = await import('module').then(m => {\n const orig = m.default._resolveFilename;\n m.default._resolveFilename = function(request, ...args) {\n if (request === 'child_process' || request === 'node:child_process') {\n throw new Error('child_process is blocked during pattern evaluation');\n }\n return orig.call(this, request, ...args);\n };\n return orig;\n}).catch(() => null);\n\nlet pattern;\ntry {\n // Strudel patterns are typically: setcpm(...); stack(...).stuff()\n // The last expression is the pattern. We need to capture it.\n // Strategy: split into statements, wrap the last one in return.\n const lines = patternCode.split('\\n');\n \n // Find the last non-empty, non-comment line that starts a pattern expression\n // Usually starts with stack(, note(, s(, n(, etc.\n let lastExprStart = -1;\n let depth = 0;\n for (let i = 0; i \u003c lines.length; i++) {\n const line = lines[i].trim();\n if (!line || line.startsWith('//')) continue;\n \n // Track if this line starts a new top-level expression\n if (depth === 0 && /^(stack|note|s|n|seq|cat|sequence|arrange|slowcat|fastcat)\\s*\\(/.test(line)) {\n lastExprStart = i;\n }\n // Track paren depth\n for (const ch of line) {\n if (ch === '(') depth++;\n if (ch === ')') depth--;\n }\n }\n \n if (lastExprStart >= 0) {\n const setup = lines.slice(0, lastExprStart).join('\\n');\n const expr = lines.slice(lastExprStart).join('\\n');\n const wrapped = setup + '\\nreturn ' + expr;\n const fn = new Function(wrapped);\n pattern = fn();\n } else {\n // Try as-is, then with return\n try {\n const fn = new Function(patternCode);\n pattern = fn();\n } catch {\n const fn = new Function('return ' + patternCode);\n pattern = fn();\n }\n }\n} catch (e) {\n console.error(' ❌ Pattern eval failed:', e.message);\n process.env = _savedEnv; // Restore before exit\n process.exit(1);\n} finally {\n // Restore environment after pattern evaluation\n process.env = _savedEnv;\n // Restore module resolution\n if (_savedCpModule) {\n import('module').then(m => { m.default._resolveFilename = _savedCpModule; }).catch(() => {});\n }\n}\n\nif (!pattern || typeof pattern.queryArc !== 'function') {\n console.error(' ❌ Pattern did not return a queryable pattern. Got:', typeof pattern);\n process.exit(1);\n}\n\n// ── Query haps ──\nconst actualCps = cpmValue / 60;\nconst actualDuration = cycles / actualCps;\nconst actualSamples = Math.ceil(actualDuration * sampleRate);\n\nconsole.log(` Using CPS: ${actualCps.toFixed(3)} (${cpmValue * 4} BPM), Duration: ${actualDuration.toFixed(1)}s`);\n\nconst haps = pattern.queryArc(0, cycles);\nconsole.log(` Found ${haps.length} haps`);\n\nif (haps.length === 0) {\n console.error(' ⚠️ No haps. Output will be silence.');\n}\n\n// ── Load samples ──\nconst SAMPLES_DIR = path.resolve(\n import.meta.dirname || path.dirname(new URL(import.meta.url).pathname),\n '../../samples'\n);\nconst sampleBuffers = new Map(); // \"bd:0\" → AudioBuffer\n\nfunction loadWavToBuffer(filePath, ctx) {\n const raw = readFileSync(filePath);\n // Parse WAV header\n const view = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);\n if (raw.toString('ascii', 0, 4) !== 'RIFF') return null;\n \n const channels = view.getUint16(22, true);\n const sr = view.getUint32(24, true);\n const bitsPerSample = view.getUint16(34, true);\n \n // Find data chunk\n let dataOffset = 12;\n while (dataOffset \u003c raw.length - 8) {\n const chunkId = raw.toString('ascii', dataOffset, dataOffset + 4);\n const chunkSize = view.getUint32(dataOffset + 4, true);\n if (chunkId === 'data') {\n dataOffset += 8;\n const numSamples = chunkSize / (bitsPerSample / 8) / channels;\n const audioBuffer = ctx.createBuffer(channels, numSamples, sr);\n \n for (let ch = 0; ch \u003c channels; ch++) {\n const channelData = audioBuffer.getChannelData(ch);\n for (let i = 0; i \u003c numSamples; i++) {\n const byteIndex = dataOffset + (i * channels + ch) * (bitsPerSample / 8);\n if (bitsPerSample === 16) {\n channelData[i] = view.getInt16(byteIndex, true) / 32768;\n } else if (bitsPerSample === 24) {\n const s = (view.getUint8(byteIndex) | (view.getUint8(byteIndex+1) \u003c\u003c 8) | (view.getInt8(byteIndex+2) \u003c\u003c 16));\n channelData[i] = s / 8388608;\n }\n }\n }\n return audioBuffer;\n }\n dataOffset += 8 + chunkSize;\n }\n return null;\n}\n\nif (existsSync(SAMPLES_DIR)) {\n console.log('Loading samples...');\n let sampleCount = 0;\n // Preload a temporary OfflineAudioContext for buffer creation\n const tmpCtx = new nwa.OfflineAudioContext(2, 1, sampleRate);\n \n for (const dir of readdirSync(SAMPLES_DIR)) {\n const dirPath = path.join(SAMPLES_DIR, dir);\n try {\n const files = readdirSync(dirPath).filter(f => f.endsWith('.wav') || f.endsWith('.WAV')).sort();\n for (let i = 0; i \u003c files.length; i++) {\n const buf = loadWavToBuffer(path.join(dirPath, files[i]), tmpCtx);\n if (buf) {\n sampleBuffers.set(`${dir}:${i}`, buf);\n if (i === 0) sampleBuffers.set(dir, buf); // default (index 0)\n sampleCount++;\n }\n }\n } catch { /* not a directory */ }\n }\n console.log(` ✅ ${sampleCount} samples loaded from ${sampleBuffers.size} entries`);\n} else {\n console.log(' ⚠️ No samples directory found. Sample-based sounds will be silent.');\n}\n\n// ── Render to OfflineAudioContext ──\nconsole.log('Rendering...');\nconst offCtx = new nwa.OfflineAudioContext(2, actualSamples, sampleRate);\n\n// Master compressor for clean output\n// Gentler settings to avoid pumping artifacts on vocal material (#22)\nconst compressor = offCtx.createDynamicsCompressor();\ncompressor.threshold.setValueAtTime(-12, 0);\ncompressor.knee.setValueAtTime(10, 0);\ncompressor.ratio.setValueAtTime(4, 0);\ncompressor.connect(offCtx.destination);\n\n// Oscillator type map (outside loop for performance)\nconst waveMap = {\n sine: 'sine', triangle: 'triangle', square: 'square',\n sawtooth: 'sawtooth', saw: 'sawtooth', tri: 'triangle',\n piano: 'triangle', bass: 'sawtooth', pluck: 'triangle',\n supersaw: 'sawtooth', supersquare: 'square', organ: 'sine',\n};\n\nconst warnedSounds = new Set(); // track warned sound names to avoid spam\nlet scheduled = 0;\nfor (const hap of haps) {\n // Skip continuation fragments — only schedule onset haps.\n // Strudel's queryArc splits long events at integer cycle boundaries,\n // producing multiple haps with the same whole arc but different part arcs.\n // hasOnset() is true only for the first fragment (part.begin === whole.begin).\n // Without this filter, samples get stacked N times at the same start time (#22 v7).\n if (typeof hap.hasOnset === 'function' && !hap.hasOnset()) continue;\n\n const startCycle = hap.whole?.begin ?? hap.part?.begin ?? 0;\n const endCycle = hap.whole?.end ?? hap.part?.end ?? startCycle + 0.25;\n const hapStart = startCycle / actualCps;\n const hapDur = (endCycle - startCycle) / actualCps;\n\n if (hapStart >= actualDuration || hapStart \u003c 0) continue;\n\n const v = hap.value;\n if (typeof v !== 'object' || v === null) continue;\n\n const gain = Math.min(v.gain ?? 0.3, 1.0);\n if (gain \u003c= 0.001) continue; // Skip silent haps (saves memory on masked layers)\n const sound = v.s || '';\n const nVal = v.n !== undefined ? Math.round(Number(v.n)) : 0;\n const lpf = v.lpf ?? v.cutoff ?? 6000;\n const attack = v.attack ?? 0.005;\n const decay = v.decay ?? 0.1;\n const sustain = v.sustain ?? 0.7;\n const release = v.release ?? 0.3;\n const pan = v.pan ?? 0.5;\n\n // Check if this is a sample-based sound\n const sampleKey = `${sound}:${nVal}`;\n const sampleBuf = sampleBuffers.get(sampleKey) || sampleBuffers.get(sound);\n \n const isSynthSound = waveMap[sound] !== undefined;\n\n // Resolve note → frequency (for synth sounds)\n let freq = null;\n if (v.freq) freq = v.freq;\n else if (v.note) freq = noteToFreq(v.note);\n // TODO: resolve scale degree to freq using tonal's Scale.get() + degree mapping\n // Currently falls through to 440Hz for unresolved scale degrees\n else if (v.n !== undefined && isSynthSound) freq = 440;\n\n // Skip if neither sample nor synth — warn on unrecognized sounds\n if (!sampleBuf && !isSynthSound && sound) {\n if (!warnedSounds.has(sound)) {\n console.warn(` ⚠️ Unrecognized sound \"${sound}\" — falling back to 440Hz synth`);\n warnedSounds.add(sound);\n }\n if (!freq) freq = 440;\n } else if (!sampleBuf && !freq && !isSynthSound) {\n if (!freq) freq = 440; // last resort fallback\n }\n\n try {\n const endTime = hapStart + hapDur;\n \n // Gain node\n const gn = offCtx.createGain();\n \n // Filter\n const flt = offCtx.createBiquadFilter();\n flt.type = 'lowpass';\n flt.frequency.setValueAtTime(Math.min(lpf, sampleRate / 2 - 100), hapStart);\n flt.Q.setValueAtTime(1.5, hapStart);\n \n // Panner\n const pnr = offCtx.createStereoPanner();\n pnr.pan.setValueAtTime((pan - 0.5) * 2, hapStart);\n\n if (sampleBuf) {\n // ── Sample playback ──\n const src = offCtx.createBufferSource();\n src.buffer = sampleBuf;\n \n // When clip=1, let the sample play its full natural duration\n // instead of cutting at the cycle/hap boundary (#22, dev#1)\n const clipVal = v.clip !== undefined ? Number(v.clip) : 0;\n \n // loopAt support: if loopAt is set OR the hap window exceeds the sample\n // length, enable looping so the sample fills the entire hap duration.\n // This is the offline-renderer counterpart to Ronan's synth.mjs loopfix.\n const loopAtVal = v.loopAt;\n const shouldLoop = loopAtVal != null || hapDur > sampleBuf.duration;\n \n if (shouldLoop) {\n src.loop = true;\n src.loopStart = 0;\n src.loopEnd = sampleBuf.duration;\n }\n \n const effectiveEnd = shouldLoop\n ? endTime // fill the entire hap window when looping\n : clipVal >= 1\n ? hapStart + sampleBuf.duration\n : Math.min(endTime, hapStart + sampleBuf.duration);\n \n // Crossfade envelope: 30ms fade-in, 50ms fade-out (#22)\n // Replaces instant-on + 20ms fade-out which caused hard splice clicks\n const fadeIn = 0.03; // 30ms\n const fadeOut = 0.05; // 50ms\n const sampleEnd = effectiveEnd;\n gn.gain.setValueAtTime(0, hapStart);\n gn.gain.linearRampToValueAtTime(gain, hapStart + fadeIn);\n gn.gain.setValueAtTime(gain, Math.max(hapStart + fadeIn, sampleEnd - fadeOut));\n gn.gain.linearRampToValueAtTime(0, sampleEnd);\n \n // Apply playback rate if note specified (pitch shifting)\n if (v.note) {\n const semitones = noteToSemitones(v.note);\n if (semitones !== 0) src.playbackRate.setValueAtTime(Math.pow(2, semitones / 12), hapStart);\n }\n if (v.speed) src.playbackRate.setValueAtTime(Math.abs(v.speed), hapStart);\n \n src.connect(flt);\n flt.connect(gn);\n gn.connect(pnr);\n pnr.connect(compressor);\n \n src.start(hapStart);\n src.stop(sampleEnd + 0.05);\n } else {\n // ── Oscillator synth ──\n if (!freq) freq = 440;\n const oscType = waveMap[sound] || 'triangle';\n \n const osc = offCtx.createOscillator();\n osc.type = oscType;\n osc.frequency.setValueAtTime(freq, hapStart);\n\n // Slight detune for richness on saw/square\n if (oscType === 'sawtooth' || oscType === 'square') {\n osc.detune.setValueAtTime(Math.random() * 10 - 5, hapStart);\n }\n\n // ADSR envelope\n gn.gain.setValueAtTime(0, hapStart);\n gn.gain.linearRampToValueAtTime(gain, Math.min(hapStart + attack, endTime));\n gn.gain.linearRampToValueAtTime(gain * sustain, Math.min(hapStart + attack + decay, endTime));\n if (endTime - release > hapStart + attack + decay) {\n gn.gain.setValueAtTime(gain * sustain, endTime - release);\n }\n gn.gain.linearRampToValueAtTime(0, endTime + 0.01);\n\n osc.connect(flt);\n flt.connect(gn);\n gn.connect(pnr);\n pnr.connect(compressor);\n\n osc.start(hapStart);\n osc.stop(endTime + 0.05);\n }\n \n scheduled++;\n } catch (e) {\n // Skip problematic haps\n }\n}\n\nconsole.log(` Scheduled ${scheduled}/${haps.length} haps`);\n\nif (scheduled === 0) {\n console.error(' ❌ Nothing to render.');\n process.exit(1);\n}\n\nconst buf = await offCtx.startRendering();\nconsole.log(` ✅ Rendered: ${buf.length} samples (${(buf.length / sampleRate).toFixed(1)}s)`);\n\n// ── Master fade-out ──\n// Apply 2-second linear fade-out to the end of the rendered buffer.\n// Prevents the hard cliff exit heard in v7 (#22).\nconst fadeOutSeconds = 2;\nconst fadeOutSamples = Math.min(Math.ceil(fadeOutSeconds * sampleRate), buf.length);\nconst fadeOutStart = buf.length - fadeOutSamples;\nfor (let ch = 0; ch \u003c buf.numberOfChannels; ch++) {\n const channelData = buf.getChannelData(ch);\n for (let i = 0; i \u003c fadeOutSamples; i++) {\n const gain = 1 - (i / fadeOutSamples); // linear ramp from 1 → 0\n channelData[fadeOutStart + i] *= gain;\n }\n}\nconsole.log(` ✅ Applied ${fadeOutSeconds}s master fade-out (${fadeOutSamples} samples)`);\n\n// ── Write WAV ──\nconst left = buf.getChannelData(0);\nconst right = buf.numberOfChannels > 1 ? buf.getChannelData(1) : left;\n\nconst pcm = Buffer.alloc(buf.length * 4);\nfor (let i = 0; i \u003c buf.length; i++) {\n pcm.writeInt16LE(Math.round(Math.max(-1, Math.min(1, left[i])) * 32767), i * 4);\n pcm.writeInt16LE(Math.round(Math.max(-1, Math.min(1, right[i])) * 32767), i * 4 + 2);\n}\n\nconst wav = makeWav(pcm, sampleRate, 2, 16);\nwriteFileSync(output, wav);\nconsole.log(`✅ ${output} (${(wav.length / 1024 / 1024).toFixed(1)}MB)`);\nprocess.exit(0);\n\n// ── Helpers ──\nfunction noteToSemitones(note) {\n // Returns semitone offset from C4 (for sample pitch shifting)\n if (typeof note === 'number') return note - 60; // MIDI\n const m = String(note).match(/^([a-gA-G])(#|b|s)?(\\d+)?$/);\n if (!m) return 0;\n const map = { c:0, d:2, e:4, f:5, g:7, a:9, b:11 };\n let semi = map[m[1].toLowerCase()] ?? 0;\n if (m[2] === '#' || m[2] === 's') semi++;\n if (m[2] === 'b') semi--;\n const oct = parseInt(m[3] ?? '4');\n return semi + (oct * 12) - 60; // offset from C4\n}\n\nfunction noteToFreq(note) {\n if (typeof note === 'number') return note;\n const m = String(note).match(/^([a-gA-G])(#|b|s)?(\\d+)?$/);\n if (!m) return 440;\n const map = { c:0, d:2, e:4, f:5, g:7, a:9, b:11 };\n let semi = map[m[1].toLowerCase()] ?? 0;\n if (m[2] === '#' || m[2] === 's') semi++;\n if (m[2] === 'b') semi--;\n const oct = parseInt(m[3] ?? '4');\n return 440 * Math.pow(2, (semi - 9 + (oct - 4) * 12) / 12);\n}\n\nfunction makeWav(pcm, sr, ch, bits) {\n const h = Buffer.alloc(44);\n h.write('RIFF', 0);\n h.writeUInt32LE(36 + pcm.length, 4);\n h.write('WAVE', 8);\n h.write('fmt ', 12);\n h.writeUInt32LE(16, 16);\n h.writeUInt16LE(1, 20);\n h.writeUInt16LE(ch, 22);\n h.writeUInt32LE(sr, 24);\n h.writeUInt32LE(sr * ch * bits / 8, 28);\n h.writeUInt16LE(ch * bits / 8, 32);\n h.writeUInt16LE(bits, 34);\n h.write('data', 36);\n h.writeUInt32LE(pcm.length, 40);\n return Buffer.concat([h, pcm]);\n}\n","content_type":"text/javascript","language":"javascript","size":21826,"content_sha256":"8f3859b68dc2f2c779ff99ae384174e5e82be824293c7a408e9b18ab257dac83"},{"filename":"src/runtime/smoke-test.mjs","content":"#!/usr/bin/env node\n/**\n * Smoke test: verify Strudel loads, mini notation parses, samples exist.\n */\nimport { existsSync, readdirSync } from 'fs';\nimport { resolve, dirname } from 'path';\nimport { fileURLToPath } from 'url';\nimport { createRequire } from 'module';\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst root = resolve(__dirname, '../..');\nlet passed = 0, failed = 0;\n\nfunction check(name, ok) {\n if (ok) { console.log(` ✅ ${name}`); passed++; }\n else { console.log(` ❌ ${name}`); failed++; }\n}\n\nconsole.log('Strudel Music — Smoke Test\\n');\n\n// 1. Core imports\nconst core = await import('@strudel/core');\ncheck('@strudel/core loaded', !!core.Pattern);\n\nconst mini = await import('@strudel/mini');\ncheck('@strudel/mini loaded', !!mini.mini);\n\n// 2. String parser registration\ncore.setStringParser(mini.mini);\nconst p = core.note('c3 e3 g3');\nconst haps = p.queryArc(0, 1);\ncheck(`Mini notation parsing (got ${haps.length} haps, expected 3)`, haps.length === 3);\n\n// 3. Tonal\ntry {\n await import('@strudel/tonal');\n check('@strudel/tonal loaded', true);\n} catch {\n check('@strudel/tonal loaded', false);\n}\n\n// 4. node-web-audio-api\nconst require = createRequire(import.meta.url);\ntry {\n const nwa = require('node-web-audio-api');\n check('node-web-audio-api (OfflineAudioContext)', !!nwa.OfflineAudioContext);\n} catch (e) {\n check(`node-web-audio-api: ${e.message}`, false);\n}\n\n// 5. Samples\nconst samplesDir = resolve(root, 'samples');\ncheck('Samples directory exists', existsSync(samplesDir));\nif (existsSync(samplesDir)) {\n const dirs = readdirSync(samplesDir).filter(d => {\n try { return readdirSync(resolve(samplesDir, d)).some(f => f.endsWith('.wav')); }\n catch { return false; }\n });\n check(`Sample sets loaded (${dirs.length} dirs, need ≥5)`, dirs.length >= 5);\n check('bd (bass drum) samples present', dirs.includes('bd'));\n check('sd (snare) samples present', dirs.includes('sd'));\n check('hh (hihat) samples present', dirs.includes('hh'));\n}\n\n// 6. Composition files\nconst compsDir = resolve(root, 'assets/compositions');\nif (existsSync(compsDir)) {\n const comps = readdirSync(compsDir).filter(f => f.endsWith('.js'));\n check(`Compositions found (${comps.length} files, need ≥5)`, comps.length >= 5);\n}\n\n// 7. Discord voice deps (optional)\ntry {\n await import('@discordjs/voice');\n check('@discordjs/voice (VC streaming)', true);\n} catch {\n check('@discordjs/voice (VC streaming, optional)', false);\n}\n\nconsole.log(`\\n${passed} passed, ${failed} failed`);\nprocess.exit(failed > 0 ? 1 : 0);\n","content_type":"text/javascript","language":"javascript","size":2563,"content_sha256":"9c4b8c479b9d2ac7a79190fd188d28c6360b2a2111bdcb0b637e107914cf7a57"},{"filename":"src/runtime/synth.mjs","content":"// synth.mjs — Minimal software synthesizer for Strudel pattern events\n// Renders pattern haps to PCM audio without any browser/WebAudio dependency.\n// Supports oscillator synthesis AND sample playback from WAV files.\n\nimport { readFileSync, readdirSync, existsSync } from \"node:fs\";\nimport path from \"node:path\";\n\nconst TWO_PI = Math.PI * 2;\n\n// ──────────────────────────────────────────────────\n// Sample loading — reads WAV files from samples/ dir\n// ──────────────────────────────────────────────────\n\nconst sampleCache = new Map(); // \"bd:0\" → Float32Array\n\n/**\n * Load all WAV samples from a samples directory.\n * Structure: samplesDir/\u003cname>/\u003cfiles>.wav\n * Each dir becomes a sample bank (bd, sd, hh, etc.)\n */\nexport function loadSamples(samplesDir) {\n if (!existsSync(samplesDir)) return;\n for (const dir of readdirSync(samplesDir, { withFileTypes: true })) {\n if (!dir.isDirectory()) continue;\n const bankPath = path.join(samplesDir, dir.name);\n const files = readdirSync(bankPath)\n .filter(f => /\\.wav$/i.test(f))\n .sort();\n files.forEach((f, idx) => {\n const key = `${dir.name}:${idx}`;\n try {\n sampleCache.set(key, readWavPcm(path.join(bankPath, f)));\n } catch { /* skip unreadable files */ }\n });\n }\n const banks = new Set([...sampleCache.keys()].map(k => k.split(':')[0]));\n console.error(` Samples loaded: ${sampleCache.size} files in ${banks.size} banks`);\n}\n\n/**\n * Read a WAV file and return mono Float32Array of samples.\n * Supports 16-bit and 24-bit PCM.\n */\nfunction readWavPcm(filePath) {\n const buf = readFileSync(filePath);\n // Find 'fmt ' chunk\n let fmtOffset = buf.indexOf('fmt ', 0, 'ascii');\n if (fmtOffset \u003c 0) throw new Error('No fmt chunk');\n fmtOffset += 4; // skip 'fmt '\n const fmtSize = buf.readUInt32LE(fmtOffset); fmtOffset += 4;\n const audioFormat = buf.readUInt16LE(fmtOffset);\n const numChannels = buf.readUInt16LE(fmtOffset + 2);\n const sampleRate = buf.readUInt32LE(fmtOffset + 4);\n const bitsPerSample = buf.readUInt16LE(fmtOffset + 14);\n\n if (audioFormat !== 1) throw new Error(`Unsupported format: ${audioFormat}`);\n\n // Find 'data' chunk\n let dataOffset = buf.indexOf('data', fmtOffset, 'ascii');\n if (dataOffset \u003c 0) throw new Error('No data chunk');\n dataOffset += 4; // skip 'data'\n const dataSize = buf.readUInt32LE(dataOffset); dataOffset += 4;\n\n const bytesPerSample = bitsPerSample / 8;\n const numSamples = Math.floor(dataSize / (bytesPerSample * numChannels));\n const pcm = new Float32Array(numSamples);\n\n for (let i = 0; i \u003c numSamples; i++) {\n const offset = dataOffset + i * numChannels * bytesPerSample;\n // Read first channel only (mono mixdown)\n if (bitsPerSample === 16) {\n pcm[i] = buf.readInt16LE(offset) / 32768;\n } else if (bitsPerSample === 24) {\n const val = buf.readUIntLE(offset, 3);\n pcm[i] = (val > 0x7fffff ? val - 0x1000000 : val) / 8388608;\n } else if (bitsPerSample === 8) {\n pcm[i] = (buf.readUInt8(offset) - 128) / 128;\n }\n }\n return pcm;\n}\n\n/**\n * Get a sample by bank name and index (n).\n * Returns Float32Array or null.\n */\nfunction getSample(bankName, n = 0) {\n const key = `${bankName}:${n}`;\n if (sampleCache.has(key)) return sampleCache.get(key);\n // Try index 0 if requested index doesn't exist\n const fallback = `${bankName}:0`;\n return sampleCache.get(fallback) ?? null;\n}\n\n/** Check if a name is a known oscillator type */\nfunction isOscillator(name) {\n return name in oscillators;\n}\n\n/**\n * Convert a note name or MIDI number to frequency in Hz.\n */\nexport function noteToFreq(note) {\n if (typeof note === 'number') {\n return 440 * Math.pow(2, (note - 69) / 12);\n }\n if (typeof note === 'string') {\n const match = note.match(/^([a-gA-G])([#b]?)(\\d+)$/);\n if (!match) return 440;\n const names = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n let semitone = names[match[1].toLowerCase()] ?? 0;\n if (match[2] === '#') semitone += 1;\n if (match[2] === 'b') semitone -= 1;\n const octave = parseInt(match[3], 10);\n const midi = (octave + 1) * 12 + semitone;\n return 440 * Math.pow(2, (midi - 69) / 12);\n }\n return 440;\n}\n\n/**\n * Oscillator functions: sample → [-1, 1]\n */\nconst oscillators = {\n sine: (phase) => Math.sin(TWO_PI * phase),\n square: (phase) => (phase % 1) \u003c 0.5 ? 1 : -1,\n sawtooth: (phase) => 2 * (phase % 1) - 1,\n triangle: (phase) => {\n const p = phase % 1;\n return p \u003c 0.5 ? 4 * p - 1 : 3 - 4 * p;\n },\n};\n\n/**\n * Simple ADSR envelope.\n */\nfunction envelope(t, duration, attack = 0.01, decay = 0.1, sustain = 0.7, release = 0.05) {\n if (t \u003c 0) return 0;\n if (t \u003c attack) return t / attack;\n if (t \u003c attack + decay) return 1 - (1 - sustain) * ((t - attack) / decay);\n if (t \u003c duration - release) return sustain;\n if (t \u003c duration) return sustain * (1 - (t - (duration - release)) / release);\n return 0;\n}\n\n/**\n * Simple low-pass filter (one-pole IIR).\n */\nfunction lpfCoeff(cutoffHz, sampleRate) {\n const dt = 1 / sampleRate;\n const rc = 1 / (TWO_PI * cutoffHz);\n return dt / (rc + dt);\n}\n\n/**\n * Extract haps (events) from a Strudel pattern for a given time span.\n */\nexport function queryPattern(pattern, startCycle, endCycle) {\n const haps = [];\n try {\n // Strudel Pattern.queryArc returns haps\n const result = pattern.queryArc(startCycle, endCycle);\n if (Array.isArray(result)) {\n return result;\n }\n // Some versions return an iterator\n if (result && typeof result[Symbol.iterator] === 'function') {\n for (const hap of result) {\n haps.push(hap);\n }\n }\n } catch (e) {\n // Fallback: try firstCycle/lastCycle\n try {\n for (let c = Math.floor(startCycle); c \u003c Math.ceil(endCycle); c++) {\n const cycleHaps = pattern.firstCycle?.(c) ?? [];\n haps.push(...cycleHaps);\n }\n } catch {\n // Pattern may not support this query method\n }\n }\n return haps;\n}\n\n/**\n * Render a set of haps to stereo PCM float arrays.\n *\n * @param {Array} haps - Strudel hap objects with .whole, .value\n * @param {number} durationSec - Total duration in seconds\n * @param {number} sampleRate - Sample rate (default 44100)\n * @returns {[Float32Array, Float32Array]} - [left, right] channels\n */\nexport function renderHapsToAudio(haps, durationSec, sampleRate = 44100) {\n const numSamples = Math.ceil(durationSec * sampleRate);\n const left = new Float32Array(numSamples);\n const right = new Float32Array(numSamples);\n\n for (const hap of haps) {\n if (!hap?.whole) continue;\n\n const value = hap.value ?? {};\n const startSec = Number(hap.whole.begin) * durationSec / (Number(hap.whole.end) > 0 ? Number(hap.whole.end) : 1);\n const endSec = Number(hap.whole.end) * durationSec / (Number(hap.whole.end) > 0 ? Number(hap.whole.end) : 1);\n\n const hapStartSec = hap._renderStart ?? startSec;\n const hapEndSec = hap._renderEnd ?? endSec;\n const hapDuration = hapEndSec - hapStartSec;\n if (hapDuration \u003c= 0) continue;\n\n // Extract common parameters\n const gain = value.gain ?? 0.3;\n const pan = value.pan ?? 0.5;\n const cutoff = value.lpf ?? value.cutoff ?? 20000;\n const hpf = value.hpf ?? 0;\n const sName = value.s ?? value.wave ?? 'sine';\n const sampleN = value.n ?? 0;\n\n const startSample = Math.max(0, Math.floor(hapStartSec * sampleRate));\n const panL = Math.cos(pan * Math.PI / 2);\n const panR = Math.sin(pan * Math.PI / 2);\n\n // Route: sample playback vs oscillator synthesis\n if (!isOscillator(sName) && !value.note && !value.freq) {\n // ── SAMPLE PLAYBACK ──\n const pcm = getSample(sName, sampleN);\n if (!pcm) continue; // unknown sample, skip silently\n\n const sampleLen = pcm.length;\n \n // Determine playback speed (for pitch shifting via .speed())\n const speed = value.speed ?? 1;\n \n // Determine how long this hap should play:\n // If loopAt is set, or if clip/slow create a window longer than the sample,\n // loop the sample to fill the entire hap duration.\n // Otherwise play once (one-shot).\n const hapDurationSamples = Math.ceil(hapDuration * sampleRate);\n const loopAt = value.loopAt;\n const clip = value.clip;\n \n // Loop if: loopAt is set, OR the hap duration exceeds the sample length\n // (which means .slow() created a window the sample can't fill alone)\n const shouldLoop = loopAt != null || hapDurationSamples > sampleLen;\n \n const endSampleIdx = shouldLoop\n ? Math.min(numSamples, startSample + hapDurationSamples)\n : Math.min(numSamples, startSample + Math.ceil(sampleLen / Math.abs(speed || 1)));\n\n const alpha = lpfCoeff(Math.min(cutoff, sampleRate / 2), sampleRate);\n const hpfAlpha = hpf > 0 ? lpfCoeff(Math.min(hpf, sampleRate / 2), sampleRate) : 0;\n let filteredL = 0, filteredR = 0;\n let hpPrevL = 0, hpPrevR = 0;\n \n // Crossfade length for loop points (avoid clicks at loop boundary)\n const xfadeSamples = Math.min(2205, Math.floor(sampleLen * 0.05)); // 50ms or 5% of sample\n\n for (let i = startSample; i \u003c endSampleIdx; i++) {\n const srcIdxRaw = (i - startSample) * Math.abs(speed || 1);\n let raw;\n \n if (shouldLoop && sampleLen > 0) {\n // Loop with crossfade at boundaries\n const pos = srcIdxRaw % sampleLen;\n const idx = Math.floor(pos);\n const frac = pos - idx;\n \n // Linear interpolation for smooth playback at non-integer speeds\n const s0 = pcm[idx % sampleLen];\n const s1 = pcm[(idx + 1) % sampleLen];\n let sample = s0 + frac * (s1 - s0);\n \n // Crossfade near loop boundary to avoid click\n const distToEnd = sampleLen - pos;\n if (distToEnd \u003c xfadeSamples && xfadeSamples > 0) {\n const fadeOut = distToEnd / xfadeSamples;\n const fadeIn = 1 - fadeOut;\n // Blend with beginning of sample\n const loopPos = pos - (sampleLen - xfadeSamples);\n const loopIdx = Math.max(0, Math.floor(loopPos));\n const loopSample = pcm[loopIdx % sampleLen];\n sample = sample * fadeOut + loopSample * fadeIn;\n }\n \n raw = sample * gain;\n } else {\n // One-shot playback\n const srcIdx = Math.floor(srcIdxRaw);\n if (srcIdx >= sampleLen) break;\n raw = pcm[srcIdx] * gain;\n }\n\n // LPF\n filteredL = filteredL + alpha * (raw * panL - filteredL);\n filteredR = filteredR + alpha * (raw * panR - filteredR);\n\n // Optional HPF (one-pole)\n if (hpf > 0) {\n const outL = filteredL - hpPrevL; hpPrevL = filteredL; filteredL = outL;\n const outR = filteredR - hpPrevR; hpPrevR = filteredR; filteredR = outR;\n }\n\n left[i] += filteredL;\n right[i] += filteredR;\n }\n } else {\n // ── OSCILLATOR SYNTHESIS ──\n const oscName = isOscillator(sName) ? sName : 'sine';\n const oscFn = oscillators[oscName];\n const freq = value.freq ?? noteToFreq(value.note ?? value.n ?? 60);\n const attack = value.attack ?? 0.01;\n const decay = value.decay ?? 0.1;\n const sustain = value.sustain ?? 0.7;\n const release = value.release ?? 0.05;\n\n const endSampleIdx = Math.min(numSamples, Math.ceil(hapEndSec * sampleRate));\n\n const alpha = lpfCoeff(Math.min(cutoff, sampleRate / 2), sampleRate);\n const hpfAlpha = hpf > 0 ? lpfCoeff(Math.min(hpf, sampleRate / 2), sampleRate) : 0;\n let filteredL = 0, filteredR = 0;\n let hpPrevL = 0, hpPrevR = 0;\n let phase = 0;\n const phaseInc = freq / sampleRate;\n\n for (let i = startSample; i \u003c endSampleIdx; i++) {\n const t = (i - startSample) / sampleRate;\n const env = envelope(t, hapDuration, attack, decay, sustain, release);\n const raw = oscFn(phase) * env * gain;\n phase += phaseInc;\n\n // LPF\n filteredL = filteredL + alpha * (raw * panL - filteredL);\n filteredR = filteredR + alpha * (raw * panR - filteredR);\n\n // Optional HPF\n if (hpf > 0) {\n const outL = filteredL - hpPrevL; hpPrevL = filteredL; filteredL = outL;\n const outR = filteredR - hpPrevR; hpPrevR = filteredR; filteredR = outR;\n }\n\n left[i] += filteredL;\n right[i] += filteredR;\n }\n }\n }\n\n // Soft clip to prevent clipping\n for (let i = 0; i \u003c numSamples; i++) {\n left[i] = Math.tanh(left[i]);\n right[i] = Math.tanh(right[i]);\n }\n\n return [left, right];\n}\n","content_type":"text/javascript","language":"javascript","size":12784,"content_sha256":"4fe642d068a091b7c679e474843014e8ea42e6555e8e121e20c3cf13816d7f86"},{"filename":"src/stream/pipe-to-vc.mjs","content":"#!/usr/bin/env node\n\nimport { createWriteStream, existsSync } from \"node:fs\";\nimport { spawn } from \"node:child_process\";\nimport process from \"node:process\";\n\nfunction usage() {\n console.error(\"Usage: node src/stream/pipe-to-vc.mjs \u003cinput.wav> [bridge-endpoint] [--file]\");\n}\n\nconst rawArgs = process.argv.slice(2);\nconst fileOutput = rawArgs.includes(\"--file\");\nconst args = rawArgs.filter((arg) => arg !== \"--file\");\nconst inputWav = args[0];\nconst bridgeEndpoint = args[1] || process.env.DISCORD_VC_BRIDGE_ENDPOINT;\n\nif (!inputWav) {\n usage();\n process.exit(1);\n}\n\nif (!existsSync(inputWav)) {\n console.error(`Input WAV not found: ${inputWav}`);\n process.exit(1);\n}\n\nif (bridgeEndpoint) {\n console.error(`Bridge endpoint: ${bridgeEndpoint}`);\n}\n\nconst ffmpegArgs = [\n \"-i\",\n inputWav,\n \"-c:a\",\n \"libopus\",\n \"-b:a\",\n \"128k\",\n \"-ar\",\n \"48000\",\n \"-f\",\n \"opus\",\n \"pipe:1\",\n];\n\nconst ffmpeg = spawn(\"ffmpeg\", ffmpegArgs, {\n stdio: [\"ignore\", \"pipe\", \"inherit\"],\n});\n\nif (fileOutput) {\n const out = createWriteStream(\"output.opus\");\n ffmpeg.stdout.pipe(out);\n out.on(\"finish\", () => {\n console.error(\"Wrote output.opus\");\n });\n} else {\n ffmpeg.stdout.pipe(process.stdout);\n}\n\nffmpeg.on(\"error\", (error) => {\n console.error(`Failed to launch ffmpeg: ${error.message}`);\n process.exit(1);\n});\n\nffmpeg.on(\"close\", (code) => {\n process.exit(code ?? 1);\n});\n\n","content_type":"text/javascript","language":"javascript","size":1380,"content_sha256":"c69446a74db0ee179325da4b5771bda2ebe50c53d1bd50a21b048f3cd1e53bcc"}],"content_json":{"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"⚠️ ","type":"text"},{"text":"Legal Notice:","type":"text","marks":[{"type":"strong"}]},{"text":" This tool processes audio you provide. You are responsible for ensuring you have the rights to use the source material. The authors make no claims about fair use, copyright, or derivative works regarding your use of this tool with copyrighted material.","type":"text"}]}]},{"type":"heading","attrs":{"level":1},"content":[{"text":"Strudel Music 🎵","type":"text"}]},{"type":"paragraph","content":[{"text":"Compose, render, deconstruct, and remix music using code. Takes natural language prompts → writes Strudel patterns → renders offline through real Web Audio synthesis → posts audio or streams to Discord VC (via the OpenClaw gateway — no separate credentials needed). Can also reverse-engineer any audio track into stems, samples, and generative programs.","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"New here?","type":"text","marks":[{"type":"strong"}]},{"text":" Read ","type":"text"},{"text":"docs/ONBOARDING.md","type":"text","marks":[{"type":"link","attrs":{"href":"docs/ONBOARDING.md","title":null}}]},{"text":" for a ground-up introduction.","type":"text"}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"⚠️ SESSION SAFETY — READ THIS FIRST","type":"text"}]},{"type":"paragraph","content":[{"text":"Rendering MUST run as a sub-agent or background process, never inline in your main session.","type":"text","marks":[{"type":"strong"}]}]},{"type":"paragraph","content":[{"text":"The offline renderer (","type":"text"},{"text":"chunked-render.mjs","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"offline-render-v2.mjs","type":"text","marks":[{"type":"code_inline"}]},{"text":") runs a tight audio-processing loop that blocks the Node.js event loop. If you run it in your main OpenClaw session, ","type":"text"},{"text":"it will kill the gateway after ~30 seconds","type":"text","marks":[{"type":"strong"}]},{"text":" (the heartbeat timeout).","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"✅ Correct: spawn a sub-agent or use background exec\n❌ Wrong: run the renderer inline in your main conversation","type":"text"}]},{"type":"paragraph","content":[{"text":"Always do this:","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# Background exec with timeout\nexec background:true timeout:120 command:\"node src/runtime/chunked-render.mjs src/compositions/my-track.js output/my-track.wav 20\"","type":"text"}]},{"type":"paragraph","content":[{"text":"Or spawn a sub-agent:","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"sessions_spawn task:\"Render strudel-music composition: node src/runtime/chunked-render.mjs ...\"","type":"text"}]},{"type":"paragraph","content":[{"text":"This is the #1 way to break things. Don't skip this.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quick Start","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# 1. Setup\ncd ~/.openclaw/workspace/strudel-music\nnpm run setup # installs deps + downloads samples (~11MB)\n\n# 2. Verify\nnpm test # 12-point smoke test\n\n# 3. Render\nnode src/runtime/chunked-render.mjs assets/compositions/fog-and-starlight.js output/fog.wav 16\nffmpeg -i output/fog.wav -codec:a libmp3lame -b:a 192k output/fog.mp3","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Commands","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Invocation","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"What it does","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"/strudel \u003cprompt>","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Compose from natural language — mood, scene, genre, instruments","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"/strudel play \u003cname>","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Stream a saved composition into Discord VC","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"/strudel list","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Show available compositions with metadata","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"/strudel samples","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Manage sample packs (list, download, add)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"/strudel concert \u003ctracks...>","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Play a setlist in Discord VC","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Composition Workflow","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Parse prompt → select mood, key, tempo, instruments (see ","type":"text"},{"text":"references/mood-parameters.md","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Write a ","type":"text"},{"text":".js","type":"text","marks":[{"type":"code_inline"}]},{"text":" composition using Strudel pattern syntax","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Render (in background!):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"node src/runtime/chunked-render.mjs \u003cfile> \u003coutput.wav> \u003ccycles> [chunkSize]","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Convert to MP3:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"ffmpeg -i output.wav -codec:a libmp3lame -b:a 192k output.mp3","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Post the MP3 as attachment or stream to Discord VC","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Discord VC Streaming","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"node src/runtime/offline-render-v2.mjs assets/compositions/combat-assault.js /tmp/track.wav 12 140\nffmpeg -i /tmp/track.wav -ar 48000 -ac 2 /tmp/track-48k.wav -y\nnode scripts/vc-play.mjs /tmp/track-48k.wav","type":"text"}]},{"type":"paragraph","content":[{"text":"WSL2 users: enable mirrored networking (","type":"text"},{"text":"networkingMode=mirrored","type":"text","marks":[{"type":"code_inline"}]},{"text":" in ","type":"text"},{"text":".wslconfig","type":"text","marks":[{"type":"code_inline"}]},{"text":") or VC streaming will fail silently (NAT breaks Discord's UDP voice protocol).","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Sample Management","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Directory Layout","type":"text"}]},{"type":"paragraph","content":[{"text":"Samples live in ","type":"text"},{"text":"samples/","type":"text","marks":[{"type":"code_inline"}]},{"text":". Any directory of WAV files is auto-discovered.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"samples/\n├── strudel.json ← sample map (pitch info, paths)\n├── kick/\n│ └── kick.wav\n├── hat/\n│ └── hat.wav\n├── bass_Cs1/\n│ └── bass_Cs1.wav ← pitched sample (root: C#1)\n├── synth_lead/\n│ └── synth_lead.wav ← pitched sample (root: C#3, declared in strudel.json)\n└── bloom_kick/\n └── bloom_kick.wav ← from audio deconstruction","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"strudel.json Format","type":"text"}]},{"type":"paragraph","content":[{"text":"Maps sample names to files with optional root note declarations. The renderer uses this as the authoritative source for pitch detection.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"json"},"content":[{"text":"{\n \"_base\": \"./\",\n \"kick\": { \"0\": \"kick/kick.wav\" },\n \"bass_Cs1\": { \"cs1\": \"bass_Cs1/bass_Cs1.wav\" },\n \"synth_lead\": { \"cs3\": \"synth_lead/synth_lead.wav\" }\n}","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Keys with note suffixes (","type":"text"},{"text":"_Cs1","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"_D2","type":"text","marks":[{"type":"code_inline"}]},{"text":") declare the root pitch","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Unpitched samples use ","type":"text"},{"text":"\"0\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" as the key","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Always declare root notes for pitched samples — without it, the renderer defaults to C4, causing wrong transpositions (see ","type":"text"},{"text":"docs/KNOWN-PITFALLS.md","type":"text","marks":[{"type":"link","attrs":{"href":"docs/KNOWN-PITFALLS.md#3-root-note-detection-defaults","title":null}}]},{"text":")","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Managing Packs","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"bash scripts/samples-manage.sh list # show installed packs\nbash scripts/samples-manage.sh add \u003curl> # download from URL\nbash scripts/samples-manage.sh add ~/my-samples/ # add local directory","type":"text"}]},{"type":"paragraph","content":[{"text":"Ships with ","type":"text"},{"text":"dirt-samples","type":"text","marks":[{"type":"strong"}]},{"text":" (153 WAVs, CC-licensed). Security: downloads enforce size limits (","type":"text"},{"text":"STRUDEL_MAX_DOWNLOAD_MB","type":"text","marks":[{"type":"code_inline"}]},{"text":", default 10GB), MIME validation, optional host allowlist (","type":"text"},{"text":"STRUDEL_ALLOWED_HOSTS","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Composition Guide","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Pattern Basics","type":"text"}]},{"type":"paragraph","content":[{"text":"CC0 / Free packs (just download and drop in ","type":"text","marks":[{"type":"strong"}]},{"text":"samples/","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":"):","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Dirt-Samples","type":"text","marks":[{"type":"link","attrs":{"href":"https://github.com/tidalcycles/Dirt-Samples","title":null}}]},{"text":" — 800+ samples (full pack, we ship a subset)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Signature Sounds – Homemade Drum Kit","type":"text","marks":[{"type":"link","attrs":{"href":"https://signalsounds.com","title":null}}]},{"text":" (CC0) — 150+ one-shots","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Looping – Synth Pack 01","type":"text","marks":[{"type":"link","attrs":{"href":"https://looping.com","title":null}}]},{"text":" (CC0) — synth one-shots + loops","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"artgamesound.com","type":"text","marks":[{"type":"link","attrs":{"href":"https://artgamesound.com","title":null}}]},{"text":" — CC0 searchable aggregator","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Your own packs:","type":"text","marks":[{"type":"strong"}]},{"text":" Export from any DAW (Ableton, FL Studio, M8 tracker, etc.) as WAV directories. Strudel doesn't care where they came from — it's just WAV files in folders.","type":"text"}]},{"type":"paragraph","content":[{"text":"Named banks","type":"text","marks":[{"type":"strong"}]},{"text":" (Strudel built-in, requires CDN access):","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"javascript"},"content":[{"text":"sound(\"bd sd cp hh\").bank(\"RolandTR909\")\nsound(\"bd sd hh oh\").bank(\"LinnDrum\")","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"WSL2 Note","type":"text"}]},{"type":"paragraph","content":[{"text":"If running on WSL2 and streaming to Discord VC, enable ","type":"text"},{"text":"mirrored networking","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"ini"},"content":[{"text":"# %USERPROFILE%\\.wslconfig\n[wsl2]\nnetworkingMode=mirrored","type":"text"}]},{"type":"paragraph","content":[{"text":"Then ","type":"text"},{"text":"wsl --shutdown","type":"text","marks":[{"type":"code_inline"}]},{"text":" and relaunch. Without this, WSL2's NAT breaks Discord's UDP voice protocol — the bot joins the channel but no audio flows because IP discovery packets can't traverse the NAT return path. Mirrored mode eliminates the NAT by putting WSL2 directly on the host's network stack.","type":"text"}]},{"type":"paragraph","content":[{"text":"This only affects VC streaming. Offline rendering and file posting work in any networking mode.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Platform Requirements","type":"text"}]},{"type":"paragraph","content":[{"text":"Two tiers, depending on what you need:","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Compose & Render (JS-only)","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Node.js 18+","type":"text","marks":[{"type":"strong"}]},{"text":" (22+ recommended for stable ","type":"text"},{"text":"OfflineAudioContext","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"ffmpeg","type":"text","marks":[{"type":"strong"}]},{"text":" (MP3/Opus conversion)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Works everywhere — x86_64, ARM64, WSL2, bare metal, containers.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No Python. No GPU. No ML stack.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Full Pipeline (audio deconstruction with Demucs)","type":"text"}]},{"type":"paragraph","content":[{"text":"Everything above, plus:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Python 3.10+","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"pip packages:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"demucs","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"librosa","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"numpy","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"scipy","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"scikit-learn","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"torch","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"~2GB disk for PyTorch + Demucs model weights (downloaded on first run)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Optional:","type":"text","marks":[{"type":"strong"}]},{"text":" NVIDIA GPU + CUDA toolkit for ~5× Demucs speedup","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Install the Python deps:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"pip install demucs librosa numpy scipy scikit-learn torch","type":"text"}]},{"type":"paragraph","content":[{"text":"If Python deps are missing, composition and rendering still work — you just can't do stem extraction. The skill should fail gracefully with a message, not a stack trace.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Full Pipeline (Audio Deconstruction)","type":"text"}]},{"type":"paragraph","content":[{"text":"If you have an MP3 and want to extract instruments from it, build sample racks, and compose with the extracted material — that's the full pipeline. It goes:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"MP3 → Demucs (stem separation) → librosa (analysis) → sample slicing → Strudel composition → render → MP3","type":"text"}]},{"type":"paragraph","content":[{"text":"This is a 4–8 minute process for a typical track.","type":"text","marks":[{"type":"strong"}]},{"text":" See ","type":"text"},{"text":"docs/pipeline.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the complete stage-by-stage breakdown with commands, timings, and resource requirements.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Quick version","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# 1. Separate stems (Python/Demucs)\npython -m demucs input.mp3 --out ./stems\n\n# 2. Analyze + slice (see docs/pipeline.md for details)\n# Currently semi-manual — analysis scripts in development\n\n# 3. Write composition referencing sliced samples\n# 4. Render\nbash scripts/dispatch.sh render my-composition.js 16 120\n\n# 5. Convert\nffmpeg -i output.wav -c:a libmp3lame -q:a 2 output.mp3 -y","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Timings (ballpark)","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Stage","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CPU estimate","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"GPU estimate","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Demucs stem separation","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"~15s/min of audio","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"~3s/min of audio","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Audio analysis (per stem)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"~10–20s","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"~10–20s","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Sample slicing","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"~5s","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"~5s","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Composition","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"instant (human/AI writes JS)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"instant","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rendering","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"~30–60s/min of output","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"~30–60s/min of output","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MP3 conversion","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"~5s","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"~5s","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Total (4-min track, CPU):","type":"text","marks":[{"type":"strong"}]},{"text":" 4–8 minutes. ","type":"text"},{"text":"Compose + render only (no Demucs):","type":"text","marks":[{"type":"strong"}]},{"text":" 2–3 minutes.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"⚠️ Session Safety — READ THIS","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"The full pipeline takes 4–8 minutes. Composition + render alone takes 2–3 minutes.","type":"text","marks":[{"type":"strong"}]}]},{"type":"paragraph","content":[{"text":"DO NOT","type":"text","marks":[{"type":"strong"}]},{"text":" run this inline in a Discord channel interaction or primary OpenClaw session. The 30-second response timeout will kill the process mid-render. There is no supervisor to recover. The skill will appear broken — silence, no output, no error message.","type":"text"}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"How to run safely","type":"text"}]},{"type":"paragraph","content":[{"text":"From an OpenClaw agent (correct):","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"javascript"},"content":[{"text":"sessions_spawn({\n task: \"Render strudel composition: /strudel dark ambient tension, 65bpm\",\n mode: \"run\",\n runTimeoutSeconds: 600 // 10 minutes — generous for full pipeline\n})","type":"text"}]},{"type":"paragraph","content":[{"text":"Background process (also correct):","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"exec({ command: \"bash scripts/dispatch.sh render ...\", background: true })","type":"text"}]},{"type":"paragraph","content":[{"text":"Direct CLI (fine for testing):","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"bash scripts/dispatch.sh render assets/compositions/fog-and-starlight.js 16 72","type":"text"}]},{"type":"paragraph","content":[{"text":"What to tell the user:","type":"text","marks":[{"type":"strong"}]},{"text":" \"Rendering takes a few minutes — I'll post the audio when it's ready.\" Don't leave them hanging with no feedback.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"What NOT to do","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"javascript"},"content":[{"text":"// WRONG — will timeout after 30s in Discord context\nexec({ command: \"bash scripts/dispatch.sh render ...\" })\n\n// WRONG — blocking the main session for minutes\n// (anything inline that takes >30s)","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Learning Resources","type":"text"}]},{"type":"paragraph","content":[{"text":"Detailed documentation lives in ","type":"text"},{"text":"docs/","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Document","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"What it covers","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"docs/pipeline.md","type":"text","marks":[{"type":"link","attrs":{"href":"docs/pipeline.md","title":null}},{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Full pipeline stages, commands, timings, resource requirements, system dependencies","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"docs/composition-guide.md","type":"text","marks":[{"type":"link","attrs":{"href":"docs/composition-guide.md","title":null}},{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Practical composition lessons — mini-notation pitfalls, the space-vs-angle-bracket rule, ","type":"text"},{"text":".slow()","type":"text","marks":[{"type":"code_inline"}]},{"text":" interactions, debugging hap explosions","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"docs/TESTING.md","type":"text","marks":[{"type":"link","attrs":{"href":"docs/TESTING.md","title":null}},{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Testing strategy — smoke tests, cross-platform validation, quality gates, naive install testing","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Start with ","type":"text","marks":[{"type":"strong"}]},{"text":"composition-guide.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" if you're writing patterns. The space-separated vs angle-bracket distinction is the #1 source of bugs (gain explosions, distortion, memory crashes). The guide covers it with real case studies.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"How It Works","type":"text"}]},{"type":"paragraph","content":[{"text":"The offline renderer uses ","type":"text"},{"text":"node-web-audio-api","type":"text","marks":[{"type":"strong"}]},{"text":" (Rust-based Web Audio for Node.js) for real audio synthesis:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pattern evaluation","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"@strudel/core","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"@strudel/mini","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"@strudel/tonal","type":"text","marks":[{"type":"code_inline"}]},{"text":" parse pattern code into timed \"haps\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Audio scheduling","type":"text","marks":[{"type":"strong"}]},{"text":" — Each hap becomes either:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"An ","type":"text"},{"text":"oscillator","type":"text","marks":[{"type":"strong"}]},{"text":" (sine/saw/square/triangle) with ADSR envelope, biquad filter, stereo pan","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A ","type":"text"},{"text":"sample","type":"text","marks":[{"type":"strong"}]},{"text":" (AudioBufferSourceNode) from the samples directory, with pitch shifting","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Offline rendering","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"OfflineAudioContext.startRendering()","type":"text","marks":[{"type":"code_inline"}]},{"text":" produces complete audio","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Output","type":"text","marks":[{"type":"strong"}]},{"text":" — 16-bit stereo WAV at 44.1kHz → ffmpeg → MP3/Opus","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Note on mini notation:","type":"text","marks":[{"type":"strong"}]},{"text":" The renderer explicitly calls ","type":"text"},{"text":"setStringParser(mini.mini)","type":"text","marks":[{"type":"code_inline"}]},{"text":" after import because Strudel's npm dist bundles duplicate the Pattern class across modules. Same class of bug as ","type":"text"},{"text":"openclaw#22790","type":"text","marks":[{"type":"link","attrs":{"href":"https://github.com/openclaw/openclaw/issues/22790","title":null}}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Composition Reference","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Tempo","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"javascript"},"content":[{"text":"setcpm(120/4) // 120 BPM\n\nstack(\n s(\"bd sd [bd bd] sd\").gain(0.4), // drums (samples)\n s(\"[hh hh] [hh oh]\").gain(0.2), // hats\n note(\"c3 eb3 g3 c4\") // melody\n .s(\"sawtooth\")\n .lpf(sine.range(400, 2000).slow(8)) // filter sweep\n .attack(0.01).decay(0.3).sustain(0.2) // ADSR envelope\n .room(0.4).delay(0.2) // space\n .gain(0.3)\n)","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Mini Notation Quick Ref","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Syntax","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Meaning","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"a b c d\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Sequence (one per beat)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"[a b]\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Subdivide (two in one beat)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"\u003ca b c>\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Alternate per cycle (slowcat)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"a*3\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Repeat","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"~\"","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rest / silence","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".slow(2)","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":".fast(2)","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Time stretch","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":".euclid(3,8)","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Euclidean rhythm","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Mood → Parameter Decision Tree","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Mood","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Tempo","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Key/Scale","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Character","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"tension","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"60-80","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"minor/phrygian","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Low cutoff, sparse, drones","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"combat","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"120-160","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"minor","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Heavy drums, fast, distorted","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"peace","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"60-80","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pentatonic/major","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Warm, slow, ambient","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"mystery","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"70-90","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"whole tone","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reverb, sparse","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"victory","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"110-130","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"major","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Bright, fanfare","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ritual","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"45-60","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"dorian","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Organ drones, chant","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Full tree: ","type":"text"},{"text":"references/mood-parameters.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". Production techniques: ","type":"text"},{"text":"references/production-techniques.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"⚠️ Critical Pitfall: Gain Patterns","type":"text"}]},{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"\u003c>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (slowcat) for sequential values, NOT spaces:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"javascript"},"content":[{"text":"// ❌ WRONG — all values play simultaneously, causes clipping\ns(\"kick\").gain(\"0.3 0.3 0.5 0.3\")\n\n// ✅ RIGHT — one value per cycle\ns(\"kick\").gain(\"\u003c0.3 0.3 0.5 0.3>\")","type":"text"}]},{"type":"paragraph","content":[{"text":"Full list: ","type":"text"},{"text":"docs/KNOWN-PITFALLS.md","type":"text","marks":[{"type":"link","attrs":{"href":"docs/KNOWN-PITFALLS.md","title":null}}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Loudness Validation","type":"text"}]},{"type":"paragraph","content":[{"text":"Always check after rendering:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"ffmpeg -i output.wav -af loudnorm=print_format=json -f null - 2>&1 | grep -E \"input_i|input_tp\"","type":"text"}]},{"type":"paragraph","content":[{"text":"Target: -16 to -10 LUFS, true peak below -1 dBTP. Above -5 LUFS = something is wrong.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Audio Deconstruction Pipeline","type":"text"}]},{"type":"paragraph","content":[{"text":"Full pipeline docs: ","type":"text"},{"text":"references/integration-pipeline.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/integration-pipeline.md","title":null}}]}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"Audio → Demucs (stems) → librosa (analysis) → strudel.json → Composition → Render","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Stem separation","type":"text","marks":[{"type":"strong"}]},{"text":" — Demucs splits audio into vocals, drums, bass, other","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Analysis","type":"text","marks":[{"type":"strong"}]},{"text":" — librosa extracts pitches, onsets, rhythm patterns","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Sample mapping","type":"text","marks":[{"type":"strong"}]},{"text":" — Results written to ","type":"text"},{"text":"strudel.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" with root notes","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Two paths:","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Grammar extraction","type":"text","marks":[{"type":"strong"}]},{"text":" (through-composed music) → generative program capturing statistical DNA","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Sample-based","type":"text","marks":[{"type":"strong"}]},{"text":" (stanzaic/repetitive music) → stem slices played back through Strudel","type":"text"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"Requires Python stack: ","type":"text"},{"text":"uv init && uv add demucs librosa scikit-learn soundfile","type":"text","marks":[{"type":"code_inline"}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"File Structure","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"src/runtime/\n chunked-render.mjs — Chunked offline renderer (avoids OOM on long pieces)\n offline-render-v2.mjs — Core offline renderer\n smoke-test.mjs — 12-point smoke test\nscripts/\n download-samples.sh — Download dirt-samples (idempotent)\n samples-manage.sh — Sample pack manager\n vc-play.mjs — Stream audio to Discord VC\nsamples/ — Sample packs + strudel.json (gitignored)\nassets/compositions/ — 15 original compositions\nsrc/compositions/ — Audio deconstructions\nreferences/ — Mood trees, techniques, architecture\ndocs/\n KNOWN-PITFALLS.md — Critical composition pitfalls\n ONBOARDING.md — Machine-actor onboarding guide","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Renderer Internals","type":"text"}]},{"type":"paragraph","content":[{"text":"Uses ","type":"text"},{"text":"node-web-audio-api","type":"text","marks":[{"type":"strong"}]},{"text":" (Rust-based Web Audio for Node.js). No browser, no Puppeteer.","type":"text"}]},{"type":"paragraph","content":[{"text":"The renderer calls ","type":"text"},{"text":"setStringParser(mini.mini)","type":"text","marks":[{"type":"code_inline"}]},{"text":" after import because Strudel's npm dist bundles duplicate the ","type":"text"},{"text":"Pattern","type":"text","marks":[{"type":"code_inline"}]},{"text":" class across modules — the mini notation parser registers on a different copy than the one used by ","type":"text"},{"text":"note()","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"s()","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"All synthesis is local and offline via ","type":"text"},{"text":"OfflineAudioContext","type":"text","marks":[{"type":"code_inline"}]},{"text":": oscillators, biquad filters, ADSR envelopes, ","type":"text"},{"text":"AudioBufferSourceNode","type":"text","marks":[{"type":"code_inline"}]},{"text":" for samples, dynamics compression, stereo panning. Output: 16-bit stereo WAV at 44.1kHz.","type":"text"}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Known Platform Issues","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Platform","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Issue","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Workaround","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ARM64 (all)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"PyTorch CPU-only, no CUDA","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Expected — Demucs runs ~0.25× realtime","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ARM64 (all)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"torchaudio.save()","type":"text","marks":[{"type":"code_inline"}]},{"text":" fails","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Patch ","type":"text"},{"text":"demucs/audio.py","type":"text","marks":[{"type":"code_inline"}]},{"text":" to use ","type":"text"},{"text":"soundfile.write()","type":"text","marks":[{"type":"code_inline"}]},{"text":" (see First-Time Setup)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ARM64 (all)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"torchcodec","type":"text","marks":[{"type":"code_inline"}]},{"text":" build fails","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Not needed — skip it, Demucs works without it","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"WSL2","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Discord VC silent (NAT blocks UDP)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Enable mirrored networking in ","type":"text"},{"text":".wslconfig","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"All","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Strudel ","type":"text"},{"text":"mini","type":"text","marks":[{"type":"code_inline"}]},{"text":" parser not registered","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Renderer calls ","type":"text"},{"text":"setStringParser(mini.mini)","type":"text","marks":[{"type":"code_inline"}]},{"text":" — already handled","type":"text"}]}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"🔒 Security Model","type":"text"}]},{"type":"paragraph","content":[{"text":"Strudel compositions are JavaScript files executed by Node.js. They have the same access as any Node.js script:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Filesystem","type":"text","marks":[{"type":"strong"}]},{"text":": read/write access to the working directory","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Environment","type":"text","marks":[{"type":"strong"}]},{"text":": can read environment variables","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Network","type":"text","marks":[{"type":"strong"}]},{"text":": can make HTTP requests","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"For untrusted compositions:","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run in a container or VM with no sensitive credentials in the environment","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Use OpenClaw's sub-agent isolation (each sub-agent gets its own process)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Review composition code before rendering","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"For your own compositions:","type":"text","marks":[{"type":"strong"}]},{"text":" No special precautions needed — you wrote the code.","type":"text"}]},{"type":"paragraph","content":[{"text":"This is the same trust model as any programming language skill. The renderer itself is safe; the risk is in what compositions you choose to run.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Discord Integration","type":"text"}]},{"type":"paragraph","content":[{"text":"This skill uses OpenClaw's built-in Discord voice channel support for streaming. ","type":"text"},{"text":"No separate ","type":"text","marks":[{"type":"strong"}]},{"text":"BOT_TOKEN","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", ","type":"text","marks":[{"type":"strong"}]},{"text":"DISCORD_TOKEN","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", or any Discord credentials are required.","type":"text","marks":[{"type":"strong"}]},{"text":" OpenClaw handles all Discord authentication and connection management. The skill simply produces audio files and hands them to OpenClaw's voice subsystem.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"npm install safety","type":"text"}]},{"type":"paragraph","content":[{"text":"package.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" contains no ","type":"text"},{"text":"postinstall","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"preinstall","type":"text","marks":[{"type":"code_inline"}]},{"text":", or lifecycle hooks. ","type":"text"},{"text":"npm run setup","type":"text","marks":[{"type":"code_inline"}]},{"text":" runs ","type":"text"},{"text":"npm install","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"scripts/download-samples.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" (downloads CC0 sample packs from known URLs).","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"What ","type":"text"},{"text":"scripts/download-samples.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" fetches","type":"text"}]},{"type":"paragraph","content":[{"text":"The download script sparse-clones ","type":"text"},{"text":"tidalcycles/Dirt-Samples","type":"text","marks":[{"type":"link","attrs":{"href":"https://github.com/tidalcycles/Dirt-Samples","title":null}}]},{"text":" from GitHub (CC-licensed) — specifically these directories: ","type":"text"},{"text":"bd sd hh oh cp cr ride rim mt lt ht cb 808bd 808sd 808hc 808oh","type":"text","marks":[{"type":"code_inline"}]},{"text":". This fetches ~153 WAV files (~11MB total). The script is idempotent (skips if samples already exist).","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"What ","type":"text"},{"text":"scripts/samples-manage.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" does","type":"text"}]},{"type":"paragraph","content":[{"text":"The sample manager downloads additional packs from user-specified URLs with safety controls:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Size limit","type":"text","marks":[{"type":"strong"}]},{"text":": configurable via ","type":"text"},{"text":"STRUDEL_MAX_DOWNLOAD_MB","type":"text","marks":[{"type":"code_inline"}]},{"text":" (default: 10GB)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Host allowlist","type":"text","marks":[{"type":"strong"}]},{"text":": optional ","type":"text"},{"text":"STRUDEL_ALLOWED_HOSTS","type":"text","marks":[{"type":"code_inline"}]},{"text":" (comma-separated; empty = allow all)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"MIME validation","type":"text","marks":[{"type":"strong"}]},{"text":": checks downloaded files are audio or archive types","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Path traversal protection","type":"text","marks":[{"type":"strong"}]},{"text":": validates extracted paths don't escape the samples directory (zip-slip protection)","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":2},"content":[{"text":"Concurrency","type":"text"}]},{"type":"paragraph","content":[{"text":"Only one render should be active per session at a time. If a user requests ","type":"text"},{"text":"/strudel clone","type":"text","marks":[{"type":"code_inline"}]},{"text":" while a previous render is in progress:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Check for active sub-agents using ","type":"text"},{"text":"subagents(action=list)","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If a strudel render is running, respond: \"🎵 A render is already in progress. Please wait for it to complete.\"","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do not dispatch a second render — disk and memory contention can cause artifacts or failures.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Why:","type":"text","marks":[{"type":"strong"}]},{"text":" Concurrent renders with default output paths both write to ","type":"text"},{"text":"output.wav","type":"text","marks":[{"type":"code_inline"}]},{"text":", causing the second to overwrite the first. Even with explicit paths, two simultaneous ","type":"text"},{"text":"OfflineAudioContext","type":"text","marks":[{"type":"code_inline"}]},{"text":" processes double memory usage. Sample loading is per-process (no shared cache), so there's no corruption risk — but disk I/O contention on the output write is real.","type":"text"}]}]},"metadata":{"date":"2026-06-05","name":"strudel-music","tags":["music","audio","strudel","composition","samples","trance"],"author":"@skillopedia","source":{"stars":2012,"repo_name":"openclaw-master-skills","origin_url":"https://github.com/leoyeai/openclaw-master-skills/blob/HEAD/skills/strudel-music/SKILL.md","repo_owner":"leoyeai","body_sha256":"6ca6ecf7870e7b07aecac9cd0f3468ac8d91d63d1c2cb14447bbe2bf87ec5631","cluster_key":"04a66daa1459bfdeb9a01f2690685ac4c3e977860ef0e7bb496e1ee51ab6a416","clean_bundle":{"format":"clean-skill-bundle-v1","source":"leoyeai/openclaw-master-skills/skills/strudel-music/SKILL.md","attachments":[{"id":"c0e0f4d8-50be-5987-a688-0bbf3b32df20","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c0e0f4d8-50be-5987-a688-0bbf3b32df20/attachment.md","path":"CHANGELOG.md","size":1591,"sha256":"984a3409908b1f95d1ddd6f36d14b94c0c99d68f8c538d7e8a9f1c52db6854e0","contentType":"text/markdown; charset=utf-8"},{"id":"8f525ac9-32f7-5b97-91a9-47ade02e7134","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8f525ac9-32f7-5b97-91a9-47ade02e7134/attachment.md","path":"CONTRIBUTING.md","size":1705,"sha256":"e1501d0d3e527644992e6f8a08ea7cb5da342b3efd54b1cb6551d18629c6fd47","contentType":"text/markdown; charset=utf-8"},{"id":"65be6c38-7e9e-5372-9e90-abd5af185a47","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/65be6c38-7e9e-5372-9e90-abd5af185a47/attachment.md","path":"README.md","size":15617,"sha256":"a6641bd36f08adec1d12f458c75e779429ee39c57c805f5748dfec78a48b6fc9","contentType":"text/markdown; charset=utf-8"},{"id":"4fe7d93b-56fa-5002-bffa-25d9f709ca48","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4fe7d93b-56fa-5002-bffa-25d9f709ca48/attachment.json","path":"_meta.json","size":1352,"sha256":"26b3f4d8b8b3472040dda6b0d974fb8141494c491cf972c3664f2434848e739d","contentType":"application/json; charset=utf-8"},{"id":"d813d1ec-346e-5c6d-91bc-01fcf39852e7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d813d1ec-346e-5c6d-91bc-01fcf39852e7/attachment.js","path":"assets/compositions/agent-parameterized.js","size":2589,"sha256":"e681ce8fe868077e689a5002ca7f174fb1c735d05a65a6c79f665910bc2be3f8","contentType":"application/javascript; charset=utf-8"},{"id":"821078df-b67d-5581-9002-1693e2039627","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/821078df-b67d-5581-9002-1693e2039627/attachment.js","path":"assets/compositions/cael-theme.js","size":1914,"sha256":"cc29a536755e5813669ef28e4e1cd488330a7fb061776581105aef81560d881d","contentType":"application/javascript; charset=utf-8"},{"id":"4cf8651a-dc19-5a93-b7e7-41aeb6185beb","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4cf8651a-dc19-5a93-b7e7-41aeb6185beb/attachment.js","path":"assets/compositions/cathedral-ritual.js","size":1212,"sha256":"16c235badd7b96bcb0f9fa9b7f303ef0d7ba0df2c16da166b54dd742f6f82331","contentType":"application/javascript; charset=utf-8"},{"id":"75a98135-7e1f-5e32-a0c0-2d4378a8a950","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/75a98135-7e1f-5e32-a0c0-2d4378a8a950/attachment.js","path":"assets/compositions/combat-assault.js","size":1320,"sha256":"f6466fb5d85fc6ae6698d70ebe5af78b53f7488dc149e465972e6c94514fd44c","contentType":"application/javascript; charset=utf-8"},{"id":"f93df69b-97bd-5438-bc6a-c2abebe6206c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f93df69b-97bd-5438-bc6a-c2abebe6206c/attachment.js","path":"assets/compositions/dark-ambient-tension.js","size":1605,"sha256":"92433ca630abe1583011a40f2a95f3a39587b253b82225983dcbceb00cf4b028","contentType":"application/javascript; charset=utf-8"},{"id":"e7679782-6790-5a9e-9144-c3d4aa038870","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e7679782-6790-5a9e-9144-c3d4aa038870/attachment.js","path":"assets/compositions/discovery-xenos.js","size":1644,"sha256":"236be2f3683b45e8fe4eea067cd701da4e500859f00e902815a4e3f7ca5d2520","contentType":"application/javascript; charset=utf-8"},{"id":"20c8e0b5-89fd-54e3-b40f-a45907824763","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/20c8e0b5-89fd-54e3-b40f-a45907824763/attachment.js","path":"assets/compositions/elliott-theme.js","size":3058,"sha256":"4a7a1d458321516c3d9fdf0f01139c3550b7eca5db98fc4a09d5d0857cc20ebd","contentType":"application/javascript; charset=utf-8"},{"id":"ab7f2b76-ef59-57a6-b44f-d7463a1ae205","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ab7f2b76-ef59-57a6-b44f-d7463a1ae205/attachment.js","path":"assets/compositions/fog-and-starlight.js","size":1896,"sha256":"6364dc4f177905746be287df87eccecf549fd9165c881188ef5132bcc7ec53ef","contentType":"application/javascript; charset=utf-8"},{"id":"d230f8ed-8053-53d3-a8f7-0c3053d64d06","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d230f8ed-8053-53d3-a8f7-0c3053d64d06/attachment.js","path":"assets/compositions/lofi-chill-beats.js","size":1715,"sha256":"613605e05daecbd65402d68cad21f6717ac747846643f8ef0f572548e3ba05fd","contentType":"application/javascript; charset=utf-8"},{"id":"cc9a9edb-5423-52f0-bb8d-79fa289f8f28","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/cc9a9edb-5423-52f0-bb8d-79fa289f8f28/attachment.js","path":"assets/compositions/machine-hum.js","size":1981,"sha256":"000cb56e34b3dc877409d37a07e268fbfd9828fddc160fdd4eb55b5fefc33181","contentType":"application/javascript; charset=utf-8"},{"id":"c2dc99c2-dbc1-5195-a498-c320092acf5d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c2dc99c2-dbc1-5195-a498-c320092acf5d/attachment.js","path":"assets/compositions/rain.js","size":2169,"sha256":"29093256b19f6a697c85d54603c09ce29099a42a4e8d83f967274582dad10f4b","contentType":"application/javascript; charset=utf-8"},{"id":"1ab93b42-c42e-5257-bd52-3b26df13a2b0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1ab93b42-c42e-5257-bd52-3b26df13a2b0/attachment.js","path":"assets/compositions/silas-theme.js","size":2885,"sha256":"fc001e1c6f049344eca94e00eee43efe0553780298c496fd341174936e573d93","contentType":"application/javascript; charset=utf-8"},{"id":"a5cb076d-d83f-59aa-8d7a-25b9f9ea2e03","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a5cb076d-d83f-59aa-8d7a-25b9f9ea2e03/attachment.js","path":"assets/compositions/tavern-respite.js","size":1167,"sha256":"87ec5a6a1eaf176afab53d1afa0165132e3d52575ba486e7b2ab5d81c1ca6ab7","contentType":"application/javascript; charset=utf-8"},{"id":"5767b40e-af04-506d-a4ac-beee88dd2b5f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5767b40e-af04-506d-a4ac-beee88dd2b5f/attachment.js","path":"assets/compositions/underhive-dread.js","size":1178,"sha256":"d089baf3dd159e5de4415734c2a40231e71b4d668cf57e04200811561bee682e","contentType":"application/javascript; charset=utf-8"},{"id":"cf836ff5-3e60-51f3-9352-79d1cd9e2cd8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/cf836ff5-3e60-51f3-9352-79d1cd9e2cd8/attachment.js","path":"assets/compositions/victory-imperium.js","size":1348,"sha256":"fec413cd561d830fa67b6e4d7c7f40a6b42654b6e6651a70f7f53ccff8c674c9","contentType":"application/javascript; charset=utf-8"},{"id":"98a97bff-3fdf-5751-8f1b-0a86689aeced","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/98a97bff-3fdf-5751-8f1b-0a86689aeced/attachment.json","path":"bank-manifest.json","size":3781,"sha256":"37529109bec78d103d0c48d8591394fb476a8eb6647731f3307631506facec7c","contentType":"application/json; charset=utf-8"},{"id":"7d9f3988-c66e-575a-93eb-c041acf9651d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7d9f3988-c66e-575a-93eb-c041acf9651d/attachment.md","path":"docs/KNOWN-PITFALLS.md","size":3493,"sha256":"0a8940c1eadc7a4fcc635afde11c6b6d8d91c4d42dee94a367e94fef497a323a","contentType":"text/markdown; charset=utf-8"},{"id":"84817c5b-c647-56ca-8eb4-fef57deb6a70","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/84817c5b-c647-56ca-8eb4-fef57deb6a70/attachment.md","path":"docs/ONBOARDING.md","size":10237,"sha256":"231074f578edbf50bd3bd2c2413580422741e841afa69d32a692a9f716a295e3","contentType":"text/markdown; charset=utf-8"},{"id":"ea3e5665-a0b8-56cd-acac-5433e1a7d458","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ea3e5665-a0b8-56cd-acac-5433e1a7d458/attachment.md","path":"docs/PROMOTION.md","size":4720,"sha256":"7783df8feb994a79aabff7227bfe9512b5b757d14f8661c1b02e333f2b8f2ab0","contentType":"text/markdown; charset=utf-8"},{"id":"6ec45118-10a3-56eb-b4be-9fa83b3e0f0b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6ec45118-10a3-56eb-b4be-9fa83b3e0f0b/attachment.md","path":"docs/TESTING.md","size":4991,"sha256":"3b37614f85ff916bee9a3846f110eb69d7790f3b215646b91ceff08379d735a4","contentType":"text/markdown; charset=utf-8"},{"id":"ee305136-308d-5636-b6cb-34b2d85428a0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ee305136-308d-5636-b6cb-34b2d85428a0/attachment.md","path":"docs/composition-guide.md","size":5942,"sha256":"4bcd5ab83185935ef0022d5fe531a52dad13801d5839225e118c519b9a4dd156","contentType":"text/markdown; charset=utf-8"},{"id":"1f254e96-6c83-50b0-8b92-ffc5a4396070","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1f254e96-6c83-50b0-8b92-ffc5a4396070/attachment.md","path":"docs/pipeline-guide.md","size":15937,"sha256":"de01c3fa425edec55bc3d09e6fe703e48fcc5a60bef4dcc84df105a7ca9ab04b","contentType":"text/markdown; charset=utf-8"},{"id":"c76925a4-e710-563d-ab3e-9ef513293e46","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c76925a4-e710-563d-ab3e-9ef513293e46/attachment.md","path":"docs/pipeline.md","size":6637,"sha256":"58befda6ab5ab85ba3f4369827f302bdf60c3df5cac14071a92bd3a37c157e71","contentType":"text/markdown; charset=utf-8"},{"id":"d3fb5487-97d8-53ac-8241-d99c87a59c5e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d3fb5487-97d8-53ac-8241-d99c87a59c5e/attachment.md","path":"docs/testing-checklist.md","size":7181,"sha256":"842c4ad16df651dc007d441b5d770026a069e833a70afbf8c9ad8ab0f70858d0","contentType":"text/markdown; charset=utf-8"},{"id":"588de3ba-8b70-5c12-a70c-4b5339f5b0f0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/588de3ba-8b70-5c12-a70c-4b5339f5b0f0/attachment.json","path":"package-lock.json","size":73095,"sha256":"db4729ec5a650cc2c6e1dffe47ef748ee64cbfdbb977d2cdc2fbc8e53f9da2fe","contentType":"application/json; charset=utf-8"},{"id":"0a90cec1-9a77-5c74-b085-30992211810c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0a90cec1-9a77-5c74-b085-30992211810c/attachment.json","path":"package.json","size":1271,"sha256":"ab4ad4f266f67a43c94caf969d431d76fea57567a6aa96532d30b1ca19548df9","contentType":"application/json; charset=utf-8"},{"id":"d1c479de-3266-5fc2-9272-0edcba61dd2a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d1c479de-3266-5fc2-9272-0edcba61dd2a/attachment.md","path":"references/cc-sample-packs-catalog.md","size":28843,"sha256":"ef2612eca44ef4423381a2fa1283e6f6632f3e4f852c21b8d5f5f7be7520f35f","contentType":"text/markdown; charset=utf-8"},{"id":"45bae120-c933-5eea-a53c-74f49eeefd10","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/45bae120-c933-5eea-a53c-74f49eeefd10/attachment.md","path":"references/composing.md","size":4869,"sha256":"b6c7377887edfd4a97d0dd63a454006f5f9828a017f1f9937f7368b1db3cf6a7","contentType":"text/markdown; charset=utf-8"},{"id":"a498e145-823a-5027-9dd6-51dd9c53211c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a498e145-823a-5027-9dd6-51dd9c53211c/attachment.md","path":"references/gain-calibration.md","size":3920,"sha256":"f4ab2e202902ffcaa5252ee1997533d661c8b5c1dd9962a1a3543809e3424a83","contentType":"text/markdown; charset=utf-8"},{"id":"8e2b1447-ab7e-5ccc-9f29-0c50ebd58916","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8e2b1447-ab7e-5ccc-9f29-0c50ebd58916/attachment.md","path":"references/integration-pipeline.md","size":3935,"sha256":"56f93a016214ee702c53c1cffae9e24a3642f868c6eacf3fbdd5bdc35d9bcc20","contentType":"text/markdown; charset=utf-8"},{"id":"4e8b9f47-5e4d-5179-9e09-8c9796de4361","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4e8b9f47-5e4d-5179-9e09-8c9796de4361/attachment.md","path":"references/mood-parameters.md","size":4347,"sha256":"509c311b1e5a8bdf571c8eee911dfd28fc6f46fc49b42b8549ddd9678647f300","contentType":"text/markdown; charset=utf-8"},{"id":"32907185-0a49-5276-a9f9-052180759043","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/32907185-0a49-5276-a9f9-052180759043/attachment.md","path":"references/openclaw-integration.md","size":3227,"sha256":"170706d4288d139f1557ae000a33f30b38db6916846392e12322d1e8619dc761","contentType":"text/markdown; charset=utf-8"},{"id":"b1b2b7e9-631e-52cd-b6c6-e4ed5dd79083","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b1b2b7e9-631e-52cd-b6c6-e4ed5dd79083/attachment.md","path":"references/pattern-transforms.md","size":4479,"sha256":"0986c82b371a107009b26b3c4598cb33053beb70a05ed9300c723946f7ea5cbb","contentType":"text/markdown; charset=utf-8"},{"id":"dce85f2b-3f8c-5c99-a94c-503fbd50bd35","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/dce85f2b-3f8c-5c99-a94c-503fbd50bd35/attachment.md","path":"references/production-techniques.md","size":3353,"sha256":"ed3a8275917f701105c3dc685f03298e3486a96f4034b1d355faf28f946e4b56","contentType":"text/markdown; charset=utf-8"},{"id":"b17a0640-82aa-5010-9b30-004d48cfde93","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b17a0640-82aa-5010-9b30-004d48cfde93/attachment.md","path":"references/rendering.md","size":3514,"sha256":"94457774f2f884e5a212af1d4cea6b40fccbaa23f4b7ae894942fa7dbeed4fdc","contentType":"text/markdown; charset=utf-8"},{"id":"7ff41d1b-46ce-5b26-a125-04073cd00fe6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7ff41d1b-46ce-5b26-a125-04073cd00fe6/attachment.md","path":"references/sample-packs.md","size":2598,"sha256":"d39f94b1bca9eb6361c34e070665fbdb052068f2e9ba3b724b354fc05f437fa8","contentType":"text/markdown; charset=utf-8"},{"id":"12b82031-796e-5902-ada1-521039a95d8a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/12b82031-796e-5902-ada1-521039a95d8a/attachment.md","path":"references/spectral-validation.md","size":6691,"sha256":"9d364d5bff11ae6321dbdd9d5de8233409d27b16a1cb5160c895ec413685e3ed","contentType":"text/markdown; charset=utf-8"},{"id":"a8577fb6-8234-5236-90e6-d0d2453767f9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a8577fb6-8234-5236-90e6-d0d2453767f9/attachment.py","path":"scripts/analyze-render.py","size":9218,"sha256":"91546261b4fbe0bf468d28ac83c7fb231a263b500463e5aecaf462413399351e","contentType":"text/x-python; charset=utf-8"},{"id":"522c5265-2497-5c7d-91ba-13bc80501a62","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/522c5265-2497-5c7d-91ba-13bc80501a62/attachment.sh","path":"scripts/dispatch.sh","size":4006,"sha256":"7f57329ab824dfc6f72765339b9c661700759b7984bf8c2575685abb999842f6","contentType":"application/x-sh; charset=utf-8"},{"id":"32416cb4-7af8-59ca-a626-87774cf0c8e7","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/32416cb4-7af8-59ca-a626-87774cf0c8e7/attachment.sh","path":"scripts/download-samples.sh","size":999,"sha256":"2e29b3962c3867b17d02d7cfd7bf5b0eeb7846b0ee60a1e8f004169dd5c13ec2","contentType":"application/x-sh; charset=utf-8"},{"id":"aaa7dc43-a6a1-5866-a99f-e211ebdb7134","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/aaa7dc43-a6a1-5866-a99f-e211ebdb7134/attachment.sh","path":"scripts/samples-manage.sh","size":8958,"sha256":"c5c002c6eff181fcc0a4c23d44fc35dd6a491b1982f04c5ed25aadef4fc6ff15","contentType":"application/x-sh; charset=utf-8"},{"id":"72e52c49-c06a-5c84-bc92-1a39c411e5bb","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/72e52c49-c06a-5c84-bc92-1a39c411e5bb/attachment.mjs","path":"scripts/vc-play.mjs","size":3030,"sha256":"26f0e31c3848aed75f205371953eba3b33d029337da9ae1ce1e12df3be053078","contentType":"text/javascript"},{"id":"7b10d17b-5a2d-518f-a45e-530210c2e1de","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7b10d17b-5a2d-518f-a45e-530210c2e1de/attachment.js","path":"src/compositions/angel-1-grammar.js","size":2172,"sha256":"1de381599bcbc5b1bc11da9b81105f92f262b4ca84df2c01c1c0a64251fbd4e5","contentType":"application/javascript; charset=utf-8"},{"id":"9a7c4081-94c9-53e1-a8ea-5413480b4fc6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9a7c4081-94c9-53e1-a8ea-5413480b4fc6/attachment.js","path":"src/compositions/bloom-cael-notelevel.js","size":20532,"sha256":"f582e6ec4a6fbdab4a5a1776f91767fa235acaf7604bd3456ed4dce0855c34df","contentType":"application/javascript; charset=utf-8"},{"id":"e2917072-8cb9-5311-b212-da9a3f7e8d1e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e2917072-8cb9-5311-b212-da9a3f7e8d1e/attachment.js","path":"src/compositions/bloom-elliott-full.js","size":10419,"sha256":"fbbec8642a49c7a8d45c31847a19b02234ed473e756346f3c503934fe67ab36f","contentType":"application/javascript; charset=utf-8"},{"id":"519d8581-d757-545a-ba92-f8bf5034668a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/519d8581-d757-545a-ba92-f8bf5034668a/attachment.js","path":"src/compositions/bloom-elliott.js","size":3969,"sha256":"c834824e59b8b0657cfbcbc10f2efa31f0e91facbc42d8ec45abafbd2a0cc9dd","contentType":"application/javascript; charset=utf-8"},{"id":"009823f7-7233-5977-b891-85f4cfe607a2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/009823f7-7233-5977-b891-85f4cfe607a2/attachment.js","path":"src/compositions/bloom-ronan.js","size":15706,"sha256":"656ce72c07d0c9ee084c60e9fc1a2b8cee5fc6401af3aeb54316e4a180a78131","contentType":"application/javascript; charset=utf-8"},{"id":"2f7e5e6e-0a53-52fa-8837-b795eb38ad65","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2f7e5e6e-0a53-52fa-8837-b795eb38ad65/attachment.js","path":"src/compositions/bloom-silas.js","size":14555,"sha256":"e278cf49b8fbd3b11bc5908f358ec41706f4f5f4557dbe30bb408aa111e7b61f","contentType":"application/javascript; charset=utf-8"},{"id":"e2423966-6021-5dd7-b52a-0f50fbe966be","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e2423966-6021-5dd7-b52a-0f50fbe966be/attachment.js","path":"src/compositions/dark-hive-grammar.js","size":1580,"sha256":"315f7c4e33b9b178efb832d2e50128d90274ce419aa9c42d7a51634acf7eca6c","contentType":"application/javascript; charset=utf-8"},{"id":"1c93fdc5-0782-5ca1-b714-9957b6729060","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1c93fdc5-0782-5ca1-b714-9957b6729060/attachment.js","path":"src/compositions/eamon-silas-v3.js","size":12481,"sha256":"c1eb4b27d12781cee135cb01dd8ac2b15b61191e1b46cadd042d15a33f6d0f60","contentType":"application/javascript; charset=utf-8"},{"id":"7dc22561-cdca-5d14-bf16-6ddfa6f378cd","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7dc22561-cdca-5d14-bf16-6ddfa6f378cd/attachment.js","path":"src/compositions/frisson-elliott-notelevel.js","size":15695,"sha256":"76d6145f4fb612fc24ce39451e629a3dbf75d42caa4d9bfe777baa66876bc128","contentType":"application/javascript; charset=utf-8"},{"id":"13846202-b7e1-5b6d-82b4-c8fcf3650f9d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/13846202-b7e1-5b6d-82b4-c8fcf3650f9d/attachment.js","path":"src/compositions/greensleeves-lute.js","size":1384,"sha256":"1a7f3f2f0abbb66c476fe088ef788c09e32594fdc3ce78ce09b99c6ebda5fbe8","contentType":"application/javascript; charset=utf-8"},{"id":"67d1d5a9-6613-55eb-b934-06fbcadc4d6c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/67d1d5a9-6613-55eb-b934-06fbcadc4d6c/attachment.js","path":"src/compositions/greensleeves.js","size":2730,"sha256":"8335555d0b42459641f364f6bf605ff11c770dfc5169a0217d5c0f6be5cdac87","contentType":"application/javascript; charset=utf-8"},{"id":"4ba8e49b-ba8a-5a60-bbb1-5555d79037dd","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4ba8e49b-ba8a-5a60-bbb1-5555d79037dd/attachment.js","path":"src/compositions/hallur-ronan-v2.js","size":8603,"sha256":"1b5a5e4df438c39cc5aee741c2047ddc9ee8d6884c3e9d22533a00a1df9c9617","contentType":"application/javascript; charset=utf-8"},{"id":"0d8251a6-348e-5253-83e4-19a51c287bad","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0d8251a6-348e-5253-83e4-19a51c287bad/attachment.js","path":"src/compositions/hallur-ronan.js","size":8557,"sha256":"6937a9729a0f85f1142d4e6dac90f0ac81768b07ab205a9f3aa0ed220021414b","contentType":"application/javascript; charset=utf-8"},{"id":"5cf9bbd6-8ca3-5032-bd53-f65946e09963","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5cf9bbd6-8ca3-5032-bd53-f65946e09963/attachment.js","path":"src/compositions/peace-piece-silas.js","size":8963,"sha256":"b7ced8bb357fc66d3b532821525823393786a67bf96eef739a13f4523e046e08","contentType":"application/javascript; charset=utf-8"},{"id":"4f0e418f-8b83-5c39-be05-a5bcfd1838c8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4f0e418f-8b83-5c39-be05-a5bcfd1838c8/attachment.js","path":"src/compositions/pitch-test.js","size":1229,"sha256":"a08c1810dfe1dfa34a10f20c3466a291b8f1abcc9f81d38eaf441a98dc569c94","contentType":"application/javascript; charset=utf-8"},{"id":"fb304fa9-61fd-5ba8-ac27-6671b5189f4d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fb304fa9-61fd-5ba8-ac27-6671b5189f4d/attachment.js","path":"src/compositions/solarstone-frisson-cael-notelevel.js","size":28459,"sha256":"c59c9a0fd5c7308ce3ee5ff7502130359e1b8c16776e101721be3db8465a80b2","contentType":"application/javascript; charset=utf-8"},{"id":"7a812a09-2b0c-52a8-9e74-18f0c20b574e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7a812a09-2b0c-52a8-9e74-18f0c20b574e/attachment.js","path":"src/compositions/solarstone-frisson-cael.js","size":6875,"sha256":"0472dd9b42ce9b103962cb3e1f9014f0f2d8091ecc93fe89403db83ba4bfb092","contentType":"application/javascript; charset=utf-8"},{"id":"105e3765-57bb-5a2e-b538-839727ffb07d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/105e3765-57bb-5a2e-b538-839727ffb07d/attachment.js","path":"src/compositions/solarstone-frisson-clone.js","size":1674,"sha256":"7f99d1c88e47aa7506ab225a11ff5fb6b77930a58c21d17a7beab0b2dd97324f","contentType":"application/javascript; charset=utf-8"},{"id":"e360dfa5-7041-57b0-a07c-0b891603ffa2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e360dfa5-7041-57b0-a07c-0b891603ffa2/attachment.js","path":"src/compositions/solarstone-frisson-silas.js","size":14797,"sha256":"7250c37692e3cafa4da325f4210fdfe9b813b7622017dc6ccb6c98b1e109c54c","contentType":"application/javascript; charset=utf-8"},{"id":"f7f3f649-7904-543a-b214-045c7f58fe01","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f7f3f649-7904-543a-b214-045c7f58fe01/attachment.js","path":"src/compositions/solarstone-ptr-477-clone.js","size":1276,"sha256":"cc1d3238d2ec8948eafa99bf0f0ba42e4a72cc98c95bec84f440982c6c656820","contentType":"application/javascript; charset=utf-8"},{"id":"8be157fc-7974-55dd-ad2f-bef0d0eac7e0","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8be157fc-7974-55dd-ad2f-bef0d0eac7e0/attachment.js","path":"src/compositions/suo-gan-vocal.js","size":3016,"sha256":"19e451d3e505f98a4112a7d1f45d8cbdccb5e143a4230db3c6cd9404f297774e","contentType":"application/javascript; charset=utf-8"},{"id":"9b72d56a-a7bf-57d4-9cdf-401f0062c50e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9b72d56a-a7bf-57d4-9cdf-401f0062c50e/attachment.js","path":"src/compositions/suo-gan.js","size":1468,"sha256":"646fa3fade4fc1c3173fe95df57be333bb8a52b515d58a36b451cbc57b660a44","contentType":"application/javascript; charset=utf-8"},{"id":"125196f5-ca75-5e20-8ff2-c7ff97fa3ee2","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/125196f5-ca75-5e20-8ff2-c7ff97fa3ee2/attachment.js","path":"src/compositions/switch-angel-clone.js","size":1347,"sha256":"00f924cc86f5380f2c30523e6c546dd61ca6dbb4da0af9f194c8e4b3f6494758","contentType":"application/javascript; charset=utf-8"},{"id":"4f9984da-d535-51a1-a377-7c4f68fba95f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4f9984da-d535-51a1-a377-7c4f68fba95f/attachment.js","path":"src/compositions/switch-angel-deconstruction.js","size":1974,"sha256":"5d8e6f2706fa53f29bc86c84e6ed72cc180957702c36433f61ca5aefed808561","contentType":"application/javascript; charset=utf-8"},{"id":"edb027e1-60b4-517b-a656-44792c205709","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/edb027e1-60b4-517b-a656-44792c205709/attachment.js","path":"src/compositions/switch-angel-full.js","size":1670,"sha256":"3053d6fb12e21804d650c45f96abe0283036572e549a5666170d689d7fe4acce","contentType":"application/javascript; charset=utf-8"},{"id":"7f20a033-439f-5735-b60c-e38ff1e13acd","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7f20a033-439f-5735-b60c-e38ff1e13acd/attachment.js","path":"src/compositions/switch-angel-grammar.js","size":1891,"sha256":"fa87e724a9056a79f79cf2f090f4b72f5f0f42e295babceb14f62690b5483e5c","contentType":"application/javascript; charset=utf-8"},{"id":"94833fe5-f21c-52cf-b275-062eb333a528","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/94833fe5-f21c-52cf-b275-062eb333a528/attachment.js","path":"src/compositions/switch-angel-remix.js","size":1772,"sha256":"8fad0e4c90e813034e0dc19da253d6b65e85e59f4f8e94796356104535ff3495","contentType":"application/javascript; charset=utf-8"},{"id":"7e3fdb7b-53ff-515b-919f-b45dc6fd17e3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7e3fdb7b-53ff-515b-919f-b45dc6fd17e3/attachment.js","path":"src/compositions/twin-princes-grammar.js","size":2035,"sha256":"7e6bab1fd581417526d9f3010429903881adb04341efa3ccd1ddcbc4a03913f1","contentType":"application/javascript; charset=utf-8"},{"id":"806e1afc-ae0c-5cdc-9afb-ca37253308bf","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/806e1afc-ae0c-5cdc-9afb-ca37253308bf/attachment.mjs","path":"src/runtime/chunked-render.mjs","size":27961,"sha256":"738b4e827db1c8061690cd4c733bf2e91ec789ddea9ed5d1b618d08f16dffb52","contentType":"text/javascript"},{"id":"8708df35-cde3-54a3-8f21-9b43b6986bec","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8708df35-cde3-54a3-8f21-9b43b6986bec/attachment.mjs","path":"src/runtime/offline-render-v2.mjs","size":21826,"sha256":"8f3859b68dc2f2c779ff99ae384174e5e82be824293c7a408e9b18ab257dac83","contentType":"text/javascript"},{"id":"96612576-9097-5c86-a947-22961b382456","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/96612576-9097-5c86-a947-22961b382456/attachment.mjs","path":"src/runtime/smoke-test.mjs","size":2563,"sha256":"9c4b8c479b9d2ac7a79190fd188d28c6360b2a2111bdcb0b637e107914cf7a57","contentType":"text/javascript"},{"id":"e9048169-50ed-561b-acfc-384ba7e6ec72","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e9048169-50ed-561b-acfc-384ba7e6ec72/attachment.mjs","path":"src/runtime/synth.mjs","size":12784,"sha256":"4fe642d068a091b7c679e474843014e8ea42e6555e8e121e20c3cf13816d7f86","contentType":"text/javascript"},{"id":"a00657eb-444a-5839-a360-4357cf275c0d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a00657eb-444a-5839-a360-4357cf275c0d/attachment.mjs","path":"src/stream/pipe-to-vc.mjs","size":1380,"sha256":"c69446a74db0ee179325da4b5771bda2ebe50c53d1bd50a21b048f3cd1e53bcc","contentType":"text/javascript"}],"bundle_sha256":"e808155e337e0b2c48eb6982606379d4ea3067ba608f45d65b737b3443141eab","attachment_count":79,"text_attachments":79,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":0,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"skills/strudel-music/SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"web-development","category_label":"Web"},"exact_dupes_collapsed_into_this":0},"license":"MIT","version":"v1","category":"web-development","metadata":{"openclaw":{"emoji":"🎵","envVars":[],"install":[{"id":"setup","kind":"script","label":"Install dependencies + download drum samples (~11MB)","script":"npm install && bash scripts/download-samples.sh"},{"id":"ffmpeg","bins":["ffmpeg"],"kind":"apt","label":"Install ffmpeg (audio format conversion)","package":"ffmpeg"}],"requires":{"bins":["node"],"node":">=20","anyBins":["ffmpeg"]},"securityNotes":"Compositions are JavaScript files evaluated by Node.js. They CAN access the filesystem, environment variables, and network. Only run compositions you trust or have reviewed. For untrusted compositions, run in a container or VM with no credentials in the environment.\nDiscord integration (VC streaming, message posting) uses the OpenClaw gateway's existing authenticated connection — this skill does NOT require its own bot token or Discord credentials. No separate authentication is needed.\nThe optional Python pipeline (Demucs, librosa) downloads ML models on first run (~1.5GB for htdemucs). These come from official PyTorch/Facebook sources.\n"}},"import_tag":"clean-skills-v1","description":"Audio deconstruction and composition via Strudel live-coding. Decompose any audio into stems, extract samples, compose with the vocabulary, render offline to WAV/MP3."}},"renderedAt":1782981647929}

⚠️ Legal Notice: This tool processes audio you provide. You are responsible for ensuring you have the rights to use the source material. The authors make no claims about fair use, copyright, or derivative works regarding your use of this tool with copyrighted material. Strudel Music 🎵 Compose, render, deconstruct, and remix music using code. Takes natural language prompts → writes Strudel patterns → renders offline through real Web Audio synthesis → posts audio or streams to Discord VC (via the OpenClaw gateway — no separate credentials needed). Can also reverse-engineer any audio track into…