diff --git a/.clangd b/.clangd new file mode 100644 index 0000000..882b8cd --- /dev/null +++ b/.clangd @@ -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" + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..84a82d3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "clangd.arguments": [ + "--enable-config" + ] +} \ No newline at end of file diff --git a/Makefile b/Makefile index 2bba2f4..c48bd84 100644 --- a/Makefile +++ b/Makefile @@ -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) $< diff --git a/adapter.cpp b/adapter.cpp new file mode 100644 index 0000000..b89ca39 --- /dev/null +++ b/adapter.cpp @@ -0,0 +1,113 @@ +#include +#include +#include +#include +#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; + } +} diff --git a/adapter.h b/adapter.h new file mode 100644 index 0000000..935090b --- /dev/null +++ b/adapter.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#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(); +}; diff --git a/doc/MIDI.md b/doc/MIDI.md new file mode 100644 index 0000000..0557312 --- /dev/null +++ b/doc/MIDI.md @@ -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. diff --git a/doc/tech-notes.md b/doc/tech-notes.md deleted file mode 100644 index 5dc262d..0000000 --- a/doc/tech-notes.md +++ /dev/null @@ -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. - diff --git a/keyers.cpp b/keyers.cpp new file mode 100644 index 0000000..b7c4831 --- /dev/null +++ b/keyers.cpp @@ -0,0 +1,404 @@ +#include +#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; + } +} diff --git a/keyers.h b/keyers.h new file mode 100644 index 0000000..b0fcb27 --- /dev/null +++ b/keyers.h @@ -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); diff --git a/polybuzzer.cpp b/polybuzzer.cpp new file mode 100644 index 0000000..2985632 --- /dev/null +++ b/polybuzzer.cpp @@ -0,0 +1,44 @@ +#include +#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(); +} + diff --git a/polybuzzer.h b/polybuzzer.h new file mode 100644 index 0000000..2b50065 --- /dev/null +++ b/polybuzzer.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#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); +}; diff --git a/vail-adapter.ino b/vail-adapter.ino index 496ef4f..f591b54 100644 --- a/vail-adapter.ino +++ b/vail-adapter.ino @@ -8,6 +8,7 @@ #include #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); } } diff --git a/webhid-test.html b/webhid-test.html new file mode 100644 index 0000000..986887b --- /dev/null +++ b/webhid-test.html @@ -0,0 +1,53 @@ + + + + + + + + +