Skip to content

Commit dae8ebf

Browse files
committed
Make the audiolink player load at all
hilll.dev/audiolink has been a 404, and would not have run if it had been found. Two separate things. The page was `content/audiolink/index.html`, and Quartz's asset emitter puts every file it copies through `slugifyFilePath`, which strips `.html` and lowercases the rest. So the page was published as `audiolink/index` with no extension -- which GitHub Pages does not serve as the directory index -- and `Build/` was published as `build/` while the HTML inside it went on asking for `Build/`. Every one of its four requests would have 404'd too. It is a page now, `_index.md`, so Quartz makes it an actual page with the site around it, and the directory is named the way it is served. The player itself was built with gzip compression and no decompression fallback, which means Unity expects the server to send `Content-Encoding: gzip` and hand the loader bytes the browser has already unpacked. GitHub Pages does not: `build.wasm.gz` comes back as `application/gzip` with no encoding header, and there is no configuration anywhere to change that. The loader's answer to that is "Unable to parse build.framework.js.gz". Rebuilding in Unity with the fallback on is the usual fix and needs Unity. Committing the files unpacked is the other one, and doubles what a reader downloads -- 19 MB becomes 38 MB -- because Pages does not compress application/wasm or application/octet-stream on the way out either; the same is measurable on this site's own .mid files, which come back uncompressed. So the page unpacks them itself, in `ts/src/audiolink.ts`. `DecompressionStream` is the same gzip the browser would have used for the header, and it takes 85 ms over the 22 MB of wasm; Unity is handed blob URLs and never learns the difference. Unity's IndexedDB cache is turned off with it, because a blob URL is new on every visit and caching against one could only ever pile up another 38 MB nobody will ask for again. The framework and wasm blobs are released once Unity has read them; the data file, which is its filesystem image, is left until the page is. Verified locally: four requests, no console errors, and Unity gets as far as loading the scene. The "referenced script ... is missing" warnings it then prints come from the build itself and need Unity to fix.
1 parent b4f3e68 commit dae8ebf

8 files changed

Lines changed: 234 additions & 47 deletions

File tree

‎content/audiolink/_index.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
title: audiolink
3+
tags:
4+
- unity
5+
- shaders
6+
---
7+
8+
[AudioLink](https://github.com/llealloo/audiolink) is the audio-visualization
9+
library for Unity I help maintain — it takes an audio signal apart into bands
10+
and hands them to shaders as a texture, so that a world's materials can move
11+
with the music. This is its example scene, built for WebGL.
12+
13+
It is about 19 MB, so give it a moment. There is no sound input here: the
14+
player runs the scene against AudioLink's built-in test signal.
15+
16+
<div class="unity-player">
17+
<canvas id="unity-canvas" width="960" height="600" tabindex="-1"></canvas>
18+
<p id="unity-status" role="status" aria-live="polite">loading…</p>
19+
</div>
20+
21+
<style>
22+
.unity-player canvas {
23+
width: 100%;
24+
max-width: 960px;
25+
aspect-ratio: 960 / 600;
26+
background: #231f20;
27+
border-radius: 5px;
28+
display: block;
29+
}
30+
31+
.unity-player p {
32+
color: var(--gray);
33+
font-size: 0.9rem;
34+
min-height: 1.4em;
35+
}
36+
</style>
37+
38+
<script type="module" src="/js/audiolink.js"></script>
File renamed without changes.

‎content/audiolink/index.html‎

Lines changed: 0 additions & 47 deletions
This file was deleted.

‎ts/src/audiolink.ts‎

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* The AudioLink demo, decompressed in the browser.
3+
*
4+
* Unity built this player with gzip compression and no decompression fallback,
5+
* which means the three big files are gzip on disk and the loader expects the
6+
* server to say so. It wants `Content-Encoding: gzip`, so that the browser has
7+
* already unpacked the bytes by the time they reach it; given anything else it
8+
* refuses with "Unable to parse build.framework.js.gz".
9+
*
10+
* GitHub Pages will not say it. It serves `build.wasm.gz` as
11+
* `application/gzip` with no encoding header at all, and it has no
12+
* configuration to change that. The usual answer is to rebuild in Unity with
13+
* the fallback turned on, which needs Unity; the other is to store the files
14+
* unpacked, which doubles what a reader downloads -- 19 MB to 38 MB -- because
15+
* Pages does not compress `application/wasm` or `application/octet-stream` on
16+
* the way out either.
17+
*
18+
* So the page unpacks them itself. `DecompressionStream` is the same gzip the
19+
* browser would have used for the header, and it takes 85 ms over the 22 MB of
20+
* wasm; the loader is handed blob URLs and never learns the difference.
21+
*/
22+
23+
const BUILD = "/audiolink/build"
24+
25+
/** The parts, with the type each one has to be handed back as. */
26+
const PARTS = [
27+
{ key: "dataUrl", file: "build.data.gz", type: "application/octet-stream" },
28+
{ key: "frameworkUrl", file: "build.framework.js.gz", type: "application/javascript" },
29+
// Anything else and the framework's streaming compile falls back to a slower
30+
// path and says so in the console.
31+
{ key: "codeUrl", file: "build.wasm.gz", type: "application/wasm" },
32+
] as const
33+
34+
interface UnityConfig {
35+
dataUrl: string
36+
frameworkUrl: string
37+
codeUrl: string
38+
streamingAssetsUrl: string
39+
companyName: string
40+
productName: string
41+
productVersion: string
42+
/** Unity's own IndexedDB cache, keyed by URL. See below for why it is off. */
43+
cacheControl: (url: string) => string
44+
}
45+
46+
declare global {
47+
interface Window {
48+
createUnityInstance?: (
49+
canvas: HTMLCanvasElement,
50+
config: UnityConfig,
51+
onProgress?: (progress: number) => void,
52+
) => Promise<unknown>
53+
}
54+
}
55+
56+
function say(message: string): void {
57+
const status = document.getElementById("unity-status")
58+
if (status !== null) status.textContent = message
59+
}
60+
61+
/**
62+
* One part, unpacked into a blob URL.
63+
*
64+
* The progress is the compressed size, because that is the part that goes over
65+
* the network and the only one a `Content-Length` is given for.
66+
*/
67+
async function unpack(
68+
file: string,
69+
type: string,
70+
onBytes: (bytes: number) => void,
71+
): Promise<string> {
72+
const response = await fetch(`${BUILD}/${file}`)
73+
if (!response.ok || response.body === null) {
74+
throw new Error(`${file}: ${response.status}`)
75+
}
76+
77+
// Read by hand rather than through a counting transform: this is where the
78+
// waiting happens -- 12 MB of it for the data file -- and the reader should
79+
// be told how it is going.
80+
const reader = response.body.getReader()
81+
const packed: BlobPart[] = []
82+
for (;;) {
83+
const { done, value } = await reader.read()
84+
if (done) break
85+
86+
// A chunk is typed as possibly backed by a SharedArrayBuffer, which a Blob
87+
// will not take. A fetch body's chunks are not.
88+
packed.push(value as BlobPart)
89+
onBytes(value.byteLength)
90+
}
91+
92+
const unpacked = new Blob(packed).stream().pipeThrough(new DecompressionStream("gzip"))
93+
const bytes = await new Response(unpacked).arrayBuffer()
94+
return URL.createObjectURL(new Blob([bytes], { type }))
95+
}
96+
97+
/** The loader defines `createUnityInstance` as a global and is not a module. */
98+
function loader(): Promise<void> {
99+
return new Promise((resolve, reject) => {
100+
const script = document.createElement("script")
101+
script.src = `${BUILD}/build.loader.js`
102+
script.onload = () => {
103+
resolve()
104+
}
105+
script.onerror = () => {
106+
reject(new Error("build.loader.js did not load"))
107+
}
108+
document.head.append(script)
109+
})
110+
}
111+
112+
async function start(canvas: HTMLCanvasElement): Promise<void> {
113+
if (typeof DecompressionStream === "undefined") {
114+
say("This browser cannot unpack the player: it has no DecompressionStream.")
115+
return
116+
}
117+
118+
// What the three files weigh compressed, so that the reader sees a bar that
119+
// means something before Unity has anything of its own to report.
120+
const total = 18_700_000
121+
let read = 0
122+
123+
let parts: (readonly [string, string])[] = []
124+
try {
125+
say("downloading the player…")
126+
127+
parts = await Promise.all(
128+
PARTS.map(async (part) => {
129+
const url = await unpack(part.file, part.type, (bytes) => {
130+
read += bytes
131+
say(`downloading the player… ${Math.min(99, Math.round((read / total) * 100))}%`)
132+
})
133+
return [part.key, url] as const
134+
}),
135+
)
136+
137+
say("starting…")
138+
await loader()
139+
140+
const createUnityInstance = window.createUnityInstance
141+
if (createUnityInstance === undefined) throw new Error("build.loader.js defined nothing")
142+
143+
await createUnityInstance(
144+
canvas,
145+
{
146+
...(Object.fromEntries(parts) as Pick<UnityConfig, "dataUrl" | "frameworkUrl" | "codeUrl">),
147+
streamingAssetsUrl: `${BUILD}/StreamingAssets`,
148+
companyName: "com.llealloo.audiolink",
149+
productName: "AudioLinkWebProject",
150+
productVersion: "1.4.0",
151+
// Unity keeps downloads in IndexedDB against their URL. A blob URL is
152+
// new every time this page is opened, so that cache could only ever
153+
// grow: the same 38 MB again under a name that will never be asked for
154+
// twice. The browser's own cache still holds the gzip files, which is
155+
// the copy worth keeping.
156+
cacheControl: () => "no-store",
157+
},
158+
(progress) => {
159+
say(`starting… ${Math.round(progress * 100)}%`)
160+
},
161+
)
162+
163+
say("")
164+
165+
// The framework has been evaluated and the wasm compiled, so those two are
166+
// finished with -- 22 MB of the 38 handed back. The data file is Unity's
167+
// filesystem image, and whether it reads that again is its business, so it
168+
// is left alone until the page is.
169+
for (const [key, url] of parts) {
170+
if (key !== "dataUrl") URL.revokeObjectURL(url)
171+
}
172+
} catch (error: unknown) {
173+
for (const [, url] of parts) URL.revokeObjectURL(url)
174+
say(`The player did not start: ${error instanceof Error ? error.message : String(error)}`)
175+
throw error
176+
}
177+
}
178+
179+
function initialise(): void {
180+
const canvas = document.querySelector<HTMLCanvasElement>("#unity-canvas")
181+
if (canvas === null || canvas.dataset.started === "true") return
182+
183+
canvas.dataset.started = "true"
184+
void start(canvas).catch((error: unknown) => {
185+
console.error("[audiolink]", error)
186+
})
187+
}
188+
189+
if (document.readyState === "loading") {
190+
document.addEventListener("DOMContentLoaded", initialise, { once: true })
191+
} else {
192+
initialise()
193+
}
194+
195+
document.addEventListener("nav", initialise)

‎ts/webpack.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const config: Configuration = {
4141
audiooscilloscope: "./dist/audiooscilloscope.js",
4242
background: "./dist/background.js",
4343
you: "./dist/you.js",
44+
audiolink: "./dist/audiolink.js",
4445
insurance: "./dist/insurance.js",
4546
comments: "./dist/comments.js",
4647
},

0 commit comments

Comments
 (0)