Skip to main content

rlx_tsac/
codec.rs

1use crate::device::{resolve_codec_device, resolve_rlx_device};
2use crate::download::resolve_tsac_bin;
3use crate::platform::tsac_binary_supported;
4use anyhow::{Context, Result, bail};
5use rlx_runtime::Device;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8use std::time::Instant;
9
10#[cfg(feature = "native-codec")]
11use crate::native::NativeCodec;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum TsacBackendKind {
15    /// Prefer the correct codec (descript weights) when available, else native.
16    #[default]
17    Auto,
18    /// Faithful tsac-ng C codec (decodes existing tsac-ng files bit-exact).
19    Native,
20    /// Original Bellard binary (Linux x86_64 only).
21    Bellard,
22    /// Correct TSAC = Descript-DAC-44kHz on RLX backends (recommended; the q8
23    /// weights are an un-dequantizable libnc BF8 format, so this uses the real
24    /// descript weights, which Bellard's tsac itself uses).
25    Correct,
26}
27
28#[derive(Debug, Clone)]
29pub struct TsacOptions {
30    pub device: Device,
31    pub backend: TsacBackendKind,
32    pub quality: Option<u8>,
33    pub fast: bool,
34    pub separate_stereo: bool,
35    pub channels: Option<u8>,
36    pub verbose: bool,
37}
38
39impl Default for TsacOptions {
40    fn default() -> Self {
41        Self {
42            device: Device::Cpu,
43            backend: TsacBackendKind::Auto,
44            quality: None,
45            fast: false,
46            separate_stereo: false,
47            channels: None,
48            verbose: false,
49        }
50    }
51}
52
53#[derive(Debug, Clone)]
54pub struct EncodeStats {
55    pub encode_ms: f64,
56    pub output_bytes: u64,
57}
58
59#[derive(Debug, Clone)]
60pub struct RoundtripStats {
61    pub encode_ms: f64,
62    pub decode_ms: f64,
63    pub tsac_bytes: u64,
64}
65
66enum CodecEngine {
67    #[cfg(feature = "native-codec")]
68    Native(NativeCodec),
69    External(ExternalCodec),
70    Correct(crate::correct::CorrectCodec),
71}
72
73struct ExternalCodec {
74    install_dir: PathBuf,
75    options: TsacOptions,
76    device: Device,
77}
78
79pub struct TsacCodec {
80    install_dir: PathBuf,
81    options: TsacOptions,
82    device: Device,
83    engine: CodecEngine,
84}
85
86impl TsacCodec {
87    pub fn open(install_dir: impl AsRef<Path>) -> Result<Self> {
88        Self::open_with_options(install_dir, TsacOptions::default())
89    }
90
91    pub fn open_native(install_dir: impl AsRef<Path>, options: TsacOptions) -> Result<Self> {
92        let mut opts = options;
93        opts.backend = TsacBackendKind::Native;
94        Self::open_with_options(install_dir, opts)
95    }
96
97    pub fn open_bellard(install_dir: impl AsRef<Path>, options: TsacOptions) -> Result<Self> {
98        let mut opts = options;
99        opts.backend = TsacBackendKind::Bellard;
100        Self::open_with_options(install_dir, opts)
101    }
102
103    pub fn open_with_options(install_dir: impl AsRef<Path>, options: TsacOptions) -> Result<Self> {
104        let install_dir = install_dir.as_ref().to_path_buf();
105        // Keep the originally-requested device in the options so the native engine
106        // can route decode to the RLX graph backend (metal/mlx/wgpu/…); the C
107        // codec resolves its own fallback internally. `device` is the resolved
108        // device reported by the codec.
109        // The `Correct` codec runs the DAC purely on an rlx backend (cpu/metal/
110        // mlx/wgpu), so its device is the requested rlx device — not the
111        // libnc-oriented cuda/vulkan resolution the native/Bellard engines use.
112        let device = match options.backend {
113            TsacBackendKind::Correct => resolve_rlx_device(options.device),
114            _ => resolve_codec_device(options.device),
115        };
116        let engine = open_engine(&install_dir, &options, options.device)?;
117        Ok(Self {
118            install_dir,
119            options,
120            device,
121            engine,
122        })
123    }
124
125    pub fn with_options(mut self, options: TsacOptions) -> Self {
126        self.options = options.clone();
127        self.device = match options.backend {
128            TsacBackendKind::Correct => resolve_rlx_device(options.device),
129            _ => resolve_codec_device(options.device),
130        };
131        if let Ok(engine) = open_engine(&self.install_dir, &self.options, self.options.device) {
132            self.engine = engine;
133        }
134        self
135    }
136
137    pub fn install_dir(&self) -> &Path {
138        &self.install_dir
139    }
140
141    pub fn options(&self) -> &TsacOptions {
142        &self.options
143    }
144
145    pub fn device(&self) -> Device {
146        self.device
147    }
148
149    pub fn encode(
150        &self,
151        in_audio: impl AsRef<Path>,
152        out_tsac: impl AsRef<Path>,
153    ) -> Result<EncodeStats> {
154        let in_audio = in_audio.as_ref();
155        let out_tsac = out_tsac.as_ref();
156        ensure_parent(out_tsac)?;
157        let t0 = Instant::now();
158        match &self.engine {
159            #[cfg(feature = "native-codec")]
160            CodecEngine::Native(native) => {
161                native.encode_file(in_audio, out_tsac, &self.options)?;
162            }
163            CodecEngine::Correct(correct) => {
164                correct.encode_file(in_audio, out_tsac)?;
165            }
166            CodecEngine::External(ext) => {
167                ext.run_tsac(&["c", in_audio.to_str().unwrap(), out_tsac.to_str().unwrap()])?;
168            }
169        }
170        let encode_ms = t0.elapsed().as_secs_f64() * 1000.0;
171        let output_bytes = std::fs::metadata(out_tsac)
172            .with_context(|| format!("stat {}", out_tsac.display()))?
173            .len();
174        Ok(EncodeStats {
175            encode_ms,
176            output_bytes,
177        })
178    }
179
180    pub fn decode(&self, in_tsac: impl AsRef<Path>, out_wav: impl AsRef<Path>) -> Result<()> {
181        let in_tsac = in_tsac.as_ref();
182        let out_wav = out_wav.as_ref();
183        ensure_parent(out_wav)?;
184        match &self.engine {
185            #[cfg(feature = "native-codec")]
186            CodecEngine::Native(native) => native.decode_file(in_tsac, out_wav)?,
187            CodecEngine::Correct(correct) => correct.decode_file(in_tsac, out_wav)?,
188            CodecEngine::External(ext) => {
189                ext.run_tsac(&["d", in_tsac.to_str().unwrap(), out_wav.to_str().unwrap()])?;
190            }
191        }
192        Ok(())
193    }
194
195    pub fn roundtrip(
196        &self,
197        in_audio: impl AsRef<Path>,
198        out_wav: impl AsRef<Path>,
199    ) -> Result<RoundtripStats> {
200        let in_audio = in_audio.as_ref();
201        let out_wav = out_wav.as_ref();
202        let tmp = std::env::temp_dir().join(format!(
203            "rlx-tsac-{}-{}.tsac",
204            std::process::id(),
205            in_audio
206                .file_stem()
207                .and_then(|s| s.to_str())
208                .unwrap_or("audio")
209        ));
210        let encode = self.encode(in_audio, &tmp)?;
211        let t0 = Instant::now();
212        self.decode(&tmp, out_wav)?;
213        let decode_ms = t0.elapsed().as_secs_f64() * 1000.0;
214        std::fs::remove_file(&tmp).ok();
215        Ok(RoundtripStats {
216            encode_ms: encode.encode_ms,
217            decode_ms,
218            tsac_bytes: encode.output_bytes,
219        })
220    }
221}
222
223fn open_engine(install_dir: &Path, options: &TsacOptions, device: Device) -> Result<CodecEngine> {
224    match options.backend {
225        TsacBackendKind::Correct => {
226            let correct = crate::correct::CorrectCodec::open(options.device, options.quality)?;
227            return Ok(CodecEngine::Correct(correct));
228        }
229        TsacBackendKind::Bellard => {
230            if !tsac_binary_supported() {
231                bail!("Bellard backend requires Linux x86_64");
232            }
233            if !resolve_tsac_bin(install_dir).is_file() {
234                bail!(
235                    "missing Bellard tsac binary under {}",
236                    install_dir.display()
237                );
238            }
239            return Ok(CodecEngine::External(ExternalCodec {
240                install_dir: install_dir.to_path_buf(),
241                options: options.clone(),
242                device,
243            }));
244        }
245        TsacBackendKind::Native => {
246            #[cfg(feature = "native-codec")]
247            {
248                let native = NativeCodec::open(install_dir, options)?;
249                return Ok(CodecEngine::Native(native));
250            }
251            #[cfg(not(feature = "native-codec"))]
252            {
253                bail!("native codec requires `native-codec` feature");
254            }
255        }
256        TsacBackendKind::Auto => {}
257    }
258
259    // Auto: prefer the correct codec (descript weights) when they're already
260    // present — a drop-in, correct, all-backend encode+decode path. Falls back to
261    // the faithful native codec otherwise (which can still decode tsac-ng files).
262    if crate::correct::weights_available(&crate::correct::default_dir())
263        && std::env::var("RLX_TSAC_ENGINE").ok().as_deref() != Some("native")
264    {
265        if let Ok(correct) = crate::correct::CorrectCodec::open(options.device, options.quality) {
266            return Ok(CodecEngine::Correct(correct));
267        }
268    }
269
270    if prefer_external(install_dir) {
271        return Ok(CodecEngine::External(ExternalCodec {
272            install_dir: install_dir.to_path_buf(),
273            options: options.clone(),
274            device,
275        }));
276    }
277
278    #[cfg(feature = "native-codec")]
279    {
280        let native = NativeCodec::open(install_dir, options)?;
281        if device != Device::Cpu
282            && native.device() == Device::Cpu
283            && resolve_codec_device(options.device) != device
284        {
285            eprintln!(
286                "tsac: requested {device:?} — running CPU native codec (enable backend features for GPU)"
287            );
288        }
289        Ok(CodecEngine::Native(native))
290    }
291
292    #[cfg(not(feature = "native-codec"))]
293    {
294        let _ = (install_dir, options, device);
295        bail!(
296            "no TSAC engine available — enable `native-codec` or use Linux x86_64 with Bellard binary"
297        );
298    }
299}
300
301fn prefer_external(install_dir: &Path) -> bool {
302    if std::env::var("RLX_TSAC_ENGINE").ok().as_deref() == Some("bellard") {
303        return tsac_binary_supported() && resolve_tsac_bin(install_dir).is_file();
304    }
305    #[cfg(not(feature = "native-codec"))]
306    {
307        return tsac_binary_supported() && resolve_tsac_bin(install_dir).is_file();
308    }
309    #[cfg(feature = "native-codec")]
310    {
311        false
312    }
313}
314
315impl ExternalCodec {
316    fn run_tsac(&self, subcommand_and_paths: &[&str]) -> Result<()> {
317        let install_dir = &self.install_dir;
318        let bin = resolve_tsac_bin(install_dir);
319        if !bin.is_file() {
320            bail!(
321                "TSAC binary missing at {} — run `just fetch-tsac`",
322                bin.display()
323            );
324        }
325        let mut cmd = Command::new(&bin);
326        cmd.current_dir(install_dir);
327        cmd.env("LD_LIBRARY_PATH", ld_library_path(install_dir));
328        if matches!(self.device, Device::Cuda) {
329            cmd.arg("--cuda");
330        }
331        if self.options.verbose {
332            cmd.arg("-v");
333        }
334        if let Some(q) = self.options.quality {
335            cmd.arg("-q").arg(q.to_string());
336        }
337        if self.options.fast {
338            cmd.arg("-f");
339        }
340        if self.options.separate_stereo {
341            cmd.arg("-s");
342        }
343        if let Some(ch) = self.options.channels {
344            cmd.arg("-c").arg(ch.to_string());
345        }
346        cmd.args(subcommand_and_paths);
347        let output = cmd
348            .output()
349            .with_context(|| format!("run {}", bin.display()))?;
350        if !output.status.success() {
351            let stderr = String::from_utf8_lossy(&output.stderr);
352            let stdout = String::from_utf8_lossy(&output.stdout);
353            bail!(
354                "tsac failed (status {})\nstdout:\n{stdout}\nstderr:\n{stderr}",
355                output.status
356            );
357        }
358        Ok(())
359    }
360}
361
362fn ld_library_path(install_dir: &Path) -> String {
363    let dir = install_dir.to_string_lossy();
364    match std::env::var("LD_LIBRARY_PATH") {
365        Ok(existing) if !existing.is_empty() => format!("{dir}:{existing}"),
366        _ => dir.into_owned(),
367    }
368}
369
370fn ensure_parent(path: &Path) -> Result<()> {
371    if let Some(parent) = path.parent() {
372        if !parent.as_os_str().is_empty() {
373            std::fs::create_dir_all(parent)
374                .with_context(|| format!("create dir {}", parent.display()))?;
375        }
376    }
377    Ok(())
378}
379
380/// Unified [`rlx_core::FileCodec`] view of TSAC (file-bitstream compressor).
381/// TSAC has no RVQ codes, so it implements `FileCodec` rather than the
382/// frame/quantizer `rlx_core::AudioCodec` trait used by mimi/dac.
383impl rlx_core::FileCodec for TsacCodec {
384    fn device(&self) -> Device {
385        self.device
386    }
387
388    fn sample_rate(&self) -> u32 {
389        crate::SAMPLE_RATE
390    }
391
392    fn encode_file(
393        &self,
394        in_audio: &Path,
395        out_compressed: &Path,
396    ) -> Result<rlx_core::CompressStats> {
397        let s = TsacCodec::encode(self, in_audio, out_compressed)?;
398        Ok(rlx_core::CompressStats {
399            compressed_bytes: s.output_bytes,
400            encode_ms: s.encode_ms,
401            decode_ms: 0.0,
402        })
403    }
404
405    fn decode_file(&self, in_compressed: &Path, out_wav: &Path) -> Result<()> {
406        TsacCodec::decode(self, in_compressed, out_wav)
407    }
408
409    fn roundtrip_file(&self, in_audio: &Path, out_wav: &Path) -> Result<rlx_core::CompressStats> {
410        let s = TsacCodec::roundtrip(self, in_audio, out_wav)?;
411        Ok(rlx_core::CompressStats {
412            compressed_bytes: s.tsac_bytes,
413            encode_ms: s.encode_ms,
414            decode_ms: s.decode_ms,
415        })
416    }
417}