diff --git a/app/src/main/assets/steam-startup/droiddeck-startup.webm b/app/src/main/assets/steam-startup/droiddeck-startup.webm
new file mode 100644
index 000000000..0c69191f9
Binary files /dev/null and b/app/src/main/assets/steam-startup/droiddeck-startup.webm differ
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
index 5cd53d1b6..fa2dc003c 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
@@ -148,8 +148,142 @@ object SessionFiles {
staged.delete()
Log.e(TAG, "could not write ld.so.preload")
}
+
+ val startupMovie = File(root, "root/.local/share/Steam/config/uioverrides/movies/droiddeck-startup.webm")
+ val stagedMovie = File(startupMovie.parentFile, startupMovie.name + ".staged")
+ var movieInstalled = false
+ try {
+ startupMovie.parentFile?.mkdirs()
+ context.assets.open("steam-startup/droiddeck-startup.webm").use { input ->
+ stagedMovie.outputStream().use { output -> FileUtils.copy(input, output) }
+ }
+ movieInstalled = stagedMovie.setReadable(true, false) && stagedMovie.renameTo(startupMovie)
+ } catch (e: Exception) {
+ Log.w(TAG, "could not stage Steam startup movie", e)
+ } finally {
+ if (!movieInstalled) stagedMovie.delete()
+ }
+ if (!movieInstalled) Log.e(TAG, "Steam startup movie NOT staged")
+ if (movieInstalled) ensureStartupMovieDefault(File(root, "root/.local/share/Steam/config/config.vdf"))
}
+ private data class VdfBlock(val keyStart: Int, val open: Int, val close: Int)
+
+ private fun ensureStartupMovieDefault(config: File) {
+ val path = "/uioverrides/movies/droiddeck-startup.webm"
+ var text = if (config.isFile) runCatching { config.readText() }.getOrElse {
+ Log.w(TAG, "could not read Steam config for startup movie", it)
+ return
+ } else ""
+ val newline = if (text.contains("\r\n")) "\r\n" else "\n"
+ val movie = findObject(text, "StartupMovie", 0, text.length)
+ if (movie != null) {
+ val block = text.substring(movie.keyStart, movie.close + 1)
+ val selectedId = scalar(block, "MovieID")
+ val selectedPath = scalar(block, "LocalPath")
+ if (selectedPath == path) {
+ Log.i(TAG, "Steam startup movie is set to DroidDeck")
+ return
+ }
+ if ((!selectedId.isNullOrEmpty() && selectedId != "0") || !selectedPath.isNullOrEmpty()) {
+ Log.i(TAG, "Steam startup movie selection preserved")
+ return
+ }
+ val updated = setScalar(block, "MovieID", "0", newline)
+ .let { setScalar(it, "LocalPath", path, newline) }
+ text = text.replaceRange(movie.keyStart, movie.close + 1, updated)
+ } else {
+ val steam = findSteamBlock(text)
+ if (steam == null) {
+ if (text.isNotBlank()) {
+ Log.w(TAG, "Steam config has no Steam settings block; startup movie default not set")
+ return
+ }
+ text = "\"InstallConfigStore\"${newline}{${newline}\t\"Software\"${newline}\t{${newline}\t\t\"Valve\"${newline}\t\t{${newline}\t\t\t\"Steam\"${newline}\t\t\t{${newline}"
+ val section = startupMovieSection("\t\t\t\t", path, newline)
+ text += section + newline + "\t\t\t}" + newline + "\t\t}" + newline + "\t}" + newline + "}"
+ } else {
+ val lineStart = text.lastIndexOf('\n', steam.close).let { if (it < 0) 0 else it + 1 }
+ val indent = text.substring(lineStart, steam.close).takeWhile { it == '\t' || it == ' ' }
+ val section = startupMovieSection(indent + "\t", path, newline)
+ text = text.substring(0, lineStart) + section + newline + indent + text.substring(lineStart)
+ }
+ }
+ val staged = File(config.parentFile, config.name + ".staged")
+ try {
+ config.parentFile?.mkdirs()
+ staged.writeText(text)
+ if (!staged.renameTo(config)) {
+ staged.delete()
+ Log.e(TAG, "could not write Steam startup movie selection")
+ } else {
+ Log.i(TAG, "Steam startup movie default selected")
+ }
+ } catch (e: Exception) {
+ staged.delete()
+ Log.w(TAG, "could not write Steam startup movie selection", e)
+ }
+ }
+
+ private fun findSteamBlock(text: String): VdfBlock? {
+ val install = findObject(text, "InstallConfigStore", 0, text.length) ?: return null
+ val software = findObject(text, "Software", install.open + 1, install.close) ?: return null
+ val valve = findObject(text, "Valve", software.open + 1, software.close) ?: return null
+ return findObject(text, "Steam", valve.open + 1, valve.close)
+ }
+
+ private fun findObject(text: String, key: String, start: Int, end: Int): VdfBlock? {
+ val pattern = Regex("(?m)^[\\t ]*\"${Regex.escape(key)}\"[\\t ]*(?:\\r?\\n[\\t ]*)?\\{")
+ val match = pattern.find(text, start)?.takeIf { it.range.first < end && it.range.last < end } ?: return null
+ val open = text.indexOf('{', match.range.first).takeIf { it < end } ?: return null
+ var depth = 0
+ var quoted = false
+ var escaped = false
+ for (index in open until end) {
+ val char = text[index]
+ if (quoted) {
+ if (escaped) escaped = false
+ else if (char == '\\') escaped = true
+ else if (char == '\"') quoted = false
+ } else {
+ when (char) {
+ '\"' -> quoted = true
+ '{' -> depth++
+ '}' -> {
+ depth--
+ if (depth == 0) return VdfBlock(match.range.first, open, index)
+ }
+ }
+ }
+ }
+ return null
+ }
+
+ private fun scalar(block: String, key: String): String? {
+ val pattern = Regex("(?m)^[\\t ]*\"${Regex.escape(key)}\"[\\t ]+\"([^\"\\r\\n]*)\"")
+ return pattern.find(block)?.groupValues?.get(1)
+ }
+
+ private fun setScalar(block: String, key: String, value: String, newline: String): String {
+ val pattern = Regex("(?m)^([\\t ]*\"${Regex.escape(key)}\"[\\t ]+)\"[^\"\\r\\n]*\"([\\t ]*)$")
+ val match = pattern.find(block)
+ if (match != null) return block.replaceRange(match.range, match.groupValues[1] + "\"" + value + "\"" + match.groupValues[2])
+ val close = block.lastIndexOf('}')
+ if (close < 0) return block
+ val lineStart = block.lastIndexOf('\n', close).let { if (it < 0) 0 else it + 1 }
+ val indent = block.substring(lineStart, close).takeWhile { it == '\t' || it == ' ' } + "\t"
+ val entry = indent + "\"" + key + "\"\t\t\"" + value + "\"" + newline
+ return block.substring(0, lineStart) + entry + block.substring(lineStart)
+ }
+
+ private fun startupMovieSection(indent: String, path: String, newline: String): String = listOf(
+ indent + "\"StartupMovie\"",
+ indent + "{",
+ indent + "\t\"MovieID\"\t\t\"0\"",
+ indent + "\t\"LocalPath\"\t\t\"" + path + "\"",
+ indent + "}",
+ ).joinToString(newline)
+
/**
* Where the session writes its log. Downloads is the point - a failed run is handed over as a
* folder rather than dug out of app-private storage - but the session script redirects its own
diff --git a/artwork/boot-animation-source/README.md b/artwork/boot-animation-source/README.md
new file mode 100644
index 000000000..72f7d0c1b
--- /dev/null
+++ b/artwork/boot-animation-source/README.md
@@ -0,0 +1,68 @@
+# DroidDeck boot animation
+
+This source accompanies the single Steam startup movie bundled by the app:
+`app/src/main/assets/steam-startup/droiddeck-startup.webm` (1920×1080, dark theme, VP9 video
+with Opus audio). `droiddeck-boot.html` is the editable animation; the scripts and WAV below
+reproduce the selected render. No alternate MP4 or WebM exports are kept in the repository.
+
+From the repository root, render the movie with:
+
+ python3 artwork/boot-animation-source/render.py 1920 1080 60 app/src/main/assets/steam-startup/droiddeck-startup.webm dark
+
+The app stages that file into Steam's `config/uioverrides/movies` folder before each session. If
+Steam still has its built-in startup movie selected, DroidDeck sets this movie as the device's
+startup default before Steam starts. A custom startup movie the user selected later is preserved.
+
+To regenerate the soundtrack, run these commands from this directory:
+
+ python3 events.py
+ python3 sound.py
+
+The animation and audio are both procedurally generated; the included WAV is the soundtrack used
+for the bundled render.
+
+`droiddeck-boot.html` holds the whole animation. Every frame is a pure function of time, so the
+preview page and the video render draw exactly the same thing. Open it in a browser to scrub, slow
+it down or step frame by frame (arrow keys).
+
+## Re-render the video
+
+ pip install playwright && playwright install chromium # ffmpeg with libvpx-vp9 also required
+ python3 render.py 1280 800 60 droiddeck-boot-1280x800.webm
+ python3 render.py 1920 1080 60 droiddeck-boot-1920x1080.webm
+ python3 render.py 1920 1080 60 droiddeck-boot-1920x1080.mp4 # .mp4 = H.264 for sharing
+ python3 render.py 1280 800 60 droiddeck-boot-dark-1280x800.webm dark # dark version
+
+Each frame gets real motion blur (180° shutter, adaptive sub-frames). Output is VP9 for .webm or H.264 for .mp4, both BT.709,
+so the orb stays #1A9FFF after compression.
+
+## Sound
+
+Every sound is synthesized in `sound.py` (no samples) and placed on the exact physics events the
+animation reports, so hits stay frame-accurate even after you change the timing. Key: D major.
+
+ python3 events.py # export impact times, rocking, piece speeds from the animation
+ python3 sound.py # -> droiddeck-boot-sound.wav (-18 LUFS, -1 dBFS peak)
+ python3 embed_sound.py # put it in the preview page
+ python3 render.py ... # muxes the wav in automatically (Opus in .webm, AAC in .mp4)
+
+The soundtrack is built from the final chord. Each piece lands on one of its notes as a soft, muted
+tone (legs D, ball A with a quieter echo, bowl F#), the wobble and jump play in silence, and the
+lock lands with the same soft voice and plays all the notes together. Tweak the cue sheet in `sound.py`: the `tock(...)` calls under
+`# 1. the stack`, the chord list under `# 4. lock`, and the room reverb mix (`.14 * wet`).
+
+## Where to tweak
+
+- Timing of the drops: `archDrop`, `ballDrop`, `domeDrop` (start time, height, bounciness `e`).
+- The balancing act: `SWAY` keyframes (time, lean). Bigger values = bigger wobble. The bowl leads,
+ the ball chases it (`sway(t - .12)`), the legs follow (`sway(t - .10)`).
+- The catch-hop on the big wobble: `HOP` (start time, duration, sideways distance, height).
+- Crouch before the jump: `crouchA/B/D`.
+- Jump and lock-in: `FL` (rise time, apex height, lock time, how far pieces spread).
+- Colors for each version (pieces, background, shadows, lock glow): `THEMES` (light, dark).
+- Total length: `DUR`.
+
+## Install as the Steam startup movie
+
+Put the .webm in `~/.steam/root/config/uioverrides/movies/`, then pick it in
+Steam > Settings > Customization > Startup Movie.
diff --git a/artwork/boot-animation-source/droiddeck-boot-sound.wav b/artwork/boot-animation-source/droiddeck-boot-sound.wav
new file mode 100644
index 000000000..a3f50bb11
Binary files /dev/null and b/artwork/boot-animation-source/droiddeck-boot-sound.wav differ
diff --git a/artwork/boot-animation-source/droiddeck-boot.html b/artwork/boot-animation-source/droiddeck-boot.html
new file mode 100644
index 000000000..78d5e6f65
--- /dev/null
+++ b/artwork/boot-animation-source/droiddeck-boot.html
@@ -0,0 +1,699 @@
+
+
+
+
+
+DroidDeck boot animation
+
+
+
+
+
+
+
+
+
+
DroidDeck boot animation
+
6.3 s at 60 fps, with sound
+
+
+
+
+
+
+
+
+
Tap to replay
+
+
+
+
+
+
+
+
+ 0.00 / 6.30
+
+
+
+
+
+
+
+
+
+
+
+
Use it as the Steam startup movie
+
+
Put the .webm file in ~/.steam/root/config/uioverrides/movies/. Create the folders if they don’t exist.
+
In Steam, open Settings > Customization > Startup Movie and pick it.
+
+
+
+
Which file to ship
+
Ship the dark version if your Steam UI is dark: it hands off to the library without a bright flash. The 1280×800 cut matches Steam Deck and is the safe default. The 1920×1080 cut is sharper on phones and TVs. Steam needs the .webm; the .mp4 files are for sharing. Everything is drawn and synthesized in code, so the included scripts can output any other size.
+
+
+
+
+
+
+
+
diff --git a/artwork/boot-animation-source/embed_sound.py b/artwork/boot-animation-source/embed_sound.py
new file mode 100644
index 000000000..7354e3c32
--- /dev/null
+++ b/artwork/boot-animation-source/embed_sound.py
@@ -0,0 +1,12 @@
+#!/usr/bin/env python3
+"""Embed droiddeck-boot-sound.wav into droiddeck-boot.html (MP3, base64) for the preview page."""
+import base64, re, subprocess
+from pathlib import Path
+here = Path(__file__).parent
+subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(here / "droiddeck-boot-sound.wav"),
+ "-c:a", "libmp3lame", "-b:a", "128k", str(here / "_snd.mp3")], check=True)
+b64 = base64.b64encode((here / "_snd.mp3").read_bytes()).decode(); (here / "_snd.mp3").unlink()
+html = (here / "droiddeck-boot.html").read_text()
+html = re.sub(r'/\*SOUND\*/".*?"/\*END\*/', lambda m: f'/*SOUND*/"{b64}"/*END*/', html, flags=re.S)
+(here / "droiddeck-boot.html").write_text(html)
+print(f"embedded {len(b64)/1024:.0f} KB of sound")
diff --git a/artwork/boot-animation-source/events.py b/artwork/boot-animation-source/events.py
new file mode 100644
index 000000000..1aa51e340
--- /dev/null
+++ b/artwork/boot-animation-source/events.py
@@ -0,0 +1,20 @@
+import json
+from pathlib import Path
+from playwright.sync_api import sync_playwright
+JS = """(() => {
+ const d = DD.drops, c = (m, q) => ({x: m[0]*q.x + m[2]*q.y + m[4], y: m[1]*q.x + m[3]*q.y + m[5]});
+ const ev = {DUR: DD.DUR, T_L: DD.T_L, LOCK: DD.BEATS[3][1], HOP: DD.HOP,
+ drops: Object.fromEntries(['archDrop','ballDrop','domeDrop'].map(k => [k, {t0: d[k].o.t0, impacts: d[k].impacts}]))};
+ const tr = {t:[], phi:[], u:[], psi:[], ax:[], ay:[], bx:[], by:[], dx:[], dy:[]}, dt = 1/1200;
+ for (let i = 0; i*dt <= DD.DUR + 1e-9; i++) { const t = i*dt, p = DD.pose(t);
+ const a = c(p.arch, DD.CEN.arch), b = c(p.ball, {x: 63.98, y: 111.86}), q = c(p.dome, DD.CEN.dome);
+ tr.t.push(t); tr.phi.push(DD.sig('phi', t)); tr.u.push(DD.sig('u', t)); tr.psi.push(DD.sig('psi', t));
+ tr.ax.push(a.x); tr.ay.push(a.y); tr.bx.push(b.x); tr.by.push(b.y); tr.dx.push(q.x); tr.dy.push(q.y); }
+ ev.tr = tr; return ev; })()"""
+with sync_playwright() as p:
+ b = p.chromium.launch(); pg = b.new_page()
+ pg.goto(Path(__file__).with_name('droiddeck-boot.html').resolve().as_uri() + '?render=1&w=320&h=200')
+ pg.wait_for_function("typeof DD !== 'undefined'", timeout=15000)
+ ev = pg.evaluate(JS); b.close()
+json.dump(ev, open('events.json', 'w'))
+print({k: v for k, v in ev.items() if k != 'tr'})
diff --git a/artwork/boot-animation-source/render.py b/artwork/boot-animation-source/render.py
new file mode 100644
index 000000000..9c55b9b10
--- /dev/null
+++ b/artwork/boot-animation-source/render.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+"""Render the DroidDeck boot animation to .webm (Steam) or .mp4 (H.264).
+
+Usage: python3 render.py [WIDTH] [HEIGHT] [FPS] [OUT] [light|dark]
+ python3 render.py 1280 800 60 droiddeck-boot.webm
+ python3 render.py 1920 1080 60 droiddeck-boot.mp4
+ python3 render.py 1280 800 60 droiddeck-boot-dark.webm dark
+
+Needs: pip install playwright && playwright install chromium, plus ffmpeg with libvpx-vp9.
+The animation is drawn by droiddeck-boot.html (same folder); each frame gets real
+motion blur (180-degree shutter, up to 48 sub-frames when things move fast).
+"""
+import base64, os, shutil, subprocess, sys, tempfile, time
+from pathlib import Path
+from playwright.sync_api import sync_playwright
+
+W = int(sys.argv[1]) if len(sys.argv) > 1 else 1280
+H = int(sys.argv[2]) if len(sys.argv) > 2 else 800
+FPS = int(sys.argv[3]) if len(sys.argv) > 3 else 60
+OUT = sys.argv[4] if len(sys.argv) > 4 else f"droiddeck-boot-{W}x{H}.webm"
+THEME = sys.argv[5] if len(sys.argv) > 5 else "light" # light | dark
+ONLY = [int(x) for x in os.environ.get("FRAMES", "").split(",") if x] # debug: render some frames only
+
+html = Path(__file__).with_name("droiddeck-boot.html").resolve()
+tmp = Path(os.environ["FRAMEDIR"]) if os.environ.get("FRAMEDIR") else Path(tempfile.mkdtemp(prefix="ddframes_"))
+tmp.mkdir(parents=True, exist_ok=True) # FRAMEDIR=... makes a long render resumable
+t0 = time.time()
+with sync_playwright() as p:
+ browser = p.chromium.launch()
+ page = browser.new_page(viewport={"width": W, "height": H})
+ page.goto(f"{html.as_uri()}?render=1&w={W}&h={H}&theme={THEME}")
+ page.wait_for_function("typeof DD !== 'undefined'", timeout=30000)
+ n = page.evaluate(f"DD.frameCount({FPS})")
+ frames = ONLY or range(n)
+ for i in frames:
+ if (tmp / f"f{i:05d}.png").exists() and not ONLY:
+ continue
+ r = page.evaluate(f"DD.renderFrame({i}, {FPS})")
+ (tmp / f"f{i:05d}.png").write_bytes(base64.b64decode(r["url"].split(",", 1)[1]))
+ if i % 30 == 0 or ONLY:
+ print(f"frame {i+1}/{n} blur samples {r['samples']:>2} {time.time()-t0:5.1f}s", flush=True)
+ browser.close()
+
+if ONLY:
+ print("frames left in", tmp); sys.exit(0)
+
+COLOR = ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709", "-color_range", "tv"]
+SOUND = Path(__file__).with_name("droiddeck-boot-sound.wav") # made by sound.py; muxed in if present
+cmd = ["ffmpeg", "-y", "-loglevel", "error", "-framerate", str(FPS), "-i", str(tmp / "f%05d.png")]
+if SOUND.exists():
+ cmd += ["-i", str(SOUND), "-map", "0:v", "-map", "1:a"]
+ cmd += ["-c:a", "aac", "-b:a", "192k"] if OUT.lower().endswith(".mp4") else ["-c:a", "libopus", "-b:a", "160k"]
+cmd += ["-vf", "scale=out_color_matrix=bt709:out_range=tv:flags=accurate_rnd+full_chroma_int,format=yuv420p"]
+if OUT.lower().endswith(".mp4"): # H.264 for sharing, previews and anything that won't play webm
+ cmd += ["-c:v", "libx264", "-preset", "slow", "-crf", "15", "-profile:v", "high", "-tune", "animation",
+ "-g", str(FPS * 2), "-movflags", "+faststart"]
+else: # VP9 webm for Steam's startup movie
+ cmd += ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "18", "-deadline", "good", "-cpu-used", "2",
+ "-row-mt", "1", "-g", str(FPS * 2)]
+cmd += COLOR + ([] if SOUND.exists() else ["-an"]) + [OUT]
+subprocess.run(cmd, check=True)
+if not os.environ.get("FRAMEDIR"):
+ shutil.rmtree(tmp)
+print(f"wrote {OUT} ({os.path.getsize(OUT)/1e6:.2f} MB) in {time.time()-t0:.0f}s")
diff --git a/artwork/boot-animation-source/sound.py b/artwork/boot-animation-source/sound.py
new file mode 100644
index 000000000..f0f7eb1b8
--- /dev/null
+++ b/artwork/boot-animation-source/sound.py
@@ -0,0 +1,214 @@
+#!/usr/bin/env python3
+"""DroidDeck boot animation: procedural sound design.
+
+Every sound is synthesized (no samples) and placed on the exact physics events the animation
+engine reports (events.json, exported by events.py). Key: D major.
+
+ legs (arch) lands on a muted D
+ ball lands on A, the second bounce a quieter echo
+ bowl lands on F#
+ balancing silence
+ jump silence
+ lock a soft landing in the same voice, then all those notes together as the D chord
+
+Usage: python3 sound.py [events.json] [out.wav]
+"""
+import json, sys
+import numpy as np
+from scipy import signal
+
+SR = 48000
+FPS = 60
+ev = json.load(open(sys.argv[1] if len(sys.argv) > 1 else 'events.json'))
+OUT = sys.argv[2] if len(sys.argv) > 2 else 'droiddeck-boot-sound.wav'
+FRAMES = round(ev['DUR'] * FPS) + 1
+N = int(np.ceil(FRAMES / FPS * SR)) # exactly the video length
+rng = np.random.default_rng(20260925)
+
+# note frequencies
+def hz(n): return 440.0 * 2 ** ((n - 69) / 12)
+D2 = 440.0 * 2 ** ((38 - 69) / 12)
+D3, A3, D4, E4, Fs4, A4, B4, Cs5, D5, E5, Fs5, A5, D6 = [hz(n) for n in (50, 57, 62, 64, 66, 69, 71, 73, 74, 76, 78, 81, 86)]
+
+# ------------------------------------------------------------------ building blocks
+def tt(dur): return np.arange(int(dur * SR)) / SR
+
+def edge(x, a_ms=1.0, r_ms=4.0):
+ """Click-free start and end."""
+ na, nr = max(1, int(a_ms * SR / 1000)), max(1, int(r_ms * SR / 1000))
+ x[:na] *= np.sin(np.linspace(0, np.pi / 2, na)) ** 2
+ x[-nr:] *= np.cos(np.linspace(0, np.pi / 2, nr)) ** 2
+ return x
+
+def bp(x, lo, hi, order=2):
+ return signal.sosfilt(signal.butter(order, [lo, min(hi, SR * .45)], 'bandpass', fs=SR, output='sos'), x)
+
+def lp(x, f, order=2): return signal.sosfilt(signal.butter(order, f, 'lowpass', fs=SR, output='sos'), x)
+def hp(x, f, order=2): return signal.sosfilt(signal.butter(order, f, 'highpass', fs=SR, output='sos'), x)
+
+def modal(freqs, decays, amps, dur):
+ t = tt(dur); y = np.zeros_like(t)
+ for f, d, a in zip(freqs, decays, amps):
+ if f < SR * .45: y += a * np.sin(2 * np.pi * f * t) * np.exp(-t / d)
+ return y
+
+def glide(f0, f1, tau, dur):
+ """Sine whose pitch moves exponentially from f0 to f1."""
+ t = tt(dur); f = f1 + (f0 - f1) * np.exp(-t / tau)
+ return np.sin(2 * np.pi * np.cumsum(f) / SR)
+
+def noise_burst(dur, lo, hi, tau):
+ t = tt(dur); return bp(rng.standard_normal(len(t)), lo, hi) * np.exp(-t / tau)
+
+def pan(x, p):
+ p = np.clip(p, -1, 1); a = (p + 1) * np.pi / 4
+ return np.stack([x * np.cos(a), x * np.sin(a)])
+
+MIX = np.zeros((2, N))
+def put(t0, st, gain=1.0):
+ i = int(round(t0 * SR))
+ if i >= N: return
+ j = min(N, i + st.shape[1]); MIX[:, i:j] += gain * st[:, :j - i]
+
+# ------------------------------------------------------------------ instruments
+def wood(amp, f0=520, size=1.0, thump=0.0, dur=.35):
+ """Hard wood / toy-block clack. thump adds body for heavy landings."""
+ r = [1, 1.93, 2.81, 4.13, 5.62]
+ y = modal([f0 * k for k in r], [s * size for s in (.055, .034, .022, .014, .009)], [1, .55, .42, .26, .16], dur)
+ y += .7 * noise_burst(dur, 1400, 7500, .0035)
+ if thump: y += thump * glide(118, 52, .035, dur) * np.exp(-tt(dur) / .075)
+ return edge(y * amp)
+
+def bonk(amp, f):
+ """Hollow rubber ball on a board: round pitched body, soft click, tiny board knock."""
+ d = .42; t = tt(d)
+ y = glide(f * 1.28, f, .018, d) * np.exp(-t / (.09 + .05 * amp))
+ y += .22 * glide(f * 2.45, f * 2.3, .02, d) * np.exp(-t / .035)
+ y += .55 * glide(95, 60, .03, d) * np.exp(-t / .045)
+ y += .22 * noise_burst(d, 1800, 5000, .002)
+ y += .25 * wood(1, f0=640, size=.6, dur=d)
+ return edge(y * amp, a_ms=.6)
+
+def bowl_ring(amp, f=A4, dur=2.4, dscale=1.0):
+ """Struck metal bowl: inharmonic partials, each split in two for the slow beating shimmer."""
+ t = tt(dur); y = np.zeros_like(t)
+ for k, dec, a, beat in zip((1, 2.71, 5.12, 8.21), tuple(d * dscale for d in (1.25, .62, .3, .15)), (1, .45, .24, .12), (.9, 1.7, 2.6, 3.1)):
+ for s in (-1, 1):
+ y += .5 * a * np.sin(2 * np.pi * (f * k + s * beat / 2) * t + (s > 0) * .8) * np.exp(-t / dec)
+ return edge(y * amp, a_ms=.8, r_ms=30)
+
+def clonk(amp, ring):
+ d = .5; t = tt(d)
+ y = .8 * glide(160, 92, .03, d) * np.exp(-t / .06) + .45 * wood(1, f0=880, size=.7, dur=d)
+ return edge(y * amp), bowl_ring(ring)
+
+def marimba(f, amp, damp=1.0):
+ d = 1.2; t = tt(d)
+ tau = .55 * (440 / f) ** .5 * damp
+ y = modal([f, f * 3.93, f * 9.2], [tau, tau * .22, .03], [1, .32, .07], d)
+ y += .05 * lp(rng.standard_normal(len(t)), 2500) * np.exp(-t / .002)
+ return edge(y * amp, a_ms=1.5, r_ms=40)
+
+def svf_whoosh(env, fc, q=.9):
+ """Noise through a band-pass whose centre follows fc(t): air moving past a spinning piece."""
+ x = rng.standard_normal(len(env)); y = np.zeros_like(x); ic1 = ic2 = 0.0
+ for i in range(len(x)):
+ g = np.tan(np.pi * min(fc[i], SR * .45) / SR); k = 1 / q
+ a1 = 1 / (1 + g * (g + k)); a2 = g * a1; a3 = g * a2
+ v3 = x[i] - ic2; v1 = a1 * ic1 + a2 * v3; v2 = ic2 + a2 * ic1 + a3 * v3
+ ic1 = 2 * v1 - ic1; ic2 = 2 * v2 - ic2; y[i] = v1
+ return y * env
+
+def epiano(f, amp, dur=2.8, decay=1.5):
+ """FM electric piano: bright tine at the strike that mellows as the index falls."""
+ t = tt(dur)
+ idx = 1.7 * np.exp(-t / .22) + .22
+ y = np.sin(2 * np.pi * f * t + idx * np.sin(2 * np.pi * f * t))
+ y += .18 * np.sin(2 * np.pi * f * t + .9 * np.exp(-t / .012) * np.sin(2 * np.pi * 14 * f * t)) * np.exp(-t / .05)
+ return edge(y * np.exp(-t / decay) * amp, a_ms=3, r_ms=60)
+
+def tock(f, amp, decay=.42, sub=0.0):
+ """A landing as a muted note in the chord's own FM e-piano voice: felt contact, no pitch bend."""
+ d = 1.5; t = tt(d)
+ idx = 1.3 * np.exp(-t / .07) + .18
+ y = np.sin(2 * np.pi * f * t + idx * np.sin(2 * np.pi * f * t)) * np.exp(-t / decay)
+ y = lp(y, 2600)
+ y += .30 * lp(rng.standard_normal(len(t)), 1600) * np.exp(-t / .0016)
+ if sub: y += sub * np.sin(2 * np.pi * D2 * t) * np.exp(-t / .075)
+ return edge(y * amp, a_ms=1.5, r_ms=80)
+
+def bell(f, amp, dur=1.6):
+ t = tt(dur)
+ y = np.sin(2 * np.pi * f * t + 1.1 * np.exp(-t / .3) * np.sin(2 * np.pi * 3.5 * f * t)) * np.exp(-t / .7)
+ return edge(y * amp, a_ms=2, r_ms=40)
+
+# ------------------------------------------------------------------ tracks from the engine
+tr = {k: np.array(v) for k, v in ev['tr'].items()}
+TT = tr['t']; dT = TT[1] - TT[0]
+def at(name, t): return np.interp(t, TT, tr[name])
+def speed(xk, yk):
+ return np.hypot(np.gradient(tr[xk], dT), np.gradient(tr[yk], dT))
+X2PAN = 1 / 330 # world units to stereo pan
+drops = ev['drops']; T_L = ev['T_L']; LOCK = ev['LOCK']; HOP = ev['HOP']
+events_log = []
+def log(t, what): events_log.append((round(t, 3), what))
+
+# ------------------------------------------------------------------ cue sheet
+# Kept deliberately sparse: one sound per story beat, with silence between them.
+
+tsamp = np.arange(N) / SR
+
+# 1. the stack: each piece lands on a muted note of the final chord (D, A, A-echo, F#);
+# the lock then plays them all together
+ai = drops['archDrop']['impacts']
+put(ai[0]['t'], pan(tock(D3, .40, decay=.5, sub=.8), .1)); log(ai[0]['t'], 'legs land: D')
+
+bi = drops['ballDrop']['impacts']
+for k, imp in enumerate(bi[:2]):
+ a = .32 * (imp['v'] / bi[0]['v']) ** .9
+ put(imp['t'], pan(tock(A3, a, decay=.36, sub=.3 * a / .32), 0)); log(imp['t'], f'ball: A {a:.2f}')
+
+di = drops['domeDrop']['impacts']
+put(di[0]['t'], pan(tock(Fs4, .28, decay=.45, sub=.45), -.05)); log(di[0]['t'], 'bowl lands: F#')
+
+# 2-3. the balancing act and the jump play in silence: the stack and the lock carry it
+
+# 4. lock: one snap, then a four-note chord that rings out
+put(LOCK, pan(tock(D3, .24, decay=.28, sub=.8), 0)); log(LOCK, 'lock: soft landing in the stack voice')
+for f, a, p, off in ((D3, .24, -.1, 0), (A3, .28, .2, .02), (D4, .30, -.2, .04), (Fs4, .26, .15, .06)):
+ put(LOCK + .018 + off, pan(epiano(f, a, decay=1.3 if f < 300 else 1.1), p))
+log(LOCK + .018, 'D chord')
+
+# ------------------------------------------------------------------ room, master
+def room_ir(dur=1.1, rt60=.75):
+ t = tt(dur); tau = rt60 / 6.9; irs = []
+ for ch in range(2):
+ n = rng.standard_normal(len(t))
+ ir = lp(n, 7000) * np.exp(-t / (tau * .6)) * .5 + lp(n, 2200) * np.exp(-t / tau)
+ ir[:int(.011 * SR)] = 0 # pre-delay
+ for d_ms, g in ((13, .5), (19, .35), (27, .3), (38, .2)):
+ ir[int((d_ms + ch * 1.3) * SR / 1000)] += g
+ irs.append(ir / np.sqrt(np.sum(ir ** 2)))
+ return irs
+irs = room_ir()
+wet = np.stack([signal.fftconvolve(MIX[ch], irs[ch])[:N] for ch in range(2)])
+out = MIX + .14 * wet
+out = hp(out, 28)
+fade = np.ones(N); nf = int(.45 * SR); fade[-nf:] = np.cos(np.linspace(0, np.pi / 2, nf)) ** 2
+out *= fade
+# gentle peak control: soft-knee compress only the loudest transients, then normalise
+pk = np.max(np.abs(out)); x = out / pk
+knee = .5; ax = np.abs(x); over = ax > knee
+x[over] = np.sign(x[over]) * (knee + (1 - knee) * np.tanh((ax[over] - knee) / (1 - knee)))
+L0, L1 = int((LOCK + .06) * SR), int((LOCK + .76) * SR)
+body_rms = np.sqrt(np.mean(x[:, L0:L1].mean(0) ** 2))
+out = x * (10 ** (-14.9 / 20) / body_rms) # chord body held at the level the team liked (-14.9 dB RMS)
+assert np.max(np.abs(out)) < 10 ** (-1 / 20), 'would clip'
+
+import wave
+pcm = (np.clip(out.T, -1, 1) * 32767).astype('