diff --git a/static/1992.html b/static/1992.html
index 830ac23..d3083bf 100644
--- a/static/1992.html
+++ b/static/1992.html
@@ -79,7 +79,7 @@
rx
-
+
If you can read this, it means the browser needs you to click somewhere on this page
before it will start beeping!
diff --git a/static/index.html b/static/index.html
index a7d987f..a7043e4 100644
--- a/static/index.html
+++ b/static/index.html
@@ -57,7 +57,7 @@
-
+
@@ -213,8 +213,8 @@
+ rx delay:
s
- rx delay
@@ -246,6 +246,89 @@
+
+
+
+ volume:
+ %
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/scripts/music.mjs b/static/scripts/music.mjs
new file mode 100644
index 0000000..8f4f4ee
--- /dev/null
+++ b/static/scripts/music.mjs
@@ -0,0 +1,57 @@
+/**
+ * @file Musical notes and frequencies
+ */
+
+/** One half step in equal temperament */
+const semitone = Math.pow(2, 1/12)
+const ln_semitone = Math.log(semitone)
+const A4_note = 69
+const A4_freq = 440
+const Cn1_note = 0
+const Cn1_freq = A4_freq / Math.pow(semitone, A4_note)
+
+const note_names = [
+ "C",
+ "C♯",
+ "D",
+ "E♭",
+ "E",
+ "F",
+ "F♯",
+ "G",
+ "A♭",
+ "A",
+ "B♭",
+ "B",
+]
+
+/**
+ * Convert a MIDI note to a frequency
+ *
+ * @param {Number} note MIDI note number
+ * @returns {Number} Frequency (Hz)
+ */
+function MIDINoteFrequency(note) {
+ return Cn1_freq * Math.pow(semitone, note)
+}
+
+/**
+ * Return closest matching MIDI note to a frequency
+ *
+ * @param {Number} frequency Frequency (Hz)
+ * @returns {Number} Closest MIDI note
+ */
+function FrequencyMIDINote(frequency) {
+ return Math.round(Math.log(frequency/Cn1_freq) / ln_semitone)
+}
+
+function MIDINoteName(note) {
+ let octave = Math.floor(note / 12) - 1
+ return note_names[note % 12] + octave
+}
+
+export {
+ MIDINoteFrequency,
+ FrequencyMIDINote,
+ MIDINoteName,
+}
\ No newline at end of file
diff --git a/static/scripts/outputs.mjs b/static/scripts/outputs.mjs
index bf32f37..d9c16d1 100644
--- a/static/scripts/outputs.mjs
+++ b/static/scripts/outputs.mjs
@@ -1,3 +1,5 @@
+import {AudioSource, AudioContextTime} from "./audio.mjs"
+
const HIGH_FREQ = 555
const LOW_FREQ = 444
@@ -26,68 +28,88 @@ const Second = 1000 * Millisecond
*/
const OscillatorRampDuration = 5*Millisecond
-console.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.")
-const BuzzerAudioContext = new AudioContext({
- latencyHint: 0,
-})
-/**
- * Compute the special "Audio Context" time
- *
- * This is is a duration from now, in seconds.
- *
- * @param {Date} when Date to compute
- * @returns audiocontext time
- */
-function BuzzerAudioContextTime(when) {
- if (!when) return 0
- let acOffset = Date.now() - (BuzzerAudioContext.currentTime * Second)
- return Math.max(when - acOffset, 0) / Second
-}
-class Oscillator {
+class Oscillator extends AudioSource {
/**
* Create a new oscillator, and encase it in a Gain for control.
*
+ * @param {AudioContext} context Audio context
* @param {number} frequency Oscillator frequency (Hz)
- * @param {number} gain Gain (volume) of this oscillator (0.0 - 1.0)
+ * @param {number} maxGain Maximum gain (volume) of this oscillator (0.0 - 1.0)
* @param {string} type Oscillator type
- * @returns {GainNode} A new GainNode object this oscillator is connected to
*/
- constructor(frequency, gain = 0.5, type = "sine") {
- this.targetGain = gain
+ constructor(context, frequency, maxGain = 0.5, type = "sine") {
+ super(context)
+ this.maxGain = maxGain
+
+ // Start quiet
+ this.masterGain.gain.value = 0
- this.gainNode = BuzzerAudioContext.createGain()
- this.gainNode.connect(BuzzerAudioContext.destination)
- this.gainNode.gain.value = 0
-
- this.osc = BuzzerAudioContext.createOscillator()
- this.osc.type = type
- this.osc.connect(this.gainNode)
- this.osc.frequency.value = frequency
+ this.osc = new OscillatorNode(this.context)
+ this.osc.type = type
+ this.osc.connect(this.masterGain)
+ this.setFrequency(frequency)
this.osc.start()
-
- return gain
}
+ /**
+ * Set oscillator frequency
+ *
+ * @param {Number} frequency New frequency (Hz)
+ */
+ setFrequency(frequency) {
+ this.osc.frequency.value = frequency
+ }
+
+ /**
+ * Set oscillator frequency to a MIDI note number
+ *
+ * This uses an equal temperament.
+ *
+ * @param {Number} note MIDI note number
+ */
+ setMIDINote(note) {
+ let frequency = 8.18 // MIDI note 0
+ for (let i = 0; i < note; i++) {
+ frequency *= 1.0594630943592953 // equal temperament half step
+ }
+ this.setFrequency(frequency)
+ }
+
/**
- *
+ * Set gain to some value at a given time.
+ *
* @param {number} target Target gain
* @param {Date} when Time this should start
* @param {Duration} timeConstant Duration of ramp to target gain
*/
async setTargetAtTime(target, when, timeConstant=OscillatorRampDuration) {
- await BuzzerAudioContext.resume()
- this.gainNode.gain.setTargetAtTime(
+ await this.context.resume()
+ this.masterGain.gain.setTargetAtTime(
target,
- BuzzerAudioContextTime(when),
+ AudioContextTime(this.context, when),
timeConstant/Second,
)
}
+ /**
+ * Make sound at a given time.
+ *
+ * @param {Number} when When to start making noise
+ * @param {Number} timeConstant How long to ramp up
+ * @returns {Promise}
+ */
SoundAt(when=0, timeConstant=OscillatorRampDuration) {
- return this.setTargetAtTime(this.targetGain, when, timeConstant)
+ return this.setTargetAtTime(this.maxGain, when, timeConstant)
}
+ /**
+ * Shut up at a given time.
+ *
+ * @param {Number} when When to stop making noise
+ * @param {Number} timeConstant How long to ramp down
+ * @returns {Promise}
+ */
HushAt(when=0, timeConstant=OscillatorRampDuration) {
return this.setTargetAtTime(0, when, timeConstant)
}
@@ -96,24 +118,20 @@ class Oscillator {
/**
* A digital sample, loaded from a URL.
*/
-class Sample {
+class Sample extends AudioSource {
/**
- *
+ * @param {AudioContext} context
* @param {string} url URL to resource
- * @param {number} gain Gain (0.0 - 1.0)
*/
- constructor(url, gain=0.5) {
+ constructor(context, url) {
+ super(context)
this.resume = this.load(url)
-
- this.gainNode = BuzzerAudioContext.createGain()
- this.gainNode.connect(BuzzerAudioContext.destination)
- this.gainNode.gain.value = gain
}
async load(url) {
let resp = await fetch(url)
let buf = await resp.arrayBuffer()
- this.data = await BuzzerAudioContext.decodeAudioData(buf)
+ this.data = await this.context.decodeAudioData(buf)
}
/**
@@ -122,22 +140,24 @@ class Sample {
* @param {Date} when When to begin playback
*/
async PlayAt(when) {
- await BuzzerAudioContext.resume()
+ await this.context.resume()
await this.resume
- let bs = BuzzerAudioContext.createBufferSource()
+ let bs = new AudioBufferSourceNode(this.context)
bs.buffer = this.data
- bs.connect(this.gainNode)
- bs.start(BuzzerAudioContextTime(when))
+ bs.connect(this.masterGain)
+ bs.start(AudioContextTime(this.context, when))
}
}
-
-
/**
* A (mostly) virtual class defining a buzzer.
*/
-class Buzzer {
- constructor() {
+class Buzzer extends AudioSource {
+ /**
+ * @param {AudioContext} context
+ */
+ constructor(context) {
+ super(context)
this.connected = true
}
@@ -191,41 +211,66 @@ class Buzzer {
}
class AudioBuzzer extends Buzzer {
- constructor(errorFreq=30) {
- super()
+ /**
+ * A buzzer that make noise
+ *
+ * @param {AudioContext} context
+ * @param {Number} errorFreq Error tone frequency (hz)
+ */
+ constructor(context, errorFreq=30) {
+ super(context)
- this.errorGain = new Oscillator(errorFreq, 0.1, "square")
+ this.errorTone = new Oscillator(this.context, errorFreq, 0.1, "square")
+ this.errorTone.connect(this.masterGain)
}
Error() {
let now = Date.now()
- this.errorGain.SoundAt(now)
- this.errorGain.HushAt(now + 200*Millisecond)
+ this.errorTone.SoundAt(now)
+ this.errorTone.HushAt(now + 200*Millisecond)
}
}
+/**
+ * Buzzers keep two oscillators: one high and one low.
+ * They generate a continuous waveform,
+ * and we change the gain to turn the pitches off and on.
+ *
+ * This also implements a very quick ramp-up and ramp-down in gain,
+ * in order to avoid "pops" (square wave overtones)
+ * that happen with instant changes in gain.
+ */
class ToneBuzzer extends AudioBuzzer {
- // Buzzers keep two oscillators: one high and one low.
- // They generate a continuous waveform,
- // and we change the gain to turn the pitches off and on.
- //
- // This also implements a very quick ramp-up and ramp-down in gain,
- // in order to avoid "pops" (square wave overtones)
- // that happen with instant changes in gain.
+ constructor(context, {txGain=0.5, highFreq=HIGH_FREQ, lowFreq=LOW_FREQ} = {}) {
+ super(context)
- constructor({txGain=0.5, highFreq=HIGH_FREQ, lowFreq=LOW_FREQ} = {}) {
- super()
+ this.rxOsc = new Oscillator(this.context, lowFreq, txGain)
+ this.txOsc = new Oscillator(this.context, highFreq, txGain)
- this.rxOsc = new Oscillator(lowFreq, txGain)
- this.txOsc = new Oscillator(highFreq, txGain)
+ this.rxOsc.connect(this.masterGain)
+ this.txOsc.connect(this.masterGain)
// Keep the speaker going always. This keeps the browser from "swapping out" our audio context.
if (false) {
- this.bgOsc = new Oscillator(1, 0.001)
+ this.bgOsc = new Oscillator(this.context, 1, 0.001)
this.bgOsc.SoundAt()
}
}
+ /**
+ * Set MIDI note for tx/rx tone
+ *
+ * @param {Boolean} tx True to set transmit note
+ * @param {Number} note MIDI note to send
+ */
+ SetMIDINote(tx, note) {
+ if (tx) {
+ this.txOsc.setMIDINote(note)
+ } else {
+ this.rxOsc.setMIDINote(note)
+ }
+ }
+
/**
* Begin buzzing at time
*
@@ -250,17 +295,20 @@ class ToneBuzzer extends AudioBuzzer {
}
class TelegraphBuzzer extends AudioBuzzer{
- constructor(gain=0.6) {
- super()
+ /**
+ *
+ * @param {AudioContext} context
+ */
+ constructor(context) {
+ super(context)
+ this.hum = new Oscillator(this.context, 140, 0.005, "sawtooth")
- this.gainNode = BuzzerAudioContext.createGain()
- this.gainNode.connect(BuzzerAudioContext.destination)
- this.gainNode.gain.value = gain
+ this.closeSample = new Sample(this.context, "../assets/telegraph-a.mp3")
+ this.openSample = new Sample(this.context, "../assets/telegraph-b.mp3")
- this.hum = new Oscillator(140, 0.005, "sawtooth")
-
- this.closeSample = new Sample("../assets/telegraph-a.mp3")
- this.openSample = new Sample("../assets/telegraph-b.mp3")
+ this.hum.connect(this.masterGain)
+ this.closeSample.connect(this.masterGain)
+ this.openSample.connect(this.masterGain)
}
async Buzz(tx, when=0) {
@@ -281,8 +329,12 @@ class TelegraphBuzzer extends AudioBuzzer{
}
class LampBuzzer extends Buzzer {
- constructor() {
- super()
+ /**
+ *
+ * @param {AudioContext} context
+ */
+ constructor(context) {
+ super(context)
this.elements = document.querySelectorAll(".recv-lamp")
}
@@ -325,9 +377,13 @@ class LampBuzzer extends Buzzer {
}
class MIDIBuzzer extends Buzzer {
- constructor() {
- super()
- this.SetNote(69) // A4; 440Hz
+ /**
+ *
+ * @param {AudioContext} context
+ */
+ constructor(context) {
+ super(context)
+ this.SetMIDINote(69) // A4; 440Hz
this.midiAccess = {outputs: []} // stub while we wait for async stuff
if (navigator.requestMIDIAccess) {
@@ -381,10 +437,13 @@ class MIDIBuzzer extends Buzzer {
this.sendAt(when, [0x80, this.note, 0x7f])
}
- /*
- * Set note to transmit
- */
- SetNote(tx, note) {
+ /**
+ * Set MIDI note for tx/rx tone
+ *
+ * @param {Boolean} tx True to set transmit note
+ * @param {Number} note MIDI note to send
+ */
+ SetMIDINote(tx, note) {
if (tx) {
return
}
@@ -392,20 +451,23 @@ class MIDIBuzzer extends Buzzer {
}
}
-/**
- * Block until the audio system is able to start making noise.
- */
-async function AudioReady() {
- await BuzzerAudioContext.resume()
-}
-
-class Collection {
- constructor() {
- this.tone = new ToneBuzzer()
- this.telegraph = new TelegraphBuzzer()
- this.lamp = new LampBuzzer()
- this.midi = new MIDIBuzzer()
+class Collection extends AudioSource {
+ /**
+ *
+ * @param {AudioContext} context Audio Context
+ */
+ constructor(context) {
+ super(context)
+ this.tone = new ToneBuzzer(this.context)
+ this.telegraph = new TelegraphBuzzer(this.context)
+ this.lamp = new LampBuzzer(this.context)
+ this.midi = new MIDIBuzzer(this.context)
this.collection = new Set([this.tone, this.lamp, this.midi])
+
+ this.tone.connect(this.masterGain)
+ this.telegraph.connect(this.masterGain)
+ this.lamp.connect(this.masterGain)
+ this.midi.connect(this.masterGain)
}
/**
@@ -414,6 +476,7 @@ class Collection {
* @param {string} audioType "telegraph" for telegraph mode, otherwise tone mode
*/
SetAudioType(audioType) {
+ this.Panic()
this.collection.delete(this.telegraph)
this.collection.delete(this.tone)
if (audioType == "telegraph") {
@@ -435,7 +498,7 @@ class Collection {
}
/**
- * Silence all outputs.
+ * Silence all outputs in a single direction.
*
* @param tx True if transmitting
*/
@@ -445,6 +508,27 @@ class Collection {
}
}
+ /**
+ * Silence all outputs.
+ */
+ Panic() {
+ this.Silence(true)
+ this.Silence(false)
+ }
+
+ /**
+ *
+ * @param {Boolean} tx True to set transmit tone
+ * @param {Number} note MIDI note to set
+ */
+ SetMIDINote(tx, note) {
+ for (let b of this.collection) {
+ if (b.SetMIDINote) {
+ b.SetMIDINote(tx, note)
+ }
+ }
+ }
+
/**
* Buzz for a certain duration at a certain time
*
@@ -472,4 +556,4 @@ class Collection {
}
}
-export {AudioReady, Collection}
+export {Collection}
diff --git a/static/scripts/vail.mjs b/static/scripts/vail.mjs
index 9e27b09..3953bd1 100644
--- a/static/scripts/vail.mjs
+++ b/static/scripts/vail.mjs
@@ -4,12 +4,18 @@ import * as Inputs from "./inputs.mjs"
import * as Repeaters from "./repeaters.mjs"
import * as Chart from "./chart.mjs"
import * as I18n from "./i18n.mjs"
+import * as Music from "./music.mjs"
const DefaultRepeater = "General"
const Millisecond = 1
const Second = 1000 * Millisecond
const Minute = 60 * Second
+console.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.")
+const globalAudioContext = new AudioContext({
+ latencyHint: "interactive",
+})
+
/**
* Pop up a message, using an notification.
*
@@ -39,7 +45,8 @@ class VailClient {
this.beginTxTime = null // Time when we began transmitting
// Outputs
- this.outputs = new Outputs.Collection()
+ this.outputs = new Outputs.Collection(globalAudioContext)
+ this.outputs.connect(globalAudioContext.destination)
// Keyers
this.straightKeyer = new Keyers.Keyers.straight(this)
@@ -50,6 +57,13 @@ class VailClient {
// Send this as the keyer so we can intercept dit and dah events for charts
this.inputs = new Inputs.Collection(this)
+ // If the user clicks anything, try immediately to resume the audio context
+ document.body.addEventListener(
+ "click",
+ e => globalAudioContext.resume(),
+ true,
+ )
+
// Maximize button
for (let e of document.querySelectorAll("button.maximize")) {
e.addEventListener("click", e => this.maximize(e))
@@ -73,6 +87,19 @@ class VailClient {
this.inputInit("#rx-delay", e => {
this.rxDelay = e.target.value * Second
})
+ this.inputInit("#masterGain", e => {
+ this.outputs.SetGain(e.target.value / 100)
+ })
+ this.inputInit(
+ "#rx-tone",
+ e => this.outputs.SetMIDINote(false, e.target.value),
+ Music.MIDINoteName,
+ )
+ this.inputInit(
+ "#tx-tone",
+ e => this.outputs.SetMIDINote(true, e.target.value),
+ Music.MIDINoteName,
+ )
this.inputInit("#telegraph-buzzer", e => {
this.setTelegraphBuzzer(e.target.checked)
})
@@ -86,10 +113,11 @@ class VailClient {
this.setTimingCharts(true)
// Turn off the "muted" symbol when we can start making noise
- Outputs.AudioReady()
+ globalAudioContext.resume()
.then(() => {
- console.log("Audio context ready")
- document.querySelector("#muted").classList.add("is-hidden")
+ for (let e of document.querySelectorAll(".muted")) {
+ e.classList.add("is-hidden")
+ }
})
}
@@ -300,8 +328,9 @@ class VailClient {
*
* @param {string} selector CSS path to the element
* @param {function} callback Callback to call with any new value that is set
+ * @param {function} transform A function to transform the value into displayed text
*/
- inputInit(selector, callback) {
+ inputInit(selector, callback, transform=null) {
let element = document.querySelector(selector)
if (!element) {
console.warn("Unable to find an input to init", selector)
@@ -322,8 +351,12 @@ class VailClient {
}
localStorage[element.id] = value
+ let displayValue = value
+ if (transform) {
+ displayValue = transform(value)
+ }
if (outputElement) {
- outputElement.value = value
+ outputElement.value = displayValue
}
if (callback) {
callback(e)