Follow

Split keyboard firmware

A Sofle RGB running Miryoku, with my own hardware tweaks — and the typing layout behind this site's copy

My daily driver is a Sofle RGB split keyboard, and it runs my own QMK userspace: the vendored Miryoku layer definitions plus personal hardware customisations layered on top.

The repository is a qmk_userspace — keymap, layer mapping, hardware support and the qmk.json build targets, with CI building the firmware.


FactDetail
HardwareSofle rev1, RGB, split
LayoutMiryoku (36-key home-row mods)
StackQMK / C
StartedSeptember 2026

What's in it

Miryoku, vendored and extended

Upstream layer definitions vendored, then adjusted where my hands disagreed with the defaults.

Built in CI

qmk.json targets plus a GitHub Actions workflow, so a firmware build does not depend on my laptop.

In detail

What is actually inside it

Miryoku, vendored not forked

.gitmodules pins the Miryoku userspace as a submodule: upstream improvements arrive as a submodule bump instead of a merge conflict in copied code.

Working notes in the repository

AGENTS.md documents the layout and the build for whoever works on it next — including the next version of me.

Layers over keycaps

Home-row modifiers and layer selection mean the layout, not the keyboard, does the work — the hardware is replaceable, the muscle memory is not.

DetailValue
BaseSofle rev1, RGB, split
LayoutMiryoku (36 keys, home-row mods)
StructureQMK userspace + Miryoku submodule

Technical notes

The decisions behind it

Why Miryoku?
It reduces hand movement to almost nothing: everything is reachable from the home row through layers. The cost is a learning curve; the payoff is not moving your hands all day.
Why keep the customisations so thin?
So upstream can move. Anything I could express in my own keymap instead of editing vendored code went there.

By the numbers

The constraints the project actually lives within

10
Layers
Base, Extra, Tap, Button, Nav, Mouse, Media, Num, Sym and Fun, resolved at compile time from MIRYOKU_LAYER_LIST. The OLED prints the active layer by iterating that same list, so its label cannot drift from the layout.
36
Keys Miryoku uses
of the Sofle's 60 — a 3×5 grid plus three thumbs per hand. The number row, the outer columns and the outer thumbs are KC_NO, apart from two right-hand keys that carry the German umlauts.
27654
Bytes of flash
of 28,672 on the ATmega32u4: 96%, 1,018 free. The dfu transcript from a later build shows the same ceiling (0x6C80 programmed, 96.88% of 0x7000). This is the binding constraint on every change.
72
WS2812 LEDs
36 per half, 58 of them under keys, driven by RGB Matrix rather than RGBLIGHT so the keypress-reactive effects have per-key positions. RGBLIGHT_ENABLE = no is stated explicitly, because enabling both overflows flash by 964 bytes.
200
Tapping term (ms)
Miryoku's home-row mods fire after 200 ms, with QUICK_TAP_TERM 0 — a tap can switch straight into a hold, and double-tap-hold auto-repeat is disabled. The defaults were kept; the ordering of the mods is what mattered.
1
Build target
["sofle/rev1", "miryoku"] — the single entry in qmk.json that qmk userspace-compile and CI build, published as a rolling latest GitHub Release.

The decisions that took the longest

Two of these cost a debugging session

The alphas are QWERTY, but the keyboard is German — isn't that backwards?

No, and it is the least obvious thing in the repo. HID usages are positions, not letters: the firmware sends 0x1C for the key at the US y position and the host layout decides the character. A German layout already maps 0x1C → z and 0x1D → y, so QWERTY alphas plus a de host give German-correct output — the key labelled y types z.

Miryoku's MIRYOKU_ALPHAS=QWERTZ moves the usages to the positions a German keyboard has them, so on a German host the two swaps cancel and every key types its US legend (verified 2026-09-28: "y still prints y, z still prints z"). QWERTZ alphas is therefore the setting for a US host, and wrong here. The keymap's rules.mk pins MIRYOKU_ALPHAS = QWERTY so local builds and CI agree, and any build can still override it on the command line.

Why is the tapping term still Miryoku's 200 ms?
Because the failures people hit with home-row mods are usually about ordering, not timing. The mods run outward-in as LGUI_T, LALT_T, LCTL_T, LSFT_T — so Shift, the modifier used constantly, sits under the strongest finger (index), and GUI, the rarest, under the weakest (pinky). QUICK_TAP_TERM 0 is the deliberate part: it enables the rapid switch from tap to hold and disables the double-tap-hold auto-repeat. AltGr is exposed as ALGR_T(KC_X) and ALGR_T(KC_DOT), which a German host needs for @, € and the like. All six thumbs are layer-taps — LT(U_NAV,KC_SPC), LT(U_SYM,KC_ENT), LT(U_NUM,KC_BSPC) and so on — so every layer is reachable without leaving the home position.
Why is Miryoku vendored into the tree instead of pinned as a submodule?

Because there is still no upstream-maintained QMK userspace port (miryoku#287), and a submodule pointing at a fork's branch would be a worse pin than a copy you can diff and review. users/manna-harbour_miryoku/ is a verbatim copy of the working port from yehorb/qmk_userspace at 2a827f9d (2026-05-27).

The rule is that this tree is never hand-edited — it is third-party code with its own licence, and the only local change inside it is a one-line layer substitution in custom_config.h, the hook Miryoku provides for exactly that. Re-vendoring means diffing the copy against upstream and recording the new commit in the README.

What breaks first when you add something?

Flash, always. The ATmega32u4 gives 28,672 bytes and the build already sits at 27,654 — 96%, 1,018 bytes free. The numbers that matter: RGB Matrix alone is 27,654, RGBLIGHT alone 26,890, and both together are 29,636, i.e. 964 bytes over the limit. Each extra RGB Matrix animation costs roughly 100–400 bytes.

It fits because of LTO_ENABLE = yes in the keymap's rules.mk plus trimming AUDIO_ENABLE, MAGIC_ENABLE, SPACE_CADET_ENABLE and GRAVE_ESC_ENABLE. The Sofle's own keyboard.json already enables the encoder, OLED, mouse keys and extra keys, so only unused core features are switched off.

Why can't config.h just include keymap_german.h?
Because config.h is pulled into the assembly translation units as well — platforms/avr/xprintf.S includes it — and keymap_german.h drags in keycodes.h, which the assembler rejects with Error: junk at end of line. So the Sofle→Miryoku mapping uses raw KC_* codes and documents the German rendering in comments instead of using QMK's DE_* aliases. QMK headers belong in a .c file, never in the config chain.
Both halves run the same firmware?

Yes — MASTER_LEFT and no EE_HANDS, so the identical artifact is flashed to each MCU over its own USB port, one at a time. The consequence is that the two halves are indistinguishable on USB (same VID/PID fc32:0287, no serial) and dfu-programmer simply waits for any ATmega32u4 in DFU mode. Forgetting to move the cable therefore flashes the same half twice — which happened on 2026-09-28.

The kernel log is the ground truth: Sofle → ATm32U4DFU → Sofle means a flash of the same board, while Sofle → Sofle with a new device number means the cable moved. Device numbers increase monotonically, so a cable move is easy to spot.

Pinned, vendored, built without my laptop

How the three inputs fit together, and what CI does with them

Three inputs, pinned separately. QMK itself is a shallow git submodule (.gitmodules sets shallow = true) at b1aea255 — a build input now, not a fork to maintain. Miryoku is deliberately not a submodule: it is a verbatim copy in users/manna-harbour_miryoku/, because no upstream userspace port exists yet and a fork's branch is a worse pin than a diffable copy.

The split is the whole design. Third-party code is never hand-edited; every local twist goes through custom_config.h, which is why the only change inside the vendored tree is one cell in the base layer. Bumping QMK is explicit — git fetch --depth 1 origin master && git checkout FETCH_HEAD inside the submodule, commit, then rebuild both paths and update the pin in the README.

There is a trap worth knowing: QMK_HOME defaults to ~/qmk_firmware, and while the old 2022 fork still lives there a bare qmk compile silently uses that tree. Both build paths pin QMK_HOME/QMK_USERSPACE explicitly, or go through the Makefile, for that reason.

.github/workflows/build_binaries.yaml contains almost no build logic: it calls QMK's reusable qmk_userspace_build.yml@main workflow, passing qmk_repo: qmk/qmk_firmware and qmk_ref: master.

The subtle part is that the reusable workflow only checks QMK out itself if qmk_firmware/requirements.txt is missing — and in this repo the pinned submodule is present, so CI builds against the pin, not against whatever master currently is. It runs qmk userspace-doctor, then qmk userspace-compile over the single qmk.json target inside the ghcr.io/qmk/qmk_cli container.

A second job always runs: it deletes the latest release, recreates it, and uploads every *.hex / *.bin / *.uf2 — so the firmware binaries are a rolling artifact, not a versioned one.