生成音声を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 として保存します。
<!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つの音声を完全には同期できません。音量差が大きい場合は、大きな音のほうを良く感じる可能性もあります。
比較条件をさらにそろえるなら、冒頭の無音を調整し、ラウドネスを合わせる方法があります。語句ごとの位置を合わせたい場合は、手動マーカーや音声アラインメントの仕組みが別途必要です。
