# teardown

Adopt this skill: https://proskills.md/skills/other/elvarofficial-teardown

Page: https://proskills.md/skills/other/elvarofficial-teardown
This document is the public skill bundle for that page. It contains SKILL.md and every helper file the catalog carries. No login is required.

## File: SKILL.md
size: 11672
sha: 9505e4aa15caa097260cd982d1a91312dd45859a
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/SKILL.md

````text
---
name: teardown
description: Build an interactive 3D simulator of a real machine or mechanism (an engine, a landing gear, a walking linkage, a pump, a gearbox) as one offline HTML page people can take apart, with live panels driven by the same model that moves the parts, a button for every subsystem, cutaways, camera views and an auto tour that hands control to the visitor; then record it frame by frame to a 60 fps video with a synced procedural score. Use when someone asks how a machine works, wants a "visual explainer", a "simulator", a "cutaway", an "exploded view" or an "interactive 3D lesson", or wants a 3D object shown working rather than described.
---

# Teardown

One machine, one page. The visitor sees the thing run, presses a button, and the part doing the work opens up while the gauges around it read the real numbers. Left alone, the page tours itself with a cursor. The same code records a film of the tour, frame by frame, with a score built on the same timeline.

`SKILL_DIR` below is the folder this file lives in. Builds and renders go to `./teardown-out` of the folder you run from.

## First run

```
cd "$SKILL_DIR" && npm install && npx playwright install chromium
```

The pages need nothing else. The video needs `ffmpeg` on PATH and the score needs Python 3 (standard library only). Set `FFMPEG`, `PYTHON` or `CHROMIUM` to point at other binaries.

## The 3 examples

Build one, open the HTML, press buttons. Copy the closest one when you start a new machine.

| | The machine | What drives it | Worth stealing |
|---|---|---|---|
| `v8-*` | A cross-plane V8 with glass liners: the charge burns inside the cylinder you track, valves lift on their cams, 8 pistons move from the exact slider-crank. | Crank angle. Firing order 1-8-4-3-6-5-7-2, a chamber pressure model for the indicator diagram. | Cutaway modes (glass, section, solid), the firing order as a cylinder picker, live charts synced to the crank. |
| `cessna-*` | The Cessna 337 main landing gear folding into the airframe on takeoff and dropping on landing. | Stroke 0..1: a 201 mm rack turns a 32-tooth pinion (module 4 mm) through exactly 180°. | One degree of freedom posing a whole mechanism, flight phases as functions of time, a runway that rolls under the model. |
| `strandbeest-*` | Theo Jansen's Strandbeest, 20 legs on one crankshaft, walking broadside over sand. | Wind. The crank speed is integrated from it; every joint follows by circle intersection from Jansen's 11 link lengths. | An exact linkage solver in 67 lines, instanced tubes, foot traces, a contact chart of all 20 feet. |

Each comes in 3 folders: `<name>-shared/` holds the machine, `<name>-film/` the recorded tour, `<name>-live/` the page you drive.

```
node "$SKILL_DIR/sims/build.mjs" v8-live          # → teardown-out/dist/v8-live.html, offline, ~800 KB
node "$SKILL_DIR/sims/build.mjs"                  # all 6
```

## How a machine is built

One folder per machine, split by job. The examples follow this exactly:

| File | Job |
|---|---|
| `kin.js` / `gear.js` / `jansen.js` | The math. Real dimensions in real units, one input (crank angle, stroke, wind) and every pose and readout as a function of it. No three.js in here. |
| `engine.js` / `gear.js` / `beast.js` | The geometry, built from code: lathed parts, tubes, instanced links. `update(input)` poses it. |
| `stage.js` | Studio, lights, shadows, post (GTAO, bloom). `frame(state, cam)` draws one frame. |
| `console.js` | The overlay: fixed 1600 x 900 CSS layout of panels, charts, toggles, callouts with leaders. `update(state)` redraws it from the frame's state. |
| `film.js` | A `SCRIPT` of operator moves (views, isolate, cutaway, throttle) and `render(t)` as a pure function of time. |
| `live.js` | The same machine in real time: buttons, sliders, orbit, zoom, keys. With no input it plays `film.js`'s script as an auto tour with a cursor; any click, drag, wheel or key hands control over, 25 s of quiet hands it back. |
| `score.py` | The sound on the film's timeline. |

For a machine outside `SKILL_DIR`, make a folder with a `main.js` and import the kit by name: `import {runSim} from 'teardown-kit/runtime.js'`. Build it with `node "$SKILL_DIR/sims/build.mjs" ./my-machine`.

## Rules that decide whether it works

1. **Start from a reference, never from imagination.** A photo, a cutaway drawing from a manual, a clip of the real thing. Build against it and compare frame by frame. Machines invented from a sentence come out generic every time; the same model rebuilds a real one closely when it has something to match.
2. **Real numbers, one source.** Get the real dimensions first (bore, stroke, tooth count, link lengths). Every gauge, chart and label reads from the same function that poses the parts, so nothing on screen can disagree with the motion. Check one by hand: in the Cessna, 32 teeth × 4 mm module is a 128 mm pitch diameter, and half its circumference is the 201 mm stroke.
3. **One input drives everything.** Pose from the crank angle, the stroke or the wind. Nothing is keyframed by hand, so the film, the live page and the score never drift.
4. **The part doing the work opens up.** Isolate it, cut it, x-ray it. A camera that goes inside is worth more than ten orbits.
5. **Light studio, one accent.** Pale backgrounds read in a feed; dark ones lose the first second. The accent marks the thing that is changing. Watch near-white parts on a pale backdrop: give them an ink outline or a darker ground.
6. **Pick the camera for the mechanism.** A planar linkage seen face-on collapses into a lattice; a 3/4 view along the spine keeps every leg readable.
7. **Fix collisions with a number.** When a part pokes through another, scale or move it in the parameter block. Never hide it with the camera.

## Live page rules

- **Test on an integrated GPU.** A page that runs at 60 fps on a desktop card can drop to 15 fps on a laptop's built-in one. The examples carry quality tiers (`TIERS` in `live.js`): no AO, lower pixel ratio, smaller shadow maps, panels redrawn every other frame. Find the expensive shader first: a displacement computed 5 times per vertex costs more than every tier together.
- **Warm the shaders.** `kit/warm.js` compiles every program in parallel before the first frame, so the page never freezes on first draw.
- **Keep the page under 1 MB.** Link crawlers (X, Slack) read the share card from the HTML and give up on a 15 MB page. Big models go out as files next to the page: `build.json` → `{"external": {"heavy.glb": "light.glb"}}`.

## render(t) contract

```js
import {runSim} from '../kit/runtime.js';

const DEFAULTS = {duration: 20, fps: 60, hud: {enabled: false}, director: {enabled: false}};

runSim(DEFAULTS, async ({cfg, canvas, capture}) => {
  // build the scene, precompute anything stateful
  return {render(t) { /* draw frame t from scratch */ return {}; }, resize() {}};
});
```

- `render(t)` is pure: no `Math.random` (use `rng(seed)` from `kit/util.js` at setup), no `Date`, no state carried between frames. Capture calls it at arbitrary times.
- Anything that moves by its own rules (agents, particles) is simulated once at load into typed arrays; `render` only reads them. A quantity integrated over time (the Strandbeest's crank under a changing wind) is integrated in closed form or tabulated at load.
- Every beat goes into `<name>-film/timeline.json` in seconds. The score reads the same file.
- Frame 0 is the thumbnail. It must already show the machine.

## Commands

```
node "$SKILL_DIR/sims/build.mjs" v8-film
node "$SKILL_DIR/sims/capture.mjs" v8-film --size 1600x900 --dpr 1.2 --stills beats --cols 4 --out teardown-out/v8-beats.png
node "$SKILL_DIR/sims/capture.mjs" v8-film --size 1600x900 --dpr 1.2 --still 12.5 --out teardown-out/v8-12.5.png
node "$SKILL_DIR/sims/capture.mjs" v8-film --size 1600x900 --dpr 1.2 --out teardown-out/v8-silent.mp4
python "$SKILL_DIR/run_sfx.py" "$SKILL_DIR/sims/v8-shared/score.py"
ffmpeg -y -i teardown-out/v8-silent.mp4 -i teardown-out/v8-score.wav -map 0:v -map 1:a -c:v copy -c:a aac -b:a 256k -shortest -movflags +faststart teardown-out/v8.mp4
node "$SKILL_DIR/wave.mjs" teardown-out/v8.mp4 --timeline "$SKILL_DIR/sims/v8-film/timeline.json"
```

`--size` is the CSS layout and `--dpr` multiplies it: 1600 x 900 at 1.2 gives 1920 x 1080. Capture prints the GPU it got; a software renderer means the GPU flags in `paths.mjs` did not take.

## Review loop

You cannot watch the video; you can look at frames. Never report a machine as done without reading sheets of the current render.

1. `--stills beats`: one tile per timeline beat, 0.3 s after it, each labelled `t=`.
2. Read every tile against the checklist below.
3. Zoom in: `--stills every:0.2 --from A --to B` for a transition, `--still T` for one full frame.
4. Fix, rebuild, go back to 1. Stop when a full pass finds nothing.
5. Final: `node "$SKILL_DIR/sheet.mjs" --video <final.mp4> --timeline <timeline.json>` and read that too: it shows what the encoder wrote.

Checklist:

- **The mechanism.** Every part that should touch touches; nothing passes through anything; the numbers on the panels match the pose.
- **Composition.** One focal point per frame. Nothing important within 60 px of an edge. Labels never cover the part they name.
- **Type.** Nothing clipped. The key readout reads at phone width.
- **3D.** Bloom eats detail: keep emissive layers under 1.0. Near-white surfaces blow out. Glossy metal under a low light streaks: roughness ≥ 0.45 and envMapIntensity ≈ 0.5 on big surfaces.
- **Timing.** Each state holds long enough to read: 1 s for a short phrase plus 0.25 s per word beyond three.

## three.js traps the examples already hit

- `smoothstep(a, b, x)` with `a > b` returns 0 on Windows (ANGLE/D3D). Write `1.0 - smoothstep(b, a, x)`.
- A `ShadowMaterial` or a decal a fraction of a millimetre above the floor loses the depth test inside a post-processing render target. Give it `polygonOffset`.
- A quad laid into the ground with a mirrored mapping gets culled as a back face. Use `DoubleSide`.
- One NaN pixel blacks out the whole frame through bloom. Clamp every `pow()` base in a shader.
- GTAO on a clipped (cutaway) model needs the same clipping planes on `gtao.normalMaterial`, or the cut half returns as a pale ghost.

## Sound

`sfx/` synthesises every sound in pure Python (48 kHz, reproducible) and reads the film's timeline, so sound cannot drift out of sync. `python "$SKILL_DIR/run_sfx.py" --list` shows the generators. `sample()` adds the recorded CC0 sounds in `sfx/samples/`. `sfx/score_kit.py` has the beds and accents the 3 scores use: a chord bed, a soft thud per power stroke, a motor, air under a drag.

- Every time comes from the timeline, never a typed number.
- Mastering is automatic: −14 LUFS, true peak ≤ −2 dBFS.
- Accent, never narrate: continuous motion stays silent. One key for every tonal sound.

## Taste, with numbers

- Easing: entrances `cubic-bezier(0.16, 1, 0.3, 1)`, exits `cubic-bezier(0.6, 0, 0.9, 0.35)` at half the entrance's length, state to state `cubic-bezier(0.65, 0, 0.35, 1)`. Linear only for continuous motion.
- The machine fills 60-70% of its viewport. Labels never under 34 px on a 1080 canvas.
- One accent, and it means something: the part that is working right now.
- Nothing dead-still: one element always alive.

## Credits

The kit, frame capture, sfx engine and review rules come from [whaleyxbt/claude-motion](https://github.com/whaleyxbt/claude-motion) (MIT). The recorded samples are CC0 from Freesound, trimmed by [Barty-Bart/motion-graphics](https://github.com/Barty-Bart/motion-graphics) (MIT); sources in `sfx/samples/SOURCES.md`.
````

## File: README.md
size: 2283
sha: ba138fb0f6cfd3cd62ac5ed021e2c16374dad645
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/README.md

````text
# teardown

A Claude Code skill that turns any machine into a 3D simulator you can take apart.

Ask how something works. Claude builds it as one offline HTML page: the machine runs in the middle, every panel around it reads the real numbers, a button opens the part doing the work, and when nobody touches it the page tours itself.

![Mellis HB-7 robotic bee simulator](docs/bee.gif)

| Telson SC-8 robotic scorpion | Pelagos OC-8 robotic octopus |
|---|---|
| ![Telson SC-8 robotic scorpion](docs/scorpion.gif) | ![Pelagos OC-8 robotic octopus](docs/octopus.gif) |

## Install

```
npx skills add elvarofficial/teardown -g -a claude-code
```

Then, once, in the installed folder:

```
npm install && npx playwright install chromium
```

You need Node.js 20 or newer. For the video you also need `ffmpeg`, and for the sound Python 3.

## Use

Ask Claude Code in plain words:

> Build me a simulator of a 4-stroke motorcycle engine I can take apart.

> How does a mechanical watch keep time? Show me as a simulator.

> Make a teardown of a hydraulic excavator arm and record a 20-second video of it.

Or open one of the 3 examples first:

```
node sims/build.mjs v8-live          # teardown-out/dist/v8-live.html
node sims/build.mjs cessna-live
node sims/build.mjs strandbeest-live
```

## What is inside

| | |
|---|---|
| `SKILL.md` | What Claude reads: how a machine is built, the rules that make it look right, the review loop. |
| `sims/kit/` | Runtime, stage, shader warm-up. |
| `sims/v8-*`, `sims/cessna-*`, `sims/strandbeest-*` | 3 complete machines. `-shared` is the machine, `-live` the page you drive, `-film` the recorded tour. |
| `sims/build.mjs` | Bundles a machine into one HTML file, three.js and fonts inlined. |
| `sims/capture.mjs` | Renders the film frame by frame to a 60 fps mp4, or to review sheets of stills. |
| `sfx/`, `run_sfx.py` | Procedural sound on the film's timeline, mastered to −14 LUFS. |

## Credits

Built on [whaleyxbt/claude-motion](https://github.com/whaleyxbt/claude-motion) (MIT): the runtime, frame capture, sound engine and review rules come from there. Recorded samples are CC0 from Freesound, prepared by [Barty-Bart/motion-graphics](https://github.com/Barty-Bart/motion-graphics); sources in `sfx/samples/SOURCES.md`.

MIT licence.
````

## File: .gitignore
size: 47
sha: 14e2c2ef366b3786885945698c6d60d171166b94
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/.gitignore

```text
node_modules/
teardown-out/
__pycache__/
*.pyc
```

## File: docs/bee.gif
size: 6250109
sha: e0f08f1b6ba919d8023cf4d345ace40d31f8485f
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/docs/bee.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/docs/bee.gif

## File: docs/octopus.gif
size: 4691465
sha: be00301f7040ff6b8e2c9998f9c366b066d2976b
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/docs/octopus.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/docs/octopus.gif

## File: docs/scorpion.gif
size: 3485023
sha: 4c5d95f5dd4ebe1e544adde8438abe6aeb796bbc
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/docs/scorpion.gif

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/docs/scorpion.gif

## File: LICENSE
size: 1099
sha: ce935de9b08f13fc0ee7135ebe31044e7c2ef733
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/LICENSE

```text
MIT License

Copyright (c) 2026 elvarofficial
Copyright (c) 2026 whaleyxbt

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```

## File: package-lock.json
size: 16634
sha: 61d09d85a520eb129b62c2c7573e6b4577e0978b
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/package-lock.json

```text
{
  "name": "teardown",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "teardown",
      "version": "1.0.0",
      "license": "MIT",
      "dependencies": {
        "@fontsource-variable/inter": "5.3.0",
        "@fontsource/jetbrains-mono": "5.3.0",
        "esbuild": "0.28.1",
        "playwright": "1.58.2",
        "three": "0.186.1"
      }
    },
    "node_modules/@esbuild/aix-ppc64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
      "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
      "cpu": [
        "ppc64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "aix"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/android-arm": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
      "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
      "cpu": [
        "arm"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/android-arm64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
      "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/android-x64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
      "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/darwin-arm64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
      "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/darwin-x64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
      "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/freebsd-arm64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
      "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "freebsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/freebsd-x64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
      "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "freebsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-arm": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
      "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
      "cpu": [
        "arm"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-arm64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
      "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-ia32": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
      "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
      "cpu": [
        "ia32"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-loong64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
      "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
      "cpu": [
        "loong64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-mips64el": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
      "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
      "cpu": [
        "mips64el"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-ppc64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
      "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
      "cpu": [
        "ppc64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-riscv64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
      "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
      "cpu": [
        "riscv64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-s390x": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
      "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
      "cpu": [
        "s390x"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-x64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
      "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/netbsd-arm64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
      "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "netbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/netbsd-x64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
      "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "netbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/openbsd-arm64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
      "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "openbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/openbsd-x64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
      "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "openbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/openharmony-arm64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
      "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "openharmony"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/sunos-x64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
      "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "sunos"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/win32-arm64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
      "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/win32-ia32": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
      "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
      "cpu": [
        "ia32"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/win32-x64": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
      "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@fontsource-variable/inter": {
      "version": "5.3.0",
      "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
      "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
      "license": "OFL-1.1",
      "funding": {
        "url": "https://github.com/sponsors/ayuhito"
      }
    },
    "node_modules/@fontsource/jetbrains-mono": {
      "version": "5.3.0",
      "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
      "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==",
      "license": "OFL-1.1",
      "funding": {
        "url": "https://github.com/sponsors/ayuhito"
      }
    },
    "node_modules/esbuild": {
      "version": "0.28.1",
      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
      "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
      "hasInstallScript": true,
      "license": "MIT",
      "bin": {
        "esbuild": "bin/esbuild"
      },
      "engines": {
        "node": ">=18"
      },
      "optionalDependencies": {
        "@esbuild/aix-ppc64": "0.28.1",
        "@esbuild/android-arm": "0.28.1",
        "@esbuild/android-arm64": "0.28.1",
        "@esbuild/android-x64": "0.28.1",
        "@esbuild/darwin-arm64": "0.28.1",
        "@esbuild/darwin-x64": "0.28.1",
        "@esbuild/freebsd-arm64": "0.28.1",
        "@esbuild/freebsd-x64": "0.28.1",
        "@esbuild/linux-arm": "0.28.1",
        "@esbuild/linux-arm64": "0.28.1",
        "@esbuild/linux-ia32": "0.28.1",
        "@esbuild/linux-loong64": "0.28.1",
        "@esbuild/linux-mips64el": "0.28.1",
        "@esbuild/linux-ppc64": "0.28.1",
        "@esbuild/linux-riscv64": "0.28.1",
        "@esbuild/linux-s390x": "0.28.1",
        "@esbuild/linux-x64": "0.28.1",
        "@esbuild/netbsd-arm64": "0.28.1",
        "@esbuild/netbsd-x64": "0.28.1",
        "@esbuild/openbsd-arm64": "0.28.1",
        "@esbuild/openbsd-x64": "0.28.1",
        "@esbuild/openharmony-arm64": "0.28.1",
        "@esbuild/sunos-x64": "0.28.1",
        "@esbuild/win32-arm64": "0.28.1",
        "@esbuild/win32-ia32": "0.28.1",
        "@esbuild/win32-x64": "0.28.1"
      }
    },
    "node_modules/fsevents": {
      "version": "2.3.2",
      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
      "hasInstallScript": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
      }
    },
    "node_modules/playwright": {
      "version": "1.58.2",
      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
      "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
      "license": "Apache-2.0",
      "dependencies": {
        "playwright-core": "1.58.2"
      },
      "bin": {
        "playwright": "cli.js"
      },
      "engines": {
        "node": ">=18"
      },
      "optionalDependencies": {
        "fsevents": "2.3.2"
      }
    },
    "node_modules/playwright-core": {
      "version": "1.58.2",
      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
      "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
      "license": "Apache-2.0",
      "bin": {
        "playwright-core": "cli.js"
      },
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/three": {
      "version": "0.186.1",
      "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
      "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==",
      "license": "MIT"
    }
  }
}
```

## File: package.json
size: 520
sha: 40a6b126a76dc64c6cc539acc9d61abc8ecc5c8b
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/package.json

```text
{
  "name": "teardown",
  "version": "1.0.0",
  "private": true,
  "description": "A Claude Code skill that builds interactive 3D simulators of real machines and records them to 60 fps video.",
  "license": "MIT",
  "scripts": {
    "setup": "npm install && npx playwright install chromium",
    "build": "node sims/build.mjs"
  },
  "dependencies": {
    "@fontsource-variable/inter": "5.3.0",
    "@fontsource/jetbrains-mono": "5.3.0",
    "esbuild": "0.28.1",
    "playwright": "1.58.2",
    "three": "0.186.1"
  }
}
```

## File: paths.mjs
size: 1652
sha: 265c70e31773573c88ba3adcc74a4d3f28313254
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/paths.mjs

```text
// Paths and browser flags for the kit. Everything resolves from this folder, so the skill runs wherever it is installed.
// Override any of them with an environment variable.

import {createRequire} from 'node:module';
import path from 'node:path';
import {fileURLToPath} from 'node:url';

const ROOT = path.dirname(fileURLToPath(import.meta.url));

export const FFMPEG = process.env.FFMPEG || 'ffmpeg';
export const FFPROBE = process.env.FFPROBE || 'ffprobe';
export const PYTHON = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3');

// three, esbuild, fonts and playwright: `npm install` in this folder puts them here.
export const DEPS = process.env.TEARDOWN_DEPS || path.join(ROOT, 'node_modules');
// Unset: playwright uses the Chromium that `npx playwright install chromium` downloaded.
export const CHROMIUM = process.env.CHROMIUM || undefined;
// Real GPU WebGL instead of a software rasteriser. On Windows d3d11 plus --force_high_performance_gpu keeps ANGLE off
// an integrated GPU when a discrete one exists. capture.mjs prints the GPU it got on every run.
export const CHROMIUM_ARGS = [
  ...(process.platform === 'win32' ? ['--use-angle=d3d11'] : []),
  '--force_high_performance_gpu', '--ignore-gpu-blocklist', '--enable-gpu-rasterization', '--enable-webgl', '--hide-scrollbars',
];

// Builds and renders land in ./teardown-out of the folder you run from.
export const OUT = path.resolve(process.env.TEARDOWN_OUT || 'teardown-out');
export const DIST = path.join(OUT, 'dist');

export const fromDeps = (id) => createRequire(path.join(DEPS, 'x.js'))(id);
export const playwright = () => fromDeps('playwright');
```

## File: run_sfx.py
size: 236
sha: ff8b1468ffb25846d2e315230657620af03a3678
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/run_sfx.py

```text
"""Render a cue sheet to a mastered WAV from any folder: python run_sfx.py <cues.py> | --list"""

import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from sfx.__main__ import main  # noqa: E402

main()
```

## File: sfx/__init__.py
size: 309
sha: d38f6b10511d6ef3053523c63bc4fab530b2a767
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/__init__.py

```text
"""Procedural sound design for motion graphics. Pure Python stdlib, 48 kHz stereo.

    python run_sfx.py sfx/cues/effort.py   render a cue sheet to WAV
    python run_sfx.py --list                             list the sound generators
"""

from .mix import Mix
from .synth import SR

__all__ = ["Mix", "SR"]
```

## File: sfx/__main__.py
size: 2414
sha: 84ebe11f84f2753ddcc3430676b5cf8aedd9ebed
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/__main__.py

```text
"""CLI: render a cue sheet, or list the available generators."""

import argparse
import importlib.util
import inspect
import json
import os
import sys

from . import synth
from .mix import Mix

def list_generators():
    for name, fn in inspect.getmembers(synth, inspect.isfunction):
        if fn.__module__ != synth.__name__ or name.startswith("_"):
            continue
        doc = (fn.__doc__ or "").strip().splitlines()[0] if fn.__doc__ else ""
        print(f"{name}{inspect.signature(fn)}\n    {doc}")

def load_cues(path):
    spec = importlib.util.spec_from_file_location("cues", path)
    cues = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(cues)
    if not hasattr(cues, "score"):
        sys.exit(f"{path} needs a score(mix, tl) function")
    return cues

def main():
    ap = argparse.ArgumentParser(prog="python run_sfx.py", description="Render a cue sheet to a mastered WAV.")
    ap.add_argument("cues", nargs="?", help="cue sheet: a .py file with score(mix, tl)")
    ap.add_argument("--timeline", help="timeline JSON (default: the cue sheet's TIMELINE, else timeline.json)")
    ap.add_argument("--out", help="output WAV (default: the cue sheet's OUT, else teardown-out/<cue>.wav, or $TEARDOWN_OUT)")
    ap.add_argument("--list", action="store_true", help="list sound generators and exit")
    args = ap.parse_args()

    if args.list:
        list_generators()
        return
    if not args.cues:
        ap.error("pass a cue sheet, or --list")

    cues = load_cues(args.cues)
    # A relative TIMELINE in the cue sheet is read next to the cue sheet, so it runs from any folder.
    timeline = args.timeline or os.path.join(os.path.dirname(os.path.abspath(args.cues)), getattr(cues, "TIMELINE", "timeline.json"))
    stem = os.path.splitext(os.path.basename(args.cues))[0]
    out = args.out or getattr(cues, "OUT", os.path.join(os.environ.get("TEARDOWN_OUT", "teardown-out"), f"{stem}.wav"))
    with open(timeline, encoding="utf8") as f:
        tl = json.load(f)

    mix = Mix(tl["duration"], seed=getattr(cues, "SEED", 11))
    cues.score(mix, tl)
    stats = mix.master(target_lufs=getattr(cues, "TARGET_LUFS", -14.0))
    mix.write(out)
    print(
        f"wrote {out}: {tl['duration']}s, {mix.count} sounds, "
        f"{stats['lufs']:.1f} LUFS, true peak {stats['peak_db']:.1f} dBFS, drive {stats['drive']}"
    )

if __name__ == "__main__":
    main()
```

## File: sfx/dsp.py
size: 13741
sha: 63631608a5be19bc94c77690215ee3a35aa53451
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/dsp.py

```text
"""DSP building blocks (oscillators, filters, delays, reverb), pure Python stdlib at 48 kHz.

Used by sound scripts that need more than the one-shot generators in synth.py,
e.g. sims/engine/sound.py.

Signals are plain lists of floats. Speed comes from two tricks:

- A Python per-sample loop costs about 0.3 µs/sample and a list comprehension
  about 0.05 µs. So constant-pitch oscillators are one comprehension (the naive
  ramp) plus a sparse PolyBLEP fix-up at each wrap. Feedback delays and the
  reverb combs/allpasses run one block at a time, where the block is no longer
  than the delay, so every block depends only on earlier blocks and is a single
  comprehension.
- Filters whose state really is sequential (SVF, biquad, ladder) are tight
  loops, run only on short one-shots or on the few full-length buses that need
  them.
"""

import math

from .synth import SR

TAU = 2.0 * math.pi
_tanh = math.tanh

# ---------------------------------------------------------------- buffers

def zeros(n):
    return [0.0] * n

def add(dst, src, at=0, gain=1.0):
    """dst[at:] += gain * src, in place, clipped to dst."""
    if at < 0:
        src = src[-at:]
        at = 0
    end = min(len(dst), at + len(src))
    if end <= at:
        return
    seg = dst[at:end]
    if gain == 1.0:
        dst[at:end] = [a + b for a, b in zip(seg, src)]
    else:
        dst[at:end] = [a + b * gain for a, b in zip(seg, src)]

def scaled(x, g):
    return [v * g for v in x]

def mul(x, y):
    return [a * b for a, b in zip(x, y)]

def summed(a, b):
    return [p + q for p, q in zip(a, b)]

def rms(x):
    return math.sqrt(sum(v * v for v in x) / len(x)) if x else 0.0

def peak(x):
    return max(max(x), -min(x)) if x else 0.0

def pan_gains(pan):
    """Equal-power pan, -1 left .. +1 right."""
    ang = (pan + 1.0) * math.pi / 4.0
    return math.cos(ang), math.sin(ang)

def fade(x, fin=0, fout=0):
    """Linear fade in/out over fin/fout samples, in place."""
    n = len(x)
    fin, fout = min(fin, n), min(fout, n)
    for i in range(fin):
        x[i] *= i / fin
    for i in range(fout):
        x[n - 1 - i] *= i / fout
    return x

# ------------------------------------------------------------ oscillators

def _blep(out, phase, dt, n, h=1.0):
    """Subtract the PolyBLEP residual at every wrap of the ramp phase + i*dt (downward jump 2h)."""
    k = 1
    while True:
        j = math.ceil((k - phase) / dt)
        # float guard: make j the first sample whose wrapped phase is small
        if j < n and (phase + j * dt) % 1.0 > 0.5:
            j += 1
        if j >= 1 and (phase + (j - 1) * dt) % 1.0 < 0.5:
            j -= 1
        if j - 1 >= n:
            break
        if 0 <= j < n:
            u = ((phase + j * dt) % 1.0) / dt
            if u < 1.0:
                out[j] -= h * (u + u - u * u - 1.0)
        if 1 <= j <= n:
            u = (((phase + (j - 1) * dt) % 1.0) - 1.0) / dt
            if u > -1.0:
                out[j - 1] -= h * (u * u + u + u + 1.0)
        k += 1

def saw(freq, n, phase=0.0):
    """Band-limited sawtooth (PolyBLEP) at constant freq, -1..1."""
    dt = freq / SR
    phase %= 1.0
    out = [2.0 * ((phase + i * dt) % 1.0) - 1.0 for i in range(n)]
    if 0.0 < dt < 0.5:
        _blep(out, phase, dt, n)
    return out

def pulse(freq, n, width=0.5, phase=0.0):
    """Band-limited pulse as the difference of two PolyBLEP saws; zero mean."""
    a = saw(freq, n, phase)
    b = saw(freq, n, phase + width)
    return [p - q for p, q in zip(a, b)]

def sine(freq, n, phase=0.0):
    w = TAU * freq / SR
    ph = TAU * phase
    sin = math.sin
    return [sin(ph + w * i) for i in range(n)]

def noise(rng, n):
    r = rng.random
    return [2.0 * r() - 1.0 for _ in range(n)]

# ---------------------------------------------------------------- filters

def _norm(b0, b1, b2, a0, a1, a2):
    return (b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0)

def lp(fc, q=0.7071):
    w = TAU * min(fc, 0.45 * SR) / SR
    c, al = math.cos(w), math.sin(w) / (2 * q)
    return _norm((1 - c) / 2, 1 - c, (1 - c) / 2, 1 + al, -2 * c, 1 - al)

def hp(fc, q=0.7071):
    w = TAU * min(fc, 0.45 * SR) / SR
    c, al = math.cos(w), math.sin(w) / (2 * q)
    return _norm((1 + c) / 2, -(1 + c), (1 + c) / 2, 1 + al, -2 * c, 1 - al)

def bp(fc, q=1.0):
    w = TAU * min(fc, 0.45 * SR) / SR
    c, al = math.cos(w), math.sin(w) / (2 * q)
    return _norm(al, 0.0, -al, 1 + al, -2 * c, 1 - al)

def peaking(fc, q, db):
    a = 10 ** (db / 40)
    w = TAU * min(fc, 0.45 * SR) / SR
    c, al = math.cos(w), math.sin(w) / (2 * q)
    return _norm(1 + al * a, -2 * c, 1 - al * a, 1 + al / a, -2 * c, 1 - al / a)

def shelf(fc, db, high=True):
    a = 10 ** (db / 40)
    w = TAU * min(fc, 0.45 * SR) / SR
    c, s = math.cos(w), math.sin(w)
    al = s / 2 * math.sqrt(2)
    sa = 2 * math.sqrt(a) * al
    if high:
        return _norm(
            a * ((a + 1) + (a - 1) * c + sa), -2 * a * ((a - 1) + (a + 1) * c), a * ((a + 1) + (a - 1) * c - sa),
            (a + 1) - (a - 1) * c + sa, 2 * ((a - 1) - (a + 1) * c), (a + 1) - (a - 1) * c - sa,
        )
    return _norm(
        a * ((a + 1) - (a - 1) * c + sa), 2 * a * ((a - 1) - (a + 1) * c), a * ((a + 1) - (a - 1) * c - sa),
        (a + 1) + (a - 1) * c + sa, -2 * ((a - 1) + (a + 1) * c), (a + 1) + (a - 1) * c - sa,
    )

def biquad(x, c):
    """Static biquad (transposed direct form II); c from lp/hp/bp/peaking/shelf."""
    b0, b1, b2, a1, a2 = c
    z1 = z2 = 0.0
    out = [0.0] * len(x)
    i = 0
    for v in x:
        y = b0 * v + z1
        z1 = b1 * v - a1 * y + z2
        z2 = b2 * v - a2 * y
        out[i] = y
        i += 1
    return out

def svf(x, fc, q=0.7071, mode="lp", ctrl=32):
    """Zero-delay-feedback state-variable filter (Cytomic/Simper), stable under fast modulation.

    fc: Hz, or a function of the sample index evaluated every `ctrl` samples.
    mode: 'lp', 'bp' or 'hp'.
    """
    n = len(x)
    out = [0.0] * n
    k = 1.0 / q
    ic1 = ic2 = 0.0
    const = not callable(fc)
    if const:
        ctrl = max(n, 1)
    tan = math.tan
    pis = math.pi / SR
    for s in range(0, n, ctrl):
        f = fc if const else fc(s)
        f = 10.0 if f < 10.0 else 20000.0 if f > 20000.0 else f
        g = tan(pis * f)
        a1 = 1.0 / (1.0 + g * (g + k))
        a2 = g * a1
        a3 = g * a2
        e = s + ctrl if s + ctrl < n else n
        if mode == "lp":
            for i in range(s, e):
                v3 = x[i] - ic2
                v1 = a1 * ic1 + a2 * v3
                v2 = ic2 + a2 * ic1 + a3 * v3
                ic1 = v1 + v1 - ic1
                ic2 = v2 + v2 - ic2
                out[i] = v2
        elif mode == "bp":
            for i in range(s, e):
                v3 = x[i] - ic2
                v1 = a1 * ic1 + a2 * v3
                v2 = ic2 + a2 * ic1 + a3 * v3
                ic1 = v1 + v1 - ic1
                ic2 = v2 + v2 - ic2
                out[i] = v1
        else:
            for i in range(s, e):
                xi = x[i]
                v3 = xi - ic2
                v1 = a1 * ic1 + a2 * v3
                v2 = ic2 + a2 * ic1 + a3 * v3
                ic1 = v1 + v1 - ic1
                ic2 = v2 + v2 - ic2
                out[i] = xi - k * v1 - v2
    return out

def onepole_hp(x, fc):
    """Gentle 6 dB/oct high-pass; used as a DC blocker."""
    a = math.exp(-TAU * fc / SR)
    out = [0.0] * len(x)
    px = py = 0.0
    i = 0
    for v in x:
        py = a * (py + v - px)
        px = v
        out[i] = py
        i += 1
    return out

def sweep(x, start, end, f0, f1, mode="lp", q=0.9, wet_in=0.0, wet_out=0.0):
    """Filter x[start:end] in place, cutoff gliding exponentially f0 -> f1.

    wet_in / wet_out (fractions of the region) crossfade from / back to the
    dry signal, so the region's edges are seamless.
    """
    start, end = max(0, start), min(len(x), end)
    n = end - start
    if n <= 0:
        return
    seg = x[start:end]
    r = f1 / f0
    y = svf(seg, lambda i: f0 * r ** (i / n), q, mode, 32)
    a = max(1, int(wet_in * n))
    b = max(1, int(wet_out * n))
    out = []
    for i in range(n):
        w = 1.0
        if wet_in and i < a:
            w = i / a
        if wet_out and i > n - b:
            w = min(w, (n - i) / b)
        out.append(seg[i] + (y[i] - seg[i]) * w)
    x[start:end] = out

# ------------------------------------------------------- delay and reverb

_COMBS = (1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617)
_ALLPASSES = (556, 441, 341, 225)

def _comb(x, m, fb, damp):
    """Freeverb comb, block-vectorized; the in-loop damping is a 2-tap low-pass."""
    n = len(x)
    a = fb * (1.0 - damp)
    b = fb * damp
    w = [0.0] * (n + m + 1)  # w[i + m + 1] = W(i)
    for s in range(0, n, m):
        e = min(s + m, n)
        w[s + m + 1:e + m + 1] = [xi + a * p + b * q for xi, p, q in zip(x[s:e], w[s + 1:e + 1], w[s:e])]
    return w[1:n + 1]

def _allpass(x, m, g=0.5):
    n = len(x)
    v = [0.0] * (n + m)  # v[i + m] = V(i)
    for s in range(0, n, m):
        e = min(s + m, n)
        v[s + m:e + m] = [xi + g * p for xi, p in zip(x[s:e], v[s:e])]
    return [p - xi for xi, p in zip(x, v)]

def reverb(x, size=0.7, damp=0.45, scale=1.0, spread=23):
    """Freeverb (8 combs + 4 allpasses per side): mono in, (left, right) wet out.

    size 0..1 maps to comb feedback 0.7..0.98; scale stretches every delay
    (0.3 = small room, 1.5 = hall). Output is roughly level-matched to input.
    """
    fb = 0.7 + 0.28 * size
    r = SR / 44100 * scale
    outs = []
    for off in (0, spread):
        acc = None
        for m in _COMBS:
            c = _comb(x, max(8, int((m + off) * r)), fb, damp)
            acc = c if acc is None else [p + q for p, q in zip(acc, c)]
        for m in _ALLPASSES:
            acc = _allpass(acc, max(8, int((m + off) * r)))
        outs.append(acc)
    g = math.sqrt((1 - fb * fb) / 8.0) * 1.4
    return [v * g for v in outs[0]], [v * g for v in outs[1]]

def pingpong(x, d, fb=0.5, damp=0.35):
    """Ping-pong delay, mono in -> (left, right) wet; first echo left at d samples, then alternating."""
    n = len(x)
    xp = [0.0] * d + x
    a = [0.0] * (n + d + 1)  # a[i + d + 1] = A(i)
    b = [0.0] * (n + d + 1)
    c0 = fb * (1.0 - damp)
    c1 = fb * damp
    for s in range(0, n, d):
        e = min(s + d, n)
        av = [xi + c0 * p + c1 * q for xi, p, q in zip(xp[s:e], b[s + 1:e + 1], b[s:e])]
        bv = [c0 * p + c1 * q for p, q in zip(a[s + 1:e + 1], a[s:e])]
        a[s + d + 1:e + d + 1] = av
        b[s + d + 1:e + d + 1] = bv
    return a[d + 1:], b[d + 1:]

# --------------------------------------------------------------- dynamics

def softclip(x, drive=1.5):
    norm = 1.0 / _tanh(drive)
    return [_tanh(drive * v) * norm for v in x]

def compress(left, right, thresh_db=-12.0, ratio=2.0, attack=0.01, release=0.15, knee_db=6.0, block=64):
    """Stereo-linked RMS bus compressor at control rate (one gain per block, interpolated)."""
    n = len(left)
    pw = [a * a + b * b for a, b in zip(left, right)]
    ca = math.exp(-block / (attack * SR))
    cr = math.exp(-block / (release * SR))
    slope = 1.0 / ratio - 1.0
    half = knee_db / 2
    gr = 0.0
    gains = []
    # warm the detector on the first second so the start isn't uncompressed
    for warm in (True, False):
        for s in range(0, n if not warm else min(n, SR), block):
            p = sum(pw[s:s + block]) / (2 * block)
            db = 10 * math.log10(p + 1e-12)
            over = db - thresh_db
            if over <= -half:
                want = 0.0
            elif over < half:
                want = slope * (over + half) ** 2 / (2 * knee_db)
            else:
                want = slope * over
            c = ca if want < gr else cr
            gr = c * gr + (1 - c) * want
            if not warm:
                gains.append(10 ** (gr / 20))
    ramp = [k / block for k in range(block)]
    g = []
    prev = gains[0]
    for cur in gains:
        d = cur - prev
        g.extend([prev + d * r for r in ramp])
        prev = cur
    return [a * b for a, b in zip(left, g)], [a * b for a, b in zip(right, g)]

def mono_below(left, right, fc=120.0):
    """Collapse the side signal below fc (24 dB/oct), so the low end is mono."""
    mid = [(a + b) * 0.5 for a, b in zip(left, right)]
    side = [(a - b) * 0.5 for a, b in zip(left, right)]
    c = hp(fc)
    side = biquad(biquad(side, c), c)
    return [m + s for m, s in zip(mid, side)], [m - s for m, s in zip(mid, side)]

# --------------------------------------------------------- glitch effects

def crush(x, bits=8, hold=2):
    """Bit and sample-rate reduction (intentionally aliasing)."""
    q = float(2 ** (bits - 1))
    return [round(x[i - i % hold] * q) / q for i in range(len(x))]

def stutter(x, start, slice_n, repeats, decay=0.9, edge=48):
    """Beat-repeat in place: x[start:start+slice_n] repeated `repeats` times."""
    sl = x[start:start + slice_n]
    if len(sl) < slice_n:
        return
    sl = fade(sl[:], edge, edge)
    for r in range(repeats):
        a = start + r * slice_n
        if a + slice_n > len(x):
            break
        x[a:a + slice_n] = [v * decay ** r for v in sl]

def tapestop(x, start, n, curve=1.6):
    """Tape stop in place over x[start:start+n]: playback slows to zero."""
    seg = x[start:start + n]
    n = len(seg)
    if n < 2:
        return
    out = []
    pos = 0.0
    for i in range(n):
        j = int(pos)
        if j + 1 >= n:
            out.append(0.0)
        else:
            f = pos - j
            out.append(seg[j] + (seg[j + 1] - seg[j]) * f)
        pos += (1 - i / n) ** curve
    fade(out, 0, min(n, 480))
    x[start:start + n] = out
```

## File: sfx/loudness.py
size: 2285
sha: 6dce8971f3a3171560d6d4329a698f05e1ea2b78
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/loudness.py

```text
"""Integrated loudness (ITU-R BS.1770-4, EBU R128 gating) for 48 kHz stereo."""

import math

# K-weighting at 48 kHz: high shelf, then high-pass (coefficients from BS.1770).
_SHELF = ((1.53512485958697, -2.69169618940638, 1.19839281085285), (-1.69065929318241, 0.73248077421585))
_HIGHPASS = ((1.0, -2.0, 1.0), (-1.99004745483398, 0.99007225036621))

def _biquad(x, coeffs):
    (b0, b1, b2), (a1, a2) = coeffs
    y = [0.0] * len(x)
    x1 = x2 = y1 = y2 = 0.0
    for i, xi in enumerate(x):
        yi = b0 * xi + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
        x2, x1, y2, y1 = x1, xi, y1, yi
        y[i] = yi
    return y

def _kernels(taps=8):
    out = []
    for frac in (0.25, 0.5, 0.75):
        k = []
        for j in range(-taps + 1, taps + 1):
            d = j - frac
            k.append((j, math.sin(math.pi * d) / (math.pi * d) * 0.5 * (1 + math.cos(math.pi * d / taps))))
        out.append(k)
    return out

_TP_KERNELS = _kernels()

def true_peak(x, near=0.7):
    """Linear true peak: 4x windowed-sinc interpolation around samples above near × sample peak."""
    peak = max(map(abs, x)) or 0.0
    lim = near * peak
    tp = peak
    for i in range(8, len(x) - 8):
        if abs(x[i]) < lim and abs(x[i + 1]) < lim:
            continue
        for kernel in _TP_KERNELS:
            v = abs(sum(c * x[i + j] for j, c in kernel))
            if v > tp:
                tp = v
    return tp

def integrated_lufs(left, right, sr=48000):
    """Integrated loudness in LUFS; -inf for silence."""
    if sr != 48000:
        raise ValueError("K-weighting coefficients here are for 48 kHz")
    kl = _biquad(_biquad(left, _SHELF), _HIGHPASS)
    kr = _biquad(_biquad(right, _SHELF), _HIGHPASS)

    cs = [0.0]
    acc = 0.0
    for a, b in zip(kl, kr):
        acc += a * a + b * b
        cs.append(acc)

    block, hop = int(0.4 * sr), int(0.1 * sr)
    z = [(cs[i + block] - cs[i]) / block for i in range(0, len(kl) - block + 1, hop)]
    lufs = lambda power: -0.691 + 10 * math.log10(power)

    above_abs = [p for p in z if p > 0 and lufs(p) > -70]
    if not above_abs:
        return float("-inf")
    relative = lufs(sum(above_abs) / len(above_abs)) - 10
    gated = [p for p in above_abs if lufs(p) > relative]
    return lufs(sum(gated) / len(gated))
```

## File: sfx/mix.py
size: 4659
sha: 358294bb95f375fab94d91303120c7610351b446
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/mix.py

```text
"""Mixer: place sounds in time, master to a loudness target, write a WAV."""

import math
import os
import random
import struct
import wave

from .loudness import integrated_lufs, true_peak
from .synth import SR, key

DRIVES = (1.2, 1.5, 1.8, 2.0, 2.2, 2.5, 3.0, 4.0)

class Mix:
    def __init__(self, duration, seed=11):
        random.seed(seed)
        self.duration = duration
        self.n = int(SR * duration)
        self.left = [0.0] * self.n
        self.right = [0.0] * self.n
        self.count = 0
        self._out = None

    def put(self, t, samples, gain=1.0, pan=0.0):
        """Place mono samples at t seconds. pan: -1 left, 0 centre, +1 right (equal power)."""
        ang = (pan + 1) * math.pi / 4
        gl, gr = gain * math.cos(ang), gain * math.sin(ang)
        i0 = int(t * SR)
        for k, s in enumerate(samples):
            i = i0 + k
            if 0 <= i < self.n:
                self.left[i] += s * gl
                self.right[i] += s * gr
        self.count += 1

    def put_stereo(self, t, left, right, gain=1.0):
        """Place a stereo pair (e.g. from synth.pad) at t seconds."""
        i0 = int(t * SR)
        for k, (a, b) in enumerate(zip(left, right)):
            i = i0 + k
            if 0 <= i < self.n:
                self.left[i] += a * gain
                self.right[i] += b * gain
        self.count += 1

    def typing(self, start, end, chars, gain=(0.16, 0.26), jitter=0.006, spread=0.3):
        """One keystroke per character, evenly spaced from start to end, humanized."""
        for k in range(chars):
            tk = start + (end - start) * k / chars
            self.put(tk + random.uniform(-jitter, jitter), key(), random.uniform(*gain), random.uniform(-spread, spread))

    def master(self, target_lufs=-14.0, ceiling_db=-2.0, fade_out=0.25):
        """Soft-clip and set gain so the mix hits target_lufs with true peak <= ceiling_db.

        Drive only rises when the ceiling would otherwise be crossed. The ceiling
        leaves room for AAC overshoot, so the muxed MP4 stays under -1 dBTP. If no
        drive reaches the target within the ceiling, the mix comes out quieter
        rather than crushed.
        """
        peak = max(max(map(abs, self.left)), max(map(abs, self.right))) or 1.0
        fade_n = int(fade_out * SR)
        fade = [1.0 if i < self.n - fade_n else (self.n - i) / fade_n for i in range(self.n)]
        ceiling = 10 ** (ceiling_db / 20)

        best = None
        for drive in DRIVES:
            norm = math.tanh(drive)
            left = [math.tanh(drive * x / peak) / norm * f for x, f in zip(self.left, fade)]
            right = [math.tanh(drive * x / peak) / norm * f for x, f in zip(self.right, fade)]
            gain = 10 ** ((target_lufs - integrated_lufs(left, right)) / 20)
            if gain > ceiling:
                continue
            tp = max(true_peak(left), true_peak(right))
            fit = min(gain, ceiling / tp)
            if best is None or fit / gain > best[0]:
                best = (fit / gain, drive, left, right, fit, tp)
            if gain * tp <= ceiling:
                break
        if best is None:
            drive = DRIVES[0]
            norm = math.tanh(drive)
            left = [math.tanh(drive * x / peak) / norm * f for x, f in zip(self.left, fade)]
            right = [math.tanh(drive * x / peak) / norm * f for x, f in zip(self.right, fade)]
            tp = max(true_peak(left), true_peak(right))
            best = (0, drive, left, right, ceiling / tp, tp)
        _, drive, left, right, gain, tp = best

        self._out = ([x * gain for x in left], [x * gain for x in right])
        lufs = integrated_lufs(*self._out)
        if lufs < target_lufs - 0.5:
            print(
                f"warning: {lufs:.1f} LUFS, short of {target_lufs}: the mix is too sparse to get louder "
                "without clipping. Add sustained sound (a pad, longer tails) or accept it quieter."
            )
        return {"lufs": lufs, "peak_db": 20 * math.log10(gain * tp), "drive": drive}

    def write(self, path):
        """Write 16-bit stereo WAV; masters with defaults first if master() wasn't called."""
        if self._out is None:
            self.master()
        os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
        left, right = self._out
        with wave.open(path, "wb") as wf:
            wf.setnchannels(2)
            wf.setsampwidth(2)
            wf.setframerate(SR)
            frames = bytearray()
            for a, b in zip(left, right):
                frames += struct.pack("<hh", int(a * 32767), int(b * 32767))
            wf.writeframes(bytes(frames))
```

## File: sfx/samples/fs-258269-mouth-pop.ogg
size: 7982
sha: 269f5784c0187e5221a8752cc2040fbb02e7a2cf
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-258269-mouth-pop.ogg

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-258269-mouth-pop.ogg

## File: sfx/samples/fs-323432-typing.ogg
size: 61078
sha: 3a13e8855621d168b3f25f73fbce0a1af17f986e
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-323432-typing.ogg

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-323432-typing.ogg

## File: sfx/samples/fs-329533-room-tone-in-an-apartment-livi.ogg
size: 144788
sha: aa2f3a906e2f9de543ef72bfe7430b6573a2d126
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-329533-room-tone-in-an-apartment-livi.ogg

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-329533-room-tone-in-an-apartment-livi.ogg

## File: sfx/samples/fs-329569-roomtone-bedroom-yew.ogg
size: 170176
sha: 8a95cf27cac491025acc804eca7ec9b97866c10c
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-329569-roomtone-bedroom-yew.ogg

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-329569-roomtone-bedroom-yew.ogg

## File: sfx/samples/fs-339521-vhs-rewind-forward-and-eject.ogg
size: 35806
sha: 0db8f8b9031a4c9b32e481b9cde9c9b37a4d0b02
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-339521-vhs-rewind-forward-and-eject.ogg

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-339521-vhs-rewind-forward-and-eject.ogg

## File: sfx/samples/fs-349504-triangle-open-04.ogg
size: 30968
sha: 4fd791a6c791a88d1dc39e9ed2727b3452317b1f
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-349504-triangle-open-04.ogg

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-349504-triangle-open-04.ogg

## File: sfx/samples/fs-416777-digital-typing-wav.ogg
size: 18236
sha: ab86f2e73fe922a3b211c81429ecae20135f3649
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-416777-digital-typing-wav.ogg

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-416777-digital-typing-wav.ogg

## File: sfx/samples/fs-426767-apartment-room-tone-wav.ogg
size: 171086
sha: c10315fe079aa437954b02ecb9b8db85dfa80eff
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-426767-apartment-room-tone-wav.ogg

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-426767-apartment-room-tone-wav.ogg

## File: sfx/samples/fs-447909-keyboard-typing.ogg
size: 49484
sha: 9c3e272b4a5fbde47c8510b33975c4e2b36d6adc
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-447909-keyboard-typing.ogg

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-447909-keyboard-typing.ogg

## File: sfx/samples/fs-447910-plop.ogg
size: 6039
sha: fda1ba261e7303e150915bd7ebb008448351e7ca
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-447910-plop.ogg

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-447910-plop.ogg

## File: sfx/samples/fs-79388-keyboard-typing-wav.ogg
size: 68989
sha: 5f52d61a5ec017b59b053320e0abce196a9b3c16
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-79388-keyboard-typing-wav.ogg

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-79388-keyboard-typing-wav.ogg

## File: sfx/samples/fs-824189-pop-in-sfx.ogg
size: 7620
sha: 9388203281aeafd2b747e36a9fbaba315efcb3b6
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-824189-pop-in-sfx.ogg

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/fs-824189-pop-in-sfx.ogg

## File: sfx/samples/SOURCES.md
size: 1968
sha: 1d81701092748ea13ea31896c402a1f1de3cce00
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/samples/SOURCES.md

```text
# Recorded samples

Real recordings for what synthesis does badly: keyboards, room tone, a mouth pop, a tape eject.
All 12 are CC0 1.0. Each Freesound page was checked for the CC0 licence on 2026-10-18.
Taken from the `sound-effects` skill of github.com/Barty-Bart/motion-graphics (MIT), which had already trimmed them, normalised them (one-shots to -3 dBFS peak, ambience to -35 LUFS) and encoded them as OGG.

Load one in a cue sheet with `sample("<file>")` from `sfx.synth`.

| File | Use | Author | Source |
|---|---|---|---|
| fs-447909-keyboard-typing.ogg | keyboard, 3.8 s | Breviceps | https://freesound.org/people/Breviceps/sounds/447909/ |
| fs-323432-typing.ogg | keyboard, 4 s | Alivvie | https://freesound.org/people/Alivvie/sounds/323432/ |
| fs-79388-keyboard-typing-wav.ogg | keyboard, 4 s | j1987 | https://freesound.org/people/j1987/sounds/79388/ |
| fs-416777-digital-typing-wav.ogg | digital typing, 1 s | Sky_Motion | https://freesound.org/people/Sky_Motion/sounds/416777/ |
| fs-329533-room-tone-in-an-apartment-livi.ogg | room tone, 17 s | leonelmail | https://freesound.org/people/leonelmail/sounds/329533/ |
| fs-426767-apartment-room-tone-wav.ogg | room tone, 20 s | cMilan | https://freesound.org/people/cMilan/sounds/426767/ |
| fs-329569-roomtone-bedroom-yew.ogg | room tone, 20 s | leonelmail | https://freesound.org/people/leonelmail/sounds/329569/ |
| fs-258269-mouth-pop.ogg | mouth pop | jcallison | https://freesound.org/people/jcallison/sounds/258269/ |
| fs-447910-plop.ogg | plop | Breviceps | https://freesound.org/people/Breviceps/sounds/447910/ |
| fs-824189-pop-in-sfx.ogg | pop-in | Sadiquecat | https://freesound.org/people/Sadiquecat/sounds/824189/ |
| fs-349504-triangle-open-04.ogg | triangle ding, 4 s | cabled_mess | https://freesound.org/people/cabled_mess/sounds/349504/ |
| fs-339521-vhs-rewind-forward-and-eject.ogg | VHS rewind and eject, 4 s | lucaslima | https://freesound.org/people/lucaslima/sounds/339521/ |

```

## File: sfx/score_kit.py
size: 7934
sha: ad2299197eed036096eb6214fe2381e4ac408cd4
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/score_kit.py

```text
"""Shared pieces for the figure scores: a parameterised lo-fi bed and a handful of accents.

A figure's score.py imports this (it sits next to the sfx engine on sys.path when run through
run_sfx.py) and adds its own accents on the tour's seconds.
"""

import math
import os
import random

from sfx.dsp import add, reverb, svf, zeros
from sfx.synth import SR, bandnoise, bass, blip, epiano, hat, kick, snare

def env_fade(x, fin, fout):
    n = len(x)
    a = max(1, int(fin * SR))
    b = max(1, int(fout * SR))
    for i in range(min(a, n)):
        x[i] *= i / a
    for i in range(min(b, n)):
        x[n - 1 - i] *= i / b
    return x

def bed(dur, bpm, chords, motif=(), motif_voice="epiano", bass_pattern=((0, 1.15, 0.9), (2.5, 0.85, 0.6)),
        kicks=(0, 2.75), snares=(1, 3), hats=8, swing=0.12, drums_from=1, keys_lp=4200, crackle_level=0.0, keys_vel=0.9):
    """A looping bed: e-piano chords (one per bar), a bass line, soft drums and an optional motif every other bar.

    chords: [([note Hz, ...], root Hz), ...]. bass_pattern: (beat, length in beats, velocity). Returns stereo keys
    plus mono bass and drum stems, all faded in and out.
    """
    if os.environ.get("FIG_VARIANT"):
        # the second-upload variant: three semitones up, a touch faster, the other motif voice
        up = 2 ** (3 / 12)
        chords = [([f * up for f in notes], root * up) for notes, root in chords]
        motif = [(bt, f * up) for bt, f in motif]
        motif_voice = "blip" if motif_voice == "epiano" else "epiano"
        bpm *= 1.06
    beat = 60 / bpm
    bar = 4 * beat
    n = int(dur * SR)
    keys, low, drums = zeros(n), zeros(n), zeros(n)
    bars = int(dur / bar) + 1
    for b in range(bars):
        t = 0.12 + b * bar
        notes, root = chords[b % len(chords)]
        for i, f in enumerate(notes):
            add(keys, epiano(f, 2.9 * 84 / bpm + 0.4, keys_vel), int((t + 0.012 * i) * SR))
            if i >= 2:
                add(keys, epiano(f, 1.3, keys_vel * 0.5), int((t + 1.5 * beat + 0.01 * i) * SR))
        if b % 2 == 1:
            for bt, f in motif:
                v = epiano(f, 1.1, 0.34, 0.6) if motif_voice == "epiano" else blip(f, 0.32, 0.11)
                add(keys, v, int((t + bt * beat) * SR), 1.0 if motif_voice == "epiano" else 0.5)
        for bt, length, vel in bass_pattern:
            add(low, bass(root, length * beat, vel), int((t + bt * beat) * SR))
        if drums_from <= b < bars - 2:
            for bt in kicks:
                add(drums, kick(1.0 if bt == 0 else 0.75), int((t + bt * beat) * SR))
            for bt in snares:
                add(drums, snare(0.55), int((t + bt * beat) * SR))
            for e in range(hats):
                sw = swing * beat if (hats == 8 and e % 2) else 0.0
                add(drums, hat(0.5 if e % 2 == 0 else 0.32), int((t + e * 4 * beat / hats + sw) * SR))
    keys = svf(keys, keys_lp, 0.6, "lp")
    wl, wr = reverb(keys, size=0.6, damp=0.5, scale=0.9)
    left = [k * 0.8 + w * 0.36 for k, w in zip(keys, wl)]
    right = [k * 0.8 + w * 0.36 for k, w in zip(keys, wr)]
    low = svf(low, 900, 0.7, "lp")
    drums = svf(drums, 7000, 0.6, "lp")
    for buf in (left, right, low, drums):
        env_fade(buf, 1.0, 2.2)
    return left, right, low, drums

def thud(f=95, decay=0.12, dur=0.4):
    """A soft body hitting something: a plush toy landing, a knock."""
    n = int(dur * SR)
    return [math.sin(2 * math.pi * f * k / SR) * math.exp(-k / (decay * SR)) * min(1, k / 40) for k in range(n)]

def motor(dur, f0=220, grit_hz=1400, wobble=17):
    """A small electric motor: a buzzy tone plus brush noise, faded at both ends."""
    n = int(dur * SR)
    grit = bandnoise(n, lambda k: grit_hz, q=0.8)
    out = []
    for k in range(n):
        t = k / SR
        m = 1 + 0.15 * math.sin(2 * math.pi * wobble * t)
        s = sum(math.sin(2 * math.pi * f0 * h * t) / h for h in (1, 2, 3, 4))
        out.append(m * (0.35 * s + 0.5 * grit[k]))
    return env_fade(out, 0.06, 0.1)

def airy(dur, f0, f1, level=1.0):
    """Moving air: band noise sweeping from f0 to f1 with a soft swell."""
    n = int(dur * SR)
    x = bandnoise(n, lambda k: f0 * (f1 / f0) ** (k / n), q=0.7)
    return [x[k] * level * 1.4 * math.sin(math.pi * k / n) for k in range(n)]

def bang(level=1.0, seed=0):
    """A small explosion: a low punch and a burst of dark noise."""
    rnd = random.Random(seed)
    n = int(0.42 * SR)
    noise = bandnoise(n, lambda k: 700 + 1600 * math.exp(-k / (0.03 * SR)), q=0.5)
    out = []
    ph = 0.0
    for k in range(n):
        t = k / SR
        f = 48 + 90 * math.exp(-t / 0.03)
        ph += 2 * math.pi * f / SR
        s = math.sin(ph) * math.exp(-t / 0.12) * 1.1 + noise[k] * 1.8 * math.exp(-t / 0.05)
        out.append(level * math.tanh(1.5 * s) * (0.92 + 0.08 * rnd.random()))
    return out

def puff(level=1.0):
    """A breath of gas out of a pipe."""
    n = int(0.3 * SR)
    x = bandnoise(n, lambda k: 900, q=0.6)
    return [level * x[k] * 1.6 * math.sin(math.pi * min(1, k / (0.06 * SR)) / 2) * math.exp(-k / (0.09 * SR)) for k in range(n)]

def rumble(dur, f_at, level=1.0):
    """An engine's low hum whose pitch follows f_at(t)."""
    n = int(dur * SR)
    out = []
    ph = 0.0
    for k in range(n):
        f = f_at(k / SR)
        ph += 2 * math.pi * f / SR
        out.append(level * (math.sin(ph) + 0.4 * math.sin(2 * ph) + 0.2 * math.sin(3 * ph)))
    return env_fade(out, 0.3, 0.4)

_SEMI = {"C": -9, "D": -7, "E": -5, "F": -4, "G": -2, "A": 0, "B": 2}

def hz(name):
    """Note name to frequency: "A4" = 440, "F#3", "Bb2"."""
    base, rest = name[0], name[1:]
    shift = 0
    while rest and rest[0] in "#b":
        shift += 1 if rest[0] == "#" else -1
        rest = rest[1:]
    return 440.0 * 2 ** ((_SEMI[base] + shift + 12 * (int(rest) - 4)) / 12)

def chords(*specs):
    """chords("Fmaj7:F3 A3 C4 E4/F2", ...) -> bed() chord list; the part after / is the bass root."""
    out = []
    for spec in specs:
        notes, root = spec.split(":", 1)[1].split("/")
        out.append(([hz(n) for n in notes.split()], hz(root.strip())))
    return out

def beep(freq, dur=0.12, level=1.0):
    """A clean electronic beep: a keypad, a timer, a piezo horn."""
    n = int(dur * SR)
    a = max(1, int(0.004 * SR))
    return [level * math.sin(2 * math.pi * freq * k / SR) * min(1, k / a, (n - k) / a) for k in range(n)]

def rattle(dur, rate=18.0, f=3200, seed=0, level=1.0):
    """Many small hard things settling: coins in a box, shavings, capsules."""
    rnd = random.Random(seed)
    n = int(dur * SR)
    out = zeros(n)
    t = 0.0
    while t < dur:
        k = int(t * SR)
        tick = [math.sin(2 * math.pi * f * (0.7 + 0.6 * rnd.random()) * j / SR) * math.exp(-j / (0.004 * SR)) for j in range(int(0.03 * SR))]
        add(out, tick, k, level * (0.4 + 0.6 * rnd.random()) * math.sin(math.pi * min(1.0, t / dur)))
        t += rnd.expovariate(rate)
    return out

def hiss(dur, fc=5200, level=1.0):
    """Steam or air escaping: bright band noise with a soft swell."""
    n = int(dur * SR)
    x = bandnoise(n, lambda k: fc, q=0.5)
    return [x[k] * level * 1.3 * min(1, k / (0.15 * SR), (n - k) / (0.25 * SR)) for k in range(n)]

def spans(tl, field, pred):
    """Intervals (t0, t1) in seconds where pred(sampled value of field) holds, from a timeline made with --sample."""
    i = tl["fields"].index(field)
    rate, out, start = tl["rate"], [], None
    for k, row in enumerate(tl["samples"]):
        on = bool(pred(row[i]))
        if on and start is None:
            start = k / rate
        elif not on and start is not None:
            out.append((start, k / rate))
            start = None
    if start is not None:
        out.append((start, len(tl["samples"]) / rate))
    return out

def rising(tl, field, pred):
    """Moments where pred(sampled value) turns true."""
    return [a for a, _ in spans(tl, field, pred)]
```

## File: sfx/synth.py
size: 10134
sha: 28089ceef43a2cd1e61f11d8dc15fc1d0143a01c
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sfx/synth.py

```text
"""Sound generators.

Every generator returns mono samples (a list of floats, roughly -1..1) at SR.
The first line of each docstring says what it's for; `python run_sfx.py --list`
prints them. To add a sound, write a function here in the same shape.
Use the module-level `random` for noise so renders stay reproducible.
"""

import array
import bisect
import math
import os
import random
import subprocess

SR = 48000
FFMPEG = r"C:\Users�ser_\.claude\bin\ffmpeg.exe"
SAMPLES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "samples")

def sample(name, start=0.0, dur=None):
    """Recorded sound from sfx/samples (or any path): keyboards, room tone. start/dur trim it in seconds."""
    path = name if os.path.isabs(name) else os.path.join(SAMPLES, name)
    cmd = [FFMPEG, "-v", "error", "-ss", str(start), "-i", path]
    if dur is not None:
        cmd += ["-t", str(dur)]
    cmd += ["-ac", "1", "-ar", str(SR), "-f", "s16le", "-"]
    raw = subprocess.run(cmd, check=True, capture_output=True).stdout
    pcm = array.array("h")
    pcm.frombytes(raw[: len(raw) // 2 * 2])
    return [x / 32768 for x in pcm]

def bandnoise(n, fc_at, q=0.8):
    """Building block: white noise through a band-pass, fc_at(k) is the cutoff in Hz at sample k."""
    low = band = 0.0
    out = []
    for k in range(n):
        f = 2 * math.sin(math.pi * min(fc_at(k), SR / 6) / SR)
        x = random.uniform(-1, 1)
        low += f * band
        high = x - low - q * band
        band += f * high
        out.append(band)
    return out

def click(freq=1800, dur=0.05):
    """UI click: a control snapping to a step, a toggle, a tick. Higher freq sounds smaller."""
    n = int(dur * SR)
    out = []
    for k in range(n):
        tt = k / SR
        s = math.sin(2 * math.pi * freq * tt) * math.exp(-tt / 0.012)
        s += random.uniform(-1, 1) * math.exp(-tt / 0.0015) * 0.6
        out.append(s)
    return out

def key():
    """One keystroke; every call is slightly different. For typing use Mix.typing()."""
    n = int(0.018 * SR)
    fc = random.uniform(2400, 4200)
    noise = bandnoise(n, lambda k: fc, q=0.5)
    thock = random.uniform(160, 220)
    return [
        noise[k] * math.exp(-k / (0.0025 * SR)) * 2.2
        + math.sin(2 * math.pi * thock * k / SR) * math.exp(-k / (0.006 * SR)) * 0.5
        for k in range(n)
    ]

def blip(freq, dur=0.35, tau=0.12):
    """Soft tonal blip: a word or element landing, one note of a sequence. Keep freq in key."""
    n = int(dur * SR)
    out = []
    ph = 0.0
    for k in range(n):
        tt = k / SR
        ph += 2 * math.pi * freq / SR
        a = min(1, tt / 0.003) * math.exp(-tt / tau)
        out.append(a * (math.sin(ph) + 0.25 * math.sin(2 * ph) + 0.08 * math.sin(3 * ph)))
    return out

def pop(f0, f1, dur=0.16):
    """Pitched pop: a badge, chip or callout popping in. Sweeps f0 to f1; raise f0 across a sequence."""
    n = int(dur * SR)
    out = []
    ph = 0.0
    for k in range(n):
        tt = k / SR
        f = f0 * (f1 / f0) ** min(1, tt / 0.06)
        ph += 2 * math.pi * f / SR
        a = min(1, tt / 0.002) * math.exp(-tt / 0.045)
        out.append(a * math.sin(ph))
    return out

def chime(freqs, dur=1.8, tau=0.7):
    """Bell chord: success, arrival, a payoff. Pass freqs from the video's key."""
    n = int(dur * SR)
    out = [0.0] * n
    for j, f in enumerate(freqs):
        ph1 = ph2 = 0.0
        g = 1.0 / (1 + j * 0.35)
        for k in range(n):
            tt = k / SR
            ph1 += 2 * math.pi * f / SR
            ph2 += 2 * math.pi * f * 2.76 / SR
            a = min(1, tt / 0.004) * math.exp(-tt / tau)
            out[k] += g * a * (math.sin(ph1) + 0.18 * math.sin(ph2) * math.exp(-tt / 0.15))
    return out

def whoosh(dur, f0, f1, peak=0.6, q=0.9):
    """Air whoosh: movement, reveals, transitions. f0 to f1 sets direction, peak (0..1) where it's loudest."""
    n = int(dur * SR)
    p = math.log(0.5) / math.log(peak)
    noise = bandnoise(n, lambda k: f0 * (f1 / f0) ** (k / n), q=q)
    return [noise[k] * math.sin(math.pi * (k / n) ** p) ** 2 * 1.6 for k in range(n)]

def riser(dur, f0=260, f1=5200):
    """Build-up into a payoff. End it exactly on the hit; one big riser per video."""
    n = int(dur * SR)
    noise = bandnoise(n, lambda k: f0 * (f1 / f0) ** (k / n), q=0.6)
    out = []
    ph = 0.0
    for k in range(n):
        x = k / n
        ph += 2 * math.pi * (180 * (4.0 ** x)) / SR
        tail = min(1, (n - k) / (0.02 * SR))
        out.append((noise[k] * 1.3 + 0.35 * math.sin(ph)) * x ** 2.2 * tail)
    return out

def impact(dur=1.2):
    """Low impact for the hero moment. One per video."""
    n = int(dur * SR)
    out = []
    ph = 0.0
    lp = 0.0
    for k in range(n):
        tt = k / SR
        f = 38 + 60 * math.exp(-tt / 0.07)
        ph += 2 * math.pi * f / SR
        lp += 0.08 * (random.uniform(-1, 1) - lp)
        s = math.sin(ph) * math.exp(-tt / 0.38)
        s += lp * math.exp(-tt / 0.05) * 3.0
        out.append(math.tanh(1.6 * s))
    return out

def scratch(dur):
    """Pencil on paper: a sketch or line drawing itself on."""
    n = int(dur * SR)
    noise = bandnoise(n, lambda k: 3800 + 1400 * math.sin(k / SR * 23), q=0.35)
    out = []
    for k in range(n):
        tt = k / SR
        stroke = max(0.0, math.sin(tt * 2 * math.pi * 7.5)) ** 3
        env = math.sin(math.pi * k / n)
        out.append(noise[k] * stroke * env * 1.4)
    return out

def envelope(points):
    """Helper: piecewise-linear gain curve from [(t, value), ...], for pad volume and ducking."""
    pts = sorted(points)
    ts = [p[0] for p in pts]

    def at(t):
        i = bisect.bisect_right(ts, t)
        if i == 0:
            return pts[0][1]
        if i == len(pts):
            return pts[-1][1]
        (t0, v0), (t1, v1) = pts[i - 1], pts[i]
        return v0 + (v1 - v0) * (t - t0) / (t1 - t0)

    return at

def pad(duration, chords, volume=None, detune=0.6, tremolo=0.23, xfade=0.35):
    """Music bed under everything; returns (left, right) for Mix.put_stereo. chords = [(start_s, [freqs]), ...]."""
    n = int(duration * SR)
    left = [0.0] * n
    right = [0.0] * n
    for ci, (t0, freqs) in enumerate(chords):
        t_next = chords[ci + 1][0] if ci + 1 < len(chords) else None
        i0 = int(t0 * SR)
        i1 = n if t_next is None else min(n, int((t_next + xfade) * SR))
        phl = [0.0] * len(freqs)
        phr = [0.0] * len(freqs)
        for i in range(i0, i1):
            t = i / SR
            w = 1.0 if ci == 0 else min(1.0, (t - t0) / xfade)
            if t_next is not None and t > t_next:
                w *= max(0.0, 1 - (t - t_next) / xfade)
            v = w * (volume(t) if volume else 1.0) * (0.85 + 0.15 * math.sin(2 * math.pi * tremolo * t))
            sl = sr = 0.0
            for j, f in enumerate(freqs):
                phl[j] += 2 * math.pi * (f - detune) / SR
                phr[j] += 2 * math.pi * (f + detune) / SR
                g = 1 / (1 + j * 0.5)
                sl += g * math.sin(phl[j])
                sr += g * math.sin(phr[j])
            left[i] += sl * v
            right[i] += sr * v
    return left, right

# ---------------------------------------------------------------- a small band, for music beds
# Added 2026-10-18 for the coffee-machine film. Soft, warm, low in the mix: the bed sits under the picture,
# the accents (clicks, hiss) sit on top of it.

def epiano(freq, dur=2.6, vel=1.0, decay=1.1):
    """Electric-piano note (tine and warm body), for lo-fi chords. Keep freq in key; vel 0..1."""
    n = int(dur * SR)
    out = [0.0] * n
    w1 = 2 * math.pi * freq / SR
    w2 = 2 * w1
    wt = 2 * math.pi * freq * 6.98 / SR
    wtrem = 2 * math.pi * 4.6 / SR
    sin = math.sin
    exp = math.exp
    for k in range(n):
        t = k / SR
        a = (t / 0.006 if t < 0.006 else 1.0) * exp(-t / decay)
        body = sin(w1 * k) + 0.28 * sin(w2 * k) * exp(-t / 0.5)
        tine = 0.16 * sin(wt * k) * exp(-t / 0.09)
        out[k] = vel * a * (body + tine) * (1 + 0.1 * sin(wtrem * k))
    tail = min(n, int(0.08 * SR))
    for i in range(tail):
        out[n - 1 - i] *= i / tail
    return out

def bass(freq, dur=0.9, vel=1.0):
    """Round bass note: sine plus a little second harmonic, gently saturated. Roots under the chords."""
    n = int(dur * SR)
    out = [0.0] * n
    w = 2 * math.pi * freq / SR
    sin = math.sin
    exp = math.exp
    for k in range(n):
        t = k / SR
        a = (t / 0.012 if t < 0.012 else 1.0) * exp(-t / 0.45)
        out[k] = math.tanh(1.4 * vel * a * (sin(w * k) + 0.22 * sin(2 * w * k)))
    tail = min(n, int(0.05 * SR))
    for i in range(tail):
        out[n - 1 - i] *= i / tail
    return out

def kick(vel=1.0):
    """Soft kick: a sine dropping from 110 to 46 Hz. Lo-fi, not club."""
    n = int(0.32 * SR)
    out = [0.0] * n
    ph = 0.0
    for k in range(n):
        t = k / SR
        f = 46 + 64 * math.exp(-t / 0.035)
        ph += 2 * math.pi * f / SR
        out[k] = vel * math.sin(ph) * math.exp(-t / 0.11) * min(1.0, t / 0.002)
    return out

def snare(vel=1.0):
    """Brushed snare: band-passed noise with a short body tone, for beats 2 and 4."""
    n = int(0.22 * SR)
    noise = bandnoise(n, lambda k: 2100, q=0.55)
    return [vel * (noise[k] * 1.6 * math.exp(-k / (0.06 * SR)) + 0.25 * math.sin(2 * math.pi * 190 * k / SR) * math.exp(-k / (0.025 * SR))) for k in range(n)]

def hat(vel=1.0):
    """Closed hat tick: high band noise, very short. Swing the off-beats."""
    n = int(0.05 * SR)
    noise = bandnoise(n, lambda k: 7600, q=0.4)
    return [vel * noise[k] * 1.8 * math.exp(-k / (0.011 * SR)) for k in range(n)]

def crackle(dur, density=7.0, hiss=0.012):
    """Vinyl crackle bed: sparse clicks over a faint hiss. density is clicks per second."""
    n = int(dur * SR)
    out = [random.uniform(-1, 1) * hiss for _ in range(n)]
    for _ in range(int(dur * density)):
        at = random.randrange(n)
        g = random.uniform(0.05, 0.35) * random.choice((-1, 1))
        for i in range(min(40, n - at)):
            out[at + i] += g * math.exp(-i / 6.0)
    return out
```

## File: sheet.mjs
size: 4405
sha: ef335ca9b5e169b70777649d2155398f15e7cddf
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sheet.mjs

```text
#!/usr/bin/env node
// Contact sheets from any rendered video: the review loop's eyes, and the way to
// read a clip before writing timecodes about it.
//
//   node sheet.mjs --video f.mp4 --timeline timeline.json   every beat, settled (+0.3s)
//   node sheet.mjs --video f.mp4 2.3 4.5 6.9                exact timestamps
//   node sheet.mjs --video f.mp4 --every 1                  one frame per second
//   node sheet.mjs --video f.mp4 --from 3 --to 5 --every 0.2   scrub a transition
//
// Options: --offset 0.3  --cols 4  --rows 2  --size 540  --name beats
// Sheets land next to the video in <video>_sheets/. Every tile is labelled t=.
import {execFileSync} from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import {FFMPEG, FFPROBE} from './paths.mjs';

const argv = process.argv.slice(2);
const opt = (name, fallback) => {
  const i = argv.indexOf(`--${name}`);
  if (i === -1) return fallback;
  const v = argv[i + 1];
  argv.splice(i, 2);
  return v;
};

const video = path.resolve(opt('video', ''));
const timelinePath = opt('timeline', null);
const offset = Number(opt('offset', '0.3'));
const every = opt('every', null);
const from = Number(opt('from', '0'));
const to = opt('to', null);
const cols = Number(opt('cols', '4'));
const rows = Number(opt('rows', '2'));
const size = Number(opt('size', '540'));
let name = opt('name', null);

if (!video || !fs.existsSync(video)) {
  console.error('pass --video <file.mp4>');
  process.exit(1);
}

const probe = (args) =>
  execFileSync(FFPROBE, ['-v', 'error', ...args, '-of', 'csv=p=0', video]).toString().trim();
const duration = Number(probe(['-show_entries', 'format=duration']));

const beats = () => {
  if (!timelinePath) {
    console.error('pass timestamps, --every, or --timeline <file.json>');
    process.exit(1);
  }
  const tl = JSON.parse(fs.readFileSync(timelinePath, 'utf8'));
  const out = [];
  const walk = (v, key) => {
    if (key === 'fps' || key === 'duration') return;
    if (typeof v === 'number') out.push(v);
    else if (Array.isArray(v)) v.forEach((x) => walk(x));
    else if (v && typeof v === 'object') Object.entries(v).forEach(([k, x]) => walk(x, k));
  };
  walk(tl);
  return out.map((t) => t + offset);
};

let times;
if (argv.length) {
  times = argv.map(Number);
  name ??= 'picked';
} else if (every) {
  const end = to === null ? duration : Number(to);
  times = [];
  for (let t = from; t <= end + 1e-6; t += Number(every)) times.push(Number(t.toFixed(3)));
  name ??= `every${every}`;
} else {
  times = beats();
  name ??= 'beats';
}

// ffmpeg returns no frame for a seek this close to the end of the file, so keep 50 ms off it
const eps = 0.05;
times = [...new Set(times.map((t) => Math.min(Math.max(t, 0), duration - eps).toFixed(2)))]
  .map(Number)
  .sort((a, b) => a - b);

const base = video.replace(/\.[^.]+$/, '') + '_sheets';
const dir = path.join(base, name);
fs.rmSync(dir, {recursive: true, force: true});
fs.mkdirSync(dir, {recursive: true});

const ffmpeg = (args) => execFileSync(FFMPEG, ['-loglevel', 'error', '-y', ...args], {stdio: 'pipe'});

let labels = true;
times.forEach((t, i) => {
  const file = path.join(dir, `${String(i).padStart(3, '0')}.png`);
  const scale = `scale=${size}:-2`;
  // Consolas ships with Windows; the colon in the drive letter is escaped for the filter parser.
  const label = `drawtext=fontfile='C\\:/Windows/Fonts/consola.ttf':text='t=${t.toFixed(2)}':x=10:y=10:fontsize=${Math.round(
    size / 24,
  )}:fontcolor=white:box=1:boxcolor=black@0.6:boxborderw=6`;
  const grab = (vf) => ffmpeg(['-ss', String(t), '-i', video, '-frames:v', '1', '-vf', vf, file]);
  if (labels) {
    try {
      grab(`${scale},${label}`);
      return;
    } catch {
      labels = false;
      console.warn('drawtext unavailable in this ffmpeg build; sheets will have no timestamps.');
    }
  }
  grab(scale);
});

const perSheet = cols * rows;
const sheets = [];
for (let s = 0; s * perSheet < times.length; s++) {
  const out = path.join(base, `${name}-${s + 1}.png`);
  ffmpeg([
    '-framerate', '1',
    '-start_number', String(s * perSheet),
    '-i', path.join(dir, '%03d.png'),
    '-vf', `tile=${cols}x${rows}:padding=6:color=0x333333`,
    '-frames:v', '1',
    out,
  ]);
  sheets.push(out);
}

console.log(`${times.length} frames from ${video} → ${sheets.length} sheet(s):`);
sheets.forEach((s) => console.log(`  ${s}`));
```

## File: sims/beats.py
size: 7155
sha: 92888aae0cf05e751d6620248e6716ed818817ee
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/beats.py

```text
#!/usr/bin/env python
"""Beat map for a music track, so a sim can cut and hit on the music.

    python sims/beats.py music/track.mp3 [--duration 30] [--start auto|SECONDS] [-o out.json]

Decodes with ffmpeg, then in pure Python:
- tempo from the autocorrelation of the onset envelope (prefers 100-160 BPM),
- the beat grid, aligned to the onsets,
- downbeats (the beat phase with the most low-band attack),
- kicks (low-band onset peaks, with strength 0..1),
- energy per bar, and the drop (the biggest sustained jump in energy).

With --start auto the 30 s window is placed so the drop lands at ~40 % of the
clip, snapped to a bar. All times in the output are relative to the window start.
"""
import argparse
import json
import math
import os
import subprocess
import sys
from array import array

FFMPEG = os.environ.get('FFMPEG', 'ffmpeg')
SR = 11025
HOP = 256
FPS = SR / HOP  # envelope frames per second (~43)

def decode(path, filt=None):
    cmd = [FFMPEG, '-v', 'error', '-i', path, '-ac', '1', '-ar', str(SR)]
    if filt:
        cmd += ['-af', filt]
    cmd += ['-f', 's16le', '-']
    raw = subprocess.run(cmd, check=True, capture_output=True).stdout
    a = array('h')
    a.frombytes(raw[: len(raw) // 2 * 2])
    return a

def frame_energy(samples):
    n = len(samples) // HOP
    out = array('d', bytes(8 * n))
    for i in range(n):
        s = 0
        for x in samples[i * HOP:(i + 1) * HOP]:
            s += x * x
        out[i] = s / HOP
    return out

def onset(env):
    """Positive change of log energy, lightly smoothed."""
    lg = [math.log(1e-3 + e) for e in env]
    d = [0.0] + [max(0.0, lg[i] - lg[i - 1]) for i in range(1, len(lg))]
    return [0.5 * d[i] + 0.25 * (d[i - 1] if i else 0) + 0.25 * (d[i + 1] if i + 1 < len(d) else 0)
            for i in range(len(d))]

def tempo(nov):
    """Best period in frames from the autocorrelation, weighted toward ~126 BPM."""
    n = len(nov)
    mean = sum(nov) / n
    x = [v - mean for v in nov]
    best, best_lag = -1e18, None
    scores = {}
    lo = int(FPS * 60 / 190)
    hi = int(FPS * 60 / 70) + 1
    for lag in range(lo, hi):
        s = 0.0
        for i in range(n - lag):
            s += x[i] * x[i + lag]
        bpm = 60 * FPS / lag
        w = math.exp(-0.5 * (math.log2(bpm / 126) / 0.6) ** 2)
        scores[lag] = s
        if s * w > best:
            best, best_lag = s * w, lag
    # parabolic refinement
    l = best_lag
    if l - 1 in scores and l + 1 in scores:
        a, b, c = scores[l - 1], scores[l], scores[l + 1]
        den = a - 2 * b + c
        if den:
            l = l + 0.5 * (a - c) / den
    return l

def beat_grid(nov, period):
    """Phase of the grid that sits on the most onset energy, then local snapping."""
    n = len(nov)
    best, best_off = -1, 0
    for off in range(int(period) + 1):
        s, k = 0.0, 0
        while True:
            i = int(round(off + k * period))
            if i >= n:
                break
            s += nov[i]
            k += 1
        if s > best:
            best, best_off = s, off
    beats = []
    while best_off - period >= 0:  # grid back to the very start
        best_off -= period
    k = 0
    while True:
        c = best_off + k * period
        if c >= n:
            break
        i = int(round(c))
        lo, hi = max(0, i - 2), min(n - 1, i + 2)
        j = max(range(lo, hi + 1), key=lambda q: nov[q])
        # only snap if the peak is clearly there, else keep the grid
        beats.append(j if nov[j] > 1.5 * nov[i] + 1e-9 else i)
        k += 1
    return beats

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('track')
    ap.add_argument('--duration', type=float, default=30)
    ap.add_argument('--start', default='auto')
    ap.add_argument('-o', '--out')
    args = ap.parse_args()

    full = decode(args.track)
    low = decode(args.track, 'lowpass=f=140,lowpass=f=140')
    total = len(full) / SR
    env = frame_energy(full)
    envl = frame_energy(low)
    nov = onset(env)
    novl = onset(envl)
    comb = [a + 1.5 * b for a, b in zip(nov, novl)]

    period = tempo(comb)
    bpm = 60 * FPS / period
    beats = beat_grid(comb, period)
    # downbeat phase: most low-band attack
    phase = max(range(4), key=lambda p: sum(novl[b] for b in beats[p::4]))
    downs = beats[phase::4]

    # kicks: low-band onset peaks above an adaptive threshold
    srt = sorted(novl)
    thr = srt[int(len(srt) * 0.9)]
    peak = max(novl) or 1
    kicks = []
    for i in range(1, len(novl) - 1):
        if novl[i] > thr and novl[i] >= novl[i - 1] and novl[i] > novl[i + 1]:
            if not kicks or i - kicks[-1][0] > FPS * 0.18:
                kicks.append((i, novl[i] / peak))

    # energy per bar
    bar_e = []
    for b in range(len(downs) - 1):
        seg = env[downs[b]:downs[b + 1]]
        bar_e.append(math.sqrt(sum(seg) / max(1, len(seg))))
    mx = max(bar_e) or 1
    bar_e = [e / mx for e in bar_e]

    # drop: biggest jump vs the previous 4 bars that holds for the next 2
    drop_bar, best = None, 0
    for b in range(4, len(bar_e) - 2):
        before = sum(bar_e[b - 4:b]) / 4
        after = min(bar_e[b:b + 2])
        j = after - before
        if j > best:
            best, drop_bar = j, b
    drop_t = downs[drop_bar] / FPS if drop_bar is not None else None

    D = args.duration
    if args.start == 'auto':
        if drop_t is not None and best > 0.12:
            want = drop_t - 0.4 * D
        else:
            # loudest window
            nb = max(1, int(D / (240 / bpm)))
            want_bar = max(range(max(1, len(bar_e) - nb)), key=lambda b: sum(bar_e[b:b + nb]))
            want = downs[want_bar] / FPS
        want = min(max(0.0, want), max(0.0, total - D))
        start = min((d / FPS for d in downs), key=lambda d: abs(d - want))
        if start + D > total:
            start = max(0.0, total - D)
    else:
        start = float(args.start)
    end = start + D

    rel = lambda f: round(f / FPS - start, 3)
    inwin = lambda f: start - 1e-6 <= f / FPS < end
    bar_starts = [d for d in downs if inwin(d)]
    out = {
        'track': os.path.abspath(args.track),
        'start': round(start, 3),
        'duration': D,
        'bpm': round(bpm, 2),
        'beats': [rel(b) for b in beats if inwin(b)],
        'downbeats': [rel(d) for d in bar_starts],
        'kicks': [[rel(i), round(s, 3)] for i, s in kicks if inwin(i)],
        'barEnergy': [round(bar_e[downs.index(d)], 3) for d in bar_starts if downs.index(d) < len(bar_e)],
        'drop': round(drop_t - start, 3) if drop_t is not None and start <= drop_t < end else None,
    }
    path = args.out or os.path.splitext(args.track)[0] + f'.beats-{int(round(start))}.json'
    os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
    with open(path, 'w') as f:
        json.dump(out, f)
    print(f'{os.path.basename(args.track)}: {out["bpm"]} bpm, window {start:.2f}-{end:.2f} s, '
          f'drop {"%.2f" % out["drop"] if out["drop"] is not None else "none"} (clip time), '
          f'{len(out["beats"])} beats, {len(out["kicks"])} kicks -> {path}')

if __name__ == '__main__':
    sys.exit(main())
```

## File: sims/build.mjs
size: 5806
sha: cf9f635c6a44d99775b46498bbff7c26b68db19b
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/build.mjs

```text
#!/usr/bin/env node
// Bundles every sims/<name>/main.js into one self-contained HTML file:
// teardown-out/dist/<name>.html (three.js, fonts, CSS inlined). Opens from file://,
// works offline, can be published as-is.
//
//   node sims/build.mjs                 every example in sims/
//   node sims/build.mjs v8-live         one example
//   node sims/build.mjs ./my-machine    your own sim: any folder with a main.js. Import the kit as
//                                       'teardown-kit/runtime.js', 'teardown-kit/util.js' and so on.
//
// An entry may carry build.json with {"swap": {"heavy.glb": "light.glb"}}: every import of a file named heavy.glb is
// served from light.glb in the same folder (a site page with lighter models than its film). {"external": {...}} does
// the same but leaves the model out of the page: the import becomes the string "url:light.glb", the file is copied
// next to the page and listed in <name>.assets.json (the site build copies those too). The page stays small enough
// for link crawlers (X reads the share card from it). {"files": [...]} copies more files from node_modules next to the
// page the same way (decoders a loader fetches at runtime).
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {DEPS, DIST, fromDeps} from '../paths.mjs';

const esbuild = fromDeps('esbuild');

const HERE = path.dirname(fileURLToPath(import.meta.url));
fs.mkdirSync(DIST, {recursive: true});

const font = (weight) => {
  const file = path.join(
    DEPS,
    `@fontsource/jetbrains-mono/files/jetbrains-mono-latin-${weight}-normal.woff2`,
  );
  const b64 = fs.readFileSync(file).toString('base64');
  return `@font-face{font-family:'JetBrains Mono';font-weight:${weight};font-style:normal;font-display:block;src:url(data:font/woff2;base64,${b64}) format('woff2');}`;
};
const inter = fs.readFileSync(path.join(DEPS, '@fontsource-variable/inter/files/inter-latin-wght-normal.woff2')).toString('base64');
const FONTS = [400, 600, 700].map(font).join('\n') +
  `\n@font-face{font-family:'Inter';font-weight:100 900;font-style:normal;font-display:block;src:url(data:font/woff2;base64,${inter}) format('woff2');}`;
const KIT_CSS = fs.readFileSync(path.join(HERE, 'kit/style.css'), 'utf8');

const names = process.argv.slice(2).length
  ? process.argv.slice(2)
  : fs
      .readdirSync(HERE)
      .filter((d) => fs.existsSync(path.join(HERE, d, 'main.js')));

// `teardown-kit/x.js` resolves to sims/kit/x.js, so a sim outside this folder can use the kit.
const kitPlugin = {
  name: 'teardown-kit',
  setup(b) {
    b.onResolve({filter: /^teardown-kit\//}, (args) => ({path: path.join(HERE, 'kit', args.path.slice('teardown-kit/'.length))}));
  },
};

for (const arg of names) {
  const dir = fs.existsSync(path.join(HERE, arg, 'main.js')) ? path.join(HERE, arg) : path.resolve(arg);
  if (!fs.existsSync(path.join(dir, 'main.js'))) {
    console.error(`no main.js in ${dir}`);
    process.exit(1);
  }
  const name = path.basename(dir);
  const t0 = Date.now();
  const cfgFile = path.join(dir, 'build.json');
  const bcfg = fs.existsSync(cfgFile) ? JSON.parse(fs.readFileSync(cfgFile, 'utf8')) : {};
  const swap = bcfg.swap || {};
  const external = bcfg.external || {};
  const assets = [];
  const plugins = Object.keys(external).length
    ? [
        {
          name: 'external',
          setup(b) {
            b.onResolve({filter: /\.glb$/}, (args) => {
              const base = path.basename(args.path);
              if (!external[base]) return undefined;
              const src = path.join(args.resolveDir, path.dirname(args.path), external[base]);
              if (!assets.includes(src)) assets.push(src);
              return {path: external[base], namespace: 'ext'};
            });
            b.onLoad({filter: /.*/, namespace: 'ext'}, (args) => ({contents: `export default ${JSON.stringify('url:' + args.path)};`, loader: 'js'}));
          },
        },
      ]
    : Object.keys(swap).length
    ? [
        {
          name: 'swap',
          setup(b) {
            b.onResolve({filter: /\.glb$/}, (args) => {
              const base = path.basename(args.path);
              if (!swap[base]) return undefined;
              return {path: path.join(args.resolveDir, path.dirname(args.path), swap[base])};
            });
          },
        },
      ]
    : [];
  const res = await esbuild.build({
    plugins: [kitPlugin, ...plugins],
    entryPoints: [path.join(dir, 'main.js')],
    bundle: true,
    nodePaths: [DEPS],
    format: 'esm',
    minify: !process.env.SIMS_DEBUG,
    write: false,
    target: 'es2022',
    legalComments: 'none',
    loader: {'.glb': 'base64', '.woff2': 'base64'},
    logLevel: 'warning',
  });
  const js = res.outputFiles[0].text.replace(/<\/script/g, '<\\/script');
  const css = fs.existsSync(path.join(dir, 'style.css')) ? fs.readFileSync(path.join(dir, 'style.css'), 'utf8') : '';
  const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>${name}</title>
<style>
${FONTS}
${KIT_CSS}
${css}
</style>
</head>
<body>
<div id="app"></div>
<script type="module">
${js}
</script>
</body>
</html>
`;
  const out = path.join(DIST, `${name}.html`);
  fs.writeFileSync(out, html);
  const listFile = path.join(DIST, `${name}.assets.json`);
  assets.push(...(bcfg.files || []).map((f) => path.join(DEPS, f)));
  if (assets.length) {
    for (const a of assets) fs.copyFileSync(a, path.join(DIST, path.basename(a)));
    fs.writeFileSync(listFile, JSON.stringify(assets.map((a) => path.basename(a))));
  } else if (fs.existsSync(listFile)) fs.rmSync(listFile);
  console.log(`${out}  ${(html.length / 1024).toFixed(0)} KB  ${Date.now() - t0} ms`);
}
```

## File: sims/capture.mjs
size: 9418
sha: 4a6f0eb838e679387ec0eb2b7841dab1842d6f87
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/capture.mjs

```text
#!/usr/bin/env node
// Frame-accurate capture of a sim page to mp4: steps window.SIM.render(t) for
// every frame in headless Chromium, screenshots, pipes to ffmpeg, then muxes
// a music track if given.
//
//   node sims/capture.mjs <name> [--config post.json] [--out teardown-out/x.mp4]
//        [--size 1080x1350] [--dpr 1.3333] [--fps 60] [--from 0] [--to <duration>]
//        (--size is the layout in CSS px; --dpr multiplies the output: 1080x1350
//         at the default 4/3 gives a 1440x1800 video. Default 60 fps, crf 16.)
//        [--music music/track.mp3 [--music-start auto|SEC] [--no-sync] [--lufs -21]] [--crf 21]
//        [--still 12.5]   (--still: one PNG, no video)
//        [--stills 0,4,9,14 --cols 3]               (tiled review sheet, every tile labelled t=)
//        [--stills beats [--timeline f.json]]       (one tile per timeline beat, +0.3 s so motion settles)
//        [--stills every:0.2 --from 3 --to 5]       (scrub a transition)
//
//   <name> is a sim built into teardown-out/dist, or a path to any .html
//   that exposes window.seek(seconds) and freezes its clock on ?render
//   (motion-effects, vox-explainer, milestone-reveal, model-showdown). An .html
//   needs --to <seconds> unless it sets window.DURATION.
import {spawn} from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath, pathToFileURL} from 'node:url';
import {CHROMIUM, CHROMIUM_ARGS, DIST, FFMPEG, OUT, PYTHON, playwright} from '../paths.mjs';

const {chromium} = playwright();

const HERE = path.dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const name = argv[0];
const opt = (k, d) => {
  const i = argv.indexOf(`--${k}`);
  return i === -1 ? d : argv[i + 1];
};
if (!name) {
  console.error('usage: node sims/capture.mjs <name|file.html> [--config f.json] [--out f.mp4] [--music f.wav]');
  process.exit(1);
}
const SEEK = name.endsWith('.html');
const html = SEEK ? path.resolve(name) : path.join(DIST, `${name}.html`);
const label = SEEK ? path.basename(name, '.html') : name;
if (!fs.existsSync(html)) {
  console.error(SEEK ? `missing ${html}` : `missing ${html}; run node sims/build.mjs ${name}`);
  process.exit(1);
}
const [W, H] = opt('size', '1080x1350').split('x').map(Number);
const config = opt('config') ? JSON.parse(fs.readFileSync(opt('config'), 'utf8')) : {};
// --music <file> [--music-start auto|SECONDS]: analyse the track (sims/beats.py),
// cut the window so the drop lands ~40% in, and give the beat map to the director.
const music = opt('music');
let beats = null;
if (music) {
  const dur = Number(opt('to', config.duration ?? 30)) - Number(opt('from', 0));
  const ms = opt('music-start', 'auto');
  const cache = path.join(OUT, 'beats', `${path.basename(music).replace(/\.[^.]+$/, '')}-${ms}-${dur}.json`);
  fs.mkdirSync(path.dirname(cache), {recursive: true});
  const {execFileSync} = await import('node:child_process');
  if (!fs.existsSync(cache) || fs.statSync(cache).mtimeMs < fs.statSync(music).mtimeMs) {
    execFileSync(PYTHON, ['-I', path.join(HERE, 'beats.py'), music, '--duration', String(dur), '--start', ms, '-o', cache], {stdio: 'inherit'});
  }
  beats = JSON.parse(fs.readFileSync(cache, 'utf8'));
  if (!opt('no-sync')) config.beats = beats;
}
const still = opt('still');
const stamp = new Date().toISOString().slice(5, 16).replace(/[-:T]/g, '');
const out = path.resolve(opt('out', path.join(OUT, still ? `${label}-${still}.png` : `${label}-${stamp}.mp4`)));
fs.mkdirSync(path.dirname(out), {recursive: true});

// Browser and GPU flags come from ../paths.mjs.
const exe = CHROMIUM;
const browser = await chromium.launch({
  executablePath: exe,
  args: CHROMIUM_ARGS,
});
const dpr = Number(opt('dpr', 4 / 3));
const page = await browser.newPage({viewport: {width: W, height: H}, deviceScaleFactor: dpr});
page.on('console', (m) => m.type() === 'error' && console.error('[page]', m.text()));
page.on('pageerror', (e) => console.error('[page error]', e.message));
await page.addInitScript((cfg) => {
  window.__CAPTURE = true;
  window.__SIM_CONFIG = cfg;
}, config);
await page.goto(pathToFileURL(html).href + (SEEK ? '?render' : ''));
await page.waitForFunction(
  (seek) => (seek ? typeof window.seek === 'function' : window.SIM && window.SIM.ready),
  SEEK,
  {timeout: 60000},
);
await page.evaluate(() => document.fonts.ready);
const info = await page.evaluate((seek) => {
  const gl = document.createElement('canvas').getContext('webgl2');
  const ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
  return {
    duration: seek ? window.DURATION : window.SIM.duration,
    fps: seek ? window.FPS : window.SIM.fps,
    gpu: ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : 'unknown',
  };
}, SEEK);
console.log(`gpu: ${info.gpu}`);
// One entry point for both contracts: a sim's SIM.render(t) or a page's seek(t).
const draw = (t) => page.evaluate(([x, seek]) => (seek ? window.seek(x) : window.SIM.render(x)), [t, SEEK]);
const end = Number(opt('to', info.duration));
if (!Number.isFinite(end)) {
  console.error('no duration: pass --to <seconds> or set window.DURATION in the page');
  process.exit(1);
}

// --stills: a list, `beats` (every number in the timeline, +0.3 s), or `every:<step>`.
const stillTimes = (arg) => {
  if (arg === 'beats') {
    const file = opt('timeline', path.join(HERE, name, 'timeline.json'));
    const tl = JSON.parse(fs.readFileSync(file, 'utf8'));
    const ts = [];
    const walk = (v, key) => {
      if (key === 'fps' || key === 'duration') return;
      if (typeof v === 'number') ts.push(v + 0.3);
      else if (Array.isArray(v)) v.forEach((x) => walk(x));
      else if (v && typeof v === 'object') Object.entries(v).forEach(([k, x]) => walk(x, k));
    };
    walk(tl);
    return [0, ...ts];
  }
  if (arg.startsWith('every:')) {
    const step = Number(arg.slice(6));
    const ts = [];
    for (let t = Number(opt('from', 0)); t <= end + 1e-6; t += step) ts.push(t);
    return ts;
  }
  return arg.split(',').map(Number);
};

const stills = opt('stills');
if (stills) {
  const ts = [...new Set(stillTimes(stills).map((t) => Math.min(Math.max(t, 0), end - 1 / 120).toFixed(2)))]
    .map(Number)
    .sort((a, b) => a - b);
  const dir = out.replace(/\.(png|mp4)$/, '') + '-frames';
  fs.mkdirSync(dir, {recursive: true});
  for (const [i, t] of ts.entries()) {
    await draw(t);
    // The t= label goes into the DOM for the screenshot only, so no ffmpeg font setup is needed.
    await page.evaluate((txt) => {
      const el = document.createElement('div');
      el.id = '__t';
      el.textContent = txt;
      el.style.cssText =
        'position:fixed;right:12px;top:12px;z-index:2147483647;font:600 28px monospace;color:#fff;background:rgba(0,0,0,.65);padding:4px 10px;border-radius:4px';
      document.body.appendChild(el);
    }, `t=${t.toFixed(2)}`);
    await page.screenshot({path: path.join(dir, `${String(i).padStart(3, '0')}.png`)});
    await page.evaluate(() => document.getElementById('__t')?.remove());
  }
  await browser.close();
  const cols = Math.min(ts.length, Number(opt('cols', 3)));
  const sheet = out.endsWith('.png') ? out : out.replace(/\.mp4$/, '') + '-sheet.png';
  const tw = Math.round(W * 0.4);
  const th = Math.round(H * 0.4);
  await new Promise((r) =>
    spawn(FFMPEG, ['-loglevel', 'error', '-y', '-framerate', '1', '-i', path.join(dir, '%03d.png'),
      '-vf', `scale=${tw}:${th},tile=${cols}x${Math.ceil(ts.length / cols)}:padding=4:color=0x333333`, '-frames:v', '1', sheet],
      {stdio: 'inherit'}).on('close', r),
  );
  console.log(`→ ${sheet}  (full frames in ${dir})`);
  process.exit(0);
}

if (still !== undefined) {
  await draw(Number(still));
  await page.screenshot({path: out});
  await browser.close();
  console.log(`→ ${out}`);
  process.exit(0);
}

const fps = Number(opt('fps', 60));
const from = Number(opt('from', 0));
const to = end;
const frames = Math.round((to - from) * fps);
const silent = music ? out.replace(/\.mp4$/, '-silent.mp4') : out;

const ff = spawn(
  FFMPEG,
  ['-loglevel', 'error', '-y', '-f', 'image2pipe', '-framerate', String(fps), '-i', '-',
    '-c:v', 'libx264', '-preset', 'slow', '-crf', String(opt('crf', 16)), '-pix_fmt', 'yuv420p', '-movflags', '+faststart', silent],
  {stdio: ['pipe', 'inherit', 'inherit']},
);
const t0 = Date.now();
for (let i = 0; i < frames; i++) {
  await draw(from + i / fps);
  const buf = await page.screenshot({type: 'jpeg', quality: 97});
  if (!ff.stdin.write(buf)) await new Promise((r) => ff.stdin.once('drain', r));
  if (i % fps === 0) process.stdout.write(`\r${i}/${frames} frames  ${((Date.now() - t0) / 1000).toFixed(0)} s`);
}
ff.stdin.end();
await new Promise((r) => ff.on('close', r));
await browser.close();
console.log(`\r${frames}/${frames} frames  ${((Date.now() - t0) / 1000).toFixed(0)} s`);

if (music) {
  await new Promise((r, j) =>
    spawn(
      FFMPEG,
      ['-loglevel', 'error', '-y', '-i', silent, '-ss', String(beats.start), '-i', music, '-map', '0:v', '-map', '1:a',
        '-c:v', 'copy', '-c:a', 'aac', '-b:a', '256k', '-af', `loudnorm=I=${opt('lufs', -21)}:TP=-3,afade=t=in:d=0.04,afade=t=out:st=${(to - from - 0.6).toFixed(2)}:d=0.6`,
        '-shortest', '-movflags', '+faststart', out],
      {stdio: 'inherit'},
    ).on('close', (c) => (c === 0 ? r() : j(new Error('mux failed')))),
  );
  fs.rmSync(silent);
}
console.log(`→ ${out}`);
```

## File: sims/cessna-film/main.js
size: 135
sha: 10c87f8419b015a7a87b4d80f6ce568a55ee1001
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/cessna-film/main.js

```text
// CESSNA 337 SKYMASTER main landing gear film. Shared code in ../cessna-shared.
import {run} from '../cessna-shared/film.js';

run();
```

## File: sims/cessna-film/timeline.json
size: 18
sha: 2c07c4667d3b64bfa7c880f6ea07f08f128b346a
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/cessna-film/timeline.json

```text
{"duration":26.2}
```

## File: sims/cessna-live/main.js
size: 236
sha: 5d6e34c797339d19d1353ddb9b7eb6e134b287e2
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/cessna-live/main.js

```text
import {runLive} from '../cessna-shared/live.js';

// The Cessna 337 main landing gear as an interactive page for the site: takeoff and landing, the timeline, views,
// display modes, orbit, and an auto tour when left alone.
runLive();
```

## File: sims/cessna-shared/console.js
size: 21616
sha: c21884e15647f15f24efba44103e202ea7b2a5e1
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/cessna-shared/console.js

```text
// The Skymaster gear console, laid out at 1600 x 900: the orange 337 badge and the title,
// the mechanism state with its live schematic, the flight panel, the system overview, the sequence switch, the views,
// the display toggles, the gear operation timeline, floor notes and the labels with leaders into the mechanism.
// update(s) redraws every live part from the frame's state.
export const C = {ink: '#2a2521', mute: '#9b8f80', faint: '#d9cdbb', line: '#eadfcd', panel: 'rgba(252,249,244,.95)', or: '#e3562a', green: '#2fa36b', blue: '#3c79a8'};
export const VIEWS = ['hero', 'front', 'plan', 'top', 'gearbox', 'sidetube'];
export const VIEW_LABEL = {hero: 'HERO', front: 'FRONT', plan: 'PLAN', top: 'TOP ¾', gearbox: 'GEARBOX', sidetube: 'SIDE TUBE'};
export const DISPLAY = ['labels', 'section', 'xray', 'exploded', 'fuselage', 'orbit'];
export const DISPLAY_LABEL = {labels: 'LABELS', section: 'SECTION', xray: 'X-RAY PLATE', exploded: 'EXPLODED', fuselage: 'FUSELAGE', orbit: 'ORBIT'};
const CSS = `
#app{font-family:Inter,system-ui,sans-serif;color:${C.ink};-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}
.ui{position:absolute;inset:0;pointer-events:none}
.mo{font-family:'JetBrains Mono',ui-monospace,monospace}
.cp{position:absolute;background:${C.panel};border:1px solid ${C.line};border-radius:6px;box-shadow:0 2px 10px rgba(120,90,50,.08)}
.ph{position:absolute;display:flex;align-items:center;gap:9px;font:700 10.5px 'JetBrains Mono',monospace;letter-spacing:1.8px}
.ph i{display:block;width:3px;height:13px;background:${C.or}}
.pn{position:absolute;font:500 9.5px 'JetBrains Mono',monospace;color:${C.mute};letter-spacing:1px}
.sm{font:500 9px 'JetBrains Mono',monospace;letter-spacing:1.6px;color:${C.mute};text-transform:uppercase;white-space:nowrap}
.bt{position:absolute;height:21px;padding:0 10px;display:flex;align-items:center;justify-content:center;border:1px solid ${C.faint};border-radius:4px;background:#fffdf9;font:700 9.4px 'JetBrains Mono',monospace;letter-spacing:1.3px;color:${C.ink};white-space:nowrap}
.call{position:absolute;white-space:nowrap}
.call b{display:flex;align-items:center;gap:6px;font:700 9.4px 'JetBrains Mono',monospace;letter-spacing:1.3px;color:${C.ink}}
.call b i{display:block;width:6px;height:6px;background:${C.or}}
.call span{display:block;font:500 7.8px 'JetBrains Mono',monospace;letter-spacing:1.2px;color:${C.mute};margin-top:2px}
.cur{position:absolute;left:0;top:0;z-index:50;filter:drop-shadow(0 1px 2px rgba(0,0,0,.3))}
.ring{position:absolute;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;border:1.5px solid ${C.or};opacity:0;z-index:49}
`;
const el = (h) => {
  const d = document.createElement('div');
  d.innerHTML = h.trim();
  return d.firstElementChild;
};

export function createConsole(app) {
  const style = document.createElement('style');
  style.textContent = CSS;
  document.head.appendChild(style);
  const ui = el(`<div class="ui"></div>`);
  app.appendChild(ui);
  const add = (h) => {
    const n = el(h);
    ui.appendChild(n);
    return n;
  };
  const panel = (x, y, w, h, title, n) => {
    add(`<div class="cp" style="left:${x}px;top:${y}px;width:${w}px;height:${h}px"></div>`);
    add(`<div class="ph" style="left:${x + 14}px;top:${y + 13}px"><i></i>${title}</div>`);
    add(`<div class="pn" style="right:${1600 - x - w + 14}px;top:${y + 15}px">${n}</div>`);
  };

  // ------------------------------------------------------------ header
  add(`<div style="position:absolute;left:0;top:0;width:160px;height:89px;background:${C.or}"></div>`);
  add(`<svg style="position:absolute;left:26px;top:30px" width="46" height="28"><g fill="#fff"><path d="M6 0h7L7 28H0z"/><path d="M19 0h7l-6 28h-7z"/><path d="M32 0h7l-6 28h-7z"/></g><circle cx="43" cy="25" r="3" fill="#fff"/></svg>`);
  add(`<div class="mo" style="position:absolute;left:85px;top:35px;color:#fff;font-size:16px;font-weight:600;letter-spacing:4px">337</div>`);
  add(`<div class="sm" style="position:absolute;left:182px;top:22px;letter-spacing:2.4px">GENERAL AVIATION</div>`);
  add(`<div style="position:absolute;left:181px;top:37px;font-size:33px;font-weight:700;letter-spacing:-.6px;line-height:1">Cessna 337 Skymaster <span style="color:${C.or};font-weight:400">/</span></div>`);
  add(`<div class="mo" style="position:absolute;left:182px;top:77px;font-size:11.2px;letter-spacing:2.2px;color:#6d6357">MAIN LANDING GEAR / RETRACTION MECHANISM</div>`);
  add(`<div class="sm" style="position:absolute;left:1087px;top:49px;letter-spacing:2.6px;font-size:9.6px">TWIN ENGINE · CENTRELINE THRUST</div>`);
  add(`<div class="mo" style="position:absolute;left:1352px;top:47px;color:${C.or};font-size:12px;letter-spacing:-1px">//////</div>`);
  add(`<div style="position:absolute;left:1418px;top:34px;color:${C.or};font-size:39px;font-weight:700;letter-spacing:-.8px;line-height:1">337</div>`);
  add(`<div style="position:absolute;left:1500px;top:33px;width:1px;height:44px;background:${C.faint}"></div>`);
  add(`<div class="sm" style="position:absolute;left:1516px;top:34px;line-height:14px;letter-spacing:2.2px">PUSH<br>PULL<br>PERFORM</div>`);
  {
    let h = '';
    for (let r = 0; r < 3; r++) for (let c = 0; c < 12; c++) h += `<path d="M${c * 25.5 + 4} ${r * 25.5}v8M${c * 25.5} ${r * 25.5 + 4}h8" stroke="${C.or}" stroke-width="1.1" opacity=".75"/>`;
    add(`<svg style="position:absolute;left:808px;top:80px" width="300" height="64">${h}</svg>`);
  }

  // ------------------------------------------------------------ 01 mechanism state
  panel(28, 126, 282, 254, 'MECHANISM STATE', '01');
  const chip = add(`<div style="position:absolute;left:42px;top:166px;width:13px;height:24px;border-radius:2px"></div>`);
  const stTitle = add(`<div style="position:absolute;left:66px;top:164px;font-size:15px;font-weight:600"></div>`);
  const stSub = add(`<div class="sm" style="position:absolute;left:67px;top:183px"></div>`);
  add(`<div style="position:absolute;left:42px;top:202px;width:254px;height:1px;background:${C.line}"></div>`);
  const sch = add(`<svg style="position:absolute;left:42px;top:214px" width="254" height="156"></svg>`);

  // ------------------------------------------------------------ 02 flight
  panel(28, 394, 282, 232, 'FLIGHT', '02');
  const fl = add(`<svg style="position:absolute;left:42px;top:430px" width="254" height="70"></svg>`);
  const bars = ['HEIGHT', 'GEAR', 'STROKE'].map((n, i) => {
    const y = 508 + i * 23;
    add(`<div class="sm" style="position:absolute;left:42px;top:${y}px">${n}</div>`);
    add(`<div style="position:absolute;left:97px;top:${y + 4}px;width:134px;height:4px;background:#efe6d8;border-radius:2px"></div>`);
    const f = add(`<div style="position:absolute;left:97px;top:${y + 4}px;height:4px;background:${C.or};border-radius:2px"></div>`);
    const v = add(`<div class="mo" style="position:absolute;right:${1600 - 296}px;top:${y - 2}px;font-size:11px;font-weight:600"></div>`);
    return {f, v};
  });
  add(`<div style="position:absolute;left:42px;top:578px;width:254px;height:1px;background:${C.line}"></div>`);
  add(`<div class="sm" style="position:absolute;left:42px;top:596px">WHEEL</div>`);
  const wheelIcon = add(`<svg style="position:absolute;left:274px;top:590px" width="22" height="22"><g data-r><circle cx="11" cy="11" r="9" fill="none" stroke="${C.blue}" stroke-width="1.5"/><path d="M11 11V2M11 11l7.8 4.5M11 11l-7.8 4.5" stroke="${C.blue}" stroke-width="1.4"/><circle cx="11" cy="11" r="2.4" fill="${C.blue}"/></g></svg>`);

  // ------------------------------------------------------------ 03 system overview
  panel(1313, 126, 258, 210, 'SYSTEM OVERVIEW', '03');
  add(`<div class="mo" style="position:absolute;left:1327px;top:163px;font-size:10.5px;font-weight:700;letter-spacing:1.6px">MAIN LANDING GEAR</div>`);
  add(`<div class="mo" style="position:absolute;left:1327px;top:183px;width:232px;font-size:9.4px;line-height:1.75;color:#5f564b">One hydraulic actuator, a single rack and pinion, and a universal joint at each end of the output shaft. The side tubes spin; they never swing.</div>`);
  add(`<div style="position:absolute;left:1327px;top:276px;width:232px;height:1px;background:${C.line}"></div>`);
  [['1', 'ACTUATOR'], ['2', 'LEGS'], ['180°', 'SWEEP']].forEach(([v, n], i) => {
    const x = 1327 + i * 85;
    if (i) add(`<div style="position:absolute;left:${x - 8}px;top:286px;width:1px;height:38px;background:${C.line}"></div>`);
    add(`<div style="position:absolute;left:${x}px;top:288px;font-size:20px;font-weight:700">${v}</div>`);
    add(`<div class="sm" style="position:absolute;left:${x}px;top:313px">${n}</div>`);
  });

  // ------------------------------------------------------------ 04 sequence, 05 views, 06 display
  const btn = {};
  panel(1313, 353, 258, 82, 'SEQUENCE', '04');
  add(`<div style="position:absolute;left:1327px;top:390px;width:230px;height:30px;border-radius:5px;background:#efe6d8"></div>`);
  btn.takeoff = add(`<div class="bt" style="left:1330px;top:393px;width:112px;height:24px;border:none;background:transparent">TAKEOFF</div>`);
  btn.landing = add(`<div class="bt" style="left:1442px;top:393px;width:112px;height:24px;border:none;background:transparent">LANDING</div>`);
  const btnRow = (keys, labels, x0, y, wmax = 230) => {
    let x = x0;
    let yy = y;
    for (const k of keys) {
      const w = Math.round(labels[k].length * 7.1 + 22);
      if (x + w > x0 + wmax) {
        x = x0;
        yy += 31;
      }
      btn[k] = add(`<div class="bt" style="left:${x}px;top:${yy}px;width:${w}px;height:23px">${labels[k]}</div>`);
      x += w + 6;
    }
  };
  panel(1313, 450, 258, 107, 'VIEWS', '05');
  btnRow(VIEWS, VIEW_LABEL, 1327, 487);
  panel(1313, 572, 258, 138, 'DISPLAY', '06');
  btnRow(DISPLAY, DISPLAY_LABEL, 1327, 609);

  // ------------------------------------------------------------ 07 gear operation
  panel(430, 752, 740, 108, 'GEAR OPERATION', '07');
  btn.play = add(`<div class="bt" style="left:445px;top:795px;width:44px;height:44px;border-radius:50%;border:none;background:${C.or};color:#fff;font-size:15px"></div>`);
  const opFrom = add(`<div class="sm" style="position:absolute;left:506px;top:791px"></div>`);
  const opPhase = add(`<div class="mo" style="position:absolute;left:506px;width:500px;top:790px;text-align:center;font-size:9.6px;font-weight:700;letter-spacing:1.6px;color:${C.or}"></div>`);
  const opTo = add(`<div class="sm" style="position:absolute;right:${1600 - 1010}px;top:791px"></div>`);
  const TL = {x: 508, w: 504};
  add(`<div style="position:absolute;left:${TL.x}px;top:820px;width:${TL.w}px;height:3px;background:#efe6d8;border-radius:2px"></div>`);
  const tlFill = add(`<div style="position:absolute;left:${TL.x}px;top:820px;height:3px;background:${C.or};border-radius:2px"></div>`);
  const tlKnob = add(`<div style="position:absolute;top:814px;width:15px;height:15px;margin-left:-7.5px;border-radius:50%;background:#fff;border:2px solid ${C.or};box-sizing:border-box"></div>`);
  const tlTime = add(`<div class="mo" style="position:absolute;left:506px;top:834px;font-size:9.6px;color:${C.mute};letter-spacing:1px"></div>`);
  add(`<div class="sm" style="position:absolute;left:1029px;top:805px">SPEED</div>`);
  const SP = {x: 1031, w: 92};
  add(`<div style="position:absolute;left:${SP.x}px;top:823px;width:${SP.w}px;height:3px;background:#efe6d8;border-radius:2px"></div>`);
  const spFill = add(`<div style="position:absolute;left:${SP.x}px;top:823px;height:3px;background:${C.or};border-radius:2px"></div>`);
  const spKnob = add(`<div style="position:absolute;top:817px;width:15px;height:15px;margin-left:-7.5px;border-radius:50%;background:#fff;border:2px solid ${C.or};box-sizing:border-box"></div>`);
  const spVal = add(`<div class="mo" style="position:absolute;left:1128px;top:818px;font-size:10px;font-weight:700"></div>`);

  // ------------------------------------------------------------ footer and floor notes
  add(`<div class="sm" style="position:absolute;left:27px;top:882px;letter-spacing:2.2px">MECHANISM STUDY &nbsp;·&nbsp; PROCEDURAL MODEL &nbsp;·&nbsp; SINGLE DEGREE OF FREEDOM</div>`);
  add(`<div class="sm" style="position:absolute;right:28px;top:882px;letter-spacing:2.2px">BUILT FROM THE TRACED PLATE <span style="color:${C.or}">//////</span></div>`);
  const notes = add(`<div style="position:absolute;inset:0"></div>`);
  notes.innerHTML = `<div class="sm" style="position:absolute;left:367px;top:658px;line-height:16px;letter-spacing:2px">RIGHT MAIN GEAR<br>SHOWN (TYPICAL)</div>
    <svg style="position:absolute;left:752px;top:658px" width="48" height="10"><path d="M0 5h40" stroke="${C.mute}" stroke-width="1.2"/><path d="M40 1l7 4-7 4z" fill="${C.mute}"/></svg>
    <div class="sm" style="position:absolute;left:768px;top:677px">FWD</div>
    <div class="sm" style="position:absolute;left:912px;top:682px;line-height:16px;letter-spacing:2px"><span style="color:${C.mute}">//////</span><br>MAIN LANDING GEAR<br>REV B</div>
    <div style="position:absolute;left:1046px;top:690px;font-size:28px;font-weight:700;color:rgba(155,143,128,.25)">337</div>
    <svg style="position:absolute;left:398px;top:162px" width="90" height="80"><path d="M0 80V0h80" fill="none" stroke="${C.or}" stroke-width="1" stroke-dasharray="3 3" opacity=".6"/></svg>
    <svg style="position:absolute;left:1088px;top:606px" width="80" height="80"><path d="M80 0v76H0" fill="none" stroke="${C.or}" stroke-width="1" stroke-dasharray="3 3" opacity=".6"/></svg>`;

  // ------------------------------------------------------------ labels, the pointer
  const callSvg = add(`<svg data-callouts style="position:absolute;inset:0;overflow:visible" width="1600" height="900"></svg>`);
  const callG = add(`<div data-callouts style="position:absolute;inset:0"></div>`);
  const ring = add(`<div class="ring"></div>`);
  const cur = add(`<div class="cur"><svg width="22" height="22" viewBox="0 0 34 34"><path d="M4 3 L30 13.5 L17.5 17.5 L13.5 30 Z" fill="#111" stroke="#fff" stroke-width="2.2" stroke-linejoin="round"/></svg></div>`);
  const centre = (n) => {
    const r = n.getBoundingClientRect();
    return [r.left + r.width / 2, r.top + r.height / 2];
  };
  const spots = Object.fromEntries(Object.entries(btn).map(([k, n]) => [k, centre(n)]));
  const pointer = (c) => {
    cur.style.display = c.x < 0 ? 'none' : 'block';
    cur.style.transform = `translate(${(c.x - 3).toFixed(2)}px, ${(c.y - 2).toFixed(2)}px) scale(${c.press ? 0.88 : 1})`;
    ring.style.left = `${c.ringX}px`;
    ring.style.top = `${c.ringY}px`;
    ring.style.opacity = String(c.ring * 0.85);
    ring.style.transform = `scale(${(0.35 + 0.9 * (1 - c.ring)).toFixed(3)})`;
  };
  const fmt = (s) => `00:${String(Math.floor(s)).padStart(2, '0')}`;

  return {
    spots,
    btn,
    root: ui,
    timeline: (k) => [TL.x + k * TL.w, 821],
    speedKnob: (k) => [SP.x + k * SP.w, 824],
    TL,
    SP,
    update(s) {
      // state chip and the schematic: actuator, rack, pinion, shaft, the two legs from the front
      const [title, sub, kind] = s.state;
      chip.style.background = kind === 'ok' ? C.green : C.or;
      stTitle.textContent = title;
      stSub.textContent = sub;
      {
        const d = s.stroke;
        const rx = 92 - d * 52;
        const th = s.stroke * Math.PI;
        let h = `<text x="2" y="10" font-size="7.4" letter-spacing="1" fill="${C.mute}" font-family="JetBrains Mono">ACTUATOR</text>`;
        h += `<rect x="2" y="18" width="${rx - 8}" height="17" rx="8" fill="#fbf8f2" stroke="${C.ink}" stroke-width="1.2"/>`;
        h += `<line x1="${rx - 8}" x2="${rx + 2}" y1="26.5" y2="26.5" stroke="${C.ink}" stroke-width="1.6"/><rect x="${rx}" y="14" width="7" height="27" fill="${C.or}"/>`;
        h += `<line x1="0" x2="250" y1="36.5" y2="36.5" stroke="${C.ink}" stroke-width="1"/>`;
        h += `<rect x="${rx - 70}" y="40" width="96" height="7" fill="#fbf8f2" stroke="${C.ink}" stroke-width="1"/>`;
        for (let i = 0; i < 16; i++) h += `<line x1="${rx - 68 + i * 6}" x2="${rx - 68 + i * 6}" y1="47" y2="51" stroke="${C.ink}" stroke-width=".9"/>`;
        h += `<text x="24" y="72" font-size="7.4" letter-spacing="1" fill="${C.mute}" font-family="JetBrains Mono">RACK</text><text x="134" y="76" font-size="7.4" letter-spacing="1" fill="${C.mute}" font-family="JetBrains Mono">PINION</text>`;
        h += `<g transform="translate(108 70) rotate(${((th * 180) / Math.PI).toFixed(1)})"><circle r="17" fill="none" stroke="${C.ink}" stroke-width="1.1"/>${Array.from({length: 16}, (_, i) => `<line x1="0" y1="-17" x2="0" y2="-20.5" stroke="${C.ink}" stroke-width="1" transform="rotate(${i * 22.5})"/>`).join('')}<path d="M-12 -12L12 12M12 -12L-12 12" stroke="${C.ink}" stroke-width="1"/></g>`;
        h += `<text x="96" y="104" font-size="7.4" letter-spacing="1" fill="${C.mute}" font-family="JetBrains Mono">SHAFT</text><text x="220" y="100" font-size="7.4" letter-spacing="1" fill="${C.mute}" font-family="JetBrains Mono">LEG</text>`;
        h += `<line x1="108" x2="108" y1="88" y2="150" stroke="${C.mute}" stroke-width=".8" stroke-dasharray="3 3"/>`;
        // the legs from the front: a stub out, then the leg down, folding up as the tube turns
        const leg = (sx) => {
          const x0 = 108 + sx * 46;
          const y0 = 122;
          const xa = x0 + sx * 44;
          const a = th;
          const lx = xa + sx * 6 * Math.cos(a);
          const ly = y0 + 34 * Math.cos(a);
          const lx2 = xa - sx * 30 * Math.sin(a) * 0.9;
          const X = lx * (1 - Math.sin(a)) + lx2 * Math.sin(a);
          return `<path d="M${x0} ${y0}H${xa}L${X.toFixed(1)} ${ly.toFixed(1)}" fill="none" stroke="${C.or}" stroke-width="2.6" stroke-linejoin="round"/><circle cx="${X.toFixed(1)}" cy="${ly.toFixed(1)}" r="5.5" fill="${C.or}"/><circle cx="${x0}" cy="${y0}" r="4" fill="${C.or}"/>`;
        };
        h += `<line x1="62" x2="154" y1="122" y2="122" stroke="${C.ink}" stroke-width="1.4"/>` + leg(1) + leg(-1);
        sch.innerHTML = h;
      }
      // flight: the climb or the descent, the marker where the aircraft is
      {
        const W = 254;
        const X = (u) => 20 + u * (W - 30);
        const Y = (h) => 58 - (h / 1.7) * 46;
        let p = '';
        for (let i = 0; i <= 40; i++) {
          const u = i / 40;
          const h = s.seq === 'takeoff' ? 1.7 * Math.pow(Math.max(0, (u - 0.38) / 0.62), 1.6) : 1.7 * Math.pow(Math.max(0, 1 - u / 0.74), 1.6);
          p += `${i ? 'L' : 'M'}${X(u).toFixed(1)} ${Y(h).toFixed(1)}`;
        }
        const u = s.prog;
        const mh = s.height;
        const dir = s.seq === 'takeoff' ? 1 : -1;
        const ang = (Math.atan2(-(s.climb ?? 0) * 18, 1) * 180) / Math.PI;
        fl.innerHTML = `<line x1="0" x2="${W}" y1="58" y2="58" stroke="${C.ink}" stroke-width="1"/><path d="${p}" fill="none" stroke="${C.or}" stroke-width="1.1" stroke-dasharray="3 3"/><g transform="translate(${X(u).toFixed(1)} ${Y(mh).toFixed(1)}) scale(${dir} 1) rotate(${ang.toFixed(1)})"><path d="M-10 -6L8 0L-10 6L-6 0z" fill="${C.or}"/></g>`;
        const vals = [[s.height / 1.7, `${s.height.toFixed(2)} m`], [s.stroke, `${Math.round(s.stroke * 180)}°`], [s.stroke, `${Math.round(s.stroke * 201)} mm`]];
        bars.forEach((b, i) => {
          b.f.style.width = `${(Math.min(1, vals[i][0]) * 134).toFixed(1)}px`;
          b.v.textContent = vals[i][1];
        });
        wheelIcon.querySelector('[data-r]').setAttribute('transform', `rotate(${((s.roll * 180) / Math.PI) % 360} 11 11)`);
      }
      // sequence, views, display
      const onBtn = (k, on) => {
        btn[k].style.background = on ? C.or : '#fffdf9';
        btn[k].style.color = on ? '#fff' : C.ink;
        btn[k].style.borderColor = on ? C.or : C.faint;
      };
      for (const k of ['takeoff', 'landing']) {
        const on = s.seq === k;
        btn[k].style.background = on ? C.or : 'transparent';
        btn[k].style.color = on ? '#fff' : '#8d8274';
        btn[k].style.borderRadius = '4px';
      }
      for (const k of VIEWS) onBtn(k, s.view === k);
      for (const k of DISPLAY) onBtn(k, !!s.display[k]);
      // the operation timeline
      btn.play.innerHTML = s.paused ? '▶' : '❚❚';
      opFrom.textContent = s.from;
      opTo.textContent = s.to;
      opPhase.textContent = s.phase;
      const kx = TL.x + s.prog * TL.w;
      tlFill.style.width = `${(s.prog * TL.w).toFixed(1)}px`;
      tlKnob.style.left = `${kx.toFixed(1)}px`;
      tlTime.textContent = `${fmt(s.tau)} / ${fmt(s.dur)}`;
      const sk = SP.x + s.speedK * SP.w;
      spFill.style.width = `${(s.speedK * SP.w).toFixed(1)}px`;
      spKnob.style.left = `${sk.toFixed(1)}px`;
      spVal.textContent = `${s.speed.toFixed(1)}×`;
      // labels: an orange square, the name, the note, a thin leader to the part
      let lh = '';
      let ch = '';
      for (const c of s.callouts) {
        if (c.o < 0.02 || !c.at) continue;
        const [ax, ay] = c.at;
        const [lx, ly] = c.label;
        const right = lx > ax;
        const ex = right ? lx - 6 : lx + c.w + 6;
        lh += `<polyline points="${ax.toFixed(1)},${ay.toFixed(1)} ${ex.toFixed(1)},${(ly + 6).toFixed(1)}" fill="none" stroke="${C.or}" stroke-width=".9" opacity="${c.o.toFixed(2)}"/><circle cx="${ax.toFixed(1)}" cy="${ay.toFixed(1)}" r="2.4" fill="${C.or}" opacity="${c.o.toFixed(2)}"/>`;
        ch += `<div class="call" style="left:${lx.toFixed(1)}px;top:${(ly - 1).toFixed(1)}px;opacity:${c.o.toFixed(2)}"><b><i></i>${c.name}</b><span>${c.sub}</span></div>`;
      }
      callSvg.innerHTML = lh;
      callG.innerHTML = ch;
      pointer(s.cursor);
    },
    pointer,
  };
}
```

## File: sims/cessna-shared/film.js
size: 10574
sha: 51df5206ce93c92f64938066a9fffead8493ffeb
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/cessna-shared/film.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/cessna-shared/film.js

## File: sims/cessna-shared/flight.js
size: 2023
sha: d5b8a11b0b0b887a9114887d25d75d793c3a7a18
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/cessna-shared/flight.js

```text
// The two sequences the gear lives through, as functions of tau (seconds into the sequence): the takeoff (roll, lift
// off, the gear comes up) and the landing (the gear comes down, the flare, touchdown, rollout). Each gives the
// stroke (0 down, 1 up), the height above the runway, the ground speed and the phase line the console shows.
const smoother = (x) => {
  const u = Math.min(1, Math.max(0, x));
  return u * u * u * (u * (u * 6 - 15) + 10);
};
const ss = (a, b, t) => smoother((t - a) / (b - a));
export const H_MAX = 1.7;
export const V_MAX = 14;

export const SEQ = {
  takeoff: {
    dur: 5,
    from: 'RUNWAY',
    to: 'GEAR UP',
    stroke: (t) => ss(2.3, 4.5, t),
    height: (t) => H_MAX * Math.pow(ss(1.9, 5.0, t), 1.25),
    speed: (t) => V_MAX * ss(0, 2.0, t),
    phases: [
      [0, 'RUNWAY'],
      [0.6, 'TAKEOFF ROLL'],
      [1.9, 'LIFT OFF'],
      [2.3, 'ACTUATOR STROKES AFT'],
      [3.0, 'RACK TURNS THE PINION'],
      [3.6, 'SIDE TUBES SPIN THE LEGS UP'],
      [4.5, 'GEAR UP & LOCKED'],
    ],
  },
  landing: {
    dur: 7,
    from: 'GEAR UP',
    to: 'RUNWAY',
    stroke: (t) => 1 - ss(0.6, 3.0, t),
    height: (t) => H_MAX * (1 - ss(0, 5.2, t)),
    speed: (t) => V_MAX * (1 - ss(5.2, 7.0, t)),
    phases: [
      [0, 'GEAR DOWN SELECTED'],
      [0.6, 'ACTUATOR STROKES FORWARD'],
      [1.3, 'SIDE TUBES SPIN THE LEGS DOWN'],
      [3.0, 'GEAR DOWN & LOCKED'],
      [5.0, 'TOUCHDOWN'],
      [5.5, 'ROLLOUT'],
    ],
  },
};
export const phaseAt = (seq, t) => SEQ[seq].phases.filter(([a]) => a <= t).pop()[1];
/** the mechanism state chip: [title, sub, colour key] */
export const stateAt = (stroke, height) => {
  if (stroke < 0.002) return height < 0.01 ? ['Gear down & locked', 'ON THE RUNWAY', 'ok'] : ['Gear down & locked', `AIRBORNE · ${height.toFixed(2)} M`, 'ok'];
  if (stroke > 0.998) return ['Gear up & locked', `AIRBORNE · ${height.toFixed(2)} M`, 'ok'];
  return ['In transit', height < 0.01 ? 'ON THE RUNWAY' : `AIRBORNE · ${height.toFixed(2)} M`, 'move'];
};
```

## File: sims/cessna-shared/gear.js
size: 16047
sha: 9f5d3d2b4901ed0acf6c0b0f266c4fe47de80203
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/cessna-shared/gear.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/cessna-shared/gear.js

## File: sims/cessna-shared/live.js
size: 12971
sha: 06ac5fb5020b4abbfd3dd94f0ced88a9a9c5ee16
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/cessna-shared/live.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/cessna-shared/live.js

## File: sims/cessna-shared/score.py
size: 1684
sha: b1adeed74bccf0747eaa98dd309598cae093161a
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/cessna-shared/score.py

```text
"""Score for the Cessna 337 main landing gear film: a light bed (Dmaj9 chords, 96 bpm) at one level the whole way, a
hydraulic whine under every gear transit, a soft air at each lift-off, a tyre thud at each touchdown and a quiet
click on every button the operator presses.

    python run_sfx.py sims/cessna-shared/score.py

Times from cessna-shared/film.js (SCRIPT) and flight.js (the sequences' own timings).
"""

import os

from sfx.score_kit import airy, bed, chords, hz, motor, thud
from sfx.synth import click

TIMELINE = "../cessna-film/timeline.json"
OUT = os.path.join(os.environ.get("TEARDOWN_OUT", "teardown-out"), "cessna-score.wav")
TARGET_LUFS = -14.0
SEED = 17

CHORDS = chords("Dmaj9:D2 A2 C#3 E3 F#3/D1", "Bm9:B1 F#2 A2 C#3 D3/B0", "Gmaj7:G2 D3 F#3 B3/G1", "A6sus:A2 D3 E3 F#3 B3/A1")
MOTIF = [(0.5, hz("F#5")), (1.5, hz("A5")), (2.5, hz("E5"))]
CLICKS = (2.4, 5.4, 5.9, 6.5, 8.7, 10.9, 11.5, 12.7, 13.0, 13.4, 16.2, 17.8, 18.3, 18.7, 19.1)
TAKEOFFS = (0.0, 13.0)
LANDINGS = (5.4, 19.1)

def score(mix, tl):
    dur = tl["duration"]
    left, right, low, drums = bed(dur, 96, CHORDS, MOTIF, motif_voice="epiano", kicks=(0, 2.5), snares=(), hats=8, swing=0.0, keys_vel=0.7, keys_lp=3600)
    mix.put_stereo(0, left, right, 0.12)
    mix.put(0, low, 0.12)
    mix.put(0, drums, 0.045)
    for t0 in TAKEOFFS:
        mix.put(t0 + 1.8, airy(1.6, 500, 1600, 0.6), 0.03)
        mix.put(t0 + 2.3, motor(2.2, 190, 1300, 14), 0.02)
    for t0 in LANDINGS:
        mix.put(t0 + 0.6, motor(2.4, 175, 1200, 14), 0.02)
        if t0 + 5.2 < dur - 0.3:
            mix.put(t0 + 5.2, thud(80, 0.05, 0.4), 0.09)
    for t in CLICKS:
        mix.put(t, click(2400, 0.025), 0.055)
```

## File: sims/cessna-shared/stage.js
size: 11252
sha: bf6c4e17c7061ea65ef23a6cf24b8888e3d9478d
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/cessna-shared/stage.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/cessna-shared/stage.js

## File: sims/kit/director.js
size: 11670
sha: aacf2d90ef83a37f09a146f06fda6af04337fda0
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/kit/director.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/kit/director.js

## File: sims/kit/hud.js
size: 6949
sha: 289f0f548f67701b8069e3dab88eafba18b00e34
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/kit/hud.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/kit/hud.js

## File: sims/kit/runtime.js
size: 5364
sha: db073e16015355a03cd04a126abe4f47baa2ba99
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/kit/runtime.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/kit/runtime.js

## File: sims/kit/stage3d.js
size: 3889
sha: 09950f2d01cb4e1397aba5c18308461c72a5b615
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/kit/stage3d.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/kit/stage3d.js

## File: sims/kit/style.css
size: 6878
sha: 7bf7ca8f3402f8376cb873852d02018ee8a31d01
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/kit/style.css

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/kit/style.css

## File: sims/kit/util.js
size: 2721
sha: 1dbf9d0c6d87c9375d424f4ae24e0b225cfa6e5a
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/kit/util.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/kit/util.js

## File: sims/kit/warm.js
size: 3880
sha: 6c54d75f67d0ced18b52db814f0c6fddb81b65f7
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/kit/warm.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/kit/warm.js

## File: sims/strandbeest-film/main.js
size: 132
sha: 2f5c920571459229d848ce3470dadf00b1964868
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/strandbeest-film/main.js

```text
// Strandbeest SB-10 console film. Shared code in ../strandbeest-shared.
import {run} from '../strandbeest-shared/film.js';

run();
```

## File: sims/strandbeest-film/timeline.json
size: 18
sha: a7efe8308f92012b40ddfde2943233391a1458d2
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/strandbeest-film/timeline.json

```text
{"duration":30.7}
```

## File: sims/strandbeest-live/main.js
size: 208
sha: 048b86975a0e75085e2e5f6fdcf98b8127408c52
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/strandbeest-live/main.js

```text
import {runLive} from '../strandbeest-shared/live.js';

// SB-10 as an interactive page for the site: views, isolate a leg, trace the feet, the wind slider, orbit, an auto
// tour when left alone.
runLive();
```

## File: sims/strandbeest-shared/beast.js
size: 17691
sha: 61b705cc5d7cf5eab9b92653bc5b26c936e8a09d
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/strandbeest-shared/beast.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/strandbeest-shared/beast.js

## File: sims/strandbeest-shared/console.js
size: 27480
sha: 219c7ccf7e203c566e11ccc185c7638099aa837c
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/strandbeest-shared/console.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/strandbeest-shared/console.js

## File: sims/strandbeest-shared/film.js
size: 14904
sha: 91e72c5280b19f1a18440d4c6cc8746ecbdd23a4
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/strandbeest-shared/film.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/strandbeest-shared/film.js

## File: sims/strandbeest-shared/jansen.js
size: 2818
sha: 3e510416f4833fbfdf0d22477cec1f5db1044470
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/strandbeest-shared/jansen.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/strandbeest-shared/jansen.js

## File: sims/strandbeest-shared/live.js
size: 16031
sha: aee1af12f04a5e7cb0b58af4aa0ad5947f51b7a5
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/strandbeest-shared/live.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/strandbeest-shared/live.js

## File: sims/strandbeest-shared/score.py
size: 2947
sha: c4373463d9ffc3273780add10ac23dc61ca0f78b
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/strandbeest-shared/score.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/strandbeest-shared/score.py

## File: sims/strandbeest-shared/stage.js
size: 20433
sha: ec7edffb38f6db6a33304260570a08aa1d2b3d40
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/strandbeest-shared/stage.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/strandbeest-shared/stage.js

## File: sims/v8-film/main.js
size: 115
sha: 279b7a58d17f9c46d84c534eaef7ec028dafdd9a
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/v8-film/main.js

```text
// V8 · Four-Stroke explorer film. Shared code in ../v8-shared.
import {run} from '../v8-shared/film.js';

run();
```

## File: sims/v8-film/timeline.json
size: 16
sha: 6d761a9732033ee4a0118d0e95fcd1d469a5e471
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/v8-film/timeline.json

```text
{"duration":26}
```

## File: sims/v8-live/main.js
size: 207
sha: b71e50971fe1ce50674d84e2483333b2957380f2
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/v8-live/main.js

```text
import {runLive} from '../v8-shared/live.js';

// V8 · Four-Stroke as an interactive page for the site: views, cutaway, overlays, firing order, speed, orbit, and an
// auto tour when left alone.
runLive();
```

## File: sims/v8-shared/console.js
size: 26272
sha: 242617806eb583413fcf72fadf059d3bd2d00b09
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/v8-shared/console.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/v8-shared/console.js

## File: sims/v8-shared/engine.js
size: 29511
sha: d596eb9376885e9e592d0e76dfa608fda06117b5
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/v8-shared/engine.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/v8-shared/engine.js

## File: sims/v8-shared/film.js
size: 10260
sha: 59c6bad951de0860f4adb396fa1b73022b0b855c
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/v8-shared/film.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/v8-shared/film.js

## File: sims/v8-shared/kin.js
size: 4149
sha: af918c2837f4674f3b9934c63ebd8437ef1097da
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/v8-shared/kin.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/v8-shared/kin.js

## File: sims/v8-shared/live.js
size: 13909
sha: 09ff54b53ffaf2a04e4849fce5482bc1d76c3f6a
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/v8-shared/live.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/v8-shared/live.js

## File: sims/v8-shared/score.py
size: 2014
sha: 04d108a56895768c62df9c75afb9c5d554fc2b77
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/v8-shared/score.py

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/v8-shared/score.py

## File: sims/v8-shared/stage.js
size: 7534
sha: fa9f696d14b00a70ff205cdbee52c71546a4c776
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/sims/v8-shared/stage.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/sims/v8-shared/stage.js

## File: wave.mjs
size: 3025
sha: 12f7c69f7ff534ad113d54c0751973b939ee1f00
upstream: https://github.com/elvarofficial/teardown/blob/HEAD/wave.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/elvarofficial/teardown/blob/HEAD/wave.mjs
