Add real-time CLI spectrum visualizer - #8
Conversation
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>
There was a problem hiding this comment.
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
SpectrumAnalyzerfor rolling-window FFT analysis with log-spaced frequency bands and attack/decay smoothing. - Adds
SpectrumDisplay/SpectrumRendererto 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.
| if n >= self.fft_size: | ||
| self._buf[:] = mono[-self.fft_size :] | ||
| else: | ||
| self._buf = np.roll(self._buf, -n) | ||
| self._buf[-n:] = mono |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| cols = max(1, size.columns) | ||
| lines = max(1, size.lines) | ||
|
|
||
| status = StatusLine._format(self.state) |
There was a problem hiding this comment.
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>
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
cavaread 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.py—SpectrumAnalyzer: windowedrfft, 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.py—SpectrumRenderer+SpectrumDisplay: vectorized bottom-up bar rendering (▁▂▃▄▅▆▇█) and an in-place multi-row ANSI block (same cursor-up/erase pattern asui/launcher.py), reusingStatusLine._formatfor 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 exposeslatest_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-spectrumflag; the redraw runs on a fixed-rate asyncio task decoupled from chunk arrival, and teardown always cancels it and restores the terminal (mirrors the existingStatusLine.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.ruffclean.focus start --mockin a real terminal to see it animate.🤖 Generated with Claude Code