diff --git a/static/buzzer.mjs b/static/buzzer.mjs
new file mode 100644
index 0000000..c9695d6
--- /dev/null
+++ b/static/buzzer.mjs
@@ -0,0 +1,294 @@
+const HIGH_FREQ = 666
+const LOW_FREQ = 555
+
+
+ /**
+ * A duration.
+ *
+ * Because JavaScript has multiple confliction notions of duration,
+ * everything in vail uses this.
+ *
+ * @typedef {number} Duration
+ */
+
+/**
+ * An epoch time, as returned by Date.now().
+ *
+ * @typedef {number} Date
+ */
+
+const Millisecond = 1
+const Second = 1000 * Millisecond
+
+/** The amount of time it should take an oscillator to ramp to and from zero gain
+ *
+ * @constant {Duration}
+ */
+ const OscillatorRampDuration = 5*Millisecond
+
+
+const BuzzerAudioContext = new AudioContext()
+/**
+ * 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
+}
+
+/**
+ * Block until the audio system is able to start making noise.
+ */
+async function Ready() {
+ await BuzzerAudioContext.resume()
+}
+
+class Oscillator {
+ /**
+ * Create a new oscillator, and encase it in a Gain for control.
+ *
+ * @param {number} frequency Oscillator frequency (Hz)
+ * @param {number} gain 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
+
+ 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.start()
+
+ return gain
+ }
+
+ /**
+ *
+ * @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(
+ target,
+ BuzzerAudioContextTime(when),
+ timeConstant/Second,
+ )
+ }
+
+ SoundAt(when=0, timeConstant=OscillatorRampDuration) {
+ return this.setTargetAtTime(this.targetGain, when, timeConstant)
+ }
+
+ HushAt(when=0, timeConstant=OscillatorRampDuration) {
+ return this.setTargetAtTime(0, when, timeConstant)
+ }
+}
+
+/**
+ * A digital sample, loaded from a URL.
+ */
+class Sample {
+ /**
+ *
+ * @param {string} url URL to resource
+ * @param {number} gain Gain (0.0 - 1.0)
+ */
+ constructor(url, gain=0.5) {
+ 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)
+ }
+
+ /**
+ * Play the sample
+ *
+ * @param {Date} when When to begin playback
+ */
+ async PlayAt(when) {
+ await BuzzerAudioContext.resume()
+ await this.resume
+ let bs = BuzzerAudioContext.createBufferSource()
+ bs.buffer = this.data
+ bs.connect(this.gainNode)
+ bs.start(BuzzerAudioContextTime(when))
+ }
+}
+
+
+
+/**
+ * A (mostly) virtual class defining a buzzer.
+ */
+class Buzzer {
+ /**
+ * Signal an error
+ */
+ Error() {
+ console.log("Error")
+ }
+
+ /**
+ * Begin buzzing at time
+ *
+ * @param {boolean} tx Transmit or receive tone
+ * @param {number} when Time to begin, in ms (0=now)
+ */
+ Buzz(tx, when=0) {
+ console.log("Buzz", tx, when)
+ }
+
+ /**
+ * End buzzing at time
+ *
+ * @param {boolean} tx Transmit or receive tone
+ * @param {number} when Time to end, in ms (0=now)
+ */
+ Silence(tx, when=0) {
+ console.log("Silence", tx, when)
+ }
+
+ /**
+ * Buzz for a duration at time
+ *
+ * @param {boolean} tx Transmit or receive tone
+ * @param {number} when Time to begin, in ms (0=now)
+ * @param {number} duration Duration of buzz (ms)
+ */
+ BuzzDuration(tx, when, duration) {
+ this.Buzz(tx, when)
+ this.Silence(tx, when + duration)
+ }
+}
+
+class AudioBuzzer extends Buzzer {
+ constructor(errorFreq=30) {
+ super()
+
+ this.errorGain = new Oscillator(errorFreq, 0.1, "square")
+ }
+
+ Error() {
+ let now = Date.now()
+ this.errorGain.SoundAt(now)
+ this.errorGain.HushAt(now + 200*Millisecond)
+ }
+}
+
+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({txGain=0.5, highFreq=HIGH_FREQ, lowFreq=LOW_FREQ} = {}) {
+ super()
+
+ this.rxOsc = new Oscillator(lowFreq, txGain)
+ this.txOsc = new Oscillator(highFreq, txGain)
+ }
+
+ /**
+ * Begin buzzing at time
+ *
+ * @param {boolean} tx Transmit or receive tone
+ * @param {number} when Time to begin, in ms (0=now)
+ */
+ Buzz(tx, when = null) {
+ let osc = tx?this.txOsc:this.rxOsc
+ osc.SoundAt(when)
+ }
+
+ /**
+ * End buzzing at time
+ *
+ * @param {boolean} tx Transmit or receive tone
+ * @param {number} when Time to end, in ms (0=now)
+ */
+ Silence(tx, when = null) {
+ let osc = tx?this.txOsc:this.rxOsc
+ osc.HushAt(when)
+ }
+}
+
+class TelegraphBuzzer extends AudioBuzzer{
+ constructor(gain=0.6) {
+ super()
+
+ this.gainNode = BuzzerAudioContext.createGain()
+ this.gainNode.connect(BuzzerAudioContext.destination)
+ this.gainNode.gain.value = gain
+
+ this.hum = new Oscillator(140, 0.005, "sawtooth")
+
+ this.closeSample = new Sample("telegraph-a.mp3")
+ this.openSample = new Sample("telegraph-b.mp3")
+ }
+
+ Buzz(tx, when=0) {
+ if (tx) {
+ this.hum.SoundAt(when)
+ } else {
+ this.closeSample.PlayAt(when)
+ }
+ }
+
+ Silence(tx ,when=0) {
+ if (tx) {
+ this.hum.HushAt(when)
+ } else {
+ this.openSample.PlayAt(when)
+ }
+ }
+}
+
+class Lamp extends Buzzer {
+ constructor() {
+ super()
+ this.lamp = document.querySelector("#recv")
+ }
+
+ Buzz(tx, when=0) {
+ if (tx) return
+
+ let ms = when - Date.now()
+ setTimeout(e => {
+ recv.classList.add("rx")
+ }, ms)
+ }
+ Silence(tx, when=0) {
+ if (tx) return
+
+ let recv = document.querySelector("#recv")
+ let ms = when - Date.now()
+ setTimeout(e => {
+ recv.classList.remove("rx")
+ }, ms)
+ }
+}
+
+export {Ready, ToneBuzzer, TelegraphBuzzer, Lamp}
diff --git a/static/index.html b/static/index.html
index 79d8ffb..d50b532 100644
--- a/static/index.html
+++ b/static/index.html
@@ -194,6 +194,14 @@
Send CK
(check) to the repeater, and play when it comes back.
+ Reset all Vail preferences to default. +
+