@real-music-packages/web-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/dist/audio.d.ts +31 -0
- package/dist/audio.js +103 -0
- package/dist/audio.js.map +1 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +117 -0
- package/dist/index.js.map +1 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# @real-music-packages/web-core
|
|
2
|
+
|
|
3
|
+
Shared, framework-agnostic music-theory primitives for the music-suite web apps
|
|
4
|
+
(stave-web-sightread / RealSightReader and realeartrainer-web). Pure TypeScript —
|
|
5
|
+
no DOM, Tone.js, Svelte, or OSMD. Published publicly on npmjs.com.
|
|
6
|
+
|
|
7
|
+
## Modules
|
|
8
|
+
- **notes** — `NOTE_NAMES`, `NOTE_NAMES_FLAT`, `noteNameToIndex(name)`, `pitchClass(midi)`, `midiToNoteName(midi, useFlats?)`
|
|
9
|
+
- **frequency** — `midiToFrequency(midi)` (equal temperament, A4=440)
|
|
10
|
+
- **enharmonic** — `KEYS_PREFER_FLATS`, `useFlatsForKeyName(key)`, `useFlatsForKeyFifths(fifths)` (bridges RET's key-name model and Stave's keyFifths model)
|
|
11
|
+
- **scales** — `MAJOR_SCALE_INTERVALS`, `ALL_KEYS`, `getMidiNote(degree, key, octave?)`, `getScaleDegree(midi, key)`, `isInScale(midi, key)`, `getStability(degree)`
|
|
12
|
+
- **intervals** — `INTERVALS`, `intervalBySemitones(n)`
|
|
13
|
+
- **chords** — `CHORD_TEMPLATES` (quality → interval set, richer qualities first)
|
|
14
|
+
|
|
15
|
+
## ⚠️ Octave-base gotcha (`scales.getMidiNote` / `getScaleDegree`)
|
|
16
|
+
These are ported verbatim from RealEarTrainer and use **RET's non-standard octave
|
|
17
|
+
base**: `getMidiNote(1, 'C', 4) === 48`, i.e. one octave below the General-MIDI
|
|
18
|
+
convention (where C4 = 60). They are internally consistent and RET-only. The
|
|
19
|
+
general-purpose helpers (`midiToNoteName`, `midiToFrequency`, `pitchClass`) use the
|
|
20
|
+
**standard** GM convention (C4 = 60). Don't mix `getMidiNote`'s output with the
|
|
21
|
+
standard helpers without accounting for the one-octave offset.
|
|
22
|
+
|
|
23
|
+
## Install (consumers)
|
|
24
|
+
Published publicly on npmjs.com — no auth/token needed anywhere:
|
|
25
|
+
```
|
|
26
|
+
npm install @real-music-packages/web-core
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Dev
|
|
30
|
+
```
|
|
31
|
+
npm install
|
|
32
|
+
npm test # vitest
|
|
33
|
+
npm run build # tsup → dist/ (ESM + .d.ts)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Publishing
|
|
37
|
+
Public on npmjs.com under the `@e7mac` scope. Push a `v*` tag; the GitHub Actions
|
|
38
|
+
`Publish` workflow runs tests, builds, and `npm publish --access public` using the
|
|
39
|
+
`NPM_TOKEN` repo secret (an npm automation token with publish rights).
|
package/dist/audio.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
declare const SALAMANDER_CDN_BASE = "https://tonejs.github.io/audio/salamander/";
|
|
2
|
+
/** 8-anchor sample set (Stave uses these from the CDN). */
|
|
3
|
+
declare const SALAMANDER_URLS_8: Record<string, string>;
|
|
4
|
+
/** Full chromatic-anchor set A0..C8 (RET ships these locally under /audio/salamander/). */
|
|
5
|
+
declare const SALAMANDER_URLS_FULL: Record<string, string>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Emit a near-silent, very short tone on the given AudioContext. iOS Safari
|
|
9
|
+
* only unlocks audio if actual output is produced DURING the user gesture —
|
|
10
|
+
* resuming the context alone is not enough. Call this synchronously inside a
|
|
11
|
+
* click/pointerdown handler. (This is the trick RET's old unlock() omitted.)
|
|
12
|
+
*/
|
|
13
|
+
declare function emitUnlockBlip(ctx: AudioContext): void;
|
|
14
|
+
type ToneContextSetter = (ctx: AudioContext) => void;
|
|
15
|
+
/**
|
|
16
|
+
* Owns a raw AudioContext that is created + resumed SYNCHRONOUSLY inside the
|
|
17
|
+
* first user gesture, then emits an unlock blip on it. Hand the context to your
|
|
18
|
+
* audio library (e.g. Tone.setContext) via the onContext callback so the lib
|
|
19
|
+
* runs on an already-running context (so its own start()/resume() can't hang
|
|
20
|
+
* on iOS). Tone-agnostic — works with any version or no Tone at all.
|
|
21
|
+
*/
|
|
22
|
+
declare function createAudioUnlocker(): {
|
|
23
|
+
/** Call SYNCHRONOUSLY from a user gesture. Idempotent. */
|
|
24
|
+
unlock(onContext?: ToneContextSetter): void;
|
|
25
|
+
/** Resume the context if it has fallen back to suspended/interrupted. */
|
|
26
|
+
ensureRunning(): void;
|
|
27
|
+
readonly context: AudioContext | null;
|
|
28
|
+
};
|
|
29
|
+
type AudioUnlocker = ReturnType<typeof createAudioUnlocker>;
|
|
30
|
+
|
|
31
|
+
export { type AudioUnlocker, SALAMANDER_CDN_BASE, SALAMANDER_URLS_8, SALAMANDER_URLS_FULL, createAudioUnlocker, emitUnlockBlip };
|
package/dist/audio.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// src/salamander.ts
|
|
2
|
+
var SALAMANDER_CDN_BASE = "https://tonejs.github.io/audio/salamander/";
|
|
3
|
+
var SALAMANDER_URLS_8 = {
|
|
4
|
+
C2: "C2.mp3",
|
|
5
|
+
"F#2": "Fs2.mp3",
|
|
6
|
+
C3: "C3.mp3",
|
|
7
|
+
"F#3": "Fs3.mp3",
|
|
8
|
+
C4: "C4.mp3",
|
|
9
|
+
"F#4": "Fs4.mp3",
|
|
10
|
+
C5: "C5.mp3",
|
|
11
|
+
"F#5": "Fs5.mp3"
|
|
12
|
+
};
|
|
13
|
+
var SALAMANDER_URLS_FULL = {
|
|
14
|
+
A0: "A0.mp3",
|
|
15
|
+
C1: "C1.mp3",
|
|
16
|
+
"D#1": "Ds1.mp3",
|
|
17
|
+
"F#1": "Fs1.mp3",
|
|
18
|
+
A1: "A1.mp3",
|
|
19
|
+
C2: "C2.mp3",
|
|
20
|
+
"D#2": "Ds2.mp3",
|
|
21
|
+
"F#2": "Fs2.mp3",
|
|
22
|
+
A2: "A2.mp3",
|
|
23
|
+
C3: "C3.mp3",
|
|
24
|
+
"D#3": "Ds3.mp3",
|
|
25
|
+
"F#3": "Fs3.mp3",
|
|
26
|
+
A3: "A3.mp3",
|
|
27
|
+
C4: "C4.mp3",
|
|
28
|
+
"D#4": "Ds4.mp3",
|
|
29
|
+
"F#4": "Fs4.mp3",
|
|
30
|
+
A4: "A4.mp3",
|
|
31
|
+
C5: "C5.mp3",
|
|
32
|
+
"D#5": "Ds5.mp3",
|
|
33
|
+
"F#5": "Fs5.mp3",
|
|
34
|
+
A5: "A5.mp3",
|
|
35
|
+
C6: "C6.mp3",
|
|
36
|
+
"D#6": "Ds6.mp3",
|
|
37
|
+
"F#6": "Fs6.mp3",
|
|
38
|
+
A6: "A6.mp3",
|
|
39
|
+
C7: "C7.mp3",
|
|
40
|
+
"D#7": "Ds7.mp3",
|
|
41
|
+
"F#7": "Fs7.mp3",
|
|
42
|
+
A7: "A7.mp3",
|
|
43
|
+
C8: "C8.mp3"
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// src/audioUnlock.ts
|
|
47
|
+
function emitUnlockBlip(ctx) {
|
|
48
|
+
try {
|
|
49
|
+
const osc = ctx.createOscillator();
|
|
50
|
+
const gain = ctx.createGain();
|
|
51
|
+
gain.gain.value = 5e-4;
|
|
52
|
+
osc.type = "sine";
|
|
53
|
+
osc.frequency.value = 440;
|
|
54
|
+
osc.connect(gain);
|
|
55
|
+
gain.connect(ctx.destination);
|
|
56
|
+
const t = ctx.currentTime;
|
|
57
|
+
osc.start(t);
|
|
58
|
+
osc.stop(t + 0.05);
|
|
59
|
+
} catch {
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function createAudioUnlocker() {
|
|
63
|
+
let ctx = null;
|
|
64
|
+
let blipped = false;
|
|
65
|
+
function ensureContext() {
|
|
66
|
+
if (ctx) return ctx;
|
|
67
|
+
if (typeof window === "undefined") return null;
|
|
68
|
+
const AC = window.AudioContext || window.webkitAudioContext;
|
|
69
|
+
if (!AC) return null;
|
|
70
|
+
ctx = new AC();
|
|
71
|
+
return ctx;
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
/** Call SYNCHRONOUSLY from a user gesture. Idempotent. */
|
|
75
|
+
unlock(onContext) {
|
|
76
|
+
const c = ensureContext();
|
|
77
|
+
if (!c) return;
|
|
78
|
+
if (c.state !== "running") c.resume().catch(() => {
|
|
79
|
+
});
|
|
80
|
+
if (!blipped) {
|
|
81
|
+
emitUnlockBlip(c);
|
|
82
|
+
blipped = true;
|
|
83
|
+
}
|
|
84
|
+
onContext?.(c);
|
|
85
|
+
},
|
|
86
|
+
/** Resume the context if it has fallen back to suspended/interrupted. */
|
|
87
|
+
ensureRunning() {
|
|
88
|
+
if (ctx && ctx.state !== "running") ctx.resume().catch(() => {
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
get context() {
|
|
92
|
+
return ctx;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export {
|
|
97
|
+
SALAMANDER_CDN_BASE,
|
|
98
|
+
SALAMANDER_URLS_8,
|
|
99
|
+
SALAMANDER_URLS_FULL,
|
|
100
|
+
createAudioUnlocker,
|
|
101
|
+
emitUnlockBlip
|
|
102
|
+
};
|
|
103
|
+
//# sourceMappingURL=audio.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/salamander.ts","../src/audioUnlock.ts"],"sourcesContent":["export const SALAMANDER_CDN_BASE = 'https://tonejs.github.io/audio/salamander/';\n\n/** 8-anchor sample set (Stave uses these from the CDN). */\nexport const SALAMANDER_URLS_8: Record<string, string> = {\n C2: 'C2.mp3', 'F#2': 'Fs2.mp3', C3: 'C3.mp3', 'F#3': 'Fs3.mp3',\n C4: 'C4.mp3', 'F#4': 'Fs4.mp3', C5: 'C5.mp3', 'F#5': 'Fs5.mp3',\n};\n\n/** Full chromatic-anchor set A0..C8 (RET ships these locally under /audio/salamander/). */\nexport const SALAMANDER_URLS_FULL: Record<string, string> = {\n A0: 'A0.mp3', C1: 'C1.mp3', 'D#1': 'Ds1.mp3', 'F#1': 'Fs1.mp3', A1: 'A1.mp3',\n C2: 'C2.mp3', 'D#2': 'Ds2.mp3', 'F#2': 'Fs2.mp3', A2: 'A2.mp3',\n C3: 'C3.mp3', 'D#3': 'Ds3.mp3', 'F#3': 'Fs3.mp3', A3: 'A3.mp3',\n C4: 'C4.mp3', 'D#4': 'Ds4.mp3', 'F#4': 'Fs4.mp3', A4: 'A4.mp3',\n C5: 'C5.mp3', 'D#5': 'Ds5.mp3', 'F#5': 'Fs5.mp3', A5: 'A5.mp3',\n C6: 'C6.mp3', 'D#6': 'Ds6.mp3', 'F#6': 'Fs6.mp3', A6: 'A6.mp3',\n C7: 'C7.mp3', 'D#7': 'Ds7.mp3', 'F#7': 'Fs7.mp3', A7: 'A7.mp3',\n C8: 'C8.mp3',\n};\n","/**\n * Emit a near-silent, very short tone on the given AudioContext. iOS Safari\n * only unlocks audio if actual output is produced DURING the user gesture —\n * resuming the context alone is not enough. Call this synchronously inside a\n * click/pointerdown handler. (This is the trick RET's old unlock() omitted.)\n */\nexport function emitUnlockBlip(ctx: AudioContext): void {\n try {\n const osc = ctx.createOscillator();\n const gain = ctx.createGain();\n gain.gain.value = 0.0005; // effectively inaudible\n osc.type = 'sine';\n osc.frequency.value = 440;\n osc.connect(gain);\n gain.connect(ctx.destination);\n const t = ctx.currentTime;\n osc.start(t);\n osc.stop(t + 0.05);\n } catch {\n /* best-effort: never throw out of an unlock path */\n }\n}\n\ntype ToneContextSetter = (ctx: AudioContext) => void;\n\n/**\n * Owns a raw AudioContext that is created + resumed SYNCHRONOUSLY inside the\n * first user gesture, then emits an unlock blip on it. Hand the context to your\n * audio library (e.g. Tone.setContext) via the onContext callback so the lib\n * runs on an already-running context (so its own start()/resume() can't hang\n * on iOS). Tone-agnostic — works with any version or no Tone at all.\n */\nexport function createAudioUnlocker() {\n let ctx: AudioContext | null = null;\n let blipped = false;\n\n function ensureContext(): AudioContext | null {\n if (ctx) return ctx;\n if (typeof window === 'undefined') return null;\n const AC = (window.AudioContext ||\n (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext) as\n | typeof AudioContext\n | undefined;\n if (!AC) return null;\n ctx = new AC();\n return ctx;\n }\n\n return {\n /** Call SYNCHRONOUSLY from a user gesture. Idempotent. */\n unlock(onContext?: ToneContextSetter): void {\n const c = ensureContext();\n if (!c) return;\n if (c.state !== 'running') c.resume().catch(() => {});\n if (!blipped) { emitUnlockBlip(c); blipped = true; }\n onContext?.(c);\n },\n /** Resume the context if it has fallen back to suspended/interrupted. */\n ensureRunning(): void {\n if (ctx && ctx.state !== 'running') ctx.resume().catch(() => {});\n },\n get context(): AudioContext | null { return ctx; },\n };\n}\n\nexport type AudioUnlocker = ReturnType<typeof createAudioUnlocker>;\n"],"mappings":";AAAO,IAAM,sBAAsB;AAG5B,IAAM,oBAA4C;AAAA,EACvD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,IAAI;AAAA,EAAU,OAAO;AAAA,EACrD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,IAAI;AAAA,EAAU,OAAO;AACvD;AAGO,IAAM,uBAA+C;AAAA,EAC1D,IAAI;AAAA,EAAU,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACpE,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AAAA,EAAU,OAAO;AAAA,EAAW,OAAO;AAAA,EAAW,IAAI;AAAA,EACtD,IAAI;AACN;;;ACZO,SAAS,eAAe,KAAyB;AACtD,MAAI;AACF,UAAM,MAAM,IAAI,iBAAiB;AACjC,UAAM,OAAO,IAAI,WAAW;AAC5B,SAAK,KAAK,QAAQ;AAClB,QAAI,OAAO;AACX,QAAI,UAAU,QAAQ;AACtB,QAAI,QAAQ,IAAI;AAChB,SAAK,QAAQ,IAAI,WAAW;AAC5B,UAAM,IAAI,IAAI;AACd,QAAI,MAAM,CAAC;AACX,QAAI,KAAK,IAAI,IAAI;AAAA,EACnB,QAAQ;AAAA,EAER;AACF;AAWO,SAAS,sBAAsB;AACpC,MAAI,MAA2B;AAC/B,MAAI,UAAU;AAEd,WAAS,gBAAqC;AAC5C,QAAI,IAAK,QAAO;AAChB,QAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,UAAM,KAAM,OAAO,gBAChB,OAAmE;AAGtE,QAAI,CAAC,GAAI,QAAO;AAChB,UAAM,IAAI,GAAG;AACb,WAAO;AAAA,EACT;AAEA,SAAO;AAAA;AAAA,IAEL,OAAO,WAAqC;AAC1C,YAAM,IAAI,cAAc;AACxB,UAAI,CAAC,EAAG;AACR,UAAI,EAAE,UAAU,UAAW,GAAE,OAAO,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AACpD,UAAI,CAAC,SAAS;AAAE,uBAAe,CAAC;AAAG,kBAAU;AAAA,MAAM;AACnD,kBAAY,CAAC;AAAA,IACf;AAAA;AAAA,IAEA,gBAAsB;AACpB,UAAI,OAAO,IAAI,UAAU,UAAW,KAAI,OAAO,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AAAA,IACjE;AAAA,IACA,IAAI,UAA+B;AAAE,aAAO;AAAA,IAAK;AAAA,EACnD;AACF;","names":[]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
declare const NOTE_NAMES: readonly ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
2
|
+
declare const NOTE_NAMES_FLAT: readonly ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"];
|
|
3
|
+
/** Note name (e.g. "C", "C#", "Db", "Cb") → pitch class 0..11. */
|
|
4
|
+
declare function noteNameToIndex(note: string): number;
|
|
5
|
+
/** MIDI number → pitch class 0..11. */
|
|
6
|
+
declare function pitchClass(midi: number): number;
|
|
7
|
+
/** MIDI → note name with octave, e.g. 61 → "C#4" (or "Db4" with useFlats). */
|
|
8
|
+
declare function midiToNoteName(midi: number, useFlats?: boolean): string;
|
|
9
|
+
|
|
10
|
+
/** Equal-temperament MIDI → frequency (A4 = MIDI 69 = 440 Hz). */
|
|
11
|
+
declare function midiToFrequency(midi: number): number;
|
|
12
|
+
|
|
13
|
+
/** Keys conventionally spelled with flats (RET's key-name model). */
|
|
14
|
+
declare const KEYS_PREFER_FLATS: readonly ["F", "Bb", "Eb", "Ab", "Db", "Gb"];
|
|
15
|
+
/** Whether to use flat spellings for a key given by name (e.g. "Eb"). */
|
|
16
|
+
declare function useFlatsForKeyName(key: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Whether to use flat spellings for a key given by its key-signature "fifths"
|
|
19
|
+
* value (Stave's model: -7..+7, negative = flat keys). 0 (C) and positive
|
|
20
|
+
* (sharp keys) use sharps.
|
|
21
|
+
*/
|
|
22
|
+
declare function useFlatsForKeyFifths(fifths: number): boolean;
|
|
23
|
+
|
|
24
|
+
declare const MAJOR_SCALE_INTERVALS: readonly [0, 2, 4, 5, 7, 9, 11];
|
|
25
|
+
declare const ALL_KEYS: readonly ["C", "G", "D", "A", "E", "B", "F#", "F", "Bb", "Eb", "Ab", "Db"];
|
|
26
|
+
/**
|
|
27
|
+
* Get MIDI note number for a scale degree in a given key.
|
|
28
|
+
* Ported verbatim from RET src/lib/audio/scales.ts.
|
|
29
|
+
* NOTE: RET uses octave*12 (not (octave+1)*12), so getMidiNote(1,'C',4)=48,
|
|
30
|
+
* not 60. This is RET's established convention — consumers must account for it.
|
|
31
|
+
*/
|
|
32
|
+
declare function getMidiNote(degree: number, key: string, octave?: number): number;
|
|
33
|
+
/**
|
|
34
|
+
* Get the scale degree (1-7) for a MIDI note in a key, or null if not in scale.
|
|
35
|
+
* Ported verbatim from RET src/lib/audio/scales.ts.
|
|
36
|
+
*/
|
|
37
|
+
declare function getScaleDegree(midiNote: number, key: string): number | null;
|
|
38
|
+
/**
|
|
39
|
+
* Check if a MIDI note is in the given key.
|
|
40
|
+
*/
|
|
41
|
+
declare function isInScale(midiNote: number, key: string): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Get stability category for a scale degree.
|
|
44
|
+
* Ported verbatim from RET src/lib/audio/scales.ts.
|
|
45
|
+
*/
|
|
46
|
+
declare function getStability(degree: number): 'stable' | 'lessStable' | 'unstable' | null;
|
|
47
|
+
|
|
48
|
+
interface IntervalDef {
|
|
49
|
+
semitones: number;
|
|
50
|
+
label: string;
|
|
51
|
+
mnemonic: string;
|
|
52
|
+
}
|
|
53
|
+
declare const INTERVALS: IntervalDef[];
|
|
54
|
+
declare function intervalBySemitones(semitones: number): IntervalDef | undefined;
|
|
55
|
+
|
|
56
|
+
interface ChordTemplate {
|
|
57
|
+
name: string;
|
|
58
|
+
intervals: number[];
|
|
59
|
+
}
|
|
60
|
+
/** Chord-quality templates, richer qualities first so naming matches the
|
|
61
|
+
* most specific quality (e.g. maj7 before plain major). */
|
|
62
|
+
declare const CHORD_TEMPLATES: ChordTemplate[];
|
|
63
|
+
|
|
64
|
+
export { ALL_KEYS, CHORD_TEMPLATES, type ChordTemplate, INTERVALS, type IntervalDef, KEYS_PREFER_FLATS, MAJOR_SCALE_INTERVALS, NOTE_NAMES, NOTE_NAMES_FLAT, getMidiNote, getScaleDegree, getStability, intervalBySemitones, isInScale, midiToFrequency, midiToNoteName, noteNameToIndex, pitchClass, useFlatsForKeyFifths, useFlatsForKeyName };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// src/notes.ts
|
|
2
|
+
var NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
3
|
+
var NOTE_NAMES_FLAT = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"];
|
|
4
|
+
var LETTER_TO_INDEX = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
|
|
5
|
+
function noteNameToIndex(note) {
|
|
6
|
+
const letter = note[0]?.toUpperCase();
|
|
7
|
+
let index = LETTER_TO_INDEX[letter];
|
|
8
|
+
if (index === void 0) throw new Error(`Invalid note name: ${note}`);
|
|
9
|
+
for (const ch of note.slice(1)) {
|
|
10
|
+
if (ch === "#") index += 1;
|
|
11
|
+
else if (ch === "b") index -= 1;
|
|
12
|
+
}
|
|
13
|
+
return (index % 12 + 12) % 12;
|
|
14
|
+
}
|
|
15
|
+
function pitchClass(midi) {
|
|
16
|
+
return (midi % 12 + 12) % 12;
|
|
17
|
+
}
|
|
18
|
+
function midiToNoteName(midi, useFlats = false) {
|
|
19
|
+
const names = useFlats ? NOTE_NAMES_FLAT : NOTE_NAMES;
|
|
20
|
+
const octave = Math.floor(midi / 12) - 1;
|
|
21
|
+
return `${names[pitchClass(midi)]}${octave}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/frequency.ts
|
|
25
|
+
function midiToFrequency(midi) {
|
|
26
|
+
return 440 * Math.pow(2, (midi - 69) / 12);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/enharmonic.ts
|
|
30
|
+
var KEYS_PREFER_FLATS = ["F", "Bb", "Eb", "Ab", "Db", "Gb"];
|
|
31
|
+
function useFlatsForKeyName(key) {
|
|
32
|
+
return KEYS_PREFER_FLATS.includes(key);
|
|
33
|
+
}
|
|
34
|
+
function useFlatsForKeyFifths(fifths) {
|
|
35
|
+
return fifths < 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/scales.ts
|
|
39
|
+
var MAJOR_SCALE_INTERVALS = [0, 2, 4, 5, 7, 9, 11];
|
|
40
|
+
var ALL_KEYS = ["C", "G", "D", "A", "E", "B", "F#", "F", "Bb", "Eb", "Ab", "Db"];
|
|
41
|
+
function getMidiNote(degree, key, octave = 4) {
|
|
42
|
+
const rootIndex = noteNameToIndex(key);
|
|
43
|
+
const actualDegree = ((degree - 1) % 7 + 7) % 7;
|
|
44
|
+
const octaveShift = Math.floor((degree - 1) / 7);
|
|
45
|
+
const semitones = MAJOR_SCALE_INTERVALS[actualDegree];
|
|
46
|
+
return rootIndex + semitones + (octave + octaveShift) * 12;
|
|
47
|
+
}
|
|
48
|
+
function getScaleDegree(midiNote, key) {
|
|
49
|
+
const rootIndex = noteNameToIndex(key);
|
|
50
|
+
const noteInOctave = (midiNote % 12 - rootIndex + 12) % 12;
|
|
51
|
+
const degreeIndex = MAJOR_SCALE_INTERVALS.indexOf(noteInOctave);
|
|
52
|
+
return degreeIndex !== -1 ? degreeIndex + 1 : null;
|
|
53
|
+
}
|
|
54
|
+
function isInScale(midiNote, key) {
|
|
55
|
+
return getScaleDegree(midiNote, key) !== null;
|
|
56
|
+
}
|
|
57
|
+
function getStability(degree) {
|
|
58
|
+
if ([1, 3, 5].includes(degree)) return "stable";
|
|
59
|
+
if ([2, 4, 6].includes(degree)) return "lessStable";
|
|
60
|
+
if (degree === 7) return "unstable";
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/intervals.ts
|
|
65
|
+
var INTERVALS = [
|
|
66
|
+
{ semitones: 1, label: "m2", mnemonic: "Jaws theme" },
|
|
67
|
+
{ semitones: 2, label: "M2", mnemonic: "Happy Birthday opening" },
|
|
68
|
+
{ semitones: 3, label: "m3", mnemonic: "Brahms' Lullaby" },
|
|
69
|
+
{ semitones: 4, label: "M3", mnemonic: "When the Saints" },
|
|
70
|
+
{ semitones: 5, label: "P4", mnemonic: "Here Comes the Bride" },
|
|
71
|
+
{ semitones: 6, label: "TT", mnemonic: "The Simpsons theme" },
|
|
72
|
+
{ semitones: 7, label: "P5", mnemonic: "Twinkle Twinkle" },
|
|
73
|
+
{ semitones: 8, label: "m6", mnemonic: "Love Story theme" },
|
|
74
|
+
{ semitones: 9, label: "M6", mnemonic: "NBC chimes" },
|
|
75
|
+
{ semitones: 10, label: "m7", mnemonic: "Somewhere (West Side Story)" },
|
|
76
|
+
{ semitones: 11, label: "M7", mnemonic: "Take On Me chorus" },
|
|
77
|
+
{ semitones: 12, label: "P8", mnemonic: "Somewhere Over the Rainbow" }
|
|
78
|
+
];
|
|
79
|
+
function intervalBySemitones(semitones) {
|
|
80
|
+
return INTERVALS.find((i) => i.semitones === semitones);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/chords.ts
|
|
84
|
+
var CHORD_TEMPLATES = [
|
|
85
|
+
{ name: "maj7", intervals: [0, 4, 7, 11] },
|
|
86
|
+
{ name: "7", intervals: [0, 4, 7, 10] },
|
|
87
|
+
{ name: "m7", intervals: [0, 3, 7, 10] },
|
|
88
|
+
{ name: "dim7", intervals: [0, 3, 6, 9] },
|
|
89
|
+
{ name: "m7b5", intervals: [0, 3, 6, 10] },
|
|
90
|
+
{ name: "", intervals: [0, 4, 7] },
|
|
91
|
+
{ name: "m", intervals: [0, 3, 7] },
|
|
92
|
+
{ name: "dim", intervals: [0, 3, 6] },
|
|
93
|
+
{ name: "aug", intervals: [0, 4, 8] },
|
|
94
|
+
{ name: "sus4", intervals: [0, 5, 7] },
|
|
95
|
+
{ name: "sus2", intervals: [0, 2, 7] }
|
|
96
|
+
];
|
|
97
|
+
export {
|
|
98
|
+
ALL_KEYS,
|
|
99
|
+
CHORD_TEMPLATES,
|
|
100
|
+
INTERVALS,
|
|
101
|
+
KEYS_PREFER_FLATS,
|
|
102
|
+
MAJOR_SCALE_INTERVALS,
|
|
103
|
+
NOTE_NAMES,
|
|
104
|
+
NOTE_NAMES_FLAT,
|
|
105
|
+
getMidiNote,
|
|
106
|
+
getScaleDegree,
|
|
107
|
+
getStability,
|
|
108
|
+
intervalBySemitones,
|
|
109
|
+
isInScale,
|
|
110
|
+
midiToFrequency,
|
|
111
|
+
midiToNoteName,
|
|
112
|
+
noteNameToIndex,
|
|
113
|
+
pitchClass,
|
|
114
|
+
useFlatsForKeyFifths,
|
|
115
|
+
useFlatsForKeyName
|
|
116
|
+
};
|
|
117
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/notes.ts","../src/frequency.ts","../src/enharmonic.ts","../src/scales.ts","../src/intervals.ts","../src/chords.ts"],"sourcesContent":["export const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] as const;\nexport const NOTE_NAMES_FLAT = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'] as const;\n\nconst LETTER_TO_INDEX: Record<string, number> = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };\n\n/** Note name (e.g. \"C\", \"C#\", \"Db\", \"Cb\") → pitch class 0..11. */\nexport function noteNameToIndex(note: string): number {\n const letter = note[0]?.toUpperCase();\n let index = LETTER_TO_INDEX[letter];\n if (index === undefined) throw new Error(`Invalid note name: ${note}`);\n for (const ch of note.slice(1)) {\n if (ch === '#') index += 1;\n else if (ch === 'b') index -= 1;\n }\n return ((index % 12) + 12) % 12;\n}\n\n/** MIDI number → pitch class 0..11. */\nexport function pitchClass(midi: number): number {\n return ((midi % 12) + 12) % 12;\n}\n\n/** MIDI → note name with octave, e.g. 61 → \"C#4\" (or \"Db4\" with useFlats). */\nexport function midiToNoteName(midi: number, useFlats = false): string {\n const names = useFlats ? NOTE_NAMES_FLAT : NOTE_NAMES;\n const octave = Math.floor(midi / 12) - 1;\n return `${names[pitchClass(midi)]}${octave}`;\n}\n","/** Equal-temperament MIDI → frequency (A4 = MIDI 69 = 440 Hz). */\nexport function midiToFrequency(midi: number): number {\n return 440 * Math.pow(2, (midi - 69) / 12);\n}\n","/** Keys conventionally spelled with flats (RET's key-name model). */\nexport const KEYS_PREFER_FLATS = ['F', 'Bb', 'Eb', 'Ab', 'Db', 'Gb'] as const;\n\n/** Whether to use flat spellings for a key given by name (e.g. \"Eb\"). */\nexport function useFlatsForKeyName(key: string): boolean {\n return (KEYS_PREFER_FLATS as readonly string[]).includes(key);\n}\n\n/**\n * Whether to use flat spellings for a key given by its key-signature \"fifths\"\n * value (Stave's model: -7..+7, negative = flat keys). 0 (C) and positive\n * (sharp keys) use sharps.\n */\nexport function useFlatsForKeyFifths(fifths: number): boolean {\n return fifths < 0;\n}\n","import { noteNameToIndex } from './notes';\n\nexport const MAJOR_SCALE_INTERVALS = [0, 2, 4, 5, 7, 9, 11] as const;\nexport const ALL_KEYS = ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'F', 'Bb', 'Eb', 'Ab', 'Db'] as const;\n\n/**\n * Get MIDI note number for a scale degree in a given key.\n * Ported verbatim from RET src/lib/audio/scales.ts.\n * NOTE: RET uses octave*12 (not (octave+1)*12), so getMidiNote(1,'C',4)=48,\n * not 60. This is RET's established convention — consumers must account for it.\n */\nexport function getMidiNote(degree: number, key: string, octave = 4): number {\n const rootIndex = noteNameToIndex(key);\n const actualDegree = ((degree - 1) % 7 + 7) % 7;\n const octaveShift = Math.floor((degree - 1) / 7);\n const semitones = MAJOR_SCALE_INTERVALS[actualDegree];\n return rootIndex + semitones + ((octave + octaveShift) * 12);\n}\n\n/**\n * Get the scale degree (1-7) for a MIDI note in a key, or null if not in scale.\n * Ported verbatim from RET src/lib/audio/scales.ts.\n */\nexport function getScaleDegree(midiNote: number, key: string): number | null {\n const rootIndex = noteNameToIndex(key);\n const noteInOctave = ((midiNote % 12) - rootIndex + 12) % 12;\n const degreeIndex = (MAJOR_SCALE_INTERVALS as readonly number[]).indexOf(noteInOctave);\n return degreeIndex !== -1 ? degreeIndex + 1 : null;\n}\n\n/**\n * Check if a MIDI note is in the given key.\n */\nexport function isInScale(midiNote: number, key: string): boolean {\n return getScaleDegree(midiNote, key) !== null;\n}\n\n/**\n * Get stability category for a scale degree.\n * Ported verbatim from RET src/lib/audio/scales.ts.\n */\nexport function getStability(degree: number): 'stable' | 'lessStable' | 'unstable' | null {\n if ([1, 3, 5].includes(degree)) return 'stable';\n if ([2, 4, 6].includes(degree)) return 'lessStable';\n if (degree === 7) return 'unstable';\n return null;\n}\n","export interface IntervalDef { semitones: number; label: string; mnemonic: string }\n\nexport const INTERVALS: IntervalDef[] = [\n { semitones: 1, label: 'm2', mnemonic: 'Jaws theme' },\n { semitones: 2, label: 'M2', mnemonic: 'Happy Birthday opening' },\n { semitones: 3, label: 'm3', mnemonic: \"Brahms' Lullaby\" },\n { semitones: 4, label: 'M3', mnemonic: 'When the Saints' },\n { semitones: 5, label: 'P4', mnemonic: 'Here Comes the Bride' },\n { semitones: 6, label: 'TT', mnemonic: 'The Simpsons theme' },\n { semitones: 7, label: 'P5', mnemonic: 'Twinkle Twinkle' },\n { semitones: 8, label: 'm6', mnemonic: 'Love Story theme' },\n { semitones: 9, label: 'M6', mnemonic: 'NBC chimes' },\n { semitones: 10, label: 'm7', mnemonic: 'Somewhere (West Side Story)' },\n { semitones: 11, label: 'M7', mnemonic: 'Take On Me chorus' },\n { semitones: 12, label: 'P8', mnemonic: 'Somewhere Over the Rainbow' },\n];\n\nexport function intervalBySemitones(semitones: number): IntervalDef | undefined {\n return INTERVALS.find(i => i.semitones === semitones);\n}\n","export interface ChordTemplate { name: string; intervals: number[] }\n\n/** Chord-quality templates, richer qualities first so naming matches the\n * most specific quality (e.g. maj7 before plain major). */\nexport const CHORD_TEMPLATES: ChordTemplate[] = [\n { name: 'maj7', intervals: [0, 4, 7, 11] },\n { name: '7', intervals: [0, 4, 7, 10] },\n { name: 'm7', intervals: [0, 3, 7, 10] },\n { name: 'dim7', intervals: [0, 3, 6, 9] },\n { name: 'm7b5', intervals: [0, 3, 6, 10] },\n { name: '', intervals: [0, 4, 7] },\n { name: 'm', intervals: [0, 3, 7] },\n { name: 'dim', intervals: [0, 3, 6] },\n { name: 'aug', intervals: [0, 4, 8] },\n { name: 'sus4', intervals: [0, 5, 7] },\n { name: 'sus2', intervals: [0, 2, 7] },\n];\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@real-music-packages/web-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared music-theory + audio primitives for the music-suite web apps",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
|
|
11
|
+
"./audio": { "types": "./dist/audio.d.ts", "import": "./dist/audio.js" }
|
|
12
|
+
},
|
|
13
|
+
"files": ["dist"],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"test:watch": "vitest",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"prepublishOnly": "npm run build"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"repository": { "type": "git", "url": "git+https://github.com/e7mac/web-core.git" },
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"tsup": "^8.3.0",
|
|
27
|
+
"typescript": "^5.6.0",
|
|
28
|
+
"vitest": "^2.1.0"
|
|
29
|
+
}
|
|
30
|
+
}
|