-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathuseAudio.ts
More file actions
278 lines (216 loc) · 9 KB
/
Copy pathuseAudio.ts
File metadata and controls
278 lines (216 loc) · 9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { useScrollProgress, useExperiencePhase } from './useScrollProgress';
import { SCENE_RANGES, inRange } from '@/lib/sceneConfig';
export function useAudio(enabled: boolean) {
const progress = useScrollProgress();
const { phase } = useExperiencePhase();
// Web Audio Context & Nodes refs
const ctxRef = useRef<AudioContext | null>(null);
// Ambient drone nodes
const droneGainRef = useRef<GainNode | null>(null);
const droneOsc1Ref = useRef<OscillatorNode | null>(null);
const droneOsc2Ref = useRef<OscillatorNode | null>(null);
const droneFilterRef = useRef<BiquadFilterNode | null>(null);
// Heartbeat loop state & nodes
const heartbeatGainRef = useRef<GainNode | null>(null);
const heartbeatTimerRef = useRef<number | null>(null);
const heartbeatIntervalRef = useRef<number>(1500); // ms
// Black hole rumble nodes
const rumbleGainRef = useRef<GainNode | null>(null);
const rumbleOscRef = useRef<OscillatorNode | null>(null);
const rumbleFilterRef = useRef<BiquadFilterNode | null>(null);
const started = useRef(false);
// Helper to trigger a single heartbeat double thump
const triggerThump = useCallback((time: number, freq = 55, duration = 0.22) => {
const ctx = ctxRef.current;
const dest = heartbeatGainRef.current;
if (!ctx || !dest || ctx.state === 'suspended') return;
// First thump of the heartbeat
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.frequency.setValueAtTime(freq, time);
osc1.frequency.exponentialRampToValueAtTime(0.01, time + duration);
gain1.gain.setValueAtTime(0, time);
gain1.gain.linearRampToValueAtTime(0.8, time + 0.02);
gain1.gain.exponentialRampToValueAtTime(0.001, time + duration);
osc1.connect(gain1);
gain1.connect(dest);
osc1.start(time);
osc1.stop(time + duration);
// Second thump (0.25 seconds later)
const t2 = time + 0.25;
const osc2 = ctx.createOscillator();
const gain2 = ctx.createGain();
osc2.frequency.setValueAtTime(freq * 0.85, t2);
osc2.frequency.exponentialRampToValueAtTime(0.01, t2 + duration);
gain2.gain.setValueAtTime(0, t2);
gain2.gain.linearRampToValueAtTime(0.55, t2 + 0.02);
gain2.gain.exponentialRampToValueAtTime(0.001, t2 + duration);
osc2.connect(gain2);
gain2.connect(dest);
osc2.start(t2);
osc2.stop(t2 + duration);
}, []);
// Warp Whoosh sound effect
const playWarpWhoosh = useCallback(() => {
const ctx = ctxRef.current;
if (!ctx) return;
const now = ctx.currentTime;
// Create random white noise buffer
const bufferSize = ctx.sampleRate * 1.5; // 1.5 seconds
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const noiseSource = ctx.createBufferSource();
noiseSource.buffer = buffer;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.Q.value = 3.5;
filter.frequency.setValueAtTime(80, now);
filter.frequency.exponentialRampToValueAtTime(2800, now + 0.7);
filter.frequency.exponentialRampToValueAtTime(100, now + 1.4);
const gain = ctx.createGain();
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.5, now + 0.25);
gain.gain.exponentialRampToValueAtTime(0.001, now + 1.4);
noiseSource.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
// Sweeping sine oscillator for a powerful sub-whoosh
const subOsc = ctx.createOscillator();
const subGain = ctx.createGain();
subOsc.frequency.setValueAtTime(45, now);
subOsc.frequency.exponentialRampToValueAtTime(550, now + 0.7);
subOsc.frequency.exponentialRampToValueAtTime(25, now + 1.4);
subGain.gain.setValueAtTime(0, now);
subGain.gain.linearRampToValueAtTime(0.35, now + 0.35);
subGain.gain.exponentialRampToValueAtTime(0.001, now + 1.4);
subOsc.connect(subGain);
subGain.connect(ctx.destination);
noiseSource.start(now);
subOsc.start(now);
noiseSource.stop(now + 1.4);
subOsc.stop(now + 1.4);
}, []);
const start = useCallback(() => {
if (started.current) return;
started.current = true;
// Create browser AudioContext safely
const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
const ctx = new AudioContextClass();
ctxRef.current = ctx;
if (ctx.state === 'suspended') {
ctx.resume();
}
const now = ctx.currentTime;
// 1. Ambient Space Drone setup
const osc1 = ctx.createOscillator();
const osc2 = ctx.createOscillator();
const filter = ctx.createBiquadFilter();
const droneGain = ctx.createGain();
osc1.type = 'triangle';
osc1.frequency.setValueAtTime(55, now); // A1 note
osc1.detune.setValueAtTime(-5, now);
osc2.type = 'sawtooth';
osc2.frequency.setValueAtTime(110, now); // A2 note
osc2.detune.setValueAtTime(5, now);
filter.type = 'lowpass';
filter.frequency.setValueAtTime(180, now);
filter.Q.value = 1.0;
droneGain.gain.setValueAtTime(0, now);
droneGain.gain.linearRampToValueAtTime(0.25, now + 1.5);
osc1.connect(filter);
osc2.connect(filter);
filter.connect(droneGain);
droneGain.connect(ctx.destination);
osc1.start(now);
osc2.start(now);
droneOsc1Ref.current = osc1;
droneOsc2Ref.current = osc2;
droneFilterRef.current = filter;
droneGainRef.current = droneGain;
// 2. Heartbeat Gain setup
const heartbeatGain = ctx.createGain();
heartbeatGain.gain.setValueAtTime(0, now);
heartbeatGain.connect(ctx.destination);
heartbeatGainRef.current = heartbeatGain;
// Start scheduled heartbeat loop
const runHeartbeat = () => {
if (!ctxRef.current) return;
triggerThump(ctxRef.current.currentTime);
heartbeatTimerRef.current = window.setTimeout(runHeartbeat, heartbeatIntervalRef.current);
};
runHeartbeat();
// 3. Black Hole Rumble setup
const rumbleOsc = ctx.createOscillator();
const rumbleFilter = ctx.createBiquadFilter();
const rumbleGain = ctx.createGain();
rumbleOsc.type = 'sawtooth';
rumbleOsc.frequency.setValueAtTime(32, now); // Very low sub frequency
rumbleFilter.type = 'lowpass';
rumbleFilter.frequency.setValueAtTime(65, now);
rumbleFilter.Q.value = 7.0; // High resonance for hum/rumble
rumbleGain.gain.setValueAtTime(0, now);
rumbleOsc.connect(rumbleFilter);
rumbleFilter.connect(rumbleGain);
rumbleGain.connect(ctx.destination);
rumbleOsc.start(now);
rumbleOscRef.current = rumbleOsc;
rumbleFilterRef.current = rumbleFilter;
rumbleGainRef.current = rumbleGain;
// Play warp whoosh to start experience
playWarpWhoosh();
}, [triggerThump, playWarpWhoosh]);
// Play whoosh when phase transitions to entering
useEffect(() => {
if (phase === 'entering' && started.current) {
playWarpWhoosh();
}
}, [phase, playWarpWhoosh]);
// Adjust volumes and parameters based on scroll progress
useEffect(() => {
if (!enabled || !started.current || !ctxRef.current) return;
const ctx = ctxRef.current;
const now = ctx.currentTime;
// Black Hole Rumble volume control
const inBH = inRange(progress, SCENE_RANGES.blackHole) || inRange(progress, SCENE_RANGES.eventHorizon);
const targetRumbleVolume = inBH ? 0.35 : 0.0;
rumbleGainRef.current?.gain.setTargetAtTime(targetRumbleVolume, now, 0.2);
if (inBH) {
const factor = (progress - SCENE_RANGES.blackHole[0]) / (SCENE_RANGES.eventHorizon[1] - SCENE_RANGES.blackHole[0]);
const pitch = 32 - factor * 6; // rumble pitch sweeps downward as you fall in
rumbleOscRef.current?.frequency.setTargetAtTime(pitch, now, 0.25);
}
// Heartbeat volume and tempo control
const inHeartbeatZone =
inRange(progress, SCENE_RANGES.eventHorizon) ||
inRange(progress, SCENE_RANGES.singularity);
const targetHBVolume = inHeartbeatZone ? 0.45 : 0.0;
heartbeatGainRef.current?.gain.setTargetAtTime(targetHBVolume, now, 0.3);
if (inHeartbeatZone) {
const horizonT = (progress - SCENE_RANGES.eventHorizon[0]) / (SCENE_RANGES.singularity[1] - SCENE_RANGES.eventHorizon[0]);
// Speed up heartbeat rate from 1400ms down to 600ms
heartbeatIntervalRef.current = 1400 - horizonT * 800;
} else {
heartbeatIntervalRef.current = 1500;
}
// Modulate space drone filter cutoff slightly based on scroll to feel interactive
const targetCutoff = 170 + Math.sin(progress * Math.PI * 6) * 60;
droneFilterRef.current?.frequency.setTargetAtTime(targetCutoff, now, 0.4);
}, [progress, enabled]);
// Cleanup Web Audio Context
useEffect(() => {
return () => {
if (heartbeatTimerRef.current) {
clearTimeout(heartbeatTimerRef.current);
}
if (ctxRef.current) {
ctxRef.current.close();
}
};
}, []);
return { start };
}