-
-
Notifications
You must be signed in to change notification settings - Fork 153
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(examples): add render-audio demo
- Loading branch information
1 parent
fe13b03
commit 565ec0e
Showing
10 changed files
with
419 additions
and
51 deletions.
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Large diffs are not rendered by default.
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,15 @@ | ||
# render-audio | ||
|
||
![screenshot](https://raw.githubusercontent.com/thi-ng/umbrella/develop/assets/examples/render-audio.png) | ||
|
||
[Live demo](http://demo.thi.ng/umbrella/render-audio/) | ||
|
||
Please refer to the [example build instructions](https://github.com/thi-ng/umbrella/wiki/Example-build-instructions) on the wiki. | ||
|
||
## Authors | ||
|
||
- Karsten Schmidt | ||
|
||
## License | ||
|
||
© 2023 Karsten Schmidt // Apache Software License 2.0 |
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,29 @@ | ||
<!DOCTYPE html> | ||
<html lang="en"> | ||
<head> | ||
<link | ||
rel="icon" | ||
href='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">⛱️</text></svg>' | ||
/> | ||
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8" /> | ||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
<meta http-equiv="X-UA-Compatible" content="ie=edge" /> | ||
<title>render-audio · @thi.ng/umbrella</title> | ||
<link | ||
href="https://unpkg.com/tachyons@4/css/tachyons.min.css" | ||
rel="stylesheet" | ||
/> | ||
<style></style> | ||
</head> | ||
<body class="ma2 sans-serif"> | ||
<div id="app"></div> | ||
<div> | ||
<a | ||
class="link" | ||
href="https://github.com/thi-ng/umbrella/tree/develop/examples/render-audio" | ||
>Source code</a | ||
> | ||
</div> | ||
<script type="module" src="/src/index.ts"></script> | ||
</body> | ||
</html> |
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,43 @@ | ||
{ | ||
"name": "@example/render-audio", | ||
"version": "0.0.1", | ||
"private": true, | ||
"description": "Generative audio synth offline renderer and WAV file export", | ||
"repository": "https://github.com/thi-ng/umbrella", | ||
"author": "Karsten Schmidt <k+npm@thi.ng>", | ||
"license": "Apache-2.0", | ||
"scripts": { | ||
"start": "vite --open", | ||
"build": "tsc && vite build --base='./'", | ||
"preview": "vite preview --host --open" | ||
}, | ||
"devDependencies": { | ||
"typescript": "^5.2.2", | ||
"vite": "^4.4.9" | ||
}, | ||
"dependencies": { | ||
"@thi.ng/dl-asset": "workspace:^", | ||
"@thi.ng/dsp": "workspace:^", | ||
"@thi.ng/dsp-io-wav": "workspace:^", | ||
"@thi.ng/fibers": "workspace:^", | ||
"@thi.ng/hex": "workspace:^", | ||
"@thi.ng/math": "workspace:^", | ||
"@thi.ng/random": "workspace:^", | ||
"@thi.ng/rdom": "workspace:^", | ||
"@thi.ng/rstream": "workspace:^", | ||
"@thi.ng/transducers": "workspace:^" | ||
}, | ||
"browser": { | ||
"process": false | ||
}, | ||
"thi.ng": { | ||
"readme": [ | ||
"dl-asset", | ||
"dsp", | ||
"dsp-io-wav", | ||
"fibers", | ||
"rdom" | ||
], | ||
"screenshot": "examples/render-audio.png" | ||
} | ||
} |
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,161 @@ | ||
import { | ||
AGen, | ||
adsr, | ||
biquadLP, | ||
filterFeedbackDelay, | ||
osc, | ||
pipe, | ||
product, | ||
sawAdditive, | ||
sin, | ||
svfLP, | ||
type ADSR, | ||
type IGen, | ||
type Osc, | ||
type SVF, | ||
} from "@thi.ng/dsp"; | ||
import { clamp11 } from "@thi.ng/math"; | ||
import { SYSTEM, XsAdd, pickRandom } from "@thi.ng/random"; | ||
import { map, range } from "@thi.ng/transducers"; | ||
|
||
// sample frequency/rate (in Hz) | ||
export const FS = 44100; | ||
|
||
// global PRNG for reproducible results | ||
export let SEED = 0xdecafbad; | ||
const RND = new XsAdd(SEED); | ||
|
||
export const randomizeSeed = () => { | ||
RND.seed((SEED = SYSTEM.int())); | ||
return SEED; | ||
}; | ||
|
||
// compute frequency of C1, derived from A0 (27.5Hz = A4/(2^4) = 440/16) | ||
// reference: https://newt.phys.unsw.edu.au/jw/notes.html | ||
const C1 = 2 ** (3 / 12) * 27.5; | ||
|
||
// Major scale (https://en.wikipedia.org/wiki/Equal_temperament) | ||
// the numbers are semitone offsets within a single octave | ||
const SCALE = [0, 2, 4, 5, 7, 9, 11]; | ||
|
||
// compute actual frequency for tone index in given scale | ||
const freqForScaleTone = (i: number) => | ||
2 ** (Math.floor(i / SCALE.length) + SCALE[i % SCALE.length] / 12) * C1; | ||
|
||
// stochastic polyphonic synth/sequencer | ||
// randomly triggers notes/voices mapped over specified octave range | ||
// and yields stream of bounced (mixed down) samples of all voices | ||
export class Sequencer extends AGen<number> { | ||
voices: Voice[]; | ||
attackLFO: Osc; | ||
time = 0; | ||
|
||
constructor( | ||
baseOctave: number, | ||
numOctaves: number, | ||
public probability: number | ||
) { | ||
super(0); | ||
const numNotes = SCALE.length; | ||
const noteRange = numOctaves * numNotes; | ||
const maxGain = 4 / noteRange; | ||
this.voices = [ | ||
...map( | ||
(i) => | ||
new Voice( | ||
freqForScaleTone(i + baseOctave * numNotes), | ||
maxGain | ||
), | ||
range(noteRange) | ||
), | ||
]; | ||
// LFO for modulating attack length | ||
this.attackLFO = osc(sin, 0.1 / FS, 0.15 * FS, 0.15 * FS); | ||
} | ||
|
||
next() { | ||
// always read next value from LFO | ||
const attackTime = this.attackLFO.next(); | ||
// only tiny chance of new note/voice trigger per frame | ||
// (`N / FS` means statistically N triggers per second) | ||
if (RND.probability(this.probability)) { | ||
for (let i = this.voices.length * 2; i-- > 0; ) { | ||
// choose a random free(!) voice | ||
const voice = pickRandom(this.voices, RND); | ||
if (voice.isFree(this.time)) { | ||
// reset & play note | ||
voice.play(this.time, attackTime); | ||
break; | ||
} | ||
} | ||
} | ||
this.time++; | ||
// mixdown all voices & clamp to [-1..1] interval | ||
return clamp11( | ||
this.voices.reduce((acc, voice) => acc + voice.gen.next(), 0) | ||
); | ||
} | ||
} | ||
|
||
// an individual voice for the above synth/sequencer | ||
class Voice { | ||
osc: Osc; | ||
env: ADSR; | ||
filter: SVF; | ||
gen: IGen<number>; | ||
lastTrigger: number; | ||
|
||
// creates & initializes a voice (aka oscillator + envelope) for given | ||
// frequency (in Hz). initial volume is set to zero. | ||
constructor(freq: number, maxGain: number) { | ||
// configure oscillator (try different waveforms, see thi.ng/dsp readme) | ||
// use frequency assigned to this voice (normalized to FS) | ||
this.osc = osc(sawAdditive(10), freq / FS, maxGain); | ||
// const voiceOsc = osc(saw, freq / FS, maxGain); | ||
|
||
// define volume envelope generator | ||
// https://en.wikipedia.org/wiki/Envelope_(music)#ADSR | ||
this.env = adsr({ | ||
a: FS * 0.01, | ||
d: FS * 0.05, | ||
s: 0.8, | ||
slen: 0, | ||
r: FS * 0.5, | ||
}); | ||
// turn down volume until activated | ||
this.env.setGain(0); | ||
// warm sounding lowpass filter (state variable filter) | ||
this.filter = svfLP(1000 / FS); | ||
// compose signal generator & processor pipeline: | ||
// multiply osc with envelope, then pass through filter and filter delay | ||
this.gen = pipe( | ||
product(this.osc, this.env), | ||
this.filter, | ||
// pick random delay length | ||
filterFeedbackDelay( | ||
(FS * pickRandom([0.25, 0.375, 0.5], RND)) | 0, | ||
biquadLP(1000 / FS, 1.1), | ||
0.66 | ||
) | ||
); | ||
// mark voice as free/available | ||
this.lastTrigger = -Infinity; | ||
} | ||
|
||
play(time: number, attack: number) { | ||
// reset envelope & set attack time & volume | ||
this.env.reset(); | ||
this.env.setAttack(attack); | ||
this.env.setGain(RND.minmax(0.2, 1)); | ||
// pick random cutoff freq & resonance for voice's filter | ||
this.filter.setFreq(RND.minmax(200, 12000) / FS); | ||
this.filter.setQ(RND.minmax(0.5, 0.95)); | ||
// update timestamp | ||
this.lastTrigger = time; | ||
} | ||
|
||
// returns true if voice is playable again (here arbitrarily after 0.5 seconds) | ||
isFree(time: number) { | ||
return time - this.lastTrigger > 0.5 * FS; | ||
} | ||
} |
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,94 @@ | ||
import { download } from "@thi.ng/dl-asset"; | ||
import { adsr, product } from "@thi.ng/dsp"; | ||
import { wavByteArray } from "@thi.ng/dsp-io-wav"; | ||
import { fiber, timeSliceIterable } from "@thi.ng/fibers"; | ||
import { U32 } from "@thi.ng/hex"; | ||
import { $compile } from "@thi.ng/rdom"; | ||
import { reactive } from "@thi.ng/rstream"; | ||
import { take } from "@thi.ng/transducers"; | ||
import { FS, SEED, Sequencer, randomizeSeed } from "./audio"; | ||
|
||
// reactive state values | ||
const progress = reactive(0); | ||
const seed = reactive(SEED); | ||
|
||
// run audio generation as fiber/co-routine as alternative to having to use a | ||
// worker in order to not freeze the main browser UI due to long running task. | ||
// (generating 60 secs of audio takes ~20-25 secs on my MBA M1 2021) | ||
const generateAudio = (duration: number) => | ||
fiber(function* () { | ||
// convert seconds to samples | ||
duration *= FS; | ||
// pre-allocate sample buffer | ||
let samples = new Float32Array(duration); | ||
let offset = 0; | ||
// render audio in a time-sliced manner of 16ms chunks. | ||
// using `yield*` here will cause the main fiber to wait until | ||
// this render sub-process is complete | ||
yield* timeSliceIterable( | ||
// only take required number of samples (from infinite sequence) | ||
take( | ||
duration, | ||
// combine generated audio with a global envelope to fade | ||
// everything in & out at beginning/end | ||
product( | ||
new Sequencer(1, 4, 4 / FS), | ||
adsr({ | ||
a: 3 * FS, | ||
acurve: 10000, | ||
slen: 54 * FS, | ||
r: 3 * FS, | ||
}) | ||
) | ||
), | ||
// consumer function: records a chunk of generated samples into the | ||
// main sample buffer and then updates progress (which in turn will | ||
// trigger an update of the UI progress bar) | ||
(chunk) => { | ||
samples.set(chunk, offset); | ||
offset += chunk.length; | ||
progress.next(offset / duration); | ||
}, | ||
// time slice duration | ||
16 | ||
); | ||
// convert raw audio into WAV byte array & trigger file download | ||
download( | ||
`audio-${Date.now()}.wav`, | ||
wavByteArray( | ||
{ sampleRate: FS, channels: 1, length: duration, bits: 16 }, | ||
samples | ||
) | ||
); | ||
// reset progress | ||
progress.next(0); | ||
}).run(); | ||
|
||
// create & mount minimal UI/DOM | ||
$compile([ | ||
"div", | ||
{}, | ||
["h1", {}, "Stochastic sequencer"], | ||
[ | ||
"button", | ||
{ | ||
onclick: () => generateAudio(60), | ||
// disable button during rendering | ||
disabled: progress.map((x) => x > 0), | ||
}, | ||
"Render audio", | ||
], | ||
[ | ||
"button.ml2", | ||
{ | ||
onclick: () => seed.next(randomizeSeed()), | ||
// disable button during rendering | ||
disabled: progress.map((x) => x > 0), | ||
}, | ||
"Randomize seed", | ||
], | ||
// display seed as formatted hex value | ||
["span.ml2", {}, seed.map((x) => `0x${U32(x)}`)], | ||
// progress bar widget will auto-update via reactive `progess` state value | ||
["progress.db.mv2.w-100", { value: progress }], | ||
]).mount(document.getElementById("app")!); |
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 @@ | ||
/// <reference types="vite/client" /> |
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,5 @@ | ||
{ | ||
"extends": "../tsconfig.json", | ||
"include": ["src/**/*"], | ||
"compilerOptions": {} | ||
} |
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