tv static
[github]snow on an old set that tunes into a test card and loses it
effects 58×26 20 fps 7.7 kB
o o ╲ ╱ ╲ ╱ ╲ ╱ ▄▄███▄▄ ╭──────────────────────────────────────────────────────╮ │ ╭───────────────────────────────────────╮ │ │ │ .░░░: .. + +::. . +.:. : . :▒+ │ │ │ │ ++░░ +..++░░: .░+░+ :: ░:+▒░▒▒▒:.░░. │ ╭─────╮ │ │ │+░. +▒ + :▒:+ : . . :+::. + +▒ +. │ │ ╱ │ │ │ │.: .: .:..:+. . +.. +.: ::+ .+.│ ╰─────╯ │ │ │+ +++:░ .: . .+::::+:.:: . :. :░+:.:│ │ │ │..:. .::::: .:+.:..: .. . : . :│ ╭─────╮ │ │ │ .. .... .. :. .. .:│ │ ╲ │ │ │ │... ..... .. .. ... │ ╰─────╯ │ │ │ :.:+++. .:: .:.. . . :+::.: . . │ │ │ │ :+.:.:.+ :.++: .. . ... ..+░. ... ++.│ ┌───────┐ │ │ │+:░▒░ . :: . :+ .+.░ :.░ .░ .:::│ │═══════│ │ │ │+░░░+:. :.:░+░++.:░+░++▒░▒:+░░: +░.:│ │═══════│ │ │ │▒░ +::░. :░▒░░+ . .+ : .+░│ │═══════│ │ │ │ .+: :░+:..░+░▒+. : ..:.:.+.. .::.. │ │═══════│ │ │ │ . : :. + +░: . .+. +░░: : │ └───────┘ │ │ ╰───────────────────────────────────────╯ │ ╰──────────────────────────────────────────────────────╯ ╱ ╲ ╱ ╲ o o ╲ ╱ ╲ ╱ ╲ ╱ ▄▄███▄▄ ╭──────────────────────────────────────────────────────╮ │ ╭───────────────────────────────────────╮ │ │ │ ▓+++▒█▓▓███░██░▒▒▓█▓█░▓▒▓█▒█▓█▒:░ │ │ │ │ ░░++█░▓▓░░++▒█▓+░+░██▒▒█+▒░:+:::▒▓++▓ │ ╭─────╮ │ │ │░+▓█░:█░███▒:▒░███▒█▓█▓██▒░▒▒▓█░█░:█░▓█│ │ ╱ │ │ │ │▓▒█▓▒█▓▒▓▓▒░▓███▓█████░▓▓█░▓▒██▒▒░██▓░▓│ ╰─────╯ │ │ │░███░░░▒+█▓▒██▓█▓░▒▒▒▒░▒▓▒▒█▓█▒▓█▒+░▒▓▒│ │ │ │▓▓▒▓██▓▒▒▒▒▒█▓▒░▓▒▓▓▒█▓▓████▓█▒█▓█████▒│ ╭─────╮ │ │ │████▓▓███▓▓▓▓███████▓▓██▒▓███████▓▓██▓▒│ │ ╲ │ │ │ │▓▓▓█████████████▓▓▓▓▓███▓▓███▓▓█▓▓▓████│ ╰─────╯ │ │ │█▒▓▒░░░▓███▓▒▒█▓▒▓▓██▓█▓█▒░▒▒▓▒██▓█▓███│ │ │ │██▒░▓▒▓▒▓░█▒▓░░▒█▓▓█▓█▓▓▓█▓▓░+▓█▓▓▓█░░▓│ ┌───────┐ │ │ │░▒+:+█▓██▒▒█▓█▒░█▓░▓+██████▒▓+██▓+█▓▒▒▒│ │═══════│ │ │ │░+++░▒▓██▒▓▒+░+░░▓▒+░+░░:+:▒░++▒███░+▓▒│ │═══════│ │ │ │:+█████░▒▒+▓████▒+:++░███▓███▓░██▒██▓░+│ │═══════│ │ │ │ ▓░▒████▒+░▒▓▓+░+:░▓█▒█▓▓▒▓▒▓░▓▓█▓▒▒▓▓ │ │═══════│ │ │ │ ████▓███▒█▒▓█░█░+▒█▓█▓░▓██░++▒██▒ │ └───────┘ │ │ ╰───────────────────────────────────────╯ │ ╰──────────────────────────────────────────────────────╯ ╱ ╲ ╱ ╲
use it
html
<script type="module" src="https://ascii.rest/ascii.js"></script>
<ascii-art piece="tv-static" options='{"set":true}'></ascii-art> No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.
react
// npm install github:bas3line/ascii
import { Ascii } from "ascii.rest/react";
import { tvStatic } from "ascii.rest/pieces";
<Ascii piece={tvStatic} options={{"set":true}} /> A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="tv-static", to fetch the piece only when it mounts.
astro
---
// npm install github:bas3line/ascii
import Ascii from "ascii.rest/astro";
---
<Ascii piece="tv-static" options={{"set":true}} /> Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.
typescript
// npm install github:bas3line/ascii
import { mount } from "ascii.rest";
import { tvStatic } from "ascii.rest/pieces";
const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, tvStatic, {"set":true}); mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.
/*
* tv-static: an old set showing snow, with a hum bar rolling through it. The
* dial clicks over, a test card rolls into place and holds, then is lost.
*/
import type { Frame, Meta } from "../types.ts";
export interface TvStaticOptions {
[key: string]: unknown;
set: boolean;
}
export const meta = {
name: "tv static",
category: "effects",
note: "snow on an old set that tunes into a test card and loses it",
cols: 58,
rows: 26,
fps: 20,
options: { set: true },
} satisfies Meta<TvStaticOptions>;
const RAMP = " .:+░▒▓█";
const LOOP = 10; // seconds: snow, tuning in, the card, losing it, snow
const BARS = [7, 6, 5, 4, 3, 2, 1]; // ramp levels, brightest bar first
const CASTLE = [1, 0, 3, 0, 5, 0, 7]; // the reversed strip under the bars
const BOTTOM: [number, number][] = [[0.17, 5], [0.34, 7], [0.51, 3], [0.68, 0], [0.73, 1], [0.78, 0], [0.83, 2], [1, 0]];
const SNOW = [0.36, 0.48, 0.6, 0.7, 0.8, 0.9, 0.98]; // where the snow steps up a level
const hash = (a: number, b: number, c: number) => {
let h = Math.imul(a, 0x27d4eb2d) ^ Math.imul(b, 0x165667b1) ^ Math.imul(c, 0x2545f491);
h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
};
const smooth = (a: number, b: number, v: number) => {
const x = Math.max(0, Math.min(1, (v - a) / (b - a)));
return x * x * (3 - 2 * x);
};
// The set: cabinet, rounded screen bezel, two knobs, a grille, legs and ears.
function drawSet(cols: number, rows: number) {
const g = Array.from({ length: rows }, () => new Array<string>(cols).fill(" "));
const box = (x0: number, y0: number, x1: number, y1: number, c = "╭╮╰╯─│") => {
for (let x = x0 + 1; x < x1; x++) g[y0][x] = g[y1][x] = c[4];
for (let y = y0 + 1; y < y1; y++) g[y][x0] = g[y][x1] = c[5];
[g[y0][x0], g[y0][x1], g[y1][x0], g[y1][x1]] = [c[0], c[1], c[2], c[3]];
};
box(1, 5, 56, 23);
box(3, 6, 43, 22);
box(46, 8, 52, 10); // the channel knob
box(46, 12, 52, 14); // the volume knob
box(45, 16, 53, 21, "┌┐└┘─│"); // the speaker
for (let y = 17; y <= 20; y++) for (let x = 46; x <= 52; x++) g[y][x] = "═";
g[13][49] = "╲";
const put = (x: number, y: number, s: string) => [...s].forEach((ch, i) => (g[y][x + i] = ch));
put(25, 4, "▄▄███▄▄");
for (let k = 1; k <= 4; k++) {
g[4 - k][25 - k] = "╲";
g[4 - k][31 + k] = "╱";
}
g[0][21] = g[0][35] = "o";
put(5, 24, "╱");
put(4, 25, "╱");
put(52, 24, "╲");
put(53, 25, "╲");
return g;
}
// The test card: bars over a reversed strip and a bottom strip of hard-edged
// blocks, with a circle and a crosshair drawn across the middle.
function drawCard(SW: number, SH: number) {
const card: (number | string)[][] = Array.from({ length: SH }, (_, r) =>
Array.from({ length: SW }, (_, c) => {
const u = (c + 0.5) / SW, v = (r + 0.5) / SH;
const bar = Math.min(6, Math.floor(u * 7));
if (v < 0.67) return BARS[bar];
if (v < 0.75) return CASTLE[bar];
return BOTTOM.find(([end]) => u < end)![1];
}),
);
// The circle, row by row: its span in each row, outlined in box drawing.
const R = Math.min(SH * 0.43, SW * 0.22) * 2, cx = SW / 2, cy = SH / 2;
const span: ([number, number] | null)[] = [];
for (let r = 0; r < SH; r++) {
const y = (r + 0.5 - cy) * 2, w = R * R - y * y;
span.push(w < 0 ? null : [Math.ceil(cx - Math.sqrt(w) - 0.5), Math.floor(cx + Math.sqrt(w) - 0.5)]);
}
const mid = Math.floor(SH / 2), mc = Math.floor(SW / 2);
for (let r = 0; r < SH; r++) {
if (!span[r]) continue;
const [a, b] = span[r]!;
const up = r < mid, near = up ? span[r - 1] : span[r + 1];
// The run out to the row nearer the middle's edge, stepped with corners.
const [na, nb] = near || [mc + 1, mc - 1];
for (let c = a; c <= b; c++) {
if (c > a && c < na && c < nb) card[r][c] = "─";
else if (c < b && c > nb && c > na) card[r][c] = "─";
}
if (a < na) {
card[r][a] = up ? "╭" : "╰";
card[r][na] = up ? "╯" : "╮";
card[r][b] = up ? "╮" : "╯";
card[r][nb] = up ? "╰" : "╭";
} else card[r][a] = card[r][b] = "│";
if (!near) for (let c = a + 1; c < b; c++) card[r][c] = "─";
}
// The crosshair, meeting the circle in tees.
const [a, b] = span[mid]!;
for (let c = a + 1; c < b; c++) card[mid][c] = "─";
for (let r = 0; r < SH; r++) if (span[r]) card[r][mc] = "│";
card[mid][a] = "├";
card[mid][b] = "┤";
card[mid][mc] = "┼";
card[span.findIndex(Boolean)][mc] = "┬";
card[SH - 1 - [...span].reverse().findIndex(Boolean)][mc] = "┴";
return card;
}
export default function tvStatic({ set = true }: Partial<TvStaticOptions> = {}): Frame {
const { cols, rows, fps } = meta;
const frame = set ? drawSet(cols, rows) : null;
// The picture area, and which of its cells the curved tube leaves out.
const [X0, Y0, SW, SH] = set ? [4, 7, 39, 15] : [0, 0, cols, rows];
const inside = (c: number, r: number) => {
if (!set) return true;
const x = (c + 0.5 - SW / 2) / (SW / 2), y = (r + 0.5 - SH / 2) / (SH / 2);
return x ** 6 + y ** 6 <= 1.02;
};
const card = drawCard(SW, SH);
const g = Array.from({ length: rows }, () => new Array<string>(cols));
const N = RAMP.length - 1;
const ink = (level: number, paper: boolean) => RAMP[paper ? N - level : level];
return (t, { paper = false } = {}) => {
const u = ((t % LOOP) + LOOP) % LOOP;
const k = Math.floor(t * fps); // the snow is new every frame
// How firmly the set holds the signal. Just before it locks, and just
// before it is lost, the dial clicks through three stops.
const lock = smooth(1.6, 3.4, u) * (1 - smooth(6.6, 7.8, u));
const dial = Math.max(0, Math.min(3, Math.floor((u - 1.1) * 8), 3 - Math.floor((u - 6.4) * 8)));
const snow = 1 - 0.97 * lock;
// Unlocked, the picture rolls (with its blanking bar) and its lines tear.
const roll = (1 - lock) ** 2 * (SH + 2) * 2.5;
const tear = (1 - lock) * 9;
const hum = ((u / LOOP + 0.45) % 1) * (SH + 6) - 3; // the hum bar's middle row, once a loop
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) g[r][c] = frame ? frame[r][c] : " ";
if (frame) g[9][49] = "╱─╲│"[dial];
for (let r = 0; r < SH; r++) {
const dim = 1 - 0.45 * Math.exp(-(((r - hum) / 2.2) ** 2));
const line = 0.9 + 0.2 * hash(k, r, 9); // each scan line a little brighter or darker
const shift = Math.round(tear * Math.sin(r * 0.8 + u * 9) * hash(k >> 2, r, 3));
const pr = Math.floor((r + roll) % (SH + 2)); // the card row shown here
let prev = hash(k, r, 1);
for (let c = 0; c < SW; c++) {
if (!inside(c, r)) {
g[Y0 + r][X0 + c] = " ";
continue;
}
// Snow is fine grain streaked along the line, as it is on a real tube,
// mostly dots with now and then a brighter fleck.
const n = hash(k, r * 97 + c, 2);
prev = 0.6 * n + 0.4 * prev;
let ch: string;
const noisy = hash(k, r * 97 + c, 5) < snow;
if (noisy && hash(k, r * 97 + c, 6) >= lock ** 6) {
const v = prev * line * dim;
let level = 0;
while (level < SNOW.length && v > SNOW[level]) level++;
ch = ink(level, paper);
} else {
const pc = (((c + shift) % SW) + SW) % SW;
let x = pr < SH ? card[pr][pc] : 0;
// Once locked, what noise is left only nudges the card a shade.
if (noisy && typeof x === "number") x = Math.max(0, Math.min(N, x + (n < 0.5 ? -1 : 1)));
ch = typeof x === "number" ? ink(x, paper) : x;
}
g[Y0 + r][X0 + c] = ch;
}
}
return g.map((row) => row.join("")).join("\n");
};
}