use real sounds from kbsim

This commit is contained in:
2026-06-08 23:34:32 -07:00
parent 43e9d77de9
commit b5345f0345
168 changed files with 815 additions and 181 deletions
+32 -9
View File
@@ -3,7 +3,9 @@
import { createAudioEngine } from './lib/audio/engine';
import {
isEditableTarget,
keyCode,
keyLabel,
shouldDeleteSelected,
shouldOverrideSelected,
shouldRecordMarker,
} from './lib/keyboard';
@@ -32,6 +34,7 @@
let audioEngine = createAudioEngine();
let statusMessage = $state('');
let lastScheduledTime = -1;
const pendingPresses = new Map<string, string>();
function rescheduleAudio() {
if (!videoEl) return;
@@ -79,12 +82,10 @@
return;
}
if (event.key === 'Backspace' || event.key === 'Delete') {
if (app.selectedIds.size > 0) {
event.preventDefault();
deleteMarkers(new Set(app.selectedIds));
rescheduleAudio();
}
if (shouldDeleteSelected(event, app.selectedIds.size > 0)) {
event.preventDefault();
deleteMarkers(new Set(app.selectedIds));
rescheduleAudio();
return;
}
@@ -109,14 +110,36 @@
const label = keyLabel(event);
if (!label) return;
event.preventDefault();
const code = keyCode(event);
const time = snapToFrame(videoEl.currentTime);
addMarker(time, label);
const marker = addMarker(time, label, code);
pendingPresses.set(code, marker.id);
audioEngine.playKey(code, label, 'press');
rescheduleAudio();
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (isEditableTarget(event.target)) return;
const code = keyCode(event);
const markerId = pendingPresses.get(code);
if (!markerId || !videoEl) return;
event.preventDefault();
const label = keyLabel(event);
pendingPresses.delete(code);
const releaseTime = snapToFrame(videoEl.currentTime);
updateMarker(markerId, { releaseTime });
if (label) audioEngine.playKey(code, label, 'release');
rescheduleAudio();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
});
onDestroy(() => {
@@ -237,7 +260,7 @@
value={app.activeSwitch}
onchange={(event) => {
setActiveSwitch(event.currentTarget.value as typeof app.activeSwitch);
rescheduleAudio();
void audioEngine.preloadActiveSwitch().then(() => rescheduleAudio());
}}
>
{#each SWITCH_OPTIONS as option}
+142 -26
View File
@@ -1,42 +1,92 @@
import {
markerReleaseTime,
resolvePressSampleKey,
resolveReleaseSampleKey,
sampleUrl,
type SoundPhase,
type SwitchManifest,
} from './keySoundMap';
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;
playKey: (code: string, keyLabel: string, phase: SoundPhase) => void;
preloadActiveSwitch: () => Promise<void>;
destroy: () => void;
};
type Manifest = Record<string, SwitchManifest>;
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>();
let sfxGain: GainNode | null = null;
let manifest: Manifest | null = null;
let loadedSwitch: SwitchType | null = null;
const buffers = new Map<string, AudioBuffer>();
const scheduledSources: AudioBufferSourceNode[] = [];
async function ensureContext() {
if (!ctx) ctx = new AudioContext();
if (!ctx) {
ctx = new AudioContext();
sfxGain = ctx.createGain();
sfxGain.connect(ctx.destination);
}
if (ctx.state === 'suspended') await ctx.resume();
return ctx;
}
async function loadBuffer(switchType: SwitchType): Promise<AudioBuffer> {
const cached = buffers.get(switchType);
async function ensureManifest() {
if (manifest) return manifest;
const response = await fetch('/assets/samples/kbsim/manifest.json');
manifest = (await response.json()) as Manifest;
return manifest;
}
function bufferKey(switchType: SwitchType, phase: SoundPhase, sampleKey: string): string {
return `${switchType}/${phase}/${sampleKey}`;
}
async function loadBuffer(
switchType: SwitchType,
phase: SoundPhase,
sampleKey: string,
): Promise<AudioBuffer> {
const key = bufferKey(switchType, phase, sampleKey);
const cached = buffers.get(key);
if (cached) return cached;
const audioCtx = await ensureContext();
const response = await fetch(SAMPLE_PATHS[switchType]);
const response = await fetch(sampleUrl(switchType, phase, sampleKey));
if (!response.ok) {
throw new Error(`Sample not found: ${sampleUrl(switchType, phase, sampleKey)}`);
}
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
buffers.set(switchType, audioBuffer);
buffers.set(key, audioBuffer);
return audioBuffer;
}
async function preloadSwitch(switchType: SwitchType) {
const data = await ensureManifest();
const entry = data[switchType];
if (!entry) throw new Error(`Unknown switch: ${switchType}`);
const loads: Promise<AudioBuffer>[] = [];
for (const sampleKey of entry.press) {
loads.push(loadBuffer(switchType, 'press', sampleKey));
}
for (const sampleKey of entry.release) {
if (sampleKey === 'GENERIC_long') continue;
loads.push(loadBuffer(switchType, 'release', sampleKey));
}
await Promise.all(loads);
loadedSwitch = switchType;
}
function clearScheduled() {
for (const source of scheduledSources) {
try {
@@ -48,6 +98,41 @@ export function createAudioEngine(): AudioEngine {
scheduledSources.length = 0;
}
function playBuffer(buffer: AudioBuffer, when = 0) {
if (!ctx || !sfxGain) return;
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(sfxGain);
source.start(when);
return source;
}
async function resolveAndPlay(
switchType: SwitchType,
code: string,
keyLabel: string,
phase: SoundPhase,
when = 0,
) {
const data = await ensureManifest();
const entry = data[switchType];
if (!entry) return null;
const sampleKey =
phase === 'press'
? resolvePressSampleKey(keyLabel, code, entry.press)
: resolveReleaseSampleKey(keyLabel, entry.release);
if (phase === 'release' && sampleKey === 'GENERIC_long') return null;
if (loadedSwitch !== switchType) {
await preloadSwitch(switchType);
}
const buffer = await loadBuffer(switchType, phase, sampleKey);
return playBuffer(buffer, when);
}
return {
async init(video: HTMLVideoElement) {
const audioCtx = await ensureContext();
@@ -57,9 +142,8 @@ export function createAudioEngine(): AudioEngine {
videoSource.connect(videoGain);
videoGain.connect(audioCtx.destination);
}
await Promise.all(
(Object.keys(SAMPLE_PATHS) as SwitchType[]).map((id) => loadBuffer(id)),
);
await ensureManifest();
await preloadSwitch(app.activeSwitch);
},
reschedule(time: number, markers: Marker[], playing: boolean) {
@@ -71,28 +155,60 @@ export function createAudioEngine(): AudioEngine {
clearScheduled();
const audioCtx = ctx;
const startAt = audioCtx.currentTime + 0.02;
const buffer = buffers.get(app.activeSwitch);
if (!buffer) return;
const switchType = app.activeSwitch;
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);
}
void (async () => {
const data = await ensureManifest();
const entry = data[switchType];
if (!entry) return;
if (loadedSwitch !== switchType) {
await preloadSwitch(switchType);
}
for (const marker of markers) {
const code = marker.code ?? marker.key;
const pressKey = resolvePressSampleKey(marker.key, code, entry.press);
const releaseKey = resolveReleaseSampleKey(marker.key, entry.release);
const releaseTime = markerReleaseTime(marker);
if (marker.time >= time - 0.05) {
const pressDelay = marker.time - time;
const pressBuffer = await loadBuffer(switchType, 'press', pressKey);
const pressSource = playBuffer(pressBuffer, startAt + Math.max(0, pressDelay));
if (pressSource) scheduledSources.push(pressSource);
}
if (releaseTime >= time - 0.05) {
const releaseDelay = releaseTime - time;
const releaseBuffer = await loadBuffer(switchType, 'release', releaseKey);
const releaseSource = playBuffer(releaseBuffer, startAt + Math.max(0, releaseDelay));
if (releaseSource) scheduledSources.push(releaseSource);
}
}
})();
},
playKey(code: string, keyLabel: string, phase: SoundPhase) {
void resolveAndPlay(app.activeSwitch, code, keyLabel, phase);
},
async preloadActiveSwitch() {
await ensureManifest();
await preloadSwitch(app.activeSwitch);
},
destroy() {
clearScheduled();
videoSource?.disconnect();
videoGain?.disconnect();
sfxGain?.disconnect();
void ctx?.close();
ctx = null;
videoSource = null;
videoGain = null;
sfxGain = null;
manifest = null;
loadedSwitch = null;
buffers.clear();
},
};
+83
View File
@@ -0,0 +1,83 @@
import type { SwitchType } from '../types';
export type SoundPhase = 'press' | 'release';
export type PressSampleKey =
| 'SPACE'
| 'ENTER'
| 'BACKSPACE'
| 'GENERIC_R0'
| 'GENERIC_R1'
| 'GENERIC_R2'
| 'GENERIC_R3'
| 'GENERIC_R4';
export type ReleaseSampleKey = 'SPACE' | 'ENTER' | 'BACKSPACE' | 'GENERIC';
export type SwitchManifest = {
press: string[];
release: string[];
};
export const DEFAULT_RELEASE_OFFSET = 0.08;
const SPECIAL_KEY_SAMPLES: Record<string, PressSampleKey> = {
Space: 'SPACE',
Enter: 'ENTER',
Backspace: 'BACKSPACE',
};
/** djb2 hash — mirrored in backend/key_sound_map.py */
export function hashCode(value: string): number {
let hash = 5381;
for (let i = 0; i < value.length; i++) {
hash = (hash * 33) ^ value.charCodeAt(i);
}
return hash >>> 0;
}
export function pressVariantIndex(code: string): number {
return hashCode(code) % 5;
}
export function pressVariantKey(code: string): PressSampleKey {
return `GENERIC_R${pressVariantIndex(code)}` as PressSampleKey;
}
export function resolvePressSampleKey(
keyLabel: string,
code: string,
available: string[],
): PressSampleKey {
const special = SPECIAL_KEY_SAMPLES[keyLabel];
if (special && available.includes(special)) return special;
const variant = pressVariantKey(code);
if (available.includes(variant)) return variant;
const fallback = `GENERIC_R${pressVariantIndex(code)}`;
if (available.includes(fallback)) return fallback as PressSampleKey;
const generic = available.find((s) => s.startsWith('GENERIC_R'));
return (generic ?? available[0]) as PressSampleKey;
}
export function resolveReleaseSampleKey(
keyLabel: string,
available: string[],
): ReleaseSampleKey {
const special = SPECIAL_KEY_SAMPLES[keyLabel];
if (special && available.includes(special)) return special;
if (available.includes('GENERIC')) return 'GENERIC';
return (available[0] ?? 'GENERIC') as ReleaseSampleKey;
}
export function sampleUrl(
switchType: SwitchType,
phase: SoundPhase,
sampleKey: string,
): string {
return `/assets/samples/kbsim/${switchType}/${phase}/${sampleKey}.mp3`;
}
export function markerReleaseTime(marker: {
time: number;
releaseTime?: number;
}): number {
return marker.releaseTime ?? marker.time + DEFAULT_RELEASE_OFFSET;
}
+20 -25
View File
@@ -1,24 +1,5 @@
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',
' ',
]);
/** Keys that are modifiers only — never annotated on their own. */
const MODIFIER_ONLY_KEYS = new Set(['Control', 'Shift', 'Alt', 'Meta', 'CapsLock']);
export function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
@@ -26,9 +7,17 @@ export function isEditableTarget(target: EventTarget | null): boolean {
return tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA' || target.isContentEditable;
}
export function keyCode(event: KeyboardEvent): string {
return event.code;
}
export function hasShortcutModifier(event: KeyboardEvent): boolean {
return event.ctrlKey || event.metaKey;
}
export function keyLabel(event: KeyboardEvent): string | null {
if (event.ctrlKey && event.code === 'Space') return null;
if (IGNORED_KEYS.has(event.key)) return null;
if (hasShortcutModifier(event)) return null;
if (MODIFIER_ONLY_KEYS.has(event.key)) return null;
if (event.key.length === 1) return event.key;
if (event.key === ' ') return 'Space';
return event.key;
@@ -36,12 +25,18 @@ export function keyLabel(event: KeyboardEvent): string | null {
export function shouldOverrideSelected(event: KeyboardEvent, hasSelection: boolean): boolean {
if (!hasSelection) return false;
if (event.ctrlKey || event.metaKey || event.altKey) return false;
if (hasShortcutModifier(event)) 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;
if (event.repeat) return false;
if (hasShortcutModifier(event)) return false;
return keyLabel(event) !== null;
}
export function shouldDeleteSelected(event: KeyboardEvent, hasSelection: boolean): boolean {
if (!hasSelection) return false;
return event.key === 'Backspace' || event.key === 'Delete';
}
+27 -6
View File
@@ -1,9 +1,30 @@
import type { Project } from './types';
import type { LegacySwitchType, Marker, Project, SwitchType } from './types';
import { app, setActiveSwitch, setMarkers, setSelectedIds } from './store.svelte';
const LEGACY_SWITCH_MAP: Record<LegacySwitchType, SwitchType> = {
'cherry-mx-blue': 'mxblue',
'cherry-mx-red': 'mxblack',
'cherry-mx-brown': 'mxbrown',
};
function migrateSwitch(value: string): SwitchType {
if (value in LEGACY_SWITCH_MAP) {
return LEGACY_SWITCH_MAP[value as LegacySwitchType];
}
return value as SwitchType;
}
function migrateMarker(marker: Marker): Marker {
return {
...marker,
code: marker.code,
releaseTime: marker.releaseTime,
};
}
export function buildProject(): Project {
return {
version: 1,
version: 2,
switch: app.activeSwitch,
markers: app.markers.map((m) => ({ ...m })),
};
@@ -20,12 +41,12 @@ export function downloadProject(filename = 'project.json') {
URL.revokeObjectURL(url);
}
export function loadProject(data: Project) {
if (data.version !== 1) {
export function loadProject(data: Project & { version?: number; switch?: string }) {
if (data.version !== 1 && data.version !== 2) {
throw new Error(`Unsupported project version: ${data.version}`);
}
setMarkers(data.markers.map((m) => ({ ...m })));
setActiveSwitch(data.switch);
setMarkers((data.markers ?? []).map(migrateMarker));
setActiveSwitch(migrateSwitch(data.switch ?? 'mxbrown'));
setSelectedIds(new Set());
}
+7 -4
View File
@@ -10,7 +10,7 @@ export const app = $state({
fps: 30,
pixelsPerSecond: ZOOM_DEFAULT,
scrollLeft: 0,
activeSwitch: 'cherry-mx-blue' as SwitchType,
activeSwitch: 'mxbrown' as SwitchType,
videoUrl: null as string | null,
videoName: null as string | null,
timelineWidth: 800,
@@ -68,13 +68,16 @@ export function setTimelineWidth(value: number) {
app.timelineWidth = value;
}
export function addMarker(time: number, key: string): Marker {
const marker: Marker = { id: createMarkerId(), time, key };
export function addMarker(time: number, key: string, code?: string): Marker {
const marker: Marker = { id: createMarkerId(), time, key, code };
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'>>) {
export function updateMarker(
id: string,
patch: Partial<Pick<Marker, 'time' | 'key' | 'code' | 'releaseTime'>>,
) {
app.markers = app.markers
.map((m) => (m.id === id ? { ...m, ...patch } : m))
.sort((a, b) => a.time - b.time);
+32 -4
View File
@@ -1,9 +1,27 @@
export type SwitchType = 'cherry-mx-blue' | 'cherry-mx-red' | 'cherry-mx-brown';
export type SwitchType =
| 'cream'
| 'holypanda'
| 'alpaca'
| 'turquoise'
| 'inkblack'
| 'inkred'
| 'mxblack'
| 'mxbrown'
| 'mxblue'
| 'boxnavy'
| 'buckling'
| 'alpsblue'
| 'topre';
/** @deprecated v1 project switch ids — migrated on load */
export type LegacySwitchType = 'cherry-mx-blue' | 'cherry-mx-red' | 'cherry-mx-brown';
export interface Marker {
id: string;
time: number;
key: string;
code?: string;
releaseTime?: number;
}
export interface Project {
@@ -13,9 +31,19 @@ export interface Project {
}
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)' },
{ id: 'cream', label: 'NovelKeys Creams' },
{ id: 'holypanda', label: 'Holy Pandas' },
{ id: 'alpaca', label: 'Alpacas' },
{ id: 'turquoise', label: 'Turquoise Tealios' },
{ id: 'inkblack', label: 'Gateron Black Inks' },
{ id: 'inkred', label: 'Gateron Red Inks' },
{ id: 'mxblack', label: 'Cherry MX Blacks' },
{ id: 'mxbrown', label: 'Cherry MX Browns' },
{ id: 'mxblue', label: 'Cherry MX Blues' },
{ id: 'boxnavy', label: 'Kailh Box Navies' },
{ id: 'buckling', label: 'Buckling Spring' },
{ id: 'alpsblue', label: 'SKCM Blue Alps' },
{ id: 'topre', label: 'Topre' },
];
export const ZOOM_MIN = 20;