4 Commits

4 changed files with 56 additions and 24 deletions

View File

@@ -14,6 +14,10 @@ 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 --firehose # dense rapid-fire headline mode
python3 mainline.py --refresh # force re-fetch (bypass cache) python3 mainline.py --refresh # force re-fetch (bypass cache)
python3 mainline.py --no-font-picker # skip interactive font picker
python3 mainline.py --font-file path.otf # use a specific font file
python3 mainline.py --font-dir ~/fonts # scan a different font folder
python3 mainline.py --font-index 1 # select face index within a collection
``` ```
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache. First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache.
@@ -29,8 +33,10 @@ All constants live in `engine/config.py`:
| `HEADLINE_LIMIT` | `1000` | Total headlines per session | | `HEADLINE_LIMIT` | `1000` | Total headlines per session |
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) | | `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_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files used by the font picker | | `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
| `FONT_PATH` | first supported font in `fonts/` | Active display font file selected at startup | | `FONT_PATH` | first file in `FONT_DIR` | Active display font (overridden by picker or `--font-file`) |
| `FONT_INDEX` | `0` | Face index within a font collection file |
| `FONT_PICKER` | `True` | Show interactive font picker at boot (`--no-font-picker` to skip) |
| `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) | | `SSAA` | `4` | Super-sampling factor (render at 4× then downsample) |
@@ -42,15 +48,24 @@ All constants live in `engine/config.py`:
| `NTFY_POLL_INTERVAL` | `15` | Seconds between ntfy polls | | `NTFY_POLL_INTERVAL` | `15` | Seconds between ntfy polls |
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen | | `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
**Font:** Put your `.otf`, `.ttf`, or `.ttc` files in `fonts/`. Startup opens the font picker from that folder and applies your selected font before streaming. ---
## Fonts
A `fonts/` directory is bundled with demo faces (AlphatronDemo, CSBishopDrawn, CyberformDemo, KATA, Microbots, Neoform, Pixel Sparta, Robocops, Xeonic, and others). On startup, an interactive picker lists all discovered faces with a live half-block preview rendered at your configured size.
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selected face persists for that session.
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or point `--font-dir` at any other folder). Font collections (`.ttc`, multi-face `.otf`) are enumerated face-by-face.
--- ---
## How it works ## How it works
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection; `--no-font-picker` skips directly to stream
- 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 - 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 with 4× SSAA 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; the gradient sweeps continuously across the full scroll canvas - The ticker uses a sweeping white-hot → deep green gradient; ntfy messages use a complementary white-hot → magenta/maroon gradient to distinguish them visually
- 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
@@ -75,7 +90,7 @@ engine/
ntfy.py NtfyPoller — standalone, zero internal deps ntfy.py NtfyPoller — standalone, zero internal deps
mic.py MicMonitor — standalone, graceful fallback mic.py MicMonitor — standalone, graceful fallback
scroll.py stream() frame loop + message rendering scroll.py stream() frame loop + message rendering
app.py main(), boot sequence, signal handler app.py main(), font picker TUI, boot sequence, signal handler
``` ```
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer. `ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
@@ -139,4 +154,4 @@ msg = poller.get_active_message() # returns (title, body, timestamp) or None
--- ---
*macOS only (system font paths hardcoded). Python 3.9+.* *macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.9+.*

View File

@@ -296,7 +296,7 @@ def main():
ntfy = NtfyPoller( ntfy = NtfyPoller(
config.NTFY_TOPIC, config.NTFY_TOPIC,
poll_interval=config.NTFY_POLL_INTERVAL, reconnect_delay=config.NTFY_RECONNECT_DELAY,
display_secs=config.MESSAGE_DISPLAY_SECS, display_secs=config.MESSAGE_DISPLAY_SECS,
) )
ntfy_ok = ntfy.start() ntfy_ok = ntfy.start()

View File

@@ -59,8 +59,8 @@ MODE = 'poetry' if '--poetry' in sys.argv or '-p' in sys.argv else 'news'
FIREHOSE = '--firehose' in sys.argv FIREHOSE = '--firehose' in sys.argv
# ─── NTFY MESSAGE QUEUE ────────────────────────────────── # ─── NTFY MESSAGE QUEUE ──────────────────────────────────
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json?since=20s&poll=1" NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
NTFY_POLL_INTERVAL = 15 # seconds between polls NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
# ─── FONT RENDERING ────────────────────────────────────── # ─── FONT RENDERING ──────────────────────────────────────

View File

@@ -1,9 +1,9 @@
""" """
ntfy.sh message poller — standalone, zero internal dependencies. ntfy.sh SSE stream listener — standalone, zero internal dependencies.
Reusable by any visualizer: Reusable by any visualizer:
from engine.ntfy import NtfyPoller from engine.ntfy import NtfyPoller
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1") poller = NtfyPoller("https://ntfy.sh/my_topic/json")
poller.start() poller.start()
# in render loop: # in render loop:
msg = poller.get_active_message() msg = poller.get_active_message()
@@ -16,21 +16,22 @@ import json
import time import time
import threading import threading
import urllib.request import urllib.request
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
class NtfyPoller: class NtfyPoller:
"""Background poller for ntfy.sh topics.""" """SSE stream listener for ntfy.sh topics. Messages arrive in ~1s (network RTT)."""
def __init__(self, topic_url, poll_interval=15, display_secs=30): def __init__(self, topic_url, reconnect_delay=5, display_secs=30):
self.topic_url = topic_url self.topic_url = topic_url
self.poll_interval = poll_interval self.reconnect_delay = reconnect_delay
self.display_secs = display_secs self.display_secs = display_secs
self._message = None # (title, body, monotonic_timestamp) or None self._message = None # (title, body, monotonic_timestamp) or None
self._lock = threading.Lock() self._lock = threading.Lock()
def start(self): def start(self):
"""Start background polling thread. Returns True.""" """Start background stream thread. Returns True."""
t = threading.Thread(target=self._poll_loop, daemon=True) t = threading.Thread(target=self._stream_loop, daemon=True)
t.start() t.start()
return True return True
@@ -50,19 +51,35 @@ class NtfyPoller:
with self._lock: with self._lock:
self._message = None self._message = None
def _poll_loop(self): def _build_url(self, last_id=None):
"""Build the stream URL, substituting since= to avoid message replays on reconnect."""
parsed = urlparse(self.topic_url)
params = parse_qs(parsed.query, keep_blank_values=True)
params['since'] = [last_id if last_id else '20s']
new_query = urlencode({k: v[0] for k, v in params.items()})
return urlunparse(parsed._replace(query=new_query))
def _stream_loop(self):
last_id = None
while True: while True:
try: try:
url = self._build_url(last_id)
req = urllib.request.Request( req = urllib.request.Request(
self.topic_url, headers={"User-Agent": "mainline/0.1"}) url, headers={"User-Agent": "mainline/0.1"})
resp = urllib.request.urlopen(req, timeout=10) # timeout=90 keeps the socket alive through ntfy.sh keepalive heartbeats
for line in resp.read().decode('utf-8', errors='replace').strip().split('\n'): resp = urllib.request.urlopen(req, timeout=90)
if not line.strip(): while True:
continue line = resp.readline()
if not line:
break # server closed connection — reconnect
try: try:
data = json.loads(line) data = json.loads(line.decode('utf-8', errors='replace'))
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
# Advance cursor on every event (message + keepalive) to
# avoid replaying already-seen events after a reconnect.
if "id" in data:
last_id = data["id"]
if data.get("event") == "message": if data.get("event") == "message":
with self._lock: with self._lock:
self._message = ( self._message = (
@@ -72,4 +89,4 @@ class NtfyPoller:
) )
except Exception: except Exception:
pass pass
time.sleep(self.poll_interval) time.sleep(self.reconnect_delay)