Compare commits

..

No commits in common. "f8b5cf22afc69062f67e6855b0f848e01664113e" and "f2302bff2a371d745c38384cbd3cade3660c96ff" have entirely different histories.

17 changed files with 183 additions and 857 deletions

View File

@ -8,6 +8,6 @@ case "$1" in
docker -H ssh://melville.woozle.org service update --image ghcr.io/nealey/vail:main melville_vail docker -H ssh://melville.woozle.org service update --image ghcr.io/nealey/vail:main melville_vail
;; ;;
"") "")
rsync --delete -va static/ melville.woozle.org:/srv/vail/testing/ rsync -va static/ melville.woozle.org:/srv/vail/testing/
;; ;;
esac esac

View File

@ -79,7 +79,7 @@
<button id="recv">rx</button> <button id="recv">rx</button>
<output class="has-text-info" id="note"></output> <output class="has-text-info" id="note"></output>
<br> <br>
<i class="muted"> <i id="muted">
If you can read this, it means the browser needs you to click somewhere on this page If you can read this, it means the browser needs you to click somewhere on this page
before it will start beeping! before it will start beeping!
</i> </i>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -1,156 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:bx="https://boxy-svg.com"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 500 500"
version="1.1"
id="svg37"
sodipodi:docname="vail-rx.svg"
inkscape:version="1.0.2 (e86c870879, 2021-01-15)">
<metadata
id="metadata41">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1295"
inkscape:window-height="808"
id="namedview39"
showgrid="false"
inkscape:zoom="1.102"
inkscape:cx="107.57784"
inkscape:cy="249.56755"
inkscape:window-x="19"
inkscape:window-y="13"
inkscape:window-maximized="0"
inkscape:current-layer="g11" />
<defs
id="defs5">
<mask
id="mask-0">
<rect
x="234.28"
y="129.248"
width="247.019"
height="167.227"
style="fill: rgb(255, 255, 255);"
id="rect2" />
</mask>
</defs>
<g
style=""
transform="matrix(0.95245, 0, 0, 1.139872, 6.889973, -7.454849)"
id="g11">
<path
d="M 327.185 337.24 L 360.06 463.215 L 214.596 463.215 L 327.185 337.24 Z"
style="fill:#ffa500;fill-opacity:1"
transform="matrix(0.859836, -0.51057, 0.51057, 0.859836, -104.141418, 118.932152)"
bx:shape="triangle 214.596 337.24 145.464 125.975 0.774 0 1@97556a29"
id="path7" />
<rect
x="24.773"
y="44.071"
width="460.948"
height="294.539"
style="fill:#ffa500;fill-opacity:1"
rx="66.036"
ry="66.036"
id="rect9" />
</g>
<path
style="fill: rgb(216, 216, 216); stroke: rgb(0, 0, 0);"
d="M 296.4 154.545"
id="path13" />
<g
transform="matrix(1.584063, 0, 0, 1.70527, -312.019287, -153.773422)"
style="mask: url(#mask-0);"
mask="url(#mask-0)"
id="g35">
<rect
x="43.405"
y="231.448"
width="284.629"
height="38.553"
style="fill: rgb(255, 255, 255);"
rx="7.898"
ry="7.898"
id="rect15" />
<path
d="M 55.235 167.548 L 306.103 167.548 L 308.543 167.548 L 310.582 168.864 L 360.729 201.228 L 448.168 201.228 L 448.168 217.228 L 434.05 217.228 L 358.289 217.228 L 355.848 217.228 L 353.809 215.912 L 303.663 183.548 L 55.235 183.548 L 55.235 167.548 Z"
style="fill: rgb(255, 255, 255);"
id="path17" />
<rect
x="412.367"
y="179.765"
width="32.602"
height="27.855"
style="fill: rgb(255, 255, 255);"
id="rect19" />
<path
d="M 383.813 153.485 C 388.547 139.021 466.546 139.043 472.225 153.485 C 477.511 166.926 445.092 184.036 445.092 184.036 L 412.726 184.036 C 412.726 184.036 379.124 167.804 383.813 153.485 Z"
style="stroke-linejoin: round; fill: rgb(255, 255, 255);"
id="path21" />
<path
d="M 257.196 232.936 L 280.351 232.936 L 273.245 196.76 L 264.768 196.76 L 257.196 232.936 Z"
style="fill: rgb(255, 255, 255);"
id="path23" />
<rect
x="119.534"
y="151.839"
width="36.544"
height="98.388"
style="fill: rgb(255, 255, 255);"
rx="10.068"
ry="10.068"
id="rect25" />
<rect
x="265.233"
y="170.764"
width="7.081"
height="19.702"
style="fill: rgb(255, 255, 255);"
id="rect27" />
<rect
y="201.262"
width="16.756"
height="33.909"
style="fill: rgb(255, 255, 255);"
x="61.162"
id="rect29" />
<rect
x="66"
y="163.211"
width="7.081"
height="31.292"
style="fill: rgb(255, 255, 255);"
id="rect31" />
<rect
y="156.105"
width="16.756"
height="8.523"
style="fill: rgb(255, 255, 255);"
x="61.162"
id="rect33" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -19,8 +19,8 @@
<!-- Vail stuff --> <!-- Vail stuff -->
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<link rel="icon" href="assets/vail.png" data-rx="assets/vail-rx.png" sizes="256x256" type="image/png"> <link rel="icon" href="assets/vail.png" sizes="256x256" type="image/png">
<link rel="icon" href="assets/vail.svg" data-rx="assets/vail-rx.svg" sizes="any" type="image/svg+xml"> <link rel="icon" href="assets/vail.svg" sizes="any" type="image/svg+xml">
<script type="module" src="scripts/vail.mjs"></script> <script type="module" src="scripts/vail.mjs"></script>
<script type="module" src="scripts/ui.mjs"></script> <script type="module" src="scripts/ui.mjs"></script>
<link rel="stylesheet" href="vail.css"> <link rel="stylesheet" href="vail.css">
@ -57,7 +57,7 @@
<!-- This appears as a little light that turns on when someone's sending --> <!-- This appears as a little light that turns on when someone's sending -->
<span class="tag recv-lamp"> <span class="tag recv-lamp">
<output class="has-text-info" id="note"></output> <output class="has-text-info" id="note"></output>
<i class="mdi mdi-volume-off muted"></i> <i class="mdi mdi-volume-off" id="muted"></i>
</span> </span>
</div> </div>
</div> </div>
@ -213,8 +213,8 @@
<div class="field is-horizontal"> <div class="field is-horizontal">
<div class="field-label"> <div class="field-label">
<label class="label"> <label class="label">
<span data-i18n="label.rx-delay">rx delay</span>:
<output for="rx-delay"></output>s <output for="rx-delay"></output>s
<span data-i18n="label.rx-delay">rx delay</span>
</label> </label>
</div> </div>
<div class="field-body"> <div class="field-body">
@ -246,91 +246,6 @@
</div> </div>
</div> </div>
<div class="field is-horizontal">
<div class="field-label">
<label class="label">
<span data-i18n="label.gain">volume</span>:
<output for="masterGain"></output>%
<i class="mdi mdi-volume-off muted"></i>
</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input
id="masterGain"
type="range"
min="0"
max="100"
value="100"
step="1">
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label">
<label class="label">
<span data-i18n="label.tx-tone">tx tone</span>:
<output for="tx-tone" data-transform="note"></output>
<output for="tx-tone" data-transform="freq"></output>Hz
</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input
id="tx-tone"
type="range"
min="0"
max="127"
value="72"
step="1"
list="tones">
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label">
<label class="label">
<span data-i18n="label.rx-tone">rx tone</span>:
<output for="rx-tone" data-transform="note"></output>
<output for="rx-tone" data-transform="freq"></output>Hz
</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input
id="rx-tone"
type="range"
min="0"
max="127"
value="69"
step="1"
list="tones">
</div>
</div>
</div>
</div>
<datalist id="tones">
<option value="0" label="C-1"></option>
<option value="12" label="C0"></option>
<option value="24" label="C1"></option>
<option value="36" label="C2"></option>
<option value="48" label="C3"></option>
<option value="60" label="C4"></option>
<option value="72" label="C5"></option>
<option value="84" label="C6"></option>
<option value="96" label="C7"></option>
<option value="108" label="C8"></option>
<option value="120" label="C9"></option>
</datalist>
<p> <p>
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" id="telegraph-buzzer"> <input type="checkbox" id="telegraph-buzzer">
@ -338,29 +253,6 @@
</label> </label>
</p> </p>
<div class="field is-horizontal">
<div class="field-label">
<label class="label">
<span data-i18n="label.gain">noise</span>:
<output for="noiseGain"></output>%
<i class="mdi mdi-volume-off muted"></i>
</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input
id="noiseGain"
type="range"
min="0"
max="100"
value="0"
step="1">
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,55 +0,0 @@
/**
* @file Provides some base audio tools.
*/
import * as time from "./time.mjs"
/**
* Compute the special "Audio Context" time
*
* This is is a duration from now, in seconds.
*
* @param {AudioContext} context
* @param {Date} when Date to compute
* @returns audiocontext time
*/
function AudioContextTime(context, when) {
if (!when) return 0
let acOffset = Date.now() - (context.currentTime * time.Second)
return Math.max(when - acOffset, 0) / time.Second
}
class AudioSource {
/**
* A generic audio source
*
* @param {AudioContext} context
*/
constructor(context) {
this.context = context
this.masterGain = new GainNode(this.context)
}
/**
* Connect to an audio node
*
* @param {AudioNode} destinationNode
*/
connect(destinationNode) {
this.masterGain.connect(destinationNode)
}
/**
* Set the master gain for this audio source.
*
* @param {Number} value New gain value
*/
SetGain(value) {
this.masterGain.gain.value = value
}
}
export {
AudioContextTime,
AudioSource,
}

View File

@ -1,4 +1,6 @@
import * as time from "./time.mjs" /** @typedef {Number} Duration */
const Millisecond = 1
const Second = 1000 * Millisecond
/** /**
* A chart of historical values. * A chart of historical values.
@ -12,7 +14,7 @@ class HistoryChart {
* @param {string} style style to draw in; falls back to the `data-style` attribute * @param {string} style style to draw in; falls back to the `data-style` attribute
* @param {Duration} duration Time to display history for * @param {Duration} duration Time to display history for
*/ */
constructor(canvas, style=null, duration=20*time.Second) { constructor(canvas, style=null, duration=20*Second) {
this.canvas = canvas this.canvas = canvas
this.ctx = canvas.getContext("2d") this.ctx = canvas.getContext("2d")
this.duration = duration this.duration = duration
@ -20,7 +22,7 @@ class HistoryChart {
this.data = [] this.data = []
// One canvas pixel = 20ms // One canvas pixel = 20ms
canvas.width = duration / (20 * time.Millisecond) canvas.width = duration / (20 * Millisecond)
// Set origin to lower-left corner // Set origin to lower-left corner
this.ctx.scale(1, -1) this.ctx.scale(1, -1)
@ -111,7 +113,7 @@ class HistoryChart {
* @param duration duration of chart window * @param duration duration of chart window
* @returns new chart, or null if it couldn't find a canvas * @returns new chart, or null if it couldn't find a canvas
*/ */
function FromSelector(selector, style, duration=20*time.Second) { function FromSelector(selector, style, duration=20*Second) {
let canvas = document.querySelector(selector) let canvas = document.querySelector(selector)
if (canvas) { if (canvas) {
return new HistoryChart(canvas, style, duration) return new HistoryChart(canvas, style, duration)

View File

@ -6,13 +6,15 @@
* *
* @typedef {number} Duration * @typedef {number} Duration
*/ */
/** @type {Duration} */
const Millisecond = 1
/** @type {Duration} */
const Second = 1000 * Millisecond
/** @type {Duration} */
const Minute = 60 * Second
/** @type {Duration} */
const Hour = 60 * Minute
export {Millisecond, Second, Minute, Hour} /** @type {Duration} */
export const Millisecond = 1
/** @type {Duration} */
export const Second = 1000 * Millisecond
/** @type {Duration} */
export const Minute = 60 * Second
/** @type {Duration} */
export const Hour = 60 * Minute

View File

@ -1,50 +0,0 @@
import * as time from "./time.mjs"
const defaultIcon = "default"
class Icon {
/**
* @param {Number} timeoutDuration Duration of timeout
*/
constructor(timeoutDuration = 2*time.Second) {
this.timeoutDuration = timeoutDuration
}
/**
* Set the icon type
*
* @param {String} iconType Icon to set to
*/
Set(iconType=defaultIcon) {
if (iconType != defaultIcon) {
clearTimeout(this.cleanupTimer)
this.cleanupTimer = setTimeout(() => this.Set(), this.timeoutDuration)
}
for (let e of document.querySelectorAll("link[rel=icon]")) {
if (! e.dataset[defaultIcon]) {
e.dataset[defaultIcon] = e.href
}
let url = e.dataset[iconType]
if (url) {
e.href = url
} else {
console.warn(`No data-${iconType} attribute`, e)
}
}
}
/**
* Set icon at the provided time.
*
* @param {String} iconType Icon to set to
* @param {Number} when Time to set the value
*/
SetAt(iconType, when=null) {
setTimeout(() => this.Set(iconType), when - Date.now())
}
}
export {
Icon,
}

View File

@ -137,9 +137,6 @@ export class MIDI extends Input{
this.midiStateChange() this.midiStateChange()
} }
// If you're looking for the thing that sets the tx tone,
// that's in outputs.mjs:SetMIDINote
sendState() { sendState() {
for (let output of this.midiAccess.outputs.values()) { for (let output of this.midiAccess.outputs.values()) {
// Turn off keyboard mode // Turn off keyboard mode

View File

@ -7,7 +7,6 @@
*/ */
import * as RoboKeyer from "./robokeyer.mjs" import * as RoboKeyer from "./robokeyer.mjs"
import * as time from "./time.mjs"
/** Silent period between dits and dash */ /** Silent period between dits and dash */
const PAUSE = -1 const PAUSE = -1
@ -16,6 +15,24 @@ const DIT = 1
/** Length of a dah */ /** Length of a dah */
const DAH = 3 const DAH = 3
/**
* A time duration.
*
* JavaScript uses milliseconds in most (but not all) places.
* I've found it helpful to be able to multiply by a unit, so it's clear what's going on.
*
* @typedef {number} Duration
*/
/** @type {Duration} */
const Millisecond = 1
/** @type {Duration} */
const Second = 1000 * Millisecond
/** @type {Duration} */
const Minute = 60 * Second
/** @type {Duration} */
const Hour = 60 * Minute
/** /**
* Queue Set: A Set you can shift and pop. * Queue Set: A Set you can shift and pop.
* *
@ -168,7 +185,7 @@ class BugKeyer extends StraightKeyer {
Reset() { Reset() {
super.Reset() super.Reset()
this.SetDitDuration(100 * time.Millisecond) this.SetDitDuration(100 * Millisecond)
if (this.pulseTimer) { if (this.pulseTimer) {
clearInterval(this.pulseTimer) clearInterval(this.pulseTimer)
this.pulseTimer = null this.pulseTimer = null

View File

@ -1,58 +0,0 @@
/**
* @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, precision=0) {
let freq = Cn1_freq * Math.pow(semitone, note)
return freq.toFixed(precision)
}
/**
* 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,
}

View File

@ -1,126 +0,0 @@
import {AudioSource, AudioContextTime} from "./audio.mjs"
/**
* Create a white noise generator with a biquad filter
*
* @param {AudioContext} context Audio context
* @returns {BiquadFilterNode} Noise filter
*/
function WhiteNoise(context) {
let bufferSize = 17 * context.sampleRate
let noiseBuffer = new AudioBuffer({
sampleRate: context.sampleRate,
length: bufferSize,
})
let output = noiseBuffer.getChannelData(0)
for (let i = 0; i < bufferSize; i++) {
output[i] = Math.random() * 2 - 1
}
let whiteNoise = context.createBufferSource()
whiteNoise.buffer = noiseBuffer
whiteNoise.loop = true
whiteNoise.start(0)
let noiseFilter = new BiquadFilterNode(context, {type: "bandpass"})
whiteNoise.connect(noiseFilter)
return noiseFilter
}
class Noise extends AudioSource {
/**
*
* @param {AudioContext} context
*/
constructor(context, noises=2) {
super(context)
this.whiteNoise = []
for (let i = 0; i < noises; i++) {
let wn = {
modulator: new OscillatorNode(context),
modulatorGain: new GainNode(context),
filter: WhiteNoise(context),
filterGain: new GainNode(context),
filterGainModulator: new OscillatorNode(context),
filterGainModulatorGain: new GainNode(context),
}
wn.modulator.frequency.value = 0
wn.modulatorGain.gain.value = 0
wn.filter.frequency.value = 800
wn.filterGain.gain.value = 0.8 / noises
wn.filterGainModulator.frequency.value = 1
wn.filterGainModulatorGain.gain.value = 1
wn.modulator.connect(wn.modulatorGain)
wn.modulatorGain.connect(wn.filter.frequency)
wn.filter.connect(this.masterGain)
wn.modulator.start()
this.whiteNoise.push(wn)
}
this.SetNoiseParams(0, 0.07, 70, 400, 0.0)
this.SetNoiseParams(1, 0.03, 200, 1600, 0.4)
this.masterGain.gain.value = 0.5
}
/**
* Set modulator frequency
*
* You probably want this to be under 1Hz, for a subtle sweeping effect
*
* @param {Number} n Which noise generator
* @param {Number} frequency Frequency (Hz)
*/
SetNoiseModulator(n, frequency) {
this.whiteNoise[n].modulator.frequency.value = frequency
}
/**
* Set modulator depth
*
* The output of the modulator [-1,1] is multiplied this and added to the
* base frequency of the filter.
*
* @param {Number} n Which noise generator
* @param {Number} depth Depth of modulation
*/
SetNoiseDepth(n, depth) {
this.whiteNoise[n].modulatorGain.gain.value = depth
}
/**
* Set noise filter base frequency
*
* @param {Number} n Which noise generator
* @param {Number} frequency Frequency (Hz)
*/
SetNoiseFrequency(n, frequency) {
this.whiteNoise[n].filter.frequency.value = frequency
}
/**
* Set gain of a noise generator
*
* @param {Number} n Which noise generator
* @param {Number} gain Gain level (typically [0-1])
*/
SetNoiseGain(n, gain) {
this.whiteNoise[n].filterGain.gain.value = gain
}
SetNoiseParams(n, modulatorFrequency, depth, baseFrequency, gain) {
this.SetNoiseModulator(n, modulatorFrequency)
this.SetNoiseDepth(n, depth)
this.SetNoiseFrequency(n, baseFrequency)
this.SetNoiseGain(n, gain)
}
}
export {
Noise,
}

View File

@ -1,6 +1,3 @@
import {AudioSource, AudioContextTime} from "./audio.mjs"
import * as time from "./time.mjs"
const HIGH_FREQ = 555 const HIGH_FREQ = 555
const LOW_FREQ = 444 const LOW_FREQ = 444
@ -20,94 +17,77 @@ const LOW_FREQ = 444
* @typedef {number} Date * @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 /** The amount of time it should take an oscillator to ramp to and from zero gain
* *
* @constant {Duration} * @constant {Duration}
*/ */
const OscillatorRampDuration = 5*time.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 extends AudioSource { class Oscillator {
/** /**
* Create a new oscillator, and encase it in a Gain for control. * 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} frequency Oscillator frequency (Hz)
* @param {number} maxGain Maximum gain (volume) of this oscillator (0.0 - 1.0) * @param {number} gain Gain (volume) of this oscillator (0.0 - 1.0)
* @param {string} type Oscillator type * @param {string} type Oscillator type
* @returns {GainNode} A new GainNode object this oscillator is connected to
*/ */
constructor(context, frequency, maxGain = 0.5, type = "sine") { constructor(frequency, gain = 0.5, type = "sine") {
super(context) this.targetGain = gain
this.maxGain = maxGain
// Start quiet this.gainNode = BuzzerAudioContext.createGain()
this.masterGain.gain.value = 0 this.gainNode.connect(BuzzerAudioContext.destination)
this.gainNode.gain.value = 0
this.osc = new OscillatorNode(this.context) this.osc = BuzzerAudioContext.createOscillator()
this.osc.type = type this.osc.type = type
this.osc.connect(this.masterGain) this.osc.connect(this.gainNode)
this.setFrequency(frequency) this.osc.frequency.value = frequency
this.osc.start() 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 {number} target Target gain
* @param {Date} when Time this should start * @param {Date} when Time this should start
* @param {Duration} timeConstant Duration of ramp to target gain * @param {Duration} timeConstant Duration of ramp to target gain
*/ */
async setTargetAtTime(target, when, timeConstant=OscillatorRampDuration) { async setTargetAtTime(target, when, timeConstant=OscillatorRampDuration) {
await this.context.resume() await BuzzerAudioContext.resume()
this.masterGain.gain.setTargetAtTime( this.gainNode.gain.setTargetAtTime(
target, target,
AudioContextTime(this.context, when), BuzzerAudioContextTime(when),
timeConstant/time.Second, 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) { SoundAt(when=0, timeConstant=OscillatorRampDuration) {
return this.setTargetAtTime(this.maxGain, when, timeConstant) return this.setTargetAtTime(this.targetGain, 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) { HushAt(when=0, timeConstant=OscillatorRampDuration) {
return this.setTargetAtTime(0, when, timeConstant) return this.setTargetAtTime(0, when, timeConstant)
} }
@ -116,20 +96,24 @@ class Oscillator extends AudioSource {
/** /**
* A digital sample, loaded from a URL. * A digital sample, loaded from a URL.
*/ */
class Sample extends AudioSource { class Sample {
/** /**
* @param {AudioContext} context *
* @param {string} url URL to resource * @param {string} url URL to resource
* @param {number} gain Gain (0.0 - 1.0)
*/ */
constructor(context, url) { constructor(url, gain=0.5) {
super(context)
this.resume = this.load(url) this.resume = this.load(url)
this.gainNode = BuzzerAudioContext.createGain()
this.gainNode.connect(BuzzerAudioContext.destination)
this.gainNode.gain.value = gain
} }
async load(url) { async load(url) {
let resp = await fetch(url) let resp = await fetch(url)
let buf = await resp.arrayBuffer() let buf = await resp.arrayBuffer()
this.data = await this.context.decodeAudioData(buf) this.data = await BuzzerAudioContext.decodeAudioData(buf)
} }
/** /**
@ -138,24 +122,22 @@ class Sample extends AudioSource {
* @param {Date} when When to begin playback * @param {Date} when When to begin playback
*/ */
async PlayAt(when) { async PlayAt(when) {
await this.context.resume() await BuzzerAudioContext.resume()
await this.resume await this.resume
let bs = new AudioBufferSourceNode(this.context) let bs = BuzzerAudioContext.createBufferSource()
bs.buffer = this.data bs.buffer = this.data
bs.connect(this.masterGain) bs.connect(this.gainNode)
bs.start(AudioContextTime(this.context, when)) bs.start(BuzzerAudioContextTime(when))
} }
} }
/** /**
* A (mostly) virtual class defining a buzzer. * A (mostly) virtual class defining a buzzer.
*/ */
class Buzzer extends AudioSource { class Buzzer {
/** constructor() {
* @param {AudioContext} context
*/
constructor(context) {
super(context)
this.connected = true this.connected = true
} }
@ -209,66 +191,41 @@ class Buzzer extends AudioSource {
} }
class AudioBuzzer extends Buzzer { class AudioBuzzer extends Buzzer {
/** constructor(errorFreq=30) {
* A buzzer that make noise super()
*
* @param {AudioContext} context
* @param {Number} errorFreq Error tone frequency (hz)
*/
constructor(context, errorFreq=30) {
super(context)
this.errorTone = new Oscillator(this.context, errorFreq, 0.1, "square") this.errorGain = new Oscillator(errorFreq, 0.1, "square")
this.errorTone.connect(this.masterGain)
} }
Error() { Error() {
let now = Date.now() let now = Date.now()
this.errorTone.SoundAt(now) this.errorGain.SoundAt(now)
this.errorTone.HushAt(now + 200*time.Millisecond) this.errorGain.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 { class ToneBuzzer extends AudioBuzzer {
constructor(context, {txGain=0.5, highFreq=HIGH_FREQ, lowFreq=LOW_FREQ} = {}) { // Buzzers keep two oscillators: one high and one low.
super(context) // 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.
this.rxOsc = new Oscillator(this.context, lowFreq, txGain) constructor({txGain=0.5, highFreq=HIGH_FREQ, lowFreq=LOW_FREQ} = {}) {
this.txOsc = new Oscillator(this.context, highFreq, txGain) super()
this.rxOsc.connect(this.masterGain) this.rxOsc = new Oscillator(lowFreq, txGain)
this.txOsc.connect(this.masterGain) this.txOsc = new Oscillator(highFreq, txGain)
// Keep the speaker going always. This keeps the browser from "swapping out" our audio context. // Keep the speaker going always. This keeps the browser from "swapping out" our audio context.
if (false) { if (false) {
this.bgOsc = new Oscillator(this.context, 1, 0.001) this.bgOsc = new Oscillator(1, 0.001)
this.bgOsc.SoundAt() 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 * Begin buzzing at time
* *
@ -293,20 +250,17 @@ class ToneBuzzer extends AudioBuzzer {
} }
class TelegraphBuzzer 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.closeSample = new Sample(this.context, "../assets/telegraph-a.mp3") this.gainNode = BuzzerAudioContext.createGain()
this.openSample = new Sample(this.context, "../assets/telegraph-b.mp3") this.gainNode.connect(BuzzerAudioContext.destination)
this.gainNode.gain.value = gain
this.hum.connect(this.masterGain) this.hum = new Oscillator(140, 0.005, "sawtooth")
this.closeSample.connect(this.masterGain)
this.openSample.connect(this.masterGain) this.closeSample = new Sample("../assets/telegraph-a.mp3")
this.openSample = new Sample("../assets/telegraph-b.mp3")
} }
async Buzz(tx, when=0) { async Buzz(tx, when=0) {
@ -327,12 +281,8 @@ class TelegraphBuzzer extends AudioBuzzer{
} }
class LampBuzzer extends Buzzer { class LampBuzzer extends Buzzer {
/** constructor() {
* super()
* @param {AudioContext} context
*/
constructor(context) {
super(context)
this.elements = document.querySelectorAll(".recv-lamp") this.elements = document.querySelectorAll(".recv-lamp")
} }
@ -375,13 +325,9 @@ class LampBuzzer extends Buzzer {
} }
class MIDIBuzzer extends Buzzer { class MIDIBuzzer extends Buzzer {
/** constructor() {
* super()
* @param {AudioContext} context this.SetNote(69) // A4; 440Hz
*/
constructor(context) {
super(context)
this.SetMIDINote(69) // A4; 440Hz
this.midiAccess = {outputs: []} // stub while we wait for async stuff this.midiAccess = {outputs: []} // stub while we wait for async stuff
if (navigator.requestMIDIAccess) { if (navigator.requestMIDIAccess) {
@ -423,6 +369,7 @@ class MIDIBuzzer extends Buzzer {
if (tx) { if (tx) {
return return
} }
this.sendAt(when, [0x90, this.note, 0x7f]) this.sendAt(when, [0x90, this.note, 0x7f])
} }
@ -434,38 +381,31 @@ class MIDIBuzzer extends Buzzer {
this.sendAt(when, [0x80, this.note, 0x7f]) this.sendAt(when, [0x80, this.note, 0x7f])
} }
/** /*
* Set MIDI note for tx/rx tone * Set note to transmit
* */
* @param {Boolean} tx True to set transmit note SetNote(tx, note) {
* @param {Number} note MIDI note to send
*/
SetMIDINote(tx, note) {
if (tx) { if (tx) {
this.sendAt(0, [0xB0, 0x02, note]) return
} else {
this.note = note
} }
this.note = note
} }
} }
class Collection extends AudioSource { /**
/** * Block until the audio system is able to start making noise.
* */
* @param {AudioContext} context Audio Context async function AudioReady() {
*/ await BuzzerAudioContext.resume()
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) class Collection {
this.telegraph.connect(this.masterGain) constructor() {
this.lamp.connect(this.masterGain) this.tone = new ToneBuzzer()
this.midi.connect(this.masterGain) this.telegraph = new TelegraphBuzzer()
this.lamp = new LampBuzzer()
this.midi = new MIDIBuzzer()
this.collection = new Set([this.tone, this.lamp, this.midi])
} }
/** /**
@ -474,7 +414,6 @@ class Collection extends AudioSource {
* @param {string} audioType "telegraph" for telegraph mode, otherwise tone mode * @param {string} audioType "telegraph" for telegraph mode, otherwise tone mode
*/ */
SetAudioType(audioType) { SetAudioType(audioType) {
this.Panic()
this.collection.delete(this.telegraph) this.collection.delete(this.telegraph)
this.collection.delete(this.tone) this.collection.delete(this.tone)
if (audioType == "telegraph") { if (audioType == "telegraph") {
@ -496,7 +435,7 @@ class Collection extends AudioSource {
} }
/** /**
* Silence all outputs in a single direction. * Silence all outputs.
* *
* @param tx True if transmitting * @param tx True if transmitting
*/ */
@ -506,27 +445,6 @@ class Collection extends AudioSource {
} }
} }
/**
* 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 * Buzz for a certain duration at a certain time
* *
@ -554,4 +472,4 @@ class Collection extends AudioSource {
} }
} }
export {Collection} export {AudioReady, Collection}

View File

@ -1,5 +1,8 @@
import {GetFortune} from "./fortunes.mjs" import {GetFortune} from "./fortunes.mjs"
import * as time from "./time.mjs"
const Millisecond = 1
const Second = 1000 * Millisecond
const Minute = 60 * Second
/** /**
* Compare two messages * Compare two messages
@ -58,7 +61,7 @@ export class Vail {
msg => { msg => {
this.rx(0, 0, {connected: false, notice: `Repeater disconnected: ${msg.reason}`}) this.rx(0, 0, {connected: false, notice: `Repeater disconnected: ${msg.reason}`})
console.error("Repeater connection dropped:", msg.reason) console.error("Repeater connection dropped:", msg.reason)
setTimeout(() => this.reopen(), 2*time.Second) setTimeout(() => this.reopen(), 2*Second)
} }
) )
} }
@ -154,7 +157,7 @@ export class Vail {
} }
export class Null { export class Null {
constructor(rx, interval=3*time.Second) { constructor(rx, interval=3*Second) {
this.rx = rx this.rx = rx
this.init() this.init()
} }
@ -216,7 +219,7 @@ export class Fortune extends Null {
if (this.timeout) { if (this.timeout) {
clearTimeout(this.timeout) clearTimeout(this.timeout)
} }
this.timeout = setTimeout(() => this.pulse(), 3 * time.Second) this.timeout = setTimeout(() => this.pulse(), 3 * Second)
} }
Close() { Close() {

View File

@ -4,24 +4,18 @@ import * as Inputs from "./inputs.mjs"
import * as Repeaters from "./repeaters.mjs" import * as Repeaters from "./repeaters.mjs"
import * as Chart from "./chart.mjs" import * as Chart from "./chart.mjs"
import * as I18n from "./i18n.mjs" import * as I18n from "./i18n.mjs"
import * as time from "./time.mjs"
import * as Music from "./music.mjs"
import * as Icon from "./icon.mjs"
import * as Noise from "./noise.mjs"
const DefaultRepeater = "General" const DefaultRepeater = "General"
const Millisecond = 1
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 Second = 1000 * Millisecond
const globalAudioContext = new AudioContext({ const Minute = 60 * Second
latencyHint: "interactive",
})
/** /**
* Pop up a message, using an notification. * Pop up a message, using an notification.
* *
* @param {string} msg Message to display * @param {string} msg Message to display
*/ */
function toast(msg, timeout=4*time.Second) { function toast(msg, timeout=4*Second) {
console.info(msg) console.info(msg)
let errors = document.querySelector("#errors") let errors = document.querySelector("#errors")
@ -41,19 +35,11 @@ class VailClient {
this.lagTimes = [0] this.lagTimes = [0]
this.rxDurations = [0] this.rxDurations = [0]
this.clockOffset = null // How badly our clock is off of the server's this.clockOffset = null // How badly our clock is off of the server's
this.rxDelay = 0 * time.Millisecond // Time to add to incoming timestamps this.rxDelay = 0 * Millisecond // Time to add to incoming timestamps
this.beginTxTime = null // Time when we began transmitting this.beginTxTime = null // Time when we began transmitting
// Outputs // Outputs
this.outputs = new Outputs.Collection(globalAudioContext) this.outputs = new Outputs.Collection()
this.outputs.connect(globalAudioContext.destination)
// Noise
this.noise = new Noise.Noise(globalAudioContext)
this.noise.connect(globalAudioContext.destination)
// App icon
this.icon = new Icon.Icon()
// Keyers // Keyers
this.straightKeyer = new Keyers.Keyers.straight(this) this.straightKeyer = new Keyers.Keyers.straight(this)
@ -64,13 +50,6 @@ class VailClient {
// Send this as the keyer so we can intercept dit and dah events for charts // Send this as the keyer so we can intercept dit and dah events for charts
this.inputs = new Inputs.Collection(this) 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 // Maximize button
for (let e of document.querySelectorAll("button.maximize")) { for (let e of document.querySelectorAll("button.maximize")) {
e.addEventListener("click", e => this.maximize(e)) e.addEventListener("click", e => this.maximize(e))
@ -83,7 +62,7 @@ class VailClient {
this.inputInit("#keyer-mode", e => this.setKeyer(e.target.value)) this.inputInit("#keyer-mode", e => this.setKeyer(e.target.value))
this.inputInit("#keyer-rate", e => { this.inputInit("#keyer-rate", e => {
let rate = e.target.value let rate = e.target.value
this.ditDuration = Math.round(time.Minute / rate / 50) this.ditDuration = Math.round(Minute / rate / 50)
for (let e of document.querySelectorAll("[data-fill='keyer-ms']")) { for (let e of document.querySelectorAll("[data-fill='keyer-ms']")) {
e.textContent = this.ditDuration e.textContent = this.ditDuration
} }
@ -92,31 +71,8 @@ class VailClient {
this.inputs.SetDitDuration(this.ditDuration) this.inputs.SetDitDuration(this.ditDuration)
}) })
this.inputInit("#rx-delay", e => { this.inputInit("#rx-delay", e => {
this.rxDelay = e.target.value * time.Second this.rxDelay = e.target.value * Second
}) })
this.inputInit("#masterGain", e => {
this.outputs.SetGain(e.target.value / 100)
})
this.inputInit("#noiseGain", e => {
this.noise.SetGain(e.target.value / 100)
})
let toneTransform = {
note: Music.MIDINoteName,
freq: Music.MIDINoteFrequency,
}
this.inputInit(
"#rx-tone",
e => {
this.noise.SetNoiseFrequency(1, Music.MIDINoteFrequency(e.target.value))
this.outputs.SetMIDINote(false, e.target.value)
},
toneTransform,
)
this.inputInit(
"#tx-tone",
e => this.outputs.SetMIDINote(true, e.target.value),
toneTransform,
)
this.inputInit("#telegraph-buzzer", e => { this.inputInit("#telegraph-buzzer", e => {
this.setTelegraphBuzzer(e.target.checked) this.setTelegraphBuzzer(e.target.checked)
}) })
@ -130,11 +86,10 @@ class VailClient {
this.setTimingCharts(true) this.setTimingCharts(true)
// Turn off the "muted" symbol when we can start making noise // Turn off the "muted" symbol when we can start making noise
globalAudioContext.resume() Outputs.AudioReady()
.then(() => { .then(() => {
for (let e of document.querySelectorAll(".muted")) { console.log("Audio context ready")
e.classList.add("is-hidden") document.querySelector("#muted").classList.add("is-hidden")
}
}) })
} }
@ -182,8 +137,6 @@ class VailClient {
Buzz() { Buzz() {
this.outputs.Buzz(false) this.outputs.Buzz(false)
this.icon.Set("rx")
if (this.rxChart) this.rxChart.Set(1) if (this.rxChart) this.rxChart.Set(1)
} }
@ -195,13 +148,7 @@ class VailClient {
BuzzDuration(tx, when, duration) { BuzzDuration(tx, when, duration) {
this.outputs.BuzzDuration(tx, when, duration) this.outputs.BuzzDuration(tx, when, duration)
let chart let chart = tx?this.txChart:this.rxChart
if (tx) {
chart = this.txChart
} else {
chart = this.rxChart
this.icon.SetAt("rx", when)
}
if (chart) { if (chart) {
chart.SetAt(1, when) chart.SetAt(1, when)
chart.SetAt(0, when+duration) chart.SetAt(0, when+duration)
@ -320,7 +267,6 @@ class VailClient {
return return
} }
this.Silence()
if (this.repeater) { if (this.repeater) {
this.repeater.Close() this.repeater.Close()
} }
@ -354,9 +300,8 @@ class VailClient {
* *
* @param {string} selector CSS path to the element * @param {string} selector CSS path to the element
* @param {function} callback Callback to call with any new value that is set * @param {function} callback Callback to call with any new value that is set
* @param {Object.<string, function>} transform Transform functions
*/ */
inputInit(selector, callback, transform={}) { inputInit(selector, callback) {
let element = document.querySelector(selector) let element = document.querySelector(selector)
if (!element) { if (!element) {
console.warn("Unable to find an input to init", selector) console.warn("Unable to find an input to init", selector)
@ -368,7 +313,7 @@ class VailClient {
element.checked = (storedValue == "on") element.checked = (storedValue == "on")
} }
let id = element.id let id = element.id
let outputElements = document.querySelectorAll(`[for="${id}"]`) let outputElement = document.querySelector(`[for="${id}"]`)
element.addEventListener("input", e => { element.addEventListener("input", e => {
let value = element.value let value = element.value
@ -377,13 +322,8 @@ class VailClient {
} }
localStorage[element.id] = value localStorage[element.id] = value
for (let e of outputElements) { if (outputElement) {
if (e.dataset.transform) { outputElement.value = value
let tf = transform[e.dataset.transform]
e.value = tf(value)
} else {
e.value = value
}
} }
if (callback) { if (callback) {
callback(e) callback(e)
@ -485,7 +425,7 @@ class VailClient {
function init() { function init() {
if (navigator.serviceWorker) { if (navigator.serviceWorker) {
navigator.serviceWorker.register("scripts/sw.js") navigator.serviceWorker.register("sw.js")
} }
I18n.Setup() I18n.Setup()
try { try {

View File

@ -66,7 +66,7 @@ export const Xlat = {
"wpm": "WPM", "wpm": "WPM",
"ms": "ms", "ms": "ms",
"wiki": "Help", "wiki": "Help",
"rx-delay": "rx delay", "rx-delay": "receive delay",
"telegraph-sounds": "Telegraph sounds" "telegraph-sounds": "Telegraph sounds"
}, },
"title": { "title": {