by MintJams

Webブラウザでラジオの「曲名」を取得する:ICYメタデータ解析とCORSフォールバック設計

ブラウザ単体では取得できないラジオのICYメタデータ(曲名情報)の抽出ロジックと、Web Audio APIのCORS制約を回避する再生エレメントの冗長化設計について解説します。

Webブラウザの <audio> 要素でラジオストリームを再生する際、標準のJavaScript APIからはストリーム内に埋め込まれている「現在流れている曲名(ICYメタデータ)」を取得することができません。また、Web Audio APIを利用してイコライザーやスペクトラムアナライザー(視覚エフェクト)を描画しようとすると、CORS(Cross-Origin Resource Sharing)の制約によって音声の読み込み自体がブロックされる課題が発生します。

本記事では、これらの課題(ICYメタデータの取得 / CORS制約下の音声再生)を解決するため、MintJams CMSのラジオアプリで採用しているアーキテクチャについて解説します。

この記事でわかること

  • ICYメタデータの抽出ロジック:HTTPレスポンスのインターリーブ構造から曲名情報を解析・抽出する仕組み
  • 音声再生のフォールバック構成:Web Audio API(CORS必須)と通常の音声再生(CORS不要)を組み合わせる手法
  • 安全なリカバリ手順:HTML5 Audioにおけるストリーム接続エラー時の適切な復旧プロセス

ICYメタデータ(StreamTitle)の抽出メカニズム

IcecastやSHOUTcastといったラジオ配信サーバーは、音声データ(オーディオバイト列)の合間に一定間隔でメタデータ(曲名など)を挿入して送信します。

ブラウザの <audio> タグはこのメタデータを無視して再生するため、JavaScriptから直接読み取ることはできません。そのため、サーバー側スクリプト(icy.groovy)でヘッダー Icy-MetaData: 1 を付与して配信サーバーへ接続し、必要なデータブロックのみを先頭から切り出して読み取ります。

// icy.groovy によるメタデータインターバルの読み飛ばしと抽出例
String metaint = headers.get("icy-metaint");
if (metaint != null && metaint.isInteger()) {
    int interval = metaint.toInteger(); // 例: 16000バイトごとにメタデータが挿入される
    if (interval > 0 && interval <= 1048576) {
        skipFully(input, interval); // 1区間分の音声データをスキップ
        int lengthByte = input.read(); // メタデータブロックの長さ(16バイト単位)
        if (lengthByte > 0) {
            byte[] block = readFully(input, lengthByte * 16);
            result.title = extractTitle(decodeMeta(block)); // "StreamTitle='...';" を抽出
        }
    }
}

抽出した情報は、フロントエンドからタイマー(例: 15秒間隔)でAPIを呼び出すことで、画面上の「NOW PLAYING」表記を動的に更新します。


CORS非対応局に対応する再生エレメントの冗長化設計

音声波形をCanvasに描画する(スペクトラムアナライザー)には AudioContext.createMediaElementSource() を使用します。これには <audio> 要素に crossOrigin = "anonymous" の設定が必須ですが、CORSヘッダーを返さないラジオ局の場合、ブラウザが音声の読み込み自体を拒否して無音になります。

これを解決するため、アプリケーション内ではCORS用と通常用の二系統の <audio> エレメントを用意し、段階的に試行する構造(フォールバック)を採用しています。

// player.ts におけるフォールバック試行ロジックの例
export class RadioPlayer {
    #corsEl: HTMLAudioElement;  // crossOrigin = "anonymous" を設定したエレメント
    #plainEl: HTMLAudioElement; // crossOrigin を設定しない通常の非CORSエレメント

    async play(station: Station): Promise<void> {
        // 1st Try: まずCORS対応エレメントで再生を試みる(スペクトラム描画可能)
        try {
            await this.#tryElement(this.#corsEl, url, attempt);
            this.#active = this.#corsEl;
            await this.#connectAnalyser(); // Web Audio APIへ接続
            return;
        } catch (err) {
            // CORS違反や接続失敗時は2nd Tryへ移動
        }

        // 2nd Try: CORS非対応エレメントでフォールバック再生(音声のみ再生、アナライザーは無効)
        try {
            await this.#tryElement(this.#plainEl, url, attempt);
            this.#active = this.#plainEl;
        } catch (err2) {
            this.#fail('generic');
        }
    }
}
再生エレメント crossOrigin 設定 適用されるケース 特徴・制限
#corsEl "anonymous" CORSヘッダーを返すラジオ局、HLSストリーム Web Audio API(スペクトラムアナライザー)が利用可能
#plainEl なし(デフォルト) CORSヘッダーを返さないラジオ局 音声再生は可能だが、Web Audio APIからの波形取得は不可

トラブル時のチェックポイント:AudioContextの自動再生ブロック

ユーザーの操作(クリック等のジェスチャー)なしに AudioContext を初期化・作成すると、ブラウザの自動再生ポリシーによって AudioContext.state が 'suspended' になります。

再生開始時に state を確認し、停止している場合は明示的に resume() を呼び出す必要があります。

async #connectAnalyser(): Promise<void> {
    if (this.#ctx && this.#ctx.state === 'suspended') {
        // ユーザーアクションの文脈内でresumeを呼び出してサスペンド状態を解除する
        await this.#ctx.resume().catch(() => { /* 無視 */ });
    }
}

実装時のチェックリスト

  • [ ] ICYメタデータ取得時に過剰な音声データを読み込まず、1インターバル分でソケットを閉じているか
  • [ ] <audio> 要素のCORS設定失敗時に、通常エレメントへの再生フォールバックが機能しているか
  • [ ] AudioContext が suspended になった際、resume() による復旧処理が入っているか

まとめ

ブラウザの仕様上、標準機能だけでは実現が難しい「曲名表示」や「CORS制限下での波形描画」も、バックエンドでのヘッダー解析とフロントエンドでの再生エレメントの冗長化によって安全に実現できます。

この記事が、HTML5 AudioやWeb Audio APIを用いたメディアアプリケーション開発の理解を深める一助となれば幸いです。 ご覧いただきありがとうございました。


参考資料