オーディオビジュアライザー(Web Audio)

合成オシレータの和音を AnalyserNode で解析し、周波数バーと波形を同時に描く音響ビジュアル。マイク不要で再生ボタンから開始でき、音楽系UIやヒーロー演出に使えます。

#canvas#audio#webaudio#visualizer

ライブデモ

使用例(お題: アイドルグループ Sakura)

この技法を「アイドルグループ Sakura」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- Sakura:楽曲試聴プレイヤー(オーディオビジュアライザー) -->
<section class="sk-audio">
  <div class="sk-audio__card">
    <!-- ジャケット+楽曲情報 -->
    <div class="sk-audio__head">
      <div class="sk-audio__cover" aria-hidden="true"></div>
      <div class="sk-audio__meta">
        <span class="sk-audio__tag">NOW PREVIEW</span>
        <h1 class="sk-audio__title">恋色トライアングル</h1>
        <p class="sk-audio__artist">Sakura — 8th Single</p>
      </div>
    </div>

    <!-- 主役:周波数バー+波形 -->
    <div class="sk-audio__viz">
      <canvas id="skAudio"></canvas>
      <span class="sk-audio__off" id="skOff">▶ を押すと試聴が始まります</span>
    </div>

    <!-- 前景UI:再生コントロール -->
    <div class="sk-audio__ctrl">
      <button class="sk-audio__play" id="skPlay" type="button">▶ 試聴する</button>
      <span class="sk-audio__note">合成音による30秒プレビュー</span>
    </div>
  </div>
</section>
CSS
/* Sakura:楽曲試聴プレイヤー(ビジュアライザー) */
:root {
  --pink: #ffd1e0;
  --pink-deep: #ff7fa8;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  height: 400px;
  font-family: "Hiragino Kaku Gothic ProN", "Segoe UI", system-ui, sans-serif;
  overflow: hidden;
}

.sk-audio {
  height: 400px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  background:
    radial-gradient(500px 280px at 50% 0%, #fff4f8, transparent),
    linear-gradient(170deg, #ffe7ef, #ffd1e0);
}

.sk-audio__card {
  width: 100%;
  max-width: 460px;
  padding: 20px 22px;
  border-radius: 20px;
  background: rgba(255,255,255,0.85);
  box-shadow: 0 16px 40px rgba(196,120,150,0.28);
}

.sk-audio__head { display: flex; gap: 14px; align-items: center; margin-bottom: 14px; }
.sk-audio__cover {
  width: 64px;
  height: 64px;
  flex: 0 0 64px;
  border-radius: 14px;
  background:
    radial-gradient(circle at 30% 30%, #fff, transparent 60%),
    linear-gradient(135deg, var(--pink-deep), #ffb3cb);
  box-shadow: 0 8px 18px rgba(255,127,168,0.4);
}
.sk-audio__tag {
  font-size: 9.5px;
  letter-spacing: 0.24em;
  color: var(--pink-deep);
  font-weight: 800;
}
.sk-audio__title { margin: 4px 0 3px; font-size: 21px; font-weight: 900; color: #5a2b3d; }
.sk-audio__artist { margin: 0; font-size: 12px; color: #93707e; font-weight: 700; }

/* 主役:ビジュアライザー枠 */
.sk-audio__viz {
  position: relative;
  height: 150px;
  border-radius: 14px;
  overflow: hidden;
  background: linear-gradient(180deg, #fff0f6, #ffe0eb);
  margin-bottom: 14px;
}
.sk-audio__viz canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.sk-audio__off {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  font-size: 12px;
  color: #b58698;
  pointer-events: none;
  transition: opacity 0.3s ease;
}
.sk-audio__off.is-hidden { opacity: 0; }

.sk-audio__ctrl { display: flex; align-items: center; gap: 14px; }
.sk-audio__play {
  padding: 10px 22px;
  border: none;
  border-radius: 999px;
  background: linear-gradient(135deg, var(--pink-deep), #ff9cbb);
  color: #fff;
  font-size: 13.5px;
  font-weight: 800;
  cursor: pointer;
  box-shadow: 0 8px 20px rgba(255,127,168,0.45);
  transition: transform 0.15s ease;
}
.sk-audio__play:hover { transform: translateY(-2px); }
.sk-audio__note { font-size: 11px; color: #93707e; }

@media (prefers-reduced-motion: reduce) {
  .sk-audio__play, .sk-audio__off { transition: none; }
}
JavaScript
// Sakura:Web Audioで合成音を解析し周波数バー+波形を描く
(() => {
  const canvas = document.getElementById('skAudio');
  if (!canvas) return; // null安全
  const ctx = canvas.getContext('2d');
  if (!ctx) return;
  const playBtn = document.getElementById('skPlay');
  const offLabel = document.getElementById('skOff');

  let w = 0, h = 0, raf = 0, running = true;
  const dpr = Math.min(window.devicePixelRatio || 1, 2);

  // Web Audio 関連(再生開始時に初期化)
  let audioCtx = null, analyser = null, oscs = [], master = null;
  let freqData = null, timeData = null, playing = false;

  function resize() {
    const r = canvas.getBoundingClientRect();
    w = r.width; h = r.height;
    canvas.width = Math.max(1, w * dpr);
    canvas.height = Math.max(1, h * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
  resize();

  // 和音(メジャーコード)を合成して解析器に接続
  function buildAudio() {
    const AC = window.AudioContext || window.webkitAudioContext;
    if (!AC) return false;
    audioCtx = new AC();
    analyser = audioCtx.createAnalyser();
    analyser.fftSize = 256;
    master = audioCtx.createGain();
    master.gain.value = 0.0;
    master.connect(analyser);
    analyser.connect(audioCtx.destination);

    // C・E・G の3和音 + 軽いビブラート
    const freqs = [261.6, 329.6, 392.0];
    oscs = freqs.map((f, i) => {
      const o = audioCtx.createOscillator();
      o.type = i === 0 ? 'sawtooth' : 'sine';
      o.frequency.value = f;
      const g = audioCtx.createGain();
      g.gain.value = 0.25;
      o.connect(g).connect(master);
      o.start();
      return o;
    });

    freqData = new Uint8Array(analyser.frequencyBinCount);
    timeData = new Uint8Array(analyser.frequencyBinCount);
    return true;
  }

  function step() {
    ctx.clearRect(0, 0, w, h);

    if (analyser && freqData && timeData) {
      analyser.getByteFrequencyData(freqData);
      analyser.getByteTimeDomainData(timeData);

      // 周波数バー(桜色グラデ)
      const bars = 40;
      const bw = w / bars;
      for (let i = 0; i < bars; i++) {
        const v = freqData[i] / 255;
        const bh = v * h * 0.8;
        const hue = 330 + (i / bars) * 30;
        ctx.fillStyle = `hsla(${hue},85%,${60 + v * 15}%,0.85)`;
        ctx.fillRect(i * bw + 1, h - bh, bw - 2, bh);
      }

      // 波形(上に重ねる)
      ctx.beginPath();
      for (let i = 0; i < timeData.length; i++) {
        const x = (i / timeData.length) * w;
        const y = (timeData[i] / 255) * h * 0.5 + h * 0.05;
        i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
      }
      ctx.strokeStyle = 'rgba(255,255,255,0.85)';
      ctx.lineWidth = 2;
      ctx.stroke();
    }
    raf = requestAnimationFrame(step);
  }

  function loop() {
    if (running) return;
    running = true;
    raf = requestAnimationFrame(step);
  }
  function halt() {
    running = false;
    cancelAnimationFrame(raf);
  }

  // 再生トグル(ユーザー操作で AudioContext を起動)
  function toggle() {
    if (!audioCtx && !buildAudio()) return; // 未対応環境は無視
    if (audioCtx.state === 'suspended') audioCtx.resume();
    playing = !playing;
    // ゲインをなめらかに増減
    const now = audioCtx.currentTime;
    master.gain.cancelScheduledValues(now);
    master.gain.setTargetAtTime(playing ? 0.25 : 0.0, now, 0.05);
    if (playBtn) playBtn.textContent = playing ? '⏸ 停止' : '▶ 試聴する';
    if (offLabel) offLabel.classList.toggle('is-hidden', playing);
  }
  if (playBtn) playBtn.addEventListener('click', toggle);

  window.addEventListener('resize', resize);
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
      halt();
      if (audioCtx && audioCtx.state === 'running') audioCtx.suspend();
    } else {
      loop();
      if (audioCtx && playing) audioCtx.resume();
    }
  });

  running = false;
  loop();
})();

コード

HTML
<!-- オーディオビジュアライザー(Web Audio)デモ -->
<div class="stage">
  <canvas id="audioCanvas"></canvas>
  <div class="controls">
    <button id="toggleBtn" type="button">▶ 再生</button>
    <span class="status" id="status">ボタンで合成音を再生</span>
  </div>
</div>
CSS
/* オーディオビジュアライザーのステージ */
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
.stage {
  position: relative;
  width: 100%;
  height: 360px;
  overflow: hidden;
  font-family: "Segoe UI", system-ui, sans-serif;
  background: radial-gradient(900px 600px at 50% 30%, #161033, #06040f 85%);
}
#audioCanvas {
  display: block;
  width: 100%;
  height: 100%;
}
/* 再生コントロール */
.controls {
  position: absolute;
  left: 50%;
  bottom: 16px;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 12px;
}
.controls button {
  appearance: none;
  border: 1px solid rgba(180, 160, 255, .4);
  background: rgba(140, 110, 255, .18);
  color: #efeaff;
  font-size: 14px;
  padding: 8px 22px;
  border-radius: 999px;
  cursor: pointer;
  backdrop-filter: blur(4px);
  transition: background .2s, transform .1s;
}
.controls button:hover { background: rgba(140, 110, 255, .32); }
.controls button:active { transform: scale(.95); }
.controls button.is-playing {
  background: rgba(255, 120, 180, .85);
  border-color: rgba(255, 160, 200, .9);
  color: #2a0a18;
  font-weight: 600;
}
.status {
  color: rgba(220, 210, 255, .7);
  font-size: 12px;
  user-select: none;
}
JavaScript
// オーディオビジュアライザー(Web Audio)デモ
// 合成オシレータの和音を AnalyserNode で解析し、周波数バー+波形を描画
(() => {
  const canvas = document.getElementById('audioCanvas');
  const btn = document.getElementById('toggleBtn');
  const statusEl = document.getElementById('status');
  if (!canvas || !btn) return;
  const ctx = canvas.getContext('2d');
  if (!ctx) return;

  let w = 0, h = 0;
  const dpr = Math.min(window.devicePixelRatio || 1, 2);

  let audioCtx = null, analyser = null, master = null;
  let oscillators = [];           // 鳴らしているオシレータ群
  let freqData = null, waveData = null;
  let rafId = 0, running = false; // 描画ループの状態
  let playing = false;            // 音の再生状態
  let t = 0;                      // 周波数を時間変化させる位相

  // 和音(Aマイナー系)の基準周波数
  const baseNotes = [220.0, 261.63, 329.63, 440.0];

  function resize() {
    const r = canvas.getBoundingClientRect();
    w = r.width; h = r.height;
    canvas.width = w * dpr;
    canvas.height = h * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
  resize();
  window.addEventListener('resize', resize);

  // ユーザー操作後に AudioContext を作成/resume
  function ensureAudio() {
    if (!audioCtx) {
      const AC = window.AudioContext || window.webkitAudioContext;
      if (!AC) { statusEl.textContent = 'Web Audio 非対応です'; return false; }
      audioCtx = new AC();
      analyser = audioCtx.createAnalyser();
      analyser.fftSize = 2048;
      analyser.smoothingTimeConstant = 0.82;
      master = audioCtx.createGain();
      master.gain.value = 0.0001;            // 無音から開始(クリック音防止)
      master.connect(analyser);
      analyser.connect(audioCtx.destination);
      freqData = new Uint8Array(analyser.frequencyBinCount);
      waveData = new Uint8Array(analyser.fftSize);
    }
    if (audioCtx.state === 'suspended') audioCtx.resume();
    return true;
  }

  // 和音のオシレータ群を生成して接続
  function startTone() {
    oscillators = baseNotes.map((freq, i) => {
      const osc = audioCtx.createOscillator();
      osc.type = i % 2 === 0 ? 'sine' : 'triangle';
      osc.frequency.value = freq;
      const g = audioCtx.createGain();
      g.gain.value = 0.18 / baseNotes.length * (i === 0 ? 1.6 : 1);
      osc.connect(g);
      g.connect(master);
      osc.start();
      return { osc, base: freq };
    });
    // フェードイン
    const now = audioCtx.currentTime;
    master.gain.cancelScheduledValues(now);
    master.gain.setValueAtTime(master.gain.value, now);
    master.gain.linearRampToValueAtTime(0.9, now + 0.4);
  }

  // 音を停止してオシレータを破棄
  function stopTone() {
    if (!audioCtx) return;
    const now = audioCtx.currentTime;
    master.gain.cancelScheduledValues(now);
    master.gain.setValueAtTime(master.gain.value, now);
    master.gain.linearRampToValueAtTime(0.0001, now + 0.25);
    const list = oscillators;
    oscillators = [];
    list.forEach(o => { try { o.osc.stop(now + 0.3); } catch (e) {} });
  }

  // 周波数を時間でゆらして音楽的に変化させる
  function modulate() {
    if (!oscillators.length) return;
    t += 0.012;
    oscillators.forEach((o, i) => {
      // ビブラートと緩やかなうねり
      const vibrato = Math.sin(t * 2.0 + i) * 2.5;
      const sweep = Math.sin(t * 0.3 + i * 1.7) * (o.base * 0.04);
      try { o.osc.frequency.value = o.base + vibrato + sweep; } catch (e) {}
    });
  }

  function draw() {
    if (!running) return;
    ctx.clearRect(0, 0, w, h);

    if (analyser && playing) {
      analyser.getByteFrequencyData(freqData);
      analyser.getByteTimeDomainData(waveData);
      modulate();
      drawBars();
      drawWave();
    } else {
      drawIdle();
    }
    rafId = requestAnimationFrame(draw);
  }

  // 周波数バー(下から伸びる)
  function drawBars() {
    const bars = 64;
    const step = Math.floor(freqData.length / bars);
    const bw = w / bars;
    for (let i = 0; i < bars; i++) {
      let sum = 0;
      for (let j = 0; j < step; j++) sum += freqData[i * step + j];
      const v = (sum / step) / 255;          // 0..1
      const bh = v * h * 0.7;
      const hue = 200 + i / bars * 140;
      ctx.fillStyle = `hsla(${hue}, 90%, ${40 + v * 30}%, 0.9)`;
      const x = i * bw;
      ctx.fillRect(x + 1, h - bh, bw - 2, bh);
    }
  }

  // 波形(中央に重ねて発光表示)
  function drawWave() {
    ctx.lineWidth = 2;
    ctx.strokeStyle = 'rgba(255, 235, 250, 0.85)';
    ctx.shadowBlur = 10;
    ctx.shadowColor = 'rgba(255, 130, 200, 0.9)';
    ctx.beginPath();
    const slice = w / waveData.length;
    for (let i = 0; i < waveData.length; i++) {
      const v = waveData[i] / 128 - 1;        // -1..1
      const x = i * slice;
      const y = h * 0.42 + v * h * 0.22;
      i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    }
    ctx.stroke();
    ctx.shadowBlur = 0;
  }

  // 停止中のアイドル表示(穏やかな波線)
  function drawIdle() {
    ctx.strokeStyle = 'rgba(150, 140, 220, 0.35)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    for (let x = 0; x <= w; x += 6) {
      const y = h / 2 + Math.sin(x * 0.02 + Date.now() * 0.002) * 12;
      x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    }
    ctx.stroke();
  }

  // 描画ループ開始(二重起動防止)
  function startLoop() {
    if (running) return;
    running = true;
    rafId = requestAnimationFrame(draw);
  }
  function stopLoop() {
    running = false;
    if (rafId) cancelAnimationFrame(rafId);
    rafId = 0;
  }

  // 再生/停止トグル
  btn.addEventListener('click', () => {
    if (!playing) {
      if (!ensureAudio()) return;
      startTone();
      playing = true;
      btn.textContent = '⏸ 停止';
      btn.classList.add('is-playing');
      statusEl.textContent = '合成音を再生中…';
    } else {
      stopTone();
      playing = false;
      btn.textContent = '▶ 再生';
      btn.classList.remove('is-playing');
      statusEl.textContent = '停止しました';
    }
  });

  // タブ非表示で描画ループ停止&音をサスペンド、復帰で再開
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
      stopLoop();
      if (audioCtx && audioCtx.state === 'running') audioCtx.suspend();
    } else {
      startLoop();
      if (playing && audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
    }
  });

  // アイドル描画は最初から回す(音は鳴らさない)
  startLoop();
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「オーディオビジュアライザー(Web Audio)」の効果を追加してください。

# 追加してほしい効果
オーディオビジュアライザー(Web Audio)(Canvas エフェクト)
合成オシレータの和音を AnalyserNode で解析し、周波数バーと波形を同時に描く音響ビジュアル。マイク不要で再生ボタンから開始でき、音楽系UIやヒーロー演出に使えます。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- オーディオビジュアライザー(Web Audio)デモ -->
<div class="stage">
  <canvas id="audioCanvas"></canvas>
  <div class="controls">
    <button id="toggleBtn" type="button">▶ 再生</button>
    <span class="status" id="status">ボタンで合成音を再生</span>
  </div>
</div>

【CSS】
/* オーディオビジュアライザーのステージ */
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
.stage {
  position: relative;
  width: 100%;
  height: 360px;
  overflow: hidden;
  font-family: "Segoe UI", system-ui, sans-serif;
  background: radial-gradient(900px 600px at 50% 30%, #161033, #06040f 85%);
}
#audioCanvas {
  display: block;
  width: 100%;
  height: 100%;
}
/* 再生コントロール */
.controls {
  position: absolute;
  left: 50%;
  bottom: 16px;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 12px;
}
.controls button {
  appearance: none;
  border: 1px solid rgba(180, 160, 255, .4);
  background: rgba(140, 110, 255, .18);
  color: #efeaff;
  font-size: 14px;
  padding: 8px 22px;
  border-radius: 999px;
  cursor: pointer;
  backdrop-filter: blur(4px);
  transition: background .2s, transform .1s;
}
.controls button:hover { background: rgba(140, 110, 255, .32); }
.controls button:active { transform: scale(.95); }
.controls button.is-playing {
  background: rgba(255, 120, 180, .85);
  border-color: rgba(255, 160, 200, .9);
  color: #2a0a18;
  font-weight: 600;
}
.status {
  color: rgba(220, 210, 255, .7);
  font-size: 12px;
  user-select: none;
}

【JavaScript】
// オーディオビジュアライザー(Web Audio)デモ
// 合成オシレータの和音を AnalyserNode で解析し、周波数バー+波形を描画
(() => {
  const canvas = document.getElementById('audioCanvas');
  const btn = document.getElementById('toggleBtn');
  const statusEl = document.getElementById('status');
  if (!canvas || !btn) return;
  const ctx = canvas.getContext('2d');
  if (!ctx) return;

  let w = 0, h = 0;
  const dpr = Math.min(window.devicePixelRatio || 1, 2);

  let audioCtx = null, analyser = null, master = null;
  let oscillators = [];           // 鳴らしているオシレータ群
  let freqData = null, waveData = null;
  let rafId = 0, running = false; // 描画ループの状態
  let playing = false;            // 音の再生状態
  let t = 0;                      // 周波数を時間変化させる位相

  // 和音(Aマイナー系)の基準周波数
  const baseNotes = [220.0, 261.63, 329.63, 440.0];

  function resize() {
    const r = canvas.getBoundingClientRect();
    w = r.width; h = r.height;
    canvas.width = w * dpr;
    canvas.height = h * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
  resize();
  window.addEventListener('resize', resize);

  // ユーザー操作後に AudioContext を作成/resume
  function ensureAudio() {
    if (!audioCtx) {
      const AC = window.AudioContext || window.webkitAudioContext;
      if (!AC) { statusEl.textContent = 'Web Audio 非対応です'; return false; }
      audioCtx = new AC();
      analyser = audioCtx.createAnalyser();
      analyser.fftSize = 2048;
      analyser.smoothingTimeConstant = 0.82;
      master = audioCtx.createGain();
      master.gain.value = 0.0001;            // 無音から開始(クリック音防止)
      master.connect(analyser);
      analyser.connect(audioCtx.destination);
      freqData = new Uint8Array(analyser.frequencyBinCount);
      waveData = new Uint8Array(analyser.fftSize);
    }
    if (audioCtx.state === 'suspended') audioCtx.resume();
    return true;
  }

  // 和音のオシレータ群を生成して接続
  function startTone() {
    oscillators = baseNotes.map((freq, i) => {
      const osc = audioCtx.createOscillator();
      osc.type = i % 2 === 0 ? 'sine' : 'triangle';
      osc.frequency.value = freq;
      const g = audioCtx.createGain();
      g.gain.value = 0.18 / baseNotes.length * (i === 0 ? 1.6 : 1);
      osc.connect(g);
      g.connect(master);
      osc.start();
      return { osc, base: freq };
    });
    // フェードイン
    const now = audioCtx.currentTime;
    master.gain.cancelScheduledValues(now);
    master.gain.setValueAtTime(master.gain.value, now);
    master.gain.linearRampToValueAtTime(0.9, now + 0.4);
  }

  // 音を停止してオシレータを破棄
  function stopTone() {
    if (!audioCtx) return;
    const now = audioCtx.currentTime;
    master.gain.cancelScheduledValues(now);
    master.gain.setValueAtTime(master.gain.value, now);
    master.gain.linearRampToValueAtTime(0.0001, now + 0.25);
    const list = oscillators;
    oscillators = [];
    list.forEach(o => { try { o.osc.stop(now + 0.3); } catch (e) {} });
  }

  // 周波数を時間でゆらして音楽的に変化させる
  function modulate() {
    if (!oscillators.length) return;
    t += 0.012;
    oscillators.forEach((o, i) => {
      // ビブラートと緩やかなうねり
      const vibrato = Math.sin(t * 2.0 + i) * 2.5;
      const sweep = Math.sin(t * 0.3 + i * 1.7) * (o.base * 0.04);
      try { o.osc.frequency.value = o.base + vibrato + sweep; } catch (e) {}
    });
  }

  function draw() {
    if (!running) return;
    ctx.clearRect(0, 0, w, h);

    if (analyser && playing) {
      analyser.getByteFrequencyData(freqData);
      analyser.getByteTimeDomainData(waveData);
      modulate();
      drawBars();
      drawWave();
    } else {
      drawIdle();
    }
    rafId = requestAnimationFrame(draw);
  }

  // 周波数バー(下から伸びる)
  function drawBars() {
    const bars = 64;
    const step = Math.floor(freqData.length / bars);
    const bw = w / bars;
    for (let i = 0; i < bars; i++) {
      let sum = 0;
      for (let j = 0; j < step; j++) sum += freqData[i * step + j];
      const v = (sum / step) / 255;          // 0..1
      const bh = v * h * 0.7;
      const hue = 200 + i / bars * 140;
      ctx.fillStyle = `hsla(${hue}, 90%, ${40 + v * 30}%, 0.9)`;
      const x = i * bw;
      ctx.fillRect(x + 1, h - bh, bw - 2, bh);
    }
  }

  // 波形(中央に重ねて発光表示)
  function drawWave() {
    ctx.lineWidth = 2;
    ctx.strokeStyle = 'rgba(255, 235, 250, 0.85)';
    ctx.shadowBlur = 10;
    ctx.shadowColor = 'rgba(255, 130, 200, 0.9)';
    ctx.beginPath();
    const slice = w / waveData.length;
    for (let i = 0; i < waveData.length; i++) {
      const v = waveData[i] / 128 - 1;        // -1..1
      const x = i * slice;
      const y = h * 0.42 + v * h * 0.22;
      i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    }
    ctx.stroke();
    ctx.shadowBlur = 0;
  }

  // 停止中のアイドル表示(穏やかな波線)
  function drawIdle() {
    ctx.strokeStyle = 'rgba(150, 140, 220, 0.35)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    for (let x = 0; x <= w; x += 6) {
      const y = h / 2 + Math.sin(x * 0.02 + Date.now() * 0.002) * 12;
      x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    }
    ctx.stroke();
  }

  // 描画ループ開始(二重起動防止)
  function startLoop() {
    if (running) return;
    running = true;
    rafId = requestAnimationFrame(draw);
  }
  function stopLoop() {
    running = false;
    if (rafId) cancelAnimationFrame(rafId);
    rafId = 0;
  }

  // 再生/停止トグル
  btn.addEventListener('click', () => {
    if (!playing) {
      if (!ensureAudio()) return;
      startTone();
      playing = true;
      btn.textContent = '⏸ 停止';
      btn.classList.add('is-playing');
      statusEl.textContent = '合成音を再生中…';
    } else {
      stopTone();
      playing = false;
      btn.textContent = '▶ 再生';
      btn.classList.remove('is-playing');
      statusEl.textContent = '停止しました';
    }
  });

  // タブ非表示で描画ループ停止&音をサスペンド、復帰で再開
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
      stopLoop();
      if (audioCtx && audioCtx.state === 'running') audioCtx.suspend();
    } else {
      startLoop();
      if (playing && audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
    }
  });

  // アイドル描画は最初から回す(音は鳴らさない)
  startLoop();
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。