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