use plotly::common::Mode;
use plotly::layout::Axis;
use plotly::{Layout, Plot, Scatter};
use std::num::NonZeroUsize;
use std::path::Path;
use super::composite::PlotComponent;
use super::{PlotParams, PlotUtils};
use crate::operations::traits::AudioTransforms;
use crate::{AudioSampleResult, AudioSamples, StandardSample};
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct PhaseSpectrumParams {
pub plot_params: PlotParams,
pub unwrap: bool,
pub freq_range: Option<(f64, f64)>,
pub n_fft: Option<NonZeroUsize>,
}
impl PhaseSpectrumParams {
#[inline]
#[must_use]
pub fn new() -> Self {
Self {
plot_params: PlotParams::default(),
unwrap: false,
freq_range: None,
n_fft: None,
}
}
#[inline]
#[must_use]
pub fn with_plot_params(mut self, plot_params: PlotParams) -> Self {
self.plot_params = plot_params;
self
}
#[inline]
#[must_use]
pub const fn with_unwrap(mut self, unwrap: bool) -> Self {
self.unwrap = unwrap;
self
}
#[inline]
#[must_use]
pub const fn with_freq_range(mut self, min_hz: f64, max_hz: f64) -> Self {
self.freq_range = Some((min_hz, max_hz));
self
}
#[inline]
#[must_use]
pub const fn with_n_fft(mut self, n_fft: NonZeroUsize) -> Self {
self.n_fft = Some(n_fft);
self
}
}
pub struct PhaseSpectrumPlot {
_params: PhaseSpectrumParams,
plot: Plot,
}
impl PlotUtils for PhaseSpectrumPlot {
#[inline]
fn html(&self) -> AudioSampleResult<String> {
Ok(self.plot.to_html())
}
#[cfg(feature = "html_view")]
#[inline]
fn show(&self) -> AudioSampleResult<()> {
let html = self.html()?;
html_view::show(html).map_err(|e| {
crate::AudioSampleError::Processing(crate::ProcessingError::external_dependency(
"html_view",
"show",
e.to_string(),
))
})?;
Ok(())
}
#[inline]
fn save<P: AsRef<Path>>(&self, path: P) -> AudioSampleResult<()> {
let path = path.as_ref();
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("html");
match extension.to_lowercase().as_str() {
"html" => {
let html = self.html()?;
std::fs::write(path, html).map_err(|e| crate::AudioSampleError::io("save", &e))?;
Ok(())
}
#[cfg(feature = "static-plots")]
"png" | "svg" | "jpeg" | "jpg" | "webp" => {
use plotly_static::{ImageFormat, StaticExporterBuilder};
use serde_json::json;
let mut static_exporter =
StaticExporterBuilder::default().build().map_err(|e| {
crate::AudioSampleError::Processing(
crate::ProcessingError::external_dependency(
"plotly_static",
"save",
e.to_string(),
),
)
})?;
let format = match extension {
"png" => ImageFormat::PNG,
"svg" => ImageFormat::SVG,
"jpeg" | "jpg" => ImageFormat::JPEG,
"webp" => ImageFormat::WEBP,
_ => ImageFormat::PNG,
};
let width = 1920;
let height = 1080;
let scale = 1.0;
static_exporter
.write_fig(
path,
&json!(&self.plot.to_json()),
format,
width,
height,
scale,
)
.map_err(|e| {
crate::AudioSampleError::Processing(
crate::ProcessingError::external_dependency(
"plotly_static",
"save",
e.to_string(),
),
)
})
}
_ => {
#[cfg(not(feature = "static-plots"))]
return Err(crate::AudioSampleError::Feature(
crate::FeatureError::NotEnabled {
feature: "static-plots".to_string(),
operation: format!("save plot as {extension}"),
},
));
#[cfg(feature = "static-plots")]
return Err(crate::AudioSampleError::Parameter(
crate::ParameterError::InvalidValue {
parameter: "file_extension".to_string(),
reason: format!("Unsupported file extension: {}", extension),
},
));
}
}
}
}
impl PlotComponent for PhaseSpectrumPlot {
#[inline]
fn get_plot(&self) -> &Plot {
&self.plot
}
#[inline]
fn get_plot_mut(&mut self) -> &mut Plot {
&mut self.plot
}
#[inline]
fn requires_shared_x_axis(&self) -> bool {
false }
}
#[inline]
pub fn create_phase_spectrum_plot<T>(
audio: &AudioSamples<'_, T>,
params: &PhaseSpectrumParams,
) -> AudioSampleResult<PhaseSpectrumPlot>
where
T: StandardSample,
{
use crate::operations::traits::AudioChannelOps;
use crate::operations::types::MonoConversionMethod;
let mono_audio = if audio.num_channels().get() > 1 {
audio.to_mono(MonoConversionMethod::Average)?
} else {
audio.clone().into_owned()
};
let signal_len = mono_audio.samples_per_channel();
let n_fft = params.n_fft.unwrap_or_else(|| {
let mut pow2 = 1;
while pow2 < signal_len.get() {
pow2 *= 2;
}
unsafe { NonZeroUsize::new_unchecked(pow2) }
});
let n_fft_nz = n_fft.max(signal_len);
let fft_result = mono_audio.fft(n_fft_nz)?;
let channel_fft = fft_result.row(0);
let mut phases: Vec<f64> = channel_fft.iter().map(|c| c.arg()).collect();
if params.unwrap {
unwrap_phase(&mut phases);
}
let sample_rate = f64::from(audio.sample_rate().get());
let n_bins = phases.len();
let freq_bin = sample_rate / (n_fft.get() as f64);
let mut frequencies: Vec<f64> = (0..n_bins).map(|i| i as f64 * freq_bin).collect();
let mut filtered_phases = phases;
if let Some((min_freq, max_freq)) = params.freq_range {
let filtered_pairs: Vec<(f64, f64)> = frequencies
.iter()
.zip(filtered_phases.iter())
.filter(|(f, _)| **f >= min_freq && **f <= max_freq)
.map(|(f, p)| (*f, *p))
.collect();
frequencies = filtered_pairs.iter().map(|(f, _)| *f).collect();
filtered_phases = filtered_pairs.iter().map(|(_, p)| *p).collect();
}
let trace = Scatter::new(frequencies, filtered_phases)
.mode(Mode::Lines)
.name("Phase");
let mut plot = Plot::new();
plot.add_trace(trace);
let x_label = params
.plot_params
.x_label
.clone()
.unwrap_or_else(|| "Frequency (Hz)".to_string());
let y_label = params
.plot_params
.y_label
.clone()
.unwrap_or_else(|| "Phase (radians)".to_string());
let x_axis = Axis::new().title(plotly::common::Title::from(x_label.as_str()));
let y_axis = Axis::new().title(plotly::common::Title::from(y_label.as_str()));
let mut layout = Layout::new().x_axis(x_axis).y_axis(y_axis);
if let Some(ref title) = params.plot_params.title {
layout = layout.title(plotly::common::Title::from(title.as_str()));
}
if params.plot_params.grid {
let x_axis_with_grid = Axis::new()
.title(plotly::common::Title::from(x_label.as_str()))
.grid_color("lightgray");
let y_axis_with_grid = Axis::new()
.title(plotly::common::Title::from(y_label.as_str()))
.grid_color("lightgray");
layout = layout.x_axis(x_axis_with_grid).y_axis(y_axis_with_grid);
}
plot.set_layout(layout);
Ok(PhaseSpectrumPlot {
_params: params.clone(),
plot,
})
}
fn unwrap_phase(phases: &mut [f64]) {
use std::f64::consts::PI;
let two_pi = 2.0 * PI;
let mut correction = 0.0;
for i in 1..phases.len() {
let mut delta = (phases[i] + correction) - phases[i - 1];
while delta > PI {
correction -= two_pi;
delta -= two_pi;
}
while delta <= -PI {
correction += two_pi;
delta += two_pi;
}
phases[i] += correction;
}
}
#[cfg(test)]
#[cfg(feature = "plotting")]
mod tests {
use super::*;
use crate::sample_rate;
use crate::utils::generation::sine_wave;
use std::time::Duration;
fn test_signal() -> AudioSamples<'static, f32> {
sine_wave::<f32>(440.0, Duration::from_millis(50), sample_rate!(44100), 0.8)
}
#[test]
fn test_phase_spectrum_renders_nonempty_html() {
let audio = test_signal();
let params = PhaseSpectrumParams::new();
let plot = create_phase_spectrum_plot(&audio, ¶ms).unwrap();
let html = plot.html().unwrap();
assert!(!html.is_empty());
assert!(
html.contains("Phase"),
"rendered HTML must contain the trace name"
);
}
#[test]
fn test_phase_spectrum_unwrap_builder() {
let audio = test_signal();
let params = PhaseSpectrumParams::new()
.with_unwrap(true)
.with_n_fft(crate::nzu!(4096))
.with_freq_range(0.0, 5000.0);
assert!(params.unwrap);
assert_eq!(params.freq_range, Some((0.0, 5000.0)));
let plot = create_phase_spectrum_plot(&audio, ¶ms).unwrap();
let html = plot.html().unwrap();
assert!(!html.is_empty());
}
#[test]
fn test_unwrap_phase_removes_discontinuity() {
use std::f64::consts::PI;
let mut phases = vec![0.0, PI / 2.0, PI, -PI / 2.0];
unwrap_phase(&mut phases);
assert!((phases[3] - 1.5 * PI).abs() < 1e-9, "got {}", phases[3]);
}
}