@hlysine/piano
Version:
Web Audio instrument using Salamander Grand Piano samples
328 lines (280 loc) • 7.08 kB
text/typescript
import {
Gain, isString,
Midi, optionsFromArguments, Param,
ToneAudioNode, Unit, isDefined
} from 'tone'
import { Harmonics } from './Harmonics'
import { Keybed } from './Keybed'
import { Pedal } from './Pedal'
import { PianoStrings } from './Strings'
type ToneAudioNodeOptions = import('tone/build/esm/core/context/ToneAudioNode').ToneAudioNodeOptions
export interface PianoOptions extends ToneAudioNodeOptions {
/**
* The number of velocity steps to load
*/
velocities: number,
/**
* The lowest note to load
*/
minNote: number,
/**
* The highest note to load
*/
maxNote: number,
/**
* If it should include a 'release' sounds composed of a keyclick and string harmonic
*/
release: boolean,
/**
* If the piano should include a 'pedal' sound.
*/
pedal: boolean,
/**
* The directory of the salamander grand piano samples
*/
url: string,
/**
* The maximum number of notes that can be held at once
*/
maxPolyphony: number,
/**
* Volume levels for each of the components (in decibels)
*/
volume: {
pedal: number,
strings: number,
keybed: number,
harmonics: number,
}
}
interface KeyEvent {
time?: Unit.Time;
velocity?: number;
note?: string;
midi?: number;
}
interface PedalEvent {
time?: Unit.Time;
}
/**
* The Piano
*/
export class Piano extends ToneAudioNode<PianoOptions> {
readonly name = 'Piano'
readonly input = undefined
readonly output = new Gain({ context: this.context })
/**
* The string harmonics
*/
private _harmonics: Harmonics
/**
* The keybed release sound
*/
private _keybed: Keybed
/**
* The pedal
*/
private _pedal: Pedal
/**
* The strings
*/
private _strings: PianoStrings
/**
* The volume level of the strings output. This is the main piano sound.
*/
strings: Param<"decibels">
/**
* The volume output of the pedal up and down sounds
*/
pedal: Param<"decibels">
/**
* The volume of the string harmonics
*/
harmonics: Param<"decibels">
/**
* The volume of the keybed click sound
*/
keybed: Param<"decibels">
/**
* The maximum number of notes which can be held at once
*/
maxPolyphony: number;
/**
* The sustained notes
*/
private _sustainedNotes: Map<number, any>
/**
* The currently held notes
*/
private _heldNotes: Map<number, any> = new Map()
/**
* If it's loaded or not
*/
private _loaded: boolean = false
constructor(options?: Partial<PianoOptions>);
constructor() {
super(optionsFromArguments(Piano.getDefaults(), arguments))
const options = optionsFromArguments(Piano.getDefaults(), arguments)
// make sure it ends with a /
if (!options.url.endsWith('/')) {
options.url += '/'
}
this.maxPolyphony = options.maxPolyphony
this._heldNotes = new Map()
this._sustainedNotes = new Map()
this._strings = new PianoStrings(Object.assign({}, options, {
enabled: true,
samples: options.url,
volume: options.volume.strings,
})).connect(this.output)
this.strings = this._strings.volume
this._pedal = new Pedal(Object.assign({}, options, {
enabled: options.pedal,
samples: options.url,
volume: options.volume.pedal,
})).connect(this.output)
this.pedal = this._pedal.volume
this._keybed = new Keybed(Object.assign({}, options, {
enabled: options.release,
samples: options.url,
volume: options.volume.keybed,
})).connect(this.output)
this.keybed = this._keybed.volume
this._harmonics = new Harmonics(Object.assign({}, options, {
enabled: options.release,
samples: options.url,
volume: options.volume.harmonics,
})).connect(this.output)
this.harmonics = this._harmonics.volume
}
static getDefaults(): PianoOptions {
return Object.assign(ToneAudioNode.getDefaults(), {
maxNote: 108,
minNote: 21,
pedal: true,
release: false,
url: 'https://tambien.github.io/Piano/audio/',
velocities: 1,
maxPolyphony: 32,
volume: {
harmonics: 0,
keybed: 0,
pedal: 0,
strings: 0,
},
})
}
/**
* Load all the samples
*/
async load(): Promise<void> {
await Promise.all([
this._strings.load(),
this._pedal.load(),
this._keybed.load(),
this._harmonics.load(),
])
this._loaded = true
}
/**
* If all the samples are loaded or not
*/
get loaded(): boolean {
return this._loaded
}
/**
* Put the pedal down at the given time. Causes subsequent
* notes and currently held notes to sustain.
*/
pedalDown({ time = this.immediate() }: PedalEvent = {}): this {
if (this.loaded) {
time = this.toSeconds(time)
if (!this._pedal.isDown(time)) {
this._pedal.down(time)
}
}
return this
}
/**
* Put the pedal up. Dampens sustained notes
*/
pedalUp({ time = this.immediate() }: PedalEvent = {}): this {
if (this.loaded) {
const seconds = this.toSeconds(time)
if (this._pedal.isDown(seconds)) {
this._pedal.up(seconds)
// dampen each of the notes
this._sustainedNotes.forEach((t, note) => {
if (!this._heldNotes.has(note)) {
this._strings.triggerRelease(note, seconds)
}
})
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, midi, time = this.immediate(), velocity = 0.8 }: KeyEvent): this {
if (this.loaded && this.maxPolyphony > this._heldNotes.size + this._sustainedNotes.size) {
time = this.toSeconds(time)
if (isString(note)) {
midi = Math.round(Midi(note).toMidi())
}
if (!this._heldNotes.has(midi)) {
// record the start time and velocity
this._heldNotes.set(midi, { time, velocity })
this._strings.triggerAttack(midi, time, velocity)
}
} else {
console.warn('samples not loaded')
}
return this
}
/**
* Release a held note.
*/
keyUp({ note, midi, time = this.immediate(), velocity = 0.8 }: KeyEvent): this {
if (this.loaded) {
time = this.toSeconds(time)
if (isString(note)) {
midi = Math.round(Midi(note).toMidi())
}
if (this._heldNotes.has(midi)) {
const prevNote = this._heldNotes.get(midi)
this._heldNotes.delete(midi)
// compute the release velocity
const holdTime = Math.pow(Math.max(time - prevNote.time, 0.1), 0.7)
const prevVel = prevNote.velocity
let dampenGain = (3 / holdTime) * prevVel * velocity
dampenGain = Math.max(dampenGain, 0.4)
dampenGain = Math.min(dampenGain, 4)
if (this._pedal.isDown(time)) {
if (!this._sustainedNotes.has(midi)) {
this._sustainedNotes.set(midi, time)
}
} else {
// release the string sound
this._strings.triggerRelease(midi, time)
// trigger the harmonics sound
this._harmonics.triggerAttack(midi, time, dampenGain)
}
// trigger the keybed release sound
this._keybed.start(midi, time, velocity)
}
}
return this
}
stopAll(): this {
this.pedalUp()
this._heldNotes.forEach((_, midi) => {
this.keyUp({ midi })
})
return this
}
}