Skip to main content

maolan_editor/
app.rs

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