record-player 0.1.0

Standalone Bitneedle record player, acoustic scratch engine, and browser command protocol
Documentation
import {
  STROBE_ROWS,
  STROBE_LAMP_DEG,
  STROBE_BEAM_FULL_DEG,
  STROBE_BEAM_HALF_DEG,
  STROBE_DEG_PER_SEC,
  STROBE_CONT_DEG_PER_SEC,
  angularDeltaDegrees,
  degToRad,
  pointOnCircle,
  FONT_FAMILY
} from "./player-canvas-geometry.js";

function drawLampFaceLabel(ctx, lamp, lightOn, theme, scale) {
  const text = "ON <-----> OFF";
  const chars = Array.from(text);
  const radius = Math.max(lamp.radius * 0.56, lamp.radius - 3.2 * scale);
  const fontSize = Math.max(3, lamp.radius * 0.18);
  const centerDeg = lightOn ? -90 : -30;
  const center = degToRad(centerDeg);

  ctx.save();
  ctx.font = `700 ${fontSize}px ${FONT_FAMILY}`;
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillStyle = theme.line;
  const widths = chars.map(char => ctx.measureText(char).width);
  const totalWidth = widths.reduce((sum, width) => sum + width, 0);
  let offset = -totalWidth / 2;

  chars.forEach((char, index) => {
    const charOffset = offset + widths[index] / 2;
    const angle = center + charOffset / radius;
    const x = lamp.x + Math.cos(angle) * radius;
    const y = lamp.y + Math.sin(angle) * radius;
    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(angle + Math.PI / 2);
    ctx.fillText(char, 0, 0);
    ctx.restore();
    offset += widths[index];
  });

  ctx.restore();
}

export function strobeLampGeometry(geometry) {
  const radius = Math.max(7, geometry.buttonHeight * 0.5);
  const distance = geometry.outerRadius + radius + geometry.buttonHeight * 0.6;
  const point = pointOnCircle(geometry, STROBE_LAMP_DEG, distance);
  return { x: point.x, y: point.y, radius, angle: STROBE_LAMP_DEG };
}

export function drawStrobe(ctx, geometry, state, theme, lightOn, timestamp) {
  const playing = Boolean(state.playing || state.scratching);
  const rate = Number(state.playbackRate) || 0;
  const timeSec = timestamp / 1000;
  const baseDot = Math.max(1.15, geometry.scale * 1.45);
  const lamp = strobeLampGeometry(geometry);
  const physicalRotation = playing ? (rate * STROBE_CONT_DEG_PER_SEC * timeSec) % 360 : 0;

  ctx.save();
  STROBE_ROWS.forEach((row, rowIndex) => {
    const t = (rowIndex + 0.5) / STROBE_ROWS.length;
    const ringRadius = geometry.outerRadius - (geometry.outerRadius - geometry.innerRadius) * t;
    const dotRadius = baseDot * row.dotScale;
    const calibratedRate = 1 + row.pitchPct / 100;
    const error = playing ? rate - calibratedRate : 0;
    const stepDeg = 360 / row.count;
    const strobeOffset = ((error * STROBE_DEG_PER_SEC * timeSec) % stepDeg + stepDeg) % stepDeg;

    for (let index = 0; index < row.count; index += 1) {
      const movingAngleDeg = index * stepDeg + physicalRotation;
      const movingDistance = angularDeltaDegrees(movingAngleDeg, lamp.angle);
      if (!lightOn || movingDistance > STROBE_BEAM_HALF_DEG) {
        const angle = degToRad(movingAngleDeg);
        ctx.beginPath();
        ctx.arc(
          geometry.cx + Math.cos(angle) * ringRadius,
          geometry.cy + Math.sin(angle) * ringRadius,
          dotRadius,
          0,
          Math.PI * 2
        );
        ctx.fillStyle = theme.syncDot;
        ctx.fill();
      }

      if (!lightOn) continue;
      const sampledAngleDeg = index * stepDeg + strobeOffset;
      const distance = angularDeltaDegrees(sampledAngleDeg, lamp.angle);
      if (distance > STROBE_BEAM_HALF_DEG) continue;
      const feather = distance <= STROBE_BEAM_FULL_DEG
        ? 1
        : 1 - ((distance - STROBE_BEAM_FULL_DEG) / (STROBE_BEAM_HALF_DEG - STROBE_BEAM_FULL_DEG));
      const pulse = 0.9 + Math.sin(timestamp / 45) * 0.1;
      const angle = degToRad(sampledAngleDeg);
      ctx.beginPath();
      ctx.arc(
        geometry.cx + Math.cos(angle) * ringRadius,
        geometry.cy + Math.sin(angle) * ringRadius,
        dotRadius * (1 + feather * 0.12),
        0,
        Math.PI * 2
      );
      ctx.fillStyle = theme.syncLit;
      ctx.globalAlpha = Math.max(0, feather) * pulse;
      ctx.fill();
      ctx.globalAlpha = 1;
    }
  });

  if (lightOn) {
    const beamRadius = geometry.outerRadius + geometry.buttonHeight * 0.2;
    const beam = ctx.createRadialGradient(lamp.x, lamp.y, 0, lamp.x, lamp.y, geometry.buttonHeight * 2.2);
    beam.addColorStop(0, theme.lamp);
    beam.addColorStop(0.2, theme.lamp);
    beam.addColorStop(1, "rgba(0,191,211,0)");
    ctx.globalAlpha = 0.16;
    ctx.fillStyle = beam;
    ctx.beginPath();
    ctx.arc(lamp.x, lamp.y, geometry.buttonHeight * 2.2, 0, Math.PI * 2);
    ctx.fill();
    ctx.globalAlpha = 1;
    const a0 = degToRad(STROBE_LAMP_DEG - STROBE_BEAM_HALF_DEG);
    const a1 = degToRad(STROBE_LAMP_DEG + STROBE_BEAM_HALF_DEG);
    ctx.beginPath();
    ctx.moveTo(lamp.x, lamp.y);
    ctx.arc(geometry.cx, geometry.cy, beamRadius, a0, a1);
    ctx.closePath();
    const gradient = ctx.createRadialGradient(lamp.x, lamp.y, 0, lamp.x, lamp.y, geometry.outerRadius);
    gradient.addColorStop(0, theme.lamp);
    gradient.addColorStop(1, "rgba(0,191,211,0)");
    ctx.fillStyle = gradient;
    ctx.globalAlpha = 0.08;
    ctx.fill();
    ctx.globalAlpha = 1;
  }

  ctx.beginPath();
  ctx.arc(lamp.x, lamp.y, lamp.radius, 0, Math.PI * 2);
  ctx.fillStyle = theme.controlFill;
  ctx.fill();
  ctx.strokeStyle = theme.line;
  ctx.lineWidth = 1;
  ctx.stroke();

  drawLampFaceLabel(ctx, lamp, lightOn, theme, geometry.scale);

  ctx.beginPath();
  ctx.arc(lamp.x, lamp.y, Math.max(1.2, lamp.radius * 0.08), 0, Math.PI * 2);
  ctx.fillStyle = lightOn ? theme.lamp : theme.mutedLine;
  ctx.fill();

  ctx.restore();
  return lamp;
}