Add piano mono/poly, octave, and expanded drum voice set
This commit is contained in:
@@ -8,6 +8,7 @@ export const PIANO_INSTRUMENT_OPTIONS = [
|
||||
'bass',
|
||||
'violin',
|
||||
'synth_lead',
|
||||
'brass',
|
||||
'nintendo',
|
||||
'drum_kit',
|
||||
] as const;
|
||||
@@ -20,6 +21,7 @@ type VoiceRuntime = {
|
||||
oscillators: OscillatorNode[];
|
||||
modulators: OscillatorNode[];
|
||||
releaseSeconds: number;
|
||||
sourceGroupId: string;
|
||||
};
|
||||
|
||||
type PianoContext = {
|
||||
@@ -116,10 +118,21 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
|
||||
releaseScale: 1,
|
||||
vibrato: { rateHz: 6.8, depthCents: 9 },
|
||||
},
|
||||
brass: {
|
||||
oscillators: [
|
||||
{ type: 'sawtooth', gain: 0.72 },
|
||||
{ type: 'square', ratio: 2, gain: 0.2 },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3300, q: 1.05 },
|
||||
gain: 0.22,
|
||||
sustainRatio: 0.62,
|
||||
releaseScale: 0.92,
|
||||
vibrato: { rateHz: 5.1, depthCents: 5 },
|
||||
},
|
||||
nintendo: {
|
||||
oscillators: [
|
||||
{ type: 'square', gain: 1 },
|
||||
{ type: 'square', detune: 8, gain: 0.16 },
|
||||
{ type: 'square', detune: 2, gain: 0.08 },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 5200, q: 1.2 },
|
||||
gain: 0.22,
|
||||
@@ -136,10 +149,11 @@ export const DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT: Record<
|
||||
electric_piano: { attack: 12, decay: 40, release: 30, brightness: 62 },
|
||||
guitar: { attack: 8, decay: 35, release: 25, brightness: 50 },
|
||||
organ: { attack: 25, decay: 70, release: 45, brightness: 48 },
|
||||
bass: { attack: 10, decay: 35, release: 28, brightness: 38 },
|
||||
bass: { attack: 2, decay: 24, release: 18, brightness: 34 },
|
||||
violin: { attack: 22, decay: 75, release: 55, brightness: 58 },
|
||||
synth_lead: { attack: 6, decay: 30, release: 22, brightness: 72 },
|
||||
nintendo: { attack: 2, decay: 28, release: 18, brightness: 85 },
|
||||
brass: { attack: 10, decay: 45, release: 30, brightness: 60 },
|
||||
nintendo: { attack: 1, decay: 24, release: 15, brightness: 85 },
|
||||
drum_kit: { attack: 1, decay: 22, release: 12, brightness: 68 },
|
||||
};
|
||||
|
||||
@@ -167,6 +181,28 @@ function brightnessPercentToMultiplier(value: number): number {
|
||||
return 0.45 + (clamped / 100) * 1.55;
|
||||
}
|
||||
|
||||
/** Maps midi note number to one deterministic drum voice variant. */
|
||||
function drumVariantForMidi(midi: number): DrumVariant {
|
||||
const palette: DrumVariant[] = [
|
||||
'kick_sub',
|
||||
'kick_punch',
|
||||
'snare_tight',
|
||||
'snare_body',
|
||||
'hat_closed',
|
||||
'hat_open',
|
||||
'tom_low',
|
||||
'tom_mid',
|
||||
'tom_high',
|
||||
'clap',
|
||||
'pow_mid',
|
||||
'pow_high',
|
||||
'snare_noise',
|
||||
'noise_8bit',
|
||||
];
|
||||
const index = ((Math.round(midi) % palette.length) + palette.length) % palette.length;
|
||||
return palette[index];
|
||||
}
|
||||
|
||||
/** Converts midi note number to frequency in hertz. */
|
||||
function midiToFrequency(midi: number): number {
|
||||
return 440 * Math.pow(2, (midi - 69) / 12);
|
||||
@@ -181,11 +217,25 @@ function safeStop(oscillator: OscillatorNode, when: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
type DrumVariant = 'kick_808' | 'snare' | 'clap' | 'hat_closed' | 'hat_open' | 'tom_low' | 'tom_high' | 'noise_8bit';
|
||||
const DRUM_VARIANTS: DrumVariant[] = ['kick_808', 'snare', 'clap', 'hat_closed', 'hat_open', 'tom_low', 'tom_high', 'noise_8bit'];
|
||||
type DrumVariant =
|
||||
| 'kick_sub'
|
||||
| 'kick_punch'
|
||||
| 'snare_tight'
|
||||
| 'snare_body'
|
||||
| 'snare_noise'
|
||||
| 'clap'
|
||||
| 'hat_closed'
|
||||
| 'hat_open'
|
||||
| 'tom_low'
|
||||
| 'tom_mid'
|
||||
| 'tom_high'
|
||||
| 'pow_mid'
|
||||
| 'pow_high'
|
||||
| 'noise_8bit';
|
||||
|
||||
export class PianoSynth {
|
||||
private readonly voices = new Map<string, VoiceRuntime>();
|
||||
private readonly activeVoiceKeysByGroup = new Map<string, Set<string>>();
|
||||
private readonly drumNoiseBuffers = new WeakMap<AudioContext, AudioBuffer>();
|
||||
private readonly bitNoiseBuffers = new WeakMap<AudioContext, AudioBuffer>();
|
||||
|
||||
@@ -199,8 +249,10 @@ export class PianoSynth {
|
||||
/** Starts one note for a specific keyboard key id. */
|
||||
noteOn(
|
||||
keyId: string,
|
||||
sourceGroupId: string,
|
||||
midi: number,
|
||||
instrument: PianoInstrumentId,
|
||||
voiceMode: 'mono' | 'poly',
|
||||
attackPercent: number,
|
||||
decayPercent: number,
|
||||
releasePercent: number,
|
||||
@@ -209,8 +261,16 @@ export class PianoSynth {
|
||||
spatial: PianoSpatialSource,
|
||||
): void {
|
||||
if (this.voices.has(keyId)) return;
|
||||
if (voiceMode === 'mono') {
|
||||
const previousKeys = this.activeVoiceKeysByGroup.get(sourceGroupId);
|
||||
if (previousKeys) {
|
||||
for (const previousKey of Array.from(previousKeys)) {
|
||||
this.noteOff(previousKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (instrument === 'drum_kit') {
|
||||
this.playDrumHit(keyId, midi, context, spatial, attackPercent, decayPercent, releasePercent, brightnessPercent);
|
||||
this.playDrumHit(midi, context, spatial, attackPercent, decayPercent, releasePercent, brightnessPercent);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -284,7 +344,11 @@ export class PianoSynth {
|
||||
oscillators,
|
||||
modulators,
|
||||
releaseSeconds,
|
||||
sourceGroupId,
|
||||
});
|
||||
const groupKeys = this.activeVoiceKeysByGroup.get(sourceGroupId) ?? new Set<string>();
|
||||
groupKeys.add(keyId);
|
||||
this.activeVoiceKeysByGroup.set(sourceGroupId, groupKeys);
|
||||
}
|
||||
|
||||
/** Releases one active note tied to a keyboard key id. */
|
||||
@@ -292,6 +356,15 @@ export class PianoSynth {
|
||||
const voice = this.voices.get(keyId);
|
||||
if (!voice) return;
|
||||
this.voices.delete(keyId);
|
||||
const groupKeys = this.activeVoiceKeysByGroup.get(voice.sourceGroupId);
|
||||
if (groupKeys) {
|
||||
groupKeys.delete(keyId);
|
||||
if (groupKeys.size === 0) {
|
||||
this.activeVoiceKeysByGroup.delete(voice.sourceGroupId);
|
||||
} else {
|
||||
this.activeVoiceKeysByGroup.set(voice.sourceGroupId, groupKeys);
|
||||
}
|
||||
}
|
||||
const now = voice.gain.context.currentTime;
|
||||
const currentGain = Math.max(0.0001, voice.gain.gain.value);
|
||||
voice.gain.gain.cancelScheduledValues(now);
|
||||
@@ -321,7 +394,6 @@ export class PianoSynth {
|
||||
|
||||
/** Plays one synthesized drum hit for drum-kit instrument mode. */
|
||||
private playDrumHit(
|
||||
keyId: string,
|
||||
midi: number,
|
||||
context: PianoContext,
|
||||
spatial: PianoSpatialSource,
|
||||
@@ -338,8 +410,8 @@ export class PianoSynth {
|
||||
baseGain: 1,
|
||||
});
|
||||
if (!spatialMix || spatialMix.gain <= 0) return;
|
||||
const typeIndex = Math.abs((midi % DRUM_VARIANTS.length) + this.hashKey(keyId)) % DRUM_VARIANTS.length;
|
||||
const variant = DRUM_VARIANTS[typeIndex];
|
||||
const variant = drumVariantForMidi(midi);
|
||||
const midiOffset = (Math.round(midi) - 60) / 24;
|
||||
const decaySeconds = 0.03 + decayPercentToSeconds(decayPercent) * 0.5;
|
||||
const releaseSeconds = 0.02 + releasePercentToSeconds(releasePercent) * 0.35;
|
||||
const attackSeconds = Math.max(0.001, attackPercentToSeconds(attackPercent) * 0.18);
|
||||
@@ -350,26 +422,45 @@ export class PianoSynth {
|
||||
gain.gain.exponentialRampToValueAtTime(0.22 * spatialMix.gain, now + attackSeconds);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + decaySeconds);
|
||||
|
||||
let tailNode: AudioNode = gain;
|
||||
if (typeof context.audioCtx.createStereoPanner === 'function') {
|
||||
const panner = context.audioCtx.createStereoPanner();
|
||||
panner.pan.setValueAtTime(spatialMix.pan, now);
|
||||
tailNode.connect(panner).connect(context.destination);
|
||||
tailNode = panner;
|
||||
gain.connect(panner).connect(context.destination);
|
||||
} else {
|
||||
tailNode.connect(context.destination);
|
||||
gain.connect(context.destination);
|
||||
}
|
||||
|
||||
if (variant === 'kick_808') {
|
||||
this.playKick808(context, gain, now, decaySeconds + releaseSeconds * 0.35);
|
||||
if (variant === 'kick_sub') {
|
||||
this.playKick808(context, gain, now, (decaySeconds + releaseSeconds * 0.35) * 1.15, 145, 36);
|
||||
return;
|
||||
}
|
||||
if (variant === 'kick_punch') {
|
||||
this.playKick808(context, gain, now, decaySeconds + releaseSeconds * 0.2, 185, 52);
|
||||
return;
|
||||
}
|
||||
if (variant === 'snare_tight') {
|
||||
this.playSnare(context, gain, now, decaySeconds * 0.55 + releaseSeconds * 0.08, 0.75);
|
||||
return;
|
||||
}
|
||||
if (variant === 'snare_body') {
|
||||
this.playSnare(context, gain, now, decaySeconds * 0.92 + releaseSeconds * 0.18, 1);
|
||||
return;
|
||||
}
|
||||
if (variant === 'snare_noise') {
|
||||
this.playSnare(context, gain, now, decaySeconds * 0.8 + releaseSeconds * 0.15, 0.45);
|
||||
this.playNoiseDrum(context, gain, now, decaySeconds * 0.75, 'highpass', 1900 * brightnessMultiplier, true);
|
||||
return;
|
||||
}
|
||||
if (variant === 'tom_low') {
|
||||
this.playTom(context, gain, now, 120, 68, decaySeconds * 0.95 + releaseSeconds * 0.2);
|
||||
this.playTom(context, gain, now, 120, 70, decaySeconds * 0.95 + releaseSeconds * 0.2);
|
||||
return;
|
||||
}
|
||||
if (variant === 'tom_mid') {
|
||||
this.playTom(context, gain, now, 175, 100, decaySeconds * 0.86 + releaseSeconds * 0.16);
|
||||
return;
|
||||
}
|
||||
if (variant === 'tom_high') {
|
||||
this.playTom(context, gain, now, 220, 125, decaySeconds * 0.8 + releaseSeconds * 0.16);
|
||||
this.playTom(context, gain, now, 250, 138, decaySeconds * 0.78 + releaseSeconds * 0.14);
|
||||
return;
|
||||
}
|
||||
if (variant === 'hat_closed') {
|
||||
@@ -380,23 +471,38 @@ export class PianoSynth {
|
||||
this.playNoiseDrum(context, gain, now, decaySeconds * 0.8 + releaseSeconds * 0.2, 'highpass', 5200 * brightnessMultiplier, false);
|
||||
return;
|
||||
}
|
||||
if (variant === 'noise_8bit') {
|
||||
this.playNoiseDrum(context, gain, now, decaySeconds * 0.45, 'bandpass', 2700 * brightnessMultiplier, true);
|
||||
return;
|
||||
}
|
||||
if (variant === 'clap') {
|
||||
this.playClap(context, gain, now, decaySeconds + releaseSeconds * 0.1);
|
||||
return;
|
||||
}
|
||||
this.playSnare(context, gain, now, decaySeconds + releaseSeconds * 0.12);
|
||||
if (variant === 'pow_mid') {
|
||||
this.playPowDown(context, gain, now, 310 + midiOffset * 30, 150 + midiOffset * 15, decaySeconds * 0.95 + releaseSeconds * 0.15);
|
||||
return;
|
||||
}
|
||||
if (variant === 'pow_high') {
|
||||
this.playPowDown(context, gain, now, 420 + midiOffset * 40, 210 + midiOffset * 22, decaySeconds * 0.88 + releaseSeconds * 0.12);
|
||||
return;
|
||||
}
|
||||
if (variant === 'noise_8bit') {
|
||||
this.playNoiseDrum(context, gain, now, decaySeconds * 0.45, 'bandpass', 2700 * brightnessMultiplier, true);
|
||||
return;
|
||||
}
|
||||
this.playSnare(context, gain, now, decaySeconds + releaseSeconds * 0.12, 1);
|
||||
}
|
||||
|
||||
/** 808-like kick: deep sine sweep with long-ish tail. */
|
||||
private playKick808(context: PianoContext, gain: GainNode, now: number, decaySeconds: number): void {
|
||||
private playKick808(
|
||||
context: PianoContext,
|
||||
gain: GainNode,
|
||||
now: number,
|
||||
decaySeconds: number,
|
||||
startHz: number,
|
||||
endHz: number,
|
||||
): void {
|
||||
const kick = context.audioCtx.createOscillator();
|
||||
kick.type = 'sine';
|
||||
kick.frequency.setValueAtTime(160, now);
|
||||
kick.frequency.exponentialRampToValueAtTime(42, now + Math.max(0.07, decaySeconds * 0.95));
|
||||
kick.frequency.setValueAtTime(startHz, now);
|
||||
kick.frequency.exponentialRampToValueAtTime(endHz, now + Math.max(0.07, decaySeconds * 0.95));
|
||||
const body = context.audioCtx.createGain();
|
||||
body.gain.setValueAtTime(1, now);
|
||||
body.gain.exponentialRampToValueAtTime(0.0001, now + Math.max(0.08, decaySeconds));
|
||||
@@ -440,13 +546,13 @@ export class PianoSynth {
|
||||
}
|
||||
|
||||
/** Snare: short tone + filtered noise burst. */
|
||||
private playSnare(context: PianoContext, gain: GainNode, now: number, decaySeconds: number): void {
|
||||
private playSnare(context: PianoContext, gain: GainNode, now: number, decaySeconds: number, toneLevel: number): void {
|
||||
const tone = context.audioCtx.createOscillator();
|
||||
tone.type = 'triangle';
|
||||
tone.frequency.setValueAtTime(220, now);
|
||||
tone.frequency.exponentialRampToValueAtTime(130, now + Math.max(0.03, decaySeconds * 0.45));
|
||||
const toneGain = context.audioCtx.createGain();
|
||||
toneGain.gain.setValueAtTime(0.45, now);
|
||||
toneGain.gain.setValueAtTime(0.45 * Math.max(0, toneLevel), now);
|
||||
toneGain.gain.exponentialRampToValueAtTime(0.0001, now + Math.max(0.04, decaySeconds * 0.55));
|
||||
tone.connect(toneGain).connect(gain);
|
||||
tone.start(now);
|
||||
@@ -473,13 +579,22 @@ export class PianoSynth {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns deterministic hash for key ids to map drum voice variants. */
|
||||
private hashKey(value: string): number {
|
||||
let out = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
out = ((out << 5) - out + value.charCodeAt(index)) | 0;
|
||||
}
|
||||
return out;
|
||||
/** Retro game-like downward-bending midrange hit for drum fills. */
|
||||
private playPowDown(context: PianoContext, gain: GainNode, now: number, startHz: number, endHz: number, decaySeconds: number): void {
|
||||
const osc = context.audioCtx.createOscillator();
|
||||
osc.type = 'square';
|
||||
osc.frequency.setValueAtTime(startHz, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(Math.max(35, endHz), now + Math.max(0.04, decaySeconds * 0.9));
|
||||
const amp = context.audioCtx.createGain();
|
||||
amp.gain.setValueAtTime(0.75, now);
|
||||
amp.gain.exponentialRampToValueAtTime(0.0001, now + Math.max(0.06, decaySeconds));
|
||||
const filter = context.audioCtx.createBiquadFilter();
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(1700, now);
|
||||
filter.Q.setValueAtTime(1.2, now);
|
||||
osc.connect(filter).connect(amp).connect(gain);
|
||||
osc.start(now);
|
||||
safeStop(osc, now + Math.max(0.08, decaySeconds) + 0.03);
|
||||
}
|
||||
|
||||
/** Returns or lazily builds short white-noise buffer for percussion synthesis. */
|
||||
|
||||
Reference in New Issue
Block a user