Skip to content

Commit

Permalink
feat(examples): add render-audio demo
Browse files Browse the repository at this point in the history
  • Loading branch information
postspectacular committed Sep 6, 2023
1 parent fe13b03 commit 565ec0e
Show file tree
Hide file tree
Showing 10 changed files with 419 additions and 51 deletions.
Binary file added assets/examples/render-audio.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
101 changes: 51 additions & 50 deletions examples/README.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions examples/render-audio/README.md
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
29 changes: 29 additions & 0 deletions examples/render-audio/index.html
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>
43 changes: 43 additions & 0 deletions examples/render-audio/package.json
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"
}
}
161 changes: 161 additions & 0 deletions examples/render-audio/src/audio.ts
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;
}
}
94 changes: 94 additions & 0 deletions examples/render-audio/src/index.ts
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")!);
1 change: 1 addition & 0 deletions examples/render-audio/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="vite/client" />
5 changes: 5 additions & 0 deletions examples/render-audio/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../tsconfig.json",
"include": ["src/**/*"],
"compilerOptions": {}
}
21 changes: 20 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1557,6 +1557,25 @@ __metadata:
languageName: unknown
linkType: soft

"@example/render-audio@workspace:examples/render-audio":
version: 0.0.0-use.local
resolution: "@example/render-audio@workspace:examples/render-audio"
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:^"
typescript: ^5.2.2
vite: ^4.4.9
languageName: unknown
linkType: soft

"@example/rotating-voronoi@workspace:examples/rotating-voronoi":
version: 0.0.0-use.local
resolution: "@example/rotating-voronoi@workspace:examples/rotating-voronoi"
Expand Down Expand Up @@ -3331,7 +3350,7 @@ __metadata:
languageName: unknown
linkType: soft

"@thi.ng/dsp-io-wav@^2.1.62, @thi.ng/dsp-io-wav@workspace:packages/dsp-io-wav":
"@thi.ng/dsp-io-wav@^2.1.62, @thi.ng/dsp-io-wav@workspace:^, @thi.ng/dsp-io-wav@workspace:packages/dsp-io-wav":
version: 0.0.0-use.local
resolution: "@thi.ng/dsp-io-wav@workspace:packages/dsp-io-wav"
dependencies:
Expand Down

0 comments on commit 565ec0e

Please sign in to comment.