15 Commits
Author SHA1 Message Date
Stevan Freeborn ddd3fee887 fix: use description instead of name 2026-07-27 22:04:14 -05:00
Stevan Freeborn b6a59f6563 chore: declare apt dependency for linux builds 2026-07-27 21:54:37 -05:00
Stevan Freeborn 575b70d4ba chore: update cargo dist 2026-07-27 21:43:16 -05:00
Stevan b0a46f0710 Merge pull request 'feat: add audio visualizer' (#3) from stevanfreeborn/feat/add-audio-visualizer into main 2026-07-28 02:36:03 +00:00
Stevan Freeborn 3b0cb7e82b chore: bump version 2026-07-27 21:35:12 -05:00
Stevan Freeborn 4d4c157f3a feat: apply my theme colors to app 2026-07-27 17:43:56 -05:00
Stevan Freeborn b047bd4324 fix: add semi colon and clean up whitespace 2026-07-27 17:38:41 -05:00
Stevan Freeborn 366c481827 fix: prever copied over cloned 2026-07-27 17:37:04 -05:00
Stevan Freeborn 01765bf115 tests: add test for with_visualizer 2026-07-27 17:36:33 -05:00
Stevan Freeborn c440f75a4a fix: account for all options when collecting positional arguments 2026-07-27 17:36:02 -05:00
Stevan Freeborn 714aebc2ed fix: clamp bar values to width that is available 2026-07-27 17:34:38 -05:00
Stevan Freeborn e3edf57745 fix: use proper arc method 2026-07-27 17:32:53 -05:00
Stevan Freeborn 627969a23b fix: pass flag to clone 2026-07-27 17:32:24 -05:00
Stevan Freeborn b7f34f795e fix: address thread shutdown
- replace unwrap() calls with checks
- add stop flag to allow thread shutdown
- implement drop trait to automatically set the stop flag
2026-07-27 17:31:47 -05:00
Stevan Freeborn 8c559d605e chore: remove pull request trigger from workflow 2026-07-27 17:17:23 -05:00
5 changed files with 102 additions and 50 deletions
+12 -12
View File
@@ -64,9 +64,9 @@ jobs:
# we specify bash to get pipefail; it guards against the `curl` command # we specify bash to get pipefail; it guards against the `curl` command
# failing. otherwise `sh` won't catch that `curl` returned non-0 # failing. otherwise `sh` won't catch that `curl` returned non-0
shell: bash shell: bash
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh" run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh"
- name: Cache dist - name: Cache dist
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v6
with: with:
name: cargo-dist-cache name: cargo-dist-cache
path: ~/.cargo/bin/dist path: ~/.cargo/bin/dist
@@ -82,7 +82,7 @@ jobs:
cat plan-dist-manifest.json cat plan-dist-manifest.json
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json" - name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v6
with: with:
name: artifacts-plan-dist-manifest name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json path: plan-dist-manifest.json
@@ -131,7 +131,7 @@ jobs:
run: ${{ matrix.install_dist.run }} run: ${{ matrix.install_dist.run }}
# Get the dist-manifest # Get the dist-manifest
- name: Fetch local artifacts - name: Fetch local artifacts
uses: actions/download-artifact@v8 uses: actions/download-artifact@v7
with: with:
pattern: artifacts-* pattern: artifacts-*
path: target/distrib/ path: target/distrib/
@@ -158,7 +158,7 @@ jobs:
cp dist-manifest.json "$BUILD_MANIFEST_NAME" cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts" - name: "Upload artifacts"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v6
with: with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }} name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: | path: |
@@ -180,14 +180,14 @@ jobs:
persist-credentials: false persist-credentials: false
submodules: recursive submodules: recursive
- name: Install cached dist - name: Install cached dist
uses: actions/download-artifact@v8 uses: actions/download-artifact@v7
with: with:
name: cargo-dist-cache name: cargo-dist-cache
path: ~/.cargo/bin/ path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist - run: chmod +x ~/.cargo/bin/dist
# Get all the local artifacts for the global tasks to use (for e.g. checksums) # Get all the local artifacts for the global tasks to use (for e.g. checksums)
- name: Fetch local artifacts - name: Fetch local artifacts
uses: actions/download-artifact@v8 uses: actions/download-artifact@v7
with: with:
pattern: artifacts-* pattern: artifacts-*
path: target/distrib/ path: target/distrib/
@@ -205,7 +205,7 @@ jobs:
cp dist-manifest.json "$BUILD_MANIFEST_NAME" cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts" - name: "Upload artifacts"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v6
with: with:
name: artifacts-build-global name: artifacts-build-global
path: | path: |
@@ -230,14 +230,14 @@ jobs:
persist-credentials: false persist-credentials: false
submodules: recursive submodules: recursive
- name: Install cached dist - name: Install cached dist
uses: actions/download-artifact@v8 uses: actions/download-artifact@v7
with: with:
name: cargo-dist-cache name: cargo-dist-cache
path: ~/.cargo/bin/ path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist - run: chmod +x ~/.cargo/bin/dist
# Fetch artifacts from scratch-storage # Fetch artifacts from scratch-storage
- name: Fetch artifacts - name: Fetch artifacts
uses: actions/download-artifact@v8 uses: actions/download-artifact@v7
with: with:
pattern: artifacts-* pattern: artifacts-*
path: target/distrib/ path: target/distrib/
@@ -250,14 +250,14 @@ jobs:
cat dist-manifest.json cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json" - name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v6
with: with:
# Overwrite the previous copy # Overwrite the previous copy
name: artifacts-dist-manifest name: artifacts-dist-manifest
path: dist-manifest.json path: dist-manifest.json
# Create a GitHub Release while uploading all files to it # Create a GitHub Release while uploading all files to it
- name: "Download GitHub Artifacts" - name: "Download GitHub Artifacts"
uses: actions/download-artifact@v8 uses: actions/download-artifact@v7
with: with:
pattern: artifacts-* pattern: artifacts-*
path: artifacts path: artifacts
Generated
+1 -1
View File
@@ -2120,7 +2120,7 @@ dependencies = [
[[package]] [[package]]
name = "termato" name = "termato"
version = "0.0.0" version = "0.1.0"
dependencies = [ dependencies = [
"cpal", "cpal",
"crossterm", "crossterm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "termato" name = "termato"
version = "0.0.0" version = "0.1.0"
edition = "2024" edition = "2024"
repository = "https://github.com/StevanFreeborn/termato" repository = "https://github.com/StevanFreeborn/termato"
+8 -1
View File
@@ -4,10 +4,17 @@ members = ["cargo:."]
# Config for 'dist' # Config for 'dist'
[dist] [dist]
# The preferred dist version to use in CI (Cargo.toml SemVer syntax) # The preferred dist version to use in CI (Cargo.toml SemVer syntax)
cargo-dist-version = "0.32.0" cargo-dist-version = "0.31.0"
# CI backends to support # CI backends to support
ci = "github" ci = "github"
# The installers to generate for each app # The installers to generate for each app
installers = ["shell", "powershell"] installers = ["shell", "powershell"]
# Target platforms to build apps for (Rust target-triple syntax) # Target platforms to build apps for (Rust target-triple syntax)
targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
# Path that installers should place binaries in
install-path = "CARGO_HOME"
# Whether to install an updater program
install-updater = true
[dist.dependencies.apt]
libasound2-dev = "*"
+72 -27
View File
@@ -1,6 +1,10 @@
use std::{ use std::{
io::{self, stdout}, io::{self, stdout},
sync::{Arc, Mutex, mpsc}, sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread, thread,
time::Duration, time::Duration,
}; };
@@ -143,8 +147,9 @@ impl Drop for TerminalGuard {
} }
} }
pub struct AudioVisualizer { struct AudioVisualizer {
pub bar_data: Arc<Mutex<Vec<u64>>>, bar_data: Arc<Mutex<Vec<u64>>>,
stop_flag: Arc<AtomicBool>,
} }
// This is a implementation largely lifted from these // This is a implementation largely lifted from these
@@ -152,18 +157,22 @@ pub struct AudioVisualizer {
// 1. CAVA (C): https://github.com/karlstav/cava // 1. CAVA (C): https://github.com/karlstav/cava
// 2. cli-visualizer (C++): https://github.com/dpayne/cli-visualizer // 2. cli-visualizer (C++): https://github.com/dpayne/cli-visualizer
impl AudioVisualizer { impl AudioVisualizer {
pub fn new(num_bars: usize) -> Self { fn new(num_bars: usize) -> Self {
//
let bar_data = Arc::new(Mutex::new(vec![0; num_bars])); let bar_data = Arc::new(Mutex::new(vec![0; num_bars]));
let bar_data_clone = Arc::clone(&bar_data); let bar_data_clone = Arc::clone(&bar_data);
let stop_flag = Arc::new(AtomicBool::new(false));
let stop_clone = Arc::clone(&stop_flag);
thread::spawn(move || { thread::spawn(move || {
if let Err(e) = Self::run_audio_loop(bar_data_clone, num_bars) { if let Err(e) = Self::run_audio_loop(bar_data_clone, num_bars, stop_clone) {
eprintln!("Audio capture error: {:?}", e); eprintln!("Audio capture error: {:?}", e);
} }
}); });
Self { bar_data } Self {
bar_data,
stop_flag,
}
} }
// Doing best effort to support cross-platform functionality // Doing best effort to support cross-platform functionality
@@ -185,9 +194,11 @@ impl AudioVisualizer {
{ {
if let Ok(devices) = host.devices() { if let Ok(devices) = host.devices() {
for dev in devices { for dev in devices {
if let Ok(name) = dev.name() { if let Ok(desc) = dev.description() {
// Monitor devices mirror system output under PulseAudio/PipeWire let name = desc.to_string();
if name.contains("monitor") { if name.contains("monitor") {
// Monitor devices mirror system output under PulseAudio/PipeWire
if let Ok(config) = dev.default_input_config() { if let Ok(config) = dev.default_input_config() {
return Ok((dev, config.into())); return Ok((dev, config.into()));
} }
@@ -304,11 +315,12 @@ impl AudioVisualizer {
smoothed smoothed
} }
// songs can be quiet and load so we try to adjust // songs can be quiet and loud so we try to adjust
// sensitivity to avoid bars becoming flattened or // sensitivity to avoid bars becoming flattened or
// clipped // clipped
fn adjust_autosens(autosens: &mut f32, bars: &[f32]) { fn adjust_autosens(autosens: &mut f32, bars: &[f32]) {
let max_val = bars.iter().cloned().fold(0.0f32, f32::max); let max_val = bars.iter().copied().fold(0.0f32, f32::max);
if max_val > 8.0 { if max_val > 8.0 {
*autosens *= 0.98; *autosens *= 0.98;
} else if max_val < 3.0 && *autosens < 3.0 { } else if max_val < 3.0 && *autosens < 3.0 {
@@ -321,6 +333,7 @@ impl AudioVisualizer {
fn run_audio_loop( fn run_audio_loop(
bar_data: Arc<Mutex<Vec<u64>>>, bar_data: Arc<Mutex<Vec<u64>>>,
num_bars: usize, num_bars: usize,
stop: Arc<AtomicBool>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let host = cpal::default_host(); let host = cpal::default_host();
let (device, config) = Self::get_audio_device(&host)?; let (device, config) = Self::get_audio_device(&host)?;
@@ -353,8 +366,7 @@ impl AudioVisualizer {
let stream = device.build_input_stream( let stream = device.build_input_stream(
config, config,
move |data: &[f32], _| { move |data: &[f32], _| {
let mut buf = buffer_clone.lock().unwrap(); if let Ok(mut buf) = buffer_clone.lock() {
for chunk in data.chunks(channels) { for chunk in data.chunks(channels) {
let mono: f32 = chunk.iter().sum::<f32>() / channels as f32; let mono: f32 = chunk.iter().sum::<f32>() / channels as f32;
buf.push(mono); buf.push(mono);
@@ -364,6 +376,7 @@ impl AudioVisualizer {
let drain_amt = buf.len() - chunk_size; let drain_amt = buf.len() - chunk_size;
buf.drain(0..drain_amt); buf.drain(0..drain_amt);
} }
}
}, },
|err| eprintln!("Stream error: {}", err), |err| eprintln!("Stream error: {}", err),
None, None,
@@ -371,11 +384,14 @@ impl AudioVisualizer {
stream.play()?; stream.play()?;
loop { while !stop.load(Ordering::Relaxed) {
thread::sleep(std::time::Duration::from_millis(16)); thread::sleep(std::time::Duration::from_millis(16));
let samples = { let samples = {
let buf = audio_buffer.lock().unwrap(); let buf = match audio_buffer.lock() {
Ok(b) => b,
Err(_) => continue,
};
if buf.len() < chunk_size { if buf.len() < chunk_size {
continue; continue;
} }
@@ -384,7 +400,10 @@ impl AudioVisualizer {
Self::apply_hann_window(&samples, &mut windowed_buffer); Self::apply_hann_window(&samples, &mut windowed_buffer);
fft.process(&mut windowed_buffer, &mut spectrum).unwrap(); if let Err(e) = fft.process(&mut windowed_buffer, &mut spectrum) {
eprintln!("FFT error: {:?}", e);
continue;
}
let raw_bars = Self::process_fft_magnitudes( let raw_bars = Self::process_fft_magnitudes(
&spectrum, &spectrum,
@@ -407,6 +426,14 @@ impl AudioVisualizer {
} }
} }
} }
Ok(())
}
}
impl Drop for AudioVisualizer {
fn drop(&mut self) {
self.stop_flag.store(true, Ordering::Relaxed);
} }
} }
@@ -422,8 +449,10 @@ impl AudioVisualizer {
fn render_visualizer(f: &mut Frame, area: Rect, bar_values: &[u64]) { fn render_visualizer(f: &mut Frame, area: Rect, bar_values: &[u64]) {
const BLOCKS: [&str; 8] = ["", "", "", "", "", "", "", ""]; const BLOCKS: [&str; 8] = ["", "", "", "", "", "", "", ""];
let max_bars = area.width as usize;
let line: String = bar_values let line: String = bar_values
.iter() .iter()
.take(max_bars)
.map(|&val| { .map(|&val| {
if val == 0 { if val == 0 {
" " " "
@@ -437,7 +466,7 @@ fn render_visualizer(f: &mut Frame, area: Rect, bar_values: &[u64]) {
.collect(); .collect();
let viz_paragraph = Paragraph::new(line) let viz_paragraph = Paragraph::new(line)
.style(Style::default().fg(Color::Cyan)) .style(Style::default().fg(Color::Rgb(200, 184, 224)))
.alignment(Alignment::Center); .alignment(Alignment::Center);
f.render_widget(viz_paragraph, area); f.render_widget(viz_paragraph, area);
@@ -480,11 +509,18 @@ fn main() -> Result<(), io::Error> {
None None
}; };
let positional_args: Vec<&String> = args let is_flag = |arg: &str| {
.iter() arg == "-n"
.skip(1) || arg == "--notify"
.filter(|arg| *arg != "-n" && *arg != "--notify" && *arg != "-z" && *arg != "--visualizer") || arg == "-z"
.collect(); || arg == "--visualizer"
|| arg == "-h"
|| arg == "--help"
|| arg == "-v"
|| arg == "--version"
};
let positional_args: Vec<&String> = args.iter().skip(1).filter(|arg| !is_flag(arg)).collect();
let work_mins = positional_args let work_mins = positional_args
.first() .first()
@@ -520,11 +556,11 @@ fn main() -> Result<(), io::Error> {
let time_str = format!("{:02}:{:02}", minutes, seconds); let time_str = format!("{:02}:{:02}", minutes, seconds);
let time_color = if !termato.is_running { let time_color = if !termato.is_running {
Color::Yellow Color::Rgb(212, 200, 122)
} else { } else {
match termato.mode { match termato.mode {
TimerMode::Work => Color::Red, TimerMode::Work => Color::Rgb(212, 115, 115),
TimerMode::Break => Color::Green, TimerMode::Break => Color::Rgb(126, 200, 192),
} }
}; };
@@ -609,7 +645,7 @@ fn main() -> Result<(), io::Error> {
let help_block = Block::default() let help_block = Block::default()
.title(" Controls ") .title(" Controls ")
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)); .border_style(Style::default().fg(Color::Rgb(58, 62, 70)));
let help_content = Paragraph::new( let help_content = Paragraph::new(
"[Space] Pause / Resume\n\ "[Space] Pause / Resume\n\
@@ -619,7 +655,7 @@ fn main() -> Result<(), io::Error> {
[Q] Quit", [Q] Quit",
) )
.block(help_block) .block(help_block)
.style(Style::default().fg(Color::DarkGray)) .style(Style::default().fg(Color::Rgb(224, 228, 232)))
.alignment(Alignment::Left); .alignment(Alignment::Left);
f.render_widget(Clear, popup_area); f.render_widget(Clear, popup_area);
@@ -798,4 +834,13 @@ mod tests {
let result = result.with_notifications(true); let result = result.with_notifications(true);
assert!(result.enable_notifications); assert!(result.enable_notifications);
} }
#[test]
fn termato_visualizer_when_called_it_should_verify_opt_in_behavior() {
let result = Termato::new(25, 5);
assert!(!result.enable_visualizer);
let result = result.with_visualizer(true);
assert!(result.enable_visualizer);
}
} }