Getting there
This commit is contained in:
parent
ed5a4379db
commit
f300dbd394
43 changed files with 1213 additions and 459 deletions
|
|
@ -1,9 +1,11 @@
|
|||
import { DEFAULT_AUDIO_VOLUME } from '../consts';
|
||||
import type { PianoNoteRole } from './garden-audio-types';
|
||||
|
||||
type GardenAudioChordQuality = 'major' | 'minor' | 'sus2' | 'sus4';
|
||||
|
||||
export interface GardenAudioChord {
|
||||
rootOffset: number;
|
||||
quality: 'major' | 'minor';
|
||||
quality: GardenAudioChordQuality;
|
||||
}
|
||||
|
||||
export interface GardenAudioVibeSettings {
|
||||
|
|
@ -14,6 +16,8 @@ export interface GardenAudioVibeSettings {
|
|||
noteLength: number;
|
||||
notePitchOffset: number;
|
||||
brightness: number;
|
||||
scale?: Array<number>;
|
||||
progression?: Array<GardenAudioChord>;
|
||||
}
|
||||
|
||||
export interface GardenAudioVibeProfile extends GardenAudioVibeSettings {
|
||||
|
|
@ -37,7 +41,9 @@ export const createGardenAudioConfig = () => ({
|
|||
fadeInSeconds: 0.45,
|
||||
updateRampSeconds: 0.08,
|
||||
delay: {
|
||||
timeSeconds: 0.405,
|
||||
timeBeats: 0.5,
|
||||
timeMinSeconds: 0.18,
|
||||
timeMaxSeconds: 0.72,
|
||||
feedback: 0.12,
|
||||
wetGain: 0.044,
|
||||
erasingActivity: 0.12,
|
||||
|
|
|
|||
|
|
@ -121,19 +121,19 @@ export class GardenAudioGraph {
|
|||
);
|
||||
}
|
||||
|
||||
public applyDelayProfile(): void {
|
||||
public applyDelayProfile(bpm: number): void {
|
||||
if (!this.context || !this.delayNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.config.delay.timeSeconds,
|
||||
this.getDelayTimeSecondsForBpm(bpm),
|
||||
this.context.currentTime,
|
||||
this.config.delay.timeRampSeconds
|
||||
);
|
||||
}
|
||||
|
||||
public updateDelay(activity: number): void {
|
||||
public updateDelay(activity: number, bpm: number): void {
|
||||
if (!this.context || !this.delayNode || !this.delayFeedback || !this.delayOutput) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -141,7 +141,7 @@ export class GardenAudioGraph {
|
|||
const now = this.context.currentTime;
|
||||
const normalizedActivity = clamp(activity, 0, 1);
|
||||
this.delayNode.delayTime.setTargetAtTime(
|
||||
this.config.delay.timeSeconds,
|
||||
this.getDelayTimeSecondsForBpm(bpm),
|
||||
now,
|
||||
this.config.delay.timeRampSeconds
|
||||
);
|
||||
|
|
@ -214,7 +214,7 @@ export class GardenAudioGraph {
|
|||
const feedbackLowPass = context.createBiquadFilter();
|
||||
const returnLowPass = context.createBiquadFilter();
|
||||
|
||||
delayNode.delayTime.value = this.config.delay.timeSeconds;
|
||||
delayNode.delayTime.value = this.getDelayTimeSecondsForBpm(this.config.rhythm.bpm);
|
||||
delayFeedback.gain.value = this.config.delay.feedback;
|
||||
delayOutput.gain.value = this.config.delay.wetGain;
|
||||
feedbackHighPass.type = 'highpass';
|
||||
|
|
@ -283,6 +283,15 @@ export class GardenAudioGraph {
|
|||
});
|
||||
}
|
||||
|
||||
private getDelayTimeSecondsForBpm(bpm: number): number {
|
||||
const safeBpm = Number.isFinite(bpm) ? Math.max(1, bpm) : this.config.rhythm.bpm;
|
||||
return clamp(
|
||||
(60 / safeBpm) * this.config.delay.timeBeats,
|
||||
this.config.delay.timeMinSeconds,
|
||||
this.config.delay.timeMaxSeconds
|
||||
);
|
||||
}
|
||||
|
||||
private createNoiseBuffer(context: AudioContext): AudioBuffer {
|
||||
const buffer = context.createBuffer(
|
||||
1,
|
||||
|
|
|
|||
|
|
@ -15,14 +15,24 @@ const DEFAULT_SCALE: ReadonlyArray<number> = [0, 2, 4, 7, 9];
|
|||
|
||||
const profileCache = new WeakMap<VibePreset, GardenAudioVibeProfile>();
|
||||
|
||||
const getProfileScale = (vibe: VibePreset): Array<number> => {
|
||||
const scale = vibe.audio.scale?.length ? vibe.audio.scale : DEFAULT_SCALE;
|
||||
return [...scale];
|
||||
};
|
||||
|
||||
const getProfileProgression = (vibe: VibePreset): Array<GardenAudioChord> =>
|
||||
(vibe.audio.progression?.length ? vibe.audio.progression : DEFAULT_PROGRESSION).map(
|
||||
(chord) => ({ ...chord })
|
||||
);
|
||||
|
||||
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
|
||||
let profile = profileCache.get(vibe);
|
||||
if (!profile) {
|
||||
profile = {
|
||||
...vibe.audio,
|
||||
rootMidi: DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset,
|
||||
scale: DEFAULT_SCALE as Array<number>,
|
||||
progression: DEFAULT_PROGRESSION as Array<GardenAudioChord>,
|
||||
scale: getProfileScale(vibe),
|
||||
progression: getProfileProgression(vibe),
|
||||
};
|
||||
profileCache.set(vibe, profile);
|
||||
return profile;
|
||||
|
|
@ -30,5 +40,7 @@ export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => {
|
|||
|
||||
Object.assign(profile, vibe.audio);
|
||||
profile.rootMidi = DEFAULT_ROOT_MIDI + vibe.audio.notePitchOffset;
|
||||
profile.scale = getProfileScale(vibe);
|
||||
profile.progression = getProfileProgression(vibe);
|
||||
return profile;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||
import { clamp01 } from '../utils/math';
|
||||
import type { VibeId, VibePreset } from '../vibes';
|
||||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import type { GardenAudioConfig, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
import { GardenAudioEnergy } from './garden-audio-energy';
|
||||
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
|
|
@ -155,11 +155,12 @@ export class GardenAudio {
|
|||
): void {
|
||||
this.lifecycle = 'started';
|
||||
this.currentVibeId = vibe.id;
|
||||
this.graph.applyDelayProfile();
|
||||
const profile = getVibeProfile(vibe);
|
||||
this.graph.applyDelayProfile(profile.bpm);
|
||||
this.graph.setMasterGain(this.masterVolume, startupRampSeconds);
|
||||
|
||||
if (cuePiano) {
|
||||
this.pianoEngine.cue(context.currentTime, getVibeProfile(vibe));
|
||||
this.pianoEngine.cue(context.currentTime, profile);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -245,7 +246,7 @@ export class GardenAudio {
|
|||
|
||||
if (!this.isGestureActive && this.isReleasingPiano) {
|
||||
this.updatePianoRelease(snapshot.vibe, context.currentTime);
|
||||
this.updateDelay(snapshot);
|
||||
this.updateDelay(snapshot, profile);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -256,7 +257,7 @@ export class GardenAudio {
|
|||
? this.config.eraser.pianoActivity
|
||||
: this.energy.getLevel(),
|
||||
});
|
||||
this.updateDelay(snapshot);
|
||||
this.updateDelay(snapshot, profile);
|
||||
}
|
||||
|
||||
public stroke(stroke: GardenAudioStroke): void {
|
||||
|
|
@ -371,7 +372,10 @@ export class GardenAudio {
|
|||
}
|
||||
}
|
||||
|
||||
private updateDelay(snapshot: GardenAudioSnapshot): void {
|
||||
private updateDelay(
|
||||
snapshot: GardenAudioSnapshot,
|
||||
profile: GardenAudioVibeProfile
|
||||
): void {
|
||||
const context = this.graph.context;
|
||||
if (!context) {
|
||||
return;
|
||||
|
|
@ -380,7 +384,7 @@ export class GardenAudio {
|
|||
const activity = snapshot.isErasing
|
||||
? this.config.delay.erasingActivity
|
||||
: this.energy.getLevel();
|
||||
this.graph.updateDelay(activity);
|
||||
this.graph.updateDelay(activity, profile.bpm);
|
||||
}
|
||||
|
||||
private applyVibe(vibe: VibePreset): void {
|
||||
|
|
@ -390,7 +394,7 @@ export class GardenAudio {
|
|||
|
||||
this.currentVibeId = vibe.id;
|
||||
const profile = getVibeProfile(vibe);
|
||||
this.graph.applyDelayProfile();
|
||||
this.graph.applyDelayProfile(profile.bpm);
|
||||
this.pianoEngine.cue(this.graph.context.currentTime, profile);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,6 +152,12 @@ interface GenerativePianoTuning {
|
|||
min: number;
|
||||
max: number;
|
||||
};
|
||||
stereoWidth: {
|
||||
idle: number;
|
||||
active: number;
|
||||
intense: number;
|
||||
intenseThreshold: number;
|
||||
};
|
||||
stylePanOffsetScale: number;
|
||||
lowpass: {
|
||||
midiBase: number;
|
||||
|
|
@ -371,6 +377,12 @@ export const generativePianoTuning: GenerativePianoTuning = {
|
|||
min: -3,
|
||||
max: 3,
|
||||
},
|
||||
stereoWidth: {
|
||||
idle: 0.46,
|
||||
active: 0.9,
|
||||
intense: 1.16,
|
||||
intenseThreshold: 0.72,
|
||||
},
|
||||
stylePanOffsetScale: 0.35,
|
||||
lowpass: {
|
||||
midiBase: 48,
|
||||
|
|
|
|||
|
|
@ -18,23 +18,34 @@ 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 chordVoicings: Record<
|
||||
GardenAudioChord['quality'],
|
||||
{ closed: Array<number>; open: Array<number> }
|
||||
> = {
|
||||
major: {
|
||||
closed: [0, 4, 7, 12, 16],
|
||||
open: [0, 7, 12, 16],
|
||||
},
|
||||
minor: {
|
||||
closed: [0, 3, 7, 12, 15],
|
||||
open: [0, 7, 12, 15],
|
||||
},
|
||||
sus2: {
|
||||
closed: [0, 2, 7, 12, 14],
|
||||
open: [0, 7, 12, 14],
|
||||
},
|
||||
sus4: {
|
||||
closed: [0, 5, 7, 12, 17],
|
||||
open: [0, 7, 12, 17],
|
||||
},
|
||||
};
|
||||
|
||||
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 voicing = chordVoicings[chord.quality];
|
||||
return openVoicing ? voicing.open : voicing.closed;
|
||||
};
|
||||
|
||||
const degreeToSemitone = (profile: GardenAudioVibeProfile, degree: number): number => {
|
||||
|
|
@ -406,7 +417,7 @@ export class GenerativePianoEngine {
|
|||
velocity + expression * generativePianoTuning.padChord.expressionVelocityWeight,
|
||||
startTime,
|
||||
durationSeconds,
|
||||
pan: register.pan,
|
||||
pan: this.getActivityPan(register.pan, expression),
|
||||
role: 'pad',
|
||||
delaySend: generativePianoTuning.padChord.delaySend,
|
||||
lowpassHz: this.getLowpassHz(
|
||||
|
|
@ -446,7 +457,7 @@ export class GenerativePianoEngine {
|
|||
velocity: release.velocities[index],
|
||||
startTime: startTime + index * release.strumSeconds,
|
||||
durationSeconds: release.durationSeconds,
|
||||
pan: register.pan,
|
||||
pan: this.getActivityPan(register.pan, 0),
|
||||
role: 'pad',
|
||||
delaySend: release.delaySend,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, release.lowpassExpression),
|
||||
|
|
@ -487,7 +498,7 @@ export class GenerativePianoEngine {
|
|||
durationSeconds:
|
||||
generativePianoTuning.supportNote.durationBaseSeconds +
|
||||
expression * generativePianoTuning.supportNote.durationExpressionSeconds,
|
||||
pan: this.getStylePan(styleIndex),
|
||||
pan: this.getStylePan(styleIndex, expression),
|
||||
role: 'support',
|
||||
delaySend:
|
||||
generativePianoTuning.supportNote.delaySendBase +
|
||||
|
|
@ -533,7 +544,7 @@ export class GenerativePianoEngine {
|
|||
durationSeconds:
|
||||
generativePianoTuning.textureNote.durationBaseSeconds +
|
||||
expression * generativePianoTuning.textureNote.durationExpressionSeconds,
|
||||
pan: this.getStylePan(styleIndex),
|
||||
pan: this.getStylePan(styleIndex, expression),
|
||||
role: 'texture',
|
||||
delaySend:
|
||||
generativePianoTuning.textureNote.delaySendBase +
|
||||
|
|
@ -582,7 +593,7 @@ export class GenerativePianoEngine {
|
|||
durationSeconds:
|
||||
generativePianoTuning.gestureAccent.durationBaseSeconds +
|
||||
strength * generativePianoTuning.gestureAccent.durationStrengthSeconds,
|
||||
pan: this.getStylePan(styleIndex),
|
||||
pan: this.getStylePan(styleIndex, strength),
|
||||
role: 'gesture',
|
||||
delaySend: generativePianoTuning.gestureAccent.delaySend,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, strength),
|
||||
|
|
@ -627,7 +638,7 @@ export class GenerativePianoEngine {
|
|||
durationSeconds:
|
||||
generativePianoTuning.touchNote.durationBaseSeconds +
|
||||
strength * generativePianoTuning.touchNote.durationStrengthSeconds,
|
||||
pan: this.getStylePan(styleIndex),
|
||||
pan: this.getStylePan(styleIndex, strength),
|
||||
role: 'gesture',
|
||||
delaySend: generativePianoTuning.touchNote.delaySend,
|
||||
lowpassHz: this.getLowpassHz(
|
||||
|
|
@ -813,7 +824,7 @@ export class GenerativePianoEngine {
|
|||
chordOffsets: this.getChordOffsets(chord, chordIntervals),
|
||||
};
|
||||
const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true);
|
||||
const pan = this.getStylePan(styleIndex);
|
||||
const pan = this.getStylePan(styleIndex, intensity);
|
||||
const durationSeconds = clamp(
|
||||
generativePianoTuning.brushStream.durationBaseSeconds +
|
||||
intensity * generativePianoTuning.brushStream.durationIntensitySeconds -
|
||||
|
|
@ -1128,16 +1139,29 @@ export class GenerativePianoEngine {
|
|||
styleCount) as GardenAudioStyleIndex;
|
||||
}
|
||||
|
||||
private getStylePan(styleIndex: GardenAudioStyleIndex): number {
|
||||
private getStylePan(styleIndex: GardenAudioStyleIndex, activity: number): number {
|
||||
const pool = generativePianoTuning.stylePools[styleIndex];
|
||||
const styleVoice = styleVoices[styleIndex];
|
||||
return clamp(
|
||||
return this.getActivityPan(
|
||||
pool.pan + styleVoice.panOffset * generativePianoTuning.stylePanOffsetScale,
|
||||
-1,
|
||||
1
|
||||
activity
|
||||
);
|
||||
}
|
||||
|
||||
private getActivityPan(pan: number, activity: number): number {
|
||||
const { active, idle, intense, intenseThreshold } = generativePianoTuning.stereoWidth;
|
||||
const normalizedActivity = clamp01(activity);
|
||||
const safeThreshold = clamp(intenseThreshold, 0.001, 0.999);
|
||||
const width =
|
||||
normalizedActivity < safeThreshold
|
||||
? idle + ((active - idle) * normalizedActivity) / safeThreshold
|
||||
: active +
|
||||
((intense - active) * (normalizedActivity - safeThreshold)) /
|
||||
(1 - safeThreshold);
|
||||
|
||||
return clamp(pan * width, -1, 1);
|
||||
}
|
||||
|
||||
private getLowpassHz(
|
||||
profile: GardenAudioVibeProfile,
|
||||
midi: number,
|
||||
|
|
|
|||
Loading…
Reference in a new issue