I built a 24/7 AI-run pixel-art TV news network in one evening
Someone on Reddit posted PNN: a pixel-art news channel that runs around the clock, with AI anchors reading AI-written news. It was great, and it was mostly crypto. I wanted the same thing for what I actually read: gardening, AI, cybersecurity, Big Tech, and Gulf Coast regional news. No crypto at all.
So now there's GNN, the Greenhouse News Network. It took one evening. The pieces:
- Writer: Claude, through the
claudeCLI, turns about 45 real RSS feeds plus the National Weather Service forecast into a 4-minute segment of JSON dialogue. - Voices: Kokoro, running locally as ONNX on the CPU.
- Picture: procedural Pillow pixel art, 320x180, 24 fps.
- Broadcast: one continuous live HLS stream that also shows up as a channel in Emby.
The rendering was the easy part. The interesting parts were making it act like television, and stopping a language model from making up statistics on air.
Problem one: a channel, not a playlist
The easy version is a folder of mp4s and a player that loops through them. That's not TV. If I open it on the kitchen TV and on my phone, both should be in the same moment of the same show. And an IPTV client like Emby expects a live channel, not a VOD list.
So each rendered segment gets stream-copied (no re-encode) into HLS chunks, with the timestamps shifted onto a running broadcast clock:
cmd = ["ffmpeg", "-y", "-loglevel", "error", "-i", item["video"], "-c", "copy",
"-muxdelay", "0", "-muxpreload", "0", "-output_ts_offset", f"{self.pts:.3f}",
"-f", "hls", "-hls_time", str(config.HLS_TIME), "-hls_list_size", "0",
"-start_number", str(self.seq), "-hls_segment_filename",
os.path.join(config.LIVE_DIR, "c%d.ts"), tmp]
subprocess.run(cmd, check=True, timeout=120)
-output_ts_offset is the important flag. Segment 412 doesn't start at PTS zero. It starts wherever segment 411 ended, so a player sees one long timeline and not a pile of clips. -start_number keeps chunk names increasing across segments. ffmpeg's throwaway playlist is parsed for chunk durations and then deleted. I write the real playlist myself.
Then comes the part that makes it live. Chunks go into a pending queue and are only published to the playlist when the wall clock reaches them:
while self.pending and time.time() >= self.next_pub:
e = self.pending.popleft()
with self.lock:
self.window.append(e)
if e["item"]:
self.now = dict(e["item"], started=time.time())
while len(self.window) > config.WINDOW:
if self.window.popleft()["disc"]:
self.disc += 1
self.next_pub += e["dur"]
The playlist is a sliding window of 8 four-second chunks with a moving EXT-X-MEDIA-SEQUENCE. Each segment boundary gets an EXT-X-DISCONTINUITY, and EXT-X-DISCONTINUITY-SEQUENCE goes up when one falls off the front. That counter is easy to forget, and without it players get confused. It's written to .tmp and moved into place with os.replace, so a client never reads half a file. Every viewer polls the same file, so every viewer is at the same point.
One more detail: MPEG-TS timestamps are 33 bits and wrap at about 26.5 hours. The clock resets at 80,000 seconds, well before that, and the discontinuity tag covers the jump.
With a real live playlist, the Emby side is two tiny endpoints: an M3U that points at /live.m3u8, and an XMLTV guide generated from the show schedule. Emby treats it like any other channel, with a program guide.
Problem two: the model will invent numbers
Each segment's prompt lists 7 numbered source items. Every line the model writes has to say which source it's about. The rules say any number has to appear in that source. The model mostly follows that. "Mostly" isn't good enough for something that reads statistics out loud as news.
So there's a standards desk: a pass after generation that drops any line it can't verify. The first version pulled out digits with a regex and checked each one against the cited item's title and summary. The model wrote "two hundred seventy-two million" right past it.
I hadn't asked it to cheat. Spelled-out numbers just don't match \d, so the check passed. The fix is to also check spelled-out scale words:
_SPELLED = re.compile(r"\b(hundred|thousand|million|billion|trillion|twenty|thirty|forty|"
r"fifty|sixty|seventy|eighty|ninety|percent)\b", re.I)
_NUM = re.compile(r"\$?\d[\d,]*(?:\.\d+)?%?")
def problems(cands):
allowed = set(extra)
txt = ""
for it in cands:
allowed |= _nums(it["title"] + " " + it["summary"])
txt += " " + (it["title"] + " " + it["summary"]).lower()
bad = [n for n in _nums(text) if n not in allowed and not (n.isdigit() and int(n) <= 12)]
for w in _SPELLED.findall(text):
w = w.lower()
if w == "percent" and "%" in txt:
continue
if w not in txt:
bad.append(w)
return bad
The prompt now also says "write every number as digits", which makes the digit check do most of the work. The word check catches what gets through. Small integers (up to 12) are allowed, so "a top 5 list" doesn't get a line killed.
There's a second failure: correct numbers on the wrong source. The model would quote a real figure from story 3 and tag it as story 5. That line isn't a lie, but the on-screen card would show the wrong headline under it. So before dropping a line, the desk checks whether some other source holds every number in it, and re-points the card if so:
bad = problems([item] if item else its)
if bad and item: # model mis-cited: re-point the card at the story that holds the numbers
for alt in its:
if alt is not item and not problems([alt]):
item, bad = alt, []
break
if bad:
killed.append((ln, f"unsourced numbers {bad}"))
continue
The rule I care about most: the headline card on screen never comes from the model. The model returns a source index. The card is drawn from that feed item's real title and outlet. The anchors can riff, but the chyron shows the actual story. Crypto terms and URLs are dropped the same way, with a regex on every line.
If fewer than 8 lines survive, the segment is thrown out and rewritten.
Mouths, voices, and the 320x180 trick
Lip sync is cheap. After Kokoro synthesizes a line, I take the RMS of the audio over each video frame, normalize it to the line's 90th percentile, and bucket it into four mouth shapes:
def rms_frames(x, fps):
hop = SR // fps
n = len(x) // hop + 1
pad = np.zeros(n * hop, np.float32)
pad[:len(x)] = x
return np.sqrt((pad.reshape(n, hop) ** 2).mean(axis=1))
# in the frame loop
mouth = 3 if lv > 0.85 else 2 if lv > 0.5 else 1 if lv > 0.15 else 0
At pixel-art resolution it reads as talking. Nobody can lip-read a four-pixel mouth.
Every frame is drawn at the native 320x180 with Pillow and piped to ffmpeg as raw RGB. I don't upscale in Python. ffmpeg does it on the way into x264:
"-vf", f"scale={config.W * config.SCALE}:{config.H * config.SCALE}:flags=neighbor",
flags=neighbor keeps the pixels square. Python only fills 57,600 pixels per frame instead of 921,600, which is the difference between a render that keeps up and one that doesn't. -tune animation helps x264 with the flat color areas.
Problem three: don't pay to broadcast to nobody
An LLM channel that writes around the clock to an empty room is a waste of money. The producer only writes when someone has fetched the playlist in the last 20 minutes, and there's a hard daily cap on LLM calls:
watched = bool(viewers(config.WATCH_GRACE))
never = not db.q("SELECT 1 FROM segments WHERE kind='show' LIMIT 1")
ready = db.q("SELECT count(*) AS n FROM segments WHERE status='ready' AND kind='show'", one=True)["n"]
if (watched or never) and ready < config.BUFFER_TARGET and writer.budget_left() > 0:
if produce_one() is None:
time.sleep(60)
But the channel still can't go dark, so the broadcaster has fallbacks. When there's nothing fresh, it reruns the least-aired segment from the last 30 hours (preferring the current show, never back-to-back) with an ENCORE bug. When there's nothing to rerun, it plays "Garden Cam": a 90-second ambient loop with a procedural chiptune bed and the headline ticker. No LLM, no TTS. It costs nothing, and it's honestly kind of nice.
The broadcast loop catches every exception, logs it, and keeps going. One bad mp4 should never knock the channel off the air.
What I'd do differently
The standards desk only checks numbers. It can't catch a line that gets the facts wrong without numbers ("Apple denied the report" when the source says nothing of the kind). A second cheap model pass that checks each line against its cited source would catch more, at the cost of another LLM call per segment. I haven't decided it's worth it.
I'd also build the live timeline first next time. The art and the mouths are the fun part, but what made it feel like TV, and not a slideshow, was 30 lines of playlist bookkeeping.