diff --git a/DATA_LICENSES.md b/DATA_LICENSES.md index 11ed29b..626b7ba 100644 --- a/DATA_LICENSES.md +++ b/DATA_LICENSES.md @@ -56,6 +56,9 @@ artifacts derived from permissively-licensed (CC-BY, public-domain) sources, and - **Groove MIDI Dataset** — Gillick, Roberts, Engel, Eck, Bahdanau; Google Magenta, 2019. Licensed CC-BY 4.0. https://magenta.tensorflow.org/datasets/groove +- **Salamander Grand Piano** — Alexander Holm. Licensed CC-BY 3.0. The piano sample + set bundled at `frontend/public/samples/piano/` (the Tone.js-hosted subset) is + derived from this work. https://archive.org/details/SalamanderGrandPianoV3 ## Note on the GPL and data diff --git a/backend/app/api/routes_generate.py b/backend/app/api/routes_generate.py index 9b5b4d7..bfb0af5 100644 --- a/backend/app/api/routes_generate.py +++ b/backend/app/api/routes_generate.py @@ -14,6 +14,7 @@ import secrets import uuid import json as _json_module +from pathlib import Path from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse, Response, StreamingResponse from pydantic import BaseModel @@ -1196,11 +1197,31 @@ def regenerate_part(req: RegeneratePartRequest): return FileInfo(part=req.part, filename=filename, url=f"/exports/{req.generation_id}/{filename}") +# Generation ids are short uuid4 hex slices (see /generate); keep the check strict +# but tolerant of any hex/underscore/hyphen id we've ever minted. +_VALID_GEN_ID = re.compile(r'^[A-Za-z0-9_\-]{1,64}$') + + +def _safe_export_dir(gen_id: str) -> Path: + """Resolve EXPORTS_DIR/ and guarantee it stays inside EXPORTS_DIR. + + Blocks path traversal via `..`/encoded separators in the id before any + filesystem access. Raises 422 for a malformed id, 404 if the dir is missing. + """ + if not _VALID_GEN_ID.match(gen_id): + raise HTTPException(status_code=422, detail="Invalid generation id") + root = EXPORTS_DIR.resolve() + target = (root / gen_id).resolve() + if not target.is_relative_to(root): + raise HTTPException(status_code=404, detail="Generation not found") + return target + + @router.get("/exports/{gen_id}/bundle.zip") def download_bundle(gen_id: str): import zipfile import io - output_dir = EXPORTS_DIR / gen_id + output_dir = _safe_export_dir(gen_id) if not output_dir.exists(): raise HTTPException(status_code=404, detail="Generation not found") mid_files = list(output_dir.glob("*.mid")) @@ -1226,7 +1247,7 @@ def download_bundle(gen_id: str): def download_sections(gen_id: str): import zipfile import io - sec_dir = EXPORTS_DIR / gen_id / "sections" + sec_dir = _safe_export_dir(gen_id) / "sections" if not sec_dir.exists(): raise HTTPException(status_code=404, detail="No section stems found — generate in Arrangement mode first") mid_files = list(sec_dir.glob("*.mid")) @@ -1249,10 +1270,14 @@ def download_sections(gen_id: str): @router.get("/exports/{gen_id}/{filename}") def download_export(gen_id: str, filename: str): - file_path = EXPORTS_DIR / gen_id / filename - if not file_path.exists(): + base = _safe_export_dir(gen_id).resolve() + file_path = (base / filename).resolve() + # Reject any filename that escapes the generation dir (e.g. `..%2f..%2fsecret`). + if not file_path.is_relative_to(base): + raise HTTPException(status_code=404, detail="File not found") + if not file_path.is_file(): raise HTTPException(status_code=404, detail="File not found") - return FileResponse(str(file_path), media_type="audio/midi", filename=filename) + return FileResponse(str(file_path), media_type="audio/midi", filename=file_path.name) _SAFE_PATH = re.compile(r'^[a-zA-Z0-9_\-]{1,80}$') diff --git a/backend/app/api/routes_styles.py b/backend/app/api/routes_styles.py index 26ebcc2..0029bc7 100644 --- a/backend/app/api/routes_styles.py +++ b/backend/app/api/routes_styles.py @@ -15,7 +15,7 @@ from fastapi import APIRouter, HTTPException from fastapi.responses import Response from app.services.style_loader import list_styles, get_style_detail, save_custom_style, load_style -from app.models.schemas import StyleInfo +from app.models.schemas import StyleInfo, CustomStyleRequest router = APIRouter() @@ -93,14 +93,9 @@ def _build_preview(style_id: str) -> bytes: @router.post("/styles/custom") -def create_custom_style(body: dict): - style_id = body.get("id", "") - if not _VALID_ID.match(style_id): +def create_custom_style(body: CustomStyleRequest): + # Pydantic validates the core fields + bounds; still re-check the id against + # the strict filename pattern, since it becomes a path on disk. + if not _VALID_ID.match(body.id): raise HTTPException(status_code=422, detail="Style id must be 1-40 lowercase alphanumeric/underscore chars") - if not body.get("name"): - raise HTTPException(status_code=422, detail="Style name is required") - required = ("bpm_range", "default_scale", "progression_templates") - for field in required: - if field not in body: - raise HTTPException(status_code=422, detail=f"Missing required field: {field}") - return save_custom_style(body) + return save_custom_style(body.model_dump()) diff --git a/backend/app/main.py b/backend/app/main.py index cb6b34e..bbc0790 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -27,14 +27,26 @@ async def lifespan(app: FastAPI): app = FastAPI(title="GenreGrid API", version="0.1.0", lifespan=lifespan) -_cors_env = os.environ.get("CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173") -_cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()] +# A packaged renderer runs on a random 127.0.0.1:, so we can't pin a fixed +# origin — but the API must NOT be open to arbitrary websites (a page the user +# visits in their browser could otherwise POST to the local backend). Allow any +# localhost / 127.0.0.1 origin (any port) via regex: same-machine requests keep +# working, while a browser blocks evil.com's cross-origin JSON POSTs at preflight. +# `CORS_ORIGINS="*"` (the old packaged setting) now maps to this safe localhost +# regex rather than a literal wildcard; an explicit comma-list is still honored. +_LOCALHOST_ORIGIN_RE = r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$" +_cors_env = os.environ.get("CORS_ORIGINS", "").strip() + +if _cors_env and _cors_env != "*": + _cors_kwargs = {"allow_origins": [o.strip() for o in _cors_env.split(",") if o.strip()]} +else: + _cors_kwargs = {"allow_origin_regex": _LOCALHOST_ORIGIN_RE} app.add_middleware( CORSMiddleware, - allow_origins=_cors_origins, allow_methods=["*"], allow_headers=["*"], + **_cors_kwargs, ) app.include_router(health_router) diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index b3a6aa8..334cb06 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -6,10 +6,35 @@ # Foundation, either version 3 of the License, or (at your option) any later # version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License # for details. -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from typing import List, Optional +class CustomStyleRequest(BaseModel): + """A user-authored style saved via POST /styles/custom. + + Validates the fields the generators actually read (with bounds), while + `extra="allow"` keeps richer per-style config (drums, voices, feel …) that + the StyleEditor sends. The route also re-checks `id` against a strict + filename pattern before it becomes a path on disk. + """ + model_config = {"extra": "allow"} + + id: str = Field(min_length=1, max_length=40) + name: str = Field(min_length=1, max_length=80) + bpm_range: List[int] = Field(min_length=2, max_length=2) + default_scale: str = Field(min_length=1, max_length=40) + progression_templates: List[List[str]] = Field(min_length=1, max_length=64) + + @field_validator("bpm_range") + @classmethod + def _bpm_range_sane(cls, v: List[int]) -> List[int]: + lo, hi = v[0], v[1] + if not (20 <= lo <= hi <= 300): + raise ValueError("bpm_range must be [low, high] within 20–300 and low ≤ high") + return v + + class GenerateRequest(BaseModel): style_id: str key: str = "C" diff --git a/backend/tests/test_api_e2e.py b/backend/tests/test_api_e2e.py index 0233376..26b66f2 100644 --- a/backend/tests/test_api_e2e.py +++ b/backend/tests/test_api_e2e.py @@ -52,6 +52,40 @@ def test_generate_loop_over_http(): assert stem.status_code == 200 and stem.content[:4] == b"MThd" +def test_cors_allows_localhost_but_not_external_sites(): + # A random-port renderer origin (as in the packaged app) is granted + r = client.options("/generate", headers={ + "Origin": "http://127.0.0.1:53311", "Access-Control-Request-Method": "POST"}) + assert r.headers.get("access-control-allow-origin") == "http://127.0.0.1:53311" + + # An arbitrary website the user might visit is NOT granted access + r = client.options("/generate", headers={ + "Origin": "https://evil.example", "Access-Control-Request-Method": "POST"}) + assert r.headers.get("access-control-allow-origin") is None + + +def test_export_download_rejects_path_traversal(): + # A generation whose stems are genuinely downloadable + r = client.post("/generate", json={ + "style_id": "lofi", "key": "C", "scale": "major", "bpm": 90, "bars": 4, + "parts": ["chords"], "mode": "loop", "seed": 9, "use_priors": False, + }) + assert r.status_code == 200 + gid = r.json()["generation_id"] + + # A real stem still downloads (regression guard for the hardened route) + assert client.get(f"/exports/{gid}/chords.mid").status_code == 200 + + # Encoded traversal in the filename must not escape the generation dir + for evil in ("..%2f..%2fmeta.json", "..%2F..%2F..%2Fetc%2Fpasswd", "%2e%2e%2fchords.mid"): + resp = client.get(f"/exports/{gid}/{evil}") + assert resp.status_code in (404, 422), evil + assert b"root:" not in resp.content + + # A malformed generation id is rejected before any filesystem access + assert client.get("/exports/..%2f..%2fetc/passwd").status_code in (404, 422) + + def test_song_lifecycle_over_http(song): gid = song["generation_id"] assert song["sections"][-1]["section_type"] == "ending" diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..926338b --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,102 @@ +# GenreGrid Roadmap + +A living, prioritized roadmap from the July 2026 project survey. Check items off as +they land; add new findings under the right phase so nothing gets lost. + +**Status legend:** `[ ]` todo · `[~]` in progress · `[x]` done · `[-]` won't do / obsolete + +_Last updated: 2026-07-23 — Phase 1 complete (see Done log)._ + +--- + +## Health snapshot (at survey time) + +- Backend: ~14.3k LoC Python, **132 tests passing**, ruff gate (E/F/W) configured. +- Frontend: ~11.3k LoC TS/Vue, **12 tests** (3 files) — thin coverage. +- Electron: `contextIsolation: true`, `nodeIntegration: false` — securely configured. +- Overall: a well-built project. Items below are refinements, not firefighting. + +--- + +## Phase 1 — Harden (high value, low risk) + +- [x] **Path traversal in `download_export`** — hardened all three export routes with a + containment check (`_safe_export_dir` + `is_relative_to`) and a strict id pattern; new + regression test. → `backend/app/api/routes_generate.py` +- [x] **CORS `*` in the packaged app** — replaced with a localhost/127.0.0.1 origin regex + (covers the renderer's random port, blocks external sites at preflight). Electron no + longer sets `CORS_ORIGINS: '*'`. → `backend/app/main.py`, `frontend/electron/main.ts` + - [ ] _Optional follow-up:_ per-session token as defense-in-depth on mutating JSON + endpoints. Deferred — a header token complicates file-URL/audio loading; the origin + allowlist already closes the browser-based attack. +- [x] **`will-navigate` / `setWindowOpenHandler` guards** added — in-app navigation is + same-origin only; external links open in the OS browser. → `frontend/electron/main.ts` +- [x] **`save-temp-file` IPC** now `path.basename()`s the renderer-supplied filename. + → `frontend/electron/main.ts` +- [x] **`create_custom_style`** now validates via `CustomStyleRequest` (typed core fields + + bounds, `extra="allow"` for rich style config). → `backend/app/models/schemas.py` +- [x] **Bundle the Salamander piano samples locally** — the 30-file sample set now lives + in `frontend/public/samples/piano/` (2 MB) and `loader.ts` loads `/samples/piano/`, so + the flagship piano works fully offline and no longer fetches from `tonejs.github.io`. + Attribution (CC-BY 3.0, Alexander Holm) added to `DATA_LICENSES.md`. + → `frontend/src/soundfonts/loader.ts` + +## Phase 2 — Sound polish + +- [ ] **Master bus glue/limiting** — master is a unity `Gain` (a `DynamicsCompressor` + renders silence on Linux Electron), so a full arrangement can peak/clip with nothing + catching it. Add a Linux-safe JS soft-clip / look-ahead limiter. + → `frontend/src/soundfonts/loader.ts:39` +- [ ] **Velocity layers / round-robins in sample sets** — ~4 MB total, ~one mp3 per + note-zone; wide pitch-shifting sounds synthetic. More velocity layers = fastest + realism gain. → `frontend/public/samples/` +- [ ] **Loudness normalization across styles** — target an LUFS level per style so + perceived volume doesn't jump between styles. +- [ ] **Per-style FX presets** — reverb/chorus/delay are currently shared and fixed. + → `frontend/src/soundfonts/loader.ts` (buses) + +## Phase 3 — Performance & refactor + +- [ ] **Parallelize full-song section generation** — song builds take ~1–2 min and + sections generate serially though they're embarrassingly parallel. SSE progress + plumbing (`generate-stream`) already exists. + → `backend/app/api/routes_song.py` +- [ ] **Extract song/arrangement logic out of route god-files into `services/`** — + handlers should be thin. → `routes_song.py` (1498), `routes_generate.py` (1299) +- [ ] **Split `useMidiPlayer.ts`** (1018 lines) into focused composables. +- [ ] **Log swallowed exceptions** — 27 broad/bare `except`; several return silently + (e.g. `record_export_keep`). Add logging. +- [ ] **Grow frontend test coverage** — 12 tests vs 132 backend; the audio scheduler and + WAV-render path (most fragile code) are untested. +- [ ] **Clear deprecation warnings** — Starlette `TestClient` httpx warning; Electron + numeric-level `console-message` signature. → `frontend/electron/main.ts:206` + +## Phase 4 — Features + +- [ ] **Expose the data-driven priors toggle in the UI** — wired end-to-end already, no + frontend control yet. Low effort. +- [ ] **Non-4/4 time signatures** (6/8, 3/4, 7/8) — currently hardcoded `numerator=4`. + → `backend/app/services/midi_writer.py:162` +- [ ] **Custom soundfont / SF2 upload** + per-part instrument picker. +- [ ] **Surface WAV/offline-audio export more prominently** — already implemented + (OfflineAudioContext + render queue); just under-discovered. +- [ ] **Tempo automation & mid-song modulation** — partial today (`chorus_key_shift`). + +--- + +## Done log + +- **2026-07-23 — Phase 1 security hardening** (uncommitted working changes): + - Export downloads hardened against path traversal (`_safe_export_dir` containment + check on `/exports/{gen_id}/bundle.zip`, `/sections.zip`, and `/{filename}`). + - CORS locked to a localhost-only origin regex; dropped the packaged `CORS_ORIGINS=*`. + - Electron `will-navigate` + `setWindowOpenHandler` guards; `save-temp-file` basenames + the filename. + - `POST /styles/custom` validated with a `CustomStyleRequest` model. + - Salamander piano bundled locally (`public/samples/piano/`, 2 MB) — flagship piano is + now fully offline; CC-BY 3.0 attribution added to `DATA_LICENSES.md`. + - Tests: +3 (traversal rejection, CORS allow/deny). Suite 131→134 green; ruff clean; + frontend `vue-tsc` + vitest clean. + +**Phase 1 complete.** Next up: Phase 2 (sound polish) — master limiter is the natural +first step and pairs well with the now-local piano. diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index 8f7555e..b5ce47b 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -8,7 +8,7 @@ * version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License * for details. */ -import { app, BrowserWindow, dialog, ipcMain, nativeImage, protocol, net as electronNet } from 'electron' +import { app, BrowserWindow, dialog, ipcMain, nativeImage, protocol, shell, net as electronNet } from 'electron' import { spawn, ChildProcess } from 'child_process' import nodeNet from 'net' import http from 'http' @@ -100,7 +100,10 @@ async function startBackend(): Promise { const logFile = fs.openSync(path.join(logDir, 'backend.log'), 'w') backendProcess = spawn(exePath, [String(backendPort)], { - env: { ...process.env, GENREGRID_DATA_DIR: dataDir, CORS_ORIGINS: '*' }, + // No CORS_ORIGINS override: the backend defaults to a localhost-only origin + // regex, which covers the renderer's random 127.0.0.1 port while keeping the + // local API closed to arbitrary websites. + env: { ...process.env, GENREGRID_DATA_DIR: dataDir }, stdio: ['ignore', logFile, logFile], // The backend is a console-subsystem exe (so devs can run it standalone // and see output); CREATE_NO_WINDOW keeps its blank console from popping @@ -201,6 +204,30 @@ async function createWindow(): Promise { }, }) + // ── Navigation hardening ────────────────────────────────────────────────── + // The renderer only ever needs to live at its own origin (the Vite dev server + // or the local static server). Anything that tries to navigate the window + // elsewhere, or open a new window/popup, is either a bug or a compromised + // renderer — block in-app navigation and hand external URLs to the OS browser. + const rendererOrigin = () => { + try { return new URL(VITE_DEV_SERVER_URL || 'http://127.0.0.1').origin } catch { return '' } + } + win.webContents.on('will-navigate', (e, url) => { + // Allow same-origin (reloads, SPA); deny everything else. + let sameOrigin = false + try { sameOrigin = new URL(url).origin === new URL(win.webContents.getURL()).origin } catch { /* deny */ } + if (!sameOrigin) { + e.preventDefault() + if (/^https?:/.test(url)) shell.openExternal(url) + console.warn('[main] blocked in-app navigation to', url) + } + }) + win.webContents.setWindowOpenHandler(({ url }) => { + // Never spawn Electron child windows; open real web links in the OS browser. + if (/^https?:/.test(url) && new URL(url).origin !== rendererOrigin()) shell.openExternal(url) + return { action: 'deny' } + }) + // ── DEBUG: mirror the whole renderer console into the main-process stdout, so the full // renderer log (audio setup, errors, everything) shows in the terminal without DevTools. win.webContents.on('console-message', (_e, level, message) => { @@ -352,7 +379,10 @@ ipcMain.on('get-api-port', (event) => { ipcMain.handle('save-temp-file', async (_, { filename, data }: { filename: string; data: number[] }) => { const dir = path.join(app.getPath('temp'), 'genregrid') await fs.promises.mkdir(dir, { recursive: true }) - const filePath = path.join(dir, filename) + // Strip any directory components — the renderer supplies this name, and a + // value like `../../evil` must not let a write escape the temp dir. + const safeName = path.basename(filename) || 'stem.mid' + const filePath = path.join(dir, safeName) await fs.promises.writeFile(filePath, Buffer.from(data)) return filePath }) diff --git a/frontend/public/samples/piano/A0.mp3 b/frontend/public/samples/piano/A0.mp3 new file mode 100644 index 0000000..0f8ccd9 Binary files /dev/null and b/frontend/public/samples/piano/A0.mp3 differ diff --git a/frontend/public/samples/piano/A1.mp3 b/frontend/public/samples/piano/A1.mp3 new file mode 100644 index 0000000..0b2ff3a Binary files /dev/null and b/frontend/public/samples/piano/A1.mp3 differ diff --git a/frontend/public/samples/piano/A2.mp3 b/frontend/public/samples/piano/A2.mp3 new file mode 100644 index 0000000..c469969 Binary files /dev/null and b/frontend/public/samples/piano/A2.mp3 differ diff --git a/frontend/public/samples/piano/A3.mp3 b/frontend/public/samples/piano/A3.mp3 new file mode 100644 index 0000000..57924c5 Binary files /dev/null and b/frontend/public/samples/piano/A3.mp3 differ diff --git a/frontend/public/samples/piano/A4.mp3 b/frontend/public/samples/piano/A4.mp3 new file mode 100644 index 0000000..3b19af8 Binary files /dev/null and b/frontend/public/samples/piano/A4.mp3 differ diff --git a/frontend/public/samples/piano/A5.mp3 b/frontend/public/samples/piano/A5.mp3 new file mode 100644 index 0000000..d17d553 Binary files /dev/null and b/frontend/public/samples/piano/A5.mp3 differ diff --git a/frontend/public/samples/piano/A6.mp3 b/frontend/public/samples/piano/A6.mp3 new file mode 100644 index 0000000..8464e29 Binary files /dev/null and b/frontend/public/samples/piano/A6.mp3 differ diff --git a/frontend/public/samples/piano/A7.mp3 b/frontend/public/samples/piano/A7.mp3 new file mode 100644 index 0000000..5e5aa4d Binary files /dev/null and b/frontend/public/samples/piano/A7.mp3 differ diff --git a/frontend/public/samples/piano/C1.mp3 b/frontend/public/samples/piano/C1.mp3 new file mode 100644 index 0000000..2b840fc Binary files /dev/null and b/frontend/public/samples/piano/C1.mp3 differ diff --git a/frontend/public/samples/piano/C2.mp3 b/frontend/public/samples/piano/C2.mp3 new file mode 100644 index 0000000..3fca8d0 Binary files /dev/null and b/frontend/public/samples/piano/C2.mp3 differ diff --git a/frontend/public/samples/piano/C3.mp3 b/frontend/public/samples/piano/C3.mp3 new file mode 100644 index 0000000..14daa30 Binary files /dev/null and b/frontend/public/samples/piano/C3.mp3 differ diff --git a/frontend/public/samples/piano/C4.mp3 b/frontend/public/samples/piano/C4.mp3 new file mode 100644 index 0000000..d5f5819 Binary files /dev/null and b/frontend/public/samples/piano/C4.mp3 differ diff --git a/frontend/public/samples/piano/C5.mp3 b/frontend/public/samples/piano/C5.mp3 new file mode 100644 index 0000000..7064bc8 Binary files /dev/null and b/frontend/public/samples/piano/C5.mp3 differ diff --git a/frontend/public/samples/piano/C6.mp3 b/frontend/public/samples/piano/C6.mp3 new file mode 100644 index 0000000..c8e20f6 Binary files /dev/null and b/frontend/public/samples/piano/C6.mp3 differ diff --git a/frontend/public/samples/piano/C7.mp3 b/frontend/public/samples/piano/C7.mp3 new file mode 100644 index 0000000..4cf780b Binary files /dev/null and b/frontend/public/samples/piano/C7.mp3 differ diff --git a/frontend/public/samples/piano/C8.mp3 b/frontend/public/samples/piano/C8.mp3 new file mode 100644 index 0000000..9297538 Binary files /dev/null and b/frontend/public/samples/piano/C8.mp3 differ diff --git a/frontend/public/samples/piano/Ds1.mp3 b/frontend/public/samples/piano/Ds1.mp3 new file mode 100644 index 0000000..a5d68f5 Binary files /dev/null and b/frontend/public/samples/piano/Ds1.mp3 differ diff --git a/frontend/public/samples/piano/Ds2.mp3 b/frontend/public/samples/piano/Ds2.mp3 new file mode 100644 index 0000000..9e7cf54 Binary files /dev/null and b/frontend/public/samples/piano/Ds2.mp3 differ diff --git a/frontend/public/samples/piano/Ds3.mp3 b/frontend/public/samples/piano/Ds3.mp3 new file mode 100644 index 0000000..ba3cc48 Binary files /dev/null and b/frontend/public/samples/piano/Ds3.mp3 differ diff --git a/frontend/public/samples/piano/Ds4.mp3 b/frontend/public/samples/piano/Ds4.mp3 new file mode 100644 index 0000000..f131233 Binary files /dev/null and b/frontend/public/samples/piano/Ds4.mp3 differ diff --git a/frontend/public/samples/piano/Ds5.mp3 b/frontend/public/samples/piano/Ds5.mp3 new file mode 100644 index 0000000..9a6a24b Binary files /dev/null and b/frontend/public/samples/piano/Ds5.mp3 differ diff --git a/frontend/public/samples/piano/Ds6.mp3 b/frontend/public/samples/piano/Ds6.mp3 new file mode 100644 index 0000000..14b38cd Binary files /dev/null and b/frontend/public/samples/piano/Ds6.mp3 differ diff --git a/frontend/public/samples/piano/Ds7.mp3 b/frontend/public/samples/piano/Ds7.mp3 new file mode 100644 index 0000000..ea3e885 Binary files /dev/null and b/frontend/public/samples/piano/Ds7.mp3 differ diff --git a/frontend/public/samples/piano/Fs1.mp3 b/frontend/public/samples/piano/Fs1.mp3 new file mode 100644 index 0000000..254ecc4 Binary files /dev/null and b/frontend/public/samples/piano/Fs1.mp3 differ diff --git a/frontend/public/samples/piano/Fs2.mp3 b/frontend/public/samples/piano/Fs2.mp3 new file mode 100644 index 0000000..3d1de69 Binary files /dev/null and b/frontend/public/samples/piano/Fs2.mp3 differ diff --git a/frontend/public/samples/piano/Fs3.mp3 b/frontend/public/samples/piano/Fs3.mp3 new file mode 100644 index 0000000..c097190 Binary files /dev/null and b/frontend/public/samples/piano/Fs3.mp3 differ diff --git a/frontend/public/samples/piano/Fs4.mp3 b/frontend/public/samples/piano/Fs4.mp3 new file mode 100644 index 0000000..780ba59 Binary files /dev/null and b/frontend/public/samples/piano/Fs4.mp3 differ diff --git a/frontend/public/samples/piano/Fs5.mp3 b/frontend/public/samples/piano/Fs5.mp3 new file mode 100644 index 0000000..5d4fcac Binary files /dev/null and b/frontend/public/samples/piano/Fs5.mp3 differ diff --git a/frontend/public/samples/piano/Fs6.mp3 b/frontend/public/samples/piano/Fs6.mp3 new file mode 100644 index 0000000..ba3e062 Binary files /dev/null and b/frontend/public/samples/piano/Fs6.mp3 differ diff --git a/frontend/public/samples/piano/Fs7.mp3 b/frontend/public/samples/piano/Fs7.mp3 new file mode 100644 index 0000000..4311953 Binary files /dev/null and b/frontend/public/samples/piano/Fs7.mp3 differ diff --git a/frontend/src/soundfonts/loader.ts b/frontend/src/soundfonts/loader.ts index b2368fa..f618f65 100644 --- a/frontend/src/soundfonts/loader.ts +++ b/frontend/src/soundfonts/loader.ts @@ -10,8 +10,11 @@ */ import * as Tone from 'tone' -// Salamander Grand Piano — same sample set used in Tone.js official examples -const BASE_URL = 'https://tonejs.github.io/audio/salamander/' +// Salamander Grand Piano — bundled locally under public/samples/piano/ (served by +// the renderer's static server) so the flagship piano works fully offline and +// doesn't fetch from a third-party host at runtime. Same sample set as the Tone.js +// examples; served identically to the other instruments' /samples/... sets. +const BASE_URL = '/samples/piano/' const SAMPLE_MAP: Record = { A0: 'A0.mp3', C1: 'C1.mp3', 'D#1': 'Ds1.mp3', 'F#1': 'Fs1.mp3',