spectrum
This commit is contained in:
@@ -28,3 +28,4 @@ arboard = "3"
|
|||||||
minimad = "0.13"
|
minimad = "0.13"
|
||||||
crossbeam-channel = "0.5"
|
crossbeam-channel = "0.5"
|
||||||
confy = "2"
|
confy = "2"
|
||||||
|
rustfft = "6"
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ impl App {
|
|||||||
display: crate::settings::DisplaySettings {
|
display: crate::settings::DisplaySettings {
|
||||||
fps: self.audio.config.refresh_rate.to_fps(),
|
fps: self.audio.config.refresh_rate.to_fps(),
|
||||||
runtime_highlight: self.ui.runtime_highlight,
|
runtime_highlight: self.ui.runtime_highlight,
|
||||||
|
show_scope: self.audio.config.show_scope,
|
||||||
|
show_spectrum: self.audio.config.show_spectrum,
|
||||||
},
|
},
|
||||||
link: crate::settings::LinkSettings {
|
link: crate::settings::LinkSettings {
|
||||||
enabled: link.is_enabled(),
|
enabled: link.is_enabled(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
|||||||
use cpal::Stream;
|
use cpal::Stream;
|
||||||
use crossbeam_channel::Receiver;
|
use crossbeam_channel::Receiver;
|
||||||
use doux::{Engine, EngineMetrics};
|
use doux::{Engine, EngineMetrics};
|
||||||
|
use rustfft::{num_complex::Complex, FftPlanner};
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
use std::sync::Arc;
|
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 struct AudioStreamConfig {
|
||||||
pub output_device: Option<String>,
|
pub output_device: Option<String>,
|
||||||
pub channels: u16,
|
pub channels: u16,
|
||||||
@@ -60,6 +161,7 @@ pub fn build_stream(
|
|||||||
config: &AudioStreamConfig,
|
config: &AudioStreamConfig,
|
||||||
audio_rx: Receiver<AudioCommand>,
|
audio_rx: Receiver<AudioCommand>,
|
||||||
scope_buffer: Arc<ScopeBuffer>,
|
scope_buffer: Arc<ScopeBuffer>,
|
||||||
|
spectrum_buffer: Arc<SpectrumBuffer>,
|
||||||
metrics: Arc<EngineMetrics>,
|
metrics: Arc<EngineMetrics>,
|
||||||
initial_samples: Vec<doux::sample::SampleEntry>,
|
initial_samples: Vec<doux::sample::SampleEntry>,
|
||||||
) -> Result<(Stream, f32), String> {
|
) -> 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));
|
let mut engine = Engine::new_with_metrics(sample_rate, channels, Arc::clone(&metrics));
|
||||||
engine.sample_index = initial_samples;
|
engine.sample_index = initial_samples;
|
||||||
|
|
||||||
|
let mut analyzer = SpectrumAnalyzer::new(sample_rate);
|
||||||
|
|
||||||
let stream = device
|
let stream = device
|
||||||
.build_output_stream(
|
.build_output_stream(
|
||||||
&stream_config,
|
&stream_config,
|
||||||
@@ -128,6 +232,12 @@ pub fn build_stream(
|
|||||||
engine.metrics.load.set_buffer_time(buffer_time_ns);
|
engine.metrics.load.set_buffer_time(buffer_time_ns);
|
||||||
engine.process_block(data, &[], &[]);
|
engine.process_block(data, &[], &[]);
|
||||||
scope_buffer.write(&engine.output);
|
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}"),
|
|err| eprintln!("stream error: {err}"),
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ mod audio;
|
|||||||
mod link;
|
mod link;
|
||||||
mod sequencer;
|
mod sequencer;
|
||||||
|
|
||||||
pub use audio::{build_stream, AudioStreamConfig, ScopeBuffer};
|
pub use audio::{build_stream, AudioStreamConfig, ScopeBuffer, SpectrumBuffer};
|
||||||
pub use link::LinkState;
|
pub use link::LinkState;
|
||||||
pub use sequencer::{
|
pub use sequencer::{
|
||||||
spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand, SequencerSnapshot,
|
spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand, SequencerSnapshot,
|
||||||
|
|||||||
12
src/input.rs
12
src/input.rs
@@ -601,6 +601,12 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
AudioFocus::RuntimeHighlight => {
|
AudioFocus::RuntimeHighlight => {
|
||||||
ctx.app.ui.runtime_highlight = !ctx.app.ui.runtime_highlight
|
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::SamplePaths => ctx.app.audio.remove_last_sample_path(),
|
||||||
AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
|
AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
|
||||||
AudioFocus::StartStopSync => ctx
|
AudioFocus::StartStopSync => ctx
|
||||||
@@ -622,6 +628,12 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
AudioFocus::RuntimeHighlight => {
|
AudioFocus::RuntimeHighlight => {
|
||||||
ctx.app.ui.runtime_highlight = !ctx.app.ui.runtime_highlight
|
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::SamplePaths => {}
|
||||||
AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
|
AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
|
||||||
AudioFocus::StartStopSync => ctx
|
AudioFocus::StartStopSync => ctx
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ use ratatui::prelude::CrosstermBackend;
|
|||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
|
|
||||||
use app::App;
|
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 input::{handle_key, InputContext, InputResult};
|
||||||
use settings::Settings;
|
use settings::Settings;
|
||||||
use state::audio::RefreshRate;
|
use state::audio::RefreshRate;
|
||||||
@@ -84,9 +84,12 @@ fn main() -> io::Result<()> {
|
|||||||
app.audio.config.sample_paths = args.samples;
|
app.audio.config.sample_paths = args.samples;
|
||||||
app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps);
|
app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps);
|
||||||
app.ui.runtime_highlight = settings.display.runtime_highlight;
|
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 metrics = Arc::new(EngineMetrics::default());
|
||||||
let scope_buffer = Arc::new(ScopeBuffer::new());
|
let scope_buffer = Arc::new(ScopeBuffer::new());
|
||||||
|
let spectrum_buffer = Arc::new(SpectrumBuffer::new());
|
||||||
|
|
||||||
let mut initial_samples = Vec::new();
|
let mut initial_samples = Vec::new();
|
||||||
for path in &app.audio.config.sample_paths {
|
for path in &app.audio.config.sample_paths {
|
||||||
@@ -114,6 +117,7 @@ fn main() -> io::Result<()> {
|
|||||||
&stream_config,
|
&stream_config,
|
||||||
sequencer.audio_rx.clone(),
|
sequencer.audio_rx.clone(),
|
||||||
Arc::clone(&scope_buffer),
|
Arc::clone(&scope_buffer),
|
||||||
|
Arc::clone(&spectrum_buffer),
|
||||||
Arc::clone(&metrics),
|
Arc::clone(&metrics),
|
||||||
initial_samples,
|
initial_samples,
|
||||||
) {
|
) {
|
||||||
@@ -157,6 +161,7 @@ fn main() -> io::Result<()> {
|
|||||||
&new_config,
|
&new_config,
|
||||||
sequencer.audio_rx.clone(),
|
sequencer.audio_rx.clone(),
|
||||||
Arc::clone(&scope_buffer),
|
Arc::clone(&scope_buffer),
|
||||||
|
Arc::clone(&spectrum_buffer),
|
||||||
Arc::clone(&metrics),
|
Arc::clone(&metrics),
|
||||||
restart_samples,
|
restart_samples,
|
||||||
) {
|
) {
|
||||||
@@ -182,6 +187,7 @@ fn main() -> io::Result<()> {
|
|||||||
app.metrics.schedule_depth = metrics.schedule_depth.load(Ordering::Relaxed) as usize;
|
app.metrics.schedule_depth = metrics.schedule_depth.load(Ordering::Relaxed) as usize;
|
||||||
app.metrics.scope = scope_buffer.read();
|
app.metrics.scope = scope_buffer.read();
|
||||||
(app.metrics.peak_left, app.metrics.peak_right) = scope_buffer.peaks();
|
(app.metrics.peak_left, app.metrics.peak_right) = scope_buffer.peaks();
|
||||||
|
app.metrics.spectrum = spectrum_buffer.read();
|
||||||
}
|
}
|
||||||
|
|
||||||
let seq_snapshot = sequencer.snapshot();
|
let seq_snapshot = sequencer.snapshot();
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ pub struct AudioSettings {
|
|||||||
pub struct DisplaySettings {
|
pub struct DisplaySettings {
|
||||||
pub fps: u32,
|
pub fps: u32,
|
||||||
pub runtime_highlight: bool,
|
pub runtime_highlight: bool,
|
||||||
|
pub show_scope: bool,
|
||||||
|
pub show_spectrum: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -46,6 +48,8 @@ impl Default for DisplaySettings {
|
|||||||
Self {
|
Self {
|
||||||
fps: 60,
|
fps: 60,
|
||||||
runtime_highlight: false,
|
runtime_highlight: false,
|
||||||
|
show_scope: true,
|
||||||
|
show_spectrum: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ pub struct AudioConfig {
|
|||||||
pub sample_paths: Vec<PathBuf>,
|
pub sample_paths: Vec<PathBuf>,
|
||||||
pub sample_count: usize,
|
pub sample_count: usize,
|
||||||
pub refresh_rate: RefreshRate,
|
pub refresh_rate: RefreshRate,
|
||||||
|
pub show_scope: bool,
|
||||||
|
pub show_spectrum: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AudioConfig {
|
impl Default for AudioConfig {
|
||||||
@@ -69,6 +71,8 @@ impl Default for AudioConfig {
|
|||||||
sample_paths: Vec::new(),
|
sample_paths: Vec::new(),
|
||||||
sample_count: 0,
|
sample_count: 0,
|
||||||
refresh_rate: RefreshRate::default(),
|
refresh_rate: RefreshRate::default(),
|
||||||
|
show_scope: true,
|
||||||
|
show_spectrum: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +86,8 @@ pub enum AudioFocus {
|
|||||||
BufferSize,
|
BufferSize,
|
||||||
RefreshRate,
|
RefreshRate,
|
||||||
RuntimeHighlight,
|
RuntimeHighlight,
|
||||||
|
ShowScope,
|
||||||
|
ShowSpectrum,
|
||||||
SamplePaths,
|
SamplePaths,
|
||||||
LinkEnabled,
|
LinkEnabled,
|
||||||
StartStopSync,
|
StartStopSync,
|
||||||
@@ -97,6 +103,7 @@ pub struct Metrics {
|
|||||||
pub scope: [f32; 64],
|
pub scope: [f32; 64],
|
||||||
pub peak_left: f32,
|
pub peak_left: f32,
|
||||||
pub peak_right: f32,
|
pub peak_right: f32,
|
||||||
|
pub spectrum: [f32; 32],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Metrics {
|
impl Default for Metrics {
|
||||||
@@ -110,6 +117,7 @@ impl Default for Metrics {
|
|||||||
scope: [0.0; 64],
|
scope: [0.0; 64],
|
||||||
peak_left: 0.0,
|
peak_left: 0.0,
|
||||||
peak_right: 0.0,
|
peak_right: 0.0,
|
||||||
|
spectrum: [0.0; 32],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,7 +157,9 @@ impl AudioSettings {
|
|||||||
AudioFocus::Channels => AudioFocus::BufferSize,
|
AudioFocus::Channels => AudioFocus::BufferSize,
|
||||||
AudioFocus::BufferSize => AudioFocus::RefreshRate,
|
AudioFocus::BufferSize => AudioFocus::RefreshRate,
|
||||||
AudioFocus::RefreshRate => AudioFocus::RuntimeHighlight,
|
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::SamplePaths => AudioFocus::LinkEnabled,
|
||||||
AudioFocus::LinkEnabled => AudioFocus::StartStopSync,
|
AudioFocus::LinkEnabled => AudioFocus::StartStopSync,
|
||||||
AudioFocus::StartStopSync => AudioFocus::Quantum,
|
AudioFocus::StartStopSync => AudioFocus::Quantum,
|
||||||
@@ -165,7 +175,9 @@ impl AudioSettings {
|
|||||||
AudioFocus::BufferSize => AudioFocus::Channels,
|
AudioFocus::BufferSize => AudioFocus::Channels,
|
||||||
AudioFocus::RefreshRate => AudioFocus::BufferSize,
|
AudioFocus::RefreshRate => AudioFocus::BufferSize,
|
||||||
AudioFocus::RuntimeHighlight => AudioFocus::RefreshRate,
|
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::LinkEnabled => AudioFocus::SamplePaths,
|
||||||
AudioFocus::StartStopSync => AudioFocus::LinkEnabled,
|
AudioFocus::StartStopSync => AudioFocus::LinkEnabled,
|
||||||
AudioFocus::Quantum => AudioFocus::StartStopSync,
|
AudioFocus::Quantum => AudioFocus::StartStopSync,
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ fn render_audio_section(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([
|
let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([
|
||||||
Constraint::Length(4),
|
Constraint::Length(4),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Length(6),
|
Constraint::Length(8),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Min(3),
|
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 buffer_focused = app.audio.focus == AudioFocus::BufferSize;
|
||||||
let fps_focused = app.audio.focus == AudioFocus::RefreshRate;
|
let fps_focused = app.audio.focus == AudioFocus::RefreshRate;
|
||||||
let highlight_focused = app.audio.focus == AudioFocus::RuntimeHighlight;
|
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 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![
|
let rows = vec![
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
@@ -147,6 +151,14 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
Span::styled("Highlight", label_style),
|
Span::styled("Highlight", label_style),
|
||||||
render_selector(highlight_text, highlight_focused, highlight, normal),
|
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![
|
Row::new(vec![
|
||||||
Span::styled("Rate", label_style),
|
Span::styled("Rate", label_style),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use ratatui::Frame;
|
|||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::engine::SequencerSnapshot;
|
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) {
|
pub fn render(frame: &mut Frame, app: &mut App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||||
let [left_area, _spacer, vu_area] = Layout::horizontal([
|
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);
|
.areas(area);
|
||||||
|
|
||||||
let [scope_area, sequencer_area] = Layout::vertical([
|
let show_scope = app.audio.config.show_scope;
|
||||||
Constraint::Length(14),
|
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),
|
Constraint::Fill(1),
|
||||||
])
|
])
|
||||||
.areas(left_area);
|
.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_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_sequencer(frame, app, snapshot, sequencer_area);
|
||||||
render_vu_meter(frame, app, vu_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);
|
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) {
|
fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right);
|
let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right);
|
||||||
frame.render_widget(vu, area);
|
frame.render_widget(vu, area);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
mod confirm;
|
mod confirm;
|
||||||
mod modal;
|
mod modal;
|
||||||
mod scope;
|
mod scope;
|
||||||
|
mod spectrum;
|
||||||
mod text_input;
|
mod text_input;
|
||||||
mod vu_meter;
|
mod vu_meter;
|
||||||
|
|
||||||
pub use confirm::ConfirmModal;
|
pub use confirm::ConfirmModal;
|
||||||
pub use modal::ModalFrame;
|
pub use modal::ModalFrame;
|
||||||
pub use scope::{Orientation, Scope};
|
pub use scope::{Orientation, Scope};
|
||||||
|
pub use spectrum::Spectrum;
|
||||||
pub use text_input::TextInputModal;
|
pub use text_input::TextInputModal;
|
||||||
pub use vu_meter::VuMeter;
|
pub use vu_meter::VuMeter;
|
||||||
|
|||||||
62
src/widgets/spectrum.rs
Normal file
62
src/widgets/spectrum.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user