vail

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

vail / static / scripts
Neale Pickett  ·  2023-03-18

vail.mjs

  1import * as Keyers from "./keyers.mjs"
  2import * as Outputs from "./outputs.mjs"
  3import * as Inputs from "./inputs.mjs"
  4import * as Repeaters from "./repeaters.mjs"
  5import * as Chart from "./chart.mjs"
  6import * as I18n from "./i18n.mjs"
  7import * as time from "./time.mjs"
  8import * as Music from "./music.mjs"
  9import * as Icon from "./icon.mjs"
 10import * as Noise from "./noise.mjs"
 11
 12const DefaultRepeater = "General"
 13
 14console.warn("Chrome will now complain about an AudioContext not being allowed to start. This is normal, and there is no way to make Chrome stop complaining about this.")
 15const globalAudioContext = new AudioContext({
 16	latencyHint: "interactive",
 17})
 18
 19function initLog(message) {
 20	for (let modal of document.querySelectorAll(".modal.init")) {
 21		if (!message) {
 22			modal.remove()
 23		} else {
 24			let ul = modal.querySelector("ul")
 25			while (ul.childNodes.length > 5) {
 26				ul.firstChild.remove()
 27			}
 28			let li = ul.appendChild(document.createElement("li"))
 29			li.textContent = message
 30			}
 31	}
 32}
 33
 34/**
 35 * Pop up a message, using an notification.
 36 * 
 37 * @param {string} msg Message to display
 38 */
 39function toast(msg, timeout=4*time.Second) {
 40	console.info(msg)
 41
 42	let errors = document.querySelector("#errors")
 43	let p = errors.appendChild(document.createElement("p"))
 44	p.textContent = msg
 45	setTimeout(() => p.remove(), timeout)
 46}
 47
 48// iOS kludge
 49if (!window.AudioContext) {
 50	window.AudioContext = window.webkitAudioContext
 51}
 52
 53class VailClient {
 54	constructor() {
 55		this.sent = []
 56		this.lagTimes = [0]
 57		this.rxDurations = [0]
 58		this.clockOffset = null // How badly our clock is off of the server's
 59		this.rxDelay = 0 * time.Millisecond // Time to add to incoming timestamps
 60		this.beginTxTime = null // Time when we began transmitting
 61
 62		initLog("Initializing outputs")
 63		this.outputs = new Outputs.Collection(globalAudioContext)
 64		this.outputs.connect(globalAudioContext.destination)
 65
 66		initLog("Starting up noise")
 67		this.noise = new Noise.Noise(globalAudioContext)
 68		this.noise.connect(globalAudioContext.destination)
 69
 70		initLog("Setting app icon name")
 71		this.icon = new Icon.Icon()
 72
 73		initLog("Initializing keyers")
 74		this.straightKeyer = new Keyers.Keyers.straight(this)
 75		this.keyer = new Keyers.Keyers.straight(this)
 76		this.roboKeyer = new Keyers.Keyers.robo(() => this.Buzz(), () => this.Silence())
 77
 78		// Send this as the keyer so we can intercept dit and dah events for charts
 79		initLog("Setting up input methods")
 80		this.inputs = new Inputs.Collection(this)
 81
 82		initLog("Listening on AudioContext")
 83		document.body.addEventListener(
 84			"click",
 85			e => globalAudioContext.resume(),
 86			true,
 87		)
 88
 89		initLog('Setting up maximize button')
 90		for (let e of document.querySelectorAll("button.maximize")) {
 91			e.addEventListener("click", e => this.maximize(e))
 92		}
 93		for (let e of document.querySelectorAll("#reset")) {
 94			e.addEventListener("click", e => this.reset())
 95		}
 96
 97		initLog("Initializing knobs")
 98		this.inputInit("#keyer-mode", e => this.setKeyer(e.target.value))
 99		this.inputInit("#keyer-rate", e => {
100			let rate = e.target.value
101			this.ditDuration = Math.round(time.Minute / rate / 50)
102			for (let e of document.querySelectorAll("[data-fill='keyer-ms']")) {
103				e.textContent = this.ditDuration
104			}
105			this.keyer.SetDitDuration(this.ditDuration)
106			this.roboKeyer.SetDitDuration(this.ditDuration)
107			this.inputs.SetDitDuration(this.ditDuration)
108		})
109		this.inputInit("#rx-delay", e => { 
110			this.rxDelay = e.target.value * time.Second
111		})
112		this.inputInit("#masterGain", e => {
113			this.outputs.SetGain(e.target.value / 100)
114		})
115		this.inputInit("#noiseGain", e => {
116			this.noise.SetGain(e.target.value / 100)
117		})
118		let toneTransform = {
119			note: Music.MIDINoteName,
120			freq: Music.MIDINoteFrequency,
121		}
122		this.inputInit(
123			"#rx-tone", 
124			e => {
125				this.noise.SetNoiseFrequency(1, Music.MIDINoteFrequency(e.target.value))
126				this.outputs.SetMIDINote(false, e.target.value)
127			},
128			toneTransform,
129		)
130		this.inputInit(
131			"#tx-tone", 
132			e => this.outputs.SetMIDINote(true, e.target.value),
133			toneTransform,
134		)
135		this.inputInit("#telegraph-buzzer", e => {
136			this.setTelegraphBuzzer(e.target.checked)
137		})
138		this.inputInit("#notes")
139
140		initLog("Filling in repeater name")
141		document.querySelector("#repeater").addEventListener("change", e => this.setRepeater(e.target.value.trim()))
142		window.addEventListener("hashchange", () => this.hashchange())
143		this.hashchange()
144	
145		initLog("Starting timing charts")
146		this.setTimingCharts(true)
147
148		initLog("Setting up mute icon")
149		globalAudioContext.resume()
150		.then(() => {
151			for (let e of document.querySelectorAll(".muted")) {
152				e.classList.add("is-hidden")
153			}
154		})
155	}
156	
157	/**
158	 * Straight key change (keyer shim)
159	 * 
160	 * @param down If key has been depressed
161	 */
162	Straight(down) {
163		this.straightKeyer.Key(0, down)
164	}
165
166	/**
167	 * Key/paddle change
168	 * 
169	 * @param {Number} key Key which was pressed
170	 * @param {Boolean} down True if key was pressed
171	 */
172	Key(key, down) {
173		this.keyer.Key(key, down)
174		if (this.keyCharts) this.keyCharts[key].Set(down?1:0)
175	}
176
177	setKeyer(keyerName) {
178		let newKeyerClass = Keyers.Keyers[keyerName]
179		let newKeyerNumber = Keyers.Numbers[keyerName]
180		if (!newKeyerClass) {
181			console.error("Keyer not found", keyerName)
182			return
183		}
184		let newKeyer = new newKeyerClass(this)
185		let i = 0
186		for (let keyName of newKeyer.KeyNames()) {
187			let e = document.querySelector(`.key[data-key="${i}"]`)
188			e.textContent = keyName
189			i += 1
190		}
191		this.keyer.Release()
192		this.keyer = newKeyer
193
194		this.inputs.SetKeyerMode(newKeyerNumber)
195
196		document.querySelector("#keyer-rate").dispatchEvent(new Event("input"))
197	}
198
199	Buzz() {
200		this.outputs.Buzz(false)
201		this.icon.Set("rx")
202
203		if (this.rxChart) this.rxChart.Set(1)
204	}
205
206	Silence() {
207		this.outputs.Silence()
208		if (this.rxChart) this.rxChart.Set(0)
209	}
210
211	BuzzDuration(tx, when, duration) {
212		this.outputs.BuzzDuration(tx, when, duration)
213
214		let chart
215		if (tx) {
216			chart = this.txChart
217		} else {
218			chart = this.rxChart
219			this.icon.SetAt("rx", when)
220		}
221		if (chart) {
222			chart.SetAt(1, when)
223			chart.SetAt(0, when+duration)
224		}
225	}
226
227	/**
228	 * Start the side tone buzzer.
229	 * 
230	 * Called from the keyer.
231	 */
232	 BeginTx() {
233		this.beginTxTime = Date.now()
234		this.outputs.Buzz(true)
235		if (this.txChart) this.txChart.Set(1)
236
237	}
238
239	/**
240	 * Stop the side tone buzzer, and send out how long it was active.
241	 * 
242	 * Called from the keyer
243	 */
244	EndTx() {
245		if (!this.beginTxTime) {
246			return
247		}
248		let endTxTime = Date.now()
249		let duration = endTxTime - this.beginTxTime
250		this.outputs.Silence(true)
251		this.repeater.Transmit(this.beginTxTime, duration)
252		this.beginTxTime = null
253		if (this.txChart) this.txChart.Set(0)
254	}
255
256
257	/**
258	 * Toggle timing charts.
259	 * 
260	 * @param enable True to enable charts
261	 */
262	setTimingCharts(enable) {
263		// XXX: UI code shouldn't be in the Keyer class.
264		// Actually, the charts calls should be in vail
265		let chartsContainer = document.querySelector("#charts")
266		if (!chartsContainer) {
267			return
268		}
269		if (enable) {
270			chartsContainer.classList.remove("hidden")
271			this.keyCharts = [
272				Chart.FromSelector("#key0Chart"),
273				Chart.FromSelector("#key1Chart")
274			]
275			this.txChart = Chart.FromSelector("#txChart")
276			this.rxChart = Chart.FromSelector("#rxChart")
277		} else {
278			chartsContainer.classList.add("hidden")
279			this.keyCharts = []
280			this.txChart = null
281			this.rxChart = null
282		}
283	}
284
285	/**
286	 * Toggle the clicktastic buzzer, instead of the beeptastic one.
287	 * 
288	 * @param {bool} enable true to enable clicky buzzer
289	 */
290	setTelegraphBuzzer(enable) {
291		if (enable) {
292			this.outputs.SetAudioType("telegraph")
293			toast("Telegraphs only make sound when receiving!")
294		} else {
295			this.outputs.SetAudioType()
296		}
297	}
298
299	/**
300	 * Called when the hash part of the URL has changed.
301	 */
302	hashchange() {
303		let hashParts = window.location.hash.split("#")
304		
305		this.setRepeater(decodeURIComponent(hashParts[1] || ""))
306	}
307
308	/**
309	 * Connect to a repeater by name.
310	 * 
311	 * This does some switching logic to provide multiple types of repeaters,
312	 * like the Fortunes repeaters.
313	 * 
314	 * @param {string} name Repeater name
315	 */
316	setRepeater(name) {
317		if (!name || (name == "")) {
318			name = DefaultRepeater
319		}
320		this.repeaterName = name
321
322		// Set value of repeater element
323		let repeaterElement = document.querySelector("#repeater")
324		let paps = repeaterElement.parentElement
325		if (paps.MaterialTextfield) {
326			paps.MaterialTextfield.change(name)
327		} else {
328			repeaterElement.value = name
329		}
330
331		// Set window URL
332		let prevHash = window.location.hash
333		window.location.hash = (name == DefaultRepeater) ? "" : name
334		if (window.location.hash != prevHash) {
335			// We're going to get a hashchange event, which will re-run this method
336			return
337		}
338		
339		this.Silence()
340		if (this.repeater) {
341			this.repeater.Close()
342		}
343		let rx = (w,d,s) => this.receive(w,d,s)
344
345		// If there's a number in the name, store that for potential later use
346		let numberMatch = name.match(/[0-9]+/)
347		let number = 0
348		if (numberMatch) {
349			number = Number(numberMatch[0])
350		}
351
352		if (name.startsWith("Fortunes")) {
353			this.roboKeyer.SetPauseMultiplier(number || 1)
354			this.repeater = new Repeaters.Fortune(rx, this.roboKeyer)
355		} else if (name.startsWith("Echo")) {
356			this.repeater = new Repeaters.Echo(rx)
357		} else if (name == "Null") {
358			this.repeater = new Repeaters.Null(rx)
359		} else {
360			this.repeater = new Repeaters.Vail(rx, name)
361		}
362	}
363
364	/**
365	 * Set up an HTML input element.
366	 * 
367	 * This reads any previously saved value and sets the input value to that.
368	 * When the input is updated, it saves the value it's updated to,
369	 * and calls the provided callback with the new value.
370	 * 
371	 * @param {string} selector CSS path to the element
372	 * @param {function} callback Callback to call with any new value that is set
373	 * @param {Object.<string, function>} transform Transform functions
374	 */
375	inputInit(selector, callback, transform={}) {
376		let element = document.querySelector(selector)
377		if (!element) {
378			console.warn("Unable to find an input to init", selector)
379			return
380		}
381		let storedValue = localStorage[element.id]
382		if (storedValue != null) {
383			element.value = storedValue
384			element.checked = (storedValue == "on")
385		}
386		let id = element.id
387		let outputElements = document.querySelectorAll(`[for="${id}"]`)
388
389		element.addEventListener("input", e => {
390			let value = element.value
391			if (element.type == "checkbox") {
392				value = element.checked?"on":"off"
393			}
394			localStorage[element.id] = value
395
396			for (let e of outputElements) {
397				if (e.dataset.transform) {
398					let tf = transform[e.dataset.transform]
399					e.value = tf(value)
400				} else {
401					e.value = value
402				}
403			}
404			if (callback) {
405				callback(e)
406			}
407		})
408		element.dispatchEvent(new Event("input"))
409	}
410
411	/**
412	 * Make an error sound and pop up a message
413	 * 
414	 * @param {string} msg The message to pop up
415	 */
416	error(msg) {
417		toast(msg)
418		this.outputs.Error()
419	}
420
421	/**
422	 * Called by a repeater class when there's something received.
423	 * 
424	 * @param {number} when When to play the tone
425	 * @param {number} duration How long to play the tone
426	 * @param {dict} stats Stuff the repeater class would like us to know about
427	 */
428	receive(when, duration, stats) {
429		this.clockOffset = stats.clockOffset || "?"
430		let now = Date.now()
431		when += this.rxDelay
432
433		if (duration > 0) {
434			if (when < now) {
435				console.warn("Too old", when, duration)
436				this.error("Packet requested playback " + (now - when) + "ms in the past. Increase receive delay!")
437				return
438			}
439
440			this.BuzzDuration(false, when, duration)
441
442			this.rxDurations.unshift(duration)
443			this.rxDurations.splice(20, 2)
444		}
445
446		if (stats.notice) {
447			toast(stats.notice)
448		}
449
450		let averageLag = (stats.averageLag || 0).toFixed(2)
451		let longestRxDuration = this.rxDurations.reduce((a,b) => Math.max(a,b))
452		let suggestedDelay = ((averageLag + longestRxDuration) * 1.2).toFixed(0)
453
454		if (stats.connected !== undefined) {
455			this.outputs.SetConnected(stats.connected)
456		}
457		this.updateReading("#note", stats.note || stats.clients || "😎")
458		this.updateReading("#lag-value", averageLag)
459		this.updateReading("#longest-rx-value", longestRxDuration)
460		this.updateReading("#suggested-delay-value", suggestedDelay)
461		this.updateReading("#clock-off-value", this.clockOffset)
462	}
463
464	/**
465	 * Update an element with a value, if that element exists
466	 * 
467	 * @param {string} selector CSS path to the element
468	 * @param value Value to set
469	 */
470	updateReading(selector, value) {
471		let e = document.querySelector(selector)
472		if (e) {
473			e.value = value
474		}
475	}
476
477	/**
478	 * Maximize/minimize a card
479	 * 
480	 * @param e Event
481	 */
482	maximize(e) {
483		let element = e.target
484		while (!element.classList.contains("mdl-card")) {
485			element = element.parentElement
486			if (!element) {
487				console.log("Maximize button: couldn't find parent card")
488				return
489			}
490		}
491		element.classList.toggle("maximized")
492		console.log(element)
493	}
494
495	/** Reset to factory defaults */
496	reset() {
497		localStorage.clear()
498		location.reload()
499	}
500}
501
502async function init() {
503	initLog("Starting service worker")
504	if (navigator.serviceWorker) {
505		navigator.serviceWorker.register("scripts/sw.js")
506	}
507
508	initLog("Setting up internationalization")
509	await I18n.Setup()
510
511	initLog("Creating client")
512	try {
513		window.app = new VailClient()
514	} catch (err) {
515		console.log(err)
516		toast(err)
517	}
518	initLog(false)
519}
520
521
522if (document.readyState === "loading") {
523	document.addEventListener("DOMContentLoaded", init)
524} else {
525	init()
526}
527
528// vim: noet sw=2 ts=2