Merge pull request 'feat/stdout' (#3) from feat/stdout into main

Reviewed-on: genewildish/Mainline#3
This commit is contained in:
2026-03-15 00:05:29 +00:00
2 changed files with 140 additions and 30 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.mainline_venv/
.mainline_cache_*.json

View File

@@ -56,6 +56,7 @@ HEADLINE_LIMIT = 1000
FEED_TIMEOUT = 10 FEED_TIMEOUT = 10
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
MODE = 'poetry' if '--poetry' in sys.argv or '-p' in sys.argv else 'news' 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/literature sources — public domain via Project Gutenberg
POETRY_SOURCES = { POETRY_SOURCES = {
@@ -459,8 +460,39 @@ def fetch_poetry():
return items, linked, failed 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 ─────────────────────────────────────────────── # ─── STREAM ───────────────────────────────────────────────
_SCROLL_DUR = 3.75 # seconds per headline _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_db = -99.0 # current mic level, written by background thread
_mic_stream = None _mic_stream = None
@@ -676,6 +708,51 @@ def _make_block(title, src, ts, w):
return content, hc, len(content) - 1 # (rows, color, meta_row_index) 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): def stream(items):
random.shuffle(items) random.shuffle(items)
pool = list(items) pool = list(items)
@@ -687,14 +764,17 @@ def stream(items):
sys.stdout.flush() sys.stdout.flush()
w, h = tw(), th() w, h = tw(), th()
fh = FIREHOSE_H if FIREHOSE else 0
sh = h - fh # scroll zone height
GAP = 3 # blank rows between headlines GAP = 3 # blank rows between headlines
dt = _SCROLL_DUR / (h + 15) * 2 # 2x slower scroll scroll_interval = _SCROLL_DUR / (sh + 15) * 2
# active blocks: (content_rows, color, canvas_y, meta_idx) # active blocks: (content_rows, color, canvas_y, meta_idx)
active = [] active = []
cam = 0 # viewport top in virtual canvas coords cam = 0 # viewport top in virtual canvas coords
next_y = h # canvas-y where next block starts (off-screen bottom) next_y = sh # canvas-y where next block starts (off-screen bottom)
noise_cache = {} noise_cache = {}
scroll_accum = 0.0
def _noise_at(cy): def _noise_at(cy):
if cy not in noise_cache: if cy not in noise_cache:
@@ -702,23 +782,41 @@ def stream(items):
return noise_cache[cy] return noise_cache[cy]
while queued < HEADLINE_LIMIT or active: while queued < HEADLINE_LIMIT or active:
t0 = time.monotonic()
w, h = tw(), th() w, h = tw(), th()
fh = FIREHOSE_H if FIREHOSE else 0
sh = h - fh
# Enqueue new headlines when room at the bottom # Advance scroll on schedule
while next_y < cam + h + 10 and queued < HEADLINE_LIMIT: scroll_accum += _FRAME_DT
t, src, ts = _next_headline(pool, items, seen) while scroll_accum >= scroll_interval:
content, hc, midx = _make_block(t, src, ts, w) scroll_accum -= scroll_interval
active.append((content, hc, next_y, midx)) cam += 1
next_y += len(content) + GAP
queued += 1
# Draw frame # Enqueue new headlines when room at the bottom
top_zone = max(1, int(h * 0.25)) # 25% fade zone at top (exit) while next_y < cam + sh + 10 and queued < HEADLINE_LIMIT:
bot_zone = max(1, int(h * 0.10)) # 10% fade zone at bottom (entry) 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 = [] buf = []
for r in range(h): for r in range(sh):
cy = cam + r cy = cam + r
row_fade = min(1.0, min(r / top_zone, (h - 1 - r) / bot_zone)) 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 drawn = False
for content, hc, by, midx in active: for content, hc, by, midx in active:
cr = cy - by cr = cy - by
@@ -743,28 +841,28 @@ def stream(items):
else: else:
buf.append(f"\033[{r+1};1H\033[K") buf.append(f"\033[{r+1};1H\033[K")
# Glitch — base rate + mic-reactive spikes # 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) mic_excess = max(0.0, _mic_db - MIC_THRESHOLD_DB)
glitch_prob = 0.32 + min(0.9, mic_excess * 0.16) glitch_prob = 0.32 + min(0.9, mic_excess * 0.16)
n_hits = 4 + int(mic_excess / 2) n_hits = 4 + int(mic_excess / 2)
if random.random() < glitch_prob and buf: g_limit = sh if FIREHOSE else len(buf)
for _ in range(min(n_hits, h)): if random.random() < glitch_prob and g_limit > 0:
gi = random.randint(0, len(buf) - 1) 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)}" buf[gi] = f"\033[{gi+1};1H{glitch_bar(w)}"
sys.stdout.write("".join(buf)) sys.stdout.buffer.write("".join(buf).encode())
sys.stdout.flush() sys.stdout.flush()
time.sleep(dt)
# Advance viewport # Precise frame timing
cam += 1 elapsed = time.monotonic() - t0
time.sleep(max(0, _FRAME_DT - elapsed))
# 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]
sys.stdout.write(CLR) sys.stdout.write(CLR)
sys.stdout.flush() sys.stdout.flush()
@@ -808,7 +906,11 @@ def main():
print() print()
time.sleep(0.4) time.sleep(0.4)
if MODE == 'poetry': 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") slow_print(" > INITIALIZING LITERARY CORPUS...\n")
time.sleep(0.2) time.sleep(0.2)
print() print()
@@ -816,6 +918,7 @@ def main():
print() print()
print(f" {G_DIM}>{RST} {G_MID}{linked} TEXTS LOADED{RST} {W_GHOST}· {failed} DARK{RST}") 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}") print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
_save_cache(items)
else: else:
slow_print(" > INITIALIZING FEED ARRAY...\n") slow_print(" > INITIALIZING FEED ARRAY...\n")
time.sleep(0.2) time.sleep(0.2)
@@ -824,6 +927,7 @@ def main():
print() print()
print(f" {G_DIM}>{RST} {G_MID}{linked} SOURCES LINKED{RST} {W_GHOST}· {failed} DARK{RST}") 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}") print(f" {G_DIM}>{RST} {G_MID}{len(items)} SIGNALS ACQUIRED{RST}")
_save_cache(items)
if not items: if not items:
print(f"\n {W_DIM}> NO SIGNAL — check network{RST}") print(f"\n {W_DIM}> NO SIGNAL — check network{RST}")
@@ -833,6 +937,8 @@ def main():
mic_ok = _start_mic() mic_ok = _start_mic()
if _HAS_MIC: if _HAS_MIC:
boot_ln("Microphone", "ACTIVE" if mic_ok else "OFFLINE · check System Settings → Privacy → Microphone", mic_ok) 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) time.sleep(0.4)
slow_print(" > STREAMING...\n") slow_print(" > STREAMING...\n")