vail

Internet morse code repeater
git clone https://git.woozle.org/neale/vail.git

vail / static / scripts
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}