目次

目次

非圧縮WAV音源を再生出来るWEBプレイヤーを実装してみた。

山本晟弘
山本晟弘
最終更新日2025/12/23 投稿日2025/12/24

この記事はレコチョク Advent Calendar 2025 の24日目です。

こんにちは、山本です。 レコチョクのWEB領域でバックエンドをメインに開発しています。 今回は個人的に「非圧縮WAV音源を再生できるWEBプレイヤー」を実装することがあり、その技術的なポイントをご紹介します。

はじめに

Apple MusicなどのサービスのWEBプレイヤーでは、m4aやmp3といった圧縮音源が一般的に使用されています。圧縮音源は転送量が抑えられ、再生までの速度も速くなり、音質も一定以上が担保されています。

ただ、自分はアンビエントミュージックなどの音響音楽を聴くのですが、WAV音源と比較して聴いたときの奥行き感や音質の差を感じることがたまにあります。 アーティストが意図した音像をそのままWEBでも再生できればと思い、非圧縮WAV音源のWEBストリーミング再生を実装してみることにしました。

実装したWEBプレイヤーURL
https://pub-fc4893efd32f4f0b8d43d3c36b7e4dfc.r2.dev/index.html

課題と解決方法

やはり実現する上で大きな課題は、WAV音源のファイルサイズが大きいため、ダウンロードして再生するまでの待ち時間です。特に、通信環境が悪い場所では再生が難しい可能性もあります。

この課題を解決するため、動画配信でよく使われるHLS(HTTP Live Streaming)のインデックスファイル(.m3u8)を活用しました。

インデックスファイルとは、プレイリストと呼ばれることもあるファイルで、セグメント化されたファイルの順番や秒数、ファイル名を定義してスムーズに再生させるように記述されています。

(例)index.m3u8

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-ALLOW-CACHE:YES
#EXT-X-TARGETDURATION:11
#EXTINF:10.031020,
segment_000.wav
#EXTINF:10.031020,
segment_001.wav
#EXTINF:9.938141,
segment_002.wav

""" 中略 """

#EXTINF:10.031020,
segment_013.wav
#EXTINF:2.560204,
segment_014.wav
#EXT-X-ENDLIST

また、今回は非圧縮音源のまま数秒ごとに音源をセグメント化しておくことで、音源データ全てのロードが完了する前に再生を開始することができます。 これにより、再生音質を保ったままダウンロード待ち時間を大幅に短縮することが出来ます。

システム構成

実装したシステム構成は、以下のようになります。

arch.png

大まかな流れとしては、

  • 事前にWAV音源を分割して、WEB上(R2)に公開
  • Next.jsでWEBプレイヤーを実装し、ブラウザ上でR2から取得し再生
  • Service Workerを介して通信を行うようにすることで、2回目以降はキャッシュから取得

使用バージョン

Python: 3.12.8
Next.js: 15.5.4
FFmpeg: 8.0

以下、音源処理に関わる具体的な実装を抜粋して紹介します。

非圧縮セグメント音源とインデックスファイルの作成

まず、入手したWAV音源を分割し、WEB上に公開する必要があります。 FFmpegを使ってセグメント音源とインデックスファイルを作成し、boto3でCloudflare R2にアップロードするプログラムをPythonで実装しました。

GitHubリポジトリ
https://github.com/portfolio-team/wav-hls-converter

以下、FFmpegでセグメント音源を作成する箇所の実装です。


""" 中略 """

def cmd_to_uncomp_hls(input_wav: str, output_dir: str) -> list:
    """WAVを非圧縮フォーマットでHLS形式に変換"""
    """セグメントの長さは 10 秒"""

    allow_sample_rates = [44100, 48000, 88200, 96000, 176400, 192000]
    with wave.open(input_wav, 'rb') as wav_file:
        bits_sample = wav_file.getsampwidth() * 8  # サンプル幅(バイト)をビットに変換
        sample_rate = wav_file.getframerate()
    if not (sample_rate in allow_sample_rates):
        raise ValueError(f"Unsupported sample rate: {sample_rate}")

    cmd = [
        "ffmpeg",
        "-i", input_wav,  # 入力ファイル
        "-ar", str(sample_rate),  # 出力音声のサンプリングレート
        "-ac", "2",  # チャンネル数にステレオを指定
        "-f", "segment",  # 出力フォーマットに分割出力を指定
        "-segment_time", "10",  # 1分割の長さ(10 秒ごとに分割)
        "-segment_format", "wav",  # 分割後ファイルの形式
        "-segment_list", "index.m3u8",  # インデックスファイル名
        "-segment_list_type", "m3u8",  # インデックスファイル形式
    ]

    # 音声コーデックの指定
    # インプット音源に合わせて16bit PCM or 24bit PCMを指定
    if bits_sample == 24:
        cmd.extend(["-c:a", "pcm_s24le"])
    else:
        cmd.extend(["-c:a", "pcm_s16le"])

    cmd.append(os.path.join(output_dir, "segment_%03d.wav"))

    return cmd

""" 中略 """

これはffmpegコマンドを作成する関数です。 セグメント音源を生成するときにデータを圧縮してしまわないように、インプット音源のビットレートとサンプリングレートを読み取り、同じ値を設定するようにしています。

例えば、「48000hz」「24bit」の音源の場合は次のようなコマンドが実行されます。

ffmpeg -i ../input_wav_file/インストDEMO_48_24.wav -ar 48000 -ac 2 -f segment -segment_time 10 -segment_format wav -segment_list index.m3u8 -segment_list_type m3u8 -c:a pcm_s24le /var/folders/_l/gw112rvn0974pbfcjk2hk765_dzh44/T/tmp1tkt6lh8/segment_%03d.wav

ビットレートとサンプリングレートを保持したままで、「10秒」ごとに分割されたWAVファイルとindex.m3u8が生成されます。

image-20251211085549641.png
image-20251211085605167.png

WEBプレイヤーの実装

今回ブラウザ上でセグメント音源を再生するプレイヤーをNext.jsで実装し、SSGで生成した静的ファイルを公開しました。

GitHubリポジトリ
https://github.com/portfolio-team/ambient-player

HLS形式の再生には「hls.js」などのJavaScriptライブラリを使用できますが、今回の非圧縮であるPCMのWAV音源には対応していませんでした。。 そのため、今回はJavaScript標準APIの「Web Audio API」を使用して実装を行いました。

ただ、Web Audio APIはインデックスファイルを直接扱う機能がないため、

  1. index.m3u8の中身からセグメントファイルの一覧を取得
  2. セグメントファイルをロードしつつ、その再生順を制御する

という実装を自前で行う必要があり、ここが大分苦労しました。。

1. index.m3u8の中身からセグメントファイルの一覧を取得

const R2_BASE_URL = "****(ファイルを配置しているURL)"; 

export async function fetchSegmentList(m3u8Path: string): Promise {
    const res = await fetch(`${R2_BASE_URL}/${m3u8Path}`);
    const text = await res.text();

    // #EXTINF 行の次の行がURL
    const lines = text.split("\n").map((l) => l.trim());
    const segmentUrls: string[] = [];

    for (let i = 0; i < lines.length; i++) {
      if (lines[i].startsWith("#EXTINF")) {
        const next = lines[i + 1];
        if (next && !next.startsWith("#")) {

          segmentUrls.push(`${R2_BASE_URL}/${next}`);
        }
      }
    }
    console.log(segmentUrls);

    return segmentUrls;
  }

この関数を呼び出すことで、セグメントファイルの一覧を取得できるようになっています。

2. セグメントファイルをロードしつつ、その再生順を制御する

// 定数定義
const DEFAULT_PRELOAD_COUNT = 3;

// セグメントを順次再生する関数
export async function playSegmentsSequentially(segmentUrls: string[]) {
  const audioCtx = new AudioContext();
  const preloadCount = DEFAULT_PRELOAD_COUNT;

  // 再生スケジュールの初期化
  let playTime = 0;

  // 最初のセグメントをプリロード
  const [preloadBuffers, elapsedTime] = await preloadInitialSegments(audioCtx, segmentUrls, preloadCount);
  playTime += elapsedTime / 1000;
  playTime = await scheduleBuffers(audioCtx, preloadBuffers, playTime);

  // 残りのセグメントを順次ロード&再生
  await scheduleRemainingSegments(audioCtx, segmentUrls, preloadCount, playTime);
}

// 最初のセグメントをプリロードする
async function preloadInitialSegments(
  audioCtx: AudioContext,
  segmentUrls: string[],
  preloadCount: number
): Promise {
  const startTime = performance.now();

  const preloadBuffers = await Promise.all(
    segmentUrls.slice(0, preloadCount).map(loadBuffer(audioCtx))
  );

  const endTime = performance.now();
  const elapsedTime = endTime - startTime;
  console.log(`Preloaded ${preloadCount} segments in ${elapsedTime}ms`);

  return [preloadBuffers, elapsedTime];
}

// バッファをスケジュールして再生時間を更新する
async function scheduleBuffers(
  audioCtx: AudioContext,
  buffers: AudioBuffer[],
  initialPlayTime: number
): Promise {
  let playTime = initialPlayTime;

  for (const buffer of buffers) {
    await scheduleBufferSource(audioCtx, buffer, playTime);
    playTime += buffer.duration;
  }

  return playTime;
}

// 残りのセグメントを順次ロード&再生する
async function scheduleRemainingSegments(
  audioCtx: AudioContext,
  segmentUrls: string[],
  preloadCount: number,
  initialPlayTime: number
) {
  let playTime = initialPlayTime;

  for (let i = preloadCount; i < segmentUrls.length; i++) {
    const buffer = await loadBuffer(audioCtx)(segmentUrls[i]);
    await scheduleBufferSource(audioCtx, buffer, playTime);
    playTime += buffer.duration;
  }
}

// オーディオバッファをロードする
function loadBuffer(audioCtx: AudioContext) {
  return async (url: string): Promise => {
    const res = await fetch(url);
    const arrayBuffer = await res.arrayBuffer();
    return await audioCtx.decodeAudioData(arrayBuffer);
  };
}

// バッファをスケジュールして再生する
async function scheduleBufferSource(
  audioCtx: AudioContext,
  buffer: AudioBuffer,
  playTime: number
) {
  const src = audioCtx.createBufferSource();
  src.buffer = buffer;
  src.connect(audioCtx.destination);
  src.start(playTime);
}

セグメントを順次ロードし、AudioContextで制御します。各セグメントのロードが完了するたびに再生予約を設定することで、完全なロードを待たずに再生できます。

工夫した点としては、最初の3セグメント分を先にロードしてから再生を行うことで、ロード待ちを再生が追い越すリスクを低減していることです。

動作結果

良好な通信環境

下り260Mbpsのネットワーク環境下で試したところ、最初の3セグメントのロードが1.5秒程度で完了し、すぐに再生が開始されました。再生中に音が途切れたりガタつくことなく視聴もできました。

image-20251211085626691.png

高速4G環境

試しにChromeでネットワークの通信速度を「高速4G」に制限したところ、最初の3セグメントのロードに9.5秒程度かかりましたが、こちらも再生は順調です。

image-20251211085645955.png

低速4G環境

次に、さらに通信速度を「低速4G」に落としてみたところ、最初のロードに49秒もかかってしまいました。 さらに、その後も1セグメントあたり16秒ほどかかってしまうため、1セグメント10秒の音源ではロード待ちに再生が追いついてしまい、再生が途切れる結果となりました。。

image-20251211085655185.png

終わりに

今回、WAV音源の再生には成功しましたが、低速のネットワーク環境での安定性に課題がありました。 今後はセグメントの長さを可変にして調整してみたり、CDNやストリーミングサーバーなど活用して、低速な環境でも再生を可能にするような方法を模索したいと思います。

明日のレコチョク Advent Calendar 2025 は25日目「音楽アプリのLiquid Glass対応を検証してわかったこと」です。お楽しみに!

参考文献

山本晟弘

目次