feat(soundtrack): play SPC, VGM, VGZ and GYM soundtracks in the browser (#5079)

This commit is contained in:
Sam Dornan
2026-10-03 13:12:28 -04:00
committed by GitHub
parent a0ede752f4
commit 655976faa9
28 changed files with 1902 additions and 118 deletions
+1
View File
@@ -54,6 +54,7 @@ frontend/assets/ruffle/*
!frontend/assets/ruffle/*.png
frontend/assets/jsdos
frontend/assets/pico8
frontend/assets/gme
# virtualenv
.venv
+15
View File
@@ -1,7 +1,21 @@
# syntax=docker/dockerfile:1
# trunk-ignore-all(trivy)
# trunk-ignore-all(checkov)
# trunk-ignore-all(hadolint/DL4006)
# Keep the pins in sync with the gme-build stage of docker/Dockerfile.
# trunk-ignore(hadolint/DL3029)
FROM --platform=linux/amd64 emscripten/emsdk:4.0.12@sha256:744fb6a68941970951bacf9d6632041a0398260492232691ef22bbf54b0585c6 AS emsdk-amd64
# trunk-ignore(hadolint/DL3029)
FROM --platform=linux/arm64 emscripten/emsdk:4.0.12-arm64@sha256:369a4cb655aa1066e6e450dde774243c502d999b1d93fb4890a9d1428daa9280 AS emsdk-arm64
# trunk-ignore(hadolint/DL3006)
FROM emsdk-${BUILDARCH} AS gme-build
ARG GME_VERSION=0.6.5
ARG GME_COMMIT=9e23d10f9fd2a6a2f33b10912dd8dc7153258995
ADD --checksum=${GME_COMMIT} "https://github.com/libgme/game-music-emu.git#${GME_VERSION}" /libgme
COPY docker/gme /romm-gme
RUN /romm-gme/build.sh /libgme /gme
# Browser player runtimes. Keep the pins in sync with the emulator stage of docker/Dockerfile.
FROM ubuntu:22.04 AS emulator-download
@@ -177,6 +191,7 @@ WORKDIR /app
# entrypoint.sh links the runtimes into the assets tree at startup.
ENV EMULATOR_ASSETS_DIR="/opt/romm/emulators"
COPY --from=emulator-download /emulators "${EMULATOR_ASSETS_DIR}"
COPY --from=gme-build /gme "${EMULATOR_ASSETS_DIR}/gme"
# Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
+2
View File
@@ -15,6 +15,7 @@ class MusicTrackSchema(BaseModel):
rom_file_id: int
rom_id: int
file_name: str
title: str | None = None
artist: str | None = None
album: str | None = None
@@ -47,6 +48,7 @@ class MusicTrackSchema(BaseModel):
return cls(
rom_file_id=row.rom_file_id,
rom_id=row.rom_id,
file_name=row.file_name,
title=row.title,
artist=row.artist,
album=row.album,
+16 -13
View File
@@ -822,6 +822,16 @@ def _queue_user_cache_bumps(
)
def _track_text_match(search: str) -> ColumnElement[bool]:
return or_(
TrackMeta.title.icontains(search, autoescape=True),
TrackMeta.artist.icontains(search, autoescape=True),
TrackMeta.album.icontains(search, autoescape=True),
# Untagged tracks, chiptunes among them, are named by file.
RomFile.file_name.icontains(search, autoescape=True),
)
class DBRomsHandler(DBBaseHandler):
@begin_session
@with_details
@@ -3009,13 +3019,7 @@ class DBRomsHandler(DBBaseHandler):
if rom_id is not None:
clauses.append(Rom.id == rom_id)
if search:
clauses.append(
or_(
TrackMeta.title.icontains(search, autoescape=True),
TrackMeta.artist.icontains(search, autoescape=True),
TrackMeta.album.icontains(search, autoescape=True),
)
)
clauses.append(_track_text_match(search))
if artist and exclude_field != "artist":
clauses.append(func.lower(TrackMeta.artist) == artist.lower())
if album and exclude_field != "album":
@@ -3139,8 +3143,9 @@ class DBRomsHandler(DBBaseHandler):
# genres blob that would otherwise be materialized just to be counted.
count_subquery = base.with_only_columns(TrackMeta.rom_file_id).subquery()
total = session.scalar(select(func.count()).select_from(count_subquery)) or 0
order_map = {
"title": TrackMeta.title,
order_map: dict[str, SQLColumnExpression[Any]] = {
# Untagged tracks, chiptunes among them, are shown by file name.
"title": func.coalesce(TrackMeta.title, RomFile.file_name),
"artist": TrackMeta.artist,
"album": TrackMeta.album,
"year": TrackMeta.year,
@@ -3150,7 +3155,7 @@ class DBRomsHandler(DBBaseHandler):
}
if playlist_id is not None:
order_map["position"] = MusicPlaylistTrack.position
col = order_map.get(order_by, TrackMeta.title)
col = order_map.get(order_by, order_map["title"])
rows = session.execute(
base.order_by(nulls_last(col, order_dir == "desc"), TrackMeta.rom_file_id)
.limit(limit)
@@ -3437,9 +3442,7 @@ class DBRomsHandler(DBBaseHandler):
where.append(
or_(
Rom.name.icontains(search, autoescape=True),
TrackMeta.title.icontains(search, autoescape=True),
TrackMeta.artist.icontains(search, autoescape=True),
TrackMeta.album.icontains(search, autoescape=True),
_track_text_match(search),
)
)
count_col = func.count().label("count")
@@ -586,6 +586,7 @@ class FSRomsHandler(FSHandler):
from utils.audio_tags import (
extract_audio_meta,
is_allowed_audio_file,
is_chiptune_file,
track_meta_columns,
)
@@ -593,6 +594,9 @@ class FSRomsHandler(FSHandler):
meta = extract_audio_meta(str(abs_file_path))
if meta:
track_meta = TrackMeta(rom_id=rom.id, **track_meta_columns(meta))
elif is_chiptune_file(file_name):
# Untagged, but the row lists the file in the music catalog.
track_meta = TrackMeta(rom_id=rom.id)
return RomFile(
rom=rom,
+2 -2
View File
@@ -15,7 +15,7 @@ from handler.rom_conversion import promote_single_file_to_folder
from handler.rom_files import refresh_rom_files
from logger.logger import log
from models.rom import DocSource, Rom, RomFile, RomFileCategory
from utils.audio_tags import ALLOWED_AUDIO_EXTENSIONS
from utils.audio_tags import SOUNDTRACK_EXTENSIONS
from utils.media_types import ALLOWED_DOCUMENT_EXTENSIONS, ALLOWED_IMAGE_EXTENSIONS
# The folder each media route uploads into. The scanner maps these names back to
@@ -34,7 +34,7 @@ CATEGORY_FILE_TYPES: dict[RomFileCategory, tuple[str, frozenset[str]]] = {
RomFileCategory.MANUAL: ("manual", ALLOWED_MANUAL_EXTENSIONS),
RomFileCategory.WALKTHROUGH: ("walkthrough", ALLOWED_DOCUMENT_EXTENSIONS),
RomFileCategory.SCREENSHOT: ("image", ALLOWED_IMAGE_EXTENSIONS),
RomFileCategory.SOUNDTRACK: ("audio", ALLOWED_AUDIO_EXTENSIONS),
RomFileCategory.SOUNDTRACK: ("audio", SOUNDTRACK_EXTENSIONS),
}
@@ -158,6 +158,45 @@ def test_upload_soundtrack_rejects_invalid_extension(
assert "Unsupported audio file type" in response.json()["detail"]
def test_upload_chiptune_soundtrack_lists_an_untagged_track(
client: TestClient,
access_token: str,
game_folder_rom: Rom,
soundtrack_fs: Path,
):
spc = b"SNES-SPC700 Sound File Data v0.30"
response = client.post(
f"/api/roms/{game_folder_rom.id}/soundtracks",
headers={**_auth(access_token), "x-upload-filename": "01 Title.spc"},
files={"01 Title.spc": ("01 Title.spc", spc, "application/octet-stream")},
)
assert response.status_code == status.HTTP_201_CREATED
assert (soundtrack_fs / "01 Title.spc").read_bytes() == spc
rom_after = db_rom_handler.get_rom(game_folder_rom.id)
assert rom_after is not None
[track] = [f for f in rom_after.files if f.category == RomFileCategory.SOUNDTRACK]
assert track.track_meta is not None
assert track.track_meta.title is None
def test_upload_soundtrack_rejects_a_multi_song_chiptune(
client: TestClient,
access_token: str,
game_folder_rom: Rom,
soundtrack_fs: Path,
):
response = client.post(
f"/api/roms/{game_folder_rom.id}/soundtracks",
headers={**_auth(access_token), "x-upload-filename": "game.nsf"},
files={"game.nsf": ("game.nsf", b"NESM\x1a", "application/octet-stream")},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not (soundtrack_fs / "game.nsf").exists()
# ---------- DELETE /api/roms/{id}/soundtracks/{file_id} ----------
+58
View File
@@ -63,6 +63,22 @@ def _make_track(
return rom
def _make_untagged_track(admin_id: int, platform: Platform, file_name: str) -> None:
"""A soundtrack file with an empty track row, as the scanner gives a chiptune."""
rom = make_rom(platform, "Plok")
db_rom_handler.add_rom_user(rom_id=rom.id, user_id=admin_id)
db_rom_handler.add_rom_file(
RomFile(
rom_id=rom.id,
file_name=file_name,
file_path=f"{rom.fs_path}/Plok/soundtrack",
file_size_bytes=66048,
category=RomFileCategory.SOUNDTRACK,
track_meta=TrackMeta(rom_id=rom.id),
)
)
@pytest.fixture
def music_library(admin_user: User):
pa = _make_platform("genesis")
@@ -154,6 +170,36 @@ def test_tracks_search_substring(client: TestClient, access_token: str, music_li
assert [i["title"] for i in r.json()["items"]] == ["Green Hill"]
def test_tracks_search_matches_an_untagged_file_by_name(
client: TestClient, access_token: str, music_library, admin_user: User
):
_make_untagged_track(admin_user.id, music_library["platform_a"], "05 Beach.spc")
r = client.get("/api/music/tracks?search=beach", headers=_auth(access_token))
[item] = r.json()["items"]
assert item["title"] is None
assert item["file_name"] == "05 Beach.spc"
assert item["stream_url"].endswith("/files/content/05%20Beach.spc")
def test_tracks_sort_an_untagged_file_by_its_name(
client: TestClient, access_token: str, music_library, admin_user: User
):
_make_untagged_track(admin_user.id, music_library["platform_a"], "Ice.spc")
r = client.get(
"/api/music/tracks?order_by=title&order_dir=asc", headers=_auth(access_token)
)
assert [i["file_name"] for i in r.json()["items"]] == [
"Green Hill.mp3",
"Ice.spc",
"Jingle.mp3",
"Overworld.mp3",
]
def test_tracks_search_escapes_like_wildcards(
client: TestClient, access_token: str, music_library
):
@@ -618,6 +664,18 @@ def test_games_facet_search_matches_game_and_track_fields(
assert [i["name"] for i in by_artist["items"]] == ["Streets"]
def test_games_facet_search_matches_an_untagged_file_by_name(
client: TestClient, access_token: str, music_library, admin_user: User
):
_make_untagged_track(admin_user.id, music_library["platform_a"], "05 Beach.spc")
r = client.get(
"/api/music/games", params={"search": "beach"}, headers=_auth(access_token)
)
assert [i["name"] for i in r.json()["items"]] == ["Plok"]
def test_games_facet_excludes_hidden_platform(music_library):
pa = music_library["platform_a"].id
rows, total = db_rom_handler.get_music_game_facet(
+15
View File
@@ -8,11 +8,26 @@ from utils.audio_tags import (
_extract_picture_from_mp4,
_parse_leading_int,
_parse_year,
guess_audio_media_type,
is_chiptune_file,
persist_embedded_cover,
track_meta_columns,
)
class TestIsChiptuneFile:
@pytest.mark.parametrize("name", ["Stage 1.SPC", "intro.vgz", "Theme.vgm"])
def test_chiptune(self, name):
assert is_chiptune_file(name)
@pytest.mark.parametrize("name", ["Theme.mp3", "Theme.nsf", "spc"])
def test_other(self, name):
assert not is_chiptune_file(name)
def test_served_as_binary(self):
assert guess_audio_media_type("Stage 1.spc") == "application/octet-stream"
class TestParseYear:
def test_clean(self):
assert _parse_year("1992") == 1992
+13
View File
@@ -20,6 +20,12 @@ ALLOWED_AUDIO_EXTENSIONS = frozenset(
{".mp3", ".ogg", ".oga", ".opus", ".m4a", ".aac", ".wav", ".flac"}
)
# Single-song console sound formats the browser plays through game-music-emu.
# mutagen can't read them, so they carry no tags.
CHIPTUNE_EXTENSIONS = frozenset({".gym", ".spc", ".vgm", ".vgz"})
SOUNDTRACK_EXTENSIONS = ALLOWED_AUDIO_EXTENSIONS | CHIPTUNE_EXTENSIONS
# Skip parsing anything larger than this; mutagen mmaps the file and can
# consume substantial memory on pathological inputs (e.g. a mislabeled 4GB WAV).
MAX_AUDIO_PARSE_BYTES = 512 * 1024 * 1024 # 512 MiB
@@ -94,6 +100,11 @@ def is_allowed_audio_file(file_name: str) -> bool:
return ext.lower() in ALLOWED_AUDIO_EXTENSIONS
def is_chiptune_file(file_name: str) -> bool:
_, ext = os.path.splitext(file_name)
return ext.lower() in CHIPTUNE_EXTENSIONS
# MIME types for audio formats that the stdlib mimetypes module guesses
# inconsistently (or not at all) across platforms.
AUDIO_MIME_OVERRIDES = {
@@ -102,6 +113,8 @@ AUDIO_MIME_OVERRIDES = {
".m4a": "audio/mp4",
".oga": "audio/ogg",
".ogg": "audio/ogg",
# mimetypes maps .spc to a chemistry format.
**dict.fromkeys(CHIPTUNE_EXTENSIONS, "application/octet-stream"),
}
+30
View File
@@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1
# Stages:
# - python-runtime: Patch the Python image's libraries before they ship
# - frontend-build: Build frontend
@@ -5,6 +6,7 @@
# - backend-dev-build: Similar to `backend-build`, but also compiles and installs development dependencies
# - rahasher-build: Build RAHasher
# - sigil-build: Build sigil Python bindings (title ID extraction)
# - gme-build: Build libgme to WebAssembly (chiptune soundtracks)
# - rom-converto-fetch: Fetch the rom-converto CLI (ROM conversion and metadata)
# - emulator-stage: Fetch and extract emulators
# - nginx-build: Build nginx modules
@@ -27,6 +29,11 @@ ARG NGINX_VERSION=1.30.5
ARG NGINX_SHA256=a5f2157a0302eb0c5e300415effb63a9e70ed1eb9c107283819bf6d149ab607c
ARG UV_VERSION=0.12.19
ARG UV_SHA256=b165481b747c340b4b3b820f6e453c3aea939c8b583cbfc01c4e99ec61181bc2
ARG EMSDK_VERSION=4.0.12
ARG EMSDK_AMD64_SHA256=744fb6a68941970951bacf9d6632041a0398260492232691ef22bbf54b0585c6
ARG EMSDK_ARM64_SHA256=369a4cb655aa1066e6e450dde774243c502d999b1d93fb4890a9d1428daa9280
ARG GME_VERSION=0.6.5
ARG GME_COMMIT=9e23d10f9fd2a6a2f33b10912dd8dc7153258995
FROM python:${PYTHON_VERSION}-alpine${ALPINE_VERSION}@sha256:${PYTHON_ALPINE_SHA256} AS python-alias
@@ -145,6 +152,28 @@ RUN git clone --filter=blob:none https://github.com/rommapp/argosy-sigil.git &&
cp ./sigil/*.py ./sigil/_sigil.*.so /sigil-pkg/
# LIBGME (LGPL-2.1+) FOR CHIPTUNE SOUNDTRACKS
# The wasm is the same on every platform, so it builds with the builder's own
# emsdk image. Keep the pins in sync with the dev image's copies in ../Dockerfile.
# trunk-ignore(hadolint/DL3029)
FROM --platform=linux/amd64 emscripten/emsdk:${EMSDK_VERSION}@sha256:${EMSDK_AMD64_SHA256} AS emsdk-amd64
# trunk-ignore(hadolint/DL3029)
FROM --platform=linux/arm64 emscripten/emsdk:${EMSDK_VERSION}-arm64@sha256:${EMSDK_ARM64_SHA256} AS emsdk-arm64
# trunk-ignore(hadolint/DL3006,checkov/CKV_DOCKER_7)
FROM emsdk-${BUILDARCH} AS gme-build
ARG GME_VERSION
ARG GME_COMMIT
# checkov:skip=CKV_DOCKER_4:the ADD below is a Git URL pinned to a commit by --checksum
ADD --checksum=${GME_COMMIT} \
"https://github.com/libgme/game-music-emu.git#${GME_VERSION}" \
/libgme
COPY ./docker/gme /romm-gme
RUN /romm-gme/build.sh /libgme /gme
# ROM-CONVERTO CLI
# Keep the version and both sums in sync with the dev image's copy in ../Dockerfile.
FROM alpine:${ALPINE_VERSION}@sha256:${ALPINE_SHA256} AS rom-converto-fetch
@@ -281,6 +310,7 @@ COPY --from=frontend-build /front/dist ${WEBSERVER_FOLDER}
COPY --from=backend-node-build /backend-node/rom_patcher/node_modules/rom-patcher/rom-patcher-js /backend/utils/rom_patcher/rom-patcher-js
COPY ./frontend/assets ${WEBSERVER_FOLDER}/assets
COPY --from=gme-build /gme ${WEBSERVER_FOLDER}/assets/gme
RUN mkdir -p ${WEBSERVER_FOLDER}/assets/romm && \
ln -sf /romm/resources ${WEBSERVER_FOLDER}/assets/romm/resources
COPY ./backend /backend
+1
View File
@@ -12,6 +12,7 @@ frontend/assets/ruffle/*
!frontend/assets/ruffle/*.png
frontend/assets/jsdos
frontend/assets/pico8
frontend/assets/gme
# Backend
backend/tests/
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Builds libgme's SPC, VGM and GYM emulators (no zlib: the player gunzips VGZ)
# and the RomM shim into a standalone WebAssembly module.
# Usage: build.sh <libgme source dir> <output dir>
set -euo pipefail
src_dir="$1"
out_dir="$2"
shim_dir="$(cd "$(dirname "$0")" && pwd)"
build_dir="$(mktemp -d)"
emcmake cmake -S "${src_dir}" -B "${build_dir}" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS="-fno-exceptions -fno-rtti" \
-DGME_BUILD_SHARED=OFF \
-DGME_BUILD_STATIC=ON \
-DGME_BUILD_TESTING=OFF \
-DGME_BUILD_EXAMPLES=OFF \
-DGME_ZLIB=OFF \
-DUSE_GME_AY=OFF \
-DUSE_GME_GBS=OFF \
-DUSE_GME_HES=OFF \
-DUSE_GME_KSS=OFF \
-DUSE_GME_NSF=OFF \
-DUSE_GME_NSFE=OFF \
-DUSE_GME_SAP=OFF
cmake --build "${build_dir}" --target gme_static --parallel
mkdir -p "${out_dir}"
# STANDALONE_WASM drops the JS glue: the worklet instantiates the module
# itself, since AudioWorkletGlobalScope has no fetch or DOM.
emcc -O3 -fno-exceptions -fno-rtti \
-I "${src_dir}" \
"${shim_dir}/romm_gme.cpp" \
"${build_dir}/gme/libgme.a" \
-sSTANDALONE_WASM \
-sALLOW_MEMORY_GROWTH \
-sFILESYSTEM=0 \
-sEXPORTED_FUNCTIONS=_malloc,_free,_romm_gme_open,_romm_gme_start,_gme_play,_gme_seek,_gme_tell,_gme_track_ended,_gme_delete \
--no-entry \
-o "${out_dir}/gme.wasm"
cp "${src_dir}/license.txt" "${out_dir}/LICENSE.txt"
rm -rf "${build_dir}"
+82
View File
@@ -0,0 +1,82 @@
// Entry points the soundtrack player's AudioWorklet calls on top of libgme's
// own C API, which returns its handles and track info through out-parameters.
#include <emscripten/emscripten.h>
#include <stdlib.h>
#include <string.h>
#include "gme/gme.h"
// Fade applied when the file doesn't specify one.
static int const DEFAULT_FADE_MSECS = 8000;
static char const GYM_TAG[] = "GYMX";
static long const GYM_HEADER_SIZE = 428;
// libgme reads a GYM frame until a zero command, so trailing zeros stop a
// truncated stream from reading past the buffer.
static long const GYM_PADDING = 4;
// Loads a GYM from a zero-padded copy. A headerless log gets a blank header,
// since libgme only counts frames for the length of a headered file.
static Music_Emu* open_gym( unsigned char const* data, long size, int sample_rate )
{
bool const headerless = size < 4 || memcmp( data, GYM_TAG, 4 ) != 0;
long const header = headerless ? GYM_HEADER_SIZE : 0;
long const total = header + size + GYM_PADDING;
unsigned char* copy = (unsigned char*) calloc( total, 1 );
if ( !copy )
return nullptr;
if ( headerless )
memcpy( copy, GYM_TAG, 4 );
memcpy( copy + header, data, size );
Music_Emu* emu = gme_new_emu( gme_gym_type, sample_rate );
if ( emu && gme_load_data( emu, copy, total ) )
{
gme_delete( emu );
emu = nullptr;
}
free( copy );
return emu;
}
extern "C" {
EMSCRIPTEN_KEEPALIVE
Music_Emu* romm_gme_open( void const* data, long size, int sample_rate )
{
unsigned char const* bytes = (unsigned char const*) data;
if ( size >= 4 && memcmp( bytes, GYM_TAG, 4 ) == 0 )
return open_gym( bytes, size, sample_rate );
Music_Emu* emu = nullptr;
if ( !gme_open_data( data, size, &emu, sample_rate ) )
return emu;
// A headerless GYM log has no magic, so it's taken, as libgme takes one,
// when it starts with a valid command.
if ( size > 0 && bytes[0] <= 3 )
return open_gym( bytes, size, sample_rate );
return nullptr;
}
// Starts the file's song with a fade-out at its resolved length, so looping
// songs end. Returns the length including the fade in milliseconds, or -1 on error.
EMSCRIPTEN_KEEPALIVE
int romm_gme_start( Music_Emu* emu )
{
if ( gme_start_track( emu, 0 ) )
return -1;
gme_info_t* info = nullptr;
if ( gme_track_info( emu, &info, 0 ) )
return -1;
int const length = info->play_length;
int const fade = info->fade_length > 0 ? info->fade_length : DEFAULT_FADE_MSECS;
gme_free_info( info );
gme_set_fade_msecs( emu, length, fade );
return length + fade;
}
}
+4 -1
View File
@@ -106,7 +106,10 @@ export default tseslint.config(
},
},
{
files: ["src/v2/utils/pico8AudioWorklet.js"],
files: [
"src/v2/utils/gmeAudioWorklet.js",
"src/v2/utils/pico8AudioWorklet.js",
],
languageOptions: { globals: globals.audioWorklet },
},
// Import cycles. The resolver has to be the one that reads tsconfig `paths`,
+1
View File
@@ -5,6 +5,7 @@
export type MusicTrackSchema = {
rom_file_id: number;
rom_id: number;
file_name: string;
title?: (string | null);
artist?: (string | null);
album?: (string | null);
+14 -12
View File
@@ -9,6 +9,16 @@ import { FRONTEND_RESOURCES_PATH, isCDBasedSystem, shuffled } from "@/utils";
const volumeStorage = useLocalStorage<number>("soundtrack.volume", 1);
const mutedStorage = useLocalStorage<boolean>("soundtrack.muted", false);
/** What the store drives playback through: an `<audio>` element or the chiptune engine. */
export interface SoundtrackSink {
readonly paused: boolean;
currentTime: number;
volume: number;
muted: boolean;
play: () => Promise<void>;
pause: () => void;
}
export interface PlayerTrack {
romId: number;
fileId: number;
@@ -86,7 +96,7 @@ const useSoundtrackPlayer = defineStore("soundtrackPlayer", () => {
const hasError = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const audioRef = shallowRef<HTMLAudioElement | null>(null);
const audioRef = shallowRef<SoundtrackSink | null>(null);
const volume = volumeStorage;
const muted = mutedStorage;
const playlist = ref<PlayerTrack[]>([]);
@@ -95,7 +105,7 @@ const useSoundtrackPlayer = defineStore("soundtrackPlayer", () => {
const playlistMeta = ref<Record<number, PlayerMeta>>({});
const activePlaylistRomId = ref<number | null>(null);
function setAudioRef(el: HTMLAudioElement | null) {
function setAudioRef(el: SoundtrackSink | null) {
audioRef.value = el;
if (el) {
el.volume = volume.value;
@@ -250,16 +260,8 @@ const useSoundtrackPlayer = defineStore("soundtrackPlayer", () => {
function stop() {
setCurrentTimeThrottled.cancel();
const el = audioRef.value;
if (el) {
el.pause();
el.removeAttribute("src");
try {
el.load();
} catch {
// ignore
}
}
// The mini player unloads the source once `track` clears.
audioRef.value?.pause();
track.value = null;
meta.value = {};
isPlaying.value = false;
@@ -28,6 +28,7 @@ import { useRomSoundtrack } from "@/v2/composables/useRomSoundtrack";
import { useRomSync } from "@/v2/composables/useRomSync";
import { useSoundtrackActions } from "@/v2/composables/useSoundtrackActions";
import { useSubtabQuery } from "@/v2/composables/useSubtabQuery";
import { SOUNDTRACK_ACCEPT } from "@/v2/utils/soundtrackTracks";
const ManualSubtab = defineAsyncComponent(
() => import("@/v2/components/GameDetails/ManualSubtab.vue"),
@@ -266,7 +267,7 @@ async function deleteSoundtrack(fileId: number) {
:hint="t('common.dropzone-hint')"
:active-title="t('common.dropzone-drag-over')"
:input-label="t('rom.upload-soundtrack')"
accept="audio/*,.flac,.opus"
:accept="SOUNDTRACK_ACCEPT"
multiple
@files="handleSoundtrackFiles"
/>
@@ -279,7 +280,7 @@ async function deleteSoundtrack(fileId: number) {
class="r-v2-media__fill"
:release-label="t('common.dropzone-drag-over')"
:input-label="t('rom.upload-soundtrack')"
accept="audio/*,.flac,.opus"
:accept="SOUNDTRACK_ACCEPT"
multiple
@files="handleSoundtrackFiles"
>
@@ -1,6 +1,6 @@
import { mount } from "@vue/test-utils";
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import { nextTick, ref } from "vue";
import useSoundtrackPlayer from "@/stores/soundtrackPlayer";
import MiniPlayer from "./MiniPlayer.vue";
@@ -18,10 +18,12 @@ vi.mock("@/v2/composables/useMiniPlayerVisible", () => ({
useMiniPlayerVisible: () => ref(false),
}));
function mountPlayer() {
async function mountPlayer() {
const wrapper = mount(MiniPlayer, {
global: { stubs: { NowPlayingCard: true } },
});
// The player's event listeners attach once the first render has flushed.
await nextTick();
const audio = wrapper.get("audio").element;
return { wrapper, audio, store: useSoundtrackPlayer() };
}
@@ -31,8 +33,8 @@ describe("MiniPlayer buffering", () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }),
);
it("reports buffering only once a wait outlasts a second", () => {
const { audio, store } = mountPlayer();
it("reports buffering only once a wait outlasts a second", async () => {
const { audio, store } = await mountPlayer();
audio.dispatchEvent(new Event("waiting"));
vi.advanceTimersByTime(999);
@@ -45,8 +47,8 @@ describe("MiniPlayer buffering", () => {
expect(store.isBuffering).toBe(false);
});
it("never reports a wait that resolves in time", () => {
const { audio, store } = mountPlayer();
it("never reports a wait that resolves in time", async () => {
const { audio, store } = await mountPlayer();
audio.dispatchEvent(new Event("waiting"));
vi.advanceTimersByTime(500);
@@ -56,8 +58,8 @@ describe("MiniPlayer buffering", () => {
expect(store.isBuffering).toBe(false);
});
it("drops a pending report on unmount", () => {
const { wrapper, audio, store } = mountPlayer();
it("drops a pending report on unmount", async () => {
const { wrapper, audio, store } = await mountPlayer();
audio.dispatchEvent(new Event("waiting"));
wrapper.unmount();
@@ -66,3 +68,20 @@ describe("MiniPlayer buffering", () => {
expect(store.isBuffering).toBe(false);
});
});
describe("MiniPlayer track changes", () => {
it("stops reporting playback when the next track's start is refused", async () => {
const { audio, store } = await mountPlayer();
vi.spyOn(audio, "play").mockRejectedValue(new Error("NotAllowedError"));
vi.spyOn(audio, "load").mockImplementation(() => {});
store.setPlaying(true);
store.play(
{ romId: 1, fileId: 2, fileName: "02 Theme.mp3", url: "/theme.mp3" },
{},
);
await flushPromises();
expect(store.isPlaying).toBe(false);
});
});
@@ -1,23 +1,31 @@
<script setup lang="ts">
// Owns the app-wide `<audio>` element, so playback survives route changes. The
// card floats on desktop; on phones the top bar's NowPlayingPill opens it.
import { useTimeoutFn } from "@vueuse/core";
import type { Emitter } from "mitt";
// Owns the app-wide `<audio>` and chiptune players, so playback survives route
// changes. Floats on desktop; on phones the top bar's NowPlayingPill opens it.
import { useEventListener, useTimeoutFn } from "@vueuse/core";
import { storeToRefs } from "pinia";
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
computed,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
watch,
} from "vue";
import { useI18n } from "vue-i18n";
import storePlaying from "@/stores/playing";
import useSoundtrackPlayer from "@/stores/soundtrackPlayer";
import type { Events } from "@/types/emitter";
import NowPlayingCard from "@/v2/components/Soundtrack/NowPlayingCard.vue";
import { useBreakpoint } from "@/v2/composables/useBreakpoint";
import { useMediaSession } from "@/v2/composables/useMediaSession";
import { useMiniPlayerVisible } from "@/v2/composables/useMiniPlayerVisible";
import { useSnackbar } from "@/v2/composables/useSnackbar";
import { ChiptunePlayer } from "@/v2/utils/chiptunePlayer";
import { isChiptuneFile } from "@/v2/utils/soundtrackTracks";
defineOptions({ inheritAttrs: false });
const { t } = useI18n();
const emitter = inject<Emitter<Events>>("emitter");
const snackbar = useSnackbar();
const store = useSoundtrackPlayer();
const { track, hasNext } = storeToRefs(store);
const { smAndDown } = useBreakpoint();
@@ -25,6 +33,10 @@ const visible = useMiniPlayerVisible();
const playingStore = storePlaying();
const audioEl = ref<HTMLAudioElement | null>(null);
const chiptune = shallowRef<ChiptunePlayer | null>(null);
// Whichever of the two is playing the current track. Events from the other
// are dropped, since pausing one while the other starts fires out of order.
let active: HTMLAudioElement | ChiptunePlayer | null = null;
// On phones the mini player lives in the top bar, which a running game hides,
// so the music pauses rather than play on with no controls.
@@ -32,14 +44,10 @@ const musicBlocked = computed(
() => playingStore.stageActive && smAndDown.value,
);
watch(musicBlocked, (isBlocked) => {
if (isBlocked) audioEl.value?.pause();
if (isBlocked) active?.pause();
});
useMediaSession(() => musicBlocked.value);
// Bumped on every `src` change, so a pending `play()` from an earlier track
// can't change the current track's state.
let loadToken = 0;
// Track loads, seeks and short stalls often resolve within a second; buffering
// is only reported once a wait outlasts that, so the covers don't flash.
const BUFFERING_DELAY_MS = 1000;
@@ -54,81 +62,108 @@ function setBuffered() {
store.setBuffering(false);
}
function getChiptune(): ChiptunePlayer {
chiptune.value ??= new ChiptunePlayer();
return chiptune.value;
}
function activate(sink: HTMLAudioElement | ChiptunePlayer) {
active = sink;
store.setAudioRef(sink);
}
function unloadAudio(el: HTMLAudioElement) {
el.pause();
el.removeAttribute("src");
try {
el.load();
} catch {
// ignore
}
}
onMounted(() => {
store.setAudioRef(audioEl.value);
if (audioEl.value) activate(audioEl.value);
});
onBeforeUnmount(() => {
store.setAudioRef(null);
chiptune.value?.close();
});
watch(track, async (t) => {
const el = audioEl.value;
if (!el) return;
const token = ++loadToken;
if (t) {
// The store flags a new track as buffering; hold that back like any wait.
store.setBuffering(false);
scheduleBuffering();
if (!t) {
setBuffered();
unloadAudio(el);
chiptune.value?.unload();
return;
}
// The store flags a new track as buffering; hold that back like any wait.
store.setBuffering(false);
scheduleBuffering();
const sink = isChiptuneFile(t.fileName) ? getChiptune() : el;
activate(sink);
if (sink instanceof ChiptunePlayer) {
unloadAudio(el);
void sink.load(t.url);
} else {
chiptune.value?.unload();
el.src = t.url;
try {
el.load();
} catch {
// ignore
}
try {
await el.play();
} catch {
if (token !== loadToken) return;
// Autoplay may be blocked; the user can hit play in the UI.
// Don't surface a snackbar for that; real load failures come
// through `@error`.
}
} else {
setBuffered();
el.pause();
el.removeAttribute("src");
try {
el.load();
} catch {
// ignore
}
}
try {
await sink.play();
} catch {
// Autoplay may be blocked; the user can hit play in the UI. A refused
// `<audio>` start fires no pause event, and the last sink's is dropped.
if (track.value === t && sink.paused) store.setPlaying(false);
}
});
function onPlay() {
store.setPlaying(true);
setBuffered();
}
function onPause() {
store.setPlaying(false);
}
function onEnded() {
store.setPlaying(false);
if (hasNext.value) store.next();
}
function onTimeUpdate() {
if (audioEl.value) store.reportCurrentTime(audioEl.value.currentTime || 0);
}
function onLoadedMetadata() {
if (audioEl.value) store.setDuration(audioEl.value.duration || 0);
}
function onWaiting() {
scheduleBuffering();
}
function onCanPlay() {
setBuffered();
}
function onError() {
cancelBuffering();
store.setError();
// Snackbar payload still uses v1's `snackbarShow` event shape;
// when v1 is removed, switch to `useSnackbar()` here.
emitter?.emit("snackbarShow", {
msg: t("rom.cant-play-track"),
icon: "mdi-alert",
color: "red",
timeout: 3000,
const sinkHandlers: Record<string, () => void> = {
play() {
store.setPlaying(true);
setBuffered();
},
pause() {
store.setPlaying(false);
},
ended() {
store.setPlaying(false);
if (hasNext.value) store.next();
},
timeupdate() {
store.reportCurrentTime(active?.currentTime || 0);
},
loadedmetadata() {
store.setDuration(active?.duration || 0);
},
waiting() {
scheduleBuffering();
},
canplay() {
setBuffered();
},
error() {
cancelBuffering();
store.setError();
snackbar.error(t("rom.cant-play-track"), { timeout: 3000 });
},
};
const sinks = computed(() =>
[audioEl.value, chiptune.value].filter((sink) => sink !== null),
);
for (const [name, handler] of Object.entries(sinkHandlers)) {
useEventListener(sinks, name, (event: Event) => {
if (event.target === active) handler();
});
}
</script>
@@ -141,14 +176,6 @@ function onError() {
class="r-v2-mp__audio"
preload="metadata"
aria-hidden="true"
@play="onPlay"
@pause="onPause"
@ended="onEnded"
@timeupdate="onTimeUpdate"
@loadedmetadata="onLoadedMetadata"
@waiting="onWaiting"
@canplay="onCanPlay"
@error="onError"
/>
<Transition name="r-v2-mp-slide">
@@ -0,0 +1,495 @@
import { gzipSync } from "node:zlib";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ChiptunePlayer, MAX_TRACK_BYTES } from "./chiptunePlayer";
vi.mock("./gmeAudioWorklet.js?worker&url", () => ({ default: "/worklet.js" }));
// Stands in for libgme when the player falls back to the main thread.
const renderer = vi.hoisted(() => ({
commands: [] as { type: string }[],
rendered: 0,
throws: false,
send: null as ((reply: object) => void) | null,
}));
vi.mock("./gmeRenderer", () => ({
GmeRenderer: class {
constructor(_module: unknown, _rate: number, send: (r: object) => void) {
renderer.send = send;
}
handle(command: { type: string }) {
renderer.commands.push(command);
}
render() {
if (renderer.throws) throw new Error("libgme trap");
renderer.rendered += 1;
}
},
}));
type Posted = {
type: string;
id?: number;
data?: ArrayBuffer;
ms?: number;
};
class FakePort {
onmessage: ((event: MessageEvent) => void) | null = null;
posted: Posted[] = [];
postMessage(message: Posted) {
this.posted.push(message);
}
/** Stand in for the worklet replying. */
reply(message: object) {
this.onmessage?.({ data: message } as MessageEvent);
}
types() {
return this.posted.map((message) => message.type);
}
lastLoad() {
return this.posted.filter((message) => message.type === "load").at(-1);
}
}
const SPC_BYTES = new TextEncoder().encode("SNES-SPC700 Sound File Data");
let port: FakePort;
const addModule = vi.fn(async () => {});
const resume = vi.fn(async () => {});
const suspend = vi.fn(async () => {});
const gain = { gain: { value: -1 }, connect: vi.fn(), disconnect: vi.fn() };
let bodies: Map<string, Uint8Array>;
let nodes: { onprocessorerror: (() => void) | null }[];
function flush() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function loaded(player: ChiptunePlayer, durationMs = 158000) {
await player.load("/track.spc");
const id = port.lastLoad()?.id;
port.reply({ type: "loaded", id, durationMs });
await flush();
return id;
}
function recordEvents(player: ChiptunePlayer) {
const events: string[] = [];
for (const name of [
"play",
"pause",
"ended",
"timeupdate",
"loadedmetadata",
"waiting",
"canplay",
"error",
]) {
player.addEventListener(name, () => events.push(name));
}
return events;
}
beforeEach(() => {
vi.clearAllMocks();
port = new FakePort();
nodes = [];
renderer.commands = [];
renderer.rendered = 0;
renderer.throws = false;
renderer.send = null;
gain.gain.value = -1;
bodies = new Map([
["/assets/gme/gme.wasm", new Uint8Array([0, 97, 115, 109])],
["/track.spc", SPC_BYTES],
]);
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const body = bodies.get(url);
return body
? new Response(body.slice())
: new Response(null, { status: 404 });
}),
);
vi.spyOn(WebAssembly, "compile").mockResolvedValue({} as WebAssembly.Module);
stubContext();
vi.stubGlobal(
"AudioWorkletNode",
class {
port = port;
onprocessorerror: (() => void) | null = null;
connect = vi.fn(() => gain);
disconnect = vi.fn();
constructor() {
nodes.push(this);
}
},
);
});
describe("ChiptunePlayer", () => {
it("hands the file to the worklet and reports the track length", async () => {
const player = new ChiptunePlayer();
const events = recordEvents(player);
await loaded(player);
const load = port.lastLoad();
expect(new Uint8Array(load?.data ?? new ArrayBuffer(0))).toEqual(SPC_BYTES);
expect(player.duration).toBe(158);
expect(events).toEqual(["loadedmetadata", "canplay"]);
});
it("gunzips a VGZ file before handing it over", async () => {
bodies.set("/track.spc", new Uint8Array(gzipSync(SPC_BYTES)));
const player = new ChiptunePlayer();
await player.load("/track.spc");
const data = port.lastLoad()?.data ?? new ArrayBuffer(0);
expect(new Uint8Array(data)).toEqual(SPC_BYTES);
});
it("refuses an uncompressed file over the size cap", async () => {
bodies.set("/track.spc", new Uint8Array(MAX_TRACK_BYTES + 1));
const player = new ChiptunePlayer();
const events = recordEvents(player);
vi.spyOn(console, "error").mockImplementation(() => {});
await player.load("/track.spc");
expect(port.lastLoad()).toBeUndefined();
expect(events).toContain("error");
});
it("refuses a file that gunzips past the size cap", async () => {
bodies.set(
"/track.spc",
new Uint8Array(gzipSync(new Uint8Array(MAX_TRACK_BYTES + 1))),
);
const player = new ChiptunePlayer();
const events = recordEvents(player);
vi.spyOn(console, "error").mockImplementation(() => {});
await player.load("/track.spc");
expect(port.lastLoad()).toBeUndefined();
expect(events).toContain("error");
});
it("starts the worklet only once a play asked for early has a track", async () => {
const player = new ChiptunePlayer();
const events = recordEvents(player);
const loading = player.load("/track.spc");
await player.play();
await loading;
expect(events).toEqual(["play", "waiting"]);
expect(port.types()).not.toContain("play");
port.reply({ type: "loaded", id: port.lastLoad()?.id, durationMs: 1000 });
await flush();
expect(player.paused).toBe(false);
expect(port.types()).toContain("play");
});
it("silences the previous track while the next one downloads", async () => {
const player = new ChiptunePlayer();
await loaded(player);
port.posted = [];
await player.load("/track.spc");
expect(port.types()).toEqual(["unload", "load"]);
});
it("stops reporting paused as soon as play is called, like <audio>", async () => {
const player = new ChiptunePlayer();
await loaded(player);
const events = recordEvents(player);
const starting = player.play();
expect(player.paused).toBe(false);
await starting;
expect(events).toEqual(["play"]);
});
it("stays paused when a pause overtakes a play still starting", async () => {
const player = new ChiptunePlayer();
await loaded(player);
const events = recordEvents(player);
const starting = player.play();
player.pause();
await starting;
expect(player.paused).toBe(true);
expect(events).toEqual(["pause"]);
expect(port.types()).not.toContain("play");
});
it("returns to paused when the audio context refuses to start", async () => {
const player = new ChiptunePlayer();
await loaded(player);
const events = recordEvents(player);
resume.mockRejectedValueOnce(new Error("NotAllowedError"));
await expect(player.play()).rejects.toThrow("NotAllowedError");
expect(player.paused).toBe(true);
expect(events).toEqual(["pause"]);
});
it("leaves a new context suspended when its first play is cancelled", async () => {
const player = new ChiptunePlayer();
const loading = player.load("/track.spc");
const starting = player.play();
player.unload();
await Promise.all([loading, starting]);
expect(suspend).toHaveBeenCalled();
expect(resume).not.toHaveBeenCalled();
});
it("won't play a track that failed to load", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const player = new ChiptunePlayer();
const events = recordEvents(player);
await player.load("/missing.spc");
await player.play();
expect(events).toEqual(["error"]);
expect(player.paused).toBe(true);
});
it("drops replies about a track that was replaced", async () => {
const player = new ChiptunePlayer();
const staleId = await loaded(player);
const events = recordEvents(player);
await player.load("/track.spc");
port.reply({ type: "time", id: staleId, ms: 5000 });
port.reply({ type: "ended", id: staleId });
expect(events).toEqual([]);
expect(player.currentTime).toBe(0);
});
it("tracks the position and stops at the end of the track", async () => {
const player = new ChiptunePlayer();
const id = await loaded(player);
await player.play();
const events = recordEvents(player);
port.reply({ type: "time", id, ms: 2500 });
port.reply({ type: "ended", id });
expect(player.currentTime).toBe(2.5);
expect(player.paused).toBe(true);
expect(events).toEqual(["timeupdate", "ended"]);
});
it("plays on when the end it hears of came before a later seek", async () => {
const player = new ChiptunePlayer();
const id = await loaded(player);
await player.play();
player.currentTime = 10;
const events = recordEvents(player);
port.reply({ type: "ended", id, seek: 0 });
expect(events).toEqual([]);
expect(player.paused).toBe(false);
expect(port.posted.at(-1)).toEqual({ type: "play" });
});
it("forwards seeks to the renderer and reports the new position", async () => {
const player = new ChiptunePlayer();
await loaded(player);
const events = recordEvents(player);
player.currentTime = 42;
await flush();
expect(events).toEqual(["timeupdate"]);
expect(port.posted.at(-1)).toEqual({ type: "seek", ms: 42000, seq: 1 });
expect(player.currentTime).toBe(42);
});
it("drops a position report sent before the latest seek", async () => {
const player = new ChiptunePlayer();
const id = await loaded(player);
player.currentTime = 42;
port.reply({ type: "time", id, ms: 3000, seek: 0 });
expect(player.currentTime).toBe(42);
port.reply({ type: "time", id, ms: 42250, seek: 1 });
expect(player.currentTime).toBe(42.25);
});
it("applies a seek made while the file downloaded once it loads", async () => {
const player = new ChiptunePlayer();
const loading = player.load("/track.spc");
player.currentTime = 30;
await loading;
expect(port.types()).not.toContain("seek");
port.reply({ type: "loaded", id: port.lastLoad()?.id, durationMs: 60000 });
await flush();
expect(port.posted.at(-1)).toEqual({ type: "seek", ms: 30000, seq: 1 });
expect(player.currentTime).toBe(30);
});
it("applies volume and mute to the output gain", async () => {
const player = new ChiptunePlayer();
player.volume = 0.4;
await loaded(player);
expect(gain.gain.value).toBe(0.4);
player.muted = true;
expect(gain.gain.value).toBe(0);
player.muted = false;
expect(gain.gain.value).toBe(0.4);
});
it("fires error when the file can't be fetched", async () => {
const player = new ChiptunePlayer();
const events = recordEvents(player);
vi.spyOn(console, "error").mockImplementation(() => {});
await player.load("/missing.spc");
expect(events).toEqual(["error"]);
});
it("fires error when the worklet rejects the file", async () => {
const player = new ChiptunePlayer();
const events = recordEvents(player);
await player.load("/track.spc");
port.reply({ type: "error", id: port.lastLoad()?.id });
expect(events).toEqual(["error"]);
});
it("fails the track and rebuilds the worklet when its processor dies", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const player = new ChiptunePlayer();
await loaded(player);
await player.play();
const events = recordEvents(player);
nodes[0]?.onprocessorerror?.();
expect(events).toEqual(["error"]);
expect(player.paused).toBe(true);
await player.load("/track.spc");
expect(nodes).toHaveLength(2);
});
it("suspends the audio context whenever nothing plays", async () => {
const player = new ChiptunePlayer();
await loaded(player);
await player.play();
suspend.mockClear();
player.pause();
expect(suspend).toHaveBeenCalledTimes(1);
await player.play();
player.unload();
expect(suspend).toHaveBeenCalledTimes(2);
expect(resume).toHaveBeenCalled();
});
it("renders on the main thread where AudioWorklet is unavailable", async () => {
const processor = stubContext({ worklet: false });
const player = new ChiptunePlayer();
const events = recordEvents(player);
await player.load("/track.spc");
await flush();
const load = renderer.commands.at(-1) as { type: string; id: number };
expect(load.type).toBe("load");
expect(addModule).not.toHaveBeenCalled();
renderer.send?.({ type: "loaded", id: load.id, durationMs: 2000 });
await flush();
const channel = { getChannelData: () => new Float32Array(4) };
processor.onaudioprocess?.({ outputBuffer: channel });
expect(events).toEqual(["loadedmetadata", "canplay"]);
expect(player.duration).toBe(2);
expect(renderer.rendered).toBe(1);
});
it("reports an error and drops a main-thread renderer that throws", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const processor = stubContext({ worklet: false });
const player = new ChiptunePlayer();
const events = recordEvents(player);
await player.load("/track.spc");
await flush();
renderer.throws = true;
const channel = { getChannelData: () => new Float32Array(4) };
processor.onaudioprocess?.({ outputBuffer: channel });
expect(events).toContain("error");
expect(processor.onaudioprocess).toBeNull();
expect(processor.disconnect).toHaveBeenCalled();
});
it("falls back to the main thread when the worklet won't start", async () => {
vi.spyOn(console, "error").mockImplementation(() => {});
addModule.mockRejectedValueOnce(new Error("DataCloneError"));
const player = new ChiptunePlayer();
await player.load("/track.spc");
await flush();
expect(addModule).toHaveBeenCalled();
expect(renderer.commands.at(-1)?.type).toBe("load");
});
});
/** An AudioContext, with or without AudioWorklet, returning its ScriptProcessorNode. */
function stubContext({ worklet = true } = {}) {
const processor = {
onaudioprocess: null as ((event: object) => void) | null,
connect: vi.fn(() => gain),
disconnect: vi.fn(),
};
vi.stubGlobal(
"AudioContext",
class {
audioWorklet = worklet ? { addModule } : undefined;
sampleRate = 48000;
destination = {};
resume = resume;
suspend = suspend;
close = vi.fn(async () => {});
createGain() {
return gain;
}
createScriptProcessor() {
return processor;
}
},
);
return processor;
}
+400
View File
@@ -0,0 +1,400 @@
import type { SoundtrackSink } from "@/stores/soundtrackPlayer";
import workletUrl from "./gmeAudioWorklet.js?worker&url";
import { type GmeCommand, type GmeReply, GmeRenderer } from "./gmeRenderer";
const GME_WASM_PATH = "/assets/gme/gme.wasm";
// Frames per main-thread render, about 85 ms at 48 kHz.
const SCRIPT_PROCESSOR_FRAMES = 4096;
/** Where the renderer runs: the port it listens on and the node it plays through. */
interface RendererHost {
port: MessagePort;
output: AudioNode;
}
let modulePromise: Promise<WebAssembly.Module> | null = null;
function compileGme(): Promise<WebAssembly.Module> {
modulePromise ??= fetch(GME_WASM_PATH)
.then((response) => {
if (!response.ok) throw new Error(`gme.wasm: HTTP ${response.status}`);
return response.arrayBuffer();
})
.then((bytes) => WebAssembly.compile(bytes))
.catch((error) => {
modulePromise = null;
throw error;
});
return modulePromise;
}
// Far above any real track, but stops a huge file or a gzip bomb from
// exhausting the tab.
export const MAX_TRACK_BYTES = 64 * 1024 * 1024;
/** Read a stream into one buffer, refusing it past `MAX_TRACK_BYTES`. */
async function readCapped(
stream: ReadableStream<Uint8Array>,
): Promise<ArrayBuffer> {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_TRACK_BYTES) {
await reader.cancel();
throw new Error("track is too large");
}
chunks.push(value);
}
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.byteLength;
}
return out.buffer;
}
/** Fetch a track, gunzipping VGZ since libgme is built without zlib. */
async function fetchTrackData(
url: string,
signal: AbortSignal,
): Promise<ArrayBuffer> {
const response = await fetch(url, { signal });
if (!response.ok || !response.body) {
throw new Error(`${url}: HTTP ${response.status}`);
}
const data = await readCapped(response.body);
const head = new Uint8Array(data, 0, Math.min(2, data.byteLength));
if (head[0] !== 0x1f || head[1] !== 0x8b) return data;
return readCapped(
new Blob([data]).stream().pipeThrough(new DecompressionStream("gzip")),
);
}
/** Plays console sound files through game-music-emu, with the `<audio>`
* properties and events the mini player relies on. */
export class ChiptunePlayer extends EventTarget implements SoundtrackSink {
private context: AudioContext | null = null;
private gain: GainNode | null = null;
private port: Promise<MessagePort> | null = null;
// Held so the main-thread fallback's node isn't collected while it plays.
private output: AudioNode | null = null;
private download: AbortController | null = null;
private loadToken = 0;
// Bumped by every play, pause and load, so a play still starting up yields.
private playToken = 0;
private loaded = false;
// Numbers each seek; reports older than `seekFloor` are dropped.
private seekSeq = 0;
private seekFloor = 0;
private failed = false;
// Flips on each play or pause call, as `<audio>` does, so a quick second
// press sees it. `started` is whether playback began.
private isPaused = true;
private started = false;
private position = 0;
private length = 0;
private level = 1;
private silenced = false;
get paused(): boolean {
return this.isPaused;
}
get duration(): number {
return this.length;
}
get currentTime(): number {
return this.position;
}
// Reports the new position at once, as `<audio>` does, since a suspended
// renderer only applies the seek when playback resumes.
set currentTime(seconds: number) {
this.position = seconds;
// Before the file loads there is nothing to seek; "loaded" applies it.
if (this.loaded) this.seek(seconds);
this.dispatchEvent(new Event("timeupdate"));
}
get volume(): number {
return this.level;
}
set volume(value: number) {
this.level = value;
this.applyGain();
}
get muted(): boolean {
return this.silenced;
}
set muted(value: boolean) {
this.silenced = value;
this.applyGain();
}
/** Load a sound file, replacing whatever was loaded. */
async load(url: string): Promise<void> {
// Silence the old track now, not once the new one has downloaded.
this.unload();
const token = this.loadToken;
const download = new AbortController();
this.download = download;
try {
const [port, data] = await Promise.all([
this.ensurePort(),
fetchTrackData(url, download.signal),
]);
if (token !== this.loadToken) return;
const command: GmeCommand = { type: "load", id: token, data };
port.postMessage(command, [data]);
} catch (error) {
if (token !== this.loadToken) return;
console.error("[chiptune] load failed", error);
this.fail();
}
}
async play(): Promise<void> {
if (this.failed) return;
const token = ++this.playToken;
this.isPaused = false;
try {
await this.ensurePort();
if (token !== this.playToken || this.failed) return;
await this.context?.resume();
} catch (error) {
// Like a rejected `<audio>` play, a refused start leaves it paused.
if (token === this.playToken) this.pause();
throw error;
}
if (token !== this.playToken || this.failed || this.started) return;
this.started = true;
if (this.loaded) this.post({ type: "play" });
this.dispatchEvent(new Event("play"));
if (!this.loaded) this.dispatchEvent(new Event("waiting"));
}
pause(): void {
this.playToken += 1;
if (this.isPaused) return;
this.isPaused = true;
// A play still starting just yields; the renderer never started.
if (this.started) {
this.started = false;
this.post({ type: "pause" });
}
this.suspendContext();
this.dispatchEvent(new Event("pause"));
}
/** Drop the loaded track without firing any events. */
unload(): void {
this.download?.abort();
this.download = null;
this.playToken += 1;
this.loadToken += 1;
this.loaded = false;
this.failed = false;
this.seekFloor = 0;
this.position = 0;
this.length = 0;
this.post({ type: "unload" });
this.halt();
}
close(): void {
this.unload();
this.dropRenderer();
void this.context?.close().catch(() => {});
this.context = null;
}
private seek(seconds: number) {
this.seekFloor = ++this.seekSeq;
this.post({ type: "seek", ms: seconds * 1000, seq: this.seekSeq });
}
private fail() {
this.failed = true;
this.halt();
this.dispatchEvent(new Event("error"));
}
private halt() {
this.isPaused = true;
this.started = false;
this.suspendContext();
}
private dropRenderer() {
this.port = null;
this.output = null;
this.gain = null;
}
// Stops the render callbacks and frees the output device; `play()` resumes.
private suspendContext() {
void this.context?.suspend().catch(() => {});
}
private applyGain() {
if (this.gain) this.gain.gain.value = this.silenced ? 0 : this.level;
}
private post(message: GmeCommand) {
if (!this.port) return;
void this.port.then((port) => port.postMessage(message));
}
private ensurePort(): Promise<MessagePort> {
this.port ??= this.createPort().catch((error) => {
this.port = null;
throw error;
});
return this.port;
}
private async createPort(): Promise<MessagePort> {
// Created before the download, while the user's gesture still counts.
const context = (this.context ??= new AudioContext());
// A new context starts running; it stays suspended until `play()`.
this.suspendContext();
const module = await compileGme();
const host = await hostRenderer(context, module, (output) =>
this.crash(output),
);
const gain = context.createGain();
host.output.connect(gain).connect(context.destination);
this.output = host.output;
this.gain = gain;
this.applyGain();
host.port.onmessage = (event: MessageEvent<GmeReply>) =>
this.receive(event.data);
return host.port;
}
// A processor that threw stays silent, so the next load builds a fresh one.
private crash(output: AudioNode) {
if (output !== this.output) return;
console.error("[chiptune] renderer stopped");
output.disconnect();
this.gain?.disconnect();
this.dropRenderer();
this.fail();
}
private receive(message: GmeReply) {
if (message.id !== this.loadToken) return;
switch (message.type) {
case "loaded":
this.loaded = true;
this.length = message.durationMs / 1000;
if (this.position) this.seek(this.position);
this.dispatchEvent(new Event("loadedmetadata"));
this.dispatchEvent(new Event("canplay"));
if (this.started) this.post({ type: "play" });
break;
case "time":
// A report sent before the latest seek would undo it.
if (message.seek < this.seekFloor) break;
this.position = message.ms / 1000;
this.dispatchEvent(new Event("timeupdate"));
break;
case "ended":
// The renderer stopped before a later seek reached it: play on from there.
if (message.seek < this.seekFloor) {
if (this.started) this.post({ type: "play" });
break;
}
this.halt();
this.dispatchEvent(new Event("ended"));
break;
case "error":
this.fail();
break;
}
}
}
/**
* Args:
* onCrash: called when the renderer throws, in the worklet or on the main thread.
*/
async function hostRenderer(
context: AudioContext,
module: WebAssembly.Module,
onCrash: (output: AudioNode) => void,
): Promise<RendererHost> {
// AudioWorklet only exists in secure contexts, and self-hosted instances are
// often served over plain HTTP on a LAN address.
if (!context.audioWorklet) return hostOnMainThread(context, module, onCrash);
try {
return await hostInWorklet(context, module, onCrash);
} catch (error) {
// Some engines refuse the worklet or the compiled module handed to it.
console.error(
"[chiptune] AudioWorklet failed, rendering on the main thread",
error,
);
return hostOnMainThread(context, module, onCrash);
}
}
async function hostInWorklet(
context: AudioContext,
module: WebAssembly.Module,
onCrash: (output: AudioNode) => void,
): Promise<RendererHost> {
await context.audioWorklet.addModule(workletUrl);
const node = new AudioWorkletNode(context, "gme-audio", {
numberOfInputs: 0,
numberOfOutputs: 1,
outputChannelCount: [2],
processorOptions: { module },
});
node.onprocessorerror = () => onCrash(node);
return { port: node.port, output: node };
}
function hostOnMainThread(
context: AudioContext,
module: WebAssembly.Module,
onCrash: (output: AudioNode) => void,
): RendererHost {
const channel = new MessageChannel();
const renderer = new GmeRenderer(module, context.sampleRate, (reply) =>
channel.port2.postMessage(reply),
);
const node = context.createScriptProcessor(SCRIPT_PROCESSOR_FRAMES, 0, 2);
// Like a worklet whose processor throws, a renderer that threw is dropped.
const guard = (run: () => void) => {
try {
run();
} catch (error) {
console.error("[chiptune] renderer failed", error);
node.onaudioprocess = null;
channel.port2.close();
onCrash(node);
}
};
channel.port2.onmessage = (event: MessageEvent<GmeCommand>) =>
guard(() => renderer.handle(event.data));
node.onaudioprocess = (event) =>
guard(() =>
renderer.render(
event.outputBuffer.getChannelData(0),
event.outputBuffer.getChannelData(1),
),
);
return { port: channel.port1, output: node };
}
+23
View File
@@ -0,0 +1,23 @@
// Runs game-music-emu on the audio thread, rendering each quantum on demand, so
// playback survives a busy main thread and a background tab.
import { GmeRenderer } from "./gmeRenderer";
class GmeProcessor extends AudioWorkletProcessor {
constructor(options) {
super();
this.renderer = new GmeRenderer(
options.processorOptions.module,
sampleRate,
(reply) => this.port.postMessage(reply),
);
this.port.onmessage = (event) => this.renderer.handle(event.data);
}
process(_inputs, outputs) {
const [left, right] = outputs[0];
if (left && right) this.renderer.render(left, right);
return true;
}
}
registerProcessor("gme-audio", GmeProcessor);
+223
View File
@@ -0,0 +1,223 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { type GmeReply, GmeRenderer } from "./gmeRenderer";
// A stand-in for gme.wasm: every rendered frame is left 0.5, right -0.5.
function fakeGme() {
const memory = new WebAssembly.Memory({ initial: 1 });
let next = 8;
const state = { ended: false, deleted: [] as number[] };
const exports = {
memory,
_initialize: vi.fn(),
malloc: (size: number) => {
const pointer = next;
next += size + (8 - (size % 8));
return pointer;
},
free: vi.fn(),
// A file starting with a zero byte is rejected.
romm_gme_open: (data: number) =>
new Uint8Array(memory.buffer)[data] === 0 ? 0 : 7,
romm_gme_start: vi.fn(() => {
state.ended = false;
return 158000;
}),
gme_play: (_emu: number, count: number, out: number) => {
const pcm = new Int16Array(memory.buffer, out, count);
for (let i = 0; i < count; i += 2) {
pcm[i] = 16384;
pcm[i + 1] = -16384;
}
return 0;
},
gme_seek: vi.fn(),
gme_tell: () => 1234,
gme_track_ended: () => (state.ended ? 1 : 0),
gme_delete: (emu: number) => state.deleted.push(emu),
};
return { exports, state };
}
let gme: ReturnType<typeof fakeGme>;
let replies: GmeReply[];
function renderer(sampleRate = 48000) {
return new GmeRenderer({} as WebAssembly.Module, sampleRate, (reply) =>
replies.push(reply),
);
}
/** Render one quantum and return its left channel. */
function quantum(target: GmeRenderer, frames = 4, fill = 0) {
const left = new Float32Array(frames).fill(fill);
target.render(left, new Float32Array(frames));
return left;
}
function load(target: GmeRenderer, bytes = [1, 2, 3], id = 1) {
target.handle({ type: "load", id, data: new Uint8Array(bytes).buffer });
}
beforeEach(() => {
gme = fakeGme();
replies = [];
vi.spyOn(WebAssembly, "Instance").mockImplementation(function () {
return { exports: gme.exports };
});
});
describe("GmeRenderer", () => {
it("reports the track length once a file loads", () => {
const target = renderer();
load(target, [1, 2, 3], 4);
expect(replies).toEqual([{ type: "loaded", id: 4, durationMs: 158000 }]);
});
it("reports an error for a file libgme rejects", () => {
const target = renderer();
load(target, [0, 1]);
expect(replies).toEqual([{ type: "error", id: 1 }]);
});
it("outputs silence until told to play, then the rendered samples", () => {
const target = renderer();
load(target);
const left = new Float32Array(4).fill(1);
const right = new Float32Array(4).fill(1);
target.render(left, right);
expect([...left, ...right]).toEqual(new Array(8).fill(0));
target.handle({ type: "play" });
target.render(left, right);
expect([...left]).toEqual([0.5, 0.5, 0.5, 0.5]);
expect([...right]).toEqual([-0.5, -0.5, -0.5, -0.5]);
});
it("reports the position about four times a second", () => {
const target = renderer(1024);
load(target);
target.handle({ type: "play" });
replies = [];
quantum(target, 128);
expect(replies).toEqual([]);
quantum(target, 128);
expect(replies).toEqual([{ type: "time", id: 1, ms: 1234, seek: 0 }]);
});
it("stops and reports the end of the track", () => {
const target = renderer();
load(target);
target.handle({ type: "play" });
replies = [];
gme.state.ended = true;
quantum(target);
const left = quantum(target, 4, 1);
expect(replies.map((reply) => reply.type)).toEqual(["time", "ended"]);
expect(replies.at(-1)).toEqual({ type: "ended", id: 1, seek: 0 });
expect([...left]).toEqual([0, 0, 0, 0]);
});
it("starts an ended track over when told to play again", () => {
const target = renderer();
load(target);
target.handle({ type: "play" });
gme.state.ended = true;
quantum(target);
replies = [];
target.handle({ type: "play" });
const left = quantum(target);
expect(gme.exports.romm_gme_start).toHaveBeenCalledTimes(2);
expect(replies).toEqual([{ type: "time", id: 1, ms: 1234, seek: 0 }]);
expect([...left]).toEqual([0.5, 0.5, 0.5, 0.5]);
});
it("applies a pending seek rather than restarting an ended track", () => {
const target = renderer();
load(target);
target.handle({ type: "play" });
gme.state.ended = true;
quantum(target);
target.handle({ type: "seek", ms: 500, seq: 1 });
target.handle({ type: "play" });
quantum(target);
expect(gme.exports.gme_seek).toHaveBeenCalledWith(7, 500);
expect(gme.exports.romm_gme_start).toHaveBeenCalledTimes(2);
});
it("restarts the track to seek backwards, so its fade survives", () => {
const target = renderer();
load(target);
target.handle({ type: "seek", ms: 500, seq: 1 });
quantum(target);
expect(gme.exports.romm_gme_start).toHaveBeenCalledTimes(2);
expect(gme.exports.gme_seek).toHaveBeenCalledWith(7, 500);
expect(replies.at(-1)).toEqual({ type: "time", id: 1, ms: 1234, seek: 1 });
});
it("seeks forwards without restarting the track", () => {
const target = renderer();
load(target);
target.handle({ type: "seek", ms: 5000, seq: 1 });
quantum(target);
expect(gme.exports.romm_gme_start).toHaveBeenCalledTimes(1);
expect(gme.exports.gme_seek).toHaveBeenCalledWith(7, 5000);
});
it("collapses a scrub into one seek per quantum", () => {
const target = renderer();
load(target);
for (const [seq, ms] of [2000, 3000, 4000].entries()) {
target.handle({ type: "seek", ms, seq });
}
expect(gme.exports.gme_seek).not.toHaveBeenCalled();
quantum(target);
expect(gme.exports.gme_seek).toHaveBeenCalledTimes(1);
expect(gme.exports.gme_seek).toHaveBeenCalledWith(7, 4000);
});
it("tags position reports with the last seek it applied", () => {
const target = renderer();
load(target);
target.handle({ type: "play" });
target.handle({ type: "seek", ms: 5000, seq: 3 });
quantum(target, 12000);
const times = replies.filter((reply) => reply.type === "time");
expect(times.length).toBeGreaterThan(1);
expect(
times.every((reply) => reply.type === "time" && reply.seek === 3),
).toBe(true);
});
it("frees the previous track when another loads", () => {
const target = renderer();
load(target, [1], 1);
load(target, [1], 2);
expect(gme.state.deleted).toEqual([7]);
expect(replies.at(-1)).toEqual({
type: "loaded",
id: 2,
durationMs: 158000,
});
});
});
+251
View File
@@ -0,0 +1,251 @@
// Drives game-music-emu one render quantum at a time. Hosted by the AudioWorklet
// where the browser has one, else by a ScriptProcessorNode on the main thread.
const IMPORTS = { env: { emscripten_notify_memory_growth() {} } };
// About four position reports a second, matching `<audio>` timeupdate.
const TIME_REPORT_INTERVAL_SECONDS = 0.25;
type GmeReplyBody =
| { type: "loaded"; durationMs: number }
/** `seek` is the last seek applied, so the page can drop older reports. */
| { type: "time"; ms: number; seek: number }
| { type: "ended"; seek: number }
| { type: "error" };
/** The renderer's replies, each tagged with the load it answers. */
export type GmeReply = { id: number } & GmeReplyBody;
export type GmeCommand =
| { type: "load"; id: number; data: ArrayBuffer }
| { type: "play" }
| { type: "pause" }
| { type: "seek"; ms: number; seq: number }
| { type: "unload" };
type GmeExports = {
memory: WebAssembly.Memory;
_initialize: () => void;
malloc: (size: number) => number;
free: (pointer: number) => void;
romm_gme_open: (data: number, size: number, sampleRate: number) => number;
romm_gme_start: (emu: number) => number;
gme_play: (emu: number, count: number, out: number) => number;
gme_seek: (emu: number, ms: number) => number;
gme_tell: (emu: number) => number;
gme_track_ended: (emu: number) => number;
gme_delete: (emu: number) => void;
};
const GME_FUNCTIONS = [
"_initialize",
"malloc",
"free",
"romm_gme_open",
"romm_gme_start",
"gme_play",
"gme_seek",
"gme_tell",
"gme_track_ended",
"gme_delete",
] as const satisfies readonly (keyof GmeExports)[];
function gmeExports(exports: WebAssembly.Exports): GmeExports {
const missing = GME_FUNCTIONS.filter(
(name) => typeof exports[name] !== "function",
);
if (!(exports.memory instanceof WebAssembly.Memory) || missing.length) {
throw new Error(`gme.wasm is missing exports: ${missing.join(", ")}`);
}
return exports as GmeExports;
}
export class GmeRenderer {
private gme: GmeExports;
private emu = 0;
// Echoed on every reply, so the page can drop those about a replaced track.
private loadId = 0;
private playing = false;
// A scrub's seeks collapse into one per quantum, since each emulates its way
// to the target.
private pendingSeek: { ms: number; seq: number } | null = null;
private seekSeq = 0;
private bufferPointer = 0;
private bufferSamples = 0;
private pcm = new Int16Array(0);
private framesSinceReport = 0;
private readonly framesPerReport: number;
private readonly sampleRate: number;
private readonly send: (reply: GmeReply) => void;
/**
* Instantiate gme.wasm for one output.
*
* Args:
* module: the compiled gme.wasm.
* sampleRate: the output rate libgme renders at.
* send: delivers replies to the ChiptunePlayer.
*/
constructor(
module: WebAssembly.Module,
sampleRate: number,
send: (reply: GmeReply) => void,
) {
this.sampleRate = sampleRate;
this.send = send;
this.gme = gmeExports(new WebAssembly.Instance(module, IMPORTS).exports);
this.gme._initialize();
this.framesPerReport = sampleRate * TIME_REPORT_INTERVAL_SECONDS;
}
handle(command: GmeCommand) {
switch (command.type) {
case "load":
this.load(command.id, command.data);
break;
case "play":
// Like `<audio>`, playing an ended track starts it over, unless a
// pending seek is about to move it anyway.
if (
this.emu &&
!this.pendingSeek &&
this.gme.gme_track_ended(this.emu) &&
this.restart()
) {
this.reportTime();
}
this.playing = this.emu !== 0;
break;
case "pause":
this.playing = false;
break;
case "seek":
if (this.emu) this.pendingSeek = { ms: command.ms, seq: command.seq };
break;
case "unload":
this.unload();
break;
}
}
/** Fill one quantum of output, or silence while nothing plays. */
render(left: Float32Array, right: Float32Array) {
if (this.pendingSeek) {
const { ms, seq } = this.pendingSeek;
this.pendingSeek = null;
this.seekSeq = seq;
this.seek(ms);
}
const frames = left.length;
if (this.playing) {
this.reserve(frames * 2);
if (this.gme.gme_play(this.emu, frames * 2, this.bufferPointer)) {
this.unload();
this.reply({ type: "error" });
}
}
if (!this.playing) {
left.fill(0);
right.fill(0);
return;
}
const pcm = this.pcmView(frames * 2);
for (let frame = 0; frame < frames; frame += 1) {
left[frame] = pcm[frame * 2]! / 32768;
right[frame] = pcm[frame * 2 + 1]! / 32768;
}
if (this.gme.gme_track_ended(this.emu)) {
this.playing = false;
this.reportTime();
this.reply({ type: "ended", seek: this.seekSeq });
} else {
this.framesSinceReport += frames;
if (this.framesSinceReport >= this.framesPerReport) this.reportTime();
}
}
private load(id: number, data: ArrayBuffer) {
this.unload();
this.loadId = id;
const emu = this.open(data);
const durationMs = emu ? this.gme.romm_gme_start(emu) : -1;
if (durationMs < 0) {
if (emu) this.gme.gme_delete(emu);
this.reply({ type: "error" });
return;
}
this.emu = emu;
this.reply({ type: "loaded", durationMs });
}
/** Open a file from a copy in wasm memory; libgme keeps its own copy. */
private open(data: ArrayBuffer): number {
const bytes = new Uint8Array(data);
const pointer = this.gme.malloc(bytes.length);
new Uint8Array(this.gme.memory.buffer, pointer, bytes.length).set(bytes);
try {
return this.gme.romm_gme_open(pointer, bytes.length, this.sampleRate);
} finally {
this.gme.free(pointer);
}
}
private seek(ms: number) {
const target = Math.max(0, Math.round(ms));
// libgme seeks backwards by restarting the track, which drops its fade.
if (target < this.gme.gme_tell(this.emu) && !this.restart()) return;
this.gme.gme_seek(this.emu, target);
this.reportTime();
}
/** Start the track over with its fade, or unload it if libgme refuses. */
private restart(): boolean {
if (this.gme.romm_gme_start(this.emu) >= 0) return true;
this.unload();
this.reply({ type: "error" });
return false;
}
private reply(message: GmeReplyBody) {
this.send({ ...message, id: this.loadId });
}
private unload() {
this.playing = false;
this.pendingSeek = null;
if (this.emu) this.gme.gme_delete(this.emu);
this.emu = 0;
}
private reportTime() {
this.framesSinceReport = 0;
this.reply({
type: "time",
ms: this.gme.gme_tell(this.emu),
seek: this.seekSeq,
});
}
// Memory growth detaches old views, so the view is rebuilt when that happens.
private pcmView(count: number): Int16Array {
const { buffer } = this.gme.memory;
if (
this.pcm.buffer !== buffer ||
this.pcm.byteOffset !== this.bufferPointer ||
this.pcm.length !== count
) {
this.pcm = new Int16Array(buffer, this.bufferPointer, count);
}
return this.pcm;
}
// Interleaved stereo samples, reallocated only when the quantum grows.
private reserve(count: number) {
if (count <= this.bufferSamples) return;
if (this.bufferPointer) this.gme.free(this.bufferPointer);
this.bufferPointer = this.gme.malloc(count * 2);
this.bufferSamples = count;
}
}
@@ -24,6 +24,8 @@ describe("isAudioFile", () => {
expect(isAudioFile("01 - Theme.mp3")).toBe(true);
expect(isAudioFile("track.FLAC")).toBe(true);
expect(isAudioFile("04 - Ending.aac")).toBe(true);
expect(isAudioFile("05 - Beach.spc")).toBe(true);
expect(isAudioFile("game.nsf")).toBe(false);
expect(isAudioFile("cover.png")).toBe(false);
expect(isAudioFile("noextension")).toBe(false);
});
@@ -79,6 +81,7 @@ describe("panelTracksFromCatalog", () => {
const base = {
rom_file_id: 5,
rom_id: 9,
file_name: "overworld.mp3",
title: "Overworld",
artist: "Kondo",
album: "SMB OST",
@@ -100,6 +103,21 @@ describe("panelTracksFromCatalog", () => {
]);
expect(track?.subtitle).toBe("NES");
});
it("names an untagged track by its file, with the game as context", () => {
const [track] = panelTracksFromCatalog([
{
...base,
file_name: "05 Beach.spc",
title: null,
artist: null,
album: null,
},
]);
expect(track?.title).toBe("05 Beach");
expect(track?.fileName).toBe("05 Beach.spc");
expect(track?.subtitle).toBe("Super Mario Bros · NES");
});
});
describe("romFolderCoverUrl", () => {
+16 -3
View File
@@ -33,6 +33,8 @@ const AUDIO_EXTS = new Set([
"aac",
"opus",
]);
// Console sound formats, played through game-music-emu instead of `<audio>`.
const CHIPTUNE_EXTS = new Set(["gym", "spc", "vgm", "vgz"]);
const COVER_EXTS = new Set(["jpg", "jpeg", "png", "webp", "gif"]);
export function getExt(name: string): string {
@@ -40,8 +42,19 @@ export function getExt(name: string): string {
return dot === -1 ? "" : name.slice(dot + 1).toLowerCase();
}
/** The file picker filter for soundtrack uploads. */
export const SOUNDTRACK_ACCEPT = [
"audio/*",
...[...AUDIO_EXTS, ...CHIPTUNE_EXTS].map((ext) => `.${ext}`),
].join(",");
export function isAudioFile(name: string): boolean {
return AUDIO_EXTS.has(getExt(name));
const ext = getExt(name);
return AUDIO_EXTS.has(ext) || CHIPTUNE_EXTS.has(ext);
}
export function isChiptuneFile(name: string): boolean {
return CHIPTUNE_EXTS.has(getExt(name));
}
export function isCoverFile(name: string): boolean {
@@ -95,11 +108,11 @@ export function panelTracksFromCatalog(
tracks: MusicTrackSchema[],
): PanelTrack[] {
return tracks.map((track) => {
const title = track.title || stripExtension(track.game_name ?? "");
const title = track.title || stripExtension(track.file_name);
return {
id: track.rom_file_id,
romId: track.rom_id,
fileName: track.title || track.game_name || String(track.rom_file_id),
fileName: track.file_name,
title,
// The game name is context only when it says something new: untagged
// rips often reuse it as the title, and tagged ones as the album.
+1
View File
@@ -158,6 +158,7 @@ export default defineConfig(({ mode }) => {
"**/assets/ruffle/**",
"**/assets/jsdos/**",
"**/assets/pico8/**",
"**/assets/gme/**",
],
},
proxy,