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


①オシロスコープとは
オシロスコープって何という人も多いかもしれません。
簡単に言うと、
オシロスコープとは、
音を視覚で見ることができる装置です。
本当は音だけではないのでけれども、
電気信号を波形として見ることができる装置。
そういえば、中学校の理科で、
大きさ、高さの学習をするときに
使ったなぁという人もいるかもしれません。
そうです、あれです。
医療系のドラマによく出てくる
心電図を表す機械みたいな
あれです。
(伝わってくれるとウレシイが、どうだろう)
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>