⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
teardown
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.
Choose how to use this skill
You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.
1. Native installer
This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.
Do not guess an installer command or replace an existing version without reviewing the diff.
2. Complete package recommended
Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.
No complete ProSkills package is published for this listing yet.3. Prompt-only
Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.
Need only the instruction file?
Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.
No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.
// RATINGS
Not yet listed on ClawHub or SkillsMP
// README
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.

| Telson SC-8 robotic scorpion | Pelagos OC-8 robotic octopus |
|---|---|
![]() | ![]() |
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 (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; sources in sfx/samples/SOURCES.md.
MIT licence.
// HOW IT'S BUILT
// FILES
73 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
SKILL.md11 KB9505e4aa15caupstream ↗
--- 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`.README.md2.2 KBba138fb0f6cfupstream ↗
# 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.  | Telson SC-8 robotic scorpion | Pelagos OC-8 robotic octopus | |---|---| |  |  | ## 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..gitignore47 B14e2c2ef366bupstream ↗
node_modules/ teardown-out/ __pycache__/ *.pycdocs/bee.gif6.0 MBe0f08f1b6ba9upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/octopus.gif4.5 MBbe00301f7040upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
docs/scorpion.gif3.3 MB4c5d95f5dd4eupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
LICENSE1.1 KBce935de9b08fupstream ↗
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.package-lock.json16 KB61d09d85a520upstream ↗
{ "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" } } }package.json520 B40a6b126a76dupstream ↗
{ "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" } }paths.mjs1.6 KB265c70e31773upstream ↗
// 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');run_sfx.py236 Bff8b1468ffb2upstream ↗
"""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()sfx/__init__.py309 Bd38f6b10511dupstream ↗
"""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"]sfx/__main__.py2.4 KB84ebe11f84f2upstream ↗
"""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()sfx/dsp.py13 KB63631608a5beupstream ↗
"""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] = outsfx/loudness.py2.2 KB6dce8971f3a3upstream ↗
"""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))sfx/mix.py4.5 KB358294bb95f3upstream ↗
"""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))sfx/samples/fs-258269-mouth-pop.ogg7.8 KB269f5784c018upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sfx/samples/fs-323432-typing.ogg60 KB3a13e8855621upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sfx/samples/fs-329533-room-tone-in-an-apartment-livi.ogg141 KBaa2f3a906e2fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sfx/samples/fs-329569-roomtone-bedroom-yew.ogg166 KB8a95cf27cac4upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sfx/samples/fs-339521-vhs-rewind-forward-and-eject.ogg35 KB0db8f8b9031aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sfx/samples/fs-349504-triangle-open-04.ogg30 KB4fd791a6c791upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sfx/samples/fs-416777-digital-typing-wav.ogg18 KBab86f2e73fe9upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sfx/samples/fs-426767-apartment-room-tone-wav.ogg167 KBc10315fe079aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sfx/samples/fs-447909-keyboard-typing.ogg48 KB9c3e272b4a5fupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sfx/samples/fs-447910-plop.ogg5.9 KBfda1ba261e73upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sfx/samples/fs-79388-keyboard-typing-wav.ogg67 KB5f52d61a5ec0upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sfx/samples/fs-824189-pop-in-sfx.ogg7.4 KB9388203281aeupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sfx/samples/SOURCES.md1.9 KB1d8170109274upstream ↗
# 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/ |sfx/score_kit.py7.7 KBad2299197eedupstream ↗
"""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)]sfx/synth.py9.9 KB28089ceef43aupstream ↗
"""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 outsheet.mjs4.3 KBef335ca9b5e1upstream ↗
#!/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:[email protected]: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}`));sims/beats.py7.0 KB92888aae0cf0upstream ↗
#!/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())sims/build.mjs5.7 KBcf9f635c6a44upstream ↗
#!/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`); }sims/capture.mjs9.2 KB4a6f0eb838e6upstream ↗
#!/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}`);sims/cessna-film/main.js135 B10c87f8419b0upstream ↗
// CESSNA 337 SKYMASTER main landing gear film. Shared code in ../cessna-shared. import {run} from '../cessna-shared/film.js'; run();sims/cessna-film/timeline.json18 B2c07c4667d3bupstream ↗
{"duration":26.2}sims/cessna-live/main.js236 B5d6e34c79733upstream ↗
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();sims/cessna-shared/console.js21 KBc21884e15647upstream ↗
// 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 · PROCEDURAL MODEL · 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, }; }sims/cessna-shared/film.js10 KB51df5206ce93upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/cessna-shared/flight.js2.0 KBd5b8a11b0b0bupstream ↗
// 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']; };sims/cessna-shared/gear.js16 KB9f5d3d2b4901upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/cessna-shared/live.js13 KB06ac5fb5020bupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/cessna-shared/score.py1.6 KBb1adeed74bccupstream ↗
"""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)sims/cessna-shared/stage.js11 KBbf6c4e17c706upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/kit/director.js11 KBaacf2d90ef83upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/kit/hud.js6.8 KB289f0f548f67upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/kit/runtime.js5.2 KBdb073e160153upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/kit/stage3d.js3.8 KB09950f2d01cbupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/kit/style.css6.7 KB7bf7ca8f3402upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/kit/util.js2.7 KB1dbf9d0c6d87upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/kit/warm.js3.8 KB6c54d75f67d0upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/strandbeest-film/main.js132 B2f5c92057145upstream ↗
// Strandbeest SB-10 console film. Shared code in ../strandbeest-shared. import {run} from '../strandbeest-shared/film.js'; run();sims/strandbeest-film/timeline.json18 Ba7efe8308f92upstream ↗
{"duration":30.7}sims/strandbeest-live/main.js208 B048b86975a0eupstream ↗
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();sims/strandbeest-shared/beast.js17 KB61b705cc5d7cupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/strandbeest-shared/console.js27 KB219c7ccf7e20upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/strandbeest-shared/film.js15 KB91e72c5280b1upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/strandbeest-shared/jansen.js2.8 KB3e510416f483upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/strandbeest-shared/live.js16 KBaee1af12f04aupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/strandbeest-shared/score.py2.9 KBc4373463d9ffupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/strandbeest-shared/stage.js20 KBec7edffb38f6upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/v8-film/main.js115 B279b7a58d17fupstream ↗
// V8 · Four-Stroke explorer film. Shared code in ../v8-shared. import {run} from '../v8-shared/film.js'; run();sims/v8-film/timeline.json16 B6d761a973203upstream ↗
{"duration":26}sims/v8-live/main.js207 Bb71e50971fe1upstream ↗
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();sims/v8-shared/console.js26 KB242617806eb5upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/v8-shared/engine.js29 KBd596eb937688upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/v8-shared/film.js10 KB59c6bad951deupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/v8-shared/kin.js4.1 KBaf918c2837f4upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/v8-shared/live.js14 KB09ff54b53ffaupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/v8-shared/score.py2.0 KB04d108a56895upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
sims/v8-shared/stage.js7.4 KBfa9f696d14b0upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
wave.mjs3.0 KB12f7c69f7ff5upstream ↗
Contents are not inlined in the catalog. Open the upstream file.
Plain-text copy, no login: /skill-bundles/elvarofficial-teardown.md

