Note adjustment

This commit is contained in:
Neale Pickett 2023-01-16 17:29:40 -07:00
parent 58b6f896d5
commit 75c933f943
5 changed files with 370 additions and 113 deletions

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>

View File

@ -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,89 @@
</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"></output>
</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input
id="tx-tone"
type="range"
min="0"
max="127"
value="69"
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"></output>
</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">

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

@ -0,0 +1,57 @@
/**
* @file Musical notes and frequencies
*/
/** One half step in equal temperament */
const semitone = Math.pow(2, 1/12)
const ln_semitone = Math.log(semitone)
const A4_note = 69
const A4_freq = 440
const Cn1_note = 0
const Cn1_freq = A4_freq / Math.pow(semitone, A4_note)
const note_names = [
"C",
"C♯",
"D",
"E♭",
"E",
"F",
"F♯",
"G",
"A♭",
"A",
"B♭",
"B",
]
/**
* Convert a MIDI note to a frequency
*
* @param {Number} note MIDI note number
* @returns {Number} Frequency (Hz)
*/
function MIDINoteFrequency(note) {
return Cn1_freq * Math.pow(semitone, note)
}
/**
* Return closest matching MIDI note to a frequency
*
* @param {Number} frequency Frequency (Hz)
* @returns {Number} Closest MIDI note
*/
function FrequencyMIDINote(frequency) {
return Math.round(Math.log(frequency/Cn1_freq) / ln_semitone)
}
function MIDINoteName(note) {
let octave = Math.floor(note / 12) - 1
return note_names[note % 12] + octave
}
export {
MIDINoteFrequency,
FrequencyMIDINote,
MIDINoteName,
}

View File

@ -1,3 +1,5 @@
import {AudioSource, AudioContextTime} from "./audio.mjs"
const HIGH_FREQ = 555 const HIGH_FREQ = 555
const LOW_FREQ = 444 const LOW_FREQ = 444
@ -26,68 +28,88 @@ const Second = 1000 * Millisecond
*/ */
const OscillatorRampDuration = 5*Millisecond const OscillatorRampDuration = 5*Millisecond
console.warn("Chrome will now complain about an AudioContext not being allowed to start. This is normal, and there is no way to make Chrome stop complaining about this.")
const BuzzerAudioContext = new AudioContext({
latencyHint: 0,
})
/**
* Compute the special "Audio Context" time
*
* This is is a duration from now, in seconds.
*
* @param {Date} when Date to compute
* @returns audiocontext time
*/
function BuzzerAudioContextTime(when) {
if (!when) return 0
let acOffset = Date.now() - (BuzzerAudioContext.currentTime * Second)
return Math.max(when - acOffset, 0) / Second
}
class Oscillator { class Oscillator extends AudioSource {
/** /**
* Create a new oscillator, and encase it in a Gain for control. * 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
this.gainNode = BuzzerAudioContext.createGain() // Start quiet
this.gainNode.connect(BuzzerAudioContext.destination) this.masterGain.gain.value = 0
this.gainNode.gain.value = 0
this.osc = BuzzerAudioContext.createOscillator() this.osc = new OscillatorNode(this.context)
this.osc.type = type this.osc.type = type
this.osc.connect(this.gainNode) this.osc.connect(this.masterGain)
this.osc.frequency.value = frequency this.setFrequency(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/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 +118,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 +140,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 +211,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*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 +295,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 +329,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 +377,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) {
@ -381,10 +437,13 @@ 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
*
* @param {Boolean} tx True to set transmit note
* @param {Number} note MIDI note to send
*/ */
SetNote(tx, note) { SetMIDINote(tx, note) {
if (tx) { if (tx) {
return return
} }
@ -392,20 +451,23 @@ class MIDIBuzzer extends Buzzer {
} }
} }
/** class Collection extends AudioSource {
* Block until the audio system is able to start making noise. /**
*
* @param {AudioContext} context Audio Context
*/ */
async function AudioReady() { constructor(context) {
await BuzzerAudioContext.resume() super(context)
} this.tone = new ToneBuzzer(this.context)
this.telegraph = new TelegraphBuzzer(this.context)
class Collection { this.lamp = new LampBuzzer(this.context)
constructor() { this.midi = new MIDIBuzzer(this.context)
this.tone = new ToneBuzzer()
this.telegraph = new TelegraphBuzzer()
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 +476,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 +498,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 +508,27 @@ class Collection {
} }
} }
/**
* Silence all outputs.
*/
Panic() {
this.Silence(true)
this.Silence(false)
}
/**
*
* @param {Boolean} tx True to set transmit tone
* @param {Number} note MIDI note to set
*/
SetMIDINote(tx, note) {
for (let b of this.collection) {
if (b.SetMIDINote) {
b.SetMIDINote(tx, note)
}
}
}
/** /**
* Buzz for a certain duration at a certain time * Buzz for a certain duration at a certain time
* *
@ -472,4 +556,4 @@ class Collection {
} }
} }
export {AudioReady, Collection} export {Collection}

View File

@ -4,12 +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 Music from "./music.mjs"
const DefaultRepeater = "General" const DefaultRepeater = "General"
const Millisecond = 1 const Millisecond = 1
const Second = 1000 * Millisecond const Second = 1000 * Millisecond
const Minute = 60 * Second const Minute = 60 * Second
console.warn("Chrome will now complain about an AudioContext not being allowed to start. This is normal, and there is no way to make Chrome stop complaining about this.")
const globalAudioContext = new AudioContext({
latencyHint: "interactive",
})
/** /**
* Pop up a message, using an notification. * Pop up a message, using an notification.
* *
@ -39,7 +45,8 @@ class VailClient {
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)
// Keyers // Keyers
this.straightKeyer = new Keyers.Keyers.straight(this) this.straightKeyer = new Keyers.Keyers.straight(this)
@ -50,6 +57,13 @@ class VailClient {
// Send this as the keyer so we can intercept dit and dah events for charts // 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))
@ -73,6 +87,19 @@ class VailClient {
this.inputInit("#rx-delay", e => { this.inputInit("#rx-delay", e => {
this.rxDelay = e.target.value * Second this.rxDelay = e.target.value * Second
}) })
this.inputInit("#masterGain", e => {
this.outputs.SetGain(e.target.value / 100)
})
this.inputInit(
"#rx-tone",
e => this.outputs.SetMIDINote(false, e.target.value),
Music.MIDINoteName,
)
this.inputInit(
"#tx-tone",
e => this.outputs.SetMIDINote(true, e.target.value),
Music.MIDINoteName,
)
this.inputInit("#telegraph-buzzer", e => { this.inputInit("#telegraph-buzzer", e => {
this.setTelegraphBuzzer(e.target.checked) this.setTelegraphBuzzer(e.target.checked)
}) })
@ -86,10 +113,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")
}
}) })
} }
@ -300,8 +328,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 {function} transform A function to transform the value into displayed text
*/ */
inputInit(selector, callback) { inputInit(selector, callback, transform=null) {
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)
@ -322,8 +351,12 @@ class VailClient {
} }
localStorage[element.id] = value localStorage[element.id] = value
let displayValue = value
if (transform) {
displayValue = transform(value)
}
if (outputElement) { if (outputElement) {
outputElement.value = value outputElement.value = displayValue
} }
if (callback) { if (callback) {
callback(e) callback(e)