use crate::error::TalkError;
pub const PEAK_DECAY: f32 = 0.998;
pub const PEAK_FLOOR: f32 = 0.0001;
pub const FFT_SIZE: usize = 2048;
pub const FREQ_MIN: f32 = 80.0;
pub const FREQ_MAX: f32 = 8000.0;
pub const FREQ_NOISE_FLOOR: f32 = 0.01;
pub const WATERFALL_COLUMNS: usize = 4096;
pub const WATERFALL_ROWS: usize = 64;
pub use crate::audio::ring_buffer::RingBuffer;
#[derive(Clone, Copy)]
pub struct Complex {
pub re: f32,
pub im: f32,
}
impl Complex {
pub fn new(re: f32, im: f32) -> Self {
Self { re, im }
}
pub fn magnitude(self) -> f32 {
(self.re * self.re + self.im * self.im).sqrt()
}
}
impl std::ops::Add for Complex {
type Output = Self;
fn add(self, rhs: Self) -> Self {
Self::new(self.re + rhs.re, self.im + rhs.im)
}
}
impl std::ops::Sub for Complex {
type Output = Self;
fn sub(self, rhs: Self) -> Self {
Self::new(self.re - rhs.re, self.im - rhs.im)
}
}
impl std::ops::Mul for Complex {
type Output = Self;
fn mul(self, rhs: Self) -> Self {
Self::new(
self.re * rhs.re - self.im * rhs.im,
self.re * rhs.im + self.im * rhs.re,
)
}
}
pub fn fft_in_place(buf: &mut [Complex]) {
let n = buf.len();
debug_assert!(n.is_power_of_two());
let mut j = 0usize;
for i in 1..n {
let mut bit = n >> 1;
while j & bit != 0 {
j ^= bit;
bit >>= 1;
}
j ^= bit;
if i < j {
buf.swap(i, j);
}
}
let mut len = 2;
while len <= n {
let half = len / 2;
let angle = -2.0 * std::f32::consts::PI / len as f32;
let wn = Complex::new(angle.cos(), angle.sin());
let mut start = 0;
while start < n {
let mut w = Complex::new(1.0, 0.0);
for k in 0..half {
let u = buf[start + k];
let v = buf[start + k + half] * w;
buf[start + k] = u + v;
buf[start + k + half] = u - v;
w = w * wn;
}
start += len;
}
len <<= 1;
}
}
pub fn compute_spectrum(samples: &[f32]) -> Vec<f32> {
let n = samples.len();
let mut buf: Vec<Complex> = samples
.iter()
.enumerate()
.map(|(i, &s)| {
let w = 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / n as f32).cos());
Complex::new(s * w, 0.0)
})
.collect();
fft_in_place(&mut buf);
buf[..n / 2].iter().map(|c| c.magnitude()).collect()
}
pub struct PixelBuffer {
pub data: Vec<u8>,
pub width: usize,
pub height: usize,
}
impl PixelBuffer {
pub fn new(width: usize, height: usize) -> Self {
Self {
data: vec![0u8; width * height * 4],
width,
height,
}
}
pub fn clear(&mut self, color: [u8; 4]) {
for pixel in self.data.chunks_exact_mut(4) {
pixel.copy_from_slice(&color);
}
}
pub fn set_pixel(&mut self, x: usize, y: usize, color: [u8; 4]) {
if x < self.width && y < self.height {
let off = (y * self.width + x) * 4;
self.data[off..off + 4].copy_from_slice(&color);
}
}
pub fn blend_pixel(&mut self, x: usize, y: usize, color: [u8; 4], opacity: f32) {
if x >= self.width || y >= self.height {
return;
}
let a = opacity.clamp(0.0, 1.0);
if a <= f32::EPSILON {
return;
}
let off = (y * self.width + x) * 4;
for (c, &fg_val) in color.iter().enumerate().take(3) {
let bg_val = self.data[off + c] as f32;
self.data[off + c] = (bg_val + (fg_val as f32 - bg_val) * a) as u8;
}
}
pub fn clear_rounded(&mut self, fg: [u8; 4], radius: usize) {
let w = self.width;
let h = self.height;
let r = radius.min(w / 2).min(h / 2);
let r2 = (r * r) as i64;
for b in self.data.iter_mut() {
*b = 0;
}
for y in 0..h {
for x in 0..w {
let inside = if x < r && y < r {
let dx = r as i64 - x as i64;
let dy = r as i64 - y as i64;
dx * dx + dy * dy <= r2
} else if x >= w - r && y < r {
let dx = x as i64 - (w - r - 1) as i64;
let dy = r as i64 - y as i64;
dx * dx + dy * dy <= r2
} else if x < r && y >= h - r {
let dx = r as i64 - x as i64;
let dy = y as i64 - (h - r - 1) as i64;
dx * dx + dy * dy <= r2
} else if x >= w - r && y >= h - r {
let dx = x as i64 - (w - r - 1) as i64;
let dy = y as i64 - (h - r - 1) as i64;
dx * dx + dy * dy <= r2
} else {
true
};
if inside {
let off = (y * w + x) * 4;
self.data[off..off + 4].copy_from_slice(&fg);
}
}
}
}
pub fn fill_rect(&mut self, x: usize, y: usize, w: usize, h: usize, color: [u8; 4]) {
for dy in 0..h {
for dx in 0..w {
self.set_pixel(x + dx, y + dy, color);
}
}
}
}
pub fn rms(samples: &[f32]) -> f32 {
if samples.is_empty() {
return 0.0;
}
let sum_sq: f32 = samples.iter().map(|s| s * s).sum();
(sum_sq / samples.len() as f32).sqrt()
}
pub fn heat_map_color(norm: f32, brightness: f32) -> [u8; 4] {
let norm = norm.clamp(0.0, 1.0);
let alpha = (brightness * 255.0).clamp(0.0, 255.0) as u8;
if alpha == 0 {
return [0, 0, 0, 0];
}
let (r, g, b) = if norm < 0.2 {
let t = norm / 0.2;
(0.0, 0.0, t)
} else if norm < 0.4 {
let t = (norm - 0.2) / 0.2;
(0.0, t, 1.0)
} else if norm < 0.6 {
let t = (norm - 0.4) / 0.2;
(0.0, 1.0, 1.0 - t)
} else if norm < 0.8 {
let t = (norm - 0.6) / 0.2;
(t, 1.0, 0.0)
} else {
let t = (norm - 0.8) / 0.2;
(1.0, 1.0 - t, 0.0)
};
let af = brightness;
[
(b * af * 255.0) as u8,
(g * af * 255.0) as u8,
(r * af * 255.0) as u8,
alpha,
]
}
pub fn level_color(norm: f32) -> [u8; 4] {
let norm = norm.clamp(0.0, 1.0);
let (r, g) = if norm < 0.5 {
let t = norm / 0.5;
(t, 1.0)
} else {
let t = (norm - 0.5) / 0.5;
(1.0, 1.0 - t)
};
[0, (g * 255.0) as u8, (r * 255.0) as u8, 0xFF]
}
pub fn lerp_color(a: [u8; 4], b: [u8; 4], t: f32) -> [u8; 4] {
let t = t.clamp(0.0, 1.0);
[
(a[0] as f32 + (b[0] as f32 - a[0] as f32) * t) as u8,
(a[1] as f32 + (b[1] as f32 - a[1] as f32) * t) as u8,
(a[2] as f32 + (b[2] as f32 - a[2] as f32) * t) as u8,
0xFF,
]
}
pub fn detect_is_dark_theme() -> bool {
match dark_light::detect() {
Ok(dark_light::Mode::Light) => {
log::debug!("theme detection: light (via dark-light)");
false
}
Ok(dark_light::Mode::Dark) => {
log::debug!("theme detection: dark (via dark-light)");
true
}
_ => {
if let Ok(theme) = std::env::var("GTK_THEME") {
let lower = theme.to_ascii_lowercase();
if lower.contains("dark") {
log::debug!("theme detection: dark (GTK_THEME={:?})", theme);
return true;
}
if lower.contains("light") {
log::debug!("theme detection: light (GTK_THEME={:?})", theme);
return false;
}
}
log::debug!("theme detection: defaulting to dark");
true
}
}
}
pub fn monochrome_palette() -> ([u8; 4], [u8; 4]) {
if detect_is_dark_theme() {
([0xFF, 0xFF, 0xFF, 0xFF], [0x00, 0x00, 0x00, 0xFF])
} else {
([0x00, 0x00, 0x00, 0xFF], [0xFF, 0xFF, 0xFF, 0xFF])
}
}
pub fn apply_rounded_shape(
conn: &impl x11rb::connection::Connection,
win: u32,
w: u16,
h: u16,
radius: usize,
) -> Result<(), TalkError> {
use x11rb::protocol::shape;
use x11rb::protocol::xproto::*;
let pixmap: Pixmap = conn
.generate_id()
.map_err(|e| TalkError::Config(format!("X11 id: {}", e)))?;
conn.create_pixmap(1, pixmap, win, w, h)
.map_err(|e| TalkError::Config(format!("X11 create pixmap: {}", e)))?;
let gc: Gcontext = conn
.generate_id()
.map_err(|e| TalkError::Config(format!("X11 id: {}", e)))?;
conn.create_gc(gc, pixmap, &CreateGCAux::new().foreground(0))
.map_err(|e| TalkError::Config(format!("X11 gc: {}", e)))?;
conn.poly_fill_rectangle(
pixmap,
gc,
&[Rectangle {
x: 0,
y: 0,
width: w,
height: h,
}],
)
.map_err(|e| TalkError::Config(format!("X11 fill: {}", e)))?;
conn.change_gc(gc, &ChangeGCAux::new().foreground(1))
.map_err(|e| TalkError::Config(format!("X11 change gc: {}", e)))?;
let r = (radius as u16).min(w / 2).min(h / 2);
let d = r * 2;
conn.poly_fill_rectangle(
pixmap,
gc,
&[
Rectangle {
x: 0,
y: r as i16,
width: w,
height: h - d,
},
Rectangle {
x: r as i16,
y: 0,
width: w - d,
height: r,
},
Rectangle {
x: r as i16,
y: (h - r) as i16,
width: w - d,
height: r,
},
],
)
.map_err(|e| TalkError::Config(format!("X11 fill: {}", e)))?;
conn.poly_fill_arc(
pixmap,
gc,
&[
x11rb::protocol::xproto::Arc {
x: 0,
y: 0,
width: d,
height: d,
angle1: 90 * 64,
angle2: 90 * 64,
},
x11rb::protocol::xproto::Arc {
x: (w - d) as i16,
y: 0,
width: d,
height: d,
angle1: 0,
angle2: 90 * 64,
},
x11rb::protocol::xproto::Arc {
x: 0,
y: (h - d) as i16,
width: d,
height: d,
angle1: 180 * 64,
angle2: 90 * 64,
},
x11rb::protocol::xproto::Arc {
x: (w - d) as i16,
y: (h - d) as i16,
width: d,
height: d,
angle1: 270 * 64,
angle2: 90 * 64,
},
],
)
.map_err(|e| TalkError::Config(format!("X11 fill arc: {}", e)))?;
shape::mask(conn, shape::SO::SET, shape::SK::BOUNDING, win, 0, 0, pixmap)
.map_err(|e| TalkError::Config(format!("X11 shape mask: {}", e)))?;
conn.free_gc(gc)
.map_err(|e| TalkError::Config(format!("X11 free gc: {}", e)))?;
conn.free_pixmap(pixmap)
.map_err(|e| TalkError::Config(format!("X11 free pixmap: {}", e)))?;
Ok(())
}
pub const FONT_SEARCH_PATHS: &[&str] = &[
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
];
pub fn load_system_font(scale: f32) -> Option<fontdue::Font> {
for path in FONT_SEARCH_PATHS {
if let Ok(data) = std::fs::read(path) {
let settings = fontdue::FontSettings {
collection_index: 0,
scale,
load_substitutions: true,
};
match fontdue::Font::from_bytes(data, settings) {
Ok(font) => {
log::debug!("loaded font from {}", path);
return Some(font);
}
Err(e) => {
log::warn!("failed to parse font {}: {}", path, e);
}
}
}
}
log::warn!("no system font found");
None
}
pub fn rasterise_glyphs(
text: &str,
font: &fontdue::Font,
font_size: f32,
) -> (Vec<(fontdue::Metrics, Vec<u8>)>, usize) {
let mut glyphs: Vec<(fontdue::Metrics, Vec<u8>)> = Vec::new();
let mut total_w: usize = 0;
for ch in text.chars() {
let (metrics, bitmap) = font.rasterize(ch, font_size);
total_w += metrics.advance_width as usize;
glyphs.push((metrics, bitmap));
}
(glyphs, total_w)
}
pub fn blit_glyphs(
pb: &mut PixelBuffer,
glyphs: &[(fontdue::Metrics, Vec<u8>)],
start_x: i32,
color: [u8; 4],
opacity: f32,
) {
let h = pb.height;
let w = pb.width;
let baseline = (h as i32 * 3) / 4;
let mut cursor_x = start_x;
for (metrics, bitmap) in glyphs {
blit_glyph_at(
pb, metrics, bitmap, cursor_x, baseline, w, h, color, opacity,
);
cursor_x += metrics.advance_width as i32;
}
}
#[allow(clippy::too_many_arguments)]
pub fn blit_glyph_at(
pb: &mut PixelBuffer,
metrics: &fontdue::Metrics,
bitmap: &[u8],
cursor_x: i32,
baseline: i32,
buf_w: usize,
buf_h: usize,
color: [u8; 4],
opacity: f32,
) {
let gx = cursor_x + metrics.xmin;
let gy = baseline - metrics.height as i32 - metrics.ymin;
for row in 0..metrics.height {
for col in 0..metrics.width {
let alpha = bitmap[row * metrics.width + col];
if alpha == 0 {
continue;
}
let px = gx + col as i32;
let py = gy + row as i32;
if px >= 0 && (px as usize) < buf_w && py >= 0 && (py as usize) < buf_h {
let off = (py as usize * buf_w + px as usize) * 4;
let a = alpha as f32 / 255.0 * opacity;
for (c, &fg_val) in color.iter().enumerate().take(3) {
let bg_val = pb.data[off + c] as f32;
pb.data[off + c] = (bg_val + (fg_val as f32 - bg_val) * a) as u8;
}
}
}
}
}
pub fn map_spectrum_to_column(
magnitudes: &[f32],
num_rows: usize,
sample_rate: u32,
freq_max: f32,
) -> Vec<f32> {
let n_bins = magnitudes.len();
if n_bins == 0 || num_rows == 0 {
return vec![0.0; num_rows];
}
let nyquist = sample_rate as f32 / 2.0;
let f_max = freq_max.min(nyquist);
let log_min = FREQ_MIN.ln();
let log_max = f_max.ln();
let bin_centers: Vec<f32> = (0..num_rows)
.map(|row| {
let t = if num_rows > 1 {
row as f32 / (num_rows - 1) as f32
} else {
0.5
};
let freq = (log_min + t * (log_max - log_min)).exp();
(freq / nyquist * n_bins as f32).clamp(0.0, (n_bins - 1) as f32)
})
.collect();
let mut column = Vec::with_capacity(num_rows);
for row in 0..num_rows {
let lo = if row == 0 {
bin_centers[0]
} else {
(bin_centers[row - 1] + bin_centers[row]) * 0.5
};
let hi = if row + 1 >= num_rows {
bin_centers[num_rows - 1]
} else {
(bin_centers[row] + bin_centers[row + 1]) * 0.5
};
let bin_start = (lo.floor() as usize).min(n_bins - 1);
let bin_end = ((hi.ceil() as usize) + 1).min(n_bins);
let bin_end = bin_end.max(bin_start + 1);
let peak_val: f32 = magnitudes[bin_start..bin_end]
.iter()
.copied()
.fold(0.0f32, f32::max);
column.push(peak_val);
}
column
}
pub fn generate_waterfall_columns(samples: &[i16], sample_rate: u32) -> (Vec<Vec<f32>>, f32) {
let padded: Vec<f32> = if samples.len() < FFT_SIZE {
let mut buf = Vec::with_capacity(FFT_SIZE);
for &s in samples {
buf.push(s as f32 / 32768.0);
}
buf.resize(FFT_SIZE, 0.0);
buf
} else {
samples.iter().map(|&s| s as f32 / 32768.0).collect()
};
let num_columns = if padded.len() <= FFT_SIZE {
1
} else {
WATERFALL_COLUMNS
};
let hop = padded.len().saturating_sub(FFT_SIZE) / (num_columns.max(2) - 1).max(1);
let mut columns = Vec::with_capacity(num_columns);
let mut global_peak: f32 = 0.0;
for col in 0..num_columns {
let start = (col * hop).min(padded.len().saturating_sub(FFT_SIZE));
let window = &padded[start..start + FFT_SIZE];
let magnitudes = compute_spectrum(window);
let frame_peak = magnitudes.iter().copied().fold(0.0f32, f32::max);
if frame_peak > global_peak {
global_peak = frame_peak;
}
let column = map_spectrum_to_column(&magnitudes, WATERFALL_ROWS, sample_rate, FREQ_MAX);
columns.push(column);
}
if columns.len() == 1 && WATERFALL_COLUMNS > 1 {
let single = columns[0].clone();
columns.resize(WATERFALL_COLUMNS, single);
}
(columns, global_peak)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fft_dc_signal() {
let mut buf: Vec<Complex> = (0..16).map(|_| Complex::new(1.0, 0.0)).collect();
fft_in_place(&mut buf);
let dc = buf[0].magnitude();
let rest_max = buf[1..]
.iter()
.map(|c| c.magnitude())
.fold(0.0f32, f32::max);
assert!(
dc > rest_max * 100.0,
"DC bin should dominate: dc={}, rest_max={}",
dc,
rest_max
);
}
#[test]
fn fft_sine_peak() {
let n = 16usize;
let freq = 4.0;
let mut buf: Vec<Complex> = (0..n)
.map(|i| {
let t = i as f32 / n as f32;
Complex::new((2.0 * std::f32::consts::PI * freq * t).sin(), 0.0)
})
.collect();
fft_in_place(&mut buf);
let peak_bin = buf[..n / 2]
.iter()
.enumerate()
.max_by(|a, b| a.1.magnitude().partial_cmp(&b.1.magnitude()).unwrap())
.map(|(i, _)| i)
.unwrap();
assert_eq!(peak_bin, 4);
}
#[test]
fn compute_spectrum_returns_half_length() {
let samples = vec![0.0f32; 256];
let mags = compute_spectrum(&samples);
assert_eq!(mags.len(), 128);
}
#[test]
fn pixel_buffer_clear() {
let mut pb = PixelBuffer::new(4, 4);
pb.clear([0xFF, 0x00, 0x00, 0xFF]);
for chunk in pb.data.chunks_exact(4) {
assert_eq!(chunk, &[0xFF, 0x00, 0x00, 0xFF]);
}
}
#[test]
fn pixel_buffer_set_pixel_bounds() {
let mut pb = PixelBuffer::new(4, 4);
pb.clear([0; 4]);
pb.set_pixel(3, 3, [1, 2, 3, 4]);
let off = (3 * 4 + 3) * 4;
assert_eq!(&pb.data[off..off + 4], &[1, 2, 3, 4]);
pb.set_pixel(10, 10, [0xFF; 4]);
}
#[test]
fn rms_of_silence_is_zero() {
assert!((rms(&[0.0; 100])).abs() < f32::EPSILON);
}
#[test]
fn rms_of_constant_is_value() {
let val = 0.5f32;
let samples = vec![val; 200];
assert!((rms(&samples) - val).abs() < 1e-6);
}
#[test]
fn rms_empty_is_zero() {
assert!((rms(&[])).abs() < f32::EPSILON);
}
#[test]
fn blend_pixel_at_full_opacity_overwrites_bgr() {
let mut pb = PixelBuffer::new(2, 2);
pb.set_pixel(0, 0, [10, 20, 30, 40]); pb.blend_pixel(0, 0, [200, 100, 50, 0], 1.0);
let off = 0;
assert_eq!(pb.data[off], 200);
assert_eq!(pb.data[off + 1], 100);
assert_eq!(pb.data[off + 2], 50);
assert_eq!(pb.data[off + 3], 40);
}
#[test]
fn blend_pixel_at_zero_opacity_is_noop() {
let mut pb = PixelBuffer::new(2, 2);
pb.set_pixel(0, 0, [10, 20, 30, 40]);
pb.blend_pixel(0, 0, [200, 100, 50, 0], 0.0);
assert_eq!(&pb.data[0..4], &[10, 20, 30, 40]);
}
#[test]
fn blend_pixel_at_half_opacity_averages_bgr() {
let mut pb = PixelBuffer::new(2, 2);
pb.set_pixel(0, 0, [0, 0, 0, 0]); pb.blend_pixel(0, 0, [100, 200, 50, 0], 0.5);
assert_eq!(pb.data[0], 50);
assert_eq!(pb.data[1], 100);
assert_eq!(pb.data[2], 25);
}
#[test]
fn blend_pixel_out_of_bounds_is_noop() {
let mut pb = PixelBuffer::new(2, 2);
pb.blend_pixel(5, 5, [255, 255, 255, 255], 1.0);
for b in &pb.data {
assert_eq!(*b, 0);
}
}
#[test]
fn blend_pixel_clamps_opacity() {
let mut pb = PixelBuffer::new(2, 2);
pb.set_pixel(0, 0, [0, 0, 0, 0]);
pb.blend_pixel(0, 0, [100, 200, 50, 0], 2.0); assert_eq!(pb.data[0], 100);
assert_eq!(pb.data[1], 200);
assert_eq!(pb.data[2], 50);
}
}