Sound-Graph / index.html
Hma47's picture
Upload index.html
4fd4566 verified
Raw
History Blame Contribute Delete
26.4 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sonicos Grapher - One File</title>
<style>
:root {
--bg: #0A0B0E;
--card: #151619;
--border: #2A2D35;
--muted: #8E9299;
--text: #E1E1E6;
--cyan: #00D1FF;
--teal: #2dd4bf;
--red: #ef4444;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.app {
width: min(1100px, 100%);
margin: 0 auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--border);
padding-bottom: 16px;
}
.brand { display: flex; align-items: center; gap: 12px; }
.logo {
width: 26px;
height: 26px;
border-radius: 7px;
background: var(--cyan);
box-shadow: 0 0 14px rgba(0,209,255,.7);
display: grid;
place-items: center;
color: var(--bg);
font-weight: 900;
}
h1 {
margin: 0;
font-size: 14px;
letter-spacing: 2px;
text-transform: uppercase;
}
.badges { display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end; }
.badge {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 11px;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 7px;
padding: 6px 8px;
background: rgba(255,255,255,.02);
}
.live {
color: var(--cyan);
border-color: rgba(0,209,255,.35);
background: rgba(0,209,255,.12);
display: none;
align-items: center;
gap: 7px;
}
.live.active { display: flex; }
.pulse {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--cyan);
box-shadow: 0 0 7px var(--cyan);
animation: pulse 1s infinite alternate;
}
@keyframes pulse { from { opacity: .35; } to { opacity: 1; } }
.layout {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(190px, 1fr);
gap: 24px;
}
.main, .side { display: flex; flex-direction: column; gap: 16px; }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
box-shadow: 0 10px 30px rgba(0,0,0,.16);
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 6px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px;
margin-bottom: 12px;
}
button {
border: 0;
font: inherit;
cursor: pointer;
}
.tool-btn, .preset-btn {
background: var(--card);
color: var(--text);
border: 1px solid transparent;
border-radius: 8px;
transition: .15s ease;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.tool-btn {
min-width: 38px;
padding: 8px 10px;
font-size: 14px;
}
.tool-btn:hover, .preset-btn:hover {
color: var(--cyan);
background: #20232a;
border-color: rgba(0,209,255,.45);
}
.formula-wrap {
display: flex;
gap: 10px;
align-items: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
}
.formula-wrap:focus-within {
outline: 2px solid rgba(0,209,255,.7);
border-color: transparent;
}
#formulaInput {
width: 100%;
min-height: 44px;
resize: vertical;
background: transparent;
color: var(--text);
border: none;
outline: none;
font-size: 20px;
line-height: 1.35;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.hint {
margin-top: 10px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.error {
display: none;
margin-top: 12px;
padding: 12px;
border-radius: 10px;
color: #fca5a5;
border: 1px solid rgba(239,68,68,.5);
background: rgba(239,68,68,.12);
font-size: 13px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
overflow-wrap: anywhere;
}
.error.active { display: block; }
.canvas-card {
position: relative;
overflow: hidden;
display: grid;
place-items: center;
padding: 0;
min-height: 260px;
background-color: var(--card);
background-image:
linear-gradient(var(--border) 1px, transparent 1px),
linear-gradient(90deg, var(--border) 1px, transparent 1px);
background-size: 40px 40px;
}
canvas {
display: block;
width: 100%;
height: auto;
max-height: 520px;
aspect-ratio: 2 / 1;
}
.controls { display: flex; gap: 14px; align-items: center; }
.primary, .secondary {
border-radius: 14px;
padding: 16px 20px;
text-transform: uppercase;
letter-spacing: 1.7px;
font-size: 13px;
font-weight: 800;
transition: .15s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.primary {
flex: 1;
background: var(--cyan);
color: var(--bg);
}
.primary:hover { filter: brightness(.94); }
.primary.stop {
background: var(--card);
color: var(--text);
border: 1px solid var(--border);
}
.primary.stop:hover { border-color: var(--cyan); }
.primary:disabled {
cursor: not-allowed;
background: var(--border);
color: var(--muted);
}
.secondary {
background: var(--bg);
color: var(--muted);
border: 1px solid var(--border);
}
.secondary:hover { border-color: var(--muted); color: var(--text); }
.side-title {
margin: 0 0 2px;
color: var(--muted);
font-size: 10px;
letter-spacing: 1.5px;
text-transform: uppercase;
font-weight: 900;
}
.preset-list {
display: flex;
flex-direction: column;
gap: 9px;
max-height: 620px;
overflow-y: auto;
padding-right: 4px;
}
.preset-btn {
width: 100%;
text-align: left;
padding: 11px 12px;
color: var(--muted);
border-color: var(--border);
background: var(--bg);
font-size: 12px;
}
.preset-fn {
display: block;
margin-top: 3px;
color: #cbd5e1;
opacity: .7;
overflow-wrap: anywhere;
font-size: 11px;
}
@media (max-width: 800px) {
.app { padding: 16px; }
header { align-items: flex-start; flex-direction: column; }
.layout { grid-template-columns: 1fr; }
.preset-list { flex-direction: row; overflow-x: auto; max-height: none; }
.preset-btn { min-width: 160px; }
.controls { flex-direction: column; }
.primary, .secondary { width: 100%; }
}
</style>
</head>
<body>
<main class="app">
<header>
<div class="brand">
<div class="logo"></div>
<h1>Sonicos Grapher</h1>
</div>
<div class="badges">
<div id="liveBadge" class="badge live"><span class="pulse"></span> STREAMING LIVE</div>
<div class="badge">AUDIO: POLY.SINE</div>
<div class="badge">ONE FILE VERSION</div>
</div>
</header>
<section class="layout">
<div class="main">
<div class="card">
<div id="toolbar" class="toolbar"></div>
<div class="formula-wrap">
<textarea id="formulaInput" spellcheck="false">sin(x)</textarea>
</div>
<div class="hint">
Use formulas such as <strong>sin(x)</strong>, <strong>x^2</strong>, <strong>x^2+y^2=25</strong>, or <strong>(x^2+y^2-25)^3-25*x^2*y^3=0</strong>.
</div>
<div id="errorBox" class="error"></div>
</div>
<div class="card canvas-card">
<canvas id="graphCanvas" width="800" height="400"></canvas>
</div>
<div class="controls">
<button id="playBtn" class="primary">▶ Initialize Stream</button>
<button id="resetBtn" class="secondary">↻ Reset</button>
</div>
</div>
<aside class="side">
<h2 class="side-title">Pattern Presets</h2>
<div id="presetList" class="preset-list"></div>
</aside>
</section>
</main>
<script>
const PRESETS = [
{ name: 'Sine', func: 'sin(x)', min: -6.28, max: 6.28, ymin: -2, ymax: 2 },
{ name: 'Cosine', func: 'cos(x)', min: -6.28, max: 6.28, ymin: -2, ymax: 2 },
{ name: 'Parabola', func: 'x^2', min: -5, max: 5, ymin: -1, ymax: 25 },
{ name: 'Cubic', func: 'x^3', min: -5, max: 5, ymin: -125, ymax: 125 },
{ name: 'Circle', func: 'x^2+y^2=25', min: -10, max: 10, ymin: -10, ymax: 10 },
{ name: 'Heart', func: '(x^2+y^2-25)^3-25*x^2*y^3=0', min: -10, max: 10, ymin: -10, ymax: 10 },
{ name: 'Ripple', func: 'sin(3*x)+0.4*sin(9*x)', min: -6.28, max: 6.28, ymin: -2, ymax: 2 },
{ name: 'Beat', func: 'sin(5*x)*cos(0.7*x)', min: -10, max: 10, ymin: -2, ymax: 2 },
{ name: 'Hyperbola', func: 'x^2-y^2=10', min: -10, max: 10, ymin: -10, ymax: 10 },
{ name: 'Custom', func: 'y=sin(x)*x', min: -20, max: 20, ymin: -20, ymax: 20 }
];
const MATH_TOOLS = [
['+', '+'], ['−', '-'], ['×', '*'], ['÷', '/'], ['=', '='], ['>', '>'], ['<', '<'], ['≥', '>='], ['≤', '<='],
['(', '('], [')', ')'], ['xⁿ', '^'], ['√', 'sqrt()'], ['frac', '()/()'], ['π', 'pi'], ['e', 'e'],
['sin', 'sin()'], ['cos', 'cos()'], ['tan', 'tan()'], ['|x|', 'abs()'],
['7', '7'], ['8', '8'], ['9', '9'], ['4', '4'], ['5', '5'], ['6', '6'], ['1', '1'], ['2', '2'], ['3', '3'], ['0', '0'], ['.', '.'], ['x', 'x'], ['y', 'y']
];
const CW = 800;
const CH = 400;
const OSC_COUNT = 5;
const canvas = document.getElementById('graphCanvas');
const ctx = canvas.getContext('2d');
const formulaInput = document.getElementById('formulaInput');
const errorBox = document.getElementById('errorBox');
const playBtn = document.getElementById('playBtn');
const resetBtn = document.getElementById('resetBtn');
const toolbar = document.getElementById('toolbar');
const presetList = document.getElementById('presetList');
const liveBadge = document.getElementById('liveBadge');
let isPlaying = false;
let rafId = 0;
let lastTime = 0;
let audioContext = null;
let oscs = [];
let gains = [];
const state = {
currentPix: 0,
lines: [],
fnObj: null,
minY: -1,
maxY: 1,
xMin: -6.28,
xMax: 6.28
};
function insertAtCursor(textarea, text) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const current = textarea.value;
textarea.value = current.slice(0, start) + text + current.slice(end);
let cursor = start + text.length;
if (text.endsWith('()')) cursor -= 1;
if (text === '()/()') cursor = start + 1;
textarea.focus();
textarea.setSelectionRange(cursor, cursor);
updateFormula();
}
MATH_TOOLS.forEach(([label, insert]) => {
const btn = document.createElement('button');
btn.className = 'tool-btn';
btn.textContent = label;
btn.addEventListener('click', () => insertAtCursor(formulaInput, insert));
toolbar.appendChild(btn);
});
PRESETS.forEach((preset) => {
const btn = document.createElement('button');
btn.className = 'preset-btn';
btn.innerHTML = `<strong>${preset.name}</strong><span class="preset-fn">${escapeHtml(preset.func)}</span>`;
btn.addEventListener('click', () => {
formulaInput.value = preset.func;
updateFormula();
});
presetList.appendChild(btn);
});
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function normalizeMath(raw) {
let expr = String(raw || '').trim();
expr = expr.replace(/×/g, '*').replace(/÷/g, '/').replace(/−/g, '-').replace(/π/g, 'pi');
expr = expr.replace(/\s+/g, '');
expr = expr.replace(/\^/g, '**');
expr = expr.replace(/\bln\(/g, 'log(');
expr = expr.replace(/\|([^|]+)\|/g, 'abs($1)');
expr = expr.replace(/(\d|\)|x|y|pi|e)(?=(x|y|pi|e|\())/gi, '$1*');
expr = expr.replace(/(x|y|pi|e|\))(?=\d)/gi, '$1*');
expr = expr.replace(/(x|y)(?=(x|y))/gi, '$1*');
return expr;
}
function validateIdentifiers(expr) {
const allowed = new Set([
'x', 'y', 'pi', 'e', 'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'sqrt', 'abs', 'log', 'exp', 'pow',
'min', 'max', 'floor', 'ceil', 'round', 'sign'
]);
const tokens = expr.match(/[A-Za-z_]\w*/g) || [];
for (const token of tokens) {
if (!allowed.has(token)) throw new Error(`Unsupported token: ${token}`);
}
if (/[^0-9A-Za-z_+\-*/().,=<>&!\s]/.test(expr)) throw new Error('Unsupported character in formula');
}
function buildEvaluator(rawExpr) {
const js = normalizeMath(rawExpr);
validateIdentifiers(js);
const names = ['sin','cos','tan','asin','acos','atan','sqrt','abs','log','exp','pow','min','max','floor','ceil','round','sign','pi','e'];
const values = [Math.sin,Math.cos,Math.tan,Math.asin,Math.acos,Math.atan,Math.sqrt,Math.abs,Math.log,Math.exp,Math.pow,Math.min,Math.max,Math.floor,Math.ceil,Math.round,Math.sign,Math.PI,Math.E];
let fn;
try {
fn = new Function('x', 'y', ...names, `"use strict"; return (${js});`);
} catch (err) {
throw new Error('Invalid formula syntax');
}
return (x, y) => {
const value = fn(x, y, ...values);
if (typeof value === 'boolean') return value ? 1 : -1;
return Number(value);
};
}
function compileFunction(input) {
if (!input || !String(input).trim()) throw new Error('Empty expression');
const raw = String(input).trim();
let expr = raw;
let isEquation = false;
if (raw.includes('=')) {
const parts = raw.split('=');
if (parts.length !== 2) throw new Error('Multiple "=" signs are not supported');
expr = `(${parts[0]})-(${parts[1]})`;
isEquation = true;
}
const normalized = normalizeMath(expr);
const hasY = /(^|[^A-Za-z_])y([^A-Za-z_]|$)/.test(normalized);
const evaluator = buildEvaluator(expr);
const test = evaluator(1, 1);
if (!Number.isFinite(test) && Number.isNaN(test)) {
// Do not fail here because some valid formulas are undefined at x=1.
}
return { isImplicit: isEquation || hasY, eval: evaluator };
}
function setError(message) {
if (!message) {
errorBox.textContent = '';
errorBox.classList.remove('active');
playBtn.disabled = false;
} else {
errorBox.textContent = message;
errorBox.classList.add('active');
playBtn.disabled = true;
}
}
function getPreset(func) {
return PRESETS.find(p => p.func === func.trim());
}
function updateFormula() {
try {
const functionStr = formulaInput.value.trim();
if (!functionStr) {
state.fnObj = null;
state.lines = [];
setError(null);
if (isPlaying) stop();
renderFrame(performance.now());
return;
}
const fnObj = compileFunction(functionStr);
const preset = getPreset(functionStr);
let x0 = preset ? preset.min : -10;
let x1 = preset ? preset.max : 10;
let uY0 = preset && preset.ymin !== undefined ? preset.ymin : NaN;
let uY1 = preset && preset.ymax !== undefined ? preset.ymax : NaN;
if (Number.isNaN(uY0) || Number.isNaN(uY1)) {
if (fnObj.isImplicit) {
uY0 = -10;
uY1 = 10;
} else {
uY0 = Infinity;
uY1 = -Infinity;
for (let i = 0; i <= 500; i++) {
const x = x0 + (i / 500) * (x1 - x0);
let y = NaN;
try { y = fnObj.eval(x, 0); } catch (_) {}
if (Number.isFinite(y)) {
if (y < uY0) uY0 = y;
if (y > uY1) uY1 = y;
}
}
if (!Number.isFinite(uY0) || !Number.isFinite(uY1)) {
uY0 = -10;
uY1 = 10;
}
}
}
if (uY0 >= uY1) {
uY0 -= 1;
uY1 += 1;
}
const pad = (uY1 - uY0) * 0.1;
const rMinY = uY0 - pad;
const rMaxY = uY1 + pad;
const mapY = (y) => CH - CH * (y - rMinY) / (rMaxY - rMinY);
const computedLines = [];
if (fnObj.isImplicit) {
const cols = 220;
const rows = 120;
const grid = new Float32Array((cols + 1) * (rows + 1));
const dx = CW / cols;
const dy = CH / rows;
for (let j = 0; j <= rows; j++) {
const mathY = rMaxY - (j / rows) * (rMaxY - rMinY);
for (let i = 0; i <= cols; i++) {
const mathX = x0 + (i / cols) * (x1 - x0);
let val = NaN;
try { val = fnObj.eval(mathX, mathY); } catch (_) {}
grid[j * (cols + 1) + i] = Number.isFinite(val) ? val : NaN;
}
}
const safeDiv = (n, d) => d === 0 ? 0.5 : n / d;
const getPt = (i, j, edge) => {
const v0 = grid[j * (cols + 1) + i];
const v1 = grid[j * (cols + 1) + i + 1];
const v2 = grid[(j + 1) * (cols + 1) + i + 1];
const v3 = grid[(j + 1) * (cols + 1) + i];
let t = 0;
if (edge === 0) { t = safeDiv(v0, v0 - v1); return [(i + t) * dx, j * dy]; }
if (edge === 1) { t = safeDiv(v1, v1 - v2); return [(i + 1) * dx, (j + t) * dy]; }
if (edge === 2) { t = safeDiv(v3, v3 - v2); return [(i + t) * dx, (j + 1) * dy]; }
if (edge === 3) { t = safeDiv(v0, v0 - v3); return [i * dx, (j + t) * dy]; }
return [i * dx, j * dy];
};
const lookup = [[], [[3,2]], [[2,1]], [[3,1]], [[1,0]], [[1,0],[3,2]], [[2,0]], [[3,0]], [[0,3]], [[0,2]], [[0,1],[2,3]], [[0,1]], [[1,3]], [[1,2]], [[2,3]], []];
for (let j = 0; j < rows; j++) {
for (let i = 0; i < cols; i++) {
const v0 = grid[j * (cols + 1) + i];
const v1 = grid[j * (cols + 1) + i + 1];
const v2 = grid[(j + 1) * (cols + 1) + i + 1];
const v3 = grid[(j + 1) * (cols + 1) + i];
if (Number.isNaN(v0) || Number.isNaN(v1) || Number.isNaN(v2) || Number.isNaN(v3)) continue;
let c = 0;
if (v0 > 0) c |= 8;
if (v1 > 0) c |= 4;
if (v2 > 0) c |= 2;
if (v3 > 0) c |= 1;
if (c === 0 || c === 15) continue;
for (const edgePair of lookup[c]) {
const p1 = getPt(i, j, edgePair[0]);
const p2 = getPt(i, j, edgePair[1]);
computedLines.push([p1[0], p1[1], p2[0], p2[1]]);
}
}
}
} else {
let pxPrev = null;
let pyPrev = null;
for (let i = 0; i < CW; i++) {
const x = x0 + (i / CW) * (x1 - x0);
let y = NaN;
try { y = fnObj.eval(x, 0); } catch (_) {}
if (Number.isFinite(y)) {
const py = mapY(y);
if (pxPrev !== null && pyPrev !== null && Math.abs(py - pyPrev) < CH * 2) {
computedLines.push([pxPrev, pyPrev, i, py]);
}
pxPrev = i;
pyPrev = py;
} else {
pxPrev = null;
pyPrev = null;
}
}
}
state.fnObj = fnObj;
state.xMin = x0;
state.xMax = x1;
state.minY = uY0;
state.maxY = uY1;
state.lines = computedLines;
setError(null);
renderFrame(performance.now());
} catch (err) {
setError(err.message || 'Invalid formula');
if (isPlaying) stop();
state.fnObj = null;
renderFrame(performance.now());
}
}
function startAudio() {
if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)();
if (audioContext.state === 'suspended') audioContext.resume();
stopAudio();
for (let i = 0; i < OSC_COUNT; i++) {
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.type = 'sine';
gain.gain.value = 0;
osc.connect(gain);
gain.connect(audioContext.destination);
osc.start();
oscs.push(osc);
gains.push(gain);
}
}
function stopAudio() {
if (audioContext) {
const ctx = audioContext;
for (const gain of gains) {
try { gain.gain.setTargetAtTime(0, ctx.currentTime, 0.05); } catch (_) {}
}
const oldOscs = oscs.slice();
setTimeout(() => {
for (const osc of oldOscs) {
try { osc.stop(); osc.disconnect(); } catch (_) {}
}
}, 100);
}
oscs = [];
gains = [];
}
function updateAudioFreqs(freqs) {
if (!audioContext) return;
const activeCount = Math.min(freqs.length, OSC_COUNT);
const masterVol = 0.5 / (activeCount || 1);
for (let i = 0; i < OSC_COUNT; i++) {
const gain = gains[i];
const osc = oscs[i];
if (!gain || !osc) continue;
if (i < activeCount) {
osc.frequency.setTargetAtTime(freqs[i], audioContext.currentTime, 0.05);
gain.gain.setTargetAtTime(masterVol, audioContext.currentTime, 0.05);
} else {
gain.gain.setTargetAtTime(0, audioContext.currentTime, 0.05);
}
}
}
function play() {
if (!state.fnObj || errorBox.classList.contains('active')) return;
isPlaying = true;
state.currentPix = 0;
lastTime = performance.now();
startAudio();
playBtn.classList.add('stop');
playBtn.textContent = '■ Stop Signal';
liveBadge.classList.add('active');
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(renderFrame);
}
function stop() {
isPlaying = false;
stopAudio();
if (rafId) cancelAnimationFrame(rafId);
rafId = 0;
playBtn.classList.remove('stop');
playBtn.textContent = '▶ Initialize Stream';
liveBadge.classList.remove('active');
renderFrame(performance.now());
}
function reset() {
stop();
formulaInput.value = '';
state.currentPix = 0;
updateFormula();
}
function renderFrame(time) {
if (!ctx) return;
if (!lastTime) lastTime = time;
const dt = Math.min((time - lastTime) / 1000, 0.1);
lastTime = time;
const { fnObj, minY, maxY, lines } = state;
ctx.clearRect(0, 0, CW, CH);
const yRange = maxY === minY ? 1 : maxY - minY;
const pad = yRange * 0.1;
const rMinY = minY - pad;
const rMaxY = maxY + pad;
const mapY = (y) => CH - CH * (y - rMinY) / (rMaxY - rMinY);
ctx.strokeStyle = '#334155';
ctx.lineWidth = 1;
ctx.beginPath();
const zeroY = mapY(0);
ctx.moveTo(0, zeroY);
ctx.lineTo(CW, zeroY);
ctx.stroke();
if (fnObj) {
ctx.beginPath();
ctx.strokeStyle = '#64748b';
ctx.lineWidth = 2;
ctx.globalAlpha = 0.45;
for (const line of lines) {
ctx.moveTo(Math.floor(line[0]), Math.floor(line[1]));
ctx.lineTo(Math.floor(line[2]), Math.floor(line[3]));
}
ctx.stroke();
ctx.globalAlpha = 1;
if (isPlaying) {
const sweepDuration = 3.0;
const pxPerSec = CW / sweepDuration;
state.currentPix = (state.currentPix + pxPerSec * dt) % CW;
const sweepX = state.currentPix;
const targetPixels = [];
for (const line of lines) {
const lx0 = Math.min(line[0], line[2]);
const lx1 = Math.max(line[0], line[2]);
if (lx0 <= sweepX && lx1 >= sweepX) {
if (lx0 === lx1) {
targetPixels.push((line[1] + line[3]) / 2);
} else {
const t = (sweepX - line[0]) / (line[2] - line[0]);
if (t >= -0.01 && t <= 1.01) {
targetPixels.push(line[1] + t * (line[3] - line[1]));
}
}
}
}
ctx.beginPath();
ctx.moveTo(sweepX, 0);
ctx.lineTo(sweepX, CH);
ctx.strokeStyle = '#2dd4bf';
ctx.lineWidth = 2;
ctx.stroke();
targetPixels.sort((a, b) => b - a);
ctx.fillStyle = '#ffffff';
for (const yp of targetPixels.slice(0, OSC_COUNT)) {
ctx.beginPath();
ctx.arc(sweepX, yp, 5, 0, Math.PI * 2);
ctx.fill();
}
const minFreq = 160;
const maxFreq = 1200;
const mappedFreqs = targetPixels.slice(0, OSC_COUNT).map(yp => {
const normY = Math.max(0, Math.min(1, 1 - yp / CH));
return minFreq * Math.pow(maxFreq / minFreq, normY);
});
updateAudioFreqs(mappedFreqs);
}
}
if (isPlaying) rafId = requestAnimationFrame(renderFrame);
}
formulaInput.addEventListener('input', updateFormula);
playBtn.addEventListener('click', () => isPlaying ? stop() : play());
resetBtn.addEventListener('click', reset);
updateFormula();
</script>
</body>
</html>