feat(tui): implement SomaFM terminal radio player
This commit is contained in:
1 parent
941e45a20f
commit
237de2efe9
21 files changed
+4937
No files matched your search
@@ -0,0 +1,202 @@
|
||||
"""The flashy bit: an ANSI-colour spectrum analyser / oscilloscope."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.text import Text
|
||||
from textual.widget import Widget
|
||||
|
||||
from ..spectrum import Frame, SpectrumEngine
|
||||
from .common import LEVELS, SPECTRUM_STOPS, gradient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import SomaFMApp
|
||||
|
||||
|
||||
class SpectrumPanel(Widget):
|
||||
can_focus = False
|
||||
|
||||
def __init__(self, engine: SpectrumEngine) -> None:
|
||||
super().__init__()
|
||||
self.engine = engine
|
||||
self.border_title = "SPECTRUM"
|
||||
self._palettes: dict[tuple[int, int, bool], list[str]] = {}
|
||||
|
||||
# Called from the app ticker so the border title tracks the analyser mode.
|
||||
def sync(self, mode: str, status: str) -> None:
|
||||
label = {"both": "MIRROR", "bars": "BARS", "wave": "WAVE"}.get(
|
||||
mode, mode.upper()
|
||||
)
|
||||
title = f"SPECTRUM · {label} · {status}"
|
||||
if self.border_title != title:
|
||||
self.border_title = title
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
app = cast("SomaFMApp", self.app)
|
||||
frame = self.engine.snapshot()
|
||||
width = max(8, self.size.width)
|
||||
height = max(2, self.size.height)
|
||||
mode = getattr(app, "visualizer", "both")
|
||||
phase = int(getattr(app, "tick_count", 0) // 2)
|
||||
dim = frame.status == "idle"
|
||||
|
||||
lines: list[Text]
|
||||
if mode == "wave":
|
||||
lines = self._wave(frame, width, height, phase, dim)
|
||||
elif mode == "bars" or height < 8:
|
||||
lines = self._bars(frame, width, height, phase, dim)
|
||||
else:
|
||||
# Default mode: a full-height mirrored spectrum. The top half
|
||||
# grows up from the centre line, the bottom half grows down from
|
||||
# it (same values, flipped rows, dimmed like a reflection).
|
||||
top_height = height // 2
|
||||
bottom_height = height - top_height
|
||||
lines = self._bars(frame, width, top_height, phase, dim)
|
||||
mirrored = self._bars(
|
||||
frame, width, bottom_height, phase + 5, True, peaks=False
|
||||
)
|
||||
mirrored.reverse()
|
||||
lines.extend(mirrored)
|
||||
return Group(*lines)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# drawing
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _row_palette(self, height: int, phase: int, dim: bool) -> list[str]:
|
||||
key = (height, phase % 12, dim)
|
||||
cached = self._palettes.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
palette: list[str] = []
|
||||
for row in range(height):
|
||||
position = (height - 1 - row) / max(1, height - 1)
|
||||
colour = gradient(SPECTRUM_STOPS, position)
|
||||
style = f"bold {colour}" if (row + phase) % 7 == 0 else colour
|
||||
if dim:
|
||||
style = f"dim {style}"
|
||||
palette.append(style)
|
||||
if len(self._palettes) > 96:
|
||||
self._palettes.clear()
|
||||
self._palettes[key] = palette
|
||||
return palette
|
||||
|
||||
@staticmethod
|
||||
def _resample(values: Sequence[float], width: int) -> list[float]:
|
||||
count = len(values)
|
||||
if count == 0:
|
||||
return [0.0] * width
|
||||
if width <= 1 or count == 1:
|
||||
return [float(values[0])] * width
|
||||
last = count - 1
|
||||
output: list[float] = []
|
||||
for column in range(width):
|
||||
position = column * last / (width - 1)
|
||||
low = int(position)
|
||||
high = min(low + 1, last)
|
||||
fraction = position - low
|
||||
output.append(values[low] * (1.0 - fraction) + values[high] * fraction)
|
||||
return output
|
||||
|
||||
@staticmethod
|
||||
def _resample_mean(values: Sequence[float], width: int) -> list[float]:
|
||||
count = len(values)
|
||||
if count == 0:
|
||||
return [0.0] * width
|
||||
output: list[float] = []
|
||||
for column in range(width):
|
||||
low = int(column * count / width)
|
||||
high = max(low + 1, int((column + 1) * count / width))
|
||||
high = min(high, count)
|
||||
chunk = values[low:high]
|
||||
output.append(sum(chunk) / len(chunk) if chunk else 0.0)
|
||||
return output
|
||||
|
||||
def _bars(
|
||||
self,
|
||||
frame: Frame,
|
||||
width: int,
|
||||
height: int,
|
||||
phase: int,
|
||||
dim: bool,
|
||||
peaks: bool = True,
|
||||
) -> list[Text]:
|
||||
values = self._resample(frame.bands, width)
|
||||
peaks = self._resample(frame.peaks, width)
|
||||
palette = self._row_palette(height, phase, dim)
|
||||
scale = height * 8.0
|
||||
|
||||
lines: list[Text] = []
|
||||
for row in range(height):
|
||||
line = Text(no_wrap=True)
|
||||
row_top = (height - row - 1) * 8.0
|
||||
style = palette[row]
|
||||
for column in range(width):
|
||||
units = min(values[column], 1.0) * scale - row_top
|
||||
peak_units = min(peaks[column], 1.0) * scale - row_top
|
||||
if units >= 8.0:
|
||||
line.append("█", style=style)
|
||||
elif units > 0.0:
|
||||
level = max(1, min(7, int(units)))
|
||||
line.append(LEVELS[level], style=style)
|
||||
elif (
|
||||
peaks
|
||||
and 0.0 <= peak_units < 8.0
|
||||
and peak_units > units + 1.0
|
||||
):
|
||||
line.append("▁", style="dim" if dim else "bold")
|
||||
else:
|
||||
line.append(" ", style="")
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
def _wave(
|
||||
self, frame: Frame, width: int, height: int, phase: int, dim: bool
|
||||
) -> list[Text]:
|
||||
wave = self._resample_mean(frame.wave, width)
|
||||
# The engine normalises each chunk, but smoothing and averaging make
|
||||
# the trace sit around the middle. A gentle display gain lets the
|
||||
# oscilloscope use the full height without amplifying silence into
|
||||
# noise.
|
||||
peak = max((abs(value) for value in wave), default=0.0)
|
||||
if peak > 0.02:
|
||||
gain = min(2.5, 0.9 / peak)
|
||||
wave = [value * gain for value in wave]
|
||||
palette = self._row_palette(height, phase, dim)
|
||||
centre = (height - 1) / 2.0
|
||||
amplitude = max(0.75, (height - 1) / 2.0)
|
||||
|
||||
grid = [[" " for _ in range(width)] for _ in range(height)]
|
||||
previous_row: int | None = None
|
||||
for column, value in enumerate(wave):
|
||||
clamped = max(-1.0, min(1.0, float(value)))
|
||||
position = centre - clamped * amplitude
|
||||
row = max(0, min(height - 1, round(position)))
|
||||
grid[row][column] = "█"
|
||||
delta = position - row
|
||||
if delta > 0.35 and row + 1 < height:
|
||||
grid[row + 1][column] = "▄"
|
||||
elif delta < -0.35 and row - 1 >= 0:
|
||||
grid[row - 1][column] = "▀"
|
||||
if previous_row is not None and previous_row != row:
|
||||
low, high = sorted((previous_row, row))
|
||||
for between in range(low + 1, high):
|
||||
if grid[between][column] == " ":
|
||||
grid[between][column] = "│"
|
||||
previous_row = row
|
||||
|
||||
lines: list[Text] = []
|
||||
for row in range(height):
|
||||
line = Text(no_wrap=True)
|
||||
style = palette[row]
|
||||
for char in grid[row]:
|
||||
if char == " ":
|
||||
line.append(" ", style="")
|
||||
else:
|
||||
line.append(char, style=style)
|
||||
lines.append(line)
|
||||
return lines
|
||||
Reference in new issue
Block a user