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_up, fast_forward, play, redo, rewind, square, trending_down, trending_up,
29    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            container(text(label).size(14))
2035                .padding([4, 8])
2036                .style(if playing {
2037                    playhead_active_style
2038                } else {
2039                    playhead_style
2040                }),
2041            toolbar_button(trending_up().size(16), "Fade in", Message::FadeIn),
2042            toolbar_button(trending_down().size(16), "Fade out", Message::FadeOut),
2043            toolbar_button(
2044                arrow_up().size(16),
2045                "Increase volume",
2046                Message::IncreaseVolume
2047            ),
2048            toolbar_button(
2049                arrow_down().size(16),
2050                "Decrease volume",
2051                Message::DecreaseVolume
2052            ),
2053            Space::new().width(Length::Fill),
2054        ]
2055        .spacing(4)
2056        .align_y(maolan_widgets::iced::Alignment::Center),
2057    )
2058    .width(Length::Fill)
2059    .height(Length::Fixed(34.0))
2060    .padding([4, 8])
2061    .style(toolbar_style)
2062    .into()
2063}
2064
2065fn toolbar_for_app(app: &EditApp, play_disabled: bool) -> Element<'static, Message> {
2066    toolbar_with_playhead_options(playhead_label(app), app.playing, play_disabled)
2067}
2068
2069fn toolbar_button<'a>(
2070    icon: impl Into<Element<'a, Message>>,
2071    label: &'static str,
2072    message: Message,
2073) -> Element<'a, Message> {
2074    tooltip(
2075        button(icon)
2076            .width(Length::Fixed(30.0))
2077            .height(Length::Fixed(26.0))
2078            .style(toolbar_button_style)
2079            .on_press(message),
2080        container(text(label).size(12))
2081            .padding([4, 8])
2082            .style(tooltip_style),
2083        tooltip::Position::Bottom,
2084    )
2085    .gap(4)
2086    .into()
2087}
2088
2089fn toolbar_button_disabled<'a>(
2090    icon: impl Into<Element<'a, Message>>,
2091    label: &'static str,
2092) -> Element<'a, Message> {
2093    tooltip(
2094        button(icon)
2095            .width(Length::Fixed(30.0))
2096            .height(Length::Fixed(26.0))
2097            .style(toolbar_button_style),
2098        container(text(label).size(12))
2099            .padding([4, 8])
2100            .style(tooltip_style),
2101        tooltip::Position::Bottom,
2102    )
2103    .gap(4)
2104    .into()
2105}
2106
2107fn load_document(
2108    app: &mut EditApp,
2109    path: PathBuf,
2110    region: Option<AudioRegion>,
2111    _timeline_region: Option<AudioRegion>,
2112) -> Task<Message> {
2113    app.busy = true;
2114    app.busy_progress = 0.0;
2115    app.status = match region {
2116        Some(_) => format!("Opening clip from {}...", path.display()),
2117        None => format!("Opening {}...", path.display()),
2118    };
2119    Task::run(
2120        {
2121            let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2122            std::thread::spawn(move || {
2123                let mut last_bucket = None;
2124                let mut last_status = String::new();
2125                let progress_tx = tx.clone();
2126                let result = AudioDocument::open_with_progress(path, region, |progress, status| {
2127                    let progress = progress.clamp(0.0, 1.0);
2128                    let bucket = (progress * 100.0).round() as u8;
2129                    if last_bucket == Some(bucket) && last_status == status {
2130                        return;
2131                    }
2132                    last_bucket = Some(bucket);
2133                    last_status = status.to_string();
2134                    let _ = progress_tx.send(Message::DocumentLoadProgress {
2135                        progress,
2136                        status: status.to_string(),
2137                    });
2138                });
2139                let _ = tx.send(Message::DocumentLoaded(result));
2140            });
2141
2142            maolan_widgets::iced::futures::stream::unfold(rx, |mut rx| async move {
2143                rx.recv().await.map(|msg| (msg, rx))
2144            })
2145        },
2146        |msg| msg,
2147    )
2148}
2149
2150fn document_status(audio: &AudioDocument) -> String {
2151    match audio.clip_region {
2152        Some(region) => format!(
2153            "{} - clip {}..{} samples, {} ch, {} Hz, {} frames",
2154            audio.source_path.display(),
2155            region.offset,
2156            region.offset.saturating_add(region.length),
2157            audio.channels,
2158            audio.sample_rate,
2159            audio.frames()
2160        ),
2161        None => format!(
2162            "{} - {} ch, {} Hz, {} frames",
2163            audio.source_path.display(),
2164            audio.channels,
2165            audio.sample_rate,
2166            audio.frames()
2167        ),
2168    }
2169}
2170
2171fn progress_view(progress: f32) -> Element<'static, Message> {
2172    let progress = progress.clamp(0.0, 1.0);
2173    let percent = (progress * 100.0).round() as u8;
2174    row![
2175        container(progress_bar(0.0..=1.0, progress)).width(Length::Fill),
2176        text(format!("{percent}%"))
2177            .size(12)
2178            .width(Length::Fixed(44.0)),
2179    ]
2180    .spacing(8)
2181    .align_y(maolan_widgets::iced::Alignment::Center)
2182    .into()
2183}
2184
2185fn marker_name_input_id() -> Id {
2186    Id::new("edit-marker-name-input")
2187}
2188
2189fn detect_markers_threshold_input_id() -> Id {
2190    Id::new("edit-detect-markers-threshold-input")
2191}
2192
2193fn marker_dialog_view(dialog: &MarkerDialog) -> Element<'_, Message> {
2194    let can_confirm = !dialog.name.trim().is_empty();
2195    let confirm_button = if can_confirm {
2196        button("Create").on_press(Message::MarkerNameConfirm)
2197    } else {
2198        button("Create")
2199    };
2200
2201    container(
2202        column![
2203            text("Add Marker"),
2204            text_input("Enter marker name", &dialog.name)
2205                .id(marker_name_input_id())
2206                .on_input(Message::MarkerNameInput)
2207                .on_submit(Message::MarkerNameConfirm)
2208                .width(Length::Fill),
2209            row![
2210                confirm_button,
2211                button("Cancel")
2212                    .on_press(Message::MarkerNameCancel)
2213                    .style(button::secondary)
2214            ]
2215            .spacing(10),
2216        ]
2217        .spacing(10),
2218    )
2219    .style(|_theme| container::Style {
2220        border: Border {
2221            color: Color::from_rgba(0.34, 0.42, 0.56, 0.72),
2222            width: 1.0,
2223            ..Border::default()
2224        },
2225        background: Some(Background::Color(Color::from_rgb(0.12, 0.13, 0.16))),
2226        ..container::Style::default()
2227    })
2228    .padding(12)
2229    .width(Length::Fixed(320.0))
2230    .into()
2231}
2232
2233fn detect_markers_dialog_view(dialog: &DetectMarkersDialog) -> Element<'_, Message> {
2234    let threshold_valid = dialog.threshold_db.trim().parse::<f32>().is_ok();
2235    let silence_valid = dialog
2236        .silence_samples
2237        .trim()
2238        .parse::<usize>()
2239        .is_ok_and(|value| value > 0);
2240    let can_confirm = threshold_valid && silence_valid;
2241    let confirm_button = if can_confirm {
2242        button("Detect").on_press(Message::DetectMarkersConfirm)
2243    } else {
2244        button("Detect")
2245    };
2246
2247    container(
2248        column![
2249            text("Detect Markers"),
2250            text("Silence threshold (dB)").size(12),
2251            text_input("-60.0", &dialog.threshold_db)
2252                .id(detect_markers_threshold_input_id())
2253                .on_input(Message::DetectMarkersThresholdInput)
2254                .on_submit(Message::DetectMarkersConfirm)
2255                .width(Length::Fill),
2256            text("Silent samples").size(12),
2257            text_input("1000", &dialog.silence_samples)
2258                .on_input(Message::DetectMarkersSilenceSamplesInput)
2259                .on_submit(Message::DetectMarkersConfirm)
2260                .width(Length::Fill),
2261            row![
2262                confirm_button,
2263                button("Cancel")
2264                    .on_press(Message::DetectMarkersCancel)
2265                    .style(button::secondary)
2266            ]
2267            .spacing(10),
2268        ]
2269        .spacing(10),
2270    )
2271    .style(|_theme| container::Style {
2272        border: Border {
2273            color: Color::from_rgba(0.34, 0.42, 0.56, 0.72),
2274            width: 1.0,
2275            ..Border::default()
2276        },
2277        background: Some(Background::Color(Color::from_rgb(0.12, 0.13, 0.16))),
2278        ..container::Style::default()
2279    })
2280    .padding(12)
2281    .width(Length::Fixed(320.0))
2282    .into()
2283}
2284
2285fn export_markers_dialog_view(dialog: &ExportMarkersDialog) -> Element<'_, Message> {
2286    let directory_label = dialog
2287        .directory
2288        .as_ref()
2289        .map(|path| path.to_string_lossy().to_string())
2290        .unwrap_or_else(|| String::from("No directory selected"));
2291    let can_confirm = dialog.directory.is_some();
2292    let confirm_button = if can_confirm {
2293        button("Export").on_press(Message::ExportMarkersConfirm)
2294    } else {
2295        button("Export")
2296    };
2297    let show_bit_depth = dialog.format != ExportFormat::Mp3;
2298    let bit_depth_input: Element<'_, Message> = if show_bit_depth {
2299        pick_list(
2300            ExportBitDepth::ALL,
2301            Some(dialog.bit_depth),
2302            Message::ExportMarkersBitDepthSelected,
2303        )
2304        .width(Length::Fill)
2305        .into()
2306    } else {
2307        container(text("MP3 uses 16-bit PCM internally.").size(12))
2308            .width(Length::Fill)
2309            .into()
2310    };
2311
2312    container(
2313        column![
2314            text("Export Marker Ranges"),
2315            button("Choose Directory...")
2316                .on_press(Message::ExportMarkersDialog)
2317                .width(Length::Fill),
2318            text(directory_label).size(12),
2319            text("Format").size(12),
2320            pick_list(
2321                ExportFormat::ALL,
2322                Some(dialog.format),
2323                Message::ExportMarkersFormatSelected
2324            )
2325            .width(Length::Fill),
2326            text("Sample rate").size(12),
2327            pick_list(
2328                ExportSampleRate::ALL,
2329                Some(dialog.sample_rate),
2330                Message::ExportMarkersSampleRateSelected
2331            )
2332            .width(Length::Fill),
2333            text("Bit depth").size(12),
2334            bit_depth_input,
2335            row![
2336                confirm_button,
2337                button("Cancel")
2338                    .on_press(Message::ExportMarkersCancel)
2339                    .style(button::secondary)
2340            ]
2341            .spacing(10),
2342        ]
2343        .spacing(10),
2344    )
2345    .style(|_theme| container::Style {
2346        border: Border {
2347            color: Color::from_rgba(0.34, 0.42, 0.56, 0.72),
2348            width: 1.0,
2349            ..Border::default()
2350        },
2351        background: Some(Background::Color(Color::from_rgb(0.12, 0.13, 0.16))),
2352        ..container::Style::default()
2353    })
2354    .padding(12)
2355    .width(Length::Fixed(360.0))
2356    .into()
2357}
2358
2359fn preferences_dialog_view(dialog: &PreferencesDialog) -> Element<'_, Message> {
2360    const HAS_SEPARATE_AUDIO_INPUT_DEVICE: bool = cfg!(any(
2361        target_os = "freebsd",
2362        target_os = "linux",
2363        target_os = "openbsd",
2364        target_os = "windows"
2365    ));
2366    let show_input_device = !dialog.output_devices.is_empty() && HAS_SEPARATE_AUDIO_INPUT_DEVICE;
2367    let mut content = column![text("Preferences")].spacing(10);
2368    if show_input_device {
2369        content = content.push(
2370            row![
2371                text("Default input device:").width(Length::Fixed(160.0)),
2372                pick_list(
2373                    dialog.input_devices.clone(),
2374                    dialog.input_device.clone(),
2375                    Message::PreferencesInputDeviceSelected
2376                )
2377                .placeholder("Choose input device")
2378                .width(Length::Fill),
2379            ]
2380            .spacing(10)
2381            .align_y(maolan_widgets::iced::Alignment::Center),
2382        );
2383    }
2384    content = content.push(
2385        row![
2386            text("Default output device:").width(Length::Fixed(160.0)),
2387            pick_list(
2388                dialog.output_devices.clone(),
2389                dialog.output_device.clone(),
2390                Message::PreferencesOutputDeviceSelected
2391            )
2392            .placeholder("Choose output device")
2393            .width(Length::Fill),
2394        ]
2395        .spacing(10)
2396        .align_y(maolan_widgets::iced::Alignment::Center),
2397    );
2398    content = content.push(
2399        row![
2400            button("Save").on_press(Message::PreferencesSave),
2401            button("Cancel")
2402                .on_press(Message::PreferencesCancel)
2403                .style(button::secondary),
2404        ]
2405        .spacing(10),
2406    );
2407
2408    container(content)
2409        .style(|_theme| container::Style {
2410            border: Border {
2411                color: Color::from_rgba(0.34, 0.42, 0.56, 0.72),
2412                width: 1.0,
2413                ..Border::default()
2414            },
2415            background: Some(Background::Color(Color::from_rgb(0.12, 0.13, 0.16))),
2416            ..container::Style::default()
2417        })
2418        .padding(12)
2419        .width(Length::Fixed(420.0))
2420        .into()
2421}
2422
2423fn audio_setup_state(app: &EditApp) -> AudioSetupState<AudioEngineOption, AudioDeviceOption> {
2424    let is_jack = app.setup.audio_engine.is_jack();
2425    let show_input_device = !is_jack
2426        && cfg!(any(
2427            target_os = "freebsd",
2428            target_os = "linux",
2429            target_os = "openbsd",
2430            target_os = "windows"
2431        ));
2432    let show_bit_depth = !is_jack
2433        && cfg!(any(
2434            target_os = "freebsd",
2435            target_os = "linux",
2436            target_os = "openbsd",
2437            target_os = "windows"
2438        ));
2439    let output_devices: Vec<AudioDeviceOption> = app
2440        .setup
2441        .output_devices
2442        .iter()
2443        .filter(|device| backend_matches_device(app.setup.audio_engine, &device.id))
2444        .cloned()
2445        .collect();
2446    let input_devices: Vec<AudioDeviceOption> = app
2447        .setup
2448        .input_devices
2449        .iter()
2450        .filter(|device| backend_matches_device(app.setup.audio_engine, &device.id))
2451        .cloned()
2452        .collect();
2453    let selected_output_device = app
2454        .setup
2455        .output_device
2456        .as_ref()
2457        .and_then(|device| output_devices.iter().find(|d| d.id == device.id).cloned());
2458    let selected_input_device = app
2459        .setup
2460        .input_device
2461        .as_ref()
2462        .and_then(|device| input_devices.iter().find(|d| d.id == device.id).cloned());
2463    let sample_rates = sample_rate_options(&app.setup);
2464    let selected_sample_rate = if sample_rates.contains(&app.setup.sample_rate_hz) {
2465        Some(app.setup.sample_rate_hz)
2466    } else {
2467        sample_rates
2468            .iter()
2469            .min_by_key(|candidate| ((*candidate).saturating_sub(app.setup.sample_rate_hz)).abs())
2470            .copied()
2471    };
2472    let bit_depths = bit_options(&app.setup);
2473    let selected_bit_depth = if show_bit_depth {
2474        Some(if bit_depths.contains(&app.setup.bits) {
2475            app.setup.bits
2476        } else {
2477            bit_depths.first().copied().unwrap_or(32)
2478        })
2479    } else {
2480        None
2481    };
2482    let period_frames = period_frame_options(&app.setup);
2483    let selected_period_frames = if period_frames.contains(&app.setup.period_frames) {
2484        Some(app.setup.period_frames)
2485    } else {
2486        period_frames
2487            .iter()
2488            .copied()
2489            .find(|value| *value >= app.setup.period_frames)
2490            .or_else(|| period_frames.last().copied())
2491    };
2492    let n_periods: Vec<usize> = (1..=16).collect();
2493    let plugins_loaded = app.plugins_loaded();
2494    const HAS_SEPARATE_AUDIO_INPUT_DEVICE: bool = cfg!(any(
2495        target_os = "freebsd",
2496        target_os = "linux",
2497        target_os = "openbsd",
2498        target_os = "windows"
2499    ));
2500    const REQUIRE_SAMPLE_RATES_FOR_HW_READY: bool = cfg!(any(
2501        target_os = "linux",
2502        target_os = "freebsd",
2503        target_os = "openbsd"
2504    ));
2505    let hw_ready = is_jack
2506        || (selected_output_device.is_some()
2507            && (!HAS_SEPARATE_AUDIO_INPUT_DEVICE || selected_input_device.is_some())
2508            && (!REQUIRE_SAMPLE_RATES_FOR_HW_READY || !sample_rates.is_empty()));
2509
2510    AudioSetupState {
2511        backends: AudioEngineOption::ALL.to_vec(),
2512        selected_backend: app.setup.audio_engine,
2513        show_input_device,
2514        input_devices,
2515        selected_input_device,
2516        show_output_device: !is_jack,
2517        output_devices,
2518        selected_output_device,
2519        show_sample_rate: !is_jack,
2520        sample_rates,
2521        selected_sample_rate,
2522        show_bit_depth,
2523        bit_depths,
2524        selected_bit_depth,
2525        show_period_frames: !is_jack,
2526        period_frames,
2527        selected_period_frames,
2528        show_n_periods: !is_jack,
2529        n_periods,
2530        selected_n_periods: Some(app.setup.nperiods),
2531        show_exclusive: !is_jack,
2532        exclusive: app.setup.exclusive,
2533        show_sync_mode: !is_jack,
2534        sync_mode: app.setup.sync_mode,
2535        plugins_loaded,
2536        can_start: plugins_loaded && hw_ready,
2537        status_message: String::new(),
2538    }
2539}
2540
2541fn startup_view(app: &EditApp) -> Element<'_, Message> {
2542    let setup_state = audio_setup_state(app);
2543
2544    let content = audio_setup(setup_state, move |action| match action {
2545        AudioSetupAction::BackendSelected(b) => Message::StartupBackendSelected(b),
2546        AudioSetupAction::InputDeviceSelected(d) => Message::StartupInputDeviceSelected(d),
2547        AudioSetupAction::OutputDeviceSelected(d) => Message::StartupOutputDeviceSelected(d),
2548        AudioSetupAction::SampleRateSelected(r) => Message::StartupSampleRateSelected(r),
2549        AudioSetupAction::BitDepthSelected(b) => Message::StartupBitsSelected(b),
2550        AudioSetupAction::PeriodFramesSelected(p) => Message::StartupPeriodFramesSelected(p),
2551        AudioSetupAction::NPeriodsSelected(n) => Message::StartupNPeriodsSelected(n),
2552        AudioSetupAction::ExclusiveToggled(e) => Message::StartupExclusiveToggled(e),
2553        AudioSetupAction::SyncModeToggled(s) => Message::StartupSyncModeToggled(s),
2554        AudioSetupAction::Start => Message::StartupOpen,
2555    });
2556
2557    container(content)
2558        .style(app_style)
2559        .width(Length::Fill)
2560        .height(Length::Fill)
2561        .align_x(maolan_widgets::iced::Alignment::Center)
2562        .align_y(maolan_widgets::iced::Alignment::Center)
2563        .into()
2564}
2565
2566fn backend_matches_device(engine: AudioEngineOption, device_id: &str) -> bool {
2567    match engine {
2568        #[cfg(unix)]
2569        AudioEngineOption::Jack => false,
2570        #[cfg(target_os = "freebsd")]
2571        AudioEngineOption::Oss => device_id.starts_with("/dev/dsp"),
2572        #[cfg(target_os = "openbsd")]
2573        AudioEngineOption::Sndio => !device_id.is_empty(),
2574        #[cfg(target_os = "linux")]
2575        AudioEngineOption::Alsa => device_id.starts_with("hw:"),
2576        #[cfg(target_os = "windows")]
2577        AudioEngineOption::Wasapi => device_id.starts_with("wasapi:"),
2578    }
2579}
2580
2581async fn save_document(
2582    path: PathBuf,
2583    samples: Vec<f32>,
2584    channels: usize,
2585    sample_rate: u32,
2586) -> Result<PathBuf, String> {
2587    let format = encode_format_for_path(&path)?;
2588    encode_audio_to_file(
2589        &path,
2590        &samples,
2591        channels,
2592        sample_rate,
2593        format,
2594        AudioDither::None,
2595    )
2596    .map_err(|err| format!("Failed to save '{}': {err}", path.display()))?;
2597    Ok(path)
2598}
2599
2600async fn choose_export_directory() -> Option<PathBuf> {
2601    rfd::FileDialog::new().pick_folder()
2602}
2603
2604async fn export_marker_ranges(
2605    directory: PathBuf,
2606    audio: AudioDocument,
2607    format: ExportFormat,
2608    bit_depth: ExportBitDepth,
2609    sample_rate: u32,
2610) -> Result<usize, String> {
2611    let channels = audio.channels.max(1);
2612    let frames = audio.frames();
2613    let ranges = marker_ranges(&audio.markers, frames);
2614    if ranges.is_empty() {
2615        return Err(String::from("No ranges to export."));
2616    }
2617
2618    let source_stem = audio
2619        .source_path
2620        .file_stem()
2621        .and_then(|s| s.to_str())
2622        .unwrap_or("export");
2623    let encode_format = export_encode_format(format, bit_depth);
2624    let extension = format.extension();
2625
2626    std::fs::create_dir_all(&directory)
2627        .map_err(|err| format!("Failed to create export directory: {err}"))?;
2628
2629    let total = ranges.len();
2630    for (index, (start, end)) in ranges.iter().enumerate() {
2631        let range_samples = marker_range_samples(&audio, *start, *end);
2632        let resampled = if sample_rate == audio.sample_rate {
2633            range_samples
2634        } else {
2635            resample_interleaved(&range_samples, channels, audio.sample_rate, sample_rate)?
2636        };
2637        let filename = export_filename(source_stem, index + 1, extension);
2638        let path = directory.join(filename);
2639        encode_audio_to_file(
2640            &path,
2641            &resampled,
2642            channels,
2643            sample_rate,
2644            encode_format,
2645            AudioDither::None,
2646        )
2647        .map_err(|err| format!("Failed to export '{}': {err}", path.display()))?;
2648    }
2649
2650    Ok(total)
2651}
2652
2653fn marker_ranges(markers: &[(usize, String)], frames: usize) -> Vec<(usize, usize)> {
2654    let mut sorted: Vec<usize> = markers.iter().map(|(sample, _)| *sample).collect();
2655    sorted.sort_unstable();
2656    sorted.dedup();
2657    let mut ranges = Vec::new();
2658    let mut start = 0usize;
2659    for marker in sorted {
2660        if marker > start && marker <= frames {
2661            ranges.push((start, marker));
2662            start = marker;
2663        }
2664    }
2665    if start < frames {
2666        ranges.push((start, frames));
2667    }
2668    ranges
2669}
2670
2671fn marker_range_samples(audio: &AudioDocument, start: usize, end: usize) -> Vec<f32> {
2672    let channels = audio.channels.max(1);
2673    let frames = audio.frames();
2674    let start = start.min(frames);
2675    let end = end.min(frames);
2676    if start >= end {
2677        return Vec::new();
2678    }
2679    audio.preview_samples[start * channels..end * channels].to_vec()
2680}
2681
2682fn export_encode_format(format: ExportFormat, bit_depth: ExportBitDepth) -> AudioEncodeFormat {
2683    match format {
2684        ExportFormat::Wav => AudioEncodeFormat::Wav(match bit_depth {
2685            ExportBitDepth::Bits16 => WavBitDepth::Int16,
2686            ExportBitDepth::Bits24 => WavBitDepth::Int24,
2687            ExportBitDepth::Bits32 => WavBitDepth::Int32,
2688        }),
2689        ExportFormat::Flac => AudioEncodeFormat::Flac(bit_depth.bits()),
2690        ExportFormat::OggFlac => AudioEncodeFormat::OggFlac(bit_depth.bits()),
2691        ExportFormat::Mp3 => AudioEncodeFormat::Mp3,
2692    }
2693}
2694
2695fn export_filename(stem: &str, index: usize, extension: &str) -> String {
2696    format!("{stem}_{index:03}.{extension}")
2697}
2698
2699fn resample_interleaved(
2700    samples: &[f32],
2701    channels: usize,
2702    from_rate: u32,
2703    to_rate: u32,
2704) -> Result<Vec<f32>, String> {
2705    if from_rate == to_rate {
2706        return Ok(samples.to_vec());
2707    }
2708    let channels = channels.max(1);
2709    let frames = samples.len() / channels;
2710    if frames == 0 {
2711        return Ok(Vec::new());
2712    }
2713
2714    let mut input_per_channel: Vec<Vec<f32>> = vec![Vec::with_capacity(frames); channels];
2715    for frame in samples.chunks_exact(channels) {
2716        for (channel, sample) in frame.iter().copied().enumerate() {
2717            input_per_channel[channel].push(sample);
2718        }
2719    }
2720
2721    let ratio = to_rate as f64 / from_rate as f64;
2722    let chunk_size = 1024.min(frames);
2723    let mut resampler = rubato::FastFixedIn::<f32>::new(
2724        ratio,
2725        2.0,
2726        rubato::PolynomialDegree::Linear,
2727        chunk_size,
2728        channels,
2729    )
2730    .map_err(|err| format!("Failed to create resampler: {err}"))?;
2731
2732    for channel in &mut input_per_channel {
2733        channel.resize(chunk_size, 0.0);
2734    }
2735
2736    let output_per_channel = resampler
2737        .process(&input_per_channel, None)
2738        .map_err(|err| format!("Failed to resample: {err}"))?;
2739
2740    let expected_output_frames = (frames as f64 * ratio).round() as usize;
2741    let output_frames = output_per_channel
2742        .first()
2743        .map(|channel| channel.len().min(expected_output_frames))
2744        .unwrap_or(0);
2745    let mut output = Vec::with_capacity(output_frames * channels);
2746    output.extend(
2747        (0..output_frames)
2748            .flat_map(|frame| output_per_channel.iter().map(move |channel| channel[frame])),
2749    );
2750
2751    Ok(output)
2752}
2753
2754fn detect_markers(
2755    preview_samples: &[f32],
2756    channels: usize,
2757    threshold_db: f32,
2758    silence_samples: usize,
2759) -> Vec<(usize, String)> {
2760    let channels = channels.max(1);
2761    let frames = preview_samples.len() / channels;
2762    if frames == 0 {
2763        return Vec::new();
2764    }
2765    let threshold = 10.0f32.powf(threshold_db / 20.0);
2766
2767    // Build contiguous segments of silent or non-silent frames.
2768    let mut segments: Vec<(usize, usize, bool)> = Vec::new();
2769    let mut segment_start = 0usize;
2770    let frame_amplitude = |frame: usize| {
2771        preview_samples[frame * channels..(frame + 1) * channels]
2772            .iter()
2773            .map(|sample| sample.abs())
2774            .sum::<f32>()
2775            / channels as f32
2776    };
2777    let mut is_silent = frame_amplitude(0) < threshold;
2778
2779    for frame in 1..frames {
2780        let frame_silent = frame_amplitude(frame) < threshold;
2781        if frame_silent != is_silent {
2782            segments.push((segment_start, frame, is_silent));
2783            segment_start = frame;
2784            is_silent = frame_silent;
2785        }
2786    }
2787    segments.push((segment_start, frames, is_silent));
2788
2789    // Treat short silent runs as part of the surrounding sound.
2790    let mut classified: Vec<(usize, usize, bool)> = Vec::new();
2791    for (start, end, silent) in segments {
2792        if silent && end.saturating_sub(start) >= silence_samples {
2793            classified.push((start, end, true));
2794        } else if let Some(last) = classified.last_mut() {
2795            if !last.2 {
2796                last.1 = end;
2797                continue;
2798            }
2799            classified.push((start, end, false));
2800        } else {
2801            classified.push((start, end, false));
2802        }
2803    }
2804
2805    // Place markers at the boundaries of every sound region.
2806    let mut markers: Vec<(usize, String)> = Vec::new();
2807    let mut region_index = 1usize;
2808    for (index, (start, end, is_silence)) in classified.iter().enumerate() {
2809        if *is_silence {
2810            continue;
2811        }
2812        let preceded_by_silence = index == 0 || classified[index - 1].2;
2813        let followed_by_silence = index == classified.len() - 1 || classified[index + 1].2;
2814        if preceded_by_silence {
2815            markers.push((*start, format!("Region {region_index}")));
2816            region_index += 1;
2817        }
2818        if followed_by_silence {
2819            markers.push((*end, format!("Region {region_index}")));
2820            region_index += 1;
2821        }
2822    }
2823
2824    markers
2825}
2826
2827impl AudioDocument {
2828    fn open_with_progress<F>(
2829        path: PathBuf,
2830        region: Option<AudioRegion>,
2831        mut progress_callback: F,
2832    ) -> Result<Self, String>
2833    where
2834        F: FnMut(f32, &str),
2835    {
2836        let name = path
2837            .file_name()
2838            .and_then(|name| name.to_str())
2839            .unwrap_or("audio file")
2840            .to_string();
2841
2842        progress_callback(0.0, &format!("Opening {name}..."));
2843        progress_callback(0.05, &format!("Decoding {name}..."));
2844        let (samples, channels, sample_rate) = decode_audio_to_f32_interleaved_sync(&path)
2845            .map_err(|err| format!("Failed to open '{}': {err}", path.display()))?;
2846        progress_callback(0.72, &format!("Preparing clip from {name}..."));
2847        let samples = clip_samples(&samples, channels, region);
2848        let edits = AudioEdits::default();
2849        progress_callback(0.78, &format!("Applying preview edits to {name}..."));
2850        let preview = apply_edits(&samples, channels, edits);
2851        progress_callback(0.85, &format!("Preparing waveform for {name}..."));
2852        let channel_samples = deinterleave(&preview, channels);
2853        progress_callback(0.95, &format!("Measuring peak level for {name}..."));
2854        let peak = peak(&preview);
2855        let save_path = region.is_none().then(|| path.clone());
2856        progress_callback(1.0, &format!("Opened {name}."));
2857
2858        Ok(Self {
2859            source_path: path,
2860            save_path,
2861            samples,
2862            preview_samples: preview,
2863            channels,
2864            sample_rate,
2865            channel_samples,
2866            peak,
2867            clip_region: region,
2868            edits,
2869            markers: Vec::new(),
2870        })
2871    }
2872
2873    fn frames(&self) -> usize {
2874        self.samples.len() / self.channels.max(1)
2875    }
2876
2877    fn rebuild_preview(&mut self) {
2878        let preview = apply_edits(&self.samples, self.channels, self.edits);
2879        self.preview_samples = preview.clone();
2880        self.channel_samples = deinterleave(&preview, self.channels);
2881        self.peak = peak(&preview);
2882    }
2883
2884    fn next_zero_crossing_frame(&self, start_frame: usize) -> Option<usize> {
2885        let channels = self.channels.max(1);
2886        let frames = self.preview_samples.len() / channels;
2887        if start_frame.saturating_add(1) >= frames {
2888            return None;
2889        }
2890
2891        let mut iter = self
2892            .preview_samples
2893            .chunks_exact(channels)
2894            .enumerate()
2895            .skip(start_frame);
2896        let (_, previous_chunk) = iter.next()?;
2897        let mut previous = previous_chunk.iter().sum::<f32>() / channels as f32;
2898
2899        for (frame, chunk) in iter {
2900            let current = chunk.iter().sum::<f32>() / channels as f32;
2901            if (previous > 0.0 && current <= 0.0) || (previous < 0.0 && current >= 0.0) {
2902                return Some(frame);
2903            }
2904            previous = current;
2905        }
2906        None
2907    }
2908}
2909
2910#[derive(Debug, Clone, Copy)]
2911enum EditOperation {
2912    FadeIn,
2913    FadeOut,
2914    IncreaseVolume,
2915    DecreaseVolume,
2916}
2917
2918fn apply_standalone_edit(app: &mut EditApp, operation: EditOperation) -> Task<Message> {
2919    let Some(audio) = app.audio.as_mut() else {
2920        app.status = String::from("No audio file is open.");
2921        return Task::none();
2922    };
2923
2924    let previous_snapshot = DocumentSnapshot {
2925        samples: audio.samples.clone(),
2926        edits: audio.edits,
2927        markers: audio.markers.clone(),
2928    };
2929
2930    if let Some((start, end)) = app.selection_samples {
2931        let frames = audio.frames();
2932        let start = start.min(frames);
2933        let end = end.min(frames);
2934        let length = end.saturating_sub(start);
2935        if length == 0 {
2936            app.status = String::from("Selection is empty.");
2937            return Task::none();
2938        }
2939        let region = AudioRegion {
2940            offset: start,
2941            length,
2942        };
2943        apply_edit_to_samples(&mut audio.samples, audio.channels, region, operation);
2944        match operation {
2945            EditOperation::FadeIn => app.status = String::from("Fade in applied to selection."),
2946            EditOperation::FadeOut => app.status = String::from("Fade out applied to selection."),
2947            EditOperation::IncreaseVolume | EditOperation::DecreaseVolume => {
2948                app.status = String::from("Volume adjusted on selection.")
2949            }
2950        }
2951    } else {
2952        match operation {
2953            EditOperation::FadeIn => {
2954                audio.edits.fade_in_samples = default_fade_samples(audio.frames());
2955                app.status = String::from("Fade in applied to preview.");
2956            }
2957            EditOperation::FadeOut => {
2958                audio.edits.fade_out_samples = default_fade_samples(audio.frames());
2959                app.status = String::from("Fade out applied to preview.");
2960            }
2961            EditOperation::IncreaseVolume => {
2962                audio.edits.gain_db = (audio.edits.gain_db + 1.0).min(24.0);
2963                app.status = format!("Preview gain: {:+.1} dB.", audio.edits.gain_db);
2964            }
2965            EditOperation::DecreaseVolume => {
2966                audio.edits.gain_db = (audio.edits.gain_db - 1.0).max(-48.0);
2967                app.status = format!("Preview gain: {:+.1} dB.", audio.edits.gain_db);
2968            }
2969        }
2970    }
2971    app.history.record(
2972        previous_snapshot,
2973        DocumentSnapshot {
2974            samples: audio.samples.clone(),
2975            edits: audio.edits,
2976            markers: audio.markers.clone(),
2977        },
2978    );
2979    audio.rebuild_preview();
2980    prepare_document_track(app)
2981}
2982
2983fn delete_selection(app: &mut EditApp) -> Task<Message> {
2984    let Some(audio) = app.audio.as_mut() else {
2985        return Task::none();
2986    };
2987    let Some((start, end)) = app.selection_samples else {
2988        return Task::none();
2989    };
2990    if start >= end || end > audio.frames() {
2991        return Task::none();
2992    }
2993
2994    let previous_snapshot = DocumentSnapshot {
2995        samples: audio.samples.clone(),
2996        edits: audio.edits,
2997        markers: audio.markers.clone(),
2998    };
2999    let channels = audio.channels.max(1);
3000    let sample_start = start * channels;
3001    let sample_end = end.min(audio.frames()) * channels;
3002    audio.samples.drain(sample_start..sample_end);
3003
3004    if let Some(region) = audio.clip_region.as_mut() {
3005        let region_start = region.offset;
3006        let region_end = region.offset + region.length;
3007        if end <= region_start {
3008            region.offset = region.offset.saturating_sub(end - start);
3009        } else if start >= region_end {
3010            // Region is entirely before the deleted range; no change.
3011        } else {
3012            let delete_start = start.max(region_start);
3013            let delete_end = end.min(region_end);
3014            let deleted_in_region = delete_end.saturating_sub(delete_start);
3015            region.length = region.length.saturating_sub(deleted_in_region);
3016            if start < region_start {
3017                region.offset = region_start.saturating_sub(end - start);
3018            }
3019            if region.length == 0 {
3020                audio.clip_region = None;
3021            }
3022        }
3023    }
3024
3025    let deleted_frames = end - start;
3026    audio
3027        .markers
3028        .retain(|(sample, _)| *sample < start || *sample >= end);
3029    for (sample, _) in &mut audio.markers {
3030        if *sample >= end {
3031            *sample = sample.saturating_sub(deleted_frames);
3032        }
3033    }
3034
3035    app.selection_anchor_samples = None;
3036    app.selection_samples = None;
3037    app.status = format!("Deleted {}..{} samples.", start, end);
3038
3039    app.history.record(
3040        previous_snapshot,
3041        DocumentSnapshot {
3042            samples: audio.samples.clone(),
3043            edits: audio.edits,
3044            markers: audio.markers.clone(),
3045        },
3046    );
3047    audio.rebuild_preview();
3048    prepare_document_track(app)
3049}
3050
3051fn restore_document(audio: &mut AudioDocument, snapshot: DocumentSnapshot) {
3052    audio.samples = snapshot.samples;
3053    audio.edits = snapshot.edits;
3054    audio.markers = snapshot.markers;
3055}
3056
3057fn apply_edit_to_samples(
3058    samples: &mut [f32],
3059    channels: usize,
3060    region: AudioRegion,
3061    operation: EditOperation,
3062) {
3063    let channels = channels.max(1);
3064    let frames = samples.len() / channels;
3065    let start = region.offset.min(frames);
3066    let end = start.saturating_add(region.length).min(frames);
3067    if start >= end {
3068        return;
3069    }
3070
3071    match operation {
3072        EditOperation::FadeIn => {
3073            let fade_len = end - start;
3074            for frame in start..end {
3075                let envelope = (frame - start) as f32 / fade_len as f32;
3076                for channel in 0..channels {
3077                    let index = frame * channels + channel;
3078                    samples[index] *= envelope;
3079                }
3080            }
3081        }
3082        EditOperation::FadeOut => {
3083            let fade_len = end - start;
3084            for frame in start..end {
3085                let envelope = (end - 1 - frame) 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::IncreaseVolume => {
3093            let gain = 10.0f32.powf(1.0 / 20.0);
3094            for frame in start..end {
3095                for channel in 0..channels {
3096                    let index = frame * channels + channel;
3097                    samples[index] = (samples[index] * gain).clamp(-1.0, 1.0);
3098                }
3099            }
3100        }
3101        EditOperation::DecreaseVolume => {
3102            let gain = 10.0f32.powf(-1.0 / 20.0);
3103            for frame in start..end {
3104                for channel in 0..channels {
3105                    let index = frame * channels + channel;
3106                    samples[index] = (samples[index] * gain).clamp(-1.0, 1.0);
3107                }
3108            }
3109        }
3110    }
3111}
3112
3113fn default_fade_samples(frames: usize) -> usize {
3114    (frames / 20).clamp(240, 48_000).min(frames / 2)
3115}
3116
3117fn apply_edits(samples: &[f32], channels: usize, edits: AudioEdits) -> Vec<f32> {
3118    let channels = channels.max(1);
3119    let frames = samples.len() / channels;
3120    let gain = 10.0f32.powf(edits.gain_db / 20.0);
3121    let mut output = samples.to_vec();
3122
3123    for frame in 0..frames {
3124        let mut envelope = 1.0f32;
3125        if edits.fade_in_samples > 0 && frame < edits.fade_in_samples {
3126            envelope *= frame as f32 / edits.fade_in_samples as f32;
3127        }
3128        if edits.fade_out_samples > 0 {
3129            let fade_start = frames.saturating_sub(edits.fade_out_samples);
3130            if frame >= fade_start {
3131                envelope *= (frames.saturating_sub(frame) as f32 / edits.fade_out_samples as f32)
3132                    .clamp(0.0, 1.0);
3133            }
3134        }
3135        for channel in 0..channels {
3136            let index = frame * channels + channel;
3137            output[index] = (output[index] * envelope * gain).clamp(-1.0, 1.0);
3138        }
3139    }
3140
3141    output
3142}
3143
3144fn peak(samples: &[f32]) -> f32 {
3145    samples
3146        .iter()
3147        .fold(0.0f32, |peak, sample| peak.max(sample.abs()))
3148}
3149
3150fn play_standalone(app: &mut EditApp) -> Task<Message> {
3151    if app.busy || app.preparing_playback {
3152        app.status = String::from("Preparing audio for playback.");
3153        return Task::none();
3154    }
3155    if app.playing {
3156        return Task::none();
3157    }
3158    let Some(audio) = app.audio.as_ref() else {
3159        app.status = String::from("No audio file is open.");
3160        return Task::none();
3161    };
3162    let Some(playback) = app.engine_playback.as_ref() else {
3163        if !app.standalone_ready {
3164            app.playing = true;
3165            app.status = String::from("Playing preview.");
3166            if app.playhead_samples >= audio.frames() {
3167                app.playhead_samples = 0;
3168            }
3169            return Task::none();
3170        }
3171        app.status = String::from("Open audio hardware before playback.");
3172        return Task::none();
3173    };
3174    app.playing = true;
3175    app.status = String::from("Playing.");
3176    if app.playhead_samples >= audio.frames() {
3177        app.playhead_samples = 0;
3178    }
3179    let start = app.playhead_samples;
3180    let client = playback.client.clone();
3181    Task::perform(
3182        async move { start_engine_playback(client, start).await },
3183        Message::StandalonePlaybackStarted,
3184    )
3185}
3186
3187fn refresh_standalone_playhead(app: &mut EditApp) -> bool {
3188    if !app.playing {
3189        return false;
3190    }
3191    let Some(audio) = app.audio.as_ref() else {
3192        app.playing = false;
3193        return false;
3194    };
3195    let step = (audio.sample_rate / 25).max(1) as usize;
3196    app.playhead_samples = app.playhead_samples.saturating_add(step);
3197    if app.playhead_samples >= audio.frames() {
3198        app.playhead_samples = audio.frames();
3199        return true;
3200    }
3201    false
3202}
3203
3204fn playhead_ratio(app: &EditApp) -> Option<f32> {
3205    let frames = app.audio.as_ref()?.frames().max(1);
3206    Some((app.playhead_samples as f32 / frames as f32).clamp(0.0, 1.0))
3207}
3208
3209fn selection_ratio(app: &EditApp) -> Option<(f32, f32)> {
3210    let frames = app.audio.as_ref()?.frames().max(1) as f32;
3211    let (start, end) = app.selection_samples?;
3212    Some((start as f32 / frames, end as f32 / frames))
3213}
3214
3215fn sample_at_ratio(app: &EditApp, ratio: f32) -> Option<usize> {
3216    let frames = app.audio.as_ref()?.frames();
3217    Some(((ratio.clamp(0.0, 1.0) * frames as f32).round() as usize).min(frames))
3218}
3219
3220fn nearest_marker_sample(audio: &AudioDocument, ratio: f32) -> Option<usize> {
3221    if audio.markers.is_empty() {
3222        return None;
3223    }
3224    let frames = audio.frames().max(1);
3225    let target = (ratio.clamp(0.0, 1.0) * frames as f32).round() as usize;
3226    audio
3227        .markers
3228        .iter()
3229        .min_by_key(|(sample, _)| sample.abs_diff(target))
3230        .map(|(sample, _)| *sample)
3231}
3232
3233fn selection_duration_seconds(app: &EditApp) -> f32 {
3234    let Some(audio) = app.audio.as_ref() else {
3235        return 0.0;
3236    };
3237    let Some((start, end)) = app.selection_samples else {
3238        return 0.0;
3239    };
3240    end.saturating_sub(start) as f32 / audio.sample_rate.max(1) as f32
3241}
3242
3243fn vu_meter(app: &EditApp) -> Element<'_, Message> {
3244    let levels = vu_levels_db(app);
3245    meters::meters(levels.len(), &levels, 0.0)
3246}
3247
3248pub fn vu_levels_db(app: &EditApp) -> Vec<f32> {
3249    let Some(audio) = app.audio.as_ref() else {
3250        return vec![-90.0, -90.0];
3251    };
3252    let channels = audio.channels.max(1);
3253    let frames = audio.frames();
3254    let start = app.playhead_samples.min(frames);
3255    let end = start.saturating_add(2048).min(frames);
3256    let channel_count = channels.min(2);
3257    let mut levels = vec![0.0f32; channel_count.max(1)];
3258    if start >= end {
3259        return levels;
3260    }
3261    for frame in start..end {
3262        for (channel, level) in levels.iter_mut().enumerate().take(channel_count) {
3263            let sample = audio.preview_samples[frame * channels + channel].abs();
3264            *level = (*level).max(sample);
3265        }
3266    }
3267    levels
3268        .into_iter()
3269        .map(|level| {
3270            if level <= 1.0e-9 {
3271                -90.0
3272            } else {
3273                (20.0 * level.log10()).clamp(-90.0, 20.0)
3274            }
3275        })
3276        .collect()
3277}
3278
3279fn prepare_document_track(app: &mut EditApp) -> Task<Message> {
3280    if !app.standalone_ready {
3281        return Task::none();
3282    }
3283    let (Some(audio), Some(playback)) = (app.audio.as_ref(), app.engine_playback.as_ref()) else {
3284        return Task::none();
3285    };
3286    let client = playback.client.clone();
3287    let path = audio.source_path.clone();
3288    let samples = audio.preview_samples.clone();
3289    let channels = audio.channels;
3290    let sample_rate = audio.sample_rate;
3291    let clip_len = audio.frames();
3292    let render_preview = !audio.edits.is_default();
3293    let clip_offset = if render_preview {
3294        0
3295    } else {
3296        audio.clip_region.map(|region| region.offset).unwrap_or(0)
3297    };
3298    app.engine_clip_path = Some(if render_preview {
3299        preview_path(&path)
3300    } else {
3301        path.clone()
3302    });
3303    app.preparing_playback = true;
3304    app.status = String::from("Preparing audio for playback...");
3305    let request = EngineDocumentRequest {
3306        path,
3307        samples,
3308        channels,
3309        sample_rate,
3310        clip_len,
3311        clip_offset,
3312        render_preview,
3313    };
3314    Task::perform(
3315        async move { prepare_engine_document(client, request).await },
3316        Message::EngineDocumentPrepared,
3317    )
3318}
3319
3320async fn open_standalone_engine(setup: StartupSetup) -> Result<EngineClient, String> {
3321    let client = EngineClient::default();
3322    let mut rx = client.subscribe().await;
3323    send_engine(&client, EngineAction::Stop).await?;
3324    scan_plugins(&client, &mut rx).await?;
3325    send_engine(
3326        &client,
3327        EngineAction::OpenAudioDevice {
3328            device: selected_output_device(&setup),
3329            input_device: selected_input_device(&setup),
3330            sample_rate_hz: setup.sample_rate_hz,
3331            bits: selected_bits(&setup),
3332            exclusive: setup.exclusive,
3333            period_frames: selected_period_frames(&setup),
3334            nperiods: setup.nperiods,
3335            sync_mode: setup.sync_mode,
3336            actual_period_frames: 0,
3337            input_channels: 0,
3338            output_channels: 0,
3339            bytes_per_frame: 0,
3340        },
3341    )
3342    .await?;
3343    wait_for_engine_response(&mut rx, |action| {
3344        matches!(action, EngineAction::OpenAudioDevice { .. })
3345    })
3346    .await?;
3347    Ok(client)
3348}
3349
3350async fn scan_plugins(
3351    client: &EngineClient,
3352    rx: &mut tokio::sync::mpsc::Receiver<EngineMessage>,
3353) -> Result<(), String> {
3354    #[cfg(unix)]
3355    {
3356        send_engine(client, EngineAction::ListLv2Plugins).await?;
3357    }
3358    send_engine(client, EngineAction::ListVst3Plugins).await?;
3359    send_engine(client, EngineAction::ListClapPlugins).await?;
3360
3361    #[cfg(unix)]
3362    wait_for_engine_response(rx, |action| {
3363        matches!(
3364            action,
3365            EngineAction::Lv2Plugins(_) | EngineAction::Lv2PluginsUnavailable { .. }
3366        )
3367    })
3368    .await?;
3369    wait_for_engine_response(rx, |action| {
3370        matches!(
3371            action,
3372            EngineAction::Vst3Plugins(_) | EngineAction::Vst3PluginsUnavailable { .. }
3373        )
3374    })
3375    .await?;
3376    wait_for_engine_response(rx, |action| {
3377        matches!(
3378            action,
3379            EngineAction::ClapPlugins(_) | EngineAction::ClapPluginsUnavailable { .. }
3380        )
3381    })
3382    .await?;
3383    Ok(())
3384}
3385
3386async fn prepare_engine_document(
3387    client: EngineClient,
3388    request: EngineDocumentRequest,
3389) -> Result<(), String> {
3390    let clip_path = if request.render_preview {
3391        let temp_path = preview_path(&request.path);
3392        save_document(
3393            temp_path.clone(),
3394            request.samples,
3395            request.channels,
3396            request.sample_rate,
3397        )
3398        .await?;
3399        temp_path
3400    } else {
3401        request.path
3402    };
3403    let track = "editor-preview".to_string();
3404    send_engine(&client, EngineAction::Stop).await?;
3405    let _ = send_engine(&client, EngineAction::RemoveTrack(track.clone())).await;
3406    let mut rx = client.subscribe().await;
3407    send_engine(
3408        &client,
3409        EngineAction::AddTrack {
3410            name: track.clone(),
3411            audio_ins: request.channels,
3412            midi_ins: 0,
3413            audio_outs: request.channels,
3414            midi_outs: 0,
3415            folder: false,
3416        },
3417    )
3418    .await?;
3419    wait_for_engine_response(
3420        &mut rx,
3421        |action| matches!(action, EngineAction::AddTrack { name, .. } if name == "editor-preview"),
3422    )
3423    .await?;
3424    send_engine(
3425        &client,
3426        EngineAction::AddClip {
3427            clip_id: generate_clip_id(),
3428            name: clip_path.to_string_lossy().to_string(),
3429            track_name: track.clone(),
3430            start: 0,
3431            length: request.clip_len,
3432            offset: request.clip_offset,
3433            input_channel: 0,
3434            muted: false,
3435            peaks_file: None,
3436            kind: Kind::Audio,
3437            fade_enabled: true,
3438            fade_in_samples: 240,
3439            fade_out_samples: 240,
3440            source_name: None,
3441            source_offset: None,
3442            source_length: None,
3443            preview_name: None,
3444            pitch_correction_points: Vec::new(),
3445            pitch_correction_frame_likeness: None,
3446            pitch_correction_inertia_ms: None,
3447            pitch_correction_formant_compensation: None,
3448            plugin_graph_json: None,
3449        },
3450    )
3451    .await?;
3452    wait_for_engine_response(
3453        &mut rx,
3454        |action| matches!(action, EngineAction::AddClip { track_name, .. } if track_name == "editor-preview"),
3455    )
3456    .await?;
3457    for channel in 0..request.channels.clamp(1, 2) {
3458        send_engine(
3459            &client,
3460            EngineAction::Connect {
3461                from_track: track.clone(),
3462                from_port: channel,
3463                to_track: "hw:out".to_string(),
3464                to_port: channel,
3465                kind: Kind::Audio,
3466            },
3467        )
3468        .await?;
3469        wait_for_engine_response(&mut rx, |action| {
3470            matches!(action, EngineAction::Connect {
3471                from_track,
3472                from_port,
3473                to_track,
3474                to_port,
3475                kind,
3476            } if from_track == "editor-preview"
3477                && *from_port == channel
3478                && to_track == "hw:out"
3479                && *to_port == channel
3480                && *kind == Kind::Audio)
3481        })
3482        .await?;
3483    }
3484    send_engine(&client, EngineAction::SetClipPlaybackEnabled(true)).await?;
3485    wait_for_engine_response(&mut rx, |action| {
3486        matches!(action, EngineAction::SetClipPlaybackEnabled(true))
3487    })
3488    .await?;
3489    Ok(())
3490}
3491
3492async fn start_engine_playback(client: EngineClient, start: usize) -> Result<(), String> {
3493    let mut rx = client.subscribe().await;
3494    send_engine(&client, EngineAction::SetClipPlaybackEnabled(true)).await?;
3495    wait_for_engine_response(&mut rx, |action| {
3496        matches!(action, EngineAction::SetClipPlaybackEnabled(true))
3497    })
3498    .await?;
3499    send_engine(&client, EngineAction::TransportPosition(start)).await?;
3500    send_engine(&client, EngineAction::Play).await?;
3501    wait_for_engine_response(&mut rx, |action| matches!(action, EngineAction::Play)).await?;
3502    Ok(())
3503}
3504
3505fn selected_output_device(setup: &StartupSetup) -> String {
3506    if setup.audio_engine.is_jack() {
3507        String::from("jack")
3508    } else {
3509        setup
3510            .output_device
3511            .as_ref()
3512            .map(|device| device.id.clone())
3513            .unwrap_or_else(|| default_audio_device(setup.audio_engine).to_string())
3514    }
3515}
3516
3517fn selected_input_device(setup: &StartupSetup) -> Option<String> {
3518    if setup.audio_engine.is_jack() {
3519        None
3520    } else {
3521        setup.input_device.as_ref().map(|device| device.id.clone())
3522    }
3523}
3524
3525fn selected_bits(setup: &StartupSetup) -> i32 {
3526    if setup.audio_engine.is_jack() {
3527        32
3528    } else {
3529        setup.bits as i32
3530    }
3531}
3532
3533fn selected_period_frames(setup: &StartupSetup) -> usize {
3534    let options = period_frame_options(setup);
3535    if options.contains(&setup.period_frames) {
3536        setup.period_frames
3537    } else {
3538        options
3539            .iter()
3540            .copied()
3541            .find(|value| *value >= setup.period_frames)
3542            .or_else(|| options.last().copied())
3543            .unwrap_or(setup.period_frames)
3544    }
3545}
3546
3547fn period_frame_options(setup: &StartupSetup) -> Vec<usize> {
3548    #[cfg(target_os = "freebsd")]
3549    {
3550        if !setup.audio_engine.is_jack()
3551            && let Some(device) = setup.output_device.as_ref()
3552            && let Some(options) = oss_period_frame_options(device, selected_bits(setup) as usize)
3553        {
3554            return options;
3555        }
3556    }
3557    vec![
3558        16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536,
3559    ]
3560}
3561
3562#[cfg(target_os = "freebsd")]
3563fn oss_period_frame_options(device: &AudioDeviceOption, bits: usize) -> Option<Vec<usize>> {
3564    if device.max_channels == 0 || device.max_buffer_bytes == 0 {
3565        return None;
3566    }
3567    let channels = device.max_channels.max(1);
3568    let bytes_per_sample = match bits {
3569        8 => 1,
3570        16 => 2,
3571        24 => 3,
3572        32 => 4,
3573        _ => return None,
3574    };
3575    let frame_bytes = channels.checked_mul(bytes_per_sample)?.max(1);
3576    let min_bytes = frame_bytes.next_power_of_two();
3577    let max_fragment_bytes = 1_usize << 16;
3578    let max_bytes = device
3579        .max_buffer_bytes
3580        .min(max_fragment_bytes)
3581        .max(min_bytes);
3582    if min_bytes > max_bytes {
3583        return None;
3584    }
3585    let mut out = Vec::new();
3586    let mut bytes = min_bytes;
3587    while bytes <= max_bytes {
3588        out.push(bytes.div_ceil(frame_bytes).max(1));
3589        match bytes.checked_mul(2) {
3590            Some(next) => bytes = next,
3591            None => break,
3592        }
3593    }
3594    out.sort_unstable();
3595    out.dedup();
3596    (!out.is_empty()).then_some(out)
3597}
3598
3599async fn send_engine(client: &EngineClient, action: EngineAction) -> Result<(), String> {
3600    client.send(EngineMessage::Request(action)).await
3601}
3602
3603async fn wait_for_engine_response(
3604    rx: &mut tokio::sync::mpsc::Receiver<EngineMessage>,
3605    mut accepts: impl FnMut(&EngineAction) -> bool,
3606) -> Result<(), String> {
3607    let deadline = Instant::now() + Duration::from_secs(5);
3608    loop {
3609        let remaining = deadline.saturating_duration_since(Instant::now());
3610        if remaining.is_zero() {
3611            return Err(String::from("Timed out waiting for audio engine."));
3612        }
3613        let Some(message) = tokio::time::timeout(remaining, rx.recv())
3614            .await
3615            .map_err(|_| String::from("Timed out waiting for audio engine."))?
3616        else {
3617            return Err(String::from("Audio engine response channel closed."));
3618        };
3619        if let EngineMessage::Response(result) = message {
3620            match result {
3621                Ok(action) if accepts(&action) => return Ok(()),
3622                Ok(_) => {}
3623                Err(err) => return Err(err),
3624            }
3625        }
3626    }
3627}
3628
3629fn preview_path(source: &Path) -> PathBuf {
3630    let mut path = std::env::temp_dir();
3631    let stem = source
3632        .file_stem()
3633        .and_then(|stem| stem.to_str())
3634        .unwrap_or("maolan-editor-preview");
3635    path.push(format!("maolan-editor-preview-{stem}.wav"));
3636    path
3637}
3638
3639fn playhead_label(app: &EditApp) -> String {
3640    let sample_rate = app
3641        .audio
3642        .as_ref()
3643        .map(|audio| audio.sample_rate)
3644        .unwrap_or(48_000)
3645        .max(1);
3646    let seconds = app.playhead_samples as f64 / sample_rate as f64;
3647    let minutes = (seconds / 60.0).floor() as u64;
3648    let secs = (seconds % 60.0).floor() as u64;
3649    let millis = ((seconds.fract()) * 1000.0).floor() as u64;
3650    format!("{minutes:02}:{secs:02}.{millis:03}")
3651}
3652
3653fn discover_output_audio_devices(engine: AudioEngineOption) -> Vec<AudioDeviceOption> {
3654    if engine.is_jack() {
3655        return vec![simple_audio_device("jack")];
3656    }
3657    let mut devices = platform_audio_devices()
3658        .into_iter()
3659        .filter(|device| device.supports_output)
3660        .collect::<Vec<_>>();
3661    if devices.is_empty() {
3662        devices.push(simple_audio_device(default_audio_device(engine)));
3663    }
3664    devices.sort_by_key(|device| device.label.to_lowercase());
3665    devices.dedup_by(|a, b| a.id == b.id);
3666    devices
3667}
3668
3669fn discover_input_audio_devices(engine: AudioEngineOption) -> Vec<AudioDeviceOption> {
3670    if engine.is_jack() {
3671        return Vec::new();
3672    }
3673    let mut devices = platform_audio_devices()
3674        .into_iter()
3675        .filter(|device| device.supports_input)
3676        .collect::<Vec<_>>();
3677    devices.sort_by_key(|device| device.label.to_lowercase());
3678    devices.dedup_by(|a, b| a.id == b.id);
3679    devices
3680}
3681
3682fn platform_audio_devices() -> Vec<AudioDeviceOption> {
3683    #[cfg(target_os = "freebsd")]
3684    {
3685        maolan_engine::audio_devices::discover_freebsd_audio_devices()
3686            .into_iter()
3687            .map(AudioDeviceOption::from)
3688            .collect()
3689    }
3690    #[cfg(target_os = "linux")]
3691    {
3692        let mut output_devices = platform_linux::discover_alsa_output_devices();
3693        let mut input_devices = platform_linux::discover_alsa_input_devices();
3694        output_devices.append(&mut input_devices);
3695        output_devices.sort_by_key(|device| device.label.to_lowercase());
3696        output_devices.dedup_by(|a, b| a.id == b.id);
3697        output_devices
3698    }
3699    #[cfg(target_os = "openbsd")]
3700    {
3701        vec![simple_audio_device("default")]
3702    }
3703    #[cfg(target_os = "windows")]
3704    {
3705        vec![simple_audio_device("default")]
3706    }
3707    #[cfg(not(any(
3708        target_os = "linux",
3709        target_os = "freebsd",
3710        target_os = "openbsd",
3711        target_os = "windows"
3712    )))]
3713    {
3714        vec![simple_audio_device("default")]
3715    }
3716}
3717
3718#[cfg(target_os = "linux")]
3719mod platform_linux {
3720    use alsa::{
3721        Direction,
3722        pcm::{Access, Format, HwParams, PCM},
3723    };
3724
3725    const SAMPLE_RATE_CANDIDATES: [u32; 12] = [
3726        8_000, 11_025, 16_000, 22_050, 32_000, 44_100, 48_000, 88_200, 96_000, 176_400, 192_000,
3727        384_000,
3728    ];
3729
3730    fn read_alsa_card_labels() -> std::collections::HashMap<u32, String> {
3731        let mut labels = std::collections::HashMap::new();
3732        let Ok(contents) = std::fs::read_to_string("/proc/asound/cards") else {
3733            return labels;
3734        };
3735        for line in contents.lines() {
3736            let line = line.trim_start();
3737            let Some((num_str, rest)) = line.split_once(' ') else {
3738                continue;
3739            };
3740            let Ok(card) = num_str.parse::<u32>() else {
3741                continue;
3742            };
3743            let Some((_, desc)) = rest.split_once("]:") else {
3744                continue;
3745            };
3746            let desc = desc.trim();
3747            if !desc.is_empty() {
3748                labels.insert(card, desc.to_string());
3749            }
3750        }
3751        labels
3752    }
3753
3754    fn probe_alsa_supported_bits(device: &str, direction: Direction) -> Vec<usize> {
3755        let Ok(pcm) = PCM::new(device, direction, false) else {
3756            return Vec::new();
3757        };
3758        let Ok(hwp) = HwParams::any(&pcm) else {
3759            return Vec::new();
3760        };
3761        if hwp.set_access(Access::RWInterleaved).is_err() {
3762            return Vec::new();
3763        }
3764
3765        fn supports(hwp: &HwParams<'_>, fmt: Format) -> bool {
3766            hwp.test_format(fmt).is_ok()
3767        }
3768
3769        let candidates: Vec<(usize, Vec<Format>)> = vec![
3770            (32, vec![native_s32(), foreign_s32()]),
3771            (24, vec![native_s24(), foreign_s24()]),
3772            (16, vec![native_s16(), foreign_s16()]),
3773            (8, vec![Format::S8]),
3774        ];
3775
3776        let mut supported = Vec::new();
3777        for (bits, formats) in candidates {
3778            if formats.iter().any(|f| supports(&hwp, *f)) {
3779                supported.push(bits);
3780            }
3781        }
3782        supported
3783    }
3784
3785    fn probe_alsa_supported_sample_rates(device: &str, direction: Direction) -> Vec<i32> {
3786        let Ok(pcm) = PCM::new(device, direction, false) else {
3787            return Vec::new();
3788        };
3789        let Ok(hwp) = HwParams::any(&pcm) else {
3790            return Vec::new();
3791        };
3792        if hwp.set_access(Access::RWInterleaved).is_err() {
3793            return Vec::new();
3794        }
3795
3796        let mut supported = Vec::new();
3797        for rate in SAMPLE_RATE_CANDIDATES {
3798            if hwp.test_rate(rate).is_ok() {
3799                supported.push(rate as i32);
3800            }
3801        }
3802        supported
3803    }
3804
3805    #[cfg(target_endian = "little")]
3806    fn native_s16() -> Format {
3807        Format::S16LE
3808    }
3809    #[cfg(target_endian = "big")]
3810    fn native_s16() -> Format {
3811        Format::S16BE
3812    }
3813    #[cfg(target_endian = "little")]
3814    fn foreign_s16() -> Format {
3815        Format::S16BE
3816    }
3817    #[cfg(target_endian = "big")]
3818    fn foreign_s16() -> Format {
3819        Format::S16LE
3820    }
3821
3822    #[cfg(target_endian = "little")]
3823    fn native_s24() -> Format {
3824        Format::S24LE
3825    }
3826    #[cfg(target_endian = "big")]
3827    fn native_s24() -> Format {
3828        Format::S24BE
3829    }
3830    #[cfg(target_endian = "little")]
3831    fn foreign_s24() -> Format {
3832        Format::S24BE
3833    }
3834    #[cfg(target_endian = "big")]
3835    fn foreign_s24() -> Format {
3836        Format::S24LE
3837    }
3838
3839    #[cfg(target_endian = "little")]
3840    fn native_s32() -> Format {
3841        Format::S32LE
3842    }
3843    #[cfg(target_endian = "big")]
3844    fn native_s32() -> Format {
3845        Format::S32BE
3846    }
3847    #[cfg(target_endian = "little")]
3848    fn foreign_s32() -> Format {
3849        Format::S32BE
3850    }
3851    #[cfg(target_endian = "big")]
3852    fn foreign_s32() -> Format {
3853        Format::S32LE
3854    }
3855
3856    fn discover_alsa_devices(
3857        direction_marker: &str,
3858        direction: Direction,
3859    ) -> Vec<super::AudioDeviceOption> {
3860        let mut devices = Vec::new();
3861        let card_labels = read_alsa_card_labels();
3862        if let Ok(contents) = std::fs::read_to_string("/proc/asound/pcm") {
3863            for line in contents.lines() {
3864                let Some((card_dev, rest)) = line.split_once(':') else {
3865                    continue;
3866                };
3867                if !rest.contains(direction_marker) {
3868                    continue;
3869                }
3870                let mut parts = card_dev.trim().split('-');
3871                let (Some(card), Some(dev)) = (parts.next(), parts.next()) else {
3872                    continue;
3873                };
3874                let Ok(card) = card.parse::<u32>() else {
3875                    continue;
3876                };
3877                let Ok(dev) = dev.parse::<u32>() else {
3878                    continue;
3879                };
3880                let device_name = rest.split(':').next().unwrap_or("").trim();
3881                let card_label = card_labels
3882                    .get(&card)
3883                    .cloned()
3884                    .unwrap_or_else(|| format!("Card {card}"));
3885                let base_label = if device_name.is_empty() {
3886                    card_label
3887                } else {
3888                    format!("{card_label} - {device_name}")
3889                };
3890                let id = format!("hw:{card},{dev}");
3891                let label = format!("{base_label} (hw:{card},{dev})");
3892                let supported_bits = probe_alsa_supported_bits(&id, direction);
3893                let supported_sample_rates = {
3894                    let rates = probe_alsa_supported_sample_rates(&id, direction);
3895                    if rates.is_empty() {
3896                        super::fallback_sample_rates()
3897                    } else {
3898                        rates
3899                    }
3900                };
3901                let (supports_input, supports_output) = match direction {
3902                    Direction::Playback => (false, true),
3903                    Direction::Capture => (true, false),
3904                };
3905                devices.push(super::AudioDeviceOption::with_supported_direction_caps(
3906                    id,
3907                    label,
3908                    supported_bits,
3909                    supported_sample_rates,
3910                    supports_input,
3911                    supports_output,
3912                ));
3913            }
3914        }
3915        devices.sort_by_key(|a| a.label.to_lowercase());
3916        devices.dedup_by(|a, b| a.id == b.id);
3917        devices
3918    }
3919
3920    pub(crate) fn discover_alsa_output_devices() -> Vec<super::AudioDeviceOption> {
3921        discover_alsa_devices("playback", Direction::Playback)
3922    }
3923
3924    pub(crate) fn discover_alsa_input_devices() -> Vec<super::AudioDeviceOption> {
3925        discover_alsa_devices("capture", Direction::Capture)
3926    }
3927}
3928
3929fn simple_audio_device(id: impl Into<String>) -> AudioDeviceOption {
3930    let id = id.into();
3931    AudioDeviceOption::with_supported_caps(
3932        id.clone(),
3933        id,
3934        vec![32, 24, 16, 8],
3935        fallback_sample_rates(),
3936    )
3937}
3938
3939fn fallback_sample_rates() -> Vec<i32> {
3940    vec![
3941        8_000, 11_025, 16_000, 22_050, 32_000, 44_100, 48_000, 88_200, 96_000, 176_400, 192_000,
3942        384_000,
3943    ]
3944}
3945
3946fn fallback_bits() -> Vec<usize> {
3947    vec![32, 24, 16, 8]
3948}
3949
3950fn sample_rate_options(setup: &StartupSetup) -> Vec<i32> {
3951    if setup.audio_engine.is_jack() {
3952        return fallback_sample_rates();
3953    }
3954    setup
3955        .output_device
3956        .as_ref()
3957        .map(|device| device.supported_sample_rates.clone())
3958        .filter(|rates| !rates.is_empty())
3959        .unwrap_or_else(fallback_sample_rates)
3960}
3961
3962fn bit_options(setup: &StartupSetup) -> Vec<usize> {
3963    if setup.audio_engine.is_jack() {
3964        return fallback_bits();
3965    }
3966    setup
3967        .output_device
3968        .as_ref()
3969        .map(|device| {
3970            if device.supported_bits.is_empty() {
3971                fallback_bits()
3972            } else {
3973                device.supported_bits.clone()
3974            }
3975        })
3976        .unwrap_or_else(fallback_bits)
3977}
3978
3979fn default_audio_device(engine: AudioEngineOption) -> &'static str {
3980    if engine.is_jack() {
3981        return "jack";
3982    }
3983    #[cfg(target_os = "linux")]
3984    {
3985        "default"
3986    }
3987    #[cfg(target_os = "freebsd")]
3988    {
3989        "/dev/dsp"
3990    }
3991    #[cfg(target_os = "openbsd")]
3992    {
3993        "default"
3994    }
3995    #[cfg(target_os = "windows")]
3996    {
3997        "default"
3998    }
3999    #[cfg(not(any(
4000        target_os = "linux",
4001        target_os = "freebsd",
4002        target_os = "openbsd",
4003        target_os = "windows"
4004    )))]
4005    {
4006        "default"
4007    }
4008}
4009
4010async fn open_audio_dialog() -> Option<PathBuf> {
4011    rfd::FileDialog::new()
4012        .add_filter(
4013            "Audio",
4014            &["wav", "flac", "mp3", "ogg", "vorbis", "m4a", "aac", "alac"],
4015        )
4016        .pick_file()
4017}
4018
4019async fn save_audio_dialog(current: Option<PathBuf>) -> Option<PathBuf> {
4020    let mut dialog =
4021        rfd::FileDialog::new().add_filter("Maolan audio export", &["wav", "flac", "mp3", "ogg"]);
4022    if let Some(path) = current.as_ref() {
4023        if let Some(parent) = path.parent() {
4024            dialog = dialog.set_directory(parent);
4025        }
4026        if let Some(name) = path.file_name() {
4027            dialog = dialog.set_file_name(name.to_string_lossy());
4028        }
4029    }
4030    dialog.save_file()
4031}
4032
4033async fn close_confirmation_dialog() -> rfd::MessageDialogResult {
4034    rfd::AsyncMessageDialog::new()
4035        .set_title("Unsaved Changes")
4036        .set_description("You have unsaved changes. Save before closing?")
4037        .set_buttons(rfd::MessageButtons::YesNoCancel)
4038        .show()
4039        .await
4040}
4041
4042fn clip_samples(samples: &[f32], channels: usize, region: Option<AudioRegion>) -> Vec<f32> {
4043    let channels = channels.max(1);
4044    let Some(region) = region else {
4045        return samples.to_vec();
4046    };
4047    let frames = samples.len() / channels;
4048    let start = region.offset.min(frames);
4049    let end = start.saturating_add(region.length).min(frames);
4050    samples[start * channels..end * channels].to_vec()
4051}
4052
4053fn deinterleave(samples: &[f32], channels: usize) -> Vec<Vec<f32>> {
4054    let channels = channels.max(1);
4055    let frames = samples.len() / channels;
4056    let mut output = vec![Vec::with_capacity(frames); channels];
4057    for frame in samples.chunks_exact(channels) {
4058        for (channel, sample) in frame.iter().copied().enumerate() {
4059            output[channel].push(sample);
4060        }
4061    }
4062    output
4063}
4064
4065fn encode_format_for_path(path: &Path) -> Result<AudioEncodeFormat, String> {
4066    let ext = path
4067        .extension()
4068        .and_then(|ext| ext.to_str())
4069        .map(str::to_ascii_lowercase)
4070        .ok_or_else(|| {
4071            String::from("Save path needs an audio extension: wav, flac, mp3, or ogg.")
4072        })?;
4073
4074    match ext.as_str() {
4075        "wav" => Ok(AudioEncodeFormat::Wav(WavBitDepth::Float32)),
4076        "flac" => Ok(AudioEncodeFormat::Flac(24)),
4077        "mp3" => Ok(AudioEncodeFormat::Mp3),
4078        "ogg" => Ok(AudioEncodeFormat::OggFlac(24)),
4079        _ => Err(format!(
4080            "Cannot save '{}': supported save formats are wav, flac, mp3, and ogg.",
4081            path.display()
4082        )),
4083    }
4084}
4085
4086fn app_style(_theme: &Theme) -> container::Style {
4087    container::Style {
4088        background: Some(Color::from_rgb(0.055, 0.06, 0.075).into()),
4089        text_color: Some(Color::from_rgb(0.88, 0.90, 0.94)),
4090        ..container::Style::default()
4091    }
4092}
4093
4094fn panel_style(_theme: &Theme) -> container::Style {
4095    container::Style {
4096        border: maolan_widgets::iced::Border {
4097            color: Color::from_rgb(0.18, 0.20, 0.24),
4098            width: 1.0,
4099            radius: 4.0.into(),
4100        },
4101        ..container::Style::default()
4102    }
4103}
4104
4105fn toolbar_style(_theme: &Theme) -> container::Style {
4106    container::Style {
4107        background: Some(Background::Color(Color::from_rgb(0.075, 0.08, 0.095))),
4108        border: Border {
4109            color: Color::from_rgb(0.16, 0.18, 0.22),
4110            width: 1.0,
4111            radius: 2.0.into(),
4112        },
4113        ..container::Style::default()
4114    }
4115}
4116
4117fn playhead_style(_theme: &Theme) -> container::Style {
4118    container::Style {
4119        text_color: Some(Color::from_rgb(0.92, 0.92, 0.92)),
4120        background: Some(Background::Color(Color::from_rgb(0.10, 0.10, 0.115))),
4121        border: Border {
4122            color: Color::from_rgb(0.22, 0.24, 0.28),
4123            width: 1.0,
4124            radius: 2.0.into(),
4125        },
4126        ..container::Style::default()
4127    }
4128}
4129
4130fn playhead_active_style(_theme: &Theme) -> container::Style {
4131    container::Style {
4132        text_color: Some(Color::from_rgb(0.92, 0.98, 0.92)),
4133        background: Some(Background::Color(Color::from_rgb(0.10, 0.16, 0.12))),
4134        border: Border {
4135            color: Color::from_rgb(0.22, 0.45, 0.26),
4136            width: 1.0,
4137            radius: 2.0.into(),
4138        },
4139        ..container::Style::default()
4140    }
4141}
4142
4143fn toolbar_button_style(theme: &Theme, status: button::Status) -> button::Style {
4144    let mut style = button::secondary(theme, status);
4145    style.border.radius = 3.0.into();
4146    style.border.width = 1.0;
4147    style.border.color = Color::from_rgb(0.18, 0.20, 0.24);
4148    style.text_color = Color::from_rgb(0.92, 0.92, 0.92);
4149    style.background = Some(Background::Color(Color::TRANSPARENT));
4150    style
4151}
4152
4153fn tooltip_style(_theme: &Theme) -> container::Style {
4154    container::Style {
4155        text_color: Some(Color::from_rgb(0.94, 0.94, 0.94)),
4156        background: Some(Background::Color(Color::from_rgba(0.08, 0.08, 0.08, 0.96))),
4157        border: Border {
4158            color: Color::from_rgba(0.32, 0.32, 0.32, 1.0),
4159            width: 1.0,
4160            radius: 3.0.into(),
4161        },
4162        ..container::Style::default()
4163    }
4164}
4165
4166#[cfg(test)]
4167mod tests {
4168    use super::*;
4169
4170    #[test]
4171    fn deinterleave_splits_channels() {
4172        assert_eq!(
4173            deinterleave(&[1.0, 2.0, 3.0, 4.0], 2),
4174            vec![vec![1.0, 3.0], vec![2.0, 4.0]]
4175        );
4176    }
4177
4178    #[test]
4179    fn clip_samples_extracts_frame_range() {
4180        let samples = [1.0, 10.0, 2.0, 20.0, 3.0, 30.0, 4.0, 40.0];
4181        assert_eq!(
4182            clip_samples(
4183                &samples,
4184                2,
4185                Some(AudioRegion {
4186                    offset: 1,
4187                    length: 2
4188                })
4189            ),
4190            vec![2.0, 20.0, 3.0, 30.0]
4191        );
4192    }
4193
4194    #[test]
4195    fn encode_format_matches_extensions() {
4196        assert!(matches!(
4197            encode_format_for_path(Path::new("x.wav")).unwrap(),
4198            AudioEncodeFormat::Wav(WavBitDepth::Float32)
4199        ));
4200        assert!(matches!(
4201            encode_format_for_path(Path::new("x.flac")).unwrap(),
4202            AudioEncodeFormat::Flac(24)
4203        ));
4204        assert!(matches!(
4205            encode_format_for_path(Path::new("x.mp3")).unwrap(),
4206            AudioEncodeFormat::Mp3
4207        ));
4208        assert!(matches!(
4209            encode_format_for_path(Path::new("x.ogg")).unwrap(),
4210            AudioEncodeFormat::OggFlac(24)
4211        ));
4212    }
4213
4214    #[test]
4215    fn apply_edit_to_samples_fades_in_region() {
4216        let mut samples = vec![1.0f32; 8];
4217        apply_edit_to_samples(
4218            &mut samples,
4219            1,
4220            AudioRegion {
4221                offset: 2,
4222                length: 4,
4223            },
4224            EditOperation::FadeIn,
4225        );
4226        assert_eq!(samples, vec![1.0, 1.0, 0.0, 0.25, 0.5, 0.75, 1.0, 1.0]);
4227    }
4228
4229    #[test]
4230    fn apply_edit_to_samples_fades_out_region() {
4231        let mut samples = vec![1.0f32; 8];
4232        apply_edit_to_samples(
4233            &mut samples,
4234            1,
4235            AudioRegion {
4236                offset: 2,
4237                length: 4,
4238            },
4239            EditOperation::FadeOut,
4240        );
4241        assert_eq!(samples, vec![1.0, 1.0, 0.75, 0.5, 0.25, 0.0, 1.0, 1.0]);
4242    }
4243
4244    #[test]
4245    fn apply_edit_to_samples_adjusts_volume_in_region() {
4246        let mut samples = vec![0.5f32; 8];
4247        apply_edit_to_samples(
4248            &mut samples,
4249            1,
4250            AudioRegion {
4251                offset: 2,
4252                length: 4,
4253            },
4254            EditOperation::IncreaseVolume,
4255        );
4256        let expected_gain = 10.0f32.powf(1.0 / 20.0);
4257        for (index, sample) in samples.iter().enumerate() {
4258            let expected = if (2..6).contains(&index) {
4259                0.5 * expected_gain
4260            } else {
4261                0.5
4262            };
4263            assert!((sample - expected).abs() < 1.0e-5, "index {index}");
4264        }
4265    }
4266
4267    #[test]
4268    fn apply_edit_to_samples_decreases_volume_in_region() {
4269        let mut samples = vec![1.0f32; 8];
4270        apply_edit_to_samples(
4271            &mut samples,
4272            1,
4273            AudioRegion {
4274                offset: 2,
4275                length: 4,
4276            },
4277            EditOperation::DecreaseVolume,
4278        );
4279        let expected_gain = 10.0f32.powf(-1.0 / 20.0);
4280        for (index, sample) in samples.iter().enumerate() {
4281            let expected = if (2..6).contains(&index) {
4282                expected_gain
4283            } else {
4284                1.0
4285            };
4286            assert!((sample - expected).abs() < 1.0e-5, "index {index}");
4287        }
4288    }
4289
4290    #[test]
4291    fn apply_edit_to_samples_clamps_region_to_bounds() {
4292        let mut samples = vec![1.0f32; 4];
4293        apply_edit_to_samples(
4294            &mut samples,
4295            1,
4296            AudioRegion {
4297                offset: 2,
4298                length: 100,
4299            },
4300            EditOperation::FadeIn,
4301        );
4302        assert_eq!(samples, vec![1.0, 1.0, 0.0, 0.5]);
4303    }
4304
4305    #[test]
4306    fn history_tracks_dirty_state_against_save_point() {
4307        let mut history = EditHistory::new(DocumentSnapshot {
4308            samples: vec![1.0f32],
4309            edits: AudioEdits::default(),
4310            markers: Vec::new(),
4311        });
4312        assert!(!history.is_dirty());
4313
4314        history.record(
4315            DocumentSnapshot {
4316                samples: vec![1.0f32],
4317                edits: AudioEdits::default(),
4318                markers: Vec::new(),
4319            },
4320            DocumentSnapshot {
4321                samples: vec![2.0f32],
4322                edits: AudioEdits::default(),
4323                markers: Vec::new(),
4324            },
4325        );
4326        assert!(history.is_dirty());
4327
4328        history.mark_saved();
4329        assert!(!history.is_dirty());
4330    }
4331
4332    #[test]
4333    fn history_undo_redo_restores_states() {
4334        let mut history = EditHistory::new(DocumentSnapshot {
4335            samples: vec![1.0f32],
4336            edits: AudioEdits::default(),
4337            markers: Vec::new(),
4338        });
4339        history.record(
4340            DocumentSnapshot {
4341                samples: vec![1.0f32],
4342                edits: AudioEdits::default(),
4343                markers: Vec::new(),
4344            },
4345            DocumentSnapshot {
4346                samples: vec![2.0f32],
4347                edits: AudioEdits::default(),
4348                markers: Vec::new(),
4349            },
4350        );
4351        history.record(
4352            DocumentSnapshot {
4353                samples: vec![2.0f32],
4354                edits: AudioEdits::default(),
4355                markers: Vec::new(),
4356            },
4357            DocumentSnapshot {
4358                samples: vec![3.0f32],
4359                edits: AudioEdits::default(),
4360                markers: Vec::new(),
4361            },
4362        );
4363
4364        let undone = history.undo().expect("can undo");
4365        assert_eq!(undone.samples, vec![2.0f32]);
4366
4367        let undone_again = history.undo().expect("can undo again");
4368        assert_eq!(undone_again.samples, vec![1.0f32]);
4369        assert!(history.undo().is_none());
4370
4371        let redone = history.redo().expect("can redo");
4372        assert_eq!(redone.samples, vec![2.0f32]);
4373    }
4374
4375    #[test]
4376    fn history_record_clears_redo_stack() {
4377        let mut history = EditHistory::new(DocumentSnapshot {
4378            samples: vec![1.0f32],
4379            edits: AudioEdits::default(),
4380            markers: Vec::new(),
4381        });
4382        history.record(
4383            DocumentSnapshot {
4384                samples: vec![1.0f32],
4385                edits: AudioEdits::default(),
4386                markers: Vec::new(),
4387            },
4388            DocumentSnapshot {
4389                samples: vec![2.0f32],
4390                edits: AudioEdits::default(),
4391                markers: Vec::new(),
4392            },
4393        );
4394        history.undo();
4395        history.record(
4396            DocumentSnapshot {
4397                samples: vec![1.0f32],
4398                edits: AudioEdits::default(),
4399                markers: Vec::new(),
4400            },
4401            DocumentSnapshot {
4402                samples: vec![3.0f32],
4403                edits: AudioEdits::default(),
4404                markers: Vec::new(),
4405            },
4406        );
4407        assert!(history.redo().is_none());
4408    }
4409
4410    fn test_document(preview: Vec<f32>, channels: usize) -> AudioDocument {
4411        let channel_samples = deinterleave(&preview, channels);
4412        AudioDocument {
4413            source_path: PathBuf::new(),
4414            save_path: None,
4415            samples: preview.clone(),
4416            preview_samples: preview,
4417            channels,
4418            sample_rate: 48_000,
4419            channel_samples,
4420            peak: 1.0,
4421            clip_region: None,
4422            edits: AudioEdits::default(),
4423            markers: Vec::new(),
4424        }
4425    }
4426
4427    #[test]
4428    fn next_zero_crossing_finds_positive_to_negative_crossing() {
4429        let audio = test_document(vec![1.0f32, -1.0], 1);
4430        assert_eq!(audio.next_zero_crossing_frame(0), Some(1));
4431    }
4432
4433    #[test]
4434    fn next_zero_crossing_finds_negative_to_positive_crossing() {
4435        let audio = test_document(vec![-1.0f32, -0.5, 0.5, 1.0], 1);
4436        assert_eq!(audio.next_zero_crossing_frame(0), Some(2));
4437    }
4438
4439    #[test]
4440    fn next_zero_crossing_detects_exact_zero_sample() {
4441        let audio = test_document(vec![1.0f32, 0.0, -1.0], 1);
4442        assert_eq!(audio.next_zero_crossing_frame(0), Some(1));
4443    }
4444
4445    #[test]
4446    fn next_zero_crossing_returns_none_when_no_crossing() {
4447        let audio = test_document(vec![0.1f32, 0.2, 0.3], 1);
4448        assert_eq!(audio.next_zero_crossing_frame(0), None);
4449    }
4450
4451    #[test]
4452    fn next_zero_crossing_respects_start_frame() {
4453        let audio = test_document(vec![1.0f32, -1.0, 1.0, -1.0], 1);
4454        assert_eq!(audio.next_zero_crossing_frame(1), Some(2));
4455    }
4456
4457    #[test]
4458    fn next_zero_crossing_returns_none_past_end() {
4459        let audio = test_document(vec![1.0f32, -1.0], 1);
4460        assert_eq!(audio.next_zero_crossing_frame(1), None);
4461    }
4462
4463    #[test]
4464    fn next_zero_crossing_averages_multiple_channels() {
4465        // Left: [1.0, 1.0], Right: [1.0, -1.0]; mixed: [1.0, 0.0]
4466        let audio = test_document(vec![1.0f32, 1.0, 1.0, -1.0], 2);
4467        assert_eq!(audio.next_zero_crossing_frame(0), Some(1));
4468    }
4469
4470    fn app_with_markers(markers: Vec<(usize, String)>, frames: usize) -> EditApp {
4471        let mut audio = test_document(vec![0.0f32; frames], 1);
4472        audio.markers = markers;
4473        EditApp {
4474            standalone_ready: true,
4475            audio: Some(audio),
4476            ..EditApp::default()
4477        }
4478    }
4479
4480    #[test]
4481    fn playhead_moved_sets_playhead_from_ratio() {
4482        let mut app = app_with_markers(Vec::new(), 100);
4483        let _ = update(&mut app, Message::PlayheadMoved(0.25));
4484        assert_eq!(app.playhead_samples, 25);
4485    }
4486
4487    #[test]
4488    fn embedded_play_without_engine_starts_preview_playback() {
4489        let mut app = EditApp {
4490            standalone_ready: false,
4491            audio: Some(test_document(vec![0.0f32; 100], 1)),
4492            ..EditApp::default()
4493        };
4494
4495        let _ = update(&mut app, Message::Play);
4496
4497        assert!(app.playing);
4498        assert_eq!(app.status, "Playing preview.");
4499    }
4500
4501    #[test]
4502    fn select_marker_region_selects_between_markers() {
4503        let mut app = app_with_markers(vec![(20, "A".to_string()), (60, "B".to_string())], 100);
4504        let _ = update(&mut app, Message::SelectMarkerRegion(0.4));
4505        assert_eq!(app.selection_samples, Some((20, 60)));
4506    }
4507
4508    #[test]
4509    fn select_marker_region_selects_start_to_first_marker() {
4510        let mut app = app_with_markers(vec![(50, "A".to_string())], 100);
4511        let _ = update(&mut app, Message::SelectMarkerRegion(0.25));
4512        assert_eq!(app.selection_samples, Some((0, 50)));
4513    }
4514
4515    #[test]
4516    fn select_marker_region_selects_last_marker_to_end() {
4517        let mut app = app_with_markers(vec![(50, "A".to_string())], 100);
4518        let _ = update(&mut app, Message::SelectMarkerRegion(0.75));
4519        assert_eq!(app.selection_samples, Some((50, 100)));
4520    }
4521
4522    #[test]
4523    fn detect_markers_places_boundaries_around_sound_regions() {
4524        // 0..10 silence, 10..20 sound, 20..30 silence, 30..40 sound, 40..50 silence.
4525        let mut samples = vec![0.0f32; 10];
4526        samples.extend(vec![0.8f32; 10]);
4527        samples.extend(vec![0.0f32; 10]);
4528        samples.extend(vec![0.8f32; 10]);
4529        samples.extend(vec![0.0f32; 10]);
4530        let markers = detect_markers(&samples, 1, -60.0, 5);
4531        assert_eq!(
4532            markers,
4533            vec![
4534                (10, "Region 1".to_string()),
4535                (20, "Region 2".to_string()),
4536                (30, "Region 3".to_string()),
4537                (40, "Region 4".to_string()),
4538            ]
4539        );
4540    }
4541
4542    #[test]
4543    fn detect_markers_ignores_short_silence() {
4544        // 0..10 silence, 10..15 sound, 15..17 short silence, 17..25 sound, 25..35 silence.
4545        let mut samples = vec![0.0f32; 10];
4546        samples.extend(vec![0.8f32; 5]);
4547        samples.extend(vec![0.0f32; 2]);
4548        samples.extend(vec![0.8f32; 8]);
4549        samples.extend(vec![0.0f32; 10]);
4550        let markers = detect_markers(&samples, 1, -60.0, 5);
4551        assert_eq!(
4552            markers,
4553            vec![(10, "Region 1".to_string()), (25, "Region 2".to_string()),]
4554        );
4555    }
4556
4557    #[test]
4558    fn detect_markers_all_silence_returns_empty() {
4559        let samples = vec![0.0f32; 100];
4560        let markers = detect_markers(&samples, 1, -60.0, 5);
4561        assert!(markers.is_empty());
4562    }
4563
4564    #[test]
4565    fn detect_markers_all_sound_returns_single_region() {
4566        let samples = vec![0.8f32; 100];
4567        let markers = detect_markers(&samples, 1, -60.0, 5);
4568        assert_eq!(
4569            markers,
4570            vec![(0, "Region 1".to_string()), (100, "Region 2".to_string()),]
4571        );
4572    }
4573
4574    #[test]
4575    fn detect_markers_confirm_adds_detected_markers() {
4576        let mut samples = vec![0.0f32; 10];
4577        samples.extend(vec![0.8f32; 10]);
4578        samples.extend(vec![0.0f32; 10]);
4579        let audio = test_document(samples, 1);
4580        let mut app = EditApp {
4581            standalone_ready: true,
4582            audio: Some(audio),
4583            detect_markers_dialog: Some(DetectMarkersDialog {
4584                threshold_db: String::from("-60.0"),
4585                silence_samples: String::from("5"),
4586            }),
4587            ..EditApp::default()
4588        };
4589        let _ = update(&mut app, Message::DetectMarkersConfirm);
4590        assert!(app.detect_markers_dialog.is_none());
4591        assert_eq!(
4592            app.audio.as_ref().unwrap().markers,
4593            vec![(10, "Region 1".to_string()), (20, "Region 2".to_string()),]
4594        );
4595    }
4596
4597    #[test]
4598    fn detect_markers_confirm_rejects_invalid_input() {
4599        let audio = test_document(vec![0.8f32; 10], 1);
4600        let mut app = EditApp {
4601            standalone_ready: true,
4602            audio: Some(audio),
4603            detect_markers_dialog: Some(DetectMarkersDialog {
4604                threshold_db: String::from("not a number"),
4605                silence_samples: String::from("5"),
4606            }),
4607            ..EditApp::default()
4608        };
4609        let _ = update(&mut app, Message::DetectMarkersConfirm);
4610        assert!(app.audio.as_ref().unwrap().markers.is_empty());
4611        assert!(app.status.contains("Invalid"));
4612    }
4613
4614    #[test]
4615    fn detect_markers_cancel_closes_dialog() {
4616        let mut app = EditApp {
4617            detect_markers_dialog: Some(DetectMarkersDialog::default()),
4618            ..EditApp::default()
4619        };
4620        let _ = update(&mut app, Message::DetectMarkersCancel);
4621        assert!(app.detect_markers_dialog.is_none());
4622    }
4623
4624    #[test]
4625    fn selection_resize_moves_start_when_clicked_before_range() {
4626        let mut app = app_with_markers(Vec::new(), 100);
4627        app.selection_samples = Some((40, 80));
4628        let _ = update(&mut app, Message::SelectionResize(0.1));
4629        assert_eq!(app.selection_samples, Some((10, 80)));
4630    }
4631
4632    #[test]
4633    fn selection_resize_moves_end_when_clicked_after_range() {
4634        let mut app = app_with_markers(Vec::new(), 100);
4635        app.selection_samples = Some((40, 80));
4636        let _ = update(&mut app, Message::SelectionResize(0.95));
4637        assert_eq!(app.selection_samples, Some((40, 95)));
4638    }
4639
4640    #[test]
4641    fn selection_resize_moves_nearest_edge_when_clicked_inside_range() {
4642        let mut app = app_with_markers(Vec::new(), 100);
4643        app.selection_samples = Some((20, 80));
4644        let _ = update(&mut app, Message::SelectionResize(0.3));
4645        assert_eq!(app.selection_samples, Some((30, 80)));
4646
4647        let _ = update(&mut app, Message::SelectionResize(0.7));
4648        assert_eq!(app.selection_samples, Some((30, 70)));
4649    }
4650
4651    #[test]
4652    fn selection_resize_ignored_when_no_selection() {
4653        let mut app = app_with_markers(Vec::new(), 100);
4654        let _ = update(&mut app, Message::SelectionResize(0.5));
4655        assert_eq!(app.selection_samples, None);
4656    }
4657
4658    #[test]
4659    fn delete_selection_removes_range_and_shifts_markers() {
4660        let mut app = app_with_markers(vec![(1, "A".to_string()), (8, "B".to_string())], 10);
4661        let audio = app.audio.as_mut().unwrap();
4662        audio.samples = (0..10).map(|i| i as f32).collect();
4663        audio.rebuild_preview();
4664        app.selection_samples = Some((3, 6));
4665        let _ = update(&mut app, Message::DeleteSelection);
4666        let audio = app.audio.as_ref().unwrap();
4667        assert_eq!(audio.frames(), 7);
4668        assert_eq!(audio.samples, vec![0.0, 1.0, 2.0, 6.0, 7.0, 8.0, 9.0]);
4669        assert_eq!(
4670            audio.markers,
4671            vec![(1, "A".to_string()), (5, "B".to_string())]
4672        );
4673        assert!(app.selection_samples.is_none());
4674        assert!(app.history.is_dirty());
4675    }
4676
4677    #[test]
4678    fn delete_selection_ignored_when_nothing_selected() {
4679        let mut app = app_with_markers(vec![(1, "A".to_string())], 5);
4680        let audio = app.audio.as_mut().unwrap();
4681        audio.samples = vec![0.0, 1.0, 2.0, 3.0, 4.0];
4682        audio.rebuild_preview();
4683        let original = audio.samples.clone();
4684        let _ = update(&mut app, Message::DeleteSelection);
4685        let audio = app.audio.as_ref().unwrap();
4686        assert_eq!(audio.samples, original);
4687        assert_eq!(audio.markers, vec![(1, "A".to_string())]);
4688        assert!(!app.history.is_dirty());
4689    }
4690
4691    #[test]
4692    fn delete_selection_undo_restores_document() {
4693        let mut app = app_with_markers(vec![(1, "A".to_string()), (8, "B".to_string())], 10);
4694        let audio = app.audio.as_mut().unwrap();
4695        audio.samples = (0..10).map(|i| i as f32).collect();
4696        audio.rebuild_preview();
4697        let original_samples = audio.samples.clone();
4698        let original_markers = audio.markers.clone();
4699        app.selection_samples = Some((3, 6));
4700        let _ = update(&mut app, Message::DeleteSelection);
4701        assert!(app.history.is_dirty());
4702        let _ = update(&mut app, Message::Undo);
4703        let audio = app.audio.as_ref().unwrap();
4704        assert_eq!(audio.samples, original_samples);
4705        assert_eq!(audio.markers, original_markers);
4706        assert!(!app.history.is_dirty());
4707    }
4708
4709    #[test]
4710    fn marker_ranges_split_at_sorted_markers() {
4711        let markers = vec![(50, "A".to_string()), (20, "B".to_string())];
4712        assert_eq!(
4713            marker_ranges(&markers, 100),
4714            vec![(0, 20), (20, 50), (50, 100)]
4715        );
4716    }
4717
4718    #[test]
4719    fn marker_ranges_ignores_out_of_bounds_markers() {
4720        let markers = vec![(150, "A".to_string())];
4721        assert_eq!(marker_ranges(&markers, 100), vec![(0, 100)]);
4722    }
4723
4724    #[test]
4725    fn marker_range_samples_extracts_interleaved_range() {
4726        let audio = test_document(vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], 2);
4727        assert_eq!(marker_range_samples(&audio, 0, 2), vec![1.0, 2.0, 3.0, 4.0]);
4728    }
4729
4730    #[test]
4731    fn export_filename_includes_index_and_extension() {
4732        assert_eq!(export_filename("track", 7, "wav"), "track_007.wav");
4733    }
4734
4735    #[test]
4736    fn export_encode_format_maps_formats() {
4737        assert!(matches!(
4738            export_encode_format(ExportFormat::Wav, ExportBitDepth::Bits24),
4739            AudioEncodeFormat::Wav(WavBitDepth::Int24)
4740        ));
4741        assert!(matches!(
4742            export_encode_format(ExportFormat::Flac, ExportBitDepth::Bits16),
4743            AudioEncodeFormat::Flac(16)
4744        ));
4745        assert!(matches!(
4746            export_encode_format(ExportFormat::OggFlac, ExportBitDepth::Bits32),
4747            AudioEncodeFormat::OggFlac(32)
4748        ));
4749        assert!(matches!(
4750            export_encode_format(ExportFormat::Mp3, ExportBitDepth::Bits24),
4751            AudioEncodeFormat::Mp3
4752        ));
4753    }
4754
4755    #[test]
4756    fn resample_interleaved_identity_when_rates_match() {
4757        let samples = vec![0.1f32, 0.2, 0.3, 0.4, 0.5, 0.6];
4758        let output = resample_interleaved(&samples, 2, 48_000, 48_000).unwrap();
4759        assert_eq!(output, samples);
4760    }
4761
4762    #[test]
4763    fn resample_interleaved_changes_length_when_rates_differ() {
4764        let samples: Vec<f32> = (0..960).map(|i| (i as f32 / 960.0).sin()).collect();
4765        let output = resample_interleaved(&samples, 1, 48_000, 24_000).unwrap();
4766        assert!(!output.is_empty());
4767        assert!(output.len() < samples.len());
4768    }
4769
4770    #[tokio::test]
4771    async fn export_marker_ranges_creates_files() {
4772        let dir = std::env::temp_dir().join(format!(
4773            "maolan-edit-export-test-{}",
4774            std::time::UNIX_EPOCH.elapsed().unwrap().as_secs()
4775        ));
4776        let _ = std::fs::remove_dir_all(&dir);
4777        let mut audio = test_document(vec![0.5f32; 48_000], 1);
4778        audio.source_path = PathBuf::from("test_track.wav");
4779        audio.markers = vec![(12_000, "A".to_string()), (36_000, "B".to_string())];
4780        let result = export_marker_ranges(
4781            dir.clone(),
4782            audio,
4783            ExportFormat::Wav,
4784            ExportBitDepth::Bits16,
4785            48_000,
4786        )
4787        .await;
4788        assert_eq!(result.unwrap(), 3);
4789        assert!(dir.join("test_track_001.wav").exists());
4790        assert!(dir.join("test_track_002.wav").exists());
4791        assert!(dir.join("test_track_003.wav").exists());
4792        let _ = std::fs::remove_dir_all(&dir);
4793    }
4794
4795    #[test]
4796    fn preferences_save_to_path_preserves_other_keys() {
4797        let dir = std::env::temp_dir().join(format!(
4798            "maolan-edit-prefs-preserve-{}",
4799            std::time::UNIX_EPOCH.elapsed().unwrap().as_secs()
4800        ));
4801        let _ = std::fs::remove_dir_all(&dir);
4802        let config_path = dir.join("config.toml");
4803        std::fs::create_dir_all(&dir).unwrap();
4804        std::fs::write(
4805            &config_path,
4806            "existing_key = \"keep me\"\ndefault_output_device_id = \"old\"\n",
4807        )
4808        .unwrap();
4809
4810        let preferences = EditorPreferences {
4811            default_output_device_id: Some(String::from("new_out")),
4812            default_input_device_id: Some(String::from("new_in")),
4813        };
4814        preferences.save_to_path(&config_path).unwrap();
4815
4816        let saved = std::fs::read_to_string(&config_path).unwrap();
4817        assert!(saved.contains("existing_key = \"keep me\""));
4818        assert!(saved.contains("default_output_device_id = \"new_out\""));
4819        assert!(saved.contains("default_input_device_id = \"new_in\""));
4820        let _ = std::fs::remove_dir_all(&dir);
4821    }
4822
4823    #[test]
4824    fn preferences_save_removes_empty_ids() {
4825        let dir = std::env::temp_dir().join(format!(
4826            "maolan-edit-prefs-remove-{}",
4827            std::time::UNIX_EPOCH.elapsed().unwrap().as_secs()
4828        ));
4829        let _ = std::fs::remove_dir_all(&dir);
4830        let config_path = dir.join("config.toml");
4831        std::fs::create_dir_all(&dir).unwrap();
4832        std::fs::write(
4833            &config_path,
4834            "default_output_device_id = \"old\"\ndefault_input_device_id = \"old\"\n",
4835        )
4836        .unwrap();
4837
4838        let preferences = EditorPreferences {
4839            default_output_device_id: None,
4840            default_input_device_id: None,
4841        };
4842        preferences.save_to_path(&config_path).unwrap();
4843
4844        let saved = std::fs::read_to_string(&config_path).unwrap();
4845        assert!(!saved.contains("default_output_device_id"));
4846        assert!(!saved.contains("default_input_device_id"));
4847        let _ = std::fs::remove_dir_all(&dir);
4848    }
4849
4850    #[test]
4851    fn preferences_dialog_opens_from_setup() {
4852        let mut app = app_with_markers(Vec::new(), 100);
4853        app.setup.output_devices = vec![AudioDeviceOption::with_supported_caps(
4854            "out1",
4855            "Out One",
4856            vec![32],
4857            vec![48_000],
4858        )];
4859        app.setup.input_devices = vec![AudioDeviceOption::with_supported_caps(
4860            "in1",
4861            "In One",
4862            vec![32],
4863            vec![48_000],
4864        )];
4865        app.setup.output_device = Some(app.setup.output_devices[0].clone());
4866        app.setup.input_device = Some(app.setup.input_devices[0].clone());
4867        let _ = update(&mut app, Message::PreferencesDialog);
4868        let dialog = app.preferences_dialog.as_ref().unwrap();
4869        assert_eq!(dialog.output_devices.len(), 1);
4870        assert_eq!(dialog.input_devices.len(), 1);
4871        assert_eq!(dialog.output_device.as_ref().unwrap().id, "out1");
4872        assert_eq!(dialog.input_device.as_ref().unwrap().id, "in1");
4873    }
4874
4875    #[test]
4876    fn preferences_save_updates_setup() {
4877        let mut app = app_with_markers(Vec::new(), 100);
4878        let out_devices = vec![
4879            AudioDeviceOption::with_supported_caps("out1", "Out One", vec![32], vec![48_000]),
4880            AudioDeviceOption::with_supported_caps("out2", "Out Two", vec![32], vec![48_000]),
4881        ];
4882        let in_devices = vec![
4883            AudioDeviceOption::with_supported_caps("in1", "In One", vec![32], vec![48_000]),
4884            AudioDeviceOption::with_supported_caps("in2", "In Two", vec![32], vec![48_000]),
4885        ];
4886        app.setup.output_devices = out_devices.clone();
4887        app.setup.input_devices = in_devices.clone();
4888        app.preferences_dialog = Some(PreferencesDialog::from_setup(&app.setup));
4889        let _ = update(
4890            &mut app,
4891            Message::PreferencesOutputDeviceSelected(out_devices[1].clone()),
4892        );
4893        let _ = update(
4894            &mut app,
4895            Message::PreferencesInputDeviceSelected(in_devices[1].clone()),
4896        );
4897        let _ = update(&mut app, Message::PreferencesSave);
4898        assert!(app.preferences_dialog.is_none());
4899        assert_eq!(app.setup.output_device.as_ref().unwrap().id, "out2");
4900        assert_eq!(app.setup.input_device.as_ref().unwrap().id, "in2");
4901    }
4902
4903    #[test]
4904    fn preferences_cancel_closes_dialog() {
4905        let mut app = EditApp {
4906            preferences_dialog: Some(PreferencesDialog::from_setup(&StartupSetup::default())),
4907            ..EditApp::default()
4908        };
4909        let _ = update(&mut app, Message::PreferencesCancel);
4910        assert!(app.preferences_dialog.is_none());
4911    }
4912
4913    #[test]
4914    fn audio_device_option_equality_compares_id_only() {
4915        let a = AudioDeviceOption::with_supported_caps("dev", "A", vec![16], vec![44_100]);
4916        let b = AudioDeviceOption::with_supported_caps("dev", "B", vec![32], vec![48_000]);
4917        assert_eq!(a, b);
4918    }
4919
4920    #[cfg(target_os = "freebsd")]
4921    #[test]
4922    fn audio_setup_state_selects_saved_device_from_discovered_list() {
4923        let mut app = EditApp::default();
4924        app.setup.audio_engine = AudioEngineOption::Oss;
4925        app.setup.output_devices = vec![AudioDeviceOption::with_oss_caps(
4926            "/dev/dsp0",
4927            "Out",
4928            vec![16, 24, 32],
4929            vec![44_100, 48_000],
4930            2,
4931            65_536,
4932        )];
4933        app.setup.output_device = Some(AudioDeviceOption::with_oss_caps(
4934            "/dev/dsp0",
4935            "Out",
4936            vec![32],
4937            vec![48_000],
4938            2,
4939            65_536,
4940        ));
4941        app.setup.input_devices = vec![AudioDeviceOption::with_oss_caps(
4942            "/dev/dsp1",
4943            "In",
4944            vec![16, 24, 32],
4945            vec![44_100, 48_000],
4946            2,
4947            65_536,
4948        )];
4949        app.setup.input_device = Some(AudioDeviceOption::with_oss_caps(
4950            "/dev/dsp1",
4951            "In",
4952            vec![32],
4953            vec![48_000],
4954            2,
4955            65_536,
4956        ));
4957
4958        let state = audio_setup_state(&app);
4959
4960        assert_eq!(
4961            state.selected_output_device.as_ref().map(|d| d.id.as_str()),
4962            Some("/dev/dsp0")
4963        );
4964        assert_eq!(
4965            state.selected_input_device.as_ref().map(|d| d.id.as_str()),
4966            Some("/dev/dsp1")
4967        );
4968        assert_eq!(
4969            state.selected_output_device,
4970            Some(app.setup.output_devices[0].clone())
4971        );
4972        assert_eq!(
4973            state.selected_input_device,
4974            Some(app.setup.input_devices[0].clone())
4975        );
4976    }
4977
4978    #[test]
4979    fn preferences_load_from_path_reads_device_ids() {
4980        let dir = std::env::temp_dir().join(format!(
4981            "maolan-edit-prefs-load-{}",
4982            std::time::UNIX_EPOCH.elapsed().unwrap().as_secs()
4983        ));
4984        let _ = std::fs::remove_dir_all(&dir);
4985        std::fs::create_dir_all(&dir).unwrap();
4986        let config_path = dir.join("edit.toml");
4987        let contents =
4988            "default_output_device_id = \"/dev/dsp2\"\ndefault_input_device_id = \"/dev/dsp3\"\n";
4989        std::fs::write(&config_path, contents).unwrap();
4990
4991        let preferences = EditorPreferences::load_from_path(&config_path);
4992
4993        assert_eq!(
4994            preferences.default_output_device_id.as_deref(),
4995            Some("/dev/dsp2")
4996        );
4997        assert_eq!(
4998            preferences.default_input_device_id.as_deref(),
4999            Some("/dev/dsp3")
5000        );
5001        let _ = std::fs::remove_dir_all(&dir);
5002    }
5003
5004    #[test]
5005    fn startup_setup_with_preferences_selects_saved_devices() {
5006        let preferences = EditorPreferences {
5007            default_output_device_id: Some(String::from("out2")),
5008            default_input_device_id: Some(String::from("in2")),
5009        };
5010        let output_devices = vec![
5011            AudioDeviceOption::with_supported_caps("out1", "Out One", vec![32], vec![48_000]),
5012            AudioDeviceOption::with_supported_caps("out2", "Out Two", vec![32], vec![48_000]),
5013        ];
5014        let input_devices = vec![
5015            AudioDeviceOption::with_supported_caps("in1", "In One", vec![32], vec![48_000]),
5016            AudioDeviceOption::with_supported_caps("in2", "In Two", vec![32], vec![48_000]),
5017        ];
5018
5019        let setup = StartupSetup::with_preferences(&preferences, output_devices, input_devices);
5020
5021        assert_eq!(
5022            setup.output_device.as_ref().map(|d| d.id.as_str()),
5023            Some("out2")
5024        );
5025        assert_eq!(
5026            setup.input_device.as_ref().map(|d| d.id.as_str()),
5027            Some("in2")
5028        );
5029    }
5030
5031    #[test]
5032    fn startup_setup_with_preferences_falls_back_to_first_device() {
5033        let preferences = EditorPreferences {
5034            default_output_device_id: Some(String::from("missing")),
5035            default_input_device_id: Some(String::from("missing")),
5036        };
5037        let output_devices = vec![AudioDeviceOption::with_supported_caps(
5038            "out1",
5039            "Out One",
5040            vec![32],
5041            vec![48_000],
5042        )];
5043        let input_devices = vec![AudioDeviceOption::with_supported_caps(
5044            "in1",
5045            "In One",
5046            vec![32],
5047            vec![48_000],
5048        )];
5049
5050        let setup = StartupSetup::with_preferences(&preferences, output_devices, input_devices);
5051
5052        assert_eq!(
5053            setup.output_device.as_ref().map(|d| d.id.as_str()),
5054            Some("out1")
5055        );
5056        assert_eq!(
5057            setup.input_device.as_ref().map(|d| d.id.as_str()),
5058            Some("in1")
5059        );
5060    }
5061}