From f6a6681e165ea3cf3550541c302f54df1ed46245 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Mon, 8 Jun 2026 22:49:50 -0700 Subject: [PATCH] initial commit --- .gitignore | 7 + .python-version | 1 + README.md | 90 ++ assets/samples/cherry-mx-blue.wav | Bin 0 -> 8684 bytes assets/samples/cherry-mx-brown.wav | Bin 0 -> 6764 bytes assets/samples/cherry-mx-red.wav | Bin 0 -> 4844 bytes backend/__init__.py | 0 backend/audio_export.py | 38 + backend/main.py | 56 + backend/models.py | 23 + frontend/.gitignore | 24 + frontend/.vscode/extensions.json | 3 + frontend/README.md | 47 + frontend/index.html | 13 + frontend/package-lock.json | 1262 +++++++++++++++++++++ frontend/package.json | 21 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.svelte | 374 ++++++ frontend/src/app.css | 14 + frontend/src/assets/hero.png | Bin 0 -> 13057 bytes frontend/src/assets/svelte.svg | 1 + frontend/src/assets/vite.svg | 1 + frontend/src/lib/Counter.svelte | 10 + frontend/src/lib/audio/engine.ts | 99 ++ frontend/src/lib/keyboard.ts | 47 + frontend/src/lib/project.ts | 56 + frontend/src/lib/store.svelte.ts | 132 +++ frontend/src/lib/timeline/Timeline.svelte | 353 ++++++ frontend/src/lib/timeline/math.ts | 30 + frontend/src/lib/types.ts | 23 + frontend/src/lib/video/player.ts | 103 ++ frontend/src/main.ts | 9 + frontend/svelte.config.js | 2 + frontend/tsconfig.app.json | 20 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 24 + frontend/vite.config.ts | 12 + pyproject.toml | 16 + scripts/generate_samples.py | 83 ++ 40 files changed, 3026 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 README.md create mode 100644 assets/samples/cherry-mx-blue.wav create mode 100644 assets/samples/cherry-mx-brown.wav create mode 100644 assets/samples/cherry-mx-red.wav create mode 100644 backend/__init__.py create mode 100644 backend/audio_export.py create mode 100644 backend/main.py create mode 100644 backend/models.py create mode 100644 frontend/.gitignore create mode 100644 frontend/.vscode/extensions.json create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.svelte create mode 100644 frontend/src/app.css create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/svelte.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/lib/Counter.svelte create mode 100644 frontend/src/lib/audio/engine.ts create mode 100644 frontend/src/lib/keyboard.ts create mode 100644 frontend/src/lib/project.ts create mode 100644 frontend/src/lib/store.svelte.ts create mode 100644 frontend/src/lib/timeline/Timeline.svelte create mode 100644 frontend/src/lib/timeline/math.ts create mode 100644 frontend/src/lib/types.ts create mode 100644 frontend/src/lib/video/player.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/svelte.config.js create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 pyproject.toml create mode 100644 scripts/generate_samples.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1bc4d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.pyc +.DS_Store +frontend/node_modules/ +frontend/dist/ +uv.lock diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..bd28b9c --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9 diff --git a/README.md b/README.md new file mode 100644 index 0000000..35acda5 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# sfxkeeb + +Annotate keyboard key presses on a video timeline and preview/export mechanical switch sounds mixed with the video audio. + +## Requirements + +- [uv](https://docs.astral.sh/uv/) (Python package manager) +- Node.js 18+ and npm +- **ffmpeg** on your PATH (required by pydub for audio export) + +```bash +# macOS +brew install ffmpeg +``` + +## Setup + +```bash +# Python dependencies +uv sync + +# Frontend dependencies +cd frontend && npm install +``` + +## Development + +Run both servers in separate terminals: + +```bash +# Terminal 1 — FastAPI backend (audio export API) +uv run uvicorn backend.main:app --reload + +# Terminal 2 — Vite dev server (proxies /api to :8000) +cd frontend && npm run dev +``` + +Open http://localhost:5173 + +## Production-like run + +```bash +cd frontend && npm run build +uv run uvicorn backend.main:app --host 127.0.0.1 --port 8000 +``` + +Open http://127.0.0.1:8000 + +## Usage + +1. **Open Video** — load an MP4 file +2. Press keys while the playhead is at the desired time to add markers (works while playing or paused) +3. Select markers to override keys, nudge with arrow keys, or multi-select with marquee drag +4. Choose a mechanical switch sound from the dropdown +5. **Save Project** / **Open Project** — JSON with version, markers, and switch setting (no video path) +6. **Export Audio** — download a WAV file of keyboard sounds only, full video length + +### Keyboard shortcuts + +| Shortcut | Action | +|----------|--------| +| Ctrl+Space | Play / pause | +| Arrow keys | Nudge selected marker(s) one frame | +| Alt+Scroll | Zoom timeline (centers on playhead) | +| Backspace / Delete | Delete selected marker(s) | +| Ctrl+A | Select all markers | + +## Project file format + +```json +{ + "version": 1, + "switch": "cherry-mx-blue", + "markers": [ + { "id": "m1", "time": 1.234, "key": "a" } + ] +} +``` + +Re-open the video manually after loading a project file. + +## Switch samples + +Bundled samples are short synthetic click sounds representing Cherry MX Blue (clicky), Red (linear), and Brown (tactile) switches. + +## Known limitations + +- Frame stepping uses `video.currentTime` and may land on nearest keyframes for some MP4 encodings +- Project files do not reference the video file path +- Very long videos may take longer to export via pydub in-memory overlay diff --git a/assets/samples/cherry-mx-blue.wav b/assets/samples/cherry-mx-blue.wav new file mode 100644 index 0000000000000000000000000000000000000000..39f0e8af31fde3e8793a9734dc1f3b8ce7fcb568 GIT binary patch literal 8684 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 AudioSegment: + path = SAMPLES_DIR / SWITCH_FILES[switch] + if not path.exists(): + raise FileNotFoundError(f"Sample not found: {path}") + sample = AudioSegment.from_file(path) + return sample.set_frame_rate(SAMPLE_RATE).set_channels(2) + + +def export_keyboard_audio(request: ExportRequest) -> bytes: + duration_ms = int(request.duration * 1000) + base = AudioSegment.silent(duration=duration_ms, frame_rate=SAMPLE_RATE) + click = _load_sample(request.switch) + + for marker in sorted(request.markers, key=lambda m: m.time): + position_ms = int(marker.time * 1000) + if position_ms < duration_ms: + base = base.overlay(click, position=position_ms) + + buffer = io.BytesIO() + base.export(buffer, format="wav") + return buffer.getvalue() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..a578dd0 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,56 @@ +from pathlib import Path + +import uvicorn +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import Response +from fastapi.staticfiles import StaticFiles + +from backend.audio_export import export_keyboard_audio +from backend.models import ExportRequest + +ROOT = Path(__file__).resolve().parent.parent +DIST = ROOT / "frontend" / "dist" +ASSETS = ROOT / "assets" + +app = FastAPI(title="sfxkeeb") + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +if ASSETS.exists(): + app.mount("/assets", StaticFiles(directory=ASSETS), name="assets") + + +@app.post("/api/export/audio") +def export_audio(request: ExportRequest) -> Response: + try: + wav_bytes = export_keyboard_audio(request) + except FileNotFoundError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Export failed: {exc}") from exc + + return Response( + content=wav_bytes, + media_type="audio/wav", + headers={"Content-Disposition": 'attachment; filename="keyboard_track.wav"'}, + ) + + +@app.get("/api/health") +def health() -> dict[str, str]: + return {"status": "ok"} + + +if DIST.exists(): + app.mount("/", StaticFiles(directory=DIST, html=True), name="frontend") + + +def run() -> None: + uvicorn.run("backend.main:app", host="127.0.0.1", port=8000, reload=True) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..2fde559 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,23 @@ +from typing import Literal + +from pydantic import BaseModel, Field + +SwitchType = Literal["cherry-mx-blue", "cherry-mx-red", "cherry-mx-brown"] + + +class Marker(BaseModel): + id: str + time: float = Field(ge=0) + key: str + + +class Project(BaseModel): + version: int = 1 + switch: SwitchType = "cherry-mx-blue" + markers: list[Marker] = Field(default_factory=list) + + +class ExportRequest(BaseModel): + duration: float = Field(gt=0) + switch: SwitchType = "cherry-mx-blue" + markers: list[Marker] = Field(default_factory=list) diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..bdef820 --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e6cd94f --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,47 @@ +# Svelte + TS + Vite + +This template should help get you started developing with Svelte and TypeScript in Vite. + +## Recommended IDE Setup + +[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). + +## Need an official Svelte framework? + +Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more. + +## Technical considerations + +**Why use this over SvelteKit?** + +- It brings its own routing solution which might not be preferable for some users. +- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app. + +This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project. + +Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate. + +**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?** + +Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information. + +**Why include `.vscode/extensions.json`?** + +Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project. + +**Why enable `allowJs` in the TS template?** + +While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant. + +**Why is HMR not preserving my local component state?** + +HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr). + +If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR. + +```ts +// store.ts +// An extremely simple external store +import { writable } from 'svelte/store' +export default writable(0) +``` diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d965aae --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + sfxkeeb + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..41a1f81 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1262 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tsconfig/svelte": "^5.0.8", + "@types/node": "^24.12.3", + "svelte": "^5.55.5", + "svelte-check": "^4.4.8", + "typescript": "~6.0.2", + "vite": "^8.0.12" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.1.1.tgz", + "integrity": "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", + "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.1.tgz", + "integrity": "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz", + "integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.3", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.3.tgz", + "integrity": "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.11", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.6.0.tgz", + "integrity": "sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "0.1.1", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..3430fcb --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tsconfig/svelte": "^5.0.8", + "@types/node": "^24.12.3", + "svelte": "^5.55.5", + "svelte-check": "^4.4.8", + "typescript": "~6.0.2", + "vite": "^8.0.12" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..2b8b940 --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,374 @@ + + +
+
+
+ + + + +
+
+ {#if app.videoName} + {app.videoName} + {/if} + {#if app.duration} + + {/if} + {#if statusMessage} + {statusMessage} + {/if} +
+ + +
+ +
+ {#if app.videoUrl} + + + {:else} +
Open an MP4 video to begin annotating key presses
+ {/if} +
+ +
+ + Ctrl+Space + + +
+ + { + controller?.seek(time); + rescheduleAudio(); + }} + onReschedule={rescheduleAudio} + /> +
+ + diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..4f1668d --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,14 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid #63b3ed; + outline-offset: 2px; +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 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 P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/frontend/src/assets/svelte.svg b/frontend/src/assets/svelte.svg new file mode 100644 index 0000000..c5e0848 --- /dev/null +++ b/frontend/src/assets/svelte.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/lib/Counter.svelte b/frontend/src/lib/Counter.svelte new file mode 100644 index 0000000..5f046bd --- /dev/null +++ b/frontend/src/lib/Counter.svelte @@ -0,0 +1,10 @@ + + + diff --git a/frontend/src/lib/audio/engine.ts b/frontend/src/lib/audio/engine.ts new file mode 100644 index 0000000..7d6dfef --- /dev/null +++ b/frontend/src/lib/audio/engine.ts @@ -0,0 +1,99 @@ +import type { Marker, SwitchType } from '../types'; +import { app } from '../store.svelte'; + +const SAMPLE_PATHS: Record = { + 'cherry-mx-blue': '/assets/samples/cherry-mx-blue.wav', + 'cherry-mx-red': '/assets/samples/cherry-mx-red.wav', + 'cherry-mx-brown': '/assets/samples/cherry-mx-brown.wav', +}; + +export type AudioEngine = { + init: (video: HTMLVideoElement) => Promise; + reschedule: (currentTime: number, markers: Marker[], isPlaying: boolean) => void; + destroy: () => void; +}; + +export function createAudioEngine(): AudioEngine { + let ctx: AudioContext | null = null; + let videoSource: MediaElementAudioSourceNode | null = null; + let videoGain: GainNode | null = null; + const buffers = new Map(); + const scheduledSources: AudioBufferSourceNode[] = []; + + async function ensureContext() { + if (!ctx) ctx = new AudioContext(); + if (ctx.state === 'suspended') await ctx.resume(); + return ctx; + } + + async function loadBuffer(switchType: SwitchType): Promise { + const cached = buffers.get(switchType); + if (cached) return cached; + const audioCtx = await ensureContext(); + const response = await fetch(SAMPLE_PATHS[switchType]); + const arrayBuffer = await response.arrayBuffer(); + const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer); + buffers.set(switchType, audioBuffer); + return audioBuffer; + } + + function clearScheduled() { + for (const source of scheduledSources) { + try { + source.stop(); + } catch { + // already stopped + } + } + scheduledSources.length = 0; + } + + return { + async init(video: HTMLVideoElement) { + const audioCtx = await ensureContext(); + if (!videoSource) { + videoSource = audioCtx.createMediaElementSource(video); + videoGain = audioCtx.createGain(); + videoSource.connect(videoGain); + videoGain.connect(audioCtx.destination); + } + await Promise.all( + (Object.keys(SAMPLE_PATHS) as SwitchType[]).map((id) => loadBuffer(id)), + ); + }, + + reschedule(time: number, markers: Marker[], playing: boolean) { + if (!ctx || !playing) { + clearScheduled(); + return; + } + + clearScheduled(); + const audioCtx = ctx; + const startAt = audioCtx.currentTime + 0.02; + const buffer = buffers.get(app.activeSwitch); + if (!buffer) return; + + for (const marker of markers) { + if (marker.time < time - 0.05) continue; + const delay = marker.time - time; + const source = audioCtx.createBufferSource(); + source.buffer = buffer; + source.connect(audioCtx.destination); + source.start(startAt + Math.max(0, delay)); + scheduledSources.push(source); + } + }, + + destroy() { + clearScheduled(); + videoSource?.disconnect(); + videoGain?.disconnect(); + void ctx?.close(); + ctx = null; + videoSource = null; + videoGain = null; + buffers.clear(); + }, + }; +} diff --git a/frontend/src/lib/keyboard.ts b/frontend/src/lib/keyboard.ts new file mode 100644 index 0000000..24093ee --- /dev/null +++ b/frontend/src/lib/keyboard.ts @@ -0,0 +1,47 @@ +const MODIFIER_KEYS = new Set([ + 'Control', + 'Shift', + 'Alt', + 'Meta', + 'CapsLock', + 'Tab', + 'Escape', +]); + +const IGNORED_KEYS = new Set([ + ...MODIFIER_KEYS, + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'Backspace', + 'Delete', + 'Enter', + ' ', +]); + +export function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName; + return tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA' || target.isContentEditable; +} + +export function keyLabel(event: KeyboardEvent): string | null { + if (event.ctrlKey && event.code === 'Space') return null; + if (IGNORED_KEYS.has(event.key)) return null; + if (event.key.length === 1) return event.key; + if (event.key === ' ') return 'Space'; + return event.key; +} + +export function shouldOverrideSelected(event: KeyboardEvent, hasSelection: boolean): boolean { + if (!hasSelection) return false; + if (event.ctrlKey || event.metaKey || event.altKey) return false; + if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return false; + return keyLabel(event) !== null; +} + +export function shouldRecordMarker(event: KeyboardEvent): boolean { + if (event.ctrlKey || event.metaKey || event.altKey) return false; + return keyLabel(event) !== null; +} diff --git a/frontend/src/lib/project.ts b/frontend/src/lib/project.ts new file mode 100644 index 0000000..3fd6b80 --- /dev/null +++ b/frontend/src/lib/project.ts @@ -0,0 +1,56 @@ +import type { Project } from './types'; +import { app, setActiveSwitch, setMarkers, setSelectedIds } from './store.svelte'; + +export function buildProject(): Project { + return { + version: 1, + switch: app.activeSwitch, + markers: app.markers.map((m) => ({ ...m })), + }; +} + +export function downloadProject(filename = 'project.json') { + const project = buildProject(); + const blob = new Blob([JSON.stringify(project, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} + +export function loadProject(data: Project) { + if (data.version !== 1) { + throw new Error(`Unsupported project version: ${data.version}`); + } + setMarkers(data.markers.map((m) => ({ ...m }))); + setActiveSwitch(data.switch); + setSelectedIds(new Set()); +} + +export async function exportAudio(duration: number) { + const project = buildProject(); + const response = await fetch('/api/export/audio', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + duration, + switch: project.switch, + markers: project.markers, + }), + }); + + if (!response.ok) { + const detail = await response.text(); + throw new Error(detail || 'Audio export failed'); + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'keyboard_track.wav'; + anchor.click(); + URL.revokeObjectURL(url); +} diff --git a/frontend/src/lib/store.svelte.ts b/frontend/src/lib/store.svelte.ts new file mode 100644 index 0000000..cb5e609 --- /dev/null +++ b/frontend/src/lib/store.svelte.ts @@ -0,0 +1,132 @@ +import type { Marker, SwitchType } from './types'; +import { ZOOM_DEFAULT } from './types'; + +export const app = $state({ + markers: [] as Marker[], + selectedIds: new Set(), + isPlaying: false, + currentTime: 0, + duration: 0, + fps: 30, + pixelsPerSecond: ZOOM_DEFAULT, + scrollLeft: 0, + activeSwitch: 'cherry-mx-blue' as SwitchType, + videoUrl: null as string | null, + videoName: null as string | null, + timelineWidth: 800, +}); + +let markerCounter = 0; + +export function createMarkerId(): string { + markerCounter += 1; + return `m${markerCounter}`; +} + +export function setMarkers(value: Marker[]) { + app.markers = value; + const maxId = value.reduce((max, m) => { + const num = parseInt(m.id.replace(/\D/g, ''), 10); + return Number.isFinite(num) ? Math.max(max, num) : max; + }, 0); + markerCounter = maxId; +} + +export function setSelectedIds(ids: Set) { + app.selectedIds = ids; +} + +export function setCurrentTime(value: number) { + app.currentTime = value; +} + +export function setDuration(value: number) { + app.duration = value; +} + +export function setIsPlaying(value: boolean) { + app.isPlaying = value; +} + +export function setFps(value: number) { + app.fps = value; +} + +export function setPixelsPerSecond(value: number) { + app.pixelsPerSecond = value; +} + +export function setScrollLeft(value: number) { + app.scrollLeft = value; +} + +export function setActiveSwitch(value: SwitchType) { + app.activeSwitch = value; +} + +export function setTimelineWidth(value: number) { + app.timelineWidth = value; +} + +export function addMarker(time: number, key: string): Marker { + const marker: Marker = { id: createMarkerId(), time, key }; + app.markers = [...app.markers, marker].sort((a, b) => a.time - b.time); + return marker; +} + +export function updateMarker(id: string, patch: Partial>) { + app.markers = app.markers + .map((m) => (m.id === id ? { ...m, ...patch } : m)) + .sort((a, b) => a.time - b.time); +} + +export function setMarkerTimes(updates: Map) { + app.markers = app.markers + .map((m) => { + const time = updates.get(m.id); + return time === undefined ? m : { ...m, time }; + }) + .sort((a, b) => a.time - b.time); +} + +export function deleteMarkers(ids: Set) { + app.markers = app.markers.filter((m) => !ids.has(m.id)); + const next = new Set(app.selectedIds); + for (const id of ids) next.delete(id); + app.selectedIds = next; +} + +export function selectAll() { + app.selectedIds = new Set(app.markers.map((m) => m.id)); +} + +export function toggleSelection(id: string, additive: boolean) { + const next = additive ? new Set(app.selectedIds) : new Set(); + if (next.has(id)) next.delete(id); + else next.add(id); + app.selectedIds = next; +} + +export function setVideoUrl(url: string | null, name: string | null = null) { + if (app.videoUrl) URL.revokeObjectURL(app.videoUrl); + app.videoUrl = url; + app.videoName = name; +} + +export function snapToFrame(time: number): number { + const frameDuration = 1 / app.fps; + return Math.round(time / frameDuration) * frameDuration; +} + +export function nudgeSelected(deltaFrames: number) { + if (app.selectedIds.size === 0) return; + const frameDuration = 1 / app.fps; + const delta = deltaFrames * frameDuration; + app.markers = app.markers + .map((m) => { + if (!app.selectedIds.has(m.id)) return m; + const next = Math.max(0, Math.min(app.duration || Infinity, m.time + delta)); + return { ...m, time: snapToFrame(next) }; + }) + .sort((a, b) => a.time - b.time); +} diff --git a/frontend/src/lib/timeline/Timeline.svelte b/frontend/src/lib/timeline/Timeline.svelte new file mode 100644 index 0000000..781ac70 --- /dev/null +++ b/frontend/src/lib/timeline/Timeline.svelte @@ -0,0 +1,353 @@ + + +
+
+ + + Alt + scroll to zoom +
+ +
+
+ + +
+ {#each ticks as tick} +
+ {formatTime(tick)} +
+ {/each} +
+ + +
+ {#each app.markers as marker (marker.id)} + + {/each} + + {#if marqueeVisible} +
+ {/if} + +
+
+
+
+
+ + diff --git a/frontend/src/lib/timeline/math.ts b/frontend/src/lib/timeline/math.ts new file mode 100644 index 0000000..2f63a94 --- /dev/null +++ b/frontend/src/lib/timeline/math.ts @@ -0,0 +1,30 @@ +export function timeToPx(time: number, pixelsPerSecond: number): number { + return time * pixelsPerSecond; +} + +export function pxToTime(px: number, pixelsPerSecond: number): number { + return px / pixelsPerSecond; +} + +export function centerScrollOnPlayhead( + playheadTime: number, + pixelsPerSecond: number, + viewportWidth: number, +): number { + const playheadPx = timeToPx(playheadTime, pixelsPerSecond); + return Math.max(0, playheadPx - viewportWidth / 2); +} + +export function formatTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toFixed(2).padStart(5, '0')}`; +} + +export function tickInterval(pixelsPerSecond: number): number { + if (pixelsPerSecond >= 200) return 0.1; + if (pixelsPerSecond >= 100) return 0.5; + if (pixelsPerSecond >= 50) return 1; + if (pixelsPerSecond >= 25) return 2; + return 5; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..fd6dc76 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,23 @@ +export type SwitchType = 'cherry-mx-blue' | 'cherry-mx-red' | 'cherry-mx-brown'; + +export interface Marker { + id: string; + time: number; + key: string; +} + +export interface Project { + version: number; + switch: SwitchType; + markers: Marker[]; +} + +export const SWITCH_OPTIONS: { id: SwitchType; label: string }[] = [ + { id: 'cherry-mx-blue', label: 'Cherry MX Blue (clicky)' }, + { id: 'cherry-mx-red', label: 'Cherry MX Red (linear)' }, + { id: 'cherry-mx-brown', label: 'Cherry MX Brown (tactile)' }, +]; + +export const ZOOM_MIN = 20; +export const ZOOM_MAX = 400; +export const ZOOM_DEFAULT = 80; diff --git a/frontend/src/lib/video/player.ts b/frontend/src/lib/video/player.ts new file mode 100644 index 0000000..6bff89d --- /dev/null +++ b/frontend/src/lib/video/player.ts @@ -0,0 +1,103 @@ +import { app, setCurrentTime, setDuration, setFps, setIsPlaying, snapToFrame } from '../store.svelte'; + +export type VideoController = { + element: HTMLVideoElement; + destroy: () => void; + togglePlayPause: () => void; + stepFrame: (delta: number) => void; + seek: (time: number) => void; +}; + +export function attachVideoPlayer(video: HTMLVideoElement): VideoController { + let rafId = 0; + let lastFrameTime: number | null = null; + const frameDeltas: number[] = []; + + const onLoadedMetadata = () => { + setDuration(video.duration); + setCurrentTime(video.currentTime); + }; + + const onPlay = () => setIsPlaying(true); + const onPause = () => setIsPlaying(false); + const onSeeked = () => setCurrentTime(video.currentTime); + const onEnded = () => setIsPlaying(false); + + const detectFps = (mediaTime: number) => { + if (lastFrameTime !== null) { + const delta = mediaTime - lastFrameTime; + if (delta > 0.001 && delta < 0.2) { + frameDeltas.push(delta); + if (frameDeltas.length > 30) frameDeltas.shift(); + if (frameDeltas.length >= 10) { + const avg = frameDeltas.reduce((a, b) => a + b, 0) / frameDeltas.length; + const detected = Math.round(1 / avg); + if (detected >= 10 && detected <= 120) setFps(detected); + } + } + } + lastFrameTime = mediaTime; + }; + + const frameLoop = (_now: number, metadata: VideoFrameCallbackMetadata) => { + setCurrentTime(metadata.mediaTime); + detectFps(metadata.mediaTime); + if (!video.paused && !video.ended) { + video.requestVideoFrameCallback(frameLoop); + } + }; + + const timeLoop = () => { + if (!video.paused) { + setCurrentTime(video.currentTime); + rafId = requestAnimationFrame(timeLoop); + } + }; + + const onPlayStart = () => { + if ('requestVideoFrameCallback' in video) { + video.requestVideoFrameCallback(frameLoop); + } else { + rafId = requestAnimationFrame(timeLoop); + } + }; + + video.addEventListener('loadedmetadata', onLoadedMetadata); + video.addEventListener('play', onPlay); + video.addEventListener('play', onPlayStart); + video.addEventListener('pause', onPause); + video.addEventListener('seeked', onSeeked); + video.addEventListener('ended', onEnded); + + if (video.readyState >= 1) onLoadedMetadata(); + + return { + element: video, + destroy() { + cancelAnimationFrame(rafId); + video.removeEventListener('loadedmetadata', onLoadedMetadata); + video.removeEventListener('play', onPlay); + video.removeEventListener('play', onPlayStart); + video.removeEventListener('pause', onPause); + video.removeEventListener('seeked', onSeeked); + video.removeEventListener('ended', onEnded); + }, + togglePlayPause() { + if (video.paused || video.ended) void video.play(); + else video.pause(); + }, + stepFrame(delta: number) { + video.pause(); + const next = snapToFrame( + Math.max(0, Math.min(video.duration || 0, video.currentTime + delta / app.fps)), + ); + video.currentTime = next; + setCurrentTime(next); + }, + seek(time: number) { + const clamped = Math.max(0, Math.min(video.duration || 0, time)); + video.currentTime = clamped; + setCurrentTime(clamped); + }, + }; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..664a057 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,9 @@ +import { mount } from 'svelte' +import './app.css' +import App from './App.svelte' + +const app = mount(App, { + target: document.getElementById('app')!, +}) + +export default app diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..0cf7db3 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,2 @@ +/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */ +export default {} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..d774b20 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,20 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "module": "esnext", + "types": ["svelte", "vite/client"], + "noEmit": true, + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable checkJs if you'd like to use dynamic types in JS. + * Note that setting allowJs false does not prevent the use + * of JS in `.svelte` files. + */ + "allowJs": true, + "checkJs": true, + "moduleDetection": "force" + }, + "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..6139ab3 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' + +export default defineConfig({ + plugins: [svelte()], + server: { + proxy: { + '/api': 'http://127.0.0.1:8000', + '/assets': 'http://127.0.0.1:8000', + }, + }, +}) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..eafedef --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "sfxkeeb" +version = "0.1.0" +description = "Video keyboard SFX annotator with timeline editing and audio export" +readme = "README.md" +requires-python = ">=3.9" +dependencies = [ + "fastapi>=0.128.8", + "pydantic>=2.13.4", + "pydub>=0.25.1", + "python-multipart>=0.0.20", + "uvicorn[standard]>=0.39.0", +] + +[project.scripts] +sfxkeeb = "backend.main:run" diff --git a/scripts/generate_samples.py b/scripts/generate_samples.py new file mode 100644 index 0000000..85c6e33 --- /dev/null +++ b/scripts/generate_samples.py @@ -0,0 +1,83 @@ +"""Generate synthetic mechanical keyboard switch samples.""" + +import math +import struct +import wave +from pathlib import Path + +SAMPLE_RATE = 48000 +OUT_DIR = Path(__file__).resolve().parent.parent / "assets" / "samples" + + +def write_wav(path: Path, samples: list[float]) -> None: + pcm = b"".join( + struct.pack(" list[float]: + env = [] + attack_samples = int(attack * SAMPLE_RATE) + decay_samples = int(decay * SAMPLE_RATE) + for i in range(length): + if i < attack_samples: + env.append(i / max(attack_samples, 1)) + elif i < attack_samples + decay_samples: + t = (i - attack_samples) / max(decay_samples, 1) + env.append(1.0 - t) + else: + env.append(0.0) + return env + + +def generate_click( + freq: float, + duration: float, + attack: float, + decay: float, + noise_mix: float = 0.0, + click_burst: bool = False, +) -> list[float]: + length = int(duration * SAMPLE_RATE) + env = envelope(length, attack, decay) + out = [] + for i in range(length): + t = i / SAMPLE_RATE + tone = math.sin(2 * math.pi * freq * t) * (0.7 if not click_burst else 0.4) + if click_burst and t < 0.008: + tone += math.sin(2 * math.pi * (freq * 2.8) * t) * 0.5 + noise = 0.0 + if noise_mix > 0: + noise = (((i * 1103515245 + 12345) & 0x7FFFFFFF) / 0x7FFFFFFF - 0.5) * noise_mix + out.append((tone + noise) * env[i]) + peak = max(abs(s) for s in out) or 1.0 + return [s / peak * 0.85 for s in out] + + +def main() -> None: + OUT_DIR.mkdir(parents=True, exist_ok=True) + + profiles = { + "cherry-mx-blue.wav": generate_click( + 2800, 0.09, 0.001, 0.085, noise_mix=0.15, click_burst=True + ), + "cherry-mx-red.wav": generate_click( + 1200, 0.05, 0.002, 0.045, noise_mix=0.08 + ), + "cherry-mx-brown.wav": generate_click( + 1800, 0.07, 0.002, 0.065, noise_mix=0.12, click_burst=True + ), + } + + for name, samples in profiles.items(): + write_wav(OUT_DIR / name, samples) + print(f"Wrote {OUT_DIR / name}") + + +if __name__ == "__main__": + main()