Neale Pickett
·
2023-02-25
outputs.mjs
1import {AudioSource, AudioContextTime} from "./audio.mjs"
2import * as time from "./time.mjs"
3
4const HIGH_FREQ = 555
5const LOW_FREQ = 444
6
7
8 /**
9 * A duration.
10 *
11 * Because JavaScript has multiple confliction notions of duration,
12 * everything in vail uses this.
13 *
14 * @typedef {number} Duration
15 */
16
17/**
18 * An epoch time, as returned by Date.now().
19 *
20 * @typedef {number} Date
21 */
22
23/** The amount of time it should take an oscillator to ramp to and from zero gain
24 *
25 * @constant {Duration}
26 */
27 const OscillatorRampDuration = 5*time.Millisecond
28
29
30class Oscillator extends AudioSource {
31 /**
32 * Create a new oscillator, and encase it in a Gain for control.
33 *
34 * @param {AudioContext} context Audio context
35 * @param {number} frequency Oscillator frequency (Hz)
36 * @param {number} maxGain Maximum gain (volume) of this oscillator (0.0 - 1.0)
37 * @param {string} type Oscillator type
38 */
39 constructor(context, frequency, maxGain = 0.5, type = "sine") {
40 super(context)
41 this.maxGain = maxGain
42
43 // Start quiet
44 this.masterGain.gain.value = 0
45
46 this.osc = new OscillatorNode(this.context)
47 this.osc.type = type
48 this.osc.connect(this.masterGain)
49 this.setFrequency(frequency)
50 this.osc.start()
51 }
52
53 /**
54 * Set oscillator frequency
55 *
56 * @param {Number} frequency New frequency (Hz)
57 */
58 setFrequency(frequency) {
59 this.osc.frequency.value = frequency
60 }
61
62 /**
63 * Set oscillator frequency to a MIDI note number
64 *
65 * This uses an equal temperament.
66 *
67 * @param {Number} note MIDI note number
68 */
69 setMIDINote(note) {
70 let frequency = 8.18 // MIDI note 0
71 for (let i = 0; i < note; i++) {
72 frequency *= 1.0594630943592953 // equal temperament half step
73 }
74 this.setFrequency(frequency)
75 }
76
77 /**
78 * Set gain to some value at a given time.
79 *
80 * @param {number} target Target gain
81 * @param {Date} when Time this should start
82 * @param {Duration} timeConstant Duration of ramp to target gain
83 */
84 async setTargetAtTime(target, when, timeConstant=OscillatorRampDuration) {
85 await this.context.resume()
86 this.masterGain.gain.setTargetAtTime(
87 target,
88 AudioContextTime(this.context, when),
89 timeConstant/time.Second,
90 )
91 }
92
93 /**
94 * Make sound at a given time.
95 *
96 * @param {Number} when When to start making noise
97 * @param {Number} timeConstant How long to ramp up
98 * @returns {Promise}
99 */
100 SoundAt(when=0, timeConstant=OscillatorRampDuration) {
101 return this.setTargetAtTime(this.maxGain, when, timeConstant)
102 }
103
104 /**
105 * Shut up at a given time.
106 *
107 * @param {Number} when When to stop making noise
108 * @param {Number} timeConstant How long to ramp down
109 * @returns {Promise}
110 */
111 HushAt(when=0, timeConstant=OscillatorRampDuration) {
112 return this.setTargetAtTime(0, when, timeConstant)
113 }
114}
115
116/**
117 * A digital sample, loaded from a URL.
118 */
119class Sample extends AudioSource {
120 /**
121 * @param {AudioContext} context
122 * @param {string} url URL to resource
123 */
124 constructor(context, url) {
125 super(context)
126 this.resume = this.load(url)
127 }
128
129 async load(url) {
130 let resp = await fetch(url)
131 let buf = await resp.arrayBuffer()
132 this.data = await this.context.decodeAudioData(buf)
133 }
134
135 /**
136 * Play the sample
137 *
138 * @param {Date} when When to begin playback
139 */
140 async PlayAt(when) {
141 await this.context.resume()
142 await this.resume
143 let bs = new AudioBufferSourceNode(this.context)
144 bs.buffer = this.data
145 bs.connect(this.masterGain)
146 bs.start(AudioContextTime(this.context, when))
147 }
148}
149
150/**
151 * A (mostly) virtual class defining a buzzer.
152 */
153class Buzzer extends AudioSource {
154 /**
155 * @param {AudioContext} context
156 */
157 constructor(context) {
158 super(context)
159 this.connected = true
160 }
161
162 /**
163 * Signal an error
164 */
165 Error() {
166 console.log("Error")
167 }
168
169 /**
170 * Begin buzzing at time
171 *
172 * @param {boolean} tx Transmit or receive tone
173 * @param {number} when Time to begin, in ms (0=now)
174 */
175 async Buzz(tx, when=0) {
176 console.log("Buzz", tx, when)
177 }
178
179 /**
180 * End buzzing at time
181 *
182 * @param {boolean} tx Transmit or receive tone
183 * @param {number} when Time to end, in ms (0=now)
184 */
185 async Silence(tx, when=0) {
186 console.log("Silence", tx, when)
187 }
188
189 /**
190 * Buzz for a duration at time
191 *
192 * @param {boolean} tx Transmit or receive tone
193 * @param {number} when Time to begin, in ms (0=now)
194 * @param {number} duration Duration of buzz (ms)
195 */
196 BuzzDuration(tx, when, duration) {
197 this.Buzz(tx, when)
198 this.Silence(tx, when + duration)
199 }
200
201 /**
202 * Set the "connectedness" indicator.
203 *
204 * @param {boolean} connected True if connected
205 */
206 SetConnected(connected) {
207 this.connected = connected
208 }
209}
210
211class AudioBuzzer extends Buzzer {
212 /**
213 * A buzzer that make noise
214 *
215 * @param {AudioContext} context
216 * @param {Number} errorFreq Error tone frequency (hz)
217 */
218 constructor(context, errorFreq=30) {
219 super(context)
220
221 this.errorTone = new Oscillator(this.context, errorFreq, 0.1, "square")
222 this.errorTone.connect(this.masterGain)
223 }
224
225 Error() {
226 let now = Date.now()
227 this.errorTone.SoundAt(now)
228 this.errorTone.HushAt(now + 200*time.Millisecond)
229 }
230}
231
232/**
233 * Buzzers keep two oscillators: one high and one low.
234 * They generate a continuous waveform,
235 * and we change the gain to turn the pitches off and on.
236 *
237 * This also implements a very quick ramp-up and ramp-down in gain,
238 * in order to avoid "pops" (square wave overtones)
239 * that happen with instant changes in gain.
240 */
241class ToneBuzzer extends AudioBuzzer {
242 constructor(context, {txGain=0.5, highFreq=HIGH_FREQ, lowFreq=LOW_FREQ} = {}) {
243 super(context)
244
245 this.rxOsc = new Oscillator(this.context, lowFreq, txGain)
246 this.txOsc = new Oscillator(this.context, highFreq, txGain)
247
248 this.rxOsc.connect(this.masterGain)
249 this.txOsc.connect(this.masterGain)
250
251 // Keep the speaker going always. This keeps the browser from "swapping out" our audio context.
252 if (false) {
253 this.bgOsc = new Oscillator(this.context, 1, 0.001)
254 this.bgOsc.SoundAt()
255 }
256 }
257
258 /**
259 * Set MIDI note for tx/rx tone
260 *
261 * @param {Boolean} tx True to set transmit note
262 * @param {Number} note MIDI note to send
263 */
264 SetMIDINote(tx, note) {
265 if (tx) {
266 this.txOsc.setMIDINote(note)
267 } else {
268 this.rxOsc.setMIDINote(note)
269 }
270 }
271
272 /**
273 * Begin buzzing at time
274 *
275 * @param {boolean} tx Transmit or receive tone
276 * @param {number} when Time to begin, in ms (0=now)
277 */
278 async Buzz(tx, when = null) {
279 let osc = tx?this.txOsc:this.rxOsc
280 osc.SoundAt(when)
281 }
282
283 /**
284 * End buzzing at time
285 *
286 * @param {boolean} tx Transmit or receive tone
287 * @param {number} when Time to end, in ms (0=now)
288 */
289 async Silence(tx, when = null) {
290 let osc = tx?this.txOsc:this.rxOsc
291 osc.HushAt(when)
292 }
293}
294
295class TelegraphBuzzer extends AudioBuzzer{
296 /**
297 *
298 * @param {AudioContext} context
299 */
300 constructor(context) {
301 super(context)
302 this.hum = new Oscillator(this.context, 140, 0.005, "sawtooth")
303
304 this.closeSample = new Sample(this.context, "../assets/telegraph-a.mp3")
305 this.openSample = new Sample(this.context, "../assets/telegraph-b.mp3")
306
307 this.hum.connect(this.masterGain)
308 this.closeSample.connect(this.masterGain)
309 this.openSample.connect(this.masterGain)
310 }
311
312 async Buzz(tx, when=0) {
313 if (tx) {
314 this.hum.SoundAt(when)
315 } else {
316 this.closeSample.PlayAt(when)
317 }
318 }
319
320 async Silence(tx ,when=0) {
321 if (tx) {
322 this.hum.HushAt(when)
323 } else {
324 this.openSample.PlayAt(when)
325 }
326 }
327}
328
329class LampBuzzer extends Buzzer {
330 /**
331 *
332 * @param {AudioContext} context
333 */
334 constructor(context) {
335 super(context)
336 this.elements = document.querySelectorAll(".recv-lamp")
337 }
338
339 async Buzz(tx, when=0) {
340 if (tx) return
341
342 let ms = when?when - Date.now():0
343 setTimeout(
344 () =>{
345 for (let e of this.elements) {
346 e.classList.add("rx")
347 }
348 },
349 ms,
350 )
351 }
352 async Silence(tx, when=0) {
353 if (tx) return
354
355 let ms = when?when - Date.now():0
356 setTimeout(
357 () => {
358 for (let e of this.elements) {
359 e.classList.remove("rx")
360 }
361 },
362 ms,
363 )
364 }
365
366 SetConnected(connected) {
367 for (let e of this.elements) {
368 if (connected) {
369 e.classList.add("connected")
370 } else {
371 e.classList.remove("connected")
372 }
373 }
374 }
375}
376
377class MIDIBuzzer extends Buzzer {
378 /**
379 *
380 * @param {AudioContext} context
381 */
382 constructor(context) {
383 super(context)
384 this.SetMIDINote(69) // A4; 440Hz
385
386 this.midiAccess = {outputs: []} // stub while we wait for async stuff
387 if (navigator.requestMIDIAccess) {
388 this.midiInit()
389 }
390 }
391
392 async midiInit(access) {
393 this.outputs = new Set()
394 this.midiAccess = await navigator.requestMIDIAccess()
395 this.midiAccess.addEventListener("statechange", e => this.midiStateChange(e))
396 this.midiStateChange()
397 }
398
399 midiStateChange(event) {
400 let newOutputs = new Set()
401 for (let output of this.midiAccess.outputs.values()) {
402 if ((output.state != "connected") || (output.name.includes("Through"))) {
403 continue
404 }
405 newOutputs.add(output)
406 }
407 this.outputs = newOutputs
408 }
409
410 sendAt(when, message) {
411 let ms = when?when - Date.now():0
412 setTimeout(
413 () => {
414 for (let output of (this.outputs || [])) {
415 output.send(message)
416 }
417 },
418 ms,
419 )
420 }
421
422 async Buzz(tx, when=0) {
423 if (tx) {
424 return
425 }
426 this.sendAt(when, [0x90, this.note, 0x7f])
427 }
428
429 async Silence(tx, when=0) {
430 if (tx) {
431 return
432 }
433
434 this.sendAt(when, [0x80, this.note, 0x7f])
435 }
436
437 /**
438 * Set MIDI note for tx/rx tone
439 *
440 * @param {Boolean} tx True to set transmit note
441 * @param {Number} note MIDI note to send
442 */
443 SetMIDINote(tx, note) {
444 if (tx) {
445 this.sendAt(0, [0xB0, 0x02, note])
446 } else {
447 this.note = note
448 }
449 }
450}
451
452class Collection extends AudioSource {
453 /**
454 *
455 * @param {AudioContext} context Audio Context
456 */
457 constructor(context) {
458 super(context)
459 this.tone = new ToneBuzzer(this.context)
460 this.telegraph = new TelegraphBuzzer(this.context)
461 this.lamp = new LampBuzzer(this.context)
462 this.midi = new MIDIBuzzer(this.context)
463 this.collection = new Set([this.tone, this.lamp, this.midi])
464
465 this.tone.connect(this.masterGain)
466 this.telegraph.connect(this.masterGain)
467 this.lamp.connect(this.masterGain)
468 this.midi.connect(this.masterGain)
469 }
470
471 /**
472 * Set the audio output type.
473 *
474 * @param {string} audioType "telegraph" for telegraph mode, otherwise tone mode
475 */
476 SetAudioType(audioType) {
477 this.Panic()
478 this.collection.delete(this.telegraph)
479 this.collection.delete(this.tone)
480 if (audioType == "telegraph") {
481 this.collection.add(this.telegraph)
482 } else {
483 this.collection.add(this.tone)
484 }
485 }
486
487 /**
488 * Buzz all outputs.
489 *
490 * @param tx True if transmitting
491 */
492 Buzz(tx=False) {
493 for (let b of this.collection) {
494 b.Buzz(tx)
495 }
496 }
497
498 /**
499 * Silence all outputs in a single direction.
500 *
501 * @param tx True if transmitting
502 */
503 Silence(tx=false) {
504 for (let b of this.collection) {
505 b.Silence(tx)
506 }
507 }
508
509 /**
510 * Silence all outputs.
511 */
512 Panic() {
513 this.Silence(true)
514 this.Silence(false)
515 }
516
517 /**
518 *
519 * @param {Boolean} tx True to set transmit tone
520 * @param {Number} note MIDI note to set
521 */
522 SetMIDINote(tx, note) {
523 for (let b of this.collection) {
524 if (b.SetMIDINote) {
525 b.SetMIDINote(tx, note)
526 }
527 }
528 }
529
530 /**
531 * Buzz for a certain duration at a certain time
532 *
533 * @param tx True if transmitting
534 * @param when Time to begin
535 * @param duration How long to buzz
536 */
537 BuzzDuration(tx, when, duration) {
538 for (let b of this.collection) {
539 b.BuzzDuration(tx, when, duration)
540 }
541 }
542
543 /**
544 * Update the "connected" status display.
545 *
546 * For example, turn the receive light to black if the repeater is not connected.
547 *
548 * @param {boolean} connected True if we are "connected"
549 */
550 SetConnected(connected) {
551 for (let b of this.collection) {
552 b.SetConnected(connected)
553 }
554 }
555}
556
557export {Collection}