mirror of https://github.com/nealey/vail.git
Many more changes:
* New keyer module, does what you expect * Fixed implementation of Iambic A and B * Smarter way to deal with VBand adapter's control key input * Nicer console error message if you try to transmit while disconnected * A new factory reset UI button * Fixed problems dealing with checkbox persistence
This commit is contained in:
parent
fc537d7341
commit
50f4b41dd9
|
@ -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}
|
|
@ -194,6 +194,14 @@
|
||||||
Send <code>CK</code> (check) to the repeater, and play when it comes back.
|
Send <code>CK</code> (check) to the repeater, and play when it comes back.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex mdl-card__supporting-text">
|
||||||
|
<button id="reset" class="mdl-button mdl-js-button mdl-button--raised mdl-button--colored">
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<p>
|
||||||
|
Reset all Vail preferences to default.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mdl-card__actions">
|
<div class="mdl-card__actions">
|
||||||
<button class="mdl-button mdl-js-button mdl-button--fab mdl-button--mini-fab mdl-button--colored maximize" title="maximize">
|
<button class="mdl-button mdl-js-button mdl-button--fab mdl-button--mini-fab mdl-button--colored maximize" title="maximize">
|
||||||
|
@ -232,7 +240,7 @@
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<label class="mdl-switch mdl-js-switch mdl-js-ripple-effect" for="iambic-mode-b">
|
<label class="mdl-switch mdl-js-switch mdl-js-ripple-effect" for="iambic-mode-b">
|
||||||
<input type="checkbox" id="iambic-mode-b" class="mdl-switch__input" checked>
|
<input type="checkbox" id="iambic-mode-b" class="mdl-switch__input">
|
||||||
<span class="mdl-switch__label">Iambic mode B</span>
|
<span class="mdl-switch__label">Iambic mode B</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="mdl-switch mdl-js-switch mdl-js-ripple-effect" for="iambic-typeahead">
|
<label class="mdl-switch mdl-js-switch mdl-js-ripple-effect" for="iambic-typeahead">
|
||||||
|
|
|
@ -55,34 +55,48 @@ export class Keyboard extends Input{
|
||||||
// Ignore everything if the user is entering text somewhere
|
// Ignore everything if the user is entering text somewhere
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.repeat) {
|
|
||||||
// Ignore key repeats generated by the OS, we do this ourselves
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let down = event.type.endsWith("down")
|
let down = event.type.endsWith("down")
|
||||||
|
|
||||||
if ((event.code == "KeyX") ||
|
if (
|
||||||
(event.code == "Period") ||
|
(event.code == "KeyX")
|
||||||
(event.code == "BracketLeft") ||
|
|| (event.code == "Period")
|
||||||
(event.key == "[")) {
|
|| (event.code == "BracketLeft")
|
||||||
event.preventDefault()
|
|| (event.code == "ControlLeft" && this.iambic) // VBand
|
||||||
this.keyer.Dit(down)
|
|| (event.key == "[")
|
||||||
|
) {
|
||||||
|
// Dit
|
||||||
|
if (this.ditDown != down) {
|
||||||
|
this.keyer.Dit(down)
|
||||||
|
this.ditDown = down
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ((event.code == "KeyZ") ||
|
if (
|
||||||
(event.code == "Slash") ||
|
(event.code == "KeyZ")
|
||||||
(event.code == "BracketRight") ||
|
|| (event.code == "Slash")
|
||||||
(event.code == "ControlRight") || // VBand only: don't display this option to the user
|
|| (event.code == "BracketRight")
|
||||||
(event.key == "]")) {
|
|| (event.code == "ControlRight" && this.iambic) // VBand
|
||||||
event.preventDefault()
|
|| (event.key == "]")
|
||||||
this.keyer.Dah(down)
|
) {
|
||||||
|
if (this.dahDown != down) {
|
||||||
|
this.keyer.Dah(down)
|
||||||
|
this.dahDown = down
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ((event.code == "KeyC") ||
|
if (
|
||||||
(event.code == "Comma") ||
|
(event.code == "KeyC")
|
||||||
(event.key == "Enter") ||
|
|| (event.code == "Comma")
|
||||||
(event.key == "NumpadEnter")) {
|
|| (event.key == "Enter")
|
||||||
event.preventDefault()
|
|| (event.key == "Control" && !this.iambic) // VBand
|
||||||
this.keyer.Straight(down)
|
|| (event.key == "NumpadEnter")
|
||||||
|
) {
|
||||||
|
if (this.straightDown != down) {
|
||||||
|
this.keyer.Straight(down)
|
||||||
|
this.straightDown = down
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((event.code == "ControlLeft")) {
|
if ((event.code == "ControlLeft")) {
|
||||||
|
|
|
@ -9,7 +9,6 @@ const DIT = 1
|
||||||
/** Duration of a dah */
|
/** Duration of a dah */
|
||||||
const DAH = 3
|
const DAH = 3
|
||||||
|
|
||||||
|
|
||||||
const MorseMap = {
|
const MorseMap = {
|
||||||
"\x04": ".-.-.", // End Of Transmission
|
"\x04": ".-.-.", // End Of Transmission
|
||||||
"\x18": "........", // Cancel
|
"\x18": "........", // Cancel
|
||||||
|
@ -74,6 +73,20 @@ if (!window.AudioContext) {
|
||||||
window.AudioContext = window.webkitAudioContext
|
window.AudioContext = window.webkitAudioContext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the inverse of the input.
|
||||||
|
* If you give it dit, it returns dah, and vice-versa.
|
||||||
|
*
|
||||||
|
* @param ditdah What to invert
|
||||||
|
* @returns The inverse of ditdah
|
||||||
|
*/
|
||||||
|
function morseNot(ditdah) {
|
||||||
|
if (ditdah == DIT) {
|
||||||
|
return DAH
|
||||||
|
}
|
||||||
|
return DIT
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A callback to start or stop transmission
|
* A callback to start or stop transmission
|
||||||
*
|
*
|
||||||
|
@ -149,21 +162,20 @@ class Keyer {
|
||||||
|
|
||||||
typematic() {
|
typematic() {
|
||||||
if (this.ditDown && this.dahDown) {
|
if (this.ditDown && this.dahDown) {
|
||||||
if (this.iambicModeB) {
|
this.modeBQueue = this.last
|
||||||
if (this.last == DIT) {
|
this.last = morseNot(this.last)
|
||||||
this.last = DAH
|
|
||||||
} else {
|
|
||||||
this.last = DIT
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.last = this.last // Mode A = keep on truckin'
|
|
||||||
}
|
|
||||||
} else if (this.ditDown) {
|
} else if (this.ditDown) {
|
||||||
|
this.modeBQueue = null
|
||||||
this.last = DIT
|
this.last = DIT
|
||||||
} else if (this.dahDown) {
|
} else if (this.dahDown) {
|
||||||
|
this.modeBQueue = null
|
||||||
this.last = DAH
|
this.last = DAH
|
||||||
|
} else if (this.modeBQueue && this.iambicModeB) {
|
||||||
|
this.last = this.modeBQueue
|
||||||
|
this.modeBQueue = null
|
||||||
} else {
|
} else {
|
||||||
this.last = null
|
this.last = null
|
||||||
|
this.modeBQueue = null
|
||||||
}
|
}
|
||||||
return this.last
|
return this.last
|
||||||
}
|
}
|
||||||
|
@ -199,8 +211,17 @@ class Keyer {
|
||||||
/**
|
/**
|
||||||
* Set Iambic mode B.
|
* Set Iambic mode B.
|
||||||
*
|
*
|
||||||
* If true, holding both keys will alternate between dit and dah.
|
* Near as I can tell, B sends one more tone than was entered, when
|
||||||
* If false, holding both keys sends whatever key was depressed first.
|
* both keys are held down.
|
||||||
|
* This logic happens in the typematic code.
|
||||||
|
*
|
||||||
|
* ▁▁▔▔▔▔▔▔▔▁▁▁▁ Dit key
|
||||||
|
*
|
||||||
|
* ▁▔▔▔▔▔▔▔▔▁▁▁▁ Dah key
|
||||||
|
*
|
||||||
|
* ▁▔▔▔▁▔▁▔▔▔▁▁▁ Mode A output
|
||||||
|
*
|
||||||
|
* ▁▔▔▔▁▔▁▔▔▔▁▔▁ Mode B output
|
||||||
*
|
*
|
||||||
* @param {boolean} value True to set mode to B
|
* @param {boolean} value True to set mode to B
|
||||||
*/
|
*/
|
||||||
|
@ -330,258 +351,4 @@ class Keyer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export {Keyer}
|
||||||
* 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 (null=now)
|
|
||||||
*/
|
|
||||||
Buzz(tx, when = null) {
|
|
||||||
console.log("Buzz", tx, when)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End buzzing at time
|
|
||||||
*
|
|
||||||
* @param {boolean} tx Transmit or receive tone
|
|
||||||
* @param {number} when Time to end, in ms (null=now)
|
|
||||||
*/
|
|
||||||
Silence(tx, when = null) {
|
|
||||||
console.log("Silence", tx, when)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Buzz for a duration at time
|
|
||||||
*
|
|
||||||
* @param {boolean} tx Transmit or receive tone
|
|
||||||
* @param {number} when Time to begin (ms since 1970-01-01Z, null=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.ac = new AudioContext()
|
|
||||||
this.ac.resume()
|
|
||||||
.then(() => {
|
|
||||||
document.querySelector("#muted").classList.add("hidden")
|
|
||||||
})
|
|
||||||
|
|
||||||
this.errorGain = this.create(errorFreq, "square")
|
|
||||||
this.errorFreq = errorFreq
|
|
||||||
}
|
|
||||||
|
|
||||||
create(frequency, type = "sine") {
|
|
||||||
let gain = this.ac.createGain()
|
|
||||||
gain.connect(this.ac.destination)
|
|
||||||
gain.gain.value = 0
|
|
||||||
let osc = this.ac.createOscillator()
|
|
||||||
osc.type = type
|
|
||||||
osc.connect(gain)
|
|
||||||
osc.frequency.value = frequency
|
|
||||||
osc.start()
|
|
||||||
return gain
|
|
||||||
}
|
|
||||||
|
|
||||||
ready() {
|
|
||||||
return this.ac.state == "running"
|
|
||||||
}
|
|
||||||
|
|
||||||
Error() {
|
|
||||||
this.errorGain.gain.setTargetAtTime(0.5, this.ac.currentTime, 0.001)
|
|
||||||
this.errorGain.gain.setTargetAtTime(0, this.ac.currentTime + 0.2, 0.001)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert clock time to AudioContext time
|
|
||||||
*
|
|
||||||
* @param {number} when Clock time in ms
|
|
||||||
* @return {number} AudioContext offset time
|
|
||||||
*/
|
|
||||||
acTime(when) {
|
|
||||||
if (!when) {
|
|
||||||
return this.ac.currentTime
|
|
||||||
}
|
|
||||||
|
|
||||||
let acOffset = Date.now() - this.ac.currentTime * 1000
|
|
||||||
let acTime = (when - acOffset) / 1000
|
|
||||||
return acTime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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.6, highFreq=660, lowFreq=550} = {}) {
|
|
||||||
super()
|
|
||||||
this.txGain = txGain
|
|
||||||
|
|
||||||
this.ac = new AudioContext()
|
|
||||||
this.ramp = 0.005 // Lead-in and lead-out time to avoid popping. This one is in seconds.
|
|
||||||
|
|
||||||
this.lowGain = this.create(lowFreq)
|
|
||||||
this.highGain = this.create(highFreq)
|
|
||||||
//this.noiseGain = this.whiteNoise()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate some noise to prevent the browser from putting us to sleep
|
|
||||||
whiteNoise() {
|
|
||||||
let bufferSize = 17 * this.ac.sampleRate
|
|
||||||
let noiseBuffer = this.ac.createBuffer(1, bufferSize, this.ac.sampleRate)
|
|
||||||
let output = noiseBuffer.getChannelData(0)
|
|
||||||
for (let i = 0; i < bufferSize; i++) {
|
|
||||||
output[i] = Math.random() * 2 - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let whiteNoise = this.ac.createBufferSource();
|
|
||||||
whiteNoise.buffer = noiseBuffer;
|
|
||||||
whiteNoise.loop = true;
|
|
||||||
whiteNoise.start(0);
|
|
||||||
|
|
||||||
let filter = this.ac.createBiquadFilter()
|
|
||||||
filter.type = "lowpass"
|
|
||||||
filter.frequency.value = 100
|
|
||||||
|
|
||||||
let gain = this.ac.createGain()
|
|
||||||
gain.gain.value = 0.01
|
|
||||||
|
|
||||||
whiteNoise.connect(filter)
|
|
||||||
filter.connect(gain)
|
|
||||||
gain.connect(this.ac.destination)
|
|
||||||
|
|
||||||
return gain
|
|
||||||
}
|
|
||||||
|
|
||||||
gain(high) {
|
|
||||||
if (high) {
|
|
||||||
return this.highGain.gain
|
|
||||||
} else {
|
|
||||||
return this.lowGain.gain
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set gain
|
|
||||||
*
|
|
||||||
* @param {number} gain Value (0-1)
|
|
||||||
*/
|
|
||||||
SetGain(gain) {
|
|
||||||
this.txGain = gain
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Begin buzzing at time
|
|
||||||
*
|
|
||||||
* @param {boolean} tx Transmit or receive tone
|
|
||||||
* @param {number} when Time to begin, in ms (null=now)
|
|
||||||
*/
|
|
||||||
Buzz(tx, when = null) {
|
|
||||||
let gain = this.gain(tx)
|
|
||||||
let acWhen = this.acTime(when)
|
|
||||||
this.ac.resume()
|
|
||||||
.then(() => {
|
|
||||||
gain.setTargetAtTime(this.txGain, acWhen, this.ramp)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End buzzing at time
|
|
||||||
*
|
|
||||||
* @param {boolean} tx Transmit or receive tone
|
|
||||||
* @param {number} when Time to end, in ms (null=now)
|
|
||||||
*/
|
|
||||||
Silence(tx, when = null) {
|
|
||||||
let gain = this.gain(tx)
|
|
||||||
let acWhen = this.acTime(when)
|
|
||||||
|
|
||||||
gain.setTargetAtTime(0, acWhen, this.ramp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TelegraphBuzzer extends AudioBuzzer{
|
|
||||||
constructor(gain=0.6) {
|
|
||||||
super()
|
|
||||||
|
|
||||||
this.gain = this.ac.createGain()
|
|
||||||
this.gain.connect(this.ac.destination)
|
|
||||||
this.gain.gain.value = gain
|
|
||||||
|
|
||||||
this.loadMedia("telegraph-a.mp3").then(s => this.closeBuf = s)
|
|
||||||
this.loadMedia("telegraph-b.mp3").then(s => this.openBuf = s)
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadMedia(url) {
|
|
||||||
let resp = await fetch(url)
|
|
||||||
let buf = await resp.arrayBuffer()
|
|
||||||
return await this.ac.decodeAudioData(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
play(buf, when) {
|
|
||||||
let bs = this.ac.createBufferSource()
|
|
||||||
bs.buffer = buf
|
|
||||||
bs.connect(this.gain)
|
|
||||||
bs.start(this.acTime(when))
|
|
||||||
}
|
|
||||||
|
|
||||||
Buzz(tx, when=0) {
|
|
||||||
if (tx) return
|
|
||||||
this.play(this.closeBuf, when)
|
|
||||||
}
|
|
||||||
|
|
||||||
Silence(tx ,when=0) {
|
|
||||||
if (tx) return
|
|
||||||
this.play(this.openBuf, 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 {DIT, DAH, PAUSE, PAUSE_WORD, PAUSE_LETTER}
|
|
||||||
export {Keyer, ToneBuzzer, TelegraphBuzzer, Lamp}
|
|
|
@ -20,7 +20,7 @@ export class Vail {
|
||||||
}
|
}
|
||||||
|
|
||||||
reopen() {
|
reopen() {
|
||||||
console.info("Attempting to reconnect", this.wsUrl)
|
console.info("Attempting to reconnect", this.wsUrl.href)
|
||||||
this.clockOffset = 0
|
this.clockOffset = 0
|
||||||
this.socket = new WebSocket(this.wsUrl)
|
this.socket = new WebSocket(this.wsUrl)
|
||||||
this.socket.addEventListener("message", e => this.wsMessage(e))
|
this.socket.addEventListener("message", e => this.wsMessage(e))
|
||||||
|
@ -107,6 +107,11 @@ export class Vail {
|
||||||
Transmit(time, duration, squelch=true) {
|
Transmit(time, duration, squelch=true) {
|
||||||
let msg = [time - this.clockOffset, duration]
|
let msg = [time - this.clockOffset, duration]
|
||||||
let jmsg = JSON.stringify(msg)
|
let jmsg = JSON.stringify(msg)
|
||||||
|
if (this.socket.readyState != 1) {
|
||||||
|
// If we aren't connected, complain.
|
||||||
|
console.error("Not connected, dropping", jmsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
this.socket.send(jmsg)
|
this.socket.send(jmsg)
|
||||||
if (squelch) {
|
if (squelch) {
|
||||||
this.sent.push(jmsg)
|
this.sent.push(jmsg)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import * as Morse from "./morse.mjs"
|
import * as Keyer from "./keyer.mjs"
|
||||||
|
import * as Buzzer from "./buzzer.mjs"
|
||||||
import * as Inputs from "./inputs.mjs"
|
import * as Inputs from "./inputs.mjs"
|
||||||
import * as Repeaters from "./repeaters.mjs"
|
import * as Repeaters from "./repeaters.mjs"
|
||||||
|
|
||||||
|
@ -34,10 +35,10 @@ class VailClient {
|
||||||
this.debug = localStorage.debug
|
this.debug = localStorage.debug
|
||||||
|
|
||||||
// Make helpers
|
// Make helpers
|
||||||
this.lamp = new Morse.Lamp()
|
this.lamp = new Buzzer.Lamp()
|
||||||
this.buzzer = new Morse.ToneBuzzer()
|
this.buzzer = new Buzzer.ToneBuzzer()
|
||||||
this.keyer = new Morse.Keyer(() => this.beginTx(), () => this.endTx())
|
this.keyer = new Keyer.Keyer(() => this.beginTx(), () => this.endTx())
|
||||||
this.roboKeyer = new Morse.Keyer(
|
this.roboKeyer = new Keyer.Keyer(
|
||||||
() => {
|
() => {
|
||||||
this.buzzer.Buzz()
|
this.buzzer.Buzz()
|
||||||
this.lamp.Buzz()
|
this.lamp.Buzz()
|
||||||
|
@ -63,6 +64,9 @@ class VailClient {
|
||||||
for (let e of document.querySelectorAll("#ck")) {
|
for (let e of document.querySelectorAll("#ck")) {
|
||||||
e.addEventListener("click", e => this.test())
|
e.addEventListener("click", e => this.test())
|
||||||
}
|
}
|
||||||
|
for (let e of document.querySelectorAll("#reset")) {
|
||||||
|
e.addEventListener("click", e => this.reset())
|
||||||
|
}
|
||||||
|
|
||||||
// Set up inputs
|
// Set up inputs
|
||||||
this.inputInit("#iambic-duration", e => {
|
this.inputInit("#iambic-duration", e => {
|
||||||
|
@ -89,6 +93,13 @@ class VailClient {
|
||||||
document.querySelector("#repeater").addEventListener("change", e => this.setRepeater(e.target.value.trim()))
|
document.querySelector("#repeater").addEventListener("change", e => this.setRepeater(e.target.value.trim()))
|
||||||
window.addEventListener("hashchange", () => this.hashchange())
|
window.addEventListener("hashchange", () => this.hashchange())
|
||||||
this.hashchange()
|
this.hashchange()
|
||||||
|
|
||||||
|
// Turn off the "muted" symbol when we can start making noise
|
||||||
|
Buzzer.Ready()
|
||||||
|
.then(() => {
|
||||||
|
console.log("Audio context ready")
|
||||||
|
document.querySelector("#muted").classList.add("hidden")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -98,9 +109,9 @@ class VailClient {
|
||||||
*/
|
*/
|
||||||
setTelegraphBuzzer(enable) {
|
setTelegraphBuzzer(enable) {
|
||||||
if (enable) {
|
if (enable) {
|
||||||
this.buzzer = new Morse.TelegraphBuzzer()
|
this.buzzer = new Buzzer.TelegraphBuzzer()
|
||||||
} else {
|
} else {
|
||||||
this.buzzer = new Morse.ToneBuzzer()
|
this.buzzer = new Buzzer.ToneBuzzer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -211,12 +222,10 @@ class VailClient {
|
||||||
|
|
||||||
element.addEventListener("input", e => {
|
element.addEventListener("input", e => {
|
||||||
let value = element.value
|
let value = element.value
|
||||||
if (element.hasAttribute("checked")) {
|
if (element.type == "checkbox") {
|
||||||
value = element.checked
|
value = element.checked?"on":"off"
|
||||||
localStorage[element.id] = value?"on":"off"
|
|
||||||
} else {
|
|
||||||
localStorage[element.id] = value
|
|
||||||
}
|
}
|
||||||
|
localStorage[element.id] = value
|
||||||
|
|
||||||
if (outputElement) {
|
if (outputElement) {
|
||||||
outputElement.value = value
|
outputElement.value = value
|
||||||
|
@ -350,6 +359,12 @@ class VailClient {
|
||||||
when += duration
|
when += duration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reset to factory defaults */
|
||||||
|
reset() {
|
||||||
|
localStorage.clear()
|
||||||
|
location.reload()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function vailInit() {
|
function vailInit() {
|
||||||
|
|
Loading…
Reference in New Issue