10 Commits

Author SHA1 Message Date
7274f57bbb feat: Implement super-sampling for text rendering and adjust _RENDER_H from 16 to 8. 2026-03-14 19:21:24 -07:00
c857d7bd81 feat: implement dynamic shifting gradients for messages and scrolling content, and adjust rendering parameters 2026-03-14 19:15:55 -07:00
6a5a73fd88 Merge pull request 'feat/mod_poetry' (#7) from feat/mod_poetry into main
Reviewed-on: #7
2026-03-15 02:06:55 +00:00
5474c58ce0 Remove Thoreau and Emerson from poetry sources. 2026-03-14 19:05:25 -07:00
571da4fa47 feat: Add several new authors and their text sources to the TEXTS dictionary. 2026-03-14 19:03:38 -07:00
6d7ab770cd Merge pull request 'feat/ntfy-local' (#6) from feat/ntfy-local into main
Reviewed-on: #6
2026-03-15 01:42:14 +00:00
ed3006677f refactor: Declare _ntfy_message as global within the stream function. 2026-03-14 18:28:15 -07:00
b8b38cd0ad perf: Cache rendered message rows to avoid redundant processing. 2026-03-14 18:26:18 -07:00
030c75f30d feat: Add ntfy message display functionality with background polling and a dedicated rendering state. 2026-03-14 18:23:22 -07:00
543c4ed50d Merge pull request 'feat: introduce server-thin client architecture for mainline.py on ESP32 with ntfy integration and update hardware documentation to reflect this design.' (#5) from feat/arduino into main
Reviewed-on: #5
2026-03-15 01:03:36 +00:00

View File

@@ -39,7 +39,7 @@ sys.path.insert(0, str(next((_VENV / "lib").glob("python*/site-packages"))))
import feedparser # noqa: E402
from PIL import Image, ImageDraw, ImageFont # noqa: E402
import random, time, re, signal, atexit, textwrap # noqa: E402
import random, time, re, signal, atexit, textwrap, threading # noqa: E402
try:
import sounddevice as _sd
import numpy as _np
@@ -58,13 +58,25 @@ 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
# 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",
"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",
}
# ─── ANSI ─────────────────────────────────────────────────
@@ -136,6 +148,7 @@ _FONT_PATH = "/Users/genejohnson/Documents/CS Bishop Drawn/CSBishopDrawn-Italic.
_FONT_OBJ = None
_FONT_SZ = 60
_RENDER_H = 8 # terminal rows per rendered text line
_SSAA = 4 # super-sampling factor: render at _SSAA× then downsample
# Non-Latin scripts → macOS system fonts
_SCRIPT_FONTS = {
@@ -490,9 +503,10 @@ def _save_cache(items):
# ─── 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)
_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)
_mic_db = -99.0 # current mic level, written by background thread
_mic_stream = None
@@ -516,6 +530,42 @@ def _start_mic():
return False
# ─── NTFY MESSAGE QUEUE ───────────────────────────────────
_ntfy_message = None # (title, body, monotonic_timestamp) or None
_ntfy_lock = threading.Lock()
def _start_ntfy_poller():
"""Start background thread polling ntfy for messages."""
def _poll():
global _ntfy_message
while True:
try:
req = urllib.request.Request(
NTFY_TOPIC, 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 _ntfy_lock:
_ntfy_message = (
data.get("title", ""),
data.get("message", ""),
time.monotonic(),
)
except Exception:
pass
time.sleep(NTFY_POLL_INTERVAL)
t = threading.Thread(target=_poll, daemon=True)
t.start()
return True
def _render_line(text, font=None):
"""Render a line of text as terminal rows using OTF font + half-blocks."""
if font is None:
@@ -530,8 +580,11 @@ def _render_line(text, font=None):
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))
hi_h = pix_h * _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 / _SSAA))
img = img.resize((new_w, pix_h), Image.Resampling.LANCZOS)
data = img.tobytes()
thr = 80
@@ -588,8 +641,8 @@ def _big_wrap(text, max_w, font=None):
return out
def _lr_gradient(rows):
"""Color each non-space block character with a left-to-right gradient."""
def _lr_gradient(rows, offset=0.0):
"""Color each non-space block character with a shifting left-to-right gradient."""
n = len(_GRAD_COLS)
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
out = []
@@ -602,7 +655,8 @@ def _lr_gradient(rows):
if ch == ' ':
buf.append(' ')
else:
idx = min(round(x / max(max_x - 1, 1) * (n - 1)), n - 1)
shifted = (x / max(max_x - 1, 1) + offset) % 1.0
idx = min(round(shifted * (n - 1)), n - 1)
buf.append(f"{_GRAD_COLS[idx]}{ch}\033[0m")
out.append("".join(buf))
return out
@@ -682,7 +736,6 @@ def _make_block(title, src, ts, w):
("\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
@@ -754,6 +807,7 @@ def _firehose_line(items, w):
def stream(items):
global _ntfy_message
random.shuffle(items)
pool = list(items)
seen = set()
@@ -781,12 +835,85 @@ def stream(items):
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_COLOR = "\033[1;38;5;87m" # sky cyan
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 < HEADLINE_LIMIT or active:
t0 = time.monotonic()
w, h = tw(), th()
fh = FIREHOSE_H if FIREHOSE else 0
sh = h - fh
# ── Check for ntfy message ────────────────────────
msg_active = False
with _ntfy_lock:
if _ntfy_message is not None:
m_title, m_body, m_ts = _ntfy_message
if time.monotonic() - m_ts < MESSAGE_DISPLAY_SECS:
msg_active = True
else:
_ntfy_message = None # expired
if msg_active:
# ── MESSAGE state: freeze scroll, render message ──
buf = []
# Render message text with OTF font (cached across frames)
display_text = m_body or m_title or "(empty)"
display_text = re.sub(r"\s+", " ", display_text.upper())
cache_key = (display_text, w)
if _msg_cache[0] != cache_key:
msg_rows = _big_wrap(display_text, w - 4)
_msg_cache = (cache_key, msg_rows)
else:
msg_rows = _msg_cache[1]
msg_rows = _lr_gradient(msg_rows, (time.monotonic() * GRAD_SPEED) % 1.0)
# Center vertically in scroll zone
total_h = len(msg_rows) + 4 # +4 for border + meta + padding
y_off = max(0, (sh - total_h) // 2)
for r in range(sh):
ri = r - y_off
if ri == 0 or ri == total_h - 1:
# Border lines
bar = "" * (w - 4)
buf.append(f"\033[{r+1};1H {MSG_BORDER}{bar}{RST}\033[K")
elif 1 <= ri <= len(msg_rows):
ln = _vis_trunc(msg_rows[ri - 1], w)
buf.append(f"\033[{r+1};1H {ln}{RST}\033[K")
elif ri == len(msg_rows) + 1:
# Title line (if present and different from body)
if m_title and m_title != m_body:
meta = f" {MSG_META}\u2591 {m_title}{RST}"
else:
meta = ""
buf.append(f"\033[{r+1};1H{meta}\033[K")
elif ri == len(msg_rows) + 2:
# Source + timestamp
elapsed_s = int(time.monotonic() - m_ts)
remaining = max(0, MESSAGE_DISPLAY_SECS - elapsed_s)
ts_str = datetime.now().strftime("%H:%M:%S")
meta = f" {MSG_META}\u2591 ntfy \u00b7 {ts_str} \u00b7 {remaining}s{RST}"
buf.append(f"\033[{r+1};1H{meta}\033[K")
else:
# Sparse noise outside the message
if random.random() < 0.06:
buf.append(f"\033[{r+1};1H{noise(w)}")
else:
buf.append(f"\033[{r+1};1H\033[K")
# Firehose keeps running during messages
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")
sys.stdout.buffer.write("".join(buf).encode())
sys.stdout.flush()
elapsed = time.monotonic() - t0
time.sleep(max(0, _FRAME_DT - elapsed))
continue
# ── SCROLL state: normal headline rendering ───────
# Advance scroll on schedule
scroll_accum += _FRAME_DT
while scroll_accum >= scroll_interval:
@@ -811,6 +938,7 @@ def stream(items):
# Draw scroll zone
top_zone = max(1, int(sh * 0.25))
bot_zone = max(1, int(sh * 0.10))
grad_offset = (time.monotonic() * GRAD_SPEED) % 1.0
buf = []
for r in range(sh):
cy = cam + r
@@ -821,13 +949,18 @@ def stream(items):
for content, hc, by, midx in active:
cr = cy - by
if 0 <= cr < len(content):
ln = _vis_trunc(content[cr], w)
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[{r+1};1H{W_COOL}{ln}{RST}\033[K")
elif ln.strip():
buf.append(f"\033[{r+1};1H{hc}{ln}{RST}\033[K")
buf.append(f"\033[{r+1};1H{ln}{RST}\033[K")
else:
buf.append(f"\033[{r+1};1H\033[K")
drawn = True
@@ -937,6 +1070,8 @@ def main():
mic_ok = _start_mic()
if _HAS_MIC:
boot_ln("Microphone", "ACTIVE" if mic_ok else "OFFLINE · check System Settings → Privacy → Microphone", mic_ok)
ntfy_ok = _start_ntfy_poller()
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
if FIREHOSE:
boot_ln("Firehose", "ENGAGED", True)