Skip to main content

maolan_editor/
app.rs

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