beats

infinite radio — composed live by claude

ready

scroll to read

I built an infinite radio station that Claude DJs

April 2026

beats.softwaresoftware.dev is a lofi radio station. there's no playlist. no pre-recorded tracks. Claude composes every song in real time, SuperCollider renders the audio on my Linux box, and the browser streams it as MP3. it runs 24/7 as long as the DJ session is alive.

I started this project wanting to answer a simple question: can Claude make music?

the Tone.js dead end

my first attempt used Tone.js. Claude generated JSON song configs, the browser synthesized everything client-side using Web Audio. it worked. you could hear kicks, hats, a TB-303 bassline.

Tone.js synths are basic. a MembraneSynth doesn't sound like a kick drum. a FMSynth set to harmonicity: 1, modulationIndex: 3.5 doesn't sound like a Rhodes. they're approximations, and your ear notices immediately. I spent hours tuning presets, adding reverb, adjusting envelopes. the result was always identifiably "web audio demo."

the real problem was deeper than sound quality. the architecture was wrong. Claude composed a song as JSON, pushed it to the browser via WebSocket, and the browser played it. every listener ran their own synthesizer. no two people heard the same thing at the same time. it wasn't a radio station. it was a sheet music distribution service.

SuperCollider changes everything

SuperCollider is a 30-year-old open-source audio synthesis language. Aphex Twin uses it. it runs headless on a server. its synthesis engine (scsynth) has hundreds of unit generators: physical modeling, granular synthesis, proper FM with arbitrary carrier-modulator ratios. the sound quality is in a completely different category.

I installed it on my Linux workstation.

sudo apt install supercollider-server supercollider-language

the server (scsynth) speaks OSC over UDP. you don't write code that runs inside it. you send messages. "create a synth node with these parameters." "set this node's frequency to 440." "free this node." it's a stateful audio render server controlled entirely by external commands.

there are 17 synthdefs. four drums: kick, snare, hihat, rimshot. six melodic: bass, sub, pluck, lead, acid, FM bell. four sustained: rhodes, pad, supersaw, strings. two textures: vinyl crackle and tape hiss. plus a default fallback. compiled with sclang, loaded by scsynth at boot.

the Rhodes synthdef:

SynthDef(\rhodes, { |out=0, freq=440, amp=0.3, pan=0, gate=1|
    var mod1 = SinOsc.ar(freq) * freq * 2
        * EnvGen.kr(Env.perc(0.002, 0.8));
    var sig = SinOsc.ar(freq + mod1)
        * EnvGen.kr(Env.adsr(0.005, 2, 0.2, 0.3),
            gate, doneAction: 2) * amp;
    sig = HPF.ar(LPF.ar(sig, 3000), 80);
    sig = LeakDC.ar(sig);
    Out.ar(out, Pan2.ar(sig, pan));
});

the plumbing problem

scsynth outputs audio to a sound card. I need it to reach a browser. the solution is five processes chained together:

scsynth → PipeWire/JACK → null sink → parec → ffmpeg → HTTP stream

I create a PipeWire null sink called beats_sc. it's a fake audio device that doesn't connect to speakers. I link SuperCollider's JACK outputs to this null sink using pw-link. then I record from the null sink's monitor with parec, pipe that raw PCM into ffmpeg for MP3 encoding, and serve the encoded stream over HTTP.

the browser plays it with new Audio('/stream'). just an MP3 stream like an internet radio station from 2005.

this took a while to debug.

pw-record was connecting to my webcam microphone instead of the null sink. I could hear myself typing through the stream. parec with --device=beats_sc.monitor also connected to the wrong source because PipeWire's PulseAudio compatibility layer doesn't respect device names the way you'd expect. I had to explicitly disconnect wrong links and reconnect the right ones with pw-link after the process starts.

this bug came back hours later in a worse form. after a server restart, PipeWire auto-linked parec to the system audio monitor instead of the null sink. the stream was recording everything playing on the machine, including the stream itself playing back in the browser. a feedback loop. each cycle added volume and distortion until the lofi station was just a wall of buzzing. the fix: on startup, enumerate every link going into parec:input_*, disconnect anything that isn't beats_sc, then force the correct connection. no more hardcoded device names to go stale.

the bass synthdef used an ADSR envelope with a gate. every note I played created a new synth node that sustained forever. 30 seconds of playback produced a wall of droning bass nodes. I switched to Env.perc so each note fires and dies on its own. same problem hit the Rhodes (1.2-second release tails stacking up on every chord change). I shortened the release to 0.3 seconds.

the sequencer

Claude doesn't send raw OSC messages. it composes a song as JSON:

{
  "title": "rainy window",
  "key": "Dm",
  "bpm": 76,
  "duration": 45,
  "channels": [
    {
      "name": "kick",
      "synth": "kick",
      "type": "trigger",
      "pattern": [1,0,0,0, 0,0,0.7,0,
                  1,0,0,0, 0,0,0,0,
                  0.8,0,0,0, 0,0,1,0,
                  0.9,0,0,0, 0,0,0,0.5]
    },
    {
      "name": "rhodes",
      "synth": "rhodes",
      "type": "sustained",
      "notes": [[146.83, 174.61, 220, 261.63],
                [196, 246.94, 293.66, 349.23]],
      "interval_steps": 16
    },
    {
      "name": "melody",
      "synth": "pluck",
      "type": "melody",
      "pattern": [220, 0, 261.63, 0, 0, 293.66, 0, 0,
                  329.63, 0, 0, 293.66, 0, 261.63, 0, 0]
    }
  ]
}

a Python sequencer on the server converts this to timed OSC messages. every 16th note, it walks through each channel's pattern and fires the appropriate /s_new message to scsynth. there are three channel types: trigger (drums — one-shot nodes), sustained (chords — held with a gate and released on chord change), and melody (individual frequencies per step).

the key field tells the server what scale the song is in. a scale-snapping system catches any out-of-key frequencies and corrects them to the nearest valid note. it's a safety net — Claude should compose correctly using the frequency maps in the skill, but mistakes happen.

the patterns are arrays of numbers. for triggers, 1 means hit, 0 means rest, floats like 0.7 mean velocity. for melodies, each value is a frequency in Hz, 0 is a rest. 32 steps covers two bars. 64 steps covers four. the longer the pattern, the less repetitive the song sounds.

this was the biggest quality lever. 16-step patterns (one bar) sounded robotic and boring. 32-step kick patterns with ghost notes and velocity variation started sounding like actual boom-bap. 64-step melodies with lots of rests created real melodic arcs.

how Claude DJs

Claude runs in a separate terminal session with a channel MCP attached. the channel polls the server every 30 seconds and pushes the state into Claude's conversation. Claude sees "this song has been playing for 25 seconds and nothing is queued" and composes the next track.

I wrote a /dj skill that gives Claude the full song format spec, all 17 synthdefs with their parameters, frequency maps for 8 keys (Am, Cm, Dm, Em, Fm, Gm, C major, F major), and pre-computed chord voicings as raw Hz values. Claude picks a key and a mood, writes the drum patterns with ghost notes and velocity variation, voices the chords as jazz 7ths, and lays a melody over the top.

there are six moods it rotates through: jazzy (classic lofi — Rhodes, boom-bap, pluck melody), ambient (no drums, slow pads, sparse FM bells), dark (sub bass, minor chords, brooding), upbeat (major keys, brighter synths, more energy), minimal (3-5 elements, lots of space), and broken (off-kilter rhythms, polyrhythmic drums, displaced accents). it never plays the same mood or key twice in a row.

transitions

I tried three approaches. crossfading (fade out A, fade in B) creates a volume dip in the middle. channel swapping (remove kick A, add kick B, remove rhodes A, add rhodes B) sounds like instruments appearing and disappearing. playing both songs simultaneously like a real DJ creates a cacophonous mess because the two songs have different drum patterns and keys.

the approach I landed on: the sequencer handles transitions in three phases. during the outro (4 bars), it strips down the current song — drops melodies, thins chords to two notes, reduces volumes. at the cut point, it frees all nodes and loads the new song. during the intro (4 bars), it morphs channel by channel: drums from the old song gradually give way to the new drums (old drums play for the first half, new drums take over in the second half), while chords and melody switch immediately to the new song. the BPM interpolates between old and new across the intro bars.

the result: the listener hears the arrangement thin out, then the harmony shifts, the groove stays constant, and by the time the new drums arrive the new song is already established. Claude doesn't have to compose the transition — the server handles it automatically. Claude just composes distinct songs and the sequencer blends them.

the stack

if you count the processes running when someone listens:

every listener gets the same MP3 stream from a shared in-memory buffer — the encoding happens once regardless of how many people are tuned in.

want to try this yourself? paste beats.softwaresoftware.dev/llm-instructions.txt into your agent of choice — it has the full song format, synth reference, scale maps, and API docs. point it at a local instance and let it DJ.