1
+ /**
2
+ * @class AudioManager
3
+ * @description Handles loading, playback, and management of audio assets using Tone.js.
4
+ * Architected for generative, reactive soundscapes.
5
+ */
6
+ import * as Tone from 'tone' ;
7
+
8
+ // === Just Intonation Ratios for Drone Voices ===
9
+ const JUST_RATIOS = [ 1 , 5 / 4 , 3 / 2 , 7 / 4 ] ; // Unison, major third, perfect fifth, harmonic seventh
10
+ const ROOT_CYCLE = [ 65.406 , 69.296 , 77.782 , 87.307 ] ; // C2, D♭2, E♭2, F2 (Hz)
11
+ const ROOT_GLIDE_TIME = 8 ; // seconds for smooth root transitions
12
+ const ROOT_SHIFT_MIN = 60 ; // seconds
13
+ const ROOT_SHIFT_MAX = 180 ; // seconds
14
+
15
+ function createDroneVoice ( rootFreq , ratio , lfoSettings , reverb , output ) {
16
+ // Voice frequency
17
+ const freq = rootFreq * ratio ;
18
+ // Oscillator
19
+ const osc = new Tone . Oscillator ( {
20
+ frequency : freq ,
21
+ type : 'sine' ,
22
+ volume : - 18
23
+ } ) ;
24
+ // Amplitude
25
+ const gain = new Tone . Gain ( 0.15 ) ;
26
+ // Spatialization
27
+ const pan = new Tone . Panner3D ( {
28
+ panningModel : 'HRTF' ,
29
+ positionX : Math . random ( ) * 6 - 3 ,
30
+ positionY : Math . random ( ) * 2 - 1 ,
31
+ positionZ : Math . random ( ) * 6 - 3
32
+ } ) ;
33
+ // Filter
34
+ const filter = new Tone . Filter ( 800 , 'lowpass' ) . set ( { Q : 0.7 } ) ;
35
+ // LFOs
36
+ const ampLFO = new Tone . LFO ( lfoSettings . ampRate , 0.05 , 0.2 ) ;
37
+ ampLFO . connect ( gain . gain ) ;
38
+ const panLFO = new Tone . LFO ( lfoSettings . panRate , - 2 , 2 ) ;
39
+ panLFO . connect ( pan . positionX ) ;
40
+ const filterLFO = new Tone . LFO ( lfoSettings . filterRate , 400 , 1200 ) ;
41
+ filterLFO . connect ( filter . frequency ) ;
42
+ // Chain
43
+ osc . chain ( gain , pan , filter , reverb , output ) ;
44
+ return { osc, gain, pan, filter, ampLFO, panLFO, filterLFO, ratio } ;
45
+ }
46
+
47
+ export class AudioManager {
48
+ constructor ( ) {
49
+ console . log ( "AudioManager (DroneArt): Initializing..." ) ;
50
+ this . toneContext = Tone . getContext ( ) ;
51
+ this . masterLimiter = new Tone . Limiter ( - 1 ) . toDestination ( ) ;
52
+ this . masterReverb = new Tone . Reverb ( { decay : 7 , preDelay : 0.04 , wet : 0.4 } ) . connect ( this . masterLimiter ) ;
53
+ this . droneVoices = [ ] ;
54
+ this . rootIndex = 0 ;
55
+ this . rootFreq = ROOT_CYCLE [ this . rootIndex ] ;
56
+ this . rootShiftTimer = null ;
57
+ this . isActive = false ;
58
+ this . playerSpeed = 0 ;
59
+ this . zone = 'default' ;
60
+ // Pre-create voices (but don't connect yet)
61
+ this . _initVoices ( ) ;
62
+ }
63
+
64
+ _initVoices ( ) {
65
+ this . droneVoices = [ ] ;
66
+ for ( let i = 0 ; i < JUST_RATIOS . length ; i ++ ) {
67
+ const lfoSettings = {
68
+ ampRate : 0.02 + Math . random ( ) * 0.04 , // 0.02–0.06 Hz
69
+ panRate : 0.01 + Math . random ( ) * 0.03 , // 0.01–0.04 Hz
70
+ filterRate : 0.015 + Math . random ( ) * 0.03 // 0.015–0.045 Hz
71
+ } ;
72
+ const voice = createDroneVoice ( this . rootFreq , JUST_RATIOS [ i ] , lfoSettings , this . masterReverb , Tone . Destination ) ;
73
+ this . droneVoices . push ( voice ) ;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Resume the AudioContext after user interaction
79
+ * Must be called in response to a user gesture (click, tap, keypress)
80
+ */
81
+ async resumeAudioContext ( ) {
82
+ try {
83
+ await Tone . start ( ) ;
84
+ console . log ( "AudioContext resumed successfully after user gesture" ) ;
85
+ return true ;
86
+ } catch ( error ) {
87
+ console . error ( "Failed to resume AudioContext:" , error ) ;
88
+ return false ;
89
+ }
90
+ }
91
+
92
+ async startDroneInstallation ( ) {
93
+ if ( this . isActive ) return ;
94
+
95
+ // First ensure AudioContext is running
96
+ const audioContextState = Tone . getContext ( ) . state ;
97
+ if ( audioContextState !== "running" ) {
98
+ console . log ( "AudioContext not running, attempting to resume..." ) ;
99
+ try {
100
+ await this . resumeAudioContext ( ) ;
101
+ } catch ( error ) {
102
+ console . error ( "Could not resume AudioContext:" , error ) ;
103
+ return false ;
104
+ }
105
+ }
106
+
107
+ this . isActive = true ;
108
+ // Connect and start all voices
109
+ for ( const v of this . droneVoices ) {
110
+ v . osc . start ( ) ;
111
+ v . ampLFO . start ( ) ;
112
+ v . panLFO . start ( ) ;
113
+ v . filterLFO . start ( ) ;
114
+ }
115
+ this . _scheduleRootShift ( ) ;
116
+ console . log ( "Drone installation started." ) ;
117
+ return true ;
118
+ }
119
+
120
+ stopDroneInstallation ( ) {
121
+ if ( ! this . isActive ) return ;
122
+ for ( const v of this . droneVoices ) {
123
+ v . osc . stop ( ) ;
124
+ v . ampLFO . stop ( ) ;
125
+ v . filterLFO . stop ( ) ;
126
+ v . panLFO . stop ( ) ;
127
+ v . osc . disconnect ( ) ;
128
+ v . gain . disconnect ( ) ;
129
+ v . pan . disconnect ( ) ;
130
+ v . filter . disconnect ( ) ;
131
+ }
132
+ this . isActive = false ;
133
+ if ( this . rootShiftTimer ) {
134
+ clearTimeout ( this . rootShiftTimer ) ;
135
+ this . rootShiftTimer = null ;
136
+ }
137
+ console . log ( "Drone installation stopped." ) ;
138
+ }
139
+
140
+ _scheduleRootShift ( ) {
141
+ if ( ! this . isActive ) return ;
142
+ const nextTime = ROOT_SHIFT_MIN * 1000 + Math . random ( ) * ( ROOT_SHIFT_MAX - ROOT_SHIFT_MIN ) * 1000 ;
143
+ this . rootShiftTimer = setTimeout ( ( ) => {
144
+ this . _shiftRoot ( ) ;
145
+ this . _scheduleRootShift ( ) ;
146
+ } , nextTime ) ;
147
+ }
148
+
149
+ _shiftRoot ( ) {
150
+ this . rootIndex = ( this . rootIndex + 1 ) % ROOT_CYCLE . length ;
151
+ const newRoot = ROOT_CYCLE [ this . rootIndex ] ;
152
+ for ( let i = 0 ; i < this . droneVoices . length ; i ++ ) {
153
+ const v = this . droneVoices [ i ] ;
154
+ const targetFreq = newRoot * v . ratio ;
155
+ v . osc . frequency . rampTo ( targetFreq , ROOT_GLIDE_TIME ) ;
156
+ }
157
+ this . rootFreq = newRoot ;
158
+ console . log ( `Root shifted to ${ newRoot . toFixed ( 2 ) } Hz` ) ;
159
+ }
160
+
161
+ setPlayerMovement ( speed ) {
162
+ this . playerSpeed = speed ;
163
+ // More movement = faster LFOs, brighter filters, more volume
164
+ for ( const v of this . droneVoices ) {
165
+ v . ampLFO . frequency . rampTo ( 0.02 + speed * 0.1 , 2 ) ;
166
+ v . panLFO . frequency . rampTo ( 0.01 + speed * 0.08 , 2 ) ;
167
+ v . filterLFO . frequency . rampTo ( 0.015 + speed * 0.09 , 2 ) ;
168
+ v . filter . frequency . rampTo ( 800 + speed * 800 , 3 ) ;
169
+ v . gain . gain . rampTo ( 0.15 + speed * 0.1 , 2 ) ;
170
+ }
171
+ this . masterReverb . wet . rampTo ( 0.4 + speed * 0.2 , 2 ) ;
172
+ }
173
+
174
+ setZone ( zoneName ) {
175
+ this . zone = zoneName ;
176
+ // Example: breakRoom = more volume, wellness = softer, mdr = more reverb
177
+ let wet = 0.4 , gain = 0.15 ;
178
+ if ( zoneName === 'breakRoom' ) { wet = 0.55 ; gain = 0.22 ; }
179
+ if ( zoneName === 'wellness' ) { wet = 0.25 ; gain = 0.10 ; }
180
+ if ( zoneName === 'mdr' ) { wet = 0.5 ; gain = 0.18 ; }
181
+ this . masterReverb . wet . rampTo ( wet , 3 ) ;
182
+ for ( const v of this . droneVoices ) {
183
+ v . gain . gain . rampTo ( gain , 3 ) ;
184
+ }
185
+ }
186
+
187
+ onPlayerStillness ( duration ) {
188
+ // The longer the stillness, the slower/darker/softer the sound
189
+ const t = Math . min ( duration , 30 ) / 30 ;
190
+ for ( const v of this . droneVoices ) {
191
+ v . ampLFO . frequency . rampTo ( 0.01 + 0.01 * ( 1 - t ) , 4 ) ;
192
+ v . filter . frequency . rampTo ( 400 + 200 * ( 1 - t ) , 4 ) ;
193
+ v . gain . gain . rampTo ( 0.08 + 0.07 * ( 1 - t ) , 4 ) ;
194
+ }
195
+ this . masterReverb . wet . rampTo ( 0.2 + 0.2 * ( 1 - t ) , 4 ) ;
196
+ }
197
+
198
+ /**
199
+ * Clean up all audio resources
200
+ */
201
+ dispose ( ) {
202
+ this . stopDroneInstallation ( ) ;
203
+
204
+ // Dispose of all Tone.js objects
205
+ if ( this . masterLimiter ) {
206
+ this . masterLimiter . dispose ( ) ;
207
+ }
208
+
209
+ if ( this . masterReverb ) {
210
+ this . masterReverb . dispose ( ) ;
211
+ }
212
+
213
+ // Clear voice references
214
+ this . droneVoices = [ ] ;
215
+ }
216
+ }
0 commit comments