Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions apps/game/public/assets/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,26 @@
"ui_hover.mp3"
],
"music": [
"throngva/More Than Enough.mp3",
"throngva/frost-on-the-oar.mp3",
"throngva/ironwood-oath.mp3",
"throngva/runes-on-ice.mp3",
"throngva/storm-crown-oath.mp3",
"throngva/the-wolf-called-want-part-2.mp3",
"throngva/the-wolf-called-want.mp3"
"Þrøngva/After the Winter of Want/001. Frost on the Oar.mp3",
"Þrøngva/After the Winter of Want/002. Runes On Ice.mp3",
"Þrøngva/After the Winter of Want/003. Ironwood Oath.mp3",
"Þrøngva/After the Winter of Want/004. Storm Crown Oath.mp3",
"Þrøngva/After the Winter of Want/005. Wake the Sun.mp3",
"Þrøngva/After the Winter of Want/006. Hammer the Dawn.mp3",
"Þrøngva/After the Winter of Want/007. Fire in Every Hall.mp3",
"Þrøngva/After the Winter of Want/008. Raise the Floor.mp3",
"Þrøngva/After the Winter of Want/009. Cut the Clock.mp3",
"Þrøngva/After the Winter of Want/010. Good as New.mp3",
"Þrøngva/After the Winter of Want/011. Dawn Without Masters.mp3",
"Þrøngva/After the Winter of Want/012. Let the Morning In.mp3",
"Þrøngva/After the Winter of Want/013. More Than Enough.mp3",
"Þrøngva/After the Winter of Want/014. The Wolf Called Want.mp3",
"Þrøngva/After the Winter of Want/015. The Wolf Called Want (Part 2).mp3",
"Þrøngva/After the Winter of Want/016. When the Last Coin Falls.mp3",
"Þrøngva/After the Winter of Want/017. Storm Crown Oath.mp3",
"Þrøngva/After the Winter of Want/018. When the Last Coin Falls (Part 2).mp3",
"Þrøngva/After the Winter of Want/019. Valhalla Bounce.mp3",
"Þrøngva/After the Winter of Want/20. Valhalla on Loop.mp3"
],
"bytes": {
"models": 6330116,
Expand Down
83 changes: 55 additions & 28 deletions apps/game/public/audio/PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,33 +63,60 @@ nothing produced by a generative audio model.

## Music

Original work by **Þrøngva**, written for this game by the project owner. Not
licensed from a third party, so there is no external licence to comply with and
no attribution obligation — the credit below is for the record.

Laid out as `music/<artist>/<song>.mp3`. The licence and attribution belong to
the artist rather than to each file, and a folder per artist is where a reader
looks for that. It also removed a real bug: the download-budget guard used to
exclude music by a `^music_` filename pattern, which silently under-counted the
moment a track arrived under its own name and put 22 MB of songs inside a 6 MB
guard. A directory cannot be misspelt into the wrong bucket.

| File | Title |
| ------------------------------------------------ | ----------------------------- |
| `music/throngva/frost-on-the-oar.mp3` | Frost on the Oar |
| `music/throngva/runes-on-ice.mp3` | Runes on Ice |
| `music/throngva/ironwood-oath.mp3` | Ironwood Oath |
| `music/throngva/storm-crown-oath.mp3` | Storm Crown Oath |
| `music/throngva/the-wolf-called-want.mp3` | The Wolf Called Want |
| `music/throngva/the-wolf-called-want-part-2.mp3` | The Wolf Called Want (Part 2) |
| `music/throngva/More Than Enough.mp3` | More Than Enough |

The table is a record, not a registry: the playlist itself is discovered by
globbing this directory (`apps/game/vite-plugin-soundtrack.ts`), so adding a
track is dropping the file in. Titles are derived from the filename, which is
why `More Than Enough.mp3` keeps its spacing and capitalisation as authored
while the kebab-case names are expanded — both routes are covered by
`apps/game/src/audio.test.ts`.
The album **_After the Winter of Want_** by **Þrøngva**: our own custom music,
made for this game and committed by the project owner on 2026-09-23 (commit
`1356cc1`). Not licensed from a third party, so there is no external licence
to comply with and no attribution obligation; the credit is for the record.

Laid out as `music/<artist>/<album>/<song>.mp3`, exactly as delivered, with the
album's own `playlist.m3u` beside it. The licence and attribution belong to the
artist, and a folder per artist is where a reader looks for that. It also keeps
music out of the download-budget guard by directory rather than by a filename
pattern, which once silently under-counted.

| # | Title |
| --- | --------------------------------- |
| 001 | Frost on the Oar |
| 002 | Runes On Ice |
| 003 | Ironwood Oath |
| 004 | Storm Crown Oath |
| 005 | Wake the Sun |
| 006 | Hammer the Dawn |
| 007 | Fire in Every Hall |
| 008 | Raise the Floor |
| 009 | Cut the Clock |
| 010 | Good as New |
| 011 | Dawn Without Masters |
| 012 | Let the Morning In |
| 013 | More Than Enough |
| 014 | The Wolf Called Want |
| 015 | The Wolf Called Want (Part 2) |
| 016 | When the Last Coin Falls |
| 017 | Storm Crown Oath (second version) |
| 018 | When the Last Coin Falls (Part 2) |
| 019 | Valhalla Bounce |
| 20 | Valhalla on Loop |

The table is a record, not a registry: the playlist is discovered by walking
this directory (`apps/game/vite-plugin-soundtrack.ts`), album folders included,
so adding a track is dropping the file in. The title is the filename as
authored with the album's track number (`001. `) removed; both rules are
covered by `apps/game/src/audio.test.ts`, which also checks that all 20 tracks
are found.

Streamed by an `<audio>` element on demand and deliberately outside the 15 MB
shell budget (PRD §30): the game is playable before a note arrives.
shell budget (PRD §30): the game is playable before a note arrives. The PWA
service worker does not precache it.

### Removed: the earlier `music/throngva/` set

Seven earlier files in `music/throngva/` were deleted on 2026-09-23 at the
project owner's instruction, because they were not our music. Four of them
(`frost-on-the-oar`, `runes-on-ice`, `ironwood-oath`, `storm-crown-oath`) were
byte-identical to album tracks 001 to 004, so those songs remain, from the
album. The other three (`More Than Enough`, `the-wolf-called-want`,
`the-wolf-called-want-part-2`) differed from the album's versions and are gone.

The published trailer's soundtrack is Ironwood Oath. Its source file was one of
the byte-identical four, so the film already carries album track 003 and was
not re-rendered; `tools/art/trailer.mjs` now reads it from the album.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
47 changes: 38 additions & 9 deletions apps/game/src/audio.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { describe, expect, it } from "vitest";
import SOUNDTRACK from "virtual:soundtrack";
import { titleFromStem, toTrack } from "./audio";

/**
* The soundtrack is discovered by globbing `public/audio/music/<artist>/`
* The soundtrack is discovered by globbing `public/audio/music/<artist>/[<album>/]`
* (see the `soundtrack()` plugin in `vite.config.ts`), so titles and artist
* names are derived from paths rather than written by hand.
*
Expand Down Expand Up @@ -34,21 +35,36 @@ describe("track titles", () => {
});

describe("tracks from paths", () => {
it("maps artist folder to display name and builds the path", () => {
expect(toTrack("throngva/runes-on-ice.mp3")).toEqual({
file: "music/throngva/runes-on-ice.mp3",
title: "Runes on Ice",
const ALBUM = "Þrøngva/After the Winter of Want";

it("reads artist, album and title from an album folder", () => {
expect(toTrack(`${ALBUM}/003. Ironwood Oath.mp3`)).toEqual({
file: `music/${ALBUM}/003. Ironwood Oath.mp3`,
title: "Ironwood Oath",
artist: "Þrøngva",
album: "After the Winter of Want",
});
});

it("handles spaces and a mixed-case extension", () => {
expect(toTrack("throngva/More Than Enough.mp3")).toEqual({
file: "music/throngva/More Than Enough.mp3",
it("drops the track number however many digits it has", () => {
expect(toTrack(`${ALBUM}/015. The Wolf Called Want (Part 2).mp3`).title).toBe(
"The Wolf Called Want (Part 2)",
);
expect(toTrack(`${ALBUM}/20. Valhalla on Loop.mp3`).title).toBe("Valhalla on Loop");
});

it("keeps a number that is part of the title", () => {
// Only "<digits>. " at the start is a track number.
expect(toTrack("Þrøngva/1999.mp3").title).toBe("1999");
});

it("still takes a song straight under the artist, with no album", () => {
expect(toTrack("Þrøngva/More Than Enough.mp3")).toEqual({
file: "music/Þrøngva/More Than Enough.mp3",
title: "More Than Enough",
artist: "Þrøngva",
});
expect(toTrack("throngva/Loud.MP3").title).toBe("Loud");
expect(toTrack("Þrøngva/Loud.MP3").title).toBe("Loud");
});

it("falls back to the folder name for an unknown artist", () => {
Expand All @@ -57,3 +73,16 @@ describe("tracks from paths", () => {
expect(toTrack("kaviran/dust-line.mp3").artist).toBe("Kaviran");
});
});

describe("the shipped soundtrack", () => {
// The real glob over public/audio/music, through the same plugin the build uses.
it("finds every track on the album, including inside the album folder", () => {
const album = SOUNDTRACK.filter((f) => f.startsWith("Þrøngva/After the Winter of Want/"));
expect(album).toHaveLength(20);
expect(SOUNDTRACK.every((f) => f.toLowerCase().endsWith(".mp3"))).toBe(true);
});

it("no longer ships the old tracks", () => {
expect(SOUNDTRACK.filter((f) => f.startsWith("throngva/"))).toEqual([]);
});
});
32 changes: 22 additions & 10 deletions apps/game/src/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,23 @@ interface Track {
readonly file: string;
readonly title: string;
readonly artist: string;
/** The album folder, when the track sits in one. */
readonly album?: string;
}

/**
* Artist display names, keyed by folder.
* Artist display names, keyed by folder, for a folder that is not already
* spelt the way the artist writes it.
*
* The folder is ASCII so it is safe in a URL and a shell; the display name is
* how the artist actually writes it. Anything unlisted falls back to the folder
* name in title case, so a new artist appears sensibly without an entry.
* Þrøngva's folder is the artist's own spelling, so it needs no entry: every
* segment of a track URL is percent-encoded (see `trackUrl`). Anything unlisted
* falls back to the folder name with its first letter capitalised, so a new
* artist appears sensibly without an entry.
*/
const ARTIST_NAMES: Record<string, string> = {
throngva: "Þrøngva",
};
const ARTIST_NAMES: Record<string, string> = {};

/** An album's track number, `001. ` or `20. `: an ordering, not part of the title. */
const TRACK_NUMBER = /^\d+\.\s*/;

/** Words a title leaves lowercase unless they lead. */
const MINOR_WORDS = new Set(["a", "an", "and", "at", "for", "in", "of", "on", "or", "the", "to"]);
Expand Down Expand Up @@ -90,13 +95,20 @@ export function titleFromStem(stem: string): string {
return part ? `${title} (Part ${part[1]})` : title;
}

/** `<artist>/<song>.mp3`, as the glob emits it, to a playable track. */
/**
* `<artist>/<song>.mp3` or `<artist>/<album>/<song>.mp3`, as the glob emits
* it, to a playable track.
*/
export function toTrack(relative: string): Track {
const [folder = "", file = ""] = relative.split("/");
const segments = relative.split("/");
const folder = segments[0] ?? "";
const file = segments.at(-1) ?? "";
const album = segments.length > 2 ? segments.slice(1, -1).join("/") : undefined;
return {
file: `music/${relative}`,
title: titleFromStem(file.replace(/\.mp3$/i, "")),
title: titleFromStem(file.replace(/\.mp3$/i, "").replace(TRACK_NUMBER, "")),
artist: ARTIST_NAMES[folder] ?? capitalise(folder),
...(album ? { album } : {}),
};
}

Expand Down
4 changes: 2 additions & 2 deletions apps/game/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

/**
* The soundtrack glob, produced by the `soundtrack()` plugin in
* `vite.config.ts`. Entries are `<artist>/<song>.mp3`, relative to
* `public/audio/music/`.
* `vite.config.ts`. Entries are `<artist>/<song>.mp3` or
* `<artist>/<album>/<song>.mp3`, relative to `public/audio/music/`.
*/
declare module "virtual:soundtrack" {
const files: string[];
Expand Down
27 changes: 16 additions & 11 deletions apps/game/vite-plugin-soundtrack.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import type { Plugin } from "vite";

Expand All @@ -12,8 +13,8 @@ import type { Plugin } from "vite";
* The alternative was a hard-coded array in `src/audio.ts`, which made every
* new track a code change, or `manifest.json`, which is only rewritten by
* `pnpm assets:build` — a Blender-dependent step no one should have to run to
* add a song. Dropping an `.mp3` into `public/audio/music/<artist>/` is now the
* whole procedure.
* add a song. Dropping an `.mp3` into `public/audio/music/<artist>/`, or an
* album folder beneath it, is now the whole procedure.
*
* Only paths are emitted. Turning them into titles and artist names is
* `audio.ts`'s job, so those rules stay unit-testable without a build.
Expand All @@ -27,17 +28,21 @@ export function soundtrack(): Plugin {
const RESOLVED = `\0${VIRTUAL}`;
const root = fileURLToPath(new URL("./public/audio/music", import.meta.url));

/** `<artist>/<song>.mp3`, sorted, so a build is reproducible. */
/**
* `<artist>/<song>.mp3` or `<artist>/<album>/<song>.mp3`, sorted, so a build
* is reproducible. An album folder is walked like any other: the artist is
* always the first segment and the song the last.
*/
const scan = (): string[] => {
if (!existsSync(root)) return [];
return readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.flatMap((artist) =>
readdirSync(fileURLToPath(new URL(`./public/audio/music/${artist.name}/`, import.meta.url)))
.filter((file) => file.toLowerCase().endsWith(".mp3"))
.map((file) => `${artist.name}/${file}`),
)
.sort();
const walk = (dir: string, prefix: string): string[] =>
readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) return walk(join(dir, entry.name), relative);
// Loose files at the top level have no artist folder, so they are skipped.
return prefix && entry.name.toLowerCase().endsWith(".mp3") ? [relative] : [];
});
return walk(root, "").sort();
};

return {
Expand Down
2 changes: 1 addition & 1 deletion apps/site/public/media/trailer/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"height": 720,
"fps": 30,
"seconds": 26.27,
"music": "apps/game/public/audio/music/throngva/ironwood-oath.mp3",
"music": "apps/game/public/audio/music/Þrøngva/After the Winter of Want/003. Ironwood Oath.mp3",
"musicTitle": "Ironwood Oath",
"musicArtist": "Þrøngva",
"version": 1,
Expand Down
11 changes: 6 additions & 5 deletions docs/prd-sandbox-followups.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,11 +199,12 @@ camera, the frag detonates off-screen, and there are no title cards.
headless page of `apps/site` or a static HTML card, captured the same way).
- 1920x1080 at 30 fps, H.264 High, crf 20 to 23, `faststart`, limited range,
AAC. Under 15 MB at 1080p, or ship 720p in the repo and 1080p on the site.
- Music: **settled.** The concern here was that no licence text was checked in
beside `apps/game/public/audio/music/throngva/`. There is one, a directory
up: `apps/game/public/audio/PROVENANCE.md` records the tracks as original
work by Þrøngva written for this game by the project owner, not licensed
from a third party. Nothing to clear, so promotional use is fine.
- Music: **settled.** `apps/game/public/audio/PROVENANCE.md` records the
soundtrack as Þrøngva's album _After the Winter of Want_, our own custom
music, not licensed from a third party. Nothing to clear, so promotional use
is fine. The earlier `music/throngva/` files were removed on 2026-09-23 as
not ours; the trailer's Ironwood Oath was byte-identical to album track 003
and is now read from the album.

### 5.3 How to get kills on camera

Expand Down
2 changes: 1 addition & 1 deletion docs/trailer/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"height": 720,
"fps": 30,
"seconds": 26.27,
"music": "apps/game/public/audio/music/throngva/ironwood-oath.mp3",
"music": "apps/game/public/audio/music/Þrøngva/After the Winter of Want/003. Ironwood Oath.mp3",
"beats": [
{
"name": "look-left",
Expand Down
13 changes: 5 additions & 8 deletions tools/art/build-assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
statSync,
writeFileSync,
} from "node:fs";
import { join, resolve } from "node:path";
import { join, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";

const HERE = resolve(fileURLToPath(new URL(".", import.meta.url)));
Expand Down Expand Up @@ -353,14 +353,11 @@ function main() {
audio,
// Streamed on demand, so outside the shell budget entirely. Listed as
// artist/song so provenance is readable straight from the manifest.
// Album folders are walked too: artist/album/song.
music: existsSync(join(AUDIO_OUT, "music"))
? readdirSync(join(AUDIO_OUT, "music"), { withFileTypes: true })
.filter((e) => e.isDirectory())
.flatMap((artist) =>
readdirSync(join(AUDIO_OUT, "music", artist.name))
.filter((f) => f.endsWith(".mp3"))
.map((f) => `${artist.name}/${f}`),
)
? readdirSync(join(AUDIO_OUT, "music"), { recursive: true })
.map((f) => String(f).split(sep).join("/"))
.filter((f) => f.includes("/") && f.toLowerCase().endsWith(".mp3"))
.sort()
: [],
bytes: {
Expand Down
5 changes: 4 additions & 1 deletion tools/art/trailer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ const WIDTH = Number(opt("width", 1280));
const HEIGHT = Number(opt("height", 720));
const FPS = Number(opt("fps", 30));
const PORT = Number(opt("port", 8903));
const MUSIC = resolve(ROOT, opt("music", join(AUDIO, "music/throngva/ironwood-oath.mp3")));
const MUSIC = resolve(
ROOT,
opt("music", join(AUDIO, "music/Þrøngva/After the Winter of Want/003. Ironwood Oath.mp3")),
);
const AMBIENCE = join(AUDIO, "ambience_yard.mp3");
const FRAME_MS = 1000 / FPS;

Expand Down
Loading