@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.
- package/LICENSE +21 -0
- package/THIRD_PARTY_NOTICES.md +83 -0
- package/index.cjs +25 -0
- package/index.d.ts +11 -0
- package/native/audio/binding-path.js +16 -0
- package/native/audio/dist/win32-x64/native_audio.node +0 -0
- package/native/audio/package.json +7 -0
- package/native/audio/src/index.d.ts +63 -0
- package/native/audio/src/index.js +5 -0
- package/native/audio/src/lib.rs +1520 -0
- package/native/gpu/dist/win32-x64/d3dcompiler_47.dll +0 -0
- package/native/gpu/dist/win32-x64/pixi_native_gpu.node +0 -0
- package/native/gpu/package.json +24 -0
- package/native/gpu/src/binding-path.js +16 -0
- package/native/gpu/src/index.d.ts +2847 -0
- package/native/gpu/src/index.js +48 -0
- package/native/video/dist/win32-x64/FFMPEG_BUILD_INFO.txt +46 -0
- package/native/video/dist/win32-x64/FFMPEG_LICENSE.txt +502 -0
- package/native/video/dist/win32-x64/FFMPEG_SHA256SUMS +4 -0
- package/native/video/dist/win32-x64/ffmpeg.exe +0 -0
- package/native/video/dist/win32-x64/ffprobe.exe +0 -0
- package/native/video/dist/win32-x64/native_video.node +0 -0
- package/native/video/package.json +7 -0
- package/native/video/src/binding-path.js +26 -0
- package/native/video/src/index.d.ts +36 -0
- package/native/video/src/index.js +61 -0
- package/native/video/src/lib.rs +994 -0
- package/native/window/binding-path.js +11 -0
- package/native/window/dist/win32-x64/native_window.node +0 -0
- package/native/window/package.json +7 -0
- package/native/window/src/index.d.ts +17 -0
- package/native/window/src/index.js +32 -0
- package/native/window/src/lib.rs +308 -0
- package/package.json +41 -0
- package/third_party/ffmpeg-source-8.0-140fd653ae.tar.gz +0 -0
- 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
|
+
}
|