diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd7c683 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.mainline_venv/ +.mainline_cache_*.json diff --git a/mainline.py b/mainline.py index 46e37d4..7d34886 100755 --- a/mainline.py +++ b/mainline.py @@ -56,6 +56,7 @@ 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 = { @@ -459,8 +460,39 @@ def fetch_poetry(): 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 @@ -676,6 +708,51 @@ def _make_block(title, src, ts, w): 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) @@ -687,14 +764,17 @@ def stream(items): 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 - 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 = [] 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 = {} + scroll_accum = 0.0 def _noise_at(cy): if cy not in noise_cache: @@ -702,23 +782,41 @@ def stream(items): 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 - # Enqueue new headlines when room at the bottom - while next_y < cam + h + 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 + # Advance scroll on schedule + scroll_accum += _FRAME_DT + while scroll_accum >= scroll_interval: + scroll_accum -= scroll_interval + cam += 1 - # Draw frame - top_zone = max(1, int(h * 0.25)) # 25% fade zone at top (exit) - bot_zone = max(1, int(h * 0.10)) # 10% fade zone at bottom (entry) + # 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(h): + for r in range(sh): 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 for content, hc, by, midx in active: cr = cy - by @@ -743,28 +841,28 @@ def stream(items): else: 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) glitch_prob = 0.32 + min(0.9, mic_excess * 0.16) n_hits = 4 + int(mic_excess / 2) - if random.random() < glitch_prob and buf: - for _ in range(min(n_hits, h)): - gi = random.randint(0, len(buf) - 1) + 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.write("".join(buf)) + sys.stdout.buffer.write("".join(buf).encode()) sys.stdout.flush() - time.sleep(dt) - # Advance viewport - cam += 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] + # Precise frame timing + elapsed = time.monotonic() - t0 + time.sleep(max(0, _FRAME_DT - elapsed)) sys.stdout.write(CLR) sys.stdout.flush() @@ -808,7 +906,11 @@ def main(): print() 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") time.sleep(0.2) print() @@ -816,6 +918,7 @@ def main(): 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) @@ -824,6 +927,7 @@ def main(): 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}") @@ -833,6 +937,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) + if FIREHOSE: + boot_ln("Firehose", "ENGAGED", True) time.sleep(0.4) slow_print(" > STREAMING...\n")