# game-audio

[![Discord](https://img.shields.io/discord/1523410614339506439?label=Discord&logo=discord)](https://discord.gg/gxuqx8phB2)

Procedural game audio library. Generates tones, noise, pattern-based music, and full multi-track songs from a text format — without bundled audio files.

## Features

- **Tone** – sine wave with configurable frequency, frequency slide, and amplitude decay
- **Noise** – PRNG-based noise with decay
- **Sequence** – multi-segment frequency sequences for one-shot SFX
- **Track format** – layered songs in a `.track` text format with synthesized notes (custom timbres, envelopes, vibrato/tremolo/slide, multi-point pitch/volume curves, per-sound pitch/volume randomization), an exponential tempo ramp, sampler sounds playing audio files (WAV/MP3/OGG/FLAC) with pitch zones, per-layer volume/pan/effects/loop
- **Motifs** – named, reusable melodic figures placed on tracks with a chosen sound and transposition; edit the definition and every instance updates
- **Tunings** – custom scales (just intonation, equal temperaments, non-octave periods) as frequency ratios; pitches become scale-degree indices
- **Adaptive audio** – chain songs across files with conditional `@jump` (host parameters, play counter, randomness) for horizontal re-sequencing, and fade additive layers live with `@volume param` for vertical layering — interactive game music
- **Sound effects** – trigger a synth or sampler sound as a one-shot at runtime with fresh pitch/volume randomization (`sound_oneshot`/`sample_oneshot`), so repeats never sound identical, or fire a beat-aligned motif stinger over the running song (`play_stinger`)
- **Imports** – pull sounds, motifs, and tunings from other `.track` files, local or by URL (downloaded and cached natively; a browser host resolves URL imports through its own async `fetch` via `resolve_imports_async`); `@import std:instruments` loads a bundled instrument bank compiled into the binary (feature `std-instruments`)
- **Singing voices** – notes with sung syllables via [phoneme-synth](https://gitlab.com/porky11/phoneme-synth) voice presets (feature `speech`)
- **Recording** – capture the default input device into memory or a WAV file (feature `record`): `Recorder::start()? … .stop().save_wav(path)`
- **`SoundSystem`** – rodio wrapper for one-shot, delayed, and ambient sounds; `SongPlayer` plays songs on every target, going through Web Audio on wasm (the browser host loads and parses `.track` sources itself, then calls `play`)
- **Headless builds** – the device-facing types sit behind the default `playback` feature; `default-features = false` (plus e.g. `export`) gives a server build that parses, renders, and exports without an audio device or ALSA

## Quick example

```rust
use std::time::Duration;
use game_audio::{Tone, Noise, Sequence, SoundSystem};

let system = SoundSystem::new();

system.play(Tone::new(Duration::from_millis(200), 440.0, 0.0, 0.2, 0.999));
system.play(Noise::new(Duration::from_millis(500), 0.5, 0.99995));
system.play(Sequence::new(&[(800.0, 0.04), (1100.0, 0.06)]));
```

## Concepts

Everything in a song is user-definable data, layered like this:

```text
Song (one .track file; tempo, length, @jump graph between songs)
└─ Layers (mix simultaneously; volume/pan/effects, own tuning + root)
   ├─ Notes ──── each names a Sound, placed at a beat with pitch/duration
   └─ Motif instances ── place a named Motif (reusable note figure)
                         with a chosen Sound, transpose and tuning override

Sound ("instrument") = any mix of
   ├─ Harmonics: waveform components — built-in shapes or drawn Waves
   │             (named curve definitions), each at a frequency multiple
   ├─ Samples:   audio files with pitch zones (imported or recorded)
   └─ Voice:     a singing preset (feature `speech`)
   plus envelope, vibrato/tremolo, slide, arpeggio, distortion, steps

Tunings (named scales) — selected per layer, overridable per motif instance
Waves (named drawn waveforms) — referenced by harmonics
Motifs (named note figures) — placed on layers, per-instance note overrides
```

Sounds, wavetables, tunings and motifs are named definitions, shareable
across files with `@import` (local paths or URLs). Motifs are flat note
lists — they have no layers of their own; simultaneous notes inside a motif
are simply notes with the same start, and one motif can be placed on several
layers.

## Track format

Songs live in `.track` files: a small text format with synthesizable sounds, multiple tracks, and clips placed on a beat timeline.

```text
@tempo 120

# Sounds

bell:   sine 1, octave 0.4, decay 0.5
guitar: sine 0.6, triangle 0.3, attack 0.01, decay 0.2
kick:   sample "kick.wav"

# Layers

## Drums
@volume 0.9

kick 0 0 1
kick 1 0 1

## Melody
@volume 0.6

bell 0 0 2
bell 2 7 2
```

- Sound definitions list harmonics as `<waveform> [@multiplier] <weight>`, where `waveform` is a built-in name (`sine`, `triangle`, `square`, `saw`, `soft_square`, `hill`) or a custom `# Waves` entry — every harmonic is a wavetable reference, and the built-ins are always available without an import. A custom `# Waves` entry of the same name overrides a built-in. Plus `distortion`, `volume`, `sustain`, `noise`, `vibrato`/`tremolo <rate> <depth> [waveform]` (the LFO shape is any waveform name too), `slide <semitones> <sec>`, and `attack`/`decay`/`release <sec> [curve]`. `decay` fades out within the note (reaching zero at its end); `release` rings out past the note end from whatever level the note ends at (piano/pad tails). Curves are `linear`, `ease-in`, `ease-out`, or `ease` (both); omitting the curve token defaults to `ease` (a smooth S-curve).
- Default instruments: those built-in waveform names also work as zero-setup *instruments* — a note may reference `sine`, `saw`, … directly (`sine 0 0 1`) with no `# Sounds` entry at all, playing a single harmonic of that waveform with a gentle click-free envelope. A `# Sounds` definition of the same name overrides it.
- Chiptune extras: `hard_square <weight> duty <width>` plays a pulse of that width (NES-style 0.125/0.25/0.5); `arpeggio <offsets…> rate <steps per second>` cycles semitone offsets over held notes (a chord on one voice, `arpeggio 0 4 7 rate 30`); a `# Waves` section defines named single-cycle waveforms drawn as vertices, referenced by name in a harmonic (bare or `wave <name>`) and shareable via `@import` like sounds. Each line is one vertex `<x> <y> [curve]`, where `x` is the phase in `0..=1`, `y` the amplitude, and the curve (same tokens as notes) shapes the span to the next vertex. Vertices run in non-decreasing `x` and chain automatically; the last wraps to the first for a seamless loop, and a vertex at `x = 1` holds a flat tail to the period boundary. Two vertices at the same `x` make a vertical jump — a sine-like tone is three eased vertices, a triangle the same marked `linear`, a square a pair of jumps. A sound-level `steps <n>` quantizes each period into `n` stairs (GB/C64 wave-channel lo-fi) while definitions stay resolution-independent.

  ```text
  # Waves

  ## smooth

  0 0
  0.25 1
  0.75 -1
  ```
- Layers (`## <name>` subsections under `# Layers`) mix simultaneously and have `@volume`, `@pan`, `@mute`, `@solo`, `@loop <length> [skip <n>]` directives. `@mute`/`@solo` are editor mixing hints: playback ignores them by default (a song shipped with a stray solo still sounds complete in a game), and an editor opts back in with `SongPlayer::set_mute_solo(MuteSolo::Honor)` or `render_song_stereo_with`. Motifs, tunings and wavetables group the same way under `# Motifs`, `# Tunings` and `# Waves`. (The pre-0.13 one-section-per-item spelling `# Layer <name>` etc. no longer parses; the `track-fmt` tool of game-audio 0.12 migrates old files.)
- `@effect <type> <params>` applies an audio effect to the layer's rendered output; a layer may have several, applied in the order they appear. Types: `lowpass <cutoff_hz>` and `highpass <cutoff_hz>` (one-pole filters), `echo <delay_sec> <feedback 0..1> <mix 0..1>`, `tremolo <rate_hz> <depth 0..1>`, `bitcrusher <bits 1..16>`.

  ```text
  ## Melody
  @effect lowpass 2000
  @effect echo 0.3 0.4 0.25
  ```
- Note clips: `<sound> <start_beat> <pitch_semitones> <duration_beats>`, with optional `glide <semitones> [curve]`, `volume <v>`, `vol-ramp <target> [curve]`, and `arpeggio <offsets…> rate <n>`. Pitch is in semitones relative to A4 (0 = 440 Hz); glide is relative (semitones added over the note, 0 = steady), vol-ramp is a factor (the volume ramps to `target ×` its level, 1 = steady, 0 = fade out). A per-note `arpeggio` cycles semitone offsets over the note (a chord on one voice, same syntax as the sound-level one) and overrides the sound's arpeggio for that note. For more than a single ramp, give the note a multi-point curve on indented sub-lines: `pitch <x> <y> [curve], …` and `volume <x> <y> [curve], …`, where `x` is normalized time over the note (0..1), pitch `y` an offset in semitones and volume `y` a factor — the same eased-vertex system as wavetables. `glide`/`vol-ramp` are the two-point shorthand for it.

  ```text
  ## Lead

  bell 0 0 2
    pitch 0 0, 0.5 7 ease, 1 0
    volume 0 0, 1 -0.4 linear
  ```
- Sound randomization: `detune <semitones>` and `vol-jitter <fraction>` on a sound give each note a random pitch and volume within `±` that range, so repeats never sound identical (footsteps, hits). In a song render it is deterministic per note (reproducible); for runtime sound effects call `sound_oneshot(&song, "<name>", pitch, duration, seed)` with a changing `seed` each trigger and play the returned source through `SoundSystem`. `sample_oneshot(&song, "<name>", pitch, base_dir, seed)` is the counterpart for sampler sounds (the sample plays to its natural end). To skip seed bookkeeping, `sound_oneshot_varied`/`sample_oneshot_varied` draw a fresh seed from an internal counter each call.
- Stingers: `SongPlayer::play_stinger(&song, "<motif>", "<sound>", quantize, base)` plays a motif once over the running song, mixed into the master and aligned to the beat or bar (`Quantize::Immediate`/`NextBeat`/`NextBar`) so a pickup fanfare or level-up sting lands in time. The motif renders with the chosen sound at the current playback tempo; the motif may come from the playing song or a separate stinger bank. Native targets only (a no-op when nothing is playing).
- Sampler sounds: `sample "<file>" [@ <low>..<high>]` entries (paths relative to the song file) turn a sound into a sampler. A note's pitch sets the playback speed (`0` = original, `12` = octave up/double speed) and the file plays to its natural end (the note duration is ignored). Several zones pick a different file per pitch range, so instruments stay natural across registers. Samples and harmonics mix: a sound may have only `sample` entries (pure sampler), only synth parameters, or both layered (e.g. a recorded kick plus a synth sub).
- `@import other.track` pulls in another file's sounds and motifs. The path may
  be a URL (`@import https://example.com/instruments.track`); URL imports are
  downloaded and cached (native, default `online` feature). A cached URL is
  reused until forced to re-fetch: add `refresh` to one import
  (`@import <url> refresh`), or pass `--refresh` to `track-play` to re-download
  all of them. Ready-made instrument banks ship in
  [`sounds/`](https://gitlab.com/porky11/game-audio/-/tree/master/sounds)
  (`orchestral`, `chiptune`, `drums`, and `all` which re-exports them); import a
  category or `sounds/all.track`. External projects can import them straight
  from this repository:

  ```text
  @import https://gitlab.com/porky11/game-audio/-/raw/master/sounds/all.track
  ```

  This tracks `master`; because imports are cached, pull updates with `refresh`
  (or `track-play --refresh`). Pin to a release tag instead of `master` for a
  frozen, reproducible version.

Motifs are named, sound-less note figures defined as `## <name>` subsections
under `# Motifs` (`<start> <pitch> <duration>` per line, with the same optional
tokens and indented multi-point `pitch`/`volume` sub-lines as notes). Place one on a track with `motif <name> <start> <sound>
[transpose <n>] [tuning <name>]` — it references the definition rather than
copying it, so editing the motif updates every placement. An optional `tuning`
overrides the layer's tuning for that placement, so the same motif can recur in
major and minor (a full leitmotif shift: pitch, instrument, and scale per
instance).

A placement can also tweak individual notes without editing the shared
definition, via indented lines under the `motif` line. `note <index> <field>
<value>…` overrides fields (`pitch`, `start`, `duration`, `glide`, `volume`) of
the note at `index` (0-based, in play order); overridden values still get the
instance's transpose and start offset, and an index with no such note is
ignored. `note <index> skip` drops that note for this placement only.
`trim <from> <count>` keeps only that run of notes for the placement (dropping
leading/trailing ones). To add or duplicate notes, define a separate variant
motif instead.

```text
# Motifs

## hook
0 0 1
1 7 1

# Layers

## Lead
motif hook 0 bell
motif hook 8 bell transpose 12
  note 1 pitch 14 volume 0.8
  trim 0 1
```

### Song structure

Each `.track` file is one song. `@tempo <start> [<end>]` sets the tempo in bpm;
with an `end` the song ramps between them exponentially, so the relative
speed-up over time is constant (`@tempo 60 240` doubles the tempo at the halfway
time and doubles again by the end — a smooth accelerando). Looping layers render
at the tempo of their first pass, so an accelerando is best on a through-composed
song rather than one built from loops. `@length <beats>` sets its length
explicitly; without it the length is the end of the latest note across all
layers. `@signature <beats_per_bar> [beat_unit]` sets the time signature (default 4/4)
for consumers that group beats into bars; it is metadata only, playback counts
beats regardless. `@jump <file> [skip_beats]` continues to another song when
this one ends — a self-reference loops, and any cycle is allowed. The optional
skip drops that many beats at the target's start (e.g. re-enter a loop past its
intro).

A `# Meta` section carries human-readable metadata (shown in song browsers and
pickers, not used in playback): `title`, `description` and `author` lines land
on `Song::title`/`description`/`author`, and a `## Tags` subsection lists one tag
per line into `Song::tags`. A `# Subtitles` section holds timed text — one
`<beat> <line>` per row, sorted by beat, a bare `<beat>` clearing the previous
line — parsed into `Song::subtitles`. During playback,
`SongPlayer::current_subtitle()` returns the line active at the current position
(loop- and `@jump`-aware), so a game shows lyrics or captions in sync without
timing them itself.

```text
@length 8
@jump loop.track 2

# Meta

title Cave Theme
author p

## Tags

ambient
loop

# Layers

## Melody
lead 0 0 8
```

A song may have several `@jump` lines, each optionally conditional; the first
whose condition holds is taken when the song ends (an unconditional jump is the
fallback). A condition is `if <source> <op> <value>`, where `source` is `plays`
(how many times this song has played), `random` (a fresh `0..1` draw), or a
parameter name; `op` is one of `< <= > >= == !=`. Parameters are declared with
`@param <name> [default]` and set at playtime with `SongPlayer::set_parameter`,
so the host can steer the music (combat, location, mood). Conditions are
read-only comparisons — the graph stays a finite-state machine, not a program.

```text
@param combat 0
@jump action.track if combat > 0
@jump verse.track if plays >= 4
@jump chorus.track if random < 0.3
@jump loop.track
```

### Adaptive audio

Adaptive game music reacts to play instead of looping one fixed track.
`game-audio` gives you two axes, usable on their own or together:

- **Vertical** — one looping song whose layers are additive stems, faded in and
  out by intensity.
- **Horizontal** — whole songs re-sequenced at their ends with conditional
  `@jump`.

Build it up step by step.

**1. Split the music into additive layer stems.** Each `## <layer>` subsection
is a stem that sums into the mix — say `Drums`, `Bass`, and `Lead`. Loop them
together with `@loop <beats>` so they stay in phase as intensity changes.

**2. Declare a parameter** for the game to drive:

```text
@param combat 0
```

**3. Bind a stem to it and drive it from the game loop.** `@volume param <name>
[scale]` scales that layer's volume by the parameter; the `@param` default is
the starting level, so `0` keeps the stem silent until combat rises. Each frame
the host sets the parameter and calls `update`, and the mixer ramps the gain, so
fades stay click-free.

```text
## Lead
@volume param combat
```

```rust,no_run
use game_audio::{track::{SongPlayer, parse}, SoundSystem};
# let track = "@tempo 120\n@param combat 0\n# Sounds\nlead: sine 1\n# Layers\n\n## Bass\nlead 0 -12 1\n\n## Lead\n@volume param combat\nlead 0 7 1\n";
# let combat_intensity = 1.0;

let song = parse(track)?;
let system = SoundSystem::new();
let mut player = SongPlayer::new();
player.play(&system, &song, None, 0.0)?;

// Each frame, from the game loop: drive the combat stem by intensity.
player.set_parameter("combat", combat_intensity);
player.update(&system)?;
# Ok::<(), game_audio::track::PlayerError>(())
```

`set_layer_volume(index, volume)` sets a layer directly for a manual mixer, and
`layer_levels()`/`master_levels()` return post-fader peak/RMS meters for a mixer
view.

**4. Re-sequence whole songs horizontally.** A looping song's `@jump` lines pick
the next song each time it reaches its end, from the same parameters plus a play
counter and randomness (see the `@jump` conditions above). The host drives them
with the same `set_parameter`, so combat cuts to battle music and calms back
down at musical boundaries rather than mid-phrase:

```text
@param combat 0
@jump action.track if combat > 0
@jump bridge.track if random < 0.3
@jump loop.track
```

**5. Live-reload while composing.** A song is a text file, so hot-reload is
nearly free: watch the `.track` file and, on save, reparse and resume in place
without losing the beat.

```rust,no_run
use game_audio::{track::{SongPlayer, Refresh}, SoundSystem};
# let system = SoundSystem::new();
# let mut player = SongPlayer::new();
# let path = std::path::Path::new("song.track");
let position = player.position();
player.play_path_with(&system, path, Refresh::Cached)?;
let _ = player.seek(position);
# Ok::<(), game_audio::track::PlayerError>(())
```

Two runnable demos: [`examples/adaptive.rs`](https://gitlab.com/porky11/game-audio/-/blob/master/examples/adaptive.rs)
fades a lead layer in and out of a drum-and-bass loop
(`cargo run --example adaptive`), and
[`examples/song/`](https://gitlab.com/porky11/game-audio/-/tree/master/examples/song)
is a full conditional-`@jump` song graph you can drive with `track-play`.

### Tunings

By default a pitch is a semitone in 12-tone equal temperament. A `## <name>`
subsection under `# Tunings` defines a custom scale as frequency ratios (fractions like
`3/2` or decimals, normalized so the first is 1.0), or `equal <n>` for `n`
equal divisions; `period <ratio>` sets the repeat interval (default `2/1`, the
octave). A layer selects one with `@tuning <name>` and sets the frequency of
pitch 0 with `@root <hz>` (default 440). Pitches are then scale-degree indices;
fractional pitches interpolate. Tunings are merged by `@import` like sounds and
motifs, and a small library ships in
[`scales/scales.track`](https://gitlab.com/porky11/game-audio/-/blob/master/scales/scales.track)
(`major`, `minor`, `pentatonic`, `whole_tone`, just-intonation `seven_limit`,
`pythagorean` from stacked pure fifths, and equal-tempered `chromatic`).

```text
@import scales/scales.track

# Layers

## Melody
@tuning major
@root 261.6
lead 0 0 1
lead 1 1 1
lead 2 2 1
```

With `@root 261.6` (C) and `major`, pitch 0 is C, pitch 1 the second scale
degree (D), pitch 2 the third (E), and so on. There are no mid-line comments,
but a whole `#` line with an unrecognized header starts an "unknown" section
that is preserved and ignored for playback, so such lines work as comments;
`@` directives after them still apply.

### Singing voices (feature `speech`)

With the `speech` feature a sound can be a singing voice instead of a synth
timbre, powered by [`phoneme-synth`](https://gitlab.com/porky11/phoneme-synth).
`voice <preset>` picks a speaker (`male`, `female`, `child`, `robot`,
`ghost`), optionally followed by continuous parameter overrides
(`formant-shift`, `tilt`, `breathiness`, `vibrato`). Adding `ensemble <n>`
renders the voice as a choir of `n` varied singers mixed together (`voice
female ensemble 6`), for a thick choral sound instead of a soloist. A note on a voice sound
carries its sung text as a quoted token in phoneme-synth's
one-symbol-per-sound notation: spaces separate words, `'` separates syllables
within a word (`"hal'lo du"` sings one two-syllable word, then another word),
and the syllables share the note's duration. A **leading `'`** attaches a note
to the previous note on the same sound (`Note.articulation` in the API) — the
run is sung as one legato phrase, so syllables flow into each other; without
it each note starts a fresh phrase.
Consonants stay short at the syllable edges while vowels stretch, and
everything a normal note can do (glide, volume, ramps, motifs with transpose
and overrides — including `text` overrides) applies to sung notes too. A
multi-point `pitch` curve bends the sung pitch through its breakpoints across
the note's syllables (volume still shapes between its two endpoints).

```text
# Sounds

singer: voice female tilt 0.6 breathiness 0.3

# Layers

## Vocals

singer 0 0 1 "hal"
singer 1 2 1 "'lo"
singer 2 0 2 "welt" glide -2
```

For editors, `sing_note(&sound, "la", pitch, seconds)` renders one sung note
as a source playable via `SoundSystem::play` (auditioning voice sounds).

See [`examples/vocal/`](https://gitlab.com/porky11/game-audio/-/tree/master/examples/vocal) for demos: `phonemes.track` (minimal syllable/glide demo) and full sung songs.

Play a single song:

```rust,no_run
use game_audio::{SoundSystem, track::SongPlayer};

let system = SoundSystem::new();
let mut player = SongPlayer::new();
player.play_path(&system, "song.track").unwrap();
```

Play a song and follow its `@jump` graph across files (call `update`
periodically to advance jumps). The next song is chosen and rendered on a
background thread half a second before the current one ends, and the mix
switches to it sample-exact at the boundary — jumps are gapless and `update`
never blocks the game loop:

```rust,no_run
use game_audio::{SoundSystem, track::SongPlayer};

let system = SoundSystem::new();
let mut player = SongPlayer::new();
player.play_graph_path(&system, "intro.track").unwrap();
loop {
    player.update(&system).unwrap();
    std::thread::sleep(std::time::Duration::from_millis(50));
}
```

A game that embeds its songs in the binary — or runs on wasm, where there is no
filesystem — builds the same graph from sources it already holds with
`SongGraph::from_sources("intro.track", &[…])`, passing each song's name and
text. Jump targets and `@import`s resolve against the other given names, so a
shared sounds file works as it does on disk, and `play_graph` takes the result
unchanged. In a real game the sources come from `include_str!`.

```rust,no_run
use game_audio::{SoundSystem, track::{SongGraph, SongPlayer}};
# let sounds = "# Sounds\nbell: sine 1\n";
# let intro = "@import sounds.track\n@jump loop.track\n# Layers\n\n## A\nbell 0 0 1\n";
# let looping = "@import sounds.track\n@jump loop.track\n# Layers\n\n## A\nbell 0 0 2\n";

let graph = SongGraph::from_sources("intro.track", &[
    ("sounds.track", sounds),
    ("intro.track", intro),
    ("loop.track", looping),
])?;
let system = SoundSystem::new();
let mut player = SongPlayer::new();
player.play_graph(&system, graph)?;
# Ok::<(), Box<dyn std::error::Error>>(())
```

Playback runs through a single master mix with a soft-clip limiter, so stacked
layers never hard-clip live either. `SongPlayer` exposes the mixer:
`set_layer_volume(index, volume)` changes one layer's fader during playback,
`layer_levels()`/`master_levels()` return post-fader peak/RMS `Levels` for
meter displays, and `position()` is sample-exact.

The `track` module also exposes `parse`, `resolve_imports`, `SongGraph`, `Song`, `Sound`, `Harmonic`, `Note`, etc., for direct use without the convenience player. Individual building blocks play on their own too: `wave_oscillator(&song, "<name>", frequency, duration)` renders a single named (or built-in) waveform as a bare click-free oscillator, with no song around it, and `sound_sustained(&song, "<name>", pitch)` returns a held voice plus a `VoiceHandle` whose `release()` ends it on note-off — for a live keyboard where the note length is not known up front. While the note sounds, `handle.set_pitch(factor)` glides its frequency live (2.0 = one octave up; smooth per sample, so sweeping it is click-free) and `handle.set_gain(factor)` rides its volume — enough for start/glide/swell/release without touching the sink. `Song` implements `Display` and `FromStr`, so `song.to_string()` writes canonical `.track` text and `text.parse::<Song>()` reads it back; a canonically-formatted file survives a parse → serialize round trip unchanged.

## Tools

Feature-gated binaries ship with the crate:

- `track-play` (feature `play`) — plays a `.track` file, following its `@jump` graph, with seek/speed/volume keys and `--refresh` to re-fetch cached URL imports.
- `track-export` (feature `export`) — renders a `.track` file to WAV or MP3, or one WAV per layer with `--stems`.
- `track-fmt` — rewrites `.track` files in the canonical format (also a migration tool after format changes).
- `track-check` — parses and resolves the imports of one or more `.track` files or directories (recursive), reporting `ok`/`FAIL` per file and exiting non-zero on any failure. Needs no audio device, so it is a headless smoke test for CI or scripts: `track-check [--refresh] <path>...`.
- `midi-import` (feature `midi`) — converts a Standard MIDI File to `.track`: one layer per track, GM programs and the channel-10 drum map to bank sound names (`@import sounds/all.track`), note pitch/timing/velocity carried over. The first tempo is used (tempo changes are dropped with a warning); pitch bend and controllers are ignored. `midi-import <input.mid> [output.track]`. The same conversion is available as a library function on all targets: `song_from_midi(&bytes)` returns an editable `Song` plus warnings, so an editor can open `.mid` files directly.

## Ecosystem

`.track` files travel between tools — see [ECOSYSTEM.md](https://gitlab.com/porky11/game-audio/-/blob/master/ECOSYSTEM.md) for the full picture:

- [plainsong](https://gitlab.com/porky11/plainsong) — keyboard-first editor: compose on a pitch×time sheet, design sounds, sing, export audio. Runs [in the browser](https://p11c.xyz/plainsong-web/).
- [plainaudio](https://gitlab.com/porky11/plainaudio) — music player for `.track`, MIDI, and standard audio formats. Opens shared links [in the browser](https://p11c.xyz/plainaudio/).
- [trackyard](https://trackyard.p11c.xyz) — registry to upload and find shareable parts (sounds, motifs, waves, tunings), every entry playable and editable in one click.

## Notes

This project is designed for use with AI coding assistants: consistent types, no implicit defaults, all parameters explicit. The `deny(missing_docs)` lint ensures complete documentation.
