use real sounds from kbsim
This commit is contained in:
+32
-9
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user