videoskilletnotes Open the app ↗
On this page

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#

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:

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:

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.

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:

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 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:

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.

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:

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:

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#

Deflection#

Screen-domain effects#

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.)

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#

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.

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:

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":

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.

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#