Skip to content

Add real-time CLI spectrum visualizer - #8

Merged
cubny merged 3 commits into
mainfrom
feat/cli-spectrum-visualizer
Jul 9, 2026
Merged

Add real-time CLI spectrum visualizer#8
cubny merged 3 commits into
mainfrom
feat/cli-spectrum-visualizer

Conversation

@cubny

@cubny cubny commented Jul 9, 2026

Copy link
Copy Markdown
Owner

What

Adds a classic bar-style audio equalizer that animates in the terminal while music plays — the feature requested in the screenshot (thin per-frequency bars). Black & white using Unicode block glyphs. On by default in an interactive terminal; disable with --no-spectrum.

![spectrum concept: bars rise from the bottom, distinct peaks per frequency band, status line as the bottom row]

Library research

There's no drop-in embeddable terminal-spectrum library for an existing Python playback pipeline — standalone tools like cava read from system audio devices, and the Python examples all roll their own FFT bars in matplotlib/PyQt GUIs. So this is built from the deps already present (numpy, scipy.fft, sounddevice).

How

  • analysis/realtime.pySpectrumAnalyzer: windowed rfft, log-spaced frequency bands (one per terminal column), dB normalization against a full-scale reference, and fast-attack / slow-decay smoothing so bars don't flicker.
  • ui/spectrum.pySpectrumRenderer + SpectrumDisplay: vectorized bottom-up bar rendering (▁▂▃▄▅▆▇█) and an in-place multi-row ANSI block (same cursor-up/erase pattern as ui/launcher.py), reusing StatusLine._format for the bottom status row. Handles resize, short-terminal fallback, and clean cursor restore on exit.
  • audio/output.py: a lock-free mono ring-buffer tap in the PortAudio callback exposes latest_samples(n) — the audio actually being heard. The renderer pulls the current window every frame at 25fps (the generator runs seconds ahead behind the playback buffer, so tapping the producer would be both frozen-between-chunks and out of sync). Cleared on pause so bars fall to the floor.
  • cli.py: --spectrum/--no-spectrum flag; the redraw runs on a fixed-rate asyncio task decoupled from chunk arrival, and teardown always cancels it and restores the terminal (mirrors the existing StatusLine.finish() guarantee). The PortAudio callback thread is never blocked.

Testing

  • tests/test_spectrum.py (17 tests): analyzer (tone peaks in the right band, silence, attack/decay), renderer (bottom-up bars, widths), display (ANSI brackets, cursor restore, short-terminal fallback), source-driven per-frame pull, ring-buffer capture/wrap/pause-clear, and the non-tty CLI gate.
  • Full suite: 90 passed, ruff clean.
  • Manual: verified end-to-end by driving the render pipeline with music-like signals (distinct peaks at fed 120 Hz / 440 Hz / 2 kHz tones). Live playback + keyboard interaction is inherently interactive; run focus start --mock in a real terminal to see it animate.

🤖 Generated with Claude Code

Add a classic bar-style audio equalizer that animates in the terminal
while music plays. Bars rise from the bottom using Unicode block glyphs
(black & white); on by default in an interactive terminal, disable with
--no-spectrum.

- SpectrumAnalyzer (analysis/realtime.py): windowed rfft, log-spaced
  frequency bands (one per column), dB normalization, fast-attack/
  slow-decay smoothing.
- SpectrumRenderer + SpectrumDisplay (ui/spectrum.py): vectorized
  bottom-up bar rendering and an in-place multi-row ANSI block reusing
  StatusLine._format for the status row.
- AudioOutput (audio/output.py): a lock-free ring buffer tap in the
  playback callback so the visualizer analyzes the audio actually being
  heard (the generator runs seconds ahead behind the buffer), pulled at
  25fps every frame rather than once per chunk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an interactive, real-time terminal spectrum visualizer to the Focus CLI playback experience, including FFT-based analysis, ANSI-based multi-row rendering, and an audio output tap to visualize the audio actually being played.

Changes:

  • Introduces SpectrumAnalyzer for rolling-window FFT analysis with log-spaced frequency bands and attack/decay smoothing.
  • Adds SpectrumDisplay/SpectrumRenderer to render per-column bars in the terminal and manage cursor/autowrap behavior.
  • Hooks the visualizer into the CLI (flag + fixed-rate asyncio redraw loop) and adds an audio-output ring-buffer tap plus tests.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/test_spectrum.py Adds analyzer/renderer/display tests plus CLI and audio-output tap coverage.
src/focus/ui/spectrum.py Implements terminal spectrum rendering and in-place multi-row display management.
src/focus/cli.py Adds --spectrum/--no-spectrum and drives redraw via an asyncio task in interactive mode.
src/focus/audio/output.py Adds a lock-free mono ring-buffer tap and latest_samples() for visualization.
src/focus/analysis/realtime.py Adds rolling-window FFT spectrum analysis for real-time visualization.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +62 to +66
if n >= self.fft_size:
self._buf[:] = mono[-self.fft_size :]
else:
self._buf = np.roll(self._buf, -n)
self._buf[-n:] = mono

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 62a5e66 — the buffer now shifts left in place, no allocation.

One correction on the premise though: this never ran per-frame. The 25fps render path calls source(self.analyzer.fft_size), which returns exactly fft_size samples, so push() always took the n >= fft_size branch (self._buf[:] = mono[-fft_size:], already in place). np.roll was only reachable for pushes smaller than the window. Worth fixing regardless, so it is — and pinned by tests, since the overlapping slice copy relies on a numpy guarantee that is easy to break by accident.

Comment thread tests/test_spectrum.py Outdated
Comment on lines +199 to +202
runner = CliRunner()
result = runner.invoke(main, ["start", "--mock", "--spectrum", "--duration", "60"])
# Duration < minimum is rejected cleanly; the point is no import/setup crash.
assert "\x1b[?25l" not in result.output

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in 62a5e66 — but the stated reason is off, and the real problem was worse.

--duration 60 is not rejected (the check is < 60), so this ran a real session. It did not take 60 seconds of wall clock, though: the duration check compares accumulated audio time, and the mock generator produces flat-out, so 60s of audio was generated in ~1.3s. What it did do is open a real sounddevice stream and play audio during the test suite.

So rather than swapping in a value < 60 (which would test the fast-fail path and quietly stop testing the spectrum gate at all), I stubbed _run_session so no audio device is touched, and made the test assert what it was actually meant to: that --spectrum / --no-spectrum thread through to the session, and that no ANSI block leaks into non-tty output. The duration fast-fail now has its own test with --duration 59 asserting a non-zero exit, as you suggested.

Comment thread src/focus/ui/spectrum.py Outdated
cols = max(1, size.columns)
lines = max(1, size.lines)

status = StatusLine._format(self.state)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed in 62a5e66. Promoted it to a public module-level format_status_line(state) in ui/transport.py, and pointed both StatusLine.render and SpectrumDisplay.render at it. tests/test_ui.py was already reaching into StatusLine._format too, so that is cleaned up as well — there are no references to the private helper left.

- Shift the analyzer's rolling window in place instead of np.roll, so
  pushes smaller than fft_size don't allocate. (The 25fps render path
  already took the in-place branch, since it pulls exactly fft_size
  samples; this covers the remaining callers.) Pinned by tests, as the
  overlapping slice copy is a subtle numpy guarantee.
- Promote StatusLine._format to a public module-level format_status_line,
  so SpectrumDisplay composes the status row without reaching across
  modules into a private helper.
- Stop the CLI gate test from opening a real sounddevice stream and
  playing audio during the suite: stub _run_session, and assert the
  --spectrum/--no-spectrum flag actually threads through. Cover the
  duration-below-minimum fast-fail separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cubny
cubny merged commit e5900e2 into main Jul 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants