Compare commits

..

13 Commits

Author SHA1 Message Date
Neale Pickett f8b5cf22af Adapter: actually change pitches 2023-02-25 18:12:17 -07:00
Neale Pickett 1c8ab50f0a Noise generator, for #54 2023-01-29 16:00:59 -07:00
Neale Pickett 72f2df5d6c Icon change on non-immediate rx
Fixes #49
2023-01-28 17:10:28 -07:00
Neale Pickett 1d861b75e4 Seems Chrome didn't like that technique 2023-01-22 16:54:34 -07:00
Neale Pickett dc3efd3104 Smarter way to do icons 2023-01-22 16:46:30 -07:00
Neale Pickett 2b52bc5d29 Make the little icon orange when rx is happening.
I am a fan of subtle notifications,
so the color doesn't flash like an rx light.

Closes #49
2023-01-22 16:34:29 -07:00
Neale Pickett 9df4c09229 Merge branch 'main' of https://github.com/nealey/vail 2023-01-22 16:12:22 -07:00
Neale Pickett 92640a22ce Silence when repeater changes
Closes #39
2023-01-22 16:11:46 -07:00
Neale Pickett 6ebb9e621d standardize definitions of durations 2023-01-17 12:25:20 -07:00
Neale Pickett 0dffcbfab0 Add frequency display 2023-01-16 18:42:07 -07:00
Neale Pickett 4bdb707730 TX pitch default C5 2023-01-16 17:38:52 -07:00
Neale Pickett 75c933f943 Note adjustment 2023-01-16 17:29:40 -07:00
Neale Pickett 58b6f896d5 Volume adjustment 2023-01-16 17:29:17 -07:00
17 changed files with 854 additions and 180 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 -va static/ melville.woozle.org:/srv/vail/testing/ rsync --delete -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 id="muted"> <i class="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>

BIN
static/assets/vail-rx.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

156
static/assets/vail-rx.svg Normal file
View File

@ -0,0 +1,156 @@
<?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>

After

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" sizes="256x256" type="image/png"> <link rel="icon" href="assets/vail.png" data-rx="assets/vail-rx.png" sizes="256x256" type="image/png">
<link rel="icon" href="assets/vail.svg" sizes="any" type="image/svg+xml"> <link rel="icon" href="assets/vail.svg" data-rx="assets/vail-rx.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" id="muted"></i> <i class="mdi mdi-volume-off 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,6 +246,91 @@
</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">
@ -253,6 +338,29 @@
</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>

55
static/scripts/audio.mjs Normal file
View File

@ -0,0 +1,55 @@
/**
* @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,6 +1,4 @@
/** @typedef {Number} Duration */ import * as time from "./time.mjs"
const Millisecond = 1
const Second = 1000 * Millisecond
/** /**
* A chart of historical values. * A chart of historical values.
@ -14,7 +12,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*Second) { constructor(canvas, style=null, duration=20*time.Second) {
this.canvas = canvas this.canvas = canvas
this.ctx = canvas.getContext("2d") this.ctx = canvas.getContext("2d")
this.duration = duration this.duration = duration
@ -22,7 +20,7 @@ class HistoryChart {
this.data = [] this.data = []
// One canvas pixel = 20ms // One canvas pixel = 20ms
canvas.width = duration / (20 * Millisecond) canvas.width = duration / (20 * time.Millisecond)
// Set origin to lower-left corner // Set origin to lower-left corner
this.ctx.scale(1, -1) this.ctx.scale(1, -1)
@ -113,7 +111,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*Second) { function FromSelector(selector, style, duration=20*time.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)

50
static/scripts/icon.mjs Normal file
View File

@ -0,0 +1,50 @@
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

@ -123,19 +123,22 @@ export class MIDI extends Input{
super(keyer) super(keyer)
this.ditDuration = 100 this.ditDuration = 100
this.keyerMode = 0 this.keyerMode = 0
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) {
this.midiInit() this.midiInit()
} }
} }
async midiInit(access) { async midiInit(access) {
this.inputs = [] this.inputs = []
this.midiAccess = await navigator.requestMIDIAccess() this.midiAccess = await navigator.requestMIDIAccess()
this.midiAccess.addEventListener("statechange", e => this.midiStateChange(e)) this.midiAccess.addEventListener("statechange", e => this.midiStateChange(e))
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()) {

View File

@ -7,6 +7,7 @@
*/ */
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
@ -15,24 +16,6 @@ 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.
* *
@ -185,7 +168,7 @@ class BugKeyer extends StraightKeyer {
Reset() { Reset() {
super.Reset() super.Reset()
this.SetDitDuration(100 * Millisecond) this.SetDitDuration(100 * time.Millisecond)
if (this.pulseTimer) { if (this.pulseTimer) {
clearInterval(this.pulseTimer) clearInterval(this.pulseTimer)
this.pulseTimer = null this.pulseTimer = null

58
static/scripts/music.mjs Normal file
View File

@ -0,0 +1,58 @@
/**
* @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,
}

126
static/scripts/noise.mjs Normal file
View File

@ -0,0 +1,126 @@
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,3 +1,6 @@
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
@ -17,77 +20,94 @@ 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*Millisecond const OscillatorRampDuration = 5*time.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. * 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} 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 * @param {string} type Oscillator type
* @returns {GainNode} A new GainNode object this oscillator is connected to
*/ */
constructor(frequency, gain = 0.5, type = "sine") { constructor(context, frequency, maxGain = 0.5, type = "sine") {
this.targetGain = gain super(context)
this.maxGain = maxGain
// Start quiet
this.masterGain.gain.value = 0
this.gainNode = BuzzerAudioContext.createGain() this.osc = new OscillatorNode(this.context)
this.gainNode.connect(BuzzerAudioContext.destination) this.osc.type = type
this.gainNode.gain.value = 0 this.osc.connect(this.masterGain)
this.setFrequency(frequency)
this.osc = BuzzerAudioContext.createOscillator()
this.osc.type = type
this.osc.connect(this.gainNode)
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 BuzzerAudioContext.resume() await this.context.resume()
this.gainNode.gain.setTargetAtTime( this.masterGain.gain.setTargetAtTime(
target, target,
BuzzerAudioContextTime(when), AudioContextTime(this.context, when),
timeConstant/Second, timeConstant/time.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.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) { HushAt(when=0, timeConstant=OscillatorRampDuration) {
return this.setTargetAtTime(0, when, timeConstant) return this.setTargetAtTime(0, when, timeConstant)
} }
@ -96,24 +116,20 @@ class Oscillator {
/** /**
* A digital sample, loaded from a URL. * A digital sample, loaded from a URL.
*/ */
class Sample { class Sample extends AudioSource {
/** /**
* * @param {AudioContext} context
* @param {string} url URL to resource * @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.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 BuzzerAudioContext.decodeAudioData(buf) this.data = await this.context.decodeAudioData(buf)
} }
/** /**
@ -122,22 +138,24 @@ class Sample {
* @param {Date} when When to begin playback * @param {Date} when When to begin playback
*/ */
async PlayAt(when) { async PlayAt(when) {
await BuzzerAudioContext.resume() await this.context.resume()
await this.resume await this.resume
let bs = BuzzerAudioContext.createBufferSource() let bs = new AudioBufferSourceNode(this.context)
bs.buffer = this.data bs.buffer = this.data
bs.connect(this.gainNode) bs.connect(this.masterGain)
bs.start(BuzzerAudioContextTime(when)) bs.start(AudioContextTime(this.context, when))
} }
} }
/** /**
* A (mostly) virtual class defining a buzzer. * A (mostly) virtual class defining a buzzer.
*/ */
class Buzzer { class Buzzer extends AudioSource {
constructor() { /**
* @param {AudioContext} context
*/
constructor(context) {
super(context)
this.connected = true this.connected = true
} }
@ -191,41 +209,66 @@ class Buzzer {
} }
class AudioBuzzer extends 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() { Error() {
let now = Date.now() let now = Date.now()
this.errorGain.SoundAt(now) this.errorTone.SoundAt(now)
this.errorGain.HushAt(now + 200*Millisecond) this.errorTone.HushAt(now + 200*time.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 {
// Buzzers keep two oscillators: one high and one low. constructor(context, {txGain=0.5, highFreq=HIGH_FREQ, lowFreq=LOW_FREQ} = {}) {
// They generate a continuous waveform, super(context)
// 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} = {}) { this.rxOsc = new Oscillator(this.context, lowFreq, txGain)
super() this.txOsc = new Oscillator(this.context, highFreq, txGain)
this.rxOsc = new Oscillator(lowFreq, txGain) this.rxOsc.connect(this.masterGain)
this.txOsc = new Oscillator(highFreq, txGain) this.txOsc.connect(this.masterGain)
// 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(1, 0.001) this.bgOsc = new Oscillator(this.context, 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
* *
@ -250,17 +293,20 @@ 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.gainNode = BuzzerAudioContext.createGain() this.closeSample = new Sample(this.context, "../assets/telegraph-a.mp3")
this.gainNode.connect(BuzzerAudioContext.destination) this.openSample = new Sample(this.context, "../assets/telegraph-b.mp3")
this.gainNode.gain.value = gain
this.hum = new Oscillator(140, 0.005, "sawtooth") this.hum.connect(this.masterGain)
this.closeSample.connect(this.masterGain)
this.closeSample = new Sample("../assets/telegraph-a.mp3") this.openSample.connect(this.masterGain)
this.openSample = new Sample("../assets/telegraph-b.mp3")
} }
async Buzz(tx, when=0) { async Buzz(tx, when=0) {
@ -281,8 +327,12 @@ 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")
} }
@ -325,9 +375,13 @@ class LampBuzzer extends Buzzer {
} }
class MIDIBuzzer 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 this.midiAccess = {outputs: []} // stub while we wait for async stuff
if (navigator.requestMIDIAccess) { if (navigator.requestMIDIAccess) {
@ -369,7 +423,6 @@ class MIDIBuzzer extends Buzzer {
if (tx) { if (tx) {
return return
} }
this.sendAt(when, [0x90, this.note, 0x7f]) this.sendAt(when, [0x90, this.note, 0x7f])
} }
@ -381,31 +434,38 @@ class MIDIBuzzer extends Buzzer {
this.sendAt(when, [0x80, this.note, 0x7f]) this.sendAt(when, [0x80, this.note, 0x7f])
} }
/* /**
* Set note to transmit * Set MIDI note for tx/rx tone
*/ *
SetNote(tx, note) { * @param {Boolean} tx True to set transmit note
* @param {Number} note MIDI note to send
*/
SetMIDINote(tx, note) {
if (tx) { if (tx) {
return this.sendAt(0, [0xB0, 0x02, note])
} else {
this.note = note
} }
this.note = note
} }
} }
/** class Collection extends AudioSource {
* Block until the audio system is able to start making noise. /**
*/ *
async function AudioReady() { * @param {AudioContext} context Audio Context
await BuzzerAudioContext.resume() */
} constructor(context) {
super(context)
class Collection { this.tone = new ToneBuzzer(this.context)
constructor() { this.telegraph = new TelegraphBuzzer(this.context)
this.tone = new ToneBuzzer() this.lamp = new LampBuzzer(this.context)
this.telegraph = new TelegraphBuzzer() this.midi = new MIDIBuzzer(this.context)
this.lamp = new LampBuzzer()
this.midi = new MIDIBuzzer()
this.collection = new Set([this.tone, this.lamp, this.midi]) 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 +474,7 @@ class Collection {
* @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") {
@ -435,7 +496,7 @@ class Collection {
} }
/** /**
* Silence all outputs. * Silence all outputs in a single direction.
* *
* @param tx True if transmitting * @param tx True if transmitting
*/ */
@ -445,6 +506,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 * Buzz for a certain duration at a certain time
* *
@ -472,4 +554,4 @@ class Collection {
} }
} }
export {AudioReady, Collection} export {Collection}

View File

@ -1,8 +1,5 @@
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
@ -61,7 +58,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*Second) setTimeout(() => this.reopen(), 2*time.Second)
} }
) )
} }
@ -157,7 +154,7 @@ export class Vail {
} }
export class Null { export class Null {
constructor(rx, interval=3*Second) { constructor(rx, interval=3*time.Second) {
this.rx = rx this.rx = rx
this.init() this.init()
} }
@ -219,11 +216,11 @@ export class Fortune extends Null {
if (this.timeout) { if (this.timeout) {
clearTimeout(this.timeout) clearTimeout(this.timeout)
} }
this.timeout = setTimeout(() => this.pulse(), 3 * Second) this.timeout = setTimeout(() => this.pulse(), 3 * time.Second)
} }
Close() { Close() {
this.keyer.Flush() this.keyer.Flush()
super.Close() super.Close()
} }
} }

View File

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

View File

@ -4,18 +4,24 @@ 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
const Second = 1000 * 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 Minute = 60 * Second const globalAudioContext = new AudioContext({
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*Second) { function toast(msg, timeout=4*time.Second) {
console.info(msg) console.info(msg)
let errors = document.querySelector("#errors") let errors = document.querySelector("#errors")
@ -35,11 +41,19 @@ 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 * Millisecond // Time to add to incoming timestamps this.rxDelay = 0 * time.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() this.outputs = new Outputs.Collection(globalAudioContext)
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)
@ -50,6 +64,13 @@ 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))
@ -62,7 +83,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(Minute / rate / 50) this.ditDuration = Math.round(time.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
} }
@ -71,8 +92,31 @@ 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 * Second this.rxDelay = e.target.value * time.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)
}) })
@ -86,10 +130,11 @@ 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
Outputs.AudioReady() globalAudioContext.resume()
.then(() => { .then(() => {
console.log("Audio context ready") for (let e of document.querySelectorAll(".muted")) {
document.querySelector("#muted").classList.add("is-hidden") e.classList.add("is-hidden")
}
}) })
} }
@ -137,6 +182,8 @@ 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)
} }
@ -148,7 +195,13 @@ class VailClient {
BuzzDuration(tx, when, duration) { BuzzDuration(tx, when, duration) {
this.outputs.BuzzDuration(tx, when, duration) this.outputs.BuzzDuration(tx, when, duration)
let chart = tx?this.txChart:this.rxChart let chart
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)
@ -267,6 +320,7 @@ class VailClient {
return return
} }
this.Silence()
if (this.repeater) { if (this.repeater) {
this.repeater.Close() this.repeater.Close()
} }
@ -300,8 +354,9 @@ 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) { inputInit(selector, callback, transform={}) {
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)
@ -313,7 +368,7 @@ class VailClient {
element.checked = (storedValue == "on") element.checked = (storedValue == "on")
} }
let id = element.id let id = element.id
let outputElement = document.querySelector(`[for="${id}"]`) let outputElements = document.querySelectorAll(`[for="${id}"]`)
element.addEventListener("input", e => { element.addEventListener("input", e => {
let value = element.value let value = element.value
@ -321,9 +376,14 @@ class VailClient {
value = element.checked?"on":"off" value = element.checked?"on":"off"
} }
localStorage[element.id] = value localStorage[element.id] = value
if (outputElement) { for (let e of outputElements) {
outputElement.value = value if (e.dataset.transform) {
let tf = transform[e.dataset.transform]
e.value = tf(value)
} else {
e.value = value
}
} }
if (callback) { if (callback) {
callback(e) callback(e)
@ -425,7 +485,7 @@ class VailClient {
function init() { function init() {
if (navigator.serviceWorker) { if (navigator.serviceWorker) {
navigator.serviceWorker.register("sw.js") navigator.serviceWorker.register("scripts/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": "receive delay", "rx-delay": "rx delay",
"telegraph-sounds": "Telegraph sounds" "telegraph-sounds": "Telegraph sounds"
}, },
"title": { "title": {