1. はじめに
新しいキーボードAIM1 瞬を買いました。最近のキーボードはラピッドトリガーなる機能が搭載されているものが増えてきており、このAIM1瞬にも搭載されています。これはキーが押し込まれて戻ったときに、設定した長さ(例えば0.1mm)のキー上昇を検知すると即座にキー入力解除とみなすものです。
この機能の実現のため、ラピッドトリガー機能搭載キーボードにはキーストロークをアナログ量として計測可能なセンサー(磁気スイッチなど)が搭載されており、AIM1瞬の場合、キーストロークを設定ソフトから見ることができます。

ここで思いつきました。通常のキーボード入力はON/OFFの二値入力ですが、ゲームパッドのスティックのようにアナログ入力が可能になれば、レースゲームやFPSでより細やかな操作ができるようになるはずです。
先に結果から。
実際の動作の様子がこちらです。押し込み量に比例してステアリングが切れています。
2. 先行例調査
調べたらそのままな機能がありました。しかし、メーカー独自ソフトのようで、別のキーボードには適用できません。
PC Watch: アナログ入力対応のメカニカルスイッチ搭載ゲーミングキーボード
「Wooting oneは、アナログ入力対応のメカニカルスイッチを搭載したテンキーレスキーボード。通常のメカニカルスイッチは、オン/オフの2値でキーの入力を判定しているが、本キーボードではキーが押されていない状態から底まで押されている状態を0~100のアナログ入力として認識できる。
これにより、カーレースなどキーボードでは操作感に難があったゲームや、シューティングなどでゆっくりとキャラクターを移動させるなどの従来は実現が難しかった操作をキーボードで実現できるとする。」
3. 実装
磁気式スイッチ搭載キーボードのWASDキーのストロークを取得し、仮想ゲームパッドを作成して左スティックに割り当てます。以下の流れで実装しました。
(1)キーボードのHIDデバイスパスの特定
(2)キー入力、キーストローク量の取得
(3)仮想ゲームパッドの作成
(4)ゲームへの適用
3-0. Python仮想環境と必要モジュールのインストール
Pythonで実装するので適当に環境構築
conda create -n gamepad python=3.11
conda activate gamepad
pip install jupyterlab
pip install hidで入れたモジュールはhidapiをロードできない問題が起こったため、以下の記事を参考にインストールしました。
Stack Overflow: Can't load hidapi with Python library "hid" on Windows
pip install hidapi Cython
git clone https://github.com/trezor/cython-hidapi.git
cd cython-hidapi
git submodule update --init
python setup.py build
python setup.py install
pip install -e .
仮想ゲームパッド作成に必要なものも入れます。
ViGEmBusインストール
https://github.com/ViGEm/ViGEmBus/releases
pip install vgamepad
3-1. キーボードのHIDデバイスパスの特定
まずキーボードのHIDパスを特定します。
for d in hid.enumerate():
# if d['product_string'] == 'Gaming Keyboard':
print(d['product_string'])
print(d['usage_page'])
print(d['path'])
print()
実行すると色々と出てきます。この中でGaming Keyboardと書かれたもののパスを控えておきます。
Pulsar Xlite V3 Wired Medium
1
b'\\\\?\\HID#VID_3710&PID_1401&MI_01&Col01#8&2284b81a&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}\\KBD'
Gaming Keyboard
1
b'\\\\?\\HID#VID_3151&PID_5029&MI_00#8&169d358d&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}\\KBD'
Gaming Keyboard
12
b'\\\\?\\HID#VID_3151&PID_5029&MI_01&Col01#8&3a74734f&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}'
Gaming Keyboard
1
b'\\\\?\\HID#VID_3151&PID_5029&MI_01&Col02#8&3a74734f&0&0001#{4d1e55b2-f16f-11cf-88cb-001111000030}'
Gaming Keyboard
65535
b'\\\\?\\HID#VID_3151&PID_5029&MI_01&Col05#8&3a74734f&0&0004#{4d1e55b2-f16f-11cf-88cb-001111000030}'
Gaming Keyboard
65535
b'\\\\?\\HID#VID_3151&PID_5029&MI_02#8&2a7ebae1&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}'
Pulsar Xlite V3 Wired Medium
1
b'\\\\?\\HID#VID_3710&PID_1401&MI_01&Col02#8&2284b81a&0&0001#{4d1e55b2-f16f-11cf-88cb-001111000030}'
Pulsar Xlite V3 Wired Medium
12
b'\\\\?\\HID#VID_3710&PID_1401&MI_01&Col03#8&2284b81a&0&0002#{4d1e55b2-f16f-11cf-88cb-001111000030}'
Pulsar Xlite V3 Wired Medium
1
b'\\\\?\\HID#VID_3710&PID_1401&MI_02#8&ac12bd5&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}\\KBD'
Gaming Keyboard
1
b'\\\\?\\HID#VID_3151&PID_5029&MI_01&Col03#8&3a74734f&0&0002#{4d1e55b2-f16f-11cf-88cb-001111000030}\\KBD'
Pulsar Xlite V3 Wired Medium
65424
b'\\\\?\\HID#VID_3710&PID_1401&MI_03#8&2e986997&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}'
Pulsar Xlite V3 Wired Medium
1
b'\\\\?\\HID#VID_3710&PID_1401&MI_01&Col04#8&2284b81a&0&0003#{4d1e55b2-f16f-11cf-88cb-001111000030}'
Pulsar Xlite V3 Wired Medium
1
b'\\\\?\\HID#VID_3710&PID_1401&MI_01&Col05#8&2284b81a&0&0004#{4d1e55b2-f16f-11cf-88cb-001111000030}'
以下のようにHIDデバイス出力を読み取ります。
ここで、AIM1瞬は純正ソフトからキーストロークを可視化するトグルをONにしておかないとデータを取得できませんでした。自分でこのトリガーを送信するところまでやるのは面倒なので、キーストロークを取得するときは純正ソフトを起動しておくことにします。
import hid
import time
# メーカー純正ソフトを起動してSimulation demonstrationをONにしている状態なら見れる
path = b'\\\\?\\HID#VID_3151&PID_5029&MI_01&Col05#8&3a74734f&0&0004#{4d1e55b2-f16f-11cf-88cb-001111000030}'
# Gaming Keyboardを全部試す
h = hid.device()
h.open_path(path)
h.set_nonblocking(True)
while True:
data = h.read(64)
if data:
print(f'\r {len(data)} {data}', end='')
time.sleep(0.001)
実行するとこのようなデータが得られます。次にこのデータの意味を探ります。
32 [5, 27, 100, 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
3-2. キー入力、キーストローク量の取得
データを表示させながら、キーの押し込み量を変えてみます。すると次のようになりました。
32 [5, 27, 14, 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
32 [5, 27, 41, 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
32 [5, 27, 68, 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
32 [5, 27, 89, 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
32 [5, 27, 110, 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
32 [5, 27, 170, 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
32 [5, 27, 230, 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
32 [5, 27, 250, 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
32 [5, 27, 4, 1, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
32 [5, 27, 14, 1, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
32 [5, 27, 44, 1, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
32 [5, 27, 74, 1, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
32 [5, 27, 84, 1, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
ここから、3要素目が押し込み量に対応しており、255を超えると4要素目が1になることがわかります。
従って、キーストロークは次のように計算できます。
key_stroke = data[2] + data[3]*255
値の範囲は0-349となっていました。ストロークは約4mmなので、分解能は4mm/350≒0.011mmとなり、メーカー謳い文句の「キー入力のオン/オフ切替位置を0.01mm単位で調整可能」と符合します。合っていそうです。
次に入力キーを変えてデータを見ます。
# a を押しているとき
32 [5, 27, 110, 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
# b を押しているとき
32 [5, 27, 225, 0, 40, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
すると5要素目が変化しています。従って、データの5要素目を取得すれば何のキーを入力したかがわかります。
ここで入力と値のマップを作っておきます。
key_map = {
9:'a',
14:'w',
15:'s',
21:'d',
5:'Left Ctrl',
41:'Space',
}
これでAIM瞬からキー入力とキーストロークが得られるようになりました。
def encode_keydata(data):
key_stroke = data[2] + data[3]*255
pushed_key = key_map.get(data[4], None)
return pushed_key, key_stroke
3-3. 仮想ゲームパッドの作成
後は、キーストロークとジョイスティックの値の範囲を揃えて仮想ゲームパッドを作成するだけです。コードを貼ります。
import hid
import time
import vgamepad as vg
path = b'\\\\?\\HID#VID_3151&PID_5029&MI_01&Col05#8&3a74734f&0&0004#{4d1e55b2-f16f-11cf-88cb-001111000030}'
key_map = {
9:'a',
14:'w',
15:'s',
21:'d',
5:'Left Ctrl',
41:'Space',
}
def encode_keydata(data):
key_stroke = data[2] + data[3]*255
pushed_key = key_map.get(data[4], None)
return pushed_key, key_stroke
def norm(v):
# 押し込み値 → スティック値変換
# 押し込み量 0 - 349
# スティック値 -32768 ~ 32767
return int((v / 349) * 32767)
h = hid.device()
h.open_path(path)
h.set_nonblocking(True)
pad = vg.VX360Gamepad()
axes = {"x":0, "y":0}
while True:
data = h.read(64)
if data:
key, val = encode_keydata(data)
if key == "a":
axes["x"] = -norm(val)
elif key == "d":
axes["x"] = norm(val)
elif key == "w":
axes["y"] = -norm(val)
elif key == "s":
axes["y"] = norm(val)
else:
pass
pad.left_joystick(x_value=axes["x"], y_value=axes["y"])
pad.update()
time.sleep(0.001)
3-4. ゲームへの適用
今回はBattleField4に適用します。オフラインのみで検証を行っていますが、オンラインではアンチチートに検知されるかもしれません。試す場合は自己責任でお願いします。
ここでは一例として、乗り物の加速にWキーを割り当ててみます。
ゲームを起動したらキーバインドに進み、ジョイスティック欄の設定したい項目である加速をクリックしてWを押します。すると軸0Yなどとジョイスティック扱いのキーが設定されます。競合を防ぐため、キーボードのバインドは解除しておきます。
実際の動作の様子がこちらです。押し込み量に比例してステアリングが切れています。
4. まとめ
磁気スイッチ搭載キーボードのキーストロークをアナログ量として取得し、仮想ゲームパッドの左スティックとして動作するように実装しました。
少し細かい操作ができるようになりました。それなりに便利なので、今後、各ゲーミングキーボードメーカーが類似の機能を搭載してくれると嬉しいですね
