Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
795aca3
feat(recording): wav/aiff append mode and live waveform
Horuse Aug 16, 2026
3eba4ff
feat(waveform): canvas scope, deterministic deltas, wav/aiff file peaks
Horuse Aug 16, 2026
c5d48a5
fix(waveform): free zoom-out range, live recording tail, follow-pinne…
Horuse Aug 16, 2026
97bbc57
fix(waveform): adaptive ring, right-anchored grid, plot clipping, uni…
Horuse Aug 17, 2026
06ac2c6
fix(waveform): always fill min/max envelope at deep zoom
Horuse Aug 19, 2026
ad81084
fix(waveform): smooth live tail, disk only on pan, multi lane cap
Horuse Aug 19, 2026
6731124
fix(waveform): grid-aligned live ring, reset on recorder restart, loader
Horuse Aug 19, 2026
669e2a8
fix(waveform): align live ring via scope startFrame
Horuse Aug 20, 2026
d6d9cc3
fix(waveform): icon buttons, lane height shrink, history on follow, r…
Horuse Aug 20, 2026
c45a171
fix(waveform): unified progressive history loading, show disk before …
Horuse Aug 20, 2026
3e1bbde
fix(waveform): bound live ring in file mode, warm disk cache on follo…
Horuse Aug 23, 2026
a220bec
fix(audio-file): reopen reader for isomp4 restart, sync loop every ti…
Horuse Aug 23, 2026
195bbd5
feat(audio-file): decode opus via symphonia libopus adapter, make plu…
Horuse Aug 23, 2026
b861d32
fix(audio-file): treat unexpected-eof on truncated wav as clean end o…
Horuse Aug 23, 2026
e651f37
style: apply formatter to waveform scope and reader
Horuse Aug 23, 2026
371445f
fix(waveform): direction-aware disk prefetch and per-lane clip for ov…
Horuse Aug 24, 2026
783b8be
fix(waveform): slice time labels at the plot edge instead of dropping…
Horuse Aug 25, 2026
4c60346
fix(audio-file): decode mp3s whose broken xing tag reports zero frames
Horuse Aug 25, 2026
176d19f
feat(recording): pinnable file sample rate, default 48 khz
Horuse Aug 25, 2026
8e3684e
feat(recording): custom rate input, rate in status line, uncapped fil…
Horuse Aug 25, 2026
d5ca49b
fix(flow): component-bound tauriListen, leaked listeners kept waking …
Horuse Aug 25, 2026
b9d6145
fix(recording): lock selectors in append mode
Horuse Aug 25, 2026
76f1463
chore(recording): drop stray CBR/M4A/PCM captions
Horuse Aug 25, 2026
6acedbd
feat(recording): confirm before switching append to overwrite
Horuse Aug 25, 2026
d3b50ba
fix(recording): offer overwrite confirmation when file choice is canc…
Horuse Aug 25, 2026
ef99410
feat(recording): confirm overwrite-armed shape changes, per-file skip…
Horuse Aug 25, 2026
aa28b7a
feat(overlay): toggle-style checkbox and running-pipeline warning in …
Horuse Aug 25, 2026
92c442c
fix(recording): confirm without dont-ask checkbox actually applies th…
Horuse Aug 25, 2026
82eb8c8
fix(waveform): reset stale file view on overwrite restart detected fr…
Horuse Aug 25, 2026
f836732
fix(waveform): file-absolute scope frames, live overlay anchored at z…
Horuse Aug 25, 2026
c2d7cc0
fix(waveform): session-scoped recording view with disk-backed history
Horuse Aug 31, 2026
f90abb4
fix(waveform): per-handle scope sessions, gap-safe ring reads, time-n…
Horuse Sep 1, 2026
bf18c3b
fix(waveform): ring resizes keep held range instead of extending over…
Horuse Sep 1, 2026
8684f25
fix(waveform): reset stale loading flags out of data range, clamp rul…
Horuse Sep 1, 2026
ee5e9fa
feat(ui): reusable number stepper component
Horuse Sep 2, 2026
356a3c4
feat(recording): per-format rate/bitrate capability config with auto-fit
Horuse Sep 2, 2026
dc19f79
fix(recording): enforce probed encoder limits on start
Horuse Sep 2, 2026
a7b7cc1
Merge branch 'main' into feat/file-recording-append-waveform
Horuse Sep 2, 2026
a501e8d
fix(recording): restrict AAC to macOS and handle sample rate limits
Horuse Sep 3, 2026
69165fd
feat(waveform): buffer boundary line with live limit badge and refine…
Horuse Sep 3, 2026
10f7091
fix(waveform): map mouse coordinates to canvas scale and bound scroll…
Horuse Sep 3, 2026
3769904
style: format files
Horuse Sep 3, 2026
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
21 changes: 21 additions & 0 deletions src-tauri/Cargo.lock

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

4 changes: 4 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ opus = "0.3"
ogg = "0.9"
mp3lame-encoder = "0.2"
symphonia = { version = "0.6", features = ["all"] }
# Opus has no first-party symphonia codec; this adapter registers a libopus
# decoder into the same symphonia registry so the reader handles it like any
# other format.
symphonia-adapter-libopus = "0.3"
base64 = "0.22"
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "net"] }
webrtc = "0.11"
Expand Down
145 changes: 119 additions & 26 deletions src-tauri/src/audio/effects/waveform.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};

use crate::audio::graph::WaveformData;

use super::Effect;

pub const WAVEFORM_FRAMES: usize = 1024;
/// Distinguishes recorder sessions in scope/progress payloads: an overwrite
/// restart rewinds the absolute frame counter, so frame arithmetic alone
/// cannot tell a fresh session from a stale tail block of the previous one.
static NEXT_SESSION: AtomicU64 = AtomicU64::new(1);

/// Scope ring holds several blocks so the 33 ms meter tick never outruns the
/// ~21 ms DSP block rate; a longer tick stall overwrites the oldest frames,
/// which the UI renders as a skipped span.
pub const SCOPE_RING_FRAMES: usize = 16384;

/// Spectrum nodes need a longer contiguous window than the scope: a single
/// 1024-frame block is ~47 Hz/bin and cannot separate low tones. 4096 frames
Expand All @@ -20,7 +29,9 @@ struct WaveformState {
buf: Box<[f32]>, // interleaved, len = frames * MAX_WAVEFORM_CHANNELS
frames: usize,
channels: usize,
write: usize, // frame write head
write: usize, // ring write head (== total % frames)
total: u64, // absolute frames written
emit_pos: u64, // absolute frames already emitted via drain
}

impl WaveformState {
Expand All @@ -30,6 +41,8 @@ impl WaveformState {
frames,
channels: 0,
write: 0,
total: 0,
emit_pos: 0,
}
}
}
Expand All @@ -40,20 +53,51 @@ pub struct WaveformHandle {
/// Rate the captured samples run at (monitor SR), so the UI can map bins to
/// frequency without assuming 48 kHz.
pub sample_rate: u32,
/// Recording session this handle belongs to; emitted with every scope and
/// progress payload so the UI can drop state owned by a replaced session.
pub session: u64,
/// Pre-existing file frames this session appends to (0 for a fresh or
/// overwrite write); lets the UI keep disk-backed history across an append.
pub base_frames: u64,
state: Arc<Mutex<WaveformState>>,
spectrum: bool,
}

impl WaveformHandle {
fn new(node_id: String, sample_rate: u32, frames: usize) -> Self {
fn with_frames(node_id: String, sample_rate: u32, frames: usize, spectrum: bool) -> Self {
Self {
node_id,
sample_rate,
// Every handle owns one timeline: a rebuilt graph must adopt as a
// new session, or the UI's absolute frame counters rewind under it.
session: NEXT_SESSION.fetch_add(1, Ordering::Relaxed),
base_frames: 0,
state: Arc::new(Mutex::new(WaveformState::new(frames))),
spectrum,
}
}

/// Scope-size handle; used by the Waveform effect and by non-effect
/// consumers such as the File Recording node.
pub fn new(node_id: String, sample_rate: u32) -> Self {
Self::with_frames(node_id, sample_rate, SCOPE_RING_FRAMES, false)
}

/// Scope-size handle for one recorder worker invocation.
pub fn for_recorder(node_id: String, sample_rate: u32, base_frames: u64) -> Self {
Self {
base_frames,
..Self::with_frames(node_id, sample_rate, SCOPE_RING_FRAMES, false)
}
}

pub fn is_spectrum(&self) -> bool {
self.spectrum
}

/// Returns the last `frames` frames as a chronologically ordered interleaved
/// buffer plus its channel count. Called from the meter tick thread (non-RT).
/// buffer plus its channel count. Called from the meter tick thread (non-RT);
/// used by the spectrum node, which needs a full contiguous window.
pub fn snapshot(&self) -> (Vec<f32>, usize) {
let g = self.state.lock().unwrap();
let ch = g.channels.max(1);
Expand All @@ -65,6 +109,74 @@ impl WaveformHandle {
out[first_len..].copy_from_slice(&g.buf[..pos]);
(out, ch)
}

/// Returns the frames written since the previous call, in chronological
/// order, plus the absolute frame index of the first sample. Scopes consume
/// this delta (rather than the whole ring) so consecutive ticks neither
/// overlap nor skip. Called from the meter tick thread (non-RT).
pub fn drain(&self) -> (u64, Vec<f32>, usize) {
let mut g = self.state.lock().unwrap();
let ch = g.channels.max(1);
let avail = g.total.saturating_sub(g.emit_pos) as usize;
let cap = g.frames;
let n = avail.min(cap);
let start = g.total - n as u64;
let mut out = vec![0.0_f32; n * ch];
for i in 0..n {
let slot = ((start + i as u64) % cap as u64) as usize;
out[i * ch..(i + 1) * ch].copy_from_slice(&g.buf[slot * ch..(slot + 1) * ch]);
}
g.emit_pos = g.total;
(start, out, ch)
}

/// Ingests an interleaved block from a non-RT thread (the recorder worker).
/// Blocks on the state lock, unlike the effect's `try_lock` path.
/// `base_frames` seeds the absolute frame counter on the first block, so
/// `drain` reports file-absolute start positions even when the UI scopes
/// a recording that appended onto existing content.
pub fn push_interleaved(&self, samples: &[f32], frames: usize, base_frames: u64) {
if frames == 0 {
return;
}
let mut g = self.state.lock().unwrap();
// write() resets the counters when it latches the channel stride, so the
// append base must be applied after it.
let seed = g.total == 0 && g.emit_pos == 0 && base_frames > 0;
write(&mut g, samples, frames);
if seed {
g.total = base_frames + frames as u64;
g.emit_pos = base_frames;
}
}
}

/// Writes one interleaved block into a `WaveformState`; `channels` is derived
/// from the stride and a change resets the ring (and its absolute counter)
/// rather than misaligning it.
fn write(g: &mut WaveformState, samples: &[f32], frames: usize) {
let ch = (samples.len() / frames).clamp(1, MAX_WAVEFORM_CHANNELS);
if g.channels != ch {
g.channels = ch;
g.write = 0;
g.total = 0;
g.emit_pos = 0;
}
let cap = g.frames;
let n = frames.min(cap);
let src = &samples[..n * ch];
let pos = g.write;
let end = pos + n;
if end <= cap {
g.buf[pos * ch..end * ch].copy_from_slice(src);
g.write = if end == cap { 0 } else { end };
} else {
let first = (cap - pos) * ch;
g.buf[pos * ch..cap * ch].copy_from_slice(&src[..first]);
g.buf[..(n * ch - first)].copy_from_slice(&src[first..]);
g.write = end - cap;
}
g.total += n as u64;
}

pub struct WaveformEffect {
Expand All @@ -73,7 +185,7 @@ pub struct WaveformEffect {

impl WaveformEffect {
pub fn new(_d: WaveformData, node_id: String, sample_rate: u32) -> (Self, WaveformHandle) {
let handle = WaveformHandle::new(node_id, sample_rate, WAVEFORM_FRAMES);
let handle = WaveformHandle::new(node_id, sample_rate);
(
Self {
handle: handle.clone(),
Expand All @@ -89,7 +201,7 @@ impl WaveformEffect {
/// Spectrum nodes capture identically to the scope, just over a longer
/// contiguous window; the FFT runs in the UI.
pub fn new_for(node_id: String, sample_rate: u32) -> (Self, WaveformHandle) {
let handle = WaveformHandle::new(node_id, sample_rate, SPECTRUM_FRAMES);
let handle = WaveformHandle::with_frames(node_id, sample_rate, SPECTRUM_FRAMES, true);
(
Self {
handle: handle.clone(),
Expand All @@ -105,28 +217,9 @@ impl Effect for WaveformEffect {
if frames == 0 {
return;
}
let ch = (samples.len() / frames).clamp(1, MAX_WAVEFORM_CHANNELS);
// try_lock: a miss means this display block is skipped -- acceptable.
if let Ok(mut g) = self.handle.state.try_lock() {
// Channel count changed: reset the ring rather than misalign strides.
if g.channels != ch {
g.channels = ch;
g.write = 0;
}
let cap = g.frames;
let n = frames.min(cap);
let src = &samples[..n * ch];
let pos = g.write;
let end = pos + n;
if end <= cap {
g.buf[pos * ch..end * ch].copy_from_slice(src);
g.write = if end == cap { 0 } else { end };
} else {
let first = (cap - pos) * ch;
g.buf[pos * ch..cap * ch].copy_from_slice(&src[..first]);
g.buf[..(n * ch - first)].copy_from_slice(&src[first..]);
g.write = end - cap;
}
write(&mut g, samples, frames);
}
}
}
Loading
Loading