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