Spaces:
Running
Running
| <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, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |
| 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> | |