| """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. |
| """ |
|
|
| |
| |
| |
| 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 |
| } |
| } |
| """ |
|
|
| |
| 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"<sub>{e['context']}</sub>" 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() |
|
|
| |
| 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) |
|
|