Skip to main content

rlx_models_core/
audio_codec.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! A unified interface for the workspace's neural audio codecs (rlx-mimi,
17//! rlx-dac, and any RVQ tokenizer), so TTS/ASR consumers can swap codecs
18//! without binding to a concrete struct. Covers the discrete-RVQ shape that
19//! mimi and dac share; codec-agnostic bitrate control and resampling are
20//! provided as trait defaults on top of [`crate::audio`].
21
22use crate::audio::resample_linear;
23use anyhow::Result;
24use rlx_runtime::Device;
25use serde::{Deserialize, Serialize};
26use std::path::Path;
27
28/// Discrete residual-vector-quantizer codes in frame-major layout:
29/// `frames[t][q]` is the codebook index at time step `t` for quantizer `q`.
30///
31/// This is the representation rlx-mimi (`MimiCodes`) and rlx-dac (`DacCodes`)
32/// already use internally; [`RvqCodes`] is the neutral exchange type.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct RvqCodes {
35    /// `[num_frames][num_quantizers]` codebook indices.
36    pub frames: Vec<Vec<u32>>,
37    /// Quantizers (codebooks) per frame.
38    pub num_quantizers: usize,
39}
40
41impl RvqCodes {
42    pub fn new(frames: Vec<Vec<u32>>, num_quantizers: usize) -> Self {
43        Self {
44            frames,
45            num_quantizers,
46        }
47    }
48
49    pub fn num_frames(&self) -> usize {
50        self.frames.len()
51    }
52
53    pub fn is_empty(&self) -> bool {
54        self.frames.is_empty()
55    }
56
57    /// Transpose to quantizer-major `[num_quantizers][num_frames]` (the layout
58    /// HF `MimiModel.encode` and the DAC reference use).
59    pub fn to_quantizer_major(&self) -> Vec<Vec<u32>> {
60        let nq = self.num_quantizers;
61        let t = self.num_frames();
62        let mut out = vec![Vec::with_capacity(t); nq];
63        for frame in &self.frames {
64            for (q, &code) in frame.iter().enumerate().take(nq) {
65                out[q].push(code);
66            }
67        }
68        out
69    }
70
71    /// Build from quantizer-major `[num_quantizers][num_frames]` rows.
72    pub fn from_quantizer_major(rows: &[Vec<u32>]) -> Self {
73        let nq = rows.len();
74        let t = rows.iter().map(|r| r.len()).max().unwrap_or(0);
75        let mut frames = Vec::with_capacity(t);
76        for ti in 0..t {
77            let mut frame = Vec::with_capacity(nq);
78            for row in rows {
79                frame.push(row.get(ti).copied().unwrap_or(0));
80            }
81            frames.push(frame);
82        }
83        Self {
84            frames,
85            num_quantizers: nq,
86        }
87    }
88}
89
90/// Multi-scale RVQ codes where each level runs at a different temporal rate
91/// (e.g. SNAC: coarse level has fewer tokens than fine levels). Levels are
92/// coarse-to-fine; lengths may differ between levels.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct HierarchicalCodes {
95    /// One token stream per RVQ level (codebook indices).
96    pub levels: Vec<Vec<u32>>,
97}
98
99impl HierarchicalCodes {
100    pub fn new(levels: Vec<Vec<u32>>) -> Self {
101        Self { levels }
102    }
103
104    pub fn num_levels(&self) -> usize {
105        self.levels.len()
106    }
107
108    pub fn level_lengths(&self) -> Vec<usize> {
109        self.levels.iter().map(|l| l.len()).collect()
110    }
111
112    pub fn total_tokens(&self) -> usize {
113        self.levels.iter().map(|l| l.len()).sum()
114    }
115
116    pub fn is_empty(&self) -> bool {
117        self.levels.iter().all(|l| l.is_empty())
118    }
119}
120
121/// Static description of a codec — enough to reason about rate, framing, and
122/// bitrate without touching a concrete config struct.
123#[derive(Debug, Clone, Copy, PartialEq)]
124pub struct CodecInfo {
125    /// Audio sample rate (Hz) the codec consumes/produces.
126    pub sample_rate: u32,
127    /// Codec frames per second (Hz) — e.g. Mimi 12.5, DAC `sample_rate/hop`.
128    pub frame_rate: f32,
129    /// PCM samples per codec frame (`sample_rate / frame_rate`).
130    pub hop_length: usize,
131    /// Audio channels (1 for the mono codecs here).
132    pub channels: usize,
133    /// Codebooks (quantizers) available at full rate.
134    pub max_quantizers: usize,
135    /// Entries per codebook.
136    pub codebook_size: usize,
137}
138
139impl CodecInfo {
140    /// Bits carried by one codebook index (`log2(codebook_size)`).
141    pub fn bits_per_codebook(&self) -> f32 {
142        (self.codebook_size.max(2) as f32).log2()
143    }
144
145    /// Bits/sec contributed by a single codebook at this frame rate.
146    pub fn bits_per_second_per_codebook(&self) -> f32 {
147        self.bits_per_codebook() * self.frame_rate
148    }
149
150    /// Nominal bitrate (bits/sec) for `num_quantizers` codebooks (default: all).
151    pub fn bitrate_bps(&self, num_quantizers: Option<usize>) -> f32 {
152        let q = num_quantizers
153            .unwrap_or(self.max_quantizers)
154            .min(self.max_quantizers);
155        q as f32 * self.bits_per_second_per_codebook()
156    }
157
158    /// The largest codebook count whose nominal bitrate stays at or below
159    /// `target_bps`, clamped to `[1, max_quantizers]`. Codec-agnostic bitrate
160    /// control: callers pass a bitrate, codecs pick the quantizer count.
161    pub fn quantizers_for_bitrate(&self, target_bps: f32) -> usize {
162        let per_q = self.bits_per_second_per_codebook();
163        if per_q <= 0.0 {
164            return 1;
165        }
166        let q = (target_bps / per_q).floor() as i64;
167        q.clamp(1, self.max_quantizers as i64) as usize
168    }
169
170    /// Number of codec frames produced for `num_samples` PCM samples.
171    pub fn frames_for_samples(&self, num_samples: usize) -> usize {
172        if self.hop_length == 0 {
173            return 0;
174        }
175        num_samples.div_ceil(self.hop_length)
176    }
177}
178
179/// A neural audio codec: PCM ⇄ discrete RVQ codes, on a chosen backend.
180///
181/// `encode_pcm`/`decode_codes` are whole-clip. The provided methods add
182/// bitrate-driven encoding and resampling front-ends on top, so every codec
183/// gets consistent `--target-bitrate` and arbitrary-input-rate behaviour for
184/// free.
185pub trait AudioCodec {
186    /// Static metadata (rate, framing, codebooks).
187    fn info(&self) -> CodecInfo;
188
189    /// Backend this codec executes on.
190    fn device(&self) -> Device;
191
192    /// Encode mono PCM (at [`CodecInfo::sample_rate`]) to RVQ codes. `None` uses
193    /// the codec's full quantizer count.
194    fn encode_pcm(&self, pcm: &[f32], num_quantizers: Option<usize>) -> Result<RvqCodes>;
195
196    /// Decode RVQ codes back to mono PCM at [`CodecInfo::sample_rate`].
197    fn decode_codes(&self, codes: &RvqCodes) -> Result<Vec<f32>>;
198
199    /// Convenience: the codec sample rate.
200    fn sample_rate(&self) -> u32 {
201        self.info().sample_rate
202    }
203
204    /// Encode at a target bitrate (bits/sec) rather than an explicit quantizer
205    /// count — the codec maps the bitrate to codebooks via [`CodecInfo`].
206    fn encode_pcm_bitrate(&self, pcm: &[f32], target_bps: f32) -> Result<RvqCodes> {
207        let nq = self.info().quantizers_for_bitrate(target_bps);
208        self.encode_pcm(pcm, Some(nq))
209    }
210
211    /// Encode PCM that arrives at `input_rate`, resampling to the codec rate
212    /// first (shared linear resampler).
213    fn encode_pcm_resampled(
214        &self,
215        pcm: &[f32],
216        input_rate: u32,
217        num_quantizers: Option<usize>,
218    ) -> Result<RvqCodes> {
219        let sr = self.info().sample_rate;
220        if input_rate == sr {
221            self.encode_pcm(pcm, num_quantizers)
222        } else {
223            let resampled = resample_linear(pcm, input_rate, sr);
224            self.encode_pcm(&resampled, num_quantizers)
225        }
226    }
227
228    /// Decode then resample the waveform to `output_rate`.
229    fn decode_codes_resampled(&self, codes: &RvqCodes, output_rate: u32) -> Result<Vec<f32>> {
230        let pcm = self.decode_codes(codes)?;
231        let sr = self.info().sample_rate;
232        Ok(if output_rate == sr {
233            pcm
234        } else {
235            resample_linear(&pcm, sr, output_rate)
236        })
237    }
238
239    /// Encode then decode, returning both the codes and the reconstruction.
240    fn roundtrip_pcm(
241        &self,
242        pcm: &[f32],
243        num_quantizers: Option<usize>,
244    ) -> Result<(RvqCodes, Vec<f32>)> {
245        let codes = self.encode_pcm(pcm, num_quantizers)?;
246        let recon = self.decode_codes(&codes)?;
247        Ok((codes, recon))
248    }
249}
250
251/// Stats from a file-bitstream compression pass.
252#[derive(Debug, Clone, Copy, PartialEq)]
253pub struct CompressStats {
254    /// Size of the compressed bitstream on disk.
255    pub compressed_bytes: u64,
256    /// Encode wall time (ms).
257    pub encode_ms: f64,
258    /// Decode wall time (ms); `0.0` for an encode-only call.
259    pub decode_ms: f64,
260}
261
262impl CompressStats {
263    /// Compression ratio vs an uncompressed PCM size (`raw_pcm_bytes / compressed`).
264    pub fn compression_ratio(&self, raw_pcm_bytes: u64) -> f64 {
265        if self.compressed_bytes == 0 {
266            return 0.0;
267        }
268        raw_pcm_bytes as f64 / self.compressed_bytes as f64
269    }
270
271    /// Effective bitrate (bits/sec) for an audio clip of `duration_secs`.
272    pub fn bitrate_bps(&self, duration_secs: f64) -> f64 {
273        if duration_secs <= 0.0 {
274            return 0.0;
275        }
276        self.compressed_bytes as f64 * 8.0 / duration_secs
277    }
278}
279
280/// A **file-level** audio compressor: an audio file ⇄ an opaque compressed
281/// bitstream on disk. Unlike [`AudioCodec`], there are no in-memory PCM buffers
282/// or discrete RVQ codes — the compressed form is a self-contained file
283/// (e.g. TSAC's transformer-entropy-coded `.tsac`). This is the home for codecs
284/// that don't expose a frame/quantizer structure.
285pub trait FileCodec {
286    /// Backend this codec executes on.
287    fn device(&self) -> Device;
288
289    /// Native sample rate the codec targets.
290    fn sample_rate(&self) -> u32;
291
292    /// Compress `in_audio` → `out_compressed`, returning size + timing.
293    fn encode_file(&self, in_audio: &Path, out_compressed: &Path) -> Result<CompressStats>;
294
295    /// Decompress `in_compressed` → `out_wav`.
296    fn decode_file(&self, in_compressed: &Path, out_wav: &Path) -> Result<()>;
297
298    /// Encode then decode through a temporary compressed file. Codecs with a
299    /// cheaper native roundtrip may override this.
300    fn roundtrip_file(&self, in_audio: &Path, out_wav: &Path) -> Result<CompressStats> {
301        let stem = in_audio
302            .file_stem()
303            .and_then(|s| s.to_str())
304            .unwrap_or("audio");
305        let tmp =
306            std::env::temp_dir().join(format!("rlx-filecodec-{}-{stem}.bin", std::process::id()));
307        let enc = self.encode_file(in_audio, &tmp)?;
308        let t0 = std::time::Instant::now();
309        let dec = self.decode_file(&tmp, out_wav);
310        let decode_ms = t0.elapsed().as_secs_f64() * 1000.0;
311        let _ = std::fs::remove_file(&tmp);
312        dec?;
313        Ok(CompressStats {
314            compressed_bytes: enc.compressed_bytes,
315            encode_ms: enc.encode_ms,
316            decode_ms,
317        })
318    }
319}
320
321/// Drives a whole-clip [`AudioCodec`] in a chunked / streaming loop: push PCM
322/// chunks to get codes, push code chunks to get PCM. Each chunk is processed
323/// independently (no carried causal state) — this is exactly how moshi/kyutai
324/// drive Mimi today (whole-clip calls on one-frame inputs).
325///
326/// For truly artifact-free, minimal-latency incremental decoding a codec needs
327/// internal state carry (causal-conv ring buffers + transformer KV); see
328/// rlx-qwen3-tts's `StreamingDecoder` for the reference design. This wrapper
329/// gives consumers a uniform streaming API over the codecs that only expose
330/// whole-clip encode/decode.
331pub struct ChunkStreamer<'a, C: AudioCodec + ?Sized> {
332    codec: &'a C,
333    num_quantizers: Option<usize>,
334    frames_emitted: usize,
335    samples_emitted: usize,
336}
337
338impl<'a, C: AudioCodec + ?Sized> ChunkStreamer<'a, C> {
339    pub fn new(codec: &'a C, num_quantizers: Option<usize>) -> Self {
340        Self {
341            codec,
342            num_quantizers,
343            frames_emitted: 0,
344            samples_emitted: 0,
345        }
346    }
347
348    /// Pick the quantizer count from a target bitrate (bits/sec).
349    pub fn with_bitrate(codec: &'a C, target_bps: f32) -> Self {
350        let nq = codec.info().quantizers_for_bitrate(target_bps);
351        Self::new(codec, Some(nq))
352    }
353
354    /// Encode the next PCM chunk to codes.
355    pub fn encode_chunk(&mut self, pcm: &[f32]) -> Result<RvqCodes> {
356        let codes = self.codec.encode_pcm(pcm, self.num_quantizers)?;
357        self.frames_emitted += codes.num_frames();
358        Ok(codes)
359    }
360
361    /// Decode the next code chunk to PCM.
362    pub fn decode_chunk(&mut self, codes: &RvqCodes) -> Result<Vec<f32>> {
363        let pcm = self.codec.decode_codes(codes)?;
364        self.samples_emitted += pcm.len();
365        Ok(pcm)
366    }
367
368    pub fn frames_emitted(&self) -> usize {
369        self.frames_emitted
370    }
371
372    pub fn samples_emitted(&self) -> usize {
373        self.samples_emitted
374    }
375
376    pub fn reset(&mut self) {
377        self.frames_emitted = 0;
378        self.samples_emitted = 0;
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    fn mimi_info() -> CodecInfo {
387        CodecInfo {
388            sample_rate: 24_000,
389            frame_rate: 12.5,
390            hop_length: 1920,
391            channels: 1,
392            max_quantizers: 32,
393            codebook_size: 2048,
394        }
395    }
396
397    #[test]
398    fn bitrate_monotonic_in_quantizers() {
399        let info = mimi_info();
400        let b8 = info.bitrate_bps(Some(8));
401        let b16 = info.bitrate_bps(Some(16));
402        assert!(b16 > b8);
403        // 8 codebooks × 11 bits × 12.5 Hz = 1100 bps.
404        assert!((b8 - 1100.0).abs() < 1.0, "got {b8}");
405    }
406
407    #[test]
408    fn quantizers_for_bitrate_roundtrips() {
409        let info = mimi_info();
410        for q in 1..=info.max_quantizers {
411            let bps = info.bitrate_bps(Some(q));
412            // Exactly-q bitrate should map back to q (or q within rounding).
413            let got = info.quantizers_for_bitrate(bps);
414            assert!(got == q || got == q.saturating_sub(1), "q={q} -> {got}");
415        }
416    }
417
418    #[test]
419    fn quantizers_for_bitrate_clamps() {
420        let info = mimi_info();
421        assert_eq!(info.quantizers_for_bitrate(0.0), 1);
422        assert_eq!(info.quantizers_for_bitrate(1.0), 1);
423        assert_eq!(info.quantizers_for_bitrate(1e9), info.max_quantizers);
424    }
425
426    #[test]
427    fn compress_stats_ratio_and_bitrate() {
428        let s = CompressStats {
429            compressed_bytes: 1_000,
430            encode_ms: 5.0,
431            decode_ms: 3.0,
432        };
433        // 8000 raw bytes / 1000 compressed = 8x.
434        assert!((s.compression_ratio(8_000) - 8.0).abs() < 1e-9);
435        // 1000 bytes over 1 second = 8000 bits/sec.
436        assert!((s.bitrate_bps(1.0) - 8_000.0).abs() < 1e-6);
437        // Degenerate inputs don't panic.
438        let z = CompressStats {
439            compressed_bytes: 0,
440            encode_ms: 0.0,
441            decode_ms: 0.0,
442        };
443        assert_eq!(z.compression_ratio(1000), 0.0);
444        assert_eq!(s.bitrate_bps(0.0), 0.0);
445    }
446
447    #[test]
448    fn rvq_codes_transpose_roundtrip() {
449        let codes = RvqCodes::new(vec![vec![1, 2, 3], vec![4, 5, 6], vec![7, 8, 9]], 3);
450        let qm = codes.to_quantizer_major();
451        assert_eq!(qm, vec![vec![1, 4, 7], vec![2, 5, 8], vec![3, 6, 9]]);
452        assert_eq!(RvqCodes::from_quantizer_major(&qm), codes);
453    }
454}