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