# SOUND — audio playback and mixing

## Overview

`sound` is Lilush's audio module, aimed at game-engine use alongside
[`term.gfx`](GFX.md) and the [LEV](LEV.md) runtime.

## Why it talks to the kernel directly

Lilush links **fully static against musl**, and a static musl binary cannot
`dlopen`. ALSA's entire plugin/config layer — including `dmix`, its software
mixer, and the `default` device — is loaded through `dlopen`, so it is
**unavailable to Lilush regardless of which audio library we link**. Linking
`libasound` or vendoring a library like `miniaudio` would still be limited to
raw `hw:` device access *and* drag in a heavy dependency for no capability gain.

So `sound` talks to the **kernel PCM interface directly**: `ioctl()` on
`/dev/snd/pcmC*D*p`, using the structures and ioctl numbers from
`<sound/asound.h>` (shipped by the build container's `linux-headers`). Zero
third-party dependencies, static by construction, and the device fd plugs
straight into LEV's `wait_writable`.

Two consequences follow from there being no `dmix`:

1. **The device is held exclusively.** While `sound` has the device open, other
   programs cannot play through it (and vice-versa). Stop other audio apps
   first.
2. **All mixing happens in-process.** That is exactly what `sound.mixer` is for
   — there is no system mixer to fall back on.

## Architecture

| Module        | Responsibility                                                       |
| ------------- | -------------------------------------------------------------------- |
| `sound.core`  | C: raw PCM playback `device`, a float32 `buffer`, and the mixer hot path |
| `sound.mixer` | Lua: software mixer — simultaneous voices, gain/pan, looping         |
| `sound.wav`   | Lua: WAV (RIFF) decoder → `buffer`                                   |
| `sound.synth` | Lua: oscillator + ADSR tone generator                               |
| `sound.speech`| Lua: formant (Klatt-style) English text-to-speech — see [SPEECH.md](SPEECH.md) |

Every Lua-facing sample buffer is **interleaved float32 in [-1, 1]**. Mixing
accumulates in float and never clips intermediate sums; the single clamp to
[-1, 1] happens in the device on write, where float is also converted to the
hardware format (s16) if the card does not accept float directly. Frame offsets
at the Lua boundary are **1-indexed**, matching the rest of the codebase.

## Quick start

```lua
local lev = require("lev")
local gfx = require("term.gfx")
local sound = require("sound")

local dev = assert(sound.open({ rate = 48000, channels = 2 }))
local mx = sound.mixer({ channels = dev:channels() })

local blip = assert(sound.synth.tone({ note = "A5", dur = 0.08, wave = "square", amp = 0.4 }))
local music = assert(sound.wav.load("music.wav", { rate = dev:rate() }))

lev.run(function()
    sound.run(dev, mx)                       -- dedicated audio writer task
    mx:play(music, { gain = 0.6, loop = true })

    gfx.loop({
        fps = 60,
        canvas = gfx.canvas({ width = 320, height = 200 }),
        display = true,
        render = function(c) --[[ draw ]] end,
        input = function(key)
            if key == " " then mx:play(blip, { pan = 0.3 }) end
        end,
    }):run()
end)
```

The frame loop drives visuals and just calls `mx:play()`; the `sound.run`
writer task does the device I/O. Both run under one `lev.run`. Audio is fed on
its own fd-driven schedule (not from `update`) because it must stay ahead of
the hardware more reliably than frames are rendered.

## `sound.open(opts)` → device | nil, err

Opens `/dev/snd/pcmC<card>D<device>p`, negotiates hardware/software parameters,
and prepares the stream. `opts` (all optional):

| key        | default | meaning                                       |
| ---------- | ------- | --------------------------------------------- |
| `card`     | 0       | ALSA card number                              |
| `device`   | 0       | PCM device number                             |
| `rate`     | 48000   | sample rate (Hz)                              |
| `channels` | 2       | channel count                                 |
| `period`   | 1024    | frames per period (latency granularity)       |
| `periods`  | 4       | periods in the ring buffer (~85 ms at default)|
| `format`   | "auto"  | `"auto"` (float, else s16), `"f32"`, or `"s16"`|

Device methods: `dev:fd()`, `dev:period()`, `dev:channels()`, `dev:rate()`,
`dev:format()` (`"f32"`/`"s16"`), `dev:avail()` (free frames), `dev:write(buf)`
(→ frames written, or `nil,"EAGAIN"`/`nil,"EPIPE"`/`nil,err`), `dev:recover()`
(re-prepare after an underrun), `dev:start()`, `dev:drop()`, `dev:close()`.
Playback auto-starts once the ring buffer fills.

## `sound.buffer(frames [, channels=2])` → buffer

A block of interleaved float32 samples. Methods:

| Method                                  | Description                                  |
| --------------------------------------- | -------------------------------------------- |
| `b:frames()` / `b:channels()`           | size                                         |
| `b:clear()`                             | reset to silence                             |
| `b:get(frame, ch)` / `b:set(f, c, v)`   | read/write one sample (1-indexed)            |
| `b:mix(src, dst_off, src_off, n, ...g)` | add `n` frames of `src` scaled by gains (mixer core) |
| `b:osc(wave, freq, rate, phase, amp, dst_off, n)` | additive oscillator → next phase   |
| `b:fade(g0, g1, dst_off, n)`            | linear gain ramp (envelope segment)          |
| `b:tostring()`                          | raw interleaved f32 bytes                    |

`mix` gains: none → unity; one → master volume; per-channel → pan. A mono source
mixed into a stereo destination is duplicated to both channels.

Other core functions: `sound.from_pcm(data, channels, fmt)` (decode raw PCM
bytes — `"u8"`, `"s16"`, `"s24"`, `"s32"`, `"f32"` — to a float buffer) and
`sound.resample(buf, src_rate, dst_rate)` (linear resample).

## `sound.mixer(opts)` → mixer

`opts.channels` (default 2) must match the output buffer / device channels.

- `mx:play(buf, opts)` → voice. `opts`: `gain` (default 1), `pan` (-1..1,
  equal-power), `loop` (bool). Voice: `:stop()`, `:set_gain(g)`, `:set_pan(p)`,
  `:is_active()`.
- `mx:fill(out, n)` — clear `out`, mix every voice's next `n` frames into it,
  advancing playheads and reaping finished non-looping voices.
- `mx:stop(id)`, `mx:stop_all()`, `mx:active()` (voice count).

`sound.run(dev, mx)` calls `fill` for you on a writer task; call `fill` directly
only if you drive the device yourself.

## `sound.wav`

- `sound.wav.decode(data, opts)` → buffer, info | nil, err
- `sound.wav.load(path, opts)` → buffer, info | nil, err

Supports uncompressed integer PCM (8/16/24/32-bit) and IEEE float32, including
`WAVE_FORMAT_EXTENSIBLE`. `opts.rate` resamples to that rate (pass `dev:rate()`
so assets play at the right pitch). `info` is `{ channels, rate, bits, frames }`.

## `sound.synth`

- `sound.synth.tone(opts)` → buffer | nil, err. `opts`: `freq` or `note`
  (e.g. `"A4"`, `"C#5"`); `dur` (s, default 0.25); `rate` (48000); `channels`
  (2); `wave` (`"sine"`/`"square"`/`"saw"`/`"triangle"`/`"noise"`); `amp`
  (0.5); ADSR `attack`/`decay`/`sustain`(level)/`release`.
- `sound.synth.note_freq(name)` → Hz (12-TET, A4 = 440).

## `sound.speech`

Built-in English text-to-speech: a deterministic Klatt-style formant
synthesizer with voice presets and knobs (pitch, formant scale, rate,
breathiness, robotic quantize). Mono output, resampled to `opts.rate`:

```lua
local buf = assert(sound.speech.say("Docking request granted.", {
    voice = "computer", rate = dev:rate(),
}))
mx:play(buf, { gain = 0.9 })
```

Full pipeline, phoneme-level API (`phonemize`/`synth`), knob semantics, and
the tuning workflow: [SPEECH.md](SPEECH.md).

## Testing

Unit tests (buffer math, PCM decode, resample, WAV parsing, synth, mixer) need
no hardware and run under the normal suite:

```
./run_tests.bash sound
```

Actual playback needs a real device. A manual smoke test lives at
`tests/sound/manual/smoke.lua` — run it on a host with `/dev/snd` (and
membership in the `audio` group). It sits in a subdirectory so `run_tests.bash`
does not pick it up (the build container has no sound device):

```
lilush tests/sound/manual/smoke.lua
```
