1
//! Microphone-capture widget (SUPER_PLAN_2 §4 P7) - a "dumb widget" with the
2
//! same architecture as the camera/screencap/video widgets, only the medium is
3
//! audio (no GL texture).
4
//!
5
//! `MicrophoneWidget::create(config).with_on_frame(data, cb).dom()` yields an
6
//! invisible node that, on `AfterMount`, starts a background capture thread.
7
//! Each captured [`AudioFrame`] flows through the writeback to the user's
8
//! `on_frame` hook (the backreference DI pattern), so app code can save,
9
//! process, or **send** the audio over the network (the azul-meet audio seam) -
10
//! all via the public API, no globals. The mic permission is the existing
11
//! `Capability::Microphone`.
12
//!
13
//! This tick uses a self-contained **test-tone** worker (a 440 Hz sine, no
14
//! platform deps); the real AVAudioEngine / AAudio / cpal capture worker
15
//! (dll-side) swaps in later.
16

            
17
use alloc::vec::Vec;
18

            
19
use azul_core::audio::{AudioConfig, AudioFrame};
20

            
21
use super::capture_common::{mic_backend, terminate_requested};
22
use azul_core::callbacks::Update;
23
use azul_core::dom::{ComponentEventFilter, DatasetMergeCallbackType, Dom, EventFilter};
24
use azul_core::refany::{OptionRefAny, RefAny};
25
use azul_core::task::{ThreadId, ThreadReceiver};
26
use azul_css::impl_option_inner; // for impl_widget_callback!'s impl_option!
27
use azul_css::F32Vec;
28

            
29
use crate::callbacks::{Callback, CallbackInfo, CallbackType};
30
use crate::thread::{
31
    Thread, ThreadCallback, ThreadReceiveMsg, ThreadSender, ThreadWriteBackMsg, WriteBackCallback,
32
};
33

            
34
// --- User hook: on_frame (backreference DI, FFI-exposed) ---
35

            
36
/// User hook fired once per captured audio chunk - the backreference DI pattern
37
/// (see `architecture.md`).
38
///
39
/// The widget's private writeback invokes it with each
40
/// [`AudioFrame`] so application code can save it, apply effects, or send it
41
/// over the network (azul-meet). Returns `Update` like any callback. Wired via
42
/// [`MicrophoneWidget::with_on_frame`].
43
pub type OnAudioFrameCallbackType = extern "C" fn(RefAny, CallbackInfo, AudioFrame) -> Update;
44
impl_widget_callback!(
45
    OnAudioFrame,
46
    OptionOnAudioFrame,
47
    OnAudioFrameCallback,
48
    OnAudioFrameCallbackType
49
);
50

            
51
// Host-invoker plumbing for managed-FFI bindings - see core/src/host_invoker.rs.
52
azul_core::impl_managed_callback! {
53
    wrapper:        OnAudioFrameCallback,
54
    info_ty:        CallbackInfo,
55
    return_ty:      Update,
56
    default_ret:    Update::DoNothing,
57
    invoker_static: ON_AUDIO_FRAME_INVOKER,
58
    invoker_ty:     AzOnAudioFrameCallbackInvoker,
59
    thunk_fn:       az_on_audio_frame_callback_thunk,
60
    setter_fn:      AzApp_setOnAudioFrameCallbackInvoker,
61
    from_handle_fn: AzOnAudioFrameCallback_createFromHostHandle,
62
    extra_args:     [ frame: AudioFrame ],
63
}
64

            
65
/// Invoke the optional `on_frame` hook with `frame`, returning the user's
66
/// `Update` (`DoNothing` when no hook is set).
67
7
fn invoke_on_audio_frame(
68
7
    hook: &OptionOnAudioFrame,
69
7
    info: &CallbackInfo,
70
7
    frame: AudioFrame,
71
7
) -> Update {
72
7
    match hook {
73
5
        OptionOnAudioFrame::Some(h) => (h.callback.cb)(h.refany.clone(), *info, frame),
74
2
        OptionOnAudioFrame::None => Update::DoNothing,
75
    }
76
7
}
77

            
78
/// Init data handed to the capture worker thread.
79
struct MicThreadInit {
80
    sample_rate: u32,
81
    channels: u16,
82
}
83

            
84
/// Live state for one microphone widget, carried across relayout by
85
/// [`merge_microphone_state`].
86
#[derive(Debug)]
87
pub struct MicrophoneWidgetState {
88
    /// The requested capture configuration (rate + channels).
89
    pub config: AudioConfig,
90
    /// `true` once the capture thread has been started.
91
    pub started: bool,
92
    /// Optional user hook invoked with each captured frame (save / effects /
93
    /// send). Re-set on every fresh build (see [`merge_microphone_state`]).
94
    pub on_frame: OptionOnAudioFrame,
95
}
96

            
97
/// A microphone-capture widget. `create(config).with_on_frame(..).dom()` yields
98
/// an invisible node a background capture thread feeds.
99
#[repr(C)]
100
#[derive(Debug)]
101
pub struct MicrophoneWidget {
102
    /// Requested capture config (sample rate, channels).
103
    pub config: AudioConfig,
104
    /// Optional per-frame user hook (save / effects / send - azul-meet).
105
    pub on_frame: OptionOnAudioFrame,
106
}
107

            
108
impl MicrophoneWidget {
109
    /// Create a microphone widget for the given capture config.
110
19
    #[must_use] pub const fn create(config: AudioConfig) -> Self {
111
19
        Self {
112
19
            config,
113
19
            on_frame: OptionOnAudioFrame::None,
114
19
        }
115
19
    }
116

            
117
    /// Set a hook invoked with every captured audio chunk - for saving,
118
    /// effects, or sending over the network (azul-meet). The backreference DI
119
    /// pattern (see `architecture.md`).
120
9
    pub fn set_on_frame<C: Into<OnAudioFrameCallback>>(&mut self, data: RefAny, on_frame: C) {
121
9
        self.on_frame = Some(OnAudioFrame {
122
9
            refany: data,
123
9
            callback: on_frame.into(),
124
9
        })
125
9
        .into();
126
9
    }
127

            
128
    /// Builder form of [`set_on_frame`](Self::set_on_frame).
129
    #[must_use]
130
5
    pub fn with_on_frame<C: Into<OnAudioFrameCallback>>(
131
5
        mut self,
132
5
        data: RefAny,
133
5
        on_frame: C,
134
5
    ) -> Self {
135
5
        self.set_on_frame(data, on_frame);
136
5
        self
137
5
    }
138

            
139
    /// Build the widget's DOM: a single invisible node, fed by a background
140
    /// capture thread started on mount. Place it anywhere in your tree - the
141
    /// capture lives as long as the node is mounted (unmount stops it).
142
9
    #[must_use] pub fn dom(self) -> Dom {
143
9
        let state = MicrophoneWidgetState {
144
9
            config: self.config,
145
9
            started: false,
146
9
            on_frame: self.on_frame,
147
9
        };
148
9
        let dataset = RefAny::new(state);
149

            
150
9
        Dom::create_div()
151
9
            .with_dataset(OptionRefAny::Some(dataset.clone()))
152
9
            .with_merge_callback(azul_core::dom::DatasetMergeCallback::from_ptr(merge_microphone_state))
153
9
            .with_callback(
154
9
                EventFilter::Component(ComponentEventFilter::AfterMount),
155
9
                dataset,
156
9
                Callback::from_ptr(mic_on_after_mount),
157
            )
158
9
    }
159
}
160

            
161
/// `AfterMount`: start the background capture thread exactly once.
162
3
extern "C" fn mic_on_after_mount(mut data: RefAny, mut info: CallbackInfo) -> Update {
163
    let (rate, channels) = {
164
3
        let Some(mut s) = data.downcast_mut::<MicrophoneWidgetState>() else {
165
2
            return Update::DoNothing;
166
        };
167
1
        if s.started {
168
1
            return Update::DoNothing;
169
        }
170
        s.started = true;
171
        let rate = if s.config.sample_rate > 0 {
172
            s.config.sample_rate
173
        } else {
174
            48_000
175
        };
176
        let channels = s.config.channels.max(1);
177
        (rate, channels)
178
    };
179

            
180
    info.add_thread(
181
        ThreadId::unique(),
182
        Thread::create(
183
            RefAny::new(MicThreadInit {
184
                sample_rate: rate,
185
                channels,
186
            }),
187
            data.clone(),
188
            ThreadCallback::new(mic_worker),
189
        ),
190
    );
191
    Update::DoNothing
192
3
}
193

            
194
/// Background worker (test tone): a 440 Hz sine in ~20 ms chunks until the
195
/// widget unmounts. The real `AVAudioEngine` / `AAudio` / cpal capture loop
196
/// replaces it (dll-side).
197
#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/counter/fixed-point cast
198
6
extern "C" fn mic_worker(
199
6
    mut init: RefAny,
200
6
    mut sender: ThreadSender,
201
6
    mut recv: ThreadReceiver,
202
6
) {
203
6
    let (rate, channels) = init
204
6
        .downcast_ref::<MicThreadInit>()
205
6
        .map_or((48_000, 1), |i| (i.sample_rate, i.channels));
206

            
207
    // Real platform capture if the dll registered a mic backend (ALSA on
208
    // Linux); otherwise the 440 Hz test tone below.
209
6
    if let Some(backend) = mic_backend() {
210
6
        let handle = (backend.open)(rate, channels);
211
6
        if handle != 0 {
212
6
            let mut buf: Vec<f32> = Vec::new();
213
            loop {
214
                // See `capture_common::terminate_requested`: an ALSA read is
215
                // not interruptible from the terminate channel, so the check
216
                // has to happen between reads.
217
6
                if terminate_requested(&mut recv) {
218
1
                    break;
219
5
                }
220
5
                let frames = (backend.read)(handle, &mut buf);
221
5
                if frames == 0 {
222
3
                    break;
223
2
                }
224
2
                let frame = AudioFrame {
225
2
                    sample_rate: rate,
226
2
                    channels,
227
2
                    samples: F32Vec::from_vec(buf.clone()),
228
2
                };
229
2
                if !sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
230
2
                    WriteBackCallback::new(mic_writeback),
231
2
                    RefAny::new(frame),
232
2
                ))) {
233
2
                    break;
234
                }
235
            }
236
6
            (backend.close)(handle);
237
6
            return;
238
        }
239
    }
240

            
241
    // Reaching here means a MicrophoneWidget is live and about to feed a
242
    // synthetic 440 Hz TEST TONE instead of the microphone — the most
243
    // misleading fallback in the tree if unannounced. Say why, once.
244
    {
245
        static TEST_TONE_ANNOUNCE: std::sync::Once = std::sync::Once::new();
246
        let have_backend = mic_backend().is_some();
247
        TEST_TONE_ANNOUNCE.call_once(|| {
248
            if have_backend {
249
                eprintln!(
250
                    "[azul][microphone] the platform microphone backend failed to open \
251
                     (device missing/busy or libasound unavailable — see lines above) \
252
                     — feeding a synthetic 440 Hz TEST TONE instead of the microphone"
253
                );
254
            } else {
255
                eprintln!(
256
                    "[azul][microphone] no microphone backend is registered in this \
257
                     build/OS — feeding a synthetic 440 Hz TEST TONE instead of the \
258
                     microphone"
259
                );
260
            }
261
        });
262
    }
263

            
264
    let frames_per_chunk = (rate as usize / 50).max(1); // ~20 ms
265
    let step = 2.0 * core::f32::consts::PI * 440.0 / rate as f32;
266
    let mut phase: f32 = 0.0;
267
    loop {
268
        if terminate_requested(&mut recv) {
269
            break;
270
        }
271
        let mut samples = Vec::with_capacity(frames_per_chunk * channels as usize);
272
        for _ in 0..frames_per_chunk {
273
            let s = phase.sin() * 0.2;
274
            phase += step;
275
            if phase > 2.0 * core::f32::consts::PI {
276
                phase -= 2.0 * core::f32::consts::PI;
277
            }
278
            for _ in 0..channels {
279
                samples.push(s);
280
            }
281
        }
282
        let frame = AudioFrame {
283
            sample_rate: rate,
284
            channels,
285
            samples: F32Vec::from_vec(samples),
286
        };
287
        let sent = sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
288
            WriteBackCallback::new(mic_writeback),
289
            RefAny::new(frame),
290
        )));
291
        if !sent {
292
            break;
293
        }
294
        std::thread::sleep(std::time::Duration::from_millis(20));
295
    }
296
6
}
297

            
298
/// Writeback (main thread): hand the captured frame to the user's `on_frame`
299
/// hook. No GL - audio has no texture.
300
6
extern "C" fn mic_writeback(
301
6
    mut writeback_data: RefAny,
302
6
    mut frame_data: RefAny,
303
6
    info: CallbackInfo,
304
6
) -> Update {
305
6
    let hook = match writeback_data.downcast_ref::<MicrophoneWidgetState>() {
306
4
        Some(s) => s.on_frame.clone(),
307
2
        None => return Update::DoNothing,
308
    };
309
4
    frame_data.downcast_ref::<AudioFrame>().map_or(Update::DoNothing, |frame| invoke_on_audio_frame(&hook, &info, frame.clone()))
310
6
}
311

            
312
/// Carry live state forward across relayout (config + started; the `on_frame`
313
/// hook is taken from the fresh build).
314
7
extern "C" fn merge_microphone_state(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
315
    {
316
7
        let new_guard = new_data.downcast_mut::<MicrophoneWidgetState>();
317
7
        let old_guard = old_data.downcast_ref::<MicrophoneWidgetState>();
318
7
        if let (Some(mut new_g), Some(old_g)) = (new_guard, old_guard) {
319
4
            new_g.started = old_g.started;
320
4
        }
321
    }
322
7
    new_data
323
7
}
324

            
325
// ============================================================================
326
// Generated adversarial tests
327
// ============================================================================
328

            
329
#[cfg(test)]
330
#[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]
331
mod autotest_generated {
332
    use std::{
333
        collections::BTreeMap,
334
        sync::{
335
            mpsc::{channel, Receiver, Sender},
336
            Arc, Mutex,
337
        },
338
    };
339

            
340
    use azul_core::{
341
        dom::{DomId, DomNodeId, NodeType},
342
        geom::OptionLogicalPosition,
343
        gl::OptionGlContextPtr,
344
        hit_test::ScrollPosition,
345
        resources::RendererResources,
346
        styled_dom::NodeHierarchyItemId,
347
        task::{
348
            OptionThreadSendMsg, ThreadReceiverDestructorCallback, ThreadReceiverInner,
349
            ThreadRecvCallback, ThreadSendMsg,
350
        },
351
        window::{MonitorVec, RawWindowHandle},
352
    };
353
    use azul_css::system::SystemStyle;
354
    use rust_fontconfig::FcFontCache;
355

            
356
    use super::*;
357
    #[cfg(feature = "icu")]
358
    use crate::icu::IcuLocalizerHandle;
359
    use crate::{
360
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
361
        thread::{
362
            ThreadSendCallback, ThreadSenderDestructorCallback, ThreadSenderInner,
363
            WriteBackCallbackType,
364
        },
365
        window::LayoutWindow,
366
        window_state::FullWindowState,
367
    };
368

            
369
    // ------------------------------------------------------------------
370
    // Helpers
371
    // ------------------------------------------------------------------
372

            
373
    /// An `AudioConfig` with the given rate + channel count.
374
    const fn cfg(sample_rate: u32, channels: u16) -> AudioConfig {
375
        AudioConfig {
376
            sample_rate,
377
            channels,
378
        }
379
    }
380

            
381
    /// An interleaved `AudioFrame`. `AudioFrame` has no `Default`, so every test
382
    /// spells out its rate / channel count / samples.
383
    fn frame(sample_rate: u32, channels: u16, samples: Vec<f32>) -> AudioFrame {
384
        AudioFrame {
385
            sample_rate,
386
            channels,
387
            samples: F32Vec::from_vec(samples),
388
        }
389
    }
390

            
391
    /// A `MicrophoneWidgetState` payload with no `on_frame` hook.
392
    fn state(config: AudioConfig, started: bool) -> RefAny {
393
        RefAny::new(MicrophoneWidgetState {
394
            config,
395
            started,
396
            on_frame: OptionOnAudioFrame::None,
397
        })
398
    }
399

            
400
    /// `(config, started, has_hook)` of a `MicrophoneWidgetState` payload.
401
    fn read_state(data: &mut RefAny) -> (AudioConfig, bool, bool) {
402
        let s = data
403
            .downcast_ref::<MicrophoneWidgetState>()
404
            .expect("payload must still be a MicrophoneWidgetState");
405
        (
406
            s.config,
407
            s.started,
408
            matches!(s.on_frame, OptionOnAudioFrame::Some(_)),
409
        )
410
    }
411

            
412
    // ---- frame hook -------------------------------------------------------
413

            
414
    /// Records every frame a widget's `on_frame` hook is handed, verbatim.
415
    struct FrameLog {
416
        seen: Vec<(u32, u16, Vec<f32>)>,
417
    }
418

            
419
    extern "C" fn record_frame(mut data: RefAny, _: CallbackInfo, frame: AudioFrame) -> Update {
420
        if let Some(mut log) = data.downcast_mut::<FrameLog>() {
421
            log.seen.push((
422
                frame.sample_rate,
423
                frame.channels,
424
                frame.samples.as_ref().to_vec(),
425
            ));
426
        }
427
        Update::RefreshDom
428
    }
429

            
430
    extern "C" fn frame_do_nothing(_: RefAny, _: CallbackInfo, _: AudioFrame) -> Update {
431
        Update::DoNothing
432
    }
433

            
434
    /// The frames recorded by a `FrameLog` payload.
435
    fn logged_frames(data: &mut RefAny) -> Vec<(u32, u16, Vec<f32>)> {
436
        data.downcast_ref::<FrameLog>()
437
            .expect("payload must still be a FrameLog")
438
            .seen
439
            .clone()
440
    }
441

            
442
    fn new_log() -> RefAny {
443
        RefAny::new(FrameLog { seen: Vec::new() })
444
    }
445

            
446
    /// An `on_frame` hook that records into `log`.
447
    fn hook_into(log: &RefAny) -> OptionOnAudioFrame {
448
        Some(OnAudioFrame {
449
            refany: log.clone(),
450
            callback: (record_frame as OnAudioFrameCallbackType).into(),
451
        })
452
        .into()
453
    }
454

            
455
    /// A `MicrophoneWidgetState` whose `on_frame` hook writes into `log`.
456
    fn state_with_hook(config: AudioConfig, started: bool, log: &RefAny) -> RefAny {
457
        RefAny::new(MicrophoneWidgetState {
458
            config,
459
            started,
460
            on_frame: hook_into(log),
461
        })
462
    }
463

            
464
    // ---- CallbackInfo harness --------------------------------------------
465

            
466
    /// Runs `f` against a real `CallbackInfo` over an empty `LayoutWindow` (no GL
467
    /// context). Returns `f`'s value plus every `CallbackChange` the callback
468
    /// recorded.
469
    fn with_callback_info<R>(f: impl FnOnce(CallbackInfo) -> R) -> (R, Vec<CallbackChange>) {
470
        let layout_window =
471
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
472
        let renderer_resources = RendererResources::default();
473
        let previous_window_state: Option<FullWindowState> = None;
474
        let current_window_state = FullWindowState::default();
475
        let gl_context = OptionGlContextPtr::None;
476
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
477
            BTreeMap::new();
478
        let window_handle = RawWindowHandle::Unsupported;
479
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
480

            
481
        let ref_data = CallbackInfoRefData {
482
            layout_window: &layout_window,
483
            renderer_resources: &renderer_resources,
484
            previous_window_state: &previous_window_state,
485
            current_window_state: &current_window_state,
486
            gl_context: &gl_context,
487
            current_scroll_manager: &scroll_states,
488
            current_window_handle: &window_handle,
489
            system_callbacks: &system_callbacks,
490
            system_style: Arc::new(SystemStyle::default()),
491
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
492
            #[cfg(feature = "icu")]
493
            icu_localizer: IcuLocalizerHandle::default(),
494
            ctx: OptionRefAny::None,
495
        };
496

            
497
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
498

            
499
        let info = CallbackInfo::new(
500
            &ref_data,
501
            &changes,
502
            DomNodeId {
503
                dom: DomId::ROOT_ID,
504
                node: NodeHierarchyItemId::NONE,
505
            },
506
            OptionLogicalPosition::None,
507
            OptionLogicalPosition::None,
508
        );
509

            
510
        let out = f(info);
511
        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
512
        (out, recorded)
513
    }
514

            
515
    // ---- mic_worker harness ----------------------------------------------
516

            
517
    /// One frame `mic_worker` handed to its sender.
518
    #[derive(Debug, Clone, PartialEq)]
519
    struct SentFrame {
520
        sample_rate: u32,
521
        channels: u16,
522
        samples: Vec<f32>,
523
        /// The writeback fn pointer the worker attached, as an address.
524
        writeback: usize,
525
    }
526

            
527
    /// Everything `mic_worker` pushed. Guarded by `WORKER_GATE` - the worker's send
528
    /// callback is a plain C fn pointer, so it has nowhere else to put its result.
529
    static WORKER_LOG: Mutex<Vec<SentFrame>> = Mutex::new(Vec::new());
530
    static WORKER_GATE: Mutex<()> = Mutex::new(());
531

            
532
    /// Records the frame, then reports the send as *failed* - i.e. "the main thread
533
    /// is gone", the only signal `mic_worker` has to stop. A worker that ignored it
534
    /// would hang this test forever (the tone loop is unbounded).
535
    extern "C" fn record_and_stop(
536
        _sender: *const core::ffi::c_void,
537
        msg: ThreadReceiveMsg,
538
    ) -> bool {
539
        if let ThreadReceiveMsg::WriteBack(mut wb) = msg {
540
            let writeback = wb.callback.cb as usize;
541
            if let Some(f) = wb.refany.downcast_ref::<AudioFrame>() {
542
                WORKER_LOG
543
                    .lock()
544
                    .unwrap_or_else(std::sync::PoisonError::into_inner)
545
                    .push(SentFrame {
546
                        sample_rate: f.sample_rate,
547
                        channels: f.channels,
548
                        samples: f.samples.as_ref().to_vec(),
549
                        writeback,
550
                    });
551
            }
552
        }
553
        false
554
    }
555

            
556
    extern "C" fn sender_drop_noop(_: *mut ThreadSenderInner) {}
557
    extern "C" fn receiver_drop_noop(_: *mut ThreadReceiverInner) {}
558
    extern "C" fn recv_nothing(_: *const core::ffi::c_void) -> OptionThreadSendMsg {
559
        OptionThreadSendMsg::None
560
    }
561

            
562
    /// A `ThreadSender` whose every `send` is recorded and then rejected.
563
    fn stopped_sender() -> (Receiver<ThreadReceiveMsg>, ThreadSender) {
564
        let (tx, rx) = channel::<ThreadReceiveMsg>();
565
        let sender = ThreadSender::new(ThreadSenderInner {
566
            ptr: Box::new(tx),
567
            send_fn: ThreadSendCallback { cb: record_and_stop },
568
            destructor: ThreadSenderDestructorCallback {
569
                cb: sender_drop_noop,
570
            },
571
        });
572
        (rx, sender)
573
    }
574

            
575
    /// A `ThreadReceiver` that never delivers anything (`mic_worker` ignores it).
576
    fn silent_receiver() -> (Sender<ThreadSendMsg>, ThreadReceiver) {
577
        let (tx, rx) = channel::<ThreadSendMsg>();
578
        let receiver = ThreadReceiver::new(ThreadReceiverInner {
579
            ptr: Box::new(rx),
580
            recv_fn: ThreadRecvCallback { cb: recv_nothing },
581
            destructor: ThreadReceiverDestructorCallback {
582
                cb: receiver_drop_noop,
583
            },
584
        });
585
        (tx, receiver)
586
    }
587

            
588
    /// Runs `mic_worker` with `init` against a sender that rejects the first frame.
589
    ///
590
    /// Returns `(frames the worker managed to send, took_the_test_tone_path)`. The
591
    /// flag is read *after* the run on purpose: `MIC_BACKEND` is a `OnceLock`, so
592
    /// "still unregistered afterwards" proves it was unregistered *during* the run
593
    /// — and another test in this binary (`capture_common`) may register one at any
594
    /// time. Assertions about the 440 Hz tone are gated on it; the path-independent
595
    /// invariants are always checked.
596
    fn run_worker(init: RefAny) -> (Vec<SentFrame>, bool) {
597
        let _gate = WORKER_GATE
598
            .lock()
599
            .unwrap_or_else(std::sync::PoisonError::into_inner);
600
        WORKER_LOG
601
            .lock()
602
            .unwrap_or_else(std::sync::PoisonError::into_inner)
603
            .clear();
604

            
605
        let (_rx, sender) = stopped_sender();
606
        let (_tx, receiver) = silent_receiver();
607
        mic_worker(init, sender, receiver);
608

            
609
        let sent = WORKER_LOG
610
            .lock()
611
            .unwrap_or_else(std::sync::PoisonError::into_inner)
612
            .clone();
613
        (sent, mic_backend().is_none())
614
    }
615

            
616
    /// The tone worker's chunk length: ~20 ms of interleaved samples, never empty
617
    /// in the frame dimension.
618
    fn expected_chunk_len(sample_rate: u32, channels: u16) -> usize {
619
        (sample_rate as usize / 50).max(1) * channels as usize
620
    }
621

            
622
    // ------------------------------------------------------------------
623
    // invoke_on_audio_frame
624
    // ------------------------------------------------------------------
625

            
626
    #[test]
627
    fn invoke_without_a_hook_is_donothing_even_for_a_degenerate_frame() {
628
        let (update, _) = with_callback_info(|info| {
629
            // 0 channels + huge rate + no samples: nothing may divide by the channel
630
            // count or index the (empty) sample buffer.
631
            invoke_on_audio_frame(
632
                &OptionOnAudioFrame::None,
633
                &info,
634
                frame(u32::MAX, 0, Vec::new()),
635
            )
636
        });
637
        assert_eq!(update, Update::DoNothing);
638
    }
639

            
640
    #[test]
641
    fn invoke_forwards_the_frame_verbatim_and_returns_the_hooks_update() {
642
        let mut log = new_log();
643
        let hook = hook_into(&log);
644
        let samples = vec![-1.0_f32, 0.0, 1.0, 0.5];
645

            
646
        let (update, _) = with_callback_info(|info| {
647
            invoke_on_audio_frame(&hook, &info, frame(44_100, 2, samples.clone()))
648
        });
649

            
650
        assert_eq!(update, Update::RefreshDom, "the hook's Update must win");
651
        assert_eq!(logged_frames(&mut log), vec![(44_100, 2, samples)]);
652
    }
653

            
654
    #[test]
655
    fn invoke_passes_nan_infinite_and_negative_zero_samples_through_untouched() {
656
        // The widget is a transport, not a filter: hostile float payloads must reach
657
        // the user hook exactly as captured, with no normalisation and no panic.
658
        let mut log = new_log();
659
        let hook = hook_into(&log);
660
        let hostile = vec![f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, f32::MIN, f32::MAX];
661

            
662
        let (update, _) =
663
            with_callback_info(|info| invoke_on_audio_frame(&hook, &info, frame(0, 1, hostile)));
664

            
665
        assert_eq!(update, Update::RefreshDom);
666
        let seen = logged_frames(&mut log);
667
        assert_eq!(seen.len(), 1);
668
        let (rate, channels, samples) = &seen[0];
669
        assert_eq!((*rate, *channels), (0, 1), "a 0 Hz frame is forwarded as-is");
670
        assert!(samples[0].is_nan(), "NaN must not be normalised");
671
        assert_eq!(samples[1], f32::INFINITY);
672
        assert_eq!(samples[2], f32::NEG_INFINITY);
673
        assert!(
674
            samples[3] == 0.0 && samples[3].is_sign_negative(),
675
            "-0.0 must keep its sign bit"
676
        );
677
        assert_eq!(samples[4], f32::MIN);
678
        assert_eq!(samples[5], f32::MAX);
679
    }
680

            
681
    #[test]
682
    fn invoke_forwards_a_frame_whose_sample_count_contradicts_its_channel_count() {
683
        // 65535 channels but 3 samples: `frame_count()` must floor to 0 rather than
684
        // divide by zero or wrap, and the hook still sees the frame.
685
        let mut log = new_log();
686
        let hook = hook_into(&log);
687
        let bogus = frame(u32::MAX, u16::MAX, vec![0.1, 0.2, 0.3]);
688
        assert_eq!(bogus.frame_count(), 0);
689

            
690
        let (update, _) = with_callback_info(|info| invoke_on_audio_frame(&hook, &info, bogus));
691

            
692
        assert_eq!(update, Update::RefreshDom);
693
        assert_eq!(logged_frames(&mut log), vec![(u32::MAX, u16::MAX, vec![0.1, 0.2, 0.3])]);
694
    }
695

            
696
    // ------------------------------------------------------------------
697
    // MicrophoneWidget::create / set_on_frame / with_on_frame
698
    // ------------------------------------------------------------------
699

            
700
    #[test]
701
    fn create_stores_the_config_verbatim_and_leaves_the_hook_unset() {
702
        for (rate, channels) in [
703
            (0, 0),
704
            (1, 1),
705
            (48_000, 2),
706
            (u32::MAX, u16::MAX),
707
            (u32::MAX, 0),
708
            (0, u16::MAX),
709
        ] {
710
            let widget = MicrophoneWidget::create(cfg(rate, channels));
711
            assert_eq!(
712
                widget.config,
713
                cfg(rate, channels),
714
                "create must not normalise the config"
715
            );
716
            assert!(
717
                matches!(widget.on_frame, OptionOnAudioFrame::None),
718
                "a fresh widget has no frame hook"
719
            );
720
        }
721

            
722
        let default = MicrophoneWidget::create(AudioConfig::default());
723
        assert_eq!(default.config, cfg(48_000, 1));
724
    }
725

            
726
    #[test]
727
    fn with_on_frame_installs_the_hook_keeps_the_config_and_shares_the_user_data() {
728
        let data = new_log();
729
        let widget = MicrophoneWidget::create(cfg(u32::MAX, u16::MAX))
730
            .with_on_frame(data.clone(), record_frame as OnAudioFrameCallbackType);
731

            
732
        assert_eq!(
733
            widget.config,
734
            cfg(u32::MAX, u16::MAX),
735
            "the builder must not touch the config"
736
        );
737
        let OptionOnAudioFrame::Some(hook) = &widget.on_frame else {
738
            panic!("with_on_frame must install a hook");
739
        };
740
        assert_eq!(
741
            hook.callback.cb as usize,
742
            record_frame as OnAudioFrameCallbackType as usize
743
        );
744
        assert_eq!(
745
            hook.refany, data,
746
            "the widget must hold the caller's RefAny, not a fresh allocation"
747
        );
748
    }
749

            
750
    #[test]
751
    fn set_on_frame_twice_keeps_only_the_last_hook_and_releases_the_first_payload() {
752
        let first = new_log();
753
        let second = new_log();
754
        let mut widget = MicrophoneWidget::create(cfg(8_000, 1));
755
        widget.set_on_frame(first.clone(), record_frame as OnAudioFrameCallbackType);
756
        widget.set_on_frame(second.clone(), frame_do_nothing as OnAudioFrameCallbackType);
757

            
758
        let OptionOnAudioFrame::Some(hook) = &widget.on_frame else {
759
            panic!("hook must still be set");
760
        };
761
        assert_eq!(
762
            hook.callback.cb as usize,
763
            frame_do_nothing as OnAudioFrameCallbackType as usize,
764
            "the second set_on_frame must replace the first"
765
        );
766
        assert_eq!(hook.refany, second);
767
        assert_ne!(hook.refany, first, "the first payload must have been dropped");
768
        assert_eq!(widget.config, cfg(8_000, 1));
769
    }
770

            
771
    #[test]
772
    fn set_on_frame_accepts_the_same_refany_for_both_hooks() {
773
        // Re-registering the *same* payload must not free it (a double-drop would
774
        // show up as a corrupt downcast here).
775
        let mut data = new_log();
776
        let mut widget = MicrophoneWidget::create(cfg(48_000, 2));
777
        widget.set_on_frame(data.clone(), record_frame as OnAudioFrameCallbackType);
778
        widget.set_on_frame(data.clone(), record_frame as OnAudioFrameCallbackType);
779

            
780
        assert!(matches!(widget.on_frame, OptionOnAudioFrame::Some(_)));
781
        assert!(logged_frames(&mut data).is_empty());
782
    }
783

            
784
    // ------------------------------------------------------------------
785
    // MicrophoneWidget::dom
786
    // ------------------------------------------------------------------
787

            
788
    #[test]
789
    fn dom_is_one_div_with_one_after_mount_callback_a_dataset_and_a_merge_callback() {
790
        let dom = MicrophoneWidget::create(cfg(48_000, 2)).dom();
791

            
792
        assert_eq!(dom.root.get_node_type(), &NodeType::Div);
793
        assert_eq!(dom.children.as_ref().len(), 0, "the widget is a single node");
794

            
795
        let callbacks = dom.root.get_callbacks();
796
        assert_eq!(
797
            callbacks.as_ref().len(),
798
            1,
799
            "exactly one callback: the AfterMount capture-thread starter"
800
        );
801
        assert_eq!(
802
            callbacks.as_ref()[0].event,
803
            EventFilter::Component(ComponentEventFilter::AfterMount)
804
        );
805
        assert_eq!(
806
            callbacks.as_ref()[0].callback.cb,
807
            mic_on_after_mount as CallbackType as usize
808
        );
809

            
810
        let merge = dom
811
            .root
812
            .get_merge_callback()
813
            .expect("state must survive relayout");
814
        assert_eq!(
815
            merge.cb as usize,
816
            merge_microphone_state as DatasetMergeCallbackType as usize
817
        );
818

            
819
        let mut dataset = dom
820
            .root
821
            .get_dataset()
822
            .cloned()
823
            .expect("the node must carry its MicrophoneWidgetState");
824
        assert_eq!(read_state(&mut dataset), (cfg(48_000, 2), false, false));
825
    }
826

            
827
    #[test]
828
    fn dom_shares_one_state_between_the_dataset_and_the_after_mount_callback() {
829
        let dom = MicrophoneWidget::create(cfg(48_000, 1)).dom();
830
        let mut dataset = dom.root.get_dataset().cloned().expect("dataset");
831
        let mut callback_data = dom.root.get_callbacks().as_ref()[0].refany.clone();
832

            
833
        assert_eq!(
834
            callback_data, dataset,
835
            "AfterMount must see the very state the dataset carries"
836
        );
837

            
838
        {
839
            let mut s = dataset
840
                .downcast_mut::<MicrophoneWidgetState>()
841
                .expect("state");
842
            s.started = true;
843
        }
844
        assert!(
845
            read_state(&mut callback_data).1,
846
            "a write through the dataset must be visible to the callback"
847
        );
848
    }
849

            
850
    #[test]
851
    fn dom_carries_an_extreme_config_and_the_hook_into_the_state_unnormalised() {
852
        // Normalisation (0 -> 48 kHz, channels.max(1)) happens on AfterMount, not at
853
        // build time - the state must record exactly what the user asked for.
854
        for (rate, channels) in [(0, 0), (1, u16::MAX), (u32::MAX, 1)] {
855
            let dom = MicrophoneWidget::create(cfg(rate, channels))
856
                .with_on_frame(new_log(), record_frame as OnAudioFrameCallbackType)
857
                .dom();
858
            let mut dataset = dom.root.get_dataset().cloned().expect("dataset");
859
            assert_eq!(read_state(&mut dataset), (cfg(rate, channels), false, true));
860
        }
861
    }
862

            
863
    #[test]
864
    fn dom_built_twice_yields_two_independent_states() {
865
        let a = MicrophoneWidget::create(cfg(8_000, 1)).dom();
866
        let b = MicrophoneWidget::create(cfg(8_000, 1)).dom();
867
        let mut a_ds = a.root.get_dataset().cloned().expect("dataset");
868
        let mut b_ds = b.root.get_dataset().cloned().expect("dataset");
869

            
870
        assert_ne!(a_ds, b_ds, "two widgets must not share one capture state");
871
        {
872
            let mut s = a_ds.downcast_mut::<MicrophoneWidgetState>().expect("state");
873
            s.started = true;
874
        }
875
        assert!(!read_state(&mut b_ds).1, "the second widget is untouched");
876
    }
877

            
878
    // ------------------------------------------------------------------
879
    // mic_on_after_mount
880
    //
881
    // NOTE: the *first* mount is deliberately not exercised - it spawns a real
882
    // capture thread, and `ThreadInner`'s destructor joins that thread while its
883
    // receiver is still alive. `mic_worker` never reads its receiver and only
884
    // stops when a send fails, so the join would hang the test binary forever
885
    // (see the report). Only the guard paths below can be driven safely.
886
    // ------------------------------------------------------------------
887

            
888
    #[test]
889
    fn after_mount_ignores_a_dataset_that_is_not_a_microphone_state() {
890
        let (update, changes) =
891
            with_callback_info(|info| mic_on_after_mount(RefAny::new(0_u32), info));
892

            
893
        assert_eq!(update, Update::DoNothing);
894
        assert!(
895
            changes.is_empty(),
896
            "a foreign dataset must not start a capture thread"
897
        );
898
    }
899

            
900
    #[test]
901
    fn after_mount_is_a_no_op_once_the_capture_thread_has_started() {
902
        let mut data = state(cfg(0, 0), true);
903
        let (update, changes) = with_callback_info(|info| mic_on_after_mount(data.clone(), info));
904

            
905
        assert_eq!(update, Update::DoNothing);
906
        assert!(
907
            changes.is_empty(),
908
            "AfterMount must start the capture thread at most once"
909
        );
910
        assert_eq!(
911
            read_state(&mut data),
912
            (cfg(0, 0), true, false),
913
            "a re-mount must not rewrite the state"
914
        );
915
    }
916

            
917
    #[test]
918
    fn after_mount_starts_nothing_while_the_state_is_borrowed_elsewhere() {
919
        // A live shared borrow makes `downcast_mut` fail. The guard must bail out
920
        // (no thread, no panic) instead of unwrapping.
921
        let data = state(cfg(48_000, 2), false);
922
        let mut probe = data.clone();
923
        let guard = probe
924
            .downcast_ref::<MicrophoneWidgetState>()
925
            .expect("shared borrow");
926

            
927
        let (update, changes) = with_callback_info(|info| mic_on_after_mount(data.clone(), info));
928

            
929
        assert_eq!(update, Update::DoNothing);
930
        assert!(changes.is_empty(), "a borrowed state must not be mounted");
931
        assert!(!guard.started, "the state must still be untouched");
932
        drop(guard);
933

            
934
        let mut after = data;
935
        assert_eq!(read_state(&mut after), (cfg(48_000, 2), false, false));
936
    }
937

            
938
    // ------------------------------------------------------------------
939
    // mic_worker
940
    // ------------------------------------------------------------------
941

            
942
    #[test]
943
    fn worker_stops_after_the_first_rejected_send_and_tags_frames_with_its_init() {
944
        let (sent, tone_path) = run_worker(RefAny::new(MicThreadInit {
945
            sample_rate: 8_000,
946
            channels: 2,
947
        }));
948

            
949
        // Path-independent: a rejected send stops the loop, and every frame carries
950
        // the requested format plus the mic writeback.
951
        assert!(
952
            sent.len() <= 1,
953
            "the worker must stop after the first rejected send, not spin"
954
        );
955
        for f in &sent {
956
            assert_eq!((f.sample_rate, f.channels), (8_000, 2));
957
            assert_eq!(f.writeback, mic_writeback as WriteBackCallbackType as usize);
958
        }
959

            
960
        if !tone_path {
961
            return; // a platform backend is registered: not the test tone
962
        }
963
        assert_eq!(sent.len(), 1);
964
        let samples = &sent[0].samples;
965
        assert_eq!(
966
            samples.len(),
967
            expected_chunk_len(8_000, 2),
968
            "~20 ms of interleaved stereo at 8 kHz"
969
        );
970
        assert!(
971
            samples.iter().all(|s| s.is_finite() && s.abs() <= 0.2),
972
            "the tone must stay finite and inside +/-0.2"
973
        );
974
        assert_eq!(samples[0], 0.0, "the tone starts at phase 0");
975
        for pair in samples.chunks_exact(2) {
976
            assert_eq!(pair[0], pair[1], "both channels carry the same mono tone");
977
        }
978
    }
979

            
980
    #[test]
981
    fn worker_with_a_foreign_init_falls_back_to_48khz_mono() {
982
        let (sent, tone_path) = run_worker(RefAny::new(0_u64));
983

            
984
        for f in &sent {
985
            assert_eq!(
986
                (f.sample_rate, f.channels),
987
                (48_000, 1),
988
                "a bad init must not panic - it defaults"
989
            );
990
        }
991
        if !tone_path {
992
            return;
993
        }
994
        assert_eq!(sent.len(), 1);
995
        assert_eq!(sent[0].samples.len(), expected_chunk_len(48_000, 1));
996
    }
997

            
998
    #[test]
999
    fn worker_with_a_zero_sample_rate_emits_one_finite_chunk_instead_of_dividing_by_zero() {
        // rate 0 makes the phase step `2*PI*440/0.0` = +inf. The chunk length still
        // has to clamp to >= 1 frame, the emitted samples still have to be finite,
        // and the worker still has to terminate.
        let (sent, tone_path) = run_worker(RefAny::new(MicThreadInit {
            sample_rate: 0,
            channels: 1,
        }));
        // Path-independent: whatever produced the frame, it carries the requested
        // format.
        for f in &sent {
            assert_eq!((f.sample_rate, f.channels), (0, 1));
        }
        if !tone_path {
            return;
        }
        // Tone-path only, like every sibling worker test. `capture_common`'s
        // `register_mic_backend_is_first_wins_and_passes_f32_samples_through`
        // installs a process-wide `OnceLock` backend whose `read` deliberately
        // yields `[NaN, inf, -inf, -0.0]` to prove the vtable passes samples
        // through untouched. Once that test has run, `mic_worker` takes the
        // backend branch and never evaluates a phase step at all — so asserting
        // finiteness ABOVE the gate made this test fail depending on which other
        // test happened to run first in the same process. (It passed under
        // `cargo nextest`, which forks per test, and failed under `cargo test`,
        // which does not.)
        assert!(
            sent.iter().all(|f| f.samples.iter().all(|s| s.is_finite())),
            "an infinite phase step must not leak NaN/inf into the samples"
        );
        assert_eq!(sent.len(), 1);
        assert_eq!(sent[0].samples, vec![0.0_f32], "one frame, at phase 0");
    }
    #[test]
    fn worker_with_zero_channels_emits_an_empty_chunk_and_stops() {
        let (sent, tone_path) = run_worker(RefAny::new(MicThreadInit {
            sample_rate: 48_000,
            channels: 0,
        }));
        for f in &sent {
            assert_eq!(f.channels, 0);
        }
        if !tone_path {
            return;
        }
        assert_eq!(sent.len(), 1);
        assert!(
            sent[0].samples.is_empty(),
            "0 channels interleaves 0 samples per frame"
        );
        // The frame such a worker produces must still be safe to inspect.
        assert_eq!(frame(48_000, 0, sent[0].samples.clone()).frame_count(), 0);
    }
    #[test]
    fn worker_chunk_length_clamps_to_one_frame_for_sub_50hz_rates() {
        // `rate / 50` truncates to 0 below 50 Hz; without the `.max(1)` the worker
        // would emit empty chunks forever.
        for (rate, channels, expected) in [
            (1_u32, 1_u16, 1_usize),
            (49, 1, 1),
            (50, 1, 1),
            (99, 2, 2),
            (100, 2, 4),
            (100, 3, 6),
        ] {
            let (sent, tone_path) = run_worker(RefAny::new(MicThreadInit {
                sample_rate: rate,
                channels,
            }));
            if !tone_path {
                return;
            }
            assert_eq!(sent.len(), 1, "rate {rate} must emit exactly one chunk");
            assert_eq!(
                sent[0].samples.len(),
                expected,
                "rate {rate} x {channels} ch must clamp to >= 1 frame"
            );
            assert_eq!(expected_chunk_len(rate, channels), expected);
            assert!(
                sent[0].samples.iter().all(|s| s.is_finite() && s.abs() <= 0.2),
                "a phase step larger than a full period must still yield bounded samples"
            );
        }
    }
    // ------------------------------------------------------------------
    // mic_writeback
    // ------------------------------------------------------------------
    #[test]
    fn writeback_hands_the_frame_to_the_hook_and_returns_its_update() {
        let mut log = new_log();
        let data = state_with_hook(cfg(44_100, 2), true, &log);
        let frame_data = RefAny::new(frame(44_100, 2, vec![0.25, -0.25, 0.5, -0.5]));
        let (update, _) =
            with_callback_info(|info| mic_writeback(data.clone(), frame_data.clone(), info));
        assert_eq!(update, Update::RefreshDom, "the hook's Update must win");
        assert_eq!(
            logged_frames(&mut log),
            vec![(44_100, 2, vec![0.25, -0.25, 0.5, -0.5])]
        );
    }
    #[test]
    fn writeback_without_a_hook_is_a_no_op() {
        let data = state(cfg(48_000, 1), true);
        let frame_data = RefAny::new(frame(48_000, 1, vec![0.0; 8]));
        let (update, changes) =
            with_callback_info(|info| mic_writeback(data.clone(), frame_data.clone(), info));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "audio has no texture - nothing to change");
    }
    #[test]
    fn writeback_ignores_frame_data_of_the_wrong_type() {
        let mut log = new_log();
        let data = state_with_hook(cfg(48_000, 1), true, &log);
        let (update, changes) =
            with_callback_info(|info| mic_writeback(data.clone(), RefAny::new(0_u32), info));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
        assert!(
            logged_frames(&mut log).is_empty(),
            "the user hook must not fire without a frame"
        );
    }
    #[test]
    fn writeback_survives_a_writeback_dataset_that_is_not_a_microphone_state() {
        let (update, changes) = with_callback_info(|info| {
            mic_writeback(RefAny::new(0_u32), RefAny::new(frame(8_000, 1, vec![0.0])), info)
        });
        assert_eq!(
            update,
            Update::DoNothing,
            "a foreign dataset means no hook - but no panic either"
        );
        assert!(changes.is_empty());
    }
    #[test]
    fn writeback_forwards_a_malformed_frame_to_the_hook_untouched() {
        // Hostile payload: a rate/channel count that no device produces, no samples,
        // NaN-free but nonsensical. The writeback is a transport - it must neither
        // validate nor panic.
        let mut log = new_log();
        let data = state_with_hook(cfg(48_000, 2), true, &log);
        let bogus = RefAny::new(frame(u32::MAX, u16::MAX, Vec::new()));
        let (update, _) =
            with_callback_info(|info| mic_writeback(data.clone(), bogus.clone(), info));
        assert_eq!(update, Update::RefreshDom);
        assert_eq!(logged_frames(&mut log), vec![(u32::MAX, u16::MAX, Vec::new())]);
    }
    #[test]
    fn writeback_is_a_no_op_while_the_state_is_mutably_borrowed() {
        let mut log = new_log();
        let data = state_with_hook(cfg(48_000, 1), true, &log);
        let mut probe = data.clone();
        let guard = probe
            .downcast_mut::<MicrophoneWidgetState>()
            .expect("exclusive borrow");
        let frame_data = RefAny::new(frame(48_000, 1, vec![0.1]));
        let (update, changes) =
            with_callback_info(|info| mic_writeback(data.clone(), frame_data.clone(), info));
        assert_eq!(update, Update::DoNothing, "a blocked downcast must not panic");
        assert!(changes.is_empty());
        drop(guard);
        assert!(logged_frames(&mut log).is_empty());
    }
    // ------------------------------------------------------------------
    // merge_microphone_state
    // ------------------------------------------------------------------
    #[test]
    fn merge_takes_started_from_old_and_everything_else_from_new() {
        let log = new_log();
        let new_data = state_with_hook(cfg(44_100, 2), false, &log);
        let old_data = state(cfg(8_000, 1), true);
        let mut merged = merge_microphone_state(new_data, old_data);
        assert_eq!(
            read_state(&mut merged),
            (cfg(44_100, 2), true, true),
            "config + hook come from the fresh build, 'started' from the old state"
        );
    }
    #[test]
    fn merge_takes_started_from_old_even_when_that_clears_it() {
        // The old state is authoritative for the thread flag in both directions -
        // otherwise a remount could start a second capture thread.
        let new_data = state(cfg(48_000, 1), true);
        let old_data = state(cfg(48_000, 1), false);
        let mut merged = merge_microphone_state(new_data, old_data);
        assert!(!read_state(&mut merged).1);
    }
    #[test]
    fn merge_returns_the_new_allocation_itself_not_a_copy() {
        let new_data = state(cfg(48_000, 1), false);
        let handle = new_data.clone();
        let merged = merge_microphone_state(new_data, state(cfg(48_000, 1), true));
        assert_eq!(merged, handle, "merge must hand back the same state object");
    }
    #[test]
    fn merge_leaves_the_new_state_alone_when_the_old_one_is_foreign() {
        let new_data = state(cfg(48_000, 2), true);
        let mut merged = merge_microphone_state(new_data, RefAny::new(0_u32));
        assert_eq!(
            read_state(&mut merged),
            (cfg(48_000, 2), true, false),
            "nothing to carry forward from a foreign payload"
        );
    }
    #[test]
    fn merge_returns_a_foreign_new_dataset_untouched() {
        let old_data = state(cfg(48_000, 1), true);
        let mut merged = merge_microphone_state(RefAny::new(77_u32), old_data);
        assert_eq!(
            merged.downcast_ref::<u32>().map(|v| *v),
            Some(77),
            "merge must hand back exactly the payload it was given"
        );
    }
    #[test]
    fn merge_of_a_dataset_with_itself_does_not_panic() {
        // The same RefAny on both sides: the mutable + shared borrow overlap, so the
        // merge is skipped rather than aliasing. Either way the state must survive.
        let mut data = state_with_hook(cfg(48_000, 2), true, &new_log());
        let mut merged = merge_microphone_state(data.clone(), data.clone());
        assert_eq!(read_state(&mut merged), (cfg(48_000, 2), true, true));
        assert_eq!(read_state(&mut data), (cfg(48_000, 2), true, true));
    }
    #[test]
    fn a_rebuilt_dom_merges_the_running_thread_flag_forward() {
        // The relayout round trip, through the callbacks `dom()` actually wires:
        // mount marks `started`, the fresh build starts at `false`, and the merge
        // carries the flag across so AfterMount cannot start a second thread.
        let old = MicrophoneWidget::create(cfg(48_000, 1)).dom();
        let mut old_ds = old.root.get_dataset().cloned().expect("dataset");
        {
            let mut s = old_ds
                .downcast_mut::<MicrophoneWidgetState>()
                .expect("state");
            s.started = true;
        }
        let new = MicrophoneWidget::create(cfg(44_100, 2))
            .with_on_frame(new_log(), record_frame as OnAudioFrameCallbackType)
            .dom();
        let new_ds = new.root.get_dataset().cloned().expect("dataset");
        let merge = new.root.get_merge_callback().expect("merge callback");
        let mut merged = (merge.cb)(new_ds, old_ds);
        assert_eq!(
            read_state(&mut merged),
            (cfg(44_100, 2), true, true),
            "the rebuilt widget keeps its new config + hook but inherits the thread"
        );
    }
    /// REGRESSION (B3): a capture worker must ACKNOWLEDGE `TerminateThread`.
    ///
    /// Reported from azul-meet on macOS after using camera + screenshare — the
    /// framework printed, twice (once per capture worker):
    ///
    /// ```text
    /// [azul][thread] a background thread did not acknowledge TerminateThread
    /// within 2000ms and was DETACHED rather than joined.
    /// ```
    ///
    /// Root cause: the worker took its receiver as `_recv` and never polled it,
    /// so the terminate message was never observed. The only exit was
    /// `sender.send()` returning false, which does NOT happen at shutdown
    /// because the main thread still owns the receiving end while it waits out
    /// the grace period. Fixed by `capture_common::terminate_requested`.
    ///
    /// The budget here is the framework's own:
    /// `THREAD_TERMINATE_GRACE_STEPS (200) * 10ms = 2000ms`.
    #[test]
    fn mic_worker_acknowledges_terminate_within_the_grace_budget() {
        use crate::thread::{Thread, ThreadCallback};
        let t = Thread::create(
            RefAny::new(MicThreadInit {
                sample_rate: 8_000,
                channels: 1,
            }),
            RefAny::new(0_usize),
            ThreadCallback::new(mic_worker),
        );
        assert!(
            t.send_message(ThreadSendMsg::TerminateThread),
            "the worker holds its receiver alive, so the send must succeed"
        );
        let finished = || {
            t.ptr
                .lock()
                .expect("thread mutex must not be poisoned")
                .is_finished()
        };
        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(2_000);
        while !finished() && std::time::Instant::now() < deadline {
            std::thread::sleep(std::time::Duration::from_millis(10));
        }
        assert!(
            finished(),
            "mic_worker did not acknowledge TerminateThread within 2000ms — at shutdown it \
             would be DETACHED rather than joined"
        );
    }
}