-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
13 changed files
with
925 additions
and
391 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
`; | ||
}; |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; |
Oops, something went wrong.