feat: add invisible selection layer, audio streaming, and updated manifesto for pure performance mode

This commit is contained in:
YusufB5 2026-05-05 13:51:27 +03:00
parent e7002173c2
commit 2043a7bb37
6 changed files with 396 additions and 244 deletions

232
app.js
View file

@ -1,8 +1,9 @@
/**
* ASCILINE ENGINE - Core Logic
* ASCILINE ENGINE - Pure & Performant Logic
* =========================================
* Handles WebSocket communication, frame buffering,
* and dual-mode rendering (Canvas/DOM).
* No decorative animations. Pure WebSocket streaming
* and high-performance canvas rendering.
* Includes an "Invisible Selection Layer" for text selection.
*/
const player = document.getElementById('ascii-player');
@ -11,100 +12,122 @@ const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
const container = document.getElementById('player-container');
const overlay = document.getElementById('play-overlay');
const audioEl = document.getElementById('ascii-audio');
const volumeSlider = document.getElementById('volume-slider');
// ── STATE ──
let state = 'IDLE'; // IDLE | PLAYING | DISSOLVING
let state = 'IDLE'; // IDLE | PLAYING
let ws = null;
const frameBuffer = [];
const BUFFER_SIZE = 4;
let targetFps = 24;
let frameInterval = 1000 / targetFps;
let renderMode = 1;
let readyToRender = false;
// Grid & Dimensions
let gridCols = 0, gridRows = 0;
let charWidth = 0, charHeight = 0;
let xPos = null, yPos = null;
// Selection Layer optimization
const textDecoder = new TextDecoder();
let selectionBuffer = null;
// Timing & Metrics
let lastRenderTime = 0;
let frameCount = 0, currentFps = 0, lastFpsUpdate = 0;
let lastFrameView = null; // Stored for the ripple effect
// Character Lookup Table (optimization)
const CHAR_LUT = new Array(128);
for (let i = 0; i < 128; i++) CHAR_LUT[i] = String.fromCharCode(i);
/**
* Pre-calculates positions and scales canvas for high-performance rendering.
*/
// ═══════════════════════════════════════
// CANVAS SETUP
// ═══════════════════════════════════════
function buildCanvas(cols, rows) {
gridCols = cols;
gridRows = rows;
ctx.font = 'bold 8px Courier New';
charWidth = Math.ceil(ctx.measureText('M').width);
charHeight = 8;
canvas.width = cols * charWidth;
canvas.height = rows * charHeight;
canvas.style.display = 'block';
player.style.display = 'none';
container.style.minWidth = canvas.width + 'px';
container.style.minHeight = canvas.height + 'px';
// Selection Layer Buffer
selectionBuffer = new Uint8Array((cols + 1) * rows);
for (let r = 0; r < rows; r++) selectionBuffer[r * (cols + 1) + cols] = 10; // Newline (\n)
// Sizing and positioning for both layers
const syncSize = (el) => {
el.style.width = container.clientWidth + 'px';
el.style.height = container.clientHeight + 'px';
el.style.objectFit = 'contain';
el.style.position = 'absolute';
el.style.top = '0';
el.style.left = '0';
};
syncSize(canvas);
syncSize(player);
ctx.font = 'bold 8px Courier New';
ctx.textBaseline = 'top';
xPos = new Float32Array(cols);
yPos = new Float32Array(rows);
for (let c = 0; c < cols; c++) xPos[c] = c * charWidth;
for (let r = 0; r < rows; r++) yPos[r] = r * charHeight;
}
/**
* Initiates WebSocket connection and stream handling.
*/
// ═══════════════════════════════════════
// STREAM CONTROL
// ═══════════════════════════════════════
function startStream() {
if (state !== 'IDLE') return;
state = 'PLAYING';
overlay.classList.add('hidden');
statusEl.textContent = 'Connecting...';
statusEl.style.color = 'var(--accent-color)';
connectWebSocket();
}
function connectWebSocket() {
frameBuffer.length = 0;
lastFrameView = null;
frameCount = 0;
currentFps = 0;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
if (audioEl) {
audioEl.src = '/audio?' + Date.now();
audioEl.volume = volumeSlider ? volumeSlider.value : 0.8;
}
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${location.host}/ws`);
ws.binaryType = 'arraybuffer';
ws.onmessage = (event) => {
if (state !== 'PLAYING') return;
if (typeof event.data === 'string') {
if (event.data.startsWith('Error:')) {
statusEl.textContent = event.data;
statusEl.style.color = '#ff0000';
state = 'IDLE';
if (ws) ws.close();
setTimeout(() => resetToIdle(), 3000);
setTimeout(() => finishStream(), 3000);
return;
}
if (event.data.startsWith('INIT:')) {
const p = event.data.split(':');
targetFps = parseFloat(p[1]);
frameInterval = 1000 / targetFps;
renderMode = parseInt(p[2]);
if (renderMode > 1) {
buildCanvas(parseInt(p[3]), parseInt(p[4]));
} else {
player.style.display = 'block';
canvas.style.display = 'none';
}
buildCanvas(parseInt(p[3]), parseInt(p[4]));
if (audioEl) audioEl.play().catch(() => {});
readyToRender = true;
state = 'PLAYING';
lastRenderTime = performance.now();
lastFpsUpdate = lastRenderTime;
requestAnimationFrame(renderFrame);
return;
}
frameBuffer.push(event.data);
@ -112,39 +135,31 @@ function startStream() {
frameBuffer.push(event.data);
}
// Buffer Overflow Protection
while (frameBuffer.length > BUFFER_SIZE * 3) frameBuffer.shift();
// Start render loop once buffered
if (frameBuffer.length >= BUFFER_SIZE && lastRenderTime === 0) {
lastRenderTime = performance.now();
lastFpsUpdate = lastRenderTime;
requestAnimationFrame(renderFrame);
}
};
ws.onopen = () => {
statusEl.textContent = 'Buffering...';
};
ws.onopen = () => { statusEl.textContent = 'Buffering...'; };
ws.onclose = () => {
if (state === 'PLAYING') {
statusEl.textContent = 'Stream Ended.';
statusEl.style.color = '#888';
setTimeout(() => resetToIdle(), 1500);
if (audioEl) audioEl.pause();
setTimeout(() => finishStream(), 800);
}
};
ws.onerror = () => {
statusEl.textContent = 'Connection Error!';
statusEl.style.color = '#ff0000';
setTimeout(() => resetToIdle(), 2000);
setTimeout(() => finishStream(), 2000);
};
}
/**
* Main render loop using requestAnimationFrame.
*/
// ═══════════════════════════════════════
// RENDER LOOP
// ═══════════════════════════════════════
function renderFrame(now) {
if (state !== 'PLAYING') return;
requestAnimationFrame(renderFrame);
@ -152,29 +167,27 @@ function renderFrame(now) {
const elapsed = now - lastRenderTime;
if (elapsed < frameInterval) return;
// FPS Counter
frameCount++;
if (now - lastFpsUpdate >= 1000) {
currentFps = frameCount;
frameCount = 0;
lastFpsUpdate = now;
let modeText = 'B&W';
const modes = { 2: '512 Color', 3: '32K Color', 4: '262K Color', 5: '16M Ultra' };
modeText = modes[renderMode] || 'B&W';
statusEl.textContent = `FPS: ${currentFps}/${Math.round(targetFps)} | Buf: ${frameBuffer.length} | ${modeText}`;
statusEl.textContent = `FPS: ${currentFps}/${Math.round(targetFps)} | Buf: ${frameBuffer.length} | ${modes[renderMode] || 'B&W'}`;
}
if (frameBuffer.length === 0) return;
lastRenderTime = now;
const frame = frameBuffer.shift();
if (renderMode === 1) {
player.style.display = 'block';
player.style.color = '#fff';
player.textContent = frame;
} else {
const view = new Uint8Array(frame);
lastFrameView = view;
// 1. Draw Canvas (Background)
ctx.fillStyle = '#050505';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = 'bold 8px Courier New';
@ -188,94 +201,37 @@ function renderFrame(now) {
prevPacked = packed;
}
ctx.fillText(CHAR_LUT[view[idx]], xPos[col], yPos[row]);
col++;
if (col >= gridCols) { col = 0; row++; }
}
}
}
/**
* Visual Effect: Ripple Dissolve
* Triggered on click during playback.
*/
function triggerRipple(clickX, clickY) {
if (!lastFrameView || renderMode === 1) {
resetToIdle();
return;
}
state = 'DISSOLVING';
if (ws) { ws.onclose = null; ws.close(); ws = null; }
const fv = lastFrameView;
const rippleSpeed = 400;
const waveFront = 70;
const maxShake = 6;
const maxDist = Math.sqrt(canvas.width * canvas.width + canvas.height * canvas.height);
const startTime = performance.now();
statusEl.textContent = '';
function animateRipple(now) {
const elapsed = (now - startTime) / 1000;
const radius = elapsed * rippleSpeed;
if (radius > maxDist + waveFront + 20) {
resetToIdle();
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = 'bold 8px Courier New';
ctx.textBaseline = 'top';
let col = 0, row = 0;
for (let idx = 0; idx < fv.length; idx += 4) {
const px = xPos[col];
const py = yPos[row];
const dx = px - clickX;
const dy = py - clickY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > radius) {
ctx.globalAlpha = 1;
ctx.fillStyle = `rgb(${fv[idx+1]},${fv[idx+2]},${fv[idx+3]})`;
ctx.fillText(CHAR_LUT[fv[idx]], px, py);
} else if (dist > radius - waveFront) {
const progress = (radius - dist) / waveFront;
const shake = Math.sin(progress * Math.PI) * maxShake;
const ox = (Math.random() - 0.5) * shake * 2;
const oy = (Math.random() - 0.5) * shake * 2;
ctx.globalAlpha = 1 - progress;
ctx.fillStyle = `rgb(${fv[idx+1]},${fv[idx+2]},${fv[idx+3]})`;
ctx.fillText(CHAR_LUT[fv[idx]], px + ox, py + oy);
}
// Fill Selection Buffer (char code is at view[idx])
selectionBuffer[row * (gridCols + 1) + col] = view[idx];
col++;
if (col >= gridCols) { col = 0; row++; }
}
ctx.globalAlpha = 1;
requestAnimationFrame(animateRipple);
// 2. Update Selection Layer (Foreground)
player.style.display = 'block';
player.style.color = 'transparent';
player.textContent = textDecoder.decode(selectionBuffer);
}
requestAnimationFrame(animateRipple);
}
function resetToIdle() {
// ═══════════════════════════════════════
// CLEANUP
// ═══════════════════════════════════════
function finishStream() {
state = 'IDLE';
if (ws) { ws.onclose = null; ws.close(); ws = null; }
frameBuffer.length = 0;
lastRenderTime = 0;
lastFrameView = null;
if (audioEl) { audioEl.pause(); audioEl.src = ''; }
ctx.clearRect(0, 0, canvas.width, canvas.height);
player.textContent = '';
player.style.display = 'none';
overlay.classList.remove('hidden');
statusEl.textContent = 'Ready';
statusEl.style.color = 'rgba(255,255,255,0.6)';
readyToRender = false;
frameBuffer.length = 0;
}
// ── EVENT LISTENERS ──
@ -284,14 +240,18 @@ overlay.addEventListener('click', (e) => {
startStream();
});
container.addEventListener('click', (e) => {
if (state !== 'PLAYING') return;
if (volumeSlider) {
volumeSlider.addEventListener('input', () => {
if (audioEl) audioEl.volume = volumeSlider.value;
});
}
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const clickX = (e.clientX - rect.left) * scaleX;
const clickY = (e.clientY - rect.top) * scaleY;
triggerRipple(clickX, clickY);
window.addEventListener('resize', () => {
const syncSize = (el) => {
if (!el) return;
el.style.width = container.clientWidth + 'px';
el.style.height = container.clientHeight + 'px';
};
syncSize(canvas);
syncSize(player);
});