Ideas / backlog
This page holds the things worth doing that are not done, and the things that look worth doing and are not, so a future pass does not re-litigate them. Where a shipped feature left a lesson the next person needs, it is here under that feature's gaps. The rest of the working-out is in the commits, so an entry here is cut once it is built or once it stops being worth the page. Grep the described feature rather than trusting a line number.
Modulation: the remaining naked periodic wave#
The premise (see ARCHITECTURE.md) is that a fault should be mechanistic, and
a single periodic wave traced down the raster reads as a filter effect rather
than a fault — the warning signal/audiostate.ts opens with. The shared home
for bounded-aperiodic drift is signal/noise.ts (valueNoise, Lorenz,
Wow); reuse it rather than rolling a new sine. Tape wow, the modulation LFOs
and intercarrier buzz are converted. One is left.
Mains-frequency roll drift (hum), channel.wgsl — deferred. The 60 Hz
fundamental is a clean sine and should stay one; it is mains, it really is that
periodic. The boring part is the fixed roll rate (the f32(P.frame) * 0.0037
term): real mains frequency wanders with grid load, so the beat against field
rate should breathe. Replace the constant with a slowly-drifting phase
accumulated CPU-side (the Engine.advanceScPhase pattern) driven by an
OU/valueNoise slow term, and optionally add a 120 Hz full-wave harmonic.
Deferred because it is the only one of these needing a new uniform and phase
plumbing (PARAM_DEFS, DEFAULT_CONTROLS, uniformValues) for the
least-visible win.
Not worth aperiodic-ising. These read like naked periodic waves and are
physically correct: the hum fundamental (mains is a clean sine — only its roll
rate is worth drifting), the wipe ping-pong (signal/mixstate.ts — a switcher
sweep is deliberately periodic), source-B detune and roll (a mistuned crystal
really does sit at a fixed wrong frequency), and the decode bend ripple
(decode.wgsl — spatial, not animated).
Tape mechanisms not modelled#
- Azimuth crosstalk from the adjacent track (EP/SLP). Narrow tracks plus azimuth suppression that only works at high frequency, so the neighbouring track bleeds through as a low-frequency-only ghost — a soft, colourless second picture that swims when tracking is off. Distinct from the multipath ghost, which is sharp and full-bandwidth.
- Crease / edge damage. A crease is a defect at a position on the tape, so
it recurs every time that stretch passes the head. The main deck has no
tape-position coordinate to hang one off, which is what has to exist first.
(The cut delay loop had one, in its ring; see
ecec59e.) - Luma FM beating the 629 kHz colour-under carrier. The fine crawling chroma noise in saturated reds. Modelling the luma FM properly is expensive; the honest cheap version is the beat product alone.
Noise mechanisms not modelled#
Two things the last pass here established, for whoever adds the next one. A
first difference is triangular (power ∝ f²) and a 1-2-1 signed pair is not
(∝ f⁴), so the honest FM shape is the cheaper kernel. And when two arms share
taps, holding the floor's level constant across a tilt needs the covariance, not
just the weights — without it the knob reads as a noise-amount control with a
side effect. The algebra is in noiseTiltWeights (pipeline.ts), CPU-side.
What is left, in rough payoff order:
- Camera sensor noise, in the feedback camera.
compose.wgslmodels an iris servo, a black cut and a full-well knee, and has no noise at all. Three mechanisms, all cheap, and this is the one that compounds: shot noise (σ ∝ √signal, so highlights are noisiest — the opposite weighting from tape grain, and the tell that separates a photographed screen from an electronic path); fixed-pattern noise, fixed to the sensor rather than to the glass, so each pass zooms and rotates the previous generation's pattern and adds its own, breeding grain into structure with nothing drawing it; and gain noise coupled to the camera's own auto-gain, so noise pumps against the iris hunt at a third rhythm alongside the beam limiter. - Flicker (1/f) and popcorn noise in the video amplifier. Everything
aperiodic in the chain is per-sample and everything slow is periodic (hum).
Missing: a random-walk level, so black level and brightness breathe sub-Hz,
and burst/RTS noise — a defective junction switching the DC between two
discrete states at random intervals, so the picture level clunks rather than
drifts. CPU-side out of
signal/noise.ts(an OU term, and a two-state Markov chain for the popcorn) into one DC uniform; the AGC, the clamp and the killer then react to it for free. - A wandering spurious carrier (switching-supply birdie). Every periodic
interference here is locked to line rate (
soundIre,rfAdjacent) or to mains (humAmp). A switch-mode supply or a nearby computer sits at some arbitrary 15–60 kHz that drifts with load, so it draws a herringbone that creeps and breathes instead of standing still — the drift is what identifies it — and it intermodulates with the subcarrier. Same CPU-accumulated-phase pattern as the hum-drift item above. - Noise on decisions rather than on picture. The dropout detector is the
good one: a real DOC fires on an RF envelope dip, so a noisy floor trips it on
lines that were fine and it patches them anyway — and the patch comes back in
the complementary hue, by the 227.5-cycle logic
dropoutCompalready has. A corrective box misfiring on noise is more interesting than noise you can see. The sync slicer and the colour killer are the same idea, and the killer's is partly reachable already throughaccLagLines. - A fixed noise floor with a varying signal, instead of substituted snow.
Structural rather than a knob:
channel.wgslmixes snow in at a set level per band (tracking, head clog, shuttle, head switch). If the preamp's floor were fixed and the RF level varied, noise would appear wherever signal is weak from any one mechanism, and the four blocks would collapse into it. The honest version, and it would delete code; also the largest of these.
Per-input feeds — what is still on the program bus#
The feeds (feed.wgsl, the FEEDS table in feedgates.ts) give each input its
own deck, head-end and cable. The loose connector and the ground loop shipped
per input, which is what the split is for: a fault on one feed makes the two
signals disagree, and the sync fight, the AGC and the other input are all
downstream of the disagreement.
Everything below still damages the mixed bus, which for several of them is
physically incoherent once two decks are patched in — the fault belongs to one
machine. Adding one is a FEEDS entry, a packFeed override, a shader block
and a feedFaults line; see ARCHITECTURE.md for the trap in the middle of
that. Rough payoff order:
- Transport (shuttle / rewind / still), per input. The biggest one.
shuttleXsits on the summed bus (channel.wgsl), but shuttle bars are one deck's head crossing tracks. Per input it gives B rewinding under a playing A, with B's bars sweeping B's raster and rolling with B's picture through the dirty sum, each strip between bars a different recorded track with its own timing and colour-under phase. The strips that lose sync hand the fight to A and the ones that don't fight back, so the picture flickers between two geometries at bar rate. Most of the machinery is there:feed.wgsl's pause path computes a per-row offset andcatmull-resamples, and shuttle is that path with a per-strip offset instead of a random scatter.decode's row-uniform constraint does not bind — a feed is 1-D on the composite. It also makesaPause/bPausethe zero of a transport continuum rather than a separate button. - Head clog, per input. Cheapest violent effect left, ~6 lines keyed on
P.frame. The heads alternate sweeps, so a clogged head on one input makes the receiver alternate which source it locks to at field rate. - Multipath ghost, per input. One input off-air, one on a line. Under the
dirty sum the ghost is a third sync edge arriving late, so the PLL has three
candidates per line. Same shape as
terminate's echo tap. - Tracking error, per input. A band parked on one deck that then rides that source's roll. Cheap; less novel than the three above.
- Macrovision is A-only.
mvAgcIre/mvStripelive inencode_composite.wgslandencode_composite_b.wgslhas no equivalent, so B can never carry a protected tape. Narrow, but a real asymmetry, and a protected B summed against a clean A makes the receiver'sagcpump against a signal whose sync is fine.
The teletype card's wire#
The card can be received badly (sources/teletype.ts › garbleRows): holes
where parity caught a bit, wrong characters where it didn't, blocks for the rest
of a row whose control code took the hit, and the odd line delivered to the
wrong address. What is left is a dial, two attributes, and the other way a
character generator goes wrong.
-
Garble as a strength. The rate is one constant picked by eye.
#garble=0.8would carry a strength without breaking the flag —q.hasis true whatever the value — but the dialog would grow its first slider where every other thing a card carries is a checkbox. Worth doing when someone reaches for it. -
The two control codes the card has no attribute for. A hit on a colour code turned the rest of a row red; a hit on double height doubled a row and ate the line under it. Both are famous garbles and neither is reachable here: this card is one bit deep and white on black, and rendering them means carrying attributes per row through
dotGrid. Double height is the cheaper and the one you saw more often. -
Bending the card's own ROM. Shipped for both character generators (
romAddr/romData/pageAddrinprelude.ts, called fromdecode.wgslandchyron.wgsl) and not for the card, because the caption's font is a ROM in a buffer where the card's is a canvas raster — two lines there and a rebuild ofdotGridhere. What the shipped ones taught: holding a pin is a range of effects rather than one, and the range comes out of the wiring. The address bus carries the character code in its high lines and the row inside the cell in its low ones, so one knob sweeps from every glyph growing a seam to the whole font substituting. And the pin is held high rather than switched, so a glyph whose bit was already set is untouched and the damage is uneven the way a jumper's is.Five faults live on that wiring now and each answers a different question the bus is being asked.
romStrideis the cell-height strap, so the address walks out of the cell the raster is drawing and a line of text shears diagonally through the whole font.romCrosstransposes two adjacent address lines, the way a socket seated a pin over does.romAddrholds one high.romRotdecays a fraction of the array to its erased state, which is a fixed pattern in the die.romDataholds a data line. The page-address counter is bent separately byccPageAddr/cgPageAddr, and that one is the odd member: every character stays correctly spelled and lands somewhere else.All five are what separates a bend from
garbleRows, which models a bad transmission — random hits on bytes in flight. A bend is deterministic: the same text comes out wrong the same way every time, because the machine is wrong rather than the wire.The counters are the exception, and they had to be a sixth thing rather than a rate on the other five.
romSlipandpageSlipare a counter losing or gaining counts instead of holding them, so the address is further out every frame and nothing puts it back. That is the whole of what moves here. Putting a clock on a held pin would have said something false — a jumper does not flicker — where a counter that will not hold its count is dynamic by construction, and it is what the page bend was described as in the first place: it walks the entire page diagonally through itself, a few cells a field.The part all of it is wired around is a 2 KiB EPROM on eleven address lines holding a 1152-byte font, so 896 bytes of it were never programmed.
romAddrmasks to the bus and the callers answer anything above the font fromROM_ERASED, which is why a high line held or a large strap error returns solid characters rather than wrapping onto a glyph that happens to be there. -
A drawing that moves on its own.
drawpaints on the card and the card then sits still, soboilis the only thing keeping it alive. Two shapes worth trying: interpolating between two saved drawings, which gives a hand- drawn tween the chain then damages; and a wigglypaint-style stroke that jitters along its own path, which isboilapplied per stroke rather than per cell.
Looks to leave alone#
A taste note rather than a mechanism: the plain green phosphor and the plain chroma-key green both read badly on their own — a flat saturated green field is the one colour in this palette that looks like a filter rather than a fault. Both are worth having as ingredients under something else and neither is worth an authored preset that is about it.
The caption channel#
Line 21 carries real characters (signal/captionstate.ts feeds them),
caption.wgsl is the set's decoder, and decode paints the page it recovers.
Three things about it are load-bearing enough to state before anyone touches it.
The cell grid is fitted to the active window, not to 503 kHz. Real line 21
clocks at the true rate and spills into the blanking either side to fit its
twenty-eight cells — which here would write over the burst that this very line's
hue lock is measured from. Fitted to ACTIVE_W the clock is 532 kHz, six
percent fast, and nothing downstream measures it. CC_CELLS is what both ends
index off, and that is the only thing that has to agree.
The run-in cannot be read at cell centres. It is a sine at the cell rate, so every one of its centres sits exactly on the midpoint the slicer is trying to measure around — sampled there, a perfect signal comes back flat and the threshold never arms. It is scanned sample by sample instead. This cost a build.
Painting goes before crt_face and is indexed by screen position. Before
the face pass because a caption is light off the same glass; after it, it would
be a sticker on a photograph of a screen. By screen position because that is the
physical claim — a decoder holds bytes and repaints on the set's own timing.
(The font ROM and the page RAM share one binding, captionrom.ts, because
decode was already carrying seven storage buffers and eight is the floor
WebGPU guarantees. They are one memory on the chip being modelled anyway.)
What it does not do:
- Roll-up is the only mode. Pop-on and paint-on are control-code state machines over the same page rather than new mechanism.
- No squelch. A real decoder muted its display after a run of parity failures; this one paints every block it catches, so heavy snow fills a row with them. Dramatic, and not quite what the box did.
- Attributes are ignored. Line 21 carries colour, italics and the PAC codes that position a caption; this page is white, upright and where it was put. Same shape of gap as the teletype card's two attributes, and one fix serves both.
- CC1 only. The second caption channel lives on field 2, so it needs interlace before it means anything.
The character generator as a keyer#
chyron.wgsl stands where the box stood: after the mixer, ahead of the loop and
the deck, so what it keys in ages with the picture instead of being laid over a
finished frame. It says what the caption says, and that is not a shortcut — an
open caption and a closed one are the same sentence down two paths, and running
both is what makes the difference legible: this one is picture, so it is torn
and smeared and rainbowed and never misspelled; line 21 is data, so it is
spelled wrong and never moves.
Two things it taught. The fill has to be video, or the timing trim does nothing — the first cut keyed a flat IRE level through the glyph matte, and with a constant fill, delaying the key only translates the type. A real CG puts out the characters as video on one wire and their matte on the other, so where the key is open and the fill has not arrived the box hands over its own black, and where the fill is lit and the key has closed the program shows straight through the letter. That artifact exists only because the two wires carry the same shapes separately. And the edge generator is OR-ed into the key, not drawn: widening the matte to the shadow's shape puts the fill's own black out there for free, which is how one extra tap bought a border.
What it does not do:
- The fill is the box's own characters and nothing else.
keyFill's trick on the chroma keyer — program A, a matte generator, or the mixer loop bus — would make an inverted key a window onto the feedback bus rather than onto program, which is the one obviously good thing left here. - Monochrome. A CG with a colour matte generator is
bKeyMatte*pointed at this instead, plus the same attribute work the teletype card wants.
The generator has its own font ROM and its own set of pins to hold (cgRomAddr,
cgRomData, cgRomCross, cgRomStride, cgRomRot, cgPageAddr) and its own
two counters to slip (cgRomSlip and cgRomLineSlip on the font address,
cgPageSlip on the page), separate from the caption decoder's in the set. They
share the baked ROM bytes and nothing else, so bending one says nothing about
the other. The wiring itself is shared — romAddr, romData, pageAddr and
counterSlip in prelude.ts — because the part is the same part, and each box
calls them with its own knobs.
The one thing that is not shared is the decay pattern. romData takes a die
seed (ROM_DIE_CC, ROM_DIE_CG), so one rot setting damages a letter
differently in each box. Seeding it on the address alone had both chips decaying
in identical places, which is the invariant this whole split exists to keep.
Chroma key follow-ons#
The keyer slices uvfB — B's chroma after the encoder's bandlimit — so the
soft-across/sharp-down composite edge and the per-line breathing on the dirty
path are the filter and the detune doing it, not anything drawn. Two things it
taught.
The keyer had to read B's chroma at B's own raster index on the dirty path, the same index the fill is resampled from. Keying at the output sample instead parks the hole on the output raster and the subject rolls out from under it — the three-domain mistake in one line.
And spill suppression cannot be a colour operation here: luma and chroma are the same wire, so the only honest null is reinjecting the backing's subcarrier antiphase, which means the suppressor has to know B's carrier phase. It does, exactly, on the genlocked path; on the dirty path it is always late by however far the fractional slip has rotated the carrier between samples, which leaves a residue that breathes. That asymmetry is the mechanism, not a gap to close. The same shape of limit governs the fill selector: a fill is only meaningful on the genlocked path, because a fill is what sits behind the foreground and only a crossfade has a behind. The row is gated on genlock.
What is left:
- The PiP inset keeps its luma key alone. Wiring the chroma key into the
inset is two lines, since
chromaKeyalready takes an index and the inset re-encodes fromyuvB/uvfB; left out to keep the first pass one box. - Nothing keys off A. A self-key on the program bus (A's own backing cut so
the loop bus shows through) is the same function pointed at the other input,
and would need A's chroma materialized the way
uvfBmaterializes B's. - Keyer bandwidth is the encoder's. A real keyer has its own key-processing
filter ahead of the slicer, usually narrower than the encoder's chroma. A
short boxcar over
uvfBwould make edge softness a control of its own rather than a side effect ofencChromaMHz— at four more storage taps per active sample, which is why it is not there.
Video synth follow-ons#
Phase is carried as cycles at frame start plus the walk per line and per sample
rather than as a frequency, both for f32 precision across a 477750-sample frame
and because the per-line walk is the lean of the pattern. Two later
findings: the FM term has to multiply the sample index, not the phase — pulling
a frequency makes the wave genuinely run faster through bright picture, where
offsetting a phase only slides the pattern about and never produces a contour.
And the synth-over-picture patch is slot A only, because compose has the
slot's picture in hand while compose_b writes its texture rather than reading
one. Left as an asymmetry rather than plumbed around.
The colorizer is the only reliable way to get colour into large fields,
because it maps level to hue, and level varies over hundreds of lines where an
encoder puts colour on detail. Everything measured against it coloured detail
instead: phosphor scatter (17.5 fringe — it spreads only the light the layer
already holds and leaves the fresh edge sharp by design), a magnetised purity
patch (17.9), collapsed demod axes (20.2), colour-under smear (35.1, and worse
than doing nothing, since its per-line jitter is speckle). Note the limit of
that measurement: colourcheck's fringe column reads edge contrast, not
edge count, so a posterizer holding four enormous hard-edged fields scores like
speckle. Low fringe proves flatness; high fringe does not prove fringing.
- One waveform selector serves both oscillators. Hardware would have one per VCO; a ramp beating against a pulse is a patch this cannot express.
- No ramp reset off drive. Real ramp generators are reset by H and V drive,
which is why they hold still; here a "ramp" is an oscillator that happens to
be at drive rate, so it is only ever as steady as the number typed in. Exact
is reachable (
synthAHz= 15734 lands within a hertz), but a genuine drive-locked mode would give a gradient that cannot creep at all.
The mixer has no hardware model#
mix_b.wgsl combines the two inputs with arithmetic —
aGain * a + gate * (bGain * b + ...). Three real mechanisms are missing, all
cheap:
- Crosspoint crosstalk. A cheap switcher leaks the unselected input at about
−40 dB, and the leak path is stray capacitance, so it is high-pass: what
gets through is B's subcarrier and edges, never B's flat areas. With the fader
fully closed you still get a faint moving rainbow from B's detuned carrier
beating the burst-locked decoder, and no visible picture — "there's something
else on this wire", which is not drawable. It interacts with the gates: a
non-zero crosstalk floor has to appear in
bWaveOn/bOnor B's chain is switched off underneath it. - Genlock that can lose lock.
bGenlockis an absolute TBC today. Real genlock has a capture range: push B's pause wander or wow past it and lock drops, B rips for a few lines, and it re-hunts. That makes the corrective box's failure a function of how hard B is driven. - Mid-field cut. A switcher cuts at the vertical interval; a cheap A/B box
or a relay cuts wherever you pressed it, tearing one frame into two
half-pictures with a broken field sequence. Cheap in
mix_b(a cut position in raster time rather than a crossfade), and it is the natural performance gesture.
Considered and left: a house-reference selector (letting B be the raster instead of A) would double the expressive range of all of the above, but B is the second raster — it is a restructure, not a knob.
The loop bus into the B input, sized but not built#
The one item from the loop-hardware pass that was started and put down. B's
dirty path resamples a signal, so patching the mixer's own loop bus in there
makes the machine's past arrive non-genlocked: bLineHz becomes a shear
that compounds a lap, bRollLps a drift per lap, and bDetuneHz a continuous
hue rate where cfbDelayUs is a fixed rotation. Every feed-B fault then lands
on the return — pause scatter, ground loop, dropouts, an SSAVI negative — and
the return's own sync tips fight A's, so the receiver locks to the machine's
past for bands of lines.
The shader half is nearly free: mix_b already binds loopBus for the keyer's
fill, so the dirty-path resample is a choice of which buffer it reads. What
stops it being cheap is everything around that:
- It wants to be a source-B mode, not a knob. The gates (
bOn,bWaveOn,bFeedOn) all requirebEnabled, so as a control it would need a B source picked in order to patch the loop in instead of a source. As a mode it isSOURCE_B_MODES,SOURCE_DESC,SOURCE_KIND, the picker, and the docgen source list. - The B encoders have to stay off in that mode, a fourth condition in gates
whose containment (
bFeedOn ⊆ bWaveOn ⊆ bOn) is under test. - The chroma keyer reads
uvfB, which does not exist for the loop bus. Either the keyer is gated off in the mode or the loop's chroma is materialized, and the first is the honest cheap answer. SlotSourcehas to carry it or a device loss drops the patch, per the three-setters rule inARCHITECTURE.md.
None of that is hard; it is a different size from the rest of that pass, and it crosses the source layer, which none of the others did.
Capture / deinterlace#
- Motion-adaptive deinterlace. Current
deintis an unconditional even-field bob — it halves vertical resolution even on still frames. Weave where fields match and bob only where they differ (a per-pixel inter-field delta metric) keeps sharpness off motion. - Deint modes instead of on/off. off / bob (current) / blend (average both fields — ghosts on motion, keeps res) / weave. Blend is cheaper and some people prefer its look.
- Auto-detect interlacing. Measure a comb metric on the incoming source and
flip
deinton only for genuinely-interlaced feeds, instead of hard-enabling it on every webcam/USB connect. - Remember the last capture device. Persist the chosen
deviceIdso a reconnect re-selects the dongle rather than the OS default camera. - PAL capture. Composite grabbers also deliver 720×576/50i; the pipeline is NTSC-shaped (525/60). At minimum square-pixel it correctly; ideally note the standard mismatch in the UI.
Deflection#
- Intra-line geometry.
hSize,hLin(S-correction failure stretching one side), pincushion. Blocked on decode's tiling: the workgroup stages one contiguous span per row, so only row-uniform horizontal offsets are free. - Vertical linearity.
vSizeshipped and was nearly free (the raster row remap is a function of the screen row alone, so decode's row-uniform constraint never bites).vLin— the top-of-frame stretch of a failing vertical output stage — is a quadratic term in the same remap. - Fractional bend.
hoffisround()ed to whole samples; at large amplitudes adjacent rows stair-step. Resampling the tile withcatmullwould smooth it, at the cost of restructuring the staging.
Screen-domain effects#
- Per-channel bloom radius. One radius serves all three channels; the
phosphors don't actually scatter alike. Note that
crtHaloKeykeys the halo radius off the destination pixel's own drive, because a gather has to pick its radius before it samples. That widens how far a bright area reaches in, which is the visible half; genuinely widening how far a highlight throws light out needs a second, higher-threshold ring rather than a keyed radius.
Two things the last screen pass taught, for whoever adds the next fault.
Convergence has to re-run the whole beam-spot integral per channel — blurring
one shared sample averages the landing error away instead of leaving a fringe —
so it costs 3× the spot taps whenever it is non-zero, behind a uniform branch.
And every new mechanism has to be added to the identity-copy early-out at the
top of crt_face's main, or turning it on by itself reads as a dead control.
Boxes in the rack#
In rough payoff-per-effort order. (A preset worth authoring off the shipped
diffPhaseDeg: inside the mixer loop, differential phase separates a feedback
trail into colour layers by brightness, because cfbDelay's rotation per
generation stops being uniform.)
- A DVE / framestore, as the digital box in the analog last mile. Distinct
from the digital cable tier below, and more era-correct. An ADO / A53 /
WJ-MX50 cannot work on composite, so it decodes to 4:2:2 601 on a 720×486,
13.5 MHz raster — a different raster from ours — and re-encodes. The payoff is
cascaded encode/decode generations: whatever the decoder got wrong becomes
real picture, so dot crawl bakes into luma, re-encodes as chroma, crawls
again, and
combModeselects which fixed point the iteration falls into. That is why multi-generation composite editing looked the way it did, and it is the one mechanism here that manufactures colour from nothing. Once the framestore exists the consumer digital-effects buttons follow as one mechanism each — mosaic and multi-image are decimation with no prefilter, so the tiles alias and the subsample pattern beats against the mask. - Frame-recursive noise reducer. A corrective box whose failure mode is the effect, which is why it is more interesting than the TBC declined below. Frame averaging gated on a motion threshold: below it, noise freezes into fixed plateaus and the picture goes plasticky; above it, motion drags a soft trail with a hard edge where the gate trips. Put the threshold in the noise floor and the grain drives the detector, so still areas breathe.
- Rutt/Etra scan deflection. The source's own luma patched into the vertical
deflection amplifier: the raster becomes a relief map of the picture, and the
brightness comes free from line bunching (line density is luminance). Fits
the deflection domain exactly — geometry detonates while hue stays put. The
catch is that it is a per-pixel vertical gather, so it wants
crt_faceover the decoded image with a bounded column search, notdecode. - Setup mismatch — a 0 IRE deck into a 7.5 IRE set and back, for crushed or milky blacks. The last of the smaller trims.
Considered and not worth it: PAL / Hanover bars (a raster change, not an
effect — constants.ts is 525/60 throughout) and standards-converter
judder, which needs 50 Hz first.
Interlace#
ARCHITECTURE.md calls progressive 525/60 "the largest remaining authenticity
gap". It is a raster restructure rather than a knob: fields at 262.5 lines with
the half-line offset, and everything indexed by row has to learn which field it
is in.
What it pays for. Vertical roll steps a whole frame at a time today because a
frame is the only unit there is; at field rate it would creep the way a real one
does. Head switch would land where it actually lands. The 2- and 3-line combs
would see the line relationships they were designed around instead of the
progressive stand-in. And it changes what dropoutComp looks like: a real
compensator's 1H delay operates within a field, so the line it patches from is
two raster lines up on the glass rather than one. The complementary hue is the
same either way — 227.5 cycles does not care — but the patch would visibly come
from further away, which on fine horizontal detail is a different artifact.
Instruments and pixel checks#
- A waveform monitor, overlaid. One line of it landed as the scope tap
(
#dbg=6): a single line traced against an IRE graticule insidedecode, columns filled min..max so an edge connects and a modulated sample draws its envelope. The real instrument is every line of the field overlaid at once, where the density of the trace is how many lines agree — a chroma error on eight lines out of 480 is invisible on one line and obvious on all of them. That one is a pass:decodewould scatter into a bins buffer andpresentdraw it, with a finite spot on the way out, or a flat field lands every sample in one bin and draws as a speck. - A line selector for the scope. It traces the middle line because that is where the cursor is parked; the interesting lines are the head-switch line, a line inside the VBI, the line a dropout is on. Wants a control and a draggable cursor.
- Extend pixelcheck.
scripts/pixelcheck.mjspins the six SMPTE hues and the fine-tuning cliff; any deterministic#set=look plus a probe is one more pinned fact. Candidates: burst-lock hue rotation, the killer threshold, scramble's wash-out level. - Read VITS back as the app's own frequency response. Lines 17 and 18 are
already stamped with the real instruments — multiburst stepping 0.5 to 4.2
MHz, and the modulated staircase differential gain and phase were measured
off. They are then eaten by the chain like everything else, so demodulating
them at the receiver end and reading the packet levels back answers what the
whole path is doing to frequency, and to chroma amplitude and phase against
luma level. That is not an approximation of the broadcaster's number, it is
the same measurement on the same signal. Two things fall out: an instrument
worth drawing (a response curve beside the waveform monitor), and a rail — a
#set=look plus a response is a pinned fact about the chain that no pixel probe reaches, because a filter regression moves the curve long before it moves a hue.vir.wgslis the worked example of the gate-and-demodulate half, andbuzzBufof getting a per-frame measurement back to the CPU cheaply. - Count the caption channel's errors. A wrong word is countable in a way a
wrong pixel is not — feed a known string, read the page
caption.wgslrecovered, and the character error rate is one scalar per look. That makes a regression rail out of a thing already built, with no tolerance to tune and no screenshot to eyeball. It also fails in the right direction: the slicer sits at the far end of sync, timing and the whole channel block, so a regression anywhere upstream shows up as a misspelling, and the number says how bad rather than only that something moved.
Digital cable tier#
Macroblocking, DCT ringing, frozen last-good-blocks, motion-vector smear. Large — it is a codec, not a knob — and it does not compose with the composite chain, so it is only interesting under one framing: a digital head-end feeding an analog last mile. Box → impairment → NTSC encode → the entire existing chain, which is era-correct for the late nineties and is genuinely mechanism modelling. Not worth starting until something needs it.
Patching into other apps (Max/MSP, Jitter, TouchDesigner, VJ software)#
Already works with no code: MIDI CC and MIDI clock in (src/ui/midi.ts) via a
virtual port; audio in by sharing the tab or app it plays out of
(AudioState.enableSystem) or via a loopback device, either of which reaches
audioBendUs / audioLoad / audioIre; Jitter output in as a webcam through a
Syphon→virtual-camera bridge; and output back out by pointing an OBS browser
source at the page. The gaps below are what would make it feel like a patchable
module rather than a coincidence.
- OSC control, via a local WebSocket bridge. Browsers can't speak UDP, so
this needs a small node process doing OSC↔WebSocket. Worth it because
DEFAULT_CONTROLSis already a flat named record anduseMidialready funnels every store-origin change through onewriteControl(key, value): a bridge lets Max address/hHold,/scDetuneKHz,/bendUsby name, with float precision and no 128-control CC ceiling. The app side is a thin client that validates the key againstControlKeyand calls the existing write path. - Bidirectional state. The same channel in reverse, so a Max patch's UI tracks the app and presets can be recalled from outside. Needs a loop guard on the write path.
- A saved look on a pad. The note-binding family shipped (
ActionTargetinui/midi.ts), so the wire exists — what it carries is the thirteen gestures that need nothing but a velocity. A saved look is a different shape: its name comes from a list that changes under the binding, which is the problempreset:already solves for knobs by binding the name and dropping the entry when the name goes.savedProfiles.tswould need the same treatment. Program change is the other half — one message per look — and would want its own family again, since a PC carries a number rather than a velocity. - MIDI transport, not just clock.
midi.tshandles0xF8/0xFC; honouring0xFAstart /0xFBcontinue would let clock-locked rates reset phase on downbeat instead of free-running from whenever the tick stream began. - Live low-latency output. WebRTC to a local peer, or NDI via a native helper, for feeding the result back into Jitter without the OBS round-trip. Meaningfully more work than the rest of this list; only worth it for performance use.
Hosting the app inside a patch (Max's jweb) waits on that web view exposing
WebGPU. Until then it is a separate app you route into.
The modulation bay#
What was deliberately left:
- Performance macros — cut, not deferred by accident. The design was three
assignable 0..1 knobs routed through the same eight slots as the LFOs, which
makes the good case the expensive one: a macro is only worth a knob once it
drives several controls at once, which is exactly when it eats the most slots,
at four clicks and one slot per control. The motion amount does the
one-gesture-scales-the-patch job with no assignment ritual, and the MIDI
binding key now reaches beyond
ControlKey, so a knob can drive the motion amount or a preset weight. If macros come back they need their own routing table, not a berth in the LFO bay. - Modulating the five filter controls (
encChromaMHz,demodMHz,chromaTail,lumaMHz,lumaPeak) rebuilds the FIR bank every frame. Allowed from the UI deliberately — it is a real patch someone may want — but authored presets are forbidden it bypresets.test.ts. If it ever needs to be cheap, the bank would have to be rebuilt only when the modulated value crosses a meaningful step. #surpriseon boot stays controls-only. A rolled recipe applies its motion in the app, but the boot path layers controls before the bay exists. Accepted asymmetry.
For whoever extends the envelope family: firing is an event, so it goes to the
engine as a method rather than a flag on ModSlot, and a press is held in a set
until a frame picks it up, because sampling an edge at 60 Hz drops presses. An
unbound note fires the whole bay (noteAction in ui/midi.ts) and binding one
pad lifts the blanket.
The stab gate#
Two gaps, both surfaced by pulling on "the stabs slider does not work":
- It does not travel with the look. The gate lives in
localStorageand nowhere else: not in#mod=, not in a preset's routings, not in a saved look. A link or a preset therefore drops the most visible thing the bay does. What is owed is the schema change to#mod=and the preset routings, with readers that tolerate its absence the wayreadStabalready does. A held look is a whole second board, so store it as a diff against stock the way the strip does, rather than as a preset name, since the look you hold is usually one you dialed. - No knob can reach it. The row passes
syncbut nomidi, so the kill switch a bender keeps a thumb on is mouse-only while the motion fader beside it is aBindTarget. It wants a'stab'target beside'motion'inui/midi.tsand a sink inapp.tsx. Inserting it intoAUTOMAP_TARGETSshifts every knob for anyone who re-runs the auto-map, which is the open question.
A hard flip between two looks is the affordable gesture and a crossfade is not,
since the filter bank is redesigned whenever a filter control moves. That is
also why this is the gate's job and not a mod slot's: a routing drives one
ControlKey, and two looks is every key at once.
Clip cues#
ui/cue.ts marks a cue on a clip's own timeline and loops a stretch of it, with
a second read head (armHead/promoteHead in ui/videoSlot.ts) so the wrap
does not seek. Three things around it are deliberately not done.
- A cue row in the Deck. Every row the Deck renders is backed by a control
read through
ControlsContext, and a cue is deliberately not a control: two timestamps into one clip cannot be recalled by a preset or moved by mutate. The Deck would need a way to take per-source state, which is a new pattern. The command palette carries the two verbs meanwhile. - Beat-snapped loops.
useTempoalready has a beat, and ½/1/2/4-bar buttons off the cue would give exact musical loops. It doubles the row and is inert on a machine with no tempo set, which is most of them. - A de-click envelope on the wrap. What is left of the dropout is one frame, a click rather than a hole, and fading the gain across the join is the standard fix.
The measurement behind the head is scripts/wrapsound.mjs: the silence on a
wrap is the seek plus about one frame, and a working head removes it entirely.
The commit that added the head carries the numbers and what they taught.
Intercarrier buzz off the main thread#
signal/buzz.ts's detect runs on the main thread inside the mapAsync
callback, at about 25 µs a frame with the slider up and nothing at all at zero.
The device and the mapped buffer cannot leave the main thread, so what could
move is the arithmetic, either into the audio worklet or into a buzz_detect
pass beside sync.wgsl. Neither is worth doing until someone measures
mapAsync's own main-thread cost, which is the half that stays put either way.
Not worth building#
- Cochannel interference. Already reachable: source B's dirty-sum path is a
second non-genlocked composite beating against A, with its own line and
subcarrier detune. That is cochannel. (Adjacent-channel is not — that
shipped as
rfAdjacent, and is carrier beats rather than a second picture.) - A TBC. A corrective box that removes
tbJitter/tbWow. Inverse-effect controls are interesting for performance but nobody has wanted one. - An After Effects / Premiere / OpenFX plugin, as a port of the shaders. The
shaders are a third of the simulator and the feedback loops need sequential
render. A sequential-render OFX effect on a native wgpu build is the shape
that could work;
EDITOR.mdhas the working notes and the fixed-framerate export is what serves an edit today. - A camera loop aimed off-centre. It dims away even at a round trip of 1.1 with the zoom under 1, because the off-axis shift carries most of the picture out of frame each lap and the gain has nothing to compound. Two tunings tried; neither held a picture.