mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
feat(soundtrack): play SPC, VGM, VGZ and GYM soundtracks in the browser (#5079)
This commit is contained in:
@@ -54,6 +54,7 @@ frontend/assets/ruffle/*
|
||||
!frontend/assets/ruffle/*.png
|
||||
frontend/assets/jsdos
|
||||
frontend/assets/pico8
|
||||
frontend/assets/gme
|
||||
|
||||
# virtualenv
|
||||
.venv
|
||||
|
||||
+15
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} ----------
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ frontend/assets/ruffle/*
|
||||
!frontend/assets/ruffle/*.png
|
||||
frontend/assets/jsdos
|
||||
frontend/assets/pico8
|
||||
frontend/assets/gme
|
||||
|
||||
# Backend
|
||||
backend/tests/
|
||||
|
||||
Executable
+44
@@ -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}"
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -158,6 +158,7 @@ export default defineConfig(({ mode }) => {
|
||||
"**/assets/ruffle/**",
|
||||
"**/assets/jsdos/**",
|
||||
"**/assets/pico8/**",
|
||||
"**/assets/gme/**",
|
||||
],
|
||||
},
|
||||
proxy,
|
||||
|
||||
Reference in New Issue
Block a user