Skip to main content

rlx_orpheus/
runner.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3
4//! High-level Orpheus TTS runner — Llama GGUF backbone + SNAC decoder.
5
6use std::path::Path;
7
8use anyhow::{Context, Result};
9
10use crate::decoder::{SAMPLE_RATE, SAMPLES_PER_FRAME, SnacBackend, decoder_weights_path};
11use crate::tokens::{build_prompt_ids, build_voice_clone_prompt_ids, pack_orpheus_codes};
12
13#[cfg(feature = "llama")]
14use crate::backbone::{BackboneLoadOptions, BackboneModel, DEFAULT_N_CTX};
15#[cfg(feature = "llama")]
16use crate::device::OrpheusRuntimeDevice;
17#[cfg(feature = "llama")]
18use rlx_runtime::Device;
19
20/// LM + decoder hyper-parameters (Orpheus defaults from upstream).
21#[derive(Debug, Clone)]
22pub struct GenerationConfig {
23    pub max_new_tokens: u32,
24    pub temperature: f32,
25    pub top_p: f32,
26    pub top_k: usize,
27    pub repetition_penalty: f32,
28    pub seed: u64,
29    pub greedy: bool,
30}
31
32impl Default for GenerationConfig {
33    fn default() -> Self {
34        Self {
35            max_new_tokens: 1200,
36            temperature: 0.6,
37            top_p: 0.8,
38            top_k: 0,
39            repetition_penalty: 1.3,
40            seed: 42,
41            greedy: false,
42        }
43    }
44}
45
46/// Synthesized mono PCM at [`SAMPLE_RATE`].
47#[derive(Debug, Clone)]
48pub struct SynthesisResult {
49    pub samples: Vec<f32>,
50    pub sample_rate: u32,
51    pub code_count: usize,
52    pub codes: Vec<i32>,
53}
54
55/// Orpheus TTS session: GGUF backbone + SNAC decoder.
56pub struct OrpheusTts {
57    #[cfg(feature = "llama")]
58    pub backbone: BackboneModel,
59    pub snac: SnacBackend,
60    pub config: GenerationConfig,
61}
62
63impl OrpheusTts {
64    #[cfg(feature = "llama")]
65    pub fn load(backbone_path: &Path, snac_path: &Path) -> Result<Self> {
66        Self::load_on(backbone_path, snac_path, Device::Cpu)
67    }
68
69    #[cfg(feature = "llama")]
70    pub fn load_on(backbone_path: &Path, snac_path: &Path, device: Device) -> Result<Self> {
71        Self::load_on_with(
72            backbone_path,
73            snac_path,
74            OrpheusRuntimeDevice {
75                lm: device,
76                snac_coreml: false,
77            },
78            BackboneLoadOptions::for_device(device),
79        )
80    }
81
82    #[cfg(feature = "llama")]
83    pub fn load_on_with(
84        backbone_path: &Path,
85        snac_path: &Path,
86        runtime: OrpheusRuntimeDevice,
87        backbone_opts: BackboneLoadOptions,
88    ) -> Result<Self> {
89        let backbone =
90            BackboneModel::load_on_with(backbone_path, DEFAULT_N_CTX, runtime.lm, backbone_opts)
91                .context("load Orpheus GGUF backbone")?;
92        let snac = SnacBackend::open(snac_path, runtime.snac_load_options())
93            .context("load SNAC decoder")?;
94        Ok(Self {
95            backbone,
96            snac,
97            config: GenerationConfig::default(),
98        })
99    }
100
101    #[cfg(feature = "llama")]
102    pub fn load_on_with_device(
103        backbone_path: &Path,
104        snac_path: &Path,
105        device: Device,
106        backbone_opts: BackboneLoadOptions,
107    ) -> Result<Self> {
108        Self::load_on_with(
109            backbone_path,
110            snac_path,
111            OrpheusRuntimeDevice {
112                lm: device,
113                snac_coreml: false,
114            },
115            backbone_opts,
116        )
117    }
118
119    #[cfg(feature = "llama")]
120    pub fn load_with_env_decoder(backbone_path: &Path) -> Result<Self> {
121        let snac_path = decoder_weights_path()?;
122        Self::load(backbone_path, &snac_path)
123    }
124
125    #[cfg(feature = "llama")]
126    pub fn load_with_env_decoder_on(backbone_path: &Path, device: Device) -> Result<Self> {
127        let snac_path = decoder_weights_path()?;
128        Self::load_on(backbone_path, &snac_path, device)
129    }
130
131    /// Synthesize speech for `text` with an optional built-in voice prefix.
132    #[cfg(feature = "llama")]
133    pub fn synthesize(&self, text: &str, voice: Option<&str>) -> Result<SynthesisResult> {
134        let body = match voice {
135            Some(v) => format!("{v}: {text}"),
136            None => text.to_string(),
137        };
138        let prompt_ids = build_prompt_ids(self.backbone.weights_path(), &body)?;
139        self.synthesize_from_prompt_ids(&prompt_ids)
140    }
141
142    /// Zero-shot voice clone using a **pretrained** Orpheus checkpoint.
143    ///
144    /// `ref_text` must match what was spoken in the reference clip (use Whisper
145    /// or a manual transcript). `ref_audio_token_ids` come from SNAC encoding —
146    /// see [`crate::tokens::VoiceCloneReference`] and
147    /// `scripts/orpheus_encode_reference.py`.
148    #[cfg(feature = "llama")]
149    pub fn synthesize_voice_clone(
150        &self,
151        ref_text: &str,
152        ref_audio_token_ids: &[u32],
153        target_text: &str,
154    ) -> Result<SynthesisResult> {
155        let prompt_ids = build_voice_clone_prompt_ids(
156            self.backbone.weights_path(),
157            ref_text,
158            ref_audio_token_ids,
159            target_text,
160        )?;
161        self.synthesize_from_prompt_ids(&prompt_ids)
162    }
163
164    /// Stream PCM chunks (~2048 samples / ~85 ms) as SNAC codes arrive (after 4 frames buffered).
165    #[cfg(feature = "llama")]
166    pub fn synthesize_stream(
167        &self,
168        text: &str,
169        voice: Option<&str>,
170        mut on_pcm: impl FnMut(&[f32]) -> Result<()>,
171    ) -> Result<SynthesisResult> {
172        let body = match voice {
173            Some(v) => format!("{v}: {text}"),
174            None => text.to_string(),
175        };
176        let prompt_ids = build_prompt_ids(self.backbone.weights_path(), &body)?;
177        let mut codes = Vec::new();
178        let mut pcm = Vec::new();
179
180        self.backbone
181            .generate_codes_from_prompt_streaming(&prompt_ids, &self.config, |code| {
182                codes.push(code);
183                if codes.len() >= 28 && codes.len() % 7 == 0 {
184                    let chunk = decode_stream_chunk(&self.snac, &codes[codes.len() - 28..])?;
185                    on_pcm(&chunk)?;
186                    pcm.extend_from_slice(&chunk);
187                }
188                Ok(())
189            })?;
190
191        if codes.len() >= 7 {
192            let samples = decode_orpheus_codes(&self.snac, &codes)?;
193            if samples.len() > pcm.len() {
194                let tail = &samples[pcm.len()..];
195                on_pcm(tail)?;
196                pcm.extend_from_slice(tail);
197            }
198        }
199
200        Ok(SynthesisResult {
201            samples: pcm,
202            sample_rate: SAMPLE_RATE,
203            code_count: codes.len(),
204            codes,
205        })
206    }
207
208    /// Synthesize from pre-built prompt token ids (incl. Orpheus control tokens).
209    #[cfg(feature = "llama")]
210    pub fn synthesize_from_prompt_ids(&self, prompt_ids: &[u32]) -> Result<SynthesisResult> {
211        let codes = self
212            .backbone
213            .generate_codes_from_prompt(prompt_ids, &self.config)
214            .context("LM code generation")?;
215
216        let samples = self.decode_all_codes(&codes)?;
217        Ok(SynthesisResult {
218            samples,
219            sample_rate: SAMPLE_RATE,
220            code_count: codes.len(),
221            codes,
222        })
223    }
224
225    /// Decode SNAC codes to PCM. Uses Orpheus streaming overlap when enough frames exist.
226    pub fn decode_all_codes(&self, codes: &[i32]) -> Result<Vec<f32>> {
227        decode_orpheus_codes(&self.snac, codes)
228    }
229
230    pub fn decode_codes_to_pcm(&self, codes: &[i32]) -> Result<Vec<f32>> {
231        self.decode_all_codes(codes)
232    }
233}
234
235const WINDOW_FRAMES: usize = 4;
236const WINDOW_CODES: usize = WINDOW_FRAMES * 7;
237
238/// One streaming chunk: center 2048 samples from the latest 4-frame SNAC window.
239pub fn decode_stream_chunk(snac: &SnacBackend, recent_28_codes: &[i32]) -> Result<Vec<f32>> {
240    anyhow::ensure!(
241        recent_28_codes.len() == WINDOW_CODES,
242        "stream chunk expects {WINDOW_CODES} codes, got {}",
243        recent_28_codes.len()
244    );
245    let audio = snac.decode_orpheus_frames(recent_28_codes)?;
246    let mut chunk = Vec::new();
247    append_center_pcm(&audio, &mut chunk);
248    Ok(chunk)
249}
250
251/// Decode interleaved Orpheus SNAC code indices to mono PCM (no LM).
252///
253/// Returns the **full** SNAC waveform for all frames (batch / WAV export).
254/// Streaming uses [`decode_stream_chunk`] (center 2048 samples per 4-frame window);
255/// [`OrpheusTts::synthesize_stream`] appends the remaining tail from this function.
256pub fn decode_orpheus_codes(snac: &SnacBackend, codes: &[i32]) -> Result<Vec<f32>> {
257    if codes.is_empty() {
258        return Ok(Vec::new());
259    }
260
261    let num_frames = codes.len() / 7;
262    if num_frames == 0 {
263        return Ok(Vec::new());
264    }
265
266    let aligned = &codes[..num_frames * 7];
267    let (c0, c1, c2) = pack_orpheus_codes(aligned).ok_or_else(|| {
268        anyhow::anyhow!(
269            "invalid code layout ({} codes, first={:?})",
270            aligned.len(),
271            &aligned[..aligned.len().min(8)]
272        )
273    })?;
274    snac.decode_codes(&c0, &c1, &c2)
275}
276
277/// Sliding-window starts for streaming (matches Python `tokens_decoder`).
278pub fn streaming_window_starts(num_codes: usize) -> Vec<usize> {
279    if num_codes < WINDOW_CODES {
280        return Vec::new();
281    }
282    (0..=num_codes - WINDOW_CODES).step_by(7).collect()
283}
284
285fn append_center_pcm(audio: &[f32], pcm: &mut Vec<f32>) {
286    let take_start = SAMPLES_PER_FRAME.min(audio.len());
287    let take_end = (SAMPLES_PER_FRAME * 2).min(audio.len());
288    if take_end > take_start {
289        pcm.extend_from_slice(&audio[take_start..take_end]);
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn streaming_window_starts_match_python() {
299        // 5 frames (35 codes): emit at counts 28 and 35 → token starts 0, 7.
300        assert_eq!(streaming_window_starts(35), vec![0, 7]);
301        // 8 frames (56 codes): emit at 28,35,42,49,56 → starts 0,7,14,21,28.
302        assert_eq!(streaming_window_starts(56), vec![0, 7, 14, 21, 28]);
303    }
304
305    #[test]
306    fn batch_decode_longer_than_streaming_centers() {
307        // 12 frames: 9 streaming chunks × 2048 + 6144 tail = full decode.
308        let streaming_samples = streaming_window_starts(84).len() * SAMPLES_PER_FRAME;
309        let full_samples = 12 * SAMPLES_PER_FRAME;
310        assert_eq!(streaming_samples + 3 * SAMPLES_PER_FRAME, full_samples);
311    }
312}