Attempt to get local keyer modes going

This commit is contained in:
Neale Pickett 2022-05-22 21:55:22 -06:00
parent 3521861f18
commit 012ee5ae31
13 changed files with 790 additions and 87 deletions

10
.clangd Normal file
View File

@ -0,0 +1,10 @@
CompileFlags:
Add:
- "--include-directory=/opt/arduino/hardware/arduino/avr/cores/arduino"
- "--include-directory=/opt/arduino/hardware/arduino/avr/variants/standard"
- "--include-directory=/opt/arduino/hardware/arduino/avr/libraries/HID/src"
- "--include-directory=/opt/arduino/hardware/tools/avr/avr/include"
- "--include-directory=/opt/arduino/libraries/Keyboard/src"
- "--include-directory=/opt/arduino/libraries/HID/src"
- "--include-directory=/home/dartcatcher/Arduino/libraries/MIDIUSB/src"

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"clangd.arguments": [
"--enable-config"
]
}

View File

@ -1,12 +1,16 @@
FQBN = Seeeduino:samd:seeed_XIAO_m0
FQBN_qtpy = adafruit:samd:adafruit_qtpy_m0
FQBN_xiao = Seeeduino:samd:seeed_XIAO_m0
UF2_MOUNT = /mnt/chromeos/removable/Arduino
ARDUINO_DIR = /app/Arduino
BUILDER = flatpak run --command ${ARDUINO_DIR}/arduino-builder cc.arduino.arduinoide
default: build/vail-adapter.xiao.uf2
default: build/vail-adapter.qtpy.uf2 build/vail-adapter.xiao.uf2
install: build/vail-adapter.xiao.uf2
./install.sh $< $(UF2_MOUNT)
clean:
rm -rf build/*
# uf2conv.py is covered by an MIT license.
build/uf2conv.py: build/uf2families.json
mkdir -p build
@ -16,13 +20,19 @@ build/uf2families.json:
mkdir -p build
curl -L https://raw.githubusercontent.com/microsoft/uf2/master/utils/$(@F) > $@
%.xiao.uf2: %.ino.bin build/uf2conv.py
%.xiao.uf2: %.xiao.bin build/uf2conv.py
build/uf2conv.py -b 0x2000 -c -o $@ $<
build/%.bin: % *.cpp *.h
%.qtpy.uf2: %.qtpy.bin build/uf2conv.py
build/uf2conv.py -b 0x2000 -c -o $@ $<
build/%.qtpy.bin: FQBN = adafruit:samd:adafruit_qtpy_m0
build/%.xiao.bin: FQBN = Seeeduino:samd:seeed_XIAO_m0
build/vail-adapter.%.bin: vail-adapter.ino *.cpp *.h
mkdir -p build/$*
arduino-builder \
-build-cache ~/.cache/arduino \
-build-path build \
-build-path build/$* \
-core-api-version 10813 \
-fqbn $(FQBN) \
-hardware ~/.arduino15/packages \
@ -34,6 +44,7 @@ build/%.bin: % *.cpp *.h
-libraries ~/Arduino/libraries \
-compile \
$<
mv build/$*/vail-adapter.ino.bin $@
upload: vail-adapter.ino
arduino --upload --board $(FQBN) $<

113
adapter.cpp Normal file
View File

@ -0,0 +1,113 @@
#include <Arduino.h>
#include <Keyboard.h>
#include <MIDIUSB.h>
#include <cstddef>
#include "keyers.h"
#include "adapter.h"
#include "polybuzzer.h"
#define MILLISECOND 1
#define SECOND (1000 * MILLISECOND)
VailAdapter::VailAdapter(unsigned int PiezoPin) {
this->buzzer = new PolyBuzzer(PiezoPin);
this->txToneFrequency = 440;
}
// Send a MIDI Key Event
void VailAdapter::midiKey(uint8_t key, bool down) {
midiEventPacket_t event = {down?9:8, down?0x90:0x80, key, 0x7f};
MidiUSB.sendMIDI(event);
MidiUSB.flush();
}
// Send a keyboard key event
void VailAdapter::keyboardKey(uint8_t key, bool down) {
if (down) {
Keyboard.press(key);
} else {
Keyboard.release(key);
}
}
// Begin transmitting
void VailAdapter::BeginTx() {
this->buzzer->Tone(0, this->txToneFrequency);
if (this->keyboardMode) {
this->keyboardKey(KEY_LEFT_CTRL, true);
} else {
this->midiKey(0, true);
}
}
// Stop transmitting
void VailAdapter::EndTx() {
this->buzzer->NoTone(0);
if (this->keyboardMode) {
this->keyboardKey(KEY_LEFT_CTRL, false);
} else {
this->midiKey(0, false);
}
}
// Handle a paddle being pressed.
//
// The caller needs to debounce keys and deal with keys wired in parallel.
void VailAdapter::HandlePaddle(Paddle paddle, bool pressed) {
switch (paddle) {
case PADDLE_STRAIGHT:
if (pressed) {
this->BeginTx();
} else {
this->EndTx();
}
return;
case PADDLE_DIT:
if (this->keyer) {
this->keyer->Key(paddle, pressed);
} else if (this->keyboardMode) {
this->keyboardKey(KEY_LEFT_CTRL, pressed);
} else {
this->midiKey(1, pressed);
}
break;
case PADDLE_DAH:
if (this->keyer) {
this->keyer->Key(paddle, pressed);
} else if (this->keyboardMode) {
this->keyboardKey(KEY_RIGHT_CTRL, pressed);
} else {
this->midiKey(2, pressed);
}
break;
}
}
// Handle a MIDI event.
//
// We act as a MIDI
void VailAdapter::HandleMIDI(midiEventPacket_t event) {
uint16_t msg = (event.byte1 << 8) | (event.byte2 << 0);
switch (event.byte1) {
case 0xB0: // Controller Change
switch (event.byte2) {
case 0: // turn keyboard mode on/off
this->keyboardMode = (event.byte3 > 0x3f);
MidiUSB.sendMIDI(event); // Send it back to acknowledge
break;
case 1: // set dit duration (0-254) *2ms
this->ditDuration = event.byte3 * 2 * MILLISECOND;
break;
}
break;
case 0xC0: // Program Change
this->keyer = GetKeyerByNumber(event.byte2, this);
break;
case 0x80: // Note off
this->buzzer->NoTone(1);
break;
case 0x90: // Note on
this->buzzer->Note(1, event.byte2);
break;
}
}

25
adapter.h Normal file
View File

@ -0,0 +1,25 @@
#pragma once
#include <MIDIUSB.h>
#include "keyers.h"
#include "polybuzzer.h"
class VailAdapter: public Transmitter {
private:
unsigned int txToneFrequency;
unsigned int ditDuration = 100;
bool keyboardMode = false;
Keyer *keyer = NULL;
PolyBuzzer *buzzer = NULL;
void midiKey(uint8_t key, bool down);
void keyboardKey(uint8_t key, bool down);
public:
VailAdapter(unsigned int PiezoPin);
void HandlePaddle(Paddle key, bool pressed);
void HandleMIDI(midiEventPacket_t event);
void BeginTx();
void EndTx();
};

55
doc/MIDI.md Normal file
View File

@ -0,0 +1,55 @@
# Vail MIDI Protocol
When it boots,
the Vail adapter sends left and right Control keyboard key up and down events.
It also shows up as a MIDI device.
The Vail web site sends MIDI control commands to enable MIDI keyer mode,
tells the keyer what sideband pitch to generate,
and can set the keyer mode.
## Controller 0 - MIDI Mode
`b0 00 ff` will enable MIDI mode and disable Keyboard mode
`b0 00 00` will enable Keyboard mode and disable MIDI mode
## Controller 1 - dit length
`b0 00 xx` will set the dit duration to `xx` times 2 milliseconds
## Controller 2 - sidetone note
`b0 00 xx` will play note `xx` as the sidetone note
## Program Change
`c0 xx` will change the keyer mode to `xx`.
### Keyer Modes
* 0: passthrough (sends C# and D for dit and dah)
* 1: cootie / straight key
* 2: bug
* 3: electric bug
* 4: single dot
* 5: ultimatic
* 6: plain iambic
* 7: iambic a
* 8: iambic b
* 9: keyahead
Any other mode will set to passthrough.
## Notes (key down / key up)
`90 00 xx` will begin playing note `xx`
`80 00 xx` will end playing note `xx`
These work just like a regular MIDI synthesizer.

View File

@ -1,24 +0,0 @@
# MIDI Negotiation
Morse code keyers are very simple devices,
they just connect two wires together.
You could use a button if you wanted to,
or even touch wires together.
The only real complication here is that some browsers
need to get keyboard events instead of musical instrument events.
The Vail adapter boots into a mode that sends both keyboard events
and MIDI messages.
If it receives a MIDI key release event
on channel 0
for note C0,
it will disable keyboard events.
Vail sends this "disable keyboard" MIDI event, so as soon as you
load up Vail, the keyboard events are disabled, and your adapter
will no longer interfere with your typing.
If your browser doesn't support MIDI,
the disable command can't be sent,
and it keeps on sending keystrokes.

404
keyers.cpp Normal file
View File

@ -0,0 +1,404 @@
#include <stddef.h>
#include "keyers.h"
#define len(t) (sizeof(t)/sizeof(*t))
// Queue Set: A Set you can shift and pop.
class QSet {
int arr[MAX_KEYER_QUEUE];
unsigned int arrlen = 0;
public:
int shift() {
if (arrlen == 0) {
return -1;
}
int ret = arr[0];
arrlen--;
for (int i = 0; i < arrlen; i++) {
arr[i] = arr[i+1];
}
return ret;
}
int pop() {
if (arrlen == 0) {
return -1;
}
int ret = arr[arrlen];
arrlen--;
return ret;
}
void add(int val) {
if (arrlen == MAX_KEYER_QUEUE-1) {
return;
}
for (int i = 0; i < arrlen; i++) {
if (arr[arrlen] == i) {
return;
}
}
arr[arrlen] = val;
arrlen++;
}
};
class StraightKeyer: public Keyer {
public:
Transmitter *output;
unsigned int ditDuration;
bool txRelays[2];
StraightKeyer(Transmitter *output) {
this->output = output;
this->ditDuration = 100;
this->Reset();
}
void Reset() {
this->output->EndTx();
}
void SetDitDuration(int duration) {
this->ditDuration = duration;
}
void Release() {}
bool TxClosed() {
for (int i = 0; i < len(this->txRelays); i++) {
if (this->TxClosed(i)) {
return true;
}
}
return false;
}
bool TxClosed(int relay) {
return this->txRelays[relay];
}
void Tx(int relay, bool closed) {
bool wasClosed = this->TxClosed();
this->txRelays[relay] = closed;
bool nowClosed = this->TxClosed();
if (wasClosed != nowClosed) {
if (nowClosed) {
this->output->BeginTx();
} else {
this->output->EndTx();
}
}
}
void Key(Paddle key, bool pressed) {
this->Tx(key, pressed);
}
void Tick(unsigned int millis) {};
};
class BugKeyer: public StraightKeyer {
public:
unsigned int pulseTime = 0;
bool keyPressed[2];
using StraightKeyer::StraightKeyer;
void Reset() {
StraightKeyer::Reset();
this->pulseTime = 0;
this->keyPressed[0] = false;
this->keyPressed[1] = false;
}
void Key(Paddle key, bool pressed) {
this->keyPressed[key] = pressed;
if (key == 0) {
this->beginPulsing();
} else {
StraightKeyer::Key(key, pressed);
}
}
void beginPulsing() {
this->pulseTime = 1;
}
void pulse(unsigned int millis) {
if (this->TxClosed(0)) {
this->Tx(0, false);
} else if (this->keyPressed[0]) {
this->Tx(0, true);
} else {
this->pulseTime = 0;
return;
}
this->pulseTime = millis + this->ditDuration;
}
void Tick(unsigned int millis) {
if (this->pulseTime && (millis >= this->pulseTime)) {
this->pulse(millis);
}
}
};
class ElBugKeyer: public BugKeyer {
public:
unsigned int nextRepeat;
using BugKeyer::BugKeyer;
void Reset() {
BugKeyer::Reset();
this->nextRepeat = -1;
}
// Return which key is pressed. If none, return -1.
int whichKeyPressed() {
for (int i = 0; i < len(this->keyPressed); i++) {
if (this->keyPressed[i]) {
return i;
}
}
return -1;
}
void Key(Paddle key, bool pressed) {
this->keyPressed[key] = pressed;
if (pressed) {
this->nextRepeat = key;
} else {
this->nextRepeat = this->whichKeyPressed();
}
this->beginPulsing();
}
unsigned int keyDuration(int key) {
switch (key) {
case 0:
return this->ditDuration;
case 1:
return 3 * this->ditDuration;
}
return 0;
}
int nextTx() {
if (this->whichKeyPressed() == -1) {
return -1;
}
return this->nextRepeat;
}
void pulse(unsigned int millis) {
int nextPulse = 0;
if (this->TxClosed(0)) {
// Pause if we're currently transmitting
nextPulse = this->ditDuration;
this->Tx(0, false);
} else {
int next = this->nextTx();
if (next >= 0) {
nextPulse = this->keyDuration(next);
this->Tx(0, true);
}
}
if (nextPulse) {
this->pulseTime = millis + nextPulse;
} else {
this->pulseTime = 0;
}
}
};
class UltimaticKeyer: public ElBugKeyer {
public:
QSet *queue;
using ElBugKeyer::ElBugKeyer;
void Reset() {
ElBugKeyer::Reset();
this->queue = new QSet();
}
void Key(Paddle key, bool pressed) {
if (pressed) {
this->queue->add(key);
}
ElBugKeyer::Key(key, pressed);
}
int nextTx() {
int key = this->queue->shift();
if (key != -1) {
return key;
}
return ElBugKeyer::nextTx();
}
};
class SingleDotKeyer: public ElBugKeyer {
public:
QSet *queue;
using ElBugKeyer::ElBugKeyer;
void Reset() {
ElBugKeyer::Reset();
this->queue = new QSet();
}
void Key(Paddle key, bool pressed) {
if (pressed && (key == 0)) {
this->queue->add(key);
}
ElBugKeyer::Key(key, pressed);
}
int nextTx() {
int key = this->queue->shift();
if (key != -1) {
return key;
}
if (this->keyPressed[1]) return 1;
if (this->keyPressed[0]) return 0;
return -1;
}
};
class IambicKeyer: public ElBugKeyer {
public:
using ElBugKeyer::ElBugKeyer;
int nextTx() {
int next = ElBugKeyer::nextTx();
if (this->whichKeyPressed() != -1) {
this->nextRepeat = 1 - this->nextRepeat;
}
return next;
}
};
class IambicAKeyer: public IambicKeyer {
public:
QSet *queue;
using IambicKeyer::IambicKeyer;
void Reset() {
IambicKeyer::Reset();
this->queue = new QSet();
}
void Key(Paddle key, bool pressed) {
if (pressed && (key == 0)) {
this->queue->add(key);
}
IambicKeyer::Key(key, pressed);
}
int nextTx() {
int next = IambicKeyer::nextTx();
int key = this->queue->shift();
if (key != -1) {
return key;
}
return next;
}
};
class IambicBKeyer: public IambicKeyer {
public:
QSet *queue;
using IambicKeyer::IambicKeyer;
void Reset() {
IambicKeyer::Reset();
this->queue = new QSet();
}
void Key(Paddle key, bool pressed) {
if (pressed) {
this->queue->add(key);
}
IambicKeyer::Key(key, pressed);
}
int nextTx() {
for (int key = 0; key < 2; key++) {
if (this->keyPressed[key]) {
this->queue->add(key);
}
}
return this->queue->shift();
}
};
class KeyaheadKeyer: public ElBugKeyer {
public:
int queue[MAX_KEYER_QUEUE];
unsigned int qlen;
using ElBugKeyer::ElBugKeyer;
void Reset() {
ElBugKeyer::Reset();
this->qlen = 0;
}
void Key(Paddle key, bool pressed) {
if (pressed) {
if (this->qlen < MAX_KEYER_QUEUE) {
this->queue[this->qlen++] = key;
}
}
ElBugKeyer::Key(key, pressed);
}
int nextTx() {
if (this->qlen > 0) {
int next = this->queue[0];
this->qlen--;
for (int i = 0; i < this->qlen; i++) {
this->queue[i] = this->queue[i+1];
}
return next;
}
return ElBugKeyer::nextTx();
}
};
Keyer *GetKeyerByNumber(int n, Transmitter *output) {
switch (n) {
case 1:
return new StraightKeyer(output);
case 2:
return new BugKeyer(output);
case 3:
return new ElBugKeyer(output);
case 4:
return new SingleDotKeyer(output);
case 5:
return new UltimaticKeyer(output);
case 6:
return new IambicKeyer(output);
case 7:
return new IambicAKeyer(output);
case 8:
return new IambicBKeyer(output);
case 9:
return new KeyaheadKeyer(output);
default:
return NULL;
}
}

29
keyers.h Normal file
View File

@ -0,0 +1,29 @@
#pragma once
#define MAX_KEYER_QUEUE 5
typedef enum {
PADDLE_DIT = 0,
PADDLE_DAH,
PADDLE_STRAIGHT,
} Paddle;
class Transmitter {
public:
virtual void BeginTx();
virtual void EndTx();
};
class Keyer {
public:
virtual void Reset();
virtual void SetDitDuration(int d);
virtual void Release();
virtual bool TxClosed();
virtual bool TxClosed(int relay);
virtual void Tx(int relay, bool closed);
virtual void Key(Paddle key, bool pressed);
virtual void Tick(unsigned int millis);
};
Keyer *GetKeyerByNumber(int n, Transmitter *output);

44
polybuzzer.cpp Normal file
View File

@ -0,0 +1,44 @@
#include <Arduino.h>
#include "polybuzzer.h"
PolyBuzzer::PolyBuzzer(uint8_t pin) {
for (int i = 0; i < POLYBUZZER_MAX_TONES; i++) {
this->tones[i] = 0;
}
this->playing = 0;
this->pin = pin;
pinMode(pin, OUTPUT);
}
void PolyBuzzer::update() {
for (int i = 0; i < POLYBUZZER_MAX_TONES; i++) {
if (tones[i]) {
if (playing != tones[i]) {
playing = tones[i];
tone(this->pin, tones[i]);
return;
}
}
}
this->playing = 0;
noTone(this->pin);
}
void PolyBuzzer::Tone(int slot, unsigned int frequency) {
tones[slot] = frequency;
this->update();
}
void PolyBuzzer::Note(int slot, int note) {
unsigned int frequency = 8.18; // MIDI note 0
for (int i = 0; i < note; i++) {
frequency *= 1.0594630943592953; // equal temperament half step
}
this->Tone(slot, frequency);
}
void PolyBuzzer::NoTone(int slot) {
tones[slot] = 0;
this->update();
}

20
polybuzzer.h Normal file
View File

@ -0,0 +1,20 @@
#pragma once
#include <Arduino.h>
#define POLYBUZZER_MAX_TONES 2
// PolyBuzzer provides a proritized monophonic buzzer.
//
// A given tone will only be played when all higher priority tones have stopped.
class PolyBuzzer {
public:
unsigned int tones[POLYBUZZER_MAX_TONES];
unsigned int playing;
uint8_t pin;
PolyBuzzer(uint8_t pin);
void update();
void Tone(int slot, unsigned int frequency);
void Note(int slot, int note);
void NoTone(int slot);
};

View File

@ -8,6 +8,7 @@
#include <Adafruit_FreeTouch.h>
#include "bounce2.h"
#include "touchbounce.h"
#include "adapter.h"
#define DIT_PIN 2
#define DAH_PIN 1
@ -19,8 +20,8 @@
#define LED_ON false // Xiao inverts this logic for some reason
#define LED_OFF (!LED_ON)
#define DIT_KEY KEY_LEFT_CTRL
#define DAH_KEY KEY_RIGHT_CTRL
#define DIT_KEYBOARD_KEY KEY_LEFT_CTRL
#define DAH_KEYBOARD_KEY KEY_RIGHT_CTRL
#define TONE 550
#define MILLISECOND 1
@ -35,10 +36,10 @@ Bounce key = Bounce();
TouchBounce qt_dit = TouchBounce();
TouchBounce qt_dah = TouchBounce();
TouchBounce qt_key = TouchBounce();
VailAdapter adapter = VailAdapter(PIEZO);
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
pinMode(PIEZO, OUTPUT);
dit.attach(DIT_PIN, INPUT_PULLUP);
dah.attach(DAH_PIN, INPUT_PULLUP);
key.attach(KEY_PIN, INPUT_PULLUP);
@ -85,76 +86,33 @@ void setLED() {
digitalWrite(LED_BUILTIN, on?LED_ON:LED_OFF);
}
void midiKey(bool down, uint8_t key) {
midiEventPacket_t event = {down?9:8, down?0x90:0x80, key, 0x7f};
MidiUSB.sendMIDI(event);
MidiUSB.flush();
}
void midiProbe() {
midiEventPacket_t event = MidiUSB.read();
uint16_t msg = (event.byte1 << 8) | (event.byte2 << 0);
switch (msg) {
case 0x8B00: // Controller 0: turn keyboard mode on/off
keyboard = (event.byte3 > 0x3f);
break;
case 0x8B01: // Controller 1: set iambic speed (0-254)
// I am probably never going to use this,
// because as soon as I implement it,
// people are going to want a way to select mode A or B,
// or typeahead,
// or some other thing that I don't want to maintain
// simultaneously in both C and JavaScript
iambicDelay = event.byte3 << 1;
break;
}
}
void loop() {
midiProbe();
midiEventPacket_t event = MidiUSB.read();
setLED();
if (event.header) {
adapter.HandleMIDI(event);
}
// Monitor straight key pin
if (key.update() || qt_key.update()) {
bool fell = key.fell() || qt_key.fell();
midiKey(fell, 0);
if (fell) {
tone(PIEZO, TONE);
} else {
noTone(PIEZO);
}
bool pressed = key.read() || qt_key.read();
adapter.HandlePaddle(PADDLE_STRAIGHT, pressed);
}
// If we made dit = dah, we have a straight key on the dit pin,
// so we skip iambic polling.
// so we skip other keys polling.
if (trs) {
return;
}
if (dit.update() || qt_dit.update()) {
bool fell = dit.fell() || qt_dit.fell();
midiKey(fell, 1);
if (keyboard) {
if (fell) {
Keyboard.press(DIT_KEY);
} else {
Keyboard.release(DIT_KEY);
}
}
bool pressed = dit.read() || qt_dit.read();
adapter.HandlePaddle(PADDLE_DIT, pressed);
}
// Monitor dah pin
if (dah.update() || qt_dah.update()) {
bool fell = dah.fell() || qt_dah.fell();
midiKey(fell, 2);
if (keyboard) {
if (fell) {
Keyboard.press(DAH_KEY);
} else {
Keyboard.release(DAH_KEY);
}
}
bool pressed = dah.read() | qt_dah.read();
adapter.HandlePaddle(PADDLE_DAH, pressed);
}
}

53
webhid-test.html Normal file
View File

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html>
<head>
<script>
let deviceFilter = { vendorId: 0x1234, productId: 0xabcd };
let requestParams = { filters: [deviceFilter] };
let outputReportId = 0x01;
let outputReport = new Uint8Array([42]);
function handleConnectedDevice(e) {
console.log("Device connected: " + e.device.productName);
}
function handleDisconnectedDevice(e) {
console.log("Device disconnected: " + e.device.productName);
}
function handleInputReport(e) {
console.log(e.device.productName + ": got input report " + e.reportId);
console.log(new Uint8Array(e.data.buffer));
}
navigator.hid.addEventListener("connect", handleConnectedDevice);
navigator.hid.addEventListener("disconnect", handleDisconnectedDevice);
function listen() {
navigator.hid.requestDevice(requestParams).then((devices) => {
if (devices.length == 0) return;
devices[0].open().then(() => {
console.log("Opened device: " + device.productName);
device.addEventListener("inputreport", handleInputReport);
device.sendReport(outputReportId, outputReport).then(() => {
console.log("Sent output report " + outputReportId);
});
});
});
}
function init() {
document.querySelector("#moo").addEventListener("click", listen)
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init)
} else {
init()
}
</script>
</head>
<body>
<button id="moo">start</button>
</body>
</html>