🎤

【Obsidian × Whisper】アイデアを逃さずにメモする方法

に公開

東京大学 M2の川田です。

ここ数年で知的生産ツールとしてのObsidianが急速に注目を集めており、私もこの波に乗って導入してみたところ、その多彩な機能とカスタマイズ性にすっかり魅了されてしまいました。

とくに「Thino」を使うと、思いついたアイデアや作業ログをデイリーノートへ直接書き込めるため、別ノートを作る手間が省け、アイデア管理が格段にスムーズになります。私はこれまでは「後でまとめればいい」と後回しにしがちでしたが、今では思いついた瞬間に記録し、そのまま日々の流れに組み込めるようになりました。

そんな中、「もっと手軽に記録を残せないか」と考えたときに目をつけたのが音声入力でした。別のアプリを開いて作業している時でも、読書をしている時でも、サクッとメモを残したい。そこで、ObsidianとOpenAIの音声認識モデルWhisperを組み合わせ、PCの簡単なキーボード操作だけで「録音→文字起こし→Obsidianへの書き込み」を自動化するフローを作ってみることにしました。

今回のZenn投稿では、その具体的な設定手順とスクリプト例を公開します。

概要

具体的な手順は以下の通りです。

  1. 特定のショートカットキー(例:Shift + Command + A)を長押しすると録音が開始される。
  2. キーを離すとWhisperが起動し、音声の文字起こしを開始する。
  3. Geminiを用いて文字起こしされたテキストの誤字・脱字やフィラーワードを修正する。
  4. 修正されたテキストをObsidianに書き込む。

実装手順

0.Obsidianをインストールする

以下よりダウンロードしてください。
https://obsidian.md/download

また、本記事では、簡易メモ投稿ができる「Thino」プラグインを使用します。
Obsidianをインストールしたら、以下の手順でThinoも導入してください。

  1. Obsidianを起動し、左サイドバー下部の「設定(歯車アイコン)」を開く。
  2. 「プラグイン」→「コミュニティプラグイン」→ 検索欄に「Thino」と入力してインストール

Thinoを有効化すると、右側にTwitterのようなメモ欄が表示されます。そこに文字を入力して投稿すると、自動的にデイリーノートに追記される仕組みです。インストールが完了しThinoを有効化すると、この写真のようにTwitterのような画面になります(右側にThinoをスプリットして表示している状態です)。

1.whisperの環境構築を行う

まずは、Whisperを使った文字起こし環境を構築します。
https://github.com/litongjava/whisper-cpp-server

# 1. whisper.cpp のリポジトリをクローン
git clone https://github.com/ggml-org/whisper.cpp.git
cd whisper.cpp

# 2. small モデルを使ってビルド(複数のモデルから選択可能ですが、ここでは small を選びます)
make -j small

これだけで、Whisperによる文字起こしの環境構築は完了です。

2.Hammerspoonの設定を行う

2-1. 必要なソフトをインストールする

ここでは、キーボード操作を検知して「録音→文字起こし→Obsidianへの書き込み」を自動化するために、次のツールをインストールし、設定を行います。

  1. Homebrew
  • macOS向けパッケージ管理ツールです。
  • インストールされていない場合は、Homebrew公式サイト の手順に従ってインストールしてください。
  1. Hammerspoon
  • macOSのオートメーションツールで、キーボード操作やマウス操作をフックしてLuaスクリプトで制御できます。
  • 「キーボードの長押しを検知」して任意の処理を呼び出す機能を使います。
# 1. Hammerspoon をインストール(--cask オプションでGUIアプリとしてインストール)
brew install --cask hammerspoon
  1. SoX(Sound eXchange)
  • コマンドライン上でオーディオの録音・変換・再生などができるツールです。
  • ここでは、マイクから録音した音声をWAVファイルとして保存する用途で使います。
# 2. SoX をインストール
brew install sox

2-2. Hammerspoonの設定ファイルを用意する

  1. Hammerspoon を起動すると、メニューバーにアイコンが表示されます。
  2. メニューバーアイコンをクリックし、「Open Config」 を選択すると、テキストエディタで ~/.hammerspoon/init.lua が開きます。

この init.lua がHammerspoonのメイン設定ファイルです。
以下のサンプルコードをそのままコピーして、init.lua に貼り付けてください。

下記4カ所はご自身の環境に合わせて書き換えてください:

  • WHISPER_PATH
  • WHISPER_MODEL
  • GEMINI_API_KEY
    Gemini API Keyはこちらのリンクより発行可能です。
  • OBSIDIAN_DAILY_DIR
#!/usr/bin/env lua
-- init.lua

-- 必要モジュール
local hotkey    = hs.hotkey
local timer     = hs.timer
local task      = hs.task
local http      = hs.http
local json      = hs.json
local alert     = hs.alert
local fs        = require("hs.fs")
local fnutils   = hs.fnutils
local logger    = hs.logger.new('WhisperModule','info')
local os        = os

logger:i("Loading configuration...")

--――――――――――
-- 設定
--――――――――――
local HOTKEY_MODS    = {"cmd", "shift"}
local HOTKEY_KEY     = "A"
local LONGPRESS_SEC  = 1.0
local SOX_PATH       = "/opt/homebrew/bin/sox"
local RECORD_OPTS    = {"-t","coreaudio","default","-r","16000","-c","1","-b","16"}
local WHISPER_PATH   = os.getenv("HOME") .. "/whisper.cpp/build/bin/whisper-cli"
local WHISPER_MODEL  = os.getenv("HOME") .. "/whisper.cpp/models/ggml-small.bin"
local GEMINI_API_KEY = "YOUR_API_KEY"
local GEMINI_URL     = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=" .. GEMINI_API_KEY
local GEMINI_HEADERS = {
  ["Content-Type"]  = "application/json",
}
local OUTPUT_DIR     = os.getenv("HOME") .. "/.hammerspoon/whisper"
local OBSIDIAN_DAILY_DIR = os.getenv("HOME") .. "/Documents/Obsidian Vault/02_Daily/"
fs.mkdir(OUTPUT_DIR)
logger:i("Configuration loaded. Output dir: " .. OUTPUT_DIR)

--――――――――――
-- 状態保持
--――――――――――
local pressTimer  = nil
local soxTask     = nil
local recFile     = nil
local isRecording = false

-- 日付ファイル名(例: 2025-05-29.md)
local function todayFilename(ts)
  local t
  if ts then
    local ts_seconds = math.floor(ts / 1000)  -- ミリ秒→秒に変換して整数化
    t = os.date("*t", ts_seconds)
  else
    t = os.date("*t")
  end
  local fname = string.format("%04d-%02d-%02d.md", t.year, t.month, t.day)
  return OBSIDIAN_DAILY_DIR .. fname
end

-- Obsidianのデイリーノートに追記
function appendToObsidianDaily(cleaned, ts)
  local ts_seconds = ts and math.floor(ts / 1000) or os.time()
  local obsidianFile = todayFilename(ts)
  local timeStr = os.date("%H:%M:%S", ts_seconds)
  -- 箇条書きのフォーマット(1行目だけ-、以降はタブインデント)
  local lines = {}
  table.insert(lines, "- " .. timeStr)
  for line in cleaned:gmatch("[^\r\n]+") do
    table.insert(lines, "\t" .. line)
  end
  table.insert(lines, "") -- ここで空行(改行)を1つ挟む
  local content = table.concat(lines, "\n")

  -- "a"で追記(なければ新規作成)
  local f = io.open(obsidianFile, "a")
  if f then
    f:write(content)
    f:close()
    alert.show("Obsidianに追記しました")
    logger:i("Appended to Obsidian Daily: " .. obsidianFile)
  else
    alert.show("Obsidianファイル書き込み失敗")
    logger:e("Failed to open Obsidian file: " .. obsidianFile)
  end
end

-- soxタスクを安全にクリーンアップする関数
local function cleanupSoxTask()
  if soxTask then
    if soxTask:isRunning() then
      logger:i("Terminating existing sox task")
      soxTask:terminate()
      -- 少し待ってからタスクを削除
      timer.doAfter(0.5, function()
        soxTask = nil
        isRecording = false
      end)
    else
      soxTask = nil
      isRecording = false
    end
  else
    isRecording = false
  end
end

--――――――――――
-- 録音開始/停止/文字起こしの実行
--――――――――――
local function startRecording()
  -- 既に録音中の場合は何もしない
  if isRecording then
    logger:d("Already recording, ignoring start request")
    return
  end
  
  -- 前回のタスクが残っている場合はクリーンアップ
  cleanupSoxTask()
  
  -- 少し待ってから新しい録音を開始
  timer.doAfter(0.2, function()
    recFile = OUTPUT_DIR .. "/rec_" .. os.time() .. ".wav"
    logger:i("Starting recording to " .. recFile)
    alert.show("録音開始…")
    
    -- 引数配列を毎回新しく作成(RECORD_OPTSを直接変更しない)
    local args = {}
    for i, opt in ipairs(RECORD_OPTS) do
      args[i] = opt
    end
    table.insert(args, recFile)
    
    logger:i("sox args: " .. table.concat(args, " "))  -- デバッグ用
    
    soxTask = task.new(SOX_PATH, function(code, stdout, stderr)
      logger:i("sox exited with code: " .. tostring(code))
      if code ~= 0 then
        logger:e("sox stderr: " .. (stderr or ""))
        logger:e("sox stdout: " .. (stdout or ""))
      end
      isRecording = false
    end, args)
    
    if soxTask then
      local success = soxTask:start()
      if success then
        isRecording = true
        logger:i("sox task started successfully")
      else
        logger:e("Failed to start sox task")
        soxTask = nil
        isRecording = false
        alert.show("録音開始に失敗しました")
      end
    end
  end)
end

local function stopRecordingAndTranscribe()
  if not soxTask or not recFile or not isRecording then
    logger:d("No recording in progress; skipping stop.")
    return
  end
  
  logger:i("Stopping recording: " .. recFile)
  
  -- タスクを終了
  if soxTask:isRunning() then
    soxTask:terminate()
  end
  
  -- 状態をリセット
  local currentRecFile = recFile
  soxTask = nil
  recFile = nil
  isRecording = false
  
  alert.show("録音停止→文字起こし開始…")

  -- soxが完全に終了するのを待ってから処理を続行
  timer.doAfter(1.5, function()
    -- Whisper 実行
    local txtOut = OUTPUT_DIR .. "/txt_" .. os.time() .. ".txt"
    logger:i("Running Whisper on " .. currentRecFile)
    local wargs = {"-m", WHISPER_MODEL, "-l", "ja", "-otxt", txtOut, "-f", currentRecFile}
    local wtask = task.new(WHISPER_PATH, function(code)
      logger:i("Whisper exited with code: " .. tostring(code))
      -- 元WAV削除
      os.remove(currentRecFile)
      logger:d("Deleted rec file: " .. currentRecFile)
      -- 出力ファイルのパスを生成
      local ts = math.floor(hs.timer.secondsSinceEpoch() * 1000)
      local originalTxtOut = currentRecFile .. ".txt"
      local newTxtOut = OUTPUT_DIR .. "/txt_" .. ts .. ".txt"
      -- ファイルをリネーム
      os.rename(originalTxtOut, newTxtOut)
      local raw = io.open(newTxtOut, "r"):read("*a")
      -- ── Gemini API 呼び出し ──
      local body = {
        ["system_instruction"] = {
          ["parts"] = {
            {
              ["text"] = "これから渡す文章は、音声を自動認識で書き起こしたものです。文中には誤認識による誤字・脱字、不自然な語順、フィラーワード(例:「えー」「あのー」など)、意味の重複や不要な口語表現が含まれている場合があります。あなたは文章校正の専門家として、全体の文脈と一般常識をもとに誤りを修正し、不要な部分を取り除き、自然で読みやすい日本語の文章にしてください。ただし、話者の意図や元の内容が変わらないよう配慮してください。"
            }
          }
        },
        ["contents"] = {
          {
            ["parts"] = {
              { ["text"] = raw }
            }
          }
        }
      }
      -- JSON 化して payload に格納
      local payload = json.encode(body)
  
      hs.http.asyncPost(
            GEMINI_URL,
            payload,
            GEMINI_HEADERS,
            function(status, body, headers)
              if status == 200 then
                local res       = json.decode(body)
                print(res)
                local parts   = res.candidates[1].content.parts or {}
                local cleaned = ""
                for _, part in ipairs(parts) do
                  cleaned = cleaned .. (part.text or "")
                end
            appendToObsidianDaily(cleaned, ts)
            os.remove(newTxtOut)
          else
            logger:e("Gemini API Error:", status, body)
            alert.show("Gemini Error: "..status)
          end
        end
      )
    end, wargs)
    wtask:start()
  end)
end

--――――――――――
-- ホットキー設定
--――――――――――
hotkey.bind(HOTKEY_MODS, HOTKEY_KEY,
  function()  -- onKeyDown
    if pressTimer then pressTimer:stop() end
    pressTimer = timer.delayed.new(LONGPRESS_SEC, startRecording)
    pressTimer:start()
  end,
  function()  -- onKeyUp
    if pressTimer then pressTimer:stop(); pressTimer = nil end
    stopRecordingAndTranscribe()
  end
)

-- 起動完了通知
alert.show("Hammerspoon 音声文字起こしモジュール 起動完了")
logger:i("Module loaded successfully.")
コードの解説 by ChatGPT
  1. ホットキー(HOTKEY_MODS, HOTKEY_KEY, LONGPRESS_SEC

    • HOTKEY_MODS:キーボード修飾キーの組み合わせです。たとえば {"cmd", "shift"} は「⌘ + ⇧」を意味します。
    • HOTKEY_KEY:修飾キーと同時に押すメインキーです。上記の例では "A" を指定しているので、⌘⇧+A を長押しする操作になります。
    • LONGPRESS_SEC:長押しと判断するまでの秒数です(たとえば 1.0 は「1秒間長押ししたら録音開始」という意味)。
    • 動作イメージ:⌘⇧+A を1秒間押し続けると「録音スタート」、キーを離すと「録音ストップ→文字起こし→Obsidianへの追記」が動く仕組みです。好きなキーに変更しても問題ありません。
  2. SoX のパス(SOX_PATH

    • ここには、実際に録音処理を行う SoX コマンドの場所を指定します。
    • macOS の場合、Intel Mac は /usr/local/bin/sox、Apple Silicon(M1/M2)では /opt/homebrew/bin/sox が一般的です。
    • ターミナルで which sox を実行すれば、自分のマシンにインストールされた SoX のパスがわかります。もし異なる場所にインストールされていれば、そのパスを SOX_PATH に書き換えてください。
  3. Whisper-CPP のバイナリおよびモデルパス(WHISPER_PATH, WHISPER_MODEL

    • WHISPER_PATH:ビルド済みの Whisper-CPP(コマンド名は whisper-cli)のフルパスを指定します。例:/Users/yourname/whisper.cpp/build/bin/whisper-cli
    • WHISPER_MODEL:Whisper 本体に読み込ませるモデルファイル(ggml-small.bin など)のフルパスを指定します。例:/Users/yourname/whisper.cpp/models/ggml-small.bin
    • ポイント:この2つのパスは場所が合っていないと文字起こしが動作しないため、実際にファイルが置かれているフォルダを確認してから書き換えてください。
  4. Gemini API キーとエンドポイント(GEMINI_API_KEY, GEMINI_URL

    • GEMINI_API_KEY:Google Cloud Platform で発行した API キーをここに貼り付けます。間違えると校正リクエストが失敗します。
    • GEMINI_URL:文字起こし結果を「きれいな日本語文章」に校正してもらうためのリクエスト先 URL です。こちらは API キーを埋め込む形になっているので、GEMINI_API_KEY を設定すると自動で正しい URL が生成されます。
    • 役割:Whisper で生の文字起こしテキストが生成されたあと、「余分な言い間違い・フィラー(「えー」「あのー」など)・脱字」を Gemini に投げて修正してもらい、きれいな文章に整えます。
  5. 出力ディレクトリ(OUTPUT_DIR

    • 録音した音声ファイル(WAV)や、一時的に生成されたテキストファイルを保存するフォルダです。例:~/.hammerspoon/whisper
    • スクリプト起動時に自動でフォルダが作成されるため、最初は存在しなくても問題ありません。
    • 用途
      1. 録音開始時に rec_<タイムスタンプ>.wav がここに出力される
      2. Whisper 実行後に txt_<タイムスタンプ>.txt (一時ファイル)が作られる
      3. 最終的に文字起こし結果は Obsidian へ転記した後に不要になるので、このディレクトリを整理すれば手動クリーンアップも可能です。
  6. Obsidian デイリーノートのフォルダ(OBSIDIAN_DAILY_DIR

    • Obsidian の「日付ごとのノートファイル」(例:2025-05-31.md)が置かれているディレクトリを示します。例:~/Notes/ObsidianVault/Daily/
    • init.lua 内では、このフォルダの直下に「YYYY-MM-DD.md」という名前でファイルを作成・追記します。
    • 注意点
      • 末尾にスラッシュ / を忘れずに付けると、ファイル結合がスムーズになります(例:~/Notes/ObsidianVault/Daily/2025-05-31.md)。
      • デイリーノート用のテンプレートや他のプラグインと競合しないよう、自分の Obsidian 環境で実際に保存されているパスに合わせてください。

2-3. 設定を反映する

  1. ~/.hammerspoon/init.lua に上記スクリプトを貼り付けて保存します。
  2. メニューバーの Hammerspoon アイコンをクリックし、「Reload Config」 を選択します。
  3. 画面右上に「Hammerspoon: 音声文字起こしモジュール 起動完了」という通知が表示されれば成功です。

これで以下の動作が可能になります。

  • Command + Shift + A を1秒間長押し → 録音ファイル(WAV)の生成開始
  • キーを離す → 録音停止 → Whisper で文字起こし → Gemini API で校正 → Obsidian デイリーノートに追記

あとは Obsidian を開いて、当日のデイリーノート(例: 2025-05-31.md)に「時刻」「文字起こし内容」の順で追記されていることを確認してください。これでキーボードからワンタッチで「録音→文字起こし→ノート追記」が完了するようになります。

おわりに

ここまでお読みいただき、ありがとうございました。本記事では、Obsidian と Whisper(+Gemini API)を組み合わせて、キーボード操作だけで「録音→文字起こし→ノートへの自動追記」を実現する仕組みをご紹介しました。Hammerspoon のホットキー設定から SoX や Whisper-CPP のビルド、さらに Gemini API での校正まで、一連のフローをゼロから構築する手順をできる限り丁寧に解説しましたが、いかがでしたでしょうか。

また、本記事で示したサンプルスクリプトはあくまでも一例です。キーボードショートカットやファイルパス、Whisper モデルの選択、Gemini の校正命令などはお好みや用途に応じてカスタマイズしてください。たとえば、

  • ほかのホットキー(⌘⇧+B や ⌥⌘+R など)を使う
  • Whisper のモデルを “tiny” や “base” に切り替えて速度優先にする
  • Gemini のシステムプロンプトを変更して、専門用語の校正を強化する
  • 追記先を「週別ノート」や「プロジェクト別ノート」にリダイレクトする

など、用途に合わせて自由に拡張できるのがこの仕組みの醍醐味です。Obsidian のプラグインや Hammerspoon のライブラリをさらに活用すれば、「録音開始前にカウントダウンを鳴らす」「ノート追記後にデスクトップ通知を表示する」「クラウドストレージと同期する」など、より便利な機能を追加することも可能です。

もしこの記事を参考に導入を検討している方や、すでに試してみた方がいらっしゃれば、ぜひ Twitter や Zenn のコメント欄で感想や改善案をシェアしていただけると嬉しいです。本フローをベースに、あなたのワークフローに最適化されたノート自動化システムが完成することを願っています。

それでは、快適な知的生産ライフを!

ちなみに、
東京大学の暦本教授がほぼ同じアプリをiphoneで作っていました。
https://x.com/rkmt/status/1888453949377958073
https://github.com/rkmt/voice2memo

Discussion