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 lofiwrites a finished song to a folder. See command line.
Where to start
- You have never made music. Install it, make your first song, then follow how a track is built. The chapters explain every term as it comes up and give you something to listen to on every page.
- You make music already. Read claudius for DAW users and keep the quick reference open. If you know Strudel or TidalCycles, start with coming from Strudel.
- You want to work on claudius. Start with a tour of the repository and the architecture.
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
| what | size | where |
|---|---|---|
| 14 sample packs: pianos, electric pianos, strings, brass, woodwinds, mallets, basses, guitars, voice, real drum machines | 2.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_realis 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(orclaude-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 | |
|---|---|
| Space | play or pause |
| Home | back to the start |
| L | loop the song or the selected section |
| ← → | select the previous or next section |
| Cmd+Z, Cmd+Shift+Z | undo, redo (Ctrl on Windows and Linux) |
| Cmd+Enter | run 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:
| layer | its job | in this course |
|---|---|---|
| drums | the beat you move to | Beats |
| bass | weight, and the ground the chords stand on | Bass lines |
| chords | harmony: the mood, happy or sad, calm or tense | Chords |
| melody | the part you remember | Melody |
| structure | when each layer plays | Song structure |
| mix | how loud each layer is, and where it sits | Mixing |
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:
| written | per bar | sounds like |
|---|---|---|
bd*4 | 4 | one per beat |
hh*8 | 8 | two per beat: one and two and |
hh*16 | 16 | four per beat: one e and a two e and a |
[~ hh]*4 | 4, between the beats | the 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 setupinstalls):
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 thetuneabove 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
| sound | character |
|---|---|
sub_bass | pure and deep, almost felt rather than heard |
organ_bass | round with some body; the classic house bass |
saw_bass | buzzy and present, cuts through on small speakers |
808 | a deep boom that drops in pitch: hip-hop and trap |
reese | two 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:
| number | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| note | A | B | C | D | E | F | G | A (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:
| chord | notes | numbers |
|---|---|---|
| Am | A C E | 0 2 4 |
| F | F A C | 5 0 2 (or 7 9) |
| C | C E G | 2 4 6 |
| G | G B D | 6 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 installedpiano,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:
| section | what happens | typical layers |
|---|---|---|
| intro | the song starts small, often only drums | kick, hats |
| groove | the main beat with bass and chords | most of the drums, bass, chords |
| break (breakdown) | the drums stop; space and atmosphere | pad, chords, maybe the melody |
| build | tension rises towards the drop | a snare roll, a rising noise, no kick |
| drop | everything comes back at once | everything |
| outro | the song winds down | kick, 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 renameddrop2automatically. ("*"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: 0and thenduck: 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.mp3andsong.wavin the song’s folder are the finished, mastered song. - The script:
song.rhaiin that folder is the song as claudius prints it, which you can read and edit. Your ownsong.rhaistays the clearest description of what you made. - The rest:
blueprint.jsonis the song in full detail,verdict.jsonthe 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 DAW | in claudius |
|---|---|
| project | a song: its blueprint (blueprint.json, the editable description) and the project it arranges into (project.json, notes and clips on a timeline) |
| arrangement view | the form: sections in order, each listing the tracks that play in it |
| clip or region | a track in a section; it plays from the section’s first bar |
| MIDI clip and piano roll | a pattern (note(), n(), chord()), or a part the theory engine writes |
| drum rack and step sequencer | a drum lane with step strings and named variants, or an s() pattern |
| instrument | a sound: a synth preset, a sampled instrument, a drum kit, an audio file |
| mixer channel | mix(): level, pan, two sends, sidechain depth, mute, solo |
| channel inserts | filters, bitcrusher and saturation on pattern tracks; the genre’s chains elsewhere |
| groups and buses | four fixed buses: drums, bass, music, fx |
| return tracks | one reverb and one delay return, set by the genre |
| sidechain compressor | duck on each track, keyed from the kick |
| master chain and limiter | the genre’s master chain and a loudness stage aimed at a LUFS target (master()) |
| automation | section flags: a lowpass opening in an intro, a highpass sweep in a build, a lowpass closing the outro |
| markers | section names |
| groove templates | swing() (sixteenth swing) and humanize() |
| export | song.wav (24-bit, 48 kHz), song.mp3, and stems with --stems |
| undo history | undo 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
- Write or change the song: a script, an edit in the studio, or a request to an assistant.
- claudius arranges and renders it (10 to 40 seconds for a full song), then judges it.
- Listen. Read the verdict: scores for mix, energy arc, theory, genre and production, with each issue naming what to change.
- 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:
| numeral | A minor | A dorian | A phrygian | A harmonic minor |
|---|---|---|---|---|
II | B | B | B♭ | B |
IV | D | D | D | D |
iv | Dm | Dm | Dm | Dm |
V | E | E | E | E |
VI | F | F♯ | F | F |
VII | G | G | G | G♯ |
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 bVIIin major,IV(dorian) orV7(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 asVI/Ain A minor)..root(), the generated basslines and the critic use it, andkey()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:
| style | mode | example progressions |
|---|---|---|
minor_epic | minor | i VI III VII, VI VII i i, i iv VI VII |
minor_dark | minor | i i VI VII, i VI iv V, i v VI iv |
minor_soul | minor | i7 iv7 VII7 IIImaj7, i7 VImaj7 iv7 v7, iiø7 V7 i7 i7 |
dorian_vamp | dorian | i7 IV7, i7 IV9 i7 VIImaj7, i7 ii7 i7 IV7 |
phrygian | phrygian | i II, i II i VII, i vii i II |
harmonic | harmonic minor | i iv V i, i VI V V, i VI iv V |
minimal | minor | i, i i i VI, i i iv iv, i9 i9 i9 VII |
major_pop | major | I V vi IV, vi IV I V, I vi IV V |
major7_jazz | major | Imaj7 vi7 ii7 V7, ii7 V7 Imaj7 Imaj7, ii9 V9 Imaj9 vi9 |
minor_80s | minor | i VI III VII, i VII VI VII, i III VII IV |
mixolydian | mixolydian | I 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:
| check | passes when |
|---|---|
| melody in key | 95 % of the lead’s notes are in the key |
| chord tones on strong beats | 60 % of the lead’s notes on beats 1 and 3 are tones of the chord sounding there |
| range | the lead spans 19 semitones or less |
| leaps | at most 20 % of the lead’s moves (between notes less than two beats apart) are larger than a fifth |
| bass on the roots | 70 % of the bass notes that start on a chord change are the chord’s root |
| voice leading | in 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:
| feel | mini-notation | step string |
|---|---|---|
| quarters | bd*4 | x...x...x...x... |
| eighths | hh*8 | x.x.x.x.x.x.x.x. |
| sixteenths | hh*16 | xxxxxxxxxxxxxxxx |
| offbeat eighths | [~ hh]*4 | ..x...x...x...x. |
| eighth triplets | hh*12 | 12 steps per bar: drum("hat", "main", "xxxxxxxxxxxx", 12) |
| sixteenth triplets | hh*24 | 48 steps per bar, every second step |
| thirty-seconds | hh*32 | 32 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
| where | how |
|---|---|
| step strings | X 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") |
| defaults | 0.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…), ormainwhen the lane does not have it; intros and outros ask forsparse; 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):
| pack | what | licence |
|---|---|---|
salamander | Yamaha C5 grand piano | CC BY 3.0 |
epianos | Wurlitzer, CP80, Pianet | CC BY 3.0 |
jrhodes | Rhodes Mark I | free for music |
vsco2 | chamber orchestra: strings, brass, woodwinds, harp, timpani | CC0 |
vcsl | mallets, pipe organ, sax, harmonica, world percussion | CC0 |
tr808, tr909, tr707 | Roland drum machines | CC0; free use; public domain |
uzu | a drum kit | Unlicense |
muldjord | an acoustic drum kit | CC BY 4.0 |
freepats | upright piano, organ, guitars, basses, synth pads and leads | CC0 |
doublebass, growlybass | upright and electric bass | CC0 |
voice | a sung vowel and a choir | CC0 |
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 withdrum_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:
| genre | form (bars) |
|---|---|
| house | intro 16, groove 16, break 16, build 8, drop 32, break 8, drop 16, outro 16 (or a 64-bar form) |
| deep house | intro 16, groove 16, break 16, build 8, drop 32, outro 16 |
| techno | intro 32, groove 32, break 16, build 8, peak 32, outro 16 |
| trap | intro 8, hook 16, verse 16, hook 16, verse 8, hook 16, outro 8 |
| lofi | intro 4, A 16, B 16, A 16, outro 8 |
| drum and bass | intro 16, build 16, drop 32, break 16, drop 32, outro 16 |
| future bass | intro 8, verse 16, build 8, drop 16, break 8, build 8, drop 16, outro 8 |
| synthwave | intro 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,altorhalfpattern per section; - progressions: a break on progression
Bchanges colour without changing key (a song without aBplaysAthere); - 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:
- the sound plays its notes, with swing and humanize;
- its inserts run: the genre’s (a chorus on some pads, tape on lofi keys) or the pattern’s (
.hpf() .lpf() .crush() .distort()); - it is measured while it plays and gained to role target + genre offset + level (limited to −40..+30 dB);
- it is ducked by the kick by
duck; - it is panned;
- 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:
| track | level | duck | sends |
|---|---|---|---|
| bass | 0 | 0.45–0.65 (house, techno), 0 (trap 808 with its own kick pattern) | none |
| chords, stabs | 0 to −2 | 0.35–0.45; 0.85 for a future-bass pump | reverb 0.2–0.35, delay 0.05–0.3 |
| pad | −2 to −4 | 0.4–0.5 | reverb 0.35–0.45 |
| lead | 0 | 0.15–0.3 | reverb 0.25–0.35, delay 0.2–0.25 |
| arp | −2 | 0.3–0.4 | delay 0.25–0.3, pan ±0.15–0.2 |
| hats, percussion | 0 | 0 | pan ±0.1–0.35, a little delay on percussion |
| clap, snare | 0 | 0 | reverb 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:
| genre | drums | bass | music |
|---|---|---|---|
| house | compressor −12 dB, 2:1, 12/120 ms | highpass 110 Hz | |
| deep house | compressor −14 dB, 1.8:1 | highpass 100 Hz, high shelf −1.5 dB at 9 kHz | |
| techno | compressor −12 dB, 2.5:1, 10/100 ms; tanh saturation | highpass 130 Hz | |
| trap | compressor −10 dB, 2:1, 8/90 ms | tanh saturation 3 dB | highpass 140 Hz |
| lofi | bitcrusher (11 bits, half rate, 35 %), lowpass 8.5 kHz, compressor −14 dB, 2.5:1 | highpass 90 Hz | |
| drum and bass | compressor −11 dB, 2.5:1, 8/90 ms | tube saturation 4 dB | highpass 140 Hz |
| future bass | compressor −11 dB, 2.2:1 | highpass 130 Hz | |
| synthwave | compressor −12 dB, 2:1 | highpass 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
| genre | master chain | target |
|---|---|---|
| house | highpass 25 Hz, high shelf +1 dB at 10 kHz; compressor −10 dB, 1.6:1; tape saturation | −10 LUFS |
| deep house | highpass 25 Hz; compressor −11 dB, 1.5:1; tape saturation | −11 |
| techno | highpass 28 Hz; compressor −10 dB, 2:1; tanh saturation 3 dB | −9 |
| trap | highpass 24 Hz; compressor −9 dB, 1.8:1; tanh saturation | −9 |
| lofi | highpass 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 bass | highpass 26 Hz; compressor −9 dB, 2:1; tanh saturation | −8.5 |
| future bass | highpass 26 Hz, high shelf +1.5 dB at 10 kHz; compressor −9 dB, 1.8:1; tanh saturation | −9 |
| synthwave | highpass 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:
| check | healthy | issue text begins |
|---|---|---|
| loudness | within 1 LU of the target | loudness -11.6 LUFS, target -10.0 |
| true peak | at most 0.1 dB over the ceiling | true peak -0.6 dBTP over the -1.0 ceiling |
| sub, 20–60 Hz | −14 to −4 dB of the total | too much sub (boomy) / not enough sub |
| low, 60–250 Hz | −7 to −1 dB | the low end is too heavy / a thin low end |
| low mids, 250 Hz–1 kHz | −12 to −4 dB | muddy 250 Hz-1 kHz / hollow low mids |
| mids, 1–4 kHz | −18 to −8 dB | boxy or harsh mids / recessed mids |
| presence, 4–8 kHz | −26 to −15 dB | harsh 4-8 kHz / dull 4-8 kHz |
| air, 8–16 kHz | −30 to −17 dB | fizzy top end / no air above 8 kHz |
| stereo | correlation 0.15 to 0.96 | nearly mono / too wide / phasey |
| low end | correlation at least 0.9 below 150 Hz | low 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,reeseinstead ofsub_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
setcpsorsetcpm;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]) |
silence | silence() |
$: 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 holdafor three cycles. *and/take numbers, not patterns, above 0 and up to 256 (so dofast(),slow()andply()).a ! 3is threeas.|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 isbd:3or the method.n("0 3")on ans()pattern..transpose()is always semitones;.add()adds to the value. Onnote()both move semitones; onn(),.add(2)moves two scale degrees and.transpose(12)moves an octave. Onchord(),.add()moves the voicing range and.transpose()the voiced notes (seeadd()andtranspose())..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)ormix()..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:
| Strudel | claudius |
|---|---|
.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,triangleandsineare 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")(orRolandTR909,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()andevery()take integers;.every(4, f)is the first bar of every four, like Strudel’sfirstOf..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"), andchord("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:
| genre | BPM | swing | modes | master | kits used | ears listen for (and against) |
|---|---|---|---|---|---|---|
house | 120–126 | 0.50–0.56 | minor, dorian, major | −10 LUFS | 909 (45 %), 707 (10 %) | house music (techno, minimal, corporate pop, drum and bass) |
deep_house | 118–124 | 0.53–0.58 | minor, dorian, major | −11 | 909 (30 %), 808 (20 %) | house music (techno, minimal, lo-fi hip hop, jazz) |
techno | 126–134 | 0.50–0.52 | minor, phrygian, dorian | −9 | 909 (50 %), 808 (15 %) | techno music (house, minimal, drum and bass, dubstep) |
trap | 130–150 | 0.50–0.54 | minor, harmonic minor, phrygian | −9 | 808 (60 %) | trap beat (heavy-bass hip hop, lo-fi hip hop, dubstep, house) |
lofi | 70–90 | 0.56–0.63 | major, minor, dorian | −12.5 | 808 (30 %), acoustic (30 %) | lo-fi hip hop beat (jazz, heavy-bass hip hop, solo piano, trap) |
dnb | 170–176 | 0.50–0.51 | minor, dorian, phrygian | −8.5 | acoustic (30 %) | drum and bass music (dubstep, techno, house, trap) |
future_bass | 140–160 | 0.50–0.53 | major, minor | −9 | 808 (30 %) | future bass music (dubstep, corporate pop, trap, house) |
synthwave | 85–118 | straight | minor, dorian | −10.5 | 707 (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:
| field | what it decides |
|---|---|
bpm, swing, humanize | tempo and feel ranges |
modes, harmony, markov, ext, bars_per_chord | keys, harmonic styles, how often a fresh Markov progression is written, chord extensions, chord lengths |
forms | the section plans, with layers, energies and flags |
drums | the 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) |
kits | which recorded drum machines the drums may be played on, and how often |
parts | bass, chords, pad, lead, arp, keys: their presets and instruments, writing styles, registers, density, voicing, and the chance a song leaves the part out |
loops | the Apple Loops a song may layer, by instrument, genre and descriptors |
transitions | risers, impacts, downlifters, reverse crashes, riser length |
mix | the loudness target, reverb and delay, bus and master chains, sidechain release, role level offsets |
query, alts | what the ears should hear, and its confusions |
accepted_flaws | flaws 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:
| area | operators |
|---|---|
| harmony | a 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 |
| drums | add, 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 |
| parts | rewrite a part (new seed); another style; another preset; tweak the patch (cutoff, resonance, release, drive, detune); move the register; change a lead’s density |
| mix | a 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 |
| form | a 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 |
| global | tempo 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.
| option | default | |
|---|---|---|
--start | 4 | fresh songs to start from |
--generations | 5 | |
--population | 4 | parents kept per generation |
--children | 8 | children per generation |
--preview-bars | 32 | bars auditioned per candidate |
--keep | 3 | best 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 Em7is the minor-soul progression (i7 VImaj7 iv7 v7): sevenths everywhere, no dominant pull, so it loops forever. The break moves toB, 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
voicepack’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; thegap()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")withkey("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 thestruct().
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_basskeeps it audible without piling on sub. - An acid line, sixteen steps with accents written as velocities: the
acidpreset’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 the808preset has glide. - A sparser verse:
section("verse", #{ layers: "kick clap 808 keys" })takes the hats out too. - Swap the bell for
pizzicatoorflute(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 Dm9is 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.1dropping 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 theBbmaj7 C9 Am7 Dm9progression, but write a second lead for it, since this one fitsA.
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 (variationsd:1, at a third of the level) in a separate layer of thestack()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 (
updownover 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*16with.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 buzzysaw_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.
stringsis the synth preset, slow attack and vibrato, more Juno than orchestra;square_leadis 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 withprogandn(). 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 Gmaj9is the popI V vi IVwith 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_bassand 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, or0.95for 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,
Xvery likely,xlikely,.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 recording | from the genre and the seed |
|---|---|
| tempo (rounded), key, swing | every sound: kits, Apple Loops one-shots, synth patches, sampled instruments |
progressions A and B, bars per chord | every part’s notes, written by the theory engine on those progressions |
| the sections: lengths (rounded to 4 bars), kinds and energies | which 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 probabilities | the drum lanes the genre has, and its other lanes |
| the bass style, when the genre has it | the 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.mp3cuts the recording into bar loops (2 bars by default,--bars) and drum one-shots, writes them intoout/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>.wavflips 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 namedsample1.
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); seesong().blank("house")replaces it with an empty song: one 8-bar section, no tracks, and the tonic chord as its only progression; seeblank().- 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
| where | how |
|---|---|
| command line | claudius 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 |
| studio | the script tab runs against the open song; every change renders and is judged in the background |
| assistants | the MCP tool run_script (read script_reference first) |
| HTTP | POST /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: the language underneath.
- Mini-notation: the grammar inside the strings.
- Patterns and time: cycles, bars, sections and randomness.
- Pattern functions: every pattern constructor and method.
- Pitch: notes, scales, chords, Roman numerals, voicing and arpeggios.
- Sounds: drum names, synth presets, sampled instruments, kits and files.
- Song functions: tempo, key, progressions, drums, parts and tracks.
- Form and sections: the arrangement and what each section flag does.
- Mixing functions: roles, levels, sends, sidechain, buses and the master.
- Songs as scripts: how a song prints, and the round-trip guarantee.
- Errors and limits: error messages and the limits a script runs under.
- Quick reference: everything on one page.
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
| value | written | notes |
|---|---|---|
| integer | 8, -3 | whole numbers; some functions accept only these |
| decimal | 0.5, 124.0, -6.5 | |
| string | "bd sd" | patterns are strings; \" and \\ escape |
| boolean | true, 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
stackmixes,and|badly: write[a | b], c, nota | 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,
!nrepeats 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.
| syntax | example | meaning |
|---|---|---|
| sequence | bd sd bd sd | the steps share the cycle equally: four steps are four beats |
| rest | bd ~ bd ~, bd - bd - | a step of silence |
| group | bd [sd sd] | a sequence squeezed into one step |
| braces | {bd sd} | the same as [bd sd] |
| stack | bd*4, hh*8 | play together, each with the whole cycle |
| alternation | <a1 f1 c2 g1> | one item per cycle, in turn |
| choice | bd | cp | rim | one sequence per cycle, picked at random |
| fast | hh*8 | the step plays 8 times within its slot |
| slow | [bd sd]/2 | the step’s content spreads over 2 cycles |
| weight | a@3 b | the step takes 3 shares of the cycle (here 3/4) |
| hold | a _ _ b | the same as a@3 b |
| repeat | a!3 b, a ! b | the step repeated as separate steps (a a a b, a a b) |
| drop | hh?, hh?0.3 | the step plays with probability 1 − p (p = 0.5, or the number) |
| euclid | bd(3,8), bd(3,8,2) | k hits spread over n slots, rotated left by r |
| index | bd:3 | a 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.
| pattern | slots (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:
| pattern | words | numbers |
|---|---|---|
s() / sound() | sound names, name:2 for a variation, pack:instrument | sound names (808) |
note() | note names: c, c#4, eb3, fs2 | MIDI note numbers: 60 is middle C |
n() | an error | scale degrees from 0 |
chord() | chord symbols, slash chords, Roman numerals, prog, prog:B | an error |
seq(), and arguments of .gain(), .struct() … | x, t, true | amounts |
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)
| string | error |
|---|---|
bd sd] | unexpected ']' |
[bd sd | expected ']', found the end |
<bd sd | missing '>' |
bd(3,8 | expected ')', 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 . sd | bad number '.' |
bd%4 | unexpected '%' in "bd%4" |
7/V | expected a number, found 'V' (a word starting with a digit does not take a slash) |
bd!300 | a step repeats at most 256 times |
| more than 1024 steps in one sequence | a 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 string | mini-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
| Strudel | claudius |
|---|---|
{a b, c d e} is a polymeter | braces 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 cycles | weights inside < > are ignored; write <a!3 b> |
a ! 3 is a a 3 | a ! 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 choice | one 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:
| duration | cycles | in mini-notation |
|---|---|---|
| a bar | 1 | a |
| a half note | 0.5 | a b |
| a beat (quarter note) | 0.25 | a*4 |
| an eighth note | 0.125 | a*8 |
| a sixteenth note | 0.0625 | a*16 |
| an eighth-note triplet | 1/12 | a*12 |
| a sixteenth-note triplet | 1/24 | a*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 froma; .every(4, f)appliesfon 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, elsegain, 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:
| constructor | kind | values are |
|---|---|---|
s(), sound() | sound | sound names |
note() | note | note names or MIDI numbers |
n() | degree | scale degrees |
chord() | chord | chord symbols, Roman numerals, prog |
seq() | plain | numbers 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 part | seeded by |
|---|---|
?, | in a string | the 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:
| pattern | add(1) moves | transpose(1) moves |
|---|---|---|
note() | one semitone up | one semitone up |
n() | one scale degree up: add(7) is an octave in a seven-note scale, add(5) in a pentatonic one | the 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 up | the root one semitone up |
s() | the sample or drum sound one semitone up | the 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.
| method | what it sets | values |
|---|---|---|
.s(name), .sound(name) | the instrument of a note(), n(), chord() or seq() pattern | a sound name or a file path |
.bank(name) | the drum machine or kit for the names in an s() pattern | tr909, 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() notes | integer; default 3, 2 for .root() |
.voicing(), .voicing(lo, hi) | the range chord voicings stay in | MIDI 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 role | kick, snare, bass, chords, pad, lead … |
.level(db) | level in dB relative to the role’s target | number |
.pan(x) | stereo position | −1 left .. 1 right |
.room(x), .reverb(x) | reverb send | 0..1 |
.delay(x) | delay send | 0..1 |
.duck(x) | sidechain depth from the kick | 0..1; default 0.3, 0 on drums |
.lpf(hz), .cutoff(hz) | a lowpass filter on the track | Hz |
.hpf(hz) | a highpass filter on the track | Hz |
.crush(bits) | a bitcrusher | bits, 1..24 |
.distort(db) | tanh saturation | drive 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.
| note | C1 | C2 | C3 | C4 | C5 | C6 |
|---|---|---|---|---|---|---|
| MIDI | 24 | 36 | 48 | 60 | 72 | 84 |
Note names
note() reads each word as a note name:
- a letter
atog, upper or lower case; - any number of accidentals:
#orsfor sharp,borffor 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".
| name | intervals (semitones from the tonic) |
|---|---|
major, ionian, maj (or no name) | 0 2 4 5 7 9 11 |
minor, aeolian, natural_minor, min, m | 0 2 3 5 7 8 10 |
dorian | 0 2 3 5 7 9 10 |
phrygian | 0 1 3 5 7 8 10 |
lydian | 0 2 4 6 7 9 11 |
mixolydian | 0 2 4 5 7 9 10 |
harmonic_minor, harmonic | 0 2 3 5 7 8 11 |
pentatonic, major_pentatonic | 0 2 4 7 9 |
minor_pentatonic | 0 3 5 7 10 |
blues, minor_blues | 0 3 5 6 7 10 |
chromatic | all 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.
| suffix | chord | notes on C |
|---|---|---|
(none), M, maj | major | C E G |
m, min, - | minor | C E♭ G |
dim, o, ° | diminished | C E♭ G♭ |
aug, + | augmented | C E G♯ |
sus2 | suspended 2nd | C D G |
sus4, sus | suspended 4th | C F G |
5 | power chord | C G |
6 | major 6th | C E G A |
m6 | minor 6th | C E♭ G A |
7 | dominant 7th | C E G B♭ |
maj7, M7, Δ, Δ7 | major 7th | C E G B |
m7, -7, min7 | minor 7th | C E♭ G B♭ |
m7b5, ø, ø7 | half-diminished | C E♭ G♭ B♭ |
dim7, o7, °7 | diminished 7th | C E♭ G♭ A |
add9 | added 9th | C E G D |
madd9 | minor added 9th | C E♭ G D |
9 | dominant 9th | C E G B♭ D |
maj9, M9, Δ9 | major 9th | C E G B D |
m9 | minor 9th | C E♭ G B♭ D |
m11 | minor 11th | C 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.VIin A minor is F;IVin A dorian is D;IIin E phrygian is F. - Case picks major or minor when there is no suffix:
IVis major,ivis 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:
7is a dominant 7th on an upper-case numeral and a minor 7th on a lower-case one;9and6work the same way;add9gives add9 or madd9 by case.°,o,dimmake a diminished triad;ø,ø7,m7b5a half-diminished 7th;°7,o7,dim7a diminished 7th;+,augaugmented;11always 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/Vread 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/Vis D major. They work inprog()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 minor | chord | numeral in C major | chord |
|---|---|---|---|
i | Am | I | C |
iiø7 | Bm7♭5 | ii7 | Dm7 |
III | C | iii | Em |
iv7 | Dm7 | IVmaj7 | Fmaj7 |
v7 | Em7 | V7 | G7 |
V7 | E7 | V/V | D |
VImaj7 | Fmaj7 | vi9 | Am9 |
VII | G | bVII | B♭ |
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():
- 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.
- 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.
- Doubling when there are fewer notes than voices (a power chord): the root first, then the fifth.
- 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:
| mode | order, for a four-note chord |
|---|---|
up | 1 2 3 4 |
down | 4 3 2 1 |
updown | 1 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 anote(),n(),chord()orseq()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:
- With
.bank()set, the name is looked up in that bank only. - Drum names play the synthesized drum voices.
- Synth presets play the synth engine.
- The sample packs: sampled instruments (
rhodes,vsco2:violins) and kit sounds (tr808:sd). - Files: a name containing
/or ending in.wav,.mp3,.aif,.aiff,.flac,.cafor.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):
| name | plays | for the recording, write |
|---|---|---|
epiano | the FM electric piano synth | rhodes, wurlitzer |
keys | the FM keys synth | piano, upright |
organ | the drawbar organ synth | hammond, church_organ |
strings | the string-machine synth | string_ensemble, violins |
pad, warm_pad | the synth pads | synth_pad, new_age_pad, bowed_pad |
shaker, cowbell | the synthesized drum voices | vcsl: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.
| names | voice | role |
|---|---|---|
bd, kick | kick | kick |
sd, snare | snare | snare |
cp, clap | clap | clap |
hh, ch, hat | closed hi-hat | hat |
oh, ohat | open hi-hat | ohat |
rd, ride | ride | ride |
cr, crash | crash | crash |
lt, mt, ht, tom | tom (low 90 Hz, mid 130 Hz, high 180 Hz; tom is the default tuning) | tom |
rim, rs | rim shot | perc |
sh, shaker | shaker | shaker |
cb, cowbell | cowbell | perc |
snap | finger snap | perc |
perc | a tuned percussion hit | perc |
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.
| preset | sound | mono | assumed role |
|---|---|---|---|
sub_bass | a sine sub with a little drive | yes, tied notes | bass |
808 | a sine with a pitch drop, long decay and heavy drive; glides | yes | bass |
reese | two detuned saws and a saw an octave down, a slowly moving ladder filter; glides | yes | bass |
acid | one saw into a resonant ladder filter with a snappy envelope; velocity opens it; glides | yes | bass |
saw_bass | detuned saws and a square an octave down, filter envelope | yes | bass |
pluck_bass | square and saw with a short filter pluck | yes | bass |
organ_bass | three sine drawbars (fundamental, octave, two octaves) | yes | bass |
wobble | detuned saws and a square, ladder filter swept by a tempo-synced LFO | yes, tied notes | bass |
pluck | three detuned saws and a square an octave up, fast filter decay | no | by pitch |
supersaw | seven detuned saws plus three an octave down, delayed vibrato | no | by pitch |
pad | five saws and a triangle, slow attack, long release, slow filter drift | no | by pitch |
warm_pad | triangles and a pulse, slower and darker than pad | no | by pitch |
stab | detuned saws and a square, short with a filter snap | no | by pitch |
epiano | FM tine electric piano | no | by pitch |
keys | brighter FM keys with a shorter decay | no | by pitch |
bell | FM bell, long decay | no | by pitch |
organ | five sine drawbars, sustained | no | by pitch |
strings | five detuned saws, slow attack, delayed vibrato | no | by pitch |
square_lead | square and saw an octave up, vibrato; glides | yes | by pitch |
chip | a 25 % pulse, short | no | by 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.
| family | names |
|---|---|
| pianos and keys | piano (Salamander grand), upright, rhodes, wurlitzer, cp80, pianet, fm_piano |
| organs | hammond, church_organ |
| strings | string_ensemble, violins, violas, cellos, contrabass, pizzicato, spiccato |
| brass and winds | brass, brass_stabs, trumpet, horn, trombone, tuba, woodwinds, flute, oboe, clarinet, bassoon, piccolo, sax, harmonica |
| mallets and bells | harp, marimba, vibes, xylophone, glock, bells, kalimba, timpani |
| bass and guitar | upright_bass, bass (electric), pick_bass, synth_bass, guitar (nylon), electric_guitar, jazz_guitar |
| voices | choir, voice |
| synths, recorded | synth_pad, new_age_pad, bowed_pad, choir_pad, synth_strings, synth_brass, lead |
| percussion | bongo, 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:
| bank | aliases | what |
|---|---|---|
tr909 | 909 | Roland TR-909 |
tr808 | 808 | Roland TR-808 |
tr707 | 707 | Roland TR-707 |
muldjord | acoustic, acoustic_kit, rock_kit | a recorded acoustic kit |
uzu | the 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"));
c4plays 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 withdrum_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.
| option | type | default | effect |
|---|---|---|---|
seed | number | 1 | which song |
bpm | number | from the genre’s range | tempo |
key | string | random tonic in one of the genre’s modes | key, e.g. "F minor" |
short | bool | false | halve every section (at least 4 bars), for sketches |
loops | bool | false | build the song around a family of Apple Loops (GarageBand style), when they are installed |
samples | number | 0.75 | chance 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:
| character | hit |
|---|---|
X | accent, velocity 1.0 |
x | hit, 0.8 |
o | ghost note, 0.45 |
1 to 9 | velocity 0.1 to 0.9 |
|, spaces | ignored, 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 asdrum_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.
| field | type | effect |
|---|---|---|
style | string | how the part is written (below) |
preset | string | a synth preset or a sampled instrument; naming the current preset keeps its patch |
register | [low, high] | MIDI range the part is written in |
density | number, clamped to 0..1 | how busy a lead is |
seed | integer | a new seed rewrites this part, and only this part |
rate | number, clamped to 1/32..4 | arpeggio step in beats (0.25 is sixteenths) |
| role | styles |
|---|---|
| bass | sustain, pulse, root8, offbeat, octave, rolling, syncopated, kick_follow (follows the kick, 808 style), acid, walking, reese |
| chords, pad, keys | sustain, half, stabs, offbeat, pump, syncopated, charleston |
| arp | up, down, updown, random, pinky |
| lead | motif, 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()andlayer()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:
| kind | energy | progression | drum variant | flags on |
|---|---|---|---|---|
intro | 0.4 | A | sparse | |
verse | 0.65 | A | main | |
groove | 0.65 | A | main | fills |
build | 0.6 | A | main | build |
drop | 0.95 | A | main | fills |
hook | 0.95 | A | main | fills |
break | 0.35 | B (A when there is no B) | main | |
outro | 0.4 | A | sparse |
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().
| method | sets | values |
|---|---|---|
.name(s) | the section’s name, used by section(), layer() and the studio | string |
.energy(x) | the section’s energy | number, clamped to 0..1 |
.prog(name) | which progression it plays | any progression name |
.variant(name) | which drum-lane variant it plays | main, sparse, alt, half … |
.build() | a build into the next section | also .build(true), .build(false) |
.gap() | a beat of silence at the end | also takes true / false |
.impact() | a crash and an impact on the first beat | also takes true / false |
.fills() | drum fills at 8-bar phrase ends | also takes true / false |
.filter_in() | a lowpass opening across the section | also 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
musicandbassbuses 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 themusicandbassbuses 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()ordrum()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.
| field | type | effect |
|---|---|---|
bars | number | length, clamped to 1..256 |
energy | number | clamped to 0..1 |
prog, variant, name | string | as the methods above |
layers | string, array or "*" | replaces the layers |
build, gap, impact, fills, filter_in | bool | the 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:
| role | target | role | target | |
|---|---|---|---|---|
kick | −12.5 | bass | −14.5 | |
snare | −13.5 | keys | −17.0 | |
clap | −14.5 | chords | −18.0 | |
hat | −17.5 | lead | −18.0 | |
ohat | −18.5 | synth | −18.0 | |
tom | −18.0 | sample | −18.0 | |
perc | −19.5 | arp | −20.5 | |
crash | −20.0 | pad | −21.5 | |
ride | −20.5 | vocal | −15.0 | |
shaker | −21.5 | top | −20.0 | |
drums | −14.5 | texture | −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 (
kickandbdare 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:sdis a snare),percfor percussion from the sample packs (conga,bongo),bassfor the bass synth presets (808is a preset first, so it staysbass),chordsfor achord()pattern,basswhen the track’s median note is below E3 (MIDI 52), andleadfor 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:
| genre | reverb | delay |
|---|---|---|
| house | 2.2 s, 20 ms predelay, low cut 300 Hz | 0.75 beats (dotted eighth), feedback 0.35, ping-pong |
| deep_house | 2.6 s, 25 ms, 280 Hz | 0.75 beats, 0.3 |
| techno | 3.0 s, 30 ms, 350 Hz | 0.5 beats (eighth), 0.45 |
| trap | 1.8 s, 15 ms, 300 Hz | 0.75 beats, 0.3 |
| lofi | 1.6 s, 10 ms, 250 Hz, dark | 0.75 beats, 0.3, dark |
| dnb | 2.8 s, 20 ms, 350 Hz | 0.75 beats, 0.35 |
| future_bass | 2.4 s, 20 ms, 300 Hz, bright | 0.75 beats, 0.35 |
| synthwave | 3.2 s, 30 ms, 250 Hz, wide | 0.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:
| bus | gets | always has |
|---|---|---|
drums | drum 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 |
bass | generated bass parts, and pattern tracks whose role is bass | mono below 140 Hz |
music | everything else | width 1.25 with mono below 160 Hz |
fx | crashes, 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:
.hpf(hz): a highpass filter (resonance 0.1);.lpf(hz)/.cutoff(hz): a lowpass filter (resonance 0.15);.crush(bits): a bitcrusher, 1 to 24 bits;.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
| field | type | range (clamped) |
|---|---|---|
level | number, dB relative to the role’s target | −40..12 |
pan | number | −1..1 |
reverb (or room) | number, send | 0..1 |
delay | number, send | 0..1 |
duck | number, sidechain depth | 0..1 |
mute | bool | |
solo | bool |
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
| field | meaning | range (clamped) | default |
|---|---|---|---|
lufs | integrated loudness of the finished master | −24..−5 | the genre’s |
ceiling | true-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
| where | how |
|---|---|
| command line | claudius script song.rhai --print prints the song a script makes, instead of rendering it |
| any song folder | echo "" | claudius script - --on out/house-crimson-tide-9 --print prints a saved song (an empty script changes nothing) |
| song folders | folders written by claudius script and by saving in the studio (save) have song.rhai |
| studio | the script tab shows the open song as a script |
| assistants | the MCP tool get_song includes it |
| HTTP | GET /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()andmaster()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, amix()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 inmix(). - 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 adrum_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
Xat velocity 0.85 or more,xfrom 0.5,obelow. Reading the string back keeps the exact velocities as long as the string is unchanged, sodrum("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 wrotex.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 with | meaning | example |
|---|---|---|
Syntax error: | the script is not valid Rhai | Expecting ')' to close the arguments list of this function call 'track' |
Function not found: | no function by that name takes these argument types | Function not found: octave (Pattern, f64) |
Variable not found: | a name without quotes | s(bd) instead of s("bd") |
Runtime error: | a claudius function refused its arguments | unknown 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:.
| function | message |
|---|---|
song(), blank() | unknown genre 'polka' (try: deep_house, dnb, future_bass, house, lofi, synthwave, techno, trap) |
song() options | tempo 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 words | track '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 packs | track '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-only | track '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 file | track 'x': /path/vox.wav does not exist; track 'x': /path/vox.wav is outside the folders this claudius may read |
track(), dense patterns | 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; 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-notation | see Mini-notation errors |
| the whole song | the 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 write | what 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 content | the synthesized drums play |
sec("drop", 8).variant("alt") on a lane without alt | the lane plays main |
Limits
A script runs under these limits, so a mistake cannot hang claudius or fill its memory:
| limit | value | error |
|---|---|---|
| running time | 10 seconds | the script ran for more than 10 seconds |
| operations | 5,000,000 | Too many operations |
| function call depth | 64 | Stack overflow |
| expression nesting | 128 (64 inside functions) | Syntax error: Expression exceeds maximum complexity |
| string length | 200,000 characters | Length of string too large |
| array length | 10,000 items | Size 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 query | 262,144 | this pattern is too dense to play: it makes more than 262144 events |
| work per pattern query | 4,000,000 steps | this pattern is too dense to play: it needs more than 4000000 steps of work |
| notes per bar, per track | 1,024 (averaged over 64 bars) | track 'x' plays N notes per bar; the limit is 1024 |
| song length | 512 bars and 20 minutes | the song is N bars long; the limit is 512 |
| tracks | 64, and 12,000 track-seconds (20 tracks of 10 minutes, 40 of 5) | the song has N tracks; the limit is 64 |
| a pattern string | 4096 characters, brackets 64 deep, 1024 steps per sequence, ! 256 times, 256 euclid slots | see Mini-notation errors |
modules and eval | not available | Module 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
| constructor | values |
|---|---|
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 c | sequence over one bar |
~, - | rest |
[a b], {a b} | group in one step |
a, b | together |
<a b c> | one per bar |
a | b | random pick per bar |
a*2, a/2 | faster, slower; above 0, up to 256 |
a@3, a _ _ | weight |
a!3, a ! | repeat |
a?, a?0.3 | drop at random |
a(3,8), a(3,8,2) | euclidean |
bd:3, tr808:sd:2 | variation: a number after the last colon |
F/A, V7/V | a 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
| drums | bd 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) |
| 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 bass guitar choir sax bongo conga ... |
| exact | vsco2: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.
| flag | default | what it does |
|---|---|---|
-g, --genre | house | a genre from claudius genres |
-s, --seed | the clock | the same genre, seed and options give the same song |
--bpm | from the genre’s range | tempo |
--key | from the genre | "Am", "F# minor", "Eb", "D dorian" |
--short | off | halves every section (at least 4 bars), for a quick sketch |
--stems | off | also writes every track to stems/<track>.wav (32-bit float) |
--samples | 0.75 | the chance that a drum lane uses a one-shot cut from Apple Loops or your library instead of a synthesized voice |
--synth-only | off | synthesizes every sound: no sample packs, Apple Loops or user library |
--no-library | off | leaves out Apple Loops and your user library; the sample packs still play |
--sample <file> | none | flips a recording into the song, warped to its tempo and key; repeatable |
--loops | off | builds the song around a family of Apple Loops (their chords, bass and beat), GarageBand style |
--no-ears | off | judges with meters and theory only, without loading earshot’s models |
--refs <path> | none | reference tracks or folders; with two or more, the verdict scores how typical the song is of them |
--tags | off | also runs the AudioSet tagger and prints its labels as held-out evidence |
-o, --out | out | the 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:
| file | what it is |
|---|---|
song.wav | the master, 48 kHz, 24-bit |
song.mp3 | a 256 kbps copy, when ffmpeg is installed |
blueprint.json | the song’s genome: open it, edit it, render it again |
song.rhai | the song as a script, for claudius script or the studio’s script editor |
project.json | the arranged session the blueprint expanded into |
report.json | tempo, key, form, loudness and every track’s level and gain |
verdict.json | the critic’s scores, measures and issues |
credits.txt | attributions, 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
| flag | default | what it does |
|---|---|---|
<file> | the script, or - to read it from stdin | |
--on <path> | none | edits this song (a blueprint.json or a song folder) instead of starting with none |
--print | off | prints the resulting song as a script and renders nothing |
--no-ears, --refs, --tags | as for make | |
-o, --out | out | where 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.
| flag | default | what it does |
|---|---|---|
<blueprint> | the blueprint file | |
--stems | off | stems too |
--no-ears, --refs, --tags | as for make | |
-o, --out | the blueprint’s folder | where 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.
| flag | default | what it does |
|---|---|---|
<blueprint> | the blueprint to vary | |
-n | 4 | how many variations |
-s, --seed | the clock | the random choices |
--no-ears, --refs, --tags | as for make | |
-o, --out | <blueprint folder>/mutations | where 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.
| flag | default | what it does |
|---|---|---|
-g, --genre | house | the genre |
--from <blueprint> | none | starts from this song instead of fresh ones |
-s, --seed | the clock | the fresh songs use seeds seed, seed + 1, …; the search uses it too |
--start | 4 | how many fresh songs start the search |
--generations | 5 | generations |
--population | 4 | parents kept per generation |
--children | 8 | children per generation |
--preview-bars | 32 | bars auditioned per candidate |
--keep | 3 | best songs rendered in full at the end |
--samples, --synth-only, --no-library, --loops | as for make | |
--no-ears, --refs, --tags | as for make | |
-o, --out | out | where 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.
| flag | default | what it does |
|---|---|---|
<file> | any audio file claudius can decode | |
-g, --genre | house | the 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, --seed | the clock | the new song’s own choices |
--bpm | as above | the new song’s tempo |
--no-ears, --refs, --tags | as for make | |
-o, --out | out | the 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+)
| flag | default | what it does |
|---|---|---|
<file> | the audio file | |
-g, --genre | the project’s, or none | the genre the ears compare against; without one they report the closest genre in earshot’s vocabulary |
--no-ears, --refs, --tags | as for make | |
--json | off | prints 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.
| flag | default | what it does |
|---|---|---|
<file> | the audio file | |
-g, --genre | none | a genre whose tempo range guides the beat tracker (else 70 to 180 BPM, centred on 120) |
--json | off | the 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.
| flag | default | what it does |
|---|---|---|
<file> | the recording | |
--bars | 2 | bars per loop |
-g, --genre | none | a tempo range for the beat tracker |
--find <description> | none | also cuts out the moments that best match a description (“a vocal chop”, “a snare hit”); repeatable; needs earshot’s models |
--top | 3 | cuts per --find |
-o, --out | out/chops | the 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/.
| flag | default | what it does |
|---|---|---|
-o, --out | out/sounds | where they go |
Information
| command | prints |
|---|---|
claudius genres | every genre with its tempo range and description, including your own genre files |
claudius reference | the script language reference, the same text /api/reference and the MCP tool script_reference return |
claudius --version | the 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:
| flag | default | what it does |
|---|---|---|
--port | 7878 (CLAUDIUS_PORT) | the port the HTTP server listens on |
--no-ears | off | no earshot models; verdicts use meters and theory |
--synth-only | off | synthesizes every sound: no sample packs, Apple Loops or user library |
--no-library | off | no Apple Loops or user library; the sample packs still play |
--no-auto-render | off | renders 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 withport 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).
| flag | default | what it does |
|---|---|---|
--host | 127.0.0.1 | the 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 |
--public | off | answers 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> | none | a 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
--tokendid not give (or gave wrong), it fails withError: the claudius at <url> asks for a token: pass --token or set CLAUDIUS_TOKEN (a wrong one is refused too); - if none answers and
--urlwas given, it fails withError: 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
| flag | default | what it does |
|---|---|---|
--url | http://127.0.0.1:<port> | a claudius elsewhere |
--token | none (CLAUDIUS_TOKEN) | the token of that claudius |
| session flags | used 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.
| flag | what it does |
|---|---|
-y, --yes | does not ask |
--no-packs | skips the sample packs |
--no-models | skips the models |
--tags | also 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 flag | what it does |
|---|---|
claude-desktop | edits claude_desktop_config.json, and writes the skills as zips into ~/Music/claudius/skills/ to upload in Claude’s settings |
claude-code | runs claude mcp add --scope user ..., and installs the skills in ~/.claude/skills/ |
cursor | edits ~/.cursor/mcp.json, and installs the skills in ~/.cursor/skills/ |
codex | edits [mcp_servers.claudius] in ~/.codex/config.toml, and installs the skills in ~/.codex/skills/ |
print | prints 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 |
--http | connects over HTTP to a running studio instead of starting claudius mcp (not for Claude Desktop or Codex) |
--no-skills | connects the MCP server only |
--remove | takes claudius, and its skills, out again |
--port | the 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 mcpand 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/mcpwhileclaudius studioorclaudius serveruns.
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"] } } }
| system | configuration 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), andclaudius mcpwas not given that token: it stops withthe 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 mcpstarts a session of its own and serves the web studio on--port. Openhttp://127.0.0.1:7878to 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.
| tool | parameters | what it does |
|---|---|---|
new_song | genre (required), seed, bpm, key, loops (false) | composes a new song in a genre, renders and judges it |
get_song | the open song and its verdict, and the whole song as a script | |
run_script | code (required), listen (true) | edits the song with a script |
script_reference | the script language reference (markdown) | |
listen | stems (false) | renders the song if it changed and returns the verdict |
mutate | count (4, 1 to 32), keep_best (true) | tries variations steered by the critic’s issues and keeps the best if it beats the song |
evolve | generations (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 |
status | idle or busy (with progress), the last 25 log lines, the latest verdict | |
undo, redo | takes back or brings back the last change, then renders | |
stop | stops 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 | |
catalog | the genres with tempo ranges, drum names, synth presets, installed instruments and every pack with its licence | |
save | path (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 |
open | path (required) | opens a song folder, a blueprint.json, or a .rhai script (which runs on the open song) |
analyze | path (required) | tempo, key, progressions, sections, drum grid and bass style of an audio file |
remake | path (required), genre, seed, bpm | a new song on the skeleton of an audio file, in a genre’s sounds, at a tempo the genre plays |
command | command (required, an object), listen (true) | sends one raw API command |
Details that matter when you read an assistant’s transcript:
listen: falseonrun_scriptandcommandskips 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.listenwaits up to 240 s,save240 s,evolve280 s andmutate300 s.mutateandevolveanswer with the log lines the job wrote (one per candidate and its score) and then the song.- A
seedis optional everywhere. Without one, the session picks one from the clock;new_songnames it in its message andget_songshows it in the script’s first line, so the song can be made again. savewithout 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.commandcovers the edits the studio makes and scripts do not:add_section,remove_section,move_section,toggle_layer,set_drum_step,set_section(includingprog),set_part(includingreseed) andset_mix(includingsolo).- 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
| route | answer |
|---|---|
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/state | the state at once |
GET /api/state?since=N | the state as soon as its version is past N (long polling) |
GET /api/state?settled=1 | the state once no job runs and the render is of the current song |
POST /api/command | runs one command (JSON body) and answers with a reply |
GET /api/script | the song as a script, text/plain (// no song yet: try song("house") before the first song) |
GET /api/reference | the script language reference, text/markdown |
GET /api/sounds | every sound name: drum voices, synth presets, installed instruments, and every pack |
GET /api/audio/{version}.wav | the master of that render, 48 kHz 24-bit |
GET /api/audio/{version}.mp3 | the same as MP3, encoded on the first request |
GET /api/stems/{version}/{track}.wav | one track of a render made with stems |
/mcp | the MCP endpoint |
GET / and anything else | the 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:
versiongoes up on every change to anything in the state: a log line, the progress of a job, a new render, an edit.song_versionis theversionat which the song itself last changed. A render belongs to the song version it was made from, inrender.version, so the render is current whenrender.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
| field | type | meaning |
|---|---|---|
version | integer | goes up on every change |
song_version | integer | the version of the last change to the song |
song | SongView or null | the open song, null before the first one |
render | RenderView or null | the latest finished render; it may be of an older song version while a new render runs |
verdict | VerdictView or null | the verdict of that render |
busy | string or null | the running job: "rendering", "listening", "mutating: judging 4 variations", "evolving: generation 2 of 3 done", "saving" |
progress | number or null | 0 to 1, when the job knows it |
ears_loading | boolean | earshot’s models are still loading; until they are, verdicts come from the meters and music theory only |
can_undo, can_redo | boolean | |
undo_label, redo_label | string or null | what undo would take back, what redo would bring back |
log | strings | the last 200 lines of the session log, oldest first |
genres | GenreInfo list | every genre: name, description, bpm range |
SongView
| field | meaning |
|---|---|
title, genre, seed, bpm, key, swing | the song’s settings; key reads like "A minor" |
bars, seconds | its length |
progressions | [name, [numerals]] pairs, "A" and "B" in generated songs |
sections | SectionView per section: name, kind, start_bar, bars, energy, layers, prog, variant and the flags filter_in, build, gap, impact, fills |
layers | every name a section can switch on: drum lanes, parts, loops, pattern tracks |
tracks | TrackView 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 |
drums | DrumView per lane: lane, pattern_bars, and variants as [name, velocities] with one velocity per sixteenth (0 for none) |
parts | PartView per generated part: name, role, style, the styles its genre offers, preset, the presets it may switch to, register (MIDI low, high), density |
script | the song as a script; running it on the song changes nothing |
lineage | what 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
| field | meaning |
|---|---|
version | the 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, bpm | the master’s length and loudness |
sections | [name, start seconds, end seconds], for drawing the arrangement |
waveform | about 1000 peak values from 0 to 1 |
stems | track names with a stem at /api/stems/<version>/<name>.wav (empty unless rendered with stems) |
VerdictView
| field | meaning |
|---|---|
total | 0 to 100 |
summary | one 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 |
ears | EarsView 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.
cmd | fields | what it does |
|---|---|---|
new_song | genre; optional seed, bpm, key, loops (false) | composes a new song |
load | path | opens a song folder, a blueprint.json, or a script file (run on the open song) |
save | optional path | writes blueprint.json and song.rhai now, then the whole folder from a render (see Jobs); default folder ~/Music/claudius/<genre>-<title>-<seed> |
set_tempo | bpm | as tempo() |
set_key | key | as key(): the progressions follow a change of mode |
set_swing | swing | as swing() |
set_progression | name, chords (strings) | as prog() |
set_section | index; optional name, bars, energy, layers, prog, variant, filter_in, build, gap, impact, fills | changes any of a section’s properties |
add_section | after, kind, bars | inserts a section after index after, with that section’s layers, energy and progression |
remove_section | index | removes a section (a song keeps at least one) |
move_section | from, to | moves a section |
toggle_layer | section, layer | switches a track on or off in a section |
set_drum_step | lane, variant, step, velocity | sets one sixteenth (0 removes the hit); a new variant starts as a copy of main |
set_drum_pattern | lane, variant, steps | a whole variant in step notation, as drum() |
set_part | name; optional style, preset, register ([low, high]), density, reseed (false) | as part(); reseed writes the part again with a new seed |
set_mix | track; optional level_db, pan, reverb, delay, duck, muted, solo | as mix() |
run_script | code | runs a script on the song |
analyze | path | the analysis of an audio file, as text in the reply’s message |
remake | path; optional genre, seed, bpm | a 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 |
mutate | optional count (4, 1 to 32), keep_best (true) | a job: tries variations and keeps the best if it beats the song |
evolve | optional generations (3, 1 to 20), children (6, 2 to 32) | a job: an evolutionary search from the song |
install_packs | optional 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 |
render | optional stems (false) | a job: renders and judges the song |
undo, redo | ||
cancel | stops 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:
| field | meaning |
|---|---|
ok | whether the command did what it was asked |
message | what happened, or why not |
version | the 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:
| status | when | body |
|---|---|---|
400 Bad Request | the JSON is not a command | a Reply whose message starts with not a command: and names the unknown cmd or the missing field |
401 Unauthorized | a token is required and missing or wrong | this claudius needs its token (Authorization: Bearer <token>, or ?token=<token>) |
403 Forbidden | the Host header is not a loopback name and the server is not --public | claudius only answers on localhost (start it with --public and a --token to share it) |
404 Not Found | no such render or stem | no render of version 999, no stem 'kick' in render 13 (render with stems), audio is /api/audio/{version}.wav or .mp3 |
413 Payload Too Large | a command body over 1 MiB | Failed to buffer the request body: length limit exceeded |
415 Unsupported Media Type | a command without Content-Type: application/json | commands 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 withSet-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 alsoSecure;- 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:
| pack | what | instruments | licence | credit required | on disk |
|---|---|---|---|---|---|
salamander | Yamaha C5 grand piano, 5 velocity layers | 1 | CC BY 3.0 | yes | 226 MB |
epianos | Wurlitzer EP200, Yamaha CP80, Hohner Pianet T | 3 | CC BY 3.0 | yes | 21 MB |
jrhodes | 1977 Rhodes Mark I, 5 crossfaded layers | 1 | free for making music; no redistribution of the samples | no | 38 MB |
vsco2 | VS Chamber Orchestra 2: strings, brass, woodwinds, harp, timpani, key-split sections | 36 | CC0 | no | 284 MB |
vcsl | mallets, pipe organ, tenor sax, harmonica, world and orchestral percussion | 32 | CC0 | no | 150 MB |
tr808 | Roland TR-808 by Michael Fischer, every knob at five positions | 16 | CC0 | no | 13 MB |
tr909 | Roland TR-909 by Audio Realism, accent layers and knob sweeps | 11 | free for personal and commercial use, no redistribution | no | 26 MB |
tr707 | Roland TR-707, all 15 sounds | 13 | public domain | no | 0.5 MB |
uzu | the uzu drum kit made for Strudel | 15 | Unlicense | no | 2.4 MB |
muldjord | MuldjordKit, an acoustic rock kit (bank acoustic) | 11 | CC BY 4.0 | yes | 135 MB |
freepats | upright piano, FM piano, drawbar organ, basses, guitars, synth pads, strings and leads | 19 | CC0 | no | 137 MB |
doublebass | double bass pizzicato with round robins | 1 | CC0 | no | 30 MB |
growlybass | Squier Jazz Bass, fingered | 1 | CC0 | no | 67 MB |
voice | a low male “ah”, and a 5-voice choir made from it | 2 | CC0 | no | 27 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:
| pack | what | licence |
|---|---|---|
splendid | Steinway grand released into the public domain by AKAI, 4 layers (77 MB) | public domain |
piano-lite | Strudel’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.
| pack | what | why it is opt-in |
|---|---|---|
tidal-drum-machines | Strudel’s default banks, about 70 drum machines | no licence is declared and the provenance is unknown |
dirt-samples | the TidalCycles Dirt-Samples | no licence, and some samples come from commercial records |
mridangam | mridangam strokes by Arthur Carabott | CC 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
| flag | what it does |
|---|---|
--defaults | every default pack |
--lazy | records the pack now and downloads each sample the first time it plays |
--threads N | parallel downloads (8) |
--accept-licence | allows 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 write | plays |
|---|---|
piano | a friendly alias: the first installed of salamander:piano, splendid:piano, freepats:upright_piano, piano-lite:piano |
vsco2:violins | that pack’s instrument, by name or by one of its aliases (vsco2:violin_ensemble) |
tr808:sd:3 | variation 3 of that instrument |
kick, bd, hh | a bare drum name: the first installed kit of tr909, tr808, muldjord, tr707, uzu that has it |
RolandTR909_bd | a Strudel bank name: bank RolandTR909, sound bd |
bd with bank tr909 | that 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:
| bank | also | kit |
|---|---|---|
tr909 | 909, rolandtr909, tr-909 | Roland TR-909 |
tr808 | 808, rolandtr808, tr-808 | Roland TR-808 |
tr707 | 707, rolandtr707, tr-707 | Roland TR-707 |
muldjord | acoustic, acoustic_kit, rock_kit | MuldjordKit |
uzu | uzu-drumkit | uzu 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 ofbd,sdand 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 forbdandsd, 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
| command | what it does |
|---|---|
claudius-packs instruments | every 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,
--barsbars 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.wavandhat.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(thensample2…), with the rolesample, 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:
c4at 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,.cafor.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 runs | the 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 withload/open, or given toanalyzeorremakemust exist and, after links and..are resolved, lie inside one of the folders. Opening ablueprint.jsonalso checks every audio file the song plays. - Writing.
savemay 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>/../escapeis 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
| what | macOS | Linux | moved by |
|---|---|---|---|
| songs saved from the studio and the MCP tools | ~/Music/claudius | $XDG_MUSIC_DIR/claudius, else ./out | the 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/claudius | CLAUDIUS_HOME |
| sample packs | ~/Library/Application Support/claudius/packs | ~/.local/share/claudius/packs | CLAUDIUS_PACKS |
| scratch space while a pack installs | ~/Library/Caches/claudius/packs | ~/.cache/claudius/packs | |
| listening models | ~/.cache/earshot-rs | ~/.cache/earshot-rs | EARSHOT_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/web | the same | CLAUDIUS_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:
| file | what it is |
|---|---|
song.wav | the master, 48 kHz, 24-bit |
song.mp3 | a 256 kbps copy, when ffmpeg is installed |
blueprint.json | the song’s genome |
song.rhai | the song as a script |
project.json | the arranged session |
report.json | tempo, key, form, loudness, and every track’s level and gain |
verdict.json | the critic’s verdict |
credits.txt | attributions, when sample packs play |
stems/<track>.wav | every 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.
| folder | what | grows by |
|---|---|---|
decoded/ | every audio file claudius has read, decoded to raw 48 kHz stereo .f32, keyed by path, size and modification time | about 23 MB per minute of audio |
stretched/ | Apple Loops and samples warped to a tempo and key | one file per loop, tempo and key |
samples/ | drum one-shots cut from Apple Loops | a few hundred KB each |
user/library.json | the user library: chops and found sounds from claudius chop | |
renders/<process id>/ | a running session’s renders: <version>.wav, .mp3, and <version>/ with stems | about 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:
--web <dir>, orCLAUDIUS_WEB;share/claudius/webnext to the program’s folder (~/.local/share/claudius/webfor~/.local/bin/claudius);webinside the program’s folder;crates/claudius-ui/distof the checkout the program was built from (cargo xtask webbuilds 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:
| client | file |
|---|---|
| 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 Code | its own configuration, through claude mcp add --scope user |
Environment variables
| variable | default | what it changes |
|---|---|---|
CLAUDIUS_HOME | the cache folder above | where caches and session renders go |
CLAUDIUS_PACKS | the packs folder above | where packs are installed and read |
CLAUDIUS_PACKS_CACHE_MB | 3072 | memory for decoded sample-pack audio |
CLAUDIUS_PORT | 7878 | --port of studio, serve, mcp, doctor and connect |
CLAUDIUS_TOKEN | none | --token of serve and mcp |
CLAUDIUS_WEB | found as above | the web studio folder |
CLAUDIUS_FFMPEG | ffmpeg on PATH, then /opt/homebrew/bin, /usr/local/bin, /usr/bin | the ffmpeg claudius uses for formats it cannot decode and for MP3; none turns it off |
CLAUDIUS_DEBUG | unset | when set, renders print their timings and the beat tracker its cues to stderr |
DO_NOT_TRACK, CLAUDIUS_TELEMETRY | unset | DO_NOT_TRACK=1 or CLAUDIUS_TELEMETRY=0 keeps usage statistics off whatever the answer |
CLAUDIUS_TELEMETRY_URL | the Worker in telemetry/ | where usage statistics go, to test an endpoint (the only place a debug build sends them) |
CLAUDIUS_UPDATES | on | off stops the studio’s daily look for updates |
CLAUDIUS_UPDATE_SINCE | unset | a version for the updater to act as, to try an update against the latest release |
EARSHOT_HOME | ~/.cache/earshot-rs | where the listening models and text embeddings go |
EARSHOT_OFFLINE | unset | 1 never downloads models; a verdict without them uses meters and theory |
EARSHOT_THREADS | half the cores, at most 4 | threads per model |
EARSHOT_FFMPEG, EARSHOT_FFPROBE | found like CLAUDIUS_FFMPEG | the ffmpeg and ffprobe earshot decodes reference tracks with |
GITHUB_TOKEN, GH_TOKEN | unset | sent to GitHub’s API while installing packs, for its higher rate limit |
CLAUDIUS_SERVER | http://127.0.0.1:7878 | the server the standalone claudius-ui program connects to |
CLAUDIUS_MOCK | unset | 1 makes claudius-ui run against its built-in demo song |
CLAUDIUS_UI_PLAN | unset | a 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:
| event | when | detail |
|---|---|---|
started | a command starts: studio, serve, mcp, make and the rest | none |
setup | claudius setup, or the studio’s pack download, ends | ok, models (the models did not download), or packs:<name> (the first pack that did not install) |
panic | claudius stops on a panic, a bug | the 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(oroff);CIset, as on continuous integration;- a debug build (
cargo runwithout--release), which sends only toCLAUDIUS_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.mjschecks that the Worker takes what claudius sends and refuses everything else; a test inclaudius-clichecks that it knows every command.npx wrangler deployintelemetry/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 (ordays) by event, command, version and system. It needsCLOUDFLARE_ACCOUNT_IDandCLOUDFLARE_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 with | the 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 AppImage | the new AppImage takes the old file’s place |
the install script on Linux, or on a Mac with --no-app | the new archive’s programs and web studio take the old ones’ place in ~/.local |
the Windows installer, or install.ps1 | the new installer runs once Claudius has closed |
Homebrew, cargo, the .deb or .rpm, a build from source | nothing 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-packsonce while online. EARSHOT_OFFLINE=1is set. Then a model that is not cached fails withonnx/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_HOMEpoints 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 ...ora session of its own. - The studio runs on another port. Use the same
--portin both. - The claudius runs with a token (
claudius serve --token, or--public).claudius mcpthen 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_TOKENin the client’s environment, orclaudius 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.
connectwrites the absolute path of theclaudiusthat ran it. If you moved or reinstalled it elsewhere (say from~/.cargo/binto~/.local/bin), runclaudius connectagain. A hand-written entry with plain"command": "claudius"fails in apps that do not see your shell’sPATH. - The configuration file is not valid JSON.
connectrefuses to touch it:<file> is not valid JSON; fix it or move it aside, then run this again. - An HTTP entry with nothing listening.
--httpentries ("url": "http://127.0.0.1:7878/mcp") needclaudius studioorclaudius serverunning.
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.wavand nosong.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 saysFormat 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:
| message | what to change |
|---|---|
the script ran for more than 10 seconds | a loop that does too much; scripts are meant to describe a song, not compute one |
Too many operations | the same, counted in operations (5 million) |
'*' takes a number above 0 and up to 256, not 100000 | a 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 events | fewer 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 work | a 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 1024 | fewer events per bar |
the song is 640 bars long; the limit is 512 | shorter sections, or fewer of them |
the song is 25 minutes long; the limit is 20 | the same at a slow tempo |
the song has 70 tracks; the limit is 64 | fewer 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
| crate | what it is | depends on |
|---|---|---|
claudius | the engine: theory, composition, patterns and scripts, synthesis, effects, rendering, the critic, evolution, analysis, the Apple Loops library | claudius-packs, earshot, claudius-decode |
claudius-decode | audio 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 CAF | symphonia |
claudius-packs | the sample packs: registry, installer, SFZ and Strudel-map readers, name resolution, the multi-sample player, credits; also the claudius-packs binary | claudius-decode |
earshot | a Rust port of the Python earshot: CLAP and the AudioSet tagger through ONNX Runtime, the calibrated vocabulary, realness, measurements | claudius-decode, ort |
claudius-api | the wire types of the HTTP API: plain serde structs, so they build for WebAssembly | serde |
claudius-server | the session (App), the HTTP routes, the MCP tools | claudius, claudius-api, claudius-packs |
claudius-ui | the studio, in egui: a native window and a WebAssembly app, both clients of the HTTP API | claudius-api |
claudius-cli | the claudius binary: one subcommand per task, setup, doctor, connect | all 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
- Generate.
compose::generatemakes 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 aBlueprint: a song’s genome, small (tens of kilobytes of JSON), serialisable and diffable. - 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.
- Arrange.
compose::arrangeexpands a blueprint into aProject, 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 inproject.json(song::write_folder), forjudge. - Render.
render::renderplays 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. - Judge.
judge::judgemeasures 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
| what | where it runs |
|---|---|
| the HTTP server and the MCP transport | a 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 |
| edits | one 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 models | a background thread at startup; verdicts include the ears once it finishes |
| a render | rayon: 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 |
| evolution | rayon: the children of a generation are rendered and judged in parallel |
| synthesis | polyphonic notes of a synth track render in parallel |
| pack downloads | scoped threads, 8 by default |
| earshot | each ONNX session behind a mutex, with EARSHOT_THREADS intra-op threads |
| the studio’s network | ehttp: 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::Rngis xoshiro256++ seeded through splitmix64.Rng::fork(label)derives an independent stream from the current state and the label without advancing it, sogenerategives 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
seedin the blueprint, and its notes come fromseed ^ 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.
?,|,degradeandsometimesdecide 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.rschecks 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
Blueprintis 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
Projectis 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"]}
| field | meaning |
|---|---|
version | the format, BLUEPRINT_FORMAT (1): claudius opens this and every earlier format, and asks for an update to open a newer one (below) |
title, genre, seed | the genre names a profile in data/genres/; the seed is the one it was generated with |
bpm, key, swing, humanize | key 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 |
harmony | named progressions of Roman numerals and how many bars each chord lasts |
form | the sections, in order |
drums | drum lanes with step patterns |
parts | melodic parts written by the theory engine |
loops | recorded loops (Apple Loops, flipped samples) |
patterns | tracks written as pattern code by scripts |
transitions | the crash, riser, impact and their switches |
mix | the reverb and delay returns, bus chains, sidechain release, per-role level offsets, the master |
lineage | what 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:
| field | meaning |
|---|---|
name | unique in the song (drop, drop2) |
kind | intro, verse, build, drop, break, hook, groove or outro |
bars, energy | length, and how intense it should be (0 to 1); the critic compares energy with loudness |
prog | which progression it plays |
layers | the names of the drum lanes, parts, loops and pattern tracks that play in it |
variant | which drum pattern variant its lanes play (main, sparse, alt, half, …) |
filter_in | a lowpass opens across the section |
build | a snare roll, a riser and a highpass sweep into the next section, the kick out for its last bar |
gap | a beat of silence before the next section |
impact | a crash and an impact on its downbeat |
fills | drum 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:
| field | meaning |
|---|---|
role | bass, chords, pad, lead, arp or keys: which writer runs, and its target level |
style | the writer’s style: a bass style (offbeat, rolling, kick_follow, …), a chord rhythm (stabs, pump, charleston, …), an arpeggio pattern, or motif for leads |
register | the lowest and highest MIDI note it may use |
density | how busy a lead is (0 to 1) |
voices, voicing | how many notes a chord has and how it spreads (close, open, spread) |
rate | an arpeggio’s step in beats |
seed | the part’s own random seed |
notes | notes written by hand or by analysis, per progression, which replace the writer |
sound, mix | its 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:
| variant | what 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:
| field | meaning |
|---|---|
level_db | dB relative to the target loudness of the track’s role, not an absolute fader |
pan | −1 to 1 |
fx | insert effects |
reverb, delay | send levels to the two returns, 0 to 1 |
duck | sidechain depth from the kick, 0 to 1 |
bus | drums, bass, music or fx |
mute, solo | while 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:
| field | meaning |
|---|---|
title, genre, bpm, key, swing, humanize, seed | copied from the blueprint |
bars | the total length |
sections | name, kind, start_bar, bars, energy per section |
chords | the chord track: one ChordSpan (start, length, chord) per chord, across the whole song |
tracks | the tracks, each with a role, a sound, notes or clips, a transpose, a chop, a choke group and a mix |
mix | the 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. Withfills, the end of every 8-bar phrase gets a fill of one or two beats on the snare lane (roll,grooveortriplets), 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_barsover 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.
| module | what it does |
|---|---|
genres.rs | loads the genre profiles from data/genres/*.json and your genres folder |
compose.rs | generate, new_part, styles_for, part_sound, kits, flipped samples (add_sample) |
compose/arrange.rs | arrange, cycle_spans, part_cycle |
theory/scale.rs | pitch classes, the seven modes, keys, scale degrees |
theory/chord.rs | chord qualities and symbols, Roman numerals, voicing |
theory/progression.rs | the progression library, the Markov generator, substitutions |
theory/rhythm.rs | drum hits, lane templates, euclidean rhythms, rolls, fills, swing, syncopation |
theory/bass.rs | bass lines in eleven styles |
theory/melody.rs | leads, arpeggios and chord parts |
Genres are data
Everything a genre knows is in one JSON file. The fields generate reads:
| field | what it decides |
|---|---|
bpm, swing, humanize | ranges to draw from, and the looseness |
modes | weighted modes for Markov progressions |
harmony, markov, ext | weighted progression styles from the library, the chance of a Markov progression instead, and its chord extensions (0 triads, 1 sevenths, 2 ninths) |
bars_per_chord | weighted chord lengths |
forms | one or more forms, each a list of sections with layers, energy and flags |
drums | lanes: 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 |
kits | weighted sample-pack drum kits; the weight left over keeps the lanes’ own sounds |
parts | melodic 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 |
loops | Apple Loops parts: instrument, subtype, genre and mood filters, whether tonal |
transitions | riser, impact, downlifter and reverse crash switches, the riser length |
mix | the LUFS target, the reverb and delay, bus and master chains, sidechain release, per-role level offsets |
query, alts | what the ears should hear ("house music") and the genres it is most often confused with |
accepted_flaws | production 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):
- 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;
- 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. ProgressionBis another progression of the same style half the time, else a substitution ofAworked out in the style’s mode. When a key of your own has another mode, both progressions then move to it the waykey()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 frombars_per_chord, and a one-chord progression lasts 4 bars; - form: one of the genre’s forms, every section halved (to at least 4 bars) with
--short; - drums: per lane, a one-shot from the library (with chance
samples, 0.75 unless--samplesor a script says otherwise, the same formake, 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; - parts: per part, unless its
optionalchance 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; - loops from the library, when there is one;
- transitions: a crash (recorded or synthesized) and the genre’s switches;
- 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:
VIin A minor is F,IVin D dorian is G; - case gives the quality:
VImajor,viminor; a suffix overrides it (7dominant on upper case and minor seventh on lower case,maj7,9,6,add9,°,ø7,°7,+,sus4,11); band#before the numeral move the root:bVII,bII;/and a numeral makes an applied chord, read in the key of the target:V7/Vin 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:
- 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.
- It enumerates every way to place the tones inside the register, and scores each: distance from the span the style
wants (
close3 to 14 semitones,open10 to 24,spread17 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. - 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):
| substitution | example |
|---|---|
| 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 it | chord 1: i -> i7 (extended) |
| the chord before becomes the secondary dominant of this one | chord 3: iv -> IV7 (secondary dominant of VII) |
| modal interchange: the same root with the opposite third | chord 4: VII -> vii (borrowed from the parallel mode) |
| a suspension (sus2 or sus4), except on diminished chords | chord 2: VI -> VIsus4 (suspension) |
| swap two neighbouring chords | swapped chords 2 and 3 |
| rotate the progression | rotated 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.
| style | the motif |
|---|---|
sustain, reese | the root held for the whole chord |
pulse | quarter notes |
root8 | eighths on the root, sometimes an octave jump at the end |
octave | eighths alternating root and octave |
offbeat | the off-beat eighths of house, sometimes a pickup on the last sixteenth |
rolling | the three sixteenths after each beat, around the kick |
syncopated | one of five sixteenth grooves, with octaves, fifths and thirds |
kick_follow | a 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 |
acid | sixteenths on a pentatonic, with rests, accents and slides, snapped to the key |
walking | root, 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.
- 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.
- 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.
- 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.
- 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.
| file | what it holds |
|---|---|
pattern/mod.rs | the pattern tree (Node), queries, the work budget, Pattern with its code |
pattern/mini.rs | the mini-notation tokenizer and parser, and its limits |
pattern/notes.rs | from pattern events to notes: sounds, note names, scale degrees, chords and voicing |
script/mod.rs | the engine: run, eval_pattern, the sandbox limits |
script/patterns.rs | the pattern functions and methods registered with Rhai |
script/song.rs | starting a song (song, blank), its settings, prog, bars_per_chord, drum and track |
script/mix.rs | part, drum_sound, mix, mute and solo, remove, master |
script/form.rs | sec, form, section, layer, and the checks on sections |
script/sounds.rs | sound names: drum voices, presets, pack instruments, files, and the role each gets |
script/steps.rs | drum step strings, both ways |
script/print.rs | to_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:
| node | meaning |
|---|---|
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:
Seqvisits 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.Altwithkchildren sends cyclecto childc % k, as that child’s cyclec / k. So<a <b c>>playsa b a c: the inner alternation advances only on its own turns.Fast(f)queries[b·f, e·f)and divides the events’ times byf;Late(t)queries[b − t, e − t)and addst.Revqueries each cycle whole and reflects every event inside it, so an event that ended at the end of the cycle starts at its beginning.Structtakes 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).MaskandCtrlread the other pattern the same way.sometimes(f)buildsSometimes(node, f(Keep(node)), p, seed).Keeppasses the events whose hash is belowp;Sometimesreturns the original events whose hash is at leastpand 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) asksKeepin 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,SometimesandKeepdecide withhash01(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, andsometimesanddegradehash the pattern’srepr. 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,slowandplytake a factor above 0 and up toMAX_SPEED(256), checked when the pattern is built, sos("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-eventPlymakes spends one unit of a thread-local budget,MAX_WORK(4,000,000) pertry_eventscall. A long sequence sped up a lot runs it out, such as a thousand rests and abdplayed*256. - Events. Every event a query makes (an atom’s, a
Plycopy, aStructvalue) 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 ass("[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.
stackhandles,(layers) and|(random choice) aroundseq, which handles steps,_and!.stepreads 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 withfast,slowandply), 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:
| kind | made by | an event becomes |
|---|---|---|
| sound | s(), sound() | a note at pitch 60 played by the sound named in the event; bd:3 or .n() picks a variation |
| note | note() | a pitch from a note name (c3, eb4, octave 3 when left out) or a MIDI number |
| degree | n() | a scale degree in .scale() (default: the song’s key) and .octave() (default 3) |
| chord | chord() | 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() |
| plain | seq() | 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:
| limit | value | set by |
|---|---|---|
| operations | 5,000,000 | set_max_operations |
| running time | 10 s | an on_progress callback that checks the clock |
| call depth | 64 | set_max_call_levels |
| expression depth | 128, 64 inside functions | set_max_expr_depths |
| string, array, map size | 200,000 characters, 10,000 items, 1,000 entries | set_max_string_size and friends |
| modules | none | a DummyModuleResolver, so import fails |
eval | disabled | disable_symbol("eval") |
| files | none, 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.
f32sandf64sprint integers plainly and everything else with Rust’s shortest representation that reads back as the same value, soswing(0.5257842)is exact. - Drum steps. A step string can only say
X,x,oor a digit, so it rounds velocities:hits_to_stepswritesXfrom 0.85 (STEP_ACCENT),obelow 0.5 (STEP_GHOST) andxbetween, 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, asclaudius_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, progressionBfor breaks, thesparsevariant for intros and outros,buildfor builds,fillsfor drops, grooves and hooks), and"*"stands for every track exactly when a section has all of them. - Seeds. A part’s
u64seed 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:
| sound | how it plays |
|---|---|
drum | the 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 |
sample | the file is decoded once (and cached on disk, see Files), sliced to start..end, faded, and played like a drum, pitch relative to root |
synth | the synth engine renders the notes |
instrument | the sample-pack player renders the notes (Sample packs) |
fx | each 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 |
loop | the 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:
| role | target | role | target | role | target |
|---|---|---|---|---|---|
| kick | −12.5 | bass | −14.5 | riser | −19 |
| snare | −13.5 | chords | −18 | impact | −16 |
| clap | −14.5 | keys | −17 | downlifter | −22 |
| hat | −17.5 | pad | −21.5 | reverse | −21 |
| ohat | −18.5 | lead | −18 | drums (loops) | −14.5 |
| shaker | −21.5 | arp | −20.5 | vocal | −15 |
| perc | −19.5 | top | −20 | sample, synth | −18 |
| ride | −20.5 | crash | −20 | texture | −27 |
| tom | −18 | anything 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
lowcutand 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):
- 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).
- If the true peak still pokes over the ceiling, the pass trims the output to 0.05 dB under it.
- 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.
| genre | target |
|---|---|
| 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.
| effect | what it is |
|---|---|
gain | gain in dB (automatable) |
eq | RBJ biquad bands: lowpass, highpass, bandpass, notch, peak, low and high shelf, allpass |
filter | a resonant filter with automatable cutoff: a zero-delay-feedback state-variable filter, or a saturating 4-pole ladder |
compressor | stereo-linked feed-forward compressor, soft knee, gain reduction smoothed in dB |
limiter | the lookahead brickwall limiter |
saturate | a 2× oversampled waveshaper: tanh, tube, tape, hard, fold |
bitcrush | bit depth and sample-and-hold |
reverb, delay | as on the returns, for inserts |
chorus, flanger, phaser | modulated delays and allpass stages |
width | mid/side width, with the side removed below mono_below_hz |
tremolo, autopan | tempo-synced |
gate | a rhythmic volume gate (trance gate) from a list of step gains |
transient | a transient shaper (a fast and a slow envelope split attack from sustain) |
tape, vinyl | wow and flutter with saturation and a lowpass; crackle and hiss |
tape_stop, stutter, reverse | edits 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:
| component | what it looks at | weight |
|---|---|---|
mix | meters on the master: loudness, true peak, tonal balance, stereo width, mono low end | 0.30 |
arc | the energy arc: does loudness follow the arrangement’s energy, is the drop louder than the break | 0.20 |
theory | the score itself: the lead, the bass, voice leading, the form | 0.20 |
genre | the ears: does it read as its genre rather than the genres it is confused with | 0.15 |
production | the ears: does it read as professionally produced rather than as a typical flaw | 0.15 |
refs | the ears, with reference tracks: how typical it is of them | 0.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:
| target | meaning | what evolution tries more often |
|---|---|---|
master | loudness or true peak of the master | |
band:<band>+, band:<band>- | too much or too little energy in a band | levels 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 mono | the same |
form | the arc or the length | layers, section flags, drum variants, section lengths |
part:<name> | a part’s writing | that part’s seed, style, density and register |
genre | it reads as another genre, or unlike the references | presets, styles, drum patterns and sounds, kits, tempo, progressions |
production | it reads as one of the flaws | as 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:
| band | range | healthy share |
|---|---|---|
| sub | 20–60 Hz | −14 to −4 dB |
| low | 60–250 Hz | −7 to −1 dB |
| lowmid | 250 Hz–1 kHz | −12 to −4 dB |
| mid | 1–4 kHz | −18 to −8 dB |
| presence | 4–8 kHz | −26 to −15 dB |
| air | 8–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, whereris the correlation between energies and loudnesses across sections (arc_correlation), and anrunder 0.5 raisesloudness 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 raisesthe 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:
| check | measure | issue when |
|---|---|---|
| the lead stays in key | lead_in_key, the share of lead notes in the key | under 95 % |
| the lead lands on chord tones | lead_chord_tones_on_strong_beats, on beats 1 and 3 | under 60 % |
| the lead is singable | lead_range_semitones | over 19 semitones |
| the lead moves by steps | lead_stepwise, and the share of leaps over a fifth | more than 20 % leaps |
| the bass plays the roots | bass_roots_on_changes, the share of chord changes where the bass hits the chord’s bass note | under 70 % |
| chords move smoothly | voice_leading_<track>, the average movement per voice per chord change, for every chords, pad and keys track | over 4.5 semitones |
| the form is sound | length_s; every section a multiple of 4 bars | under 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 airissues that are style, not fault. judgeon an arbitrary file without itsproject.jsonhas 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).
| level | operators | what one does, in the words it logs |
|---|---|---|
| harmony | harmony_sub, harmony_new | harmony A: chord 2: VI -> iv (third relation); a new progression from a style of the key’s family |
| drums | drum_edit, drum_regen, drum_sound, drum_kit | drums (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 |
| parts | part_seed, part_style, part_preset, patch_tweak, part_register, part_density | bass: 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 |
| mix | mix_level, mix_send, mix_duck, mix_pan, space | mix: 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 |
| form | form_layer, form_bars, form_flags, form_variant | form: 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 |
| feel | tempo, key, swing | tempo 128 -> 126 BPM within the genre’s range; key A minor -> C minor (tonal loops follow); swing nudged |
| library | loop_swap | another 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.
The search
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_windowtakes 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 withProject::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/.
| file | what it does |
|---|---|
onsets.rs | onset 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.rs | tempo by autocorrelation of the onset envelope, beats by dynamic programming (Ellis 2007), then a constant-tempo grid and the downbeat |
chroma.rs | pitch-class energy, keys by Krumhansl-Kessler profiles, chords by template matching |
song.rs | the 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.
| file | what it does |
|---|---|
registry.json, catalog.rs | the curated packs: what each contains, its licence as read from the source, how to fetch and map it |
install.rs | builds 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.rs | instruments from SFZ files, from file names, from Strudel maps |
fetch.rs | HTTP: GitHub listings, parallel resumable downloads, archives, WAV to FLAC |
sfz.rs | an SFZ reader |
strudel.rs | Strudel sample maps and the github: shorthand |
notes.rs | note names, velocity and round-robin markers in file names, drum name synonyms |
model.rs | the installed index format (index.json) |
resolve.rs | names to instruments: aliases, banks, kits |
decode.rs | decoding at the file’s own rate, and the process-wide sample cache |
resample.rs | band-limited interpolation for pitch and rate |
player.rs | rendering notes with an instrument |
lib.rs | the 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), andlicence_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_inwith awarning, orlazy; - where it comes from (
fetch): a GitHub repository at a pinned commit, or an archive URL with a prefix to strip and the archive’ssizeandsha256; flac, to convert WAV downloads losslessly, and bankaliases(909,rolandtr909);- its instruments, each built from one of four kinds of source.
| instrument source | used for | the registry entry says |
|---|---|---|
| an SFZ file | pianos, orchestra, basses, the acoustic kit | the .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 names | the drum machines | a 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 map | uzu, the lazy opt-in packs | the 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 instruments | ensembles | compose: 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:
- Sources. A GitHub source is read from
raw.githubusercontent.comat 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’star(bsdtar reads zip, 7z and tar; GNU systems fall back tounzipand7z), deletes any symbolic links in it, and strips the prefix. A Strudel map’s_baseon 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. - Instruments. SFZ files are read with
sfz.rs:#defineand#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.rsturns each region into aZone. File-name sources (install/from_names.rs) are parsed withnotes.rs, which reads note names (C4,Db2, Tidal’sAs4), velocity and dynamic markers, and round-robin markers out of names. Strudel maps go throughinstall/from_map.rs, andcomposeinstruments are assembled from the others ininstall.rs. - 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. - Downloads run on 8 threads. Each goes to
<file>.part, resumes with an HTTP range request when a.partis already there, is checked against the expected size when the listing gave one (and againstContent-Lengthalways), and is renamed into place, so an interrupted install leaves only.partfiles and resumes on the next run. A Git LFS pointer is recognised and fetched again frommedia.githubusercontent.com. Failures are retried three times with backoff, except 4xx answers other than 429. - FLAC. With
flac, a WAV is encoded losslessly withflacencas it arrives (float WAV becomes 24-bit). - The index is written to
index.json.tmpand 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 field | meaning |
|---|---|
file, root | the sample and the MIDI pitch it was recorded at (fractional for detuned regions) |
lokey..hikey, lovel..hivel | the keys and velocities it answers |
xfin, xfout | velocity crossfades, with SFZ’s power curve so overlapping layers keep constant power |
seq_len, seq_pos | round robin: the cycle length and this zone’s place in it |
lorand, hirand | random alternatives |
gain_db, pan, offset, end, keytrack | level, balance, sample range, how much the pitch follows the key |
loop_mode, loop_start, loop_end | sustain loops |
attack, hold, decay, sustain, release | the 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:
- 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; pack:instrument[:variant], orbank:soundwhen the first part is a Strudel bank;- friendly aliases (
ALIASES), each a list of candidates in order of preference, the first installed one winning:pianoissalamander:piano, elsesplendid:piano, elsefreepats:upright_piano, elsepiano-lite:piano; - bare drum names, in the first kit of
KIT_PREFERENCE(tr909,tr808,muldjord,tr707,uzu) that has them; - exact instrument names and per-instrument aliases, default packs first, then user packs, then opt-in packs;
- a drum synonym in any pack, a
Bank_soundname, 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) × keytracksemitones. 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 avel_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_shotinstruments ignore the gate. - Cuts.
monoinstruments fade the previous voice in 4 ms when a new note starts;retriggerinstruments fade a repeated key’s previous voice over 120 ms;render_chokedcuts every voice sounding at a choke time in 8 ms (the open hat under the closed hat). - Unison. An instrument with
unisonplays every note as several copies, detuned, panned and staggered by up todelay_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
- 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. - Pin the source: a commit for GitHub (
rev); for an archive itssizeandsha256(shasum -a 256 <file>); for a Strudel map, its URL at a commit, andmap_baseat a commit when its_basenames a branch of another repository.every_download_is_pinnedchecks the registry. - Write the instruments, start with
claudius-packs install <name>, and listen withclaudius-packs audition /tmp/audition --pack <name>, fixing the mapping until the audition reports no problems. - Check the names:
claudius-packs resolveandaliases, and whether the new pack should be a candidate inALIASESorKIT_PREFERENCE. - Run
cargo test -p claudius-packs, and the network test withcargo 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.
| file | what it holds |
|---|---|
lib.rs | serve (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.rs | App and its Session: state, versions, waiting for changes |
app/commands.rs | every Command: edits, undo and redo, load and save, analysis |
app/jobs.rs | the long jobs: render and judge, mutate, evolve, save, install packs |
http.rs | the routes, the guard (host names, token), CORS, security headers |
mcp.rs | the MCP tools, the backends they run on, the stdio and HTTP transports |
views.rs | blueprints and verdicts as claudius-api types, and the text summaries the MCP tools return |
The session
An App holds one Session behind a mutex:
| field | what it is |
|---|---|
version | bumped by every change to anything below |
song_version | the version at which the song last changed |
bp, view | the blueprint, and its SongView (built once per change, not per request) |
undo, redo | stacks of whole blueprints with labels, at most 200 |
render | the latest finished render’s RenderView and the paths of its stems |
verdict, loudness | its verdict, and each track’s loudness in the master |
busy, progress, job, rerender | the running job, and whether a render was asked for meanwhile |
pending_saves | the folders saves asked for during a job, all written from one render when the job ends |
log | the 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)runsfon the session, bumpsversion, and sends the new version on atokio::sync::watchchannel, which wakes every long poll.try_update(f)isupdatefor a change that may be refused: whenfreturns 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
editmutex 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 (orran; 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_partandset_mixbuild script code (every string throughpattern::quote) and go throughrun_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 inedit_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:
- waiting saves (
pending_saves) take it straight away, without clearing the busy state, so nobody sees the session idle in between andsettledwaits for the save too; its render, of the song as it is then, also serves as the fresh render; - 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;
- 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:
- takes the blueprint and
song_versionunder the lock, and arranges and renders outside it, with stems when they were asked for or the render is for a save; - judges the render, with the ears when the background loader has finished (the session loads CLAP only, not the AudioSet tagger);
- writes the master to
<cache>/renders/<process id>/<version>.wav, and stems to<version>/<track>.wavas 24-bit WAVs; - builds the
RenderView: URL, length, loudness, section times, a 1000-point waveform, stem names; - computes each track’s loudness in the master (its measured level plus its gain plus the master’s gain from pre-master to final);
- 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; - 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>orcould 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.1andhttp://[::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
Hostthat is not a loopback name unless the server is--public, then, with a token, accepts a matchingAuthorization: Bearer,claudius_tokencookie or?token=(which also sets the cookie, withSecurewhenX-Forwarded-Protosays https), comparing in constant time. - Commands must be
application/jsonand at most 1 MiB, and run onspawn_blocking, since an edit can take seconds. - Audio and stems are served with
tower-http’sServeFile, which handlesRange; 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
distfolder withindex.htmlas 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: theAppin this process. Commands run onspawn_blocking; state and waiting call theAppdirectly.Remote: a claudius in another process, reached through its HTTP API withureq:POST /api/command(600 s timeout, for jobs that start slowly),GET /api/state,GET /api/state?settled=1&timeout=N,/api/scriptand/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, withlisten, 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 anddescribe(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 ana|bentry 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, …). summaryandverdict_textwrite 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.
| file | what it does |
|---|---|
lib.rs, main.rs | run_native, start_web, the claudius-ui program’s arguments |
app.rs | Studio, the eframe::App: state, layout, shortcuts, applying server state |
net.rs | the connection: long polling, commands, the backend (a server or the mock) |
edits.rs | edits applied to a SongView, for optimistic updates and for the mock |
cmds.rs | builders 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.rs | the theory needed to label things: keys, numerals as chord names, note names |
theme.rs, widgets.rs | the palette (one colour per role and section kind), knobs, faders, meters, chips |
mark.rs | the logo’s C, drawn at the size the header shows it, from the numbers scripts/icons.py draws the icons with |
devplan.rs | scripted runs of the native window, for checking the UI without a person |
native.rs | Native, what the native window may do on its computer beyond the server (connect assistants) |
tour.rs | the 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 │
└───────────────────────────────────────────────────────────────────────────────┘
| panel | what it edits | commands it sends |
|---|---|---|
| arrangement | section order; which layers play in which section (click or drag across cells); seeking | move_section, toggle_layer |
| inspector | the selected section’s name, bars, energy, variant and flags; adding, duplicating, removing | set_section, add_section, remove_section, move_section |
| step sequencer | one 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 strings | set_drum_step, set_drum_pattern |
| mixer | a strip per track: pan, sends, duck, fader, mute, solo, with its loudness from the latest render | set_mix |
| parts | style, sound, register (drag on a keyboard), density, reseed | set_part |
| harmony | which progression each section plays; progressions as chord chips, from the key’s diatonic chords or as text | set_section, set_progression |
| script | the song as a script, highlighted, Run with Cmd or Ctrl+Enter, errors marking their line, sounds to insert, the reference | run_script |
| sounds | the packs and every sound name; click one to put it on a part | set_part |
| verdict | read only: the total, a bar per score, issues with their targets, the ears, the tonal balance | |
| log | read 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 showsaudio: no audio output: .... - Web (
audio/web.rs): anHtmlAudioElementplays the MP3, andcurrentTimeis 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:
| command | what it runs |
|---|---|
cargo test -p claudius | the engine’s unit tests and every file in crates/claudius/tests/ |
cargo test -p claudius --test script | one of them (pipeline, theory, script, docs_examples, pattern_laws, generators) |
cargo test -p claudius-server | the session, HTTP and MCP tests |
cargo test -p claudius-cli | the binary end to end, and the connect unit test |
cargo test -p claudius-packs | the packs’ unit tests and the offline pack test |
cargo test -p claudius-packs -- --ignored | the network test: real downloads |
cargo test -p claudius-decode | the decoder |
cargo test -p claudius-ui | the studio’s tests: the native player, the mock, the script editor’s highlighting and error lines, numeral labels |
cargo test -p earshot | earshot’s fast parity checks, no models |
cargo test -p earshot --release -- --ignored --nocapture | earshot’s model parity checks (downloads the models) |
cargo +nightly fuzz run script | a fuzz target, from fuzz/ |
cargo bench -p claudius | the 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, whichclaudius referenceprints 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)andrev().rev()give back the original;- randomness (
sometimes,degradeBy) gives the same events every time; degradeBy(0)keeps everything anddegradeBy(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 isdoes 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:
| target | feeds | must hold |
|---|---|---|
mini_notation | any 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 |
script | any UTF-8 text to script::run | no 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_PACKSat an empty folder; - tests that need packs skip without them (
sampled_instruments_and_drum_machines,js packsexamples); - the engine’s tests pass no Apple Loops library to
generate, and the session tests run withlibrary: 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 intests/script.rs, a session behaviour intests/session.rs. - A new script function needs a case in
tests/script.rs, an example in the language reference (ajsblock, whichdocs_examplesruns), 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, throughhttp::routerwithtower::ServiceExtand no network. - Pack handling is tested with a hand-written pack in a temporary folder, as
tests/offline.rsdoes, 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
| actor | trusted? | can reach claudius by |
|---|---|---|
| you, at your computer | yes | the studio, the CLI, curl |
| assistants you connect | acting for you; their scripts are untrusted code | MCP over stdio or HTTP |
| programs and users on the same computer | not defended against | 127.0.0.1, like you |
| web pages in your browser | no | requests your browser sends to 127.0.0.1 |
| machines on your network | no | nothing, unless you listen beyond loopback |
| whoever holds your token | treated as you | a tunnel or the network, with --public |
| hosts claudius downloads from | partly: content is checked where it can be | |
| files and blueprints you open | no | their 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/commandrequiresContent-Type: application/json, which no simple request can carry (415 otherwise);- the MCP endpoint requires
Content-Type: application/jsonandAccept: application/json, text/event-stream, and every request afterinitializeneeds themcp-session-idfrom a response the page cannot read; GETroutes 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 16makes 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_errorturns a refusal into a message. Used for scripts’.s(path),load/open(and every audio file a loaded blueprint plays),analyzeandremake. - 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, soallowed/../outsideandallowed/missing/../../outsideare refused. Used forsave.
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.wavinto a folder follows a symbolic link already there. If you keep links inside~/Music/claudiusto places you do not want written, a token holder can write through them. - Existence.
config::read_errorsays<path> does not existonly 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/claudiusexists, a shared claudius refuses to save there (<path> is outside the folders this claudius may write), including the defaultsavewithout 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,saveand.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; importfinds no modules (DummyModuleResolver) andevalis 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,slowandplytake 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 forbidsunsafecode 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=noneturns that off. Every ffmpeg and ffprobe input goes throughclaudius_decode::ffmpeg_input, which allows only thefileprotocol 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
| what | from | checked |
|---|---|---|
| CLAP and AudioSet models | huggingface.co, at pinned revisions | SHA-256 of every file, pinned in earshot/src/models.rs; a mismatch is deleted and never used |
| curated packs from GitHub | raw.githubusercontent.com at pinned commits | the commit pins the content; sizes against GitHub’s listing when there is one |
| curated Strudel maps | the map and every file it names on raw.githubusercontent.com at pinned commits | the 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 organ | audiorealism.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 add | any URL you give, HTTPS or HTTP | nothing 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
mainafter a reviewed release pull request, with aSHA256SUMSfile 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/mainchecks 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 onmain, 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 onlymainmay 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 denychecks every dependency’s licence, advisories and source on every pull request; dependabot proposes updates.- Building
earshotdownloads a prebuilt ONNX Runtime through theortcrate (itsdownload-binariesfeature), which is linked statically. That download is a build-time dependency on theortproject’s hosting.
Known gaps
These are open, and listed so that nobody has to rediscover them:
- 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. - The token never expires, and a
?token=URL stays in browser history and in proxy and tunnel logs. - 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.
cancelstops 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.
| record | in one line |
|---|---|
| 0001 Real audio only | every sound is synthesized in code or recorded; no generated audio, no General MIDI |
| 0002 Rust end to end | engine, server, UI, ears and decoder in one language, no Python at runtime |
| 0003 The blueprint is the song | a song is stored as its decisions; the arranged notes are derived |
| 0004 Rhai with Strudel-style patterns | an embedded, sandboxed language with live-coding patterns inside strings |
| 0005 One session, many clients | the studio, browsers and assistants all edit one song |
| 0006 A critic with ears | meters, theory and a calibrated CLAP model judge every render; some evidence is held out |
| 0007 Which samples we ship | none: curated packs are downloaded from their authors, and only clear licences are on by default |
| 0008 egui for native and web | one immediate-mode UI compiled natively and to WebAssembly, talking only HTTP |
| 0009 Releases through pull requests | a 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
- Copy the shape of an existing record into
docs/src/design/NNNN-short-title.md, with the next free number. - 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.
- Add it to
docs/src/SUMMARY.mdunder “Design decisions” and to the table above. - 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 theortcrate, 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. Theclaudiusprogram 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.rschecks 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)isFunction 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=Nand 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 mcpfinds 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, andclaudius mcpfails 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.txtnaming exactly the packs it used. - Opt-in packs, whose licence is unknown or carries conditions (
tidal-drum-machines,dirt-samples,mridangamunder CC BY-SA), install only with--accept-licenceand print a warning. - User maps added with
add-maprecord the licence the map or its repository declares, orunknownwith 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 studiosays 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 bypr-title.yml. Pull requests are squash-merged with their title as the commit message, somain’s history is one line per change. - Starting a release is running the
release-pr.ymlworkflow with a bump (patch,minor,majoror a version). It runscargo xtask release prepare, which bumps the workspace version, the path dependencies andCargo.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 requestrelease: vX.Y.Zfromrelease/vX.Y.Z. The notes can be edited on that branch before merging. - Merging pushes the new version to
main, which runsrelease.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:
SHA256SUMSfor integrity andgh attestation verifyfor 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_TOKENwhen 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:
claudius_server::App::newcreates the session;claudius_server::spawnserves it on a thread;claudius_ui::run_nativeopens the window on the main thread.- 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 pushesCommand::SetDrumStep, shows the step at once (edits.rs), and posts it. http.rschecks the host name, parses the JSON, and callsApp::commandon a blocking thread (claudius-server/src/app/commands.rs).edit_withchanges a copy of the blueprint,commitpushes the old one for undo, andafter_changestarts a render job (app/jobs.rs).- 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 newrender.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.
crates/claudius/src/model.rs. The two documents. Notice what aPartstores (a style and a seed, not notes), that everySoundvariant is audio, andTrackMix.level_dbbeing relative to a role, not a fader.crates/claudius/data/genres/house.json. What a genre knows: weighted choices, step probabilities, forms with flags, presets witha|balternatives, the mix. Most musical changes are edits to files like this one.compose.rs,generate_inner, thencompose/arrange.rs,arrange. The creative step and the layout step. Notice therng.fork(...)calls, the tempo and tonic drawn even when they are given, and why both matter (Determinism).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 skimmelody.rsfor the lead’s A A’ A B phrasing.render.rs,render(). The whole mix in about 130 lines: sources, the auto-mixer, the sidechain, buses, returns, the master. Thenfinalizefor the loudness search.judge.rs. What “good” means here, and the issue targets evolution reads.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.rsandscript/form.rshold the rest of the song functions.script/print.rsandsongs_print_as_scripts_that_change_nothingintests/script.rs. The round trip, and the small concessions on the reading side that keep it exact (Printing a song).evolve.rs. The 25 operators, how issues weight them, the (μ + λ) loop with previews.crates/claudius-server/src/app/, thenhttp.rsandmcp.rs. The session, its versions and jobs; the guard; the tools and their two backends.crates/claudius-ui/src/app.rsandnet.rs. How the studio holds back server state while you drag, and applies edits optimistically.crates/claudius-packs/src/resolve.rsandplayer.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
f32everywhere. 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
jsblock 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 intheory/. - 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 setupskips the models, andclaudius doctorsays “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 itclaudius studiopoints you atclaudius 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
| tool | what it is for |
|---|---|
| trunk 0.21 | builds the web studio (WebAssembly) |
| cargo-nextest | runs the tests; cargo xtask ci falls back to cargo test without it |
| mdBook 0.5 | builds this book |
| cargo-deny | checks licences, advisories and where crates come from (deny.toml) |
| typos | finds misspellings (_typos.toml) |
| git-cliff | turns commit messages into release notes (cliff.toml) |
| lychee | checks the links between the pages of the built site |
| actionlint, shellcheck | check the workflows and the scripts in scripts/ |
| gitleaks | finds secrets in commits before they leave your computer (.gitleaks.toml) |
| osv-scanner | checks the lockfiles against known vulnerabilities (osv-scanner.toml) |
| Node.js 24 | runs the website’s smoke test (Playwright) |
rsvg-convert (librsvg), fontTools | only 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:
| command | builds | into |
|---|---|---|
cargo build | every crate (the dev profile is optimized, so songs render at a usable speed) | target/debug |
cargo build -p claudius-cli --no-default-features --features studio | claudius without the ears, as on Intel Macs and Windows | target/debug |
cargo build -p claudius-cli --no-default-features --features ears | claudius without the studio window: the headless build | target/debug |
cargo xtask web | the web studio, with trunk | crates/claudius-ui/dist |
cargo xtask docs | this book | target/book |
cargo xtask site | the website: the landing page, the book at /docs/, the studio at /studio/ and the install scripts | target/site |
cargo xtask dist | a 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 changed | run |
|---|---|
| the docs or the site | cargo 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/ |
| dependencies | cargo deny check |
| anything with words in it | typos |
| workflows or scripts | actionlint and shellcheck scripts/*.sh scripts/ci/*.sh .githooks/* |
| a lockfile | osv-scanner scan source --config osv-scanner.toml --lockfile Cargo.lock --lockfile site/package-lock.json |
| benchmarks | cargo bench --no-run |
| code that the Intel Mac build compiles | rustup 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:
-
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 plainjsfence, sodocs_examplesruns it; once the method exists it passes. On this page the example is markedno-run, because the method does not exist yet:track("perc", s("rim ~ rim [rim rim]").palindrome()); // the figure, then the figure backwards -
The quick reference (
docs/src/language/quick-reference.md): one row. -
crates/claudius/src/script_reference.md: add.palindrome()to the time row. This file is built into the program;claudius reference,GET /api/referenceand the MCP toolscript_referencereturn it, so it is what assistants read before writing scripts.the_reference_examples_runintests/script.rsruns its examples. -
Coming from Strudel, if it lists
palindromeas 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 = 120anduse_small_heuristics = "Max", so short expressions stay on one line and lines may run to 120 columns.cargo fmt --allformats everything; CI checks it. - clippy must be clean with
-D warningson every target:cargo clippy --workspace --all-targets --locked -- -D warnings. The studio is also checked forwasm32-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 behindcfg(feature = ...)compiles only there.cargo xtask ciruns all of these except the web one, which iscargo 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_complexityandshould_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.tomlwith 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 returnStringerrors that surface verbatim in the studio and in MCP replies; binaries useanyhowwith 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.
- One thing, explained. The change does one thing, and the description says why, not only what.
- 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.
- Docs. User-visible behaviour changes the book in the same pull request,
jsexamples included, andscript_reference.mdwhen the script language changes, since assistants read it. - Determinism. The same seed still gives the same song, or the snapshot diff shows exactly what changed and the description says it is intended.
- 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 intests/script.rs). - The sandbox and the file policy. New script functions do no I/O beyond what
resolve_soundalready does; new commands that take paths check them withconfig::path_allowedorconfig::write_allowed; nothing new listens on the network or skips the guard (Security model). - Error messages. Read the failure path: is the message one a person can act on?
- Performance. Rendering and pattern queries are hot paths: no allocation per sample, no lock held across a render.
For changes there,
cargo bench -p claudiusbefore and after. - 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.rsholds a change to it), and a new field has a serde default. A change songs cannot take as they are raisesBLUEPRINT_FORMATwith an upgrade step; a breaking change is marked!in the title and explained. - 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. - 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:
| fence | what the test does |
|---|---|
```js | runs it with the sample packs switched off, as on CI; it must succeed |
```js packs | runs it when the sample packs are installed (it uses sampled instruments or kits) |
```js error | runs it; it must fail (for showing error messages) |
```js no-run | shows 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:
| bump | from 0.4.2 | for |
|---|---|---|
patch | 0.4.3 | fixes, and changes that leave scripts, saved songs, the HTTP API and the MCP tools working as they did |
minor | 0.5.0 | new features, and anything that changes the script language, the song format, the API or the tools (the changelog says how) |
major | 1.0.0 | the 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:
| title | section |
|---|---|
feat(script): add euclid() to patterns | Added |
fix(server): keep the session when a render fails | Fixed |
fix(security): ... | Security |
perf(render): mix the stems in parallel | Changed |
docs: a chapter on swing | Documentation |
refactor, test, build, ci, chore | left 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
-
Start it. From the command line:
gh workflow run release-pr.yml -f bump=minoror 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 inCargo.toml(and theversion = "..."of path dependencies between workspace crates, whichcargo add --pathwrites), the workspace entries inCargo.lock,CHANGELOG.md, where Unreleased becomes## [0.5.0] - <today>with the notes from git-cliff, and the songs 0.5.0 saves, incrates/claudius/tests/compat/v0.5.0/, which every later release has to open (saved songs keep opening). It commits that to the branchrelease/v0.5.0through GitHub’s API, so that GitHub signs the commit (mainaccepts only signed commits), and opens the PR release: v0.5.0, with the notes in its description. -
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 optionalRELEASE_TOKENsecret (see below) the PR is opened with that token instead and starts its checks like any other. -
Read the notes, then merge. To reword them, edit
CHANGELOG.mdon the release branch. Merge the PR once ci ok passes. -
The release builds itself. The merge changes
Cargo.tomlonmain, which startsrelease.yml:- plan decides whether this commit is a release: it is on
mainand is the merge of the release PR,CHANGELOG.mdhas a section for the version inCargo.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 Linuxcargo xtask dist --target <target> --headlessandscripts/package/linux.shtoo, which makes the.deb,.rpmand AppImage, and on Windowsscripts/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.0and install instructions; - publish checks that each build uploaded only its own archives and that they match their
.sha256, writesSHA256SUMSfor 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 tagv0.5.0on the merge commit; - homebrew tap updates
Formula/claudius.rbindevlato/homebrew-tap; - site deploys the website with the new version, changelog and docs.
No job in
release.ymlrestores 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 environmentrelease, which onlymainand the hotfix branches may use. WhenRELEASE_REVIEWERSwas given toscripts/github-setup.sh, the run waits at publish until one of them approves it (Review deployments on the run’s page). - plan decides whether this commit is a release: it is on
-
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
| file | what |
|---|---|
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.dmg | Claudius.app for Apple silicon and for Intel Macs, to drag to Applications |
claudius-0.5.0-x86_64-pc-windows-msvc.msi | the 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.ps1 | the install scripts, which releases/latest/download/install.sh points at |
SHA256SUMS | the 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
| target | built on | archives |
|---|---|---|
aarch64-apple-darwin | macos-latest | the studio and the ears |
x86_64-apple-darwin | macos-latest, cross-compiled | no ears: ort has no ONNX Runtime for Intel Macs |
x86_64-unknown-linux-gnu | ubuntu-24.04 | the studio and the ears, and a -headless one; glibc 2.38 or newer |
aarch64-unknown-linux-gnu | ubuntu-24.04-arm | the same |
x86_64-pc-windows-msvc | windows-latest | experimental, 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
mainthat changedocs/,site/,CHANGELOG.mdor the install scripts, so the docs followmain(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
| secret | environment | used by | without it |
|---|---|---|---|
CLOUDFLARE_API_TOKEN | site | site.yml | the site is built but not deployed (a notice says so) |
CLOUDFLARE_ACCOUNT_ID | site | site.yml | the same |
HOMEBREW_TAP_TOKEN | release | release.yml | the Homebrew formula is not updated |
RELEASE_TOKEN (optional) | release-pr | release-pr.yml | the workflow opens the release PR with its own token and starts CI itself |
MACOS_CERTIFICATE, MACOS_CERTIFICATE_PASSWORD | release | release.yml | the Mac app is signed ad hoc, and macOS asks before opening it |
MACOS_NOTARY_KEY, MACOS_NOTARY_KEY_ID, MACOS_NOTARY_ISSUER | release | release.yml | the 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=devlatomakes every release wait for that person’s approval before publish runs. Without it, merging a release PR releases.REVIEWS=1requires an approving review on every pull request. It also stops workflows from opening (and so from approving) pull requests, sorelease-pr.ymlthen needsRELEASE_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 namedrelease/*andhotfix/*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
mainand the line in the changelog:feat(script): add euclid() to patterns. The type is one offeat,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 cibefore 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 fromdocs/src/contributing/setup.mdtoo. - 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 behindstudio. 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.tomland the workflows’ubuntu-24.04alone 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]inCHANGELOG.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
mainor 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 ordeny.tomlthat drop a failing check. If a check is wrong, say so in the pull request. - Commit secrets, tokens,
.envfiles, downloaded models or sample packs, or anything fromtarget/andout/. - Change the version in
Cargo.toml, the released sections ofCHANGELOG.md, orv*tags by hand. Versions change only through the release PR thatrelease-pr.ymlopens. - 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 connectinstalls both. - Analysis and remakes of any song: tempo, key, chords, sections and groove, rebuilt in any genre.
claudius setup,doctorandconnectfor 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.