Clean up
This commit is contained in:
parent
ea0304356f
commit
7c70f15e49
65 changed files with 1122 additions and 1906 deletions
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue