Play Famicom games through a complete emulation of the console's RF output and a television's RF reception — with realistic simulated noising of the RF signal.
intro720tr1.mp4
^ minor noising gives a rich warmth
twt27.mp4
^ with heavier noising, produces wonderfully accurate glitch effects
Instead of operating in pixel space and using filters to approximate a television, this project generates the analog composite video the console would actually produce, modulates it onto a VHF carrier just as the Famicom's RF box did, optionally damages that radio signal, and then decodes it back with a demodulator written for real hardware.
What reaches the screen has been through sync separation, an AGC, a flywheel PLL and a chroma demodulator — the same path a television took in 1985.
The point of the exercise is also the impairments. Snow, ghosting, mains hum, carrier drift, clock jitter... these are applied to the radio signal itself, before the demodulator, which has no idea it is being lied to. The artifacts you see are what a real sync separator and colour decoder do when fed a damaged signal, not filters or pixel-space approximations.
The two building pieces are existing projects by GOROman:
- cluade-famicom-emu — the NES core, which includes cartridge-fault and connector modelling, some of which isn't exposed here yet
- famicom-rf-hackrf-decoder — the RF demodulation chain, written to decode an actual Famicom over VHF captured with a HackRF
The latter project above was what inspired this new work: when I realized he had implemented a faithful software RF demodulation, it seemed obvious that it would be a fantastic exercise to implement the other half -- an RF modulation that is faithful to the system's real hardware, and that can then be plugged together with the demodulator for a full end-to-en and realtime emulator+RF.
This repo needed to add a few components to become the missing link between his emulator and demodulator: an NTSC encoder using the console's real 12-phase colour model, an RF modulator, a channel impairment stage, a receiver IF model, and a native app to drive it all.
The big payoff is then to add noise and disruptions to the signal in RF space, which brings a wonderfully accurate output that simulates perfectly how real noise distortions looked when playing via RF.
Native Mac app only; why not a web ui?: it's only playable via a Mac native app now (could also be built for a linux target easily, I haven't done so or tested). The reason it wouldn't work on the web (despite GOROman's own web interface to his emulator) is that the RF modulation & demodulation is very resource intensive, particularly heavy on the CPU. Quite a lot of computation is happening per frame to achieve 100% speed -- I've only test on an M3 silicon Mac, and am not sure what speed you'll be able to obtain on other hardware.
No ROMs are included. Bring your own .nes file.
Every impairment attaches at a specific point in the chain:
NES palette indices
-> composite volts (12-phase colour) <- (1) phase noise
-> band-limit, resample
-> AM video carrier + FM aural carrier <- (2) detune (3) buzz
-> complex IQ
<- (4) ghost (5) hum (6) snow
-> int8 IQ ===== the demodulator's input =====
-> DC block -> mix -> IF filter <- (2) detune, receiver half
-> envelope detect -> AGC
-> sync PLL -> line painting <- (7) h-hold (8) v-hold
-> RGB frame
<- (9) tube geometry
-> screen
Six of the nine are in the signal path, upstream of the decoder. Two are receiver deflection — the signal is perfect, the television's oscillators are misadjusted, exactly like the hold knobs on the back of a real set. Only the ninth, tube curvature, is a screen-space effect.
Nothing that simulates a transmission or reception fault touches the finished framebuffer.
Every chart below is generated from the actual signal chain — encoder, modulator, channel, then the decoder's own front end — so each trace is literally what the demodulator receives.
One scanline, on a flat grey field. The sync pulse holds −40 IRE for 4.7 µs; blanking sits at 0; the colourburst is those ~8 cycles of 3.58 MHz on the back porch, which tells the receiver what "colour" means for this line; then active picture. Everything that follows is a way of wrecking this waveform.
In reality: jitter on the Famicom's 21.477 MHz master crystal — supply noise, an aging crystal, a marginal oscillator. Since NTSC hue is subcarrier phase, an unsteady clock becomes unsteady colour.
In the code: applied at the encoder, to the composite timebase —
p₀ = t·f_phase + σ·n_lp(t), noise low-passed at 90 kHz. The corner has to sit
above the 15.7 kHz line rate: the decoder re-measures burst phase every line,
so slower wander is simply tracked out and invisible.
Colourburst phase measured line by line. A clean clock holds within a few degrees; at full jitter the reference wanders ±50°, and since that burst is the decoder's colour reference, every hue on the line shifts with it.
In reality: the RF box's oscillator drifting as the console warms up, and the television's tuner drifting too. This is why you nudged the fine-tune dial ten minutes into a session.
In the code: ω_video = −2π(f_offset + Δ)/fs. There is a subtlety here — an
envelope detector computes |z|, which is blind to carrier phase, so a pure
frequency offset provably cannot change the picture. Real sets misbehave
because the carrier slides along a sloped IF response, so the receiver models
that: H(f) = 1 + t·(f/B) with the tilt proportional to detune.
The carriers move; the IF curve does not. Tuning up walks the aural carrier toward the video passband — beats, then dropout. Tuning down degrades gracefully. Asymmetric, like a real tuner. Its effects are largely temporal (breathing, drift, wobble), so they read in motion more than in a still.
In reality: an intercarrier receiver recovers audio as the 4.5 MHz beat between the video and sound carriers. Any incidental phase modulation of the video carrier therefore lands straight in the audio. Cheap modulators — like a Famicom's — do exactly this, which is why the buzz rises and falls with picture brightness.
In the code: φ_audio += … + k·IRE(t)/100, i.e. AM-to-PM crosstalk in the
shared output stage. The FM discriminator turns it back into sound. No
post-processing effect can produce this: it is an audio artifact caused by
video content, via a coupling that only exists in the RF domain.
In reality: the signal arrives twice, having taken two path lengths — a reflection off a building, a hillside, a water tower. 1 µs of delay ≈ 300 m of extra path, so the usable range here corresponds to reflectors roughly 30–225 m off axis. (This is broadcast multipath; a reflection inside 3 m of coax is ~20 ns and shows up as edge ringing, not a ghost.)
In the code: z[n] = (z[n] + g·z[n−d]·e^{jωd}) / (1+g), d = τ·fs. The
e^{jωd} keeps the echo phase-coherent — without it the echo nulls the
carrier instead of displacing the image, which is a real multipath null but
not what the control is for.
Every edge repeats, delayed. Because the echo exists in the radio signal, the decoder demodulates the sum: the ghost is soft, carries its own colour phase error, and smears sync when strong. A shader would blit a translated copy of the framebuffer; this cannot, because it is happening before the picture exists.
In reality: 50/60 Hz leaking into the signal path — a ground loop between console and television on different outlets, or a power supply whose smoothing capacitor has dried out. (A real Famicom in Tokyo would hum at 50 Hz; in Osaka, 60. Japan is split down the middle. This models 60.)
In the code: z[n] *= (1 + h·sin(2π·60·n/fs)) — amplitude modulation of the
carrier.
Because it modulates the carrier, it modulates the sync tips too, so the AGC and sync slicer wobble — you get the brightness bar and the instability that accompanies real hum. The bar creeps rather than sitting still, because 60 Hz beats against the 60.0988 Hz field rate at about 0.1 Hz. Nothing animates that; it falls out of the arithmetic.
In reality: Johnson–Nyquist noise in the tuner's first RF stage. It is always present; you see it when the wanted signal is too weak to swamp it. A corroded connector, long cheap coax, a four-way splitter. A Famicom RF box only puts out a few millivolts to begin with.
In the code: z[n] += σ(𝒩(0,1) + j𝒩(0,1)) — independent complex Gaussian
noise added to the carrier, before demodulation.
This is the clearest illustration of why the distinction matters. The dashed line is the sync slicer's −20 IRE threshold: the decoder calls anything below it "sync". At low noise the picture is grainy but the sync pulse is still unambiguous. Push further and noise excursions start crossing that threshold in the middle of active video, the PLL is fed garbage edges, and lock collapses into rolling snow. A shader adds grain to pixels; this makes the receiver fail.
In reality: a television's sweep oscillators were free-running LC circuits that incoming sync pulses merely nudged into step. Capacitor drift moved the free-run frequency until sync could no longer catch it — hence the hold potentiometers on the back panel.
In the code: these are receiver-side, and honestly so — the signal is
undamaged. h_shear offsets where along each line the decoder samples;
v_roll offsets which output row a decoded line lands on. Not post-effects
either: they change where the raster is painted as it is painted.
brew install cmake sdl2
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j8
./build/fami-rf # then hit Load ROM... under the picture
./build/fami-rf path/to/game.nes # or straight from the command lineThat is the whole dependency list — everything else (Dear ImGui, the native file dialog, the NES core, the decoder DSP) is vendored in this repo.
The default build links whatever SDL2 is installed, and on macOS Homebrew's
"sdl2" is really sdl2-compat — so the binary picks up an absolute path to
/opt/homebrew/opt/sdl2-compat/lib/libSDL2-2.0.0.dylib, which then dlopen()s
libSDL3.dylib. Two runtime dependencies that will not exist on anyone else's
machine.
For a binary that depends on nothing but macOS itself, with static linked SDL2:
cmake -B build-release -DCMAKE_BUILD_TYPE=Release \
-DFAMIRF_BUNDLED_SDL=ON \
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"
cmake --build build-release -j8Untested. Nothing in src/ is macOS-specific, but the file dialog is the Cocoa
backend of nativefiledialog-extended, so a Linux or Windows build needs the
matching NFD backend added to CMakeLists.txt.
Same mapping as the web build, so muscle memory carries over:
| Key | Button |
|---|---|
| X / Z (or S / A) | A / B |
| Shift | Select |
| Enter | Start |
| Arrows | D-pad |
| R | toggle CRT |
Double-click any slider to snap it back to its default — the bipolar ones (hold, detune, tint) can't be dragged to an exact zero by hand.
fami-rf ~/roms/zelda.nes # ROM straight away
fami-rf ~/roms/zelda.nes --preset ~/looks/weak-antenna.fmrf
fami-rf --rom ~/roms/zelda.nes --preset ~/looks/dying-tv.fmrf --set snow=0
fami-rf --helpROM.nes or --rom PATH |
cartridge to load |
--preset PATH |
a .fmrf preset saved from the RF panel |
--set KEY=VALUE |
override one knob; repeatable |
--shot SECS PATH |
render for SECS, write a PPM, print stats, exit |
--set is applied after --preset, so it wins — handy for taking a saved
look and turning one thing off. Keys: snow ghost ghostdelay hum detune phasenoise buzz hhold vhold curvature tint crt.
Battery saves are automatic. Cartridges with battery-backed SRAM get a
<rom>.sav written beside the ROM.
Presets save your config (every impairment setting; plus curvature, saturation,
tint and volume) to a .fmrf text config file via the Save/Load buttons next to Reset
all impairments.
- Snow gives out around 2.5; the top of the slider is past the cliff.
- Ghost delay is capped at 1.5 µs (see Known limits).
- H-hold is in screen widths per second of drift, on a logarithmic slider: slow creep and hard diagonal tearing are about three decades apart, so a linear control put everything usable in its first pixel of travel.
- Detune runs −200…+100 kHz, asymmetric because the failure mode is.
- Ghost delay defaults to 0.70 µs, which is ~2.5 subcarrier cycles — the echo lands half a cycle out and cancels chroma, so raising the level desaturates the picture as well as doubling edges. A real and rather nice artifact, and the reason that particular delay is the default.
- Tint defaults to +11°, not zero. That figure is calibrated, not taste:
it is the measured minimum RMS error against
NES_PALETTEacross the twelve saturated hues, compensating phase offsets accumulated through encode → modulate → demodulate. At 0° the picture runs noticeably magenta.
Measured on an M3 Pro at 10 MSPS, holding 1.000× realtime and 60.09 fps (the NES field rate) with every impairment engaged:
| Stage | Throughput | Thread |
|---|---|---|
| NES core | 12–14× realtime | producer |
| NTSC encode + RF modulate | 2.7–3.7× | producer |
| famidec DSP decode | 3.1× | DSP |
The producer paces itself to wall clock; the decoder consumes as fast as it can and then blocks, so the producer sets real time for the whole pipeline. Typical IQ buffer occupancy is ~0%, i.e. latency stays near the floor.
- not all mappers or ROMs supported; version of https://github.com/GOROman/cluade-famicom-emu used in current build supports: 0 (NROM), 1 (MMC1), 2 (UxROM), 3 (CNROM, including oversize 64 KB CHR), 4 (MMC3 with scanline IRQ)
- Ghost delay is capped at 1.5 µs. Longer echoes destabilise the decoder's AGC — its blanking reference window (8.2–9.2 µs after the sync edge) starts catching the echo of the colourburst. The signal path itself is fine at all delays (verified: full-width sync pulses out to 5 µs); this is a decoder robustness issue, not a modulator bug.
- Everything is NTSC-J / 60 Hz. PAL is not modelled.
./build/fami-rf ROM --set snow=2 --set ghost=0.4 # drive impairments
./build/fami-rf ROM --shot 5 out.ppm # render 5s, dump a frame, exit
FAMIRF_DUMP_COMPOSITE=c.f32 ./build/fami-rf ROM # post-AGC composite as f32
FAMIRF_NO_IF=1 ./build/fami-rf ROM # bypass the receiver IF model--shot also prints realtime ratio, decoded frame rate, line and coast counts,
and lock state, which makes regressions scriptable.
CMakeLists.txt
src/ the parts this project adds
ntsc_encoder.* NES palette indices -> composite (12-phase colour)
rf_chain.hpp AM/FM modulator + channel impairments
if_filter.hpp receiver IF, tilts with detune
emu_source.hpp/.cpp NES + encoder + modulator, as an ISampleSource
crt.hpp tube geometry
main.cpp SDL2 + ImGui app
docs/figures/ the charts above, generated from the real signal chain
vendor/ third-party, all vendored (no submodules)
nes_core/ cluade-famicom-emu core [patched]
famidec/ famicom-rf-hackrf-decoder DSP [patched]
imgui/ nfd/ unmodified
Two of the four dependencies carry local patches and so must be vendored; the
other two are vendored for consistency and to pin exact versions. See
PATCHES.md for exactly what was changed upstream.