A song made from Valis circuits has two halves. The score decides which notes each part plays, and ends as one MIDI
file per part. Fixed patterns and chords can be written as data (see "The score" below); generated parts are code. The layout and the mix are data: the tempo, the sections and their lengths,
which circuit plays each part, and how loud, where in the stereo field and how filtered each stem sits in the mix. The
second half lives in a Turtle file, song.ttl, so it can be read, checked and changed without touching the score.
The file is read by the C++ reader (src/model/Song.cpp), which the MCP tools read_song, render_song and mix_song and
the command line tool valis-song all use. A score script reads it from Python with rdflib
(scripts/song/songlib.py) for the tempo and the section lengths it needs to place notes.
@prefix val: <http://purl.org/stuff/valis/> .
@prefix : <urn:example:my-song#> .
:song a val:Song ;
val:title "My Song" ;
val:tempo 100 ; # beats per minute
val:loudness -14 ; # integrated LUFS the mix is aimed at (optional)
val:hasSection :intro , :verse ;
val:hasPart :bass , :kit ;
val:hasReturn :bass-verb .
:intro a val:Section ; val:index 1 ; val:bars 8 ; val:level -4 .
:verse a val:Section ; val:index 2 ; val:bars 16 ; val:level 0 .
:bass a val:Part ; val:circuit "circuits/bass.ttl" ; val:level -20 ; val:pan 0 ; val:highpass 30 .
:bass-verb a val:Return ; val:source :bass ; val:circuit "circuits/reverb-return.ttl" ; val:level -31 ; val:pan 0.3 .
| Term | On | Meaning | Default |
|---|---|---|---|
val:title |
Song | Name of the piece. Its lower-case words joined by hyphens name the mix file | none |
val:tempo |
Song | Beats per minute | required |
val:beatsPerBar |
Song | Beats in a bar | 4 |
val:loudness |
Song | Target integrated loudness in LUFS (ITU-R BS.1770) | none |
val:tailSeconds |
Song | Seconds rendered after the last bar, so reverb and release can ring out | 10 |
val:fadeSeconds |
Song | Length of the fade at the end of the mix | 4 |
val:roomAmount |
Song | A synthetic room added to the mix, as a fraction of it, 0 to 1 | 0.10 |
val:roomDecay |
Song | Seconds the room takes to fall by 60 dB, 0.02 to 5 | 0.4 |
val:roomLowpass |
Song | Corner in Hz of the low-pass that darkens the room's tail, 200 to 20000 | 4500 |
val:drive |
Song | Gain into the final soft clip (a hyperbolic tangent): 1 is nearly clean, higher is harder | 1.3 |
val:index |
Section, Part, Return | Position in order. Sections must be numbered 1, 2, 3 and so on | order of the name |
val:bars |
Section | Length in bars | required |
val:level |
Section | Gain in dB of the whole mix while the section plays | 0 |
val:level |
Part, Return | Level in dB of the stem's playing RMS (RMS over the tenth-second windows that are not silent) | -20 |
val:circuit |
Part, Return | Circuit file, relative to the song file | required |
val:midi |
Part | MIDI file, relative to the song file | midi/<name>.mid |
val:midi |
Return | MIDI file played into the return's circuit as well as the source stem (for a Leslie speed switch, say) | none |
val:silent |
Part | true renders the part but leaves it out of the mix |
false |
val:recorded |
Part | true for a part made elsewhere: no val:circuit or MIDI, not rendered; wavs/NAME.wav must already exist |
false |
val:follows |
Part | Another part. This part takes that part's gain plus its own val:level, which is therefore a number of dB under the leader's gain (0 or below, since a level above 0 is refused), instead of being levelled by its own rms. For sparse percussion that should keep its balance with the kit |
none |
val:source |
Return | The part whose stem is the return's input | required |
val:pan |
Part, Return | -1 left, 0 centre, 1 right. A stereo stem keeps its image and takes the pan as a balance | 0 |
val:highpass |
Part, Return | Second-order high-pass corner in Hz, 0 for none | 0 |
The stem's name is the local name of its IRI (:bass is bass), and its audio is wavs/<name>.wav. A return is a
stem too: the source stem is played through the return's circuit, then mixed like any other.
Sections are numbered explicitly with val:index rather than written as an rdf:List, so the order survives merging
and editing files in any triple order.
The notes can be data too. A song names a score file with val:score "score.ttl", and valis-song score plays it into
one MIDI file for each part it plays. The file holds patterns written as strings of steps, chords with their voicings, a
progression, how loosely each part is played, and a plan. A part the score does not play keeps its MIDI file, so a
generator that builds a bass line or a solo can still be a script.
:score a val:Score ; val:seed 85 ;
val:progression "C C Db C C C Fm Db" ; # a chord a bar, from the start of each section
val:hasChord :C , :Db , :Fm ; val:hasPattern :onedrop , :skank ; val:hasFeel :kit-feel ;
val:hasPlay :groove-kit , :groove-stabs .
:C a val:Chord ; val:root 36 ; val:tones "60 64 67" .
:Db a val:Chord ; val:root 37 ; val:tones "61 65 68" .
:Fm a val:Chord ; val:root 41 ; val:tones "60 65 68" .
:onedrop a val:Pattern ; val:hasLane # one bar of 4/4, sixteen steps
[ val:key 36 ; val:steps "....|....|X...|...." ] , # X is an accented hit, x a hit, . a rest
[ val:key 42 ; val:steps "x.x.|x.x.|x.x.|x.x." ; val:velocity 70 ] .
:skank a val:Pattern ; val:hasLane
[ val:voice "chord" ; val:steps "....|x...|....|x..." ; val:length 2 ; val:octave 1 ] .
:kit-feel a val:Feel ; val:part "kit" ; val:timing 4 ; val:velocity 5 ; val:swing 0.54 .
:groove-kit a val:Play ; val:section "groove" ; val:part "kit" ; val:pattern :onedrop .
:groove-stabs a val:Play ; val:section "groove" ; val:part "stabs" ; val:pattern :skank ; val:fromBar 5 .
| Term | On | Meaning | Default |
|---|---|---|---|
val:score |
Song | The score file, relative to the song file | none |
val:seed |
Score | Seed for the humanising. The same score and seed always give the same notes, on every platform | 1 |
val:progression |
Score, Play | Chord names separated by spaces, one for each val:barsPerChord bars, repeated from the start of each section |
none |
val:barsPerChord |
Score | Bars to a chord | 1 |
val:root, val:tones, val:pickup |
Chord | The key a bass plays, the keys of the voicing (lowest first), and a second bass key that leads into the next chord | required, required, the root |
val:bars, val:stepsPerBeat |
Pattern | Length in bars, and steps to a beat (4 is sixteenth notes) | 1, 4 |
val:steps |
Lane | One character a step: x hit, X accented hit, . or - rest. Spaces and ` |
` are ignored. The length must fill the pattern exactly |
val:key or val:voice |
Lane | A MIDI key, or chord (every tone), root, pickup, top or low from the chord of the bar |
one of them |
val:velocity, val:accent |
Lane | Velocity, and what X adds |
90, 20 |
val:length, val:gate |
Lane | Length in steps, and the fraction of it that is held, so the note ends before the next step | 1, 0.8 |
val:octave, val:transpose |
Lane | Moves a voice by octaves, or any lane by semitones | 0, 0 |
val:part, val:timing, val:velocity, val:swing |
Feel | The part; standard deviation of each start in milliseconds and of each velocity; where the second step of a pair falls, 0.5 to 0.75 | exact |
val:section, val:part, val:pattern |
Play | Which part plays which pattern in which section (names are the local names of the IRIs) | required |
val:fromBar, val:toBar, val:every |
Play | The bars of the section it covers, and how often the pattern restarts | 1, the end, its own length |
A bar is val:beatsPerBar beats of the song, so a 4/4 pattern at four steps to a beat has 16 steps in a bar. A pattern
is placed in the song by stream position, and a note that falls in a bar is counted in that bar. No note starts before
the first beat, however loose the feel. The humanising comes from a small generator defined in the code (not the
standard library's, whose results differ between platforms) seeded from val:seed and the part's name, so a score
gives the same MIDI on every machine. It is not the same as the sequence Python's random gave, so a song first written
as a script will sound slightly different when its score is written as data.
valis-song score song.ttl writes the MIDI files; valis-song score song.ttl --only kit writes one part. Then render
and check the stems as before.
The read_song tool takes a path, reads the file, and returns the song as JSON together with a list of problems. It
reports a section numbered out of order, a missing circuit or MIDI file, a return whose source is not a part, and a
value outside its range, each with the name of the thing at fault.
Six operations work on a song file. Each is an op in src/ops/, so the MCP tools and the command line tool are the same
code:
| MCP tool | valis-song |
What it does |
|---|---|---|
read_song |
valis-song check song.ttl |
Reads the file, lists the layout and any circuit or MIDI file it names that is missing |
render_song |
valis-song render song.ttl [--only a,b] |
Writes wavs/<stem>.wav for each stem |
score_song |
valis-song score song.ttl [--only a,b] |
Plays the song's score into a MIDI file for each part it plays (see below) |
check_stems |
valis-song stems song.ttl |
Compares each stem with its MIDI file bar by bar and reports silence where there are notes (a note counts as heard if the stem sounds from its onset to 100 ms past the end of its bar), clipping and non-finite samples |
reaper_project |
valis-song reaper song.ttl [--output f.rpp] [--overwrite] |
Writes <title>.rpp beside the song: a track per stem with the mix's volume (the loudest at 0 dB) and pan, the tempo and the meter. The high-pass is not written, and an existing project is never replaced. With --midi each track holds a Valis instrument already loaded with the part's circuit and its MCP server switched on, at port 7677 for the first track and up, so the project opens ready to play and each instance can be found by its track name at /health |
mix_song |
valis-song mix song.ttl --version v1 [--arc] [--lufs N] |
Writes mixes/<title>-<version>.wav and reports the loudness and each section's level |
Render. A part is its circuit played by its MIDI file at the song's tempo with the transport rolling, for the length of
the song plus its tail. A return is its source part's wav played through the return's circuit. Each stem is a private copy of
the circuit (a model, a compiler and an engine of its own), so the live circuit in a running Valis is not touched and stems
render several at a time. A stem that will not load, or that holds a NaN, is named in the failure and not written; the others
still are. --only redoes the named parts and their returns.
Mix. Each stem is high-passed, levelled so its playing RMS is its val:level, and panned with a constant-power law (a
stereo stem keeps its image and takes the pan as a balance). The stems are summed, then the sections' levels are applied if
--arc is given (stepped, then smoothed over about two seconds), then the fade, the room, the soft clip, and a gain that sets the
peak to -1 dBFS. If the song has a loudness, or --lufs gives one, a last gain brings the mix to that
integrated loudness, held back so the true peak stays at or under -1 dBTP. The reply
says when the ceiling, not the target, set the gain. The room is noise shaped by a fixed seed, so a mix is repeatable.
A mix is never replaced: asking for a version that exists fails unless --overwrite is given, so every version is kept. The mix is
a reference to judge the arrangement by. It is measured, not heard, and vocals and mastering are done in a DAW.
Followers and played returns. A sparse part such as percussion is levelled by its own playing RMS, which would make a
few hits as loud as a full kit. Give it val:follows and the name of another part: it then takes that part's gain, adjusted by
its own val:level as an offset in dB, so it keeps the balance it was played at. Only a part that is not itself a
follower can be followed, and a return cannot follow. A return may also name a val:midi file, which is played into its
circuit as well as the source stem; a rotating speaker whose speed is a MIDI note is the example.
A part made outside Valis, such as a speech synthesizer rendered in a host, takes val:recorded true in place of val:circuit. It is not scored,
rendered or written as a track by reaper --midi. Its wavs/NAME.wav must already exist; check reads it (with no notes to compare) and mix
levels it like any other stem, by val:level, or as an offset if it val:follows a part.
A part that exists only to feed a return takes val:silent true: it is rendered, so the return can read its stem, but it is
left out of the mix. A return cannot be silent, and a silent part cannot be followed.
The room. val:roomAmount is how much of it is added, val:roomDecay how long it takes to fall by 60 dB, and
val:roomLowpass darkens its tail. The impulse is four decays long, noise from a fixed seed, so the mix is repeatable.
A track script keeps only its score and calls these through songlib.render(song) and songlib.mix(song, version, arc=True).
scripts/interstate-rain/track.py, scripts/dub-metal/track.py, scripts/lights-on-nobody-home/track.py (a mariachi
band, no arc) and scripts/autumn-leaves/track.py (a follower and a played return) are the examples. The two
older scripts that mixed in Python, electro83 and halloween, are kept unconverted in scripts/archive/. The C++ mix matches the Python one it
replaced to within -74 dB of the signal when the room is switched off; the room differs only in its noise. Converting lights-on-nobody-home and autumn-leaves gave mixes within 0.2 LU of the
Python ones (measured, not heard); the C++ mix keeps stereo stems stereo, where the scripts folded them to mono.
Generators stay code: a walking bass line, a rain of random thunder hits, a solo built from a seeded motif. Data is the right shape for what is a table, and a drum bar, a chord progression and a plan of which part plays when are tables, so they are in the score. A script and a score can share a song: the score writes the parts it plays and the script writes the rest.