aboutsummaryrefslogtreecommitdiffstats
path: root/docs/adr/0001-pcm-ring-buffer-over-opus-storage.md
blob: 24c9f98b266febac5d9d2d97bdcb18c23c2758f4 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# ADR 0001 — Mixed PCM ring buffer over per-SSRC Opus storage

Date: 2026-07-08
Status: Accepted

## Context

The voice-recording feature (`/record`) must keep a rolling, fixed-length
window of everything spoken in the bot's current voice channel, so that a
flush produces a single mixed audio file of (up to) the window length.

songbird delivers incoming voice as one Opus stream per speaking SSRC. There
were two viable storage strategies:

- **(A) Mixed PCM ring buffer.** Decode each packet to PCM, sum all speakers
  into one pre-allocated ring of mono i16 samples. Flush = encode the ring.
- **(B) Raw Opus packets per SSRC.** Store each SSRC's compressed packets in a
  timestamped deque. Flush = decode all, mix by timestamp, then encode.

## Decision

Store a single mixed mono-s16 PCM ring buffer (**A**), sized
`sample_rate × window_seconds` (~86 MB at 48 kHz / 15 min), allocated only
while the bot is connected and recording is enabled.

## Rationale

- **Matches the stated mental model.** The feature was specified as "a blob in
  memory that has the newest 15 minutes, deletes the old, appends the new at
  the end." (A) is the literal implementation of that; (B) stores N separate
  per-user deques, which contradicts the "single blob, append at end" shape.
- **Trivial flush.** (A) flushes by handing a known-size s16le buffer to one
  ffmpeg encode. (B) requires bulk decode + timestamp-aligned mixing at flush
  time — heavier and more failure-prone (drift, late packets, anchor resets).
- **songbird already decodes.** With the `receive` feature and
  `DecodeMode::Decode(Mono, 48kHz)`, songbird's `CoreEvent::VoiceTick` delivers
  already-decoded, jitter-buffered mono i16 every 20 ms — so (A) needs no
  per-SSRC Opus decoder, no `opus2` direct dependency, and no f32→s16 step.
  (B) would re-derive work songbird already does.
- **The memory cost is opt-in.** `RECORDING_ENABLED` defaults to `true` but a
  memory-constrained host sets it `false` and pays 0. A host that enables
  recording accepts the ~86 MB. `RECORDING_WINDOW_SECONDS` lets such a host
  shrink the ring (e.g. 300 s → ~29 MB) without code changes.

## Consequences

- ~86 MB resident while recording (at the default 15 min / 48 kHz window).
  Acceptable for a single-guild self-hosted bot; tunable down via the window.
- Multi-speaker overlap is mixed with `i16::saturating_add`, which can clip
  slightly on very loud overlaps rather than wrap to garbage. The right trade
  for a 50 Hz × N-packet hot path.
- No per-speaker attribution is possible from the stored buffer (the mix is
  lossy by design). If attribution is ever needed, (B) would have to be
  revisited — this decision is the main thing that makes that reversal costly.