///|
/// Window family used by short-time spectral analysis.
pub enum AnalysisWindow {
Rectangular
Hann
Hamming
Blackman
}
///|
/// Return all supported windows in a stable order for configuration UIs.
pub fn supported_analysis_windows() -> Array[AnalysisWindow] {
[Rectangular, Hann, Hamming, Blackman]
}
///|
/// Return the Hann analysis-window tag.
pub fn analysis_window_hann() -> AnalysisWindow {
Hann
}
///|
/// A single short-time analysis frame.
pub struct TimeFrequencyFrame {
index : Int
start_index : Int
start_time : Double
values : Array[Double]
rms : Double
peak : Double
zero_crossings : Int
}
///|
/// A compact spectrogram whose rows are consecutive time frames.
pub struct Spectrogram {
sample_rate : Double
frame_size : Int
hop_size : Int
window : AnalysisWindow
frames : Array[TimeFrequencyFrame]
frequencies : Array[Double]
powers : Array[Array[Double]]
}
///|
/// A time-frequency ridge point selected from a spectrogram row.
pub struct RidgePoint {
frame_index : Int
time : Double
frequency : Double
power : Double
confidence : Double
}
///|
/// Summary statistics for a spectrogram or a selected band.
pub struct SpectrogramSummary {
frame_count : Int
frequency_bins : Int
duration : Double
total_energy : Double
mean_energy : Double
peak_frequency : Double
peak_power : Double
dominant_time : Double
active_fraction : Double
}
///|
/// Return a normalized analysis window of the requested type.
pub fn analysis_window(kind : AnalysisWindow, length : Int) -> Array[Double] {
if length <= 0 {
return []
}
match kind {
Rectangular => Array::make(length, 1.0)
Hann => hann_window(length)
Hamming => hamming_window(length)
Blackman => blackman_window(length)
}
}
///|
/// Clamp a frame size to a usable positive value.
pub fn safe_frame_size(requested : Int, sample_count : Int) -> Int {
if requested <= 0 || sample_count <= 0 {
0
} else if requested > sample_count {
sample_count
} else {
requested
}
}
///|
/// Clamp a hop size to a usable positive value.
pub fn safe_hop_size(requested : Int, frame_size : Int) -> Int {
if frame_size <= 0 {
0
} else if requested <= 0 {
frame_size
} else {
requested
}
}
///|
/// Count complete frames in a signal using a hop size.
pub fn complete_frame_count(
sample_count : Int,
frame_size : Int,
hop_size : Int,
) -> Int {
if sample_count <= 0 ||
frame_size <= 0 ||
hop_size <= 0 ||
sample_count < frame_size {
0
} else {
(sample_count - frame_size) / hop_size + 1
}
}
///|
/// Return frame start indexes, optionally including a zero-padded tail.
pub fn frame_start_indexes(
sample_count : Int,
frame_size : Int,
hop_size : Int,
include_partial : Bool,
) -> Array[Int] {
let result = Array::new()
if sample_count <= 0 || frame_size <= 0 || hop_size <= 0 {
return result
}
let mut start = 0
while start < sample_count {
if !include_partial && start + frame_size > sample_count {
break
}
result.push(start)
start += hop_size
}
result
}
///|
/// Calculate frame RMS with an explicit empty-frame policy.
pub fn frame_rms(values : Array[Double]) -> Double {
rms_samples(values)
}
///|
/// Calculate the crest factor of one frame.
pub fn frame_crest_factor(values : Array[Double]) -> Double {
let rms = rms_samples(values)
if rms <= 0.0 {
0.0
} else {
let peak = maximum_sample(values).abs().max(minimum_sample(values).abs())
peak / rms
}
}
///|
/// Estimate the mean absolute first difference of a frame.
pub fn frame_activity(values : Array[Double]) -> Double {
if values.length() < 2 {
0.0
} else {
let mut total = 0.0
for index = 1; index < values.length(); index = index + 1 {
total += (values[index] - values[index - 1]).abs()
}
total / (values.length() - 1).to_double()
}
}
///|
/// Compute the power spectrum of one frame using the selected window.
pub fn frame_periodogram(
values : Array[Double],
sample_rate : Double,
window : AnalysisWindow,
) -> Spectrum {
periodogram(values, sample_rate, analysis_window(window, values.length()))
}
///|
/// Build a spectrogram from a signal. Short final frames are omitted.
pub fn make_spectrogram(
data : Array[Double],
sample_rate : Double,
frame_size : Int,
hop_size : Int,
window : AnalysisWindow,
) -> Spectrogram {
let safe_size = safe_frame_size(frame_size, data.length())
let safe_hop = safe_hop_size(hop_size, safe_size)
let starts = frame_start_indexes(data.length(), safe_size, safe_hop, false)
let frames = Array::new(capacity=starts.length())
let powers = Array::new(capacity=starts.length())
let mut frequencies = []
if safe_size == 0 {
return {
sample_rate: if sample_rate > 0.0 {
sample_rate
} else {
0.0
},
frame_size: 0,
hop_size: 0,
window,
frames,
frequencies,
powers,
}
}
for frame_index = 0
frame_index < starts.length()
frame_index = frame_index + 1 {
let start = starts[frame_index]
let values = extract_window(data, start, safe_size, 0.0)
let spectrum = frame_periodogram(values, sample_rate, window)
if frame_index == 0 {
frequencies = spectrum.frequencies.copy()
}
frames.push({
index: frame_index,
start_index: start,
start_time: if sample_rate > 0.0 {
start.to_double() / sample_rate
} else {
0.0
},
values,
rms: frame_rms(values),
peak: maximum_sample(values).abs().max(minimum_sample(values).abs()),
zero_crossings: zero_crossing_count(values),
})
powers.push(spectrum.powers)
}
{
sample_rate: if sample_rate > 0.0 {
sample_rate
} else {
0.0
},
frame_size: safe_size,
hop_size: safe_hop,
window,
frames,
frequencies,
powers,
}
}
///|
/// Build a spectrogram from a trace and preserve its absolute start time.
pub fn trace_spectrogram(
trace : Trace,
frame_size : Int,
hop_size : Int,
window : AnalysisWindow,
) -> Spectrogram {
let result = make_spectrogram(
trace.data,
trace.sampling_rate,
frame_size,
hop_size,
window,
)
let frames = Array::new(capacity=result.frames.length())
for frame in result.frames {
frames.push({
index: frame.index,
start_index: frame.start_index,
start_time: frame.start_time + trace.start_time,
values: frame.values,
rms: frame.rms,
peak: frame.peak,
zero_crossings: frame.zero_crossings,
})
}
{
sample_rate: result.sample_rate,
frame_size: result.frame_size,
hop_size: result.hop_size,
window: result.window,
frames,
frequencies: result.frequencies,
powers: result.powers,
}
}
///|
/// Return a copy of a spectrogram with each frequency row normalized.
pub fn normalize_spectrogram(spectrogram : Spectrogram) -> Spectrogram {
let normalized = Array::new(capacity=spectrogram.powers.length())
for row in spectrogram.powers {
normalized.push(normalize_spectral_power(row))
}
{
sample_rate: spectrogram.sample_rate,
frame_size: spectrogram.frame_size,
hop_size: spectrogram.hop_size,
window: spectrogram.window,
frames: spectrogram.frames.copy(),
frequencies: spectrogram.frequencies.copy(),
powers: normalized,
}
}
///|
/// Return the energy of each spectrogram frame in a frequency band.
pub fn spectrogram_band_energy(
spectrogram : Spectrogram,
band : SpectralBand,
) -> Array[Double] {
let result = Array::make(spectrogram.powers.length(), 0.0)
for row_index = 0
row_index < spectrogram.powers.length()
row_index = row_index + 1 {
let row = spectrogram.powers[row_index]
let count = if row.length() < spectrogram.frequencies.length() {
row.length()
} else {
spectrogram.frequencies.length()
}
for bin = 0; bin < count; bin = bin + 1 {
if spectral_band_contains(band, spectrogram.frequencies[bin]) {
result[row_index] += row[bin].max(0.0)
}
}
}
result
}
///|
/// Return the dominant frequency in every frame.
pub fn spectrogram_dominant_frequencies(
spectrogram : Spectrogram,
) -> Array[Double] {
let result = Array::make(spectrogram.powers.length(), 0.0)
for row_index = 0
row_index < spectrogram.powers.length()
row_index = row_index + 1 {
let row = spectrogram.powers[row_index]
if row.length() > 1 && spectrogram.frequencies.length() > 1 {
let limit = if row.length() < spectrogram.frequencies.length() {
row.length()
} else {
spectrogram.frequencies.length()
}
let mut best = 1
for bin = 2; bin < limit; bin = bin + 1 {
if row[bin] > row[best] {
best = bin
}
}
result[row_index] = spectrogram.frequencies[best]
}
}
result
}
///|
/// Select the maximum-power bin from each spectrogram row.
pub fn spectrogram_ridge(
spectrogram : Spectrogram,
minimum_power : Double,
) -> Array[RidgePoint] {
let result = Array::new()
for row_index = 0
row_index < spectrogram.powers.length()
row_index = row_index + 1 {
let row = spectrogram.powers[row_index]
let limit = if row.length() < spectrogram.frequencies.length() {
row.length()
} else {
spectrogram.frequencies.length()
}
if limit == 0 {
continue
}
let mut best = 0
for bin = 1; bin < limit; bin = bin + 1 {
if row[bin] > row[best] {
best = bin
}
}
let total = sum_samples(row).max(1.0e-20)
let power = row[best].max(0.0)
if power >= minimum_power {
result.push({
frame_index: row_index,
time: if row_index < spectrogram.frames.length() {
spectrogram.frames[row_index].start_time
} else {
row_index.to_double() *
spectrogram.hop_size.to_double() /
spectrogram.sample_rate.max(1.0)
},
frequency: spectrogram.frequencies[best],
power,
confidence: power / total,
})
}
}
result
}
///|
/// Compute a stable summary, including active-frame occupancy.
pub fn summarize_spectrogram(
spectrogram : Spectrogram,
active_threshold : Double,
) -> SpectrogramSummary {
let energies = spectrogram_band_energy(
spectrogram,
SpectralBand::new(0.0, 1.0e300),
)
let mut total = 0.0
let mut active = 0
let mut peak_power = 0.0
let mut peak_frequency = 0.0
let mut dominant_time = 0.0
for energy in energies {
total += energy
if energy >= active_threshold {
active += 1
}
}
for row_index = 0
row_index < spectrogram.powers.length()
row_index = row_index + 1 {
let row = spectrogram.powers[row_index]
for bin = 0
bin < row.length() && bin < spectrogram.frequencies.length()
bin = bin + 1 {
if row[bin] > peak_power {
peak_power = row[bin]
peak_frequency = spectrogram.frequencies[bin]
if row_index < spectrogram.frames.length() {
dominant_time = spectrogram.frames[row_index].start_time
}
}
}
}
let mean_energy = if energies.is_empty() {
0.0
} else {
total / energies.length().to_double()
}
{
frame_count: spectrogram.powers.length(),
frequency_bins: spectrogram.frequencies.length(),
duration: if spectrogram.frames.is_empty() {
0.0
} else {
let last = spectrogram.frames[spectrogram.frames.length() - 1]
let frame_duration = if spectrogram.sample_rate > 0.0 {
spectrogram.frame_size.to_double() / spectrogram.sample_rate
} else {
0.0
}
last.start_time + frame_duration
},
total_energy: total,
mean_energy,
peak_frequency,
peak_power,
dominant_time,
active_fraction: if energies.is_empty() {
0.0
} else {
active.to_double() / energies.length().to_double()
},
}
}
///|
/// Compute positive spectral flux between consecutive spectrogram rows.
pub fn spectrogram_flux(spectrogram : Spectrogram) -> Array[Double] {
let result = Array::make(spectrogram.powers.length(), 0.0)
if spectrogram.powers.length() < 2 {
return result
}
for index = 1; index < spectrogram.powers.length(); index = index + 1 {
let left = normalize_spectral_power(spectrogram.powers[index - 1])
let right = normalize_spectral_power(spectrogram.powers[index])
let count = if left.length() < right.length() {
left.length()
} else {
right.length()
}
let mut energy = 0.0
for bin = 0; bin < count; bin = bin + 1 {
let change = right[bin] - left[bin]
if change > 0.0 {
energy += change * change
}
}
result[index] = energy.sqrt()
}
result
}
///|
/// Compute frame-to-frame spectral similarity in `[0, 1]`.
pub fn spectrogram_similarity(
left : Spectrogram,
right : Spectrogram,
) -> Array[Double] {
let count = if left.powers.length() < right.powers.length() {
left.powers.length()
} else {
right.powers.length()
}
let result = Array::make(count, 0.0)
for row = 0; row < count; row = row + 1 {
let a = normalize_spectral_power(left.powers[row])
let b = normalize_spectral_power(right.powers[row])
let bins = if a.length() < b.length() { a.length() } else { b.length() }
let mut dot = 0.0
let mut norm_a = 0.0
let mut norm_b = 0.0
for bin = 0; bin < bins; bin = bin + 1 {
dot += a[bin] * b[bin]
norm_a += a[bin] * a[bin]
norm_b += b[bin] * b[bin]
}
let denominator = (norm_a * norm_b).sqrt()
result[row] = if denominator <= 0.0 { 0.0 } else { dot / denominator }
}
result
}
///|
/// Smooth a ridge with a moving median in the frequency direction.
pub fn smooth_ridge(
points : Array[RidgePoint],
radius : Int,
) -> Array[RidgePoint] {
let result = Array::new(capacity=points.length())
let window = if radius < 0 { 0 } else { radius }
for index = 0; index < points.length(); index = index + 1 {
let point = points[index]
let values = Array::new()
let start = if index < window { 0 } else { index - window }
let end = if index + window + 1 > points.length() {
points.length()
} else {
index + window + 1
}
for cursor = start; cursor < end; cursor = cursor + 1 {
values.push(points[cursor].frequency)
}
result.push({
frame_index: point.frame_index,
time: point.time,
frequency: median_samples(values),
power: point.power,
confidence: point.confidence,
})
}
result
}
///|
/// Serialize a spectrogram ridge for downstream plotting tools.
pub fn ridge_to_csv(points : Array[RidgePoint]) -> String {
let mut output = "frame,time,frequency,power,confidence\n"
for point in points {
output = output +
point.frame_index.to_string() +
"," +
point.time.to_string() +
"," +
point.frequency.to_string() +
"," +
point.power.to_string() +
"," +
point.confidence.to_string() +
"\n"
}
output
}
///|
/// Compute the ratio of high-band to low-band energy for every frame.
pub fn spectrogram_band_ratio(
spectrogram : Spectrogram,
low_band : SpectralBand,
high_band : SpectralBand,
) -> Array[Double] {
let low = spectrogram_band_energy(spectrogram, low_band)
let high = spectrogram_band_energy(spectrogram, high_band)
let result = Array::make(low.length().min(high.length()), 0.0)
for index = 0; index < result.length(); index = index + 1 {
result[index] = if low[index] <= 0.0 {
0.0
} else {
high[index] / low[index]
}
}
result
}
///|
/// Return the frame with the greatest RMS, if one exists.
pub fn loudest_time_frequency_frame(
spectrogram : Spectrogram,
) -> TimeFrequencyFrame? {
if spectrogram.frames.is_empty() {
return None
}
let mut best = 0
for index = 1; index < spectrogram.frames.length(); index = index + 1 {
if spectrogram.frames[index].rms > spectrogram.frames[best].rms {
best = index
}
}
Some(spectrogram.frames[best])
}
///|
/// Estimate the time span whose frame energies exceed a threshold.
pub fn active_time_span(
spectrogram : Spectrogram,
threshold : Double,
) -> (Double, Double)? {
let energies = spectrogram_band_energy(
spectrogram,
SpectralBand::new(0.0, 1.0e300),
)
let mut first = -1
let mut last = -1
for index = 0; index < energies.length(); index = index + 1 {
if energies[index] >= threshold {
if first < 0 {
first = index
}
last = index
}
}
if first < 0 || last < 0 {
None
} else {
let start = if first < spectrogram.frames.length() {
spectrogram.frames[first].start_time
} else {
0.0
}
let end = if last < spectrogram.frames.length() {
spectrogram.frames[last].start_time +
spectrogram.frame_size.to_double() / spectrogram.sample_rate.max(1.0)
} else {
start
}
Some((start, end))
}
}
///|
/// Create a compact, deterministic summary line for a spectrogram.
pub fn spectrogram_summary_text(summary : SpectrogramSummary) -> String {
"frames=" +
summary.frame_count.to_string() +
" bins=" +
summary.frequency_bins.to_string() +
" duration=" +
summary.duration.to_string() +
" energy=" +
summary.total_energy.to_string() +
" mean_energy=" +
summary.mean_energy.to_string() +
" peak_hz=" +
summary.peak_frequency.to_string() +
" peak_power=" +
summary.peak_power.to_string() +
" active_fraction=" +
summary.active_fraction.to_string()
}