AUDIOVISUAL / 327
Audio-clock beat sync
音の拍と、動きの拍を揃える
Web Audioの時計に拍を予約し、同じ時刻から図形の位相を計算します。
見本の中を操作して変化を試してください。
実装コードを見る
<!doctype html><html lang="ja"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{margin:0;min-height:330px}.sample{width:100%;height:100%;min-height:330px;padding:12px!important;display:flex!important;flex-direction:column;justify-content:center;align-items:center;gap:8px!important;background:#f0efeb;color:#232323;font-family:Arial,Meiryo,sans-serif;overflow:hidden;box-sizing:border-box}.sample *{box-sizing:border-box}.lab-scene{width:100%;max-width:430px;height:140px;position:relative;overflow:hidden;border-bottom:1px solid #ccc}.lab-scene:has(canvas){max-width:320px;height:auto;aspect-ratio:16/9;flex-shrink:0}.lab-canvas{display:block;width:100%;height:100%!important;outline-offset:-3px}.lab-canvas:focus-visible{outline:2px solid #222}.lab-controls{width:100%;max-width:390px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 14px}.lab-controls label{display:grid;grid-template-columns:1fr auto;gap:2px;font-size:11px;min-width:0}.lab-controls input{grid-column:1/-1;width:100%;min-width:0;margin:0;accent-color:#333;height:30px}.lab-controls output{font-variant-numeric:tabular-nums}.lab-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:center}.sample .lab-actions button{color:inherit;font:11px Arial,Meiryo,sans-serif;border:1px solid #aaa;border-radius:99px;background:transparent;min-height:44px;padding:8px 12px;cursor:pointer}.lab-status{font-size:10px;line-height:1.4;min-height:14px;margin:0;text-align:center}.lab-note{font-size:9px;line-height:1.3;color:#666;margin:0;text-align:center}.lab-actions button:focus-visible{outline:2px solid #222;outline-offset:2px}@media(prefers-reduced-motion:reduce){.sample *{animation-play-state:paused!important;transition:none!important}}.lab-scene{height:160px;width:100%;max-width:320px}</style><div class="sample"><div class="lab-scene"><canvas role="img" aria-label="音の大きさと拍の可視化" style="width:100%;height:100%"></canvas></div><div class="lab-controls"><label>テンポ BPM<output>90</output><input aria-label="テンポ BPM" type="range" min="50" max="160" step="1" value="90" data-key="tempo"></label></div><div class="lab-actions"><button data-play>停止</button><button data-reset>音を再生</button></div><p class="lab-status" role="status"></p></div><script>const lab=(function runDomLab(root,cfg,setup){
const abort=new AbortController(),values=Object.fromEntries(cfg.controls.map(c=>[c.key,c.value])),mq=matchMedia('(prefers-reduced-motion:reduce)');let auto=!mq.matches;
const on=(el,event,fn)=>el.addEventListener(event,fn,{signal:abort.signal});
const api=setup({root,values,kind:cfg.kind,on,reduced:mq.matches})||{};
for(const input of root.querySelectorAll('[data-key]'))on(input,'input',()=>{values[input.dataset.key]=Number(input.value);input.closest('label').querySelector('output').textContent=input.value;api.update?.()});
const play=root.querySelector('[data-play]');on(play,'click',()=>{auto=!auto;api.setAuto?.(auto);for(const a of root.querySelector('.lab-scene').getAnimations({subtree:true}))auto?a.play():a.pause();play.textContent=auto?'停止':'再生';play.setAttribute('aria-pressed',String(!auto))});
const reset=root.querySelector('[data-reset]');on(reset,'click',()=>api.action?.());
on(document,'visibilitychange',()=>api.setAuto?.(auto&&!document.hidden));
api.update?.();if(cfg.static)play.hidden=true;
return {setAuto(on){auto=on&&!mq.matches;api.setAuto?.(auto);for(const a of root.querySelector('.lab-scene').getAnimations({subtree:true}))auto?a.play():a.pause();play.textContent=auto?'停止':'再生'},destroy(){abort.abort();api.destroy?.()}};
})(document,{"kind":"av-beat","controls":[{"key":"tempo","label":"テンポ BPM","min":50,"max":160,"value":90,"step":1}],"static":true,"action":"音を再生"},function audioSetup({root,values:v,kind,on}){
const canvas=root.querySelector('canvas'),g=canvas.getContext('2d'),status=root.querySelector('.lab-status'),button=root.querySelector('[data-reset]');let ac,stream,analyser,gains=[],frame=0,timer=0,clock=0,next=0,started=0,dead=false,starting=false,playing=false;
canvas.width=640;canvas.height=360;
const paint=(amount=0,beat=0)=>{g.setTransform(2,0,0,2,0,0);g.clearRect(0,0,320,180);g.strokeStyle='#bbb';g.beginPath();g.arc(160,90,60,0,Math.PI*2);g.stroke();g.fillStyle='#292929';g.beginPath();g.arc(160,90,15+amount*45,0,Math.PI*2);g.fill();for(let i=0;i<4;i++){g.fillStyle=i===beat?'#222':'#ccc';g.fillRect(125+i*20,160,10,4)}};
const stop=()=>{playing=false;cancelAnimationFrame(frame);clearTimeout(timer);clearInterval(clock);stream?.getTracks().forEach(t=>t.stop());stream=null;const old=ac;ac=null;old?.close().catch(()=>{});gains=[];button.textContent=kind==='av-microphone'?'マイクを許可して試す':'音を再生';button.setAttribute('aria-pressed','false');status.textContent='停止中';paint()};
const update=()=>{if(ac&&gains.length){gains[0].gain.setTargetAtTime(Math.cos(v.mix/100*Math.PI/2)*.09,ac.currentTime,.05);gains[1].gain.setTargetAtTime(Math.sin(v.mix/100*Math.PI/2)*.09,ac.currentTime,.05)}};
async function start(){if(starting||dead)return;if(playing){stop();return}starting=true;try{
const Audio=window.AudioContext||window.webkitAudioContext;if(!Audio)throw Error('この環境はWeb Audioに対応していません。');ac=new Audio();await ac.resume();if(dead){stop();return}
analyser=ac.createAnalyser();analyser.fftSize=256;
if(kind==='av-microphone'){
if(!navigator.mediaDevices?.getUserMedia)throw Error('個別ページの「ラボで詳しく試す」から開き、マイクの利用を許可してください。');
const granted=await navigator.mediaDevices.getUserMedia({audio:true});if(dead||!ac){granted.getTracks().forEach(t=>t.stop());return}stream=granted;ac.createMediaStreamSource(stream).connect(analyser);status.textContent='マイク入力を可視化中 · 録音・送信・スピーカー出力はしません';
}else if(kind==='sound-crossfade'){
analyser.connect(ac.destination);for(let voice=0;voice<2;voice++){const buffer=ac.createBuffer(1,ac.sampleRate,ac.sampleRate),data=buffer.getChannelData(0);for(let i=0;i<data.length;i++){const t=i/ac.sampleRate;data[i]=Math.sin(2*Math.PI*(voice?330:220)*t)*(.5+.35*Math.sin(2*Math.PI*(voice?3:2)*t))}const source=ac.createBufferSource(),gain=ac.createGain();source.buffer=buffer;source.loop=true;gain.gain.value=0;source.connect(gain);gain.connect(analyser);gains.push(gain);source.start()}update();status.textContent='生成した二つのループ音源 · 等電力クロスフェード';
}else{
analyser.connect(ac.destination);next=ac.currentTime+.05;started=next;const schedule=()=>{if(!ac)return;while(next<ac.currentTime+.12){const o=ac.createOscillator(),gain=ac.createGain();o.frequency.value=180;gain.gain.setValueAtTime(.12,next);gain.gain.exponentialRampToValueAtTime(.001,next+.1);o.connect(gain);gain.connect(analyser);o.start(next);o.stop(next+.12);next+=60/v.tempo}};schedule();clock=setInterval(schedule,30);status.textContent='音の時計に合わせて、拍と形を動かします';
}
playing=true;button.textContent='停止する';button.setAttribute('aria-pressed','true');const data=new Uint8Array(256);function tick(){if(!playing||!ac||dead)return;analyser.getByteTimeDomainData(data);let rms=Math.sqrt(data.reduce((sum,x)=>sum+((x-128)/128)**2,0)/data.length);const t=ac.currentTime-started,phase=Math.max(0,t*v.tempo/60);paint(kind==='av-beat'?Math.exp(-(phase%1)*8):Math.min(1,rms*(kind==='av-microphone'?v.sensitivity:6)),Math.floor(phase)%4);frame=requestAnimationFrame(tick)}tick();timer=setTimeout(stop,kind==='av-microphone'?20000:8000);
}catch(e){stop();status.textContent=kind==='av-microphone'?'マイクを開始できません。許可設定を確認し、ラボ画面から試してください。':e.message}finally{starting=false}}
on(document,'visibilitychange',()=>{if(document.hidden)stop()});paint();status.textContent=kind==='av-microphone'?'操作後にマイクの許可を求めます · 最大20秒':'操作後だけ音が鳴ります · 最大8秒';
return{update,action:start,setAuto(auto){if(!auto)stop()},destroy(){dead=true;stop()}};
});addEventListener('pagehide',()=>lab.destroy(),{once:true});</script></html>