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.

it sounded like a toy.

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.

I wrote 12 synthdefs in SuperCollider's language: kick, snare, hihat, rhodes, pad, bass, pluck, lead, supersaw, acid, vinyl crackle. compiled them with sclang, and scsynth loads the .scsyndef files at boot.

the Rhodes synthdef is 4 lines:

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));
});

it sounds like an actual electric piano. FM synthesis with a carrier-modulator ratio, percussive modulation envelope, bandpass filtering. four lines.

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'). that's it. no WebSocket, no Web Audio synthesis, no Tone.js. 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.

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",
  "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],
      "params": {"freq": 55, "amp": 0.4}
    },
    {
      "name": "rhodes",
      "synth": "rhodes",
      "type": "sustained",
      "notes": [[146.83, 174.61, 220, 261.63],
                [196, 246.94, 293.66, 349.23]],
      "interval_steps": 16,
      "params": {"amp": 0.1}
    }
  ]
}

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. trigger channels (drums) create one-shot nodes. sustained channels (chords) hold notes with a gate and release them on the next chord change. melody channels play individual frequencies.

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 12 synthdefs with their parameters, a frequency reference table, and five lofi chord progressions in raw Hz values. Claude picks a key, writes the drum patterns with ghost notes and velocity variation, voices the chords as jazz 7ths, and lays a melody over the top.

the songs are good. not "good for AI" good. _actually_ good. "rainy window" is a track I'd genuinely put on in the background. Dm7-G7-Cmaj7-Am7 progression on Rhodes, boom-bap drums with a 32-step pattern that varies across bars, a plucked melody that spans 4 bars before repeating, a quiet counter-melody on a filtered saw lead, a pad layer underneath, vinyl crackle on top.

transitions

this is the part I'm still working on.

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: compose the transition. Claude knows what song A sounds like. it composes song B's opening bars to use song A's drums and BPM. at the cut point, only the chords change. the drums stay identical. then over the next 4 bars, the drums morph into song B's pattern. the listener hears the harmony shift, the groove stays constant, and by the time the new drums arrive, the new song is already established.

this is something a human DJ can't do. a human DJ is stuck with two pre-made tracks. we're composing both sides of the transition, so we can make song B's intro _be_ the transition.

the stack

if you count the processes running when someone listens to beats.softwaresoftware.dev:

  • Claude Code session with beats-dj channel MCP (Node.js)
  • scsynth (SuperCollider audio server)
  • parec (PipeWire audio capture)
  • ffmpeg (MP3 encoding)
  • uvicorn (FastAPI serving HTTP stream + WebSocket metadata)
  • Nginx (reverse proxy)
  • Cloudflare Tunnel (HTTPS edge)

seven processes to turn Claude's JSON into audio in your browser.

the whole thing runs on a single Linux workstation. scsynth uses maybe 3% CPU. the sequencer is a Python thread sleeping between 16th notes. ffmpeg encodes at 128kbps. it's not heavy.

what I'd do differently

the MP3 stream adds 2-3 seconds of latency. the browser's <audio> element buffers aggressively. I'd like to try WebSocket binary frames with opus encoding, which should cut that to under a second. but MP3 works everywhere, and latency doesn't matter much for a radio station.

I'd also like to add drum samples. the synthesized kick and snare are fine. real .wav samples would be better. SuperCollider can load samples into buffers and play them back, so the architecture supports it.

the code is at github.com/softwaresoftware-dev/beats.