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.
|