オーディオビジュアライザー(Web Audio)
合成オシレータの和音を AnalyserNode で解析し、周波数バーと波形を同時に描く音響ビジュアル。マイク不要で再生ボタンから開始でき、音楽系UIやヒーロー演出に使えます。
ライブデモ
使用例(お題: アイドルグループ 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。