Skip to content

Signal detection

NuclearMeltdown edited this page Aug 25, 2026 · 3 revisions

Signal detection and the empty state

src/render/video_renderer.cpp (AnalyzeSignal), src/app.cpp (HaveLiveSignal, LoadIdleIcon), src/ui/overlay.cpp (DrawIdleScreen)

Why "are frames arriving" is the wrong question

On a digital input it is the whole answer: no signal, no frames, and FrameSink::HasRecentFrame() says so.

On an analogue input it is no answer at all. A card with nothing on its composite pin goes on delivering sixty frames a second of whatever its decoder makes of an open wire. The frames arrive; there is simply nothing in them.

So the question has to be put to the pixels instead.

The trap: no signal is not black

The obvious test — "is the picture dark and flat?" — fails on exactly the retro consoles this matters for. An unterminated analogue input is snow, which is full of contrast.

There are two appearances of no signal and they look nothing like each other:

Contrast in frame Change between frames
Real picture, moving high moderate
Real picture, still (paused, menu) high ~0
Snow (open input, no carrier) high very high
Flat (card muting, decoder unlocked) ~0 ~0
Fade to black ~0 ~0

Snow is decidable. That much contrast with that little correlation cannot be a picture.

Flat is not. A black loading screen produces an identical measurement to a muted input. Nothing in a single frame separates them — only time does.

The measurement

AnalyzeSignal() runs on the CPU over the raw capture buffer, alongside the other three analyses in UploadFrame().

Every sixth frame (kSignalSampleEvery), 2048 luma samples on a sparse grid with an odd stride — for the same reason the level detector uses one: a stride that divides the row length reads a single column and calls it the picture.

Two numbers come out:

  • spreadmax − min across the grid
  • delta — mean absolute difference against the same grid one measurement ago

Only the previous sample set is kept, not the previous frame: a few hundred bytes stand in for a megabyte. A format change resizes the grid, and the comparison simply starts over.

if (spread < kSignalFlatSpan)                                    verdict = Flat;
else if (delta >= kSignalSnowDelta && spread >= kSignalSnowSpan)  verdict = Snow;
else                                                              verdict = Picture;
Constant Value Why
kSignalFlatSpan 16 Wide enough to cover the noise a decoder puts on a muted output, narrow enough that a dim night scene still clears it
kSignalSnowDelta 65 Two independent uniform samples average 85 apart; measured fast motion reaches 52, and the two errors are not equally bad — see below
kSignalSnowSpan 96 So a hard cut between two flat colours cannot be mistaken for snow

Nothing here latches. Unlike the range and interlace verdicts, a signal can come and go, so the measurement runs for the whole session.

Magnitude alone is not enough

Checked against synthetic signals replicating the exact classification:

Case Verdict spread delta
Snow (open input) Snow 255 85
Flat black (card muting) Flat 0 0
Flat blue, slight noise Flat 6 2
Still picture (menu, paused) Picture 223 0
Picture, gentle motion Picture 255 4
Picture, fast motion Picture 255 17
Hard scene cut Snow 223 72
Fade to black Flat 2 2

That last row is the important one. A hard cut between two busy scenes lands inside the snow band — 72 against a threshold of 65. Snow and cuts genuinely overlap in magnitude, and no threshold separates them: pushing it above 72 would start missing real snow, which averages 85 and can sit lower after a decoder's own filtering.

What separates them is that snow persists and a cut does not.

Persistence, in HaveLiveSignal()

Neither failure verdict is acted on the instant it appears.

Verdict Patience
Snow 2.0 s Far longer than any cut, far shorter than anyone's patience with a dead input
Flat 8.0 s Being wrong here covers a game that was only fading out
Flat, and the decoder reports no lock 2.0 s No reason to wait on a second opinion

Snow started at 1.0 s and was raised after the detector met real content. On a live racing demo, steady fast motion reads a change of 37, far above the synthetic estimate of 17, and a fade-and-cut between scenes produced a burst of Snow, Picture, Flat, Picture, Snow inside 1.2 seconds. None of those held long enough to show the idle screen even at 1.0 s, but the margin was thinner than it should be, and widening the time costs nothing: an unterminated input does not stop being snow after two seconds.

Why the threshold is 65 and not 48

The same correction, applied to the other axis. 48 was the first value tried, chosen off the synthetic estimate of 17 for fast motion. Live 1080p60 with a busy animated background reaches 52 and sits in the high forties for seconds at a time, so 48 had no margin at all and the verdict flipped several times a second.

Sixty-five, because the two errors are not equally bad:

  • Too low and a moving picture is declared dead and covered by the idle screen. That is the one outcome nobody can work around.
  • Too high and snow goes unrecognised — at which point you simply see the snow, which tells you exactly what the idle screen would have.

The persistence window is the second line of defence, and the reason a threshold this generous is safe: a hard cut at 72 crosses it, but not for two seconds.

Simulated over a stream of measurements at ten a second:

Stream Idle screen shown
Six seconds of play with one scene cut 0 of 59 measurements
Six seconds of play with three rapid cuts 0 of 59 measurements
Six seconds of open input 48 of 59 measurements

The decoder's own get_HorizontalLocked is used only to say "no" faster, never to say "yes" — this card reports a lock on some standards with nothing connected at all, which is measured and written down in Source and signal.

Known limits

  • RGB sources are judged on green alone. LumaLayout() returns green as the stand-in for luma, which carries most of it for one read instead of three. A flat blue mute screen therefore reads as spread 0, which is correct — but a genuinely blue-dominated picture has less headroom than it looks.
  • P010 and P016 are not analysed at all. LumaLayout() has no entry for them, so an HDR source never leaves Unknown and always counts as live. HDR arrives over HDMI, where frame arrival already answers the question.
  • Marginal snow can flicker between Snow and Picture, resetting the clock and never reaching a second. That is a false negative — you keep seeing the noise, which is itself informative — and it is the safe direction to fail in.

The empty state

DrawIdleScreen() replaces the old text card when there is no picture: the application icon, the name under it, and one line saying why.

It is separate from DrawStatusCard() because it means something different. The card interrupts a picture and expects it back — which is why Connection lost still uses the card, spinner and all. The idle screen replaces a picture that was never there.

The scrim. A translucent fill of the theme's window background is drawn into ImGui's background list, which sits over the video and under every window. Not opaque: what the card is sending is still worth seeing, and the difference between blue, black and snow is exactly what tells you which cable to go and look at. But a wall of moving noise behind the wordmark is unreadable, and a muted blue is bright enough to fight with it.

The icon is loaded by App::LoadIdleIcon() through LoadImage and GetDIBits rather than through WIC — although WIC is already linked for screenshots. The resource compiler splits an .ico into an RT_GROUP_ICON plus one RT_ICON per size, so there is no .ico file in the binary for WIC to decode; LoadImage understands that split and picks the size asked for.

Two details on the way to a texture:

  • GetDIBits hands back BGRA, so the channels are swapped.
  • The result is premultiplied by alpha, because ImGui's blend state expects premultiplied and straight alpha draws a dark halo around every edge.
  • An icon with no alpha anywhere is an old-style one whose transparency lives in the mask. Rather than decode the mask, it is drawn opaque — plain rather than wrong.

The icon is scaled against the window (18 % of width or 30 % of height, whichever is smaller, capped at its native size and floored at 48 px), because 256 pixels in a 3840-wide window is a stamp in the middle of a field.

Clone this wiki locally