"""Desk Companion — a context-aware ASCII visual diary. HF "Build Small" submission, Thousand Token Wood track. One small local model (llama.cpp) senses the room — through YOUR browser's webcam/mic (light + sound computed client-side; only two numbers reach the app), through a real Arduino over serial, or through a simulated day — and writes a moody diary + ASCII art, pushing a short line to the (virtual or physical) OLED screen. """ from __future__ import annotations import math import gradio as gr import threading from companion import CompanionRunner, DeskPersona from companion.runner import POLL_S from companion.text import bar from companion.ui import CSS, FORCE_DARK_JS, theme WELCOME_ART = r""" .--------------. | hello, you | | ( o_o ) | | watching | | your room | '--------------' """ EXPLAINER = """ **Your desk has a tiny spirit that keeps a diary.** It feels the room — brightness, sound, the hour — and when something shifts (you switch the lights off, the night gets long) it writes a short moody diary entry, draws ASCII art on its little screen, and leaves you a two-line note. One small 3B model, running locally. No cloud. **Try it:** ① press **🎥 sense my room** and allow camera+mic (nothing is recorded — light & sound levels are computed in your browser) → ② turn your lights off → ③ watch the screen react. Too shy? Press **✎ write now** and it writes about the room as-is. """ # Runs once when the user presses "sense my room": asks for camera+mic, then # keeps window._dc.{light,sound} updated twice a second. All processing stays # in the browser — frames and audio never leave the page. START_SENSING_JS = """ async () => { if (window._dc && window._dc.started) return; window._dc = {light: -1, sound: -1, started: true}; try { const stream = await navigator.mediaDevices.getUserMedia({video: true, audio: true}); const video = document.createElement('video'); video.srcObject = stream; video.muted = true; video.playsInline = true; await video.play(); const canvas = document.createElement('canvas'); canvas.width = 32; canvas.height = 24; const ctx = canvas.getContext('2d', {willReadFrequently: true}); const ac = new AudioContext(); const analyser = ac.createAnalyser(); analyser.fftSize = 256; ac.createMediaStreamSource(stream).connect(analyser); const buf = new Uint8Array(analyser.fftSize); setInterval(() => { try { ctx.drawImage(video, 0, 0, 32, 24); const d = ctx.getImageData(0, 0, 32, 24).data; let lum = 0; for (let i = 0; i < d.length; i += 4) lum += 0.2126*d[i] + 0.7152*d[i+1] + 0.0722*d[i+2]; window._dc.light = Math.round(lum / (d.length/4) / 255 * 100); analyser.getByteTimeDomainData(buf); let rms = 0; for (let i = 0; i < buf.length; i++) { const v = (buf[i] - 128) / 128; rms += v*v; } window._dc.sound = Math.round(Math.min(100, Math.sqrt(rms/buf.length) * 400)); } catch (e) { /* keep last values */ } }, 500); } catch (e) { window._dc.started = false; // permission denied — stay on simulation } } """ # Every poll tick: hand the latest browser readings to Python (-1 = not live). READ_SENSORS_JS = """ (a, b) => { const d = window._dc || {}; return [d.light ?? -1, d.sound ?? -1]; } """ def build(): runner = CompanionRunner(DeskPersona(), mock_mode="day", browser=True) def status_md(): r = runner.latest() clk = r.clock if r else "--:--" return runner.status_md() + f" · **desk clock** `{clk}`" def gauges_md(): r = runner.latest() if r is None: return "```\nwaiting for the desk to wake up...\n```" temp = "" if math.isnan(r.temp) else f"temp {r.temp:5.1f}C\n" return ("```\n" f"light {r.light:3d}% {bar(r.light)}\n" f"sound {r.sound:3d}% {bar(r.sound)}\n" f"{temp}```") def art_text(): return runner.get("ascii") or WELCOME_ART def screen_text(): return (runner.get("screen") or "...").replace("|", "\n") def feed_md(): entries = runner.feed() if not entries: return "_warming up — the first entry writes itself in a few seconds..._" return "\n\n---\n\n".join( f"**`{e['clock']}`** · _{e['trigger']}_ \n{e['entry']} \n" f"{e['context']}" for e in entries ) def on_tick(light, sound): runner.ingest_browser(light, sound) return status_md(), gauges_md(), art_text(), screen_text(), feed_md() # First entry writes itself shortly after boot so the page is never blank. def first_entry(): for _ in range(20): if runner.latest() is not None: runner.generate(auto=True) return import time time.sleep(0.5) threading.Thread(target=first_entry, daemon=True).start() with gr.Blocks(title="Desk Companion") as demo: gr.Markdown("# ▚ desk companion") gr.Markdown(EXPLAINER, elem_id="hero") status = gr.Markdown(status_md()) with gr.Row(): with gr.Column(scale=1): sense_btn = gr.Button("🎥 sense my room", variant="secondary") gr.Markdown("### sensors") gauges = gr.Markdown(gauges_md()) gr.Markdown("### screen (OLED)") art = gr.Markdown(elem_id="oled", value=art_text()) screen = gr.Markdown(elem_id="screen", value=screen_text()) with gr.Accordion("hardware (optional Arduino)", open=False): port = gr.Textbox(label="serial port", placeholder="/dev/tty.usbmodem... (blank = no device)") connect = gr.Button("connect") write_btn = gr.Button("✎ write now", variant="primary") with gr.Column(scale=2): gr.Markdown("### diary") feed = gr.Markdown(feed_md()) h_light = gr.Number(value=-1, visible=False) h_sound = gr.Number(value=-1, visible=False) sense_btn.click(None, js=START_SENSING_JS) gr.Timer(POLL_S).tick( on_tick, inputs=[h_light, h_sound], outputs=[status, gauges, art, screen, feed], js=READ_SENSORS_JS, ) write_btn.click(lambda: (runner.generate(False), art_text(), screen_text(), feed_md())[1:], outputs=[art, screen, feed]) connect.click(runner.connect, inputs=[port], outputs=[status]) return demo if __name__ == "__main__": build().launch(css=CSS, theme=theme(), js=FORCE_DARK_JS)