initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
uv.lock
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.9
|
||||||
@@ -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
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,38 @@
|
|||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydub import AudioSegment
|
||||||
|
|
||||||
|
from backend.models import ExportRequest, SwitchType
|
||||||
|
|
||||||
|
SAMPLE_RATE = 48000
|
||||||
|
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "assets" / "samples"
|
||||||
|
|
||||||
|
SWITCH_FILES: dict[SwitchType, str] = {
|
||||||
|
"cherry-mx-blue": "cherry-mx-blue.wav",
|
||||||
|
"cherry-mx-red": "cherry-mx-red.wav",
|
||||||
|
"cherry-mx-brown": "cherry-mx-brown.wav",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sample(switch: SwitchType) -> 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()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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?
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>sfxkeeb</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1262
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,374 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { createAudioEngine } from './lib/audio/engine';
|
||||||
|
import {
|
||||||
|
isEditableTarget,
|
||||||
|
keyLabel,
|
||||||
|
shouldOverrideSelected,
|
||||||
|
shouldRecordMarker,
|
||||||
|
} from './lib/keyboard';
|
||||||
|
import { downloadProject, exportAudio, loadProject } from './lib/project';
|
||||||
|
import {
|
||||||
|
addMarker,
|
||||||
|
app,
|
||||||
|
deleteMarkers,
|
||||||
|
nudgeSelected,
|
||||||
|
selectAll,
|
||||||
|
setActiveSwitch,
|
||||||
|
setFps,
|
||||||
|
setVideoUrl,
|
||||||
|
snapToFrame,
|
||||||
|
updateMarker,
|
||||||
|
} from './lib/store.svelte';
|
||||||
|
import Timeline from './lib/timeline/Timeline.svelte';
|
||||||
|
import { SWITCH_OPTIONS } from './lib/types';
|
||||||
|
import { attachVideoPlayer, type VideoController } from './lib/video/player';
|
||||||
|
|
||||||
|
let videoEl: HTMLVideoElement | undefined = $state();
|
||||||
|
let videoInput: HTMLInputElement | undefined = $state();
|
||||||
|
let projectInput: HTMLInputElement | undefined = $state();
|
||||||
|
let controller: VideoController | null = null;
|
||||||
|
let audioEngine = createAudioEngine();
|
||||||
|
let statusMessage = $state('');
|
||||||
|
let lastScheduledTime = -1;
|
||||||
|
|
||||||
|
function rescheduleAudio() {
|
||||||
|
if (!videoEl) return;
|
||||||
|
audioEngine.reschedule(videoEl.currentTime, app.markers, !videoEl.paused);
|
||||||
|
lastScheduledTime = videoEl.currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initVideo() {
|
||||||
|
if (!videoEl) return;
|
||||||
|
controller?.destroy();
|
||||||
|
void audioEngine.init(videoEl).then(() => rescheduleAudio());
|
||||||
|
controller = attachVideoPlayer(videoEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (app.videoUrl && videoEl) initVideo();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!videoEl) return;
|
||||||
|
const t = app.currentTime;
|
||||||
|
const playing = app.isPlaying;
|
||||||
|
void app.markers;
|
||||||
|
void app.activeSwitch;
|
||||||
|
if (playing && Math.abs(t - lastScheduledTime) > 0.25) {
|
||||||
|
rescheduleAudio();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (isEditableTarget(event.target)) return;
|
||||||
|
|
||||||
|
if (event.ctrlKey && event.code === 'Space') {
|
||||||
|
event.preventDefault();
|
||||||
|
controller?.togglePlayPause();
|
||||||
|
rescheduleAudio();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
|
||||||
|
event.preventDefault();
|
||||||
|
selectAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Backspace' || event.key === 'Delete') {
|
||||||
|
if (app.selectedIds.size > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
deleteMarkers(new Set(app.selectedIds));
|
||||||
|
rescheduleAudio();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['ArrowLeft', 'ArrowRight'].includes(event.key) && app.selectedIds.size > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
nudgeSelected(event.key === 'ArrowLeft' ? -1 : 1);
|
||||||
|
rescheduleAudio();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldOverrideSelected(event, app.selectedIds.size > 0)) {
|
||||||
|
const label = keyLabel(event);
|
||||||
|
if (!label) return;
|
||||||
|
event.preventDefault();
|
||||||
|
for (const id of app.selectedIds) updateMarker(id, { key: label });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app.videoUrl || !videoEl) return;
|
||||||
|
|
||||||
|
if (shouldRecordMarker(event)) {
|
||||||
|
const label = keyLabel(event);
|
||||||
|
if (!label) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const time = snapToFrame(videoEl.currentTime);
|
||||||
|
addMarker(time, label);
|
||||||
|
rescheduleAudio();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
controller?.destroy();
|
||||||
|
audioEngine.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleVideoFile(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setVideoUrl(URL.createObjectURL(file), file.name);
|
||||||
|
statusMessage = `Loaded video: ${file.name}`;
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProjectFile(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
loadProject(JSON.parse(text));
|
||||||
|
statusMessage = `Loaded project: ${file.name}`;
|
||||||
|
rescheduleAudio();
|
||||||
|
} catch (error) {
|
||||||
|
statusMessage = error instanceof Error ? error.message : 'Failed to load project';
|
||||||
|
}
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExportAudio() {
|
||||||
|
if (!app.duration) {
|
||||||
|
statusMessage = 'Load a video before exporting audio';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
statusMessage = 'Exporting audio...';
|
||||||
|
await exportAudio(app.duration);
|
||||||
|
statusMessage = 'Audio export complete';
|
||||||
|
} catch (error) {
|
||||||
|
statusMessage = error instanceof Error ? error.message : 'Export failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
<header class="toolbar">
|
||||||
|
<div class="file-actions">
|
||||||
|
<button type="button" onclick={() => videoInput?.click()}>Open Video</button>
|
||||||
|
<button type="button" onclick={() => projectInput?.click()}>Open Project</button>
|
||||||
|
<button type="button" onclick={() => downloadProject()}>Save Project</button>
|
||||||
|
<button type="button" onclick={handleExportAudio}>Export Audio</button>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
{#if app.videoName}
|
||||||
|
<span>{app.videoName}</span>
|
||||||
|
{/if}
|
||||||
|
{#if app.duration}
|
||||||
|
<label class="fps-control">
|
||||||
|
FPS
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="240"
|
||||||
|
value={app.fps}
|
||||||
|
onchange={(event) => {
|
||||||
|
const value = Number(event.currentTarget.value);
|
||||||
|
if (value >= 1 && value <= 240) setFps(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{#if statusMessage}
|
||||||
|
<span class="status">{statusMessage}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<input bind:this={videoInput} type="file" accept="video/mp4,video/*" hidden onchange={handleVideoFile} />
|
||||||
|
<input bind:this={projectInput} type="file" accept="application/json,.json" hidden onchange={handleProjectFile} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="player-section">
|
||||||
|
{#if app.videoUrl}
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video
|
||||||
|
bind:this={videoEl}
|
||||||
|
src={app.videoUrl}
|
||||||
|
controls={false}
|
||||||
|
onplay={rescheduleAudio}
|
||||||
|
onpause={rescheduleAudio}
|
||||||
|
onseeked={rescheduleAudio}
|
||||||
|
></video>
|
||||||
|
{:else}
|
||||||
|
<div class="placeholder">Open an MP4 video to begin annotating key presses</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="transport">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="play-btn"
|
||||||
|
disabled={!app.videoUrl}
|
||||||
|
onclick={() => {
|
||||||
|
controller?.togglePlayPause();
|
||||||
|
rescheduleAudio();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{app.isPlaying ? 'Pause' : 'Play'}
|
||||||
|
</button>
|
||||||
|
<span class="shortcut-hint">Ctrl+Space</span>
|
||||||
|
|
||||||
|
<label class="switch-select">
|
||||||
|
Switch
|
||||||
|
<select
|
||||||
|
value={app.activeSwitch}
|
||||||
|
onchange={(event) => {
|
||||||
|
setActiveSwitch(event.currentTarget.value as typeof app.activeSwitch);
|
||||||
|
rescheduleAudio();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#each SWITCH_OPTIONS as option}
|
||||||
|
<option value={option.id}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Timeline
|
||||||
|
onSeek={(time) => {
|
||||||
|
controller?.seek(time);
|
||||||
|
rescheduleAudio();
|
||||||
|
}}
|
||||||
|
onReschedule={rescheduleAudio}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
background: #111;
|
||||||
|
color: #eee;
|
||||||
|
font-family: Inter, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
background: #2d3748;
|
||||||
|
color: #edf2f7;
|
||||||
|
border: 1px solid #4a5568;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
color: #68d391;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-section {
|
||||||
|
background: #000;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 320px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 60vh;
|
||||||
|
display: block;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: #718096;
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transport {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn {
|
||||||
|
min-width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #a0aec0;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border: 1px dashed #4a5568;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-select {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fps-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fps-control input {
|
||||||
|
width: 4rem;
|
||||||
|
background: #2d3748;
|
||||||
|
color: #edf2f7;
|
||||||
|
border: 1px solid #4a5568;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.2rem 0.35rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let count: number = $state(0)
|
||||||
|
const increment = () => {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button type="button" class="counter" onclick={increment}>
|
||||||
|
Count is {count}
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { Marker, SwitchType } from '../types';
|
||||||
|
import { app } from '../store.svelte';
|
||||||
|
|
||||||
|
const SAMPLE_PATHS: Record<SwitchType, string> = {
|
||||||
|
'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<void>;
|
||||||
|
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<SwitchType, AudioBuffer>();
|
||||||
|
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<AudioBuffer> {
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<string>(),
|
||||||
|
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<string>) {
|
||||||
|
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<Pick<Marker, 'time' | 'key'>>) {
|
||||||
|
app.markers = app.markers
|
||||||
|
.map((m) => (m.id === id ? { ...m, ...patch } : m))
|
||||||
|
.sort((a, b) => a.time - b.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMarkerTimes(updates: Map<string, number>) {
|
||||||
|
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<string>) {
|
||||||
|
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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
app,
|
||||||
|
setMarkerTimes,
|
||||||
|
setPixelsPerSecond,
|
||||||
|
setScrollLeft,
|
||||||
|
setSelectedIds,
|
||||||
|
setTimelineWidth,
|
||||||
|
snapToFrame,
|
||||||
|
toggleSelection,
|
||||||
|
} from '../store.svelte';
|
||||||
|
import { ZOOM_MAX, ZOOM_MIN } from '../types';
|
||||||
|
import {
|
||||||
|
centerScrollOnPlayhead,
|
||||||
|
formatTime,
|
||||||
|
pxToTime,
|
||||||
|
tickInterval,
|
||||||
|
timeToPx,
|
||||||
|
} from './math';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSeek?: (time: number) => void;
|
||||||
|
onReschedule?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onSeek, onReschedule }: Props = $props();
|
||||||
|
|
||||||
|
let viewportEl: HTMLDivElement | undefined = $state();
|
||||||
|
let dragMode = $state<'none' | 'marker' | 'marquee' | 'move'>('none');
|
||||||
|
let dragMarkerId = $state<string | null>(null);
|
||||||
|
let dragStartX = 0;
|
||||||
|
let marqueeStart = 0;
|
||||||
|
let marqueeEnd = 0;
|
||||||
|
let moveStartTimes = new Map<string, number>();
|
||||||
|
|
||||||
|
function updateScrollCenter() {
|
||||||
|
setScrollLeft(
|
||||||
|
centerScrollOnPlayhead(app.currentTime, app.pixelsPerSecond, app.timelineWidth),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void app.currentTime;
|
||||||
|
void app.pixelsPerSecond;
|
||||||
|
if (app.duration > 0 && viewportEl) updateScrollCenter();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!viewportEl) return;
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
setTimelineWidth(viewportEl!.clientWidth);
|
||||||
|
});
|
||||||
|
observer.observe(viewportEl);
|
||||||
|
setTimelineWidth(viewportEl.clientWidth);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
function clampZoom(value: number) {
|
||||||
|
return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWheel(event: WheelEvent) {
|
||||||
|
if (!event.altKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const factor = event.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||||||
|
setPixelsPerSecond(clampZoom(app.pixelsPerSecond * factor));
|
||||||
|
updateScrollCenter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function localX(event: PointerEvent): number {
|
||||||
|
if (!viewportEl) return 0;
|
||||||
|
const rect = viewportEl.getBoundingClientRect();
|
||||||
|
return event.clientX - rect.left + app.scrollLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event: PointerEvent) {
|
||||||
|
if (!viewportEl) return;
|
||||||
|
const x = localX(event);
|
||||||
|
const time = pxToTime(x, app.pixelsPerSecond);
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
const markerEl = target.closest('[data-marker-id]') as HTMLElement | null;
|
||||||
|
|
||||||
|
if (markerEl?.dataset.markerId) {
|
||||||
|
const id = markerEl.dataset.markerId;
|
||||||
|
toggleSelection(id, event.shiftKey);
|
||||||
|
dragMode = app.selectedIds.has(id) ? 'move' : 'marker';
|
||||||
|
dragMarkerId = id;
|
||||||
|
dragStartX = x;
|
||||||
|
moveStartTimes = new Map(
|
||||||
|
app.markers.filter((m) => app.selectedIds.has(m.id)).map((m) => [m.id, m.time]),
|
||||||
|
);
|
||||||
|
viewportEl.setPointerCapture(event.pointerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.shiftKey) setSelectedIds(new Set());
|
||||||
|
dragMode = 'marquee';
|
||||||
|
marqueeStart = time;
|
||||||
|
marqueeEnd = time;
|
||||||
|
viewportEl.setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event: PointerEvent) {
|
||||||
|
if (dragMode === 'none' || !viewportEl) return;
|
||||||
|
const x = localX(event);
|
||||||
|
|
||||||
|
if (dragMode === 'marquee') {
|
||||||
|
marqueeEnd = pxToTime(x, app.pixelsPerSecond);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragMode === 'move' && moveStartTimes.size > 0) {
|
||||||
|
const deltaTime = pxToTime(x - dragStartX, app.pixelsPerSecond);
|
||||||
|
const updates = new Map<string, number>();
|
||||||
|
for (const [id, start] of moveStartTimes) {
|
||||||
|
const next = Math.max(0, Math.min(app.duration || Infinity, start + deltaTime));
|
||||||
|
updates.set(id, snapToFrame(next));
|
||||||
|
}
|
||||||
|
setMarkerTimes(updates);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragMode === 'marker' && dragMarkerId) {
|
||||||
|
const time = snapToFrame(
|
||||||
|
Math.max(0, Math.min(app.duration || Infinity, pxToTime(x, app.pixelsPerSecond))),
|
||||||
|
);
|
||||||
|
setMarkerTimes(new Map([[dragMarkerId, time]]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(event: PointerEvent) {
|
||||||
|
if (dragMode === 'marquee') {
|
||||||
|
const minT = Math.min(marqueeStart, marqueeEnd);
|
||||||
|
const maxT = Math.max(marqueeStart, marqueeEnd);
|
||||||
|
const hits = app.markers.filter((m) => m.time >= minT && m.time <= maxT).map((m) => m.id);
|
||||||
|
setSelectedIds(new Set(hits));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragMode === 'move' || dragMode === 'marker') {
|
||||||
|
onReschedule?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
dragMode = 'none';
|
||||||
|
dragMarkerId = null;
|
||||||
|
moveStartTimes.clear();
|
||||||
|
viewportEl?.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRulerClick(event: MouseEvent) {
|
||||||
|
if (dragMode !== 'none') return;
|
||||||
|
const x = localX(event as unknown as PointerEvent);
|
||||||
|
const time = snapToFrame(
|
||||||
|
Math.max(0, Math.min(app.duration || Infinity, pxToTime(x, app.pixelsPerSecond))),
|
||||||
|
);
|
||||||
|
onSeek?.(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleZoomInput(event: Event) {
|
||||||
|
const value = Number((event.currentTarget as HTMLInputElement).value);
|
||||||
|
setPixelsPerSecond(value);
|
||||||
|
updateScrollCenter();
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentWidth = $derived(
|
||||||
|
Math.max(app.timelineWidth, timeToPx(app.duration || 1, app.pixelsPerSecond) + 40),
|
||||||
|
);
|
||||||
|
const ticks = $derived.by(() => {
|
||||||
|
const interval = tickInterval(app.pixelsPerSecond);
|
||||||
|
const count = Math.ceil((app.duration || 1) / interval);
|
||||||
|
return Array.from({ length: count + 1 }, (_, i) => i * interval);
|
||||||
|
});
|
||||||
|
const playheadX = $derived(timeToPx(app.currentTime, app.pixelsPerSecond));
|
||||||
|
const marqueeVisible = $derived(dragMode === 'marquee');
|
||||||
|
const marqueeLeft = $derived(timeToPx(Math.min(marqueeStart, marqueeEnd), app.pixelsPerSecond));
|
||||||
|
const marqueeWidth = $derived(
|
||||||
|
Math.abs(
|
||||||
|
timeToPx(marqueeEnd, app.pixelsPerSecond) - timeToPx(marqueeStart, app.pixelsPerSecond),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="timeline-panel">
|
||||||
|
<div class="zoom-row">
|
||||||
|
<label for="zoom-slider">Zoom</label>
|
||||||
|
<input
|
||||||
|
id="zoom-slider"
|
||||||
|
type="range"
|
||||||
|
min={ZOOM_MIN}
|
||||||
|
max={ZOOM_MAX}
|
||||||
|
value={app.pixelsPerSecond}
|
||||||
|
oninput={handleZoomInput}
|
||||||
|
/>
|
||||||
|
<span class="hint">Alt + scroll to zoom</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeline-viewport" bind:this={viewportEl} onwheel={handleWheel}>
|
||||||
|
<div
|
||||||
|
class="timeline-content"
|
||||||
|
style="width: {contentWidth}px; transform: translateX({-app.scrollLeft}px);"
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="ruler"
|
||||||
|
onclick={handleRulerClick}
|
||||||
|
onpointerdown={handlePointerDown}
|
||||||
|
onpointermove={handlePointerMove}
|
||||||
|
onpointerup={handlePointerUp}
|
||||||
|
>
|
||||||
|
{#each ticks as tick}
|
||||||
|
<div class="tick" style="left: {timeToPx(tick, app.pixelsPerSecond)}px">
|
||||||
|
<span>{formatTime(tick)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="track"
|
||||||
|
onpointerdown={handlePointerDown}
|
||||||
|
onpointermove={handlePointerMove}
|
||||||
|
onpointerup={handlePointerUp}
|
||||||
|
>
|
||||||
|
{#each app.markers as marker (marker.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="marker"
|
||||||
|
class:selected={app.selectedIds.has(marker.id)}
|
||||||
|
data-marker-id={marker.id}
|
||||||
|
style="left: {timeToPx(marker.time, app.pixelsPerSecond)}px"
|
||||||
|
title="{marker.key} @ {formatTime(marker.time)}"
|
||||||
|
>
|
||||||
|
{marker.key}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if marqueeVisible}
|
||||||
|
<div class="marquee" style="left: {marqueeLeft}px; width: {marqueeWidth}px"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="playhead" style="left: {playheadX}px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.timeline-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-row input[type='range'] {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-viewport {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 120px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ruler {
|
||||||
|
position: relative;
|
||||||
|
height: 28px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
background: #222;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-left: 1px solid #444;
|
||||||
|
padding-left: 4px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #888;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
position: relative;
|
||||||
|
height: calc(100% - 28px);
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #2d3748;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: grab;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker.selected {
|
||||||
|
background: #3182ce;
|
||||||
|
border-color: #63b3ed;
|
||||||
|
box-shadow: 0 0 0 1px #63b3ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marquee {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
height: 72px;
|
||||||
|
background: rgba(99, 179, 237, 0.2);
|
||||||
|
border: 1px solid #63b3ed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playhead {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: #f56565;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
||||||
|
export default {}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./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"]
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
@@ -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("<h", max(-32768, min(32767, int(s * 32767)))) for s in samples
|
||||||
|
)
|
||||||
|
with wave.open(str(path), "w") as wf:
|
||||||
|
wf.setnchannels(1)
|
||||||
|
wf.setsampwidth(2)
|
||||||
|
wf.setframerate(SAMPLE_RATE)
|
||||||
|
wf.writeframes(pcm)
|
||||||
|
|
||||||
|
|
||||||
|
def envelope(length: int, attack: float, decay: float) -> 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()
|
||||||
Reference in New Issue
Block a user