initial commit

This commit is contained in:
2026-06-08 22:49:50 -07:00
commit f6a6681e16
40 changed files with 3026 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.pyc
.DS_Store
frontend/node_modules/
frontend/dist/
uv.lock
+1
View File
@@ -0,0 +1 @@
3.9
+90
View File
@@ -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.
View File
+38
View File
@@ -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()
+56
View File
@@ -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)
+23
View File
@@ -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)
+24
View File
@@ -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?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+47
View File
@@ -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)
```
+13
View File
@@ -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>
+1262
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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

+24
View File
@@ -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

+374
View File
@@ -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>
+14
View File
@@ -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

+1
View File
@@ -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

+10
View File
@@ -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>
+99
View File
@@ -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();
},
};
}
+47
View File
@@ -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;
}
+56
View File
@@ -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);
}
+132
View File
@@ -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);
}
+353
View File
@@ -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>
+30
View File
@@ -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;
}
+23
View File
@@ -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;
+103
View File
@@ -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);
},
};
}
+9
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {}
+20
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -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"]
}
+12
View File
@@ -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',
},
},
})
+16
View File
@@ -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"
+83
View File
@@ -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()