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