Compare commits
55 Commits
feat/stdou
...
bddbd69371
| Author | SHA1 | Date | |
|---|---|---|---|
| bddbd69371 | |||
| 1ba3848bed | |||
| a986df344a | |||
| c84bd5c05a | |||
| 7b0f886e53 | |||
| 9eeb817dca | |||
| ac80ab23cc | |||
| 516123345e | |||
| 11226872a1 | |||
| e6826c884c | |||
| 0740e34293 | |||
| 1e99d70387 | |||
| 7098b2f5aa | |||
| e7de09be50 | |||
| 9140bfd32b | |||
| c49c0aab33 | |||
| 66c13b5829 | |||
| 089c8ed66a | |||
| 086214f05e | |||
| 0f762475b5 | |||
| b00b612da0 | |||
| 39dab4b22b | |||
| 47f17e12ef | |||
| 851c4a77b4 | |||
| cdbb6dfd1c | |||
| 45a202e955 | |||
| 339510dd60 | |||
| 9bd8115c55 | |||
| 2c777729f5 | |||
| 0e500d1b71 | |||
| 3571e2780b | |||
| dfd902fb90 | |||
| 2e6b2c48bd | |||
| 1ff2e54586 | |||
| 424332e065 | |||
| f6ad89769f | |||
| d3c403848c | |||
| 119ed193c0 | |||
| dcc3718012 | |||
| 2e69cad984 | |||
| 7274f57bbb | |||
| c857d7bd81 | |||
| 6a5a73fd88 | |||
| 5474c58ce0 | |||
| 571da4fa47 | |||
| 6d7ab770cd | |||
| ed3006677f | |||
| b8b38cd0ad | |||
| 030c75f30d | |||
| 543c4ed50d | |||
| e2467bff4d | |||
| 5b4c6cbaac | |||
| 270f119184 | |||
| ae81ba9b79 | |||
| 9979d955ed |
109
Mainline Renderer + ntfy Message Queue for ESP32.md
Normal file
109
Mainline Renderer + ntfy Message Queue for ESP32.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Mainline Renderer \+ ntfy Message Queue for ESP32
|
||||||
|
## Problem
|
||||||
|
mainline\.py does heavy work unsuitable for ESP32: 25\+ HTTPS/TLS RSS feeds, OTF font rasterization via Pillow, Google Translate API calls, and complex text layout\. Simultaneously, messages arriving on `ntfy.sh/klubhaus_terminal_mainline` need to interrupt the news ticker on the same device\.
|
||||||
|
## Architecture: Server \+ Thin Client
|
||||||
|
Split the system into two halves that are designed together\.
|
||||||
|
**Server (mainline\.py `--serve` mode, runs on any always\-on machine)**
|
||||||
|
* Reuses existing feed fetching, caching, content filtering, translation, and Pillow font rendering pipeline — no duplication\.
|
||||||
|
* Pre\-renders each headline into a 1\-bit bitmap strip (the OTF→half\-block pipeline already produces this as an intermediate step in `_render_line()`)\.
|
||||||
|
* Exposes a lightweight HTTP API the ESP32 polls\.
|
||||||
|
**ESP32 thin client (Arduino sketch)**
|
||||||
|
* Polls the mainline server for pre\-rendered headline bitmaps over plain HTTP (no TLS needed if on the same LAN)\.
|
||||||
|
* Polls `ntfy.sh/klubhaus_terminal_mainline` directly for messages, reusing the proven `NetManager::httpGet()` \+ JSON parsing pattern from DoorbellLogic (`DoorbellLogic.cpp:155-192`)\.
|
||||||
|
* Manages scrolling, gradient coloring, and glitch effects locally (cheap per\-frame GPU work)\.
|
||||||
|
* When an ntfy message arrives, the scroll is paused and the message takes over the display — same interrupt pattern as the doorbell's ALERT→DASHBOARD flow\.
|
||||||
|
## Server API (mainline repo)
|
||||||
|
New file: `serve.py` (or `--serve` mode in mainline\.py)\.
|
||||||
|
Endpoints:
|
||||||
|
* `GET /api/headlines` — returns JSON array of headline metadata: `[{"id": 0, "src": "Nature", "ts": "14:30", "width": 280, "height": 16, "bitmap": "<base64 1-bit packed>"}]`\. Bitmaps are 1\-bit\-per\-pixel, row\-major, packed 8px/byte\. The ESP32 applies gradient color locally\.
|
||||||
|
* `GET /api/config` — returns `{"count": 120, "version": "...", "mode": "news"}` so the ESP32 knows what it's getting\.
|
||||||
|
* `GET /api/health` — `{"ok": true, "last_fetch": "...", "headline_count": 120}`
|
||||||
|
The server renders at a configurable target width (e\.g\. 800px for Board 3, 320px for Boards 1/2) via a `--width` flag or query parameter\. Height is fixed per headline by the font size\.
|
||||||
|
The server refreshes feeds on a timer (reusing `_SCROLL_DUR` cadence or a longer interval), re\-renders, and serves the latest set\. The ESP32 polls `/api/headlines` periodically (e\.g\. every 60s) and swaps in the new set\.
|
||||||
|
## Render pipeline (server side)
|
||||||
|
The existing `_render_line()` in mainline\.py already does:
|
||||||
|
1. `ImageFont.truetype()` → `ImageDraw.text()` → grayscale `Image`
|
||||||
|
2. Resize to target height
|
||||||
|
3. Threshold to 1\-bit (the `thr = 80` step)
|
||||||
|
For the server, we stop at step 3 and pack the 1\-bit data into bytes instead of converting to half\-block Unicode\. This is the exact same pipeline, just with a different output format\. The `_big_wrap()` and `_lr_gradient()` logic stays on the server for layout; gradient *coloring* moves to the ESP32 (it's just an index lookup per pixel column)\.
|
||||||
|
## ESP32 client
|
||||||
|
### State machine
|
||||||
|
```warp-runnable-command
|
||||||
|
BOOT → SCROLL ⇄ MESSAGE
|
||||||
|
↘ OFF (inactivity timeout)
|
||||||
|
```
|
||||||
|
* **BOOT** — WiFi connect, initial headline fetch from server\.
|
||||||
|
* **SCROLL** — Vertical scroll through pre\-rendered headlines with local gradient \+ glitch\. Polls server for new headlines periodically\. Polls ntfy every 15s\.
|
||||||
|
* **MESSAGE** — ntfy message arrived\. Scroll paused, message displayed\. Auto\-dismiss after timeout or touch\-dismiss\. Returns to SCROLL\.
|
||||||
|
* **OFF** — Backlight off after inactivity (polling continues in background)\.
|
||||||
|
### ntfy integration
|
||||||
|
The ESP32 polls `https://ntfy.sh/klubhaus_terminal_mainline/json?since=20s&poll=1` on the same 15s interval as the doorbell polls its topics\. When a message event arrives:
|
||||||
|
1. Parse JSON: `{"event": "message", "title": "...", "message": "..."}`
|
||||||
|
2. Save current scroll position\.
|
||||||
|
3. Transition to MESSAGE state\.
|
||||||
|
4. Render message text using the display library's built\-in fonts (messages are short, no custom font needed)\.
|
||||||
|
5. After `MESSAGE_TIMEOUT_MS` (e\.g\. 30s) or touch, restore scroll position and resume\.
|
||||||
|
This is architecturally identical to `DoorbellLogic::onAlert()` → `dismissAlert()`, just with different content\. The ntfy polling runs independently of the server connection, so messages work even if the mainline server is offline (the device just shows the last cached headlines)\.
|
||||||
|
### Headline storage
|
||||||
|
* Board 3 (8 MB PSRAM): store all ~120 headline bitmaps in PSRAM\. At 800px × 16px × 1 bit = 1\.6 KB each → ~192 KB total\. Trivial\.
|
||||||
|
* Boards 1/2 (PSRAM TBD): at 320px × 16px = 640 bytes each → ~77 KB for 120 headlines\. Fits if PSRAM is present\. Without PSRAM, keep ~20 headlines in a ring buffer (~13 KB)\.
|
||||||
|
### Gradient coloring (local)
|
||||||
|
The 12\-step ANSI gradient in mainline\.py maps to 12 RGB565 values:
|
||||||
|
```warp-runnable-command
|
||||||
|
const uint16_t GRADIENT[] = {
|
||||||
|
0xFFFF, // white
|
||||||
|
0xC7FF, // pale cyan
|
||||||
|
0x07F9, // bright cyan
|
||||||
|
0x07C0, // bright lime
|
||||||
|
...
|
||||||
|
0x0120, // deep green
|
||||||
|
0x18C3, // near black
|
||||||
|
};
|
||||||
|
```
|
||||||
|
For each pixel column `x` in the bitmap, the ESP32 picks `GRADIENT[x * 12 / width]` if the bit is set, else background color\. This is a per\-pixel multiply \+ table lookup — fast\.
|
||||||
|
## File layout
|
||||||
|
### mainline repo
|
||||||
|
```warp-runnable-command
|
||||||
|
mainline.py (existing, unchanged)
|
||||||
|
serve.py (new — HTTP server, imports mainline rendering functions)
|
||||||
|
klubhaus-doorbell-hardware.md (existing)
|
||||||
|
```
|
||||||
|
`serve.py` imports the rendering functions from mainline\.py (after refactoring them into importable form — they're currently top\-level but not wrapped in `if __name__`)\.
|
||||||
|
### klubhaus\-doorbell repo (or mainline repo under firmware/)
|
||||||
|
```warp-runnable-command
|
||||||
|
boards/esp32-mainline/
|
||||||
|
├── esp32-mainline.ino Main sketch
|
||||||
|
├── board_config.h Display/pin config (copy from target board)
|
||||||
|
├── secrets.h WiFi creds + server URL
|
||||||
|
├── MainlineLogic.h/.cpp State machine (replaces DoorbellLogic)
|
||||||
|
├── HeadlineStore.h/.cpp Bitmap ring buffer in PSRAM
|
||||||
|
└── NtfyPoller.h/.cpp ntfy.sh polling (extracted from DoorbellLogic pattern)
|
||||||
|
```
|
||||||
|
The display driver is reused from the target board (e\.g\. `DisplayDriverGFX` for Board 3)\. `MainlineLogic` replaces `DoorbellLogic` as the state machine but follows the same patterns\.
|
||||||
|
## Branch strategy recommendation
|
||||||
|
The work spans two repos and has clear dependency ordering\.
|
||||||
|
### Phase 1 — Finish current branch (mainline repo)
|
||||||
|
**Branch:** `feat/arduino` (current)
|
||||||
|
**Content:** Hardware spec doc\. Already done\.
|
||||||
|
**Action:** Merge to main when ready\.
|
||||||
|
### Phase 2 — Server renderer (mainline repo)
|
||||||
|
**Branch:** `feat/renderer` (branch from main after Phase 1 merges)
|
||||||
|
**Content:**
|
||||||
|
* Refactor mainline\.py rendering functions to be importable (extract from `__main__` guard)
|
||||||
|
* `serve.py` — HTTP server with `/api/headlines`, `/api/config`, `/api/health`
|
||||||
|
* Bitmap packing utility (1\-bit row\-major)
|
||||||
|
**Why a separate branch:** This changes mainline\.py's structure (refactoring for imports) and adds a new entry point\. It's a self\-contained, testable unit — you can verify the API with `curl` before touching any Arduino code\.
|
||||||
|
### Phase 3 — ESP32 client (klubhaus\-doorbell repo, or mainline repo)
|
||||||
|
**Branch:** `feat/mainline-client` in whichever repo hosts it
|
||||||
|
**Content:**
|
||||||
|
* `MainlineLogic` state machine
|
||||||
|
* `HeadlineStore` bitmap buffer
|
||||||
|
* `NtfyPoller` for `klubhaus_terminal_mainline`
|
||||||
|
* Board\-specific sketch for the target board
|
||||||
|
**Depends on:** Phase 2 (needs a running server to test against)
|
||||||
|
**Repo decision:** If you have push access to klubhaus\-doorbell, it fits naturally as a new board target alongside the existing doorbell sketches — it reuses `NetManager`, `IDisplayDriver`, and the vendored display libraries\. If not, put it under `mainline/firmware/` and vendor the shared KlubhausCore library\.
|
||||||
|
### Merge order
|
||||||
|
1. `feat/arduino` → main (hardware spec)
|
||||||
|
2. `feat/renderer` → main (server)
|
||||||
|
3. `feat/mainline-client` → main in whichever repo (ESP32 client)
|
||||||
|
Each phase is independently testable and doesn't block the other until Phase 3 needs a running server\.
|
||||||
111
README.md
111
README.md
@@ -2,54 +2,128 @@
|
|||||||
|
|
||||||
> *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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
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)
|
||||||
|
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.
|
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_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
|
||||||
| `_FONT_SZ` | `60` | Font render size (affects block density) |
|
| `FONT_PATH` | first file in `FONT_DIR` | Active display font (overridden by picker or `--font-file`) |
|
||||||
| `_RENDER_H` | `8` | Terminal rows per headline line |
|
| `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) |
|
||||||
|
| `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.
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
- Feeds are fetched and filtered on startup (sports and vapid content stripped)
|
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection; `--no-font-picker` skips directly to stream
|
||||||
- Headlines are rasterized via Pillow into half-block characters (`▀▄█ `) at the configured font size
|
- 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
|
||||||
- A left-to-right ANSI gradient colors each character: white-hot leading edge trails off to near-black
|
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters (`▀▄█ `) at the configured font size
|
||||||
|
- 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
|
||||||
|
- 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(), font picker TUI, 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 +136,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 +148,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 (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.9+.*
|
||||||
|
|||||||
178
Refactor mainline.md
Normal file
178
Refactor mainline.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# Refactor mainline\.py into modular package
|
||||||
|
## Problem
|
||||||
|
`mainline.py` is a single 1085\-line file with ~10 interleaved concerns\. This prevents:
|
||||||
|
* Reusing the ntfy doorbell interrupt in other visualizers
|
||||||
|
* Importing the render pipeline from `serve.py` \(future ESP32 HTTP server\)
|
||||||
|
* Testing any concern in isolation
|
||||||
|
* Porting individual layers to Rust independently
|
||||||
|
## Target structure
|
||||||
|
```warp-runnable-command
|
||||||
|
mainline.py # thin entrypoint: venv bootstrap → engine.app.main()
|
||||||
|
engine/
|
||||||
|
__init__.py
|
||||||
|
config.py # constants, CLI flags, glyph tables
|
||||||
|
sources.py # FEEDS, POETRY_SOURCES, SOURCE_LANGS, _LOCATION_LANGS
|
||||||
|
terminal.py # ANSI codes, tw/th, type_out, slow_print, boot_ln
|
||||||
|
filter.py # HTML stripping, content filter (_SKIP_RE)
|
||||||
|
translate.py # Google Translate wrapper + location→language detection
|
||||||
|
render.py # OTF font loading, _render_line, _big_wrap, _lr_gradient, _make_block
|
||||||
|
effects.py # noise, glitch_bar, _fade_line, _vis_trunc, _firehose_line, _next_headline
|
||||||
|
fetch.py # RSS/Gutenberg fetching, cache load/save
|
||||||
|
ntfy.py # NtfyPoller class — standalone, zero internal deps
|
||||||
|
mic.py # MicMonitor class — standalone
|
||||||
|
scroll.py # stream() frame loop + message rendering
|
||||||
|
app.py # main(), TITLE art, boot sequence, signal handler
|
||||||
|
```
|
||||||
|
The package is named `engine/` to avoid a naming conflict with the `mainline.py` entrypoint\.
|
||||||
|
## Module dependency graph
|
||||||
|
```warp-runnable-command
|
||||||
|
config ← (nothing)
|
||||||
|
sources ← (nothing)
|
||||||
|
terminal ← (nothing)
|
||||||
|
filter ← (nothing)
|
||||||
|
translate ← sources
|
||||||
|
render ← config, terminal, sources
|
||||||
|
effects ← config, terminal, sources
|
||||||
|
fetch ← config, sources, filter, terminal
|
||||||
|
ntfy ← (nothing — stdlib only, fully standalone)
|
||||||
|
mic ← (nothing — sounddevice only)
|
||||||
|
scroll ← config, terminal, render, effects, ntfy, mic
|
||||||
|
app ← everything above
|
||||||
|
```
|
||||||
|
Critical property: **ntfy\.py and mic\.py have zero internal dependencies**, making ntfy reusable by any visualizer\.
|
||||||
|
## Module details
|
||||||
|
### mainline\.py \(entrypoint — slimmed down\)
|
||||||
|
Keeps only the venv bootstrap \(lines 10\-38\) which must run before any third\-party imports\. After bootstrap, delegates to `engine.app.main()`\.
|
||||||
|
### engine/config\.py
|
||||||
|
From current mainline\.py:
|
||||||
|
* `HEADLINE_LIMIT`, `FEED_TIMEOUT`, `MIC_THRESHOLD_DB` \(lines 55\-57\)
|
||||||
|
* `MODE`, `FIREHOSE` CLI flag parsing \(lines 58\-59\)
|
||||||
|
* `NTFY_TOPIC`, `NTFY_POLL_INTERVAL`, `MESSAGE_DISPLAY_SECS` \(lines 62\-64\)
|
||||||
|
* `_FONT_PATH`, `_FONT_SZ`, `_RENDER_H` \(lines 147\-150\)
|
||||||
|
* `_SCROLL_DUR`, `_FRAME_DT`, `FIREHOSE_H` \(lines 505\-507\)
|
||||||
|
* `GLITCH`, `KATA` glyph tables \(lines 143\-144\)
|
||||||
|
### engine/sources\.py
|
||||||
|
Pure data, no logic:
|
||||||
|
* `FEEDS` dict \(lines 102\-140\)
|
||||||
|
* `POETRY_SOURCES` dict \(lines 67\-80\)
|
||||||
|
* `SOURCE_LANGS` dict \(lines 258\-266\)
|
||||||
|
* `_LOCATION_LANGS` dict \(lines 269\-289\)
|
||||||
|
* `_SCRIPT_FONTS` dict \(lines 153\-165\)
|
||||||
|
* `_NO_UPPER` set \(line 167\)
|
||||||
|
### engine/terminal\.py
|
||||||
|
ANSI primitives and terminal I/O:
|
||||||
|
* All ANSI constants: `RST`, `BOLD`, `DIM`, `G_HI`, `G_MID`, `G_LO`, `G_DIM`, `W_COOL`, `W_DIM`, `W_GHOST`, `C_DIM`, `CLR`, `CURSOR_OFF`, `CURSOR_ON` \(lines 83\-99\)
|
||||||
|
* `tw()`, `th()` \(lines 223\-234\)
|
||||||
|
* `type_out()`, `slow_print()`, `boot_ln()` \(lines 355\-386\)
|
||||||
|
### engine/filter\.py
|
||||||
|
* `_Strip` HTML parser class \(lines 205\-214\)
|
||||||
|
* `strip_tags()` \(lines 217\-220\)
|
||||||
|
* `_SKIP_RE` compiled regex \(lines 322\-346\)
|
||||||
|
* `_skip()` predicate \(lines 349\-351\)
|
||||||
|
### engine/translate\.py
|
||||||
|
* `_TRANSLATE_CACHE` \(line 291\)
|
||||||
|
* `_detect_location_language()` \(lines 294\-300\) — imports `_LOCATION_LANGS` from sources
|
||||||
|
* `_translate_headline()` \(lines 303\-319\)
|
||||||
|
### engine/render\.py
|
||||||
|
The OTF→terminal pipeline\. This is exactly what `serve.py` will import to produce 1\-bit bitmaps for the ESP32\.
|
||||||
|
* `_GRAD_COLS` gradient table \(lines 169\-182\)
|
||||||
|
* `_font()`, `_font_for_lang()` with lazy\-load \+ cache \(lines 185\-202\)
|
||||||
|
* `_render_line()` — OTF text → half\-block terminal rows \(lines 567\-605\)
|
||||||
|
* `_big_wrap()` — word\-wrap \+ render \(lines 608\-636\)
|
||||||
|
* `_lr_gradient()` — apply left→right color gradient \(lines 639\-656\)
|
||||||
|
* `_make_block()` — composite: translate → render → colorize a headline \(lines 718\-756\)\. Imports from translate, sources\.
|
||||||
|
### engine/effects\.py
|
||||||
|
Visual effects applied during the frame loop:
|
||||||
|
* `noise()` \(lines 237\-245\)
|
||||||
|
* `glitch_bar()` \(lines 248\-252\)
|
||||||
|
* `_fade_line()` — probabilistic character dissolve \(lines 659\-680\)
|
||||||
|
* `_vis_trunc()` — ANSI\-aware width truncation \(lines 683\-701\)
|
||||||
|
* `_firehose_line()` \(lines 759\-801\) — imports config\.MODE, sources\.FEEDS/POETRY\_SOURCES
|
||||||
|
* `_next_headline()` — pool management \(lines 704\-715\)
|
||||||
|
### engine/fetch\.py
|
||||||
|
* `fetch_feed()` \(lines 390\-396\)
|
||||||
|
* `fetch_all()` \(lines 399\-426\) — imports filter\.\_skip, filter\.strip\_tags, terminal\.boot\_ln
|
||||||
|
* `_fetch_gutenberg()` \(lines 429\-456\)
|
||||||
|
* `fetch_poetry()` \(lines 459\-472\)
|
||||||
|
* `_cache_path()`, `_load_cache()`, `_save_cache()` \(lines 476\-501\)
|
||||||
|
### engine/ntfy\.py — standalone, reusable
|
||||||
|
Refactored from the current globals \+ thread \(lines 531\-564\) and the message rendering section of `stream()` \(lines 845\-909\) into a class:
|
||||||
|
```python
|
||||||
|
class NtfyPoller:
|
||||||
|
def __init__(self, topic_url, poll_interval=15, display_secs=30):
|
||||||
|
...
|
||||||
|
def start(self):
|
||||||
|
"""Start background polling thread."""
|
||||||
|
def get_active_message(self):
|
||||||
|
"""Return (title, body, timestamp) if a message is active and not expired, else None."""
|
||||||
|
def dismiss(self):
|
||||||
|
"""Manually dismiss current message."""
|
||||||
|
```
|
||||||
|
Dependencies: `urllib.request`, `json`, `threading`, `time` — all stdlib\. No internal imports\.
|
||||||
|
Other visualizers use it like:
|
||||||
|
```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()
|
||||||
|
if msg:
|
||||||
|
title, body, ts = msg
|
||||||
|
render_my_message(title, body) # visualizer-specific
|
||||||
|
```
|
||||||
|
### engine/mic\.py — standalone
|
||||||
|
Refactored from the current globals \(lines 508\-528\) into a class:
|
||||||
|
```python
|
||||||
|
class MicMonitor:
|
||||||
|
def __init__(self, threshold_db=50):
|
||||||
|
...
|
||||||
|
def start(self) -> bool:
|
||||||
|
"""Start background mic stream. Returns False if unavailable."""
|
||||||
|
def stop(self):
|
||||||
|
...
|
||||||
|
@property
|
||||||
|
def db(self) -> float:
|
||||||
|
"""Current RMS dB level."""
|
||||||
|
@property
|
||||||
|
def excess(self) -> float:
|
||||||
|
"""dB above threshold (clamped to 0)."""
|
||||||
|
```
|
||||||
|
Dependencies: `sounddevice`, `numpy` \(both optional — graceful fallback\)\.
|
||||||
|
### engine/scroll\.py
|
||||||
|
The `stream()` function \(lines 804\-990\)\. Receives its dependencies via arguments or imports:
|
||||||
|
* `stream(items, ntfy_poller, mic_monitor, config)` or similar
|
||||||
|
* Message rendering \(lines 855\-909\) stays here since it's terminal\-display\-specific — a different visualizer would render messages differently
|
||||||
|
### engine/app\.py
|
||||||
|
The orchestrator:
|
||||||
|
* `TITLE` ASCII art \(lines 994\-1001\)
|
||||||
|
* `main()` \(lines 1004\-1084\): CLI handling, signal setup, boot animation, fetch, wire up ntfy/mic/scroll
|
||||||
|
## Execution order
|
||||||
|
### Step 1: Create engine/ package skeleton
|
||||||
|
Create `engine/__init__.py` and all empty module files\.
|
||||||
|
### Step 2: Extract pure data modules \(zero\-dep\)
|
||||||
|
Move constants and data dicts into `config.py`, `sources.py`\. These have no logic dependencies\.
|
||||||
|
### Step 3: Extract terminal\.py
|
||||||
|
Move ANSI codes and terminal I/O helpers\. No internal deps\.
|
||||||
|
### Step 4: Extract filter\.py and translate\.py
|
||||||
|
Both are small, self\-contained\. translate imports from sources\.
|
||||||
|
### Step 5: Extract render\.py
|
||||||
|
Font loading \+ the OTF→half\-block pipeline\. Imports from config, terminal, sources\. This is the module `serve.py` will later import\.
|
||||||
|
### Step 6: Extract effects\.py
|
||||||
|
Visual effects\. Imports from config, terminal, sources\.
|
||||||
|
### Step 7: Extract fetch\.py
|
||||||
|
Feed/Gutenberg fetching \+ caching\. Imports from config, sources, filter, terminal\.
|
||||||
|
### Step 8: Extract ntfy\.py and mic\.py
|
||||||
|
Refactor globals\+threads into classes\. Zero internal deps\.
|
||||||
|
### Step 9: Extract scroll\.py
|
||||||
|
The frame loop\. Last to extract because it depends on everything above\.
|
||||||
|
### Step 10: Extract app\.py
|
||||||
|
The `main()` function, boot sequence, signal handler\. Wire up all modules\.
|
||||||
|
### Step 11: Slim down mainline\.py
|
||||||
|
Keep only venv bootstrap \+ `from engine.app import main; main()`\.
|
||||||
|
### Step 12: Verify
|
||||||
|
Run `python3 mainline.py`, `python3 mainline.py --poetry`, and `python3 mainline.py --firehose` to confirm identical behavior\. No behavioral changes in this refactor\.
|
||||||
|
## What this enables
|
||||||
|
* **serve\.py** \(future\): `from engine.render import _render_line, _big_wrap` \+ `from engine.fetch import fetch_all` — imports the pipeline directly
|
||||||
|
* **Other visualizers**: `from engine.ntfy import NtfyPoller` — doorbell feature with no coupling to mainline's scroll engine
|
||||||
|
* **Rust port**: Clear boundaries for what to port first \(ntfy client, render pipeline\) vs what stays in Python \(fetching, caching — the server side\)
|
||||||
|
* **Testing**: Each module can be unit\-tested in isolation
|
||||||
1
engine/__init__.py
Normal file
1
engine/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# engine — modular internals for mainline
|
||||||
321
engine/app.py
Normal file
321
engine/app.py
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
"""
|
||||||
|
Application orchestrator — boot sequence, signal handling, main loop wiring.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import signal
|
||||||
|
import atexit
|
||||||
|
import termios
|
||||||
|
import tty
|
||||||
|
|
||||||
|
from engine import config, render
|
||||||
|
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 _read_picker_key():
|
||||||
|
ch = sys.stdin.read(1)
|
||||||
|
if ch == "\x03":
|
||||||
|
return "interrupt"
|
||||||
|
if ch in ("\r", "\n"):
|
||||||
|
return "enter"
|
||||||
|
if ch == "\x1b":
|
||||||
|
c1 = sys.stdin.read(1)
|
||||||
|
if c1 != "[":
|
||||||
|
return None
|
||||||
|
c2 = sys.stdin.read(1)
|
||||||
|
if c2 == "A":
|
||||||
|
return "up"
|
||||||
|
if c2 == "B":
|
||||||
|
return "down"
|
||||||
|
return None
|
||||||
|
if ch in ("k", "K"):
|
||||||
|
return "up"
|
||||||
|
if ch in ("j", "J"):
|
||||||
|
return "down"
|
||||||
|
if ch in ("q", "Q"):
|
||||||
|
return "enter"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _normalize_preview_rows(rows):
|
||||||
|
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
|
||||||
|
non_empty = [r for r in rows if r.strip()]
|
||||||
|
if not non_empty:
|
||||||
|
return [""]
|
||||||
|
left_pad = min(len(r) - len(r.lstrip(" ")) for r in non_empty)
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
if left_pad < len(row):
|
||||||
|
out.append(row[left_pad:].rstrip())
|
||||||
|
else:
|
||||||
|
out.append(row.rstrip())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_font_picker(faces, selected):
|
||||||
|
w = tw()
|
||||||
|
h = 24
|
||||||
|
try:
|
||||||
|
h = os.get_terminal_size().lines
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
max_preview_w = max(24, w - 8)
|
||||||
|
header_h = 6
|
||||||
|
footer_h = 3
|
||||||
|
preview_h = max(4, min(config.RENDER_H + 2, max(4, h // 2)))
|
||||||
|
visible = max(1, h - header_h - preview_h - footer_h)
|
||||||
|
top = max(0, selected - (visible // 2))
|
||||||
|
bottom = min(len(faces), top + visible)
|
||||||
|
top = max(0, bottom - visible)
|
||||||
|
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
print(f" {G_HI}FONT PICKER{RST}")
|
||||||
|
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||||
|
print(f" {W_DIM}{config.FONT_DIR[:max_preview_w]}{RST}")
|
||||||
|
print(f" {W_GHOST}↑/↓ move · Enter select · q accept current{RST}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for pos in range(top, bottom):
|
||||||
|
face = faces[pos]
|
||||||
|
active = pos == selected
|
||||||
|
pointer = "▶" if active else " "
|
||||||
|
color = G_HI if active else W_DIM
|
||||||
|
print(f" {color}{pointer} {face['name']}{RST}{W_GHOST} · {face['file_name']}{RST}")
|
||||||
|
|
||||||
|
if top > 0:
|
||||||
|
print(f" {W_GHOST}… {top} above{RST}")
|
||||||
|
if bottom < len(faces):
|
||||||
|
print(f" {W_GHOST}… {len(faces) - bottom} below{RST}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||||
|
print(
|
||||||
|
f" {W_DIM}Preview: {faces[selected]['name']} · {faces[selected]['file_name']}{RST}"
|
||||||
|
)
|
||||||
|
preview_rows = faces[selected]["preview_rows"][:preview_h]
|
||||||
|
for row in preview_rows:
|
||||||
|
shown = row[:max_preview_w]
|
||||||
|
print(f" {shown}")
|
||||||
|
|
||||||
|
def pick_font_face():
|
||||||
|
"""Interactive startup picker for selecting a face from repo OTF files."""
|
||||||
|
if not config.FONT_PICKER:
|
||||||
|
return
|
||||||
|
|
||||||
|
font_files = config.list_repo_font_files()
|
||||||
|
if not font_files:
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
print(f" {G_HI}FONT PICKER{RST}")
|
||||||
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||||
|
print(f" {G_DIM}> no .otf/.ttf/.ttc files found in: {config.FONT_DIR}{RST}")
|
||||||
|
print(f" {W_GHOST}> add font files to the fonts folder, then rerun{RST}")
|
||||||
|
time.sleep(1.8)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
prepared = []
|
||||||
|
for font_path in font_files:
|
||||||
|
try:
|
||||||
|
faces = render.list_font_faces(font_path, max_faces=64)
|
||||||
|
except Exception:
|
||||||
|
fallback = os.path.splitext(os.path.basename(font_path))[0]
|
||||||
|
faces = [{"index": 0, "name": fallback}]
|
||||||
|
for face in faces:
|
||||||
|
idx = face["index"]
|
||||||
|
name = face["name"]
|
||||||
|
file_name = os.path.basename(font_path)
|
||||||
|
try:
|
||||||
|
fnt = render.load_font_face(font_path, idx)
|
||||||
|
rows = _normalize_preview_rows(render.render_line(name, fnt))
|
||||||
|
except Exception:
|
||||||
|
rows = ["(preview unavailable)"]
|
||||||
|
prepared.append(
|
||||||
|
{
|
||||||
|
"font_path": font_path,
|
||||||
|
"font_index": idx,
|
||||||
|
"name": name,
|
||||||
|
"file_name": file_name,
|
||||||
|
"preview_rows": rows,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not prepared:
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
print(f" {G_HI}FONT PICKER{RST}")
|
||||||
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||||
|
print(f" {G_DIM}> no readable font faces found in: {config.FONT_DIR}{RST}")
|
||||||
|
time.sleep(1.8)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def _same_path(a, b):
|
||||||
|
try:
|
||||||
|
return os.path.samefile(a, b)
|
||||||
|
except Exception:
|
||||||
|
return os.path.abspath(a) == os.path.abspath(b)
|
||||||
|
|
||||||
|
selected = next(
|
||||||
|
(
|
||||||
|
i
|
||||||
|
for i, f in enumerate(prepared)
|
||||||
|
if _same_path(f["font_path"], config.FONT_PATH)
|
||||||
|
and f["font_index"] == config.FONT_INDEX
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
selected_font = prepared[selected]
|
||||||
|
config.set_font_selection(
|
||||||
|
font_path=selected_font["font_path"],
|
||||||
|
font_index=selected_font["font_index"],
|
||||||
|
)
|
||||||
|
render.clear_font_cache()
|
||||||
|
print(
|
||||||
|
f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}"
|
||||||
|
)
|
||||||
|
time.sleep(0.8)
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
return
|
||||||
|
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
old_settings = termios.tcgetattr(fd)
|
||||||
|
try:
|
||||||
|
tty.setcbreak(fd)
|
||||||
|
while True:
|
||||||
|
_draw_font_picker(prepared, selected)
|
||||||
|
key = _read_picker_key()
|
||||||
|
if key == "up":
|
||||||
|
selected = max(0, selected - 1)
|
||||||
|
elif key == "down":
|
||||||
|
selected = min(len(prepared) - 1, selected + 1)
|
||||||
|
elif key == "enter":
|
||||||
|
break
|
||||||
|
elif key == "interrupt":
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||||
|
|
||||||
|
selected_font = prepared[selected]
|
||||||
|
config.set_font_selection(
|
||||||
|
font_path=selected_font["font_path"],
|
||||||
|
font_index=selected_font["font_index"],
|
||||||
|
)
|
||||||
|
render.clear_font_cache()
|
||||||
|
print(f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}")
|
||||||
|
time.sleep(0.8)
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
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="")
|
||||||
|
pick_font_face()
|
||||||
|
w = tw()
|
||||||
|
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()
|
||||||
100
engine/config.py
Normal file
100
engine/config.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
Configuration constants, CLI flags, and glyph tables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
_FONT_EXTENSIONS = {".otf", ".ttf", ".ttc"}
|
||||||
|
|
||||||
|
|
||||||
|
def _arg_value(flag):
|
||||||
|
"""Get value following a CLI flag, if present."""
|
||||||
|
if flag not in sys.argv:
|
||||||
|
return None
|
||||||
|
i = sys.argv.index(flag)
|
||||||
|
return sys.argv[i + 1] if i + 1 < len(sys.argv) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _arg_int(flag, default):
|
||||||
|
"""Get int CLI argument with safe fallback."""
|
||||||
|
raw = _arg_value(flag)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_font_path(raw_path):
|
||||||
|
"""Resolve font path; relative paths are anchored to repo root."""
|
||||||
|
p = Path(raw_path).expanduser()
|
||||||
|
if p.is_absolute():
|
||||||
|
return str(p)
|
||||||
|
return str((_REPO_ROOT / p).resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def _list_font_files(font_dir):
|
||||||
|
"""List supported font files within a font directory."""
|
||||||
|
font_root = Path(font_dir)
|
||||||
|
if not font_root.exists() or not font_root.is_dir():
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
str(path.resolve())
|
||||||
|
for path in sorted(font_root.iterdir())
|
||||||
|
if path.is_file() and path.suffix.lower() in _FONT_EXTENSIONS
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def list_repo_font_files():
|
||||||
|
"""Public helper for discovering repository font files."""
|
||||||
|
return _list_font_files(FONT_DIR)
|
||||||
|
# ─── RUNTIME ──────────────────────────────────────────────
|
||||||
|
HEADLINE_LIMIT = 1000
|
||||||
|
FEED_TIMEOUT = 10
|
||||||
|
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
||||||
|
MODE = 'poetry' if '--poetry' in sys.argv or '-p' in sys.argv else 'news'
|
||||||
|
FIREHOSE = '--firehose' in sys.argv
|
||||||
|
|
||||||
|
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
|
||||||
|
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json?since=20s&poll=1"
|
||||||
|
NTFY_POLL_INTERVAL = 15 # seconds between polls
|
||||||
|
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
||||||
|
|
||||||
|
# ─── FONT RENDERING ──────────────────────────────────────
|
||||||
|
FONT_DIR = _resolve_font_path(_arg_value('--font-dir') or "fonts")
|
||||||
|
_FONT_FILE_ARG = _arg_value('--font-file')
|
||||||
|
_FONT_FILES = _list_font_files(FONT_DIR)
|
||||||
|
FONT_PATH = (
|
||||||
|
_resolve_font_path(_FONT_FILE_ARG)
|
||||||
|
if _FONT_FILE_ARG
|
||||||
|
else (_FONT_FILES[0] if _FONT_FILES else "")
|
||||||
|
)
|
||||||
|
FONT_INDEX = max(0, _arg_int('--font-index', 0))
|
||||||
|
FONT_PICKER = '--no-font-picker' not in sys.argv
|
||||||
|
FONT_SZ = 60
|
||||||
|
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_DUR = 5.625 # seconds per headline (2/3 original speed)
|
||||||
|
FRAME_DT = 0.05 # 50ms base frame rate (20 FPS)
|
||||||
|
FIREHOSE_H = 12 # firehose zone height (terminal rows)
|
||||||
|
GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep)
|
||||||
|
|
||||||
|
# ─── GLYPHS ───────────────────────────────────────────────
|
||||||
|
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||||
|
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
||||||
|
|
||||||
|
|
||||||
|
def set_font_selection(font_path=None, font_index=None):
|
||||||
|
"""Set runtime primary font selection."""
|
||||||
|
global FONT_PATH, FONT_INDEX
|
||||||
|
if font_path is not None:
|
||||||
|
FONT_PATH = _resolve_font_path(font_path)
|
||||||
|
if font_index is not None:
|
||||||
|
FONT_INDEX = max(0, int(font_index))
|
||||||
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
|
||||||
60
engine/filter.py
Normal file
60
engine/filter.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
HTML stripping and content filter (sports, vapid, insipid).
|
||||||
|
No internal dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from html import unescape
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
|
||||||
|
# ─── HTML STRIPPING ───────────────────────────────────────
|
||||||
|
class _Strip(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._t = []
|
||||||
|
|
||||||
|
def handle_data(self, d):
|
||||||
|
self._t.append(d)
|
||||||
|
|
||||||
|
def text(self):
|
||||||
|
return "".join(self._t).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def strip_tags(html):
|
||||||
|
s = _Strip()
|
||||||
|
s.feed(unescape(html or ""))
|
||||||
|
return s.text()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CONTENT FILTER ───────────────────────────────────────
|
||||||
|
_SKIP_RE = re.compile(
|
||||||
|
r'\b(?:'
|
||||||
|
# ── sports ──
|
||||||
|
r'football|soccer|basketball|baseball|softball|tennis|golf|cricket|rugby|'
|
||||||
|
r'hockey|lacrosse|volleyball|badminton|'
|
||||||
|
r'nba|nfl|nhl|mlb|mls|fifa|uefa|'
|
||||||
|
r'premier league|champions league|la liga|serie a|bundesliga|'
|
||||||
|
r'world cup|super bowl|world series|stanley cup|'
|
||||||
|
r'playoff|playoffs|touchdown|goalkeeper|striker|quarterback|'
|
||||||
|
r'slam dunk|home run|grand slam|offside|halftime|'
|
||||||
|
r'batting|wicket|innings|'
|
||||||
|
r'formula 1|nascar|motogp|'
|
||||||
|
r'boxing|ufc|mma|'
|
||||||
|
r'marathon|tour de france|'
|
||||||
|
r'transfer window|draft pick|relegation|'
|
||||||
|
# ── vapid / insipid ──
|
||||||
|
r'kardashian|jenner|reality tv|reality show|'
|
||||||
|
r'influencer|viral video|tiktok|instagram|'
|
||||||
|
r'best dressed|worst dressed|red carpet|'
|
||||||
|
r'horoscope|zodiac|gossip|bikini|selfie|'
|
||||||
|
r'you won.t believe|what happened next|'
|
||||||
|
r'celebrity couple|celebrity feud|baby bump'
|
||||||
|
r')\b',
|
||||||
|
re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def skip(title):
|
||||||
|
"""Return True if headline is sports, vapid, or insipid."""
|
||||||
|
return bool(_SKIP_RE.search(title))
|
||||||
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)
|
||||||
256
engine/render.py
Normal file
256
engine/render.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
"""
|
||||||
|
OTF → terminal half-block rendering pipeline.
|
||||||
|
Font loading, text rasterization, word-wrap, gradient coloring, headline block assembly.
|
||||||
|
Depends on: config, terminal, sources, translate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import random
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.terminal import RST
|
||||||
|
from engine.sources import SCRIPT_FONTS, SOURCE_LANGS, NO_UPPER
|
||||||
|
from engine.translate import detect_location_language, translate_headline
|
||||||
|
|
||||||
|
# ─── GRADIENT ─────────────────────────────────────────────
|
||||||
|
# Left → right: white-hot leading edge fades to near-black
|
||||||
|
GRAD_COLS = [
|
||||||
|
"\033[1;38;5;231m", # white
|
||||||
|
"\033[1;38;5;195m", # pale cyan-white
|
||||||
|
"\033[38;5;123m", # bright cyan
|
||||||
|
"\033[38;5;118m", # bright lime
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;46m", # bright green
|
||||||
|
"\033[38;5;40m", # green
|
||||||
|
"\033[38;5;34m", # medium green
|
||||||
|
"\033[38;5;28m", # dark green
|
||||||
|
"\033[38;5;22m", # deep green
|
||||||
|
"\033[2;38;5;22m", # dim deep green
|
||||||
|
"\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_OBJ_KEY = None
|
||||||
|
_FONT_CACHE = {}
|
||||||
|
|
||||||
|
|
||||||
|
def font():
|
||||||
|
"""Lazy-load the primary OTF font (path + face index aware)."""
|
||||||
|
global _FONT_OBJ, _FONT_OBJ_KEY
|
||||||
|
if not config.FONT_PATH:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"No primary font selected. Add .otf/.ttf/.ttc files to {config.FONT_DIR}."
|
||||||
|
)
|
||||||
|
key = (config.FONT_PATH, config.FONT_INDEX, config.FONT_SZ)
|
||||||
|
if _FONT_OBJ is None or _FONT_OBJ_KEY != key:
|
||||||
|
_FONT_OBJ = ImageFont.truetype(
|
||||||
|
config.FONT_PATH, config.FONT_SZ, index=config.FONT_INDEX
|
||||||
|
)
|
||||||
|
_FONT_OBJ_KEY = key
|
||||||
|
return _FONT_OBJ
|
||||||
|
|
||||||
|
def clear_font_cache():
|
||||||
|
"""Reset cached font objects after changing primary font selection."""
|
||||||
|
global _FONT_OBJ, _FONT_OBJ_KEY
|
||||||
|
_FONT_OBJ = None
|
||||||
|
_FONT_OBJ_KEY = None
|
||||||
|
|
||||||
|
|
||||||
|
def load_font_face(font_path, font_index=0, size=None):
|
||||||
|
"""Load a specific face from a font file or collection."""
|
||||||
|
font_size = size or config.FONT_SZ
|
||||||
|
return ImageFont.truetype(font_path, font_size, index=font_index)
|
||||||
|
|
||||||
|
|
||||||
|
def list_font_faces(font_path, max_faces=64):
|
||||||
|
"""Return discoverable face indexes + display names from a font file."""
|
||||||
|
faces = []
|
||||||
|
for idx in range(max_faces):
|
||||||
|
try:
|
||||||
|
fnt = load_font_face(font_path, idx)
|
||||||
|
except Exception:
|
||||||
|
if idx == 0:
|
||||||
|
raise
|
||||||
|
break
|
||||||
|
family, style = fnt.getname()
|
||||||
|
display = f"{family} {style}".strip()
|
||||||
|
if not display:
|
||||||
|
display = f"{Path(font_path).stem} [{idx}]"
|
||||||
|
faces.append({"index": idx, "name": display})
|
||||||
|
return faces
|
||||||
|
|
||||||
|
|
||||||
|
def font_for_lang(lang=None):
|
||||||
|
"""Get appropriate font for a language."""
|
||||||
|
if lang is None or lang not in SCRIPT_FONTS:
|
||||||
|
return font()
|
||||||
|
if lang not in _FONT_CACHE:
|
||||||
|
try:
|
||||||
|
_FONT_CACHE[lang] = ImageFont.truetype(SCRIPT_FONTS[lang], config.FONT_SZ)
|
||||||
|
except Exception:
|
||||||
|
_FONT_CACHE[lang] = font()
|
||||||
|
return _FONT_CACHE[lang]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── RASTERIZATION ────────────────────────────────────────
|
||||||
|
def render_line(text, fnt=None):
|
||||||
|
"""Render a line of text as terminal rows using OTF font + half-blocks."""
|
||||||
|
if fnt is None:
|
||||||
|
fnt = font()
|
||||||
|
bbox = fnt.getbbox(text)
|
||||||
|
if not bbox or bbox[2] <= bbox[0]:
|
||||||
|
return [""]
|
||||||
|
pad = 4
|
||||||
|
img_w = bbox[2] - bbox[0] + pad * 2
|
||||||
|
img_h = bbox[3] - bbox[1] + pad * 2
|
||||||
|
img = Image.new('L', (img_w, img_h), 0)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
draw.text((-bbox[0] + pad, -bbox[1] + pad), text, fill=255, font=fnt)
|
||||||
|
pix_h = config.RENDER_H * 2
|
||||||
|
hi_h = pix_h * config.SSAA
|
||||||
|
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)
|
||||||
|
data = img.tobytes()
|
||||||
|
thr = 80
|
||||||
|
rows = []
|
||||||
|
for y in range(0, pix_h, 2):
|
||||||
|
row = []
|
||||||
|
for x in range(new_w):
|
||||||
|
top = data[y * new_w + x] > thr
|
||||||
|
bot = data[(y + 1) * new_w + x] > thr if y + 1 < pix_h else False
|
||||||
|
if top and bot:
|
||||||
|
row.append("█")
|
||||||
|
elif top:
|
||||||
|
row.append("▀")
|
||||||
|
elif bot:
|
||||||
|
row.append("▄")
|
||||||
|
else:
|
||||||
|
row.append(" ")
|
||||||
|
rows.append("".join(row))
|
||||||
|
while rows and not rows[-1].strip():
|
||||||
|
rows.pop()
|
||||||
|
while rows and not rows[0].strip():
|
||||||
|
rows.pop(0)
|
||||||
|
return rows if rows else [""]
|
||||||
|
|
||||||
|
|
||||||
|
def big_wrap(text, max_w, fnt=None):
|
||||||
|
"""Word-wrap text and render with OTF font."""
|
||||||
|
if fnt is None:
|
||||||
|
fnt = font()
|
||||||
|
words = text.split()
|
||||||
|
lines, cur = [], ""
|
||||||
|
for word in words:
|
||||||
|
test = f"{cur} {word}".strip() if cur else word
|
||||||
|
bbox = fnt.getbbox(test)
|
||||||
|
if bbox:
|
||||||
|
img_h = bbox[3] - bbox[1] + 8
|
||||||
|
pix_h = config.RENDER_H * 2
|
||||||
|
scale = pix_h / max(img_h, 1)
|
||||||
|
term_w = int((bbox[2] - bbox[0] + 8) * scale)
|
||||||
|
else:
|
||||||
|
term_w = 0
|
||||||
|
if term_w > max_w - 4 and cur:
|
||||||
|
lines.append(cur)
|
||||||
|
cur = word
|
||||||
|
else:
|
||||||
|
cur = test
|
||||||
|
if cur:
|
||||||
|
lines.append(cur)
|
||||||
|
out = []
|
||||||
|
for i, ln in enumerate(lines):
|
||||||
|
out.extend(render_line(ln, fnt))
|
||||||
|
if i < len(lines) - 1:
|
||||||
|
out.append("")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def lr_gradient(rows, offset=0.0, grad_cols=None):
|
||||||
|
"""Color each non-space block character with a shifting left-to-right gradient."""
|
||||||
|
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:
|
||||||
|
if not row.strip():
|
||||||
|
out.append(row)
|
||||||
|
continue
|
||||||
|
buf = []
|
||||||
|
for x, ch in enumerate(row):
|
||||||
|
if ch == ' ':
|
||||||
|
buf.append(' ')
|
||||||
|
else:
|
||||||
|
shifted = (x / max(max_x - 1, 1) + offset) % 1.0
|
||||||
|
idx = min(round(shifted * (n - 1)), n - 1)
|
||||||
|
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."""
|
||||||
|
target_lang = (SOURCE_LANGS.get(src) or detect_location_language(title)) if config.MODE == 'news' else None
|
||||||
|
lang_font = font_for_lang(target_lang)
|
||||||
|
if target_lang:
|
||||||
|
title = translate_headline(title, target_lang)
|
||||||
|
# Don't uppercase scripts that have no case (CJK, Arabic, etc.)
|
||||||
|
if target_lang and target_lang in NO_UPPER:
|
||||||
|
title_up = re.sub(r"\s+", " ", title)
|
||||||
|
else:
|
||||||
|
title_up = re.sub(r"\s+", " ", title.upper())
|
||||||
|
for old, new in [("\u2019","'"), ("\u2018","'"), ("\u201c",'"'),
|
||||||
|
("\u201d",'"'), ("\u2013","-"), ("\u2014","-")]:
|
||||||
|
title_up = title_up.replace(old, new)
|
||||||
|
big_rows = big_wrap(title_up, w - 4, lang_font)
|
||||||
|
hc = random.choice([
|
||||||
|
"\033[38;5;46m", # matrix green
|
||||||
|
"\033[38;5;34m", # dark green
|
||||||
|
"\033[38;5;82m", # lime
|
||||||
|
"\033[38;5;48m", # sea green
|
||||||
|
"\033[38;5;37m", # teal
|
||||||
|
"\033[38;5;44m", # cyan
|
||||||
|
"\033[38;5;87m", # sky
|
||||||
|
"\033[38;5;117m", # ice blue
|
||||||
|
"\033[38;5;250m", # cool white
|
||||||
|
"\033[38;5;156m", # pale green
|
||||||
|
"\033[38;5;120m", # mint
|
||||||
|
"\033[38;5;80m", # dark cyan
|
||||||
|
"\033[38;5;108m", # grey-green
|
||||||
|
"\033[38;5;115m", # sage
|
||||||
|
"\033[1;38;5;46m", # bold green
|
||||||
|
"\033[1;38;5;250m", # bold white
|
||||||
|
])
|
||||||
|
content = [" " + r for r in big_rows]
|
||||||
|
content.append("")
|
||||||
|
meta = f"\u2591 {src} \u00b7 {ts}"
|
||||||
|
content.append(" " * max(2, w - len(meta) - 2) + meta)
|
||||||
|
return content, hc, len(content) - 1 # (rows, color, meta_row_index)
|
||||||
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, 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 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_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)
|
||||||
|
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()
|
||||||
115
engine/sources.py
Normal file
115
engine/sources.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Data sources: feed URLs, poetry sources, language mappings, script fonts.
|
||||||
|
Pure data — no logic, no dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ─── RSS FEEDS ────────────────────────────────────────────
|
||||||
|
FEEDS = {
|
||||||
|
# Science & Technology
|
||||||
|
"Nature": "https://www.nature.com/nature.rss",
|
||||||
|
"Science Daily": "https://www.sciencedaily.com/rss/all.xml",
|
||||||
|
"Phys.org": "https://phys.org/rss-feed/",
|
||||||
|
"NASA": "https://www.nasa.gov/news-release/feed/",
|
||||||
|
"Ars Technica": "https://feeds.arstechnica.com/arstechnica/index",
|
||||||
|
"New Scientist": "https://www.newscientist.com/section/news/feed/",
|
||||||
|
"Quanta": "https://api.quantamagazine.org/feed/",
|
||||||
|
"BBC Science": "http://feeds.bbci.co.uk/news/science_and_environment/rss.xml",
|
||||||
|
"MIT Tech Review": "https://www.technologyreview.com/feed/",
|
||||||
|
# Economics & Business
|
||||||
|
"BBC Business": "http://feeds.bbci.co.uk/news/business/rss.xml",
|
||||||
|
"MarketWatch": "https://feeds.marketwatch.com/marketwatch/topstories/",
|
||||||
|
"Economist": "https://www.economist.com/finance-and-economics/rss.xml",
|
||||||
|
# World & Politics
|
||||||
|
"BBC World": "http://feeds.bbci.co.uk/news/world/rss.xml",
|
||||||
|
"NPR": "https://feeds.npr.org/1001/rss.xml",
|
||||||
|
"Al Jazeera": "https://www.aljazeera.com/xml/rss/all.xml",
|
||||||
|
"Guardian World": "https://www.theguardian.com/world/rss",
|
||||||
|
"DW": "https://rss.dw.com/rdf/rss-en-all",
|
||||||
|
"France24": "https://www.france24.com/en/rss",
|
||||||
|
"ABC Australia": "https://www.abc.net.au/news/feed/2942460/rss.xml",
|
||||||
|
"Japan Times": "https://www.japantimes.co.jp/feed/",
|
||||||
|
"The Hindu": "https://www.thehindu.com/news/national/feeder/default.rss",
|
||||||
|
"SCMP": "https://www.scmp.com/rss/91/feed",
|
||||||
|
"Der Spiegel": "https://www.spiegel.de/international/index.rss",
|
||||||
|
# Culture & Ideas
|
||||||
|
"Guardian Culture": "https://www.theguardian.com/culture/rss",
|
||||||
|
"Aeon": "https://aeon.co/feed.rss",
|
||||||
|
"Smithsonian": "https://www.smithsonianmag.com/rss/latest_articles/",
|
||||||
|
"The Marginalian": "https://www.themarginalian.org/feed/",
|
||||||
|
"Nautilus": "https://nautil.us/feed/",
|
||||||
|
"Wired": "https://www.wired.com/feed/rss",
|
||||||
|
"The Conversation": "https://theconversation.com/us/articles.atom",
|
||||||
|
"Longreads": "https://longreads.com/feed/",
|
||||||
|
"Literary Hub": "https://lithub.com/feed/",
|
||||||
|
"Atlas Obscura": "https://www.atlasobscura.com/feeds/latest",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── POETRY / LITERATURE ─────────────────────────────────
|
||||||
|
# Public domain via Project Gutenberg
|
||||||
|
POETRY_SOURCES = {
|
||||||
|
"Whitman": "https://www.gutenberg.org/cache/epub/1322/pg1322.txt",
|
||||||
|
"Dickinson": "https://www.gutenberg.org/cache/epub/12242/pg12242.txt",
|
||||||
|
"Whitman II": "https://www.gutenberg.org/cache/epub/8388/pg8388.txt",
|
||||||
|
"Rilke": "https://www.gutenberg.org/cache/epub/38594/pg38594.txt",
|
||||||
|
"Pound": "https://www.gutenberg.org/cache/epub/41162/pg41162.txt",
|
||||||
|
"Pound II": "https://www.gutenberg.org/cache/epub/51992/pg51992.txt",
|
||||||
|
"Eliot": "https://www.gutenberg.org/cache/epub/1567/pg1567.txt",
|
||||||
|
"Yeats": "https://www.gutenberg.org/cache/epub/38877/pg38877.txt",
|
||||||
|
"Masters": "https://www.gutenberg.org/cache/epub/1280/pg1280.txt",
|
||||||
|
"Baudelaire": "https://www.gutenberg.org/cache/epub/36098/pg36098.txt",
|
||||||
|
"Crane": "https://www.gutenberg.org/cache/epub/40786/pg40786.txt",
|
||||||
|
"Poe": "https://www.gutenberg.org/cache/epub/10031/pg10031.txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── SOURCE → LANGUAGE MAPPING ───────────────────────────
|
||||||
|
# Headlines from these outlets render in their cultural home language
|
||||||
|
SOURCE_LANGS = {
|
||||||
|
"Der Spiegel": "de",
|
||||||
|
"DW": "de",
|
||||||
|
"France24": "fr",
|
||||||
|
"Japan Times": "ja",
|
||||||
|
"The Hindu": "hi",
|
||||||
|
"SCMP": "zh-cn",
|
||||||
|
"Al Jazeera": "ar",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── LOCATION → LANGUAGE ─────────────────────────────────
|
||||||
|
LOCATION_LANGS = {
|
||||||
|
r'\b(?:china|chinese|beijing|shanghai|hong kong|xi jinping)\b': 'zh-cn',
|
||||||
|
r'\b(?:japan|japanese|tokyo|osaka|kishida)\b': 'ja',
|
||||||
|
r'\b(?:korea|korean|seoul|pyongyang)\b': 'ko',
|
||||||
|
r'\b(?:russia|russian|moscow|kremlin|putin)\b': 'ru',
|
||||||
|
r'\b(?:saudi|dubai|qatar|egypt|cairo|arabic)\b': 'ar',
|
||||||
|
r'\b(?:india|indian|delhi|mumbai|modi)\b': 'hi',
|
||||||
|
r'\b(?:germany|german|berlin|munich|scholz)\b': 'de',
|
||||||
|
r'\b(?:france|french|paris|lyon|macron)\b': 'fr',
|
||||||
|
r'\b(?:spain|spanish|madrid)\b': 'es',
|
||||||
|
r'\b(?:italy|italian|rome|milan|meloni)\b': 'it',
|
||||||
|
r'\b(?:portugal|portuguese|lisbon)\b': 'pt',
|
||||||
|
r'\b(?:brazil|brazilian|são paulo|lula)\b': 'pt',
|
||||||
|
r'\b(?:greece|greek|athens)\b': 'el',
|
||||||
|
r'\b(?:turkey|turkish|istanbul|ankara|erdogan)\b': 'tr',
|
||||||
|
r'\b(?:iran|iranian|tehran)\b': 'fa',
|
||||||
|
r'\b(?:thailand|thai|bangkok)\b': 'th',
|
||||||
|
r'\b(?:vietnam|vietnamese|hanoi)\b': 'vi',
|
||||||
|
r'\b(?:ukraine|ukrainian|kyiv|kiev|zelensky)\b': 'uk',
|
||||||
|
r'\b(?:israel|israeli|jerusalem|tel aviv|netanyahu)\b': 'he',
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── NON-LATIN SCRIPT FONTS (macOS) ──────────────────────
|
||||||
|
SCRIPT_FONTS = {
|
||||||
|
'zh-cn': '/System/Library/Fonts/STHeiti Medium.ttc',
|
||||||
|
'ja': '/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc',
|
||||||
|
'ko': '/System/Library/Fonts/AppleSDGothicNeo.ttc',
|
||||||
|
'ru': '/System/Library/Fonts/Supplemental/Arial.ttf',
|
||||||
|
'uk': '/System/Library/Fonts/Supplemental/Arial.ttf',
|
||||||
|
'el': '/System/Library/Fonts/Supplemental/Arial.ttf',
|
||||||
|
'he': '/System/Library/Fonts/Supplemental/Arial.ttf',
|
||||||
|
'ar': '/System/Library/Fonts/GeezaPro.ttc',
|
||||||
|
'fa': '/System/Library/Fonts/GeezaPro.ttc',
|
||||||
|
'hi': '/System/Library/Fonts/Kohinoor.ttc',
|
||||||
|
'th': '/System/Library/Fonts/ThonburiUI.ttc',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Scripts that have no uppercase
|
||||||
|
NO_UPPER = {'zh-cn', 'ja', 'ko', 'ar', 'fa', 'hi', 'th', 'he'}
|
||||||
78
engine/terminal.py
Normal file
78
engine/terminal.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""
|
||||||
|
ANSI escape codes, terminal size helpers, and text output primitives.
|
||||||
|
No internal dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
|
||||||
|
# ─── ANSI ─────────────────────────────────────────────────
|
||||||
|
RST = "\033[0m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
DIM = "\033[2m"
|
||||||
|
# Matrix greens
|
||||||
|
G_HI = "\033[38;5;46m"
|
||||||
|
G_MID = "\033[38;5;34m"
|
||||||
|
G_LO = "\033[38;5;22m"
|
||||||
|
G_DIM = "\033[2;38;5;34m"
|
||||||
|
# THX-1138 sterile tones
|
||||||
|
W_COOL = "\033[38;5;250m"
|
||||||
|
W_DIM = "\033[2;38;5;245m"
|
||||||
|
W_GHOST = "\033[2;38;5;238m"
|
||||||
|
C_DIM = "\033[2;38;5;37m"
|
||||||
|
# Terminal control
|
||||||
|
CLR = "\033[2J\033[H"
|
||||||
|
CURSOR_OFF = "\033[?25l"
|
||||||
|
CURSOR_ON = "\033[?25h"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── TERMINAL SIZE ────────────────────────────────────────
|
||||||
|
def tw():
|
||||||
|
try:
|
||||||
|
return os.get_terminal_size().columns
|
||||||
|
except Exception:
|
||||||
|
return 80
|
||||||
|
|
||||||
|
|
||||||
|
def th():
|
||||||
|
try:
|
||||||
|
return os.get_terminal_size().lines
|
||||||
|
except Exception:
|
||||||
|
return 24
|
||||||
|
|
||||||
|
|
||||||
|
# ─── TEXT OUTPUT ──────────────────────────────────────────
|
||||||
|
def type_out(text, color=G_HI):
|
||||||
|
i = 0
|
||||||
|
while i < len(text):
|
||||||
|
if random.random() < 0.3:
|
||||||
|
b = random.randint(2, 5)
|
||||||
|
sys.stdout.write(f"{color}{text[i:i+b]}{RST}")
|
||||||
|
i += b
|
||||||
|
else:
|
||||||
|
sys.stdout.write(f"{color}{text[i]}{RST}")
|
||||||
|
i += 1
|
||||||
|
sys.stdout.flush()
|
||||||
|
time.sleep(random.uniform(0.004, 0.018))
|
||||||
|
|
||||||
|
|
||||||
|
def slow_print(text, color=G_DIM, delay=0.015):
|
||||||
|
for ch in text:
|
||||||
|
sys.stdout.write(f"{color}{ch}{RST}")
|
||||||
|
sys.stdout.flush()
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
|
||||||
|
def boot_ln(label, status, ok=True):
|
||||||
|
dots = max(3, min(30, tw() - len(label) - len(status) - 8))
|
||||||
|
sys.stdout.write(f" {G_DIM}>{RST} {W_DIM}{label} ")
|
||||||
|
sys.stdout.flush()
|
||||||
|
for _ in range(dots):
|
||||||
|
sys.stdout.write(f"{G_LO}.")
|
||||||
|
sys.stdout.flush()
|
||||||
|
time.sleep(random.uniform(0.006, 0.025))
|
||||||
|
c = G_MID if ok else "\033[2;38;5;196m"
|
||||||
|
print(f" {c}{status}{RST}")
|
||||||
|
time.sleep(random.uniform(0.02, 0.1))
|
||||||
41
engine/translate.py
Normal file
41
engine/translate.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""
|
||||||
|
Google Translate wrapper and location→language detection.
|
||||||
|
Depends on: sources (for LOCATION_LANGS).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
from engine.sources import LOCATION_LANGS
|
||||||
|
|
||||||
|
_TRANSLATE_CACHE = {}
|
||||||
|
|
||||||
|
|
||||||
|
def detect_location_language(title):
|
||||||
|
"""Detect if headline mentions a location, return target language."""
|
||||||
|
title_lower = title.lower()
|
||||||
|
for pattern, lang in LOCATION_LANGS.items():
|
||||||
|
if re.search(pattern, title_lower):
|
||||||
|
return lang
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def translate_headline(title, target_lang):
|
||||||
|
"""Translate headline via Google Translate API (zero dependencies)."""
|
||||||
|
key = (title, target_lang)
|
||||||
|
if key in _TRANSLATE_CACHE:
|
||||||
|
return _TRANSLATE_CACHE[key]
|
||||||
|
try:
|
||||||
|
q = urllib.parse.quote(title)
|
||||||
|
url = ("https://translate.googleapis.com/translate_a/single"
|
||||||
|
f"?client=gtx&sl=en&tl={target_lang}&dt=t&q={q}")
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||||
|
resp = urllib.request.urlopen(req, timeout=5)
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
result = "".join(p[0] for p in data[0] if p[0]) or title
|
||||||
|
except Exception:
|
||||||
|
result = title
|
||||||
|
_TRANSLATE_CACHE[key] = result
|
||||||
|
return result
|
||||||
BIN
fonts/AgorTechnoDemo-Regular.otf
Normal file
BIN
fonts/AgorTechnoDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/AlphatronDemo-Display.otf
Normal file
BIN
fonts/AlphatronDemo-Display.otf
Normal file
Binary file not shown.
BIN
fonts/CSBishopDrawn-Italic.otf
Normal file
BIN
fonts/CSBishopDrawn-Italic.otf
Normal file
Binary file not shown.
BIN
fonts/CSBishopDrawn-Italic.ttf
Normal file
BIN
fonts/CSBishopDrawn-Italic.ttf
Normal file
Binary file not shown.
BIN
fonts/CSBishopDrawn-Regular.otf
Normal file
BIN
fonts/CSBishopDrawn-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/CSBishopDrawn-Regular.ttf
Normal file
BIN
fonts/CSBishopDrawn-Regular.ttf
Normal file
Binary file not shown.
BIN
fonts/Corptic DEMO.otf
Normal file
BIN
fonts/Corptic DEMO.otf
Normal file
Binary file not shown.
BIN
fonts/CubaTechnologyDemo-Regular.otf
Normal file
BIN
fonts/CubaTechnologyDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/CyberformDemo-Oblique.otf
Normal file
BIN
fonts/CyberformDemo-Oblique.otf
Normal file
Binary file not shown.
BIN
fonts/CyberformDemo.otf
Normal file
BIN
fonts/CyberformDemo.otf
Normal file
Binary file not shown.
BIN
fonts/Eyekons.otf
Normal file
BIN
fonts/Eyekons.otf
Normal file
Binary file not shown.
BIN
fonts/KATA Mac.otf
Normal file
BIN
fonts/KATA Mac.otf
Normal file
Binary file not shown.
BIN
fonts/KATA Mac.ttf
Normal file
BIN
fonts/KATA Mac.ttf
Normal file
Binary file not shown.
BIN
fonts/KATA.otf
Normal file
BIN
fonts/KATA.otf
Normal file
Binary file not shown.
BIN
fonts/KATA.ttf
Normal file
BIN
fonts/KATA.ttf
Normal file
Binary file not shown.
BIN
fonts/Microbots Demo.otf
Normal file
BIN
fonts/Microbots Demo.otf
Normal file
Binary file not shown.
BIN
fonts/ModernSpaceDemo-Regular.otf
Normal file
BIN
fonts/ModernSpaceDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/Neoform-Demo.otf
Normal file
BIN
fonts/Neoform-Demo.otf
Normal file
Binary file not shown.
BIN
fonts/Pixel Sparta.otf
Normal file
BIN
fonts/Pixel Sparta.otf
Normal file
Binary file not shown.
BIN
fonts/RaceHugoDemo-Regular.otf
Normal file
BIN
fonts/RaceHugoDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/Resond-Regular.otf
Normal file
BIN
fonts/Resond-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/Robocops-Demo.otf
Normal file
BIN
fonts/Robocops-Demo.otf
Normal file
Binary file not shown.
BIN
fonts/Synthetix.otf
Normal file
BIN
fonts/Synthetix.otf
Normal file
Binary file not shown.
BIN
fonts/Xeonic.ttf
Normal file
BIN
fonts/Xeonic.ttf
Normal file
Binary file not shown.
@@ -139,25 +139,27 @@ Things that are plausibly relevant but entirely absent from the codebase:
|
|||||||
|
|
||||||
## Questions for board owner
|
## Questions for board owner
|
||||||
|
|
||||||
I'm looking at porting [mainline.py](mainline.py) — a scrolling terminal news/poetry stream with OTF-font rendering, RSS feeds, ANSI gradients, and glitch effects — to run on one of these boards. To figure out the right approach I need a few things only you can answer:
|
I'm looking at displaying [mainline.py](mainline.py) — a scrolling news/poetry consciousness stream — on one of these boards. The plan ("Mainline Renderer + ntfy Message Queue for ESP32") uses a **server + thin client** architecture: a Python server pre-renders headlines and serves them via HTTP; the ESP32 just displays pre-rendered bitmaps, applies gradient/glitch effects locally, and polls `ntfy.sh/klubhaus_terminal_mainline` for messages that interrupt the news scroll.
|
||||||
|
|
||||||
### 1. Which board should I target?
|
To build this I need the following from you:
|
||||||
|
|
||||||
The three boards have very different constraints:
|
### 1. Which board?
|
||||||
|
|
||||||
|
With a renderer server doing the heavy lifting, all three boards are viable — but the experience differs:
|
||||||
|
|
||||||
| | Board 1 (2.8″) | Board 2 (4.0″) | Board 3 (4.3″) |
|
| | Board 1 (2.8″) | Board 2 (4.0″) | Board 3 (4.3″) |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Resolution | 320 × 240 | 320 × 480 | 800 × 480 |
|
| Resolution | 320 × 240 | 320 × 480 | 800 × 480 |
|
||||||
| Display bus | SPI (40 MHz) | SPI (40 MHz) | RGB parallel (14 MHz pclk) |
|
| Headline buffer (120 items) | ~77 KB | ~77 KB | ~192 KB |
|
||||||
| Flash | 4 MB | 4 MB | 16 MB |
|
| Firehose mode | no (too narrow) | no (SPI too slow) | yes |
|
||||||
| PSRAM | unknown | unknown | 8 MB |
|
| Smooth scroll at 20 FPS | yes (partial updates) | tight (partial updates mandatory) | yes (framebuffer) |
|
||||||
| Full-screen redraw | ~60 ms+ | ~120 ms+ | near-instant (framebuffer) |
|
| Flash for caching | 4 MB (tight) | 4 MB (tight) | 16 MB (9 MB FAT partition) |
|
||||||
|
|
||||||
Board 3 is the only one with enough RAM and display bandwidth for smooth scrolling with many headlines buffered. Boards 1 & 2 would need aggressive feature cuts. **Which board do you want this on?**
|
Board 3 is the most capable. Boards 1 & 2 work but lose firehose mode and need careful partial-update rendering. **Which board do you want this on?**
|
||||||
|
|
||||||
### 2. PSRAM on your ESP32-32E boards
|
### 2. PSRAM on Boards 1 & 2
|
||||||
|
|
||||||
The build flags say `-DBOARD_HAS_PSRAM` but I can't tell the capacity. Can you check? Easiest way:
|
The build flags say `-DBOARD_HAS_PSRAM` but I can't confirm the capacity. Can you check?
|
||||||
|
|
||||||
```
|
```
|
||||||
// Add to setup() temporarily:
|
// Add to setup() temporarily:
|
||||||
@@ -165,27 +167,124 @@ Serial.printf("PSRAM size: %d bytes\n", ESP.getPsramSize());
|
|||||||
Serial.printf("Free PSRAM: %d bytes\n", ESP.getFreePsram());
|
Serial.printf("Free PSRAM: %d bytes\n", ESP.getFreePsram());
|
||||||
```
|
```
|
||||||
|
|
||||||
If PSRAM is 0 on Boards 1 or 2, those boards can only hold a handful of headlines in 520 KB SRAM (WiFi + TLS eat most of it).
|
If PSRAM is 0, the board can only buffer ~20 headlines in a ring buffer instead of the full ~120 set. (Board 3's 8 MB PSRAM is confirmed — this only matters if you pick Board 1 or 2.)
|
||||||
|
|
||||||
### 3. Feature priorities
|
### 3. Network & server hosting
|
||||||
|
|
||||||
mainline.py does a lot of things that don't map directly to an ESP32 + TFT. Which of these matter to you?
|
The renderer server (`serve.py`) needs Python 3 + Pillow, internet access (for RSS feeds), and network access to the ESP32.
|
||||||
|
|
||||||
- **RSS headline scrolling** — the core experience. How many feeds? All ~25, or a curated subset?
|
- **Where will the server run?** Raspberry Pi, NAS, always-on desktop, cloud VM?
|
||||||
- **OTF font rendering** — mainline uses Pillow to rasterize a custom `.otf` font into half-block characters. On ESP32 this would become either bitmap fonts or a pre-rendered glyph atlas baked into flash. Is the specific font important, or is the aesthetic (large, stylized text) what matters?
|
- **Same LAN as the ESP32?** If yes, the ESP32 can use plain HTTP (no TLS overhead). If remote, we'd need HTTPS (~40–50 KB RAM per connection).
|
||||||
- **Left-to-right color gradient** — the white-hot → green → black fade. Easy to replicate in RGB565 on the TFT. Keep?
|
- **Server discovery:** Hardcoded IP in `secrets.h`, mDNS (`mainline.local`), or a DNS name?
|
||||||
- **Glitch / noise effects** — the ░▒▓█ and katakana rain. Keep?
|
- **WiFi credentials:** Use the existing multi-network setup from the doorbell firmware, or a specific SSID?
|
||||||
- **Mic-reactive glitch intensity** — none of these boards have a microphone. Drop entirely, or substitute with something else (e.g. touch-reactive, or time-of-day reactive)?
|
|
||||||
- **Auto-translation** — mainline translates headlines for region-specific sources via Google Translate. This requires HTTPS calls that are expensive on ESP32 (~40–50 KB RAM per TLS connection). Keep, pre-translate on a server, or drop?
|
|
||||||
- **Poetry mode** — fetches full Gutenberg texts. These are large (100+ KB each). Cache to SD card, trim to a small set, or drop?
|
|
||||||
- **Content filtering** — the sports/vapid regex filter. Trivial to keep.
|
|
||||||
- **Boot sequence animation** — the typewriter-style boot log. Keep?
|
|
||||||
|
|
||||||
### 4. Network environment
|
### 4. ESP32 client repo
|
||||||
|
|
||||||
- Will the board be on a WiFi network that can reach the public internet (RSS feeds, Google Translate, ntfy.sh)?
|
The ESP32 sketch reuses `NetManager`, `IDisplayDriver`, and vendored display libraries from klubhaus-doorbell. Two options:
|
||||||
- Is there a preferred SSID / network, or should it use the existing multi-network setup from the doorbell firmware?
|
|
||||||
|
|
||||||
### 5. SD card availability
|
- **klubhaus-doorbell repo** — natural fit as a new board target (`boards/esp32-mainline/`). Requires push access or a PR.
|
||||||
|
- **mainline repo** — under `firmware/esp32-mainline/` with a vendored copy of KlubhausCore. Self-contained but duplicates shared code.
|
||||||
|
|
||||||
All three boards have TF card slots but the doorbell firmware doesn't use them. A microSD card would be useful for caching fonts, pre-rendered glyph atlases, or translated headline buffers. **Is there an SD card in the board you'd want to target?**
|
**Which repo should host the ESP32 client?**
|
||||||
|
|
||||||
|
### 5. Display features
|
||||||
|
|
||||||
|
With the renderer handling RSS fetching, translation, content filtering, and font rendering server-side, the remaining feature questions are about what the ESP32 displays locally:
|
||||||
|
|
||||||
|
- **Left-to-right color gradient** — the white-hot → green → black fade, applied per-pixel on the ESP32. Keep?
|
||||||
|
- **Glitch / noise effects** — random block characters and katakana rain between headlines. Keep?
|
||||||
|
- **Glitch reactivity** — mainline.py uses a microphone (none on these boards). Substitute with touch-reactive, time-of-day reactive, or just random?
|
||||||
|
- **Firehose mode** — dense data ticker at the bottom (only viable on Board 3). Want it?
|
||||||
|
- **Boot sequence animation** — typewriter-style status log during startup. Keep?
|
||||||
|
- **Poetry mode** — the server can serve Gutenberg text instead of RSS. Want both modes available, or just news?
|
||||||
|
|
||||||
|
### 6. ntfy message queue
|
||||||
|
|
||||||
|
The ESP32 polls `ntfy.sh/klubhaus_terminal_mainline` directly for messages that interrupt the news scroll.
|
||||||
|
|
||||||
|
- **Is `klubhaus_terminal_mainline` the right topic name?** Or match the doorbell convention (`ALERT_klubhaus_topic`, etc.)?
|
||||||
|
- **Who sends messages?** Just you (manual `curl`), a bot, other people?
|
||||||
|
- **Display duration:** How long before auto-dismiss? The doorbell uses 120s for alerts. 30s for terminal messages? Touch-to-dismiss regardless?
|
||||||
|
- **Priority levels?** ntfy supports 1–5. Should high-priority messages turn on the backlight if the display is OFF, or show in a different color?
|
||||||
|
- **Message history on boot?** Show recent messages from the topic, or only messages arriving while running? The doorbell uses `?since=20s`. You might want `?since=5m` for a message board feel.
|
||||||
|
|
||||||
|
### 7. Server-offline fallback
|
||||||
|
|
||||||
|
If the renderer server goes down, what should the ESP32 do?
|
||||||
|
|
||||||
|
- **A.** Show last cached headlines indefinitely (ntfy messages still work independently).
|
||||||
|
- **B.** Show a "no signal" screen after a timeout, keep polling.
|
||||||
|
- **C.** Fall back to ntfy messages + clock only.
|
||||||
|
|
||||||
|
### 8. Scroll and layout
|
||||||
|
|
||||||
|
- **Vertical scroll (like mainline.py)?** Or horizontal marquee?
|
||||||
|
- **Speed:** mainline.py uses ~3.75s per headline. Same pace, or different for ambient display?
|
||||||
|
- **Font height:** The server pre-renders at a configurable pixel height. On Board 3 (480px tall), 32–48px headlines would match mainline.py's feel. On Boards 1/2, 16–24px. What looks right to you?
|
||||||
|
|
||||||
|
### 9. Font and aesthetics
|
||||||
|
|
||||||
|
mainline.py uses `CSBishopDrawn-Italic.otf`. The server renders bitmaps with this font, so the ESP32 never needs the font file itself.
|
||||||
|
|
||||||
|
- **Can you provide this font to whoever hosts the server?** Or should it fall back to a freely available alternative?
|
||||||
|
- **Any license concern** with serving the rendered bitmaps (not the font file) over HTTP?
|
||||||
|
|
||||||
|
### 10. SD card
|
||||||
|
|
||||||
|
All three boards have TF card slots (unused by the doorbell firmware). An SD card would let the ESP32 cache headline bitmaps for instant boot and offline operation. **Is there an SD card in the board you'd pick?**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recent mainline.py changes and port implications
|
||||||
|
|
||||||
|
*Updated after merge of latest `main` — three new features affect the port calculus.*
|
||||||
|
|
||||||
|
### Local headline cache (`--refresh`)
|
||||||
|
|
||||||
|
mainline.py now caches fetched headlines to a JSON file (`.mainline_cache_{MODE}.json`) and skips network fetches on subsequent runs unless `--refresh` is passed. This is good news for an ESP32 port:
|
||||||
|
|
||||||
|
- **Faster boot.** The device could load cached headlines from flash/SD on power-up and start scrolling immediately, then refresh feeds in the background.
|
||||||
|
- **Offline operation.** If WiFi drops, the device can still display cached content.
|
||||||
|
- **Storage question.** A typical cache is ~200–500 headlines × ~100 bytes ≈ 20–50 KB of JSON. That fits comfortably in Board 3's 9 MB FAT partition or on SD. On Boards 1 & 2 (4 MB flash), it would need SPIFFS/LittleFS and would compete with app code for flash space.
|
||||||
|
|
||||||
|
**Recommendation:** If targeting Boards 1 or 2, confirm how much flash is left after the app partition. Board 3's `app3M_fat9M_16MB` partition scheme already has 9 MB of FAT storage — more than enough.
|
||||||
|
|
||||||
|
### Firehose mode (`--firehose`)
|
||||||
|
|
||||||
|
mainline.py now has a `--firehose` flag that adds a 12-row dense data zone at the bottom of the viewport, cycling rapidly (every frame) through raw headline text, glitch noise, status lines, and headline fragments. This is the most demanding new feature for an ESP32 port:
|
||||||
|
|
||||||
|
- **Frame rate.** The firehose zone redraws completely every frame at 20 FPS. On SPI displays (Boards 1 & 2), rewriting 12 rows × 320 or 480 pixels at 40 MHz SPI would consume a significant fraction of each 50 ms frame budget. On Board 3's RGB framebuffer, this is trivial.
|
||||||
|
- **Randomness.** Each firehose line calls `random.choice()` over the full item pool. On ESP32, `esp_random()` is hardware-backed and fast, but iterating the full pool each frame needs the pool in RAM.
|
||||||
|
- **Visual density.** At 320px wide (Boards 1 & 2), 12 rows of dense data may be illegible. At 800px (Board 3), it works.
|
||||||
|
|
||||||
|
**Recommendation:** Firehose mode is only practical on Board 3. On Boards 1 & 2, consider either dropping it or replacing it with a single-line ticker.
|
||||||
|
|
||||||
|
### Fixed 20 FPS frame timing
|
||||||
|
|
||||||
|
The scroll loop now uses `time.monotonic()` with a 50 ms frame budget (`_FRAME_DT = 0.05`) and a scroll accumulator instead of sleeping per scroll step. This is actually a better fit for ESP32 than the old approach — it maps cleanly to a `millis()`-based main loop:
|
||||||
|
|
||||||
|
```
|
||||||
|
// ESP32 equivalent pattern
|
||||||
|
void loop() {
|
||||||
|
uint32_t t0 = millis();
|
||||||
|
// ... render frame ...
|
||||||
|
uint32_t elapsed = millis() - t0;
|
||||||
|
if (elapsed < FRAME_DT_MS) delay(FRAME_DT_MS - elapsed);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Estimated per-frame budgets:**
|
||||||
|
|
||||||
|
| | Board 1 (320×240) | Board 2 (320×480) | Board 3 (800×480) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Full-screen SPI flush | ~30 ms | ~60 ms | n/a (framebuffer) |
|
||||||
|
| Partial update (12 rows) | ~4 ms | ~4 ms | n/a |
|
||||||
|
| Remaining CPU budget (of 50 ms) | ~20 ms | ~0 ms (tight) | ~45 ms |
|
||||||
|
|
||||||
|
Board 2 at 20 FPS with a full-screen redraw each frame would have essentially zero headroom. Partial updates (dirty-rect tracking) would be mandatory.
|
||||||
|
|
||||||
|
### Port-relevant implications
|
||||||
|
|
||||||
|
- **Firehose mode** is only practical on Board 3 (see §5 in Questions). On Boards 1 & 2, consider a single-line ticker instead.
|
||||||
|
- **Headline caching** maps to the ESP32 storing bitmap data from the server to flash/SD for instant boot and offline fallback (see §10 in Questions).
|
||||||
|
- **20 FPS frame timing** maps cleanly to a `millis()`-based ESP32 main loop. Board 2 would have zero headroom without partial updates.
|
||||||
|
|||||||
922
mainline.py
922
mainline.py
@@ -5,7 +5,7 @@ Digital news consciousness stream.
|
|||||||
Matrix aesthetic · THX-1138 hue.
|
Matrix aesthetic · THX-1138 hue.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess, sys, os, pathlib
|
import subprocess, sys, pathlib
|
||||||
|
|
||||||
# ─── BOOTSTRAP VENV ───────────────────────────────────────
|
# ─── BOOTSTRAP VENV ───────────────────────────────────────
|
||||||
_VENV = pathlib.Path(__file__).resolve().parent / ".mainline_venv"
|
_VENV = pathlib.Path(__file__).resolve().parent / ".mainline_venv"
|
||||||
@@ -37,924 +37,8 @@ if not _MARKER_SD.exists():
|
|||||||
|
|
||||||
sys.path.insert(0, str(next((_VENV / "lib").glob("python*/site-packages"))))
|
sys.path.insert(0, str(next((_VENV / "lib").glob("python*/site-packages"))))
|
||||||
|
|
||||||
import feedparser # noqa: E402
|
# ─── DELEGATE TO ENGINE ───────────────────────────────────
|
||||||
from PIL import Image, ImageDraw, ImageFont # noqa: E402
|
from engine.app import main # noqa: E402
|
||||||
import random, time, re, signal, atexit, textwrap # noqa: E402
|
|
||||||
try:
|
|
||||||
import sounddevice as _sd
|
|
||||||
import numpy as _np
|
|
||||||
_HAS_MIC = True
|
|
||||||
except Exception:
|
|
||||||
_HAS_MIC = False
|
|
||||||
import urllib.request, urllib.parse, json # noqa: E402
|
|
||||||
from datetime import datetime
|
|
||||||
from html import unescape
|
|
||||||
from html.parser import HTMLParser
|
|
||||||
|
|
||||||
# ─── CONFIG ───────────────────────────────────────────────
|
|
||||||
HEADLINE_LIMIT = 1000
|
|
||||||
FEED_TIMEOUT = 10
|
|
||||||
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
|
||||||
MODE = 'poetry' if '--poetry' in sys.argv or '-p' in sys.argv else 'news'
|
|
||||||
FIREHOSE = '--firehose' in sys.argv
|
|
||||||
|
|
||||||
# Poetry/literature sources — public domain via Project Gutenberg
|
|
||||||
POETRY_SOURCES = {
|
|
||||||
"Whitman": "https://www.gutenberg.org/cache/epub/1322/pg1322.txt",
|
|
||||||
"Dickinson": "https://www.gutenberg.org/cache/epub/12242/pg12242.txt",
|
|
||||||
"Thoreau": "https://www.gutenberg.org/cache/epub/205/pg205.txt",
|
|
||||||
"Emerson": "https://www.gutenberg.org/cache/epub/2944/pg2944.txt",
|
|
||||||
"Whitman II": "https://www.gutenberg.org/cache/epub/8388/pg8388.txt",
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── ANSI ─────────────────────────────────────────────────
|
|
||||||
RST = "\033[0m"
|
|
||||||
BOLD = "\033[1m"
|
|
||||||
DIM = "\033[2m"
|
|
||||||
# Matrix greens
|
|
||||||
G_HI = "\033[38;5;46m"
|
|
||||||
G_MID = "\033[38;5;34m"
|
|
||||||
G_LO = "\033[38;5;22m"
|
|
||||||
G_DIM = "\033[2;38;5;34m"
|
|
||||||
# THX-1138 sterile tones
|
|
||||||
W_COOL = "\033[38;5;250m"
|
|
||||||
W_DIM = "\033[2;38;5;245m"
|
|
||||||
W_GHOST = "\033[2;38;5;238m"
|
|
||||||
C_DIM = "\033[2;38;5;37m"
|
|
||||||
# Terminal control
|
|
||||||
CLR = "\033[2J\033[H"
|
|
||||||
CURSOR_OFF = "\033[?25l"
|
|
||||||
CURSOR_ON = "\033[?25h"
|
|
||||||
|
|
||||||
# ─── FEEDS ────────────────────────────────────────────────
|
|
||||||
FEEDS = {
|
|
||||||
# Science & Technology
|
|
||||||
"Nature": "https://www.nature.com/nature.rss",
|
|
||||||
"Science Daily": "https://www.sciencedaily.com/rss/all.xml",
|
|
||||||
"Phys.org": "https://phys.org/rss-feed/",
|
|
||||||
"NASA": "https://www.nasa.gov/news-release/feed/",
|
|
||||||
"Ars Technica": "https://feeds.arstechnica.com/arstechnica/index",
|
|
||||||
"New Scientist": "https://www.newscientist.com/section/news/feed/",
|
|
||||||
"Quanta": "https://api.quantamagazine.org/feed/",
|
|
||||||
"BBC Science": "http://feeds.bbci.co.uk/news/science_and_environment/rss.xml",
|
|
||||||
"MIT Tech Review": "https://www.technologyreview.com/feed/",
|
|
||||||
# Economics & Business
|
|
||||||
"BBC Business": "http://feeds.bbci.co.uk/news/business/rss.xml",
|
|
||||||
"MarketWatch": "https://feeds.marketwatch.com/marketwatch/topstories/",
|
|
||||||
"Economist": "https://www.economist.com/finance-and-economics/rss.xml",
|
|
||||||
# World & Politics
|
|
||||||
"BBC World": "http://feeds.bbci.co.uk/news/world/rss.xml",
|
|
||||||
"NPR": "https://feeds.npr.org/1001/rss.xml",
|
|
||||||
"Al Jazeera": "https://www.aljazeera.com/xml/rss/all.xml",
|
|
||||||
"Guardian World": "https://www.theguardian.com/world/rss",
|
|
||||||
"DW": "https://rss.dw.com/rdf/rss-en-all",
|
|
||||||
"France24": "https://www.france24.com/en/rss",
|
|
||||||
"ABC Australia": "https://www.abc.net.au/news/feed/2942460/rss.xml",
|
|
||||||
"Japan Times": "https://www.japantimes.co.jp/feed/",
|
|
||||||
"The Hindu": "https://www.thehindu.com/news/national/feeder/default.rss",
|
|
||||||
"SCMP": "https://www.scmp.com/rss/91/feed",
|
|
||||||
"Der Spiegel": "https://www.spiegel.de/international/index.rss",
|
|
||||||
# Culture & Ideas
|
|
||||||
"Guardian Culture": "https://www.theguardian.com/culture/rss",
|
|
||||||
"Aeon": "https://aeon.co/feed.rss",
|
|
||||||
"Smithsonian": "https://www.smithsonianmag.com/rss/latest_articles/",
|
|
||||||
"The Marginalian": "https://www.themarginalian.org/feed/",
|
|
||||||
"Nautilus": "https://nautil.us/feed/",
|
|
||||||
"Wired": "https://www.wired.com/feed/rss",
|
|
||||||
"The Conversation": "https://theconversation.com/us/articles.atom",
|
|
||||||
"Longreads": "https://longreads.com/feed/",
|
|
||||||
"Literary Hub": "https://lithub.com/feed/",
|
|
||||||
"Atlas Obscura": "https://www.atlasobscura.com/feeds/latest",
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── GLYPHS ───────────────────────────────────────────────
|
|
||||||
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
|
||||||
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
|
||||||
|
|
||||||
# ─── FONT RENDERING (OTF → terminal blocks) ─────────────
|
|
||||||
_FONT_PATH = "/Users/genejohnson/Documents/CS Bishop Drawn/CSBishopDrawn-Italic.otf"
|
|
||||||
_FONT_OBJ = None
|
|
||||||
_FONT_SZ = 60
|
|
||||||
_RENDER_H = 8 # terminal rows per rendered text line
|
|
||||||
|
|
||||||
# Non-Latin scripts → macOS system fonts
|
|
||||||
_SCRIPT_FONTS = {
|
|
||||||
'zh-cn': '/System/Library/Fonts/STHeiti Medium.ttc',
|
|
||||||
'ja': '/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc',
|
|
||||||
'ko': '/System/Library/Fonts/AppleSDGothicNeo.ttc',
|
|
||||||
'ru': '/System/Library/Fonts/Supplemental/Arial.ttf',
|
|
||||||
'uk': '/System/Library/Fonts/Supplemental/Arial.ttf',
|
|
||||||
'el': '/System/Library/Fonts/Supplemental/Arial.ttf',
|
|
||||||
'he': '/System/Library/Fonts/Supplemental/Arial.ttf',
|
|
||||||
'ar': '/System/Library/Fonts/GeezaPro.ttc',
|
|
||||||
'fa': '/System/Library/Fonts/GeezaPro.ttc',
|
|
||||||
'hi': '/System/Library/Fonts/Kohinoor.ttc',
|
|
||||||
'th': '/System/Library/Fonts/ThonburiUI.ttc',
|
|
||||||
}
|
|
||||||
_FONT_CACHE = {}
|
|
||||||
_NO_UPPER = {'zh-cn', 'ja', 'ko', 'ar', 'fa', 'hi', 'th', 'he'}
|
|
||||||
# Left → right gradient: white-hot leading edge fades to near-black
|
|
||||||
_GRAD_COLS = [
|
|
||||||
"\033[1;38;5;231m", # white
|
|
||||||
"\033[1;38;5;195m", # pale cyan-white
|
|
||||||
"\033[38;5;123m", # bright cyan
|
|
||||||
"\033[38;5;118m", # bright lime
|
|
||||||
"\033[38;5;82m", # lime
|
|
||||||
"\033[38;5;46m", # bright green
|
|
||||||
"\033[38;5;40m", # green
|
|
||||||
"\033[38;5;34m", # medium green
|
|
||||||
"\033[38;5;28m", # dark green
|
|
||||||
"\033[38;5;22m", # deep green
|
|
||||||
"\033[2;38;5;22m", # dim deep green
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _font():
|
|
||||||
"""Lazy-load the OTF font."""
|
|
||||||
global _FONT_OBJ
|
|
||||||
if _FONT_OBJ is None:
|
|
||||||
_FONT_OBJ = ImageFont.truetype(_FONT_PATH, _FONT_SZ)
|
|
||||||
return _FONT_OBJ
|
|
||||||
|
|
||||||
|
|
||||||
def _font_for_lang(lang=None):
|
|
||||||
"""Get appropriate font for a language."""
|
|
||||||
if lang is None or lang not in _SCRIPT_FONTS:
|
|
||||||
return _font()
|
|
||||||
if lang not in _FONT_CACHE:
|
|
||||||
try:
|
|
||||||
_FONT_CACHE[lang] = ImageFont.truetype(_SCRIPT_FONTS[lang], _FONT_SZ)
|
|
||||||
except Exception:
|
|
||||||
_FONT_CACHE[lang] = _font()
|
|
||||||
return _FONT_CACHE[lang]
|
|
||||||
|
|
||||||
# ─── HELPERS ──────────────────────────────────────────────
|
|
||||||
class _Strip(HTMLParser):
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self._t = []
|
|
||||||
|
|
||||||
def handle_data(self, d):
|
|
||||||
self._t.append(d)
|
|
||||||
|
|
||||||
def text(self):
|
|
||||||
return "".join(self._t).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def strip_tags(html):
|
|
||||||
s = _Strip()
|
|
||||||
s.feed(unescape(html or ""))
|
|
||||||
return s.text()
|
|
||||||
|
|
||||||
|
|
||||||
def tw():
|
|
||||||
try:
|
|
||||||
return os.get_terminal_size().columns
|
|
||||||
except Exception:
|
|
||||||
return 80
|
|
||||||
|
|
||||||
|
|
||||||
def th():
|
|
||||||
try:
|
|
||||||
return os.get_terminal_size().lines
|
|
||||||
except Exception:
|
|
||||||
return 24
|
|
||||||
|
|
||||||
|
|
||||||
def noise(w):
|
|
||||||
d = random.choice([0.15, 0.25, 0.35, 0.12]) # was [0.08, 0.12, 0.2, 0.05], now much denser
|
|
||||||
return "".join(
|
|
||||||
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
|
||||||
f"{random.choice(GLITCH + 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
|
|
||||||
|
|
||||||
|
|
||||||
# ─── SOURCE → LANGUAGE MAPPING ──────────────────────────
|
|
||||||
# Headlines from these outlets render in their cultural home language
|
|
||||||
# regardless of content, reflecting the true distribution of sources.
|
|
||||||
SOURCE_LANGS = {
|
|
||||||
"Der Spiegel": "de",
|
|
||||||
"DW": "de",
|
|
||||||
"France24": "fr",
|
|
||||||
"Japan Times": "ja",
|
|
||||||
"The Hindu": "hi",
|
|
||||||
"SCMP": "zh-cn",
|
|
||||||
"Al Jazeera": "ar",
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─── LOCATION → LANGUAGE ─────────────────────────────────
|
|
||||||
_LOCATION_LANGS = {
|
|
||||||
r'\b(?:china|chinese|beijing|shanghai|hong kong|xi jinping)\b': 'zh-cn',
|
|
||||||
r'\b(?:japan|japanese|tokyo|osaka|kishida)\b': 'ja',
|
|
||||||
r'\b(?:korea|korean|seoul|pyongyang)\b': 'ko',
|
|
||||||
r'\b(?:russia|russian|moscow|kremlin|putin)\b': 'ru',
|
|
||||||
r'\b(?:saudi|dubai|qatar|egypt|cairo|arabic)\b': 'ar',
|
|
||||||
r'\b(?:india|indian|delhi|mumbai|modi)\b': 'hi',
|
|
||||||
r'\b(?:germany|german|berlin|munich|scholz)\b': 'de',
|
|
||||||
r'\b(?:france|french|paris|lyon|macron)\b': 'fr',
|
|
||||||
r'\b(?:spain|spanish|madrid)\b': 'es',
|
|
||||||
r'\b(?:italy|italian|rome|milan|meloni)\b': 'it',
|
|
||||||
r'\b(?:portugal|portuguese|lisbon)\b': 'pt',
|
|
||||||
r'\b(?:brazil|brazilian|são paulo|lula)\b': 'pt',
|
|
||||||
r'\b(?:greece|greek|athens)\b': 'el',
|
|
||||||
r'\b(?:turkey|turkish|istanbul|ankara|erdogan)\b': 'tr',
|
|
||||||
r'\b(?:iran|iranian|tehran)\b': 'fa',
|
|
||||||
r'\b(?:thailand|thai|bangkok)\b': 'th',
|
|
||||||
r'\b(?:vietnam|vietnamese|hanoi)\b': 'vi',
|
|
||||||
r'\b(?:ukraine|ukrainian|kyiv|kiev|zelensky)\b': 'uk',
|
|
||||||
r'\b(?:israel|israeli|jerusalem|tel aviv|netanyahu)\b': 'he',
|
|
||||||
}
|
|
||||||
|
|
||||||
_TRANSLATE_CACHE = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _detect_location_language(title):
|
|
||||||
"""Detect if headline mentions a location, return target language."""
|
|
||||||
title_lower = title.lower()
|
|
||||||
for pattern, lang in _LOCATION_LANGS.items():
|
|
||||||
if re.search(pattern, title_lower):
|
|
||||||
return lang
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _translate_headline(title, target_lang):
|
|
||||||
"""Translate headline via Google Translate API (zero dependencies)."""
|
|
||||||
key = (title, target_lang)
|
|
||||||
if key in _TRANSLATE_CACHE:
|
|
||||||
return _TRANSLATE_CACHE[key]
|
|
||||||
try:
|
|
||||||
q = urllib.parse.quote(title)
|
|
||||||
url = ("https://translate.googleapis.com/translate_a/single"
|
|
||||||
f"?client=gtx&sl=en&tl={target_lang}&dt=t&q={q}")
|
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
|
||||||
resp = urllib.request.urlopen(req, timeout=5)
|
|
||||||
data = json.loads(resp.read())
|
|
||||||
result = "".join(p[0] for p in data[0] if p[0]) or title
|
|
||||||
except Exception:
|
|
||||||
result = title
|
|
||||||
_TRANSLATE_CACHE[key] = result
|
|
||||||
return result
|
|
||||||
|
|
||||||
# ─── CONTENT FILTER ───────────────────────────────────────
|
|
||||||
_SKIP_RE = re.compile(
|
|
||||||
r'\b(?:'
|
|
||||||
# ── sports ──
|
|
||||||
r'football|soccer|basketball|baseball|softball|tennis|golf|cricket|rugby|'
|
|
||||||
r'hockey|lacrosse|volleyball|badminton|'
|
|
||||||
r'nba|nfl|nhl|mlb|mls|fifa|uefa|'
|
|
||||||
r'premier league|champions league|la liga|serie a|bundesliga|'
|
|
||||||
r'world cup|super bowl|world series|stanley cup|'
|
|
||||||
r'playoff|playoffs|touchdown|goalkeeper|striker|quarterback|'
|
|
||||||
r'slam dunk|home run|grand slam|offside|halftime|'
|
|
||||||
r'batting|wicket|innings|'
|
|
||||||
r'formula 1|nascar|motogp|'
|
|
||||||
r'boxing|ufc|mma|'
|
|
||||||
r'marathon|tour de france|'
|
|
||||||
r'transfer window|draft pick|relegation|'
|
|
||||||
# ── vapid / insipid ──
|
|
||||||
r'kardashian|jenner|reality tv|reality show|'
|
|
||||||
r'influencer|viral video|tiktok|instagram|'
|
|
||||||
r'best dressed|worst dressed|red carpet|'
|
|
||||||
r'horoscope|zodiac|gossip|bikini|selfie|'
|
|
||||||
r'you won.t believe|what happened next|'
|
|
||||||
r'celebrity couple|celebrity feud|baby bump'
|
|
||||||
r')\b',
|
|
||||||
re.IGNORECASE
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _skip(title):
|
|
||||||
"""Return True if headline is sports, vapid, or insipid."""
|
|
||||||
return bool(_SKIP_RE.search(title))
|
|
||||||
|
|
||||||
|
|
||||||
# ─── DISPLAY ──────────────────────────────────────────────
|
|
||||||
def type_out(text, color=G_HI):
|
|
||||||
i = 0
|
|
||||||
while i < len(text):
|
|
||||||
if random.random() < 0.3:
|
|
||||||
b = random.randint(2, 5)
|
|
||||||
sys.stdout.write(f"{color}{text[i:i+b]}{RST}")
|
|
||||||
i += b
|
|
||||||
else:
|
|
||||||
sys.stdout.write(f"{color}{text[i]}{RST}")
|
|
||||||
i += 1
|
|
||||||
sys.stdout.flush()
|
|
||||||
time.sleep(random.uniform(0.004, 0.018))
|
|
||||||
|
|
||||||
|
|
||||||
def slow_print(text, color=G_DIM, delay=0.015):
|
|
||||||
for ch in text:
|
|
||||||
sys.stdout.write(f"{color}{ch}{RST}")
|
|
||||||
sys.stdout.flush()
|
|
||||||
time.sleep(delay)
|
|
||||||
|
|
||||||
|
|
||||||
def boot_ln(label, status, ok=True):
|
|
||||||
dots = max(3, min(30, tw() - len(label) - len(status) - 8))
|
|
||||||
sys.stdout.write(f" {G_DIM}>{RST} {W_DIM}{label} ")
|
|
||||||
sys.stdout.flush()
|
|
||||||
for _ in range(dots):
|
|
||||||
sys.stdout.write(f"{G_LO}.")
|
|
||||||
sys.stdout.flush()
|
|
||||||
time.sleep(random.uniform(0.006, 0.025))
|
|
||||||
c = G_MID if ok else "\033[2;38;5;196m"
|
|
||||||
print(f" {c}{status}{RST}")
|
|
||||||
time.sleep(random.uniform(0.02, 0.1))
|
|
||||||
|
|
||||||
|
|
||||||
# ─── FETCH ────────────────────────────────────────────────
|
|
||||||
def fetch_feed(url):
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
|
||||||
resp = urllib.request.urlopen(req, timeout=FEED_TIMEOUT)
|
|
||||||
return feedparser.parse(resp.read())
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_path():
|
|
||||||
return _CACHE_DIR / f".mainline_cache_{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
|
|
||||||
|
|
||||||
|
|
||||||
# ─── STREAM ───────────────────────────────────────────────
|
|
||||||
_SCROLL_DUR = 3.75 # seconds per headline
|
|
||||||
_FRAME_DT = 0.05 # 50ms base frame rate (20 FPS)
|
|
||||||
FIREHOSE_H = 12 # firehose zone height (terminal rows)
|
|
||||||
_mic_db = -99.0 # current mic level, written by background thread
|
|
||||||
_mic_stream = None
|
|
||||||
|
|
||||||
|
|
||||||
def _start_mic():
|
|
||||||
"""Start background mic monitoring; silently skipped if unavailable."""
|
|
||||||
global _mic_db, _mic_stream
|
|
||||||
if not _HAS_MIC:
|
|
||||||
return
|
|
||||||
def _cb(indata, frames, t, status):
|
|
||||||
global _mic_db
|
|
||||||
rms = float(_np.sqrt(_np.mean(indata ** 2)))
|
|
||||||
_mic_db = 20 * _np.log10(rms) if rms > 0 else -99.0
|
|
||||||
try:
|
|
||||||
_mic_stream = _sd.InputStream(
|
|
||||||
callback=_cb, channels=1, samplerate=44100, blocksize=2048)
|
|
||||||
_mic_stream.start()
|
|
||||||
atexit.register(lambda: _mic_stream.stop() if _mic_stream else None)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _render_line(text, font=None):
|
|
||||||
"""Render a line of text as terminal rows using OTF font + half-blocks."""
|
|
||||||
if font is None:
|
|
||||||
font = _font()
|
|
||||||
bbox = font.getbbox(text)
|
|
||||||
if not bbox or bbox[2] <= bbox[0]:
|
|
||||||
return [""]
|
|
||||||
pad = 4
|
|
||||||
img_w = bbox[2] - bbox[0] + pad * 2
|
|
||||||
img_h = bbox[3] - bbox[1] + pad * 2
|
|
||||||
img = Image.new('L', (img_w, img_h), 0)
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
draw.text((-bbox[0] + pad, -bbox[1] + pad), text, fill=255, font=font)
|
|
||||||
pix_h = _RENDER_H * 2
|
|
||||||
scale = pix_h / max(img_h, 1)
|
|
||||||
new_w = max(1, int(img_w * scale))
|
|
||||||
img = img.resize((new_w, pix_h), Image.Resampling.LANCZOS)
|
|
||||||
data = img.tobytes()
|
|
||||||
thr = 80
|
|
||||||
rows = []
|
|
||||||
for y in range(0, pix_h, 2):
|
|
||||||
row = []
|
|
||||||
for x in range(new_w):
|
|
||||||
top = data[y * new_w + x] > thr
|
|
||||||
bot = data[(y + 1) * new_w + x] > thr if y + 1 < pix_h else False
|
|
||||||
if top and bot:
|
|
||||||
row.append("█")
|
|
||||||
elif top:
|
|
||||||
row.append("▀")
|
|
||||||
elif bot:
|
|
||||||
row.append("▄")
|
|
||||||
else:
|
|
||||||
row.append(" ")
|
|
||||||
rows.append("".join(row))
|
|
||||||
while rows and not rows[-1].strip():
|
|
||||||
rows.pop()
|
|
||||||
while rows and not rows[0].strip():
|
|
||||||
rows.pop(0)
|
|
||||||
return rows if rows else [""]
|
|
||||||
|
|
||||||
|
|
||||||
def _big_wrap(text, max_w, font=None):
|
|
||||||
"""Word-wrap text and render with OTF font."""
|
|
||||||
if font is None:
|
|
||||||
font = _font()
|
|
||||||
words = text.split()
|
|
||||||
lines, cur = [], ""
|
|
||||||
for word in words:
|
|
||||||
test = f"{cur} {word}".strip() if cur else word
|
|
||||||
bbox = font.getbbox(test)
|
|
||||||
if bbox:
|
|
||||||
img_h = bbox[3] - bbox[1] + 8
|
|
||||||
pix_h = _RENDER_H * 2
|
|
||||||
scale = pix_h / max(img_h, 1)
|
|
||||||
term_w = int((bbox[2] - bbox[0] + 8) * scale)
|
|
||||||
else:
|
|
||||||
term_w = 0
|
|
||||||
if term_w > max_w - 4 and cur:
|
|
||||||
lines.append(cur)
|
|
||||||
cur = word
|
|
||||||
else:
|
|
||||||
cur = test
|
|
||||||
if cur:
|
|
||||||
lines.append(cur)
|
|
||||||
out = []
|
|
||||||
for i, ln in enumerate(lines):
|
|
||||||
out.extend(_render_line(ln, font))
|
|
||||||
if i < len(lines) - 1:
|
|
||||||
out.append("")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _lr_gradient(rows):
|
|
||||||
"""Color each non-space block character with a left-to-right gradient."""
|
|
||||||
n = len(_GRAD_COLS)
|
|
||||||
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
|
||||||
out = []
|
|
||||||
for row in rows:
|
|
||||||
if not row.strip():
|
|
||||||
out.append(row)
|
|
||||||
continue
|
|
||||||
buf = []
|
|
||||||
for x, ch in enumerate(row):
|
|
||||||
if ch == ' ':
|
|
||||||
buf.append(' ')
|
|
||||||
else:
|
|
||||||
idx = min(round(x / max(max_x - 1, 1) * (n - 1)), n - 1)
|
|
||||||
buf.append(f"{_GRAD_COLS[idx]}{ch}\033[0m")
|
|
||||||
out.append("".join(buf))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
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 _make_block(title, src, ts, w):
|
|
||||||
"""Render a headline into a content block with color."""
|
|
||||||
target_lang = (SOURCE_LANGS.get(src) or _detect_location_language(title)) if MODE == 'news' else None
|
|
||||||
lang_font = _font_for_lang(target_lang)
|
|
||||||
if target_lang:
|
|
||||||
title = _translate_headline(title, target_lang)
|
|
||||||
# Don't uppercase scripts that have no case (CJK, Arabic, etc.)
|
|
||||||
if target_lang and target_lang in _NO_UPPER:
|
|
||||||
title_up = re.sub(r"\s+", " ", title)
|
|
||||||
else:
|
|
||||||
title_up = re.sub(r"\s+", " ", title.upper())
|
|
||||||
for old, new in [("\u2019","'"), ("\u2018","'"), ("\u201c",'"'),
|
|
||||||
("\u201d",'"'), ("\u2013","-"), ("\u2014","-")]:
|
|
||||||
title_up = title_up.replace(old, new)
|
|
||||||
big_rows = _big_wrap(title_up, w - 4, lang_font)
|
|
||||||
big_rows = _lr_gradient(big_rows)
|
|
||||||
hc = random.choice([
|
|
||||||
"\033[38;5;46m", # matrix green
|
|
||||||
"\033[38;5;34m", # dark green
|
|
||||||
"\033[38;5;82m", # lime
|
|
||||||
"\033[38;5;48m", # sea green
|
|
||||||
"\033[38;5;37m", # teal
|
|
||||||
"\033[38;5;44m", # cyan
|
|
||||||
"\033[38;5;87m", # sky
|
|
||||||
"\033[38;5;117m", # ice blue
|
|
||||||
"\033[38;5;250m", # cool white
|
|
||||||
"\033[38;5;156m", # pale green
|
|
||||||
"\033[38;5;120m", # mint
|
|
||||||
"\033[38;5;80m", # dark cyan
|
|
||||||
"\033[38;5;108m", # grey-green
|
|
||||||
"\033[38;5;115m", # sage
|
|
||||||
"\033[1;38;5;46m", # bold green
|
|
||||||
"\033[1;38;5;250m",# bold white
|
|
||||||
])
|
|
||||||
content = [" " + r for r in big_rows]
|
|
||||||
content.append("")
|
|
||||||
meta = f"\u2591 {src} \u00b7 {ts}"
|
|
||||||
content.append(" " * max(2, w - len(meta) - 2) + meta)
|
|
||||||
return content, hc, len(content) - 1 # (rows, color, meta_row_index)
|
|
||||||
|
|
||||||
|
|
||||||
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(GLITCH + KATA)}{RST}"
|
|
||||||
if random.random() < d else " "
|
|
||||||
for _ in range(w)
|
|
||||||
)
|
|
||||||
elif r < 0.78:
|
|
||||||
# Status / program output
|
|
||||||
sources = FEEDS if 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(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(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}"
|
|
||||||
|
|
||||||
|
|
||||||
def stream(items):
|
|
||||||
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 = FIREHOSE_H if FIREHOSE else 0
|
|
||||||
sh = h - fh # scroll zone height
|
|
||||||
GAP = 3 # blank rows between headlines
|
|
||||||
scroll_interval = _SCROLL_DUR / (sh + 15) * 2
|
|
||||||
|
|
||||||
# active 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)
|
|
||||||
noise_cache = {}
|
|
||||||
scroll_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]
|
|
||||||
|
|
||||||
while queued < HEADLINE_LIMIT or active:
|
|
||||||
t0 = time.monotonic()
|
|
||||||
w, h = tw(), th()
|
|
||||||
fh = FIREHOSE_H if FIREHOSE else 0
|
|
||||||
sh = h - fh
|
|
||||||
|
|
||||||
# Advance scroll on schedule
|
|
||||||
scroll_accum += _FRAME_DT
|
|
||||||
while scroll_accum >= scroll_interval:
|
|
||||||
scroll_accum -= scroll_interval
|
|
||||||
cam += 1
|
|
||||||
|
|
||||||
# Enqueue new headlines when room at the bottom
|
|
||||||
while next_y < cam + sh + 10 and queued < 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
|
|
||||||
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]
|
|
||||||
for k in list(noise_cache):
|
|
||||||
if k < cam:
|
|
||||||
del noise_cache[k]
|
|
||||||
|
|
||||||
# Draw scroll zone
|
|
||||||
top_zone = max(1, int(sh * 0.25))
|
|
||||||
bot_zone = max(1, int(sh * 0.10))
|
|
||||||
buf = []
|
|
||||||
for r in range(sh):
|
|
||||||
cy = cam + r
|
|
||||||
top_f = min(1.0, r / top_zone)
|
|
||||||
bot_f = min(1.0, (sh - 1 - r) / bot_zone)
|
|
||||||
row_fade = min(top_f, bot_f)
|
|
||||||
drawn = False
|
|
||||||
for content, hc, by, midx in active:
|
|
||||||
cr = cy - by
|
|
||||||
if 0 <= cr < len(content):
|
|
||||||
ln = _vis_trunc(content[cr], w)
|
|
||||||
if row_fade < 1.0:
|
|
||||||
ln = _fade_line(ln, row_fade)
|
|
||||||
if cr == midx:
|
|
||||||
buf.append(f"\033[{r+1};1H{W_COOL}{ln}{RST}\033[K")
|
|
||||||
elif ln.strip():
|
|
||||||
buf.append(f"\033[{r+1};1H{hc}{ln}{RST}\033[K")
|
|
||||||
else:
|
|
||||||
buf.append(f"\033[{r+1};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[{r+1};1H{n}")
|
|
||||||
else:
|
|
||||||
buf.append(f"\033[{r+1};1H\033[K")
|
|
||||||
|
|
||||||
# Draw firehose zone
|
|
||||||
if 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 = max(0.0, _mic_db - MIC_THRESHOLD_DB)
|
|
||||||
glitch_prob = 0.32 + min(0.9, mic_excess * 0.16)
|
|
||||||
n_hits = 4 + int(mic_excess / 2)
|
|
||||||
g_limit = sh if FIREHOSE else len(buf)
|
|
||||||
if random.random() < glitch_prob and g_limit > 0:
|
|
||||||
for _ in range(min(n_hits, g_limit)):
|
|
||||||
gi = random.randint(0, g_limit - 1)
|
|
||||||
buf[gi] = f"\033[{gi+1};1H{glitch_bar(w)}"
|
|
||||||
|
|
||||||
sys.stdout.buffer.write("".join(buf).encode())
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
# Precise frame timing
|
|
||||||
elapsed = time.monotonic() - t0
|
|
||||||
time.sleep(max(0, _FRAME_DT - elapsed))
|
|
||||||
|
|
||||||
sys.stdout.write(CLR)
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── MAIN ─────────────────────────────────────────────────
|
|
||||||
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 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 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_ok = _start_mic()
|
|
||||||
if _HAS_MIC:
|
|
||||||
boot_ln("Microphone", "ACTIVE" if mic_ok else "OFFLINE · check System Settings → Privacy → Microphone", mic_ok)
|
|
||||||
if 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)
|
|
||||||
|
|
||||||
print()
|
|
||||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
|
||||||
print(f" {G_DIM}> {HEADLINE_LIMIT} SIGNALS PROCESSED{RST}")
|
|
||||||
print(f" {W_GHOST}> end of stream{RST}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user