diff --git a/raumklang-gui/Cargo.toml b/raumklang-gui/Cargo.toml index 0a99f9b..b8c94ad 100644 --- a/raumklang-gui/Cargo.toml +++ b/raumklang-gui/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "gui" version = "0.1.0" -edition = "2021" +edition = "2024" [[bin]] name = "raumklang" diff --git a/raumklang-gui/fonts/icons.toml b/raumklang-gui/fonts/icons.toml index 3f9d8ba..ac5ece7 100644 --- a/raumklang-gui/fonts/icons.toml +++ b/raumklang-gui/fonts/icons.toml @@ -1,8 +1,9 @@ module = "icon" [glyphs] -delete = "fontawesome-trash" +plus = "fontawesome-plus" record = "typicons-record" +delete = "fontawesome-trash" download = "typicons-download" settings = "fontawesome-cog-alt" reset = "fontawesome-ccw" diff --git a/raumklang-gui/fonts/icons.ttf b/raumklang-gui/fonts/icons.ttf index e9ef9d7..033906d 100644 Binary files a/raumklang-gui/fonts/icons.ttf and b/raumklang-gui/fonts/icons.ttf differ diff --git a/raumklang-gui/src/audio.rs b/raumklang-gui/src/audio.rs index dc8101b..a59bb79 100644 --- a/raumklang-gui/src/audio.rs +++ b/raumklang-gui/src/audio.rs @@ -20,8 +20,8 @@ use tokio::sync::mpsc; use tokio::sync::mpsc::error::TryRecvError; use tokio_stream::wrappers::ReceiverStream; -use std::sync::atomic::{self, AtomicBool}; use std::sync::Arc; +use std::sync::atomic::{self, AtomicBool}; use std::thread; use std::time::Duration; diff --git a/raumklang-gui/src/audio/driver.rs b/raumklang-gui/src/audio/driver.rs deleted file mode 100644 index 811d6a8..0000000 --- a/raumklang-gui/src/audio/driver.rs +++ /dev/null @@ -1,7 +0,0 @@ -#[derive(Debug, Clone)] -pub enum Notification { - OutPortConnected(String), - OutPortDisconnected, - InPortConnected(String), - InPortDisconnected, -} diff --git a/raumklang-gui/src/audio/loudness.rs b/raumklang-gui/src/audio/loudness.rs index 1324d97..6237047 100644 --- a/raumklang-gui/src/audio/loudness.rs +++ b/raumklang-gui/src/audio/loudness.rs @@ -4,7 +4,7 @@ use tokio::sync::mpsc::error::TrySendError; use std::time::{Duration, Instant}; -use super::{process::Control, Process}; +use super::{Process, process::Control}; #[derive(Debug, Clone, Copy)] pub struct Loudness { diff --git a/raumklang-gui/src/audio/measurement.rs b/raumklang-gui/src/audio/measurement.rs index 43f17d0..5c8cb59 100644 --- a/raumklang-gui/src/audio/measurement.rs +++ b/raumklang-gui/src/audio/measurement.rs @@ -1,16 +1,16 @@ use crate::log; -use super::{loudness, process::Control, Process}; +use super::{Process, loudness, process::Control}; use ringbuf::{ - traits::{Consumer as _, Producer as _, Split as _}, HeapCons, HeapProd, HeapRb, + traits::{Consumer as _, Producer as _, Split as _}, }; use std::{ sync::{ - atomic::{self, AtomicBool}, Arc, + atomic::{self, AtomicBool}, }, time::Duration, }; diff --git a/raumklang-gui/src/data.rs b/raumklang-gui/src/data.rs index 3ba8498..1759bfe 100644 --- a/raumklang-gui/src/data.rs +++ b/raumklang-gui/src/data.rs @@ -21,8 +21,8 @@ pub use spectral_decay::SpectralDecay; pub use spectrogram::Spectrogram; pub use window::Window; -use ndarray::{concatenate, Array, Array1, ArrayView, Axis}; -use ndarray_interp::interp1d::{cubic_spline::CubicSpline, Interp1DBuilder}; +use ndarray::{Array, Array1, ArrayView, Axis, concatenate}; +use ndarray_interp::interp1d::{Interp1DBuilder, cubic_spline::CubicSpline}; use ndarray_stats::SummaryStatisticsExt; use std::{io, sync::Arc}; diff --git a/raumklang-gui/src/data/impulse_response.rs b/raumklang-gui/src/data/impulse_response.rs index 8fe26a2..5efb8af 100644 --- a/raumklang-gui/src/data/impulse_response.rs +++ b/raumklang-gui/src/data/impulse_response.rs @@ -1,26 +1,71 @@ -#[derive(Debug, Clone)] -pub struct ImpulseResponse { - pub origin: raumklang_core::ImpulseResponse, +use std::sync::Arc; + +use iced::task::{Sipper, sipper}; + +#[derive(Debug, Clone, Default)] +pub struct ImpulseResponse(State); + +#[derive(Debug, Clone, Default)] +enum State { + #[default] + None, + Computing, + Computed(Arc), } impl ImpulseResponse { - pub fn from_data(impulse_response: raumklang_core::ImpulseResponse) -> Self { - Self { - origin: impulse_response, + pub fn compute( + self, + loopback: &raumklang_core::Loopback, + measurement: &raumklang_core::Measurement, + ) -> Option + use<>> { + if let State::Computing = self.0 { + return None; + } + + if let State::Computed(_) = self.0 { + return None; + } + + let loopback = loopback.clone(); + let measurement = measurement.clone(); + + let sipper = sipper(async move |mut progress| { + progress.send(ImpulseResponse(State::Computing)).await; + + let impulse_response = tokio::task::spawn_blocking(move || { + raumklang_core::ImpulseResponse::from_signals(&loopback, &measurement) + }) + .await + .unwrap() + .unwrap(); + + ImpulseResponse(State::Computed(Arc::new(impulse_response))) + }); + + Some(sipper) + } + + pub fn result(&self) -> Option<&raumklang_core::ImpulseResponse> { + match self.0 { + State::None => None, + State::Computing => None, + State::Computed(ref impulse_response) => Some(impulse_response), + } + } + + pub fn progress(&self) -> Progress { + match self.0 { + State::None => Progress::None, + State::Computing => Progress::Computing, + State::Computed(_) => Progress::Computed, } } } -pub async fn compute( - loopback: raumklang_core::Loopback, - measurement: raumklang_core::Measurement, -) -> ImpulseResponse { - let impulse_response = tokio::task::spawn_blocking(move || { - raumklang_core::ImpulseResponse::from_signals(&loopback, &measurement) - }) - .await - .unwrap() - .unwrap(); - - ImpulseResponse::from_data(impulse_response) +#[derive(Debug, Clone)] +pub enum Progress { + None, + Computing, + Computed, } diff --git a/raumklang-gui/src/data/project/file.rs b/raumklang-gui/src/data/project/file.rs deleted file mode 100644 index 997a0c3..0000000 --- a/raumklang-gui/src/data/project/file.rs +++ /dev/null @@ -1,46 +0,0 @@ -use std::{ - io, - path::{Path, PathBuf}, -}; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct File { - pub loopback: Option, - pub measurements: Vec, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct Loopback(Measurement); - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct Measurement { - pub path: PathBuf, -} - -#[derive(thiserror::Error, Debug, Clone)] -pub enum Error { - #[error("could not load file: {0}")] - Io(io::ErrorKind), - #[error("could not parse file: {0}")] - Json(String), -} - -impl File { - pub async fn load(path: impl AsRef) -> Result { - let path = path.as_ref(); - let content = tokio::fs::read(path) - .await - .map_err(|err| Error::Io(err.kind()))?; - - let project = - serde_json::from_slice(&content).map_err(|err| Error::Json(err.to_string()))?; - - Ok(project) - } -} - -impl Loopback { - pub fn path(&self) -> &PathBuf { - &self.0.path - } -} diff --git a/raumklang-gui/src/data/recent_projects.rs b/raumklang-gui/src/data/recent_projects.rs index bbe738d..8719bb8 100644 --- a/raumklang-gui/src/data/recent_projects.rs +++ b/raumklang-gui/src/data/recent_projects.rs @@ -1,7 +1,7 @@ use super::Error; use std::{ - collections::{vec_deque, VecDeque}, + collections::{VecDeque, vec_deque}, io, path::{Path, PathBuf}, }; diff --git a/raumklang-gui/src/data/spectral_decay.rs b/raumklang-gui/src/data/spectral_decay.rs index 03eeee8..4accde4 100644 --- a/raumklang-gui/src/data/spectral_decay.rs +++ b/raumklang-gui/src/data/spectral_decay.rs @@ -3,85 +3,11 @@ use std::{fmt, sync::Arc, time::Duration}; use raumklang_core::{Window, WindowBuilder}; use rustfft::{ - num_complex::{Complex, Complex32}, FftPlanner, + num_complex::{Complex, Complex32}, }; -use crate::data::{smooth_fractional_octave, SampleRate, Samples}; - -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -pub struct Config { - pub shift: Shift, - pub left_window_width: WindowWidth, - pub right_window_width: WindowWidth, - pub smoothing_fraction: u8, -} - -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -pub struct Shift(Duration); - -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -pub struct WindowWidth(Duration); - -#[derive(Debug, thiserror::Error)] -pub enum ValidationError { - #[error("Must be in range: 0..50")] - Range, - #[error("Not a number.")] - NotANumber, -} - -impl Shift { - pub(crate) fn from_millis_string(str: &str) -> Result { - let millis = str.parse().map_err(|_| ValidationError::NotANumber)?; - - if !(1..=50).contains(&millis) { - return Err(ValidationError::Range); - } - - Ok(Self(Duration::from_millis(millis))) - } - - pub(crate) fn as_millis(&self) -> u128 { - self.0.as_millis() - } - - fn from_millis(millis: u64) -> Self { - Self(Duration::from_millis(millis)) - } -} - -impl From<&Shift> for Duration { - fn from(shift: &Shift) -> Self { - shift.0 - } -} - -impl WindowWidth { - pub(crate) fn from_millis_string(str: &str) -> Result { - let millis = str.parse().map_err(|_| ValidationError::NotANumber)?; - - if !(0..=500).contains(&millis) { - return Err(ValidationError::Range); - } - - Ok(Self(Duration::from_millis(millis))) - } - - pub(crate) fn as_millis(&self) -> u128 { - self.0.as_millis() - } - - fn from_millis(millis: u64) -> Self { - Self(Duration::from_millis(millis)) - } -} - -impl From<&WindowWidth> for Duration { - fn from(value: &WindowWidth) -> Self { - value.0 - } -} +use crate::data::{SampleRate, Samples, smooth_fractional_octave}; #[derive(Clone)] pub struct SpectralDecay(Vec); @@ -96,6 +22,12 @@ impl SpectralDecay { } } +impl fmt::Debug for SpectralDecay { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Spectral Decay of size: {} slices", self.0.len()) + } +} + pub(crate) async fn compute( ir: raumklang_core::ImpulseResponse, preferences: Config, @@ -170,12 +102,72 @@ pub(crate) async fn compute( .unwrap() } -impl fmt::Debug for SpectralDecay { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Spectral Decay of size: {} slices", self.0.len()) +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct Shift(Duration); + +impl Shift { + pub(crate) fn from_millis_string(str: &str) -> Result { + let millis = str.parse().map_err(|_| ValidationError::NotANumber)?; + + if !(1..=50).contains(&millis) { + return Err(ValidationError::Range); + } + + Ok(Self(Duration::from_millis(millis))) + } + + pub(crate) fn as_millis(&self) -> u128 { + self.0.as_millis() + } + + fn from_millis(millis: u64) -> Self { + Self(Duration::from_millis(millis)) + } +} + +impl From<&Shift> for Duration { + fn from(shift: &Shift) -> Self { + shift.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct WindowWidth(Duration); + +impl WindowWidth { + pub(crate) fn from_millis_string(str: &str) -> Result { + let millis = str.parse().map_err(|_| ValidationError::NotANumber)?; + + if !(0..=500).contains(&millis) { + return Err(ValidationError::Range); + } + + Ok(Self(Duration::from_millis(millis))) + } + + pub(crate) fn as_millis(&self) -> u128 { + self.0.as_millis() + } + + fn from_millis(millis: u64) -> Self { + Self(Duration::from_millis(millis)) } } +impl From<&WindowWidth> for Duration { + fn from(value: &WindowWidth) -> Self { + value.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct Config { + pub shift: Shift, + pub left_window_width: WindowWidth, + pub right_window_width: WindowWidth, + pub smoothing_fraction: u8, +} + impl Default for Config { fn default() -> Self { Self { @@ -186,3 +178,11 @@ impl Default for Config { } } } + +#[derive(Debug, thiserror::Error)] +pub enum ValidationError { + #[error("Must be in range: 0..50")] + Range, + #[error("Not a number.")] + NotANumber, +} diff --git a/raumklang-gui/src/data/spectrogram.rs b/raumklang-gui/src/data/spectrogram.rs index f6b9768..1737689 100644 --- a/raumklang-gui/src/data/spectrogram.rs +++ b/raumklang-gui/src/data/spectrogram.rs @@ -2,13 +2,13 @@ use core::slice; use std::{fmt, sync::Arc, time::Duration}; use rustfft::{ - num_complex::{Complex, Complex32}, FftPlanner, + num_complex::{Complex, Complex32}, }; use crate::data::{SampleRate, Samples}; -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +#[derive(Debug, Clone, PartialEq, PartialOrd)] pub struct Preferences { pub span_before_peak: Duration, pub span_after_peak: Duration, diff --git a/raumklang-gui/src/data/store.rs b/raumklang-gui/src/data/store.rs deleted file mode 100644 index 234361b..0000000 --- a/raumklang-gui/src/data/store.rs +++ /dev/null @@ -1,220 +0,0 @@ -use std::{collections::HashMap, hash::Hash, marker::PhantomData, ops::AddAssign, slice}; - -pub struct Store { - all: Vec, Id>>, - loaded: IdMap, - not_loaded: IdMap, -} - -#[derive(Debug)] -struct IdMap { - next_id: Id, - data: HashMap, T>, -} - -#[derive(Debug)] -pub struct Id { - inner: usize, - phantom: PhantomData, -} - -#[derive(Debug)] -pub enum MeasurementState { - Loaded(L), - Loading, - NotLoaded(O), -} - -impl Store { - pub fn new() -> Self { - Self { - not_loaded: IdMap::new(), - loaded: IdMap::new(), - all: Vec::new(), - } - } - - pub fn insert(&mut self, state: MeasurementState) { - let id = match state { - MeasurementState::NotLoaded(om) => { - let id = self.not_loaded.insert(om); - MeasurementState::NotLoaded(id) - } - MeasurementState::Loaded(m) => { - let id = self.loaded.insert(m); - MeasurementState::Loaded(id) - } - }; - - self.all.push(id); - } - - pub fn get(&self, index: usize) -> Option> { - let id = self.all.get(index); - - id.map(|m| match m { - MeasurementState::Loaded(id) => self.loaded.get(id).map(MeasurementState::Loaded), - MeasurementState::NotLoaded(id) => { - self.not_loaded.get(id).map(MeasurementState::NotLoaded) - } - })? - } - - pub fn get_loaded_id(&self, index: usize) -> Option> { - self.all.get(index).map(|m| match m { - MeasurementState::Loaded(id) => Some(*id), - MeasurementState::NotLoaded(_) => None, - })? - } - - pub fn get_loaded_by_id(&self, id: &Id) -> Option<&L> { - self.loaded.get(id) - } - - pub fn iter(&self) -> Iter { - Iter { - inner: self.all.iter(), - loaded: &self.loaded, - not_loaded: &self.not_loaded, - } - } - - pub fn loaded(&self) -> impl Iterator, &L)> { - self.all.iter().filter_map(|state| match state { - MeasurementState::Loaded(id) => self.loaded.get(id).map(|m| (id, m)), - MeasurementState::NotLoaded(_) => None, - }) - } - - pub fn is_loaded_empty(&self) -> bool { - self.loaded.is_empty() - } - - pub fn remove(&mut self, id: usize) -> Option> { - let state = self.all.remove(id); - - match state { - MeasurementState::Loaded(id) => self.loaded.remove(&id).map(MeasurementState::Loaded), - MeasurementState::NotLoaded(id) => { - self.not_loaded.remove(&id).map(MeasurementState::NotLoaded) - } - } - } - - pub fn is_empty(&self) -> bool { - self.all.is_empty() - } -} - -impl<'a, L, O> From> for Option<&'a L> { - fn from(value: MeasurementState<&'a L, &O>) -> Self { - match value { - MeasurementState::Loaded(m) => Some(m), - MeasurementState::NotLoaded(_) => None, - } - } -} - -pub struct Iter<'a, L, O> { - inner: slice::Iter<'a, MeasurementState, Id>>, - loaded: &'a IdMap, - not_loaded: &'a IdMap, -} - -impl<'a, L, O> Iterator for Iter<'a, L, O> { - type Item = MeasurementState<&'a L, &'a O>; - - fn next(&mut self) -> Option { - self.inner.next().map(|state| match state { - MeasurementState::NotLoaded(id) => { - MeasurementState::NotLoaded(self.not_loaded.get(id).unwrap()) - } - MeasurementState::Loaded(id) => MeasurementState::Loaded(self.loaded.get(id).unwrap()), - }) - } -} - -impl IdMap { - fn new() -> Self { - Self { - next_id: Id::new(), - data: HashMap::new(), - } - } - - fn insert(&mut self, entry: T) -> Id { - let cur_id = self.next_id; - - self.data.insert(cur_id, entry); - self.next_id += 1; - - cur_id - } - - fn get(&self, key: &Id) -> Option<&T> { - self.data.get(key) - } - - fn remove(&mut self, id: &Id) -> Option { - self.data.remove(id) - } - - fn is_empty(&self) -> bool { - self.data.is_empty() - } -} - -impl Id { - fn new() -> Self { - Self { - inner: 0, - phantom: PhantomData, - } - } -} - -impl PartialEq for Id { - fn eq(&self, other: &Self) -> bool { - self.inner == other.inner - } -} - -impl Eq for Id {} - -impl Hash for Id { - fn hash(&self, state: &mut H) { - self.inner.hash(state); - } -} - -impl Clone for Id { - fn clone(&self) -> Self { - *self - } -} - -impl Copy for Id {} - -impl AddAssign for Id { - fn add_assign(&mut self, rhs: usize) { - self.inner += rhs - } -} - -#[cfg(test)] -mod test { - use super::{MeasurementState, Store}; - - #[test] - fn loaded_returns_items_in_correct_order() { - let mut store: Store<&str, ()> = Store::new(); - - store.insert(MeasurementState::Loaded("a")); - store.insert(MeasurementState::Loaded("c")); - store.insert(MeasurementState::Loaded("b")); - - let items: Vec<_> = store.loaded().map(|(_, i)| *i).collect(); - - assert_eq!(items, vec!["a", "c", "b"]) - } -} diff --git a/raumklang-gui/src/data/window.rs b/raumklang-gui/src/data/window.rs index 23781af..921d957 100644 --- a/raumklang-gui/src/data/window.rs +++ b/raumklang-gui/src/data/window.rs @@ -7,7 +7,7 @@ use super::{SampleRate, Samples}; use std::time::Duration; #[derive(Debug, Clone, PartialEq)] -pub struct Window { +pub struct Window { sample_rate: SampleRate, left_type: raumklang_core::Window, left_width: D, diff --git a/raumklang-gui/src/icon.rs b/raumklang-gui/src/icon.rs index 5bc4caf..9b9c74b 100644 --- a/raumklang-gui/src/icon.rs +++ b/raumklang-gui/src/icon.rs @@ -1,6 +1,6 @@ // Generated automatically by iced_fontello at build time. // Do not edit manually. Source: ../fonts/icons.toml -// a31a04bdecfefc20c79309ff6399870e033e64c8b1bb66096a94c9e027c9d20c +// 44042cbeec84dc8662b531772612b8dd3be566793229f21ef656918a300a1863 use iced::Font; use iced::widget::{Text, text}; @@ -14,6 +14,10 @@ pub fn download<'a>() -> Text<'a> { icon("\u{1F4E5}") } +pub fn plus<'a>() -> Text<'a> { + icon("\u{2B}") +} + pub fn record<'a>() -> Text<'a> { icon("\u{E0A5}") } diff --git a/raumklang-gui/src/main.rs b/raumklang-gui/src/main.rs index 9b7703b..da74fa9 100644 --- a/raumklang-gui/src/main.rs +++ b/raumklang-gui/src/main.rs @@ -8,14 +8,13 @@ mod ui; mod widget; use screen::{ - landing, + Screen, landing, main::{self}, - Screen, }; -use data::{project, RecentProjects}; +use data::{RecentProjects, project}; -use iced::{futures::FutureExt, Element, Font, Subscription, Task, Theme}; +use iced::{Element, Font, Subscription, Task, Theme}; use std::{ path::{Path, PathBuf}, @@ -97,13 +96,9 @@ impl Raumklang { Task::none() } - landing::Message::Load => Task::perform( - pick_project_file().then(async |res| { - let path = res?; - load_project(path).await - }), - Message::ProjectLoaded, - ), + landing::Message::Load => Task::future(pick_project_file()) + .and_then(|path| Task::future(load_project(path))) + .map(Message::ProjectLoaded), landing::Message::Recent(id) => match self.recent_projects.get(id) { Some(path) => Task::perform(load_project(path.clone()), Message::ProjectLoaded), None => Task::none(), diff --git a/raumklang-gui/src/screen.rs b/raumklang-gui/src/screen.rs index 4ad9bd1..4027bb4 100644 --- a/raumklang-gui/src/screen.rs +++ b/raumklang-gui/src/screen.rs @@ -5,8 +5,8 @@ pub use landing::landing; pub use main::Main; use iced::{ - widget::{container, text}, Element, Length, + widget::{container, text}, }; #[allow(clippy::large_enum_variant)] diff --git a/raumklang-gui/src/screen/landing.rs b/raumklang-gui/src/screen/landing.rs index 314accf..270502a 100644 --- a/raumklang-gui/src/screen/landing.rs +++ b/raumklang-gui/src/screen/landing.rs @@ -1,10 +1,10 @@ use crate::data::RecentProjects; use iced::{ - font, - widget::{button, column, container, row, scrollable, space, text}, Element, Font, Length::{self, Fill}, + font, + widget::{button, column, container, row, scrollable, space, text}, }; #[derive(Debug, Clone)] diff --git a/raumklang-gui/src/screen/main.rs b/raumklang-gui/src/screen/main.rs index fd84b9d..627ade9 100644 --- a/raumklang-gui/src/screen/main.rs +++ b/raumklang-gui/src/screen/main.rs @@ -1,112 +1,108 @@ mod chart; mod frequency_response; mod impulse_response; -mod measurement; +mod modal; mod recording; -mod spectral_decay_config; -mod spectrogram_config; +mod tab; + +use modal::Modal; +use tab::Tab; use crate::{ + PickAndLoadError, data::{ - self, project, spectrogram, window, Project, RecentProjects, SampleRate, Samples, Window, + self, Project, RecentProjects, SampleRate, Samples, Window, project, spectrogram, window, }, icon, load_project, log, screen::main::{ - chart::waveform, impulse_response::processing_overlay, - spectral_decay_config::SpectralDecayConfig, spectrogram_config::SpectrogramConfig, + chart::waveform, + modal::{SpectralDecayConfig, pending_window, spectral_decay_config, spectrogram_config}, }, - ui, - widget::sidebar, - PickAndLoadError, + ui::{self, Analysis, Loopback, Measurement, measurement}, + widget::{processing_overlay, sidebar}, }; -use impulse_response::{ChartOperation, WindowSettings}; -use recording::Recording; - use raumklang_core::dbfs; +use impulse_response::ChartOperation; +use recording::Recording; + use chrono::{DateTime, Utc}; use generic_overlay::generic_overlay::{dropdown_menu, dropdown_root}; use iced::{ + Alignment::{self, Center}, + Color, Element, Function, Length, Subscription, Task, Theme, alignment::{Horizontal, Vertical}, - futures::{FutureExt, TryFutureExt}, keyboard, padding, widget::{ - button, canvas, center, column, container, opaque, pick_list, right, row, rule, scrollable, - space, stack, text, text::Wrapping, Button, + Button, button, canvas, center, column, container, opaque, pick_list, row, rule, + scrollable, stack, text, }, - Alignment::{self}, - Color, Element, Function, Length, Subscription, Task, Theme, }; -use prism::{axis, line_series, Axis, Chart, Labels}; +use prism::{Axis, Chart, Labels, axis, line_series}; +use rfd::FileHandle; use std::{ - fmt::Display, + collections::BTreeMap, + mem, path::{Path, PathBuf}, sync::Arc, }; pub struct Main { state: State, + modal: Modal, + recording: Option, selected: Option, + + loopback: Option, + measurements: measurement::List, + + project_path: Option, + signal_cache: canvas::Cache, - smoothing: frequency_response::Smoothing, - measurements: ui::measurement::List, - modal: Modal, zoom: chart::Zoom, offset: chart::Offset, - project_path: Option, + + smoothing: frequency_response::Smoothing, + window: Option>, + + ir_chart: impulse_response::Chart, + spectrogram: Spectrogram, + spectral_decay_config: data::spectral_decay::Config, spectrogram_config: spectrogram::Preferences, } #[allow(clippy::large_enum_variant)] +#[derive(Default)] enum State { - CollectingMeasuremnts { - recording: Option, - loopback: Option, - }, + #[default] + Collecting, Analysing { - loopback: ui::Loopback, active_tab: Tab, - window: Window, - selected_impulse_response: Option, - charts: Charts, + selected: Option, + analyses: BTreeMap, }, } -impl Default for State { - fn default() -> Self { - State::CollectingMeasuremnts { - recording: None, - loopback: None, +impl State { + pub fn analysis() -> Self { + Self::Analysing { + active_tab: Tab::default(), + selected: None, + analyses: BTreeMap::new(), } } -} - -#[allow(clippy::large_enum_variant)] -pub enum Tab { - Measurements { recording: Option }, - ImpulseResponses { window_settings: WindowSettings }, - FrequencyResponses, - SpectralDecay, - Spectrogram, -} -impl Default for Tab { - fn default() -> Self { - Self::Measurements { recording: None } + fn active_tab(&self) -> Option<&Tab> { + match &self { + State::Collecting => None, + State::Analysing { active_tab, .. } => Some(active_tab), + } } } -#[derive(Debug, Default)] -struct Charts { - impulse_responses: impulse_response::Chart, - frequency_responses: frequency_response::ChartData, - spectral_decay_cache: canvas::Cache, - spectrogram: Spectrogram, -} - #[derive(Debug, Default)] struct Spectrogram { pub zoom: chart::Zoom, @@ -114,75 +110,52 @@ struct Spectrogram { pub cache: canvas::Cache, } -#[derive(Default, Debug)] -enum Modal { - #[default] - None, - PendingWindow { - goto_tab: TabId, - }, - SpectralDecayConfig(SpectralDecayConfig), - SpectrogramConfig(SpectrogramConfig), -} - -#[derive(Debug, Clone)] -pub enum ModalAction { - Discard, - Apply, -} - #[derive(Debug, Clone)] pub enum Message { NewProject, LoadProject, - SaveProject, - RecentProject(usize), ProjectLoaded(Result<(Arc, PathBuf), PickAndLoadError>), - ProjectSaved(Result), + SaveProject, + ProjectSaved(Result), + LoadRecentProject(usize), - TabSelected(TabId), + LoadLoopback, + LoopbackLoaded(Loopback), + LoadMeasurement, + MeasurementLoaded(Measurement), + Measurement(measurement::Message), - Measurements(measurement::Message), - MeasurementChart(waveform::Interaction), - ImpulseResponseSelected(ui::measurement::Id), + OpenTab(tab::Id), + ImpulseResponseComputed(measurement::Id, data::ImpulseResponse), + SaveImpulseResponseToFile(measurement::Id, Option>), - FrequencyResponseToggled(ui::measurement::Id, bool), - SmoothingChanged(frequency_response::Smoothing), - FrequencyResponseSmoothed((ui::measurement::Id, Box<[f32]>)), - FrequencyResponseComputed(ui::measurement::Id, data::FrequencyResponse), + FrequencyResponseComputed(measurement::Id, data::FrequencyResponse), + ImpulseResponseSaved(measurement::Id, Arc), + ImpulseResponseChart(impulse_response::ChartOperation), + ImpulseResponse(ui::measurement::Id, ui::impulse_response::Message), - ImpulseResponses(impulse_response::Message), - SaveImpulseResponseFileDialog(ui::measurement::Id), - SaveImpulseResponse(ui::measurement::Id, Arc), - ImpulseResponsesSaved(Arc), + FrequencyResponseToggled(measurement::Id, bool), + ChangeSmoothing(frequency_response::Smoothing), + FrequencyResponseSmoothed(measurement::Id, Box<[f32]>), ShiftKeyPressed, ShiftKeyReleased, - ImpulseResponseComputed(ui::measurement::Id, data::ImpulseResponse), + MeasurementChart(waveform::Interaction), Recording(recording::Message), StartRecording(recording::Kind), - SpectralDecayComputed(ui::measurement::Id, data::SpectralDecay), - - Spectrogram(chart::spectrogram::Interaction), - SpectrogramComputed(ui::measurement::Id, data::Spectrogram), - - Modal(ModalAction), OpenSpectralDecayConfig, SpectralDecayConfig(spectral_decay_config::Message), + SpectralDecayComputed(measurement::Id, data::SpectralDecay), + OpenSpectrogramConfig, SpectrogramConfig(spectrogram_config::Message), -} + SpectrogramComputed(measurement::Id, data::Spectrogram), + Spectrogram(chart::spectrogram::Interaction), -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TabId { - Measurements, - ImpulseResponses, - FrequencyResponses, - SpectralDecay, - Spectrogram, + PendingWindow(pending_window::Message), } impl Main { @@ -191,16 +164,16 @@ impl Main { .loopback .map(|loopback| { Task::perform( - measurement::load_measurement(loopback.0.path, measurement::Kind::Loopback), - measurement::Message::Loaded, + Loopback::from_file(loopback.0.path), + Message::LoopbackLoaded, ) }) - .unwrap_or(Task::none()); + .unwrap_or_default(); let load_measurements = project.measurements.into_iter().map(|measurement| { Task::perform( - measurement::load_measurement(measurement.path, measurement::Kind::Normal), - measurement::Message::Loaded, + Measurement::from_file(measurement.path), + Message::MeasurementLoaded, ) }); @@ -209,777 +182,743 @@ impl Main { project_path: Some(path), ..Default::default() }, - Task::batch([load_loopback, Task::batch(load_measurements)]).map(Message::Measurements), + Task::batch([load_loopback, Task::batch(load_measurements)]), ) } - pub fn update( - &mut self, - recent_projects: &mut RecentProjects, - message: Message, - ) -> Task { - match message { - Message::TabSelected(id) => { - let State::Analysing { - ref mut active_tab, - ref window, - ref mut selected_impulse_response, - ref loopback, - .. - } = self.state - else { + pub fn update(&mut self, recent_projects: &mut RecentProjects, msg: Message) -> Task { + match msg { + Message::NewProject => { + *self = Self::default(); + Task::none() + } + Message::LoadProject => Task::future(pick_project_file_to_load()) + .and_then(|path| Task::perform(load_project(path), Message::ProjectLoaded)), + Message::LoadRecentProject(index) => { + let Some(path) = recent_projects.get(index) else { return Task::none(); }; - let (tab, tasks) = match (&active_tab, id) { - (Tab::Measurements { .. }, TabId::Measurements) - | (Tab::ImpulseResponses { .. }, TabId::ImpulseResponses) - | (Tab::FrequencyResponses, TabId::FrequencyResponses) => return Task::none(), - ( - Tab::ImpulseResponses { - ref window_settings, - .. - }, - tab_id, - ) if window_settings.window != *window => { - self.modal = Modal::PendingWindow { goto_tab: tab_id }; - return Task::none(); - } - (_, TabId::Measurements) => { - (Tab::Measurements { recording: None }, Task::none()) - } - (_, TabId::ImpulseResponses) => ( - Tab::ImpulseResponses { - window_settings: WindowSettings::new(window.clone()), - }, - Task::none(), - ), - (_, TabId::FrequencyResponses) => { - let tasks = self.measurements.loaded_mut().map(|measurement| { - compute_frequency_response(loopback, measurement, window) - }); - (Tab::FrequencyResponses, Task::batch(tasks)) - } - (_, TabId::SpectralDecay) => (Tab::SpectralDecay, Task::none()), - (_, TabId::Spectrogram) => (Tab::Spectrogram, Task::none()), + Task::perform(load_project(path.clone()), Message::ProjectLoaded) + } + Message::ProjectLoaded(Ok((project, path))) => { + let Some(project) = Arc::into_inner(project) else { + return Task::none(); }; - *active_tab = tab; - *selected_impulse_response = None; + let (view, tasks) = Self::from_project(path, project); + *self = view; tasks } - Message::Measurements(message) => { - match message { - measurement::Message::Load(kind) => { - let dialog_caption = kind.to_string(); - - return Task::perform( - measurement::pick_file_and_load_signal(dialog_caption, kind), - measurement::Message::Loaded, - ) - .map(Message::Measurements); + 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, + }; + + 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)) + }) + }) + } + } + Message::ProjectSaved(Ok(path)) => { + self.project_path = Some(path.clone()); + recent_projects.insert(path); + + Task::future(recent_projects.clone().save()).discard() + } + Message::OpenTab(tab) => { + let State::Analysing { ref active_tab, .. } = self.state else { + return Task::none(); + }; + + if let Tab::ImpulseResponses { pending_window } = active_tab + && !matches!(tab, tab::Id::ImpulseResponses) + && self + .window + .as_ref() + .is_none_or(|window| pending_window != window) + { + self.modal = Modal::PendingWindow { goto_tab: tab }; + return Task::none(); + } + + match tab { + tab::Id::Measurements => { + let State::Analysing { + active_tab: ref mut tab, + .. + } = self.state + else { + return Task::none(); + }; + + *tab = Tab::Measurements; + + Task::none() + } + tab::Id::ImpulseResponses => { + let State::Analysing { + active_tab: ref mut tab, + .. + } = self.state + else { + return Task::none(); + }; + + let Some(window) = &self.window else { + return Task::none(); + }; + + *tab = Tab::ImpulseResponses { + pending_window: window.clone(), + }; + + Task::none() } - measurement::Message::Loaded(Ok(result)) => match Arc::into_inner(result) { - Some(measurement::LoadedKind::Loopback(new)) => match &mut self.state { - State::CollectingMeasuremnts { loopback, .. } => *loopback = Some(new), - State::Analysing { loopback, .. } => *loopback = new, - }, - Some(measurement::LoadedKind::Normal(measurement)) => { - self.measurements.push(measurement) + tab::Id::FrequencyResponses => { + let State::Analysing { + ref mut active_tab, + ref mut analyses, + .. + } = self.state + else { + return Task::none(); + }; + + *active_tab = Tab::FrequencyResponses { + cache: canvas::Cache::new(), + }; + + let tasks = self.measurements.loaded().map(Measurement::id).map(|id| { + compute_frequency_response( + analyses, + id, + self.loopback.as_ref(), + &self.measurements, + self.window.as_ref().cloned().unwrap(), + ) + }); + + Task::batch(tasks) + } + tab::Id::SpectralDecays => { + let State::Analysing { + selected, + ref mut active_tab, + ref mut analyses, + .. + } = self.state + else { + return Task::none(); + }; + + *active_tab = Tab::SpectralDecays { + cache: canvas::Cache::new(), + }; + + if let Some(id) = selected { + compute_spectral_decay( + id, + analyses, + self.spectral_decay_config, + self.loopback.as_ref(), + &self.measurements, + ) + } else { + Task::none() } - None => {} - }, - measurement::Message::Loaded(Err(err)) => { - log::error!("{err}"); } - measurement::Message::Remove(id) => { - self.measurements.remove(id); + tab::Id::Spectrograms => { + let State::Analysing { + selected, + ref mut active_tab, + ref mut analyses, + .. + } = self.state + else { + return Task::none(); + }; + + *active_tab = Tab::Spectrograms; + self.spectrogram.cache.clear(); + + if let Some(id) = selected { + compute_spectrogram( + id, + analyses, + &self.spectrogram_config, + self.loopback.as_ref(), + &self.measurements, + ) + } else { + Task::none() + } } + } + } + Message::LoadLoopback => Task::future(pick_measurement_file("Load Loopback ...")) + .and_then(|path| Task::perform(Loopback::from_file(path), Message::LoopbackLoaded)), + Message::LoadMeasurement => Task::future(pick_measurement_file("Load measurement ...")) + .and_then(|path| { + Task::perform(Measurement::from_file(path), Message::MeasurementLoaded) + }), + Message::LoopbackLoaded(loopback) => { + self.window = loopback + .loaded() + .map(raumklang_core::Loopback::sample_rate) + .map(SampleRate::from) + .map(Window::new) + .map(Into::into); + + self.loopback = Some(loopback); + + if !self.measurements.is_empty() { + self.state = State::analysis(); + } + + Task::none() + } + Message::MeasurementLoaded(measurement) => { + let is_loopback_loaded = self.loopback.as_ref().is_some_and(Loopback::is_loaded); + + if is_loopback_loaded && self.measurements.is_empty() { + self.state = State::analysis(); + } + + self.measurements.push(measurement); + + Task::none() + } + Message::Measurement(msg) => { + match msg { measurement::Message::Select(selected) => { self.selected = Some(selected); self.signal_cache.clear(); } - } + measurement::Message::Remove(id) => { + self.measurements.remove(id); - let is_analysing_possible = self.analysing_possible(); - let state = std::mem::take(&mut self.state); - self.state = match (state, is_analysing_possible) { - ( - State::CollectingMeasuremnts { - recording, - loopback, - }, - false, - ) => State::CollectingMeasuremnts { - recording, - loopback, - }, - ( - State::CollectingMeasuremnts { - recording, - loopback: Some(loopback), - }, - true, - ) => State::Analysing { - active_tab: Tab::Measurements { recording }, - window: Window::new(SampleRate::from( - loopback - .loaded() - .map_or(44_100, |l| l.as_ref().sample_rate()), - )) - .into(), - selected_impulse_response: None, - charts: Charts::default(), - loopback, - }, - (old_state, true) => old_state, - (State::Analysing { loopback, .. }, false) => State::CollectingMeasuremnts { - recording: None, - loopback: Some(loopback), - }, + if self.measurements.loaded().next().is_none() { + self.state = State::Collecting + } + + if let State::Analysing { + ref mut analyses, .. + } = self.state + { + analyses.remove(&id); + } + } }; Task::none() } - Message::ImpulseResponseSelected(id) => { - log::debug!("Impulse response selected: {id}"); + Message::MeasurementChart(interaction) => { + match interaction { + waveform::Interaction::ZoomChanged(zoom) => self.zoom = zoom, + waveform::Interaction::OffsetChanged(offset) => self.offset = offset, + } + self.signal_cache.clear(); + + Task::none() + } + Message::ImpulseResponse(id, ui::impulse_response::Message::Select) => { let State::Analysing { - ref mut selected_impulse_response, - ref mut charts, - ref active_tab, - ref loopback, + active_tab: tab, + selected, + analyses, .. - } = self.state + } = &mut self.state else { return Task::none(); }; - *selected_impulse_response = Some(id); - charts.impulse_responses.data_cache.clear(); + *selected = Some(id); + self.ir_chart.data_cache.clear(); - let Some(measurement) = self.measurements.get_mut(id) else { - return Task::none(); - }; + match tab { + Tab::Measurements => Task::none(), + Tab::ImpulseResponses { .. } => compute_impulse_response( + analyses, + id, + self.loopback.as_ref(), + &self.measurements, + ), + Tab::FrequencyResponses { .. } => Task::none(), + Tab::SpectralDecays { .. } => compute_spectral_decay( + id, + analyses, + self.spectral_decay_config, + self.loopback.as_ref(), + &self.measurements, + ), - match active_tab { - Tab::Measurements { .. } => Task::none(), - Tab::ImpulseResponses { .. } => compute_impulse_response(loopback, measurement), - Tab::FrequencyResponses => Task::none(), - Tab::SpectralDecay => { - compute_spectral_decay(loopback, measurement, self.spectral_decay_config) - } - Tab::Spectrogram => { - compute_spectrogram(loopback, measurement, &self.spectrogram_config) - } + Tab::Spectrograms => compute_spectrogram( + id, + analyses, + &self.spectrogram_config, + self.loopback.as_ref(), + &self.measurements, + ), } } - Message::ImpulseResponseComputed(id, impulse_response) => { - let State::Analysing { - window, - active_tab, - selected_impulse_response, - charts, - loopback, - .. - } = &mut self.state - else { + Message::ImpulseResponse(id, ui::impulse_response::Message::Save) => { + let State::Analysing { .. } = self.state else { return Task::none(); }; - let impulse_response = ui::ImpulseResponse::from_data(impulse_response); - - let Some(measurement) = self.measurements.get_mut(id) else { + Task::perform( + choose_impulse_response_file_path(), + Message::SaveImpulseResponseToFile.with(id), + ) + } + Message::SaveImpulseResponseToFile(id, path) => { + let Some(path) = path else { return Task::none(); }; - let Some(analysis) = measurement.analysis_mut() else { + let State::Analysing { + ref mut analyses, .. + } = self.state + else { return Task::none(); }; - analysis.impulse_response.computed(impulse_response.clone()); - - if selected_impulse_response.is_some_and(|selected| selected == id) { - charts - .impulse_responses - .x_range - .get_or_insert(0.0..=impulse_response.data.len() as f32); - - charts.impulse_responses.data_cache.clear(); - } - - if let Tab::FrequencyResponses = active_tab { - compute_frequency_response(loopback, measurement, window) - } else if let Tab::SpectralDecay = active_tab { - compute_spectral_decay(loopback, measurement, self.spectral_decay_config) - } else if let Tab::Spectrogram = active_tab { - compute_spectrogram(loopback, measurement, &self.spectrogram_config) + let analysis = analyses.entry(id).or_default(); + if let Some(ir) = analysis.impulse_response.result().cloned() { + Task::perform(save_impulse_response(path.clone(), ir.clone()), move |_| { + Message::ImpulseResponseSaved(id, path) + }) } else { - Task::none() + compute_impulse_response( + analyses, + id, + self.loopback.as_ref(), + &self.measurements, + ) + .chain(Task::done(Message::SaveImpulseResponseToFile( + id, + Some(path), + ))) } } - Message::ImpulseResponses(impulse_response::Message::Chart(operation)) => { + Message::ImpulseResponseSaved(id, path) => { + eprintln!("IR (#{:?}) saved to: {:?}", id, path); + + Task::none() + } + Message::ImpulseResponseComputed(id, impulse_response) => { let State::Analysing { - active_tab: - Tab::ImpulseResponses { - ref mut window_settings, - .. - }, - ref mut charts, + ref active_tab, + ref mut analyses, .. } = self.state else { return Task::none(); }; - if let ChartOperation::Interaction(ref interaction) = operation { - match interaction { - chart::Interaction::HandleMoved(index, new_pos) => { - let mut handles: window::Handles = Into::into(&window_settings.window); - handles.update(*index, *new_pos); - window_settings.window.update(handles); - } - chart::Interaction::ZoomChanged(zoom) => { - charts.impulse_responses.zoom = *zoom; - } - chart::Interaction::OffsetChanged(offset) => { - charts.impulse_responses.offset = *offset; - } - } - } - - charts.impulse_responses.update(operation); + analyses.entry(id).and_modify(|analysis| { + analysis.impulse_response = + ui::impulse_response::State::from_data(impulse_response); + }); - Task::none() + match active_tab { + Tab::Measurements => Task::none(), + Tab::ImpulseResponses { .. } => Task::none(), + Tab::FrequencyResponses { .. } => compute_frequency_response( + analyses, + id, + self.loopback.as_ref(), + &self.measurements, + self.window.as_ref().cloned().unwrap(), + ), + Tab::SpectralDecays { .. } => compute_spectral_decay( + id, + analyses, + self.spectral_decay_config, + self.loopback.as_ref(), + &self.measurements, + ), + Tab::Spectrograms => compute_spectrogram( + id, + analyses, + &self.spectrogram_config, + self.loopback.as_ref(), + &self.measurements, + ), + } } - Message::FrequencyResponseComputed(id, frequency_response) => { - log::debug!("Frequency response computed: {id}"); - - let State::Analysing { ref mut charts, .. } = self.state else { + Message::PendingWindow(action) => { + let State::Analysing { + active_tab, + analyses, + .. + } = &mut self.state + else { return Task::none(); }; - let Some(measurement) = self.measurements.get_mut(id) else { + let Modal::PendingWindow { goto_tab } = mem::take(&mut self.modal) else { return Task::none(); }; - let Some(analysis) = measurement.analysis_mut() else { - return Task::none(); - }; + let tab = mem::take(active_tab); + if let Tab::ImpulseResponses { pending_window } = tab { + match action { + pending_window::Message::Discard => self.ir_chart.overlay_cache.clear(), + pending_window::Message::Apply => { + self.window = Some(pending_window); + analyses.values_mut().for_each(|a| *a = Analysis::default()); + } + } + } - analysis - .frequency_response - .computed(frequency_response.clone()); + self.update(recent_projects, Message::OpenTab(goto_tab)) + } + Message::FrequencyResponseComputed(id, new_fr) => { + log::debug!("Frequency response computed: {id}"); - charts.frequency_responses.cache.clear(); + let State::Analysing { + ref mut analyses, + active_tab: Tab::FrequencyResponses { ref cache }, + .. + } = self.state + else { + return Task::none(); + }; - if let Some(fraction) = self.smoothing.fraction() { + let task = if let Some(fraction) = self.smoothing.fraction() { Task::perform( - frequency_response::smooth_frequency_response( - id, - frequency_response, - fraction, - ), - Message::FrequencyResponseSmoothed, + frequency_response::smooth_frequency_response(new_fr.clone(), fraction), + Message::FrequencyResponseSmoothed.with(id), ) } else { Task::none() - } + }; + + let analysis = analyses.entry(id).or_default(); + analysis.frequency_response.set_result(new_fr); + cache.clear(); + + task } Message::FrequencyResponseToggled(id, state) => { - let State::Analysing { ref mut charts, .. } = self.state else { + let State::Analysing { + ref mut analyses, + active_tab: Tab::FrequencyResponses { ref cache }, + .. + } = self.state + else { return Task::none(); }; - let Some(frequency_response) = self - .measurements - .get_mut(id) - .and_then(ui::Measurement::analysis_mut) - .map(|analysis| &mut analysis.frequency_response) - else { + let Some(fr) = analyses.get_mut(&id).map(Analysis::frequency_response_mut) else { return Task::none(); }; - frequency_response.is_shown = state; - - charts.frequency_responses.cache.clear(); + fr.is_shown = state; + cache.clear(); Task::none() } - Message::SmoothingChanged(smoothing) => { - let State::Analysing { ref mut charts, .. } = self.state else { + Message::ChangeSmoothing(smoothing) => { + let State::Analysing { + ref mut analyses, + active_tab: Tab::FrequencyResponses { ref cache }, + .. + } = self.state + else { return Task::none(); }; self.smoothing = smoothing; if let Some(fraction) = smoothing.fraction() { - let tasks = self.measurements.iter().flat_map(|measurement| { - let fr = measurement - .analysis() - .as_ref() - .and_then(|a| a.frequency_response.data.clone())?; + let tasks = analyses.iter().flat_map(|(id, analysis)| { + let fr = analysis.frequency_response.result()?; Some(Task::perform( - frequency_response::smooth_frequency_response( - measurement.id(), - fr, - fraction, - ), - Message::FrequencyResponseSmoothed, + frequency_response::smooth_frequency_response(fr.clone(), fraction), + Message::FrequencyResponseSmoothed.with(*id), )) }); Task::batch(tasks) } else { - self.measurements - .iter_mut() - .flat_map(ui::Measurement::analysis_mut) - .map(|analysis| &mut analysis.frequency_response) + analyses + .values_mut() + .map(Analysis::frequency_response_mut) .for_each(|fr| fr.smoothed = None); - charts.frequency_responses.cache.clear(); + cache.clear(); Task::none() } } - Message::FrequencyResponseSmoothed((id, smoothed_data)) => { - let State::Analysing { ref mut charts, .. } = self.state else { - return Task::none(); - }; - - let Some(frequency_response) = self - .measurements - .get_mut(id) - .and_then(ui::Measurement::analysis_mut) - .map(|analysis| &mut analysis.frequency_response) + Message::FrequencyResponseSmoothed(id, smoothed) => { + let State::Analysing { + ref mut analyses, + active_tab: Tab::FrequencyResponses { ref cache }, + .. + } = self.state else { return Task::none(); }; - frequency_response.smoothed = Some(smoothed_data); - charts.frequency_responses.cache.clear(); + if let Some(fr) = analyses.get_mut(&id).map(|a| &mut a.frequency_response) { + fr.smoothed = Some(smoothed); + cache.clear(); + } Task::none() } - Message::ShiftKeyPressed => { - let State::Analysing { ref mut charts, .. } = self.state else { + + Message::SpectralDecayComputed(id, sd) => { + let State::Analysing { + ref mut analyses, + active_tab: Tab::SpectralDecays { ref cache }, + .. + } = self.state + else { return Task::none(); }; - charts.impulse_responses.shift_key_pressed(); - - Task::none() - } - Message::ShiftKeyReleased => { - let State::Analysing { ref mut charts, .. } = self.state else { + let Some(analysis) = analyses.get_mut(&id) else { return Task::none(); }; - charts.impulse_responses.shift_key_released(); + analysis.spectral_decay.set_result(sd); + cache.clear(); + + Task::none() + } + Message::OpenSpectralDecayConfig => { + self.modal = Modal::SpectralDecayConfig(SpectralDecayConfig::new( + self.spectral_decay_config, + )); Task::none() } - Message::Modal(action) => { - let Modal::PendingWindow { goto_tab } = std::mem::take(&mut self.modal) else { + Message::SpectralDecayConfig(msg) => { + let Modal::SpectralDecayConfig(modal) = &mut self.modal else { return Task::none(); }; let State::Analysing { - ref mut active_tab, - ref mut window, + selected, + ref mut analyses, .. } = self.state else { return Task::none(); }; - let Tab::ImpulseResponses { - ref mut window_settings, - .. - } = active_tab - else { - return Task::none(); - }; - - match action { - ModalAction::Discard => { - *window_settings = WindowSettings::new(window.clone()); - } - ModalAction::Apply => { - self.measurements - .iter_mut() - .flat_map(ui::Measurement::analysis_mut) - .for_each(|analysis| { - analysis.frequency_response = ui::FrequencyResponse::default(); - analysis.spectral_decay = ui::spectral_decay::State::default(); - analysis.spectrogram = ui::spectrogram::State::default(); - }); - - *window = window_settings.window.clone(); + match modal.update(msg) { + spectral_decay_config::Action::None => Task::none(), + spectral_decay_config::Action::Discard => { + self.modal = Modal::None; + Task::none() } - } - - Task::done(Message::TabSelected(goto_tab)) - } - Message::Recording(message) => { - let (State::CollectingMeasuremnts { - ref mut recording, - loopback: Some(ref mut loopback), - } - | State::Analysing { - active_tab: Tab::Measurements { ref mut recording }, - ref mut loopback, - .. - }) = self.state - else { - return Task::none(); - }; - - if let Some(view) = recording { - match view.update(message) { - recording::Action::None => Task::none(), - recording::Action::Cancel => { - *recording = None; - Task::none() - } - recording::Action::Finished(result) => { - match result { - recording::Result::Loopback(signal) => { - *loopback = ui::Loopback::new("Loopback".to_string(), signal) - } - recording::Result::Measurement(signal) => { - self.measurements.push(ui::Measurement::new( - "Measurement".to_string(), - None, - Some(signal), - )) - } - } - *recording = None; - Task::none() - } - recording::Action::Task(task) => task.map(Message::Recording), + spectral_decay_config::Action::Apply(config) => { + self.modal = Modal::None; + self.spectral_decay_config = config; + + analyses.values_mut().for_each(|a| a.spectral_decay.reset()); + + selected + .map(|id| { + compute_spectral_decay( + id, + analyses, + config, + self.loopback.as_ref(), + &self.measurements, + ) + }) + .unwrap_or_default() } - } else { - Task::none() } } - Message::StartRecording(kind) => match &mut self.state { - State::CollectingMeasuremnts { recording, .. } - | State::Analysing { - active_tab: Tab::Measurements { recording }, - .. - } => { - *recording = Some(Recording::new(kind)); - Task::none() - } - _ => Task::none(), - }, - Message::MeasurementChart(interaction) => { - match interaction { - waveform::Interaction::ZoomChanged(zoom) => self.zoom = zoom, - waveform::Interaction::OffsetChanged(offset) => self.offset = offset, - } - - self.signal_cache.clear(); - - Task::none() - } - Message::SaveImpulseResponseFileDialog(id) => { - Task::future(choose_impulse_response_file_path()) - .and_then(Task::done) - .map(Message::SaveImpulseResponse.with(id)) - } - Message::SaveImpulseResponse(id, path) => { + Message::SpectrogramComputed(id, spectrogram) => { let State::Analysing { - active_tab: Tab::ImpulseResponses { .. }, + selected, + ref mut analyses, .. - } = &self.state + } = self.state else { return Task::none(); }; - if let Some(impulse_response) = self - .measurements - .get(id) - .and_then(ui::Measurement::analysis) - .and_then(|analysis| analysis.impulse_response.result()) - .cloned() - { - Task::perform( - save_impulse_response(path.clone(), impulse_response), - |_| Message::ImpulseResponsesSaved(path), - ) - } else { - self.compute_impulse_response(id) - .chain(Task::done(Message::SaveImpulseResponse(id, path))) - } - } - Message::ImpulseResponsesSaved(path) => { - log::debug!("Impulse response saved to: {}", path.display()); - - Task::none() - } - Message::NewProject => { - *self = Self::default(); + let analysis = analyses.entry(id).or_default(); + analysis.spectrogram.set_result(spectrogram); - Task::none() - } - Message::LoadProject => Task::perform( - crate::pick_project_file().then(async |res| { - let path = res?; - load_project(path).await - }), - Message::ProjectLoaded, - ), - Message::ProjectLoaded(Ok((project, path))) => match Arc::into_inner(project) { - Some(project) => { - recent_projects.insert(path.clone()); - - let (screen, tasks) = Self::from_project(path, project); - *self = screen; - - Task::batch([ - tasks, - Task::future(recent_projects.clone().save()).discard(), - ]) + if selected.is_some_and(|selected| selected == id) { + self.spectrogram.cache.clear(); } - None => Task::none(), - }, - Message::ProjectLoaded(Err(err)) => { - log::debug!("Loading project failed: {err}"); - - Task::none() - } - Message::RecentProject(id) => match recent_projects.get(id) { - Some(path) => Task::perform(load_project(path.clone()), Message::ProjectLoaded), - None => Task::none(), - }, - Message::SaveProject => { - let loopback = if let State::CollectingMeasuremnts { ref loopback, .. } = self.state - { - loopback.as_ref() - } else if let State::Analysing { ref loopback, .. } = self.state { - Some(loopback) - } else { - None - }; - - let loopback = loopback - .as_ref() - .and_then(|l| l.path.clone()) - .map(|path| project::Loopback(project::Measurement { path })); - - let measurements = self - .measurements - .iter() - .flat_map(|m| m.path.clone()) - .map(|path| project::Measurement { path }) - .collect(); - - let project = Project { - loopback, - measurements, - }; - - if let Some(path) = self.project_path.clone() { - Task::perform( - project - .save(path.clone()) - .map_ok(move |_| path) - .map_err(PickAndSaveError::File), - Message::ProjectSaved, - ) - } else { - Task::perform( - pick_project_file().then(async |res| { - let path = res?; - project.save(path.clone()).await?; - - Ok(path) - }), - Message::ProjectSaved, - ) - } - } - Message::ProjectSaved(Ok(path)) => { - log::debug!("Project saved."); - - self.project_path = Some(path); - - Task::none() - } - Message::ProjectSaved(Err(err)) => { - log::debug!("Saving project failed: {err}"); - Task::none() - } - Message::SpectralDecayComputed(id, decay) => { - log::debug!( - "Spectral decay for measurement (ID: {}) with: {} slices, computed.", - id, - decay.len() - ); - - let Some(spectral_decay) = self - .measurements - .get_mut(id) - .and_then(ui::Measurement::analysis_mut) - .map(|analysis| &mut analysis.spectral_decay) - else { - return Task::none(); - }; - - spectral_decay.computed(decay); - - if let State::Analysing { charts, .. } = &mut self.state { - charts.spectral_decay_cache.clear(); - }; Task::none() } Message::Spectrogram(interaction) => { - log::debug!("Spectrogram chart: {interaction:?}."); - - let State::Analysing { charts, .. } = &mut self.state else { - return Task::none(); - }; - match interaction { chart::spectrogram::Interaction::ZoomChanged(zoom) => { - charts.spectrogram.zoom = zoom + self.spectrogram.zoom = zoom } chart::spectrogram::Interaction::OffsetChanged(offset) => { - charts.spectrogram.offset = offset + self.spectrogram.offset = offset } } - charts.spectrogram.cache.clear(); + self.spectrogram.cache.clear(); Task::none() } - Message::SpectrogramComputed(id, data) => { - log::debug!( - "Spectrogram for measurement (ID: {}) with: {} slices, computed.", - id, - data.len() - ); - - let Some(spectrogram) = self - .measurements - .get_mut(id) - .and_then(ui::Measurement::analysis_mut) - .map(|analysis| &mut analysis.spectrogram) - else { - return Task::none(); - }; - - spectrogram.computed(data); - - if let State::Analysing { charts, .. } = &mut self.state { - charts.spectrogram.cache.clear(); - }; - - Task::none() - } - Message::OpenSpectralDecayConfig => { - self.modal = Modal::SpectralDecayConfig(SpectralDecayConfig::new( - self.spectral_decay_config, + Message::OpenSpectrogramConfig => { + self.modal = Modal::SpectrogramConfig(modal::SpectrogramConfig::new( + self.spectrogram_config.clone(), )); Task::none() } - Message::SpectralDecayConfig(message) => { - let Modal::SpectralDecayConfig(config) = &mut self.modal else { + Message::SpectrogramConfig(message) => { + let Modal::SpectrogramConfig(config) = &mut self.modal else { return Task::none(); }; let State::Analysing { - selected_impulse_response, - ref loopback, - .. - } = self.state + selected, analyses, .. + } = &mut self.state else { return Task::none(); }; match config.update(message) { - Some(action) => { + spectrogram_config::Action::None => Task::none(), + spectrogram_config::Action::Close => { + self.modal = Modal::None; + + Task::none() + } + spectrogram_config::Action::ConfigChanged(preferences) => { self.modal = Modal::None; - if let spectral_decay_config::Action::Apply(config) = action { - self.spectral_decay_config = config; - - self.measurements - .iter_mut() - .flat_map(ui::Measurement::analysis_mut) - .for_each(|analysis| { - analysis.spectral_decay = ui::spectral_decay::State::default() - }); - - selected_impulse_response - .and_then(|id| self.measurements.iter_mut().find(|m| m.id() == id)) - .map_or(Task::none(), |measurement| { - compute_spectral_decay( - loopback, - measurement, - self.spectral_decay_config, - ) + let task = if let Some(id) = selected { + analyses + .get_mut(id) + .and_then(|a| { + a.spectrogram.compute(&a.impulse_response, &preferences) }) + .map(|f| Task::perform(f, Message::SpectrogramComputed.with(*id))) + .unwrap_or_default() } else { Task::none() - } + }; + + self.spectrogram_config = preferences; + analyses.values_mut().for_each(|a| a.spectrogram.reset()); + + task } - None => Task::none(), } } - Message::OpenSpectrogramConfig => { - self.modal = - Modal::SpectrogramConfig(SpectrogramConfig::new(self.spectrogram_config)); - - Task::none() - } - Message::SpectrogramConfig(message) => { - let Modal::SpectrogramConfig(config) = &mut self.modal else { - return Task::none(); - }; - + Message::ImpulseResponseChart(operation) => { let State::Analysing { - selected_impulse_response, - ref loopback, + active_tab: Tab::ImpulseResponses { pending_window }, .. - } = self.state + } = &mut self.state else { return Task::none(); }; - match config.update(message) { - spectrogram_config::Action::None => Task::none(), - spectrogram_config::Action::Close => { - self.modal = Modal::None; - + if let ChartOperation::Interaction(ref interaction) = operation { + match interaction { + chart::Interaction::HandleMoved(index, new_pos) => { + let mut handles = window::Handles::from(&*pending_window); + handles.update(*index, *new_pos); + pending_window.update(handles); + } + chart::Interaction::ZoomChanged(zoom) => { + self.ir_chart.zoom = *zoom; + } + chart::Interaction::OffsetChanged(offset) => { + self.ir_chart.offset = *offset; + } + } + } + self.ir_chart.update(operation); + + Task::none() + } + Message::StartRecording(kind) => { + self.recording = Some(Recording::new(kind)); + Task::none() + } + Message::Recording(msg) => { + let Some(recording) = &mut self.recording else { + return Task::none(); + }; + + match recording.update(msg) { + recording::Action::None => Task::none(), + recording::Action::Cancel => { + self.recording = None; Task::none() } - spectrogram_config::Action::ConfigChanged(preferences) => { - self.modal = Modal::None; - self.spectrogram_config = preferences; + recording::Action::Task(task) => task.map(Message::Recording), + recording::Action::Finished(result) => { + match result { + recording::Result::Loopback(loopback) => { + self.loopback = + Some(ui::Loopback::new("Loopback".to_string(), loopback)); + } + recording::Result::Measurement(measurement) => { + self.measurements.push(ui::Measurement::new( + "Measurement".to_string(), + None, + Some(measurement), + )); + } + } - self.measurements - .iter_mut() - .flat_map(ui::Measurement::analysis_mut) - .for_each(|analysis| { - analysis.spectrogram = ui::spectrogram::State::default() - }); - - selected_impulse_response - .and_then(|id| self.measurements.get_mut(id)) - .map_or(Task::none(), |measurement| { - compute_spectrogram(loopback, measurement, &self.spectrogram_config) - }) + self.recording = None; + Task::none() } } } + Message::ShiftKeyPressed => { + self.ir_chart.shift_key_pressed(); + Task::none() + } + Message::ShiftKeyReleased => { + self.ir_chart.shift_key_released(); + Task::none() + } + _ => Task::none(), } } @@ -994,7 +933,7 @@ impl Main { .filter_map(|(i, p)| p.to_str().map(|f| (i, f))) .map(|(i, s)| { button(s) - .on_press(Message::RecentProject(i)) + .on_press(Message::LoadRecentProject(i)) .style(button::subtle) .width(Length::Fill) .into() @@ -1021,140 +960,145 @@ impl Main { .width(Length::Fill) }; + let tab = |s, is_active, id: Option<_>| { + button(text(s).size(20)) + .padding(10) + .style(move |theme: &Theme, status| { + let palette = theme.extended_palette(); + + let base = button::text(theme, status); + + if is_active { + button::Style { + text_color: palette.background.neutral.text, + background: Some(palette.background.base.color.into()), + ..base + } + } else { + base + } + }) + .on_press_maybe(id.map(Message::OpenTab)) + }; + + let active_tab = self.state.active_tab(); + let tabs = row![ + tab( + "Measurements", + active_tab.is_none() || matches!(active_tab, Some(Tab::Measurements)), + Some(tab::Id::Measurements) + ), + tab( + "Impulse Responses", + matches!(active_tab, Some(Tab::ImpulseResponses { .. })), + active_tab.is_some().then_some(tab::Id::ImpulseResponses) + ), + tab( + "Frequency Responses", + matches!(active_tab, Some(Tab::FrequencyResponses { .. })), + active_tab.is_some().then_some(tab::Id::FrequencyResponses) + ), + tab( + "Spectral Decays", + matches!(active_tab, Some(Tab::SpectralDecays { .. })), + active_tab.is_some().then_some(tab::Id::SpectralDecays) + ), + tab( + "Spectrogram", + matches!(active_tab, Some(Tab::Spectrograms)), + active_tab.is_some().then_some(tab::Id::Spectrograms) + ), + ] + .spacing(5) + .align_y(Center); + container(column![ dropdown_root("Project", project_menu).style(button::secondary), - match &self.state { - State::CollectingMeasuremnts { .. } => TabId::Measurements.view(false), - State::Analysing { active_tab, .. } => TabId::from(active_tab).view(true), - } + tabs, ]) .width(Length::Fill) .style(container::dark) }; - let content = match &self.state { - State::CollectingMeasuremnts { - recording, - loopback, - } => { - if let Some(recording) = recording { - recording.view().map(Message::Recording) - } else { - self.measurements_tab(loopback.as_ref()) - } - } - State::Analysing { - active_tab, - selected_impulse_response, - charts, - loopback, - .. - } => match active_tab { - Tab::Measurements { recording } => { - if let Some(recording) = recording { - recording.view().map(Message::Recording) - } else { - self.measurements_tab(Some(loopback)) + let content = { + match self.state { + State::Collecting => self.measurements_tab(), + State::Analysing { + ref active_tab, + selected, + ref analyses, + } => match active_tab { + Tab::Measurements => self.measurements_tab(), + Tab::ImpulseResponses { + pending_window: window_settings, + } => self.impulse_responses_tab( + selected, + &self.ir_chart, + window_settings, + analyses, + ), + Tab::FrequencyResponses { cache } => { + self.frequency_responses_tab(cache, analyses) } - } - Tab::ImpulseResponses { - window_settings, .. - } => self.impulse_responses_tab( - *selected_impulse_response, - &charts.impulse_responses, - window_settings, - ), - Tab::FrequencyResponses => { - self.frequency_responses_tab(&charts.frequency_responses) - } - Tab::SpectralDecay => self - .spectral_decay_tab(*selected_impulse_response, &charts.spectral_decay_cache), - Tab::Spectrogram => { - self.spectrogram_tab(*selected_impulse_response, &charts.spectrogram) - } - }, + Tab::SpectralDecays { cache } => { + self.spectral_decay_tab(selected, analyses, cache) + } + Tab::Spectrograms => { + self.spectrogram_tab(selected, analyses, &self.spectrogram) + } + }, + } }; - let content = container(column![header, container(content).padding(10)]); + let content = if let Some(recording) = &self.recording { + container(recording.view().map(Message::Recording)).padding(10) + } else { + container(column![header, container(content).padding(10)]) + }; match self.modal { Modal::None => content.into(), Modal::PendingWindow { .. } => { - let pending_window = { - container( - column![ - text("Window pending!").size(18), - column![ - text("You have modified the window used for frequency response computations."), - text("You need to discard or apply your changes before proceeding."), - ].spacing(5), - row![ - space::horizontal(), - button("Discard") - .style(button::danger) - .on_press(Message::Modal(ModalAction::Discard)), - button("Apply") - .style(button::success) - .on_press(Message::Modal(ModalAction::Apply)) - ] - .spacing(5) - ] - .spacing(10)) - .padding(20) - .width(400) - .style(container::bordered_box) - }; - - modal(content, pending_window) + modal(content, modal::pending_window().map(Message::PendingWindow)) } Modal::SpectralDecayConfig(ref config) => { modal(content, config.view().map(Message::SpectralDecayConfig)) } - Modal::SpectrogramConfig(ref spectrogram_config) => modal( - content, - spectrogram_config.view().map(Message::SpectrogramConfig), - ), + Modal::SpectrogramConfig(ref config) => { + modal(content, config.view().map(Message::SpectrogramConfig)) + } } } - fn measurements_tab<'a>(&'a self, loopback: Option<&'a ui::Loopback>) -> Element<'a, Message> { + fn measurements_tab<'a>(&'a self) -> Element<'a, Message> { let sidebar = { let loopback = Category::new("Loopback") + .push_button(sidebar::button(icon::plus()).on_press(Message::LoadLoopback)) .push_button( - button("+") - .on_press_maybe(Some(Message::Measurements(measurement::Message::Load( - measurement::Kind::Loopback, - )))) - .style(button::secondary), - ) - .push_button( - button(icon::record()) - .on_press(Message::StartRecording(recording::Kind::Loopback)) - .style(button::secondary), + sidebar::button(icon::record()) + .on_press(Message::StartRecording(recording::Kind::Loopback)), ) - .push_entry_maybe(loopback.map(|loopback| { - measurement::loopback_entry(self.selected, loopback).map(Message::Measurements) + .push_entry_maybe(self.loopback.as_ref().map(|loopback| { + let active = self.selected == Some(measurement::Selected::Loopback); + loopback.view(active).map(Message::Measurement) })); let measurements = Category::new("Measurements") + .push_button(sidebar::button(icon::plus()).on_press(Message::LoadMeasurement)) .push_button( - button("+") - .style(button::secondary) - .on_press(Message::Measurements(measurement::Message::Load( - measurement::Kind::Normal, - ))), - ) - .push_button( - button(icon::record()) - .on_press(Message::StartRecording(recording::Kind::Measurement)) - .style(button::secondary), + sidebar::button(icon::record()) + .on_press(Message::StartRecording(recording::Kind::Measurement)), ) .extend_entries(self.measurements.iter().map(|measurement| { - measurement::list_entry(self.selected, measurement).map(Message::Measurements) + let active = + self.selected == Some(measurement::Selected::Measurement(measurement.id())); + measurement.view(active).map(Message::Measurement) })); container(scrollable( - column![loopback, measurements].spacing(20).padding(10), + column![loopback, rule::horizontal(1), measurements] + .spacing(10) + .padding(10), )) .style(|theme| { container::rounded_box(theme) @@ -1183,25 +1127,16 @@ impl Main { .into() }; - let loopback = if let State::CollectingMeasuremnts { ref loopback, .. } = self.state { - loopback.as_ref() - } else if let State::Analysing { ref loopback, .. } = self.state { - Some(loopback) - } else { - None - }; - let content = if let Some(measurement) = self.selected.and_then(|selected| match selected { - measurement::Selected::Loopback => loopback + measurement::Selected::Loopback => self + .loopback .as_ref() - .and_then(|l| l.loaded()) + .and_then(Loopback::loaded) .map(AsRef::as_ref), - measurement::Selected::Measurement(id) => self - .measurements - .iter() - .find(|m| m.id() == id) - .and_then(ui::Measurement::signal), + measurement::Selected::Measurement(id) => { + self.measurements.get(id).and_then(Measurement::signal) + } }) { chart::waveform(measurement, &self.signal_cache, self.zoom, self.offset) .map(Message::MeasurementChart) @@ -1222,24 +1157,30 @@ impl Main { pub fn impulse_responses_tab<'a>( &'a self, - selected: Option, + selected: Option, chart: &'a impulse_response::Chart, - window_settings: &'a WindowSettings, + window: &'a Window, + analyses: &'a BTreeMap, ) -> Element<'a, Message> { let sidebar = { let header = sidebar::header("Impulse Responses"); let entries = self.measurements.iter().flat_map(|measurement| { + let active = selected == Some(measurement.id()); let signal = measurement.signal()?; - let analysis = measurement.analysis()?; + let progress = analyses + .get(&measurement.id()) + .map(|a| a.impulse_response.progress()); - Some(impulse_response_item( - selected, - measurement.id(), + let entry = ui::impulse_response::view( &measurement.name, - signal, - analysis, - )) + signal.modified, + progress, + active, + ) + .map(Message::ImpulseResponse.with(measurement.id())); + + Some(entry) }); container(column![header, scrollable(column(entries))].spacing(6)) @@ -1251,17 +1192,18 @@ impl Main { }; let content = { - let placeholder = center(text("Impulse response not computed, yet.")).into(); + let placeholder = center(text("Impulse response not computed, yet.")); selected - .and_then(|id| self.measurements.get(id)) - .and_then(ui::Measurement::analysis) - .and_then(|analysis| analysis.impulse_response.result()) - .map_or(placeholder, |impulse_response| { + .as_ref() + .and_then(|id| analyses.get(id)) + .and_then(Analysis::impulse_response) + .map(|impulse_response| { chart - .view(impulse_response, window_settings) - .map(Message::ImpulseResponses) + .view(impulse_response, window) + .map(Message::ImpulseResponseChart) }) + .unwrap_or(placeholder.into()) }; row![ @@ -1276,21 +1218,21 @@ impl Main { fn frequency_responses_tab<'a>( &'a self, - chart_settings: &'a frequency_response::ChartData, + cache: &'a canvas::Cache, + analyses: &'a BTreeMap, ) -> Element<'a, Message> { let sidebar = { let header = sidebar::header("Frequency Responses"); let entries = self.measurements.iter().flat_map(|measurement| { - let analysis = &measurement.analysis()?; + let analysis = analyses.get(&measurement.id())?; let content = analysis.frequency_response.view( &measurement.name, - analysis.impulse_response.progress(), Message::FrequencyResponseToggled.with(measurement.id()), ); - Some(sidebar::item(content, false)) + Some(content) }); container(column![header, scrollable(column(entries).spacing(6))].spacing(6)) @@ -1305,21 +1247,18 @@ impl Main { row![pick_list( frequency_response::Smoothing::ALL, Some(&self.smoothing), - Message::SmoothingChanged, + Message::ChangeSmoothing, )] }; - let frequency_responses = self - .measurements - .iter() - .flat_map(ui::Measurement::analysis) - .map(|analysis| &analysis.frequency_response); - - let content = if frequency_responses + let frequency_responses = analyses.values().map(|a| &a.frequency_response); + let chart_needed = frequency_responses .clone() - .any(|fr| fr.is_shown && fr.result().is_some()) - { + .any(|fr| fr.result().is_some() && fr.is_shown); + + let content = if chart_needed { let series_list = frequency_responses + .filter(|fr| fr.is_shown) .flat_map(|item| { let Some(frequency_response) = item.result() else { return [None, None]; @@ -1362,10 +1301,9 @@ impl Main { .collect(), ), ) - .x_range(chart_settings.x_range.clone().unwrap_or(20.0..=22_500.0)) .y_labels(Labels::default().format(&|v| format!("{v:.0}"))) .extend_series(series_list) - .cache(&chart_settings.cache); + .cache(cache); container(chart) } else { @@ -1385,6 +1323,7 @@ impl Main { pub fn spectral_decay_tab<'a>( &'a self, selected: Option, + analyses: &'a BTreeMap, cache: &'a canvas::Cache, ) -> Element<'a, Message> { let sidebar = { @@ -1400,6 +1339,7 @@ impl Main { let is_active = selected.is_some_and(|s| s == id); let signal = measurement.signal()?; + let entry = { // TODO: refactor, basically the same btn as IR and Spectrogram let dt: DateTime = signal.modified.into(); @@ -1407,13 +1347,16 @@ impl Main { column![ text(&measurement.name) .size(16) - .wrapping(Wrapping::WordOrGlyph), + .wrapping(text::Wrapping::WordOrGlyph), text!("{}", dt.format("%x %X")).size(10) ] .clip(true) .spacing(6), ) - .on_press_with(move || Message::ImpulseResponseSelected(id)) + // FIXME message type + .on_press_with(move || { + Message::ImpulseResponse(id, ui::impulse_response::Message::Select) + }) .width(Length::Fill) .style(move |theme: &Theme, status| { let base = button::subtle(theme, status); @@ -1429,16 +1372,22 @@ impl Main { sidebar::item(btn, is_active) }; - let analysis = measurement.analysis()?; - let entry = match analysis.spectral_decay_progress() { - ui::spectral_decay::Progress::None => entry, - ui::spectral_decay::Progress::ComputingImpulseResponse => { - processing_overlay("Impulse Response", entry) - } - ui::spectral_decay::Progress::Computing => { - processing_overlay("Spectral Decay", entry) + // FIXME + let analysis = analyses.get(&id); + + let entry = if let Some(analysis) = analysis { + match analysis.spectral_decay.progress() { + ui::spectral_decay::Progress::None => entry, + ui::spectral_decay::Progress::WaitingForImpulseResponse => { + processing_overlay("Impulse Response", entry) + } + ui::spectral_decay::Progress::Computing => { + processing_overlay("Computing ...", entry) + } + ui::spectral_decay::Progress::Finished => entry, } - ui::spectral_decay::Progress::Finished => entry, + } else { + entry }; Some(entry) @@ -1452,16 +1401,15 @@ impl Main { }) }; - let content = if let Some(decay) = self - .measurements - .iter() - .find(|m| Some(m.id()) == selected) - .and_then(ui::Measurement::analysis) - .and_then(|analysis| analysis.spectral_decay.result()) - { + let spectral_decay = selected + .and_then(|id| analyses.get(&id)) + .map(|a| &a.spectral_decay); + + let content = if let Some(decay) = spectral_decay.and_then(ui::SpectralDecay::result) { let gradient = colorous::MAGMA; let series_list = decay.iter().enumerate().map(|(fr_index, fr)| { + // FIXME refactor bin -> Hz computation let sample_rate = fr.sample_rate; let len = fr.data.len() * 2 + 1; let resolution = sample_rate as f32 / len as f32; @@ -1491,7 +1439,7 @@ impl Main { container(chart) } else { - container(text("Please select a frequency respone.")) + center(text("Please select a frequency respone.").size(18)) }; row![ @@ -1506,7 +1454,8 @@ impl Main { fn spectrogram_tab<'a>( &'a self, - selected: Option, + selected: Option, + analyses: &'a BTreeMap, spectrogram: &'a Spectrogram, ) -> Element<'a, Message> { let sidebar = { @@ -1528,13 +1477,15 @@ impl Main { column![ text(&measurement.name) .size(16) - .wrapping(Wrapping::WordOrGlyph), + .wrapping(text::Wrapping::WordOrGlyph), text!("{}", dt.format("%x %X")).size(10) ] .clip(true) .spacing(6), ) - .on_press_with(move || Message::ImpulseResponseSelected(id)) + .on_press_with(move || { + Message::ImpulseResponse(id, ui::impulse_response::Message::Select) + }) .width(Length::Fill) .style(move |theme: &Theme, status| { let base = button::subtle(theme, status); @@ -1550,16 +1501,20 @@ impl Main { sidebar::item(btn, is_active) }; - let analysis = measurement.analysis()?; - let entry = match analysis.spectrogram_progress() { - ui::spectrogram::Progress::None => entry, - ui::spectrogram::Progress::ComputingImpulseResponse => { - processing_overlay("Impulse Response", entry) - } - ui::spectrogram::Progress::Computing => { - processing_overlay("Spectral Decay", entry) + let spectrogram = &analyses.get(&id).map(|a| &a.spectrogram); + let entry = if let Some(spectrogram) = spectrogram { + match spectrogram.progress() { + ui::spectrogram::Progress::None => entry, + ui::spectrogram::Progress::ComputingImpulseResponse => { + processing_overlay("Impulse Response", entry) + } + ui::spectrogram::Progress::Computing => { + processing_overlay("Spectrogram", entry) + } + ui::spectrogram::Progress::Finished => entry, } - ui::spectrogram::Progress::Finished => entry, + } else { + entry }; Some(entry) @@ -1574,8 +1529,7 @@ impl Main { }; let spectrogram_data = selected - .and_then(|id| self.measurements.get(id)) - .and_then(ui::Measurement::analysis) + .and_then(|id| analyses.get(&id)) .and_then(|analysis| analysis.spectrogram.result()); let content = if let Some(data) = spectrogram_data { @@ -1624,339 +1578,171 @@ impl Main { _ => None, }); - let recording = match &self.state { - State::CollectingMeasuremnts { - recording: Some(recording), - .. - } - | State::Analysing { - active_tab: - Tab::Measurements { - recording: Some(recording), - }, - .. - } => recording.subscription().map(Message::Recording), - _ => Subscription::none(), - }; - - Subscription::batch([hotkeys, recording]) - } - - fn analysing_possible(&self) -> bool { - let is_loopback_loaded = if let State::CollectingMeasuremnts { - loopback: Some(ref loopback), - .. - } - | State::Analysing { ref loopback, .. } = self.state - { - loopback.is_loaded() - } else { - return false; - }; - - is_loopback_loaded && self.measurements.iter().any(ui::Measurement::is_loaded) - } - - fn compute_impulse_response(&mut self, id: ui::measurement::Id) -> Task { - let loopback = if let State::CollectingMeasuremnts { - loopback: Some(ref loopback), - .. - } - | State::Analysing { ref loopback, .. } = self.state - { - loopback - } else { - return Task::none(); - }; - - let measurement = self.measurements.get_mut(id).unwrap(); - - compute_impulse_response(loopback, measurement) - } -} - -fn impulse_response_item<'a>( - selected: Option, - id: ui::measurement::Id, - name: &'a str, - signal: &'a raumklang_core::Measurement, - analysis: &'a ui::measurement::Analysis, -) -> Element<'a, Message> { - let is_active = selected.is_some_and(|selected| selected == id); - - let entry = { - let dt: DateTime = signal.modified.into(); - let ir_btn = button( - column![ - text(name).size(16).wrapping(Wrapping::WordOrGlyph), - text!("{}", dt.format("%x %X")).size(10) - ] - .clip(true) - .spacing(6), - ) - .on_press_with(move || Message::ImpulseResponseSelected(id)) - .width(Length::Fill) - .style(move |theme: &Theme, status| { - let background = theme.extended_palette().background; - let base = button::subtle(theme, status); - - if is_active { - base.with_background(background.weak.color) - } else { - base - } - }); - - let save_btn = button(icon::download().size(10)) - .style(button::secondary) - .on_press_with(move || Message::SaveImpulseResponseFileDialog(id)); - - let content = row![ - ir_btn, - rule::vertical(1.0), - right(save_btn).width(Length::Shrink).padding([0, 6]) - ]; - - sidebar::item(content, is_active) - }; + let recording = self + .recording + .as_ref() + .map(Recording::subscription) + .unwrap_or(Subscription::none()); - match analysis.impulse_response.progress() { - ui::impulse_response::Progress::None => entry, - ui::impulse_response::Progress::Computing => { - impulse_response::processing_overlay("Impulse Response", entry) - } - ui::impulse_response::Progress::Finished => entry, + Subscription::batch([hotkeys, recording.map(Message::Recording)]) } } fn compute_impulse_response( - loopback: &ui::Loopback, - measurement: &mut ui::Measurement, + analyses: &mut BTreeMap, + id: measurement::Id, + loopback: Option<&Loopback>, + measurements: &measurement::List, ) -> Task { - let Some(loopback) = loopback.loaded() else { + let Some(loopback) = loopback.and_then(Loopback::loaded) else { return Task::none(); }; - if let Some(analysis) = &mut measurement.analysis_mut() { - if analysis.impulse_response.result().is_some() { - return Task::none(); - } - - analysis.impulse_response = ui::impulse_response::State::Computing; - }; - - let Some(signal) = measurement.signal() else { + let Some(measurement) = measurements.get(id).and_then(Measurement::signal) else { return Task::none(); }; - Task::perform( - data::impulse_response::compute(loopback.clone(), signal.clone()), - Message::ImpulseResponseComputed.with(measurement.id()), - ) -} - -impl Default for Main { - fn default() -> Self { - Self { - state: State::CollectingMeasuremnts { - recording: None, - loopback: None, - }, - measurements: ui::measurement::List::default(), - project_path: None, - selected: None, - smoothing: frequency_response::Smoothing::default(), - modal: Modal::None, - signal_cache: canvas::Cache::default(), - zoom: chart::Zoom::default(), - offset: chart::Offset::default(), - spectral_decay_config: data::spectral_decay::Config::default(), - spectrogram_config: spectrogram::Preferences::default(), - } - } -} - -async fn choose_impulse_response_file_path() -> Option> { - rfd::AsyncFileDialog::new() - .set_title("Save Impulse Response ...") - .add_filter("wav", &["wav", "wave"]) - .add_filter("all", &["*"]) - .save_file() - .await - .as_ref() - .map(|h| h.path().to_path_buf().into()) -} - -async fn save_impulse_response(path: Arc, impulse_response: ui::ImpulseResponse) { - tokio::task::spawn_blocking(move || { - let spec = hound::WavSpec { - channels: 1, - sample_rate: impulse_response.sample_rate.into(), - bits_per_sample: 32, - sample_format: hound::SampleFormat::Float, - }; - - let mut writer = hound::WavWriter::create(path, spec).unwrap(); - for s in impulse_response.data.iter().copied() { - writer.write_sample(s).unwrap(); - } - writer.finalize().unwrap(); - }) - .await - .unwrap(); + let analysis = analyses.entry(id).or_default(); + + analysis + .impulse_response + .clone() + .compute(loopback, measurement) + .map(|sipper| { + Task::sip( + sipper, + Message::ImpulseResponseComputed.with(id), + Message::ImpulseResponseComputed.with(id), + ) + }) + .unwrap_or_default() } fn compute_frequency_response( - loopback: &ui::Loopback, - measurement: &mut ui::Measurement, - window: &Window, + analyses: &mut BTreeMap, + id: measurement::Id, + loopback: Option<&Loopback>, + measurements: &measurement::List, + window: data::Window, ) -> Task { - let id = measurement.id(); + let analysis = analyses.entry(id).or_default(); - let Some(analysis) = measurement.analysis_mut() else { + if analysis.frequency_response.result().is_some() { return Task::none(); - }; - - if let Some(impulse_response) = analysis.impulse_response.result() { - analysis.frequency_response.progress = ui::frequency_response::Progress::Computing; + } + if let Some(ir) = analysis.impulse_response.result() { + // TODO move into analysis itself + analysis.frequency_response.state = ui::frequency_response::State::Computing; Task::perform( - data::frequency_response::compute(impulse_response.origin.clone(), window.clone()), + data::frequency_response::compute(ir.data.clone(), window), Message::FrequencyResponseComputed.with(id), ) } else { - compute_impulse_response(loopback, measurement) + analysis.frequency_response.state = + ui::frequency_response::State::WaitingForImpulseResponse; + compute_impulse_response(analyses, id, loopback, measurements) } } fn compute_spectral_decay( - loopback: &ui::Loopback, - measurement: &mut ui::Measurement, + id: measurement::Id, + analyses: &mut BTreeMap, config: data::spectral_decay::Config, + loopback: Option<&Loopback>, + measurements: &measurement::List, ) -> Task { - let Some(analysis) = measurement.analysis_mut() else { - return Task::none(); - }; - - if analysis.spectral_decay.result().is_some() { - return Task::none(); - } + let analysis = analyses.entry(id).or_default(); - if let Some(impulse_response) = analysis.impulse_response.result() { - analysis.spectral_decay = ui::spectral_decay::State::Computing; - - Task::perform( - data::spectral_decay::compute(impulse_response.origin.clone(), config), - Message::SpectralDecayComputed.with(measurement.id()), - ) + if let Some(computation) = analysis + .spectral_decay + .compute(&analysis.impulse_response, config) + { + Task::perform(computation, Message::SpectralDecayComputed.with(id)) } else { - compute_impulse_response(loopback, measurement) + compute_impulse_response(analyses, id, loopback, measurements) } } fn compute_spectrogram( - loopback: &ui::Loopback, - measurement: &mut ui::Measurement, + id: measurement::Id, + analyses: &mut BTreeMap, config: &spectrogram::Preferences, + loopback: Option<&ui::Loopback>, + measurements: &measurement::List, ) -> Task { - let Some(analysis) = measurement.analysis_mut() else { - return Task::none(); - }; - - if analysis.spectrogram.result().is_some() { - return Task::none(); - } + let analysis = analyses.entry(id).or_default(); - if let Some(impulse_response) = analysis.impulse_response.result() { - analysis.spectrogram = ui::spectrogram::State::Computing; - - Task::perform( - data::spectrogram::compute(impulse_response.origin.clone(), *config), - Message::SpectrogramComputed.with(measurement.id()), - ) + if let Some(computation) = analysis + .spectrogram + .compute(&analysis.impulse_response, config) + { + Task::perform(computation, Message::SpectrogramComputed.with(id)) } else { - compute_impulse_response(loopback, measurement) + compute_impulse_response(analyses, id, loopback, measurements) } } -impl TabId { - pub fn iter() -> impl Iterator { - [ - TabId::Measurements, - TabId::ImpulseResponses, - TabId::FrequencyResponses, - TabId::SpectralDecay, - TabId::Spectrogram, - ] - .into_iter() - } +impl Default for Main { + fn default() -> Self { + Self { + state: State::default(), + modal: Modal::None, + recording: None, + selected: None, - pub fn view<'a>(self, is_analysing: bool) -> Element<'a, Message> { - let mut row = row![]; + loopback: None, + measurements: measurement::List::default(), - for tab in TabId::iter() { - let is_active = self == tab; + project_path: None, - let is_enabled = match tab { - TabId::Measurements => true, - TabId::ImpulseResponses - | TabId::FrequencyResponses - | TabId::SpectralDecay - | TabId::Spectrogram => is_analysing, - }; + spectral_decay_config: data::spectral_decay::Config::default(), - let button = button(text(tab.to_string()).size(16)) - .padding(10) - .style(move |theme: &Theme, status| { - if is_active { - let palette = theme.extended_palette(); + zoom: chart::Zoom::default(), + offset: chart::Offset::default(), + smoothing: frequency_response::Smoothing::default(), + window: None, - button::Style { - background: Some(palette.background.base.color.into()), - text_color: palette.background.base.text, - ..button::text(theme, status) - } - } else { - button::text(theme, status) - } - }) - .on_press_maybe(if is_enabled { - Some(Message::TabSelected(tab)) - } else { - None - }); + signal_cache: canvas::Cache::default(), - row = row.push(button); + ir_chart: impulse_response::Chart::default(), + spectrogram: Spectrogram::default(), + spectrogram_config: spectrogram::Preferences::default(), } - - row.spacing(5).align_y(Alignment::Center).into() } } -impl Display for TabId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let label = match self { - TabId::Measurements => "Measurements", - TabId::ImpulseResponses => "Impulse Responses", - TabId::FrequencyResponses => "Frequency Responses", - TabId::SpectralDecay => "Spectral Decay", - TabId::Spectrogram => "Spectorgram", - }; - - write!(f, "{}", label) - } +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() + .await + .as_ref() + .map(|h| h.path().into()) } -impl From<&Tab> for TabId { - fn from(tab: &Tab) -> Self { - match tab { - Tab::Measurements { .. } => TabId::Measurements, - Tab::ImpulseResponses { .. } => TabId::ImpulseResponses, - Tab::FrequencyResponses => TabId::FrequencyResponses, - Tab::SpectralDecay => TabId::SpectralDecay, - Tab::Spectrogram => TabId::Spectrogram, +// TODO: error handling +async fn save_impulse_response(path: Arc, ir: ui::ImpulseResponse) { + tokio::task::spawn_blocking(move || { + let spec = hound::WavSpec { + channels: 1, + sample_rate: ir.sample_rate.into(), + bits_per_sample: 32, + sample_format: hound::SampleFormat::Float, + }; + + let mut writer = hound::WavWriter::create(path, spec).unwrap(); + for s in ir.normalized { + writer.write_sample(s).unwrap(); } - } + writer.finalize().unwrap(); + }) + .await + .unwrap(); } fn modal<'a, Message>( @@ -2029,7 +1815,7 @@ where pub fn view(self) -> Element<'a, Message> { let header = row![sidebar::header(self.title),] .padding(padding::right(6)) - .extend(self.buttons.into_iter().map(|btn| btn.width(30).into())) + .extend(self.buttons.into_iter().map(|btn| btn.into())) .spacing(6) .align_y(Alignment::Center); @@ -2050,20 +1836,33 @@ where } } -#[derive(Debug, Clone, thiserror::Error)] -pub enum PickAndSaveError { - #[error("dialog closed")] - DialogClosed, - #[error(transparent)] - File(#[from] project::Error), +pub async fn pick_measurement_file(title: impl AsRef) -> Option { + let handle = rfd::AsyncFileDialog::new() + .set_title(title.as_ref()) + .add_filter("wav", &["wav", "wave"]) + .add_filter("all", &["*"]) + .pick_file() + .await; + + handle.as_ref().map(FileHandle::path).map(Path::to_path_buf) +} + +async fn pick_project_file_to_load() -> Option { + let handle = rfd::AsyncFileDialog::new() + .set_title("Load project...") + .add_filter("json", &["json"]) + .add_filter("all", &["*"]) + .pick_file() + .await?; + + Some(handle.path().to_path_buf()) } -async fn pick_project_file() -> Result { +async fn pick_project_file_to_save() -> Option { let handle = rfd::AsyncFileDialog::new() .set_title("Save project file ...") .save_file() - .await - .ok_or(PickAndSaveError::DialogClosed)?; + .await?; - Ok(handle.path().to_path_buf()) + Some(handle.path().to_path_buf()) } diff --git a/raumklang-gui/src/screen/main/chart.rs b/raumklang-gui/src/screen/main/chart.rs index 6cba9e9..eaa4a6e 100644 --- a/raumklang-gui/src/screen/main/chart.rs +++ b/raumklang-gui/src/screen/main/chart.rs @@ -4,12 +4,15 @@ pub mod waveform; use waveform::Waveform; use crate::{ - data::{self, chart, window::Handles, Samples, Window}, + data::{self, Samples, Window, chart, window::Handles}, screen::main::chart::spectrogram::Spectrogram, ui, }; use iced::{ + Element, Event, Font, + Length::Fill, + Pixels, Point, Rectangle, Renderer, Size, Theme, Vector, advanced::{ graphics::text::Paragraph, text::{self, Paragraph as _}, @@ -21,9 +24,7 @@ use iced::{ container, text::{Fragment, IntoFragment}, }, - window, Element, Event, Font, - Length::Fill, - Pixels, Point, Rectangle, Renderer, Size, Theme, Vector, + window, }; use std::{ @@ -102,7 +103,7 @@ pub fn impulse_response<'a>( canvas::Canvas::new(BarChart { window, datapoints: impulse_response - .data + .normalized .iter() .copied() .map(f32::abs) @@ -318,8 +319,8 @@ where x_axis, y_axis, plane, - ref mut hovered_handle, - ref mut dragging, + hovered_handle, + dragging, .. } = state else { @@ -411,7 +412,7 @@ where let State::Initialized { hovered_handle, plane, - ref mut dragging, + dragging, .. } = state else { @@ -435,8 +436,8 @@ where let State::Initialized { x_axis, plane, - ref mut hovered_handle, - ref mut dragging, + hovered_handle, + dragging, .. } = state else { diff --git a/raumklang-gui/src/screen/main/chart/impulse_response.rs b/raumklang-gui/src/screen/main/chart/impulse_response.rs deleted file mode 100644 index e69de29..0000000 diff --git a/raumklang-gui/src/screen/main/chart/spectrogram.rs b/raumklang-gui/src/screen/main/chart/spectrogram.rs index 37e9eba..8f13ae9 100644 --- a/raumklang-gui/src/screen/main/chart/spectrogram.rs +++ b/raumklang-gui/src/screen/main/chart/spectrogram.rs @@ -8,9 +8,9 @@ use crate::{ use super::{HorizontalAxis, Offset, VerticalAxis, Zoom}; use iced::{ + Event, Point, Rectangle, Renderer, Size, Vector, mouse::{self, ScrollDelta}, widget::canvas::{self}, - Event, Point, Rectangle, Renderer, Size, Vector, }; use raumklang_core::dbfs; diff --git a/raumklang-gui/src/screen/main/chart/waveform.rs b/raumklang-gui/src/screen/main/chart/waveform.rs index 36928f6..d3dd881 100644 --- a/raumklang-gui/src/screen/main/chart/waveform.rs +++ b/raumklang-gui/src/screen/main/chart/waveform.rs @@ -1,9 +1,9 @@ use super::{HorizontalAxis, Offset, VerticalAxis, Zoom}; use iced::{ + Event, Point, Rectangle, Renderer, Size, Vector, mouse::{self, ScrollDelta}, widget::canvas::{self}, - Event, Point, Rectangle, Renderer, Size, Vector, }; use std::cmp::Ordering; diff --git a/raumklang-gui/src/screen/main/frequency_response.rs b/raumklang-gui/src/screen/main/frequency_response.rs index 1770206..7750ef5 100644 --- a/raumklang-gui/src/screen/main/frequency_response.rs +++ b/raumklang-gui/src/screen/main/frequency_response.rs @@ -1,20 +1,6 @@ -use std::{ - fmt::{self}, - ops::RangeInclusive, -}; +use std::fmt::{self}; -use crate::{ - data, - ui::{self}, -}; - -use iced::widget::canvas; - -#[derive(Debug, Default)] -pub struct ChartData { - pub x_range: Option>, - pub cache: canvas::Cache, -} +use crate::data; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum Smoothing { @@ -75,16 +61,13 @@ impl fmt::Display for Smoothing { } pub async fn smooth_frequency_response( - id: ui::measurement::Id, frequency_response: data::FrequencyResponse, fraction: u8, -) -> (ui::measurement::Id, Box<[f32]>) { - let data = tokio::task::spawn_blocking(move || { +) -> Box<[f32]> { + tokio::task::spawn_blocking(move || { data::smooth_fractional_octave(&frequency_response.data.clone(), fraction) }) .await .unwrap() - .into_boxed_slice(); - - (id, data) + .into_boxed_slice() } diff --git a/raumklang-gui/src/screen/main/impulse_response.rs b/raumklang-gui/src/screen/main/impulse_response.rs index 04503c0..9621566 100644 --- a/raumklang-gui/src/screen/main/impulse_response.rs +++ b/raumklang-gui/src/screen/main/impulse_response.rs @@ -1,22 +1,15 @@ use super::chart; use crate::{ - data::{self}, + data::{self, Window}, ui::ImpulseResponse, }; use iced::{ - widget::{canvas, column, container, pick_list, row, stack, text}, - Alignment, Color, Element, Length, + Alignment, Element, Length, + widget::{canvas, column, container, pick_list, row}, }; -use std::ops::RangeInclusive; - -#[derive(Debug, Clone)] -pub enum Message { - Chart(ChartOperation), -} - #[derive(Debug, Clone)] pub enum ChartOperation { TimeUnitChanged(data::chart::TimeSeriesUnit), @@ -26,7 +19,6 @@ pub enum ChartOperation { #[derive(Debug, Default)] pub struct Chart { - pub x_range: Option>, shift_key_pressed: bool, pub amplitude_unit: data::chart::AmplitudeUnit, pub time_unit: data::chart::TimeSeriesUnit, @@ -56,20 +48,20 @@ impl Chart { pub(crate) fn view<'a>( &'a self, impulse_response: &'a ImpulseResponse, - window_settings: &'a WindowSettings, - ) -> Element<'a, Message> { + window: &'a Window, + ) -> Element<'a, ChartOperation> { let header = { pick_list( &data::chart::AmplitudeUnit::ALL[..], Some(&self.amplitude_unit), - |unit| Message::Chart(ChartOperation::AmplitudeUnitChanged(unit)), + ChartOperation::AmplitudeUnitChanged, ) }; let chart = { container( chart::impulse_response( - &window_settings.window, + window, impulse_response, &self.time_unit, &self.amplitude_unit, @@ -78,19 +70,20 @@ impl Chart { &self.data_cache, &self.overlay_cache, ) - .map(ChartOperation::Interaction) - .map(Message::Chart), + .map(ChartOperation::Interaction), ) .style(container::rounded_box) }; let footer = { - row![container(pick_list( - &data::chart::TimeSeriesUnit::ALL[..], - Some(&self.time_unit), - |unit| Message::Chart(ChartOperation::TimeUnitChanged(unit)) - )) - .align_right(Length::Fill)] + row![ + container(pick_list( + &data::chart::TimeSeriesUnit::ALL[..], + Some(&self.time_unit), + ChartOperation::TimeUnitChanged + )) + .align_right(Length::Fill) + ] .align_y(Alignment::Center) }; @@ -105,36 +98,3 @@ impl Chart { self.shift_key_pressed = true } } - -pub struct WindowSettings { - pub window: data::Window, -} - -impl WindowSettings { - pub(crate) fn new(window: data::Window) -> Self { - Self { window } - } -} - -pub fn processing_overlay<'a, Message>( - status: &'a str, - entry: impl Into>, -) -> Element<'a, Message> -where - Message: 'a, -{ - stack([ - container(entry).style(container::bordered_box).into(), - container(column![text("Computing..."), text(status).size(12)]) - .center(Length::Fill) - .style(|theme| container::Style { - border: container::rounded_box(theme).border, - background: Some(iced::Background::Color(Color::from_rgba( - 0.0, 0.0, 0.0, 0.8, - ))), - ..Default::default() - }) - .into(), - ]) - .into() -} diff --git a/raumklang-gui/src/screen/main/measurement.rs b/raumklang-gui/src/screen/main/measurement.rs deleted file mode 100644 index f087a04..0000000 --- a/raumklang-gui/src/screen/main/measurement.rs +++ /dev/null @@ -1,212 +0,0 @@ -use crate::{ - icon, - ui::{self, measurement, Loopback}, - widget::sidebar, -}; - -use chrono::{DateTime, Utc}; -use iced::{ - widget::{button, column, right, row, rule, text, tooltip}, - Alignment::Center, - Element, - Length::{self, Fill}, -}; -use raumklang_core::WavLoadError; -use rfd::FileHandle; - -use std::{fmt::Display, path::Path, sync::Arc}; - -#[derive(Debug, Clone)] -pub enum Message { - Select(Selected), - Load(Kind), - Loaded(Result, Arc>), - Remove(measurement::Id), -} - -#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -pub enum Selected { - Loopback, - Measurement(measurement::Id), -} - -#[derive(Debug, Clone, Copy)] -pub enum Kind { - Loopback, - Normal, -} - -impl Display for Kind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let text = match self { - Kind::Loopback => "Loopback", - Kind::Normal => "Measurement", - }; - - write!(f, "{}", text) - } -} - -#[allow(clippy::large_enum_variant)] -#[derive(Debug)] -pub enum LoadedKind { - Loopback(ui::Loopback), - Normal(ui::Measurement), -} - -pub fn loopback_entry<'a>( - selected: Option, - loopback: &'a Loopback, -) -> Element<'a, Message> { - let is_active = selected.is_some_and(|s| matches!(s, Selected::Loopback)); - - let info: Element<_> = match &loopback.state { - measurement::loopback::State::Loaded(loopback) => { - let dt: DateTime = loopback.as_ref().modified.into(); - column![ - text("Last modified:").size(10), - text!("{}", dt.format("%x %X")).size(10) - ] - .into() - } - measurement::loopback::State::NotLoaded(err) => tooltip( - text("Offline").style(text::danger), - text!("{err}").style(text::danger), - tooltip::Position::default(), - ) - .into(), - }; - - let measurement_btn = button(column![text(&loopback.name).size(16)].push(info).spacing(5)) - .on_press_maybe( - loopback - .loaded() - .map(|_| Message::Select(Selected::Loopback)), - ) - .style(move |theme, status| { - let background = theme.extended_palette().background; - let base = button::subtle(theme, status); - - if is_active { - base.with_background(background.weak.color) - } else { - base - } - }) - .width(Fill); - - let delete_btn = button(icon::delete()) - // .on_press(Message::RemoveLoopback) - .width(30) - .height(30) - .style(button::danger); - - let content = row![ - measurement_btn, - rule::vertical(1.0), - right(delete_btn).width(Length::Shrink).padding([0, 6]) - ]; - - sidebar::item(content, is_active) -} - -pub fn list_entry<'a>( - selected: Option, - measurement: &'a ui::Measurement, -) -> Element<'a, Message> { - let id = measurement.id(); - - let is_active = selected.is_some_and(|s| s == Selected::Measurement(id)); - - let info: Element<_> = match &measurement.signal() { - Some(signal) => { - let dt: DateTime = signal.modified.into(); - column![ - text("Last modified:").size(10), - text!("{}", dt.format("%x %X")).size(10) - ] - .into() - } - None => text("Offline").style(text::danger).into(), - }; - - let measurement_btn = button( - column![ - text(&measurement.name).wrapping(text::Wrapping::WordOrGlyph), - info - ] - .spacing(5), - ) - .on_press_maybe(if measurement.is_loaded() { - Some(Message::Select(Selected::Measurement(id))) - } else { - None - }) - .style(move |theme, status| { - let background = theme.extended_palette().background; - let base = button::subtle(theme, status); - - if is_active { - base.with_background(background.weak.color) - } else { - base - } - }) - .width(Fill) - .clip(true); - - let delete_btn = button(icon::delete().align_x(Center).align_y(Center)) - .on_press_with(move || Message::Remove(id)) - .width(30) - .height(30) - .style(button::danger); - - let content = row![ - measurement_btn, - rule::vertical(1.0), - right(delete_btn).width(Length::Shrink).padding([0, 6]) - ]; - - sidebar::item(content, is_active) -} - -pub async fn load_measurement( - path: impl AsRef, - kind: Kind, -) -> Result, Arc> { - match kind { - Kind::Loopback => Ok(LoadedKind::Loopback( - ui::measurement::Loopback::from_file(path).await, - )), - Kind::Normal => Ok(LoadedKind::Normal(ui::Measurement::from_file(path).await)), - } - .map(Arc::new) - .map_err(Arc::new) -} - -pub async fn pick_file_and_load_signal( - file_type: impl AsRef, - kind: Kind, -) -> Result, Arc> { - let handle = pick_file(file_type).await.unwrap(); - - load_measurement(handle.path(), kind).await -} - -pub async fn pick_file(file_type: impl AsRef) -> Result { - rfd::AsyncFileDialog::new() - .set_title(format!("Choose {} file", file_type.as_ref())) - .add_filter("wav", &["wav", "wave"]) - .add_filter("all", &["*"]) - .pick_file() - .await - .ok_or(Error::DialogClosed) -} - -#[derive(Debug, Clone, thiserror::Error)] -pub enum Error { - // #[error("error while loading file: {0}")] - // File(PathBuf, Arc), - #[error("dialog closed")] - DialogClosed, -} diff --git a/raumklang-gui/src/screen/main/modal.rs b/raumklang-gui/src/screen/main/modal.rs new file mode 100644 index 0000000..040ef12 --- /dev/null +++ b/raumklang-gui/src/screen/main/modal.rs @@ -0,0 +1,20 @@ +pub mod pending_window; +pub mod spectral_decay_config; +pub mod spectrogram_config; + +pub use pending_window::pending_window; +pub use spectral_decay_config::SpectralDecayConfig; +pub use spectrogram_config::SpectrogramConfig; + +use crate::screen::main::tab; + +#[derive(Default, Debug)] +pub enum Modal { + #[default] + None, + PendingWindow { + goto_tab: tab::Id, + }, + SpectralDecayConfig(SpectralDecayConfig), + SpectrogramConfig(SpectrogramConfig), +} diff --git a/raumklang-gui/src/screen/main/modal/pending_window.rs b/raumklang-gui/src/screen/main/modal/pending_window.rs new file mode 100644 index 0000000..2f0ed63 --- /dev/null +++ b/raumklang-gui/src/screen/main/modal/pending_window.rs @@ -0,0 +1,38 @@ +use iced::{ + Element, + widget::{button, column, container, row, space, text}, +}; + +#[derive(Debug, Clone)] +pub enum Message { + Discard, + Apply, +} + +pub fn pending_window() -> Element<'static, Message> { + container( + column![ + text("Window pending!").size(18), + column![ + text("You have modified the window used for frequency response computations."), + text("You need to discard or apply your changes before proceeding."), + ] + .spacing(5), + row![ + space::horizontal(), + button("Discard") + .style(button::danger) + .on_press(Message::Discard), + button("Apply") + .style(button::success) + .on_press(Message::Apply) + ] + .spacing(5) + ] + .spacing(10), + ) + .padding(20) + .width(400) + .style(container::bordered_box) + .into() +} diff --git a/raumklang-gui/src/screen/main/spectral_decay_config.rs b/raumklang-gui/src/screen/main/modal/spectral_decay_config.rs similarity index 89% rename from raumklang-gui/src/screen/main/spectral_decay_config.rs rename to raumklang-gui/src/screen/main/modal/spectral_decay_config.rs index c9e6652..8dc3e82 100644 --- a/raumklang-gui/src/screen/main/spectral_decay_config.rs +++ b/raumklang-gui/src/screen/main/modal/spectral_decay_config.rs @@ -5,13 +5,13 @@ use crate::{ }; use iced::{ - widget::{button, column, container, row, rule, scrollable, space, text, tooltip}, Alignment::Center, Element, + widget::{button, column, container, row, rule, scrollable, space, text, tooltip}, }; #[derive(Debug, Clone)] -pub(crate) enum Message { +pub enum Message { Discard, ResetToDefault, ResetToPrevious, @@ -21,13 +21,14 @@ pub(crate) enum Message { Apply(spectral_decay::Config), } -pub(crate) enum Action { - Apply(spectral_decay::Config), +pub enum Action { + None, Discard, + Apply(spectral_decay::Config), } #[derive(Debug)] -pub(crate) struct SpectralDecayConfig { +pub struct SpectralDecayConfig { shift: String, left_window_width: String, right_window_width: String, @@ -35,7 +36,7 @@ pub(crate) struct SpectralDecayConfig { } impl SpectralDecayConfig { - pub(crate) fn new(config: spectral_decay::Config) -> Self { + pub fn new(config: spectral_decay::Config) -> Self { Self { shift: config.shift.as_millis().to_string(), left_window_width: config.left_window_width.as_millis().to_string(), @@ -44,44 +45,44 @@ impl SpectralDecayConfig { } } - pub(crate) fn reset_to_default(&mut self) { + pub fn reset_to_default(&mut self) { self.reset_to_config(spectral_decay::Config::default()); } - pub(crate) fn reset_to_config(&mut self, config: spectral_decay::Config) { + pub fn reset_to_config(&mut self, config: spectral_decay::Config) { self.shift = config.shift.as_millis().to_string(); self.left_window_width = config.left_window_width.as_millis().to_string(); self.right_window_width = config.right_window_width.as_millis().to_string(); } - pub(crate) fn update(&mut self, message: Message) -> Option { + pub fn update(&mut self, message: Message) -> Action { match message { - Message::Apply(config) => Some(Action::Apply(config)), - Message::Discard => Some(Action::Discard), + Message::Apply(config) => Action::Apply(config), + Message::Discard => Action::Discard, Message::ShiftChanged(shift) => { self.shift = shift; - None + Action::None } Message::LeftWidthChanged(left_width) => { self.left_window_width = left_width; - None + Action::None } Message::RightWidthChanged(right_width) => { self.right_window_width = right_width; - None + Action::None } Message::ResetToDefault => { self.reset_to_default(); - None + Action::None } Message::ResetToPrevious => { self.reset_to_config(self.prev_config); - None + Action::None } } } - pub(crate) fn view(&self) -> Element<'_, Message> { + pub fn view(&self) -> Element<'_, Message> { let shift = Shift::from_millis_string(&self.shift); let left_window_width = WindowWidth::from_millis_string(&self.left_window_width); let right_window_width = WindowWidth::from_millis_string(&self.right_window_width); diff --git a/raumklang-gui/src/screen/main/spectrogram_config.rs b/raumklang-gui/src/screen/main/modal/spectrogram_config.rs similarity index 93% rename from raumklang-gui/src/screen/main/spectrogram_config.rs rename to raumklang-gui/src/screen/main/modal/spectrogram_config.rs index bddac71..f580d5e 100644 --- a/raumklang-gui/src/screen/main/spectrogram_config.rs +++ b/raumklang-gui/src/screen/main/modal/spectrogram_config.rs @@ -3,13 +3,13 @@ use std::time::Duration; use crate::{data::spectrogram, icon, widget::number_input}; use iced::{ - widget::{button, column, container, row, rule, scrollable, space, text, tooltip}, Alignment::Center, Element, + widget::{button, column, container, row, rule, scrollable, space, text, tooltip}, }; #[derive(Debug, Clone)] -pub(crate) enum Message { +pub enum Message { Close, ResetToDefault, ResetToPrevious, @@ -19,14 +19,14 @@ pub(crate) enum Message { Apply(spectrogram::Preferences), } -pub(crate) enum Action { +pub enum Action { None, Close, ConfigChanged(spectrogram::Preferences), } #[derive(Debug, Clone)] -pub(crate) struct SpectrogramConfig { +pub struct SpectrogramConfig { window_width: String, span_before_peak: String, span_after_peak: String, @@ -34,7 +34,7 @@ pub(crate) struct SpectrogramConfig { } impl SpectrogramConfig { - pub(crate) fn new(config: spectrogram::Preferences) -> Self { + pub fn new(config: spectrogram::Preferences) -> Self { Self { window_width: config.window_width.as_millis().to_string(), span_before_peak: config.span_before_peak.as_millis().to_string(), @@ -44,7 +44,7 @@ impl SpectrogramConfig { } #[must_use] - pub(crate) fn update(&mut self, message: Message) -> Action { + pub fn update(&mut self, message: Message) -> Action { match message { Message::Close => Action::Close, Message::WindowWidthChanged(width) => { @@ -65,13 +65,13 @@ impl SpectrogramConfig { Action::None } Message::ResetToPrevious => { - self.reset_to_config(self.prev_config); + self.reset_to_config(self.prev_config.clone()); Action::None } } } - pub(crate) fn view(&self) -> Element<'_, Message> { + pub fn view(&self) -> Element<'_, Message> { let window_width = self.window_width.parse().map(Duration::from_millis); let span_before_peak = self.span_before_peak.parse().map(Duration::from_millis); let span_after_peak = self.span_after_peak.parse().map(Duration::from_millis); @@ -151,7 +151,7 @@ impl SpectrogramConfig { space::horizontal(), tooltip( button(icon::reset().center()) - .on_press_maybe(config.map(|_| Message::ResetToPrevious)) + .on_press_maybe(config.is_some().then_some(Message::ResetToPrevious)) .style(button::secondary), "Reset to defaults.", tooltip::Position::default() diff --git a/raumklang-gui/src/screen/main/recording.rs b/raumklang-gui/src/screen/main/recording.rs index a00a790..32c138d 100644 --- a/raumklang-gui/src/screen/main/recording.rs +++ b/raumklang-gui/src/screen/main/recording.rs @@ -9,18 +9,18 @@ use crate::{ measurement::config, recording::{self, volume}, }, - screen::main::recording::page::{measurement, Component}, - widget::{meter, RmsPeakMeter}, + screen::main::recording::page::{Component, measurement}, + widget::{RmsPeakMeter, meter}, }; use iced::{ - alignment::{Horizontal, Vertical}, - time, - widget::{self, canvas, column, container, pick_list, row, rule, slider, text, text_input}, Alignment::Center, Element, Length::{Fill, Shrink}, Subscription, Task, + alignment::{Horizontal, Vertical}, + time, + widget::{self, canvas, column, container, pick_list, row, rule, slider, text, text_input}, }; use tokio_stream::wrappers::ReceiverStream; @@ -571,15 +571,17 @@ where column![] .push(self.label.map(text)) .push( - row![text_input("", self.value) - .id(widget::Id::new("from")) - .align_x(Horizontal::Right) - .on_input_maybe(self.on_input) - .style(if self.is_valid { - text_input::default - } else { - number_input_danger - })] + row![ + text_input("", self.value) + .id(widget::Id::new("from")) + .align_x(Horizontal::Right) + .on_input_maybe(self.on_input) + .style(if self.is_valid { + text_input::default + } else { + number_input_danger + }) + ] .push(self.unit.map(text)) .align_y(Vertical::Center) .spacing(3), diff --git a/raumklang-gui/src/screen/main/recording/page/component.rs b/raumklang-gui/src/screen/main/recording/page/component.rs index 39c3049..c5a0ac0 100644 --- a/raumklang-gui/src/screen/main/recording/page/component.rs +++ b/raumklang-gui/src/screen/main/recording/page/component.rs @@ -1,13 +1,12 @@ use crate::data; use iced::{ + Element, Length, alignment::Vertical, widget::{ - button, column, container, row, rule, space, text, + Button, button, column, container, row, rule, space, text, text::{Fragment, IntoFragment}, - Button, }, - Element, Length, }; pub struct Page<'a, Message> { diff --git a/raumklang-gui/src/screen/main/recording/page/measurement.rs b/raumklang-gui/src/screen/main/recording/page/measurement.rs index 898d7a3..044a300 100644 --- a/raumklang-gui/src/screen/main/recording/page/measurement.rs +++ b/raumklang-gui/src/screen/main/recording/page/measurement.rs @@ -7,11 +7,11 @@ use crate::{ }; use iced::{ + Element, Length, Task, alignment::{Horizontal, Vertical}, widget::{canvas, column, container, row, rule, text}, - Element, Length, Task, }; -use prism::{line_series, Chart}; +use prism::{Chart, line_series}; use tokio_stream::wrappers::ReceiverStream; #[derive(Debug)] diff --git a/raumklang-gui/src/screen/main/tab.rs b/raumklang-gui/src/screen/main/tab.rs index fcef599..500a1aa 100644 --- a/raumklang-gui/src/screen/main/tab.rs +++ b/raumklang-gui/src/screen/main/tab.rs @@ -1,19 +1,28 @@ -pub mod frequency_responses; -pub mod impulse_responses; -pub mod measurements; +use iced::widget::canvas; -pub use frequency_responses::FrequencyResponses; -pub use impulse_responses::ImpulseReponses; -pub use measurements::Measurements; +use crate::data::Window; +#[derive(Default)] pub enum Tab { - Measurements(Measurements), - ImpulseResponses, - FrequencyResponses, + #[default] + Measurements, + ImpulseResponses { + pending_window: Window, + }, + FrequencyResponses { + cache: canvas::Cache, + }, + SpectralDecays { + cache: canvas::Cache, + }, + Spectrograms, } -impl Default for Tab { - fn default() -> Self { - Self::Measurements(Measurements::new()) - } +#[derive(Debug, Clone, Copy)] +pub enum Id { + Measurements, + ImpulseResponses, + FrequencyResponses, + SpectralDecays, + Spectrograms, } diff --git a/raumklang-gui/src/ui.rs b/raumklang-gui/src/ui.rs index 28002b1..2a4f9b2 100644 --- a/raumklang-gui/src/ui.rs +++ b/raumklang-gui/src/ui.rs @@ -1,9 +1,12 @@ +pub mod analysis; pub mod frequency_response; pub mod impulse_response; pub mod measurement; pub mod spectral_decay; pub mod spectrogram; +pub use analysis::Analysis; pub use frequency_response::FrequencyResponse; pub use impulse_response::ImpulseResponse; pub use measurement::{Loopback, Measurement}; +pub use spectral_decay::SpectralDecay; diff --git a/raumklang-gui/src/ui/analysis.rs b/raumklang-gui/src/ui/analysis.rs new file mode 100644 index 0000000..0c1adc1 --- /dev/null +++ b/raumklang-gui/src/ui/analysis.rs @@ -0,0 +1,22 @@ +use crate::ui::{ + FrequencyResponse, ImpulseResponse, impulse_response, spectral_decay::SpectralDecay, + spectrogram::Spectrogram, +}; + +#[derive(Debug, Clone, Default)] +pub struct Analysis { + pub impulse_response: impulse_response::State, + pub frequency_response: FrequencyResponse, + pub spectral_decay: SpectralDecay, + pub spectrogram: Spectrogram, +} + +impl Analysis { + pub(crate) fn impulse_response(&self) -> Option<&ImpulseResponse> { + self.impulse_response.result() + } + + pub(crate) fn frequency_response_mut(&mut self) -> &mut FrequencyResponse { + &mut self.frequency_response + } +} diff --git a/raumklang-gui/src/ui/frequency_response.rs b/raumklang-gui/src/ui/frequency_response.rs index 80e10b8..3f404c6 100644 --- a/raumklang-gui/src/ui/frequency_response.rs +++ b/raumklang-gui/src/ui/frequency_response.rs @@ -1,14 +1,12 @@ -use std::fmt::{self, Display}; - -use crate::ui::impulse_response; +use crate::widget::sidebar; use crate::{data, icon}; +use iced::Alignment; use iced::widget::stack; use iced::widget::text::IntoFragment; -use iced::Alignment; use iced::{ - widget::{column, container, row, text, toggler}, Element, Length, + widget::{column, container, row, text, toggler}, }; use rand::Rng as _; @@ -17,9 +15,18 @@ use rand::Rng as _; pub struct FrequencyResponse { pub color: iced::Color, pub is_shown: bool, - pub progress: Progress, - pub data: Option, + pub smoothed: Option>, + + pub state: State, +} + +#[derive(Debug, Clone)] +pub enum State { + None, + WaitingForImpulseResponse, + Computing, + Computed(data::FrequencyResponse), } impl FrequencyResponse { @@ -29,67 +36,70 @@ impl FrequencyResponse { Self { color, is_shown: true, - progress: Progress::None, - data: None, smoothed: None, - } - } - pub fn computed(&mut self, data: data::FrequencyResponse) { - self.data = Some(data); + state: State::None, + } } pub fn view<'a, Message>( &'a self, measurement_name: &'a str, - impulse_response_progess: impulse_response::Progress, on_toggle: impl Fn(bool) -> Message + 'a, ) -> Element<'a, Message> where - Message: 'a, + Message: Clone + 'a, { - let item = row![ - icon::record().color(self.color).align_y(Alignment::Center), - container( + let item = { + let color_dot = icon::record().color(self.color).align_y(Alignment::Center); + + let content = container( text(measurement_name) .size(16) .style(|theme| { let mut base = text::default(theme); - let text_color = theme.extended_palette().background.weakest.text; - base.color = Some(text_color); + let palette = theme.extended_palette(); + base.color = Some(palette.background.weakest.text); base }) - .align_y(Alignment::Center) - .wrapping(text::Wrapping::Glyph), + .wrapping(text::Wrapping::Glyph) + .align_y(Alignment::Center), ) .width(Length::Fill) - .clip(true), - container(toggler(self.is_shown).on_toggle(on_toggle)).align_right(Length::Shrink) - ] - .align_y(Alignment::Center) - .spacing(10) - .padding(6) - .into(); - - if self.data.is_some() { - item - } else { - match impulse_response_progess { - impulse_response::Progress::None => item, - impulse_response::Progress::Computing => { - processing_overlay("Impulse Response", item) - } - impulse_response::Progress::Finished => { - processing_overlay(self.progress.to_string(), item) - } - } - } + .clip(true); + + let switch = + container(toggler(self.is_shown).on_toggle(on_toggle)).align_right(Length::Shrink); + + row![color_dot, content, switch] + .align_y(Alignment::Center) + .spacing(10) + .padding(20) + .into() + }; + + let content = match self.state { + State::None => item, + State::WaitingForImpulseResponse => processing_overlay("Impulse Response", item), + State::Computing => processing_overlay("Computing ...", item), + State::Computed(_) => item, + }; + + sidebar::item(content, false) + } + + pub fn result(&self) -> Option<&data::FrequencyResponse> { + let State::Computed(ref result) = self.state else { + return None; + }; + + Some(result) } - pub(crate) fn result(&self) -> Option<&data::FrequencyResponse> { - self.data.as_ref() + pub fn set_result(&mut self, fr: data::FrequencyResponse) { + self.state = State::Computed(fr) } } @@ -99,24 +109,6 @@ impl Default for FrequencyResponse { } } -#[derive(Debug, Clone, Copy, Default)] -pub enum Progress { - #[default] - None, - Computing, -} - -impl Display for Progress { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let text = match self { - Self::None => "Not Started", - Self::Computing => "Impulse Response", - }; - - write!(f, "{}", text) - } -} - fn random_color() -> iced::Color { const MAX_COLOR_VALUE: u8 = 255; diff --git a/raumklang-gui/src/ui/impulse_response.rs b/raumklang-gui/src/ui/impulse_response.rs index e041f40..dd8c5d6 100644 --- a/raumklang-gui/src/ui/impulse_response.rs +++ b/raumklang-gui/src/ui/impulse_response.rs @@ -1,53 +1,79 @@ -use crate::data::{self, SampleRate}; +use std::time::SystemTime; -#[derive(Debug, Clone, Default)] +use crate::{ + data::{self, SampleRate, impulse_response}, + icon, + widget::{processing_overlay, sidebar}, +}; + +use chrono::{DateTime, Utc}; +use iced::{ + Element, + Length::{Fill, Shrink}, + task::Sipper, + widget::{button, column, right, row, rule, text}, +}; + +#[derive(Debug, Clone)] +pub enum Message { + Select, + Save, +} + +#[derive(Debug, Clone)] pub enum State { - #[default] - None, - Computing, + Computing(data::ImpulseResponse), Computed(ImpulseResponse), } impl State { - pub fn progress(&self) -> Progress { - match self { - State::None => Progress::None, - State::Computing => Progress::Computing, - State::Computed(_) => Progress::Finished, + pub(crate) fn from_data(impulse_response: data::ImpulseResponse) -> State { + match ImpulseResponse::from_data(&impulse_response) { + Some(ir) => State::Computed(ir), + None => State::Computing(impulse_response), } } - pub(crate) fn computed(&mut self, impulse_response: ImpulseResponse) { - *self = State::Computed(impulse_response) + pub(crate) fn progress(&self) -> impulse_response::Progress { + match self { + State::Computing(ir) => ir.progress(), + State::Computed(_) => impulse_response::Progress::Computed, + } } pub(crate) fn result(&self) -> Option<&ImpulseResponse> { - let State::Computed(ir) = self else { - return None; - }; - - Some(ir) + match self { + State::Computing(_) => None, + State::Computed(impulse_response) => Some(impulse_response), + } } -} -#[derive(Debug, Clone)] -pub enum Progress { - None, - Computing, - Finished, + pub(crate) fn compute( + &self, + loopback: &raumklang_core::Loopback, + measurement: &raumklang_core::Measurement, + ) -> Option + use<>> { + match self { + State::Computing(impulse_response) => { + impulse_response.clone().compute(loopback, measurement) + } + State::Computed(_) => None, + } + } } #[derive(Debug, Clone)] pub struct ImpulseResponse { pub sample_rate: SampleRate, - pub data: Vec, - pub origin: raumklang_core::ImpulseResponse, + pub normalized: Vec, + pub data: raumklang_core::ImpulseResponse, } impl ImpulseResponse { - pub fn from_data(impulse_response: data::ImpulseResponse) -> Self { + pub fn from_data(data: &data::ImpulseResponse) -> Option { + let impulse_response = data.result()?; + let max = impulse_response - .origin .data .iter() .map(|s| s.re.abs()) @@ -55,17 +81,72 @@ impl ImpulseResponse { .unwrap(); let normalized = impulse_response - .origin .data .iter() .map(|s| s.re) .map(|s| s / max.abs()) .collect(); - Self { - sample_rate: SampleRate::new(impulse_response.origin.sample_rate), - data: normalized, - origin: impulse_response.origin, + Some(Self { + sample_rate: SampleRate::new(impulse_response.sample_rate), + normalized, + data: impulse_response.clone(), + }) + } +} + +impl Default for State { + fn default() -> Self { + Self::Computing(data::ImpulseResponse::default()) + } +} + +pub fn view<'a>( + name: &'a str, + date_time: SystemTime, + progress: Option, + active: bool, +) -> Element<'a, Message> { + let entry = { + let dt: DateTime = date_time.into(); + let ir_btn = button( + column![ + text(name).size(16).wrapping(text::Wrapping::WordOrGlyph), + text!("{}", dt.format("%x %X")).size(10) + ] + .clip(true) + .spacing(6), + ) + .on_press(Message::Select) + .width(Fill) + .style(move |theme, status| { + let base = button::subtle(theme, status); + let background = theme.extended_palette().background; + + if active { + base.with_background(background.weak.color) + } else { + base + } + }); + + let save_btn = button(icon::download().size(10)) + .style(button::secondary) + .on_press_with(move || Message::Save); + + let content = row![ + ir_btn, + rule::vertical(1.0), + right(save_btn).width(Shrink).padding([0, 6]) + ]; + + sidebar::item(content, active) + }; + + match progress { + Some(impulse_response::Progress::Computing) => { + processing_overlay("Impulse Response", entry) } + _ => entry, } } diff --git a/raumklang-gui/src/ui/measurement.rs b/raumklang-gui/src/ui/measurement.rs index d2cd3af..cf268ab 100644 --- a/raumklang-gui/src/ui/measurement.rs +++ b/raumklang-gui/src/ui/measurement.rs @@ -2,52 +2,31 @@ pub mod loopback; pub use loopback::Loopback; +use chrono::{DateTime, Utc}; +use iced::{ + Element, + Length::{Fill, Shrink}, + widget::{button, column, right, row, rule, text}, +}; + use std::{ fmt::Display, path::{Path, PathBuf}, sync::atomic::{self, AtomicUsize}, }; -use crate::ui::{impulse_response, spectral_decay, spectrogram, FrequencyResponse}; - -#[derive(Debug, Default, Clone)] -pub struct List(Vec); - -impl List { - pub fn iter(&self) -> impl Iterator + Clone { - self.0.iter() - } - - pub(crate) fn iter_mut(&mut self) -> impl Iterator { - self.0.iter_mut() - } - - pub fn loaded_mut(&mut self) -> impl Iterator { - self.0.iter_mut().filter(|m| m.is_loaded()) - } - - pub(crate) fn push(&mut self, measurement: Measurement) { - self.0.push(measurement); - } +use crate::{icon, widget::sidebar}; - pub(crate) fn remove(&mut self, id: Id) -> Option { - let index = self - .0 - .iter() - .enumerate() - .find(|(_, m)| m.id == id) - .map(|(i, _)| i)?; - - Some(self.0.remove(index)) - } - - pub(crate) fn get(&self, id: Id) -> Option<&Measurement> { - self.0.iter().find(|m| m.id == id) - } +#[derive(Debug, Clone)] +pub enum Message { + Select(Selected), + Remove(Id), +} - pub(crate) fn get_mut(&mut self, id: Id) -> Option<&mut Measurement> { - self.0.iter_mut().find(|m| m.id == id) - } +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub enum Selected { + Loopback, + Measurement(Id), } #[derive(Debug, Clone)] @@ -65,14 +44,11 @@ pub struct Id(usize); #[derive(Debug, Clone)] enum State { NotLoaded, - Loaded { - signal: raumklang_core::Measurement, - analysis: Analysis, - }, + Loaded { signal: raumklang_core::Measurement }, } impl Measurement { - pub(crate) fn new( + pub fn new( name: String, path: Option, signal: Option, @@ -81,10 +57,7 @@ impl Measurement { let id = Id(ID.fetch_add(1, atomic::Ordering::Relaxed)); let state = match signal { - Some(signal) => State::Loaded { - signal, - analysis: Analysis::default(), - }, + Some(signal) => State::Loaded { signal }, None => State::NotLoaded, }; @@ -110,6 +83,53 @@ impl Measurement { Self::new(name, path, signal) } + pub fn view(&self, active: bool) -> Element<'_, Message> { + let info: Element<_> = match &self.signal() { + Some(signal) => { + let dt: DateTime = signal.modified.into(); + column![ + text("Last modified:").size(10), + text!("{}", dt.format("%x %X")).size(10) + ] + .into() + } + None => text("Offline").style(text::danger).into(), + }; + + let measurement_btn = button( + column![text(&self.name).wrapping(text::Wrapping::WordOrGlyph), info].spacing(5), + ) + .on_press_maybe( + self.is_loaded() + .then_some(Selected::Measurement(self.id)) + .map(Message::Select), + ) + .style(move |theme, status| { + let background = theme.extended_palette().background; + let base = button::subtle(theme, status); + + if active { + base.with_background(background.weak.color) + } else { + base + } + }) + .width(Fill) + .clip(true); + + let delete_btn = sidebar::button(icon::delete()) + .style(button::danger) + .on_press_with(move || Message::Remove(self.id)); + + let content = row![ + measurement_btn, + rule::vertical(1.0), + right(delete_btn).width(Shrink).padding([0, 6]) + ]; + + sidebar::item(content, active) + } + pub fn is_loaded(&self) -> bool { match &self.state { State::NotLoaded => false, @@ -127,62 +147,46 @@ impl Measurement { pub(crate) fn id(&self) -> Id { self.id } +} - pub fn analysis(&self) -> Option<&Analysis> { - match self.state { - State::NotLoaded => None, - State::Loaded { ref analysis, .. } => Some(analysis), - } +#[derive(Debug, Default, Clone)] +pub struct List(Vec); + +impl List { + pub fn iter(&self) -> impl Iterator + Clone { + self.0.iter() } - pub fn analysis_mut(&mut self) -> Option<&mut Analysis> { - match self.state { - State::NotLoaded => None, - State::Loaded { - ref mut analysis, .. - } => Some(analysis), - } + pub fn loaded(&self) -> impl Iterator { + self.0.iter().filter(|m| m.is_loaded()) } -} -impl Display for Id { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) + pub fn push(&mut self, measurement: Measurement) { + self.0.push(measurement); } -} -#[derive(Debug, Clone, Default)] -pub struct Analysis { - pub impulse_response: impulse_response::State, - pub frequency_response: FrequencyResponse, - pub spectral_decay: spectral_decay::State, - pub spectrogram: spectrogram::State, -} + pub fn remove(&mut self, id: Id) -> Option { + let index = self + .0 + .iter() + .enumerate() + .find(|(_, m)| m.id == id) + .map(|(i, _)| i)?; -impl Analysis { - pub(crate) fn spectral_decay_progress(&self) -> spectral_decay::Progress { - match self.impulse_response { - impulse_response::State::None => spectral_decay::Progress::None, - impulse_response::State::Computing => { - spectral_decay::Progress::ComputingImpulseResponse - } - impulse_response::State::Computed(_) => match self.spectral_decay { - spectral_decay::State::None => spectral_decay::Progress::None, - spectral_decay::State::Computing => spectral_decay::Progress::Computing, - spectral_decay::State::Computed(_) => spectral_decay::Progress::Finished, - }, - } + Some(self.0.remove(index)) } - pub(crate) fn spectrogram_progress(&self) -> spectrogram::Progress { - match self.impulse_response { - impulse_response::State::None => spectrogram::Progress::None, - impulse_response::State::Computing => spectrogram::Progress::ComputingImpulseResponse, - impulse_response::State::Computed(_) => match self.spectrogram { - spectrogram::State::None => spectrogram::Progress::None, - spectrogram::State::Computing => spectrogram::Progress::Computing, - spectrogram::State::Computed(_) => spectrogram::Progress::Finished, - }, - } + pub fn get(&self, id: Id) -> Option<&Measurement> { + self.0.iter().find(|m| m.id == id) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl Display for Id { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) } } diff --git a/raumklang-gui/src/ui/measurement/loopback.rs b/raumklang-gui/src/ui/measurement/loopback.rs index 2333dba..4314449 100644 --- a/raumklang-gui/src/ui/measurement/loopback.rs +++ b/raumklang-gui/src/ui/measurement/loopback.rs @@ -1,3 +1,15 @@ +use super::{Message, Selected}; + +use crate::{icon, widget::sidebar}; + +use iced::{ + Element, + Length::{Fill, Shrink}, + widget::{button, column, right, row, rule, text, tooltip}, +}; + +use chrono::{DateTime, Utc}; + use std::{ path::{Path, PathBuf}, sync::Arc, @@ -7,11 +19,11 @@ use std::{ pub struct Loopback { pub name: String, pub path: Option, - pub state: State, + state: State, } #[derive(Debug, Clone)] -pub enum State { +enum State { Loaded(raumklang_core::Loopback), NotLoaded(Arc), } @@ -49,6 +61,53 @@ impl Loopback { } } + pub fn view(&self, active: bool) -> Element<'_, super::Message> { + let info: Element<_> = match &self.state { + State::Loaded(loopback) => { + let dt: DateTime = loopback.as_ref().modified.into(); + column![ + text("Last modified:").size(10), + text!("{}", dt.format("%x %X")).size(10) + ] + .into() + } + State::NotLoaded(err) => tooltip( + text("Offline").style(text::danger), + text!("{err}").style(text::danger), + tooltip::Position::default(), + ) + .into(), + }; + + let measurement_btn = button(column![text(&self.name).size(16)].push(info).spacing(5)) + .on_press_maybe( + self.loaded() + .is_some() + .then_some(Message::Select(Selected::Loopback)), + ) + .style(move |theme, status| { + let background = theme.extended_palette().background; + let base = button::subtle(theme, status); + + if active { + base.with_background(background.weak.color) + } else { + base + } + }) + .width(Fill); + + let delete_btn = sidebar::button(icon::delete()).style(button::danger); + + let content = row![ + measurement_btn, + rule::vertical(1.0), + right(delete_btn).width(Shrink).padding([0, 6]) + ]; + + sidebar::item(content, active) + } + pub(crate) fn loaded(&self) -> Option<&raumklang_core::Loopback> { match &self.state { State::Loaded(loopback) => Some(loopback), diff --git a/raumklang-gui/src/ui/spectral_decay.rs b/raumklang-gui/src/ui/spectral_decay.rs index 18cd157..730f228 100644 --- a/raumklang-gui/src/ui/spectral_decay.rs +++ b/raumklang-gui/src/ui/spectral_decay.rs @@ -1,31 +1,70 @@ -use crate::data; +use crate::{data, ui::impulse_response}; + +use std::future::Future; + +#[derive(Debug, Clone, Default)] +pub struct SpectralDecay(State); #[derive(Debug, Clone, Default)] -pub enum State { +enum State { #[default] None, + WaitingForImpulseResponse, Computing, Computed(data::SpectralDecay), } -#[derive(Debug, Clone)] -pub enum Progress { - None, - ComputingImpulseResponse, - Computing, - Finished, -} +impl SpectralDecay { + pub fn result(&self) -> Option<&data::SpectralDecay> { + let State::Computed(result) = &self.0 else { + return None; + }; -impl State { - pub(crate) fn computed(&mut self, decay: data::SpectralDecay) { - *self = State::Computed(decay) + Some(result) + } + pub fn progress(&self) -> Progress { + match self.0 { + State::None => Progress::None, + State::WaitingForImpulseResponse => Progress::WaitingForImpulseResponse, + State::Computing => Progress::Computing, + State::Computed(_) => Progress::Finished, + } } - pub(crate) fn result(&self) -> Option<&data::SpectralDecay> { - let State::Computed(spectral_decay) = self else { + pub fn compute( + &mut self, + impulse_response: &impulse_response::State, + config: data::spectral_decay::Config, + ) -> Option + use<>> { + if self.result().is_some() { return None; - }; + } - Some(spectral_decay) + if let Some(impulse_response) = impulse_response.result() { + self.0 = State::Computing; + + let computation = data::spectral_decay::compute(impulse_response.data.clone(), config); + + Some(computation) + } else { + self.0 = State::WaitingForImpulseResponse; + None + } + } + + pub fn set_result(&mut self, spectral_decay: data::SpectralDecay) { + self.0 = State::Computed(spectral_decay); } + + pub fn reset(&mut self) { + self.0 = State::None + } +} + +#[derive(Debug, Clone)] +pub enum Progress { + None, + WaitingForImpulseResponse, + Computing, + Finished, } diff --git a/raumklang-gui/src/ui/spectrogram.rs b/raumklang-gui/src/ui/spectrogram.rs index 9a813a2..064b69d 100644 --- a/raumklang-gui/src/ui/spectrogram.rs +++ b/raumklang-gui/src/ui/spectrogram.rs @@ -1,25 +1,66 @@ -use crate::data; +use std::future::Future; + +use crate::data::{self, spectrogram}; + +#[derive(Debug, Clone, Default)] +pub struct Spectrogram(State); #[derive(Debug, Clone, Default)] -pub enum State { +enum State { #[default] None, + WaitingForImpulseResponse, Computing, Computed(data::Spectrogram), } -impl State { - pub(crate) fn computed(&mut self, data: data::Spectrogram) { - *self = State::Computed(data) - } - - pub(crate) fn result(&self) -> Option<&data::Spectrogram> { - let State::Computed(data) = self else { +impl Spectrogram { + pub fn result(&self) -> Option<&data::Spectrogram> { + let State::Computed(ref data) = self.0 else { return None; }; Some(data) } + + pub fn progress(&self) -> Progress { + match self.0 { + State::None => Progress::None, + State::WaitingForImpulseResponse => Progress::ComputingImpulseResponse, + State::Computing => Progress::Computing, + State::Computed(_) => Progress::Finished, + } + } + + pub fn compute( + &mut self, + impulse_response: &super::impulse_response::State, + config: &spectrogram::Preferences, + ) -> Option + use<>> { + if self.result().is_some() { + return None; + } + + if let Some(impulse_response) = impulse_response.result() { + self.0 = State::Computing; + + let computation = + data::spectrogram::compute(impulse_response.data.clone(), config.clone()); + + Some(computation) + } else { + self.0 = State::WaitingForImpulseResponse; + None + } + } + + pub fn set_result(&mut self, spectrogram: data::Spectrogram) { + self.0 = State::Computed(spectrogram); + } + + pub fn reset(&mut self) { + self.0 = State::None + } } #[derive(Debug, Clone)] diff --git a/raumklang-gui/src/widget.rs b/raumklang-gui/src/widget.rs index 86a3b4a..af731d9 100644 --- a/raumklang-gui/src/widget.rs +++ b/raumklang-gui/src/widget.rs @@ -4,9 +4,10 @@ pub mod sidebar; pub use meter::RmsPeakMeter; use iced::{ + Color, Element, Font, + Length::Fill, alignment::Horizontal::Right, - widget::{text, text_input, tooltip}, - Element, Font, + widget::{column, container, stack, text, text_input, tooltip}, }; use std::fmt; @@ -46,3 +47,26 @@ pub fn number_input<'a, E: fmt::Display, Message: Clone + 'a>( } .into() } + +pub fn processing_overlay<'a, Message>( + status: &'a str, + entry: impl Into>, +) -> Element<'a, Message> +where + Message: 'a, +{ + stack([ + container(entry).style(container::bordered_box).into(), + container(column![text("Computing..."), text(status).size(12)]) + .center(Fill) + .style(|theme| container::Style { + border: container::rounded_box(theme).border, + background: Some(iced::Background::Color(Color::from_rgba( + 0.0, 0.0, 0.0, 0.8, + ))), + ..Default::default() + }) + .into(), + ]) + .into() +} diff --git a/raumklang-gui/src/widget/meter.rs b/raumklang-gui/src/widget/meter.rs index f9df1aa..5596b14 100644 --- a/raumklang-gui/src/widget/meter.rs +++ b/raumklang-gui/src/widget/meter.rs @@ -1,10 +1,10 @@ use iced::{ + Font, Pixels, Point, Renderer, Size, advanced::{graphics::text::Paragraph, text::Paragraph as _}, widget::{ canvas, text::{LineHeight, Shaping}, }, - Font, Pixels, Point, Renderer, Size, }; pub struct RmsPeakMeter<'a> { diff --git a/raumklang-gui/src/widget/sidebar.rs b/raumklang-gui/src/widget/sidebar.rs index 13f4906..d3d2640 100644 --- a/raumklang-gui/src/widget/sidebar.rs +++ b/raumklang-gui/src/widget/sidebar.rs @@ -1,7 +1,7 @@ use iced::{ - widget::{container, text}, Element, Font, Length::Fill, + widget::{self, Button, Text, container, text}, }; pub fn header<'a, Message: Clone + 'a>(title: impl text::IntoFragment<'a>) -> Element<'a, Message> { @@ -10,6 +10,13 @@ pub fn header<'a, Message: Clone + 'a>(title: impl text::IntoFragment<'a>) -> El .into() } +pub fn button<'a, Message>(title: Text<'a>) -> Button<'a, Message> { + widget::button(title.size(16).center()) + .width(26) + .height(26) + .style(widget::button::secondary) +} + pub fn item<'a, Message: Clone + 'a>( content: impl Into>, is_active: bool,