Skip to main content

dioxus_audio/
recorder.rs

1//! Audio recording state and hooks.
2
3use std::cell::RefCell;
4use std::fmt;
5use std::rc::Rc;
6use std::time::Duration;
7
8use dioxus::prelude::*;
9#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
10use wasm_bindgen::JsCast;
11
12use crate::AudioError;
13use crate::analysis::AudioAnalyser;
14use crate::devices::MicrophonePermission;
15use crate::{AudioInputId, RecordedAudio, RecordingChunk, RecordingId};
16
17#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
18mod web;
19
20/// An opaque, application-supplied live audio source.
21///
22/// Recorder snapshots exactly one live audio track and its shutdown agreement
23/// when [`AudioRecorder::start_with_source`] accepts a Recording. The default
24/// agreement preserves the track.
25#[derive(Clone)]
26pub struct RecordingSource {
27    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
28    stream: web_sys::MediaStream,
29    shutdown: RecordingSourceShutdown,
30    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
31    _unsupported: (),
32}
33
34impl fmt::Debug for RecordingSource {
35    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36        formatter
37            .debug_struct("RecordingSource")
38            .field("shutdown", &self.shutdown)
39            .finish_non_exhaustive()
40    }
41}
42
43/// Authority granted to Recorder for an accepted supplied Recording Source.
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
45pub enum RecordingSourceShutdown {
46    /// Leave the accepted audio track under application control.
47    #[default]
48    PreserveTracks,
49    /// Stop the accepted audio track when Recorder cleans up the Recording.
50    ///
51    /// Stopping a shared track affects every consumer of that track.
52    StopAudioTracks,
53}
54
55/// Browser-reported availability of the accepted Recording Source.
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum RecordingSourceAvailability {
58    Live,
59    Interrupted,
60}
61
62#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
63impl RecordingSource {
64    /// Wrap a browser media stream as a preserved Recording Source.
65    ///
66    /// The raw stream remains application-owned and is not exposed again by
67    /// Recorder. Source validation occurs when a Recording is started.
68    pub fn from_media_stream(stream: &web_sys::MediaStream) -> Self {
69        Self {
70            stream: <web_sys::MediaStream as AsRef<wasm_bindgen::JsValue>>::as_ref(stream)
71                .clone()
72                .unchecked_into(),
73            shutdown: RecordingSourceShutdown::PreserveTracks,
74        }
75    }
76
77    /// Set the shutdown authority snapshotted when Recorder accepts a start.
78    pub fn with_shutdown(mut self, shutdown: RecordingSourceShutdown) -> Self {
79        self.shutdown = shutdown;
80        self
81    }
82}
83
84#[derive(Clone, Debug, PartialEq, Eq)]
85#[non_exhaustive]
86pub enum RecorderStatus {
87    Idle,
88    Preparing,
89    Recording,
90    Paused,
91    Stopping,
92    Failed(AudioError),
93}
94
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub enum CompletionDisposition {
97    Save,
98    Discard,
99}
100
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102enum RecordingTerminalIntent {
103    Save(RecordingCompletionCause),
104    Discard,
105}
106
107impl RecordingTerminalIntent {
108    fn disposition(self) -> CompletionDisposition {
109        match self {
110            Self::Save(_) => CompletionDisposition::Save,
111            Self::Discard => CompletionDisposition::Discard,
112        }
113    }
114
115    fn completion_cause(self) -> Option<RecordingCompletionCause> {
116        match self {
117            Self::Save(cause) => Some(cause),
118            Self::Discard => None,
119        }
120    }
121}
122
123/// Why a Recording completed with Recorded Audio.
124#[derive(Clone, Copy, Debug, PartialEq, Eq)]
125pub enum RecordingCompletionCause {
126    /// The application requested completion.
127    Requested,
128    /// The accepted Recording Source ended externally.
129    SourceEnded,
130    /// The Recording ended without an earlier application or source intent.
131    UnexpectedEnd,
132}
133
134/// The terminal outcome of an accepted Recording.
135#[derive(Clone, Debug, PartialEq, Eq)]
136#[non_exhaustive]
137pub enum RecordingOutcome {
138    Completed {
139        recording_id: RecordingId,
140        cause: RecordingCompletionCause,
141    },
142    Discarded(RecordingId),
143    Failed {
144        recording_id: RecordingId,
145        error: AudioError,
146    },
147}
148
149impl RecordingOutcome {
150    pub fn recording_id(&self) -> RecordingId {
151        match self {
152            Self::Completed { recording_id, .. } | Self::Discarded(recording_id) => *recording_id,
153            Self::Failed { recording_id, .. } => *recording_id,
154        }
155    }
156}
157
158/// The terminal failure of incremental Recording Chunk delivery.
159///
160/// Capture and final Recorded Audio assembly continue independently after this
161/// failure, but this Recording will deliver no later chunks.
162#[derive(Clone, Debug, PartialEq, Eq)]
163pub struct RecordingChunkDeliveryFailure {
164    recording_id: RecordingId,
165    failed_sequence: u64,
166    error: AudioError,
167}
168
169impl RecordingChunkDeliveryFailure {
170    pub fn recording_id(&self) -> RecordingId {
171        self.recording_id
172    }
173
174    pub fn failed_sequence(&self) -> u64 {
175        self.failed_sequence
176    }
177
178    pub fn error(&self) -> &AudioError {
179        &self.error
180    }
181}
182
183#[derive(Clone, Debug, PartialEq, Eq)]
184pub struct RecorderCommandError {
185    message: &'static str,
186}
187
188impl RecorderCommandError {
189    pub fn message(&self) -> &'static str {
190        self.message
191    }
192}
193
194impl fmt::Display for RecorderCommandError {
195    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
196        formatter.write_str(self.message)
197    }
198}
199
200impl std::error::Error for RecorderCommandError {}
201
202#[derive(Debug)]
203pub struct RecorderLifecycle {
204    status: RecorderStatus,
205    generation: u64,
206    active_recording: Option<RecordingId>,
207    next_chunk_sequence: u64,
208    terminal_intent: RecordingTerminalIntent,
209    finalizing: bool,
210    has_unconsumed_recording: bool,
211}
212
213impl Default for RecorderLifecycle {
214    fn default() -> Self {
215        Self {
216            status: RecorderStatus::Idle,
217            generation: 0,
218            active_recording: None,
219            next_chunk_sequence: 0,
220            terminal_intent: RecordingTerminalIntent::Save(RecordingCompletionCause::Requested),
221            finalizing: false,
222            has_unconsumed_recording: false,
223        }
224    }
225}
226
227impl RecorderLifecycle {
228    pub fn status(&self) -> &RecorderStatus {
229        &self.status
230    }
231
232    pub fn start(&mut self) -> Result<RecordingId, RecorderCommandError> {
233        if self.has_unconsumed_recording {
234            return Err(command_error(
235                "clear the completed recording before starting another",
236            ));
237        }
238        if !matches!(
239            self.status,
240            RecorderStatus::Idle | RecorderStatus::Failed(_)
241        ) {
242            return Err(command_error("recording is already active"));
243        }
244
245        self.generation = self.generation.wrapping_add(1);
246        let recording_id = RecordingId::from_generation(self.generation);
247        self.active_recording = Some(recording_id);
248        self.next_chunk_sequence = 0;
249        self.terminal_intent = RecordingTerminalIntent::Save(RecordingCompletionCause::Requested);
250        self.finalizing = false;
251        self.status = RecorderStatus::Preparing;
252        Ok(recording_id)
253    }
254
255    pub fn started(&mut self, recording_id: RecordingId) -> bool {
256        if self.active_recording == Some(recording_id)
257            && matches!(self.status, RecorderStatus::Preparing)
258        {
259            self.status = RecorderStatus::Recording;
260            true
261        } else {
262            false
263        }
264    }
265
266    pub fn stop(&mut self) -> Result<(), RecorderCommandError> {
267        if !matches!(
268            self.status,
269            RecorderStatus::Recording | RecorderStatus::Paused
270        ) {
271            return Err(command_error(
272                "recording cannot be stopped in its current state",
273            ));
274        }
275
276        self.terminal_intent = RecordingTerminalIntent::Save(RecordingCompletionCause::Requested);
277        self.status = RecorderStatus::Stopping;
278        Ok(())
279    }
280
281    /// Accept external Recording Source completion as the terminal intent.
282    pub fn source_ended(&mut self) -> bool {
283        if !matches!(
284            self.status,
285            RecorderStatus::Recording | RecorderStatus::Paused
286        ) {
287            return false;
288        }
289
290        self.terminal_intent = RecordingTerminalIntent::Save(RecordingCompletionCause::SourceEnded);
291        self.status = RecorderStatus::Stopping;
292        true
293    }
294
295    pub fn completion_cause(&self, recording_id: RecordingId) -> Option<RecordingCompletionCause> {
296        (self.active_recording == Some(recording_id))
297            .then(|| self.terminal_intent.completion_cause())
298            .flatten()
299    }
300
301    pub fn pause(&mut self) -> Result<(), RecorderCommandError> {
302        if !matches!(self.status, RecorderStatus::Recording) {
303            return Err(command_error("recording can only be paused while active"));
304        }
305        self.status = RecorderStatus::Paused;
306        Ok(())
307    }
308
309    pub fn resume(&mut self) -> Result<(), RecorderCommandError> {
310        if !matches!(self.status, RecorderStatus::Paused) {
311            return Err(command_error("recording can only be resumed while paused"));
312        }
313        self.status = RecorderStatus::Recording;
314        Ok(())
315    }
316
317    pub fn request_chunk_boundary(&self) -> Result<(), RecorderCommandError> {
318        if !matches!(
319            self.status,
320            RecorderStatus::Recording | RecorderStatus::Paused
321        ) {
322            return Err(command_error(
323                "a chunk boundary can only be requested while recording or paused",
324            ));
325        }
326        Ok(())
327    }
328
329    /// Reserve the next contiguous sequence for a non-empty Recording Chunk.
330    pub fn next_chunk_sequence(&mut self, recording_id: RecordingId) -> Option<u64> {
331        let accepts_chunk = self.active_recording == Some(recording_id)
332            && !self.finalizing
333            && (matches!(
334                self.status,
335                RecorderStatus::Recording | RecorderStatus::Paused
336            ) || (matches!(self.status, RecorderStatus::Stopping)
337                && matches!(self.terminal_intent, RecordingTerminalIntent::Save(_))));
338        if !accepts_chunk {
339            return None;
340        }
341
342        let sequence = self.next_chunk_sequence;
343        self.next_chunk_sequence = self.next_chunk_sequence.checked_add(1)?;
344        Some(sequence)
345    }
346
347    pub fn cancel(&mut self) -> Result<(), RecorderCommandError> {
348        match self.status {
349            RecorderStatus::Preparing => {
350                self.active_recording = None;
351                self.status = RecorderStatus::Idle;
352            }
353            RecorderStatus::Recording | RecorderStatus::Paused => {
354                self.terminal_intent = RecordingTerminalIntent::Discard;
355                self.status = RecorderStatus::Stopping;
356            }
357            _ => {
358                return Err(command_error(
359                    "recording cannot be cancelled in its current state",
360                ));
361            }
362        }
363
364        Ok(())
365    }
366
367    pub fn begin_finalize(&mut self, recording_id: RecordingId) -> Option<CompletionDisposition> {
368        if self.active_recording != Some(recording_id) || self.finalizing {
369            return None;
370        }
371
372        match self.status {
373            RecorderStatus::Stopping => {}
374            RecorderStatus::Recording | RecorderStatus::Paused => {
375                self.terminal_intent =
376                    RecordingTerminalIntent::Save(RecordingCompletionCause::UnexpectedEnd);
377                self.status = RecorderStatus::Stopping;
378            }
379            _ => return None,
380        }
381
382        self.finalizing = true;
383        Some(self.terminal_intent.disposition())
384    }
385
386    pub fn complete_finalize(&mut self, recording_id: RecordingId) -> bool {
387        if self.active_recording != Some(recording_id) || !self.finalizing {
388            return false;
389        }
390
391        self.active_recording = None;
392        self.finalizing = false;
393        self.has_unconsumed_recording =
394            matches!(self.terminal_intent, RecordingTerminalIntent::Save(_));
395        self.status = RecorderStatus::Idle;
396        true
397    }
398
399    pub fn clear_completed(&mut self) {
400        self.has_unconsumed_recording = false;
401    }
402
403    pub fn failed(&mut self, recording_id: RecordingId, error: AudioError) -> bool {
404        if self.active_recording != Some(recording_id) {
405            return false;
406        }
407
408        self.active_recording = None;
409        self.finalizing = false;
410        self.status = RecorderStatus::Failed(error);
411        true
412    }
413
414    pub fn configuration_failed(&mut self, error: AudioError) -> bool {
415        if self.active_recording.is_some()
416            || !matches!(
417                self.status,
418                RecorderStatus::Idle | RecorderStatus::Failed(_)
419            )
420        {
421            return false;
422        }
423        self.status = RecorderStatus::Failed(error);
424        true
425    }
426
427    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
428    fn abandon(&mut self) {
429        self.active_recording = None;
430        self.finalizing = false;
431    }
432}
433
434fn command_error(message: &'static str) -> RecorderCommandError {
435    RecorderCommandError { message }
436}
437
438/// A best-effort or required value requested for a Recording Source.
439#[derive(Clone, Debug, PartialEq, Eq)]
440pub enum RecordingConstraint<T> {
441    /// Prefer this value while allowing the browser to select another.
442    Ideal(T),
443    /// Require this value or reject source acquisition.
444    Exact(T),
445}
446
447/// Portable startup constraints applied when the Recorder acquires a source.
448#[derive(Clone, Debug, Default, PartialEq, Eq)]
449pub struct RecordingConstraints {
450    pub channel_count: Option<RecordingConstraint<u32>>,
451    pub sample_rate: Option<RecordingConstraint<u32>>,
452    pub echo_cancellation: Option<RecordingConstraint<bool>>,
453    pub noise_suppression: Option<RecordingConstraint<bool>>,
454    pub latency: Option<RecordingConstraint<Duration>>,
455}
456
457/// Constraint fields that the browser reports recognizing.
458///
459/// Recognition does not prove that any particular value is available.
460#[derive(Clone, Debug, Default, PartialEq, Eq)]
461pub struct RecorderConstraintCapabilities {
462    pub channel_count: bool,
463    pub sample_rate: bool,
464    pub echo_cancellation: bool,
465    pub noise_suppression: bool,
466    pub latency: bool,
467}
468
469/// Effective settings reported by an acquired Recording Source.
470///
471/// Every field is optional because browser reporting varies.
472#[derive(Clone, Debug, Default, PartialEq, Eq)]
473pub struct RecordingSourceSettings {
474    pub channel_count: Option<u32>,
475    pub sample_rate: Option<u32>,
476    pub echo_cancellation: Option<bool>,
477    pub noise_suppression: Option<bool>,
478    pub latency: Option<Duration>,
479}
480
481type RecordingChunkHandler = Rc<RefCell<Box<dyn FnMut(RecordingChunk)>>>;
482
483/// Opt-in cadence and callback for ordered Recording Chunk delivery.
484#[derive(Clone)]
485pub struct RecordingChunkDelivery {
486    /// Approximate interval at which the browser should create chunk boundaries.
487    ///
488    /// Boundaries may be empty, late, or dependent on earlier fragments.
489    pub cadence: Duration,
490    on_chunk: RecordingChunkHandler,
491}
492
493impl fmt::Debug for RecordingChunkDelivery {
494    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
495        formatter
496            .debug_struct("RecordingChunkDelivery")
497            .field("cadence", &self.cadence)
498            .finish_non_exhaustive()
499    }
500}
501
502impl PartialEq for RecordingChunkDelivery {
503    fn eq(&self, other: &Self) -> bool {
504        self.cadence == other.cadence && Rc::ptr_eq(&self.on_chunk, &other.on_chunk)
505    }
506}
507
508impl RecordingChunkDelivery {
509    /// Configure ordered push delivery at an approximate cadence.
510    pub fn new(cadence: Duration, on_chunk: impl FnMut(RecordingChunk) + 'static) -> Self {
511        Self {
512            cadence,
513            on_chunk: Rc::new(RefCell::new(Box::new(on_chunk))),
514        }
515    }
516
517    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
518    fn call(&self, chunk: RecordingChunk) {
519        (self.on_chunk.borrow_mut())(chunk);
520    }
521
522    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
523    fn time_slice_millis(&self) -> i32 {
524        i32::try_from(self.cadence.as_millis())
525            .expect("validated Recording Chunk cadence fits in an i32")
526    }
527}
528
529#[derive(Clone, Debug, PartialEq)]
530#[non_exhaustive]
531pub struct RecorderOptions {
532    pub fft_size: u32,
533    pub smoothing: f64,
534    pub peak_interval: Duration,
535    pub constraints: RecordingConstraints,
536    pub mime_types: Vec<String>,
537    pub audio_bits_per_second: Option<u32>,
538    pub chunk_delivery: Option<RecordingChunkDelivery>,
539}
540
541impl Default for RecorderOptions {
542    fn default() -> Self {
543        Self {
544            fft_size: 256,
545            smoothing: 0.8,
546            peak_interval: Duration::from_millis(100),
547            constraints: RecordingConstraints::default(),
548            mime_types: vec![
549                "audio/webm;codecs=opus".to_string(),
550                "audio/webm".to_string(),
551                "audio/mp4".to_string(),
552            ],
553            audio_bits_per_second: None,
554            chunk_delivery: None,
555        }
556    }
557}
558
559impl RecorderOptions {
560    pub fn validate(&self) -> Result<(), AudioError> {
561        if !(32..=32768).contains(&self.fft_size) || !self.fft_size.is_power_of_two() {
562            return Err(AudioError::new(
563                crate::AudioErrorKind::InvalidConfiguration,
564                "fft_size must be a power of two between 32 and 32768",
565            ));
566        }
567        if !(0.0..=1.0).contains(&self.smoothing) {
568            return Err(AudioError::new(
569                crate::AudioErrorKind::InvalidConfiguration,
570                "smoothing must be between 0 and 1",
571            ));
572        }
573        if self.peak_interval.is_zero() {
574            return Err(AudioError::new(
575                crate::AudioErrorKind::InvalidConfiguration,
576                "peak_interval must be greater than zero",
577            ));
578        }
579        if let Some(delivery) = &self.chunk_delivery
580            && (delivery.cadence.as_millis() == 0
581                || delivery.cadence.as_millis() > i32::MAX as u128)
582        {
583            return Err(AudioError::new(
584                crate::AudioErrorKind::InvalidConfiguration,
585                "Recording Chunk cadence must be between 1 millisecond and 2147483647 milliseconds",
586            ));
587        }
588        Ok(())
589    }
590}
591
592#[derive(Clone, Debug, PartialEq, Eq)]
593pub struct MicrophoneStatus {
594    pub permission: MicrophonePermission,
595    pub recorder: RecorderStatus,
596    pub input_device: Option<AudioInputId>,
597    pub muted: bool,
598}
599
600#[derive(Clone, Copy, PartialEq)]
601pub struct AudioRecorder {
602    status: ReadSignal<RecorderStatus>,
603    completed: ReadSignal<Option<RecordedAudio>>,
604    analyser: ReadSignal<Option<AudioAnalyser>>,
605    elapsed: ReadSignal<Duration>,
606    source_availability: ReadSignal<Option<RecordingSourceAvailability>>,
607    microphone: ReadSignal<MicrophoneStatus>,
608    requested_constraints: ReadSignal<Option<RecordingConstraints>>,
609    constraint_capabilities: ReadSignal<Option<RecorderConstraintCapabilities>>,
610    settings: ReadSignal<Option<RecordingSourceSettings>>,
611    media_type: ReadSignal<Option<String>>,
612    outcome: ReadSignal<Option<RecordingOutcome>>,
613    chunk_delivery_failure: ReadSignal<Option<RecordingChunkDeliveryFailure>>,
614    start: Callback<(), Result<(), RecorderCommandError>>,
615    start_with_source: Callback<RecordingSource, Result<(), RecorderCommandError>>,
616    pause: Callback<(), Result<(), RecorderCommandError>>,
617    resume: Callback<(), Result<(), RecorderCommandError>>,
618    request_chunk_boundary: Callback<(), Result<(), RecorderCommandError>>,
619    stop: Callback<(), Result<(), RecorderCommandError>>,
620    cancel: Callback<(), Result<(), RecorderCommandError>>,
621    take_completed: Callback<(), Option<RecordedAudio>>,
622    clear_completed: Callback,
623}
624
625impl AudioRecorder {
626    pub fn status(self) -> ReadSignal<RecorderStatus> {
627        self.status
628    }
629
630    pub fn completed(self) -> ReadSignal<Option<RecordedAudio>> {
631        self.completed
632    }
633
634    pub fn analyser(self) -> ReadSignal<Option<AudioAnalyser>> {
635        self.analyser
636    }
637
638    pub fn elapsed(self) -> ReadSignal<Duration> {
639        self.elapsed
640    }
641
642    /// Browser-reported availability of the active Recording Source.
643    ///
644    /// Interruption does not pause Recording or elapsed time. This
645    /// observation does not inspect application control of the track's enabled
646    /// state.
647    pub fn source_availability(self) -> ReadSignal<Option<RecordingSourceAvailability>> {
648        self.source_availability
649    }
650
651    pub fn microphone(self) -> ReadSignal<MicrophoneStatus> {
652        self.microphone
653    }
654
655    /// Constraints snapshotted by the most recently accepted start request.
656    pub fn requested_constraints(self) -> ReadSignal<Option<RecordingConstraints>> {
657        self.requested_constraints
658    }
659
660    /// Constraint fields that the browser reports recognizing.
661    ///
662    /// Recognition does not imply that a particular value can be acquired.
663    pub fn constraint_capabilities(self) -> ReadSignal<Option<RecorderConstraintCapabilities>> {
664        self.constraint_capabilities
665    }
666
667    /// Effective settings reported by the acquired Recording Source.
668    pub fn settings(self) -> ReadSignal<Option<RecordingSourceSettings>> {
669        self.settings
670    }
671
672    /// Encoder media type selected for the current or most recent Recording.
673    pub fn media_type(self) -> ReadSignal<Option<String>> {
674        self.media_type
675    }
676
677    /// Terminal outcome of the most recently accepted Recording, if any.
678    pub fn outcome(self) -> ReadSignal<Option<RecordingOutcome>> {
679        self.outcome
680    }
681
682    /// Terminal incremental delivery failure for the current or most recent Recording.
683    pub fn chunk_delivery_failure(self) -> ReadSignal<Option<RecordingChunkDeliveryFailure>> {
684        self.chunk_delivery_failure
685    }
686
687    pub fn start(self) -> Result<(), RecorderCommandError> {
688        self.start.call(())
689    }
690
691    /// Start a Recording from one application-supplied live audio track.
692    ///
693    /// Video tracks are ignored. The supplied audio track is never stopped by
694    /// Recorder under this default preserve-tracks agreement.
695    pub fn start_with_source(self, source: RecordingSource) -> Result<(), RecorderCommandError> {
696        self.start_with_source.call(source)
697    }
698
699    pub fn pause(self) -> Result<(), RecorderCommandError> {
700        self.pause.call(())
701    }
702
703    pub fn resume(self) -> Result<(), RecorderCommandError> {
704        self.resume.call(())
705    }
706
707    /// Ask the browser to create a best-effort Recording Chunk boundary.
708    ///
709    /// The request is available only to an opted-in Recording while active or
710    /// paused. It does not promise exact timing, non-empty output, or an
711    /// independently playable chunk.
712    pub fn request_chunk_boundary(self) -> Result<(), RecorderCommandError> {
713        self.request_chunk_boundary.call(())
714    }
715
716    pub fn stop(self) -> Result<(), RecorderCommandError> {
717        self.stop.call(())
718    }
719
720    pub fn cancel(self) -> Result<(), RecorderCommandError> {
721        self.cancel.call(())
722    }
723
724    pub fn clear_completed(self) {
725        self.clear_completed.call(());
726    }
727
728    /// Move the completed recording out without cloning its audio buffer.
729    pub fn take_completed(self) -> Option<RecordedAudio> {
730        self.take_completed.call(())
731    }
732}
733
734/// Probe whether the browser recognizes a Recorder media type.
735///
736/// A positive result does not guarantee that source acquisition, Recorder
737/// construction, or a complete Recording will succeed.
738pub fn is_recorder_mime_type_supported(mime_type: &str) -> bool {
739    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
740    {
741        web_sys::MediaRecorder::is_type_supported(mime_type)
742    }
743
744    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
745    {
746        let _ = mime_type;
747        false
748    }
749}
750
751/// Create a recorder controller. The selected input is snapshotted by `start`.
752pub fn use_audio_recorder(
753    options: RecorderOptions,
754    selected_input: ReadSignal<Option<AudioInputId>>,
755) -> AudioRecorder {
756    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
757    {
758        web::use_web_audio_recorder(options, selected_input)
759    }
760
761    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
762    {
763        let _ = (options, selected_input);
764        use_unsupported_audio_recorder()
765    }
766}
767
768#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
769fn use_unsupported_audio_recorder() -> AudioRecorder {
770    let error = AudioError::unsupported();
771    let mut status = use_signal(|| RecorderStatus::Idle);
772    let completed = use_signal(|| None::<RecordedAudio>);
773    let analyser = use_signal(|| None::<AudioAnalyser>);
774    let elapsed = use_signal(|| Duration::ZERO);
775    let source_availability = use_signal(|| None::<RecordingSourceAvailability>);
776    let requested_constraints = use_signal(|| None::<RecordingConstraints>);
777    let constraint_capabilities = use_signal(|| None::<RecorderConstraintCapabilities>);
778    let settings = use_signal(|| None::<RecordingSourceSettings>);
779    let media_type = use_signal(|| None::<String>);
780    let outcome = use_signal(|| None::<RecordingOutcome>);
781    let chunk_delivery_failure = use_signal(|| None::<RecordingChunkDeliveryFailure>);
782    let mut microphone = use_signal(|| MicrophoneStatus {
783        permission: MicrophonePermission::Unknown,
784        recorder: RecorderStatus::Idle,
785        input_device: None,
786        muted: false,
787    });
788    use_effect(move || {
789        let status_error = RecorderStatus::Failed(error.clone());
790        status.set(status_error.clone());
791        microphone.set(MicrophoneStatus {
792            permission: MicrophonePermission::Unsupported,
793            recorder: status_error,
794            input_device: None,
795            muted: false,
796        });
797    });
798    let unsupported: Callback<(), Result<(), RecorderCommandError>> = use_callback(|()| {
799        Err(command_error(
800            "audio recording is unsupported on this platform",
801        ))
802    });
803    let unsupported_source: Callback<RecordingSource, Result<(), RecorderCommandError>> =
804        use_callback(|_| {
805            Err(command_error(
806                "audio recording is unsupported on this platform",
807            ))
808        });
809    let mut completed_to_clear = completed;
810    let clear_completed = use_callback(move |()| completed_to_clear.set(None));
811    let mut completed_to_take = completed;
812    let take_completed = use_callback(move |()| completed_to_take.write().take());
813
814    AudioRecorder {
815        status: status.into(),
816        completed: completed.into(),
817        analyser: analyser.into(),
818        elapsed: elapsed.into(),
819        source_availability: source_availability.into(),
820        microphone: microphone.into(),
821        requested_constraints: requested_constraints.into(),
822        constraint_capabilities: constraint_capabilities.into(),
823        settings: settings.into(),
824        media_type: media_type.into(),
825        outcome: outcome.into(),
826        chunk_delivery_failure: chunk_delivery_failure.into(),
827        start: unsupported,
828        start_with_source: unsupported_source,
829        pause: unsupported,
830        resume: unsupported,
831        request_chunk_boundary: unsupported,
832        stop: unsupported,
833        cancel: unsupported,
834        take_completed,
835        clear_completed,
836    }
837}