Hey! This repo combines my ZSA Moonlander's online Oryx layout with custom QMK firmware, a custom Windhawk mod, and CopyQ text tools. The Oryx web configurator is limited so I supplemented it with custom code. The additions:
- A 12-note chromatic MIDI piano layer
- Hebrew/English language-aware RGB: English keeps Oryx's green base colour; Hebrew substitutes the brown used by Layer 2's black MIDI keys while preserving individually coloured keys (requires the accompanying Windhawk mod)
- Smart Title Case and case switching functionality (requires the accompanying CopyQ commands)
- A language correction feature that transplants Hebrew characters to their physical English QWERTY equivalents, and vice versa (requires CopyQ). This fixes the problem of typing an entire sentence with the wrong input language active.
- Some custom keys and functions; for example, quickly double tapping the right space key outputs ". " just like on my mobile phone
The beauty of this setup is that it uses CI to build everything automatically, merging Oryx and my custom code cleanly and outputting a compiled .bin file to flash. Full credit to poulainpi for that functionality: https://github.com/poulainpi/oryx-with-custom-qmk
Open https://configure.zsa.io/moonlander/layouts/3aMQz/latest/0 to see the base layout — most custom keys have peach-colored labels that mark function-key placeholders which get replaced with real behavior by the patch script after the firmware builds.
The rest of this README is AI-generated technical documentation that explains each feature in depth. You can contact me at me@yiftah.com with questions or feedback.
Advanced MIDI keyboard, language-aware RGB, and Windows text automation — managed through Oryx with zero-merge-conflict CI/CD injection.
This repository provides custom QMK firmware for the ZSA Moonlander keyboard, combining:
- Layer-0 Keymap: Modified QWERTY with dual-function thumb keys, 3 tap-dances, and F-key placeholders for Windows text automation
- Language-Aware RGB: Hebrew/English indicator with Windows sync
- CopyQ Text Tools: Clipboard-protected Smart Title Case, case cycling, and Hebrew/English transplantation
- Windhawk Companion: Windows language switching and Hebrew/English RGB synchronization
- CI/CD Pipeline: Automated Oryx → patch → build → release workflow
- MIDI Engine (Layer 2): A 26-note polyphonic MIDI controller featuring independent melody/bass splits and a dynamic thumb-controlled transpose shifter
The key innovation: Oryx (ZSA's online layout editor) has no native MIDI support. Instead of manually merging generated keymap.c files, CI downloads fresh Oryx source, applies deterministic custom patches, builds in Docker, publishes a verified .bin, and then synchronizes the patched 3aMQz/ snapshot back to the repository. Custom logic remains separate from generated source, while the generated snapshot stays available for inspection and rollback.
Use this table at a glance when adding a new function:
| F-key | Status | Notes |
|---|---|---|
| F14, F15, F16, F17 | Free | No standard OS/browser shortcuts |
| F20, F21, F23, F24 | Free | Not emitted by the current firmware or assigned to a host tool; F24 appears only in legacy migration detection |
| F1–F12 | Reserved | Layer 1 function row (also all have standard OS/browser shortcuts for refresh, search, fullscreen, etc.) |
| F18 | Reserved | Language switch — wired to the left-thumb tap-dance key (k40) and Windhawk |
| F13 | Reserved | CopyQ Smart Title Case — right-space triple tap |
| F19 | Reserved | CopyQ upper/lower toggle — left-space triple tap |
| F22 | Reserved | CopyQ Hebrew/English transplantation — language-key double tap |
The 8 free keys above (F14–F17, F20, F21, F23, F24) are neither mapped in the current firmware nor assigned to these host tools.
Working-Oryx-QMK-Sync/
├── custom_qmk/ ← Canonical custom firmware (custom_code.c)
│ └── custom_code.c ← MIDI bass shifter and language RGB overlay
├── scripts/ ← Python patching engine
│ └── patch_keymap.py ← 11+ deterministic transformations injected into Oryx source
├── host_tools/copyq/ ← CopyQ transformations, protected transaction, installer, tests
├── host_tools/reselect/ ← .NET 10 grapheme-aware reselection helper
├── host_tools/windhawk/ ← Windows Windhawk mod (v2.0.1)
│ ├── moonlander_language_sync.wh.cpp ← F18 language switching + RGB sync only
│ └── deprecated/ ← Preserved v1.2.8 F19/F22 rollback source; do not delete on merge
├── .github/workflows/ ← CI: fetch Oryx → patch → build → release
│ └── fetch-and-build-layout.yml
├── 3aMQz/ ← Auto-synced patched layout snapshot (committed by workflow)
│ ├── keymap.c ← Oryx-generated + patched MIDI layer
│ ├── config.h ← MIDI_ADVANCED, low-latency settings
│ └── rules.mk ← MIDI_ENABLE = yes
├── Dockerfile ← Dated Debian arm-none-eabi QMK build container
└── qmk_firmware/ ← Ignored runtime-only ZSA QMK clone for local builds
A modified QWERTY layout with dual-function thumb keys (managed in Oryx, snapshot at 3aMQz/keymap.c). F18 is owned by Windhawk; F13/F19/F22 selected-text actions are owned by CopyQ.
| Left half | Right half |
|---|---|
ESC 1 2 3 4 5 = |
MEH_T(PageUp) 6 7 8 9 0 Home |
MEH_T: Hold activates MeH (Alt+Ctrl+Shift), tap sends the key.
| Left half | Right half |
|---|---|
DUAL_FUNC_0 Q W E R T - |
ALL_T(PgDn) Y U I O P End |
ALL_T: Hold activates ALL (Ctrl+Alt+Shift+Gui), tap sends the key.- DUAL_FUNC_0 (custom override): tap →
DELETE, hold →CTRL+DELETE
| Left half | Right half |
|---|---|
DUAL_FUNC_1 A S D F G ` |
TG(1) H J K L ; \ |
TG(1): Momentary toggle to Layer 1.- DUAL_FUNC_1 (custom override): tap →
BACKSPACE, hold →CTRL+BACKSPACE
| Left half | Right half |
|---|---|
Shift Z X C V B |
N M , . Up OSL(1) |
OSL(1): One-shot Layer 1 (next keypress only).
| Left half | Right half |
|---|---|
DANCE_0 Gui Alt [ ] MT(RAlt,Tab) |
DUAL_FUNC_2 ' / ← ↓ → |
MT(RAlt, Tab): Hold Right Alt, tap Tab.- DANCE_0 (language key): single tap →
F18, hold →LeftCtrl, second press → immediateF22transplantation - DUAL_FUNC_2 (custom override): tap →
ENTER, hold →SHIFT+ENTER
| Left cluster | Right cluster |
|---|---|
DANCE_1 MT(RCtrl,NO) MT(Shift+Ctrl,NO) |
Delete Backspace DANCE_2 |
MT(RCtrl, NO): Hold = Right Ctrl; taps do nothing.MT(Shift+Ctrl, NO): Hold = Left Shift + Left Ctrl; taps do nothing.- DANCE_1 (left space/caps): single tap →
SPACE, hold →LEFT_SHIFT, double tap →CAPS LOCK, third press → immediateF19 - DANCE_2 (right space/numdot): single tap →
SPACE, hold →SPACE, double tap →KP_DOT + SPACE, third press → immediateF13
Once a terminal text-tool action fires, surplus taps in the same tap-dance window are ignored. The internal timeout closes the gesture without emitting a delayed duplicate or turning an extra tap into Space, Caps Lock, period-space, or F18. A held terminal press still fires, matching normal key-press behavior. Oryx normally generates “repeat the ordinary tap three times” callbacks because its generic tap-dance exporter has no custom semantic for count 3; the patcher replaces that fallback for the language dance and both space dances.
Three keys have reduced tapping terms to favor tapping over holding during fast typing:
| Key | Override | Effect |
|---|---|---|
KC_I |
TAPPING_TERM - 70 |
Faster tap registration on the home-row letter I |
KC_DELETE |
TAPPING_TERM - 120 |
Faster tap on the Delete key in the thumb cluster |
KC_BSPC |
TAPPING_TERM - 120 |
Faster tap on the Backspace key in the thumb cluster |
The base layer keeps its normal Oryx colours for English. For Hebrew, only Oryx's dominant base colour is replaced with the brown assigned to Layer 2's ten black/accidental MIDI melody keys (currently HSV 12, 255, 255). State is synced from Windows over RAW HID using Oryx's ORYX_STATUS_LED_CONTROL command (0x0A):
- Param[0] =
0x00→ English - Param[0] =
0x01→ Hebrew
patch_keymap.py detects Layer 0's unique dominant non-black HSV triplet and reads the replacement from the ten semantic LED positions used by Layer 2's black MIDI melody keys. All ten source positions must agree on one non-black colour or patching fails. The Hebrew overlay changes only LEDs whose original Layer 0 colour exactly matches the detected base triplet, rendering the replacement through Oryx's normal brightness-aware HSV path. Individually assigned colours—including the language key colour—remain under Oryx control, Caps Lock still overrides its own key, and all higher-layer colours remain unchanged.
patch_keymap.py normalizes tap-dance handlers to fix tapping-term races:
SINGLE_HOLD→SINGLE_TAPfallbackDOUBLE_SINGLE_TAP→DOUBLE_TAP- Hold-preference on Space/Shift and F18 language dances
- Signature-detected immediate terminal actions (
F22on language press two,F19/F13on space press three), with build failure if any target cannot be identified safely
Host-key ownership is deliberately split:
| Hotkey | Firmware sources | Owner | Function |
|---|---|---|---|
| F13 | Right-space triple tap | CopyQ | Smart Title Case; finishes unselected with caret at end |
| F18 | Language-key single tap | Windhawk | Windows language switch |
| F19 | Left-space triple tap | CopyQ | lower ↔ UPPER; mixed case → UPPER; remains selected |
| F22 | Language-key double tap | CopyQ | Hebrew/English physical-key transplantation; finishes unselected |
Smart Title Case uses the configured small-word list (a, an, and, as, at, but, by, for, in, nor, of, on, or, per, so, the, to, up, via, vs, yet), while always capitalizing the first/last lexical word and the first word after a colon or dash. Ordinary words are normalized; acronym detection is intentionally omitted.
Transplantation follows Microsoft kbdhebl3 physical positions. Direction is chosen by the predominant recognizable alphabet. Lowercase English is mapped to Hebrew, uppercase A–Z is preserved, Hebrew maps back to lowercase English, and alphabet ties are no-ops. This preserves the useful pattern where an initial shifted English capital survives while the following accidental lowercase English becomes Hebrew.
CopyQ performs each transformation as a protected, non-blocking transaction: it snapshots every clipboard MIME format, disables history storage, captures and pastes the selection immediately, and restores monitoring. CopyQ then uses afterMilliseconds() to reselect F19 output after 35 ms and safely restore the original clipboard after 250 ms only if no newer copy has replaced the generated text. Generation guards make stale callbacks harmless during rapid repeats, so delayed restoration does not freeze the CopyQ UI. The .NET helper used by F19 receives UTF-8 text over standard input, counts Unicode grapheme clusters, and reselects with the caret at the right edge. It has no clipboard, network, administrator, tray, or persistent-process access.
See host_tools/copyq/README.md for build, staged installation, activation, acceptance checks, and rollback.
The v2 Windhawk source owns only F18 and RGB synchronization. Install or update it from host_tools/windhawk/moonlander_language_sync.wh.cpp, then use:
enableF18Hotkey = trueshortcutMode = 1(Win+Space),2(Alt+Shift),3(Ctrl+Shift), or0(none)pollIntervalMs = 120onlyMoonlander = true
The previous v1.2.8 implementation is preserved under host_tools/windhawk/deprecated/ and marked deprecated. It must remain available as the immediate rollback during and after merge; do not enable it simultaneously with active CopyQ F19/F22 shortcuts.
Windhawk sends an Oryx-native RAW HID command to sync Windows language state to keyboard RGB:
- Command:
ORYX_STATUS_LED_CONTROL(0x0A) - Payload:
param[0] = 0x00(English),0x01(Hebrew) - Transport: raw HID output report to any ZSA Moonlander device (filtered by manufacturer + product string)
- Firmware reads mirrored state from
rawhid_state.status_led_controlincustom_qmk/custom_code.c
The language RGB overlay only responds on Layer 0; MIDI and other layers use Oryx-configured per-layer colours. The language key has no firmware colour override and can be assigned a fixed colour directly in Oryx.
Troubleshooting:
- Language switches but RGB does not update: set
debugLogging = trueand check Windhawk's log; temporarily setonlyMoonlander = falseto test broader HID matching. - F18 should not trigger Windows language shortcut: set
enableF18Hotkey = false. Keyboard-side RGB sync still runs. - Different Windows shortcut preferred:
shortcutMode=1Win+Space (recommended),2Alt+Shift,3Ctrl+Shift,0None.
Layer 1 and Layer 3 carry Unicode and editor macros. Highlights:
| Macros | Purpose |
|---|---|
ST_MACRO_0 |
Unicode U+20AC (Euro €) + Enter |
ST_MACRO_1 |
Cut (Ctrl+X) + type B |
ST_MACRO_5–ST_MACRO_11 |
Various Ctrl+X/zoom/search shortcuts |
ST_MACRO_12, ST_MACRO_13 |
Alt-code input for U+002E (period) |
ST_MACRO_14–ST_MACRO_17 |
Unicode U+002E input variants |
These macros include a deliberate 100ms delay (SS_DELAY(100)) between keystrokes to ensure OS registration; they can be optimized to 10ms or 0ms in config.h if faster output is desired.
Two octaves of polyphonic MIDI input with split melody/bass and a transpose shifter. Uses MIDI_ADVANCED (not MIDI_BASIC) so note keycodes route through process_midi() which decodes by keycode value — required for the bass shifter to work correctly.
Melody (Row 2, natural/white keys):
| Hand | Keys | Notes |
|---|---|---|
| Left | k20–k26 | C3 D3 E3 F3 G3 A3 B3 |
| Right | k27–k2d | C4 D4 E4 F4 G4 A4 B4 |
Sharps/flats (Row 1, biased LEFT so each accidental sits directly above the natural it sharpens):
| Hand | Notes |
|---|---|
| Left (octave 3) | C#3 D#3 F#3 G#3 A#3 |
| Right (octave 4) | Db4 Eb4 Gb4 Ab4 Bb4 (enharmonic = C#4 D#4 F#4 G#4 A#4) |
Bass (Rows 3–4 + thumb cluster, left hand only; full chromatic C2–B2):
| Row | Keys | Notes |
|---|---|---|
| Row 3 (BASS1–6) | k30–k35 | C2 C#2 D2 D#2 E2 F2 |
| Row 4 (BASS7–11) | k40–k44 | F#2 G2 G#2 A2 A#2 |
| Row 5 (BASS12) | k50 | B2 (left thumb cluster, one semitone below BASS1 root) |
Three keys share the left thumb cluster on Layer 2: BASS12 (k50, the 12th chromatic bass note B2), BASS_up (k51), and BASS_down (k52). The two shifter keys each tap to transpose all bass notes ±1 semitone (free transpose across octaves, clamped −24..+24). Melody keys are in a higher keycode range and are never affected.
Implementation: The firmware intercepts bass keys by MIDI keycode range (MI_C2..MI_B2), not by matrix position. On press, the shifter forwards a transposed note keycode to process_midi(), which emits the real MIDI note. Each held key snapshots its shifted note keycode so that a shift change mid-hold cannot strand a stuck note.
Note on pitch: Under QMK MIDI, the keycode octave is relative to the global
midi_config.octave(default putsMI_Cat note 48 / C3). Keycode names describe note relationships; absolute pitch follows the global octave. The firmware setsmidi_config.octave = 1at init soMI_C2sounds as C2 (not C5).
MIDI_ENABLE = yesinrules.mk#define MIDI_ADVANCEDinconfig.hDEBOUNCE_TYPE = sym_eager_pk,DEBOUNCE = 5, andUSB_POLLING_INTERVAL_MS = 1for low-latency MIDIQMK_KEYS_PER_SCAN = 12for instant polyphonic MIDI chords
Triggered manually via Actions → Fetch and build layout (fetch-and-build-layout.yml).
Parameters: Layout ID (3aMQz), geometry (moonlander/reva).
Steps:
- Validates the requested layout and Oryx response—including Oryx's integer-valued QMK strings such as
"25.0"—then downloads and checks the source ZIP. - Runs the repository test suite before resolving QMK.
- Applies
scripts/patch_keymap.pytwice and requires byte-identical patched trees. - Checks out the exact
firmware<version>ZSA QMK branch at a recorded commit; missing branches fail instead of falling back tomaster. - Pulls the dated base image explicitly, builds the QMK container, and records the resolved base-image digest, compiler, and Python versions.
- Requires exactly one non-empty firmware candidate and its matching ELF.
- Publishes a unique, non-overwriting release containing the
.bin, its SHA-256 checksum, and a provenance manifest. - Downloads the published assets and verifies their names and checksum before synchronizing
3aMQz/.
- Build passed → the source compiled successfully.
- CI-verified candidate → tests, patch idempotence, exact QMK identity, closed artifact inventory, manifest, checksum, publication, and downloaded-asset verification all passed.
- Hardware accepted → Wally, Keymapp, or ZSA's flasher successfully flashed that exact
.bin, and the Moonlander reconnected and operated normally.
A green workflow deliberately stops at CI-verified candidate. GitHub Actions cannot prove that the physical keyboard accepted the image.
The first complete run of the hardened pipeline passed on 2026-07-21: workflow run 29808688464. It built Oryx revision v6Grvl with ZSA QMK firmware25 at commit 717745325af7e8c92a1ec78faaa9abf8db321d5e, then published and downloaded the exact three-asset release for verification:
- Release and flashable firmware
- Firmware:
zsa_moonlander_reva_3aMQz.bin— 55,872 bytes, SHA-25605b5a9a40b1fd9d4a23f17e09e73af59aacba35ac9a8eabaf8bab586ddc7440b - Provenance:
zsa_moonlander_reva_3aMQz.bin.manifest.json - Portable checksum:
zsa_moonlander_reva_3aMQz.bin.sha256
This receipt proves the complete CI path for the then-current turquoise Hebrew overlay, immediate thumb-tap triggers, release inventory, and downloaded checksum. It is retained as a historical pipeline reference, but it does not verify the newer brown black-MIDI-key colour source. The next workflow run must create a new CI-verified candidate for that change, and hardware acceptance still requires flashing and testing that exact new binary.
What the patch script injects:
- MIDI custom-keycode enum near top of
keymap.c - Layer 2 MIDI keycodes (preserving user-added keys like RGB toggles)
#define MIDI_ADVANCEDinconfig.hMIDI_ENABLE = yesinrules.mk- Low-latency settings (
DEBOUNCE_TYPE sym_eager_pk,DEBOUNCE 5,USB_POLLING_INTERVAL_MS 1,QMK_KEYS_PER_SCAN 12) - MIDI octave fix (
midi_config.octave = 1inkeyboard_post_init_user) - Tap-dance stabilization patches
- Language RGB indicator hook
- Hardware: ZSA Moonlander keyboard
- Firmware flashing: Wally or ZSA Keymapp
- GitHub account: for Actions (CI builds)
- Windhawk (Windows-only, optional): https://windhawk.net/ — install
moonlander_language_sync.wh.cpp - Docker (for local builds; not needed for CI-only usage)
- Edit your layout in Oryx (https://configure.zsa.io/)
- Trigger Actions → Fetch and build layout on GitHub
- Wait ~3-5 minutes for the build
git pullto get the updated3aMQz/folder- Flash the
.binfrom the release artifacts
- Edit
custom_qmk/custom_code.c - Commit and push
- The next workflow run will copy your changes into the build
- Edit
scripts/patch_keymap.py - Test locally:
python3 scripts/patch_keymap.py 3aMQz - Commit and push
- The next workflow run will use your updated patch
# From the repository root, reproduce the QMK input recorded by the
# 2026-07-21 reference manifest. Use a later manifest's identities when
# reproducing a later release.
git init qmk_firmware
git -C qmk_firmware remote add origin https://github.com/zsa/qmk_firmware.git
git -C qmk_firmware fetch --depth 1 origin 717745325af7e8c92a1ec78faaa9abf8db321d5e
git -C qmk_firmware checkout --detach 717745325af7e8c92a1ec78faaa9abf8db321d5e
git -C qmk_firmware submodule update --init --recursive --depth 1
mkdir -p qmk_firmware/keyboards/zsa/moonlander/reva/keymaps
cp -r 3aMQz qmk_firmware/keyboards/zsa/moonlander/reva/keymaps/
docker pull debian:bookworm-20260713-slim
docker build --pull --tag moonlander-qmk:local .
docker run --rm --volume "$PWD/qmk_firmware:/root" moonlander-qmk:local \
/bin/sh -c 'make zsa/moonlander/reva:3aMQz'Investigation of QMK source confirmed:
- MIDI_BASIC routes note keycodes through
process_music(), which (a) requires MIDI mode toggled ON (MI_ON) and (b) derives the note from matrix position, ignoring the note keycode entirely.register_code16()never emits MIDI. - MIDI_ADVANCED routes note keycodes through
process_midi(), which decodes the note by keycode value (midi_compute_note) and tracks note-on/off. No mode toggle needed.
Decision: Use MIDI_ADVANCED (strict superset; future-proof). The bass shifter forwards a transposed note keycode to process_midi(), keeping the per-key snapshot so press/release use the same shifted keycode (no stuck notes).
F19 is emitted only by the left-space triple tap. F22 is emitted only by the language-key double tap. The two adjacent modifier-only thumb keys use KC_NO as their tap action, so tapping or double tapping them does nothing. F13 is assigned to right-space Smart Title Case. CopyQ owns all three selected-text hotkeys so they share one protected clipboard transaction.
F18 remains separate because it is not a selected-text action: Windhawk converts it to the configured Windows language shortcut and synchronizes RGB state.
The original patch script replaced the entire Layer 2 keymap, which erased user-configured keys from Oryx (such as RGB or layer toggles). The modern parser selectively merges QMK MIDI notes into designated matrix positions, leaving custom Oryx layout adjustments untouched.
- Ensure Windhawk is installed and the mod is enabled
- Check that F18 is emitted by DANCE_0 (left thumb)
- Ordinary language-state checks use the configured background interval (120 ms by default). After F18, the mod checks every 10 ms for up to 250 ms and sends RGB only after Windows confirms the new language.
- The mod targets
explorer.exe; if another app has focus the window-level shortcut may not fire
- Confirm CopyQ is running and the three exact
Moonlander:commands are enabled. - If F19 or F22 cannot be registered, update the installed Windhawk mod to v2 before activating CopyQ shortcuts; the old mod owns those keys.
- Stage without shortcuts first and run each command manually from CopyQ.
- Do not invoke a transformation without selected text in a terminal where
Ctrl+Cwould interrupt a process.
The firmware sets midi_config.octave = 1 at init. If notes are still wrong, check your DAW's MIDI channel mapping.
The firmware uses DEBOUNCE_TYPE sym_eager_pk, DEBOUNCE 5, and USB_POLLING_INTERVAL_MS 1. If latency persists:
- Disable RGB effects on the MIDI layer (use the RGB_TOG key you added)
- Reduce your DAW's audio buffer size (128 or 256 samples)
- Use ASIO drivers on Windows
The Moonlander with MIDI + RAW HID + NKRO can exceed the STM32F303's USB endpoint limit. The firmware already disables MOUSEKEY_ENABLE and CONSOLE_ENABLE to free endpoints. If it still fails, you may need to disable additional features.
This project is provided as-is for personal use. QMK firmware is licensed under GPL-2.0.
- ZSA Technology Labs for the Moonlander and Oryx
- QMK community for the firmware framework
- Microsoft for the kbdhebl3 Hebrew keyboard layout specification