Compare commits
21 Commits
f6ad89769f
...
drift
| Author | SHA1 | Date | |
|---|---|---|---|
| 66c13b5829 | |||
| 086214f05e | |||
| 0f762475b5 | |||
| b00b612da0 | |||
| 39dab4b22b | |||
| 47f17e12ef | |||
| 851c4a77b4 | |||
| cdbb6dfd1c | |||
| 45a202e955 | |||
| 339510dd60 | |||
| 9bd8115c55 | |||
| 2c777729f5 | |||
| 0e500d1b71 | |||
| 3571e2780b | |||
| dfd902fb90 | |||
| 2e6b2c48bd | |||
| 1ff2e54586 | |||
| 424332e065 | |||
| 2e69cad984 | |||
| 7274f57bbb | |||
| c857d7bd81 |
87
README.md
87
README.md
@@ -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+.*
|
||||||
|
|||||||
114
engine/app.py
Normal file
114
engine/app.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""
|
||||||
|
Application orchestrator — boot sequence, signal handling, main loop wiring.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import signal
|
||||||
|
import atexit
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.terminal import (
|
||||||
|
RST, G_HI, G_MID, G_DIM, W_DIM, W_GHOST, CLR, CURSOR_OFF, CURSOR_ON, tw,
|
||||||
|
slow_print, boot_ln,
|
||||||
|
)
|
||||||
|
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
from engine.mic import MicMonitor
|
||||||
|
from engine.scroll import stream
|
||||||
|
|
||||||
|
TITLE = [
|
||||||
|
" ███╗ ███╗ █████╗ ██╗███╗ ██╗██╗ ██╗███╗ ██╗███████╗",
|
||||||
|
" ████╗ ████║██╔══██╗██║████╗ ██║██║ ██║████╗ ██║██╔════╝",
|
||||||
|
" ██╔████╔██║███████║██║██╔██╗ ██║██║ ██║██╔██╗ ██║█████╗ ",
|
||||||
|
" ██║╚██╔╝██║██╔══██║██║██║╚██╗██║██║ ██║██║╚██╗██║██╔══╝ ",
|
||||||
|
" ██║ ╚═╝ ██║██║ ██║██║██║ ╚████║███████╗██║██║ ╚████║███████╗",
|
||||||
|
" ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
||||||
|
|
||||||
|
def handle_sigint(*_):
|
||||||
|
print(f"\n\n {G_DIM}> SIGNAL LOST{RST}")
|
||||||
|
print(f" {W_GHOST}> connection terminated{RST}\n")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, handle_sigint)
|
||||||
|
|
||||||
|
w = tw()
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
for ln in TITLE:
|
||||||
|
print(f"{G_HI}{ln}{RST}")
|
||||||
|
time.sleep(0.07)
|
||||||
|
|
||||||
|
print()
|
||||||
|
_subtitle = "literary consciousness stream" if config.MODE == 'poetry' else "digital consciousness stream"
|
||||||
|
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
|
||||||
|
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||||
|
print()
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
cached = load_cache() if '--refresh' not in sys.argv else None
|
||||||
|
if cached:
|
||||||
|
items = cached
|
||||||
|
boot_ln("Cache", f"LOADED [{len(items)} SIGNALS]", True)
|
||||||
|
elif config.MODE == 'poetry':
|
||||||
|
slow_print(" > INITIALIZING LITERARY CORPUS...\n")
|
||||||
|
time.sleep(0.2)
|
||||||
|
print()
|
||||||
|
items, linked, failed = fetch_poetry()
|
||||||
|
print()
|
||||||
|
print(f" {G_DIM}>{RST} {G_MID}{linked} TEXTS LOADED{RST} {W_GHOST}· {failed} DARK{RST}")
|
||||||
|
print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
|
||||||
|
save_cache(items)
|
||||||
|
else:
|
||||||
|
slow_print(" > INITIALIZING FEED ARRAY...\n")
|
||||||
|
time.sleep(0.2)
|
||||||
|
print()
|
||||||
|
items, linked, failed = fetch_all()
|
||||||
|
print()
|
||||||
|
print(f" {G_DIM}>{RST} {G_MID}{linked} SOURCES LINKED{RST} {W_GHOST}· {failed} DARK{RST}")
|
||||||
|
print(f" {G_DIM}>{RST} {G_MID}{len(items)} SIGNALS ACQUIRED{RST}")
|
||||||
|
save_cache(items)
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
print(f"\n {W_DIM}> NO SIGNAL — check network{RST}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print()
|
||||||
|
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
|
||||||
|
mic_ok = mic.start()
|
||||||
|
if mic.available:
|
||||||
|
boot_ln("Microphone", "ACTIVE" if mic_ok else "OFFLINE · check System Settings → Privacy → Microphone", bool(mic_ok))
|
||||||
|
|
||||||
|
ntfy = NtfyPoller(
|
||||||
|
config.NTFY_TOPIC,
|
||||||
|
poll_interval=config.NTFY_POLL_INTERVAL,
|
||||||
|
display_secs=config.MESSAGE_DISPLAY_SECS,
|
||||||
|
)
|
||||||
|
ntfy_ok = ntfy.start()
|
||||||
|
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
|
||||||
|
|
||||||
|
if config.FIREHOSE:
|
||||||
|
boot_ln("Firehose", "ENGAGED", True)
|
||||||
|
|
||||||
|
time.sleep(0.4)
|
||||||
|
slow_print(" > STREAMING...\n")
|
||||||
|
time.sleep(0.2)
|
||||||
|
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||||
|
print()
|
||||||
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
stream(items, ntfy, mic)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||||
|
print(f" {G_DIM}> {config.HEADLINE_LIMIT} SIGNALS PROCESSED{RST}")
|
||||||
|
print(f" {W_GHOST}> end of stream{RST}")
|
||||||
|
print()
|
||||||
@@ -21,10 +21,14 @@ FONT_PATH = "/Users/genejohnson/Documents/CS Bishop Drawn/CSBishopDrawn-Italic.o
|
|||||||
FONT_SZ = 60
|
FONT_SZ = 60
|
||||||
RENDER_H = 8 # terminal rows per rendered text line
|
RENDER_H = 8 # terminal rows per rendered text line
|
||||||
|
|
||||||
|
# ─── FONT RENDERING (ADVANCED) ────────────────────────────
|
||||||
|
SSAA = 4 # super-sampling factor: render at SSAA× then downsample
|
||||||
|
|
||||||
# ─── SCROLL / FRAME ──────────────────────────────────────
|
# ─── SCROLL / FRAME ──────────────────────────────────────
|
||||||
SCROLL_DUR = 3.75 # seconds per headline
|
SCROLL_DUR = 5.625 # seconds per headline (2/3 original speed)
|
||||||
FRAME_DT = 0.05 # 50ms base frame rate (20 FPS)
|
FRAME_DT = 0.05 # 50ms base frame rate (20 FPS)
|
||||||
FIREHOSE_H = 12 # firehose zone height (terminal rows)
|
FIREHOSE_H = 12 # firehose zone height (terminal rows)
|
||||||
|
GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep)
|
||||||
|
|
||||||
# ─── GLYPHS ───────────────────────────────────────────────
|
# ─── GLYPHS ───────────────────────────────────────────────
|
||||||
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||||
|
|||||||
133
engine/effects.py
Normal file
133
engine/effects.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
Visual effects: noise, glitch, fade, ANSI-aware truncation, firehose, headline pool.
|
||||||
|
Depends on: config, terminal, sources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.terminal import RST, DIM, G_LO, G_DIM, W_GHOST, C_DIM
|
||||||
|
from engine.sources import FEEDS, POETRY_SOURCES
|
||||||
|
|
||||||
|
|
||||||
|
def noise(w):
|
||||||
|
d = random.choice([0.15, 0.25, 0.35, 0.12])
|
||||||
|
return "".join(
|
||||||
|
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||||
|
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||||
|
if random.random() < d
|
||||||
|
else " "
|
||||||
|
for _ in range(w)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def glitch_bar(w):
|
||||||
|
c = random.choice(["░", "▒", "─", "╌"])
|
||||||
|
n = random.randint(3, w // 2)
|
||||||
|
o = random.randint(0, w - n)
|
||||||
|
return " " * o + f"{G_LO}{DIM}" + c * n + RST
|
||||||
|
|
||||||
|
|
||||||
|
def fade_line(s, fade):
|
||||||
|
"""Dissolve a rendered line by probabilistically dropping characters."""
|
||||||
|
if fade >= 1.0:
|
||||||
|
return s
|
||||||
|
if fade <= 0.0:
|
||||||
|
return ''
|
||||||
|
result = []
|
||||||
|
i = 0
|
||||||
|
while i < len(s):
|
||||||
|
if s[i] == '\033' and i + 1 < len(s) and s[i + 1] == '[':
|
||||||
|
j = i + 2
|
||||||
|
while j < len(s) and not s[j].isalpha():
|
||||||
|
j += 1
|
||||||
|
result.append(s[i:j + 1])
|
||||||
|
i = j + 1
|
||||||
|
elif s[i] == ' ':
|
||||||
|
result.append(' ')
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
result.append(s[i] if random.random() < fade else ' ')
|
||||||
|
i += 1
|
||||||
|
return ''.join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def vis_trunc(s, w):
|
||||||
|
"""Truncate string to visual width w, skipping ANSI escape codes."""
|
||||||
|
result = []
|
||||||
|
vw = 0
|
||||||
|
i = 0
|
||||||
|
while i < len(s):
|
||||||
|
if vw >= w:
|
||||||
|
break
|
||||||
|
if s[i] == '\033' and i + 1 < len(s) and s[i + 1] == '[':
|
||||||
|
j = i + 2
|
||||||
|
while j < len(s) and not s[j].isalpha():
|
||||||
|
j += 1
|
||||||
|
result.append(s[i:j + 1])
|
||||||
|
i = j + 1
|
||||||
|
else:
|
||||||
|
result.append(s[i])
|
||||||
|
vw += 1
|
||||||
|
i += 1
|
||||||
|
return ''.join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def next_headline(pool, items, seen):
|
||||||
|
"""Pull the next unique headline from pool, refilling as needed."""
|
||||||
|
while True:
|
||||||
|
if not pool:
|
||||||
|
pool.extend(items)
|
||||||
|
random.shuffle(pool)
|
||||||
|
seen.clear()
|
||||||
|
title, src, ts = pool.pop()
|
||||||
|
sig = title.lower().strip()
|
||||||
|
if sig not in seen:
|
||||||
|
seen.add(sig)
|
||||||
|
return title, src, ts
|
||||||
|
|
||||||
|
|
||||||
|
def firehose_line(items, w):
|
||||||
|
"""Generate one line of rapidly cycling firehose content."""
|
||||||
|
r = random.random()
|
||||||
|
if r < 0.35:
|
||||||
|
# Raw headline text
|
||||||
|
title, src, ts = random.choice(items)
|
||||||
|
text = title[:w - 1]
|
||||||
|
color = random.choice([G_LO, G_DIM, W_GHOST, C_DIM])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
|
elif r < 0.55:
|
||||||
|
# Dense glitch noise
|
||||||
|
d = random.choice([0.45, 0.55, 0.65, 0.75])
|
||||||
|
return "".join(
|
||||||
|
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||||
|
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||||
|
if random.random() < d else " "
|
||||||
|
for _ in range(w)
|
||||||
|
)
|
||||||
|
elif r < 0.78:
|
||||||
|
# Status / program output
|
||||||
|
sources = FEEDS if config.MODE == 'news' else POETRY_SOURCES
|
||||||
|
src = random.choice(list(sources.keys()))
|
||||||
|
msgs = [
|
||||||
|
f" SIGNAL :: {src} :: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}",
|
||||||
|
f" ░░ FEED ACTIVE :: {src}",
|
||||||
|
f" >> DECODE 0x{random.randint(0x1000, 0xFFFF):04X} :: {src[:24]}",
|
||||||
|
f" ▒▒ ACQUIRE :: {random.choice(['TCP', 'UDP', 'RSS', 'ATOM', 'XML'])} :: {src}",
|
||||||
|
f" {''.join(random.choice(config.KATA) for _ in range(3))} STRM "
|
||||||
|
f"{random.randint(0, 255):02X}:{random.randint(0, 255):02X}",
|
||||||
|
]
|
||||||
|
text = random.choice(msgs)[:w - 1]
|
||||||
|
color = random.choice([G_LO, G_DIM, W_GHOST])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
|
else:
|
||||||
|
# Headline fragment with glitch prefix
|
||||||
|
title, _, _ = random.choice(items)
|
||||||
|
start = random.randint(0, max(0, len(title) - 20))
|
||||||
|
frag = title[start:start + random.randint(10, 35)]
|
||||||
|
pad = random.randint(0, max(0, w - len(frag) - 8))
|
||||||
|
gp = ''.join(random.choice(config.GLITCH) for _ in range(random.randint(1, 3)))
|
||||||
|
text = (' ' * pad + gp + ' ' + frag)[:w - 1]
|
||||||
|
color = random.choice([G_LO, C_DIM, W_GHOST])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
133
engine/fetch.py
Normal file
133
engine/fetch.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
RSS feed fetching, Project Gutenberg parsing, and headline caching.
|
||||||
|
Depends on: config, sources, filter, terminal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import feedparser
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.sources import FEEDS, POETRY_SOURCES
|
||||||
|
from engine.filter import strip_tags, skip
|
||||||
|
from engine.terminal import boot_ln
|
||||||
|
|
||||||
|
# ─── SINGLE FEED ──────────────────────────────────────────
|
||||||
|
def fetch_feed(url):
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||||
|
resp = urllib.request.urlopen(req, timeout=config.FEED_TIMEOUT)
|
||||||
|
return feedparser.parse(resp.read())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── ALL RSS FEEDS ────────────────────────────────────────
|
||||||
|
def fetch_all():
|
||||||
|
items = []
|
||||||
|
linked = failed = 0
|
||||||
|
for src, url in FEEDS.items():
|
||||||
|
feed = fetch_feed(url)
|
||||||
|
if feed is None or (feed.bozo and not feed.entries):
|
||||||
|
boot_ln(src, "DARK", False)
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
n = 0
|
||||||
|
for e in feed.entries:
|
||||||
|
t = strip_tags(e.get("title", ""))
|
||||||
|
if not t or skip(t):
|
||||||
|
continue
|
||||||
|
pub = e.get("published_parsed") or e.get("updated_parsed")
|
||||||
|
try:
|
||||||
|
ts = datetime(*pub[:6]).strftime("%H:%M") if pub else "——:——"
|
||||||
|
except Exception:
|
||||||
|
ts = "——:——"
|
||||||
|
items.append((t, src, ts))
|
||||||
|
n += 1
|
||||||
|
if n:
|
||||||
|
boot_ln(src, f"LINKED [{n}]", True)
|
||||||
|
linked += 1
|
||||||
|
else:
|
||||||
|
boot_ln(src, "EMPTY", False)
|
||||||
|
failed += 1
|
||||||
|
return items, linked, failed
|
||||||
|
|
||||||
|
|
||||||
|
# ─── PROJECT GUTENBERG ────────────────────────────────────
|
||||||
|
def _fetch_gutenberg(url, label):
|
||||||
|
"""Download and parse stanzas/passages from a Project Gutenberg text."""
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||||
|
resp = urllib.request.urlopen(req, timeout=15)
|
||||||
|
text = resp.read().decode('utf-8', errors='replace').replace('\r\n', '\n').replace('\r', '\n')
|
||||||
|
# Strip PG boilerplate
|
||||||
|
m = re.search(r'\*\*\*\s*START OF[^\n]*\n', text)
|
||||||
|
if m:
|
||||||
|
text = text[m.end():]
|
||||||
|
m = re.search(r'\*\*\*\s*END OF', text)
|
||||||
|
if m:
|
||||||
|
text = text[:m.start()]
|
||||||
|
# Split on blank lines into stanzas/passages
|
||||||
|
blocks = re.split(r'\n{2,}', text.strip())
|
||||||
|
items = []
|
||||||
|
for blk in blocks:
|
||||||
|
blk = ' '.join(blk.split()) # flatten to one line
|
||||||
|
if len(blk) < 20 or len(blk) > 280:
|
||||||
|
continue
|
||||||
|
if blk.isupper(): # skip all-caps headers
|
||||||
|
continue
|
||||||
|
if re.match(r'^[IVXLCDM]+\.?\s*$', blk): # roman numerals
|
||||||
|
continue
|
||||||
|
items.append((blk, label, ''))
|
||||||
|
return items
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_poetry():
|
||||||
|
"""Fetch all poetry/literature sources."""
|
||||||
|
items = []
|
||||||
|
linked = failed = 0
|
||||||
|
for label, url in POETRY_SOURCES.items():
|
||||||
|
stanzas = _fetch_gutenberg(url, label)
|
||||||
|
if stanzas:
|
||||||
|
boot_ln(label, f"LOADED [{len(stanzas)}]", True)
|
||||||
|
items.extend(stanzas)
|
||||||
|
linked += 1
|
||||||
|
else:
|
||||||
|
boot_ln(label, "DARK", False)
|
||||||
|
failed += 1
|
||||||
|
return items, linked, failed
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CACHE ────────────────────────────────────────────────
|
||||||
|
_CACHE_DIR = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_path():
|
||||||
|
return _CACHE_DIR / f".mainline_cache_{config.MODE}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_cache():
|
||||||
|
"""Load cached items from disk if available."""
|
||||||
|
p = _cache_path()
|
||||||
|
if not p.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(p.read_text())
|
||||||
|
items = [tuple(i) for i in data["items"]]
|
||||||
|
return items if items else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_cache(items):
|
||||||
|
"""Save fetched items to disk for fast subsequent runs."""
|
||||||
|
try:
|
||||||
|
_cache_path().write_text(json.dumps({"items": items}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
62
engine/mic.py
Normal file
62
engine/mic.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
Microphone input monitor — standalone, no internal dependencies.
|
||||||
|
Gracefully degrades if sounddevice/numpy are unavailable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
|
||||||
|
try:
|
||||||
|
import sounddevice as _sd
|
||||||
|
import numpy as _np
|
||||||
|
_HAS_MIC = True
|
||||||
|
except Exception:
|
||||||
|
_HAS_MIC = False
|
||||||
|
|
||||||
|
|
||||||
|
class MicMonitor:
|
||||||
|
"""Background mic stream that exposes current RMS dB level."""
|
||||||
|
|
||||||
|
def __init__(self, threshold_db=50):
|
||||||
|
self.threshold_db = threshold_db
|
||||||
|
self._db = -99.0
|
||||||
|
self._stream = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self):
|
||||||
|
"""True if sounddevice is importable."""
|
||||||
|
return _HAS_MIC
|
||||||
|
|
||||||
|
@property
|
||||||
|
def db(self):
|
||||||
|
"""Current RMS dB level."""
|
||||||
|
return self._db
|
||||||
|
|
||||||
|
@property
|
||||||
|
def excess(self):
|
||||||
|
"""dB above threshold (clamped to 0)."""
|
||||||
|
return max(0.0, self._db - self.threshold_db)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start background mic stream. Returns True on success, False/None otherwise."""
|
||||||
|
if not _HAS_MIC:
|
||||||
|
return None
|
||||||
|
def _cb(indata, frames, t, status):
|
||||||
|
rms = float(_np.sqrt(_np.mean(indata ** 2)))
|
||||||
|
self._db = 20 * _np.log10(rms) if rms > 0 else -99.0
|
||||||
|
try:
|
||||||
|
self._stream = _sd.InputStream(
|
||||||
|
callback=_cb, channels=1, samplerate=44100, blocksize=2048)
|
||||||
|
self._stream.start()
|
||||||
|
atexit.register(self.stop)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the mic stream if running."""
|
||||||
|
if self._stream:
|
||||||
|
try:
|
||||||
|
self._stream.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._stream = None
|
||||||
75
engine/ntfy.py
Normal file
75
engine/ntfy.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
ntfy.sh message poller — standalone, zero internal dependencies.
|
||||||
|
Reusable by any visualizer:
|
||||||
|
|
||||||
|
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()
|
||||||
|
if msg:
|
||||||
|
title, body, ts = msg
|
||||||
|
render_my_message(title, body)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
class NtfyPoller:
|
||||||
|
"""Background poller for ntfy.sh topics."""
|
||||||
|
|
||||||
|
def __init__(self, topic_url, poll_interval=15, display_secs=30):
|
||||||
|
self.topic_url = topic_url
|
||||||
|
self.poll_interval = poll_interval
|
||||||
|
self.display_secs = display_secs
|
||||||
|
self._message = None # (title, body, monotonic_timestamp) or None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start background polling thread. Returns True."""
|
||||||
|
t = threading.Thread(target=self._poll_loop, daemon=True)
|
||||||
|
t.start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_active_message(self):
|
||||||
|
"""Return (title, body, timestamp) if a message is active and not expired, else None."""
|
||||||
|
with self._lock:
|
||||||
|
if self._message is None:
|
||||||
|
return None
|
||||||
|
title, body, ts = self._message
|
||||||
|
if time.monotonic() - ts < self.display_secs:
|
||||||
|
return self._message
|
||||||
|
self._message = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def dismiss(self):
|
||||||
|
"""Manually dismiss the current message."""
|
||||||
|
with self._lock:
|
||||||
|
self._message = None
|
||||||
|
|
||||||
|
def _poll_loop(self):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
self.topic_url, headers={"User-Agent": "mainline/0.1"})
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
for line in resp.read().decode('utf-8', errors='replace').strip().split('\n'):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if data.get("event") == "message":
|
||||||
|
with self._lock:
|
||||||
|
self._message = (
|
||||||
|
data.get("title", ""),
|
||||||
|
data.get("message", ""),
|
||||||
|
time.monotonic(),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(self.poll_interval)
|
||||||
@@ -10,7 +10,7 @@ import random
|
|||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
from engine import config
|
from engine import config
|
||||||
from engine.terminal import RST, W_COOL
|
from engine.terminal import RST
|
||||||
from engine.sources import SCRIPT_FONTS, SOURCE_LANGS, NO_UPPER
|
from engine.sources import SCRIPT_FONTS, SOURCE_LANGS, NO_UPPER
|
||||||
from engine.translate import detect_location_language, translate_headline
|
from engine.translate import detect_location_language, translate_headline
|
||||||
|
|
||||||
@@ -71,8 +71,11 @@ def render_line(text, fnt=None):
|
|||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
draw.text((-bbox[0] + pad, -bbox[1] + pad), text, fill=255, font=fnt)
|
draw.text((-bbox[0] + pad, -bbox[1] + pad), text, fill=255, font=fnt)
|
||||||
pix_h = config.RENDER_H * 2
|
pix_h = config.RENDER_H * 2
|
||||||
scale = pix_h / max(img_h, 1)
|
hi_h = pix_h * config.SSAA
|
||||||
new_w = max(1, int(img_w * scale))
|
scale = hi_h / max(img_h, 1)
|
||||||
|
new_w_hi = max(1, int(img_w * scale))
|
||||||
|
img = img.resize((new_w_hi, hi_h), Image.Resampling.LANCZOS)
|
||||||
|
new_w = max(1, int(new_w_hi / config.SSAA))
|
||||||
img = img.resize((new_w, pix_h), Image.Resampling.LANCZOS)
|
img = img.resize((new_w, pix_h), Image.Resampling.LANCZOS)
|
||||||
data = img.tobytes()
|
data = img.tobytes()
|
||||||
thr = 80
|
thr = 80
|
||||||
@@ -129,8 +132,8 @@ def big_wrap(text, max_w, fnt=None):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def lr_gradient(rows):
|
def lr_gradient(rows, offset=0.0):
|
||||||
"""Color each non-space block character with a left-to-right gradient."""
|
"""Color each non-space block character with a shifting left-to-right gradient."""
|
||||||
n = len(GRAD_COLS)
|
n = len(GRAD_COLS)
|
||||||
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
||||||
out = []
|
out = []
|
||||||
@@ -143,7 +146,8 @@ def lr_gradient(rows):
|
|||||||
if ch == ' ':
|
if ch == ' ':
|
||||||
buf.append(' ')
|
buf.append(' ')
|
||||||
else:
|
else:
|
||||||
idx = min(round(x / max(max_x - 1, 1) * (n - 1)), n - 1)
|
shifted = (x / max(max_x - 1, 1) + offset) % 1.0
|
||||||
|
idx = min(round(shifted * (n - 1)), n - 1)
|
||||||
buf.append(f"{GRAD_COLS[idx]}{ch}\033[0m")
|
buf.append(f"{GRAD_COLS[idx]}{ch}\033[0m")
|
||||||
out.append("".join(buf))
|
out.append("".join(buf))
|
||||||
return out
|
return out
|
||||||
@@ -165,7 +169,6 @@ def make_block(title, src, ts, w):
|
|||||||
("\u201d",'"'), ("\u2013","-"), ("\u2014","-")]:
|
("\u201d",'"'), ("\u2013","-"), ("\u2014","-")]:
|
||||||
title_up = title_up.replace(old, new)
|
title_up = title_up.replace(old, new)
|
||||||
big_rows = big_wrap(title_up, w - 4, lang_font)
|
big_rows = big_wrap(title_up, w - 4, lang_font)
|
||||||
big_rows = lr_gradient(big_rows)
|
|
||||||
hc = random.choice([
|
hc = random.choice([
|
||||||
"\033[38;5;46m", # matrix green
|
"\033[38;5;46m", # matrix green
|
||||||
"\033[38;5;34m", # dark green
|
"\033[38;5;34m", # dark green
|
||||||
|
|||||||
195
engine/scroll.py
Normal file
195
engine/scroll.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""
|
||||||
|
Render engine — ticker content, scroll motion, message panel, and firehose overlay.
|
||||||
|
Depends on: config, terminal, render, effects, ntfy, mic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.terminal import RST, W_COOL, CLR, tw, th
|
||||||
|
from engine.render import big_wrap, lr_gradient, make_block
|
||||||
|
from engine.effects import noise, glitch_bar, fade_line, vis_trunc, next_headline, firehose_line
|
||||||
|
|
||||||
|
|
||||||
|
def stream(items, ntfy_poller, mic_monitor):
|
||||||
|
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
||||||
|
random.shuffle(items)
|
||||||
|
pool = list(items)
|
||||||
|
seen = set()
|
||||||
|
queued = 0
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
sys.stdout.write(CLR)
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
w, h = tw(), th()
|
||||||
|
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||||
|
ticker_view_h = h - fh # reserve fixed firehose strip at bottom
|
||||||
|
GAP = 3 # blank rows between headlines
|
||||||
|
scroll_step_interval = config.SCROLL_DUR / (ticker_view_h + 15) * 2
|
||||||
|
|
||||||
|
# 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 = []
|
||||||
|
scroll_cam = 0 # viewport top in virtual canvas coords
|
||||||
|
ticker_next_y = ticker_view_h # canvas-y where next block starts (off-screen bottom)
|
||||||
|
noise_cache = {}
|
||||||
|
scroll_motion_accum = 0.0
|
||||||
|
|
||||||
|
def _noise_at(cy):
|
||||||
|
if cy not in noise_cache:
|
||||||
|
noise_cache[cy] = noise(w) if random.random() < 0.15 else None
|
||||||
|
return noise_cache[cy]
|
||||||
|
|
||||||
|
# Message color: bright cyan/white — distinct from headline greens
|
||||||
|
MSG_META = "\033[38;5;245m" # cool grey
|
||||||
|
MSG_BORDER = "\033[2;38;5;37m" # dim teal
|
||||||
|
_msg_cache = (None, None) # (cache_key, rendered_rows)
|
||||||
|
|
||||||
|
while queued < config.HEADLINE_LIMIT or active:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
w, h = tw(), th()
|
||||||
|
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||||
|
ticker_view_h = h - fh
|
||||||
|
|
||||||
|
# ── Check for ntfy message ────────────────────────
|
||||||
|
msg_h = 0
|
||||||
|
msg_overlay = []
|
||||||
|
msg = ntfy_poller.get_active_message()
|
||||||
|
|
||||||
|
buf = []
|
||||||
|
if msg is not None:
|
||||||
|
m_title, m_body, m_ts = msg
|
||||||
|
# ── Message overlay: centered in the viewport ──
|
||||||
|
display_text = m_body or m_title or "(empty)"
|
||||||
|
display_text = re.sub(r"\s+", " ", display_text.upper())
|
||||||
|
cache_key = (display_text, w)
|
||||||
|
if _msg_cache[0] != cache_key:
|
||||||
|
msg_rows = big_wrap(display_text, w - 4)
|
||||||
|
_msg_cache = (cache_key, msg_rows)
|
||||||
|
else:
|
||||||
|
msg_rows = _msg_cache[1]
|
||||||
|
msg_rows = lr_gradient(msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0)
|
||||||
|
# Layout: rendered text + meta + border
|
||||||
|
elapsed_s = int(time.monotonic() - m_ts)
|
||||||
|
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_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
|
||||||
|
for mr in msg_rows:
|
||||||
|
ln = vis_trunc(mr, w)
|
||||||
|
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H {ln}{RST}\033[K")
|
||||||
|
row_idx += 1
|
||||||
|
# Meta line: title (if distinct) + source + countdown
|
||||||
|
meta_parts = []
|
||||||
|
if m_title and m_title != m_body:
|
||||||
|
meta_parts.append(m_title)
|
||||||
|
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]
|
||||||
|
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}{RST}\033[K")
|
||||||
|
row_idx += 1
|
||||||
|
# Border — constant boundary under message panel
|
||||||
|
bar = "\u2500" * (w - 4)
|
||||||
|
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}{RST}\033[K")
|
||||||
|
|
||||||
|
# Ticker draws above the fixed firehose strip; message is a centered overlay.
|
||||||
|
ticker_h = ticker_view_h - msg_h
|
||||||
|
|
||||||
|
# ── Ticker content + scroll motion (always runs) ──
|
||||||
|
scroll_motion_accum += config.FRAME_DT
|
||||||
|
while scroll_motion_accum >= scroll_step_interval:
|
||||||
|
scroll_motion_accum -= scroll_step_interval
|
||||||
|
scroll_cam += 1
|
||||||
|
|
||||||
|
# Enqueue new headlines when room at the bottom
|
||||||
|
while ticker_next_y < scroll_cam + ticker_view_h + 10 and queued < config.HEADLINE_LIMIT:
|
||||||
|
t, src, ts = next_headline(pool, items, seen)
|
||||||
|
ticker_content, hc, midx = make_block(t, src, ts, w)
|
||||||
|
active.append((ticker_content, hc, ticker_next_y, midx))
|
||||||
|
ticker_next_y += len(ticker_content) + GAP
|
||||||
|
queued += 1
|
||||||
|
|
||||||
|
# Prune off-screen blocks and stale noise
|
||||||
|
active = [(c, hc, by, mi) for c, hc, by, mi in active
|
||||||
|
if by + len(c) > scroll_cam]
|
||||||
|
for k in list(noise_cache):
|
||||||
|
if k < scroll_cam:
|
||||||
|
del noise_cache[k]
|
||||||
|
|
||||||
|
# Draw ticker zone (above fixed firehose strip)
|
||||||
|
top_zone = max(1, int(ticker_h * 0.25))
|
||||||
|
bot_zone = max(1, int(ticker_h * 0.10))
|
||||||
|
grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||||
|
ticker_buf_start = len(buf) # track where ticker rows start in buf
|
||||||
|
for r in range(ticker_h):
|
||||||
|
scr_row = r + 1 # 1-indexed ANSI screen row
|
||||||
|
cy = scroll_cam + r
|
||||||
|
top_f = min(1.0, r / top_zone) if top_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)
|
||||||
|
drawn = False
|
||||||
|
for content, hc, by, midx in active:
|
||||||
|
cr = cy - by
|
||||||
|
if 0 <= cr < len(content):
|
||||||
|
raw = content[cr]
|
||||||
|
if cr != midx:
|
||||||
|
colored = lr_gradient([raw], grad_offset)[0]
|
||||||
|
else:
|
||||||
|
colored = raw
|
||||||
|
ln = vis_trunc(colored, w)
|
||||||
|
if row_fade < 1.0:
|
||||||
|
ln = fade_line(ln, row_fade)
|
||||||
|
if cr == midx:
|
||||||
|
buf.append(f"\033[{scr_row};1H{W_COOL}{ln}{RST}\033[K")
|
||||||
|
elif ln.strip():
|
||||||
|
buf.append(f"\033[{scr_row};1H{ln}{RST}\033[K")
|
||||||
|
else:
|
||||||
|
buf.append(f"\033[{scr_row};1H\033[K")
|
||||||
|
drawn = True
|
||||||
|
break
|
||||||
|
if not drawn:
|
||||||
|
n = _noise_at(cy)
|
||||||
|
if row_fade < 1.0 and n:
|
||||||
|
n = fade_line(n, row_fade)
|
||||||
|
if n:
|
||||||
|
buf.append(f"\033[{scr_row};1H{n}")
|
||||||
|
else:
|
||||||
|
buf.append(f"\033[{scr_row};1H\033[K")
|
||||||
|
|
||||||
|
# Glitch — base rate + mic-reactive spikes (ticker zone only)
|
||||||
|
mic_excess = mic_monitor.excess
|
||||||
|
glitch_prob = 0.32 + min(0.9, mic_excess * 0.16)
|
||||||
|
n_hits = 4 + int(mic_excess / 2)
|
||||||
|
ticker_buf_len = len(buf) - ticker_buf_start
|
||||||
|
if random.random() < glitch_prob and ticker_buf_len > 0:
|
||||||
|
for _ in range(min(n_hits, ticker_buf_len)):
|
||||||
|
gi = random.randint(0, ticker_buf_len - 1)
|
||||||
|
scr_row = gi + 1
|
||||||
|
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.flush()
|
||||||
|
|
||||||
|
# Precise frame timing
|
||||||
|
elapsed = time.monotonic() - t0
|
||||||
|
time.sleep(max(0, config.FRAME_DT - elapsed))
|
||||||
|
|
||||||
|
sys.stdout.write(CLR)
|
||||||
|
sys.stdout.flush()
|
||||||
1046
mainline.py
1046
mainline.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user