diff --git a/raumklang-gui/src/data.rs b/raumklang-gui/src/data.rs index 1759bfe..ca7cab5 100644 --- a/raumklang-gui/src/data.rs +++ b/raumklang-gui/src/data.rs @@ -1,4 +1,5 @@ pub mod chart; +pub mod directory; pub mod frequency_response; pub mod impulse_response; pub mod measurement; diff --git a/raumklang-gui/src/data/directory.rs b/raumklang-gui/src/data/directory.rs new file mode 100644 index 0000000..c8d7095 --- /dev/null +++ b/raumklang-gui/src/data/directory.rs @@ -0,0 +1,12 @@ +use std::path::Path; +use std::sync::LazyLock; + +pub fn data() -> &'static Path { + PROJECT + .as_ref() + .map(directories::ProjectDirs::data_dir) + .unwrap_or(Path::new("./data")) +} + +static PROJECT: LazyLock> = + LazyLock::new(|| directories::ProjectDirs::from("de", "henku", "raumklang")); diff --git a/raumklang-gui/src/data/project.rs b/raumklang-gui/src/data/project.rs index 4642b53..a85515a 100644 --- a/raumklang-gui/src/data/project.rs +++ b/raumklang-gui/src/data/project.rs @@ -1,22 +1,74 @@ +use serde::{Deserialize, Serialize}; +use tokio::fs; + use std::{ - io, + fmt, io, path::{Path, PathBuf}, }; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Project { pub loopback: Option, pub measurements: Vec, + #[serde(default)] + pub measurement_operation: Operation, + pub export_from_memory: bool, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Loopback(pub Measurement); -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +impl Loopback { + pub fn new(path: PathBuf) -> Self { + Self(Measurement { path }) + } + + pub async fn copy(&mut self, dest: impl AsRef) { + self.0.copy(dest).await + } + + pub async fn rename(&mut self, dest: impl AsRef) { + self.0.rename(dest).await + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Measurement { pub path: PathBuf, } +impl Measurement { + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + pub async fn copy(&mut self, dest: impl AsRef) { + let dest = dest.as_ref().with_file_name(self.path.file_name().unwrap()); + + if self.path == dest { + return; + } + + dbg!(&self.path, &dest); + fs::copy(&self.path, &dest).await.unwrap(); + + self.path = dest; + } + + // WARNING: will only work when both files are on the same mount point + pub async fn rename(&mut self, dest: impl AsRef) { + let dest = dest.as_ref().with_file_name(self.path.file_name().unwrap()); + + if self.path == dest { + return; + } + + fs::rename(&self.path, &dest).await.unwrap(); + + self.path = dest; + } +} + #[derive(thiserror::Error, Debug, Clone)] pub enum Error { #[error("could not load file: {0}")] @@ -38,7 +90,35 @@ impl Project { Ok(project) } - pub async fn save(self, path: impl AsRef) -> Result<(), Error> { + pub async fn save(mut self, path: impl AsRef) -> Result { + let path = path.as_ref(); + + if let Some(parent) = path.parent() { + fs::create_dir_all(&parent).await.unwrap(); + } + + match self.measurement_operation { + Operation::None => {} + Operation::Copy => { + if let Some(loopback) = self.loopback.as_mut() { + loopback.copy(&path).await; + } + + for m in self.measurements.iter_mut() { + m.copy(&path).await; + } + } + Operation::Move => { + if let Some(loopback) = self.loopback.as_mut() { + loopback.rename(&path).await; + } + + for m in self.measurements.iter_mut() { + m.rename(&path).await; + } + } + } + let json = serde_json::to_string_pretty(&self).map_err(|err| Error::Json(err.to_string()))?; @@ -46,6 +126,30 @@ impl Project { .await .map_err(|err| Error::Io(err.kind()))?; - Ok(()) + Ok(self) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Operation { + #[default] + None, + Copy, + Move, +} + +impl Operation { + pub const ALL: &[Operation] = &[Operation::None, Operation::Copy, Operation::Move]; +} + +impl fmt::Display for Operation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Operation::None => "do nothing", + Operation::Copy => "copy to project directory", + Operation::Move => "move to project directory", + }; + + write!(f, "{}", s) } } diff --git a/raumklang-gui/src/data/recent_projects.rs b/raumklang-gui/src/data/recent_projects.rs index 8719bb8..a80843a 100644 --- a/raumklang-gui/src/data/recent_projects.rs +++ b/raumklang-gui/src/data/recent_projects.rs @@ -1,3 +1,5 @@ +use crate::data::directory; + use super::Error; use std::{ @@ -97,12 +99,11 @@ impl<'a> IntoIterator for &'a RecentProjects { } async fn data_dir() -> Result { - let app_dir = directories::ProjectDirs::from("de", "HenKu", "raumklang").unwrap(); - let data_dir = app_dir.data_local_dir().to_path_buf(); + let path = directory::data(); - tokio::fs::create_dir_all(&data_dir).await?; + tokio::fs::create_dir_all(&path).await?; - Ok(data_dir) + Ok(path.to_path_buf()) } #[cfg(test)] diff --git a/raumklang-gui/src/data/spectrogram.rs b/raumklang-gui/src/data/spectrogram.rs index 1737689..2860953 100644 --- a/raumklang-gui/src/data/spectrogram.rs +++ b/raumklang-gui/src/data/spectrogram.rs @@ -9,7 +9,7 @@ use rustfft::{ use crate::data::{SampleRate, Samples}; #[derive(Debug, Clone, PartialEq, PartialOrd)] -pub struct Preferences { +pub struct Config { pub span_before_peak: Duration, pub span_after_peak: Duration, pub window_width: Duration, @@ -34,7 +34,7 @@ impl Spectrogram { pub(crate) async fn compute( ir: raumklang_core::ImpulseResponse, - preferences: Preferences, + preferences: Config, ) -> Spectrogram { let sample_rate = SampleRate::from(ir.sample_rate); @@ -114,7 +114,7 @@ impl fmt::Debug for Spectrogram { } } -impl Default for Preferences { +impl Default for Config { fn default() -> Self { Self { span_before_peak: Duration::from_millis(200), diff --git a/raumklang-gui/src/screen/main.rs b/raumklang-gui/src/screen/main.rs index 627ade9..4eaa0fb 100644 --- a/raumklang-gui/src/screen/main.rs +++ b/raumklang-gui/src/screen/main.rs @@ -7,16 +7,20 @@ mod tab; use modal::Modal; use tab::Tab; +use tokio::fs; +use crate::data::{ + self, Project, RecentProjects, SampleRate, Samples, Window, project, spectral_decay, + spectrogram, window, +}; use crate::{ - PickAndLoadError, - data::{ - self, Project, RecentProjects, SampleRate, Samples, Window, project, spectrogram, window, - }, - icon, load_project, log, + PickAndLoadError, icon, load_project, log, screen::main::{ chart::waveform, - modal::{SpectralDecayConfig, pending_window, spectral_decay_config, spectrogram_config}, + modal::{ + SpectralDecayConfig, pending_window, save_project, spectral_decay_config, + spectrogram_config, + }, }, ui::{self, Analysis, Loopback, Measurement, measurement}, widget::{processing_overlay, sidebar}, @@ -42,6 +46,7 @@ use iced::{ use prism::{Axis, Chart, Labels, axis, line_series}; use rfd::FileHandle; +use std::io; use std::{ collections::BTreeMap, mem, @@ -53,16 +58,18 @@ pub struct Main { state: State, modal: Modal, recording: Option, - selected: Option, + selected: Option, loopback: Option, measurements: measurement::List, project_path: Option, + measurement_operation: project::Operation, + export_from_memory: bool, - signal_cache: canvas::Cache, zoom: chart::Zoom, offset: chart::Offset, + signal_cache: canvas::Cache, smoothing: frequency_response::Smoothing, window: Option>, @@ -70,8 +77,8 @@ pub struct Main { ir_chart: impulse_response::Chart, spectrogram: Spectrogram, - spectral_decay_config: data::spectral_decay::Config, - spectrogram_config: spectrogram::Preferences, + spectral_decay_config: spectral_decay::Config, + spectrogram_config: spectrogram::Config, } #[allow(clippy::large_enum_variant)] @@ -114,9 +121,10 @@ struct Spectrogram { pub enum Message { NewProject, LoadProject, - ProjectLoaded(Result<(Arc, PathBuf), PickAndLoadError>), - SaveProject, - ProjectSaved(Result), + ProjectLoaded(Result<(Arc, PathBuf), PickAndLoadError>), + SaveProject(PathBuf), + OpenSaveProjectDialog, + ProjectSaved(Result<(PathBuf, Project), ProjectError>), LoadRecentProject(usize), LoadLoopback, @@ -156,10 +164,11 @@ pub enum Message { Spectrogram(chart::spectrogram::Interaction), PendingWindow(pending_window::Message), + ProjectSaveDialog(save_project::Message), } impl Main { - pub fn from_project(path: PathBuf, project: data::Project) -> (Self, Task) { + pub fn from_project(path: impl AsRef, project: Project) -> (Self, Task) { let load_loopback = project .loopback .map(|loopback| { @@ -179,7 +188,8 @@ impl Main { ( Self { - project_path: Some(path), + project_path: Some(path.as_ref().to_path_buf()), + measurement_operation: project.measurement_operation, ..Default::default() }, Task::batch([load_loopback, Task::batch(load_measurements)]), @@ -211,45 +221,44 @@ impl Main { *self = view; tasks } - Message::SaveProject => { - let loopback = self - .loopback - .as_ref() - .cloned() - .and_then(|l| l.path) - .map(|path| project::Loopback(project::Measurement { path })); - - let measurements = self - .measurements - .loaded() - .flat_map(|m| m.path.as_ref()) - .cloned() - .map(|path| project::Measurement { path }) - .collect(); - - let project = Project { - loopback, - measurements, + Message::OpenSaveProjectDialog => { + self.modal = Modal::SaveProjectDialog(save_project::View::new( + self.measurement_operation, + self.export_from_memory, + )); + + Task::none() + } + Message::ProjectSaveDialog(msg) => { + let Modal::SaveProjectDialog(dialog) = &mut self.modal else { + return Task::none(); }; - if let Some(path) = self.project_path.as_ref() { - let path = path.clone(); - Task::perform(project.save(path.clone()), |res| { - Message::ProjectSaved(res.map(|_| path)) - }) - } else { - Task::future(pick_project_file_to_save()).and_then(move |path| { - Task::perform(project.clone().save(path.clone()), |res| { - Message::ProjectSaved(res.map(|_| path)) - }) - }) + match dialog.update(msg) { + save_project::Action::None => Task::none(), + save_project::Action::Cancel => { + self.modal = Modal::None; + Task::none() + } + save_project::Action::Task(task) => task.map(Message::ProjectSaveDialog), + save_project::Action::Save(path_buf, operation, export_from_memory) => { + self.save_project(path_buf, operation, export_from_memory) + } } } - Message::ProjectSaved(Ok(path)) => { - self.project_path = Some(path.clone()); + Message::SaveProject(path) => { + self.save_project(path, self.measurement_operation, self.export_from_memory) + } + Message::ProjectSaved(Ok((path, project))) => { + // TODO: replace with soft-reload + let (this, tasks) = Main::from_project(&path, project); + *self = this; + recent_projects.insert(path); - Task::future(recent_projects.clone().save()).discard() + let update_recent_projects = Task::future(recent_projects.clone().save()).discard(); + + Task::batch([update_recent_projects, tasks]) } Message::OpenTab(tab) => { let State::Analysing { ref active_tab, .. } = self.state else { @@ -918,7 +927,14 @@ impl Main { self.ir_chart.shift_key_released(); Task::none() } - _ => Task::none(), + Message::ProjectLoaded(Err(err)) => { + log::error!("{err}"); + Task::none() + } + Message::ProjectSaved(Err(err)) => { + log::error!("Could not save project to {:?} - {err}", self.project_path); + Task::none() + } } } @@ -946,7 +962,15 @@ impl Main { .style(button::subtle) .width(Length::Fill), button("Save") - .on_press(Message::SaveProject) + .on_press(if let Some(path) = self.project_path.as_ref() { + Message::SaveProject(path.clone()) + } else { + Message::OpenSaveProjectDialog + }) + .style(button::subtle) + .width(Length::Fill), + button("Save as ..") + .on_press(Message::OpenSaveProjectDialog) .style(button::subtle) .width(Length::Fill), button("Open ...") @@ -1056,17 +1080,20 @@ impl Main { container(column![header, container(content).padding(10)]) }; - match self.modal { + match &self.modal { Modal::None => content.into(), Modal::PendingWindow { .. } => { modal(content, modal::pending_window().map(Message::PendingWindow)) } - Modal::SpectralDecayConfig(ref config) => { + Modal::SpectralDecayConfig(config) => { modal(content, config.view().map(Message::SpectralDecayConfig)) } - Modal::SpectrogramConfig(ref config) => { + Modal::SpectrogramConfig(config) => { modal(content, config.view().map(Message::SpectrogramConfig)) } + Modal::SaveProjectDialog(dialog) => { + modal(content, dialog.view().map(Message::ProjectSaveDialog)) + } } } @@ -1134,9 +1161,11 @@ impl Main { .as_ref() .and_then(Loopback::loaded) .map(AsRef::as_ref), - measurement::Selected::Measurement(id) => { - self.measurements.get(id).and_then(Measurement::signal) - } + measurement::Selected::Measurement(id) => self + .measurements + .get(id) + .and_then(Measurement::signal) + .map(AsRef::as_ref), }) { chart::waveform(measurement, &self.signal_cache, self.zoom, self.offset) .map(Message::MeasurementChart) @@ -1586,6 +1615,94 @@ impl Main { Subscription::batch([hotkeys, recording.map(Message::Recording)]) } + + fn save_project( + &self, + path: PathBuf, + measurement_operation: project::Operation, + export_from_memory: bool, + ) -> Task { + let loopback = self.loopback.clone(); + let measurements: Vec<_> = self.measurements.iter().cloned().collect(); + + Task::perform( + save_project( + path, + loopback, + measurements, + export_from_memory, + measurement_operation, + ), + Message::ProjectSaved, + ) + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum ProjectError { + #[error("not a sub-directory")] + NoSubDirectory, + #[error("dir is not empty: {0}")] + Io(Arc), +} + +impl From for ProjectError { + fn from(err: io::Error) -> Self { + ProjectError::Io(Arc::new(err)) + } +} + +async fn save_project( + path: impl AsRef, + loopback: Option, + measurements: impl IntoIterator, + export_from_memory: bool, + measurement_operation: project::Operation, +) -> Result<(PathBuf, Project), ProjectError> { + let path = path.as_ref(); + let project_dir = path.parent().ok_or(ProjectError::NoSubDirectory)?; + + fs::create_dir_all(&project_dir).await?; + + let loopback_path = if let Some(loopback) = loopback.as_ref() { + if let Some(path) = loopback.path.as_ref() { + Some(path.clone()) + } else if export_from_memory { + let path = path.with_file_name("loopback.wav"); + loopback.clone().save(path).await + } else { + None + } + } else { + None + }; + + let mut measurement_paths = vec![]; + for measurement in measurements { + let path = if let Some(path) = measurement.path.as_ref() { + Some(path.clone()) + } else if export_from_memory { + let path = path.with_file_name(format!("measurement_{}.wav", measurement.id())); + measurement.save(path).await + } else { + None + }; + + measurement_paths.extend(path); + } + + let project = Project { + loopback: loopback_path.map(project::Loopback::new), + measurements: measurement_paths + .into_iter() + .map(project::Measurement::new) + .collect(), + measurement_operation, + export_from_memory, + }; + + let project = project.save(path).await.unwrap(); + Ok((path.to_path_buf(), project)) } fn compute_impulse_response( @@ -1667,7 +1784,7 @@ fn compute_spectral_decay( fn compute_spectrogram( id: measurement::Id, analyses: &mut BTreeMap, - config: &spectrogram::Preferences, + config: &spectrogram::Config, loopback: Option<&ui::Loopback>, measurements: &measurement::List, ) -> Task { @@ -1695,6 +1812,8 @@ impl Default for Main { measurements: measurement::List::default(), project_path: None, + measurement_operation: project::Operation::Copy, + export_from_memory: true, spectral_decay_config: data::spectral_decay::Config::default(), @@ -1707,7 +1826,7 @@ impl Default for Main { ir_chart: impulse_response::Chart::default(), spectrogram: Spectrogram::default(), - spectrogram_config: spectrogram::Preferences::default(), + spectrogram_config: spectrogram::Config::default(), } } } @@ -1715,8 +1834,6 @@ impl Default for Main { async fn choose_impulse_response_file_path() -> Option> { rfd::AsyncFileDialog::new() .set_title("Save Impulse Response ...") - // FIXME: remove - .set_file_name("test.wave") .add_filter("wav", &["wav", "wave"]) .add_filter("all", &["*"]) .save_file() @@ -1857,12 +1974,3 @@ async fn pick_project_file_to_load() -> Option { Some(handle.path().to_path_buf()) } - -async fn pick_project_file_to_save() -> Option { - let handle = rfd::AsyncFileDialog::new() - .set_title("Save project file ...") - .save_file() - .await?; - - Some(handle.path().to_path_buf()) -} diff --git a/raumklang-gui/src/screen/main/modal.rs b/raumklang-gui/src/screen/main/modal.rs index 040ef12..2a27044 100644 --- a/raumklang-gui/src/screen/main/modal.rs +++ b/raumklang-gui/src/screen/main/modal.rs @@ -1,4 +1,5 @@ pub mod pending_window; +pub mod save_project; pub mod spectral_decay_config; pub mod spectrogram_config; @@ -17,4 +18,5 @@ pub enum Modal { }, SpectralDecayConfig(SpectralDecayConfig), SpectrogramConfig(SpectrogramConfig), + SaveProjectDialog(save_project::View), } diff --git a/raumklang-gui/src/screen/main/modal/save_project.rs b/raumklang-gui/src/screen/main/modal/save_project.rs new file mode 100644 index 0000000..ef7a120 --- /dev/null +++ b/raumklang-gui/src/screen/main/modal/save_project.rs @@ -0,0 +1,255 @@ +use iced::{ + Element, Font, + Length::{Fill, Shrink}, + Task, + advanced::graphics::core::font, + alignment::Vertical::Bottom, + widget::{button, checkbox, column, container, pick_list, right, row, rule, text}, +}; +use tokio::fs; + +use std::{ + io, + path::{Path, PathBuf}, + sync::Arc, +}; + +use crate::data::project::{self, Operation}; + +#[derive(Debug, Clone)] +pub struct View { + base_path: PathBuf, + file_path_str: String, + create_subdir: bool, + measurement_operation: Operation, + export_from_memory: bool, + path_error: Result<(), Error>, +} + +#[derive(Debug, Clone)] +pub enum Message { + OpenFileDialog, + ChangeProjectPath(String), + ToggleCreateSubdir(bool), + + ChangeOperation(Operation), + ToggleExportFromMemory(bool), + + StoreDirectoryCheck(Result<(), Error>), + + Cancel, + Save, +} + +pub enum Action { + None, + Cancel, + Task(Task), + Save(PathBuf, project::Operation, bool), +} + +impl View { + pub fn new(measurement_operation: Operation, export_from_memory: bool) -> Self { + Self { + base_path: PathBuf::new(), + file_path_str: String::new(), + create_subdir: true, + measurement_operation, + export_from_memory, + path_error: Ok(()), + } + } + + pub fn update(&mut self, msg: Message) -> Action { + match msg { + Message::OpenFileDialog => { + let task = Task::future(pick_file()).and_then(|path| { + Task::done(Message::ChangeProjectPath( + path.to_string_lossy().to_string(), + )) + }); + + Action::Task(task) + } + Message::ChangeProjectPath(path) => { + self.base_path = PathBuf::from(path); + + let new_path = self.compute_final_path(self.create_subdir); + self.file_path_str = new_path.to_string_lossy().to_string(); + + Action::Task(Task::perform( + check_directory(new_path), + Message::StoreDirectoryCheck, + )) + } + Message::ToggleCreateSubdir(state) => { + self.create_subdir = state; + + let new_path = self.compute_final_path(state); + self.file_path_str = new_path.to_string_lossy().to_string(); + + Action::Task(Task::perform( + check_directory(new_path), + Message::StoreDirectoryCheck, + )) + } + Message::StoreDirectoryCheck(result) => { + self.path_error = result; + Action::None + } + Message::ChangeOperation(operation) => { + self.measurement_operation = operation; + Action::None + } + Message::ToggleExportFromMemory(state) => { + self.export_from_memory = state; + Action::None + } + Message::Cancel => Action::Cancel, + Message::Save => Action::Save( + PathBuf::from(&self.file_path_str), + self.measurement_operation, + self.export_from_memory, + ), + } + } + + pub fn view(&self) -> Element<'_, Message> { + let bold = |s| { + let mut font = Font::DEFAULT; + font.weight = font::Weight::Bold; + + text(s).font(font) + }; + + let header = column![bold("Save Project").size(18), rule::horizontal(2)].spacing(8); + + let content = { + let file_path_picker = { + let subdir_checkbox = checkbox(self.create_subdir) + .label("Create subdirectory for project") + .on_toggle(Message::ToggleCreateSubdir); + + column![ + text("Project file path:"), + row![ + container( + text(&self.file_path_str) + .wrapping(text::Wrapping::WordOrGlyph) + .align_y(Bottom) + ) + .padding(6) + .align_y(Bottom) + .width(Fill) + .style(|theme| { + let base = container::bordered_box(theme); + let palette = theme.extended_palette(); + + base.background(palette.background.base.color) + }), + container( + button("...") + .style(button::secondary) + .on_press(Message::OpenFileDialog) + ) + .padding(2) + ] + .height(Shrink) + .spacing(10) + .align_y(Bottom), + subdir_checkbox, + ] + .spacing(5) + }; + + let measurement_settings = { + let measurement_file_operation = { + column![ + text("Choose how imported measurements should be handled"), + pick_list( + Operation::ALL, + Some(&self.measurement_operation), + Message::ChangeOperation + ) + ] + .spacing(10) + }; + + let export_in_memory_measurements = checkbox(self.export_from_memory) + .label("Export in-memory measurements.") + .on_toggle(Message::ToggleExportFromMemory); + + column![measurement_file_operation, export_in_memory_measurements].spacing(5) + }; + + column![file_path_picker, measurement_settings].spacing(20) + }; + + let controls = { + let cancel = button("Cancel") + .style(button::secondary) + .on_press(Message::Cancel); + + let is_valid = !self.file_path_str.is_empty() && self.path_error.is_ok(); + let save = button("Save") + .style(button::success) + .on_press_maybe(is_valid.then_some(Message::Save)); + + right(row![save, cancel].spacing(8)) + }; + + container(column![header, content, rule::horizontal(1), controls].spacing(20)) + .padding(20) + .width(600) + .style(container::bordered_box) + .into() + } + + fn compute_final_path(&mut self, subdir: bool) -> PathBuf { + if subdir { + let mut subdir_path = self + .base_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_default(); + + subdir_path.push(self.base_path.file_stem().unwrap_or_default()); + subdir_path.push(self.base_path.file_name().unwrap_or_default()); + + subdir_path + } else { + self.base_path.to_path_buf() + } + } +} + +async fn check_directory(path: PathBuf) -> Result<(), Error> { + if fs::try_exists(&path).await? && fs::read_dir(&path).await?.next_entry().await?.is_some() { + return Err(Error::DirectoryNotEmpty(path.into())); + } + + Ok(()) +} + +async fn pick_file() -> Option { + let handle = rfd::AsyncFileDialog::new() + .set_title("Save project file ...") + .save_file() + .await?; + + Some(handle.path().to_path_buf()) +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum Error { + #[error("dir is not empty: {0}")] + DirectoryNotEmpty(Arc), + #[error("io operation failed: {0}")] + Io(Arc), +} + +impl From for Error { + fn from(err: io::Error) -> Self { + Error::Io(Arc::new(err)) + } +} diff --git a/raumklang-gui/src/screen/main/modal/spectrogram_config.rs b/raumklang-gui/src/screen/main/modal/spectrogram_config.rs index f580d5e..8add03a 100644 --- a/raumklang-gui/src/screen/main/modal/spectrogram_config.rs +++ b/raumklang-gui/src/screen/main/modal/spectrogram_config.rs @@ -16,13 +16,13 @@ pub enum Message { WindowWidthChanged(String), SpanBeforePeakChanged(String), SpanAfterPeakChanged(String), - Apply(spectrogram::Preferences), + Apply(spectrogram::Config), } pub enum Action { None, Close, - ConfigChanged(spectrogram::Preferences), + ConfigChanged(spectrogram::Config), } #[derive(Debug, Clone)] @@ -30,11 +30,11 @@ pub struct SpectrogramConfig { window_width: String, span_before_peak: String, span_after_peak: String, - prev_config: spectrogram::Preferences, + prev_config: spectrogram::Config, } impl SpectrogramConfig { - pub fn new(config: spectrogram::Preferences) -> Self { + pub fn new(config: spectrogram::Config) -> Self { Self { window_width: config.window_width.as_millis().to_string(), span_before_peak: config.span_before_peak.as_millis().to_string(), @@ -81,7 +81,7 @@ impl SpectrogramConfig { span_before_peak.as_ref(), span_after_peak.as_ref(), ) { - let new_config = spectrogram::Preferences { + let new_config = spectrogram::Config { window_width: *window_width, span_before_peak: *span_before_peak, span_after_peak: *span_after_peak, @@ -174,10 +174,10 @@ impl SpectrogramConfig { } fn reset_to_default(&mut self) { - self.reset_to_config(spectrogram::Preferences::default()); + self.reset_to_config(spectrogram::Config::default()); } - fn reset_to_config(&mut self, config: spectrogram::Preferences) { + fn reset_to_config(&mut self, config: spectrogram::Config) { self.window_width = config.window_width.as_millis().to_string(); self.span_before_peak = config.span_before_peak.as_millis().to_string(); self.span_after_peak = config.span_after_peak.as_millis().to_string(); diff --git a/raumklang-gui/src/ui/measurement.rs b/raumklang-gui/src/ui/measurement.rs index cf268ab..52924f8 100644 --- a/raumklang-gui/src/ui/measurement.rs +++ b/raumklang-gui/src/ui/measurement.rs @@ -6,13 +6,16 @@ use chrono::{DateTime, Utc}; use iced::{ Element, Length::{Fill, Shrink}, - widget::{button, column, right, row, rule, text}, + widget::{button, column, container, right, row, rule, text, tooltip}, }; use std::{ fmt::Display, path::{Path, PathBuf}, - sync::atomic::{self, AtomicUsize}, + sync::{ + Arc, + atomic::{self, AtomicUsize}, + }, }; use crate::{icon, widget::sidebar}; @@ -44,7 +47,7 @@ pub struct Id(usize); #[derive(Debug, Clone)] enum State { NotLoaded, - Loaded { signal: raumklang_core::Measurement }, + Loaded(Arc), } impl Measurement { @@ -57,7 +60,7 @@ impl Measurement { let id = Id(ID.fetch_add(1, atomic::Ordering::Relaxed)); let state = match signal { - Some(signal) => State::Loaded { signal }, + Some(signal) => State::Loaded(Arc::new(signal)), None => State::NotLoaded, }; @@ -83,6 +86,33 @@ impl Measurement { Self::new(name, path, signal) } + // TODO error handling + pub fn save(self, path: impl AsRef) -> impl Future> { + let path = path.as_ref().to_path_buf(); + async move { + tokio::task::spawn_blocking(move || { + let signal = self.signal()?; + + let spec = hound::WavSpec { + channels: 1, + sample_rate: signal.sample_rate(), + bits_per_sample: 32, + sample_format: hound::SampleFormat::Float, + }; + + let mut writer = hound::WavWriter::create(&path, spec).unwrap(); + for s in signal.iter() { + writer.write_sample(*s).unwrap(); + } + writer.finalize().unwrap(); + + Some(path) + }) + .await + .unwrap() + } + } + pub fn view(&self, active: bool) -> Element<'_, Message> { let info: Element<_> = match &self.signal() { Some(signal) => { @@ -127,7 +157,20 @@ impl Measurement { right(delete_btn).width(Shrink).padding([0, 6]) ]; - sidebar::item(content, active) + let file_path = self + .path + .as_ref() + .and_then(|p| p.to_str()) + .unwrap_or_default(); + + tooltip( + sidebar::item(content, active), + container(text(file_path)) + .padding(5) + .style(container::bordered_box), + tooltip::Position::Bottom, + ) + .into() } pub fn is_loaded(&self) -> bool { @@ -137,10 +180,10 @@ impl Measurement { } } - pub fn signal(&self) -> Option<&raumklang_core::Measurement> { + pub fn signal(&self) -> Option<&Arc> { match &self.state { State::NotLoaded => None, - State::Loaded { signal, .. } => Some(signal), + State::Loaded(signal) => Some(signal), } } diff --git a/raumklang-gui/src/ui/measurement/loopback.rs b/raumklang-gui/src/ui/measurement/loopback.rs index 4314449..7399517 100644 --- a/raumklang-gui/src/ui/measurement/loopback.rs +++ b/raumklang-gui/src/ui/measurement/loopback.rs @@ -108,10 +108,38 @@ impl Loopback { sidebar::item(content, active) } - pub(crate) fn loaded(&self) -> Option<&raumklang_core::Loopback> { + pub fn loaded(&self) -> Option<&raumklang_core::Loopback> { match &self.state { State::Loaded(loopback) => Some(loopback), State::NotLoaded(_) => None, } } + + // TODO error handling + // FIXME duplicate code with measurement + pub fn save(self, path: impl AsRef) -> impl Future> { + let path = path.as_ref().to_path_buf(); + async move { + tokio::task::spawn_blocking(move || { + let signal = self.loaded()?; + + let spec = hound::WavSpec { + channels: 1, + sample_rate: signal.sample_rate(), + bits_per_sample: 32, + sample_format: hound::SampleFormat::Float, + }; + + let mut writer = hound::WavWriter::create(&path, spec).unwrap(); + for s in signal.iter() { + writer.write_sample(*s).unwrap(); + } + writer.finalize().unwrap(); + + Some(path) + }) + .await + .unwrap() + } + } } diff --git a/raumklang-gui/src/ui/spectrogram.rs b/raumklang-gui/src/ui/spectrogram.rs index 064b69d..055c1f1 100644 --- a/raumklang-gui/src/ui/spectrogram.rs +++ b/raumklang-gui/src/ui/spectrogram.rs @@ -35,7 +35,7 @@ impl Spectrogram { pub fn compute( &mut self, impulse_response: &super::impulse_response::State, - config: &spectrogram::Preferences, + config: &spectrogram::Config, ) -> Option + use<>> { if self.result().is_some() { return None;