0
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?

ブラウザだけで生成音声をA/B比較する:HTMLとJavaScriptで試聴ツールを作る

0
Posted at

87.png

生成音声を2案聞き比べるとき、ファイルを別々のプレーヤーで開くと再生位置がずれやすく、短い言い回しの差も追いにくくなります。

この記事では、HTMLとJavaScriptだけで動くローカル試聴ページを作ります。2つの音声を読み込み、再生位置を引き継ぎながらA/Bを切り替える構成です。サーバーや外部APIは使いません。

このツールでできること

  • ローカルにある2つの音声ファイルをA/Bとして読み込む
  • 共通のボタンで再生、一時停止、シークを操作する
  • 再生中に切り替えても、切り替え前の再生位置を引き継ぐ
  • 選択した音声を外部サーバーへ送信せず、ブラウザ内で再生する

ここで行う同期は、currentTime を使った秒単位の位置合わせです。単語単位の自動アラインメントではありません。冒頭の無音や読み上げ速度が違う音声では、同じ秒数でも別の語句が再生されます。音量の自動調整も行いません。

動作環境

次の環境でコードを再確認しました。

  • Windows 11 Pro(ビルド 26200)
  • Microsoft Edge 155.0.4283.62(Chromiumベース)
  • 16 kHz、モノラル、PCM WAVの合成テスト音声
    • A:2秒
    • B:3秒

index.html はローカルファイルとして直接開けます。開発用サーバーや追加ライブラリは不要です。

index.html を作る

次のコードを index.html として保存します。

index.html
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>音声 A/B 試聴</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 680px; margin: 3rem auto; padding: 0 1rem; line-height: 1.6; }
    .inputs { display: grid; gap: 1rem; margin: 1.5rem 0; }
    label { display: block; }
    .buttons { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
    button { font: inherit; padding: .5rem .9rem; cursor: pointer; }
    button:disabled { cursor: not-allowed; opacity: .45; }
    button[aria-pressed="true"] { background: #164e63; color: white; }
    #seek { width: 100%; }
    #status { color: #475569; }
  </style>
</head>
<body>
  <h1>音声 A/B 試聴</h1>
  <p>同じ原稿から作った2つの音声ファイルを選んでください。</p>
  <div class="inputs">
    <label>A の音声 <input id="fileA" type="file" accept="audio/*"></label>
    <label>B の音声 <input id="fileB" type="file" accept="audio/*"></label>
  </div>
  <div class="buttons">
    <button id="selectA" aria-pressed="true" disabled>A に切替</button>
    <button id="selectB" aria-pressed="false" disabled>B に切替</button>
    <button id="toggle" disabled>再生</button>
  </div>
  <label>再生位置 <input id="seek" type="range" min="0" max="1" step="0.1" value="0" disabled></label>
  <p id="time">0:00 / 0:00</p>
  <p id="status" role="status">音声ファイルを選択してください。</p>

  <script>
    const audio = { A: new Audio(), B: new Audio() };
    const urls = { A: null, B: null };
    let active = "A";
    const el = (id) => document.getElementById(id);
    const ready = (side) => audio[side].readyState >= 1;
    const clock = (sec) => {
      if (!Number.isFinite(sec)) return "0:00";
      return `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, "0")}`;
    };

    function render() {
      const current = audio[active];
      el("selectA").disabled = !ready("A");
      el("selectB").disabled = !ready("B");
      el("selectA").setAttribute("aria-pressed", String(active === "A"));
      el("selectB").setAttribute("aria-pressed", String(active === "B"));
      el("toggle").disabled = !ready(active);
      el("toggle").textContent = current.paused ? "再生" : "一時停止";
      const duration = Number.isFinite(current.duration) ? current.duration : 0;
      el("seek").disabled = !ready(active) || duration <= 0;
      el("seek").max = String(Math.max(duration, 1));
      el("seek").value = String(Math.min(current.currentTime || 0, duration));
      el("time").textContent = `${clock(current.currentTime)} / ${clock(duration)}`;
    }

    function load(side, file) {
      const player = audio[side];
      player.pause();
      player.removeAttribute("src");
      player.load();
      if (urls[side]) URL.revokeObjectURL(urls[side]);
      urls[side] = file ? URL.createObjectURL(file) : null;
      if (file) {
        player.src = urls[side];
        player.load();
      }
      el("status").textContent = file ? `${side}: ${file.name} を読み込み中` : `${side}: 未選択`;
      render();
    }

    async function switchTo(side) {
      if (!ready(side) || side === active) return;
      const previous = audio[active];
      const wasPlaying = !previous.paused;
      const position = previous.currentTime || 0;
      previous.pause();
      active = side;
      const next = audio[side];
      const limit = Number.isFinite(next.duration) ? Math.max(0, next.duration - 0.05) : position;
      next.currentTime = Math.min(position, limit);
      render();
      if (wasPlaying) {
        try { await next.play(); }
        catch { el("status").textContent = "再生できません。ファイル形式を確認してください。"; }
      }
    }

    for (const side of ["A", "B"]) {
      el(`file${side}`).addEventListener("change", (event) => load(side, event.target.files[0]));
      el(`select${side}`).addEventListener("click", () => switchTo(side));
      for (const type of ["loadedmetadata", "timeupdate", "play", "pause", "ended"]) {
        audio[side].addEventListener(type, () => {
          if (type === "loadedmetadata") el("status").textContent = `${side} の音声を読み込みました`;
          render();
        });
      }
      audio[side].addEventListener("error", () => {
        el("status").textContent = `${side}: このファイルは再生できません`;
        render();
      });
    }
    el("toggle").addEventListener("click", async () => {
      const player = audio[active];
      if (player.paused) {
        try { await player.play(); }
        catch { el("status").textContent = "再生できません。ファイル形式を確認してください。"; }
      } else {
        player.pause();
      }
    });
    el("seek").addEventListener("input", (event) => {
      if (ready(active)) audio[active].currentTime = Number(event.target.value);
    });
    window.addEventListener("pagehide", () => {
      for (const side of ["A", "B"]) if (urls[side]) URL.revokeObjectURL(urls[side]);
    });
    render();
  </script>
</body>
</html>

保存したファイルをブラウザで開き、AとBの音声を選択します。片方だけでも再生できます。両方を読み込むと、「A に切替」と「B に切替」が使えるようになります。

再生中に切り替えた場合は、現在の音声を停止してから、もう一方を近い再生位置から開始します。

実装のポイント

Object URLでローカル音声を読み込む

<input type="file"> で得た File オブジェクトを URL.createObjectURL() に渡すと、ブラウザ内で参照できる一時URLを作れます。そのURLを Audio オブジェクトの src に設定しています。

このコードには fetch() やアップロード処理がないため、選択したファイルを外部サーバーへ送りません。

同じ枠で別のファイルを選び直す前に、古いObject URLを URL.revokeObjectURL() で解放します。ページを閉じるときも pagehide でA/B両方のURLを解放します。

currentTime で再生位置を引き継ぐ

MDNのHTMLMediaElement.currentTimeの説明にあるとおり、currentTime は秒単位の再生位置を表します。

switchTo() では、切り替え前の currentTime を読み取り、切り替え先へ設定しています。切り替え先の音声が短い場合は、duration を超えない位置に収めます。

同時再生を防ぎ、再生失敗を処理する

A/Bを切り替える前に、再生中の Audio オブジェクトへ pause() を呼びます。2つの音声が重なった状態を避けるためです。

ブラウザの再生制限や未対応形式によって play() が失敗することもあります。このため await player.play() を try...catch で囲み、失敗時はステータス欄にメッセージを表示します。音声側の error イベントも別に処理しています。

進捗表示を再描画する

loadedmetadata、timeupdate、play、pause、ended の各イベントで render() を呼びます。これにより、ボタンの有効・無効、再生中のA/B、シークバー、経過時間の表示を現在の状態に合わせています。

動作確認の結果

上記の環境と合成テスト音声で、次の動作を確認しました。

操作 確認結果
初期表示 A/B切り替え、再生、シークは無効
Aだけ読み込む Aを再生でき、Bへの切り替えは無効
Bを追加で読み込む Bへの切り替えが有効になる
Aの再生位置を0.8秒にしてBへ切り替える Aが停止し、Bが同じ位置付近から再生される
一時停止・シーク 選択中の音声に反映される
Aへ別ファイルを読み直す 以前のObject URLが解放される
音声ではないファイルを読み込む エラーメッセージが表示される

accept="audio/*" はファイル選択画面のフィルターです。ブラウザがすべての音声形式をデコードできるという意味ではないため、実際に使うファイル形式でも再生を確認してください。

生成音声を比べるときの注意

同じ台本を異なる話し方で生成した場合や、句読点を変えた台本の出力を比較する場合に使えます。音量、冒頭の無音、台本の違いも記録しておくと、聞こえ方以外の条件を整理しやすくなります。

音声ファイルの作成元は問いません。FreeVoiceCloneも、500+ languagesで生成音声をプレビューできるサービスの一例です。無料プレビューには軽いビープ音の透かしが入り、透かしのないファイルを保存するにはダウンロードパスが必要です。この試聴ページへ読み込むのは、正当に保存・利用できるファイルに限ってください。声の本人から得た同意と、公開目的に必要なライセンスも別に確認する必要があります。

このHTMLは音声生成サービスとAPI連携しません。ローカルで選択したファイルを再生するだけです。

この実装の限界

秒数を引き継ぐだけなので、速度や無音区間が異なる2つの音声を完全には同期できません。音量差が大きい場合は、大きな音のほうを良く感じる可能性もあります。

比較条件をさらにそろえるなら、冒頭の無音を調整し、ラウドネスを合わせる方法があります。語句ごとの位置を合わせたい場合は、手動マーカーや音声アラインメントの仕組みが別途必要です。

0
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
0
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?