Skip to content

Commit e65c536

Browse files
committed
docs: add simple sound analyser
1 parent ffb12d2 commit e65c536

File tree

11 files changed

+336
-0
lines changed

11 files changed

+336
-0
lines changed
1.28 MB
Binary file not shown.
1.28 MB
Binary file not shown.
1.28 MB
Binary file not shown.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { computed, Injectable, signal } from '@angular/core';
2+
import { tracks } from './tracks';
3+
4+
declare global {
5+
interface Window {
6+
webkitAudioContext: typeof AudioContext;
7+
}
8+
}
9+
10+
export type Audio = Awaited<ReturnType<AudioStore['createAudio']>>;
11+
12+
@Injectable()
13+
export class AudioStore {
14+
clicked = signal(false);
15+
16+
drums = signal<Audio | null>(null);
17+
synth = signal<Audio | null>(null);
18+
snare = signal<Audio | null>(null);
19+
20+
loaded = computed(() => {
21+
const [drums, synth, snare] = [this.drums(), this.synth(), this.snare()];
22+
return !!drums && !!synth && !!snare;
23+
});
24+
25+
constructor() {
26+
void Promise.all(
27+
tracks.map(({ sound }) => this.createAudio(`./${sound}-final.mp3`).then(this[sound].set.bind(this[sound]))),
28+
);
29+
}
30+
31+
start() {
32+
const [drums, synth, snare] = [this.drums(), this.synth(), this.snare()];
33+
if (!drums || !synth || !snare) return;
34+
35+
drums.source.start(0);
36+
synth.source.start(0);
37+
snare.source.start(0);
38+
this.clicked.set(true);
39+
}
40+
41+
private async createAudio(url: string) {
42+
const response = await fetch(url);
43+
const buffer = await response.arrayBuffer();
44+
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
45+
const analyser = audioContext.createAnalyser();
46+
analyser.fftSize = 64;
47+
const data = new Uint8Array(analyser.frequencyBinCount);
48+
const source = audioContext.createBufferSource();
49+
source.buffer = await new Promise((res) => audioContext.decodeAudioData(buffer, res));
50+
source.loop = true;
51+
52+
const gainNode = audioContext.createGain();
53+
54+
source.connect(analyser);
55+
analyser.connect(gainNode);
56+
57+
let avg = 0;
58+
59+
return {
60+
audioContext,
61+
source,
62+
data,
63+
gainNode,
64+
avg,
65+
update() {
66+
analyser.getByteFrequencyData(data);
67+
68+
avg = data.reduce((prev, cur) => prev + cur / data.length, 0);
69+
70+
if (this) {
71+
this.avg = avg;
72+
}
73+
74+
// Calculate a frequency average
75+
return avg;
76+
},
77+
};
78+
}
79+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
2+
import { NgtArgs } from 'angular-three';
3+
import { Track } from './track';
4+
import { tracks, zoomIndex } from './tracks';
5+
import { Zoom } from './zoom';
6+
7+
@Component({
8+
standalone: true,
9+
template: `
10+
<ngt-spot-light [position]="[-4, 4, -4]" [angle]="0.06" [penumbra]="1" [castShadow]="true">
11+
<ngt-vector2 *args="[2048, 2048]" attach="shadow.mapSize" />
12+
</ngt-spot-light>
13+
14+
@for (track of tracks; track track.sound) {
15+
<app-track [sound]="track.sound" [position]="[0, 0, track.positionZ]" [zoom]="zoomIndex() === $index" />
16+
}
17+
18+
<ngt-mesh [receiveShadow]="true" [rotation]="[-Math.PI / 2, 0, 0]" [position]="[0, -0.025, 0]">
19+
<ngt-plane-geometry />
20+
<ngt-shadow-material [transparent]="true" [opacity]="0.15" />
21+
</ngt-mesh>
22+
`,
23+
schemas: [CUSTOM_ELEMENTS_SCHEMA],
24+
changeDetection: ChangeDetectionStrategy.OnPush,
25+
imports: [NgtArgs, Track, Zoom],
26+
host: { class: 'simple-sound-analyser-soba-experience' },
27+
})
28+
export class Experience {
29+
protected readonly Math = Math;
30+
31+
protected readonly tracks = tracks;
32+
protected readonly zoomIndex = zoomIndex;
33+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
2+
import { AudioStore } from './audio.store';
3+
import { tracks, zoomIndex, zoomTrack } from './tracks';
4+
5+
@Component({
6+
selector: 'app-overlay',
7+
standalone: true,
8+
template: `
9+
<div
10+
[class.cursor-pointer]="audioStore.loaded()"
11+
class="absolute left-0 top-0 flex h-screen w-screen flex-col items-center justify-center gap-2 font-mono transition"
12+
[class]="audioStore.clicked() ? ['pointer-events-none', 'opacity-0'] : []"
13+
>
14+
@if (audioStore.loaded()) {
15+
<span>This sandbox needs</span>
16+
<span>user interaction for audio</span>
17+
<strong>(loud audio warning!!!!!)</strong>
18+
<button
19+
class="cursor-pointer self-center rounded border border-b-4 border-transparent border-b-gray-400 bg-white px-10 py-2"
20+
(click)="onClick()"
21+
>
22+
▶︎
23+
</button>
24+
} @else {
25+
<span>loading</span>
26+
}
27+
</div>
28+
<div
29+
class="absolute top-2 left-0 flex w-full items-center justify-between px-2 opacity-0"
30+
[class.opacity-100]="audioStore.clicked()"
31+
>
32+
<div class="flex flex-col gap-2 text-black">
33+
<code>
34+
Camera currently reacts to:
35+
<button
36+
class="rounded border border-b-4 border-transparent border-b-gray-400 bg-white px-4 py-1 text-black"
37+
(click)="onChangeZoomTrack()"
38+
>
39+
{{ zoomTrack() }}
40+
</button>
41+
</code>
42+
<code>
43+
credits:
44+
<a class="underline" href="https://pmndrs.github.io/examples/demos/simple-audio-analyser" target="_blank">
45+
R3F Simple Sound Analyser
46+
</a>
47+
</code>
48+
</div>
49+
<code class="text-black">Triplet After Triplet · SEGA · Hidenori Shoji</code>
50+
</div>
51+
`,
52+
changeDetection: ChangeDetectionStrategy.OnPush,
53+
styles: `
54+
:host > div:first-child {
55+
background: linear-gradient(15deg, rgb(82, 81, 88) 0%, rgb(255, 247, 248) 100%);
56+
}
57+
`,
58+
})
59+
export class Overlay {
60+
protected audioStore = inject(AudioStore);
61+
protected readonly zoomTrack = zoomTrack;
62+
63+
onClick() {
64+
if (this.audioStore.loaded()) {
65+
this.audioStore.start();
66+
}
67+
}
68+
69+
onChangeZoomTrack() {
70+
zoomIndex.update((current) => {
71+
let nextRandom = Math.floor(Math.random() * tracks.length);
72+
while (nextRandom === current) {
73+
nextRandom = Math.floor(Math.random() * tracks.length);
74+
}
75+
return nextRandom;
76+
});
77+
}
78+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { ChangeDetectionStrategy, Component } from '@angular/core';
2+
import { NgtCanvas } from 'angular-three';
3+
import { AudioStore } from './audio.store';
4+
import { Experience } from './experience';
5+
import { Overlay } from './overlay';
6+
7+
@Component({
8+
standalone: true,
9+
template: `
10+
<ngt-canvas [sceneGraph]="sceneGraph" [shadows]="true" [camera]="{ position: [-1, 1.5, 2], fov: 25 }" />
11+
<app-overlay />
12+
`,
13+
imports: [NgtCanvas, Overlay],
14+
changeDetection: ChangeDetectionStrategy.OnPush,
15+
host: { class: 'simple-sound-analyser-soba' },
16+
styles: `
17+
:host {
18+
display: block;
19+
height: 100%;
20+
width: 100%;
21+
background: linear-gradient(15deg, rgb(82, 81, 88) 0%, rgb(255, 247, 248) 100%);
22+
}
23+
`,
24+
providers: [AudioStore],
25+
})
26+
export default class SimpleSoundAnalyser {
27+
protected sceneGraph = Experience;
28+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {
2+
ChangeDetectionStrategy,
3+
Component,
4+
computed,
5+
CUSTOM_ELEMENTS_SCHEMA,
6+
effect,
7+
ElementRef,
8+
inject,
9+
input,
10+
viewChild,
11+
} from '@angular/core';
12+
import { injectBeforeRender, NgtArgs } from 'angular-three';
13+
import { NgtsText } from 'angular-three-soba/abstractions';
14+
import { NgtsPivotControls } from 'angular-three-soba/controls';
15+
import { InstancedMesh, MeshBasicMaterial, Object3D, PlaneGeometry } from 'three';
16+
import { AudioStore } from './audio.store';
17+
18+
@Component({
19+
selector: 'app-track',
20+
standalone: true,
21+
template: `
22+
<ngt-group [position]="position()">
23+
<ngt-instanced-mesh #instanced *args="[undefined, undefined, length()]" [castShadow]="true">
24+
<ngt-plane-geometry *args="[0.01, 0.05]" />
25+
<ngt-mesh-basic-material [toneMapped]="false" />
26+
</ngt-instanced-mesh>
27+
28+
<ngts-text [text]="sound()" [options]="{ fontSize: 0.05, color: 'black', position: [0.375, 0, 0] }" />
29+
</ngt-group>
30+
`,
31+
schemas: [CUSTOM_ELEMENTS_SCHEMA],
32+
changeDetection: ChangeDetectionStrategy.OnPush,
33+
imports: [NgtArgs, NgtsText, NgtsPivotControls],
34+
})
35+
export class Track {
36+
sound = input.required<'drums' | 'synth' | 'snare'>();
37+
position = input<[number, number, number]>([0, 0, 0]);
38+
39+
private instancedRef = viewChild<ElementRef<InstancedMesh<PlaneGeometry, MeshBasicMaterial>>>('instanced');
40+
41+
private audioStore = inject(AudioStore);
42+
43+
audio = computed(() => this.audioStore[this.sound()]());
44+
protected length = computed(() => {
45+
return this.audio()?.data.length;
46+
});
47+
48+
constructor() {
49+
effect((onCleanup) => {
50+
const audio = this.audio();
51+
if (!audio) return;
52+
const { gainNode, audioContext } = audio;
53+
gainNode.connect(audioContext.destination);
54+
onCleanup(() => gainNode.disconnect());
55+
});
56+
57+
const dummy = new Object3D();
58+
injectBeforeRender(() => {
59+
const instanced = this.instancedRef()?.nativeElement;
60+
if (!instanced) return;
61+
62+
const audio = this.audio();
63+
if (!audio) return;
64+
65+
const { data } = audio;
66+
const avg = audio.update();
67+
68+
// Distribute the instanced planes according to the frequency data
69+
for (let i = 0; i < data.length; i++) {
70+
dummy.position.set(i * 0.01 * 1.8 - (data.length * 0.01 * 1.8) / 2, data[i] / 2500, 0);
71+
dummy.updateMatrix();
72+
instanced.setMatrixAt(i, dummy.matrix);
73+
}
74+
// Set the hue according to the frequency average
75+
instanced.material.color.setHSL(avg / 500, 0.75, 0.75);
76+
instanced.instanceMatrix.needsUpdate = true;
77+
});
78+
}
79+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { computed, signal } from '@angular/core';
2+
3+
export const tracks = [
4+
{ sound: 'drums', positionZ: 0.25 },
5+
{ sound: 'synth', positionZ: -0.25 },
6+
{ sound: 'snare', positionZ: 0 },
7+
] as const;
8+
9+
export const zoomIndex = signal(Math.floor(Math.random() * tracks.length));
10+
export const zoomTrack = computed(() => tracks[zoomIndex()].sound);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Directive, inject, input } from '@angular/core';
2+
import { injectBeforeRender } from 'angular-three';
3+
import { PerspectiveCamera } from 'three';
4+
import { Track } from './track';
5+
6+
@Directive({ standalone: true, selector: 'app-track[zoom]' })
7+
export class Zoom {
8+
enabled = input(false, { alias: 'zoom' });
9+
10+
track = inject(Track);
11+
12+
constructor() {
13+
injectBeforeRender(({ camera }) => {
14+
const enabled = this.enabled();
15+
if (!enabled) return;
16+
17+
const audio = this.track.audio();
18+
if (!audio) return;
19+
20+
// Set the cameras field of view according to the frequency average
21+
(camera as PerspectiveCamera).fov = 25 - audio.avg / 15;
22+
camera.updateProjectionMatrix();
23+
});
24+
}
25+
}

apps/kitchen-sink/src/app/soba/soba.routes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ const routes: Routes = [
5353
path: 'inverted-stencil-buffer',
5454
loadComponent: () => import('./inverted-stencil-buffer/inverted-stencil-buffer'),
5555
},
56+
{
57+
path: 'simple-sound-analyser',
58+
loadComponent: () => import('./simple-sound-analyser/simple-sound-analyser'),
59+
},
5660
{
5761
path: '',
5862
redirectTo: 'stars',

0 commit comments

Comments
 (0)