This commit is contained in:
2026-01-23 01:42:07 +01:00
parent 10e2812e4c
commit 183dd5b516
13 changed files with 165975 additions and 9 deletions

View File

@@ -28,3 +28,4 @@ arboard = "3"
minimad = "0.13"
crossbeam-channel = "0.5"
confy = "2"
rustfft = "6"

165719
arpeggio Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -85,6 +85,8 @@ impl App {
display: crate::settings::DisplaySettings {
fps: self.audio.config.refresh_rate.to_fps(),
runtime_highlight: self.ui.runtime_highlight,
show_scope: self.audio.config.show_scope,
show_spectrum: self.audio.config.show_spectrum,
},
link: crate::settings::LinkSettings {
enabled: link.is_enabled(),

View File

@@ -2,6 +2,7 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::Stream;
use crossbeam_channel::Receiver;
use doux::{Engine, EngineMetrics};
use rustfft::{num_complex::Complex, FftPlanner};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
@@ -50,6 +51,106 @@ impl ScopeBuffer {
}
}
pub struct SpectrumBuffer {
pub bands: [AtomicU32; 32],
}
impl SpectrumBuffer {
pub fn new() -> Self {
Self {
bands: std::array::from_fn(|_| AtomicU32::new(0)),
}
}
pub fn write(&self, data: &[f32; 32]) {
for (atom, &val) in self.bands.iter().zip(data.iter()) {
atom.store(val.to_bits(), Ordering::Relaxed);
}
}
pub fn read(&self) -> [f32; 32] {
std::array::from_fn(|i| f32::from_bits(self.bands[i].load(Ordering::Relaxed)))
}
}
const FFT_SIZE: usize = 512;
const NUM_BANDS: usize = 32;
struct SpectrumAnalyzer {
ring: Vec<f32>,
pos: usize,
fft: Arc<dyn rustfft::Fft<f32>>,
window: [f32; FFT_SIZE],
scratch: Vec<Complex<f32>>,
band_edges: [usize; NUM_BANDS + 1],
}
impl SpectrumAnalyzer {
fn new(sample_rate: f32) -> Self {
let mut planner = FftPlanner::new();
let fft = planner.plan_fft_forward(FFT_SIZE);
let scratch_len = fft.get_inplace_scratch_len();
let window: [f32; FFT_SIZE] = std::array::from_fn(|i| {
0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE - 1) as f32).cos())
});
let nyquist = sample_rate / 2.0;
let min_freq: f32 = 20.0;
let log_min = min_freq.ln();
let log_max = nyquist.ln();
let band_edges: [usize; NUM_BANDS + 1] = std::array::from_fn(|i| {
let freq = (log_min + (log_max - log_min) * i as f32 / NUM_BANDS as f32).exp();
let bin = (freq * FFT_SIZE as f32 / sample_rate).round() as usize;
bin.min(FFT_SIZE / 2)
});
Self {
ring: vec![0.0; FFT_SIZE],
pos: 0,
fft,
window,
scratch: vec![Complex::default(); scratch_len],
band_edges,
}
}
fn feed(&mut self, samples: &[f32], output: &SpectrumBuffer) {
for &s in samples {
self.ring[self.pos] = s;
self.pos += 1;
if self.pos >= FFT_SIZE {
self.pos = 0;
self.run_fft(output);
}
}
}
fn run_fft(&mut self, output: &SpectrumBuffer) {
let mut buf: Vec<Complex<f32>> = (0..FFT_SIZE)
.map(|i| {
let idx = (self.pos + i) % FFT_SIZE;
Complex::new(self.ring[idx] * self.window[i], 0.0)
})
.collect();
self.fft.process_with_scratch(&mut buf, &mut self.scratch);
let mut bands = [0.0f32; NUM_BANDS];
for (band, mag) in bands.iter_mut().enumerate() {
let lo = self.band_edges[band];
let hi = self.band_edges[band + 1].max(lo + 1);
let sum: f32 = buf[lo..hi].iter().map(|c| c.norm()).sum();
let avg = sum / (hi - lo) as f32;
let amplitude = avg / (FFT_SIZE as f32 / 2.0);
let db = 20.0 * amplitude.max(1e-10).log10();
*mag = ((db + 60.0) / 60.0).clamp(0.0, 1.0);
}
output.write(&bands);
}
}
pub struct AudioStreamConfig {
pub output_device: Option<String>,
pub channels: u16,
@@ -60,6 +161,7 @@ pub fn build_stream(
config: &AudioStreamConfig,
audio_rx: Receiver<AudioCommand>,
scope_buffer: Arc<ScopeBuffer>,
spectrum_buffer: Arc<SpectrumBuffer>,
metrics: Arc<EngineMetrics>,
initial_samples: Vec<doux::sample::SampleEntry>,
) -> Result<(Stream, f32), String> {
@@ -95,6 +197,8 @@ pub fn build_stream(
let mut engine = Engine::new_with_metrics(sample_rate, channels, Arc::clone(&metrics));
engine.sample_index = initial_samples;
let mut analyzer = SpectrumAnalyzer::new(sample_rate);
let stream = device
.build_output_stream(
&stream_config,
@@ -128,6 +232,12 @@ pub fn build_stream(
engine.metrics.load.set_buffer_time(buffer_time_ns);
engine.process_block(data, &[], &[]);
scope_buffer.write(&engine.output);
// Feed mono mix to spectrum analyzer
let mono: Vec<f32> = engine.output.chunks(channels)
.map(|ch| ch.iter().sum::<f32>() / channels as f32)
.collect();
analyzer.feed(&mono, &spectrum_buffer);
},
|err| eprintln!("stream error: {err}"),
None,

View File

@@ -2,7 +2,7 @@ mod audio;
mod link;
mod sequencer;
pub use audio::{build_stream, AudioStreamConfig, ScopeBuffer};
pub use audio::{build_stream, AudioStreamConfig, ScopeBuffer, SpectrumBuffer};
pub use link::LinkState;
pub use sequencer::{
spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand, SequencerSnapshot,

View File

@@ -601,6 +601,12 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
AudioFocus::RuntimeHighlight => {
ctx.app.ui.runtime_highlight = !ctx.app.ui.runtime_highlight
}
AudioFocus::ShowScope => {
ctx.app.audio.config.show_scope = !ctx.app.audio.config.show_scope;
}
AudioFocus::ShowSpectrum => {
ctx.app.audio.config.show_spectrum = !ctx.app.audio.config.show_spectrum;
}
AudioFocus::SamplePaths => ctx.app.audio.remove_last_sample_path(),
AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
AudioFocus::StartStopSync => ctx
@@ -622,6 +628,12 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
AudioFocus::RuntimeHighlight => {
ctx.app.ui.runtime_highlight = !ctx.app.ui.runtime_highlight
}
AudioFocus::ShowScope => {
ctx.app.audio.config.show_scope = !ctx.app.audio.config.show_scope;
}
AudioFocus::ShowSpectrum => {
ctx.app.audio.config.show_spectrum = !ctx.app.audio.config.show_spectrum;
}
AudioFocus::SamplePaths => {}
AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
AudioFocus::StartStopSync => ctx

View File

@@ -28,7 +28,7 @@ use ratatui::prelude::CrosstermBackend;
use ratatui::Terminal;
use app::App;
use engine::{build_stream, spawn_sequencer, AudioStreamConfig, LinkState, ScopeBuffer};
use engine::{build_stream, spawn_sequencer, AudioStreamConfig, LinkState, ScopeBuffer, SpectrumBuffer};
use input::{handle_key, InputContext, InputResult};
use settings::Settings;
use state::audio::RefreshRate;
@@ -84,9 +84,12 @@ fn main() -> io::Result<()> {
app.audio.config.sample_paths = args.samples;
app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps);
app.ui.runtime_highlight = settings.display.runtime_highlight;
app.audio.config.show_scope = settings.display.show_scope;
app.audio.config.show_spectrum = settings.display.show_spectrum;
let metrics = Arc::new(EngineMetrics::default());
let scope_buffer = Arc::new(ScopeBuffer::new());
let spectrum_buffer = Arc::new(SpectrumBuffer::new());
let mut initial_samples = Vec::new();
for path in &app.audio.config.sample_paths {
@@ -114,6 +117,7 @@ fn main() -> io::Result<()> {
&stream_config,
sequencer.audio_rx.clone(),
Arc::clone(&scope_buffer),
Arc::clone(&spectrum_buffer),
Arc::clone(&metrics),
initial_samples,
) {
@@ -157,6 +161,7 @@ fn main() -> io::Result<()> {
&new_config,
sequencer.audio_rx.clone(),
Arc::clone(&scope_buffer),
Arc::clone(&spectrum_buffer),
Arc::clone(&metrics),
restart_samples,
) {
@@ -182,6 +187,7 @@ fn main() -> io::Result<()> {
app.metrics.schedule_depth = metrics.schedule_depth.load(Ordering::Relaxed) as usize;
app.metrics.scope = scope_buffer.read();
(app.metrics.peak_left, app.metrics.peak_right) = scope_buffer.peaks();
app.metrics.spectrum = spectrum_buffer.read();
}
let seq_snapshot = sequencer.snapshot();

View File

@@ -21,6 +21,8 @@ pub struct AudioSettings {
pub struct DisplaySettings {
pub fps: u32,
pub runtime_highlight: bool,
pub show_scope: bool,
pub show_spectrum: bool,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -46,6 +48,8 @@ impl Default for DisplaySettings {
Self {
fps: 60,
runtime_highlight: false,
show_scope: true,
show_spectrum: true,
}
}
}

View File

@@ -56,6 +56,8 @@ pub struct AudioConfig {
pub sample_paths: Vec<PathBuf>,
pub sample_count: usize,
pub refresh_rate: RefreshRate,
pub show_scope: bool,
pub show_spectrum: bool,
}
impl Default for AudioConfig {
@@ -69,6 +71,8 @@ impl Default for AudioConfig {
sample_paths: Vec::new(),
sample_count: 0,
refresh_rate: RefreshRate::default(),
show_scope: true,
show_spectrum: true,
}
}
}
@@ -82,6 +86,8 @@ pub enum AudioFocus {
BufferSize,
RefreshRate,
RuntimeHighlight,
ShowScope,
ShowSpectrum,
SamplePaths,
LinkEnabled,
StartStopSync,
@@ -97,6 +103,7 @@ pub struct Metrics {
pub scope: [f32; 64],
pub peak_left: f32,
pub peak_right: f32,
pub spectrum: [f32; 32],
}
impl Default for Metrics {
@@ -110,6 +117,7 @@ impl Default for Metrics {
scope: [0.0; 64],
peak_left: 0.0,
peak_right: 0.0,
spectrum: [0.0; 32],
}
}
}
@@ -149,7 +157,9 @@ impl AudioSettings {
AudioFocus::Channels => AudioFocus::BufferSize,
AudioFocus::BufferSize => AudioFocus::RefreshRate,
AudioFocus::RefreshRate => AudioFocus::RuntimeHighlight,
AudioFocus::RuntimeHighlight => AudioFocus::SamplePaths,
AudioFocus::RuntimeHighlight => AudioFocus::ShowScope,
AudioFocus::ShowScope => AudioFocus::ShowSpectrum,
AudioFocus::ShowSpectrum => AudioFocus::SamplePaths,
AudioFocus::SamplePaths => AudioFocus::LinkEnabled,
AudioFocus::LinkEnabled => AudioFocus::StartStopSync,
AudioFocus::StartStopSync => AudioFocus::Quantum,
@@ -165,7 +175,9 @@ impl AudioSettings {
AudioFocus::BufferSize => AudioFocus::Channels,
AudioFocus::RefreshRate => AudioFocus::BufferSize,
AudioFocus::RuntimeHighlight => AudioFocus::RefreshRate,
AudioFocus::SamplePaths => AudioFocus::RuntimeHighlight,
AudioFocus::ShowScope => AudioFocus::RuntimeHighlight,
AudioFocus::ShowSpectrum => AudioFocus::ShowScope,
AudioFocus::SamplePaths => AudioFocus::ShowSpectrum,
AudioFocus::LinkEnabled => AudioFocus::SamplePaths,
AudioFocus::StartStopSync => AudioFocus::LinkEnabled,
AudioFocus::Quantum => AudioFocus::StartStopSync,

View File

@@ -47,7 +47,7 @@ fn render_audio_section(frame: &mut Frame, app: &App, area: Rect) {
let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([
Constraint::Length(4),
Constraint::Length(1),
Constraint::Length(6),
Constraint::Length(8),
Constraint::Length(1),
Constraint::Min(3),
])
@@ -112,8 +112,12 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
let buffer_focused = app.audio.focus == AudioFocus::BufferSize;
let fps_focused = app.audio.focus == AudioFocus::RefreshRate;
let highlight_focused = app.audio.focus == AudioFocus::RuntimeHighlight;
let scope_focused = app.audio.focus == AudioFocus::ShowScope;
let spectrum_focused = app.audio.focus == AudioFocus::ShowSpectrum;
let highlight_text = if app.ui.runtime_highlight { "On" } else { "Off" };
let scope_text = if app.audio.config.show_scope { "On" } else { "Off" };
let spectrum_text = if app.audio.config.show_spectrum { "On" } else { "Off" };
let rows = vec![
Row::new(vec![
@@ -147,6 +151,14 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
Span::styled("Highlight", label_style),
render_selector(highlight_text, highlight_focused, highlight, normal),
]),
Row::new(vec![
Span::styled("Scope", label_style),
render_selector(scope_text, scope_focused, highlight, normal),
]),
Row::new(vec![
Span::styled("Spectrum", label_style),
render_selector(spectrum_text, spectrum_focused, highlight, normal),
]),
Row::new(vec![
Span::styled("Rate", label_style),
Span::styled(

View File

@@ -5,7 +5,7 @@ use ratatui::Frame;
use crate::app::App;
use crate::engine::SequencerSnapshot;
use crate::widgets::{Orientation, Scope, VuMeter};
use crate::widgets::{Orientation, Scope, Spectrum, VuMeter};
pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, area: Rect) {
let [left_area, _spacer, vu_area] = Layout::horizontal([
@@ -15,13 +15,31 @@ pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, ar
])
.areas(area);
let [scope_area, sequencer_area] = Layout::vertical([
Constraint::Length(14),
let show_scope = app.audio.config.show_scope;
let show_spectrum = app.audio.config.show_spectrum;
let viz_height = if show_scope || show_spectrum { 14 } else { 0 };
let [viz_area, sequencer_area] = Layout::vertical([
Constraint::Length(viz_height),
Constraint::Fill(1),
])
.areas(left_area);
if show_scope && show_spectrum {
let [scope_area, _, spectrum_area] = Layout::horizontal([
Constraint::Percentage(50),
Constraint::Length(2),
Constraint::Percentage(50),
])
.areas(viz_area);
render_scope(frame, app, scope_area);
render_spectrum(frame, app, spectrum_area);
} else if show_scope {
render_scope(frame, app, viz_area);
} else if show_spectrum {
render_spectrum(frame, app, viz_area);
}
render_sequencer(frame, app, snapshot, sequencer_area);
render_vu_meter(frame, app, vu_area);
}
@@ -166,6 +184,12 @@ fn render_scope(frame: &mut Frame, app: &App, area: Rect) {
frame.render_widget(scope, area);
}
fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) {
let area = Rect { height: area.height.saturating_sub(1), ..area };
let spectrum = Spectrum::new(&app.metrics.spectrum);
frame.render_widget(spectrum, area);
}
fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right);
frame.render_widget(vu, area);

View File

@@ -1,11 +1,13 @@
mod confirm;
mod modal;
mod scope;
mod spectrum;
mod text_input;
mod vu_meter;
pub use confirm::ConfirmModal;
pub use modal::ModalFrame;
pub use scope::{Orientation, Scope};
pub use spectrum::Spectrum;
pub use text_input::TextInputModal;
pub use vu_meter::VuMeter;

62
src/widgets/spectrum.rs Normal file
View File

@@ -0,0 +1,62 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::widgets::Widget;
const BLOCKS: [char; 8] = ['\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}'];
pub struct Spectrum<'a> {
data: &'a [f32; 32],
}
impl<'a> Spectrum<'a> {
pub fn new(data: &'a [f32; 32]) -> Self {
Self { data }
}
}
impl Widget for Spectrum<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let height = area.height as f32;
let band_width = area.width as usize / 32;
if band_width == 0 {
return;
}
for (band, &mag) in self.data.iter().enumerate() {
let bar_height = mag * height;
let full_cells = bar_height as usize;
let frac = bar_height - full_cells as f32;
let frac_idx = (frac * 8.0) as usize;
let x_start = area.x + (band * band_width) as u16;
for row in 0..area.height as usize {
let y = area.y + area.height - 1 - row as u16;
let ratio = row as f32 / area.height as f32;
let color = if ratio < 0.33 {
Color::Rgb(40, 180, 80)
} else if ratio < 0.66 {
Color::Rgb(220, 180, 40)
} else {
Color::Rgb(220, 60, 40)
};
for dx in 0..band_width as u16 {
let x = x_start + dx;
if x >= area.x + area.width {
break;
}
if row < full_cells {
buf[(x, y)].set_char(BLOCKS[7]).set_fg(color);
} else if row == full_cells && frac_idx > 0 {
buf[(x, y)].set_char(BLOCKS[frac_idx - 1]).set_fg(color);
}
}
}
}
}
}