Python製タイポ分析ツールで探る：NuPhy Air60 V2キースイッチの最適化

![DALL·E Workspace.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184128/b2af60ba-d001-ee16-9c0b-d2931a532ab7.png)


## はじめに

発表後すぐに注文し、ずっと待っていた[NuPhy Air60 V2 Ionic White](https://nuphy.com/collections/in-stock-keyboards/products/air60-v2)が手元に届きました。Air60 V1からこのキーボードを気に入っていました。V2も、初めて触れた瞬間から、その質感と機能性に感激しています。

- [おすすめのキーボードランキング！ - 虎（牛）龍未酉2.1](https://masatler.hatenablog.com/entry/2023/12/07/002347)

しかしながら、キースイッチの選択には頭を悩ませていて、迷走しています。[いろいろなキースイッチを試しながら、暫定的な配置にいたりました](https://masatler.hatenablog.com/entry/2023/12/16/223016)。

- [NuPhy Air60 V2 Ionic Whiteのキースイッチ選択 - 虎（牛）龍未酉2.1](https://masatler.hatenablog.com/entry/2023/12/16/223016)

![IMG_7595.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184128/9902bf80-d318-9832-bcd9-66022597a764.jpeg)

こちらのQiitaの記事では、キースイッチを選択するための、タイポ分析ツールを紹介します。タイプミスを減らし、快適なタイピング体験を実現する参考になれば幸いです。



## タイポ分析ツールのねらい

このタイポ分析ツールを作成した主な目的は、タイプミスの原因とパターンを正確に特定し、それを減らすことにあります。多くの場合、タイプミスに気がついても、その原因を忘れがちです。しかし、原因分析のための記録を取ろうとしても、気合いだけでは難しいことがわかりました。というのも、タイプミスが発生すると、ほとんど反射的にそれを消去してしまい、証拠を残すことができないのです。

また、記録のために、タイピング中にたびたび思考が中断されることも、ほんらいのタスクを進める障害になります。そこでこのツールです。タイプミスの瞬間を正確に捉え、後で分析できるように記録することで、より効果的なタイピングスキルの訓練とキーボードのカスタマイズを可能にします。


## スクリプト実行のための準備

このタイポ分析ツールを使用するためには、Pythonの環境設定を推奨します。ここでは、venvという仮想環境を使用します。venvはPythonの機能の一つで、プロジェクトごとに異なる依存関係やライブラリを分離して管理することができます。これにより、システム全体に影響を与えることなく、安全かつ柔軟にプロジェクトを進めることができます。

任意のディレクトリで、venvで仮想環境をつくり、`pynput`をインストールします。

```bash
$ python3 -m venv typotracker

$ source typotracker/bin/activate # 仮想環境を抜けるには $ deactivate

$ pip install pynput

```

ディレクトリ構造は下記となります。

```
.
├── typotracker.py
└── typotracker
    ├── bin
    ├── include
    ├── lib
    └── pyvenv.cfg
```

pynputを動かすために、アクセス許可を与える必要があります。macOS Sonomaの場合は、

- `システム環境設定 > プライバシーとセキュリティ > アクセシビリティ`
- `システム環境設定 > プライバシーとセキュリティ > 入力監視`

の２箇所で、Terminal（iTerm）に許可を与えます。

:::note info
Pythonに許可を与えよとか、sudoで実行せよという記事がネット上にありますが、違いました。
「アクセシビリティと入力監視の両方に許可を与える」が正解です。
:::

## typotracker.pyの動作

仮想環境でtypotracker.pyを起動します。

```bash
$ python3 typotracker.py
```

- スクリプトはバックグラウンドでキー入力を記録しています。
- タイポに気づいたら、`Ctrl+R`と入力すると、5秒後にタイポ箇所を記録ファイルに書き出します。
- タイポ箇所とは、「直近で2回以上`Ctrl+H`または`BS`を連続で押した箇所」としています。

タイポに気づいたら、

- `Ctrl+R`を押してからタイポ修正をしてもいいですし、
- タイポ修正をしてからおもむろに`Ctrl+R`を押してもOKです。

たとえば、このような記録が残ります。

```
(2023-12-dd hh:07:41) unisenntakusurukotonoyh🔥🔥🔥niyotte(Key.space)(Key.enter)(Key.cmd_r).🔥,(Key.enter)tai
(2023-12-dd hh:07:52) oherasukotaode🔥🔥🔥todesu(Key.space).
(Key.enter)
```

ひとつめは、「ことによって」と打ちたいところ「kotonoy」と打っている、つまりiとoを打ち間違えていることがわかります。
ふたつめは、「減らすことで」と打ちたいところ「kotaode」と打っている、つまり「to」ではなく「tao」と打ち間違えていることがわかります（なんでだ俺）。

## typotracker.pyのコード

こまごまと修正していますので、GitHubリポジトリをご覧ください。

https://github.com/hnsol/TypoTracker

- `extract_last_context`関数: この関数は、最後に発生したタイプミス周辺のテキストを抽出します。具体的には、2回連続でBackspaceが押された箇所を中心にテキストを取り出します。
- `TypingLogger`クラス: キーストロークを監視し、ログを記録するためのクラスです。特に、`Ctrl+R`を押すことでタイプミスを記録する機能が組み込まれています。
- スクリプトのメイン部分: ここでは、`TypingLogger`クラスのインスタンスを作成し、キーボードの入力を監視します。

:::note
下記コードは初期のバージョンですが、見通しが良いので残しておきます（いちおう動きます）。
:::

<details><summary>初期バージョン</summary>


```python
from pynput import keyboard
import time
import threading
import datetime

MAX_SIZE = 1000

# Function to extract the context from key logs, focusing on the last occurrence of a double backspace ('🔥🔥')
def extract_last_context(key_log, additional_window_size=16):
    fire_count = 0
    i = len(key_log) - 1
    while i >= 0:
        if key_log[i] == '🔥':
            fire_count += 1
            i -= 1
        else:
            if fire_count >= 2:
                start = max(0, i - fire_count * 2 - additional_window_size)
                end = min(i + fire_count * 2 + additional_window_size, len(key_log))
                context = key_log[start:end]
                return context
            fire_count = 0
            i -= 1
    return None

# TypingLogger class to monitor and log keystrokes
class TypingLogger:
    def __init__(self, debug=False):
        self.recording = True
        self.last_key_time = 0
        self.key_log = []
        self.ctrl_pressed = False
        self.debug = debug
        self.timer_active = False
        self.timer_delay = 5
        self.listener = keyboard.Listener(on_press=self.on_press, on_release=self.on_release)

    def on_press(self, key):
        if self.debug:
            try:
                print(f"[DEBUG] Key pressed: {key.char}")
            except AttributeError:
                print(f"[DEBUG] Special key pressed: {key}")
        current_time = time.time()

        if self.ctrl_pressed and hasattr(key, 'char') and key.char == 'r':
            if not self.timer_active:
                self.timer_active = True
                threading.Timer(self.timer_delay, self.process_log).start()
                if self.debug:
                    print(f"[DEBUG] Timer activated for processing log...")
            return

        if key in [keyboard.Key.ctrl_l, keyboard.Key.ctrl_r]:
            self.ctrl_pressed = True
        elif self.ctrl_pressed:
            if hasattr(key, 'char'):
                if key.char == 'h':
                    self.key_log.append('🔥')
                else:
                    self.key_log.append(f'Ctrl+{key.char}')
            else:
                key_name = str(key).replace('Key.', '(Key.') + ')'
                self.key_log.append(f'Ctrl+{key_name}')
        elif key == keyboard.Key.backspace:
            self.key_log.append('🔥')
        else:
            try:
                self.key_log.append(key.char)
            except AttributeError:
                key_name = str(key).replace('Key.', '(Key.') + ')'
                self.key_log.append(key_name)

        if len(self.key_log) > MAX_SIZE:
            self.key_log = self.key_log[len(self.key_log) // 2:]
        
        if self.debug:
            print(f"[DEBUG] Current key log size: {len(self.key_log)} | Contents: {self.key_log}")

    def on_release(self, key):
        if key in [keyboard.Key.ctrl_l, keyboard.Key.ctrl_r]:
            self.ctrl_pressed = False
            if self.debug:
                print(f"[DEBUG] Ctrl key released")

    def process_log(self):
        context = extract_last_context(self.key_log)
        if context:
            timestamp = (datetime.datetime.now() - datetime.timedelta(seconds=self.timer_delay)).strftime("%Y-%m-%d %H:%M:%S")
            log_data = f"({timestamp}) {''.join(context)}\n"
            file_path = "/path/file.txt"
            with open(file_path, 'a', encoding='utf-8') as file:
                file.write(log_data)
            if self.debug:
                print(f"[DEBUG] Logged data: {log_data}")
        else:
            if self.debug:
                print("[DEBUG] No relevant context for logging.")

        self.reset_log()
        self.timer_active = False

    def reset_log(self):
        self.key_log = []
        if self.debug:
            print("[DEBUG] Logger state reset")

    def start(self):
        self.listener.start()

def main():
    print("Script started")
    logger = TypingLogger(debug=True)
    logger.start()
    logger.listener.join()

if __name__ == "__main__":
    main()
    print("Script stopped")

```

</details>


## おわりに

このタイポ分析ツールを使用することで、自分のタイプミスの傾向を分析することができるようになりました。特定のキーを押しすぎていたり、近くのキーを押してしまっていたり、ミスのパターンを特定できたのは大きな進歩です。今後は、このツールを利用して、タイピングの癖の修正や、より快適なキースイッチ選択に役立てたいと考えています。

### 環境

- python 3.11 with pynput
- macOS Sonoma 14.1.2（23B92）
- MacBook Pro (14-inch, 2021)