@matjash/pixi-native-win32-x64 0.1.2

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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/THIRD_PARTY_NOTICES.md +83 -0
  3. package/index.cjs +25 -0
  4. package/index.d.ts +11 -0
  5. package/native/audio/binding-path.js +16 -0
  6. package/native/audio/dist/win32-x64/native_audio.node +0 -0
  7. package/native/audio/package.json +7 -0
  8. package/native/audio/src/index.d.ts +63 -0
  9. package/native/audio/src/index.js +5 -0
  10. package/native/audio/src/lib.rs +1520 -0
  11. package/native/gpu/dist/win32-x64/d3dcompiler_47.dll +0 -0
  12. package/native/gpu/dist/win32-x64/pixi_native_gpu.node +0 -0
  13. package/native/gpu/package.json +24 -0
  14. package/native/gpu/src/binding-path.js +16 -0
  15. package/native/gpu/src/index.d.ts +2847 -0
  16. package/native/gpu/src/index.js +48 -0
  17. package/native/video/dist/win32-x64/FFMPEG_BUILD_INFO.txt +46 -0
  18. package/native/video/dist/win32-x64/FFMPEG_LICENSE.txt +502 -0
  19. package/native/video/dist/win32-x64/FFMPEG_SHA256SUMS +4 -0
  20. package/native/video/dist/win32-x64/ffmpeg.exe +0 -0
  21. package/native/video/dist/win32-x64/ffprobe.exe +0 -0
  22. package/native/video/dist/win32-x64/native_video.node +0 -0
  23. package/native/video/package.json +7 -0
  24. package/native/video/src/binding-path.js +26 -0
  25. package/native/video/src/index.d.ts +36 -0
  26. package/native/video/src/index.js +61 -0
  27. package/native/video/src/lib.rs +994 -0
  28. package/native/window/binding-path.js +11 -0
  29. package/native/window/dist/win32-x64/native_window.node +0 -0
  30. package/native/window/package.json +7 -0
  31. package/native/window/src/index.d.ts +17 -0
  32. package/native/window/src/index.js +32 -0
  33. package/native/window/src/lib.rs +308 -0
  34. package/package.json +41 -0
  35. package/third_party/ffmpeg-source-8.0-140fd653ae.tar.gz +0 -0
  36. package/third_party/ffmpeg-source-8.0-140fd653ae.tar.gz.sha256 +1 -0
@@ -0,0 +1,1520 @@
1
+ #![deny(clippy::all)]
2
+
3
+ use std::collections::{BTreeMap, HashMap};
4
+ use std::io::Read;
5
+ use std::process::{Child, Command, Stdio};
6
+ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
7
+ use std::sync::{Arc, Mutex, OnceLock};
8
+ use std::thread;
9
+
10
+ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
11
+ use cpal::{FromSample, SampleFormat, SizedSample, Stream};
12
+ use crossbeam_queue::{ArrayQueue, SegQueue};
13
+ use napi::bindgen_prelude::*;
14
+ use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
15
+ use napi_derive::napi;
16
+
17
+ const CHANNELS: usize = 2;
18
+ const STREAM_CHUNK_FRAMES: usize = 2048;
19
+ const STREAM_CHUNK_SAMPLES: usize = STREAM_CHUNK_FRAMES * CHANNELS;
20
+ const STREAM_START_CHUNKS: usize = 4;
21
+ const STREAM_BUFFER_SECONDS: usize = 2;
22
+ type EventNotifier = ThreadsafeFunction<(), (), (), Status, false, true, 1>;
23
+
24
+ #[napi(object)]
25
+ #[derive(Clone)]
26
+ pub struct NativeVoiceOptions {
27
+ pub owner_id: u32,
28
+ pub id: u32,
29
+ pub source: String,
30
+ pub ffmpeg_path: String,
31
+ pub offset_seconds: f64,
32
+ pub duration_seconds: Option<f64>,
33
+ pub volume: f64,
34
+ pub muted: bool,
35
+ pub loop_: bool,
36
+ pub streaming: bool,
37
+ pub playback_rate: f64,
38
+ pub input_args: Vec<String>,
39
+ pub output_args: Vec<String>,
40
+ }
41
+
42
+ #[napi(object)]
43
+ pub struct NativeCommandOptions {
44
+ pub owner_id: u32,
45
+ pub command: String,
46
+ pub id: Option<u32>,
47
+ pub value: Option<f64>,
48
+ pub bool_value: Option<bool>,
49
+ pub from: Option<f64>,
50
+ pub to: Option<f64>,
51
+ pub duration_ms: Option<f64>,
52
+ pub fade_version: Option<u32>,
53
+ }
54
+
55
+ #[napi(object)]
56
+ pub struct NativeAudioEvent {
57
+ pub owner_id: u32,
58
+ pub event: String,
59
+ pub id: Option<u32>,
60
+ pub message: Option<String>,
61
+ }
62
+
63
+ #[napi(object)]
64
+ pub struct NativeAudioDiagnostics {
65
+ pub active_voices: u32,
66
+ pub queued_ms: f64,
67
+ pub underruns: u32,
68
+ }
69
+
70
+ struct QueuedEvent {
71
+ owner_id: u32,
72
+ event: &'static str,
73
+ id: Option<u32>,
74
+ message: Option<String>,
75
+ target_frame: u64,
76
+ sequence: u64,
77
+ fade_version: Option<u32>,
78
+ }
79
+
80
+ struct Fade {
81
+ from: f32,
82
+ to: f32,
83
+ start_frame: u64,
84
+ duration_frames: u64,
85
+ version: u32,
86
+ }
87
+
88
+ enum VoiceSource {
89
+ Loading,
90
+ Static(Arc<Vec<f32>>),
91
+ Streaming {
92
+ buffer: Arc<StreamingBuffer>,
93
+ chunk: Option<Box<[f32]>>,
94
+ offset: usize,
95
+ },
96
+ }
97
+
98
+ struct VoiceSnapshot {
99
+ owner_id: u32,
100
+ time_bits: AtomicU64,
101
+ volume_bits: AtomicU32,
102
+ playing: AtomicBool,
103
+ stream: Option<Arc<StreamingBuffer>>,
104
+ current_chunk_samples: AtomicUsize,
105
+ }
106
+
107
+ struct Voice {
108
+ owner_id: u32,
109
+ id: u32,
110
+ source: VoiceSource,
111
+ offset_seconds: f64,
112
+ position_frames: f64,
113
+ rendered_frames: u64,
114
+ volume: f32,
115
+ muted: bool,
116
+ looped: bool,
117
+ playing: bool,
118
+ playback_rate: f64,
119
+ fade: Option<Fade>,
120
+ play_announced: bool,
121
+ finished: bool,
122
+ snapshot: Arc<VoiceSnapshot>,
123
+ }
124
+
125
+ struct StreamingBuffer {
126
+ chunks: ArrayQueue<Box<[f32]>>,
127
+ queued_samples: AtomicUsize,
128
+ produced_chunks: AtomicUsize,
129
+ ready: AtomicBool,
130
+ ended: AtomicBool,
131
+ stopped: AtomicBool,
132
+ producer_waiting: AtomicBool,
133
+ producer_thread: OnceLock<thread::Thread>,
134
+ child: Mutex<Option<Child>>,
135
+ }
136
+
137
+ impl StreamingBuffer {
138
+ fn new(sample_rate: u32) -> Self {
139
+ let capacity = (sample_rate as usize * STREAM_BUFFER_SECONDS).div_ceil(STREAM_CHUNK_FRAMES);
140
+ Self {
141
+ chunks: ArrayQueue::new(capacity),
142
+ queued_samples: AtomicUsize::new(0),
143
+ produced_chunks: AtomicUsize::new(0),
144
+ ready: AtomicBool::new(false),
145
+ ended: AtomicBool::new(false),
146
+ stopped: AtomicBool::new(false),
147
+ producer_waiting: AtomicBool::new(false),
148
+ producer_thread: OnceLock::new(),
149
+ child: Mutex::new(None),
150
+ }
151
+ }
152
+
153
+ fn push(&self, mut chunk: Box<[f32]>) -> bool {
154
+ let _ = self.producer_thread.set(thread::current());
155
+ loop {
156
+ if self.stopped.load(Ordering::Acquire) {
157
+ return false;
158
+ }
159
+ match self.chunks.push(chunk) {
160
+ Ok(()) => {
161
+ self.queued_samples
162
+ .fetch_add(STREAM_CHUNK_SAMPLES, Ordering::Release);
163
+ self.produced_chunks.fetch_add(1, Ordering::Relaxed);
164
+ if self.chunks.len() >= STREAM_START_CHUNKS {
165
+ self.ready.store(true, Ordering::Release);
166
+ }
167
+ return true;
168
+ }
169
+ Err(returned) => {
170
+ chunk = returned;
171
+ self.producer_waiting.store(true, Ordering::Release);
172
+ if self.chunks.is_full() {
173
+ thread::park();
174
+ }
175
+ self.producer_waiting.store(false, Ordering::Release);
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ fn pop(&self) -> Option<Box<[f32]>> {
182
+ let chunk = self.chunks.pop()?;
183
+ self.queued_samples
184
+ .fetch_sub(chunk.len(), Ordering::Release);
185
+ if self.producer_waiting.swap(false, Ordering::AcqRel) {
186
+ if let Some(producer) = self.producer_thread.get() {
187
+ producer.unpark();
188
+ }
189
+ }
190
+ Some(chunk)
191
+ }
192
+
193
+ fn queued_samples(&self) -> usize {
194
+ self.queued_samples.load(Ordering::Acquire)
195
+ }
196
+
197
+ fn stop(&self) {
198
+ self.stopped.store(true, Ordering::Release);
199
+ if let Some(producer) = self.producer_thread.get() {
200
+ producer.unpark();
201
+ }
202
+ if let Ok(mut child) = self.child.lock() {
203
+ if let Some(child) = child.as_mut() {
204
+ let _ = child.kill();
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ enum MixerCommand {
211
+ AddVoice(Voice),
212
+ AttachStatic { id: u32, samples: Arc<Vec<f32>> },
213
+ Control(NativeCommandOptions),
214
+ RemoveOwner(u32),
215
+ StopAll,
216
+ }
217
+
218
+ #[derive(Default)]
219
+ struct Mixer {
220
+ voices: BTreeMap<u32, Voice>,
221
+ }
222
+
223
+ struct SharedState {
224
+ sample_rate: u32,
225
+ output_channels: usize,
226
+ commands: SegQueue<MixerCommand>,
227
+ snapshots: Mutex<HashMap<u32, Arc<VoiceSnapshot>>>,
228
+ completed_voices: SegQueue<u32>,
229
+ cache: Mutex<HashMap<String, Arc<Vec<f32>>>>,
230
+ events: SegQueue<QueuedEvent>,
231
+ event_notifier: Option<Arc<EventNotifier>>,
232
+ event_sequence: AtomicU64,
233
+ audible_frame: AtomicU64,
234
+ global_volume_bits: AtomicU32,
235
+ global_muted: AtomicBool,
236
+ fade_versions: Mutex<HashMap<u32, u32>>,
237
+ underruns: AtomicU32,
238
+ shutdown: AtomicBool,
239
+ }
240
+
241
+ impl SharedState {
242
+ fn queue_event(
243
+ &self,
244
+ owner_id: u32,
245
+ event: &'static str,
246
+ id: Option<u32>,
247
+ message: Option<String>,
248
+ fade_version: Option<u32>,
249
+ ) {
250
+ self.events.push(QueuedEvent {
251
+ owner_id,
252
+ event,
253
+ id,
254
+ message,
255
+ target_frame: self.audible_frame.load(Ordering::Acquire),
256
+ sequence: self.event_sequence.fetch_add(1, Ordering::Relaxed),
257
+ fade_version,
258
+ });
259
+ if let Some(notifier) = &self.event_notifier {
260
+ notifier.call((), ThreadsafeFunctionCallMode::NonBlocking);
261
+ }
262
+ }
263
+
264
+ fn stop_snapshot(snapshot: &VoiceSnapshot) {
265
+ if let Some(stream) = &snapshot.stream {
266
+ stream.stop();
267
+ }
268
+ snapshot.playing.store(false, Ordering::Release);
269
+ }
270
+ }
271
+
272
+ #[napi]
273
+ pub struct NativeAudioEngine {
274
+ state: Arc<SharedState>,
275
+ stream: Mutex<Option<Stream>>,
276
+ }
277
+
278
+ #[napi]
279
+ impl NativeAudioEngine {
280
+ #[napi(constructor)]
281
+ pub fn new(event_notifier: EventNotifier) -> Result<Self> {
282
+ let host = cpal::default_host();
283
+ let device = host.default_output_device().ok_or_else(|| {
284
+ Error::from_reason("Windows has no default WASAPI audio output device")
285
+ })?;
286
+ let supported = device
287
+ .default_output_config()
288
+ .map_err(|error| Error::from_reason(format!("Cannot query WASAPI output: {error}")))?;
289
+ let sample_format = supported.sample_format();
290
+ let config = supported.config();
291
+ let state = Arc::new(SharedState {
292
+ sample_rate: config.sample_rate.0,
293
+ output_channels: config.channels as usize,
294
+ commands: SegQueue::new(),
295
+ snapshots: Mutex::new(HashMap::new()),
296
+ completed_voices: SegQueue::new(),
297
+ cache: Mutex::new(HashMap::new()),
298
+ events: SegQueue::new(),
299
+ event_notifier: Some(Arc::new(event_notifier)),
300
+ event_sequence: AtomicU64::new(0),
301
+ audible_frame: AtomicU64::new(0),
302
+ global_volume_bits: AtomicU32::new(1.0_f32.to_bits()),
303
+ global_muted: AtomicBool::new(false),
304
+ fade_versions: Mutex::new(HashMap::new()),
305
+ underruns: AtomicU32::new(0),
306
+ shutdown: AtomicBool::new(false),
307
+ });
308
+ let stream_state = Arc::clone(&state);
309
+ let error_state = Arc::clone(&state);
310
+ let error_callback = move |error: cpal::StreamError| {
311
+ error_state.queue_event(0, "playerror", None, Some(error.to_string()), None);
312
+ };
313
+ let stream = match sample_format {
314
+ SampleFormat::F32 => {
315
+ build_stream::<f32>(&device, &config, stream_state, error_callback)
316
+ }
317
+ SampleFormat::I16 => {
318
+ build_stream::<i16>(&device, &config, stream_state, error_callback)
319
+ }
320
+ SampleFormat::U16 => {
321
+ build_stream::<u16>(&device, &config, stream_state, error_callback)
322
+ }
323
+ format => Err(Error::from_reason(format!(
324
+ "Unsupported WASAPI sample format: {format:?}"
325
+ ))),
326
+ }?;
327
+ stream
328
+ .play()
329
+ .map_err(|error| Error::from_reason(format!("Cannot start WASAPI output: {error}")))?;
330
+ Ok(Self {
331
+ state,
332
+ stream: Mutex::new(Some(stream)),
333
+ })
334
+ }
335
+
336
+ #[napi]
337
+ pub fn create_voice(&self, options: NativeVoiceOptions) -> Result<()> {
338
+ validate_voice_options(&options)?;
339
+ let id = options.id;
340
+ let owner_id = options.owner_id;
341
+ let stream = options
342
+ .streaming
343
+ .then(|| Arc::new(StreamingBuffer::new(self.state.sample_rate)));
344
+ let snapshot = Arc::new(VoiceSnapshot {
345
+ owner_id,
346
+ time_bits: AtomicU64::new(options.offset_seconds.to_bits()),
347
+ volume_bits: AtomicU32::new((options.volume as f32).to_bits()),
348
+ playing: AtomicBool::new(true),
349
+ stream: stream.clone(),
350
+ current_chunk_samples: AtomicUsize::new(0),
351
+ });
352
+ let voice = Voice {
353
+ owner_id,
354
+ id,
355
+ source: stream
356
+ .as_ref()
357
+ .map_or(VoiceSource::Loading, |buffer| VoiceSource::Streaming {
358
+ buffer: Arc::clone(buffer),
359
+ chunk: None,
360
+ offset: 0,
361
+ }),
362
+ offset_seconds: options.offset_seconds,
363
+ position_frames: 0.0,
364
+ rendered_frames: 0,
365
+ volume: options.volume as f32,
366
+ muted: options.muted,
367
+ looped: options.loop_ && !options.streaming,
368
+ playing: true,
369
+ playback_rate: options.playback_rate,
370
+ fade: None,
371
+ play_announced: false,
372
+ finished: false,
373
+ snapshot: Arc::clone(&snapshot),
374
+ };
375
+ self.state
376
+ .snapshots
377
+ .lock()
378
+ .map_err(|_| Error::from_reason("Audio mixer state is poisoned"))?
379
+ .insert(id, snapshot);
380
+ self.state.commands.push(MixerCommand::AddVoice(voice));
381
+
382
+ if let Some(stream) = stream {
383
+ start_streaming_decode(Arc::clone(&self.state), options, stream);
384
+ } else {
385
+ start_static_decode(Arc::clone(&self.state), options, false, None);
386
+ }
387
+ Ok(())
388
+ }
389
+
390
+ #[napi]
391
+ pub fn preload(
392
+ &self,
393
+ owner_id: u32,
394
+ request_id: u32,
395
+ source: String,
396
+ ffmpeg_path: String,
397
+ offset_seconds: f64,
398
+ duration_seconds: Option<f64>,
399
+ ) -> Result<()> {
400
+ if offset_seconds < 0.0 || duration_seconds.is_some_and(|value| value <= 0.0) {
401
+ return Err(Error::from_reason("Invalid audio preload range"));
402
+ }
403
+ start_static_decode(
404
+ Arc::clone(&self.state),
405
+ NativeVoiceOptions {
406
+ owner_id,
407
+ id: 0,
408
+ source,
409
+ ffmpeg_path,
410
+ offset_seconds,
411
+ duration_seconds,
412
+ volume: 1.0,
413
+ muted: false,
414
+ loop_: false,
415
+ streaming: false,
416
+ playback_rate: 1.0,
417
+ input_args: Vec::new(),
418
+ output_args: Vec::new(),
419
+ },
420
+ true,
421
+ Some(request_id),
422
+ );
423
+ Ok(())
424
+ }
425
+
426
+ #[napi]
427
+ pub fn command(&self, options: NativeCommandOptions) -> Result<()> {
428
+ match options.command.as_str() {
429
+ "play" | "pause" | "mute" | "loop" | "stop" => {}
430
+ "volume" => {
431
+ required_unit_value(options.value, "volume")?;
432
+ }
433
+ "seek" => {
434
+ let seconds = options.value.unwrap_or(0.0);
435
+ if !seconds.is_finite() || seconds < 0.0 {
436
+ return Err(Error::from_reason(
437
+ "Audio seek must be non-negative and finite",
438
+ ));
439
+ }
440
+ }
441
+ "fade" => {
442
+ required_unit_value(options.from, "fade start")?;
443
+ required_unit_value(options.to, "fade target")?;
444
+ let duration_ms = options.duration_ms.unwrap_or(0.0);
445
+ if !duration_ms.is_finite() || duration_ms < 0.0 {
446
+ return Err(Error::from_reason(
447
+ "Fade duration must be non-negative and finite",
448
+ ));
449
+ }
450
+ if let Some(id) = options.id {
451
+ if let Ok(mut versions) = self.state.fade_versions.lock() {
452
+ versions.insert(id, options.fade_version.unwrap_or(0));
453
+ }
454
+ }
455
+ }
456
+ command => {
457
+ return Err(Error::from_reason(format!(
458
+ "Unknown audio command: {command}"
459
+ )))
460
+ }
461
+ }
462
+
463
+ if options.command == "stop" {
464
+ self.remove_snapshots(options.owner_id, options.id);
465
+ }
466
+ self.state.commands.push(MixerCommand::Control(options));
467
+ Ok(())
468
+ }
469
+
470
+ #[napi]
471
+ pub fn unload_owner(&self, owner_id: u32) {
472
+ self.remove_snapshots(owner_id, None);
473
+ self.state
474
+ .commands
475
+ .push(MixerCommand::RemoveOwner(owner_id));
476
+ }
477
+
478
+ #[napi]
479
+ pub fn current_time(&self, id: u32) -> Option<f64> {
480
+ let snapshot = self.state.snapshots.lock().ok()?.get(&id).cloned()?;
481
+ Some(f64::from_bits(snapshot.time_bits.load(Ordering::Acquire)))
482
+ }
483
+
484
+ #[napi]
485
+ pub fn current_volume(&self, id: u32) -> Option<f64> {
486
+ let snapshot = self.state.snapshots.lock().ok()?.get(&id).cloned()?;
487
+ Some(f32::from_bits(snapshot.volume_bits.load(Ordering::Acquire)) as f64)
488
+ }
489
+
490
+ #[napi]
491
+ pub fn set_global_volume(&self, value: f64) -> Result<()> {
492
+ let value = required_unit_value(Some(value), "global volume")? as f32;
493
+ self.state
494
+ .global_volume_bits
495
+ .store(value.to_bits(), Ordering::Release);
496
+ Ok(())
497
+ }
498
+
499
+ #[napi]
500
+ pub fn set_global_muted(&self, value: bool) {
501
+ self.state.global_muted.store(value, Ordering::Release);
502
+ }
503
+
504
+ #[napi]
505
+ pub fn drain_events(&self) -> Vec<NativeAudioEvent> {
506
+ if let Ok(mut snapshots) = self.state.snapshots.lock() {
507
+ while let Some(id) = self.state.completed_voices.pop() {
508
+ snapshots.remove(&id);
509
+ }
510
+ }
511
+ let mut queued = Vec::new();
512
+ while let Some(event) = self.state.events.pop() {
513
+ queued.push(event);
514
+ }
515
+ queued.sort_by_key(|event| (event.target_frame, event.sequence));
516
+ let fade_versions = self.state.fade_versions.lock().ok();
517
+ queued
518
+ .into_iter()
519
+ .filter(|event| {
520
+ let Some(version) = event.fade_version else {
521
+ return true;
522
+ };
523
+ event.id.is_some_and(|id| {
524
+ fade_versions
525
+ .as_ref()
526
+ .and_then(|versions| versions.get(&id))
527
+ .is_some_and(|current| *current == version)
528
+ })
529
+ })
530
+ .map(|event| NativeAudioEvent {
531
+ owner_id: event.owner_id,
532
+ event: event.event.to_string(),
533
+ id: event.id,
534
+ message: event.message,
535
+ })
536
+ .collect()
537
+ }
538
+
539
+ #[napi]
540
+ pub fn diagnostics(&self) -> NativeAudioDiagnostics {
541
+ let (active_voices, queued_samples) = self
542
+ .state
543
+ .snapshots
544
+ .lock()
545
+ .map(|snapshots| {
546
+ let active = snapshots
547
+ .values()
548
+ .filter(|snapshot| snapshot.playing.load(Ordering::Acquire))
549
+ .count() as u32;
550
+ let queued = snapshots
551
+ .values()
552
+ .map(|snapshot| {
553
+ snapshot
554
+ .stream
555
+ .as_ref()
556
+ .map_or(0, |stream| stream.queued_samples())
557
+ + snapshot.current_chunk_samples.load(Ordering::Acquire)
558
+ })
559
+ .sum::<usize>();
560
+ (active, queued)
561
+ })
562
+ .unwrap_or((0, 0));
563
+ NativeAudioDiagnostics {
564
+ active_voices,
565
+ queued_ms: queued_samples as f64 * 1000.0
566
+ / (self.state.sample_rate as f64 * CHANNELS as f64),
567
+ underruns: self.state.underruns.load(Ordering::Relaxed),
568
+ }
569
+ }
570
+
571
+ #[napi]
572
+ pub fn stop_all(&self) {
573
+ if let Ok(mut snapshots) = self.state.snapshots.lock() {
574
+ for snapshot in snapshots.values() {
575
+ SharedState::stop_snapshot(snapshot);
576
+ }
577
+ snapshots.clear();
578
+ }
579
+ self.state.commands.push(MixerCommand::StopAll);
580
+ }
581
+
582
+ #[napi]
583
+ pub fn shutdown(&self) {
584
+ self.state.shutdown.store(true, Ordering::Release);
585
+ self.stop_all();
586
+ if let Ok(mut stream) = self.stream.lock() {
587
+ stream.take();
588
+ }
589
+ if let Ok(mut cache) = self.state.cache.lock() {
590
+ cache.clear();
591
+ }
592
+ }
593
+
594
+ fn remove_snapshots(&self, owner_id: u32, id: Option<u32>) {
595
+ if let Ok(mut snapshots) = self.state.snapshots.lock() {
596
+ let ids: Vec<u32> = snapshots
597
+ .iter()
598
+ .filter(|(voice_id, snapshot)| {
599
+ snapshot.owner_id == owner_id && id.is_none_or(|id| id == **voice_id)
600
+ })
601
+ .map(|(id, _)| *id)
602
+ .collect();
603
+ for id in ids {
604
+ if let Some(snapshot) = snapshots.remove(&id) {
605
+ SharedState::stop_snapshot(&snapshot);
606
+ }
607
+ }
608
+ }
609
+ }
610
+ }
611
+
612
+ impl Drop for NativeAudioEngine {
613
+ fn drop(&mut self) {
614
+ self.state.shutdown.store(true, Ordering::Release);
615
+ if let Ok(snapshots) = self.state.snapshots.lock() {
616
+ for snapshot in snapshots.values() {
617
+ SharedState::stop_snapshot(snapshot);
618
+ }
619
+ }
620
+ }
621
+ }
622
+
623
+ impl Mixer {
624
+ fn apply_commands(&mut self, state: &SharedState) {
625
+ while let Some(command) = state.commands.pop() {
626
+ match command {
627
+ MixerCommand::AddVoice(voice) => {
628
+ self.voices.insert(voice.id, voice);
629
+ }
630
+ MixerCommand::AttachStatic { id, samples } => {
631
+ if let Some(voice) = self.voices.get_mut(&id) {
632
+ voice.source = VoiceSource::Static(samples);
633
+ voice.play_announced = true;
634
+ state.queue_event(voice.owner_id, "play", Some(id), None, None);
635
+ }
636
+ }
637
+ MixerCommand::Control(options) => self.apply_control(state, options),
638
+ MixerCommand::RemoveOwner(owner_id) => {
639
+ self.voices.retain(|_, voice| voice.owner_id != owner_id);
640
+ }
641
+ MixerCommand::StopAll => {
642
+ self.voices.clear();
643
+ }
644
+ }
645
+ }
646
+ }
647
+
648
+ fn apply_control(&mut self, state: &SharedState, options: NativeCommandOptions) {
649
+ if options.command == "stop" {
650
+ self.voices.retain(|id, voice| {
651
+ let matches = voice.owner_id == options.owner_id
652
+ && options.id.is_none_or(|requested| requested == *id);
653
+ if matches {
654
+ state.queue_event(voice.owner_id, "stop", Some(*id), None, None);
655
+ }
656
+ !matches
657
+ });
658
+ return;
659
+ }
660
+
661
+ for (id, voice) in &mut self.voices {
662
+ if voice.owner_id != options.owner_id
663
+ || options.id.is_some_and(|requested| requested != *id)
664
+ {
665
+ continue;
666
+ }
667
+ Self::apply_voice_control(state, *id, voice, &options);
668
+ }
669
+ }
670
+
671
+ fn apply_voice_control(
672
+ state: &SharedState,
673
+ id: u32,
674
+ voice: &mut Voice,
675
+ options: &NativeCommandOptions,
676
+ ) {
677
+ match options.command.as_str() {
678
+ "play" => {
679
+ voice.playing = true;
680
+ voice.snapshot.playing.store(true, Ordering::Release);
681
+ state.queue_event(voice.owner_id, "play", Some(id), None, None);
682
+ }
683
+ "pause" => {
684
+ voice.playing = false;
685
+ voice.snapshot.playing.store(false, Ordering::Release);
686
+ state.queue_event(voice.owner_id, "pause", Some(id), None, None);
687
+ }
688
+ "volume" => {
689
+ voice.volume = options.value.unwrap_or(1.0) as f32;
690
+ voice.fade = None;
691
+ voice
692
+ .snapshot
693
+ .volume_bits
694
+ .store(voice.volume.to_bits(), Ordering::Release);
695
+ state.queue_event(voice.owner_id, "volume", Some(id), None, None);
696
+ }
697
+ "mute" => {
698
+ voice.muted = options.bool_value.unwrap_or(false);
699
+ state.queue_event(voice.owner_id, "mute", Some(id), None, None);
700
+ }
701
+ "loop" => voice.looped = options.bool_value.unwrap_or(false),
702
+ "seek" => {
703
+ let seconds = options.value.unwrap_or(0.0);
704
+ voice.position_frames = seconds * state.sample_rate as f64;
705
+ voice.rendered_frames = (seconds * state.sample_rate as f64) as u64;
706
+ voice.fade = None;
707
+ state.queue_event(voice.owner_id, "seek", Some(id), None, None);
708
+ }
709
+ "fade" => {
710
+ voice.fade = Some(Fade {
711
+ from: voice.volume,
712
+ to: options.to.unwrap_or(1.0) as f32,
713
+ start_frame: voice.rendered_frames,
714
+ duration_frames: (options.duration_ms.unwrap_or(0.0) * state.sample_rate as f64
715
+ / 1000.0) as u64,
716
+ version: options.fade_version.unwrap_or(0),
717
+ });
718
+ }
719
+ _ => {}
720
+ }
721
+ }
722
+ }
723
+
724
+ enum VoiceFrame {
725
+ Sample(f32, f32, bool),
726
+ Pending,
727
+ Starved,
728
+ }
729
+
730
+ fn build_stream<T>(
731
+ device: &cpal::Device,
732
+ config: &cpal::StreamConfig,
733
+ state: Arc<SharedState>,
734
+ error_callback: impl FnMut(cpal::StreamError) + Send + 'static,
735
+ ) -> Result<Stream>
736
+ where
737
+ T: SizedSample + FromSample<f32>,
738
+ {
739
+ let mut mixer = Mixer::default();
740
+ device
741
+ .build_output_stream(
742
+ config,
743
+ move |output: &mut [T], _| render_output(output, &state, &mut mixer),
744
+ error_callback,
745
+ None,
746
+ )
747
+ .map_err(|error| Error::from_reason(format!("Cannot open WASAPI output: {error}")))
748
+ }
749
+
750
+ fn render_output<T>(output: &mut [T], state: &SharedState, mixer: &mut Mixer)
751
+ where
752
+ T: SizedSample + FromSample<f32>,
753
+ {
754
+ let channels = state.output_channels;
755
+ if state.shutdown.load(Ordering::Acquire) {
756
+ output.fill(T::from_sample(0.0));
757
+ return;
758
+ }
759
+ mixer.apply_commands(state);
760
+ let global_volume = f32::from_bits(state.global_volume_bits.load(Ordering::Acquire));
761
+ let global_muted = state.global_muted.load(Ordering::Acquire);
762
+ let mut starved = false;
763
+
764
+ for frame in output.chunks_mut(channels) {
765
+ let mut left = 0.0_f32;
766
+ let mut right = 0.0_f32;
767
+ for voice in mixer.voices.values_mut() {
768
+ if !voice.playing {
769
+ continue;
770
+ }
771
+ if !voice.play_announced
772
+ && matches!(
773
+ &voice.source,
774
+ VoiceSource::Streaming { buffer, .. }
775
+ if buffer.ready.load(Ordering::Acquire)
776
+ )
777
+ {
778
+ voice.play_announced = true;
779
+ state.queue_event(voice.owner_id, "play", Some(voice.id), None, None);
780
+ }
781
+ let (voice_left, voice_right, source_finished) = match next_voice_frame(voice) {
782
+ VoiceFrame::Sample(left, right, finished) => (left, right, finished),
783
+ VoiceFrame::Pending => continue,
784
+ VoiceFrame::Starved => {
785
+ starved = true;
786
+ continue;
787
+ }
788
+ };
789
+ update_fade(voice, state);
790
+ let gain = if global_muted || voice.muted {
791
+ 0.0
792
+ } else {
793
+ global_volume * voice.volume
794
+ };
795
+ left += voice_left * gain;
796
+ right += voice_right * gain;
797
+ voice.rendered_frames += 1;
798
+ if source_finished {
799
+ state.queue_event(voice.owner_id, "end", Some(voice.id), None, None);
800
+ if voice.looped {
801
+ voice.position_frames = 0.0;
802
+ } else {
803
+ voice.finished = true;
804
+ }
805
+ }
806
+ }
807
+ left = left.clamp(-1.0, 1.0);
808
+ right = right.clamp(-1.0, 1.0);
809
+ if let Some(sample) = frame.get_mut(0) {
810
+ *sample = T::from_sample(left);
811
+ }
812
+ if let Some(sample) = frame.get_mut(1) {
813
+ *sample = T::from_sample(right);
814
+ }
815
+ for sample in frame.iter_mut().skip(2) {
816
+ *sample = T::from_sample((left + right) * 0.5);
817
+ }
818
+ }
819
+ if starved {
820
+ state.underruns.fetch_add(1, Ordering::Relaxed);
821
+ }
822
+ state
823
+ .audible_frame
824
+ .fetch_add((output.len() / channels) as u64, Ordering::Release);
825
+ for voice in mixer.voices.values() {
826
+ update_snapshot(voice, state.sample_rate);
827
+ }
828
+ mixer.voices.retain(|id, voice| {
829
+ if voice.finished {
830
+ voice.snapshot.playing.store(false, Ordering::Release);
831
+ state.completed_voices.push(*id);
832
+ false
833
+ } else {
834
+ true
835
+ }
836
+ });
837
+ }
838
+
839
+ fn next_voice_frame(voice: &mut Voice) -> VoiceFrame {
840
+ match &mut voice.source {
841
+ VoiceSource::Loading => VoiceFrame::Pending,
842
+ VoiceSource::Static(samples) => {
843
+ let frame_count = samples.len() / CHANNELS;
844
+ if frame_count == 0 {
845
+ return VoiceFrame::Sample(0.0, 0.0, true);
846
+ }
847
+ let index = voice.position_frames.floor() as usize;
848
+ if index >= frame_count {
849
+ return VoiceFrame::Sample(0.0, 0.0, true);
850
+ }
851
+ let left = samples[index * CHANNELS];
852
+ let right = samples[index * CHANNELS + 1];
853
+ voice.position_frames += voice.playback_rate;
854
+ VoiceFrame::Sample(left, right, voice.position_frames >= frame_count as f64)
855
+ }
856
+ VoiceSource::Streaming {
857
+ buffer,
858
+ chunk,
859
+ offset,
860
+ } => {
861
+ if !buffer.ready.load(Ordering::Acquire) {
862
+ return VoiceFrame::Pending;
863
+ }
864
+ if chunk.is_none() {
865
+ *chunk = buffer.pop();
866
+ *offset = 0;
867
+ }
868
+ let Some(samples) = chunk.as_ref() else {
869
+ return if buffer.ended.load(Ordering::Acquire) {
870
+ VoiceFrame::Sample(0.0, 0.0, true)
871
+ } else {
872
+ VoiceFrame::Starved
873
+ };
874
+ };
875
+ let left = samples[*offset];
876
+ let right = samples[*offset + 1];
877
+ *offset += CHANNELS;
878
+ voice.position_frames += 1.0;
879
+ if *offset >= samples.len() {
880
+ *chunk = None;
881
+ *offset = 0;
882
+ }
883
+ VoiceFrame::Sample(left, right, false)
884
+ }
885
+ }
886
+ }
887
+
888
+ fn update_snapshot(voice: &Voice, sample_rate: u32) {
889
+ let media_rate = if matches!(voice.source, VoiceSource::Streaming { .. }) {
890
+ voice.playback_rate
891
+ } else {
892
+ 1.0
893
+ };
894
+ let time = voice.offset_seconds + voice.position_frames * media_rate / sample_rate as f64;
895
+ voice
896
+ .snapshot
897
+ .time_bits
898
+ .store(time.to_bits(), Ordering::Release);
899
+ voice
900
+ .snapshot
901
+ .volume_bits
902
+ .store(voice.volume.to_bits(), Ordering::Release);
903
+ let remaining = match &voice.source {
904
+ VoiceSource::Streaming { chunk, offset, .. } => chunk
905
+ .as_ref()
906
+ .map_or(0, |samples| samples.len().saturating_sub(*offset)),
907
+ _ => 0,
908
+ };
909
+ voice
910
+ .snapshot
911
+ .current_chunk_samples
912
+ .store(remaining, Ordering::Release);
913
+ }
914
+
915
+ fn update_fade(voice: &mut Voice, state: &SharedState) {
916
+ let Some(fade) = &voice.fade else { return };
917
+ let elapsed = voice.rendered_frames.saturating_sub(fade.start_frame) + 1;
918
+ let progress = if fade.duration_frames == 0 {
919
+ 1.0
920
+ } else {
921
+ (elapsed as f32 / fade.duration_frames as f32).min(1.0)
922
+ };
923
+ voice.volume = fade.from + (fade.to - fade.from) * progress;
924
+ if progress >= 1.0 {
925
+ let version = fade.version;
926
+ voice.fade = None;
927
+ state.queue_event(voice.owner_id, "fade", Some(voice.id), None, Some(version));
928
+ }
929
+ }
930
+
931
+ fn start_static_decode(
932
+ state: Arc<SharedState>,
933
+ options: NativeVoiceOptions,
934
+ preload: bool,
935
+ request_id: Option<u32>,
936
+ ) {
937
+ thread::spawn(move || {
938
+ let key = cache_key(&options);
939
+ let cached = state
940
+ .cache
941
+ .lock()
942
+ .ok()
943
+ .and_then(|cache| cache.get(&key).cloned());
944
+ let decoded = match cached {
945
+ Some(samples) => Ok(samples),
946
+ None => state
947
+ .cache
948
+ .lock()
949
+ .ok()
950
+ .and_then(|cache| cache.get(&full_source_cache_key(&options.source)).cloned())
951
+ .map_or_else(
952
+ || decode_static(&options, state.sample_rate).map(Arc::new),
953
+ |samples| slice_cached_samples(&samples, &options, state.sample_rate),
954
+ ),
955
+ };
956
+ match decoded {
957
+ Ok(samples) => {
958
+ if let Ok(mut cache) = state.cache.lock() {
959
+ cache.insert(key, Arc::clone(&samples));
960
+ }
961
+ if preload {
962
+ state.queue_event(options.owner_id, "load", request_id, None, None);
963
+ } else {
964
+ state.commands.push(MixerCommand::AttachStatic {
965
+ id: options.id,
966
+ samples,
967
+ });
968
+ }
969
+ }
970
+ Err(message) => {
971
+ if !preload {
972
+ if let Ok(mut snapshots) = state.snapshots.lock() {
973
+ snapshots.remove(&options.id);
974
+ }
975
+ state
976
+ .commands
977
+ .push(MixerCommand::Control(NativeCommandOptions {
978
+ owner_id: options.owner_id,
979
+ command: "stop".to_string(),
980
+ id: Some(options.id),
981
+ value: None,
982
+ bool_value: None,
983
+ from: None,
984
+ to: None,
985
+ duration_ms: None,
986
+ fade_version: None,
987
+ }));
988
+ }
989
+ state.queue_event(
990
+ options.owner_id,
991
+ if preload { "loaderror" } else { "playerror" },
992
+ request_id.or(Some(options.id)),
993
+ Some(message),
994
+ None,
995
+ );
996
+ }
997
+ }
998
+ });
999
+ }
1000
+
1001
+ fn start_streaming_decode(
1002
+ state: Arc<SharedState>,
1003
+ options: NativeVoiceOptions,
1004
+ stream: Arc<StreamingBuffer>,
1005
+ ) {
1006
+ thread::spawn(move || {
1007
+ if let Err(message) = decode_stream(&state, &options, &stream) {
1008
+ if !stream.stopped.load(Ordering::Acquire) {
1009
+ if let Ok(mut snapshots) = state.snapshots.lock() {
1010
+ snapshots.remove(&options.id);
1011
+ }
1012
+ state
1013
+ .commands
1014
+ .push(MixerCommand::Control(NativeCommandOptions {
1015
+ owner_id: options.owner_id,
1016
+ command: "stop".to_string(),
1017
+ id: Some(options.id),
1018
+ value: None,
1019
+ bool_value: None,
1020
+ from: None,
1021
+ to: None,
1022
+ duration_ms: None,
1023
+ fade_version: None,
1024
+ }));
1025
+ state.queue_event(
1026
+ options.owner_id,
1027
+ "playerror",
1028
+ Some(options.id),
1029
+ Some(message),
1030
+ None,
1031
+ );
1032
+ }
1033
+ }
1034
+ stream.ended.store(true, Ordering::Release);
1035
+ });
1036
+ }
1037
+
1038
+ fn decode_static(
1039
+ options: &NativeVoiceOptions,
1040
+ sample_rate: u32,
1041
+ ) -> std::result::Result<Vec<f32>, String> {
1042
+ let mut command = ffmpeg_command(options, sample_rate, false)?;
1043
+ let output = command
1044
+ .output()
1045
+ .map_err(|error| format!("Cannot start FFmpeg audio decoder: {error}"))?;
1046
+ if !output.status.success() {
1047
+ return Err(redact_credentials(
1048
+ String::from_utf8_lossy(&output.stderr).trim(),
1049
+ ));
1050
+ }
1051
+ bytes_to_samples(&output.stdout)
1052
+ }
1053
+
1054
+ fn decode_stream(
1055
+ state: &SharedState,
1056
+ options: &NativeVoiceOptions,
1057
+ stream: &StreamingBuffer,
1058
+ ) -> std::result::Result<(), String> {
1059
+ let mut child = ffmpeg_command(options, state.sample_rate, true)?
1060
+ .spawn()
1061
+ .map_err(|error| format!("Cannot start FFmpeg audio stream: {error}"))?;
1062
+ let mut stdout = child
1063
+ .stdout
1064
+ .take()
1065
+ .ok_or_else(|| "FFmpeg audio stdout is unavailable".to_string())?;
1066
+ let mut stderr = child
1067
+ .stderr
1068
+ .take()
1069
+ .ok_or_else(|| "FFmpeg audio stderr is unavailable".to_string())?;
1070
+ if let Ok(mut stored_child) = stream.child.lock() {
1071
+ *stored_child = Some(child);
1072
+ }
1073
+ let mut byte_carry = Vec::new();
1074
+ let mut sample_carry = Vec::new();
1075
+ let mut bytes = vec![0_u8; 32 * 1024];
1076
+ loop {
1077
+ if stream.stopped.load(Ordering::Acquire) || state.shutdown.load(Ordering::Acquire) {
1078
+ return Ok(());
1079
+ }
1080
+ let read = stdout.read(&mut bytes).map_err(|error| error.to_string())?;
1081
+ if read == 0 {
1082
+ break;
1083
+ }
1084
+ byte_carry.extend_from_slice(&bytes[..read]);
1085
+ let aligned = byte_carry.len() - byte_carry.len() % 4;
1086
+ for chunk in byte_carry[..aligned].chunks_exact(4) {
1087
+ sample_carry.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
1088
+ }
1089
+ byte_carry.drain(..aligned);
1090
+ while sample_carry.len() >= STREAM_CHUNK_SAMPLES {
1091
+ let remainder = sample_carry.split_off(STREAM_CHUNK_SAMPLES);
1092
+ let chunk = std::mem::replace(&mut sample_carry, remainder).into_boxed_slice();
1093
+ if !stream.push(chunk) {
1094
+ return Ok(());
1095
+ }
1096
+ }
1097
+ }
1098
+ let mut child = stream
1099
+ .child
1100
+ .lock()
1101
+ .map_err(|_| "FFmpeg process state is poisoned".to_string())?
1102
+ .take()
1103
+ .ok_or_else(|| "FFmpeg process disappeared".to_string())?;
1104
+ let status = child.wait().map_err(|error| error.to_string())?;
1105
+ if !status.success() {
1106
+ let mut error = String::new();
1107
+ let _ = stderr.read_to_string(&mut error);
1108
+ return Err(redact_credentials(error.trim()));
1109
+ }
1110
+ if !sample_carry.is_empty() {
1111
+ sample_carry.resize(STREAM_CHUNK_SAMPLES, 0.0);
1112
+ if !stream.push(sample_carry.into_boxed_slice()) {
1113
+ return Ok(());
1114
+ }
1115
+ }
1116
+ if stream.produced_chunks.load(Ordering::Acquire) == 0 {
1117
+ return Err("Audio stream contains no decodable samples".to_string());
1118
+ }
1119
+ stream.ready.store(true, Ordering::Release);
1120
+ Ok(())
1121
+ }
1122
+
1123
+ fn ffmpeg_command(
1124
+ options: &NativeVoiceOptions,
1125
+ sample_rate: u32,
1126
+ streaming: bool,
1127
+ ) -> std::result::Result<Command, String> {
1128
+ let mut command = Command::new(&options.ffmpeg_path);
1129
+ command.args(["-hide_banner", "-loglevel", "error", "-nostdin"]);
1130
+ if options.offset_seconds > 0.0 {
1131
+ command.args(["-ss", &options.offset_seconds.to_string()]);
1132
+ }
1133
+ command.args(&options.input_args);
1134
+ command.args(["-i", &options.source, "-vn"]);
1135
+ if let Some(duration) = options.duration_seconds {
1136
+ command.args(["-t", &duration.to_string()]);
1137
+ }
1138
+ if streaming && (options.playback_rate - 1.0).abs() > f64::EPSILON {
1139
+ command.args(["-af", &build_atempo_filter(options.playback_rate)?]);
1140
+ }
1141
+ command.args(&options.output_args);
1142
+ command.args([
1143
+ "-ac",
1144
+ "2",
1145
+ "-ar",
1146
+ &sample_rate.to_string(),
1147
+ "-f",
1148
+ "f32le",
1149
+ "pipe:1",
1150
+ ]);
1151
+ command
1152
+ .stdin(Stdio::null())
1153
+ .stdout(Stdio::piped())
1154
+ .stderr(Stdio::piped());
1155
+ #[cfg(target_os = "windows")]
1156
+ {
1157
+ use std::os::windows::process::CommandExt;
1158
+ command.creation_flags(0x0800_0000);
1159
+ }
1160
+ Ok(command)
1161
+ }
1162
+
1163
+ fn build_atempo_filter(rate: f64) -> std::result::Result<String, String> {
1164
+ if !rate.is_finite() || rate <= 0.0 {
1165
+ return Err("Audio playback rate must be positive and finite".to_string());
1166
+ }
1167
+ let mut factors = Vec::new();
1168
+ let mut remaining = rate;
1169
+ while remaining < 0.5 {
1170
+ factors.push(0.5);
1171
+ remaining /= 0.5;
1172
+ }
1173
+ while remaining > 2.0 {
1174
+ factors.push(2.0);
1175
+ remaining /= 2.0;
1176
+ }
1177
+ factors.push(remaining);
1178
+ Ok(factors
1179
+ .into_iter()
1180
+ .map(|factor| format!("atempo={factor}"))
1181
+ .collect::<Vec<_>>()
1182
+ .join(","))
1183
+ }
1184
+
1185
+ fn bytes_to_samples(bytes: &[u8]) -> std::result::Result<Vec<f32>, String> {
1186
+ if bytes.is_empty() {
1187
+ return Err("Audio stream contains no decodable samples".to_string());
1188
+ }
1189
+ Ok(bytes
1190
+ .chunks_exact(4)
1191
+ .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
1192
+ .collect())
1193
+ }
1194
+
1195
+ fn cache_key(options: &NativeVoiceOptions) -> String {
1196
+ format!(
1197
+ "{}\0{}\0{}",
1198
+ options.source,
1199
+ options.offset_seconds,
1200
+ options
1201
+ .duration_seconds
1202
+ .map_or_else(|| "end".to_string(), |value| value.to_string())
1203
+ )
1204
+ }
1205
+
1206
+ fn full_source_cache_key(source: &str) -> String {
1207
+ format!("{source}\0{}\0end", 0.0)
1208
+ }
1209
+
1210
+ fn slice_cached_samples(
1211
+ samples: &[f32],
1212
+ options: &NativeVoiceOptions,
1213
+ sample_rate: u32,
1214
+ ) -> std::result::Result<Arc<Vec<f32>>, String> {
1215
+ let start = (options.offset_seconds * sample_rate as f64) as usize * CHANNELS;
1216
+ let requested = options
1217
+ .duration_seconds
1218
+ .map(|duration| (duration * sample_rate as f64) as usize * CHANNELS)
1219
+ .unwrap_or_else(|| samples.len().saturating_sub(start));
1220
+ let end = start.saturating_add(requested).min(samples.len());
1221
+ if start >= end {
1222
+ return Err("Audio sprite is outside the decoded source".to_string());
1223
+ }
1224
+ Ok(Arc::new(samples[start..end].to_vec()))
1225
+ }
1226
+
1227
+ fn validate_voice_options(options: &NativeVoiceOptions) -> Result<()> {
1228
+ if !options.offset_seconds.is_finite() || options.offset_seconds < 0.0 {
1229
+ return Err(Error::from_reason(
1230
+ "Audio offset must be non-negative and finite",
1231
+ ));
1232
+ }
1233
+ if options
1234
+ .duration_seconds
1235
+ .is_some_and(|value| !value.is_finite() || value <= 0.0)
1236
+ {
1237
+ return Err(Error::from_reason(
1238
+ "Audio duration must be positive and finite",
1239
+ ));
1240
+ }
1241
+ required_unit_value(Some(options.volume), "volume")?;
1242
+ if !options.playback_rate.is_finite() || options.playback_rate <= 0.0 {
1243
+ return Err(Error::from_reason(
1244
+ "Audio playback rate must be positive and finite",
1245
+ ));
1246
+ }
1247
+ Ok(())
1248
+ }
1249
+
1250
+ fn required_unit_value(value: Option<f64>, name: &str) -> Result<f64> {
1251
+ let value = value.ok_or_else(|| Error::from_reason(format!("Missing audio {name}")))?;
1252
+ if !value.is_finite() || !(0.0..=1.0).contains(&value) {
1253
+ return Err(Error::from_reason(format!(
1254
+ "Audio {name} must be between 0 and 1"
1255
+ )));
1256
+ }
1257
+ Ok(value)
1258
+ }
1259
+
1260
+ fn redact_credentials(message: &str) -> String {
1261
+ let mut result = message.to_string();
1262
+ let mut start = 0;
1263
+ while let Some(scheme) = result[start..].find("://") {
1264
+ let authority_start = start + scheme + 3;
1265
+ let authority_end = result[authority_start..]
1266
+ .find(['/', ' ', '\n', '\r'])
1267
+ .map_or(result.len(), |offset| authority_start + offset);
1268
+ if let Some(at) = result[authority_start..authority_end].find('@') {
1269
+ let credential_end = authority_start + at;
1270
+ result.replace_range(authority_start..credential_end, "***:***");
1271
+ start = authority_start + 8;
1272
+ } else {
1273
+ start = authority_end;
1274
+ }
1275
+ }
1276
+ if result.trim().is_empty() {
1277
+ "FFmpeg audio decoder failed".to_string()
1278
+ } else {
1279
+ result
1280
+ }
1281
+ }
1282
+
1283
+ #[cfg(test)]
1284
+ mod tests {
1285
+ use super::*;
1286
+
1287
+ fn test_state() -> Arc<SharedState> {
1288
+ Arc::new(SharedState {
1289
+ sample_rate: 48_000,
1290
+ output_channels: 2,
1291
+ commands: SegQueue::new(),
1292
+ snapshots: Mutex::new(HashMap::new()),
1293
+ completed_voices: SegQueue::new(),
1294
+ cache: Mutex::new(HashMap::new()),
1295
+ events: SegQueue::new(),
1296
+ event_notifier: None,
1297
+ event_sequence: AtomicU64::new(0),
1298
+ audible_frame: AtomicU64::new(0),
1299
+ global_volume_bits: AtomicU32::new(1.0_f32.to_bits()),
1300
+ global_muted: AtomicBool::new(false),
1301
+ fade_versions: Mutex::new(HashMap::new()),
1302
+ underruns: AtomicU32::new(0),
1303
+ shutdown: AtomicBool::new(false),
1304
+ })
1305
+ }
1306
+
1307
+ fn static_voice(id: u32, samples: Vec<f32>) -> Voice {
1308
+ let snapshot = Arc::new(VoiceSnapshot {
1309
+ owner_id: 1,
1310
+ time_bits: AtomicU64::new(0.0_f64.to_bits()),
1311
+ volume_bits: AtomicU32::new(1.0_f32.to_bits()),
1312
+ playing: AtomicBool::new(true),
1313
+ stream: None,
1314
+ current_chunk_samples: AtomicUsize::new(0),
1315
+ });
1316
+ Voice {
1317
+ owner_id: 1,
1318
+ id,
1319
+ source: VoiceSource::Static(Arc::new(samples)),
1320
+ offset_seconds: 0.0,
1321
+ position_frames: 0.0,
1322
+ rendered_frames: 0,
1323
+ volume: 1.0,
1324
+ muted: false,
1325
+ looped: false,
1326
+ playing: true,
1327
+ playback_rate: 1.0,
1328
+ fade: None,
1329
+ play_announced: true,
1330
+ finished: false,
1331
+ snapshot,
1332
+ }
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 bounded_stream_buffer_has_two_seconds_of_capacity() {
1343
+ let stream = StreamingBuffer::new(48_000);
1344
+ assert_eq!(stream.chunks.capacity(), 47);
1345
+ }
1346
+
1347
+ #[test]
1348
+ fn overlapping_voices_are_mixed_without_clipping() {
1349
+ let state = test_state();
1350
+ let mut mixer = Mixer::default();
1351
+ mixer
1352
+ .voices
1353
+ .insert(1, static_voice(1, vec![0.25, 0.25, 0.0, 0.0]));
1354
+ mixer
1355
+ .voices
1356
+ .insert(2, static_voice(2, vec![0.5, -0.5, 0.0, 0.0]));
1357
+
1358
+ let mut output = [0.0_f32; 2];
1359
+ render_output(&mut output, &state, &mut mixer);
1360
+ assert_eq!(output, [0.75, -0.25]);
1361
+ }
1362
+
1363
+ #[test]
1364
+ fn looping_voice_emits_ordered_end_events_and_stays_active() {
1365
+ let state = test_state();
1366
+ let mut mixer = Mixer::default();
1367
+ let mut voice = static_voice(7, vec![0.1, 0.1]);
1368
+ voice.looped = true;
1369
+ mixer.voices.insert(7, voice);
1370
+
1371
+ render_output(&mut [0.0_f32; 6], &state, &mut mixer);
1372
+ assert!(mixer.voices.contains_key(&7));
1373
+ let mut events = Vec::new();
1374
+ while let Some(event) = state.events.pop() {
1375
+ events.push((event.target_frame, event.sequence, event.event));
1376
+ }
1377
+ assert_eq!(events.len(), 3);
1378
+ assert!(events.windows(2).all(|pair| pair[0] < pair[1]));
1379
+ }
1380
+
1381
+ #[test]
1382
+ fn fade_uses_rendered_sample_clock_and_fires_once() {
1383
+ let state = test_state();
1384
+ let mut mixer = Mixer::default();
1385
+ let mut voice = static_voice(3, vec![1.0; 16]);
1386
+ voice.fade = Some(Fade {
1387
+ from: 1.0,
1388
+ to: 0.0,
1389
+ start_frame: 0,
1390
+ duration_frames: 4,
1391
+ version: 9,
1392
+ });
1393
+ state.fade_versions.lock().unwrap().insert(3, 9);
1394
+ mixer.voices.insert(3, voice);
1395
+
1396
+ render_output(&mut [0.0_f32; 8], &state, &mut mixer);
1397
+ assert_eq!(mixer.voices.get(&3).unwrap().volume, 0.0);
1398
+ assert_eq!(state.events.len(), 1);
1399
+ }
1400
+
1401
+ #[test]
1402
+ fn streaming_underrun_outputs_silence_without_advancing_voice_clock() {
1403
+ let state = test_state();
1404
+ let mut mixer = Mixer::default();
1405
+ let stream = Arc::new(StreamingBuffer::new(48_000));
1406
+ stream.ready.store(true, Ordering::Release);
1407
+ let mut voice = static_voice(4, Vec::new());
1408
+ voice.snapshot = Arc::new(VoiceSnapshot {
1409
+ owner_id: 1,
1410
+ time_bits: AtomicU64::new(0.0_f64.to_bits()),
1411
+ volume_bits: AtomicU32::new(1.0_f32.to_bits()),
1412
+ playing: AtomicBool::new(true),
1413
+ stream: Some(Arc::clone(&stream)),
1414
+ current_chunk_samples: AtomicUsize::new(0),
1415
+ });
1416
+ voice.source = VoiceSource::Streaming {
1417
+ buffer: stream,
1418
+ chunk: None,
1419
+ offset: 0,
1420
+ };
1421
+ mixer.voices.insert(4, voice);
1422
+
1423
+ let mut output = [1.0_f32; 4];
1424
+ render_output(&mut output, &state, &mut mixer);
1425
+ assert_eq!(output, [0.0; 4]);
1426
+ assert_eq!(mixer.voices.get(&4).unwrap().position_frames, 0.0);
1427
+ assert_eq!(state.underruns.load(Ordering::Relaxed), 1);
1428
+ }
1429
+
1430
+ #[test]
1431
+ fn render_does_not_touch_js_snapshot_or_cache_locks() {
1432
+ let state = test_state();
1433
+ let mut mixer = Mixer::default();
1434
+ mixer
1435
+ .voices
1436
+ .insert(1, static_voice(1, vec![0.25, 0.25, 0.0, 0.0]));
1437
+ let _snapshots = state.snapshots.lock().unwrap();
1438
+ let _cache = state.cache.lock().unwrap();
1439
+ let mut output = [0.0_f32; 2];
1440
+ render_output(&mut output, &state, &mut mixer);
1441
+ assert_eq!(output, [0.25, 0.25]);
1442
+ }
1443
+
1444
+ #[test]
1445
+ fn sustained_streaming_stays_bounded_without_starvation() {
1446
+ let state = test_state();
1447
+ let stream = Arc::new(StreamingBuffer::new(48_000));
1448
+ let producer_stream = Arc::clone(&stream);
1449
+ let producer = thread::spawn(move || {
1450
+ for _ in 0..(30 * 48_000 / STREAM_CHUNK_FRAMES) {
1451
+ assert!(producer_stream.push(vec![0.1; STREAM_CHUNK_SAMPLES].into_boxed_slice()));
1452
+ }
1453
+ producer_stream.ended.store(true, Ordering::Release);
1454
+ });
1455
+ while stream.chunks.len() < STREAM_START_CHUNKS {
1456
+ thread::yield_now();
1457
+ }
1458
+ let mut voice = static_voice(5, Vec::new());
1459
+ voice.snapshot = Arc::new(VoiceSnapshot {
1460
+ owner_id: 1,
1461
+ time_bits: AtomicU64::new(0.0_f64.to_bits()),
1462
+ volume_bits: AtomicU32::new(1.0_f32.to_bits()),
1463
+ playing: AtomicBool::new(true),
1464
+ stream: Some(Arc::clone(&stream)),
1465
+ current_chunk_samples: AtomicUsize::new(0),
1466
+ });
1467
+ voice.source = VoiceSource::Streaming {
1468
+ buffer: Arc::clone(&stream),
1469
+ chunk: None,
1470
+ offset: 0,
1471
+ };
1472
+ voice.play_announced = false;
1473
+ let mut mixer = Mixer::default();
1474
+ mixer.voices.insert(5, voice);
1475
+ let mut output = vec![0.0_f32; STREAM_CHUNK_SAMPLES];
1476
+ for _ in 0..(30 * 48_000 / STREAM_CHUNK_FRAMES) {
1477
+ render_output(&mut output, &state, &mut mixer);
1478
+ assert!(stream.chunks.len() <= stream.chunks.capacity());
1479
+ }
1480
+ producer.join().unwrap();
1481
+ assert_eq!(state.underruns.load(Ordering::Relaxed), 0);
1482
+ }
1483
+
1484
+ #[test]
1485
+ fn streaming_teardown_wakes_backpressure_waiters() {
1486
+ let stream = StreamingBuffer::new(48_000);
1487
+ stream.stop();
1488
+ assert!(stream.stopped.load(Ordering::Acquire));
1489
+ }
1490
+
1491
+ #[test]
1492
+ fn credentials_are_redacted_from_decoder_errors() {
1493
+ assert_eq!(
1494
+ redact_credentials("https://user:secret@example.test/audio.mp3 failed"),
1495
+ "https://***:***@example.test/audio.mp3 failed"
1496
+ );
1497
+ }
1498
+
1499
+ #[test]
1500
+ fn preloaded_source_is_sliced_for_sprite_playback() {
1501
+ let options = NativeVoiceOptions {
1502
+ owner_id: 1,
1503
+ id: 2,
1504
+ source: "atlas.wav".to_string(),
1505
+ ffmpeg_path: "ffmpeg".to_string(),
1506
+ offset_seconds: 0.25,
1507
+ duration_seconds: Some(0.5),
1508
+ volume: 1.0,
1509
+ muted: false,
1510
+ loop_: false,
1511
+ streaming: false,
1512
+ playback_rate: 1.0,
1513
+ input_args: Vec::new(),
1514
+ output_args: Vec::new(),
1515
+ };
1516
+ let samples = vec![0.0; 48_000 * CHANNELS];
1517
+ let sliced = slice_cached_samples(&samples, &options, 48_000).unwrap();
1518
+ assert_eq!(sliced.len(), 24_000 * CHANNELS);
1519
+ }
1520
+ }