8 Commits

2 changed files with 136 additions and 68 deletions

View File

@@ -2,7 +2,7 @@
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.* > *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with a white-hot → deep green gradient. Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with a white-hot → deep green gradient. Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh).
--- ---
@@ -12,44 +12,102 @@ A full-screen terminal news ticker that renders live global headlines in large O
python3 mainline.py # news stream python3 mainline.py # news stream
python3 mainline.py --poetry # literary consciousness mode python3 mainline.py --poetry # literary consciousness mode
python3 mainline.py -p # same python3 mainline.py -p # same
python3 mainline.py --firehose # dense rapid-fire headline mode
python3 mainline.py --refresh # force re-fetch (bypass cache)
``` ```
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately. First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache.
--- ---
## Config ## Config
At the top of `mainline.py`: All constants live in `engine/config.py`:
| Constant | Default | What it does | | Constant | Default | What it does |
|---|---|---| |---|---|---|
| `HEADLINE_LIMIT` | `1000` | Total headlines per session | | `HEADLINE_LIMIT` | `1000` | Total headlines per session |
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike | | `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
| `_FONT_PATH` | hardcoded path | Path to your OTF/TTF display font | | `FONT_PATH` | hardcoded path | Path to your OTF/TTF display font |
| `_FONT_SZ` | `60` | Font render size (affects block density) | | `FONT_SZ` | `60` | Font render size (affects block density) |
| `_RENDER_H` | `8` | Terminal rows per headline line | | `RENDER_H` | `8` | Terminal rows per headline line |
| `SSAA` | `4` | Super-sampling factor (render at 4× then downsample) |
| `SCROLL_DUR` | `5.625` | Seconds per headline |
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
| `GRAD_SPEED` | `0.08` | Gradient sweep speed (cycles/sec, ~12s full sweep) |
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON endpoint to poll |
| `NTFY_POLL_INTERVAL` | `15` | Seconds between ntfy polls |
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
**Font:** `_FONT_PATH` is hardcoded to a local path. Update it to point to whatever display font you want — anything with strong contrast and wide letterforms works well. **Font:** `FONT_PATH` is hardcoded to a local path. Update it to point to whatever display font you want — anything with strong contrast and wide letterforms works well.
--- ---
## How it works ## How it works
- Feeds are fetched and filtered on startup (sports and vapid content stripped) - Feeds are fetched and filtered on startup (sports and vapid content stripped); results are cached to `.mainline_cache_news.json` / `.mainline_cache_poetry.json` for fast restarts
- Headlines are rasterized via Pillow into half-block characters (`▀▄█ `) at the configured font size - Headlines are rasterized via Pillow with 4× SSAA into half-block characters (`▀▄█ `) at the configured font size
- A left-to-right ANSI gradient colors each character: white-hot leading edge trails off to near-black - A left-to-right ANSI gradient colors each character: white-hot leading edge trails off to near-black; the gradient sweeps continuously across the full scroll canvas
- Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts - Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame - The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically - The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
- An ntfy.sh poller runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
---
## Architecture
`mainline.py` is a thin entrypoint (venv bootstrap → `engine.app.main()`). All logic lives in the `engine/` package:
```
engine/
config.py constants, CLI flags, glyph tables
sources.py FEEDS, POETRY_SOURCES, language/script maps
terminal.py ANSI codes, tw/th, type_out, boot_ln
filter.py HTML stripping, content filter
translate.py Google Translate wrapper + region detection
render.py OTF → half-block pipeline (SSAA, gradient)
effects.py noise, glitch_bar, fade, firehose
fetch.py RSS/Gutenberg fetching + cache load/save
ntfy.py NtfyPoller — standalone, zero internal deps
mic.py MicMonitor — standalone, graceful fallback
scroll.py stream() frame loop + message rendering
app.py main(), boot sequence, signal handler
```
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
--- ---
## Feeds ## Feeds
~25 sources across four categories: **Science & Technology**, **Economics & Business**, **World & Politics**, **Culture & Ideas**. Add or swap in `FEEDS`. ~25 sources across four categories: **Science & Technology**, **Economics & Business**, **World & Politics**, **Culture & Ideas**. Add or swap feeds in `engine/sources.py` `FEEDS`.
**Poetry mode** pulls from Project Gutenberg: Whitman, Dickinson, Thoreau, Emerson. **Poetry mode** pulls from Project Gutenberg: Whitman, Dickinson, Thoreau, Emerson. Sources are in `engine/sources.py``POETRY_SOURCES`.
---
## ntfy.sh Integration
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
To push a message:
```bash
curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
```
Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic. The `NtfyPoller` class is fully standalone and can be reused by other visualizers:
```python
from engine.ntfy import NtfyPoller
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
poller.start()
# in render loop:
msg = poller.get_active_message() # returns (title, body, timestamp) or None
```
--- ---
@@ -62,7 +120,6 @@ At the top of `mainline.py`:
### Graphics ### Graphics
- **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer - **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer
- **Animated gradient** — shift the white-hot leading edge left/right each frame for a pulse/comet effect
- **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen - **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen
- **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal - **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side - **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
@@ -75,6 +132,10 @@ At the top of `mainline.py`:
- **Persona modes** — `--surveillance`, `--oracle`, `--underground` as feed presets with matching color themes and boot copy - **Persona modes** — `--surveillance`, `--oracle`, `--underground` as feed presets with matching color themes and boot copy
- **Synthesized audio** — short static bursts tied to glitch events, independent of mic input - **Synthesized audio** — short static bursts tied to glitch events, independent of mic input
### Extensibility
- **serve.py** — HTTP server that imports `engine.render` and `engine.fetch` directly to stream 1-bit bitmaps to an ESP32 display
- **Rust port** — `ntfy.py` and `render.py` are the natural first targets; clear module boundaries make incremental porting viable
--- ---
*macOS only (system font paths hardcoded). Python 3.9+.* *macOS only (system font paths hardcoded). Python 3.9+.*

View File

@@ -1,5 +1,5 @@
""" """
Scroll engine — the main frame loop with headline rendering and message display. Render engine — ticker content, scroll motion, message panel, and firehose overlay.
Depends on: config, terminal, render, effects, ntfy, mic. Depends on: config, terminal, render, effects, ntfy, mic.
""" """
@@ -16,7 +16,7 @@ from engine.effects import noise, glitch_bar, fade_line, vis_trunc, next_headlin
def stream(items, ntfy_poller, mic_monitor): def stream(items, ntfy_poller, mic_monitor):
"""Main rendering loop. Scrolls headlines, shows ntfy messages, applies effects.""" """Main render loop with four layers: message, ticker, scroll motion, firehose."""
random.shuffle(items) random.shuffle(items)
pool = list(items) pool = list(items)
seen = set() seen = set()
@@ -28,16 +28,21 @@ def stream(items, ntfy_poller, mic_monitor):
w, h = tw(), th() w, h = tw(), th()
fh = config.FIREHOSE_H if config.FIREHOSE else 0 fh = config.FIREHOSE_H if config.FIREHOSE else 0
sh = h - fh # scroll zone height ticker_view_h = h - fh # reserve fixed firehose strip at bottom
GAP = 3 # blank rows between headlines GAP = 3 # blank rows between headlines
scroll_interval = config.SCROLL_DUR / (sh + 15) * 2 scroll_step_interval = config.SCROLL_DUR / (ticker_view_h + 15) * 2
# active blocks: (content_rows, color, canvas_y, meta_idx) # Taxonomy:
# - message: centered ntfy overlay panel
# - ticker: large headline text content
# - scroll: upward camera motion applied to ticker content
# - firehose: fixed carriage-return style strip pinned at bottom
# Active ticker blocks: (content_rows, color, canvas_y, meta_idx)
active = [] active = []
cam = 0 # viewport top in virtual canvas coords scroll_cam = 0 # viewport top in virtual canvas coords
next_y = sh # canvas-y where next block starts (off-screen bottom) ticker_next_y = ticker_view_h # canvas-y where next block starts (off-screen bottom)
noise_cache = {} noise_cache = {}
scroll_accum = 0.0 scroll_motion_accum = 0.0
def _noise_at(cy): def _noise_at(cy):
if cy not in noise_cache: if cy not in noise_cache:
@@ -53,16 +58,17 @@ def stream(items, ntfy_poller, mic_monitor):
t0 = time.monotonic() t0 = time.monotonic()
w, h = tw(), th() w, h = tw(), th()
fh = config.FIREHOSE_H if config.FIREHOSE else 0 fh = config.FIREHOSE_H if config.FIREHOSE else 0
sh = h - fh ticker_view_h = h - fh
# ── Check for ntfy message ──────────────────────── # ── Check for ntfy message ────────────────────────
msg_h = 0 # rows consumed by message zone at top msg_h = 0
msg_overlay = []
msg = ntfy_poller.get_active_message() msg = ntfy_poller.get_active_message()
buf = [] buf = []
if msg is not None: if msg is not None:
m_title, m_body, m_ts = msg m_title, m_body, m_ts = msg
# ── Message zone: pinned to top, scroll continues below ── # ── Message overlay: centered in the viewport ──
display_text = m_body or m_title or "(empty)" display_text = m_body or m_title or "(empty)"
display_text = re.sub(r"\s+", " ", display_text.upper()) display_text = re.sub(r"\s+", " ", display_text.upper())
cache_key = (display_text, w) cache_key = (display_text, w)
@@ -76,10 +82,12 @@ def stream(items, ntfy_poller, mic_monitor):
elapsed_s = int(time.monotonic() - m_ts) elapsed_s = int(time.monotonic() - m_ts)
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s) remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
ts_str = datetime.now().strftime("%H:%M:%S") ts_str = datetime.now().strftime("%H:%M:%S")
panel_h = len(msg_rows) + 2 # meta + border
panel_top = max(0, (h - panel_h) // 2)
row_idx = 0 row_idx = 0
for mr in msg_rows: for mr in msg_rows:
ln = vis_trunc(mr, w) ln = vis_trunc(mr, w)
buf.append(f"\033[{row_idx+1};1H {ln}{RST}\033[K") msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H {ln}{RST}\033[K")
row_idx += 1 row_idx += 1
# Meta line: title (if distinct) + source + countdown # Meta line: title (if distinct) + source + countdown
meta_parts = [] meta_parts = []
@@ -87,49 +95,46 @@ def stream(items, ntfy_poller, mic_monitor):
meta_parts.append(m_title) meta_parts.append(m_title)
meta_parts.append(f"ntfy \u00b7 {ts_str} \u00b7 {remaining}s") meta_parts.append(f"ntfy \u00b7 {ts_str} \u00b7 {remaining}s")
meta = " " + " \u00b7 ".join(meta_parts) if len(meta_parts) > 1 else " " + meta_parts[0] meta = " " + " \u00b7 ".join(meta_parts) if len(meta_parts) > 1 else " " + meta_parts[0]
buf.append(f"\033[{row_idx+1};1H{MSG_META}{meta}{RST}\033[K") msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}{RST}\033[K")
row_idx += 1 row_idx += 1
# Border — constant boundary between message and scroll # Border — constant boundary under message panel
bar = "\u2500" * (w - 4) bar = "\u2500" * (w - 4)
buf.append(f"\033[{row_idx+1};1H {MSG_BORDER}{bar}{RST}\033[K") msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}{RST}\033[K")
row_idx += 1
msg_h = row_idx
# Effective scroll zone: below message, above firehose # Ticker draws above the fixed firehose strip; message is a centered overlay.
scroll_h = sh - msg_h ticker_h = ticker_view_h - msg_h
# ── Scroll: headline rendering (always runs) ────── # ── Ticker content + scroll motion (always runs) ──
# Advance scroll on schedule scroll_motion_accum += config.FRAME_DT
scroll_accum += config.FRAME_DT while scroll_motion_accum >= scroll_step_interval:
while scroll_accum >= scroll_interval: scroll_motion_accum -= scroll_step_interval
scroll_accum -= scroll_interval scroll_cam += 1
cam += 1
# Enqueue new headlines when room at the bottom # Enqueue new headlines when room at the bottom
while next_y < cam + sh + 10 and queued < config.HEADLINE_LIMIT: while ticker_next_y < scroll_cam + ticker_view_h + 10 and queued < config.HEADLINE_LIMIT:
t, src, ts = next_headline(pool, items, seen) t, src, ts = next_headline(pool, items, seen)
content, hc, midx = make_block(t, src, ts, w) ticker_content, hc, midx = make_block(t, src, ts, w)
active.append((content, hc, next_y, midx)) active.append((ticker_content, hc, ticker_next_y, midx))
next_y += len(content) + GAP ticker_next_y += len(ticker_content) + GAP
queued += 1 queued += 1
# Prune off-screen blocks and stale noise # Prune off-screen blocks and stale noise
active = [(c, hc, by, mi) for c, hc, by, mi in active active = [(c, hc, by, mi) for c, hc, by, mi in active
if by + len(c) > cam] if by + len(c) > scroll_cam]
for k in list(noise_cache): for k in list(noise_cache):
if k < cam: if k < scroll_cam:
del noise_cache[k] del noise_cache[k]
# Draw scroll zone (below message zone, above firehose) # Draw ticker zone (above fixed firehose strip)
top_zone = max(1, int(scroll_h * 0.25)) top_zone = max(1, int(ticker_h * 0.25))
bot_zone = max(1, int(scroll_h * 0.10)) bot_zone = max(1, int(ticker_h * 0.10))
grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0 grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0
scroll_buf_start = len(buf) # track where scroll rows start in buf ticker_buf_start = len(buf) # track where ticker rows start in buf
for r in range(scroll_h): for r in range(ticker_h):
scr_row = msg_h + r + 1 # 1-indexed ANSI screen row scr_row = r + 1 # 1-indexed ANSI screen row
cy = cam + r cy = scroll_cam + r
top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0 top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0
bot_f = min(1.0, (scroll_h - 1 - r) / bot_zone) if bot_zone > 0 else 1.0 bot_f = min(1.0, (ticker_h - 1 - r) / bot_zone) if bot_zone > 0 else 1.0
row_fade = min(top_f, bot_f) row_fade = min(top_f, bot_f)
drawn = False drawn = False
for content, hc, by, midx in active: for content, hc, by, midx in active:
@@ -160,22 +165,24 @@ def stream(items, ntfy_poller, mic_monitor):
else: else:
buf.append(f"\033[{scr_row};1H\033[K") buf.append(f"\033[{scr_row};1H\033[K")
# Draw firehose zone # Glitch — base rate + mic-reactive spikes (ticker zone only)
if config.FIREHOSE and fh > 0:
for fr in range(fh):
fline = firehose_line(items, w)
buf.append(f"\033[{sh + fr + 1};1H{fline}\033[K")
# Glitch — base rate + mic-reactive spikes (scroll zone only)
mic_excess = mic_monitor.excess mic_excess = mic_monitor.excess
glitch_prob = 0.32 + min(0.9, mic_excess * 0.16) glitch_prob = 0.32 + min(0.9, mic_excess * 0.16)
n_hits = 4 + int(mic_excess / 2) n_hits = 4 + int(mic_excess / 2)
scroll_buf_len = len(buf) - scroll_buf_start ticker_buf_len = len(buf) - ticker_buf_start
if random.random() < glitch_prob and scroll_buf_len > 0: if random.random() < glitch_prob and ticker_buf_len > 0:
for _ in range(min(n_hits, scroll_buf_len)): for _ in range(min(n_hits, ticker_buf_len)):
gi = random.randint(0, scroll_buf_len - 1) gi = random.randint(0, ticker_buf_len - 1)
scr_row = msg_h + gi + 1 scr_row = gi + 1
buf[scroll_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}" buf[ticker_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}"
if config.FIREHOSE and fh > 0:
for fr in range(fh):
scr_row = h - fh + fr + 1
fline = firehose_line(items, w)
buf.append(f"\033[{scr_row};1H{fline}\033[K")
if msg_overlay:
buf.extend(msg_overlay)
sys.stdout.buffer.write("".join(buf).encode()) sys.stdout.buffer.write("".join(buf).encode())
sys.stdout.flush() sys.stdout.flush()