Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

claudius

claudius makes music the way a producer does in a DAW. It picks sounds, writes drum patterns, bass lines, chords and melodies with music theory, arranges them into a song with an intro, builds and drops, mixes and masters it, listens to the result, and changes what it does not like.

Every sound is real audio. There is no neural audio generation and no General MIDI: drums and synths are synthesized in code, and the rest comes from recordings (a grand piano, a Rhodes, string sections, real TR-808 and TR-909 drum machines, Apple Loops if you have GarageBand, or your own files). Notes exist only as the score that plays those sounds, like a piano roll.

After every change claudius judges the song: is it loud enough and clean at the top, does the drop hit harder than the breakdown, are the bass notes on the chords, does it sound like the genre it claims to be? Each problem comes with what to change.

Four ways to use it

  • The studio. An app with an arrangement view, step sequencer, mixer, harmony editor and script editor. It runs as a native window and in the browser. See the studio.
  • Scripts. A small language in which a song is a few lines of code: track("drums", s("bd*4, [~ cp]*2")). Any song claudius makes can be printed as a script. See the language reference.
  • An assistant. Connect Claude, ChatGPT or Cursor and ask for a song; it composes, listens, and revises while you hear every change in the studio. See working with an assistant.
  • The command line. claudius make --genre lofi writes a finished song to a folder. See command line.

Where to start

Install

The Mac app

Paste this into Terminal:

curl -fsSL https://github.com/devlato/claudius/releases/latest/download/install.sh | sh

It installs Claudius into Applications, and its command-line programs into ~/.local/bin. It runs on Macs with Apple silicon or Intel and macOS 11 or later, and opening it opens the studio.

You can also download the disk image for your Mac from the latest release: claudius-<version>-aarch64-apple-darwin.dmg for Apple silicon, claudius-<version>-x86_64-apple-darwin.dmg for Intel (the Apple menu, About This Mac, says which you have). Open it, and drag Claudius onto Applications. Until the releases are signed with an Apple Developer ID, though, macOS refuses an app downloaded in a browser the first time: ““Claudius” Not Opened. Apple could not verify “Claudius” is free of malware”. Click Done, open System Settings, Privacy & Security, scroll down to the line about Claudius, and click Open Anyway. macOS asks once more, and then remembers. The command above avoids all this, because macOS checks only what a browser downloads, and curl is not one.

The first time the studio starts, it downloads its listening models by itself and offers to download the sample packs (below). To connect Claude, Cursor or Codex, use ?, Connect an assistant in the studio. Move the app into Applications first: an app opened from its disk image, or from Downloads, runs from a copy macOS takes away again, and claudius will not connect an assistant to it. The app holds the command-line programs too. The command above links them into ~/.local/bin; after dragging the app in yourself, link them like this to use them in a terminal:

mkdir -p ~/.local/bin
ln -s /Applications/Claudius.app/Contents/MacOS/Claudius ~/.local/bin/claudius
ln -s /Applications/Claudius.app/Contents/MacOS/claudius-packs ~/.local/bin/claudius-packs

Linux packages

Every release has packages for x86_64 and ARM64 Linux with glibc 2.38 or newer (Ubuntu 24.04, Debian 13, Fedora 39 and later). Each puts the programs in /usr/bin, the web studio in /usr/share/claudius/web, and Claudius in your desktop’s app menu, which opens the studio. Download the one for your system from the latest release:

sudo apt install ./claudius-<version>-x86_64-unknown-linux-gnu.deb    # Debian, Ubuntu
sudo dnf install ./claudius-<version>-x86_64-unknown-linux-gnu.rpm    # Fedora and its relatives

or the AppImage, which needs no installing: make it executable (chmod +x claudius-*.AppImage), then open it to start the studio, or run it with a command, ./claudius-<version>-x86_64-unknown-linux-gnu.AppImage make --genre lofi. It needs FUSE to run, as every AppImage does, and ALSA for the studio’s sound.

The command line: macOS and Linux

curl -fsSL https://github.com/devlato/claudius/releases/latest/download/install.sh | sh

The script comes with every release, next to the archives, and is in the release’s checksums. It downloads the latest release for your computer from GitHub, checks it against the published SHA-256 sums, and installs it. On Linux that is under ~/.local: the programs claudius and claudius-packs in ~/.local/bin, and the web studio in ~/.local/share/claudius/web. On a Mac it is the app, in /Applications (or ~/Applications when you cannot write to /Applications), with ~/.local/bin/claudius and claudius-packs as links into it; --no-app installs the programs and the web studio under ~/.local instead, as on Linux. It tells you if ~/.local/bin is not on your PATH yet.

When you run it in a terminal, it offers to download the sounds and the ears straight away (below). To do that without being asked, pass --yes:

curl -fsSL https://github.com/devlato/claudius/releases/latest/download/install.sh | sh -s -- --yes

Run as root, it installs the programs and leaves the downloads for later: the packs are archives from other sites, so run claudius setup as the user who makes the music.

CLAUDIUS_INSTALL_DIR puts the programs in another folder (the web studio goes next to it, in ../share/claudius/web), CLAUDIUS_APP_DIR puts the Mac app in another folder, and CLAUDIUS_VERSION=0.1.0 installs a specific release.

Windows

Windows is a preview: its build has no ears yet, so verdicts use meters and music theory.

Download claudius-<version>-x86_64-pc-windows-msvc.msi from the latest release and run it. It needs no administrator: it installs claudius for you in %LOCALAPPDATA%\Programs\claudius, adds its programs to your PATH, and puts Claudius in the Start menu, which opens the studio. The installer is not signed yet, so SmartScreen may say Windows protected your PC: click More info, then Run anyway. Settings, Apps, removes it again.

Or install the programs only, in PowerShell:

irm https://github.com/devlato/claudius/releases/latest/download/install.ps1 | iex

The sounds and the ears

The programs are small. The sounds and the listening models are downloaded once, when you agree to it:

claudius setup
whatsizewhere
14 sample packs: pianos, electric pianos, strings, brass, woodwinds, mallets, basses, guitars, voice, real drum machines2.2 GB to download, 1.2 GB on disk~/Library/Application Support/claudius/packs on macOS, ~/.local/share/claudius/packs on Linux
earshot’s listening models (CLAP)about 0.8 GB~/.cache/earshot-rs

The studio offers the same download of the packs when they are missing, and downloads the models by itself the first time it starts. Each model file is checked against a pinned SHA-256 hash. Without the packs, claudius synthesizes every sound; without the models, the verdict uses meters and music theory only. claudius setup --help lists the options, and claudius-packs list shows every pack with its licence.

claudius uses Apple Loops automatically when GarageBand or Logic content is installed. ffmpeg is optional: with it, songs are also saved as MP3.

Check the installation

claudius doctor
claudius 0.1.0 (macos aarch64)
  ok  web studio      ~/.local/share/claudius/web
  ok  sample packs    14 default packs (1.2 GB) in ~/Library/Application Support/claudius/packs
  ok  ears            models in ~/.cache/earshot-rs
  ok  Apple Loops     14096 loops and samples
  ok  ffmpeg          9.0.2 (writes song.mp3)
  ok  port 7878       free
  ..  Claude Desktop  not connected: `claudius connect claude-desktop`
  ..  statistics      off (`claudius telemetry` explains them)

Everything claudius needs is here.

From source

You need Rust (the version in rust-toolchain.toml; rustup picks it up) and, for the web studio, trunk.

git clone https://github.com/devlato/claudius
cd claudius
cargo install --locked --path crates/claudius-cli
cargo install --locked --path crates/claudius-packs
cargo xtask web          # builds the web studio into crates/claudius-ui/dist
claudius setup

A claudius built from a checkout finds the web studio in that checkout. The development setup covers everything else.

Uninstall

The Mac app: drag Claudius from Applications to the Bin, and delete the links in ~/.local/bin (rm ~/.local/bin/claudius ~/.local/bin/claudius-packs). The command-line install:

rm ~/.local/bin/claudius ~/.local/bin/claudius-packs
rm -r ~/.local/share/claudius/web

The sample packs, the models and the caches are kept, so that reinstalling does not download them again. To remove them too, delete the packs folder claudius doctor shows (~/Library/Application Support/claudius/packs on macOS, ~/.local/share/claudius/packs on Linux), ~/.cache/earshot-rs, and ~/Library/Caches/claudius (~/.cache/claudius on Linux). Songs you saved stay in ~/Music/claudius.

Your first song

This page takes you from a fresh install to a song you made and changed yourself, in about ten minutes. It assumes you have followed Install and have the claudius command.

Check the installation

claudius doctor
claudius 0.1.0 (macos aarch64)
  ok  web studio      ~/.local/share/claudius/web
  ok  sample packs    14 default packs (1.2 GB) in ~/Library/Application Support/claudius/packs
  ok  ears            models in ~/.cache/earshot-rs
  ok  Apple Loops     14096 loops and samples
  ok  ffmpeg          9.0.2 (writes song.mp3)
  ok  port 7878       free
  ..  Claude Desktop  not connected: `claudius connect claude-desktop`
  ..  Cursor          not connected: `claudius connect cursor`
  ..  statistics      off (`claudius telemetry` explains them)

Everything claudius needs is here.

Two things matter for this page: the sample packs (recorded instruments and drum machines) and the ears (the listening models the critic uses). If either is missing, claudius setup downloads them: about 2.2 GB of packs (1.2 GB once unpacked) and 0.8 GB of models, once. Apple Loops are optional (claudius uses them when GarageBand or Logic content is installed), and without ffmpeg you get song.wav but no song.mp3.

Make a song

claudius make --genre house --seed 9

claudius composes a song in the style, arranges it, renders it with real sounds, mixes and masters it, and judges the result. On this machine it printed:

ears loaded in 3.5 s
Crimson Tide — house at 124 BPM in G dorian, 64 bars (2:04)
  progression A: i7 ii7 i7 IV7
  progression B: i7 VIImaj7 i7 IV7
  form: intro 8 | groove 16 | break 8 | drop 24 | outro 8
  tracks: kick (sampled bd (tr909:bd)), clap (sampled cp (tr909:cp)), hat (sampled hh (tr909:hh)), ohat (sampled oh (tr909:oh)), shaker (shaker cut from 'Cosmic Harmony Shaker' (Apple Loops)), perc (sampled rim (tr909:rim)), bass (synth patch 'saw_bass'), chords (synth patch 'stab'), pad (synth patch 'warm_pad'), lead (synth patch 'pluck'), arp (synth patch 'bell'), crash (synth crash), riser (synth riser), impact (synth impact), downlifter (synth downlifter), reverse_crash (synth reverse_crash)
  master: -10.0 LUFS, true peak -1.0 dBTP (pre-master -9.3 LUFS); done in 24.2 s
  ears: 'house music' z +3.2 (closest confusion 'techno music' +3.7); production z +1.6 vs 'a low quality recording with hiss' +1.7; p_real 0.74
        sounds like: techno music, drum and bass music, tech house music, house music
  verdict: 84/100 (arc 0.92, genre 0.58, mix 1.00, production 0.47, theory 1.00). Fix first: sounds about as much like a hissy recording as a professional mix: noise-built sounds (hats, shakers, risers) and long reverbs are the usual cause; sounds more like 'techno music' than 'house music'
  wrote out/house-crimson-tide-9/song.mp3

The same genre and seed give the same song on any machine. What is installed changes only which recordings play: without Apple Loops, the shaker cut from them is synthesized, and without the sample packs so are the 909 drums, but the notes and drum patterns stay the same. Leave out --seed and you get a new song each time.

Open out/house-crimson-tide-9/song.mp3 and listen. Reading the summary while it plays:

  • Crimson Tide: the title the generator chose, the style, tempo, key and length.
  • progression: the chord sequences, as Roman numerals (what those are).
  • form: the sections and their lengths in bars. The drop is the loud part in the middle.
  • tracks: every part and the sound playing it: a real Roland TR-909 for most of the drums, synthesizers for bass, chords, pad, lead and arpeggio, and the transition effects at the end.
  • master: how loud the finished song is, in LUFS (the loudness measure streaming services use), and its highest peak. claudius masters a song to the loudness its style usually has.
  • ears and sounds like: what the listening models hear. The first reading is how clearly the song sounds like house next to the style it is most often taken for, here techno (2.5 is a plausible reading, 3 a strong one); the second compares “professionally mixed” with the closest of six typical flaws. p_real is the models’ guess that this is a real recording, reported and not scored. These are a machine’s impressions: hints, not rules.
  • verdict: the critic’s score out of 100, and the issues that cost the most, each with where it usually comes from. This song sounds about as much like a hissy recording as a professional mix, and a little more like techno than house. Listening, judging, finishing explains every part of it.

The folder holds the song (song.mp3, and song.wav in 24-bit), the song’s full description (blueprint.json) and the same song as a script (song.rhai), the arranged timeline (project.json), the critic’s report (verdict.json), a summary of the render with every track’s loudness (report.json) and credits for the recorded instruments whose licences ask for them (credits.txt).

Change it

A script can edit a saved song. Make a file called edit.rhai:

tempo(122);
part("chords", #{ preset: "piano" });
mix("lead", #{ level: -2, reverb: 0.35 });
section("drop", #{ bars: 32 });

It slows the song down a little, plays the chords on a sampled grand piano instead of a synth stab, pulls the lead back and into more reverb, and makes the drop 32 bars long. Run it on the song:

claudius script edit.rhai --on out/house-crimson-tide-9
ears loaded in 6.2 s
Crimson Tide — house at 122 BPM in G dorian, 72 bars (2:22)
  progression A: i7 ii7 i7 IV7
  progression B: i7 VIImaj7 i7 IV7
  form: intro 8 | groove 16 | break 8 | drop 32 | outro 8
  tracks: kick (sampled bd (tr909:bd)), clap (sampled cp (tr909:cp)), hat (sampled hh (tr909:hh)), ohat (sampled oh (tr909:oh)), shaker (shaker cut from 'Cosmic Harmony Shaker' (Apple Loops)), perc (sampled rim (tr909:rim)), bass (synth patch 'saw_bass'), chords (sampled piano (salamander:piano)), pad (synth patch 'warm_pad'), lead (synth patch 'pluck'), arp (synth patch 'bell'), crash (synth crash), riser (synth riser), impact (synth impact), downlifter (synth downlifter), reverse_crash (synth reverse_crash)
  master: -10.1 LUFS, true peak -1.0 dBTP (pre-master -9.3 LUFS); done in 22.4 s
  ears: 'house music' z +3.4 (closest confusion 'techno music' +3.7); production z +1.6 vs 'a low quality recording with hiss' +1.6; p_real 0.75
        sounds like: techno music, tech house music, house music, drum and bass music
  verdict: 85/100 (arc 0.92, genre 0.63, mix 1.00, production 0.49, theory 1.00). Fix first: sounds about as much like a hissy recording as a professional mix: noise-built sounds (hats, shakers, risers) and long reverbs are the usual cause; sounds more like 'techno music' than 'house music'
  wrote out/house-crimson-tide-9/song.mp3

Everything you did not name stayed as it was. The chords are now sampled piano, the song is 72 bars, and the score moved a point: the critic is a guide, and your ears decide whether the piano is better.

The edited song is written back into the same folder, because its genre, title and seed are the same, so the original files are replaced. To keep both, add title("Crimson Tide (piano)") to the script, or pass --out another-folder.

Read the song as a script

Every song can be written as a script that describes it completely enough to edit, and the folder already has one: song.rhai. An empty script changes nothing, so this prints the same script for any song:

echo "" | claudius script - --on out/house-crimson-tide-9 --print

out/house-crimson-tide-9/song.rhai starts like this:

// Crimson Tide — house (seed 9)
// song("house", 9);   // uncomment to start over from the generator
title("Crimson Tide"); tempo(122); key("G dorian"); swing(0.5257842);
humanize(0.25);
prog("A", "i7 ii7 i7 IV7");
prog("B", "i7 VIImaj7 i7 IV7");
bars_per_chord(1);
master(#{ lufs: -10, ceiling: -1 });

// drums: X accent, x hit, o ghost, . rest, | bar line; 16 steps per bar unless a number follows
drum("kick", "main", "X...X...X...X...");
drum("kick", "sparse", "X...X...X...X...");
drum_sound("kick", "tr909:bd");

and goes on with every drum lane and the sound it plays, every part, the form and the mix. Copy any line into your edit.rhai, change it, and run the script again. Songs as scripts explains the format.

Make a beat from nothing

blank() starts an empty song instead of a generated one. Save this as beat.rhai:

blank("house");
tempo(124); key("A minor");
prog("A", "Am7 Fmaj7 Dm7 Em7"); bars_per_chord(1);
track("drums", s("bd*4, [~ cp]*2, [~ hh]*4"));
track("bass",  chord("prog").root().struct("~ x ~ x ~ x ~ [x x]").s("organ_bass").legato(0.7));
track("keys",  chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano").room(0.25));
form([sec("intro", 8, "drums keys").filter_in(), sec("drop", 16, "*").impact()]);

and render it with claudius script beat.rhai. You hear eight bars of drums with electric piano chords coming into focus as a filter opens on them, then a crash and sixteen bars with an organ bass bouncing between the kicks on the root of each chord. Each line is one idea: the style, the tempo and key, the chords, three tracks, and two sections.

Where to go from here

  • Never made music before? How a track is built starts a short course that ends with a song you understand, line by line.
  • Already make music? claudius for DAW users maps what you know onto claudius.
  • The studio is the same engine in a window: an arrangement view, a step sequencer, a mixer and the verdict updating as you edit.
  • Working with an assistant: claudius connect claude-desktop (or claude-code, cursor) lets an assistant make and edit songs while you listen.
  • The cookbook has complete songs in eight styles.

The studio

claudius studio

opens the studio in its own window. claudius serve runs the same studio in your browser at http://127.0.0.1:7878. Both are views of one session: you can have the window, a browser tab and an assistant working on the same song at once, and each sees the others’ changes as they happen.

Every change is rendered and judged in the background. The arrangement shows when the render you hear is older than the song, and the score badge in the toolbar updates when the new verdict arrives, a few seconds later.

The first time

The first time it opens, a short tour points at the parts of the studio in five cards: making a song, listening, the verdict, the editors, and connecting an assistant, with the Connect buttons right in the last card. The app then asks once whether to send anonymous usage statistics, which stay off unless you say yes (what they are). The tour never blocks the studio. Skip the tour or Esc ends it for good, and ? in the bottom right corner, Take the tour, starts it again.

The first time it starts, claudius downloads its listening models (about 0.8 GB) in the background; the verdict judges with meters and music theory until they are ready, and the log says when they are. If the default sample packs are not installed, the studio says so and offers Download the packs (a 2.2 GB download): until then, the sampled instruments are synthesized. The Sounds tab lists every pack, with a button for each one that is not installed.

Starting a song

New song asks for a genre and, if you like, a seed, tempo and key. The same genre and seed always give the same song, so a seed is a way to come back to one you liked. Open loads a song folder you saved or a .rhai script.

The panels

  • Arrangement. The sections of the song from left to right (intro, build, drop and so on), and below them one row per track. Click a cell, or drag across cells, to switch a track on or off in those sections. Drag a section to move it; right-click it to duplicate, insert or remove one. The waveform of the latest render is underneath; click it to jump.
  • Section. The selected section’s length, energy, drum variant, which chord progression it plays, and the switches for a build, a gap before the next section, an impact, drum fills, and a filter that opens over the section.
  • Steps. A step sequencer for every drum track. Click a step to toggle it, drag to paint, shift-drag to set how hard a hit is.
  • Parts. The bass, chords, pad, lead and arpeggio parts: their writing style, their sound (a synth or a sampled instrument), their range and how busy they are. Reseed rewrites a part.
  • Mixer. Level, pan, reverb, delay and how much each track ducks under the kick; mute and solo; meters from the last render; the master’s loudness and true peak.
  • Harmony. The chord progressions, as chords in the song’s key and as numerals, and which sections play which.
  • Script. The whole song as a script. Edit it and press Cmd+Enter (Ctrl+Enter on Windows and Linux) to run it. The sound browser lists every drum, synth and instrument name.
  • Verdict. The score and its parts, the issues with what to change, and what earshot hears.
  • Log. What happened, and the song’s history.

Mutate tries a few variations and keeps the best one if it beats the current song; Evolve runs several generations of that. Both run in the background and can be stopped. Save writes the song folder.

Help and assistants

? in the bottom right corner opens the help menu: Take the tour, Connect an assistant, Check for updates and Update automatically and the switch for anonymous usage statistics (in the app), the documentation, and a form to report a problem. When a new release has been downloaded, a bar under the toolbar offers Restart to update (Updates). Connect an assistant lists Claude Desktop, Claude Code, Cursor and Codex, says which are connected, and connects one with a click, the way claudius connect does (Working with an assistant). The assistant then makes and edits the song open here, and you hear every change.

Keys

key
Spaceplay or pause
Homeback to the start
Lloop the song or the selected section
← →select the previous or next section
Cmd+Z, Cmd+Shift+Zundo, redo (Ctrl on Windows and Linux)
Cmd+Enterrun the script, in the script editor

When something does not work

claudius doctor checks the installation. If the studio window opens but stays empty, the engine may not have started: claudius serve prints why. Troubleshooting has more.

Working with an assistant

claudius speaks MCP, the protocol assistants use for tools. Once it is connected, you can ask Claude, ChatGPT or Cursor for a song in plain words. The assistant writes a script or starts from a genre, listens through claudius’ critic, reads the issues, and revises, while you listen in the studio and steer.

From the studio

The studio connects them with a click: ? in the bottom right corner, then Connect an assistant (it is also under New song while no song is open). It lists Claude Desktop, Claude Code, Cursor and Codex, says which are connected, and Connect does what claudius connect below does, skills included. An assistant set up for another claudius (an older install, say) offers Use this app. Then restart the assistant, as it says. This is the way for the Mac app, the Windows installer and the Linux packages, whose claudius may not be on your PATH.

The web studio, a page in a browser, cannot change the settings of the apps on a computer, so it lists the commands to run instead.

Claude Desktop

claudius connect claude-desktop

Quit and reopen Claude Desktop. claudius now appears in the tools menu of a new chat. The command adds one entry to Claude’s configuration file and keeps a backup of the old one next to it; --remove takes it out again.

It also writes claudius’ skills as zips into ~/Music/claudius/skills/. Upload them in Claude’s settings, under Capabilities, and Claude uses them in Claude Desktop and on claude.ai.

Claude Code, Cursor and Codex

claudius connect claude-code
claudius connect cursor
claudius connect codex

Each adds the MCP server and installs the skills where that assistant reads them (~/.claude/skills/, ~/.cursor/skills/, ~/.codex/skills/). --no-skills leaves the skills out, and --remove takes both out again.

For any other client, claudius connect print shows the configuration to paste. Clients that can reach a URL can also connect over HTTP to a running studio: http://127.0.0.1:7878/mcp.

With the assistant’s own command

claudius is an ordinary MCP server: the assistant starts claudius mcp. So each assistant’s own way of adding a server works as well:

claude mcp add --scope user claudius -- claudius mcp    # Claude Code, in every project
codex mcp add claudius -- claudius mcp                  # Codex

Cursor adds it from a link: Add claudius to Cursor opens Cursor, which asks before it adds the server. Or put it in ~/.cursor/mcp.json yourself:

{ "mcpServers": { "claudius": { "command": "claudius", "args": ["mcp"] } } }

Claude Desktop has no command for it: in its Settings, choose Developer, then Edit Config, and add the same entry with the full path of claudius (which claudius prints it), since Claude Desktop does not see your shell’s PATH.

These find claudius on your PATH, where the installers put it. The Mac app dragged from its disk image leaves none there: use /Applications/Claudius.app/Contents/MacOS/Claudius in its place. claudius connect print prints every one of these with the full path of the claudius you run it with.

They add the MCP server only. The skills come with claudius connect; claudius skills --out <folder> writes them anywhere else, a folder each, to copy into ~/.claude/skills, ~/.codex/skills or ~/.cursor/skills.

Skills

The MCP tools give an assistant the controls; claudius’ skills give it the craft. They are short guides, in the Agent Skills format, that an assistant reads when the conversation turns to music:

  • claudius-songs: how to make and edit a song: listen, read the verdict, change one thing, and say what changed in musical terms; the script language with recipes; a starting point for each genre.
  • claudius-remix: analysing a recording, remaking its skeleton in another genre, chopping and flipping it.
  • claudius-setup: installing claudius, connecting assistants, and fixing what goes wrong.

claudius connect installs them for Claude Code, Cursor and Codex, and writes zips for the Claude apps. claudius skills --out <folder> writes them anywhere else, for a project’s own .claude/skills/ folder, say. They are built into claudius, so they always match the version you have. For an assistant without skills, such as a ChatGPT project, claudius-songs/SKILL.md works as its instructions.

You and the assistant on the same song

Open the studio first (claudius studio). When the assistant starts claudius, it finds the studio already running and works on the song you have open, so every change it makes shows up in front of you, and you can change things yourself in between. Without a studio running, it starts a session of its own; open http://127.0.0.1:7878 to follow it.

What to ask

Be specific about what you want to hear, the way you would brief a producer:

  • “Make a deep house track at 122 BPM in F minor. Keep the intro sparse and make the drop hit hard.”
  • “The lead is too busy. Simplify it, and put a Rhodes on the chords instead of the synth.”
  • “Listen to it and fix the critic’s two most serious issues. Tell me what you changed.”
  • “Try five variations of the drums and keep the best one.”
  • “Remake ~/Music/reference.mp3 as lofi.”

The assistant can undo anything it does, and so can you (Cmd+Z in the studio).

ChatGPT and other assistants in the cloud

These run on someone else’s computer, so they need a public address. Run claudius with a token behind a tunnel:

TOKEN=$(openssl rand -hex 16); echo "$TOKEN"
claudius serve --public --token "$TOKEN"
cloudflared tunnel --url http://127.0.0.1:7878

Then add a custom connector (in ChatGPT’s developer mode) with the URL https://<your tunnel>/mcp?token=<token>. A shared claudius only reads and writes files in ~/Music/claudius, its caches and the sound libraries; see the security model.

What the assistant can do

The MCP tools page lists every tool. In short: start, open and save songs; read the song and its script; run scripts; render and judge; mutate and evolve; analyze and remake audio files; undo and redo.

How a track is built

This course takes you from no music knowledge to a finished song you understand, one page at a time. Each page adds one layer to the same song, explains the musical idea behind it in plain words, and gives you something to listen to. You need claudius installed (see Install) and a text editor.

Listen first

Before writing anything, listen to a track claudius makes on its own:

claudius make --genre house --seed 9

It takes under a minute. When it finishes, it prints where it wrote the song; open the song.mp3 in that folder and listen with headphones if you have them. Then listen again and try to follow one sound at a time:

  • the kick, the deep thump that comes four times in every count of four;
  • the clap answering it on every second thump;
  • the hi-hats, the short ticking sounds between the thumps;
  • the bass, the low, round notes that move underneath;
  • the chords, several notes played together, which give the music its mood;
  • the melody, a single line of notes on top that you could hum;
  • the pad, a soft, long sound that fills the background in the quieter parts.

Notice also that the song changes over time. It starts thin, fills up, drops to almost nothing in the middle, builds up with a rising sound, and comes back full. Those changes are sections.

Layers and sections

Almost every track in electronic music, and most pop, is built this way:

layerits jobin this course
drumsthe beat you move toBeats
bassweight, and the ground the chords stand onBass lines
chordsharmony: the mood, happy or sad, calm or tenseChords
melodythe part you rememberMelody
structurewhen each layer playsSong structure
mixhow loud each layer is, and where it sitsMixing

Notes and keys (Notes, scales and keys) come between drums and bass, because bass, chords and melody all need them. The last page, Listening, judging, finishing, is about hearing what is wrong and fixing it.

In claudius, a song is a set of tracks (one per layer, or more) and a list of sections, each saying which tracks play in it. You describe both in a script, a short text file, and claudius turns it into audio.

Your first script

Make a file called song.rhai with these two lines:

blank("house");
track("kick", s("bd*4"));

blank("house") starts an empty song. “House” is the style; it decides things you will meet later, like how the finished song is mixed. track("kick", s("bd*4")) adds a track called kick that plays the sound bd (bass drum) four times in every bar. You will learn what a bar is on the next page.

Render it:

claudius script song.rhai

claudius prints a summary and writes the song into a folder such as out/house-untitled-1/. Open song.mp3 there: eight bars of a steady kick drum, about fifteen seconds. That is the start of the song you will build. The studio does the same with no files at all: paste the script into its script tab (see The studio).

On every page, the code is the whole song so far, or a part of it you can add to your file. When a page shows something to try, change one thing, render, and listen to what changed.

Beats: tempo, bars and steps

Play the kick from the last page and tap along with it. Each tap is a beat: the steady pulse you would nod your head to. Everything in this page is about where sounds fall in relation to that pulse.

Tempo

How fast the beats come is the tempo, counted in beats per minute (BPM). Set it at the top of the script:

blank("house");
tempo(120);
track("kick", s("bd*4"));

At 120 BPM there are two beats every second. House music lives around 120 to 126 BPM, hip-hop around 85 to 95, drum and bass around 174. Try tempo(90) and tempo(160) and hear how much the same kick changes character.

Bars

Now count along with the kick: one, two, three, four, one, two, three, four. Each group of four beats is a bar. Musicians count in bars because almost everything in a song repeats in groups of four beats, and bigger ideas come in groups of 4, 8 or 16 bars.

In claudius, one pattern string is one bar. s("bd*4") means “fit four kicks into the bar”, one on each beat. s("bd bd bd bd") is the same thing written out: the words share the bar equally. A kick on every beat is called four on the floor, and it is the heartbeat of house and techno.

The backbeat

Add a clap. Listen for where it lands before reading the explanation:

blank("house");
tempo(120);
track("kick", s("bd*4"));
track("clap", s("~ cp ~ cp"));

The clap falls on beats two and four. ~ is a rest, a beat of silence, so "~ cp ~ cp" reads as rest, clap, rest, clap. Hits on two and four are called the backbeat, and you have heard it in nearly every pop, rock and dance song.

Between the beats

Each beat can be divided into smaller steps. Square brackets squeeze several sounds into one step:

blank("house");
tempo(120);
track("kick", s("bd*4"));
track("clap", s("~ cp ~ cp"));
track("hats", s("[~ hh]*4"));

[~ hh] is a rest and a hi-hat sharing one beat, so the hat lands exactly halfway between two kicks; *4 repeats that in all four beats. Count one AND two AND three AND four AND: the hats are on every AND, the offbeats. This “tss” between the thumps is what makes house sound like house.

The usual ways of dividing a beat:

writtenper barsounds like
bd*44one per beat
hh*88two per beat: one and two and
hh*1616four per beat: one e and a two e and a
[~ hh]*44, between the beatsthe offbeats

Sixteen steps per bar are a common grid. Producers call each one a step, and talk about “a hit on step 5” (the second beat) or “a ghost note on step 8”.

A fuller groove

Add a shaker playing sixteen soft steps, so you can hear the grid filled in:

blank("house");
tempo(120);
track("kick",   s("bd*4"));
track("clap",   s("~ cp ~ cp"));
track("hats",   s("[~ hh]*4"));
track("shaker", s("sh*16").gain(0.4));

.gain(0.4) plays the shaker softly: gain is how hard each sound is hit, from 0 to 1. Soft, busy parts like this make a groove feel alive without getting in the way.

Swing

Straight sixteenth steps sound mechanical. Swing delays every second sixteenth a little, so the steps go long-short, long-short, the way a drummer’s hands do:

blank("house");
tempo(120);
swing(0.58);
track("kick",   s("bd*4"));
track("clap",   s("~ cp ~ cp"));
track("hats",   s("[~ hh]*4"));
track("shaker", s("sh*16").gain(0.4));

0.5 is straight; 0.66 is a full triplet swing. Listen to the shaker: at 0.58 it bounces. House usually swings a little (0.52 to 0.56), lofi hip-hop a lot (0.58 to 0.62).

Try this

  • Make the kick play only on beats one and three: s("bd ~ bd ~"). The groove relaxes into half the energy.
  • Add a kick on the very last sixteenth of the bar, a little push into the next bar: s("bd bd bd [bd ~ ~ bd]"). The last beat is split into four sixteenths, and the extra kick sits on the fourth.
  • Replace the hats with eight-per-bar hats, s("hh*8"), and hear how the feel gets busier.
  • Play the drums on a real drum machine, the Roland TR-909 used on countless house records (this needs the sample packs, which claudius setup installs):
blank("house");
tempo(120);
track("kick", s("bd*4").bank("tr909"));
track("clap", s("~ cp ~ cp").bank("tr909"));
track("hats", s("[~ hh]*4").bank("tr909"));

The song so far

Keep this in your song.rhai; the next pages add to it.

blank("house");
title("First Light");
tempo(120);
track("kick",   s("bd*4"));
track("clap",   s("~ cp ~ cp"));
track("hats",   s("[~ hh]*4"));
track("shaker", s("sh*16").gain(0.4));

Mini-notation has every symbol you can use in a pattern string.

Notes, scales and keys

Drums mostly don’t have a pitch: a kick is a thump, not a note. Bass, chords and melody do. Before writing them, you need three ideas: notes, scales and keys.

Notes

Play this and listen to the sound climbing:

blank("house");
tempo(120);
track("up", note("c4 d4 e4 f4 g4 a4 b4 c5").s("epiano"));

Each sound is a note, and how high or low it sounds is its pitch. Notes are named with the letters a to g. After g the names start again at a, and the number says which round you are in, the octave: c4 to c5 is one octave up. Listen to the first and last note: they sound like the same note, one higher. That sameness is why they share a name.

c4 is middle C, the C in the middle of a piano. Low notes for bass sit around octave 1 and 2 (a1, e2), chords around 3 and 4, melodies around 4 and 5.

Semitones, sharps and flats

The eight notes above are the white keys of a piano. Between most of them there is a black key, a note halfway between. The smallest step between two neighbouring notes, white or black, is a semitone. The in-between notes are written with # (sharp, a semitone up) or b (flat, a semitone down): c#4 is the black key directly above c4, and it is the same note as db4. There are twelve semitones in an octave.

blank("house");
tempo(120);
track("all twelve", note("c4 c#4 d4 d#4 e4 f4 f#4 g4 g#4 a4 a#4 b4").s("epiano"));

Every note in that bar is a semitone higher than the one before. It sounds like stepping up a staircase, with no sense of where it is going. Music usually picks fewer notes than that.

Scales

A scale is a selection of seven (sometimes five) of the twelve notes that sound good together. Listen to two:

blank("house");
tempo(120);
track("major", n("0 1 2 3 4 5 6 7 6 5 4 3 2 1 0 ~").scale("C:major").octave(4).s("epiano"));
blank("house");
tempo(120);
track("minor", n("0 1 2 3 4 5 6 7 6 5 4 3 2 1 0 ~").scale("A:minor").octave(3).s("epiano"));

The first is C major: bright, settled, the “do re mi” you know. The second is A minor: darker, a little sad. They use exactly the same seven notes, the white keys, but A minor starts and ends on A. The note a scale is built on and comes home to is its tonic, and it changes the whole mood.

n() plays a scale by numbers instead of note names: 0 is the tonic, 1 the next note of the scale, and so on; 7 is the tonic an octave up. That is why the same numbers gave two different tunes: they were counted in two scales.

Keys

A song’s key is the scale it uses: “A minor” means the A minor scale, with A as home. Set it for the whole song:

blank("house");
tempo(120);
key("A minor");
track("tune", n("0 2 4 2 0 ~ ~ ~").octave(4).s("epiano"));

n() without .scale() uses the song’s key. The numbers 0, 2 and 4 skip every other note of the scale (A, C, E), which is a pattern you will meet again on the chords page.

Writing notes as numbers in the key has two advantages. Every number is in the scale, so nothing sounds wrong by accident. And if you change the key, everything written this way moves with it: change key("A minor") to key("D minor") and render again. The tune is the same, only lower.

Try this

  • Play the five-note pentatonic scale, which sounds good in almost any order: n("0 1 2 3 4 3 2 1").scale("A:minor:pentatonic").octave(4). Shuffle the numbers; it still works.
  • Try key("A major") with the tune above and hear the same numbers turn bright.
  • Write a short tune of your own with numbers from 0 to 7 and ~ for rests. Start and end on 0 and it will sound finished.

The song so far

The drums from the last page, now in A minor. You won’t hear a difference yet: the key matters from the next page on.

blank("house");
title("First Light");
tempo(120);
key("A minor");
track("kick",   s("bd*4"));
track("clap",   s("~ cp ~ cp"));
track("hats",   s("[~ hh]*4"));
track("shaker", s("sh*16").gain(0.4));

Pitch has every scale name and the exact rules for notes.

Bass lines

The bass is the lowest part of a song. It does two jobs at once: it gives the beat weight (you feel it as much as hear it), and it tells your ear which chord the music is on, by playing that chord’s lowest note. A good bass line locks to the kick drum and moves with the chords.

Low notes

Bass notes live in octaves 1 and 2. Compare:

blank("house");
tempo(120);
track("high", note("a3 ~ ~ ~").s("sub_bass"));
blank("house");
tempo(120);
track("low", note("a1 ~ ~ ~").s("sub_bass"));

Both are the note A. The second is two octaves lower, and on good headphones or speakers you feel it more than you hear it. sub_bass is a pure, round synthesizer bass, which is why it nearly disappears on laptop speakers.

One note per bar

Our song will move through four chords, one per bar: A minor, F, C and G. You will build those chords on the next page. The bass plays the note each chord is named after, its root: A, F, C, G.

blank("house");
tempo(120);
key("A minor");
track("kick", s("bd*4"));
track("bass", note("<a1 f1 c2 g1>").s("sub_bass"));

The angle brackets < > mean “one per bar, in turn”: A in bar 1, F in bar 2, C in bar 3, G in bar 4, then A again. Each note lasts its whole bar. Listen to how the music seems to change mood every bar, even without chords: that is the bass doing its second job.

c2 is higher than a1 and g1, so the line moves down, up, down without big jumps: A, then F a little below it, C above, G below again. Keeping a bass line in a small range makes it smooth.

Rhythm

A bass that holds one note per bar is a drone. Give it a rhythm with .struct(), which takes the notes from one pattern and the timing from another:

blank("house");
tempo(120);
key("A minor");
track("kick", s("bd*4"));
track("bass", note("<a1 f1 c2 g1>").struct("x ~ ~ x ~ x ~ ~").s("sub_bass"));

"x ~ ~ x ~ x ~ ~" divides the bar into eight steps, two per beat, and plays on three of them. Count one and two and three and four and: the notes fall on one, on the and after two, and on the and after three. Each x plays whatever note the bar has. The first note lands with the kick on beat one, so you hear the chord change exactly when the bar starts; the other two fall between kicks, which is what makes the line groove.

Note length

Each note lasts until the next step unless you shorten it. .legato(0.5) makes every note half as long, so the bass bounces instead of sliding from note to note:

blank("house");
tempo(120);
key("A minor");
track("kick", s("bd*4"));
track("bass", note("<a1 f1 c2 g1>").struct("x ~ ~ x ~ x ~ ~").s("sub_bass").legato(0.5));

Bass sounds

soundcharacter
sub_basspure and deep, almost felt rather than heard
organ_bassround with some body; the classic house bass
saw_bassbuzzy and present, cuts through on small speakers
808a deep boom that drops in pitch: hip-hop and trap
reesetwo detuned saws, moving and dark: drum and bass

Swap sub_bass for each and listen. On laptop speakers, organ_bass and saw_bass come through much more clearly, because they have higher overtones on top of the low note.

Try this

  • The house offbeat bass: .struct("~ x ~ x ~ x ~ x"). The bass plays on every and, between the kicks, and the kick and bass take turns.
  • Octave jumps: note("<[a1 a2]*4 [f1 f2]*4 [c2 c3]*4 [g1 g2]*4>").s("saw_bass").legato(0.5) jumps between the root and the same note an octave up, four times a bar: an eighties sound.
  • Start the bass a sixteenth late with .late(0.0625) and hear the groove lean back.

The song so far

blank("house");
title("First Light");
tempo(120);
key("A minor");
track("kick",   s("bd*4"));
track("clap",   s("~ cp ~ cp"));
track("hats",   s("[~ hh]*4"));
track("shaker", s("sh*16").gain(0.4));
track("bass",   note("<a1 f1 c2 g1>").struct("x ~ ~ x ~ x ~ ~").s("sub_bass"));

Chords and progressions

A chord is several notes played at the same time. Chords carry the mood of a song more than anything else: the same beat sounds hopeful over one chord sequence and sad over another.

Three notes at once

On the last page, n("0 2 4") played A, C and E one after another. Play them together and you have a chord:

blank("house");
tempo(120);
key("A minor");
track("arpeggio", n("0 2 4 ~").octave(3).s("epiano"));
track("chord",    n("[0, 2, 4] ~ ~ ~").octave(3).s("epiano"));

The comma inside the brackets stacks the notes instead of playing them in turn. A chord built from every other note of the scale (1st, 3rd and 5th) is a triad, the basic chord of almost all Western music.

Major and minor

Triads come in two main colours. Listen to these two chords, one bar each:

blank("house");
tempo(120);
track("chords", chord("<C Cm>").s("epiano"));

C is C major: bright and stable. Cm is C minor: darker. The only difference is the middle note, one semitone lower in the minor chord. In chord names, a capital letter alone is major (F, G), and m after the letter means minor (Am, Dm).

A progression

A sequence of chords that repeats is a progression. The bass from the last page already played the roots of ours: A, F, C, G. Here are the chords themselves:

blank("house");
tempo(120);
key("A minor");
track("chords", chord("<Am F C G>").s("epiano"));
track("bass",   note("<a1 f1 c2 g1>").struct("x ~ ~ x ~ x ~ ~").s("sub_bass"));

A minor, F major, C major, G major, one bar each. This is one of the most used progressions in pop and dance music: it starts sad, lifts, and the G at the end pulls back to the beginning. The bass plays each chord’s root at the start of its bar, so the two agree.

Telling the song its chords

Writing the chords inside a pattern works, but claudius can do more when the song itself knows its progression. Set it once with prog(), and play it with chord("prog"):

blank("house");
tempo(120);
key("A minor");
prog("A", "Am F C G");
bars_per_chord(1);
track("chords", chord("prog").s("epiano"));

prog("A", ...) names the song’s main progression A. bars_per_chord(1) makes each chord last one bar (a blank song starts that way; a 2 would hold each chord for two bars). chord("prog") plays whatever chord the progression has at each moment. Now the song’s harmony is in one place: other parts can follow it, claudius checks your melody against it, and a second progression for another section is one more line.

A bass that follows

The bass can follow the progression too. .root() makes a chord pattern play only each chord’s root, the note the chord is named after (A for A minor), down in the bass register:

blank("house");
tempo(120);
key("A minor");
prog("A", "Am F C G");
bars_per_chord(1);
track("chords", chord("prog").s("epiano"));
track("bass",   chord("prog").root().struct("x ~ ~ x ~ x ~ ~").s("sub_bass"));

The bass plays A, F, C and G, the same rhythm as before, now taken from the progression. Change the chords in prog() and the bass changes with them.

Rhythm for chords

A held chord is calm. Chords played in a rhythm, called stabs, drive the groove:

blank("house");
tempo(120);
key("A minor");
prog("A", "Am F C G");
bars_per_chord(1);
track("kick",   s("bd*4"));
track("chords", chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano").legato(0.5));

The rhythm x ~ ~ x ~ ~ x ~ puts three chords in each bar, unevenly spaced (3 + 3 + 2 eighth notes). It is one of the oldest rhythms in dance music, and it rubs nicely against the steady kick.

You did not choose which A, C and E to play, or in which order. claudius picks the notes of each chord so they move as little as possible from the chord before. Musicians call that voice leading, and it is why the chords sound connected rather than jumping around.

A pad underneath

A pad is a soft, long sound holding the chords in the background. It fills the space and makes quiet sections feel full:

blank("house");
tempo(120);
key("A minor");
prog("A", "Am F C G");
bars_per_chord(1);
track("pad", chord("prog").s("pad").role("pad"));

.role("pad") tells the mixer what this track is, so it sits quietly behind everything else. You will see roles again on the mixing page.

The numbers in the progression

If you print your song as a script (you will on the last page), the progression shows up as prog("A", "i VI III VII"). These are Roman numerals: they name each chord by its place in the key. i is the chord on the first note of the scale (A in A minor), VI the chord on the sixth (F), and so on; lower case means a minor chord, capitals a major one. Written this way, the progression works in any key: in D minor, i VI III VII is Dm, B♭, F, C.

Try this

  • Seventh chords add one more note on top and sound richer, jazzier: prog("A", "Am7 Fmaj7 Cmaj7 G7").
  • A brighter progression in the same key: prog("A", "F G Am Am").
  • Hold each chord for two bars with bars_per_chord(2). The chords, the pad and the bass all follow.
  • Arpeggiate the chords, playing their notes one after another: chord("prog").struct("x*4").arp("up").s("pluck").

The song so far

blank("house");
title("First Light");
tempo(120);
key("A minor");
prog("A", "Am F C G");
bars_per_chord(1);

track("kick",   s("bd*4"));
track("clap",   s("~ cp ~ cp"));
track("hats",   s("[~ hh]*4"));
track("shaker", s("sh*16").gain(0.4));
track("bass",   chord("prog").root().struct("x ~ ~ x ~ x ~ ~").s("sub_bass"));
track("chords", chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano").legato(0.5));
track("pad",    chord("prog").s("pad").role("pad"));

Pitch lists every chord name claudius understands.

Melody

The melody is the part of a song you could sing back: one note at a time, on top of everything else. In electronic music it is often called the lead, or the hook when it is the catchiest bit.

Notes from the key

Write melodies with n(): every number is a note of the key, so you cannot hit a wrong note. In A minor:

number01234567
noteABCDEFGA (higher)

A first idea, with eight steps in the bar:

blank("house");
tempo(120);
key("A minor");
track("melody", n("4 ~ 2 ~ 0 ~ 2 4").octave(4).s("pluck"));

E, C, A, C, E: it falls and climbs back. .octave(4) puts the melody above the chords; pluck is a short, bright synth sound that cuts through.

Fitting the chords

Play that same bar over all four chords and something sounds off, most of all over the G in bar 4. The reason: each chord is made of three notes, and a melody note that is part of the chord underneath sounds settled, while one that isn’t sounds tense. A little tension is good; too much on the strongest beats sounds like a mistake.

The strong beats are beat 1 and beat 3 of each bar, steps 1 and 5 when the bar has eight steps. Here are the chord notes of our progression as numbers:

chordnotesnumbers
AmA C E0 2 4
FF A C5 0 2 (or 7 9)
CC E G2 4 6
GG B D6 1 3 (or 8 10)

So write each bar for its chord, with a chord note on steps 1 and 5:

blank("house");
tempo(120);
key("A minor");
prog("A", "Am F C G");
bars_per_chord(1);
track("chords", chord("prog").s("epiano"));
track("melody", n("<[4 ~ 2 ~ 0 ~ 2 4] [5 ~ 4 ~ 2 ~ ~ ~] [4 ~ 2 ~ 4 ~ 6 4] [6 ~ 4 ~ 3 ~ ~ ~]>")
                  .octave(4).s("pluck"));

Each pair of square brackets is one bar, and the angle brackets play them in turn. Check bar 2 against the table: F on beat 1 (5) and C on beat 3 (2), both notes of the F chord. The notes in between (the 4 in bar 2 is E) pass through, which is what makes a line move.

Repetition

Listen to the four bars again: bars 1 and 3 start the same way (4, then 2), and bars 2 and 4 both end with a held note. Repeating a short idea, a motif, with small changes is how melodies become memorable. Four bars that are all different are hard to remember; four identical bars are boring. Aim between.

Rests matter as much as notes: the held note at the end of bars 2 and 4 is a breath, and it splits the four bars into a question and an answer.

Echo

A delay repeats a sound a moment later, quieter, like an echo in a canyon. It makes a short melody sound wider and fills the gaps:

blank("house");
tempo(120);
key("A minor");
prog("A", "Am F C G");
bars_per_chord(1);
track("melody", n("<[4 ~ 2 ~ 0 ~ 2 4] [5 ~ 4 ~ 2 ~ ~ ~] [4 ~ 2 ~ 4 ~ 6 4] [6 ~ 4 ~ 3 ~ ~ ~]>")
                  .octave(4).s("pluck").delay(0.2));

.delay(0.2) sends a fifth of the sound to the song’s echo. The echoes come in time with the beat.

Try this

  • Play the melody an octave higher every fourth bar: .every(4, |x| x.add(7)). Adding 7 moves every note up seven steps of the scale, which is one octave, so it still fits the chords.
  • Swap the sound: bell, square_lead, or with the sample packs installed piano, vibes, flute.
  • Add a quieter copy an eighth note behind, an octave up: .off(0.125, |x| x.add(7).gain(0.5)).
  • Write your own four bars using the table. Put chord notes on steps 1 and 5, and anything from the key in between.

The song so far

blank("house");
title("First Light");
tempo(120);
key("A minor");
prog("A", "Am F C G");
bars_per_chord(1);

track("kick",   s("bd*4"));
track("clap",   s("~ cp ~ cp"));
track("hats",   s("[~ hh]*4"));
track("shaker", s("sh*16").gain(0.4));
track("bass",   chord("prog").root().struct("x ~ ~ x ~ x ~ ~").s("sub_bass"));
track("chords", chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano").legato(0.5));
track("pad",    chord("prog").s("pad").role("pad"));
track("melody", n("<[4 ~ 2 ~ 0 ~ 2 4] [5 ~ 4 ~ 2 ~ ~ ~] [4 ~ 2 ~ 4 ~ 6 4] [6 ~ 4 ~ 3 ~ ~ ~]>")
                  .octave(4).s("pluck").delay(0.2));

Render it. Every layer plays all the time, for eight bars: it sounds like the loudest moment of a song, with nothing leading up to it. The next page fixes that.

Song structure

Your song so far plays every layer at once from the first bar to the last. Real songs change: they start small, build, release, breathe, and end. Each stretch of a song with its own set of layers is a section, and the plan of sections is the song’s structure (or form).

Sections in dance music

Listen to the song you made on the first page again (claudius make --genre house --seed 9) and follow its sections:

sectionwhat happenstypical layers
introthe song starts small, often only drumskick, hats
groovethe main beat with bass and chordsmost of the drums, bass, chords
break (breakdown)the drums stop; space and atmospherepad, chords, maybe the melody
buildtension rises towards the dropa snare roll, a rising noise, no kick
dropeverything comes back at onceeverything
outrothe song winds downkick, hats, bass

Sections are usually 4, 8 or 16 bars long. Ears expect change at those points: a section of 7 bars feels like someone tripped.

Writing the form

form() lists the sections in order. Each sec() gives the kind of section, its length in bars, and the tracks that play in it:

blank("house");
tempo(120);
key("A minor");
prog("A", "Am F C G");
bars_per_chord(1);
track("kick",   s("bd*4"));
track("hats",   s("[~ hh]*4"));
track("bass",   chord("prog").root().struct("x ~ ~ x ~ x ~ ~").s("sub_bass"));
track("chords", chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano").legato(0.5));
track("pad",    chord("prog").s("pad").role("pad"));
form([
  sec("intro", 8, "kick hats"),
  sec("groove", 8, "kick hats bass chords"),
  sec("break", 8, "pad chords"),
  sec("drop", 8, "kick hats bass chords pad"),
]);

Thirty-two bars, about a minute. Listen to how much bigger the drop feels after the break, though it adds only the pad to the groove. Taking things away does as much as adding them.

Energy

Every section has an energy, from 0 (silent) to 1 (as intense as the song gets). claudius gives each kind a default (an intro 0.4, a break 0.35, a drop 0.95) and the critic checks that the song really gets louder where the energy rises. You can set it with .energy(0.3) when a section is quieter than its kind suggests.

Making the drop land

Four small touches turn a list of sections into a song that flows. Add them as methods on a section:

  • .filter_in() on the intro: the music starts muffled and opens up across the section, like walking into the room where the party is. (The drums stay clear.)
  • .build() on the section before the drop: a rising noise (a riser) and a sweep that thins out the low end, both growing until the drop.
  • .gap() on that same section: one beat of silence at its very end. The drop hits harder after a breath.
  • .impact() on the drop: a crash cymbal and a boom on its first beat.

A drummer would also play a snare roll into the drop: hits getting faster and louder. Make one as its own track, one bar of each speed, and play it only in the build:

blank("house");
tempo(120);
track("roll", s("<sd*2 sd*4 sd*8 sd*16>").gain("<0.5 0.65 0.8 1>"));
form([sec("build", 4, "roll").build()]);

Two hits in bar 1, four in bar 2, eight, then sixteen, getting louder each bar. Since the angle brackets start over in every section, the roll always begins with the build.

A second progression

The break is a good place for different chords. Sections play progression A unless they say otherwise, and breaks play progression B when the song has one (until now, yours did not, so the break played A). Give the song a B:

blank("house");
tempo(120);
key("A minor");
prog("A", "Am F C G");
prog("B", "F G Am Am");
bars_per_chord(1);
track("pad", chord("prog").s("pad").role("pad"));
form([sec("groove", 8, "pad"), sec("break", 8, "pad")]);

The same pad track plays A in the groove and B in the break, because chord("prog") follows whatever progression the section plays, and so does a bass written with .root(). The melody, though, was written note by note for progression A, so keep it out of the break, or tell a section to play A with .prog("A").

The song so far

The whole song: 60 bars, two minutes at 120 BPM.

blank("house");
title("First Light");
tempo(120);
key("A minor");
prog("A", "Am F C G");
prog("B", "F G Am Am");
bars_per_chord(1);

track("kick",   s("bd*4"));
track("clap",   s("~ cp ~ cp"));
track("hats",   s("[~ hh]*4"));
track("shaker", s("sh*16").gain(0.4));
track("roll",   s("<sd*2 sd*4 sd*8 sd*16>").gain("<0.5 0.65 0.8 1>"));
track("bass",   chord("prog").root().struct("x ~ ~ x ~ x ~ ~").s("sub_bass"));
track("chords", chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano").legato(0.5));
track("pad",    chord("prog").s("pad").role("pad"));
track("melody", n("<[4 ~ 2 ~ 0 ~ 2 4] [5 ~ 4 ~ 2 ~ ~ ~] [4 ~ 2 ~ 4 ~ 6 4] [6 ~ 4 ~ 3 ~ ~ ~]>")
                  .octave(4).s("pluck").delay(0.2));

form([
  sec("intro", 8, "kick hats pad").filter_in(),
  sec("groove", 16, "kick clap hats shaker bass chords"),
  sec("break", 8, "pad chords"),
  sec("build", 4, "clap hats pad roll melody").gap(),
  sec("drop", 16, "kick clap hats shaker bass chords pad melody").impact(),
  sec("outro", 8, "kick hats bass"),
]);

The pad plays in the intro so the opening filter has something to open: the drums are never filtered. The build has no kick: taking the kick away for four bars makes its return in the drop the biggest moment of the song. A section of kind build gets the riser and the sweep without being asked; .gap() adds the breath.

Try this

  • Make the break longer (16 bars) and put the melody in it with .prog("A").
  • Add a short break and a second drop before the outro: sec("break", 4, "pad") and a copy of the drop line. A second section with the same name is renamed drop2 automatically. ("*" instead of a list means every track, which here would include the roll.)
  • Remove .gap() and listen to the drop again. Then put it back.

Form and sections explains every section setting exactly.

Mixing

Mixing is making every part of a song audible and placing it: how loud each track is, how far left or right, how close or far away. A good mix lets you follow any part you choose to listen to.

claudius does a first mix for you. This page shows what it does and how to adjust it.

Roles: the automatic balance

Every track has a role that says what it is: kick, snare, hat, bass, chords, pad, lead and so on. Each role has a loudness it is usually mixed at, and claudius brings every track to that loudness. Kick and bass are loud, hats and shakers quieter, pads quieter still, behind everything.

Most tracks get the right role on their own: a track playing bd is a kick, one playing sub_bass is a bass, a chord() pattern is chords. A melodic track with nothing else to go on is treated as the lead. That is why the pad had .role("pad") on the chords page: without it, the pad would be mixed as loud as a melody.

Level

To change how loud a track is, give it a level in decibels (dB), relative to where its role puts it:

blank("house");
tempo(120);
key("A minor");
prog("A", "Am F C G");
bars_per_chord(1);
track("kick", s("bd*4"));
track("pad",  chord("prog").s("pad").role("pad"));
mix("pad", #{ level: -3 });

level: -3 is three decibels quieter than usual for a pad. Decibels are how loudness is measured: 1 dB is about the smallest change you can notice, 3 dB is a clear step, and 10 dB sounds roughly half as loud. Use small numbers; anything beyond ±6 usually means the track has the wrong role or the wrong sound.

Left and right

Pan places a track between the left speaker (−1) and the right (1). Kick, bass, snare or clap and the lead stay in the middle; hats, percussion and other small parts spread out to the sides, which makes room in the centre:

blank("house");
tempo(120);
track("kick",   s("bd*4"));
track("hats",   s("[~ hh]*4"));
track("shaker", s("sh*16").gain(0.4));
mix("hats",   #{ pan: 0.2 });
mix("shaker", #{ pan: -0.3 });

Listen on headphones: the hats tick slightly to the right, the shaker to the left.

Space: reverb and delay

Reverb is the sound of a room: the reflections that make a sound seem far away. Delay is a distinct echo. Each track sends some of itself to the song’s reverb and delay, from 0 (none) to 1 (a lot):

blank("house");
tempo(120);
key("A minor");
prog("A", "Am F C G");
bars_per_chord(1);
track("chords", chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano").legato(0.5));
mix("chords", #{ reverb: 0.3 });

Short, plucky sounds (stabs, melodies) and pads like reverb; kick and bass sound best dry, close and solid. Too much reverb on everything makes a mix sound washed out and far away.

Ducking under the kick

In dance music, the bass and chords get quieter for a split second every time the kick hits, then swell back. This is called sidechain ducking, and it gives the kick room and the whole track a pumping, breathing feel. Each track has a duck amount from 0 (none) to 1 (the track nearly vanishes on every kick):

blank("house");
tempo(120);
key("A minor");
prog("A", "Am F C G");
bars_per_chord(1);
track("kick", s("bd*4"));
track("pad",  chord("prog").s("pad").role("pad"));
mix("pad", #{ duck: 0.7 });

Listen for the pad pulsing with the kick. Pattern tracks start at 0.3, which you hear as a gentle movement; 0.5 is clearly pumping, 0.8 is the exaggerated pump of future bass.

Listening to one thing

While mixing, it helps to hear one track alone. solo() plays only the soloed tracks, mute() silences one:

blank("house");
tempo(120);
key("A minor");
track("kick", s("bd*4"));
track("bass", note("<a1 f1 c2 g1>").struct("x ~ ~ x ~ x ~ ~").s("sub_bass"));
solo("kick");
solo("bass");

This renders only the kick and the bass, so you can hear whether they fit together. Remove the solo() lines before your final render; the critic judges only what plays.

Try this

  • Turn the melody up 2 dB and down 2 dB, rendering each time. Which one lets you hear the chords?
  • Give the bass duck: 0 and then duck: 0.6, and listen to the low end with the kick.
  • Put a lot of reverb on the clap (reverb: 0.5) and hear it move back into a big room.

The song so far

The mix lines go at the end of the script, after form():

blank("house");
title("First Light");
tempo(120);
key("A minor");
prog("A", "Am F C G");
prog("B", "F G Am Am");
bars_per_chord(1);

track("kick",   s("bd*4"));
track("clap",   s("~ cp ~ cp"));
track("hats",   s("[~ hh]*4"));
track("shaker", s("sh*16").gain(0.4));
track("roll",   s("<sd*2 sd*4 sd*8 sd*16>").gain("<0.5 0.65 0.8 1>"));
track("bass",   chord("prog").root().struct("x ~ ~ x ~ x ~ ~").s("sub_bass"));
track("chords", chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano").legato(0.5));
track("pad",    chord("prog").s("pad").role("pad"));
track("melody", n("<[4 ~ 2 ~ 0 ~ 2 4] [5 ~ 4 ~ 2 ~ ~ ~] [4 ~ 2 ~ 4 ~ 6 4] [6 ~ 4 ~ 3 ~ ~ ~]>")
                  .octave(4).s("pluck").delay(0.2));

form([
  sec("intro", 8, "kick hats pad").filter_in(),
  sec("groove", 16, "kick clap hats shaker bass chords"),
  sec("break", 8, "pad chords"),
  sec("build", 4, "clap hats pad roll melody").gap(),
  sec("drop", 16, "kick clap hats shaker bass chords pad melody").impact(),
  sec("outro", 8, "kick hats bass"),
]);

mix("hats",   #{ pan: 0.2 });
mix("shaker", #{ pan: -0.3 });
mix("bass",   #{ duck: 0.5 });
mix("chords", #{ reverb: 0.25, duck: 0.4 });
mix("pad",    #{ level: -3, reverb: 0.4 });
mix("melody", #{ reverb: 0.3 });

Mixing functions has every setting and the loudness of every role.

Listening, judging, finishing

Your song has every layer, a structure and a mix. This page is about the part producers spend most of their time on: listening critically, finding what is wrong, and fixing it until the song is done.

Render the whole song

Save the song from the mixing page as song.rhai and render it:

claudius script song.rhai

This is what claudius printed for it:

track kick: bd
track clap: cp
track hats: hh
track shaker: sh
track roll: sd
track bass: sub_bass
track chords: epiano
track pad: pad
track melody: pluck
ears loaded in 3.6 s
First Light — house at 120 BPM in A minor, 60 bars (2:00)
  progression A: i VI III VII
  progression B: VI VII i i
  form: intro 8 | groove 16 | break 8 | build 4 | drop 16 | outro 8
  tracks: kick (synth kick), clap (synth clap), hats (synth closed_hat), shaker (synth shaker), roll (synth snare), bass (synth patch 'sub_bass'), chords (synth patch 'epiano'), pad (synth patch 'pad'), melody (synth patch 'pluck'), crash (synth crash), riser (synth riser), impact (synth impact), downlifter (synth downlifter), reverse_crash (synth reverse_crash)
  master: -10.0 LUFS, true peak -1.0 dBTP (pre-master -10.2 LUFS); done in 12.8 s
  ears: 'house music' z +3.4 (closest confusion 'techno music' +3.1); production z +1.2 vs 'a low quality recording with hiss' +0.7; p_real 0.47
        sounds like: house music, tech house music, techno music, drum and bass music
  verdict: 87/100 (arc 0.80, genre 0.80, mix 0.99, production 0.63, theory 1.00). Fix first: dull 4-8 kHz: hats, claps and snare lack bite (presence -26.4 dB, healthy from -26)
  wrote out/house-first-light-1/song.mp3

Line by line:

  • track …: each track and the sounds it found.
  • ears loaded: claudius loaded its listening models (the first run downloads them; later runs take a few seconds on an idle machine, longer on a busy one).
  • First Light: title, style, tempo, key, length.
  • progression: your chords, written as Roman numerals.
  • form: your sections and their lengths.
  • tracks: every track and what plays it. The last five are the transition sounds your section flags asked for: the riser in the build, a reversed cymbal swelling into the drop, and a crash, an impact and a falling “downlifter” on the drop’s first beat.
  • master: how loud the finished song is. claudius makes it as loud as songs in the style usually are.
  • ears and sounds like: what the listening model hears. More below.
  • verdict: the critic’s score, and what to fix first.

Listen before you read

The score is useful, but your ears come first. Listen to the whole song once without stopping, on the best speakers or headphones you have, then on something small (a phone or a laptop). Note anything that bothers you: a part you can’t hear, a part that is too loud, a moment that drags. Then read the verdict and see whether it agrees.

The verdict

The critic listens in three ways and gives each a score from 0 to 1:

  • mix: measurements of the finished audio. Is it as loud as it should be, without distortion? Is the sound balanced from deep bass to bright treble? Is it wide enough, and is the bass centred?
  • arc: does the song get louder where it should? It compares the loudness of each section with its energy, and checks that the drop is clearly louder than the break.
  • theory: the notes. Is the melody in the key? Are its strongest notes part of the chords? Does the bass play the chords’ roots when they change?

When the listening models are loaded (they are by default), two more:

  • genre: does it sound like the style you chose, more than the styles it is usually confused with?
  • production: does it sound like a professionally produced record, more than like a cheap or broken recording?

With --no-ears, or on a computer the listening models cannot run on (claudius then says earshot is unavailable), the critic judges with the first three only, and the score is out of those.

The total out of 100 combines them. The Fix first list names the problems that cost the most, with numbers and a hint. Ours has one:

  • dull 4-8 kHz: hats, claps and snare lack bite (presence -26.4 dB, healthy from -26). The bright, crisp range where hats and claps live is a little quiet compared with the rest. The fix is in the hint: turn the hats and clap up.

The ears line says the song reads as house (z +3.4) a little more than as its closest confusion, techno (z +3.1). The genre model’s readings are hints, not rules: two styles that share a tempo or a drum sound often sit close together for it.

Fix and render again

One change for the one problem: the hats and the clap 2 dB louder.

blank("house");
title("First Light");
tempo(120);
key("A minor");
prog("A", "Am F C G");
prog("B", "F G Am Am");
bars_per_chord(1);

track("kick",   s("bd*4"));
track("clap",   s("~ cp ~ cp"));
track("hats",   s("[~ hh]*4"));
track("shaker", s("sh*16").gain(0.4));
track("roll",   s("<sd*2 sd*4 sd*8 sd*16>").gain("<0.5 0.65 0.8 1>"));
track("bass",   chord("prog").root().struct("x ~ ~ x ~ x ~ ~").s("sub_bass"));
track("chords", chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano").legato(0.5));
track("pad",    chord("prog").s("pad").role("pad"));
track("melody", n("<[4 ~ 2 ~ 0 ~ 2 4] [5 ~ 4 ~ 2 ~ ~ ~] [4 ~ 2 ~ 4 ~ 6 4] [6 ~ 4 ~ 3 ~ ~ ~]>")
                  .octave(4).s("pluck").delay(0.2));

form([
  sec("intro", 8, "kick hats pad").filter_in(),
  sec("groove", 16, "kick clap hats shaker bass chords"),
  sec("break", 8, "pad chords"),
  sec("build", 4, "clap hats pad roll melody").gap(),
  sec("drop", 16, "kick clap hats shaker bass chords pad melody").impact(),
  sec("outro", 8, "kick hats bass"),
]);

mix("hats",   #{ pan: 0.2, level: 2 });
mix("clap",   #{ level: 2 });
mix("shaker", #{ pan: -0.3 });
mix("bass",   #{ duck: 0.5 });
mix("chords", #{ reverb: 0.25, duck: 0.4 });
mix("pad",    #{ level: -3, reverb: 0.4 });
mix("melody", #{ reverb: 0.3 });

The new verdict:

  ears: 'house music' z +3.4 (closest confusion 'techno music' +3.1); production z +1.2 vs 'a low quality recording with hiss' +0.7; p_real 0.47
        sounds like: house music, tech house music, techno music, drum and bass music
  verdict: 87/100 (arc 0.80, genre 0.79, mix 1.00, production 0.64, theory 1.00)

The mix score went from 0.99 to 1.00, and the critic has nothing left to fix first. The total stays at 87: it weighs all five scores, and the mix is only one of them. That is a typical round: a small change, a point or less, and a problem gone.

Knowing when to stop

A score in the high 80s means the critic found no big measurable problem. It does not mean the song is good; it has no taste, only measurements. Some things it cannot tell you: whether the melody is catchy, whether the drop is exciting, whether the song is too long. So:

  • Fix what the critic finds when you can hear the problem too.
  • Ignore a suggestion that makes the song worse to your ears. The genre model, in particular, is a hint.
  • Stop when the song does what you wanted and nothing bothers you on three different speakers.

As an example of the critic’s limits: the same song played on a real 909 drum machine with a sampled piano and strings (.bank("tr909"), .s("piano"), .s("string_ensemble")) scores four points lower than the version with synthesizers, because the genre model hears it as closer to drum and bass. Whether it sounds better is your call.

Keep and share it

  • The audio: song.mp3 and song.wav in the song’s folder are the finished, mastered song.
  • The script: song.rhai in that folder is the song as claudius prints it, which you can read and edit. Your own song.rhai stays the clearest description of what you made.
  • The rest: blueprint.json is the song in full detail, verdict.json the critic’s whole report.

To see your song as claudius describes it, print it instead of rendering: claudius script song.rhai --print. You will find your progressions as Roman numerals and your tracks exactly as you wrote them.

Where to go next

  • The studio: the same song in a window, with a step sequencer, a mixer and the verdict updating as you edit.
  • Working with an assistant: ask Claude or another assistant to make and fix songs with you.
  • The cookbook: complete songs in eight styles, with every choice explained.
  • For producers: the same ideas in more depth, once you are comfortable with these.

claudius for DAW users

If you work in Ableton, Logic, FL Studio or Bitwig, most of claudius will be familiar: tracks, an arrangement in sections, a mixer with sends and a sidechain, a master chain with a limiter. What differs is how you edit them. There is no free timeline of clips: the arrangement is a grid of sections and tracks (the studio lets you drag sections and paint which tracks play in each), and the notes come from patterns and parts. You describe the song (in a script, in the studio’s panels, or through an assistant), claudius arranges, renders and judges it, and you listen and revise. A render of a three-minute song takes 10 to 40 seconds.

Where things are

in a DAWin claudius
projecta song: its blueprint (blueprint.json, the editable description) and the project it arranges into (project.json, notes and clips on a timeline)
arrangement viewthe form: sections in order, each listing the tracks that play in it
clip or regiona track in a section; it plays from the section’s first bar
MIDI clip and piano rolla pattern (note(), n(), chord()), or a part the theory engine writes
drum rack and step sequencera drum lane with step strings and named variants, or an s() pattern
instrumenta sound: a synth preset, a sampled instrument, a drum kit, an audio file
mixer channelmix(): level, pan, two sends, sidechain depth, mute, solo
channel insertsfilters, bitcrusher and saturation on pattern tracks; the genre’s chains elsewhere
groups and busesfour fixed buses: drums, bass, music, fx
return tracksone reverb and one delay return, set by the genre
sidechain compressorduck on each track, keyed from the kick
master chain and limiterthe genre’s master chain and a loudness stage aimed at a LUFS target (master())
automationsection flags: a lowpass opening in an intro, a highpass sweep in a build, a lowpass closing the outro
markerssection names
groove templatesswing() (sixteenth swing) and humanize()
exportsong.wav (24-bit, 48 kHz), song.mp3, and stems with --stems
undo historyundo and redo in the studio; every song also records its lineage of edits

There is one tempo, one key and one time signature (4/4) per song. There is no MIDI input, MIDI export or live recording.

The same groove, two ways

A house groove programmed in a step sequencer:

blank("house");
tempo(124);
drum("kick", "X...X...X...X...");
drum("clap", "....X.......X...");
drum("hat",  "..x...x...x...x.");
drum("hat", "sparse", "......x.......x.");

And in pattern form:

blank("house");
tempo(124);
track("drums", s("bd*4, [~ cp]*2, [~ hh]*4"));

They sound alike but behave differently. Drum lanes are what the generator writes: they have named variants that sections switch between (main, sparse, alt), and the arranger rewrites them for builds (a snare roll, the kick dropping out) and phrase-end fills. Pattern tracks are never rewritten; they give you the whole pattern language (alternation, euclidean rhythms, randomness, sampled kits such as tr909:bd), and you write the variation yourself. To put a drum lane on a recorded kit, drum_sound("kick", "tr909:bd") swaps its sound and keeps its pattern. Most of the cookbook uses patterns.

Levels are relative

A DAW fader is an absolute gain. In claudius every track has a role (kick, bass, pad, lead …), every role has a target loudness, and each track is gained to its target while it plays. level is an offset from that target. A pad at level: -3 sits 3 dB below where a pad normally sits, whatever its patch or velocity, and adding a track does not change the balance of the others.

The consequence: gain staging is done for you, and the first mix decision is the role. A track’s role comes from its sound: drums and percussion find theirs, a bass preset or a low line is bass, a chord pattern is chords, and anything else melodic is treated as a lead. Set .role("pad") or .role("arp") on pattern tracks that are not leads.

Sections replace clips

A DAW lets every clip differ. In claudius a track plays the same material in every section it is in, starting from the section’s first bar: an alternation starts again from its first item, every(4, ...) counts from the section, the progression starts on its first chord. To vary a part in one section, make a second track and put it only there:

blank("house");
tempo(124); key("A minor"); prog("A", "Am7 Fmaj7 Dm7 Em7"); bars_per_chord(1);
track("drums",  s("bd*4, [~ cp]*2, [~ hh]*4"));
track("keys",   chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano"));
track("keys16", chord("prog").struct("x*16").legato(0.4).s("epiano"));   // the busy version, drop only
form([sec("groove", 16, "drums keys"), sec("drop", 16, "drums keys16")]);

Harmony is written once

A DAW has no idea what key you are in. claudius does: the song has a key and named progressions in Roman numerals (prog("A", "i7 VImaj7 iv7 v7")), sections choose a progression, and chord patterns, generated parts and the critic all read it. Write chord("prog") and the chords follow whatever progression the section plays; change the key and everything written in numerals or scale degrees moves with it. See Harmony.

The loop you work in

  1. Write or change the song: a script, an edit in the studio, or a request to an assistant.
  2. claudius arranges and renders it (10 to 40 seconds for a full song), then judges it.
  3. Listen. Read the verdict: scores for mix, energy arc, theory, genre and production, with each issue naming what to change.
  4. Revise, or let evolution try variations for you.

Every song prints as a script (Songs as scripts), so whatever produced it (the generator, the studio, an assistant), you can read it and edit it as text.

Harmony

claudius knows the song’s key and its chord progressions, and everything harmonic reads them: chord patterns, the parts the theory engine writes, arpeggios, the bass check of the critic. This page covers how to write harmony so that all of those agree, and exactly how claudius interprets it. The full rules for symbols and numerals are in Pitch.

Key and mode

A song has one key, in one of seven modes: major, minor (aeolian), dorian, phrygian, lydian, mixolydian, harmonic minor. Roman numerals are counted in the key’s own mode, so the same numeral changes quality with the mode:

numeralA minorA dorianA phrygianA harmonic minor
IIBBB♭B
IVDDDD
ivDmDmDmDm
VEEEE
VIFF♯FF
VIIGGGG♯

Upper and lower case only choose major or minor; they never move the root. IV in A minor is a D major chord (borrowed from dorian) on the fourth degree, iv the diatonic D minor. Accidentals move the root: bVII in C major is B♭, bVI is A♭.

Writing a progression

blank("deep_house");
key("F minor");
prog("A", "Fm9 Bbm9 Dbmaj7 Cm7");     // stored as i9 iv9 VImaj7 v7
prog("B", "Dbmaj7 Eb Fm9 Fm9");
bars_per_chord(1);
track("keys", chord("prog").struct("x ~ ~ x ~ ~ ~ ~").voicing(53, 72).s("epiano"));
form([sec("groove", 8, "keys"), sec("break", 8, "keys")]);

Chord names are converted to numerals in the key that is current when prog() runs, then stored as numerals. Set the key first; a later key() transposes the progression (and changes chord qualities if the mode changes, see below).

Progressions have names. Sections play A by default and breaks B (or A, when the song has no B); any other name works with sec(...).prog("verse"), and a section that asks for a progression the song does not have is an error. Every progression uses the same bars_per_chord(), the number of bars each chord lasts. A progression starts on its first chord at the start of every section and loops.

What numerals can express:

  • diatonic triads and sevenths by degree and case: i7 ii° III iv7 v7 VImaj7 VII;
  • extensions: 9, add9, 6, maj7, maj9, 11 (always minor 11), sus2, sus4;
  • borrowed chords with accidentals: bVI bVII in major, IV (dorian) or V7 (harmonic) in minor;
  • applied chords: V/V, V7/ii, viio7/V, read in the key of the target chord;
  • a bass note: I/E, VI/A, or as a name, F/A (stored as VI/A in A minor). .root(), the generated basslines and the critic use it, and key() moves it with the chord.

All of these work in pattern strings too: chord("<I V7/V V7 I>"), chord("<Am F/A C G>").

Chord patterns

chord("prog") plays the section’s progression; chord("prog:B") always plays B; chord("<Am7 Fmaj7>") plays the named chords regardless of the song’s progressions. Prefer prog: the arranger’s chord track, the generated parts and the critic all use the song’s progressions, so a pattern with its own chord names can disagree with all of them.

Every chord event is voiced with voice leading (see Voicing):

  • three voices for triads, four for sevenths and anything larger; the fifth goes first when there are too many notes, then the root;
  • close voicings (within about an octave and a half), placed in the range given by .voicing(lo, hi) (default 52..74, E3 to D5);
  • each chord chosen to move the voices as little as possible from the previous one, the first chord of a section near the middle of the range.

There is no drop-2 or spread voicing for patterns, and no control over inversions beyond the range. A narrow range forces inversions: .voicing(60, 72) keeps every chord inside one octave above middle C.

Rhythm comes from the events: .struct("x ~ ~ x ~ ~ x ~") for a tresillo stab, "x*16" with a short legato for a pumping sixteenth chord, .arp("updown") for an arpeggio. Layering two tracks on chord("prog") with different ranges and rhythms (a pad low and long, stabs higher and short) keeps them in the same harmony automatically.

Bass lines

Generated bass parts follow the progression and the kick (see the bass styles of part()). A pattern bass follows the progression with .root(), which plays each chord’s root (or its slash bass) in octave 2:

blank("techno");
key("F phrygian");
prog("A", "i i II i");
bars_per_chord(2);
track("bass", chord("prog").root().octave(1).struct("[~ x x x]*4").s("sub_bass").legato(0.6));

.octave(1) puts the roots an octave lower: F1 for four bars, G♭1 for two, F1 for two. Change the progression and the bass follows. When the line should move by step or leap to a different octave, write the notes out instead: note("<f1!4 gb1!2 f1!2>") plays the same roots, one per bar.

On a blank song, part() adds the genre’s bass part over your progression: part("bass", #{ style: "rolling" }).

The critic checks that the bass plays the chord’s root (or its slash bass) on at least 70 % of the chord changes it plays on; a bass that plays offbeats only is not checked.

Changing key

key() moves everything written relatively: numerals, n() degrees, generated parts. Note names in note() patterns are absolute and stay. When the mode changes, every diatonic chord keeps its degree and takes the new mode’s quality: Imaj7 vi7 ii7 V7 in E major becomes i7 VImaj7 iiø7 v7 in F minor. Borrowed (bVII) and applied (V/V) chords are kept as written.

The generator’s harmony

song(genre, seed) picks a harmonic style from the genre’s profile, or writes a progression with a functional-harmony Markov chain (tonic, subdominant and dominant degrees, no chord repeated twice in a row, ending on a chord that leads back to the start). The styles:

stylemodeexample progressions
minor_epicminori VI III VII, VI VII i i, i iv VI VII
minor_darkminori i VI VII, i VI iv V, i v VI iv
minor_soulminori7 iv7 VII7 IIImaj7, i7 VImaj7 iv7 v7, iiø7 V7 i7 i7
dorian_vampdoriani7 IV7, i7 IV9 i7 VIImaj7, i7 ii7 i7 IV7
phrygianphrygiani II, i II i VII, i vii i II
harmonicharmonic minori iv V i, i VI V V, i VI iv V
minimalminori, i i i VI, i i iv iv, i9 i9 i9 VII
major_popmajorI V vi IV, vi IV I V, I vi IV V
major7_jazzmajorImaj7 vi7 ii7 V7, ii7 V7 Imaj7 Imaj7, ii9 V9 Imaj9 vi9
minor_80sminori VI III VII, i VII VI VII, i III VII IV
mixolydianmixolydianI VII IV I, I VII, I IV VII IV

Progression B is another progression of the same style or a substitution of A. Evolution changes harmony with these substitutions, each recorded in the song’s lineage: a chord a third away, an extended or plain version, a secondary dominant before a chord, a chord borrowed from the parallel mode, a suspension, two chords swapped, the progression rotated.

What the critic checks

The theory score reads the arranged song, not the audio:

checkpasses when
melody in key95 % of the lead’s notes are in the key
chord tones on strong beats60 % of the lead’s notes on beats 1 and 3 are tones of the chord sounding there
rangethe lead spans 19 semitones or less
leapsat most 20 % of the lead’s moves (between notes less than two beats apart) are larger than a fifth
bass on the roots70 % of the bass notes that start on a chord change are the chord’s root
voice leadingin chord, pad and keys tracks, voices move 4.5 semitones or less per change on average

“The lead” is the first track whose role is lead, and “the bass” the first whose role is bass; the chords are the song’s progressions, as the arranger lays them out. That is why chord("prog") and .role() matter: a melody checked against the wrong chords, or a pad checked as a melody, costs points for no musical reason.

Rhythm and groove

Rhythm in claudius has three layers: where notes sit (patterns and step strings), how hard they are played (velocity), and how the grid is bent when the song renders (swing and humanize). This page gives the exact behaviour of each and the idioms that come out of them.

The grid

Pattern time is continuous: a cycle is one bar, and mini-notation divides it however you like. Common subdivisions:

feelmini-notationstep string
quartersbd*4x...x...x...x...
eighthshh*8x.x.x.x.x.x.x.x.
sixteenthshh*16xxxxxxxxxxxxxxxx
offbeat eighths[~ hh]*4..x...x...x...x.
eighth tripletshh*1212 steps per bar: drum("hat", "main", "xxxxxxxxxxxx", 12)
sixteenth tripletshh*2448 steps per bar, every second step
thirty-secondshh*3232 steps per bar

Drum lanes (drum()) are a step grid with 16 steps per bar by default, or any resolution from 1 to 96. When a lane prints as a script, it uses 16 steps if every hit sits on a sixteenth, then 32, then 48.

Velocity

wherehow
step stringsX 1.0, x 0.8, o 0.45 (ghost), 1 to 9 for 0.1 to 0.9
patterns.gain() or .velocity(), per event: .gain("[0.5 0.3 0.9 0.3]*4")
defaults0.9 for sound patterns, 0.8 for pitched patterns

Velocity drives level and tone the way the instrument does. Synthesized drums and audio files scale level by velocity to the power 1.6 (0.5 is about 10 dB below 1.0); the sampled kits switch velocity layers (the 909 has eleven) as well; synth presets open their filters. The track as a whole is then levelled to its role’s target, so velocity shapes the groove, not the track’s loudness.

A sixteenth hat with accents on the off-beat eighths and soft ghosts between:

blank("house");
tempo(124);
track("hats", s("hh*16").bank("tr909").gain("[0.35 0.2 0.8 0.2]*4"));

Swing

swing(amount) is sixteenth-note swing for the whole song:

  • each off-beat sixteenth (the second of each pair inside an eighth note) moves to amount of the way through the eighth; 0.5 is straight, 0.66 is a triplet feel; the range is 0 to 0.8, and up to 0.501 is straight;
  • only notes within 0.01 beats of a sixteenth grid position move, so eighth notes, triplets and notes nudged with .late() are untouched;
  • it applies at render to every track: drum lanes, parts and pattern tracks alike.

The genres’ generated swing ranges are a good guide: house 0.50–0.56, deep house 0.53–0.58, lofi 0.56–0.63, trap 0.50–0.54, techno 0.50–0.52, drum and bass 0.50–0.51, future bass 0.50–0.53, synthwave straight.

Since swing is global, a part that should sit straight in a swung song has to avoid off-beat sixteenths (use eighths or triplets), and a part that should lag can use .late(0.01) to .late(0.03), which also takes it off the grid that swing moves.

Humanize

humanize(amount) adds random timing and velocity at render:

  • timing: a normal distribution with a standard deviation of 4 ms × amount on drum roles, 2.4 ms × amount on others (synth tracks: 3 ms and 1.8 ms);
  • velocity: ±5 % × amount (one standard deviation);
  • seeded by the song and the track name, so every render is the same.

Generated songs use 0.25 in house, 0.35 in deep house, 0.6 in lofi, 0.15 in trap and 0.1 elsewhere. blank() keeps the genre’s value.

Idioms

Ghost notes are low-velocity hits between the main ones. In a step string, o; in a pattern, a quiet layer in a stack(), since a gain pattern applies by time and would turn down the backbeat too:

blank("lofi");
tempo(86); swing(0.6);
track("snare", stack([s("~ sd ~ sd").gain(0.9), s("~ ~ ~ [~ sd] ~ ~ [~ sd] ~").gain(0.3)]).bank("acoustic"));

Rolls are fast repeats inside one beat. Write the bar beat by beat, with the roll in the beat that has it:

blank("trap");
tempo(140);
track("hats", s("<[hh*2 hh*2 hh*2 hh*2] [hh*2 hh*2 hh*2 hh*6] [hh*2 hh*2 hh*2 hh*2] [hh*2 hh*4 hh*2 hh*8]>")
                .bank("tr808").gain("[0.9 0.6]*4"));

Bar 2 ends with a sixteenth-triplet roll (six in a beat), bar 4 with thirty-seconds. Generated trap hats get rolls from the drum grammar instead: random beats replaced by bursts of thirty-seconds or triplets with a rising or falling velocity ramp, most often on the last beat of a bar and rarely on the first.

Euclidean rhythms spread k hits over n steps as evenly as possible, which gives most of the world’s clave-like patterns: (3,8) is the tresillo (x..x..x.), (5,8) the cinquillo (x.xx.xx.), (5,16) and (7,16) are good percussion lines over a four-on-the-floor kick. A third number rotates the rhythm left by that many steps (see Euclidean rhythms).

blank("techno");
tempo(130);
track("kick", s("bd*4").bank("tr909"));
track("rim",  s("rim(5,16,2)").bank("tr909").gain(0.6).pan(0.3));
track("perc", s("cb(3,8,1)").gain(0.4).pan(-0.3));

Polymeter. There is no { } polymeter, but a fractional fast() runs a short figure across bar lines. A three-step figure on a sixteenth grid is three sixteenths long, so it repeats 16/3 times per bar:

blank("techno");
track("perc", s("rim ~ ~").fast(16.0 / 3.0).gain(0.5));   // lands one sixteenth earlier each bar

It restarts with every section, like every pattern.

Kick and bass together. A bass that plays exactly with the kick uses the kick’s rhythm as its structure. Keep the rhythm in a variable so the two cannot drift apart:

blank("trap");
tempo(140); key("C minor");
let kick = "x ~ ~ ~ ~ ~ ~ x ~ ~ x ~ ~ ~ ~ ~";
track("kick", s("bd").struct(kick).bank("tr808"));
track("808",  note("<c2!2 ab1!2 f1!2 g1!2>").struct(kick).s("808").legato(1.0));

Generated parts do the same with the kick_follow bass style, which plays on every kick of the main kick pattern.

Drum lanes in the arrangement

Drum lanes change with the sections they play in:

  • each section plays the lane’s variant it asks for (main, sparse, alt, half …), or main when the lane does not have it; intros and outros ask for sparse;
  • fills() replaces the last one or two beats of every 8-bar phrase on the snare (or clap) lane with a fill, and sometimes drops the kick for the last half beat;
  • build() turns the snare (or clap) lane into a snare roll for the second half of the build and drops the kick for its last bar.

Form and sections has the exact rules. Pattern tracks are never rewritten; make fills with lastOf() and builds with a track that only plays in the build:

blank("house");
tempo(124);
track("drums", s("bd*4, [~ cp]*2, [~ hh]*4").bank("tr909").lastOf(8, |x| x.ply(2)));
track("roll",  s("<sd*2 sd*2 sd*4 sd*4 sd*8 sd*8 sd*16 sd*16>").bank("tr909")
                 .gain("<0.4 0.45 0.5 0.55 0.65 0.75 0.85 1>"));
form([sec("groove", 16, "drums"), sec("build", 8, "roll").gap(), sec("drop", 16, "drums").impact()]);

The generator’s grooves

Generated drum lanes come from each genre’s drum grammar: for every lane, a probability of a hit on each sixteenth step (1.0 always hits), a velocity range scaled by the metrical weight of the step, a chance of ghost notes, a chance of rolls, and a chance that the last bar of the 4-bar pattern varies. Some lanes use a euclidean rhythm instead (house percussion is a rotated (5,16)). The same seed gives the same grooves; mutation edits single hits, nudges them by a sixteenth, re-accents them or regenerates a lane.

Sounds, samples and sound design

claudius plays two kinds of audio: sounds synthesized in code and sounds that were recorded. There is no General MIDI and no neural audio generation; a note is only ever the score that plays one of these sounds. This page covers where each comes from, how genres choose them, and how much of them you can shape. The names you can use are in the Sounds reference.

Synthesized

Drum voices. Thirteen drum circuits rendered in code: kick, snare, clap, closed and open hats, ride, crash, tom, rim, shaker, cowbell, snap and a tuned percussion voice. Each has nine parameters: tune, decay, tone, punch, snap, drive, pitch-envelope depth and time, and room. The generator draws them from the genre’s ranges (a house kick decays in 0.32–0.55 s with 3–8 dB of drive) and, in genres that ask for it, tunes the kick’s fundamental to the tonic or fifth of the key. A variation (bd:3) moves the voice a few steps through its parameters.

The synth engine. Oscillators with polyBLEP anti-aliasing (saw, square, pulse, triangle, sine), unison with per-voice analog drift, a sub oscillator and noise, FM with a tine component for electric pianos, state-variable and ladder filters with an envelope, key tracking and velocity, an LFO for vibrato, filter and amplitude (free or tempo-synced), glide, mono and legato modes, and drive. Twenty factory presets cover basses (sub_bass, 808, reese, acid, wobble …), keys (epiano, keys, organ), pads, plucks, leads and bells. A generated part takes a copy of a preset with its cutoff and detune varied a little; evolution tweaks cutoff, resonance, release, drive and detune further.

Transitions. Risers, downlifters, impacts, reverse crashes and filter sweeps are rendered at the length the arrangement asks for, with the riser pitched to the key. The crash is a drum voice or a recorded one-shot.

claudius sounds renders every drum voice, preset and transition into out/sounds/ for auditioning.

Recorded

Sample packs. Multi-sampled instruments and drum machines under licences that allow making and publishing music with them. claudius-packs install --defaults installs fourteen packs (a 2.2 GB download, 1.2 GB on disk):

packwhatlicence
salamanderYamaha C5 grand pianoCC BY 3.0
epianosWurlitzer, CP80, PianetCC BY 3.0
jrhodesRhodes Mark Ifree for music
vsco2chamber orchestra: strings, brass, woodwinds, harp, timpaniCC0
vcslmallets, pipe organ, sax, harmonica, world percussionCC0
tr808, tr909, tr707Roland drum machinesCC0; free use; public domain
uzua drum kitUnlicense
muldjordan acoustic drum kitCC BY 4.0
freepatsupright piano, organ, guitars, basses, synth pads and leadsCC0
doublebass, growlybassupright and electric bassCC0
voicea sung vowel and a choirCC0

The instruments have velocity layers and round robins (the Salamander piano has five layers; the 909 kick eleven layers and 126 variations). A song that plays a pack whose licence asks for attribution gets a credits.txt in its folder. claudius-packs list shows what is installed, claudius-packs audition renders every instrument, and claudius-packs add-map github:user/repo adds any Strudel sample map, recording its licence (or warning when it has none).

Apple Loops. When GarageBand or Logic content is installed, claudius finds it where Apple put it and uses it three ways: as one-shots cut from drum loops for the drum lanes of generated songs (each lane uses a recorded hit with a 75 % chance in claudius make), as texture loops (house’s top percussion loop), and, with --loops or song(genre, #{ loops: true }), as the backbone of a song built around one loop family: its chords, bass and beat, warped to the tempo and transposed to the key. Apple licenses the loops for use in your own music; claudius reads them in place and only caches the one-shots it cuts (in ~/Library/Caches/claudius). In patterns, s("bd sd").bank("apple") plays one-shots cut from them.

Your recordings. .s("/path/file.wav") plays a file as a sampler (pitch by playback speed, c4 as recorded). claudius chop song.mp3 cuts bar loops and drum one-shots from any recording into your library, and --find "a vocal chop" uses the ears to pull out moments that match a description. claudius make --sample file flips a recording into a new song: warped to the tempo, transposed to the key, re-sliced in half-beat pieces (or played straight) and placed in the high-energy sections. See Remaking a song from audio.

How genres choose sounds

Every genre profile lists, per part, the presets and instruments it may use, most preferred first where it matters: "rhodes|epiano" plays the sampled Rhodes when its pack is installed and the FM electric piano when not. Lofi reaches for Rhodes, upright bass, vibes and kalimba; house for 909s, organ basses, stabs and piano; trap for an 808, bells and pizzicato. Drum lanes are played on a recorded machine with a genre-specific chance (909 in 45 % of house songs and 50 % of techno songs, 808 in 60 % of trap songs, the acoustic kit in 30 % of lofi and drum and bass songs) and stay synthesized otherwise. With the packs or Apple Loops missing, those sounds fall back to synthesis and the rest of the song is unchanged: the same seed writes the same notes and drum patterns either way.

Shaping a sound

From a script you choose sounds; you do not edit them. What you can do:

  • pick a preset, an instrument, a kit or a file, per track;
  • play it differently: velocity, note length, register, glide (overlapping notes on mono presets);
  • process a pattern track with .hpf(), .lpf(), .crush() and .distort() (see Inserts), and place it with level, pan and the two sends;
  • swap a generated part’s preset with part("chords", #{ preset: "piano" }), which keeps its notes, and a drum lane’s sound with drum_sound("kick", "tr909:bd"), which keeps its pattern.

Synth patch parameters, drum voice parameters, bus chains and the master chain are set by the generator and the genre, and changed by mutation (patch tweaks, drum voice edits, moving a song’s drums to another kit). A filtered, crushed piano:

blank("lofi");
key("D minor"); prog("A", "Dm9 Gm9 Bbmaj7 A7"); bars_per_chord(1);
track("keys", chord("prog").voicing(50, 72).s("piano").lpf(2500).crush(10).room(0.3));

Sounds and the critic

The ears hear recordings and synthesis differently. Songs built only from synthesis read as less “professionally mixed” to earshot than songs with sampled instruments, and as more “computer-generated”. When the production score is low, swapping a synth part for a recorded instrument (piano, Rhodes, strings, a real drum machine) is usually the most effective change. The genre score responds to the genre’s signature sounds: a 909 for house and techno, an 808 for trap, a dusty kit and a Rhodes for lofi.

Arrangement and energy

The arrangement is the form: a list of sections, each with a kind, a length, an energy and the tracks that play in it. In the studio it is the grid of tracks against sections; in a script it is form(). The arranger then does what a producer does by hand: tiles the parts across their sections, switches drum variants, adds fills at phrase ends, rolls and risers into drops, crashes on downbeats, filter sweeps in intros and outros.

Forms by genre

The generator picks one of the genre’s forms. They are a useful starting point for your own:

genreform (bars)
houseintro 16, groove 16, break 16, build 8, drop 32, break 8, drop 16, outro 16 (or a 64-bar form)
deep houseintro 16, groove 16, break 16, build 8, drop 32, outro 16
technointro 32, groove 32, break 16, build 8, peak 32, outro 16
trapintro 8, hook 16, verse 16, hook 16, verse 8, hook 16, outro 8
lofiintro 4, A 16, B 16, A 16, outro 8
drum and bassintro 16, build 16, drop 32, break 16, drop 32, outro 16
future bassintro 8, verse 16, build 8, drop 16, break 8, build 8, drop 16, outro 8
synthwaveintro 8, verse 16, chorus 16, verse 16, chorus 16, bridge 8, chorus 16, outro 8

song(genre, #{ short: true }) halves every section (to at least 4 bars), which is handy for sketching.

The tools for energy

Energy in a track comes from what plays, how dense it is, and the transitions between sections. claudius gives you:

  • layers: which tracks play in each section, the strongest tool;
  • drum variants: a lane’s sparse, main, alt or half pattern per section;
  • progressions: a break on progression B changes colour without changing key (a song without a B plays A there);
  • flags: filter_in (a lowpass opening across the section), build (riser, highpass sweep, snare roll, kick out for the last bar), gap (a beat of silence), impact (crash, impact, downlifter on the downbeat), fills (drum fills every 8 bars);
  • automatic touches: a crash every 8 bars in drops and hooks, and a lowpass closing over a final outro.

The exact rules (how long the roll is, which tracks a gap silences, where fills fall) are in What the flags do.

Energy values and the critic

Each section’s energy (0 to 1) is a statement of intent, and the critic holds you to it. It measures the master’s short-term loudness (3-second windows, every half second), takes the median of each section (skipping its first 1.5 seconds and last second), and checks:

  • that loudness rises and falls with energy: the correlation between the two across sections, which sets most of the arc score;
  • that the loudest drop or hook is at least 3 LU louder than the quietest break (aim for 3 to 7).

A low arc score means the energies and the layers disagree. The usual causes:

  • an intro with the full kick: drums are never filtered by filter_in, so an intro at energy 0.4 can be as loud as a groove at 0.65; give the intro fewer drum layers or a higher energy;
  • a build without a kick that is quieter than the break before it: either keep the kick until the build’s last bar (drum lanes do that by themselves), or give the build a lower energy;
  • a break that keeps the bass or the drums: the drop cannot be 3 LU louder than a break that is almost as full.

Building an arrangement

On a generated song, form() rewrites the arrangement with the song’s own tracks:

form([
  sec("intro", 16, "kick hat perc pad").filter_in(),
  sec("groove", 16, "kick clap hat ohat perc bass chords"),
  sec("break", 16, "pad chords lead").energy(0.3),
  sec("build", 8, "clap hat pad lead chords").gap(),
  sec("drop", 32, "*").impact(),
  sec("break", 8, "pad arp").build(),
  sec("drop", 16, "*").energy(1.0).impact(),
  sec("outro", 16, "kick hat perc bass"),
]);

The second break plays progression B (breaks do by default) and builds back into drop2 with a riser, a highpass sweep and a roll on the clap lane (this song has no snare lane). The two drops are named drop and drop2.

Smaller edits leave the rest alone:

section("drop", #{ bars: 32 });                  // a longer drop
section("break", #{ layers: "pad lead" });        // a sparser break
layer("groove", "shaker", true);                  // the shaker in the groove too
section("intro", #{ variant: "main" });           // the full drum patterns in the intro

Patterns in the arrangement

A pattern track plays its pattern from the first bar of each section it is in. It is not rewritten by builds or fills, so a song made of patterns arranges its own builds:

blank("house");
tempo(124); key("A minor"); prog("A", "Am7 Fmaj7 Dm7 Em7"); bars_per_chord(1);
track("kick",  s("bd*4").bank("tr909"));
track("hats",  s("[~ hh]*4").bank("tr909"));
track("clap",  s("~ cp ~ cp").bank("tr909"));
track("roll",  s("<sd*2 sd*2 sd*4 sd*4 sd*8 sd*8 sd*16 sd*16>").bank("tr909")
                 .gain("<0.4 0.45 0.5 0.55 0.65 0.75 0.85 1>"));
track("keys",  chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano"));
track("pad",   chord("prog").s("pad").role("pad"));
form([
  sec("intro", 8, "kick hats keys").filter_in(),
  sec("groove", 16, "kick hats clap keys"),
  sec("break", 8, "pad keys").energy(0.3),
  sec("build", 8, "hats clap roll pad").gap(),
  sec("drop", 16, "kick hats clap keys pad").impact(),
]);

The roll track only plays in the build, rising from half notes to sixteenths over its eight bars, and the build flag adds the riser and the highpass sweep. The break plays progression A, since this song has no B. Because the roll’s alternation starts from its first item in every section, it lines up with the build however long the sections before it are.

Length and phrasing

The critic expects a song of 90 to 330 seconds, and every section a whole number of 4-bar phrases; anything else costs form points. Fills fall every 8 bars from the start of a section, and drops and hooks crash every 8 bars, so sections of 8, 16 or 32 bars keep phrases, fills and crashes aligned.

Mixing and mastering

A claudius mix starts where an engineer’s first pass ends: every track at a sensible level for what it is, the kick ducking what should breathe, everything on a bus with glue, and a master landing on a loudness target under a true-peak ceiling. Your work is the second pass. This page explains the signal flow, the genre chains, and how to act on the critic’s mix report. The functions are in Mixing functions.

Signal flow

For every track, in this order:

  1. the sound plays its notes, with swing and humanize;
  2. its inserts run: the genre’s (a chorus on some pads, tape on lofi keys) or the pattern’s (.hpf() .lpf() .crush() .distort());
  3. it is measured while it plays and gained to role target + genre offset + level (limited to −40..+30 dB);
  4. it is ducked by the kick by duck;
  5. it is panned;
  6. it feeds its bus, and the reverb and delay returns by its sends.

Then each bus runs its chain, the buses and the returns (ducked 30 % by the kick) are summed, the master chain runs, and the loudness stage brings the result to the target.

Starting values

Roles first: a track’s role sets its level, and the critic’s theory checks read roles too (see Roles). Then these are reasonable starting points; the genre profiles use similar values for generated parts:

tracklevelducksends
bass00.45–0.65 (house, techno), 0 (trap 808 with its own kick pattern)none
chords, stabs0 to −20.35–0.45; 0.85 for a future-bass pumpreverb 0.2–0.35, delay 0.05–0.3
pad−2 to −40.4–0.5reverb 0.35–0.45
lead00.15–0.3reverb 0.25–0.35, delay 0.2–0.25
arp−20.3–0.4delay 0.25–0.3, pan ±0.15–0.2
hats, percussion00pan ±0.1–0.35, a little delay on percussion
clap, snare00reverb 0.15–0.3

Bus chains by genre

Besides what every song gets (a tanh saturator on the drums bus unless the genre already saturates there, the bass bus mono below 140 Hz, the music bus at width 1.25 and mono below 160 Hz), each genre has its own chains:

genredrumsbassmusic
housecompressor −12 dB, 2:1, 12/120 mshighpass 110 Hz
deep housecompressor −14 dB, 1.8:1highpass 100 Hz, high shelf −1.5 dB at 9 kHz
technocompressor −12 dB, 2.5:1, 10/100 ms; tanh saturationhighpass 130 Hz
trapcompressor −10 dB, 2:1, 8/90 mstanh saturation 3 dBhighpass 140 Hz
lofibitcrusher (11 bits, half rate, 35 %), lowpass 8.5 kHz, compressor −14 dB, 2.5:1highpass 90 Hz
drum and basscompressor −11 dB, 2.5:1, 8/90 mstube saturation 4 dBhighpass 140 Hz
future basscompressor −11 dB, 2.2:1highpass 130 Hz
synthwavecompressor −12 dB, 2:1highpass 110 Hz

The music bus highpass is why pads and chords never fight the bass: anything on the music bus below 90–140 Hz is gone. Give a part the bass role (and so the bass bus) only if it is the bass.

The master

genremaster chaintarget
househighpass 25 Hz, high shelf +1 dB at 10 kHz; compressor −10 dB, 1.6:1; tape saturation−10 LUFS
deep househighpass 25 Hz; compressor −11 dB, 1.5:1; tape saturation−11
technohighpass 28 Hz; compressor −10 dB, 2:1; tanh saturation 3 dB−9
traphighpass 24 Hz; compressor −9 dB, 1.8:1; tanh saturation−9
lofihighpass 30 Hz, high shelf −2.5 dB at 8 kHz; tape (wow, flutter, lowpass 11 kHz); vinyl crackle and hiss; compressor −14 dB−12.5
drum and basshighpass 26 Hz; compressor −9 dB, 2:1; tanh saturation−8.5
future basshighpass 26 Hz, high shelf +1.5 dB at 10 kHz; compressor −9 dB, 1.8:1; tanh saturation−9
synthwavehighpass 25 Hz; compressor −11 dB, 1.6:1; tape saturation−10.5

After the chain, the loudness stage searches for the input gain that lands the song on the target (within 0.15 LU), through a soft clipper 0.1 dB under the ceiling and a limiter 0.5 dB under it, then trims if the true peak is still over. master() changes the target (−24 to −5 LUFS) and the ceiling (−3 to −0.1 dBTP, default −1).

Reading the mix report

The critic measures the master and reports each problem as an issue with its numbers. The mix score starts at 1 and loses a little for each:

checkhealthyissue text begins
loudnesswithin 1 LU of the targetloudness -11.6 LUFS, target -10.0
true peakat most 0.1 dB over the ceilingtrue peak -0.6 dBTP over the -1.0 ceiling
sub, 20–60 Hz−14 to −4 dB of the totaltoo much sub (boomy) / not enough sub
low, 60–250 Hz−7 to −1 dBthe low end is too heavy / a thin low end
low mids, 250 Hz–1 kHz−12 to −4 dBmuddy 250 Hz-1 kHz / hollow low mids
mids, 1–4 kHz−18 to −8 dBboxy or harsh mids / recessed mids
presence, 4–8 kHz−26 to −15 dBharsh 4-8 kHz / dull 4-8 kHz
air, 8–16 kHz−30 to −17 dBfizzy top end / no air above 8 kHz
stereocorrelation 0.15 to 0.96nearly mono / too wide / phasey
low endcorrelation at least 0.9 below 150 Hzlow end not mono

The band values are each band’s share of the master’s total energy, in dB, so they move together: cutting the sub raises everything else’s share. What usually fixes each:

  • too much sub, thin low end: the bass sits too low or is too pure. Lower its level, move it up an octave, or use a bass with harmonics (saw_bass, organ_bass, reese instead of sub_bass); a long 808 kick adds sub too.
  • muddy low mids: pads and chords voiced low. Raise their .voicing() range or lower their level.
  • recessed mids: the lead and chords are buried; raise them, or choose brighter sounds (piano over a warm pad).
  • dull presence, no air: hats, claps, shakers and rides too quiet or too dark. Raise their levels, add an open hat or a ride. Lofi’s master darkens the top end on purpose, so it needs louder hats than other genres.
  • fizzy top end: hats and bright synths (supersaws) too loud; lower the hats or .lpf() the synth.
  • nearly mono: everything important sits in the middle. Pan the hats and percussion, raise the reverb and delay sends of chords and pads (the returns are wide), prefer sampled stereo instruments for pads.
  • low end not mono: a stereo recording below 150 Hz on the drums bus, usually the acoustic kit’s kick (the drums bus is not made mono). Use a 909 or 808 kick.

The ears add a production score: how strongly the master reads as “a professionally mixed and mastered music track” against six named flaws (a cheap synthesized beep, a low quality recording with hiss, a distorted clipped recording, a computer-generated electronic tone, a low bitrate mp3 with artifacts, a recording made on a phone). It moves with sound choice more than with mix settings: sampled instruments and real drum machines help most, and heavy distortion or a buzzy synth lead pull it towards the flaws.

Stems

claudius make --stems and claudius render blueprint.json --stems write every track as its own WAV into stems/, after its level, sidechain and pan and before the buses, so the stems sum to the mix without the bus chains, the returns and the master.

Coming from Strudel or TidalCycles

claudius borrows Strudel’s mini-notation and many of its pattern methods, so most of your patterns carry over. The model around them is different: a Strudel program is a live loop, and a claudius script describes a song (sections, an arrangement, a mix) that is rendered and judged. This page lists every difference that changes what you write.

The same groove in both

Strudel:

setcpm(124/4)
stack(
  s("bd*4, [~ cp]*2, [~ hh]*4").bank("RolandTR909"),
  note("<a1 f1 d2 e2>").struct("~ x ~ x ~ x ~ [x x]").s("sawtooth").lpf(800),
  n("<0 2 4 6>").scale("A:minor").s("piano").room(0.5)
)

claudius:

blank("house");
tempo(124); key("A minor");
track("drums", s("bd*4, [~ cp]*2, [~ hh]*4").bank("RolandTR909"));
track("bass",  note("<a1 f1 d2 e2>").struct("~ x ~ x ~ x ~ [x x]").s("saw_bass").lpf(800));
track("keys",  n("<0 2 4 6>").scale("A:minor").octave(4).s("piano").room(0.5));

Each pattern becomes a named track, the tempo is in BPM, and the sounds are claudius’s: saw_bass is a synth preset, piano a sampled grand, and the Strudel bank name RolandTR909 is an alias of the tr909 pack.

Time

  • One cycle is one bar, always. There is no setcps or setcpm; tempo(bpm) sets the song’s tempo, and a cycle lasts four beats of it.
  • Patterns play inside sections. A track plays in the sections that list it, and every section starts the pattern from cycle 0: alternations, every() and random choices start over. There is no global clock running through the song.
  • Queries return whole events whose onset falls in the span, never fragments. An event longer than a bar exists only in the bar where it starts, though it still sounds for its full length, and .struct(), .mask() and the controls still find it in the bars after (see Patterns and time).

Syntax

Strudel (JavaScript)claudius (Rhai)
x => x.fast(2)|x| x.fast(2)
stack(a, b, c)stack([a, b, c])
cat(a, b)cat([a, b])
silencesilence()
$: s("bd*4")track("kick", s("bd*4"))
'single quotes'"double quotes"
samples('github:user/repo')claudius-packs add-map github:user/repo on the command line

Mini-notation

Most of it is identical. The differences (full list):

  • { } is a plain group: no polymeter, no %. A fractional .fast() runs a figure across bar lines instead (see Polymeter).
  • . is not a separator; it is an error.
  • Weights inside < > are ignored: write <a!3 b> to hold a for three cycles.
  • * and / take numbers, not patterns, above 0 and up to 256 (so do fast(), slow() and ply()).
  • a ! 3 is three as.
  • | and , at the same level group differently; bracket the choice: [a | b], c.

Euclidean rhythms are Strudel’s: bd(3,8), bd(5,8,2) and .euclid(7, 16) give the same steps in both. So do slash chords and applied chords, which stay one word: chord("<Am F/A C G>"), chord("<I V7/V>").

Values and controls

  • n() is always scale degrees. In Strudel, n() without .scale() is a sample index (or a note number). In claudius, n("0 2 4") without a scale uses the song’s key, and the sample index is bd:3 or the method .n("0 3") on an s() pattern.
  • .transpose() is always semitones; .add() adds to the value. On note() both move semitones; on n(), .add(2) moves two scale degrees and .transpose(12) moves an octave. On chord(), .add() moves the voicing range and .transpose() the voiced notes (see add() and transpose()).
  • .gain() is velocity. It is how hard a note is played (0 to 1), not an amplitude multiplier: claudius levels every track to its mixing role, so the track’s loudness is set with .level(dB) or mix(). .velocity() is the same control and wins when both are set.
  • Note names default to octave 3, as in Strudel: note("c") is C3, MIDI 48.

Effects are per track

Strudel effects are controls on each event; claudius effects belong to the track:

Strudelclaudius
.room(0.5), .size(0.8).room(0.5) is a send to the song’s reverb, whose size the genre sets
.delay(0.5), .delaytime(), .delayfeedback().delay(0.5) is a send to a tempo-synced delay set by the genre
.pan(0.7), 0 to 1.pan(0.4), −1 to 1 with 0 in the centre
.lpf("<400 2000>").lpf(800), one number per track
.crush(4), .distort(0.5).crush(bits), .distort(dB)
.orbit(), .duckorbit().duck(depth): sidechain from the kick

A patterned effect argument is an error: .lpf("<400 2000>") raises lpf needs a number. Filter movement comes from the arrangement instead (an intro’s filter_in, a build’s highpass sweep).

Sounds

  • .s() on a note pattern sets the track’s instrument; it takes one name, not a pattern, so a note pattern cannot switch instruments from event to event. Use two tracks.
  • sawtooth, square, triangle and sine are not sounds; use a synth preset (saw_bass, pluck, square_lead, sub_bass …).
  • Drum names are the usual bd sd cp hh oh rd cr lt mt ht rim cb sh. Without .bank() they play claudius’s synthesized drum voices; with .bank("tr909") (or RolandTR909, 808, acoustic …) the recorded machines. A bank can also go in the word, s("tr909:bd*4, tr808:cp*2"), to mix machines in one pattern; a number after the last colon is the sample index (tr808:sd:3).
  • A sound pattern with several names is split into one track per name when arranged (drums.bd, drums.hh), so each is mixed by its own role.

Methods

Available, with the same meaning: fast slow late early rev ply every lastOf sometimes often rarely almostNever almostAlways sometimesBy off superimpose degrade degradeBy struct mask euclid euclidRot gain velocity legato add transpose n s sound bank scale voicing arp. Differences in detail:

  • ply() and every() take integers; .every(4, f) is the first bar of every four, like Strudel’s firstOf.
  • .arp() takes a mode ("up", "down", "updown"), not an index pattern.
  • .voicing() takes a MIDI range; voice leading is always on.
  • Randomness is seeded by the code, not by the cycle alone: two tracks with the same pattern make the same choices.

Not available: continuous signals (sine, saw, rand, perlin, range, segment), jux, sample slicing (chop, striate, slice, splice, begin, end, speed, loopAt, fit), chunk, iter, palindrome, echo/stut, when, ADSR and filter-envelope controls, vowel, and the live-coding commands (hush, setcps).

What claudius adds

  • A song: sections with energies, builds, gaps, impacts and fills.
  • Harmony the song knows about: prog(), Roman numerals, chord("prog"), and chord("prog").root() for a bass line that follows it.
  • Parts written by music theory, drum grammars and genre profiles (song()).
  • A mix: roles, target levels, sidechain, buses, a mastered result at a LUFS target.
  • A critic that tells you what to fix, and evolution that tries fixes for you.

Genres, mutation and evolution

A genre in claudius is a data file: the production knowledge of a style written down as tempo ranges, harmonic styles, forms, drum grammars, part writers, sounds, transitions and mix targets. The generator composes from it, the critic judges against it, and evolution mutates songs within it. This page covers all three.

The genres

claudius genres lists them:

genreBPMswingmodesmasterkits usedears listen for (and against)
house120–1260.50–0.56minor, dorian, major−10 LUFS909 (45 %), 707 (10 %)house music (techno, minimal, corporate pop, drum and bass)
deep_house118–1240.53–0.58minor, dorian, major−11909 (30 %), 808 (20 %)house music (techno, minimal, lo-fi hip hop, jazz)
techno126–1340.50–0.52minor, phrygian, dorian−9909 (50 %), 808 (15 %)techno music (house, minimal, drum and bass, dubstep)
trap130–1500.50–0.54minor, harmonic minor, phrygian−9808 (60 %)trap beat (heavy-bass hip hop, lo-fi hip hop, dubstep, house)
lofi70–900.56–0.63major, minor, dorian−12.5808 (30 %), acoustic (30 %)lo-fi hip hop beat (jazz, heavy-bass hip hop, solo piano, trap)
dnb170–1760.50–0.51minor, dorian, phrygian−8.5acoustic (30 %)drum and bass music (dubstep, techno, house, trap)
future_bass140–1600.50–0.53major, minor−9808 (30 %)future bass music (dubstep, corporate pop, trap, house)
synthwave85–118straightminor, dorian−10.5707 (40 %), 808 (20 %)synthwave music (corporate pop, chiptune, house, ambient pad)

The “against” list matters: the genre score is high only when a song reads as its genre more than as the styles it is usually confused with.

What a profile contains

Each profile (crates/claudius/data/genres/*.json) has:

fieldwhat it decides
bpm, swing, humanizetempo and feel ranges
modes, harmony, markov, ext, bars_per_chordkeys, harmonic styles, how often a fresh Markov progression is written, chord extensions, chord lengths
formsthe section plans, with layers, energies and flags
drumsthe lanes: per-step hit probabilities and velocity ranges per variant, or a euclidean rhythm; drum voice parameter ranges; which Apple Loops one-shots may replace the voice; choke groups (open and closed hat)
kitswhich recorded drum machines the drums may be played on, and how often
partsbass, chords, pad, lead, arp, keys: their presets and instruments, writing styles, registers, density, voicing, and the chance a song leaves the part out
loopsthe Apple Loops a song may layer, by instrument, genre and descriptors
transitionsrisers, impacts, downlifters, reverse crashes, riser length
mixthe loudness target, reverb and delay, bus and master chains, sidechain release, role level offsets
query, altswhat the ears should hear, and its confusions
accepted_flawsflaws the ears may hear that are the genre’s sound, such as lofi’s hiss, so they do not count against it

To add a genre or change one, drop a JSON file with the same fields into the claudius/genres folder of your configuration directory (~/Library/Application Support/claudius/genres/ on macOS, ~/.config/claudius/genres/ on Linux). A file whose name matches a built-in genre replaces it. A good start is a copy of the built-in file closest to what you want.

Making songs

claudius make --genre techno                          # a new song, seeded from the clock
claudius make --genre trap --key "F# minor" --bpm 142 --seed 7 --stems
claudius make --genre deep_house --loops              # built around an Apple Loops family
claudius make --genre lofi --synth-only               # no recordings at all
claudius make --genre house --sample chop.wav         # flip a recording into the song

In a script, song("trap", #{ seed: 7, bpm: 142, key: "F# minor" }) makes the same song, and the edits after it apply on top. --bpm and --key change only the tempo and the key: the title, the form, progression A and the drum patterns are the ones --seed 7 gives without them, except that a key in another mode moves the chords into it, as key() does. The same genre, seed and options make the same song on any machine, in the studio’s New as on the command line; the sample packs and Apple Loops installed change only which recordings it plays.

Mutation

A mutation is one edit a producer might try, chosen from 25 operators:

areaoperators
harmonya theory-aware substitution in A or B (a chord a third away, extended or plain, a secondary dominant, a chord borrowed from the parallel mode, a suspension, a swap, a rotation); a new progression from one of the genre’s styles
drumsadd, remove, nudge or re-accent one hit; regenerate a lane from the grammar; swap a lane’s sound (a recorded one-shot, or one synth parameter moved); move every lane to another kit, or back to synthesis
partsrewrite a part (new seed); another style; another preset; tweak the patch (cutoff, resonance, release, drive, detune); move the register; change a lead’s density
mixa track 1–3 dB up or down; a reverb or delay send; a sidechain depth; a pan; the reverb’s decay or the delay’s time
forma track into or out of a section; a section 4 or 8 bars longer or shorter; toggle a section’s fills, gap, impact or filter sweep; another drum variant for a section
globaltempo by 1–4 BPM within the genre’s range; key by up to 5 semitones; swing; another Apple Loop

Operators are not equally likely. The critic’s top four issues raise the weight of the operators that address them, by 1 + 4 × the issue’s severity: band and width problems favour level, sound, kit, patch, space and pan edits; form problems favour the form operators; a part:lead issue favours rewriting the lead; a weak genre reading favours presets, styles, drum grooves and kits, tempo and harmony. Every mutation records what it did, and the song keeps the list as its lineage.

Mutation edits what the generator writes: drum lanes, parts, loops and their mix. Pattern tracks are moved between sections and follow tempo, key and swing, but their code is never changed.

claudius mutate out/house-crimson-tide-9/blueprint.json -n 4 writes four mutated variants into mutations/ next to it, each rendered and judged, with what changed. The MCP tool mutate tries a few variations of the open song and keeps the best if it beats the current one.

Evolution

claudius evolve runs a (μ + λ) evolution: it starts from fresh songs (or --from a blueprint), and in each generation the best songs so far are parents of new children, each made by one or two mutations or by a crossover of two parents (drums and form from one, harmony and parts from the other) followed by mutations. A child identical to one already heard is skipped. Each candidate is judged on a preview, the build-up into the first drop and the drop (--preview-bars, 32 by default), and at the end the best are rendered in full.

optiondefault
--start4fresh songs to start from
--generations5
--population4parents kept per generation
--children8children per generation
--preview-bars32bars auditioned per candidate
--keep3best songs rendered in full at the end

A short run, two generations of four children from two deep house songs:

claudius evolve --genre deep_house --seed 4 --start 2 --generations 2 --children 4 --keep 1
ears loaded in 3.7 s
evolving deep_house (2 start, 2 generations x 4 children, 32-bar previews, with ears)
  g0-0  78.4  Velvet Orbit
  g0-1  86.5  Hollow Harbor
  g1-0  86.1  from g0-1: key Bb dorian -> Eb dorian
  g1-1  86.4  from g0-1: form: clap out of build
  g1-2  84.9  from g0-1: hat: sampled hh (tr909:hh) -> hat cut from 'Times of Plenty Open Hi-Hat' (Apple Loops); key Bb dorian -> F# dorian
  g1-3  85.9  from g0-1: harmony A: i7 IV7 -> i7 iv7 v7 i7 (minor_soul); mix: shaker +2.0 dB (now +2.0)
generation 1: best 86.5
  g2-0  85.1  from g0-1: crossover: drums and form of one parent, harmony and parts of the other; drums (sparse): re-accented a hat hit; key Bb dorian -> G dorian
  g2-1  86.1  from g0-1: form: drop loses fills
  g2-2  84.8  from g1-3: form: build now 4 bars; form: groove loses fills
  g2-3  85.8  from g1-0: crossover: drums and form of one parent, harmony and parts of the other; mix: chords sidechain depth 0.28; mix: kick -1.8 dB (now -1.8)
generation 2: best 86.5
searched 10 candidates in 17 s; rendering the best 1 in full
#1 g0-1 (Hollow Harbor): preview 86.5, full song 86.1

A + marks a child that beats the best parent. In this short run none did, so the winner is a starting song, g0-1, and its full render scores a little under its preview (the preview hears only the build and the first drop). Small differences in score are within the critic’s noise, so treat a gain of half a point as a draw and listen. Evolution is most useful from a weak start (a score in the 70s, like g0-0) or aimed at one problem, and less so for polishing a song that already scores well.

The MCP tool evolve does the same on the open song; a generation takes 10 to 40 seconds.

Judging against references

--refs <files or folders> on make, script, evolve, render and judge adds a typicality score: how close the song’s ears embedding is to a set of reference tracks you choose (at least two). It counts as a sixth score with the same weight as genre and production, and an issue appears when the song is unlike the references.

What the score is

The total is a weighted mean: mix 0.3, arc 0.2, theory 0.2, and with the ears genre 0.15 and production 0.15 (plus references 0.15 when given). A high score means no measurable problems and a confident genre reading, not a hit. Optimising against the ears can please the models without the music getting better, which is earshot’s own warning; judge finished songs by ear.

House, deep house and techno

Three styles built on the same foundation, a kick on every beat, and different in nearly everything else: house is warm and swung with piano and vocals, deep house is slower and jazzier, techno is hypnotic, dark and dry. Each recipe below is a complete song from a blank start, with the reasons for its choices and the critic’s verdict when we rendered it. They use sampled instruments and drum machines, so they need the default sample packs (claudius setup).

Save a recipe as a .rhai file and render it with claudius script house.rhai.

House: “Glass Avenue”

blank("house");
title("Glass Avenue");
tempo(122); key("A minor"); swing(0.58);
prog("A", "Am7 Fmaj7 Dm7 Em7");
prog("B", "Fmaj7 G Em7 Am7");
bars_per_chord(1);

track("kick",  s("bd*4").bank("tr909"));
track("clap",  s("~ cp ~ cp").bank("tr909").room(0.2));
track("hat",   s("[~ hh]*4").bank("tr808").gain(0.7).pan(0.15));
track("ohat",  s("[~ oh]*4").bank("tr909").gain(0.5).pan(-0.15));
track("shaker", s("shaker*8").gain("[0.3 0.6]*4").pan(-0.3).level(-3));
track("roll",  s("<sd*2 sd*2 sd*4 sd*4 sd*8 sd*8 sd*8 sd*8>").bank("tr909")
                 .gain("<0.4 0.45 0.5 0.55 0.65 0.75 0.85 1>"));

track("bass",   note("<a1 f1 d2 e2>").struct("~ x ~ x ~ x ~ [x x]").s("organ_bass").legato(0.7));
track("chords", chord("prog").struct("x ~ ~ x ~ ~ x ~").voicing(55, 74).s("piano").legato(0.5).room(0.2).level(2));
track("pad",    chord("prog").voicing(52, 72).s("string_ensemble").role("pad").level(-2).room(0.35));
track("vox",    note("<[~ ~ e3 ~ ~ ~ c3 ~] [~ ~ a2 ~ ~ ~ c3 ~] [~ ~ a2 ~ ~ ~ f2 ~] [~ ~ b2 ~ ~ ~ ~ ~]>").s("voice").role("vocal").legato(0.9).room(0.3).delay(0.15).level(-4));
track("lead",   n("<[4 ~ 4 2 4 ~ 6 4] [5 ~ 5 4 2 ~ 0 ~] [3 ~ 3 2 0 ~ 2 3] [4 ~ ~ 6 4 ~ ~ ~]>")
                  .octave(4).s("rhodes").room(0.25).delay(0.2));

form([
  sec("intro", 8, "kick hat shaker chords").filter_in(),
  sec("groove", 16, "kick clap hat shaker bass chords"),
  sec("break", 8, "pad chords"),
  sec("build", 8, "kick clap hat pad lead roll").gap(),
  sec("drop", 16, "kick clap hat ohat shaker bass chords pad lead vox").impact(),
  sec("outro", 8, "kick hat shaker bass"),
]);

Why it is built this way:

  • Tempo and swing. 122 BPM sits in the middle of house’s range. Swing 0.58 is on the heavy side for house; it makes the shaker and the bass pickups lean, which reads as warmth rather than as techno’s grid.
  • Harmony. Am7 Fmaj7 Dm7 Em7 is the minor-soul progression (i7 VImaj7 iv7 v7): sevenths everywhere, no dominant pull, so it loops forever. The break moves to B, which lifts to G before falling back.
  • Drums. A 909 kick and clap, the instruments of house. The closed hat comes from the 808 because it is softer than the 909’s, and the open hat on the same offbeats gives the classic “tss” lift in the drop only. The roll doubles every two bars across the 8-bar build, and stops at eighths: house builds less violently than trance.
  • Bass. An organ bass on the offbeats, with a sixteenth pickup at the end of each bar ([x x]), plays between the kicks instead of on them, which is the house bounce. It sits on the roots of the progression.
  • Chords. A sampled grand piano on the tresillo rhythm (x ~ ~ x ~ ~ x ~), the sound of 90s house.
  • Vocal. The voice pack’s sung vowel plays a two-note answer in each bar of the drop. Pitched low, inside the recording’s range, it sounds like a vocal chop rather than a synth.
  • Lead. A Rhodes motif with chord tones on beats 1 and 3, enters in the build so the drop is a return, not a surprise.
  • Form. The intro’s filter_in() opens a lowpass on the piano over eight bars; the drums are never filtered, so the intro needs a music part for the sweep to be heard. The build keeps the kick (pattern tracks get no automatic kick drop-out), so its energy rises into the drop rather than dipping; the gap() gives the drop its breath.

The critic’s verdict:

  ears: 'house music' z +3.6 (closest confusion 'techno music' +3.4); production z +1.6 vs 'a low quality recording with hiss' +1.0; p_real 0.65
        sounds like: house music, techno music, tech house music, drum and bass music
  verdict: 87/100 (arc 0.85, genre 0.79, mix 0.93, production 0.66, theory 1.00). Fix first: dull 4-8 kHz: hats, claps and snare lack bite (presence -28.7 dB, healthy from -26); fizzy top end (air -16.1 dB, healthy up to -17)

House and techno sit close together for the ears, and a 909 groove pulls towards techno. An earlier version of this recipe had only drums in the intro and read as techno (sounds more like 'techno music' than 'house music', by z +3.5 to +3.4); putting the filtered piano in the intro tipped it to house. The mix notes point at the hats: there is too much above 8 kHz and not enough at 4 to 8 kHz, which a brighter clap and a darker hat would balance.

Try:

  • Swap the piano for a Rhodes (.s("rhodes")) and the lead for a piano for a deeper, softer track.
  • Replace the offbeat bass with the root on every beat, struct("x*4"), for a harder, more driving groove.
  • Put the vocal in the build too, so its hook is introduced before the drop.

Deep house: “Velvet Hours”

blank("deep_house");
title("Velvet Hours");
tempo(121); key("F minor"); swing(0.57);
prog("A", "Fm9 Bbm9 Dbmaj7 Cm7");
prog("B", "Dbmaj7 Cm7 Bbm9 Bbm9");
bars_per_chord(1);

track("kick",   s("bd*4").bank("tr909").gain(0.9));
track("rim",    s("~ ~ rim ~ ~ ~ rim ~ ~ ~ rim ~ ~ rim ~ ~").bank("tr909").gain(0.6).pan(0.3).delay(0.1));
track("hat",    s("[~ hh]*4").bank("tr808").gain(0.6).pan(0.2));
track("shaker", s("shaker*8").gain("[0.3 0.55]*4").pan(-0.3).level(-2));
track("clap",   s("~ cp ~ cp").bank("tr808").room(0.3).level(-2));

track("bass",   note("<f1 bb1 db2 c2>").struct("x ~ ~ x ~ ~ x ~ ~ ~ x ~ ~ ~ ~ ~").s("sub_bass").legato(0.8));
track("chords", chord("prog").struct("x ~ ~ x ~ ~ ~ ~").voicing(53, 72).s("rhodes").room(0.3).delay(0.12));
track("pad",    chord("prog").voicing(50, 74).s("warm_pad").role("pad").level(-3).room(0.4));
track("lead",   n("<[4 ~ ~ 6 4 ~ 2 ~] [2 ~ ~ 4 3 ~ ~ ~] [4 ~ 5 4 2 ~ ~ ~] [~ ~ 1 2 4 ~ ~ ~]>")
                  .octave(4).s("vibes").room(0.35).delay(0.25));

form([
  sec("intro", 8, "kick hat shaker rim chords").filter_in(),
  sec("groove", 16, "kick hat shaker rim clap bass chords"),
  sec("break", 8, "pad chords"),
  sec("build", 8, "kick hat shaker pad chords lead"),
  sec("drop", 16, "kick hat shaker rim clap bass chords pad lead"),
  sec("outro", 8, "kick hat shaker rim bass"),
]);

Why:

  • Slower and swung. 121 BPM and swing 0.57: deep house walks where house runs.
  • Ninth chords. C is a note of all four chords in Fm9 Bbm9 Dbmaj7 Cm7, so the voice-led Rhodes voicings barely move and the harmony shimmers rather than steps. The lead does the same: its strong beats land on C and A♭, notes most of the chords share.
  • The Rhodes plays only twice a bar, on the charleston rhythm (x ~ ~ x), leaving air. Space is the style.
  • Softer drums. The 808’s clap and hat are rounder than the 909’s; the rim shot on a syncopated pattern with a touch of delay is the deep house percussion signature.
  • Sub bass on a syncopated pattern (x ~ ~ x ~ ~ x ~ ~ ~ x): felt more than heard, under warm sounds.
  • No impact. Deep house prefers a riser and a downlifter to a crash and a boom; the genre’s transitions leave the impact out, and the drop has no impact().
  ears: 'house music' z +3.8 (closest confusion 'minimal electronic music' +3.1); production z +1.9 vs 'a low quality recording with hiss' +1.3; p_real 0.52
        sounds like: house music, tech house music, minimal electronic music, drum and bass music
  verdict: 86/100 (arc 0.81, genre 0.91, mix 0.90, production 0.63, theory 1.00). Fix first: dull 4-8 kHz: hats, claps and snare lack bite (presence -31.2 dB, healthy from -26)

The deep house genre score is strong: the ears hear house clearly over its confusions. The one issue, a dull presence range, is the style’s own softness (808 hats, a warm pad, a lowpassed master); raising the clap and the hat by 2 to 3 dB fixes it if you want it brighter.

Try:

  • prog("A", "Gm9 C9 Gm9 C9") with key("G dorian"): the dorian vamp, two chords for the whole track.
  • A walking bass through each chord’s notes, one per beat: note("<[f1 ab1 c2 eb2] [bb1 db2 f2 ab1] [db2 f2 ab2 c2] [c2 eb2 g2 bb1]>") without the struct().

Techno: “Hollow Engine”

blank("techno");
title("Hollow Engine");
tempo(130); key("F phrygian"); swing(0.5);
prog("A", "i i II i");
prog("B", "i");
bars_per_chord(2);

track("kick",  s("bd*4").bank("tr909"));
track("hat",   s("hh*16").bank("tr909").gain("[0.35 0.25 0.7 0.25]*4").pan(0.35));
track("ohat",  s("[~ oh]*4").bank("tr909").gain(0.6).pan(-0.3).room(0.2));
track("clap",  s("~ cp ~ cp").bank("tr909").room(0.25).level(-2));
track("rim",   s("rim(5,16,2)").bank("tr909").gain(0.7).pan(0.35).delay(0.2));
track("ride",  s("[~ rd]*4").bank("tr909").gain(0.5).pan(0.25));
track("roll",  s("<cp*2 cp*4 cp*8 [cp*16]>").bank("tr909").gain("<0.5 0.65 0.8 1>"));

track("bass",  note("<f1!4 gb1!2 f1!2>").struct("[~ x x x]*4").s("saw_bass").legato(0.6).level(-3));
track("acid",  note("f2 f2 f3 f2 ab2 f2 gb2 f2 f2 c3 f2 eb3 f2 f2 db3 f2")
                 .velocity("1 0.6 0.9 0.6 0.7 0.6 1 0.6 0.6 0.9 0.6 1 0.6 0.6 0.9 0.7")
                 .legato(0.7).s("acid").role("synth").delay(0.3).pan(0.2).level(-4));
track("stab",  chord("prog").struct("~ x ~ ~ ~ ~ x ~").voicing(52, 70).s("stab").room(0.5).delay(0.4).pan(-0.2).level(-1));
track("pad",   chord("prog").voicing(45, 70).s("warm_pad").role("pad").room(0.45).level(-2));

form([
  sec("intro", 8, "kick hat rim stab").filter_in(),
  sec("groove", 16, "kick hat ohat clap rim bass"),
  sec("break", 8, "pad stab acid").prog("A"),
  sec("build", 8, "kick hat clap acid roll").gap(),
  sec("drop", 16, "kick hat ohat clap rim ride bass stab pad").impact(),
  sec("outro", 8, "kick hat rim"),
]);

Why:

  • Phrygian. F phrygian has a G♭ a semitone above the tonic, and the progression only moves between F minor and G♭ (i i II i, two bars a chord). That half step is the darkness of techno; there is almost no harmony beyond it.
  • A rolling bass. Three sixteenths after every kick ([~ x x x]*4), never on the beat, so kick and bass interlock into one continuous low-end engine. saw_bass keeps it audible without piling on sub.
  • An acid line, sixteen steps with accents written as velocities: the acid preset’s resonant filter opens on the loud steps, which is the 303 sound. It plays in the break and the build, then leaves the drop to the drums and stabs.
  • Euclidean percussion. rim(5,16,2) spreads five rim shots over the bar against the four kicks, and the ride joins in the drop. Panned hats, rim and ride keep the dry, mono-heavy low end from making the whole track mono.
  • Stabs with long delay and reverb fill the space between hits, two per bar, and turn into echoes.
  • A clap roll instead of a snare: techno builds on the clap.
  ears: 'techno music' z +4.5 (closest confusion 'drum and bass music' +3.5); production z +1.3 vs 'a distorted clipped recording' +1.5; p_real 0.63
        sounds like: techno music, drum and bass music, tech house music, house music
  verdict: 86/100 (arc 0.82, genre 1.00, mix 0.92, production 0.45, theory 1.00). Fix first: nearly mono (correlation 0.97): widen pads, chords and effects; sounds about as much like a clipped recording as a professional mix: less drive and saturation, or a quieter master

A perfect genre score: nothing about this reads as anything but techno. The production score is the weak point; the ears hear the saturated 909, the drive of the acid and the techno master’s saturation as close to “distorted”. That is partly the style. Lowering the acid further, or keeping it out of the build too, raises production at some cost to character; the call is yours.

Try:

  • A 32-bar intro and groove, as techno DJs expect: change the bar counts and nothing else.
  • prog("B", "i") is there for a reason: put .prog("B") on the break for eight bars of pure F minor.
  • Replace the acid with stabs on every offbeat, struct("[~ x]*4"), for a 90s Detroit feel.

Trap and lofi

Two descendants of hip-hop at opposite ends: trap is fast hi-hats, a booming 808 and dark minor melodies at 140 BPM in half time; lofi is a dusty, swung loop of jazz chords at 84 BPM that sounds like a worn record. Both recipes need the default sample packs (claudius setup).

Trap: “Paper Satellite”

blank("trap");
title("Paper Satellite");
tempo(140); key("C harmonic minor"); swing(0.5);
prog("A", "Cm Ab Fm G");
prog("B", "Ab G Cm Cm");
bars_per_chord(2);

track("kick",  s("bd ~ ~ ~ ~ ~ ~ bd ~ ~ bd ~ ~ ~ ~ ~").bank("tr808"));
track("clap",  s("~ ~ [cp, sd] ~").bank("tr808").room(0.2));
track("hat",   s("<[hh*2 hh*2 hh*2 hh*2] [hh*2 hh*2 hh*2 hh*6] [hh*2 hh*2 hh*2 hh*2] [hh*2 hh*4 hh*2 hh*8]>")
                 .bank("tr808").gain("[0.9 0.6]*4").pan(0.12));
track("ohat",  s("~ ~ ~ oh ~ ~ ~ ~").bank("tr808").gain(0.5).pan(-0.2));
track("808",   note("<c2!2 ab1!2 f1!2 g1!2>").struct("x ~ ~ ~ ~ ~ ~ x ~ ~ x ~ ~ ~ ~ ~").s("808").legato(1.0).duck(0));

track("keys",  chord("prog").voicing(55, 72).s("piano").room(0.3).level(-1));
track("pad",   chord("prog").voicing(48, 70).s("choir").role("pad").room(0.4).level(-4));
track("lead",  n("<[7 ~ 6 7 4 ~ ~ ~] [~ ~ 2 3 4 ~ 2 ~] [7 ~ 6 7 5 ~ ~ ~] [~ ~ 4 5 7 ~ 5 ~] [5 ~ 4 5 3 ~ ~ ~] [~ ~ 2 3 5 ~ 3 ~] [6 ~ 4 6 8 ~ ~ ~] [4 ~ ~ ~ 6 ~ ~ ~]>")
                 .octave(4).s("bell").room(0.3).delay(0.25));

form([
  sec("intro", 8, "keys pad lead").filter_in(),
  sec("hook", 16, "kick clap hat ohat 808 keys pad lead").impact(),
  sec("verse", 16, "kick clap hat 808 keys"),
  sec("hook", 16, "kick clap hat ohat 808 keys pad lead").impact(),
  sec("outro", 8, "keys pad lead"),
]);

Why:

  • Half time. At 140 BPM the clap lands on beat 3 only (~ ~ [cp, sd] ~), so the groove feels like 70 BPM while the hats run at full speed. The clap is layered with the snare in one step ([cp, sd]), a standard trap trick for a thicker backbeat.
  • Hi-hat rolls. The hats are written beat by beat, with a sixteenth-triplet roll (hh*6) on the last beat of bar 2, and sixteenths on beat 2 and thirty-seconds on beat 4 of bar 4. The <...> cycles four bars, so the rolls come back at the same place in every phrase, and the accents ([0.9 0.6]*4) keep the eighths from sounding like a machine gun.
  • The 808 is the bass. It shares the kick’s rhythm exactly (x ~ ~ ~ ~ ~ ~ x ~ ~ x), holds each note until the next (legato(1.0)), and follows the chord roots two bars at a time (<c2!2 ab1!2 f1!2 g1!2>). duck(0) turns off the sidechain that pattern tracks get by default: ducking an 808 under a kick on the same beat would cut off its attack.
  • Harmonic minor. C harmonic minor gives G major as the fifth chord (Cm Ab Fm G), with a B that pulls hard back to C: the dark, classical tension of a lot of trap. The bell melody uses that B on the G chord.
  • Choir and piano. A sustained piano and a low choir pad are the cinematic bed under the drums; the verse drops the choir, the open hat and the lead so the hook has somewhere to return to.
  ears: 'trap beat' z +3.4 (closest confusion 'hip hop beat with heavy bass' +3.2); production z +1.0 vs 'a recording made on a phone' +0.8; p_real 0.30
        sounds like: trap beat, hip hop beat with heavy bass, lo-fi hip hop beat, a success chime
  verdict: 88/100 (arc 0.97, genre 0.79, mix 0.94, production 0.56, theory 1.00). Fix first: too much sub (boomy): lower the bass / kick, or highpass the music bus (sub -1.8 dB, healthy up to -4); a thin low end: bass or kick too quiet (low -7.7 dB, healthy from -7); sounds about as much like a phone recording as a professional mix: a thin or narrow mix is the usual cause

The low end is the 808’s: a lot of sub, not much above it. That is trap’s sound and the critic’s taste disagreeing. Drive on the 808 (.distort(6)) adds harmonics in the 60–250 Hz band, where small speakers can hear it, without changing its notes; .level(-2) on it trims the sub.

Try:

  • Glide: overlap the 808 notes (legato(1.2)) and they slide into each other, since the 808 preset has glide.
  • A sparser verse: section("verse", #{ layers: "kick clap 808 keys" }) takes the hats out too.
  • Swap the bell for pizzicato or flute (sampled), two other trap lead sounds.

Lofi: “Rain on the Avenue”

blank("lofi");
title("Rain on the Avenue");
tempo(84); key("F major"); swing(0.6);
prog("A", "Gm9 C9 Fmaj9 Dm9");
prog("B", "Bbmaj7 C9 Am7 Dm9");
bars_per_chord(1);

track("kick",  s("bd ~ ~ ~ ~ ~ ~ bd ~ ~ bd ~ ~ ~ ~ ~").bank("tr808").gain(0.9));
track("snare", s("~ sd ~ sd").bank("acoustic").gain(0.8).room(0.15));
track("hat",   s("hh*8?0.1").bank("tr808").gain("[0.6 0.35]*4").pan(0.2).level(4));
track("ohat",  s("~ ~ ~ ~ ~ ~ ~ oh").bank("tr808").gain(0.5).pan(-0.2).level(2));

track("bass",  note("<g1 c2 f1 d2>").struct("x ~ ~ x ~ x ~ ~").s("upright_bass").legato(0.9));
track("keys",  chord("prog").struct("x ~ ~ ~ ~ x ~ ~").voicing(50, 72).s("rhodes").room(0.3).late(0.02));
track("lead",  n("<[2 ~ ~ 1 2 ~ 4 ~] [~ ~ 4 3 1 ~ ~ ~] [2 ~ ~ 4 6 ~ 4 ~] [5 ~ 4 2 0 ~ ~ ~]>")
                 .octave(4).s("kalimba").room(0.35).delay(0.2).level(-1));

form([
  sec("intro", 4, "keys").filter_in(),
  sec("verse", 16, "kick snare hat bass keys"),
  sec("hook", 16, "kick snare hat ohat bass keys lead"),
  sec("verse", 16, "kick snare hat ohat bass keys lead"),
  sec("outro", 8, "keys lead"),
]);

Why:

  • The ii–V–I. Gm9 C9 Fmaj9 Dm9 is the jazz cadence in F major with ninths on every chord, then the relative minor to loop back. Lofi borrows its harmony from jazz; the ninths are what make it sound wistful rather than plain.
  • Heavy swing and lazy keys. Swing 0.6 drags every off-beat sixteenth, and .late(0.02) puts the Rhodes a hair behind the beat, like a player leaning back. The keys play on beat 1 and the and of 3 only.
  • Mixed kits. An 808 kick (mono, deep) with the acoustic kit’s snare (a real drum in a room), and 808 hats with ?0.1 dropping one hit in ten so the pattern never repeats exactly. The kick plays on beat 1, on the last sixteenth of beat 2 and on the and of 3: a boom-bap pattern that pushes into the second half of the bar.
  • Upright bass on the chord roots with long notes, and a kalimba melody: acoustic, plucked, warm.
  • The genre does the rest. blank("lofi") brings lofi’s mix: a bitcrusher and a lowpass on the drums bus, tape wow and flutter, vinyl crackle and hiss on the master, and a quiet −12.5 LUFS target. That darkening is why the hats are 4 dB up: without it the critic finds no air above 8 kHz.
  ears: 'lo-fi hip hop beat' z +4.0 (closest confusion 'hip hop beat with heavy bass' +3.3); production z +2.0 vs 'a distorted clipped recording' +1.5; p_real 0.32
        sounds like: lo-fi hip hop beat, hip hop beat with heavy bass, jazz music, minimal electronic music
  verdict: 90/100 (arc 0.90, genre 0.92, mix 0.97, production 0.64, theory 1.00). Fix first: recessed mids: the lead and chords are buried (mid -19.0 dB, healthy from -18); too much sub (boomy): lower the bass / kick, or highpass the music bus (sub -3.2 dB, healthy up to -4)

The ears hear hiss, because there is hiss: the vinyl on the master is the style. So lofi’s production reading leaves hiss out, and the thin sound of a phone recording that a lowpassed drum bus has, and compares the master with the next flaw instead, here clipping. The first version of this recipe used the acoustic kit’s kick and a vibraphone lead, and the critic read it as jazz (sounds more like 'jazz music' than 'lo-fi hip hop beat') and found the low end not mono (the acoustic kick is a stereo room recording). The 808 kick and the kalimba fixed both.

Try:

  • .s("piano") on the keys and .lpf(3000) for a muffled, sampled-from-a-record piano.
  • swing(0.66) for a full triplet shuffle.
  • A B section: sec("hook", 16, ...).prog("B") plays the Bbmaj7 C9 Am7 Dm9 progression, but write a second lead for it, since this one fits A.

Drum and bass, synthwave, future bass

Three styles that lean on synthesizers: liquid drum and bass with a rolling breakbeat under pads, synthwave’s eighties arpeggios and gated drums, and future bass’s pumping supersaw chords. All three use sampled drums, so they need the default sample packs (claudius setup).

Drum and bass: “Liquid Orbit”

blank("dnb");
title("Liquid Orbit");
tempo(174); key("F minor"); swing(0.5);
prog("A", "Fm Db Ab Eb");
prog("B", "Db Eb Fm Fm");
bars_per_chord(2);

track("kick",  s("bd ~ ~ ~ ~ ~ ~ ~ ~ ~ bd ~ ~ ~ ~ ~").bank("acoustic"));
track("snare", stack([s("~ sd ~ sd"), s("~ ~ ~ ~ ~ ~ ~ sd:1 ~ ~ sd:1 ~ ~ ~ ~ ~").gain(0.35)]).bank("acoustic").room(0.15));
track("hat",   s("hh*8").bank("acoustic").gain("[0.7 0.45]*4").pan(0.15));
track("ride",  s("[~ rd]*4").bank("acoustic").gain(0.45).pan(-0.2));

track("bass",  note("<f1!2 db1!2 ab1!2 eb1!2>").s("reese").legato(1.0));
track("pad",   chord("prog").voicing(48, 72).s("pad").role("pad").room(0.4).level(-2));
track("arp",   chord("prog").struct("x x").arp("updown").voicing(60, 76).s("pluck").role("arp").delay(0.3).room(0.3));
track("keys",  chord("prog").struct("x ~ ~ x ~ ~ x ~").voicing(55, 72).s("rhodes").room(0.3).level(-2));

form([
  sec("intro", 16, "pad arp").filter_in(),
  sec("build", 8, "hat ride pad arp keys").gap(),
  sec("drop", 32, "kick snare hat ride bass pad arp keys").impact(),
  sec("break", 16, "pad arp keys"),
  sec("drop", 16, "kick snare hat ride bass pad arp keys").impact(),
  sec("outro", 8, "pad arp"),
]);

Why:

  • The two-step. At 174 BPM, a kick on beat 1 and on the and of 3 (bd ~ ~ ~ ~ ~ ~ ~ ~ ~ bd), with the snare on 2 and 4: the basic drum and bass rhythm. Two ghost snares (variation sd:1, at a third of the level) in a separate layer of the stack() give it the shuffle of a chopped breakbeat; a gain pattern on the whole snare would have turned down the backbeat with them.
  • A recorded kit. The acoustic kit sounds like a sampled break, which is where the style comes from. The ride on the offbeats keeps the top end moving at half the hats’ rate.
  • Reese bass. Two detuned saws a bar at a time, on the chord roots two bars each, so the bass is one slow, growling movement under fast drums.
  • Three harmonic layers at three speeds: a pad holding the chords, a Rhodes on a slow syncopated rhythm, and a pluck arpeggio (updown over two events a bar) running eighths with a long delay.
  • Long sections. Drum and bass phrases are long; the first drop is 32 bars, and the song is 96 bars, 2:12 at 174 BPM. Anything under 90 seconds would cost form points.
  ears: 'drum and bass music' z +3.4 (closest confusion 'techno music' +2.7); production z +1.1 vs 'a low quality recording with hiss' +0.3; p_real 0.76
        sounds like: drum and bass music, energetic and driving music, synthwave music, inspirational corporate background music
  verdict: 93/100 (arc 0.96, genre 0.88, mix 0.99, production 0.72, theory 1.00). Fix first: dull 4-8 kHz: hats, claps and snare lack bite (presence -26.5 dB, healthy from -26)

The best score in the cookbook. The energy arc is nearly perfect because the sections are clearly layered: the intro and break have no drums at all, the drops have everything.

Try:

  • .s("wobble") on the bass for a heavier, neurofunk movement (its filter LFO is tempo-synced).
  • Busier hats: hh*16 with .gain("[0.7 0.3 0.5 0.3]*4") for a rolling sixteenth feel.
  • Replace the arp with a lead melody in F minor, with chord tones on beats 1 and 3 of each bar.

Synthwave: “Neon Harbor”

blank("synthwave");
title("Neon Harbor");
tempo(100); key("A minor"); swing(0.5);
prog("A", "Am F C G");
prog("B", "F G Am Am");
bars_per_chord(1);

track("kick",  s("bd ~ bd ~").bank("tr707"));
track("snare", s("~ sd ~ sd").bank("tr707").room(0.45));
track("hat",   s("hh*16").bank("tr707").gain("[0.6 0.3 0.45 0.3]*4").pan(0.2));

track("bass",  note("<[a1 a2]*4 [f1 f2]*4 [c2 c3]*4 [g1 g2]*4>").s("saw_bass").legato(0.8));
track("arp",   chord("prog").struct("x*4").arp("updown").voicing(57, 76).s("pluck").role("arp").delay(0.25).pan(0.15));
track("pad",   chord("prog").voicing(52, 76).s("strings").role("pad").room(0.45).level(-2));
track("lead",  n("<[4 ~ ~ 4 2 ~ 0 ~] [0 ~ ~ 2 5 ~ 4 ~] [4 ~ ~ 4 6 ~ 4 ~] [6 ~ ~ 4 3 ~ ~ ~]>")
                 .octave(4).s("square_lead").room(0.3).delay(0.2));

form([
  sec("intro", 8, "pad arp").filter_in(),
  sec("verse", 16, "kick snare hat bass arp pad"),
  sec("chorus", 16, "kick snare hat bass arp pad lead").impact(),
  sec("break", 8, "pad lead").prog("A"),
  sec("chorus", 16, "kick snare hat bass arp pad lead").impact(),
  sec("outro", 8, "pad arp"),
]);

Why:

  • The 707. Roland’s TR-707 is the drum machine of eighties pop. A kick on 1 and 3, a snare on 2 and 4 with a lot of reverb: the big, simple backbeat of the decade.
  • Octave bass. Each bar alternates the root and its octave in eighths ([a1 a2]*4), the driving bass of synth pop, on a buzzy saw_bass.
  • Arpeggios in sixteenths. chord("prog").struct("x*4").arp("updown") gives four notes per beat from a triad (up and down without repeating the ends is four steps), so the arpeggio is straight sixteenths and always on the right chord.
  • String machine and square lead. strings is the synth preset, slow attack and vibrato, more Juno than orchestra; square_lead is a mono square wave with delayed vibrato. Both are synthesized on purpose: this is the one style where “computer-generated” is the aesthetic.
  • A pop form. Verse, chorus, a short break with only pad and lead, and the chorus again: synthwave is song-shaped rather than DJ-shaped.
  ears: 'synthwave music' z +3.5 (closest confusion 'upbeat corporate pop music' +3.0); production z +0.5 vs 'a cheap synthesized beep' +0.3; p_real 0.61
        sounds like: synthwave music, inspirational corporate background music, uplifting and hopeful music, upbeat corporate pop music
  verdict: 90/100 (arc 0.95, genre 0.87, mix 1.00, production 0.55, theory 1.00). Fix first: sounds about as much like a cheap synth as a professional mix: sampled instruments in place of synth patches usually read better

The ears’ worst flaw is “a cheap synthesized beep”, which for synthwave is half the point. A sampled string_ensemble pad instead of the strings synth would raise production and make it less eighties.

Try:

  • tempo(86) makes it outrun-slow; tempo(118) makes it italo disco.
  • A tom fill in the last beat of every fourth bar, with a big reverb: track("toms", s("<~ ~ ~ [~ ~ ~ [lt mt ht ht]]>").bank("tr707").room(0.5)), added to the choruses’ layers.
  • key("F# minor") and render again: the chords, the arpeggio and the lead follow, because they are written with prog and n(). The bass is written in note names and stays in A; move it with .transpose(-3).

Future bass: “Golden Bloom”

blank("future_bass");
title("Golden Bloom");
tempo(150); key("D major"); swing(0.5);
prog("A", "Dmaj9 A Bm9 Gmaj9");
prog("B", "Gmaj9 A Bm9 Bm9");
bars_per_chord(1);

track("kick",  s("bd ~ ~ ~ ~ ~ ~ ~ ~ ~ bd ~ ~ ~ ~ ~").bank("tr909"));
track("clap",  s("~ ~ [cp, sd] ~").bank("tr808").room(0.25));
track("hat",   s("[~ hh]*4, ~ ~ ~ [~ hh hh hh]").bank("tr808").gain(0.6).pan(0.2).level(-3));

track("bass",   note("<d2 a1 b1 g1>").struct("x ~ ~ ~ ~ ~ ~ ~ ~ ~ x ~ ~ ~ ~ ~").s("saw_bass").legato(1.0).level(-3));
track("chords", chord("prog").struct("x ~ ~ x ~ ~ x ~ ~ ~ x ~ x ~ ~ ~").voicing(55, 79).s("supersaw").legato(0.9).duck(0.85).room(0.25).level(2));
track("pad",    chord("prog").voicing(50, 74).s("pad").role("pad").room(0.4).level(-3));
track("lead",   n("<[4 ~ 2 ~ 4 ~ 5 4] [6 ~ 4 ~ 6 ~ 7 6] [5 ~ 4 ~ 2 ~ 4 ~] [3 ~ 2 ~ 4 ~ ~ ~]>")
                  .octave(4).s("pluck").room(0.3).delay(0.25));

form([
  sec("intro", 8, "pad lead").filter_in(),
  sec("verse", 16, "kick clap hat bass pad lead"),
  sec("build", 8, "clap hat chords pad").gap(),
  sec("drop", 16, "kick clap hat bass chords lead").impact(),
  sec("break", 8, "pad lead"),
  sec("build", 8, "clap hat chords pad").gap(),
  sec("drop", 16, "kick clap hat bass chords lead pad").impact(),
  sec("outro", 8, "pad lead"),
]);

Why:

  • The pump. The supersaw chords duck under the kick at depth 0.85: they nearly vanish on every kick and swell back, which is the future-bass sound. The kick only plays twice a bar (beat 1 and the and of 3), so the swells are long.
  • Syncopated chords. x ~ ~ x ~ ~ x ~ ~ ~ x ~ x ~ ~ ~ is a sixteenth-note rhythm built from threes and twos, the chopped chord rhythm of the style. The chords only enter in the builds and drops; the verse keeps them back.
  • Major ninths. Dmaj9 A Bm9 Gmaj9 is the pop I V vi IV with ninths, bright and bittersweet.
  • Half-time drums at 150 BPM, with the layered clap and snare on beat 3, as in trap, and a hat skip at the end of each bar ([~ hh hh hh]).
  • A saw bass on the kick, not a sub: the first version used sub_bass and the critic found too much sub and a thin low end at once.
  ears: 'future bass music' z +2.4 (closest confusion 'dubstep music' +2.8); production z +1.1 vs 'a low quality recording with hiss' +0.3; p_real 0.53
        sounds like: synthwave music, hip hop beat with heavy bass, drum and bass music, techno music
  verdict: 85/100 (arc 0.96, genre 0.41, mix 0.96, production 0.70, theory 1.00). Fix first: fizzy top end (air -14.8 dB, healthy up to -17); sounds more like 'dubstep music' than 'future bass music'

Future bass is the style the ears are least sure of: it reads close to dubstep and synthwave, which share its sounds. The air band is high because a supersaw at the front of the mix is bright; .lpf(9000) on the chords tames it. An earlier version with a chip arpeggio read as “chiptune video game music”, and taking it out helped.

Try:

  • duck(0.6) on the chords for a subtler pump, or 0.95 for an extreme one.
  • Vocal chops in the drops: track("vox", note("<[~ d3 ~ a2] [~ c#3 ~ a2]>").s("voice").role("vocal").legato(0.5)), sung vowels on chord notes.
  • bars_per_chord(2) and a slower lead for a more anthemic, half-speed drop.

Remaking a song from audio

claudius remake listens to a recording, works out its skeleton (tempo, key, chords, sections, drum habits, the style of its bass) and writes a new song on that skeleton in any genre, with its own parts and sounds. No audio from the original is used. This recipe remakes a song whose answers we know, so you can see what the analysis gets right and what it gets wrong, then edits and evolves the result.

A song to remake

Any audio file ffmpeg can read works. To follow along without one, render the house recipe from House, deep house and techno first: claudius script house.rhai writes out/house-glass-avenue-1/song.mp3. We know exactly what is in it: 122 BPM, A minor, swing 0.58, the progression Am7 Fmaj7 Dm7 Em7 (i7 VImaj7 iv7 v7), and six sections (intro 8, groove 16, break 8, build 8, drop 16, outro 8).

What the analysis hears

claudius analyze out/house-glass-avenue-1/song.mp3
122.0 BPM (pulse clarity 0.58), A minor (confidence 0.75), swing 0.50, 64 bars from 0.00 s
  progression A: III VImaj7 iv7 v7  |  B: i VII v7 i7  (1 bar(s) per chord)
  bar   0 +24  drop    energy 0.97  kick bass hats
  bar  24 + 8  break   energy 0.30  
  bar  32 +32  drop    energy 1.00  kick bass hats
  kick  x...X...X...X...
  snare .-x-X-x-.-x-X-x-
  hats  .-X-x-X-.-X-x-X-
  bass style: syncopated

Against what we know:

  • Tempo, key, length, bar grid: right. The tempo comes from onset autocorrelation and a grid fitted to the kicks; the key from the chroma of the whole song.
  • Swing: missed (0.50 for 0.58). Swing is measured from the hi-hats on the off-beat sixteenths, and this song’s hats play eighths.
  • Progression: three chords out of four right. The first chord, Am7 (A C E G), was heard as C (III): it contains all of C major’s notes, and chords are read from chroma, the energy of each note name per bar.
  • Sections: the analysis cuts the song where the energy changes, in 4-bar steps, and labels the parts by energy. It found the break, but merged the intro and groove into one 24-bar “drop”, and the build, drop and outro into another. It reports which sections have kick, bass and hats.
  • Drum habits: per sixteenth step, X very likely, x likely, . occasional, - never. The kick is exactly right. The snare line shows the claps on steps 5 and 13 (beats 2 and 4) plus hat energy leaking into the snare’s band on the eighths.
  • Bass style: syncopated, a fair name for an offbeat line with pickups.

The analysis is approximate by nature (it reads a finished mix, not stems) and works best on steady electronic music.

Remake it in another genre

claudius remake out/house-glass-avenue-1/song.mp3 --genre deep_house --seed 3

It prints the analysis again, then the new song:

Faded Engine — deep_house at 122 BPM in A minor, 64 bars (2:06)
  progression A: III VImaj7 iv7 v7
  progression B: i VII v7 i7
  form: drop1 24 | break2 8 | drop3 32
  tracks: kick (sampled bd (tr909:bd)), clap (sampled cp (tr909:cp)), hat (sampled hh (tr909:hh)), shaker (shaker cut from 'Loose Leaf Beat Shaker' (Apple Loops)), perc (sampled rim (tr909:rim)), bass (synth patch 'sub_bass'), chords (synth patch 'keys'), pad (synth patch 'warm_pad'), lead (sampled rhodes (jrhodes:rhodes)), top (loop 'Deep Blue Void Hi-Hat' (House, Apple Loops) (126 BPM)), crash (synth crash), riser (synth riser), downlifter (synth downlifter), reverse_crash (synth reverse_crash)
  master: -11.0 LUFS, true peak -1.0 dBTP (pre-master -8.8 LUFS); done in 13.3 s
  ears: 'house music' z +3.3 (closest confusion 'techno music' +3.1); production z +1.3 vs 'a low quality recording with hiss' +1.2; p_real 0.80
        sounds like: house music, drum and bass music, techno music, tech house music
  verdict: 89/100 (arc 1.00, genre 0.79, mix 0.98, production 0.53, theory 1.00). Fix first: sounds about as much like a hissy recording as a professional mix: noise-built sounds (hats, shakers, risers) and long reverbs are the usual cause; dull 4-8 kHz: hats, claps and snare lack bite (presence -26.9 dB, healthy from -26)
  wrote out/remake-deep_house-faded-engine-3/song.mp3

What the remake keeps from the analysis, and what it takes from the genre:

from the recordingfrom the genre and the seed
tempo (rounded), key, swingevery sound: kits, Apple Loops one-shots, synth patches, sampled instruments
progressions A and B, bars per chordevery part’s notes, written by the theory engine on those progressions
the sections: lengths (rounded to 4 bars), kinds and energieswhich parts play where, chosen from what the analysis heard (kick, bass, hats) and each section’s energy
the kick, snare and hat habits, as the drum lanes’ step probabilitiesthe drum lanes the genre has, and its other lanes
the bass style, when the genre has itthe mix, transitions and master

The sections get builds and gaps before drops and impacts on them, an intro (if there is one) filters in, and breaks, intros and outros play progression B. On this machine the shaker was cut from Apple Loops and the top percussion is an Apple Loop; without Apple Loops, the shaker is synthesized and the loop is left out.

The score is high because the skeleton is simple and the sections are clearly layered: the arc check is perfect.

Edit the remake

The remake is an ordinary song. A script can fix what the analysis got wrong and replace any part with your own. This one corrects the first chord and puts a written motif in place of the generated Rhodes lead:

prog("A", "i7 VImaj7 iv7 v7");
track("lead", n("<[4 ~ 4 2 4 ~ 6 4] [5 ~ 5 4 2 ~ 0 ~] [3 ~ 3 2 0 ~ 2 3] [4 ~ ~ 6 4 ~ ~ ~]>")
                .octave(4).s("rhodes").room(0.3).delay(0.2));
claudius script fix.rhai --on out/remake-deep_house-faded-engine-3
  verdict: 87/100 (arc 1.00, genre 0.68, mix 0.98, production 0.46, theory 1.00). Fix first: sounds about as much like a hissy recording as a professional mix: noise-built sounds (hats, shakers, risers) and long reverbs are the usual cause; dull 4-8 kHz: hats, claps and snare lack bite (presence -26.8 dB, healthy from -26)
  wrote out/deep_house-faded-engine-3/song.mp3

The generated parts follow the corrected progression on their own, since they are written from it. The new lead takes the generated lead’s place in every section: both drops and the break. The critic liked it slightly less (the genre reading moved towards techno), which is a matter of taste you settle by listening. The edited song is written to out/deep_house-faded-engine-3/, next to the remake rather than over it, because a script names its folder after the genre, title and seed.

Evolve it

claudius evolve --genre deep_house --from out/deep_house-faded-engine-3/blueprint.json

starts evolution from the edited remake instead of fresh songs. The lead, a pattern track, keeps its notes; the drums, parts, mix, form and global feel are mutated.

Using the audio itself

A remake uses none of the recording’s sound. Two commands do:

  • claudius chop song.mp3 cuts the recording into bar loops (2 bars by default, --bars) and drum one-shots, writes them into out/chops/, and adds them to your user library, where generated songs can pick them up as drum sounds. --find "a vocal chop" also uses the ears to cut out the moments that best match a description, three per description (--top).
  • claudius make --genre house --sample out/chops/<loop>.wav flips a recording into a new song: warped to the song’s tempo, transposed to its key, re-sliced in half-beat pieces (or played straight) and placed in the high-energy sections, as a track named sample1.

Only use recordings you have the right to use this way. analyze and remake take nothing but measurements from a recording; chop and make --sample put its audio in your song.

From an assistant, the MCP tools analyze and remake take a file path and do the same as the commands; a remake becomes the song open in the studio.

Overview

A claudius script is a Rhai program with two kinds of functions on top:

  • Song edits change the song that is open: tempo(124), key("A minor"), prog("A", "i7 VImaj7 iv7 v7"), mix("pad", #{ level: -4 }), form([...]).
  • Patterns describe music in Strudel-style mini-notation, and track(name, pattern) turns a pattern into a track of the song: track("hats", s("[~ hh]*4")).

Here is a complete song in eight lines. It builds everything itself, so it runs anywhere:

blank("house");
tempo(122); key("A minor"); prog("A", "Am7 Fmaj7 Dm7 Em7"); bars_per_chord(1);
track("drums", s("bd*4, [~ cp]*2, [~ hh]*4"));
track("bass",  chord("prog").root().struct("~ x ~ x ~ x ~ [x x]").s("organ_bass").legato(0.7));
track("keys",  chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano").room(0.25));
track("lead",  n("<[4 ~ 4 2] [5 ~ 2 0] [3 ~ 0 2] [4 ~ ~ ~]>").octave(4).s("pluck").delay(0.2));
form([sec("intro", 8, "drums keys").filter_in(), sec("drop", 16, "*").impact(),
      sec("break", 8, "keys lead"), sec("drop", 16, "*")]);

You hear a four-on-the-floor house groove in A minor at 122 BPM: kick, claps on 2 and 4, offbeat hats, an organ bass on the offbeats playing the root of each chord, electric piano stabs and a plucked melody. The intro opens a lowpass filter over eight bars, the first drop lands with a crash.

How a script runs

A script runs top to bottom against the current song and changes only what it names. There is always a current song:

  • song("house", 7) replaces it with a song the generator writes (genre, seed); see song().
  • blank("house") replaces it with an empty song: one 8-bar section, no tracks, and the tonic chord as its only progression; see blank().
  • Without either, edits apply to the song that is open: the one in the studio, the folder you pass with claudius script --on, or, when there is none, a blank house song.

Edits apply in order, so a later line sees what an earlier line did. key("D minor") after prog() transposes the progression; prog() after key() reads chord names in the new key. Where order matters, the reference says so.

When the script ends, the song is arranged, rendered and judged. That happens outside the script: a script only describes the song.

Where scripts run

wherehow
command lineclaudius script song.rhai renders into out/<genre>-<title>-<seed>/; --on <folder> edits a saved song; --print prints the result as a script instead of rendering
studiothe script tab runs against the open song; every change renders and is judged in the background
assistantsthe MCP tool run_script (read script_reference first)
HTTPPOST /api/command with {"cmd": "run_script", "code": "..."}

claudius reference prints the compact reference that assistants get.

The examples in this reference

Every example in these pages is run by the test suite. An example that starts with song(...) or blank(...) stands alone. One that does not edits a generated song, the same one every time: song("house", 9), “Crimson Tide”, 124 BPM in G dorian, with the tracks kick clap hat ohat shaker perc bass chords pad lead arp, the sections intro groove break drop outro and the progressions A and B. To try such an example yourself, put song("house", 9); on the line before it.

// on the open song: faster, and the hats out of the intro
tempo(126);
layer("intro", "hat", false);

Reading the reference

Signatures use plain names for parameters and say what each one accepts:

tempo(bpm)                 // bpm: number, 40..240
drum(lane, variant, steps, per_bar)

number means an integer or a decimal (124 or 124.5). integer means a whole number: Rhai keeps the two apart, so bars_per_chord(1.5) is an error, not a rounding. A pattern string is mini-notation in quotes. Where a number is clamped into a range rather than rejected, the reference says so.

The pages

Rhai in five minutes

Scripts are written in Rhai, a small scripting language that reads like JavaScript or Rust. You need very little of it: most scripts are a list of function calls. This page covers the parts that come up in music scripts. The Rhai book has the rest.

Statements and comments

Statements end with ;. Line breaks inside a call are fine, which is how long patterns stay readable:

blank("house");   // a comment runs to the end of the line
/* a block comment
   spans lines */
track("lead", n("0 2 4 7")
                .scale("A:minor")
                .s("pluck"));

Values

valuewrittennotes
integer8, -3whole numbers; some functions accept only these
decimal0.5, 124.0, -6.5
string"bd sd"patterns are strings; \" and \\ escape
booleantrue, false
array["kick", "hat"]
map#{ level: -3, pan: 0.2 }the options of mix(), part(), section(), song(), master(); a key the function does not know is an error
closure|x| x.fast(2)a small function, passed to every(), sometimes(), off() …

Integers and decimals are different types. Functions that take a count (ply(2), every(4, ...), octave(4), bars_per_chord(2), the bars of sec()) need an integer, and a decimal there is an error:

blank("house");
track("lead", n("0 2 4").octave(4.5));
Error: Function not found: octave (Pattern, f64) (line 2, position 26)

“Function not found” with a list of argument types almost always means one argument has the wrong type. Functions that take an amount (tempo(), gain(), level(), swing()) accept both.

Variables

let makes a variable, const a constant. Variables hold anything, patterns included:

blank("house");
let groove = "x ~ ~ x ~ ~ x ~";
const ROOTS = "<a1 f1 c2 g1>";
track("bass", note(ROOTS).struct(groove).s("sub_bass"));
track("keys", chord("<Am F C G>").struct(groove).s("epiano"));

Both tracks share one rhythm, so the bass and the chords hit together.

Calling methods

A pattern method is called with a dot and returns a new pattern, so calls chain left to right. Each method applies to everything before it:

blank("house");
track("hats", s("hh*8").gain(0.6).pan(0.2).every(4, |x| x.fast(2)));

Closures

Higher-order methods take a closure: |x| ... is a function of one pattern, x, that returns a pattern. A closure can use variables from around it:

blank("house");
let up = 7;
track("lead", n("0 2 4 2").scale("A:minor").every(2, |x| x.add(up)).s("pluck"));

When the song prints as a script, the closure prints with the value it used: .every(2, |x| x.add(7)).

Functions, loops and conditions

Rhai has fn, for, while, if and switch. They are useful for building strings and for repeated edits:

song("house", 9);

// the same reverb send on several tracks
for t in ["chords", "pad", "lead"] {
    mix(t, #{ reverb: 0.3 });
}

// a function that writes a pattern string
fn every_beat(sound) { sound + " " + sound + " " + sound + " " + sound }
track("rim", s(every_beat("rim")).gain(0.5));

let late_night = true;
if late_night { tempo(120); swing(0.56); } else { tempo(126); }

A function defined with fn cannot see variables from outside it; pass what it needs as arguments. Ranges are written 0..4 (0 to 3) and 0..=4 (0 to 4).

Strings

Strings join with +. Backtick strings fill in ${...}:

blank("house");
let n = 4;
track("kick", s(`bd*${n}`));
track("hats", s("[~ hh]" + "*4"));

Printing

print(x) and debug(x) write to the script’s log: the terminal for claudius script, the log panel in the studio. Lines printed by the script come after the lines claudius writes for each track().

blank("house");
track("kick", s("bd*4"));
print("kick written");
track kick: bd
kick written

What a script cannot do

A script cannot read or write files (except audio files named in .s(), see Sounds), import modules, call eval, or run forever. It stops with an error after 10 seconds or 5,000,000 operations. The full list is in Errors and limits.

Mini-notation

Mini-notation is the little language inside pattern strings: s("bd*4, [~ cp]*2"). It comes from TidalCycles and Strudel, and claudius implements most of it. This page is the exact grammar, taken from the tokenizer and parser in crates/claudius/src/pattern/mini.rs, followed by what each construct means and where claudius differs from Strudel.

A string is parsed when the pattern is built, so a syntax error stops the script at the line that wrote it.

Grammar

Two levels: the tokenizer cuts the string into tokens, the parser builds a pattern from them. Whitespace separates tokens and is otherwise ignored, so a ! and a! are the same.

Tokens

token       = symbol | rest | hold | number | word ;
symbol      = "[" | "]" | "<" | ">" | "{" | "}" | "(" | ")"
            | "," | "|" | "*" | "/" | "@" | "!" | "?" ;
rest        = "~"
            | "-" ;                (* only when the next character is not a digit or "." *)
hold        = "_" ;                (* only when the next character is not a word character *)
number      = num-start { digit | "." } ;
                                   (* not followed by a word character other than ".";
                                      the text must read as a decimal number, else "bad number" *)
word        = word-start { word-char | "/" ascii-letter }
                                   (* a slash followed by a letter stays in the word: "F/A", "V7/V" *)
            | num-start { digit | "." } word-char-not-dot { word-char } ;
                                   (* "808bd", "3am", "1-2": a number run that continues into letters *)
word-start  = letter | "#" | ":" | "_" | "'" | "^" ;
num-start   = digit | "-" | "." ;
word-char   = letter | digit | "#" | ":" | "." | "_" | "'" | "-" | "^" ;
word-char-not-dot = word-char - "." ;
letter      = (* any Unicode alphabetic character *) ;
ascii-letter = "a" .. "z" | "A" .. "Z" ;
digit       = (* any Unicode digit *) ;

A / followed by a letter belongs to the word it is in, so slash chords (F/A) and applied chords (V7/V) are single words. Anywhere else / is the “slower” modifier: [a b]/2, a/2.

Any other character is an error: unexpected '%' in "bd%4". That includes %, $, =, +, ", °, ♭ and ♯, so write bb rather than B♭, and o or dim rather than °.

Patterns

pattern     = stack ;                                (* the whole string *)
stack       = sequence { ( "," | "|" ) sequence } ;
sequence    = { step | "_" | "!" } ;                 (* may be empty: silence *)
step        = atom { modifier } ;
atom        = "[" stack "]"
            | "{" stack "}"
            | "<" alternation
            | "~" | "-"
            | word
            | number ;
alternation = alt-sequence { "," alt-sequence } ">" ;
alt-sequence = { step | "!" } ;
modifier    = "*" number
            | "/" number
            | "@" number
            | "!" [ number ]
            | "?" [ number ]
            | "(" number "," number [ "," number ] ")" ;

Rules the grammar alone does not show:

  • In a sequence, _ adds one step of length to the step before it, and a bare ! repeats the step before it. Either one with no step before it is an error.
  • Inside < >, _ and | are errors, and @ weights are read but ignored.
  • * and / take a number above 0 and up to 256.
  • A stack mixes , and | badly: write [a | b], c, not a | b, c (see Differences).
  • Size limits: a string is at most 4096 characters, brackets nest at most 64 deep, a sequence has at most 1024 steps, !n repeats a step at most 256 times, and a euclidean rhythm has at most 256 slots.

What each construct means

A pattern string describes one cycle, and in claudius one cycle is one bar. The constructs below divide the cycle, layer things in it, or change from one cycle to the next. Patterns and time explains cycles in full.

syntaxexamplemeaning
sequencebd sd bd sdthe steps share the cycle equally: four steps are four beats
restbd ~ bd ~, bd - bd -a step of silence
groupbd [sd sd]a sequence squeezed into one step
braces{bd sd}the same as [bd sd]
stackbd*4, hh*8play together, each with the whole cycle
alternation<a1 f1 c2 g1>one item per cycle, in turn
choicebd | cp | rimone sequence per cycle, picked at random
fasthh*8the step plays 8 times within its slot
slow[bd sd]/2the step’s content spreads over 2 cycles
weighta@3 bthe step takes 3 shares of the cycle (here 3/4)
holda _ _ bthe same as a@3 b
repeata!3 b, a ! bthe step repeated as separate steps (a a a b, a a b)
drophh?, hh?0.3the step plays with probability 1 − p (p = 0.5, or the number)
euclidbd(3,8), bd(3,8,2)k hits spread over n slots, rotated left by r
indexbd:3a word carrying a variation number (see Sounds)

Modifiers apply to the step they follow and chain left to right: hh*2? drops some of the doubled hits, [bd sd](3,8) plays the pair inside each euclidean hit.

Sequences and weights

A sequence divides the cycle by weight. Every step weighs 1 unless @ says otherwise, and each _ after a step adds 1:

blank("house");
track("lead", note("e4@3 d4 c4@2 d4 e4").s("pluck"));   // 3/8, 1/8, 2/8, 1/8, 1/8 of the bar

!n writes the step n times; each copy is a full step. a! with no number means one more copy, and ! after whitespace is the same token, so a ! b is a a b. A number after a bare ! is read as the count: a ! 3 is a a a, not a a 3.

Groups, stacks and alternation

[ ] makes a sub-sequence that takes one step. A comma at the top level, inside [ ] or inside < > stacks layers that all take the full span:

blank("house");
track("drums", s("bd*4, [~ cp]*2, [~ hh]*4, hh*16?0.6"));

< > plays one item per cycle: <a1 f1 c2 g1> is A in bar 1, F in bar 2, C in bar 3, G in bar 4, then again. Items can be groups, and alternations nest: <a <b c>> plays a, b, a, c. Inside < >, ! repeats an item: <c2!2 ab1!2> plays C in bars 1 and 2, then A♭ in bars 3 and 4, a new event in every bar. <c2 ab1>/2 covers the same four bars with two events, each two bars long.

Speed

*n plays the step’s content n times within its slot; /n stretches it over n slots, so it continues in the next cycle. In x [a b c d]/2, the second half of bar 1 plays a b and the second half of bar 2 plays c d. The number can be a decimal above 0 and up to 256; it cannot be a pattern (bd*<2 3> is an error).

Euclidean rhythms

x(k,n,r) puts k hits on n equal slots as evenly as possible, with Bjorklund’s algorithm as Tidal and Strudel run it, and rotates the result left by r slots. A k larger than n fills every slot; an n of 0 is silence.

patternslots (x hit, . rest)
x(3,8)x..x..x.
x(3,8,2).x..x.x.
x(5,8)x.xx.xx.
x(5,8,2)xx.xx.x.
x(3,16)x....x....x.....
x(5,16)x..x..x..x..x...
x(7,16)x..x.x.x..x.x.x.

Randomness

? and | are random, but not from run to run: their random numbers are seeded from the text of the string. The same string gives the same choices on every render, a different bar gets a different choice, and any edit to the string (even a space) gives new choices. hh*16?0.3 thins a 16th-note hat differently in each bar of a section, and identically every time you render.

Values

What a word or number means depends on the pattern it is in:

patternwordsnumbers
s() / sound()sound names, name:2 for a variation, pack:instrumentsound names (808)
note()note names: c, c#4, eb3, fs2MIDI note numbers: 60 is middle C
n()an errorscale degrees from 0
chord()chord symbols, slash chords, Roman numerals, prog, prog:Ban error
seq(), and arguments of .gain(), .struct() …x, t, trueamounts

In a sound name, only a number after the last colon is a variation: bd:3 and tr808:sd:2 carry one, tr808:sd is a single name. Pitch has the note, scale and chord rules. In .struct() and .mask(), a value is “on” when it is a non-zero number or any word except 0, false and f.

Errors

Errors name the token the parser did not expect. These are the messages, as claudius script prints them:

blank("house");
track("x", s("[bd sd"));
Error: Runtime error: expected ']', found the end (line 2, position 12)
stringerror
bd sd]unexpected ']'
[bd sdexpected ']', found the end
<bd sdmissing '>'
bd(3,8expected ')', found the end
bd(3)expected ',', found ')'
bd*<2 3>expected a number, found '<'
bd/0'/' takes a number above 0 and up to 256, not 0
bd*1000'*' takes a number above 0 and up to 256, not 1000
_ bd'_' needs a step before it
! bd'!' needs a step before it
<bd | sd>unexpected '|'
<bd _ sd>unexpected '_'
bd . sdbad number '.'
bd%4unexpected '%' in "bd%4"
7/Vexpected a number, found 'V' (a word starting with a digit does not take a slash)
bd!300a step repeats at most 256 times
more than 1024 steps in one sequencea sequence has at most 1024 steps
bd(3,300)a euclidean rhythm has at most 256 steps
65 nested [brackets are nested more than 64 deep
a 5000-character stringmini-notation is limited to 4096 characters (this one has 5000)

A word that parses but means nothing is not an error at this stage; it is one when the track is made, and the message says what was expected: s("bdd") is an unknown sound, note("h3") cannot be read as a note, chord("Xyz") cannot be read as a chord, and n("0 e") is not a scale degree. See Errors and limits.

Differences from Strudel

Strudelclaudius
{a b, c d e} is a polymeterbraces are plain groups; there is no polymeter and no %
a . b c groups with dots. is an error
<a@3 b> holds a for 3 cyclesweights inside < > are ignored; write <a!3 b>
a ! 3 is a a 3a ! 3 is a a a
bd*<2 3> takes a pattern* and / take numbers only, above 0 and up to 256
a | b, c| binds across the comma: it plays b with a random pick of a or c; bracket it: [a | b], c
"c e g" with a cps of your choiceone cycle is always one bar at the song’s tempo

Patterns and time

A pattern is a description of events in time that claudius can ask questions of: “what happens in bar 3?” This page says exactly how those questions are asked and answered, because everything else (alternation, every, prog, randomness) follows from it.

One cycle is one bar

Pattern time is counted in cycles, and a cycle is always one bar of four beats at the song’s tempo. At 120 BPM a cycle lasts 2 seconds; at 174 BPM, 1.38 seconds. There is no separate cycles-per-second setting: change the tempo with tempo() and the patterns follow.

Every duration in the pattern language is a fraction of a bar:

durationcyclesin mini-notation
a bar1a
a half note0.5a b
a beat (quarter note)0.25a*4
an eighth note0.125a*8
a sixteenth note0.0625a*16
an eighth-note triplet1/12a*12
a sixteenth-note triplet1/24a*24

So .late(0.25) moves a pattern one beat later, and .off(0.125, f) adds a copy an eighth note behind.

Events

A pattern produces events. Each event has a whole, the span it occupies (start and end, in cycles), a value ("bd", "c3", 4, "Am7"), and controls set by methods such as .gain() or .legato(). In s("bd sd"), bar 1 has two events: bd from 0 to 0.5 and sd from 0.5 to 1.

Queries

When claudius needs a pattern’s events, it asks for a span of cycles, from b up to (not including) e, and gets the events whose start falls inside the span. An event that started earlier and is still sounding is not returned, and events are never cut into fragments.

This differs from Strudel, which returns fragments of events that overlap the span. You notice it in one place: an event longer than a bar exists only in the bar where it starts. note("<c3 g3>/2") has an event in bars 1 and 3, none in bars 2 and 4; each note still lasts its full two bars, so you hear it. Methods that read another pattern at each event look further back, so they still find it (see Reading one pattern from another).

Sections restart their patterns

A pattern track plays in the sections that list it (see Form). For a section of N bars, claudius asks the pattern for cycles 0 to N and places the result at the start of the section. Every section therefore starts the pattern from its first cycle:

  • an alternation <a b c> starts again from a;
  • .every(4, f) applies f on the first bar of each section, then every fourth bar;
  • chord("prog") follows the section’s progression from its first chord;
  • random choices repeat: bar 1 of every section makes the same choices.
blank("house");
tempo(120);
track("lead", n("<0 2 4 6>").scale("A:minor").octave(4).s("pluck"));
form([sec("groove", 6, "lead"), sec("break", 4, "lead")]);

The groove plays A, C, E, G, A, C; the break starts again on A. A section whose length is not a multiple of the pattern’s length cuts the pattern off, so a 4-bar idea in a 6-bar section plays once and a half.

Drum lanes written with drum() work the same way: their step pattern is tiled from the start of each section.

From events to notes

When a section is arranged, each event becomes a note:

  • start: the event’s start, in beats from the section’s first beat (cycle × 4);
  • length: the event’s whole length × legato (1 when not set), never shorter than 0.02 beats;
  • velocity: velocity, else gain, else 0.9 for sound patterns and 0.8 for the others; anything above 1 plays as 1;
  • pitch: from the value, as described in Pitch.

Events shifted by .late() or .early() follow the same rule: .late(0.25) pulls the last beat of cycle −1 into the first beat of every section, and drops the last beat of the section’s final bar.

Swing and humanize are not part of the pattern. They are applied when the song is rendered, to every track: see swing() and humanize().

What a pattern’s values mean

The constructor decides the pattern’s kind, and the kind travels with it through every method:

constructorkindvalues are
s(), sound()soundsound names
note()notenote names or MIDI numbers
n()degreescale degrees
chord()chordchord symbols, Roman numerals, prog
seq()plainnumbers and words, for controls and structure

stack() and cat() take the kind (and the track settings such as .s() and .scale()) of their first pattern. Stack patterns of one kind: stack([note("c3"), s("bd")]) reads bd as a note name and fails with cannot read 'bd' as a note.

Reading one pattern from another

.struct(p), .mask(p) and the per-event controls (.gain(p), .velocity(p), .legato(p), .add(p), .transpose(p), .n(p)) read a second pattern at the start of each event: they take the event of p that is sounding at that moment, including one that started in an earlier bar (up to 64 bars back).

blank("house");
// each bar's root, played on the struct's hits
track("bass", note("<a1 f1 c2 g1>").struct("x ~ x x ~ x x ~").s("sub_bass"));
// a different gain on each beat
track("hats", s("hh*16").gain("0.9 0.4 0.7 0.4"));

A value that lasts longer than a bar holds for all of it. Both of these play A for two bars, then F for two:

blank("house");
track("bass", note("<a1 f1>/2").struct("x ~ x ~").s("sub_bass"));
track("bass2", note("<a1!2 f1!2>").struct("x ~ x ~").s("sub_bass"));

Randomness

Random methods (? and | in mini-notation, .degrade(), .degradeBy(), .sometimes() and its relatives) are deterministic. Each decision comes from a hash of a seed and the start time of the event it is about, and the seed comes from code:

random partseeded by
?, | in a stringthe text of the string
.degrade(), .degradeBy(p)the code of the pattern before the call
.sometimes(f), .often(f) …the code of the pattern before the call, and the method’s name
.sometimesBy(p, f)the code of the pattern before the call

What follows from that:

  • The same script renders the same song every time, on every machine.
  • Different bars get different choices, and every section repeats the choices of its own first bars.
  • Two tracks with the same code make the same choices. s("hh*8?") on two tracks drops the same hits.
  • Editing the code before a random method changes its choices; editing after it does not. In s("hh*8").degrade().gain(0.6), changing the gain keeps the same hits, changing "hh*8" to "hh*8 " does not.

To hear a different variation of the same idea, change the code before the random method without changing the music: a space added inside the pattern string ("hh*8 ") is enough. .degrade() and .degradeBy(0.5) make identical choices, since they share a seed.

Pattern functions

Every function that makes or changes a pattern, with its parameters, what it accepts, the errors it raises and an example. Methods are called on a pattern (s("bd*4").fast(2)) and return a new pattern; the original is unchanged.

Many parameters accept a number, pattern string or pattern: gain(0.8), gain("1 0.6") and gain(seq("1 0.6")) are the same kind of argument. Anything else raises expected a number, a pattern string or a pattern. How a second pattern is read is explained in Reading one pattern from another.

Constructors

s() and sound()

s(pattern)        // number, pattern string or pattern
sound(pattern)    // the same

A pattern of sound names: drums, synth presets, sampled instruments, kit sounds such as tr808:sd (see Sounds). Each word is one hit; a number after the last colon picks a variation (bd:3, tr808:sd:2). A track whose sound pattern uses several names is split into one track per name (drums.bd, drums.hh …) so each gets its own mixing role.

blank("house");
track("drums", s("bd*4, [~ cp]*2, [~ hh]*4"));

note()

note(pattern)     // note names or MIDI numbers

A pattern of pitches: c3, eb2, f#4, or MIDI numbers (60 is middle C). A name without an octave is in octave 3. The instrument comes from .s(); without it, the pluck synth plays. See Pitch.

Errors: a word that is not a note name, when the track is made: cannot read 'h3' as a note (names with an octave like c3, f#4, bb2, or MIDI numbers).

blank("house");
track("bass", note("<a1 f1 c2 g1>").struct("x ~ ~ x ~ x ~ ~").s("sub_bass"));

n()

n(pattern)        // scale degrees, from 0

A pattern of scale degrees in the scale set by .scale(), or the song’s key when there is none. Degree 0 is the tonic in octave 3 (.octave() changes it); 7 in a seven-note scale is the tonic an octave up; negative degrees go down. Without .s(), the pluck synth plays. See Pitch.

Errors: a word instead of a number, when the track is made: n() takes scale degrees (0 1 2 -1 ...), not 'e'.

blank("house");
key("A minor");
track("lead", n("0 2 4 7 4 2").octave(4).s("pluck"));

chord()

chord(pattern)    // chord symbols, Roman numerals, prog, prog:NAME

A pattern of chords: names (Am7), slash chords (F/A), Roman numerals (ii7, V7/V). Each event plays every note of its chord, voiced with voice leading (see Voicing), or only its root with .root(). prog plays the song’s progression for the section; prog:B always plays progression B (or A when the song has no B). Without .s(), the epiano synth plays.

Errors: a word that is neither, when the track is made: cannot read 'Xyz' as a chord (names like Am7, Fmaj7, Bb, numerals like ii7, bVII, V7/V, or prog).

blank("house");
key("A minor"); prog("A", "Am7 Fmaj7 Dm7 Em7"); bars_per_chord(1);
track("keys", chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano"));

seq()

seq(pattern)      // plain values

A pattern of plain numbers and words, for arguments: .gain(seq("1 0.6")), .struct(seq("x ~ x x")). A string argument does the same job, so seq() is mostly useful for building an argument once and reusing it.

stack()

stack([pattern, pattern, ...])

Plays patterns together. The result has the kind and track settings of the first pattern, so stack patterns of one kind.

Errors: stack() takes patterns when an item is not a pattern; stack() needs at least one pattern for [].

blank("house");
track("drums", stack([s("bd*4"), s("[~ cp]*2"), s("hh*8").gain(0.5)]));

cat()

cat([pattern, pattern, ...])

One pattern per bar, in turn: bar 1 plays the first, bar 2 the second, and so on, then again. Like stack(), it takes the kind and track settings of the first pattern. Errors as for stack(), with cat() in the message.

blank("house");
track("drums", cat([s("bd*4, hh*8"), s("bd*4, hh*8"), s("bd*4, hh*8"), s("bd*2 [bd*4], hh*16")]));

silence()

silence()

A pattern with no events.

Time

fast() and slow()

pattern.fast(factor)     // number above 0, up to 256
pattern.slow(factor)     // number above 0, up to 256

fast(2) plays the pattern twice per bar; slow(2) spreads one cycle over two bars. Factors can be decimals.

Errors: fast needs a number; fast takes a number above 0 and up to 256, not 1000; the same for slow.

blank("house");
track("hats", s("hh hh hh oh").fast(2));          // eighth notes, an open hat on the "and" of 2 and 4
track("pad", chord("<Am F>").slow(2).s("pad"));     // each chord for two bars

late() and early()

pattern.late(cycles)     // number; negative moves earlier
pattern.early(cycles)    // number; negative moves later

Shifts the pattern in time. late(0.25) is one beat; small values such as late(0.02) make a part sit lazily behind the beat. At a section’s start, a shifted pattern brings in the end of the bar before it.

Errors: late needs a number, early needs a number.

blank("lofi");
track("keys", chord("<Dm9 G9>").s("epiano").late(0.02));

rev()

pattern.rev()

Reverses each bar: the last event comes first.

blank("house");
track("lead", n("0 1 2 4").scale("A:minor").octave(4).every(2, |x| x.rev()).s("pluck"));

ply()

pattern.ply(count)       // integer, 1 to 256

Repeats each event count times inside its own slot: s("bd sd").ply(2) is s("[bd bd] [sd sd]").

Errors: ply takes a number above 0 and up to 256, not 300; ply takes a whole number from 1 to 256, not 1.5.

blank("trap");
track("hats", s("hh*8").lastOf(4, |x| x.ply(2)));

Higher-order functions

These take a closure |x| ... that receives the pattern and returns a changed one. Track settings written inside a closure (|x| x.s("piano")) have no effect: only the events it returns are used.

every() and lastOf()

pattern.every(n, f)      // n: integer (below 1 acts as 1), f: closure
pattern.lastOf(n, f)

every(n, f) uses f’s version in the first bar of every n, counted from the start of the section (bars 1, 5, 9 … for n = 4). lastOf(n, f) uses it in the last bar of every n (bars 4, 8, 12 …), which is where a fill belongs.

blank("house");
track("drums", s("bd*4, [~ cp]*2").lastOf(4, |x| x.fast(2)));   // a doubled bar before each phrase

sometimes() and its relatives

pattern.sometimes(f)       // probability 0.5
pattern.often(f)           // 0.75
pattern.rarely(f)          // 0.25
pattern.almostNever(f)     // 0.1
pattern.almostAlways(f)    // 0.9
pattern.sometimesBy(p, f)  // p: number; 0 never, 1 always

Decides once for each event of the pattern, with the given probability, whether f applies to it, the way Strudel does. The chosen events go through f and the others play as they are, so each event is either itself or f’s version of it, never both: s("hh*16").sometimes(|x| x.late(0.01)) plays sixteen hats, some nudged late, and .sometimes(|x| x.ply(2)) doubles some events and leaves the others single. A transform that changes speed (fast, slow) is the exception: it picks events in its own time, so its copies can land on top of originals. The choice is random but repeatable (see Randomness).

Errors: sometimesBy needs a number.

blank("house");
track("hats", s("hh*16").gain(0.6).sometimes(|x| x.gain(0.3)));         // accents vary hit by hit
track("ride", s("rd*8").sometimes(|x| x.ply(2)).gain(0.5));              // some eighths become two sixteenths
track("lead", n("0 2 4 2").scale("A:minor").octave(4).rarely(|x| x.add(7)).s("pluck"));

off()

pattern.off(cycles, f)   // cycles: number; f: closure

Plays the pattern and, on top of it, f’s version shifted later by cycles. A classic is an echo an eighth note behind, an octave up.

Errors: off needs a number.

blank("house");
track("arp", n("0 2 4 6").scale("A:minor").octave(4).off(0.125, |x| x.add(7)).s("pluck"));

superimpose()

pattern.superimpose(f)

Plays the pattern and f’s version together, unshifted. Use it for doublings: a third or an octave above.

blank("house");
track("lead", n("0 1 2 4").scale("A:minor").octave(4).superimpose(|x| x.add(2)).s("pluck"));

degrade() and degradeBy()

pattern.degrade()        // drops each event with probability 0.5
pattern.degradeBy(p)     // p: number; 0 keeps all, 1 drops all

Drops events at random, repeatably. degradeBy(0.3) keeps about 70 % of the events. The mini-notation ? does the same per step.

Errors: degradeBy needs a number.

blank("house");
track("shaker", s("shaker*16").degradeBy(0.3).gain(0.5));

Structure

struct()

pattern.struct(rhythm)   // number, pattern string or pattern

Takes the timing from rhythm and the values from the pattern. Every “on” event of the rhythm (any non-zero number, any word but 0, false, f) becomes an event with the value the pattern has at that moment. Rests in the rhythm are silent. Use it to give a melody or a chord progression a rhythm without rewriting it.

blank("house");
track("stabs", chord("<Am7 Dm7>").struct("~ x ~ x ~ x ~ [x x]").s("stab"));

mask()

pattern.mask(onoff)      // number, pattern string or pattern

Keeps the events of the pattern that start where onoff is on, and removes the others. Unlike struct(), it never adds events or changes timing.

blank("house");
track("hats", s("hh*16").mask("<1 [1 0] 1 [1 0 0 1]>"));   // the hats drop out in places, bar by bar

euclid() and euclidRot()

pattern.euclid(k, n)         // integers: k hits over n slots
pattern.euclidRot(k, n, r)   // and rotated left by r slots

The pattern played on a euclidean rhythm. s("rim").euclid(5, 16) is s("rim(5,16)"). n is 1 to 256, as in mini-notation (otherwise a euclidean rhythm has 1 to 256 steps, not 1000); k is held between 0 and n; r wraps around, so a negative r rotates right.

blank("techno");
track("rim", s("rim").euclidRot(5, 16, 2).gain(0.6));

Per-event controls

Each control is read from its argument at the start of every event, so a pattern string gives different values in different places.

gain() and velocity()

pattern.gain(amount)       // number, pattern string or pattern; 0..1
pattern.velocity(amount)

How hard each note is played. Both set the same thing; when both are present, velocity wins. Without either, sound patterns play at 0.9 and the others at 0.8. Values above 1 play as 1. On synthesized drums and audio files, the level follows velocity to the power 1.6, so 0.5 sounds about 10 dB below 1; synth presets and sampled instruments respond the way the instrument does, with tone as well as level (a sampled piano switches to softer recordings).

blank("house");
track("hats", s("hh*16").gain("[0.4 0.25 0.8 0.25]*4"));   // accent on the off-beat eighth

legato()

pattern.legato(fraction)   // number, pattern string or pattern; 1 = the full slot

How long each note lasts, as a fraction of its slot. 0.5 is staccato, 1 (the default) is the whole slot, and values above 1 overlap the next note. On the mono synths with glide (808, acid, reese, square_lead) an overlap makes the pitch slide into the next note; sub_bass and wobble tie the notes without starting the envelope again. Arpeggiated notes default to 0.9.

blank("house");
track("bass", note("a1*8").s("organ_bass").legato("0.4 0.9"));

add() and transpose()

pattern.add(amount)        // number, pattern string or pattern; rounded to a whole number
pattern.transpose(amount)  // the same, in semitones always

Both move pitches. transpose() always counts semitones. add() adds to the pattern’s values, so what one step means depends on the pattern:

patternadd(1) movestranspose(1) moves
note()one semitone upone semitone up
n()one scale degree up: add(7) is an octave in a seven-note scale, add(5) in a pentatonic onethe played note one semitone up
chord()the voicing range one semitone up (voice leading still picks the notes)every voiced note one semitone up
chord().root()the root one semitone upthe root one semitone up
s()the sample or drum sound one semitone upthe same

n("0 2 4").transpose(12) is the same line an octave up in any scale; n("0 2 4").add(7) is too, in a seven-note scale. Several calls add up.

blank("house");
key("A minor");
track("lead",  n("0 2 4 2").octave(4).add("<0 0 -2 -1>").s("pluck"));       // the motif moves by scale steps
track("lead2", n("0 2 4 2").octave(4).transpose(12).s("bell").level(-6));   // the same line an octave up
track("perc",  s("rim*8").transpose("0 7 12 7").gain(0.5));

n() on a sound pattern

pattern.n(index)           // number, pattern string or pattern

On an s() pattern, picks the variation of each sound, like bd:3. It replaces an index written in the word.

blank("house");
track("kick", s("bd*4").bank("tr909").n("0 0 3 0"));

Track settings

These describe the track rather than single events, so they apply to the whole track wherever they appear in the chain, and a later call replaces an earlier one. Only the pattern given to track() counts: settings inside a closure are ignored.

methodwhat it setsvalues
.s(name), .sound(name)the instrument of a note(), n(), chord() or seq() patterna sound name or a file path
.bank(name)the drum machine or kit for the names in an s() patterntr909, tr808, tr707, acoustic, uzu, apple …
.scale(name)the scale for n(), and the key for Roman numerals in chord()"A:minor", "D dorian", "E:minor:pentatonic", "C:blues"
.octave(n)the octave of degree 0 in n(), of note names without an octave, and of .root() notesinteger; default 3, 2 for .root()
.voicing(), .voicing(lo, hi)the range chord voicings stay inMIDI integers, default 52, 74
.arp(mode)arpeggiate each chord event"up", "down", "updown"
.root()a chord() pattern plays only each chord’s root (its slash bass, when it has one)octave 2 unless .octave(n) says otherwise
.role(name)the mixing rolekick, snare, bass, chords, pad, lead …
.level(db)level in dB relative to the role’s targetnumber
.pan(x)stereo position−1 left .. 1 right
.room(x), .reverb(x)reverb send0..1
.delay(x)delay send0..1
.duck(x)sidechain depth from the kick0..1; default 0.3, 0 on drums
.lpf(hz), .cutoff(hz)a lowpass filter on the trackHz
.hpf(hz)a highpass filter on the trackHz
.crush(bits)a bitcrusherbits, 1..24
.distort(db)tanh saturationdrive in dB

.scale() and .arp() check their argument: unknown scale 'Q:minor' (try "A:minor", "C:major", "D:dorian", "E:minor:pentatonic"), unknown arp 'sideways' ("up", "down" or "updown"). The numeric settings raise <name> needs a number for anything else, and are not clamped by the pattern; Mixing functions says what the mixer does with them. .s() on an s() pattern does nothing, since the words already name the sounds.

blank("deep_house");
track("keys", chord("<Fm9 Bbm9>").voicing(53, 72).s("epiano")
                .level(-2).pan(-0.2).room(0.3).delay(0.1).lpf(4000));

.root() turns a chord pattern into a bass line that follows it. On the song’s progression, the bass changes when the progression does:

blank("house");
key("A minor"); prog("A", "Am F/A C G"); bars_per_chord(1);
track("keys", chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano"));
track("bass", chord("prog").root().struct("x ~ ~ x ~ x ~ ~").s("sub_bass"));   // A2 A2 C2 G2

Pitch: notes, scales and chords

Three pattern kinds carry pitch: note() (absolute notes), n() (scale degrees) and chord() (chords). This page gives the exact rules for each, then voicing and arpeggios. Pitches are MIDI numbers: 60 is middle C (C4), 69 is the A at 440 Hz, and each step is a semitone.

noteC1C2C3C4C5C6
MIDI243648607284

Note names

note() reads each word as a note name:

  • a letter a to g, upper or lower case;
  • any number of accidentals: # or s for sharp, b or f for flat (c#, cs, eb, ef, cbb);
  • an optional octave number. Without one, the octave is 3, or the track’s .octave(n).

So c is C3 (48), c4 is 60, a1 is 33, f#2 is 42, bb1 is 34. A number instead of a name is a MIDI note: note("60 64 67") is a C major triad from middle C. Decimals are rounded.

blank("house");
track("bass", note("a1 a1 [a2 a1] g1").s("sub_bass"));
track("keys", note("[a3, c4, e4] ~ [f3, a3, c4] ~").s("epiano"));   // chords written note by note

A word that is not a note name is an error when the track is made: cannot read 'h3' as a note (names with an octave like c3, f#4, bb2, or MIDI numbers).

Scale degrees

n() reads numbers as degrees of a scale, counted from 0:

  • degree 0 is the tonic in octave 3 (or .octave(n)): in A minor, A3 (57);
  • degree 7 in a seven-note scale is the tonic an octave up; degree 5 in a pentatonic scale;
  • negative degrees go down: −1 in A minor is G3;
  • decimals are rounded; a word that is not a number is an error (n() takes scale degrees (0 1 2 -1 ...), not 'e').

The scale is the track’s .scale(name), or the song’s key when the track has none. Writing melodies in degrees keeps them in key, and key() moves them with the song.

blank("house");
key("A minor");
track("lead", n("0 2 4 7 6 4 2 1").octave(4).s("pluck"));   // A C E A G E C B, from A4

Scale names

A scale is written as a tonic and a name, separated by : or spaces, in any case: "A:minor", "a minor", "E:minor:pentatonic", "F# dorian".

nameintervals (semitones from the tonic)
major, ionian, maj (or no name)0 2 4 5 7 9 11
minor, aeolian, natural_minor, min, m0 2 3 5 7 8 10
dorian0 2 3 5 7 9 10
phrygian0 1 3 5 7 8 10
lydian0 2 4 6 7 9 11
mixolydian0 2 4 5 7 9 10
harmonic_minor, harmonic0 2 3 5 7 8 11
pentatonic, major_pentatonic0 2 4 7 9
minor_pentatonic0 3 5 7 10
blues, minor_blues0 3 5 6 7 10
chromaticall twelve

Words after the tonic are joined with _, so "minor pentatonic" and "minor:pentatonic" both name minor_pentatonic. Locrian, melodic minor and other scales are not available; .scale() rejects them with unknown scale.

Keys

key() takes a tonic and a mode: "A minor", "Am", "F#m", "Eb" (major), "D dorian", "C# phrygian", "Bb mixolydian", "E harmonic minor". The tonic accepts #, b, ♯ and ♭. The seven modes in the first seven rows of the table above are the keys a song can be in; the pentatonic, blues and chromatic scales exist only for n().

Chord symbols

chord() reads a word as a chord symbol when it starts with a note letter: a root, then a quality suffix.

suffixchordnotes on C
(none), M, majmajorC E G
m, min, -minorC E♭ G
dim, o, °diminishedC E♭ G♭
aug, +augmentedC E G♯
sus2suspended 2ndC D G
sus4, sussuspended 4thC F G
5power chordC G
6major 6thC E G A
m6minor 6thC E♭ G A
7dominant 7thC E G B♭
maj7, M7, Δ, Δ7major 7thC E G B
m7, -7, min7minor 7thC E♭ G B♭
m7b5, ø, ø7half-diminishedC E♭ G♭ B♭
dim7, o7, °7diminished 7thC E♭ G♭ A
add9added 9thC E G D
madd9minor added 9thC E♭ G D
9dominant 9thC E G B♭ D
maj9, M9, Δ9major 9thC E G B D
m9minor 9thC E♭ G B♭ D
m11minor 11thC E♭ G B♭ D F

Roots are case-insensitive (am7 is A minor 7). A slash chord names its bass note after a slash: F/A is F major over A, C/E is C major over E. Inside a pattern string, °, + and ♭ are not allowed by the tokenizer: write dim or o, aug, and b; a slash followed by a letter stays in the word, so chord("<Am F/A C G>") works. An unknown symbol (Cmaj13, Xyz) is an error when the track is made: cannot read 'Xyz' as a chord (names like Am7, Fmaj7, Bb, numerals like ii7, bVII, V7/V, or prog).

The slash bass does not change the voicing (the chord’s own notes are voiced as usual). It is what .root() plays, what generated basslines play on that chord, and what the critic expects the bass to play.

Roman numerals

A Roman numeral names a chord by its place in the key, so a progression in numerals follows every key change. chord() reads any word that is not a chord symbol as a numeral, and prog() stores progressions as numerals.

  • Degree: I II III IV V VI VII, counted in the key’s own mode. VI in A minor is F; IV in A dorian is D; II in E phrygian is F.
  • Case picks major or minor when there is no suffix: IV is major, iv is minor. Case never moves the root.
  • Accidentals before the numeral move the root a semitone: bVII, bVI, bIII, #iv. These are the borrowed chords.
  • Suffixes: 7 is a dominant 7th on an upper-case numeral and a minor 7th on a lower-case one; 9 and 6 work the same way; add9 gives add9 or madd9 by case. °, o, dim make a diminished triad; ø, ø7, m7b5 a half-diminished 7th; °7, o7, dim7 a diminished 7th; +, aug augmented; 11 always a minor 11th. Any other chord-symbol suffix (maj7, maj9, sus4, m7) sets the quality outright, whatever the case.
  • Applied chords: V/V, V7/ii, viio7/V read the part before the slash in the key of the chord after it (major or minor, by that chord’s quality). In C major, V/V is D major. They work in prog() and in pattern strings.
  • A note after the slash (I/E, VI/A) is a slash chord: the chord, with that note in the bass.
numeral in A minorchordnumeral in C majorchord
iAmIC
iiø7Bm7♭5ii7Dm7
IIICiiiEm
iv7Dm7IVmaj7Fmaj7
v7Em7V7G7
V7E7V/VD
VImaj7Fmaj7vi9Am9
VIIGbVIIB♭
blank("house");
key("A minor");
track("keys", chord("<i VImaj7 iv7 V7>").s("epiano"));   // Am, Fmaj7, Dm7, E7: a harmonic-minor turn

A chord pattern with .scale() reads its numerals in that scale instead of the song’s key: chord("<i VI III VII>").scale("C:minor") plays Cm, A♭, E♭, B♭ in any song.

The song’s progression: prog and prog:NAME

In a chord pattern, the word prog means “the chord the song’s progression has here”. The progression is the one the section plays (A unless the section says otherwise; breaks play B, or A when there is none), with bars_per_chord() bars per chord, starting from its first chord at the start of each section and looping. prog:B always reads progression B, whatever the section.

blank("house");
key("A minor");
prog("A", "Am7 Fmaj7 Dm7 Em7");
prog("B", "Fmaj7 G Em7 Am7");
bars_per_chord(1);
track("keys", chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano"));
track("pad",  chord("prog").s("pad").role("pad"));
form([sec("groove", 8, "*"), sec("break", 8, "pad")]);

The keys and the pad play A in the groove and switch to B in the break, without being told. A name with no progression behind it (prog:C when there is no C) plays progression A, as the generated parts do. A section cannot ask for a progression the song does not have: form() and section() refuse it, except that a break with no B plays A.

Writing harmony this way has a second benefit: the arranger’s chord track, the generated parts and the critic’s harmony checks all read the song’s progressions. A chord pattern with explicit names plays what it says, but the critic still checks the melody and bass against prog("A") (on a blank() song, the tonic chord alone), so set it to the same chords.

Roots: bass lines from the chords

.root() makes a chord pattern play only each chord’s root, or its slash bass when it has one, as a single note in octave 2 (.octave(n) moves it). On chord("prog"), that is a bass line that follows the song’s progression, and changes when you change the progression:

blank("house");
key("A minor");
prog("A", "Am F/A C G");
bars_per_chord(1);
track("keys", chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano"));
track("bass", chord("prog").root().struct("x ~ ~ x ~ x ~ ~").s("organ_bass"));

The bass plays A2, A2 (the slash bass of F/A), C2 and G2, a bar each. Because every chord’s root lands in the same octave, the line jumps where a bassist would step (G2 up to A2 is fine, A2 down to C2 is a sixth); write the notes out with note() when the shape of the line matters.

Voicing

Every chord event is voiced, whether or not you call .voicing():

  1. How many voices: the number of different notes in the chord, but at least 3 and at most 4. A triad gets three voices, a 7th chord four, a 9th or 11th chord four.
  2. Which notes go when there are more notes than voices: the fifth first, then the root (the bass covers it). A 9th chord keeps the third, seventh and ninth plus the root.
  3. Doubling when there are fewer notes than voices (a power chord): the root first, then the fifth.
  4. Where: every arrangement of those notes inside the range is scored. Close voicings (3 to 14 semitones from bottom to top) are preferred; intervals under a fourth below E3 and semitone clusters below E4 are penalised; and the distance each voice moves from the previous chord counts most. The first chord of a section is placed near the middle of the range.

The range is .voicing(lo, hi) in MIDI numbers, by default 52 to 74 (E3 to D5). .voicing() without arguments uses the default. .add(n) shifts the range by n semitones.

blank("house");
key("A minor"); prog("A", "Am7 Fmaj7 Dm7 Em7"); bars_per_chord(1);
track("keys", chord("prog").voicing(55, 74).s("epiano"));    // Am7 as C4 E4 G4 A4
track("pad",  chord("prog").voicing(45, 64).s("pad").role("pad"));

The keys hold common tones and move the other voices by step: from Am7 (C4 E4 G4 A4) to Fmaj7 only the G moves, down to F; to Dm7 the E moves down to D (C4 D4 F4 A4); to Em7 the C, F and A each step down (B3 D4 E4 G4). A range narrower than an octave can leave no valid voicing, in which case the chord tones are stacked upwards from lo.

Arpeggios

chord(...).arp(mode)    // "up", "down", "updown"

.arp() plays each chord event’s voiced notes one after another, evenly across the event:

modeorder, for a four-note chord
up1 2 3 4
down4 3 2 1
updown1 2 3 4 3 2

Each note lasts its share × legato (0.9 by default). Any other mode plays up. The speed comes from the events: one chord per bar arpeggiated over a triad gives three notes per bar; give the pattern more events with .struct() or * for faster arpeggios.

blank("synthwave");
key("A minor"); prog("A", "Am F C G"); bars_per_chord(1);
track("arp", chord("prog").struct("x*4").arp("updown").voicing(57, 76).s("pluck"));   // triads: 16th notes

A triad in updown has four steps (1 2 3 2), so four events per bar give straight sixteenths; up would give triplets.

Velocity and length

Pitch patterns use the same controls as every pattern: gain()/velocity() for how hard, legato() for how long. A note lasts its whole slot unless legato says otherwise, so note("a1 ~ ~ ~") is a quarter note and note("a1 _ _ _") a whole bar.

Sounds

Every sound in claudius is real audio: synthesized in code (drum voices, the synth engine) or recorded (sampled instruments and drum machines from the sample packs, Apple Loops, your own files). There is no General MIDI and no neural audio. A sound name appears in two places:

  • the words of an s() pattern: s("bd*4, [~ cp]*2");
  • .s(name) on a note(), n(), chord() or seq() pattern: note("a1 c2").s("upright_bass").

claudius sounds renders every drum voice, synth preset and transition effect into out/sounds/ so you can hear them; claudius-packs aliases lists every sampled-instrument name your installed packs answer to.

How a name is resolved

A number after the last colon is a variation (bd:3, tr808:sd:2); the rest is the name, colons included (tr808:sd, vsco2:violins). When a track is made, each name is looked up in this order, and the first match wins:

  1. With .bank() set, the name is looked up in that bank only.
  2. Drum names play the synthesized drum voices.
  3. Synth presets play the synth engine.
  4. The sample packs: sampled instruments (rhodes, vsco2:violins) and kit sounds (tr808:sd).
  5. Files: a name containing / or ending in .wav, .mp3, .aif, .aiff, .flac, .caf or .m4a.

Anything else is an error that lists what exists:

blank("house");
track("x", s("bdd*4"));
Error: Runtime error: track 'x': unknown sound 'bdd' (drums: bd kick sd snare cp clap hh ch hat oh ohat rd ride cr crash lt mt ht tom rim rs sh shaker cb cowbell snap perc; synths: sub_bass 808 reese acid saw_bass pluck_bass organ_bass wobble pluck supersaw pad warm_pad stab epiano keys bell organ strings square_lead chip; sampled: piano upright rhodes wurlitzer hammond string_ensemble violins cellos pizzicato brass trumpet flute harp marimba vibes kalimba upright_bass guitar choir sax ... (claudius-packs aliases)) (line 2, position 1)

Because drum names and synth presets come first, a few names play synthesis even when a recording by that name exists. Ask for the recording by another name, or exactly as pack:instrument (claudius-packs instruments lists them):

nameplaysfor the recording, write
epianothe FM electric piano synthrhodes, wurlitzer
keysthe FM keys synthpiano, upright
organthe drawbar organ synthhammond, church_organ
stringsthe string-machine synthstring_ensemble, violins
pad, warm_padthe synth padssynth_pad, new_age_pad, bowed_pad
shaker, cowbellthe synthesized drum voicesvcsl:shaker, tr808:sh, tr808:cb

lead is not a synth preset: it plays the sampled FreePats square lead. The synth leads are square_lead, supersaw and the other synth presets.

Drum names

These names play the synthesized drum voices. Each one also decides the mixing role of its track, wherever it appears in the name: bd:3 and tr909:bd are kicks, tr808:sd is a snare, vcsl:shaker a shaker.

namesvoicerole
bd, kickkickkick
sd, snaresnaresnare
cp, clapclapclap
hh, ch, hatclosed hi-hathat
oh, ohatopen hi-hatohat
rd, riderideride
cr, crashcrashcrash
lt, mt, ht, tomtom (low 90 Hz, mid 130 Hz, high 180 Hz; tom is the default tuning)tom
rim, rsrim shotperc
sh, shakershakershaker
cb, cowbellcowbellperc
snapfinger snapperc
perca tuned percussion hitperc

Synth presets

The synth engine has polyBLEP oscillators with unison and analog drift, state-variable and ladder filters, FM, glide and drive. These are its factory presets; the role is what role_of assumes for a pattern track.

presetsoundmonoassumed role
sub_bassa sine sub with a little driveyes, tied notesbass
808a sine with a pitch drop, long decay and heavy drive; glidesyesbass
reesetwo detuned saws and a saw an octave down, a slowly moving ladder filter; glidesyesbass
acidone saw into a resonant ladder filter with a snappy envelope; velocity opens it; glidesyesbass
saw_bassdetuned saws and a square an octave down, filter envelopeyesbass
pluck_basssquare and saw with a short filter pluckyesbass
organ_bassthree sine drawbars (fundamental, octave, two octaves)yesbass
wobbledetuned saws and a square, ladder filter swept by a tempo-synced LFOyes, tied notesbass
pluckthree detuned saws and a square an octave up, fast filter decaynoby pitch
supersawseven detuned saws plus three an octave down, delayed vibratonoby pitch
padfive saws and a triangle, slow attack, long release, slow filter driftnoby pitch
warm_padtriangles and a pulse, slower and darker than padnoby pitch
stabdetuned saws and a square, short with a filter snapnoby pitch
epianoFM tine electric pianonoby pitch
keysbrighter FM keys with a shorter decaynoby pitch
bellFM bell, long decaynoby pitch
organfive sine drawbars, sustainednoby pitch
stringsfive detuned saws, slow attack, delayed vibratonoby pitch
square_leadsquare and saw an octave up, vibrato; glidesyesby pitch
chipa 25 % pulse, shortnoby pitch

“By pitch” means a pattern track gets the role chords when it is a chord() pattern, bass when its median note is below E3, and lead otherwise. Mono presets play one note at a time; with glide, overlapping notes slide (see legato()).

Sampled instruments

With the default packs installed (claudius-packs install --defaults), these names play multi-sampled recordings with velocity layers and round robins. Most have several aliases; claudius-packs aliases prints them all.

familynames
pianos and keyspiano (Salamander grand), upright, rhodes, wurlitzer, cp80, pianet, fm_piano
organshammond, church_organ
stringsstring_ensemble, violins, violas, cellos, contrabass, pizzicato, spiccato
brass and windsbrass, brass_stabs, trumpet, horn, trombone, tuba, woodwinds, flute, oboe, clarinet, bassoon, piccolo, sax, harmonica
mallets and bellsharp, marimba, vibes, xylophone, glock, bells, kalimba, timpani
bass and guitarupright_bass, bass (electric), pick_bass, synth_bass, guitar (nylon), electric_guitar, jazz_guitar
voiceschoir, voice
synths, recordedsynth_pad, new_age_pad, bowed_pad, choir_pad, synth_strings, synth_brass, lead
percussionbongo, conga, cajon, darbuka, frame_drum, tambourine, cabasa, claves, woodblock, agogo, guiro, triangle, sleigh_bells, vibraslap, mark_tree, claps, gong, suspended_cymbal

pack:instrument names one exactly, in .s() or in an s() pattern: note("c4 e4").s("vsco2:violins"), s("vcsl:shaker*8").

Every recorded instrument has a playable range; notes outside it are pitched from the nearest sample, which sounds stretched far from home. Songs that play instruments from packs whose licences ask for credit get a credits.txt in their folder.

A sampled instrument name used without its pack installed is an unknown sound, and the error ends with sampled instruments need packs: claudius-packs install --defaults. A session started with --synth-only (make, evolve, serve, studio and mcp take it) plays no recordings at all: a sampled name ends its error with sampled instruments are off in this session (--synth-only), and .bank("tr909") raises bank 'tr909' plays sample packs, which are off in this session (--synth-only).

blank("lofi");
key("D minor");
track("keys", chord("<Dm9 Gm9>").voicing(50, 72).s("rhodes"));
track("bass", note("<d2 g1>").struct("x ~ ~ x ~ x ~ ~").s("upright_bass"));
track("bongos", s("~ bongo [~ bongo] bongo:2").gain(0.6));

Sampled percussion from the packs gets the perc role by itself.

Drum machines and kits

.bank(name) plays the drum names of an s() pattern on a recorded drum machine or kit from the sample packs:

bankaliaseswhat
tr909909Roland TR-909
tr808808Roland TR-808
tr707707Roland TR-707
muldjordacoustic, acoustic_kit, rock_kita recorded acoustic kit
uzuthe uzu drum kit

Each kit has a subset of bd sd cp hh oh rd cr lt mt ht rim cb sh (the 909 has no shaker, the acoustic kit no clap); names like kick and snare map to them. A name the bank does not have is an error:

blank("house");
track("drums", s("bd*4, bongo*2").bank("tr909"));
Error: Runtime error: track 'drums': no 'bongo' in bank 'tr909' (installed packs: salamander epianos jrhodes vsco2 vcsl tr808 tr909 tr707 uzu muldjord freepats doublebass growlybass voice; kits: tr909 tr808 tr707 acoustic uzu) (line 2, position 1)

The bank applies to every word of the pattern. To mix kits, or a kit and a sampled instrument, in one pattern, name each kit sound with its bank instead: s("tr909:bd*4, tr808:cp*2, bongo*2"). Strudel bank names (RolandTR909) work as aliases, and Strudel sample maps added with claudius-packs add-map become banks too.

.bank("apple") cuts drum one-shots from installed Apple Loops (GarageBand or Logic content) instead. On a machine without Apple Loops it falls back to the synthesized voices, without an error.

blank("house");
track("drums", s("bd*4, [~ cp]*2, [~ hh]*4").bank("tr909"));

Variations

A number after the last colon (bd:3, tr808:sd:2) or .n(n) picks variation n of a sound (0 is the default):

  • on a kit, the recording numbered n: the 909 has 126 kicks, the 808 has 25;
  • on a synthesized drum, the same voice moved a few steps through its parameters (tuning, decay, tone …), the same way every time;
  • a variation a recording does not have is an error (no 'bd:200' in bank 'tr909').
blank("techno");
track("kick", s("bd*4").bank("tr909").n("<0 0 0 7>"));

Your own files

.s(path) plays an audio file as a sampler, on any note(), n() or chord() pattern:

track("vox", note("c4 ~ c4 [c4 c4]").s("/Users/me/Samples/vox.wav"));
  • c4 plays the file as recorded; other notes play it faster or slower, like a sampler without time-stretch.
  • On a track whose role is not a drum role, each note is cut at its length plus 50 ms, so legato() shapes it.
  • Paths cannot go inside a pattern string, so a file is set with .s(), or with drum_sound() for a drum lane of a generated song.
  • A missing file is an error: track 'vox': /Users/me/Samples/vox.wav does not exist.
  • When claudius is shared beyond your computer (a server reached by others), it reads files only from its own folders (saved songs, its caches, the sample packs, Apple Loops) and refuses others with ... is outside the folders this claudius may read.

To use a song or a loop as material, claudius chop cuts bar loops and drum one-shots from it into the user library, and claudius make --sample <file> flips a recording into a new song. See Remaking a song from audio.

Sounds you cannot set from a script

A script names sounds; it does not edit them. The synth presets’ parameters and the tuning and decay of a generated song’s drum voices are set by the generator and changed by evolution. A script can swap them for other sounds: drum_sound() for a drum lane, part() for a generated part’s preset. Pattern tracks can add filters, a bitcrusher and saturation (see Track settings).

Song functions

Song functions edit the current song. Each one changes only what it names, in the order the script calls them. Sections and arrangement are on Form and sections; mix(), master() and the other mixer edits are on Mixing functions.

Starting a song

song()

song(genre)               // a new song, seeded from the clock
song(genre, seed)         // seed: integer
song(genre, options)      // options: map

Replaces the current song with one the generator writes: tempo, key, swing, two progressions, a form, drum lanes, theory-written parts, sounds and a mix, all from the genre’s profile. The same genre and seed give the same song on any machine. The recordings installed (sample packs, Apple Loops) change only which recordings it plays.

optiontypedefaulteffect
seednumber1which song
bpmnumberfrom the genre’s rangetempo
keystringrandom tonic in one of the genre’s modeskey, e.g. "F minor"
shortboolfalsehalve every section (at least 4 bars), for sketches
loopsboolfalsebuild the song around a family of Apple Loops (GarageBand style), when they are installed
samplesnumber0.75chance that each drum lane uses a recorded one-shot from your library instead of synthesis

The genre is one of house deep_house techno trap lofi dnb future_bass synthwave, or one of your own (see Genres); case, spaces and hyphens do not matter ("deep house"). A negative seed acts as 0. bpm and key change only the tempo and the key: the title, the form, progression A and the drum patterns are the ones the seed gives without them. A key in another mode than the seed’s moves the chords into that mode, as key() does. The options are checked like tempo() and key() check their arguments, and an option the map should not have is an error rather than ignored (so is a misspelt one).

Errors: unknown genre 'polka' (try: deep_house, dnb, future_bass, house, lofi, synthwave, techno, trap); tempo between 40 and 240 BPM; cannot read key 'H minor' (try "A minor", "F#m", "Eb", "D dorian"); song() has no option 'tempo' (options: seed bpm key loops short samples).

song("techno", #{ seed: 3, bpm: 130, key: "F minor", short: true });

blank()

blank(genre)

Replaces the current song with an empty one: a single 8-bar section called loop (kind groove, energy 0.8), no tracks, and one progression, A, which is the key’s tonic chord alone (i in a minor key, I in a major one) at bars_per_chord(1). The rest comes from the genre: the mix (bus effects, master chain, loudness target, reverb and delay), the critic’s genre, and a tempo, key, swing and humanize from the generator. Set the ones you care about; in particular, set prog("A", ...) to the chords you play, since the critic checks the bass and melody against it.

blank("lofi");
tempo(84); key("D minor"); swing(0.58);
track("drums", s("bd ~ ~ bd, ~ sd, hh*8?0.15"));

A script that calls neither song() nor blank() edits the song that is open (the studio’s, or the one passed with claudius script --on), and with no song open it starts from blank("house"). So a script for another genre that should run on its own starts with blank().

Errors: as for song().

Global settings

title()

title(name)    // string

The song’s title, used in the song folder’s name (out/house-night-drive-1/).

tempo()

tempo(bpm)     // number, 40..240
bpm(bpm)       // the same function

Errors: tempo between 40 and 240 BPM; tempo needs a number (also from bpm()).

key()

key(name)      // "A minor", "Am", "F#m", "Eb", "D dorian", "C# phrygian" ...

Sets the key. Roman-numeral progressions and n() patterns follow it; note names in note() patterns do not, since they name absolute pitches. When the mode changes (minor to dorian, major to minor), every diatonic chord in every progression moves to the same degree in the new mode: Imaj7 in a major key becomes i7 in minor, IV7 in dorian becomes iv7 in minor. A borrowed chord keeps its sound and is spelled in the new mode: bIII in F major (A flat) becomes III in F minor, where it is diatonic. Applied chords (V/V) stay as written. Because a borrowed chord can become diatonic, going to minor and back does not always restore the original: bVII becomes VII in minor, and VII becomes vii° back in major. Tonal Apple Loops in the song are repitched the shortest way (at most six semitones).

Errors: cannot read key 'H minor' (try "A minor", "F#m", "Eb", "D dorian").

// G dorian to A minor: "i7 ii7 i7 IV7" becomes "i7 iiø7 i7 iv7"
key("A minor");

The accepted names and modes are in Pitch.

swing()

swing(amount)  // number, clamped to 0..0.8

Sixteenth-note swing for the whole song. Each off-beat sixteenth (the second of each pair) is moved to amount of the way through its eighth note: 0.5 is straight, 0.58 is a lazy hip-hop feel, 0.66 is close to triplets. Values up to 0.501 play straight. Only notes exactly on the sixteenth grid move; eighth notes and triplets stay where they are. Swing applies when the song renders, to drums, parts and pattern tracks alike.

humanize()

humanize(amount)   // number, clamped to 0..1

Random timing and velocity, applied at render. The timing offset has a standard deviation of 4 ms × amount on drum roles and 60 % of that on other tracks (synth tracks use 3 ms instead of 4); velocity varies by 5 % × amount. The offsets are seeded by the song and the track name, so they are the same on every render.

Harmony

prog()

prog(name, chords)   // name: string; chords: string, separated by spaces

Sets a progression. Chords are Roman numerals ("i7 VImaj7 iv7 v7") or chord names ("Am7 Fmaj7 Dm7 Em7"); names are converted to numerals in the current key when prog() runs, so call key() first. Each chord lasts bars_per_chord() bars, and the progression loops.

Sections play A unless they say otherwise (sec().prog()), and breaks play B, or A when the song has no B. Any name works: prog("verse", ...) is used by sections that ask for "verse". The progressions drive chord("prog"), the generated parts, the chord track the critic checks melodies and basslines against, and the harmony tab of the studio. Pitch has the numeral rules.

A chord name outside the key gets an accidental (Bb in A minor is bII). A slash chord keeps its bass note, as a name or as a numeral: F/A in A minor is stored as VI/A, and key() moves the bass note with the chord.

Errors: cannot read chord 'Q7' (Roman numerals like i7 VImaj7 bVII, or names like Am7).

blank("house");
key("A minor");
prog("A", "Am7 Fmaj7 Dm7 Em7");     // stored as i7 VImaj7 iv7 v7
prog("B", "Fmaj7/A G Em7 Am7");     // stored as VImaj7/A VII v7 i7
bars_per_chord(1);
track("keys", chord("prog").s("epiano"));

bars_per_chord()

bars_per_chord(bars)   // integer, clamped to 1..8

How many bars each chord lasts: bars_per_chord(2) holds each chord for two bars. It applies to every progression. blank() starts at 1; a generated song has the generator’s value, often 2.

Drums

drum()

drum(lane, steps)                     // the "main" variant, 16 steps per bar
drum(lane, variant, steps)
drum(lane, variant, steps, per_bar)   // per_bar: integer, clamped to 1..96

Writes a drum lane’s pattern as a step string, the way a step sequencer shows it:

characterhit
Xaccent, velocity 1.0
xhit, 0.8
oghost note, 0.45
1 to 9velocity 0.1 to 0.9
|, spacesignored, for readability
anything else (., -, 0 …)rest

There are 16 steps per bar unless per_bar says otherwise: 32 for thirty-second notes, 12 or 48 for triplet grids. The length of the string decides the length of the pattern, rounded up to whole bars (at most 16): 16 steps are one bar, 20 steps are two bars with the last 12 steps empty. All the variants of a lane have the same length; a shorter one is repeated to fit.

On an existing lane, drum() replaces that variant. On a song without the lane, it creates one, if the name is a drum name (or hats): the lane uses the synthesized voice at its default settings, and joins every section with energy 0.6 or more. drum_sound() changes what it plays.

Sections choose a variant by name with .variant(); intros and outros use sparse, the others main, and a lane without the requested variant plays main.

A lane’s mixing role comes from its name when the name is a drum name (bd and kick are kicks, sd and snare snares), else from the sound it plays. The arranger finds lanes by role: snare rolls and fills go on the snare lane, else the clap; the kick drops out at the end of a build, and the other tracks duck against it.

Errors: no drum lane 'bongo', and drum() makes lanes only for drum names (bd kick sd ...); the song's lanes: kick clap hat ohat shaker perc. For other sounds, track("bongo", s("...")).

drum("kick", "X...x...X...x..x");                     // a pickup into the next bar
drum("hat", "sparse", "..x...x...x...x.");
drum("hat", "main", "x.x.x.x.x.x.x.x.x.x.x.x.x.x.xxxx", 32);

drum_sound()

drum_sound(lane, sound)         // sound: string
drum_sound(lane, sound, bank)   // bank: string, a drum machine or kit

Sets what a drum lane plays, keeping its patterns:

  • a drum name, for the synthesized voice: drum_sound("snare", "cp");
  • a kit sound from the sample packs: drum_sound("kick", "tr909:bd"), or the same as drum_sound("kick", "bd", "tr909"), with any bank; a variation number picks another recording ("tr909:bd:12");
  • another recording from the packs, such as "conga";
  • an audio file: drum_sound("perc", "/Users/me/Samples/rim.wav").

Naming the sound the lane already plays keeps it as it is, including the tuning evolution gave a synthesized voice. The lane keeps its name, so a lane named with a drum name keeps its role; a lane named otherwise takes the role of its new sound.

Errors: no drum lane 'bongo' (lanes: kick clap hat ohat shaker perc); drum() makes one, or (lanes: none yet) on a song without drum lanes; 'pad' is not a drum sound (a drum name, a kit sound, or a sample) for a synth preset; the sound errors for a name that does not resolve.

song("house", 9);
drum_sound("kick", "tr909:bd");
drum_sound("clap", "cp", "tr808");

Parts

part()

part(name, options)   // options: map with any of the fields below

Edits a part the theory engine writes: the bass, chords, pad, lead, arp or keys of a generated song. On a song without that part (a blank() song has none), part() first adds the genre’s part, written by the theory engine over the song’s progressions and joining the sections the way a new track does; the options then edit it. part("bass", #{}) adds the genre’s bass as it comes.

fieldtypeeffect
stylestringhow the part is written (below)
presetstringa synth preset or a sampled instrument; naming the current preset keeps its patch
register[low, high]MIDI range the part is written in
densitynumber, clamped to 0..1how busy a lead is
seedintegera new seed rewrites this part, and only this part
ratenumber, clamped to 1/32..4arpeggio step in beats (0.25 is sixteenths)
rolestyles
basssustain, pulse, root8, offbeat, octave, rolling, syncopated, kick_follow (follows the kick, 808 style), acid, walking, reese
chords, pad, keyssustain, half, stabs, offbeat, pump, syncopated, charleston
arpup, down, updown, random, pinky
leadmotif, the only one: leads are motifs with A A′ A B phrasing; density and seed change them

A style the part’s role does not have is an error. A decimal seed is ignored.

Errors: no part 'keys' (the song has: bass chords pad lead arp; house parts: bass chords pad lead arp); track() writes your own; a bass part has no style 'wobbly' (styles: sustain pulse root8 ...); unknown preset 'nope' (synths: ...; or an installed instrument such as piano, rhodes, string_ensemble); part() has no option 'styles' (options: style preset register density seed rate).

part("bass", #{ style: "rolling", preset: "reese" });
part("lead", #{ seed: 42, density: 0.4 });
part("chords", #{ style: "stabs", register: [55, 72] });

On a blank song, part() gives you the generator’s parts over your own progression:

blank("house");
key("A minor"); prog("A", "Am F C G");
track("drums", s("bd*4, [~ cp]*2, [~ hh]*4"));
part("bass", #{ style: "offbeat" });
part("chords", #{ style: "stabs", preset: "stab" });

Tracks

track()

track(name, pattern)

Makes a pattern track, or replaces the track of that name: a drum lane, a generated part, a loop or another pattern track (track("bass", ...) on a generated song replaces its bass part). The pattern’s sounds and words are read here, so an unknown sound, a word the pattern cannot read (note("h3")) or a pattern that is too dense fails at this line.

Where it plays:

  • A replaced track keeps the replaced track’s place in every section.
  • A new track joins every section with energy 0.6 or more, or every section when no section has any track yet (the first track of a blank() song).
  • form() and layer() decide it exactly.

Its mix comes from the pattern’s own settings (.level(), .pan(), .room() …); a mix() on the old track does not carry over. Its role comes from .role(), else from its sound (see Roles). A sound pattern with several sounds becomes one track per sound when arranged: drums.bd, drums.cp, drums.hh.

Each track() writes a log line with the sounds it found:

blank("house");
track("drums", s("bd*4, [~ cp]*2, [~ hh]*4"));
track drums: bd, cp, hh

Errors: track 'x': unknown sound 'bdd' (...); track 'x': cannot read 'h3' as a note (...); track 'x': this pattern is too dense to play: it makes more than 262144 events (or it needs more than 4000000 steps of work); track 'x' plays 2048 notes per bar; the limit is 1024. See Errors and limits.

mute(), unmute(), solo(), unsolo(), remove()

mute(track)    unmute(track)
solo(track)    unsolo(track)
remove(track)

mute() silences a track; while any track is soloed, only soloed tracks play, and only they are judged. remove() deletes the track and takes it out of every section. They work on drum lanes, parts, loops and pattern tracks.

Errors: no track 'nope'.

mute("hat");
remove("arp");

Form and sections

The form is the song’s list of sections: an intro, a groove, a break, a build, a drop. Each section has a kind, a length in bars, an energy, a progression, a drum variant, the tracks (layers) that play in it, and flags that ask the arranger for builds, gaps, impacts, fills and filter sweeps. This page says exactly what each one does.

form()

form([sec(...), sec(...), ...])

Replaces the whole form. Sections play in the order given. Two sections with the same name are renamed in order: the second drop becomes drop2, the third drop3.

Every layer must be a track of the song, or one of the genre’s optional parts and loops (which may be absent from a song, and are silent then). "*" and a missing layer list mean every track that exists when form() runs; tracks added later join by the track() rule. Every section’s progression must exist, except B: a section that plays B on a song without one plays A.

Errors: form() takes sec(...) items; section 'drop' uses 'snare', which is not a track (tracks: kick clap hat ohat shaker perc bass chords pad lead arp); section 'drop' plays progression 'C', which the song does not have (progressions: A B; prog() adds one).

form([
  sec("intro", 8, "kick hat perc pad").filter_in(),
  sec("groove", 16, "kick clap hat ohat perc bass chords"),
  sec("break", 8, "pad chords lead"),
  sec("build", 8, "clap hat pad lead").gap(),
  sec("drop", 16, "*").impact(),
  sec("outro", 8, "kick hat perc bass"),
]);

sec()

sec(kind, bars)            // every track
sec(kind, bars, layers)    // layers: "kick hat", ["kick", "hat"] or "*"

bars is an integer, clamped to 1..256. The kind is one of the words below; breakdown, chorus and loop are accepted for break, hook and groove, and the section keeps the word you wrote as its name.

Each kind comes with defaults:

kindenergyprogressiondrum variantflags on
intro0.4Asparse
verse0.65Amain
groove0.65Amainfills
build0.6Amainbuild
drop0.95Amainfills
hook0.95Amainfills
break0.35B (A when there is no B)main
outro0.4Asparse

The kind also matters to the arranger (drops and hooks get crashes every 8 bars, a final outro closes a filter) and to the critic, which compares the loudness of drops and hooks with breaks.

Errors: unknown section kind 'party' (intro verse build drop break hook groove outro); layers are a string ("kick hats") or an array of names.

Section methods

Each method returns a new section, so they chain: sec("drop", 16, "*").energy(1.0).impact().

methodsetsvalues
.name(s)the section’s name, used by section(), layer() and the studiostring
.energy(x)the section’s energynumber, clamped to 0..1
.prog(name)which progression it playsany progression name
.variant(name)which drum-lane variant it playsmain, sparse, alt, half …
.build()a build into the next sectionalso .build(true), .build(false)
.gap()a beat of silence at the endalso takes true / false
.impact()a crash and an impact on the first beatalso takes true / false
.fills()drum fills at 8-bar phrase endsalso takes true / false
.filter_in()a lowpass opening across the sectionalso takes true / false

.energy() raises energy needs a number for anything else. A variant that does not exist plays main. A progression that does not exist is an error when form() gets the section, unless it is B (see above).

What the flags do

These are the exact rules of the arranger (compose::arrange). Several act on drum lanes only (the lanes written by the generator or by drum()); pattern tracks are never rewritten, so a song made only of patterns gets the risers, sweeps and crashes but no automatic rolls or fills.

The arranger finds lanes by their role. The fill lane is the first lane whose role is snare (a lane named snare or sd, or one that plays a snare sound), or clap when there is none; the kick lane is the first lane whose role is kick.

build

  • The fill lane stops its pattern for the last part of the section and plays a snare roll instead: quarter notes, then eighths, sixteenths and thirty-seconds, with velocity rising from 0.4 to 1.0. The roll lasts half of the section’s first 8 bars, between 1 and 4 bars: 4 bars in an 8-bar build, 2 in a 4-bar one. It is added even when the fill lane is not a layer of the section.
  • The kick lane stops for the last bar.
  • A riser plays over the last bars of the section: the genre’s riser length (8 bars in house, techno, dnb and future bass; 4 in deep house and synthwave), or the whole section if it is shorter. Trap and lofi have no riser.
  • A highpass filter on the music and bass buses sweeps from 10 Hz to 900 Hz across the section and snaps back at the next one.

gap

In the section’s last beat, every track except the riser and the reverse crash is silent: notes that start there are removed and notes that ring into it are cut. Audio loops keep playing. A gap before a drop makes the downbeat land harder.

impact

  • A crash on the first beat, at full velocity.
  • An impact hit and a downlifter, in genres that use them: house, techno, dnb and future bass have both; deep house has only the downlifter, trap only the impact, synthwave and lofi neither.
  • A reverse crash over the last 2 beats of the section before, in genres that use it (all but techno and lofi).

fills

At the end of every 8-bar phrase, counted from the section’s start, the fill lane’s last 1 or 2 beats are replaced by a fill: a sixteenth-note roll, triplets, or a broken groove, at 85 % velocity. At about a third of those phrase ends, the kick drops out for the last half beat. A section shorter than 8 bars gets no fills, and a 12-bar section gets one, at bar 8.

filter_in

A lowpass filter on the music and bass buses opens from 350 Hz to 20 kHz across the section: the classic intro that comes into focus. The drums are not filtered.

Without a flag

  • Drops and hooks get a crash every 8 bars after their first bar, at velocity 0.7.
  • A final outro (the last section, of kind outro) closes a lowpass on the music and bass buses from 20 kHz, starting 40 % of the way in, down to 500 Hz at the end.
  • Every section starts its patterns from their first cycle and its progression from its first chord (see Patterns and time).

Energy

Energy is a number from 0 to 1 that says how intense a section is meant to be. It does not change the sound by itself; what plays decides that. It is used in three places:

  • the critic checks that sections get louder where energy rises (the correlation between energy and measured loudness) and that the loudest drop or hook is at least 3 LU louder than the quietest break;
  • new tracks made by track() or drum() join the sections with energy 0.6 or more;
  • the generator, when it builds a song around Apple Loops, layers the loops into sections by energy.

So set energies that match the layers: a break with three quiet tracks at 0.35, a full drop at 0.95.

section()

section(which, options)   // which: a section name or a 0-based index

Edits one section. A name picks the first section with that name.

fieldtypeeffect
barsnumberlength, clamped to 1..256
energynumberclamped to 0..1
prog, variant, namestringas the methods above
layersstring, array or "*"replaces the layers
build, gap, impact, fills, filter_inboolthe flags

The section is checked as form() checks it: its layers must be tracks (or the genre’s optional parts and loops) and its progression must exist. The map takes only the fields above.

Errors: no section 'nope'; no section 12; section() takes a name or an index; section 'drop' uses 'leed', which is not a track (tracks: ...); section 'break' plays progression 'Z', which the song does not have (progressions: A B; prog() adds one); section() has no option 'bar' (options: bars energy prog variant name layers build gap impact fills filter_in).

section("drop", #{ energy: 1.0, layers: "kick clap hat bass chords lead" });
section(0, #{ bars: 16, filter_in: true });

layer()

layer(section, track, on)   // section: name or 0-based index; on: bool

Switches one track on or off in one section, without touching the others. The track must exist, or be one of the genre’s optional parts or loops.

Errors: no section 'nope'; layer() takes a section name or index; no track 'leed' (tracks: ...).

layer("break", "lead", false);
layer("intro", "shaker", true);

Mixing functions

claudius mixes the way an engineer starts a mix: every track has a role, every role has a target loudness, and each track is brought to its target while it plays. Your settings are offsets from that starting point. This page covers roles, levels, panning, sends, sidechain, buses, inserts and the master, and the functions that set them. Mixing and mastering explains how to use them.

Roles

A role says what a track is in the mix. Each role has a target loudness, measured in LUFS while the track plays (silence does not count), before the master stage:

roletargetroletarget
kick−12.5bass−14.5
snare−13.5keys−17.0
clap−14.5chords−18.0
hat−17.5lead−18.0
ohat−18.5synth−18.0
tom−18.0sample−18.0
perc−19.5arp−20.5
crash−20.0pad−21.5
ride−20.5vocal−15.0
shaker−21.5top−20.0
drums−14.5texture−27.0

Any other role name gets −19. The transition effects have their own: riser −19, impact −16, downlifter −22, reverse −21. The drum roles (kick snare clap hat ohat shaker perc ride tom crash) also get the full amount of humanize, and an audio file on a drum role rings out instead of stopping at its note length.

How a track gets its role:

  • a drum lane: the role of its name when the name is a drum name (kick and bd are kicks), otherwise the role of the sound it plays;
  • a generated part: its role (bass, chords, pad, lead, arp, keys);
  • a pattern track: .role(name) when the pattern sets it; otherwise, for each sound in it, in this order: the role of a drum name anywhere in the sound’s name (tr808:sd is a snare), perc for percussion from the sample packs (conga, bongo), bass for the bass synth presets (808 is a preset first, so it stays bass), chords for a chord() pattern, bass when the track’s median note is below E3 (MIDI 52), and lead for everything else.

The last rule is the one to watch. A pad written with note() or chord(), an arpeggio, a sampled string section: all become lead or chords unless you say otherwise, and a lead is mixed 3.5 dB louder than a pad and checked by the critic as the melody. Set the role:

blank("house");
track("pad",   chord("<Am F>").s("pad").role("pad"));
track("arp",   n("0 2 4 7").scale("A:minor").octave(4).fast(2).s("pluck").role("arp"));
track("conga", s("~ conga ~ [conga conga]"));   // perc without being told

Genres shift some targets: techno’s kick +1 dB; trap’s bass +1.5, kick −1, hat −1; lofi’s kick −1.5, snare −0.5, keys +2.

Level

The gain a track receives is target of its role + genre offset + its level, minus its own measured loudness, limited to −40..+30 dB. level is therefore relative: level: -4 on a pad puts it 4 dB under where a pad normally sits, whatever its sound or velocity. Two tracks with the same role and level play equally loud.

Pan

pan runs from −1 (left) through 0 to 1 (right) and works as a balance control: the far side is turned down and the near side stays at full level. At ±0.5 the far side is about 5 dB down; at ±1 it is silent.

Sends

Every track has two sends, reverb and delay, from 0 to 1. They feed two returns shared by the whole song, whose settings come from the genre:

genrereverbdelay
house2.2 s, 20 ms predelay, low cut 300 Hz0.75 beats (dotted eighth), feedback 0.35, ping-pong
deep_house2.6 s, 25 ms, 280 Hz0.75 beats, 0.3
techno3.0 s, 30 ms, 350 Hz0.5 beats (eighth), 0.45
trap1.8 s, 15 ms, 300 Hz0.75 beats, 0.3
lofi1.6 s, 10 ms, 250 Hz, dark0.75 beats, 0.3, dark
dnb2.8 s, 20 ms, 350 Hz0.75 beats, 0.35
future_bass2.4 s, 20 ms, 300 Hz, bright0.75 beats, 0.35
synthwave3.2 s, 30 ms, 250 Hz, wide0.75 beats, 0.4

The sends are taken after the track’s level, sidechain and pan. The returns are ducked by the kick too, by 30 % of the full depth, so tails breathe with the groove.

Sidechain

duck (0..1) is the depth of sidechain ducking from the kick. At each kick, the track’s gain falls to 1 − duck 3 ms before the hit and recovers over the genre’s release time (0.25 beats in trap and dnb, 0.45 in future bass, 0.3 to 0.35 elsewhere), along a curve that comes back quickly at first. At duck: 0.85 a chord nearly vanishes on each kick and swells back: the future-bass pump.

The kick is the first track whose role is kick; a song without one ducks nothing. Pattern tracks start with duck 0.3, except drum sounds and the kick itself, which start at 0. Generated parts take the genre’s values (in house: bass 0.55, chords 0.45, pad 0.5, lead 0.2).

Buses

Each track plays into a bus, and the buses are summed with the reverb and delay returns into the master:

busgetsalways has
drumsdrum lanes, and pattern tracks that play only drum sounds (drum names, kit sounds, percussion from the packs)a tanh saturator (2.5 dB, half wet) after the genre’s chain, unless the genre already saturates
bassgenerated bass parts, and pattern tracks whose role is bassmono below 140 Hz
musiceverything elsewidth 1.25 with mono below 160 Hz
fxcrashes, risers, impacts, downlifters, reverse crashes

On top, each genre has its own chain per bus (house: a glue compressor on the drums and a 110 Hz highpass on the music bus; lofi: a bitcrusher, a lowpass and a compressor on the drums). The arranger adds filter automation to the music and bass buses: the lowpass of filter_in(), the highpass sweep of a build, the lowpass closing in a final outro. The drums bus is never filtered. A script cannot move a track to another bus or edit the bus chains.

Inserts on pattern tracks

A pattern track can carry its own effects, set with track settings. They run in this order, before the level stage:

  1. .hpf(hz): a highpass filter (resonance 0.1);
  2. .lpf(hz) / .cutoff(hz): a lowpass filter (resonance 0.15);
  3. .crush(bits): a bitcrusher, 1 to 24 bits;
  4. .distort(db): tanh saturation with that much drive.

Because the level stage comes after, saturation and filtering change the tone, not the loudness.

Functions

mix()

mix(track, options)   // options: map with any of the fields below
fieldtyperange (clamped)
levelnumber, dB relative to the role’s target−40..12
pannumber−1..1
reverb (or room)number, send0..1
delaynumber, send0..1
ducknumber, sidechain depth0..1
mutebool
solobool

mix() works on drum lanes, parts, loops and pattern tracks. On a pattern track it overrides what the pattern set (.level(), .pan() …), until the next track() for that name resets the mix to the pattern’s settings. The names fx and transitions address the transition effects (crashes, risers, impacts).

Errors: no track 'nope'; mix() has no option 'volume' (options: level pan reverb room delay duck mute solo).

mix("pad", #{ level: -4, reverb: 0.45 });
mix("lead", #{ pan: 0.2, delay: 0.25, duck: 0.2 });
mix("fx", #{ level: -3 });

master()

master(options)   // options: map
fieldmeaningrange (clamped)default
lufsintegrated loudness of the finished master−24..−5the genre’s
ceilingtrue-peak ceiling, dBTP−3..−0.1−1

The master stage runs the genre’s master chain (EQ, a glue compressor, saturation; tape and vinyl in lofi), then searches for the input gain that lands the song within 0.15 LU of lufs, through a soft clipper 0.1 dB under the ceiling and a limiter 0.5 dB below it, and trims the result if the true peak still pokes over. The genre targets are house −10, deep_house −11, techno −9, trap −9, lofi −12.5, dnb −8.5, future_bass −9, synthwave −10.5 LUFS.

Every printed song has a master() line with these two values (see Printing a song).

Errors: master() has no option 'loudness' (options: lufs ceiling).

master(#{ lufs: -9, ceiling: -1 });

mute(), solo() and the others

mute(), unmute(), solo(), unsolo() and remove() are on the song functions page. When any track is soloed, everything else is left out of the arrangement, including the transition effects (unless fx is soloed), and the critic judges only what is left.

Songs as scripts

Every song can be printed as a script, whether it was generated, written by hand, edited in the studio, mutated or remade from audio. The printed script is the readable form of the song: its tempo and key, its progressions, every drum lane as step strings, every generated part with its settings, every pattern track’s code, the form and the mix settings that differ from the defaults.

The guarantee

Running a song’s printed script on that same song changes nothing. The test suite checks this for generated songs in several genres and for songs built from patterns. It is what makes the printed script useful: copy it, change one line, run it, and only that line changes.

The guarantee is about running the script on the song it came from. Run anywhere else, a printed script is a recipe for the song only as far as the header and What is not printed allow.

Where to get it

wherehow
command lineclaudius script song.rhai --print prints the song a script makes, instead of rendering it
any song folderecho "" | claudius script - --on out/house-crimson-tide-9 --print prints a saved song (an empty script changes nothing)
song foldersfolders written by claudius script and by saving in the studio (save) have song.rhai
studiothe script tab shows the open song as a script
assistantsthe MCP tool get_song includes it
HTTPGET /api/script

What a printed script looks like

This script:

blank("house");
title("Night Drive"); tempo(122); key("A minor"); swing(0.54);
prog("A", "Am7 Fmaj7 Dm7 Em7");
bars_per_chord(1);
track("drums", s("bd*4, [~ cp]*2, [~ hh]*4"));
track("keys", chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano").room(0.25));
drum("snare", "....x.......x...");
form([sec("intro", 8, "drums").filter_in(), sec("drop", 16, "*").impact()]);
mix("keys", #{ level: -2 });

prints as:

// Night Drive — house (seed 1)
// blank("house");   // uncomment to build this song from nothing (as it is, the script edits the song open)
title("Night Drive"); tempo(122); key("A minor"); swing(0.54);
humanize(0.25);
prog("A", "i7 VImaj7 iv7 v7");
bars_per_chord(1);
master(#{ lufs: -10, ceiling: -1 });

// drums: X accent, x hit, o ghost, . rest, | bar line; 16 steps per bar unless a number follows
drum("snare", "main", "....x.......x...");

// pattern tracks (mini-notation, Strudel style)
track("drums", s("bd*4, [~ cp]*2, [~ hh]*4"));
track("keys", chord("prog").struct("x ~ ~ x ~ ~ x ~").s("epiano").room(0.25));

form([
  sec("intro", 8, "drums").filter_in(),
  sec("drop", 16, "*").impact(),
]);

mix("keys", #{ level: -2 });
mix("fx", #{ reverb: 0.25 });

Things to notice:

  • Chord names are printed as the Roman numerals they were stored as.
  • bars_per_chord() and master() always print. Other settings print only when they differ from what the song would have without them: humanize() when it is not 0, a section method when it differs from the kind’s default, a mix() field when it differs from the track’s starting mix.
  • A pattern track’s mix line compares against the pattern’s own settings, so .room(0.25) stays in the code and is not repeated in mix().
  • The drum lane prints every variant, with the variant name, so a step string from the studio’s sequencer reads back exactly. A lane that plays something other than what its name gives (a kit sound, a sample, another drum voice, a variation such as bd:3) also prints a drum_sound() line. A one-shot cut from Apple Loops or your library prints as a comment with its label, such as // shaker: shaker cut from 'Cosmic Harmony Shaker' (Apple Loops): the cut file lives in claudius’s cache, so a path would mean nothing on another computer.
  • A generated part prints its style, preset, register, density and seed, and an arp part its rate.
  • mix("fx", ...) is the transition effects’ mix (crashes and risers), which comes from the genre.

How values print

  • Numbers print as the shortest text that reads back as the same value: swing(0.54), density: 0.42195085. Nothing is rounded.
  • Drum steps print at 16 steps per bar when every hit sits on a sixteenth, else 32, else 48 (with the number after the string). A hit prints as X at velocity 0.85 or more, x from 0.5, o below. Reading the string back keeps the exact velocities as long as the string is unchanged, so drum("hat", "main", "x...") on a lane whose hits print that way leaves them alone. A lane whose bars are all the same prints one bar.
  • Seeds print as signed 64-bit integers, so a large seed can print negative (seed: -8861390911444144378); it reads back as the same seed.
  • Closures print with the values they used: .every(2, |x| x.add(7)), even if the script wrote x.add(up).

What is not printed

These are part of the song but not of its script. Running the printed script on the same song keeps them; running it anywhere else does not recreate them.

  • the settings the generator or evolution gave a synthesized drum voice (its tuning, decay and the rest): no drum word plays them, so there is nothing to print. A voice that a drum word plays exactly, such as the variation bd:3, prints as that word;
  • one-shots cut from Apple Loops or your library, which print as a comment naming them;
  • the patch of a generated part beyond its preset name (the generator’s variation and evolution’s tweaks), its voice count and voicing style;
  • Apple Loops layered into the song as loops, and flipped samples;
  • the effects on tracks and buses, the reverb and delay settings, and the master chain (all from the genre);
  • the transition sounds and settings;
  • the song’s history (the lineage shown in the studio’s log).

The header

The first two lines say what the song is and how it began: // song("house", 9); for a generated song, // blank("house"); for one that began empty. The second line is a comment, so the script edits whatever song is open: running it on its own song changes nothing. With no song open (claudius script without --on), a script starts from a blank house song, so a printed script run on its own needs that line. Uncomment it to start over:

  • A song that began with blank() and was made by scripts is rebuilt exactly, unless it has something from the list above.
  • A generated song is generated again from its seed, and the lines after the header re-apply the edits. song() picks the Apple Loops one-shots again from your library. What was changed after generation and is in the list above (evolution’s tweaks, a flipped sample) is not recreated.

Errors and limits

When a script fails, nothing it did is kept: the song stays as it was before the script ran. This page shows what errors look like, lists the mistakes that do not raise an error, and gives the limits a script runs under.

What an error looks like

claudius script prints the error and exits with status 1:

song("house", 9);
tempo(300);
Error: Runtime error: tempo between 40 and 240 BPM (line 2, position 1)

The line and position point at the call that failed. In a chain, that is the innermost call that raised the error, so a bad pattern string points at s( rather than at track(. The studio and the MCP tools report the same message.

Errors come in four kinds:

begins withmeaningexample
Syntax error:the script is not valid RhaiExpecting ')' to close the arguments list of this function call 'track'
Function not found:no function by that name takes these argument typesFunction not found: octave (Pattern, f64)
Variable not found:a name without quotess(bd) instead of s("bd")
Runtime error:a claudius function refused its argumentsunknown sound 'bdd', no track 'x'

“Function not found” covers typos (trak, gian), a decimal where an integer is needed (octave(4.5), bars_per_chord(1.5)), a missing or extra argument, and a method called on the wrong thing. The last one is common: track("x", s("bd")).gain(0.5) calls .gain() on what track() returns, which is nothing, and fails with Function not found: gain ((), f64). Put the method inside the parentheses.

Errors by function

The messages, as printed after Runtime error:.

functionmessage
song(), blank()unknown genre 'polka' (try: deep_house, dnb, future_bass, house, lofi, synthwave, techno, trap)
song() optionstempo between 40 and 240 BPM; cannot read key 'H' (try "A minor", "F#m", "Eb", "D dorian"); song() has no option 'tempo' (options: seed bpm key loops short samples)
tempo(), bpm()tempo between 40 and 240 BPM; tempo needs a number
key()cannot read key 'H minor' (try "A minor", "F#m", "Eb", "D dorian")
swing(), humanize()swing needs a number, humanize needs a number
prog()cannot read chord 'Q7' (Roman numerals like i7 VImaj7 bVII, or names like Am7)
drum()no drum lane 'bongo', and drum() makes lanes only for drum names (bd kick sd ...); the song's lanes: none yet. For other sounds, track("bongo", s("..."))
drum_sound()no drum lane 'bongo' (lanes: none yet); drum() makes one; 'pad' is not a drum sound (a drum name, a kit sound, or a sample); the track() sound errors below
part()no part 'keys' (the song has: ...; house parts: bass chords pad lead arp); track() writes your own; a bass part has no style 'funky' (styles: ...); unknown preset 'nope' (synths: ...; or an installed instrument such as piano, rhodes, string_ensemble); part() has no option 'styles' (options: style preset register density seed rate)
mix(), mute(), solo(), remove() …no track 'nope'; mix() has no option 'volume' (options: level pan reverb room delay duck mute solo)
master()master() has no option 'loudness' (options: lufs ceiling)
track()track 'x': unknown sound 'bdd' (drums: ...; synths: ...; sampled: ...)
track(), unreadable wordstrack 'x': cannot read 'h3' as a note (...); track 'x': cannot read 'Xyz' as a chord (...); track 'x': n() takes scale degrees (0 1 2 -1 ...), not 'e'
track() with .bank()track 'x': no 'bongo' in bank 'tr909' (installed packs: ...; kits: tr909 tr808 tr707 acoustic uzu)
track() without packstrack 'x': no sample packs are installed for bank 'tr909' (install them with: claudius-packs install --defaults); a sampled name: unknown sound 'rhodes' (...; sampled instruments need packs: claudius-packs install --defaults)
track() under --synth-onlytrack 'x': bank 'tr909' plays sample packs, which are off in this session (--synth-only); a sampled name: unknown sound 'rhodes' (...; sampled instruments are off in this session (--synth-only))
track() with a filetrack 'x': /path/vox.wav does not exist; track 'x': /path/vox.wav is outside the folders this claudius may read
track(), dense patternstrack 'x': this pattern is too dense to play: it makes more than 262144 events; track 'x': this pattern is too dense to play: it needs more than 4000000 steps of work; track 'x' plays 2048 notes per bar; the limit is 1024
form()form() takes sec(...) items; section 'drop' uses 'snare', which is not a track (tracks: ...); section 'drop' plays progression 'C', which the song does not have (progressions: A B; prog() adds one)
sec()unknown section kind 'party' (intro verse build drop break hook groove outro); layers are a string ("kick hats") or an array of names
.energy()energy needs a number
section()no section 'nope'; no section 12; section() takes a name or an index; the two form() checks; section() has no option 'bar' (options: ...)
layer()no section 'nope'; layer() takes a section name or index; no track 'leed' (tracks: ...)
stack(), cat()stack() takes patterns; stack() needs at least one pattern
.fast(), .slow(), .ply()fast takes a number above 0 and up to 256, not 1000; fast needs a number; ply takes a whole number from 1 to 256, not 1.5
.late(), .early(), .off() …late needs a number
.gain(), .struct() …expected a number, a pattern string or a pattern
.scale()unknown scale 'Q:minor' (try "A:minor", "C:major", "D:dorian", "E:minor:pentatonic")
.arp()unknown arp 'sideways' ("up", "down" or "updown")
mini-notationsee Mini-notation errors
the whole songthe song is 768 bars long; the limit is 512; the song is 36 minutes long; the limit is 20; the song has 70 tracks; the limit is 64 (checked when the script ends, and printed as Error: the song is ..., without Runtime error: or a line number)

Errors about the words of a pattern (cannot read ... as a note) are raised by track(), when the pattern is first played, so they point at the track( line.

Silent mistakes

These run without an error and do something you may not expect.

you writewhat happens
key("D minor") after prog("A", "Am F C G")the progression moves to D minor (prog() stored numerals)
a chord() pattern with chord names, no matching prog()the critic checks the melody and bass against progression A, which on a blank() song is the tonic chord alone
note(...).s("pad"), chord(...).s("pad") with no .role()mixed and judged as a lead or chords, not a pad (see Roles)
<a b c@3>weights inside < > are ignored: each item still takes one cycle; write <a b c!3> (see Alternation)
.every(4, |x| x.s("piano"))track settings inside a closure are ignored
s("bd").s("piano").s() on a sound pattern is ignored
.bank("apple") without GarageBand or Logic contentthe synthesized drums play
sec("drop", 8).variant("alt") on a lane without altthe lane plays main

Limits

A script runs under these limits, so a mistake cannot hang claudius or fill its memory:

limitvalueerror
running time10 secondsthe script ran for more than 10 seconds
operations5,000,000Too many operations
function call depth64Stack overflow
expression nesting128 (64 inside functions)Syntax error: Expression exceeds maximum complexity
string length200,000 charactersLength of string too large
array length10,000 itemsSize of array/BLOB too large
speed*, /, fast(), slow() and ply() above 0 and up to 256'*' takes a number above 0 and up to 256, not 1000
events per pattern query262,144this pattern is too dense to play: it makes more than 262144 events
work per pattern query4,000,000 stepsthis pattern is too dense to play: it needs more than 4000000 steps of work
notes per bar, per track1,024 (averaged over 64 bars)track 'x' plays N notes per bar; the limit is 1024
song length512 bars and 20 minutesthe song is N bars long; the limit is 512
tracks64, and 12,000 track-seconds (20 tracks of 10 minutes, 40 of 5)the song has N tracks; the limit is 64
a pattern string4096 characters, brackets 64 deep, 1024 steps per sequence, ! 256 times, 256 euclid slotssee Mini-notation errors
modules and evalnot availableModule not found, reserved keyword 'eval' is disabled

A script reads no files except the audio files named in .s(), and writes none. When claudius is shared beyond your computer, .s() reads only inside claudius’s own folders (see Your own files).

What the language does not do

  • Time: every bar has four beats; there are no other time signatures, no tempo changes within a song, and no key changes within a song.
  • Automation: there is no general automation lane. Filter sweeps come from section flags (build, filter_in, the final outro), and level changes come from layers and energy.
  • Sound design: scripts choose presets and instruments and add simple inserts; they do not edit synth patches or drum voices (see Sounds).
  • Arrangement edits per bar: a track plays the same pattern in every section it is in, from the section’s start. For a variation in one section, make a second track and put it in that section only.

Quick reference

Everything on one page. Each name links to its full entry.

Starting

song("house", 7)a generated song; options #{ seed, bpm, key, short, loops, samples }
blank("house")an empty song: one 8-bar section, no tracks, progression A = the tonic chord, the genre’s mix
(neither)edit the open song, or a blank house song when none is open

Song edits

title("Night Drive")
tempo(124), bpm(124)40..240
key("A minor")"F#m", "Eb", "D dorian", "E harmonic minor"
swing(0.56)0.5 straight .. 0.8; sixteenth-note swing
humanize(0.3)0..1
prog("A", "i7 VImaj7 iv7 v7")numerals, chord names, slash chords; sections play A, breaks B (or A)
bars_per_chord(2)bars each chord lasts, 1..8
drum("kick", "X...x...")X 1.0, x 0.8, o 0.45, 1-9, . rest; drum(lane, variant, steps, per_bar)
drum_sound("kick", "tr909:bd")a drum name, a kit sound (or a name and a bank), a file
part("bass", #{ ... })style preset register density seed rate; adds the genre’s part when the song has none
track("hats", pattern)a pattern track; new ones join sections with energy ≥ 0.6
mix("pad", #{ ... })level −40..12, pan −1..1, reverb/room, delay, duck 0..1, mute, solo
master(#{ lufs: -9, ceiling: -1 })−24..−5 LUFS, −3..−0.1 dBTP
mute() unmute() solo() unsolo() remove()by track name

Form

form([sec(...), ...])the whole form; repeated names become drop2, drop3
sec(kind, bars, layers)kinds intro verse build drop break hook groove outro; layers "kick hat", [...], "*"
.name() .energy() .prog() .variant()
.build() .gap() .impact() .fills() .filter_in()each also takes true / false
section("drop", #{ ... })bars energy prog variant name layers build gap impact fills filter_in; by name or 0-based index
layer("break", "lead", true)one track on or off in one section

Patterns

constructorvalues
s("bd sd"), sound()sound names
note("c3 e3 g3")note names (octave 3 by default) or MIDI numbers
n("0 2 4")scale degrees (.scale(), else the song’s key)
chord("<Am7 F/A>")chord symbols, slash chords, numerals, prog, prog:B
seq("1 0 1 1")plain values
stack([...]), cat([...]), silence()together / one per bar / nothing

Mini-notation

a b csequence over one bar
~, -rest
[a b], {a b}group in one step
a, btogether
<a b c>one per bar
a | brandom pick per bar
a*2, a/2faster, slower; above 0, up to 256
a@3, a _ _weight
a!3, a !repeat
a?, a?0.3drop at random
a(3,8), a(3,8,2)euclidean
bd:3, tr808:sd:2variation: a number after the last colon
F/A, V7/Va slash before a letter stays in the word

Pattern methods

.fast(2) .slow(2)speed; above 0, up to 256
.late(0.25) .early(0.125)shift (cycles = bars)
.rev() .ply(2)reverse each bar, repeat each event (1 to 256 times)
.every(4, f) .lastOf(4, f)first / last bar of every n, from the section start
.sometimes(f) .often(f) .rarely(f) .almostNever(f) .almostAlways(f) .sometimesBy(p, f)per event, seeded
.off(0.125, f) .superimpose(f)shifted copy, copy on top
.degrade() .degradeBy(0.3)drop events, seeded
.struct("x ~ x x") .mask("1 0 1 1")rhythm from another pattern
.euclid(3, 8) .euclidRot(3, 8, 2)euclidean rhythm
.gain() .velocity() .legato()per event; numbers or patterns
.add(2) .transpose(12)add to the value (degrees on n()) / semitones always
.n("0 1 2")variation, on sound patterns

Track settings

.s(name), .sound(name)instrument for note / degree / chord patterns
.bank("tr909")tr909 tr808 tr707 acoustic uzu apple
.scale("A:minor")also dorian phrygian lydian mixolydian harmonic_minor minor_pentatonic pentatonic blues chromatic
.octave(4)octave of degree 0 and of note names without one
.voicing(), .voicing(48, 72)chord range, default 52..74
.arp("up")up down updown
.root()a chord pattern’s roots (or slash basses), octave 2
.role("pad")mixing role
.level(-3) .pan(-0.4) .room(0.3) .delay(0.2) .duck(0.5)mix
.lpf(1200) .hpf(200) .crush(8) .distort(6)inserts

Sounds

drumsbd sd cp hh oh rd cr lt mt ht rim sh cb snap perc (and kick snare clap hat ohat ride crash tom rs shaker cowbell ch)
synthssub_bass 808 reese acid saw_bass pluck_bass organ_bass wobble pluck supersaw pad warm_pad stab epiano keys bell organ strings square_lead chip
sampledpiano upright rhodes wurlitzer hammond string_ensemble violins cellos pizzicato brass trumpet flute harp marimba vibes kalimba upright_bass bass guitar choir sax bongo conga ...
exactvsco2:violins, tr808:sd, in .s() or in s() patterns
files.s("/path/sound.wav"): c4 plays it as recorded

Roles and targets (LUFS while playing)

kick −12.5 · snare −13.5 · clap −14.5 · hat −17.5 · ohat −18.5 · perc −19.5 · shaker −21.5 · ride −20.5 · tom −18 · crash −20 · bass −14.5 · keys −17 · chords −18 · lead −18 · arp −20.5 · pad −21.5 · vocal −15 · anything else −19.

Command line

claudius is one program with a subcommand per task. claudius --help lists them and claudius <command> --help shows the flags of one. The sample packs have a second program, claudius-packs, described in Sample packs.

Every command that fails prints Error: and the reason on stderr and exits with status 1.

Make a song

claudius make

Composes, arranges, mixes, masters and judges a new song, and writes it into a folder.

claudius make --genre deep_house --seed 7 --short
ears loaded in 3.8 s
Glass Garden — deep_house at 118 BPM in D minor, 52 bars (1:46)
  progression A: i7 VImaj7 iv7 v7
  progression B: VImaj7 v7 iv7 i7
  form: intro 8 | groove 8 | break 8 | build 4 | drop 16 | outro 8
  tracks: kick (synth kick), clap (synth clap), hat (hat cut from 'Silver Hi-Hat 04' (Apple Loops)), shaker (shaker cut from 'Ride With Me Shaker' (Apple Loops)), perc (perc cut from 'Latin Day Bongo 05' (Apple Loops)), bass (synth patch 'pluck_bass'), chords (synth patch 'keys'), pad (sampled string_ensemble (vsco2:strings)), lead (synth patch 'pluck'), crash (crash cut from 'Beautiful Memories Percussion 02' (Apple Loops)), riser (synth riser)
  master: -11.0 LUFS, true peak -1.0 dBTP (pre-master -10.3 LUFS); done in 11.4 s
  ears: 'house music' z +3.7 (closest confusion 'techno music' +3.9); production z +1.7 vs 'a low quality recording with hiss' +1.3; p_real 0.54
        sounds like: techno music, tech house music, house music, drum and bass music
  verdict: 86/100 (arc 0.94, genre 0.71, mix 0.93, production 0.59, theory 1.00). Fix first: dull 4-8 kHz: hats, claps and snare lack bite (presence -28.6 dB, healthy from -26); fizzy top end (air -16.2 dB, healthy up to -17); sounds more like 'techno music' than 'house music'
  wrote out/deep_house-glass-garden-7/song.mp3

The result depends on what is installed: the same genre and seed give the same song on any machine with the same packs and Apple Loops, and a different one, in its sounds and possibly its drum patterns, on a machine without them.

flagdefaultwhat it does
-g, --genrehousea genre from claudius genres
-s, --seedthe clockthe same genre, seed and options give the same song
--bpmfrom the genre’s rangetempo
--keyfrom the genre"Am", "F# minor", "Eb", "D dorian"
--shortoffhalves every section (at least 4 bars), for a quick sketch
--stemsoffalso writes every track to stems/<track>.wav (32-bit float)
--samples0.75the chance that a drum lane uses a one-shot cut from Apple Loops or your library instead of a synthesized voice
--synth-onlyoffsynthesizes every sound: no sample packs, Apple Loops or user library
--no-libraryoffleaves out Apple Loops and your user library; the sample packs still play
--sample <file>noneflips a recording into the song, warped to its tempo and key; repeatable
--loopsoffbuilds the song around a family of Apple Loops (their chords, bass and beat), GarageBand style
--no-earsoffjudges with meters and theory only, without loading earshot’s models
--refs <path>nonereference tracks or folders; with two or more, the verdict scores how typical the song is of them
--tagsoffalso runs the AudioSet tagger and prints its labels as held-out evidence
-o, --outoutthe folder the song folder goes into, relative to where you run the command

A seed from the clock is a number below one million, printed in the folder name, so you can make the same song again.

The song folder is <out>/<genre>-<title>-<seed>, for example out/deep_house-glass-garden-7, and holds:

filewhat it is
song.wavthe master, 48 kHz, 24-bit
song.mp3a 256 kbps copy, when ffmpeg is installed
blueprint.jsonthe song’s genome: open it, edit it, render it again
song.rhaithe song as a script, for claudius script or the studio’s script editor
project.jsonthe arranged session the blueprint expanded into
report.jsontempo, key, form, loudness and every track’s level and gain
verdict.jsonthe critic’s scores, measures and issues
credits.txtattributions, when the song plays sample-pack instruments
stems/with --stems

Files and settings compares this with the folders claudius script and the studio write.

claudius script

Runs a script and renders the song it makes or edits.

claudius script song.rhai                       # a script that starts its own song
claudius script edit.rhai --on out/house-glass-haze-9
echo 'tempo(120); mute("lead");' | claudius script - --on out/house-glass-haze-9 --print
flagdefaultwhat it does
<file>the script, or - to read it from stdin
--on <path>noneedits this song (a blueprint.json or a song folder) instead of starting with none
--printoffprints the resulting song as a script and renders nothing
--no-ears, --refs, --tagsas for make
-o, --outoutwhere the song folder goes

The folder has the same files as make writes. The script’s log goes to stderr: the song it started, the sounds each track resolved to (track hats: hh), and anything it printed with print() or debug(). A script that ends without a song fails:

Error: the script made no song (start it with song("house") or blank("house"))

Script errors come back with the line and position; Errors and limits lists them.

Error: Runtime error: tempo between 40 and 240 BPM (line 1, position 1)

claudius render

Renders a blueprint.json again, for example after you edited it by hand.

flagdefaultwhat it does
<blueprint>the blueprint file
--stemsoffstems too
--no-ears, --refs, --tagsas for make
-o, --outthe blueprint’s folderwhere to write; without it the song files next to the blueprint are replaced

claudius mutate

Writes variations of a blueprint, each rendered in full, with what changed.

claudius mutate out/house-glass-haze-9/blueprint.json -n 4

Each variation goes into <blueprint folder>/mutations/<n>/ (or -o), and one line per variation reports the change and its verdict: 1: <what changed> -> <score> (<folder>). Unlike mutation in the studio, these mutations are not steered by the critic’s issues.

flagdefaultwhat it does
<blueprint>the blueprint to vary
-n4how many variations
-s, --seedthe clockthe random choices
--no-ears, --refs, --tagsas for make
-o, --out<blueprint folder>/mutationswhere they go

claudius evolve

Runs an evolutionary search: mutation and crossover over generations, each candidate judged on a preview of its build-up and first drop. Evolution and analysis explains the search.

claudius evolve --genre techno --seed 4 --start 2 --generations 2 --children 3 --preview-bars 16 --keep 0 --no-ears
evolving techno (2 start, 2 generations x 3 children, 16-bar previews, without ears)
  g0-0  90.5  Velvet Orbit
  g0-1  97.1  Hollow Harbor
  g1-0  97.1  from g0-1: tempo 128 -> 126 BPM
  g1-1  90.4  from g0-0: mix: bass -1.3 dB (now -1.3); form: outro now 12 bars
  g1-2  90.5  from g0-0: ride: synth voice parameter 0 -> 0.931
generation 1: best 97.1
  g2-0  97.1  from g0-1: form: peak drums main -> sparse; tempo 128 -> 130 BPM
  g2-1  97.1  from g0-1: form: groove drums main -> sparse; drums (main): nudged a hat hit by +0.25 beats
  g2-2  91.4  from g1-0: crossover: drums and form of one parent, harmony and parts of the other; form: break drums main -> sparse; form: build loses gap
generation 2: best 97.1
searched 8 candidates in 7 s; rendering the best 0 in full

A child that beats the best parent so far is marked with +. Every candidate goes into <out>/evolve-<genre>-<seed>/evolution.json (id, generation, parent, change, score, summary), and the best --keep are rendered in full into numbered folders next to it, with their history printed.

flagdefaultwhat it does
-g, --genrehousethe genre
--from <blueprint>nonestarts from this song instead of fresh ones
-s, --seedthe clockthe fresh songs use seeds seed, seed + 1, …; the search uses it too
--start4how many fresh songs start the search
--generations5generations
--population4parents kept per generation
--children8children per generation
--preview-bars32bars auditioned per candidate
--keep3best songs rendered in full at the end
--samples, --synth-only, --no-library, --loopsas for make
--no-ears, --refs, --tagsas for make
-o, --outoutwhere the run folder goes

claudius remake

Analyses an audio file and writes a new song on its skeleton: tempo, key, swing, form, progressions, drum habits and bass style, with the genre’s sounds and mix. It prints the analysis (as analyze does) and the new song.

flagdefaultwhat it does
<file>any audio file claudius can decode
-g, --genrehousethe genre of the new song. When the recording’s tempo is outside the genre’s range, the song plays at half or double it if that falls in the range (a 160 BPM recording as lofi at 80), else at the nearest end, and says so
-s, --seedthe clockthe new song’s own choices
--bpmas abovethe new song’s tempo
--no-ears, --refs, --tagsas for make
-o, --outoutthe folder is remake-<genre>-<title>-<seed>

Listen and judge

claudius judge

Gives the critic’s verdict on any audio file. When the file has a project.json next to it (as make writes), the arrangement and theory checks run too; without one, only the meters on the master and the ears do.

claudius judge out/deep_house-glass-garden-7/song.wav --no-ears
out/deep_house-glass-garden-7/song.wav: 95/100 (arc 0.94, mix 0.93, theory 1.00). Fix first: dull 4-8 kHz: hats, claps and snare lack bite (presence -28.6 dB, healthy from -26); fizzy top end (air -16.2 dB, healthy up to -17)
  arc_correlation: 0.89
  band_air_db: -16.2
  band_low_db: -2.6
  band_lowmid_db: -5.5
  band_mid_db: -12.2
  band_presence_db: -28.6
  band_sub_db: -11.4
  bass_roots_on_changes: 1
  drop_minus_break_lu: 3.5
  lead_chord_tones_on_strong_beats: 1
  lead_in_key: 1
  lead_range_semitones: 12
  lead_stepwise: 0.63
  length_s: 106
  low_end_correlation: 1
  lra_lu: 3.73
  lufs: -10.98
  stereo_correlation: 0.86
  true_peak_db: -1.05
  voice_leading_chords: 1
  voice_leading_pad: 1.1
  - [0.44] dull 4-8 kHz: hats, claps and snare lack bite (presence -28.6 dB, healthy from -26) (band:presence-)
  - [0.13] fizzy top end (air -16.2 dB, healthy up to -17) (band:air+)
flagdefaultwhat it does
<file>the audio file
-g, --genrethe project’s, or nonethe genre the ears compare against; without one they report the closest genre in earshot’s vocabulary
--no-ears, --refs, --tagsas for make
--jsonoffprints the whole verdict as JSON

The critic and the ears explains every measure and score.

claudius analyze

Reads a song the way a producer does before remaking it.

claudius analyze out/deep_house-glass-garden-7/song.wav --genre deep_house
118.0 BPM (pulse clarity 0.55), Bb major (confidence 0.43), swing 0.50, 52 bars from 0.00 s
  progression A: iii7 Imaj7 vi7 vii  |  B: Imaj7 Imaj7  (1 bar(s) per chord)
  bar   0 +16  intro   energy 0.83  kick bass hats
  bar  16 +12  break   energy 0.30  kick hats
  bar  28 +24  drop    energy 1.00  kick bass hats
  kick  X.-xX.xxX.x.X.x-
  snare X.xXXxxxXxXxX-Xx
  hats  x-Xxx.X-xXXxx.XX
  bass style: offbeat

This is claudius reading its own D minor song: the tempo is exact and progression A has the song’s chords in their order (Dm7, B♭maj7, Gm7, Am), but named in B♭ major, and the break’s progression B came out as one chord where the song has four. Analysis of full mixes is approximate; the engineering page says where it goes wrong. In the step grids, X is a hit in almost every bar, x a common one, . a rare one and - none.

flagdefaultwhat it does
<file>the audio file
-g, --genrenonea genre whose tempo range guides the beat tracker (else 70 to 180 BPM, centred on 120)
--jsonoffthe whole analysis as JSON, including the chord of every bar

Make material

claudius chop

Cuts a recording into material: one bar loop per section it finds and a kick, clap and hat one-shot, saved as WAV files and added to the user library, where make and scripts find them.

claudius chop out/deep_house-glass-garden-7/song.wav --bars 2
chopped 3 loops and 3 one-shots (added to the user library):
  out/chops/song/01-intro-2bars.wav (118 BPM, 8 beats, key A#)
  out/chops/song/02-break-2bars.wav (118 BPM, 8 beats, key A#)
  out/chops/song/03-drop-2bars.wav (118 BPM, 8 beats, key A#)
  kick: out/chops/song/kick.wav
  clap: out/chops/song/clap.wav
  hat: out/chops/song/hat.wav

The analysis is printed first, as analyze prints it.

flagdefaultwhat it does
<file>the recording
--bars2bars per loop
-g, --genrenonea tempo range for the beat tracker
--find <description>nonealso cuts out the moments that best match a description (“a vocal chop”, “a snare hit”); repeatable; needs earshot’s models
--top3cuts per --find
-o, --outout/chopsthe files go into <out>/<file name>/

Apple Loops and your own samples shows how to use the chops.

claudius sounds

Renders every built-in synthesized sound to WAV files, so you can hear them: the 13 drum voices in drums/, the 20 synth presets playing a phrase in synth/, and five transition effects in fx/.

flagdefaultwhat it does
-o, --outout/soundswhere they go

Information

commandprints
claudius genresevery genre with its tempo range and description, including your own genre files
claudius referencethe script language reference, the same text /api/reference and the MCP tool script_reference return
claudius --versionthe version
$ claudius genres
deep_house   118-124 BPM  Deep house: softer four-on-the-floor, shuffled hats and shakers, minor 9th chords on electric piano and organ, a round syncopated bass, long warm reverbs
dnb          170-176 BPM  Drum and bass: 174 BPM two-step breakbeat (kick on 1 and the and-of-3, snares on 2 and 4 with ghost notes), a growling reese bass, atmospheric pads, long intro and breakdown
...

Run the studio and the server

These three commands run a session: one song with undo, renders and verdicts, which the studio window, browsers, assistants and the HTTP API share. They take the same session flags:

flagdefaultwhat it does
--port7878 (CLAUDIUS_PORT)the port the HTTP server listens on
--no-earsoffno earshot models; verdicts use meters and theory
--synth-onlyoffsynthesizes every sound: no sample packs, Apple Loops or user library
--no-libraryoffno Apple Loops or user library; the sample packs still play
--no-auto-renderoffrenders only when asked, instead of after every change
--web <dir>found automatically (CLAUDIUS_WEB)the built web studio; see Files and settings

claudius studio

Opens the studio window. It first asks http://127.0.0.1:<port> what answers there:

  • a claudius: the window opens on that session, says a claudius is already running on port 7878: opening the studio on it, and ignores the session flags;
  • a claudius that asks for a token: it stops, since the window cannot send one, and says to open http://127.0.0.1:<port>/?token=<token> in a browser or to start another studio with --port;
  • nothing: it starts a session and an HTTP server on 127.0.0.1 and --port (which also serves the web studio and MCP), then opens the window on it. A port taken by another program stops it with port 7878 is taken, by another claudius or another program (--port picks another).

A claudius built without the studio feature prints the address to open in a browser and keeps serving.

claudius serve

Runs the session and the HTTP server without a window: the web studio at /, the HTTP API under /api, and MCP at /mcp.

$ claudius serve
claudius at http://127.0.0.1:7878: web UI at /, API at /api, MCP at /mcp

It prints the address once the port is bound; a taken port stops it with port 7878 is taken, by another claudius or another program (--port picks another).

flagdefaultwhat it does
--host127.0.0.1the address to listen on; 0.0.0.0 listens on every interface. Any address but loopback requires a token and limits file access, as --public does
--publicoffanswers requests addressed to any host name (a tunnel, your LAN), requires a token (one is made when --token is not given), and limits file access to claudius’ own folders
--token <secret>none (CLAUDIUS_TOKEN)requires this token on every request; a blank one counts as none
--files <dir>nonea folder commands and scripts may read and write, besides ~/Music/claudius, claudius’ caches and the sound libraries; repeatable

--public without --token makes a random token and prints how to use it:

$ claudius serve --public --port 7920
claudius at http://127.0.0.1:7920: web UI at /, API at /api, MCP at /mcp (token required)
shared with a new token: open http://127.0.0.1:7920/?token=<token> (through your tunnel, its address with ?token=<token>), or send Authorization: Bearer <token>. --token sets your own.

The token is new at every start; pass --token to keep one across restarts, as an assistant’s configuration needs. MCP tools shows the tunnel setup and the security model what each flag protects. --host with any address but loopback makes a token the same way, with or without --public: browsers on the network are refused by the host-name check, and the token keeps out programs that send Host: localhost.

--public, --host with an address other than loopback, or --files limits file access to ~/Music/claudius, claudius’ caches and the sound libraries, plus the --files folders; without any of them, commands read and write wherever you can. What a shared claudius may read and write has the details.

claudius mcp

Speaks MCP over stdin and stdout, for assistants that start claudius themselves (Claude Desktop, Claude Code, Cursor). It first asks the address in --url, or http://127.0.0.1:<port>, whether a claudius is running there:

  • if one answers, every tool works on that session: the song open in your studio;
  • if one answers but asks for a token that --token did not give (or gave wrong), it fails with Error: the claudius at <url> asks for a token: pass --token or set CLAUDIUS_TOKEN (a wrong one is refused too);
  • if none answers and --url was given, it fails with Error: no claudius answers at <url>;
  • otherwise it starts a session of its own and serves the web studio on --port, so you can open it to listen.

What it decided goes to stderr, which MCP clients show in their logs:

claudius mcp: driving the claudius app at http://127.0.0.1:7878
flagdefaultwhat it does
--urlhttp://127.0.0.1:<port>a claudius elsewhere
--tokennone (CLAUDIUS_TOKEN)the token of that claudius
session flagsused only when it runs a session of its own

Set up and check

claudius setup

Downloads what does not ship with the program: the default sample packs and earshot’s listening models. On a terminal it asks before each download; elsewhere (and with --yes) it does not ask.

flagwhat it does
-y, --yesdoes not ask
--no-packsskips the sample packs
--no-modelsskips the models
--tagsalso downloads the AudioSet tagger, 347 MB more, used by --tags

The prompt shows what is missing and how much it downloads: about 2.2 GB for the default packs, which take 1.2 GB on disk because several arrive as WAV and are converted to FLAC, and 785 MB for the models (the prompt says about 0.8 GB, or 1.1 GB with --tags). An interrupted pack download resumes where it stopped; a model file that was cut off downloads again from its start, and the files that finished are kept. After the models arrive, the first load spends about 30 s embedding earshot’s vocabulary once.

claudius doctor

Checks the installation and says what is missing.

$ claudius doctor
claudius 0.1.0 (macos aarch64)
  ok  web studio      ~/.local/share/claudius/web
  ok  sample packs    14 default packs (1.2 GB) in ~/Library/Application Support/claudius/packs
  ok  ears            models in ~/.cache/earshot-rs
  ok  Apple Loops     14096 loops and samples
  ok  ffmpeg          9.0.2 (writes song.mp3)
  ok  port 7878       free
  ..  Claude Desktop  not connected: `claudius connect claude-desktop`
  ..  Cursor          not connected: `claudius connect cursor`
  ..  statistics      off (`claudius telemetry` explains them)

Everything claudius needs is here.

ok is fine, -- counts as a problem, .. is optional. The port line says whether --port (or CLAUDIUS_PORT) is free, already serving claudius (claudius is running here, also for one that asks for a token), or taken by another program.

claudius connect

Adds claudius to an assistant, or removes it: its MCP configuration, and the skills that teach it claudius. MCP tools covers each client.

argument or flagwhat it does
claude-desktopedits claude_desktop_config.json, and writes the skills as zips into ~/Music/claudius/skills/ to upload in Claude’s settings
claude-coderuns claude mcp add --scope user ..., and installs the skills in ~/.claude/skills/
cursoredits ~/.cursor/mcp.json, and installs the skills in ~/.cursor/skills/
codexedits [mcp_servers.claudius] in ~/.codex/config.toml, and installs the skills in ~/.codex/skills/
printprints each assistant’s own command for setting it up by hand (claude mcp add, codex mcp add, Cursor’s install link), and the configuration for any other client, with the full path of this claudius
--httpconnects over HTTP to a running studio instead of starting claudius mcp (not for Claude Desktop or Codex)
--no-skillsconnects the MCP server only
--removetakes claudius, and its skills, out again
--portthe port the --http URL uses (CLAUDIUS_PORT)

Every configuration file it edits keeps a .bak copy of the old one. Installing the skills replaces only claudius’ own skill folders (claudius-songs, claudius-remix, claudius-setup). An assistant reads its configuration when it starts, so connect ends by saying how to restart it: quit and reopen Claude Desktop, restart Cursor or reload its window, start a new Codex or Claude Code session.

claude-code runs the claude command, found on PATH or where its installers put it (~/.local/bin, ~/.claude/local, ~/.npm-global/bin, /opt/homebrew/bin, /usr/local/bin), and replaces a claudius Claude Code already has. The studio’s Connect an assistant does what connect does, and claudius doctor lists the assistants that are connected.

claudius update

claudius update             # downloads the latest release, and installs it after asking
claudius update --check     # only says whether there is a newer release
claudius update --auto off  # the studio stops looking for updates (on turns it back on)

It installs the latest release the way this claudius was installed: the Mac app, an AppImage, the install script’s programs, or the Windows installer, from the release’s file for this computer, checked against its SHA256SUMS. Installed with Homebrew, cargo, a .deb or .rpm, or built from source, it says how to update instead. --yes does not ask. Updates has the details.

claudius telemetry

claudius telemetry        # whether anonymous usage statistics are on, and an event exactly as it is sent
claudius telemetry on
claudius telemetry off

Anonymous usage statistics are off until you turn them on, here or when the studio’s tour asks. When they are on, claudius sends each kind of event at most once a day: which command started it, the version and the system, and where a setup or a panic failed. DO_NOT_TRACK=1, CLAUDIUS_TELEMETRY=0 and CI keep them off whatever the answer. Privacy and usage statistics lists every field and where they go.

claudius skills

claudius skills --out ~/my-project/.claude/skills

Writes the skills that teach an assistant claudius into a folder (claudius-skills by default): a folder with a SKILL.md for each, and a zip of each for the Claude apps. They are built into the binary, so they match it.

MCP tools

claudius is an MCP server: an assistant connected to it makes, edits, renders and judges songs with the tools below, on the same session the studio shows. Working with an assistant is the short version of this page.

There are two ways to reach it:

  • stdio: the client starts claudius mcp and talks to it over stdin and stdout. This is what desktop clients do.
  • Streamable HTTP: the client connects to http://127.0.0.1:7878/mcp while claudius studio or claudius serve runs.

Setting up a client

Claude Desktop

claudius connect claude-desktop

This adds an entry to Claude Desktop’s configuration, keeps the old file as claude_desktop_config.json.bak, and asks you to quit and reopen Claude Desktop. claudius connect claude-desktop --remove takes it out again.

By hand, add this to the configuration file (in Claude Desktop’s Settings, Developer, Edit Config opens it), with the absolute path of your claudius (which claudius prints it; Claude Desktop does not see your shell’s PATH):

{ "mcpServers": { "claudius": { "command": "/Users/you/.local/bin/claudius", "args": ["mcp"] } } }
systemconfiguration file
macOS~/Library/Application Support/Claude/claude_desktop_config.json
Windows%APPDATA%\Claude\claude_desktop_config.json
Linux~/.config/Claude/claude_desktop_config.json

Claude Desktop starts its local servers itself, so --http is refused for it.

Claude Code

claudius connect claude-code          # runs: claude mcp add --scope user claudius -- /path/to/claudius mcp
claudius connect claude-code --http   # runs: claude mcp add --scope user --transport http claudius http://127.0.0.1:7878/mcp

When the claude command is not on your PATH, connect prints the command for you to run where it is. Claude Code’s own command does the same, with the claudius on your PATH: claude mcp add --scope user claudius -- claudius mcp. Without --scope user, Claude Code adds it to the folder you run that in only.

Cursor

claudius connect cursor               # "claudius": { "command": "/path/to/claudius", "args": ["mcp"] }
claudius connect cursor --http        # "claudius": { "url": "http://127.0.0.1:7878/mcp" }

Both edit mcpServers in ~/.cursor/mcp.json and keep a .bak copy. A project’s .cursor/mcp.json takes the same entry. Cursor’s install link adds it as well, after asking: Add claudius to Cursor starts the claudius on your PATH; claudius connect print prints a link with its full path.

Codex

claudius connect codex

This adds [mcp_servers.claudius] to ~/.codex/config.toml, with the absolute path of claudius and args = ["mcp"], and leaves the rest of the file as it was (comments included), with a config.toml.bak copy. Codex starts local servers itself, so --http is refused for it. Codex’s own codex mcp add claudius -- claudius mcp writes the same table, with the claudius on your PATH.

Skills

claudius connect also installs claudius’ skills for the clients that read them from a folder (Claude Code, Cursor, Codex), and writes them as zips for the Claude apps. --no-skills leaves them out.

Any other client

claudius connect print prints each assistant’s own command (claude mcp add, codex mcp add, Cursor’s install link) with the full path of this claudius, the stdio configuration as JSON for Claude Desktop and any other client, and the HTTP address. --port (or CLAUDIUS_PORT) changes the port in the HTTP address.

ChatGPT and other assistants in the cloud

An assistant that runs on someone else’s computer cannot start claudius mcp or reach 127.0.0.1. It needs a public HTTPS address, so put claudius behind a tunnel and require a token:

TOKEN=$(openssl rand -hex 16); echo "$TOKEN"
claudius serve --public --token "$TOKEN"
cloudflared tunnel --url http://127.0.0.1:7878

--public makes claudius answer requests addressed to the tunnel’s host name; it still listens on 127.0.0.1. In ChatGPT’s developer mode, add a custom connector with the URL https://<tunnel>/mcp?token=<token>. Clients that can send headers can use Authorization: Bearer <token> instead of the query string. Open https://<tunnel>/?token=<token> in a browser to use the studio through the same tunnel.

--public always runs with a token. Without --token it makes a new random one at every start and prints it, which is enough for a browser but means updating the connector after each restart; a token of your own, as above, stays the same. A shared claudius reads and writes files only in its own folders; the security model explains what the token, --public and those folders protect.

The studio and the assistant on one song

claudius mcp first asks http://127.0.0.1:<port>/api/hello (or --url) whether a claudius is running:

                     claudius studio (or serve) on :7878
                      session: song, undo, renders
                     /             |              \
         studio window      browser tab        claudius mcp  <-- stdio --  Claude Desktop
                                                (HTTP client)
  • A studio is running: every tool call becomes a request to its HTTP API. The assistant edits the song you have open, you hear each render in the studio, and you can change things yourself between its calls. Undo in the studio takes back the assistant’s changes too.
  • A claudius that asks for a token is running (serve --token, or --public), and claudius mcp was not given that token: it stops with the claudius at http://127.0.0.1:7878 asks for a token: pass --token or set CLAUDIUS_TOKEN (a wrong one is refused too) rather than quietly working on a song of its own.
  • Nothing is running: claudius mcp starts a session of its own and serves the web studio on --port. Open http://127.0.0.1:7878 to see and hear what the assistant does. If the port is taken by another program, the session still works but has no web studio.

So open the studio before you start the assistant’s chat. With --url http://host:port and --token, claudius mcp drives a claudius elsewhere, and fails if none answers there.

A client connected over HTTP (/mcp) always works on that server’s session; there is nothing to decide.

The tools

A tool that edits the song waits until the change is rendered and judged, then answers with the song and the verdict, so the assistant hears the result of every change. Each tool that waits gives up after a few minutes and says the job is still running; status follows it from there.

toolparameterswhat it does
new_songgenre (required), seed, bpm, key, loops (false)composes a new song in a genre, renders and judges it
get_songthe open song and its verdict, and the whole song as a script
run_scriptcode (required), listen (true)edits the song with a script
script_referencethe script language reference (markdown)
listenstems (false)renders the song if it changed and returns the verdict
mutatecount (4, 1 to 32), keep_best (true)tries variations steered by the critic’s issues and keeps the best if it beats the song
evolvegenerations (3, 1 to 20), children (6, 2 to 32)runs an evolutionary search from the song and keeps the best candidate if it beats the song
statusidle or busy (with progress), the last 25 log lines, the latest verdict
undo, redotakes back or brings back the last change, then renders
stopstops a mutation after the candidate in progress, an evolution after its generation, or a pack download after the pack in progress; a render always finishes
catalogthe genres with tempo ranges, drum names, synth presets, installed instruments and every pack with its licence
savepath (optional)writes the song folder: blueprint.json and song.rhai at once, then, from a render of the song as it is then, the whole folder claudius make writes, stems included; a save asked for during a job waits for it
openpath (required)opens a song folder, a blueprint.json, or a .rhai script (which runs on the open song)
analyzepath (required)tempo, key, progressions, sections, drum grid and bass style of an audio file
remakepath (required), genre, seed, bpma new song on the skeleton of an audio file, in a genre’s sounds, at a tempo the genre plays
commandcommand (required, an object), listen (true)sends one raw API command

Details that matter when you read an assistant’s transcript:

  • listen: false on run_script and command skips the wait, so an assistant can batch several edits and listen once. The studio still renders each change unless it runs with --no-auto-render.
  • listen waits up to 240 s, save 240 s, evolve 280 s and mutate 300 s. mutate and evolve answer with the log lines the job wrote (one per candidate and its score) and then the song.
  • A seed is optional everywhere. Without one, the session picks one from the clock; new_song names it in its message and get_song shows it in the script’s first line, so the song can be made again.
  • save without a path writes to ~/Music/claudius/<genre>-<title>-<seed>. Paths are on the computer running claudius, and a shared claudius refuses paths outside its folders.
  • command covers the edits the studio makes and scripts do not: add_section, remove_section, move_section, toggle_layer, set_drum_step, set_section (including prog), set_part (including reseed) and set_mix (including solo).
  • A failed edit changes nothing and comes back as a tool error (isError: true) with claudius’ message, the same text the studio shows:
Runtime error: track 'x': unknown sound 'nope' (drums: bd kick sd snare cp clap hh ch hat oh ohat rd ride cr crash lt mt ht tom rim rs sh shaker cb cowbell snap perc; synths: sub_bass 808 reese acid saw_bass pluck_bass organ_bass wobble pluck supersaw pad warm_pad stab epiano keys bell organ strings square_lead chip; sampled: piano upright rhodes wurlitzer hammond string_ensemble violins cellos pizzicato brass trumpet flute harp marimba vibes kalimba upright_bass guitar choir sax ... (claudius-packs aliases)) (line 1, position 1)

What the assistant reads back

This is listen after tempo(122); mix("lead", #{ level: -2 }); on a house song made with seed 9, in a session on port 7931:

Crimson Tide — house at 122 BPM in G dorian, swing 0.53, 64 bars (2:06)
progression A: i7 ii7 i7 IV7
progression B: i7 VIImaj7 i7 IV7
form:
  0. intro (intro) bar 0 +8, energy 0.40, drums 'sparse' [filter_in]: kick hat perc top
  1. groove (groove) bar 8 +16, energy 0.70, drums 'main' [fills]: kick clap hat ohat perc bass chords top
  2. break (break) bar 24 +8, energy 0.35, drums 'main' [build, gap]: pad lead chords
  3. drop (drop) bar 32 +24, energy 0.95, drums 'main' [impact, fills]: kick clap hat ohat shaker perc bass chords lead arp top
  4. outro (outro) bar 56 +8, energy 0.40, drums 'sparse': kick hat perc bass
tracks:
  kick [drum kick] sampled bd (tr909:bd); level +0.0 dB, pan +0.00, reverb 0.00, delay 0.00, duck 0.00, -13.2 LUFS
  clap [drum clap] sampled cp (tr909:cp); level +0.0 dB, pan +0.00, reverb 0.18, delay 0.00, duck 0.00, -15.2 LUFS
  ...
  lead [part lead] synth patch 'pluck'; level -2.0 dB, pan +0.00, reverb 0.25, delay 0.20, duck 0.20, -20.7 LUFS
  arp [part arp] synth patch 'bell'; level +0.0 dB, pan +0.20, reverb 0.20, delay 0.25, duck 0.30, -21.2 LUFS
  fx [fx fx] risers, impacts, crashes; level +0.0 dB, pan +0.00, reverb 0.25, delay 0.00, duck 0.00
render: -10.1 LUFS, true peak -1.0 dBTP, 128 s
verdict 97.2/100: 97/100 (arc 0.90, mix 1.00, theory 1.00)
  scores: arc 0.90, mix 1.00, theory 1.00
audio: http://127.0.0.1:7931/api/audio/8.wav (the studio at http://127.0.0.1:7931 plays it)

get_song adds the song as a script in a js block. A verdict of a song with several sections also has a loudness by section (LUFS): line, each section’s median loudness in the order they play, which shows whether the drop is really the loudest part. With earshot’s models loaded, the verdict has a line that starts with ears:, and up to eight issues follow, each with where its fix lives (issue (0.24): dull 4-8 kHz: ... — fix: band:presence-). The critic and the ears explains them.

Prompts that work

The assistant does better with what you want to hear than with what to type. Some that work well:

  • “Make a deep house track at 122 BPM in F minor. Keep the intro sparse and make the drop hit hard. Listen to it and tell me the two biggest problems before you change anything.”
  • “Read the script reference, then replace the chords with a Rhodes playing i9 VImaj9 iv9 v7, two bars each.”
  • “The breakdown is too busy. Take the lead out of it and lower the pad by 3 dB. Undo if the verdict gets worse.”
  • “Try eight mutations and keep the best one. Then tell me what changed, in musical terms.”
  • “Remake ~/Music/reference.mp3 as lofi, keep its progression, and put the drums on the 808 kit.”
  • “Write the hats yourself as a pattern track: sixteenths, with an open hat on every offbeat and a roll before each drop.”
  • “Save it to ~/Music/claudius/night-drive.”

Two habits help: ask the assistant to get_song before it edits a song you made in the studio, so it starts from what is there, and ask it to say what it changed in musical words, since you hear the result and it reads numbers.

MCP over HTTP

The endpoint at /mcp is MCP’s Streamable HTTP transport. Every request is a POST with Content-Type: application/json and Accept: application/json, text/event-stream; replies come as server-sent events. The first request is initialize, whose reply carries an mcp-session-id header that later requests send back. The endpoint sits behind the same guards as the rest of the server: loopback host names only unless --public, and the token when one is set.

curl -si http://127.0.0.1:7878/mcp -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
HTTP/1.1 200 OK
content-type: text/event-stream
mcp-session-id: 67068efb-d5e6-4618-84eb-326443bc774c
...

A request without the session id gets 422 Unexpected message, expect initialize request; one without both Accept types gets 406 Not Acceptable.

HTTP API

claudius studio, claudius serve and a claudius mcp that runs its own session answer HTTP on http://127.0.0.1:7878 (--port, CLAUDIUS_PORT). The studio, in its window and in the browser, uses nothing but this API, so anything it does you can do with curl. The types are plain serde structs in the claudius-api crate (crates/claudius-api/src/lib.rs), which also compiles to WebAssembly for the web studio.

Routes

routeanswer
GET /api/hello{"name":"claudius","version":"0.1.0","state":1,"ears":false}: is this claudius, which version, the state version, whether earshot’s models are loaded
GET /api/statethe state at once
GET /api/state?since=Nthe state as soon as its version is past N (long polling)
GET /api/state?settled=1the state once no job runs and the render is of the current song
POST /api/commandruns one command (JSON body) and answers with a reply
GET /api/scriptthe song as a script, text/plain (// no song yet: try song("house") before the first song)
GET /api/referencethe script language reference, text/markdown
GET /api/soundsevery sound name: drum voices, synth presets, installed instruments, and every pack
GET /api/audio/{version}.wavthe master of that render, 48 kHz 24-bit
GET /api/audio/{version}.mp3the same as MP3, encoded on the first request
GET /api/stems/{version}/{track}.wavone track of a render made with stems
/mcpthe MCP endpoint
GET / and anything elsethe web studio; when it is not built, a page that says how to build it

state, since, settled and timeout combine as described below; timeout is in seconds, 20 by default and 600 at most.

Versions and long polling

Two numbers tell a client what changed:

  • version goes up on every change to anything in the state: a log line, the progress of a job, a new render, an edit.
  • song_version is the version at which the song itself last changed. A render belongs to the song version it was made from, in render.version, so the render is current when render.version >= song_version.

A client keeps itself up to date with one request in flight:

curl -s http://127.0.0.1:7878/api/state                 # version 7
curl -s 'http://127.0.0.1:7878/api/state?since=7'       # returns when the version passes 7, or after 20 s

since=N answers as soon as the version is greater than N, and after timeout seconds otherwise, with whatever the state is then. The client takes the version of each answer as its next since. A since ahead of the server’s version, as after a restart, waits out the timeout once and then answers with the current version.

settled=1 waits for something else: that no job is running and that the render is of the current song. That is the moment the verdict describes the song as it is now. When the session renders only on request (--no-auto-render) and no render was asked for, it is settled at once. settled takes precedence over since.

curl -s -X POST http://127.0.0.1:7878/api/command -H 'Content-Type: application/json' \
  -d '{"cmd":"new_song","genre":"house","seed":9,"bpm":126}'
curl -s 'http://127.0.0.1:7878/api/state?settled=1&timeout=120'    # returned after 7.9 s, with the render and verdict

The MCP tools wait the same way after every edit.

The state

GET /api/state returns a State. Here it is after the request above, with long lists cut short:

{
  "version": 7, "song_version": 2, "busy": null, "progress": null, "ears_loading": false,
  "can_undo": true, "can_redo": false,
  "undo_label": "new song: Crimson Tide — house at 126 BPM in G dorian (seed 9)", "redo_label": null,
  "log": ["sample library: Apple Loops and your folders",
          "new song: Crimson Tide — house at 126 BPM in G dorian (seed 9)",
          "rendered in 7.7 s: -10.1 LUFS, verdict 98.0 — 98/100 (arc 0.93, mix 1.00, theory 1.00)"],
  "genres": [{ "name": "deep_house", "description": "Deep house: softer four-on-the-floor, ...", "bpm": [118.0, 124.0] }],
  "song": {
    "title": "Crimson Tide", "genre": "house", "seed": 9, "bpm": 126.0, "key": "G dorian", "swing": 0.5257842,
    "bars": 64, "seconds": 121.9047619047619,
    "progressions": [["A", ["i7", "ii7", "i7", "IV7"]], ["B", ["i7", "VIImaj7", "i7", "IV7"]]],
    "sections": [{ "name": "intro", "kind": "intro", "start_bar": 0, "bars": 8, "energy": 0.4,
                   "layers": ["kick", "hat", "perc", "top"], "prog": "A", "variant": "sparse",
                   "filter_in": true, "build": false, "gap": false, "impact": false, "fills": false }],
    "layers": ["kick", "clap", "hat", "ohat", "shaker", "perc", "bass", "chords", "pad", "lead", "arp"],
    "tracks": [{ "name": "kick", "kind": "drum", "role": "kick", "sound": "sampled bd (tr909:bd)",
                 "level_db": 0.0, "pan": 0.0, "reverb": 0.0, "delay": 0.0, "duck": 0.0,
                 "muted": false, "solo": false, "loudness": -13.2 }],
    "drums": [{ "lane": "kick", "pattern_bars": 4,
                "variants": [["main", [0.99353635, 0.0, 0.0, 0.0, 0.9903264, 0.0, 0.0, 0.0]]] }],
    "parts": [{ "name": "bass", "role": "bass", "style": "syncopated", "styles": ["offbeat", "syncopated", "offbeat"],
                "preset": "saw_bass", "presets": ["organ_bass", "pluck_bass", "saw_bass", "sub_bass", "808", "reese",
                "acid", "wobble", "upright_bass", "electric_bass", "synth_bass", "contrabass"],
                "register": [33, 52], "density": 0.42195085 }],
    "script": "// Crimson Tide — house (seed 9)\n// song(\"house\", 9);   // uncomment to start over from the generator\n...",
    "lineage": ["generated: house seed 9"]
  },
  "render": {
    "version": 2, "url": "/api/audio/2.mp3", "seconds": 123.58135416666667, "lufs": -10.091306995130768,
    "true_peak_db": -1.05, "bpm": 126.0,
    "sections": [["intro", 0.0, 15.238095238095237], ["groove", 15.238095238095237, 45.71428571428571]],
    "waveform": [0.8221747, 0.501973, 0.47014454, 0.8221747],
    "stems": []
  },
  "verdict": {
    "total": 97.96757459489606, "summary": "98/100 (arc 0.93, mix 1.00, theory 1.00)",
    "scores": [["arc", 0.9288651108213621], ["mix", 1.0], ["theory", 1.0]],
    "issues": [], "ears": null,
    "measures": [["arc_correlation", 0.86], ["band_air_db", -19.1], ["band_low_db", -3.2]]
  }
}

State

fieldtypemeaning
versionintegergoes up on every change
song_versionintegerthe version of the last change to the song
songSongView or nullthe open song, null before the first one
renderRenderView or nullthe latest finished render; it may be of an older song version while a new render runs
verdictVerdictView or nullthe verdict of that render
busystring or nullthe running job: "rendering", "listening", "mutating: judging 4 variations", "evolving: generation 2 of 3 done", "saving"
progressnumber or null0 to 1, when the job knows it
ears_loadingbooleanearshot’s models are still loading; until they are, verdicts come from the meters and music theory only
can_undo, can_redoboolean
undo_label, redo_labelstring or nullwhat undo would take back, what redo would bring back
logstringsthe last 200 lines of the session log, oldest first
genresGenreInfo listevery genre: name, description, bpm range

SongView

fieldmeaning
title, genre, seed, bpm, key, swingthe song’s settings; key reads like "A minor"
bars, secondsits length
progressions[name, [numerals]] pairs, "A" and "B" in generated songs
sectionsSectionView per section: name, kind, start_bar, bars, energy, layers, prog, variant and the flags filter_in, build, gap, impact, fills
layersevery name a section can switch on: drum lanes, parts, loops, pattern tracks
tracksTrackView per track: name, kind (drum, part, loop, pattern, fx), role, sound in words, the mix (level_db, pan, reverb, delay, duck, muted, solo) and loudness in the master (LUFS while playing) once rendered
drumsDrumView per lane: lane, pattern_bars, and variants as [name, velocities] with one velocity per sixteenth (0 for none)
partsPartView per generated part: name, role, style, the styles its genre offers, preset, the presets it may switch to, register (MIDI low, high), density
scriptthe song as a script; running it on the song changes nothing
lineagewhat was done to the song, oldest first

The last entry of tracks is always fx, the transition effects (crashes, risers, impacts), whose mix scripts set with mix("fx", ...).

RenderView

fieldmeaning
versionthe song version it was rendered from
url/api/audio/<version>.mp3; the same path ending in .wav gives the 24-bit master
seconds, lufs, true_peak_db, bpmthe master’s length and loudness
sections[name, start seconds, end seconds], for drawing the arrangement
waveformabout 1000 peak values from 0 to 1
stemstrack names with a stem at /api/stems/<version>/<name>.wav (empty unless rendered with stems)

VerdictView

fieldmeaning
total0 to 100
summaryone line: the total, the scores, the first three issues
scores[name, 0..1] for mix, arc, theory, and with the ears genre, production (and refs)
issues[severity 0..1, what, where to fix it], worst first
earsEarsView or null: genre_query, genre_z, best_alt, best_alt_z, production_z, worst_flaw, worst_flaw_z, sounds_like, p_real
measures[name, value] meters: lufs, true_peak_db, band_*_db, stereo_correlation, drop_minus_break_lu, …

The critic and the ears explains every score, measure and issue target.

Commands

POST /api/command takes one command as a JSON object tagged by "cmd", with Content-Type: application/json. The fields marked optional may be left out.

cmdfieldswhat it does
new_songgenre; optional seed, bpm, key, loops (false)composes a new song
loadpathopens a song folder, a blueprint.json, or a script file (run on the open song)
saveoptional pathwrites blueprint.json and song.rhai now, then the whole folder from a render (see Jobs); default folder ~/Music/claudius/<genre>-<title>-<seed>
set_tempobpmas tempo()
set_keykeyas key(): the progressions follow a change of mode
set_swingswingas swing()
set_progressionname, chords (strings)as prog()
set_sectionindex; optional name, bars, energy, layers, prog, variant, filter_in, build, gap, impact, fillschanges any of a section’s properties
add_sectionafter, kind, barsinserts a section after index after, with that section’s layers, energy and progression
remove_sectionindexremoves a section (a song keeps at least one)
move_sectionfrom, tomoves a section
toggle_layersection, layerswitches a track on or off in a section
set_drum_steplane, variant, step, velocitysets one sixteenth (0 removes the hit); a new variant starts as a copy of main
set_drum_patternlane, variant, stepsa whole variant in step notation, as drum()
set_partname; optional style, preset, register ([low, high]), density, reseed (false)as part(); reseed writes the part again with a new seed
set_mixtrack; optional level_db, pan, reverb, delay, duck, muted, soloas mix()
run_scriptcoderuns a script on the song
analyzepaththe analysis of an audio file, as text in the reply’s message
remakepath; optional genre, seed, bpma new song on the file’s skeleton; the genre defaults to the open song’s, and the tempo to the recording’s, halved or doubled into the genre’s range when it is outside
mutateoptional count (4, 1 to 32), keep_best (true)a job: tries variations and keeps the best if it beats the song
evolveoptional generations (3, 1 to 20), children (6, 2 to 32)a job: an evolutionary search from the song
install_packsoptional names (the missing default packs when left out)a job: downloads sample packs, as claudius setup does; not the opt-in packs, whose licence is unclear, and not on a shared claudius
renderoptional stems (false)a job: renders and judges the song
undo, redo
cancelstops a mutation or evolution after its current candidate or generation
{"cmd": "set_tempo", "bpm": 124}
{"cmd": "set_section", "index": 0, "energy": 0.5, "layers": ["kick", "hat"]}
{"cmd": "set_drum_step", "lane": "kick", "variant": "main", "step": 3, "velocity": 0.6}
{"cmd": "set_part", "name": "bass", "style": "rolling", "reseed": true}
{"cmd": "run_script", "code": "tempo(126); mix(\"lead\", #{ level: -2 });"}

set_tempo, set_key, set_swing, set_progression, set_drum_pattern, set_part and set_mix are turned into the matching script call and run as a script, so they accept exactly what the script functions accept and fail with the same messages. The other edits change the blueprint directly. Every edit that changes the song is one undo step with a label, and starts a render after 200 ms unless the session runs with --no-auto-render.

Jobs

render, mutate, evolve and save are jobs. A job runs on its own thread, one at a time, and the command answers as soon as it has started. Follow it in busy, progress and log, or wait with settled=1. A mutate or evolve while a job runs is refused, and the refusal leaves the version as it was; render and save wait for the job in progress instead:

{"ok": true, "message": "rendering after the job in progress", "version": 4}
{"ok": false, "message": "busy (rendering); try again when it is done, or cancel it", "version": 4}

A save writes blueprint.json and song.rhai at once. Asked for while the session is idle, it starts its own render; asked for during a job, such as the render that follows an edit, it waits, and the job slot passes straight to it when that job ends, so the session never looks idle in between and settled=1 waits for the save too:

{"ok": true, "message": "saving to /tmp/cl-save2 (blueprint.json and song.rhai now, the audio after the render)", "version": 14}
{"ok": true, "message": "saving to /tmp/cl-save2: blueprint.json and song.rhai now, the audio once the current job (rendering) is done", "version": 4}

Either way the save renders the song as it is when its turn comes, with stems, and rewrites the whole folder from that render, so the score in it is always the song its audio plays: song.wav, song.mp3, blueprint.json, song.rhai, project.json, report.json, verdict.json, credits.txt when sample packs play, and stems/ as 32-bit float. The log then says saved <folder>, or could not save <folder>: <reason>.

cancel does not stop a render that has started.

Replies and errors

Every command answers with a Reply:

fieldmeaning
okwhether the command did what it was asked
messagewhat happened, or why not
versionthe state version after the command
{"ok": true, "message": "new song: Crimson Tide — house at 124 BPM in G dorian (seed 9)", "version": 2}
{"ok": false, "message": "no progression 'Z' (there are A, B)", "version": 11}
{"ok": false, "message": "Runtime error: expected ']', found the end (line 1, position 12)", "version": 11}

A command claudius understood but refused (a bad edit, a script error, a busy session) answers 200 OK with "ok": false, and changes nothing. The HTTP status says when the request itself is wrong:

statuswhenbody
400 Bad Requestthe JSON is not a commanda Reply whose message starts with not a command: and names the unknown cmd or the missing field
401 Unauthorizeda token is required and missing or wrongthis claudius needs its token (Authorization: Bearer <token>, or ?token=<token>)
403 Forbiddenthe Host header is not a loopback name and the server is not --publicclaudius only answers on localhost (start it with --public and a --token to share it)
404 Not Foundno such render or stemno render of version 999, no stem 'kick' in render 13 (render with stems), audio is /api/audio/{version}.wav or .mp3
413 Payload Too Largea command body over 1 MiBFailed to buffer the request body: length limit exceeded
415 Unsupported Media Typea command without Content-Type: application/jsoncommands are JSON (Content-Type: application/json)

Audio

/api/audio/{version}.wav and .mp3 serve the render of song version version, with Range requests, so players can seek:

$ curl -sI -H 'Range: bytes=0-99' http://127.0.0.1:7878/api/audio/13.wav
HTTP/1.1 206 Partial Content
content-type: audio/wav
accept-ranges: bytes
content-range: bytes 0-99/72955106
content-length: 100

The MP3 is encoded by ffmpeg on the first request (a few seconds for a song) and kept. Without ffmpeg, the .mp3 address serves the WAV, which browsers play too. A session keeps its last ten renders on disk, so older versions return 404. Stems exist only for a render made with {"cmd": "render", "stems": true} or by save, and only the latest render’s stems are served, as 24-bit WAV; the copies a save writes into the song folder are 32-bit float.

Who may call it

The server is written for one person on one computer, and opens up only when asked. The security model has the reasoning; these are the rules as a client sees them.

Host names. Without --public, every request must name a loopback host in its Host header: localhost, 127.0.0.1 or [::1], with any port. Anything else gets 403, which stops a web page from reaching claudius through DNS rebinding. It is not access control: any program on the machine can send Host: localhost.

Tokens. With --token <secret> (or CLAUDIUS_TOKEN), and always with --public, which makes a token when it is given none, every request must carry the token, in one of three ways:

  • Authorization: Bearer <secret>;
  • ?token=<secret> in the URL, which also answers with Set-Cookie: claudius_token=<secret>; Path=/; HttpOnly; SameSite=Strict, so a browser opened at /?token=<secret> keeps working without it. When the request came through https (X-Forwarded-Proto: https, which tunnels send), the cookie is also Secure;
  • that cookie.

The comparison takes the same time wherever the strings differ. CORS preflight requests (OPTIONS) are answered before the token check, so a browser can ask what is allowed.

Commands are JSON. /api/command refuses any other content type, and the MCP endpoint does the same, so a page on another site cannot send a command with a plain form post or a no-cors fetch.

CORS. Cross-origin requests are allowed from pages served by http://localhost, http://127.0.0.1 and http://[::1] on any port, for GET, POST and DELETE, with the headers Content-Type, Authorization, mcp-session-id and mcp-protocol-version. Pages from anywhere else, including https://localhost, get no Access-Control-Allow-Origin, so the browser does not let them read the answer.

Security headers. Every response carries:

x-content-type-options: nosniff
referrer-policy: no-referrer
x-frame-options: DENY
content-security-policy: frame-ancestors 'none'

so a browser does not guess content types, leak the address in a Referer, or let another page frame the studio.

Sample packs

The sample packs are recordings of real instruments and drum machines, mapped so that claudius can play any note at any velocity with them. Every pack in the default set is under a licence that lets you make and publish music with it. Without packs claudius synthesizes every sound; with them, genres reach for a Rhodes, an upright bass or a real 909 where those belong.

Packs live in ~/Library/Application Support/claudius/packs on macOS and ~/.local/share/claudius/packs on Linux, or in $CLAUDIUS_PACKS. Each pack is a folder with an index.json (its licence, attribution, files and how every instrument maps onto them) and a files/ folder.

The packs

claudius-packs list shows every pack with its state and licence. The 14 default packs hold 162 instruments:

packwhatinstrumentslicencecredit requiredon disk
salamanderYamaha C5 grand piano, 5 velocity layers1CC BY 3.0yes226 MB
epianosWurlitzer EP200, Yamaha CP80, Hohner Pianet T3CC BY 3.0yes21 MB
jrhodes1977 Rhodes Mark I, 5 crossfaded layers1free for making music; no redistribution of the samplesno38 MB
vsco2VS Chamber Orchestra 2: strings, brass, woodwinds, harp, timpani, key-split sections36CC0no284 MB
vcslmallets, pipe organ, tenor sax, harmonica, world and orchestral percussion32CC0no150 MB
tr808Roland TR-808 by Michael Fischer, every knob at five positions16CC0no13 MB
tr909Roland TR-909 by Audio Realism, accent layers and knob sweeps11free for personal and commercial use, no redistributionno26 MB
tr707Roland TR-707, all 15 sounds13public domainno0.5 MB
uzuthe uzu drum kit made for Strudel15Unlicenseno2.4 MB
muldjordMuldjordKit, an acoustic rock kit (bank acoustic)11CC BY 4.0yes135 MB
freepatsupright piano, FM piano, drawbar organ, basses, guitars, synth pads, strings and leads19CC0no137 MB
doublebassdouble bass pizzicato with round robins1CC0no30 MB
growlybassSquier Jazz Bass, fingered1CC0no67 MB
voicea low male “ah”, and a 5-voice choir made from it2CC0no27 MB

The download is about 2.2 GB, larger than the 1.2 GB on disk, because vsco2, vcsl, tr909, doublebass, growlybass and voice arrive as WAV and are converted to lossless FLAC as they install. Where a pack was cut for size (the piano keeps 5 of its 16 velocity layers, VSCO leaves out the tremolo and muted brass), the pack’s description in claudius-packs list says what was left out.

Two more packs install only when you name them:

packwhatlicence
splendidSteinway grand released into the public domain by AKAI, 4 layers (77 MB)public domain
piano-liteStrudel’s default piano: Salamander, one layer, MP3 (10 MB)CC BY 3.0

Three are opt-in: their licences are unclear or carry conditions, and they install only with --accept-licence.

packwhatwhy it is opt-in
tidal-drum-machinesStrudel’s default banks, about 70 drum machinesno licence is declared and the provenance is unknown
dirt-samplesthe TidalCycles Dirt-Samplesno licence, and some samples come from commercial records
mridangammridangam strokes by Arthur CarabottCC BY-SA 4.0: share-alike may extend to your music

All three install lazily: their samples download the first time a song plays them. 0007 Which samples we ship explains how packs are chosen.

Installing

claudius setup                          # the default packs and the listening models, asking first
claudius-packs install --defaults       # the default packs only
claudius-packs install splendid          # one pack
claudius-packs install mridangam --accept-licence
flagwhat it does
--defaultsevery default pack
--lazyrecords the pack now and downloads each sample the first time it plays
--threads Nparallel downloads (8)
--accept-licenceallows an opt-in pack (--accept-license works too)

Curated packs come from their authors’ GitHub repositories at pinned commits, except tr909 (a zip from audiorealism.se) and the FreePats drawbar organ (a .tar.xz from freepats.zenvoid.org), which are unpacked with the system’s tar (or unzip). Each file downloads to a .part file, is checked against its expected size, and is renamed into place, so an interrupted install resumes where it stopped: run the same command again. Installing a pack that is already there downloads only what is missing and deletes files its index no longer uses.

Listing a repository uses GitHub’s API, which allows 60 requests an hour without an account. If an install fails with set GITHUB_TOKEN if the API rate limit was hit, set GITHUB_TOKEN (or GH_TOKEN) to any GitHub token and run it again.

Removing

claudius-packs remove splendid

remove deletes the folder <packs>/<name> and everything in it. It takes a plain pack name, as claudius-packs list shows it, and only for a pack that is installed (its folder has an index.json) or that the registry knows, which covers a half-finished install. Anything else is refused and nothing is deleted:

$ claudius-packs remove ..
Error: '..' is not a pack name
$ claudius-packs remove nonexistentpack
Error: pack nonexistentpack is not installed

Paths are refused the same way as ..: a/b and absolute paths are “not a pack name” too.

Names

A script or a genre asks for an instrument by name, and the installed packs decide what plays:

you writeplays
pianoa friendly alias: the first installed of salamander:piano, splendid:piano, freepats:upright_piano, piano-lite:piano
vsco2:violinsthat pack’s instrument, by name or by one of its aliases (vsco2:violin_ensemble)
tr808:sd:3variation 3 of that instrument
kick, bd, hha bare drum name: the first installed kit of tr909, tr808, muldjord, tr707, uzu that has it
RolandTR909_bda Strudel bank name: bank RolandTR909, sound bd
bd with bank tr909that kit’s kick

claudius-packs resolve answers what a name plays on your machine, and claudius-packs aliases lists every friendly name with what it resolves to now (262 of them with the default packs):

$ claudius-packs resolve piano
salamander:piano
$ claudius-packs resolve bd:2 909
tr909:bd:2
$ claudius-packs resolve sd acoustic
muldjord:sd
$ claudius-packs resolve theremin
Error: nothing installed matches theremin

In scripts, drum names and synth presets are looked up before the packs, so s("bd") plays the synthesized kick and .s("epiano") the FM synth. Sounds has the full order and the names to use for the recordings.

Banks and variations

A bank is a drum kit picked by name. s("bd*4, [~ sd]*2, hh*8").bank("tr909") plays all three on the 909. Banks answer to the pack’s name and its aliases:

bankalsokit
tr909909, rolandtr909, tr-909Roland TR-909
tr808808, rolandtr808, tr-808Roland TR-808
tr707707, rolandtr707, tr-707Roland TR-707
muldjordacoustic, acoustic_kit, rock_kitMuldjordKit
uzuuzu-drumkituzu drum kit

Drum names have synonyms (kick, kd, bassdrum for bd; snare for sd; rimshot, sidestick for rim; and so on), so s("kick").bank("808") works. A variation picks another recording of the same drum: bd:2 in the pattern, or .n("0 1 2"). The drum machines have many:

  • tr808: every knob at five positions, 25 variations of bd, sd and the cymbal (tone by decay, or tone by snappy), 5 of the toms, congas and open hat;
  • tr909: variation 0 of every drum plays its 11 accent levels as velocity layers; the others are the knob sweeps (125 for bd and sd, 121 for the toms, the decay of the hats, the tuning of the cymbals);
  • tr707: one recording per sound.

claudius-packs instruments shows how many variations each instrument has.

Strudel sample maps

Any Strudel sample map can be added as a pack of your own:

claudius-packs add-map github:tidalcycles/uzu-drumkit       # reads strudel.json on the main branch
claudius-packs add-map github:user/repo/branch/sub/folder   # strudel.json in that folder
claudius-packs add-map https://example.org/samples.json
added tidalcycles-uzu-drumkit (16 sounds, licence Unlicense, lazy)

The pack is named after the GitHub owner and repository (tidalcycles-uzu-drumkit; a curated pack’s name is refused), and installs lazily: each sample downloads the first time a song plays it. Its sounds take the names in the map, so a map with RolandTR909_bd answers to s("bd").bank("RolandTR909"). The licence comes from the map’s _license field or, failing that, from the GitHub repository. When there is none, it is recorded as unknown, add-map prints a warning, and every song that uses the pack says so in its credits.txt. claudius-packs install <name> downloads all of a user pack’s samples at once.

Credits

A song that plays sample-pack instruments gets a credits.txt next to it with the packs it used, and only those:

Sounds:
- E-Pianos (Yamaha CP80, Hohner Pianet T, Wurlitzer EP200) by Greg Sullivan (CC BY 3.0), SFZ mapping by kinwie / sfzinstruments https://creativecommons.org/licenses/by/3.0/
- jRhodes3d by Jeff Learman [jRhodes licence: free for making music (CC0 for musicians); redistributing the samples: CC BY-NC 4.0] https://github.com/sfzinstruments/jlearman.jRhodes3d/blob/master/LICENSE
- Roland TR-808 samples by Michael Fischer (Technopolis, 1994), packaged by TidalCycles [CC0-1.0] https://creativecommons.org/publicdomain/zero/1.0/
- VS Chamber Orchestra 2: Community Edition by Versilian Studios (CC0); recorded by Sam Gossner and Simon Dalzell [CC0-1.0] https://creativecommons.org/publicdomain/zero/1.0/

The packs whose licence requires credit (CC BY) come first, then the others as a courtesy, then a Warning: licence unknown for ... line for any pack without a known licence. When you publish the song, put the lines of the packs that require credit where you credit the music.

claudius-packs credits prints the required credits of every installed pack, and claudius-packs credits piano rhodes bd@tr909 those of the packs behind specific names (name@bank for a drum in a bank).

Auditioning

commandwhat it does
claudius-packs instrumentsevery installed instrument with its range, velocity layers, round robins, zones and variations
claudius-packs render <name> <out.wav> [--bank B] [--notes 60,64,67] [--secs S]renders a chord with one instrument
claudius-packs audition [dir] [--pack P]renders every instrument (an arpeggio, or a few hits for drums, and a beat per kit) into dir (/tmp/claudius-packs-audition), and reports instruments that are silent or seem mapped an octave off
$ claudius-packs instruments
salamander:piano                     piano                pitched   A0..C8   layers 5  rr 1  zones 150  variants 1
epianos:wurlitzer                    electric piano       pitched   A1..G#6  layers 4  rr 1  zones 48   variants 1
vcsl:marimba                         mallets              pitched   F2..C7   layers 1  rr 1  zones 30   variants 1   one-shot
...

Memory

Decoded samples stay in memory once played, up to 3072 MB by default (CLAUDIUS_PACKS_CACHE_MB changes the budget). When a new sample takes the cache over budget, the samples used longest ago that nothing is playing are dropped until it is back to 80 % of the budget; they are decoded again the next time they play.

Apple Loops and your own samples

Besides synthesis and the sample packs, claudius plays three kinds of recordings: Apple Loops when GarageBand or Logic content is installed, cuts of songs you give it, and any audio file a script names.

Apple Loops

Where claudius finds them

GarageBand and Logic keep a database of every installed loop with its tempo, length in beats, key and scale, instrument, genre and mood. claudius reads the newest LoopsDatabaseV*.db in ~/Music/Audio Music Apps/Databases or in GarageBand’s container (~/Library/Containers/com.apple.garageband10/Data/Music/Audio Music Apps/Databases), read-only, and keeps the loops whose files exist. A full install has about 14,000; claudius doctor counts them:

  ok  Apple Loops     14096 loops and samples

The count includes your user library. When there is no database, every Apple Loops feature quietly falls back to synthesis or the packs.

How a song uses them

Drum one-shots. When make, song() in a script, or new_song writes a drum lane, it may cut the lane’s sound from a loop instead of synthesizing it: a kick from a loop whose subtype is Kick, a hat from a Hi-hat loop, a clap from a loop with “Clap” in its name, percussion from Clave, Conga, Bongo or Cowbell loops. Loops tagged with a GarageBand genre that suits the song’s genre (Deep House and Tech House for house, Hip Hop for trap and lofi) are four times as likely. The chance is --samples, or samples in song(); it is 0.75 unless you change it, for make, scripts and new_song alike, so the same seed makes the same song everywhere. When the genre puts the song’s drums on a sample-pack kit, the kit wins.

To cut a hit, claudius finds the onsets in the loop and scores each hit by its level, the room before the next hit, how much of the previous hit bleeds into it, and whether its decay suits the lane (long for a kick, short for a closed hat). The best one is trimmed to its decay, faded, normalised to −1 dBFS and cached as a WAV in ~/Library/Caches/claudius/samples/ (~/.cache/claudius/samples/ on Linux), so the loop is read once. The track’s sound then reads like hat cut from 'Silver Hi-Hat 04' (Apple Loops). Whether a lane uses a one-shot is drawn the same way with or without Apple Loops, so the drum patterns of a seed are the same on every machine and only their sounds differ.

Loops. House, deep house and techno have a percussion loop part called top. claudius picks a Drums or Percussion loop of a suitable subtype and genre within 23 % of the song’s tempo (the closer, the likelier), warps it to the tempo, and plays it in the sections that list top. Tonal loops are also transposed to the song’s key by the shortest way, a minor loop to the minor tonic (the relative minor in a major key).

Loop families. make --loops, song("house", #{ loops: true }) and new_song with "loops": true build the whole song around a family of loops produced together: loops in one folder, at one tempo, whose names share a prefix before the instrument word (“Bliss Abyss” in “Bliss Abyss Chord Layers 01”). The song takes the family’s tempo and key, reads the chords of its harmony loop bar by bar from their pitch content (helped by the family’s bass loop), and plays the family’s chords, bass and beat loops as tracks, the way you would start a song in GarageBand. Families with a bass loop and a genre that suits the song are favoured.

Scripts. .bank("apple") cuts drum names from Apple Loops instead of synthesizing them: s("bd*4, [~ cp]*2").bank("apple").

Warping

A loop plays at the song’s tempo and key through Signalsmith Stretch, which changes tempo and pitch in one pass. When the tempo changes by less than 1.5 % and the key not at all, claudius resamples instead (varispeed), which adds no artefacts at all. The loop is stretched as three copies end to end and the middle one kept, so it wraps without a click. Warped loops are cached in ~/Library/Caches/claudius/stretched/.

The licence

Apple’s licence for GarageBand and Logic content lets you use the loops in music you make; read Apple’s software licence agreement for the exact terms. claudius reads the loops where GarageBand or Logic installed them and never copies them anywhere, except the one-shots it cuts, which stay in its cache. It does not redistribute them, and nothing in the claudius repository or its releases contains Apple content.

--no-library (on make, evolve, studio, serve and mcp) keeps Apple Loops and your user library out of the song while the sample packs still play; --synth-only keeps the packs out too; --samples 0 keeps only the one-shots out.

Chopping a song

claudius chop turns a recording into material:

claudius chop ~/Music/reference.mp3 --bars 2

It analyses the song (tempo, downbeat, key, sections), then writes into out/chops/<file name>/:

  • one loop per section, --bars bars long from the section’s first downbeat (01-intro-2bars.wav, 02-drop-...), tagged with the tempo, the length in beats, the key and the section’s energy;
  • a kick.wav, clap.wav and hat.wav, each cut from the sparsest section that still has that instrument.

Each file is added to the user library, ~/Library/Caches/claudius/user/library.json, which claudius merges with the Apple Loops index: from then on, the one-shots can be picked for drum lanes like Apple Loops one-shots are.

--find "a vocal chop" also cuts out the moments that best match a description, using earshot’s search, as find-a-vocal-chop-1.wav and so on (--top of them per description).

Flipping a recording into a song

claudius make --sample <file> builds a song around a recording, the way a producer flips a sample:

claudius make --genre lofi --sample out/chops/reference/03-drop-2bars.wav
  • The recording’s tempo, length and key come from the user library when it is a chop. Otherwise claudius analyses it with the genre’s tempo range and uses 4, 8, 12 or 16 beats of it.
  • It is warped to the song’s tempo and transposed to the song’s key by the shortest way.
  • Six times in ten (when it is at least four half-beat slices long), it is re-chopped: an eight-slice motif that starts each half bar on the slice of the same position and fills the rest with random slices. Otherwise it plays as recorded.
  • It becomes a loop track named sample1 (then sample2 …), with the role sample, ducked under the kick and sent to the reverb, and plays in the drops, hooks and grooves, and in some breaks.

The song’s history records it as flipped '<file>' (<tempo> BPM, <beats> beats, transposed <semitones>), with , re-chopped at the end when it was.

To write a whole new song on another song’s skeleton instead, use claudius remake; see Remaking a song from audio.

Files in scripts

A script plays any audio file through .s() on a note(), n() or chord() pattern:

track("vox", note("c4 ~ c4 [c4 c4]").s("/Users/me/Samples/vox.wav"));
  • The file plays like a sampler: c4 at its recorded pitch, other notes faster or slower.
  • On a track whose role is not a drum role, each note stops at its length plus 50 ms; drum roles play the whole file.
  • A name counts as a file when it contains / or ends in .wav, .mp3, .aif, .aiff, .flac, .caf or .m4a. It cannot go inside a mini-notation string, where / means “slower”.
  • claudius decodes WAV, AIFF, FLAC, MP3, AAC and ALAC (M4A), CAF and Ogg Vorbis itself, and asks ffmpeg for anything else when it is installed.

The song stores the file’s path, not its audio, so moving or deleting the file breaks the song. Every decoded file is cached as raw 48 kHz audio in ~/Library/Caches/claudius/decoded/ (about 23 MB per minute of stereo), keyed by its path, size and modification time.

What a shared claudius may read and write

A claudius only you can reach reads and writes wherever you ask. A claudius others can reach checks every path it is given against a list of folders:

how it runsthe folders
claudius serve --public, claudius serve --host with an address other than loopback, or claudius serve --files <dir>~/Music/claudius, claudius’ cache folder, the sample packs folder, /Library/Audio/Apple Loops, ~/Library/Audio/Apple Loops, plus every --files folder
anything else (studio, serve, mcp)no limit

The checks:

  • Reading. A file named in .s(), opened with load / open, or given to analyze or remake must exist and, after links and .. are resolved, lie inside one of the folders. Opening a blueprint.json also checks every audio file the song plays.
  • Writing. save may create a folder when its nearest existing parent lies inside one of the folders and the rest of the path is plain names, so <allowed>/../escape is refused.

Refusals name the path. A path outside the folders gets the same answer whether or not it exists, so a shared claudius does not tell anyone which files you have; does not exist is said only of a path inside them. The last line comes from a claudius whose sample packs folder was /tmp/claudius-packs-demo (CLAUDIUS_PACKS):

/etc/hosts is outside the folders this claudius may read
/tmp/cl-elsewhere is outside the folders this claudius may write
track 'v': /etc/hosts is outside the folders this claudius may read
track 'v': /tmp/claudius-packs-demo/nope.wav does not exist

A folder that does not exist yet is not on the list: until something creates ~/Music/claudius, a shared claudius refuses to save there (Troubleshooting). Sample-pack instruments and Apple Loops are found by name, not by path, so they play in every mode. The security model explains the policy and its limits.

Files and settings

claudius keeps songs, sounds, models and caches in separate places, so that each can be backed up, moved or deleted on its own. The paths below are for macOS and Linux; where an environment variable moves a folder, it is named.

Where everything is

whatmacOSLinuxmoved by
songs saved from the studio and the MCP tools~/Music/claudius$XDG_MUSIC_DIR/claudius, else ./outthe path of save
songs written by the command line./out (where you run it)./out-o
caches~/Library/Caches/claudius$XDG_CACHE_HOME/claudius or ~/.cache/claudiusCLAUDIUS_HOME
sample packs~/Library/Application Support/claudius/packs~/.local/share/claudius/packsCLAUDIUS_PACKS
scratch space while a pack installs~/Library/Caches/claudius/packs~/.cache/claudius/packs
listening models~/.cache/earshot-rs~/.cache/earshot-rsEARSHOT_HOME
your genres~/Library/Application Support/claudius/genres~/.config/claudius/genres
the answer about usage statistics~/Library/Application Support/claudius/telemetry.json~/.config/claudius/telemetry.json
whether to update automatically, and the day it last looked~/Library/Application Support/claudius/updates.json~/.config/claudius/updates.json
studio window preferences~/Library/Application Support/claudius-studio/app.ron~/.local/share/claudius-studio/app.ron
Apple Loops (read only)/Library/Audio/Apple Loops, ~/Library/Audio/Apple Loops
programs and web studio (install script)~/.local/bin, ~/.local/share/claudius/webthe sameCLAUDIUS_INSTALL_DIR

On Linux, $XDG_MUSIC_DIR comes from ~/.config/user-dirs.dirs; without it, the studio saves under out in the folder it was started from. The web studio keeps its preferences in the browser’s local storage.

Song folders

A song folder holds a song in every form claudius has for it. One function, song::write_folder, writes every folder, whether the CLI (make, script, render, remake, evolve, mutate) or a save in the studio or over MCP made it, so they hold the same files:

filewhat it is
song.wavthe master, 48 kHz, 24-bit
song.mp3a 256 kbps copy, when ffmpeg is installed
blueprint.jsonthe song’s genome
song.rhaithe song as a script
project.jsonthe arranged session
report.jsontempo, key, form, loudness, and every track’s level and gain
verdict.jsonthe critic’s verdict
credits.txtattributions, when sample packs play
stems/<track>.wavevery track, 32-bit float: always from a save, from make and render with --stems

Stems are floating point because a track can peak above full scale before the master’s limiter brings the mix down. claudius judge runs its arrangement and theory checks when project.json sits next to the audio, so any song folder gets the full verdict.

blueprint.json is the song: claudius render, claudius script --on, claudius evolve --from, claudius mutate, and open in the studio and the MCP tools all start from it. The song model describes its fields. Every later claudius opens it; a newer claudius’s song, in a format this one does not read yet, says so and asks for an update. A blueprint that plays audio files (Apple Loops, chops, files from scripts) stores their paths, so it plays on another computer only where those files exist.

Caches

Everything in the cache folder can be deleted while claudius is not running; it is rebuilt on demand.

folderwhatgrows by
decoded/every audio file claudius has read, decoded to raw 48 kHz stereo .f32, keyed by path, size and modification timeabout 23 MB per minute of audio
stretched/Apple Loops and samples warped to a tempo and keyone file per loop, tempo and key
samples/drum one-shots cut from Apple Loopsa few hundred KB each
user/library.jsonthe user library: chops and found sounds from claudius chop
renders/<process id>/a running session’s renders: <version>.wav, .mp3, and <version>/ with stemsabout 70 MB per four-minute render, and a WAV per track more for a render with stems, which every save makes: a new two-minute lofi song and one save left 433 MB

A session keeps its last ten renders and deletes older ones as it goes. When a session starts, it deletes the render folders of sessions that last wrote more than 12 hours ago, so a session that crashed leaves its renders behind for at most that long. decoded/ and stretched/ are never trimmed: on a machine that renders and analyses a lot, they reach gigabytes, and deleting them costs only the time to decode again.

The pack installer’s scratch space holds downloaded archives and their unpacked files only while a pack installs, and is removed afterwards.

Sample packs and models

A pack is a folder under the packs folder with an index.json and a files/ folder; Sample packs covers installing and removing them. claudius-packs list prints the folder in use.

The models live under models/<repository>/<revision>/ in the models folder, one revision folder per pinned model, with CLAP’s text embeddings of earshot’s vocabulary cached in text/clap-d78c3994912c.bin. Deleting the folder makes the next verdict download 785 MB again (1.13 GB with the AudioSet tagger).

Your genres

A genre is a JSON file. claudius loads its eight built-in genres, then every .json file in your genres folder; a file whose name matches a built-in genre replaces it. The fields are those of crates/claudius/data/genres/house.json; copy it, rename the name, change what you like, and claudius genres lists it. A file that does not parse makes every genre lookup fail, the built-in genres included, so make and song() fail with Error: genre file <path> and the JSON error under Caused by:, and the studio’s list of genres comes up empty. Fix or move the file and try again.

The web studio

claudius studio, serve and mcp serve the web studio at / from the first of these folders that has an index.html:

  1. --web <dir>, or CLAUDIUS_WEB;
  2. share/claudius/web next to the program’s folder (~/.local/share/claudius/web for ~/.local/bin/claudius);
  3. web inside the program’s folder;
  4. crates/claudius-ui/dist of the checkout the program was built from (cargo xtask web builds it there).

Without one, / shows a page that says how to build it, and the API and MCP work as usual.

Assistant configuration

claudius connect edits these files, keeping a copy of the previous version as <file>.bak:

clientfile
Claude Desktop~/Library/Application Support/Claude/claude_desktop_config.json (macOS), %APPDATA%\Claude\claude_desktop_config.json (Windows), ~/.config/Claude/claude_desktop_config.json (Linux)
Cursor~/.cursor/mcp.json
Claude Codeits own configuration, through claude mcp add --scope user

Environment variables

variabledefaultwhat it changes
CLAUDIUS_HOMEthe cache folder abovewhere caches and session renders go
CLAUDIUS_PACKSthe packs folder abovewhere packs are installed and read
CLAUDIUS_PACKS_CACHE_MB3072memory for decoded sample-pack audio
CLAUDIUS_PORT7878--port of studio, serve, mcp, doctor and connect
CLAUDIUS_TOKENnone--token of serve and mcp
CLAUDIUS_WEBfound as abovethe web studio folder
CLAUDIUS_FFMPEGffmpeg on PATH, then /opt/homebrew/bin, /usr/local/bin, /usr/binthe ffmpeg claudius uses for formats it cannot decode and for MP3; none turns it off
CLAUDIUS_DEBUGunsetwhen set, renders print their timings and the beat tracker its cues to stderr
DO_NOT_TRACK, CLAUDIUS_TELEMETRYunsetDO_NOT_TRACK=1 or CLAUDIUS_TELEMETRY=0 keeps usage statistics off whatever the answer
CLAUDIUS_TELEMETRY_URLthe Worker in telemetry/where usage statistics go, to test an endpoint (the only place a debug build sends them)
CLAUDIUS_UPDATESonoff stops the studio’s daily look for updates
CLAUDIUS_UPDATE_SINCEunseta version for the updater to act as, to try an update against the latest release
EARSHOT_HOME~/.cache/earshot-rswhere the listening models and text embeddings go
EARSHOT_OFFLINEunset1 never downloads models; a verdict without them uses meters and theory
EARSHOT_THREADShalf the cores, at most 4threads per model
EARSHOT_FFMPEG, EARSHOT_FFPROBEfound like CLAUDIUS_FFMPEGthe ffmpeg and ffprobe earshot decodes reference tracks with
GITHUB_TOKEN, GH_TOKENunsetsent to GitHub’s API while installing packs, for its higher rate limit
CLAUDIUS_SERVERhttp://127.0.0.1:7878the server the standalone claudius-ui program connects to
CLAUDIUS_MOCKunset1 makes claudius-ui run against its built-in demo song
CLAUDIUS_UI_PLANunseta scripted run of the studio window, for testing (The studio UI)

The install script reads CLAUDIUS_VERSION, CLAUDIUS_INSTALL_DIR and CLAUDIUS_DOWNLOAD_URL; see Install.

Privacy and usage statistics

claudius runs on your computer and sends nothing about you or your music anywhere. It goes on the network for four things only: the downloads you ask for (the sample packs, from GitHub and their authors’ sites, and the listening models, from Hugging Face), the update check, once a day, to GitHub, an assistant you connect from outside your computer if you set that up, and anonymous usage statistics, which are off until you turn them on.

Usage statistics

They stay off unless you say yes. The studio asks once, at the end of its first-run tour, with two answers and neither one picked for you; closing the tour without answering leaves them off. The studio’s ? menu has the same switch, and from a terminal:

claudius telemetry        # whether they are on, and an event exactly as it is sent
claudius telemetry on
claudius telemetry off

When they are on, claudius sends three kinds of events, each at most once a day:

eventwhendetail
starteda command starts: studio, serve, mcp, make and the restnone
setupclaudius setup, or the studio’s pack download, endsok, models (the models did not download), or packs:<name> (the first pack that did not install)
panicclaudius stops on a panic, a bugthe file and line, such as claudius/src/render.rs:123, never the message

An event is these seven fields and nothing else:

{
  "v": 1,
  "event": "started",
  "command": "studio",
  "version": "0.2.0",
  "os": "macos",
  "arch": "aarch64",
  "detail": ""
}

It never holds a file name, a path, a song or any part of one, your name, or an identifier: nothing tells one person’s events from another’s. It is sent in the background with a 3-second timeout and never sent again, so a computer without a network notices nothing.

Where they go

To https://claudius-telemetry.devlato.workers.dev/v1/event, a Cloudflare Worker whose source is telemetry/ in this repository. It refuses anything that is not an event of the shape above, and writes the seven fields to Workers Analytics Engine, which keeps them for three months. It writes no IP address and nothing else from the request. Cloudflare, which runs it, sees each request the way any web server sees one.

Keeping them off

Any of these keeps them off, whatever the answer:

  • DO_NOT_TRACK=1;
  • CLAUDIUS_TELEMETRY=0 (or off);
  • CI set, as on continuous integration;
  • a debug build (cargo run without --release), which sends only to CLAUDIUS_TELEMETRY_URL, when that is set.

The answer is kept in telemetry.json in claudius’ settings folder (~/Library/Application Support/claudius on macOS, ~/.config/claudius on Linux, %APPDATA%\claudius on Windows), with the day each kind of event last went out. Deleting the file is the same as never having answered.

For maintainers

  • node telemetry/test.mjs checks that the Worker takes what claudius sends and refuses everything else; a test in claudius-cli checks that it knows every command.
  • npx wrangler deploy in telemetry/ publishes it. Analytics Engine has to be enabled on the Cloudflare account once, in the dashboard.
  • scripts/telemetry-report.sh [days] sums the events of the last 30 days (or days) by event, command, version and system. It needs CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, a token that can read Account Analytics.

Updates

Claudius looks for a new release once a day, downloads it in the background, and installs it when you say so.

In the studio

When a new release has been downloaded and checked, a bar under the toolbar says so. Restart to update installs it, closes the studio, and opens the new version. What’s new opens its release notes, and Later puts it off until the studio opens next. While it downloads, the status bar shows how far it is.

The ? menu has Check for updates, which looks now and says what it found, and Update automatically, which turns the daily look off and on.

How each install updates

installed withthe update
the Mac app (the install script, or a disk image dragged to Applications)the new disk image for your Mac; its app takes the old one’s place. The app has to be in a folder you can write in, as Applications is
an AppImagethe new AppImage takes the old file’s place
the install script on Linux, or on a Mac with --no-appthe new archive’s programs and web studio take the old ones’ place in ~/.local
the Windows installer, or install.ps1the new installer runs once Claudius has closed
Homebrew, cargo, the .deb or .rpm, a build from sourcenothing is downloaded: the bar says how it updates (brew upgrade claudius, your package manager, …)

From the command line

claudius update             # downloads the latest release, and installs it after asking
claudius update --yes       # without asking
claudius update --check     # only says whether there is a newer release
claudius update --auto off  # the studio stops looking (on turns it back on)

Checked before it is installed

The latest release is the one GitHub’s releases/latest points to. Its file for your computer is downloaded over HTTPS, and checked against the SHA-256 sums the release lists in SHA256SUMS; a file that does not match is thrown away. The releases cannot be changed once published, and each has a provenance attestation (see Install).

Your songs

Every release opens the songs that earlier ones saved: the test suite keeps the songs each release saved and opens them all (saved songs keep opening). A song saved by a newer Claudius than yours says so, and asks you to update.

What it sends

The daily look is one request to github.com for the address of the latest release, and, when there is a newer one, the download of its checksums and its file for your computer. GitHub sees these requests, as it sees any download, and nothing about you or your music goes with them. It is off with claudius update --auto off, Update automatically in the studio’s ? menu, or CLAUDIUS_UPDATES=off, and in CI.

CLAUDIUS_UPDATE_SINCE=0.1.0 makes Claudius act as if it were that version, to try an update against the latest release.

Troubleshooting

Start with claudius doctor: it checks the web studio, the packs, the models, Apple Loops, ffmpeg and the port, and says what to do about each. The sections below are the problems it cannot see, by the message you get.

macOS will not open claudius

“Claudius” Not Opened
Apple could not verify “Claudius” is free of malware that may harm your Mac or compromise your privacy.

macOS says this about an app downloaded in a browser that Apple has not notarized, which claudius’ releases are not yet. The app itself is fine; it is the check. Either:

  • install it from Terminal instead, which gives an app macOS opens straight away, because it checks only what a browser downloads:

    curl -fsSL https://github.com/devlato/claudius/releases/latest/download/install.sh | sh
    
  • or click Done, open System Settings, Privacy & Security, scroll down to the line about Claudius, click Open Anyway, and confirm. macOS remembers it after that.

The same message about a claudius program from a downloaded .tar.gz has the same two answers.

The verdict has no ears

earshot is unavailable (download failed: https://huggingface.co/Xenova/larger_clap_general/resolve/...); judging with meters and theory only

The verdict then has mix, arc and theory scores but no genre or production. The models download on first use, 785 MB, from the Hugging Face Hub. The usual causes:

  • No network on the first run. Run claudius setup --no-packs once while online.
  • EARSHOT_OFFLINE=1 is set. Then a model that is not cached fails with onnx/audio_model.onnx is not in the cache (...) and offline mode is on (EARSHOT_OFFLINE=1 / EarsOptions::offline): run once online to download it.
  • A download was altered or cut short. Every model file is checked against a pinned SHA-256 hash, and a file that does not match is thrown away: ... does not match its pinned SHA-256 (got ..., expected ...); not using it. Run it again; if it keeps failing, something between you and the Hub changes the files.
  • EARSHOT_HOME points somewhere unwritable: cannot create <folder>.

The first load after the download spends about 30 s embedding earshot’s vocabulary; later loads take 4 to 8 s. In the studio, verdicts include the ears from the moment the log says ears ready (...). --no-ears skips the models altogether, which also saves about half a gigabyte of memory.

The port is in use

$ claudius serve --port 7933
Error: port 7933 is taken, by another claudius or another program (--port picks another)

claudius studio stops with the same message when the program on the port is not claudius; when it is a claudius, the studio opens on it instead. claudius doctor tells the cases apart:

  --  port 7933       used by another program: pass --port to studio, serve and mcp
  ok  port 7922       claudius is running here

Pick another port with --port or CLAUDIUS_PORT, for every command that should meet there: claudius studio --port 7900 and an assistant configured with claudius mcp --port 7900. claudius mcp does not stop: it runs a session of its own without a web studio and says so on stderr:

claudius mcp: a session of its own (no web UI: port 7933 is taken, by another claudius or another program (--port picks another))

The assistant edits a different song from the studio

claudius mcp works on the studio’s song only if it finds the studio. It looks at http://127.0.0.1:7878 (or --port, --url) when the assistant starts it, so:

  • The studio was started after the assistant. Restart the assistant’s MCP server (in Claude Desktop, quit and reopen it) once the studio is up. stderr says which way it went: driving the claudius app at ... or a session of its own.
  • The studio runs on another port. Use the same --port in both.
  • The claudius runs with a token (claudius serve --token, or --public). claudius mcp then stops instead of starting a session of its own, and the client shows a server that failed to start: the claudius at http://127.0.0.1:7878 asks for a token: pass --token or set CLAUDIUS_TOKEN (a wrong one is refused too). Give it the token: CLAUDIUS_TOKEN in the client’s environment, or claudius mcp --token <token>.

An MCP client shows no claudius tools

  • The client was not restarted after claudius connect (or the studio’s Connect an assistant). Claude Desktop reads its configuration at start.
  • The Mac app was not in Applications. Opened from its disk image or from Downloads, it runs from a copy macOS takes away again, and connecting says so: claudius is running from <path>, <why>, which goes away: move Claudius into Applications, open it from there, and connect again.
  • The configured program is not there. connect writes the absolute path of the claudius that ran it. If you moved or reinstalled it elsewhere (say from ~/.cargo/bin to ~/.local/bin), run claudius connect again. A hand-written entry with plain "command": "claudius" fails in apps that do not see your shell’s PATH.
  • The configuration file is not valid JSON. connect refuses to touch it: <file> is not valid JSON; fix it or move it aside, then run this again.
  • An HTTP entry with nothing listening. --http entries ("url": "http://127.0.0.1:7878/mcp") need claudius studio or claudius serve running.

To see what the client sees, run the server by hand and give it an initialize request:

printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' | claudius mcp --no-ears

It should answer with a line starting {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":... and claudius’ instructions. The protocol is on stdout and everything else on stderr, which Claude Desktop writes to ~/Library/Logs/Claude/mcp-server-claudius.log.

Sample packs are missing

A script that asks for a drum machine or an instrument without packs fails with what to do:

Error: Runtime error: track 'd': no sample packs are installed for bank 'tr909' (install them with: claudius-packs install --defaults) (line 1, position 17)

and an unknown sound ends its list with sampled instruments need packs: claudius-packs install --defaults. In a session started with --synth-only the packs are installed but switched off, and the same mistakes say so: sampled instruments are off in this session (--synth-only) and bank '808' plays sample packs, which are off in this session (--synth-only).

A song made where packs were installed does not render where they are not, because its blueprint names the instruments it plays:

Error: no installed instrument "tr808:sh" (no packs installed: claudius-packs install --defaults)

Install the packs (claudius setup), or only the pack named before the colon (claudius-packs install tr808). Under --synth-only the hint reads the sample packs are off in this session instead. A file deleted from inside a pack fails with sample missing: <path> (reinstall the pack); installing the pack again downloads only what is missing.

Installs that fail say so per pack and resume when run again:

claudius-packs: vsco2: ...
Error: could not install: vsco2 (run again to resume)

claudius setup downloads the listening models even when a pack fails, so the critic has its ears either way; it ends with the error, and with how to retry.

Two causes need more than a retry. set GITHUB_TOKEN if the API rate limit was hit means GitHub’s 60 requests an hour without an account ran out: set GITHUB_TOKEN to any token. cannot extract <archive>: ... means the system’s tar could not unpack the tr909 zip or the organ’s .tar.xz; install bsdtar or GNU tar with xz support (and unzip).

ffmpeg is missing

claudius does not need ffmpeg to work. Without it:

  • songs have song.wav and no song.mp3, without an error;
  • the web studio is sent the WAV where it asks for MP3, which browsers play as well;
  • files in formats claudius cannot decode itself fail with cannot decode <file>: <reason> (installing ffmpeg adds more formats). It reads WAV, AIFF, FLAC, MP3, AAC and ALAC, CAF and Ogg Vorbis on its own.

claudius doctor shows .. ffmpeg not installed (optional: songs are saved as WAV only). claudius looks for it on PATH and then in /opt/homebrew/bin, /usr/local/bin and /usr/bin, since apps started from the Dock do not get your shell’s PATH. CLAUDIUS_FFMPEG=/path/to/ffmpeg names one, CLAUDIUS_FFMPEG=none turns it off.

Two files do not decode even with ffmpeg:

  • A file longer than 30 minutes (of stereo at 48 kHz): <file> is too long: claudius decodes up to 30 minutes of stereo at 48 kHz. Cut the part you want with any audio editor, or with ffmpeg: ffmpeg -i mix.flac -t 600 first-ten-minutes.flac.
  • A playlist or a list of files (.m3u8, an ffconcat list): ffmpeg is allowed to open only the file it is given, so it says Format not on whitelist. Point claudius at the audio file itself.

The studio window has no sound

The status bar at the bottom of the window shows audio problems after audio::

  • no audio output: ...: the window could not open the system’s default output device when it started. It opens the device once, so after you plug in headphones or change the output, restart the window.
  • could not fetch http://127.0.0.1:7878/api/audio/<n>.wav: ... or ... answered 404 Not Found: the render is not there. A session keeps only its last ten renders.

If there is no error, there may be nothing to play yet. The first render of a new song takes a few seconds (the toolbar shows the job in progress), and with --no-auto-render nothing renders until you press Render. Check the volume slider at the bottom right too.

In the browser, the first playback has to start from a click (browsers block sound a page starts on its own), and the page plays the MP3, or the WAV without ffmpeg.

If the window shows Cannot reach the claudius server at http://127.0.0.1:7878, the server it was opened for is gone; start claudius studio again. Use the demo switches to a built-in song that needs no server.

The studio window cannot open a claudius with a token

$ claudius studio --port 7922
Error: the claudius on port 7922 asks for a token, which the studio window cannot send: open http://127.0.0.1:7922/?token=<token> in a browser, or start another studio with --port

claudius studio opens on a claudius it finds on the port, and starts its own session when there is none. The native window cannot send a token, so a claudius started with --token or --public is for the browser: open the address in the message with your token in place of <token>. claudius studio --port 7900 starts a separate session instead, with a song of its own.

A song opens but does not render

Songs that play audio files (Apple Loops, chops, files from scripts) store their paths. On another computer, or after the files moved:

render failed: no such file: /Users/me/Samples/vox.wav

On a shared claudius, opening such a song fails earlier, with cannot open <folder>/blueprint.json: the song plays <path>, which is outside the folders this claudius may read, whether the file is missing or outside the shared folders.

A shared claudius refuses to save

/Users/<you>/Samples/mix is outside the folders this claudius may write

A claudius started with --public, --files, or --host with an address other than loopback saves only inside its folders: ~/Music/claudius (the default), its caches and the sound libraries, plus each --files folder. Save there, or start it with --files for the folder you want.

A script or a song hits a limit

Scripts run under limits so that a mistake cannot hang claudius; Errors and limits lists them all. The ones people meet:

messagewhat to change
the script ran for more than 10 secondsa loop that does too much; scripts are meant to describe a song, not compute one
Too many operationsthe same, counted in operations (5 million)
'*' takes a number above 0 and up to 256, not 100000a smaller factor; the same limit holds for /, fast, slow and ply
track 'x': this pattern is too dense to play: it makes more than 262144 eventsfewer speed-ups inside speed-ups: s("[bd*256]*256") is 65,536 hits a bar
track 'x': this pattern is too dense to play: it needs more than 4000000 steps of worka long sequence sped up a lot, such as a thousand rests and a bd played *256
track 'x' plays 2048 notes per bar; the limit is 1024fewer events per bar
the song is 640 bars long; the limit is 512shorter sections, or fewer of them
the song is 25 minutes long; the limit is 20the same at a slow tempo
the song has 70 tracks; the limit is 64fewer tracks; a pattern with several sounds plays as a track for each
24 tracks of 10.0 minutes are more than claudius renders at once (200 track-minutes, ...)fewer tracks, or a shorter form
mini-notation is limited to 4096 characters (this one has 6000)split the pattern into tracks

The HTTP API refuses commands over 1 MiB with 413 Payload Too Large. mutate tries at most 32 variations and evolve runs at most 20 generations of at most 32 children; larger numbers are lowered to those.

The session is busy

busy (rendering); try again when it is done, or cancel it

One job runs at a time: a render, a mutation, an evolution or a save. Edits still work while a job runs, and a render asked for during a job runs after it. A save asked for during a job waits for it too, and says so: saving to <folder>: blueprint.json and song.rhai now, the audio once the current job (rendering) is done. cancel (stop for assistants) ends a mutation or an evolution after the candidate or generation in progress, and a download of packs from the studio after the pack in progress; a render always finishes.

The analysis of a song is wrong

claudius analyze and remake read tempo, key and chords from the mixed audio, and get some songs wrong:

  • Half or double tempo. Pass --genre, whose tempo range guides the beat tracker.
  • The relative key. A minor song can read as its relative major (D minor as F major or B♭ major when the chords favour them); the chords are usually right, and named in the other key.
  • The progression starts on the wrong chord. The downbeat was placed a beat or a bar off, so the progression is rotated.

Analysis is best on steady electronic music with a clear kick. Evolution and analysis explains how it reads a song.

The disk is filling up

The cache folder (~/Library/Caches/claudius, ~/.cache/claudius on Linux) grows: decoded audio (about 23 MB per minute of every file claudius reads), warped loops, and each running session’s last ten renders (about 70 MB each for a four-minute song). Delete it while claudius is not running; Files and settings says what each folder holds. Sample packs and models are elsewhere and are not affected.

Something else

Run the command again with CLAUDIUS_DEBUG=1 for render timings, and report it at github.com/devlato/claudius/issues with the command, its output and claudius doctor. Security problems go to the private channel in SECURITY.md.

Architecture

claudius is a Cargo workspace of eight crates. One of them, claudius, is the engine and does all the music; the others decode audio, manage sample packs, listen, serve, draw and parse command lines around it.

The crates

                       claudius-cli  (the `claudius` binary)
                      /      |      \___________________________
                     /       |                                  \
          claudius-server    |                     claudius-ui (feature "studio")
           /     |     \     |                                  |
          /      |      \    |                                  |
 claudius-api  claudius (the engine)                      claudius-api
               /      |       \
              /       |        \
   claudius-packs   earshot   claudius-decode
          \           |
           \          |
          claudius-decode
cratewhat it isdepends on
claudiusthe engine: theory, composition, patterns and scripts, synthesis, effects, rendering, the critic, evolution, analysis, the Apple Loops libraryclaudius-packs, earshot, claudius-decode
claudius-decodeaudio files to samples: symphonia in pure Rust, ffmpeg as a fallback, limited to the one file and to 30 minutes; trims the encoder priming of AAC in CAFsymphonia
claudius-packsthe sample packs: registry, installer, SFZ and Strudel-map readers, name resolution, the multi-sample player, credits; also the claudius-packs binaryclaudius-decode
earshota Rust port of the Python earshot: CLAP and the AudioSet tagger through ONNX Runtime, the calibrated vocabulary, realness, measurementsclaudius-decode, ort
claudius-apithe wire types of the HTTP API: plain serde structs, so they build for WebAssemblyserde
claudius-serverthe session (App), the HTTP routes, the MCP toolsclaudius, claudius-api, claudius-packs
claudius-uithe studio, in egui: a native window and a WebAssembly app, both clients of the HTTP APIclaudius-api
claudius-clithe claudius binary: one subcommand per task, setup, doctor, connectall of the above

Two dependency rules hold the shape together. The engine knows nothing about HTTP, sessions or windows: it takes a blueprint and returns audio and a verdict. And the UI knows nothing about the engine: it depends only on claudius-api, so it compiles to wasm32-unknown-unknown without pulling in ONNX Runtime, rayon or file access. Everything the studio does, it does through the API, which keeps the API complete.

Outside crates/ are xtask/ (the repository’s build and release chores, run as cargo xtask) and fuzz/ (two cargo-fuzz targets, in a workspace of their own because they need nightly).

How a song flows

  genre profile (data/genres/*.json) + seed + options
                     │ compose::generate
                     ▼
                 Blueprint ◀─────── script::run          scripts, studio edits, MCP tools
                     │    ◀─────── evolve::mutate         mutation, crossover
                     │    ◀─────── analysis::song         remakes of audio files
                     │ compose::arrange
                     ▼
                  Project          tracks with notes or audio clips, chords, sections, bus automation
                     │ render::render
                     ▼
                 Rendered          master, stems, per-track levels and gains, LUFS, true peak
                     │ judge::judge (+ ears::Ears::report)
                     ▼
                  Verdict          scores, measures, issues that name what to change
  1. Generate. compose::generate makes every creative choice for a genre and a seed: tempo, key, two progressions, a form, drum patterns, the parts and their sounds, the mix. The result is a Blueprint: a song’s genome, small (tens of kilobytes of JSON), serialisable and diffable.
  2. Edit. Everything that changes a song changes its blueprint: a script, a step in the sequencer, a mutation, an undo. A blueprint is never patched in place by two parties; each edit produces a new one, and the session keeps the old one for undo.
  3. Arrange. compose::arrange expands a blueprint into a Project, the way a producer lays a session out: one loop of every part per progression tiled across its sections, drum variants per section, fills, builds, gaps, transitions, and filter automation on the buses. Arranging is a pure function of the blueprint and cheap next to rendering, so projects are never stored by the session; every song folder gets one in project.json (song::write_folder), for judge.
  4. Render. render::render plays every track through its sound, mixes them to the genre’s targets, runs the buses and returns, and masters the sum to the target loudness under a true-peak ceiling.
  5. Judge. judge::judge measures the master, the arrangement and the score, and, when earshot’s models are loaded, listens. Each issue carries a target (master, form, part:lead, band:sub+ …), which evolution uses to choose its next edits.

A blueprint also prints as a script (script::to_script) that runs back into the same blueprint, which is how the studio’s script tab, GET /api/script and get_song show a song. Patterns and the script engine explains how that round trip is kept.

One session, many clients

  studio window ──┐
  browser tab ────┤ HTTP /api ──┐
  curl ───────────┘             │
  assistant (HTTP) ─── /mcp ────┼──▶  App: one Session behind a Mutex
  Claude Desktop ─ stdio ─ claudius mcp ─(HTTP)─┘       │
                                                        ├─ version on a watch channel → long polls
                                                        └─ one job thread at a time

claudius-server holds one App per process. Its Session is the song with its undo and redo stacks, the latest render and verdict, a log, and the running job. Every client is a view of it: the native window and the browser long-poll GET /api/state?since=N; the MCP tools call the same App methods in process, or the same HTTP routes when claudius mcp drives a studio in another process. Because there is one copy of the song, a person in the studio and an assistant over MCP see each other’s edits within one long poll. 0005 One session, many clients records why, and Server, sessions and MCP how.

Threads

whatwhere it runs
the HTTP server and the MCP transporta multi-threaded tokio runtime; in claudius studio, on its own thread, since the window needs the main thread
a command (POST /api/command)tokio::task::spawn_blocking, because scripts and analysis take milliseconds to seconds
editsone at a time, serialised by the session’s edit mutex
long jobs (render, mutate, evolve, save)a plain std::thread, one job at a time; panics are caught and logged
loading earshot’s modelsa background thread at startup; verdicts include the ears once it finishes
a renderrayon: every track in parallel, then the buses in parallel, the two returns in parallel, and each channel of the soft clipper and true-peak meter
evolutionrayon: the children of a generation are rendered and judged in parallel
synthesispolyphonic notes of a synth track render in parallel
pack downloadsscoped threads, 8 by default
earshoteach ONNX session behind a mutex, with EARSHOT_THREADS intra-op threads
the studio’s networkehttp: a thread per request natively, fetch in the browser; replies come back on a channel

The session’s lock is held only to read or swap state, never across a render or a script, so GET /api/state answers while a job runs. Long polls wait on a tokio::sync::watch channel that every state change bumps.

Determinism

The same blueprint renders the same song, and the same genre, seed and options generate the same blueprint, given the same installed sounds. The pieces that make that so:

  • One random generator, many streams. rng::Rng is xoshiro256++ seeded through splitmix64. Rng::fork(label) derives an independent stream from the current state and the label without advancing it, so generate gives harmony, form, drums, the kit, parts, loops, transitions and the title a stream each: writing one more part does not change the drums.
  • Seeds stored where they are used. Every part has its own seed in the blueprint, and its notes come from seed ^ hash(progression name). Changing a part’s seed rewrites that part alone; changing another part’s style leaves it as it was.
  • Pattern randomness is a hash, not a stream. ?, |, degrade and sometimes decide each event from a hash of a seed and the event’s start time. A pattern gives the same events whichever window you query and in whatever order, which is what lets a track be queried section by section (tests/pattern_laws.rs checks it).
  • Render seeds from names. Each track’s humanisation, each effect’s noise and each reverb’s impulse response are seeded with project seed ^ hash(track or bus name).
  • Hashes. rng::hash_str (64-bit FNV-1a) turns names into seeds and lets evolution skip blueprints it has seen; blake3 names cache files.

What is not deterministic: a seed taken from the clock when none is given (always printed), the sounds that resolve differently on machines with different packs or Apple Loops, and floating-point results across CPUs, which is why the generator snapshots summarise blueprints instead of comparing audio.

Streams have one trap: an option that skipped a random draw would shift every draw after it. So generate draws a tempo and a tonic whether or not they are given, and a bpm or key of your own replaces only the result. song("house", #{ seed: 9, bpm: 131 }) is “Crimson Tide” at 131 BPM, with the form, progressions and drums of song("house", 9); a_tempo_or_key_of_your_own_changes_only_that in tests/script.rs holds the title, the form, progression A and every drum pattern to that. A key brings its mode too, and progression B is derived in the key, so it can differ when the mode does: seed 9 asked for E minor instead of its own G dorian turns B from i7 VIImaj7 i7 IV7 into i7 VII7 i7 IV7, while A keeps its numerals. A song printed as a script still starts from song("house", 9); the tempo() and key() lines after it set the rest.

Errors

Engine functions return anyhow::Result or Result<_, String> with messages written for the person reading them (unknown sound 'bdd' (drums: ...; synths: ...)). The server passes them through unchanged as Reply.message, MCP tool errors and log lines, and the CLI prints them after Error:. A failed edit leaves the song as it was: scripts run against a copy, and the session swaps the copy in only when the script succeeds.

Where to go next

A tour of the repository walks the code in reading order. The other engineering pages take one stage each: the song model, composition and theory, patterns, rendering, the critic, evolution and analysis, sample packs, the server, the studio, testing and security.

The song model

claudius works with two documents, both in crates/claudius/src/model.rs:

  • a Blueprint is a song’s genome: the choices a producer makes (tempo, key, progressions, form, drum patterns, how each part is written, every sound, the mix), stored as data small enough to read, diff and mutate;
  • a Project is the arranged timeline a blueprint expands into, like a DAW session: tracks with notes or audio clips, a chord track, sections, and effect chains with automation.

Everything that edits a song edits its blueprint. The project is derived on demand by compose::arrange and thrown away after rendering. 0003 The blueprint is the song records why.

Units, everywhere: time in beats from the start of the song (4 beats to a bar, no other metre), pitch in MIDI semitones (60 is middle C), velocity from 0 to 1, levels in dB, sample rate 48 kHz.

The blueprint

This is the head of a real one, house with seed 9 (claudius make --genre house --seed 9 writes it to blueprint.json):

{"version": 1, "title": "Crimson Tide", "genre": "house", "seed": 9, "bpm": 124.0,
 "key": {"tonic": 7, "mode": "dorian"}, "swing": 0.5257842, "humanize": 0.25,
 "harmony": {"progressions": {"A": ["i7", "ii7", "i7", "IV7"], "B": ["i7", "VIImaj7", "i7", "IV7"]},
             "bars_per_chord": 1},
 "form": [{"name": "intro", "kind": "intro", "bars": 8, "energy": 0.4, "prog": "A",
           "layers": ["kick", "hat", "perc", "top"], "variant": "sparse",
           "filter_in": true, "build": false, "gap": false, "impact": false, "fills": false}, ...],
 "drums": [...], "parts": [...], "loops": [], "patterns": [], "transitions": {...}, "mix": {...},
 "lineage": ["generated: house seed 9"]}
fieldmeaning
versionthe format, BLUEPRINT_FORMAT (1): claudius opens this and every earlier format, and asks for an update to open a newer one (below)
title, genre, seedthe genre names a profile in data/genres/; the seed is the one it was generated with
bpm, key, swing, humanizekey is a tonic pitch class (C = 0) and a mode; swing 0.5 is straight, about 0.66 a triplet feel; humanize 0 to 1 loosens timing and velocity
harmonynamed progressions of Roman numerals and how many bars each chord lasts
formthe sections, in order
drumsdrum lanes with step patterns
partsmelodic parts written by the theory engine
loopsrecorded loops (Apple Loops, flipped samples)
patternstracks written as pattern code by scripts
transitionsthe crash, riser, impact and their switches
mixthe reverb and delay returns, bus chains, sidechain release, per-role level offsets, the master
lineagewhat was done to the song, one line per change, oldest first

Harmony

Progressions are Roman numerals in the key’s own mode (VI in A minor is F major), so changing the key moves every chord with it, and key() in a script re-spells diatonic numerals when the mode changes (Imaj7 in E major becomes i7 in F minor). Generated songs have two, A and B; sections play A unless they say otherwise, and breaks play B. Scripts may add more names. Composition and theory covers the numeral grammar.

Form

Each SectionSpec is a block on the arrangement:

fieldmeaning
nameunique in the song (drop, drop2)
kindintro, verse, build, drop, break, hook, groove or outro
bars, energylength, and how intense it should be (0 to 1); the critic compares energy with loudness
progwhich progression it plays
layersthe names of the drum lanes, parts, loops and pattern tracks that play in it
variantwhich drum pattern variant its lanes play (main, sparse, alt, half, …)
filter_ina lowpass opens across the section
builda snare roll, a riser and a highpass sweep into the next section, the kick out for its last bar
gapa beat of silence before the next section
impacta crash and an impact on its downbeat
fillsdrum fills at the end of every 8 bars

Layers are names, not references: a section may list a layer that no track has (a genre’s optional top loop when the library had none), and it plays nothing.

Drum lanes

A DrumLane has a sound, a pattern_bars length (4 in generated songs), and patterns: a map from variant name to hits, each hit a position in beats within the pattern and a velocity. Variants of one lane all have the same length; when a script sets a longer one, the others are repeated up to it. pitch transposes the lane, choke names a group in which a new hit cuts the previous one (the open hat stops when the closed hat plays), and mix is the lane’s TrackMix.

Parts

A Part stores how to write notes, not the notes:

fieldmeaning
rolebass, chords, pad, lead, arp or keys: which writer runs, and its target level
stylethe writer’s style: a bass style (offbeat, rolling, kick_follow, …), a chord rhythm (stabs, pump, charleston, …), an arpeggio pattern, or motif for leads
registerthe lowest and highest MIDI note it may use
densityhow busy a lead is (0 to 1)
voices, voicinghow many notes a chord has and how it spreads (close, open, spread)
ratean arpeggio’s step in beats
seedthe part’s own random seed
notesnotes written by hand or by analysis, per progression, which replace the writer
sound, mixits sound and mix

Storing the recipe makes the part small, readable as a script line (part("bass", #{ style: "syncopated", preset: "saw_bass", register: [33, 52], density: 0.42195085, seed: ... })), and editable at the level a producer thinks at: a new seed is a new bass line in the same style, a new key moves the same line. The writer is deterministic, so the recipe is enough to get the same notes back.

Loops and pattern tracks

A LoopPart plays a recorded loop (Sound::Loop with its tempo, length in beats and key): transpose in semitones, and optionally chop, a slice order over slice_beats-long slices, for flipped samples.

A PatternTrack is a track a script wrote: code is the pattern expression exactly as written (s("bd*4, [~ cp]*2")), sounds maps each sound label in it to the Sound it resolved to when the track was written, and role is set when the pattern plays one sound. The code is the source of truth: the project re-evaluates it at every arrangement, which is what lets a song print back as the script that made it. Storing the resolved sounds keeps a song’s sounds stable if, say, another sample pack is installed later.

Sounds

Sound is an enum, and every variant is audio:

variantwhat plays
drum { voice, params }a synthesized drum voice with nine parameters (tune, decay, tone, punch, snap, drive, pitch envelope and time, room)
synth { preset, patch }the synth engine with a full copy of a preset’s patch, possibly varied or mutated
sample { path, root, start, end, label }a recorded one-shot played like a sampler; root is the note that plays it as recorded
fx { fx }a transition effect rendered in code: riser, downlifter, impact, reverse_crash, sweep_up, sweep_down
instrument { name, label }a multi-sampled instrument from a pack, by its canonical pack:instrument name; label is what it was asked for by
loop { path, bpm, beats, key, tonal, label }a recorded loop, warped to the song’s tempo and, when tonal, transposed to its key

There is no variant for General MIDI or generated audio, which is the point of 0001 Real audio only. Blueprint::files() lists every file path a song plays, which the server’s file policy checks when a blueprint is opened.

The mix

Every track has a TrackMix:

fieldmeaning
level_dbdB relative to the target loudness of the track’s role, not an absolute fader
pan−1 to 1
fxinsert effects
reverb, delaysend levels to the two returns, 0 to 1
ducksidechain depth from the kick, 0 to 1
busdrums, bass, music or fx
mute, solowhile any track is soloed, only soloed tracks are arranged

The song’s MixPlan holds the reverb and delay effects of the returns, an insert chain per bus, duck_release (the sidechain recovery, in beats), levels (a dB offset per role) and the master (fx, a lufs target and a ceiling_db, −1 by default). Effects are plain data too; Rendering, mixing and mastering lists them.

Size limits

A song is at most MAX_BARS (512) bars and MAX_SECONDS (20 minutes) long, and has at most MAX_TRACKS (64) tracks and MAX_TRACK_SECONDS (12,000, such as 20 tracks of 10 minutes), because a render holds every track in memory at 48 kHz, 384 KB a second. Blueprint::check_size enforces it after every script and edit, and render::render refuses a bigger project; it counts the arranged tracks, where a pattern with several sounds is a track for each.

The project

compose::arrange (in compose/arrange.rs, with cycle_spans and part_cycle) turns a blueprint into a Project:

fieldmeaning
title, genre, bpm, key, swing, humanize, seedcopied from the blueprint
barsthe total length
sectionsname, kind, start_bar, bars, energy per section
chordsthe chord track: one ChordSpan (start, length, chord) per chord, across the whole song
tracksthe tracks, each with a role, a sound, notes or clips, a transpose, a chop, a choke group and a mix
mixthe blueprint’s mix plan, with the arrangement’s automation and fixed inserts added to the buses

A Note is {t, d, p, v}: start and duration in beats, pitch, velocity. A Clip places a loop: start and duration in song beats, an offset into the warped loop, a gain and fades. These are the first three notes of the kick track in the song above:

{"name": "kick", "role": "kick", "sound": {"kind": "drum", "voice": "kick", "params": {...}},
 "notes": [{"t": 0.0, "d": 0.25, "p": 60, "v": 0.9350958}, {"t": 1.0, "d": 0.25, "p": 60, "v": 0.93947273},
           {"t": 2.0, "d": 0.25, "p": 60, "v": 0.9417365}], "clips": [], ...}

What arranging does

For every section, arrange:

  • drums: tiles the lane’s pattern for the section’s variant (or main) across the section. In a build, the snare (or clap) lane turns into a roll over the last half of the section (at least one bar, at most four), speeding up from quarters to thirty-seconds, and the kick drops out for the last bar. With fills, the end of every 8-bar phrase gets a fill of one or two beats on the snare lane (roll, groove or triplets), and the kick sometimes skips the last half beat.
  • parts: writes one cycle of each part per progression, once, and tiles it across every section that plays the part with that progression, so a hook comes back the same each time. A lead’s cycle is at least 16 beats.
  • loops: one clip per section the loop plays in.
  • pattern tracks: evaluates the code and places each section’s notes from the pattern’s first cycle, one bar per cycle. A pattern with several sounds becomes one track per sound, named <track>.<sound> (drums.bd), each mixed by its own role.
  • transitions: a crash on every impact downbeat and every 8 bars of a drop or hook, impacts and downlifters on impact sections, a riser of up to riser_bars over a build, a reverse crash into the next impact.

Then, across the song, it drops muted tracks (and, when any track is soloed, every track that is not), cuts a beat of silence before every section with gap (risers and reverse crashes play through), and adds automation to the music and bass buses: a lowpass that opens from 350 Hz across filter_in sections and closes towards 500 Hz over the last 60 % of a final outro, and a highpass that sweeps from 10 Hz to 900 Hz across each build. Finally it adds three inserts every song gets unless its genre already has them: width 1.25 with mono below 160 Hz on music, mono below 140 Hz on bass, and light tanh saturation on drums.

Arranging is deterministic: fills draw from seed ^ hash(lane) ^ section index, parts from their own seeds.

Slices

Project::slice(from_bar, to_bar) cuts a project down to a range of bars, shifting notes, clips, chord spans, sections, and the automation curves and time-based effects (tape stop, stutter, reverse) of tracks and buses. Evolution auditions candidates on slices, and the tests render slices to stay fast.

Saving and loading

A blueprint’s JSON is its save format: blueprint.json in every song folder, and what open and claudius render read. Most fields have serde defaults (no loops, no pattern tracks, an empty lineage, a main variant), so a file that leaves them out still loads. A pattern track whose code no longer evaluates is skipped when the song is arranged, without an error. Scripts check a pattern when they add it, so that takes a hand-edited blueprint, or one saved by a version of claudius that accepted more (a pattern with a speed-up above 256 from before that limit). song::write_folder writes a project to project.json in every song folder only so that claudius judge can check the arrangement and theory of a rendered file; nothing reads it back as a song.

Saved songs keep opening

Every claudius opens the songs earlier ones saved. Blueprint::from_json is the one way a blueprint.json is read (open in the studio and the MCP tools, claudius render, --on, --from). It refuses a format newer than BLUEPRINT_FORMAT with a newer claudius wrote it, in blueprint format 2, and this one reads up to format 1: update claudius to open it, and reads the rest.

crates/claudius/tests/compat/v<version>/ holds what each release saved: a song in each genre and one edited by a script (<name>.json), with the song.rhai printed for each (<name>.rhai). tests/compat.rs opens every one, checks it the way loading does, arranges it, and runs each script on its song; a field added without a default fails it, naming the file and the field. The folders of 0.1.0 and 0.2.0 came from those releases’ own programs. The release PR adds its version’s (cargo xtask release prepare runs examples/compat_fixtures.rs), so each release is held to every one before it.

A change the songs cannot take as they are (a field that changes meaning or goes away) raises BLUEPRINT_FORMAT, and from_json gets the step that upgrades the JSON of the format before it; the fixtures of the earlier formats then test that step.

Composition and theory

Composition happens in two places. compose::generate makes the creative choices and writes them into a blueprint: which progressions, which form, which drum patterns, which style and seed for each part. compose::arrange turns the stored choices into notes, calling the writers in theory/ for every part. Both are deterministic, so a blueprint is all it takes to get the same notes back.

modulewhat it does
genres.rsloads the genre profiles from data/genres/*.json and your genres folder
compose.rsgenerate, new_part, styles_for, part_sound, kits, flipped samples (add_sample)
compose/arrange.rsarrange, cycle_spans, part_cycle
theory/scale.rspitch classes, the seven modes, keys, scale degrees
theory/chord.rschord qualities and symbols, Roman numerals, voicing
theory/progression.rsthe progression library, the Markov generator, substitutions
theory/rhythm.rsdrum hits, lane templates, euclidean rhythms, rolls, fills, swing, syncopation
theory/bass.rsbass lines in eleven styles
theory/melody.rsleads, arpeggios and chord parts

Genres are data

Everything a genre knows is in one JSON file. The fields generate reads:

fieldwhat it decides
bpm, swing, humanizeranges to draw from, and the looseness
modesweighted modes for Markov progressions
harmony, markov, extweighted progression styles from the library, the chance of a Markov progression instead, and its chord extensions (0 triads, 1 sevenths, 2 ninths)
bars_per_chordweighted chord lengths
formsone or more forms, each a list of sections with layers, energy and flags
drumslanes: a voice, a step template or a euclidean rhythm, named variants, parameter ranges, Apple Loops subtypes for one-shots, a choke group, whether to tune the kick to the key, a mix
kitsweighted sample-pack drum kits; the weight left over keeps the lanes’ own sounds
partsmelodic parts: a role, weighted-by-repetition lists of presets and styles, a register, a density range, voices, voicing, arpeggio rates, a chance of leaving the part out, a mix
loopsApple Loops parts: instrument, subtype, genre and mood filters, whether tonal
transitionsriser, impact, downlifter and reverse crash switches, the riser length
mixthe LUFS target, the reverb and delay, bus and master chains, sidechain release, per-role level offsets
query, altswhat the ears should hear ("house music") and the genres it is most often confused with
accepted_flawsproduction flaws of the ears’ that are the genre’s sound, left out of its production reading (lofi’s hiss)

A list like "styles": ["offbeat", "syncopated", "offbeat"] weights by repetition: offbeat is picked two times in three. A preset entry can name alternatives separated by |: "piano|epiano" plays the sampled piano when its pack is installed and the epiano synth otherwise. part_sound explains why that does not change the rest of the song.

How generate writes a song

generate_inner draws, in order, from forks of one generator seeded with the song’s seed (see Determinism):

  1. the tempo and the swing, from the genre’s ranges; the tempo is drawn even when one is given, which then replaces the result, so that a tempo of your own changes nothing else;
  2. harmony: with chance markov (or when the genre lists no styles), a Markov progression of 4 chords (2, one time in five) in one of the genre’s modes; otherwise a progression from a library style, in that style’s mode. The tonic is drawn the same way, always, and a key of your own replaces the tonic and the mode. Progression B is another progression of the same style half the time, else a substitution of A worked out in the style’s mode. When a key of your own has another mode, both progressions then move to it the way key() moves them: diatonic chords keep their degree and take the new mode’s quality, borrowed chords keep their sound spelled in the new mode, and applied chords stay as written. Chord length comes from bars_per_chord, and a one-chord progression lasts 4 bars;
  3. form: one of the genre’s forms, every section halved (to at least 4 bars) with --short;
  4. drums: per lane, a one-shot from the library (with chance samples, 0.75 unless --samples or a script says otherwise, the same for make, scripts and the studio’s new songs, when there is a library) or a synthesized voice with parameters in the genre’s ranges (or a third of the way from the voice’s default towards a random point), the kick tuned to the key; then a 4-bar pattern per variant from the lane’s template or euclidean rhythm; then, when a kit was drawn, the kit’s drum for the lane;
  5. parts: per part, unless its optional chance leaves it out, a preset, a style, a density and a seed of its own. A chords, keys or pad part on a single-voice synth patch gets a light chorus so it does not sit dead centre;
  6. loops from the library, when there is one;
  7. transitions: a crash (recorded or synthesized) and the genre’s switches;
  8. the mix from the genre, and a title from two word lists.

With --loops (a loop family), the song is generated with the family’s tempo and key, both progressions become the chords read from the family’s harmony loop, one chord per bar, and the family’s chord, bass and beat loops replace the parts and lanes of those names.

Keys and modes

A Key is a tonic pitch class (C = 0) and one of seven modes: major, minor, dorian, phrygian, lydian, mixolydian and harmonic minor. Key::parse reads "Am", "A minor", "F# dorian", "Eb", "C#m". A key spells with flats when its relative major sits on the flat side of the circle of fifths (Bb major, D minor), with sharps otherwise.

The functions the writers lean on: degree_pitch(d, base) (the pitch of scale degree d counted from the tonic at or above base), snap (the nearest scale tone, ties going down), step (move by scale steps), index_of and from_index (between pitches and diatonic indices), and transpose_from (the shortest way between two tonics, −6 to +5 semitones), used for loops and key changes.

Chords

Chord is a root, one of 20 qualities (triads, sus2 and sus4, sevenths, sixths, add9, ninths, minor 11th, power chords) and an optional bass note. Chord::parse reads symbols (Am7, F/A, Cmaj7, Bbm9, Dsus4, E5, and Δ, ø, ° spellings), and symbol prints them back.

Roman numerals

chord::roman(numeral, key) reads a numeral in the key’s own mode, which is why progressions survive a change of key:

  • the degree is counted in the key’s mode: VI in A minor is F, IV in D dorian is G;
  • case gives the quality: VI major, vi minor; a suffix overrides it (7 dominant on upper case and minor seventh on lower case, maj7, 9, 6, add9, °, ø7, °7, +, sus4, 11);
  • b and # before the numeral move the root: bVII, bII;
  • / and a numeral makes an applied chord, read in the key of the target: V7/V in C is D7;
  • / and a note name sets the bass: I/E.

roman_of(chord, key) goes back, choosing b or # for roots outside the scale. A script’s prog("A", "Am7 Fmaj7") stores chord names as numerals this way.

Voicing

chord::voice picks the actual pitches of a chord inside a register, for n voices, given the previous voicing:

  1. With more chord tones than voices, it drops the fifth, then the root (the bass has it). With fewer, it tries doubling the root, then the fifth, then the third, each try costing a little more.
  2. It enumerates every way to place the tones inside the register, and scores each: distance from the span the style wants (close 3 to 14 semitones, open 10 to 24, spread 17 to 34); a penalty for intervals under a fourth below E3 (MIDI 52), where they turn to mud, and for semitone clashes below E4; and the voice-leading distance from the previous chord (the sum of each voice’s movement), or, for the first chord, the distance from the middle of the register.
  3. It takes the best. When the second best is within one point, it takes the second three times in ten, so repeated progressions do not voice identically every time.

The critic checks the result: voice_leading_<track> is the average movement per voice per chord change, and above 4.5 semitones it raises an issue.

Progressions

The library in progression::STYLES has eleven styles, each a mode and a handful of progressions: minor_epic, minor_dark, minor_soul, dorian_vamp, phrygian, harmonic, minimal, major_pop, major7_jazz, minor_80s and mixolydian. For example minor_soul holds i7 iv7 VII7 IIImaj7 and iiø7 V7 i7 i7; genres weight the styles they use (house: minor_soul 0.35, minor_epic 0.25, dorian_vamp and major7_jazz 0.2 each).

The Markov generator walks a 7 by 7 table of transition weights between scale degrees, one for major and one for minor keys, written by hand around functional harmony: tonic to subdominant to dominant to tonic, plus the stepwise and third moves pop and EDM use. A progression starts on the tonic (three times in four) or the sixth degree, never repeats a chord immediately (its weight is cut to a tenth), and is accepted only when its last chord leads back to its first with a weight of at least 1; otherwise it tries again, up to 64 times. The chords are the diatonic chords of those degrees, extended to sevenths or ninths by the genre’s ext (a major chord whose seventh is minor becomes a dominant seventh).

Substitutions change one chord in a way a musician would, and say what they did, for the song’s history (the examples are on i VI iv VII in A minor):

substitutionexample
a chord a third away (up a sixth or up a third, in the scale)chord 2: VI -> iv (third relation)
richer or plainer: a triad gains a seventh or ninth, an extended chord loses itchord 1: i -> i7 (extended)
the chord before becomes the secondary dominant of this onechord 3: iv -> IV7 (secondary dominant of VII)
modal interchange: the same root with the opposite thirdchord 4: VII -> vii (borrowed from the parallel mode)
a suspension (sus2 or sus4), except on diminished chordschord 2: VI -> VIsus4 (suspension)
swap two neighbouring chordsswapped chords 2 and 3
rotate the progressionrotated the progression by 2

generate uses them to derive progression B from A, and evolution’s harmony_sub operator applies one at a time.

Rhythm

Lane templates. A drum lane’s habits are a LaneTemplate: a hit probability for every sixteenth (16 per bar, 32 for two bars), where 1 or more always hits; a velocity range; ghost, the chance of a soft extra hit on an empty step; rolls, the chance per beat of a trap roll; and vary, how often the last bar of a phrase deviates. generate samples one bar from it, repeats that bar over the 4-bar pattern, and sometimes swaps the last bar for a varied one (probabilities raised by 1.6 and halved at random), so the pattern loops like a programmed beat instead of shuffling every bar. Velocities follow the metrical weight of the step: the downbeat strongest, then beat 3, then beats 2 and 4, then the eighths, then the rest.

The house hat template reads like this, a pattern a person would program:

{"steps": [0.7, 0.3, 0.25, 0.35, 0.7, 0.3, 0.25, 0.35, 0.7, 0.3, 0.25, 0.35, 0.7, 0.3, 0.25, 0.5],
 "vel": [0.35, 0.8], "vary": 0.2}

Trap rolls replace a whole beat with a burst of thirty-seconds, sixteenth triplets, a half beat of thirty-seconds, or thirty-second triplets, with a velocity ramp up (six times in ten) or down. They fall mostly on the last beat of a bar and rarely on the downbeat.

Euclidean rhythms spread k onsets over n steps as evenly as possible (rhythm::euclid, computed with the (i * k) % n < k formula, which gives the same patterns as Bjorklund’s algorithm up to rotation). A lane with "euclid": [5, 16] gets a random rotation and a sparse variant with half the onsets.

Builds and fills. snare_roll plays quarters, then eighths, sixteenths and thirty-seconds, rising in velocity. fill writes the last one or two beats of a phrase as a roll (sixteenths rising), triplets, or a groove of random sixteenths.

Swing delays every off-beat sixteenth: within each eighth, the second sixteenth moves from halfway to swing of the way through (0.5 is straight, 0.66 a triplet feel). It is applied when notes are rendered, so the blueprint stays on the grid.

Measures for the critic and analysis: syncopation (Longuet-Higgins and Lee: an onset on a weak position followed by silence on a stronger one scores the difference in metrical level) and density (onsets per beat).

Bass lines

bass::bassline writes a one-bar motif once per part and transposes it onto every chord, which is how a producer writes a bass line: one groove, followed through the changes. The motif’s intervals are relative to the chord’s bass note, and some are resolved against the sounding chord (its third, its fifth, chord tones for walking lines, a chromatic approach to the next root), so the line fits every chord. The root of each chord is the one nearest the previous root, unless that strays more than 7 semitones from the middle of the part’s register.

stylethe motif
sustain, reesethe root held for the whole chord
pulsequarter notes
root8eighths on the root, sometimes an octave jump at the end
octaveeighths alternating root and octave
offbeatthe off-beat eighths of house, sometimes a pickup on the last sixteenth
rollingthe three sixteenths after each beat, around the kick
syncopatedone of five sixteenth grooves, with octaves, fifths and thirds
kick_followa note on every kick of the kick lane’s main pattern, held to the next, with occasional octave or fifth jumps and slides: the trap 808
acidsixteenths on a pentatonic, with rests, accents and slides, snapped to the key
walkingroot, two chord tones and a chromatic approach to the next root

A style the writer does not know plays sustain.

Leads, arpeggios and chord parts

Leads (melody::lead) are built like a hook: a one-bar motif played in an A A’ A B phrase every four bars.

  1. The rhythm: 2 to 9 onsets in a bar, depending on density, drawn with weights that favour the strong sixteenths (or, for a syncopated lead, the off-beat ones), the first always on the downbeat.
  2. The contour: steps in the scale, in one of four shapes (an arch, falling, rising, zigzag), with a leap of a third or fourth now and then, always followed by a step back the other way, and kept within a range of 12 scale steps.
  3. The phrase: bar 2 (A’) repeats the motif two steps higher (one step lower in the second half of an 8-bar phrase), bar 4 (B) is a sparser answer whose last note is held, landing on the motif’s home note (the middle of the register, snapped to the scale) at the end of 8 bars and two steps above it otherwise.
  4. Each note is snapped to the key, and notes on strong beats (beats 1 and 3, or a note held a beat or more) move to the nearest chord tone of the chord sounding then. Notes outside the register are folded back in by octaves.

Arpeggios (melody::arp) voice each chord in close position at the bottom of the register, spread it over two octaves, and walk it up, down, updown, random or pinky (the top note alternating with the others), one note every rate beats.

Chord parts (melody::chord_part) voice every chord with voice leading and play it in a rhythm: sustain, half, stabs, offbeat, pump (eighths), syncopated or charleston.

Sounds for parts and drums

compose::part_sound(entry, rng) turns a preset entry into a sound. For "piano|epiano" it plays the first alternative that exists (a synth preset, or an instrument an installed pack answers to), and falls back to the pluck synth. It always draws the variation of the last synth alternative first, whether or not that is the sound it returns, and varies any other synth from a fork of the generator; instruments draw nothing. So the generator’s streams advance the same way with or without sample packs, and installing packs changes the sounds of a song without changing its notes. (A synth patch is varied by moving its filter cutoff by a factor of 0.8 to 1.25 and its detune by 0.85 to 1.2.)

Drum kits work the same way: the kit is drawn from a fork, and kit_sound maps lanes to the kit’s drums (kick to bd, hat to hh, perc to rim, …), so a missing kit leaves the lane’s synthesized voice in place. Apple Loops one-shots follow the same rule: each lane draws the chance of using one, and picks it, from a fork of its own (dr.fork(lane)), and draws that chance whether or not there is a library. The drum stream that writes the patterns advances the same way either way, so a seed gives the same drum patterns on every machine, and Apple Loops change only which sounds play them. The generator snapshot tests still run without a library, so that they describe the generator and not what is installed.

A kick with tune_to_key has its fundamental moved to the tonic or the fifth of the key, whichever lies nearest its drawn tuning within the lane’s range, so the kick and the bass agree.

Patterns and the script engine

Scripts are Rhai with two families of functions: patterns in Strudel-style mini-notation, and song edits. This page is about how they work inside: what a pattern is and how it is queried, the budgets that keep a pattern from running away, the sandbox, and how a song prints as a script that runs back into itself. The language reference describes what they do for the person writing them.

filewhat it holds
pattern/mod.rsthe pattern tree (Node), queries, the work budget, Pattern with its code
pattern/mini.rsthe mini-notation tokenizer and parser, and its limits
pattern/notes.rsfrom pattern events to notes: sounds, note names, scale degrees, chords and voicing
script/mod.rsthe engine: run, eval_pattern, the sandbox limits
script/patterns.rsthe pattern functions and methods registered with Rhai
script/song.rsstarting a song (song, blank), its settings, prog, bars_per_chord, drum and track
script/mix.rspart, drum_sound, mix, mute and solo, remove, master
script/form.rssec, form, section, layer, and the checks on sections
script/sounds.rssound names: drum voices, presets, pack instruments, files, and the role each gets
script/steps.rsdrum step strings, both ways
script/print.rsto_script: a song as a script

A pattern is a tree

mini::parse turns a string into a Node, and methods wrap nodes in more nodes:

nodemeaning
Silence, Atom(value)nothing; one event per cycle with a word or a number
Seq([(node, weight)])each cycle divided among the children by weight (a b, a@3 b)
Stack([node])children at once (a, b)
Alt([node])one child per cycle, in turn (<a b c>)
Fast(node, factor), Late(node, cycles), Rev(node)time scaled, shifted, reversed within each cycle
Every(n, offset, node, other)other on cycles where cycle % n == offset (every, lastOf)
Degrade(node, p, seed), Choose([node], seed)events dropped at random (?, degradeBy); one child picked at random per cycle (a | b)
Ply(node, k)every event split into k
Struct(values, structure), Mask(node, mask)timing from one pattern and values from another; events kept where the mask is true
Ctrl(node, key, param)a per-event control (gain, velocity, legato, add, transpose, sample index) read from another pattern
Sometimes(node, other, p, seed), Keep(node, p, seed)the events a coin leaves alone, plus other, the transform of the events it picked; Keep is what hands the transform only the picked events

A Pattern is a node behind an Arc, plus its Kind (what its values mean: sounds, notes, scale degrees, chords or plain values), the track settings set on it (.s(), .bank(), .room() …) and repr, the script code that built it. Patterns are immutable: every method returns a new one.

Queries

query(node, b, e, out) appends the events of node whose onset lies in [b, e), in cycles, one cycle being one bar. An event (Ev) is a whole span [b, e), a value and its controls. The nodes compose by transforming the query window on the way down and the events on the way back up:

  • Seq visits each cycle the window touches, gives each child its slice of the cycle, queries the child in the child’s own time (scaled so that the slice is one cycle), and maps the events back.
  • Alt with k children sends cycle c to child c % k, as that child’s cycle c / k. So <a <b c>> plays a b a c: the inner alternation advances only on its own turns.
  • Fast(f) queries [b·f, e·f) and divides the events’ times by f; Late(t) queries [b − t, e − t) and adds t.
  • Rev queries each cycle whole and reflects every event inside it, so an event that ended at the end of the cycle starts at its beginning.
  • Struct takes the true events of the structure and, for each, the value the values pattern has sounding at that onset (sample_at, the last event whose span contains it). Mask and Ctrl read the other pattern the same way.
  • sometimes(f) builds Sometimes(node, f(Keep(node)), p, seed). Keep passes the events whose hash is below p; Sometimes returns the original events whose hash is at least p and every event of the transformed copy. A transform that keeps time (gain, add, ply) therefore gets exactly the events the original side dropped. One that moves time (fast, late) asks Keep in its own time, so its picks follow that time instead.

Two properties follow, and tests/pattern_laws.rs checks them with generated patterns:

  • Windows do not matter. Querying [0, 1), [1, 1.5) and [1.5, 3) gives the same events as querying [0, 3). Nothing is cut at a window edge, because only onsets count; an event that started before the window is not returned at all. This is where claudius differs from Strudel, which returns fragments of every event overlapping the window (Patterns and time shows where you hear the difference).
  • Randomness is stable. Degrade, Choose, Sometimes and Keep decide with hash01(seed, onset), a hash of the event’s onset time and a seed, instead of a random stream, so the same pattern gives the same events in any window, in any order, every time. Seeds come from the code: the parser seeds a small generator with a hash of the mini-notation string, and sometimes and degrade hash the pattern’s repr. Two tracks with identical code make identical choices; change one character and they differ.

Pattern::try_events(b, e) runs a query and sorts the events by onset. Each track is placed section by section with notes::place, which queries cycles 0 to the section’s length and puts them at the section’s start: every section plays its patterns from their first cycle.

The work budget

A query is recursive, and a few characters of mini-notation could ask for a lot. Three limits keep it in reach, each the same on every machine and in every test run, since none of them is a time:

  • Speed. *, /, fast, slow and ply take a factor above 0 and up to MAX_SPEED (256), checked when the pattern is built, so s("bd*100000000") fails while it parses: '*' takes a number above 0 and up to 256, not 100000000.
  • Work. Every call to query, every cycle a node visits and every sub-event Ply makes spends one unit of a thread-local budget, MAX_WORK (4,000,000) per try_events call. A long sequence sped up a lot runs it out, such as a thousand rests and a bd played *256.
  • Events. Every event a query makes (an atom’s, a Ply copy, a Struct value) counts against a second budget, MAX_EVENTS (262,144), and running out also ends the work. This is the one that bounds memory, since the events gathered are kept until the query returns: speed-ups inside speed-ups, such as s("[bd*256]*256") at 65,536 hits a bar, stop here.

When a budget runs out, the query returns what it has, try_events notices, and the call fails, naming the event budget first:

track 'x': this pattern is too dense to play: it makes more than 262144 events
track 'x': this pattern is too dense to play: it needs more than 4000000 steps of work

Both are far beyond anything musical: a dense 64-bar drum pattern needs thousands of events. The worst case the limits allow, s("[bd*256]*256"), stops at the event budget with the whole claudius script process at about 134 MB, and the parse-time refusal of s("bd*100000000") at 33 MB. Pattern::events is the same query returning nothing when a budget runs out, for callers that only look (such as listing a pattern’s sound names).

track() adds a second limit on the result: it places 64 bars of the pattern, and more than MAX_NOTES_PER_BAR (1,024) notes per bar on average fails with track 'x' plays N notes per bar; the limit is 1024.

Mini-notation

pattern/mini.rs is a tokenizer and a recursive-descent parser. The grammar, as it is implemented, is written out in Mini-notation; the parts that matter to someone changing it:

  • Tokens. Words take letters, digits and # : . _ ' - ^; a number followed by letters is a word (808, 3am). ~ is a rest, and so is a - not followed by a digit; _ on its own lengthens the previous step.
  • Structure. stack handles , (layers) and | (random choice) around seq, which handles steps, _ and !. step reads one atom or group ([ ], < >, and { }, which is parsed exactly like [ ]), then its modifiers: *, /, @, ! (with or without a count), ? (with or without a probability) and (k,n,r).
  • Limits. The source is at most 4,096 characters (MAX_SOURCE, counted in characters, not bytes), brackets nest at most 64 deep (MAX_DEPTH), a step repeats at most 256 times and a euclidean rhythm has at most 256 steps (MAX_STEPS), * and / take factors up to 256 (MAX_SPEED, shared with fast, slow and ply), and a sequence has at most 1,024 steps. Each limit has its own error message, because a fuzzer finds these first.
  • Errors say what was expected and where: expected ']', found the end, unexpected ')', '/' takes a number above 0 and up to 256, not 0.

The mini_notation fuzz target feeds arbitrary text to parse and queries whatever parses; the script target does the same for whole scripts. See Testing.

From events to notes

notes::place turns a pattern’s events into notes on the song’s timeline, by the pattern’s kind:

kindmade byan event becomes
sounds(), sound()a note at pitch 60 played by the sound named in the event; bd:3 or .n() picks a variation
notenote()a pitch from a note name (c3, eb4, octave 3 when left out) or a MIDI number
degreen()a scale degree in .scale() (default: the song’s key) and .octave() (default 3)
chordchord()a chord from a symbol (Am7), a numeral (VI), or prog / prog:B (the song’s progression at that bar of the section), voiced with voice leading in .voicing(lo, hi) (MIDI 52 to 74 by default), arpeggiated across the event with .arp(), or reduced to its bass note with .root()
plainseq()numbers, read as MIDI pitches when the pattern plays

Start and length come from the event’s span, four beats per cycle; legato scales the length (never below 0.02 beats); velocity or gain set the velocity (0.9 for sounds, 0.8 otherwise); add adds semitones to notes and scale steps to degrees, and transpose always adds semitones. A word that is not a note, a degree or a chord is an error naming the word, except a prog chord when the progression has no chord there.

track(name, pattern) places 64 bars to learn which sounds the pattern uses, resolves each label once (script::resolve_sound: a .bank() kit, a drum voice, a synth preset, a pack instrument, an Apple Loops cut, or a file), and stores the code with the resolved sounds as a PatternTrack. The track’s role comes from .role(), or from its one sound (bd is a kick, 808 a bass), or from its kind and median pitch (chords are chords, anything with a median under MIDI 52 is a bass, the rest a lead). The song model covers how the arrangement plays it.

The engine and its sandbox

script::run(code, blueprint, library) builds a fresh Rhai Engine for every run, registers the pattern functions and the song functions (song::register_song, which also registers those of mix.rs and form.rs), and runs the code. run_with is the same with the folders a shared session may read, which the server passes. The song functions share an Rc<RefCell<Ctx>> holding the blueprint being edited, the Apple Loops library, the log and those folders; each edit borrows it, changes the blueprint, and returns. A script that edits without starting a song gets a blank house song first. When the script ends, run checks the song’s size and returns the new blueprint and the log. When it fails, nothing is returned but the error, and the caller still has the old blueprint: the session swaps blueprints only on success.

Scripts come from people, from assistants and from files, so the engine assumes nothing about them:

limitvalueset by
operations5,000,000set_max_operations
running time10 san on_progress callback that checks the clock
call depth64set_max_call_levels
expression depth128, 64 inside functionsset_max_expr_depths
string, array, map size200,000 characters, 10,000 items, 1,000 entriesset_max_string_size and friends
modulesnonea DummyModuleResolver, so import fails
evaldisableddisable_symbol("eval")
filesnone, except audio named in .s()no file functions are registered; resolve_sound checks paths with config::read_error against the session’s folders

print() and debug() go to the log, not to stdout, which in claudius mcp carries the protocol. The same limits apply to eval_pattern, which evaluates a pattern track’s code with only the pattern functions registered every time the song is arranged.

How a pattern remembers its code

Every constructor and method appends to repr: s("bd*4"), then .fast(2), then .gain("1 0.7"). Strings are re-quoted with escapes, numbers printed by fmt_num (integers plainly, other numbers with at most four decimals). Methods that take a closure (every, lastOf, sometimes and its relatives, off, superimpose) need the closure’s code too, which Rhai does not give back. So apply_fn calls the closure twice: once with the real pattern, to get the result, and once with a placeholder pattern whose repr is x, to learn what the closure does to it. The second call returns, say, x.add(7), and the method prints |x| x.add(7):

blank("house");
let up = 7;
track("lead", n("0 2 4").scale("A:minor").superimpose(|x| x.add(up)).sometimes(|x| x.fast(2)));

prints as

track("lead", n("0 2 4").scale("A:minor").superimpose(|x| x.add(7)).sometimes(|x| x.fast(2)));

with the variable replaced by the value it had. A closure that inspects its argument instead of transforming it would print what it did to the placeholder, but no pattern method gives a closure anything to inspect.

Printing a song

script::to_script(blueprint) writes, in order: a header naming the title, genre and seed; title, tempo, key and swing; humanize when it is not 0; every progression; bars_per_chord when it is not 1; every variant of every drum lane as a step string; every generated part as a part() line with its style, preset, register, density and seed; every pattern track’s code as it was written; the form, one sec() per section with the methods that differ from that kind’s defaults; and a mix() line for every track whose mix differs from the mix it would have without one. Songs as scripts shows the output.

The guarantee is that running the printed script on the same song changes nothing. Each part of the printer has a partner on the reading side that keeps it true:

  • Numbers. f32s and f64s print integers plainly and everything else with Rust’s shortest representation that reads back as the same value, so swing(0.5257842) is exact.
  • Drum steps. A step string can only say X, x, o or a digit, so it rounds velocities: hits_to_steps writes X from 0.85 (STEP_ACCENT), o below 0.5 (STEP_GHOST) and x between, and reading gives back 1.0, 0.8 and 0.45 (a digit is tenths). The studio’s accent and ghost marks use the same two numbers, as claudius_api::{ACCENT, GHOST}. drum() therefore compares the string it is given with the string the lane’s current hits would print as, and keeps the exact hits when they match. The resolution is 16 steps per bar when every hit is on a sixteenth, else 32, else 48, printed after the string.
  • Parts. part() with the preset the part already has keeps its patch, so the generator’s variation of a preset and evolution’s tweaks survive.
  • Sections. sec() fills in the same defaults the printer leaves out (energy by kind, progression B for breaks, the sparse variant for intros and outros, build for builds, fills for drops, grooves and hooks), and "*" stands for every track exactly when a section has all of them.
  • Seeds. A part’s u64 seed prints as a signed 64-bit integer, Rhai’s integer type, and reads back as the same bits.
  • Pattern tracks. The code is printed verbatim and resolves to the same sounds in the same environment.

The tests hold the printer to it: songs_print_as_scripts_that_change_nothing generates songs in five genres and compares the blueprint before and after running its script, field by field, and printed_patterns_read_back_the_same does the same for generated patterns. A change to the printer or to a song function that breaks the guarantee fails them.

What a script cannot say is not printed: drum voices and kits, synth patches beyond the preset name, Apple Loops and flipped samples, effect chains, the master, the transitions. Running the script on another song leaves those as that song has them. Songs as scripts lists them for users.

Rendering, mixing and mastering

render::render(project, options) turns an arranged project into a mastered stereo file. It works the way a mix engineer does in a DAW, in a fixed order, and everything it does is decided by the project: there is no hidden state and no randomness that is not seeded from the project’s seed and the track’s name.

Everything inside claudius runs at 48 kHz (earshot’s rate) on planar stereo f32 buffers (audio::Stereo).

 per track, in parallel (rayon)
 ┌──────────────────────────────────────────────────────────────────────────────────────────┐
 │ notes / clips ──▶ source ──▶ insert chain ──▶ gain to the role's target ──▶ sidechain ──▶ pan │──┐
 └──────────────────────────────────────────────────────────────────────────────────────────┘  │
        ┌────────────────────────────────────────────┬───────────────────────────────────┘
        ▼                                            ▼ sends (reverb, delay)
  bus: drums │ bass │ music │ fx                  returns: convolution reverb, ping-pong delay
  (each bus chain, in parallel)                   (wet only, ducked 30 % by the kick)
        └──────────────────────────┬─────────────────┘
                                   ▼
                        sum ─▶ premaster LUFS ─▶ master chain ─▶ finalize (gain search,
                                                                 soft clip, limiter, trim)
                                                                         ▼
                                                    master, trimmed after its tail, 0.5 s fade

The length of a render

A render buffer is the song’s length plus 3 s of tail, plus the longest tail of any track’s insert chain (a reverb’s decay, a delay’s echoes until they fall 60 dB), capped at 6 s. At the end, the master is cut where it falls below −80 dBFS for good (never shorter than the song) and faded out over 0.5 s. A project over 512 bars or 20 minutes, or with more tracks than model::check_tracks allows, is refused before anything is allocated.

Sources

Each track plays its notes or clips through its Sound:

soundhow it plays
drumthe voice is rendered once from its parameters and cached for the process, keyed by the sound, the pitch and the seed; other pitches are resampled from it like a sampler
samplethe file is decoded once (and cached on disk, see Files), sliced to start..end, faded, and played like a drum, pitch relative to root
synththe synth engine renders the notes
instrumentthe sample-pack player renders the notes (Sample packs)
fxeach note renders a transition effect of the note’s length: riser, downlifter, impact, reverse_crash, sweep_up or sweep_down; risers are tuned to the key’s tonic
loopthe loop is warped to the tempo and key, then each clip copies it in, re-sliced by chop when set, with fades of at least 1 ms in and 2 ms out

Details that make it sound played rather than printed:

  • Swing moves every off-beat sixteenth late when notes are turned into sample positions, so the blueprint stays on the grid and one number changes the feel of every part.
  • Humanisation adds timing jitter with a standard deviation of humanize × 4 ms (× 0.6 for parts, so drums carry the looseness) and velocity jitter of ±5 % × humanize, from a generator seeded by the track’s name.
  • Velocity of a one-shot scales its level by velocity^1.6.
  • Choke groups stop a ringing hit at the next onset of any track in the same group, with a 4 ms fade (the open hat stops when the closed hat plays). Pack instruments get the same cut times.
  • Samples on melodic roles stop at the note’s length plus 50 ms, so legato() shapes them; on drum roles they play to the end.

The auto-mixer

A producer balances by ear: the kick here, the bass a little under it, the pad well back. claudius does the same with a meter. After its inserts, each track’s active level is measured: the 80th percentile of its short-term loudness (3 s blocks every 0.5 s) over the blocks where it is audible (above −50 LUFS), falling back to momentary blocks (0.4 s) for tracks that play only a few hits. The track is then scaled to:

target = role target + genre's offset for the role (mix.levels) + the track's level_db

with the gain clamped to −40..+30 dB. The role targets, in LUFS while the track plays:

roletargetroletargetroletarget
kick−12.5bass−14.5riser−19
snare−13.5chords−18impact−16
clap−14.5keys−17downlifter−22
hat−17.5pad−21.5reverse−21
ohat−18.5lead−18drums (loops)−14.5
shaker−21.5arp−20.5vocal−15
perc−19.5top−20sample, synth−18
ride−20.5crash−20texture−27
tom−18anything else−19

So level_db is a level relative to where the role belongs, not a fader position, and mix("pad", #{ level: -3 }) means “3 dB quieter than a pad usually sits”. Genres shift roles with levels: trap pushes the bass up 1.5 dB and pulls the kick and hats down 1 dB, lofi lifts the keys 2 dB. The report and the studio’s mixer show each track’s measured level and the gain it got.

Sidechain

The kick drives one duck curve for the whole song: at each kick onset the gain falls to zero over 3 ms, then recovers along 1 − (1 − x)² over duck_release beats (0.25 in trap and drum and bass, 0.45 in future bass, which pumps the hardest). A track with duck d is multiplied by 1 − d × (1 − curve), so duck: 0.55 on the bass pumps it hard and duck: 0.2 on a lead only breathes. The reverb and delay returns are ducked by 0.3 too, which keeps the low end clear on the kick.

Buses and returns

Tracks go to one of four buses, drums, bass, music and fx. Each bus runs its chain: the genre’s (house compresses the drums 2:1 from −12 dB and highpasses the music at 110 Hz), the arrangement’s filter automation on music and bass, and the three inserts every song gets (width with mono low end on music and bass, light saturation on drums). The buses run in parallel.

Each track’s post-fader signal also feeds the two returns in proportion to its reverb and delay sends:

  • Reverb is a convolution with an impulse response generated in code: three bands of decorrelated noise (below 500 Hz, 500 Hz to 4 kHz, above 4 kHz) decaying at different rates, so damping makes the highs die faster, plus seven early reflections in the first 70 ms. The input is cross-fed 20 % between channels so a hard-panned source fills both sides, and the wet signal is high-passed at lowcut and low-passed between 9 kHz and 5 kHz depending on damping, because a noise tail reads as hiss up there. The impulse is seeded, so a reverb sounds the same every render.
  • Delay is a tempo-synced feedback delay in beats, ping-pong when asked, with a lowpass and a 180 Hz highpass in the feedback path, so repeats darken and do not build up low end. The return keeps only the repeats.

Mastering

The buses and returns sum to the pre-master, whose integrated loudness goes into the report. The master chain from the genre runs next (house: a 25 Hz highpass and a +1 dB shelf at 10 kHz, a gentle 1.6:1 compressor, tape saturation), and then finalize brings the result to the genre’s LUFS target under the ceiling (−1 dBTP unless master() changes it):

  1. Each pass applies an input gain, a soft clipper (2× oversampled, linear to 70 % of the ceiling minus 0.1 dB, then a tanh curve that never passes it, which shaves drum transients the way mastering engineers clip before the limiter), and a lookahead brickwall limiter at the ceiling minus 0.5 dB (1.5 ms lookahead, 50 ms release).
  2. If the true peak still pokes over the ceiling, the pass trims the output to 0.05 dB under it.
  3. The integrated loudness of the result is measured, and the next gain comes from a secant step on (gain, loudness), with the slope clamped to 0.15..1.5 and each step to ±6 dB. It stops within 0.15 LU of the target or after seven passes.

The critic flags a master more than 1 LU from its target or over its ceiling; the pipeline test asserts both on every CI run.

genretarget
dnb−8.5 LUFS
techno, trap, future bass−9 LUFS
house−10 LUFS
synthwave−10.5 LUFS
deep house−11 LUFS
lofi−12.5 LUFS

Loudness and peaks

loudness.rs implements ITU-R BS.1770: K-weighting with the 48 kHz coefficients, momentary (0.4 s) and short-term (3 s) blocks from prefix sums of the weighted power, integrated loudness with the absolute gate at −70 LUFS and the relative gate 10 LU below, loudness range (the 10th to 95th percentile of short-term loudness, gated 20 LU below), and the active level above. A test checks it on the standard’s reference: a −3 dBFS 1 kHz sine on both channels reads −3.0 LUFS.

fx::true_peak_db measures true peak by 4× oversampling with a windowed sinc, evaluated only around samples within about 4.4 dB of the sample peak, where inter-sample overs live.

Effects

Every effect is plain data, so blueprints store, mutate and diff effect chains. In JSON an effect is tagged by fx: {"fx": "compressor", "threshold_db": -12, "ratio": 2.0, "attack_ms": 12, "release_ms": 120, "knee_db": 6}. Parameters that can move are Params: a number, or a curve of [beat, value] points, interpolated per sample.

effectwhat it is
gaingain in dB (automatable)
eqRBJ biquad bands: lowpass, highpass, bandpass, notch, peak, low and high shelf, allpass
filtera resonant filter with automatable cutoff: a zero-delay-feedback state-variable filter, or a saturating 4-pole ladder
compressorstereo-linked feed-forward compressor, soft knee, gain reduction smoothed in dB
limiterthe lookahead brickwall limiter
saturatea 2× oversampled waveshaper: tanh, tube, tape, hard, fold
bitcrushbit depth and sample-and-hold
reverb, delayas on the returns, for inserts
chorus, flanger, phasermodulated delays and allpass stages
widthmid/side width, with the side removed below mono_below_hz
tremolo, autopantempo-synced
gatea rhythmic volume gate (trance gate) from a list of step gains
transienta transient shaper (a fast and a slow envelope split attack from sustain)
tape, vinylwow and flutter with saturation and a lowpass; crackle and hiss
tape_stop, stutter, reverseedits on a region of beats: a slowdown to a halt, a beat repeat, a reversal

The synth engine

synth/voice.rs renders subtractive and two-operator FM voices. A Patch has:

  • oscillators (sine, saw, square, pulse, tri, noise), band-limited with polyBLEP, each with a level, an octave and semitone offset, and a unison stack (voices, detune in cents, stereo width) with slow random pitch drift per voice (analog);
  • a sub oscillator an octave down, a noise level, and an optional FM modulator (ratio, index, its own decay and sustain, and a fast-decaying ratio-14 “tine” for electric-piano bark);
  • a filter (state-variable lowpass, highpass, bandpass or notch, or the ladder) with an envelope depth in octaves, key tracking and velocity sensitivity;
  • amplitude and filter ADSRs, a pitch envelope (the drop that makes an 808 thump), an LFO (free or tempo-synced) to vibrato, cutoff and amplitude, glide, drive, and velocity sensitivity.

Polyphonic patches render every note as its own voice, in parallel batches of 96. Mono patches (basses, acid, leads) render one voice that follows the notes, retriggering its envelopes unless notes overlap and the patch has legato or glide, in which case it slides. Envelopes, pitch and filter coefficients update every 16 samples, with amplitude interpolated across the block so there is no zipper noise.

The 20 presets in synth/presets.rs are sub_bass, 808, reese, acid, saw_bass, pluck_bass, organ_bass, wobble, pluck, supersaw, pad, warm_pad, stab, epiano, keys, bell, organ, strings, square_lead and chip, each tagged with the roles it suits. A part holds a full copy of its patch, which the generator varies slightly and evolution tweaks (cutoff, resonance, release, drive, detune).

Drum voices and transition effects

synth/drums.rs renders 13 voices in the spirit of the 808 and 909 circuits: kick, snare, clap, closed and open hat, ride, crash, tom, rim, shaker, cowbell, snap and perc. They are pitch-enveloped sines, clusters of detuned square waves for metal, and shaped noise. All of them share one set of nine parameters (tune, decay, tone, punch, snap, drive, pitch envelope, pitch time, room), each with a range per voice, so mutation can explore every voice the same way. Every hit is rendered at −1 dBFS peak.

synth/fxsounds.rs renders the transitions: a riser (noise swept up through a resonant bandpass, with a detuned saw climbing two octaves), a downlifter (the reverse), an impact (a sub boom and a noise burst), a reverse crash that ends exactly on the downbeat, and noise sweeps up or down.

claudius sounds renders all of them, and every preset playing a phrase, into out/sounds/.

Stems

With stems on, each track’s buffer is kept after its gain, sidechain and pan, before the buses and returns: dry stems, as a DAW bounces tracks without their sends. A song folder gets them as 32-bit float WAVs (song::write_folder, from make --stems, render --stems and every save), since a track can peak above full scale before the master’s limiter. The server also writes 24-bit copies to its render cache and serves those at /api/stems/{version}/{track}.wav.

Speed

A render’s cost is mostly the synthesized and sampled melodic tracks, the reverb convolution, and the master’s search, which runs the master stage up to seven times. On the machine these docs were written on, a full four-minute house song renders in about 11 s. CLAUDIUS_DEBUG=1 prints the time of every track and stage; this is a 64-bar house sketch made of synthesis:

render: track hat in 0.01 s
render: track kick in 0.02 s
...
render: track bass in 1.29 s
render: track chords in 1.50 s
render: track arp in 1.51 s
render: tracks + buses + returns in 3.20 s
render: master chain done at 4.12 s (-9.87 LUFS)
finalize: input -9.87 LUFS, gain -0.13 -> -10.72 LUFS (tp -1.05)
finalize: gain +0.59 -> -10.19 LUFS (tp -1.05)
finalize: gain +0.85 -> -9.96 LUFS (tp -1.05)
render: finalized at 6.22 s

The tracks render in parallel, so the slowest track sets the pace of the first stage; the three finalize lines are the gain search converging on −10 LUFS.

cargo bench -p claudius measures generating, arranging and rendering 8 bars, and records results in target/criterion.

The critic and the ears

After every render, judge::judge(project, rendered, ears) answers one question, is this song any good, with evidence of three kinds, plus a fourth when earshot’s models are loaded:

componentwhat it looks atweight
mixmeters on the master: loudness, true peak, tonal balance, stereo width, mono low end0.30
arcthe energy arc: does loudness follow the arrangement’s energy, is the drop louder than the break0.20
theorythe score itself: the lead, the bass, voice leading, the form0.20
genrethe ears: does it read as its genre rather than the genres it is confused with0.15
productionthe ears: does it read as professionally produced rather than as a typical flaw0.15
refsthe ears, with reference tracks: how typical it is of them0.15

Each component scores 0 to 1. The total is the weighted mean of the components present, times 100, so a verdict without ears is out of the same 100. 0006 A critic with ears records why the critic is built this way.

The verdict

This is the verdict.json of claudius make --genre deep_house --seed 7 --short, with the measures cut short:

{"total": 86.41901839224477,
 "scores": {"arc": 0.9449047645757822, "genre": 0.7125, "mix": 0.9315307700243037,
            "production": 0.5925, "theory": 1.0},
 "measures": {"lufs": -10.98185847420735, "true_peak_db": -1.0499999523162842, "band_presence_db": -28.6,
              "drop_minus_break_lu": 3.5, ...},
 "issues": [{"severity": 0.4366487862341189, "target": "band:presence-",
             "what": "dull 4-8 kHz: hats, claps and snare lack bite (presence -28.6 dB, healthy from -26)"},
            {"severity": 0.13392813023001673, "target": "band:air+",
             "what": "fizzy top end (air -16.2 dB, healthy up to -17)"},
            {"severity": 0.05999999999999996, "target": "genre",
             "what": "sounds more like 'techno music' than 'house music'"},
            {"severity": 0.052000000000000046, "target": "production",
             "what": "sounds about as much like a hissy recording as a professional mix: noise-built sounds (hats, shakers, risers) and long reverbs are the usual cause"}],
 "ears": {"genre_query": "house music", "genre_z": 3.71, "best_alt": "techno music", "best_alt_z": 3.86,
          "production_z": 1.7, "worst_flaw": "a low quality recording with hiss", "worst_flaw_z": 1.33,
          "sounds_like": ["techno music", "tech house music", "house music", "drum and bass music"],
          "p_real": 0.537, "audioset": [], "ref_typicality_z": null},
 "summary": "86/100 (arc 0.94, genre 0.71, mix 0.93, production 0.59, theory 1.00). Fix first: dull 4-8 kHz: hats, claps and snare lack bite (presence -28.6 dB, healthy from -26); fizzy top end (air -16.2 dB, healthy up to -17); sounds more like 'techno music' than 'house music'"}

An issue is a problem in words, a severity from 0 to 1, and a target: where in the blueprint a fix lives. Issues are sorted worst first, and the summary names the first three after “Fix first”. The targets are a small vocabulary that people, assistants and the evolution loop all read:

targetmeaningwhat evolution tries more often
masterloudness or true peak of the master
band:<band>+, band:<band>-too much or too little energy in a bandlevels of the tracks that live in that band, drum sounds and kits, patches, space, pan
width+, width-too narrow, too wide or phasey, or a low end that is not monothe same
formthe arc or the lengthlayers, section flags, drum variants, section lengths
part:<name>a part’s writingthat part’s seed, style, density and register
genreit reads as another genre, or unlike the referencespresets, styles, drum patterns and sounds, kits, tempo, progressions
productionit reads as one of the flawsas for the bands

Mix

Loudness and peak. More than 1 LU from the master’s target costs 0.1 and raises an issue of severity 0.3; a true peak more than 0.1 dB over the ceiling costs 0.15 (severity 0.5). The loudness range is measured and reported but not scored.

Tonal balance. The master’s power spectrum (Welch, 8192-point frames, mono) is split into six bands, and each band’s share of the energy above 20 Hz is compared with a healthy range for a modern electronic master. The ranges are built around a spectrum whose energy per octave is flat from 40 to 150 Hz and falls about 3 dB per octave above:

bandrangehealthy share
sub20–60 Hz−14 to −4 dB
low60–250 Hz−7 to −1 dB
lowmid250 Hz–1 kHz−12 to −4 dB
mid1–4 kHz−18 to −8 dB
presence4–8 kHz−26 to −15 dB
air8–16 kHz−30 to −17 dB

Outside its range, a band costs 0.12 × severity, where severity is the distance in dB over 6 (at most 1), and the issue says what is likely wrong: too much sub (boomy): lower the bass / kick, or highpass the music bus, muddy 250 Hz-1 kHz: too much pad / chord body, no air above 8 kHz: hats and cymbals too quiet or too dark.

Width. The correlation between left and right over the whole master: above 0.96 is nearly mono (−0.08), below 0.15 too wide / phasey (−0.1). Below 150 Hz the correlation must stay above 0.9, or the low end is not mono (−0.08), which matters for club systems and vinyl.

Arc

A producer shapes energy across a song: a breakdown drops back, a build rises, the drop hits. The critic measures each section’s loudness as the median short-term loudness (3 s blocks) inside it, leaving out its first 1.5 s and last 1 s so transitions do not count, and compares that with the energy the blueprint gives the section:

  • the score starts at 0.5 + 0.5 × r, where r is the correlation between energies and loudnesses across sections (arc_correlation), and an r under 0.5 raises loudness does not follow the arrangement's energy;
  • the loudest drop or hook should be 3 to 7 LU louder than the quietest break (drop_minus_break_lu); under 3 LU costs up to 0.3 and raises the drop is only 2.1 LU louder than the breakdown (aim for 3-7): thin the break or push the drop.

A song with fewer than two measurable sections scores 1. Each section’s loudness is kept in the measures as section_<n>_<name>_lufs, numbered in the arrangement’s order (section_05_drop_lufs), and the MCP tools print them as one loudness by section line: the critic compares only the drop with the break, and these let a person or an assistant compare any two.

Theory

The critic reads the project’s notes, not the audio, so these checks are exact:

checkmeasureissue when
the lead stays in keylead_in_key, the share of lead notes in the keyunder 95 %
the lead lands on chord toneslead_chord_tones_on_strong_beats, on beats 1 and 3under 60 %
the lead is singablelead_range_semitonesover 19 semitones
the lead moves by stepslead_stepwise, and the share of leaps over a fifthmore than 20 % leaps
the bass plays the rootsbass_roots_on_changes, the share of chord changes where the bass hits the chord’s bass noteunder 70 %
chords move smoothlyvoice_leading_<track>, the average movement per voice per chord change, for every chords, pad and keys trackover 4.5 semitones
the form is soundlength_s; every section a multiple of 4 barsunder 90 s or over 330 s; any other section length

The theory score is the mean of a score per check that applies: a song without a lead or bass is not marked down for them.

The ears

The ears are earshot, ported to Rust in crates/earshot with CLAP and the AudioSet tagger running as ONNX models; its README documents the port and its parity with the Python original. ears::Ears wraps it for claudius.

One embedding. The master is downmixed to mono the way the models were calibrated (ffmpeg’s −3 dB per channel) and embedded once with CLAP: a song up to 10 s long is one window; a longer one gets 10 s windows every 5 s, the last flush with the end, at most six spread evenly, averaged and renormalised. Every question below is answered from that one 512-dimensional vector, which is why the ears add only about a second to a verdict.

Calibrated scores. A raw CLAP similarity between audio and a text means little on its own: some texts match everything. earshot calibrates it against its vocabulary of 322 terms:

u = cos(audio, text) − text · m      m: the mean embedding of earshot's reference recordings
z = (u − mean of u over the 322 terms) / their standard deviation

A z says how much better this text describes this sound than the vocabulary does on average; earshot calls 2.5 plausible and 3 strong.

Genre. Each genre profile names what the ears should hear (query, "house music" for house and deep house) and the genres it is most often confused with (alts: "techno music", "minimal electronic music", …). The genre score rewards a high genre_z and a margin over the best alternative:

genre = 0.5 × clamp((genre_z − 1) / 2.5) + 0.5 × clamp((genre_z − best_alt_z + 1) / 2)

An issue is raised when an alternative beats the genre, sounds more like 'techno music' than 'house music', or when genre_z is under 2.5, sounds only faintly like 'house music' (the nearest other reading: 'techno music'). Its severity is the larger of (2.5 − genre_z) / 2.5 and (best_alt_z − genre_z) / 2.5. The issues leave the z values out; the ears line of the command line and the MCP tools prints them.

Production. The best of two descriptions of a professional track (a professionally mixed and mastered music track, a high quality studio recording) against the best of six flaws (a cheap synthesized beep, hiss, clipping, a computer-generated tone, low-bitrate artefacts, a phone recording):

production = clamp(0.5 + (production_z − worst_flaw_z) / 4)

with an issue when the difference is under 0.5. The issue names the flaw in a few words and where it usually comes from in a song of claudius’: sounds about as much like a hissy recording as a professional mix: noise-built sounds (hats, shakers, risers) and long reverbs are the usual cause. It says “more like” only when the flaw leads by more than 0.5. Its severity is (0.5 − difference) / 2.5: a near tie is a faint reading, about 0.2, so the issues that name a change come before it, and a flaw that leads by 1.5 reaches 0.8.

A genre can list flaws that are its sound in accepted_flaws, and its production reading leaves them out: lofi accepts the hiss and the phone recording, since vinyl crackle, tape hiss and a lowpassed drum bus are the style.

References. With --refs and at least two reference tracks, the song’s typicality among them (the mean cosine to its five nearest references, as a z against the references’ own spread) scores clamp((z + 3) / 4), and an issue is raised under −1.

What it sounds like. sounds_like lists the four vocabulary terms that describe the master best, leaving out the quality and production categories, for people to read.

Held-out evidence

Two readings are reported and never scored: p_real, earshot’s estimate that the audio is a real, produced recording rather than something synthesized in code (in the command line’s verdicts, the mean over eight 4-second windows spread across the song, where earshot would read all of them), and the AudioSet tagger’s top six labels over three 10-second excerpts (with --tags). Optimising against a model can please the model without making the music better, which is earshot’s own warning. Keeping some evidence out of the score leaves a way to notice when evolution is gaming the scored parts: the scores rise and the held-out readings do not.

Where the critic is weak

The critic measures; it does not have taste. A high total means no measurable problems and a confident genre reading, not a good song.

  • earshot was validated for matching sound effects, not music. House, tech house and techno sit close together for it, and fully synthesized songs read only marginally “professionally mixed”; songs with sampled instruments read better.
  • The healthy band ranges are one target for every genre: a lofi track is meant to be darker than they allow, so it gets no air issues that are style, not fault.
  • judge on an arbitrary file without its project.json has no sections and no notes: the arc scores 1, the theory checks only the length, and the length is estimated at 120 BPM.
  • The evolution loop judges 32-bar previews, so a preview’s length issue (30 s long) is in every candidate’s verdict; it shifts every score by the same amount and does not change which candidate wins.

Judge finished songs by ear too.

Evolution and analysis

Two parts of the engine work on whole songs at once. Evolution (evolve.rs) searches for a better song by editing blueprints the way a producer revises a track and keeping what the critic prefers. Analysis (analysis/) reads a song from audio, so that a new one can be written on its skeleton.

Mutation

evolve::mutate(blueprint, genre, library, issues, rng) returns a changed copy and a sentence saying what changed. It picks one of 25 operators, applies it, and keeps the result if the blueprint actually changed; otherwise it tries another, up to 12 times, and as a last resort writes every part again with new seeds (rewrote every part).

leveloperatorswhat one does, in the words it logs
harmonyharmony_sub, harmony_newharmony A: chord 2: VI -> iv (third relation); a new progression from a style of the key’s family
drumsdrum_edit, drum_regen, drum_sound, drum_kitdrums (main): added a hat hit at beat 2.75, removed, nudged by a sixteenth, or re-accented; a new pattern from the lane’s template; a one-shot from the library or a nudge of one synth parameter; every lane onto another kit
partspart_seed, part_style, part_preset, patch_tweak, part_register, part_densitybass: rewrote the part; chords: style stabs -> charleston; a preset from the genre’s list; cutoff, resonance, release, drive or detune of a synth patch; the register moved by 3, 5 or 12 semitones; a lead’s density
mixmix_level, mix_send, mix_duck, mix_pan, spacemix: bass -1.3 dB (now -1.3); a reverb or delay send; a part’s sidechain depth; the pan of anything but kick, bass, snare and clap; the reverb’s decay or the delay’s time
formform_layer, form_bars, form_flags, form_variantform: lead out of break; a section 4 or 8 bars longer or shorter (4 to 48); a fill, gap, impact or filter sweep switched; a section’s drum variant
feeltempo, key, swingtempo 128 -> 126 BPM within the genre’s range; key A minor -> C minor (tonal loops follow); swing nudged
libraryloop_swapanother Apple Loop for a loop part

Every operator stays inside what the genre allows: styles and presets from the genre’s lists, tempo within its range, levels within −12 to +9 dB, pans within ±0.7.

drum_kit moves every drum lane onto another installed kit: the genre’s kits first, then tr909, tr808, tr707, muldjord and uzu, never the kit the song is already on. When the song is on a kit, one time in four it goes back to the synthesized voices instead. It logs drums: played on the tr808 kit or drums: back to the synthesized voices.

Issues steer the choice. All operators start with weight 1. For each of the verdict’s four worst issues, the operators that address its target are multiplied by 1 + 4 × severity: a band:sub+ issue of severity 0.5 makes the level, drum sound, kit, patch, space and pan operators three times as likely. mix_level goes further and, three times in four, turns the track the band issue points at the right way (too much sub turns the kick or bass down); the part operators pick a part an issue names seven times in ten.

Every mutation appends its sentence to the blueprint’s lineage, so a song carries its own history.

Crossover

evolve::crossover(a, b) makes a child from two parents in one of two ways, half the time each: the drums and form of one with the harmony and parts of the other, or the drums of one with everything else of the other. Both keep a coherent song, because the harmony and the parts written over it travel together.

evolve::run is a (μ + λ) search: every candidate ever judged stays in the pool, and each generation’s parents are the best population of all of them.

initial songs ──judge──▶ pool
                          │  each generation:
                          │    parents = best `population` of the pool
                          │    children: pick a parent (biased to the better ones),
                          │              crossover with another parent (20 %),
                          │              then 1 or 2 mutations steered by the parent's issues;
                          │              skip any blueprint already seen
                          │    judge every child in parallel (rayon) and add it to the pool
                          ▼
                     every candidate, best first
  • Previews. A candidate is judged on its build-up and first drop, not the whole song: preview_window takes the first drop or hook and up to 16 bars before it, preview_bars (32) in all, and the rest of the song is cut away with Project::slice. A whole song takes seconds to render; a preview a fraction of that, so a generation of eight children takes 10 to 40 s with the ears.
  • Duplicates are skipped by hashing each blueprint’s JSON, so the search never judges the same song twice.
  • Parents are drawn with min(random(n), random(n)), which favours the better half without ignoring the rest.
  • Stopping. A cancel flag is checked before each generation.

Each candidate records its id (g2-1: generation 2, child 1), its parent, what changed and its score. The command line writes them all to evolution.json and renders the best in full; the session keeps the best only if it beats the song it started from.

The studio’s Mutate (the mutate command and MCP tool) is a single generation without crossover: count children of the current song, one or two mutations each, steered by the current song’s issues, judged on the same 32-bar previews, and the best kept if it beats the current song’s preview score.

Reading a song

analysis::song::analyze(audio, tempo_range) reads a whole mixed song the way a producer does before remaking it: tempo, downbeat, key, the chord of every bar, sections with their energy and what plays in them, drum habits per sixteenth, swing and a bass style. It uses no models: everything is signal processing in analysis/.

filewhat it does
onsets.rsonset strength as log-compressed spectral flux, optionally in a frequency band; peaks above a moving median; onsets refined to where the energy starts rising
beats.rstempo by autocorrelation of the onset envelope, beats by dynamic programming (Ellis 2007), then a constant-tempo grid and the downbeat
chroma.rspitch-class energy, keys by Krumhansl-Kessler profiles, chords by template matching
song.rsthe rest: sections, progressions, drum steps, swing, and to_blueprint

Tempo. The onset envelope adds the full-band flux to twice the kick-band flux (30 to 160 Hz), because in dance music the kick marks the beat. Its autocorrelation, weighted by a prior centred on 120 BPM (or on the middle of the range a genre gives) and helped by the autocorrelation at twice the lag, gives the beat period. Beats tracked by dynamic programming drift and insert beats through breakdowns, but music made in a DAW keeps one tempo, so the tracker then fits a constant grid, trying every tempo within 0.5 % in steps of 0.005 BPM and every phase, and keeps the one that lands on the most onset energy.

Downbeat. Of the four beat phases, the downbeat is the one with the best mix of three cues, each scaled to its best phase: a change of harmony across the beat (chroma a bar before against a bar after, weight 1.5), the biggest level jumps (sections start on downbeats, 0.75), and kick strength (0.5). Harmony leads because level jumps also come from pre-drop gaps and risers, and kick strength from rolling basses.

Key and chords. Chroma comes from FFT bins close to an equal-tempered pitch, so drums and noise smear less into it. The key is chosen among the four best Krumhansl-Kessler fits of the loudness-weighted average chroma, by its fit plus how many bar chords it explains diatonically, how often its tonic chord appears, and how much bass sits on its tonic. Each bar’s chord is matched against triad and seventh templates, with a pull towards the key’s diatonic chords, using a bass chroma read in the second half of each of the first three beats, where the kick has decayed and a sidechained bass has swelled back.

Sections and energy. The song is cut into 4-bar blocks described by their loudness, low and high band levels and chroma; a boundary goes where neighbouring blocks differ by more than the mean plus half a standard deviation, at least 8 bars after the previous boundary and not in the last 4 bars. Each section’s energy is its loudness scaled between the quietest and loudest sections, from 0.3 to 1. Kinds follow from energy and position: the first section is an intro and the last an outro unless they are loud, anything at 0.85 or more is a drop, a section of at least 0.45 before a drop is a build, a section without kick or under 0.5 is a break, the rest grooves.

Progressions. In the loudest drop, the chords are tested for repeating every 2, 4 or 8 bars, and the cycle that agrees most becomes progression A; when the chords come in pairs, it becomes one chord per two bars. Progression B is read the same way from the longest break or intro.

Drums, swing and bass. The kick, snare and hat onset envelopes (by band) are sampled at every sixteenth of every bar in the sections with a kick and energy of at least 0.6, averaged, and turned into lane templates: a step that hits in almost every bar becomes certain, weaker steps become probabilities. Swing is the median position of hat onsets in the second half of each eighth. The bass style is guessed from the kick’s step profile: rolling when ten or more sixteenths are busy, offbeat when the off-beat eighths carry more than 55 % of the on-beat weight, sustain when at most two are busy, syncopated otherwise.

Writing a new song on it

analysis::song::to_blueprint(analysis, genre, seed, ...) generates a song in the genre at the analysed tempo and key, then replaces its skeleton with the analysis: progressions A and B and their chord length, the swing, and the form, one section per analysed section with its length rounded to whole 4-bar phrases, its energy, and layers chosen from what was heard (the kick when there was a kick, hat-like lanes with hats, bass and chords by energy, the lead in drops and breaks). Intros filter in, sections before a drop build with a gap, drops after something else get an impact, quiet sections use the sparse drum variant. The kick, snare, clap and hat lanes get new patterns drawn from the analysed templates, and the bass takes the analysed style when the genre has it. The sounds and the mix come from the genre. claudius remake and the remake command do exactly this, and the song’s history begins with remade from <file>: <tempo> BPM, <key>, <n> sections, progression <numerals>.

Where it goes wrong

Analysis is best on steady electronic music with a clear kick, and approximate everywhere. Reading claudius’ own D minor deep house song back:

118.0 BPM (pulse clarity 0.55), Bb major (confidence 0.43), swing 0.50, 52 bars from 0.00 s
  progression A: iii7 Imaj7 vi7 vii  |  B: Imaj7 Imaj7  (1 bar(s) per chord)

The tempo and the first downbeat are exact. The chords of A (Dm7, B♭maj7, Gm7, Am) are the song’s own (Dm7, B♭maj7, Gm7, Am7) in the right order, named in B♭ major, the key of the song’s sixth degree, with the seventh of the last one missed. Progression B, read from a 12-bar break, came out as two bars of B♭maj7, the first of the song’s four chords. The usual failures are these: half or double tempo (a genre’s tempo range fixes most), a relative or neighbouring key, a progression rotated when the first downbeat lands a beat or a bar off, and sections merged when their energy is close. CLAUDIUS_DEBUG=1 prints the downbeat cues per phase.

Sample packs

crates/claudius-packs turns freely licensed sample libraries, published in a dozen different layouts, into instruments the engine can play by name. It is a library (used by the engine, the server and the CLI) and the claudius-packs binary. Sample packs is the user’s side; this page is how it works.

filewhat it does
registry.json, catalog.rsthe curated packs: what each contains, its licence as read from the source, how to fetch and map it
install.rsbuilds a pack’s index from its registry entry (sources, key-split composites) and installs it; removes packs
install/from_sfz.rs, install/from_names.rs, install/from_map.rsinstruments from SFZ files, from file names, from Strudel maps
fetch.rsHTTP: GitHub listings, parallel resumable downloads, archives, WAV to FLAC
sfz.rsan SFZ reader
strudel.rsStrudel sample maps and the github: shorthand
notes.rsnote names, velocity and round-robin markers in file names, drum name synonyms
model.rsthe installed index format (index.json)
resolve.rsnames to instruments: aliases, banks, kits
decode.rsdecoding at the file’s own rate, and the process-wide sample cache
resample.rsband-limited interpolation for pitch and rate
player.rsrendering notes with an instrument
lib.rsthe public API: packs, install, remove, resolve, instrument, credits_for, add_sample_map

The registry

registry.json is embedded in the binary. Each pack records:

  • what it is (title, description, categories) and what was left out for size;
  • its licence (licence, licence_url), the credit line exactly as it should appear (attribution), whether the licence requires it (attribution_required), and licence_note: what the licence text says and where it was read, quoted, so a reviewer can check it without re-reading the source;
  • whether it is in the default set, opt_in with a warning, or lazy;
  • where it comes from (fetch): a GitHub repository at a pinned commit, or an archive URL with a prefix to strip and the archive’s size and sha256;
  • flac, to convert WAV downloads losslessly, and bank aliases (909, rolandtr909);
  • its instruments, each built from one of four kinds of source.
instrument sourceused forthe registry entry says
an SFZ filepianos, orchestra, basses, the acoustic kitthe .sfz path; optionally keep_layers (velocity layers to keep, widened to cover every velocity), keys, exclude, an octave correction for mappings at written pitch, kit (drum name to keys) or kit_auto (one variant per key range)
file namesthe drum machinesa glob (bd8/*.WAV), each file its own variant, first the file for variant 0, or vel, a regex whose group is the velocity layer (the 909’s accent(\d+))
a Strudel mapuzu, the lazy opt-in packsthe map’s URL at a pinned commit, keys to leave out, and map_base when the map’s _base names a branch of another repository
other instrumentsensemblescompose: key ranges taken from other instruments (strings: contrabass below MIDI 36, cellos to 57, violas to 64, violins above)

Instruments also carry playing settings: release, attack, veltrack (how much velocity changes the level), one_shot (play to the end whatever the note length), mono (a new note cuts the last, as on drum machines), retrigger (a repeated key fades the previous voice, as on a piano), unison (the choir is one sampled voice in a five-voice ensemble), and aliases.

Installing

install::install_spec builds the pack’s PackIndex and fetches its files:

  1. Sources. A GitHub source is read from raw.githubusercontent.com at the pinned commit; when an instrument is given as a file glob, the repository is also listed through GitHub’s tree API, which gives each file’s size. For an archive it downloads the file into the cache, checks its size and SHA-256 against the registry, unpacks it with the system’s tar (bsdtar reads zip, 7z and tar; GNU systems fall back to unzip and 7z), deletes any symbolic links in it, and strips the prefix. A Strudel map’s _base on a branch of the map’s own repository is moved to the map’s commit (strudel::pin_base), and every file of a curated map must be at a commit.
  2. Instruments. SFZ files are read with sfz.rs: #define and #include (including mid-line includes, which the sfzinstruments mappings use), the <control> <global> <master> <group> <region> inheritance chain, and default controller values; install/from_sfz.rs turns each region into a Zone. File-name sources (install/from_names.rs) are parsed with notes.rs, which reads note names (C4, Db2, Tidal’s As4), velocity and dynamic markers, and round-robin markers out of names. Strudel maps go through install/from_map.rs, and compose instruments are assembled from the others in install.rs.
  3. Files. Every file the zones use gets a path inside the pack (files/..., sanitised so .. cannot leave the pack folder) and a download URL, or a copy from the unpacked archive. Files no zone uses are dropped, and files on disk the new index does not reference are deleted, so updating a pack also cleans it.
  4. Downloads run on 8 threads. Each goes to <file>.part, resumes with an HTTP range request when a .part is already there, is checked against the expected size when the listing gave one (and against Content-Length always), and is renamed into place, so an interrupted install leaves only .part files and resumes on the next run. A Git LFS pointer is recognised and fetched again from media.githubusercontent.com. Failures are retried three times with backoff, except 4xx answers other than 429.
  5. FLAC. With flac, a WAV is encoded losslessly with flacenc as it arrives (float WAV becomes 24-bit).
  6. The index is written to index.json.tmp and renamed, so a pack is either installed or not.

A lazy pack stops after step 3 and records each file’s URL in its index; the player downloads a file the first time a note needs it. add_sample_map builds a lazy user pack from any Strudel map, taking the licence from the map’s _license or GitHub’s licence detection.

The index

<packs>/<pack>/index.json is a PackIndex: the pack’s metadata, its files (path, URL, size, whether to convert to FLAC), and its instruments. An instrument has one or more variants (the :2 in tr808:sd:2), and a variant is a list of Zones:

zone fieldmeaning
file, rootthe sample and the MIDI pitch it was recorded at (fractional for detuned regions)
lokey..hikey, lovel..hivelthe keys and velocities it answers
xfin, xfoutvelocity crossfades, with SFZ’s power curve so overlapping layers keep constant power
seq_len, seq_posround robin: the cycle length and this zone’s place in it
lorand, hirandrandom alternatives
gain_db, pan, offset, end, keytracklevel, balance, sample range, how much the pitch follows the key
loop_mode, loop_start, loop_endsustain loops
attack, hold, decay, sustain, releasethe amplitude envelope, when the zone overrides the instrument’s

The installed index is the only thing the player reads, so a pack can be built by hand: tests/offline.rs writes a pack of sine waves and an index, and plays it.

Names

resolve::resolve_in(installed, name, bank) turns a name into an InstrumentRef (pack, instrument, variant), in this order:

  1. with a bank, the bank’s pack (by name or alias) and the instrument in it, by name, alias or drum synonym; or a Strudel-style instrument named <bank>_<sound> in any pack;
  2. pack:instrument[:variant], or bank:sound when the first part is a Strudel bank;
  3. friendly aliases (ALIASES), each a list of candidates in order of preference, the first installed one winning: piano is salamander:piano, else splendid:piano, else freepats:upright_piano, else piano-lite:piano;
  4. bare drum names, in the first kit of KIT_PREFERENCE (tr909, tr808, muldjord, tr707, uzu) that has them;
  5. exact instrument names and per-instrument aliases, default packs first, then user packs, then opt-in packs;
  6. a drum synonym in any pack, a Bank_sound name, and finally a pack name alone (its first instrument).

notes::drum_canonical maps drum synonyms to canonical names (kick, kd, bassdrum to bd), which is how s("kick").bank("808") finds the 808’s bd. Resolution is cheap and runs every time a script names a sound; the installed indexes are cached per process (refresh() forgets them). set_disabled(true), which --synth-only calls, makes installed() return no packs, so every name falls through to the synthesizer.

The player

Instrument::render(notes, len) renders NoteOns (start and gate in output samples, a fractional pitch, a velocity) into a 48 kHz stereo buffer:

  • Zone selection (select): the zones that cover the key and velocity, with round robin counted per key (so repeated notes alternate samples) and random alternatives drawn from a hash of the note’s start, index and key, not a random stream, so the same notes always give the same audio.
  • Pitch is (pitch − root) × keytrack semitones. The source’s sample rate is folded into the same step, so a 44.1 kHz sample played a fifth up is interpolated once, not resampled and then pitched. Interpolation is a Kaiser-windowed sinc with 16 zero crossings a side, widened when reading faster than one source sample per output sample, so pitching up filters out what would alias.
  • Level. Velocity picks layers and, through veltrack, scales the level as (v / 127)^(2 × veltrack) (or a vel_curve). Each instrument is normalised once, so that the loudest zone nearest middle C peaks at −6 dBFS at full velocity; that keeps instruments from different libraries at comparable levels before the mixer balances them.
  • Envelope and loops. Attack, hold, decay, sustain and release, with sustain loops for zones that loop while the key is held. one_shot instruments ignore the gate.
  • Cuts. mono instruments fade the previous voice in 4 ms when a new note starts; retrigger instruments fade a repeated key’s previous voice over 120 ms; render_choked cuts every voice sounding at a choke time in 8 ms (the open hat under the closed hat).
  • Unison. An instrument with unison plays every note as several copies, detuned, panned and staggered by up to delay_ms, at 1/√n gain each: the choir is the solo voice five times, ±14 cents, 35 ms apart at most.
  • Speed. Voices render in parallel batches of 64 and are summed in a fixed order.

Decoded samples come from the cache in decode.rs: WAV through hound (keeping its loop points), everything else through claudius-decode, at the file’s own rate, with the silent tail trimmed and a short fade. The cache is shared by the whole process and bounded by CLAUDIUS_PACKS_CACHE_MB (3072): over budget, the least recently used samples that no voice holds are dropped until it is at 80 %. Concurrent requests for one file wait for a single decode.

Credits

credits_for(names) resolves the instrument names a song plays, collects their packs, and writes the packs that require attribution first, then the rest as courtesy credits, then a warning for packs with an unknown licence. A credit line is the pack’s attribution, its licence when the attribution does not already name it, and the licence URL. The engine calls it with the instruments of a project’s tracks, so a song credits what it used and nothing else.

Checking a pack

claudius-packs audition renders every instrument (an arpeggio and a held chord for pitched instruments, a few hits for drums, a beat for every kit with bd, sd and hh) and checks each pitched one for octave mistakes in its mapping: a correctly mapped harmonic sound has nothing at half its fundamental, and its fundamental is not tens of dB below the octave above. That is how a mapping at written pitch shows up; the registry corrects the ones it knows with octave (the piccolo, xylophone and glockenspiel, the growly bass, the solo voice, the drawbar organ). Tubular bells are skipped, since a chime has no partial at its nominal pitch.

Adding a pack

  1. Read the licence where the samples are published, and quote it in licence_note. A pack enters the default set only if its licence allows making and publishing music with it without conditions beyond attribution; 0007 Which samples we ship has the rules and the cases that were refused.
  2. Pin the source: a commit for GitHub (rev); for an archive its size and sha256 (shasum -a 256 <file>); for a Strudel map, its URL at a commit, and map_base at a commit when its _base names a branch of another repository. every_download_is_pinned checks the registry.
  3. Write the instruments, start with claudius-packs install <name>, and listen with claudius-packs audition /tmp/audition --pack <name>, fixing the mapping until the audition reports no problems.
  4. Check the names: claudius-packs resolve and aliases, and whether the new pack should be a candidate in ALIASES or KIT_PREFERENCE.
  5. Run cargo test -p claudius-packs, and the network test with cargo test -p claudius-packs -- --ignored.

Server, sessions and MCP

crates/claudius-server wraps the engine in a session that many clients share: the studio window, browser tabs, assistants over MCP and anything that speaks HTTP. 0005 One session, many clients records why there is exactly one.

filewhat it holds
lib.rsserve (on the caller’s runtime) and spawn (on a thread with its own runtime, for the native window), and what a taken port says
app/mod.rsApp and its Session: state, versions, waiting for changes
app/commands.rsevery Command: edits, undo and redo, load and save, analysis
app/jobs.rsthe long jobs: render and judge, mutate, evolve, save, install packs
http.rsthe routes, the guard (host names, token), CORS, security headers
mcp.rsthe MCP tools, the backends they run on, the stdio and HTTP transports
views.rsblueprints and verdicts as claudius-api types, and the text summaries the MCP tools return

The session

An App holds one Session behind a mutex:

fieldwhat it is
versionbumped by every change to anything below
song_versionthe version at which the song last changed
bp, viewthe blueprint, and its SongView (built once per change, not per request)
undo, redostacks of whole blueprints with labels, at most 200
renderthe latest finished render’s RenderView and the paths of its stems
verdict, loudnessits verdict, and each track’s loudness in the master
busy, progress, job, rerenderthe running job, and whether a render was asked for meanwhile
pending_savesthe folders saves asked for during a job, all written from one render when the job ends
logthe last 200 lines

Four primitives keep it consistent:

  • lock() takes the session mutex for a moment, to read or swap state, never across a script or a render; a poisoned lock is recovered rather than propagated, since the state is valid after any panic in a job.
  • update(f) runs f on the session, bumps version, and sends the new version on a tokio::sync::watch channel, which wakes every long poll.
  • try_update(f) is update for a change that may be refused: when f returns an error it must have left the session as it was, and there is no new version, so nobody waiting on the state wakes for nothing. A failed undo or redo and a job refused as busy go through it.
  • The edit mutex serialises edits. Scripts take milliseconds to seconds (cutting a one-shot from an Apple Loop the first time, for instance), and two edits must not both start from the same blueprint.

Edits

Every edit works on a copy and commits only on success:

  • run_script(code) runs the script on a copy of the blueprint. If the copy did not change, the reply is the script’s log (or ran; nothing changed) and no undo step is made. If it changed, it is committed with a label for undo: the caller’s (tempo 124, mix bass: level: -2, duck: 0.6), or one made from the script’s first line.
  • set_tempo, set_key, set_swing, set_progression, set_drum_pattern, set_part and set_mix build script code (every string through pattern::quote) and go through run_script, so the studio’s controls and scripts validate the same way and fail with the same messages.
  • The other edits (set_section, add_section, remove_section, move_section, toggle_layer, set_drum_step) change the blueprint directly in edit_with, which also checks the song’s size and commits only when something changed.

commit pushes the old blueprint and the label onto undo, clears redo, rebuilds the view, logs the message, and then after_change sets song_version to the new version and, unless the session runs with --no-auto-render, starts a render. Undo and redo swap whole blueprints between the stacks; a blueprint is tens of kilobytes, so 200 of them cost a few megabytes, and undo can never fail to restore exactly what was there.

Versions and waiting

state_after(since, timeout) returns at once when version > since, and otherwise waits on the watch channel until it is, or until the timeout. settled(timeout) waits until is_settled(): no job is running, and either there is no song, or the render is of the current song (render.version >= song_version), or auto-render is off and no render is pending. The HTTP API exposes both as GET /api/state?since=N and ?settled=1; the MCP tools wait on settled after every edit.

A render belongs to the song version it started from, so a client can always tell a stale render from a current one, even while the next render runs. Audio URLs carry that version (/api/audio/12.mp3), so a URL always means the same audio, until the session prunes it.

Jobs

Renders, mutations, evolutions, saves and pack downloads are jobs. A pack download that ends passes its outcome to claudius_server::REPORT, the hook through which claudius-cli counts it in its usage statistics, which send nothing unless the person turned them on (privacy). start_job (and, for install_packs, which needs no song, take_slot alone) takes the job slot through try_update, so a refusal (busy (rendering); try again when it is done, or cancel it) changes nothing, not even the version; it marks the session busy and spawn_job runs the job on a std::thread, catching panics (the job failed (internal error)). When the job ends, finish_job decides who has the slot next, in this order:

  1. waiting saves (pending_saves) take it straight away, without clearing the busy state, so nobody sees the session idle in between and settled waits for the save too; its render, of the song as it is then, also serves as the fresh render;
  2. otherwise the busy state is cleared and, if the render is stale (a render was asked for during the job, or auto-render is on and the song changed), a render starts 100 ms later;
  3. otherwise the session is idle.

That is also the debounce: an edit starts a render job that first sleeps 200 ms; edits arriving during the sleep or the render only mark the render stale, and the job’s end starts one more render for all of them. A burst of edits made while a render runs costs one more render, not one per edit, and the last render is always of the final song.

save(path) checks the folder against the file policy, writes blueprint.json and song.rhai at once (a write error is the reply), and then either starts a render job for the folder or, when a job holds the slot, adds the folder to pending_saves and answers saving to <folder>: blueprint.json and song.rhai now, the audio once the current job (rendering) is done. Every save made during one job gets its folder, all from the same render. If the job ended between the two steps, the save tries again to start, so it is never left waiting for a job that is gone.

render_job:

  1. takes the blueprint and song_version under the lock, and arranges and renders outside it, with stems when they were asked for or the render is for a save;
  2. judges the render, with the ears when the background loader has finished (the session loads CLAP only, not the AudioSet tagger);
  3. writes the master to <cache>/renders/<process id>/<version>.wav, and stems to <version>/<track>.wav as 24-bit WAVs;
  4. builds the RenderView: URL, length, loudness, section times, a 1000-point waveform, stem names;
  5. computes each track’s loudness in the master (its measured level plus its gain plus the master’s gain from pre-master to final);
  6. for a save, writes the whole song folder from this render with song::write_folder, the function the CLI uses, so the score in it is the song its audio plays;
  7. publishes the render unless a newer one already landed, logs rendered in 7.7 s: -10.1 LUFS, verdict 98.0 — ... and, for a save, saved <folder> or could not save <folder>: <reason>, and deletes renders older than the last ten.

mutate_job and evolve_job run the searches described in Evolution and analysis, reporting progress through busy, progress and one log line per candidate, and commit the best result as one undoable change. cancel sets a flag those two check between candidates and generations; renders do not check it.

Files

Settings::file_roots is the file policy: None for a claudius only you can reach, config::shared_roots() plus the --files folders for one started with --public, --host beyond loopback, or --files. readable(path) guards load, analyze and remake with config::read_error, which scripts use for .s(path) too, so both refuse a path outside the roots with the same words whether or not it exists. config::write_allowed guards save, and opening a blueprint checks every file it plays. The security model discusses its limits.

Starting up

App::new creates its render folder, deletes the render folders of sessions that last wrote more than 12 hours ago, opens the Apple Loops library and the user library (unless --synth-only or --no-library), and, unless --no-ears, loads earshot’s models on a background thread, logging ears ready (<seconds> s): verdicts now include earshot's listening or why not. --synth-only also turns the sample packs off for the whole process (claudius_packs::set_disabled), before the session starts, so nothing resolves to a sampled instrument. The session answers from the first moment; verdicts made before the ears are ready have none.

HTTP

http::router(app, options) builds the axum router:

request ─▶ CORS ─▶ security headers ─▶ guard ─▶ routes: /api/..., /mcp, the web studio or the no-UI page
  • CORS is the outermost layer, so preflight requests are answered before the guard asks for a token. Allowed origins are http://localhost, http://127.0.0.1 and http://[::1] on any port.
  • Security headers go on every response, including the guard’s refusals: X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none'.
  • The guard refuses a Host that is not a loopback name unless the server is --public, then, with a token, accepts a matching Authorization: Bearer, claudius_token cookie or ?token= (which also sets the cookie, with Secure when X-Forwarded-Proto says https), comparing in constant time.
  • Commands must be application/json and at most 1 MiB, and run on spawn_blocking, since an edit can take seconds.
  • Audio and stems are served with tower-http’s ServeFile, which handles Range; an MP3 is encoded with ffmpeg on first request, and the WAV is served in its place without ffmpeg.
  • The web studio is served from its dist folder with index.html as the fallback for any other path; without one, a short page says how to build it.

serve binds the listener, records the address it got as the session’s base URL, which the MCP tools put in front of audio links, and only then calls back with the address, so claudius serve never prints an address it did not get. spawn does the same on a thread with its own multi-threaded runtime, because claudius studio must give the main thread to the window. Both turn a taken port into port <port> is taken, by another claudius or another program (--port picks another). The server takes its token from Options; for claudius serve --public without --token, the CLI makes one first (new_token: 16 bytes from getrandom, in hex) and prints it with the address.

HTTP API documents the routes and types for clients.

MCP

mcp::Studio is an rmcp server handler whose tools are methods marked #[tool]; their parameter structs derive JsonSchema, so the schemas clients see come from the same types, including each field’s doc comment. The handler’s instructions tell the assistant the typical session (look, edit with scripts, listen, undo what got worse, save) and that a person is listening.

The tools run on a Backend:

  • Local: the App in this process. Commands run on spawn_blocking; state and waiting call the App directly.
  • Remote: a claudius in another process, reached through its HTTP API with ureq: POST /api/command (600 s timeout, for jobs that start slowly), GET /api/state, GET /api/state?settled=1&timeout=N, /api/script and /api/sounds, with the token as a bearer header.

mcp::probe(base, token) asks /api/hello with an 800 ms timeout and returns a Probe: Claudius for a success whose body names "claudius", NeedsToken for a 401 whose body is claudius’ token refusal, Nothing for anything else. claudius mcp picks Remote on Claudius, fails on NeedsToken (the claudius at <url> asks for a token: pass --token or set CLAUDIUS_TOKEN (a wrong one is refused too)), and on Nothing fails when --url was given or runs Local with a web server of its own. claudius studio uses the same probe to open the window on a running claudius or to explain that it cannot send a token, and claudius doctor reports anything but Nothing as a claudius running on the port. The HTTP endpoint at /mcp always uses Local on its server’s App.

Two helpers shape every tool:

  • edit(command, listen) sends the command; on success, with listen, it asks for a render (a no-op when the render is current) and waits up to 240 s for the session to settle, then returns the reply and describe(state): the song summary, the verdict and the audio link.
  • job(command, timeout) remembers the log, sends the command, waits for the session to settle (240 to 300 s), and returns the log lines written since, the candidate scores of a mutation or evolution, followed by the description, or a note that the job is still running.

The transports: serve_stdio(backend) for claudius mcp, where stdout carries the protocol and everything else goes to stderr; and http_service(app, public), rmcp’s Streamable HTTP service with a local session manager, nested at /mcp. rmcp’s own allowed-hosts check is turned off for --public servers, since the outer guard has already decided.

Views

views.rs turns the engine’s documents into the wire types. Most of it is direct, with two places that encode decisions:

  • A part’s presets (what the studio offers to switch to) are the genre’s entries, every alternative of an a|b entry that exists here, then the synth presets suited to the role, then installed instruments suited to it (instruments_for: upright and electric bass for a bass, pianos, Rhodes, Wurlitzer and organs for chords, strings, choir and brass for pads, …).
  • summary and verdict_text write the song and the verdict as the plain text the MCP tools return. They are the assistant’s eyes, so they list every section with its flags and layers, every track with its sound, mix and measured loudness, and up to eight issues with their targets.

The wire contract

claudius-api holds every type that crosses HTTP: State, SongView, RenderView, VerdictView, Command, Reply and the rest. It depends only on serde, so the web studio compiles it to WebAssembly. New fields get #[serde(default)], so an older studio still reads a newer server’s state and the other way round, and Command is a tagged enum ({"cmd": "set_tempo", ...}), so a client sending an unknown command gets a clear 400 instead of a silent no-op.

tests/session.rs runs a session through new songs, renders, scripts, solo, undo and redo, the file policy (with probes for files outside it), the HTTP guards, MCP over HTTP, and a save made during a render; it also pins claudius_api::{ACCENT, GHOST}, the studio’s accent and ghost marks, to the engine’s step-string thresholds claudius::script::{STEP_ACCENT, STEP_GHOST}. See Testing.

The studio UI

crates/claudius-ui is the studio: one egui application that runs as a native window (claudius studio, or the standalone claudius-ui program) and as a WebAssembly app in the browser, served by claudius serve at /. It is a client of the HTTP API and nothing else: it depends on claudius-api for the wire types, never on the engine. 0008 egui for native and web records why. The studio shows it from the user’s side.

The studio is still being finished; this page describes its structure, which is settled.

filewhat it does
lib.rs, main.rsrun_native, start_web, the claudius-ui program’s arguments
app.rsStudio, the eframe::App: state, layout, shortcuts, applying server state
net.rsthe connection: long polling, commands, the backend (a server or the mock)
edits.rsedits applied to a SongView, for optimistic updates and for the mock
cmds.rsbuilders for commands that change a few fields
panels/one module per panel
audio/the player: rodio natively, an HtmlAudioElement on the web
mock/a built-in stand-in for the server, with a generated song and a rough preview synth
music.rsthe theory needed to label things: keys, numerals as chord names, note names
theme.rs, widgets.rsthe palette (one colour per role and section kind), knobs, faders, meters, chips
mark.rsthe logo’s C, drawn at the size the header shows it, from the numbers scripts/icons.py draws the icons with
devplan.rsscripted runs of the native window, for checking the UI without a person
native.rsNative, what the native window may do on its computer beyond the server (connect assistants)
tour.rsthe first-run tour: its cards, the parts of the studio they point at, and when it is done

Layout

┌───────────────────────────────────────────────────────────────────────────────┐
│ transport: play, position, tempo / key / swing, score                          │
│ toolbar: new song, render, mutate, evolve, undo / redo, save, cancel, busy     │
├───────────────────────────────────────────────────────┬───────────────────────┤
│ arrangement: section blocks, one row per layer with    │ inspector: the        │
│ a cell per section, the waveform and playhead          │ selected section      │
├───────────────────────────────────────────────────────┤                       │
│ dock: step sequencer │ mixer │ parts │ harmony │ script │ verdict: scores,     │
│       │ sounds │ log                                   │ issues, ears, bands   │
├───────────────────────────────────────────────────────┴───────────────────────┤
│ status: connection, server, state version, audio errors, volume, shortcuts     │
└───────────────────────────────────────────────────────────────────────────────┘
panelwhat it editscommands it sends
arrangementsection order; which layers play in which section (click or drag across cells); seekingmove_section, toggle_layer
inspectorthe selected section’s name, bars, energy, variant and flags; adding, duplicating, removingset_section, add_section, remove_section, move_section
step sequencerone row per drum lane, one cell per sixteenth, one variant at a time; click toggles (velocity 0.8), drag paints, shift sets velocity, right-click offers presets; an accent dot and the “(accent)” and “(ghost)” labels follow claudius_api::{ACCENT, GHOST}, the thresholds of the engine’s step stringsset_drum_step, set_drum_pattern
mixera strip per track: pan, sends, duck, fader, mute, solo, with its loudness from the latest renderset_mix
partsstyle, sound, register (drag on a keyboard), density, reseedset_part
harmonywhich progression each section plays; progressions as chord chips, from the key’s diatonic chords or as textset_section, set_progression
scriptthe song as a script, highlighted, Run with Cmd or Ctrl+Enter, errors marking their line, sounds to insert, the referencerun_script
soundsthe packs and every sound name; click one to put it on a partset_part
verdictread only: the total, a bar per score, issues with their targets, the ears, the tonal balance
logread only: State.log, filterable

The shortcuts are Space (play or pause), the arrow keys (pick a section), L (loop the section), Home (to the start), Cmd or Ctrl+Z (undo) and Shift+Cmd or Ctrl+Z (redo), active when no text field has focus.

State, commands and optimism

Studio keeps three copies of the server’s state:

  • state, what is on screen;
  • server, the last state the server sent, without local edits, to fall back on when an edit is refused;
  • pending, the newest state from the server, held back while something might be overwritten by it.

Every frame, the panels draw from state and push Commands into out. flush sends them, and for the edits it can predict (tempo, key, swing, progressions, sections, layers, drum steps and patterns, parts, mix) it first applies them to state itself with edits::apply, the same logic the mock uses, so a click in the sequencer shows at once instead of after a round trip and a render. When the reply comes back refused, the song falls back to server.

A new state from the long poll goes into pending and is applied only when it is safe: not while the user drags something (a fader, a section, a painted row of steps), and not while an edit is in flight or the state is older than a reply already received (Reply.version), for up to 3 s. Without this, a long poll answering between a click and its reply would flash the old value back.

The script editor follows the same idea: while its text has unsaved edits, new states do not replace it; it follows the song’s script again once a run succeeds.

The connection

net::Remote runs one long poll at a time: GET /api/state first, then ?since=<last version>, with a 45 s client timeout against the server’s 20 s. Each answer schedules the next. On failure it backs off 1, 2, 3, then 5 s, shows Cannot reach the claudius server at ... with how to start one (claudius serve, or cargo run --release -p claudius-cli -- serve in a checkout), Retry and Use the demo, and forgets its version and sound list, since a restarted server starts counting again. After the first state it asks once for /api/sounds and /api/hello (and for /api/hello again when a state says the ears have finished loading), and for /api/reference when the reference window opens.

ehttp runs requests on threads natively and with fetch in the browser, delivering results through a channel and asking egui to repaint. Natively the server’s address comes from the argument, CLAUDIUS_SERVER or http://127.0.0.1:7878; on the web it is the page’s own origin, or ?server= in the URL, or the mock with ?mock.

The native window has no way to send a token. claudius studio runs its own server without one; to use a token-protected claudius, open it in a browser with ?token=, which sets the cookie every later request carries.

What the window does on its own computer goes through Native, which claudius studio passes to run_native (connect::FromTheStudio in claudius-cli): reading the assistants’ configurations and connecting one, on a thread of its own so that a slow claude mcp add does not stop the window, keeping the answer about usage statistics that the tour’s last card asks for (privacy), and the updater (update.rs), whose state the studio reads each frame and repaints for while it downloads (updates). None of it goes over HTTP, so neither a page in a browser nor anything else that reaches the server can change those configurations; the web studio, which has no Native, lists the claudius connect commands instead.

Playback

When a new render arrives (render.version changed), the player loads it:

  • Native (audio/native.rs): fetches the 24-bit WAV (/api/audio/<version>.wav), decodes it with hound, and plays it through a rodio source that reads shared atomics for play, seek, loop region, position and volume, so the UI thread never blocks the audio thread. Play and pause ramp the gain over about 5 ms so they never click. The output device is opened once, when the window starts; if that fails the status bar shows audio: no audio output: ....
  • Web (audio/web.rs): an HtmlAudioElement plays the MP3, and currentTime is the playhead.

A new render of the same song keeps the playback position (and the loop region, which the studio sets separately), so you can listen to a section while you edit it and hear each change land in place.

The mock

mock/ plays the server’s part without one: a generated song per genre with a plausible form, patterns, parts and mix; edits through edits.rs; undo and redo with labels; jobs simulated with progress and log lines; a critic that reacts to edits the way the real verdict reads; and a rough preview render, synthesized a slice per frame (no threads, so it runs in the browser). It is how the studio is developed and tested without an engine, and what Use the demo switches to. It is not claudius’ sound.

cargo run -p claudius-ui -- --mock          # the native studio on the demo song
CLAUDIUS_MOCK=1 cargo run -p claudius-ui

On the web, ?mock in the URL does the same.

Scripted runs

CLAUDIUS_UI_PLAN drives the native window from a list of steps separated by ;, so a change to the UI can be checked, screenshotted and repeated without a person at the screen:

CLAUDIUS_UI_PLAN='wait:4;shot:/tmp/a.png;tab:mixer;play;wait:2;shot:/tmp/b.png;exit' cargo run -p claudius-ui -- --mock

The steps: wait:<s>, tab:<name>, section:<i>, play, pause, seek:<s>, newsong, close, tour[:<step>], assistants (the Connect an assistant dialog), cmd:<command JSON>, size:<w>,<h>, script:<code> (into the editor, \n for a newline and %3B for ;), run, reference, audiopath:<file>, shot:<file.png> (the window as rendered), exit, and synthetic pointer input in points: move:x,y, click:x,y, rclick:x,y, drag:x1,y1,x2,y2, sdrag:x1,y1,x2,y2 (with shift). A scripted run starts the first-run tour only when a tour step asks for it, so that the screenshots of other steps do not have its card in them.

Building

Natively, the studio is part of the claudius binary (the studio feature, on by default) and of claudius-ui. For the web, trunk builds it into crates/claudius-ui/dist:

cargo xtask web                       # trunk build --release, with relative asset URLs

Trunk.toml builds index.html with relative URLs (public_url = "./"), so the same dist works at / of any claudius and from a static file server, and hashes file names so a browser never runs a stale build. CI runs clippy for wasm32-unknown-unknown separately, because the native clippy run never sees the code behind cfg(target_arch = "wasm32"). Preferences (the open tab, volume, mutate and evolve settings) persist through eframe: natively in claudius-studio/app.ron under the platform’s data folder, on the web in local storage.

Testing

claudius makes music, and most of what can go wrong with it is audible rather than a crash: a hat that moved, a song that came out 3 dB too loud, a script that stopped printing back into itself. The tests are built around that. Some check laws (a printed song changes nothing when run), some check measurements (a render lands within 1 LU of its target), some freeze the generators so that a change to the music shows up as a readable diff in review.

Running them

cargo xtask ci

runs what CI runs on every pull request: cargo fmt --all --check, cargo clippy --workspace --all-targets --locked -- -D warnings, clippy again on the builds that leave features out (the studio without the ears, the engine and server without the ears) and a check of the CLI without the studio window, so that code behind cfg(feature = ...) keeps compiling, then the tests (with cargo-nextest when it is installed, cargo test otherwise), the doctests, and the examples in the docs. The dev profile compiles the workspace at opt-level = 2 and dependencies at 3, because many tests render audio, so expect a long first build and fast test runs after it.

One kind at a time:

commandwhat it runs
cargo test -p claudiusthe engine’s unit tests and every file in crates/claudius/tests/
cargo test -p claudius --test scriptone of them (pipeline, theory, script, docs_examples, pattern_laws, generators)
cargo test -p claudius-serverthe session, HTTP and MCP tests
cargo test -p claudius-clithe binary end to end, and the connect unit test
cargo test -p claudius-packsthe packs’ unit tests and the offline pack test
cargo test -p claudius-packs -- --ignoredthe network test: real downloads
cargo test -p claudius-decodethe decoder
cargo test -p claudius-uithe studio’s tests: the native player, the mock, the script editor’s highlighting and error lines, numeral labels
cargo test -p earshotearshot’s fast parity checks, no models
cargo test -p earshot --release -- --ignored --nocaptureearshot’s model parity checks (downloads the models)
cargo +nightly fuzz run scripta fuzz target, from fuzz/
cargo bench -p claudiusthe benchmarks

What each kind protects

Unit tests

Every crate keeps small tests next to its code: the mini-notation parser in pattern/mini.rs (sequences, alternation, weights, euclid, readable errors), velocity crossfades keeping constant power in claudius-packs/src/model.rs, name resolution with banks and aliases in resolve.rs, SFZ inheritance, Strudel maps, WAV to FLAC being lossless, connect editing a config file without losing other servers and refusing broken JSON, the decoder reading WAV at its own rate and explaining what it cannot read. They pin down the behaviour of one function, so a failure points at it.

tests/pipeline.rs

The engine end to end, on signals with known answers:

  • every genre composes and arranges, every section’s layers name real tracks (or the genre’s optional parts), and a blueprint survives a round trip through JSON unchanged;
  • a short house song renders with every sample finite, a true peak at most 0.05 dB over its ceiling, a loudness within 1 LU of its target, and a verdict over 50;
  • the beat tracker reads click tracks at 92, 124, 140 and 174 BPM within 1 % (half or double counts);
  • chroma finds G major in I IV V I played as sine triads;
  • loudness reads −3.0 LUFS (±0.2) for a −3 dBFS 1 kHz sine, the BS.1770 reference.

tests/theory.rs

Keys parse and print; Roman numerals follow the key’s mode (VI in A minor is F) and round-trip; chord symbols round-trip; voicings stay in range, use only chord tones and move at most 24 semitones over three changes; Markov progressions stay diatonic; euclidean rhythms spread their onsets; templates keep their certain hits; every bass style lands on the chord’s bass note at each change (except acid), and a lead stays in key and in range.

tests/script.rs

The script language:

  • patterns built from code have the right events, and keep their code, closures included;
  • the round trip: songs in five genres print as scripts that, run on the same song, change nothing; and a pattern-built song does the same;
  • pattern tracks play in their sections with exact note counts per sound, voiced in range, with the right roles;
  • edits on a generated song do what they say, and a change of mode moves the progression’s numerals;
  • every example in the script reference (script_reference.md, which claudius reference prints and assistants read) runs;
  • sampled instruments and drum machines resolve and render, when packs are installed (skipped otherwise);
  • the sandbox: dense patterns, deep nesting, long strings, huge repeats and euclids, infinite loops, import, eval, songs over 512 bars or 20 minutes, and songs with more tracks than a render can hold all fail with the right message;
  • errors say what to do (unknown sound 'foo' lists the synths, cannot read key, no part 'nope').

tests/docs_examples.rs

Every fenced js block in docs/src and in the README runs against a house song with seed 9, so the book cannot drift from the language. The info string decides what is expected: js must succeed, js error must fail, js packs runs only when packs are installed, js no-run is not run. Lines starting with ~ run but are hidden on the page. A block passes when the script succeeds and, if it leaves a song, the song arranges into at least one track; it is not rendered. A failure report starts with <n> of <m> examples failed: and gives, for each, docs/src/<file>:<line>:, the error and the example’s code, the line being that of the opening fence. Writing docs explains how to write examples.

tests/pattern_laws.rs

Properties that hold for any pattern, checked with proptest on 256 generated cases each, from mini-notation built out of the pieces people write (bd, ~, cp:2, numbers, *2, /2, !, ?, @2, (3,8), (5,8,2), nested in [ ], < >, stacks and [ ]*2):

  • the parser never panics, on any text up to 120 characters;
  • events start inside the queried window, in order, with positive, finite lengths;
  • querying [0, 1), [1, 1.5) and [1.5, 3) gives the events of [0, 3);
  • fast(k).slow(k), late(t).early(t) and rev().rev() give back the original;
  • randomness (sometimes, degradeBy) gives the same events every time;
  • degradeBy(0) keeps everything and degradeBy(1) nothing;
  • a pattern’s printed code evaluates to the same events.

When a case fails, proptest shrinks it to a small counterexample and saves it in tests/pattern_laws.proptest-regressions, which is committed so the case keeps running.

tests/generators.rs

A snapshot (with insta) of what each genre’s generator writes for seed 42: tempo, key and swing, the progressions, every section with its flags and layers, every drum variant as a step string, every part, the mix of every track. Numbers are rounded, so floating-point differences between platforms cannot fail it, and the test points CLAUDIUS_PACKS at an empty folder, so the snapshots describe the generators and not what is installed. A second test checks that the same seed gives the same blueprint and the same project.

Any change to theory, genre profiles or the composer shows up here as a diff a reviewer can read: “the house hats moved”, “trap’s progression changed”. When the change is intended:

cargo test -p claudius --test generators     # fails and writes tests/snapshots/*.snap.new
cargo insta review                           # shows each diff; accept the intended ones

(cargo install cargo-insta provides the command.) Commit the updated .snap files with the change, and say in the pull request how the music changed.

crates/claudius-server/tests/session.rs

  • a session makes a song, settles with a render over 60 s long and a verdict over 50, takes a script (tempo and solo), refuses a bad progression, and undoes and redoes back to the right tempo;
  • a session limited to one folder refuses to load, save or play files outside it, including inside/../escape, and answers a missing file outside it exactly as an existing one, while a missing file inside is does not exist;
  • the HTTP guards: security headers, 403 for a foreign Host, 415 for a command that is not JSON, 400 for an unknown command, 401 without the token, 200 with ?token= (and a cookie) or a bearer header;
  • MCP over HTTP: initialize, list the tools, make a song, get a tool error for an unknown sound, read the song back as a script;
  • a save asked for while the new song’s render runs waits for it, then writes the whole folder, stems included;
  • the studio’s accent and ghost thresholds (claudius_api::{ACCENT, GHOST}) equal the engine’s step-string ones.

These tests run real renders with the library and the ears off, so they take tens of seconds.

crates/claudius-cli/tests/cli.rs

The claudius binary as people and assistants run it, with CLAUDIUS_PACKS pointed at an empty folder so the result is the same on every machine: the help names the commands, genres and reference print, a script from stdin prints its song, a broken script fails with its reason on stderr, connect print prints valid JSON, make writes a song folder with a verdict over 50, and MCP over stdio initialises, lists tools, runs a script and reads the song back.

Packs, the decoder and earshot

claudius-packs/tests/offline.rs builds a pack of sine waves by hand (two velocity layers at two pitches, a drum with two variants), installs nothing from the network, and checks resolution, layer selection, pitch, variants and credits. A unit test in install.rs, pack_names_stay_inside_the_packs_folder, holds remove to plain pack names. tests/network.rs is ignored by default: it installs tr707 and a Strudel map into a temporary folder and plays them, and checks that an opt-in pack refuses to install without consent.

earshot’s tests/parity.rs compares the Rust port with the Python earshot on fixtures it wrote (parity/fixtures/, made by parity/make_fixtures.py): decoding, resampling, the mel front end, CLAP embeddings (1 − cosine within 3e-10), calibrated z-scores, AudioSet probabilities, realness, measurements, beat grids and search. The model checks are ignored by default because they need the models; inputs that are Apple Loops are skipped where they are not installed. earshot’s README lists every tolerance.

Fuzzing

fuzz/ holds two cargo-fuzz targets, in a workspace of their own because libFuzzer needs nightly:

targetfeedsmust hold
mini_notationany UTF-8 text to mini::parse, then queries what parses over [0, 4) and [−1.5, 0.25)no panic, no hang, no runaway query
scriptany UTF-8 text to script::runno panic, no hang, no memory exhaustion
cargo install cargo-fuzz
cargo +nightly fuzz run mini_notation -- -max_total_time=300

CI runs each target for five minutes every Saturday, keeping the corpus in the Actions cache and uploading any crash. A crash leaves its input in fuzz/artifacts/<target>/: turn it into a regular test before fixing the bug, so it stays fixed. The sandbox limits in Patterns and the script engine exist because fuzzing finds the gaps first.

Benchmarks

crates/claudius/benches/engine.rs (criterion) times generating and arranging a house song, rendering 8 bars, querying a dense pattern for 64 bars, running a script edit and printing a song. Results go to target/criterion, with comparisons against the previous run. CI only checks that the benchmarks build.

The rest of CI

The tests run on Linux and macOS, and on Windows without the ears as an experimental job that reports without blocking. Besides them, every pull request checks: formatting; clippy on the workspace, on the studio for wasm32-unknown-unknown, and on the builds that leave features out; that the Intel Mac release target compiles; that the web studio builds with trunk; that the workspace builds on the minimum supported Rust version (rust-version in Cargo.toml); cargo deny (licences, advisories, sources); spelling with typos; that the book builds and its examples run; that the benchmarks build; the workflows and install scripts pass actionlint, shellcheck and PSScriptAnalyzer; and the website builds, its internal links resolve (lychee, with the book at /docs/), and it passes its smoke test. The only required check is ci ok, which fails when any of them failed or was cancelled. Development setup covers installing the tools.

Tests and the machine they run on

Installed packs, Apple Loops and models change what claudius does, so the tests decide what they see:

  • generator snapshots and the CLI tests point CLAUDIUS_PACKS at an empty folder;
  • tests that need packs skip without them (sampled_instruments_and_drum_machines, js packs examples);
  • the engine’s tests pass no Apple Loops library to generate, and the session tests run with library: false;
  • no test loads earshot’s models except earshot’s ignored parity checks.

Tests that render with packs installed are slower but check the same things. CI has no packs, no Apple Loops and no models, so whatever passes there passes on a clean machine.

Adding a test

  • A bug gets a test that fails before the fix, in the file of the layer where it lives: a parser bug in pattern/mini.rs, a script function in tests/script.rs, a session behaviour in tests/session.rs.
  • A new script function needs a case in tests/script.rs, an example in the language reference (a js block, which docs_examples runs), and, when it changes what a song stores, a check that the song still prints back into itself. Making a change walks through one.
  • A change to the music (theory, a genre, the composer) updates the generator snapshots; review the diff as music and describe it in the pull request.
  • A new pattern method with a law (an inverse, an identity) belongs in tests/pattern_laws.rs, and a new mini-notation feature in the strategy that generates sources there.
  • A new HTTP route or command gets a case in tests/session.rs, through http::router with tower::ServiceExt and no network.
  • Pack handling is tested with a hand-written pack in a temporary folder, as tests/offline.rs does, never with downloads in the default test run.

Security model

claudius runs on your computer, opens audio files, runs scripts written by people and assistants, downloads models and samples, and answers HTTP. SECURITY.md is the summary and says how to report a vulnerability. This page is the full model: who claudius trusts, what it defends against, how, and where the defences end.

What is worth protecting

  • Your files. claudius can read audio and write song folders; it must not become a way for someone else to read or write anything else.
  • Your computer’s resources. Rendering is heavy by nature; nothing outside should be able to make claudius use unbounded memory, disk or time.
  • Control of the session. Whoever can send commands can run scripts, start jobs, open and save files.
  • The integrity of what runs. The models and samples claudius downloads, and the releases you install.
  • The licences of what you publish. A song should not quietly contain samples you may not publish.

Who is who

actortrusted?can reach claudius by
you, at your computeryesthe studio, the CLI, curl
assistants you connectacting for you; their scripts are untrusted codeMCP over stdio or HTTP
programs and users on the same computernot defended against127.0.0.1, like you
web pages in your browsernorequests your browser sends to 127.0.0.1
machines on your networknonothing, unless you listen beyond loopback
whoever holds your tokentreated as youa tunnel or the network, with --public
hosts claudius downloads frompartly: content is checked where it can be
files and blueprints you opennotheir contents

Two of these deserve a sentence each. A local process is outside the model: it can already read your files, and claudius listening on loopback gives it nothing it lacks. A token holder is inside the model on purpose: the token is how you reach your own claudius from a phone or a cloud assistant, so whoever has it is you.

The network

Loopback by default

claudius serve and studio listen on 127.0.0.1 unless --host says otherwise. Listening on another address without a token prints warning: listening on <host> without --token: anyone who can reach this port controls claudius. The server prints its address only once the port is bound; a taken port is an error, port <port> is taken, by another claudius or another program (--port picks another).

Host names: no DNS rebinding

A web page cannot read http://127.0.0.1:7878 directly, but it can point a domain it controls at 127.0.0.1 and talk to claudius as a same-origin page. That attack (DNS rebinding) is stopped by the Host header: unless the server is --public, every request must name localhost, 127.0.0.1 or [::1], and anything else gets 403. The MCP endpoint has rmcp’s own allowed-hosts check as a second layer, turned off for --public servers. The Host check is not authentication: a program can send Host: localhost to a claudius listening on the network, which is why listening beyond loopback takes a token.

Cross-site requests: no forms, no no-cors

Without CORS, a page on another site can still send simple requests (a form post, a fetch in no-cors mode), even if it cannot read the answer. claudius makes every state-changing request impossible to send that way:

  • POST /api/command requires Content-Type: application/json, which no simple request can carry (415 otherwise);
  • the MCP endpoint requires Content-Type: application/json and Accept: application/json, text/event-stream, and every request after initialize needs the mcp-session-id from a response the page cannot read;
  • GET routes change nothing (the one side effect is encoding an MP3 of a render that exists).

CORS then allows reading answers only from pages served by http://localhost, http://127.0.0.1 or http://[::1] on any port, so a local web app can use the API and a remote site cannot. The allowlist includes every local port, which means any local web server’s pages, consistent with not defending against local processes.

Tokens

With a token (--token, CLAUDIUS_TOKEN, or the one --public makes when it is given none), every request must carry it as Authorization: Bearer, as the claudius_token cookie, or as ?token=. A request with ?token= also sets the cookie, HttpOnly; SameSite=Strict; Path=/, and Secure when the request came through https, which tunnels report with X-Forwarded-Proto: https; over plain HTTP on a LAN a Secure cookie would be dropped, so it is left off there. The comparison takes the same time wherever the strings first differ. CORS preflights are answered without the token so a browser can learn what is allowed; the request itself then needs it. A request without the right token gets 401 with this claudius needs its token (Authorization: Bearer <token>, or ?token=<token>), which is how claudius mcp, studio and doctor tell a claudius that wants a token from nothing at all.

What the token does not do:

  • It does not expire or rotate. Restart with a new token to revoke the old one.
  • A ?token= URL lands in browser history and in the logs of the tunnel and any proxy in between. The bearer header avoids that where the client supports it.
  • Anyone with the token can do everything you can through the API, including reading and writing files inside the shared folders below. Treat it like a password: openssl rand -hex 16 makes one of the same strength as the tokens claudius makes.

--public

--public lifts the Host check so a tunnel’s or a LAN’s host name is accepted, and switches on the file policy below. A public claudius always has a token: without --token it makes one from 16 random bytes from the operating system and prints it once the port is bound:

claudius at http://127.0.0.1:7920: web UI at /, API at /api, MCP at /mcp (token required)
shared with a new token: open http://127.0.0.1:7920/?token=<token> (through your tunnel, its address with ?token=<token>), or send Authorization: Bearer <token>. --token sets your own.

Each start makes a new one, so a restart revokes the last. --host with any address but loopback makes one the same way, with or without --public: the Host check refuses browsers, but a program on the network can send Host: localhost, so only the token keeps it out.

Files

The policy

A claudius only you can reach reads and writes wherever your user can, because that is what a local app does. A shared claudius (serve --public, serve --host with an address other than loopback, or serve --files <dir>) checks every path it is given against a list of roots, config::shared_roots() plus the --files folders:

  • Reading (config::path_allowed): the path must exist and, canonicalised (symbolic links and .. resolved), start with one of the canonicalised roots. config::read_error turns a refusal into a message. Used for scripts’ .s(path), load / open (and every audio file a loaded blueprint plays), analyze and remake.
  • Writing (config::write_allowed): the nearest existing ancestor of the path must pass the reading check, and every remaining component must be a plain name, so allowed/../outside and allowed/missing/../../outside are refused. Used for save.

config::shared_roots() is the song folder (~/Music/claudius), claudius’ cache folder, the sample packs folder and the two Apple Loops folders. Sample-pack instruments and Apple Loops one-shots are found by name, not path, and stay available. The command line is not subject to the policy: you are running it.

Its limits

  • Check, then use. The check and the use are separate system calls. A local process that can swap a path for a symbolic link between them can redirect a read or write; local processes are outside the model, and a remote token holder has no command that creates links.
  • Links already inside a root. Writing song.wav into a folder follows a symbolic link already there. If you keep links inside ~/Music/claudius to places you do not want written, a token holder can write through them.
  • Existence. config::read_error says <path> does not exist only for a path that would be inside the roots; any other path gets <path> is outside the folders this claudius may read, whether it exists or not. A token holder learns which files exist only inside the folders it may read anyway.
  • Roots that do not exist yet. Roots are canonicalised, and one that does not exist is left out. Until ~/Music/claudius exists, a shared claudius refuses to save there (<path> is outside the folders this claudius may write), including the default save without a path. This is listed as a known gap below.
  • Unshared mode is open. An assistant connected to an unshared claudius can open and save files anywhere your user can, through open, save and .s(). That is the trust you give it by connecting it; serve --files <dir> fences it in.

Scripts

Scripts come from you, from assistants, from files you open and from printed songs, so they run in a sandbox (script::limits):

  • no file, network or process functions exist; the only file access is resolving .s(path), under the file policy;
  • import finds no modules (DummyModuleResolver) and eval is disabled;
  • 5 million operations, 10 seconds, call depth 64, expression depth 128 (64 in functions), strings of 200,000 characters, arrays of 10,000 items, maps of 1,000 entries;
  • a pattern query may do 4 million steps of work and make 262,144 events; *, /, fast, slow and ply take factors above 0 and up to 256; a track may play 1,024 notes per bar; mini-notation is limited in length (4,096 characters), nesting (64), repeats and euclidean steps (256);
  • a song is at most 512 bars and 20 minutes, with at most 64 tracks and 12,000 track-seconds (20 tracks of 10 minutes), because a render holds every track in memory; this is checked after every script and edit and again before rendering, where a pattern with several sounds counts as a track for each.

The fuzz targets run arbitrary text through the parser and the script engine every week. The event budget is what bounds a query’s memory. track("x", s("bd*100000000")) is refused while parsing, and the whole claudius script process peaks at 33 MB; the densest pattern the speed limit allows, track("x", s("[bd*256]*256")), stops at the event budget with the process at 134 MB.

Pattern tracks in a blueprint are code: arranging a song evaluates them, with the same sandbox and only the pattern functions.

Files and blueprints you open

  • Audio is decoded by symphonia, a Rust decoder, in claudius-decode, which forbids unsafe code itself. When symphonia cannot read a file and ffmpeg is installed, ffmpeg decodes it, and earshot decodes with ffmpeg whenever it is installed, to match the Python reference: C code, run as a separate process with the file name as an argument (never through a shell) and -nostdin. CLAUDIUS_FFMPEG=none turns that off. Every ffmpeg and ffprobe input goes through claudius_decode::ffmpeg_input, which allows only the file protocol and the demuxers of files that hold their own audio. A playlist (.m3u8) or a list of files is refused, because it would make ffmpeg open files the path check never saw, outside the folders of a shared claudius.
  • Decoding is bounded: a file gives at most 30 minutes of stereo at 48 kHz (MAX_SAMPLES, as many samples in other layouts), and a longer one is refused (TooLong) before it fills the memory. ffmpeg is asked for no more than that (-t), and its output is read as it comes.
  • Blueprints are data, with three things in them that act: pattern code (sandboxed, above), file paths (checked by the policy on a shared claudius), and numbers. Lengths are bounded, but not every effect parameter is, so a hand-crafted blueprint can make a render slow or large within the song-length limit. Open blueprints from people you trust as you would open their project files in a DAW.
  • Text that reaches an assistant: song titles, sound names and file names from a blueprint or a file appear in the MCP tools’ answers, where they could carry instructions aimed at the assistant (prompt injection). claudius passes them through as data; the assistant’s host decides what to trust.

Downloads

whatfromchecked
CLAP and AudioSet modelshuggingface.co, at pinned revisionsSHA-256 of every file, pinned in earshot/src/models.rs; a mismatch is deleted and never used
curated packs from GitHubraw.githubusercontent.com at pinned commitsthe commit pins the content; sizes against GitHub’s listing when there is one
curated Strudel mapsthe map and every file it names on raw.githubusercontent.com at pinned commitsthe commit; a map whose _base names a branch is pinned to the map’s commit, or by the registry’s map_base, and an install refuses a file that is not at a commit
tr909, the FreePats organaudiorealism.se, freepats.zenvoid.org (archives)the size and SHA-256 in the registry; a mismatch, in a download or in the cache, is deleted
Strudel maps you addany URL you give, HTTPS or HTTPnothing beyond the length, and at most 1 GiB a file

Archives are unpacked into a scratch folder with the system’s tar (or unzip, 7z); bsdtar and GNU tar strip absolute paths and refuse .. members by default. Every symbolic link in the unpacked tree is then deleted, and the walk over it follows none, because a member named like a sample but linking to ~/.ssh/id_rsa would otherwise be copied into the pack. The members are copied into the pack under sanitised relative paths (.. cannot survive sanitize), so what lands in the pack’s folder stays inside it. A download whose size is not known in advance stops at 1 GiB, and one that sends more than its known size is cut off. A GITHUB_TOKEN in the environment is sent only to api.github.com. Every sample is audio and goes through the decoder above. The packs on an unclear licence are opt-in and refuse to install without --accept-licence.

Releases and the build

  • Release archives are built by the release workflow from main after a reviewed release pull request, with a SHA256SUMS file and a build provenance attestation. The install scripts come with each release and are in its sums, and they check the archive they download against them. gh attestation verify <archive> --repo devlato/claudius --signer-workflow devlato/claudius/.github/workflows/release.yml --source-ref refs/heads/main checks where an archive was built. 0009 Releases through pull requests explains the process.
  • The release workflow runs only on main (and on the hotfix branches, which only admins can push), and on a push only for the merge of a release PR. None of its jobs restores a cache: a cache is written by whatever ran in an earlier job on main, so restoring one would let that code into the archives. The jobs that hold tokens run no cargo, and the tokens are secrets of environments that only main may use. The site is built without secrets and deployed by a job that only uploads it.
  • Third-party GitHub Actions are pinned by commit SHA, checkouts do not keep credentials, and workflow permissions are the minimum each job needs. Tools downloaded in CI (lychee, actionlint, gitleaks, OSV-Scanner) are checked against SHA-256 sums.
  • cargo deny checks every dependency’s licence, advisories and source on every pull request; dependabot proposes updates.
  • Building earshot downloads a prebuilt ONNX Runtime through the ort crate (its download-binaries feature), which is linked statically. That download is a build-time dependency on the ort project’s hosting.

Known gaps

These are open, and listed so that nobody has to rediscover them:

  1. Sample pack archives (tr909, the FreePats organ) and user maps are not pinned by hash; a compromised host could serve different audio. GitHub-hosted packs are pinned by commit.
  2. The token never expires, and a ?token= URL stays in browser history and in proxy and tunnel logs.
  3. Caches do not shrink by themselves: decoded audio and Apple Loops cuts on disk, a session’s last ten renders (other sessions’ are deleted after 12 hours), and the one-shot and warp caches in memory. A token holder who keeps rendering and saving can fill the disk.
  4. cancel stops a mutation or an evolution after the current step but never a render, which runs to its end.

Report anything else privately, as SECURITY.md describes.

About these records

These are claudius’ architecture decision records: one short page per decision that shapes the code and would be expensive to reverse. Each records what the situation was, what was decided, and what that costs, so that someone who wants to change it later starts from the reasons instead of guessing them.

They are not design documents for features. A record is worth writing when a reasonable newcomer would ask “why on earth is it done this way?”, and the answer is a trade-off rather than a mistake.

recordin one line
0001 Real audio onlyevery sound is synthesized in code or recorded; no generated audio, no General MIDI
0002 Rust end to endengine, server, UI, ears and decoder in one language, no Python at runtime
0003 The blueprint is the songa song is stored as its decisions; the arranged notes are derived
0004 Rhai with Strudel-style patternsan embedded, sandboxed language with live-coding patterns inside strings
0005 One session, many clientsthe studio, browsers and assistants all edit one song
0006 A critic with earsmeters, theory and a calibrated CLAP model judge every render; some evidence is held out
0007 Which samples we shipnone: curated packs are downloaded from their authors, and only clear licences are on by default
0008 egui for native and webone immediate-mode UI compiled natively and to WebAssembly, talking only HTTP
0009 Releases through pull requestsa release is a reviewed pull request; merging it builds and publishes

The format

Each record has a status and three parts:

  • Context: the forces at play when the decision was made: what claudius needed, what constrained it, what the options were.
  • Decision: what was decided, stated plainly, and the options that were rejected, with why.
  • Consequences: what follows, good and bad. The bad ones matter most; a record that lists only benefits is a sales pitch.

A status is accepted, superseded by NNNN, or deprecated. Records are not edited to match later thinking: when a decision changes, a new record supersedes the old one, and the old one gets a line pointing to it. Fixing a typo or a broken link is fine.

Adding one

  1. Copy the shape of an existing record into docs/src/design/NNNN-short-title.md, with the next free number.
  2. Write it while the decision is being made, in the pull request that implements it or before. Half a page to a page is enough; link to the code and the engineering pages for detail.
  3. Add it to docs/src/SUMMARY.md under “Design decisions” and to the table above.
  4. If it replaces an earlier record, set that one’s status to superseded by NNNN.

Use the pull request title docs(design): NNNN short title, so the record shows up in the changelog.

0001 Real audio only

Status: accepted

Context

claudius is meant to make songs the way a producer does in GarageBand or FL Studio, and to make them sound like a producer’s work. There were four ways to get sound:

  • Generated audio: a neural model that outputs waveforms from a description. It can sound impressive, but the result is a finished recording: there is no kick to move, no bass line to rewrite, no mix to rebalance. Its provenance depends on training data nobody can audit, and it needs a large model and, in practice, a GPU.
  • General MIDI: notes played through a General MIDI or SoundFont set. Every note stays editable, but GM sound sets sound like GM sets, and the free SoundFonts that sound better often have unclear licences.
  • Synthesis only: drums and synths rendered in code. Fully editable, small, deterministic, licence-free. But earshot’s ears, and people, hear a fully synthesized song as only marginally “professionally mixed”; a real piano, strings or a recorded 909 make a large difference.
  • Recordings: multi-sampled instruments, drum machines, loops. They sound real, cost disk space, and each set comes with a licence.

Decision

Every sound in claudius is real audio: either synthesized in code (13 drum voices, a subtractive and FM synth engine with 20 presets, risers and impacts) or recorded (sampled instruments and drum machines from sample packs, Apple Loops where GarageBand or Logic installed them, chops of songs you give it, any file a script names). Notes exist only as the score that plays those sounds, like a piano roll.

The model enforces it: model::Sound has a variant for each kind of audio source and none for anything else. Synthesis is always available as the fallback, so a song can be made with nothing installed, and recordings replace it where they exist (a genre’s "piano|epiano" preset plays the sampled piano when its pack is installed).

Generated audio was rejected because it cannot be edited at the level a producer works at, its output cannot be attributed, and its runtime cost dwarfs the rest of claudius. General MIDI was rejected for its sound, and because the packs claudius uses are mapped per instrument with velocity layers and round robins, which GM sets rarely have.

Consequences

  • Everything in a song stays editable and explainable: every note has a pitch and a time, every sound a name, every level a reason. Mutation, scripts and the critic all depend on that.
  • Rendering is deterministic: the same blueprint and the same installed sounds give the same audio.
  • The good-sounding version needs a download: about 2.2 GB of sample packs (1.2 GB on disk), and the sound of a song depends on what is installed, so the same seed sounds different on a machine without packs.
  • Sounds arrive at wildly different levels and timbres, so claudius needs an automatic mixer that balances every track to its role’s loudness, a mastering stage, and a critic to check the result (see Rendering).
  • Sampled instruments have the limits of their recordings: the default piano keeps 5 of its 16 velocity layers for size, the only clearly licensed choir is built from one sung vowel, and there are no vocals with words.
  • Licensing becomes claudius’ problem: which packs it may download by default, what credits a song needs. That is 0007.
  • claudius cannot make sounds that exist only as recordings it does not have, and will never “imagine” a sound. That is the point, and also a real limit next to generated audio.

0002 Rust end to end

Status: accepted

Context

claudius has to do several different jobs well:

  • render a four-minute song of a dozen tracks, with convolution reverb and a mastering search, in seconds, many times over during evolution;
  • parse audio files, scripts and blueprints that come from other people and from assistants;
  • serve a studio natively and in a browser, an HTTP API and MCP, from one session;
  • listen: run CLAP and the AudioSet tagger, which earshot, the library that calibrates them, does in Python with PyTorch.

It also has to install as a program a musician downloads and runs, not as a development environment.

Decision

claudius is written in Rust throughout, as one Cargo workspace:

  • the engine (DSP, theory, rendering, the critic) uses rayon for parallel rendering and evolution;
  • the server uses tokio and axum, and the MCP tools rmcp;
  • the studio is egui, compiled natively and to WebAssembly (0008);
  • the scripting language is Rhai, which is itself written in Rust (0004);
  • audio decoding is symphonia, with ffmpeg only as an optional fallback;
  • earshot is ported to Rust (crates/earshot): its DSP reimplemented, the two models run as ONNX exports on ONNX Runtime through the ort crate, linked statically. Nothing needs Python at runtime.

Rejected: keeping earshot in Python and calling it as a subprocess, which would make every user install a Python environment with PyTorch, add seconds of startup to every verdict, and split the program in two. A C++ engine (a JUCE or similar stack) would be as fast but give up memory safety where claudius parses untrusted input, and make the build harder to keep portable. A JavaScript engine sharing code with Strudel would have made the web studio simpler and offline rendering of long, dense songs much slower.

Consequences

  • One language, one toolchain (rust-toolchain.toml), one dependency audit (cargo deny), and binaries that run without an interpreter. The claudius program carries the whole engine, the server and the studio.
  • The wire types (claudius-api) are shared by the server and the WebAssembly studio, so they cannot drift.
  • Parsing audio, scripts and JSON happens in memory-safe code; the remaining C is ONNX Runtime (running pinned, hash-checked models) and ffmpeg when it is installed.
  • The port of earshot is a maintenance cost: it has to match the Python original, which crates/earshot/tests/parity.rs checks against fixtures the Python version wrote. Regenerating those fixtures is the one task that still needs a Python environment, and only for developers.
  • ONNX Runtime arrives as a prebuilt static library downloaded at build time by ort. There is no build for Intel Macs, so that release target is marked experimental and fails for now.
  • Compile times are long (the dev profile optimises everything, because tests render audio), and contributors need to know Rust, which is a higher bar than Python for people who come from music.

0003 The blueprint is the song

Status: accepted

Context

A claudius song is edited from many directions: a script changes the tempo, the studio paints drum steps, mutation swaps a bass style, an assistant rewrites the form, analysis of an audio file produces a new song. It has to be saved, undone, printed as a script and evolved, and two versions of it have to be comparable.

A DAW stores the arranged session: tracks with their notes and clips. That is what it plays, and it is the obvious thing to store. But a session records results, not decisions. A bass line stored as 144 notes no longer knows that it was an off-beat house bass over a progression; changing the key means moving every note; trying another style means writing it again by hand; and a mutation operating on notes can only nudge notes.

Decision

A song is its Blueprint (crates/claudius/src/model.rs): the decisions a producer makes, stored as data. Tempo, key and swing; progressions as Roman numerals; the form; drum lanes as step patterns; each melodic part as a recipe (a role, a style, a register, a density and a seed of its own); pattern tracks as the code that makes them; every sound; the mix. The arranged Project with its notes and clips is derived from it by compose::arrange, deterministically, every time it is needed, and thrown away after rendering.

The blueprint is what is saved (blueprint.json), what undo restores, what scripts print and edit, and what mutation and crossover change. Notes are stored in a blueprint only where no recipe produced them: hand-written or analysed notes in a part’s notes, and the drum steps.

Rejected: storing the project as the truth, for the reasons above, and storing both, which would leave two copies of the song that drift apart the first time one is edited.

Consequences

  • Edits happen at the level a producer thinks at. A new seed is a new bass line in the same style; a new key moves every part, because progressions are numerals and parts are written from them; a style change rewrites a part in a sentence. Mutation operators are a few lines each (Evolution).
  • A blueprint is small: about 30 KB of JSON for a full house song, against about 300 KB for its project. Undo keeps 200 whole blueprints, and evolution hashes them to skip duplicates.
  • A song can print as a script that runs back into itself (Patterns and the script engine), because every field is a decision a script function can state.
  • The notes of a generated part depend on the writer’s code. When theory code changes, an existing blueprint renders differently: a saved song is not frozen across versions of claudius. The generator snapshot tests make such changes visible in review, but they cannot keep old songs as they were.
  • Single notes of a generated part cannot be edited in place. To change one, the part has to become hand-written notes or a pattern track, and then it no longer follows the key and style.
  • A track plays the same way in every section it is in; there is no per-bar arrangement edit. A variation takes a second track.
  • Everything that derives the project must be deterministic, which shapes the random number generator, the seeds stored in parts, and the hash-based randomness of patterns (Architecture).

0004 Rhai with Strudel-style patterns

Status: accepted

Context

claudius needs a language in which a song can be written, read and edited, by people and by assistants:

  • Patterns. Drum grooves, bass lines and chord rhythms are cyclic. Live coders have a compact, proven notation for them: TidalCycles’ mini-notation, as Strudel uses it ("bd*4, [~ cp]*2, hh*8?"). Many musicians who code already know it, and assistants have seen a lot of it.
  • Song edits. Tempo, key, progressions, parts, mix, form: the decisions in a blueprint, named as functions.
  • A sandbox. Scripts come from assistants and from files people share. They must not read files, open sockets, run forever or exhaust memory.
  • Embedding. The language runs inside the Rust engine, in the server’s process, many times a minute.
  • Printing. Every song must print as a script that runs back into itself (0003), so the language has to be able to state everything a blueprint holds.

The candidates were JavaScript (Strudel’s own language, through QuickJS or V8), Lua, a language of claudius’ own, and JSON commands only.

Decision

Scripts are Rhai, with Strudel-style mini-notation inside strings and pattern methods named after Strudel’s (fast, every, sometimes, struct, off, superimpose …). One cycle is one bar. Song edits are plain functions (tempo(), prog(), drum(), part(), mix(), track(), form()). Patterns are immutable values that remember the code that built them, so a song’s pattern tracks print exactly as written.

Rhai is written in Rust, embeds with a few lines, looks enough like JavaScript that assistants write it well, and has the limits a sandbox needs built in (operations, call depth, string and array sizes, a progress callback for a time limit, module resolution that can be switched off); it has no file or network functions unless the host registers them. Patterns are claudius’ own implementation (pattern/), not a binding to Strudel.

Rejected: JavaScript, because embedding a JS engine brings a large C or C++ dependency and makes the sandbox the host’s job, and because Strudel’s semantics (per-event controls, fragments, its scheduler) do not map onto a blueprint anyway. Lua, for similar embedding reasons and an unfamiliar syntax for most users. A language of our own, because the general-purpose parts (variables, loops, closures, maps) would have to be built and documented. JSON commands only, because nobody can read or write a song in them; they remain available as the HTTP API.

Consequences

  • Scripts are safe to run from anyone: the limits and the work budget for patterns are enforced by the engine, and fuzzed weekly (Security).
  • Strudel and Tidal users recognise most of it. What differs includes no polymeter ({} is a plain group), events returned by onset rather than as fragments, track settings (.room(), .lpf()) that apply to a whole track instead of per event, and one cycle per bar; Coming from Strudel covers the rest.
  • Closures print by calling them once on a placeholder pattern, so |x| x.add(up) prints as |x| x.add(7): the value is kept, the variable name is lost.
  • Rhai is stricter about types than JavaScript: ply(1.5) is Function not found: ply (Pattern, f64), a common first error that Errors and limits explains.
  • Rhai is slower than a JIT-compiled language, which does not matter for scripts that describe songs but rules out writing DSP in it.
  • Maps are written #{ k: v }, which is Rhai’s own and has to be learned.

0005 One session, many clients

Status: accepted

Context

The studio exists as a native window and as a web page, and assistants edit songs over MCP. The experience claudius aims for is a person listening in the studio while an assistant works, hearing every change and stepping in. That means several clients edit one song at the same time, and each must see the others’ changes quickly.

The options were to give each client its own engine and synchronise them, to merge concurrent edits with a CRDT or operational transforms, or to keep exactly one song and make every client a view of it.

Decision

A process holds one App (crates/claudius-server) with one Session: the song, its undo and redo stacks, the latest render and verdict, a log, and at most one running job. Every client goes through it:

  • the studio, native and web, uses the HTTP API only, long-polling GET /api/state?since=N and sending commands;
  • MCP over HTTP calls the session in process;
  • claudius mcp, started by a desktop assistant, first looks for a claudius already running on the port and drives it through its HTTP API; only if there is none does it start a session of its own.

Edits are serialised by a mutex, and each runs on a copy of the current blueprint and replaces it on success. Every change bumps a version that long polls wait on. Jobs (renders, mutation, evolution, saves) run one at a time, and a render or a save asked for during a job runs after it.

Rejected: an engine per client, which would leave the person and the assistant on different songs the moment one of them edits. Merging concurrent edits, which suits documents edited by many people offline and is far more machinery than one person and one assistant need, when serialising edits gives a clear “last edit wins, nothing is lost to undo”. WebSockets for updates, which would add a protocol for what a long poll does, and long polls pass through tunnels and proxies without configuration.

Consequences

  • There is no synchronisation protocol and no conflict to resolve: whoever edits second edits the result of the first. Undo is shared too, so you can take back an assistant’s change from the studio with Cmd+Z.
  • The studio is an ordinary HTTP client, which keeps the API complete: anything the studio can do, curl, scripts and assistants can do.
  • One process holds one song. Working on two songs means two processes on two ports.
  • One job at a time: while an evolution runs, a render or a save waits for it, and a second mutation is refused with busy.
  • Optimistic edits in the studio can be refused by the server, and then flash back to the server’s state.
  • A client holds a connection open per long poll, up to 20 s (or the timeout it asks for), which is cheap for the handful of clients a session has and would not scale to many.
  • When claudius mcp finds nothing on its port (the studio runs on another port, or was started after the assistant), it starts its own session without failing, and the assistant edits a different song from the one on screen. stderr says which way it went, but people rarely look there. A claudius that asks for a token the assistant was not given is recognised, and claudius mcp fails with a message instead of starting a second session.

0006 A critic with ears

Status: accepted

Context

claudius revises songs: evolution tries dozens of variations, and assistants edit a song, listen and edit again. Both need feedback after every render, faster than a person can listen and in a form they can act on.

Measurements alone (loudness, spectrum, width) say whether a master is technically sound, not whether a house track sounds like house. A learned judge can say the latter, but every model can be pleased without the music getting better: optimise against it and you get songs that score well with it. earshot, the library claudius listens with, warns about exactly this. No model exists that rates music quality with clear licences and data behind it.

Decision

The critic (judge.rs, ears.rs) combines evidence that cannot all be gamed at once:

  • mix: model-free meters on the master (loudness and true peak against the targets, energy per band against a healthy range, stereo width, a mono low end);
  • arc: whether section loudness follows the energy the arrangement intends, and whether the drop is clearly louder than the break;
  • theory: checks on the notes themselves (lead in key and on chord tones, singable range, bass on the roots, smooth voice leading, a sound form);
  • ears, when earshot’s models are installed: one CLAP embedding of the master, scored with earshot’s calibrated z-scores against the genre and the genres it is confused with, and against “professionally mixed” versus six named flaws; with reference tracks, typicality among them.

Every problem is an issue with a severity, a sentence, and a target naming where the fix lives (band:sub+, part:lead, form), which evolution uses to choose what to mutate and assistants read as advice. Two readings are held out: the realness estimate and the AudioSet tags are reported and never scored, so that a rising score without rising held-out readings shows the scored parts being gamed. Without the models, the critic works with the first three components.

Rejected: listening by people alone, which cannot drive an evolutionary search. Meters alone, which cannot hear genre. Scoring the AudioSet tags or realness too, which would leave nothing held out. Training a model of our own, for lack of data we could license.

Consequences

  • Verdicts explain themselves: “the drop is only 2.1 LU louder than the breakdown (aim for 3-7)” is something a person or an assistant can act on, and the targets let evolution act on it automatically.
  • The critic works offline and without the 785 MB of models, with less to say.
  • The ears are only as good as CLAP on music: earshot validated it for matching sound effects, house, tech house and techno sit close together for it, and fully synthesized songs read only marginally professional. Genre and production scores are hints.
  • The healthy band ranges are one target for every genre, so dark genres such as lofi collect issues that are style, not fault.
  • A high score means no measurable problems and a confident genre reading. It does not mean a good song, and the docs say so wherever scores appear.
  • Evolution judges previews (the build-up and first drop), which keeps it affordable but misses problems elsewhere in the song.
  • The models cost a download, about half a gigabyte of memory, and a few seconds to load; verdicts made before they load have no ears.

0007 Which samples we ship

Status: accepted

Context

Real audio (0001) needs recordings, and people publish what they make with claudius. Free samples are plentiful; samples whose licence is clear are not. In the sets live coders use every day:

  • many drum machine sets trace back to anonymous uploads with no stated terms;
  • TidalCycles’ Dirt-Samples has no licence file, and some of its folders are cut from commercial records;
  • some well-made libraries are free to use in music but forbid redistributing the samples themselves (Audio Realism’s TR-909 pack, the jRhodes3d Rhodes);
  • share-alike licences can reach into the music made with the samples.

Putting samples in the repository or the release archives would also make claudius a redistributor, and the repository huge.

Decision

claudius ships no samples. Sample packs are installed on demand from their authors’ own hosts, at pinned revisions, by claudius setup or claudius-packs install (crates/claudius-packs). The curated registry records, for each pack, its licence, its credit line, whether credit is required, and a licence_note quoting the licence text and where it was read.

  • The default set (14 packs, 162 instruments) contains only packs whose licence lets you make and publish music with them, with at most an attribution: CC0, public domain, the Unlicense, CC BY, and free-for-music licences such as jRhodes’ and Audio Realism’s. Packs that forbid redistribution are acceptable because each machine downloads them from their author.
  • Credits are automatic: a song that plays CC BY packs gets a credits.txt naming exactly the packs it used.
  • Opt-in packs, whose licence is unknown or carries conditions (tidal-drum-machines, dirt-samples, mridangam under CC BY-SA), install only with --accept-licence and print a warning.
  • User maps added with add-map record the licence the map or its repository declares, or unknown with a warning that also appears in the credits of every song that uses them.
  • Apple Loops are read where GarageBand or Logic installed them, under Apple’s licence for using them in your own music, and never copied, except the drum one-shots claudius cuts into its cache.
  • Where no clearly licensed recording exists, claudius does without or builds one: the choir is a single sung vowel, CC0, played as a five-voice ensemble, and the uzu kit’s processed Amen break is left out.

Rejected: bundling packs (redistribution, and a gigabyte-sized repository); enabling the popular unlicensed sets by default (users would publish samples they have no right to); limiting claudius to CC0 (it would lose the best piano, the electric pianos and the acoustic kit, all CC BY).

Consequences

  • Music made with the default set can be published, with the credits claudius writes.
  • Every licence decision can be checked by reading registry.json, without repeating the research.
  • The first run downloads about 2.2 GB from a dozen hosts, and claudius depends on those hosts staying up. GitHub-hosted packs are pinned by commit; the two archives (the 909 and the FreePats organ) are not pinned by hash, so a compromised host could serve different audio (Security).
  • Strudel’s default drum-machine banks, which Strudel users expect, are opt-in.
  • The library has gaps where no clearly licensed recording exists: no multi-sampled choir, no vocals with words, a piano cut to 5 of its 16 layers for size.
  • Adding a pack means reading its licence at the source and quoting it (Sample packs).

0008 egui for native and web

Status: accepted

Context

The studio is DAW-like: an arrangement of section blocks and track rows, a step sequencer, a mixer with faders and meters, a waveform with a playhead, a script editor, a verdict panel. It has to run as a native window on the user’s computer (claudius studio) and as a web page (claudius serve), including through a tunnel on a phone or a laptop elsewhere. Almost all of it is custom drawing; few standard widgets fit.

The rest of claudius is Rust (0002), the team is small, and the server’s types should be the UI’s types.

Decision

The studio is written once with egui and eframe (crates/claudius-ui), compiled natively with the glow backend and to wasm32-unknown-unknown with trunk. It depends only on claudius-api and talks to the engine only through the HTTP API (0005), so the same code is a native window or a web page. Playback is rodio natively and an HtmlAudioElement on the web. The widgets that matter (faders, knobs, meters, the arrangement grid) are drawn with egui’s painter, and a built-in mock server lets the studio run, and be developed, without an engine.

Rejected: a web front end in a JavaScript framework with a native shell around it, which would mean two languages, the wire types written twice, and a build pipeline per language. A native toolkit per platform, which would triple the work and still leave the web. Rendering the UI on the server and streaming it, which does not work offline or through slow tunnels.

Consequences

  • One studio for both targets, in one language, sharing types with the server; a change shows up in the window and the browser at once.
  • egui redraws the whole UI each frame it paints, so the studio schedules repaints itself (while playing, while a job runs, while waiting on the network) to stay idle otherwise.
  • Accessibility is weaker than native widgets or HTML give: screen-reader and keyboard support are limited to what egui provides. Text input and editing are egui’s too, which is enough for the script editor but not a code editor.
  • The web build is a WebAssembly bundle of several megabytes that has to download before the studio appears.
  • The two audio paths differ: natively the 24-bit WAV is decoded and played sample-accurately; on the web the browser plays the MP3, and a browser blocks sound until the user clicks.
  • The native window cannot send a token, so it cannot open a token-protected claudius; claudius studio says so and points to the browser, which can.
  • Clippy has to run separately for the wasm target, because native builds never compile the cfg(target_arch = "wasm32") code.

0009 Releases through pull requests

Status: accepted

Context

claudius is published as binaries for several platforms, with a web studio inside, a changelog, an install script, a Homebrew formula and a website. Every release has to bump the version in every manifest and Cargo.lock, write the changelog, tag the right commit, build five targets, publish checksums and provenance, and update the formula and the site. Done by hand, that is a checklist of a dozen steps where any skipped one ships something wrong. Contributors should never have to touch versions, and the people using claudius should be able to verify what they download.

Decision

A release is a pull request, and merging it is what releases:

  • Pull request titles are Conventional Commits (feat(script): add euclid() to patterns), checked by pr-title.yml. Pull requests are squash-merged with their title as the commit message, so main’s history is one line per change.
  • Starting a release is running the release-pr.yml workflow with a bump (patch, minor, major or a version). It runs cargo xtask release prepare, which bumps the workspace version, the path dependencies and Cargo.lock, turns the commits since the last tag into a changelog section with git-cliff (adding anything written under “Unreleased” by hand), and opens the pull request release: vX.Y.Z from release/vX.Y.Z. The notes can be edited on that branch before merging.
  • Merging pushes the new version to main, which runs release.yml: it checks that the version is unreleased and has a changelog section, builds the web studio and the archives for each target, checks and collects their SHA-256 sums, attests their build provenance, creates the GitHub release as a draft and publishes it (which creates the tag on the merge commit), then updates the Homebrew tap and deploys the site. Run again by hand, it retries a release that failed halfway, replacing the draft.

The details are in Releasing.

Rejected: tagging releases by hand, which is where versions and tags drift apart; releasing on every merge to main, which would ship half-finished work and make the changelog noise; a third-party release bot, which would put a dependency with write access in the most sensitive workflow when a few hundred lines of xtask and two workflows do the job and can be read.

Consequences

  • Every release is reviewed like any other change, with its notes in front of the reviewer, and CI runs on it.
  • Nobody bumps versions by hand, and the changelog writes itself from titles people already had to write.
  • Archives can be verified: SHA256SUMS for integrity and gh attestation verify for where they were built. The install scripts check the sums.
  • Pull request titles carry weight: a vague title becomes a vague changelog line. The title check stops malformed ones, not uninformative ones.
  • Squash merging loses the commits inside a pull request from main’s history.
  • A pull request opened with the workflow’s own token starts no workflows, so the release workflow starts CI on the release branch itself, or opens the pull request with a RELEASE_TOKEN when one is configured.
  • Two targets are experimental: Intel macOS (no ONNX Runtime build exists for it) and Windows (not verified yet). Their failures leave them out of a release instead of stopping it.
  • Fixes go into the next release only; there are no maintenance branches for older versions.

A tour of the repository

claudius is about 45,000 lines of Rust in eight crates, and most of what makes it interesting is in one of them. This page gives you the map, follows one song through the code from the command line to the speakers, and suggests an order to read things in, with what to look for in each. Set up your machine first with Development setup; the commands below assume a built checkout.

The map

crates/
  claudius/                the engine: everything that makes or judges music
    src/model.rs             Blueprint (a song's decisions) and Project (the arranged timeline)
    src/compose.rs           generate: decisions from a genre and a seed (parts, sounds, kits, flipped samples)
    src/compose/arrange.rs   arrange: decisions into notes, sections and automation
    src/genres.rs            loads data/genres/*.json
    src/theory/              keys, chords and voicing, progressions, rhythm, bass lines, leads
    src/pattern/             mini-notation, pattern queries, events into notes
    src/script/              the Rhai engine; song functions (song.rs, mix.rs, form.rs); sound names; printing
    src/render.rs            the mixer and the master
    src/fx/ dsp/ synth/      effects; filters, oscillators, FFT; drum voices, the synth engine, risers
    src/loudness.rs          BS.1770 loudness
    src/judge.rs ears.rs     the critic, and the ears that feed it
    src/evolve.rs            mutation, crossover, the evolutionary search
    src/analysis/            reading tempo, key, chords and sections from audio
    src/library/             Apple Loops, chops, one-shot cutting
    src/config.rs            constants, folders, the shared-mode file policy
    src/song.rs              one song end to end, and write_folder, which writes every song folder
    data/genres/             the eight genres, as data
    tests/ benches/          the engine's tests and benchmarks
  claudius-packs/          sample packs: registry, installer, SFZ and Strudel maps, the player
  claudius-decode/         audio files to samples (symphonia, ffmpeg as fallback)
  earshot/                 the ears: earshot ported to Rust, CLAP and AudioSet on ONNX Runtime
  claudius-api/            the HTTP API's types, shared with the web studio
  claudius-server/         the session, HTTP routes, MCP tools
  claudius-ui/             the studio, native and WebAssembly
  claudius-cli/            the claudius program: main.rs (the commands), args.rs (flags), report.rs (printing)
docs/                      this book
fuzz/                      cargo-fuzz targets (their own workspace)
xtask/                     cargo xtask: ci, web, docs, site, dist, release
scripts/                   install scripts, the Homebrew formula template
site/                      the website
.github/                   CI, releases, fuzzing, dependency updates

Two rules keep the shape: the engine knows nothing about HTTP, sessions or windows, and the studio knows nothing about the engine, only the API. Architecture draws the dependencies.

One song through the code

Follow claudius make --genre house --seed 9 from start to finish. Keep the files open as you go.

The command (crates/claudius-cli/src/main.rs, Cmd::Make). clap parses the flags, which are defined in args.rs. genres::get("house") deserialises data/genres/house.json, plus any genre files of your own, into a GenreProfile. Library::open reads the Apple Loops database if GarageBand content is installed. listen() loads earshot’s models unless --no-ears.

The decisions (compose::generate, then generate_inner). A seeded generator is forked into one stream per area: harmony picks two progressions as Roman numerals, form picks a list of sections, drums sample a 4-bar pattern per lane from the genre’s step probabilities, parts get a preset, a style and a seed of their own. Sounds come from part_sound and kit_sound, which prefer a sample-pack instrument where the genre asks for one and it is installed. The result is a Blueprint (model.rs): about 30 KB of JSON that says everything about the song and contains almost no notes.

The layout (song::render_and_write, then compose::arrange in compose/arrange.rs). The blueprint becomes a Project. For each section the drum patterns are tiled, with snare rolls in builds and fills every 8 bars; each part is written once per progression by the writers in theory/ (bass::bassline, melody::chord_part, melody::lead, melody::arp) and tiled across its sections; transitions are placed; bus automation is added.

The sound (render::render). Every track is rendered in parallel: notes through a drum voice (synth/drums.rs), the synth engine (synth/voice.rs), a sample-pack instrument (claudius_packs::Instrument) or a loop (warp.rs); then its inserts, the auto-mixer’s gain to its role’s target loudness, the sidechain, the pan. The buses and the two returns are summed, and finalize searches for the input gain that lands the master on the genre’s LUFS target under the true-peak ceiling.

The judgement (judge::judge, with ears::Ears::report). Meters, the energy arc, theory checks on the notes, and earshot’s calibrated CLAP scores become a Verdict with scores and issues.

The files. render_and_write hands the render to song::write_folder, which writes song.wav, song.mp3, blueprint.json, song.rhai, project.json, report.json, verdict.json and credits.txt (and stems/ with --stems), and report.rs prints the summary. A save from the studio goes through the same write_folder.

Now the same song in the studio, after claudius studio:

  1. claudius_server::App::new creates the session; claudius_server::spawn serves it on a thread; claudius_ui::run_native opens the window on the main thread.
  2. The studio long-polls GET /api/state?since=N (claudius-ui/src/net.rs). You click a step in the sequencer (panels/sequencer.rs): it pushes Command::SetDrumStep, shows the step at once (edits.rs), and posts it.
  3. http.rs checks the host name, parses the JSON, and calls App::command on a blocking thread (claudius-server/src/app/commands.rs). edit_with changes a copy of the blueprint, commit pushes the old one for undo, and after_change starts a render job (app/jobs.rs).
  4. The job arranges, renders and judges as above, writes <cache>/renders/<pid>/<version>.wav, and bumps the version. The long poll returns, the studio sees a new render.version, fetches the WAV and plays it from where you were.

And an assistant, through claudius mcp: mcp.rs finds the studio on the port, picks Backend::Remote, and turns a run_script tool call into POST /api/command, which lands in App::run_script and script::run_with, the same path as the studio’s script tab. That is the whole architecture: one session, every client a view of it.

A reading order

A first pass takes a few evenings. Each step says what to look for.

  1. crates/claudius/src/model.rs. The two documents. Notice what a Part stores (a style and a seed, not notes), that every Sound variant is audio, and TrackMix.level_db being relative to a role, not a fader.
  2. crates/claudius/data/genres/house.json. What a genre knows: weighted choices, step probabilities, forms with flags, presets with a|b alternatives, the mix. Most musical changes are edits to files like this one.
  3. compose.rs, generate_inner, then compose/arrange.rs, arrange. The creative step and the layout step. Notice the rng.fork(...) calls, the tempo and tonic drawn even when they are given, and why both matter (Determinism).
  4. theory/bass.rs. A short writer that shows the idea behind all of them: a one-bar motif, written once, transposed onto every chord. Then skim melody.rs for the lead’s A A’ A B phrasing.
  5. render.rs, render(). The whole mix in about 130 lines: sources, the auto-mixer, the sidechain, buses, returns, the master. Then finalize for the loudness search.
  6. judge.rs. What “good” means here, and the issue targets evolution reads.
  7. pattern/mod.rs, query; pattern/mini.rs; script/mod.rs; script/song.rs, track(). How a mini-notation string becomes a tree, how a tree is queried by onset, the work and event budgets, the sandbox limits, and how a pattern becomes a track. script/mix.rs and script/form.rs hold the rest of the song functions.
  8. script/print.rs and songs_print_as_scripts_that_change_nothing in tests/script.rs. The round trip, and the small concessions on the reading side that keep it exact (Printing a song).
  9. evolve.rs. The 25 operators, how issues weight them, the (μ + λ) loop with previews.
  10. crates/claudius-server/src/app/, then http.rs and mcp.rs. The session, its versions and jobs; the guard; the tools and their two backends.
  11. crates/claudius-ui/src/app.rs and net.rs. How the studio holds back server state while you drag, and applies edits optimistically.
  12. crates/claudius-packs/src/resolve.rs and player.rs, and earshot’s README. Names to instruments, zones to sound; how the ears were ported and checked.

Read with claudius running. These make the code concrete:

echo 'song("house", 9);' | claudius script - --print          # a song's decisions, as a script
claudius make --genre house --seed 9 --short --no-ears          # the whole pipeline in ~10 s
CLAUDIUS_DEBUG=1 claudius make --genre house --short --no-ears  # with render timings
claudius serve --no-ears                                        # then: curl -s localhost:7878/api/state
cargo test -p claudius --test script                            # the language, end to end

Conventions you will meet

  • Units. Time in beats from the start of the song, four beats to a bar; pitch in MIDI semitones (60 is middle C); velocity 0 to 1; levels in dB; 48 kHz planar stereo f32 everywhere. Names carry units when they are not these (release_ms, ceiling_db, size_mb).
  • Seeded randomness only. Every random choice draws from rng::Rng, seeded from the song and forked per area, or from a hash (patterns, the pack player). Nothing reads the clock except to pick a seed when none is given.
  • Data over code. Genres are JSON; effects are serde enums; a blueprint is plain data. New fields get serde defaults so older files keep loading.
  • Errors are for people. Messages say what is wrong and what would work: unknown sound 'bdd' (drums: bd kick sd ...), cannot read key 'H minor' (try "A minor", "F#m", "Eb", "D dorian").
  • Comments state constraints, not what the next line does: // a noise-built tail reads as hiss above ~9 kHz: real rooms and plates are darker up there. Doc comments are short lowercase phrases; module comments explain the module’s job in prose.

Code style and review has the rest.

Where to start contributing

Good first changes, roughly from least to most code:

  • An example or a fix in this book. Every js block runs in CI, so examples stay true.
  • A genre tweak. Change a weight, a template or a form in data/genres/*.json, look at the generator snapshot diff, and listen (Making a change).
  • A progression style in theory/progression.rs, or a bass or chord rhythm in theory/.
  • A script function, such as a pattern method Strudel has and claudius lacks (Making a change walks through one).
  • A mutation operator in evolve.rs.
  • A sample pack, if you find a well-mapped, clearly licensed library (Sample packs).

Before anything larger, open an issue and describe what you want to change; the design records in Design decisions explain the choices you might want to revisit, and why they were made.

Development setup

You need Rust, a handful of cargo tools, and on Linux a few system packages. Everything else (ONNX Runtime for the ears, wasm-bindgen for the web studio) is downloaded by the build itself.

Rust

Install rustup. rust-toolchain.toml at the root pins the Rust that CI and the releases build with (1.96.0), with rustfmt, clippy and the wasm32-unknown-unknown target, and rustup installs all of it the first time you run cargo in the repository. Your machine and CI run the same clippy, so its lints do not change under you. Moving to a newer Rust is a pull request of its own, with whatever its new lints ask for. The same goes for Ubuntu: the workflows run on ubuntu-24.04, the image the Linux releases are built on, and not on ubuntu-latest, which GitHub moves to each new release.

rust-version in Cargo.toml is the oldest Rust the workspace builds with: 1.96 too, because 1.95 cannot infer some of the types in claudius. The msrv job in CI checks it.

System packages

macOS: the Xcode command line tools (xcode-select --install), for the C and C++ that some crates compile (SQLite, signalsmith-stretch) and the clang runtime that ONNX Runtime links against.

Linux (Debian and Ubuntu; the list is what a bare ubuntu:24.04 needs on top of a compiler):

sudo apt install build-essential pkg-config libssl-dev libasound2-dev libclang-dev

pkg-config and libssl-dev let ort download ONNX Runtime at build time (only builds with the ears need them), libasound2-dev is ALSA for the studio’s audio output (only builds with the studio window), and libclang-dev is for the bindgen step of signalsmith-stretch. On Fedora: sudo dnf install gcc-c++ pkgconf-pkg-config openssl-devel alsa-lib-devel clang-devel. The native studio window also needs X11 or Wayland and OpenGL at run time, which any desktop has; no -dev packages are needed for them.

The Linux build needs glibc 2.38 or newer (Ubuntu 24.04, Debian 13, Fedora 39), because the ONNX Runtime library that ort downloads was built against it.

Windows builds are experimental and not checked yet: you need the Visual Studio C++ build tools and LLVM (for bindgen). Build without the ears there (below).

Features

claudius has two cargo features, both on by default:

  • ears: earshot’s listening models, through ONNX Runtime. Without it the verdicts use meters and music theory, claudius setup skips the models, and claudius doctor says “ears: not in this build”. ONNX Runtime has no build for Intel Macs, so they never get the ears, and the Windows build leaves them out until someone has run it there with them.
  • studio: the native studio window (egui). Without it claudius studio points you at claudius serve, which still serves the web studio, and the binary links no ALSA, X11 or OpenGL. That is the headless build for Linux servers.

Code that needs earshot goes behind #[cfg(feature = "ears")], the window behind studio. cargo xtask ci checks the builds without each of them, so a gated path cannot stop compiling unnoticed.

Tools

toolwhat it is for
trunk 0.21builds the web studio (WebAssembly)
cargo-nextestruns the tests; cargo xtask ci falls back to cargo test without it
mdBook 0.5builds this book
cargo-denychecks licences, advisories and where crates come from (deny.toml)
typosfinds misspellings (_typos.toml)
git-cliffturns commit messages into release notes (cliff.toml)
lycheechecks the links between the pages of the built site
actionlint, shellcheckcheck the workflows and the scripts in scripts/
gitleaksfinds secrets in commits before they leave your computer (.gitleaks.toml)
osv-scannerchecks the lockfiles against known vulnerabilities (osv-scanner.toml)
Node.js 24runs the website’s smoke test (Playwright)
rsvg-convert (librsvg), fontToolsonly to redraw the logo and the app’s icons: python3 scripts/icons.py

All of them have prebuilt binaries. With cargo-binstall, which downloads those instead of compiling:

cargo binstall trunk cargo-nextest mdbook cargo-deny typos-cli git-cliff lychee

On macOS, Homebrew has all of them too:

brew install trunk cargo-nextest mdbook cargo-deny typos-cli git-cliff lychee actionlint shellcheck node gitleaks osv-scanner

Then turn on the repository’s git hooks, once per clone:

git config core.hooksPath .githooks

pre-commit checks the formatting and scans the staged changes for secrets with gitleaks; pre-push refuses a push to main, which changes only through pull requests.

Building each part

cargo xtask runs the repository’s chores; cargo xtask help lists them. The ones you need day to day:

commandbuildsinto
cargo buildevery crate (the dev profile is optimized, so songs render at a usable speed)target/debug
cargo build -p claudius-cli --no-default-features --features studioclaudius without the ears, as on Intel Macs and Windowstarget/debug
cargo build -p claudius-cli --no-default-features --features earsclaudius without the studio window: the headless buildtarget/debug
cargo xtask webthe web studio, with trunkcrates/claudius-ui/dist
cargo xtask docsthis booktarget/book
cargo xtask sitethe website: the landing page, the book at /docs/, the studio at /studio/ and the install scriptstarget/site
cargo xtask dista release archive for this machine, as the release workflow builds it (--headless for the Linux server build, --no-ears without the ears)target/dist

The first build downloads ONNX Runtime (about 120 MB) for earshot, into ~/Library/Caches/ort.pyke.io on macOS and ~/.cache/ort.pyke.io on Linux.

For the docs and the site there are live previews: mdbook serve docs rebuilds the book on every save at http://localhost:3000, and node site/tests/serve.mjs serves target/site the way Cloudflare Pages does (headers, redirects, the 404 page) at http://127.0.0.1:4321.

Running the studio from source

cargo xtask web                                  # the web studio, once and after changes to the UI
cargo run --release -p claudius-cli -- setup     # the sample packs and the listening models, once (about 3 GB)
cargo run --release -p claudius-cli -- studio    # the native window, with the engine inside
cargo run --release -p claudius-cli -- serve     # or the studio in the browser at http://127.0.0.1:7878

A claudius built in a checkout serves the web studio from crates/claudius-ui/dist of that checkout.

When you work on the UI, NO_COLOR=true trunk serve in crates/claudius-ui rebuilds it on every save and serves it at http://127.0.0.1:8090. Open http://127.0.0.1:8090/?server=http://127.0.0.1:7878 to drive a claudius serve running next to it, or http://127.0.0.1:8090/?mock for the demo that needs no server. (NO_COLOR=true because trunk rejects the common NO_COLOR=1; cargo xtask web sets it for you.)

Before you push

cargo xtask ci runs what every pull request has to pass first: cargo fmt --check, clippy with warnings as errors on every target, clippy and cargo check of the builds without the ears or the studio window, the tests, and the examples in the docs. CI runs more; when you changed the matching part, run it too:

you changedrun
the docs or the sitecargo xtask site, then lychee --offline --include-fragments --root-dir "$PWD/target/site" 'target/site/**/*.html' for the links, and npm ci && npx playwright test in site/
dependenciescargo deny check
anything with words in ittypos
workflows or scriptsactionlint and shellcheck scripts/*.sh scripts/ci/*.sh .githooks/*
a lockfileosv-scanner scan source --config osv-scanner.toml --lockfile Cargo.lock --lockfile site/package-lock.json
benchmarkscargo bench --no-run
code that the Intel Mac build compilesrustup target add x86_64-apple-darwin, then cargo check --target x86_64-apple-darwin -p claudius-cli --no-default-features --features studio (CI’s intel mac build job)

Making a change

This page walks through one small, complete change: adding a pattern method to the script language, with its test, its documentation, and its pull request. The same steps, in the same order, fit most changes. A genre tweak at the end covers the other common kind.

The change

Strudel has palindrome(): a pattern plays forwards on one cycle and backwards on the next. claudius has rev() and lastOf(), so you can write it as .lastOf(2, |x| x.rev()), but people coming from Strudel look for the name. The change adds .palindrome() as a method on patterns.

First, check it does not exist and see how the neighbours are built:

rg -n 'palindrome' crates/claudius/src            # nothing
rg -n '"rev"|"lastOf"' crates/claudius/src/script/patterns.rs

lastOf(n, f) builds Node::Every(n, n - 1, original, transformed): the transformed pattern plays on every cycle where cycle % n == n - 1. A palindrome is Every(2, 1, p, Rev(p)), so no new kind of node is needed, and queries, the work and event budgets and printing need no changes either.

git switch -c feat/palindrome

The test first

Write the test before the code and watch it fail, so you know it tests the change. Pattern methods are tested in crates/claudius/tests/script.rs:

#![allow(unused)]
fn main() {
#[test]
fn palindrome_plays_backwards_every_other_cycle() {
    let p = script::eval_pattern(r#"s("bd sd hh").palindrome()"#).unwrap();
    let names = |cycle: f64| p.events(cycle, cycle + 1.0).iter().map(|e| format!("{:?}", e.v)).collect::<Vec<_>>();
    let mut backwards = names(0.0);
    backwards.reverse();
    assert_eq!(names(1.0), backwards);
    assert_eq!(names(2.0), names(0.0));
    // the code is kept, so a song with it prints back into itself
    assert_eq!(p.repr, r#"s("bd sd hh").palindrome()"#);
}
}
cargo test -p claudius --test script palindrome

It fails with Rhai’s Function not found: palindrome (Pattern).

A palindrome applied twice gives back the original pattern, which is a law, and laws belong in crates/claudius/tests/pattern_laws.rs, inside the proptest! block next to reversing_twice_is_the_original:

#![allow(unused)]
fn main() {
#[test]
fn palindrome_twice_is_the_original(src in mini_source()) {
    let a = pattern(&format!("s({src:?})"));
    let b = pattern(&format!("s({src:?}).palindrome().palindrome()"));
    prop_assert_eq!(onsets(&a, 0.0, 4.0), onsets(&b, 0.0, 4.0));
}
}

The code

Pattern methods are registered in crates/claudius/src/script/patterns.rs, in register_patterns. The time transformations sit together; add this after rev:

#![allow(unused)]
fn main() {
engine.register_fn("palindrome", |p: &mut Pattern| {
    p.with(Node::Every(2, 1, Box::new(p.node()), Box::new(Node::Rev(Box::new(p.node())))), ".palindrome()")
});
}

Pattern::with wraps the node and appends .palindrome() to the pattern’s repr, which is what makes a track that uses it print back as written. It keeps the pattern’s kind (sounds, notes, degrees, chords) and its track settings, so note("c3 e3 g3").palindrome().s("pluck") works too. Seeds and randomness need nothing: Every and Rev are deterministic.

cargo test -p claudius --test script palindrome    # passes
cargo test -p claudius --test pattern_laws         # 256 generated patterns, palindrome twice included
cargo test -p claudius --test script               # nothing else changed

Then try it the way a user would, and check the round trip by eye:

echo 'blank("house"); track("hats", s("hh hh*2 oh hh").palindrome());' | claudius script - --print

The output should contain track("hats", s("hh hh*2 oh hh").palindrome()); exactly. Render a short song with it and listen: claudius script with the same code and --no-ears writes a song folder in a few seconds.

The documentation

A language change is not finished until the people using the language can find it:

  1. The language reference (docs/src/language/pattern-functions.md): an entry in the section on time, with what it does, an example and what you hear. Use a plain js fence, so docs_examples runs it; once the method exists it passes. On this page the example is marked no-run, because the method does not exist yet:

    track("perc", s("rim ~ rim [rim rim]").palindrome());   // the figure, then the figure backwards
    
  2. The quick reference (docs/src/language/quick-reference.md): one row.

  3. crates/claudius/src/script_reference.md: add .palindrome() to the time row. This file is built into the program; claudius reference, GET /api/reference and the MCP tool script_reference return it, so it is what assistants read before writing scripts. the_reference_examples_run in tests/script.rs runs its examples.

  4. Coming from Strudel, if it lists palindrome as missing.

Then run the examples:

cargo test -p claudius --test docs_examples

Before the pull request

cargo xtask ci

runs formatting, clippy with warnings as errors, every test and the docs examples, as CI will. Fix what it reports rather than silencing it; Code style and review explains the few lints the engine allows and why.

The pull request

The title is a Conventional Commit, written for the people who use claudius, because it becomes the commit message on main and the line in the changelog:

feat(script): add .palindrome() to patterns

The description follows the template: what changes and why (“Strudel users look for palindrome; it is lastOf(2, rev) under a name they know”), how you checked it (the tests, and that you listened to a render), and anything a reviewer should look at closely (here, nothing: no new node, no change to printing).

A maintainer reviews it against the checklist in Code style and review. Changes requested on the branch are pushed as new commits; the pull request is squash-merged with its title, so the history on main stays one line per change. You never touch the version or the released sections of CHANGELOG.md: the release process writes the changelog from the titles (Releasing). A note that needs more than the title, such as an upgrade step, goes under ## [Unreleased] in the same pull request.

A genre tweak

The other common change is musical: a different weight, template or form in a genre. Say house’s hats should play the open hat less often. In crates/claudius/data/genres/house.json, the ohat lane’s template has a probability per sixteenth; lower the ones you want rarer, then:

cargo test -p claudius --test generators       # fails: the house snapshot changed
cargo insta review                             # read the diff, accept it if it is what you meant
claudius make --genre house --seed 42 --short --no-ears    # and listen

The snapshot in crates/claudius/tests/snapshots/generators__house.snap shows the change as step strings, so the reviewer reads drum ohat main (drum open_hat): ..x...x...x...x.|..x...x...x...x.|... becoming something sparser and can judge it without running anything. Say in the pull request how it sounds, before and after. A genre file can also be tried without rebuilding: put a copy with the same name in your genres folder (Files and settings).

Code style and review

The short version: run cargo xtask ci before you push, make the change do one thing, and write code that reads like the code around it. The rest of this page is what that means here, and what a reviewer looks at.

Formatting and lints

  • rustfmt with the repository’s rustfmt.toml: max_width = 120 and use_small_heuristics = "Max", so short expressions stay on one line and lines may run to 120 columns. cargo fmt --all formats everything; CI checks it.
  • clippy must be clean with -D warnings on every target: cargo clippy --workspace --all-targets --locked -- -D warnings. The studio is also checked for wasm32-unknown-unknown, since native builds never compile its web-only code, and the builds without the ears or without the studio window are checked too, since code behind cfg(feature = ...) compiles only there. cargo xtask ci runs all of these except the web one, which is cargo clippy -p claudius-ui --target wasm32-unknown-unknown --locked -- -D warnings.
  • The engine allows four lints crate-wide, in crates/claudius/src/lib.rs: needless_range_loop, too_many_arguments, type_complexity and should_implement_trait. DSP code reads best as index loops over parallel buffers, and processors take their full parameter lists. Do not add allows elsewhere without a comment that says why the lint is wrong in that place.
  • typos checks spelling in code, comments and docs. Domain words it does not know (caf, thr) go in _typos.toml with a comment.
  • Edition 2024 and the minimum Rust version in Cargo.toml (rust-version). Let-chains (if let ... && let ...) are used throughout; use them where they read better than nesting.

How the code reads

  • Like its neighbours. Match the naming, the error style and the density of the module you are in. A new function in theory/ looks like the functions already there.
  • Comments state what the code cannot. A constraint, a reason, a unit, a source: // a noise-built tail reads as hiss above ~9 kHz: real rooms and plates are darker up there, or // SAFETY: set once, before any thread of this test binary reads the environment. Not what the next line does, not the history of the change, not why your version is right; that belongs in the pull request.
  • Doc comments are short lowercase phrases that say what a thing is or returns: /// the sound for a genre's preset entry: alternatives separated by |, the first that exists wins. Module comments (//!) explain the module’s job in a paragraph, so a reader knows whether they are in the right file.
  • Units are in the name when they are not the defaults (beats, MIDI pitch, dB, 48 kHz): attack_ms, size_mb, lufs.
  • Errors are written for the person who will read them. Say what was wrong, show the value, and say what would work: cannot read key 'H minor' (try "A minor", "F#m", "Eb", "D dorian"). Script functions return String errors that surface verbatim in the studio and in MCP replies; binaries use anyhow with context. Nothing panics on user input, a file’s contents or a network answer.
  • Randomness is seeded. Draw from rng::Rng, seeded from the song, forked per area with a label; patterns and the pack player use hashes of event positions. Never a thread-local generator or the clock.
  • Data over code. Musical knowledge goes in genre files, effects are serde data, and new fields in serialised types get #[serde(default)], so older blueprints, genre files and API clients keep working.

What reviewers check

Every pull request is read for these. Pre-empting them in the description saves a round trip.

  1. One thing, explained. The change does one thing, and the description says why, not only what.
  2. Tests. New behaviour is tested; a bug fix comes with a test that failed before it. Music-facing changes show their effect: a snapshot diff, a verdict, or a render you listened to and describe.
  3. Docs. User-visible behaviour changes the book in the same pull request, js examples included, and script_reference.md when the script language changes, since assistants read it.
  4. Determinism. The same seed still gives the same song, or the snapshot diff shows exactly what changed and the description says it is intended.
  5. The round trip. Anything new that a blueprint stores and a script can say prints back into the same blueprint (script/print.rs, and the tests in tests/script.rs).
  6. The sandbox and the file policy. New script functions do no I/O beyond what resolve_sound already does; new commands that take paths check them with config::path_allowed or config::write_allowed; nothing new listens on the network or skips the guard (Security model).
  7. Error messages. Read the failure path: is the message one a person can act on?
  8. Performance. Rendering and pattern queries are hot paths: no allocation per sample, no lock held across a render. For changes there, cargo bench -p claudius before and after.
  9. Compatibility. The HTTP API’s types and the blueprint format stay readable by older clients and files: every song a release saved still opens (tests/compat.rs holds a change to it), and a new field has a serde default. A change songs cannot take as they are raises BLUEPRINT_FORMAT with an upgrade step; a breaking change is marked ! in the title and explained.
  10. Dependencies. A new crate needs a reason, a licence on deny.toml’s list (permissive, plus MPL-2.0), and preferably no C. Sample data needs a licence that fits 0007.
  11. The title. A Conventional Commit that makes sense as a changelog line to someone who uses claudius.

Reviewing

Review the change, not the person: say what you would do instead and why, and separate what must change from what you would merely prefer. Approve when the checklist holds; small follow-ups can be issues. A maintainer merges with squash, using the pull request’s title, once CI’s ci ok passes.

When you review a pull request written with an AI agent, review it like any other: the author is responsible for it, and Working with AI agents describes what is expected.

Writing docs

The docs have three audiences, and each page is written for one of them:

  • People who have never made music. The “Learn to make music” chapters. Every musical word is explained the first time it appears, with something to listen to.
  • Producers and musicians. The “For producers” chapters and the cookbook. They know what a ii–V–I or a sidechain is; tell them how claudius does it and where it differs from their DAW.
  • Engineers. The engineering chapters, the design records and the contributor guide. They want to know how the code works and why it is built that way.

The language reference serves everyone: it is the precise description, and the tutorials link into it.

Voice

Write the way you would explain something to a colleague sitting next to you: plainly, in full sentences, with the thing on screen. Use “you”. Use the present tense. Say what happens, not what “can” or “will” happen.

Be specific. “The kick drops out for the last bar of a build” is useful; “builds add tension” is not. If a sentence would be true of any music software, cut it.

Prefer one good example to three paragraphs of explanation. Show the code, say what you will hear, then explain the part that is not obvious.

Keep paragraphs short. A page that needs a summary at the end is too long or badly ordered.

Words we do not use

These either say nothing or sound like a brochure: comprehensive, robust, seamless, leverage, delve, unlock, empower, elevate, harness, cutting-edge, state-of-the-art, game-changer, effortless, blazing, powerful, world-class, journey, dive in, deep dive, in today’s world, whether you’re X or Y, it’s worth noting, in conclusion.

Avoid simply, just and easy: if the reader is stuck, they now also feel stupid. Avoid exclamation marks and emoji. Do not open a page by restating its title, and do not end it by restating what it said.

Formatting

  • Headings in sentence case: “Song structure”, not “Song Structure”.
  • Code, function names, file names and commands in backticks: track(), song.rhai, claudius serve.
  • Units with a space: 124 BPM, −6 dB, −9 LUFS, 8 kHz. Bars and beats are counted from 1 in prose (“the snare on beats 2 and 4”), steps from 1 too (“a hit on step 5”).
  • Tables are for reference material (function lists, parameters). Explanations go in prose.
  • Link the first mention of a function to its entry in the language reference.

Examples are tested

Every fenced block marked js is run by cargo test -p claudius --test docs_examples, so an example that stops working fails the build. The fence’s info string says how:

fencewhat the test does
```jsruns it with the sample packs switched off, as on CI; it must succeed
```js packsruns it when the sample packs are installed (it uses sampled instruments or kits)
```js errorruns it; it must fail (for showing error messages)
```js no-runshows it without running it (fragments, signatures)

An example runs against an open song: house, seed 9, with the tracks kick clap hat ohat shaker perc bass chords pad lead arp, the sections intro groove break drop outro and the progressions A and B. An example that starts its own song with song(...) or blank(...) does not depend on any of that.

Lines starting with ~ are run but hidden on the page, for setup the reader does not need to see:

blank("house");
tempo(124);
track("kick", s("bd*4"));

When you show output (a verdict, a log line, an error), paste what the program printed. Do not paraphrase it.

Music terms

In the “Learn to make music” chapters, explain a term the first time it is used and give the reader a way to hear it. “A bar is a group of four beats; count 1 2 3 4 along with the kick” is better than a definition from a dictionary.

Elsewhere, assume the reader knows the term, and link to the beginner chapter that explains it.

Accuracy

Run what you describe. If the docs and the program disagree, the program is right and the docs get fixed, or the program has a bug and gets an issue. Do not describe features that do not exist yet.

Checking your work

cargo test -p claudius --test docs_examples   # every example runs
cargo xtask docs                               # builds the book into target/book

Releasing

A release is made by merging a pull request. Nobody bumps a version or pushes a tag by hand: one workflow opens the release PR, and merging it builds the archives, publishes the GitHub release, updates Homebrew and deploys the website. A person can do all of it in the GitHub web interface, and an agent with gh (see Working with AI agents).

Versions

claudius follows semantic versioning. The version lives in one place, version under [workspace.package] in the root Cargo.toml, and every crate that says version.workspace = true has it. (earshot keeps a version of its own, and xtask is never released.)

Before 1.0, the minor version is the one that may break things:

bumpfrom 0.4.2for
patch0.4.3fixes, and changes that leave scripts, saved songs, the HTTP API and the MCP tools working as they did
minor0.5.0new features, and anything that changes the script language, the song format, the API or the tools (the changelog says how)
major1.0.0the promise that none of that breaks again without a major version; a decision, not a routine bump

An exact version works too: 0.5.0-rc.1 makes a pre-release, which GitHub does not mark as the latest, so the install scripts keep installing the last full release (CLAUDIUS_VERSION=0.5.0-rc.1 installs the pre-release). Homebrew and the website stay on the last full release as well.

Commit messages are the changelog

Pull requests are squash-merged, and the PR title becomes the commit message on main, so the title has to be a Conventional Commit: type(scope): what changed, with ! after the type or scope for a breaking change. The pr title check enforces it. When a release is prepared, git-cliff (cliff.toml) sorts the commits since the last tag into the sections of CHANGELOG.md:

titlesection
feat(script): add euclid() to patternsAdded
fix(server): keep the session when a render failsFixed
fix(security): ...Security
perf(render): mix the stems in parallelChanged
docs: a chapter on swingDocumentation
refactor, test, build, ci, choreleft out, unless breaking (refactor!: ...), which goes to Changed

Breaking changes are marked Breaking: in their section, and (#123) at the end of a title becomes a link to the pull request.

Anything worth more than a line (what a release is about, how to update scripts that break) goes under ## [Unreleased] in CHANGELOG.md, in the pull request that makes the change. The release keeps it at the top of its section and adds the generated entries under the headings of the same name.

Making a release

  1. Start it. From the command line:

    gh workflow run release-pr.yml -f bump=minor
    

    or in the web interface: Actions, release pr, Run workflow. The workflow runs cargo xtask release prepare minor, which changes three things and adds a fourth: the version in Cargo.toml (and the version = "..." of path dependencies between workspace crates, which cargo add --path writes), the workspace entries in Cargo.lock, CHANGELOG.md, where Unreleased becomes ## [0.5.0] - <today> with the notes from git-cliff, and the songs 0.5.0 saves, in crates/claudius/tests/compat/v0.5.0/, which every later release has to open (saved songs keep opening). It commits that to the branch release/v0.5.0 through GitHub’s API, so that GitHub signs the commit (main accepts only signed commits), and opens the PR release: v0.5.0, with the notes in its description.

  2. Let CI run. GitHub starts no workflows for a pull request opened with a workflow’s own GITHUB_TOKEN, so the workflow starts CI on the release branch itself (gh workflow run ci.yml --ref release/v0.5.0); the checks show on the PR as usual. If they are missing, run that command yourself. With the optional RELEASE_TOKEN secret (see below) the PR is opened with that token instead and starts its checks like any other.

  3. Read the notes, then merge. To reword them, edit CHANGELOG.md on the release branch. Merge the PR once ci ok passes.

  4. The release builds itself. The merge changes Cargo.toml on main, which starts release.yml:

    • plan decides whether this commit is a release: it is on main and is the merge of the release PR, CHANGELOG.md has a section for the version in Cargo.toml, and there is no published release for it yet;
    • web studio builds the WebAssembly studio once, for every archive;
    • build runs cargo xtask dist --target <target> on one runner per target (below), on Linux cargo xtask dist --target <target> --headless and scripts/package/linux.sh too, which makes the .deb, .rpm and AppImage, and on Windows scripts/package/windows.ps1, which makes the installer with WiX;
    • macOS app puts each Mac build into Claudius.app and a disk image of its own, one for Apple silicon and one for Intel, with scripts/package/macos.sh, signing and notarizing them when the Apple secrets below are set;
    • release notes writes the notes: cargo xtask release notes 0.5.0 and install instructions;
    • publish checks that each build uploaded only its own archives and that they match their .sha256, writes SHA256SUMS for the archives and the install scripts, signs a build provenance attestation for them, creates the release as a draft, uploads the files, and publishes it, which creates the tag v0.5.0 on the merge commit;
    • homebrew tap updates Formula/claudius.rb in devlato/homebrew-tap;
    • site deploys the website with the new version, changelog and docs.

    No job in release.yml restores a cache: everything a release links is built or downloaded (and checked) in that run, so nothing another workflow wrote into the Actions cache can end up in an archive. publish and homebrew tap, which hold the tokens, run no cargo at all, and run in the environment release, which only main and the hotfix branches may use. When RELEASE_REVIEWERS was given to scripts/github-setup.sh, the run waits at publish until one of them approves it (Review deployments on the run’s page).

  5. Check it. Verifying a release below.

To see what release prepare would do without opening a PR, run it locally and look at the diff, then throw it away: cargo xtask release prepare minor && git diff, then git checkout -- ..

What a release contains

filewhat
claudius-0.5.0-<target>.tar.gz (.zip on Windows)the programs, the web studio and the docs that go with them
claudius-0.5.0-<target>-headless.tar.gz (Linux)the same without the studio window, for servers
claudius-0.5.0-aarch64-apple-darwin.dmg, claudius-0.5.0-x86_64-apple-darwin.dmgClaudius.app for Apple silicon and for Intel Macs, to drag to Applications
claudius-0.5.0-x86_64-pc-windows-msvc.msithe Windows installer: the programs on the user’s PATH and the studio in the Start menu
claudius-0.5.0-<target>.deb, .rpm, .AppImage (Linux)packages for Debian and Ubuntu, Fedora and its relatives, and any distribution, with the studio in the app menu
install.sh, install.ps1the install scripts, which releases/latest/download/install.sh points at
SHA256SUMSthe SHA-256 of every archive and both install scripts; the install scripts check downloads against it

Each archive unpacks into a directory of the same name, laid out the way claudius looks for its files:

claudius-0.5.0-aarch64-apple-darwin/
  bin/claudius
  bin/claudius-packs
  share/claudius/web/        the web studio; claudius finds it at <its own directory>/../share/claudius/web
  README.md  LICENSE  CHANGELOG.md
targetbuilt onarchives
aarch64-apple-darwinmacos-latestthe studio and the ears
x86_64-apple-darwinmacos-latest, cross-compiledno ears: ort has no ONNX Runtime for Intel Macs
x86_64-unknown-linux-gnuubuntu-24.04the studio and the ears, and a -headless one; glibc 2.38 or newer
aarch64-unknown-linux-gnuubuntu-24.04-armthe same
x86_64-pc-windows-msvcwindows-latestexperimental, no ears

Without the ears, verdicts use meters and music theory. xtask/src/dist.rs says which targets leave them out (NO_EARS): ort has no ONNX Runtime for Intel Macs, and its Windows ONNX Runtime always links DirectML and DirectX 12, which nobody has run yet. The -headless Linux archives, claudius-0.5.0-<target>-headless.tar.gz, leave out the studio window, so they need neither ALSA nor a display; they still contain the web studio, which claudius serve serves. install.sh picks the headless archive when ALSA’s libasound.so.2 is missing, or with --headless.

The Windows build is experimental: when it fails, the release goes out without it, and the install script tells people on Windows that there is no build for them. The Linux builds cannot target an older glibc: the ONNX Runtime library they link was itself built against glibc 2.38.

The archives do not include ffmpeg, the sample packs or the listening models. claudius setup downloads the packs and models; ffmpeg is optional.

Verifying a release

gh release view v0.5.0
gh release download v0.5.0 --pattern 'claudius-0.5.0-aarch64-apple-darwin.tar.gz' --pattern SHA256SUMS
shasum -a 256 --check --ignore-missing SHA256SUMS
gh attestation verify claudius-0.5.0-aarch64-apple-darwin.tar.gz --repo devlato/claudius \
  --signer-workflow devlato/claudius/.github/workflows/release.yml --source-ref refs/heads/main

The checksum says the file is the one that was uploaded. The attestation says more: that the archive was built by release.yml in devlato/claudius, from a specific commit on main, on GitHub’s runners. gh attestation verify checks its Sigstore signature and prints the workflow and the commit. Without --signer-workflow and --source-ref it would accept a build by any workflow on any branch of the repository. A hotfix release is built from its branch, --source-ref refs/heads/hotfix/v0.5.1; the release notes give the command with the right one.

The installers come with the release and are in its checksums, so curl ... releases/latest/download/install.sh now installs the new version:

gh release download v0.5.0 --pattern install.sh --pattern SHA256SUMS --clobber
shasum -a 256 --check --ignore-missing SHA256SUMS

The website

site.yml builds the site with cargo xtask site, without a cache or secrets, and hands it to a deploy job that does nothing but upload it to a Cloudflare Pages project, in the environment site. It deploys once the site has an address: the repository variable SITE_URL (gh variable set SITE_URL --body https://claudius.example) and the project’s name in SITE_PROJECT, with the Cloudflare secrets below. Until then it builds and checks the site and deploys nothing. It deploys main:

  • after a release, called by release.yml;
  • on pushes to main that change docs/, site/, CHANGELOG.md or the install scripts, so the docs follow main (except the merge of a release PR, which waits for the release, so that the site never shows a version that cannot be downloaded yet);
  • by hand: gh workflow run site.yml.

Pull requests build the site and deploy nothing: a preview would run the Cloudflare token next to a branch nobody has reviewed yet.

cargo xtask site fills in {{version}} (from Cargo.toml), {{site_url}} and {{repo_url}} in the files of site/. The install commands on the site point at the release’s own install.sh and install.ps1, so they work wherever the site is served.

Secrets and settings

secretenvironmentused bywithout it
CLOUDFLARE_API_TOKENsitesite.ymlthe site is built but not deployed (a notice says so)
CLOUDFLARE_ACCOUNT_IDsitesite.ymlthe same
HOMEBREW_TAP_TOKENreleaserelease.ymlthe Homebrew formula is not updated
RELEASE_TOKEN (optional)release-prrelease-pr.ymlthe workflow opens the release PR with its own token and starts CI itself
MACOS_CERTIFICATE, MACOS_CERTIFICATE_PASSWORDreleaserelease.ymlthe Mac app is signed ad hoc, and macOS asks before opening it
MACOS_NOTARY_KEY, MACOS_NOTARY_KEY_ID, MACOS_NOTARY_ISSUERreleaserelease.ymlthe Mac app is signed but not notarized, so macOS still asks

Each secret lives in an environment, not in the repository, so it reaches only the job that needs it, and only when that job runs on main (or, for release, a hotfix branch): a workflow pushed to some other branch cannot read it. Set each one with gh secret set <NAME> --repo devlato/claudius --env <environment>, which reads the value from standard input.

Cloudflare. In the Cloudflare dashboard, My Profile, API Tokens, Create Token: a custom token with the permission Account, Cloudflare Pages, Edit, for your account only, with an expiry date. The account ID is on the Workers & Pages overview. The Pages project has to exist before the first deploy. Its name is also its address, <name>.pages.dev, and has to be free, so pick it first, then tell the workflows about it:

CLOUDFLARE_API_TOKEN=... npx wrangler pages project create <name> --production-branch main
gh variable set SITE_PROJECT --body <name>
gh variable set SITE_URL --body https://<name>.pages.dev # or the custom domain you add to the project

Apple. Both need a membership of the Apple Developer Program. In Xcode, Settings, Accounts, Manage Certificates, add a Developer ID Application certificate, then export it from Keychain Access as a .p12 file with a password: MACOS_CERTIFICATE is the file in base64 (base64 -i developer-id.p12 | gh secret set MACOS_CERTIFICATE --repo devlato/claudius --env release), and MACOS_CERTIFICATE_PASSWORD its password. For notarization, in App Store Connect, Users and Access, Integrations, Team Keys, make a key with the Developer role: MACOS_NOTARY_KEY is the text of the .p8 file it downloads, MACOS_NOTARY_KEY_ID its key ID, and MACOS_NOTARY_ISSUER the issuer ID above the list. With all five, the macOS app job signs the app and its disk image with the hardened runtime, has Apple notarize both, and staples the tickets to them, so the app opens without a warning.

Homebrew. Create the repository devlato/homebrew-tap (a README is enough). Then a fine-grained personal access token (GitHub, Settings, Developer settings, Fine-grained tokens) for that repository only, with Contents: Read and write.

Release PRs. RELEASE_TOKEN is a fine-grained token for devlato/claudius with Contents and Pull requests: Read and write. A GitHub App token works as well.

Fine-grained tokens expire, after a year at most. When one has, the job that uses it fails with a 401; make a new token and set the secret again.

The repository settings that the release depends on (squash merges with the PR title, the ruleset that requires ci ok on main, the ones that keep v* tags from being moved or deleted and let only admins push hotfix/* branches, immutable releases, the three environments, and permission for workflows to open pull requests, which release-pr.yml needs without RELEASE_TOKEN) are made by scripts/github-setup.sh, once the repository exists, and again once it is public. Running it again is harmless. Two of its variables change how releases work:

  • RELEASE_REVIEWERS=devlato makes every release wait for that person’s approval before publish runs. Without it, merging a release PR releases.
  • REVIEWS=1 requires an approving review on every pull request. It also stops workflows from opening (and so from approving) pull requests, so release-pr.yml then needs RELEASE_TOKEN.

An approval only means a person looked if the person is the only one holding their token. An agent that works with the maintainer’s own gh login can approve as the maintainer; give agents a token or an account of their own.

Homebrew

brew install devlato/tap/claudius

The formula is rendered from scripts/homebrew/claudius.rb.tmpl: the release workflow fills in the version and the checksums from SHA256SUMS and pushes Formula/claudius.rb to the tap. It installs the archive with the studio window on macOS (Apple silicon and Intel) and Linux; on Intel Macs its caveats say that the build has no ears.

When a release fails

A build failed. Nothing was published and no tag exists. Fix the cause in a normal pull request, then start the release again by hand: gh workflow run release.yml. It builds main as it is then, so the release includes the fix. (The workflow starts by itself only for changes to Cargo.toml or CHANGELOG.md.)

Publishing failed halfway. A draft release may be left over; the next run of release.yml deletes it and publishes again.

Only Windows failed. It is experimental, so the release is out without its archive. Fix it, and it is in the next release.

The Homebrew or site job failed. The release itself is out. Rerun the failed jobs: gh run rerun <run id> --failed.

Rolling back

A published release stays published: people have downloaded it, and the formula points at it. Do not delete it or move its tag. To stop handing it out while a fix is on its way:

gh release edit v0.5.0 --prerelease     # the install scripts stop picking it
gh release edit v0.4.3 --latest         # releases/latest points at the previous one again

then revert the formula commit in devlato/homebrew-tap, and roll the site back in the Cloudflare dashboard (Workers & Pages, the project, Deployments, Rollback on the deployment before). The fix itself goes out as the next patch release.

Hotfix releases

A fix usually ships with a patch release from main. When main already has changes that should not go out yet, release the fix from the last tag instead:

git fetch --tags
git switch -c hotfix/v0.5.1 v0.5.0
git cherry-pick <the fix's commit on main>
cargo xtask release prepare patch
git add --update && git commit -m "release: v0.5.1"
git push -u origin hotfix/v0.5.1
gh workflow run ci.yml --ref hotfix/v0.5.1          # wait for "ci ok"
gh workflow run release.yml --ref hotfix/v0.5.1

Only admins can create and push hotfix/* branches (a ruleset), and release.yml releases from no other branch but main. The release is built and tagged on the hotfix branch; the site is only deployed from main. Then bring the version and the changelog back to main: open a pull request that cherry-picks the release commit, keeping 0.5.1’s section under Unreleased.

Working with AI agents

AI coding agents work on claudius the way people do: on a branch, through a pull request with a Conventional Commit title, with cargo xtask ci run before every push. Everything after that runs on GitHub and is driven with gh, so an agent can take a change from a branch to a published release, and a person can follow every step in the web interface.

Agents read AGENTS.md at the root of the repository. This page shows the same text, so the rules for people and for agents cannot drift apart. Releasing explains what each release step does.

Rules

  • Never work on main. Make a branch named <type>/<topic>, where the type is the kind of change: feat/euclid-patterns, fix/render-error-session, docs/swing-chapter. Branches named release/* and hotfix/* belong to the release process; do not make them unless you were asked to release.
  • Every change goes in through a pull request whose title is a Conventional Commit, because the title becomes the commit on main and the line in the changelog: feat(script): add euclid() to patterns. The type is one of feat, fix, docs, refactor, perf, test, build, ci, chore; a ! after it marks a breaking change (feat(api)!: rename /api/state). Write it for someone reading the changelog.
  • Run cargo xtask ci before you push. It runs formatting, clippy with warnings as errors (also on the builds without the ears or the studio window), the tests and the examples in the docs, which is what CI checks first. Fix what it finds. If you touched the docs, the site, dependencies or workflows, run the matching checks from docs/src/contributing/setup.md too.
  • Keep the skills in step. skills/ teaches assistants the MCP tools, the commands and the script language. A change to a tool, a command, a message or a function they quote changes the skill in the same pull request; their examples run with the docs examples.
  • Keep the two features apart. Code that needs earshot goes behind #[cfg(feature = "ears")], code that needs the native window behind studio. The Intel Mac and Windows builds have no ears, and the headless Linux build has no window, so both have to keep compiling.
  • Leave rust-toolchain.toml and the workflows’ ubuntu-24.04 alone in a change of your own. Moving to a newer Rust or a newer Ubuntu is a pull request of its own, with the lints the new clippy brings or the fixes the new image needs.
  • Keep a pull request to one change, and describe in it how you checked it.
  • Notes for the changelog that need more than the PR title (an upgrade step, a highlight) go under ## [Unreleased] in CHANGELOG.md, in the same pull request.

Commands

Make a change and open a pull request:

git switch -c feat/euclid-patterns
# edit, then
cargo xtask ci
git add -A && git commit -m "feat(script): add euclid() to patterns"
git push -u origin HEAD
gh pr create --title "feat(script): add euclid() to patterns" --body "What changes, why, and how I checked it."

Watch the checks, and let GitHub merge once the required check, ci ok, passes:

gh pr checks --watch
gh pr merge --auto --squash

If a check fails, read its log (gh run view <run id> --log-failed), fix the cause on the same branch, and push again.

Release, only when the person you work for asks for it:

gh workflow run release-pr.yml -f bump=minor      # or patch, major, or an exact version such as 0.4.0
gh run list --workflow release-pr.yml --limit 1   # the run; `gh run watch <id>` follows it
gh pr view release/v0.4.0                          # the release PR, "release: v0.4.0"; read its notes
gh pr checks release/v0.4.0 --watch                # if no checks appear: gh workflow run ci.yml --ref release/v0.4.0
gh pr merge release/v0.4.0 --squash                # this publishes the release

Then check what was published:

gh run list --workflow release.yml --limit 1       # `gh run watch <id>` until it finishes
gh release view v0.4.0
gh release download v0.4.0 --pattern 'claudius-0.4.0-aarch64-apple-darwin.tar.gz' --pattern SHA256SUMS
shasum -a 256 --check --ignore-missing SHA256SUMS
gh attestation verify claudius-0.4.0-aarch64-apple-darwin.tar.gz --repo devlato/claudius \
  --signer-workflow devlato/claudius/.github/workflows/release.yml --source-ref refs/heads/main
gh release download v0.4.0 --pattern install.sh --pattern SHA256SUMS --clobber && shasum -a 256 --check --ignore-missing SHA256SUMS

Never

  • Push to main or force-push it, or rewrite its history.
  • Get a check to pass by going around it: no --no-verify, no #[allow(...)], #[ignore] or deleted assertions to silence a failure, no edits to workflows or deny.toml that drop a failing check. If a check is wrong, say so in the pull request.
  • Commit secrets, tokens, .env files, downloaded models or sample packs, or anything from target/ and out/.
  • Change the version in Cargo.toml, the released sections of CHANGELOG.md, or v* tags by hand. Versions change only through the release PR that release-pr.yml opens.
  • Start a release or merge a release PR without being asked to.

Changelog

All notable changes to claudius are listed here. The format follows Keep a Changelog, and claudius uses semantic versioning: until 1.0, a minor version may change the script language or the API, and the changelog says how.

Unreleased

Claudius updates itself from this release on: the studio downloads a new release in the background and offers to restart into it, and claudius update does the same in a terminal (Updates). The releases before this one can’t, so install this one the way you installed the last.

0.2.0 - 2026-10-19

Added

  • studio: Download the sample packs from the studio (#17)
  • macos: claudius.app in a disk image, one app for Apple silicon and Intel Macs (#13)
  • windows: An installer, with the studio in the Start menu (#14)
  • linux: .deb, .rpm and AppImage packages, with the studio in the app menu (#15)

Changed

  • critic: Read realness over eight windows, and encode the MP3 while the critic listens (#18)

Fixed

  • packs: Setup installs a pack whose WAV files have a long fmt chunk, and gets the ears when a pack fails (#19)
  • mcp: Wait for the ears to load before exiting, instead of crashing (#20)
  • remake: Play a remake at a tempo its genre plays (#21)
  • What a newcomer misread: download sizes, save messages, ears loading, MCP schemas (#22)
  • critic: Advice a person can act on, instead of z-scores and near ties (#23)

0.1.0 - 2026-10-18

The first public release.

Added

  • A beatmaker that composes, arranges, mixes and masters songs in eight genres (house, deep house, techno, trap, lofi, drum and bass, synthwave, future bass), using real audio only: synthesized drums and synths, 162 sampled instruments from 14 freely licensed sample packs, Apple Loops when GarageBand is installed, and your own files.
  • A critic that judges every render: loudness and tonal balance, the energy arc of the arrangement, music theory checks, and earshot’s CLAP model for genre fit and production quality. Every issue names what to change.
  • Mutation and evolution: songs change the way a producer would change them, and the critic keeps the better ones.
  • A script language: Rhai with Strudel-style mini-notation patterns. Every song prints as a script that runs back into the same song.
  • The studio, a native and web app (egui) with arrangement, step sequencer, mixer, harmony, script and verdict panels.
  • MCP tools, over stdio and HTTP, so Claude, ChatGPT, Cursor, Codex and other assistants can make and edit songs while you listen, and skills that teach them the craft: making and editing songs, remixing recordings, and setting claudius up. claudius connect installs both.
  • Analysis and remakes of any song: tempo, key, chords, sections and groove, rebuilt in any genre.
  • claudius setup, doctor and connect for installing the sounds and models, checking an installation, and configuring assistants.
  • Builds for macOS on Apple silicon and Intel (Intel Macs have no ears: their verdicts come from the meters and the theory checks), Linux on x86_64 and ARM64 with a headless build for machines without sound or a display, and Windows as a preview. One-line installers for each, which come with every release and are in its checksums.