Skip to content

Instantly share code, notes, and snippets.

@fujimogn
Last active August 3, 2026 02:56
Show Gist options
  • Select an option

  • Save fujimogn/c462d629ecb2e5ee00b7d82f8e97f68c to your computer and use it in GitHub Desktop.

Select an option

Save fujimogn/c462d629ecb2e5ee00b7d82f8e97f68c to your computer and use it in GitHub Desktop.
macOS の音声入力 (Aqua Voice 等) を RDP (Windows App) 先へ通す — 合成⌘V が v になる問題の原因と解法 (CGEvent flagsChanged + デバイスビット)
#!/bin/bash
# aqua-rdp-relay — Aqua Voice の文字起こしを RDP (Windows App) セッションへ中継する
#
# 要件: Mac の音声入力を、リモート側にソフトを足さず・ローカルにも常駐を増やさず、
# RDP 先 (Windows / Linux 問わず) へそのまま入力できるようにする。Aqua Voice で検証
# したが、「クリップボード + 合成⌘V」方式の音声入力ツール (Whisper 系等) なら同様に有効。
#
# 背景: Windows App がフォーカスのとき、Aqua は「クリップボードに文字起こしを載せて
# 合成 ⌘V → 約0.8秒後にクリップボードを復元」する。しかし合成 ⌘V は Windows App で
# 修飾キーが落ちて "v" 1文字になり、クリップボードも即復元されるため貼り付けが失敗する。
#
# 本スクリプトは Karabiner の shell_command から口述キー (右⌘ / F10) をトリガーに起動され、
# 「クリップボード変化 → 短時間で復元」という Aqua 特有のペア署名を検出して文字起こしを
# キャッシュし、混入した "v" を Backspace で消してから再ペーストする。
#
# 使い方:
# aqua-rdp-relay <TTL秒> # 右⌘ (push-to-talk): チャンクごとに即貼り付け
# aqua-rdp-relay <TTL秒> f10 # F10 (ハンズフリー): 1度目=開始(溜める) / 2度目=一括送信
#
# キー送信は HID レベルの CGEvent を投げる自前ヘルパ aqua-sendkeys (Swift) を使う。
# 責任プロセスの karabiner_console_user_server にアクセシビリティ権限が必要。
#
# 貼り付けまでの体感速度は AQUA_SYNC_WAIT (既定 0.1 秒) で調整する。RDP 先への
# クリップボード同期 (CLIPRDR) を待つ時間。短いほど速いが、Aqua の壊れた ⌘V が入れた
# "v" の到着を backspace が追い越して消し残すことがある (0.1 秒で 4 回中 1 回)。
# 消し残りが気になる回線・接続先では 0.25〜0.5 秒へ上げる。
ttl="${1:-60}"
mode="${2:-}"
log=/tmp/aqua-rdp-relay.log
flag=/tmp/aqua-handsfree-on
rdp_bundle="com.microsoft.rdc.macos"
sendkeys="$HOME/bin/aqua-sendkeys"
logmsg() { printf '%s %s\n' "$(date '+%H:%M:%S')" "$*" >> "$log"; }
running_pids() { pgrep -f "bash.*aqua-rdp-relay" | grep -v "^$$\$"; }
if [ "$mode" = "f10" ]; then
# F10 はトグル。flag があれば「2度目 = 送信指示」で、flag を消すだけで終了し、
# 稼働中の f10 watcher がそれを検知してバッファを送信する
if [ -e "$flag" ]; then
rm -f "$flag"
exit 0
fi
# 1度目: 右⌘ の push-to-talk watcher が生きていると多重起動ガードに阻まれるため、
# それを畳んでから自分が f10 watcher として立ち上がる (ptt は即時貼り付けなので
# 途中で終了しても取りこぼしはない)
for pid in $(running_pids); do
kill "$pid" 2>/dev/null
done
sleep 0.1
touch "$flag"
else
# push-to-talk: 既に何かが動いていれば任せる (f10 バッファ中に横入りしない)
[ -n "$(running_pids)" ] && exit 0
fi
front_is_rdp() {
# AQUA_RELAY_TEST=1 のときはフロント判定をスキップ (ロジック単体テスト用)
[ -n "$AQUA_RELAY_TEST" ] && return 0
lsappinfo info -only bundleid "$(lsappinfo front)" 2>/dev/null | grep -q "$rdp_bundle"
}
# 起動時点で RDP がフロントでなければ何もしない (通常アプリへの口述に干渉しない)
if ! front_is_rdp; then
[ "$mode" = "f10" ] && rm -f "$flag"
exit 0
fi
logmsg "watch start (ttl=${ttl}s mode=${mode:-ptt})"
buffer=""
chunks=0
# バッファ済みチャンクを一括送信する (混入した "v" をチャンク数ぶん消してから貼る)
send_buffer() {
[ "$chunks" -eq 0 ] && return 0
if ! front_is_rdp; then
logmsg "send skipped (front not RDP, ${chunks} chunks discarded)"
buffer=""; chunks=0
return 0
fi
logmsg "send buffer (${chunks} chunks, ${#buffer} chars)"
printf '%s' "$buffer" | pbcopy
sleep "${AQUA_SYNC_WAIT:-0.1}"
"$sendkeys" "backspace=${chunks}" cmd+v >> "$log" 2>&1
buffer=""; chunks=0
}
baseline=$(pbpaste 2>/dev/null)
end=$((SECONDS + ttl))
senddeadline=""
# f10 watcher が異常終了してもフラグを残さない (次回のトグルが噛み合わなくなるため)
[ "$mode" = "f10" ] && trap 'rm -f "$flag"' EXIT
while [ $SECONDS -lt $end ]; do
# F10 の送信指示 (flag 消滅) を検知したら、最終チャンクを少しだけ待ってから送る
if [ "$mode" = "f10" ] && [ ! -e "$flag" ]; then
if [ -z "$senddeadline" ]; then
senddeadline=$((SECONDS + 3))
logmsg "handsfree stop -> waiting final chunk"
elif [ $SECONDS -ge $senddeadline ]; then
send_buffer
logmsg "watch end (sent)"
exit 0
fi
fi
cur=$(pbpaste 2>/dev/null)
if [ "$cur" != "$baseline" ] && [ -n "$cur" ]; then
candidate="$cur"
# ペア署名の後半 (Aqua による復元) を最大2秒待つ
restored=false
for _ in $(seq 1 40); do
sleep 0.05
now=$(pbpaste 2>/dev/null)
if [ "$now" = "$baseline" ]; then restored=true; break; fi
# 復元前にさらに変わった場合は candidate を追随 (切替中の空読みは無視)
[ -n "$now" ] && [ "$now" != "$candidate" ] && candidate="$now"
done
if [ "$restored" = true ] && front_is_rdp; then
if [ "$mode" = "f10" ]; then
buffer="${buffer}${candidate}"
chunks=$((chunks + 1))
logmsg "buffered chunk #${chunks} (${#candidate} chars)"
# 送信指示後に届いた最終チャンクなら即送信
if [ -n "$senddeadline" ]; then
send_buffer
logmsg "watch end (sent)"
exit 0
fi
# 復元後のクリップボードは baseline のままなので更新不要
else
logmsg "pair detected (${#candidate} chars) -> paste"
printf '%s' "$candidate" | pbcopy
# RDP 先へのクリップボード同期 (CLIPRDR) を待ってから貼り付ける (冒頭の注記参照)
sleep "${AQUA_SYNC_WAIT:-0.1}"
# Aqua の壊れた ⌘V が挿入した "v" を消してから貼り直す
# (osascript の session レベル合成イベントは Windows App に落とされるため、
# HID レベルで投げる自前ヘルパ aqua-sendkeys を使う)
"$sendkeys" backspace cmd+v >> "$log" 2>&1
baseline="$candidate"
fi
else
# 復元が来ない = ただのコピー操作。新しい基準値として続行
logmsg "single change (no restore) -> new baseline"
baseline="$candidate"
fi
fi
sleep 0.05
done
# TTL 切れ: 溜まっているものがあれば送ってから終わる (言い放しの取りこぼし防止)
if [ "$mode" = "f10" ]; then
send_buffer
rm -f "$flag"
fi
logmsg "watch end"
// aqua-sendkeys — HID レベルで CGEvent キー入力を投げるミニヘルパ
//
// RDP クライアント (Windows App) は合成イベントの修飾キーを次の条件でしか受理しない:
// 1. 修飾キーは keyDown ではなく flagsChanged イベントとして送る
// 2. flags に修飾のデバイス固有ビット (左⌘=0x8) と NonCoalesced (0x100) を含める
// (実キーボードのイベントを CGEvent タップでダンプして特定した。単なる maskCommand
// だけのイベントは修飾が剥がされ "v" 単体として届く)
//
// build: xcrun swiftc -O -o ~/bin/aqua-sendkeys ~/bin/aqua-sendkeys.swift
// usage: aqua-sendkeys backspace cmd+v (backspace=N で N 回連打)
import ApplicationServices
import CoreGraphics
import Foundation
// 権限がないと CGEvent post は無音で捨てられるため、信頼状態を stderr へ出す
FileHandle.standardError.write(
"aqua-sendkeys: AXIsProcessTrusted=\(AXIsProcessTrusted())\n".data(using: .utf8)!)
let src = CGEventSource(stateID: .hidSystemState)
// 注意: ここで「物理修飾キーが離されるまで待つ」実装を試したが逆効果だった。
// Karabiner の shell_command 経由だと CGEventSource.flagsState が常に maskCommand を
// 報告し続け (0x20100000)、しかも 1 回の呼び出しに ~100ms かかるため、待ちループが
// 必ず上限まで回って 1〜5 秒の遅延になる。修飾の取りこぼし対策は post 間隔 25ms 側で足りる。
let vKey: CGKeyCode = 9 // ANSI 'v'
let cmdKey: CGKeyCode = 55 // left command
let backspaceKey: CGKeyCode = 51
// 実キーボード再現フラグ (イベントダンプ実測値)
let cmdDownFlags = CGEventFlags(rawValue: 0x2010_0108) // maskCommand + 左⌘デバイスビット + NonCoalesced
let plainFlags = CGEventFlags(rawValue: 0x2000_0100) // 修飾なし + NonCoalesced
func post(_ code: CGKeyCode, flags: CGEventFlags, down: Bool, asFlagsChanged: Bool = false) {
guard let e = CGEvent(keyboardEventSource: src, virtualKey: code, keyDown: down) else { return }
if asFlagsChanged { e.type = .flagsChanged }
e.flags = flags
e.post(tap: .cghidEventTap)
// 25ms 未満に詰めると Windows App が flagsChanged を処理する前に v が届き
// 修飾が落ちることがある (15ms で実際に再発)。ここは詰めない
usleep(25_000)
}
for arg in CommandLine.arguments.dropFirst() {
if arg == "backspace" || arg.hasPrefix("backspace=") {
let n = Int(arg.dropFirst("backspace=".count)) ?? 1
for _ in 0..<max(n, 1) {
post(backspaceKey, flags: plainFlags, down: true)
post(backspaceKey, flags: plainFlags, down: false)
}
} else if arg == "cmd+v" {
post(cmdKey, flags: cmdDownFlags, down: true, asFlagsChanged: true)
post(vKey, flags: cmdDownFlags, down: true)
post(vKey, flags: cmdDownFlags, down: false)
post(cmdKey, flags: plainFlags, down: false, asFlagsChanged: true)
} else {
FileHandle.standardError.write("unknown arg: \(arg)\n".data(using: .utf8)!)
exit(1)
}
usleep(80_000)
}
{
"title": "Aqua Voice → RDP リレー (Windows App)",
"rules": [
{
"description": "Windows App フォーカス中の右⌘ (口述) 後に aqua-rdp-relay を起動",
"manipulators": [
{
"type": "basic",
"from": {
"key_code": "right_command",
"modifiers": {
"optional": [
"any"
]
}
},
"to": [
{
"key_code": "right_command"
}
],
"to_after_key_up": [
{
"shell_command": "/Users/YOUR_USERNAME/bin/aqua-rdp-relay 60 >/dev/null 2>&1 &"
}
],
"conditions": [
{
"type": "frontmost_application_if",
"bundle_identifiers": [
"^com\\.microsoft\\.rdc\\.macos$"
]
}
]
}
]
},
{
"description": "Windows App フォーカス中の F10 (ハンズフリー) で aqua-rdp-relay を起動 (TTL 10分)",
"manipulators": [
{
"type": "basic",
"from": {
"key_code": "f10",
"modifiers": {
"optional": [
"any"
]
}
},
"to": [
{
"key_code": "f10"
}
],
"to_after_key_up": [
{
"shell_command": "/Users/YOUR_USERNAME/bin/aqua-rdp-relay 600 f10 >/dev/null 2>&1 &"
}
],
"conditions": [
{
"type": "frontmost_application_if",
"bundle_identifiers": [
"^com\\.microsoft\\.rdc\\.macos$"
]
}
]
}
]
}
]
}

macOS の音声入力を RDP (Windows App) 先へ通す

要件

Mac の音声入力ソフトで口述した内容を、RDP セッション内のリモート PC へそのまま入力できるようにする。

  • リモート側にソフトを入れない(Windows / Linux どちらの接続先でも、増えても、そのまま使える)
  • Mac 側にも常駐アプリを増やさない(Karabiner-Elements + macOS 標準ツール + 自前ヘルパ 1 本で完結)
  • 音声入力ツールは Aqua Voice で検証したが、「クリップボードに載せて合成 ⌘V を送る」方式のツール(SuperWhisper / Wispr Flow / MacWhisper など Whisper 系全般)なら同じ仕組みが有効なはず

そのままでは RDP セッションに向かって口述すると「v」が 1 文字入るだけで使い物にならない。本 Gist はその原因調査(CGEvent ダンプによる特定)と解決策一式。

症状

  • Windows App (旧 Microsoft Remote Desktop) のセッションにフォーカスした状態で音声入力すると、文字起こしの代わりに v が 1 文字入るだけ
  • osascript (System Events keystroke "v" using command down) で ⌘V を送っても同じく v になる
  • クリップボードにも文字起こしが残っていない

原因(2つの合わせ技)

1. 音声入力ツールの挿入方式

Aqua Voice は通常アプリには合成タイピングで直接挿入するが、RDP クライアントが相手のときだけ「クリップボードに文字起こしを載せて合成 ⌘V を送り、約0.8秒後にクリップボードを復元する」方式に切り替わる。復元が速いため、手動で ⌘V を押しても間に合わない。

2. Windows App は合成イベントの修飾キーを落とす

実キーボードのイベントと合成イベントを CGEvent タップでダンプして比較した結果、Windows App が修飾キーを受理する条件は:

  1. 修飾キーが keyDown ではなく flagsChanged イベントとして届くこと
  2. flags に 修飾キーのデバイス固有ビット(左⌘ = 0x8)と NonCoalesced (0x100) が含まれること
    • ⌘押下中: 0x20100108 / 解放後: 0x20000100

単なる maskCommand (0x100000) だけの合成イベントは(HID tap / session tap / postToPid いずれでも)修飾が剥がされ、v 単体としてリモートへ転送される。Windows App は右⌘/左⌘を Windows キーへ区別してマップするため、デバイスビットの無い修飾を無視するのだと思われる。

解決策の構成

新規常駐アプリなし。Karabiner-Elements + macOS 標準ツール + ローカルビルドの Swift 20行で構成。

右⌘ / F10 で口述 (Windows App フォーカス中のみ)
  → Karabiner の shell_command が aqua-rdp-relay を起動 (短命 watcher、右⌘=60秒 / F10=10分)
  → クリップボードの「変化 → 即復元」ペア署名を検出して文字起こしを捕獲
  → 混入した "v" を Backspace で消去 → クリップボード再セット → HID レベル ⌘V で貼り付け

トリガーは 2 モード:

  • 右⌘ (push-to-talk): 1 チャンクごとに即貼り付け
  • F10 (ハンズフリー): 1 度目で開始してチャンクを溜め、2 度目で全文を一括貼り付け(溜めた分の v はまとめて消す)

⚠️ トリガーキーは Karabiner が見えるハードウェアイベントである必要がある。マウスドライバ (Logi Options+ / ELECOM Mouse Assistant 等) のボタン割り当てで送られる F10 は合成イベント なので Karabiner から見えず、この仕組みは発火しない(音声入力ツール自体は起動する)。 ボタンによってはベンダー独自 HID で Karabiner が掴めないものもあるため、トリガーは キーボードのキーにするのが確実。

  • aqua-sendkeys.swift — 上記 3 条件を満たす CGEvent を投げるミニヘルパ
  • aqua-rdp-relay — クリップボード監視 bash スクリプト(Karabiner から起動)
  • karabiner-aqua-rdp-relay.json — Karabiner complex modification(右⌘ / F10 トリガー)

セットアップ

  1. ヘルパをビルド(Xcode Command Line Tools が必要)

    xcrun swiftc -O -o ~/bin/aqua-sendkeys aqua-sendkeys.swift
  2. aqua-rdp-relay~/bin/ に置いて chmod +x

  3. karabiner-aqua-rdp-relay.json 内の YOUR_USERNAME を自分のユーザー名に置換し、~/.config/karabiner/assets/complex_modifications/ へ配置 → Karabiner-Elements の Complex Modifications で 2 ルールを有効化

  4. アクセシビリティ権限: システム設定 → プライバシーとセキュリティ → アクセシビリティに以下を追加

    /Library/Application Support/org.pqrs/Karabiner-Elements/bin/karabiner_console_user_server
    
  5. Windows App 側でクリップボードリダイレクトを有効化(Devices & Audio → Clipboard: Bidirectional)

チューニング

AQUA_SYNC_WAIT(秒、既定 0.1)で RDP へのクリップボード同期待ちを調整できる。短いほど 貼り付けが速いが、音声入力ツールの壊れた ⌘V が入れた v の到着を backspace が追い越して 消し残すことがある(0.1 秒で 4 回中 1 回程度)。気になる場合は 0.25〜0.5 秒へ上げる。

なお v が一瞬表示されること自体は避けられない。あれは音声入力ツールが RDP へ直接 送っているキー入力で、Karabiner からは見えない合成イベントのため事前に抑止できない。 本スクリプトにできるのは「速やかに消す」ことだけ。

⚠️ 「修飾キーが離されるまで待つ」実装は逆効果だった。Karabiner の shell_command 経由だと CGEventSource.flagsState が常に maskCommand を報告し続け(0x20100000)、しかも 1 回の 呼び出しに ~100ms かかるため、待ちループが必ず上限まで回って 1〜5 秒の遅延になる。

注意

  • トリガーキー(右⌘ / F10)は Aqua Voice の起動キー設定に合わせて JSON を書き換える
  • リモートが Linux の場合は xrdp のクリップボードリダイレクト (chansrv) が動いていること
  • RDP のクリップボード同期 (CLIPRDR) 自体が時々沈黙する持病がある。効かなくなったらリモート側で rdpclip.exe を再起動(Linux は xrdp-chansrv)するか、Windows App を再起動
  • デバッグは /tmp/aqua-rdp-relay.log を見る

動作確認環境

  • macOS (Apple Silicon) / Windows App v11 系 / Aqua Voice v0.18.8
  • リモート: Windows 11 / Linux (xrdp) — 2026-08-03 検証
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment