Files

1083 lines
38 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Virtual Screen & Speakers - MCP Companion</title>
<!-- Modern Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;600&family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet">
<style>
:root {
--bg-color: #0f172a;
--card-bg: rgba(30, 41, 59, 0.7);
--border-color: rgba(255, 255, 255, 0.08);
--primary-glow: rgba(99, 102, 241, 0.15);
--secondary-glow: rgba(236, 72, 153, 0.15);
--accent-violet: #6366f1;
--accent-pink: #ec4899;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--font-outfit: 'Outfit', sans-serif;
--font-mono: 'Fira Code', monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-outfit);
background-color: var(--bg-color);
background-image:
radial-gradient(circle at 10% 20%, var(--primary-glow) 0%, transparent 40%),
radial-gradient(circle at 90% 80%, var(--secondary-glow) 0%, transparent 40%);
background-attachment: fixed;
color: var(--text-main);
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
/* Top navigation header */
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border-color);
z-index: 100;
}
.logo {
font-weight: 800;
font-size: 1.5rem;
letter-spacing: -0.025em;
background: linear-gradient(135deg, var(--accent-violet) 0%, var(--accent-pink) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
display: flex;
align-items: center;
gap: 0.5rem;
}
.logo-dot {
width: 8px;
height: 8px;
background-color: var(--accent-pink);
border-radius: 50%;
display: inline-block;
box-shadow: 0 0 10px var(--accent-pink);
}
.status-badge {
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border-color);
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
}
.status-dot {
width: 8px;
height: 8px;
background-color: #ef4444; /* Default disconnected red */
border-radius: 50%;
box-shadow: 0 0 8px #ef4444;
transition: all 0.3s ease;
}
.status-dot.connected {
background-color: #10b981; /* Connected green */
box-shadow: 0 0 8px #10b981;
}
/* Dashboard Grid Layout */
.dashboard-container {
display: grid;
grid-template-columns: 320px 1fr 340px;
gap: 2rem;
padding: 2rem;
flex-grow: 1;
max-width: 1600px;
margin: 0 auto;
width: 100%;
}
@media (max-width: 1200px) {
.dashboard-container {
grid-template-columns: 1fr;
}
}
/* Card styles */
.glass-card {
background: var(--card-bg);
backdrop-filter: blur(16px);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 1.5rem;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.3);
transition: border-color 0.3s ease;
position: relative;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.glass-card:hover {
border-color: rgba(255, 255, 255, 0.15);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text-main);
}
.card-subtitle {
font-size: 0.875rem;
color: var(--text-muted);
margin-top: -0.5rem;
}
/* Virtual Screen Bezel and Canvas */
.screen-column {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.monitor-bezel {
background: #1e293b;
border-radius: 24px;
padding: 1rem;
border: 3px solid #334155;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.5),
inset 0 0 20px rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
position: relative;
overflow: hidden;
}
.monitor-bezel::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: linear-gradient(135deg, rgba(255,255,255,0.03) 0%, transparent 60%);
pointer-events: none;
}
.virtual-canvas {
background-color: #0f172a;
border-radius: 12px;
width: 100%;
aspect-ratio: 4 / 3;
max-width: 800px;
display: block;
box-shadow: inset 0 0 15px rgba(0,0,0,0.9);
image-rendering: pixelated;
}
/* Screen Reflection and CRT effects */
.crt-scanlines {
position: absolute;
top: 1rem;
left: 1rem;
right: 1rem;
bottom: 1rem;
border-radius: 12px;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%);
background-size: 100% 4px;
pointer-events: none;
z-index: 10;
}
/* Custom UI sliders and dials */
.sensor-control {
display: flex;
flex-direction: column;
gap: 1rem;
}
.slider-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.slider-header {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
font-weight: 600;
}
.slider-name {
color: var(--text-muted);
}
.slider-value {
font-family: var(--font-mono);
color: var(--accent-violet);
}
input[type="range"] {
-webkit-appearance: none;
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 9999px;
outline: none;
transition: background 0.3s;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-violet), var(--accent-pink));
cursor: pointer;
box-shadow: 0 0 10px rgba(99, 102, 241, 0.5);
transition: transform 0.1s ease;
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
/* LED Ring Display */
.led-panel {
align-items: center;
}
.led-ring-container {
position: relative;
width: 200px;
height: 200px;
margin: 1.5rem auto;
border-radius: 50%;
background: rgba(0,0,0,0.2);
border: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
}
.led-pixel {
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: #334155;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.6);
transition: all 0.3s ease;
}
/* Audio Visualization */
.audio-wave-container {
width: 100%;
height: 60px;
background: rgba(0, 0, 0, 0.2);
border-radius: 12px;
border: 1px solid var(--border-color);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
padding: 0 10px;
}
.audio-bar {
width: 4px;
height: 10%;
background: linear-gradient(to top, var(--accent-violet), var(--accent-pink));
border-radius: 2px;
transition: height 0.1s ease;
}
/* Record Panel & Controls */
.mic-btn-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-top: 1rem;
}
.mic-circle {
width: 72px;
height: 72px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
border: 2px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.mic-circle svg {
width: 32px;
height: 32px;
fill: var(--text-muted);
transition: all 0.3s ease;
}
.mic-circle.recording {
border-color: #ef4444;
background: rgba(239, 68, 68, 0.15);
box-shadow: 0 0 25px rgba(239, 68, 68, 0.5);
animation: pulse-red 1.5s infinite;
}
.mic-circle.recording svg {
fill: #ef4444;
transform: scale(1.1);
}
.recording-status-text {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
}
.recording-status-text.active {
color: #ef4444;
}
@keyframes pulse-red {
0% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
}
70% {
box-shadow: 0 0 0 15px rgba(239, 68, 68, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
}
}
/* Details list */
.info-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem 1.25rem;
font-size: 0.875rem;
}
.info-label {
color: var(--text-muted);
font-weight: 500;
}
.info-value {
text-align: right;
font-family: var(--font-mono);
font-weight: 600;
}
/* Fullscreen Overlay for disconnecting */
.overlay-reconnect {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(15, 23, 42, 0.85);
backdrop-filter: blur(12px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 200;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
gap: 1.5rem;
}
.overlay-reconnect.visible {
opacity: 1;
pointer-events: auto;
}
.spinner {
width: 50px;
height: 50px;
border: 3px solid rgba(255, 255, 255, 0.05);
border-top: 3px solid var(--accent-violet);
border-radius: 50%;
animation: spin 1s linear infinite;
box-shadow: 0 0 15px rgba(99, 102, 241, 0.3);
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.overlay-text {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.01rem;
}
/* LED Animation keyframes */
@keyframes led-breath {
0%, 100% { opacity: 0.3; filter: drop-shadow(0 0 2px var(--led-color)); }
50% { opacity: 1.0; filter: drop-shadow(0 0 12px var(--led-color)); }
}
</style>
</head>
<body>
<header>
<div class="logo">
<span class="logo-dot"></span>
Virtual Screen & Speakers
</div>
<div class="status-badge">
<span id="statusDot" class="status-dot"></span>
<span id="statusText">Disconnected</span>
</div>
</header>
<div class="dashboard-container">
<!-- LEFT COLUMN: Sensors & System Info -->
<div class="glass-card">
<div class="card-header">
<h2 class="card-title">Virtual Sensors</h2>
</div>
<div class="card-subtitle">Adjust sliders to simulate local device telemetry</div>
<div class="sensor-control">
<div class="slider-group">
<div class="slider-header">
<span class="slider-name">Temperature</span>
<span id="tempVal" class="slider-value">22.5 °C</span>
</div>
<input type="range" id="tempSlider" min="0" max="50" step="0.1" value="22.5">
</div>
<div class="slider-group">
<div class="slider-header">
<span class="slider-name">Humidity</span>
<span id="humVal" class="slider-value">45 %</span>
</div>
<input type="range" id="humSlider" min="0" max="100" step="1" value="45">
</div>
<div class="slider-group">
<div class="slider-header">
<span class="slider-name">Ambient Light</span>
<span id="lightVal" class="slider-value">350 lx</span>
</div>
<input type="range" id="lightSlider" min="0" max="2000" step="10" value="350">
</div>
</div>
<hr style="border: 0; border-top: 1px solid var(--border-color); margin: 0.5rem 0;">
<div class="card-header">
<h2 class="card-title">System telemetry</h2>
</div>
<div class="info-grid">
<div class="info-label">Virtual Client</div>
<div id="infoClient" class="info-value">Browser Tab</div>
<div class="info-label">WebSocket URL</div>
<div id="infoWsUrl" class="info-value">ws://...</div>
<div class="info-label">Latency</div>
<div id="infoLatency" class="info-value">0 ms</div>
</div>
</div>
<!-- CENTER COLUMN: Screen -->
<div class="screen-column">
<div class="glass-card" style="flex-grow: 1; justify-content: center;">
<div class="card-header">
<div>
<h2 class="card-title">Device Display</h2>
<div class="card-subtitle">800x600 High-Res Virtual Frame Buffer</div>
</div>
</div>
<div class="monitor-bezel">
<canvas id="virtualCanvas" class="virtual-canvas" width="800" height="600"></canvas>
<div class="crt-scanlines"></div>
</div>
</div>
</div>
<!-- RIGHT COLUMN: LEDs, Audio, & Mic -->
<div class="glass-card">
<div class="card-header">
<h2 class="card-title">NeoPixel LED Ring</h2>
</div>
<div class="card-subtitle" id="ledModeText">Mode: Off</div>
<div class="led-ring-container" id="ledRing">
<!-- 12 pixels inserted by JS -->
</div>
<hr style="border: 0; border-top: 1px solid var(--border-color); margin: 0.5rem 0;">
<div class="card-header">
<h2 class="card-title">Audio & Microphone</h2>
</div>
<div class="audio-wave-container" id="audioVisualizer">
<!-- 15 bars for visualization -->
<div class="audio-bar"></div>
<div class="audio-bar"></div>
<div class="audio-bar"></div>
<div class="audio-bar"></div>
<div class="audio-bar"></div>
<div class="audio-bar"></div>
<div class="audio-bar"></div>
<div class="audio-bar"></div>
<div class="audio-bar"></div>
<div class="audio-bar"></div>
<div class="audio-bar"></div>
<div class="audio-bar"></div>
<div class="audio-bar"></div>
<div class="audio-bar"></div>
<div class="audio-bar"></div>
</div>
<div class="mic-btn-container">
<div class="mic-circle" id="micButton">
<svg viewBox="0 0 24 24">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/>
</svg>
</div>
<span id="recordingStatus" class="recording-status-text">Microphone Idle</span>
</div>
</div>
</div>
<!-- Reconnection Overlay -->
<div class="overlay-reconnect" id="reconnectOverlay">
<div class="spinner"></div>
<div class="overlay-text">Establishing WebSocket Connection...</div>
</div>
<script>
// Setup Canvas
const canvas = document.getElementById('virtualCanvas');
const ctx = canvas.getContext('2d');
// Initialize Canvas with default deep background
function clearCanvasLocal(colorVal) {
ctx.fillStyle = parseColorLocal(colorVal);
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
function parseColorLocal(colorVal) {
if (colorVal === 0 || colorVal === '0') return '#ffffff';
if (colorVal === 1 || colorVal === '1') return '#000000';
if (!colorVal) return '#0f172a';
return colorVal;
}
clearCanvasLocal();
// Setup 12-Pixel LED Ring
const ledRing = document.getElementById('ledRing');
const ledCount = 12;
const ledPixels = [];
for (let i = 0; i < ledCount; i++) {
const pixel = document.createElement('div');
pixel.className = 'led-pixel';
const angle = (i * 360 / ledCount) * (Math.PI / 180);
const radius = 70; // px
const x = Math.round(92 + radius * Math.cos(angle));
const y = Math.round(92 + radius * Math.sin(angle));
pixel.style.left = `${x}px`;
pixel.style.top = `${y}px`;
ledRing.appendChild(pixel);
ledPixels.push(pixel);
}
// Web Audio Context
let audioCtx = null;
function getAudioContext() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
return audioCtx;
}
// Audio Visualization logic
const audioVisualizer = document.getElementById('audioVisualizer');
const visualizerBars = Array.from(audioVisualizer.querySelectorAll('.audio-bar'));
let visualizationInterval = null;
function triggerVisualizer(durationMs) {
clearInterval(visualizationInterval);
let elapsed = 0;
visualizationInterval = setInterval(() => {
visualizerBars.forEach(bar => {
const height = Math.random() * 85 + 10;
bar.style.height = `${height}%`;
});
elapsed += 100;
if (elapsed >= durationMs) {
clearInterval(visualizationInterval);
resetVisualizer();
}
}, 100);
}
function resetVisualizer() {
visualizerBars.forEach(bar => {
bar.style.height = '10%';
});
}
// WebSocket Management
let socket = null;
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const infoWsUrl = document.getElementById('infoWsUrl');
const infoLatency = document.getElementById('infoLatency');
const reconnectOverlay = document.getElementById('reconnectOverlay');
// Populate system details
const infoClient = document.getElementById('infoClient');
infoClient.textContent = `${navigator.platform} / ${navigator.appName}`;
function connectWebSocket() {
const loc = window.location;
let wsUri = loc.protocol === "https:" ? "wss:" : "ws:";
wsUri += "//" + loc.host + "/ws";
infoWsUrl.textContent = wsUri;
statusText.textContent = "Connecting...";
reconnectOverlay.classList.add('visible');
socket = new WebSocket(wsUri);
socket.onopen = function() {
console.log("WebSocket connected.");
statusDot.classList.add('connected');
statusText.textContent = "Connected";
reconnectOverlay.classList.remove('visible');
// Send initial telemetry values to server
sendSensorUpdate();
};
socket.onclose = function() {
console.log("WebSocket disconnected. Retrying...");
statusDot.classList.remove('connected');
statusText.textContent = "Disconnected";
reconnectOverlay.classList.add('visible');
setTimeout(connectWebSocket, 2000);
};
socket.onerror = function(err) {
console.error("WebSocket error:", err);
};
socket.onmessage = function(event) {
const data = JSON.parse(event.data);
const action = data.action;
console.log("Received message:", data);
if (data.event === "pong") {
const rtt = Date.now() - data.t;
infoLatency.textContent = rtt + " ms";
return;
}
if (action === 'clear') {
clearCanvasLocal(data.color);
}
else if (action === 'draw_text') {
ctx.font = `${data.size || 16}px var(--font-outfit)`;
ctx.fillStyle = parseColorLocal(data.color || 'white');
ctx.fillText(data.text, data.x, data.y);
}
else if (action === 'draw_shape') {
ctx.fillStyle = parseColorLocal(data.color || 'white');
ctx.strokeStyle = parseColorLocal(data.color || 'white');
ctx.lineWidth = 2;
ctx.beginPath();
if (data.shape === 'rect') {
if (data.fill) {
ctx.fillRect(data.x, data.y, data.w, data.h);
} else {
ctx.strokeRect(data.x, data.y, data.w, data.h);
}
} else if (data.shape === 'circle') {
const radius = Math.min(data.w, data.h) / 2;
ctx.arc(data.x + radius, data.y + radius, radius, 0, 2 * Math.PI);
if (data.fill) {
ctx.fill();
} else {
ctx.stroke();
}
} else if (data.shape === 'line') {
ctx.moveTo(data.x, data.y);
ctx.lineTo(data.x + data.w, data.y + data.h);
ctx.stroke();
}
}
else if (action === 'draw_image') {
const img = new Image();
img.onload = function() {
const w = data.w || img.width;
const h = data.h || img.height;
ctx.drawImage(img, data.x || 0, data.y || 0, w, h);
};
img.src = data.image_base64.startsWith('data:') ? data.image_base64 : 'data:image/png;base64,' + data.image_base64;
}
else if (action === 'set_led') {
updateLedRing(data.r, data.g, data.b, data.mode);
}
else if (action === 'play_tone') {
playToneLocal(data.frequency, data.duration, data.volume);
}
else if (action === 'play_audio') {
playAudioLocal(data.audio, data.volume);
}
else if (action === 'record_voice') {
startVoiceRecording(data.duration_sec, data.recording_id);
}
else if (action === 'ping') {
socket.send(JSON.stringify({ event: "pong", id: data.id }));
}
};
}
// Start Connection
connectWebSocket();
// Virtual Sensor Sliders
const tempSlider = document.getElementById('tempSlider');
const humSlider = document.getElementById('humSlider');
const lightSlider = document.getElementById('lightSlider');
const tempVal = document.getElementById('tempVal');
const humVal = document.getElementById('humVal');
const lightVal = document.getElementById('lightVal');
function sendSensorUpdate() {
if (socket && socket.readyState === WebSocket.OPEN) {
const temp = parseFloat(tempSlider.value);
const hum = parseInt(humSlider.value);
const light = parseInt(lightSlider.value);
tempVal.textContent = temp.toFixed(1) + " °C";
humVal.textContent = hum + " %";
lightVal.textContent = light + " lx";
socket.send(JSON.stringify({
event: "sensor_update",
temperature: temp,
humidity: hum,
light: light
}));
}
}
tempSlider.oninput = sendSensorUpdate;
humSlider.oninput = sendSensorUpdate;
lightSlider.oninput = sendSensorUpdate;
// LED Animations
let ledInterval = null;
function updateLedRing(r, g, b, mode) {
clearInterval(ledInterval);
document.getElementById('ledModeText').textContent = `Mode: ${mode.toUpperCase()} (${r}, ${g}, ${b})`;
const colorStr = `rgb(${r}, ${g}, ${b})`;
ledPixels.forEach(p => {
p.style.animation = 'none';
p.style.backgroundColor = 'rgba(51, 65, 85, 0.4)';
p.style.boxShadow = 'inset 0 2px 4px rgba(0,0,0,0.6)';
});
if (mode === 'static') {
ledPixels.forEach(p => {
p.style.backgroundColor = colorStr;
p.style.boxShadow = `0 0 15px rgba(${r}, ${g}, ${b}, 0.8), inset 0 1px 2px rgba(255,255,255,0.4)`;
});
}
else if (mode === 'breath') {
ledPixels.forEach(p => {
p.style.setProperty('--led-color', colorStr);
p.style.backgroundColor = colorStr;
p.style.boxShadow = `0 0 15px rgba(${r}, ${g}, ${b}, 0.8)`;
p.style.animation = 'led-breath 2s infinite ease-in-out';
});
}
else if (mode === 'rainbow') {
let hue = 0;
ledInterval = setInterval(() => {
ledPixels.forEach((p, idx) => {
const localHue = (hue + (idx * 360 / ledCount)) % 360;
const c = `hsl(${localHue}, 100%, 50%)`;
p.style.backgroundColor = c;
p.style.boxShadow = `0 0 12px ${c}`;
});
hue = (hue + 5) % 360;
}, 50);
}
else if (mode === 'off') {
// Done above
}
}
// Web Audio Synthesizer
function playToneLocal(frequency, durationMs, volume) {
try {
const actx = getAudioContext();
const osc = actx.createOscillator();
const gain = actx.createGain();
osc.connect(gain);
gain.connect(actx.destination);
osc.type = 'sine';
osc.frequency.setValueAtTime(frequency, actx.currentTime);
// Volume ramp
gain.gain.setValueAtTime(volume, actx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.0001, actx.currentTime + (durationMs / 1000.0));
osc.start();
osc.stop(actx.currentTime + (durationMs / 1000.0));
triggerVisualizer(durationMs);
} catch (err) {
console.error("Failed to play synthesis tone:", err);
}
}
// Play WAV/MP3 Audio file from base64
function playAudioLocal(base64Data, volume) {
try {
const actx = getAudioContext();
// Decode base64
const binaryString = atob(base64Data.startsWith('data:') ? base64Data.split(',')[1] : base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
actx.decodeAudioData(bytes.buffer, function(buffer) {
const source = actx.createBufferSource();
const gain = actx.createGain();
source.buffer = buffer;
gain.gain.value = volume;
source.connect(gain);
gain.connect(actx.destination);
source.start(0);
triggerVisualizer(buffer.duration * 1000);
}, function(err) {
console.error("decodeAudioData error:", err);
});
} catch (err) {
console.error("Audio playback failed:", err);
}
}
// Audio Voice Recording Handler
let recordingTimer = null;
const micButton = document.getElementById('micButton');
const recordingStatus = document.getElementById('recordingStatus');
async function startVoiceRecording(durationSec, recordingId) {
const actx = getAudioContext();
recordingStatus.textContent = `Recording... (${durationSec}s)`;
recordingStatus.classList.add('active');
micButton.classList.add('recording');
// Trigger visualizer while recording
triggerVisualizer(durationSec * 1000);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const source = actx.createMediaStreamSource(stream);
const processor = actx.createScriptProcessor(4096, 1, 1);
const leftChannel = [];
let isRecording = true;
processor.onaudioprocess = function(e) {
if (!isRecording) return;
const samples = e.inputBuffer.getChannelData(0);
leftChannel.push(new Float32Array(samples));
};
source.connect(processor);
processor.connect(actx.destination);
// Stop recording after duration
setTimeout(() => {
isRecording = false;
// Stop tracks
stream.getTracks().forEach(track => track.stop());
processor.disconnect();
source.disconnect();
// Process and compile WAV file
const completedBuffer = mergeBuffers(leftChannel);
const wavBlob = encodeWavMono(completedBuffer, actx.sampleRate);
// Convert to base64
const reader = new FileReader();
reader.onloadend = function() {
const b64Wav = reader.result.split(',')[1];
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
event: "voice_recording",
recording_id: recordingId,
audio_base64: b64Wav
}));
}
// Done status
recordingStatus.textContent = "Voice Sent successfully!";
recordingStatus.classList.remove('active');
micButton.classList.remove('recording');
setTimeout(() => {
recordingStatus.textContent = "Microphone Idle";
}, 2000);
};
reader.readAsDataURL(wavBlob);
}, durationSec * 1000);
} catch (err) {
console.error("Recording initialization failed:", err);
recordingStatus.textContent = "Mic Permission Denied";
recordingStatus.classList.remove('active');
micButton.classList.remove('recording');
// Return error response back to server so it doesn't hang
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
event: "voice_recording",
recording_id: recordingId,
error: "Permission denied or failed to access mic: " + err.message
}));
}
}
}
// Helper buffer merger for recording
function mergeBuffers(channelBuffer) {
let resultLength = 0;
for (let i = 0; i < channelBuffer.length; i++) {
resultLength += channelBuffer[i].length;
}
const result = new Float32Array(resultLength);
let offset = 0;
for (let i = 0; i < channelBuffer.length; i++) {
result.set(channelBuffer[i], offset);
offset += channelBuffer[i].length;
}
return result;
}
// WAV mono Encoder Helper
function encodeWavMono(samples, sampleRate) {
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
/* RIFF identifier */
writeString(view, 0, 'RIFF');
/* file length */
view.setUint32(4, 36 + samples.length * 2, true);
/* RIFF type */
writeString(view, 8, 'WAVE');
/* format chunk identifier */
writeString(view, 12, 'fmt ');
/* format chunk length */
view.setUint32(16, 16, true);
/* sample format (raw PCM) */
view.setUint16(20, 1, true);
/* channel count */
view.setUint16(22, 1, true);
/* sample rate */
view.setUint32(24, sampleRate, true);
/* byte rate (sample rate * block align) */
view.setUint32(28, sampleRate * 2, true);
/* block align (channel count * bytes per sample) */
view.setUint16(32, 2, true);
/* bits per sample */
view.setUint16(34, 16, true);
/* data chunk identifier */
writeString(view, 36, 'data');
/* data chunk length */
view.setUint32(40, samples.length * 2, true);
// Write sample volume values
floatTo16BitPCM(view, 44, samples);
return new Blob([view], { type: 'audio/wav' });
}
function floatTo16BitPCM(output, offset, input) {
for (let i = 0; i < input.length; i++, offset += 2) {
let s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
}
function writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
// Latency Measurement Ping-Pong
setInterval(() => {
if (socket && socket.readyState === WebSocket.OPEN) {
const startTime = Date.now();
socket.send(JSON.stringify({ event: "ping", t: startTime }));
}
}, 5000);
</script>
</body>
</html>