Skip to main content

azul_layout/widgets/
microphone.rs

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
17use alloc::vec::Vec;
18
19use azul_core::audio::{AudioConfig, AudioFrame};
20
21use super::capture_common::mic_backend;
22use azul_core::callbacks::Update;
23use azul_core::dom::{ComponentEventFilter, DatasetMergeCallbackType, Dom, EventFilter};
24use azul_core::refany::{OptionRefAny, RefAny};
25use azul_core::task::{ThreadId, ThreadReceiver};
26use azul_css::impl_option_inner; // for impl_widget_callback!'s impl_option!
27use azul_css::F32Vec;
28
29use crate::callbacks::{Callback, CallbackInfo, CallbackType};
30use 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`].
43pub type OnAudioFrameCallbackType = extern "C" fn(RefAny, CallbackInfo, AudioFrame) -> Update;
44impl_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.
52azul_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).
67fn invoke_on_audio_frame(
68    hook: &OptionOnAudioFrame,
69    info: &CallbackInfo,
70    frame: AudioFrame,
71) -> Update {
72    match hook {
73        OptionOnAudioFrame::Some(h) => (h.callback.cb)(h.refany.clone(), *info, frame),
74        OptionOnAudioFrame::None => Update::DoNothing,
75    }
76}
77
78/// Init data handed to the capture worker thread.
79struct 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)]
87pub 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)]
101pub 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
108impl MicrophoneWidget {
109    /// Create a microphone widget for the given capture config.
110    #[must_use] pub const fn create(config: AudioConfig) -> Self {
111        Self {
112            config,
113            on_frame: OptionOnAudioFrame::None,
114        }
115    }
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    pub fn set_on_frame<C: Into<OnAudioFrameCallback>>(&mut self, data: RefAny, on_frame: C) {
121        self.on_frame = Some(OnAudioFrame {
122            refany: data,
123            callback: on_frame.into(),
124        })
125        .into();
126    }
127
128    /// Builder form of [`set_on_frame`](Self::set_on_frame).
129    #[must_use]
130    pub fn with_on_frame<C: Into<OnAudioFrameCallback>>(
131        mut self,
132        data: RefAny,
133        on_frame: C,
134    ) -> Self {
135        self.set_on_frame(data, on_frame);
136        self
137    }
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    #[must_use] pub fn dom(self) -> Dom {
143        let state = MicrophoneWidgetState {
144            config: self.config,
145            started: false,
146            on_frame: self.on_frame,
147        };
148        let dataset = RefAny::new(state);
149
150        Dom::create_div()
151            .with_dataset(OptionRefAny::Some(dataset.clone()))
152            .with_merge_callback(azul_core::dom::DatasetMergeCallback::from_ptr(merge_microphone_state))
153            .with_callback(
154                EventFilter::Component(ComponentEventFilter::AfterMount),
155                dataset,
156                Callback::from_ptr(mic_on_after_mount),
157            )
158    }
159}
160
161/// `AfterMount`: start the background capture thread exactly once.
162extern "C" fn mic_on_after_mount(mut data: RefAny, mut info: CallbackInfo) -> Update {
163    let (rate, channels) = {
164        let Some(mut s) = data.downcast_mut::<MicrophoneWidgetState>() else {
165            return Update::DoNothing;
166        };
167        if s.started {
168            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}
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
198extern "C" fn mic_worker(mut init: RefAny, mut sender: ThreadSender, _recv: ThreadReceiver) {
199    let (rate, channels) = init
200        .downcast_ref::<MicThreadInit>()
201        .map_or((48_000, 1), |i| (i.sample_rate, i.channels));
202
203    // Real platform capture if the dll registered a mic backend (ALSA on
204    // Linux); otherwise the 440 Hz test tone below.
205    if let Some(backend) = mic_backend() {
206        let handle = (backend.open)(rate, channels);
207        if handle != 0 {
208            let mut buf: Vec<f32> = Vec::new();
209            loop {
210                let frames = (backend.read)(handle, &mut buf);
211                if frames == 0 {
212                    break;
213                }
214                let frame = AudioFrame {
215                    sample_rate: rate,
216                    channels,
217                    samples: F32Vec::from_vec(buf.clone()),
218                };
219                if !sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
220                    WriteBackCallback::new(mic_writeback),
221                    RefAny::new(frame),
222                ))) {
223                    break;
224                }
225            }
226            (backend.close)(handle);
227            return;
228        }
229    }
230
231    let frames_per_chunk = (rate as usize / 50).max(1); // ~20 ms
232    let step = 2.0 * core::f32::consts::PI * 440.0 / rate as f32;
233    let mut phase: f32 = 0.0;
234    loop {
235        let mut samples = Vec::with_capacity(frames_per_chunk * channels as usize);
236        for _ in 0..frames_per_chunk {
237            let s = phase.sin() * 0.2;
238            phase += step;
239            if phase > 2.0 * core::f32::consts::PI {
240                phase -= 2.0 * core::f32::consts::PI;
241            }
242            for _ in 0..channels {
243                samples.push(s);
244            }
245        }
246        let frame = AudioFrame {
247            sample_rate: rate,
248            channels,
249            samples: F32Vec::from_vec(samples),
250        };
251        let sent = sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
252            WriteBackCallback::new(mic_writeback),
253            RefAny::new(frame),
254        )));
255        if !sent {
256            break;
257        }
258        std::thread::sleep(std::time::Duration::from_millis(20));
259    }
260}
261
262/// Writeback (main thread): hand the captured frame to the user's `on_frame`
263/// hook. No GL - audio has no texture.
264extern "C" fn mic_writeback(
265    mut writeback_data: RefAny,
266    mut frame_data: RefAny,
267    info: CallbackInfo,
268) -> Update {
269    let hook = match writeback_data.downcast_ref::<MicrophoneWidgetState>() {
270        Some(s) => s.on_frame.clone(),
271        None => return Update::DoNothing,
272    };
273    frame_data.downcast_ref::<AudioFrame>().map_or(Update::DoNothing, |frame| invoke_on_audio_frame(&hook, &info, frame.clone()))
274}
275
276/// Carry live state forward across relayout (config + started; the `on_frame`
277/// hook is taken from the fresh build).
278extern "C" fn merge_microphone_state(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
279    {
280        let new_guard = new_data.downcast_mut::<MicrophoneWidgetState>();
281        let old_guard = old_data.downcast_ref::<MicrophoneWidgetState>();
282        if let (Some(mut new_g), Some(old_g)) = (new_guard, old_guard) {
283            new_g.started = old_g.started;
284        }
285    }
286    new_data
287}
288
289// ============================================================================
290// Generated adversarial tests
291// ============================================================================
292
293#[cfg(test)]
294#[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]
295mod autotest_generated {
296    use std::{
297        collections::BTreeMap,
298        sync::{
299            mpsc::{channel, Receiver, Sender},
300            Arc, Mutex,
301        },
302    };
303
304    use azul_core::{
305        dom::{DomId, DomNodeId, NodeType},
306        geom::OptionLogicalPosition,
307        gl::OptionGlContextPtr,
308        hit_test::ScrollPosition,
309        resources::RendererResources,
310        styled_dom::NodeHierarchyItemId,
311        task::{
312            OptionThreadSendMsg, ThreadReceiverDestructorCallback, ThreadReceiverInner,
313            ThreadRecvCallback, ThreadSendMsg,
314        },
315        window::{MonitorVec, RawWindowHandle},
316    };
317    use azul_css::system::SystemStyle;
318    use rust_fontconfig::FcFontCache;
319
320    use super::*;
321    #[cfg(feature = "icu")]
322    use crate::icu::IcuLocalizerHandle;
323    use crate::{
324        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
325        thread::{
326            ThreadSendCallback, ThreadSenderDestructorCallback, ThreadSenderInner,
327            WriteBackCallbackType,
328        },
329        window::LayoutWindow,
330        window_state::FullWindowState,
331    };
332
333    // ------------------------------------------------------------------
334    // Helpers
335    // ------------------------------------------------------------------
336
337    /// An `AudioConfig` with the given rate + channel count.
338    const fn cfg(sample_rate: u32, channels: u16) -> AudioConfig {
339        AudioConfig {
340            sample_rate,
341            channels,
342        }
343    }
344
345    /// An interleaved `AudioFrame`. `AudioFrame` has no `Default`, so every test
346    /// spells out its rate / channel count / samples.
347    fn frame(sample_rate: u32, channels: u16, samples: Vec<f32>) -> AudioFrame {
348        AudioFrame {
349            sample_rate,
350            channels,
351            samples: F32Vec::from_vec(samples),
352        }
353    }
354
355    /// A `MicrophoneWidgetState` payload with no `on_frame` hook.
356    fn state(config: AudioConfig, started: bool) -> RefAny {
357        RefAny::new(MicrophoneWidgetState {
358            config,
359            started,
360            on_frame: OptionOnAudioFrame::None,
361        })
362    }
363
364    /// `(config, started, has_hook)` of a `MicrophoneWidgetState` payload.
365    fn read_state(data: &mut RefAny) -> (AudioConfig, bool, bool) {
366        let s = data
367            .downcast_ref::<MicrophoneWidgetState>()
368            .expect("payload must still be a MicrophoneWidgetState");
369        (
370            s.config,
371            s.started,
372            matches!(s.on_frame, OptionOnAudioFrame::Some(_)),
373        )
374    }
375
376    // ---- frame hook -------------------------------------------------------
377
378    /// Records every frame a widget's `on_frame` hook is handed, verbatim.
379    struct FrameLog {
380        seen: Vec<(u32, u16, Vec<f32>)>,
381    }
382
383    extern "C" fn record_frame(mut data: RefAny, _: CallbackInfo, frame: AudioFrame) -> Update {
384        if let Some(mut log) = data.downcast_mut::<FrameLog>() {
385            log.seen.push((
386                frame.sample_rate,
387                frame.channels,
388                frame.samples.as_ref().to_vec(),
389            ));
390        }
391        Update::RefreshDom
392    }
393
394    extern "C" fn frame_do_nothing(_: RefAny, _: CallbackInfo, _: AudioFrame) -> Update {
395        Update::DoNothing
396    }
397
398    /// The frames recorded by a `FrameLog` payload.
399    fn logged_frames(data: &mut RefAny) -> Vec<(u32, u16, Vec<f32>)> {
400        data.downcast_ref::<FrameLog>()
401            .expect("payload must still be a FrameLog")
402            .seen
403            .clone()
404    }
405
406    fn new_log() -> RefAny {
407        RefAny::new(FrameLog { seen: Vec::new() })
408    }
409
410    /// An `on_frame` hook that records into `log`.
411    fn hook_into(log: &RefAny) -> OptionOnAudioFrame {
412        Some(OnAudioFrame {
413            refany: log.clone(),
414            callback: (record_frame as OnAudioFrameCallbackType).into(),
415        })
416        .into()
417    }
418
419    /// A `MicrophoneWidgetState` whose `on_frame` hook writes into `log`.
420    fn state_with_hook(config: AudioConfig, started: bool, log: &RefAny) -> RefAny {
421        RefAny::new(MicrophoneWidgetState {
422            config,
423            started,
424            on_frame: hook_into(log),
425        })
426    }
427
428    // ---- CallbackInfo harness --------------------------------------------
429
430    /// Runs `f` against a real `CallbackInfo` over an empty `LayoutWindow` (no GL
431    /// context). Returns `f`'s value plus every `CallbackChange` the callback
432    /// recorded.
433    fn with_callback_info<R>(f: impl FnOnce(CallbackInfo) -> R) -> (R, Vec<CallbackChange>) {
434        let layout_window =
435            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
436        let renderer_resources = RendererResources::default();
437        let previous_window_state: Option<FullWindowState> = None;
438        let current_window_state = FullWindowState::default();
439        let gl_context = OptionGlContextPtr::None;
440        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
441            BTreeMap::new();
442        let window_handle = RawWindowHandle::Unsupported;
443        let system_callbacks = ExternalSystemCallbacks::rust_internal();
444
445        let ref_data = CallbackInfoRefData {
446            layout_window: &layout_window,
447            renderer_resources: &renderer_resources,
448            previous_window_state: &previous_window_state,
449            current_window_state: &current_window_state,
450            gl_context: &gl_context,
451            current_scroll_manager: &scroll_states,
452            current_window_handle: &window_handle,
453            system_callbacks: &system_callbacks,
454            system_style: Arc::new(SystemStyle::default()),
455            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
456            #[cfg(feature = "icu")]
457            icu_localizer: IcuLocalizerHandle::default(),
458            ctx: OptionRefAny::None,
459        };
460
461        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
462
463        let info = CallbackInfo::new(
464            &ref_data,
465            &changes,
466            DomNodeId {
467                dom: DomId::ROOT_ID,
468                node: NodeHierarchyItemId::NONE,
469            },
470            OptionLogicalPosition::None,
471            OptionLogicalPosition::None,
472        );
473
474        let out = f(info);
475        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
476        (out, recorded)
477    }
478
479    // ---- mic_worker harness ----------------------------------------------
480
481    /// One frame `mic_worker` handed to its sender.
482    #[derive(Debug, Clone, PartialEq)]
483    struct SentFrame {
484        sample_rate: u32,
485        channels: u16,
486        samples: Vec<f32>,
487        /// The writeback fn pointer the worker attached, as an address.
488        writeback: usize,
489    }
490
491    /// Everything `mic_worker` pushed. Guarded by `WORKER_GATE` - the worker's send
492    /// callback is a plain C fn pointer, so it has nowhere else to put its result.
493    static WORKER_LOG: Mutex<Vec<SentFrame>> = Mutex::new(Vec::new());
494    static WORKER_GATE: Mutex<()> = Mutex::new(());
495
496    /// Records the frame, then reports the send as *failed* - i.e. "the main thread
497    /// is gone", the only signal `mic_worker` has to stop. A worker that ignored it
498    /// would hang this test forever (the tone loop is unbounded).
499    extern "C" fn record_and_stop(
500        _sender: *const core::ffi::c_void,
501        msg: ThreadReceiveMsg,
502    ) -> bool {
503        if let ThreadReceiveMsg::WriteBack(mut wb) = msg {
504            let writeback = wb.callback.cb as usize;
505            if let Some(f) = wb.refany.downcast_ref::<AudioFrame>() {
506                WORKER_LOG
507                    .lock()
508                    .unwrap_or_else(std::sync::PoisonError::into_inner)
509                    .push(SentFrame {
510                        sample_rate: f.sample_rate,
511                        channels: f.channels,
512                        samples: f.samples.as_ref().to_vec(),
513                        writeback,
514                    });
515            }
516        }
517        false
518    }
519
520    extern "C" fn sender_drop_noop(_: *mut ThreadSenderInner) {}
521    extern "C" fn receiver_drop_noop(_: *mut ThreadReceiverInner) {}
522    extern "C" fn recv_nothing(_: *const core::ffi::c_void) -> OptionThreadSendMsg {
523        OptionThreadSendMsg::None
524    }
525
526    /// A `ThreadSender` whose every `send` is recorded and then rejected.
527    fn stopped_sender() -> (Receiver<ThreadReceiveMsg>, ThreadSender) {
528        let (tx, rx) = channel::<ThreadReceiveMsg>();
529        let sender = ThreadSender::new(ThreadSenderInner {
530            ptr: Box::new(tx),
531            send_fn: ThreadSendCallback { cb: record_and_stop },
532            destructor: ThreadSenderDestructorCallback {
533                cb: sender_drop_noop,
534            },
535        });
536        (rx, sender)
537    }
538
539    /// A `ThreadReceiver` that never delivers anything (`mic_worker` ignores it).
540    fn silent_receiver() -> (Sender<ThreadSendMsg>, ThreadReceiver) {
541        let (tx, rx) = channel::<ThreadSendMsg>();
542        let receiver = ThreadReceiver::new(ThreadReceiverInner {
543            ptr: Box::new(rx),
544            recv_fn: ThreadRecvCallback { cb: recv_nothing },
545            destructor: ThreadReceiverDestructorCallback {
546                cb: receiver_drop_noop,
547            },
548        });
549        (tx, receiver)
550    }
551
552    /// Runs `mic_worker` with `init` against a sender that rejects the first frame.
553    ///
554    /// Returns `(frames the worker managed to send, took_the_test_tone_path)`. The
555    /// flag is read *after* the run on purpose: `MIC_BACKEND` is a `OnceLock`, so
556    /// "still unregistered afterwards" proves it was unregistered *during* the run
557    /// — and another test in this binary (`capture_common`) may register one at any
558    /// time. Assertions about the 440 Hz tone are gated on it; the path-independent
559    /// invariants are always checked.
560    fn run_worker(init: RefAny) -> (Vec<SentFrame>, bool) {
561        let _gate = WORKER_GATE
562            .lock()
563            .unwrap_or_else(std::sync::PoisonError::into_inner);
564        WORKER_LOG
565            .lock()
566            .unwrap_or_else(std::sync::PoisonError::into_inner)
567            .clear();
568
569        let (_rx, sender) = stopped_sender();
570        let (_tx, receiver) = silent_receiver();
571        mic_worker(init, sender, receiver);
572
573        let sent = WORKER_LOG
574            .lock()
575            .unwrap_or_else(std::sync::PoisonError::into_inner)
576            .clone();
577        (sent, mic_backend().is_none())
578    }
579
580    /// The tone worker's chunk length: ~20 ms of interleaved samples, never empty
581    /// in the frame dimension.
582    fn expected_chunk_len(sample_rate: u32, channels: u16) -> usize {
583        (sample_rate as usize / 50).max(1) * channels as usize
584    }
585
586    // ------------------------------------------------------------------
587    // invoke_on_audio_frame
588    // ------------------------------------------------------------------
589
590    #[test]
591    fn invoke_without_a_hook_is_donothing_even_for_a_degenerate_frame() {
592        let (update, _) = with_callback_info(|info| {
593            // 0 channels + huge rate + no samples: nothing may divide by the channel
594            // count or index the (empty) sample buffer.
595            invoke_on_audio_frame(
596                &OptionOnAudioFrame::None,
597                &info,
598                frame(u32::MAX, 0, Vec::new()),
599            )
600        });
601        assert_eq!(update, Update::DoNothing);
602    }
603
604    #[test]
605    fn invoke_forwards_the_frame_verbatim_and_returns_the_hooks_update() {
606        let mut log = new_log();
607        let hook = hook_into(&log);
608        let samples = vec![-1.0_f32, 0.0, 1.0, 0.5];
609
610        let (update, _) = with_callback_info(|info| {
611            invoke_on_audio_frame(&hook, &info, frame(44_100, 2, samples.clone()))
612        });
613
614        assert_eq!(update, Update::RefreshDom, "the hook's Update must win");
615        assert_eq!(logged_frames(&mut log), vec![(44_100, 2, samples)]);
616    }
617
618    #[test]
619    fn invoke_passes_nan_infinite_and_negative_zero_samples_through_untouched() {
620        // The widget is a transport, not a filter: hostile float payloads must reach
621        // the user hook exactly as captured, with no normalisation and no panic.
622        let mut log = new_log();
623        let hook = hook_into(&log);
624        let hostile = vec![f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, f32::MIN, f32::MAX];
625
626        let (update, _) =
627            with_callback_info(|info| invoke_on_audio_frame(&hook, &info, frame(0, 1, hostile)));
628
629        assert_eq!(update, Update::RefreshDom);
630        let seen = logged_frames(&mut log);
631        assert_eq!(seen.len(), 1);
632        let (rate, channels, samples) = &seen[0];
633        assert_eq!((*rate, *channels), (0, 1), "a 0 Hz frame is forwarded as-is");
634        assert!(samples[0].is_nan(), "NaN must not be normalised");
635        assert_eq!(samples[1], f32::INFINITY);
636        assert_eq!(samples[2], f32::NEG_INFINITY);
637        assert!(
638            samples[3] == 0.0 && samples[3].is_sign_negative(),
639            "-0.0 must keep its sign bit"
640        );
641        assert_eq!(samples[4], f32::MIN);
642        assert_eq!(samples[5], f32::MAX);
643    }
644
645    #[test]
646    fn invoke_forwards_a_frame_whose_sample_count_contradicts_its_channel_count() {
647        // 65535 channels but 3 samples: `frame_count()` must floor to 0 rather than
648        // divide by zero or wrap, and the hook still sees the frame.
649        let mut log = new_log();
650        let hook = hook_into(&log);
651        let bogus = frame(u32::MAX, u16::MAX, vec![0.1, 0.2, 0.3]);
652        assert_eq!(bogus.frame_count(), 0);
653
654        let (update, _) = with_callback_info(|info| invoke_on_audio_frame(&hook, &info, bogus));
655
656        assert_eq!(update, Update::RefreshDom);
657        assert_eq!(logged_frames(&mut log), vec![(u32::MAX, u16::MAX, vec![0.1, 0.2, 0.3])]);
658    }
659
660    // ------------------------------------------------------------------
661    // MicrophoneWidget::create / set_on_frame / with_on_frame
662    // ------------------------------------------------------------------
663
664    #[test]
665    fn create_stores_the_config_verbatim_and_leaves_the_hook_unset() {
666        for (rate, channels) in [
667            (0, 0),
668            (1, 1),
669            (48_000, 2),
670            (u32::MAX, u16::MAX),
671            (u32::MAX, 0),
672            (0, u16::MAX),
673        ] {
674            let widget = MicrophoneWidget::create(cfg(rate, channels));
675            assert_eq!(
676                widget.config,
677                cfg(rate, channels),
678                "create must not normalise the config"
679            );
680            assert!(
681                matches!(widget.on_frame, OptionOnAudioFrame::None),
682                "a fresh widget has no frame hook"
683            );
684        }
685
686        let default = MicrophoneWidget::create(AudioConfig::default());
687        assert_eq!(default.config, cfg(48_000, 1));
688    }
689
690    #[test]
691    fn with_on_frame_installs_the_hook_keeps_the_config_and_shares_the_user_data() {
692        let data = new_log();
693        let widget = MicrophoneWidget::create(cfg(u32::MAX, u16::MAX))
694            .with_on_frame(data.clone(), record_frame as OnAudioFrameCallbackType);
695
696        assert_eq!(
697            widget.config,
698            cfg(u32::MAX, u16::MAX),
699            "the builder must not touch the config"
700        );
701        let OptionOnAudioFrame::Some(hook) = &widget.on_frame else {
702            panic!("with_on_frame must install a hook");
703        };
704        assert_eq!(
705            hook.callback.cb as usize,
706            record_frame as OnAudioFrameCallbackType as usize
707        );
708        assert_eq!(
709            hook.refany, data,
710            "the widget must hold the caller's RefAny, not a fresh allocation"
711        );
712    }
713
714    #[test]
715    fn set_on_frame_twice_keeps_only_the_last_hook_and_releases_the_first_payload() {
716        let first = new_log();
717        let second = new_log();
718        let mut widget = MicrophoneWidget::create(cfg(8_000, 1));
719        widget.set_on_frame(first.clone(), record_frame as OnAudioFrameCallbackType);
720        widget.set_on_frame(second.clone(), frame_do_nothing as OnAudioFrameCallbackType);
721
722        let OptionOnAudioFrame::Some(hook) = &widget.on_frame else {
723            panic!("hook must still be set");
724        };
725        assert_eq!(
726            hook.callback.cb as usize,
727            frame_do_nothing as OnAudioFrameCallbackType as usize,
728            "the second set_on_frame must replace the first"
729        );
730        assert_eq!(hook.refany, second);
731        assert_ne!(hook.refany, first, "the first payload must have been dropped");
732        assert_eq!(widget.config, cfg(8_000, 1));
733    }
734
735    #[test]
736    fn set_on_frame_accepts_the_same_refany_for_both_hooks() {
737        // Re-registering the *same* payload must not free it (a double-drop would
738        // show up as a corrupt downcast here).
739        let mut data = new_log();
740        let mut widget = MicrophoneWidget::create(cfg(48_000, 2));
741        widget.set_on_frame(data.clone(), record_frame as OnAudioFrameCallbackType);
742        widget.set_on_frame(data.clone(), record_frame as OnAudioFrameCallbackType);
743
744        assert!(matches!(widget.on_frame, OptionOnAudioFrame::Some(_)));
745        assert!(logged_frames(&mut data).is_empty());
746    }
747
748    // ------------------------------------------------------------------
749    // MicrophoneWidget::dom
750    // ------------------------------------------------------------------
751
752    #[test]
753    fn dom_is_one_div_with_one_after_mount_callback_a_dataset_and_a_merge_callback() {
754        let dom = MicrophoneWidget::create(cfg(48_000, 2)).dom();
755
756        assert_eq!(dom.root.get_node_type(), &NodeType::Div);
757        assert_eq!(dom.children.as_ref().len(), 0, "the widget is a single node");
758
759        let callbacks = dom.root.get_callbacks();
760        assert_eq!(
761            callbacks.as_ref().len(),
762            1,
763            "exactly one callback: the AfterMount capture-thread starter"
764        );
765        assert_eq!(
766            callbacks.as_ref()[0].event,
767            EventFilter::Component(ComponentEventFilter::AfterMount)
768        );
769        assert_eq!(
770            callbacks.as_ref()[0].callback.cb,
771            mic_on_after_mount as CallbackType as usize
772        );
773
774        let merge = dom
775            .root
776            .get_merge_callback()
777            .expect("state must survive relayout");
778        assert_eq!(
779            merge.cb as usize,
780            merge_microphone_state as DatasetMergeCallbackType as usize
781        );
782
783        let mut dataset = dom
784            .root
785            .get_dataset()
786            .cloned()
787            .expect("the node must carry its MicrophoneWidgetState");
788        assert_eq!(read_state(&mut dataset), (cfg(48_000, 2), false, false));
789    }
790
791    #[test]
792    fn dom_shares_one_state_between_the_dataset_and_the_after_mount_callback() {
793        let dom = MicrophoneWidget::create(cfg(48_000, 1)).dom();
794        let mut dataset = dom.root.get_dataset().cloned().expect("dataset");
795        let mut callback_data = dom.root.get_callbacks().as_ref()[0].refany.clone();
796
797        assert_eq!(
798            callback_data, dataset,
799            "AfterMount must see the very state the dataset carries"
800        );
801
802        {
803            let mut s = dataset
804                .downcast_mut::<MicrophoneWidgetState>()
805                .expect("state");
806            s.started = true;
807        }
808        assert!(
809            read_state(&mut callback_data).1,
810            "a write through the dataset must be visible to the callback"
811        );
812    }
813
814    #[test]
815    fn dom_carries_an_extreme_config_and_the_hook_into_the_state_unnormalised() {
816        // Normalisation (0 -> 48 kHz, channels.max(1)) happens on AfterMount, not at
817        // build time - the state must record exactly what the user asked for.
818        for (rate, channels) in [(0, 0), (1, u16::MAX), (u32::MAX, 1)] {
819            let dom = MicrophoneWidget::create(cfg(rate, channels))
820                .with_on_frame(new_log(), record_frame as OnAudioFrameCallbackType)
821                .dom();
822            let mut dataset = dom.root.get_dataset().cloned().expect("dataset");
823            assert_eq!(read_state(&mut dataset), (cfg(rate, channels), false, true));
824        }
825    }
826
827    #[test]
828    fn dom_built_twice_yields_two_independent_states() {
829        let a = MicrophoneWidget::create(cfg(8_000, 1)).dom();
830        let b = MicrophoneWidget::create(cfg(8_000, 1)).dom();
831        let mut a_ds = a.root.get_dataset().cloned().expect("dataset");
832        let mut b_ds = b.root.get_dataset().cloned().expect("dataset");
833
834        assert_ne!(a_ds, b_ds, "two widgets must not share one capture state");
835        {
836            let mut s = a_ds.downcast_mut::<MicrophoneWidgetState>().expect("state");
837            s.started = true;
838        }
839        assert!(!read_state(&mut b_ds).1, "the second widget is untouched");
840    }
841
842    // ------------------------------------------------------------------
843    // mic_on_after_mount
844    //
845    // NOTE: the *first* mount is deliberately not exercised - it spawns a real
846    // capture thread, and `ThreadInner`'s destructor joins that thread while its
847    // receiver is still alive. `mic_worker` never reads its receiver and only
848    // stops when a send fails, so the join would hang the test binary forever
849    // (see the report). Only the guard paths below can be driven safely.
850    // ------------------------------------------------------------------
851
852    #[test]
853    fn after_mount_ignores_a_dataset_that_is_not_a_microphone_state() {
854        let (update, changes) =
855            with_callback_info(|info| mic_on_after_mount(RefAny::new(0_u32), info));
856
857        assert_eq!(update, Update::DoNothing);
858        assert!(
859            changes.is_empty(),
860            "a foreign dataset must not start a capture thread"
861        );
862    }
863
864    #[test]
865    fn after_mount_is_a_no_op_once_the_capture_thread_has_started() {
866        let mut data = state(cfg(0, 0), true);
867        let (update, changes) = with_callback_info(|info| mic_on_after_mount(data.clone(), info));
868
869        assert_eq!(update, Update::DoNothing);
870        assert!(
871            changes.is_empty(),
872            "AfterMount must start the capture thread at most once"
873        );
874        assert_eq!(
875            read_state(&mut data),
876            (cfg(0, 0), true, false),
877            "a re-mount must not rewrite the state"
878        );
879    }
880
881    #[test]
882    fn after_mount_starts_nothing_while_the_state_is_borrowed_elsewhere() {
883        // A live shared borrow makes `downcast_mut` fail. The guard must bail out
884        // (no thread, no panic) instead of unwrapping.
885        let data = state(cfg(48_000, 2), false);
886        let mut probe = data.clone();
887        let guard = probe
888            .downcast_ref::<MicrophoneWidgetState>()
889            .expect("shared borrow");
890
891        let (update, changes) = with_callback_info(|info| mic_on_after_mount(data.clone(), info));
892
893        assert_eq!(update, Update::DoNothing);
894        assert!(changes.is_empty(), "a borrowed state must not be mounted");
895        assert!(!guard.started, "the state must still be untouched");
896        drop(guard);
897
898        let mut after = data;
899        assert_eq!(read_state(&mut after), (cfg(48_000, 2), false, false));
900    }
901
902    // ------------------------------------------------------------------
903    // mic_worker
904    // ------------------------------------------------------------------
905
906    #[test]
907    fn worker_stops_after_the_first_rejected_send_and_tags_frames_with_its_init() {
908        let (sent, tone_path) = run_worker(RefAny::new(MicThreadInit {
909            sample_rate: 8_000,
910            channels: 2,
911        }));
912
913        // Path-independent: a rejected send stops the loop, and every frame carries
914        // the requested format plus the mic writeback.
915        assert!(
916            sent.len() <= 1,
917            "the worker must stop after the first rejected send, not spin"
918        );
919        for f in &sent {
920            assert_eq!((f.sample_rate, f.channels), (8_000, 2));
921            assert_eq!(f.writeback, mic_writeback as WriteBackCallbackType as usize);
922        }
923
924        if !tone_path {
925            return; // a platform backend is registered: not the test tone
926        }
927        assert_eq!(sent.len(), 1);
928        let samples = &sent[0].samples;
929        assert_eq!(
930            samples.len(),
931            expected_chunk_len(8_000, 2),
932            "~20 ms of interleaved stereo at 8 kHz"
933        );
934        assert!(
935            samples.iter().all(|s| s.is_finite() && s.abs() <= 0.2),
936            "the tone must stay finite and inside +/-0.2"
937        );
938        assert_eq!(samples[0], 0.0, "the tone starts at phase 0");
939        for pair in samples.chunks_exact(2) {
940            assert_eq!(pair[0], pair[1], "both channels carry the same mono tone");
941        }
942    }
943
944    #[test]
945    fn worker_with_a_foreign_init_falls_back_to_48khz_mono() {
946        let (sent, tone_path) = run_worker(RefAny::new(0_u64));
947
948        for f in &sent {
949            assert_eq!(
950                (f.sample_rate, f.channels),
951                (48_000, 1),
952                "a bad init must not panic - it defaults"
953            );
954        }
955        if !tone_path {
956            return;
957        }
958        assert_eq!(sent.len(), 1);
959        assert_eq!(sent[0].samples.len(), expected_chunk_len(48_000, 1));
960    }
961
962    #[test]
963    fn worker_with_a_zero_sample_rate_emits_one_finite_chunk_instead_of_dividing_by_zero() {
964        // rate 0 makes the phase step `2*PI*440/0.0` = +inf. The chunk length still
965        // has to clamp to >= 1 frame, the emitted samples still have to be finite,
966        // and the worker still has to terminate.
967        let (sent, tone_path) = run_worker(RefAny::new(MicThreadInit {
968            sample_rate: 0,
969            channels: 1,
970        }));
971
972        // Path-independent: whatever produced the frame, it carries the requested
973        // format.
974        for f in &sent {
975            assert_eq!((f.sample_rate, f.channels), (0, 1));
976        }
977        if !tone_path {
978            return;
979        }
980        // Tone-path only, like every sibling worker test. `capture_common`'s
981        // `register_mic_backend_is_first_wins_and_passes_f32_samples_through`
982        // installs a process-wide `OnceLock` backend whose `read` deliberately
983        // yields `[NaN, inf, -inf, -0.0]` to prove the vtable passes samples
984        // through untouched. Once that test has run, `mic_worker` takes the
985        // backend branch and never evaluates a phase step at all — so asserting
986        // finiteness ABOVE the gate made this test fail depending on which other
987        // test happened to run first in the same process. (It passed under
988        // `cargo nextest`, which forks per test, and failed under `cargo test`,
989        // which does not.)
990        assert!(
991            sent.iter().all(|f| f.samples.iter().all(|s| s.is_finite())),
992            "an infinite phase step must not leak NaN/inf into the samples"
993        );
994        assert_eq!(sent.len(), 1);
995        assert_eq!(sent[0].samples, vec![0.0_f32], "one frame, at phase 0");
996    }
997
998    #[test]
999    fn worker_with_zero_channels_emits_an_empty_chunk_and_stops() {
1000        let (sent, tone_path) = run_worker(RefAny::new(MicThreadInit {
1001            sample_rate: 48_000,
1002            channels: 0,
1003        }));
1004
1005        for f in &sent {
1006            assert_eq!(f.channels, 0);
1007        }
1008        if !tone_path {
1009            return;
1010        }
1011        assert_eq!(sent.len(), 1);
1012        assert!(
1013            sent[0].samples.is_empty(),
1014            "0 channels interleaves 0 samples per frame"
1015        );
1016        // The frame such a worker produces must still be safe to inspect.
1017        assert_eq!(frame(48_000, 0, sent[0].samples.clone()).frame_count(), 0);
1018    }
1019
1020    #[test]
1021    fn worker_chunk_length_clamps_to_one_frame_for_sub_50hz_rates() {
1022        // `rate / 50` truncates to 0 below 50 Hz; without the `.max(1)` the worker
1023        // would emit empty chunks forever.
1024        for (rate, channels, expected) in [
1025            (1_u32, 1_u16, 1_usize),
1026            (49, 1, 1),
1027            (50, 1, 1),
1028            (99, 2, 2),
1029            (100, 2, 4),
1030            (100, 3, 6),
1031        ] {
1032            let (sent, tone_path) = run_worker(RefAny::new(MicThreadInit {
1033                sample_rate: rate,
1034                channels,
1035            }));
1036            if !tone_path {
1037                return;
1038            }
1039            assert_eq!(sent.len(), 1, "rate {rate} must emit exactly one chunk");
1040            assert_eq!(
1041                sent[0].samples.len(),
1042                expected,
1043                "rate {rate} x {channels} ch must clamp to >= 1 frame"
1044            );
1045            assert_eq!(expected_chunk_len(rate, channels), expected);
1046            assert!(
1047                sent[0].samples.iter().all(|s| s.is_finite() && s.abs() <= 0.2),
1048                "a phase step larger than a full period must still yield bounded samples"
1049            );
1050        }
1051    }
1052
1053    // ------------------------------------------------------------------
1054    // mic_writeback
1055    // ------------------------------------------------------------------
1056
1057    #[test]
1058    fn writeback_hands_the_frame_to_the_hook_and_returns_its_update() {
1059        let mut log = new_log();
1060        let data = state_with_hook(cfg(44_100, 2), true, &log);
1061        let frame_data = RefAny::new(frame(44_100, 2, vec![0.25, -0.25, 0.5, -0.5]));
1062
1063        let (update, _) =
1064            with_callback_info(|info| mic_writeback(data.clone(), frame_data.clone(), info));
1065
1066        assert_eq!(update, Update::RefreshDom, "the hook's Update must win");
1067        assert_eq!(
1068            logged_frames(&mut log),
1069            vec![(44_100, 2, vec![0.25, -0.25, 0.5, -0.5])]
1070        );
1071    }
1072
1073    #[test]
1074    fn writeback_without_a_hook_is_a_no_op() {
1075        let data = state(cfg(48_000, 1), true);
1076        let frame_data = RefAny::new(frame(48_000, 1, vec![0.0; 8]));
1077
1078        let (update, changes) =
1079            with_callback_info(|info| mic_writeback(data.clone(), frame_data.clone(), info));
1080
1081        assert_eq!(update, Update::DoNothing);
1082        assert!(changes.is_empty(), "audio has no texture - nothing to change");
1083    }
1084
1085    #[test]
1086    fn writeback_ignores_frame_data_of_the_wrong_type() {
1087        let mut log = new_log();
1088        let data = state_with_hook(cfg(48_000, 1), true, &log);
1089
1090        let (update, changes) =
1091            with_callback_info(|info| mic_writeback(data.clone(), RefAny::new(0_u32), info));
1092
1093        assert_eq!(update, Update::DoNothing);
1094        assert!(changes.is_empty());
1095        assert!(
1096            logged_frames(&mut log).is_empty(),
1097            "the user hook must not fire without a frame"
1098        );
1099    }
1100
1101    #[test]
1102    fn writeback_survives_a_writeback_dataset_that_is_not_a_microphone_state() {
1103        let (update, changes) = with_callback_info(|info| {
1104            mic_writeback(RefAny::new(0_u32), RefAny::new(frame(8_000, 1, vec![0.0])), info)
1105        });
1106
1107        assert_eq!(
1108            update,
1109            Update::DoNothing,
1110            "a foreign dataset means no hook - but no panic either"
1111        );
1112        assert!(changes.is_empty());
1113    }
1114
1115    #[test]
1116    fn writeback_forwards_a_malformed_frame_to_the_hook_untouched() {
1117        // Hostile payload: a rate/channel count that no device produces, no samples,
1118        // NaN-free but nonsensical. The writeback is a transport - it must neither
1119        // validate nor panic.
1120        let mut log = new_log();
1121        let data = state_with_hook(cfg(48_000, 2), true, &log);
1122        let bogus = RefAny::new(frame(u32::MAX, u16::MAX, Vec::new()));
1123
1124        let (update, _) =
1125            with_callback_info(|info| mic_writeback(data.clone(), bogus.clone(), info));
1126
1127        assert_eq!(update, Update::RefreshDom);
1128        assert_eq!(logged_frames(&mut log), vec![(u32::MAX, u16::MAX, Vec::new())]);
1129    }
1130
1131    #[test]
1132    fn writeback_is_a_no_op_while_the_state_is_mutably_borrowed() {
1133        let mut log = new_log();
1134        let data = state_with_hook(cfg(48_000, 1), true, &log);
1135        let mut probe = data.clone();
1136        let guard = probe
1137            .downcast_mut::<MicrophoneWidgetState>()
1138            .expect("exclusive borrow");
1139
1140        let frame_data = RefAny::new(frame(48_000, 1, vec![0.1]));
1141        let (update, changes) =
1142            with_callback_info(|info| mic_writeback(data.clone(), frame_data.clone(), info));
1143
1144        assert_eq!(update, Update::DoNothing, "a blocked downcast must not panic");
1145        assert!(changes.is_empty());
1146        drop(guard);
1147        assert!(logged_frames(&mut log).is_empty());
1148    }
1149
1150    // ------------------------------------------------------------------
1151    // merge_microphone_state
1152    // ------------------------------------------------------------------
1153
1154    #[test]
1155    fn merge_takes_started_from_old_and_everything_else_from_new() {
1156        let log = new_log();
1157        let new_data = state_with_hook(cfg(44_100, 2), false, &log);
1158        let old_data = state(cfg(8_000, 1), true);
1159
1160        let mut merged = merge_microphone_state(new_data, old_data);
1161
1162        assert_eq!(
1163            read_state(&mut merged),
1164            (cfg(44_100, 2), true, true),
1165            "config + hook come from the fresh build, 'started' from the old state"
1166        );
1167    }
1168
1169    #[test]
1170    fn merge_takes_started_from_old_even_when_that_clears_it() {
1171        // The old state is authoritative for the thread flag in both directions -
1172        // otherwise a remount could start a second capture thread.
1173        let new_data = state(cfg(48_000, 1), true);
1174        let old_data = state(cfg(48_000, 1), false);
1175
1176        let mut merged = merge_microphone_state(new_data, old_data);
1177        assert!(!read_state(&mut merged).1);
1178    }
1179
1180    #[test]
1181    fn merge_returns_the_new_allocation_itself_not_a_copy() {
1182        let new_data = state(cfg(48_000, 1), false);
1183        let handle = new_data.clone();
1184
1185        let merged = merge_microphone_state(new_data, state(cfg(48_000, 1), true));
1186
1187        assert_eq!(merged, handle, "merge must hand back the same state object");
1188    }
1189
1190    #[test]
1191    fn merge_leaves_the_new_state_alone_when_the_old_one_is_foreign() {
1192        let new_data = state(cfg(48_000, 2), true);
1193        let mut merged = merge_microphone_state(new_data, RefAny::new(0_u32));
1194
1195        assert_eq!(
1196            read_state(&mut merged),
1197            (cfg(48_000, 2), true, false),
1198            "nothing to carry forward from a foreign payload"
1199        );
1200    }
1201
1202    #[test]
1203    fn merge_returns_a_foreign_new_dataset_untouched() {
1204        let old_data = state(cfg(48_000, 1), true);
1205        let mut merged = merge_microphone_state(RefAny::new(77_u32), old_data);
1206
1207        assert_eq!(
1208            merged.downcast_ref::<u32>().map(|v| *v),
1209            Some(77),
1210            "merge must hand back exactly the payload it was given"
1211        );
1212    }
1213
1214    #[test]
1215    fn merge_of_a_dataset_with_itself_does_not_panic() {
1216        // The same RefAny on both sides: the mutable + shared borrow overlap, so the
1217        // merge is skipped rather than aliasing. Either way the state must survive.
1218        let mut data = state_with_hook(cfg(48_000, 2), true, &new_log());
1219        let mut merged = merge_microphone_state(data.clone(), data.clone());
1220
1221        assert_eq!(read_state(&mut merged), (cfg(48_000, 2), true, true));
1222        assert_eq!(read_state(&mut data), (cfg(48_000, 2), true, true));
1223    }
1224
1225    #[test]
1226    fn a_rebuilt_dom_merges_the_running_thread_flag_forward() {
1227        // The relayout round trip, through the callbacks `dom()` actually wires:
1228        // mount marks `started`, the fresh build starts at `false`, and the merge
1229        // carries the flag across so AfterMount cannot start a second thread.
1230        let old = MicrophoneWidget::create(cfg(48_000, 1)).dom();
1231        let mut old_ds = old.root.get_dataset().cloned().expect("dataset");
1232        {
1233            let mut s = old_ds
1234                .downcast_mut::<MicrophoneWidgetState>()
1235                .expect("state");
1236            s.started = true;
1237        }
1238
1239        let new = MicrophoneWidget::create(cfg(44_100, 2))
1240            .with_on_frame(new_log(), record_frame as OnAudioFrameCallbackType)
1241            .dom();
1242        let new_ds = new.root.get_dataset().cloned().expect("dataset");
1243        let merge = new.root.get_merge_callback().expect("merge callback");
1244
1245        let mut merged = (merge.cb)(new_ds, old_ds);
1246
1247        assert_eq!(
1248            read_state(&mut merged),
1249            (cfg(44_100, 2), true, true),
1250            "the rebuilt widget keeps its new config + hook but inherits the thread"
1251        );
1252    }
1253}