見出し画像

Web Audio APIでオシロスコープを作ってみた。

お久しぶりです。
今回は、Web Audio APIを使って
オシロスコープを作ってみました。

今回作ったオシロスコープはこれです。

PCで表示したときの画面


スマホで表示した時の画面

①オシロスコープとは

オシロスコープって何という人も多いかもしれません。

簡単に言うと、
オシロスコープとは、
音を視覚で見ることができる装置です。

本当は音だけではないのでけれども、
電気信号を波形として見ることができる装置。

そういえば、中学校の理科で、
大きさ、高さの学習をするときに
使ったなぁという人もいるかもしれません。

そうです、あれです。
医療系のドラマによく出てくる
心電図を表す機械みたいな
あれです。

(伝わってくれるとウレシイが、どうだろう)

Web Audio APIについてはこちら

②Geminiがコードを書いてくれました。

今回もいつも通り、
Geminiにコード作成を担当してもらい、
私は指示を出すだけで作成しました。

まずは、Geminiに

Web Audio APIを使って、オシロスコープを作成することはできますか?


と聞きました。

その後は、Geminiと相談しながら作成しました。

プログラムのコードを書きのは大変なので、
コードについては全てGeminiに書いてもらいました。

この方法を使えば、
プログラミングをしたことがない方でも
簡単に自分の欲しいアプリを作ることができます。
絶対おすすめです!!

といっても、今回はこだわりまくって
20~30回くらい修正してもらったので、
いつもよりはかなり時間がかかっています。
2時間くらい。

普通に使えるアプリが出てくるまでは
5分くらいだったので
ほとんどの時間(1時間55分)はこだわりです。
コダワリのカタマリ。

ですので、ぜひ
使ってくださると嬉しいです。

一応、
中学校の理科の授業で使えるように
作りましたので、
ぜひ活躍して欲しい思います。

③コードについて

最後にコードを表示しておきます。

コード全体をコピーして、テキストとして保存。
ファイル名の拡張子を.htmlにして開けば、
ブラウザが立ち上がり使用できます。

では、また。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Pro Web Oscilloscope v3.1</title>

<style>
:root {
  --bg-color: #0b0f0b;
  --accent-color: #39ff14;
  --grid-color: rgba(57,255,20,0.15);
  --text-color: #d8ffd8;
  --panel-bg: #1e241e;
  --gap-size: 15px;
}

body {
  margin: 0;
  background: var(--bg-color);
  color: var(--text-color);
  font-family: 'Share Tech Mono', monospace, sans-serif;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding-bottom: 50px;
  overflow-x: hidden;
}

/* ヘッダーレイアウト:3カラムで中央寄せを実現 */
.app-header {
  width: 90%;
  max-width: 860px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 20px;
  margin-bottom: 8px;
}

.header-side {
  flex: 1; /* 左右に同じだけの器を作る */
  display: flex;
}

.header-side.right {
  justify-content: flex-end; /* 右側の中身を右寄せ */
}

h1 { 
  color: var(--accent-color); 
  margin: 0; 
  letter-spacing: 4px; 
  font-size: 1.2rem;
  text-align: center;
  flex: 2; /* 中央エリアを広く確保 */
  white-space: nowrap;
}

.lang-switch {
  background: rgba(57, 255, 20, 0.1); 
  padding: 6px 14px;
  border-radius: 20px; 
  border: 1px solid var(--accent-color);
  font-size: 0.7rem; 
  color: var(--accent-color);
  cursor: pointer; 
  transition: 0.3s;
}

.lang-switch:hover { background: rgba(57, 255, 20, 0.2); }

.scope-wrapper { position: relative; width: 90%; max-width: 860px; margin-bottom: 20px; }

canvas {
  background: linear-gradient(var(--grid-color) 1px, transparent 1px),
              linear-gradient(90deg, var(--grid-color) 1px, transparent 1px),
              #020402;
  background-size: 25px 25px; border: 4px solid #2b332b; border-radius: 8px;
  width: 100%; height: 250px; display: block;
}

.overlay { position: absolute; font-family: monospace; color: var(--accent-color); font-size: 0.7rem; pointer-events: none; }
.top-left { top: 10px; left: 12px; }
.top-right { top: 10px; right: 12px; }
.bottom-left { bottom: 12px; left: 12px; }

.controls { width: 100%; max-width: 900px; display: flex; flex-direction: column; gap: var(--gap-size); }

.control-wrapper { 
  position: relative; width: 100%; padding: 0 20px; box-sizing: border-box; display: flex; flex-direction: column;
}

.control-wrapper::before, .control-wrapper::after {
  content: ""; position: absolute; top: 0; width: 15px; height: 100%;
  background: linear-gradient(to bottom, transparent, rgba(57,255,20,0.03), transparent);
}
.control-wrapper::before { left: 2px; border-right: 1px solid rgba(57,255,20,0.1); }
.control-wrapper::after { right: 2px; border-left: 1px solid rgba(57,255,20,0.1); }

/* コンパクトモード設定 */
.controls.compact-mode .control-wrapper { padding: 0 var(--gap-size); }
.controls.compact-mode .control-wrapper::before,
.controls.compact-mode .control-wrapper::after { opacity: 0; }

.control-group {
  background: var(--panel-bg); border: 1px solid #333; border-radius: 4px;
  padding: 15px; box-sizing: border-box; width: 100%; border-left: 4px solid var(--accent-color);
  flex-grow: 1; 
}

.panel-title {
  display: block; font-size: 0.7rem; color: var(--accent-color);
  border-bottom: 1px solid #333; margin-bottom: 12px; padding-bottom: 4px;
  letter-spacing: 1px; font-weight: bold;
}

label { font-size: 0.8rem; color: #adffad; font-weight: bold; margin-bottom: 5px; display: block; }
.value { float: right; color: var(--accent-color); font-family: monospace; }

select, button {
  width: 100%; margin-top: 10px; padding: 12px; background: #2a2a2a;
  color: #fff; border: 1px solid #444; border-radius: 4px; font-size: 0.85rem; cursor: pointer;
}

input[type="range"] { width: 100%; appearance: none; height: 8px; background: #000; border-radius: 4px; margin: 15px 0; }
input[type="range"]::-webkit-slider-thumb { appearance: none; width: 28px; height: 28px; border-radius: 50%; background: radial-gradient(#fff, #888); border: 2px solid #000; }

#startBtn { background: var(--accent-color); color: #000; font-weight: bold; height: 50px; }
#startBtn.active { background: #ff3333; color: #fff; }

#meterContainer { height: 10px; background: #000; border-radius: 5px; margin-bottom: 15px; overflow: hidden; display: flex; border: 1px solid #333; }
#meterBar { width: 0%; height: 100%; background: linear-gradient(90deg, #39ff14, #ffcc00, #ff3333); transition: width 0.05s ease-out; }
#muteBtn { margin: 0; padding: 5px 10px; width: auto; font-size: 0.7rem; background: #333; border: 1px solid #555; }
#muteBtn.muted { background: #ff3333; color: #fff; border-color: #ff8888; }

@media (min-width: 600px) {
  .controls { display: grid; grid-template-columns: 1fr 1fr; align-items: stretch; }
  .control-wrapper { padding: 0 10px; }
  .control-wrapper::before, .control-wrapper::after { display: none !important; }
}
</style>
</head>
<body>

<div class="app-header">
  <div class="header-side"></div> <h1>OSCILLOSCOPE</h1>
  <div class="header-side right">
    <div class="lang-switch" id="langToggle">EN / JP</div>
  </div>
</div>

<div class="scope-wrapper">
  <canvas id="scope"></canvas>
  <div class="overlay top-left" id="holdLabel" data-en="FREEZE" data-jp="静止中">FREEZE</div>
  <div class="overlay top-right" id="timeDiv">TIME/DIV: --</div>
  <div class="overlay bottom-left" id="voltDiv">VOLTS/DIV: --</div>
</div>

<div class="controls" id="mainControls">
  <div class="control-wrapper">
    <div class="control-group" style="border-left-color:#ffcc00">
      <span class="panel-title" data-en="SOURCE" data-jp="入力元">SOURCE</span>
      <select id="sourceSelect">
        <option value="osc" data-en="OSCILLATOR" data-jp="内部音源">OSCILLATOR</option>
        <option value="mic" data-en="MICROPHONE" data-jp="マイク入力">MICROPHONE</option>
      </select>
      <button id="startBtn" data-en="RUN" data-jp="開始">RUN</button>
    </div>
  </div>

  <div class="control-wrapper">
    <div class="control-group" id="oscPanel">
      <span class="panel-title" data-en="OSCILLATOR" data-jp="音源設定">OSCILLATOR</span>
      <label class="lang" data-en="AMP (Volume)" data-jp="AMP (音の強さ)">AMP (Volume) <span class="value" id="oscVolVal">0.5</span></label>
      <input type="range" id="oscVolSlider" min="0" max="1" step="0.01" value="0.5">
      <label class="lang" data-en="FREQ (Pitch)" data-jp="FREQ (音の高さ)">FREQ (Pitch) <span class="value" id="freqVal">440</span>Hz</label>
      <input type="range" id="freqSlider" min="100" max="2000" value="440">
      <select id="typeSelect">
        <option value="sine" data-en="SINE WAVE" data-jp="サイン波">SINE WAVE</option>
        <option value="square" data-en="SQUARE WAVE" data-jp="矩形波">SQUARE WAVE</option>
        <option value="triangle" data-en="TRIANGLE WAVE" data-jp="三角波">TRIANGLE WAVE</option>
        <option value="sawtooth" data-en="SAWTOOTH WAVE" data-jp="ノコギリ波">SAWTOOTH WAVE</option>
      </select>
    </div>
  </div>

  <div class="control-wrapper">
    <div class="control-group">
      <span class="panel-title" data-en="DISPLAY" data-jp="表示設定">DISPLAY</span>
      <label class="lang" data-en="GAIN (Vertical)" data-jp="GAIN (縦の拡大率)">GAIN (Vertical) <span class="value" id="gainVal">1.0</span></label>
      <input type="range" id="gainSlider" min="1" max="10" step="0.1" value="1">
      
      <label class="lang" data-en="HORIZONTAL (Time)" data-jp="HORIZONTAL (時間軸)">HORIZONTAL (Time)</label>
      <select id="fftSizeSelect">
        <option value="256" data-en="SHORT (Zoom In)" data-jp="SHORT (短時間を拡大)">SHORT (Zoom In)</option>
        <option value="2048" selected data-en="NORMAL" data-jp="NORMAL (ふつう)">NORMAL</option>
        <option value="8192" data-en="LONG (Zoom Out)" data-jp="LONG (長時間を表示)">LONG (Zoom Out)</option>
      </select>

      <label style="margin-top: 15px;" class="lang" data-en="TRIGGER (Freeze)" data-jp="TRIGGER (波形を止める)">TRIGGER (Freeze)
        <input type="checkbox" id="triggerCheck" checked style="float:right; width: 22px; height: 22px;">
      </label>
    </div>
  </div>

  <div class="control-wrapper">
    <div class="control-group" style="border-left-color:#00d4ff">
      <span class="panel-title" data-en="SPEAKER" data-jp="最終出力">SPEAKER</span>
      <div id="meterContainer"><div id="meterBar"></div></div>
      <label class="lang" data-en="MASTER VOL" data-jp="最終音量">MASTER VOL <span class="value" id="spVolVal">0.0</span></label>
      <div style="display: flex; align-items: center; gap: 10px;">
        <input type="range" id="spVolSlider" min="0" max="1" step="0.01" value="0" style="flex-grow: 1;">
        <button id="muteBtn" data-en="MUTE" data-jp="消音">MUTE</button>
      </div>
      <div id="scrollGuide" style="font-size: 0.6rem; color: #666; margin-top: 10px; text-align: center;" class="lang" data-en="Use side rails for scrolling" data-jp="横のレールを触ってスクロール">Use side rails for scrolling</div>
    </div>
  </div>
</div>

<script>
let audioCtx, analyser, source, oscillator, oscGain, speakerGain, animationId;
let currentLang = 'en';
let isMuted = false;

const langToggle = document.getElementById("langToggle");
const startBtn = document.getElementById("startBtn");
const timeDivLabel = document.getElementById("timeDiv");
const voltDivLabel = document.getElementById("voltDiv");
const scrollGuide = document.getElementById("scrollGuide");
const mainControls = document.getElementById("mainControls");
const meterBar = document.getElementById("meterBar");
const muteBtn = document.getElementById("muteBtn");

function updateLayout() {
  const isScrollable = document.body.scrollHeight > window.innerHeight;
  if (isScrollable) {
    scrollGuide.style.display = "block";
    mainControls.classList.remove("compact-mode");
  } else {
    scrollGuide.style.display = "none";
    mainControls.classList.add("compact-mode");
  }
}

langToggle.onclick = () => {
  currentLang = currentLang === 'en' ? 'jp' : 'en';
  document.querySelectorAll('[data-en]').forEach(el => {
    const text = el.getAttribute(`data-${currentLang}`);
    if (el.tagName === 'OPTION' || el.tagName === 'BUTTON' || el.classList.contains('lang') || el.classList.contains('overlay') || el.classList.contains('panel-title')) {
      if (el.querySelector('.value')) {
        const val = el.querySelector('.value').outerHTML;
        el.innerHTML = text + " " + val;
      } else { el.textContent = text; }
    }
  });
  updateTimeDiv(); updateVoltDiv();
};

const canvas = document.getElementById("scope");
const ctx = canvas.getContext("2d");
const sourceSelect = document.getElementById("sourceSelect");
const oscPanel = document.getElementById("oscPanel");
const freqSlider = document.getElementById("freqSlider");
const oscVolSlider = document.getElementById("oscVolSlider");
const spVolSlider = document.getElementById("spVolSlider");
const gainSlider = document.getElementById("gainSlider");
const typeSelect = document.getElementById("typeSelect");
const fftSizeSelect = document.getElementById("fftSizeSelect");
const triggerCheck = document.getElementById("triggerCheck");
const holdLabel = document.getElementById("holdLabel");
holdLabel.style.display = "none";

function resizeCanvas(){ 
  canvas.width = canvas.clientWidth; 
  canvas.height = canvas.clientHeight; 
  updateLayout(); 
}
window.onresize = resizeCanvas;
resizeCanvas();
window.onload = updateLayout;

sourceSelect.onchange = () => { oscPanel.classList.toggle("disabled", sourceSelect.value === "mic"); };

function updateTimeDiv(){
  if(!analyser || !audioCtx) return;
  const sec = analyser.fftSize / audioCtx.sampleRate;
  const label = currentLang === 'en' ? 'TIME/DIV' : '時間/目盛';
  timeDivLabel.textContent = `${label}: ${(sec/10*1000).toFixed(2)} ms`;
}

function updateVoltDiv(){
  const g = parseFloat(gainSlider.value);
  const label = currentLang === 'en' ? 'VOLTS/DIV' : '電圧/目盛';
  voltDivLabel.textContent = `${label}: ${(1/g).toFixed(2)} V`;
}

startBtn.onclick = async ()=>{
  if(audioCtx){
    audioCtx.close(); audioCtx = null;
    cancelAnimationFrame(animationId);
    startBtn.textContent = currentLang === 'en' ? "RUN" : "開始";
    startBtn.classList.remove("active");
    sourceSelect.disabled = false;
    meterBar.style.width = "0%";
    return;
  }
  audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  analyser = audioCtx.createAnalyser();
  analyser.fftSize = parseInt(fftSizeSelect.value);
  oscGain = audioCtx.createGain();
  oscGain.gain.value = oscVolSlider.value;
  speakerGain = audioCtx.createGain();
  speakerGain.gain.value = isMuted ? 0 : spVolSlider.value;
  sourceSelect.disabled = true;

  if (sourceSelect.value === 'mic') {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      source = audioCtx.createMediaStreamSource(stream);
      source.connect(analyser); 
    } catch (err) { alert(currentLang === 'en' ? "Mic error" : "マイクエラー"); return; }
  } else {
    oscillator = audioCtx.createOscillator();
    oscillator.type = typeSelect.value;
    oscillator.frequency.value = freqSlider.value;
    oscillator.connect(oscGain);
    oscGain.connect(analyser);
    oscGain.connect(speakerGain);
    speakerGain.connect(audioCtx.destination);
    oscillator.start();
  }
  updateTimeDiv(); updateVoltDiv();
  startBtn.textContent = currentLang === 'en' ? "STOP" : "停止";
  startBtn.classList.add("active");
  draw();
};

function draw(){
  animationId = requestAnimationFrame(draw);
  const bufLen = analyser.frequencyBinCount;
  const data = new Uint8Array(bufLen);
  analyser.getByteTimeDomainData(data);

  let sum = 0;
  for(let i=0; i<bufLen; i++){
    let val = (data[i]-128)/128;
    sum += val * val;
  }
  let rms = Math.sqrt(sum / bufLen);
  let meterLevel = Math.min(rms * 200, 100); 
  meterBar.style.width = meterLevel + "%";

  ctx.fillStyle = "rgba(0,0,0,0.2)";
  ctx.fillRect(0,0,canvas.width,canvas.height);
  let offset = 0;
  if(triggerCheck.checked){
    for (let i = 0; i < bufLen / 2; i++) {
      if (data[i] < 128 && data[i+1] >= 128) { offset = i; break; }
    }
  }
  ctx.beginPath();
  ctx.lineWidth = 2; ctx.strokeStyle = "#39ff14";
  ctx.shadowColor = "#39ff14"; ctx.shadowBlur = 10;
  let effectiveLen = bufLen - offset;
  let slice = canvas.width / effectiveLen;
  let x = 0;
  for(let i = offset; i < bufLen; i++){
    let v = (data[i]/128-1) * gainSlider.value;
    let y = (v+1)*canvas.height/2;
    if(i === offset) ctx.moveTo(x,y); else ctx.lineTo(x,y);
    x += slice;
  }
  ctx.stroke();
}

freqSlider.oninput = ()=>{
  const val = freqSlider.value;
  document.getElementById("freqVal").textContent = val;
  if(oscillator) oscillator.frequency.setTargetAtTime(val, audioCtx.currentTime, 0.03);
};
oscVolSlider.oninput = ()=>{
  const val = oscVolSlider.value;
  document.getElementById("oscVolVal").textContent = val;
  if(oscGain) oscGain.gain.setTargetAtTime(val, audioCtx.currentTime, 0.03);
};
spVolSlider.oninput = ()=>{
  if(isMuted) toggleMute();
  const val = spVolSlider.value;
  document.getElementById("spVolVal").textContent = val;
  if(speakerGain) speakerGain.gain.setTargetAtTime(val, audioCtx.currentTime, 0.03);
};
muteBtn.onclick = toggleMute;

function toggleMute(){
  isMuted = !isMuted;
  muteBtn.classList.toggle("muted", isMuted);
  const targetVol = isMuted ? 0 : spVolSlider.value;
  if(speakerGain) speakerGain.gain.setTargetAtTime(targetVol, audioCtx.currentTime, 0.05);
}

gainSlider.oninput = ()=>{
  document.getElementById("gainVal").textContent = gainSlider.value;
  updateVoltDiv();
};
fftSizeSelect.onchange = ()=>{
  if(analyser) analyser.fftSize = parseInt(fftSizeSelect.value);
  updateTimeDiv();
};
typeSelect.onchange = ()=>{
  if(oscillator) oscillator.type = typeSelect.value;
};
triggerCheck.onchange = ()=>{
  holdLabel.style.display = triggerCheck.checked ? "none" : "block";
};
</script>

</body>
</html>


いいなと思ったら応援しよう!