Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ thiserror = "1"
cpal = "0.15"
rtrb = "0.3"
rubato = "0.16"
realfft = "3.4"
audio_thread_priority = "0.36"
ebur128 = "0.1"
flac-codec = "1.3"
Expand Down
54 changes: 28 additions & 26 deletions src-tauri/src/audio/pipeline/meter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ use crate::audio::health;

use super::dag::{OutputMeta, SourceMeta, DSP_BLOCK_FRAMES};
use super::output::LIVE_SPEAKER_STREAMS;
use super::spectrum::SpectrumCalculator;

const METER_EVENT: &str = "audio://meter";
const LUFS_EVENT: &str = "audio://lufs";
const GR_EVENT: &str = "audio://gr";
const SCOPE_EVENT: &str = "audio://scope";
const SPECTRUM_EVENT: &str = "audio://spectrum";
const METER_TICK: Duration = Duration::from_millis(33);

const XRUN_TICK: Duration = Duration::from_millis(1000);
Expand Down Expand Up @@ -347,6 +349,7 @@ pub(super) fn spawn_meter_thread(
let join = thread::Builder::new()
.name("meter-tick".into())
.spawn(move || {
let mut spectrum_calc = SpectrumCalculator::new();
while !stop_thread.load(Ordering::SeqCst) {
thread::sleep(METER_TICK);
for m in &meters {
Expand Down Expand Up @@ -387,15 +390,22 @@ pub(super) fn spawn_meter_thread(
let _ = app.emit(GR_EVENT, json!({ "nodeId": g.node_id, "grLin": gr_lin }));
}
for s in &scopes {
// Scopes emit a delta since the last tick; spectrum emits the
// full contiguous window it needs for its FFT.
let (start_frame, interleaved, ch) = if s.is_spectrum() {
let (v, ch) = s.snapshot();
(None, v, ch)
} else {
let (start, v, ch) = s.drain();
(Some(start), v, ch)
};
if s.is_spectrum() {
let (interleaved, ch) = s.snapshot();
let bars = spectrum_calc.calculate_bars(&interleaved, ch, s.sample_rate);
let _ = app.emit(
SPECTRUM_EVENT,
json!({
"nodeId": s.node_id,
"bars": bars,
"sampleRate": s.sample_rate,
}),
);
continue;
}

// Scopes emit a delta since the last tick (waveform / recording)
let (start, interleaved, ch) = s.drain();
let frames = interleaved.len() / ch;
if frames == 0 {
continue;
Expand All @@ -407,23 +417,15 @@ pub(super) fn spawn_meter_thread(
chans[c].push(interleaved[base + c]);
}
}
let payload = match start_frame {
Some(start) => json!({
"nodeId": s.node_id,
"channels": ch,
"data": chans,
"sampleRate": s.sample_rate,
"startFrame": start,
"session": s.session,
"baseFrames": s.base_frames,
}),
None => json!({
"nodeId": s.node_id,
"channels": ch,
"data": chans,
"sampleRate": s.sample_rate,
}),
};
let payload = json!({
"nodeId": s.node_id,
"channels": ch,
"data": chans,
"sampleRate": s.sample_rate,
"startFrame": start,
"session": s.session,
"baseFrames": s.base_frames,
});
let _ = app.emit(SCOPE_EVENT, payload);
}
}
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/audio/pipeline/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ mod output;
#[cfg(target_os = "linux")]
pub(crate) use worker::RtThread;
mod sig;
pub(crate) mod spectrum;
mod worker;

use dag::{build_output_graph, ring_capacity_frames, OutputGraph, OutputMeta, SourceMeta};
Expand Down
122 changes: 122 additions & 0 deletions src-tauri/src/audio/pipeline/spectrum.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
use realfft::num_complex::Complex;
use realfft::{RealFftPlanner, RealToComplex};
use std::f32::consts::PI;
use std::sync::Arc;

pub const FFT_SIZE: usize = 4096;
pub const BINS: usize = FFT_SIZE / 2;
pub const BARS: usize = 80;
pub const F_MIN: f32 = 20.0;
pub const DB_FLOOR: f32 = -96.0;
pub const MAX_CH: usize = 8;

pub struct SpectrumCalculator {
r2c: Arc<dyn RealToComplex<f32>>,
hann: Vec<f32>,
in_buf: Vec<f32>,
spectrum_buf: Vec<Complex<f32>>,
last_sample_rate: u32,
band_bins: Vec<(usize, usize)>,
}

impl SpectrumCalculator {
pub fn new() -> Self {
let mut planner = RealFftPlanner::<f32>::new();
let r2c = planner.plan_fft_forward(FFT_SIZE);
let in_buf = r2c.make_input_vec();
let spectrum_buf = r2c.make_output_vec();

let mut hann = vec![0.0_f32; FFT_SIZE];
for (i, h) in hann.iter_mut().enumerate() {
*h = 0.5 * (1.0 - (2.0 * PI * i as f32 / (FFT_SIZE - 1) as f32).cos());
}

let mut s = Self {
r2c,
hann,
in_buf,
spectrum_buf,
last_sample_rate: 0,
band_bins: Vec::with_capacity(BARS),
};
s.update_bands(48000);
s
}

fn update_bands(&mut self, sample_rate: u32) {
if sample_rate == self.last_sample_rate && !self.band_bins.is_empty() {
return;
}
self.last_sample_rate = sample_rate;
self.band_bins.clear();
let f_max = (sample_rate as f32) / 2.0;
let bins_f = BINS as f32;

for b in 0..BARS {
let f0 = F_MIN * (f_max / F_MIN).powf(b as f32 / BARS as f32);
let f1 = F_MIN * (f_max / F_MIN).powf((b + 1) as f32 / BARS as f32);
let lo = (1usize).max(((f0 / f_max) * bins_f).floor() as usize);
let hi = (lo + 1).max(((f1 / f_max) * bins_f).ceil() as usize);
self.band_bins.push((lo.min(BINS), hi.min(BINS)));
}
}

/// Takes interleaved audio with `frames` frames and `channels` channels.
/// Runs FFT over up to `MAX_CH` channels, takes max magnitude per bin,
/// and returns 80 log-spaced bar levels in decibels [DB_FLOOR..0 dB].
pub fn calculate_bars(
&mut self,
interleaved: &[f32],
channels: usize,
sample_rate: u32,
) -> Vec<f32> {
self.update_bands(sample_rate);
let ch = channels.max(1);
let cap_ch = ch.min(MAX_CH);
let frames = interleaved.len() / ch;
let n = FFT_SIZE.min(frames);

let mut band_peaks = vec![0.0_f32; BARS];

for c in 0..cap_ch {
// Fill input with Hann-windowed samples for this channel
for i in 0..n {
self.in_buf[i] = interleaved[i * ch + c] * self.hann[i];
}
for i in n..FFT_SIZE {
self.in_buf[i] = 0.0;
}

if self
.r2c
.process(&mut self.in_buf, &mut self.spectrum_buf)
.is_ok()
{
for b in 0..BARS {
let (lo, hi) = self.band_bins[b];
let mut peak = band_peaks[b];
for k in lo..hi {
let c = self.spectrum_buf[k];
let mag = (c.re * c.re + c.im * c.im).sqrt();
if mag > peak {
peak = mag;
}
}
band_peaks[b] = peak;
}
}
}

// Convert to normalized dB
let bins_norm = BINS as f32;
let mut bars_db = vec![DB_FLOOR; BARS];
for b in 0..BARS {
let norm = band_peaks[b] / bins_norm;
if norm > 1e-7 {
bars_db[b] = (20.0 * norm.log10()).max(DB_FLOOR);
}
}

bars_db
}
}
9 changes: 7 additions & 2 deletions src/lib/components/waveform_scope.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -1304,10 +1304,13 @@
}
}

let unlistenScope: (() => void) | undefined;
let unlistenProgress: (() => void) | undefined;

onMount(() => {
ctx = canvas.getContext('2d');
tauriListen<ScopeTick>('audio://scope', (p) => onScope(p));
tauriListen<RecorderProgress>('audio://recorder_progress', (p) => onProgress(p));
unlistenScope = tauriListen<ScopeTick>('audio://scope', (p) => onScope(p));
unlistenProgress = tauriListen<RecorderProgress>('audio://recorder_progress', (p) => onProgress(p));
ro = new ResizeObserver((entries) => {
const rect = entries[0].contentRect;
const w = rect.width;
Expand All @@ -1329,6 +1332,8 @@

onDestroy(() => {
disposed = true;
unlistenScope?.();
unlistenProgress?.();
if (stopTimer) clearTimeout(stopTimer);
ro?.disconnect();
ctx = null;
Expand Down
Loading
Loading