spectrum
This commit is contained in:
@@ -28,3 +28,4 @@ arboard = "3"
|
||||
minimad = "0.13"
|
||||
crossbeam-channel = "0.5"
|
||||
confy = "2"
|
||||
rustfft = "6"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
12
src/input.rs
12
src/input.rs
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
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