Skip to main content

maolan_editor/
app.rs

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