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}