A data-driven typing analysis tool for optimizing Home Row Modifiers (HRMs) on ZMK keyboards.
Tune your keyboard's timing parameters based on YOUR actual typing behavior, not generic defaults. Works with Glove80, Piantor, Corne, and any ZMK-powered keyboard.
Credits: Based on Chih-Yuan Huang's InputLogger, heavily modified for HRM timing analysis and modern Python environments.
- What Are Home Row Modifiers?
- What This Tool Does
- Quick Start
- Detailed Setup
- Usage Workflows
- Understanding the Analysis
- Applying Recommendations to ZMK
- Project Structure
- Troubleshooting
- Advanced Topics
- Real-World Example
- Contributing
- License
- Additional Resources
Home Row Modifiers (HRMs) let you use your home row keys (A, S, D, F, J, K, L, ;) as both regular letters AND modifier keys (Shift, Ctrl, Alt, Cmd):
- Tap the key quickly → Types the letter (e.g., 'f' or 'j')
- Hold the key down → Activates modifier (e.g., Shift)
- Ergonomic: Eliminates reaching for modifier keys
- Efficient: Keeps fingers on home row
- Powerful: Can assign modifiers to any key, not just traditional positions
Getting the timing right is critical:
- Too fast: Normal typing accidentally triggers modifiers → "jgood" when you meant "just good"
- Too slow: Intentional holds don't activate → Missing capital letters
- Space bar issues: Holding space for layer access accidentally triggers symbols
This tool solves this by analyzing YOUR typing to find YOUR perfect timing.
-
High-Precision Logging
- Records every keypress/release with microsecond timestamps
- Captures your natural typing rhythm without interference
-
Statistical Analysis
- Separates quick taps from intentional holds
- Calculates 95th percentile for safe thresholds
- Identifies overlapping tap/hold distributions (problem areas)
-
Personalized Recommendations
- Suggests ZMK timing values based on YOUR data
- Recommends
tapping-term-ms,quick-tap-ms,require-prior-idle-ms - Provides safe margins to prevent misfires
-
Two Analysis Modes
- Simple Analysis: Basic per-key statistics (good for general tuning)
- HRM Analysis: Advanced mode that separates pure taps from HRM holds
The fastest way to get started:
./setup.shThis creates a virtual environment and installs dependencies.
./quick-start.shThis script:
- Cleans old logs
- Starts the keyboard logger
- Displays the typing test script
- Waits for you to type
- Analyzes your data
- Shows recommendations
Open a text editor and type through the displayed script. The script includes:
- Normal words with HRM keys (f, j)
- Intentional capital letters using HRM holds
- Space + key combinations for quotes
- Fast "flow state" typing to reveal timing issues
Press Control-C when finished.
The analysis will show:
- Current vs recommended timing values
- Statistical breakdown of your typing
- Specific ZMK configuration snippets to copy
- Operating System: macOS (tested), Linux (should work), Windows (may need adjustments)
- Python: 3.8 or newer
- Keyboard: Any ZMK-based keyboard (Glove80, Piantor, Corne, etc.)
- Permissions: Accessibility/Input Monitoring access (macOS will prompt)
If you prefer manual setup over ./setup.sh:
- Clone the repository:
git clone https://github.com/dsifry/hrm-tuner.git
cd hrm-tuner- Create virtual environment:
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate- Install dependencies:
pip install -r requirements.txtWhen you first run the logger, macOS will ask for Input Monitoring permissions:
- Go to System Preferences → Privacy & Security → Input Monitoring
- Enable access for Terminal or iTerm (whichever you're using)
- Restart your terminal
Best for: First-time users, quick analysis
./quick-start.shFollow the on-screen instructions.
Best for: Advanced users, custom testing
source venv/bin/activate
python3 main.py startThe logger runs in the background, saving data to ./log/ every 30 seconds.
cat TYPING-SCRIPT-HRMThis shows 12 sections testing different HRM patterns.
Open a text editor (not your terminal!) and type through the script. Focus on:
- Typing naturally at your normal speed
- Don't worry about typos (we only care about timing!)
- Complete all 12 sections for comprehensive data
Press Control-C in the terminal running the logger.
Option A: HRM-Specific Analysis (Recommended)
python3 hrmAnalysis.py [--verbose]This separates pure taps from HRM holds for accurate recommendations.
Available flags:
--verbose: Include detailed explanations in output
Option B: Simple Analysis
python3 simpleAnanlysis.py --verboseAvailable flags:
--aggressive: Suggests lower timing values (snappier, more risk)--zmk: Outputs ZMK behavior binding format--verbose: Includes detailed comments--no-explanation: Suppresses explanatory text
Best for: Testing specific timing changes
- Make changes to your ZMK keymap
- Flash to keyboard
- Start logger:
python3 main.py start - Type naturally for 5-10 minutes
- Stop logger (Control-C)
- Run analysis:
python3 hrmAnalysis.py - Compare before/after results
The advanced analysis shows:
Key 'f' Pure Taps (n=127):
Average: 28.3ms
Std Dev: 12.1ms
Min: 8.2ms
Max: 67.4ms
95th percentile: 48.9ms
What this means:
- When you tap 'f' normally (not using it as Shift), you release it within 48.9ms 95% of the time
- Your
tapping-term-msshould be higher than this (e.g., 150ms gives safe margin)
Key 'j' as Shift (n=45):
Activation time (press 'j' → press next key):
Average: 89.2ms
Min: 52.3ms
5th percentile: 58.7ms
What this means:
- When intentionally using 'j' as Shift, you typically press the next key 89ms later
- Your
tapping-term-msof 150ms works (gives you 60ms+ buffer)
⚠️ WARNING: Overlapping distributions detected for 'f'
Max tap: 67.4ms
Min hold activation: 52.3ms
Recommendation: Use 'balanced' flavor or increase tapping-term-ms
What this means:
- Sometimes your fast taps overlap with slow holds (problematic!)
- Solution: Switch from "tap-preferred" to "balanced" flavor
Recommended ZMK Configuration:
INDEX_HOLDING_TIME = 150ms // tapping-term-ms for f/j
INDEX_HOLDING_TYPE = "balanced" // Flavor
INDEX_STREAK_DECAY = 50ms // require-prior-idle-ms
SPACE_HOLDING_TIME = 200ms // For space layer access
Shows basic statistics for all keys:
Key Statistics:
'f': avg=45.2ms, std=15.3ms, min=12.1ms, max=127.8ms
'j': avg=38.7ms, std=18.2ms, min=9.4ms, max=156.3ms
'SPACE': avg=2.6ms, std=8.1ms, min=0.8ms, max=105.2ms
Good for: Quick overview, general tuning
Your ZMK keymap defines HRM behavior with these parameters:
What it does: Time threshold separating taps from holds
&mt LSHIFT F // hold-tap: Left Shift when held, 'f' when tapped
tapping-term-ms = <150>; // 150ms threshold- Too low (e.g., 100ms): Normal typing triggers modifiers
- Too high (e.g., 300ms): Intentional holds feel sluggish
- Recommendation: Use your 95th percentile tap time + 50-100ms margin
What it does: Allows repeating keys without triggering hold
quick-tap-ms = <200>;Example: Typing "ffff" quickly
- Without
quick-tap-ms: Might trigger shift after first 'f' - With
quick-tap-ms = 200: Treats rapid repeats as taps
What it does: Prevents activation during fast rolling/sliding
require-prior-idle-ms = <50>;Example: Typing "just" quickly with finger roll
- Without
require-prior-idle-ms: 'j' might activate as Shift → "Just" - With
require-prior-idle-ms = 50: 'j' stays as letter if previous key was <50ms ago
Options:
tap-preferred: Waits for key release to decide (laggy)balanced: Decides on next keypress (recommended!)hold-preferred: Activates hold quickly (aggressive)
flavor = "balanced";Based on analysis results, your keymap might look like:
// Timing constants (top of keymap)
#define TAPPING_RESOLUTION 200
#define INDEX_HOLDING_TIME 150 // f/j keys
#define INDEX_HOLDING_TYPE "balanced"
#define INDEX_STREAK_DECAY 50 // require-prior-idle-ms
#define SPACE_HOLDING_TIME 200 // space bar
// Behavior definitions
/ {
behaviors {
// Right Index HRM (j key)
RightIndex: right_index_hrm {
compatible = "zmk,behavior-hold-tap";
#binding-cells = <2>;
flavor = INDEX_HOLDING_TYPE;
tapping-term-ms = <INDEX_HOLDING_TIME>;
quick-tap-ms = <TAPPING_RESOLUTION>;
require-prior-idle-ms = <INDEX_STREAK_DECAY>;
bindings = <&kp>, <&kp>;
hold-trigger-key-positions = <LEFT_HAND_KEYS>;
};
};
};| File | Purpose |
|---|---|
setup.sh |
One-time setup script (creates venv, installs deps) |
quick-start.sh |
Automated workflow (start logger → analyze → report) |
main.py |
Starts/stops keyboard logger |
hrmAnalysis.py |
Advanced HRM analysis (separates taps from holds) |
simpleAnanlysis.py |
Basic per-key statistics |
TYPING-SCRIPT-HRM |
Comprehensive 12-part test script for HRMs |
TYPING-SCRIPT |
Original generic typing test |
requirements.txt |
Python dependencies |
| File | Purpose |
|---|---|
keyboard_logger.py |
Core logging logic (pynput-based) |
input_logger.py |
Base class for loggers |
constants.py |
Configuration constants |
log.py |
Log file I/O |
utils.py |
Helper functions |
| File | Purpose |
|---|---|
START-HERE.md |
Quick reference guide with workflow overview |
ANALYSIS-PLAN.md |
Development notes and analysis methodology |
README-TESTING.md |
Testing methodology documentation |
RUN-ME.md |
Simple workflow instructions |
TYPING-INSTRUCTIONS.md |
Detailed guidelines for typing tests |
| Path | Contents |
|---|---|
log/*.json |
Raw keystroke logs (timestamped) |
venv/ |
Python virtual environment |
Cause: macOS hasn't granted Input Monitoring permissions
Fix:
- Open System Preferences → Privacy & Security → Input Monitoring
- Click the lock icon and authenticate
- Enable checkbox for Terminal (or iTerm, etc.)
- Fully quit and restart your terminal app
- Run the logger again
Cause: Need to use python3 on macOS/Linux
Fix: Always use python3 instead of python:
python3 main.py start
python3 hrmAnalysis.pyOr activate the virtual environment first:
source venv/bin/activate
python main.py start # Now 'python' worksPossible causes:
- Permissions not granted (see above)
- Not running in background properly
Fix: Use main.py instead of calling keyboard_logger.py directly:
python3 main.py start # CorrectCause: You didn't type enough samples of that key
Fix: Type more of that key naturally. The test script includes specific sections for each HRM key.
Cause: No HRM hold data captured for that key
Impact: Minor - the script still outputs useful data before crashing
Workaround: Use simpleAnanlysis.py for basic stats, or ensure you type intentional capitals using the HRM in the test.
- Behavior: Waits for key release before deciding
- Pros: Very safe, rarely misfires
- Cons: Feels laggy, delays output
- Use case: Very fast typists who need maximum safety
- Behavior: Decides when next key is pressed
- Pros: Responsive, minimal lag, works for most
- Cons: Requires good timing tuning
- Use case: Most users (default recommendation)
- Behavior: Activates hold quickly if held past threshold
- Pros: Very snappy modifier activation
- Cons: More prone to misfires during fast typing
- Use case: Deliberate typers, gaming
Bilateral Hold-Tap:
- Only activates modifier when used with opposite hand
- Example: 'f' as Shift only works with right-hand keys
hold-trigger-key-positions = <RIGHT_HAND_KEYS>;Why use it:
- Prevents "jjjj" from triggering shift
- Safer for same-hand rolls
- Establish Baseline: Run initial analysis, note current misfire rate
- Make Small Changes: Adjust one parameter at a time (±20ms)
- Test Extensively: Type naturally for 30+ minutes
- Re-analyze: Run hrmAnalysis.py, compare statistics
- Iterate: Fine-tune based on results
Why not use max/min?
- Outliers (accidental long presses, hardware glitches) skew data
- 95th percentile = "95% of your typing is faster than this"
- Provides safe threshold while ignoring outliers
Example:
Tap times: [20ms, 22ms, 25ms, 28ms, 30ms, ..., 150ms (outlier)]
Max: 150ms (misleading!)
95th percentile: 48ms (realistic)
Keymap Settings:
INDEX_HOLDING_TIME = 220ms
INDEX_HOLDING_TYPE = "tap-preferred"
SPACE_HOLDING_TIME = 220msProblems:
- Typing "jgood" → "jgood" (not "Good" - modifier didn't activate)
- Space+I accidentally triggers cursor up
- Feels laggy waiting for key release
Your Data:
'j' pure taps: avg 23.5ms, 95th percentile 45ms
'j' as Shift activation: avg 89ms, min 52ms
'space' taps: avg 2.6ms, max 90.4ms
New Settings:
INDEX_HOLDING_TIME = 150ms // Well above 45ms tap threshold
INDEX_HOLDING_TYPE = "balanced" // Decides on next keypress
INDEX_STREAK_DECAY = 50ms // Prevents rolls
SPACE_HOLDING_TIME = 200ms // Above 90ms max tapResults:
- "jgood" → "Good" ✓ (modifier activates reliably)
- Space+I → "i" (not cursor movement) ✓
- Feels snappy and responsive ✓
Found a bug? Have a feature request?
- Open an issue at: https://github.com/dsifry/hrm-tuner/issues
- Include your OS, Python version, and keyboard model
- Attach relevant log files or analysis output
Pull requests welcome!
MIT License - see LICENSE.md
Original work: Chih-Yuan Huang (InputLogger) HRM analysis & modifications: Dave Sifry
Permission granted by original author to fork and modify under MIT License.
- ZMK Documentation: https://zmk.dev/docs/behaviors/hold-tap
- Glove80 Layout Editor: https://my.glove80.com
- HRM Guide by Precondition: https://precondition.github.io/home-row-mods
- ZMK Discord: https://zmk.dev/community/discord/invite
Happy typing! 🎹⌨️