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

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