Skip to main content

maolan_editor/
app.rs

1#[cfg(feature = "standalone")]
2use std::time::Instant;
3use std::{
4    fmt,
5    path::{Path, PathBuf},
6    sync::Arc,
7    time::Duration,
8};
9
10#[cfg(feature = "standalone")]
11use crate::audio_codec::{
12    AudioDither, AudioEncodeFormat, WavBitDepth, decode_audio_to_f32_interleaved_sync,
13    encode_audio_to_file,
14};
15#[cfg(feature = "standalone")]
16use maolan_engine::{
17    client::Client as EngineClient,
18    kind::Kind,
19    message::{Action as EngineAction, Message as EngineMessage, generate_clip_id},
20};
21#[cfg(feature = "standalone")]
22type StandaloneOpenResult = EngineClient;
23#[cfg(not(feature = "standalone"))]
24type StandaloneOpenResult = ();
25use maolan_widgets::iced::{
26    Background, Border, Color, Element, Length, Subscription, Task, Theme, keyboard, time,
27    widget::{
28        Id, Space, button, column, container, pick_list, progress_bar, row, text, text_input,
29        tooltip,
30    },
31    window,
32};
33use maolan_widgets::iced_aw::menu::DrawPath;
34use maolan_widgets::iced_fonts::lucide::{
35    arrow_down, arrow_right, arrow_up, fast_forward, flag, play, redo, rewind, square,
36    trending_down, trending_up, undo,
37};
38use maolan_widgets::waveform::SampleWaveform;
39use maolan_widgets::{
40    audio_setup::{AudioSetupAction, AudioSetupState, audio_setup},
41    menu::{menu_bar, menu_dropdown, menu_item, menu_items},
42    meters,
43};
44#[cfg(feature = "standalone")]
45use rubato::audioadapter_buffers::direct::SequentialSliceOfVecs;
46#[cfg(feature = "standalone")]
47use rubato::{Fft, FixedSync, Resampler};
48use serde::{Deserialize, Serialize};
49
50#[derive(Debug, Clone)]
51pub enum Message {
52    None,
53    StartupBackendSelected(AudioEngineOption),
54    StartupOutputDeviceSelected(AudioDeviceOption),
55    StartupInputDeviceSelected(AudioDeviceOption),
56    StartupSampleRateSelected(i32),
57    StartupBitsSelected(usize),
58    StartupPeriodFramesSelected(usize),
59    StartupNPeriodsSelected(usize),
60    StartupExclusiveToggled(bool),
61    StartupSyncModeToggled(bool),
62    StartupOpen,
63    StartupOpened(Result<StandaloneOpenResult, String>),
64    Vst3PluginsLoaded,
65    Vst3PluginsUnavailable,
66    ClapPluginsLoaded,
67    ClapPluginsUnavailable,
68    #[cfg(unix)]
69    Lv2PluginsLoaded,
70    #[cfg(unix)]
71    Lv2PluginsUnavailable,
72    Open,
73    Close,
74    Save,
75    SaveAs,
76    Play,
77    Stop,
78    TogglePlayback,
79    RewindToStart,
80    GoToEnd,
81    JumpToNextZeroCrossing,
82    PlaybackTick,
83    SelectionStart(f32),
84    SelectionDrag(f32),
85    SelectionFinish(f32),
86    SelectionResize(f32),
87    PlayheadMoved(f32),
88    SelectMarkerRegion(f32),
89    StandalonePlaybackStarted(Result<(), String>),
90    StandalonePlaybackStopped(Result<(), String>),
91    FadeIn,
92    FadeOut,
93    IncreaseVolume,
94    DecreaseVolume,
95    Reverse,
96    EditAction(AudioEditAction),
97    Undo,
98    Redo,
99    DeleteSelection,
100    OpenPath(PathBuf),
101    OpenAudio(AudioBuffer),
102    OpenClip {
103        path: PathBuf,
104        offset: usize,
105        length: usize,
106        timeline_start: Option<usize>,
107    },
108    FileOpened(Option<PathBuf>),
109    DocumentLoadProgress {
110        progress: f32,
111        status: String,
112    },
113    DocumentLoaded(Result<AudioDocument, String>),
114    EngineDocumentPrepared(Result<(), String>),
115    FileSaved(Option<PathBuf>),
116    DocumentSaved(Result<PathBuf, String>),
117    WindowCloseRequested(window::Id),
118    CloseDialogResult(window::Id, rfd::MessageDialogResult),
119    MarkerCreateDialog {
120        sample: usize,
121    },
122    MarkerNameInput(String),
123    MarkerNameConfirm,
124    MarkerNameCancel,
125    MarkerDelete {
126        sample: usize,
127    },
128    DetectMarkersDialog,
129    DetectMarkersThresholdInput(String),
130    DetectMarkersSilenceSamplesInput(String),
131    DetectMarkersConfirm,
132    DetectMarkersCancel,
133    ExportMarkersDialog,
134    ExportMarkersDirectorySelected(Option<PathBuf>),
135    ExportMarkersFormatSelected(ExportFormat),
136    ExportMarkersBitDepthSelected(ExportBitDepth),
137    ExportMarkersSampleRateSelected(ExportSampleRate),
138    ExportMarkersConfirm,
139    ExportMarkersCancel,
140    ExportMarkersFinished(Result<usize, String>),
141    PreferencesDialog,
142    PreferencesOutputDeviceSelected(AudioDeviceOption),
143    PreferencesInputDeviceSelected(AudioDeviceOption),
144    PreferencesSave,
145    PreferencesCancel,
146}
147
148pub fn message_edits_document(message: &Message) -> bool {
149    matches!(
150        message,
151        Message::FadeIn
152            | Message::FadeOut
153            | Message::IncreaseVolume
154            | Message::DecreaseVolume
155            | Message::Reverse
156            | Message::EditAction(_)
157            | Message::Undo
158            | Message::Redo
159            | Message::DeleteSelection
160            | Message::MarkerNameConfirm
161            | Message::MarkerDelete { .. }
162            | Message::DetectMarkersConfirm
163    )
164}
165
166pub fn set_embedded_transport(app: &mut EditApp, playing: bool, playhead_samples: usize) {
167    app.playing = playing;
168    let frames = app.audio.as_ref().map(AudioDocument::frames).unwrap_or(0);
169    app.playhead_samples = playhead_samples.min(frames);
170}
171
172pub struct HostPreview {
173    pub samples: Arc<Vec<f32>>,
174    pub channels: usize,
175    pub start_sample: usize,
176}
177
178#[derive(Debug, Clone)]
179pub struct AudioBuffer {
180    pub name: String,
181    pub samples: Arc<Vec<f32>>,
182    pub channels: usize,
183    pub sample_rate: u32,
184}
185
186impl AudioBuffer {
187    pub fn new(
188        name: impl Into<String>,
189        samples: impl Into<Arc<Vec<f32>>>,
190        channels: usize,
191        sample_rate: u32,
192    ) -> Self {
193        Self {
194            name: name.into(),
195            samples: samples.into(),
196            channels,
197            sample_rate,
198        }
199    }
200}
201
202#[derive(Debug, Clone)]
203pub struct RenderedAudio {
204    pub samples: Arc<Vec<f32>>,
205    pub channels: usize,
206    pub sample_rate: u32,
207}
208
209pub fn host_preview(app: &EditApp) -> Option<HostPreview> {
210    let audio = app.audio.as_ref()?;
211    Some(HostPreview {
212        samples: Arc::new(audio.preview_samples.clone()),
213        channels: audio.channels,
214        start_sample: app.playhead_samples,
215    })
216}
217
218pub fn is_playing(app: &EditApp) -> bool {
219    app.playing
220}
221
222pub fn rendered_audio(app: &EditApp) -> Option<RenderedAudio> {
223    let audio = app.audio.as_ref()?;
224    Some(RenderedAudio {
225        samples: Arc::new(audio.preview_samples.clone()),
226        channels: audio.channels,
227        sample_rate: audio.sample_rate,
228    })
229}
230
231pub fn current_audio_edits(app: &EditApp) -> Option<AudioEdits> {
232    app.audio.as_ref().map(|audio| audio.edit_summary())
233}
234
235pub fn current_audio_edit_actions(app: &EditApp) -> Option<Vec<AudioEditAction>> {
236    app.audio.as_ref().map(|audio| audio.edit_actions.clone())
237}
238
239pub fn audio_edit_action_for_message(app: &EditApp, message: &Message) -> Option<AudioEditAction> {
240    let audio = app.audio.as_ref()?;
241    match message {
242        Message::EditAction(action) => Some(*action),
243        Message::FadeIn => Some(audio.region_action(AudioEditKind::FadeIn)),
244        Message::FadeOut => Some(audio.region_action(AudioEditKind::FadeOut)),
245        Message::IncreaseVolume => {
246            Some(audio.region_action(AudioEditKind::GainDb { delta_db: 1.0 }))
247        }
248        Message::DecreaseVolume => {
249            Some(audio.region_action(AudioEditKind::GainDb { delta_db: -1.0 }))
250        }
251        Message::Reverse => Some(AudioEditAction::Reverse),
252        Message::DeleteSelection => app.selection_samples.and_then(|(start, end)| {
253            (start < end).then_some(AudioEditAction::Delete {
254                start_sample: start,
255                length_samples: end - start,
256            })
257        }),
258        _ => None,
259    }
260}
261
262pub fn open_audio(app: &mut EditApp, audio: AudioBuffer) -> Task<Message> {
263    update(app, Message::OpenAudio(audio))
264}
265
266#[derive(Debug, Default)]
267pub struct EditApp {
268    standalone_ready: bool,
269    setup: StartupSetup,
270    audio: Option<AudioDocument>,
271    history: EditHistory,
272    status: String,
273    busy: bool,
274    preparing_playback: bool,
275    busy_progress: f32,
276    playing: bool,
277    playhead_samples: usize,
278    selection_anchor_samples: Option<usize>,
279    selection_samples: Option<(usize, usize)>,
280    engine_clip_path: Option<PathBuf>,
281    #[cfg(feature = "standalone")]
282    engine_playback: Option<EnginePlayback>,
283    close_window_id: Option<window::Id>,
284    marker_dialog: Option<MarkerDialog>,
285    detect_markers_dialog: Option<DetectMarkersDialog>,
286    export_markers_dialog: Option<ExportMarkersDialog>,
287    preferences_dialog: Option<PreferencesDialog>,
288    vst3_plugins_loaded: bool,
289    vst3_plugins_unavailable: bool,
290    clap_plugins_loaded: bool,
291    clap_plugins_unavailable: bool,
292    #[cfg(unix)]
293    lv2_plugins_loaded: bool,
294    #[cfg(unix)]
295    lv2_plugins_unavailable: bool,
296}
297
298#[derive(Debug, Clone)]
299pub struct AudioDocument {
300    source_path: PathBuf,
301    save_path: Option<PathBuf>,
302    samples: Vec<f32>,
303    preview_samples: Vec<f32>,
304    channels: usize,
305    sample_rate: u32,
306    channel_samples: Vec<Vec<f32>>,
307    peak: f32,
308    clip_region: Option<AudioRegion>,
309    edits: AudioEdits,
310    edit_actions: Vec<AudioEditAction>,
311    markers: Vec<(usize, String)>,
312}
313
314#[derive(Debug, Clone)]
315struct MarkerDialog {
316    sample: usize,
317    name: String,
318}
319
320#[derive(Debug, Clone)]
321struct DetectMarkersDialog {
322    threshold_db: String,
323    silence_samples: String,
324}
325
326impl Default for DetectMarkersDialog {
327    fn default() -> Self {
328        Self {
329            threshold_db: String::from("-60.0"),
330            silence_samples: String::from("1000"),
331        }
332    }
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
336pub enum ExportFormat {
337    #[default]
338    Wav,
339    Flac,
340    OggFlac,
341    Mp3,
342}
343
344impl fmt::Display for ExportFormat {
345    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
346        match self {
347            Self::Wav => write!(f, "WAV"),
348            Self::Flac => write!(f, "FLAC"),
349            Self::OggFlac => write!(f, "OGG FLAC"),
350            Self::Mp3 => write!(f, "MP3"),
351        }
352    }
353}
354
355impl ExportFormat {
356    const ALL: &'static [Self] = &[Self::Wav, Self::Flac, Self::OggFlac, Self::Mp3];
357
358    #[cfg(feature = "standalone")]
359    fn extension(self) -> &'static str {
360        match self {
361            Self::Wav => "wav",
362            Self::Flac => "flac",
363            Self::OggFlac => "ogg",
364            Self::Mp3 => "mp3",
365        }
366    }
367}
368
369#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
370pub enum ExportBitDepth {
371    #[default]
372    Bits16,
373    Bits24,
374    Bits32,
375}
376
377impl fmt::Display for ExportBitDepth {
378    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
379        match self {
380            Self::Bits16 => write!(f, "16-bit"),
381            Self::Bits24 => write!(f, "24-bit"),
382            Self::Bits32 => write!(f, "32-bit"),
383        }
384    }
385}
386
387impl ExportBitDepth {
388    const ALL: &'static [Self] = &[Self::Bits16, Self::Bits24, Self::Bits32];
389
390    #[cfg(feature = "standalone")]
391    fn bits(self) -> u16 {
392        match self {
393            Self::Bits16 => 16,
394            Self::Bits24 => 24,
395            Self::Bits32 => 32,
396        }
397    }
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401pub enum ExportSampleRate {
402    Hz22050,
403    Hz44100,
404    Hz48000,
405    Hz88200,
406    Hz96000,
407    Hz192000,
408}
409
410impl fmt::Display for ExportSampleRate {
411    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
412        write!(f, "{} Hz", self.value())
413    }
414}
415
416impl ExportSampleRate {
417    const ALL: &'static [Self] = &[
418        Self::Hz22050,
419        Self::Hz44100,
420        Self::Hz48000,
421        Self::Hz88200,
422        Self::Hz96000,
423        Self::Hz192000,
424    ];
425
426    fn value(self) -> u32 {
427        match self {
428            Self::Hz22050 => 22_050,
429            Self::Hz44100 => 44_100,
430            Self::Hz48000 => 48_000,
431            Self::Hz88200 => 88_200,
432            Self::Hz96000 => 96_000,
433            Self::Hz192000 => 192_000,
434        }
435    }
436}
437
438#[derive(Debug, Clone)]
439struct ExportMarkersDialog {
440    directory: Option<PathBuf>,
441    format: ExportFormat,
442    bit_depth: ExportBitDepth,
443    sample_rate: ExportSampleRate,
444}
445
446impl Default for ExportMarkersDialog {
447    fn default() -> Self {
448        Self {
449            directory: None,
450            format: ExportFormat::Wav,
451            bit_depth: ExportBitDepth::Bits24,
452            sample_rate: ExportSampleRate::Hz48000,
453        }
454    }
455}
456
457#[derive(Debug, Clone)]
458struct PreferencesDialog {
459    output_devices: Vec<AudioDeviceOption>,
460    input_devices: Vec<AudioDeviceOption>,
461    output_device: Option<AudioDeviceOption>,
462    input_device: Option<AudioDeviceOption>,
463}
464
465impl PreferencesDialog {
466    fn from_setup(setup: &StartupSetup) -> Self {
467        Self {
468            output_devices: setup.output_devices.clone(),
469            input_devices: setup.input_devices.clone(),
470            output_device: setup.output_device.clone(),
471            input_device: setup.input_device.clone(),
472        }
473    }
474}
475
476#[derive(Debug, Clone, Copy)]
477struct AudioRegion {
478    offset: usize,
479    length: usize,
480}
481
482#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
483pub enum AudioEditAction {
484    FadeIn {
485        start_sample: usize,
486        length_samples: usize,
487    },
488    FadeOut {
489        start_sample: usize,
490        length_samples: usize,
491    },
492    GainDb {
493        start_sample: usize,
494        length_samples: usize,
495        delta_db: f32,
496    },
497    Reverse,
498    Delete {
499        start_sample: usize,
500        length_samples: usize,
501    },
502    ReplaceWithSilence {
503        start_sample: usize,
504        length_samples: usize,
505    },
506}
507
508#[derive(Debug, Clone, Copy)]
509enum AudioEditKind {
510    FadeIn,
511    FadeOut,
512    GainDb { delta_db: f32 },
513}
514
515#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
516pub struct AudioEdits {
517    pub fade_in_samples: usize,
518    pub fade_out_samples: usize,
519    pub gain_db: f32,
520    pub reversed: bool,
521}
522
523impl AudioEdits {
524    #[cfg(feature = "standalone")]
525    fn needs_rendered_preview_file(self) -> bool {
526        self.fade_in_samples != 0 || self.fade_out_samples != 0 || self.gain_db != 0.0
527    }
528}
529
530impl AudioEditAction {
531    fn is_empty_for_frames(self, frames: usize) -> bool {
532        match self {
533            Self::FadeIn {
534                start_sample,
535                length_samples,
536            }
537            | Self::FadeOut {
538                start_sample,
539                length_samples,
540            }
541            | Self::GainDb {
542                start_sample,
543                length_samples,
544                ..
545            }
546            | Self::Delete {
547                start_sample,
548                length_samples,
549            }
550            | Self::ReplaceWithSilence {
551                start_sample,
552                length_samples,
553            } => start_sample >= frames || length_samples == 0,
554            Self::Reverse => false,
555        }
556    }
557}
558
559pub fn summarize_audio_edit_actions(frames: usize, actions: &[AudioEditAction]) -> AudioEdits {
560    let mut edits = AudioEdits::default();
561    for action in actions {
562        match *action {
563            AudioEditAction::FadeIn {
564                start_sample: 0,
565                length_samples,
566            } => {
567                edits.fade_in_samples = edits.fade_in_samples.max(length_samples.min(frames));
568            }
569            AudioEditAction::FadeOut {
570                start_sample,
571                length_samples,
572            } if start_sample.saturating_add(length_samples) >= frames => {
573                edits.fade_out_samples = edits.fade_out_samples.max(length_samples.min(frames));
574            }
575            AudioEditAction::GainDb {
576                start_sample,
577                length_samples,
578                delta_db,
579            } if start_sample == 0 && length_samples >= frames => {
580                edits.gain_db = (edits.gain_db + delta_db).clamp(-48.0, 24.0);
581            }
582            AudioEditAction::Reverse => edits.reversed = !edits.reversed,
583            _ => {}
584        }
585    }
586    edits
587}
588
589fn audio_edit_status(action: AudioEditAction, edits: AudioEdits, frames: usize) -> String {
590    match action {
591        AudioEditAction::FadeIn {
592            start_sample,
593            length_samples: _,
594        } => {
595            if start_sample == 0 {
596                String::from("Fade in applied to preview.")
597            } else {
598                String::from("Fade in applied to selection.")
599            }
600        }
601        AudioEditAction::FadeOut {
602            start_sample,
603            length_samples,
604        } => {
605            if start_sample.saturating_add(length_samples) >= frames {
606                String::from("Fade out applied to preview.")
607            } else {
608                String::from("Fade out applied to selection.")
609            }
610        }
611        AudioEditAction::GainDb {
612            start_sample,
613            length_samples,
614            ..
615        } => {
616            if start_sample == 0 && length_samples > 0 {
617                format!("Preview gain: {:+.1} dB.", edits.gain_db)
618            } else {
619                String::from("Volume adjusted on selection.")
620            }
621        }
622        AudioEditAction::Reverse => {
623            if edits.reversed {
624                String::from("Clip reversed.")
625            } else {
626                String::from("Clip restored to forward playback.")
627            }
628        }
629        AudioEditAction::Delete { .. } => String::from("Selection deleted."),
630        AudioEditAction::ReplaceWithSilence { .. } => {
631            String::from("Selection replaced with silence.")
632        }
633    }
634}
635
636#[cfg(test)]
637#[derive(Debug, Clone, Copy)]
638enum EditOperation {
639    FadeIn,
640    FadeOut,
641    IncreaseVolume,
642    DecreaseVolume,
643}
644
645#[derive(Debug, Clone)]
646struct DocumentSnapshot {
647    samples: Vec<f32>,
648    edits: AudioEdits,
649    edit_actions: Vec<AudioEditAction>,
650    markers: Vec<(usize, String)>,
651}
652
653const EDIT_HISTORY_MAX_ENTRIES: usize = 1000;
654
655#[derive(Default)]
656struct EditHistory {
657    undo_entries: Vec<UndoEntry>,
658    redo_entries: Vec<UndoEntry>,
659    saved_position: usize,
660    snapshots: Vec<DocumentSnapshot>,
661}
662
663struct UndoEntry {
664    forward_snapshot: usize,
665    inverse_snapshot: usize,
666}
667
668impl std::fmt::Debug for EditHistory {
669    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
670        f.debug_struct("EditHistory")
671            .field("snapshots", &self.snapshots.len())
672            .finish()
673    }
674}
675
676impl EditHistory {
677    fn new(initial: DocumentSnapshot) -> Self {
678        Self {
679            undo_entries: Vec::new(),
680            redo_entries: Vec::new(),
681            saved_position: 0,
682            snapshots: vec![initial],
683        }
684    }
685
686    fn is_dirty(&self) -> bool {
687        self.undo_entries.len() != self.saved_position
688    }
689
690    fn mark_saved(&mut self) {
691        self.saved_position = self.undo_entries.len();
692    }
693
694    fn record(&mut self, previous: DocumentSnapshot, current: DocumentSnapshot) {
695        let previous_index = self.push_snapshot(previous);
696        let current_index = self.push_snapshot(current);
697        self.undo_entries.push(UndoEntry {
698            forward_snapshot: current_index,
699            inverse_snapshot: previous_index,
700        });
701        if self.undo_entries.len() > EDIT_HISTORY_MAX_ENTRIES {
702            self.undo_entries.remove(0);
703            self.saved_position = self.saved_position.saturating_sub(1);
704        }
705        self.redo_entries.clear();
706    }
707
708    fn undo(&mut self) -> Option<DocumentSnapshot> {
709        let entry = self.undo_entries.pop()?;
710        let snapshot = self.snapshots.get(entry.inverse_snapshot).cloned();
711        self.redo_entries.push(entry);
712        snapshot
713    }
714
715    fn redo(&mut self) -> Option<DocumentSnapshot> {
716        let entry = self.redo_entries.pop()?;
717        let snapshot = self.snapshots.get(entry.forward_snapshot).cloned();
718        self.undo_entries.push(entry);
719        snapshot
720    }
721
722    fn push_snapshot(&mut self, snapshot: DocumentSnapshot) -> usize {
723        let index = self.snapshots.len();
724        self.snapshots.push(snapshot);
725        index
726    }
727}
728
729#[cfg(feature = "standalone")]
730#[derive(Debug)]
731struct EnginePlayback {
732    client: EngineClient,
733}
734
735#[cfg(feature = "standalone")]
736struct EngineDocumentRequest {
737    path: PathBuf,
738    samples: Vec<f32>,
739    channels: usize,
740    sample_rate: u32,
741    clip_len: usize,
742    clip_offset: usize,
743    render_preview: bool,
744    reversed: bool,
745}
746
747#[derive(Debug, Clone)]
748pub struct AudioDeviceOption {
749    pub(crate) id: String,
750    pub(crate) label: String,
751    pub(crate) supported_bits: Vec<usize>,
752    pub(crate) supported_sample_rates: Vec<i32>,
753    #[cfg(all(feature = "standalone", target_os = "freebsd"))]
754    pub(crate) max_channels: usize,
755    #[cfg(all(feature = "standalone", target_os = "freebsd"))]
756    pub(crate) max_buffer_bytes: usize,
757    pub(crate) supports_input: bool,
758    pub(crate) supports_output: bool,
759}
760
761impl PartialEq for AudioDeviceOption {
762    fn eq(&self, other: &Self) -> bool {
763        self.id == other.id
764    }
765}
766
767impl Eq for AudioDeviceOption {}
768
769impl std::hash::Hash for AudioDeviceOption {
770    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
771        self.id.hash(state);
772    }
773}
774
775impl AudioDeviceOption {
776    pub(crate) fn with_supported_caps(
777        id: impl Into<String>,
778        label: impl Into<String>,
779        mut supported_bits: Vec<usize>,
780        mut supported_sample_rates: Vec<i32>,
781    ) -> Self {
782        supported_bits.sort_by(|a, b| b.cmp(a));
783        supported_bits.dedup();
784        supported_sample_rates.retain(|rate| *rate > 0);
785        supported_sample_rates.sort_unstable();
786        supported_sample_rates.dedup();
787        Self {
788            id: id.into(),
789            label: label.into(),
790            supported_bits,
791            supported_sample_rates,
792            #[cfg(all(feature = "standalone", target_os = "freebsd"))]
793            max_channels: 0,
794            #[cfg(all(feature = "standalone", target_os = "freebsd"))]
795            max_buffer_bytes: 0,
796            supports_input: true,
797            supports_output: true,
798        }
799    }
800
801    #[cfg(all(feature = "standalone", target_os = "freebsd"))]
802    pub(crate) fn with_oss_caps(
803        id: impl Into<String>,
804        label: impl Into<String>,
805        supported_bits: Vec<usize>,
806        supported_sample_rates: Vec<i32>,
807        max_channels: usize,
808        max_buffer_bytes: usize,
809    ) -> Self {
810        let mut out = Self::with_supported_caps(id, label, supported_bits, supported_sample_rates);
811        out.max_channels = max_channels;
812        out.max_buffer_bytes = max_buffer_bytes;
813        out
814    }
815
816    #[cfg(target_os = "linux")]
817    pub(crate) fn with_supported_direction_caps(
818        id: impl Into<String>,
819        label: impl Into<String>,
820        mut supported_bits: Vec<usize>,
821        mut supported_sample_rates: Vec<i32>,
822        supports_input: bool,
823        supports_output: bool,
824    ) -> Self {
825        supported_bits.sort_by(|a, b| b.cmp(a));
826        supported_bits.dedup();
827        supported_sample_rates.retain(|rate| *rate > 0);
828        supported_sample_rates.sort_unstable();
829        supported_sample_rates.dedup();
830        Self {
831            id: id.into(),
832            label: label.into(),
833            supported_bits,
834            supported_sample_rates,
835            #[cfg(target_os = "freebsd")]
836            max_channels: 0,
837            #[cfg(target_os = "freebsd")]
838            max_buffer_bytes: 0,
839            supports_input,
840            supports_output,
841        }
842    }
843}
844
845#[cfg(all(feature = "standalone", target_os = "freebsd"))]
846impl From<maolan_engine::audio_devices::AudioDeviceDescriptor> for AudioDeviceOption {
847    fn from(device: maolan_engine::audio_devices::AudioDeviceDescriptor) -> Self {
848        let mut out = Self::with_oss_caps(
849            device.id,
850            device.label,
851            device.supported_bits,
852            device.supported_sample_rates,
853            device.max_channels,
854            device.max_buffer_bytes,
855        );
856        out.supports_input = device.supports_input;
857        out.supports_output = device.supports_output;
858        out
859    }
860}
861
862impl fmt::Display for AudioDeviceOption {
863    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
864        if self.supported_bits.is_empty() {
865            return f.write_str(&self.label);
866        }
867        let formats = self
868            .supported_bits
869            .iter()
870            .map(|bits| format!("{bits}"))
871            .collect::<Vec<_>>()
872            .join("/");
873        write!(f, "{} [{}-bit]", self.label, formats)
874    }
875}
876
877#[derive(Debug, Clone, PartialEq, Eq)]
878struct StartupSetup {
879    audio_engine: AudioEngineOption,
880    output_devices: Vec<AudioDeviceOption>,
881    input_devices: Vec<AudioDeviceOption>,
882    output_device: Option<AudioDeviceOption>,
883    input_device: Option<AudioDeviceOption>,
884    sample_rate_hz: i32,
885    bits: usize,
886    exclusive: bool,
887    period_frames: usize,
888    nperiods: usize,
889    sync_mode: bool,
890}
891
892impl StartupSetup {
893    fn with_preferences(
894        preferences: &EditorPreferences,
895        output_devices: Vec<AudioDeviceOption>,
896        input_devices: Vec<AudioDeviceOption>,
897    ) -> Self {
898        let audio_engine = AudioEngineOption::default();
899        let output_device = preferences
900            .default_output_device_id
901            .as_deref()
902            .and_then(|id| {
903                output_devices
904                    .iter()
905                    .find(|device| device.id == id)
906                    .cloned()
907            })
908            .or_else(|| output_devices.first().cloned());
909        let input_device = preferences
910            .default_input_device_id
911            .as_deref()
912            .and_then(|id| input_devices.iter().find(|device| device.id == id).cloned())
913            .or_else(|| input_devices.first().cloned());
914        let mut setup = Self {
915            audio_engine,
916            output_devices,
917            input_devices,
918            output_device,
919            input_device,
920            sample_rate_hz: 48_000,
921            bits: 32,
922            exclusive: true,
923            period_frames: 1024,
924            nperiods: maolan_widgets::audio_setup::DEFAULT_N_PERIODS,
925            sync_mode: false,
926        };
927        setup.sample_rate_hz = pick_sample_rate(&setup);
928        setup.bits = pick_bits(&setup);
929        setup.period_frames = pick_period_frames(&setup);
930        setup
931    }
932}
933
934impl Default for StartupSetup {
935    fn default() -> Self {
936        let preferences = EditorPreferences::load();
937        let audio_engine = AudioEngineOption::default();
938        let output_devices = discover_output_audio_devices(audio_engine);
939        let input_devices = discover_input_audio_devices(audio_engine);
940        Self::with_preferences(&preferences, output_devices, input_devices)
941    }
942}
943
944#[derive(Debug, Clone, Default)]
945struct EditorPreferences {
946    default_output_device_id: Option<String>,
947    default_input_device_id: Option<String>,
948}
949
950impl EditorPreferences {
951    fn load() -> Self {
952        let Some(config_path) = edit_config_path() else {
953            return Self::default();
954        };
955        Self::load_from_path(&config_path)
956    }
957
958    fn load_from_path(config_path: &Path) -> Self {
959        let Ok(contents) = std::fs::read_to_string(config_path) else {
960            return Self::default();
961        };
962        let Ok(value) = toml::from_str::<toml::Value>(&contents) else {
963            return Self::default();
964        };
965        Self {
966            default_output_device_id: preference_device_id(&value, "default_output_device_id"),
967            default_input_device_id: preference_device_id(&value, "default_input_device_id"),
968        }
969    }
970
971    fn save(&self) -> Result<(), String> {
972        let Some(config_path) = edit_config_path() else {
973            return Err(String::from("Could not determine config directory."));
974        };
975        self.save_to_path(&config_path)
976    }
977
978    fn save_to_path(&self, config_path: &Path) -> Result<(), String> {
979        let mut lines: Vec<String> = if config_path.exists() {
980            std::fs::read_to_string(config_path)
981                .map_err(|err| err.to_string())?
982                .lines()
983                .map(ToOwned::to_owned)
984                .collect()
985        } else {
986            Vec::new()
987        };
988
989        let mut output_set = false;
990        let mut input_set = false;
991        for line in &mut lines {
992            let trimmed = line.trim_start();
993            if trimmed.starts_with("default_output_device_id") {
994                if let Some(id) = self.default_output_device_id.as_deref() {
995                    *line = format!("default_output_device_id = \"{id}\"");
996                } else {
997                    *line = String::new();
998                }
999                output_set = true;
1000            } else if trimmed.starts_with("default_input_device_id") {
1001                if let Some(id) = self.default_input_device_id.as_deref() {
1002                    *line = format!("default_input_device_id = \"{id}\"");
1003                } else {
1004                    *line = String::new();
1005                }
1006                input_set = true;
1007            }
1008        }
1009        lines.retain(|line| !line.is_empty());
1010
1011        if !output_set && let Some(id) = self.default_output_device_id.as_deref() {
1012            lines.push(format!("default_output_device_id = \"{id}\""));
1013        }
1014        if !input_set && let Some(id) = self.default_input_device_id.as_deref() {
1015            lines.push(format!("default_input_device_id = \"{id}\""));
1016        }
1017
1018        if let Some(parent) = config_path.parent() {
1019            std::fs::create_dir_all(parent).map_err(|err| err.to_string())?;
1020        }
1021        let content = if lines.is_empty() {
1022            String::new()
1023        } else {
1024            format!("{}\n", lines.join("\n"))
1025        };
1026        std::fs::write(config_path, content).map_err(|err| err.to_string())?;
1027        Ok(())
1028    }
1029}
1030
1031fn preference_device_id(value: &toml::Value, key: &str) -> Option<String> {
1032    value
1033        .get(key)
1034        .and_then(toml::Value::as_str)
1035        .filter(|id| !id.is_empty() && *id != "__auto__")
1036        .map(ToOwned::to_owned)
1037}
1038
1039fn edit_config_path() -> Option<PathBuf> {
1040    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
1041    Some(
1042        PathBuf::from(home)
1043            .join(".config")
1044            .join("maolan")
1045            .join("edit.toml"),
1046    )
1047}
1048
1049#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1050pub enum AudioEngineOption {
1051    #[cfg(target_os = "linux")]
1052    #[default]
1053    Alsa,
1054    #[cfg(unix)]
1055    #[cfg_attr(
1056        not(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd")),
1057        default
1058    )]
1059    Jack,
1060    #[cfg(target_os = "freebsd")]
1061    #[default]
1062    Oss,
1063    #[cfg(target_os = "openbsd")]
1064    #[default]
1065    Sndio,
1066    #[cfg(target_os = "windows")]
1067    #[default]
1068    Wasapi,
1069}
1070
1071impl AudioEngineOption {
1072    const ALL: &'static [Self] = &[
1073        #[cfg(target_os = "linux")]
1074        Self::Alsa,
1075        #[cfg(target_os = "freebsd")]
1076        Self::Oss,
1077        #[cfg(target_os = "openbsd")]
1078        Self::Sndio,
1079        #[cfg(target_os = "windows")]
1080        Self::Wasapi,
1081        #[cfg(unix)]
1082        Self::Jack,
1083    ];
1084
1085    fn is_jack(self) -> bool {
1086        #[cfg(unix)]
1087        {
1088            self == Self::Jack
1089        }
1090        #[cfg(not(unix))]
1091        {
1092            false
1093        }
1094    }
1095}
1096
1097impl fmt::Display for AudioEngineOption {
1098    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1099        match self {
1100            #[cfg(target_os = "linux")]
1101            Self::Alsa => write!(f, "ALSA"),
1102            #[cfg(unix)]
1103            Self::Jack => write!(f, "JACK"),
1104            #[cfg(target_os = "freebsd")]
1105            Self::Oss => write!(f, "OSS"),
1106            #[cfg(target_os = "openbsd")]
1107            Self::Sndio => write!(f, "sndio"),
1108            #[cfg(target_os = "windows")]
1109            Self::Wasapi => write!(f, "WASAPI"),
1110        }
1111    }
1112}
1113
1114fn pick_sample_rate(setup: &StartupSetup) -> i32 {
1115    let options = sample_rate_options(setup);
1116    if options.contains(&setup.sample_rate_hz) {
1117        setup.sample_rate_hz
1118    } else {
1119        options
1120            .iter()
1121            .min_by_key(|candidate| ((*candidate).saturating_sub(setup.sample_rate_hz)).abs())
1122            .copied()
1123            .unwrap_or(48_000)
1124    }
1125}
1126
1127fn pick_bits(setup: &StartupSetup) -> usize {
1128    let options = bit_options(setup);
1129    if options.contains(&setup.bits) {
1130        setup.bits
1131    } else {
1132        options.first().copied().unwrap_or(32)
1133    }
1134}
1135
1136fn pick_period_frames(setup: &StartupSetup) -> usize {
1137    let options = period_frame_options(setup);
1138    if options.contains(&setup.period_frames) {
1139        setup.period_frames
1140    } else {
1141        options
1142            .iter()
1143            .copied()
1144            .find(|value| *value >= setup.period_frames)
1145            .or_else(|| options.last().copied())
1146            .unwrap_or(setup.period_frames)
1147    }
1148}
1149
1150#[cfg(feature = "standalone")]
1151impl EditApp {
1152    fn plugins_loaded(&self) -> bool {
1153        let core = (self.vst3_plugins_loaded || self.vst3_plugins_unavailable)
1154            && (self.clap_plugins_loaded || self.clap_plugins_unavailable);
1155        #[cfg(unix)]
1156        {
1157            core && (self.lv2_plugins_loaded || self.lv2_plugins_unavailable)
1158        }
1159        #[cfg(not(unix))]
1160        {
1161            core
1162        }
1163    }
1164}
1165
1166#[cfg(not(feature = "standalone"))]
1167impl EditApp {
1168    fn plugins_loaded(&self) -> bool {
1169        true
1170    }
1171}
1172
1173#[cfg(feature = "standalone")]
1174pub fn new() -> (EditApp, Task<Message>) {
1175    let client = EngineClient::default();
1176    let scan_tasks = vec![
1177        Task::perform(
1178            scan_plugins_startup(client.clone(), PluginFormat::Vst3),
1179            |loaded| {
1180                if loaded {
1181                    Message::Vst3PluginsLoaded
1182                } else {
1183                    Message::Vst3PluginsUnavailable
1184                }
1185            },
1186        ),
1187        Task::perform(
1188            scan_plugins_startup(client.clone(), PluginFormat::Clap),
1189            |loaded| {
1190                if loaded {
1191                    Message::ClapPluginsLoaded
1192                } else {
1193                    Message::ClapPluginsUnavailable
1194                }
1195            },
1196        ),
1197        #[cfg(unix)]
1198        Task::perform(
1199            scan_plugins_startup(client.clone(), PluginFormat::Lv2),
1200            |loaded| {
1201                if loaded {
1202                    Message::Lv2PluginsLoaded
1203                } else {
1204                    Message::Lv2PluginsUnavailable
1205                }
1206            },
1207        ),
1208    ];
1209    (
1210        EditApp {
1211            status: String::from("Choose audio hardware and open the engine."),
1212            ..EditApp::default()
1213        },
1214        Task::batch(scan_tasks),
1215    )
1216}
1217
1218#[cfg(not(feature = "standalone"))]
1219pub fn new() -> (EditApp, Task<Message>) {
1220    (
1221        EditApp {
1222            status: String::from("Open an audio file to view its waveform."),
1223            ..EditApp::default()
1224        },
1225        Task::none(),
1226    )
1227}
1228
1229#[cfg(feature = "standalone")]
1230#[derive(Debug, Clone, Copy)]
1231enum PluginFormat {
1232    Vst3,
1233    Clap,
1234    #[cfg(unix)]
1235    Lv2,
1236}
1237
1238#[cfg(feature = "standalone")]
1239async fn scan_plugins_startup(client: EngineClient, format: PluginFormat) -> bool {
1240    let mut rx = client.subscribe().await;
1241    let action = match format {
1242        PluginFormat::Vst3 => EngineAction::ListVst3Plugins,
1243        PluginFormat::Clap => EngineAction::ListClapPlugins,
1244        #[cfg(unix)]
1245        PluginFormat::Lv2 => EngineAction::ListLv2Plugins,
1246    };
1247    let Ok(()) = send_engine(&client, action).await else {
1248        return false;
1249    };
1250    let accepts = |action: &EngineAction| match format {
1251        PluginFormat::Vst3 => {
1252            matches!(
1253                action,
1254                EngineAction::Vst3Plugins(_) | EngineAction::Vst3PluginsUnavailable { .. }
1255            )
1256        }
1257        PluginFormat::Clap => {
1258            matches!(
1259                action,
1260                EngineAction::ClapPlugins(_) | EngineAction::ClapPluginsUnavailable { .. }
1261            )
1262        }
1263        #[cfg(unix)]
1264        PluginFormat::Lv2 => {
1265            matches!(
1266                action,
1267                EngineAction::Lv2Plugins(_) | EngineAction::Lv2PluginsUnavailable { .. }
1268            )
1269        }
1270    };
1271    wait_for_engine_response(&mut rx, accepts).await.is_ok()
1272}
1273
1274pub fn title(app: &EditApp) -> String {
1275    let base = app
1276        .audio
1277        .as_ref()
1278        .and_then(|audio| audio.source_path.file_name())
1279        .map(|name| format!("Maolan Editor - {}", name.to_string_lossy()))
1280        .unwrap_or_else(|| String::from("Maolan Editor"));
1281    if app.history.is_dirty() {
1282        format!("{base} *")
1283    } else {
1284        base
1285    }
1286}
1287
1288pub fn update(app: &mut EditApp, message: Message) -> Task<Message> {
1289    match message {
1290        Message::None => Task::none(),
1291        Message::StartupBackendSelected(engine) => {
1292            app.setup.audio_engine = engine;
1293            app.setup.output_devices = discover_output_audio_devices(engine);
1294            app.setup.input_devices = discover_input_audio_devices(engine);
1295            app.setup.output_device = app.setup.output_devices.first().cloned();
1296            app.setup.input_device = app.setup.input_devices.first().cloned();
1297            app.setup.sample_rate_hz = pick_sample_rate(&app.setup);
1298            app.setup.bits = pick_bits(&app.setup);
1299            app.setup.period_frames = pick_period_frames(&app.setup);
1300            Task::none()
1301        }
1302        Message::StartupOutputDeviceSelected(device) => {
1303            app.setup.output_device = Some(device);
1304            app.setup.sample_rate_hz = pick_sample_rate(&app.setup);
1305            app.setup.bits = pick_bits(&app.setup);
1306            app.setup.period_frames = pick_period_frames(&app.setup);
1307            Task::none()
1308        }
1309        Message::StartupInputDeviceSelected(device) => {
1310            app.setup.input_device = Some(device);
1311            Task::none()
1312        }
1313        Message::StartupSampleRateSelected(rate) => {
1314            app.setup.sample_rate_hz = rate;
1315            Task::none()
1316        }
1317        Message::StartupBitsSelected(bits) => {
1318            app.setup.bits = bits;
1319            app.setup.period_frames = pick_period_frames(&app.setup);
1320            Task::none()
1321        }
1322        Message::StartupPeriodFramesSelected(period_frames) => {
1323            app.setup.period_frames = period_frames;
1324            Task::none()
1325        }
1326        Message::StartupNPeriodsSelected(nperiods) => {
1327            app.setup.nperiods = nperiods;
1328            Task::none()
1329        }
1330        Message::StartupExclusiveToggled(exclusive) => {
1331            app.setup.exclusive = exclusive;
1332            Task::none()
1333        }
1334        Message::StartupSyncModeToggled(sync_mode) => {
1335            app.setup.sync_mode = sync_mode;
1336            Task::none()
1337        }
1338        Message::StartupOpen => {
1339            app.busy = true;
1340            app.busy_progress = 0.0;
1341            #[cfg(feature = "standalone")]
1342            {
1343                app.status = String::from("Scanning plugins and opening audio device...");
1344                let setup = app.setup.clone();
1345                Task::perform(open_standalone_engine(setup), Message::StartupOpened)
1346            }
1347            #[cfg(not(feature = "standalone"))]
1348            {
1349                app.status = String::from("Open an audio file to view its waveform.");
1350                Task::perform(async { Ok(()) }, Message::StartupOpened)
1351            }
1352        }
1353        Message::StartupOpened(Ok(client)) => {
1354            app.busy = false;
1355            app.busy_progress = 1.0;
1356            app.standalone_ready = true;
1357            #[cfg(feature = "standalone")]
1358            {
1359                app.engine_playback = Some(EnginePlayback { client });
1360            }
1361            #[cfg(not(feature = "standalone"))]
1362            {
1363                let _ = client;
1364            }
1365            app.status = String::from("Open an audio file to view its waveform.");
1366            Task::none()
1367        }
1368        Message::StartupOpened(Err(err)) => {
1369            app.busy = false;
1370            app.busy_progress = 0.0;
1371            app.status = err;
1372            Task::none()
1373        }
1374        Message::Vst3PluginsLoaded => {
1375            app.vst3_plugins_loaded = true;
1376            Task::none()
1377        }
1378        Message::Vst3PluginsUnavailable => {
1379            app.vst3_plugins_unavailable = true;
1380            Task::none()
1381        }
1382        Message::ClapPluginsLoaded => {
1383            app.clap_plugins_loaded = true;
1384            Task::none()
1385        }
1386        Message::ClapPluginsUnavailable => {
1387            app.clap_plugins_unavailable = true;
1388            Task::none()
1389        }
1390        #[cfg(unix)]
1391        Message::Lv2PluginsLoaded => {
1392            app.lv2_plugins_loaded = true;
1393            Task::none()
1394        }
1395        #[cfg(unix)]
1396        Message::Lv2PluginsUnavailable => {
1397            app.lv2_plugins_unavailable = true;
1398            Task::none()
1399        }
1400        Message::Open => {
1401            #[cfg(feature = "standalone")]
1402            {
1403                app.busy = true;
1404                app.busy_progress = 0.0;
1405                app.status = String::from("Opening audio file...");
1406                Task::perform(open_audio_dialog(), Message::FileOpened)
1407            }
1408            #[cfg(not(feature = "standalone"))]
1409            {
1410                app.status = String::from("File loading is handled by the embedding host.");
1411                Task::none()
1412            }
1413        }
1414        Message::Close => {
1415            reset_after_close(app);
1416            Task::none()
1417        }
1418        Message::OpenPath(path) => {
1419            #[cfg(feature = "standalone")]
1420            {
1421                load_document(app, path, None, None)
1422            }
1423            #[cfg(not(feature = "standalone"))]
1424            {
1425                let _ = path;
1426                app.status =
1427                    String::from("File loading is only available in standalone editor builds.");
1428                Task::none()
1429            }
1430        }
1431        Message::OpenAudio(audio) => load_audio_buffer(app, audio, None),
1432        Message::OpenClip {
1433            path,
1434            offset,
1435            length,
1436            timeline_start,
1437        } => {
1438            #[cfg(feature = "standalone")]
1439            {
1440                load_document(
1441                    app,
1442                    path,
1443                    Some(AudioRegion { offset, length }),
1444                    timeline_start.map(|offset| AudioRegion { offset, length }),
1445                )
1446            }
1447            #[cfg(not(feature = "standalone"))]
1448            {
1449                let _ = (path, offset, length, timeline_start);
1450                app.status =
1451                    String::from("File loading is only available in standalone editor builds.");
1452                Task::none()
1453            }
1454        }
1455        Message::Save => {
1456            #[cfg(feature = "standalone")]
1457            {
1458                if let Some(audio) = app.audio.as_ref() {
1459                    if let Some(path) = audio.save_path.clone() {
1460                        if encode_format_for_path(&path).is_ok() {
1461                            app.busy = true;
1462                            app.busy_progress = 0.0;
1463                            app.status = format!("Saving {}...", path.display());
1464                            let samples = audio.rendered_save_samples();
1465                            let channels = audio.channels;
1466                            let sample_rate = audio.sample_rate;
1467                            Task::perform(
1468                                save_document(path, samples, channels, sample_rate),
1469                                Message::DocumentSaved,
1470                            )
1471                        } else {
1472                            app.busy = true;
1473                            app.busy_progress = 0.0;
1474                            app.status = String::from("Choose a Maolan export format to save.");
1475                            Task::perform(save_audio_dialog(Some(path)), Message::FileSaved)
1476                        }
1477                    } else {
1478                        app.busy = true;
1479                        app.busy_progress = 0.0;
1480                        app.status = String::from("Choose where to save this clip.");
1481                        Task::perform(
1482                            save_audio_dialog(Some(audio.source_path.clone())),
1483                            Message::FileSaved,
1484                        )
1485                    }
1486                } else {
1487                    app.status = String::from("No audio file is open.");
1488                    Task::none()
1489                }
1490            }
1491            #[cfg(not(feature = "standalone"))]
1492            {
1493                app.status = String::from("Saving is handled by the embedding host.");
1494                Task::none()
1495            }
1496        }
1497        Message::SaveAs => {
1498            #[cfg(feature = "standalone")]
1499            {
1500                if let Some(audio) = app.audio.as_ref() {
1501                    app.busy = true;
1502                    app.busy_progress = 0.0;
1503                    app.status = String::from("Choosing save destination...");
1504                    Task::perform(
1505                        save_audio_dialog(Some(audio.source_path.clone())),
1506                        Message::FileSaved,
1507                    )
1508                } else {
1509                    app.status = String::from("No audio file is open.");
1510                    Task::none()
1511                }
1512            }
1513            #[cfg(not(feature = "standalone"))]
1514            {
1515                app.status = String::from("Saving is handled by the embedding host.");
1516                Task::none()
1517            }
1518        }
1519        Message::Play => play_standalone(app),
1520        Message::TogglePlayback => {
1521            if app.playing {
1522                update(app, Message::Stop)
1523            } else {
1524                update(app, Message::Play)
1525            }
1526        }
1527        Message::Stop => {
1528            app.playing = false;
1529            app.status = String::from("Stopped.");
1530            stop_engine_playback(app)
1531        }
1532        Message::RewindToStart => {
1533            app.playhead_samples = 0;
1534            Task::none()
1535        }
1536        Message::GoToEnd => {
1537            app.playhead_samples = app.audio.as_ref().map(AudioDocument::frames).unwrap_or(0);
1538            Task::none()
1539        }
1540        Message::JumpToNextZeroCrossing => {
1541            let Some(audio) = app.audio.as_ref() else {
1542                app.status = String::from("No audio file is open.");
1543                return Task::none();
1544            };
1545            let start_frame = app.playhead_samples.min(audio.frames());
1546            match audio.next_zero_crossing_frame(start_frame) {
1547                Some(frame) => {
1548                    app.playhead_samples = frame;
1549                    app.status = format!("Jumped to zero crossing at frame {frame}.");
1550                }
1551                None => {
1552                    app.status = String::from("No zero crossing found after playhead.");
1553                }
1554            }
1555            Task::none()
1556        }
1557        Message::PlaybackTick => {
1558            if refresh_standalone_playhead(app) {
1559                update(app, Message::Stop)
1560            } else {
1561                Task::none()
1562            }
1563        }
1564        Message::SelectionStart(ratio) => {
1565            if let Some(sample) = sample_at_ratio(app, ratio) {
1566                app.selection_anchor_samples = Some(sample);
1567                app.selection_samples = Some((sample, sample));
1568            }
1569            Task::none()
1570        }
1571        Message::SelectionDrag(ratio) => {
1572            if let (Some(anchor), Some(sample)) =
1573                (app.selection_anchor_samples, sample_at_ratio(app, ratio))
1574            {
1575                app.selection_samples = Some((anchor.min(sample), anchor.max(sample)));
1576            }
1577            Task::none()
1578        }
1579        Message::SelectionFinish(ratio) => {
1580            if let (Some(anchor), Some(sample)) = (
1581                app.selection_anchor_samples.take(),
1582                sample_at_ratio(app, ratio),
1583            ) {
1584                let start = anchor.min(sample);
1585                let end = anchor.max(sample);
1586                app.selection_samples = (end > start).then_some((start, end));
1587                if let Some((start, end)) = app.selection_samples {
1588                    app.status = format!(
1589                        "Selected {}..{} samples ({:.3} s).",
1590                        start,
1591                        end,
1592                        selection_duration_seconds(app)
1593                    );
1594                }
1595            }
1596            Task::none()
1597        }
1598        Message::SelectionResize(ratio) => {
1599            let Some((start, end)) = app.selection_samples else {
1600                return Task::none();
1601            };
1602            let Some(click_sample) = sample_at_ratio(app, ratio) else {
1603                return Task::none();
1604            };
1605            let (new_start, new_end) = if click_sample <= start {
1606                (click_sample, end)
1607            } else if click_sample >= end {
1608                (start, click_sample)
1609            } else if click_sample - start < end - click_sample {
1610                (click_sample, end)
1611            } else {
1612                (start, click_sample)
1613            };
1614            app.selection_anchor_samples = None;
1615            app.selection_samples = Some((new_start, new_end));
1616            app.status = format!(
1617                "Selected {}..{} samples ({:.3} s).",
1618                new_start,
1619                new_end,
1620                selection_duration_seconds(app)
1621            );
1622            Task::none()
1623        }
1624        Message::PlayheadMoved(ratio) => {
1625            if let Some(sample) = sample_at_ratio(app, ratio) {
1626                app.playhead_samples = sample;
1627            }
1628            Task::none()
1629        }
1630        Message::SelectMarkerRegion(ratio) => {
1631            let Some(audio) = app.audio.as_ref() else {
1632                return Task::none();
1633            };
1634            if audio.markers.is_empty() {
1635                app.status = String::from("No markers to select between.");
1636                return Task::none();
1637            }
1638            let frames = audio.frames();
1639            let click_sample = (ratio.clamp(0.0, 1.0) * frames as f32).round() as usize;
1640            let mut sorted = audio.markers.clone();
1641            sorted.sort_unstable_by_key(|(sample, _)| *sample);
1642
1643            let (start, end) =
1644                if let Some((next, _)) = sorted.iter().find(|(s, _)| *s > click_sample) {
1645                    let prev = sorted
1646                        .iter()
1647                        .filter(|(s, _)| *s < click_sample)
1648                        .map(|(s, _)| *s)
1649                        .next_back()
1650                        .unwrap_or(0);
1651                    (prev, *next)
1652                } else {
1653                    let last = sorted.last().map(|(s, _)| *s).unwrap_or(0);
1654                    (last, frames)
1655                };
1656
1657            app.selection_anchor_samples = None;
1658            app.selection_samples = Some((start, end));
1659            app.status = format!(
1660                "Selected region {}..{} samples ({:.3} s).",
1661                start,
1662                end,
1663                selection_duration_seconds(app)
1664            );
1665            Task::none()
1666        }
1667        Message::StandalonePlaybackStarted(Ok(())) => Task::none(),
1668        Message::StandalonePlaybackStarted(Err(err)) => {
1669            app.playing = false;
1670            app.status = err;
1671            Task::none()
1672        }
1673        Message::StandalonePlaybackStopped(Ok(())) => {
1674            app.playing = false;
1675            app.status = String::from("Stopped.");
1676            Task::none()
1677        }
1678        Message::StandalonePlaybackStopped(Err(err)) => {
1679            app.status = err;
1680            Task::none()
1681        }
1682        Message::FadeIn
1683        | Message::FadeOut
1684        | Message::IncreaseVolume
1685        | Message::DecreaseVolume
1686        | Message::Reverse => dispatch_audio_edit(app, message.clone()),
1687        Message::EditAction(action) => apply_standalone_audio_edit_action(app, action),
1688        Message::Undo => {
1689            let Some(audio) = app.audio.as_mut() else {
1690                app.status = String::from("No audio file is open.");
1691                return Task::none();
1692            };
1693            match app.history.undo() {
1694                Some(snapshot) => {
1695                    restore_document(audio, snapshot);
1696                    audio.rebuild_preview();
1697                    app.status = String::from("Undone.");
1698                    prepare_document_track(app)
1699                }
1700                None => {
1701                    app.status = String::from("Nothing to undo.");
1702                    Task::none()
1703                }
1704            }
1705        }
1706        Message::Redo => {
1707            let Some(audio) = app.audio.as_mut() else {
1708                app.status = String::from("No audio file is open.");
1709                return Task::none();
1710            };
1711            match app.history.redo() {
1712                Some(snapshot) => {
1713                    restore_document(audio, snapshot);
1714                    audio.rebuild_preview();
1715                    app.status = String::from("Redone.");
1716                    prepare_document_track(app)
1717                }
1718                None => {
1719                    app.status = String::from("Nothing to redo.");
1720                    Task::none()
1721                }
1722            }
1723        }
1724        Message::DeleteSelection => delete_selection(app),
1725        Message::FileOpened(Some(path)) => {
1726            #[cfg(feature = "standalone")]
1727            {
1728                load_document(app, path, None, None)
1729            }
1730            #[cfg(not(feature = "standalone"))]
1731            {
1732                let _ = path;
1733                app.busy = false;
1734                app.busy_progress = 0.0;
1735                app.status =
1736                    String::from("File loading is only available in standalone editor builds.");
1737                Task::none()
1738            }
1739        }
1740        Message::FileOpened(None) => {
1741            app.busy = false;
1742            app.busy_progress = 0.0;
1743            app.status = String::from("Open cancelled.");
1744            Task::none()
1745        }
1746        Message::DocumentLoadProgress { progress, status } => {
1747            app.busy = true;
1748            app.busy_progress = progress.clamp(0.0, 1.0);
1749            app.status = status;
1750            Task::none()
1751        }
1752        Message::DocumentLoaded(Ok(audio)) => {
1753            app.busy = false;
1754            app.busy_progress = 1.0;
1755            app.status = document_status(&audio);
1756            app.playing = false;
1757            app.playhead_samples = 0;
1758            app.engine_clip_path = None;
1759            app.selection_anchor_samples = None;
1760            app.selection_samples = None;
1761            app.history = EditHistory::new(DocumentSnapshot {
1762                samples: audio.samples.clone(),
1763                edits: audio.edits,
1764                edit_actions: audio.edit_actions.clone(),
1765                markers: audio.markers.clone(),
1766            });
1767            app.audio = Some(audio);
1768            prepare_document_track(app)
1769        }
1770        Message::DocumentLoaded(Err(err)) => {
1771            app.busy = false;
1772            app.busy_progress = 0.0;
1773            app.status = err;
1774            Task::none()
1775        }
1776        Message::EngineDocumentPrepared(Ok(())) => {
1777            app.preparing_playback = false;
1778            if let Some(audio) = app.audio.as_ref() {
1779                app.status = document_status(audio);
1780            }
1781            Task::none()
1782        }
1783        Message::EngineDocumentPrepared(Err(err)) => {
1784            app.preparing_playback = false;
1785            app.status = err;
1786            Task::none()
1787        }
1788        Message::FileSaved(Some(path)) => {
1789            #[cfg(feature = "standalone")]
1790            {
1791                let Some(audio) = app.audio.as_ref() else {
1792                    app.busy = false;
1793                    app.busy_progress = 0.0;
1794                    app.status = String::from("No audio file is open.");
1795                    return Task::none();
1796                };
1797                app.busy = true;
1798                app.busy_progress = 0.0;
1799                app.status = format!("Saving {}...", path.display());
1800                let samples = audio.rendered_save_samples();
1801                let channels = audio.channels;
1802                let sample_rate = audio.sample_rate;
1803                Task::perform(
1804                    save_document(path, samples, channels, sample_rate),
1805                    Message::DocumentSaved,
1806                )
1807            }
1808            #[cfg(not(feature = "standalone"))]
1809            {
1810                let _ = path;
1811                app.busy = false;
1812                app.busy_progress = 0.0;
1813                app.status = String::from("Saving is handled by the embedding host.");
1814                Task::none()
1815            }
1816        }
1817        Message::FileSaved(None) => {
1818            app.busy = false;
1819            app.busy_progress = 0.0;
1820            app.close_window_id = None;
1821            app.status = String::from("Save cancelled.");
1822            Task::none()
1823        }
1824        Message::DocumentSaved(Ok(path)) => {
1825            app.busy = false;
1826            app.busy_progress = 1.0;
1827            if let Some(audio) = app.audio.as_mut() {
1828                audio.save_path = Some(path.clone());
1829            }
1830            app.history.mark_saved();
1831            app.status = format!("Saved {}.", path.display());
1832            if let Some(window_id) = app.close_window_id.take() {
1833                window::close(window_id)
1834            } else {
1835                Task::none()
1836            }
1837        }
1838        Message::DocumentSaved(Err(err)) => {
1839            app.busy = false;
1840            app.busy_progress = 0.0;
1841            app.close_window_id = None;
1842            app.status = err;
1843            Task::none()
1844        }
1845        Message::WindowCloseRequested(window_id) => {
1846            if app.history.is_dirty() {
1847                app.status = String::from("Unsaved changes. Save, discard, or cancel?");
1848                Task::perform(close_confirmation_dialog(), move |result| {
1849                    Message::CloseDialogResult(window_id, result)
1850                })
1851            } else {
1852                window::close(window_id)
1853            }
1854        }
1855        Message::CloseDialogResult(window_id, result) => match result {
1856            rfd::MessageDialogResult::Yes => {
1857                app.close_window_id = Some(window_id);
1858                update(app, Message::Save)
1859            }
1860            rfd::MessageDialogResult::No => window::close(window_id),
1861            _ => {
1862                app.close_window_id = None;
1863                app.status = String::from("Close cancelled.");
1864                Task::none()
1865            }
1866        },
1867        Message::MarkerCreateDialog { sample } => {
1868            app.marker_dialog = Some(MarkerDialog {
1869                sample,
1870                name: String::new(),
1871            });
1872            maolan_widgets::iced::widget::operation::focus(marker_name_input_id())
1873        }
1874        Message::MarkerNameInput(name) => {
1875            if let Some(dialog) = app.marker_dialog.as_mut() {
1876                dialog.name = name;
1877            }
1878            Task::none()
1879        }
1880        Message::MarkerNameConfirm => {
1881            let Some(dialog) = app.marker_dialog.take() else {
1882                return Task::none();
1883            };
1884            let name = dialog.name.trim().to_string();
1885            if name.is_empty() {
1886                return Task::none();
1887            }
1888            let Some(audio) = app.audio.as_mut() else {
1889                return Task::none();
1890            };
1891            audio.markers.push((dialog.sample, name));
1892            audio.markers.sort_unstable_by_key(|(sample, _)| *sample);
1893            audio.markers.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
1894            app.status = format!("Marker added at sample {}.", dialog.sample);
1895            Task::none()
1896        }
1897        Message::MarkerNameCancel => {
1898            app.marker_dialog = None;
1899            app.detect_markers_dialog = None;
1900            app.export_markers_dialog = None;
1901            app.preferences_dialog = None;
1902            Task::none()
1903        }
1904        Message::MarkerDelete { sample } => {
1905            let Some(audio) = app.audio.as_mut() else {
1906                return Task::none();
1907            };
1908            let before = audio.markers.len();
1909            audio
1910                .markers
1911                .retain(|(marker_sample, _)| *marker_sample != sample);
1912            if audio.markers.len() < before {
1913                app.status = format!("Marker at sample {sample} deleted.");
1914            } else {
1915                app.status = String::from("No marker at that position.");
1916            }
1917            Task::none()
1918        }
1919        Message::DetectMarkersDialog => {
1920            app.detect_markers_dialog = Some(DetectMarkersDialog::default());
1921            Task::none()
1922        }
1923        Message::DetectMarkersThresholdInput(value) => {
1924            if let Some(dialog) = app.detect_markers_dialog.as_mut() {
1925                dialog.threshold_db = value;
1926            }
1927            Task::none()
1928        }
1929        Message::DetectMarkersSilenceSamplesInput(value) => {
1930            if let Some(dialog) = app.detect_markers_dialog.as_mut() {
1931                dialog.silence_samples = value;
1932            }
1933            Task::none()
1934        }
1935        Message::DetectMarkersConfirm => {
1936            let Some(dialog) = app.detect_markers_dialog.take() else {
1937                return Task::none();
1938            };
1939            let Some(audio) = app.audio.as_mut() else {
1940                app.status = String::from("No audio file is open.");
1941                return Task::none();
1942            };
1943            let Ok(threshold_db) = dialog.threshold_db.trim().parse::<f32>() else {
1944                app.status = String::from("Invalid threshold value.");
1945                return Task::none();
1946            };
1947            let Ok(silence_samples) = dialog.silence_samples.trim().parse::<usize>() else {
1948                app.status = String::from("Invalid silence sample count.");
1949                return Task::none();
1950            };
1951            if silence_samples == 0 {
1952                app.status = String::from("Silence sample count must be greater than zero.");
1953                return Task::none();
1954            }
1955            let detected = detect_markers(
1956                &audio.preview_samples,
1957                audio.channels,
1958                threshold_db,
1959                silence_samples,
1960            );
1961            let added = detected.len();
1962            audio.markers.extend(detected);
1963            audio.markers.sort_unstable_by_key(|(sample, _)| *sample);
1964            audio.markers.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
1965            app.status = format!("Detected {added} marker positions.");
1966            Task::none()
1967        }
1968        Message::DetectMarkersCancel => {
1969            app.detect_markers_dialog = None;
1970            Task::none()
1971        }
1972        Message::ExportMarkersDialog => {
1973            #[cfg(feature = "standalone")]
1974            {
1975                let task = if app.export_markers_dialog.is_some() {
1976                    Task::perform(
1977                        choose_export_directory(),
1978                        Message::ExportMarkersDirectorySelected,
1979                    )
1980                } else {
1981                    Task::none()
1982                };
1983                app.export_markers_dialog = Some(ExportMarkersDialog::default());
1984                task
1985            }
1986            #[cfg(not(feature = "standalone"))]
1987            {
1988                app.status =
1989                    String::from("Marker export is only available in standalone editor builds.");
1990                Task::none()
1991            }
1992        }
1993        Message::ExportMarkersDirectorySelected(directory) => {
1994            if let Some(dialog) = app.export_markers_dialog.as_mut() {
1995                dialog.directory = directory;
1996            }
1997            Task::none()
1998        }
1999        Message::ExportMarkersFormatSelected(format) => {
2000            if let Some(dialog) = app.export_markers_dialog.as_mut() {
2001                dialog.format = format;
2002            }
2003            Task::none()
2004        }
2005        Message::ExportMarkersBitDepthSelected(bit_depth) => {
2006            if let Some(dialog) = app.export_markers_dialog.as_mut() {
2007                dialog.bit_depth = bit_depth;
2008            }
2009            Task::none()
2010        }
2011        Message::ExportMarkersSampleRateSelected(sample_rate) => {
2012            if let Some(dialog) = app.export_markers_dialog.as_mut() {
2013                dialog.sample_rate = sample_rate;
2014            }
2015            Task::none()
2016        }
2017        Message::ExportMarkersConfirm => {
2018            #[cfg(feature = "standalone")]
2019            {
2020                let Some(dialog) = app.export_markers_dialog.take() else {
2021                    return Task::none();
2022                };
2023                let Some(audio) = app.audio.as_ref() else {
2024                    app.status = String::from("No audio file is open.");
2025                    return Task::none();
2026                };
2027                let Some(directory) = dialog.directory else {
2028                    app.status = String::from("Choose an export directory.");
2029                    return Task::none();
2030                };
2031                if audio.markers.is_empty() {
2032                    app.status = String::from("No markers to export between.");
2033                    return Task::none();
2034                }
2035                app.busy = true;
2036                app.busy_progress = 0.0;
2037                app.status = String::from("Exporting marker ranges...");
2038                let audio_clone = audio.clone();
2039                Task::perform(
2040                    export_marker_ranges(
2041                        directory,
2042                        audio_clone,
2043                        dialog.format,
2044                        dialog.bit_depth,
2045                        dialog.sample_rate.value(),
2046                    ),
2047                    Message::ExportMarkersFinished,
2048                )
2049            }
2050            #[cfg(not(feature = "standalone"))]
2051            {
2052                app.export_markers_dialog = None;
2053                app.status =
2054                    String::from("Marker export is only available in standalone editor builds.");
2055                Task::none()
2056            }
2057        }
2058        Message::ExportMarkersCancel => {
2059            app.export_markers_dialog = None;
2060            Task::none()
2061        }
2062        Message::ExportMarkersFinished(result) => {
2063            app.busy = false;
2064            app.busy_progress = 1.0;
2065            match result {
2066                Ok(count) => app.status = format!("Exported {count} marker range(s)."),
2067                Err(err) => app.status = err,
2068            }
2069            Task::none()
2070        }
2071        Message::PreferencesDialog => {
2072            app.preferences_dialog = Some(PreferencesDialog::from_setup(&app.setup));
2073            Task::none()
2074        }
2075        Message::PreferencesOutputDeviceSelected(device) => {
2076            if let Some(dialog) = app.preferences_dialog.as_mut() {
2077                dialog.output_device = Some(device);
2078            }
2079            Task::none()
2080        }
2081        Message::PreferencesInputDeviceSelected(device) => {
2082            if let Some(dialog) = app.preferences_dialog.as_mut() {
2083                dialog.input_device = Some(device);
2084            }
2085            Task::none()
2086        }
2087        Message::PreferencesSave => {
2088            let Some(dialog) = app.preferences_dialog.take() else {
2089                return Task::none();
2090            };
2091            app.setup.output_device = dialog.output_device.clone();
2092            app.setup.input_device = dialog.input_device.clone();
2093            let preferences = EditorPreferences {
2094                default_output_device_id: dialog.output_device.map(|device| device.id),
2095                default_input_device_id: dialog.input_device.map(|device| device.id),
2096            };
2097            match preferences.save() {
2098                Ok(()) => app.status = String::from("Preferences saved."),
2099                Err(err) => app.status = format!("Failed to save preferences: {err}"),
2100            }
2101            Task::none()
2102        }
2103        Message::PreferencesCancel => {
2104            app.preferences_dialog = None;
2105            Task::none()
2106        }
2107    }
2108}
2109
2110pub fn subscription(app: &EditApp) -> Subscription<Message> {
2111    let mut subscriptions = Vec::new();
2112    if app.standalone_ready {
2113        subscriptions.push(keyboard::listen().map(keyboard_message));
2114    }
2115    subscriptions.push(window::close_requests().map(Message::WindowCloseRequested));
2116    if app.playing {
2117        subscriptions.push(time::every(Duration::from_millis(40)).map(|_| Message::PlaybackTick));
2118    }
2119    Subscription::batch(subscriptions)
2120}
2121
2122fn keyboard_message(event: keyboard::Event) -> Message {
2123    match event {
2124        keyboard::Event::KeyPressed {
2125            key: keyboard::Key::Named(keyboard::key::Named::Space),
2126            modifiers,
2127            repeat: false,
2128            ..
2129        } if modifiers.is_empty() => Message::TogglePlayback,
2130        keyboard::Event::KeyPressed {
2131            key: keyboard::Key::Character(c),
2132            modifiers,
2133            repeat: false,
2134            ..
2135        } if modifiers.is_empty() && c.as_str() == "z" => Message::JumpToNextZeroCrossing,
2136        keyboard::Event::KeyPressed {
2137            key: keyboard::Key::Character(c),
2138            modifiers,
2139            repeat: false,
2140            ..
2141        } if modifiers.command() => match c.as_str() {
2142            "z" | "Z" if modifiers.shift() => Message::Redo,
2143            "z" | "Z" => Message::Undo,
2144            "y" | "Y" => Message::Redo,
2145            _ => Message::None,
2146        },
2147        keyboard::Event::KeyPressed {
2148            key: keyboard::Key::Named(keyboard::key::Named::Delete),
2149            repeat: false,
2150            ..
2151        } => Message::DeleteSelection,
2152        keyboard::Event::KeyPressed {
2153            key: keyboard::Key::Named(keyboard::key::Named::Escape),
2154            repeat: false,
2155            ..
2156        } => Message::MarkerNameCancel,
2157        _ => Message::None,
2158    }
2159}
2160
2161pub fn view(app: &EditApp) -> Element<'_, Message> {
2162    if !app.standalone_ready {
2163        return startup_view(app);
2164    }
2165    view_with_chrome(app, true, true, true)
2166}
2167
2168pub fn embedded_view(app: &EditApp) -> Element<'_, Message> {
2169    view_with_chrome(app, false, true, true)
2170}
2171
2172pub fn embedded_view_with_play_disabled(
2173    app: &EditApp,
2174    play_disabled: bool,
2175) -> Element<'_, Message> {
2176    view_with_chrome_options(app, false, true, true, play_disabled)
2177}
2178
2179pub fn embedded_view_without_vu_meter(app: &EditApp) -> Element<'_, Message> {
2180    view_with_chrome(app, false, true, false)
2181}
2182
2183fn view_with_chrome(
2184    app: &EditApp,
2185    show_menu: bool,
2186    show_toolbar: bool,
2187    show_vu_meter: bool,
2188) -> Element<'_, Message> {
2189    view_with_chrome_options(app, show_menu, show_toolbar, show_vu_meter, false)
2190}
2191
2192fn view_with_chrome_options(
2193    app: &EditApp,
2194    show_menu: bool,
2195    show_toolbar: bool,
2196    show_vu_meter: bool,
2197    play_disabled: bool,
2198) -> Element<'_, Message> {
2199    let waveform: Element<'_, Message> = match app.audio.as_ref() {
2200        Some(audio) => {
2201            let markers = audio
2202                .markers
2203                .iter()
2204                .map(|(sample, name)| (*sample, name.clone()))
2205                .collect::<Vec<_>>();
2206            SampleWaveform::new(audio.channel_samples.iter().map(Vec::as_slice), audio.peak)
2207                .playhead_ratio(playhead_ratio(app))
2208                .selection_ratio(selection_ratio(app))
2209                .markers(markers)
2210                .on_selection_start(Message::SelectionStart)
2211                .on_selection_drag(Message::SelectionDrag)
2212                .on_selection_finish(Message::SelectionFinish)
2213                .on_click(Message::PlayheadMoved)
2214                .on_double_click(Message::SelectMarkerRegion)
2215                .on_right_click(|ratio| {
2216                    let sample = sample_at_ratio(app, ratio).unwrap_or(0);
2217                    Message::MarkerCreateDialog { sample }
2218                })
2219                .on_middle_click(|ratio| {
2220                    let sample = app
2221                        .audio
2222                        .as_ref()
2223                        .and_then(|audio| nearest_marker_sample(audio, ratio))
2224                        .unwrap_or(0);
2225                    Message::MarkerDelete { sample }
2226                })
2227                .on_middle_click_away(Message::SelectionResize)
2228                .view()
2229        }
2230        None => SampleWaveform::<Message>::new(std::iter::empty::<&[f32]>(), 0.0).view(),
2231    };
2232
2233    let mut waveform = row![
2234        container(waveform)
2235            .width(Length::Fill)
2236            .height(Length::Fill)
2237            .style(panel_style),
2238    ]
2239    .spacing(8);
2240    if show_vu_meter {
2241        waveform = waveform.push(vu_meter(app));
2242    }
2243    let waveform = container(waveform)
2244        .width(Length::Fill)
2245        .height(Length::Fill)
2246        .style(|_theme| container::Style::default());
2247    let mut content = column![]
2248        .spacing(10)
2249        .padding(12)
2250        .width(Length::Fill)
2251        .height(Length::Fill);
2252    if show_menu {
2253        content = content.push(standalone_menu());
2254    }
2255    if show_toolbar {
2256        content = content.push(toolbar_for_app(app, play_disabled));
2257    }
2258    let mut content = content.push(waveform.width(Length::Fill).height(Length::Fill));
2259    if app.busy {
2260        content = content.push(progress_view(app.busy_progress));
2261    }
2262    let content = content.push(text(&app.status).size(12));
2263
2264    let mut view: Element<'_, Message> = container(content)
2265        .width(Length::Fill)
2266        .height(Length::Fill)
2267        .style(app_style)
2268        .into();
2269    if let Some(dialog) = app.preferences_dialog.as_ref() {
2270        view = row![view, preferences_dialog_view(dialog)]
2271            .width(Length::Fill)
2272            .height(Length::Fill)
2273            .into();
2274    } else if let Some(dialog) = app.export_markers_dialog.as_ref() {
2275        view = row![view, export_markers_dialog_view(dialog)]
2276            .width(Length::Fill)
2277            .height(Length::Fill)
2278            .into();
2279    } else if let Some(dialog) = app.detect_markers_dialog.as_ref() {
2280        view = row![view, detect_markers_dialog_view(dialog)]
2281            .width(Length::Fill)
2282            .height(Length::Fill)
2283            .into();
2284    } else if let Some(dialog) = app.marker_dialog.as_ref() {
2285        view = row![view, marker_dialog_view(dialog)]
2286            .width(Length::Fill)
2287            .height(Length::Fill)
2288            .into();
2289    }
2290
2291    view
2292}
2293
2294pub fn menu(show_open: bool) -> Element<'static, Message> {
2295    let file_items = if show_open {
2296        maolan_widgets::iced_aw::menu::Menu::new(menu_items!(
2297            (menu_item("Open", Message::Open)),
2298            (menu_item("Close", Message::Close)),
2299            (menu_item("Save", Message::Save)),
2300            (menu_item("Save As", Message::SaveAs)),
2301        ))
2302    } else {
2303        maolan_widgets::iced_aw::menu::Menu::new(menu_items!(
2304            (menu_item("Close", Message::Close)),
2305            (menu_item("Save", Message::Save)),
2306            (menu_item("Save As", Message::SaveAs)),
2307        ))
2308    }
2309    .width(180.0)
2310    .offset(15.0)
2311    .spacing(5.0);
2312
2313    let edit_items = maolan_widgets::iced_aw::menu::Menu::new(menu_items!(
2314        (menu_item("Undo", Message::Undo)),
2315        (menu_item("Redo", Message::Redo)),
2316        (menu_item("Next Zero Crossing", Message::JumpToNextZeroCrossing)),
2317        (menu_item("Reverse", Message::Reverse)),
2318        (menu_item("Detect Markers", Message::DetectMarkersDialog)),
2319        (menu_item("Export Markers", Message::ExportMarkersDialog)),
2320        (menu_item("Preferences", Message::PreferencesDialog)),
2321    ))
2322    .width(180.0)
2323    .offset(15.0)
2324    .spacing(5.0);
2325
2326    menu_bar!(
2327        (menu_dropdown("File", Message::None), { file_items }),
2328        (menu_dropdown("Edit", Message::None), { edit_items }),
2329    )
2330    .draw_path(DrawPath::Backdrop)
2331    .close_on_item_click_global(true)
2332    .width(Length::Fill)
2333    .into()
2334}
2335
2336pub fn standalone_menu() -> Element<'static, Message> {
2337    let file_items = maolan_widgets::iced_aw::menu::Menu::new(menu_items!(
2338        (menu_item("Open", Message::Open)),
2339        (menu_item("Close", Message::Close)),
2340        (menu_item("Save", Message::Save)),
2341        (menu_item("Save As", Message::SaveAs)),
2342    ))
2343    .width(180.0)
2344    .offset(15.0)
2345    .spacing(5.0);
2346
2347    let edit_items = maolan_widgets::iced_aw::menu::Menu::new(menu_items!(
2348        (menu_item("Undo", Message::Undo)),
2349        (menu_item("Redo", Message::Redo)),
2350        (menu_item("Next Zero Crossing", Message::JumpToNextZeroCrossing)),
2351        (menu_item("Reverse", Message::Reverse)),
2352        (menu_item("Detect Markers", Message::DetectMarkersDialog)),
2353        (menu_item("Export Markers", Message::ExportMarkersDialog)),
2354        (menu_item("Preferences", Message::PreferencesDialog)),
2355    ))
2356    .width(180.0)
2357    .offset(15.0)
2358    .spacing(5.0);
2359
2360    menu_bar!(
2361        (menu_dropdown("File", Message::None), { file_items }),
2362        (menu_dropdown("Edit", Message::None), { edit_items }),
2363    )
2364    .draw_path(DrawPath::Backdrop)
2365    .close_on_item_click_global(true)
2366    .width(Length::Fill)
2367    .into()
2368}
2369
2370pub fn toolbar() -> Element<'static, Message> {
2371    toolbar_with_playhead("00:00.000", false)
2372}
2373
2374pub fn toolbar_with_playhead(label: impl Into<String>, playing: bool) -> Element<'static, Message> {
2375    toolbar_with_playhead_options(label, playing, false)
2376}
2377
2378fn toolbar_with_playhead_options(
2379    label: impl Into<String>,
2380    playing: bool,
2381    play_disabled: bool,
2382) -> Element<'static, Message> {
2383    let label = label.into();
2384    let play_button = if play_disabled {
2385        toolbar_button_disabled(play().size(16), "Play")
2386    } else {
2387        toolbar_button(play().size(16), "Play", Message::Play)
2388    };
2389    container(
2390        row![
2391            toolbar_button(undo().size(16), "Undo", Message::Undo),
2392            toolbar_button(redo().size(16), "Redo", Message::Redo),
2393            toolbar_button(rewind().size(16), "Rewind to start", Message::RewindToStart),
2394            play_button,
2395            toolbar_button(square().size(16), "Stop", Message::Stop),
2396            toolbar_button(fast_forward().size(16), "Go to end", Message::GoToEnd),
2397            toolbar_button(
2398                arrow_right().size(16),
2399                "Next zero crossing",
2400                Message::JumpToNextZeroCrossing
2401            ),
2402            container(text(label).size(14))
2403                .padding([4, 8])
2404                .style(if playing {
2405                    playhead_active_style
2406                } else {
2407                    playhead_style
2408                }),
2409            toolbar_button(trending_up().size(16), "Fade in", Message::FadeIn),
2410            toolbar_button(trending_down().size(16), "Fade out", Message::FadeOut),
2411            toolbar_button(
2412                flag().size(16),
2413                "Detect markers",
2414                Message::DetectMarkersDialog
2415            ),
2416            toolbar_button(
2417                arrow_up().size(16),
2418                "Increase volume",
2419                Message::IncreaseVolume
2420            ),
2421            toolbar_button(
2422                arrow_down().size(16),
2423                "Decrease volume",
2424                Message::DecreaseVolume
2425            ),
2426            Space::new().width(Length::Fill),
2427        ]
2428        .spacing(4)
2429        .align_y(maolan_widgets::iced::Alignment::Center),
2430    )
2431    .width(Length::Fill)
2432    .height(Length::Fixed(34.0))
2433    .padding([4, 8])
2434    .style(toolbar_style)
2435    .into()
2436}
2437
2438fn toolbar_for_app(app: &EditApp, play_disabled: bool) -> Element<'static, Message> {
2439    toolbar_with_playhead_options(playhead_label(app), app.playing, play_disabled)
2440}
2441
2442fn toolbar_button<'a>(
2443    icon: impl Into<Element<'a, Message>>,
2444    label: &'static str,
2445    message: Message,
2446) -> Element<'a, Message> {
2447    tooltip(
2448        button(icon)
2449            .width(Length::Fixed(30.0))
2450            .height(Length::Fixed(26.0))
2451            .style(toolbar_button_style)
2452            .on_press(message),
2453        container(text(label).size(12))
2454            .padding([4, 8])
2455            .style(tooltip_style),
2456        tooltip::Position::Bottom,
2457    )
2458    .gap(4)
2459    .into()
2460}
2461
2462fn toolbar_button_disabled<'a>(
2463    icon: impl Into<Element<'a, Message>>,
2464    label: &'static str,
2465) -> Element<'a, Message> {
2466    tooltip(
2467        button(icon)
2468            .width(Length::Fixed(30.0))
2469            .height(Length::Fixed(26.0))
2470            .style(toolbar_button_style),
2471        container(text(label).size(12))
2472            .padding([4, 8])
2473            .style(tooltip_style),
2474        tooltip::Position::Bottom,
2475    )
2476    .gap(4)
2477    .into()
2478}
2479
2480fn load_audio_buffer(
2481    app: &mut EditApp,
2482    audio: AudioBuffer,
2483    region: Option<AudioRegion>,
2484) -> Task<Message> {
2485    app.busy = true;
2486    app.busy_progress = 0.0;
2487    app.status = format!("Opening {}...", audio.name);
2488    Task::perform(
2489        async move { AudioDocument::from_interleaved(audio, region) },
2490        Message::DocumentLoaded,
2491    )
2492}
2493
2494#[cfg(feature = "standalone")]
2495fn load_document(
2496    app: &mut EditApp,
2497    path: PathBuf,
2498    region: Option<AudioRegion>,
2499    _timeline_region: Option<AudioRegion>,
2500) -> Task<Message> {
2501    app.busy = true;
2502    app.busy_progress = 0.0;
2503    app.status = match region {
2504        Some(_) => format!("Opening clip from {}...", path.display()),
2505        None => format!("Opening {}...", path.display()),
2506    };
2507    Task::run(
2508        {
2509            let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2510            std::thread::spawn(move || {
2511                let mut last_bucket = None;
2512                let mut last_status = String::new();
2513                let progress_tx = tx.clone();
2514                let result = AudioDocument::open_with_progress(path, region, |progress, status| {
2515                    let progress = progress.clamp(0.0, 1.0);
2516                    let bucket = (progress * 100.0).round() as u8;
2517                    if last_bucket == Some(bucket) && last_status == status {
2518                        return;
2519                    }
2520                    last_bucket = Some(bucket);
2521                    last_status = status.to_string();
2522                    let _ = progress_tx.send(Message::DocumentLoadProgress {
2523                        progress,
2524                        status: status.to_string(),
2525                    });
2526                });
2527                let _ = tx.send(Message::DocumentLoaded(result));
2528            });
2529
2530            maolan_widgets::iced::futures::stream::unfold(rx, |mut rx| async move {
2531                rx.recv().await.map(|msg| (msg, rx))
2532            })
2533        },
2534        |msg| msg,
2535    )
2536}
2537
2538fn document_status(audio: &AudioDocument) -> String {
2539    match audio.clip_region {
2540        Some(region) => format!(
2541            "{} - clip {}..{} samples, {} ch, {} Hz, {} frames",
2542            audio.source_path.display(),
2543            region.offset,
2544            region.offset.saturating_add(region.length),
2545            audio.channels,
2546            audio.sample_rate,
2547            audio.frames()
2548        ),
2549        None => format!(
2550            "{} - {} ch, {} Hz, {} frames",
2551            audio.source_path.display(),
2552            audio.channels,
2553            audio.sample_rate,
2554            audio.frames()
2555        ),
2556    }
2557}
2558
2559fn progress_view(progress: f32) -> Element<'static, Message> {
2560    let progress = progress.clamp(0.0, 1.0);
2561    let percent = (progress * 100.0).round() as u8;
2562    row![
2563        container(progress_bar(0.0..=1.0, progress)).width(Length::Fill),
2564        text(format!("{percent}%"))
2565            .size(12)
2566            .width(Length::Fixed(44.0)),
2567    ]
2568    .spacing(8)
2569    .align_y(maolan_widgets::iced::Alignment::Center)
2570    .into()
2571}
2572
2573fn marker_name_input_id() -> Id {
2574    Id::new("edit-marker-name-input")
2575}
2576
2577fn detect_markers_threshold_input_id() -> Id {
2578    Id::new("edit-detect-markers-threshold-input")
2579}
2580
2581fn marker_dialog_view(dialog: &MarkerDialog) -> Element<'_, Message> {
2582    let can_confirm = !dialog.name.trim().is_empty();
2583    let confirm_button = if can_confirm {
2584        button("Create").on_press(Message::MarkerNameConfirm)
2585    } else {
2586        button("Create")
2587    };
2588
2589    container(
2590        column![
2591            text("Add Marker"),
2592            text_input("Enter marker name", &dialog.name)
2593                .id(marker_name_input_id())
2594                .on_input(Message::MarkerNameInput)
2595                .on_submit(Message::MarkerNameConfirm)
2596                .width(Length::Fill),
2597            row![
2598                confirm_button,
2599                button("Cancel")
2600                    .on_press(Message::MarkerNameCancel)
2601                    .style(button::secondary)
2602            ]
2603            .spacing(10),
2604        ]
2605        .spacing(10),
2606    )
2607    .style(|_theme| container::Style {
2608        border: Border {
2609            color: Color::from_rgba(0.34, 0.42, 0.56, 0.72),
2610            width: 1.0,
2611            ..Border::default()
2612        },
2613        background: Some(Background::Color(Color::from_rgb(0.12, 0.13, 0.16))),
2614        ..container::Style::default()
2615    })
2616    .padding(12)
2617    .width(Length::Fixed(320.0))
2618    .into()
2619}
2620
2621fn detect_markers_dialog_view(dialog: &DetectMarkersDialog) -> Element<'_, Message> {
2622    let threshold_valid = dialog.threshold_db.trim().parse::<f32>().is_ok();
2623    let silence_valid = dialog
2624        .silence_samples
2625        .trim()
2626        .parse::<usize>()
2627        .is_ok_and(|value| value > 0);
2628    let can_confirm = threshold_valid && silence_valid;
2629    let confirm_button = if can_confirm {
2630        button("Detect").on_press(Message::DetectMarkersConfirm)
2631    } else {
2632        button("Detect")
2633    };
2634
2635    container(
2636        column![
2637            text("Detect Markers"),
2638            text("Silence threshold (dB)").size(12),
2639            text_input("-60.0", &dialog.threshold_db)
2640                .id(detect_markers_threshold_input_id())
2641                .on_input(Message::DetectMarkersThresholdInput)
2642                .on_submit(Message::DetectMarkersConfirm)
2643                .width(Length::Fill),
2644            text("Silent samples").size(12),
2645            text_input("1000", &dialog.silence_samples)
2646                .on_input(Message::DetectMarkersSilenceSamplesInput)
2647                .on_submit(Message::DetectMarkersConfirm)
2648                .width(Length::Fill),
2649            row![
2650                confirm_button,
2651                button("Cancel")
2652                    .on_press(Message::DetectMarkersCancel)
2653                    .style(button::secondary)
2654            ]
2655            .spacing(10),
2656        ]
2657        .spacing(10),
2658    )
2659    .style(|_theme| container::Style {
2660        border: Border {
2661            color: Color::from_rgba(0.34, 0.42, 0.56, 0.72),
2662            width: 1.0,
2663            ..Border::default()
2664        },
2665        background: Some(Background::Color(Color::from_rgb(0.12, 0.13, 0.16))),
2666        ..container::Style::default()
2667    })
2668    .padding(12)
2669    .width(Length::Fixed(320.0))
2670    .into()
2671}
2672
2673fn export_markers_dialog_view(dialog: &ExportMarkersDialog) -> Element<'_, Message> {
2674    let directory_label = dialog
2675        .directory
2676        .as_ref()
2677        .map(|path| path.to_string_lossy().to_string())
2678        .unwrap_or_else(|| String::from("No directory selected"));
2679    let can_confirm = dialog.directory.is_some();
2680    let confirm_button = if can_confirm {
2681        button("Export").on_press(Message::ExportMarkersConfirm)
2682    } else {
2683        button("Export")
2684    };
2685    let show_bit_depth = dialog.format != ExportFormat::Mp3;
2686    let bit_depth_input: Element<'_, Message> = if show_bit_depth {
2687        pick_list(
2688            ExportBitDepth::ALL,
2689            Some(dialog.bit_depth),
2690            Message::ExportMarkersBitDepthSelected,
2691        )
2692        .width(Length::Fill)
2693        .into()
2694    } else {
2695        container(text("MP3 uses 16-bit PCM internally.").size(12))
2696            .width(Length::Fill)
2697            .into()
2698    };
2699
2700    container(
2701        column![
2702            text("Export Marker Ranges"),
2703            button("Choose Directory...")
2704                .on_press(Message::ExportMarkersDialog)
2705                .width(Length::Fill),
2706            text(directory_label).size(12),
2707            text("Format").size(12),
2708            pick_list(
2709                ExportFormat::ALL,
2710                Some(dialog.format),
2711                Message::ExportMarkersFormatSelected
2712            )
2713            .width(Length::Fill),
2714            text("Sample rate").size(12),
2715            pick_list(
2716                ExportSampleRate::ALL,
2717                Some(dialog.sample_rate),
2718                Message::ExportMarkersSampleRateSelected
2719            )
2720            .width(Length::Fill),
2721            text("Bit depth").size(12),
2722            bit_depth_input,
2723            row![
2724                confirm_button,
2725                button("Cancel")
2726                    .on_press(Message::ExportMarkersCancel)
2727                    .style(button::secondary)
2728            ]
2729            .spacing(10),
2730        ]
2731        .spacing(10),
2732    )
2733    .style(|_theme| container::Style {
2734        border: Border {
2735            color: Color::from_rgba(0.34, 0.42, 0.56, 0.72),
2736            width: 1.0,
2737            ..Border::default()
2738        },
2739        background: Some(Background::Color(Color::from_rgb(0.12, 0.13, 0.16))),
2740        ..container::Style::default()
2741    })
2742    .padding(12)
2743    .width(Length::Fixed(360.0))
2744    .into()
2745}
2746
2747fn preferences_dialog_view(dialog: &PreferencesDialog) -> Element<'_, Message> {
2748    const HAS_SEPARATE_AUDIO_INPUT_DEVICE: bool = cfg!(any(
2749        target_os = "freebsd",
2750        target_os = "linux",
2751        target_os = "openbsd",
2752        target_os = "windows"
2753    ));
2754    let show_input_device = !dialog.output_devices.is_empty() && HAS_SEPARATE_AUDIO_INPUT_DEVICE;
2755    let mut content = column![text("Preferences")].spacing(10);
2756    if show_input_device {
2757        content = content.push(
2758            row![
2759                text("Default input device:").width(Length::Fixed(160.0)),
2760                pick_list(
2761                    dialog.input_devices.clone(),
2762                    dialog.input_device.clone(),
2763                    Message::PreferencesInputDeviceSelected
2764                )
2765                .placeholder("Choose input device")
2766                .width(Length::Fill),
2767            ]
2768            .spacing(10)
2769            .align_y(maolan_widgets::iced::Alignment::Center),
2770        );
2771    }
2772    content = content.push(
2773        row![
2774            text("Default output device:").width(Length::Fixed(160.0)),
2775            pick_list(
2776                dialog.output_devices.clone(),
2777                dialog.output_device.clone(),
2778                Message::PreferencesOutputDeviceSelected
2779            )
2780            .placeholder("Choose output device")
2781            .width(Length::Fill),
2782        ]
2783        .spacing(10)
2784        .align_y(maolan_widgets::iced::Alignment::Center),
2785    );
2786    content = content.push(
2787        row![
2788            button("Save").on_press(Message::PreferencesSave),
2789            button("Cancel")
2790                .on_press(Message::PreferencesCancel)
2791                .style(button::secondary),
2792        ]
2793        .spacing(10),
2794    );
2795
2796    container(content)
2797        .style(|_theme| container::Style {
2798            border: Border {
2799                color: Color::from_rgba(0.34, 0.42, 0.56, 0.72),
2800                width: 1.0,
2801                ..Border::default()
2802            },
2803            background: Some(Background::Color(Color::from_rgb(0.12, 0.13, 0.16))),
2804            ..container::Style::default()
2805        })
2806        .padding(12)
2807        .width(Length::Fixed(420.0))
2808        .into()
2809}
2810
2811fn audio_setup_state(app: &EditApp) -> AudioSetupState<AudioEngineOption, AudioDeviceOption> {
2812    let is_jack = app.setup.audio_engine.is_jack();
2813    let show_input_device = !is_jack
2814        && cfg!(any(
2815            target_os = "freebsd",
2816            target_os = "linux",
2817            target_os = "openbsd",
2818            target_os = "windows"
2819        ));
2820    let show_bit_depth = !is_jack
2821        && cfg!(any(
2822            target_os = "freebsd",
2823            target_os = "linux",
2824            target_os = "openbsd",
2825            target_os = "windows"
2826        ));
2827    let output_devices: Vec<AudioDeviceOption> = app
2828        .setup
2829        .output_devices
2830        .iter()
2831        .filter(|device| backend_matches_device(app.setup.audio_engine, &device.id))
2832        .cloned()
2833        .collect();
2834    let input_devices: Vec<AudioDeviceOption> = app
2835        .setup
2836        .input_devices
2837        .iter()
2838        .filter(|device| backend_matches_device(app.setup.audio_engine, &device.id))
2839        .cloned()
2840        .collect();
2841    let selected_output_device = app
2842        .setup
2843        .output_device
2844        .as_ref()
2845        .and_then(|device| output_devices.iter().find(|d| d.id == device.id).cloned());
2846    let selected_input_device = app
2847        .setup
2848        .input_device
2849        .as_ref()
2850        .and_then(|device| input_devices.iter().find(|d| d.id == device.id).cloned());
2851    let sample_rates = sample_rate_options(&app.setup);
2852    let selected_sample_rate = if sample_rates.contains(&app.setup.sample_rate_hz) {
2853        Some(app.setup.sample_rate_hz)
2854    } else {
2855        sample_rates
2856            .iter()
2857            .min_by_key(|candidate| ((*candidate).saturating_sub(app.setup.sample_rate_hz)).abs())
2858            .copied()
2859    };
2860    let bit_depths = bit_options(&app.setup);
2861    let selected_bit_depth = if show_bit_depth {
2862        Some(if bit_depths.contains(&app.setup.bits) {
2863            app.setup.bits
2864        } else {
2865            bit_depths.first().copied().unwrap_or(32)
2866        })
2867    } else {
2868        None
2869    };
2870    let period_frames = period_frame_options(&app.setup);
2871    let selected_period_frames = if period_frames.contains(&app.setup.period_frames) {
2872        Some(app.setup.period_frames)
2873    } else {
2874        period_frames
2875            .iter()
2876            .copied()
2877            .find(|value| *value >= app.setup.period_frames)
2878            .or_else(|| period_frames.last().copied())
2879    };
2880    let n_periods: Vec<usize> = (1..=16).collect();
2881    let plugins_loaded = app.plugins_loaded();
2882    const HAS_SEPARATE_AUDIO_INPUT_DEVICE: bool = cfg!(any(
2883        target_os = "freebsd",
2884        target_os = "linux",
2885        target_os = "openbsd",
2886        target_os = "windows"
2887    ));
2888    const REQUIRE_SAMPLE_RATES_FOR_HW_READY: bool = cfg!(any(
2889        target_os = "linux",
2890        target_os = "freebsd",
2891        target_os = "openbsd"
2892    ));
2893    let hw_ready = is_jack
2894        || (selected_output_device.is_some()
2895            && (!HAS_SEPARATE_AUDIO_INPUT_DEVICE || selected_input_device.is_some())
2896            && (!REQUIRE_SAMPLE_RATES_FOR_HW_READY || !sample_rates.is_empty()));
2897
2898    AudioSetupState {
2899        backends: AudioEngineOption::ALL.to_vec(),
2900        selected_backend: app.setup.audio_engine,
2901        show_input_device,
2902        input_devices,
2903        selected_input_device,
2904        show_output_device: !is_jack,
2905        output_devices,
2906        selected_output_device,
2907        show_sample_rate: !is_jack,
2908        sample_rates,
2909        selected_sample_rate,
2910        show_bit_depth,
2911        bit_depths,
2912        selected_bit_depth,
2913        show_period_frames: !is_jack,
2914        period_frames,
2915        selected_period_frames,
2916        show_n_periods: !is_jack,
2917        n_periods,
2918        selected_n_periods: Some(app.setup.nperiods),
2919        show_exclusive: !is_jack,
2920        exclusive: app.setup.exclusive,
2921        show_sync_mode: !is_jack,
2922        sync_mode: app.setup.sync_mode,
2923        plugins_loaded,
2924        can_start: plugins_loaded && hw_ready,
2925        status_message: String::new(),
2926    }
2927}
2928
2929fn startup_view(app: &EditApp) -> Element<'_, Message> {
2930    let setup_state = audio_setup_state(app);
2931
2932    let content = audio_setup(setup_state, move |action| match action {
2933        AudioSetupAction::BackendSelected(b) => Message::StartupBackendSelected(b),
2934        AudioSetupAction::InputDeviceSelected(d) => Message::StartupInputDeviceSelected(d),
2935        AudioSetupAction::OutputDeviceSelected(d) => Message::StartupOutputDeviceSelected(d),
2936        AudioSetupAction::SampleRateSelected(r) => Message::StartupSampleRateSelected(r),
2937        AudioSetupAction::BitDepthSelected(b) => Message::StartupBitsSelected(b),
2938        AudioSetupAction::PeriodFramesSelected(p) => Message::StartupPeriodFramesSelected(p),
2939        AudioSetupAction::NPeriodsSelected(n) => Message::StartupNPeriodsSelected(n),
2940        AudioSetupAction::ExclusiveToggled(e) => Message::StartupExclusiveToggled(e),
2941        AudioSetupAction::SyncModeToggled(s) => Message::StartupSyncModeToggled(s),
2942        AudioSetupAction::Start => Message::StartupOpen,
2943    });
2944
2945    container(content)
2946        .style(app_style)
2947        .width(Length::Fill)
2948        .height(Length::Fill)
2949        .align_x(maolan_widgets::iced::Alignment::Center)
2950        .align_y(maolan_widgets::iced::Alignment::Center)
2951        .into()
2952}
2953
2954fn backend_matches_device(engine: AudioEngineOption, device_id: &str) -> bool {
2955    match engine {
2956        #[cfg(unix)]
2957        AudioEngineOption::Jack => false,
2958        #[cfg(target_os = "freebsd")]
2959        AudioEngineOption::Oss => device_id.starts_with("/dev/dsp"),
2960        #[cfg(target_os = "openbsd")]
2961        AudioEngineOption::Sndio => !device_id.is_empty(),
2962        #[cfg(target_os = "linux")]
2963        AudioEngineOption::Alsa => device_id.starts_with("hw:"),
2964        #[cfg(target_os = "windows")]
2965        AudioEngineOption::Wasapi => device_id.starts_with("wasapi:"),
2966    }
2967}
2968
2969#[cfg(feature = "standalone")]
2970async fn save_document(
2971    path: PathBuf,
2972    samples: Vec<f32>,
2973    channels: usize,
2974    sample_rate: u32,
2975) -> Result<PathBuf, String> {
2976    let format = encode_format_for_path(&path)?;
2977    encode_audio_to_file(
2978        &path,
2979        &samples,
2980        channels,
2981        sample_rate,
2982        format,
2983        AudioDither::None,
2984    )
2985    .map_err(|err| format!("Failed to save '{}': {err}", path.display()))?;
2986    Ok(path)
2987}
2988
2989#[cfg(feature = "standalone")]
2990async fn choose_export_directory() -> Option<PathBuf> {
2991    rfd::FileDialog::new().pick_folder()
2992}
2993
2994#[cfg(feature = "standalone")]
2995async fn export_marker_ranges(
2996    directory: PathBuf,
2997    audio: AudioDocument,
2998    format: ExportFormat,
2999    bit_depth: ExportBitDepth,
3000    sample_rate: u32,
3001) -> Result<usize, String> {
3002    let channels = audio.channels.max(1);
3003    let frames = audio.frames();
3004    let ranges = marker_ranges(&audio.markers, frames);
3005    if ranges.is_empty() {
3006        return Err(String::from("No ranges to export."));
3007    }
3008
3009    let source_stem = audio
3010        .source_path
3011        .file_stem()
3012        .and_then(|s| s.to_str())
3013        .unwrap_or("export");
3014    let encode_format = export_encode_format(format, bit_depth);
3015    let extension = format.extension();
3016
3017    std::fs::create_dir_all(&directory)
3018        .map_err(|err| format!("Failed to create export directory: {err}"))?;
3019
3020    let total = ranges.len();
3021    for (index, (start, end)) in ranges.iter().enumerate() {
3022        let range_samples = marker_range_samples(&audio, *start, *end);
3023        let resampled = if sample_rate == audio.sample_rate {
3024            range_samples
3025        } else {
3026            resample_interleaved(&range_samples, channels, audio.sample_rate, sample_rate)?
3027        };
3028        let filename = export_filename(source_stem, index + 1, extension);
3029        let path = directory.join(filename);
3030        encode_audio_to_file(
3031            &path,
3032            &resampled,
3033            channels,
3034            sample_rate,
3035            encode_format,
3036            AudioDither::None,
3037        )
3038        .map_err(|err| format!("Failed to export '{}': {err}", path.display()))?;
3039    }
3040
3041    Ok(total)
3042}
3043
3044#[cfg(feature = "standalone")]
3045fn marker_ranges(markers: &[(usize, String)], frames: usize) -> Vec<(usize, usize)> {
3046    let mut sorted: Vec<usize> = markers.iter().map(|(sample, _)| *sample).collect();
3047    sorted.sort_unstable();
3048    sorted.dedup();
3049    let mut ranges = Vec::new();
3050    let mut start = 0usize;
3051    for marker in sorted {
3052        if marker > start && marker <= frames {
3053            ranges.push((start, marker));
3054            start = marker;
3055        }
3056    }
3057    if start < frames {
3058        ranges.push((start, frames));
3059    }
3060    ranges
3061}
3062
3063#[cfg(feature = "standalone")]
3064fn marker_range_samples(audio: &AudioDocument, start: usize, end: usize) -> Vec<f32> {
3065    let channels = audio.channels.max(1);
3066    let frames = audio.frames();
3067    let start = start.min(frames);
3068    let end = end.min(frames);
3069    if start >= end {
3070        return Vec::new();
3071    }
3072    audio.preview_samples[start * channels..end * channels].to_vec()
3073}
3074
3075#[cfg(feature = "standalone")]
3076fn export_encode_format(format: ExportFormat, bit_depth: ExportBitDepth) -> AudioEncodeFormat {
3077    match format {
3078        ExportFormat::Wav => AudioEncodeFormat::Wav(match bit_depth {
3079            ExportBitDepth::Bits16 => WavBitDepth::Int16,
3080            ExportBitDepth::Bits24 => WavBitDepth::Int24,
3081            ExportBitDepth::Bits32 => WavBitDepth::Int32,
3082        }),
3083        ExportFormat::Flac => AudioEncodeFormat::Flac(bit_depth.bits()),
3084        ExportFormat::OggFlac => AudioEncodeFormat::OggFlac(bit_depth.bits()),
3085        ExportFormat::Mp3 => AudioEncodeFormat::Mp3,
3086    }
3087}
3088
3089#[cfg(feature = "standalone")]
3090fn export_filename(stem: &str, index: usize, extension: &str) -> String {
3091    format!("{stem}_{index:03}.{extension}")
3092}
3093
3094#[cfg(feature = "standalone")]
3095fn resample_interleaved(
3096    samples: &[f32],
3097    channels: usize,
3098    from_rate: u32,
3099    to_rate: u32,
3100) -> Result<Vec<f32>, String> {
3101    if from_rate == to_rate {
3102        return Ok(samples.to_vec());
3103    }
3104    let channels = channels.max(1);
3105    let frames = samples.len() / channels;
3106    if frames == 0 {
3107        return Ok(Vec::new());
3108    }
3109
3110    let mut input_per_channel: Vec<Vec<f32>> = vec![Vec::with_capacity(frames); channels];
3111    for frame in samples.chunks_exact(channels) {
3112        for (channel, sample) in frame.iter().copied().enumerate() {
3113            input_per_channel[channel].push(sample);
3114        }
3115    }
3116
3117    let input = SequentialSliceOfVecs::new(&input_per_channel, channels, frames)
3118        .map_err(|err| format!("Failed to wrap input samples: {err}"))?;
3119    let mut resampler = Fft::<f32>::new(
3120        from_rate as usize,
3121        to_rate as usize,
3122        1024,
3123        channels,
3124        FixedSync::Both,
3125    )
3126    .map_err(|err| format!("Failed to create resampler: {err}"))?;
3127
3128    let output = resampler
3129        .process_all(&input, frames, None)
3130        .map_err(|err| format!("Failed to resample: {err}"))?;
3131
3132    let expected_output_frames =
3133        (frames as f64 * to_rate as f64 / from_rate as f64).round() as usize;
3134    let mut output_samples = output.take_data();
3135    output_samples.truncate(expected_output_frames * channels);
3136
3137    Ok(output_samples)
3138}
3139
3140fn detect_markers(
3141    preview_samples: &[f32],
3142    channels: usize,
3143    threshold_db: f32,
3144    silence_samples: usize,
3145) -> Vec<(usize, String)> {
3146    let channels = channels.max(1);
3147    let frames = preview_samples.len() / channels;
3148    if frames == 0 {
3149        return Vec::new();
3150    }
3151    let threshold = 10.0f32.powf(threshold_db / 20.0);
3152
3153    // Build contiguous segments of silent or non-silent frames.
3154    let mut segments: Vec<(usize, usize, bool)> = Vec::new();
3155    let mut segment_start = 0usize;
3156    let frame_amplitude = |frame: usize| {
3157        preview_samples[frame * channels..(frame + 1) * channels]
3158            .iter()
3159            .map(|sample| sample.abs())
3160            .sum::<f32>()
3161            / channels as f32
3162    };
3163    let mut is_silent = frame_amplitude(0) < threshold;
3164
3165    for frame in 1..frames {
3166        let frame_silent = frame_amplitude(frame) < threshold;
3167        if frame_silent != is_silent {
3168            segments.push((segment_start, frame, is_silent));
3169            segment_start = frame;
3170            is_silent = frame_silent;
3171        }
3172    }
3173    segments.push((segment_start, frames, is_silent));
3174
3175    // Treat short silent runs as part of the surrounding sound.
3176    let mut classified: Vec<(usize, usize, bool)> = Vec::new();
3177    for (start, end, silent) in segments {
3178        if silent && end.saturating_sub(start) >= silence_samples {
3179            classified.push((start, end, true));
3180        } else if let Some(last) = classified.last_mut() {
3181            if !last.2 {
3182                last.1 = end;
3183                continue;
3184            }
3185            classified.push((start, end, false));
3186        } else {
3187            classified.push((start, end, false));
3188        }
3189    }
3190
3191    // Place markers at the boundaries of every sound region.
3192    let mut markers: Vec<(usize, String)> = Vec::new();
3193    let mut region_index = 1usize;
3194    for (index, (start, end, is_silence)) in classified.iter().enumerate() {
3195        if *is_silence {
3196            continue;
3197        }
3198        let preceded_by_silence = index == 0 || classified[index - 1].2;
3199        let followed_by_silence = index == classified.len() - 1 || classified[index + 1].2;
3200        if preceded_by_silence {
3201            markers.push((*start, format!("Region {region_index}")));
3202            region_index += 1;
3203        }
3204        if followed_by_silence {
3205            markers.push((*end, format!("Region {region_index}")));
3206            region_index += 1;
3207        }
3208    }
3209
3210    markers
3211}
3212
3213impl AudioDocument {
3214    fn from_interleaved(audio: AudioBuffer, region: Option<AudioRegion>) -> Result<Self, String> {
3215        let channels = audio.channels.max(1);
3216        let sample_rate = audio.sample_rate.max(1);
3217        let source_path = PathBuf::from(if audio.name.is_empty() {
3218            String::from("embedded audio")
3219        } else {
3220            audio.name
3221        });
3222        let samples = clip_samples(audio.samples.as_ref(), channels, region);
3223        let edits = AudioEdits::default();
3224        let edit_actions = Vec::new();
3225        let preview = render_preview_samples(&samples, channels, &edit_actions);
3226        let channel_samples = deinterleave(&preview, channels);
3227        let peak = peak(&preview);
3228
3229        Ok(Self {
3230            source_path,
3231            save_path: None,
3232            samples,
3233            preview_samples: preview,
3234            channels,
3235            sample_rate,
3236            channel_samples,
3237            peak,
3238            clip_region: region,
3239            edits,
3240            edit_actions,
3241            markers: Vec::new(),
3242        })
3243    }
3244
3245    #[cfg(feature = "standalone")]
3246    fn open_with_progress<F>(
3247        path: PathBuf,
3248        region: Option<AudioRegion>,
3249        mut progress_callback: F,
3250    ) -> Result<Self, String>
3251    where
3252        F: FnMut(f32, &str),
3253    {
3254        let name = path
3255            .file_name()
3256            .and_then(|name| name.to_str())
3257            .unwrap_or("audio file")
3258            .to_string();
3259
3260        progress_callback(0.0, &format!("Opening {name}..."));
3261        progress_callback(0.05, &format!("Decoding {name}..."));
3262        let (samples, channels, sample_rate) = decode_audio_to_f32_interleaved_sync(&path)
3263            .map_err(|err| format!("Failed to open '{}': {err}", path.display()))?;
3264        progress_callback(0.72, &format!("Preparing clip from {name}..."));
3265        let samples = clip_samples(&samples, channels, region);
3266        let edits = AudioEdits::default();
3267        let edit_actions = Vec::new();
3268        progress_callback(0.78, &format!("Applying preview edits to {name}..."));
3269        let preview = render_preview_samples(&samples, channels, &edit_actions);
3270        progress_callback(0.85, &format!("Preparing waveform for {name}..."));
3271        let channel_samples = deinterleave(&preview, channels);
3272        progress_callback(0.95, &format!("Measuring peak level for {name}..."));
3273        let peak = peak(&preview);
3274        let save_path = region.is_none().then(|| path.clone());
3275        progress_callback(1.0, &format!("Opened {name}."));
3276
3277        Ok(Self {
3278            source_path: path,
3279            save_path,
3280            samples,
3281            preview_samples: preview,
3282            channels,
3283            sample_rate,
3284            channel_samples,
3285            peak,
3286            clip_region: region,
3287            edits,
3288            edit_actions,
3289            markers: Vec::new(),
3290        })
3291    }
3292
3293    fn frames(&self) -> usize {
3294        self.samples.len() / self.channels.max(1)
3295    }
3296
3297    fn rebuild_preview(&mut self) {
3298        self.edits = summarize_audio_edit_actions(self.frames(), &self.edit_actions);
3299        let preview = render_preview_samples(&self.samples, self.channels, &self.edit_actions);
3300        self.preview_samples = preview.clone();
3301        self.channel_samples = deinterleave(&preview, self.channels);
3302        self.peak = peak(&preview);
3303    }
3304
3305    fn region_action(&self, kind: AudioEditKind) -> AudioEditAction {
3306        match kind {
3307            AudioEditKind::FadeIn => {
3308                let length = default_fade_samples(self.frames());
3309                AudioEditAction::FadeIn {
3310                    start_sample: 0,
3311                    length_samples: length,
3312                }
3313            }
3314            AudioEditKind::FadeOut => {
3315                let length = default_fade_samples(self.frames());
3316                AudioEditAction::FadeOut {
3317                    start_sample: self.frames().saturating_sub(length),
3318                    length_samples: length,
3319                }
3320            }
3321            AudioEditKind::GainDb { delta_db } => AudioEditAction::GainDb {
3322                start_sample: 0,
3323                length_samples: self.frames(),
3324                delta_db,
3325            },
3326        }
3327    }
3328
3329    fn edit_summary(&self) -> AudioEdits {
3330        summarize_audio_edit_actions(self.frames(), &self.edit_actions)
3331    }
3332
3333    #[cfg(feature = "standalone")]
3334    fn rendered_save_samples(&self) -> Vec<f32> {
3335        render_preview_samples(&self.samples, self.channels, &self.edit_actions)
3336    }
3337
3338    fn next_zero_crossing_frame(&self, start_frame: usize) -> Option<usize> {
3339        let channels = self.channels.max(1);
3340        let frames = self.preview_samples.len() / channels;
3341        if start_frame.saturating_add(1) >= frames {
3342            return None;
3343        }
3344
3345        let mut iter = self
3346            .preview_samples
3347            .chunks_exact(channels)
3348            .enumerate()
3349            .skip(start_frame);
3350        let (_, previous_chunk) = iter.next()?;
3351        let mut previous = previous_chunk.iter().sum::<f32>() / channels as f32;
3352
3353        for (frame, chunk) in iter {
3354            let current = chunk.iter().sum::<f32>() / channels as f32;
3355            if (previous > 0.0 && current <= 0.0) || (previous < 0.0 && current >= 0.0) {
3356                return Some(frame);
3357            }
3358            previous = current;
3359        }
3360        None
3361    }
3362}
3363
3364fn dispatch_audio_edit(app: &mut EditApp, message: Message) -> Task<Message> {
3365    match audio_edit_action_for_message(app, &message) {
3366        Some(action) => apply_standalone_audio_edit_action(app, action),
3367        None => {
3368            app.status = String::from("No audio file is open.");
3369            Task::none()
3370        }
3371    }
3372}
3373
3374fn apply_standalone_audio_edit_action(app: &mut EditApp, action: AudioEditAction) -> Task<Message> {
3375    let Some(audio) = app.audio.as_mut() else {
3376        app.status = String::from("No audio file is open.");
3377        return Task::none();
3378    };
3379
3380    if action.is_empty_for_frames(audio.frames()) {
3381        app.status = String::from("Selection is empty.");
3382        return Task::none();
3383    }
3384
3385    let previous_snapshot = DocumentSnapshot {
3386        samples: audio.samples.clone(),
3387        edits: audio.edits,
3388        edit_actions: audio.edit_actions.clone(),
3389        markers: audio.markers.clone(),
3390    };
3391
3392    match action {
3393        AudioEditAction::Delete {
3394            start_sample,
3395            length_samples,
3396        } => {
3397            delete_sample_range(audio, start_sample, length_samples);
3398            app.selection_anchor_samples = None;
3399            app.selection_samples = None;
3400            app.status = format!(
3401                "Deleted {}..{} samples.",
3402                start_sample,
3403                start_sample.saturating_add(length_samples)
3404            );
3405        }
3406        _ => {
3407            audio.edit_actions.push(action);
3408            audio.edits = audio.edit_summary();
3409            app.status = audio_edit_status(action, audio.edits, audio.frames());
3410        }
3411    }
3412
3413    app.history.record(
3414        previous_snapshot,
3415        DocumentSnapshot {
3416            samples: audio.samples.clone(),
3417            edits: audio.edits,
3418            edit_actions: audio.edit_actions.clone(),
3419            markers: audio.markers.clone(),
3420        },
3421    );
3422    audio.rebuild_preview();
3423    prepare_document_track(app)
3424}
3425
3426fn delete_selection(app: &mut EditApp) -> Task<Message> {
3427    match audio_edit_action_for_message(app, &Message::DeleteSelection) {
3428        Some(action) => apply_standalone_audio_edit_action(app, action),
3429        None => Task::none(),
3430    }
3431}
3432
3433fn delete_sample_range(audio: &mut AudioDocument, start: usize, length: usize) {
3434    let end = start.saturating_add(length).min(audio.frames());
3435    if start >= end {
3436        return;
3437    }
3438    let channels = audio.channels.max(1);
3439    let sample_start = start * channels;
3440    let sample_end = end * channels;
3441    audio.samples.drain(sample_start..sample_end);
3442
3443    if let Some(region) = audio.clip_region.as_mut() {
3444        let region_start = region.offset;
3445        let region_end = region.offset + region.length;
3446        if end <= region_start {
3447            region.offset = region.offset.saturating_sub(end - start);
3448        } else if start < region_end {
3449            let delete_start = start.max(region_start);
3450            let delete_end = end.min(region_end);
3451            let deleted_in_region = delete_end.saturating_sub(delete_start);
3452            region.length = region.length.saturating_sub(deleted_in_region);
3453            if start < region_start {
3454                region.offset = region_start.saturating_sub(end - start);
3455            }
3456            if region.length == 0 {
3457                audio.clip_region = None;
3458            }
3459        }
3460    }
3461
3462    let deleted_frames = end - start;
3463    audio
3464        .markers
3465        .retain(|(sample, _)| *sample < start || *sample >= end);
3466    for (sample, _) in &mut audio.markers {
3467        if *sample >= end {
3468            *sample = sample.saturating_sub(deleted_frames);
3469        }
3470    }
3471}
3472
3473fn restore_document(audio: &mut AudioDocument, snapshot: DocumentSnapshot) {
3474    audio.samples = snapshot.samples;
3475    audio.edits = snapshot.edits;
3476    audio.edit_actions = snapshot.edit_actions;
3477    audio.markers = snapshot.markers;
3478}
3479
3480#[cfg(test)]
3481fn apply_edit_to_samples(
3482    samples: &mut [f32],
3483    channels: usize,
3484    region: AudioRegion,
3485    operation: EditOperation,
3486) {
3487    let channels = channels.max(1);
3488    let frames = samples.len() / channels;
3489    let start = region.offset.min(frames);
3490    let end = start.saturating_add(region.length).min(frames);
3491    if start >= end {
3492        return;
3493    }
3494
3495    match operation {
3496        EditOperation::FadeIn => {
3497            let fade_len = end - start;
3498            for frame in start..end {
3499                let envelope = (frame - start) as f32 / fade_len as f32;
3500                for channel in 0..channels {
3501                    let index = frame * channels + channel;
3502                    samples[index] *= envelope;
3503                }
3504            }
3505        }
3506        EditOperation::FadeOut => {
3507            let fade_len = end - start;
3508            for frame in start..end {
3509                let envelope = (end - 1 - frame) as f32 / fade_len as f32;
3510                for channel in 0..channels {
3511                    let index = frame * channels + channel;
3512                    samples[index] *= envelope;
3513                }
3514            }
3515        }
3516        EditOperation::IncreaseVolume => {
3517            let gain = 10.0f32.powf(1.0 / 20.0);
3518            for frame in start..end {
3519                for channel in 0..channels {
3520                    let index = frame * channels + channel;
3521                    samples[index] = (samples[index] * gain).clamp(-1.0, 1.0);
3522                }
3523            }
3524        }
3525        EditOperation::DecreaseVolume => {
3526            let gain = 10.0f32.powf(-1.0 / 20.0);
3527            for frame in start..end {
3528                for channel in 0..channels {
3529                    let index = frame * channels + channel;
3530                    samples[index] = (samples[index] * gain).clamp(-1.0, 1.0);
3531                }
3532            }
3533        }
3534    }
3535}
3536
3537fn default_fade_samples(frames: usize) -> usize {
3538    (frames / 20).clamp(240, 48_000).min(frames / 2)
3539}
3540
3541fn reverse_samples(samples: &[f32], channels: usize) -> Vec<f32> {
3542    let channels = channels.max(1);
3543    let mut output = Vec::with_capacity(samples.len());
3544    for frame in samples.chunks_exact(channels).rev() {
3545        output.extend_from_slice(frame);
3546    }
3547    output
3548}
3549
3550fn render_preview_samples(
3551    samples: &[f32],
3552    channels: usize,
3553    actions: &[AudioEditAction],
3554) -> Vec<f32> {
3555    apply_audio_edit_actions(samples, channels, actions)
3556}
3557
3558pub fn apply_audio_edit_actions(
3559    samples: &[f32],
3560    channels: usize,
3561    actions: &[AudioEditAction],
3562) -> Vec<f32> {
3563    let channels = channels.max(1);
3564    let mut output = samples.to_vec();
3565    for action in actions {
3566        apply_audio_edit_action_to_samples(&mut output, channels, *action);
3567    }
3568    output
3569}
3570
3571pub fn apply_audio_edit_action_to_samples(
3572    samples: &mut Vec<f32>,
3573    channels: usize,
3574    action: AudioEditAction,
3575) {
3576    let channels = channels.max(1);
3577    match action {
3578        AudioEditAction::Reverse => {
3579            *samples = reverse_samples(samples, channels);
3580        }
3581        AudioEditAction::Delete {
3582            start_sample,
3583            length_samples,
3584        } => {
3585            let frames = samples.len() / channels;
3586            let start = start_sample.min(frames);
3587            let end = start.saturating_add(length_samples).min(frames);
3588            if start < end {
3589                samples.drain(start * channels..end * channels);
3590            }
3591        }
3592        AudioEditAction::ReplaceWithSilence {
3593            start_sample,
3594            length_samples,
3595        } => {
3596            apply_region(
3597                samples,
3598                channels,
3599                start_sample,
3600                length_samples,
3601                |_frame, sample| {
3602                    *sample = 0.0;
3603                },
3604            );
3605        }
3606        AudioEditAction::FadeIn {
3607            start_sample,
3608            length_samples,
3609        } => {
3610            apply_region(
3611                samples,
3612                channels,
3613                start_sample,
3614                length_samples,
3615                |pos, sample| {
3616                    let envelope = pos as f32 / length_samples.max(1) as f32;
3617                    *sample *= envelope;
3618                },
3619            );
3620        }
3621        AudioEditAction::FadeOut {
3622            start_sample,
3623            length_samples,
3624        } => {
3625            apply_region(
3626                samples,
3627                channels,
3628                start_sample,
3629                length_samples,
3630                |pos, sample| {
3631                    let envelope = (length_samples.saturating_sub(pos + 1) as f32
3632                        / length_samples.max(1) as f32)
3633                        .clamp(0.0, 1.0);
3634                    *sample *= envelope;
3635                },
3636            );
3637        }
3638        AudioEditAction::GainDb {
3639            start_sample,
3640            length_samples,
3641            delta_db,
3642        } => {
3643            let gain = 10.0f32.powf(delta_db / 20.0);
3644            apply_region(
3645                samples,
3646                channels,
3647                start_sample,
3648                length_samples,
3649                |_pos, sample| {
3650                    *sample = (*sample * gain).clamp(-1.0, 1.0);
3651                },
3652            );
3653        }
3654    }
3655}
3656
3657fn apply_region(
3658    samples: &mut [f32],
3659    channels: usize,
3660    start_sample: usize,
3661    length_samples: usize,
3662    mut f: impl FnMut(usize, &mut f32),
3663) {
3664    let channels = channels.max(1);
3665    let frames = samples.len() / channels;
3666    let start = start_sample.min(frames);
3667    let end = start.saturating_add(length_samples).min(frames);
3668    if start >= end {
3669        return;
3670    }
3671    for frame in start..end {
3672        let pos = frame - start;
3673        for channel in 0..channels {
3674            f(pos, &mut samples[frame * channels + channel]);
3675        }
3676    }
3677}
3678
3679fn peak(samples: &[f32]) -> f32 {
3680    samples
3681        .iter()
3682        .fold(0.0f32, |peak, sample| peak.max(sample.abs()))
3683}
3684
3685fn reset_after_close(app: &mut EditApp) {
3686    #[cfg(feature = "standalone")]
3687    let engine_playback = if app.standalone_ready {
3688        app.engine_playback.take()
3689    } else {
3690        None
3691    };
3692
3693    *app = EditApp {
3694        status: String::from("Open an audio file to view its waveform."),
3695        standalone_ready: app.standalone_ready,
3696        setup: app.setup.clone(),
3697        #[cfg(feature = "standalone")]
3698        engine_playback,
3699        ..EditApp::default()
3700    };
3701}
3702
3703#[cfg(feature = "standalone")]
3704fn stop_engine_playback(app: &EditApp) -> Task<Message> {
3705    if let Some(playback) = app.engine_playback.as_ref() {
3706        let client = playback.client.clone();
3707        Task::perform(
3708            async move { send_engine(&client, EngineAction::Stop).await },
3709            Message::StandalonePlaybackStopped,
3710        )
3711    } else {
3712        Task::none()
3713    }
3714}
3715
3716#[cfg(not(feature = "standalone"))]
3717fn stop_engine_playback(_app: &EditApp) -> Task<Message> {
3718    Task::none()
3719}
3720
3721#[cfg(feature = "standalone")]
3722fn play_standalone(app: &mut EditApp) -> Task<Message> {
3723    if app.busy || app.preparing_playback {
3724        app.status = String::from("Preparing audio for playback.");
3725        return Task::none();
3726    }
3727    if app.playing {
3728        return Task::none();
3729    }
3730    let Some(audio) = app.audio.as_ref() else {
3731        app.status = String::from("No audio file is open.");
3732        return Task::none();
3733    };
3734    let Some(playback) = app.engine_playback.as_ref() else {
3735        if !app.standalone_ready {
3736            app.playing = true;
3737            app.status = String::from("Playing preview.");
3738            if app.playhead_samples >= audio.frames() {
3739                app.playhead_samples = 0;
3740            }
3741            return Task::none();
3742        }
3743        app.status = String::from("Open audio hardware before playback.");
3744        return Task::none();
3745    };
3746    app.playing = true;
3747    app.status = String::from("Playing.");
3748    if app.playhead_samples >= audio.frames() {
3749        app.playhead_samples = 0;
3750    }
3751    let start = app.playhead_samples;
3752    let client = playback.client.clone();
3753    Task::perform(
3754        async move { start_engine_playback(client, start).await },
3755        Message::StandalonePlaybackStarted,
3756    )
3757}
3758
3759#[cfg(not(feature = "standalone"))]
3760fn play_standalone(app: &mut EditApp) -> Task<Message> {
3761    if app.busy || app.preparing_playback {
3762        app.status = String::from("Preparing audio for playback.");
3763        return Task::none();
3764    }
3765    if app.playing {
3766        return Task::none();
3767    }
3768    let Some(audio) = app.audio.as_ref() else {
3769        app.status = String::from("No audio file is open.");
3770        return Task::none();
3771    };
3772    app.playing = true;
3773    app.status = String::from("Playing preview.");
3774    if app.playhead_samples >= audio.frames() {
3775        app.playhead_samples = 0;
3776    }
3777    Task::none()
3778}
3779
3780fn refresh_standalone_playhead(app: &mut EditApp) -> bool {
3781    if !app.playing {
3782        return false;
3783    }
3784    let Some(audio) = app.audio.as_ref() else {
3785        app.playing = false;
3786        return false;
3787    };
3788    let step = (audio.sample_rate / 25).max(1) as usize;
3789    app.playhead_samples = app.playhead_samples.saturating_add(step);
3790    if app.playhead_samples >= audio.frames() {
3791        app.playhead_samples = audio.frames();
3792        return true;
3793    }
3794    false
3795}
3796
3797fn playhead_ratio(app: &EditApp) -> Option<f32> {
3798    let frames = app.audio.as_ref()?.frames().max(1);
3799    Some((app.playhead_samples as f32 / frames as f32).clamp(0.0, 1.0))
3800}
3801
3802fn selection_ratio(app: &EditApp) -> Option<(f32, f32)> {
3803    let frames = app.audio.as_ref()?.frames().max(1) as f32;
3804    let (start, end) = app.selection_samples?;
3805    Some((start as f32 / frames, end as f32 / frames))
3806}
3807
3808fn sample_at_ratio(app: &EditApp, ratio: f32) -> Option<usize> {
3809    let frames = app.audio.as_ref()?.frames();
3810    Some(((ratio.clamp(0.0, 1.0) * frames as f32).round() as usize).min(frames))
3811}
3812
3813fn nearest_marker_sample(audio: &AudioDocument, ratio: f32) -> Option<usize> {
3814    if audio.markers.is_empty() {
3815        return None;
3816    }
3817    let frames = audio.frames().max(1);
3818    let target = (ratio.clamp(0.0, 1.0) * frames as f32).round() as usize;
3819    audio
3820        .markers
3821        .iter()
3822        .min_by_key(|(sample, _)| sample.abs_diff(target))
3823        .map(|(sample, _)| *sample)
3824}
3825
3826fn selection_duration_seconds(app: &EditApp) -> f32 {
3827    let Some(audio) = app.audio.as_ref() else {
3828        return 0.0;
3829    };
3830    let Some((start, end)) = app.selection_samples else {
3831        return 0.0;
3832    };
3833    end.saturating_sub(start) as f32 / audio.sample_rate.max(1) as f32
3834}
3835
3836fn vu_meter(app: &EditApp) -> Element<'_, Message> {
3837    let levels = vu_levels_db(app);
3838    meters::meters(levels.len(), &levels, 0.0)
3839}
3840
3841pub fn vu_levels_db(app: &EditApp) -> Vec<f32> {
3842    let Some(audio) = app.audio.as_ref() else {
3843        return vec![-90.0, -90.0];
3844    };
3845    let channels = audio.channels.max(1);
3846    let frames = audio.frames();
3847    let start = app.playhead_samples.min(frames);
3848    let end = start.saturating_add(2048).min(frames);
3849    let channel_count = channels.min(2);
3850    let mut levels = vec![0.0f32; channel_count.max(1)];
3851    if start >= end {
3852        return levels;
3853    }
3854    for frame in start..end {
3855        for (channel, level) in levels.iter_mut().enumerate().take(channel_count) {
3856            let sample = audio.preview_samples[frame * channels + channel].abs();
3857            *level = (*level).max(sample);
3858        }
3859    }
3860    levels
3861        .into_iter()
3862        .map(|level| {
3863            if level <= 1.0e-9 {
3864                -90.0
3865            } else {
3866                (20.0 * level.log10()).clamp(-90.0, 20.0)
3867            }
3868        })
3869        .collect()
3870}
3871
3872#[cfg(feature = "standalone")]
3873fn prepare_document_track(app: &mut EditApp) -> Task<Message> {
3874    if !app.standalone_ready {
3875        return Task::none();
3876    }
3877    let (Some(audio), Some(playback)) = (app.audio.as_ref(), app.engine_playback.as_ref()) else {
3878        return Task::none();
3879    };
3880    let client = playback.client.clone();
3881    let path = audio.source_path.clone();
3882    let samples = apply_audio_edit_actions(&audio.samples, audio.channels, &audio.edit_actions);
3883    let channels = audio.channels;
3884    let sample_rate = audio.sample_rate;
3885    let clip_len = audio.frames();
3886    let render_preview = audio.edits.needs_rendered_preview_file();
3887    let clip_offset = if render_preview {
3888        0
3889    } else {
3890        audio.clip_region.map(|region| region.offset).unwrap_or(0)
3891    };
3892    app.engine_clip_path = Some(if render_preview {
3893        preview_path(&path)
3894    } else {
3895        path.clone()
3896    });
3897    app.preparing_playback = true;
3898    app.status = String::from("Preparing audio for playback...");
3899    let request = EngineDocumentRequest {
3900        path,
3901        samples,
3902        channels,
3903        sample_rate,
3904        clip_len,
3905        clip_offset,
3906        render_preview,
3907        reversed: audio.edits.reversed,
3908    };
3909    Task::perform(
3910        async move { prepare_engine_document(client, request).await },
3911        Message::EngineDocumentPrepared,
3912    )
3913}
3914
3915#[cfg(not(feature = "standalone"))]
3916fn prepare_document_track(_app: &mut EditApp) -> Task<Message> {
3917    Task::none()
3918}
3919
3920#[cfg(feature = "standalone")]
3921async fn open_standalone_engine(setup: StartupSetup) -> Result<EngineClient, String> {
3922    let client = EngineClient::default();
3923    let mut rx = client.subscribe().await;
3924    send_engine(&client, EngineAction::Stop).await?;
3925    scan_plugins(&client, &mut rx).await?;
3926    send_engine(
3927        &client,
3928        EngineAction::OpenAudioDevice {
3929            device: selected_output_device(&setup),
3930            input_device: selected_input_device(&setup),
3931            sample_rate_hz: setup.sample_rate_hz,
3932            bits: selected_bits(&setup),
3933            exclusive: setup.exclusive,
3934            period_frames: selected_period_frames(&setup),
3935            nperiods: setup.nperiods,
3936            sync_mode: setup.sync_mode,
3937            actual_period_frames: 0,
3938            input_channels: 0,
3939            output_channels: 0,
3940            bytes_per_frame: 0,
3941        },
3942    )
3943    .await?;
3944    wait_for_engine_response(&mut rx, |action| {
3945        matches!(action, EngineAction::OpenAudioDevice { .. })
3946    })
3947    .await?;
3948    Ok(client)
3949}
3950
3951#[cfg(feature = "standalone")]
3952async fn scan_plugins(
3953    client: &EngineClient,
3954    rx: &mut tokio::sync::mpsc::Receiver<EngineMessage>,
3955) -> Result<(), String> {
3956    #[cfg(unix)]
3957    {
3958        send_engine(client, EngineAction::ListLv2Plugins).await?;
3959    }
3960    send_engine(client, EngineAction::ListVst3Plugins).await?;
3961    send_engine(client, EngineAction::ListClapPlugins).await?;
3962
3963    #[cfg(unix)]
3964    wait_for_engine_response(rx, |action| {
3965        matches!(
3966            action,
3967            EngineAction::Lv2Plugins(_) | EngineAction::Lv2PluginsUnavailable { .. }
3968        )
3969    })
3970    .await?;
3971    wait_for_engine_response(rx, |action| {
3972        matches!(
3973            action,
3974            EngineAction::Vst3Plugins(_) | EngineAction::Vst3PluginsUnavailable { .. }
3975        )
3976    })
3977    .await?;
3978    wait_for_engine_response(rx, |action| {
3979        matches!(
3980            action,
3981            EngineAction::ClapPlugins(_) | EngineAction::ClapPluginsUnavailable { .. }
3982        )
3983    })
3984    .await?;
3985    Ok(())
3986}
3987
3988#[cfg(feature = "standalone")]
3989async fn prepare_engine_document(
3990    client: EngineClient,
3991    request: EngineDocumentRequest,
3992) -> Result<(), String> {
3993    let clip_path = if request.render_preview {
3994        let temp_path = preview_path(&request.path);
3995        save_document(
3996            temp_path.clone(),
3997            request.samples,
3998            request.channels,
3999            request.sample_rate,
4000        )
4001        .await?;
4002        temp_path
4003    } else {
4004        request.path
4005    };
4006    let track = "editor-preview".to_string();
4007    send_engine(&client, EngineAction::Stop).await?;
4008    let _ = send_engine(&client, EngineAction::RemoveTrack(track.clone())).await;
4009    let mut rx = client.subscribe().await;
4010    send_engine(
4011        &client,
4012        EngineAction::AddTrack {
4013            name: track.clone(),
4014            audio_ins: request.channels,
4015            midi_ins: 0,
4016            audio_outs: request.channels,
4017            midi_outs: 0,
4018            folder: false,
4019            mixosc_addr: None,
4020        },
4021    )
4022    .await?;
4023    wait_for_engine_response(
4024        &mut rx,
4025        |action| matches!(action, EngineAction::AddTrack { name, .. } if name == "editor-preview"),
4026    )
4027    .await?;
4028    send_engine(
4029        &client,
4030        EngineAction::AddClip {
4031            clip_id: generate_clip_id(),
4032            name: clip_path.to_string_lossy().to_string(),
4033            track_name: track.clone(),
4034            start: 0,
4035            length: request.clip_len,
4036            offset: request.clip_offset,
4037            input_channel: 0,
4038            muted: false,
4039            reversed: request.reversed,
4040            gain_db: 0.0,
4041            peaks_file: None,
4042            kind: Kind::Audio,
4043            fade_enabled: true,
4044            fade_in_samples: 240,
4045            fade_out_samples: 240,
4046            source_name: None,
4047            source_offset: None,
4048            source_length: None,
4049            preview_name: None,
4050            pitch_correction_points: Vec::new(),
4051            pitch_correction_frame_likeness: None,
4052            pitch_correction_inertia_ms: None,
4053            pitch_correction_formant_compensation: None,
4054            plugin_graph_json: None,
4055        },
4056    )
4057    .await?;
4058    wait_for_engine_response(
4059        &mut rx,
4060        |action| matches!(action, EngineAction::AddClip { track_name, .. } if track_name == "editor-preview"),
4061    )
4062    .await?;
4063    for channel in 0..request.channels.clamp(1, 2) {
4064        send_engine(
4065            &client,
4066            EngineAction::Connect {
4067                from_track: track.clone(),
4068                from_port: channel,
4069                to_track: "hw:out".to_string(),
4070                to_port: channel,
4071                kind: Kind::Audio,
4072            },
4073        )
4074        .await?;
4075        wait_for_engine_response(&mut rx, |action| {
4076            matches!(action, EngineAction::Connect {
4077                from_track,
4078                from_port,
4079                to_track,
4080                to_port,
4081                kind,
4082            } if from_track == "editor-preview"
4083                && *from_port == channel
4084                && to_track == "hw:out"
4085                && *to_port == channel
4086                && *kind == Kind::Audio)
4087        })
4088        .await?;
4089    }
4090    send_engine(&client, EngineAction::SetClipPlaybackEnabled(true)).await?;
4091    wait_for_engine_response(&mut rx, |action| {
4092        matches!(action, EngineAction::SetClipPlaybackEnabled(true))
4093    })
4094    .await?;
4095    Ok(())
4096}
4097
4098#[cfg(feature = "standalone")]
4099async fn start_engine_playback(client: EngineClient, start: usize) -> Result<(), String> {
4100    let mut rx = client.subscribe().await;
4101    send_engine(&client, EngineAction::SetClipPlaybackEnabled(true)).await?;
4102    wait_for_engine_response(&mut rx, |action| {
4103        matches!(action, EngineAction::SetClipPlaybackEnabled(true))
4104    })
4105    .await?;
4106    send_engine(&client, EngineAction::TransportPosition(start)).await?;
4107    send_engine(&client, EngineAction::Play).await?;
4108    wait_for_engine_response(&mut rx, |action| matches!(action, EngineAction::Play)).await?;
4109    Ok(())
4110}
4111
4112#[cfg(feature = "standalone")]
4113fn selected_output_device(setup: &StartupSetup) -> String {
4114    if setup.audio_engine.is_jack() {
4115        String::from("jack")
4116    } else {
4117        setup
4118            .output_device
4119            .as_ref()
4120            .map(|device| device.id.clone())
4121            .unwrap_or_else(|| default_audio_device(setup.audio_engine).to_string())
4122    }
4123}
4124
4125#[cfg(feature = "standalone")]
4126fn selected_input_device(setup: &StartupSetup) -> Option<String> {
4127    if setup.audio_engine.is_jack() {
4128        None
4129    } else {
4130        setup.input_device.as_ref().map(|device| device.id.clone())
4131    }
4132}
4133
4134#[cfg(feature = "standalone")]
4135fn selected_bits(setup: &StartupSetup) -> i32 {
4136    if setup.audio_engine.is_jack() {
4137        32
4138    } else {
4139        setup.bits as i32
4140    }
4141}
4142
4143#[cfg(feature = "standalone")]
4144fn selected_period_frames(setup: &StartupSetup) -> usize {
4145    let options = period_frame_options(setup);
4146    if options.contains(&setup.period_frames) {
4147        setup.period_frames
4148    } else {
4149        options
4150            .iter()
4151            .copied()
4152            .find(|value| *value >= setup.period_frames)
4153            .or_else(|| options.last().copied())
4154            .unwrap_or(setup.period_frames)
4155    }
4156}
4157
4158#[cfg(all(feature = "standalone", target_os = "freebsd"))]
4159fn period_frame_options(setup: &StartupSetup) -> Vec<usize> {
4160    if !setup.audio_engine.is_jack()
4161        && let Some(device) = setup.output_device.as_ref()
4162        && let Some(options) = oss_period_frame_options(device, selected_bits(setup) as usize)
4163    {
4164        return options;
4165    }
4166    default_period_frame_options()
4167}
4168
4169#[cfg(any(not(feature = "standalone"), not(target_os = "freebsd")))]
4170fn period_frame_options(_setup: &StartupSetup) -> Vec<usize> {
4171    default_period_frame_options()
4172}
4173
4174fn default_period_frame_options() -> Vec<usize> {
4175    vec![
4176        16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536,
4177    ]
4178}
4179
4180#[cfg(all(feature = "standalone", target_os = "freebsd"))]
4181fn oss_period_frame_options(device: &AudioDeviceOption, bits: usize) -> Option<Vec<usize>> {
4182    if device.max_channels == 0 || device.max_buffer_bytes == 0 {
4183        return None;
4184    }
4185    let channels = device.max_channels.max(1);
4186    let bytes_per_sample = match bits {
4187        8 => 1,
4188        16 => 2,
4189        24 => 3,
4190        32 => 4,
4191        _ => return None,
4192    };
4193    let frame_bytes = channels.checked_mul(bytes_per_sample)?.max(1);
4194    let min_bytes = frame_bytes.next_power_of_two();
4195    let max_fragment_bytes = 1_usize << 16;
4196    let max_bytes = device
4197        .max_buffer_bytes
4198        .min(max_fragment_bytes)
4199        .max(min_bytes);
4200    if min_bytes > max_bytes {
4201        return None;
4202    }
4203    let mut out = Vec::new();
4204    let mut bytes = min_bytes;
4205    while bytes <= max_bytes {
4206        out.push(bytes.div_ceil(frame_bytes).max(1));
4207        match bytes.checked_mul(2) {
4208            Some(next) => bytes = next,
4209            None => break,
4210        }
4211    }
4212    out.sort_unstable();
4213    out.dedup();
4214    (!out.is_empty()).then_some(out)
4215}
4216
4217#[cfg(feature = "standalone")]
4218async fn send_engine(client: &EngineClient, action: EngineAction) -> Result<(), String> {
4219    client.send(EngineMessage::Request(action)).await
4220}
4221
4222#[cfg(feature = "standalone")]
4223async fn wait_for_engine_response(
4224    rx: &mut tokio::sync::mpsc::Receiver<EngineMessage>,
4225    mut accepts: impl FnMut(&EngineAction) -> bool,
4226) -> Result<(), String> {
4227    let deadline = Instant::now() + Duration::from_secs(5);
4228    loop {
4229        let remaining = deadline.saturating_duration_since(Instant::now());
4230        if remaining.is_zero() {
4231            return Err(String::from("Timed out waiting for audio engine."));
4232        }
4233        let Some(message) = tokio::time::timeout(remaining, rx.recv())
4234            .await
4235            .map_err(|_| String::from("Timed out waiting for audio engine."))?
4236        else {
4237            return Err(String::from("Audio engine response channel closed."));
4238        };
4239        if let EngineMessage::Response(result) = message {
4240            match result {
4241                Ok(action) if accepts(&action) => return Ok(()),
4242                Ok(_) => {}
4243                Err(err) => return Err(err),
4244            }
4245        }
4246    }
4247}
4248
4249#[cfg(feature = "standalone")]
4250fn preview_path(source: &Path) -> PathBuf {
4251    let mut path = std::env::temp_dir();
4252    let stem = source
4253        .file_stem()
4254        .and_then(|stem| stem.to_str())
4255        .unwrap_or("maolan-editor-preview");
4256    path.push(format!("maolan-editor-preview-{stem}.wav"));
4257    path
4258}
4259
4260fn playhead_label(app: &EditApp) -> String {
4261    let sample_rate = app
4262        .audio
4263        .as_ref()
4264        .map(|audio| audio.sample_rate)
4265        .unwrap_or(48_000)
4266        .max(1);
4267    let seconds = app.playhead_samples as f64 / sample_rate as f64;
4268    let minutes = (seconds / 60.0).floor() as u64;
4269    let secs = (seconds % 60.0).floor() as u64;
4270    let millis = ((seconds.fract()) * 1000.0).floor() as u64;
4271    format!("{minutes:02}:{secs:02}.{millis:03}")
4272}
4273
4274fn discover_output_audio_devices(engine: AudioEngineOption) -> Vec<AudioDeviceOption> {
4275    if engine.is_jack() {
4276        return vec![simple_audio_device("jack")];
4277    }
4278    let mut devices = platform_audio_devices()
4279        .into_iter()
4280        .filter(|device| device.supports_output)
4281        .collect::<Vec<_>>();
4282    if devices.is_empty() {
4283        devices.push(simple_audio_device(default_audio_device(engine)));
4284    }
4285    devices.sort_by_key(|device| device.label.to_lowercase());
4286    devices.dedup_by(|a, b| a.id == b.id);
4287    devices
4288}
4289
4290fn discover_input_audio_devices(engine: AudioEngineOption) -> Vec<AudioDeviceOption> {
4291    if engine.is_jack() {
4292        return Vec::new();
4293    }
4294    let mut devices = platform_audio_devices()
4295        .into_iter()
4296        .filter(|device| device.supports_input)
4297        .collect::<Vec<_>>();
4298    devices.sort_by_key(|device| device.label.to_lowercase());
4299    devices.dedup_by(|a, b| a.id == b.id);
4300    devices
4301}
4302
4303#[cfg(feature = "standalone")]
4304fn platform_audio_devices() -> Vec<AudioDeviceOption> {
4305    #[cfg(target_os = "freebsd")]
4306    {
4307        maolan_engine::audio_devices::discover_freebsd_audio_devices()
4308            .into_iter()
4309            .map(AudioDeviceOption::from)
4310            .collect()
4311    }
4312    #[cfg(target_os = "linux")]
4313    {
4314        let mut output_devices = platform_linux::discover_alsa_output_devices();
4315        let mut input_devices = platform_linux::discover_alsa_input_devices();
4316        output_devices.append(&mut input_devices);
4317        output_devices.sort_by_key(|device| device.label.to_lowercase());
4318        output_devices.dedup_by(|a, b| a.id == b.id);
4319        output_devices
4320    }
4321    #[cfg(target_os = "openbsd")]
4322    {
4323        vec![simple_audio_device("default")]
4324    }
4325    #[cfg(target_os = "windows")]
4326    {
4327        vec![simple_audio_device("default")]
4328    }
4329    #[cfg(not(any(
4330        target_os = "linux",
4331        target_os = "freebsd",
4332        target_os = "openbsd",
4333        target_os = "windows"
4334    )))]
4335    {
4336        vec![simple_audio_device("default")]
4337    }
4338}
4339
4340#[cfg(not(feature = "standalone"))]
4341fn platform_audio_devices() -> Vec<AudioDeviceOption> {
4342    vec![simple_audio_device("default")]
4343}
4344
4345#[cfg(all(feature = "standalone", target_os = "linux"))]
4346mod platform_linux {
4347    use alsa::{
4348        Direction,
4349        pcm::{Access, Format, HwParams, PCM},
4350    };
4351
4352    const SAMPLE_RATE_CANDIDATES: [u32; 12] = [
4353        8_000, 11_025, 16_000, 22_050, 32_000, 44_100, 48_000, 88_200, 96_000, 176_400, 192_000,
4354        384_000,
4355    ];
4356
4357    fn read_alsa_card_labels() -> std::collections::HashMap<u32, String> {
4358        let mut labels = std::collections::HashMap::new();
4359        let Ok(contents) = std::fs::read_to_string("/proc/asound/cards") else {
4360            return labels;
4361        };
4362        for line in contents.lines() {
4363            let line = line.trim_start();
4364            let Some((num_str, rest)) = line.split_once(' ') else {
4365                continue;
4366            };
4367            let Ok(card) = num_str.parse::<u32>() else {
4368                continue;
4369            };
4370            let Some((_, desc)) = rest.split_once("]:") else {
4371                continue;
4372            };
4373            let desc = desc.trim();
4374            if !desc.is_empty() {
4375                labels.insert(card, desc.to_string());
4376            }
4377        }
4378        labels
4379    }
4380
4381    fn probe_alsa_supported_bits(device: &str, direction: Direction) -> Vec<usize> {
4382        let Ok(pcm) = PCM::new(device, direction, false) else {
4383            return Vec::new();
4384        };
4385        let Ok(hwp) = HwParams::any(&pcm) else {
4386            return Vec::new();
4387        };
4388        if hwp.set_access(Access::RWInterleaved).is_err() {
4389            return Vec::new();
4390        }
4391
4392        fn supports(hwp: &HwParams<'_>, fmt: Format) -> bool {
4393            hwp.test_format(fmt).is_ok()
4394        }
4395
4396        let candidates: Vec<(usize, Vec<Format>)> = vec![
4397            (32, vec![native_s32(), foreign_s32()]),
4398            (24, vec![native_s24(), foreign_s24()]),
4399            (16, vec![native_s16(), foreign_s16()]),
4400            (8, vec![Format::S8]),
4401        ];
4402
4403        let mut supported = Vec::new();
4404        for (bits, formats) in candidates {
4405            if formats.iter().any(|f| supports(&hwp, *f)) {
4406                supported.push(bits);
4407            }
4408        }
4409        supported
4410    }
4411
4412    fn probe_alsa_supported_sample_rates(device: &str, direction: Direction) -> Vec<i32> {
4413        let Ok(pcm) = PCM::new(device, direction, false) else {
4414            return Vec::new();
4415        };
4416        let Ok(hwp) = HwParams::any(&pcm) else {
4417            return Vec::new();
4418        };
4419        if hwp.set_access(Access::RWInterleaved).is_err() {
4420            return Vec::new();
4421        }
4422
4423        let mut supported = Vec::new();
4424        for rate in SAMPLE_RATE_CANDIDATES {
4425            if hwp.test_rate(rate).is_ok() {
4426                supported.push(rate as i32);
4427            }
4428        }
4429        supported
4430    }
4431
4432    #[cfg(target_endian = "little")]
4433    fn native_s16() -> Format {
4434        Format::S16LE
4435    }
4436    #[cfg(target_endian = "big")]
4437    fn native_s16() -> Format {
4438        Format::S16BE
4439    }
4440    #[cfg(target_endian = "little")]
4441    fn foreign_s16() -> Format {
4442        Format::S16BE
4443    }
4444    #[cfg(target_endian = "big")]
4445    fn foreign_s16() -> Format {
4446        Format::S16LE
4447    }
4448
4449    #[cfg(target_endian = "little")]
4450    fn native_s24() -> Format {
4451        Format::S24LE
4452    }
4453    #[cfg(target_endian = "big")]
4454    fn native_s24() -> Format {
4455        Format::S24BE
4456    }
4457    #[cfg(target_endian = "little")]
4458    fn foreign_s24() -> Format {
4459        Format::S24BE
4460    }
4461    #[cfg(target_endian = "big")]
4462    fn foreign_s24() -> Format {
4463        Format::S24LE
4464    }
4465
4466    #[cfg(target_endian = "little")]
4467    fn native_s32() -> Format {
4468        Format::S32LE
4469    }
4470    #[cfg(target_endian = "big")]
4471    fn native_s32() -> Format {
4472        Format::S32BE
4473    }
4474    #[cfg(target_endian = "little")]
4475    fn foreign_s32() -> Format {
4476        Format::S32BE
4477    }
4478    #[cfg(target_endian = "big")]
4479    fn foreign_s32() -> Format {
4480        Format::S32LE
4481    }
4482
4483    fn discover_alsa_devices(
4484        direction_marker: &str,
4485        direction: Direction,
4486    ) -> Vec<super::AudioDeviceOption> {
4487        let mut devices = Vec::new();
4488        let card_labels = read_alsa_card_labels();
4489        if let Ok(contents) = std::fs::read_to_string("/proc/asound/pcm") {
4490            for line in contents.lines() {
4491                let Some((card_dev, rest)) = line.split_once(':') else {
4492                    continue;
4493                };
4494                if !rest.contains(direction_marker) {
4495                    continue;
4496                }
4497                let mut parts = card_dev.trim().split('-');
4498                let (Some(card), Some(dev)) = (parts.next(), parts.next()) else {
4499                    continue;
4500                };
4501                let Ok(card) = card.parse::<u32>() else {
4502                    continue;
4503                };
4504                let Ok(dev) = dev.parse::<u32>() else {
4505                    continue;
4506                };
4507                let device_name = rest.split(':').next().unwrap_or("").trim();
4508                let card_label = card_labels
4509                    .get(&card)
4510                    .cloned()
4511                    .unwrap_or_else(|| format!("Card {card}"));
4512                let base_label = if device_name.is_empty() {
4513                    card_label
4514                } else {
4515                    format!("{card_label} - {device_name}")
4516                };
4517                let id = format!("hw:{card},{dev}");
4518                let label = format!("{base_label} (hw:{card},{dev})");
4519                let supported_bits = probe_alsa_supported_bits(&id, direction);
4520                let supported_sample_rates = {
4521                    let rates = probe_alsa_supported_sample_rates(&id, direction);
4522                    if rates.is_empty() {
4523                        super::fallback_sample_rates()
4524                    } else {
4525                        rates
4526                    }
4527                };
4528                let (supports_input, supports_output) = match direction {
4529                    Direction::Playback => (false, true),
4530                    Direction::Capture => (true, false),
4531                };
4532                devices.push(super::AudioDeviceOption::with_supported_direction_caps(
4533                    id,
4534                    label,
4535                    supported_bits,
4536                    supported_sample_rates,
4537                    supports_input,
4538                    supports_output,
4539                ));
4540            }
4541        }
4542        devices.sort_by_key(|a| a.label.to_lowercase());
4543        devices.dedup_by(|a, b| a.id == b.id);
4544        devices
4545    }
4546
4547    pub(crate) fn discover_alsa_output_devices() -> Vec<super::AudioDeviceOption> {
4548        discover_alsa_devices("playback", Direction::Playback)
4549    }
4550
4551    pub(crate) fn discover_alsa_input_devices() -> Vec<super::AudioDeviceOption> {
4552        discover_alsa_devices("capture", Direction::Capture)
4553    }
4554}
4555
4556fn simple_audio_device(id: impl Into<String>) -> AudioDeviceOption {
4557    let id = id.into();
4558    AudioDeviceOption::with_supported_caps(
4559        id.clone(),
4560        id,
4561        vec![32, 24, 16, 8],
4562        fallback_sample_rates(),
4563    )
4564}
4565
4566fn fallback_sample_rates() -> Vec<i32> {
4567    vec![
4568        8_000, 11_025, 16_000, 22_050, 32_000, 44_100, 48_000, 88_200, 96_000, 176_400, 192_000,
4569        384_000,
4570    ]
4571}
4572
4573fn fallback_bits() -> Vec<usize> {
4574    vec![32, 24, 16, 8]
4575}
4576
4577fn sample_rate_options(setup: &StartupSetup) -> Vec<i32> {
4578    if setup.audio_engine.is_jack() {
4579        return fallback_sample_rates();
4580    }
4581    setup
4582        .output_device
4583        .as_ref()
4584        .map(|device| device.supported_sample_rates.clone())
4585        .filter(|rates| !rates.is_empty())
4586        .unwrap_or_else(fallback_sample_rates)
4587}
4588
4589fn bit_options(setup: &StartupSetup) -> Vec<usize> {
4590    if setup.audio_engine.is_jack() {
4591        return fallback_bits();
4592    }
4593    setup
4594        .output_device
4595        .as_ref()
4596        .map(|device| {
4597            if device.supported_bits.is_empty() {
4598                fallback_bits()
4599            } else {
4600                device.supported_bits.clone()
4601            }
4602        })
4603        .unwrap_or_else(fallback_bits)
4604}
4605
4606fn default_audio_device(engine: AudioEngineOption) -> &'static str {
4607    if engine.is_jack() {
4608        return "jack";
4609    }
4610    #[cfg(target_os = "linux")]
4611    {
4612        "default"
4613    }
4614    #[cfg(target_os = "freebsd")]
4615    {
4616        "/dev/dsp"
4617    }
4618    #[cfg(target_os = "openbsd")]
4619    {
4620        "default"
4621    }
4622    #[cfg(target_os = "windows")]
4623    {
4624        "default"
4625    }
4626    #[cfg(not(any(
4627        target_os = "linux",
4628        target_os = "freebsd",
4629        target_os = "openbsd",
4630        target_os = "windows"
4631    )))]
4632    {
4633        "default"
4634    }
4635}
4636
4637#[cfg(feature = "standalone")]
4638async fn open_audio_dialog() -> Option<PathBuf> {
4639    rfd::FileDialog::new()
4640        .add_filter(
4641            "Audio",
4642            &["wav", "flac", "mp3", "ogg", "vorbis", "m4a", "aac", "alac"],
4643        )
4644        .pick_file()
4645}
4646
4647#[cfg(feature = "standalone")]
4648async fn save_audio_dialog(current: Option<PathBuf>) -> Option<PathBuf> {
4649    let mut dialog =
4650        rfd::FileDialog::new().add_filter("Maolan audio export", &["wav", "flac", "mp3", "ogg"]);
4651    if let Some(path) = current.as_ref() {
4652        if let Some(parent) = path.parent() {
4653            dialog = dialog.set_directory(parent);
4654        }
4655        if let Some(name) = path.file_name() {
4656            dialog = dialog.set_file_name(name.to_string_lossy());
4657        }
4658    }
4659    dialog.save_file()
4660}
4661
4662async fn close_confirmation_dialog() -> rfd::MessageDialogResult {
4663    rfd::AsyncMessageDialog::new()
4664        .set_title("Unsaved Changes")
4665        .set_description("You have unsaved changes. Save before closing?")
4666        .set_buttons(rfd::MessageButtons::YesNoCancel)
4667        .show()
4668        .await
4669}
4670
4671fn clip_samples(samples: &[f32], channels: usize, region: Option<AudioRegion>) -> Vec<f32> {
4672    let channels = channels.max(1);
4673    let Some(region) = region else {
4674        return samples.to_vec();
4675    };
4676    let frames = samples.len() / channels;
4677    let start = region.offset.min(frames);
4678    let end = start.saturating_add(region.length).min(frames);
4679    samples[start * channels..end * channels].to_vec()
4680}
4681
4682fn deinterleave(samples: &[f32], channels: usize) -> Vec<Vec<f32>> {
4683    let channels = channels.max(1);
4684    let frames = samples.len() / channels;
4685    let mut output = vec![Vec::with_capacity(frames); channels];
4686    for frame in samples.chunks_exact(channels) {
4687        for (channel, sample) in frame.iter().copied().enumerate() {
4688            output[channel].push(sample);
4689        }
4690    }
4691    output
4692}
4693
4694#[cfg(feature = "standalone")]
4695fn encode_format_for_path(path: &Path) -> Result<AudioEncodeFormat, String> {
4696    let ext = path
4697        .extension()
4698        .and_then(|ext| ext.to_str())
4699        .map(str::to_ascii_lowercase)
4700        .ok_or_else(|| {
4701            String::from("Save path needs an audio extension: wav, flac, mp3, or ogg.")
4702        })?;
4703
4704    match ext.as_str() {
4705        "wav" => Ok(AudioEncodeFormat::Wav(WavBitDepth::Float32)),
4706        "flac" => Ok(AudioEncodeFormat::Flac(24)),
4707        "mp3" => Ok(AudioEncodeFormat::Mp3),
4708        "ogg" => Ok(AudioEncodeFormat::OggFlac(24)),
4709        _ => Err(format!(
4710            "Cannot save '{}': supported save formats are wav, flac, mp3, and ogg.",
4711            path.display()
4712        )),
4713    }
4714}
4715
4716fn app_style(_theme: &Theme) -> container::Style {
4717    container::Style {
4718        background: Some(Color::from_rgb(0.055, 0.06, 0.075).into()),
4719        text_color: Some(Color::from_rgb(0.88, 0.90, 0.94)),
4720        ..container::Style::default()
4721    }
4722}
4723
4724fn panel_style(_theme: &Theme) -> container::Style {
4725    container::Style {
4726        border: maolan_widgets::iced::Border {
4727            color: Color::from_rgb(0.18, 0.20, 0.24),
4728            width: 1.0,
4729            radius: 4.0.into(),
4730        },
4731        ..container::Style::default()
4732    }
4733}
4734
4735fn toolbar_style(_theme: &Theme) -> container::Style {
4736    container::Style {
4737        background: Some(Background::Color(Color::from_rgb(0.075, 0.08, 0.095))),
4738        border: Border {
4739            color: Color::from_rgb(0.16, 0.18, 0.22),
4740            width: 1.0,
4741            radius: 2.0.into(),
4742        },
4743        ..container::Style::default()
4744    }
4745}
4746
4747fn playhead_style(_theme: &Theme) -> container::Style {
4748    container::Style {
4749        text_color: Some(Color::from_rgb(0.92, 0.92, 0.92)),
4750        background: Some(Background::Color(Color::from_rgb(0.10, 0.10, 0.115))),
4751        border: Border {
4752            color: Color::from_rgb(0.22, 0.24, 0.28),
4753            width: 1.0,
4754            radius: 2.0.into(),
4755        },
4756        ..container::Style::default()
4757    }
4758}
4759
4760fn playhead_active_style(_theme: &Theme) -> container::Style {
4761    container::Style {
4762        text_color: Some(Color::from_rgb(0.92, 0.98, 0.92)),
4763        background: Some(Background::Color(Color::from_rgb(0.10, 0.16, 0.12))),
4764        border: Border {
4765            color: Color::from_rgb(0.22, 0.45, 0.26),
4766            width: 1.0,
4767            radius: 2.0.into(),
4768        },
4769        ..container::Style::default()
4770    }
4771}
4772
4773fn toolbar_button_style(theme: &Theme, status: button::Status) -> button::Style {
4774    let mut style = button::secondary(theme, status);
4775    style.border.radius = 3.0.into();
4776    style.border.width = 1.0;
4777    style.border.color = Color::from_rgb(0.18, 0.20, 0.24);
4778    style.text_color = Color::from_rgb(0.92, 0.92, 0.92);
4779    style.background = Some(Background::Color(Color::TRANSPARENT));
4780    style
4781}
4782
4783fn tooltip_style(_theme: &Theme) -> container::Style {
4784    container::Style {
4785        text_color: Some(Color::from_rgb(0.94, 0.94, 0.94)),
4786        background: Some(Background::Color(Color::from_rgba(0.08, 0.08, 0.08, 0.96))),
4787        border: Border {
4788            color: Color::from_rgba(0.32, 0.32, 0.32, 1.0),
4789            width: 1.0,
4790            radius: 3.0.into(),
4791        },
4792        ..container::Style::default()
4793    }
4794}
4795
4796#[cfg(test)]
4797mod tests {
4798    use super::*;
4799
4800    #[test]
4801    fn deinterleave_splits_channels() {
4802        assert_eq!(
4803            deinterleave(&[1.0, 2.0, 3.0, 4.0], 2),
4804            vec![vec![1.0, 3.0], vec![2.0, 4.0]]
4805        );
4806    }
4807
4808    #[test]
4809    fn clip_samples_extracts_frame_range() {
4810        let samples = [1.0, 10.0, 2.0, 20.0, 3.0, 30.0, 4.0, 40.0];
4811        assert_eq!(
4812            clip_samples(
4813                &samples,
4814                2,
4815                Some(AudioRegion {
4816                    offset: 1,
4817                    length: 2
4818                })
4819            ),
4820            vec![2.0, 20.0, 3.0, 30.0]
4821        );
4822    }
4823
4824    #[test]
4825    fn encode_format_matches_extensions() {
4826        assert!(matches!(
4827            encode_format_for_path(Path::new("x.wav")).unwrap(),
4828            AudioEncodeFormat::Wav(WavBitDepth::Float32)
4829        ));
4830        assert!(matches!(
4831            encode_format_for_path(Path::new("x.flac")).unwrap(),
4832            AudioEncodeFormat::Flac(24)
4833        ));
4834        assert!(matches!(
4835            encode_format_for_path(Path::new("x.mp3")).unwrap(),
4836            AudioEncodeFormat::Mp3
4837        ));
4838        assert!(matches!(
4839            encode_format_for_path(Path::new("x.ogg")).unwrap(),
4840            AudioEncodeFormat::OggFlac(24)
4841        ));
4842    }
4843
4844    #[test]
4845    fn apply_edit_to_samples_fades_in_region() {
4846        let mut samples = vec![1.0f32; 8];
4847        apply_edit_to_samples(
4848            &mut samples,
4849            1,
4850            AudioRegion {
4851                offset: 2,
4852                length: 4,
4853            },
4854            EditOperation::FadeIn,
4855        );
4856        assert_eq!(samples, vec![1.0, 1.0, 0.0, 0.25, 0.5, 0.75, 1.0, 1.0]);
4857    }
4858
4859    #[test]
4860    fn apply_edit_to_samples_fades_out_region() {
4861        let mut samples = vec![1.0f32; 8];
4862        apply_edit_to_samples(
4863            &mut samples,
4864            1,
4865            AudioRegion {
4866                offset: 2,
4867                length: 4,
4868            },
4869            EditOperation::FadeOut,
4870        );
4871        assert_eq!(samples, vec![1.0, 1.0, 0.75, 0.5, 0.25, 0.0, 1.0, 1.0]);
4872    }
4873
4874    #[test]
4875    fn apply_edit_to_samples_adjusts_volume_in_region() {
4876        let mut samples = vec![0.5f32; 8];
4877        apply_edit_to_samples(
4878            &mut samples,
4879            1,
4880            AudioRegion {
4881                offset: 2,
4882                length: 4,
4883            },
4884            EditOperation::IncreaseVolume,
4885        );
4886        let expected_gain = 10.0f32.powf(1.0 / 20.0);
4887        for (index, sample) in samples.iter().enumerate() {
4888            let expected = if (2..6).contains(&index) {
4889                0.5 * expected_gain
4890            } else {
4891                0.5
4892            };
4893            assert!((sample - expected).abs() < 1.0e-5, "index {index}");
4894        }
4895    }
4896
4897    #[test]
4898    fn apply_edit_to_samples_decreases_volume_in_region() {
4899        let mut samples = vec![1.0f32; 8];
4900        apply_edit_to_samples(
4901            &mut samples,
4902            1,
4903            AudioRegion {
4904                offset: 2,
4905                length: 4,
4906            },
4907            EditOperation::DecreaseVolume,
4908        );
4909        let expected_gain = 10.0f32.powf(-1.0 / 20.0);
4910        for (index, sample) in samples.iter().enumerate() {
4911            let expected = if (2..6).contains(&index) {
4912                expected_gain
4913            } else {
4914                1.0
4915            };
4916            assert!((sample - expected).abs() < 1.0e-5, "index {index}");
4917        }
4918    }
4919
4920    #[test]
4921    fn apply_edit_to_samples_clamps_region_to_bounds() {
4922        let mut samples = vec![1.0f32; 4];
4923        apply_edit_to_samples(
4924            &mut samples,
4925            1,
4926            AudioRegion {
4927                offset: 2,
4928                length: 100,
4929            },
4930            EditOperation::FadeIn,
4931        );
4932        assert_eq!(samples, vec![1.0, 1.0, 0.0, 0.5]);
4933    }
4934
4935    #[test]
4936    fn history_tracks_dirty_state_against_save_point() {
4937        let mut history = EditHistory::new(DocumentSnapshot {
4938            samples: vec![1.0f32],
4939            edits: AudioEdits::default(),
4940            edit_actions: Vec::new(),
4941            markers: Vec::new(),
4942        });
4943        assert!(!history.is_dirty());
4944
4945        history.record(
4946            DocumentSnapshot {
4947                samples: vec![1.0f32],
4948                edits: AudioEdits::default(),
4949                edit_actions: Vec::new(),
4950                markers: Vec::new(),
4951            },
4952            DocumentSnapshot {
4953                samples: vec![2.0f32],
4954                edits: AudioEdits::default(),
4955                edit_actions: Vec::new(),
4956                markers: Vec::new(),
4957            },
4958        );
4959        assert!(history.is_dirty());
4960
4961        history.mark_saved();
4962        assert!(!history.is_dirty());
4963    }
4964
4965    #[test]
4966    fn history_undo_redo_restores_states() {
4967        let mut history = EditHistory::new(DocumentSnapshot {
4968            samples: vec![1.0f32],
4969            edits: AudioEdits::default(),
4970            edit_actions: Vec::new(),
4971            markers: Vec::new(),
4972        });
4973        history.record(
4974            DocumentSnapshot {
4975                samples: vec![1.0f32],
4976                edits: AudioEdits::default(),
4977                edit_actions: Vec::new(),
4978                markers: Vec::new(),
4979            },
4980            DocumentSnapshot {
4981                samples: vec![2.0f32],
4982                edits: AudioEdits::default(),
4983                edit_actions: Vec::new(),
4984                markers: Vec::new(),
4985            },
4986        );
4987        history.record(
4988            DocumentSnapshot {
4989                samples: vec![2.0f32],
4990                edits: AudioEdits::default(),
4991                edit_actions: Vec::new(),
4992                markers: Vec::new(),
4993            },
4994            DocumentSnapshot {
4995                samples: vec![3.0f32],
4996                edits: AudioEdits::default(),
4997                edit_actions: Vec::new(),
4998                markers: Vec::new(),
4999            },
5000        );
5001
5002        let undone = history.undo().expect("can undo");
5003        assert_eq!(undone.samples, vec![2.0f32]);
5004
5005        let undone_again = history.undo().expect("can undo again");
5006        assert_eq!(undone_again.samples, vec![1.0f32]);
5007        assert!(history.undo().is_none());
5008
5009        let redone = history.redo().expect("can redo");
5010        assert_eq!(redone.samples, vec![2.0f32]);
5011    }
5012
5013    #[test]
5014    fn history_record_clears_redo_stack() {
5015        let mut history = EditHistory::new(DocumentSnapshot {
5016            samples: vec![1.0f32],
5017            edits: AudioEdits::default(),
5018            edit_actions: Vec::new(),
5019            markers: Vec::new(),
5020        });
5021        history.record(
5022            DocumentSnapshot {
5023                samples: vec![1.0f32],
5024                edits: AudioEdits::default(),
5025                edit_actions: Vec::new(),
5026                markers: Vec::new(),
5027            },
5028            DocumentSnapshot {
5029                samples: vec![2.0f32],
5030                edits: AudioEdits::default(),
5031                edit_actions: Vec::new(),
5032                markers: Vec::new(),
5033            },
5034        );
5035        history.undo();
5036        history.record(
5037            DocumentSnapshot {
5038                samples: vec![1.0f32],
5039                edits: AudioEdits::default(),
5040                edit_actions: Vec::new(),
5041                markers: Vec::new(),
5042            },
5043            DocumentSnapshot {
5044                samples: vec![3.0f32],
5045                edits: AudioEdits::default(),
5046                edit_actions: Vec::new(),
5047                markers: Vec::new(),
5048            },
5049        );
5050        assert!(history.redo().is_none());
5051    }
5052
5053    fn test_document(preview: Vec<f32>, channels: usize) -> AudioDocument {
5054        let channel_samples = deinterleave(&preview, channels);
5055        AudioDocument {
5056            source_path: PathBuf::new(),
5057            save_path: None,
5058            samples: preview.clone(),
5059            preview_samples: preview,
5060            channels,
5061            sample_rate: 48_000,
5062            channel_samples,
5063            peak: 1.0,
5064            clip_region: None,
5065            edits: AudioEdits::default(),
5066            edit_actions: Vec::new(),
5067            markers: Vec::new(),
5068        }
5069    }
5070
5071    #[test]
5072    fn next_zero_crossing_finds_positive_to_negative_crossing() {
5073        let audio = test_document(vec![1.0f32, -1.0], 1);
5074        assert_eq!(audio.next_zero_crossing_frame(0), Some(1));
5075    }
5076
5077    #[test]
5078    fn next_zero_crossing_finds_negative_to_positive_crossing() {
5079        let audio = test_document(vec![-1.0f32, -0.5, 0.5, 1.0], 1);
5080        assert_eq!(audio.next_zero_crossing_frame(0), Some(2));
5081    }
5082
5083    #[test]
5084    fn next_zero_crossing_detects_exact_zero_sample() {
5085        let audio = test_document(vec![1.0f32, 0.0, -1.0], 1);
5086        assert_eq!(audio.next_zero_crossing_frame(0), Some(1));
5087    }
5088
5089    #[test]
5090    fn next_zero_crossing_returns_none_when_no_crossing() {
5091        let audio = test_document(vec![0.1f32, 0.2, 0.3], 1);
5092        assert_eq!(audio.next_zero_crossing_frame(0), None);
5093    }
5094
5095    #[test]
5096    fn next_zero_crossing_respects_start_frame() {
5097        let audio = test_document(vec![1.0f32, -1.0, 1.0, -1.0], 1);
5098        assert_eq!(audio.next_zero_crossing_frame(1), Some(2));
5099    }
5100
5101    #[test]
5102    fn next_zero_crossing_returns_none_past_end() {
5103        let audio = test_document(vec![1.0f32, -1.0], 1);
5104        assert_eq!(audio.next_zero_crossing_frame(1), None);
5105    }
5106
5107    #[test]
5108    fn next_zero_crossing_averages_multiple_channels() {
5109        // Left: [1.0, 1.0], Right: [1.0, -1.0]; mixed: [1.0, 0.0]
5110        let audio = test_document(vec![1.0f32, 1.0, 1.0, -1.0], 2);
5111        assert_eq!(audio.next_zero_crossing_frame(0), Some(1));
5112    }
5113
5114    fn app_with_markers(markers: Vec<(usize, String)>, frames: usize) -> EditApp {
5115        let mut audio = test_document(vec![0.0f32; frames], 1);
5116        audio.markers = markers;
5117        EditApp {
5118            standalone_ready: true,
5119            audio: Some(audio),
5120            ..EditApp::default()
5121        }
5122    }
5123
5124    #[test]
5125    fn playhead_moved_sets_playhead_from_ratio() {
5126        let mut app = app_with_markers(Vec::new(), 100);
5127        let _ = update(&mut app, Message::PlayheadMoved(0.25));
5128        assert_eq!(app.playhead_samples, 25);
5129    }
5130
5131    #[test]
5132    fn embedded_play_without_engine_starts_preview_playback() {
5133        let mut app = EditApp {
5134            standalone_ready: false,
5135            audio: Some(test_document(vec![0.0f32; 100], 1)),
5136            ..EditApp::default()
5137        };
5138
5139        let _ = update(&mut app, Message::Play);
5140
5141        assert!(app.playing);
5142        assert_eq!(app.status, "Playing preview.");
5143    }
5144
5145    #[test]
5146    fn select_marker_region_selects_between_markers() {
5147        let mut app = app_with_markers(vec![(20, "A".to_string()), (60, "B".to_string())], 100);
5148        let _ = update(&mut app, Message::SelectMarkerRegion(0.4));
5149        assert_eq!(app.selection_samples, Some((20, 60)));
5150    }
5151
5152    #[test]
5153    fn select_marker_region_selects_start_to_first_marker() {
5154        let mut app = app_with_markers(vec![(50, "A".to_string())], 100);
5155        let _ = update(&mut app, Message::SelectMarkerRegion(0.25));
5156        assert_eq!(app.selection_samples, Some((0, 50)));
5157    }
5158
5159    #[test]
5160    fn select_marker_region_selects_last_marker_to_end() {
5161        let mut app = app_with_markers(vec![(50, "A".to_string())], 100);
5162        let _ = update(&mut app, Message::SelectMarkerRegion(0.75));
5163        assert_eq!(app.selection_samples, Some((50, 100)));
5164    }
5165
5166    #[test]
5167    fn detect_markers_places_boundaries_around_sound_regions() {
5168        // 0..10 silence, 10..20 sound, 20..30 silence, 30..40 sound, 40..50 silence.
5169        let mut samples = vec![0.0f32; 10];
5170        samples.extend(vec![0.8f32; 10]);
5171        samples.extend(vec![0.0f32; 10]);
5172        samples.extend(vec![0.8f32; 10]);
5173        samples.extend(vec![0.0f32; 10]);
5174        let markers = detect_markers(&samples, 1, -60.0, 5);
5175        assert_eq!(
5176            markers,
5177            vec![
5178                (10, "Region 1".to_string()),
5179                (20, "Region 2".to_string()),
5180                (30, "Region 3".to_string()),
5181                (40, "Region 4".to_string()),
5182            ]
5183        );
5184    }
5185
5186    #[test]
5187    fn detect_markers_ignores_short_silence() {
5188        // 0..10 silence, 10..15 sound, 15..17 short silence, 17..25 sound, 25..35 silence.
5189        let mut samples = vec![0.0f32; 10];
5190        samples.extend(vec![0.8f32; 5]);
5191        samples.extend(vec![0.0f32; 2]);
5192        samples.extend(vec![0.8f32; 8]);
5193        samples.extend(vec![0.0f32; 10]);
5194        let markers = detect_markers(&samples, 1, -60.0, 5);
5195        assert_eq!(
5196            markers,
5197            vec![(10, "Region 1".to_string()), (25, "Region 2".to_string()),]
5198        );
5199    }
5200
5201    #[test]
5202    fn detect_markers_all_silence_returns_empty() {
5203        let samples = vec![0.0f32; 100];
5204        let markers = detect_markers(&samples, 1, -60.0, 5);
5205        assert!(markers.is_empty());
5206    }
5207
5208    #[test]
5209    fn detect_markers_all_sound_returns_single_region() {
5210        let samples = vec![0.8f32; 100];
5211        let markers = detect_markers(&samples, 1, -60.0, 5);
5212        assert_eq!(
5213            markers,
5214            vec![(0, "Region 1".to_string()), (100, "Region 2".to_string()),]
5215        );
5216    }
5217
5218    #[test]
5219    fn detect_markers_confirm_adds_detected_markers() {
5220        let mut samples = vec![0.0f32; 10];
5221        samples.extend(vec![0.8f32; 10]);
5222        samples.extend(vec![0.0f32; 10]);
5223        let audio = test_document(samples, 1);
5224        let mut app = EditApp {
5225            standalone_ready: true,
5226            audio: Some(audio),
5227            detect_markers_dialog: Some(DetectMarkersDialog {
5228                threshold_db: String::from("-60.0"),
5229                silence_samples: String::from("5"),
5230            }),
5231            ..EditApp::default()
5232        };
5233        let _ = update(&mut app, Message::DetectMarkersConfirm);
5234        assert!(app.detect_markers_dialog.is_none());
5235        assert_eq!(
5236            app.audio.as_ref().unwrap().markers,
5237            vec![(10, "Region 1".to_string()), (20, "Region 2".to_string()),]
5238        );
5239    }
5240
5241    #[test]
5242    fn detect_markers_confirm_rejects_invalid_input() {
5243        let audio = test_document(vec![0.8f32; 10], 1);
5244        let mut app = EditApp {
5245            standalone_ready: true,
5246            audio: Some(audio),
5247            detect_markers_dialog: Some(DetectMarkersDialog {
5248                threshold_db: String::from("not a number"),
5249                silence_samples: String::from("5"),
5250            }),
5251            ..EditApp::default()
5252        };
5253        let _ = update(&mut app, Message::DetectMarkersConfirm);
5254        assert!(app.audio.as_ref().unwrap().markers.is_empty());
5255        assert!(app.status.contains("Invalid"));
5256    }
5257
5258    #[test]
5259    fn detect_markers_cancel_closes_dialog() {
5260        let mut app = EditApp {
5261            detect_markers_dialog: Some(DetectMarkersDialog::default()),
5262            ..EditApp::default()
5263        };
5264        let _ = update(&mut app, Message::DetectMarkersCancel);
5265        assert!(app.detect_markers_dialog.is_none());
5266    }
5267
5268    #[test]
5269    fn selection_resize_moves_start_when_clicked_before_range() {
5270        let mut app = app_with_markers(Vec::new(), 100);
5271        app.selection_samples = Some((40, 80));
5272        let _ = update(&mut app, Message::SelectionResize(0.1));
5273        assert_eq!(app.selection_samples, Some((10, 80)));
5274    }
5275
5276    #[test]
5277    fn selection_resize_moves_end_when_clicked_after_range() {
5278        let mut app = app_with_markers(Vec::new(), 100);
5279        app.selection_samples = Some((40, 80));
5280        let _ = update(&mut app, Message::SelectionResize(0.95));
5281        assert_eq!(app.selection_samples, Some((40, 95)));
5282    }
5283
5284    #[test]
5285    fn selection_resize_moves_nearest_edge_when_clicked_inside_range() {
5286        let mut app = app_with_markers(Vec::new(), 100);
5287        app.selection_samples = Some((20, 80));
5288        let _ = update(&mut app, Message::SelectionResize(0.3));
5289        assert_eq!(app.selection_samples, Some((30, 80)));
5290
5291        let _ = update(&mut app, Message::SelectionResize(0.7));
5292        assert_eq!(app.selection_samples, Some((30, 70)));
5293    }
5294
5295    #[test]
5296    fn selection_resize_ignored_when_no_selection() {
5297        let mut app = app_with_markers(Vec::new(), 100);
5298        let _ = update(&mut app, Message::SelectionResize(0.5));
5299        assert_eq!(app.selection_samples, None);
5300    }
5301
5302    #[test]
5303    fn delete_selection_removes_range_and_shifts_markers() {
5304        let mut app = app_with_markers(vec![(1, "A".to_string()), (8, "B".to_string())], 10);
5305        let audio = app.audio.as_mut().unwrap();
5306        audio.samples = (0..10).map(|i| i as f32).collect();
5307        audio.rebuild_preview();
5308        app.selection_samples = Some((3, 6));
5309        let _ = update(&mut app, Message::DeleteSelection);
5310        let audio = app.audio.as_ref().unwrap();
5311        assert_eq!(audio.frames(), 7);
5312        assert_eq!(audio.samples, vec![0.0, 1.0, 2.0, 6.0, 7.0, 8.0, 9.0]);
5313        assert_eq!(
5314            audio.markers,
5315            vec![(1, "A".to_string()), (5, "B".to_string())]
5316        );
5317        assert!(app.selection_samples.is_none());
5318        assert!(app.history.is_dirty());
5319    }
5320
5321    #[test]
5322    fn delete_selection_ignored_when_nothing_selected() {
5323        let mut app = app_with_markers(vec![(1, "A".to_string())], 5);
5324        let audio = app.audio.as_mut().unwrap();
5325        audio.samples = vec![0.0, 1.0, 2.0, 3.0, 4.0];
5326        audio.rebuild_preview();
5327        let original = audio.samples.clone();
5328        let _ = update(&mut app, Message::DeleteSelection);
5329        let audio = app.audio.as_ref().unwrap();
5330        assert_eq!(audio.samples, original);
5331        assert_eq!(audio.markers, vec![(1, "A".to_string())]);
5332        assert!(!app.history.is_dirty());
5333    }
5334
5335    #[test]
5336    fn delete_selection_undo_restores_document() {
5337        let mut app = app_with_markers(vec![(1, "A".to_string()), (8, "B".to_string())], 10);
5338        let audio = app.audio.as_mut().unwrap();
5339        audio.samples = (0..10).map(|i| i as f32).collect();
5340        audio.rebuild_preview();
5341        let original_samples = audio.samples.clone();
5342        let original_markers = audio.markers.clone();
5343        app.selection_samples = Some((3, 6));
5344        let _ = update(&mut app, Message::DeleteSelection);
5345        assert!(app.history.is_dirty());
5346        let _ = update(&mut app, Message::Undo);
5347        let audio = app.audio.as_ref().unwrap();
5348        assert_eq!(audio.samples, original_samples);
5349        assert_eq!(audio.markers, original_markers);
5350        assert!(!app.history.is_dirty());
5351    }
5352
5353    #[cfg(feature = "standalone")]
5354    #[test]
5355    fn marker_ranges_split_at_sorted_markers() {
5356        let markers = vec![(50, "A".to_string()), (20, "B".to_string())];
5357        assert_eq!(
5358            marker_ranges(&markers, 100),
5359            vec![(0, 20), (20, 50), (50, 100)]
5360        );
5361    }
5362
5363    #[cfg(feature = "standalone")]
5364    #[test]
5365    fn marker_ranges_ignores_out_of_bounds_markers() {
5366        let markers = vec![(150, "A".to_string())];
5367        assert_eq!(marker_ranges(&markers, 100), vec![(0, 100)]);
5368    }
5369
5370    #[cfg(feature = "standalone")]
5371    #[test]
5372    fn marker_range_samples_extracts_interleaved_range() {
5373        let audio = test_document(vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], 2);
5374        assert_eq!(marker_range_samples(&audio, 0, 2), vec![1.0, 2.0, 3.0, 4.0]);
5375    }
5376
5377    #[cfg(feature = "standalone")]
5378    #[test]
5379    fn export_filename_includes_index_and_extension() {
5380        assert_eq!(export_filename("track", 7, "wav"), "track_007.wav");
5381    }
5382
5383    #[cfg(feature = "standalone")]
5384    #[test]
5385    fn export_encode_format_maps_formats() {
5386        assert!(matches!(
5387            export_encode_format(ExportFormat::Wav, ExportBitDepth::Bits24),
5388            AudioEncodeFormat::Wav(WavBitDepth::Int24)
5389        ));
5390        assert!(matches!(
5391            export_encode_format(ExportFormat::Flac, ExportBitDepth::Bits16),
5392            AudioEncodeFormat::Flac(16)
5393        ));
5394        assert!(matches!(
5395            export_encode_format(ExportFormat::OggFlac, ExportBitDepth::Bits32),
5396            AudioEncodeFormat::OggFlac(32)
5397        ));
5398        assert!(matches!(
5399            export_encode_format(ExportFormat::Mp3, ExportBitDepth::Bits24),
5400            AudioEncodeFormat::Mp3
5401        ));
5402    }
5403
5404    #[cfg(feature = "standalone")]
5405    #[test]
5406    fn resample_interleaved_identity_when_rates_match() {
5407        let samples = vec![0.1f32, 0.2, 0.3, 0.4, 0.5, 0.6];
5408        let output = resample_interleaved(&samples, 2, 48_000, 48_000).unwrap();
5409        assert_eq!(output, samples);
5410    }
5411
5412    #[cfg(feature = "standalone")]
5413    #[test]
5414    fn resample_interleaved_changes_length_when_rates_differ() {
5415        let samples: Vec<f32> = (0..960).map(|i| (i as f32 / 960.0).sin()).collect();
5416        let output = resample_interleaved(&samples, 1, 48_000, 24_000).unwrap();
5417        assert!(!output.is_empty());
5418        assert!(output.len() < samples.len());
5419    }
5420
5421    #[cfg(feature = "standalone")]
5422    #[tokio::test]
5423    async fn export_marker_ranges_creates_files() {
5424        let dir = std::env::temp_dir().join(format!(
5425            "maolan-edit-export-test-{}",
5426            std::time::UNIX_EPOCH.elapsed().unwrap().as_secs()
5427        ));
5428        let _ = std::fs::remove_dir_all(&dir);
5429        let mut audio = test_document(vec![0.5f32; 48_000], 1);
5430        audio.source_path = PathBuf::from("test_track.wav");
5431        audio.markers = vec![(12_000, "A".to_string()), (36_000, "B".to_string())];
5432        let result = export_marker_ranges(
5433            dir.clone(),
5434            audio,
5435            ExportFormat::Wav,
5436            ExportBitDepth::Bits16,
5437            48_000,
5438        )
5439        .await;
5440        assert_eq!(result.unwrap(), 3);
5441        assert!(dir.join("test_track_001.wav").exists());
5442        assert!(dir.join("test_track_002.wav").exists());
5443        assert!(dir.join("test_track_003.wav").exists());
5444        let _ = std::fs::remove_dir_all(&dir);
5445    }
5446
5447    #[test]
5448    fn preferences_save_to_path_preserves_other_keys() {
5449        let dir = std::env::temp_dir().join(format!(
5450            "maolan-edit-prefs-preserve-{}",
5451            std::time::UNIX_EPOCH.elapsed().unwrap().as_secs()
5452        ));
5453        let _ = std::fs::remove_dir_all(&dir);
5454        let config_path = dir.join("config.toml");
5455        std::fs::create_dir_all(&dir).unwrap();
5456        std::fs::write(
5457            &config_path,
5458            "existing_key = \"keep me\"\ndefault_output_device_id = \"old\"\n",
5459        )
5460        .unwrap();
5461
5462        let preferences = EditorPreferences {
5463            default_output_device_id: Some(String::from("new_out")),
5464            default_input_device_id: Some(String::from("new_in")),
5465        };
5466        preferences.save_to_path(&config_path).unwrap();
5467
5468        let saved = std::fs::read_to_string(&config_path).unwrap();
5469        assert!(saved.contains("existing_key = \"keep me\""));
5470        assert!(saved.contains("default_output_device_id = \"new_out\""));
5471        assert!(saved.contains("default_input_device_id = \"new_in\""));
5472        let _ = std::fs::remove_dir_all(&dir);
5473    }
5474
5475    #[test]
5476    fn preferences_save_removes_empty_ids() {
5477        let dir = std::env::temp_dir().join(format!(
5478            "maolan-edit-prefs-remove-{}",
5479            std::time::UNIX_EPOCH.elapsed().unwrap().as_secs()
5480        ));
5481        let _ = std::fs::remove_dir_all(&dir);
5482        let config_path = dir.join("config.toml");
5483        std::fs::create_dir_all(&dir).unwrap();
5484        std::fs::write(
5485            &config_path,
5486            "default_output_device_id = \"old\"\ndefault_input_device_id = \"old\"\n",
5487        )
5488        .unwrap();
5489
5490        let preferences = EditorPreferences {
5491            default_output_device_id: None,
5492            default_input_device_id: None,
5493        };
5494        preferences.save_to_path(&config_path).unwrap();
5495
5496        let saved = std::fs::read_to_string(&config_path).unwrap();
5497        assert!(!saved.contains("default_output_device_id"));
5498        assert!(!saved.contains("default_input_device_id"));
5499        let _ = std::fs::remove_dir_all(&dir);
5500    }
5501
5502    #[test]
5503    fn preferences_dialog_opens_from_setup() {
5504        let mut app = app_with_markers(Vec::new(), 100);
5505        app.setup.output_devices = vec![AudioDeviceOption::with_supported_caps(
5506            "out1",
5507            "Out One",
5508            vec![32],
5509            vec![48_000],
5510        )];
5511        app.setup.input_devices = vec![AudioDeviceOption::with_supported_caps(
5512            "in1",
5513            "In One",
5514            vec![32],
5515            vec![48_000],
5516        )];
5517        app.setup.output_device = Some(app.setup.output_devices[0].clone());
5518        app.setup.input_device = Some(app.setup.input_devices[0].clone());
5519        let _ = update(&mut app, Message::PreferencesDialog);
5520        let dialog = app.preferences_dialog.as_ref().unwrap();
5521        assert_eq!(dialog.output_devices.len(), 1);
5522        assert_eq!(dialog.input_devices.len(), 1);
5523        assert_eq!(dialog.output_device.as_ref().unwrap().id, "out1");
5524        assert_eq!(dialog.input_device.as_ref().unwrap().id, "in1");
5525    }
5526
5527    #[test]
5528    fn preferences_save_updates_setup() {
5529        let mut app = app_with_markers(Vec::new(), 100);
5530        let out_devices = vec![
5531            AudioDeviceOption::with_supported_caps("out1", "Out One", vec![32], vec![48_000]),
5532            AudioDeviceOption::with_supported_caps("out2", "Out Two", vec![32], vec![48_000]),
5533        ];
5534        let in_devices = vec![
5535            AudioDeviceOption::with_supported_caps("in1", "In One", vec![32], vec![48_000]),
5536            AudioDeviceOption::with_supported_caps("in2", "In Two", vec![32], vec![48_000]),
5537        ];
5538        app.setup.output_devices = out_devices.clone();
5539        app.setup.input_devices = in_devices.clone();
5540        app.preferences_dialog = Some(PreferencesDialog::from_setup(&app.setup));
5541        let _ = update(
5542            &mut app,
5543            Message::PreferencesOutputDeviceSelected(out_devices[1].clone()),
5544        );
5545        let _ = update(
5546            &mut app,
5547            Message::PreferencesInputDeviceSelected(in_devices[1].clone()),
5548        );
5549        let _ = update(&mut app, Message::PreferencesSave);
5550        assert!(app.preferences_dialog.is_none());
5551        assert_eq!(app.setup.output_device.as_ref().unwrap().id, "out2");
5552        assert_eq!(app.setup.input_device.as_ref().unwrap().id, "in2");
5553    }
5554
5555    #[test]
5556    fn preferences_cancel_closes_dialog() {
5557        let mut app = EditApp {
5558            preferences_dialog: Some(PreferencesDialog::from_setup(&StartupSetup::default())),
5559            ..EditApp::default()
5560        };
5561        let _ = update(&mut app, Message::PreferencesCancel);
5562        assert!(app.preferences_dialog.is_none());
5563    }
5564
5565    #[test]
5566    fn audio_device_option_equality_compares_id_only() {
5567        let a = AudioDeviceOption::with_supported_caps("dev", "A", vec![16], vec![44_100]);
5568        let b = AudioDeviceOption::with_supported_caps("dev", "B", vec![32], vec![48_000]);
5569        assert_eq!(a, b);
5570    }
5571
5572    #[cfg(target_os = "freebsd")]
5573    #[test]
5574    fn audio_setup_state_selects_saved_device_from_discovered_list() {
5575        let mut app = EditApp::default();
5576        app.setup.audio_engine = AudioEngineOption::Oss;
5577        app.setup.output_devices = vec![AudioDeviceOption::with_oss_caps(
5578            "/dev/dsp0",
5579            "Out",
5580            vec![16, 24, 32],
5581            vec![44_100, 48_000],
5582            2,
5583            65_536,
5584        )];
5585        app.setup.output_device = Some(AudioDeviceOption::with_oss_caps(
5586            "/dev/dsp0",
5587            "Out",
5588            vec![32],
5589            vec![48_000],
5590            2,
5591            65_536,
5592        ));
5593        app.setup.input_devices = vec![AudioDeviceOption::with_oss_caps(
5594            "/dev/dsp1",
5595            "In",
5596            vec![16, 24, 32],
5597            vec![44_100, 48_000],
5598            2,
5599            65_536,
5600        )];
5601        app.setup.input_device = Some(AudioDeviceOption::with_oss_caps(
5602            "/dev/dsp1",
5603            "In",
5604            vec![32],
5605            vec![48_000],
5606            2,
5607            65_536,
5608        ));
5609
5610        let state = audio_setup_state(&app);
5611
5612        assert_eq!(
5613            state.selected_output_device.as_ref().map(|d| d.id.as_str()),
5614            Some("/dev/dsp0")
5615        );
5616        assert_eq!(
5617            state.selected_input_device.as_ref().map(|d| d.id.as_str()),
5618            Some("/dev/dsp1")
5619        );
5620        assert_eq!(
5621            state.selected_output_device,
5622            Some(app.setup.output_devices[0].clone())
5623        );
5624        assert_eq!(
5625            state.selected_input_device,
5626            Some(app.setup.input_devices[0].clone())
5627        );
5628    }
5629
5630    #[test]
5631    fn preferences_load_from_path_reads_device_ids() {
5632        let dir = std::env::temp_dir().join(format!(
5633            "maolan-edit-prefs-load-{}",
5634            std::time::UNIX_EPOCH.elapsed().unwrap().as_secs()
5635        ));
5636        let _ = std::fs::remove_dir_all(&dir);
5637        std::fs::create_dir_all(&dir).unwrap();
5638        let config_path = dir.join("edit.toml");
5639        let contents =
5640            "default_output_device_id = \"/dev/dsp2\"\ndefault_input_device_id = \"/dev/dsp3\"\n";
5641        std::fs::write(&config_path, contents).unwrap();
5642
5643        let preferences = EditorPreferences::load_from_path(&config_path);
5644
5645        assert_eq!(
5646            preferences.default_output_device_id.as_deref(),
5647            Some("/dev/dsp2")
5648        );
5649        assert_eq!(
5650            preferences.default_input_device_id.as_deref(),
5651            Some("/dev/dsp3")
5652        );
5653        let _ = std::fs::remove_dir_all(&dir);
5654    }
5655
5656    #[test]
5657    fn startup_setup_with_preferences_selects_saved_devices() {
5658        let preferences = EditorPreferences {
5659            default_output_device_id: Some(String::from("out2")),
5660            default_input_device_id: Some(String::from("in2")),
5661        };
5662        let output_devices = vec![
5663            AudioDeviceOption::with_supported_caps("out1", "Out One", vec![32], vec![48_000]),
5664            AudioDeviceOption::with_supported_caps("out2", "Out Two", vec![32], vec![48_000]),
5665        ];
5666        let input_devices = vec![
5667            AudioDeviceOption::with_supported_caps("in1", "In One", vec![32], vec![48_000]),
5668            AudioDeviceOption::with_supported_caps("in2", "In Two", vec![32], vec![48_000]),
5669        ];
5670
5671        let setup = StartupSetup::with_preferences(&preferences, output_devices, input_devices);
5672
5673        assert_eq!(
5674            setup.output_device.as_ref().map(|d| d.id.as_str()),
5675            Some("out2")
5676        );
5677        assert_eq!(
5678            setup.input_device.as_ref().map(|d| d.id.as_str()),
5679            Some("in2")
5680        );
5681    }
5682
5683    #[test]
5684    fn startup_setup_with_preferences_falls_back_to_first_device() {
5685        let preferences = EditorPreferences {
5686            default_output_device_id: Some(String::from("missing")),
5687            default_input_device_id: Some(String::from("missing")),
5688        };
5689        let output_devices = vec![AudioDeviceOption::with_supported_caps(
5690            "out1",
5691            "Out One",
5692            vec![32],
5693            vec![48_000],
5694        )];
5695        let input_devices = vec![AudioDeviceOption::with_supported_caps(
5696            "in1",
5697            "In One",
5698            vec![32],
5699            vec![48_000],
5700        )];
5701
5702        let setup = StartupSetup::with_preferences(&preferences, output_devices, input_devices);
5703
5704        assert_eq!(
5705            setup.output_device.as_ref().map(|d| d.id.as_str()),
5706            Some("out1")
5707        );
5708        assert_eq!(
5709            setup.input_device.as_ref().map(|d| d.id.as_str()),
5710            Some("in1")
5711        );
5712    }
5713}