Skip to content

Tracks, sections, arrangement

Every song is a default-exported function that receives a Song. Three calls form the skeleton: you build tracks, declare sections, and lay out the arrangement.

import type { Song } from "@barline/runtime";
export default function (song: Song) {
// ... song.track(...) / song.section(...) / song.arrange(...)
}

song.track(options) registers a named destination patterns play into. It’s code-owned data: an instrument output, an optional static effect chain, and optional bus sends.

const kick = song.track({
name: "kick",
output: { kind: "synth", synth: { id: "kick" } },
effects: [fx.distortion({ drive: 0.35 }), fx.limiter({ threshold: -2 })],
});
const hats = song.track({
name: "hats",
output: { kind: "synth", synth: { id: "hat", params: { volume: -12 } } },
sends: { echo: 0.12 },
});

The output.kind is one of:

  • synth — a built-in or package-provided synth, referenced by id with optional params (see Types for the deep synths kick909, acid303, stab).
  • sampler — named sample slots: { kind: "sampler", samples: { kick: "/samples/kick.wav" } }. Named keys map chromatically from C3 in declaration order.
  • soundfont — a user-uploaded SoundFont (.sf2/.sf3) played live: { kind: "soundfont", font: "<upload-id>", program: 0 }. font is the id of an uploaded font (served from /api/uploads/<id>); program is a GM program number 0–127 (bank 0, GM melodic), one program per track.
  • midi — Web MIDI out (“pro mode”: drive Ableton or any DAW), with a channel and optional port.

song.section(name, length, body) registers a section. The body runs at every bar boundary, so per-bar logic and flag reads just work. Track methods are legal only inside a body — they write into the current bar’s capture buffer:

  • track.play(pattern) → a PlayHandle you can chain .gain(v) and .through(...fx) on.
  • track.automate(param, curve) → attach an automation curve.
  • track.clip(options) → schedule an audio clip (one-shot or pre-warped loop).
  • track.at(offset) → offset subsequent play() within the section, e.g. clap.at(bars(8)).play(p) makes the clap exist only from bar 8 onward.

The body receives a SectionContext (t by convention):

song.section("drop1", bars(16), (t) => {
const drive = Number(t.flags.drive ?? 0); // the flag snapshot
const bar = Math.floor(t.position() / 4); // position() is in beats
kick.play(straightKick);
if (bar === 15) hats.play(fast(2, crescRoll)).gain(0.5); // a turnaround fill
});

t.position() is the position within the section (in beats); t.flags is the bar-boundary flag snapshot; t.bpm is the tempo. t.inherit("other") re-runs another section’s body first, then you layer on top:

song.section("build1", bars(16), (t) => {
t.inherit("intro"); // everything intro does, then:
hats.play(at([2, 6, 10, 14])).gain(0.7);
rumble.play(rumbleA).gain(0.5);
});

song.arrange(entries) is the play order. repeat plays a section more than once. Reorder it live and the clock applies the change at the next bar.

song.arrange([
{ section: "intro" }, // 16 bars
{ section: "build1" }, // 16 bars
{ section: "drop1", repeat: 2 }, // 32 bars
{ section: "breakdown" }, // 16 bars
{ section: "drop2", repeat: 2 }, // 32 bars
{ section: "outro" }, // 16 bars
]);

Sections are 1-bar-and-up chunks of structure; the arrangement is a flat list of them. That’s the whole timeline — and the Arrange view is a direct projection of this list plus the dry-run of each section.