drop python backend layer
This commit is contained in:
@@ -1,51 +0,0 @@
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from pydub import AudioSegment
|
||||
|
||||
from backend.key_sound_map import (
|
||||
MANIFEST,
|
||||
marker_release_time,
|
||||
resolve_marker_samples,
|
||||
sample_path,
|
||||
)
|
||||
from backend.models import ExportRequest
|
||||
|
||||
SAMPLE_RATE = 48000
|
||||
|
||||
|
||||
def _load_sample(path: Path) -> AudioSegment:
|
||||
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:
|
||||
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)
|
||||
|
||||
for marker in sorted(request.markers, key=lambda m: m.time):
|
||||
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")
|
||||
return buffer.getvalue()
|
||||
@@ -1,83 +0,0 @@
|
||||
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
|
||||
@@ -1,56 +0,0 @@
|
||||
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)
|
||||
@@ -1,43 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
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 = 2
|
||||
switch: SwitchType = "mxbrown"
|
||||
markers: list[Marker] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
duration: float = Field(gt=0)
|
||||
switch: SwitchType = "mxbrown"
|
||||
markers: list[Marker] = Field(default_factory=list)
|
||||
Reference in New Issue
Block a user