diff --git a/README.md b/README.md index 8930489..b5076a5 100644 --- a/README.md +++ b/README.md @@ -2,54 +2,112 @@ > *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). --- ## Run ```bash -python3 mainline.py # news stream -python3 mainline.py --poetry # literary consciousness mode -python3 mainline.py -p # same +python3 mainline.py # news stream +python3 mainline.py --poetry # literary consciousness mode +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 -At the top of `mainline.py`: +All constants live in `engine/config.py`: | Constant | Default | What it does | |---|---|---| | `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 | -| `_FONT_PATH` | hardcoded path | Path to your OTF/TTF display font | -| `_FONT_SZ` | `60` | Font render size (affects block density) | -| `_RENDER_H` | `8` | Terminal rows per headline line | +| `FONT_PATH` | hardcoded path | Path to your OTF/TTF display font | +| `FONT_SZ` | `60` | Font render size (affects block density) | +| `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 -- Feeds are fetched and filtered on startup (sports and vapid content stripped) -- Headlines are rasterized via Pillow 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 +- 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 +- 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 - 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 +- 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 -~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 - **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 - **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 @@ -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 - **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+.* diff --git a/engine/render.py b/engine/render.py index 1b76570..fb7bc5c 100644 --- a/engine/render.py +++ b/engine/render.py @@ -31,6 +31,22 @@ GRAD_COLS = [ "\033[2;38;5;235m", # near black ] +# Complementary sweep for queue messages (opposite hue family from ticker greens) +MSG_GRAD_COLS = [ + "\033[1;38;5;231m", # white + "\033[1;38;5;225m", # pale pink-white + "\033[38;5;219m", # bright pink + "\033[38;5;213m", # hot pink + "\033[38;5;207m", # magenta + "\033[38;5;201m", # bright magenta + "\033[38;5;165m", # orchid-red + "\033[38;5;161m", # ruby-magenta + "\033[38;5;125m", # dark magenta + "\033[38;5;89m", # deep maroon-magenta + "\033[2;38;5;89m", # dim deep maroon-magenta + "\033[2;38;5;235m", # near black +] + # ─── FONT LOADING ───────────────────────────────────────── _FONT_OBJ = None _FONT_CACHE = {} @@ -132,9 +148,10 @@ def big_wrap(text, max_w, fnt=None): return out -def lr_gradient(rows, offset=0.0): +def lr_gradient(rows, offset=0.0, grad_cols=None): """Color each non-space block character with a shifting left-to-right gradient.""" - n = len(GRAD_COLS) + cols = grad_cols or GRAD_COLS + n = len(cols) max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1) out = [] for row in rows: @@ -148,11 +165,16 @@ def lr_gradient(rows, offset=0.0): else: 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"{cols[idx]}{ch}{RST}") out.append("".join(buf)) return out +def lr_gradient_opposite(rows, offset=0.0): + """Complementary (opposite wheel) gradient used for queue message panels.""" + return lr_gradient(rows, offset, MSG_GRAD_COLS) + + # ─── HEADLINE BLOCK ASSEMBLY ───────────────────────────── def make_block(title, src, ts, w): """Render a headline into a content block with color.""" diff --git a/engine/scroll.py b/engine/scroll.py index 3466e37..f9b7f39 100644 --- a/engine/scroll.py +++ b/engine/scroll.py @@ -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. """ @@ -11,12 +11,12 @@ 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.render import big_wrap, lr_gradient, lr_gradient_opposite, 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 rendering loop. Scrolls headlines, shows ntfy messages, applies effects.""" + """Main render loop with four layers: message, ticker, scroll motion, firehose.""" random.shuffle(items) pool = list(items) seen = set() @@ -28,16 +28,21 @@ def stream(items, ntfy_poller, mic_monitor): w, h = tw(), th() 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 - 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 = [] - cam = 0 # viewport top in virtual canvas coords - next_y = sh # canvas-y where next block starts (off-screen bottom) + 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_accum = 0.0 + scroll_motion_accum = 0.0 def _noise_at(cy): if cy not in noise_cache: @@ -53,16 +58,17 @@ def stream(items, ntfy_poller, mic_monitor): t0 = time.monotonic() w, h = tw(), th() fh = config.FIREHOSE_H if config.FIREHOSE else 0 - sh = h - fh + ticker_view_h = h - fh # ── 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() buf = [] if msg is not None: 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 = re.sub(r"\s+", " ", display_text.upper()) cache_key = (display_text, w) @@ -71,15 +77,17 @@ def stream(items, ntfy_poller, mic_monitor): _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) + msg_rows = lr_gradient_opposite(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) - 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 # Meta line: title (if distinct) + source + countdown meta_parts = [] @@ -87,49 +95,46 @@ def stream(items, ntfy_poller, mic_monitor): 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] - 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 - # Border — constant boundary between message and scroll + # Border — constant boundary under message panel bar = "\u2500" * (w - 4) - buf.append(f"\033[{row_idx+1};1H {MSG_BORDER}{bar}{RST}\033[K") - row_idx += 1 - msg_h = row_idx + msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}{RST}\033[K") - # Effective scroll zone: below message, above firehose - scroll_h = sh - msg_h + # Ticker draws above the fixed firehose strip; message is a centered overlay. + ticker_h = ticker_view_h - msg_h - # ── Scroll: headline rendering (always runs) ────── - # Advance scroll on schedule - scroll_accum += config.FRAME_DT - while scroll_accum >= scroll_interval: - scroll_accum -= scroll_interval - cam += 1 + # ── 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 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) - content, hc, midx = make_block(t, src, ts, w) - active.append((content, hc, next_y, midx)) - next_y += len(content) + GAP + 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) > cam] + if by + len(c) > scroll_cam] for k in list(noise_cache): - if k < cam: + if k < scroll_cam: del noise_cache[k] - # Draw scroll zone (below message zone, above firehose) - top_zone = max(1, int(scroll_h * 0.25)) - bot_zone = max(1, int(scroll_h * 0.10)) + # 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 - scroll_buf_start = len(buf) # track where scroll rows start in buf - for r in range(scroll_h): - scr_row = msg_h + r + 1 # 1-indexed ANSI screen row - cy = cam + r + 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, (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) drawn = False for content, hc, by, midx in active: @@ -160,22 +165,24 @@ def stream(items, ntfy_poller, mic_monitor): else: buf.append(f"\033[{scr_row};1H\033[K") - # Draw firehose zone - 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) + # 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) - scroll_buf_len = len(buf) - scroll_buf_start - if random.random() < glitch_prob and scroll_buf_len > 0: - for _ in range(min(n_hits, scroll_buf_len)): - gi = random.randint(0, scroll_buf_len - 1) - scr_row = msg_h + gi + 1 - buf[scroll_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}" + 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()