-
Notifications
You must be signed in to change notification settings - Fork 0
Signal detection
src/render/video_renderer.cpp (AnalyzeSignal), src/app.cpp
(HaveLiveSignal, LoadIdleIcon), src/ui/overlay.cpp (DrawIdleScreen)
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 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.
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:
-
spread —
max − minacross 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 |
48 | Two independent uniform samples average 85 apart; measured fast motion sits at 17 |
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.
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 48. 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.
Neither failure verdict is acted on the instant it appears.
| Verdict | Patience | |
|---|---|---|
| Snow | 1.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 |
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.
-
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 leavesUnknownand 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.
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:
-
GetDIBitshands 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.