1use std::{
2 fmt,
3 path::{Path, PathBuf},
4 sync::Arc,
5 time::{Duration, Instant},
6};
7
8use maolan_engine::audio_codec::{
9 AudioDither, AudioEncodeFormat, WavBitDepth, decode_audio_to_f32_interleaved_sync,
10 encode_audio_to_file,
11};
12use maolan_engine::{
13 client::Client as EngineClient,
14 history::{History as EngineHistory, UndoEntry},
15 kind::Kind,
16 message::{Action as EngineAction, Message as EngineMessage, generate_clip_id},
17};
18use maolan_widgets::iced::{
19 Background, Border, Color, Element, Length, Subscription, Task, Theme, keyboard, time,
20 widget::{
21 Id, Space, button, column, container, pick_list, progress_bar, row, text, text_input,
22 tooltip,
23 },
24 window,
25};
26use maolan_widgets::iced_aw::menu::DrawPath;
27use maolan_widgets::iced_fonts::lucide::{
28 arrow_down, arrow_right, arrow_up, fast_forward, flag, play, redo, rewind, square,
29 trending_down, trending_up, undo,
30};
31use maolan_widgets::waveform::SampleWaveform;
32use maolan_widgets::{
33 audio_setup::{AudioSetupAction, AudioSetupState, audio_setup},
34 menu::{menu_bar, menu_dropdown, menu_item, menu_items},
35 meters,
36};
37use rubato::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 toolbar_button(
2035 arrow_right().size(16),
2036 "Next zero crossing",
2037 Message::JumpToNextZeroCrossing
2038 ),
2039 container(text(label).size(14))
2040 .padding([4, 8])
2041 .style(if playing {
2042 playhead_active_style
2043 } else {
2044 playhead_style
2045 }),
2046 toolbar_button(trending_up().size(16), "Fade in", Message::FadeIn),
2047 toolbar_button(trending_down().size(16), "Fade out", Message::FadeOut),
2048 toolbar_button(
2049 flag().size(16),
2050 "Detect markers",
2051 Message::DetectMarkersDialog
2052 ),
2053 toolbar_button(
2054 arrow_up().size(16),
2055 "Increase volume",
2056 Message::IncreaseVolume
2057 ),
2058 toolbar_button(
2059 arrow_down().size(16),
2060 "Decrease volume",
2061 Message::DecreaseVolume
2062 ),
2063 Space::new().width(Length::Fill),
2064 ]
2065 .spacing(4)
2066 .align_y(maolan_widgets::iced::Alignment::Center),
2067 )
2068 .width(Length::Fill)
2069 .height(Length::Fixed(34.0))
2070 .padding([4, 8])
2071 .style(toolbar_style)
2072 .into()
2073}
2074
2075fn toolbar_for_app(app: &EditApp, play_disabled: bool) -> Element<'static, Message> {
2076 toolbar_with_playhead_options(playhead_label(app), app.playing, play_disabled)
2077}
2078
2079fn toolbar_button<'a>(
2080 icon: impl Into<Element<'a, Message>>,
2081 label: &'static str,
2082 message: Message,
2083) -> Element<'a, Message> {
2084 tooltip(
2085 button(icon)
2086 .width(Length::Fixed(30.0))
2087 .height(Length::Fixed(26.0))
2088 .style(toolbar_button_style)
2089 .on_press(message),
2090 container(text(label).size(12))
2091 .padding([4, 8])
2092 .style(tooltip_style),
2093 tooltip::Position::Bottom,
2094 )
2095 .gap(4)
2096 .into()
2097}
2098
2099fn toolbar_button_disabled<'a>(
2100 icon: impl Into<Element<'a, Message>>,
2101 label: &'static str,
2102) -> Element<'a, Message> {
2103 tooltip(
2104 button(icon)
2105 .width(Length::Fixed(30.0))
2106 .height(Length::Fixed(26.0))
2107 .style(toolbar_button_style),
2108 container(text(label).size(12))
2109 .padding([4, 8])
2110 .style(tooltip_style),
2111 tooltip::Position::Bottom,
2112 )
2113 .gap(4)
2114 .into()
2115}
2116
2117fn load_document(
2118 app: &mut EditApp,
2119 path: PathBuf,
2120 region: Option<AudioRegion>,
2121 _timeline_region: Option<AudioRegion>,
2122) -> Task<Message> {
2123 app.busy = true;
2124 app.busy_progress = 0.0;
2125 app.status = match region {
2126 Some(_) => format!("Opening clip from {}...", path.display()),
2127 None => format!("Opening {}...", path.display()),
2128 };
2129 Task::run(
2130 {
2131 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2132 std::thread::spawn(move || {
2133 let mut last_bucket = None;
2134 let mut last_status = String::new();
2135 let progress_tx = tx.clone();
2136 let result = AudioDocument::open_with_progress(path, region, |progress, status| {
2137 let progress = progress.clamp(0.0, 1.0);
2138 let bucket = (progress * 100.0).round() as u8;
2139 if last_bucket == Some(bucket) && last_status == status {
2140 return;
2141 }
2142 last_bucket = Some(bucket);
2143 last_status = status.to_string();
2144 let _ = progress_tx.send(Message::DocumentLoadProgress {
2145 progress,
2146 status: status.to_string(),
2147 });
2148 });
2149 let _ = tx.send(Message::DocumentLoaded(result));
2150 });
2151
2152 maolan_widgets::iced::futures::stream::unfold(rx, |mut rx| async move {
2153 rx.recv().await.map(|msg| (msg, rx))
2154 })
2155 },
2156 |msg| msg,
2157 )
2158}
2159
2160fn document_status(audio: &AudioDocument) -> String {
2161 match audio.clip_region {
2162 Some(region) => format!(
2163 "{} - clip {}..{} samples, {} ch, {} Hz, {} frames",
2164 audio.source_path.display(),
2165 region.offset,
2166 region.offset.saturating_add(region.length),
2167 audio.channels,
2168 audio.sample_rate,
2169 audio.frames()
2170 ),
2171 None => format!(
2172 "{} - {} ch, {} Hz, {} frames",
2173 audio.source_path.display(),
2174 audio.channels,
2175 audio.sample_rate,
2176 audio.frames()
2177 ),
2178 }
2179}
2180
2181fn progress_view(progress: f32) -> Element<'static, Message> {
2182 let progress = progress.clamp(0.0, 1.0);
2183 let percent = (progress * 100.0).round() as u8;
2184 row![
2185 container(progress_bar(0.0..=1.0, progress)).width(Length::Fill),
2186 text(format!("{percent}%"))
2187 .size(12)
2188 .width(Length::Fixed(44.0)),
2189 ]
2190 .spacing(8)
2191 .align_y(maolan_widgets::iced::Alignment::Center)
2192 .into()
2193}
2194
2195fn marker_name_input_id() -> Id {
2196 Id::new("edit-marker-name-input")
2197}
2198
2199fn detect_markers_threshold_input_id() -> Id {
2200 Id::new("edit-detect-markers-threshold-input")
2201}
2202
2203fn marker_dialog_view(dialog: &MarkerDialog) -> Element<'_, Message> {
2204 let can_confirm = !dialog.name.trim().is_empty();
2205 let confirm_button = if can_confirm {
2206 button("Create").on_press(Message::MarkerNameConfirm)
2207 } else {
2208 button("Create")
2209 };
2210
2211 container(
2212 column![
2213 text("Add Marker"),
2214 text_input("Enter marker name", &dialog.name)
2215 .id(marker_name_input_id())
2216 .on_input(Message::MarkerNameInput)
2217 .on_submit(Message::MarkerNameConfirm)
2218 .width(Length::Fill),
2219 row![
2220 confirm_button,
2221 button("Cancel")
2222 .on_press(Message::MarkerNameCancel)
2223 .style(button::secondary)
2224 ]
2225 .spacing(10),
2226 ]
2227 .spacing(10),
2228 )
2229 .style(|_theme| container::Style {
2230 border: Border {
2231 color: Color::from_rgba(0.34, 0.42, 0.56, 0.72),
2232 width: 1.0,
2233 ..Border::default()
2234 },
2235 background: Some(Background::Color(Color::from_rgb(0.12, 0.13, 0.16))),
2236 ..container::Style::default()
2237 })
2238 .padding(12)
2239 .width(Length::Fixed(320.0))
2240 .into()
2241}
2242
2243fn detect_markers_dialog_view(dialog: &DetectMarkersDialog) -> Element<'_, Message> {
2244 let threshold_valid = dialog.threshold_db.trim().parse::<f32>().is_ok();
2245 let silence_valid = dialog
2246 .silence_samples
2247 .trim()
2248 .parse::<usize>()
2249 .is_ok_and(|value| value > 0);
2250 let can_confirm = threshold_valid && silence_valid;
2251 let confirm_button = if can_confirm {
2252 button("Detect").on_press(Message::DetectMarkersConfirm)
2253 } else {
2254 button("Detect")
2255 };
2256
2257 container(
2258 column![
2259 text("Detect Markers"),
2260 text("Silence threshold (dB)").size(12),
2261 text_input("-60.0", &dialog.threshold_db)
2262 .id(detect_markers_threshold_input_id())
2263 .on_input(Message::DetectMarkersThresholdInput)
2264 .on_submit(Message::DetectMarkersConfirm)
2265 .width(Length::Fill),
2266 text("Silent samples").size(12),
2267 text_input("1000", &dialog.silence_samples)
2268 .on_input(Message::DetectMarkersSilenceSamplesInput)
2269 .on_submit(Message::DetectMarkersConfirm)
2270 .width(Length::Fill),
2271 row![
2272 confirm_button,
2273 button("Cancel")
2274 .on_press(Message::DetectMarkersCancel)
2275 .style(button::secondary)
2276 ]
2277 .spacing(10),
2278 ]
2279 .spacing(10),
2280 )
2281 .style(|_theme| container::Style {
2282 border: Border {
2283 color: Color::from_rgba(0.34, 0.42, 0.56, 0.72),
2284 width: 1.0,
2285 ..Border::default()
2286 },
2287 background: Some(Background::Color(Color::from_rgb(0.12, 0.13, 0.16))),
2288 ..container::Style::default()
2289 })
2290 .padding(12)
2291 .width(Length::Fixed(320.0))
2292 .into()
2293}
2294
2295fn export_markers_dialog_view(dialog: &ExportMarkersDialog) -> Element<'_, Message> {
2296 let directory_label = dialog
2297 .directory
2298 .as_ref()
2299 .map(|path| path.to_string_lossy().to_string())
2300 .unwrap_or_else(|| String::from("No directory selected"));
2301 let can_confirm = dialog.directory.is_some();
2302 let confirm_button = if can_confirm {
2303 button("Export").on_press(Message::ExportMarkersConfirm)
2304 } else {
2305 button("Export")
2306 };
2307 let show_bit_depth = dialog.format != ExportFormat::Mp3;
2308 let bit_depth_input: Element<'_, Message> = if show_bit_depth {
2309 pick_list(
2310 ExportBitDepth::ALL,
2311 Some(dialog.bit_depth),
2312 Message::ExportMarkersBitDepthSelected,
2313 )
2314 .width(Length::Fill)
2315 .into()
2316 } else {
2317 container(text("MP3 uses 16-bit PCM internally.").size(12))
2318 .width(Length::Fill)
2319 .into()
2320 };
2321
2322 container(
2323 column![
2324 text("Export Marker Ranges"),
2325 button("Choose Directory...")
2326 .on_press(Message::ExportMarkersDialog)
2327 .width(Length::Fill),
2328 text(directory_label).size(12),
2329 text("Format").size(12),
2330 pick_list(
2331 ExportFormat::ALL,
2332 Some(dialog.format),
2333 Message::ExportMarkersFormatSelected
2334 )
2335 .width(Length::Fill),
2336 text("Sample rate").size(12),
2337 pick_list(
2338 ExportSampleRate::ALL,
2339 Some(dialog.sample_rate),
2340 Message::ExportMarkersSampleRateSelected
2341 )
2342 .width(Length::Fill),
2343 text("Bit depth").size(12),
2344 bit_depth_input,
2345 row![
2346 confirm_button,
2347 button("Cancel")
2348 .on_press(Message::ExportMarkersCancel)
2349 .style(button::secondary)
2350 ]
2351 .spacing(10),
2352 ]
2353 .spacing(10),
2354 )
2355 .style(|_theme| container::Style {
2356 border: Border {
2357 color: Color::from_rgba(0.34, 0.42, 0.56, 0.72),
2358 width: 1.0,
2359 ..Border::default()
2360 },
2361 background: Some(Background::Color(Color::from_rgb(0.12, 0.13, 0.16))),
2362 ..container::Style::default()
2363 })
2364 .padding(12)
2365 .width(Length::Fixed(360.0))
2366 .into()
2367}
2368
2369fn preferences_dialog_view(dialog: &PreferencesDialog) -> Element<'_, Message> {
2370 const HAS_SEPARATE_AUDIO_INPUT_DEVICE: bool = cfg!(any(
2371 target_os = "freebsd",
2372 target_os = "linux",
2373 target_os = "openbsd",
2374 target_os = "windows"
2375 ));
2376 let show_input_device = !dialog.output_devices.is_empty() && HAS_SEPARATE_AUDIO_INPUT_DEVICE;
2377 let mut content = column![text("Preferences")].spacing(10);
2378 if show_input_device {
2379 content = content.push(
2380 row![
2381 text("Default input device:").width(Length::Fixed(160.0)),
2382 pick_list(
2383 dialog.input_devices.clone(),
2384 dialog.input_device.clone(),
2385 Message::PreferencesInputDeviceSelected
2386 )
2387 .placeholder("Choose input device")
2388 .width(Length::Fill),
2389 ]
2390 .spacing(10)
2391 .align_y(maolan_widgets::iced::Alignment::Center),
2392 );
2393 }
2394 content = content.push(
2395 row![
2396 text("Default output device:").width(Length::Fixed(160.0)),
2397 pick_list(
2398 dialog.output_devices.clone(),
2399 dialog.output_device.clone(),
2400 Message::PreferencesOutputDeviceSelected
2401 )
2402 .placeholder("Choose output device")
2403 .width(Length::Fill),
2404 ]
2405 .spacing(10)
2406 .align_y(maolan_widgets::iced::Alignment::Center),
2407 );
2408 content = content.push(
2409 row![
2410 button("Save").on_press(Message::PreferencesSave),
2411 button("Cancel")
2412 .on_press(Message::PreferencesCancel)
2413 .style(button::secondary),
2414 ]
2415 .spacing(10),
2416 );
2417
2418 container(content)
2419 .style(|_theme| container::Style {
2420 border: Border {
2421 color: Color::from_rgba(0.34, 0.42, 0.56, 0.72),
2422 width: 1.0,
2423 ..Border::default()
2424 },
2425 background: Some(Background::Color(Color::from_rgb(0.12, 0.13, 0.16))),
2426 ..container::Style::default()
2427 })
2428 .padding(12)
2429 .width(Length::Fixed(420.0))
2430 .into()
2431}
2432
2433fn audio_setup_state(app: &EditApp) -> AudioSetupState<AudioEngineOption, AudioDeviceOption> {
2434 let is_jack = app.setup.audio_engine.is_jack();
2435 let show_input_device = !is_jack
2436 && cfg!(any(
2437 target_os = "freebsd",
2438 target_os = "linux",
2439 target_os = "openbsd",
2440 target_os = "windows"
2441 ));
2442 let show_bit_depth = !is_jack
2443 && cfg!(any(
2444 target_os = "freebsd",
2445 target_os = "linux",
2446 target_os = "openbsd",
2447 target_os = "windows"
2448 ));
2449 let output_devices: Vec<AudioDeviceOption> = app
2450 .setup
2451 .output_devices
2452 .iter()
2453 .filter(|device| backend_matches_device(app.setup.audio_engine, &device.id))
2454 .cloned()
2455 .collect();
2456 let input_devices: Vec<AudioDeviceOption> = app
2457 .setup
2458 .input_devices
2459 .iter()
2460 .filter(|device| backend_matches_device(app.setup.audio_engine, &device.id))
2461 .cloned()
2462 .collect();
2463 let selected_output_device = app
2464 .setup
2465 .output_device
2466 .as_ref()
2467 .and_then(|device| output_devices.iter().find(|d| d.id == device.id).cloned());
2468 let selected_input_device = app
2469 .setup
2470 .input_device
2471 .as_ref()
2472 .and_then(|device| input_devices.iter().find(|d| d.id == device.id).cloned());
2473 let sample_rates = sample_rate_options(&app.setup);
2474 let selected_sample_rate = if sample_rates.contains(&app.setup.sample_rate_hz) {
2475 Some(app.setup.sample_rate_hz)
2476 } else {
2477 sample_rates
2478 .iter()
2479 .min_by_key(|candidate| ((*candidate).saturating_sub(app.setup.sample_rate_hz)).abs())
2480 .copied()
2481 };
2482 let bit_depths = bit_options(&app.setup);
2483 let selected_bit_depth = if show_bit_depth {
2484 Some(if bit_depths.contains(&app.setup.bits) {
2485 app.setup.bits
2486 } else {
2487 bit_depths.first().copied().unwrap_or(32)
2488 })
2489 } else {
2490 None
2491 };
2492 let period_frames = period_frame_options(&app.setup);
2493 let selected_period_frames = if period_frames.contains(&app.setup.period_frames) {
2494 Some(app.setup.period_frames)
2495 } else {
2496 period_frames
2497 .iter()
2498 .copied()
2499 .find(|value| *value >= app.setup.period_frames)
2500 .or_else(|| period_frames.last().copied())
2501 };
2502 let n_periods: Vec<usize> = (1..=16).collect();
2503 let plugins_loaded = app.plugins_loaded();
2504 const HAS_SEPARATE_AUDIO_INPUT_DEVICE: bool = cfg!(any(
2505 target_os = "freebsd",
2506 target_os = "linux",
2507 target_os = "openbsd",
2508 target_os = "windows"
2509 ));
2510 const REQUIRE_SAMPLE_RATES_FOR_HW_READY: bool = cfg!(any(
2511 target_os = "linux",
2512 target_os = "freebsd",
2513 target_os = "openbsd"
2514 ));
2515 let hw_ready = is_jack
2516 || (selected_output_device.is_some()
2517 && (!HAS_SEPARATE_AUDIO_INPUT_DEVICE || selected_input_device.is_some())
2518 && (!REQUIRE_SAMPLE_RATES_FOR_HW_READY || !sample_rates.is_empty()));
2519
2520 AudioSetupState {
2521 backends: AudioEngineOption::ALL.to_vec(),
2522 selected_backend: app.setup.audio_engine,
2523 show_input_device,
2524 input_devices,
2525 selected_input_device,
2526 show_output_device: !is_jack,
2527 output_devices,
2528 selected_output_device,
2529 show_sample_rate: !is_jack,
2530 sample_rates,
2531 selected_sample_rate,
2532 show_bit_depth,
2533 bit_depths,
2534 selected_bit_depth,
2535 show_period_frames: !is_jack,
2536 period_frames,
2537 selected_period_frames,
2538 show_n_periods: !is_jack,
2539 n_periods,
2540 selected_n_periods: Some(app.setup.nperiods),
2541 show_exclusive: !is_jack,
2542 exclusive: app.setup.exclusive,
2543 show_sync_mode: !is_jack,
2544 sync_mode: app.setup.sync_mode,
2545 plugins_loaded,
2546 can_start: plugins_loaded && hw_ready,
2547 status_message: String::new(),
2548 }
2549}
2550
2551fn startup_view(app: &EditApp) -> Element<'_, Message> {
2552 let setup_state = audio_setup_state(app);
2553
2554 let content = audio_setup(setup_state, move |action| match action {
2555 AudioSetupAction::BackendSelected(b) => Message::StartupBackendSelected(b),
2556 AudioSetupAction::InputDeviceSelected(d) => Message::StartupInputDeviceSelected(d),
2557 AudioSetupAction::OutputDeviceSelected(d) => Message::StartupOutputDeviceSelected(d),
2558 AudioSetupAction::SampleRateSelected(r) => Message::StartupSampleRateSelected(r),
2559 AudioSetupAction::BitDepthSelected(b) => Message::StartupBitsSelected(b),
2560 AudioSetupAction::PeriodFramesSelected(p) => Message::StartupPeriodFramesSelected(p),
2561 AudioSetupAction::NPeriodsSelected(n) => Message::StartupNPeriodsSelected(n),
2562 AudioSetupAction::ExclusiveToggled(e) => Message::StartupExclusiveToggled(e),
2563 AudioSetupAction::SyncModeToggled(s) => Message::StartupSyncModeToggled(s),
2564 AudioSetupAction::Start => Message::StartupOpen,
2565 });
2566
2567 container(content)
2568 .style(app_style)
2569 .width(Length::Fill)
2570 .height(Length::Fill)
2571 .align_x(maolan_widgets::iced::Alignment::Center)
2572 .align_y(maolan_widgets::iced::Alignment::Center)
2573 .into()
2574}
2575
2576fn backend_matches_device(engine: AudioEngineOption, device_id: &str) -> bool {
2577 match engine {
2578 #[cfg(unix)]
2579 AudioEngineOption::Jack => false,
2580 #[cfg(target_os = "freebsd")]
2581 AudioEngineOption::Oss => device_id.starts_with("/dev/dsp"),
2582 #[cfg(target_os = "openbsd")]
2583 AudioEngineOption::Sndio => !device_id.is_empty(),
2584 #[cfg(target_os = "linux")]
2585 AudioEngineOption::Alsa => device_id.starts_with("hw:"),
2586 #[cfg(target_os = "windows")]
2587 AudioEngineOption::Wasapi => device_id.starts_with("wasapi:"),
2588 }
2589}
2590
2591async fn save_document(
2592 path: PathBuf,
2593 samples: Vec<f32>,
2594 channels: usize,
2595 sample_rate: u32,
2596) -> Result<PathBuf, String> {
2597 let format = encode_format_for_path(&path)?;
2598 encode_audio_to_file(
2599 &path,
2600 &samples,
2601 channels,
2602 sample_rate,
2603 format,
2604 AudioDither::None,
2605 )
2606 .map_err(|err| format!("Failed to save '{}': {err}", path.display()))?;
2607 Ok(path)
2608}
2609
2610async fn choose_export_directory() -> Option<PathBuf> {
2611 rfd::FileDialog::new().pick_folder()
2612}
2613
2614async fn export_marker_ranges(
2615 directory: PathBuf,
2616 audio: AudioDocument,
2617 format: ExportFormat,
2618 bit_depth: ExportBitDepth,
2619 sample_rate: u32,
2620) -> Result<usize, String> {
2621 let channels = audio.channels.max(1);
2622 let frames = audio.frames();
2623 let ranges = marker_ranges(&audio.markers, frames);
2624 if ranges.is_empty() {
2625 return Err(String::from("No ranges to export."));
2626 }
2627
2628 let source_stem = audio
2629 .source_path
2630 .file_stem()
2631 .and_then(|s| s.to_str())
2632 .unwrap_or("export");
2633 let encode_format = export_encode_format(format, bit_depth);
2634 let extension = format.extension();
2635
2636 std::fs::create_dir_all(&directory)
2637 .map_err(|err| format!("Failed to create export directory: {err}"))?;
2638
2639 let total = ranges.len();
2640 for (index, (start, end)) in ranges.iter().enumerate() {
2641 let range_samples = marker_range_samples(&audio, *start, *end);
2642 let resampled = if sample_rate == audio.sample_rate {
2643 range_samples
2644 } else {
2645 resample_interleaved(&range_samples, channels, audio.sample_rate, sample_rate)?
2646 };
2647 let filename = export_filename(source_stem, index + 1, extension);
2648 let path = directory.join(filename);
2649 encode_audio_to_file(
2650 &path,
2651 &resampled,
2652 channels,
2653 sample_rate,
2654 encode_format,
2655 AudioDither::None,
2656 )
2657 .map_err(|err| format!("Failed to export '{}': {err}", path.display()))?;
2658 }
2659
2660 Ok(total)
2661}
2662
2663fn marker_ranges(markers: &[(usize, String)], frames: usize) -> Vec<(usize, usize)> {
2664 let mut sorted: Vec<usize> = markers.iter().map(|(sample, _)| *sample).collect();
2665 sorted.sort_unstable();
2666 sorted.dedup();
2667 let mut ranges = Vec::new();
2668 let mut start = 0usize;
2669 for marker in sorted {
2670 if marker > start && marker <= frames {
2671 ranges.push((start, marker));
2672 start = marker;
2673 }
2674 }
2675 if start < frames {
2676 ranges.push((start, frames));
2677 }
2678 ranges
2679}
2680
2681fn marker_range_samples(audio: &AudioDocument, start: usize, end: usize) -> Vec<f32> {
2682 let channels = audio.channels.max(1);
2683 let frames = audio.frames();
2684 let start = start.min(frames);
2685 let end = end.min(frames);
2686 if start >= end {
2687 return Vec::new();
2688 }
2689 audio.preview_samples[start * channels..end * channels].to_vec()
2690}
2691
2692fn export_encode_format(format: ExportFormat, bit_depth: ExportBitDepth) -> AudioEncodeFormat {
2693 match format {
2694 ExportFormat::Wav => AudioEncodeFormat::Wav(match bit_depth {
2695 ExportBitDepth::Bits16 => WavBitDepth::Int16,
2696 ExportBitDepth::Bits24 => WavBitDepth::Int24,
2697 ExportBitDepth::Bits32 => WavBitDepth::Int32,
2698 }),
2699 ExportFormat::Flac => AudioEncodeFormat::Flac(bit_depth.bits()),
2700 ExportFormat::OggFlac => AudioEncodeFormat::OggFlac(bit_depth.bits()),
2701 ExportFormat::Mp3 => AudioEncodeFormat::Mp3,
2702 }
2703}
2704
2705fn export_filename(stem: &str, index: usize, extension: &str) -> String {
2706 format!("{stem}_{index:03}.{extension}")
2707}
2708
2709fn resample_interleaved(
2710 samples: &[f32],
2711 channels: usize,
2712 from_rate: u32,
2713 to_rate: u32,
2714) -> Result<Vec<f32>, String> {
2715 if from_rate == to_rate {
2716 return Ok(samples.to_vec());
2717 }
2718 let channels = channels.max(1);
2719 let frames = samples.len() / channels;
2720 if frames == 0 {
2721 return Ok(Vec::new());
2722 }
2723
2724 let mut input_per_channel: Vec<Vec<f32>> = vec![Vec::with_capacity(frames); channels];
2725 for frame in samples.chunks_exact(channels) {
2726 for (channel, sample) in frame.iter().copied().enumerate() {
2727 input_per_channel[channel].push(sample);
2728 }
2729 }
2730
2731 let ratio = to_rate as f64 / from_rate as f64;
2732 let chunk_size = 1024.min(frames);
2733 let mut resampler = rubato::FastFixedIn::<f32>::new(
2734 ratio,
2735 2.0,
2736 rubato::PolynomialDegree::Linear,
2737 chunk_size,
2738 channels,
2739 )
2740 .map_err(|err| format!("Failed to create resampler: {err}"))?;
2741
2742 for channel in &mut input_per_channel {
2743 channel.resize(chunk_size, 0.0);
2744 }
2745
2746 let output_per_channel = resampler
2747 .process(&input_per_channel, None)
2748 .map_err(|err| format!("Failed to resample: {err}"))?;
2749
2750 let expected_output_frames = (frames as f64 * ratio).round() as usize;
2751 let output_frames = output_per_channel
2752 .first()
2753 .map(|channel| channel.len().min(expected_output_frames))
2754 .unwrap_or(0);
2755 let mut output = Vec::with_capacity(output_frames * channels);
2756 output.extend(
2757 (0..output_frames)
2758 .flat_map(|frame| output_per_channel.iter().map(move |channel| channel[frame])),
2759 );
2760
2761 Ok(output)
2762}
2763
2764fn detect_markers(
2765 preview_samples: &[f32],
2766 channels: usize,
2767 threshold_db: f32,
2768 silence_samples: usize,
2769) -> Vec<(usize, String)> {
2770 let channels = channels.max(1);
2771 let frames = preview_samples.len() / channels;
2772 if frames == 0 {
2773 return Vec::new();
2774 }
2775 let threshold = 10.0f32.powf(threshold_db / 20.0);
2776
2777 let mut segments: Vec<(usize, usize, bool)> = Vec::new();
2779 let mut segment_start = 0usize;
2780 let frame_amplitude = |frame: usize| {
2781 preview_samples[frame * channels..(frame + 1) * channels]
2782 .iter()
2783 .map(|sample| sample.abs())
2784 .sum::<f32>()
2785 / channels as f32
2786 };
2787 let mut is_silent = frame_amplitude(0) < threshold;
2788
2789 for frame in 1..frames {
2790 let frame_silent = frame_amplitude(frame) < threshold;
2791 if frame_silent != is_silent {
2792 segments.push((segment_start, frame, is_silent));
2793 segment_start = frame;
2794 is_silent = frame_silent;
2795 }
2796 }
2797 segments.push((segment_start, frames, is_silent));
2798
2799 let mut classified: Vec<(usize, usize, bool)> = Vec::new();
2801 for (start, end, silent) in segments {
2802 if silent && end.saturating_sub(start) >= silence_samples {
2803 classified.push((start, end, true));
2804 } else if let Some(last) = classified.last_mut() {
2805 if !last.2 {
2806 last.1 = end;
2807 continue;
2808 }
2809 classified.push((start, end, false));
2810 } else {
2811 classified.push((start, end, false));
2812 }
2813 }
2814
2815 let mut markers: Vec<(usize, String)> = Vec::new();
2817 let mut region_index = 1usize;
2818 for (index, (start, end, is_silence)) in classified.iter().enumerate() {
2819 if *is_silence {
2820 continue;
2821 }
2822 let preceded_by_silence = index == 0 || classified[index - 1].2;
2823 let followed_by_silence = index == classified.len() - 1 || classified[index + 1].2;
2824 if preceded_by_silence {
2825 markers.push((*start, format!("Region {region_index}")));
2826 region_index += 1;
2827 }
2828 if followed_by_silence {
2829 markers.push((*end, format!("Region {region_index}")));
2830 region_index += 1;
2831 }
2832 }
2833
2834 markers
2835}
2836
2837impl AudioDocument {
2838 fn open_with_progress<F>(
2839 path: PathBuf,
2840 region: Option<AudioRegion>,
2841 mut progress_callback: F,
2842 ) -> Result<Self, String>
2843 where
2844 F: FnMut(f32, &str),
2845 {
2846 let name = path
2847 .file_name()
2848 .and_then(|name| name.to_str())
2849 .unwrap_or("audio file")
2850 .to_string();
2851
2852 progress_callback(0.0, &format!("Opening {name}..."));
2853 progress_callback(0.05, &format!("Decoding {name}..."));
2854 let (samples, channels, sample_rate) = decode_audio_to_f32_interleaved_sync(&path)
2855 .map_err(|err| format!("Failed to open '{}': {err}", path.display()))?;
2856 progress_callback(0.72, &format!("Preparing clip from {name}..."));
2857 let samples = clip_samples(&samples, channels, region);
2858 let edits = AudioEdits::default();
2859 progress_callback(0.78, &format!("Applying preview edits to {name}..."));
2860 let preview = apply_edits(&samples, channels, edits);
2861 progress_callback(0.85, &format!("Preparing waveform for {name}..."));
2862 let channel_samples = deinterleave(&preview, channels);
2863 progress_callback(0.95, &format!("Measuring peak level for {name}..."));
2864 let peak = peak(&preview);
2865 let save_path = region.is_none().then(|| path.clone());
2866 progress_callback(1.0, &format!("Opened {name}."));
2867
2868 Ok(Self {
2869 source_path: path,
2870 save_path,
2871 samples,
2872 preview_samples: preview,
2873 channels,
2874 sample_rate,
2875 channel_samples,
2876 peak,
2877 clip_region: region,
2878 edits,
2879 markers: Vec::new(),
2880 })
2881 }
2882
2883 fn frames(&self) -> usize {
2884 self.samples.len() / self.channels.max(1)
2885 }
2886
2887 fn rebuild_preview(&mut self) {
2888 let preview = apply_edits(&self.samples, self.channels, self.edits);
2889 self.preview_samples = preview.clone();
2890 self.channel_samples = deinterleave(&preview, self.channels);
2891 self.peak = peak(&preview);
2892 }
2893
2894 fn next_zero_crossing_frame(&self, start_frame: usize) -> Option<usize> {
2895 let channels = self.channels.max(1);
2896 let frames = self.preview_samples.len() / channels;
2897 if start_frame.saturating_add(1) >= frames {
2898 return None;
2899 }
2900
2901 let mut iter = self
2902 .preview_samples
2903 .chunks_exact(channels)
2904 .enumerate()
2905 .skip(start_frame);
2906 let (_, previous_chunk) = iter.next()?;
2907 let mut previous = previous_chunk.iter().sum::<f32>() / channels as f32;
2908
2909 for (frame, chunk) in iter {
2910 let current = chunk.iter().sum::<f32>() / channels as f32;
2911 if (previous > 0.0 && current <= 0.0) || (previous < 0.0 && current >= 0.0) {
2912 return Some(frame);
2913 }
2914 previous = current;
2915 }
2916 None
2917 }
2918}
2919
2920#[derive(Debug, Clone, Copy)]
2921enum EditOperation {
2922 FadeIn,
2923 FadeOut,
2924 IncreaseVolume,
2925 DecreaseVolume,
2926}
2927
2928fn apply_standalone_edit(app: &mut EditApp, operation: EditOperation) -> Task<Message> {
2929 let Some(audio) = app.audio.as_mut() else {
2930 app.status = String::from("No audio file is open.");
2931 return Task::none();
2932 };
2933
2934 let previous_snapshot = DocumentSnapshot {
2935 samples: audio.samples.clone(),
2936 edits: audio.edits,
2937 markers: audio.markers.clone(),
2938 };
2939
2940 if let Some((start, end)) = app.selection_samples {
2941 let frames = audio.frames();
2942 let start = start.min(frames);
2943 let end = end.min(frames);
2944 let length = end.saturating_sub(start);
2945 if length == 0 {
2946 app.status = String::from("Selection is empty.");
2947 return Task::none();
2948 }
2949 let region = AudioRegion {
2950 offset: start,
2951 length,
2952 };
2953 apply_edit_to_samples(&mut audio.samples, audio.channels, region, operation);
2954 match operation {
2955 EditOperation::FadeIn => app.status = String::from("Fade in applied to selection."),
2956 EditOperation::FadeOut => app.status = String::from("Fade out applied to selection."),
2957 EditOperation::IncreaseVolume | EditOperation::DecreaseVolume => {
2958 app.status = String::from("Volume adjusted on selection.")
2959 }
2960 }
2961 } else {
2962 match operation {
2963 EditOperation::FadeIn => {
2964 audio.edits.fade_in_samples = default_fade_samples(audio.frames());
2965 app.status = String::from("Fade in applied to preview.");
2966 }
2967 EditOperation::FadeOut => {
2968 audio.edits.fade_out_samples = default_fade_samples(audio.frames());
2969 app.status = String::from("Fade out applied to preview.");
2970 }
2971 EditOperation::IncreaseVolume => {
2972 audio.edits.gain_db = (audio.edits.gain_db + 1.0).min(24.0);
2973 app.status = format!("Preview gain: {:+.1} dB.", audio.edits.gain_db);
2974 }
2975 EditOperation::DecreaseVolume => {
2976 audio.edits.gain_db = (audio.edits.gain_db - 1.0).max(-48.0);
2977 app.status = format!("Preview gain: {:+.1} dB.", audio.edits.gain_db);
2978 }
2979 }
2980 }
2981 app.history.record(
2982 previous_snapshot,
2983 DocumentSnapshot {
2984 samples: audio.samples.clone(),
2985 edits: audio.edits,
2986 markers: audio.markers.clone(),
2987 },
2988 );
2989 audio.rebuild_preview();
2990 prepare_document_track(app)
2991}
2992
2993fn delete_selection(app: &mut EditApp) -> Task<Message> {
2994 let Some(audio) = app.audio.as_mut() else {
2995 return Task::none();
2996 };
2997 let Some((start, end)) = app.selection_samples else {
2998 return Task::none();
2999 };
3000 if start >= end || end > audio.frames() {
3001 return Task::none();
3002 }
3003
3004 let previous_snapshot = DocumentSnapshot {
3005 samples: audio.samples.clone(),
3006 edits: audio.edits,
3007 markers: audio.markers.clone(),
3008 };
3009 let channels = audio.channels.max(1);
3010 let sample_start = start * channels;
3011 let sample_end = end.min(audio.frames()) * channels;
3012 audio.samples.drain(sample_start..sample_end);
3013
3014 if let Some(region) = audio.clip_region.as_mut() {
3015 let region_start = region.offset;
3016 let region_end = region.offset + region.length;
3017 if end <= region_start {
3018 region.offset = region.offset.saturating_sub(end - start);
3019 } else if start >= region_end {
3020 } else {
3022 let delete_start = start.max(region_start);
3023 let delete_end = end.min(region_end);
3024 let deleted_in_region = delete_end.saturating_sub(delete_start);
3025 region.length = region.length.saturating_sub(deleted_in_region);
3026 if start < region_start {
3027 region.offset = region_start.saturating_sub(end - start);
3028 }
3029 if region.length == 0 {
3030 audio.clip_region = None;
3031 }
3032 }
3033 }
3034
3035 let deleted_frames = end - start;
3036 audio
3037 .markers
3038 .retain(|(sample, _)| *sample < start || *sample >= end);
3039 for (sample, _) in &mut audio.markers {
3040 if *sample >= end {
3041 *sample = sample.saturating_sub(deleted_frames);
3042 }
3043 }
3044
3045 app.selection_anchor_samples = None;
3046 app.selection_samples = None;
3047 app.status = format!("Deleted {}..{} samples.", start, end);
3048
3049 app.history.record(
3050 previous_snapshot,
3051 DocumentSnapshot {
3052 samples: audio.samples.clone(),
3053 edits: audio.edits,
3054 markers: audio.markers.clone(),
3055 },
3056 );
3057 audio.rebuild_preview();
3058 prepare_document_track(app)
3059}
3060
3061fn restore_document(audio: &mut AudioDocument, snapshot: DocumentSnapshot) {
3062 audio.samples = snapshot.samples;
3063 audio.edits = snapshot.edits;
3064 audio.markers = snapshot.markers;
3065}
3066
3067fn apply_edit_to_samples(
3068 samples: &mut [f32],
3069 channels: usize,
3070 region: AudioRegion,
3071 operation: EditOperation,
3072) {
3073 let channels = channels.max(1);
3074 let frames = samples.len() / channels;
3075 let start = region.offset.min(frames);
3076 let end = start.saturating_add(region.length).min(frames);
3077 if start >= end {
3078 return;
3079 }
3080
3081 match operation {
3082 EditOperation::FadeIn => {
3083 let fade_len = end - start;
3084 for frame in start..end {
3085 let envelope = (frame - start) 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::FadeOut => {
3093 let fade_len = end - start;
3094 for frame in start..end {
3095 let envelope = (end - 1 - frame) as f32 / fade_len as f32;
3096 for channel in 0..channels {
3097 let index = frame * channels + channel;
3098 samples[index] *= envelope;
3099 }
3100 }
3101 }
3102 EditOperation::IncreaseVolume => {
3103 let gain = 10.0f32.powf(1.0 / 20.0);
3104 for frame in start..end {
3105 for channel in 0..channels {
3106 let index = frame * channels + channel;
3107 samples[index] = (samples[index] * gain).clamp(-1.0, 1.0);
3108 }
3109 }
3110 }
3111 EditOperation::DecreaseVolume => {
3112 let gain = 10.0f32.powf(-1.0 / 20.0);
3113 for frame in start..end {
3114 for channel in 0..channels {
3115 let index = frame * channels + channel;
3116 samples[index] = (samples[index] * gain).clamp(-1.0, 1.0);
3117 }
3118 }
3119 }
3120 }
3121}
3122
3123fn default_fade_samples(frames: usize) -> usize {
3124 (frames / 20).clamp(240, 48_000).min(frames / 2)
3125}
3126
3127fn apply_edits(samples: &[f32], channels: usize, edits: AudioEdits) -> Vec<f32> {
3128 let channels = channels.max(1);
3129 let frames = samples.len() / channels;
3130 let gain = 10.0f32.powf(edits.gain_db / 20.0);
3131 let mut output = samples.to_vec();
3132
3133 for frame in 0..frames {
3134 let mut envelope = 1.0f32;
3135 if edits.fade_in_samples > 0 && frame < edits.fade_in_samples {
3136 envelope *= frame as f32 / edits.fade_in_samples as f32;
3137 }
3138 if edits.fade_out_samples > 0 {
3139 let fade_start = frames.saturating_sub(edits.fade_out_samples);
3140 if frame >= fade_start {
3141 envelope *= (frames.saturating_sub(frame) as f32 / edits.fade_out_samples as f32)
3142 .clamp(0.0, 1.0);
3143 }
3144 }
3145 for channel in 0..channels {
3146 let index = frame * channels + channel;
3147 output[index] = (output[index] * envelope * gain).clamp(-1.0, 1.0);
3148 }
3149 }
3150
3151 output
3152}
3153
3154fn peak(samples: &[f32]) -> f32 {
3155 samples
3156 .iter()
3157 .fold(0.0f32, |peak, sample| peak.max(sample.abs()))
3158}
3159
3160fn play_standalone(app: &mut EditApp) -> Task<Message> {
3161 if app.busy || app.preparing_playback {
3162 app.status = String::from("Preparing audio for playback.");
3163 return Task::none();
3164 }
3165 if app.playing {
3166 return Task::none();
3167 }
3168 let Some(audio) = app.audio.as_ref() else {
3169 app.status = String::from("No audio file is open.");
3170 return Task::none();
3171 };
3172 let Some(playback) = app.engine_playback.as_ref() else {
3173 if !app.standalone_ready {
3174 app.playing = true;
3175 app.status = String::from("Playing preview.");
3176 if app.playhead_samples >= audio.frames() {
3177 app.playhead_samples = 0;
3178 }
3179 return Task::none();
3180 }
3181 app.status = String::from("Open audio hardware before playback.");
3182 return Task::none();
3183 };
3184 app.playing = true;
3185 app.status = String::from("Playing.");
3186 if app.playhead_samples >= audio.frames() {
3187 app.playhead_samples = 0;
3188 }
3189 let start = app.playhead_samples;
3190 let client = playback.client.clone();
3191 Task::perform(
3192 async move { start_engine_playback(client, start).await },
3193 Message::StandalonePlaybackStarted,
3194 )
3195}
3196
3197fn refresh_standalone_playhead(app: &mut EditApp) -> bool {
3198 if !app.playing {
3199 return false;
3200 }
3201 let Some(audio) = app.audio.as_ref() else {
3202 app.playing = false;
3203 return false;
3204 };
3205 let step = (audio.sample_rate / 25).max(1) as usize;
3206 app.playhead_samples = app.playhead_samples.saturating_add(step);
3207 if app.playhead_samples >= audio.frames() {
3208 app.playhead_samples = audio.frames();
3209 return true;
3210 }
3211 false
3212}
3213
3214fn playhead_ratio(app: &EditApp) -> Option<f32> {
3215 let frames = app.audio.as_ref()?.frames().max(1);
3216 Some((app.playhead_samples as f32 / frames as f32).clamp(0.0, 1.0))
3217}
3218
3219fn selection_ratio(app: &EditApp) -> Option<(f32, f32)> {
3220 let frames = app.audio.as_ref()?.frames().max(1) as f32;
3221 let (start, end) = app.selection_samples?;
3222 Some((start as f32 / frames, end as f32 / frames))
3223}
3224
3225fn sample_at_ratio(app: &EditApp, ratio: f32) -> Option<usize> {
3226 let frames = app.audio.as_ref()?.frames();
3227 Some(((ratio.clamp(0.0, 1.0) * frames as f32).round() as usize).min(frames))
3228}
3229
3230fn nearest_marker_sample(audio: &AudioDocument, ratio: f32) -> Option<usize> {
3231 if audio.markers.is_empty() {
3232 return None;
3233 }
3234 let frames = audio.frames().max(1);
3235 let target = (ratio.clamp(0.0, 1.0) * frames as f32).round() as usize;
3236 audio
3237 .markers
3238 .iter()
3239 .min_by_key(|(sample, _)| sample.abs_diff(target))
3240 .map(|(sample, _)| *sample)
3241}
3242
3243fn selection_duration_seconds(app: &EditApp) -> f32 {
3244 let Some(audio) = app.audio.as_ref() else {
3245 return 0.0;
3246 };
3247 let Some((start, end)) = app.selection_samples else {
3248 return 0.0;
3249 };
3250 end.saturating_sub(start) as f32 / audio.sample_rate.max(1) as f32
3251}
3252
3253fn vu_meter(app: &EditApp) -> Element<'_, Message> {
3254 let levels = vu_levels_db(app);
3255 meters::meters(levels.len(), &levels, 0.0)
3256}
3257
3258pub fn vu_levels_db(app: &EditApp) -> Vec<f32> {
3259 let Some(audio) = app.audio.as_ref() else {
3260 return vec![-90.0, -90.0];
3261 };
3262 let channels = audio.channels.max(1);
3263 let frames = audio.frames();
3264 let start = app.playhead_samples.min(frames);
3265 let end = start.saturating_add(2048).min(frames);
3266 let channel_count = channels.min(2);
3267 let mut levels = vec![0.0f32; channel_count.max(1)];
3268 if start >= end {
3269 return levels;
3270 }
3271 for frame in start..end {
3272 for (channel, level) in levels.iter_mut().enumerate().take(channel_count) {
3273 let sample = audio.preview_samples[frame * channels + channel].abs();
3274 *level = (*level).max(sample);
3275 }
3276 }
3277 levels
3278 .into_iter()
3279 .map(|level| {
3280 if level <= 1.0e-9 {
3281 -90.0
3282 } else {
3283 (20.0 * level.log10()).clamp(-90.0, 20.0)
3284 }
3285 })
3286 .collect()
3287}
3288
3289fn prepare_document_track(app: &mut EditApp) -> Task<Message> {
3290 if !app.standalone_ready {
3291 return Task::none();
3292 }
3293 let (Some(audio), Some(playback)) = (app.audio.as_ref(), app.engine_playback.as_ref()) else {
3294 return Task::none();
3295 };
3296 let client = playback.client.clone();
3297 let path = audio.source_path.clone();
3298 let samples = audio.preview_samples.clone();
3299 let channels = audio.channels;
3300 let sample_rate = audio.sample_rate;
3301 let clip_len = audio.frames();
3302 let render_preview = !audio.edits.is_default();
3303 let clip_offset = if render_preview {
3304 0
3305 } else {
3306 audio.clip_region.map(|region| region.offset).unwrap_or(0)
3307 };
3308 app.engine_clip_path = Some(if render_preview {
3309 preview_path(&path)
3310 } else {
3311 path.clone()
3312 });
3313 app.preparing_playback = true;
3314 app.status = String::from("Preparing audio for playback...");
3315 let request = EngineDocumentRequest {
3316 path,
3317 samples,
3318 channels,
3319 sample_rate,
3320 clip_len,
3321 clip_offset,
3322 render_preview,
3323 };
3324 Task::perform(
3325 async move { prepare_engine_document(client, request).await },
3326 Message::EngineDocumentPrepared,
3327 )
3328}
3329
3330async fn open_standalone_engine(setup: StartupSetup) -> Result<EngineClient, String> {
3331 let client = EngineClient::default();
3332 let mut rx = client.subscribe().await;
3333 send_engine(&client, EngineAction::Stop).await?;
3334 scan_plugins(&client, &mut rx).await?;
3335 send_engine(
3336 &client,
3337 EngineAction::OpenAudioDevice {
3338 device: selected_output_device(&setup),
3339 input_device: selected_input_device(&setup),
3340 sample_rate_hz: setup.sample_rate_hz,
3341 bits: selected_bits(&setup),
3342 exclusive: setup.exclusive,
3343 period_frames: selected_period_frames(&setup),
3344 nperiods: setup.nperiods,
3345 sync_mode: setup.sync_mode,
3346 actual_period_frames: 0,
3347 input_channels: 0,
3348 output_channels: 0,
3349 bytes_per_frame: 0,
3350 },
3351 )
3352 .await?;
3353 wait_for_engine_response(&mut rx, |action| {
3354 matches!(action, EngineAction::OpenAudioDevice { .. })
3355 })
3356 .await?;
3357 Ok(client)
3358}
3359
3360async fn scan_plugins(
3361 client: &EngineClient,
3362 rx: &mut tokio::sync::mpsc::Receiver<EngineMessage>,
3363) -> Result<(), String> {
3364 #[cfg(unix)]
3365 {
3366 send_engine(client, EngineAction::ListLv2Plugins).await?;
3367 }
3368 send_engine(client, EngineAction::ListVst3Plugins).await?;
3369 send_engine(client, EngineAction::ListClapPlugins).await?;
3370
3371 #[cfg(unix)]
3372 wait_for_engine_response(rx, |action| {
3373 matches!(
3374 action,
3375 EngineAction::Lv2Plugins(_) | EngineAction::Lv2PluginsUnavailable { .. }
3376 )
3377 })
3378 .await?;
3379 wait_for_engine_response(rx, |action| {
3380 matches!(
3381 action,
3382 EngineAction::Vst3Plugins(_) | EngineAction::Vst3PluginsUnavailable { .. }
3383 )
3384 })
3385 .await?;
3386 wait_for_engine_response(rx, |action| {
3387 matches!(
3388 action,
3389 EngineAction::ClapPlugins(_) | EngineAction::ClapPluginsUnavailable { .. }
3390 )
3391 })
3392 .await?;
3393 Ok(())
3394}
3395
3396async fn prepare_engine_document(
3397 client: EngineClient,
3398 request: EngineDocumentRequest,
3399) -> Result<(), String> {
3400 let clip_path = if request.render_preview {
3401 let temp_path = preview_path(&request.path);
3402 save_document(
3403 temp_path.clone(),
3404 request.samples,
3405 request.channels,
3406 request.sample_rate,
3407 )
3408 .await?;
3409 temp_path
3410 } else {
3411 request.path
3412 };
3413 let track = "editor-preview".to_string();
3414 send_engine(&client, EngineAction::Stop).await?;
3415 let _ = send_engine(&client, EngineAction::RemoveTrack(track.clone())).await;
3416 let mut rx = client.subscribe().await;
3417 send_engine(
3418 &client,
3419 EngineAction::AddTrack {
3420 name: track.clone(),
3421 audio_ins: request.channels,
3422 midi_ins: 0,
3423 audio_outs: request.channels,
3424 midi_outs: 0,
3425 folder: false,
3426 mixosc_addr: None,
3427 },
3428 )
3429 .await?;
3430 wait_for_engine_response(
3431 &mut rx,
3432 |action| matches!(action, EngineAction::AddTrack { name, .. } if name == "editor-preview"),
3433 )
3434 .await?;
3435 send_engine(
3436 &client,
3437 EngineAction::AddClip {
3438 clip_id: generate_clip_id(),
3439 name: clip_path.to_string_lossy().to_string(),
3440 track_name: track.clone(),
3441 start: 0,
3442 length: request.clip_len,
3443 offset: request.clip_offset,
3444 input_channel: 0,
3445 muted: false,
3446 peaks_file: None,
3447 kind: Kind::Audio,
3448 fade_enabled: true,
3449 fade_in_samples: 240,
3450 fade_out_samples: 240,
3451 source_name: None,
3452 source_offset: None,
3453 source_length: None,
3454 preview_name: None,
3455 pitch_correction_points: Vec::new(),
3456 pitch_correction_frame_likeness: None,
3457 pitch_correction_inertia_ms: None,
3458 pitch_correction_formant_compensation: None,
3459 plugin_graph_json: None,
3460 },
3461 )
3462 .await?;
3463 wait_for_engine_response(
3464 &mut rx,
3465 |action| matches!(action, EngineAction::AddClip { track_name, .. } if track_name == "editor-preview"),
3466 )
3467 .await?;
3468 for channel in 0..request.channels.clamp(1, 2) {
3469 send_engine(
3470 &client,
3471 EngineAction::Connect {
3472 from_track: track.clone(),
3473 from_port: channel,
3474 to_track: "hw:out".to_string(),
3475 to_port: channel,
3476 kind: Kind::Audio,
3477 },
3478 )
3479 .await?;
3480 wait_for_engine_response(&mut rx, |action| {
3481 matches!(action, EngineAction::Connect {
3482 from_track,
3483 from_port,
3484 to_track,
3485 to_port,
3486 kind,
3487 } if from_track == "editor-preview"
3488 && *from_port == channel
3489 && to_track == "hw:out"
3490 && *to_port == channel
3491 && *kind == Kind::Audio)
3492 })
3493 .await?;
3494 }
3495 send_engine(&client, EngineAction::SetClipPlaybackEnabled(true)).await?;
3496 wait_for_engine_response(&mut rx, |action| {
3497 matches!(action, EngineAction::SetClipPlaybackEnabled(true))
3498 })
3499 .await?;
3500 Ok(())
3501}
3502
3503async fn start_engine_playback(client: EngineClient, start: usize) -> Result<(), String> {
3504 let mut rx = client.subscribe().await;
3505 send_engine(&client, EngineAction::SetClipPlaybackEnabled(true)).await?;
3506 wait_for_engine_response(&mut rx, |action| {
3507 matches!(action, EngineAction::SetClipPlaybackEnabled(true))
3508 })
3509 .await?;
3510 send_engine(&client, EngineAction::TransportPosition(start)).await?;
3511 send_engine(&client, EngineAction::Play).await?;
3512 wait_for_engine_response(&mut rx, |action| matches!(action, EngineAction::Play)).await?;
3513 Ok(())
3514}
3515
3516fn selected_output_device(setup: &StartupSetup) -> String {
3517 if setup.audio_engine.is_jack() {
3518 String::from("jack")
3519 } else {
3520 setup
3521 .output_device
3522 .as_ref()
3523 .map(|device| device.id.clone())
3524 .unwrap_or_else(|| default_audio_device(setup.audio_engine).to_string())
3525 }
3526}
3527
3528fn selected_input_device(setup: &StartupSetup) -> Option<String> {
3529 if setup.audio_engine.is_jack() {
3530 None
3531 } else {
3532 setup.input_device.as_ref().map(|device| device.id.clone())
3533 }
3534}
3535
3536fn selected_bits(setup: &StartupSetup) -> i32 {
3537 if setup.audio_engine.is_jack() {
3538 32
3539 } else {
3540 setup.bits as i32
3541 }
3542}
3543
3544fn selected_period_frames(setup: &StartupSetup) -> usize {
3545 let options = period_frame_options(setup);
3546 if options.contains(&setup.period_frames) {
3547 setup.period_frames
3548 } else {
3549 options
3550 .iter()
3551 .copied()
3552 .find(|value| *value >= setup.period_frames)
3553 .or_else(|| options.last().copied())
3554 .unwrap_or(setup.period_frames)
3555 }
3556}
3557
3558fn period_frame_options(setup: &StartupSetup) -> Vec<usize> {
3559 #[cfg(target_os = "freebsd")]
3560 {
3561 if !setup.audio_engine.is_jack()
3562 && let Some(device) = setup.output_device.as_ref()
3563 && let Some(options) = oss_period_frame_options(device, selected_bits(setup) as usize)
3564 {
3565 return options;
3566 }
3567 }
3568 vec![
3569 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536,
3570 ]
3571}
3572
3573#[cfg(target_os = "freebsd")]
3574fn oss_period_frame_options(device: &AudioDeviceOption, bits: usize) -> Option<Vec<usize>> {
3575 if device.max_channels == 0 || device.max_buffer_bytes == 0 {
3576 return None;
3577 }
3578 let channels = device.max_channels.max(1);
3579 let bytes_per_sample = match bits {
3580 8 => 1,
3581 16 => 2,
3582 24 => 3,
3583 32 => 4,
3584 _ => return None,
3585 };
3586 let frame_bytes = channels.checked_mul(bytes_per_sample)?.max(1);
3587 let min_bytes = frame_bytes.next_power_of_two();
3588 let max_fragment_bytes = 1_usize << 16;
3589 let max_bytes = device
3590 .max_buffer_bytes
3591 .min(max_fragment_bytes)
3592 .max(min_bytes);
3593 if min_bytes > max_bytes {
3594 return None;
3595 }
3596 let mut out = Vec::new();
3597 let mut bytes = min_bytes;
3598 while bytes <= max_bytes {
3599 out.push(bytes.div_ceil(frame_bytes).max(1));
3600 match bytes.checked_mul(2) {
3601 Some(next) => bytes = next,
3602 None => break,
3603 }
3604 }
3605 out.sort_unstable();
3606 out.dedup();
3607 (!out.is_empty()).then_some(out)
3608}
3609
3610async fn send_engine(client: &EngineClient, action: EngineAction) -> Result<(), String> {
3611 client.send(EngineMessage::Request(action)).await
3612}
3613
3614async fn wait_for_engine_response(
3615 rx: &mut tokio::sync::mpsc::Receiver<EngineMessage>,
3616 mut accepts: impl FnMut(&EngineAction) -> bool,
3617) -> Result<(), String> {
3618 let deadline = Instant::now() + Duration::from_secs(5);
3619 loop {
3620 let remaining = deadline.saturating_duration_since(Instant::now());
3621 if remaining.is_zero() {
3622 return Err(String::from("Timed out waiting for audio engine."));
3623 }
3624 let Some(message) = tokio::time::timeout(remaining, rx.recv())
3625 .await
3626 .map_err(|_| String::from("Timed out waiting for audio engine."))?
3627 else {
3628 return Err(String::from("Audio engine response channel closed."));
3629 };
3630 if let EngineMessage::Response(result) = message {
3631 match result {
3632 Ok(action) if accepts(&action) => return Ok(()),
3633 Ok(_) => {}
3634 Err(err) => return Err(err),
3635 }
3636 }
3637 }
3638}
3639
3640fn preview_path(source: &Path) -> PathBuf {
3641 let mut path = std::env::temp_dir();
3642 let stem = source
3643 .file_stem()
3644 .and_then(|stem| stem.to_str())
3645 .unwrap_or("maolan-editor-preview");
3646 path.push(format!("maolan-editor-preview-{stem}.wav"));
3647 path
3648}
3649
3650fn playhead_label(app: &EditApp) -> String {
3651 let sample_rate = app
3652 .audio
3653 .as_ref()
3654 .map(|audio| audio.sample_rate)
3655 .unwrap_or(48_000)
3656 .max(1);
3657 let seconds = app.playhead_samples as f64 / sample_rate as f64;
3658 let minutes = (seconds / 60.0).floor() as u64;
3659 let secs = (seconds % 60.0).floor() as u64;
3660 let millis = ((seconds.fract()) * 1000.0).floor() as u64;
3661 format!("{minutes:02}:{secs:02}.{millis:03}")
3662}
3663
3664fn discover_output_audio_devices(engine: AudioEngineOption) -> Vec<AudioDeviceOption> {
3665 if engine.is_jack() {
3666 return vec![simple_audio_device("jack")];
3667 }
3668 let mut devices = platform_audio_devices()
3669 .into_iter()
3670 .filter(|device| device.supports_output)
3671 .collect::<Vec<_>>();
3672 if devices.is_empty() {
3673 devices.push(simple_audio_device(default_audio_device(engine)));
3674 }
3675 devices.sort_by_key(|device| device.label.to_lowercase());
3676 devices.dedup_by(|a, b| a.id == b.id);
3677 devices
3678}
3679
3680fn discover_input_audio_devices(engine: AudioEngineOption) -> Vec<AudioDeviceOption> {
3681 if engine.is_jack() {
3682 return Vec::new();
3683 }
3684 let mut devices = platform_audio_devices()
3685 .into_iter()
3686 .filter(|device| device.supports_input)
3687 .collect::<Vec<_>>();
3688 devices.sort_by_key(|device| device.label.to_lowercase());
3689 devices.dedup_by(|a, b| a.id == b.id);
3690 devices
3691}
3692
3693fn platform_audio_devices() -> Vec<AudioDeviceOption> {
3694 #[cfg(target_os = "freebsd")]
3695 {
3696 maolan_engine::audio_devices::discover_freebsd_audio_devices()
3697 .into_iter()
3698 .map(AudioDeviceOption::from)
3699 .collect()
3700 }
3701 #[cfg(target_os = "linux")]
3702 {
3703 let mut output_devices = platform_linux::discover_alsa_output_devices();
3704 let mut input_devices = platform_linux::discover_alsa_input_devices();
3705 output_devices.append(&mut input_devices);
3706 output_devices.sort_by_key(|device| device.label.to_lowercase());
3707 output_devices.dedup_by(|a, b| a.id == b.id);
3708 output_devices
3709 }
3710 #[cfg(target_os = "openbsd")]
3711 {
3712 vec![simple_audio_device("default")]
3713 }
3714 #[cfg(target_os = "windows")]
3715 {
3716 vec![simple_audio_device("default")]
3717 }
3718 #[cfg(not(any(
3719 target_os = "linux",
3720 target_os = "freebsd",
3721 target_os = "openbsd",
3722 target_os = "windows"
3723 )))]
3724 {
3725 vec![simple_audio_device("default")]
3726 }
3727}
3728
3729#[cfg(target_os = "linux")]
3730mod platform_linux {
3731 use alsa::{
3732 Direction,
3733 pcm::{Access, Format, HwParams, PCM},
3734 };
3735
3736 const SAMPLE_RATE_CANDIDATES: [u32; 12] = [
3737 8_000, 11_025, 16_000, 22_050, 32_000, 44_100, 48_000, 88_200, 96_000, 176_400, 192_000,
3738 384_000,
3739 ];
3740
3741 fn read_alsa_card_labels() -> std::collections::HashMap<u32, String> {
3742 let mut labels = std::collections::HashMap::new();
3743 let Ok(contents) = std::fs::read_to_string("/proc/asound/cards") else {
3744 return labels;
3745 };
3746 for line in contents.lines() {
3747 let line = line.trim_start();
3748 let Some((num_str, rest)) = line.split_once(' ') else {
3749 continue;
3750 };
3751 let Ok(card) = num_str.parse::<u32>() else {
3752 continue;
3753 };
3754 let Some((_, desc)) = rest.split_once("]:") else {
3755 continue;
3756 };
3757 let desc = desc.trim();
3758 if !desc.is_empty() {
3759 labels.insert(card, desc.to_string());
3760 }
3761 }
3762 labels
3763 }
3764
3765 fn probe_alsa_supported_bits(device: &str, direction: Direction) -> Vec<usize> {
3766 let Ok(pcm) = PCM::new(device, direction, false) else {
3767 return Vec::new();
3768 };
3769 let Ok(hwp) = HwParams::any(&pcm) else {
3770 return Vec::new();
3771 };
3772 if hwp.set_access(Access::RWInterleaved).is_err() {
3773 return Vec::new();
3774 }
3775
3776 fn supports(hwp: &HwParams<'_>, fmt: Format) -> bool {
3777 hwp.test_format(fmt).is_ok()
3778 }
3779
3780 let candidates: Vec<(usize, Vec<Format>)> = vec![
3781 (32, vec![native_s32(), foreign_s32()]),
3782 (24, vec![native_s24(), foreign_s24()]),
3783 (16, vec![native_s16(), foreign_s16()]),
3784 (8, vec![Format::S8]),
3785 ];
3786
3787 let mut supported = Vec::new();
3788 for (bits, formats) in candidates {
3789 if formats.iter().any(|f| supports(&hwp, *f)) {
3790 supported.push(bits);
3791 }
3792 }
3793 supported
3794 }
3795
3796 fn probe_alsa_supported_sample_rates(device: &str, direction: Direction) -> Vec<i32> {
3797 let Ok(pcm) = PCM::new(device, direction, false) else {
3798 return Vec::new();
3799 };
3800 let Ok(hwp) = HwParams::any(&pcm) else {
3801 return Vec::new();
3802 };
3803 if hwp.set_access(Access::RWInterleaved).is_err() {
3804 return Vec::new();
3805 }
3806
3807 let mut supported = Vec::new();
3808 for rate in SAMPLE_RATE_CANDIDATES {
3809 if hwp.test_rate(rate).is_ok() {
3810 supported.push(rate as i32);
3811 }
3812 }
3813 supported
3814 }
3815
3816 #[cfg(target_endian = "little")]
3817 fn native_s16() -> Format {
3818 Format::S16LE
3819 }
3820 #[cfg(target_endian = "big")]
3821 fn native_s16() -> Format {
3822 Format::S16BE
3823 }
3824 #[cfg(target_endian = "little")]
3825 fn foreign_s16() -> Format {
3826 Format::S16BE
3827 }
3828 #[cfg(target_endian = "big")]
3829 fn foreign_s16() -> Format {
3830 Format::S16LE
3831 }
3832
3833 #[cfg(target_endian = "little")]
3834 fn native_s24() -> Format {
3835 Format::S24LE
3836 }
3837 #[cfg(target_endian = "big")]
3838 fn native_s24() -> Format {
3839 Format::S24BE
3840 }
3841 #[cfg(target_endian = "little")]
3842 fn foreign_s24() -> Format {
3843 Format::S24BE
3844 }
3845 #[cfg(target_endian = "big")]
3846 fn foreign_s24() -> Format {
3847 Format::S24LE
3848 }
3849
3850 #[cfg(target_endian = "little")]
3851 fn native_s32() -> Format {
3852 Format::S32LE
3853 }
3854 #[cfg(target_endian = "big")]
3855 fn native_s32() -> Format {
3856 Format::S32BE
3857 }
3858 #[cfg(target_endian = "little")]
3859 fn foreign_s32() -> Format {
3860 Format::S32BE
3861 }
3862 #[cfg(target_endian = "big")]
3863 fn foreign_s32() -> Format {
3864 Format::S32LE
3865 }
3866
3867 fn discover_alsa_devices(
3868 direction_marker: &str,
3869 direction: Direction,
3870 ) -> Vec<super::AudioDeviceOption> {
3871 let mut devices = Vec::new();
3872 let card_labels = read_alsa_card_labels();
3873 if let Ok(contents) = std::fs::read_to_string("/proc/asound/pcm") {
3874 for line in contents.lines() {
3875 let Some((card_dev, rest)) = line.split_once(':') else {
3876 continue;
3877 };
3878 if !rest.contains(direction_marker) {
3879 continue;
3880 }
3881 let mut parts = card_dev.trim().split('-');
3882 let (Some(card), Some(dev)) = (parts.next(), parts.next()) else {
3883 continue;
3884 };
3885 let Ok(card) = card.parse::<u32>() else {
3886 continue;
3887 };
3888 let Ok(dev) = dev.parse::<u32>() else {
3889 continue;
3890 };
3891 let device_name = rest.split(':').next().unwrap_or("").trim();
3892 let card_label = card_labels
3893 .get(&card)
3894 .cloned()
3895 .unwrap_or_else(|| format!("Card {card}"));
3896 let base_label = if device_name.is_empty() {
3897 card_label
3898 } else {
3899 format!("{card_label} - {device_name}")
3900 };
3901 let id = format!("hw:{card},{dev}");
3902 let label = format!("{base_label} (hw:{card},{dev})");
3903 let supported_bits = probe_alsa_supported_bits(&id, direction);
3904 let supported_sample_rates = {
3905 let rates = probe_alsa_supported_sample_rates(&id, direction);
3906 if rates.is_empty() {
3907 super::fallback_sample_rates()
3908 } else {
3909 rates
3910 }
3911 };
3912 let (supports_input, supports_output) = match direction {
3913 Direction::Playback => (false, true),
3914 Direction::Capture => (true, false),
3915 };
3916 devices.push(super::AudioDeviceOption::with_supported_direction_caps(
3917 id,
3918 label,
3919 supported_bits,
3920 supported_sample_rates,
3921 supports_input,
3922 supports_output,
3923 ));
3924 }
3925 }
3926 devices.sort_by_key(|a| a.label.to_lowercase());
3927 devices.dedup_by(|a, b| a.id == b.id);
3928 devices
3929 }
3930
3931 pub(crate) fn discover_alsa_output_devices() -> Vec<super::AudioDeviceOption> {
3932 discover_alsa_devices("playback", Direction::Playback)
3933 }
3934
3935 pub(crate) fn discover_alsa_input_devices() -> Vec<super::AudioDeviceOption> {
3936 discover_alsa_devices("capture", Direction::Capture)
3937 }
3938}
3939
3940fn simple_audio_device(id: impl Into<String>) -> AudioDeviceOption {
3941 let id = id.into();
3942 AudioDeviceOption::with_supported_caps(
3943 id.clone(),
3944 id,
3945 vec![32, 24, 16, 8],
3946 fallback_sample_rates(),
3947 )
3948}
3949
3950fn fallback_sample_rates() -> Vec<i32> {
3951 vec![
3952 8_000, 11_025, 16_000, 22_050, 32_000, 44_100, 48_000, 88_200, 96_000, 176_400, 192_000,
3953 384_000,
3954 ]
3955}
3956
3957fn fallback_bits() -> Vec<usize> {
3958 vec![32, 24, 16, 8]
3959}
3960
3961fn sample_rate_options(setup: &StartupSetup) -> Vec<i32> {
3962 if setup.audio_engine.is_jack() {
3963 return fallback_sample_rates();
3964 }
3965 setup
3966 .output_device
3967 .as_ref()
3968 .map(|device| device.supported_sample_rates.clone())
3969 .filter(|rates| !rates.is_empty())
3970 .unwrap_or_else(fallback_sample_rates)
3971}
3972
3973fn bit_options(setup: &StartupSetup) -> Vec<usize> {
3974 if setup.audio_engine.is_jack() {
3975 return fallback_bits();
3976 }
3977 setup
3978 .output_device
3979 .as_ref()
3980 .map(|device| {
3981 if device.supported_bits.is_empty() {
3982 fallback_bits()
3983 } else {
3984 device.supported_bits.clone()
3985 }
3986 })
3987 .unwrap_or_else(fallback_bits)
3988}
3989
3990fn default_audio_device(engine: AudioEngineOption) -> &'static str {
3991 if engine.is_jack() {
3992 return "jack";
3993 }
3994 #[cfg(target_os = "linux")]
3995 {
3996 "default"
3997 }
3998 #[cfg(target_os = "freebsd")]
3999 {
4000 "/dev/dsp"
4001 }
4002 #[cfg(target_os = "openbsd")]
4003 {
4004 "default"
4005 }
4006 #[cfg(target_os = "windows")]
4007 {
4008 "default"
4009 }
4010 #[cfg(not(any(
4011 target_os = "linux",
4012 target_os = "freebsd",
4013 target_os = "openbsd",
4014 target_os = "windows"
4015 )))]
4016 {
4017 "default"
4018 }
4019}
4020
4021async fn open_audio_dialog() -> Option<PathBuf> {
4022 rfd::FileDialog::new()
4023 .add_filter(
4024 "Audio",
4025 &["wav", "flac", "mp3", "ogg", "vorbis", "m4a", "aac", "alac"],
4026 )
4027 .pick_file()
4028}
4029
4030async fn save_audio_dialog(current: Option<PathBuf>) -> Option<PathBuf> {
4031 let mut dialog =
4032 rfd::FileDialog::new().add_filter("Maolan audio export", &["wav", "flac", "mp3", "ogg"]);
4033 if let Some(path) = current.as_ref() {
4034 if let Some(parent) = path.parent() {
4035 dialog = dialog.set_directory(parent);
4036 }
4037 if let Some(name) = path.file_name() {
4038 dialog = dialog.set_file_name(name.to_string_lossy());
4039 }
4040 }
4041 dialog.save_file()
4042}
4043
4044async fn close_confirmation_dialog() -> rfd::MessageDialogResult {
4045 rfd::AsyncMessageDialog::new()
4046 .set_title("Unsaved Changes")
4047 .set_description("You have unsaved changes. Save before closing?")
4048 .set_buttons(rfd::MessageButtons::YesNoCancel)
4049 .show()
4050 .await
4051}
4052
4053fn clip_samples(samples: &[f32], channels: usize, region: Option<AudioRegion>) -> Vec<f32> {
4054 let channels = channels.max(1);
4055 let Some(region) = region else {
4056 return samples.to_vec();
4057 };
4058 let frames = samples.len() / channels;
4059 let start = region.offset.min(frames);
4060 let end = start.saturating_add(region.length).min(frames);
4061 samples[start * channels..end * channels].to_vec()
4062}
4063
4064fn deinterleave(samples: &[f32], channels: usize) -> Vec<Vec<f32>> {
4065 let channels = channels.max(1);
4066 let frames = samples.len() / channels;
4067 let mut output = vec![Vec::with_capacity(frames); channels];
4068 for frame in samples.chunks_exact(channels) {
4069 for (channel, sample) in frame.iter().copied().enumerate() {
4070 output[channel].push(sample);
4071 }
4072 }
4073 output
4074}
4075
4076fn encode_format_for_path(path: &Path) -> Result<AudioEncodeFormat, String> {
4077 let ext = path
4078 .extension()
4079 .and_then(|ext| ext.to_str())
4080 .map(str::to_ascii_lowercase)
4081 .ok_or_else(|| {
4082 String::from("Save path needs an audio extension: wav, flac, mp3, or ogg.")
4083 })?;
4084
4085 match ext.as_str() {
4086 "wav" => Ok(AudioEncodeFormat::Wav(WavBitDepth::Float32)),
4087 "flac" => Ok(AudioEncodeFormat::Flac(24)),
4088 "mp3" => Ok(AudioEncodeFormat::Mp3),
4089 "ogg" => Ok(AudioEncodeFormat::OggFlac(24)),
4090 _ => Err(format!(
4091 "Cannot save '{}': supported save formats are wav, flac, mp3, and ogg.",
4092 path.display()
4093 )),
4094 }
4095}
4096
4097fn app_style(_theme: &Theme) -> container::Style {
4098 container::Style {
4099 background: Some(Color::from_rgb(0.055, 0.06, 0.075).into()),
4100 text_color: Some(Color::from_rgb(0.88, 0.90, 0.94)),
4101 ..container::Style::default()
4102 }
4103}
4104
4105fn panel_style(_theme: &Theme) -> container::Style {
4106 container::Style {
4107 border: maolan_widgets::iced::Border {
4108 color: Color::from_rgb(0.18, 0.20, 0.24),
4109 width: 1.0,
4110 radius: 4.0.into(),
4111 },
4112 ..container::Style::default()
4113 }
4114}
4115
4116fn toolbar_style(_theme: &Theme) -> container::Style {
4117 container::Style {
4118 background: Some(Background::Color(Color::from_rgb(0.075, 0.08, 0.095))),
4119 border: Border {
4120 color: Color::from_rgb(0.16, 0.18, 0.22),
4121 width: 1.0,
4122 radius: 2.0.into(),
4123 },
4124 ..container::Style::default()
4125 }
4126}
4127
4128fn playhead_style(_theme: &Theme) -> container::Style {
4129 container::Style {
4130 text_color: Some(Color::from_rgb(0.92, 0.92, 0.92)),
4131 background: Some(Background::Color(Color::from_rgb(0.10, 0.10, 0.115))),
4132 border: Border {
4133 color: Color::from_rgb(0.22, 0.24, 0.28),
4134 width: 1.0,
4135 radius: 2.0.into(),
4136 },
4137 ..container::Style::default()
4138 }
4139}
4140
4141fn playhead_active_style(_theme: &Theme) -> container::Style {
4142 container::Style {
4143 text_color: Some(Color::from_rgb(0.92, 0.98, 0.92)),
4144 background: Some(Background::Color(Color::from_rgb(0.10, 0.16, 0.12))),
4145 border: Border {
4146 color: Color::from_rgb(0.22, 0.45, 0.26),
4147 width: 1.0,
4148 radius: 2.0.into(),
4149 },
4150 ..container::Style::default()
4151 }
4152}
4153
4154fn toolbar_button_style(theme: &Theme, status: button::Status) -> button::Style {
4155 let mut style = button::secondary(theme, status);
4156 style.border.radius = 3.0.into();
4157 style.border.width = 1.0;
4158 style.border.color = Color::from_rgb(0.18, 0.20, 0.24);
4159 style.text_color = Color::from_rgb(0.92, 0.92, 0.92);
4160 style.background = Some(Background::Color(Color::TRANSPARENT));
4161 style
4162}
4163
4164fn tooltip_style(_theme: &Theme) -> container::Style {
4165 container::Style {
4166 text_color: Some(Color::from_rgb(0.94, 0.94, 0.94)),
4167 background: Some(Background::Color(Color::from_rgba(0.08, 0.08, 0.08, 0.96))),
4168 border: Border {
4169 color: Color::from_rgba(0.32, 0.32, 0.32, 1.0),
4170 width: 1.0,
4171 radius: 3.0.into(),
4172 },
4173 ..container::Style::default()
4174 }
4175}
4176
4177#[cfg(test)]
4178mod tests {
4179 use super::*;
4180
4181 #[test]
4182 fn deinterleave_splits_channels() {
4183 assert_eq!(
4184 deinterleave(&[1.0, 2.0, 3.0, 4.0], 2),
4185 vec![vec![1.0, 3.0], vec![2.0, 4.0]]
4186 );
4187 }
4188
4189 #[test]
4190 fn clip_samples_extracts_frame_range() {
4191 let samples = [1.0, 10.0, 2.0, 20.0, 3.0, 30.0, 4.0, 40.0];
4192 assert_eq!(
4193 clip_samples(
4194 &samples,
4195 2,
4196 Some(AudioRegion {
4197 offset: 1,
4198 length: 2
4199 })
4200 ),
4201 vec![2.0, 20.0, 3.0, 30.0]
4202 );
4203 }
4204
4205 #[test]
4206 fn encode_format_matches_extensions() {
4207 assert!(matches!(
4208 encode_format_for_path(Path::new("x.wav")).unwrap(),
4209 AudioEncodeFormat::Wav(WavBitDepth::Float32)
4210 ));
4211 assert!(matches!(
4212 encode_format_for_path(Path::new("x.flac")).unwrap(),
4213 AudioEncodeFormat::Flac(24)
4214 ));
4215 assert!(matches!(
4216 encode_format_for_path(Path::new("x.mp3")).unwrap(),
4217 AudioEncodeFormat::Mp3
4218 ));
4219 assert!(matches!(
4220 encode_format_for_path(Path::new("x.ogg")).unwrap(),
4221 AudioEncodeFormat::OggFlac(24)
4222 ));
4223 }
4224
4225 #[test]
4226 fn apply_edit_to_samples_fades_in_region() {
4227 let mut samples = vec![1.0f32; 8];
4228 apply_edit_to_samples(
4229 &mut samples,
4230 1,
4231 AudioRegion {
4232 offset: 2,
4233 length: 4,
4234 },
4235 EditOperation::FadeIn,
4236 );
4237 assert_eq!(samples, vec![1.0, 1.0, 0.0, 0.25, 0.5, 0.75, 1.0, 1.0]);
4238 }
4239
4240 #[test]
4241 fn apply_edit_to_samples_fades_out_region() {
4242 let mut samples = vec![1.0f32; 8];
4243 apply_edit_to_samples(
4244 &mut samples,
4245 1,
4246 AudioRegion {
4247 offset: 2,
4248 length: 4,
4249 },
4250 EditOperation::FadeOut,
4251 );
4252 assert_eq!(samples, vec![1.0, 1.0, 0.75, 0.5, 0.25, 0.0, 1.0, 1.0]);
4253 }
4254
4255 #[test]
4256 fn apply_edit_to_samples_adjusts_volume_in_region() {
4257 let mut samples = vec![0.5f32; 8];
4258 apply_edit_to_samples(
4259 &mut samples,
4260 1,
4261 AudioRegion {
4262 offset: 2,
4263 length: 4,
4264 },
4265 EditOperation::IncreaseVolume,
4266 );
4267 let expected_gain = 10.0f32.powf(1.0 / 20.0);
4268 for (index, sample) in samples.iter().enumerate() {
4269 let expected = if (2..6).contains(&index) {
4270 0.5 * expected_gain
4271 } else {
4272 0.5
4273 };
4274 assert!((sample - expected).abs() < 1.0e-5, "index {index}");
4275 }
4276 }
4277
4278 #[test]
4279 fn apply_edit_to_samples_decreases_volume_in_region() {
4280 let mut samples = vec![1.0f32; 8];
4281 apply_edit_to_samples(
4282 &mut samples,
4283 1,
4284 AudioRegion {
4285 offset: 2,
4286 length: 4,
4287 },
4288 EditOperation::DecreaseVolume,
4289 );
4290 let expected_gain = 10.0f32.powf(-1.0 / 20.0);
4291 for (index, sample) in samples.iter().enumerate() {
4292 let expected = if (2..6).contains(&index) {
4293 expected_gain
4294 } else {
4295 1.0
4296 };
4297 assert!((sample - expected).abs() < 1.0e-5, "index {index}");
4298 }
4299 }
4300
4301 #[test]
4302 fn apply_edit_to_samples_clamps_region_to_bounds() {
4303 let mut samples = vec![1.0f32; 4];
4304 apply_edit_to_samples(
4305 &mut samples,
4306 1,
4307 AudioRegion {
4308 offset: 2,
4309 length: 100,
4310 },
4311 EditOperation::FadeIn,
4312 );
4313 assert_eq!(samples, vec![1.0, 1.0, 0.0, 0.5]);
4314 }
4315
4316 #[test]
4317 fn history_tracks_dirty_state_against_save_point() {
4318 let mut history = EditHistory::new(DocumentSnapshot {
4319 samples: vec![1.0f32],
4320 edits: AudioEdits::default(),
4321 markers: Vec::new(),
4322 });
4323 assert!(!history.is_dirty());
4324
4325 history.record(
4326 DocumentSnapshot {
4327 samples: vec![1.0f32],
4328 edits: AudioEdits::default(),
4329 markers: Vec::new(),
4330 },
4331 DocumentSnapshot {
4332 samples: vec![2.0f32],
4333 edits: AudioEdits::default(),
4334 markers: Vec::new(),
4335 },
4336 );
4337 assert!(history.is_dirty());
4338
4339 history.mark_saved();
4340 assert!(!history.is_dirty());
4341 }
4342
4343 #[test]
4344 fn history_undo_redo_restores_states() {
4345 let mut history = EditHistory::new(DocumentSnapshot {
4346 samples: vec![1.0f32],
4347 edits: AudioEdits::default(),
4348 markers: Vec::new(),
4349 });
4350 history.record(
4351 DocumentSnapshot {
4352 samples: vec![1.0f32],
4353 edits: AudioEdits::default(),
4354 markers: Vec::new(),
4355 },
4356 DocumentSnapshot {
4357 samples: vec![2.0f32],
4358 edits: AudioEdits::default(),
4359 markers: Vec::new(),
4360 },
4361 );
4362 history.record(
4363 DocumentSnapshot {
4364 samples: vec![2.0f32],
4365 edits: AudioEdits::default(),
4366 markers: Vec::new(),
4367 },
4368 DocumentSnapshot {
4369 samples: vec![3.0f32],
4370 edits: AudioEdits::default(),
4371 markers: Vec::new(),
4372 },
4373 );
4374
4375 let undone = history.undo().expect("can undo");
4376 assert_eq!(undone.samples, vec![2.0f32]);
4377
4378 let undone_again = history.undo().expect("can undo again");
4379 assert_eq!(undone_again.samples, vec![1.0f32]);
4380 assert!(history.undo().is_none());
4381
4382 let redone = history.redo().expect("can redo");
4383 assert_eq!(redone.samples, vec![2.0f32]);
4384 }
4385
4386 #[test]
4387 fn history_record_clears_redo_stack() {
4388 let mut history = EditHistory::new(DocumentSnapshot {
4389 samples: vec![1.0f32],
4390 edits: AudioEdits::default(),
4391 markers: Vec::new(),
4392 });
4393 history.record(
4394 DocumentSnapshot {
4395 samples: vec![1.0f32],
4396 edits: AudioEdits::default(),
4397 markers: Vec::new(),
4398 },
4399 DocumentSnapshot {
4400 samples: vec![2.0f32],
4401 edits: AudioEdits::default(),
4402 markers: Vec::new(),
4403 },
4404 );
4405 history.undo();
4406 history.record(
4407 DocumentSnapshot {
4408 samples: vec![1.0f32],
4409 edits: AudioEdits::default(),
4410 markers: Vec::new(),
4411 },
4412 DocumentSnapshot {
4413 samples: vec![3.0f32],
4414 edits: AudioEdits::default(),
4415 markers: Vec::new(),
4416 },
4417 );
4418 assert!(history.redo().is_none());
4419 }
4420
4421 fn test_document(preview: Vec<f32>, channels: usize) -> AudioDocument {
4422 let channel_samples = deinterleave(&preview, channels);
4423 AudioDocument {
4424 source_path: PathBuf::new(),
4425 save_path: None,
4426 samples: preview.clone(),
4427 preview_samples: preview,
4428 channels,
4429 sample_rate: 48_000,
4430 channel_samples,
4431 peak: 1.0,
4432 clip_region: None,
4433 edits: AudioEdits::default(),
4434 markers: Vec::new(),
4435 }
4436 }
4437
4438 #[test]
4439 fn next_zero_crossing_finds_positive_to_negative_crossing() {
4440 let audio = test_document(vec![1.0f32, -1.0], 1);
4441 assert_eq!(audio.next_zero_crossing_frame(0), Some(1));
4442 }
4443
4444 #[test]
4445 fn next_zero_crossing_finds_negative_to_positive_crossing() {
4446 let audio = test_document(vec![-1.0f32, -0.5, 0.5, 1.0], 1);
4447 assert_eq!(audio.next_zero_crossing_frame(0), Some(2));
4448 }
4449
4450 #[test]
4451 fn next_zero_crossing_detects_exact_zero_sample() {
4452 let audio = test_document(vec![1.0f32, 0.0, -1.0], 1);
4453 assert_eq!(audio.next_zero_crossing_frame(0), Some(1));
4454 }
4455
4456 #[test]
4457 fn next_zero_crossing_returns_none_when_no_crossing() {
4458 let audio = test_document(vec![0.1f32, 0.2, 0.3], 1);
4459 assert_eq!(audio.next_zero_crossing_frame(0), None);
4460 }
4461
4462 #[test]
4463 fn next_zero_crossing_respects_start_frame() {
4464 let audio = test_document(vec![1.0f32, -1.0, 1.0, -1.0], 1);
4465 assert_eq!(audio.next_zero_crossing_frame(1), Some(2));
4466 }
4467
4468 #[test]
4469 fn next_zero_crossing_returns_none_past_end() {
4470 let audio = test_document(vec![1.0f32, -1.0], 1);
4471 assert_eq!(audio.next_zero_crossing_frame(1), None);
4472 }
4473
4474 #[test]
4475 fn next_zero_crossing_averages_multiple_channels() {
4476 let audio = test_document(vec![1.0f32, 1.0, 1.0, -1.0], 2);
4478 assert_eq!(audio.next_zero_crossing_frame(0), Some(1));
4479 }
4480
4481 fn app_with_markers(markers: Vec<(usize, String)>, frames: usize) -> EditApp {
4482 let mut audio = test_document(vec![0.0f32; frames], 1);
4483 audio.markers = markers;
4484 EditApp {
4485 standalone_ready: true,
4486 audio: Some(audio),
4487 ..EditApp::default()
4488 }
4489 }
4490
4491 #[test]
4492 fn playhead_moved_sets_playhead_from_ratio() {
4493 let mut app = app_with_markers(Vec::new(), 100);
4494 let _ = update(&mut app, Message::PlayheadMoved(0.25));
4495 assert_eq!(app.playhead_samples, 25);
4496 }
4497
4498 #[test]
4499 fn embedded_play_without_engine_starts_preview_playback() {
4500 let mut app = EditApp {
4501 standalone_ready: false,
4502 audio: Some(test_document(vec![0.0f32; 100], 1)),
4503 ..EditApp::default()
4504 };
4505
4506 let _ = update(&mut app, Message::Play);
4507
4508 assert!(app.playing);
4509 assert_eq!(app.status, "Playing preview.");
4510 }
4511
4512 #[test]
4513 fn select_marker_region_selects_between_markers() {
4514 let mut app = app_with_markers(vec![(20, "A".to_string()), (60, "B".to_string())], 100);
4515 let _ = update(&mut app, Message::SelectMarkerRegion(0.4));
4516 assert_eq!(app.selection_samples, Some((20, 60)));
4517 }
4518
4519 #[test]
4520 fn select_marker_region_selects_start_to_first_marker() {
4521 let mut app = app_with_markers(vec![(50, "A".to_string())], 100);
4522 let _ = update(&mut app, Message::SelectMarkerRegion(0.25));
4523 assert_eq!(app.selection_samples, Some((0, 50)));
4524 }
4525
4526 #[test]
4527 fn select_marker_region_selects_last_marker_to_end() {
4528 let mut app = app_with_markers(vec![(50, "A".to_string())], 100);
4529 let _ = update(&mut app, Message::SelectMarkerRegion(0.75));
4530 assert_eq!(app.selection_samples, Some((50, 100)));
4531 }
4532
4533 #[test]
4534 fn detect_markers_places_boundaries_around_sound_regions() {
4535 let mut samples = vec![0.0f32; 10];
4537 samples.extend(vec![0.8f32; 10]);
4538 samples.extend(vec![0.0f32; 10]);
4539 samples.extend(vec![0.8f32; 10]);
4540 samples.extend(vec![0.0f32; 10]);
4541 let markers = detect_markers(&samples, 1, -60.0, 5);
4542 assert_eq!(
4543 markers,
4544 vec![
4545 (10, "Region 1".to_string()),
4546 (20, "Region 2".to_string()),
4547 (30, "Region 3".to_string()),
4548 (40, "Region 4".to_string()),
4549 ]
4550 );
4551 }
4552
4553 #[test]
4554 fn detect_markers_ignores_short_silence() {
4555 let mut samples = vec![0.0f32; 10];
4557 samples.extend(vec![0.8f32; 5]);
4558 samples.extend(vec![0.0f32; 2]);
4559 samples.extend(vec![0.8f32; 8]);
4560 samples.extend(vec![0.0f32; 10]);
4561 let markers = detect_markers(&samples, 1, -60.0, 5);
4562 assert_eq!(
4563 markers,
4564 vec![(10, "Region 1".to_string()), (25, "Region 2".to_string()),]
4565 );
4566 }
4567
4568 #[test]
4569 fn detect_markers_all_silence_returns_empty() {
4570 let samples = vec![0.0f32; 100];
4571 let markers = detect_markers(&samples, 1, -60.0, 5);
4572 assert!(markers.is_empty());
4573 }
4574
4575 #[test]
4576 fn detect_markers_all_sound_returns_single_region() {
4577 let samples = vec![0.8f32; 100];
4578 let markers = detect_markers(&samples, 1, -60.0, 5);
4579 assert_eq!(
4580 markers,
4581 vec![(0, "Region 1".to_string()), (100, "Region 2".to_string()),]
4582 );
4583 }
4584
4585 #[test]
4586 fn detect_markers_confirm_adds_detected_markers() {
4587 let mut samples = vec![0.0f32; 10];
4588 samples.extend(vec![0.8f32; 10]);
4589 samples.extend(vec![0.0f32; 10]);
4590 let audio = test_document(samples, 1);
4591 let mut app = EditApp {
4592 standalone_ready: true,
4593 audio: Some(audio),
4594 detect_markers_dialog: Some(DetectMarkersDialog {
4595 threshold_db: String::from("-60.0"),
4596 silence_samples: String::from("5"),
4597 }),
4598 ..EditApp::default()
4599 };
4600 let _ = update(&mut app, Message::DetectMarkersConfirm);
4601 assert!(app.detect_markers_dialog.is_none());
4602 assert_eq!(
4603 app.audio.as_ref().unwrap().markers,
4604 vec![(10, "Region 1".to_string()), (20, "Region 2".to_string()),]
4605 );
4606 }
4607
4608 #[test]
4609 fn detect_markers_confirm_rejects_invalid_input() {
4610 let audio = test_document(vec![0.8f32; 10], 1);
4611 let mut app = EditApp {
4612 standalone_ready: true,
4613 audio: Some(audio),
4614 detect_markers_dialog: Some(DetectMarkersDialog {
4615 threshold_db: String::from("not a number"),
4616 silence_samples: String::from("5"),
4617 }),
4618 ..EditApp::default()
4619 };
4620 let _ = update(&mut app, Message::DetectMarkersConfirm);
4621 assert!(app.audio.as_ref().unwrap().markers.is_empty());
4622 assert!(app.status.contains("Invalid"));
4623 }
4624
4625 #[test]
4626 fn detect_markers_cancel_closes_dialog() {
4627 let mut app = EditApp {
4628 detect_markers_dialog: Some(DetectMarkersDialog::default()),
4629 ..EditApp::default()
4630 };
4631 let _ = update(&mut app, Message::DetectMarkersCancel);
4632 assert!(app.detect_markers_dialog.is_none());
4633 }
4634
4635 #[test]
4636 fn selection_resize_moves_start_when_clicked_before_range() {
4637 let mut app = app_with_markers(Vec::new(), 100);
4638 app.selection_samples = Some((40, 80));
4639 let _ = update(&mut app, Message::SelectionResize(0.1));
4640 assert_eq!(app.selection_samples, Some((10, 80)));
4641 }
4642
4643 #[test]
4644 fn selection_resize_moves_end_when_clicked_after_range() {
4645 let mut app = app_with_markers(Vec::new(), 100);
4646 app.selection_samples = Some((40, 80));
4647 let _ = update(&mut app, Message::SelectionResize(0.95));
4648 assert_eq!(app.selection_samples, Some((40, 95)));
4649 }
4650
4651 #[test]
4652 fn selection_resize_moves_nearest_edge_when_clicked_inside_range() {
4653 let mut app = app_with_markers(Vec::new(), 100);
4654 app.selection_samples = Some((20, 80));
4655 let _ = update(&mut app, Message::SelectionResize(0.3));
4656 assert_eq!(app.selection_samples, Some((30, 80)));
4657
4658 let _ = update(&mut app, Message::SelectionResize(0.7));
4659 assert_eq!(app.selection_samples, Some((30, 70)));
4660 }
4661
4662 #[test]
4663 fn selection_resize_ignored_when_no_selection() {
4664 let mut app = app_with_markers(Vec::new(), 100);
4665 let _ = update(&mut app, Message::SelectionResize(0.5));
4666 assert_eq!(app.selection_samples, None);
4667 }
4668
4669 #[test]
4670 fn delete_selection_removes_range_and_shifts_markers() {
4671 let mut app = app_with_markers(vec![(1, "A".to_string()), (8, "B".to_string())], 10);
4672 let audio = app.audio.as_mut().unwrap();
4673 audio.samples = (0..10).map(|i| i as f32).collect();
4674 audio.rebuild_preview();
4675 app.selection_samples = Some((3, 6));
4676 let _ = update(&mut app, Message::DeleteSelection);
4677 let audio = app.audio.as_ref().unwrap();
4678 assert_eq!(audio.frames(), 7);
4679 assert_eq!(audio.samples, vec![0.0, 1.0, 2.0, 6.0, 7.0, 8.0, 9.0]);
4680 assert_eq!(
4681 audio.markers,
4682 vec![(1, "A".to_string()), (5, "B".to_string())]
4683 );
4684 assert!(app.selection_samples.is_none());
4685 assert!(app.history.is_dirty());
4686 }
4687
4688 #[test]
4689 fn delete_selection_ignored_when_nothing_selected() {
4690 let mut app = app_with_markers(vec![(1, "A".to_string())], 5);
4691 let audio = app.audio.as_mut().unwrap();
4692 audio.samples = vec![0.0, 1.0, 2.0, 3.0, 4.0];
4693 audio.rebuild_preview();
4694 let original = audio.samples.clone();
4695 let _ = update(&mut app, Message::DeleteSelection);
4696 let audio = app.audio.as_ref().unwrap();
4697 assert_eq!(audio.samples, original);
4698 assert_eq!(audio.markers, vec![(1, "A".to_string())]);
4699 assert!(!app.history.is_dirty());
4700 }
4701
4702 #[test]
4703 fn delete_selection_undo_restores_document() {
4704 let mut app = app_with_markers(vec![(1, "A".to_string()), (8, "B".to_string())], 10);
4705 let audio = app.audio.as_mut().unwrap();
4706 audio.samples = (0..10).map(|i| i as f32).collect();
4707 audio.rebuild_preview();
4708 let original_samples = audio.samples.clone();
4709 let original_markers = audio.markers.clone();
4710 app.selection_samples = Some((3, 6));
4711 let _ = update(&mut app, Message::DeleteSelection);
4712 assert!(app.history.is_dirty());
4713 let _ = update(&mut app, Message::Undo);
4714 let audio = app.audio.as_ref().unwrap();
4715 assert_eq!(audio.samples, original_samples);
4716 assert_eq!(audio.markers, original_markers);
4717 assert!(!app.history.is_dirty());
4718 }
4719
4720 #[test]
4721 fn marker_ranges_split_at_sorted_markers() {
4722 let markers = vec![(50, "A".to_string()), (20, "B".to_string())];
4723 assert_eq!(
4724 marker_ranges(&markers, 100),
4725 vec![(0, 20), (20, 50), (50, 100)]
4726 );
4727 }
4728
4729 #[test]
4730 fn marker_ranges_ignores_out_of_bounds_markers() {
4731 let markers = vec![(150, "A".to_string())];
4732 assert_eq!(marker_ranges(&markers, 100), vec![(0, 100)]);
4733 }
4734
4735 #[test]
4736 fn marker_range_samples_extracts_interleaved_range() {
4737 let audio = test_document(vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], 2);
4738 assert_eq!(marker_range_samples(&audio, 0, 2), vec![1.0, 2.0, 3.0, 4.0]);
4739 }
4740
4741 #[test]
4742 fn export_filename_includes_index_and_extension() {
4743 assert_eq!(export_filename("track", 7, "wav"), "track_007.wav");
4744 }
4745
4746 #[test]
4747 fn export_encode_format_maps_formats() {
4748 assert!(matches!(
4749 export_encode_format(ExportFormat::Wav, ExportBitDepth::Bits24),
4750 AudioEncodeFormat::Wav(WavBitDepth::Int24)
4751 ));
4752 assert!(matches!(
4753 export_encode_format(ExportFormat::Flac, ExportBitDepth::Bits16),
4754 AudioEncodeFormat::Flac(16)
4755 ));
4756 assert!(matches!(
4757 export_encode_format(ExportFormat::OggFlac, ExportBitDepth::Bits32),
4758 AudioEncodeFormat::OggFlac(32)
4759 ));
4760 assert!(matches!(
4761 export_encode_format(ExportFormat::Mp3, ExportBitDepth::Bits24),
4762 AudioEncodeFormat::Mp3
4763 ));
4764 }
4765
4766 #[test]
4767 fn resample_interleaved_identity_when_rates_match() {
4768 let samples = vec![0.1f32, 0.2, 0.3, 0.4, 0.5, 0.6];
4769 let output = resample_interleaved(&samples, 2, 48_000, 48_000).unwrap();
4770 assert_eq!(output, samples);
4771 }
4772
4773 #[test]
4774 fn resample_interleaved_changes_length_when_rates_differ() {
4775 let samples: Vec<f32> = (0..960).map(|i| (i as f32 / 960.0).sin()).collect();
4776 let output = resample_interleaved(&samples, 1, 48_000, 24_000).unwrap();
4777 assert!(!output.is_empty());
4778 assert!(output.len() < samples.len());
4779 }
4780
4781 #[tokio::test]
4782 async fn export_marker_ranges_creates_files() {
4783 let dir = std::env::temp_dir().join(format!(
4784 "maolan-edit-export-test-{}",
4785 std::time::UNIX_EPOCH.elapsed().unwrap().as_secs()
4786 ));
4787 let _ = std::fs::remove_dir_all(&dir);
4788 let mut audio = test_document(vec![0.5f32; 48_000], 1);
4789 audio.source_path = PathBuf::from("test_track.wav");
4790 audio.markers = vec![(12_000, "A".to_string()), (36_000, "B".to_string())];
4791 let result = export_marker_ranges(
4792 dir.clone(),
4793 audio,
4794 ExportFormat::Wav,
4795 ExportBitDepth::Bits16,
4796 48_000,
4797 )
4798 .await;
4799 assert_eq!(result.unwrap(), 3);
4800 assert!(dir.join("test_track_001.wav").exists());
4801 assert!(dir.join("test_track_002.wav").exists());
4802 assert!(dir.join("test_track_003.wav").exists());
4803 let _ = std::fs::remove_dir_all(&dir);
4804 }
4805
4806 #[test]
4807 fn preferences_save_to_path_preserves_other_keys() {
4808 let dir = std::env::temp_dir().join(format!(
4809 "maolan-edit-prefs-preserve-{}",
4810 std::time::UNIX_EPOCH.elapsed().unwrap().as_secs()
4811 ));
4812 let _ = std::fs::remove_dir_all(&dir);
4813 let config_path = dir.join("config.toml");
4814 std::fs::create_dir_all(&dir).unwrap();
4815 std::fs::write(
4816 &config_path,
4817 "existing_key = \"keep me\"\ndefault_output_device_id = \"old\"\n",
4818 )
4819 .unwrap();
4820
4821 let preferences = EditorPreferences {
4822 default_output_device_id: Some(String::from("new_out")),
4823 default_input_device_id: Some(String::from("new_in")),
4824 };
4825 preferences.save_to_path(&config_path).unwrap();
4826
4827 let saved = std::fs::read_to_string(&config_path).unwrap();
4828 assert!(saved.contains("existing_key = \"keep me\""));
4829 assert!(saved.contains("default_output_device_id = \"new_out\""));
4830 assert!(saved.contains("default_input_device_id = \"new_in\""));
4831 let _ = std::fs::remove_dir_all(&dir);
4832 }
4833
4834 #[test]
4835 fn preferences_save_removes_empty_ids() {
4836 let dir = std::env::temp_dir().join(format!(
4837 "maolan-edit-prefs-remove-{}",
4838 std::time::UNIX_EPOCH.elapsed().unwrap().as_secs()
4839 ));
4840 let _ = std::fs::remove_dir_all(&dir);
4841 let config_path = dir.join("config.toml");
4842 std::fs::create_dir_all(&dir).unwrap();
4843 std::fs::write(
4844 &config_path,
4845 "default_output_device_id = \"old\"\ndefault_input_device_id = \"old\"\n",
4846 )
4847 .unwrap();
4848
4849 let preferences = EditorPreferences {
4850 default_output_device_id: None,
4851 default_input_device_id: None,
4852 };
4853 preferences.save_to_path(&config_path).unwrap();
4854
4855 let saved = std::fs::read_to_string(&config_path).unwrap();
4856 assert!(!saved.contains("default_output_device_id"));
4857 assert!(!saved.contains("default_input_device_id"));
4858 let _ = std::fs::remove_dir_all(&dir);
4859 }
4860
4861 #[test]
4862 fn preferences_dialog_opens_from_setup() {
4863 let mut app = app_with_markers(Vec::new(), 100);
4864 app.setup.output_devices = vec![AudioDeviceOption::with_supported_caps(
4865 "out1",
4866 "Out One",
4867 vec![32],
4868 vec![48_000],
4869 )];
4870 app.setup.input_devices = vec![AudioDeviceOption::with_supported_caps(
4871 "in1",
4872 "In One",
4873 vec![32],
4874 vec![48_000],
4875 )];
4876 app.setup.output_device = Some(app.setup.output_devices[0].clone());
4877 app.setup.input_device = Some(app.setup.input_devices[0].clone());
4878 let _ = update(&mut app, Message::PreferencesDialog);
4879 let dialog = app.preferences_dialog.as_ref().unwrap();
4880 assert_eq!(dialog.output_devices.len(), 1);
4881 assert_eq!(dialog.input_devices.len(), 1);
4882 assert_eq!(dialog.output_device.as_ref().unwrap().id, "out1");
4883 assert_eq!(dialog.input_device.as_ref().unwrap().id, "in1");
4884 }
4885
4886 #[test]
4887 fn preferences_save_updates_setup() {
4888 let mut app = app_with_markers(Vec::new(), 100);
4889 let out_devices = vec![
4890 AudioDeviceOption::with_supported_caps("out1", "Out One", vec![32], vec![48_000]),
4891 AudioDeviceOption::with_supported_caps("out2", "Out Two", vec![32], vec![48_000]),
4892 ];
4893 let in_devices = vec![
4894 AudioDeviceOption::with_supported_caps("in1", "In One", vec![32], vec![48_000]),
4895 AudioDeviceOption::with_supported_caps("in2", "In Two", vec![32], vec![48_000]),
4896 ];
4897 app.setup.output_devices = out_devices.clone();
4898 app.setup.input_devices = in_devices.clone();
4899 app.preferences_dialog = Some(PreferencesDialog::from_setup(&app.setup));
4900 let _ = update(
4901 &mut app,
4902 Message::PreferencesOutputDeviceSelected(out_devices[1].clone()),
4903 );
4904 let _ = update(
4905 &mut app,
4906 Message::PreferencesInputDeviceSelected(in_devices[1].clone()),
4907 );
4908 let _ = update(&mut app, Message::PreferencesSave);
4909 assert!(app.preferences_dialog.is_none());
4910 assert_eq!(app.setup.output_device.as_ref().unwrap().id, "out2");
4911 assert_eq!(app.setup.input_device.as_ref().unwrap().id, "in2");
4912 }
4913
4914 #[test]
4915 fn preferences_cancel_closes_dialog() {
4916 let mut app = EditApp {
4917 preferences_dialog: Some(PreferencesDialog::from_setup(&StartupSetup::default())),
4918 ..EditApp::default()
4919 };
4920 let _ = update(&mut app, Message::PreferencesCancel);
4921 assert!(app.preferences_dialog.is_none());
4922 }
4923
4924 #[test]
4925 fn audio_device_option_equality_compares_id_only() {
4926 let a = AudioDeviceOption::with_supported_caps("dev", "A", vec![16], vec![44_100]);
4927 let b = AudioDeviceOption::with_supported_caps("dev", "B", vec![32], vec![48_000]);
4928 assert_eq!(a, b);
4929 }
4930
4931 #[cfg(target_os = "freebsd")]
4932 #[test]
4933 fn audio_setup_state_selects_saved_device_from_discovered_list() {
4934 let mut app = EditApp::default();
4935 app.setup.audio_engine = AudioEngineOption::Oss;
4936 app.setup.output_devices = vec![AudioDeviceOption::with_oss_caps(
4937 "/dev/dsp0",
4938 "Out",
4939 vec![16, 24, 32],
4940 vec![44_100, 48_000],
4941 2,
4942 65_536,
4943 )];
4944 app.setup.output_device = Some(AudioDeviceOption::with_oss_caps(
4945 "/dev/dsp0",
4946 "Out",
4947 vec![32],
4948 vec![48_000],
4949 2,
4950 65_536,
4951 ));
4952 app.setup.input_devices = vec![AudioDeviceOption::with_oss_caps(
4953 "/dev/dsp1",
4954 "In",
4955 vec![16, 24, 32],
4956 vec![44_100, 48_000],
4957 2,
4958 65_536,
4959 )];
4960 app.setup.input_device = Some(AudioDeviceOption::with_oss_caps(
4961 "/dev/dsp1",
4962 "In",
4963 vec![32],
4964 vec![48_000],
4965 2,
4966 65_536,
4967 ));
4968
4969 let state = audio_setup_state(&app);
4970
4971 assert_eq!(
4972 state.selected_output_device.as_ref().map(|d| d.id.as_str()),
4973 Some("/dev/dsp0")
4974 );
4975 assert_eq!(
4976 state.selected_input_device.as_ref().map(|d| d.id.as_str()),
4977 Some("/dev/dsp1")
4978 );
4979 assert_eq!(
4980 state.selected_output_device,
4981 Some(app.setup.output_devices[0].clone())
4982 );
4983 assert_eq!(
4984 state.selected_input_device,
4985 Some(app.setup.input_devices[0].clone())
4986 );
4987 }
4988
4989 #[test]
4990 fn preferences_load_from_path_reads_device_ids() {
4991 let dir = std::env::temp_dir().join(format!(
4992 "maolan-edit-prefs-load-{}",
4993 std::time::UNIX_EPOCH.elapsed().unwrap().as_secs()
4994 ));
4995 let _ = std::fs::remove_dir_all(&dir);
4996 std::fs::create_dir_all(&dir).unwrap();
4997 let config_path = dir.join("edit.toml");
4998 let contents =
4999 "default_output_device_id = \"/dev/dsp2\"\ndefault_input_device_id = \"/dev/dsp3\"\n";
5000 std::fs::write(&config_path, contents).unwrap();
5001
5002 let preferences = EditorPreferences::load_from_path(&config_path);
5003
5004 assert_eq!(
5005 preferences.default_output_device_id.as_deref(),
5006 Some("/dev/dsp2")
5007 );
5008 assert_eq!(
5009 preferences.default_input_device_id.as_deref(),
5010 Some("/dev/dsp3")
5011 );
5012 let _ = std::fs::remove_dir_all(&dir);
5013 }
5014
5015 #[test]
5016 fn startup_setup_with_preferences_selects_saved_devices() {
5017 let preferences = EditorPreferences {
5018 default_output_device_id: Some(String::from("out2")),
5019 default_input_device_id: Some(String::from("in2")),
5020 };
5021 let output_devices = vec![
5022 AudioDeviceOption::with_supported_caps("out1", "Out One", vec![32], vec![48_000]),
5023 AudioDeviceOption::with_supported_caps("out2", "Out Two", vec![32], vec![48_000]),
5024 ];
5025 let input_devices = vec![
5026 AudioDeviceOption::with_supported_caps("in1", "In One", vec![32], vec![48_000]),
5027 AudioDeviceOption::with_supported_caps("in2", "In Two", vec![32], vec![48_000]),
5028 ];
5029
5030 let setup = StartupSetup::with_preferences(&preferences, output_devices, input_devices);
5031
5032 assert_eq!(
5033 setup.output_device.as_ref().map(|d| d.id.as_str()),
5034 Some("out2")
5035 );
5036 assert_eq!(
5037 setup.input_device.as_ref().map(|d| d.id.as_str()),
5038 Some("in2")
5039 );
5040 }
5041
5042 #[test]
5043 fn startup_setup_with_preferences_falls_back_to_first_device() {
5044 let preferences = EditorPreferences {
5045 default_output_device_id: Some(String::from("missing")),
5046 default_input_device_id: Some(String::from("missing")),
5047 };
5048 let output_devices = vec![AudioDeviceOption::with_supported_caps(
5049 "out1",
5050 "Out One",
5051 vec![32],
5052 vec![48_000],
5053 )];
5054 let input_devices = vec![AudioDeviceOption::with_supported_caps(
5055 "in1",
5056 "In One",
5057 vec![32],
5058 vec![48_000],
5059 )];
5060
5061 let setup = StartupSetup::with_preferences(&preferences, output_devices, input_devices);
5062
5063 assert_eq!(
5064 setup.output_device.as_ref().map(|d| d.id.as_str()),
5065 Some("out1")
5066 );
5067 assert_eq!(
5068 setup.input_device.as_ref().map(|d| d.id.as_str()),
5069 Some("in1")
5070 );
5071 }
5072}