star back image
people4
電飾 電飾
moon
men

グラフィックイコライザーのあるオーディオプレイヤーを作る

BLOG WEBログWordPress
読了約:73分

AIコーディング界隈の一角、Cursor – The AI Code Editor を使う機会がありました。
勤め先で有料で登録してくれて使いたい放題だと言うのです。

まじですか太っ腹ですね。

感謝しかありません。

何を作ろうか悩んで「ローカルなオーディオプレイヤー」を作ろうと思い至りました。
※4/30 イコライザー表示に指摘あり修正。
※5/6 リピートボタンを追加。

【経緯】なぜオーディオプレイヤーなのか

「オーディオシステム」が家にある。それは昭和世代の夢。

中高生の頃、家には父のレコードプレイヤーがありクラシックが鳴ってました。
クラシックがよくわからなかったので宇宙戦艦ヤマトのレコードを買ってもらって聴いてました。

そんな頃、CDと言う新しい規格のメディアが現れます。
父はCDラジカセを買ってきて試した後、レコードで十分だと言うのです。
それからバブル期のオーディオブームが到来。
大手家電企業がこぞって商品開発をし、コンポと言うジャンルの製品も現れました。

欲しかったのはこんなやつです。

拡大したグラフィックイコライザー部分の画像

ヤフオク:システムコンポ
https://auctions.yahoo.co.jp/category/list/2084221623/

ヤフオク:グラフィックイコライザー オーディオ
https://auctions.yahoo.co.jp/search/search/

そのコンポについていた「グライコ」に釘付けになったのです。

音に合わせてライトが動くやつですね。

そうです。
しかし月に五百円のお小遣いの高校生にコンポは無理です。

マイオーディオシステムは高嶺の花。
記憶から消すしかありませんでした。

しかし今はAIがあります。
「グライコ」つくれるかもしかない。

【調査】グライコってなんだろう

グライコとは「グラフィックイコライザー」の略らしいです。

グラフィックイコライザーとは何?

特定の音域の音量を調整するもので、音のトーンコントロールができる装置で、好きな特定の音域を調整できるそうです。

触ったことがありません。

参考:新・オーディオ入門6 グラフィックイコライザー
https://note.com/musica_corp/n/n7a004fd8c8dd

調べるとあの光る波の動作、その1本1本が特定の音域周波数のレベル(音量・音圧)をリアルタイムで示すものらしいです。

コンポのアレは音を調整することが出来たのですね。。
知らなかったです。

バブル期に発達したハイテク技術ですな。

音域の調整ツマミはいらないかなぁ。あの光る波の動きに惹かれたんです。

要件は以下です。

  • ブラウザーで動作する
  • ローカルの音声ファイルを読み込ませる
  • 再生すればビジュアルイコライザーが動く
  • インターネットがない場所で動作すること

他にどうやってブラウザーで音を鳴らすのか、調べればいくらでも出てきます。
参考にしたのは以下のサイトです。ありがとう。

参考:オーディオ設計の可聴周波数帯域を理解する
https://jp.sameskydevices.com/blog/understanding-audio-frequency-range-in-audio-design

参考:HTML5のaudio要素で、音楽の再生や効果音を鳴らす方法
https://allabout.co.jp/gm/gc/385187/

できるかな、やる前から不安です。

AIがいますよ。

今回のAIはCursorを使いました。

  • 「ブラウザで音楽プレイヤーを作りたいです」
  • 「音楽と連動する20本分の周波数でグライコを表示させたい」
  • 「ファイル名や音量も付けたいです」

と希望を言ったら、速攻でHTMLソースやjavascriptを提供してくれます。

私は1回や2回のやり取りでは出来なかったのですが、うまく言葉の説明をAIへ語れる人なら、もしかしたら一筆書きで作ってもらえるかも。

Cursorのすごいのは「画像の解析」もしてくれるところです。

ヤフオクの「コンポ / グライコ」の画像を探して切り取って拡大します。
それをテキスト入力するところに放り込み「こうしたい!」と叫びます。

拡大したグラフィックイコライザー部分の画像

言葉で説明が難しい時は、絵で説明するのが効果的です。

【共有1】サンプルページ

ブラウザーで自分だけの音声を流せるオフライン音楽プレイヤー、グライコ付きです。

自分専用プレイヤーにでもどうですか。

サンプルページで再生するのもよし。

プレイヤーの見た目はこんな感じです。

グラフィックイコライザー(サンプルページ)
https://astrowave.jp/amnesia_record/visualizer.php

※5/6更新
リピート機能が欲しくなりました。停止ボタンの横に配置しています。

HTML(リピート機能の追加版)
https://astrowave.jp/amnesia_record/visualizer_local.html

「ブラウザーでアクセスしないと使えないならオフラインじゃないじゃん。」って思いましたか?
これはコピペして自分のローカルにHTMLファイルを複製すればインターネットなしで使えるのです。

え!?ブラウザーに音楽ファイル放り込めば、そんな苦労しなくても聴けるじゃん?

でもグラフィックイコライザーはありませんね。

そうだそうだ(#^ω^)

昭和なレトロ調にカッコよくやりたかった (*゚∀゚)ノアヒャヒャ

発展させればこんな時に便利かも?

  • タイマーで再生させる。

プログラムは初心者のようなもの。何度も修正して、ようやく今の形になりました。
まだ変なところあるかも。。

グライコ表示って必要なくないですか?

いや、必要でしょう。
あった方がカッコいいです(#^ω^)

【共有2】ソースコード

HTMLのUIで基礎的な動作。
好きな場所にコピペして貼り付けて使えます。

HTML(5/6修正:リピートボタン追加)

<link rel="stylesheet" href="css/visualizer.css">
<script src="js/visualizer_repeat.js"></script>

<div class="audio-visualizer">
  <canvas id="visualizerCanvas"></canvas>
  
  <div class="audio-info">
      <div id="fileName">ファイル名: 未選択</div>
      <div id="musicInfo">タイトル: 未選択</div>
  </div>
  
  <div class="audio-controls">
      <input type="file" id="audioFile" accept="audio/*" style="display: none;">
      <button id="fileSelectBtn" class="control-btn">
          <span>OPEN FILE</span>
      </button>
      
      <button id="playBtn" class="control-btn" disabled>
          <span class="play-icon">▶</span>
      </button>
      <button id="pauseBtn" class="control-btn" disabled>
          <span class="pause-icon">⏸</span>
      </button>
      <button id="stopBtn" class="control-btn" disabled>
          <span class="stop-icon">⏹</span>
      </button>
      <button id="repeatBtn" class="control-btn" title="リピート再生">
          <span class="repeat-icon">↻</span>
      </button>

      <div class="time-display">
          <span id="currentTime">0:00</span> / <span id="totalTime">0:00</span>
      </div>
      
      <input type="range" id="seekBar" class="seek-bar" min="0" max="100" value="0" disabled>
      
      <div class="volume-control">
          <span class="volume-icon">🔊</span>
          <input type="range" id="volumeSlider" class="volume-slider" min="0" max="100" value="100">
      </div>
  </div>
</div>

<script>
document.getElementById('fileSelectBtn').addEventListener('click', function() {
    document.getElementById('audioFile').click();
});
</script>

CSS(5/6修正:リピートボタン追加)

.audio-visualizer {
    position: relative;
    width: auto;
    margin: 20px 0;
    background: rgba(0, 0, 0, 0.8);
    padding: 20px;
    border-radius: 10px;
    box-shadow: 0 0 20px rgba(0, 255, 255, 0.3);
}

.audio-info {
    margin: 10px 0;
    padding: 10px;
    background: rgba(0, 0, 0, 0.1);
    border-radius: 5px;
}

#fileName, #musicInfo {
    margin: 5px 0;
    font-size: 14px;
    color: #333;
}
.file-info #fileName,
.file-info #musicInfo {
    color: #c8c8c8;
}

#visualizerCanvas {
    width: calc(100% - 5px);
    height: 200px;
    background-color: #000;
    border-radius: 0;
    padding: 0 0 5px 5px;
}

.audio-controls {
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 10px;
    margin-top: 20px;
}
#audioFile {
    display: none;
}
.audio-controls button.control-btn {
    /* 既存のスタイル */
    margin-right: 5px; /* ボタン間のスペース */
    padding: 10px 20px;
}
.file-upload-btn {
    padding: 10px 20px;
    background-color: #333;
    color: #fff;
    border: 2px solid #00ffff;
    border-radius: 5px;
    cursor: pointer;
    transition: all 0.3s ease;
}

.file-upload-btn:hover {
    background-color: #444;
    transform: scale(1.05);
}

.time-display {
    color: #c8c8c8;
}

.control-btn {
    background-color: #4CAF50;
    color: white;
    border: none;
    padding: 10px 20px;
    text-align: center;
    text-decoration: none;
    display: inline-block;
    font-size: 16px;
    margin: 4px 2px;
    cursor: pointer;
    border-radius: 4px;
    transition: background-color 0.3s;
}

.control-btn:hover {
    background-color: #45a049;
}

.control-btn:disabled {
    background-color: #cccccc;
    cursor: not-allowed;
    opacity: 0.6;
}

.control-btn.playing {
    background-color: #cccccc;
    cursor: not-allowed;
    opacity: 0.6;
}

/* タブレットサイズのスタイル */
@media screen and (max-width: 1024px) {
    .audio-controls {
        display: block;
        justify-content: center;
        gap: 10px;
        margin-top: 20px;
    }
}

/* スマホサイズのスタイル */
@media screen and (max-width: 768px) {
    .audio-controls {
        display: block;
        justify-content: center;
        gap: 10px;
        margin-top: 20px;
    }
}

/* リピートボタンの状態 */
#repeatBtn:not(:disabled) {
    background-color: rgba(255, 68, 68, 0.8) !important;  /* リピートオフ時の背景色(赤) */
}

#repeatBtn:not(:disabled).active {
    background-color: rgba(68, 255, 68, 0.8) !important;  /* リピートオン時の背景色(緑) */
}

#repeatBtn:disabled {
    background-color: rgba(102, 102, 102, 0.8) !important;  /* 無効時の背景色(グレー) */
}

JAVASCRIPT(5/6修正:リピートボタン追加)

/**
 * オーディオビジュアライザークラス
 * 音声ファイルを読み込み、周波数スペクトルをリアルタイムで可視化します
 * 
 * 主な機能:
 * - オーディオファイルの読み込みと再生制御
 * - リアルタイムな周波数スペクトル表示
 * - LEDスタイルのビジュアライザー表示
 * - 再生時間とシークバーの制御
 */
class AudioVisualizer {
    /**
     * ビジュアライザーの初期化
     * @param {string} canvasId - 描画に使用するcanvas要素のID
     */
    constructor(canvasId) {
        // ビジュアライザーの設定
        this.config = {
            // キャンバスの基本設定
            canvas: {
                height: 200,               // キャンバスの高さ
                bottomMargin: 15,          // 周波数表示用の余白
                backgroundColor: 'rgb(0, 0, 0)' // 背景色
            },
            
            // スペクトラム表示の設定
            spectrum: {
                bands: 20,                 // 周波数帯域の数(20バンド)
                barWidthRatio: 0.8,        // バーの幅の比率(全体の80%)
                barGapRatio: 0.2,          // バー間の隙間の比率(全体の20%)
                totalSegments: 32,         // 縦方向のセグメント数
                segmentHeight: 4,          // 各セグメントの高さ(ピクセル)
                segmentGap: 1,             // セグメント間の隙間(ピクセル)
                minFrequency: 20,          // 最小周波数(Hz)
                maxFrequency: 20000        // 最大周波数(Hz)
            },
            
            // カラーテーマ設定
            colors: {
                text: '#ffffff',           // 基本テキストカラー(白)
                frequencyText: '#999999',  // 周波数表示の色(グレー)
                inactive: '#001100',       // 非アクティブセグメントの色(暗い緑)
                segments: {
                    low: '#00ff00',        // 低域のセグメント色(緑)
                    mid: '#ffff00',        // 中域のセグメント色(黄)
                    high: '#ff0000',       // 高域のセグメント色(赤)
                    lowThreshold: 0.6,     // 低域の閾値(下60%)
                    midThreshold: 0.8      // 中域の閾値(60-80%)
                }
            },
            
            // フォント設定
            fonts: {
                frequency: '7px "Helvetica Neue", Helvetica, sans-serif'  // 周波数表示用フォント
            }
        };

        // Web Audio API の初期化
        this.canvas = document.getElementById(canvasId);
        this.ctx = this.canvas.getContext('2d');
        this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
        this.analyser = this.audioContext.createAnalyser();
        this.analyser.fftSize = 4096;  // FFTサイズを増やして周波数分解能を向上
        this.bufferLength = this.analyser.frequencyBinCount;
        this.dataArray = new Uint8Array(this.bufferLength);
        
        // キャンバスのサイズ設定
        this.canvas.width = this.canvas.offsetWidth;
        this.canvas.height = this.config.canvas.height;
        
        // オーディオ要素とコントロールの参照を保持
        this.audio = null;              // Audio要素
        this.currentFile = null;        // 現在再生中のファイル
        this.playBtn = document.getElementById('playBtn');          // 再生ボタン
        this.stopBtn = document.getElementById('stopBtn');          // 停止ボタン
        this.seekBar = document.getElementById('seekBar');          // シークバー
        this.volumeSlider = document.getElementById('volumeSlider'); // 音量スライダー
        this.currentTimeDisplay = document.getElementById('currentTime'); // 現在の再生時間
        this.totalTimeDisplay = document.getElementById('totalTime');     // 総再生時間
        this.fileNameDisplay = document.getElementById('fileName');       // ファイル名表示
        this.musicInfoDisplay = document.getElementById('musicInfo');     // 音楽情報表示
        this.repeatBtn = document.getElementById('repeatBtn');            // リピートボタン

        this.initializeControls();
    }

    /**
     * コントロールの初期化
     * ボタンと音量スライダーの初期状態を設定
     */
    initializeControls() {
        // ボタンの初期状態を設定
        this.playBtn.disabled = true;
        this.stopBtn.disabled = true;
        this.seekBar.disabled = true;
        this.repeatBtn.disabled = true;
        this.repeatBtn.classList.remove('active');  // リピートボタンの初期状態をオフに設定

        // 音量スライダーの初期設定
        this.volumeSlider.addEventListener('input', (e) => {
            if (this.audio) {
                this.audio.volume = e.target.value / 100;
            }
        });
    }

    /**
     * オーディオコントロールの設定
     * 再生、停止、シークバーの制御を設定
     */
    setupControls() {
        if (!this.audio) return;

        // ファイル名とメタデータの表示
        this.updateFileInfo();

        // 既存のイベントリスナーを削除
        this.playBtn.removeEventListener('click', this.playHandler);
        this.stopBtn.removeEventListener('click', this.stopHandler);
        this.repeatBtn.removeEventListener('click', this.repeatHandler);
        this.seekBar.removeEventListener('input', this.seekHandler);

        // イベントハンドラーの定義
        this.playHandler = () => {
            if (this.audio.paused) {
                this.audio.play();
            }
        };

        this.stopHandler = () => {
            this.audio.pause();
            this.audio.currentTime = 0;
            this.updateTimeDisplay();
            this.playBtn.disabled = false;
            document.getElementById('pauseBtn').disabled = true;
            this.stopBtn.disabled = true;
            this.seekBar.disabled = true;
            this.repeatBtn.disabled = false;  // リピートボタンは常に有効に
            this.repeatBtn.classList.remove('active');
        };

        this.repeatHandler = () => {
            this.toggleRepeat();
        };

        this.seekHandler = () => {
            const seekTime = (this.audio.duration * this.seekBar.value) / 100;
            this.audio.currentTime = seekTime;
        };

        // イベントリスナーの設定
        this.playBtn.addEventListener('click', this.playHandler);
        this.stopBtn.addEventListener('click', this.stopHandler);
        this.repeatBtn.addEventListener('click', this.repeatHandler);
        this.seekBar.addEventListener('input', this.seekHandler);

        // 一時停止ボタン
        const pauseBtn = document.getElementById('pauseBtn');
        pauseBtn.removeEventListener('click', this.pauseHandler);
        this.pauseHandler = () => {
            if (!this.audio.paused) {
                this.audio.pause();
            }
        };
        pauseBtn.addEventListener('click', this.pauseHandler);

        // 時間更新
        this.audio.addEventListener('timeupdate', () => {
            this.updateTimeDisplay();
        });

        // 再生開始/一時停止の状態に合わせてボタンの活性/非活性を制御
        this.audio.addEventListener('play', () => {
            this.playBtn.disabled = true;
            pauseBtn.disabled = false;
            this.stopBtn.disabled = false;
            this.seekBar.disabled = false;
            this.repeatBtn.disabled = false;  // リピートボタンは常に有効に
        });

        this.audio.addEventListener('pause', () => {
            this.playBtn.disabled = false;
            pauseBtn.disabled = true;
            this.stopBtn.disabled = false;
            this.seekBar.disabled = false;
            this.repeatBtn.disabled = false;  // リピートボタンは常に有効に
        });

        // 再生終了時
        this.audio.addEventListener('ended', () => {
            this.playBtn.disabled = false;
            pauseBtn.disabled = true;
            this.stopBtn.disabled = true;
            this.seekBar.disabled = true;
            this.repeatBtn.disabled = false;  // リピートボタンは常に有効に
            this.repeatBtn.classList.remove('active');
        });
    }

    /**
     * リピート機能の切り替え
     */
    toggleRepeat() {
        if (!this.audio) return;
        
        this.audio.loop = !this.audio.loop;
        if (this.audio.loop) {
            this.repeatBtn.classList.add('active');
        } else {
            this.repeatBtn.classList.remove('active');
        }
        console.log('Repeat toggled:', this.audio.loop); // デバッグ用
    }

    /**
     * 再生時間表示の更新
     * 現在の再生時間と総再生時間を表示
     */
    updateTimeDisplay() {
        if (!this.audio) return;

        const currentMinutes = Math.floor(this.audio.currentTime / 60);
        const currentSeconds = Math.floor(this.audio.currentTime % 60);
        const totalMinutes = Math.floor(this.audio.duration / 60);
        const totalSeconds = Math.floor(this.audio.duration % 60);

        this.currentTimeDisplay.style.color = this.config.colors.text;
        this.totalTimeDisplay.style.color = this.config.colors.text;
        
        this.currentTimeDisplay.textContent = 
            `${currentMinutes}:${currentSeconds.toString().padStart(2, '0')}`;
        this.totalTimeDisplay.textContent = 
            `${totalMinutes}:${totalSeconds.toString().padStart(2, '0')}`;

        this.seekBar.value = (this.audio.currentTime / this.audio.duration) * 100;
    }

    /**
     * ファイル情報の更新
     * ファイル名とメタデータを表示
     */
    updateFileInfo() {
        if (this.currentFile) {
            this.fileNameDisplay.style.color = this.config.colors.text;
            this.musicInfoDisplay.style.color = this.config.colors.text;
            
            this.fileNameDisplay.textContent = `ファイル名: ${this.currentFile.name}`;

            this.getMusicMetadata().then(metadata => {
                let infoText = 'タイトル: ';
                if (metadata.title) {
                    infoText += metadata.title;
                    if (metadata.artist) {
                        infoText += ` / アーティスト: ${metadata.artist}`;
                    }
                } else {
                    infoText += '不明';
                }
                this.musicInfoDisplay.textContent = infoText;
            }).catch(() => {
                this.musicInfoDisplay.textContent = 'タイトル: 不明';
            });
        } else {
            this.fileNameDisplay.textContent = 'ファイル名: 未選択';
            this.musicInfoDisplay.textContent = 'タイトル: 未選択';
        }
    }

    /**
     * 音楽メタデータの取得
     * タイトルやアーティスト情報を取得
     * @returns {Promise} メタデータを含むPromise
     */
    async getMusicMetadata() {
        return new Promise((resolve) => {
            const metadata = {
                title: null,
                artist: null
            };

            // メタデータの取得を試みる
            if (this.audio.metadata) {
                metadata.title = this.audio.metadata.title || null;
                metadata.artist = this.audio.metadata.artist || null;
            }

            // ID3タグの取得を試みる
            if (this.audio.id3) {
                metadata.title = this.audio.id3.title || metadata.title;
                metadata.artist = this.audio.id3.artist || metadata.artist;
            }

            resolve(metadata);
        });
    }

    /**
     * オーディオソースの接続
     * アナライザーノードとデスティネーションの接続を設定
     * @param {AudioNode} source - 接続するオーディオソース
     */
    connectSource(source) {
        try {
            source.connect(this.analyser);
            this.analyser.connect(this.audioContext.destination);
        } catch (error) {
            console.error('ソースの接続に失敗しました:', error);
        }
    }

    draw() {
        requestAnimationFrame(() => this.draw());
        
        this.analyser.getByteFrequencyData(this.dataArray);
        
        // キャンバスのクリア
        this.ctx.fillStyle = this.config.canvas.backgroundColor;
        this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height - this.config.canvas.bottomMargin);
        
        const barWidth = (this.canvas.width / this.config.spectrum.bands) * this.config.spectrum.barWidthRatio;
        const barGap = (this.canvas.width / this.config.spectrum.bands) * this.config.spectrum.barGapRatio;
        
        // 対数スケールで周波数帯域を分割
        const minFreq = this.config.spectrum.minFrequency;
        const maxFreq = this.config.spectrum.maxFrequency;
        const logMin = Math.log10(minFreq);
        const logMax = Math.log10(maxFreq);
        const logRange = logMax - logMin;
        
        // スペクトルの描画
        for (let i = 0; i < this.config.spectrum.bands; i++) {
            // 対数スケールで周波数帯域を計算
            const logFreqStart = logMin + (i * logRange / this.config.spectrum.bands);
            const logFreqEnd = logMin + ((i + 1) * logRange / this.config.spectrum.bands);
            const freqStart = Math.pow(10, logFreqStart);
            const freqEnd = Math.pow(10, logFreqEnd);
            
            // 周波数インデックスの計算
            const startIndex = Math.floor((freqStart / (this.audioContext.sampleRate / 2)) * this.bufferLength);
            const endIndex = Math.floor((freqEnd / (this.audioContext.sampleRate / 2)) * this.bufferLength);
            
            let sum = 0;
            for (let j = startIndex; j < endIndex; j++) {
                sum += this.dataArray[j];
            }
            
            const average = sum / (endIndex - startIndex);
            const activeSegments = Math.floor((average / 255) * this.config.spectrum.totalSegments);
            
            // セグメントの描画
            for (let j = 0; j < this.config.spectrum.totalSegments; j++) {
                const y = this.canvas.height - this.config.canvas.bottomMargin - 
                         ((j + 1) * (this.config.spectrum.segmentHeight + this.config.spectrum.segmentGap));
                
                // セグメントの色を決定
                let segmentColor;
                if (j < this.config.spectrum.totalSegments * this.config.colors.segments.lowThreshold) {
                    segmentColor = this.config.colors.segments.low;
                } else if (j < this.config.spectrum.totalSegments * this.config.colors.segments.midThreshold) {
                    segmentColor = this.config.colors.segments.mid;
                } else {
                    segmentColor = this.config.colors.segments.high;
                }
                
                this.ctx.fillStyle = j < activeSegments 
                    ? segmentColor
                    : this.config.colors.inactive;
                
                this.ctx.fillRect(
                    Math.floor(i * (barWidth + barGap)),
                    Math.floor(y),
                    Math.floor(barWidth),
                    Math.floor(this.config.spectrum.segmentHeight)
                );
            }
            
            // 周波数ラベルの描画(より正確な表示)
            const freq = Math.round(freqStart);
            this.ctx.fillStyle = this.config.colors.frequencyText;
            this.ctx.font = this.config.fonts.frequency;
            
            // 周波数表示のフォーマット
            let freqText;
            if (freq < 1000) {
                freqText = `${freq}Hz`;
            } else {
                freqText = `${(freq/1000).toFixed(1)}kHz`;
            }
            
            this.ctx.fillText(
                freqText,
                i * (barWidth + barGap),
                this.canvas.height - 2
            );
        }
    }
}

// 使用例
function setupVisualizer() {
    const visualizer = new AudioVisualizer('visualizerCanvas');
    visualizer.draw();

    // ファイルアップロードの処理
    const fileInput = document.getElementById('audioFile');

    if (!fileInput) {
        console.warn('audioFile要素が見つかりません');
        return;
    }

    // 対応する音声フォーマットを指定
    fileInput.accept = '.mp3, .wav, .aac, .m4a, .ogg, .flac, audio/*';

    fileInput.addEventListener('change', async (e) => {
        const file = e.target.files[0];
        if (!file) return;

        try {
            // 再生中のオーディオがあれば停止する
            if (visualizer.audio && !visualizer.audio.paused) {
                visualizer.audio.pause();
                visualizer.audio.currentTime = 0;
                visualizer.playBtn.querySelector('.play-icon').textContent = '▶';
            }

            // 現在のファイルを保存
            visualizer.currentFile = file;

            // オーディオコンテキストを再開(ユーザー操作が必要)
            if (visualizer.audioContext.state === 'suspended') {
                await visualizer.audioContext.resume();
            }

            const url = URL.createObjectURL(file);
            visualizer.audio = new Audio(url);
            visualizer.setupControls();  // ここでsetupControlsを呼び出す

            // オーディオの設定
            visualizer.audio.loop = false;  // リピートは初期状態でオフ
            visualizer.repeatBtn.classList.remove('active');  // リピートボタンの初期状態をオフに設定

            // オーディオをアナライザーに接続
            const source = visualizer.audioContext.createMediaElementSource(visualizer.audio);
            source.connect(visualizer.analyser);
            visualizer.analyser.connect(visualizer.audioContext.destination);

            // ファイル情報の更新
            visualizer.updateFileInfo();

            // コントロールの有効化
            visualizer.playBtn.disabled = false;
            document.getElementById('pauseBtn').disabled = true;
            visualizer.stopBtn.disabled = false;
            visualizer.seekBar.disabled = false;
            visualizer.repeatBtn.disabled = false;

        } catch (error) {
            console.error('Error loading audio file:', error);
        }
    });
}

// DOMの読み込み完了後に実行
document.addEventListener('DOMContentLoaded', () => {
    setupVisualizer();
});

// ウィンドウのリサイズに対応
window.addEventListener('resize', () => {
    const canvas = document.getElementById('visualizerCanvas');
    if (canvas) {
        canvas.width = canvas.offsetWidth;
    }
});

Scriptの解説ならGeminiやchatGPTに放り込んでみてどうぞ。。

あなたは分かってるんですか?

スクリプトを修正です。

低音の表示が出ていないのではないかと指摘をいただきました。
ありがとうございます。

結論、低音域が十分に表示されていないと言う。。

前の表示は20バンド、各バンドは約1.1kHzずつUPと指定していた。
const freq = Math.round((i + 1) * (22050 / this.config.spectrum.bands));
→音は普通に出力だけど、表示は確かに左にばかり寄ってます。。
また人に聞こえない高周波数の表示に意味ありません。

バランス計算が悪かったです。さすがです!

-----

1.FFTサイズの増加:
fftSizeを2048から4096に増やし、周波数分解能を向上させました。

2.対数スケールの導入:
周波数帯域を対数スケールで分割するように変更
最小周波数を20Hz、最大周波数を20kHzに設定
低音域(20Hz-200Hz)がより詳細に表示されます
中音域(200Hz-2kHz)と高音域(2kHz-20kHz)のバランスが改善されます。

3.周波数帯域の計算方法の改善:
以前:線形スケール(均等な分割)
改善後:対数スケール(人間の聴覚に近い分割)

・表示の変更
1000Hz未満は「Hz」単位で表示
1000Hz以上は「kHz」単位で表示

見た目と数字の信憑性を改善しました( ᐛ )テヘッ

※ソースとサンプルを更新です。

(;^_^A アセアセ・・・

【共有3】WordpressのカスタムHTML用コード

wordpressの記事にプレイヤーを差し込みたい。
音楽もメディアにUPしたものをセッティングして再生するだけに。

そう思うでしょう。wordpressの記事ブロック要素「カスタムHTML」にソースを放り入れて、気軽に使えないのでしょうか。

AIに頼みました。

記事ブロック要素「カスタムHTML」用コード

<link rel="stylesheet" href="https://astrowave.jp/amnesia_record/css/visualizer.css">

<div class="audio-visualizer">
  <canvas id="visualizerCanvas"></canvas>
  <div class="audio-controls">
    <button id="playBtn" class="control-btn">
      <span class="play-icon">▶</span>
    </button>
    <button id="pauseBtn" class="control-btn">
      <span class="pause-icon">⏸</span>
    </button>
    <button id="stopBtn" class="control-btn">
      <span class="stop-icon">⏹</span>
    </button>
    <button id="repeatBtn" class="control-btn" title="リピート再生">
      <span class="repeat-icon">↻</span>
    </button>
    <div class="time-display">
      <span id="currentTime">0:00</span> / <span id="totalTime">0:00</span>
    </div>
    <input type="range" id="seekBar" class="seek-bar" min="0" max="100" value="0">
    <div class="volume-control">
      <span class="volume-icon">🔊</span>
      <input type="range" id="volumeSlider" class="volume-slider" min="0" max="100" value="100">
    </div>
  </div>
  <div class="file-info">
    <div id="fileName">ファイル名: astrowave.wav</div>
    <div id="musicInfo">タイトル: AstroWaveポッドキャスト</div>
  </div>
</div>

<script src="https://astrowave.jp/amnesia_record/js/visualizer_repeat.js"></script>
<script src="https://astrowave.jp/amnesia_record/js/visualizer_embed.js" data-audio-url="https://neo.astrowave.jp/wp-content/uploads/2025/04/astrowave.wav"></script>

javascriptとかcssもコードはすごく長いから短くしたい。
そのため別ファイルとして読み込ませます。

ファイルの準備

ファイルは自分のブログがあるサーバーに置いて読み込ませましょう。

cssやvisualizer_repeat.jsは共有したソースコードと同じです。

最後の行でオーディオファイルを読み込ませるURLを入れます。

<script src="https://astrowave.jp/amnesia_record/js/visualizer_embed.js" data-audio-url="https://neo.astrowave.jp/wp-content/uploads/2025/04/astrowave.wav"></script>

※Blogのメディアに登録したものを使用。
※サウンドファイルは同一ドメイン内で指定してください。再生環境ドメインと指定ファイルのドメインが違っていると無音になると思います。

※5/6更新
リピート機能が欲しくなりました。停止ボタンの横に配置しています。

HTML
https://astrowave.jp/amnesia_record/visualizer_embed.html

↓こんな感じに出力されて再生できます。

0:00 / 0:00
🔊
ファイル名: astrowave.wav
タイトル: AstroWaveポッドキャスト

ボタンなどをID指定で作成したので、記事1件につきプレイヤーは1つしか設置できないと思います。悪しからず。

なんでcanvasにしたの?

ビジュアルの部分、なんでcanvasにしたの?AIに聞きました。
canvasって馴染みがありません。

🔧 AIに聞いてなるほどと納得したこと。

なんでcanvasにしたのか:
それはリアルタイムに出力するためです。

cssとかで出来ないでしょうかと聞くとできるけど処理が重いと言うのです。
音に合わせて60fps出すにはcanvasが最適なんだとのこと。

まじですか。すげぇ。

ちゃんと理解してますか?

わからないこと、Scriptの解説ならGeminiやchatGPTに放り込んでみてどうぞ。

インターネットに依存しないシンプルなツールで、スタンドアローンな感じの、意外と可能性を秘めているかもな妄想をして、終わりとします。

参考サイト置き場

参考:新・オーディオ入門6 グラフィックイコライザー
https://note.com/musica_corp/n/n7a004fd8c8dd

参考:オーディオ設計の可聴周波数帯域を理解する
https://jp.sameskydevices.com/blog/understanding-audio-frequency-range-in-audio-design

参考:HTML5のaudio要素で、音楽の再生や効果音を鳴らす方法
https://allabout.co.jp/gm/gc/385187/

ヤフオク:システムコンポ
https://auctions.yahoo.co.jp/category/list/2084221623/

ヤフオク:グラフィックイコライザー オーディオ
https://auctions.yahoo.co.jp/search/search/

HTML+CSS+JavaScriptで作るグラフィックイコライザー
https://fukkokulab.com/geq.php

【AI】イラストを描いてもらった

今回の記事のキャッチ画像で使わせてもらいます「Google ImageFX」で作成した画像です。誰でもgoogleアカウントでログインして使えます。

この記事にピッタリなイラストのための考えたリクエストは、「宇宙船の大きな窓の前に現れたアダムスキー型UFOからの黄色い電波から信号を受け取り、モニターに移ったグラフィックイコライザーの波形が激しく波打つ。。」です。

Memeplex.appが最近は人気なのかすごく待たされます。。

星間旅路のメロディ

「宇宙の静けさに包まれながら、漂流する過去の音楽を捜し求め、銀河の奥底でその旋律に耳を傾ける。」

「この電波はどこの星からきたのだろうか。」

水の豊かな惑星の歌。
生命も輝いていた、そんな雰囲気がします。

白い雪とは水の結晶かもしれませんね。