Pitch Lab
by ArcadeLab917 lines27.3 KB
<!--ARCADELAB
title: Pitch Lab
description: Pluck a row of strings like a mini-harp and watch each one wobble while you hear its note — make a string shorter or tighter for a higher pitch, and pull harder to play louder.
emoji: 🎸
color: pink
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Pitch Lab</title>
<style>
:root {
--bg: #1a0a14;
--panel: rgba(48, 18, 36, 0.86);
--accent: #ff7ab8;
--accent2: #ffd166;
--text: #ffe9f3;
--muted: #e0a9c6;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
overflow: hidden;
background: var(--bg);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: var(--text);
-webkit-tap-highlight-color: transparent;
}
#stage { position: fixed; inset: 0; }
canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
#hud {
position: fixed;
top: 0; left: 0; right: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
padding: clamp(10px, 2.4vw, 18px);
pointer-events: none;
z-index: 5;
}
.title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: clamp(17px, 4.6vw, 22px);
background: var(--panel);
border: 1px solid rgba(255, 122, 184, 0.32);
border-radius: 14px;
padding: 8px 13px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.title .em { font-size: 1.15em; }
.spacer { flex: 1 1 auto; }
button {
pointer-events: auto;
font: inherit;
font-size: clamp(13px, 3.6vw, 15px);
font-weight: 600;
color: var(--text);
background: var(--panel);
border: 1px solid rgba(255, 122, 184, 0.36);
border-radius: 13px;
padding: 11px 16px;
min-height: 44px;
cursor: pointer;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: transform .12s ease, background .18s ease, border-color .18s ease;
}
button:hover { background: rgba(78, 30, 58, 0.95); border-color: var(--accent); }
button:active { transform: scale(0.94); }
/* control panel at bottom */
#panel {
position: fixed;
left: 0; right: 0; bottom: 0;
display: flex;
justify-content: center;
padding: clamp(10px, 2.4vw, 18px);
pointer-events: none;
z-index: 5;
}
#controls {
pointer-events: auto;
background: var(--panel);
border: 1px solid rgba(255, 122, 184, 0.26);
border-radius: 16px;
padding: 12px 16px 14px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
width: min(560px, 94vw);
}
.pick {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 6px;
font-weight: 700;
font-size: clamp(13px, 3.5vw, 15px);
color: var(--muted);
}
.pick .name {
color: var(--accent2);
font-size: 1.05em;
}
.row {
display: flex;
align-items: center;
gap: 12px;
margin: 8px 0;
}
.row .lbl {
width: 92px;
flex: 0 0 auto;
font-weight: 700;
font-size: clamp(14px, 3.8vw, 16px);
display: flex;
align-items: center;
gap: 6px;
}
input[type=range] {
-webkit-appearance: none;
appearance: none;
flex: 1 1 auto;
height: 44px;
background: transparent;
cursor: pointer;
margin: 0;
}
input[type=range]::-webkit-slider-runnable-track {
height: 12px;
border-radius: 8px;
background: linear-gradient(90deg, #ff7ab8, #ffd166);
}
input[type=range]::-moz-range-track {
height: 12px;
border-radius: 8px;
background: linear-gradient(90deg, #ff7ab8, #ffd166);
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 30px;
height: 30px;
margin-top: -9px;
border-radius: 50%;
background: #fff;
border: 4px solid #ff4f9e;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
input[type=range]::-moz-range-thumb {
width: 26px;
height: 26px;
border-radius: 50%;
background: #fff;
border: 4px solid #ff4f9e;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
.pitchbar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
font-size: clamp(13px, 3.4vw, 15px);
color: var(--muted);
}
.pitchbar .track {
flex: 1 1 auto;
height: 14px;
border-radius: 8px;
background: rgba(255,255,255,0.12);
overflow: hidden;
position: relative;
}
.pitchbar .fill {
position: absolute;
left: 0; top: 0; bottom: 0;
border-radius: 8px;
background: linear-gradient(90deg, #6fd3ff, #ffd166, #ff7ab8);
width: 40%;
transition: width .12s ease;
}
#freq { font-weight: 700; color: var(--accent2); font-variant-numeric: tabular-nums; }
/* how it works */
#help {
position: fixed;
left: clamp(10px, 2.4vw, 18px);
top: 64px;
z-index: 6;
max-width: min(340px, 88vw);
}
#helpPanel {
margin-top: 8px;
background: var(--panel);
border: 1px solid rgba(255, 122, 184, 0.3);
border-radius: 14px;
padding: 12px 14px;
font-size: clamp(13px, 3.5vw, 15px);
line-height: 1.5;
color: var(--text);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: none;
}
#helpPanel.open { display: block; }
#helpPanel p { margin: 6px 0; }
#stage { opacity: 0; transition: opacity .8s ease; }
#stage.ready { opacity: 1; }
</style>
</head>
<body>
<div id="stage">
<canvas id="c"></canvas>
</div>
<div id="hud">
<div class="title"><span class="em">🎸</span><span>Pitch Lab</span></div>
<div class="spacer"></div>
</div>
<div id="help">
<button id="helpBtn" type="button">❓ How it works</button>
<div id="helpPanel">
<p>🎸 Pull any string with your finger and let go to make it sing!</p>
<p>📏 A <b>shorter</b> string wobbles faster and sounds <b>higher</b>.</p>
<p>💪 More <b>tension</b> (a tighter string) also makes it higher.</p>
<p>🔊 Pull <b>harder</b> and the string plays <b>louder</b>.</p>
<p>🎵 Tap a string to pick it, then use the sliders to tune it. The little wave at the top shows the sound you hear!</p>
</div>
</div>
<div id="panel">
<div id="controls">
<div class="pick">🎵 Tuning string <span id="pickName" class="name">A3</span></div>
<div class="row">
<div class="lbl">📏 Length</div>
<input id="lenSlider" type="range" min="0" max="100" value="55" aria-label="String length">
</div>
<div class="row">
<div class="lbl">💪 Tension</div>
<input id="tenSlider" type="range" min="0" max="100" value="50" aria-label="String tension">
</div>
<div class="pitchbar">
<span>🔈 low</span>
<div class="track"><div id="pitchFill" class="fill"></div></div>
<span>high 🔊</span>
<span id="freq">220 Hz</span>
</div>
</div>
</div>
<script>
(function () {
"use strict";
var stage = document.getElementById("stage");
var canvas = document.getElementById("c");
var ctx = canvas.getContext("2d");
if (!ctx) return;
var lenSlider = document.getElementById("lenSlider");
var tenSlider = document.getElementById("tenSlider");
var freqEl = document.getElementById("freq");
var pitchFill = document.getElementById("pitchFill");
var pickName = document.getElementById("pickName");
var helpBtn = document.getElementById("helpBtn");
var helpPanel = document.getElementById("helpPanel");
var W = 0, H = 0, DPR = 1;
// ---- pitch math helpers ----
// Convert a frequency to the nearest musical note name (e.g. 220 -> "A3").
var NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
function noteNameFor(f) {
if (!(f > 0)) return "—";
// MIDI note number: 69 = A4 = 440Hz
var midi = Math.round(69 + 12 * Math.log(f / 440) / Math.log(2));
if (midi < 0) midi = 0;
var name = NOTE_NAMES[((midi % 12) + 12) % 12];
var octave = Math.floor(midi / 12) - 1;
return name + octave;
}
// map a frequency to 0..1 for the indicator (log scale, musical)
function freqToFrac(f) {
var lo = Math.log(70), hi = Math.log(900);
var t = (Math.log(f) - lo) / (hi - lo);
return Math.max(0, Math.min(1, t));
}
// ---- strings: a mini-harp of several strings ----
// Each string has its own length & tension fraction. Frequency follows
// f ~ (1/L) * sqrt(T). They are drawn vertically, side by side.
var STRING_COUNT = 5;
// starting length fractions chosen so the harp sounds like a rising scale
var startLen = [0.95, 0.80, 0.66, 0.54, 0.44];
var strings = [];
for (var si = 0; si < STRING_COUNT; si++) {
strings.push({
lenFrac: startLen[si], // 0.35 .. 1.0 (slider-mapped range)
tenNorm: 0.5, // 0 .. 1
amp: 0, // current pluck amplitude (px)
pluckPos: 0.5, // where along the string it was plucked
phase: 0,
sign: 1,
lastAmp: 0 // amplitude at moment of pluck (for volume)
});
}
var selected = 2; // which string the sliders control
// frequency of a given string from its length/tension
function freqOfString(st) {
var lenFrac = st.lenFrac; // 0.35..1.0
var tension = 0.25 + st.tenNorm * 1.75; // 0.25..2.0
var baseLen = 0.6;
var f = 220 * (baseLen / lenFrac) * Math.sqrt(tension);
if (f < 70) f = 70;
if (f > 900) f = 900;
return f;
}
// Bottom edge (in canvas px) of the top-left controls — the title chip and
// the "How it works" button. On narrow screens these stack vertically, so
// the waveform card and harp must start below them, never underneath.
function topControlsBottom() {
var canRect = canvas.getBoundingClientRect();
var b = 0;
var ids = ["helpBtn"];
var header = document.querySelector("header, .header, h1");
var nodes = [];
if (header) nodes.push(header);
for (var i = 0; i < ids.length; i++) {
var el = document.getElementById(ids[i]);
if (el) nodes.push(el);
}
for (var k = 0; k < nodes.length; k++) {
var rr = nodes[k].getBoundingClientRect();
var local = rr.bottom - canRect.top;
if (local > b) b = local;
}
return b;
}
// ---- string geometry ----
// Strings run top-to-bottom. Each string occupies a vertical column.
// The TOP end is fixed; the playable span length depends on lenFrac.
function harpRect() {
// top room reserves space for the controls + waveform card (card + gaps)
var cardH = Math.min(70, H * 0.12);
var ctrlB = topControlsBottom();
// the card sits just below the controls; the harp sits below the card
var topMargin = Math.max(118, ctrlB + 8 + cardH + 30);
topMargin = Math.min(topMargin, H * 0.42);
var bottomMargin = Math.max(96, Math.min(H * 0.30, 230));
var maxSpan = Math.max(110, H - topMargin - bottomMargin);
var sideMargin = Math.min(W * 0.12, 70);
var usable = Math.max(80, W - sideMargin * 2);
return {
top: topMargin,
maxSpan: maxSpan,
sideMargin: sideMargin,
usable: usable
};
}
function stringX(i) {
var hr = harpRect();
if (STRING_COUNT === 1) return hr.sideMargin + hr.usable * 0.5;
return hr.sideMargin + (hr.usable) * (i / (STRING_COUNT - 1));
}
// returns {x, y0, y1, span} for string i
function stringGeom(i) {
var hr = harpRect();
var st = strings[i];
var span = hr.maxSpan * st.lenFrac;
var x = stringX(i);
var y0 = hr.top;
var y1 = y0 + span;
return { x: x, y0: y0, y1: y1, span: span };
}
// ---- Web Audio (created lazily on first user gesture) ----
var audioCtx = null;
var masterGain = null;
var analyser = null;
var waveData = null;
var audioReady = false;
function ensureAudio() {
if (audioReady) {
if (audioCtx && audioCtx.state === "suspended") {
audioCtx.resume();
}
return;
}
try {
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
audioCtx = new AC();
masterGain = audioCtx.createGain();
masterGain.gain.value = 0.9;
// a soft lowpass to keep the tone gentle for kids
var filter = audioCtx.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.value = 2600;
analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
waveData = new Uint8Array(analyser.fftSize);
masterGain.connect(filter);
filter.connect(analyser);
analyser.connect(audioCtx.destination);
audioReady = true;
} catch (err) {
audioReady = false;
}
if (audioCtx && audioCtx.state === "suspended") {
audioCtx.resume();
}
}
// Play one pluck note. A fresh oscillator per pluck so several strings
// can ring at once. loudness scales with how hard the string was pulled.
function playPluck(st) {
ensureAudio();
if (!audioReady || !audioCtx || !masterGain) return;
var f = freqOfString(st);
var now = audioCtx.currentTime;
// amp is in pixels (~4..150). Map to a comfortable gain range.
var pull = Math.max(4, Math.min(150, st.lastAmp));
var loud = 0.05 + (pull / 150) * 0.30;
try {
var osc = audioCtx.createOscillator();
osc.type = "triangle";
osc.frequency.setValueAtTime(f, now);
var g = audioCtx.createGain();
g.gain.setValueAtTime(0.0001, now);
g.gain.linearRampToValueAtTime(loud, now + 0.012);
g.gain.exponentialRampToValueAtTime(0.0001, now + 2.0);
osc.connect(g);
g.connect(masterGain);
osc.start(now);
osc.stop(now + 2.1);
osc.onended = function () {
try { osc.disconnect(); g.disconnect(); } catch (e) {}
};
} catch (err) {}
}
// ---- layout ----
function resize() {
var rect = stage.getBoundingClientRect();
W = Math.max(1, rect.width);
H = Math.max(1, rect.height);
DPR = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * DPR);
canvas.height = Math.round(H * DPR);
canvas.style.width = W + "px";
canvas.style.height = H + "px";
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
// ---- input ----
function pointFromEvent(e) {
var rect = canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
// which string is near (x,y)? returns {index, frac} or null
function nearString(x, y) {
var best = -1, bestDx = 1e9, bestFrac = 0.5;
for (var i = 0; i < STRING_COUNT; i++) {
var g = stringGeom(i);
if (y < g.y0 - 50 || y > g.y1 + 50) continue;
var dx = Math.abs(x - g.x);
var grabBand = 46; // generous tap target
if (dx < grabBand && dx < bestDx) {
bestDx = dx;
best = i;
bestFrac = g.span > 0 ? (y - g.y0) / g.span : 0.5;
}
}
if (best < 0) return null;
return { index: best, frac: Math.max(0, Math.min(1, bestFrac)) };
}
// dragging state
var dragging = false;
var dragPointerId = null;
var dragX = 0, dragY = 0;
var dragString = -1;
var dragGrabFrac = 0.5;
canvas.addEventListener("pointerdown", function (e) {
e.preventDefault();
ensureAudio();
if (dragging) return;
var pt = pointFromEvent(e);
var hit = nearString(pt.x, pt.y);
if (!hit) return;
dragging = true;
dragPointerId = e.pointerId;
dragString = hit.index;
dragGrabFrac = hit.frac;
dragX = pt.x;
dragY = pt.y;
selected = hit.index;
syncSlidersToSelected();
var st = strings[dragString];
st.amp = 0;
if (canvas.setPointerCapture) {
try { canvas.setPointerCapture(e.pointerId); } catch (err) {}
}
});
canvas.addEventListener("pointermove", function (e) {
if (!dragging || e.pointerId !== dragPointerId) return;
e.preventDefault();
var pt = pointFromEvent(e);
dragX = pt.x;
dragY = pt.y;
});
function releaseString(e) {
if (!dragging || e.pointerId !== dragPointerId) return;
e.preventDefault();
dragging = false;
dragPointerId = null;
var i = dragString;
dragString = -1;
if (i < 0 || i >= STRING_COUNT) return;
var st = strings[i];
var g = stringGeom(i);
var restX = g.x;
// horizontal pull distance is how far the finger moved sideways
var pull = dragX - restX;
var amp = Math.abs(pull);
if (amp < 4) {
amp = 26; // a tap counts as a small gentle pluck
}
amp = Math.min(amp, 150);
st.amp = amp;
st.lastAmp = amp;
st.pluckPos = dragGrabFrac;
st.phase = 0;
st.sign = (pull >= 0) ? 1 : -1;
playPluck(st);
}
canvas.addEventListener("pointerup", releaseString);
canvas.addEventListener("pointercancel", function (e) {
if (dragging && e.pointerId === dragPointerId) {
dragging = false;
dragPointerId = null;
dragString = -1;
}
});
// ---- sliders ----
function syncSlidersToSelected() {
var st = strings[selected];
// lenFrac is 0.35..1.0 -> slider 0..100
var lenVal = Math.round(((st.lenFrac - 0.35) / 0.65) * 100);
lenSlider.value = Math.max(0, Math.min(100, lenVal));
tenSlider.value = Math.round(st.tenNorm * 100);
updateReadout();
}
function onLenChange() {
var st = strings[selected];
st.lenFrac = 0.35 + (lenSlider.value / 100) * 0.65;
updateReadout();
}
function onTenChange() {
var st = strings[selected];
st.tenNorm = tenSlider.value / 100;
updateReadout();
}
lenSlider.addEventListener("input", onLenChange);
tenSlider.addEventListener("input", onTenChange);
function updateReadout() {
var st = strings[selected];
var f = freqOfString(st);
freqEl.textContent = Math.round(f) + " Hz";
pitchFill.style.width = (8 + freqToFrac(f) * 92) + "%";
pickName.textContent = noteNameFor(f);
}
helpBtn.addEventListener("click", function () {
helpPanel.classList.toggle("open");
});
// ---- drawing ----
// The vibration we DRAW must visually match the pitch we HEAR.
function visibleWobbleRate(f) {
return (f / 220) * 9.0; // 220Hz -> 9 rad/s wobble
}
// sideways displacement of string i at fraction u (0..1)
function stringOffset(i, u) {
var g = stringGeom(i);
var st = strings[i];
if (dragging && dragString === i) {
var peak = dragX - g.x;
var grab = dragGrabFrac;
var shape;
if (u <= grab) {
shape = (grab <= 0) ? 0 : u / grab;
} else {
shape = (grab >= 1) ? 0 : (1 - u) / (1 - grab);
}
return peak * shape;
}
if (st.amp <= 0.4) return 0;
var envelope = Math.sin(Math.PI * u);
var wob = Math.cos(st.phase);
return st.sign * st.amp * envelope * wob;
}
function drawPost(x, y, r) {
ctx.fillStyle = "#7a2a4c";
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#ff7ab8";
ctx.beginPath();
ctx.arc(x, y, r * 0.62, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(255,255,255,0.85)";
ctx.beginPath();
ctx.arc(x - r * 0.22, y - r * 0.22, r * 0.22, 0, Math.PI * 2);
ctx.fill();
}
function roundRect(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
var lastTime = 0;
function draw(time) {
var dt = lastTime ? (time - lastTime) / 1000 : 0;
if (dt > 0.05) dt = 0.05;
lastTime = time;
// advance each string's vibration
for (var i = 0; i < STRING_COUNT; i++) {
var st = strings[i];
if (!(dragging && dragString === i) && st.amp > 0.4) {
var f = freqOfString(st);
st.phase += visibleWobbleRate(f) * dt;
var decay = Math.exp(-dt * (0.9 + f / 1400));
st.amp *= decay;
if (st.amp < 0.4) st.amp = 0;
}
}
// background
var bg = ctx.createLinearGradient(0, 0, 0, H);
bg.addColorStop(0, "#2a0e20");
bg.addColorStop(0.55, "#1a0a14");
bg.addColorStop(1, "#120710");
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
var hr = harpRect();
// top beam the strings hang from
var beamH = Math.min(26, H * 0.045);
ctx.fillStyle = "#5a1f3c";
roundRect(hr.sideMargin - 30, hr.top - beamH, hr.usable + 60, beamH, 10);
ctx.fill();
ctx.fillStyle = "rgba(255,255,255,0.10)";
roundRect(hr.sideMargin - 26, hr.top - beamH + 4, hr.usable + 52, beamH * 0.4, 6);
ctx.fill();
var samples = 64;
ctx.lineCap = "round";
ctx.lineJoin = "round";
// draw each string
for (var s = 0; s < STRING_COUNT; s++) {
var g = stringGeom(s);
var strSt = strings[s];
var isSel = (s === selected);
var live = strSt.amp;
if (dragging && dragString === s) {
live = Math.abs(dragX - g.x);
}
// soft vertical glow band behind the string when it's loud
if (live > 2) {
var glowA = Math.min(0.32, live / 220);
var bandW = 70;
var bgg = ctx.createLinearGradient(g.x - bandW, 0, g.x + bandW, 0);
bgg.addColorStop(0, "rgba(255,122,184,0)");
bgg.addColorStop(0.5, "rgba(255,122,184," + glowA.toFixed(3) + ")");
bgg.addColorStop(1, "rgba(255,122,184,0)");
ctx.fillStyle = bgg;
ctx.fillRect(g.x - bandW, g.y0 - 20, bandW * 2, g.span + 40);
}
// selection highlight column
if (isSel) {
ctx.fillStyle = "rgba(255,209,102,0.10)";
roundRect(g.x - 30, g.y0 - 8, 60, g.span + 16, 16);
ctx.fill();
}
// top peg
drawPost(g.x, g.y0, Math.min(13, W * 0.03 + 7));
// ghost motion trails
if (!(dragging && dragString === s) && strSt.amp > 1.5) {
for (var t = 1; t <= 2; t++) {
var ghostPhase = strSt.phase - t * 0.55;
ctx.beginPath();
for (var k = 0; k <= samples; k++) {
var uk = k / samples;
var env = Math.sin(Math.PI * uk);
var ox = strSt.sign * strSt.amp * env * Math.cos(ghostPhase);
var gy = g.y0 + uk * g.span;
if (k === 0) ctx.moveTo(g.x + ox, gy); else ctx.lineTo(g.x + ox, gy);
}
ctx.strokeStyle = "rgba(255,226,138," + (0.16 / t).toFixed(3) + ")";
ctx.lineWidth = 4;
ctx.stroke();
}
}
// outer glow stroke
ctx.beginPath();
for (var a = 0; a <= samples; a++) {
var ua = a / samples;
var offa = stringOffset(s, ua);
var ya = g.y0 + ua * g.span;
if (a === 0) ctx.moveTo(g.x + offa, ya); else ctx.lineTo(g.x + offa, ya);
}
ctx.strokeStyle = isSel ? "rgba(255,209,102,0.45)" : "rgba(255,209,102,0.22)";
ctx.lineWidth = 10;
ctx.stroke();
// main string
ctx.beginPath();
for (var b = 0; b <= samples; b++) {
var ub = b / samples;
var offb = stringOffset(s, ub);
var yb = g.y0 + ub * g.span;
if (b === 0) ctx.moveTo(g.x + offb, yb); else ctx.lineTo(g.x + offb, yb);
}
var strGrad = ctx.createLinearGradient(0, g.y0, 0, g.y1);
strGrad.addColorStop(0, "#fff3c4");
strGrad.addColorStop(0.5, "#ffe28a");
strGrad.addColorStop(1, "#fff3c4");
ctx.strokeStyle = strGrad;
ctx.lineWidth = isSel ? 5.5 : 4;
ctx.stroke();
// bottom anchor dot
ctx.fillStyle = "#7a2a4c";
ctx.beginPath();
ctx.arc(g.x, g.y1, 6, 0, Math.PI * 2);
ctx.fill();
// note name label below the string
var f = freqOfString(strSt);
var labelY = g.y1 + 26;
var fontSize = Math.round(Math.min(W * 0.05, 19));
ctx.font = "700 " + fontSize + "px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
// pill background
var noteTxt = noteNameFor(f);
var pillW = Math.max(40, ctx.measureText(noteTxt).width + 18);
var pillH = fontSize + 12;
ctx.fillStyle = isSel ? "rgba(255,209,102,0.92)" : "rgba(48,18,36,0.85)";
roundRect(g.x - pillW / 2, labelY - pillH / 2, pillW, pillH, pillH / 2);
ctx.fill();
if (!isSel) {
ctx.strokeStyle = "rgba(255,122,184,0.4)";
ctx.lineWidth = 1.5;
ctx.stroke();
}
ctx.fillStyle = isSel ? "#3a0e26" : "#ffe9f3";
ctx.fillText(noteTxt, g.x, labelY);
// finger grab dot
if (dragging && dragString === s) {
var gx = g.x + (dragX - g.x);
var gy2 = g.y0 + dragGrabFrac * g.span;
var pg = ctx.createRadialGradient(gx, gy2, 0, gx, gy2, 24);
pg.addColorStop(0, "rgba(255,255,255,0.95)");
pg.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = pg;
ctx.beginPath();
ctx.arc(gx, gy2, 24, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#ff4f9e";
ctx.beginPath();
ctx.arc(gx, gy2, 9, 0, Math.PI * 2);
ctx.fill();
}
}
// ---- live sound waveform across the top ----
drawWaveform();
// instruction text when everything is quiet
var anyMoving = dragging;
for (var q = 0; q < STRING_COUNT; q++) {
if (strings[q].amp > 0.5) { anyMoving = true; break; }
}
if (!anyMoving) {
ctx.fillStyle = "rgba(255,233,243,0.80)";
ctx.font = "600 " + Math.round(Math.min(W * 0.044, 21)) + "px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
ctx.fillText("Pluck a string! 👈👉", W / 2, hr.top + hr.maxSpan * 0.5);
}
requestAnimationFrame(draw);
}
// draws the analyser waveform inside a rounded card near the top
function drawWaveform() {
var hr = harpRect();
var cardW = Math.min(W - 32, 360);
var cardH = Math.min(70, H * 0.12);
var cardX = (W - cardW) / 2;
// sit the card just below the top-left controls and above the harp,
// so it never overlaps the title chip or "How it works" button.
var cardY = topControlsBottom() + 8;
var maxCardY = hr.top - cardH - 8;
if (cardY > maxCardY) cardY = maxCardY;
if (cardY < 6) cardY = 6;
// card background
ctx.fillStyle = "rgba(48,18,36,0.78)";
roundRect(cardX, cardY, cardW, cardH, 14);
ctx.fill();
ctx.strokeStyle = "rgba(255,122,184,0.32)";
ctx.lineWidth = 1.5;
ctx.stroke();
var midY = cardY + cardH / 2;
var padX = 12;
var plotW = cardW - padX * 2;
if (audioReady && analyser && waveData) {
try {
analyser.getByteTimeDomainData(waveData);
} catch (e) {}
ctx.beginPath();
var n = waveData.length;
// show a slice of the buffer so the wave is readable
var slice = Math.min(n, 512);
for (var i = 0; i < slice; i++) {
var v = (waveData[i] - 128) / 128; // -1..1
var x = cardX + padX + (i / (slice - 1)) * plotW;
var y = midY - v * (cardH * 0.40);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.strokeStyle = "#ffd166";
ctx.lineWidth = 2.4;
ctx.lineJoin = "round";
ctx.stroke();
} else {
// flat line before audio starts
ctx.beginPath();
ctx.moveTo(cardX + padX, midY);
ctx.lineTo(cardX + cardW - padX, midY);
ctx.strokeStyle = "rgba(255,209,102,0.5)";
ctx.lineWidth = 2.4;
ctx.stroke();
}
// tiny label
ctx.fillStyle = "rgba(224,169,198,0.9)";
ctx.font = "600 11px system-ui, sans-serif";
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.fillText("🔊 sound wave", cardX + 12, cardY + 15);
}
// ---- boot ----
window.addEventListener("resize", resize);
resize();
syncSlidersToSelected();
requestAnimationFrame(draw);
requestAnimationFrame(function () {
stage.classList.add("ready");
});
})();
</script>
</body>
</html>
Game Source: Pitch Lab
Creator: ArcadeLab
Libraries: none
Complexity: complex (917 lines, 27.3 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: pitch-lab-arcadelab" to link back to the original. Then publish at arcadelab.ai/publish.