more cleaning up
This commit is contained in:
parent
2c7d72a699
commit
560398fefb
110 changed files with 931 additions and 2645 deletions
|
|
@ -7,7 +7,7 @@ export interface GardenAudioChord {
|
|||
quality: GardenAudioChordQuality;
|
||||
}
|
||||
|
||||
interface GardenAudioColorVoice {
|
||||
interface GardenAudioStyleVoice {
|
||||
scaleDegreeOffset: number;
|
||||
velocityMultiplier: number;
|
||||
panOffset: number;
|
||||
|
|
@ -20,13 +20,14 @@ export interface GardenAudioRegister {
|
|||
pan: number;
|
||||
}
|
||||
|
||||
export interface GardenAudioColorPool extends GardenAudioRegister {
|
||||
export interface GardenAudioStylePool extends GardenAudioRegister {
|
||||
scaleDegrees: Array<number>;
|
||||
}
|
||||
|
||||
interface GardenAudioGenerativePianoConfig {
|
||||
colorPools: [GardenAudioColorPool, GardenAudioColorPool, GardenAudioColorPool];
|
||||
stylePools: [GardenAudioStylePool, GardenAudioStylePool, GardenAudioStylePool];
|
||||
padRegisters: [GardenAudioRegister, GardenAudioRegister, GardenAudioRegister];
|
||||
styleRotationSeconds: number;
|
||||
chordBars: number;
|
||||
supportBarSpacing: number;
|
||||
supportBarOffset: number;
|
||||
|
|
@ -38,16 +39,14 @@ interface GardenAudioGenerativePianoConfig {
|
|||
noteScorePreferenceWeight: number;
|
||||
noteScoreRegisterWeight: number;
|
||||
noteScoreRepeatPenalty: number;
|
||||
gestureAccentSpacingSeconds: number;
|
||||
gestureAccentMinIntervalSeconds: number;
|
||||
strokeAccentMinIntervalSeconds: number;
|
||||
strokeAccentThreshold: number;
|
||||
stingerSpacingSeconds: number;
|
||||
stingerDurationSeconds: number;
|
||||
stingerSpacingSeconds: number;
|
||||
maxBrushPhraseLayers: number;
|
||||
brushLayerBaseSeconds: number;
|
||||
brushLayerEnergySeconds: number;
|
||||
brushLayerMirrorSeconds: number;
|
||||
brushLayerMinIntensity: number;
|
||||
brushStreamIdleIntervalBeats: number;
|
||||
brushStreamActiveIntervalBeats: number;
|
||||
|
|
@ -137,12 +136,10 @@ export interface GardenAudioConfig {
|
|||
unlockTickSeconds: number;
|
||||
};
|
||||
input: {
|
||||
activeActivityThreshold: number;
|
||||
distanceWindowForFullActivityPixels: number;
|
||||
distanceWindowSeconds: number;
|
||||
fallbackFrameSeconds: number;
|
||||
manicActivityThreshold: number;
|
||||
manicModeThreshold: number;
|
||||
};
|
||||
muteGain: number;
|
||||
muteRampSeconds: number;
|
||||
|
|
@ -156,8 +153,7 @@ export interface GardenAudioConfig {
|
|||
startDelaySeconds: number;
|
||||
vibeChangeStingerMinIntervalSeconds: number;
|
||||
generativePiano: GardenAudioGenerativePianoConfig;
|
||||
colorVoices: [GardenAudioColorVoice, GardenAudioColorVoice, GardenAudioColorVoice];
|
||||
vibes: Record<string, GardenAudioVibeProfile>;
|
||||
styleVoices: [GardenAudioStyleVoice, GardenAudioStyleVoice, GardenAudioStyleVoice];
|
||||
}
|
||||
|
||||
export const gardenAudioConfig: GardenAudioConfig = appConfig.audio;
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@ import { clamp01 } from '../utils/clamp';
|
|||
import type { GardenAudioConfig } from './garden-audio-config';
|
||||
import type { GardenAudioStrokeMetrics } from './garden-audio-input';
|
||||
|
||||
type GardenAudioGestureMode = 'calm' | 'active' | 'manic' | 'afterglow';
|
||||
|
||||
interface GardenAudioGestureFrame {
|
||||
mode: GardenAudioGestureMode;
|
||||
activity: number;
|
||||
maniaAmount: number;
|
||||
}
|
||||
|
|
@ -15,44 +12,18 @@ interface GestureDistanceSample {
|
|||
distancePixels: number;
|
||||
}
|
||||
|
||||
const DEFAULT_FRAME: GardenAudioGestureFrame = {
|
||||
mode: 'calm',
|
||||
activity: 0,
|
||||
maniaAmount: 0,
|
||||
};
|
||||
|
||||
export class GardenAudioGestureState {
|
||||
private readonly samples: Array<GestureDistanceSample> = [];
|
||||
private gestureClockSeconds = 0;
|
||||
private peakActivity = 0;
|
||||
private lastFrame: GardenAudioGestureFrame = DEFAULT_FRAME;
|
||||
|
||||
public constructor(private readonly inputConfig: GardenAudioConfig['input']) {}
|
||||
|
||||
public beginGesture(): void {
|
||||
this.samples.length = 0;
|
||||
this.gestureClockSeconds = 0;
|
||||
this.peakActivity = 0;
|
||||
this.lastFrame = DEFAULT_FRAME;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
public endGesture(): GardenAudioGestureFrame {
|
||||
this.samples.length = 0;
|
||||
this.gestureClockSeconds = 0;
|
||||
this.lastFrame = {
|
||||
...DEFAULT_FRAME,
|
||||
mode:
|
||||
this.peakActivity >= this.inputConfig.activeActivityThreshold
|
||||
? 'afterglow'
|
||||
: 'calm',
|
||||
};
|
||||
this.peakActivity = 0;
|
||||
return this.lastFrame;
|
||||
}
|
||||
|
||||
public recordTouchDown(): GardenAudioGestureFrame {
|
||||
this.lastFrame = DEFAULT_FRAME;
|
||||
return this.lastFrame;
|
||||
public endGesture(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
public recordStroke({
|
||||
|
|
@ -77,28 +48,16 @@ export class GardenAudioGestureState {
|
|||
const activity = clamp01(
|
||||
windowDistancePixels / this.inputConfig.distanceWindowForFullActivityPixels
|
||||
);
|
||||
const maniaAmount = smoothstep(this.inputConfig.manicActivityThreshold, 1, activity);
|
||||
|
||||
this.peakActivity = Math.max(this.peakActivity, activity);
|
||||
this.lastFrame = {
|
||||
...DEFAULT_FRAME,
|
||||
mode: this.getMode(activity, maniaAmount),
|
||||
return {
|
||||
activity,
|
||||
maniaAmount,
|
||||
maniaAmount: smoothstep(this.inputConfig.manicActivityThreshold, 1, activity),
|
||||
};
|
||||
|
||||
return this.lastFrame;
|
||||
}
|
||||
|
||||
public getFrame(): GardenAudioGestureFrame {
|
||||
return this.lastFrame;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.samples.length = 0;
|
||||
this.gestureClockSeconds = 0;
|
||||
this.peakActivity = 0;
|
||||
this.lastFrame = DEFAULT_FRAME;
|
||||
}
|
||||
|
||||
private trimSamples(): void {
|
||||
|
|
@ -107,14 +66,6 @@ export class GardenAudioGestureState {
|
|||
this.samples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private getMode(activity: number, maniaAmount: number): GardenAudioGestureMode {
|
||||
if (maniaAmount >= this.inputConfig.manicModeThreshold) {
|
||||
return 'manic';
|
||||
}
|
||||
|
||||
return activity >= this.inputConfig.activeActivityThreshold ? 'active' : 'calm';
|
||||
}
|
||||
}
|
||||
|
||||
const smoothstep = (edge0: number, edge1: number, value: number): number => {
|
||||
|
|
|
|||
|
|
@ -1,25 +1,7 @@
|
|||
import { VibePreset } from '../vibes';
|
||||
import {
|
||||
GardenAudioChord,
|
||||
GardenAudioConfig,
|
||||
GardenAudioVibeProfile,
|
||||
} from './garden-audio-config';
|
||||
import { GardenAudioColorIndex } from './garden-audio-types';
|
||||
import { GardenAudioChord, GardenAudioVibeProfile } from './garden-audio-config';
|
||||
|
||||
export const normalizeColorIndex = (index: number): GardenAudioColorIndex =>
|
||||
Math.max(0, Math.min(2, Math.round(index))) as GardenAudioColorIndex;
|
||||
|
||||
export const getVibeProfile = (
|
||||
config: GardenAudioConfig,
|
||||
vibe: VibePreset
|
||||
): GardenAudioVibeProfile => {
|
||||
const profile = config.vibes[vibe.id];
|
||||
if (!profile) {
|
||||
throw new Error(`Missing audio profile for vibe "${vibe.id}"`);
|
||||
}
|
||||
|
||||
return profile;
|
||||
};
|
||||
export const getVibeProfile = (vibe: VibePreset): GardenAudioVibeProfile => vibe.audio;
|
||||
|
||||
export const getChordIntervals = (
|
||||
chord: GardenAudioChord,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { VibePreset } from '../vibes';
|
||||
|
||||
export type GardenAudioColorIndex = 0 | 1 | 2;
|
||||
|
||||
export interface GardenAudioSnapshot {
|
||||
vibe: VibePreset;
|
||||
selectedColorIndex: number;
|
||||
isErasing: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -12,15 +9,10 @@ export interface GardenAudioStroke {
|
|||
vibe: VibePreset;
|
||||
from: ArrayLike<number>;
|
||||
to: ArrayLike<number>;
|
||||
colorIndex: number;
|
||||
isErasing: boolean;
|
||||
elapsedSeconds?: number;
|
||||
}
|
||||
|
||||
export interface GardenAudioTouchDown {
|
||||
colorIndex: number;
|
||||
}
|
||||
|
||||
export interface GardenAudioStartOptions {
|
||||
userGesture?: boolean;
|
||||
}
|
||||
|
|
@ -33,7 +25,6 @@ export interface LoadedPianoSample {
|
|||
export interface ActivePianoVoice {
|
||||
gain: GainNode;
|
||||
source: AudioScheduledSourceNode;
|
||||
startAt: number;
|
||||
stopAt: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { appConfig } from '../config';
|
||||
import { ErrorHandler, Severity } from '../utils/error-handler';
|
||||
import { VIBE_PRESETS } from '../vibes';
|
||||
import { GardenAudio } from './garden-audio';
|
||||
import { gardenAudioConfig, GardenAudioConfig } from './garden-audio-config';
|
||||
import { loadPianoSamples, resetPianoSampleCacheForTest } from './piano-samples';
|
||||
|
||||
type FakeScheduledSourceNode = {
|
||||
start: ReturnType<typeof vi.fn>;
|
||||
|
|
@ -21,6 +18,10 @@ const calls = {
|
|||
|
||||
let contextState: AudioContextState = 'suspended';
|
||||
let resumeError: Error | null = null;
|
||||
let ErrorHandler: typeof import('../utils/error-handler').ErrorHandler;
|
||||
let GardenAudio: typeof import('./garden-audio').GardenAudio;
|
||||
let loadPianoSamples: typeof import('./piano-samples').loadPianoSamples;
|
||||
let Severity: typeof import('../utils/error-handler').Severity;
|
||||
|
||||
class FakeAudioParam {
|
||||
public value = 0;
|
||||
|
|
@ -140,14 +141,18 @@ const makeConfig = (): GardenAudioConfig => ({
|
|||
});
|
||||
|
||||
describe('GardenAudio startup policy', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ ErrorHandler, Severity } = await import('../utils/error-handler'));
|
||||
({ GardenAudio } = await import('./garden-audio'));
|
||||
({ loadPianoSamples } = await import('./piano-samples'));
|
||||
|
||||
calls.constructed = 0;
|
||||
calls.resumed = 0;
|
||||
calls.sourcesStarted = 0;
|
||||
calls.sources = [];
|
||||
contextState = 'suspended';
|
||||
resumeError = null;
|
||||
resetPianoSampleCacheForTest();
|
||||
vi.stubGlobal('AudioContext', FakeAudioContext);
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not loaded in tests')));
|
||||
});
|
||||
|
|
@ -155,7 +160,6 @@ describe('GardenAudio startup policy', () => {
|
|||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
resetPianoSampleCacheForTest();
|
||||
});
|
||||
|
||||
it('does not create an AudioContext from passive audio paths', () => {
|
||||
|
|
@ -167,7 +171,6 @@ describe('GardenAudio startup policy', () => {
|
|||
vibe,
|
||||
from: [0, 0],
|
||||
to: [12, 0],
|
||||
colorIndex: 0,
|
||||
isErasing: false,
|
||||
});
|
||||
|
||||
|
|
@ -215,14 +218,10 @@ describe('GardenAudio startup policy', () => {
|
|||
expect(calls.sourcesStarted).toBe(1);
|
||||
|
||||
audio.beginGesture();
|
||||
audio.touchDown({
|
||||
colorIndex: 1,
|
||||
});
|
||||
audio.stroke({
|
||||
vibe,
|
||||
from: [30, 40],
|
||||
to: [60, 60],
|
||||
colorIndex: 1,
|
||||
isErasing: false,
|
||||
elapsedSeconds: 0.05,
|
||||
});
|
||||
|
|
@ -233,7 +232,6 @@ describe('GardenAudio startup policy', () => {
|
|||
vibe,
|
||||
from: [60, 60],
|
||||
to: [75, 80],
|
||||
colorIndex: 1,
|
||||
isErasing: true,
|
||||
elapsedSeconds: 0.05,
|
||||
});
|
||||
|
|
@ -256,14 +254,10 @@ describe('GardenAudio startup policy', () => {
|
|||
|
||||
audio.start(vibe, { userGesture: true });
|
||||
audio.beginGesture();
|
||||
audio.touchDown({
|
||||
colorIndex: 1,
|
||||
});
|
||||
audio.stroke({
|
||||
vibe,
|
||||
from: [30, 40],
|
||||
to: [90, 40],
|
||||
colorIndex: 1,
|
||||
elapsedSeconds: 0.05,
|
||||
isErasing: false,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,13 +6,11 @@ import { GardenAudioEnergy } from './garden-audio-energy';
|
|||
import { GardenAudioGestureState } from './garden-audio-gesture-state';
|
||||
import { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { getStrokeMetrics } from './garden-audio-input';
|
||||
import { getVibeProfile, normalizeColorIndex } from './garden-audio-music';
|
||||
import { getVibeProfile } from './garden-audio-music';
|
||||
import type {
|
||||
GardenAudioColorIndex,
|
||||
GardenAudioSnapshot,
|
||||
GardenAudioStartOptions,
|
||||
GardenAudioStroke,
|
||||
GardenAudioTouchDown,
|
||||
} from './garden-audio-types';
|
||||
import { GenerativePianoEngine } from './generative-piano';
|
||||
import { NoiseBurstPlayer } from './noise-burst-player';
|
||||
|
|
@ -22,7 +20,6 @@ export type {
|
|||
GardenAudioSnapshot,
|
||||
GardenAudioStartOptions,
|
||||
GardenAudioStroke,
|
||||
GardenAudioTouchDown,
|
||||
} from './garden-audio-types';
|
||||
|
||||
export class GardenAudio {
|
||||
|
|
@ -38,7 +35,6 @@ export class GardenAudio {
|
|||
private isDestroyed = false;
|
||||
private isMuted = false;
|
||||
private isGestureActive = false;
|
||||
private selectedColorIndex: GardenAudioColorIndex = 0;
|
||||
private hasQueuedPianoLoad = false;
|
||||
private lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
private lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
|
|
@ -162,20 +158,6 @@ export class GardenAudio {
|
|||
this.pianoEngine.endGesture();
|
||||
}
|
||||
|
||||
public touchDown(touch: GardenAudioTouchDown): void {
|
||||
if (this.isDestroyed || this.isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this.graph.context;
|
||||
if (!context || !this.isGestureActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedColorIndex = normalizeColorIndex(touch.colorIndex);
|
||||
this.gestureState.recordTouchDown();
|
||||
}
|
||||
|
||||
public update(snapshot: GardenAudioSnapshot): void {
|
||||
const context = this.graph.context;
|
||||
if (!this.hasStarted || !context || this.isMuted) {
|
||||
|
|
@ -183,7 +165,6 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
this.applyVibe(snapshot.vibe);
|
||||
this.selectedColorIndex = normalizeColorIndex(snapshot.selectedColorIndex);
|
||||
this.energy.update(context.currentTime);
|
||||
|
||||
if (snapshot.isErasing) {
|
||||
|
|
@ -194,7 +175,6 @@ export class GardenAudio {
|
|||
vibe: snapshot.vibe,
|
||||
now: context.currentTime,
|
||||
activity: snapshot.isErasing ? 0 : this.energy.getLevel(),
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
});
|
||||
this.updateDelay(snapshot);
|
||||
}
|
||||
|
|
@ -216,7 +196,6 @@ export class GardenAudio {
|
|||
const metrics = getStrokeMetrics(stroke, this.config.input);
|
||||
const now = context.currentTime;
|
||||
|
||||
this.selectedColorIndex = normalizeColorIndex(stroke.colorIndex);
|
||||
const frame = this.gestureState.recordStroke({ metrics });
|
||||
const strokeEnergy = frame.activity;
|
||||
|
||||
|
|
@ -231,7 +210,6 @@ export class GardenAudio {
|
|||
vibe: stroke.vibe,
|
||||
now,
|
||||
activity: strokeEnergy,
|
||||
selectedColorIndex: this.selectedColorIndex,
|
||||
maniaAmount: frame.maniaAmount,
|
||||
});
|
||||
}
|
||||
|
|
@ -247,7 +225,6 @@ export class GardenAudio {
|
|||
this.currentVibeId = null;
|
||||
this.hasStarted = false;
|
||||
this.isGestureActive = false;
|
||||
this.selectedColorIndex = 0;
|
||||
this.hasQueuedPianoLoad = false;
|
||||
this.lastEraserAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastVibeStingerAt = Number.NEGATIVE_INFINITY;
|
||||
|
|
@ -301,7 +278,7 @@ export class GardenAudio {
|
|||
return;
|
||||
}
|
||||
|
||||
const profile = getVibeProfile(this.config, snapshot.vibe);
|
||||
const profile = getVibeProfile(snapshot.vibe);
|
||||
const activity = snapshot.isErasing
|
||||
? this.config.delay.erasingActivity
|
||||
: this.energy.getLevel();
|
||||
|
|
@ -314,7 +291,7 @@ export class GardenAudio {
|
|||
}
|
||||
|
||||
this.currentVibeId = vibe.id;
|
||||
this.graph.applyDelayProfile(getVibeProfile(this.config, vibe));
|
||||
this.graph.applyDelayProfile(getVibeProfile(vibe));
|
||||
this.pianoEngine.cue(this.graph.context.currentTime);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,14 +24,13 @@ const getBeatsPerBar = (): number =>
|
|||
const renderBars = (
|
||||
engine: GenerativePianoEngine,
|
||||
activity: number,
|
||||
selectedColorIndex = 0,
|
||||
bars = 8
|
||||
bars = 8,
|
||||
now = 0
|
||||
) => {
|
||||
engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now: 0,
|
||||
now,
|
||||
activity,
|
||||
selectedColorIndex: selectedColorIndex as 0 | 1 | 2,
|
||||
lookaheadSeconds: getBeatSeconds() * getBeatsPerBar() * bars,
|
||||
});
|
||||
};
|
||||
|
|
@ -64,7 +63,7 @@ describe('GenerativePianoEngine', () => {
|
|||
it('keeps the background sparse instead of filling every beat', () => {
|
||||
const { engine, notes } = makeEngine();
|
||||
|
||||
renderBars(engine, 0, 1, 4);
|
||||
renderBars(engine, 0, 4);
|
||||
|
||||
expect(uniqueStartTimes(notes).length).toBeLessThan(8);
|
||||
});
|
||||
|
|
@ -74,8 +73,8 @@ describe('GenerativePianoEngine', () => {
|
|||
const active = makeEngine();
|
||||
const startDelaySeconds = 0.02;
|
||||
|
||||
renderBars(idle.engine, 0, 1, 8);
|
||||
renderBars(active.engine, 1, 1, 8);
|
||||
renderBars(idle.engine, 0, 8);
|
||||
renderBars(active.engine, 1, 8);
|
||||
|
||||
expect(active.notes.length).toBeGreaterThan(idle.notes.length);
|
||||
active.notes.forEach((note) => {
|
||||
|
|
@ -84,29 +83,34 @@ describe('GenerativePianoEngine', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('uses color pools with multiple notes instead of one key per color', () => {
|
||||
([0, 1, 2] as const).forEach((selectedColorIndex) => {
|
||||
const { engine, notes } = makeEngine();
|
||||
it('uses style pools with multiple notes instead of one repeating key', () => {
|
||||
const { engine, notes } = makeEngine();
|
||||
|
||||
renderBars(engine, 1, selectedColorIndex, 16);
|
||||
renderBars(engine, 1, 16);
|
||||
|
||||
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3);
|
||||
});
|
||||
expect(new Set(notes.map((note) => note.midi)).size).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it('keeps the upper color higher and wider than the lower color', () => {
|
||||
const lower = makeEngine();
|
||||
const upper = makeEngine();
|
||||
it('changes musical style over time without a color change', () => {
|
||||
const { engine, notes } = makeEngine();
|
||||
|
||||
renderBars(lower.engine, 1, 0, 16);
|
||||
renderBars(upper.engine, 1, 2, 16);
|
||||
renderBars(engine, 1, 32);
|
||||
|
||||
expect(average(upper.notes.map((note) => note.midi))).toBeGreaterThan(
|
||||
average(lower.notes.map((note) => note.midi))
|
||||
const styleWindows = [
|
||||
notes.filter((note) => note.startTime >= 0 && note.startTime < 8),
|
||||
notes.filter((note) => note.startTime >= 8 && note.startTime < 16),
|
||||
notes.filter((note) => note.startTime >= 16 && note.startTime < 24),
|
||||
];
|
||||
const averageMidiByWindow = styleWindows.map((windowNotes) =>
|
||||
Math.round(average(windowNotes.map((note) => note.midi)))
|
||||
);
|
||||
expect(average(upper.notes.map((note) => note.pan))).toBeGreaterThan(
|
||||
average(lower.notes.map((note) => note.pan))
|
||||
const averagePanByWindow = styleWindows.map((windowNotes) =>
|
||||
Number(average(windowNotes.map((note) => note.pan)).toFixed(2))
|
||||
);
|
||||
|
||||
expect(styleWindows.every((windowNotes) => windowNotes.length > 0)).toBe(true);
|
||||
expect(new Set(averageMidiByWindow).size).toBeGreaterThan(1);
|
||||
expect(new Set(averagePanByWindow).size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('starts a fading brush phrase layer with each new brush gesture', () => {
|
||||
|
|
@ -118,7 +122,6 @@ describe('GenerativePianoEngine', () => {
|
|||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.35,
|
||||
selectedColorIndex: 1,
|
||||
lookaheadSeconds: 12,
|
||||
});
|
||||
|
||||
|
|
@ -127,14 +130,11 @@ describe('GenerativePianoEngine', () => {
|
|||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.85,
|
||||
selectedColorIndex: 1,
|
||||
mirrorAmount: 0.45,
|
||||
});
|
||||
layered.engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.35,
|
||||
selectedColorIndex: 1,
|
||||
lookaheadSeconds: 12,
|
||||
});
|
||||
|
||||
|
|
@ -149,46 +149,6 @@ describe('GenerativePianoEngine', () => {
|
|||
expect(lateExtra).toBe(0);
|
||||
});
|
||||
|
||||
it('makes brush phrase layers denser at higher mirror amounts', () => {
|
||||
const lowMirror = makeEngine();
|
||||
const highMirror = makeEngine();
|
||||
const now = 4;
|
||||
|
||||
lowMirror.engine.beginGesture();
|
||||
lowMirror.engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.85,
|
||||
selectedColorIndex: 2,
|
||||
mirrorAmount: 0,
|
||||
});
|
||||
lowMirror.engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.35,
|
||||
selectedColorIndex: 2,
|
||||
lookaheadSeconds: 9,
|
||||
});
|
||||
|
||||
highMirror.engine.beginGesture();
|
||||
highMirror.engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.85,
|
||||
selectedColorIndex: 2,
|
||||
mirrorAmount: 1,
|
||||
});
|
||||
highMirror.engine.renderLookahead({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.35,
|
||||
selectedColorIndex: 2,
|
||||
lookaheadSeconds: 9,
|
||||
});
|
||||
|
||||
expect(highMirror.notes.length).toBeGreaterThan(lowMirror.notes.length);
|
||||
});
|
||||
|
||||
it('plays one immediate touch note and throttles later stroke accents', () => {
|
||||
const { engine, notes } = makeEngine();
|
||||
const now = 4;
|
||||
|
|
@ -198,13 +158,11 @@ describe('GenerativePianoEngine', () => {
|
|||
vibe: VIBE_PRESETS[0],
|
||||
now,
|
||||
activity: 0.9,
|
||||
selectedColorIndex: 1,
|
||||
});
|
||||
engine.recordStroke({
|
||||
vibe: VIBE_PRESETS[0],
|
||||
now: now + 1,
|
||||
activity: 0.95,
|
||||
selectedColorIndex: 1,
|
||||
});
|
||||
|
||||
expect(notes).toHaveLength(1);
|
||||
|
|
@ -214,7 +172,6 @@ describe('GenerativePianoEngine', () => {
|
|||
vibe: VIBE_PRESETS[0],
|
||||
now: now + 6,
|
||||
activity: 0.95,
|
||||
selectedColorIndex: 1,
|
||||
});
|
||||
|
||||
expect(notes).toHaveLength(2);
|
||||
|
|
@ -225,8 +182,8 @@ describe('GenerativePianoEngine', () => {
|
|||
const first = makeEngine();
|
||||
const second = makeEngine();
|
||||
|
||||
renderBars(first.engine, 0.78, 2, 16);
|
||||
renderBars(second.engine, 0.78, 2, 16);
|
||||
renderBars(first.engine, 0.78, 16);
|
||||
renderBars(second.engine, 0.78, 16);
|
||||
|
||||
expect(second.notes).toEqual(first.notes);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import { clamp, clamp01 } from '../utils/clamp';
|
|||
import { VibePreset } from '../vibes';
|
||||
import {
|
||||
GardenAudioChord,
|
||||
GardenAudioColorPool,
|
||||
GardenAudioConfig,
|
||||
GardenAudioRegister,
|
||||
GardenAudioStylePool,
|
||||
GardenAudioVibeProfile,
|
||||
} from './garden-audio-config';
|
||||
import {
|
||||
|
|
@ -12,13 +12,14 @@ import {
|
|||
getChordIntervals,
|
||||
getVibeProfile,
|
||||
} from './garden-audio-music';
|
||||
import { GardenAudioColorIndex, PianoNote } from './garden-audio-types';
|
||||
import { PianoNote } from './garden-audio-types';
|
||||
|
||||
type GardenAudioStyleIndex = 0 | 1 | 2;
|
||||
|
||||
interface RenderLookaheadRequest {
|
||||
vibe: VibePreset;
|
||||
now: number;
|
||||
activity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
lookaheadSeconds?: number;
|
||||
}
|
||||
|
||||
|
|
@ -26,14 +27,6 @@ interface StrokeAccentRequest {
|
|||
vibe: VibePreset;
|
||||
now: number;
|
||||
activity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
mirrorAmount?: number;
|
||||
panBias?: number;
|
||||
registerBias?: number;
|
||||
brightnessBias?: number;
|
||||
contour?: number;
|
||||
pressureAmount?: number;
|
||||
pressureDelta?: number;
|
||||
maniaAmount?: number;
|
||||
}
|
||||
|
||||
|
|
@ -41,14 +34,6 @@ interface TouchDownRequest {
|
|||
vibe: VibePreset;
|
||||
now: number;
|
||||
strength: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
mirrorAmount?: number;
|
||||
panBias?: number;
|
||||
registerBias?: number;
|
||||
brightnessBias?: number;
|
||||
contour?: number;
|
||||
pressureAmount?: number;
|
||||
pressureDelta?: number;
|
||||
maniaAmount?: number;
|
||||
}
|
||||
|
||||
|
|
@ -66,16 +51,9 @@ interface BrushPhraseLayer {
|
|||
vibe: VibePreset;
|
||||
startedAt: number;
|
||||
expiresAt: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
styleIndex: GardenAudioStyleIndex;
|
||||
energy: number;
|
||||
mirrorAmount: number;
|
||||
motifOffsets: Array<number>;
|
||||
panBias: number;
|
||||
registerBias: number;
|
||||
brightnessBias: number;
|
||||
contour: number;
|
||||
pressureAmount: number;
|
||||
pressureDelta: number;
|
||||
maniaAmount: number;
|
||||
}
|
||||
|
||||
|
|
@ -86,7 +64,7 @@ export class GenerativePianoEngine {
|
|||
private isWaitingForGestureAccent = false;
|
||||
private lastGestureAccentAt = Number.NEGATIVE_INFINITY;
|
||||
private lastStrokeAccentAt = Number.NEGATIVE_INFINITY;
|
||||
private readonly lastMidiByColor: [number | null, number | null, number | null] = [
|
||||
private readonly lastMidiByStyle: [number | null, number | null, number | null] = [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
|
|
@ -130,31 +108,15 @@ export class GenerativePianoEngine {
|
|||
this.isWaitingForGestureAccent = false;
|
||||
}
|
||||
|
||||
public recordTouchDown({
|
||||
private recordTouchDown({
|
||||
vibe,
|
||||
now,
|
||||
strength,
|
||||
selectedColorIndex,
|
||||
mirrorAmount = 0,
|
||||
panBias = 0,
|
||||
registerBias = 0,
|
||||
brightnessBias = 0.5,
|
||||
contour = 0,
|
||||
pressureAmount = 0,
|
||||
pressureDelta = 0,
|
||||
maniaAmount = 0,
|
||||
}: TouchDownRequest): void {
|
||||
const normalizedStrength = clamp01(strength);
|
||||
const normalizedMirrorAmount = clamp01(mirrorAmount);
|
||||
const normalizedMotif = this.normalizeMotif({
|
||||
panBias,
|
||||
registerBias,
|
||||
brightnessBias,
|
||||
contour,
|
||||
pressureAmount,
|
||||
pressureDelta,
|
||||
maniaAmount,
|
||||
});
|
||||
const normalizedManiaAmount = clamp01(maniaAmount);
|
||||
const styleIndex = this.getStyleIndex(now);
|
||||
|
||||
this.isWaitingForGestureAccent = false;
|
||||
this.lastGestureAccentAt = now;
|
||||
|
|
@ -163,18 +125,14 @@ export class GenerativePianoEngine {
|
|||
vibe,
|
||||
now,
|
||||
strength: normalizedStrength,
|
||||
selectedColorIndex,
|
||||
mirrorAmount: normalizedMirrorAmount,
|
||||
...normalizedMotif,
|
||||
styleIndex,
|
||||
maniaAmount: normalizedManiaAmount,
|
||||
});
|
||||
this.playTouchNote({
|
||||
vibe,
|
||||
now,
|
||||
selectedColorIndex,
|
||||
styleIndex,
|
||||
strength: normalizedStrength,
|
||||
panBias: normalizedMotif.panBias,
|
||||
registerBias: normalizedMotif.registerBias,
|
||||
brightnessBias: normalizedMotif.brightnessBias,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -182,27 +140,11 @@ export class GenerativePianoEngine {
|
|||
vibe,
|
||||
now,
|
||||
activity,
|
||||
selectedColorIndex,
|
||||
mirrorAmount = 0,
|
||||
panBias = 0,
|
||||
registerBias = 0,
|
||||
brightnessBias = 0.5,
|
||||
contour = 0,
|
||||
pressureAmount = 0,
|
||||
pressureDelta = 0,
|
||||
maniaAmount = 0,
|
||||
}: StrokeAccentRequest): void {
|
||||
const strength = clamp01(activity);
|
||||
const normalizedMirrorAmount = clamp01(mirrorAmount);
|
||||
const normalizedMotif = this.normalizeMotif({
|
||||
panBias,
|
||||
registerBias,
|
||||
brightnessBias,
|
||||
contour,
|
||||
pressureAmount,
|
||||
pressureDelta,
|
||||
maniaAmount,
|
||||
});
|
||||
const normalizedManiaAmount = clamp01(maniaAmount);
|
||||
const styleIndex = this.getStyleIndex(now);
|
||||
|
||||
if (
|
||||
this.isWaitingForGestureAccent &&
|
||||
|
|
@ -212,9 +154,7 @@ export class GenerativePianoEngine {
|
|||
vibe,
|
||||
now,
|
||||
strength,
|
||||
selectedColorIndex,
|
||||
mirrorAmount: normalizedMirrorAmount,
|
||||
...normalizedMotif,
|
||||
maniaAmount: normalizedManiaAmount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -223,16 +163,15 @@ export class GenerativePianoEngine {
|
|||
this.updateBrushPhraseLayer({
|
||||
now,
|
||||
strength,
|
||||
selectedColorIndex,
|
||||
mirrorAmount: normalizedMirrorAmount,
|
||||
...normalizedMotif,
|
||||
styleIndex,
|
||||
maniaAmount: normalizedManiaAmount,
|
||||
});
|
||||
if (
|
||||
strength >= this.generation.strokeAccentThreshold &&
|
||||
now - this.lastStrokeAccentAt >= this.generation.strokeAccentMinIntervalSeconds
|
||||
) {
|
||||
this.lastStrokeAccentAt = now;
|
||||
this.playGestureAccent(vibe, now, selectedColorIndex, strength, 1);
|
||||
this.playGestureAccent(vibe, now, styleIndex, strength);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -240,7 +179,6 @@ export class GenerativePianoEngine {
|
|||
vibe,
|
||||
now,
|
||||
activity,
|
||||
selectedColorIndex,
|
||||
lookaheadSeconds = this.config.rhythm.lookaheadSeconds,
|
||||
}: RenderLookaheadRequest): void {
|
||||
this.prime(now);
|
||||
|
|
@ -250,7 +188,7 @@ export class GenerativePianoEngine {
|
|||
return;
|
||||
}
|
||||
|
||||
const profile = getVibeProfile(this.config, vibe);
|
||||
const profile = getVibeProfile(vibe);
|
||||
const lookaheadEnd = now + lookaheadSeconds;
|
||||
while (this.nextBeatAt <= lookaheadEnd) {
|
||||
this.renderBeat({
|
||||
|
|
@ -258,7 +196,6 @@ export class GenerativePianoEngine {
|
|||
beatIndex: this.beatIndex,
|
||||
startTime: this.nextBeatAt,
|
||||
expression: this.getExpression(activity),
|
||||
selectedColorIndex,
|
||||
});
|
||||
this.nextBeatAt += this.getBeatDurationSeconds();
|
||||
this.beatIndex += 1;
|
||||
|
|
@ -268,12 +205,11 @@ export class GenerativePianoEngine {
|
|||
now,
|
||||
lookaheadEnd,
|
||||
activity,
|
||||
selectedColorIndex,
|
||||
});
|
||||
}
|
||||
|
||||
public playVibeChangeStinger(vibe: VibePreset, now: number): void {
|
||||
const profile = getVibeProfile(this.config, vibe);
|
||||
const profile = getVibeProfile(vibe);
|
||||
const chord = this.getChord(profile, 0);
|
||||
const intervals = getChordIntervals(chord, true);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
|
|
@ -324,9 +260,9 @@ export class GenerativePianoEngine {
|
|||
this.isWaitingForGestureAccent = false;
|
||||
this.lastGestureAccentAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastStrokeAccentAt = Number.NEGATIVE_INFINITY;
|
||||
this.lastMidiByColor[0] = null;
|
||||
this.lastMidiByColor[1] = null;
|
||||
this.lastMidiByColor[2] = null;
|
||||
this.lastMidiByStyle[0] = null;
|
||||
this.lastMidiByStyle[1] = null;
|
||||
this.lastMidiByStyle[2] = null;
|
||||
this.brushPhraseLayers = [];
|
||||
this.nextBrushStreamAt = null;
|
||||
this.brushStreamNoteIndex = 0;
|
||||
|
|
@ -338,31 +274,30 @@ export class GenerativePianoEngine {
|
|||
beatIndex,
|
||||
startTime,
|
||||
expression,
|
||||
selectedColorIndex,
|
||||
}: {
|
||||
profile: GardenAudioVibeProfile;
|
||||
beatIndex: number;
|
||||
startTime: number;
|
||||
expression: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
}): void {
|
||||
const beatsPerBar = this.getBeatsPerBar();
|
||||
const beatInBar = beatIndex % beatsPerBar;
|
||||
const barIndex = Math.floor(beatIndex / beatsPerBar);
|
||||
const styleIndex = this.getStyleIndex(startTime);
|
||||
|
||||
if (beatInBar === 0 && barIndex % this.generation.chordBars === 0) {
|
||||
this.playPadChord(profile, barIndex, startTime, expression);
|
||||
}
|
||||
|
||||
if (beatInBar === 0 && this.shouldPlaySupport(expression, barIndex)) {
|
||||
this.playSupportNote(profile, barIndex, startTime, expression, selectedColorIndex);
|
||||
this.playSupportNote(profile, barIndex, startTime, expression, styleIndex);
|
||||
}
|
||||
|
||||
if (
|
||||
beatInBar === this.generation.textureBeat &&
|
||||
this.shouldPlayTexture(expression, barIndex)
|
||||
) {
|
||||
this.playTextureNote(profile, barIndex, startTime, expression, selectedColorIndex);
|
||||
this.playTextureNote(profile, barIndex, startTime, expression, styleIndex);
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
@ -374,7 +309,7 @@ export class GenerativePianoEngine {
|
|||
barIndex + 1,
|
||||
startTime,
|
||||
expression * 0.9,
|
||||
selectedColorIndex
|
||||
styleIndex
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -429,31 +364,31 @@ export class GenerativePianoEngine {
|
|||
barIndex: number,
|
||||
startTime: number,
|
||||
expression: number,
|
||||
selectedColorIndex: GardenAudioColorIndex
|
||||
styleIndex: GardenAudioStyleIndex
|
||||
): void {
|
||||
const pool = this.generation.colorPools[selectedColorIndex];
|
||||
const pool = this.generation.stylePools[styleIndex];
|
||||
const chord = this.getChord(profile, barIndex);
|
||||
const chordIntervals = getChordIntervals(chord, false);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
const midi = this.chooseMidi(
|
||||
{
|
||||
baseMidi: rootMidi,
|
||||
offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex),
|
||||
offsets: this.getSupportOffsets(chordIntervals, styleIndex),
|
||||
},
|
||||
pool,
|
||||
this.lastMidiByColor[selectedColorIndex],
|
||||
this.lastMidiByStyle[styleIndex],
|
||||
true
|
||||
);
|
||||
|
||||
this.lastMidiByColor[selectedColorIndex] = midi;
|
||||
this.lastMidiByStyle[styleIndex] = midi;
|
||||
this.playNote({
|
||||
midi,
|
||||
velocity:
|
||||
(0.105 + expression * 0.07) *
|
||||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||
this.config.styleVoices[styleIndex].velocityMultiplier,
|
||||
startTime,
|
||||
durationSeconds: 1.35 + expression * 0.4,
|
||||
pan: this.getColorPan(selectedColorIndex),
|
||||
pan: this.getStylePan(styleIndex),
|
||||
delaySend: 0.016 + expression * 0.006,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, expression * 0.7),
|
||||
});
|
||||
|
|
@ -464,29 +399,29 @@ export class GenerativePianoEngine {
|
|||
barIndex: number,
|
||||
startTime: number,
|
||||
expression: number,
|
||||
selectedColorIndex: GardenAudioColorIndex
|
||||
styleIndex: GardenAudioStyleIndex
|
||||
): void {
|
||||
const pool = this.generation.colorPools[selectedColorIndex];
|
||||
const degrees = this.rotate(pool.scaleDegrees, barIndex + selectedColorIndex);
|
||||
const pool = this.generation.stylePools[styleIndex];
|
||||
const degrees = this.rotate(pool.scaleDegrees, barIndex + styleIndex);
|
||||
const midi = this.chooseMidi(
|
||||
{
|
||||
baseMidi: profile.rootMidi,
|
||||
offsets: degrees.map((degree) => degreeToSemitone(profile, degree)),
|
||||
},
|
||||
pool,
|
||||
this.lastMidiByColor[selectedColorIndex],
|
||||
this.lastMidiByStyle[styleIndex],
|
||||
true
|
||||
);
|
||||
|
||||
this.lastMidiByColor[selectedColorIndex] = midi;
|
||||
this.lastMidiByStyle[styleIndex] = midi;
|
||||
this.playNote({
|
||||
midi,
|
||||
velocity:
|
||||
(0.09 + expression * 0.08) *
|
||||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||
this.config.styleVoices[styleIndex].velocityMultiplier,
|
||||
startTime,
|
||||
durationSeconds: 0.62 + expression * 0.24,
|
||||
pan: this.getColorPan(selectedColorIndex),
|
||||
pan: this.getStylePan(styleIndex),
|
||||
delaySend: 0.016 + expression * 0.006,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, expression),
|
||||
});
|
||||
|
|
@ -495,95 +430,74 @@ export class GenerativePianoEngine {
|
|||
private playGestureAccent(
|
||||
vibe: VibePreset,
|
||||
now: number,
|
||||
selectedColorIndex: GardenAudioColorIndex,
|
||||
strength: number,
|
||||
noteCount: number
|
||||
styleIndex: GardenAudioStyleIndex,
|
||||
strength: number
|
||||
): void {
|
||||
const profile = getVibeProfile(this.config, vibe);
|
||||
const pool = this.generation.colorPools[selectedColorIndex];
|
||||
const profile = getVibeProfile(vibe);
|
||||
const pool = this.generation.stylePools[styleIndex];
|
||||
const degrees = this.rotate(pool.scaleDegrees, Math.round(strength * 3));
|
||||
|
||||
for (let index = 0; index < noteCount; index += 1) {
|
||||
const midi = this.chooseMidi(
|
||||
{
|
||||
baseMidi: profile.rootMidi,
|
||||
offsets: degrees
|
||||
.slice(index)
|
||||
.concat(degrees.slice(0, index))
|
||||
.map((degree) => degreeToSemitone(profile, degree)),
|
||||
},
|
||||
pool,
|
||||
this.lastMidiByColor[selectedColorIndex],
|
||||
true
|
||||
);
|
||||
const midi = this.chooseMidi(
|
||||
{
|
||||
baseMidi: profile.rootMidi,
|
||||
offsets: degrees.map((degree) => degreeToSemitone(profile, degree)),
|
||||
},
|
||||
pool,
|
||||
this.lastMidiByStyle[styleIndex],
|
||||
true
|
||||
);
|
||||
|
||||
this.lastMidiByColor[selectedColorIndex] = midi;
|
||||
this.playNote({
|
||||
midi,
|
||||
velocity:
|
||||
(0.12 + strength * 0.09) *
|
||||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||
startTime:
|
||||
now +
|
||||
this.config.startDelaySeconds +
|
||||
index * this.generation.gestureAccentSpacingSeconds,
|
||||
durationSeconds: 0.48 + strength * 0.22,
|
||||
pan: this.getColorPan(selectedColorIndex),
|
||||
delaySend: 0.012,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, strength),
|
||||
});
|
||||
}
|
||||
this.lastMidiByStyle[styleIndex] = midi;
|
||||
this.playNote({
|
||||
midi,
|
||||
velocity:
|
||||
(0.12 + strength * 0.09) * this.config.styleVoices[styleIndex].velocityMultiplier,
|
||||
startTime: now + this.config.startDelaySeconds,
|
||||
durationSeconds: 0.48 + strength * 0.22,
|
||||
pan: this.getStylePan(styleIndex),
|
||||
delaySend: 0.012,
|
||||
lowpassHz: this.getLowpassHz(profile, midi, strength),
|
||||
});
|
||||
}
|
||||
|
||||
private playTouchNote({
|
||||
vibe,
|
||||
now,
|
||||
selectedColorIndex,
|
||||
styleIndex,
|
||||
strength,
|
||||
panBias,
|
||||
registerBias,
|
||||
brightnessBias,
|
||||
}: {
|
||||
vibe: VibePreset;
|
||||
now: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
styleIndex: GardenAudioStyleIndex;
|
||||
strength: number;
|
||||
panBias: number;
|
||||
registerBias: number;
|
||||
brightnessBias: number;
|
||||
}): void {
|
||||
const profile = getVibeProfile(this.config, vibe);
|
||||
const pool = this.generation.colorPools[selectedColorIndex];
|
||||
const register = this.getBiasedRegister(pool, registerBias, 0);
|
||||
const profile = getVibeProfile(vibe);
|
||||
const pool = this.generation.stylePools[styleIndex];
|
||||
const register = this.getBiasedRegister(pool, 0);
|
||||
const chord = this.getChord(profile, this.getGlobalBarIndex(now));
|
||||
const chordIntervals = getChordIntervals(chord, false);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
const midi = this.chooseMidi(
|
||||
{
|
||||
baseMidi: rootMidi,
|
||||
offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex),
|
||||
offsets: this.getSupportOffsets(chordIntervals, styleIndex),
|
||||
},
|
||||
register,
|
||||
this.lastMidiByColor[selectedColorIndex],
|
||||
this.lastMidiByStyle[styleIndex],
|
||||
true
|
||||
);
|
||||
|
||||
this.lastMidiByColor[selectedColorIndex] = midi;
|
||||
this.lastMidiByStyle[styleIndex] = midi;
|
||||
this.lastBrushStreamMidi = midi;
|
||||
this.playNote({
|
||||
midi,
|
||||
velocity:
|
||||
(0.14 + strength * 0.11) *
|
||||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||
(0.14 + strength * 0.11) * this.config.styleVoices[styleIndex].velocityMultiplier,
|
||||
startTime: now,
|
||||
durationSeconds: 0.55 + strength * 0.18,
|
||||
pan: this.getLayerPan(selectedColorIndex, panBias, 0, 0),
|
||||
pan: this.getStylePan(styleIndex),
|
||||
delaySend: 0.006,
|
||||
lowpassHz: this.getLowpassHz(
|
||||
profile,
|
||||
midi,
|
||||
clamp01(0.45 + strength * 0.35 + brightnessBias * 0.2)
|
||||
),
|
||||
lowpassHz: this.getLowpassHz(profile, midi, clamp01(0.55 + strength * 0.35)),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -591,52 +505,26 @@ export class GenerativePianoEngine {
|
|||
vibe,
|
||||
now,
|
||||
strength,
|
||||
selectedColorIndex,
|
||||
mirrorAmount,
|
||||
panBias,
|
||||
registerBias,
|
||||
brightnessBias,
|
||||
contour,
|
||||
pressureAmount,
|
||||
pressureDelta,
|
||||
styleIndex,
|
||||
maniaAmount,
|
||||
}: {
|
||||
vibe: VibePreset;
|
||||
now: number;
|
||||
strength: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
mirrorAmount: number;
|
||||
panBias: number;
|
||||
registerBias: number;
|
||||
brightnessBias: number;
|
||||
contour: number;
|
||||
pressureAmount: number;
|
||||
pressureDelta: number;
|
||||
styleIndex: GardenAudioStyleIndex;
|
||||
maniaAmount: number;
|
||||
}): void {
|
||||
const lifetimeSeconds =
|
||||
this.generation.brushLayerBaseSeconds +
|
||||
strength * this.generation.brushLayerEnergySeconds +
|
||||
mirrorAmount * this.generation.brushLayerMirrorSeconds;
|
||||
strength * this.generation.brushLayerEnergySeconds;
|
||||
|
||||
this.brushPhraseLayers.push({
|
||||
vibe,
|
||||
startedAt: now,
|
||||
expiresAt: now + lifetimeSeconds,
|
||||
selectedColorIndex,
|
||||
styleIndex,
|
||||
energy: strength,
|
||||
mirrorAmount,
|
||||
motifOffsets: this.getInitialMotifOffsets({
|
||||
selectedColorIndex,
|
||||
registerBias,
|
||||
contour,
|
||||
}),
|
||||
panBias,
|
||||
registerBias,
|
||||
brightnessBias,
|
||||
contour,
|
||||
pressureAmount,
|
||||
pressureDelta,
|
||||
motifOffsets: [styleIndex - 1],
|
||||
maniaAmount,
|
||||
});
|
||||
|
||||
|
|
@ -650,26 +538,12 @@ export class GenerativePianoEngine {
|
|||
private updateBrushPhraseLayer({
|
||||
now,
|
||||
strength,
|
||||
selectedColorIndex,
|
||||
mirrorAmount,
|
||||
panBias,
|
||||
registerBias,
|
||||
brightnessBias,
|
||||
contour,
|
||||
pressureAmount,
|
||||
pressureDelta,
|
||||
styleIndex,
|
||||
maniaAmount,
|
||||
}: {
|
||||
now: number;
|
||||
strength: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
mirrorAmount: number;
|
||||
panBias: number;
|
||||
registerBias: number;
|
||||
brightnessBias: number;
|
||||
contour: number;
|
||||
pressureAmount: number;
|
||||
pressureDelta: number;
|
||||
styleIndex: GardenAudioStyleIndex;
|
||||
maniaAmount: number;
|
||||
}): void {
|
||||
const layer = this.brushPhraseLayers[this.brushPhraseLayers.length - 1];
|
||||
|
|
@ -677,20 +551,10 @@ export class GenerativePianoEngine {
|
|||
return;
|
||||
}
|
||||
|
||||
const followAmount = 0.24 + clamp01(strength) * 0.24;
|
||||
layer.selectedColorIndex = selectedColorIndex;
|
||||
layer.styleIndex = styleIndex;
|
||||
layer.energy = Math.max(layer.energy * 0.94, strength);
|
||||
layer.mirrorAmount = Math.max(layer.mirrorAmount * 0.96, mirrorAmount);
|
||||
layer.panBias = mix(layer.panBias, panBias, followAmount);
|
||||
layer.registerBias = mix(layer.registerBias, registerBias, followAmount);
|
||||
layer.brightnessBias = mix(layer.brightnessBias, brightnessBias, followAmount);
|
||||
layer.contour = mix(layer.contour, contour, followAmount);
|
||||
layer.pressureAmount = mix(layer.pressureAmount, pressureAmount, followAmount);
|
||||
layer.pressureDelta = pressureDelta;
|
||||
layer.maniaAmount = Math.max(layer.maniaAmount * 0.92, maniaAmount);
|
||||
layer.motifOffsets.push(
|
||||
this.getMotifOffset({ registerBias, contour, pressureDelta, strength })
|
||||
);
|
||||
layer.motifOffsets.push(this.getMotifOffset(strength));
|
||||
if (layer.motifOffsets.length > this.generation.brushMotifMaxSteps) {
|
||||
layer.motifOffsets = layer.motifOffsets.slice(-this.generation.brushMotifMaxSteps);
|
||||
}
|
||||
|
|
@ -701,13 +565,11 @@ export class GenerativePianoEngine {
|
|||
now,
|
||||
lookaheadEnd,
|
||||
activity,
|
||||
selectedColorIndex,
|
||||
}: {
|
||||
vibe: VibePreset;
|
||||
now: number;
|
||||
lookaheadEnd: number;
|
||||
activity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
}): void {
|
||||
const earliestStart = now + this.config.piano.scheduleAheadSeconds;
|
||||
this.nextBrushStreamAt ??= now + this.config.startDelaySeconds;
|
||||
|
|
@ -729,7 +591,7 @@ export class GenerativePianoEngine {
|
|||
vibe,
|
||||
startTime: this.nextBrushStreamAt,
|
||||
intensity: frame.intensity,
|
||||
selectedColorIndex: frame.selectedColorIndex ?? selectedColorIndex,
|
||||
styleIndex: this.getStyleIndex(this.nextBrushStreamAt),
|
||||
layer: frame.layer,
|
||||
});
|
||||
}
|
||||
|
|
@ -742,23 +604,19 @@ export class GenerativePianoEngine {
|
|||
vibe,
|
||||
startTime,
|
||||
intensity,
|
||||
selectedColorIndex,
|
||||
styleIndex,
|
||||
layer,
|
||||
}: {
|
||||
vibe: VibePreset;
|
||||
startTime: number;
|
||||
intensity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
styleIndex: GardenAudioStyleIndex;
|
||||
layer: BrushPhraseLayer | null;
|
||||
}): void {
|
||||
const profile = getVibeProfile(this.config, vibe);
|
||||
const pool = this.generation.colorPools[selectedColorIndex];
|
||||
const profile = getVibeProfile(vibe);
|
||||
const pool = this.generation.stylePools[styleIndex];
|
||||
const maniaAmount = layer?.maniaAmount ?? clamp01((intensity - 0.82) / 0.18);
|
||||
const register = this.getBiasedRegister(
|
||||
pool,
|
||||
layer?.registerBias ?? 0,
|
||||
maniaAmount * 0.45
|
||||
);
|
||||
const register = this.getBiasedRegister(pool, maniaAmount * 0.45);
|
||||
const chord = this.getChord(profile, this.getGlobalBarIndex(startTime));
|
||||
const chordIntervals = getChordIntervals(chord, false);
|
||||
const rootMidi = profile.rootMidi + chord.rootOffset;
|
||||
|
|
@ -766,44 +624,35 @@ export class GenerativePianoEngine {
|
|||
const source = useChordTone
|
||||
? {
|
||||
baseMidi: rootMidi,
|
||||
offsets: this.getSupportOffsets(chordIntervals, selectedColorIndex),
|
||||
offsets: this.getSupportOffsets(chordIntervals, styleIndex),
|
||||
}
|
||||
: {
|
||||
baseMidi: profile.rootMidi,
|
||||
offsets: this.getBrushMotifDegrees({
|
||||
layer,
|
||||
pool,
|
||||
selectedColorIndex,
|
||||
styleIndex,
|
||||
}).map((degree) => degreeToSemitone(profile, degree)),
|
||||
};
|
||||
const midi = this.chooseMidi(source, register, this.lastBrushStreamMidi, true);
|
||||
const pan = this.getLayerPan(
|
||||
selectedColorIndex,
|
||||
layer?.panBias ?? 0,
|
||||
maniaAmount,
|
||||
layer?.mirrorAmount ?? 0
|
||||
);
|
||||
const pan = this.getStylePan(styleIndex);
|
||||
const durationSeconds = clamp(
|
||||
0.48 + intensity * 0.08 - maniaAmount * 0.34,
|
||||
0.14,
|
||||
0.62
|
||||
);
|
||||
const delaySend = clamp(
|
||||
0.012 +
|
||||
intensity * 0.011 +
|
||||
(layer?.mirrorAmount ?? 0) * 0.004 -
|
||||
maniaAmount * 0.006,
|
||||
0.012 + intensity * 0.011 - maniaAmount * 0.006,
|
||||
0.006,
|
||||
0.032
|
||||
);
|
||||
|
||||
this.lastBrushStreamMidi = midi;
|
||||
this.lastMidiByColor[selectedColorIndex] = midi;
|
||||
this.lastMidiByStyle[styleIndex] = midi;
|
||||
this.playNote({
|
||||
midi,
|
||||
velocity:
|
||||
(0.1 + intensity * 0.13) *
|
||||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||
(0.1 + intensity * 0.13) * this.config.styleVoices[styleIndex].velocityMultiplier,
|
||||
startTime,
|
||||
durationSeconds,
|
||||
pan,
|
||||
|
|
@ -811,12 +660,7 @@ export class GenerativePianoEngine {
|
|||
lowpassHz: this.getLowpassHz(
|
||||
profile,
|
||||
midi,
|
||||
clamp01(
|
||||
0.32 +
|
||||
intensity * 0.48 +
|
||||
(layer?.brightnessBias ?? 0.5) * 0.14 +
|
||||
maniaAmount * 0.18
|
||||
)
|
||||
clamp01(0.39 + intensity * 0.48 + maniaAmount * 0.18)
|
||||
),
|
||||
});
|
||||
|
||||
|
|
@ -829,11 +673,8 @@ export class GenerativePianoEngine {
|
|||
midi: echoMidi,
|
||||
velocity:
|
||||
(0.045 + intensity * 0.05) *
|
||||
this.config.colorVoices[selectedColorIndex].velocityMultiplier,
|
||||
startTime:
|
||||
startTime +
|
||||
this.generation.brushMotifCanonDelaySeconds +
|
||||
(layer?.mirrorAmount ?? 0) * 0.04,
|
||||
this.config.styleVoices[styleIndex].velocityMultiplier,
|
||||
startTime: startTime + this.generation.brushMotifCanonDelaySeconds,
|
||||
durationSeconds: Math.max(0.11, durationSeconds * 0.68),
|
||||
pan: clamp(-pan * 0.75, -1, 1),
|
||||
delaySend: Math.max(0.006, delaySend * 0.72),
|
||||
|
|
@ -847,7 +688,6 @@ export class GenerativePianoEngine {
|
|||
activity: number
|
||||
): {
|
||||
intensity: number;
|
||||
selectedColorIndex: GardenAudioColorIndex | null;
|
||||
layer: BrushPhraseLayer | null;
|
||||
} {
|
||||
const layerStates = this.brushPhraseLayers.map((layer) => ({
|
||||
|
|
@ -855,7 +695,7 @@ export class GenerativePianoEngine {
|
|||
intensity:
|
||||
layer.energy *
|
||||
this.getBrushPhraseFade(layer, startTime) *
|
||||
(0.8 + layer.mirrorAmount * 0.45 + layer.maniaAmount * 0.42),
|
||||
(0.8 + layer.maniaAmount * 0.42),
|
||||
}));
|
||||
const dominant = layerStates.reduce<{
|
||||
layer: BrushPhraseLayer;
|
||||
|
|
@ -875,7 +715,6 @@ export class GenerativePianoEngine {
|
|||
intensity: clamp01(
|
||||
activity * 0.42 + layeredIntensity + (dominant?.layer.maniaAmount ?? 0) * 0.18
|
||||
),
|
||||
selectedColorIndex: dominant?.layer.selectedColorIndex ?? null,
|
||||
layer: dominant?.layer ?? null,
|
||||
};
|
||||
}
|
||||
|
|
@ -898,98 +737,28 @@ export class GenerativePianoEngine {
|
|||
return clamp01(1 - ageSeconds / Math.max(0.001, lifetimeSeconds));
|
||||
}
|
||||
|
||||
private normalizeMotif({
|
||||
panBias,
|
||||
registerBias,
|
||||
brightnessBias,
|
||||
contour,
|
||||
pressureAmount,
|
||||
pressureDelta,
|
||||
maniaAmount,
|
||||
}: {
|
||||
panBias: number;
|
||||
registerBias: number;
|
||||
brightnessBias: number;
|
||||
contour: number;
|
||||
pressureAmount: number;
|
||||
pressureDelta: number;
|
||||
maniaAmount: number;
|
||||
}): {
|
||||
panBias: number;
|
||||
registerBias: number;
|
||||
brightnessBias: number;
|
||||
contour: number;
|
||||
pressureAmount: number;
|
||||
pressureDelta: number;
|
||||
maniaAmount: number;
|
||||
} {
|
||||
return {
|
||||
panBias: clamp(panBias, -1, 1),
|
||||
registerBias: clamp(registerBias, -1, 1),
|
||||
brightnessBias: clamp01(brightnessBias),
|
||||
contour: clamp(contour, -1, 1),
|
||||
pressureAmount: clamp01(pressureAmount),
|
||||
pressureDelta: clamp(pressureDelta, -1, 1),
|
||||
maniaAmount: clamp01(maniaAmount),
|
||||
};
|
||||
}
|
||||
|
||||
private getInitialMotifOffsets({
|
||||
selectedColorIndex,
|
||||
registerBias,
|
||||
contour,
|
||||
}: {
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
registerBias: number;
|
||||
contour: number;
|
||||
}): Array<number> {
|
||||
const start = selectedColorIndex - 1 + Math.round(registerBias);
|
||||
const motion = contour > 0.2 ? 1 : contour < -0.2 ? -1 : 0;
|
||||
return [start, start + motion, start + motion * 2, start + motion];
|
||||
}
|
||||
|
||||
private getMotifOffset({
|
||||
registerBias,
|
||||
contour,
|
||||
pressureDelta,
|
||||
strength,
|
||||
}: {
|
||||
registerBias: number;
|
||||
contour: number;
|
||||
pressureDelta: number;
|
||||
strength: number;
|
||||
}): number {
|
||||
const contourStep = contour > 0.3 ? 1 : contour < -0.3 ? -1 : 0;
|
||||
const registerStep = Math.round(registerBias * 2);
|
||||
const pressureStep = pressureDelta > 0.08 ? 1 : pressureDelta < -0.08 ? -1 : 0;
|
||||
private getMotifOffset(strength: number): number {
|
||||
const energyStep = strength >= 0.82 ? 1 : strength >= 0.55 ? 0 : -1;
|
||||
return clamp(contourStep + registerStep + pressureStep + energyStep, -3, 4);
|
||||
return clamp(energyStep, -3, 4);
|
||||
}
|
||||
|
||||
private getBrushMotifDegrees({
|
||||
layer,
|
||||
pool,
|
||||
selectedColorIndex,
|
||||
styleIndex,
|
||||
}: {
|
||||
layer: BrushPhraseLayer | null;
|
||||
pool: GardenAudioColorPool;
|
||||
selectedColorIndex: GardenAudioColorIndex;
|
||||
pool: GardenAudioStylePool;
|
||||
styleIndex: GardenAudioStyleIndex;
|
||||
}): Array<number> {
|
||||
const colorOffset = this.config.colorVoices[selectedColorIndex].scaleDegreeOffset;
|
||||
const styleOffset = this.config.styleVoices[styleIndex].scaleDegreeOffset;
|
||||
if (!layer || layer.motifOffsets.length === 0) {
|
||||
return this.rotate(pool.scaleDegrees, this.brushStreamNoteIndex + colorOffset);
|
||||
return this.rotate(pool.scaleDegrees, this.brushStreamNoteIndex + styleOffset);
|
||||
}
|
||||
|
||||
const motifOffset =
|
||||
layer.motifOffsets[this.brushStreamNoteIndex % layer.motifOffsets.length];
|
||||
const contourOffset =
|
||||
layer.contour > 0.28
|
||||
? this.brushStreamNoteIndex % 3
|
||||
: layer.contour < -0.28
|
||||
? -(this.brushStreamNoteIndex % 3)
|
||||
: 0;
|
||||
const pressureLift = layer.pressureAmount > 0.68 ? 1 : 0;
|
||||
const baseOffset = colorOffset + motifOffset + contourOffset + pressureLift;
|
||||
const baseOffset = styleOffset + motifOffset;
|
||||
|
||||
return this.rotate(
|
||||
pool.scaleDegrees.map((degree) => degree + baseOffset),
|
||||
|
|
@ -999,10 +768,9 @@ export class GenerativePianoEngine {
|
|||
|
||||
private getBiasedRegister(
|
||||
register: GardenAudioRegister,
|
||||
registerBias: number,
|
||||
maniaAmount: number
|
||||
): GardenAudioRegister {
|
||||
const shift = Math.round(registerBias * 7 + maniaAmount * 4);
|
||||
const shift = Math.round(maniaAmount * 4);
|
||||
const midiMin = clamp(register.midiMin + shift, 36, 86);
|
||||
const midiMax = clamp(register.midiMax + shift, midiMin + 4, 91);
|
||||
|
||||
|
|
@ -1014,26 +782,6 @@ export class GenerativePianoEngine {
|
|||
};
|
||||
}
|
||||
|
||||
private getLayerPan(
|
||||
selectedColorIndex: GardenAudioColorIndex,
|
||||
panBias: number,
|
||||
maniaAmount: number,
|
||||
mirrorAmount: number
|
||||
): number {
|
||||
const shimmer =
|
||||
maniaAmount > 0.4
|
||||
? Math.sin(this.brushStreamNoteIndex * Math.PI * 0.5) * mirrorAmount * 0.14
|
||||
: 0;
|
||||
|
||||
return clamp(
|
||||
this.getColorPan(selectedColorIndex) +
|
||||
panBias * (0.18 + maniaAmount * 0.42) +
|
||||
shimmer,
|
||||
-1,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
private chooseMidi(
|
||||
pitchSource: PitchSource,
|
||||
register: GardenAudioRegister,
|
||||
|
|
@ -1113,13 +861,13 @@ export class GenerativePianoEngine {
|
|||
|
||||
private getSupportOffsets(
|
||||
chordIntervals: ReadonlyArray<number>,
|
||||
selectedColorIndex: GardenAudioColorIndex
|
||||
styleIndex: GardenAudioStyleIndex
|
||||
): Array<number> {
|
||||
if (selectedColorIndex === 0) {
|
||||
if (styleIndex === 0) {
|
||||
return [0, chordIntervals[2], 12];
|
||||
}
|
||||
|
||||
if (selectedColorIndex === 1) {
|
||||
if (styleIndex === 1) {
|
||||
return [chordIntervals[1], chordIntervals[2], 0, 12];
|
||||
}
|
||||
|
||||
|
|
@ -1138,10 +886,19 @@ export class GenerativePianoEngine {
|
|||
return Math.floor(elapsedSeconds / this.getBarDurationSeconds());
|
||||
}
|
||||
|
||||
private getColorPan(selectedColorIndex: GardenAudioColorIndex): number {
|
||||
const pool = this.generation.colorPools[selectedColorIndex];
|
||||
const colorVoice = this.config.colorVoices[selectedColorIndex];
|
||||
return clamp(pool.pan + colorVoice.panOffset * 0.35, -1, 1);
|
||||
private getStyleIndex(startTime: number): GardenAudioStyleIndex {
|
||||
const timelineStartedAt = this.timelineStartedAt ?? startTime;
|
||||
const elapsedSeconds = Math.max(0, startTime - timelineStartedAt);
|
||||
const styleCount = this.generation.stylePools.length;
|
||||
const rotationSeconds = Math.max(0.001, this.generation.styleRotationSeconds);
|
||||
return (Math.floor(elapsedSeconds / rotationSeconds) %
|
||||
styleCount) as GardenAudioStyleIndex;
|
||||
}
|
||||
|
||||
private getStylePan(styleIndex: GardenAudioStyleIndex): number {
|
||||
const pool = this.generation.stylePools[styleIndex];
|
||||
const styleVoice = this.config.styleVoices[styleIndex];
|
||||
return clamp(pool.pan + styleVoice.panOffset * 0.35, -1, 1);
|
||||
}
|
||||
|
||||
private getLowpassHz(
|
||||
|
|
@ -1199,6 +956,3 @@ export class GenerativePianoEngine {
|
|||
return values.map((_, index) => values[(index + offset) % values.length]);
|
||||
}
|
||||
}
|
||||
|
||||
const mix = (from: number, to: number, amount: number): number =>
|
||||
from + (to - from) * clamp01(amount);
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import { gardenAudioConfig } from './garden-audio-config';
|
||||
import type { GardenAudioGraph } from './garden-audio-graph';
|
||||
import { PianoSampler } from './piano-sampler';
|
||||
import { pianoSampleDefinitions, resetPianoSampleCacheForTest } from './piano-samples';
|
||||
import type { PianoSampler } from './piano-sampler';
|
||||
|
||||
const calls = {
|
||||
bufferSourcesStarted: 0,
|
||||
};
|
||||
const sampleCount = 30;
|
||||
|
||||
class FakeAudioParam {
|
||||
public value = 0;
|
||||
|
|
@ -61,7 +61,8 @@ class FakeAudioContext {
|
|||
}
|
||||
}
|
||||
|
||||
const makeSampler = (context: AudioContext): PianoSampler => {
|
||||
const makeSampler = async (context: AudioContext): Promise<PianoSampler> => {
|
||||
const { PianoSampler } = await import('./piano-sampler');
|
||||
const eventBus = new FakeAudioNode() as unknown as GainNode;
|
||||
const graph = {
|
||||
context,
|
||||
|
|
@ -75,17 +76,16 @@ const makeSampler = (context: AudioContext): PianoSampler => {
|
|||
describe('PianoSampler', () => {
|
||||
beforeEach(() => {
|
||||
calls.bufferSourcesStarted = 0;
|
||||
resetPianoSampleCacheForTest();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
resetPianoSampleCacheForTest();
|
||||
});
|
||||
|
||||
it('loads every piano sample before playback', async () => {
|
||||
const context = new FakeAudioContext() as unknown as AudioContext;
|
||||
const sampler = makeSampler(context);
|
||||
const sampler = await makeSampler(context);
|
||||
const fetch = vi.fn(async () => {
|
||||
return {
|
||||
arrayBuffer: async () => new ArrayBuffer(8),
|
||||
|
|
@ -103,23 +103,23 @@ describe('PianoSampler', () => {
|
|||
velocity: 0.5,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(pianoSampleDefinitions.length);
|
||||
expect(context.decodeAudioData).toHaveBeenCalledTimes(pianoSampleDefinitions.length);
|
||||
expect(fetch).toHaveBeenCalledTimes(sampleCount);
|
||||
expect(context.decodeAudioData).toHaveBeenCalledTimes(sampleCount);
|
||||
expect(calls.bufferSourcesStarted).toBe(1);
|
||||
});
|
||||
|
||||
it('stays silent when no decoded sample is available', () => {
|
||||
const context = new FakeAudioContext() as unknown as AudioContext;
|
||||
const sampler = makeSampler(context);
|
||||
return makeSampler(context).then((sampler) => {
|
||||
sampler.play({
|
||||
durationSeconds: 0.2,
|
||||
midi: 60,
|
||||
pan: 0,
|
||||
startTime: context.currentTime,
|
||||
velocity: 0.5,
|
||||
});
|
||||
|
||||
sampler.play({
|
||||
durationSeconds: 0.2,
|
||||
midi: 60,
|
||||
pan: 0,
|
||||
startTime: context.currentTime,
|
||||
velocity: 0.5,
|
||||
expect(calls.bufferSourcesStarted).toBe(0);
|
||||
});
|
||||
|
||||
expect(calls.bufferSourcesStarted).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ export class PianoSampler {
|
|||
|
||||
source.start(scheduledStart);
|
||||
source.stop(stopAt + this.config.piano.tailStopExtraSeconds);
|
||||
this.activeVoices.push({ gain, source, startAt: scheduledStart, stopAt });
|
||||
this.activeVoices.push({ gain, source, stopAt });
|
||||
|
||||
source.addEventListener(
|
||||
'ended',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { LoadedPianoSample } from './garden-audio-types';
|
||||
|
||||
export interface PianoSampleDefinition {
|
||||
interface PianoSampleDefinition {
|
||||
midi: number;
|
||||
url: string;
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ const sampleFiles: Array<[fileName: string, midi: number]> = [
|
|||
['C8v12.m4a', 108],
|
||||
];
|
||||
|
||||
export const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
||||
const pianoSampleDefinitions: Array<PianoSampleDefinition> = sampleFiles
|
||||
.map(([fileName, midi]) => ({
|
||||
midi,
|
||||
url: `${sampleBaseUrl}${fileName}`,
|
||||
|
|
@ -116,11 +116,6 @@ export const loadPianoSamples = (
|
|||
export const getLoadedPianoSamples = (): Array<LoadedPianoSample> | null =>
|
||||
loadedPianoSamples ? [...loadedPianoSamples] : null;
|
||||
|
||||
export const resetPianoSampleCacheForTest = (): void => {
|
||||
loadedPianoSamples = null;
|
||||
pianoSampleLoadPromise = null;
|
||||
};
|
||||
|
||||
const loadPianoSample = async (
|
||||
decodeContext: BaseAudioContext,
|
||||
sample: PianoSampleDefinition
|
||||
|
|
|
|||
Loading…
Reference in a new issue