From 655976faa9dfa65290e9d67cfbdde5c1fc39bcf3 Mon Sep 17 00:00:00 2001 From: Sam Dornan Date: Sat, 3 Oct 2026 13:12:28 -0400 Subject: [PATCH] feat(soundtrack): play SPC, VGM, VGZ and GYM soundtracks in the browser (#5079) --- .gitignore | 1 + Dockerfile | 15 + backend/endpoints/responses/music.py | 2 + backend/handler/database/roms_handler.py | 29 +- backend/handler/filesystem/roms_handler.py | 4 + backend/handler/rom_upload.py | 4 +- .../tests/endpoints/roms/test_soundtrack.py | 39 ++ backend/tests/endpoints/test_music.py | 58 ++ backend/tests/utils/test_audio_tags.py | 15 + backend/utils/audio_tags.py | 13 + docker/Dockerfile | 30 ++ docker/Dockerfile.dockerignore | 1 + docker/gme/build.sh | 44 ++ docker/gme/romm_gme.cpp | 82 +++ frontend/eslint.config.js | 5 +- .../__generated__/models/MusicTrackSchema.ts | 1 + frontend/src/stores/soundtrackPlayer.ts | 26 +- .../v2/components/GameDetails/MediaTab.vue | 5 +- .../components/Soundtrack/MiniPlayer.test.ts | 37 +- .../v2/components/Soundtrack/MiniPlayer.vue | 179 ++++--- frontend/src/v2/utils/chiptunePlayer.test.ts | 495 ++++++++++++++++++ frontend/src/v2/utils/chiptunePlayer.ts | 400 ++++++++++++++ frontend/src/v2/utils/gmeAudioWorklet.js | 23 + frontend/src/v2/utils/gmeRenderer.test.ts | 223 ++++++++ frontend/src/v2/utils/gmeRenderer.ts | 251 +++++++++ .../src/v2/utils/soundtrackTracks.test.ts | 18 + frontend/src/v2/utils/soundtrackTracks.ts | 19 +- frontend/vite.config.js | 1 + 28 files changed, 1902 insertions(+), 118 deletions(-) create mode 100755 docker/gme/build.sh create mode 100644 docker/gme/romm_gme.cpp create mode 100644 frontend/src/v2/utils/chiptunePlayer.test.ts create mode 100644 frontend/src/v2/utils/chiptunePlayer.ts create mode 100644 frontend/src/v2/utils/gmeAudioWorklet.js create mode 100644 frontend/src/v2/utils/gmeRenderer.test.ts create mode 100644 frontend/src/v2/utils/gmeRenderer.ts diff --git a/.gitignore b/.gitignore index e010c9e7e..49dfa956c 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ frontend/assets/ruffle/* !frontend/assets/ruffle/*.png frontend/assets/jsdos frontend/assets/pico8 +frontend/assets/gme # virtualenv .venv diff --git a/Dockerfile b/Dockerfile index 2881b0378..cdd246962 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/backend/endpoints/responses/music.py b/backend/endpoints/responses/music.py index d675499c4..7278de505 100644 --- a/backend/endpoints/responses/music.py +++ b/backend/endpoints/responses/music.py @@ -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, diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 01506b44b..5fcdd3584 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -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") diff --git a/backend/handler/filesystem/roms_handler.py b/backend/handler/filesystem/roms_handler.py index af1072a36..7e0a0b214 100644 --- a/backend/handler/filesystem/roms_handler.py +++ b/backend/handler/filesystem/roms_handler.py @@ -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, diff --git a/backend/handler/rom_upload.py b/backend/handler/rom_upload.py index 820a232d5..dbf68865e 100644 --- a/backend/handler/rom_upload.py +++ b/backend/handler/rom_upload.py @@ -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), } diff --git a/backend/tests/endpoints/roms/test_soundtrack.py b/backend/tests/endpoints/roms/test_soundtrack.py index 1f8ac6631..441709556 100644 --- a/backend/tests/endpoints/roms/test_soundtrack.py +++ b/backend/tests/endpoints/roms/test_soundtrack.py @@ -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} ---------- diff --git a/backend/tests/endpoints/test_music.py b/backend/tests/endpoints/test_music.py index 86b2ce39d..c29e2b15d 100644 --- a/backend/tests/endpoints/test_music.py +++ b/backend/tests/endpoints/test_music.py @@ -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( diff --git a/backend/tests/utils/test_audio_tags.py b/backend/tests/utils/test_audio_tags.py index f1ed72c31..472c7fa29 100644 --- a/backend/tests/utils/test_audio_tags.py +++ b/backend/tests/utils/test_audio_tags.py @@ -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 diff --git a/backend/utils/audio_tags.py b/backend/utils/audio_tags.py index 096f5136d..96517df58 100644 --- a/backend/utils/audio_tags.py +++ b/backend/utils/audio_tags.py @@ -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"), } diff --git a/docker/Dockerfile b/docker/Dockerfile index 40278c56c..74e66b978 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/docker/Dockerfile.dockerignore b/docker/Dockerfile.dockerignore index 25018384b..f2e238565 100644 --- a/docker/Dockerfile.dockerignore +++ b/docker/Dockerfile.dockerignore @@ -12,6 +12,7 @@ frontend/assets/ruffle/* !frontend/assets/ruffle/*.png frontend/assets/jsdos frontend/assets/pico8 +frontend/assets/gme # Backend backend/tests/ diff --git a/docker/gme/build.sh b/docker/gme/build.sh new file mode 100755 index 000000000..7d017e05b --- /dev/null +++ b/docker/gme/build.sh @@ -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 +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}" diff --git a/docker/gme/romm_gme.cpp b/docker/gme/romm_gme.cpp new file mode 100644 index 000000000..351dc9e83 --- /dev/null +++ b/docker/gme/romm_gme.cpp @@ -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 +#include +#include + +#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; +} + +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 52d7f90c3..8366614ee 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -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`, diff --git a/frontend/src/__generated__/models/MusicTrackSchema.ts b/frontend/src/__generated__/models/MusicTrackSchema.ts index 4a973aa6b..605e5c457 100644 --- a/frontend/src/__generated__/models/MusicTrackSchema.ts +++ b/frontend/src/__generated__/models/MusicTrackSchema.ts @@ -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); diff --git a/frontend/src/stores/soundtrackPlayer.ts b/frontend/src/stores/soundtrackPlayer.ts index 332aa68f0..4add9713c 100644 --- a/frontend/src/stores/soundtrackPlayer.ts +++ b/frontend/src/stores/soundtrackPlayer.ts @@ -9,6 +9,16 @@ import { FRONTEND_RESOURCES_PATH, isCDBasedSystem, shuffled } from "@/utils"; const volumeStorage = useLocalStorage("soundtrack.volume", 1); const mutedStorage = useLocalStorage("soundtrack.muted", false); +/** What the store drives playback through: an `