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 #[default]
17 Auto,
18 Native,
20 Bellard,
22 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 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 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
380impl 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}