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
+27 -14
View File
@@ -3,20 +3,18 @@ from pathlib import Path
from pydub import AudioSegment
from backend.models import ExportRequest, SwitchType
from backend.key_sound_map import (
MANIFEST,
marker_release_time,
resolve_marker_samples,
sample_path,
)
from backend.models import ExportRequest
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]
def _load_sample(path: Path) -> AudioSegment:
if not path.exists():
raise FileNotFoundError(f"Sample not found: {path}")
sample = AudioSegment.from_file(path)
@@ -24,14 +22,29 @@ def _load_sample(switch: SwitchType) -> AudioSegment:
def export_keyboard_audio(request: ExportRequest) -> bytes:
if request.switch not in MANIFEST:
raise FileNotFoundError(f"Unknown switch: {request.switch}")
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)
press_key, release_key = resolve_marker_samples(
request.switch,
marker.key,
marker.code,
)
press_sample = _load_sample(sample_path(request.switch, "press", press_key))
release_sample = _load_sample(sample_path(request.switch, "release", release_key))
press_ms = int(marker.time * 1000)
if press_ms < duration_ms:
base = base.overlay(press_sample, position=press_ms)
release_time = marker_release_time(marker.time, marker.release_time)
release_ms = int(release_time * 1000)
if release_ms < duration_ms:
base = base.overlay(release_sample, position=release_ms)
buffer = io.BytesIO()
base.export(buffer, format="wav")
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "assets" / "samples" / "kbsim"
MANIFEST_PATH = SAMPLES_DIR / "manifest.json"
DEFAULT_RELEASE_OFFSET = 0.08
SPECIAL_KEY_SAMPLES: dict[str, str] = {
"Space": "SPACE",
"Enter": "ENTER",
"Backspace": "BACKSPACE",
}
def _load_manifest() -> dict[str, dict[str, list[str]]]:
with MANIFEST_PATH.open(encoding="utf-8") as f:
return json.load(f)
MANIFEST = _load_manifest()
def hash_code(value: str) -> int:
"""djb2 hash — mirrored in frontend/src/lib/audio/keySoundMap.ts"""
hash_val = 5381
for ch in value:
hash_val = ((hash_val * 33) ^ ord(ch)) & 0xFFFFFFFF
return hash_val
def press_variant_index(code: str) -> int:
return hash_code(code) % 5
def press_variant_key(code: str) -> str:
return f"GENERIC_R{press_variant_index(code)}"
def resolve_press_sample_key(key_label: str, code: str, available: list[str]) -> str:
special = SPECIAL_KEY_SAMPLES.get(key_label)
if special and special in available:
return special
variant = press_variant_key(code)
if variant in available:
return variant
for sample in available:
if sample.startswith("GENERIC_R"):
return sample
return available[0]
def resolve_release_sample_key(key_label: str, available: list[str]) -> str:
special = SPECIAL_KEY_SAMPLES.get(key_label)
if special and special in available:
return special
if "GENERIC" in available:
return "GENERIC"
return available[0]
def sample_path(switch_id: str, phase: str, sample_key: str) -> Path:
return SAMPLES_DIR / switch_id / phase / f"{sample_key}.mp3"
def marker_release_time(time: float, release_time: Optional[float]) -> float:
return release_time if release_time is not None else time + DEFAULT_RELEASE_OFFSET
def resolve_marker_samples(
switch_id: str,
key_label: str,
code: Optional[str],
) -> tuple[str, str]:
switch_manifest = MANIFEST[switch_id]
press_available = switch_manifest["press"]
release_available = switch_manifest["release"]
resolved_code = code or key_label
press_key = resolve_press_sample_key(key_label, resolved_code, press_available)
release_key = resolve_release_sample_key(key_label, release_available)
return press_key, release_key
+25 -5
View File
@@ -1,23 +1,43 @@
from typing import Literal
from __future__ import annotations
from typing import Literal, Optional
from pydantic import BaseModel, Field
SwitchType = Literal["cherry-mx-blue", "cherry-mx-red", "cherry-mx-brown"]
SwitchType = Literal[
"cream",
"holypanda",
"alpaca",
"turquoise",
"inkblack",
"inkred",
"mxblack",
"mxbrown",
"mxblue",
"boxnavy",
"buckling",
"alpsblue",
"topre",
]
class Marker(BaseModel):
id: str
time: float = Field(ge=0)
key: str
code: Optional[str] = None
release_time: Optional[float] = Field(default=None, ge=0, alias="releaseTime")
model_config = {"populate_by_name": True}
class Project(BaseModel):
version: int = 1
switch: SwitchType = "cherry-mx-blue"
version: int = 2
switch: SwitchType = "mxbrown"
markers: list[Marker] = Field(default_factory=list)
class ExportRequest(BaseModel):
duration: float = Field(gt=0)
switch: SwitchType = "cherry-mx-blue"
switch: SwitchType = "mxbrown"
markers: list[Marker] = Field(default_factory=list)