Transformers.js v4とWhisperで日本語音声文字起こしをブラウザ実行する

日本語音声を文字にする:マイクとWhisperのブラウザ文字起こしの見出し ブラウザAI
カテゴリー
ブラウザAI
公開日
2026.09.20

はじめに

会議録や動画字幕を作るために、音声ファイルを文字起こしサービスへアップロードすることがあります。しかし、社内会議や個人的な録音など、外部サービスへ送りにくい音声もあります。

Transformers.jsとWhisperの変換済みモデルを使うと、音声認識処理をWebブラウザ内で実行できます。

この記事では、次の機能を持つ日本語文字起こしアプリを作ります。

  • 音声ファイルを選択する
  • 音声をブラウザ内で再生する
  • 日本語として文字起こしする
  • 認識区間の開始時刻と終了時刻を表示する
  • 長い音声を30秒単位で分割して処理する
  • キャンセル時にWeb Workerを終了する

APIキーと文字起こし用サーバーは使用しません。

Whisperと多言語モデル

Whisperは音声認識に利用できるモデルです。Transformers.jsのautomatic-speech-recognitionパイプラインから、Transformers.js向けにONNX形式へ変換されたWhisperモデルを利用できます。

モデル名に.enが付いたものは英語専用です。日本語を認識する場合は、多言語版のwhisper-tinyなどを選びます。

whisper-tiny.en  英語専用
whisper-tiny     多言語版

今回は比較的軽いwhisper-tinyを使います。軽いモデルは端末内で試しやすい一方、大きなモデルより誤認識が増える可能性があります。人名、製品名、雑音、複数人の同時発話などでは、必ず人による確認が必要です。

なぜWeb Workerを使うのか

モデルの初期化と音声認識は重い処理です。メインスレッドだけで処理すると、ページ操作へ影響する場合があります。また、Transformers.jsのパイプライン呼び出しへ任意のタイミングで渡せる共通のAbortSignalがあるわけではありません。

そこで今回のアプリは、モデルと推論処理を専用Workerへ置きます。「キャンセル」ボタンではWorker自体をterminate()し、処理を止めます。

この方法には、キャンセル後にモデルインスタンスを作り直す必要があるという代償があります。ダウンロード済みファイルがブラウザキャッシュに残っていても、Worker内のメモリ上のモデルは失われます。

必要な環境

  • Node.js 22.12以降の22系、または24系(Viteの実行環境)
  • Web WorkerとES Modulesに対応したブラウザ
  • 初回モデル取得用のインターネット接続
  • 音声処理に必要な空きメモリ

Whisperモデルは画像分類モデルより処理負荷が高くなりやすいため、スマートフォンや低性能PCでは時間がかかったり、メモリ不足になったりする可能性があります。

プロジェクトを準備する

mkdir whisper-browser
cd whisper-browser
npm init -y
npm install @huggingface/transformers@4.2.0
npm install --save-dev vite@7.3.6

次の3ファイルを作ります。

whisper-browser/
├─ index.html
├─ main.js
└─ transcriber.worker.js

HTMLを作成する

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ブラウザ日本語文字起こし</title>
    <style>
      body { width: min(760px, calc(100% - 32px)); margin: 40px auto; font-family: system-ui; }
      audio, progress { width: 100%; margin-block: 12px; }
      button { padding: 8px 14px; margin-right: 8px; }
      li { margin-block: 8px; }
    </style>
  </head>
  <body>
    <main>
      <h1>日本語音声文字起こし</h1>
      <input id="audio-file" type="file" accept="audio/*" />
      <audio id="audio" controls hidden></audio>
      <div>
        <button id="start" type="button">文字起こし</button>
        <button id="cancel" type="button" disabled>キャンセル</button>
      </div>
      <p id="status" role="status" aria-live="polite">音声を選択してください。</p>
      <progress id="progress" max="100" value="0" hidden></progress>
      <h2>全文</h2>
      <p id="transcript"></p>
      <h2>認識区間</h2>
      <ol id="chunks"></ol>
    </main>
    <script type="module" src="/main.js"></script>
  </body>
</html>

Workerへ音声認識を実装する

transcriber.worker.jsへ次を記述します。

import { pipeline } from "@huggingface/transformers";

const MODEL_ID = "onnx-community/whisper-tiny";
let transcriberPromise;

function createTranscriber() {
  transcriberPromise ??= pipeline("automatic-speech-recognition", MODEL_ID, {
    device: "wasm",
    // Whisperはencoderとdecoderで量子化への感度が異なるため個別指定する
    dtype: {
      encoder_model: "fp32",
      decoder_model_merged: "q4",
    },
    progress_callback: (event) => {
      if (event.status === "progress" && Number.isFinite(event.progress)) {
        self.postMessage({ type: "progress", value: event.progress });
      }
    },
  }).catch((error) => {
    transcriberPromise = undefined;
    throw error;
  });

  return transcriberPromise;
}

self.addEventListener("message", async (event) => {
  if (event.data?.type !== "transcribe") {
    return;
  }

  const audioData = event.data.audioData;

  if (!(audioData instanceof Float32Array) || audioData.length === 0) {
    self.postMessage({ type: "error", message: "音声データが空です。" });
    return;
  }

  try {
    self.postMessage({ type: "status", message: "モデルを準備しています。" });
    const transcriber = await createTranscriber();
    self.postMessage({ type: "status", message: "音声を認識しています。" });

    const result = await transcriber(audioData, {
      language: "japanese",
      task: "transcribe",
      chunk_length_s: 30,
      stride_length_s: 5,
      return_timestamps: true,
    });

    self.postMessage({ type: "complete", result });
  } catch (error) {
    console.error(error);
    self.postMessage({
      type: "error",
      message: error instanceof Error ? error.message : "文字起こしに失敗しました。",
    });
  }
});

Whisperのようなencoder-decoderモデルは、すべての部分へ同じ量子化方式を指定すると、認識精度の低下やモデル初期化エラーが起きる場合があります。Transformers.jsの量子化ガイドに従い、この例ではencoderをfp32、decoderをq4へ分けています。ダウンロード量とメモリ使用量は増えるため、公開前には採用モデルとTransformers.jsの組み合わせを固定して実音声で確認してください。

chunk_length_s: 30は音声を30秒単位で処理する指定です。stride_length_s: 5で前後5秒を重ね、分割位置付近の単語が欠けにくいようにします。重なりを増やすと計算量も増えます。

task: "transcribe"は日本語音声を日本語の文章へ変換する指定です。translateへ変えると翻訳タスクになりますが、この記事では文字起こしだけを扱います。

画面側を実装する

キャンセル時はWorkerを終了し、実行番号を更新して古い音声変換の結果を無視します。音声のデコード自体を直ちに停止する処理ではありませんが、キャンセル後にその結果から文字起こしが始まることを防ぎます。

main.jsへ次を記述します。

const fileInput = document.querySelector("#audio-file");
const audio = document.querySelector("#audio");
const startButton = document.querySelector("#start");
const cancelButton = document.querySelector("#cancel");
const status = document.querySelector("#status");
const progress = document.querySelector("#progress");
const transcript = document.querySelector("#transcript");
const chunks = document.querySelector("#chunks");

let worker;
let previewUrl;
let runId = 0;

function createWorker() {
  const nextWorker = new Worker(
    new URL("./transcriber.worker.js", import.meta.url),
    { type: "module" },
  );

  nextWorker.addEventListener("message", (event) => {
    if (worker === nextWorker) handleWorkerMessage(event);
  });
  nextWorker.addEventListener("error", () => {
    if (worker !== nextWorker) return;
    nextWorker.terminate();
    worker = undefined;
    finishWithError("Workerでエラーが発生しました。");
  });
  return nextWorker;
}

function formatTime(seconds) {
  if (!Number.isFinite(seconds)) return "--:--";
  const minutes = Math.floor(seconds / 60);
  const remain = Math.floor(seconds % 60);
  return `${String(minutes).padStart(2, "0")}:${String(remain).padStart(2, "0")}`;
}

function setRunning(running) {
  startButton.disabled = running;
  cancelButton.disabled = !running;
  fileInput.disabled = running;
}

async function decodeToMono16k(file) {
  const audioContext = new AudioContext();

  try {
    const decoded = await audioContext.decodeAudioData(await file.arrayBuffer());
    if (!decoded.duration || !Number.isFinite(decoded.duration)) {
      throw new Error("音声の長さを取得できません。");
    }

    const sampleRate = 16_000;
    const frameCount = Math.ceil(decoded.duration * sampleRate);
    const offline = new OfflineAudioContext(1, frameCount, sampleRate);
    const source = offline.createBufferSource();
    source.buffer = decoded;
    source.connect(offline.destination);
    source.start();

    const rendered = await offline.startRendering();
    return rendered.getChannelData(0).slice();
  } finally {
    await audioContext.close();
  }
}

function finishWithError(message) {
  setRunning(false);
  progress.hidden = true;
  status.textContent = message;
}

function handleWorkerMessage(event) {
  const message = event.data;

  if (message.type === "progress") {
    progress.hidden = false;
    progress.value = Math.max(0, Math.min(100, message.value));
  } else if (message.type === "status") {
    status.textContent = message.message;
  } else if (message.type === "error") {
    finishWithError(`文字起こしに失敗しました: ${message.message}`);
  } else if (message.type === "complete") {
    transcript.textContent = message.result.text.trim();
    chunks.replaceChildren();

    for (const chunk of message.result.chunks ?? []) {
      const li = document.createElement("li");
      const [start, end] = chunk.timestamp;
      li.textContent = `${formatTime(start)}–${formatTime(end)} ${chunk.text.trim()}`;
      chunks.append(li);
    }

    setRunning(false);
    progress.hidden = true;
    status.textContent = "文字起こしが完了しました。内容を確認してください。";
  }
}

fileInput.addEventListener("change", () => {
  if (previewUrl) URL.revokeObjectURL(previewUrl);
  const file = fileInput.files?.[0];

  if (!file) {
    audio.hidden = true;
    return;
  }

  if (!file.type.startsWith("audio/")) {
    fileInput.value = "";
    audio.hidden = true;
    status.textContent = "音声ファイルを選択してください。";
    return;
  }

  previewUrl = URL.createObjectURL(file);
  audio.src = previewUrl;
  audio.hidden = false;
  status.textContent = `${file.name}を選択しました。`;
});

startButton.addEventListener("click", async () => {
  const file = fileInput.files?.[0];

  if (!file) {
    status.textContent = "先に音声ファイルを選択してください。";
    return;
  }

  transcript.textContent = "";
  chunks.replaceChildren();
  const currentRun = ++runId;
  setRunning(true);
  status.textContent = "音声を16kHzモノラルへ変換しています。";

  try {
    const audioData = await decodeToMono16k(file);
    if (currentRun !== runId) return;
    worker ??= createWorker();
    worker.postMessage(
      { type: "transcribe", audioData },
      [audioData.buffer],
    );
  } catch (error) {
    if (currentRun !== runId) return;
    finishWithError(
      `音声を読み込めませんでした: ${error instanceof Error ? error.message : String(error)}`,
    );
  }
});

cancelButton.addEventListener("click", () => {
  runId += 1;
  worker?.terminate();
  worker = undefined;
  setRunning(false);
  progress.hidden = true;
  status.textContent = "文字起こしをキャンセルしました。";
});

音声ファイルのデコードにはWeb Audio APIのAudioContextが必要です。AudioContextは通常のWeb Workerでは利用できないため、メインスレッド側でファイルを読み込み、OfflineAudioContextでWhisperが期待する16kHz・モノラルへ変換します。WorkerへはFloat32Arrayを転送し、モデル推論だけをバックグラウンドで実行します。

postMessage()の第2引数へaudioData.bufferを指定しているのは、大きなPCM配列をコピーせずWorkerへ移すためです。転送後、メインスレッド側のaudioDataは使用できません。

起動する

npx vite

表示されたURLを開き、最初は10秒程度の明瞭な日本語音声で試します。モデルの初回取得と推論には時間がかかるため、短い音声で正常動作を確認してから長い音声へ進んでください。

動作確認

正常系

  • WAV、MP3などブラウザが読み込める音声を選択できる
  • 音声プレーヤーで内容を確認できる
  • 日本語の全文と区間別の時刻が表示される
  • 同じWorkerを使う間はモデルインスタンスが再利用される

エラー系

  • 音声未選択では開始しない
  • 初回ダウンロード中にオフラインならエラーになる
  • 処理中はファイル選択と開始ボタンを無効化する
  • キャンセルするとWorkerが終了し、次回は新しいWorkerを作る
  • 壊れた音声やブラウザ非対応形式ではエラーを表示する

よくあるトラブル

日本語が英語へ翻訳される

task: "transcribe"language: "japanese"を確認します。また、.en付きの英語専用モデルを選ばないでください。

固有名詞を間違える

Whisperは音声から聞こえた内容を推定するため、知らない製品名、人名、略語を誤認識することがあります。文字起こし結果をそのまま議事録として公開せず、元音声と照合してください。

キャンセル後の再開が遅い

Workerを終了すると、メモリ上のモデルも破棄されます。ブラウザキャッシュが使われればファイルの再ダウンロードを避けられる場合がありますが、モデルの初期化は再度必要です。

長い音声でメモリ不足になる

音声時間、モデルサイズ、チャンク設定、ブラウザの使用メモリを見直します。数時間の音声を一度に処理する用途では、音声をあらかじめ分割するか、サーバー側処理を検討してください。

制約

ブラウザ内で動くことは、常に高速であることを意味しません。推論速度は利用者の端末に依存します。また、初回モデル取得時には通信が発生し、モデルファイルがブラウザストレージを使用します。

Tinyモデルは試しやすい代わりに認識精度に限界があります。医療、法律、契約、字幕公開など、誤認識の影響が大きい用途では必ず人による確認と適切な運用が必要です。

関連記事

まとめ

Transformers.jsの音声認識パイプラインでは、多言語Whisperモデルを使った日本語文字起こしをブラウザ内へ実装できます。

実用的な画面にするには、モデル取得の進捗、長い音声のチャンク処理、時刻表示、入力エラーに加え、重い処理を止めるためのWorker設計も必要です。

参考リンク

コメント

タイトルとURLをコピーしました