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