🎮ArcadeLab

Pitch Lab

by ArcadeLab
917 lines27.3 KB
▶ Play
<!--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.