Experiment

It Really Was Worse at Night

TODO add joke about how light attracts bugs but this time the bug came at night.

"I swear it stutters more at night." That was the whole bug report, and even I didn't fully believe it. The baby monitor I built streams beautifully every time I test it during the day, from the same spot in the house, on the same phone. But at night, checking on a sleeping newborn, the picture would freeze, catch up, freeze again. New parent, 3am, staring at a frozen frame of a crib — you don't exactly trust your own perception at that point. I was fully prepared to learn this was the sleep deprivation talking.

The monitor is fuschild_monitor: a Raspberry Pi 4 with a Camera Module 3 NoIR pointed at the crib, streaming 720p WebRTC through go2rtc, plus a cry detector listening on a USB mic. Self-hosted, no cloud, no subscription. Works great. Except at night. Maybe.

So instead of arguing with my own memory, I pointed Claude Code (Fable 5) at it. It could SSH into the Pi over the tailnet, and — this is the part I enjoyed most — it could open the live stream itself in a headless browser and read the WebRTC statistics while it watched. No more "it felt choppy." Numbers.

The suspects

My theories, in order of confidence: the Pi 4 is too weak for video, or I'm imagining the whole thing.

The Pi was acquitted almost immediately. 57°C and never throttled. The video encoding happens in hardware, so the stream costs about 8% of one CPU. Seven gigabytes of RAM free. Even the cry detector's neural network, which I was sure was grinding the machine down, had used six minutes of CPU time in two days. The Pi is, frankly, bored.

Which left the embarrassing theory. And here's where it got fun, because it was about 9:40pm and the nursery was already dark — the exact conditions I claimed were cursed.

The camera confesses

Claude grabbed eight seconds of video straight off the camera, along with the per-frame metadata the camera stack reports. The room measured 0.74 lux — near-total darkness, which is the whole reason for the infrared-capable NoIR camera. To cope, the sensor had cranked its analogue gain to 16×, which is the ceiling, and stretched each exposure to 66 milliseconds, the longest a 15fps frame allows. Both maxed out. And still delivering a perfectly steady 15 frames per second — the camera wasn't dropping a thing.

But those eight seconds of video weighed 9.3 megabytes. That's roughly 10 Mbps, which happens to be the encoder's default bitrate budget, fully spent. There's the mechanism: at 16× gain the image is mostly sensor noise, noise looks like fine random detail, and fine random detail is the one thing video compression fundamentally cannot compress. During the day the same scene is clean and compresses to a fraction of the budget. At night, the encoder was faithfully spending 10 megabits per second transmitting static.

The stuttering wasn't in my head. It was photons — the lack of them.

Watching it fail in real time

Then the receipts. Claude watched the actual live stream for 35 seconds, in the dark, collecting stats: frozen for 24 of them. Four separate freezes, one lasting five straight seconds while packets kept arriving. In that half minute the browser sent 569 retransmission requests and 19 formal pleas for a fresh keyframe.

Those pleas are the cruel detail. WebRTC has a recovery mechanism built for exactly this: the viewer asks the encoder for a new keyframe and playback resumes almost instantly. But my video comes out of rpicam-vid through a one-way pipe — there's no channel to pass the request back to the encoder. So every unlucky burst of packet loss froze the picture until the next scheduled keyframe wandered by, several seconds later. At 3am, over a frozen crib, those are long seconds.

(One side discovery I can't leave out: the config binds go2rtc's WebRTC to 127.0.0.1 — loopback only, which by all rights should mean video for no one. It works anyway, because the ICE library quietly gathers its own network candidates on every interface and streams over the LAN directly, ignoring the carefully-configured proxy entirely. The monitor has been working by accident since day one.)

Two flags

The test that settled it was satisfying: same camera, same dark room, minutes apart, one temporary stream with two extra flags — --bitrate 3000000 --intra 15. Cap the encoder at 3 Mbps, emit a keyframe every second.

Thirty-five seconds watched: zero freezes. Zero packet loss. Zero retransmissions. Steady 15fps. The sensor noise is still there — the encoder just smooths it over instead of transmitting every grain of static in archival quality, which a baby monitor emphatically does not need.

That's the fix that shipped. The verification run afterwards even caught a small packet-loss burst live, and the picture recovered in 1.05 seconds — the one-second keyframe interval doing precisely its job.


For weeks I treated "it's worse at night" like a suspicion I should apologize for. It turned out to be a measurement. Darkness raised the sensor gain, gain made noise, noise saturated the encoder's default budget, and the losses had no fast way to heal. Every step mundane, the sum invisible until someone watched the stream with instruments instead of feelings.

The fix was two flags I could have set on day one. I just never would have believed they were the problem without watching the before and after, in the dark, with numbers attached. Turns out I wasn't the unreliable sensor in this system.