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