Clean up
This commit is contained in:
parent
ea0304356f
commit
7c70f15e49
65 changed files with 1122 additions and 1906 deletions
|
|
@ -1,38 +0,0 @@
|
|||
import { clamp } from '../utils/math';
|
||||
import { isIosLike } from './audio-platform';
|
||||
|
||||
interface AudioPanNode {
|
||||
input: AudioNode;
|
||||
output: AudioNode;
|
||||
disconnect: () => void;
|
||||
}
|
||||
|
||||
type AudioContextWithOptionalPanner = AudioContext & {
|
||||
createStereoPanner?: () => StereoPannerNode;
|
||||
};
|
||||
|
||||
export const createAudioPanNode = (
|
||||
context: AudioContext,
|
||||
pan: number,
|
||||
startTime: number
|
||||
): AudioPanNode => {
|
||||
const createStereoPanner = (context as AudioContextWithOptionalPanner)
|
||||
.createStereoPanner;
|
||||
|
||||
if (!isIosLike() && typeof createStereoPanner === 'function') {
|
||||
const panner = createStereoPanner.call(context);
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), startTime);
|
||||
return {
|
||||
input: panner,
|
||||
output: panner,
|
||||
disconnect: () => panner.disconnect(),
|
||||
};
|
||||
}
|
||||
|
||||
const passthrough = context.createGain();
|
||||
return {
|
||||
input: passthrough,
|
||||
output: passthrough,
|
||||
disconnect: () => passthrough.disconnect(),
|
||||
};
|
||||
};
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export const isIosLike = (): boolean =>
|
||||
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { appConfig } from '../config';
|
||||
import type { PianoNoteRole } from './garden-audio-types';
|
||||
|
||||
type GardenAudioChordQuality = 'major' | 'minor';
|
||||
|
|
@ -86,5 +85,3 @@ export interface GardenAudioConfig {
|
|||
maniaSmoothingSeconds: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;
|
||||
|
|
|
|||
|
|
@ -19,20 +19,14 @@ export class GardenAudioEnergy {
|
|||
this.targetEnergy = 0;
|
||||
}
|
||||
|
||||
public recordStroke(strokeEnergy: number, now: number): void {
|
||||
const energy = clamp01(strokeEnergy);
|
||||
this.targetEnergy = Math.max(this.targetEnergy, energy);
|
||||
public recordStroke(strokeEnergy: number): void {
|
||||
this.targetEnergy = Math.max(this.targetEnergy, strokeEnergy);
|
||||
if (this.isGestureActive) {
|
||||
this.energy = Math.max(
|
||||
this.energy,
|
||||
energy * this.config.energy.immediateActivityScale
|
||||
strokeEnergy * this.config.energy.immediateActivityScale
|
||||
);
|
||||
}
|
||||
this.lastEnergyUpdateAt ||= now;
|
||||
}
|
||||
|
||||
public recordEraserStroke(): void {
|
||||
this.targetEnergy = 0;
|
||||
}
|
||||
|
||||
public silence(): void {
|
||||
|
|
@ -46,7 +40,7 @@ export class GardenAudioEnergy {
|
|||
return;
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.max(0, now - this.lastEnergyUpdateAt);
|
||||
const elapsedSeconds = now - this.lastEnergyUpdateAt;
|
||||
this.lastEnergyUpdateAt = now;
|
||||
this.targetEnergy *= Math.exp(
|
||||
-elapsedSeconds / this.config.energy.strokeDecaySeconds
|
||||
|
|
@ -62,14 +56,6 @@ export class GardenAudioEnergy {
|
|||
this.energy = approach(this.energy, target, elapsedSeconds, timeConstant);
|
||||
}
|
||||
|
||||
public getActivity(): number {
|
||||
if (!this.isGestureActive) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.getLevel();
|
||||
}
|
||||
|
||||
public getLevel(): number {
|
||||
return clamp01(this.energy);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,28 +8,17 @@ interface GardenAudioGestureFrame {
|
|||
}
|
||||
|
||||
export class GardenAudioGestureState {
|
||||
private gestureClockSeconds = 0;
|
||||
private activity = 0;
|
||||
private maniaAmount = 0;
|
||||
private isManic = false;
|
||||
|
||||
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
|
||||
|
||||
public beginGesture(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
public recordStroke({
|
||||
metrics,
|
||||
}: {
|
||||
metrics: GardenAudioStrokeMetrics;
|
||||
}): GardenAudioGestureFrame {
|
||||
this.gestureClockSeconds += metrics.elapsedSeconds;
|
||||
|
||||
const targetActivity = this.getTargetActivity(metrics);
|
||||
const activityTimeConstant =
|
||||
targetActivity > this.activity
|
||||
|
|
@ -65,29 +54,21 @@ export class GardenAudioGestureState {
|
|||
}
|
||||
|
||||
public reset(): void {
|
||||
this.gestureClockSeconds = 0;
|
||||
this.activity = 0;
|
||||
this.maniaAmount = 0;
|
||||
this.isManic = false;
|
||||
}
|
||||
|
||||
private getTargetActivity(metrics: GardenAudioStrokeMetrics): number {
|
||||
const normalizedSpeed = Math.max(0, metrics.normalizedSpeed);
|
||||
const activeSpeed = Math.max(
|
||||
this.inputConfig.activityNoiseFloorSpeed,
|
||||
this.inputConfig.fullActivitySpeed
|
||||
);
|
||||
const speedRange =
|
||||
this.inputConfig.fullActivitySpeed - this.inputConfig.activityNoiseFloorSpeed;
|
||||
const speedAmount = clamp01(
|
||||
(normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) /
|
||||
(activeSpeed - this.inputConfig.activityNoiseFloorSpeed)
|
||||
(metrics.normalizedSpeed - this.inputConfig.activityNoiseFloorSpeed) / speedRange
|
||||
);
|
||||
const distanceAmount = clamp01(
|
||||
metrics.normalizedDistance / this.inputConfig.minAudibleDistance
|
||||
);
|
||||
const activity = Math.pow(
|
||||
speedAmount,
|
||||
Math.max(0.001, this.inputConfig.activityCurve)
|
||||
);
|
||||
const activity = Math.pow(speedAmount, this.inputConfig.activityCurve);
|
||||
|
||||
return clamp(activity * distanceAmount, 0, this.inputConfig.activitySoftCeiling);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { clamp } from '../utils/math';
|
||||
import { isIosLike } from './audio-platform';
|
||||
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
import type { PianoNoteRole } from './garden-audio-types';
|
||||
|
||||
type NavigatorWithAudioSession = Navigator & {
|
||||
audioSession?: { type: 'auto' | 'playback' | 'ambient' | 'transient' | 'transient-solo' | 'play-and-record' };
|
||||
};
|
||||
|
||||
const outputHighPassFrequencyHz = 45;
|
||||
const noiseBufferDurationSeconds = 1;
|
||||
const graphTuning = {
|
||||
closeGain: 0.0001,
|
||||
closeRampSeconds: 0.015,
|
||||
|
|
@ -13,12 +17,6 @@ const graphTuning = {
|
|||
noiseMin: -1,
|
||||
latencyHint: 'interactive' as AudioContextLatencyCategory,
|
||||
outputFilterType: 'highpass' as BiquadFilterType,
|
||||
noiseBufferChannels: 1,
|
||||
noiseBufferDurationSeconds: 1,
|
||||
unlockTickFrequencyHz: 440,
|
||||
unlockTickGain: 0.0001,
|
||||
unlockTickSeconds: 0.025,
|
||||
unlockTickType: 'sine' as OscillatorType,
|
||||
compressor: {
|
||||
thresholdDb: -18,
|
||||
kneeDb: 18,
|
||||
|
|
@ -44,8 +42,10 @@ export class GardenAudioGraph {
|
|||
private delayNode: DelayNode | null = null;
|
||||
private delayFeedback: GainNode | null = null;
|
||||
private delayOutput: GainNode | null = null;
|
||||
private mediaStreamDestination: MediaStreamAudioDestinationNode | null = null;
|
||||
private mediaStreamElement: HTMLAudioElement | null = null;
|
||||
private lastPianoBusActivity = 0;
|
||||
private pianoBusGainScale = 1;
|
||||
private pianoBusGainScaleAutomationUntil = 0;
|
||||
private pianoBusGainScaleTimeConstantSeconds = 0;
|
||||
private readonly pianoBuses = new Map<PianoNoteRole, GainNode>();
|
||||
|
||||
public constructor(private readonly config: GardenAudioConfig) {}
|
||||
|
|
@ -64,20 +64,20 @@ export class GardenAudioGraph {
|
|||
return null;
|
||||
}
|
||||
|
||||
let context: AudioContext;
|
||||
try {
|
||||
context = new AudioContextConstructor({
|
||||
latencyHint: graphTuning.latencyHint,
|
||||
});
|
||||
} catch {
|
||||
context = new AudioContextConstructor();
|
||||
// Tells iOS to treat this as media playback, so the hardware ringer/mute
|
||||
// switch does not silence Web Audio output. No-op on browsers without the
|
||||
// Audio Session API.
|
||||
const audioSession = (navigator as NavigatorWithAudioSession).audioSession;
|
||||
if (audioSession) {
|
||||
audioSession.type = 'playback';
|
||||
}
|
||||
|
||||
const context = new AudioContextConstructor({
|
||||
latencyHint: graphTuning.latencyHint,
|
||||
});
|
||||
const masterGain = context.createGain();
|
||||
const highPass = context.createBiquadFilter();
|
||||
const compressor = context.createDynamicsCompressor();
|
||||
const mediaStreamDestination = isIosLike()
|
||||
? context.createMediaStreamDestination()
|
||||
: null;
|
||||
|
||||
masterGain.gain.value = 0;
|
||||
highPass.type = graphTuning.outputFilterType;
|
||||
|
|
@ -90,15 +90,10 @@ export class GardenAudioGraph {
|
|||
|
||||
masterGain.connect(highPass);
|
||||
highPass.connect(compressor);
|
||||
if (mediaStreamDestination) {
|
||||
compressor.connect(mediaStreamDestination);
|
||||
} else {
|
||||
compressor.connect(context.destination);
|
||||
}
|
||||
compressor.connect(context.destination);
|
||||
|
||||
this.context = context;
|
||||
this.masterGain = masterGain;
|
||||
this.mediaStreamDestination = mediaStreamDestination;
|
||||
this.noiseBuffer = this.createNoiseBuffer(context);
|
||||
this.createDelay(context, masterGain);
|
||||
this.createBuses(context, masterGain);
|
||||
|
|
@ -106,35 +101,6 @@ export class GardenAudioGraph {
|
|||
return context;
|
||||
}
|
||||
|
||||
// iOS WebKit can report "running" while the hardware output is still silent.
|
||||
// A very short, nearly inaudible oscillator in the gesture stack is more
|
||||
// reliable than a fully silent buffer on recent Safari versions.
|
||||
public unlock(): void {
|
||||
if (!this.context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = this.context.currentTime;
|
||||
const source = this.context.createOscillator();
|
||||
const gain = this.context.createGain();
|
||||
|
||||
source.type = graphTuning.unlockTickType;
|
||||
source.frequency.setValueAtTime(graphTuning.unlockTickFrequencyHz, now);
|
||||
gain.gain.setValueAtTime(graphTuning.unlockTickGain, now);
|
||||
source.connect(gain);
|
||||
gain.connect(this.context.destination);
|
||||
source.start(now);
|
||||
source.stop(now + graphTuning.unlockTickSeconds);
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
source.disconnect();
|
||||
gain.disconnect();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
public setMasterGain(targetGain: number, timeConstantSeconds: number): void {
|
||||
if (!this.context || !this.masterGain) {
|
||||
return;
|
||||
|
|
@ -147,38 +113,6 @@ export class GardenAudioGraph {
|
|||
);
|
||||
}
|
||||
|
||||
public startMediaElementOutput(): void {
|
||||
if (!this.mediaStreamDestination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaElement = this.ensureMediaStreamElement();
|
||||
const playPromise = mediaElement.play();
|
||||
void playPromise?.catch(() => undefined);
|
||||
}
|
||||
|
||||
private ensureMediaStreamElement(): HTMLAudioElement {
|
||||
if (this.mediaStreamElement) {
|
||||
return this.mediaStreamElement;
|
||||
}
|
||||
|
||||
const mediaElement = document.createElement('audio');
|
||||
mediaElement.autoplay = true;
|
||||
mediaElement.volume = 1;
|
||||
mediaElement.setAttribute('playsinline', '');
|
||||
mediaElement.setAttribute('aria-hidden', 'true');
|
||||
mediaElement.style.position = 'fixed';
|
||||
mediaElement.style.width = '1px';
|
||||
mediaElement.style.height = '1px';
|
||||
mediaElement.style.opacity = '0';
|
||||
mediaElement.style.pointerEvents = 'none';
|
||||
mediaElement.style.left = '-9999px';
|
||||
mediaElement.srcObject = this.mediaStreamDestination?.stream ?? null;
|
||||
document.body.append(mediaElement);
|
||||
this.mediaStreamElement = mediaElement;
|
||||
return mediaElement;
|
||||
}
|
||||
|
||||
public applyDelayProfile(profile: GardenAudioVibeProfile): void {
|
||||
if (!this.context || !this.delayNode) {
|
||||
return;
|
||||
|
|
@ -228,6 +162,20 @@ export class GardenAudioGraph {
|
|||
return this.pianoBuses.get(role ?? 'gesture') ?? this.eventBus;
|
||||
}
|
||||
|
||||
public setPianoBusGainScale(targetScale: number, timeConstantSeconds: number): void {
|
||||
if (!this.context) {
|
||||
this.pianoBusGainScale = clamp(targetScale, 0, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = this.context.currentTime;
|
||||
|
||||
this.pianoBusGainScale = clamp(targetScale, 0, 1);
|
||||
this.pianoBusGainScaleTimeConstantSeconds = timeConstantSeconds;
|
||||
this.pianoBusGainScaleAutomationUntil = now + timeConstantSeconds * 4;
|
||||
this.updatePianoBusGains(this.lastPianoBusActivity, now, timeConstantSeconds);
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
const context = this.context;
|
||||
if (!context) {
|
||||
|
|
@ -304,27 +252,33 @@ export class GardenAudioGraph {
|
|||
this.noiseBus.connect(eventBus);
|
||||
}
|
||||
|
||||
private updatePianoBusGains(activity: number, now: number): void {
|
||||
private updatePianoBusGains(
|
||||
activity: number,
|
||||
now: number,
|
||||
timeConstantSeconds?: number
|
||||
): void {
|
||||
const effectiveTimeConstantSeconds =
|
||||
timeConstantSeconds ??
|
||||
(now < this.pianoBusGainScaleAutomationUntil
|
||||
? this.pianoBusGainScaleTimeConstantSeconds
|
||||
: this.config.updateRampSeconds);
|
||||
|
||||
this.lastPianoBusActivity = activity;
|
||||
this.pianoBuses.forEach((bus, role) => {
|
||||
const baseGain = this.config.graph.pianoBusGains[role];
|
||||
const ducking = this.config.graph.pianoBusActivityDucking[role];
|
||||
bus.gain.setTargetAtTime(
|
||||
Math.max(0, baseGain * (1 - activity * ducking)),
|
||||
Math.max(0, baseGain * (1 - activity * ducking) * this.pianoBusGainScale),
|
||||
now,
|
||||
this.config.updateRampSeconds
|
||||
effectiveTimeConstantSeconds
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private createNoiseBuffer(context: AudioContext): AudioBuffer {
|
||||
const buffer = context.createBuffer(
|
||||
appPositiveInteger(graphTuning.noiseBufferChannels),
|
||||
Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
context.sampleRate * Math.max(0.001, graphTuning.noiseBufferDurationSeconds)
|
||||
)
|
||||
),
|
||||
1,
|
||||
Math.floor(context.sampleRate * noiseBufferDurationSeconds),
|
||||
context.sampleRate
|
||||
);
|
||||
const data = buffer.getChannelData(0);
|
||||
|
|
@ -348,16 +302,10 @@ export class GardenAudioGraph {
|
|||
this.delayNode = null;
|
||||
this.delayFeedback = null;
|
||||
this.delayOutput = null;
|
||||
this.mediaStreamDestination = null;
|
||||
if (this.mediaStreamElement) {
|
||||
this.mediaStreamElement.pause();
|
||||
this.mediaStreamElement.srcObject = null;
|
||||
this.mediaStreamElement.remove();
|
||||
this.mediaStreamElement = null;
|
||||
}
|
||||
this.lastPianoBusActivity = 0;
|
||||
this.pianoBusGainScale = 1;
|
||||
this.pianoBusGainScaleAutomationUntil = 0;
|
||||
this.pianoBusGainScaleTimeConstantSeconds = 0;
|
||||
this.pianoBuses.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const appPositiveInteger = (value: number): number =>
|
||||
Math.max(1, Math.floor(Number.isFinite(value) ? value : 1));
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import type { GardenAudioStroke } from './garden-audio-types';
|
||||
|
||||
const fallbackNormalizationPixels = 1000;
|
||||
const fallbackFrameSeconds = 1 / 60;
|
||||
const minElapsedSeconds = 0.001;
|
||||
|
||||
export interface GardenAudioStrokeMetrics {
|
||||
distancePixels: number;
|
||||
elapsedSeconds: number;
|
||||
normalizedDistance: number;
|
||||
normalizedSpeed: number;
|
||||
|
|
@ -15,42 +12,13 @@ export const getStrokeMetrics = (stroke: GardenAudioStroke): GardenAudioStrokeMe
|
|||
const dx = stroke.to[0] - stroke.from[0];
|
||||
const dy = stroke.to[1] - stroke.from[1];
|
||||
const distancePixels = Math.hypot(dx, dy);
|
||||
const elapsedSeconds = getElapsedSeconds(stroke);
|
||||
const normalizedDistance = distancePixels / getStrokeNormalizationPixels(stroke);
|
||||
const elapsedSeconds = Math.max(minElapsedSeconds, stroke.elapsedSeconds ?? 0);
|
||||
const normalizationPixels = Math.max(1, Math.min(stroke.canvasSize[0], stroke.canvasSize[1]));
|
||||
const normalizedDistance = distancePixels / normalizationPixels;
|
||||
|
||||
return {
|
||||
distancePixels,
|
||||
elapsedSeconds,
|
||||
normalizedDistance,
|
||||
normalizedSpeed: normalizedDistance / elapsedSeconds,
|
||||
};
|
||||
};
|
||||
|
||||
const getElapsedSeconds = (stroke: GardenAudioStroke): number => {
|
||||
if (
|
||||
stroke.elapsedSeconds !== undefined &&
|
||||
Number.isFinite(stroke.elapsedSeconds) &&
|
||||
stroke.elapsedSeconds > 0
|
||||
) {
|
||||
return Math.max(minElapsedSeconds, stroke.elapsedSeconds);
|
||||
}
|
||||
|
||||
return fallbackFrameSeconds;
|
||||
};
|
||||
|
||||
const getStrokeNormalizationPixels = (stroke: GardenAudioStroke): number => {
|
||||
const width = stroke.canvasSize?.[0];
|
||||
const height = stroke.canvasSize?.[1];
|
||||
if (
|
||||
width !== undefined &&
|
||||
height !== undefined &&
|
||||
Number.isFinite(width) &&
|
||||
Number.isFinite(height) &&
|
||||
width > 0 &&
|
||||
height > 0
|
||||
) {
|
||||
return Math.max(1, Math.min(width, height));
|
||||
}
|
||||
|
||||
return fallbackNormalizationPixels;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,36 +1,6 @@
|
|||
import type { VibePreset } from '../vibes';
|
||||
import type { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
import type { GardenAudioVibeProfile } from './garden-audio-config';
|
||||
|
||||
export const PITCH_SEMITONES_PER_OCTAVE = 12;
|
||||
|
||||
const chordVoicings = {
|
||||
majorOpen: [0, 7, 12, 16],
|
||||
minorOpen: [0, 7, 12, 15],
|
||||
majorClosed: [0, 4, 7, 12, 16],
|
||||
minorClosed: [0, 3, 7, 12, 15],
|
||||
};
|
||||
|
||||
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio;
|
||||
|
||||
export const getChordIntervals = (
|
||||
chord: GardenAudioChord,
|
||||
openVoicing: boolean
|
||||
): Array<number> => {
|
||||
if (openVoicing) {
|
||||
return chord.quality === 'major' ? chordVoicings.majorOpen : chordVoicings.minorOpen;
|
||||
}
|
||||
|
||||
return chord.quality === 'major'
|
||||
? chordVoicings.majorClosed
|
||||
: chordVoicings.minorClosed;
|
||||
};
|
||||
|
||||
export const degreeToSemitone = (
|
||||
profile: GardenAudioVibeProfile,
|
||||
degree: number
|
||||
): number => {
|
||||
const scaleIndex =
|
||||
((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
|
||||
const octave = Math.floor(degree / profile.scale.length);
|
||||
return profile.scale[scaleIndex] + octave * PITCH_SEMITONES_PER_OCTAVE;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export const GENERATIVE_LOOKAHEAD_SECONDS = 0.3;
|
||||
export const GENERATIVE_START_DELAY_SECONDS = 0.02;
|
||||
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
|
||||
|
|
@ -9,9 +9,9 @@ export interface GardenAudioStroke {
|
|||
vibe: VibePreset;
|
||||
from: ArrayLike<number>;
|
||||
to: ArrayLike<number>;
|
||||
canvasSize?: ArrayLike<number>;
|
||||
canvasSize: ArrayLike<number>;
|
||||
isErasing: boolean;
|
||||
elapsedSeconds?: number;
|
||||
elapsedSeconds: number;
|
||||
}
|
||||
|
||||
export interface GardenAudioStartOptions {
|
||||
|
|
@ -23,12 +23,6 @@ export interface LoadedPianoSample {
|
|||
buffer: AudioBuffer;
|
||||
}
|
||||
|
||||
export interface ActivePianoVoice {
|
||||
gain: GainNode;
|
||||
source: AudioScheduledSourceNode;
|
||||
stopAt: number;
|
||||
}
|
||||
|
||||
export interface PianoNote {
|
||||
midi: number;
|
||||
velocity: number;
|
||||
|
|
|
|||
|
|
@ -16,18 +16,12 @@ import { GenerativePianoEngine } from './generative-piano';
|
|||
import { NoiseBurstPlayer } from './noise-burst-player';
|
||||
import { PianoSampler } from './piano-sampler';
|
||||
|
||||
export type {
|
||||
GardenAudioSnapshot,
|
||||
GardenAudioStartOptions,
|
||||
GardenAudioStroke,
|
||||
} from './garden-audio-types';
|
||||
|
||||
type AudioLifecycle = 'idle' | 'started' | 'destroyed';
|
||||
|
||||
const muteGain = 0.0001;
|
||||
const muteRampSeconds = 0.02;
|
||||
const brushUpPianoFinishSeconds = 1.2;
|
||||
const brushUpPianoFadeSeconds = 1.1;
|
||||
const brushUpPianoBusFadeSeconds = 2.4;
|
||||
const brushUpPianoBusFadeSettleSeconds = 3.2;
|
||||
const vibeChangeStingerMinIntervalSeconds = 0.45;
|
||||
|
||||
export class GardenAudio {
|
||||
|
|
@ -40,12 +34,12 @@ export class GardenAudio {
|
|||
|
||||
private currentVibeId: VibeId | null = null;
|
||||
private lifecycle: AudioLifecycle = 'idle';
|
||||
private isReleasingPiano = false;
|
||||
private isMuted = false;
|
||||
private isGestureActive = false;
|
||||
private isPianoStoppedAfterGesture = false;
|
||||
private fadePianoAfter: number | null = null;
|
||||
private fadePianoAt: number | null = null;
|
||||
private masterVolume: number;
|
||||
private stopPianoAfter: number | null = null;
|
||||
private stopPianoAt: number | null = null;
|
||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
|
||||
|
|
@ -75,33 +69,16 @@ export class GardenAudio {
|
|||
? muteRampSeconds
|
||||
: this.config.fadeInSeconds;
|
||||
const needsResume = context.state !== 'running' && context.state !== 'closed';
|
||||
let resumePromise: Promise<void> | null = null;
|
||||
|
||||
if (isUserGesture) {
|
||||
this.graph.startMediaElementOutput();
|
||||
this.graph.unlock();
|
||||
}
|
||||
|
||||
if (needsResume) {
|
||||
if (!isUserGesture) {
|
||||
return;
|
||||
}
|
||||
resumePromise = context.resume();
|
||||
}
|
||||
|
||||
if (isUserGesture) {
|
||||
this.graph.unlock();
|
||||
}
|
||||
|
||||
if (resumePromise) {
|
||||
void resumePromise
|
||||
void context
|
||||
.resume()
|
||||
.then(() => {
|
||||
if (this.graph.context === context && this.lifecycle !== 'destroyed') {
|
||||
this.graph.unlock();
|
||||
this.completeStart(vibe, {
|
||||
context,
|
||||
startupRampSeconds,
|
||||
});
|
||||
this.completeStart(vibe, { context, startupRampSeconds });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
|
@ -113,10 +90,7 @@ export class GardenAudio {
|
|||
return;
|
||||
}
|
||||
|
||||
this.completeStart(vibe, {
|
||||
context,
|
||||
startupRampSeconds,
|
||||
});
|
||||
this.completeStart(vibe, { context, startupRampSeconds });
|
||||
}
|
||||
|
||||
private completeStart(
|
||||
|
|
@ -207,23 +181,21 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
this.isGestureActive = true;
|
||||
this.isPianoStoppedAfterGesture = false;
|
||||
this.fadePianoAfter = null;
|
||||
this.stopPianoAfter = null;
|
||||
this.gestureState.beginGesture();
|
||||
this.isReleasingPiano = false;
|
||||
this.fadePianoAt = null;
|
||||
this.stopPianoAt = null;
|
||||
this.graph.setPianoBusGainScale(1, this.config.fadeInSeconds);
|
||||
this.gestureState.reset();
|
||||
this.energy.beginGesture(context.currentTime);
|
||||
this.pianoEngine.beginGesture();
|
||||
}
|
||||
|
||||
public endGesture(): void {
|
||||
this.gestureState.endGesture();
|
||||
this.gestureState.reset();
|
||||
this.isGestureActive = false;
|
||||
const context = this.graph.context;
|
||||
this.isPianoStoppedAfterGesture = true;
|
||||
this.fadePianoAfter = context
|
||||
? context.currentTime + brushUpPianoFinishSeconds
|
||||
: null;
|
||||
this.stopPianoAfter = null;
|
||||
this.isReleasingPiano = true;
|
||||
this.fadePianoAt = null;
|
||||
this.stopPianoAt = null;
|
||||
this.energy.endGesture();
|
||||
this.pianoEngine.endGesture();
|
||||
}
|
||||
|
|
@ -241,17 +213,8 @@ export class GardenAudio {
|
|||
this.energy.silence();
|
||||
}
|
||||
|
||||
if (!this.isGestureActive && this.isPianoStoppedAfterGesture) {
|
||||
if (this.fadePianoAfter !== null && context.currentTime >= this.fadePianoAfter) {
|
||||
this.piano.fadeAll(brushUpPianoFadeSeconds);
|
||||
this.fadePianoAfter = null;
|
||||
this.stopPianoAfter = context.currentTime + brushUpPianoFadeSeconds;
|
||||
}
|
||||
if (this.stopPianoAfter !== null && context.currentTime >= this.stopPianoAfter) {
|
||||
this.piano.stopAll();
|
||||
this.pianoEngine.reset();
|
||||
this.stopPianoAfter = null;
|
||||
}
|
||||
if (!this.isGestureActive && this.isReleasingPiano) {
|
||||
this.updatePianoRelease(snapshot.vibe, context.currentTime);
|
||||
this.updateDelay(snapshot);
|
||||
return;
|
||||
}
|
||||
|
|
@ -287,12 +250,11 @@ export class GardenAudio {
|
|||
const strokeEnergy = frame.activity;
|
||||
|
||||
if (stroke.isErasing) {
|
||||
this.energy.recordEraserStroke();
|
||||
this.playEraser(strokeEnergy, now);
|
||||
return;
|
||||
}
|
||||
|
||||
this.energy.recordStroke(strokeEnergy, now);
|
||||
this.energy.recordStroke(strokeEnergy);
|
||||
this.pianoEngine.recordStroke({
|
||||
vibe: stroke.vibe,
|
||||
now,
|
||||
|
|
@ -311,9 +273,9 @@ export class GardenAudio {
|
|||
this.pianoEngine.reset();
|
||||
this.currentVibeId = null;
|
||||
this.isGestureActive = false;
|
||||
this.isPianoStoppedAfterGesture = false;
|
||||
this.fadePianoAfter = null;
|
||||
this.stopPianoAfter = null;
|
||||
this.isReleasingPiano = false;
|
||||
this.fadePianoAt = null;
|
||||
this.stopPianoAt = null;
|
||||
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
|
@ -333,6 +295,25 @@ export class GardenAudio {
|
|||
this.pianoEngine.playVibeChangeStinger(vibe, now);
|
||||
}
|
||||
|
||||
private updatePianoRelease(vibe: VibePreset, now: number): void {
|
||||
if (this.fadePianoAt === null && this.stopPianoAt === null) {
|
||||
this.fadePianoAt = this.pianoEngine.release(vibe, now);
|
||||
}
|
||||
|
||||
if (this.fadePianoAt !== null && now >= this.fadePianoAt) {
|
||||
this.graph.setPianoBusGainScale(0, brushUpPianoBusFadeSeconds);
|
||||
this.fadePianoAt = null;
|
||||
this.stopPianoAt = now + brushUpPianoBusFadeSettleSeconds;
|
||||
}
|
||||
|
||||
if (this.stopPianoAt !== null && now >= this.stopPianoAt) {
|
||||
this.piano.stopAll();
|
||||
this.pianoEngine.reset();
|
||||
this.stopPianoAt = null;
|
||||
this.isReleasingPiano = false;
|
||||
}
|
||||
}
|
||||
|
||||
private playEraser(activity: number, now: number): void {
|
||||
if (!this.graph.context) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,16 @@ interface GenerativePianoTuning {
|
|||
pans: [number, number, number];
|
||||
delaySends: [number, number, number];
|
||||
lowpassExpression: number;
|
||||
noteDurationSeconds: number;
|
||||
spacingSeconds: number;
|
||||
};
|
||||
releaseResolution: {
|
||||
durationSeconds: number;
|
||||
fadeAfterSeconds: number;
|
||||
velocities: [number, number, number];
|
||||
delaySend: number;
|
||||
lowpassExpression: number;
|
||||
strumSeconds: number;
|
||||
};
|
||||
highActivityExtra: {
|
||||
barOffset: number;
|
||||
|
|
@ -67,7 +77,6 @@ interface GenerativePianoTuning {
|
|||
delaySend: number;
|
||||
};
|
||||
touchNote: {
|
||||
registerBiasManiaAmount: number;
|
||||
velocityBase: number;
|
||||
velocityStrengthWeight: number;
|
||||
durationBaseSeconds: number;
|
||||
|
|
@ -80,7 +89,6 @@ interface GenerativePianoTuning {
|
|||
initialMotifOffset: number;
|
||||
energyDecaySeconds: number;
|
||||
maniaDecaySeconds: number;
|
||||
fadeMinimumLifetimeSeconds: number;
|
||||
layerIntensityBase: number;
|
||||
layerIntensityManiaWeight: number;
|
||||
frameActivityWeight: number;
|
||||
|
|
@ -106,7 +114,6 @@ interface GenerativePianoTuning {
|
|||
lowpassBaseExpression: number;
|
||||
lowpassIntensityWeight: number;
|
||||
lowpassManiaWeight: number;
|
||||
manicThreshold: number;
|
||||
intenseThreshold: number;
|
||||
activeThreshold: number;
|
||||
};
|
||||
|
|
@ -133,8 +140,6 @@ interface GenerativePianoTuning {
|
|||
highOffset: number;
|
||||
mediumOffset: number;
|
||||
lowOffset: number;
|
||||
minOffset: number;
|
||||
maxOffset: number;
|
||||
};
|
||||
registerBias: {
|
||||
maniaShiftSemitones: number;
|
||||
|
|
@ -171,8 +176,6 @@ interface GenerativePianoTuning {
|
|||
gestureAccentMinIntervalSeconds: number;
|
||||
strokeAccentMinSteps: number;
|
||||
strokeAccentThreshold: number;
|
||||
stingerDurationSeconds: number;
|
||||
stingerSpacingSeconds: number;
|
||||
maxBrushPhraseLayers: number;
|
||||
maxBrushStreamNotesPerBar: number;
|
||||
brushLayerBaseSeconds: number;
|
||||
|
|
@ -181,7 +184,6 @@ interface GenerativePianoTuning {
|
|||
brushStreamIdleIntervalBeats: number;
|
||||
brushStreamActiveIntervalBeats: number;
|
||||
brushStreamIntenseIntervalBeats: number;
|
||||
brushStreamManicIntervalBeats: number;
|
||||
brushMotifMaxSteps: number;
|
||||
brushMotifCanonDelaySeconds: number;
|
||||
padDurationBarScale: number;
|
||||
|
|
@ -236,6 +238,16 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
|||
pans: [-0.16, 0, 0.16],
|
||||
delaySends: [0.012, 0.014, 0.016],
|
||||
lowpassExpression: 0.35,
|
||||
noteDurationSeconds: 1.1,
|
||||
spacingSeconds: 0.08,
|
||||
},
|
||||
releaseResolution: {
|
||||
durationSeconds: 3.4,
|
||||
fadeAfterSeconds: 2.4,
|
||||
velocities: [0.064, 0.05, 0.038],
|
||||
delaySend: 0.018,
|
||||
lowpassExpression: 0.34,
|
||||
strumSeconds: 0.055,
|
||||
},
|
||||
highActivityExtra: {
|
||||
barOffset: 1,
|
||||
|
|
@ -284,7 +296,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
|||
delaySend: 0.012,
|
||||
},
|
||||
touchNote: {
|
||||
registerBiasManiaAmount: 0,
|
||||
velocityBase: 0.14,
|
||||
velocityStrengthWeight: 0.11,
|
||||
durationBaseSeconds: 0.55,
|
||||
|
|
@ -297,7 +308,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
|||
initialMotifOffset: -1,
|
||||
energyDecaySeconds: 0.72,
|
||||
maniaDecaySeconds: 0.54,
|
||||
fadeMinimumLifetimeSeconds: 0.001,
|
||||
layerIntensityBase: 0.8,
|
||||
layerIntensityManiaWeight: 0.42,
|
||||
frameActivityWeight: 0.42,
|
||||
|
|
@ -323,7 +333,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
|||
lowpassBaseExpression: 0.39,
|
||||
lowpassIntensityWeight: 0.48,
|
||||
lowpassManiaWeight: 0.18,
|
||||
manicThreshold: 0.85,
|
||||
intenseThreshold: 0.62,
|
||||
activeThreshold: 0.34,
|
||||
},
|
||||
|
|
@ -350,8 +359,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
|||
highOffset: 1,
|
||||
mediumOffset: 0,
|
||||
lowOffset: -1,
|
||||
minOffset: -3,
|
||||
maxOffset: 4,
|
||||
},
|
||||
registerBias: {
|
||||
maniaShiftSemitones: 4,
|
||||
|
|
@ -388,8 +395,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
|||
gestureAccentMinIntervalSeconds: 2.5,
|
||||
strokeAccentMinSteps: 12,
|
||||
strokeAccentThreshold: 0.58,
|
||||
stingerSpacingSeconds: 0.08,
|
||||
stingerDurationSeconds: 1.1,
|
||||
maxBrushPhraseLayers: 3,
|
||||
maxBrushStreamNotesPerBar: 9,
|
||||
brushLayerBaseSeconds: 5.5,
|
||||
|
|
@ -398,7 +403,6 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
|||
brushStreamIdleIntervalBeats: 2,
|
||||
brushStreamActiveIntervalBeats: 1,
|
||||
brushStreamIntenseIntervalBeats: 0.5,
|
||||
brushStreamManicIntervalBeats: 0.5,
|
||||
brushMotifMaxSteps: 8,
|
||||
brushMotifCanonDelaySeconds: 0.055,
|
||||
padDurationBarScale: 0.46,
|
||||
|
|
|
|||
|
|
@ -5,17 +5,7 @@ import type {
|
|||
GardenAudioConfig,
|
||||
GardenAudioVibeProfile,
|
||||
} from './garden-audio-config';
|
||||
import {
|
||||
degreeToSemitone,
|
||||
getChordIntervals,
|
||||
getVibeProfile,
|
||||
PITCH_SEMITONES_PER_OCTAVE,
|
||||
} from './garden-audio-music';
|
||||
import {
|
||||
GENERATIVE_LOOKAHEAD_SECONDS,
|
||||
GENERATIVE_START_DELAY_SECONDS,
|
||||
PIANO_SCHEDULE_AHEAD_SECONDS,
|
||||
} from './garden-audio-scheduling';
|
||||
import { getVibeProfile, PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
|
||||
import type { PianoNote } from './garden-audio-types';
|
||||
import {
|
||||
generativePianoTuning,
|
||||
|
|
@ -23,6 +13,39 @@ import {
|
|||
type GardenAudioRegister,
|
||||
type GardenAudioStylePool,
|
||||
} from './generative-piano-tuning';
|
||||
import { PIANO_SCHEDULE_AHEAD_SECONDS } from './piano-sampler';
|
||||
|
||||
const GENERATIVE_LOOKAHEAD_SECONDS = 0.3;
|
||||
const GENERATIVE_START_DELAY_SECONDS = 0.02;
|
||||
|
||||
const chordVoicings = {
|
||||
majorOpen: [0, 7, 12, 16],
|
||||
minorOpen: [0, 7, 12, 15],
|
||||
majorClosed: [0, 4, 7, 12, 16],
|
||||
minorClosed: [0, 3, 7, 12, 15],
|
||||
};
|
||||
|
||||
const getChordIntervals = (
|
||||
chord: GardenAudioChord,
|
||||
openVoicing: boolean
|
||||
): Array<number> => {
|
||||
if (openVoicing) {
|
||||
return chord.quality === 'major' ? chordVoicings.majorOpen : chordVoicings.minorOpen;
|
||||
}
|
||||
return chord.quality === 'major'
|
||||
? chordVoicings.majorClosed
|
||||
: chordVoicings.minorClosed;
|
||||
};
|
||||
|
||||
const degreeToSemitone = (
|
||||
profile: GardenAudioVibeProfile,
|
||||
degree: number
|
||||
): number => {
|
||||
const scaleIndex =
|
||||
((degree % profile.scale.length) + profile.scale.length) % profile.scale.length;
|
||||
const octave = Math.floor(degree / profile.scale.length);
|
||||
return profile.scale[scaleIndex] + octave * PITCH_SEMITONES_PER_OCTAVE;
|
||||
};
|
||||
|
||||
type GardenAudioStyleIndex = 0 | 1 | 2;
|
||||
|
||||
|
|
@ -124,6 +147,23 @@ export class GenerativePianoEngine {
|
|||
this.isWaitingForGestureAccent = false;
|
||||
}
|
||||
|
||||
public release(vibe: VibePreset, now: number): number {
|
||||
this.prime(now);
|
||||
this.isWaitingForGestureAccent = false;
|
||||
|
||||
const profile = getVibeProfile(vibe);
|
||||
const releaseStep = this.getReleaseResolutionStep(now);
|
||||
const releaseStart = Math.max(now, this.getTimeForStep(releaseStep));
|
||||
|
||||
this.playReleaseResolution(profile, releaseStep, releaseStart);
|
||||
this.nextBeatStep = null;
|
||||
this.nextBrushStreamStep = null;
|
||||
this.brushPhraseLayers = [];
|
||||
this.brushStreamNoteCountsByBar.clear();
|
||||
|
||||
return releaseStart + this.generation.releaseResolution.fadeAfterSeconds;
|
||||
}
|
||||
|
||||
private recordTouchDown({
|
||||
vibe,
|
||||
now,
|
||||
|
|
@ -234,47 +274,27 @@ export class GenerativePianoEngine {
|
|||
const chord = this.getChord(profile, 0);
|
||||
const intervals = getChordIntervals(chord, true);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
const notes = [
|
||||
{
|
||||
midi: this.chooseMidi(
|
||||
{ baseMidi: rootMidi, offsets: [0] },
|
||||
this.generation.padRegisters[0]
|
||||
),
|
||||
velocity: this.generation.vibeChangeStinger.velocities[0],
|
||||
pan: this.generation.vibeChangeStinger.pans[0],
|
||||
delaySend: this.generation.vibeChangeStinger.delaySends[0],
|
||||
},
|
||||
{
|
||||
midi: this.chooseMidi(
|
||||
{ baseMidi: rootMidi, offsets: [intervals[1], intervals[2]] },
|
||||
this.generation.padRegisters[1]
|
||||
),
|
||||
velocity: this.generation.vibeChangeStinger.velocities[1],
|
||||
pan: this.generation.vibeChangeStinger.pans[1],
|
||||
delaySend: this.generation.vibeChangeStinger.delaySends[1],
|
||||
},
|
||||
{
|
||||
midi: this.chooseMidi(
|
||||
{ baseMidi: rootMidi, offsets: [intervals[3], intervals[2]] },
|
||||
this.generation.padRegisters[2]
|
||||
),
|
||||
velocity: this.generation.vibeChangeStinger.velocities[2],
|
||||
pan: this.generation.vibeChangeStinger.pans[2],
|
||||
delaySend: this.generation.vibeChangeStinger.delaySends[2],
|
||||
},
|
||||
const stinger = this.generation.vibeChangeStinger;
|
||||
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
|
||||
[0],
|
||||
[intervals[1], intervals[2]],
|
||||
[intervals[3], intervals[2]],
|
||||
];
|
||||
|
||||
notes.forEach((note, index) => {
|
||||
offsetsByVoice.forEach((offsets, index) => {
|
||||
const midi = this.chooseMidi(
|
||||
{ baseMidi: rootMidi, offsets },
|
||||
this.generation.padRegisters[index]
|
||||
);
|
||||
this.playNote({
|
||||
...note,
|
||||
durationSeconds: this.generation.stingerDurationSeconds,
|
||||
midi,
|
||||
velocity: stinger.velocities[index],
|
||||
pan: stinger.pans[index],
|
||||
delaySend: stinger.delaySends[index],
|
||||
durationSeconds: stinger.noteDurationSeconds,
|
||||
role: 'stinger',
|
||||
lowpassHz: this.getLowpassHz(
|
||||
profile,
|
||||
note.midi,
|
||||
this.generation.vibeChangeStinger.lowpassExpression
|
||||
),
|
||||
startTime: now + index * this.generation.stingerSpacingSeconds,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, stinger.lowpassExpression),
|
||||
startTime: now + index * stinger.spacingSeconds,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -285,12 +305,8 @@ export class GenerativePianoEngine {
|
|||
this.isWaitingForGestureAccent = false;
|
||||
this.lastGestureAccentAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastStrokeAccentStep = Number.NEGATIVE_INFINITY;
|
||||
this.lastMidiByStyle[0] = null;
|
||||
this.lastMidiByStyle[1] = null;
|
||||
this.lastMidiByStyle[2] = null;
|
||||
this.lastPadMidiByVoice[0] = null;
|
||||
this.lastPadMidiByVoice[1] = null;
|
||||
this.lastPadMidiByVoice[2] = null;
|
||||
this.lastMidiByStyle.fill(null);
|
||||
this.lastPadMidiByVoice.fill(null);
|
||||
this.brushPhraseLayers = [];
|
||||
this.nextBrushStreamStep = null;
|
||||
this.brushStreamNoteIndex = 0;
|
||||
|
|
@ -400,6 +416,37 @@ export class GenerativePianoEngine {
|
|||
});
|
||||
}
|
||||
|
||||
private playReleaseResolution(
|
||||
profile: GardenAudioVibeProfile,
|
||||
stepIndex: number,
|
||||
startTime: number
|
||||
): void {
|
||||
const chord = this.getChord(profile, this.getBarIndexForStep(stepIndex));
|
||||
const intervals = getChordIntervals(chord, true);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
const release = this.generation.releaseResolution;
|
||||
const offsetsByVoice: ReadonlyArray<ReadonlyArray<number>> = [
|
||||
[0],
|
||||
[intervals[1], intervals[2]],
|
||||
[intervals[3], intervals[2]],
|
||||
];
|
||||
|
||||
offsetsByVoice.forEach((offsets, index) => {
|
||||
const register = this.generation.padRegisters[index];
|
||||
const midi = this.chooseMidi({ baseMidi: rootMidi, offsets }, register, null, false);
|
||||
this.playNote({
|
||||
midi,
|
||||
velocity: release.velocities[index],
|
||||
startTime: startTime + index * release.strumSeconds,
|
||||
durationSeconds: release.durationSeconds,
|
||||
pan: register.pan,
|
||||
role: 'pad',
|
||||
delaySend: release.delaySend,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, release.lowpassExpression),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private playSupportNote(
|
||||
profile: GardenAudioVibeProfile,
|
||||
barIndex: number,
|
||||
|
|
@ -546,10 +593,6 @@ export class GenerativePianoEngine {
|
|||
}): void {
|
||||
const profile = getVibeProfile(vibe);
|
||||
const pool = this.generation.stylePools[styleIndex];
|
||||
const register = this.getBiasedRegister(
|
||||
pool,
|
||||
this.generation.touchNote.registerBiasManiaAmount
|
||||
);
|
||||
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
|
||||
const chordIntervals = getChordIntervals(chord, false);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
|
|
@ -558,7 +601,7 @@ export class GenerativePianoEngine {
|
|||
baseMidi: rootMidi,
|
||||
offsets: this.getSupportOffsets(chordIntervals, styleIndex),
|
||||
},
|
||||
register,
|
||||
pool,
|
||||
this.lastMidiByStyle[styleIndex],
|
||||
true
|
||||
);
|
||||
|
|
@ -877,41 +920,26 @@ export class GenerativePianoEngine {
|
|||
|
||||
private getBrushStreamIntervalSteps(intensity: number): number {
|
||||
const intervalBeats =
|
||||
intensity >= this.generation.brushStream.manicThreshold
|
||||
? this.generation.brushStreamManicIntervalBeats
|
||||
: intensity >= this.generation.brushStream.intenseThreshold
|
||||
? this.generation.brushStreamIntenseIntervalBeats
|
||||
: intensity >= this.generation.brushStream.activeThreshold
|
||||
? this.generation.brushStreamActiveIntervalBeats
|
||||
: this.generation.brushStreamIdleIntervalBeats;
|
||||
intensity >= this.generation.brushStream.intenseThreshold
|
||||
? this.generation.brushStreamIntenseIntervalBeats
|
||||
: intensity >= this.generation.brushStream.activeThreshold
|
||||
? this.generation.brushStreamActiveIntervalBeats
|
||||
: this.generation.brushStreamIdleIntervalBeats;
|
||||
return Math.max(1, Math.round(intervalBeats * this.config.rhythm.stepsPerBeat));
|
||||
}
|
||||
|
||||
private getBrushPhraseFade(layer: BrushPhraseLayer, startTime: number): number {
|
||||
const lifetimeSeconds = layer.expiresAt - layer.startedAt;
|
||||
const lifetimeSeconds = Math.max(0.001, layer.expiresAt - layer.startedAt);
|
||||
const ageSeconds = startTime - layer.startedAt;
|
||||
return clamp01(
|
||||
1 -
|
||||
ageSeconds /
|
||||
Math.max(
|
||||
this.generation.brushPhrase.fadeMinimumLifetimeSeconds,
|
||||
lifetimeSeconds
|
||||
)
|
||||
);
|
||||
return clamp01(1 - ageSeconds / lifetimeSeconds);
|
||||
}
|
||||
|
||||
private getMotifOffset(strength: number): number {
|
||||
const energyStep =
|
||||
strength >= this.generation.brushMotif.highThreshold
|
||||
? this.generation.brushMotif.highOffset
|
||||
: strength >= this.generation.brushMotif.mediumThreshold
|
||||
? this.generation.brushMotif.mediumOffset
|
||||
: this.generation.brushMotif.lowOffset;
|
||||
return clamp(
|
||||
energyStep,
|
||||
this.generation.brushMotif.minOffset,
|
||||
this.generation.brushMotif.maxOffset
|
||||
);
|
||||
return strength >= this.generation.brushMotif.highThreshold
|
||||
? this.generation.brushMotif.highOffset
|
||||
: strength >= this.generation.brushMotif.mediumThreshold
|
||||
? this.generation.brushMotif.mediumOffset
|
||||
: this.generation.brushMotif.lowOffset;
|
||||
}
|
||||
|
||||
private getBrushMotifDegrees({
|
||||
|
|
@ -1185,6 +1213,19 @@ export class GenerativePianoEngine {
|
|||
);
|
||||
}
|
||||
|
||||
private getReleaseResolutionStep(startTime: number): number {
|
||||
const currentStep = this.getStepIndexAtTime(startTime);
|
||||
const nextBeatStep =
|
||||
Math.ceil((currentStep + 1) / this.config.rhythm.stepsPerBeat) *
|
||||
this.config.rhythm.stepsPerBeat;
|
||||
const nextBarStep =
|
||||
Math.ceil((currentStep + 1) / this.config.rhythm.stepsPerBar) *
|
||||
this.config.rhythm.stepsPerBar;
|
||||
const barIsClose = nextBarStep - currentStep <= this.config.rhythm.stepsPerBeat * 2;
|
||||
|
||||
return barIsClose ? nextBarStep : nextBeatStep;
|
||||
}
|
||||
|
||||
private getNextBarTimeAt(startTime: number): number {
|
||||
const nextBarStep =
|
||||
Math.ceil(this.getStepIndexAtTime(startTime) / this.config.rhythm.stepsPerBar) *
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createAudioPanNode } from './audio-pan-node';
|
||||
import { clamp } from '../utils/math';
|
||||
import type { GardenAudioGraph } from './garden-audio-graph';
|
||||
import type { NoiseBurst } from './garden-audio-types';
|
||||
|
||||
|
|
@ -15,9 +15,8 @@ export class NoiseBurstPlayer {
|
|||
public constructor(private readonly graph: GardenAudioGraph) {}
|
||||
|
||||
public play({ startTime, durationSeconds, gain, filterHz, pan }: NoiseBurst): void {
|
||||
const { context, eventBus, noiseBus, noiseBuffer } = this.graph;
|
||||
const outputBus = noiseBus ?? eventBus;
|
||||
if (!context || !outputBus || !noiseBuffer) {
|
||||
const { context, noiseBus, noiseBuffer } = this.graph;
|
||||
if (!context || !noiseBus || !noiseBuffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -28,7 +27,7 @@ export class NoiseBurstPlayer {
|
|||
const source = context.createBufferSource();
|
||||
const filter = context.createBiquadFilter();
|
||||
const envelope = context.createGain();
|
||||
const panNode = createAudioPanNode(context, pan, scheduledStart);
|
||||
const panner = context.createStereoPanner();
|
||||
const stopAt = scheduledStart + durationSeconds;
|
||||
|
||||
source.buffer = noiseBuffer;
|
||||
|
|
@ -41,10 +40,11 @@ export class NoiseBurstPlayer {
|
|||
scheduledStart + noiseBurstTuning.attackSeconds
|
||||
);
|
||||
envelope.gain.exponentialRampToValueAtTime(noiseBurstTuning.silentGain, stopAt);
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||
source.connect(filter);
|
||||
filter.connect(envelope);
|
||||
envelope.connect(panNode.input);
|
||||
panNode.output.connect(outputBus);
|
||||
envelope.connect(panner);
|
||||
panner.connect(noiseBus);
|
||||
source.start(scheduledStart, Math.random() * noiseBurstTuning.offsetRandomSeconds);
|
||||
source.stop(stopAt);
|
||||
source.addEventListener(
|
||||
|
|
@ -53,7 +53,7 @@ export class NoiseBurstPlayer {
|
|||
source.disconnect();
|
||||
filter.disconnect();
|
||||
envelope.disconnect();
|
||||
panNode.disconnect();
|
||||
panner.disconnect();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
import { clamp, clamp01 } from '../utils/math';
|
||||
import { createAudioPanNode } from './audio-pan-node';
|
||||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import type { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { PITCH_SEMITONES_PER_OCTAVE } from './garden-audio-music';
|
||||
import { PIANO_SCHEDULE_AHEAD_SECONDS } from './garden-audio-scheduling';
|
||||
import type {
|
||||
ActivePianoVoice,
|
||||
LoadedPianoSample,
|
||||
PianoNote,
|
||||
} from './garden-audio-types';
|
||||
import type { LoadedPianoSample, PianoNote } from './garden-audio-types';
|
||||
import { getLoadedPianoSamples, loadPianoSamples } from './piano-samples';
|
||||
|
||||
export const PIANO_SCHEDULE_AHEAD_SECONDS = 0.002;
|
||||
|
||||
type PianoLoadState = 'idle' | 'loading' | 'loaded';
|
||||
|
||||
interface ActivePianoVoice {
|
||||
gain: GainNode;
|
||||
source: AudioScheduledSourceNode;
|
||||
stopAt: number;
|
||||
}
|
||||
|
||||
const pianoSamplerTuning = {
|
||||
filterType: 'lowpass' as BiquadFilterType,
|
||||
filterQ: 0.7,
|
||||
|
|
@ -29,7 +31,6 @@ const pianoSamplerTuning = {
|
|||
|
||||
export class PianoSampler {
|
||||
private loadState: PianoLoadState = 'idle';
|
||||
private sampleLoadPromise: Promise<void> | null = null;
|
||||
private samples: Array<LoadedPianoSample> = [];
|
||||
private activeVoices: Array<ActivePianoVoice> = [];
|
||||
|
||||
|
|
@ -38,9 +39,9 @@ export class PianoSampler {
|
|||
private readonly graph: GardenAudioGraph
|
||||
) {}
|
||||
|
||||
public load(context: BaseAudioContext): Promise<void> {
|
||||
if (this.loadState === 'loaded') {
|
||||
return Promise.resolve();
|
||||
public loadIfIdle(context: BaseAudioContext): Promise<void> | null {
|
||||
if (this.loadState !== 'idle') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const loadedSamples = getLoadedPianoSamples();
|
||||
|
|
@ -50,33 +51,16 @@ export class PianoSampler {
|
|||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.sampleLoadPromise) {
|
||||
return this.sampleLoadPromise;
|
||||
}
|
||||
|
||||
this.loadState = 'loading';
|
||||
this.sampleLoadPromise = loadPianoSamples(context, undefined, {
|
||||
forceReload: true,
|
||||
})
|
||||
return loadPianoSamples(context)
|
||||
.then((samples) => {
|
||||
this.setSamples(samples);
|
||||
this.loadState = 'loaded';
|
||||
})
|
||||
.catch((error) => {
|
||||
this.loadState = 'idle';
|
||||
this.sampleLoadPromise = null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return this.sampleLoadPromise;
|
||||
}
|
||||
|
||||
public loadIfIdle(context: BaseAudioContext): Promise<void> | null {
|
||||
if (this.loadState !== 'idle') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.load(context);
|
||||
}
|
||||
|
||||
public play({
|
||||
|
|
@ -89,115 +73,114 @@ export class PianoSampler {
|
|||
delaySend = 0,
|
||||
lowpassHz = this.config.piano.lowpassHz,
|
||||
}: PianoNote): void {
|
||||
const { context, delayInput } = this.graph;
|
||||
const { context } = this.graph;
|
||||
const eventBus = this.graph.getPianoBus(role);
|
||||
if (!context || !eventBus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sample = this.findNearestSample(midi);
|
||||
if (!sample) {
|
||||
this.playSynthFallback({
|
||||
midi,
|
||||
velocity,
|
||||
startTime,
|
||||
durationSeconds,
|
||||
pan,
|
||||
role,
|
||||
delaySend,
|
||||
lowpassHz,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledStart = Math.max(
|
||||
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
|
||||
startTime
|
||||
);
|
||||
const noteVelocity = clamp01(velocity);
|
||||
const sample = this.findNearestSample(midi);
|
||||
|
||||
if (sample) {
|
||||
const noteGainValue = Math.max(
|
||||
pianoSamplerTuning.minGain,
|
||||
this.config.piano.gain * noteVelocity
|
||||
);
|
||||
const sustainSeconds =
|
||||
this.config.piano.sustainSeconds *
|
||||
(this.config.piano.sustainBase +
|
||||
noteVelocity * this.config.piano.sustainVelocityRange);
|
||||
const sustainAt =
|
||||
scheduledStart +
|
||||
Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
|
||||
const releaseAt = sustainAt + sustainSeconds;
|
||||
const stopAt = releaseAt + this.config.piano.releaseSeconds;
|
||||
const source = context.createBufferSource();
|
||||
|
||||
source.buffer = sample.buffer;
|
||||
source.playbackRate.setValueAtTime(
|
||||
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
|
||||
scheduledStart
|
||||
);
|
||||
|
||||
this.scheduleVoice({
|
||||
source,
|
||||
scheduledStart,
|
||||
stopAt,
|
||||
pan,
|
||||
lowpassHz,
|
||||
delaySend,
|
||||
eventBus,
|
||||
configureGainEnvelope: (gain) => {
|
||||
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + this.config.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
Math.max(
|
||||
pianoSamplerTuning.minGain,
|
||||
noteGainValue * this.config.piano.sustainLevel
|
||||
),
|
||||
sustainAt,
|
||||
Math.max(
|
||||
pianoSamplerTuning.minFadeSeconds,
|
||||
sustainSeconds * this.config.piano.sustainBase
|
||||
)
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
pianoSamplerTuning.minGain,
|
||||
releaseAt,
|
||||
this.config.piano.releaseSeconds
|
||||
);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const noteGainValue = Math.max(
|
||||
pianoSamplerTuning.minGain,
|
||||
this.config.piano.gain * noteVelocity
|
||||
this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale
|
||||
);
|
||||
const sustainSeconds =
|
||||
this.config.piano.sustainSeconds *
|
||||
(this.config.piano.sustainBase +
|
||||
noteVelocity * this.config.piano.sustainVelocityRange);
|
||||
const sustainAt =
|
||||
scheduledStart + Math.max(pianoSamplerTuning.minDurationSeconds, durationSeconds);
|
||||
const releaseAt = sustainAt + sustainSeconds;
|
||||
const releaseSeconds = this.config.piano.releaseSeconds;
|
||||
const stopAt = releaseAt + releaseSeconds;
|
||||
const source = context.createBufferSource();
|
||||
const filter = context.createBiquadFilter();
|
||||
const gain = context.createGain();
|
||||
const panNode = createAudioPanNode(context, pan, scheduledStart);
|
||||
let sendGain: GainNode | null = null;
|
||||
const releaseAt =
|
||||
scheduledStart +
|
||||
clamp(
|
||||
durationSeconds + this.config.piano.sustainSeconds * 0.5,
|
||||
pianoSamplerTuning.minDurationSeconds,
|
||||
pianoSamplerTuning.synthMaxDurationSeconds
|
||||
);
|
||||
const stopAt = releaseAt + this.config.piano.releaseSeconds;
|
||||
const source = context.createOscillator();
|
||||
|
||||
this.trimActiveVoices(scheduledStart);
|
||||
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
||||
const oldest = this.activeVoices.shift();
|
||||
if (oldest) {
|
||||
this.stopVoice(oldest, scheduledStart);
|
||||
}
|
||||
}
|
||||
source.type = pianoSamplerTuning.synthOscillatorType;
|
||||
source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart);
|
||||
|
||||
source.buffer = sample.buffer;
|
||||
source.playbackRate.setValueAtTime(
|
||||
Math.pow(2, (midi - sample.midi) / PITCH_SEMITONES_PER_OCTAVE),
|
||||
scheduledStart
|
||||
);
|
||||
filter.type = pianoSamplerTuning.filterType;
|
||||
filter.frequency.setValueAtTime(
|
||||
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
|
||||
scheduledStart
|
||||
);
|
||||
filter.Q.value = pianoSamplerTuning.filterQ;
|
||||
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + this.config.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
Math.max(
|
||||
pianoSamplerTuning.minGain,
|
||||
noteGainValue * this.config.piano.sustainLevel
|
||||
),
|
||||
sustainAt,
|
||||
Math.max(
|
||||
pianoSamplerTuning.minFadeSeconds,
|
||||
sustainSeconds * this.config.piano.sustainBase
|
||||
)
|
||||
);
|
||||
gain.gain.setTargetAtTime(pianoSamplerTuning.minGain, releaseAt, releaseSeconds);
|
||||
source.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(panNode.input);
|
||||
panNode.output.connect(eventBus);
|
||||
|
||||
if (delayInput && delaySend > 0) {
|
||||
sendGain = context.createGain();
|
||||
sendGain.gain.value = delaySend;
|
||||
panNode.output.connect(sendGain);
|
||||
sendGain.connect(delayInput);
|
||||
}
|
||||
|
||||
source.start(scheduledStart);
|
||||
source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
|
||||
this.activeVoices.push({ gain, source, stopAt });
|
||||
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
() => {
|
||||
source.disconnect();
|
||||
filter.disconnect();
|
||||
gain.disconnect();
|
||||
panNode.disconnect();
|
||||
sendGain?.disconnect();
|
||||
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
|
||||
this.scheduleVoice({
|
||||
source,
|
||||
scheduledStart,
|
||||
stopAt,
|
||||
pan,
|
||||
lowpassHz,
|
||||
delaySend,
|
||||
eventBus,
|
||||
configureGainEnvelope: (gain) => {
|
||||
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + this.config.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
pianoSamplerTuning.minGain,
|
||||
releaseAt,
|
||||
this.config.piano.releaseSeconds
|
||||
);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public stopAll(): void {
|
||||
|
|
@ -215,114 +198,64 @@ export class PianoSampler {
|
|||
this.activeVoices = [];
|
||||
}
|
||||
|
||||
public fadeAll(fadeSeconds: number): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
this.activeVoices = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const now = context.currentTime;
|
||||
const fadeDurationSeconds = Math.max(pianoSamplerTuning.minFadeSeconds, fadeSeconds);
|
||||
|
||||
this.trimActiveVoices(now);
|
||||
this.activeVoices.forEach((voice) => {
|
||||
this.fadeVoice(voice, now, fadeDurationSeconds);
|
||||
});
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.loadState = 'idle';
|
||||
this.sampleLoadPromise = null;
|
||||
this.samples = [];
|
||||
this.activeVoices = [];
|
||||
}
|
||||
|
||||
private findNearestSample(midi: number): LoadedPianoSample | null {
|
||||
if (this.samples.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.samples.reduce((nearest, sample) =>
|
||||
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
|
||||
);
|
||||
}
|
||||
|
||||
private playSynthFallback({
|
||||
midi,
|
||||
velocity,
|
||||
startTime,
|
||||
durationSeconds,
|
||||
private scheduleVoice({
|
||||
source,
|
||||
scheduledStart,
|
||||
stopAt,
|
||||
pan,
|
||||
role,
|
||||
delaySend = 0,
|
||||
lowpassHz = this.config.piano.lowpassHz,
|
||||
}: PianoNote): void {
|
||||
lowpassHz,
|
||||
delaySend,
|
||||
eventBus,
|
||||
configureGainEnvelope,
|
||||
}: {
|
||||
source: AudioScheduledSourceNode;
|
||||
scheduledStart: number;
|
||||
stopAt: number;
|
||||
pan: number;
|
||||
lowpassHz: number;
|
||||
delaySend: number;
|
||||
eventBus: GainNode;
|
||||
configureGainEnvelope: (gain: GainNode) => void;
|
||||
}): void {
|
||||
const { context, delayInput } = this.graph;
|
||||
const eventBus = this.graph.getPianoBus(role);
|
||||
if (!context || !eventBus) {
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduledStart = Math.max(
|
||||
context.currentTime + PIANO_SCHEDULE_AHEAD_SECONDS,
|
||||
startTime
|
||||
);
|
||||
const noteVelocity = clamp01(velocity);
|
||||
const noteGainValue = Math.max(
|
||||
pianoSamplerTuning.minGain,
|
||||
this.config.piano.gain * noteVelocity * pianoSamplerTuning.synthGainScale
|
||||
);
|
||||
const releaseAt =
|
||||
scheduledStart +
|
||||
clamp(
|
||||
durationSeconds + this.config.piano.sustainSeconds * 0.5,
|
||||
pianoSamplerTuning.minDurationSeconds,
|
||||
pianoSamplerTuning.synthMaxDurationSeconds
|
||||
);
|
||||
const stopAt = releaseAt + this.config.piano.releaseSeconds;
|
||||
const source = context.createOscillator();
|
||||
const filter = context.createBiquadFilter();
|
||||
const gain = context.createGain();
|
||||
const panNode = createAudioPanNode(context, pan, scheduledStart);
|
||||
const panner = context.createStereoPanner();
|
||||
let sendGain: GainNode | null = null;
|
||||
|
||||
this.trimActiveVoices(scheduledStart);
|
||||
while (this.activeVoices.length >= this.config.piano.maxVoices) {
|
||||
const oldest = this.activeVoices.shift();
|
||||
if (oldest) {
|
||||
this.stopVoice(oldest, scheduledStart);
|
||||
}
|
||||
this.stopVoice(this.activeVoices.shift() as ActivePianoVoice, scheduledStart);
|
||||
}
|
||||
|
||||
source.type = pianoSamplerTuning.synthOscillatorType;
|
||||
source.frequency.setValueAtTime(getMidiFrequency(midi), scheduledStart);
|
||||
filter.type = pianoSamplerTuning.filterType;
|
||||
filter.frequency.setValueAtTime(
|
||||
clamp(lowpassHz, this.config.piano.lowpassMinHz, this.config.piano.lowpassMaxHz),
|
||||
scheduledStart
|
||||
);
|
||||
filter.Q.value = pianoSamplerTuning.filterQ;
|
||||
gain.gain.setValueAtTime(pianoSamplerTuning.minGain, scheduledStart);
|
||||
gain.gain.exponentialRampToValueAtTime(
|
||||
noteGainValue,
|
||||
scheduledStart + this.config.piano.gainAttackSeconds
|
||||
);
|
||||
gain.gain.setTargetAtTime(
|
||||
pianoSamplerTuning.minGain,
|
||||
releaseAt,
|
||||
this.config.piano.releaseSeconds
|
||||
);
|
||||
panner.pan.setValueAtTime(clamp(pan, -1, 1), scheduledStart);
|
||||
configureGainEnvelope(gain);
|
||||
|
||||
source.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(panNode.input);
|
||||
panNode.output.connect(eventBus);
|
||||
gain.connect(panner);
|
||||
panner.connect(eventBus);
|
||||
|
||||
if (delayInput && delaySend > 0) {
|
||||
sendGain = context.createGain();
|
||||
sendGain.gain.value = delaySend;
|
||||
panNode.output.connect(sendGain);
|
||||
panner.connect(sendGain);
|
||||
sendGain.connect(delayInput);
|
||||
}
|
||||
|
||||
|
|
@ -336,7 +269,7 @@ export class PianoSampler {
|
|||
source.disconnect();
|
||||
filter.disconnect();
|
||||
gain.disconnect();
|
||||
panNode.disconnect();
|
||||
panner.disconnect();
|
||||
sendGain?.disconnect();
|
||||
this.activeVoices = this.activeVoices.filter((voice) => voice.gain !== gain);
|
||||
},
|
||||
|
|
@ -344,6 +277,16 @@ export class PianoSampler {
|
|||
);
|
||||
}
|
||||
|
||||
private findNearestSample(midi: number): LoadedPianoSample | null {
|
||||
if (this.samples.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.samples.reduce((nearest, sample) =>
|
||||
Math.abs(sample.midi - midi) < Math.abs(nearest.midi - midi) ? sample : nearest
|
||||
);
|
||||
}
|
||||
|
||||
private trimActiveVoices(now: number): void {
|
||||
this.activeVoices = this.activeVoices.filter((voice) => voice.stopAt > now);
|
||||
}
|
||||
|
|
@ -358,32 +301,7 @@ export class PianoSampler {
|
|||
pianoSamplerTuning.voiceStealFadeSeconds
|
||||
);
|
||||
voice.stopAt = stopAt;
|
||||
try {
|
||||
voice.source.stop(stopAt);
|
||||
} catch {
|
||||
// The voice may already have ended; either way it is no longer part of the mix.
|
||||
}
|
||||
}
|
||||
|
||||
private fadeVoice(
|
||||
voice: ActivePianoVoice,
|
||||
now: number,
|
||||
fadeDurationSeconds: number
|
||||
): void {
|
||||
const stopAt = Math.min(voice.stopAt, now + fadeDurationSeconds);
|
||||
|
||||
voice.gain.gain.cancelScheduledValues(now);
|
||||
voice.gain.gain.setTargetAtTime(
|
||||
pianoSamplerTuning.minGain,
|
||||
now,
|
||||
Math.max(0.001, fadeDurationSeconds / 4)
|
||||
);
|
||||
voice.stopAt = stopAt;
|
||||
try {
|
||||
voice.source.stop(stopAt + pianoSamplerTuning.tailStopExtraSeconds);
|
||||
} catch {
|
||||
// The voice may already have ended; either way it is fading out of the mix.
|
||||
}
|
||||
voice.source.stop(stopAt);
|
||||
}
|
||||
|
||||
private setSamples(samples: Array<LoadedPianoSample>): void {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ interface PianoSampleDefinition {
|
|||
export interface PianoSampleLoadProgress {
|
||||
loadedCount: number;
|
||||
totalCount: number;
|
||||
sample?: PianoSampleDefinition;
|
||||
}
|
||||
|
||||
const sampleFiles: Array<[fileName: string, midi: number]> = [
|
||||
|
|
@ -45,11 +44,6 @@ const sampleFiles: Array<[fileName: string, midi: number]> = [
|
|||
];
|
||||
|
||||
const sampleBaseUrl = `${import.meta.env.BASE_URL}audio/`;
|
||||
const preloadDecode = {
|
||||
channels: 2,
|
||||
frames: 128,
|
||||
sampleRateHz: 48_000,
|
||||
};
|
||||
|
||||
const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
||||
.map(([fileName, midi]) => ({
|
||||
|
|
@ -61,10 +55,6 @@ const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
|||
let loadedPianoSamples: Array<LoadedPianoSample> | null = null;
|
||||
let pianoSampleLoadPromise: Promise<Array<LoadedPianoSample>> | null = null;
|
||||
|
||||
interface PianoSampleLoadOptions {
|
||||
forceReload?: boolean;
|
||||
}
|
||||
|
||||
const sampleLoadTuning = {
|
||||
concurrency: 4,
|
||||
sampleTimeoutMs: 15_000,
|
||||
|
|
@ -81,18 +71,14 @@ export const preloadPianoSamples = (
|
|||
);
|
||||
}
|
||||
|
||||
const decodeContext = new OfflineAudioContextConstructor(
|
||||
preloadDecode.channels,
|
||||
preloadDecode.frames,
|
||||
preloadDecode.sampleRateHz
|
||||
);
|
||||
// Decoding ignores these, but the constructor demands real numbers.
|
||||
const decodeContext = new OfflineAudioContextConstructor(1, 1, 48_000);
|
||||
return loadPianoSamples(decodeContext, onProgress);
|
||||
};
|
||||
|
||||
export const loadPianoSamples = (
|
||||
decodeContext: BaseAudioContext,
|
||||
onProgress?: (progress: PianoSampleLoadProgress) => void,
|
||||
options: PianoSampleLoadOptions = {}
|
||||
onProgress?: (progress: PianoSampleLoadProgress) => void
|
||||
): Promise<Array<LoadedPianoSample>> => {
|
||||
if (loadedPianoSamples) {
|
||||
onProgress?.({
|
||||
|
|
@ -102,7 +88,7 @@ export const loadPianoSamples = (
|
|||
return Promise.resolve([...loadedPianoSamples]);
|
||||
}
|
||||
|
||||
if (pianoSampleLoadPromise && options.forceReload !== true) {
|
||||
if (pianoSampleLoadPromise) {
|
||||
return pianoSampleLoadPromise;
|
||||
}
|
||||
|
||||
|
|
@ -115,12 +101,12 @@ export const loadPianoSamples = (
|
|||
async (sample) => {
|
||||
try {
|
||||
return await withTimeout(
|
||||
loadPianoSample(decodeContext, sample),
|
||||
(signal) => loadPianoSample(decodeContext, sample, signal),
|
||||
sampleLoadTuning.sampleTimeoutMs
|
||||
);
|
||||
} finally {
|
||||
loadedCount += 1;
|
||||
onProgress?.({ loadedCount, totalCount, sample });
|
||||
onProgress?.({ loadedCount, totalCount });
|
||||
}
|
||||
}
|
||||
).then(
|
||||
|
|
@ -147,15 +133,16 @@ export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
|
|||
|
||||
const loadPianoSample = async (
|
||||
decodeContext: BaseAudioContext,
|
||||
sample: PianoSampleDefinition
|
||||
sample: PianoSampleDefinition,
|
||||
signal: AbortSignal
|
||||
): Promise<LoadedPianoSample> => {
|
||||
const response = await fetch(sample.url);
|
||||
const response = await fetch(sample.url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load piano sample ${sample.url}`);
|
||||
}
|
||||
|
||||
const audioData = await response.arrayBuffer();
|
||||
const buffer = await decodeAudioData(decodeContext, audioData);
|
||||
const buffer = await decodeContext.decodeAudioData(audioData);
|
||||
return { midi: sample.midi, buffer };
|
||||
};
|
||||
|
||||
|
|
@ -178,13 +165,18 @@ const loadPianoSampleBatch = async (
|
|||
return results;
|
||||
};
|
||||
|
||||
const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T> =>
|
||||
const withTimeout = <T>(
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs: number
|
||||
): Promise<T> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error('Timed out while loading a piano sample.'));
|
||||
}, timeoutMs);
|
||||
|
||||
promise.then(
|
||||
operation(controller.signal).then(
|
||||
(value) => {
|
||||
globalThis.clearTimeout(timeout);
|
||||
resolve(value);
|
||||
|
|
@ -195,12 +187,3 @@ const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T> =>
|
|||
}
|
||||
);
|
||||
});
|
||||
|
||||
const decodeAudioData = (
|
||||
decodeContext: BaseAudioContext,
|
||||
audioData: ArrayBuffer
|
||||
): Promise<AudioBuffer> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const decodePromise = decodeContext.decodeAudioData(audioData, resolve, reject);
|
||||
decodePromise?.then(resolve, reject);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue