@matjash/pixi-native-linux-x64 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1348 @@
1
+ use std::collections::{BTreeMap, HashMap, VecDeque};
2
+ use std::io::Read;
3
+ use std::process::{Child, Command, Stdio};
4
+ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
5
+ use std::sync::{Arc, Condvar, Mutex};
6
+ use std::thread;
7
+ use std::time::{Duration, Instant};
8
+
9
+ use maudio::audio::sample_rate::SampleRate;
10
+ use maudio::data_source::data_source_builder::DataSourceBuilder;
11
+ use maudio::data_source::pcm_source::PcmSource;
12
+ use maudio::data_source::sources::pcm_ring_buffer::{PcmRbRecv, PcmRbSend, PcmRingBuffer};
13
+ use maudio::data_source::{DataSource, SourceContext};
14
+ use maudio::engine::{engine_builder::EngineBuilder, Engine};
15
+ use maudio::sound::{notifier::EndNotifier, sound_builder::SoundBuilder, Sound};
16
+ use maudio::{ErrorKinds, MaResult, MaudioError};
17
+ use napi::bindgen_prelude::*;
18
+ use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
19
+ use napi_derive::napi;
20
+
21
+ const CHANNELS: usize = 2;
22
+ const SAMPLE_RATE: u32 = 48_000;
23
+ const STREAM_CHUNK_FRAMES: usize = 2048;
24
+ const STREAM_CHUNK_SAMPLES: usize = STREAM_CHUNK_FRAMES * CHANNELS;
25
+ const STREAM_START_FRAMES: u32 = (STREAM_CHUNK_FRAMES * 4) as u32;
26
+ const STREAM_BUFFER_FRAMES: u32 = SAMPLE_RATE * 2;
27
+ const CONTROL_INTERVAL: Duration = Duration::from_millis(5);
28
+ type EventNotifier = ThreadsafeFunction<(), (), (), Status, false, true, 1>;
29
+
30
+ #[napi(object)]
31
+ #[derive(Clone)]
32
+ pub struct NativeVoiceOptions {
33
+ pub owner_id: u32,
34
+ pub id: u32,
35
+ pub source: String,
36
+ pub ffmpeg_path: String,
37
+ pub offset_seconds: f64,
38
+ pub duration_seconds: Option<f64>,
39
+ pub volume: f64,
40
+ pub muted: bool,
41
+ pub loop_: bool,
42
+ pub streaming: bool,
43
+ pub playback_rate: f64,
44
+ pub input_args: Vec<String>,
45
+ pub output_args: Vec<String>,
46
+ }
47
+
48
+ #[napi(object)]
49
+ pub struct NativeCommandOptions {
50
+ pub owner_id: u32,
51
+ pub command: String,
52
+ pub id: Option<u32>,
53
+ pub value: Option<f64>,
54
+ pub bool_value: Option<bool>,
55
+ pub from: Option<f64>,
56
+ pub to: Option<f64>,
57
+ pub duration_ms: Option<f64>,
58
+ pub fade_version: Option<u32>,
59
+ }
60
+
61
+ #[napi(object)]
62
+ pub struct NativeAudioEvent {
63
+ pub owner_id: u32,
64
+ pub event: String,
65
+ pub id: Option<u32>,
66
+ pub message: Option<String>,
67
+ }
68
+
69
+ #[napi(object)]
70
+ pub struct NativeAudioDiagnostics {
71
+ pub active_voices: u32,
72
+ pub queued_ms: f64,
73
+ pub underruns: u32,
74
+ }
75
+
76
+ struct QueuedEvent {
77
+ owner_id: u32,
78
+ event: &'static str,
79
+ id: Option<u32>,
80
+ message: Option<String>,
81
+ sequence: u64,
82
+ fade_version: Option<u32>,
83
+ }
84
+
85
+ struct ActiveFade {
86
+ to: f32,
87
+ ends_at: Instant,
88
+ version: u32,
89
+ }
90
+
91
+ struct StreamingControl {
92
+ ready: AtomicBool,
93
+ ended: AtomicBool,
94
+ stopped: AtomicBool,
95
+ child: Mutex<Option<Child>>,
96
+ }
97
+
98
+ impl StreamingControl {
99
+ fn new() -> Self {
100
+ Self {
101
+ ready: AtomicBool::new(false),
102
+ ended: AtomicBool::new(false),
103
+ stopped: AtomicBool::new(false),
104
+ child: Mutex::new(None),
105
+ }
106
+ }
107
+
108
+ fn stop(&self) {
109
+ self.stopped.store(true, Ordering::Release);
110
+ if let Ok(mut child) = self.child.lock() {
111
+ if let Some(child) = child.as_mut() {
112
+ let _ = child.kill();
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ struct SharedPcmSource {
119
+ samples: Arc<Vec<f32>>,
120
+ }
121
+
122
+ impl SharedPcmSource {
123
+ fn new(samples: Arc<Vec<f32>>) -> Self {
124
+ Self { samples }
125
+ }
126
+ }
127
+
128
+ impl PcmSource<f32> for SharedPcmSource {
129
+ fn fill_pcm_frames(
130
+ &mut self,
131
+ output: &mut [f32],
132
+ context: &mut SourceContext,
133
+ ) -> MaResult<usize> {
134
+ let channels = context.data_format.channels as usize;
135
+ let frame_count = self.samples.len() / channels;
136
+ let cursor = usize::try_from(context.cursor).unwrap_or(usize::MAX);
137
+ if cursor >= frame_count {
138
+ return Ok(0);
139
+ }
140
+
141
+ let output_frames = output.len() / channels;
142
+ let frames_to_copy = output_frames.min(frame_count - cursor);
143
+ let sample_start = cursor * channels;
144
+ let samples_to_copy = frames_to_copy * channels;
145
+ output[..samples_to_copy]
146
+ .copy_from_slice(&self.samples[sample_start..sample_start + samples_to_copy]);
147
+ context.cursor += frames_to_copy as u64;
148
+ Ok(frames_to_copy)
149
+ }
150
+
151
+ fn seek_to_pcm_frame(&mut self, frame_index: u64, context: &mut SourceContext) -> MaResult<()> {
152
+ let frame_count = self.samples.len() / context.data_format.channels as usize;
153
+ if frame_index > frame_count as u64 {
154
+ return Err(MaudioError::new_ma_error(ErrorKinds::InvalidOperation(
155
+ "Audio seek is outside the cached PCM source",
156
+ )));
157
+ }
158
+ context.cursor = frame_index;
159
+ Ok(())
160
+ }
161
+
162
+ fn cursor_in_pcm_frames(&self, context: &SourceContext) -> MaResult<u64> {
163
+ Ok(context.cursor)
164
+ }
165
+
166
+ fn length_in_pcm_frames(&self, context: &SourceContext) -> MaResult<u64> {
167
+ Ok((self.samples.len() / context.data_format.channels as usize) as u64)
168
+ }
169
+ }
170
+
171
+ type StaticDataSource = DataSource<f32, SharedPcmSource>;
172
+
173
+ enum VoiceSource {
174
+ Loading,
175
+ Static(Box<StaticDataSource>),
176
+ Streaming {
177
+ receiver: Box<PcmRbRecv<f32>>,
178
+ control: Arc<StreamingControl>,
179
+ },
180
+ }
181
+
182
+ struct VoiceSnapshot {
183
+ owner_id: u32,
184
+ time_bits: AtomicU64,
185
+ volume_bits: AtomicU32,
186
+ playing: AtomicBool,
187
+ queued_frames: AtomicUsize,
188
+ stream: Option<Arc<StreamingControl>>,
189
+ }
190
+
191
+ struct Voice {
192
+ owner_id: u32,
193
+ id: u32,
194
+ sound: Option<Sound>,
195
+ end_notifier: Option<EndNotifier>,
196
+ source: VoiceSource,
197
+ offset_seconds: f64,
198
+ duration_seconds: Option<f64>,
199
+ timeline_seconds: f64,
200
+ timeline_updated_at: Instant,
201
+ volume: f32,
202
+ muted: bool,
203
+ looped: bool,
204
+ playing: bool,
205
+ playback_rate: f64,
206
+ streaming: bool,
207
+ fade: Option<ActiveFade>,
208
+ play_announced: bool,
209
+ starved: bool,
210
+ snapshot: Arc<VoiceSnapshot>,
211
+ }
212
+
213
+ impl Drop for Voice {
214
+ fn drop(&mut self) {
215
+ if let Some(sound) = self.sound.take() {
216
+ let _ = sound.stop_sound();
217
+ drop(sound);
218
+ }
219
+ if let VoiceSource::Streaming { control, .. } = &self.source {
220
+ control.stop();
221
+ }
222
+ }
223
+ }
224
+
225
+ impl Voice {
226
+ fn update_static_timeline(&mut self, now: Instant) {
227
+ if self.streaming || !self.playing {
228
+ self.timeline_updated_at = now;
229
+ return;
230
+ }
231
+ self.timeline_seconds +=
232
+ now.duration_since(self.timeline_updated_at).as_secs_f64() * self.playback_rate;
233
+ if self.looped {
234
+ if let Some(duration_seconds) = self.duration_seconds {
235
+ self.timeline_seconds %= duration_seconds;
236
+ }
237
+ }
238
+ self.timeline_updated_at = now;
239
+ }
240
+ }
241
+
242
+ enum EngineCommand {
243
+ AddVoice(Voice),
244
+ AttachStatic { id: u32, samples: Arc<Vec<f32>> },
245
+ Control(NativeCommandOptions),
246
+ RemoveOwner(u32),
247
+ StopAll,
248
+ SetGlobalVolume,
249
+ }
250
+
251
+ struct EngineRuntime {
252
+ engine: Engine,
253
+ voices: BTreeMap<u32, Voice>,
254
+ }
255
+
256
+ struct SharedState {
257
+ sample_rate: u32,
258
+ commands: Mutex<VecDeque<EngineCommand>>,
259
+ command_wakeup: Condvar,
260
+ snapshots: Mutex<HashMap<u32, Arc<VoiceSnapshot>>>,
261
+ cache: Mutex<HashMap<String, Arc<Vec<f32>>>>,
262
+ events: Mutex<VecDeque<QueuedEvent>>,
263
+ event_notifier: Option<Arc<EventNotifier>>,
264
+ event_sequence: AtomicU64,
265
+ global_volume_bits: AtomicU32,
266
+ global_muted: AtomicBool,
267
+ fade_versions: Mutex<HashMap<u32, u32>>,
268
+ underruns: AtomicU32,
269
+ shutdown: AtomicBool,
270
+ }
271
+
272
+ impl SharedState {
273
+ fn push_command(&self, command: EngineCommand) {
274
+ if let Ok(mut commands) = self.commands.lock() {
275
+ commands.push_back(command);
276
+ self.command_wakeup.notify_one();
277
+ }
278
+ }
279
+
280
+ fn queue_event(
281
+ &self,
282
+ owner_id: u32,
283
+ event: &'static str,
284
+ id: Option<u32>,
285
+ message: Option<String>,
286
+ fade_version: Option<u32>,
287
+ ) {
288
+ if let Ok(mut events) = self.events.lock() {
289
+ events.push_back(QueuedEvent {
290
+ owner_id,
291
+ event,
292
+ id,
293
+ message,
294
+ sequence: self.event_sequence.fetch_add(1, Ordering::Relaxed),
295
+ fade_version,
296
+ });
297
+ }
298
+ if let Some(notifier) = &self.event_notifier {
299
+ notifier.call((), ThreadsafeFunctionCallMode::NonBlocking);
300
+ }
301
+ }
302
+
303
+ fn stop_snapshot(snapshot: &VoiceSnapshot) {
304
+ if let Some(stream) = &snapshot.stream {
305
+ stream.stop();
306
+ }
307
+ snapshot.playing.store(false, Ordering::Release);
308
+ }
309
+ }
310
+
311
+ #[napi]
312
+ pub struct NativeAudioEngine {
313
+ state: Arc<SharedState>,
314
+ control_thread: Mutex<Option<thread::JoinHandle<()>>>,
315
+ }
316
+
317
+ #[napi]
318
+ impl NativeAudioEngine {
319
+ #[napi(constructor)]
320
+ pub fn new(event_notifier: EventNotifier) -> Result<Self> {
321
+ let mut builder = EngineBuilder::new();
322
+ builder
323
+ .set_channels(CHANNELS as u32)
324
+ .set_sample_rate(SampleRate::Sr48000);
325
+ let engine = builder.build().map_err(|error| {
326
+ Error::from_reason(format!("Cannot initialize miniaudio output: {error}"))
327
+ })?;
328
+ let state = Arc::new(SharedState {
329
+ sample_rate: SAMPLE_RATE,
330
+ commands: Mutex::new(VecDeque::new()),
331
+ command_wakeup: Condvar::new(),
332
+ snapshots: Mutex::new(HashMap::new()),
333
+ cache: Mutex::new(HashMap::new()),
334
+ events: Mutex::new(VecDeque::new()),
335
+ event_notifier: Some(Arc::new(event_notifier)),
336
+ event_sequence: AtomicU64::new(0),
337
+ global_volume_bits: AtomicU32::new(1.0_f32.to_bits()),
338
+ global_muted: AtomicBool::new(false),
339
+ fade_versions: Mutex::new(HashMap::new()),
340
+ underruns: AtomicU32::new(0),
341
+ shutdown: AtomicBool::new(false),
342
+ });
343
+ let thread_state = Arc::clone(&state);
344
+ let control_thread = thread::Builder::new()
345
+ .name("pixi-native-audio".to_string())
346
+ .spawn(move || run_engine(engine, thread_state))
347
+ .map_err(|error| Error::from_reason(format!("Cannot start audio control: {error}")))?;
348
+ Ok(Self {
349
+ state,
350
+ control_thread: Mutex::new(Some(control_thread)),
351
+ })
352
+ }
353
+
354
+ #[napi]
355
+ pub fn create_voice(&self, options: NativeVoiceOptions) -> Result<()> {
356
+ validate_voice_options(&options)?;
357
+ let id = options.id;
358
+ let owner_id = options.owner_id;
359
+ let stream_control = options.streaming.then(|| Arc::new(StreamingControl::new()));
360
+ let snapshot = Arc::new(VoiceSnapshot {
361
+ owner_id,
362
+ time_bits: AtomicU64::new(options.offset_seconds.to_bits()),
363
+ volume_bits: AtomicU32::new((options.volume as f32).to_bits()),
364
+ playing: AtomicBool::new(true),
365
+ queued_frames: AtomicUsize::new(0),
366
+ stream: stream_control.clone(),
367
+ });
368
+ let mut stream_sender = None;
369
+ let source = if let Some(control) = &stream_control {
370
+ let (mut sender, mut receiver) =
371
+ PcmRingBuffer::new_f32(STREAM_BUFFER_FRAMES, CHANNELS as u32).map_err(|error| {
372
+ Error::from_reason(format!("Cannot create audio ring buffer: {error}"))
373
+ })?;
374
+ sender.set_sample_rate(SampleRate::Sr48000);
375
+ receiver.set_sample_rate(SampleRate::Sr48000);
376
+ stream_sender = Some(sender);
377
+ VoiceSource::Streaming {
378
+ receiver: Box::new(receiver),
379
+ control: Arc::clone(control),
380
+ }
381
+ } else {
382
+ VoiceSource::Loading
383
+ };
384
+ let voice = Voice {
385
+ owner_id,
386
+ id,
387
+ sound: None,
388
+ end_notifier: None,
389
+ source,
390
+ offset_seconds: options.offset_seconds,
391
+ duration_seconds: options.duration_seconds,
392
+ timeline_seconds: 0.0,
393
+ timeline_updated_at: Instant::now(),
394
+ volume: options.volume as f32,
395
+ muted: options.muted,
396
+ looped: options.loop_ && !options.streaming,
397
+ playing: true,
398
+ playback_rate: options.playback_rate,
399
+ streaming: options.streaming,
400
+ fade: None,
401
+ play_announced: false,
402
+ starved: false,
403
+ snapshot: Arc::clone(&snapshot),
404
+ };
405
+ self.state
406
+ .snapshots
407
+ .lock()
408
+ .map_err(|_| Error::from_reason("Audio engine state is poisoned"))?
409
+ .insert(id, snapshot);
410
+ self.state.push_command(EngineCommand::AddVoice(voice));
411
+
412
+ if let (Some(sender), Some(control)) = (stream_sender, stream_control) {
413
+ start_streaming_decode(Arc::clone(&self.state), options, sender, control);
414
+ } else {
415
+ start_static_decode(Arc::clone(&self.state), options, false, None);
416
+ }
417
+ Ok(())
418
+ }
419
+
420
+ #[napi]
421
+ pub fn preload(
422
+ &self,
423
+ owner_id: u32,
424
+ request_id: u32,
425
+ source: String,
426
+ ffmpeg_path: String,
427
+ offset_seconds: f64,
428
+ duration_seconds: Option<f64>,
429
+ ) -> Result<()> {
430
+ if offset_seconds < 0.0 || duration_seconds.is_some_and(|value| value <= 0.0) {
431
+ return Err(Error::from_reason("Invalid audio preload range"));
432
+ }
433
+ start_static_decode(
434
+ Arc::clone(&self.state),
435
+ NativeVoiceOptions {
436
+ owner_id,
437
+ id: 0,
438
+ source,
439
+ ffmpeg_path,
440
+ offset_seconds,
441
+ duration_seconds,
442
+ volume: 1.0,
443
+ muted: false,
444
+ loop_: false,
445
+ streaming: false,
446
+ playback_rate: 1.0,
447
+ input_args: Vec::new(),
448
+ output_args: Vec::new(),
449
+ },
450
+ true,
451
+ Some(request_id),
452
+ );
453
+ Ok(())
454
+ }
455
+
456
+ #[napi]
457
+ pub fn command(&self, options: NativeCommandOptions) -> Result<()> {
458
+ match options.command.as_str() {
459
+ "play" | "pause" | "mute" | "loop" | "stop" => {}
460
+ "volume" => {
461
+ required_unit_value(options.value, "volume")?;
462
+ }
463
+ "seek" => {
464
+ let seconds = options.value.unwrap_or(0.0);
465
+ if !seconds.is_finite() || seconds < 0.0 {
466
+ return Err(Error::from_reason(
467
+ "Audio seek must be non-negative and finite",
468
+ ));
469
+ }
470
+ }
471
+ "fade" => {
472
+ required_unit_value(options.from, "fade start")?;
473
+ required_unit_value(options.to, "fade target")?;
474
+ let duration_ms = options.duration_ms.unwrap_or(0.0);
475
+ if !duration_ms.is_finite() || duration_ms < 0.0 {
476
+ return Err(Error::from_reason(
477
+ "Fade duration must be non-negative and finite",
478
+ ));
479
+ }
480
+ if let Some(id) = options.id {
481
+ if let Ok(mut versions) = self.state.fade_versions.lock() {
482
+ versions.insert(id, options.fade_version.unwrap_or(0));
483
+ }
484
+ }
485
+ }
486
+ command => {
487
+ return Err(Error::from_reason(format!(
488
+ "Unknown audio command: {command}"
489
+ )));
490
+ }
491
+ }
492
+
493
+ if options.command == "stop" {
494
+ self.remove_snapshots(options.owner_id, options.id);
495
+ }
496
+ self.state.push_command(EngineCommand::Control(options));
497
+ Ok(())
498
+ }
499
+
500
+ #[napi]
501
+ pub fn unload_owner(&self, owner_id: u32) {
502
+ self.remove_snapshots(owner_id, None);
503
+ self.state
504
+ .push_command(EngineCommand::RemoveOwner(owner_id));
505
+ }
506
+
507
+ #[napi]
508
+ pub fn current_time(&self, id: u32) -> Option<f64> {
509
+ let snapshot = self.state.snapshots.lock().ok()?.get(&id).cloned()?;
510
+ Some(f64::from_bits(snapshot.time_bits.load(Ordering::Acquire)))
511
+ }
512
+
513
+ #[napi]
514
+ pub fn current_volume(&self, id: u32) -> Option<f64> {
515
+ let snapshot = self.state.snapshots.lock().ok()?.get(&id).cloned()?;
516
+ Some(f32::from_bits(snapshot.volume_bits.load(Ordering::Acquire)) as f64)
517
+ }
518
+
519
+ #[napi]
520
+ pub fn set_global_volume(&self, value: f64) -> Result<()> {
521
+ let value = required_unit_value(Some(value), "global volume")? as f32;
522
+ self.state
523
+ .global_volume_bits
524
+ .store(value.to_bits(), Ordering::Release);
525
+ self.state.push_command(EngineCommand::SetGlobalVolume);
526
+ Ok(())
527
+ }
528
+
529
+ #[napi]
530
+ pub fn set_global_muted(&self, value: bool) {
531
+ self.state.global_muted.store(value, Ordering::Release);
532
+ self.state.push_command(EngineCommand::SetGlobalVolume);
533
+ }
534
+
535
+ #[napi]
536
+ pub fn drain_events(&self) -> Vec<NativeAudioEvent> {
537
+ let mut queued = self
538
+ .state
539
+ .events
540
+ .lock()
541
+ .map(|mut events| events.drain(..).collect::<Vec<_>>())
542
+ .unwrap_or_default();
543
+ queued.sort_by_key(|event| event.sequence);
544
+ let fade_versions = self.state.fade_versions.lock().ok();
545
+ queued
546
+ .into_iter()
547
+ .filter(|event| {
548
+ let Some(version) = event.fade_version else {
549
+ return true;
550
+ };
551
+ event.id.is_some_and(|id| {
552
+ fade_versions
553
+ .as_ref()
554
+ .and_then(|versions| versions.get(&id))
555
+ .is_some_and(|current| *current == version)
556
+ })
557
+ })
558
+ .map(|event| NativeAudioEvent {
559
+ owner_id: event.owner_id,
560
+ event: event.event.to_string(),
561
+ id: event.id,
562
+ message: event.message,
563
+ })
564
+ .collect()
565
+ }
566
+
567
+ #[napi]
568
+ pub fn diagnostics(&self) -> NativeAudioDiagnostics {
569
+ let (active_voices, queued_frames) = self
570
+ .state
571
+ .snapshots
572
+ .lock()
573
+ .map(|snapshots| {
574
+ let active = snapshots
575
+ .values()
576
+ .filter(|snapshot| snapshot.playing.load(Ordering::Acquire))
577
+ .count() as u32;
578
+ let queued = snapshots
579
+ .values()
580
+ .map(|snapshot| snapshot.queued_frames.load(Ordering::Acquire))
581
+ .sum::<usize>();
582
+ (active, queued)
583
+ })
584
+ .unwrap_or((0, 0));
585
+ NativeAudioDiagnostics {
586
+ active_voices,
587
+ queued_ms: queued_frames as f64 * 1000.0 / self.state.sample_rate as f64,
588
+ underruns: self.state.underruns.load(Ordering::Relaxed),
589
+ }
590
+ }
591
+
592
+ #[napi]
593
+ pub fn stop_all(&self) {
594
+ if let Ok(mut snapshots) = self.state.snapshots.lock() {
595
+ for snapshot in snapshots.values() {
596
+ SharedState::stop_snapshot(snapshot);
597
+ }
598
+ snapshots.clear();
599
+ }
600
+ self.state.push_command(EngineCommand::StopAll);
601
+ }
602
+
603
+ #[napi]
604
+ pub fn shutdown(&self) {
605
+ self.state.shutdown.store(true, Ordering::Release);
606
+ self.stop_all();
607
+ self.state.command_wakeup.notify_all();
608
+ if let Ok(mut control_thread) = self.control_thread.lock() {
609
+ if let Some(control_thread) = control_thread.take() {
610
+ let _ = control_thread.join();
611
+ }
612
+ }
613
+ if let Ok(mut cache) = self.state.cache.lock() {
614
+ cache.clear();
615
+ }
616
+ }
617
+
618
+ fn remove_snapshots(&self, owner_id: u32, id: Option<u32>) {
619
+ if let Ok(mut snapshots) = self.state.snapshots.lock() {
620
+ let ids: Vec<u32> = snapshots
621
+ .iter()
622
+ .filter(|(voice_id, snapshot)| {
623
+ snapshot.owner_id == owner_id && id.is_none_or(|id| id == **voice_id)
624
+ })
625
+ .map(|(id, _)| *id)
626
+ .collect();
627
+ for id in ids {
628
+ if let Some(snapshot) = snapshots.remove(&id) {
629
+ SharedState::stop_snapshot(&snapshot);
630
+ }
631
+ }
632
+ }
633
+ }
634
+ }
635
+
636
+ impl Drop for NativeAudioEngine {
637
+ fn drop(&mut self) {
638
+ self.state.shutdown.store(true, Ordering::Release);
639
+ if let Ok(snapshots) = self.state.snapshots.lock() {
640
+ for snapshot in snapshots.values() {
641
+ SharedState::stop_snapshot(snapshot);
642
+ }
643
+ }
644
+ self.state.command_wakeup.notify_all();
645
+ if let Ok(mut control_thread) = self.control_thread.lock() {
646
+ if let Some(control_thread) = control_thread.take() {
647
+ let _ = control_thread.join();
648
+ }
649
+ }
650
+ }
651
+ }
652
+
653
+ fn run_engine(engine: Engine, state: Arc<SharedState>) {
654
+ let mut runtime = EngineRuntime {
655
+ engine,
656
+ voices: BTreeMap::new(),
657
+ };
658
+ runtime.apply_global_volume(&state);
659
+ while !state.shutdown.load(Ordering::Acquire) {
660
+ let commands = if let Ok(commands) = state.commands.lock() {
661
+ let mut commands = if commands.is_empty() {
662
+ state
663
+ .command_wakeup
664
+ .wait_timeout(commands, CONTROL_INTERVAL)
665
+ .map_or_else(|poisoned| poisoned.into_inner().0, |result| result.0)
666
+ } else {
667
+ commands
668
+ };
669
+ commands.drain(..).collect::<Vec<_>>()
670
+ } else {
671
+ Vec::new()
672
+ };
673
+ runtime.apply_commands(&state, commands);
674
+ runtime.poll_voices(&state);
675
+ }
676
+ runtime.voices.clear();
677
+ let _ = runtime.engine.stop();
678
+ }
679
+
680
+ impl EngineRuntime {
681
+ fn apply_commands(&mut self, state: &SharedState, commands: Vec<EngineCommand>) {
682
+ for command in commands {
683
+ match command {
684
+ EngineCommand::AddVoice(mut voice) => {
685
+ if matches!(voice.source, VoiceSource::Streaming { .. }) {
686
+ if let Err(message) = self.attach_sound(&mut voice) {
687
+ state.queue_event(
688
+ voice.owner_id,
689
+ "playerror",
690
+ Some(voice.id),
691
+ Some(message),
692
+ None,
693
+ );
694
+ remove_snapshot(state, voice.id);
695
+ continue;
696
+ }
697
+ }
698
+ self.voices.insert(voice.id, voice);
699
+ }
700
+ EngineCommand::AttachStatic { id, samples } => {
701
+ let Some(mut voice) = self.voices.remove(&id) else {
702
+ continue;
703
+ };
704
+ let result = build_static_data_source(samples)
705
+ .map_err(|error| format!("Cannot create miniaudio data source: {error}"))
706
+ .and_then(|buffer| {
707
+ voice.source = VoiceSource::Static(Box::new(buffer));
708
+ self.attach_sound(&mut voice)
709
+ });
710
+ match result {
711
+ Ok(()) => {
712
+ state.queue_event(voice.owner_id, "play", Some(id), None, None);
713
+ self.voices.insert(id, voice);
714
+ }
715
+ Err(message) => {
716
+ state.queue_event(
717
+ voice.owner_id,
718
+ "playerror",
719
+ Some(id),
720
+ Some(message),
721
+ None,
722
+ );
723
+ remove_snapshot(state, id);
724
+ }
725
+ }
726
+ }
727
+ EngineCommand::Control(options) => self.apply_control(state, options),
728
+ EngineCommand::RemoveOwner(owner_id) => {
729
+ self.voices.retain(|_, voice| voice.owner_id != owner_id);
730
+ }
731
+ EngineCommand::StopAll => self.voices.clear(),
732
+ EngineCommand::SetGlobalVolume => self.apply_global_volume(state),
733
+ }
734
+ }
735
+ }
736
+
737
+ fn attach_sound(&self, voice: &mut Voice) -> std::result::Result<(), String> {
738
+ let (sound, notifier) = match &voice.source {
739
+ VoiceSource::Static(source) => SoundBuilder::new(&self.engine)
740
+ .data_source(source.as_ref())
741
+ .with_end_notifier(),
742
+ VoiceSource::Streaming { receiver, .. } => SoundBuilder::new(&self.engine)
743
+ .data_source(receiver.as_ref())
744
+ .with_end_notifier(),
745
+ VoiceSource::Loading => return Ok(()),
746
+ }
747
+ .map_err(|error| format!("Cannot create miniaudio sound: {error}"))?;
748
+ sound.set_spatialization(false);
749
+ sound.set_volume(if voice.muted { 0.0 } else { 1.0 });
750
+ sound.set_fade_mili(voice.volume, voice.volume, 0);
751
+ sound.set_looping(voice.looped && !voice.streaming);
752
+ if !voice.streaming {
753
+ sound.set_pitch(voice.playback_rate as f32);
754
+ sound
755
+ .play_sound()
756
+ .map_err(|error| format!("Cannot start miniaudio sound: {error}"))?;
757
+ voice.timeline_updated_at = Instant::now();
758
+ voice.play_announced = true;
759
+ }
760
+ voice.sound = Some(sound);
761
+ voice.end_notifier = Some(notifier);
762
+ Ok(())
763
+ }
764
+
765
+ fn apply_global_volume(&self, state: &SharedState) {
766
+ let muted = state.global_muted.load(Ordering::Acquire);
767
+ let volume = f32::from_bits(state.global_volume_bits.load(Ordering::Acquire));
768
+ let _ = self.engine.set_volume(if muted { 0.0 } else { volume });
769
+ }
770
+
771
+ fn apply_control(&mut self, state: &SharedState, options: NativeCommandOptions) {
772
+ if options.command == "stop" {
773
+ self.voices.retain(|id, voice| {
774
+ let matches = voice.owner_id == options.owner_id
775
+ && options.id.is_none_or(|requested| requested == *id);
776
+ if matches {
777
+ state.queue_event(voice.owner_id, "stop", Some(*id), None, None);
778
+ }
779
+ !matches
780
+ });
781
+ return;
782
+ }
783
+
784
+ for (id, voice) in &mut self.voices {
785
+ if voice.owner_id != options.owner_id
786
+ || options.id.is_some_and(|requested| requested != *id)
787
+ {
788
+ continue;
789
+ }
790
+ Self::apply_voice_control(state, *id, voice, &options);
791
+ }
792
+ }
793
+
794
+ fn apply_voice_control(
795
+ state: &SharedState,
796
+ id: u32,
797
+ voice: &mut Voice,
798
+ options: &NativeCommandOptions,
799
+ ) {
800
+ match options.command.as_str() {
801
+ "play" => {
802
+ voice.timeline_updated_at = Instant::now();
803
+ voice.playing = true;
804
+ voice.snapshot.playing.store(true, Ordering::Release);
805
+ if let Some(sound) = &voice.sound {
806
+ let _ = sound.play_sound();
807
+ }
808
+ state.queue_event(voice.owner_id, "play", Some(id), None, None);
809
+ }
810
+ "pause" => {
811
+ voice.update_static_timeline(Instant::now());
812
+ voice.playing = false;
813
+ voice.snapshot.playing.store(false, Ordering::Release);
814
+ if let Some(sound) = &voice.sound {
815
+ let _ = sound.stop_sound();
816
+ }
817
+ state.queue_event(voice.owner_id, "pause", Some(id), None, None);
818
+ }
819
+ "volume" => {
820
+ voice.volume = options.value.unwrap_or(1.0) as f32;
821
+ voice.fade = None;
822
+ if let Some(sound) = &voice.sound {
823
+ sound.set_fade_mili(voice.volume, voice.volume, 0);
824
+ }
825
+ voice
826
+ .snapshot
827
+ .volume_bits
828
+ .store(voice.volume.to_bits(), Ordering::Release);
829
+ state.queue_event(voice.owner_id, "volume", Some(id), None, None);
830
+ }
831
+ "mute" => {
832
+ voice.muted = options.bool_value.unwrap_or(false);
833
+ if let Some(sound) = &voice.sound {
834
+ sound.set_volume(if voice.muted { 0.0 } else { 1.0 });
835
+ }
836
+ state.queue_event(voice.owner_id, "mute", Some(id), None, None);
837
+ }
838
+ "loop" => {
839
+ voice.looped = options.bool_value.unwrap_or(false);
840
+ if let Some(sound) = &voice.sound {
841
+ sound.set_looping(voice.looped && !voice.streaming);
842
+ }
843
+ }
844
+ "seek" => {
845
+ let seconds = options.value.unwrap_or(0.0);
846
+ voice.fade = None;
847
+ if !voice.streaming {
848
+ if let Some(sound) = &voice.sound {
849
+ let _ = sound.seek_to_second(seconds as f32);
850
+ }
851
+ voice.timeline_seconds = seconds;
852
+ voice.timeline_updated_at = Instant::now();
853
+ }
854
+ voice.snapshot.time_bits.store(
855
+ (voice.offset_seconds + seconds).to_bits(),
856
+ Ordering::Release,
857
+ );
858
+ state.queue_event(voice.owner_id, "seek", Some(id), None, None);
859
+ }
860
+ "fade" => {
861
+ let from = options.from.unwrap_or(voice.volume as f64) as f32;
862
+ let to = options.to.unwrap_or(1.0) as f32;
863
+ let duration_ms = options.duration_ms.unwrap_or(0.0).round() as u64;
864
+ if let Some(sound) = &voice.sound {
865
+ sound.set_fade_mili(from, to, duration_ms);
866
+ }
867
+ voice.fade = Some(ActiveFade {
868
+ to,
869
+ ends_at: Instant::now() + Duration::from_millis(duration_ms),
870
+ version: options.fade_version.unwrap_or(0),
871
+ });
872
+ }
873
+ _ => {}
874
+ }
875
+ }
876
+
877
+ fn poll_voices(&mut self, state: &SharedState) {
878
+ let mut completed = Vec::new();
879
+ for (id, voice) in &mut self.voices {
880
+ if voice.sound.is_none() {
881
+ continue;
882
+ }
883
+ voice.update_static_timeline(Instant::now());
884
+ let sound = voice.sound.as_ref().expect("sound was checked above");
885
+ if let VoiceSource::Streaming { receiver, control } = &voice.source {
886
+ let queued = receiver.available_read() as usize;
887
+ voice
888
+ .snapshot
889
+ .queued_frames
890
+ .store(queued, Ordering::Release);
891
+ if control.ready.load(Ordering::Acquire)
892
+ && !voice.play_announced
893
+ && sound.play_sound().is_ok()
894
+ {
895
+ voice.play_announced = true;
896
+ state.queue_event(voice.owner_id, "play", Some(*id), None, None);
897
+ }
898
+ let starved = voice.play_announced
899
+ && voice.playing
900
+ && queued == 0
901
+ && !control.ended.load(Ordering::Acquire);
902
+ if starved && !voice.starved {
903
+ state.underruns.fetch_add(1, Ordering::Relaxed);
904
+ }
905
+ voice.starved = starved;
906
+ if control.ended.load(Ordering::Acquire) && queued == 0 {
907
+ if voice.play_announced {
908
+ state.queue_event(voice.owner_id, "end", Some(*id), None, None);
909
+ }
910
+ completed.push(*id);
911
+ continue;
912
+ }
913
+ }
914
+
915
+ let elapsed_seconds = if voice.streaming {
916
+ sound.time_millis() as f64 * voice.playback_rate / 1000.0
917
+ } else {
918
+ voice.timeline_seconds
919
+ };
920
+ let time = voice.offset_seconds + elapsed_seconds;
921
+ voice
922
+ .snapshot
923
+ .time_bits
924
+ .store(time.to_bits(), Ordering::Release);
925
+ let current_volume = if voice.fade.is_some() {
926
+ sound.current_fade_volume()
927
+ } else {
928
+ voice.volume
929
+ };
930
+ voice
931
+ .snapshot
932
+ .volume_bits
933
+ .store(current_volume.to_bits(), Ordering::Release);
934
+ if voice
935
+ .fade
936
+ .as_ref()
937
+ .is_some_and(|fade| Instant::now() >= fade.ends_at)
938
+ {
939
+ let fade = voice.fade.take().expect("fade was checked above");
940
+ voice.volume = fade.to;
941
+ state.queue_event(
942
+ voice.owner_id,
943
+ "fade",
944
+ Some(voice.id),
945
+ None,
946
+ Some(fade.version),
947
+ );
948
+ }
949
+ if voice.end_notifier.as_ref().is_some_and(EndNotifier::take) {
950
+ state.queue_event(voice.owner_id, "end", Some(*id), None, None);
951
+ if !voice.looped {
952
+ completed.push(*id);
953
+ }
954
+ }
955
+ }
956
+ for id in completed {
957
+ self.voices.remove(&id);
958
+ remove_snapshot(state, id);
959
+ }
960
+ }
961
+ }
962
+
963
+ fn remove_snapshot(state: &SharedState, id: u32) {
964
+ if let Ok(mut snapshots) = state.snapshots.lock() {
965
+ snapshots.remove(&id);
966
+ }
967
+ }
968
+
969
+ fn start_static_decode(
970
+ state: Arc<SharedState>,
971
+ options: NativeVoiceOptions,
972
+ preload: bool,
973
+ request_id: Option<u32>,
974
+ ) {
975
+ let key = cache_key(&options);
976
+ if let Some(samples) = state
977
+ .cache
978
+ .lock()
979
+ .ok()
980
+ .and_then(|cache| cache.get(&key).cloned())
981
+ {
982
+ complete_static_decode(&state, &options, preload, request_id, samples);
983
+ return;
984
+ }
985
+
986
+ thread::spawn(move || {
987
+ let decoded = decode_static(&options, state.sample_rate).map(Arc::new);
988
+ match decoded {
989
+ Ok(samples) => {
990
+ if let Ok(mut cache) = state.cache.lock() {
991
+ cache.insert(key, Arc::clone(&samples));
992
+ }
993
+ complete_static_decode(&state, &options, preload, request_id, samples);
994
+ }
995
+ Err(message) => {
996
+ if !preload {
997
+ remove_snapshot(&state, options.id);
998
+ }
999
+ state.queue_event(
1000
+ options.owner_id,
1001
+ if preload { "loaderror" } else { "playerror" },
1002
+ request_id.or(Some(options.id)),
1003
+ Some(message),
1004
+ None,
1005
+ );
1006
+ }
1007
+ }
1008
+ });
1009
+ }
1010
+
1011
+ fn complete_static_decode(
1012
+ state: &SharedState,
1013
+ options: &NativeVoiceOptions,
1014
+ preload: bool,
1015
+ request_id: Option<u32>,
1016
+ samples: Arc<Vec<f32>>,
1017
+ ) {
1018
+ if preload {
1019
+ state.queue_event(options.owner_id, "load", request_id, None, None);
1020
+ } else {
1021
+ state.push_command(EngineCommand::AttachStatic {
1022
+ id: options.id,
1023
+ samples,
1024
+ });
1025
+ }
1026
+ }
1027
+
1028
+ fn build_static_data_source(samples: Arc<Vec<f32>>) -> MaResult<StaticDataSource> {
1029
+ DataSourceBuilder::new(CHANNELS as u32, SampleRate::Sr48000)
1030
+ .build_f32(SharedPcmSource::new(samples))
1031
+ }
1032
+
1033
+ fn start_streaming_decode(
1034
+ state: Arc<SharedState>,
1035
+ options: NativeVoiceOptions,
1036
+ mut sender: PcmRbSend<f32>,
1037
+ control: Arc<StreamingControl>,
1038
+ ) {
1039
+ thread::spawn(move || {
1040
+ let result = decode_stream(&state, &options, &mut sender, &control);
1041
+ if let Err(message) = result {
1042
+ if !control.stopped.load(Ordering::Acquire) {
1043
+ remove_snapshot(&state, options.id);
1044
+ state.queue_event(
1045
+ options.owner_id,
1046
+ "playerror",
1047
+ Some(options.id),
1048
+ Some(message),
1049
+ None,
1050
+ );
1051
+ }
1052
+ }
1053
+ control.ended.store(true, Ordering::Release);
1054
+ state.command_wakeup.notify_one();
1055
+ });
1056
+ }
1057
+
1058
+ fn decode_static(
1059
+ options: &NativeVoiceOptions,
1060
+ sample_rate: u32,
1061
+ ) -> std::result::Result<Vec<f32>, String> {
1062
+ let mut command = ffmpeg_command(options, sample_rate, false)?;
1063
+ let output = command
1064
+ .output()
1065
+ .map_err(|error| format!("Cannot start FFmpeg audio decoder: {error}"))?;
1066
+ if !output.status.success() {
1067
+ return Err(redact_credentials(
1068
+ String::from_utf8_lossy(&output.stderr).trim(),
1069
+ ));
1070
+ }
1071
+ bytes_to_samples(&output.stdout)
1072
+ }
1073
+
1074
+ fn decode_stream(
1075
+ state: &SharedState,
1076
+ options: &NativeVoiceOptions,
1077
+ sender: &mut PcmRbSend<f32>,
1078
+ control: &StreamingControl,
1079
+ ) -> std::result::Result<(), String> {
1080
+ let mut child = ffmpeg_command(options, state.sample_rate, true)?
1081
+ .spawn()
1082
+ .map_err(|error| format!("Cannot start FFmpeg audio stream: {error}"))?;
1083
+ let mut stdout = child
1084
+ .stdout
1085
+ .take()
1086
+ .ok_or_else(|| "FFmpeg audio stdout is unavailable".to_string())?;
1087
+ let mut stderr = child
1088
+ .stderr
1089
+ .take()
1090
+ .ok_or_else(|| "FFmpeg audio stderr is unavailable".to_string())?;
1091
+ if let Ok(mut stored_child) = control.child.lock() {
1092
+ *stored_child = Some(child);
1093
+ }
1094
+ let mut byte_carry = Vec::new();
1095
+ let mut sample_carry = Vec::new();
1096
+ let mut bytes = vec![0_u8; 32 * 1024];
1097
+ let mut produced_frames = 0_u32;
1098
+ loop {
1099
+ if control.stopped.load(Ordering::Acquire) || state.shutdown.load(Ordering::Acquire) {
1100
+ return Ok(());
1101
+ }
1102
+ let read = stdout.read(&mut bytes).map_err(|error| error.to_string())?;
1103
+ if read == 0 {
1104
+ break;
1105
+ }
1106
+ byte_carry.extend_from_slice(&bytes[..read]);
1107
+ let aligned = byte_carry.len() - byte_carry.len() % 4;
1108
+ for chunk in byte_carry[..aligned].chunks_exact(4) {
1109
+ sample_carry.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
1110
+ }
1111
+ byte_carry.drain(..aligned);
1112
+ while sample_carry.len() >= STREAM_CHUNK_SAMPLES {
1113
+ let remainder = sample_carry.split_off(STREAM_CHUNK_SAMPLES);
1114
+ let chunk = std::mem::replace(&mut sample_carry, remainder);
1115
+ write_stream_chunk(sender, &chunk, control)?;
1116
+ produced_frames = produced_frames.saturating_add(STREAM_CHUNK_FRAMES as u32);
1117
+ if produced_frames >= STREAM_START_FRAMES {
1118
+ control.ready.store(true, Ordering::Release);
1119
+ }
1120
+ }
1121
+ }
1122
+ let mut child = control
1123
+ .child
1124
+ .lock()
1125
+ .map_err(|_| "FFmpeg process state is poisoned".to_string())?
1126
+ .take()
1127
+ .ok_or_else(|| "FFmpeg process disappeared".to_string())?;
1128
+ let status = child.wait().map_err(|error| error.to_string())?;
1129
+ if !status.success() {
1130
+ let mut error = String::new();
1131
+ let _ = stderr.read_to_string(&mut error);
1132
+ return Err(redact_credentials(error.trim()));
1133
+ }
1134
+ if !sample_carry.is_empty() {
1135
+ let frames = sample_carry.len() / CHANNELS;
1136
+ sample_carry.resize(STREAM_CHUNK_SAMPLES, 0.0);
1137
+ write_stream_chunk(sender, &sample_carry, control)?;
1138
+ produced_frames = produced_frames.saturating_add(frames as u32);
1139
+ }
1140
+ if produced_frames == 0 {
1141
+ return Err("Audio stream contains no decodable samples".to_string());
1142
+ }
1143
+ control.ready.store(true, Ordering::Release);
1144
+ Ok(())
1145
+ }
1146
+
1147
+ fn write_stream_chunk(
1148
+ sender: &mut PcmRbSend<f32>,
1149
+ samples: &[f32],
1150
+ control: &StreamingControl,
1151
+ ) -> std::result::Result<(), String> {
1152
+ let mut written = 0;
1153
+ while written < samples.len() / CHANNELS {
1154
+ if control.stopped.load(Ordering::Acquire) {
1155
+ return Ok(());
1156
+ }
1157
+ let frames = sender
1158
+ .write(&samples[written * CHANNELS..])
1159
+ .map_err(|error| format!("Cannot write audio stream buffer: {error}"))?;
1160
+ if frames == 0 {
1161
+ thread::sleep(Duration::from_millis(2));
1162
+ } else {
1163
+ written += frames;
1164
+ }
1165
+ }
1166
+ Ok(())
1167
+ }
1168
+
1169
+ fn ffmpeg_command(
1170
+ options: &NativeVoiceOptions,
1171
+ sample_rate: u32,
1172
+ streaming: bool,
1173
+ ) -> std::result::Result<Command, String> {
1174
+ let mut command = Command::new(&options.ffmpeg_path);
1175
+ command.args(["-hide_banner", "-loglevel", "error", "-nostdin"]);
1176
+ if options.offset_seconds > 0.0 {
1177
+ command.args(["-ss", &options.offset_seconds.to_string()]);
1178
+ }
1179
+ command.args(&options.input_args);
1180
+ command.args(["-i", &options.source, "-vn"]);
1181
+ if let Some(duration) = options.duration_seconds {
1182
+ command.args(["-t", &duration.to_string()]);
1183
+ }
1184
+ if streaming && (options.playback_rate - 1.0).abs() > f64::EPSILON {
1185
+ command.args(["-af", &build_atempo_filter(options.playback_rate)?]);
1186
+ }
1187
+ command.args(&options.output_args);
1188
+ command.args([
1189
+ "-ac",
1190
+ "2",
1191
+ "-ar",
1192
+ &sample_rate.to_string(),
1193
+ "-f",
1194
+ "f32le",
1195
+ "pipe:1",
1196
+ ]);
1197
+ command
1198
+ .stdin(Stdio::null())
1199
+ .stdout(Stdio::piped())
1200
+ .stderr(Stdio::piped());
1201
+ #[cfg(target_os = "windows")]
1202
+ {
1203
+ use std::os::windows::process::CommandExt;
1204
+ command.creation_flags(0x0800_0000);
1205
+ }
1206
+ Ok(command)
1207
+ }
1208
+
1209
+ fn build_atempo_filter(rate: f64) -> std::result::Result<String, String> {
1210
+ if !rate.is_finite() || rate <= 0.0 {
1211
+ return Err("Audio playback rate must be positive and finite".to_string());
1212
+ }
1213
+ let mut factors = Vec::new();
1214
+ let mut remaining = rate;
1215
+ while remaining < 0.5 {
1216
+ factors.push(0.5);
1217
+ remaining /= 0.5;
1218
+ }
1219
+ while remaining > 2.0 {
1220
+ factors.push(2.0);
1221
+ remaining /= 2.0;
1222
+ }
1223
+ factors.push(remaining);
1224
+ Ok(factors
1225
+ .into_iter()
1226
+ .map(|factor| format!("atempo={factor}"))
1227
+ .collect::<Vec<_>>()
1228
+ .join(","))
1229
+ }
1230
+
1231
+ fn bytes_to_samples(bytes: &[u8]) -> std::result::Result<Vec<f32>, String> {
1232
+ if bytes.is_empty() {
1233
+ return Err("Audio stream contains no decodable samples".to_string());
1234
+ }
1235
+ Ok(bytes
1236
+ .chunks_exact(4)
1237
+ .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
1238
+ .collect())
1239
+ }
1240
+
1241
+ fn cache_key(options: &NativeVoiceOptions) -> String {
1242
+ format!(
1243
+ "{}\0{}\0{}",
1244
+ options.source,
1245
+ options.offset_seconds,
1246
+ options
1247
+ .duration_seconds
1248
+ .map_or_else(|| "end".to_string(), |value| value.to_string())
1249
+ )
1250
+ }
1251
+
1252
+ fn validate_voice_options(options: &NativeVoiceOptions) -> Result<()> {
1253
+ if !options.offset_seconds.is_finite() || options.offset_seconds < 0.0 {
1254
+ return Err(Error::from_reason(
1255
+ "Audio offset must be non-negative and finite",
1256
+ ));
1257
+ }
1258
+ if options
1259
+ .duration_seconds
1260
+ .is_some_and(|value| !value.is_finite() || value <= 0.0)
1261
+ {
1262
+ return Err(Error::from_reason(
1263
+ "Audio duration must be positive and finite",
1264
+ ));
1265
+ }
1266
+ required_unit_value(Some(options.volume), "volume")?;
1267
+ if !options.playback_rate.is_finite() || options.playback_rate <= 0.0 {
1268
+ return Err(Error::from_reason(
1269
+ "Audio playback rate must be positive and finite",
1270
+ ));
1271
+ }
1272
+ Ok(())
1273
+ }
1274
+
1275
+ fn required_unit_value(value: Option<f64>, name: &str) -> Result<f64> {
1276
+ let value = value.ok_or_else(|| Error::from_reason(format!("Missing audio {name}")))?;
1277
+ if !value.is_finite() || !(0.0..=1.0).contains(&value) {
1278
+ return Err(Error::from_reason(format!(
1279
+ "Audio {name} must be between 0 and 1"
1280
+ )));
1281
+ }
1282
+ Ok(value)
1283
+ }
1284
+
1285
+ fn redact_credentials(message: &str) -> String {
1286
+ let mut result = message.to_string();
1287
+ let mut start = 0;
1288
+ while let Some(scheme) = result[start..].find("://") {
1289
+ let authority_start = start + scheme + 3;
1290
+ let authority_end = result[authority_start..]
1291
+ .find(['/', ' ', '\n', '\r'])
1292
+ .map_or(result.len(), |offset| authority_start + offset);
1293
+ if let Some(at) = result[authority_start..authority_end].find('@') {
1294
+ let credential_end = authority_start + at;
1295
+ result.replace_range(authority_start..credential_end, "***:***");
1296
+ start = authority_start + 8;
1297
+ } else {
1298
+ start = authority_end;
1299
+ }
1300
+ }
1301
+ if result.trim().is_empty() {
1302
+ "FFmpeg audio decoder failed".to_string()
1303
+ } else {
1304
+ result
1305
+ }
1306
+ }
1307
+
1308
+ #[cfg(test)]
1309
+ mod tests {
1310
+ use super::*;
1311
+
1312
+ #[test]
1313
+ fn static_voices_share_pcm_with_independent_cursors() {
1314
+ let samples = Arc::new(vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6]);
1315
+ let mut first = build_static_data_source(Arc::clone(&samples)).unwrap();
1316
+ let second = build_static_data_source(Arc::clone(&samples)).unwrap();
1317
+
1318
+ assert_eq!(Arc::strong_count(&samples), 3);
1319
+ assert_eq!(first.cursor_in_pcm_frames().unwrap(), 0);
1320
+ assert_eq!(second.cursor_in_pcm_frames().unwrap(), 0);
1321
+
1322
+ assert_eq!(first.read_pcm_frames(1).unwrap().frames(), 1);
1323
+ assert_eq!(first.cursor_in_pcm_frames().unwrap(), 1);
1324
+ assert_eq!(second.cursor_in_pcm_frames().unwrap(), 0);
1325
+
1326
+ first.seek_to_pcm_frame(0).unwrap();
1327
+ assert_eq!(first.cursor_in_pcm_frames().unwrap(), 0);
1328
+ assert!(first.seek_to_pcm_frame(4).is_err());
1329
+
1330
+ drop(first);
1331
+ drop(second);
1332
+ assert_eq!(Arc::strong_count(&samples), 1);
1333
+ }
1334
+
1335
+ #[test]
1336
+ fn atempo_decomposes_extreme_rates() {
1337
+ assert_eq!(build_atempo_filter(4.0).unwrap(), "atempo=2,atempo=2");
1338
+ assert_eq!(build_atempo_filter(0.25).unwrap(), "atempo=0.5,atempo=0.5");
1339
+ }
1340
+
1341
+ #[test]
1342
+ fn credentials_are_redacted() {
1343
+ assert_eq!(
1344
+ redact_credentials("https://user:secret@example.test/audio.mp3"),
1345
+ "https://***:***@example.test/audio.mp3"
1346
+ );
1347
+ }
1348
+ }