diff --git a/src/index.html b/src/index.html index aba0bb3..10c1a4d 100644 --- a/src/index.html +++ b/src/index.html @@ -152,18 +152,34 @@
Your Station Settings
+
+
- + +
+
- - Sidetone Volume (%) + +
+ +
+ + +
+
+ @@ -317,10 +333,9 @@
Responding Station Settings
- - +
@@ -580,7 +595,7 @@

After completing an exchange, click "TU" to send a wrap-up, thank you message. Each mode has a slightly different way of completing.

After clicking TU, new stations sometimes hop in! But, in case they - don't, you can always go back and click CQ.

+ don’t, you can always go back and click CQ.

diff --git a/src/js/app.js b/src/js/app.js index 4ce74da..6c0cf4f 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -32,6 +32,7 @@ import { import {getYourStation, getCallingStation} from "./stationGenerator.js"; import {updateStaticIntensity} from "./audio.js"; import {modeLogicConfig, modeUIConfig} from "./modes.js"; +import { morseInput } from './morse-input/morse-input.js'; // Import morse input functionality /** * Application state variables. @@ -82,12 +83,18 @@ document.addEventListener('DOMContentLoaded', () => { const modeRadios = document.querySelectorAll('input[name="mode"]'); const yourCallsign = document.getElementById("yourCallsign"); const yourName = document.getElementById("yourName"); + const yourState = document.getElementById("yourState"); // Added yourState const yourSpeed = document.getElementById("yourSpeed"); const yourSidetone = document.getElementById("yourSidetone"); const yourVolume = document.getElementById("yourVolume"); + const keyerMode = document.getElementById("keyerMode"); // Event Listeners - cqButton.addEventListener('click', cq); + cqButton.addEventListener('click', () => { + // Initialize morse input when user starts using MorseWalker + morseInput.initialize(); + cq(); + }); sendButton.addEventListener('click', send); tuButton.addEventListener('click', tu); resetButton.addEventListener('click', reset); @@ -114,6 +121,15 @@ document.addEventListener('DOMContentLoaded', () => { // Toggle the Farnsworth speed input when the checkbox changes enableFarnsworthCheckbox.addEventListener('change', () => { farnsworthSpeedInput.disabled = !enableFarnsworthCheckbox.checked; + if (enableFarnsworthCheckbox.checked) { + morseInput.updateSettings({ farnsworth: parseInt(farnsworthSpeedInput.value) }); + } + }); + + farnsworthSpeedInput.addEventListener('input', () => { + if (enableFarnsworthCheckbox.checked) { + morseInput.updateSettings({ farnsworth: parseInt(farnsworthSpeedInput.value) }); + } }); // Add hotkey for CQ (Ctrl + Shift + C) @@ -161,7 +177,8 @@ document.addEventListener('keydown', (event) => { yourState: "yourState", // Added yourState yourSpeed: "yourSpeed", yourSidetone: "yourSidetone", - yourVolume: "yourVolume" + yourVolume: "yourVolume", + keyerMode: "keyerMode" }; /** @@ -177,6 +194,7 @@ document.addEventListener('keydown', (event) => { yourSpeed.value = localStorage.getItem(keys.yourSpeed) || yourSpeed.value; yourSidetone.value = localStorage.getItem(keys.yourSidetone) || yourSidetone.value; yourVolume.value = localStorage.getItem(keys.yourVolume) || yourVolume.value; + keyerMode.value = localStorage.getItem(keys.keyerMode) || keyerMode.value; // Save user settings to localStorage on input change yourCallsign.addEventListener("input", () => { @@ -190,13 +208,20 @@ document.addEventListener('keydown', (event) => { }); yourSpeed.addEventListener("input", () => { localStorage.setItem(keys.yourSpeed, yourSpeed.value); + // Update morse input speed when changed + morseInput.updateSettings({ wpm: parseInt(yourSpeed.value) }); }); yourSidetone.addEventListener("input", () => { localStorage.setItem(keys.yourSidetone, yourSidetone.value); + // Update morse input tone when changed + morseInput.updateSettings({ tone: parseInt(yourSidetone.value) }); }); yourVolume.addEventListener("input", () => { localStorage.setItem(keys.yourVolume, yourVolume.value); }); + keyerMode.addEventListener("change", () => { + localStorage.setItem(keys.keyerMode, keyerMode.value); + }); // Handle QRN intensity changes const qrnRadioButtons = document.querySelectorAll('input[name="qrn"]'); diff --git a/src/js/inputs.js b/src/js/inputs.js index 97c6a1a..ccab2a0 100644 --- a/src/js/inputs.js +++ b/src/js/inputs.js @@ -33,6 +33,7 @@ function getDOMInputs() { yourCallsign: document.getElementById('yourCallsign').value.trim().toUpperCase(), yourName: document.getElementById('yourName').value.trim(), yourState: document.getElementById('yourState').value.trim().toUpperCase(), // Convert to uppercase for consistency + keyerMode: parseInt(document.getElementById('keyerMode').value, 10), yourSpeed: parseInt(document.getElementById('yourSpeed').value, 10), yourSidetone: parseInt(document.getElementById('yourSidetone').value, 10), // convert volume to a float between 0 and 1 diff --git a/src/js/morse-input/decoder.js b/src/js/morse-input/decoder.js new file mode 100644 index 0000000..ae55cbe --- /dev/null +++ b/src/js/morse-input/decoder.js @@ -0,0 +1,148 @@ +const morseToAlphabet = new Map([ + ["12", "A"], + ["2111", "B"], + ["2121", "C"], + ["211", "D"], + ["1", "E"], + ["1121", "F"], + ["221", "G"], + ["1111", "H"], + ["11", "I"], + ["1222", "J"], + ["212", "K"], + ["1211", "L"], + ["22", "M"], + ["21", "N"], + ["222", "O"], + ["1221", "P"], + ["2212", "Q"], + ["121", "R"], + ["111", "S"], + ["2", "T"], + ["112", "U"], + ["1112", "V"], + ["122", "W"], + ["2112", "X"], + ["2122", "Y"], + ["2211", "Z"], + ["12222", "1"], + ["11222", "2"], + ["11122", "3"], + ["11112", "4"], + ["11111", "5"], + ["21111", "6"], + ["22111", "7"], + ["22211", "8"], + ["22221", "9"], + ["22222", "0"], + ["121212", "."], + ["221122", ","], + ["21121", "/"], + ["112211", "?"], + ["212122", "!"], + ["211112", "-"], + ["21221", "("], + ["212212", ")"], + ["222111", ":"], +]); + +export class Decoder { + constructor(onLetterDecoded) { + this.onLetterDecoded = onLetterDecoded; // Store the callback function + this.lastLetter = ''; + this.decodeArray = ''; + this.unit = 80; // adjustment: short dit reduces, long dah lengthens + this.keyStartTime = null; + this.keyEndTime = null; + this.spaceTimer = null; + this.farnsworth = 3; + this.wordTimer = null; // Timer for word boundaries + this.wordTimeout = this.unit * 7; // A typical word gap is 7 units + } + + keyOn() { + clearTimeout(this.spaceTimer); + clearTimeout(this.wordTimer); // Clear the wordTimer as well since we are receiving input + this.keyStartTime = Date.now(); + //var pauseDuration = (this.keyEndTime) ? this.keyStartTime - this.keyEndTime : 0; + //if (pauseDuration > this.unit + (this.unit/10)) { // end sequence and decode letter + //this.registerLetter(); + //} + } + + keyOff() { + this.keyEndTime = Date.now(); + var keyDuration = (this.keyStartTime) ? this.keyEndTime - this.keyStartTime : 0; + if (keyDuration < this.unit) { + // reduce unit based on short dit + this.unit = (keyDuration + this.unit) / 2; + this.registerDit(); + } else if (keyDuration > this.unit * 3) { + // lengthen unit based on long dah + this.unit = ((keyDuration / 3) + this.unit) / 2; + this.registerDah(); + } else { + var ditAndDahThreshold = (this.unit * 2); + if (keyDuration >= ditAndDahThreshold) { + this.registerDah(); + } else { + this.registerDit(); + } + } + let spaceTime = this.unit * this.farnsworth; + this.spaceTimer = setTimeout(() => { // end sequence and decode letter + this.updateLastLetter(this.morseToLetter(this.decodeArray)); + this.decodeArray = ''; + this.startWordTimer(); // Start the word timer after finishing a letter + }, spaceTime, "keyOff"); + } + + registerDit() { + this.decodeArray += '1'; + } + + registerDah() { + this.decodeArray += '2'; + } + + updateLastLetter(letter) { + //updateCurrentLetter(letter); + this.lastLetter = letter; + //console.log(this.lastLetter); + + // Notify the callback function that a new letter is decoded + if (this.onLetterDecoded) { + this.onLetterDecoded(letter); + } + } + + morseToLetter(sequence) { + var letter = morseToAlphabet.get(sequence); + if (letter) { + return letter; + } else { + return '*'; + } + } + + startWordTimer() { + // Comment out word spacing for MorseWalker integration + // Set up the word timer to add a space after a word boundary + /* + this.wordTimer = setTimeout(() => { + // Update with a space to indicate a word boundary + if (this.onLetterDecoded) { + this.onLetterDecoded(' '); + } + }, this.wordTimeout); + */ + } + + calculateWpm() { + return 60000 / (this.unit * 50); + } + + setFarnsworth(farnsworth) { + this.farnsworth = farnsworth; + } +} \ No newline at end of file diff --git a/src/js/morse-input/keyer.js b/src/js/morse-input/keyer.js new file mode 100644 index 0000000..47ddc87 --- /dev/null +++ b/src/js/morse-input/keyer.js @@ -0,0 +1,256 @@ +import { restartAudioNeeded, restartAudio } from './sounder.js'; + +export class Keyer { + constructor(sndr, decoder) { + this.sndr = sndr; // Sounder instance + this.decoder = decoder; // Decoder instance + // Using [ and ] for dits and dahs respectively for compatibility with the vband usb interface + this.ditKey1 = 'ControlLeft'; + this.dahKey1 = 'ControlRight'; + this.ditKey2 = 'BracketLeft'; + this.dahKey2 = 'BracketRight'; + this.wpm = 20; + this.unit = 60; // length of dit in milliseconds; 60 is 20wpm + this.mode = 2; // 1: straight key, 2: iambicA, 3: iambicB, 4: ultimatic + this.tone = 550; + this.queue = []; + this.ditKeyState = 0; + this.dahKeyState = 0; + this.lastKey = null; + this.ditStreak = 0; + this.dahStreak = 0; + this.streak = 0; + this.ditStart = null; + this.ditStop = null; + this.dahStart = null; + this.dahStop = null; + this.sending = false; + this.lastSendTimestamp = null; + this.oscillatorTimer = setInterval(() => { + this.oscillate(); + }, 0); + } + + setWpm(wpm){ + this.wpm = wpm; + this.unit = 60000 / (wpm * 50) // based on the PARIS method 60 seconds / 50 elements per word * WPM + this.decoder.unit = this.unit; + } + + setMode(mode){ + this.mode = mode; + } + + setTone(tone){ + this.tone = tone; + } + + sendSignal() { + this.sending = true; + //console.log('startSignal'); + if (restartAudioNeeded()) { + restartAudio(); + } + this.sndr.setTone(this.tone); + this.sndr.on(); + this.decoder.keyOn(); + } + + stopSignal() { + //console.log('stopSignal'); + this.sndr.off(); + this.decoder.keyOff(); + this.lastSendTimestamp = Date.now(); + setTimeout(() => { + this.sending = false; + }, this.unit); + } + + press(event, down, mode=this.mode) { + if (mode > 1 && event.code != this.ditKey1 && event.code != this.dahKey1 && event.code != this.ditKey2 && event.code != this.dahKey2) return; + if (mode == 1) { + if (down) { + if (restartAudioNeeded()) { + restartAudio(); + } + this.sndr.setTone(this.tone); + this.sndr.on(); + this.decoder.keyOn(); + + } else { + this.sndr.off(); + this.decoder.keyOff(); + } + } else if (mode > 1) { + //console.log(key); + if (event.code == this.ditKey1 || event.code == this.ditKey2) { + if (down) { // dit key down + this.ditKeyState = 1; + this.ditStart = Date.now() + } else { // dit key up + this.ditKeyState = 0; + this.ditStop = Date.now() + } + } + if (event.code == this.dahKey1 || event.code == this.dahKey2) { + if (down) { // dah key down + this.dahKeyState = 1; + this.dahStart = Date.now() + } else { // dah key up + this.dahKeyState = 0; + this.dahStop = Date.now() + } + } + } + } + + processQueue() { + //console.log('processQueue'); + if (!this.sending && this.queue.length) { + this.lastKey = this.queue.shift(); + var signalLength = (this.lastKey == 1) ? this.unit : this.unit * 3; + this.sendSignal(); + setTimeout(() => { + this.stopSignal(); + }, signalLength); + } + } + + oscillatev1() { + if (!this.ditKeyState && !this.dahKeyState) { + this.queue = []; + } + if (this.ditKeyState) { + if (this.queue.length == 0) { + if (!this.dahKeyState && !this.sending || this.lastKey == 2) { + this.queue.push(1); + } + } + } + if (this.dahKeyState) { + if (this.queue.length == 0) { + if (!this.ditKeyState && !this.sending || this.lastKey == 1) { + this.queue.push(2); + } + } + } + if (!this.sending && Date.now() - this.lastSendTimestamp > this.unit) { + this.processQueue(); + } + } + + oscillatev2() { + if (this.mode == 2 && !this.ditKeyState && !this.dahKeyState) { // Iambic B doesn't clear the queue + if (this.streak > 1) { + //console.log(this.streak + " queue: " + this.queue[0]); + this.queue = []; + } + this.streak = 0; + } + if (this.ditKeyState) { + if (this.queue.length < 1) { + if (!this.sending || this.lastKey == 2) { + this.queue.push(1); + if (this.lastKey == 2 && this.dahKeyState) { + this.streak++; + } else { + this.streak = 0; + } + } + } + } + if (this.dahKeyState) { + if (this.queue.length < 1) { + if (!this.sending || this.lastKey == 1) { + this.queue.push(2); + if (this.lastKey == 1 && this.ditKeyState) { + this.streak++; + } else { + this.streak = 0; + } + } + } + } + if (!this.sending && Date.now() - this.lastSendTimestamp >= this.unit) { + this.processQueue(); + } + } + + oscillatev3() { + if (this.mode == 2 && !this.ditKeyState && !this.dahKeyState && this.queue.length) { // Iambic B doesn't clear the queue + if ((this.ditStreak && this.queue[0] == 1) || (this.dahStreak && this.queue[0] == 2)) { + console.log("ditStreak: "+this.ditStreak+" dahStreak: "+this.dahStreak+" queue: "+this.queue[0]); + this.queue = []; + } + //console.log("NO CLEAR ditStreak: "+this.ditStreak+" dahStreak: "+this.dahStreak+" queue: "+this.queue[0]); + this.ditStreak = 0; + this.dahStreak = 0; + } + if (this.ditKeyState) { + if (!this.queue.length) { + if (!this.sending || this.lastKey == 2) { + this.queue.push(1); + this.ditStreak++; + } + } else if (!this.dahKeyState && this.queue[0] == 2) { // dah was canceled. Replace in queue + this.queue[0] = 1; + this.dahStreak = 0; + this.ditStreak++; + } + } + if (this.dahKeyState) { + if (!this.queue.length) { + if (!this.sending || this.lastKey == 1) { + this.queue.push(2); + this.dahStreak++; + } + } else if (!this.ditKeyState && this.queue[0] == 1) { // dit was canceled. Replace in queue + this.queue[0] = 2; + this.ditStreak = 0; + this.dahStreak++; + } + } + if (!this.sending && Date.now() - this.lastSendTimestamp >= this.unit) { + this.processQueue(); + } + } + + oscillate() { + if (this.mode == 2 && !this.ditKeyState && !this.dahKeyState && this.queue.length) { + if (this.queue[0] == 1) { // Dit is in the queue + if (this.ditStart < this.dahStart || this.ditStop - this.ditStart > this.unit * 4) { + this.queue.pop(); + } + } else { // Dah is in the queue + if (this.dahStart < this.ditStart || this.dahStop - this.dahStart > this.unit * 2) { + this.queue.pop(); + } + } + } + if (this.ditKeyState) { + if (this.queue.length == 0) { + if ((!this.dahKeyState && !this.sending) || this.lastKey == 2) { + this.queue.push(1); + } + } else { // dah key was lifted and is still in queue + if (this.mode == 2 && !this.dahKeyState && this.dahStart < this.ditStart && this.queue[0] == 2) { + this.queue.pop(); + } + } + } + if (this.dahKeyState) { + if (this.queue.length == 0) { + if ((!this.ditKeyState && !this.sending) || this.lastKey == 1) { + this.queue.push(2); + } + } else { // dit key was lifted and is still in queue + if (this.mode == 2 && !this.ditKeyState && this.ditStart < this.dahStart && this.queue[0] == 1) { + this.queue.pop(); + } + } + } + if (!this.sending && Date.now() - this.lastSendTimestamp > this.unit) { + this.processQueue(); + } + } +} diff --git a/src/js/morse-input/morse-input.js b/src/js/morse-input/morse-input.js new file mode 100644 index 0000000..9a1ef54 --- /dev/null +++ b/src/js/morse-input/morse-input.js @@ -0,0 +1,113 @@ +import { Sounder } from './sounder.js'; +import { Decoder } from './decoder.js'; +import { Keyer } from './keyer.js'; +import { getInputs } from '../inputs.js'; + +/** + * MorseInput class handles real-time morse code input from keyboard or USB devices. + * It integrates with MorseWalker's input fields and settings. + */ +class MorseInput { + constructor() { + this.sounder = null; + this.decoder = null; + this.keyer = null; + this.initialized = false; + + // Set up keyboard input - but don't initialize audio yet + window.addEventListener('keydown', (e) => this.handleKeyEvent(e, true)); + window.addEventListener('keyup', (e) => this.handleKeyEvent(e, false)); + } + + /** + * Initialize the morse input system after user interaction + */ + initialize() { + if (this.initialized) return; + + this.sounder = new Sounder(); + this.decoder = new Decoder(this.handleDecodedLetter.bind(this)); + this.keyer = new Keyer(this.sounder, this.decoder); + + // Load settings from MorseWalker + this.syncWithMorseWalker(); + + this.initialized = true; + } + + /** + * Handle key events, initializing if needed + */ + handleKeyEvent(e, down) { + if (!this.initialized) { + this.initialize(); + } + this.keyer?.press(e, down); + } + + /** + * Synchronizes settings with MorseWalker's current configuration + */ + syncWithMorseWalker() { + const inputs = getInputs(); + if (!inputs) return; + + // Update keyer settings + this.keyer?.setWpm(inputs.yourSpeed); + this.keyer?.setMode(inputs.keyerMode); + this.keyer?.setTone(inputs.yourSidetone); + + // Sync Farnsworth if enabled + if (inputs.enableFarnsworth) { + this.decoder?.setFarnsworth(inputs.farnsworthSpeed); + } + } + + /** + * Handles decoded letters from morse input + * @param {string} letter - The decoded letter + */ + handleDecodedLetter(letter) { + const activeField = document.activeElement; + if (!activeField || !['INPUT', 'TEXTAREA'].includes(activeField.tagName)) { + return; + } + + // Insert the letter at the cursor position + const start = activeField.selectionStart; + const end = activeField.selectionEnd; + const value = activeField.value; + + activeField.value = value.substring(0, start) + letter + value.substring(end); + + // Move cursor after inserted letter + activeField.selectionStart = activeField.selectionEnd = start + letter.length; + + // Trigger input event to ensure MorseWalker processes the change + activeField.dispatchEvent(new Event('input', { bubbles: true })); + } + + /** + * Updates settings when MorseWalker configuration changes + * @param {Object} settings - New settings object + */ + updateSettings(settings) { + if (!this.initialized) return; + + if (settings.wpm !== undefined) { + this.keyer.setWpm(settings.wpm); + } + if (settings.tone !== undefined) { + this.keyer.setTone(settings.tone); + } + if (settings.farnsworth !== undefined) { + this.decoder.setFarnsworth(settings.farnsworth); + } + if (settings.mode !== undefined) { + this.keyer.setMode(settings.mode); + } + } +} + +// Create and export a singleton instance +export const morseInput = new MorseInput(); diff --git a/src/js/morse-input/sounder.js b/src/js/morse-input/sounder.js new file mode 100644 index 0000000..8072b06 --- /dev/null +++ b/src/js/morse-input/sounder.js @@ -0,0 +1,112 @@ +// Morse Code Audio Generation Component +import { audioContext } from '../audio.js'; + +let sounderGlobalVolume = 0.5; + +export function setSounderGlobalVolume(volume) { + sounderGlobalVolume = volume; +} + +export class Sounder { + constructor() { + this.oscillator = null; + this.gainNode = null; + this.isPlaying = false; + this.frequency = 550; // Default frequency + this.volume = sounderGlobalVolume; // Default volume + + // Attack and release parameters for smooth transitions + this.attackTime = 0.005; // 5ms attack + this.releaseTime = 0.005; // 5ms release + } + + /** + * Initialize audio components + */ + initialize() { + if (this.oscillator) return; + + this.oscillator = audioContext.createOscillator(); + this.gainNode = audioContext.createGain(); + + this.oscillator.type = 'sine'; + this.oscillator.frequency.value = this.frequency; + this.gainNode.gain.value = 0; // Start silent + + this.oscillator.connect(this.gainNode); + this.gainNode.connect(audioContext.destination); + this.oscillator.start(); + } + + /** + * Clean up audio resources + */ + cleanup() { + if (this.oscillator) { + this.oscillator.stop(); + this.oscillator.disconnect(); + this.oscillator = null; + } + if (this.gainNode) { + this.gainNode.disconnect(); + this.gainNode = null; + } + this.isPlaying = false; + } + + /** + * Set the frequency of the oscillator + * @param {number} freq - Frequency in Hz + */ + setTone(freq) { + this.frequency = freq; + if (this.oscillator) { + this.oscillator.frequency.value = freq; + } + } + + /** + * Set the volume of the audio + * @param {number} vol - Volume between 0 and 1 + */ + setVolume(vol) { + this.volume = Math.max(0, Math.min(1, vol)); + } + + /** + * Start playing the tone with smooth attack + */ + on() { + if (!this.gainNode) this.initialize(); + + const now = audioContext.currentTime; + this.gainNode.gain.cancelScheduledValues(now); + this.gainNode.gain.setValueAtTime(0, now); + this.gainNode.gain.linearRampToValueAtTime(this.volume, now + this.attackTime); + + this.isPlaying = true; + } + + /** + * Stop playing the tone with smooth release + */ + off() { + if (!this.gainNode) return; + + const now = audioContext.currentTime; + this.gainNode.gain.cancelScheduledValues(now); + this.gainNode.gain.setValueAtTime(this.gainNode.gain.value, now); + this.gainNode.gain.linearRampToValueAtTime(0, now + this.releaseTime); + + this.isPlaying = false; + } +} + +// These are needed by keyer.js but we don't need them anymore since we're using MorseWalker's audio context +export function restartAudioNeeded() { + return false; +} + +export function restartAudio() { + // No-op since we're using MorseWalker's audio context +} diff --git a/webpack.common.js b/webpack.common.js index 1580aba..21481fb 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -36,7 +36,14 @@ module.exports = { new CopyPlugin({ patterns: [ { from: 'src/audio', to: 'audio' }, // Ensure all files in 'audio' are copied to 'dist/audio' - ], + { from: 'src/img', to: 'img' }, + { from: 'src/favicon.ico', to: 'favicon.ico' }, + { from: 'src/robots.txt', to: 'robots.txt' }, + { from: 'src/site.webmanifest', to: 'site.webmanifest' }, + { from: 'src/js/morse-input/sounder.js', to: 'js/morse-input/sounder.js' }, + { from: 'src/js/morse-input/decoder.js', to: 'js/morse-input/decoder.js' }, + { from: 'src/js/morse-input/keyer.js', to: 'js/morse-input/keyer.js' } + ] }), ], };