Skip to content

Commit

Permalink
Update example
Browse files Browse the repository at this point in the history
  • Loading branch information
jonikorpi committed Jan 20, 2025
1 parent e61acad commit 714d502
Show file tree
Hide file tree
Showing 13 changed files with 925 additions and 391 deletions.
86 changes: 86 additions & 0 deletions example/AudioControls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { html, render } from "lit-html";
import { live } from "lit-html/directives/live.js";
import { AudioSystem, configureReverb, defaultReverbOptions } from "./AudioSystem.js";
import { Magic } from "./magic.js";

export const AudioControls = new Magic(() => {
const { audioContext, mainGain, effectsGain, musicGain } = AudioSystem.get();

const isRunning = audioContext.state === "running";
const action = () => (isRunning ? audioContext.suspend() : audioContext.resume());
const actionTitle = isRunning ? "Stop" : "Play";

const onStateChange = () => {
AudioControls.update({ type: "refresh" });
};

audioContext.addEventListener("statechange", onStateChange, { once: true });

const reverbSliders = [];

for (const key in defaultReverbOptions) {
reverbSliders.push(reverbSlider(key as keyof typeof defaultReverbOptions));
}

return render(
html`
<fieldset>
<legend>Volume</legend>
${volumeSlider("Main", mainGain, 1.0)}
<!-- ${volumeSlider("Effects", effectsGain, 0.5)} -->
${volumeSlider("Music", musicGain, 0.5)}
</fieldset>
<datalist id="gain-steps">
<option value="0.5"></option>
</datalist>
<fieldset>
<legend>Reverb</legend>
${reverbSliders}
</fieldset>
<fieldset>
<button type="button" @click=${action}>${actionTitle}</button>
</fieldset>
`,
document.getElementById("audio-controls") as HTMLElement,
);
});

const volumeSlider = (title: string, gainNode: GainNode, maxGain: number) => {
const handleInput = ({ target }: Event) => {
if (!(target instanceof HTMLInputElement)) return;
gainNode.gain.setTargetAtTime(Number.parseFloat(target.value) * maxGain, gainNode.context.currentTime, 0.001);
};

return html`
<label>
<span>${title}</span>
<input
type="range"
.value=${live(gainNode.gain.value / maxGain)}
@input=${handleInput}
min="0"
max="1"
step="0.025"
list="gain-steps"
/>
</label>
`;
};

const reverbSlider = (title: keyof typeof defaultReverbOptions) => {
const handleInput = async ({ target }: Event) => {
if (!(target instanceof HTMLInputElement)) return;
const { audioContext, reverb } = AudioSystem.get();
configureReverb(audioContext, await reverb, { [title]: Number.parseFloat(target.value) });
};

return html`
<label>
<span>${title}</span>
<input type="range" @input=${handleInput} min="0" max="1" step="0.0001" value="${defaultReverbOptions[title]}" />
</label>
`;
};
113 changes: 0 additions & 113 deletions example/AudioSystem.js

This file was deleted.

182 changes: 182 additions & 0 deletions example/AudioSystem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import * as instrumentPresets from "../instrumentPresets.js";
import type { createInstrument } from "../instruments.js";
import dattorroReverb from "./dattorro-reverb.js?url";
import { Magic } from "./magic";

const allInstruments = new Map(Object.entries(instrumentPresets).map(([name, preset]) => [preset, name]));

export const AudioSystem = new Magic(
(previousAudioSystem?: {
audioContext: AudioContext;
mainGain: GainNode;
effectsGain: GainNode;
musicGain: GainNode;
musicCompressor: DynamicsCompressorNode;
effectsCompressor: DynamicsCompressorNode;
lowPass: BiquadFilterNode;
limiter: DynamicsCompressorNode;
output: AudioNode;
reverb: Promise<AudioWorkletNode>;
connectInstrument: (instrument: ReturnType<typeof createInstrument>) => void;
}) => {
if (previousAudioSystem) {
const { audioContext } = previousAudioSystem;
if (audioContext.state !== "closed") audioContext.close();
audioContext.removeEventListener("statechange", onStateChange);
}

// General nodes and reverb
const audioContext = new AudioContext();
const musicGain = new GainNode(audioContext, { gain: 0.5 });
const effectsGain = new GainNode(audioContext, { gain: 0.5 });
const mainGain = new GainNode(audioContext, { gain: 1.0 });
const effectsCompressor = new DynamicsCompressorNode(audioContext, { threshold: -24, ratio: 12 });
const musicCompressor = new DynamicsCompressorNode(audioContext, { threshold: -24, ratio: 12 });
const limiter = new DynamicsCompressorNode(audioContext, {
threshold: 0,
ratio: 1,
attack: 0.0001,
});
const highPass = new BiquadFilterNode(audioContext, { type: "highpass", frequency: 20 });
const lowPass = new BiquadFilterNode(audioContext, { type: "lowpass", frequency: 20000 });

musicCompressor.connect(musicGain).connect(mainGain);
effectsCompressor.connect(effectsGain).connect(mainGain);

lowPass.connect(highPass).connect(limiter).connect(audioContext.destination);

audioContext.addEventListener("statechange", onStateChange);

if ("mediaSession" in navigator) {
navigator.mediaSession.setActionHandler("pause", () => audioContext.suspend());
navigator.mediaSession.setActionHandler("play", () => audioContext.resume());
navigator.mediaSession.metadata = new MediaMetadata({
title: "Audio Playground",
artist: "Vuoro",
// album: "Playground",
// artwork: [{ src: "favicon.png" }],
});
}

let resolveReverb: (value: AudioWorkletNode) => void;
const reverb: Promise<AudioWorkletNode> = new Promise((resolve) => {
resolveReverb = resolve;
});

audioContext.audioWorklet
.addModule(dattorroReverb)
.then(() => {
const reverbNode = new AudioWorkletNode(audioContext, "DattorroReverb", {
outputChannelCount: [2],
});

configureReverb(audioContext, reverbNode, defaultReverbOptions);
mainGain.connect(reverbNode).connect(lowPass);

resolveReverb(reverbNode);
})
.catch((error) => {
mainGain.connect(lowPass);
(reportError || console.error)(error);
});

let panningIndex = Math.round(Math.random() * 10);

const connectInstrument = (instrument: ReturnType<typeof createInstrument>) => {
const panningCycle = 17;
const panningPositions = 29;
const panningSpread = 0.146;

const panningPosition = (panningCycle * panningIndex++) % panningPositions;
const pan = panningSpread * ((panningPosition / panningPositions) * 2.0 - 1.0);
console.log("connecting instrument", allInstruments.get(instrument?.preset), "panned by", pan);

const panner = new StereoPannerNode(audioContext, { pan });

panner.connect(musicCompressor);
instrument.output.connect(panner);
};

return {
audioContext,
mainGain,
effectsGain,
musicGain,
musicCompressor,
effectsCompressor,
lowPass,
limiter,
output: limiter,
reverb,
connectInstrument,
};
},
);

const onStateChange = function (this: AudioContext) {
if (this.state === "closed") {
AudioSystem.update({ type: "reopen" });
}
};

export const configureReverb = (
audioContext: AudioContext,
reverb: AudioWorkletNode,
options: {
/** extra distance the first reflection has to travel, in seconds; room-like sounds */
preDelay?: number;
/** first reflection lowpass filter weakness; hard spaces */
bandwidth?: number;
/** first reflection diffusion amount; uneven spaces */
inputDiffusion1?: number;
/** first reflection alternating diffusion amount; uneven spaces */
inputDiffusion2?: number;
/** echoiness; space enclosedness */
decay?: number;
/** diffusion amount; uneven spaces */
decayDiffusion1?: number;
/** alternating diffusion amount; uneven spaces */
decayDiffusion2?: number;
/** lowpass filter strength; soft spaces, */
damping?: number;
/** how quickly diffusors shift in time; wandering echo */
excursionRate?: number;
/** how much diffusors shift; booming echo */
excursionDepth?: number;
/** how much of the original sound is heard */
dry?: number;
/** how much of reverb is heard */
wet?: number;
},
/** `timeConstant` passed to `setTargetAtTime` when setting the new values */
speed = 0.618,
) => {
const time = audioContext.currentTime;

for (const key in options) {
const value = options[key as keyof typeof options]; // FIXME: ??? why does TS want me to do this?
if (value === undefined) continue;
const finalValue =
key === "preDelay"
? value * audioContext.sampleRate
: key === "excursionRate" || key === "excursionDepth"
? value * 2.0
: value;
reverb.parameters.get(key)?.setTargetAtTime(finalValue, time, speed);
}
};

export const defaultReverbOptions = {
preDelay: 0.034, // could be up to 0.04ms before being obvious
bandwidth: 0.987,
inputDiffusion1: 0.236,
inputDiffusion2: 0.382,
decay: 0.414,
decayDiffusion1: 0.764,
decayDiffusion2: 0.618,
damping: 0.013,
excursionRate: 0.236,
excursionDepth: 0.236,
dry: Math.SQRT1_2,
wet: 1.0 - Math.SQRT1_2,
};
Loading

0 comments on commit 714d502

Please sign in to comment.