Audio as Code

Runnable examples

Each script runs from a checkout with the local install (uv sync, then uv run python <script>). They write their WAV, MIDI and JSON under output/.

01_first_score.py

Download .py

A first score: four bars, four tracks, one WAV, one MIDI file.

docs/site/examples/01_first_score.py
"""A first score: four bars, four tracks, one WAV, one MIDI file.

Run from the root of a source checkout:

    python docs/site/examples/01_first_score.py [output-directory]

Writes score.json, song.wav, song.mid, and report.json to
output/docs-examples/01-first-score/ unless another directory is given.
"""

from __future__ import annotations

import hashlib
import json
import sys
from pathlib import Path

from audio_as_code import Pattern, Song, Track, export_midi, render

out = Path(sys.argv[1] if len(sys.argv) > 1 else "output/docs-examples/01-first-score")

# Time is measured in quarter-note beats. One step of 0.5 is an eighth note.
# None is a rest; a list is a chord; strings and integers are pitches (C4 = 60).
# Two lines of four beats each, written as plain Python lists.
bar_1_2 = ["E4", "G4", "A4", None, "G4", "E4", "D4", None]
bar_3_4 = ["C4", "D4", "E4", "G4", "E4", None, None, None]
melody = Pattern.sequence(
    bar_1_2 + bar_3_4,
    step=0.5,
    gate=0.75,
    velocity=0.7,
)
chords = Pattern.sequence(
    [["C3", "E3", "G3"], ["A2", "C3", "E3"], ["F2", "A2", "C3"], ["G2", "B2", "D3"]],
    step=4,
    gate=0.95,
    velocity=0.5,
)
kick = Pattern.sequence([36, None, 36, None], gate=0.4, velocity=0.9).repeat(4)
hat = Pattern.sequence([None, 42], step=0.5, gate=0.2, velocity=0.45).repeat(16)

song = Song(
    title="First score",
    bpm=96,  # 96 quarter notes per minute, so 16 beats last 10 seconds
    beats=16,  # four bars of 4/4; every note must end by beat 16
    seed=1,
    tracks=[
        # The melody pattern is 8 beats long; .repeat(2) fills all four bars.
        Track(name="Melody", instrument="pluck", gain=0.55, pan=0.2, notes=melody.repeat(2).notes),
        Track(name="Chords", instrument="pad", gain=0.3, pan=-0.2, notes=chords.notes),
        Track(name="Kick", instrument="kick", gain=0.8, notes=kick.notes),
        Track(name="Hat", instrument="hat", gain=0.3, pan=0.3, notes=hat.notes),
    ],
)

song.save(out / "score.json")
report = render(song, out / "song.wav")
midi = export_midi(song, out / "song.mid")
(out / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")

digest = hashlib.sha256((out / "song.wav").read_bytes()).hexdigest()
print(f"{song.title}: {song.beats} beats at {song.bpm} BPM = {song.seconds:.2f} s")
print(f"WAV  {report['output']}  peak {report['wav']['peak']:.3f}  sha256 {digest[:16]}...")
print(f"MIDI {midi['output']}  {midi['duration_seconds']:.2f} s")
print("warnings:", report["warnings"] or "none")

02_motif_and_progression.py

Download .py

Motifs, a chord progression, and arrangement with Pattern.

docs/site/examples/02_motif_and_progression.py
"""Motifs, a chord progression, and arrangement with Pattern.

Run from the root of a source checkout:

    python docs/site/examples/02_motif_and_progression.py [output-directory]

One two-bar motif is transposed to follow a I-vi-IV-V progression, answered
by a varied phrase, and accompanied by voiced chords, a walking bass, and
an arpeggio. Output goes to output/docs-examples/02-motif-and-progression/.
"""

from __future__ import annotations

import json
import sys
from pathlib import Path

from audio_as_code import Note, Pattern, Song, Track, export_midi, render

out = Path(sys.argv[1] if len(sys.argv) > 1 else "output/docs-examples/02-motif-and-progression")

BAR = 4  # beats per bar in this 4/4 piece

# The progression: C major, A minor, F major, G major. One chord per bar.
roots = ["C3", "A2", "F2", "G2"]
fifths = ["G2", "E2", "C3", "D3"]
approach = ["B2", "G2", "F#2", "B2"]  # a step away from the next bar's root
voicings = [
    ["E3", "G3", "C4"],
    ["E3", "A3", "C4"],
    ["F3", "A3", "C4"],
    ["D3", "G3", "B3"],
]  # close voicings that move by step instead of jumping in parallel

# A two-bar motif (8 beats, including its final rest).
motif = Pattern.sequence(
    ["G4", None, "E4", "G4", "A4", "G4", "E4", None]  # bar 1
    + ["D4", "E4", "G4", None, "E4", None, None, None],  # bar 2
    step=0.5,
    gate=0.7,
    velocity=0.72,
)
# A contrasting two-bar answer that settles on the tonic, C.
answer = Pattern.sequence(
    ["C5", None, "A4", "G4", "E4", None, "D4", "C4"]
    + ["D4", None, "E4", "D4", "C4", None, None, None],
    step=0.5,
    gate=0.7,
    velocity=0.68,
)
# transpose() counts semitones: -3 repeats the motif a minor third lower over A minor.
phrase_a = motif.then(motif.transpose(-3))  # bars 1-4
phrase_b = motif.transpose(2).then(answer)  # bars 5-8: a step higher, then the answer
lead = phrase_a.then(phrase_b)  # 16 beats + 16 beats = 32 beats


def chord(pitches: list[str], start: float, duration: float, velocity: float) -> list[Note]:
    """A block chord is several notes with the same start; there is no chord object."""
    return [Note(pitch=p, start=start, duration=duration, velocity=velocity) for p in pitches]


pads: list[Note] = []
bass: list[Note] = []
arp: list[Note] = []
for bar in range(8):
    at = bar * BAR
    i = bar % 4
    pads += chord(voicings[i], at, 3.9, 0.5)
    # Bass: root, rest, fifth, then an approach note leading into the next bar.
    bass += Pattern.sequence([roots[i], None, fifths[i], approach[i]], gate=0.8).at(at)
    # Arpeggiate the chord tones an octave up in eighth notes (8 steps = 4 beats).
    broken = Pattern.sequence(voicings[i] * 2 + [voicings[i][1], None], step=0.5, gate=0.5)
    arp += broken.transpose(12).at(at)

# Accents: Pattern sets one velocity for all steps; rebuild notes to vary it.
accented_arp = [
    Note(**{**n.model_dump(), "velocity": 0.55 if n.start % 2 == 0 else 0.35}) for n in arp
]
kick = Pattern.sequence([36, None, None, 36, None, 36, None, None], step=0.5, gate=0.3)  # 1 bar
snare = Pattern.sequence([None, 38], step=2, gate=0.15, velocity=0.6)  # backbeat on beat 3

song = Song(
    title="Motif and progression",
    bpm=108,
    beats=33,  # 32 beats of music plus one beat for the last notes to fade
    seed=2,
    tracks=[
        Track(name="Lead", instrument="marimba", gain=0.6, pan=0.15, notes=lead.notes),
        Track(name="Chords", instrument="electric_piano", gain=0.32, pan=-0.3, notes=tuple(pads)),
        Track(name="Arpeggio", instrument="harp", gain=0.3, pan=0.45, notes=tuple(accented_arp)),
        Track(name="Bass", instrument="bass_guitar", gain=0.7, notes=tuple(bass)),
        Track(name="Kick", instrument="kick", gain=0.75, notes=kick.repeat(8).notes),
        Track(name="Snare", instrument="snare", gain=0.45, notes=snare.repeat(8).notes),
    ],
)

song.save(out / "score.json")
report = render(song, out / "song.wav")
export_midi(song, out / "song.mid")
(out / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(f"{song.title}: {sum(len(t.notes) for t in song.tracks)} notes, {song.seconds:.1f} s")
print(f"lead pattern: {len(lead.notes)} notes over {lead.beats} beats")
print(f"peak {report['wav']['peak']:.3f}, gain applied {report['gain_applied']:.3f}")
print("warnings:", report["warnings"] or "none")

03_song_form.py

Download .py

Song form: intro, verse, chorus, bridge, final chorus, and ending.

docs/site/examples/03_song_form.py
"""Song form: intro, verse, chorus, bridge, final chorus, and ending.

Run from the root of a source checkout:

    python docs/site/examples/03_song_form.py [output-directory]

The score has no section objects. Sections are a plain Python table of
start beats; each part is written by functions that take a section and
return notes on the absolute timeline. Dynamics come from velocity and
from which tracks play in each section. Writes score.json, song.wav,
song.mid, stems/, report.json, and sections.json.
"""

from __future__ import annotations

import json
import sys
from pathlib import Path

from audio_as_code import Note, Pattern, Song, Tone, Track, export_midi, render

out = Path(sys.argv[1] if len(sys.argv) > 1 else "output/docs-examples/03-song-form")

BPM = 92
BAR = 4
# (name, bars, chords): one chord symbol per bar, spelled as root + voicing.
CHORDS = {
    "Dm": ("D2", ["D3", "F3", "A3", "C4"]),
    "Bb": ("Bb1", ["D3", "F3", "Bb3", "D4"]),
    "F": ("F2", ["C3", "F3", "A3", "C4"]),
    "C": ("C2", ["C3", "E3", "G3", "C4"]),
    "Gm": ("G2", ["D3", "G3", "Bb3", "D4"]),
    "A": ("A1", ["C#3", "E3", "A3", "C#4"]),
}
FORM = [
    ("intro", ["Dm", "Bb", "Dm", "Bb"]),
    ("verse", ["Dm", "Bb", "F", "C", "Dm", "Bb", "Gm", "A"]),
    ("chorus", ["F", "C", "Dm", "Bb", "F", "C", "Gm", "A"]),
    ("bridge", ["Gm", "Dm", "Gm", "A"]),
    ("chorus 2", ["F", "C", "Dm", "Bb", "F", "C", "Gm", "A"]),
    ("ending", ["Dm", "Dm"]),
]

# Turn the form into absolute beat positions.
sections = []
beat = 0
for name, bars in FORM:
    sections.append({"name": name, "start": beat, "bars": bars})
    beat += len(bars) * BAR
END = beat  # 136 beats

verse_motif = Pattern.sequence(
    ["A4", None, "A4", "G4", "F4", None, "E4", "D4"], step=0.5, gate=0.8, velocity=0.62
)
chorus_motif = Pattern.sequence(
    ["C5", None, "A4", "C5", "D5", None, "C5", "A4"], step=0.5, gate=0.8, velocity=0.78
)


def harmony(section: dict, velocity: float, length: float = 3.9) -> list[Note]:
    notes = []
    for bar, symbol in enumerate(section["bars"]):
        at = section["start"] + bar * BAR
        notes += [
            Note(pitch=p, start=at, duration=length, velocity=velocity) for p in CHORDS[symbol][1]
        ]
    return notes


def bassline(section: dict, velocity: float, busy: bool) -> list[Note]:
    notes = []
    for bar, symbol in enumerate(section["bars"]):
        root = CHORDS[symbol][0]
        steps = (
            [root, None, root, None, root, root, None, None] if busy else [root, None, None, None]
        )
        step = 0.5 if busy else 1
        notes += Pattern.sequence(steps, step=step, gate=0.85, velocity=velocity).at(
            section["start"] + bar * BAR
        )
    return notes


def melody(section: dict, motif: Pattern) -> list[Note]:
    """Play the motif in odd bars; follow the chord root a sixth up in the even bars."""
    notes = []
    for bar, symbol in enumerate(section["bars"]):
        at = section["start"] + bar * BAR
        if bar % 2 == 0:
            notes += motif.at(at)
        else:
            answer = Pattern.sequence([CHORDS[symbol][1][-1], None], step=2, gate=0.9)
            notes += answer.transpose(12).at(at)
    return notes


def drums(section: dict, fill: bool) -> list[Note]:
    """drum_machine selects the voice by pitch: 36 kick, 38 snare, 42 hat, 45 tom, 49 cymbal."""
    groove = Pattern.sequence(
        [[36, 42], 42, [38, 42], 42, [36, 42], [36, 42], [38, 42], 42],
        step=0.5,
        gate=0.3,
        velocity=0.7,
    )
    bars = len(section["bars"])
    notes = list(groove.repeat(bars - 1 if fill else bars).at(section["start"]))
    if fill:
        tom_fill = Pattern.sequence(
            [45, 45, 38, 45, 38, 38, 38, 38], step=0.5, gate=0.3, velocity=0.8
        )
        notes += tom_fill.at(section["start"] + (bars - 1) * BAR)
    return notes


by_name = {s["name"]: s for s in sections}
intro, verse, chorus, bridge, chorus2, ending = (by_name[n] for n, _ in FORM)

piano = harmony(intro, 0.45) + harmony(verse, 0.5) + harmony(chorus, 0.62)
piano += harmony(bridge, 0.4) + harmony(chorus2, 0.68)
piano += [Note(pitch=p, start=ending["start"], duration=7.5, velocity=0.5) for p in CHORDS["Dm"][1]]
cello = harmony(bridge, 0.6, length=3.8)[::4]  # just the lowest voice of each bridge chord
bass = bassline(verse, 0.7, busy=False) + bassline(chorus, 0.8, busy=True)
bass += bassline(chorus2, 0.85, busy=True)
bass.append(Note(pitch="D2", start=ending["start"], duration=6, velocity=0.7))
lead = melody(verse, verse_motif) + melody(chorus, chorus_motif) + melody(chorus2, chorus_motif)
lead.append(Note(pitch="D5", start=ending["start"], duration=6, velocity=0.6))
kit = drums(verse, fill=True) + drums(chorus, fill=True) + drums(chorus2, fill=False)
kit.append(Note(pitch=49, start=ending["start"], duration=6, velocity=0.6))

song = Song(
    title="Song form",
    bpm=BPM,
    beats=END,
    seed=3,
    tracks=[
        Track(
            name="Piano",
            instrument="piano",
            gain=0.38,
            pan=-0.2,
            tone=Tone(brightness=0.45),
            notes=tuple(piano),
        ),
        Track(name="Bridge cello", instrument="cello", gain=0.5, pan=-0.35, notes=tuple(cello)),
        Track(name="Bass", instrument="bass_guitar", gain=0.62, notes=tuple(bass)),
        Track(name="Flute lead", instrument="flute", gain=0.42, pan=0.25, notes=tuple(lead)),
        Track(name="Kit", instrument="drum_machine", gain=0.45, notes=tuple(kit)),
    ],
)

song.save(out / "score.json")
report = render(song, out / "song.wav", stems_dir=out / "stems")
export_midi(song, out / "song.mid")
(out / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
seconds_per_beat = 60 / BPM
table = [
    {
        "name": s["name"],
        "start_beat": s["start"],
        "end_beat": s["start"] + len(s["bars"]) * BAR,
        "start_seconds": round(s["start"] * seconds_per_beat, 2),
    }
    for s in sections
]
(out / "sections.json").write_text(json.dumps(table, indent=2) + "\n", encoding="utf-8")
for row in table:
    beats = f"{row['start_beat']:>3}-{row['end_beat']:<3}"
    print(f"{row['name']:>9}  beats {beats}  at {row['start_seconds']:6.2f} s")
print(f"total {song.seconds:.1f} s, peak {report['wav']['peak']:.3f}")
print("warnings:", report["warnings"] or "none")

04_expressive_controls.py

Download .py

Tone controls, velocity, gain, pan, and the generated drum kit.

docs/site/examples/04_expressive_controls.py
"""Tone controls, velocity, gain, pan, and the generated drum kit.

Run from the root of a source checkout:

    python docs/site/examples/04_expressive_controls.py [output-directory]

Each instrument accepts only the Tone fields listed in its catalog entry
(`aac instruments`). Tone applies to a whole track, so two articulations
of one instrument are two tracks. Writes score.json, song.wav, song.mid,
stems/, and report.json to output/docs-examples/04-expressive-controls/.
"""

from __future__ import annotations

import json
import sys
from pathlib import Path

from pydantic import ValidationError

from audio_as_code import Note, Pattern, Song, Tone, Track, export_midi, get_instrument, render

out = Path(sys.argv[1] if len(sys.argv) > 1 else "output/docs-examples/04-expressive-controls")

# Read the capabilities instead of guessing them.
for voice in ["guitar", "violin", "flute", "theremin", "synthesizer", "pluck"]:
    info = get_instrument(voice)
    print(f"{voice:>11}: tone controls {list(info.tone_controls) or 'none'}")

# Unsupported controls are rejected, never silently ignored.
try:
    Track(name="Bad", instrument="violin", tone=Tone(decay_seconds=3))
except ValidationError as error:
    print("rejected:", error.errors()[0]["msg"])

# Velocity (0-1] changes loudness and, on modeled voices, brightness.
crescendo = [
    Note(pitch=p, start=i * 0.5, duration=0.45, velocity=0.25 + i * 0.06)
    for i, p in enumerate(["E3", "G3", "B3", "E4", "G4", "B4", "E4", "B3"] * 1)
]
guitar_notes = tuple(crescendo) + tuple(
    Note(pitch=p, start=4, duration=4, velocity=0.7) for p in ["E2", "B2", "E3", "G3", "B3"]
)

song = Song(
    title="Expressive controls",
    bpm=80,
    beats=16,
    seed=4,
    master_gain=0.8,
    tracks=[
        # Plucking near the bridge (0.08) is brighter and thinner than mid-string (0.4).
        Track(
            name="Guitar near bridge",
            instrument="guitar",
            pan=-0.45,
            gain=0.55,
            tone=Tone(brightness=0.7, decay_seconds=2.5, pluck_position=0.08),
            notes=guitar_notes,
        ),
        Track(
            name="Guitar mid string",
            instrument="guitar",
            pan=0.45,
            gain=0.55,
            tone=Tone(brightness=0.35, decay_seconds=4, pluck_position=0.4),
            notes=tuple(Note(**{**n.model_dump(), "start": n.start + 8}) for n in guitar_notes),
        ),
        # Wide, slow vibrato on violin; vibrato needs a nonzero depth to be audible.
        Track(
            name="Violin",
            instrument="violin",
            pan=0.2,
            gain=0.4,
            tone=Tone(brightness=0.55, vibrato_depth_cents=25, vibrato_rate_hz=4.5),
            notes=(Note(pitch="B4", start=8, duration=3.8), Note(pitch="G5", start=12, duration=4)),
        ),
        # Breath adds generated air noise to winds.
        Track(
            name="Breathy flute",
            instrument="flute",
            pan=-0.2,
            gain=0.4,
            tone=Tone(breath=0.8, vibrato_depth_cents=8, vibrato_rate_hz=5),
            notes=(Note(pitch="E5", start=0, duration=3.8, velocity=0.6),),
        ),
        # Glide starts each note 7 semitones below and settles onto the written pitch.
        Track(
            name="Theremin",
            instrument="theremin",
            gain=0.3,
            tone=Tone(glide_semitones=-7, vibrato_depth_cents=30, vibrato_rate_hz=6),
            notes=(Note(pitch="E5", start=4, duration=3.5, velocity=0.6),),
        ),
        Track(
            name="Detuned synth",
            instrument="synthesizer",
            pan=0.3,
            gain=0.22,
            tone=Tone(brightness=0.6, detune_cents=25),
            notes=Pattern.sequence([["E3", "B3", "E4"], None], step=2, gate=0.9).repeat(4).at(0),
        ),
        # drum_machine picks a generated voice from the pitch: 36 kick, 38 snare,
        # 42 hat, 45 tom, 49 cymbal, 54 tambourine, 60 bongo, 64 conga.
        Track(
            name="Kit",
            instrument="drum_machine",
            gain=0.5,
            notes=Pattern.sequence(
                [36, 42, 38, 42, 36, [36, 54], 38, 64], step=0.5, gate=0.3, velocity=0.7
            )
            .repeat(4)
            .at(0)
            + (Note(pitch=49, start=15, duration=1, velocity=0.6),),
        ),
    ],
)

song.save(out / "score.json")
report = render(song, out / "song.wav", stems_dir=out / "stems")
midi = export_midi(song, out / "song.mid")
(out / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
for stem in report["stems"]:
    print(f"stem {stem['path']}: {stem['track']}, rms {stem['audio']['rms']:.4f}")
print(f"mix peak {report['wav']['peak']:.3f}; warnings: {report['warnings'] or 'none'}")
print("MIDI note:", midi["warnings"][0])

05_agent_loop.py

Download .py

The agent loop, driven only through the aac command line and JSON files.

docs/site/examples/05_agent_loop.py
"""The agent loop, driven only through the `aac` command line and JSON files.

Run from the root of a source checkout:

    python docs/site/examples/05_agent_loop.py [output-directory]

An agent in any language can make the same calls with its shell tool. This
script stands in for that agent: it discovers voices, validates the JSON
score in agent-score.json, renders it, reads the measurements, recovers
from a deliberately invalid edit, applies a measured revision, and checks
that a repeated render is byte-identical. Every candidate gets its own
directory under output/docs-examples/05-agent-loop/.
"""

from __future__ import annotations

import copy
import hashlib
import json
import subprocess
import sys
from pathlib import Path

out = Path(sys.argv[1] if len(sys.argv) > 1 else "output/docs-examples/05-agent-loop")
source = Path(__file__).with_name("agent-score.json")


def aac(*args: str) -> tuple[int, dict]:
    """Run one CLI command. Success: JSON on stdout, exit 0. Failure: JSON on stderr, exit 2."""
    command = [sys.executable, "-m", "audio_as_code", *args]
    done = subprocess.run(command, capture_output=True, text=True, encoding="utf-8")
    print("$ aac", " ".join(args), f"-> exit {done.returncode}")
    return done.returncode, json.loads(done.stdout if done.returncode == 0 else done.stderr)


def write(path: Path, data: dict) -> Path:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
    return path


def candidate(name: str, score: dict) -> dict:
    """Validate, render, export MIDI, and measure one score in a fresh directory."""
    folder = out / name
    score_path = write(folder / "score.json", score)
    status, result = aac("validate", str(score_path))
    if status != 0:
        return {"ok": False, "error": result}
    status, report = aac(
        "render",
        str(score_path),
        "-o",
        str(folder / "song.wav"),
        "--report",
        str(folder / "report.json"),
    )
    if status != 0:
        return {"ok": False, "error": report}
    status, midi = aac("midi", str(score_path), "-o", str(folder / "song.mid"))
    status, wav = aac("analyze", str(folder / "song.wav"))
    return {"ok": True, "report": report, "midi": midi, "wav": wav}


# 1. Observe: what can be played, and what shape must the score have?
_, catalog = aac("instruments")
playable = {item["id"]: item for item in catalog["instruments"]}
aac("schema", "-o", str(out / "song-v1.schema.json"))
print(f"{catalog['counts']['available']} playable voices; policy {catalog['synthesis_policy']}")

# 2. Compose: the score is plain data. Check every instrument and tone field against the catalog.
score = json.loads(source.read_text(encoding="utf-8"))
for track in score["tracks"]:
    allowed = playable[track["instrument"]]["tone_controls"]
    assert set(track.get("tone") or {}) <= set(allowed), (track["name"], allowed)

# 3. Validate, render, and inspect the first draft.
draft = candidate("v1", score)
report = draft["report"]
print("v1 gain_applied:", round(report["gain_applied"], 3), "warnings:", report["warnings"])

# 4. A bad edit, and recovery from the error's path. This one extends the lead past the end.
broken = copy.deepcopy(score)
broken["tracks"][2]["notes"][-1]["duration"] = 8
failure = candidate("v2-broken", broken)
issue = failure["error"]["issues"][0]
print("invalid_score:", issue["message"])
fixed = copy.deepcopy(broken)
note = fixed["tracks"][2]["notes"][-1]
note["duration"] = fixed["beats"] - note["start"]  # end exactly at the song's last beat

# 5. Revise from the measurements. The draft mix exceeded the 0.95 peak ceiling and was
#    turned down by the renderer, so lower the master gain by the same amount instead.
if report["gain_applied"] < 1:
    fixed["master_gain"] = round(fixed["master_gain"] * report["gain_applied"] * 0.9, 3)
revision = candidate("v2", fixed)
report2 = revision["report"]
print("v2 master_gain:", fixed["master_gain"], "gain_applied:", report2["gain_applied"])
print("v2 warnings:", report2["warnings"] or "none")
print(f"v2 WAV peak {revision['wav']['peak']:.3f}, rms {revision['wav']['rms']:.4f}")

# 6. Reproduce: the same score in the same environment renders the same bytes.
again = candidate("v2-repeat", fixed)
digests = {
    hashlib.sha256((out / d / "song.wav").read_bytes()).hexdigest() for d in ["v2", "v2-repeat"]
}
same = len(digests) == 1
print("repeat render identical:", same, "| score_sha256", again["report"]["score_sha256"][:16])
# Measurements are signal checks. Listen to v2/song.wav before calling it finished.

06_agent_handoff.py

Download .py

Validate/render an agent-written score and emit a local artifact handoff.

docs/site/examples/06_agent_handoff.py
"""Validate/render an agent-written score and emit a local artifact handoff.

Run from an installed Audio as Code environment:
    python docs/site/examples/06_agent_handoff.py SCORE.json OUTPUT_DIRECTORY

The output directory must be new. This uses only the Python standard library;
the child CLI uses the Audio as Code installation in the same environment.
"""

from __future__ import annotations

import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path


class CLIError(Exception):
    def __init__(self, diagnostic: dict):
        self.diagnostic = diagnostic
        super().__init__(str(diagnostic))


def aac(*arguments: str, timeout: int = 300) -> dict:
    result = subprocess.run(
        [sys.executable, "-m", "audio_as_code", *arguments],
        capture_output=True,
        text=True,
        encoding="utf-8",
        check=False,
        timeout=timeout,
    )
    if result.returncode == 0:
        return json.loads(result.stdout)
    if result.returncode == 2:
        raise CLIError(json.loads(result.stderr))
    raise RuntimeError(f"CLI exited {result.returncode}: {result.stderr}")


def write_json(path: Path, value: dict) -> None:
    path.write_text(json.dumps(value, indent=2, allow_nan=False) + "\n", encoding="utf-8")


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("score", type=Path)
    parser.add_argument("output_directory", type=Path)
    args = parser.parse_args()
    try:
        inspection = aac("inspect", str(args.score), timeout=60)
        if not all(inspection["readiness"][target]["ready"] for target in ("render", "midi")):
            raise CLIError(
                {
                    "error": "score_not_exportable",
                    "issues": [i for i in inspection["issues"] if i["severity"] == "error"],
                    "hint": "Revise the score so both WAV and MIDI checks pass, then try again.",
                }
            )
        out = args.output_directory.resolve()
        out.mkdir(parents=True, exist_ok=False)
        score = out / "score.json"
        shutil.copyfile(args.score, score)
        write_json(out / "inspection.json", inspection)
        wav, midi, report = out / "song.wav", out / "song.mid", out / "report.json"
        rendered = aac("render", str(score), "-o", str(wav), "--report", str(report))
        analysis = aac("analyze", str(wav), timeout=60)
        exported = aac("midi", str(score), "-o", str(midi), timeout=60)
        write_json(out / "analysis.json", analysis)
        handoff = {
            "score": str(score),
            "audio": str(wav),
            "midi": str(midi),
            "report": str(report),
            "analysis": str(out / "analysis.json"),
            "inspection": str(out / "inspection.json"),
            "duration_seconds": analysis["duration_seconds"],
            "render_warnings": rendered["warnings"],
            "midi_warnings": exported["warnings"],
        }
        write_json(out / "handoff.json", handoff)
        print(json.dumps(handoff, indent=2))
        return 0
    except CLIError as error:
        print(json.dumps(error.diagnostic), file=sys.stderr)
        return 2
    except (OSError, RuntimeError, ValueError, subprocess.TimeoutExpired) as error:
        print(json.dumps({"error": "handoff_failed", "message": str(error)}), file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())