Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions 150b3a-virtual-piano/utils/audio-helpers.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/* ── Frequency calculation ───────────────────────────────────────────── */
export const BASE_FREQS: Record<string, number> = {
C: 261.63,
'C#': 277.18,
D: 293.66,
'D#': 311.13,
E: 329.63,
F: 349.23,
'F#': 369.99,
G: 392.0,
'G#': 415.3,
A: 440.0,
'A#': 466.16,
B: 493.88,
};

export function noteFreq(note: string, octave: number): number {
return (BASE_FREQS[note] ?? 440) * Math.pow(2, octave - 4);
}

/* ── Instrument profiles (harmonic stacking + ADSR) ─────────────────── */
export interface InstrumentProfile {
harmonics: { ratio: number; gain: number; detune?: number }[];
attack: number;
decay: number;
sustainLevel: number;
releaseDecay: number; /* how fast the tail fades (seconds to near-zero) */
}

export const INSTRUMENT_PROFILES: Record<string, InstrumentProfile> = {
classical: {
/* Grand piano: inharmonic overtones (real strings are slightly sharp above
fundamental), two detuned unison oscillators for natural "chorus".
ADSR: very fast attack → rapid initial decay → slow long tail (piano
strings don't have a flat sustain level — they just keep decaying). */
harmonics: [
{ ratio: 1.0, gain: 0.5 } /* fundamental */,
{ ratio: 1.0, gain: 0.1, detune: 5 } /* unison +5 cents */,
{ ratio: 2.005, gain: 0.22 } /* 2nd harmonic slightly sharp */,
{ ratio: 3.015, gain: 0.1 } /* 3rd */,
{ ratio: 4.03, gain: 0.06 } /* 4th */,
{ ratio: 5.05, gain: 0.03 } /* 5th */,
{ ratio: 6.08, gain: 0.015 } /* 6th */,
{ ratio: 8.13, gain: 0.008 } /* 8th */,
],
attack: 0.004,
decay: 0.12,
sustainLevel: 0.18 /* piano strings continue decaying — no flat sustain */,
releaseDecay: 1.8,
},
electric: {
/* Rhodes-style: mellow mid harmonics, slightly warmer detune */
harmonics: [
{ ratio: 1.0, gain: 0.42 },
{ ratio: 1.0, gain: 0.08, detune: 3 },
{ ratio: 2.002, gain: 0.28 },
{ ratio: 3.005, gain: 0.15 },
{ ratio: 4.01, gain: 0.08 },
{ ratio: 5.02, gain: 0.04 },
],
attack: 0.012,
decay: 0.2,
sustainLevel: 0.25,
releaseDecay: 1.2,
},
organ: {
/* Hammond-style: perfectly harmonic, steady sustain — NO decay */
harmonics: [
{ ratio: 1.0, gain: 0.38 },
{ ratio: 2.0, gain: 0.28 },
{ ratio: 3.0, gain: 0.2 },
{ ratio: 4.0, gain: 0.1 },
{ ratio: 6.0, gain: 0.04 },
],
attack: 0.006,
decay: 0.01,
sustainLevel: 0.45,
releaseDecay: 0.06,
},
harpsichord: {
/* Sharp percussive attack, fast decay, bright upper harmonics */
harmonics: [
{ ratio: 1.0, gain: 0.48 },
{ ratio: 2.002, gain: 0.26 },
{ ratio: 4.008, gain: 0.16 },
{ ratio: 8.02, gain: 0.08 },
{ ratio: 16.05, gain: 0.03 },
],
attack: 0.003,
decay: 0.03,
sustainLevel: 0.06,
releaseDecay: 0.5,
},
felt: {
/* Felt/soft piano: muted, warm — piano with felt strip on strings */
harmonics: [
{ ratio: 1.0, gain: 0.48 },
{ ratio: 2.002, gain: 0.14 },
{ ratio: 3.01, gain: 0.06 },
{ ratio: 4.02, gain: 0.03 },
],
attack: 0.008,
decay: 0.18,
sustainLevel: 0.22,
releaseDecay: 2.4,
},
bright: {
/* Bright piano: strong upper harmonics, crisp attack */
harmonics: [
{ ratio: 1.0, gain: 0.42 },
{ ratio: 1.0, gain: 0.09, detune: 7 },
{ ratio: 2.005, gain: 0.26 },
{ ratio: 3.02, gain: 0.16 },
{ ratio: 4.04, gain: 0.12 },
{ ratio: 5.08, gain: 0.08 },
{ ratio: 6.12, gain: 0.05 },
{ ratio: 8.2, gain: 0.03 },
],
attack: 0.002,
decay: 0.08,
sustainLevel: 0.14,
releaseDecay: 1.5,
},
symphonic: {
/* Symphonic / Concert Grand: rich overtones, long tail */
harmonics: [
{ ratio: 1.0, gain: 0.45 },
{ ratio: 1.0, gain: 0.12, detune: 4 },
{ ratio: 2.004, gain: 0.24 },
{ ratio: 3.012, gain: 0.14 },
{ ratio: 4.025, gain: 0.09 },
{ ratio: 5.045, gain: 0.05 },
{ ratio: 6.07, gain: 0.03 },
{ ratio: 7.1, gain: 0.02 },
],
attack: 0.005,
decay: 0.15,
sustainLevel: 0.2,
releaseDecay: 3.2,
},
violin: {
/* Violin: slow bow attack, flat sustain, expressive tail */
harmonics: [
{ ratio: 1.0, gain: 0.4 },
{ ratio: 2.0, gain: 0.3 },
{ ratio: 3.0, gain: 0.18 },
{ ratio: 4.0, gain: 0.08 },
{ ratio: 5.0, gain: 0.04 },
],
attack: 0.08,
decay: 0.05,
sustainLevel: 0.42,
releaseDecay: 0.3,
},
harp: {
/* Harp: plucked, clean fast attack, warm decay */
harmonics: [
{ ratio: 1.0, gain: 0.5 },
{ ratio: 2.001, gain: 0.22 },
{ ratio: 3.004, gain: 0.12 },
{ ratio: 4.009, gain: 0.07 },
{ ratio: 5.016, gain: 0.04 },
{ ratio: 6.025, gain: 0.02 },
],
attack: 0.003,
decay: 0.05,
sustainLevel: 0.1,
releaseDecay: 2.8,
},
};
187 changes: 187 additions & 0 deletions 150b3a-virtual-piano/utils/keyboard-helpers.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/* ═══════════════════════════════════════════════════════════════════════════
VP.NET KEYBOARD MAPPING — 61 keys C2–C7
─────────────────────────────────────────────────────────────────────────
White keys per octave:
Oct 2: 1 2 3 4 5 6 7
Oct 3: 8 9 0 q w e r
Oct 4: t y u i o p a ← Middle C (t = C4)
Oct 5: s d f g h j k
Oct 6: l z x c v b n
C7 only: m

Black keys (Shift+key):
Oct 2: ! @ $ % ^
Oct 3: * ( Q W E
Oct 4: T Y I O P
Oct 5: S D G H J
Oct 6: L Z C V B
═══════════════════════════════════════════════════════════════════════════ */
export const KEYBOARD_MAPPING: Record<
string,
{ note: string; octave: number }
> = {
/* oct 2 */ '1': { note: 'C', octave: 2 },
'!': { note: 'C#', octave: 2 },
'2': { note: 'D', octave: 2 },
'@': { note: 'D#', octave: 2 },
'3': { note: 'E', octave: 2 },
'4': { note: 'F', octave: 2 },
$: { note: 'F#', octave: 2 },
'5': { note: 'G', octave: 2 },
'%': { note: 'G#', octave: 2 },
'6': { note: 'A', octave: 2 },
'^': { note: 'A#', octave: 2 },
'7': { note: 'B', octave: 2 },
/* oct 3 */ '8': { note: 'C', octave: 3 },
'*': { note: 'C#', octave: 3 },
'9': { note: 'D', octave: 3 },
'(': { note: 'D#', octave: 3 },
'0': { note: 'E', octave: 3 },
q: { note: 'F', octave: 3 },
Q: { note: 'F#', octave: 3 },
w: { note: 'G', octave: 3 },
W: { note: 'G#', octave: 3 },
e: { note: 'A', octave: 3 },
E: { note: 'A#', octave: 3 },
r: { note: 'B', octave: 3 },
/* oct 4 */ t: { note: 'C', octave: 4 },
T: { note: 'C#', octave: 4 },
y: { note: 'D', octave: 4 },
Y: { note: 'D#', octave: 4 },
u: { note: 'E', octave: 4 },
i: { note: 'F', octave: 4 },
I: { note: 'F#', octave: 4 },
o: { note: 'G', octave: 4 },
O: { note: 'G#', octave: 4 },
p: { note: 'A', octave: 4 },
P: { note: 'A#', octave: 4 },
a: { note: 'B', octave: 4 },
/* oct 5 */ s: { note: 'C', octave: 5 },
S: { note: 'C#', octave: 5 },
d: { note: 'D', octave: 5 },
D: { note: 'D#', octave: 5 },
f: { note: 'E', octave: 5 },
g: { note: 'F', octave: 5 },
G: { note: 'F#', octave: 5 },
h: { note: 'G', octave: 5 },
H: { note: 'G#', octave: 5 },
j: { note: 'A', octave: 5 },
J: { note: 'A#', octave: 5 },
k: { note: 'B', octave: 5 },
/* oct 6 */ l: { note: 'C', octave: 6 },
L: { note: 'C#', octave: 6 },
z: { note: 'D', octave: 6 },
Z: { note: 'D#', octave: 6 },
x: { note: 'E', octave: 6 },
c: { note: 'F', octave: 6 },
C: { note: 'F#', octave: 6 },
v: { note: 'G', octave: 6 },
V: { note: 'G#', octave: 6 },
b: { note: 'A', octave: 6 },
B: { note: 'A#', octave: 6 },
n: { note: 'B', octave: 6 },
/* C7 */ m: { note: 'C', octave: 7 },
};

export const SHIFT_KEY_MAPPING: Record<string, string> = {
Digit1: '!',
Digit2: '@',
Digit4: '$',
Digit5: '%',
Digit6: '^',
Digit8: '*',
Digit9: '(',
KeyQ: 'Q',
KeyW: 'W',
KeyE: 'E',
KeyT: 'T',
KeyY: 'Y',
KeyI: 'I',
KeyO: 'O',
KeyP: 'P',
KeyS: 'S',
KeyD: 'D',
KeyG: 'G',
KeyH: 'H',
KeyJ: 'J',
KeyL: 'L',
KeyZ: 'Z',
KeyC: 'C',
KeyV: 'V',
KeyB: 'B',
};

export function pianoKeyFromKeyboardEvent(e: KeyboardEvent): string {
if (e.shiftKey) {
return SHIFT_KEY_MAPPING[e.code] ?? e.key;
}
return e.key;
}

/* reverse map: "C4" → keyboard letter */
export const NOTE_TO_KEY: Record<string, string> = {};
for (const [k, v] of Object.entries(KEYBOARD_MAPPING)) {
NOTE_TO_KEY[`${v.note}${v.octave}`] = k;
}

/* ── Piano key layout data ───────────────────────────────────────────── */
export interface KeyData {
note: string;
octave: number;
isBlack: boolean;
id: string;
kbKey: string;
leftPx?: number; /* absolute left offset (px) for black keys only */
}

export function buildKeyLayout(): KeyData[] {
const WHITE_NOTES = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];
const BLACK_AFTER: Record<string, string> = {
C: 'C#',
D: 'D#',
F: 'F#',
G: 'G#',
A: 'A#',
};
/* WW = white-key slot width: key (38 px) + flex gap (2 px) = 40 px */
const WW = 40;
/* BW = black key visual width */
const BW = 26;
const keys: KeyData[] = [];
let wIdx = 0; /* running white-key counter for leftPx */
for (const oct of [2, 3, 4, 5, 6]) {
for (const note of WHITE_NOTES) {
const id = `${note}${oct}`;
keys.push({
note,
octave: oct,
isBlack: false,
id,
kbKey: NOTE_TO_KEY[id] ?? '',
});
if (BLACK_AFTER[note]) {
const bNote = BLACK_AFTER[note]!;
const bid = `${bNote}${oct}`;
/* Centre black key over the boundary between this white key and the next:
right edge of wIdx key = (wIdx+1)*WW (gap is included),
minus half black-key width = centre over that boundary. */
const leftPx = (wIdx + 1) * WW - Math.round(BW / 2);
keys.push({
note: bNote,
octave: oct,
isBlack: true,
id: bid,
kbKey: NOTE_TO_KEY[bid] ?? '',
leftPx,
});
}
wIdx++;
}
}
keys.push({ note: 'C', octave: 7, isBlack: false, id: 'C7', kbKey: 'm' });
return keys;
}

export const KEY_LAYOUT = buildKeyLayout();
export const WHITE_KEYS = KEY_LAYOUT.filter((k) => !k.isBlack);
export const BLACK_KEYS = KEY_LAYOUT.filter((k) => k.isBlack);
Loading
Loading