> ## Documentation Index
> Fetch the complete documentation index at: /llms.txt
> Use this file to discover all available pages before exploring further.

# Oscilloscope Trace

> CRT oscilloscope beam sweeping a waveform across a 10x8 graticule, with closed-form phosphor persistence: the tail decays as exp(-age/tau) and brightens where the beam slows

# Oscilloscope Trace

CRT oscilloscope beam sweeping a waveform across a 10x8 graticule, with closed-form phosphor persistence: the tail decays as exp(-age/tau) and brightens where the beam slows

**Oscilloscope Trace**

```html oscilloscope-trace.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>Oscilloscope Trace</title>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
    <style>
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: #05070a;
      }
      #os-root {
        position: relative;
        width: 1920px;
        height: 1080px;
        font-family: "JetBrains Mono", ui-monospace, monospace;
      }
      #os-bg {
        position: absolute;
        inset: 0;
        background: radial-gradient(120% 120% at 30% 25%, #0d141b 0%, #05070a 68%);
      }
      #os-screen {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
        display: block;
      }
      /* Purely decorative CRT falloff — no timing, no motion. */
      #os-vignette {
        position: absolute;
        inset: 0;
        pointer-events: none;
        background: radial-gradient(
          78% 78% at 34% 50%,
          rgba(0, 0, 0, 0) 55%,
          rgba(0, 0, 0, 0.55) 100%
        );
      }
      #os-readout {
        position: absolute;
        left: 1250px;
        top: 150px;
        width: 520px;
        color: #6f8794;
        font-size: 26px;
        line-height: 1.15;
        letter-spacing: 0.04em;
      }
      #os-readout .os-title {
        color: #b9ccd6;
        font-size: 34px;
        letter-spacing: 0.22em;
        padding-bottom: 26px;
        border-bottom: 2px solid #1d2a33;
        margin-bottom: 26px;
      }
      #os-readout .os-row {
        display: flex;
        justify-content: space-between;
        padding: 13px 0;
      }
      #os-readout .os-val {
        color: #d7e6ee;
      }
      #os-readout .os-note {
        margin-top: 28px;
        font-size: 20px;
        color: #6a7b85;
        line-height: 1.5;
      }
    </style>
  </head>
  <body>
    <div
      id="os-root"
      data-composition-variables='[
    {"id":"waveform","type":"enum","label":"Waveform","default":"sine","options":[{"value":"sine","label":"Sine"},{"value":"square","label":"Square"},{"value":"triangle","label":"Triangle"},{"value":"data","label":"Data series"}]},
    {"id":"frequency","type":"number","label":"Signal frequency","unit":"Hz","default":12,"min":0.1,"max":200,"step":0.1},
    {"id":"amplitude","type":"number","label":"Amplitude","unit":"div","default":3,"min":0.1,"max":4,"step":0.1},
    {"id":"persistenceMs","type":"number","label":"Phosphor persistence (tau)","unit":"ms","default":100,"min":1,"max":1000,"step":1},
    {"id":"phosphorColor","type":"color","label":"Phosphor colour","default":"#5dff8f"},
    {"id":"sweepRate","type":"number","label":"Sweep rate","unit":"sweeps/s","default":5,"min":0.25,"max":60,"step":0.25},
    {"id":"dataSeries","type":"string","label":"Data series (waveform=data)","default":"","placeholder":"0,0.4,0.9,0.3,-0.6,-1,-0.2,0.5"}
  ]'
      data-composition-id="oscilloscope-trace"
      data-start="0"
      data-duration="6"
      data-width="1920"
      data-height="1080"
    >
      <div id="os-bg"></div>
      <canvas
        id="os-screen"
        class="clip"
        width="1920"
        height="1080"
        data-start="0"
        data-duration="6"
        data-track-index="0"
      ></canvas>
      <div id="os-vignette"></div>
      <div id="os-readout" class="clip" data-start="0" data-duration="6" data-track-index="1">
        <div class="os-title">OSCILLOSCOPE</div>
        <div class="os-row"><span>SOURCE</span><span class="os-val" id="os-r-wave">SINE</span></div>
        <div class="os-row"><span>FREQ</span><span class="os-val" id="os-r-freq">-</span></div>
        <div class="os-row"><span>TIMEBASE</span><span class="os-val" id="os-r-time">-</span></div>
        <div class="os-row"><span>VERT</span><span class="os-val" id="os-r-amp">-</span></div>
        <div class="os-row"><span>PHOSPHOR</span><span class="os-val" id="os-r-phos">-</span></div>
        <div class="os-note" id="os-r-note"></div>
      </div>
    </div>
    <script>
      (function () {
        "use strict";

        // ---------------------------------------------------------------
        // Oscilloscope trace with phosphor persistence.
        //
        // Two pieces of physical truth drive the look:
        //
        //  1. PERSISTENCE. The phosphor keeps emitting after the beam has
        //     passed, decaying as exp(-age/tau). Decay constants by EIA
        //     phosphor class (research entry F#10):
        //       P11/P31 - 0.01-1 ms   (what a real bench scope uses: no
        //                              visible afterglow at video rates)
        //       P1      - 80-150 ms   (the "vintage glow" look; tau ~= 100 ms
        //                              is the entry's recommended default)
        //       P33     - > 1 s
        //       P2/P7   - 30 s .. ~1 min (radar territory, out of range here)
        //     The tail window is 3*tau: after three time constants ~5% of the
        //     brightness remains, which the entry gives as the safe cutoff.
        //
        //  2. BEAM-VELOCITY BRIGHTNESS. A trace is brighter where the beam
        //     moves slower, because the same deposited energy is spread over
        //     a shorter path. Each sub-step deposits a fixed amount of energy
        //     (constant dt), so surface brightness goes as 1/segment-length.
        //     Flat parts of the waveform are bright; fast vertical edges are
        //     faint. Qualitative law only - the research entry states it with
        //     no proportionality constant, so the reference length below is
        //     the physical minimum (pure horizontal sweep motion), not a
        //     fudge factor.
        //
        // SWEEP RATE PROVENANCE. The phosphor decay constants above are
        // measured; the sweep rate is NOT - the research entry gives no
        // timebase figure. The default of 5 sweeps/s is authored: across the
        // 10-division graticule it works out to 20 ms/div, a real value from
        // the standard 1-2-5 timebase sequence, picked because it puts a few
        // cycles of the default signal on screen. Treat it as a dial, not as
        // a measurement.
        //
        // NO FEEDBACK BUFFER. The canvas is cleared every frame and the tail
        // is recomputed by evaluating the beam curve backwards in time from
        // the current frame: P(t - k*dt) for k = 0..K. Frame N depends only
        // on N, so seeking anywhere is exact rather than approximate.
        // ---------------------------------------------------------------

        var COMP_ID = "oscilloscope-trace";
        var DURATION = 6;

        // Screen geometry: a 10x8 division graticule of SQUARE divisions,
        // the standard CRT scope face.
        var DIV = 100;
        var DIVS_X = 10;
        var DIVS_Y = 8;
        var SW = DIV * DIVS_X;
        var SH = DIV * DIVS_Y;
        var SX = 140;
        var SY = 140;
        var CY = SY + SH / 2;

        // Tail window in time constants (research entry F#10: 3*tau leaves
        // ~5% weight, stated there as the safe cutoff).
        var TAIL_TAUS = 3;
        // Sub-step count. The entry suggests K ~= 24 steps across the window,
        // which is enough to quantise the DECAY but far too coarse spatially:
        // at 12 Hz the beam would advance 0.15 of a cycle per step and the
        // trace would render as a polygon. So K is derived from how fast the
        // beam actually moves - keep each sub-step under SEG_TARGET_PX of
        // travel. That target and the clamps are MY numbers, not measured
        // ones; the clamp bounds per-frame cost when persistence is long.
        var SEG_TARGET_PX = 3;
        var K_MIN = 120;
        var K_MAX = 8000;

        // Band-limit for the square wave. A real generator + a real scope
        // front end both have finite bandwidth, so the edge is steep but not
        // instantaneous; a mathematical step would make the vertical edge one
        // sub-step long and the velocity law would erase it entirely.
        var SQUARE_SHARPNESS = 8;

        function readVariables() {
          var api = window.__hyperframes && window.__hyperframes.getVariables;
          if (typeof api === "function") return api() || {};
          // Standalone fallback (raw file opened without the runtime): read
          // the same declaration the runtime reads, so defaults have exactly
          // one home.
          var out = {};
          try {
            var raw = document.getElementById("os-root").getAttribute("data-composition-variables");
            var decls = JSON.parse(raw || "[]");
            for (var i = 0; i < decls.length; i++) out[decls[i].id] = decls[i].default;
          } catch (err) {
            /* declaration missing or malformed - fall through to hard defaults */
          }
          return out;
        }

        function num(value, fallback, min, max) {
          var n = typeof value === "number" ? value : parseFloat(value);
          if (!isFinite(n)) n = fallback;
          return Math.min(max, Math.max(min, n));
        }

        var V = readVariables();
        var waveform =
          ["sine", "square", "triangle", "data"].indexOf(String(V.waveform)) >= 0
            ? String(V.waveform)
            : "sine";
        var frequency = num(V.frequency, 12, 0.1, 200);
        var amplitude = num(V.amplitude, 3, 0.1, 4);
        var tau = num(V.persistenceMs, 100, 1, 1000) / 1000;
        var sweepRate = num(V.sweepRate, 5, 0.25, 60);
        var phosphor = parseColor(V.phosphorColor, [93, 255, 143]);

        // waveform="data" replays a supplied series instead of a synthetic
        // shape: comma/whitespace separated numbers, clamped to -1..1, linearly
        // interpolated, one full pass of the series per `frequency` cycle.
        // Empty or unparseable -> falls back to sine.
        var series = String(V.dataSeries == null ? "" : V.dataSeries)
          .split(/[\s,]+/)
          .map(parseFloat)
          .filter(function (n) {
            return isFinite(n);
          })
          .map(function (n) {
            return Math.min(1, Math.max(-1, n));
          });
        if (waveform === "data" && series.length < 2) waveform = "sine";

        function parseColor(value, fallback) {
          var m = /^#?([0-9a-f]{6})$/i.exec(String(value == null ? "" : value).trim());
          if (!m) return fallback;
          var v = parseInt(m[1], 16);
          return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
        }

        function rgba(c, a) {
          return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a.toFixed(4) + ")";
        }

        // --- the signal: pure function of time, no state ------------------

        function wave(t) {
          var p = frequency * t; // cycles elapsed
          if (waveform === "square") {
            return Math.tanh(SQUARE_SHARPNESS * Math.sin(2 * Math.PI * p));
          }
          if (waveform === "triangle") {
            return (2 / Math.PI) * Math.asin(Math.sin(2 * Math.PI * p));
          }
          if (waveform === "data") {
            var frac = p - Math.floor(p);
            var pos = frac * (series.length - 1);
            var i = Math.floor(pos);
            var f = pos - i;
            var a = series[i];
            var b = series[Math.min(series.length - 1, i + 1)];
            return a + (b - a) * f;
          }
          return Math.sin(2 * Math.PI * p);
        }

        /** Horizontal sweep position, 0..1 across the graticule. */
        function sweepU(t) {
          var s = t * sweepRate;
          return s - Math.floor(s);
        }

        function beamX(t) {
          return SX + sweepU(t) * SW;
        }

        function beamY(t) {
          return CY - wave(t) * amplitude * DIV;
        }

        // Upper bound on beam speed, in px/s, used to pick the sub-step count.
        // Horizontal is the constant sweep; vertical is the waveform's steepest
        // slope, which differs per shape - a band-limited square is
        // SQUARE_SHARPNESS times steeper at its edge than a sine of the same
        // frequency, and under-sampling exactly there is what turns the edge
        // into a polygon.
        var slopeBound;
        if (waveform === "square") {
          slopeBound = SQUARE_SHARPNESS * 2 * Math.PI * frequency;
        } else if (waveform === "triangle") {
          slopeBound = 4 * frequency;
        } else if (waveform === "data") {
          var maxStep = 0;
          for (var si = 1; si < series.length; si++) {
            maxStep = Math.max(maxStep, Math.abs(series[si] - series[si - 1]));
          }
          slopeBound = maxStep * (series.length - 1) * frequency;
        } else {
          slopeBound = 2 * Math.PI * frequency;
        }
        var beamSpeedMax = Math.sqrt(
          Math.pow(SW * sweepRate, 2) + Math.pow(slopeBound * amplitude * DIV, 2),
        );

        // --- graticule (drawn once to an offscreen canvas) ----------------

        var grat = document.createElement("canvas");
        grat.width = 1920;
        grat.height = 1080;
        (function drawGraticule() {
          var g = grat.getContext("2d");
          g.fillStyle = "#04080a";
          g.fillRect(SX, SY, SW, SH);
          g.strokeStyle = "rgba(120,180,160,0.16)";
          g.lineWidth = 1;
          for (var i = 1; i < DIVS_X; i++) {
            g.beginPath();
            g.moveTo(SX + i * DIV + 0.5, SY);
            g.lineTo(SX + i * DIV + 0.5, SY + SH);
            g.stroke();
          }
          for (var j = 1; j < DIVS_Y; j++) {
            g.beginPath();
            g.moveTo(SX, SY + j * DIV + 0.5);
            g.lineTo(SX + SW, SY + j * DIV + 0.5);
            g.stroke();
          }
          // Centre axes carry the fine 0.2-division ticks, as on a real face.
          g.strokeStyle = "rgba(150,205,185,0.32)";
          var cx = SX + SW / 2 + 0.5;
          var cy = CY + 0.5;
          g.beginPath();
          g.moveTo(cx, SY);
          g.lineTo(cx, SY + SH);
          g.moveTo(SX, cy);
          g.lineTo(SX + SW, cy);
          g.stroke();
          g.strokeStyle = "rgba(150,205,185,0.42)";
          for (var k = 1; k < DIVS_X * 5; k++) {
            var x = SX + (k * DIV) / 5 + 0.5;
            g.beginPath();
            g.moveTo(x, cy - 9);
            g.lineTo(x, cy + 9);
            g.stroke();
          }
          for (var m = 1; m < DIVS_Y * 5; m++) {
            var y = SY + (m * DIV) / 5 + 0.5;
            g.beginPath();
            g.moveTo(cx - 9, y);
            g.lineTo(cx + 9, y);
            g.stroke();
          }
          g.strokeStyle = "rgba(160,215,195,0.55)";
          g.lineWidth = 2;
          g.strokeRect(SX + 1, SY + 1, SW - 2, SH - 2);
        })();

        // --- per-frame paint ----------------------------------------------

        var canvas = document.getElementById("os-screen");
        var ctx = canvas.getContext("2d");

        function draw(t) {
          ctx.globalCompositeOperation = "source-over";
          ctx.clearRect(0, 0, 1920, 1080);
          ctx.drawImage(grat, 0, 0);

          var windowS = TAIL_TAUS * tau;
          var K = Math.max(
            K_MIN,
            Math.min(K_MAX, Math.round((windowS * beamSpeedMax) / SEG_TARGET_PX)),
          );
          var dt = windowS / K;
          // Length the beam covers in one sub-step with zero vertical motion.
          // That is the slowest the beam can ever move, so it is the maximum
          // brightness reference and the velocity weight never exceeds 1.
          var lRef = SW * sweepRate * dt;

          ctx.globalCompositeOperation = "lighter";
          ctx.lineCap = "round";

          // Oldest -> newest, so the bright head lands on top.
          var prevX = 0;
          var prevY = 0;
          var prevU = 0;
          var havePrev = false;
          for (var k = K; k >= 0; k--) {
            var tk = t - k * dt;
            if (tk < 0) {
              // Before frame 0 the beam had not been switched on yet.
              havePrev = false;
              continue;
            }
            var u = sweepU(tk);
            var x = SX + u * SW;
            var y = beamY(tk);
            if (havePrev && u >= prevU) {
              var dx = x - prevX;
              var dy = y - prevY;
              var len = Math.sqrt(dx * dx + dy * dy);
              // Beam-velocity law: brightness ~ 1 / path length per unit time.
              var vel = len > 1e-6 ? Math.min(1, lRef / len) : 1;
              var decay = Math.exp((-k * dt) / tau);
              var a = decay * vel;
              if (a > 0.002) {
                ctx.beginPath();
                ctx.moveTo(prevX, prevY);
                ctx.lineTo(x, y);
                ctx.strokeStyle = rgba(phosphor, a * 0.13);
                ctx.lineWidth = 13;
                ctx.stroke();
                ctx.strokeStyle = rgba(phosphor, a * 0.9);
                ctx.lineWidth = 3;
                ctx.stroke();
                if (a > 0.55) {
                  // Overdriven phosphor saturates towards white at the head.
                  ctx.strokeStyle =
                    "rgba(255,255,255," + (((a - 0.55) / 0.45) * 0.7).toFixed(4) + ")";
                  ctx.lineWidth = 1.4;
                  ctx.stroke();
                }
              }
            }
            // Retrace is blanked on a real scope: when u wraps, drop the
            // connecting segment instead of drawing a line back across.
            prevX = x;
            prevY = y;
            prevU = u;
            havePrev = true;
          }

          // Beam head: the spot itself, brightest point on the screen.
          var hx = beamX(t);
          var hy = beamY(t);
          var glow = ctx.createRadialGradient(hx, hy, 0, hx, hy, 22);
          glow.addColorStop(0, "rgba(255,255,255,0.95)");
          glow.addColorStop(0.22, rgba(phosphor, 0.85));
          glow.addColorStop(1, rgba(phosphor, 0));
          ctx.fillStyle = glow;
          ctx.beginPath();
          ctx.arc(hx, hy, 22, 0, Math.PI * 2);
          ctx.fill();

          ctx.globalCompositeOperation = "source-over";
        }

        // --- readout --------------------------------------------------------

        (function fillReadout() {
          // Nearest EIA phosphor class for the chosen tau (bands from the
          // research entry). Labelling, not simulation.
          var tauMs = tau * 1000;
          var cls = tauMs <= 1 ? "P31" : tauMs <= 150 ? "P1" : "P33";
          var msPerDiv = 1000 / (sweepRate * DIVS_X);
          function set(id, text) {
            document.getElementById(id).textContent = text;
          }
          set("os-r-wave", waveform.toUpperCase());
          set("os-r-freq", frequency.toFixed(frequency < 10 ? 2 : 1) + " Hz");
          set(
            "os-r-time",
            (msPerDiv >= 10 ? msPerDiv.toFixed(0) : msPerDiv.toFixed(2)) + " ms/div",
          );
          set("os-r-amp", amplitude.toFixed(1) + " div pk");
          set("os-r-phos", cls + "  tau " + tauMs.toFixed(0) + " ms");
          set(
            "os-r-note",
            "Trace brightness falls as exp(-age/tau) behind the beam, and rises where the beam slows.",
          );
        })();

        // --- timeline -------------------------------------------------------
        //
        // tl.eventCallback("onUpdate", ...) is NOT usable here: the runtime
        // seeks with suppressEvents, so the callback never fires on a seek and
        // the canvas would keep whatever the last played frame drew. A tweened
        // property with an accessor is applied by GSAP on every render,
        // including suppressed ones, so the repaint is driven from the setter.

        var beam = { t: 0 };
        var driver = {};
        Object.defineProperty(driver, "t", {
          get: function () {
            return beam.t;
          },
          set: function (value) {
            beam.t = value;
            draw(value);
          },
        });

        window.__timelines = window.__timelines || {};
        var tl = gsap.timeline({ paused: true });
        tl.to(driver, { t: DURATION, duration: DURATION, ease: "none", lazy: false }, 0);
        window.__timelines[COMP_ID] = tl;

        draw(0);
      })();
    </script>
  </body>
</html>
```

Copy npx hyperframes add oscilloscope-trace to the clipboard

That writes one file: `compositions/oscilloscope-trace.html`.

## Add it to your video

It runs for 6 seconds at 1920×1080. Paste this into your composition:

```html index.html
<div
  data-composition-id="oscilloscope-trace"
  data-composition-src="compositions/oscilloscope-trace.html"
  data-start="0"
  data-duration="6"
  data-track-index="1"
  data-width="1920"
  data-height="1080"
></div>
```

Move it in time with `data-start`. Put it on a different timeline row with
`data-track-index`. See [data attributes](/concepts/data-attributes.md) for the rest.

## Variables

Every one of these has a default, so the piece works untouched. Set the ones you
want to change on the element:

| Variable | Default | Accepts | What it does |
| - | - | - | - |
| `waveform` | `sine` | `sine`, `square`, `triangle`, `data` | |
| `frequency` | `12` | 0.1Hz to 200Hz, step 0.1Hz | |
| `amplitude` | `3` | 0.1div to 4div, step 0.1div | |
| `persistenceMs` | `100` | 1ms to 1000ms, step 1ms | |
| `phosphorColor` | `#5dff8f` | color | |
| `sweepRate` | `5` | 0.25sweeps/s to 60sweeps/s, step 0.25sweeps/s | |
| `dataSeries` | \`\` | string | |

Set them with `data-variable-values` on the element that mounts it. These are the
defaults, so this behaves exactly like the preview above until you change one:

```html wrap
<div
  data-composition-id="oscilloscope-trace"
  data-composition-src="compositions/oscilloscope-trace.html"
  data-variable-values='{"waveform":"sine","frequency":12,"amplitude":3,"persistenceMs":100,"phosphorColor":"#5dff8f","sweepRate":5,"dataSeries":""}'
></div>
```

{/* hf:generated-footer */}

Tagged `instrument` `data` `showcase` `retro`.

## Related topics

* [Browse the complete Catalog](/catalog.md)
* [Add assets and Catalog items in the browser editor](/studio/assets-and-blocks.md)
* [Put blocks together into one video](/concepts/compositions.md)
