2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Tauri v2 + Rust + MoonshineVoice(Python sidecar) で作る、macOS向けリアルタイム文字起こしアプリを作ってリリースしてみた

2
Last updated at Posted at 2026-03-18

Tauri v2 + Rust + MoonshineVoice(Python sidecar) で作る、macOS向けリアルタイム文字起こしアプリを作ってリリースしてみた

はじめに

MTGの議事録を自動で取りたいけど、録音すると音声ファイルが残ってゴミが溜まるし、大きすぎる音声データはLLMに渡すにはコストがかかる。
そんなときに見たのがMoonshineVoiceの記事でした。

リアルタイム文字起こしに特化したライブラリで動作も軽快。

それなら自分でデスクトップアプリを作ってみよう & あわよくば販売もできたらいいなということで、macOS向けのリアルタイム文字起こしデスクトップアプリ MoonRiverScribe を開発しました。

Stripeで決済し、Cloudflare Workersでライセンス管理し、Gmail APIでライセンスキーを自動送信する仕組みをClaudeCodeとAntigravityで実装しました。

自身でコードを書いた部分はほとんどありません。

アカウント作成、Stripe決済のセットアップ、Cloudflare Workers/Pagesのセットアップ、Gmail APIの設定等一部ブラウザからしかできない設定を除き、ほぼ全てAI主導による開発です。

ただ、以下の本文もほぼ作ってもらったものとなるので理解負債が溜まってる部分も多分にあり、エンハンスがてら理解を進めようとしているところです。

決済周りの構築にかかった費用感なども別記事で出せたら良いなと考えています。

あとはAppleDeveloperProgramの罠にハマり公証が取れていないのはご愛嬌…
取れ次第再リリース予定です。

(HPも人の手でデザイン入れて綺麗にしたいなァ)


特徴は以下の通りです:

  • 完全オンデバイス処理:音声データがクラウドに送信されない(プライバシー安全)
  • マイク+システム音声の同時キャプチャ:自分の声もZoom相手の声も同時に文字起こし
  • BlackHole等の仮想オーディオデバイス不要:macOS ScreenCaptureKit APIで直接システム音声を取得
  • リアルタイム表示:発話中のテキストがストリーミングで画面に表示される

本記事では、技術選定の理由から、Rust↔Python間のプロセス間通信、ScreenCaptureKitによるシステム音声キャプチャまで、実装のポイントを解説します。

技術スタック

レイヤー 技術 役割
デスクトップフレームワーク Tauri v2 ウィンドウ管理、IPC、バンドル
フロントエンド React 19 + TypeScript + Tailwind CSS UI
バックエンド Rust (cpal, screencapturekit, tokio) 音声キャプチャ、プロセス管理
音声認識 (ASR) Moonshine Voice (Python) オンデバイスリアルタイム文字起こし
プロセス間通信 stdin/stdout JSON-RPC Rust ↔ Python

なぜこの構成なのか

Tauri v2 を選んだ理由

Electronと比べてバイナリサイズが圧倒的に小さく、RustバックエンドでネイティブAPIに直接アクセスできます。特にmacOSの ScreenCaptureKit や cpal(CoreAudio)を使うにはRustの低レベルアクセスが不可欠でした。

ASRにMoonshine Voiceを選んだ理由

Moonshine / Moonshine v2 とは

Moonshine Voiceの基盤となっている Moonshine は、2024年に提案されたリアルタイム音声認識モデルです。アーキテクチャとしては、音声を畳み込みで圧縮 → Transformer Encoder → Decoderでtoken ID出力 → BPEでテキスト生成、という流れで、位置埋め込みには Rotary Position Embedding (RoPE) を採用しています。WER(単語誤り率)ベンチマークでは、同サイズのWhisperモデルを上回る性能を示しています。

2026年に提案された Moonshine v2 では、さらに2つの重要な改善が加わりました:

  • Sliding-window Attention Encoder:通常のアテンションは全フレーム入力後に計算しますが、Moonshine v2では任意のフレーム数のみでアテンション計算し、随時テキスト生成が可能。これがリアルタイム処理の鍵です。
  • Ergodic Encoder:Encoderに位置埋め込みを施さず、入力音声の順序情報は畳み込み処理で保持。Encoderの出力をアダプタで位置埋め込みしてからDecoderに入力する設計です。

これにより Time-to-first-token (TTFT) が入力長に依存しなくなり、「6倍サイズのモデルと同等品質で大幅高速化」を実現しています。

Moonshine Voiceの詳しい解説は @Taito_Murakami さんの記事 が非常に参考になります。

本プロジェクトでの採用理由

OpenAI Whisperと比較して、Moonshine Voiceはリアルタイムストリーミング処理に特化しています。音声チャンクを逐次投入でき、部分的なテキスト結果(line_started → line_updated → line_completed)をイベントとして受け取れます。Apple Siliconでの推論も高速で、実用的なレイテンシでした。

また、iOS・macOS・Linuxなど複数プラットフォームに対応しており、macOS 14以上で安定動作します。英語モデルはMITライセンス、日本語などの多言語モデルはMoonshine AI Community Licenseで提供されています。

ライセンスに関する補足:Moonshine AI Community License(全文)では、日本語等の非英語モデルの商用利用について以下のように定められています。

  • 年間収益 100万ドル(約1.5億円)未満の個人・組織は、商用利用が無料で許可されています(Section III)
  • 商用利用の場合は moonshine.ai/community-license への登録が必要です
  • 製品やサービスで利用する場合、Webサイト・UI・ドキュメント等に 「Powered by Moonshine AI」の表示が必須です(Section IV-a)
  • 配布物には Moonshine AI Community License の帰属表示 を含める必要があります
  • 年間収益が100万ドルを超えた時点でライセンスは自動的に終了し、エンタープライズライセンス の取得が必要になります

個人開発や小規模なスタートアップであれば、登録と帰属表示を行うことで問題なく商用利用できます。なお、英語モデルはMITライセンスなので制約なく自由に利用可能です。

現在はMacOS版のみですが、Windows版展開も考えて、Python sidecar パターン を採用しました。

アーキテクチャ全体図

マイク (cpal/CoreAudio)       システム音声 (ScreenCaptureKit)
    │ f32 PCM                      │ CMSampleBuffer → f32 PCM
    │                              │
    ▼                              ▼
  capture.rs                 screen_capture.rs
    │                              │
    └──────────┬───────────────────┘
               ▼
     crossbeam channel (bounded, 200 chunks)
               │
               ▼  base64 JSON-RPC via stdin
         sidecar.rs ──────► transcriber.py (Moonshine Voice)
               │                    │
               │    ◀── stdout JSON-RPC events ──┘
               │
               ├──► Tauri emit → React Frontend
               └──► FileWriter → .txt / .srt

実装のポイント

1. ScreenCaptureKitでシステム音声を直接キャプチャ

多くの文字起こしアプリは BlackHole などの仮想オーディオデバイスを要求しますが、macOS 13 (Ventura) 以降では ScreenCaptureKit を使えば、仮想デバイスなしでシステム音声を直接取得できます。

use screencapturekit::{
    cm::CMSampleBuffer,
    stream::{
        SCStream, configuration::SCStreamConfiguration,
        content_filter::SCContentFilter,
        output_trait::SCStreamOutputTrait,
        output_type::SCStreamOutputType,
    },
};

struct AudioOutputHandler {
    callback: Arc<dyn Fn(Vec<f32>, u32) + Send + Sync>,
}

impl SCStreamOutputTrait for AudioOutputHandler {
    fn did_output_sample_buffer(
        &self, sample: CMSampleBuffer, _of_type: SCStreamOutputType
    ) {
        if let Some(abl) = sample.audio_buffer_list() {
            let mut all_samples = Vec::new();
            for buffer in abl.iter() {
                let data = buffer.data();
                if data.is_empty() { continue; }
                let floats: &[f32] = bytemuck::cast_slice(data);
                all_samples.extend_from_slice(floats);
            }
            if !all_samples.is_empty() {
                (self.callback)(all_samples, 48000);
            }
        }
    }
}

ポイント:

  • .with_captures_audio(true) でシステム音声取得を有効化
  • .with_width(1).with_height(1) で映像を最小化(音声のみ欲しいので)
  • CMSampleBuffer から AudioBufferList → f32 PCMデータに変換
  • アプリ単位のフィルタリングも可能(例:Zoomの音声だけキャプチャ)
  • ユーザーに必要なのは「画面収録」権限の許可のみ

2. Rust ↔ Python のsidecar通信

Moonshine Voiceを動かすPythonプロセスとは、stdin/stdout を使ったJSON-RPCプロトコルで通信します。

Rust → Python(音声データ送信)

{
  "method": "audio_chunk",
  "params": {
    "data": "<base64エンコードされたf32 PCMデータ>",
    "sample_rate": 16000
  }
}

Python → Rust(文字起こし結果)

{"jsonrpc": "2.0", "method": "line_started",   "params": {"id": "a1b2", "text": "こんに", "start_time": 3.5}}
{"jsonrpc": "2.0", "method": "line_updated",   "params": {"id": "a1b2", "text": "こんにちは、今日の"}}
{"jsonrpc": "2.0", "method": "line_completed", "params": {"id": "a1b2", "text": "こんにちは、今日の会議を始めましょう。", "start_time": 3.5, "end_time": 8.2}}

なぜWebSocketではなくstdin/stdout?

  • Tauri sidecar の標準パターンに沿っている
  • ポート管理が不要(ポート衝突の心配なし)
  • 1行1メッセージでデバッグが容易
  • プロセスのライフサイクル管理がシンプル

3. 音声パイプラインの設計

マイクとシステム音声の2つのソースを1つのチャネルに統合する設計が肝です。

pub enum AudioSource {
    Mic(Vec<f32>),
    System(Vec<f32>),
    ClearMic,
    ClearSystem,
}
cpal callback (~10ms)            SCK callback (~20-50ms)
source="mic"                     source="system"
    │                                │
    ▼ try_send                       ▼ try_send
    └──────────┬─────────────────────┘
               ▼
  crossbeam bounded channel (capacity: 200)
               │
               ▼ recv (専用スレッド)
         sidecar stdin に書き込み

設計上の工夫:

  • try_send(ノンブロッキング) を使用:音声コールバックはリアルタイムスレッドで動くため、ブロック(malloc/lock)は禁止
  • bounded channel(容量200):sidecar側が遅延した場合、古いチャンクをドロップして「音飛び」で済ませる(フリーズさせない)
  • source タグ:マイク/システム音声の出典をファイル出力時に区別可能

4. リサンプリング

マイクのサンプルレートはデバイス依存(44.1kHz/48kHz)ですが、Moonshine Voiceは16kHz monoを要求します。高品質な rubato クレートによるSinc補間リサンプリングを採用しました。

fn make_resampler(from_rate: u32) -> Option<SincFixedIn<f32>> {
    if from_rate == 16000 { return None; }
    let params = SincInterpolationParameters {
        sinc_len: 256,
        f_cutoff: 0.95,
        interpolation: SincInterpolationType::Linear,
        oversampling_factor: 256,
        window: WindowFunction::BlackmanHarris2,
    };
    let ratio = 16000.0 / from_rate as f64;
    Some(SincFixedIn::<f32>::new(ratio, 2.0, params, 1024, 1)
        .expect("Failed to create resampler"))
}

cpalコールバックは小さなチャンク(256〜512サンプル)を返すので、内部バッファに蓄積してリサンプラーの要求サイズ(1024)に達したら処理する方式にしています。

5. 日本語Moonshine Voice固有の注意点

transcriber = Transcriber(
    model_path=model_path,
    model_arch=model_arch,
    max_tokens_per_second=13.0,  # 非ラテン文字言語では必須!
)
  • max_tokens_per_secondを13.0に設定:デフォルト値だと日本語のようなトークン密度の高い言語でハルシネーションが多発する
  • 短すぎるセグメントを無視:ノイズによる誤検出を抑制
  • 言語別の設定は transcriber_config.py に外部化して管理

6. PyInstallerによるPython sidecarのバイナリ化

ユーザーにPython環境を要求しないために、PyInstallerでスタンドアロンバイナリにパッケージングしています。

# ARM64 (Apple Silicon) でビルド → 約81MBのバイナリ
pyinstaller transcriber.spec

Tauriの externalBin 設定で、ビルド時にバイナリを自動バンドル。実行時はバイナリ優先 → Python venvフォールバックの二段構え。

フロントエンド

React 19 + Tailwind CSSで構築。状態管理はカスタムHooksに集約しています。

// useTranscription.ts
const {
  lines,          // TranscriptLine[] — 確定済みの文字起こし行
  currentLine,    // 進行中の行(リアルタイム更新)
  isRecording,
  duration,       // 録音経過時間
  start,          // invoke("start_transcription")
  stop,           // invoke("stop_transcription")
} = useTranscription();

Tauri commands/eventsとのやり取りは lib/tauri-commands.ts に集約し、コンポーネントから直接 invoke() を呼ばないルールにしています。

課金・ライセンス管理

Stripe + Cloudflare Workers + Gmail API で構築:

  • Stripe Checkout で決済
  • Cloudflare Workers でWebhook受信 → KVにライセンス情報を保存
  • Gmail API でライセンスキーを自動送信
  • Rustクライアント:デバイスID生成(ioreg + hash)、オフライン猶予期間ロジック
  • 無料版は10分録音制限、5件履歴制限、3回エクスポート制限

まとめ

技術 採用理由
Tauri v2 軽量バイナリ+Rustでネイティブ API直接アクセス
ScreenCaptureKit 仮想オーディオデバイス不要でシステム音声取得
Moonshine Voice リアルタイムストリーミング対応のオンデバイスASR
stdin/stdout JSON-RPC シンプルで堅牢なプロセス間通信
crossbeam bounded channel ロックフリーで音声リアルタイムスレッドから安全に送信
rubato 高品質Sincリサンプリング
PyInstaller ユーザーにPython不要

Tauri + Rust + Python sidecar の組み合わせは、「ネイティブの性能が必要だが、ML/AIライブラリのエコシステムはPythonが強い」というケースで非常に有効です。同様のアーキテクチャは、画像認識やLLM推論をローカルで行うデスクトップアプリにも応用できるでしょう。

参考リンク

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?