10
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WhisperとTauri(Rust+Typescript)で自動文字起こしアプリ開発

Posted at

※この記事は文系的冗長性及び反知性的曖昧主義に支配されています1

葬送の限界事務職員

事務職御用達コラボレーションツール「Teams」にライブ文字起こし機能が導入されました2。幹部会議の議事録作成に命を燃やす我ら事務職員にとって、使わない手はありません。しかし同機能をオンにすると、すべての参加者にそれが通知されてしまいます。ここで文系的危機回避能力を発動、事前伺いを実行!

上司A「弊社のセキュリティ規定でトランスクリプト機能を使って良いのは機密性2情報までだよ?」
上司B「この会議は機密性2だけど機密性3の扱いだよ?」
上司C「M社に情報抜かれない保証は?ポリシーではだめだよ、相対契約にそう書いた?」
同期達「同期で昼食会しよう!会費は一人4,000円ね!」

_人人人人人人人人人人人人人人人人人人人_
> まるで!御伽の話!終わり!迎えた証! <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y ̄

そうだね、従来手法では自動文字起こしできないね。
でも①日本語の自動文字起こし処理をローカルで、②実用的な速度で走らせて、③誰でも使えるGUIで実装すれば、魔物を倒せるよね。勇者ヒンメルがそうしたように。

というわけで、自動文字起こし可能なアプリPothookを開発しました。本稿ではこれを解説します。MIT Licenseでソースコードを公開していますので、whisper-rsTauriを使う予定のある方はご活用ください。4,000円は、補正予算確保に向けて家庭内折衝します。

Pothook GitHub
Pothook

自動で文字起こしする魔法

……とはいえ、さすがにM社のトランスクリプト処理に匹敵する品質のツールを書き起こすことは不可能です。わたしは文系だからspeech recognition modelのことがよくわからなくて、”文字起こしを知る”ために旅をしていたところ、その途中で”Whisper”と”Tauri”を知ったので、以下の方針でアプリ開発を行うこととしました。

① ローカルで日本語の自動文字起こし処理をする魔法

「自動文字起こし」が可能なツールは世にいくつも存在しますが、限界事務職員に予算はありません。ここまでくれば読者諸賢老は勘付かれたことと思います。そう、高品質な日本語文字起こしが可能なツールといえばゾルトラークもといOpenAI社のWhisperです。ただし、Whisperはグラボの使用を前提としているため、事務職員用の貧弱なノートPCでは実用的な速度が出ません。

② 実用的な速度で自動文字起こしする魔法

あれやこれや懊悩していたところ、Whisper.cppを発見。早速、手元の環境で走らせてみたところ、リアルタイム処理とまではいきませんが、それなりの速度が出るではないですか。加えてご丁寧にRustのラッパーまであります。

③ GUIでWhisper.cppを叩く簡潔な魔法

Whisper.cppはCLIツールです。事務職員に広く使ってもらうことは難しいですが、RustのラッパーがあるならTauriで簡単かつ迅速にGUI開発が可能です。Nullを許さない鬼軍曹ことRust・Typescriptと、geocities世代なら誰でも書けるHTMLの組み合わせは、まさに文系に最適です。業務用携帯がガラケーな弊社ではモバイルアプリを導入できないため、Tauriが必要十分です。3

女神様の聖典(ツールキット)

ここからは自動文字起こしアプリPothookの主要部分について解説を行います。詳細を知りたい方は実際のコードをご確認ください。

Tauri の章

Tauriは、ウェブ技術を駆使してWindows・Mac・LinuxといったPC用のGUIを構築できるアプリ構築ツールキットです。コアライブラリ(Core Process)はRustで、ユーザーインターフェース(WebView Process)はフロントエンド技術で記述しますElectronと比較してファイルサイズやメモリ使用量の軽い点が注目されがちですが、個人的にはRustで書かれた既存資産をCargoで手軽に依存追加できる点に魅力を感じました。PWAChrome拡張からOS側に踏み込みつつ、簡単・安全に書ける安心感は至高です。

クイックスタートに従えば、フロントエンドをかじったことのある方であれば違和感なく取り掛かれるかと思います。PothookではフロントエンドツールとしてViteを利用しただけでフレームワークは無しとしましたが、GUIは状態管理が複雑化するため、使えるのであれば素直にNext.jsの使用をお勧めします。あとで後悔します。というかしました。

先述のとおりWhisper.cppにはRustのラッパー(whisper-rs)があるので、Core Process側でそれを呼び出します。情報の入力・出力はWebView Process側で行う必要があるため、プロセス間通信により情報のやり取りをします。といってもTauriがほとんどの処理を隠蔽してくれるため複雑な記述は不要です。まずはWebView Processの情報をCore Processへ渡します。

whisper.ts
  import { invoke } from "@tauri-apps/api/tauri";
  
  public async callWhisper(): Promise<boolean> {
    /* 略 */
    await invoke("whisper", {
      pathToWav: this.pathToWav,
      pathToModel: this.pathToModel,
      lang: this.lang,
      translate: this.translate,
      offsetMs,
      durationMs,
    });
    return true;
  }

main.rs
#[tauri::command]
async fn whisper(
    path_to_wav: &str,
    path_to_model: &str,
    lang: &str,
    translate: bool,
    offset_ms: i32,
    duration_ms: i32,
    app: tauri::AppHandle,
) -> Result<(), String> {
  /* 略 */
}

fn main() {
    tracing_subscriber::fmt::init();
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![check_wav, audio_conv, whisper])
        /* 略 */
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

次にCore Processの出力をWebView Processに戻します。

whisper.rs
pub async fn run(
    path_to_wav: &str,
    path_to_model: &str,
    lang: &str,
    translate: bool,
    offset_ms: i32,
    duration_ms: i32,
    app: tauri::AppHandle,
) -> Result<(), String> {
    /* 略 */
    app.emit_all(
        "whisper",
        /* ここにペイロードを記載 */
    )
    .unwrap();
    /* 略 */
}
main.ts
import { listen } from "@tauri-apps/api/event";

(async () => {
  await listen<WhisperPayload>("whisper", (event) => {
    /* ここにペイロードに対する処理を記載 */
  });
})();

whisper-rs の章

whisper-rsはあくまでWhisper.cppのラッパーなので、使用感はWhisper.cppと同じです。パラメーターをsetしてコンテキストをnewしてステートをcreateすればバーン!です4

whisper.rs
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};

pub async fn run(
    path_to_wav: &str,
    path_to_model: &str,
    lang: &str,
    translate: bool,
    offset_ms: i32,
    duration_ms: i32,
    app: tauri::AppHandle,
) -> Result<(), String> {
    /* 略 */
    let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
    params.set_language(Some(lang));
    params.set_translate(translate);
    params.set_offset_ms(offset_ms);
    params.set_duration_ms(duration_ms);
    params.set_tdrz_enable(true);
    unsafe {
        params.set_new_segment_callback(Some(whisper_callback));
        params.set_new_segment_callback_user_data(Box::into_raw(Box::new(app.clone())) as *mut c_void);
    }
    let context =
        WhisperContext::new_with_params(path_to_model, WhisperContextParameters::default())
            .map_err(|_| /* 略 */)?;
    let mut state = context
        .create_state()
        .map_err(|_| /* 略 */)?;
    /* 略 */
    Ok(())
}

一点、注意が必要なのは状態のコールバック関数にFFIを使う点です。Whisper.cppは処理が速いと言っても、ユースケース(e.g. 1時間の会議の文字起こし)によっては処理が完了するまで数時間かかります。状態の通知は必須です。そのためtauri::AppHandleBoxに格納して無理やり呼び出しています。ポインタの解放は忘れずに。

whisper.rs
use libc::c_void;

unsafe extern "C" fn whisper_callback(
    _: *mut whisper_rs_sys::whisper_context,
    ptr: *mut whisper_rs_sys::whisper_state,
    _: i32,
    app: *mut c_void,
) {
    let i_segment = unsafe { whisper_rs_sys::whisper_full_n_segments_from_state(ptr) } - 1;
    let ret = unsafe { whisper_rs_sys::whisper_full_get_segment_text_from_state(ptr, i_segment) };
    if ret.is_null() {
        return;
    }
    let payload = WhisperPayload {
      /* 略 */
    };
    /* 略 */
    let box_app = Box::from_raw(app as *mut tauri::AppHandle);
    box_app.emit_all("whisper", payload).unwrap();
    _ = Box::into_raw(box_app);
}

Symphonia と rubato の章

Whisper.cppは入力音声ファイルが16bit 16KHz モノラルのWAVファイルである必要があります。しかし、事務職員は普通FFmpegのようなCLIツールを使えません。使えるならWhisper.cppをCLIで叩いています。そのため音声の前処理もアプリ側で吸収してやる必要があります。パッと思いつくのがFFmpeg sidecarやFFmpegのRust向けラッパーですが、いずれも別途バイナリが必要で、それに弊社のセキュリティソフトが反応するため実行毎に許可が必要になります。またAudioContextの活用も候補の一つでしたが、そもそも長時間の音声変換・書き出しを想定しておらず、処理に時間がかかります。
そこでPothookで採用したのがRustのライブラリSymphoniaです。大体の動画・音声ファイルを読み込んでくれます。下準備やパラメータが多かったり、処理前に曲全体の長さを測るため一度ファイルを全走査しなければいけなかったり、走査ループ処理はErrで止めたりとクセが強いですが、stack overflowと腹を括れる男気5があればなんとかなります。

audio_conv.rs
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL};
use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo};
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;

pub async fn run(path_in: &str, path_out: &str, app: &tauri::AppHandle) -> Result<(), String> {
    /* 略 */
    let mut hint = Hint::new();
    hint.with_extension(/* 略 */);
    let meta_opts: MetadataOptions = Default::default();
    let fmt_opts: FormatOptions = Default::default();
    let probed = symphonia::default::get_probe()
        .format(&hint, mss, &fmt_opts, &meta_opts)
        .map_err(/* 略 */)?;
    let mut format = probed.format;
    let track: &symphonia::core::formats::prelude::Track = format
        .tracks()
        .iter()
        .find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
        .ok_or_else(/* 略 */)?;
    let dec_opts: DecoderOptions = Default::default();
    let mut decoder = symphonia::default::get_codecs()
        .make(&track.codec_params, &dec_opts)
        .map_err(/* 略 */)?;
    let track_id = track.id;
    let input_sample_rate = track.codec_params.sample_rate.unwrap() as f64;
    let mut n_frames: u64 = 0;
    loop {
        let packet = match format.next_packet() {
            Ok(packet) => packet,
            /* 略 */
            Err(symphonia::core::errors::Error::IoError(err)) => {
                if err.kind() == std::io::ErrorKind::UnexpectedEof {
                    break;
                }
                /* 略 */
            }
            /* 略 */
        };
        /* 略 */
    }
    format
        .seek(SeekMode::Coarse, SeekTo::TimeStamp { ts: 0, track_id })
        .map_err(/* 略 */)?;
    let mut waves_in = vec![vec![0.0f32; n_frames as usize]; 1];
    loop {
        let packet = match format.next_packet() {
            Ok(packet) => packet,
            /* 略 */
            Err(symphonia::core::errors::Error::IoError(err)) => {
                if err.kind() == std::io::ErrorKind::UnexpectedEof {
                    break;
                }
                /* 略 */
            }
            /* 略 */
        };
        /* 略 */
        match decoder.decode(&packet) {
            Ok(decoded) => {
                let mut sb: SampleBuffer<f32> = SampleBuffer::new(packet.dur, *decoded.spec());
                sb.copy_planar_ref(decoded);
                let samples = sb.samples();
                (0..packet.dur as usize).for_each(|idx| {
                    waves_in[0][packet.ts as usize + idx] = samples[idx];
                });
            }
            /* 略 */
        }
    }
}

なおSymphonia単体ではリサンプリング処理ができませんが、rubatoを使うことでWhisper.cppでの使用に耐える補間が可能です。

audio_conv.rs
use rubato::{
    Resampler, SincFixedIn, SincInterpolationParameters, SincInterpolationType, WindowFunction,
};

pub async fn run(path_in: &str, path_out: &str, app: &tauri::AppHandle) -> Result<(), String> {
    /* 略 */
    let waves_out = if input_sample_rate == 16000. {
        waves_in
    } else {
        SincFixedIn::<f32>::new(
            16000. / input_sample_rate,
            2.0,
            SincInterpolationParameters {
                sinc_len: 256,
                f_cutoff: 0.95,
                interpolation: SincInterpolationType::Linear,
                oversampling_factor: 256,
                window: WindowFunction::BlackmanHarris2,
            },
            waves_in[0].len(),
            1,
        )
        .map_err(/* 略 */)?
        .process(&waves_in, None)
        .map_err(/* 略 */)?
    };
    /* 略 */
}

daisyUI と dual range slider の章

ところでPothookのような業務用ツールではいくら見た目を拘らないと言っても、ある程度は整えてやる必要があります。大魔法使いフランメもそう言ってました6。Pothookではコーディングスピード向上のためTailwind CSSを採用したこともあり、デザインはdaisyUIを使ってみました。Viteのホットリローディングと相性は抜群です。

tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
  content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
  daisyui: {
    themes: ["emerald"],
  },
  theme: {
    extend: {},
  },
  plugins: [require("daisyui")],
};

ところでGUI作成にあたり壁となるのが文字起こし範囲の指定方法です。開始時間と終了時間を文字入力させても良いですが、せっかくのGUIなのでスライダーを使いたいところ。一方で<input type="range">はrangeという名前の割につまみが一つしかありません。なければ作るだけです。幸い、先人の知恵が蓄積されているため、ありがたく活用します。具体的には、<input type="range">を二つ重ねて、JavascriptとCSSでつまみ処理を実装します。

index.html
<div id="dual-range-slider" class="range_container flex-col w-full mx-auto hidden">
  <div class="label mb-2">
    <span class="label-text">文字起こし範囲</span>
  </div>
  <div class="sliders_control relative">
    <input id="fromSlider" type="range" value="0" min="0" max="1000" />
    <input id="toSlider" type="range" value="1000" min="0" max="1000" />
  </div>
  <!--  略 -->
</div>
dual_range_slider.ts
export function controlFromSlider(fromSlider: HTMLInputElement, toSlider: HTMLInputElement, fromInput: HTMLInputElement) {
  const [from, to] = getParsed(fromSlider, toSlider);
  fillSlider(fromSlider, toSlider, toSlider);
  if (from > to) {
    fromSlider.value = to.toString();
    /* 略 */
  } else {
    /* 略 */
  }
}
export function controlToSlider(fromSlider: HTMLInputElement, toSlider: HTMLInputElement, toInput: HTMLInputElement) {
  /* 略 */
}
export function fillSlider(from: HTMLInputElement, to: HTMLInputElement, controlSlider: HTMLInputElement) {
  const rangeDistance = parseInt(to.max) - parseInt(to.min);
  const fromPosition = parseInt(from.value) - parseInt(to.min);
  const toPosition = parseInt(to.value) - parseInt(to.min);
  /* 略 */
}
export function setToggleAccessible(currentTarget: HTMLInputElement) {
  const toSlider = document.querySelector("#toSlider") as HTMLInputElement;
  if (Number(currentTarget.value && toSlider) <= 0) {
    toSlider.style.zIndex = "2";
  } else {
    toSlider.style.zIndex = "0";
  }
}
index.css
input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  pointer-events: all;
  /* 略 */
  cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
  /* 略 */
}
input[type="range"]::-webkit-slider-thumb:hover {
  /* 略 */
}
input[type="range"] {
  -webkit-appearance: none;
  appearance: none;
  /* 略 */
  position: absolute;
  pointer-events: none;
}

落ち穂拾いの章

実際に文字起こしさせてわかりましたが、Whisperの出力は完全ではなく、ある程度は手動修正が必要です。そのため文字起こし出力画面を非同期表示として、処理中でも直接手動修正できる仕様としています。
また、弊社はWindowsとMacが混在する環境です。Rustなのでクロスコンパイルできるかな?と思って試したところ、whisper-rsを数行修正するだけでできてしまいましたが、どうせGitHubで公開するのでGitHub Actionsにお願いすることにしました。Tauri公式もそれを推奨しています。バイナリファイルはリリースページで公開していますのでご参照ください。
限界事務職員は予算がないのでGitHub Copilotは使っていませんが、PothookのREADMEはBing Chat先生にご指導・ご鞭撻いただきました。具体的には、怪しい英語で一発書きした下書きを「GitHubのREADME風に添削して」とお願いしていて作成しています。おかげでなんとなくそれっぽい、どこかで見たような絵文字がたくさんついています。文脈を指定して英文添削してもらえるというのは革命的ですね。
今後は国1化もといi18nやリアルタイム処理、辞書機能追加を試したいですが、私のユースケースは満たしたので、どこかの誰かに任せます。

Anytime Anywhere

世の中、DXといえばM社のPowerなんとかが使われがちですが、Whisperのような重たい処理にはやはりTauriです。Pothook開発とその解説を通して、文系でも組めるし事務職員でも使いやすいというポテンシャルを示せたかと思います。何よりコーディングが楽しいです。文系こそRustとTypescriptを活用しましょう!
なお、限界事務職員に予算はないためM社やA社の開発者登録をしていません。Pothookはアプリストア等での配布はなく、インストール時に警告がバンバンでます。PothookはMITライセンスにしてあるので、事務職に寄り添える心あるどなたかが改善してアプリ登録してくださることを祈ります。

後日譚

セキュリティ部「新しいツールを導入したい?事前登録が必要です。ツールの作者は?自作?認証のない野良ツールだからインストール時に警告が出る?セキュリティをどう担保するの?」

やめて!セキュリティ部の特殊能力で、自作ツールを焼き払われたら、闇のゲームでコードと繋がってる著者の精神まで燃え尽きちゃう!

お願い、死なないで著者!ここを耐えれば、上司に勝てるんだから!

次回「著者死す」デュエルスタンバイ!

  1. 良い記事を書くためのガイドライン?知らんなぁ……。

  2. 事務職員は技術のキャッチアップが遅い。なおSlackは幹部の了解が得られない模様。

  3. 私は何か選択を

  4. 大体なんでも切る魔法(レイルザイデン)

  5. 殴り合いじゃァァァァッ!!!!

  6. アプリの偉さはわかりづらい。だから着飾って見た目でわかるようにするんだ。

10
10
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
10
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?