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

+
    +
  1. Put the .webm file in ~/.steam/root/config/uioverrides/movies/. Create the folders if they don’t exist.
  2. +
  3. In Steam, open Settings > Customization > Startup Movie and pick it.
  4. +
+
+
+

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('