d-piano
Version:
Web Audio instrument using Salamander Grand Piano samples
617 lines (616 loc) • 18.3 kB
JavaScript
import { ToneAudioNode as p, Volume as E, Frequency as F, ToneAudioBuffers as q, getContext as T, ToneAudioBuffer as k, Midi as m, Sampler as U, ToneBufferSource as A, optionsFromArguments as w, Gain as R, isString as B } from "tone";
class v extends p {
constructor(t) {
super(t), this.name = "PianoComponent", this.input = void 0, this.output = new E({ context: this.context }), this._enabled = !1, this.volume = this.output.volume, this._loaded = !1, this.volume.value = t.volume, this._enabled = t.enabled, this.samples = t.samples;
}
/**
* If the samples are loaded or not
*/
get loaded() {
return this._loaded;
}
/**
* Load the samples
*/
async load() {
if (this._enabled)
await this._internalLoad(), this._loaded = !0;
else
return Promise.resolve();
}
}
function I(a) {
return F(a, "midi").toNote();
}
function y(a, t) {
return Math.random() * (t - a) + a;
}
function H(a) {
return `rel${a - 20}.ogg`;
}
function V(a) {
return `harmS${I(a).replace("#", "s")}.ogg`;
}
function G(a, t) {
return `${I(a).replace("#", "s")}v${t}.ogg`;
}
const N = {
1: [8],
2: [6, 12],
3: [1, 7, 15],
4: [1, 5, 10, 15],
5: [1, 4, 8, 12, 16],
6: [1, 3, 7, 10, 13, 16],
7: [1, 3, 6, 9, 11, 13, 16],
8: [1, 3, 5, 7, 9, 11, 13, 16],
9: [1, 3, 5, 7, 9, 11, 13, 15, 16],
10: [1, 2, 3, 5, 7, 9, 11, 13, 15, 16],
11: [1, 2, 3, 5, 7, 9, 11, 13, 14, 15, 16],
12: [1, 2, 3, 4, 5, 7, 9, 11, 13, 14, 15, 16],
13: [1, 2, 3, 4, 5, 7, 9, 11, 12, 13, 14, 15, 16],
14: [1, 2, 3, 4, 5, 6, 7, 9, 11, 12, 13, 14, 15, 16],
15: [1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 16],
16: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
}, C = [
21,
24,
27,
30,
33,
36,
39,
42,
45,
48,
51,
54,
57,
60,
63,
66,
69,
72,
75,
78,
81,
84,
87,
90,
93,
96,
99,
102,
105,
108
];
function O(a, t) {
return C.filter((e) => a <= e && e <= t);
}
const _ = [21, 24, 27, 30, 33, 36, 39, 42, 45, 48, 51, 54, 57, 60, 63, 66, 69, 72, 75, 78, 81, 84, 87];
function W(a, t) {
return _.filter((e) => a <= e && e <= t);
}
function z(a) {
return _[0] <= a && a <= _[_.length - 1];
}
const $ = "d-piano-samples", K = "d-piano-decoded", g = "buffers", J = 1, i = class i {
static async getBufferMap(t, e) {
const s = {};
return await Promise.allSettled(Object.entries(e).map(async ([n, r]) => {
s[n] = await i.getBuffer(t + r);
})), s;
}
static async getBuffers(t, e) {
const s = new q();
return await Promise.allSettled(Object.entries(e).map(async ([n, r]) => {
s.add(n, await i.getBuffer(t + r));
})), s;
}
static async getBuffer(t) {
if (i.buffers[t])
return i.buffers[t];
if (i.loading[t])
return i.loading[t];
const e = i._loadBuffer(t);
i.loading[t] = e;
try {
const s = await e;
return i.buffers[t] = s, s;
} finally {
delete i.loading[t];
}
}
static _openDb() {
return i._db !== null || (i._db = new Promise((t) => {
if (typeof indexedDB > "u") {
t(null);
return;
}
const e = indexedDB.open(K, J);
e.onupgradeneeded = () => e.result.createObjectStore(g), e.onsuccess = () => t(e.result), e.onerror = () => t(null);
})), i._db;
}
static async _getFromIdb(t) {
try {
const e = await i._openDb();
return e ? new Promise((s) => {
const n = e.transaction(g, "readonly").objectStore(g).get(t);
n.onsuccess = () => {
const r = n.result;
if (!r) {
s(null);
return;
}
try {
const c = T().rawContext.createBuffer(r.numberOfChannels, r.length, r.sampleRate);
for (let o = 0; o < r.numberOfChannels; o++)
c.copyToChannel(r.channels[o], o);
s(new k(c));
} catch {
s(null);
}
}, n.onerror = () => s(null);
}) : null;
} catch {
return null;
}
}
static async _saveToIdb(t, e) {
try {
const s = await i._openDb();
if (!s)
return;
const n = e.get(), r = [];
for (let o = 0; o < n.numberOfChannels; o++)
r.push(n.getChannelData(o).slice());
const c = {
sampleRate: n.sampleRate,
numberOfChannels: n.numberOfChannels,
length: n.length,
channels: r
};
return new Promise((o) => {
const l = s.transaction(g, "readwrite");
l.objectStore(g).put(c, t), l.oncomplete = () => o(), l.onerror = () => o();
});
} catch {
}
}
static async _loadBuffer(t) {
const e = await i._getFromIdb(t);
if (e)
return e;
const s = await caches.match(t);
if (s) {
const n = await i._decodeResponse(s);
return i._saveToIdb(t, n), n;
}
return i._fetchAndCache(t);
}
static async _fetchAndCache(t) {
const e = await fetch(t);
if (!e.ok)
throw new Error(`Failed to load ${t}: HTTP ${e.status}`);
try {
await (await caches.open($)).put(t, e.clone());
} catch {
}
const s = await i._decodeResponse(e);
return i._saveToIdb(t, s), s;
}
static async _decodeResponse(t) {
const e = await t.arrayBuffer(), s = await T().rawContext.decodeAudioData(e);
return new k(s);
}
};
i.buffers = {}, i.loading = {}, i._db = null;
let f = i;
class Q extends v {
constructor(t) {
super(t), this._urls = {};
const e = W(t.minNote, t.maxNote);
for (const s of e)
this._urls[s] = V(s);
}
triggerAttack(t, e, s) {
this._enabled && z(t) && this._sampler.triggerAttack(m(t).toNote(), e, s * y(0.5, 1));
}
async _internalLoad() {
const t = await f.getBufferMap(this.samples, this._urls);
return new Promise((e) => {
this._sampler = new U({
baseUrl: this.samples,
onload: e,
urls: t
}).connect(this.output);
});
}
}
class X extends v {
constructor(t) {
super(t), this._urls = {};
for (let e = t.minNote; e <= t.maxNote; e++)
this._urls[e] = H(e);
}
async _internalLoad() {
this._buffers = await f.getBuffers(this.samples, this._urls);
}
start(t, e, s) {
this._enabled && this._buffers.has(t) && new A({
context: this.context,
url: this._buffers.get(t)
}).connect(this.output).start(e, 0, void 0, 0.015 * s * y(0.5, 1));
}
}
class Y extends v {
constructor(t) {
super(t), this._downTime = 1 / 0, this._currentSound = null, this._downTime = 1 / 0;
}
async _internalLoad() {
this._buffers = await f.getBuffers(this.samples, {
down1: "pedalD1.ogg",
down2: "pedalD2.ogg",
up1: "pedalU1.ogg",
up2: "pedalU2.ogg"
});
}
/**
* Squash the current playing sound
*/
_squash(t) {
this._currentSound && this._currentSound.state !== "stopped" && this._currentSound.stop(t), this._currentSound = null;
}
_playSample(t, e) {
this._enabled && (this._currentSound = new A({
context: this.context,
curve: "exponential",
fadeIn: 0.05,
fadeOut: 0.1,
url: this._buffers.get(`${e}${Math.random() > 0.5 ? 1 : 2}`)
}).connect(this.output), this._currentSound.start(t, y(0, 0.01), void 0, 0.1 * y(0.5, 1)));
}
/**
* Put the pedal down
*/
down(t) {
this._squash(t), this._downTime = t, this._playSample(t, "down");
}
/**
* Put the pedal up
*/
up(t) {
this._squash(t), this._downTime = 1 / 0, this._playSample(t, "up");
}
/**
* Indicates if the pedal is down at the given time
*/
isDown(t) {
return t >= this._downTime;
}
}
class M extends p {
constructor(t) {
super(t), this.name = "PianoString", this._urls = {}, this.velocity = t.velocity, t.notes.forEach((e) => this._urls[e] = G(e, t.velocity)), this.samples = t.samples;
}
async load() {
const t = await f.getBufferMap(this.samples, this._urls);
return new Promise((e) => {
this._sampler = this.output = new U({
attack: 0,
baseUrl: this.samples,
curve: "exponential",
onload: e,
release: 0.4,
urls: t,
volume: 3
});
});
}
triggerAttack(t, e, s) {
this._sampler.triggerAttack(t, e, s);
}
triggerRelease(t, e) {
this._sampler.triggerRelease(t, e);
}
}
class Z extends v {
constructor(t) {
super(t);
const e = O(t.minNote, t.maxNote), s = N[t.velocities].slice();
this._notes = e, this._stringBaseConfig = { ...t }, this._strings = s.map((n) => new M({ ...t, notes: e, velocity: n })), this._activeNotes = /* @__PURE__ */ new Map();
}
/**
* Scale a value between a given range
*/
scale(t, e, s, n, r) {
return (t - e) / (s - e) * (r - n) + n;
}
triggerAttack(t, e, s) {
const n = this.scale(s, 0, 1, -0.5, this._strings.length - 0.51), r = Math.max(Math.round(n), 0);
let c = 1 + n - r;
this._strings.length === 1 && (c = s);
const o = this._strings[r];
this._activeNotes.has(t) && this.triggerRelease(t, e), this._activeNotes.set(t, o), o.triggerAttack(m(t).toNote(), e, c);
}
triggerRelease(t, e) {
this._activeNotes.has(t) && (this._activeNotes.get(t).triggerRelease(m(t).toNote(), e), this._activeNotes.delete(t));
}
/**
* Load additional velocity strings up to the target velocity count.
* New strings are loaded and connected, then atomically merged into _strings.
* Safe to call while notes are playing: _activeNotes holds refs to old strings.
*/
async expandTo(t) {
if (!this._enabled)
return;
const e = N[t];
if (!e?.length)
return;
const s = new Set(this._strings.map((o) => o.velocity)), n = e.filter((o) => !s.has(o));
if (n.length === 0)
return;
const r = await Promise.all(n.map(async (o) => {
const l = new M({ ...this._stringBaseConfig, notes: this._notes, velocity: o });
return await l.load(), l.connect(this.output), l;
})), c = [...this._strings, ...r];
c.sort((o, l) => o.velocity - l.velocity), this._strings = c;
}
async _internalLoad() {
await Promise.all(this._strings.map(async (t) => {
await t.load(), t.connect(this.output);
}));
}
}
class b extends p {
constructor() {
super(w(b.getDefaults(), arguments)), this.name = "PianoSampler", this.input = void 0, this.output = new R({ context: this.context }), this._heldNotes = /* @__PURE__ */ new Map(), this._loaded = !1;
const t = w(b.getDefaults(), arguments);
t.url.endsWith("/") || (t.url += "/"), this.maxPolyphony = t.maxPolyphony, this._heldNotes = /* @__PURE__ */ new Map(), this._sustainedNotes = /* @__PURE__ */ new Map(), this._strings = new Z(Object.assign({}, t, {
enabled: !0,
samples: t.url,
volume: t.volume.strings
})).connect(this.output), this.strings = this._strings.volume, this._pedal = new Y(Object.assign({}, t, {
enabled: t.pedal,
samples: t.url,
volume: t.volume.pedal
})).connect(this.output), this.pedal = this._pedal.volume, this._keybed = new X(Object.assign({}, t, {
enabled: t.release,
samples: t.url,
volume: t.volume.keybed
})).connect(this.output), this.keybed = this._keybed.volume, this._harmonics = new Q(Object.assign({}, t, {
enabled: t.release,
samples: t.url,
volume: t.volume.harmonics
})).connect(this.output), this.harmonics = this._harmonics.volume;
}
static getDefaults() {
return Object.assign(p.getDefaults(), {
maxNote: 108,
maxPolyphony: 32,
minNote: 21,
pedal: !0,
release: !1,
url: "https://tambien.github.io/Piano/Salamander/",
velocities: 1,
volume: {
harmonics: 0,
keybed: 0,
pedal: 0,
strings: 0
}
});
}
/**
* Load all the samples
*/
async load() {
await Promise.all([
this._strings.load(),
this._pedal.load(),
this._keybed.load(),
this._harmonics.load()
]), this._loaded = !0;
}
/**
* If all the samples are loaded or not
*/
get loaded() {
return this._loaded;
}
/**
* Expand the strings component to a higher velocity resolution.
* Loads and connects new velocity layers in-place without disrupting playing notes.
*/
async expandTo(t) {
await this._strings.expandTo(t);
}
/**
* Put the pedal down at the given time. Causes subsequent
* notes and currently held notes to sustain.
*/
pedalDown({ time: t = this.immediate() } = {}) {
return this.loaded && (t = this.toSeconds(t), this._pedal.isDown(t) || this._pedal.down(t)), this;
}
/**
* Put the pedal up. Dampens sustained notes
*/
pedalUp({ time: t = this.immediate() } = {}) {
if (this.loaded) {
const e = this.toSeconds(t);
this._pedal.isDown(e) && (this._pedal.up(e), this._sustainedNotes.forEach((s, n) => {
this._heldNotes.has(n) || this._strings.triggerRelease(n, e);
}), this._sustainedNotes.clear());
}
return this;
}
/**
* Play a note.
* @param note The note to play. If it is a number, it is assumed to be MIDI
* @param velocity The velocity to play the note
* @param time The time of the event
*/
keyDown({ note: t, midi: e, time: s = this.immediate(), velocity: n = 0.8 }) {
return this.loaded && this.maxPolyphony > this._heldNotes.size + this._sustainedNotes.size ? (s = this.toSeconds(s), B(t) && (e = Math.round(m(t).toMidi())), this._heldNotes.has(e) || (this._heldNotes.set(e, { time: s, velocity: n }), this._strings.triggerAttack(e, s, n))) : console.warn("samples not loaded"), this;
}
/**
* Release a held note.
*/
keyUp({ note: t, midi: e, time: s = this.immediate(), velocity: n = 0.8 }) {
if (this.loaded && (s = this.toSeconds(s), B(t) && (e = Math.round(m(t).toMidi())), this._heldNotes.has(e))) {
const r = this._heldNotes.get(e);
this._heldNotes.delete(e);
const c = Math.pow(Math.max(s - r.time, 0.1), 0.7), o = r.velocity;
let l = 3 / c * o * n;
l = Math.max(l, 0.4), l = Math.min(l, 4), this._pedal.isDown(s) ? this._sustainedNotes.has(e) || this._sustainedNotes.set(e, s) : (this._strings.triggerRelease(e, s), this._harmonics.triggerAttack(e, s, l)), this._keybed.start(e, s, n);
}
return this;
}
stopAll() {
return this.pedalUp(), this._heldNotes.forEach((t, e) => {
this.keyUp({ midi: e });
}), this;
}
}
class x extends p {
constructor() {
super(w(x.getDefaults(), arguments)), this.name = "Piano", this.input = void 0, this.output = new R({ context: this.context }), this._loaded = !1;
const t = w(x.getDefaults(), arguments);
t.url.endsWith("/") || (t.url += "/"), this._velocities = t.velocities, this._onPlayable = t.onPlayable, this._onLoadProgress = t.onLoadProgress, this._onTimeout = t.onTimeout, this._samplerConfig = {
minNote: t.minNote,
maxNote: t.maxNote,
release: t.release,
pedal: t.pedal,
url: t.url,
maxPolyphony: t.maxPolyphony,
volume: t.volume
};
}
static getDefaults() {
return Object.assign(p.getDefaults(), {
velocities: 8,
maxNote: 108,
maxPolyphony: 32,
minNote: 21,
onPlayable: void 0,
onLoadProgress: void 0,
onTimeout: void 0,
pedal: !0,
release: !1,
url: "https://d-buckner.github.io/salamander-piano/",
volume: {
harmonics: 0,
keybed: 0,
pedal: 0,
strings: 0
}
});
}
/**
* Load samples progressively. Resolves after the first velocity pass is ready;
* upgrades to the target velocity count continue in the background.
*/
async load() {
const t = new b({
...this._samplerConfig,
velocities: 1,
context: this.context
});
await t.load(), t.connect(this.output), this._sampler = t, this._loaded = !0, this._onPlayable?.(), this._onLoadProgress?.(1 / this._velocities), this._velocities > 1 && this._expandInBackground(1);
}
/**
* If the first velocity pass is loaded and ready to play
*/
get loaded() {
return this._loaded;
}
/**
* Play a note.
*/
keyDown(t) {
return this._sampler?.keyDown(t), this;
}
/**
* Release a held note.
*/
keyUp(t) {
return this._sampler?.keyUp(t), this;
}
/**
* Put the pedal down. Causes subsequent notes and currently held notes to sustain.
*/
pedalDown(t = {}) {
return this._sampler?.pedalDown(t), this;
}
/**
* Put the pedal up. Dampens sustained notes.
*/
pedalUp(t = {}) {
return this._sampler?.pedalUp(t), this;
}
/**
* Stop all currently playing notes.
*/
stopAll() {
return this._sampler?.stopAll(), this;
}
async _expandInBackground(t) {
const e = Date.now() + 3e4;
try {
for (let s = t + 1; s <= this._velocities; s++) {
if (Date.now() >= e) {
this._onTimeout?.();
return;
}
await this._sampler.expandTo(s), this._onLoadProgress?.(s / this._velocities);
}
} catch {
}
}
}
function L(a) {
const t = Math.floor((a - 12) / 12), e = (a - 12) % 12;
return `${["C", "Cs", "D", "Ds", "E", "F", "Fs", "G", "Gs", "A", "As", "B"][e]}${t}`;
}
function tt(a, t) {
return `${L(a).replace("#", "s")}v${t}.ogg`;
}
function et(a) {
return `harmS${L(a).replace("#", "s")}.ogg`;
}
function st(a) {
return `rel${a - 20}.ogg`;
}
async function at(a, t = {}) {
const {
baseUrl: e = "https://tambien.github.io/Piano/Salamander/",
minNote: s = 21,
maxNote: n = 108,
includeHarmonics: r = !1,
includePedal: c = !1,
includeRelease: o = !1
} = t, l = e.endsWith("/") ? e : `${e}/`, j = N[a] || [8], S = O(s, n), u = [];
for (const h of S)
for (const d of j)
u.push(l + tt(h, d));
if (r) {
const h = C.filter((d) => d >= 21 && d <= 87);
for (const d of h)
u.push(l + et(d));
}
if (c && (u.push(l + "pedalD1.ogg"), u.push(l + "pedalD2.ogg"), u.push(l + "pedalU1.ogg"), u.push(l + "pedalU2.ogg")), o)
for (const h of S)
u.push(l + st(h));
const P = await caches.open($);
await Promise.allSettled(u.map(async (h) => {
if (!await P.match(h))
try {
const D = await fetch(h);
D.ok && await P.put(h, D);
} catch {
}
}));
}
export {
x as Piano,
at as preloadSamples
};