mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
feat: set DroidDeck startup movie by default (#25)
This commit is contained in:
1 parent
c3b0d8ef03
commit
d4cf890d8a
9 files changed
+1211
No files matched your search
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Binary file not shown.
@@ -0,0 +1,699 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>DroidDeck boot animation</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
box-sizing:border-box;
|
||||
padding-top:env(safe-area-inset-top,0px);
|
||||
padding-bottom:env(safe-area-inset-bottom,0px);
|
||||
--page:#E2E8EE; --panel:#F3F6F9; --ink:#131a21; --muted:#56636F; --line:rgba(19,26,33,.12);
|
||||
--track:rgba(19,26,33,.14); --accent:#1A9FFF; --accent-ink:#0B6FC2; --stage-edge:rgba(19,26,33,.10);
|
||||
--font:"Sora", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--code:ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root:not([data-theme="light"]){
|
||||
--page:#0D1319; --panel:#151D25; --ink:#DCE5ED; --muted:#8A98A6; --line:rgba(220,229,237,.12);
|
||||
--track:rgba(220,229,237,.16); --accent-ink:#5CB8FF; --stage-edge:rgba(0,0,0,.5);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--page:#0D1319; --panel:#151D25; --ink:#DCE5ED; --muted:#8A98A6; --line:rgba(220,229,237,.12);
|
||||
--track:rgba(220,229,237,.16); --accent-ink:#5CB8FF; --stage-edge:rgba(0,0,0,.5);
|
||||
}
|
||||
*,*::before,*::after{box-sizing:inherit}
|
||||
html{scroll-padding-top:env(safe-area-inset-top,0px)}
|
||||
body{margin:0;background:var(--page);color:var(--ink);font-family:var(--font);font-size:15px;line-height:1.55;-webkit-font-smoothing:antialiased}
|
||||
main{max-width:980px;margin:0 auto;padding:28px 20px 40px}
|
||||
header{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:18px}
|
||||
h1{font-size:1.35rem;font-weight:600;letter-spacing:-.01em;margin:0}
|
||||
.meta{color:var(--muted);font-size:.9rem;margin:0}
|
||||
.stage{position:relative;width:100%;aspect-ratio:16/10;border-radius:14px;overflow:hidden;background:#D6E1EA;box-shadow:0 0 0 1px var(--stage-edge),0 18px 50px -24px rgba(10,20,30,.45)}
|
||||
.stage canvas{display:block;width:100%;height:100%;cursor:pointer}
|
||||
.stage.dark{background:#07090C}
|
||||
.stage.dark .hint{color:#C9D3DD}
|
||||
.head-l{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
|
||||
.version{display:inline-flex;border-radius:999px;background:var(--panel);box-shadow:inset 0 0 0 1px var(--line);padding:3px}
|
||||
.version button{border:0;background:none;border-radius:999px;padding:5px 14px;font-size:.85rem;cursor:pointer;color:var(--muted)}
|
||||
.version button[aria-pressed="true"]{background:var(--ink);color:var(--page)}
|
||||
.version button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
.stage .hint{position:absolute;inset:auto 0 14px 0;text-align:center;font-size:.85rem;color:#2c3a47;opacity:0;transition:opacity .25s}
|
||||
.stage.ended .hint{opacity:.75}
|
||||
.controls{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:14px;margin-top:16px}
|
||||
button{font:inherit;color:inherit}
|
||||
.play{display:inline-flex;align-items:center;gap:8px;border:0;background:var(--accent);color:#fff;border-radius:999px;padding:9px 16px 9px 12px;font-weight:600;font-size:.9rem;cursor:pointer}
|
||||
.play svg{width:18px;height:18px}
|
||||
.play:hover{filter:brightness(1.06)}
|
||||
.play:focus-visible,.speed button:focus-visible,.beat:focus-visible,input[type=range]:focus-visible,.fs:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
|
||||
.scrub{position:relative;padding-bottom:22px}
|
||||
input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:28px;background:transparent;margin:0;cursor:pointer}
|
||||
input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:4px;background:linear-gradient(to right,var(--accent) var(--p,0%),var(--track) var(--p,0%))}
|
||||
input[type=range]::-moz-range-track{height:4px;border-radius:4px;background:linear-gradient(to right,var(--accent) var(--p,0%),var(--track) var(--p,0%))}
|
||||
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:var(--ink);margin-top:-6px;border:3px solid var(--page)}
|
||||
input[type=range]::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:var(--ink);border:3px solid var(--page)}
|
||||
.beats{position:absolute;left:8px;right:8px;bottom:0;height:20px}
|
||||
.beat{position:absolute;transform:translateX(-50%);border:0;background:none;padding:0 2px;font-size:.74rem;color:var(--muted);cursor:pointer;white-space:nowrap}
|
||||
.beat:first-child{transform:translateX(-4px)}
|
||||
.beat:first-child::before{left:4px}
|
||||
.beat::before{content:"";position:absolute;left:50%;top:-9px;width:1px;height:6px;background:var(--muted);opacity:.6}
|
||||
.beat:hover,.beat.on{color:var(--ink)}
|
||||
.right{display:flex;align-items:center;gap:10px}
|
||||
.time{font-variant-numeric:tabular-nums;font-size:.85rem;color:var(--muted);min-width:78px;text-align:right}
|
||||
.speed{display:inline-flex;border-radius:999px;background:var(--panel);box-shadow:inset 0 0 0 1px var(--line);padding:3px}
|
||||
.speed button{border:0;background:none;border-radius:999px;padding:4px 10px;font-size:.8rem;cursor:pointer;color:var(--muted)}
|
||||
.speed button[aria-pressed="true"]{background:var(--ink);color:var(--page)}
|
||||
.snd{display:inline-flex;align-items:center;gap:6px;border:0;background:var(--panel);box-shadow:inset 0 0 0 1px var(--line);border-radius:999px;height:32px;padding:0 12px 0 9px;font-size:.8rem;cursor:pointer;color:var(--ink)}
|
||||
.snd svg{width:18px;height:18px}
|
||||
.snd[aria-pressed="true"]{background:var(--accent);color:#fff;box-shadow:none}
|
||||
.snd:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
|
||||
.fs{border:0;background:var(--panel);box-shadow:inset 0 0 0 1px var(--line);border-radius:999px;width:32px;height:32px;display:grid;place-items:center;cursor:pointer;color:var(--ink)}
|
||||
.fs svg{width:16px;height:16px}
|
||||
.install{margin-top:34px;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:28px;border-top:1px solid var(--line);padding-top:24px}
|
||||
.install h2{font-size:1rem;font-weight:600;margin:0 0 8px}
|
||||
.install p,.install ol{margin:0;color:var(--muted);font-size:.9rem;max-width:62ch}
|
||||
.install ol{padding-left:1.2em}
|
||||
.install li+li{margin-top:6px}
|
||||
code{font-family:var(--code);font-size:.84em;background:var(--panel);box-shadow:inset 0 0 0 1px var(--line);padding:1px 6px;border-radius:6px;color:var(--ink);overflow-wrap:anywhere}
|
||||
@media (max-width:640px){
|
||||
main{padding:18px 14px 32px}
|
||||
.controls{grid-template-columns:auto 1fr;row-gap:6px}
|
||||
.right{grid-column:1 / -1;justify-content:space-between}
|
||||
.install{grid-template-columns:1fr}
|
||||
.beat{font-size:.68rem}
|
||||
.snd span{display:none}
|
||||
.snd{padding:0 8px}
|
||||
.right{gap:8px}
|
||||
}
|
||||
body.render{background:#000;margin:0}
|
||||
body.render main{display:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<div class="head-l">
|
||||
<h1>DroidDeck boot animation</h1>
|
||||
<p class="meta" id="meta">6.3 s at 60 fps, with sound</p>
|
||||
</div>
|
||||
<div class="version" role="group" aria-label="Version">
|
||||
<button data-v="light" aria-pressed="true">Light</button>
|
||||
<button data-v="dark" aria-pressed="false">Dark</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="stage" id="stage">
|
||||
<canvas id="cv" aria-label="DroidDeck boot animation: three logo pieces stack into a little droid balancing a bowl on its head, then jump and lock into the DroidDeck mark"></canvas>
|
||||
<div class="hint" id="hint">Tap to replay</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button class="play" id="play" aria-label="Pause">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path id="playIcon" fill="currentColor" d="M7 5h4v14H7zM13 5h4v14h-4z"/></svg>
|
||||
<span id="playLabel">Pause</span>
|
||||
</button>
|
||||
<div class="scrub">
|
||||
<input type="range" id="scrub" min="0" max="1000" step="1" value="0" aria-label="Timeline">
|
||||
<div class="beats" id="beats"></div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<span class="time" id="time">0.00 / 6.30</span>
|
||||
<div class="speed" role="group" aria-label="Playback speed">
|
||||
<button data-s="1" aria-pressed="true">1×</button>
|
||||
<button data-s="0.5" aria-pressed="false">½×</button>
|
||||
<button data-s="0.25" aria-pressed="false">¼×</button>
|
||||
</div>
|
||||
<button class="snd" id="snd" aria-pressed="false" aria-label="Sound">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 9.5h4l5-4.2v13.4l-5-4.2H3z"/><path id="sndMark" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M16 9.5l5 5M21 9.5l-5 5"/></svg>
|
||||
<span>Sound</span>
|
||||
</button>
|
||||
<button class="fs" id="fs" aria-label="Full screen" hidden>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<section class="install">
|
||||
<div>
|
||||
<h2>Use it as the Steam startup movie</h2>
|
||||
<ol>
|
||||
<li>Put the <code>.webm</code> file in <code>~/.steam/<wbr>root/<wbr>config/<wbr>uioverrides/<wbr>movies/</code>. Create the folders if they don’t exist.</li>
|
||||
<li>In Steam, open Settings > Customization > Startup Movie and pick it.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Which file to ship</h2>
|
||||
<p>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 <code>.webm</code>; the <code>.mp4</code> files are for sharing. Everything is drawn and synthesized in code, so the included scripts can output any other size.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
/* =====================================================================
|
||||
DroidDeck boot animation engine
|
||||
Every frame is a pure function of time t, so the live preview and the
|
||||
offline video render draw exactly the same thing.
|
||||
Units: logo units from droiddeck-mark.svg. World origin = frame centre,
|
||||
y points down. The frame is always 690 units tall.
|
||||
===================================================================== */
|
||||
const DD = (() => {
|
||||
'use strict';
|
||||
const deg = Math.PI / 180;
|
||||
const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
|
||||
const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a)); return t * t * (3 - 2 * t); };
|
||||
const eio = t => { t = clamp(t); return t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; };
|
||||
const eInQ = t => { t = clamp(t); return t * t; };
|
||||
const eOutC = t => { t = clamp(t); return 1 - Math.pow(1 - t, 3); };
|
||||
|
||||
// ---- 2D affine helpers. m = [a,b,c,d,e,f] (canvas setTransform order)
|
||||
const mul = (m, n) => [
|
||||
m[0]*n[0] + m[2]*n[1], m[1]*n[0] + m[3]*n[1],
|
||||
m[0]*n[2] + m[2]*n[3], m[1]*n[2] + m[3]*n[3],
|
||||
m[0]*n[4] + m[2]*n[5] + m[4], m[1]*n[4] + m[3]*n[5] + m[5]];
|
||||
const T = (x, y) => [1, 0, 0, 1, x, y];
|
||||
const R = a => { const c = Math.cos(a), s = Math.sin(a); return [c, s, -s, c, 0, 0]; };
|
||||
const S = (x, y) => [x, 0, 0, y, 0, 0];
|
||||
const chain = (...ms) => ms.reduce(mul);
|
||||
const ap = (m, p) => ({ x: m[0]*p.x + m[2]*p.y + m[4], y: m[1]*p.x + m[3]*p.y + m[5] });
|
||||
const rv = (a, x, y) => { const c = Math.cos(a), s = Math.sin(a); return { x: c*x - s*y, y: s*x + c*y }; };
|
||||
|
||||
// ---- Geometry straight from the SVG
|
||||
const G = { L: -31.98, B: 223.72, slotL: 56.055, slotR: 71.885, cy: 111.86,
|
||||
dcx: 63.97, dR: 111.86, hcx: 63.98, hR: 71.81, ocx: 63.98, oR: 55.98 };
|
||||
const ORB = '#1A9FFF';
|
||||
const THEMES = {
|
||||
light: { ink: '#1b1b1b', bg: ['#EDF3F8', '#DCE6EE', '#B6C7D6'], shadow: '22,40,58', shadowK: 1, ringA: 1, ringGlow: 0, glow: 0, noise: 3 },
|
||||
dark: { ink: '#FFFFFF', bg: ['#1B222B', '#0F1419', '#030405'], shadow: '0,0,0', shadowK: 2.3, ringA: 1.5, ringGlow: 14, glow: .34, noise: 4 },
|
||||
};
|
||||
let TH = THEMES.light, THN = 'light';
|
||||
const setTheme = n => { THN = THEMES[n] ? n : 'light'; TH = THEMES[THN]; };
|
||||
const ORB_C = { x: G.ocx, y: G.cy };
|
||||
|
||||
function archPath() { // left piece: the "bridge"
|
||||
const p = new Path2D();
|
||||
const dx = G.slotL - G.hcx, hy = Math.sqrt(G.hR*G.hR - dx*dx);
|
||||
p.moveTo(G.L, 0); p.lineTo(G.slotL, 0); p.lineTo(G.slotL, G.cy - hy);
|
||||
p.arc(G.hcx, G.cy, G.hR, Math.atan2(-hy, dx), Math.atan2(hy, dx), true);
|
||||
p.lineTo(G.slotL, G.B); p.lineTo(G.L, G.B); p.closePath();
|
||||
return p;
|
||||
}
|
||||
function domePath() { // right piece: the curve of the D
|
||||
const dxo = G.slotR - G.dcx, hyo = Math.sqrt(G.dR*G.dR - dxo*dxo);
|
||||
const dxi = G.slotR - G.hcx, hyi = Math.sqrt(G.hR*G.hR - dxi*dxi);
|
||||
const p = new Path2D();
|
||||
p.moveTo(G.slotR, G.cy - hyo);
|
||||
p.arc(G.dcx, G.cy, G.dR, Math.atan2(-hyo, dxo), Math.atan2(hyo, dxo), false);
|
||||
p.lineTo(G.slotR, G.cy + hyi);
|
||||
p.arc(G.hcx, G.cy, G.hR, Math.atan2(hyi, dxi), Math.atan2(-hyi, dxi), true);
|
||||
p.closePath();
|
||||
return p;
|
||||
}
|
||||
function orbPath() { const p = new Path2D(); p.arc(G.ocx, G.cy, G.oR, 0, Math.PI*2); return p; }
|
||||
|
||||
const PATH = { arch: archPath(), dome: domePath(), orb: orbPath() };
|
||||
|
||||
// centroids (numerical, symmetric in y)
|
||||
function centroid(path, x0, x1) {
|
||||
const c = document.createElement('canvas').getContext('2d');
|
||||
let n = 0, sx = 0;
|
||||
for (let x = x0; x <= x1; x += .5) for (let y = 0; y <= G.B; y += .5)
|
||||
if (c.isPointInPath(path, x, y)) { n++; sx += x; }
|
||||
return { x: sx / n, y: G.cy };
|
||||
}
|
||||
const CEN = { arch: centroid(PATH.arch, G.L, G.slotL), dome: centroid(PATH.dome, G.slotR, 176) };
|
||||
|
||||
// anchors: the point of each piece that touches whatever it stands on
|
||||
const ANC = { arch: { x: G.slotL, y: G.cy }, ball: { x: G.ocx, y: G.cy + G.oR }, dome: { x: G.hcx + G.hR, y: G.cy } };
|
||||
const HA = G.slotL - G.L; // arch height when standing (88.035)
|
||||
const WA = G.cy; // arch half width (111.86)
|
||||
const FOOT_HALF = (G.cy - (G.cy - Math.sqrt(G.hR*G.hR - (G.slotL-G.hcx)**2))) ; // unused helper
|
||||
const FEET = [{ x: G.slotL, y: 20.24 }, { x: G.slotL, y: G.B - 20.24 }]; // foot centres (piece coords)
|
||||
|
||||
// ---- Scene constants
|
||||
const VIEW_H = 690; // world units visible vertically
|
||||
const FLOOR = 150;
|
||||
const GRAV = 6500;
|
||||
const DUR = 6.30;
|
||||
const T_L = 4.00; // lift-off
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Drops: exact ballistic bounces (height above whatever you land on)
|
||||
// ------------------------------------------------------------------
|
||||
function drop(o) {
|
||||
const g = GRAV, segs = [], impacts = [];
|
||||
let t = o.t0, v = o.v0;
|
||||
let tau = (-v + Math.sqrt(v*v + 2*g*o.h0)) / g;
|
||||
segs.push({ ts: t, te: t + tau, h0: o.h0, vu: -v });
|
||||
t += tau; v = v + g*tau; impacts.push({ t, v });
|
||||
for (;;) {
|
||||
const vu = o.e * v; if (vu < o.minV) break;
|
||||
const d = 2*vu / g; segs.push({ ts: t, te: t + d, h0: 0, vu });
|
||||
t += d; v = vu; impacts.push({ t, v });
|
||||
}
|
||||
const rest = t;
|
||||
const seg = tt => { for (const s of segs) if (tt < s.te) return s; return null; };
|
||||
return {
|
||||
o, impacts, rest, land: impacts[0].t,
|
||||
h(tt) { if (tt <= o.t0) return o.h0; const s = seg(tt); if (!s) return 0; const u = tt - s.ts; return Math.max(0, s.h0 + s.vu*u - .5*g*u*u); },
|
||||
v(tt) { if (tt <= o.t0 || tt >= rest) return 0; const s = seg(tt); return s ? s.vu - g*(tt - s.ts) : 0; },
|
||||
air(tt) { return tt > o.t0 && tt < rest; }
|
||||
};
|
||||
}
|
||||
|
||||
const archDrop = drop({ t0: 0.10, h0: 520, v0: 250, e: 0.20, minV: 300 });
|
||||
const ballDrop = drop({ t0: 0.50, h0: 440, v0: 250, e: 0.45, minV: 170 });
|
||||
const domeDrop = drop({ t0: 1.15, h0: 330, v0: 250, e: 0.24, minV: 250 });
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// The balancing act: one master sway curve, everything else follows it
|
||||
// through springs so the parts lag, overshoot and settle on their own.
|
||||
// ------------------------------------------------------------------
|
||||
const SWAY = [[1.60, 0], [1.90, .42], [2.24, -.70], [2.62, .95], [2.82, 1.10],
|
||||
[3.08, -.44], [3.30, .16], [3.50, 0]];
|
||||
function sway(t) {
|
||||
if (t <= SWAY[0][0] || t >= SWAY[SWAY.length-1][0]) return 0;
|
||||
for (let i = 0; i < SWAY.length - 1; i++) {
|
||||
const [ta, va] = SWAY[i], [tb, vb] = SWAY[i+1];
|
||||
if (t < tb) { const p = (t - ta)/(tb - ta); return va + (vb - va)*(.5 - .5*Math.cos(Math.PI*p)); }
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
// legs hop sideways to get back under the bowl on the big wobble
|
||||
const HOP = { t: 2.70, d: .19, dx: 26, h: 16 };
|
||||
const hopX = t => HOP.dx*eio((t - HOP.t)/HOP.d);
|
||||
const hopH = t => (t > HOP.t && t < HOP.t + HOP.d) ? HOP.h*Math.sin(Math.PI*(t - HOP.t)/HOP.d) : 0;
|
||||
// crouch before the jump (added straight onto squash)
|
||||
function crouch(t, amt, a, b, hold) {
|
||||
if (t < a || t >= T_L) return 0;
|
||||
if (t < b) return amt * eio((t - a)/(b - a));
|
||||
if (t < hold) return amt;
|
||||
return amt * (1 - eInQ((t - hold)/(T_L - hold)));
|
||||
}
|
||||
const crouchA = t => crouch(t, .17, 3.56, 3.84, 3.91);
|
||||
const crouchB = t => crouch(t, .12, 3.60, 3.86, 3.91);
|
||||
const crouchD = t => crouch(t, .09, 3.64, 3.88, 3.91);
|
||||
|
||||
const PHI_FALL = 5*deg, PSI_FALL = -6*deg, U0 = 12;
|
||||
const ROLL = G.oR/G.dR; // bowl radius is 2x the ball: it turns 1.5x as it rolls
|
||||
const DT = 1/1200, N = Math.ceil((DUR + .2)/DT);
|
||||
const SIG = { phi: new Float32Array(N), u: new Float32Array(N), psi: new Float32Array(N),
|
||||
qA: new Float32Array(N), qB: new Float32Array(N), qD: new Float32Array(N) };
|
||||
|
||||
function peakFactor(w, z) { // peak of a unit-velocity kick
|
||||
const wd = w*Math.sqrt(1 - z*z), ts = Math.atan(wd/(z*w))/wd;
|
||||
return Math.exp(-z*w*ts)*Math.sin(wd*ts)/wd;
|
||||
}
|
||||
const SPR = { A: [40, .32], B: [34, .28], D: [36, .34] };
|
||||
const kick = (k, peak) => peak / peakFactor(SPR[k][0], SPR[k][1]);
|
||||
|
||||
(function simulate() {
|
||||
const K = [];
|
||||
const v1a = archDrop.impacts[0].v, v1b = ballDrop.impacts[0].v, v1d = domeDrop.impacts[0].v;
|
||||
archDrop.impacts.forEach(i => K.push({ t: i.t, k: 'A', dv: kick('A', .13 * i.v/v1a) }));
|
||||
ballDrop.impacts.forEach(i => { const r = i.v/v1b;
|
||||
K.push({ t: i.t, k: 'B', dv: kick('B', .24*r) }); K.push({ t: i.t, k: 'A', dv: kick('A', .06*r) });
|
||||
K.push({ t: i.t, k: 'phi', dv: .55*r }); });
|
||||
domeDrop.impacts.forEach(i => { const r = i.v/v1d;
|
||||
K.push({ t: i.t, k: 'D', dv: kick('D', .11*r) }); K.push({ t: i.t, k: 'B', dv: kick('B', .17*r) });
|
||||
K.push({ t: i.t, k: 'A', dv: kick('A', .07*r) }); });
|
||||
K.push({ t: HOP.t + HOP.d, k: 'A', dv: kick('A', .07) }); K.push({ t: HOP.t + HOP.d, k: 'B', dv: kick('B', .06) });
|
||||
K.push({ t: HOP.t + HOP.d, k: 'D', dv: kick('D', .04) });
|
||||
K.sort((a, b) => a.t - b.t);
|
||||
|
||||
let phi = PHI_FALL, phiV = 0, phiA = 0, u = U0, uV = 0, psi = PSI_FALL, psiV = 0;
|
||||
const q = { A: [0, 0], B: [0, 0], D: [0, 0] };
|
||||
let ki = 0, crossings = 0, lastSign = 1;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const t = i*DT;
|
||||
while (ki < K.length && K[ki].t <= t) {
|
||||
const k = K[ki++];
|
||||
if (k.k === 'phi') phiV += k.dv; else q[k.k][1] += k.dv;
|
||||
}
|
||||
const s = sway(t);
|
||||
// arch rocking on its feet
|
||||
if (t < archDrop.land) { phi = PHI_FALL; phiV = 0; phiA = 0; }
|
||||
else {
|
||||
const w = 20, z = .38, tgt = 5.6*deg*sway(t - .10);
|
||||
phiA = -w*w*(phi - tgt) - 2*z*w*phiV; phiV += phiA*DT; phi += phiV*DT;
|
||||
const sg = Math.sign(phi) || lastSign;
|
||||
if (sg !== lastSign && crossings < 2 && t < 1.0) { q.A[1] += kick('A', .035); crossings++; }
|
||||
lastSign = sg;
|
||||
}
|
||||
// ball rolling along the arch top
|
||||
if (t < ballDrop.rest) { u = U0; uV = 0; }
|
||||
else { const w = 13, z = .5, tgt = 24*sway(t - .12); const a = -w*w*(u - tgt) - 2*z*w*uV; uV += a*DT; u += uV*DT; }
|
||||
// dome: floppy hat with inertia (it lags the body)
|
||||
if (t < domeDrop.land) { psi = PSI_FALL; psiV = 0; }
|
||||
else { const w = 13, z = .22, tgt = 11*deg*s; const a = -w*w*(psi - tgt) - 2*z*w*psiV - .6*phiA; psiV += a*DT; psi += psiV*DT; }
|
||||
// squash springs; falling pieces stretch along their velocity
|
||||
const tgA = archDrop.air(t) ? -Math.min(.12, Math.abs(archDrop.v(t))*4.6e-5) : 0;
|
||||
const tgB = ballDrop.air(t) ? -Math.min(.14, Math.abs(ballDrop.v(t))*6.5e-5) : 0;
|
||||
const tgD = domeDrop.air(t) ? -Math.min(.10, Math.abs(domeDrop.v(t))*4.0e-5) : 0;
|
||||
for (const [k, tg] of [['A', tgA], ['B', tgB], ['D', tgD]]) {
|
||||
const [w, z] = SPR[k]; const st = q[k];
|
||||
const a = -w*w*(st[0] - tg) - 2*z*w*st[1]; st[1] += a*DT; st[0] += st[1]*DT;
|
||||
}
|
||||
SIG.phi[i] = phi; SIG.u[i] = u; SIG.psi[i] = psi;
|
||||
SIG.qA[i] = q.A[0]; SIG.qB[i] = q.B[0]; SIG.qD[i] = q.D[0];
|
||||
}
|
||||
})();
|
||||
const sig = (name, t) => {
|
||||
const x = clamp(t/DT, 0, N - 1.001), i = Math.floor(x), f = x - i, a = SIG[name];
|
||||
return a[i] + (a[i+1] - a[i])*f;
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Standing pose: a kinematic chain floor → arch → ball → dome
|
||||
// ------------------------------------------------------------------
|
||||
function standPose(t) {
|
||||
const hA = archDrop.h(t) + hopH(t), hx = hopX(t), phi = sig('phi', t), u = sig('u', t), psi = sig('psi', t);
|
||||
const qA = sig('qA', t) + crouchA(t), qB = sig('qB', t) + crouchB(t), qD = sig('qD', t) + crouchD(t);
|
||||
const hB = ballDrop.h(t), hD = domeDrop.h(t);
|
||||
|
||||
const sxA = 1 + .55*qA, syA = 1 - qA;
|
||||
const side = phi >= 0 ? 1 : -1; // rock on the outer corner of one foot
|
||||
const piv = { x: hx + side*WA*sxA, y: FLOOR - hA };
|
||||
const off = rv(phi, -side*WA*sxA, 0);
|
||||
const A = { x: piv.x + off.x, y: piv.y + off.y };
|
||||
const mA = chain(T(A.x, A.y), R(phi), S(sxA, syA), R(90*deg), T(-ANC.arch.x, -ANC.arch.y));
|
||||
|
||||
const top = rv(phi, u, -HA*syA);
|
||||
const cB = { x: A.x + top.x, y: A.y + top.y - hB };
|
||||
const sxB = 1 + .8*qB, syB = 1 - qB;
|
||||
const mB = chain(T(cB.x, cB.y), R(phi), S(sxB, syB), T(-ANC.ball.x, -ANC.ball.y));
|
||||
const bc = rv(phi, 0, -G.oR*syB), O = { x: cB.x + bc.x, y: cB.y + bc.y };
|
||||
|
||||
// bowl: outer curve resting on the ball, rolling without slipping
|
||||
const ang = phi + psi, dd = rv(ang, 0, -G.oR*syB);
|
||||
const cD = { x: O.x + dd.x, y: O.y + dd.y - hD };
|
||||
const sxD = 1 + .6*qD, syD = 1 - qD;
|
||||
const mD = chain(T(cD.x, cD.y), R(ang), S(sxD, syD), T(0, -G.dR), R(psi*ROLL), R(90*deg), T(-G.dcx, -G.cy));
|
||||
return { arch: mA, ball: mB, dome: mD, O, hA };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Flight: lift-off, unfold around the orb, magnetic clack, hover settle
|
||||
// ------------------------------------------------------------------
|
||||
const FL = { TR: .34, APEX: -108, TC: .44, X: 48, ROT: .40 };
|
||||
const H0 = (() => {
|
||||
const p = standPose(T_L), O = p.O, h = { O };
|
||||
for (const k of ['arch', 'dome']) {
|
||||
const c = ap(p[k], CEN[k]), m = p[k];
|
||||
const fin = { x: CEN[k].x - ORB_C.x, y: 0 };
|
||||
h[k] = { a0: Math.atan2(c.y - O.y, c.x - O.x), r0: Math.hypot(c.x - O.x, c.y - O.y),
|
||||
th0: Math.atan2(m[1], m[0]), aF: k === 'arch' ? Math.PI : 0, rF: Math.abs(fin.x) };
|
||||
}
|
||||
return h;
|
||||
})();
|
||||
const damp = (s, w, z) => { const wd = w*Math.sqrt(1 - z*z); return Math.exp(-z*w*s)*Math.sin(wd*s)/wd; };
|
||||
const fromRest = (x0, s, w, z) => { const wd = w*Math.sqrt(1 - z*z);
|
||||
return Math.exp(-z*w*s)*(x0*Math.cos(wd*s) + (z*w*x0/wd)*Math.sin(wd*s)); };
|
||||
|
||||
function flightPose(t) {
|
||||
const tau = t - T_L, sC = tau - FL.TC;
|
||||
const done = tau > 1.7;
|
||||
let Oy = tau < FL.TR ? H0.O.y + (FL.APEX - H0.O.y)*(1 - (1 - tau/FL.TR)**2)
|
||||
: fromRest(FL.APEX, tau - FL.TR, 9.5, .75);
|
||||
let Ox = H0.O.x*(1 - eio(tau/.4));
|
||||
if (done) { Ox = 0; Oy = 0; }
|
||||
const O = { x: Ox, y: Oy };
|
||||
const st = -Math.sin(Math.PI*clamp(tau/.30))*.085; // stretch on the way up
|
||||
const gs = sC > 0 && !done ? 1 + .05*Math.exp(-6*sC)*Math.sin(20*sC) : 1;
|
||||
const G_ = chain(T(O.x, O.y), S(gs, gs), T(-O.x, -O.y));
|
||||
|
||||
const piece = (k, jsign, kq) => {
|
||||
const h = H0[k];
|
||||
const a = h.a0 + (h.aF - h.a0)*eio(tau/FL.ROT);
|
||||
let r = h.rF + (h.r0 - h.rF)*(1 - smooth(0, .30, tau));
|
||||
r += FL.X*smooth(0, .22, tau)*(1 - eInQ((tau - .30)/(FL.TC - .30)));
|
||||
if (sC > 0 && !done) r += 3.6*Math.exp(-14*sC)*Math.abs(Math.sin(38*sC)); // hard stop, tiny rebound
|
||||
let th = h.th0 + (0 - h.th0)*eio((tau - .02)/FL.ROT);
|
||||
if (done) { r = h.rF; th = 0; }
|
||||
const q = st*kq, c = CEN[k];
|
||||
const C = { x: O.x + r*Math.cos(done ? h.aF : a), y: O.y + r*Math.sin(done ? h.aF : a) };
|
||||
return mul(G_, chain(T(C.x, C.y), S(1 + .6*q, 1 - q), R(th), T(-c.x, -c.y)));
|
||||
};
|
||||
const qb = st*1.1;
|
||||
const ball = mul(G_, chain(T(O.x, O.y), S(1 + .8*qb, 1 - qb), T(-ORB_C.x, -ORB_C.y)));
|
||||
return { arch: piece('arch', 1, .8), dome: piece('dome', -1, .8), ball, O, tau };
|
||||
}
|
||||
|
||||
function pose(t) {
|
||||
t = clamp(t, 0, DUR);
|
||||
const p = t < T_L ? standPose(t) : flightPose(t);
|
||||
// contact shadows
|
||||
const fade = t < T_L ? 1 : 1 - smooth(0, .2, t - T_L);
|
||||
const sh = [];
|
||||
if (fade > 0) {
|
||||
let lowest = Infinity, cx = 0;
|
||||
for (const f of FEET) {
|
||||
const w = ap(p.arch, f), hf = Math.max(0, FLOOR - w.y);
|
||||
lowest = Math.min(lowest, hf); cx += w.x/2;
|
||||
sh.push({ x: w.x, rx: 30 + hf*.35, ry: 5.5 + hf*.05, a: .34*Math.exp(-hf/22)*fade });
|
||||
}
|
||||
sh.unshift({ x: cx, rx: 150 + lowest*.35, ry: 15 + lowest*.03, a: .17*Math.exp(-lowest/210)*fade });
|
||||
}
|
||||
p.shadows = sh;
|
||||
// shock ring when the pieces lock
|
||||
const sC = t - (T_L + FL.TC);
|
||||
p.sC = sC;
|
||||
p.ring = sC > 0 && sC < .8 ? { O: p.O, r: 118 + 210*eOutC(sC/.8), a: .34*(1 - sC/.8)**2, w: 4*(1 - .7*sC/.8) } : null;
|
||||
return p;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Drawing
|
||||
// ------------------------------------------------------------------
|
||||
function makeBG(W, H, noise) {
|
||||
const c = document.createElement('canvas'); c.width = W; c.height = H;
|
||||
const g = c.getContext('2d');
|
||||
const cx = W/2, cy = H*.47, R = Math.hypot(W, H)*.6;
|
||||
const gr = g.createRadialGradient(cx, cy, 0, cx, cy, R);
|
||||
gr.addColorStop(0, TH.bg[0]); gr.addColorStop(.42, TH.bg[1]); gr.addColorStop(1, TH.bg[2]);
|
||||
g.fillStyle = gr; g.fillRect(0, 0, W, H);
|
||||
if (noise) { // static dither: kills banding in the video
|
||||
const id = g.getImageData(0, 0, W, H), d = id.data; let s = 1337;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
s = (s*1664525 + 1013904223) >>> 0; const n = ((s >>> 24)/255 - .5)*noise;
|
||||
d[i] += n; d[i+1] += n; d[i+2] += n;
|
||||
}
|
||||
g.putImageData(id, 0, 0);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
function draw(ctx, W, H, t, bg) {
|
||||
const px = H/VIEW_H, base = [px, 0, 0, px, W/2, H/2];
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.drawImage(bg, 0, 0, W, H);
|
||||
const p = pose(t);
|
||||
for (const s of p.shadows) {
|
||||
if (s.a < .003) continue;
|
||||
ctx.setTransform(...mul(base, [s.rx, 0, 0, s.ry, s.x, FLOOR]));
|
||||
const g = ctx.createRadialGradient(0, 0, 0, 0, 0, 1);
|
||||
const a = Math.min(.9, s.a*TH.shadowK), c = TH.shadow;
|
||||
g.addColorStop(0, `rgba(${c},${a})`); g.addColorStop(.55, `rgba(${c},${a*.45})`); g.addColorStop(1, `rgba(${c},0)`);
|
||||
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 0, 1, 0, Math.PI*2); ctx.fill();
|
||||
}
|
||||
if (p.ring) {
|
||||
ctx.setTransform(...base);
|
||||
ctx.strokeStyle = `rgba(26,159,255,${Math.min(1, p.ring.a*TH.ringA)})`; ctx.lineWidth = p.ring.w;
|
||||
if (TH.ringGlow) { ctx.shadowColor = `rgba(26,159,255,${Math.min(1, p.ring.a*2)})`; ctx.shadowBlur = TH.ringGlow*px; }
|
||||
ctx.beginPath(); ctx.arc(p.ring.O.x, p.ring.O.y, p.ring.r, 0, Math.PI*2); ctx.stroke();
|
||||
ctx.shadowBlur = 0; ctx.shadowColor = 'transparent';
|
||||
}
|
||||
if (TH.glow && p.sC > 0 && p.sC < 1.4) { // dark only: the orb flashes on at lock, light leaks through the halo gap
|
||||
const ga = TH.glow*Math.min(1, p.sC/.03)*Math.exp(-p.sC/.32)*(1 - smooth(1.0, 1.4, p.sC));
|
||||
ctx.setTransform(...base);
|
||||
const g = ctx.createRadialGradient(p.O.x, p.O.y, G.oR*.9, p.O.x, p.O.y, G.oR*2.6);
|
||||
g.addColorStop(0, `rgba(26,159,255,${ga})`); g.addColorStop(.35, `rgba(26,159,255,${ga*.45})`); g.addColorStop(1, 'rgba(26,159,255,0)');
|
||||
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(p.O.x, p.O.y, G.oR*2.6, 0, Math.PI*2); ctx.fill();
|
||||
}
|
||||
ctx.fillStyle = TH.ink;
|
||||
ctx.setTransform(...mul(base, p.arch)); ctx.fill(PATH.arch);
|
||||
ctx.setTransform(...mul(base, p.dome)); ctx.fill(PATH.dome);
|
||||
ctx.fillStyle = ORB;
|
||||
ctx.setTransform(...mul(base, p.ball)); ctx.fill(PATH.orb);
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
}
|
||||
|
||||
// how far (px) things move during an exposure: drives motion-blur sample count
|
||||
const PROBE = { arch: [{x:G.L,y:0},{x:G.L,y:G.B},{x:G.slotL,y:0},{x:G.slotL,y:G.B}],
|
||||
dome: [{x:G.slotR,y:0},{x:G.slotR,y:G.B},{x:176,y:G.cy}],
|
||||
ball: [{x:G.ocx-G.oR,y:G.cy},{x:G.ocx+G.oR,y:G.cy},{x:G.ocx,y:G.cy-G.oR},{x:G.ocx,y:G.cy+G.oR}] };
|
||||
function motionPx(t0, t1, H) {
|
||||
const a = pose(t0), b = pose(t1), px = H/VIEW_H; let m = 0;
|
||||
for (const k of ['arch', 'dome', 'ball']) for (const q of PROBE[k]) {
|
||||
const p0 = ap(a[k], q), p1 = ap(b[k], q); m = Math.max(m, Math.hypot(p1.x - p0.x, p1.y - p0.y));
|
||||
}
|
||||
if (b.ring) m = Math.max(m, Math.abs(b.ring.r - (a.ring ? a.ring.r : 116)));
|
||||
return m*px;
|
||||
}
|
||||
|
||||
// Offline render with real motion blur (180° shutter, adaptive samples)
|
||||
let RC = null;
|
||||
function renderFrame(i, fps = 60, shutter = .5) {
|
||||
const cv = document.getElementById('cv'), W = cv.width, H = cv.height;
|
||||
if (!RC || RC.W !== W || RC.H !== H || RC.theme !== THN) {
|
||||
const ctx = cv.getContext('2d', { willReadFrequently: true });
|
||||
RC = { W, H, theme: THN, ctx, bg: makeBG(W, H, TH.noise), acc: new Uint16Array(W*H*4) };
|
||||
}
|
||||
const t = i/fps, ex = shutter/fps;
|
||||
const t0 = Math.max(0, t - ex/2), t1 = Math.min(DUR, t + ex/2);
|
||||
const n = Math.max(1, Math.min(48, Math.ceil(motionPx(t0, t1, H)/.45)));
|
||||
if (n === 1) { draw(RC.ctx, W, H, t, RC.bg); }
|
||||
else {
|
||||
const acc = RC.acc; acc.fill(0);
|
||||
for (let k = 0; k < n; k++) {
|
||||
draw(RC.ctx, W, H, t0 + (k + .5)/n*(t1 - t0), RC.bg);
|
||||
const d = RC.ctx.getImageData(0, 0, W, H).data;
|
||||
for (let j = 0; j < d.length; j++) acc[j] += d[j];
|
||||
}
|
||||
const out = RC.ctx.createImageData(W, H), o = out.data;
|
||||
for (let j = 0; j < o.length; j++) o[j] = (acc[j] + (n >> 1))/n;
|
||||
RC.ctx.putImageData(out, 0, 0);
|
||||
}
|
||||
return { url: cv.toDataURL('image/png'), samples: n };
|
||||
}
|
||||
|
||||
return { DUR, T_L, HOP, setTheme, get theme() { return THN; }, draw, makeBG, renderFrame, pose, sig, sway, drops: { archDrop, ballDrop, domeDrop },
|
||||
BEATS: [['Drop', .10], ['Wobble', 1.60], ['Jump', 3.56], ['Lock', T_L + FL.TC]],
|
||||
frameCount: fps => Math.round(DUR*fps) + 1, CEN, H0 };
|
||||
})();
|
||||
|
||||
/* =====================================================================
|
||||
Preview player (or bare canvas when ?render=1)
|
||||
===================================================================== */
|
||||
(() => {
|
||||
const qs = new URLSearchParams(location.search);
|
||||
const cv = document.getElementById('cv');
|
||||
if (qs.get('render')) {
|
||||
document.body.classList.add('render');
|
||||
DD.setTheme(qs.get('theme') || 'light');
|
||||
document.body.appendChild(cv);
|
||||
cv.width = +qs.get('w') || 1280; cv.height = +qs.get('h') || 800;
|
||||
cv.style.width = cv.width + 'px'; cv.style.height = cv.height + 'px';
|
||||
return;
|
||||
}
|
||||
const stage = document.getElementById('stage'), ctx = cv.getContext('2d');
|
||||
const scrub = document.getElementById('scrub'), timeEl = document.getElementById('time');
|
||||
const playBtn = document.getElementById('play'), playIcon = document.getElementById('playIcon'), playLabel = document.getElementById('playLabel');
|
||||
const beatsEl = document.getElementById('beats'), fsBtn = document.getElementById('fs');
|
||||
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const sndBtn = document.getElementById('snd'), sndMark = document.getElementById('sndMark'), hint = document.getElementById('hint');
|
||||
let W = 0, H = 0, bg = null, t = reduce ? DD.DUR : 0, playing = !reduce, speed = 1, last = 0, raf = 0;
|
||||
|
||||
// Soundtrack through Web Audio. When it's playing, the audio clock drives the picture.
|
||||
const Sound = (() => {
|
||||
let ctx = null, buf = null, src = null, on = false, pending = null, t0 = 0, c0 = 0, rate = 1;
|
||||
const bytes = () => { const b = atob(DD_SOUND), u = new Uint8Array(b.length); for (let i = 0; i < b.length; i++) u[i] = b.charCodeAt(i); return u.buffer; };
|
||||
async function ensure() { // call from a tap: browsers only allow sound after one
|
||||
if (!ctx) { const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return false; ctx = new AC(); }
|
||||
if (ctx.state !== 'running') { try { await ctx.resume(); } catch (e) {} }
|
||||
if (!buf) { pending = pending || new Promise((ok, no) => ctx.decodeAudioData(bytes(), ok, no));
|
||||
try { buf = await pending; } catch (e) { pending = null; return false; } }
|
||||
return ctx.state === 'running';
|
||||
}
|
||||
function stop() { if (src) { try { src.stop(); } catch (e) {} src.disconnect(); src = null; } }
|
||||
function start(at, r) {
|
||||
stop(); if (!on || !buf || !ctx || ctx.state !== 'running' || at >= buf.duration) return;
|
||||
src = ctx.createBufferSource(); src.buffer = buf; src.playbackRate.value = r; src.connect(ctx.destination);
|
||||
c0 = ctx.currentTime; t0 = at; rate = r; src.start(c0, at);
|
||||
}
|
||||
function clock() { return src ? t0 + Math.max(0, ctx.currentTime - c0 - (ctx.outputLatency || 0))*rate : null; }
|
||||
return { ensure, start, stop, clock, get on() { return on; }, set on(v) { on = v; if (!v) stop(); } };
|
||||
})();
|
||||
|
||||
// version: remembered per viewer, otherwise follows the system theme
|
||||
let ver = null; try { ver = localStorage.getItem('dd-version'); } catch (e) {}
|
||||
if (ver !== 'light' && ver !== 'dark') ver = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
function setVersion(v) {
|
||||
ver = v; DD.setTheme(v); stage.classList.toggle('dark', v === 'dark');
|
||||
document.querySelectorAll('.version button').forEach(b => b.setAttribute('aria-pressed', b.dataset.v === v));
|
||||
if (W) bg = DD.makeBG(W, H, 0);
|
||||
try { localStorage.setItem('dd-version', v); } catch (e) {}
|
||||
}
|
||||
document.querySelectorAll('.version button').forEach(b => b.onclick = () => { setVersion(b.dataset.v); render(); });
|
||||
setVersion(ver);
|
||||
function size() {
|
||||
const r = stage.getBoundingClientRect(), dpr = Math.min(window.devicePixelRatio || 1, 2.5);
|
||||
const w = Math.round(r.width*dpr), h = Math.round(r.height*dpr);
|
||||
if (w !== W || h !== H) { W = cv.width = w; H = cv.height = h; bg = DD.makeBG(W, H, 0); }
|
||||
render();
|
||||
}
|
||||
const ICON = { pause: 'M7 5h4v14H7zM13 5h4v14h-4z', play: 'M8 5.5v13l11-6.5z', replay: 'M12 5a7 7 0 1 1-6.6 4.7l1.9.6A5 5 0 1 0 12 7v3L7.5 6 12 2z' };
|
||||
function ui() {
|
||||
const ended = t >= DD.DUR - 1e-6;
|
||||
const mode = playing ? 'pause' : ended ? 'replay' : 'play';
|
||||
playIcon.setAttribute('d', ICON[mode]);
|
||||
playLabel.textContent = mode === 'pause' ? 'Pause' : mode === 'replay' ? 'Replay' : 'Play';
|
||||
playBtn.setAttribute('aria-label', playLabel.textContent);
|
||||
stage.classList.toggle('ended', ended && !playing);
|
||||
hint.textContent = Sound.on ? 'Tap to replay' : 'Turn on Sound to hear it';
|
||||
sndBtn.setAttribute('aria-pressed', Sound.on);
|
||||
sndMark.setAttribute('d', Sound.on ? 'M15.5 9.2a4 4 0 0 1 0 5.6M18.3 6.6a7.6 7.6 0 0 1 0 10.8' : 'M16 9.5l5 5M21 9.5l-5 5');
|
||||
const p = t/DD.DUR; scrub.value = Math.round(p*1000); scrub.style.setProperty('--p', (p*100) + '%');
|
||||
timeEl.textContent = `${t.toFixed(2)} / ${DD.DUR.toFixed(2)}`;
|
||||
let cur = -1; DD.BEATS.forEach((b, i) => { if (t >= b[1]) cur = i; });
|
||||
[...beatsEl.children].forEach((el, i) => el.classList.toggle('on', i === cur));
|
||||
}
|
||||
function render() { if (bg) DD.draw(ctx, W, H, t, bg); ui(); }
|
||||
function loop(now) {
|
||||
raf = 0;
|
||||
if (playing) {
|
||||
const heard = Sound.clock();
|
||||
if (heard !== null) t = Math.min(DD.DUR, heard);
|
||||
else t = Math.min(DD.DUR, t + Math.min(.05, (now - last)/1000)*speed);
|
||||
if (t >= DD.DUR) playing = false;
|
||||
}
|
||||
last = now; render();
|
||||
if (playing) raf = requestAnimationFrame(loop);
|
||||
}
|
||||
function play() {
|
||||
if (t >= DD.DUR - 1e-6) t = 0;
|
||||
playing = true; Sound.start(t, speed); last = performance.now();
|
||||
if (!raf) raf = requestAnimationFrame(loop);
|
||||
}
|
||||
function pause() { playing = false; Sound.stop(); render(); }
|
||||
sndBtn.onclick = async () => {
|
||||
if (Sound.on) { Sound.on = false; ui(); return; }
|
||||
Sound.on = true; ui();
|
||||
if (!(await Sound.ensure())) { Sound.on = false; ui(); return; }
|
||||
t = 0; play(); // replay from the top so it's heard in sync
|
||||
};
|
||||
playBtn.onclick = () => playing ? pause() : play();
|
||||
cv.onclick = () => playing ? pause() : play();
|
||||
scrub.oninput = () => { playing = false; Sound.stop(); t = scrub.value/1000*DD.DUR; render(); };
|
||||
document.querySelectorAll('.speed button').forEach(b => b.onclick = () => {
|
||||
speed = +b.dataset.s; document.querySelectorAll('.speed button').forEach(x => x.setAttribute('aria-pressed', x === b));
|
||||
if (playing) Sound.start(t, speed);
|
||||
});
|
||||
DD.BEATS.forEach(([name, bt]) => {
|
||||
const b = document.createElement('button'); b.className = 'beat'; b.textContent = name;
|
||||
b.style.left = (bt/DD.DUR*100) + '%'; b.onclick = () => { t = bt; play(); };
|
||||
beatsEl.appendChild(b);
|
||||
});
|
||||
addEventListener('keydown', e => {
|
||||
if (e.target.closest('input,button') && e.key !== ' ') return;
|
||||
if (e.key === ' ') { e.preventDefault(); playing ? pause() : play(); }
|
||||
else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||
e.preventDefault(); playing = false; Sound.stop(); t = Math.max(0, Math.min(DD.DUR, t + (e.key === 'ArrowRight' ? 1 : -1)/60)); render();
|
||||
}
|
||||
});
|
||||
if (stage.requestFullscreen) {
|
||||
fsBtn.hidden = false;
|
||||
fsBtn.onclick = () => document.fullscreenElement ? document.exitFullscreen() : stage.requestFullscreen().catch(() => {});
|
||||
}
|
||||
new ResizeObserver(size).observe(stage);
|
||||
addEventListener('resize', size);
|
||||
size();
|
||||
if (playing) play();
|
||||
})();
|
||||
</script>
|
||||
<script id="dd-sound">/* soundtrack: MP3, base64. Rebuild with embed_sound.py */
|
||||
const DD_SOUND = /*SOUND*/"SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjE2LjEwMAAAAAAAAAAAAAAA//uUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAEJAAGPAAADBggLDhATFhgaHR8iJScqLS8yNDY5Oz5BQ0ZJS01QUlVYWl1gYmVnaWxucXR2eXx+gIOFiIuNkJOVmJqcn6Gkp6msr7Gztri7vsDDxsjLzc/S1Nfa3N/i5Obp6+7x8/b5+/4AAAAATGF2YzYwLjMxAAAAAAAAAAAAAAAAJAXgAAAAAAABjwC+eaeUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uUZAAP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAETEFNRTMuMTAwVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVMQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//uUZFGP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVMQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//uUZFGP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVMQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//uUZFGP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVMQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//uUZFGP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVMQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//uUZFGP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVMQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//uUZFGP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVMQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//uUZFGP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVMQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//uUZFGP8AAAaQAAAAgAAA0gAAABAAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVMQU1FMy4xLine truncated
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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")
|
||||
@@ -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'})
|
||||
@@ -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")
|
||||
@@ -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('<i2')
|
||||
with wave.open(OUT, 'wb') as w:
|
||||
w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR); w.writeframes(pcm.tobytes())
|
||||
json.dump(sorted(events_log), open('sound_events.json', 'w'), indent=0)
|
||||
print(f'wrote {OUT}: {N / SR:.3f}s, {len(events_log)} cues')
|
||||
for t, w_ in sorted(events_log): print(f' {t:6.3f} {w_}')
|
||||
Reference in new issue
Block a user