From 63ac1ba03eace8cd8cfaeceddbe468a84a13c04b Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Wed, 30 Sep 2026 11:22:15 +0200 Subject: [PATCH] Decode inlined sounds directly instead of fetching them, with the test. --- src/soundUtils.test.ts | 77 ++++++++++++++++++++++++++++++++++++++++++ src/soundUtils.ts | 54 +++++++++++++++++++++++++---- 2 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 src/soundUtils.test.ts diff --git a/src/soundUtils.test.ts b/src/soundUtils.test.ts new file mode 100644 index 000000000..e65441f83 --- /dev/null +++ b/src/soundUtils.test.ts @@ -0,0 +1,77 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { prefetchSounds } from "./soundUtils"; + +const bytesOf = (buffer: ArrayBuffer): number[] => [...new Uint8Array(buffer)]; + +describe("prefetchSounds", () => { + const fetchSpy = vi.fn(); + + beforeEach(() => { + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + fetchSpy.mockReset(); + }); + + it("fetches sounds given as ordinary URLs", async () => { + fetchSpy.mockResolvedValue( + new Response(new Uint8Array([1, 2, 3]), { status: 200 }), + ); + const sounds = await prefetchSounds({ + beep: { ogg: "https://example.org/beep.ogg" }, + }); + expect(fetchSpy).toHaveBeenCalledWith("https://example.org/beep.ogg"); + expect(bytesOf(sounds.beep)).toEqual([1, 2, 3]); + }); + + it("decodes base64 data: URLs without fetching them", async () => { + // "OggS" — the bytes 0x4f 0x67 0x67 0x53 + const sounds = await prefetchSounds({ + beep: { ogg: "data:audio/ogg;base64,T2dnUw==" }, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(bytesOf(sounds.beep)).toEqual([0x4f, 0x67, 0x67, 0x53]); + }); + + it("decodes percent-encoded data: URLs without fetching them", async () => { + const sounds = await prefetchSounds({ + beep: { ogg: "data:audio/ogg,Ogg%53" }, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(bytesOf(sounds.beep)).toEqual([0x4f, 0x67, 0x67, 0x53]); + }); + + it("leaves out a sound whose response is not okay", async () => { + fetchSpy.mockResolvedValue(new Response(null, { status: 404 })); + const sounds = await prefetchSounds({ + beep: { ogg: "https://example.org/missing.ogg" }, + }); + expect(sounds).toEqual({}); + }); + + it("leaves out a sound that fails to load, keeping the others", async () => { + fetchSpy.mockRejectedValue(new TypeError("Failed to fetch")); + const sounds = await prefetchSounds({ + beep: { ogg: "https://example.org/beep.ogg" }, + boop: { ogg: "data:audio/ogg;base64,T2dnUw==" }, + }); + expect(Object.keys(sounds)).toEqual(["boop"]); + }); + + it("leaves out a sound with a malformed data: URL", async () => { + const sounds = await prefetchSounds({ + beep: { ogg: "data:audio/ogg;base64" }, + }); + expect(sounds).toEqual({}); + }); +}); diff --git a/src/soundUtils.ts b/src/soundUtils.ts index 3e774802e..18d2785af 100644 --- a/src/soundUtils.ts +++ b/src/soundUtils.ts @@ -32,6 +32,43 @@ function getPreferredAudioFormat(): "ogg" | "mp3" { const preferredFormat = getPreferredAudioFormat(); +/** + * Decode a `data:` URL into the bytes it carries. + * + * The component build inlines the sound files as `data:` URLs. Those could be + * `fetch`ed like any other URL, but that fetch is subject to the host page's + * Content Security Policy, and `connect-src` lists rarely allow `data:` (a `*` + * source does not cover it). Reading the bytes out of the URL directly needs no + * network access, so no policy can stop it. + * @param url The `data:` URL to decode. + * @returns The decoded bytes. + */ +function decodeDataUrl(url: string): ArrayBuffer { + const separator = url.indexOf(","); + if (!url.startsWith("data:") || separator === -1) + throw new Error("Not a valid data: URL"); + const header = url.slice("data:".length, separator); + const payload = url.slice(separator + 1); + const decoded = header.split(";").includes("base64") + ? atob(payload) + : decodeURIComponent(payload); + // `decoded` is a binary string: one char per byte + const bytes = Uint8Array.from(decoded, (c) => c.charCodeAt(0)); + return bytes.buffer; +} + +/** + * Load the bytes of a single sound file. + * @param url Where the sound is: an ordinary URL to fetch, or a `data:` URL. + * @returns The bytes, or null if the sound could not be loaded. + */ +async function loadSound(url: string): Promise { + if (url.startsWith("data:")) return decodeDataUrl(url); + const response = await fetch(url); + if (isFailure(response)) return null; + return await response.arrayBuffer(); +} + /** * Prefetch sounds to be used by the AudioContext. This can * be called outside the scope of a component to ensure the @@ -47,18 +84,21 @@ export async function prefetchSounds( Object.entries(sounds).map(async ([name, file]) => { const { mp3, ogg } = file as SoundDefinition; // Use preferred format, fallback to ogg if no mp3 is provided. - // Load an audio file - const response = await fetch( - preferredFormat === "ogg" ? ogg : (mp3 ?? ogg), - ); - if (isFailure(response)) { + const url = preferredFormat === "ogg" ? ogg : (mp3 ?? ogg); + let buffer: ArrayBuffer | null = null; + try { + buffer = await loadSound(url); + } catch (e) { + logger.warn(`Could not load sound ${name}`, e); + return; + } + if (buffer === null) { // If the sound doesn't load, it's not the end of the world. We won't play // the sound when requested, but it's better than failing the whole application. logger.warn(`Could not load sound ${name}, response was not okay`); return; } - // Decode it - buffers[name] = await response.arrayBuffer(); + buffers[name] = buffer; }), ); return buffers as Record;