feat: core types, time, rng, model fetch

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 02:38:02 +01:00
co-authored by Claude Haiku 5.5
parent 0a6ec1dc40
commit bac4fbeb1d
8 changed files with 140 additions and 2 deletions
-1
View File
@@ -1 +0,0 @@
console.log("Hello via Bun!");
+6 -1
View File
@@ -1,8 +1,13 @@
{
"name": "stsheets",
"module": "index.ts",
"type": "module",
"private": true,
"scripts": {
"test": "bun test",
"typecheck": "bunx tsc --noEmit",
"fetch-models": "bun scripts/fetch-models.ts",
"bench": "bun src/bench/cli.ts"
},
"devDependencies": {
"@types/bun": "latest"
},
+60
View File
@@ -0,0 +1,60 @@
// Downloads model files into models/ and verifies their sha256.
// Files already present with a matching hash are skipped.
import { join } from "node:path";
interface ModelFile {
path: string; // relative to repo root
url: string;
sha256: string;
}
export const MODELS: ModelFile[] = [
{
path: "models/demucs/htdemucs_6s.onnx",
url: "https://huggingface.co/Kani95/htdemucs-6s-ort/resolve/main/htdemucs_6s.onnx",
sha256: "35cb61d4d35173ff1d16df7393f41f05651ffdfe13d3c10e06abc26708bd6933",
},
{
path: "models/basic-pitch/nmp.onnx",
url: "https://github.com/spotify/basic-pitch/raw/main/basic_pitch/saved_models/icassp_2022/nmp.onnx",
sha256: "2c3c1d144bfa61ad236e92e169c13535c880469a12a047d4e73451f2c059a0ec",
},
];
const ROOT = join(import.meta.dir, "..");
async function sha256Of(file: string): Promise<string> {
const hasher = new Bun.CryptoHasher("sha256");
hasher.update(new Uint8Array(await Bun.file(file).arrayBuffer()));
return hasher.digest("hex");
}
export async function fetchModels(root = ROOT): Promise<void> {
for (const m of MODELS) {
const dest = join(root, m.path);
if (await Bun.file(dest).exists()) {
const have = await sha256Of(dest);
if (have === m.sha256) {
console.log(`ok ${m.path}`);
continue;
}
console.log(`mismatch ${m.path} (have ${have}), re-downloading`);
}
console.log(`fetch ${m.url}`);
const res = await fetch(m.url, { redirect: "follow" });
if (!res.ok) throw new Error(`download failed for ${m.url}: HTTP ${res.status}`);
const bytes = new Uint8Array(await res.arrayBuffer());
const hasher = new Bun.CryptoHasher("sha256");
hasher.update(bytes);
const got = hasher.digest("hex");
if (got !== m.sha256) {
throw new Error(`sha256 mismatch for ${m.path}: expected ${m.sha256}, got ${got}`);
}
await Bun.write(dest, bytes);
console.log(`saved ${m.path}`);
}
}
if (import.meta.main) {
await fetchModels();
}
+33
View File
@@ -0,0 +1,33 @@
export interface Rng {
/** Uniform in [0, 1). */
next(): number;
/** Uniform integer in [lo, hi], inclusive. */
int(lo: number, hi: number): number;
pick<T>(a: readonly T[]): T;
chance(p: number): boolean;
}
// mulberry32
export function createRng(seed: number): Rng {
let s = seed >>> 0;
const next = (): number => {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return {
next,
int(lo, hi) {
return lo + Math.floor(next() * (hi - lo + 1));
},
pick<T>(a: readonly T[]): T {
if (a.length === 0) throw new Error("rng.pick: empty array");
return a[Math.floor(next() * a.length)] as T;
},
chance(p) {
return next() < p;
},
};
}
+10
View File
@@ -0,0 +1,10 @@
import { TPQ } from "./types.ts";
// One quarter note = TPQ ticks.
export function ticksToSeconds(tick: number, bpm: number): number {
return (tick / TPQ) * (60 / bpm);
}
export function secondsToTicks(s: number, bpm: number): number {
return s * (bpm / 60) * TPQ;
}
+13
View File
@@ -0,0 +1,13 @@
export type Role = "guitar" | "bass" | "keys" | "vocals" | "drums";
export type FxPreset = "none" | "clean" | "clean-chorus" | "light-drive";
// string numbering: 1 = highest-pitched string (alphaTex convention). Drums: pitch = GM drum number.
export interface ScoreNote { tick: number; dur: number; pitch: number; string?: number; fret?: number }
export interface Track { name: string; role: Role; program: number; tuning?: number[]; pan: number; fx: FxPreset; gainDb: number; notes: ScoreNote[] }
export interface Song { title: string; tier: string; seed: number; bpm: number; timeSig: [number, number]; bars: number; key: string; tracks: Track[] }
export interface TimedNote { onset: number; offset: number; pitch: number; velocity?: number; string?: number; fret?: number }
export interface TruthTrack { name: string; role: Role; tuning?: number[]; notes: TimedNote[] }
export interface Grid { bpm: number; firstDownbeat: number; beatsPerBar: number }
export interface Audio { sampleRate: number; channels: Float32Array[] }
export const TPQ = 960;
export const GUITAR_STANDARD = [64, 59, 55, 50, 45, 40];
export const BASS_STANDARD = [43, 38, 33, 28];
+10
View File
@@ -0,0 +1,10 @@
import { test, expect } from "bun:test";
import { createRng } from "../../src/core/rng.ts";
test("rng deterministic", () => {
const a = createRng(42), b = createRng(42), c = createRng(43);
const sa = [a.next(), a.next(), a.next()];
expect([b.next(), b.next(), b.next()]).toEqual(sa);
expect(c.next()).not.toBe(sa[0]);
const r = createRng(1); for (let i = 0; i < 1000; i++) { const v = r.int(3, 5); expect(v >= 3 && v <= 5).toBe(true); }
});
+8
View File
@@ -0,0 +1,8 @@
import { test, expect } from "bun:test";
import { ticksToSeconds, secondsToTicks } from "../../src/core/time.ts";
test("ticks/seconds", () => {
expect(ticksToSeconds(960, 60)).toBeCloseTo(1);
expect(ticksToSeconds(3840, 120)).toBeCloseTo(2);
expect(secondsToTicks(0.5, 120)).toBeCloseTo(960);
});