initial commit (clean, no models)

This commit is contained in:
akukanara
2026-05-30 21:30:49 +07:00
Unverified
commit e90b5c971c
22 changed files with 7872 additions and 0 deletions
+744
View File
@@ -0,0 +1,744 @@
/**
* Omni Real-Time Voice Changer - Client App
* High-performance browser-based mic streaming and RVC playback.
*/
// UI Elements
const wsUrlInput = document.getElementById('ws_url');
const connectionStatus = document.getElementById('connection_status');
const connectBtn = document.getElementById('connect_btn');
const streamBtn = document.getElementById('stream_btn');
const playToggleBtn = document.getElementById('play_toggle_btn');
const modelSelect = document.getElementById('model_select');
const deviceSelect = document.getElementById('device_select');
const transposeSlider = document.getElementById('transpose_slider');
const transposeVal = document.getElementById('transpose_val');
const gateSlider = document.getElementById('gate_slider');
const gateVal = document.getElementById('gate_val');
const inputGainSlider = document.getElementById('input_gain_slider');
const inputGainVal = document.getElementById('input_gain_val');
const outputGainSlider = document.getElementById('output_gain_slider');
const outputGainVal = document.getElementById('output_gain_val');
const chunkSelect = document.getElementById('chunk_select');
const noiseCancelCheckbox = document.getElementById('noise_cancel_checkbox');
const routingModeSelect = document.getElementById('routing_mode_select');
const hardwareDevicesPanel = document.getElementById('hardware_devices_panel');
const serverInputSelect = document.getElementById('server_input_select');
const serverOutputSelect = document.getElementById('server_output_select');
const browserNoiseCancelGroup = document.getElementById('browser_noise_cancel_group');
const presetLatencyBtn = document.getElementById('preset_latency_btn');
const presetQualityBtn = document.getElementById('preset_quality_btn');
const inputCanvas = document.getElementById('input_canvas');
const outputCanvas = document.getElementById('output_canvas');
const hudLatency = document.getElementById('hud_latency');
const hudTime = document.getElementById('hud_time');
const hudGateStatus = document.getElementById('hud_gate_status');
const hudSr = document.getElementById('hud_sr');
// Audio Visualizer Contexts
const inputCtx = inputCanvas.getContext('2d');
const outputCtx = outputCanvas.getContext('2d');
// Web Audio State
let audioContext = null;
let micStream = null;
let micSourceNode = null;
let scriptProcessorNode = null;
let micAccumulator = new Float32Array(0); // Accumulates audio for large/custom chunk sizes
// WebSocket State
let socket = null;
let isStreaming = false;
let playOutput = true;
let targetSampleRate = 40000; // RVC Model default, updated dynamically
// Playback Sync State
let nextPlaybackTime = 0;
const safetyDelay = 0.10; // 100ms buffer to absorb network/websocket jitter (increased for perfect smoothness!)
// Latency Tracking Queues
let sentTimestamps = [];
const maxSentLogs = 50;
// --- SMOOTH VISUALIZER (Rolling Display Buffers + RAF loop) ---
// Fixed display buffer size: ~85ms window looks great at all chunk sizes.
const VIS_DISPLAY_SIZE = 4096;
let inputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE); // rolling input (updated ~85ms)
let outputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE); // fallback for hardware mode
let rafHandle = null;
// Time-synced output queue: each entry = { data: Float32Array, startTime: number (audioCtx seconds) }
let outputChunkQueue = [];
function pushToDisplayBuf(displayBuf, newSamples) {
if (newSamples.length >= VIS_DISPLAY_SIZE) {
displayBuf.set(newSamples.slice(newSamples.length - VIS_DISPLAY_SIZE));
} else {
displayBuf.copyWithin(0, newSamples.length);
displayBuf.set(newSamples, VIS_DISPLAY_SIZE - newSamples.length);
}
}
// Build a VIS_DISPLAY_SIZE window of output samples ending at audioContext.currentTime
function buildTimeSyncedOutputBuf() {
if (!audioContext || outputChunkQueue.length === 0) return outputDisplayBuf;
const now = audioContext.currentTime;
const windowDuration = VIS_DISPLAY_SIZE / targetSampleRate;
const windowStart = now - windowDuration;
// Drop chunks that ended before our window start
while (outputChunkQueue.length > 0) {
const c = outputChunkQueue[0];
if (c.startTime + c.data.length / targetSampleRate < windowStart) {
outputChunkQueue.shift();
} else break;
}
const out = new Float32Array(VIS_DISPLAY_SIZE);
for (const chunk of outputChunkQueue) {
const chunkEnd = chunk.startTime + chunk.data.length / targetSampleRate;
// Overlap between [windowStart, now] and [chunk.startTime, chunkEnd]
const overlapStart = Math.max(windowStart, chunk.startTime);
const overlapEnd = Math.min(now, chunkEnd);
if (overlapStart >= overlapEnd) continue;
const srcOffset = Math.floor((overlapStart - chunk.startTime) * targetSampleRate);
const destOffset = Math.floor((overlapStart - windowStart) * targetSampleRate);
const count = Math.floor((overlapEnd - overlapStart) * targetSampleRate);
const safeCount = Math.min(count,
chunk.data.length - srcOffset,
VIS_DISPLAY_SIZE - destOffset);
if (safeCount > 0) out.set(chunk.data.subarray(srcOffset, srcOffset + safeCount), destOffset);
}
return out;
}
function startVisualizerLoop() {
if (rafHandle) return;
function frame() {
drawWaveform(inputDisplayBuf, inputCanvas, '#6366f1');
// Time-synced output: scrub through queued chunks using audioContext clock
drawWaveform(buildTimeSyncedOutputBuf(), outputCanvas, '#a855f7');
rafHandle = requestAnimationFrame(frame);
}
rafHandle = requestAnimationFrame(frame);
}
function stopVisualizerLoop() {
if (rafHandle) {
cancelAnimationFrame(rafHandle);
rafHandle = null;
}
outputChunkQueue = [];
}
// Setup Canvas Sizes dynamically
function resizeCanvases() {
inputCanvas.width = inputCanvas.clientWidth * window.devicePixelRatio;
inputCanvas.height = inputCanvas.clientHeight * window.devicePixelRatio;
outputCanvas.width = outputCanvas.clientWidth * window.devicePixelRatio;
outputCanvas.height = outputCanvas.clientHeight * window.devicePixelRatio;
}
resizeCanvases();
window.addEventListener('resize', resizeCanvases);
// Connect / Disconnect WebSocket
connectBtn.addEventListener('click', () => {
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
disconnectServer();
} else {
connectServer();
}
});
function connectServer() {
const url = wsUrlInput.value.trim();
updateConnectionStatus('connecting');
try {
socket = new WebSocket(url);
socket.binaryType = 'arraybuffer';
socket.onopen = () => {
console.log('Connected to RVC Server');
updateConnectionStatus('connected');
sendConfigToServer(); // Send initial configurations
streamBtn.disabled = false;
playToggleBtn.disabled = false;
};
socket.onclose = () => {
console.log('WebSocket Connection Closed');
disconnectServer();
};
socket.onerror = (err) => {
console.error('WebSocket Error:', err);
disconnectServer();
};
socket.onmessage = (event) => {
if (typeof event.data === 'string') {
// Config or control response
try {
const response = JSON.parse(event.data);
if (response.type === 'config_success') {
targetSampleRate = response.target_sr;
console.log('Server configuration synced successfully:', response);
} else if (response.type === 'init_devices') {
populateServerDevices(response.devices, response.default_input, response.default_output);
} else if (response.type === 'visualizer') {
// Feed rolling display buffers — RAF loop handles drawing at 60fps
pushToDisplayBuf(inputDisplayBuf, new Float32Array(response.input));
pushToDisplayBuf(outputDisplayBuf, new Float32Array(response.output));
if (!rafHandle) startVisualizerLoop();
} else if (response.type === 'error') {
alert('Server Error: ' + response.message);
}
} catch (e) {
console.error('Error parsing text message:', e);
}
} else if (event.data instanceof ArrayBuffer) {
// Binary processed PCM audio chunk returned from server (Browser Mode only)
handleServerAudioChunk(event.data);
}
};
} catch (e) {
console.error('Connection failed:', e);
disconnectServer();
}
}
function disconnectServer() {
if (isStreaming) {
stopStreaming();
}
if (socket) {
try {
socket.close();
} catch (e) {}
socket = null;
}
updateConnectionStatus('disconnected');
streamBtn.disabled = true;
playToggleBtn.disabled = true;
}
function updateConnectionStatus(status) {
connectionStatus.className = 'status-badge ' + status;
if (status === 'connected') {
connectionStatus.textContent = 'Terhubung';
connectBtn.textContent = 'Putuskan Server';
connectBtn.className = 'btn btn-primary';
} else if (status === 'connecting') {
connectionStatus.textContent = 'Menghubungkan';
connectBtn.textContent = 'Batal';
} else {
connectionStatus.textContent = 'Terputus';
connectBtn.textContent = 'Hubungkan Server';
connectBtn.className = 'btn btn-primary';
}
}
// Config synchronization
function sendConfigToServer() {
if (!socket || socket.readyState !== WebSocket.OPEN) return;
const activeF0 = document.querySelector('input[name="f0_method"]:checked').value;
const config = {
type: 'config',
model_name: modelSelect.value,
device: deviceSelect.value,
f0_method: activeF0,
f0_up_key: parseInt(transposeSlider.value),
noise_gate: parseFloat(gateSlider.value),
input_gain: parseFloat(inputGainSlider.value),
output_gain: parseFloat(outputGainSlider.value),
input_sr: audioContext ? audioContext.sampleRate : 44100,
routing_mode: routingModeSelect.value,
input_device: serverInputSelect.value ? parseInt(serverInputSelect.value) : null,
output_device: serverOutputSelect.value ? parseInt(serverOutputSelect.value) : null,
chunk_size: parseInt(chunkSelect.value)
};
socket.send(jsonEncode(config));
console.log('Sent configuration change:', config);
}
// Populate Server Audio Devices dropdowns
function populateServerDevices(devices, defaultInput, defaultOutput) {
serverInputSelect.innerHTML = '';
serverOutputSelect.innerHTML = '';
if (devices.length === 0) {
const optIn = document.createElement('option');
optIn.textContent = 'Tidak ada mic terdeteksi di server';
serverInputSelect.appendChild(optIn);
const optOut = document.createElement('option');
optOut.textContent = 'Tidak ada output terdeteksi di server';
serverOutputSelect.appendChild(optOut);
return;
}
devices.forEach(device => {
if (device.max_input_channels > 0) {
const opt = document.createElement('option');
opt.value = device.id;
opt.textContent = `[ID ${device.id}] ${device.name}`;
if (device.id === defaultInput) opt.selected = true;
serverInputSelect.appendChild(opt);
}
if (device.max_output_channels > 0) {
const opt = document.createElement('option');
opt.value = device.id;
opt.textContent = `[ID ${device.id}] ${device.name}`;
if (device.id === defaultOutput) opt.selected = true;
serverOutputSelect.appendChild(opt);
}
});
console.log('Successfully populated server hardware devices in UI.');
}
// UI Event Listeners to trigger instant sync
modelSelect.addEventListener('change', sendConfigToServer);
deviceSelect.addEventListener('change', sendConfigToServer);
document.querySelectorAll('input[name="f0_method"]').forEach(radio => {
radio.addEventListener('change', sendConfigToServer);
});
transposeSlider.addEventListener('input', () => {
transposeVal.textContent = (transposeSlider.value >= 0 ? '+' : '') + transposeSlider.value + ' semitone';
});
transposeSlider.addEventListener('change', sendConfigToServer);
gateSlider.addEventListener('input', () => {
gateVal.textContent = gateSlider.value + ' dB';
});
gateSlider.addEventListener('change', sendConfigToServer);
inputGainSlider.addEventListener('input', () => {
inputGainVal.textContent = parseFloat(inputGainSlider.value).toFixed(1) + 'x';
});
inputGainSlider.addEventListener('change', sendConfigToServer);
outputGainSlider.addEventListener('input', () => {
outputGainVal.textContent = parseFloat(outputGainSlider.value).toFixed(1) + 'x';
});
outputGainSlider.addEventListener('change', sendConfigToServer);
chunkSelect.addEventListener('change', () => {
// Reinitialize stream if buffer size is changed during active streaming
if (isStreaming) {
stopStreaming();
startStreaming();
}
});
noiseCancelCheckbox.addEventListener('change', () => {
// Reinitialize microphone with new noise cancellation constraints if streaming
if (isStreaming) {
stopStreaming();
startStreaming();
}
});
// Helper to dynamically adjust UI layout based on Routing Mode
function applyAudioRoutingUI() {
if (routingModeSelect.value === 'hardware') {
hardwareDevicesPanel.style.display = 'block';
playToggleBtn.style.display = 'none'; // Hide browser-only "Mendengarkan" button
browserNoiseCancelGroup.style.display = 'none'; // Hide browser-only Noise Cancel checkbox
} else {
hardwareDevicesPanel.style.display = 'none';
playToggleBtn.style.display = 'inline-block'; // Show browser-only "Mendengarkan" button
browserNoiseCancelGroup.style.display = 'block'; // Show browser-only Noise Cancel checkbox
}
}
// Routing Mode Event Listeners
routingModeSelect.addEventListener('change', () => {
applyAudioRoutingUI();
sendConfigToServer();
if (isStreaming) {
stopStreaming();
startStreaming();
}
});
serverInputSelect.addEventListener('change', sendConfigToServer);
serverOutputSelect.addEventListener('change', sendConfigToServer);
// Quick Presets Event Listeners
presetLatencyBtn.addEventListener('click', () => {
const radioPM = document.querySelector('input[name="f0_method"][value="pm"]');
if (radioPM) radioPM.checked = true;
chunkSelect.value = "8192";
console.log("Preset loaded: Latency (PM + 8192)");
sendConfigToServer();
if (isStreaming) {
stopStreaming();
startStreaming();
}
});
presetQualityBtn.addEventListener('click', () => {
const radioRMVPE = document.querySelector('input[name="f0_method"][value="rmvpe"]');
if (radioRMVPE) radioRMVPE.checked = true;
chunkSelect.value = "16384";
console.log("Preset loaded: Quality (RMVPE + 16384)");
sendConfigToServer();
if (isStreaming) {
stopStreaming();
startStreaming();
}
});
// Helper functions for UI JSON safely
function jsonEncode(obj) {
return JSON.stringify(obj);
}
playToggleBtn.addEventListener('click', () => {
playOutput = !playOutput;
if (playOutput) {
playToggleBtn.textContent = '🔊 Mendengarkan: AKTIF';
playToggleBtn.className = 'btn btn-primary';
} else {
playToggleBtn.textContent = '🔇 Mendengarkan: SENYAP';
playToggleBtn.className = 'btn btn-accent';
}
});
// Stream Toggle
streamBtn.addEventListener('click', () => {
if (isStreaming) {
stopStreaming();
} else {
startStreaming();
}
});
async function startStreaming() {
isStreaming = true;
streamBtn.textContent = 'Hentikan Pengubah Suara';
streamBtn.className = 'btn btn-primary';
const isHardwareMode = (routingModeSelect.value === 'hardware');
if (isHardwareMode) {
// --- SERVER HARDWARE ROUTING MODE ---
inputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE);
outputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE);
startVisualizerLoop();
sendConfigToServer(); // Sends config with routing_mode: 'hardware' which triggers stream start on server
console.log('Server Hardware Mode initialized.');
return;
}
// --- CLIENT BROWSER MODE ---
// 1. Create AudioContext if not active
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)({
latencyHint: 'interactive'
});
}
if (audioContext.state === 'suspended') {
await audioContext.resume();
}
hudSr.textContent = audioContext.sampleRate + ' Hz';
sendConfigToServer(); // sync actual input sample rate
// 2. Request user microphone with high-fidelity, lowest possible latency constraints
try {
const useNoiseCancel = noiseCancelCheckbox.checked;
micStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: useNoiseCancel,
noiseSuppression: useNoiseCancel,
autoGainControl: useNoiseCancel
}
});
micSourceNode = audioContext.createMediaStreamSource(micStream);
// 3. Create Audio Processing Loop Node (ScriptProcessorNode)
// BaseAudioContext's createScriptProcessor buffer size MUST be a power of two between 256 and 16384.
// We use a fixed, highly supported buffer size of 4096 for recording, and accumulate samples in-memory
// to support ANY arbitrary or extremely large chunk size (like 12288, 24576, 32768) selected by the user!
const recordBufferSize = 4096;
scriptProcessorNode = audioContext.createScriptProcessor(recordBufferSize, 1, 1);
scriptProcessorNode.onaudioprocess = (event) => {
if (!isStreaming) return;
const inputBuffer = event.inputBuffer;
const inputData = inputBuffer.getChannelData(0); // 4096 samples
// Push latest mic samples into the rolling display buffer every callback (~85ms)
pushToDisplayBuf(inputDisplayBuf, inputData);
// Append incoming recorded samples to our accumulator
const temp = new Float32Array(micAccumulator.length + inputData.length);
temp.set(micAccumulator);
temp.set(inputData, micAccumulator.length);
micAccumulator = temp;
const targetChunkSize = parseInt(chunkSelect.value);
// Process and send chunks of the user's selected target size
while (micAccumulator.length >= targetChunkSize) {
const chunkToSend = micAccumulator.slice(0, targetChunkSize);
micAccumulator = micAccumulator.slice(targetChunkSize); // Keep remainder
// Voice Activity Detection for gate status badge
let maxVal = 0;
for (let i = 0; i < chunkToSend.length; i++) maxVal = Math.max(maxVal, Math.abs(chunkToSend[i]));
if (maxVal > 0.005) {
hudGateStatus.textContent = 'Bicara';
hudGateStatus.className = 'hud-value active-badge';
} else {
hudGateStatus.textContent = 'Berdiam';
hudGateStatus.className = 'hud-value text-muted';
}
// Send binary PCM Float32 audio chunk of target size to Python Server
if (socket && socket.readyState === WebSocket.OPEN) {
const packetTime = performance.now();
sentTimestamps.push({ id: packetTime, sent: packetTime });
if (sentTimestamps.length > maxSentLogs) {
sentTimestamps.shift();
}
socket.send(chunkToSend.buffer); // Send direct array buffer
}
}
};
micSourceNode.connect(scriptProcessorNode);
scriptProcessorNode.connect(audioContext.destination); // Required to trigger onaudioprocess
// Reset playback sync clock
nextPlaybackTime = 0;
micAccumulator = new Float32Array(0); // Reset accumulator
inputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE);
outputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE);
startVisualizerLoop();
console.log('Browser Streaming active. Recording buffer size: 4096 | Target chunk size:', chunkSelect.value);
} catch (e) {
console.error('Failed to access microphone:', e);
alert('Gagal mengakses mikrofon Anda: ' + e.message);
stopStreaming();
}
}
function stopStreaming() {
isStreaming = false;
streamBtn.textContent = 'Mulai Mengubah Suara';
streamBtn.className = 'btn btn-accent';
playOutput = true;
playToggleBtn.textContent = '🔊 Mendengarkan: AKTIF';
playToggleBtn.className = 'btn btn-primary';
const isHardwareMode = (routingModeSelect.value === 'hardware');
if (isHardwareMode) {
// --- SERVER HARDWARE ROUTING MODE ---
if (socket && socket.readyState === WebSocket.OPEN) {
const config = {
type: 'config',
routing_mode: 'browser' // Tells server to stop local hardware stream
};
socket.send(jsonEncode(config));
}
console.log('Server Hardware Mode stopped.');
hudGateStatus.textContent = 'Berdiam';
hudGateStatus.className = 'hud-value text-muted';
hudLatency.textContent = '-- ms';
hudTime.textContent = '-- ms';
stopVisualizerLoop();
inputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE);
outputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE);
clearCanvas(inputCanvas);
clearCanvas(outputCanvas);
return;
}
// --- CLIENT BROWSER MODE ---
// Stop microphone stream tracks
if (micStream) {
micStream.getTracks().forEach(track => track.stop());
micStream = null;
}
// Disconnect Web Audio nodes
if (micSourceNode) {
micSourceNode.disconnect();
micSourceNode = null;
}
if (scriptProcessorNode) {
scriptProcessorNode.disconnect();
scriptProcessorNode = null;
}
micAccumulator = new Float32Array(0); // Reset accumulator
stopVisualizerLoop();
inputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE);
outputDisplayBuf = new Float32Array(VIS_DISPLAY_SIZE);
hudGateStatus.textContent = 'Berdiam';
hudGateStatus.className = 'hud-value text-muted';
hudLatency.textContent = '-- ms';
hudTime.textContent = '-- ms';
clearCanvas(inputCanvas);
clearCanvas(outputCanvas);
}
// Seamless Audio Playback Scheduler (Absorbs WebSocket & processing jitter)
function handleServerAudioChunk(arrayBuffer) {
if (!isStreaming) return;
// 1. Measure Round-Trip Time Latency (RTT)
const now = performance.now();
let rtt = 0;
if (sentTimestamps.length > 0) {
const oldestSent = sentTimestamps.shift();
rtt = now - oldestSent.sent;
hudLatency.textContent = Math.round(rtt) + ' ms';
}
// Convert arrayBuffer to Float32 samples
const payload = new Float32Array(arrayBuffer);
const processingTime = payload[0]; // first float32 is the server processing time in ms
const pcmData = payload.subarray(1); // the rest is the audio
// 2. Schedule chunk smoothly inside the AudioContext timeline
const audioBuf = audioContext.createBuffer(1, pcmData.length, targetSampleRate);
audioBuf.getChannelData(0).set(pcmData);
const source = audioContext.createBufferSource();
source.buffer = audioBuf;
if (playOutput) {
source.connect(audioContext.destination);
}
// Calculate precise playback clock scheduling
const currentTime = audioContext.currentTime;
const chunkDuration = audioBuf.duration; // actual chunk duration in seconds
// Adaptive buffer: enough headroom so next chunk always arrives before this one ends.
// 2.5× chunk or 500ms cap — absorbs even 300ms+ processing spikes.
const adaptiveBuf = Math.min(chunkDuration * 2.5, 0.50);
if (nextPlaybackTime < currentTime) {
// Clock behind — first chunk or dropout recovery.
// Use full adaptiveBuf on BOTH cases so recovery fully rebuilds headroom.
// (0.5× recovery was causing cascading dropouts: one late chunk → the next also late)
nextPlaybackTime = currentTime + adaptiveBuf;
} else if (nextPlaybackTime > currentTime + chunkDuration * 5.0) {
// --- ADAPTIVE LATENCY BUSTER ---
// Only snap when queue is >5 chunk-durations ahead (genuine backlog, not normal look-ahead).
// At 8192 (170ms): threshold = 850ms
// At 65536 (1.6s): threshold = 8s
const snapTarget = currentTime + adaptiveBuf;
console.log(`Latency Buster: ${Math.round((nextPlaybackTime-currentTime)*1000)}ms → ${Math.round(adaptiveBuf*1000)}ms`);
nextPlaybackTime = snapTarget;
}
// Record schedule start time BEFORE advancing the clock (for time-synced visualizer)
const scheduleStartTime = nextPlaybackTime;
// Schedule play
source.start(nextPlaybackTime);
hudTime.textContent = Math.max(0, Math.round(processingTime)) + ' ms';
// Advance playback sync clock
nextPlaybackTime += audioBuf.duration;
// Push to time-synced output queue for visualizer (keyed by when audio actually plays)
outputChunkQueue.push({ data: pcmData, startTime: scheduleStartTime });
// Keep queue bounded to ~10 seconds of audio max
while (outputChunkQueue.length > 0) {
const c = outputChunkQueue[0];
if (c.startTime + c.data.length / targetSampleRate < audioContext.currentTime - 2.0) {
outputChunkQueue.shift();
} else break;
}
}
// --- VISUALIZATION / DRAWING ROUTINES ---
function drawWaveform(dataArray, canvas, strokeColor) {
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
// Dark transparent redraw for trace/motion-blur effect
ctx.fillStyle = 'rgba(11, 12, 19, 0.4)';
ctx.fillRect(0, 0, width, height);
ctx.lineWidth = 2 * window.devicePixelRatio;
ctx.strokeStyle = strokeColor;
ctx.beginPath();
const sliceWidth = width / dataArray.length;
let x = 0;
for (let i = 0; i < dataArray.length; i++) {
// Center the wave around half-height and scale scale amplitude
const v = dataArray[i] * 1.5;
const y = (v * (height / 2)) + (height / 2);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
x += sliceWidth;
}
ctx.lineTo(width, height / 2);
ctx.stroke();
// Draw a subtle baseline center glowing path
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, height / 2);
ctx.lineTo(width, height / 2);
ctx.stroke();
}
function clearCanvas(canvas) {
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#0b0c13';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// Apply initial UI layout on startup
applyAudioRoutingUI();
+243
View File
@@ -0,0 +1,243 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Omni Real-time Voice Changer - Pengubah suara real-time berbasis AI berlatensi sangat rendah dengan ONNX Runtime.">
<title>🎙️ Omni Real-Time Voice Changer - High-Performance AI Audio</title>
<!-- Modern Typography: Inter & Outfit from 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=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;600;800&display=swap" rel="stylesheet">
<!-- Link to premium Vanilla CSS -->
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="glow-backdrop"></div>
<div class="dashboard-container">
<!-- HEADER -->
<header class="app-header">
<div class="logo-area">
<span class="pulse-indicator active"></span>
<h1>🎙️ OMNI VOICE CHANGER</h1>
</div>
<p class="tagline">Pengubah Suara Real-Time AI Berlatensi Ultra Rendah menggunakan Akselerasi ONNX Runtime</p>
</header>
<!-- CONNECTION BAR -->
<div class="connection-bar card glassmorphism">
<div class="form-row">
<div class="input-group">
<label for="ws_url">URL Server WebSocket</label>
<input type="text" id="ws_url" value="ws://127.0.0.1:8765" placeholder="ws://localhost:8765">
</div>
<div class="connection-status-container">
<span id="connection_status" class="status-badge disconnected">Terputus</span>
</div>
<div class="btn-group-row">
<button id="connect_btn" class="btn btn-primary">Hubungkan Server</button>
<button id="stream_btn" class="btn btn-accent" disabled>Mulai Mengubah Suara</button>
<button id="play_toggle_btn" class="btn btn-primary" disabled>🔊 Mendengarkan: AKTIF</button>
</div>
</div>
</div>
<!-- MAIN DASHBOARD CONTENT -->
<main class="dashboard-grid">
<!-- MODEL CONFIGURATION -->
<section class="card glassmorphism col-span-1" aria-labelledby="model-config-title">
<h2 id="model-config-title" class="card-title">⚙️ Konfigurasi Model &amp; Perangkat</h2>
<!-- QUICK PRESETS PANEL -->
<div class="control-group">
<label>⚡ Quick Presets (Profil Performa)</label>
<div class="btn-group-row" style="width: 100%; display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; height: auto; margin-bottom: 0.75rem;">
<button id="preset_latency_btn" class="btn btn-primary" style="font-size: 0.8rem; padding: 0.65rem 0.5rem;">⚡ Respon Kilat (PM)</button>
<button id="preset_quality_btn" class="btn btn-accent" style="font-size: 0.8rem; padding: 0.65rem 0.5rem;">🎙️ Kualitas Tinggi (RMVPE)</button>
</div>
</div>
<div class="control-group">
<label for="model_select">Pilih Model Suara (RVC ONNX)</label>
<select id="model_select" class="custom-select">
<option value="HuTao">HuTao (Genshin Impact)</option>
<option value="HuoHuo">HuoHuo (Honkai Star Rail)</option>
</select>
</div>
<div class="control-group">
<label for="device_select">Execution Provider (Akselerasi GPU)</label>
<select id="device_select" class="custom-select">
<option value="cpu">CPU (Sangat Stabil)</option>
<option value="cuda" selected>CUDA (NVIDIA GPU - Super Cepat)</option>
<option value="dml">DirectML (AMD/Intel GPU Windows)</option>
</select>
</div>
<!-- DUAL AUDIO ROUTING MODE (SERVER VS CLIENT) -->
<div class="control-group" style="border-top: 1px solid rgba(255, 255, 255, 0.05); padding-top: 0.75rem; margin-top: 0.75rem;">
<label for="routing_mode_select">Mode Audio (Routing Mode)</label>
<select id="routing_mode_select" class="custom-select">
<option value="browser" selected>Client Mode (Browser Streaming - Portabel)</option>
<option value="hardware">Server Mode (Hardware Direct - Latensi Nol)</option>
</select>
</div>
<div id="hardware_devices_panel" class="control-group" style="display: none; border: 1px solid rgba(99, 102, 241, 0.2); padding: 0.75rem; border-radius: 8px; background: rgba(11, 12, 19, 0.5); box-shadow: 0 0 10px rgba(99, 102, 241, 0.05);">
<div style="margin-bottom: 0.75rem;">
<label for="server_input_select" style="font-size: 0.75rem; margin-bottom: 0.25rem; color: var(--primary); text-transform: uppercase; font-weight: 600;">🎙️ Input Mikrofon Server</label>
<select id="server_input_select" class="custom-select" style="font-size: 0.8rem; padding: 0.4rem;"></select>
</div>
<div>
<label for="server_output_select" style="font-size: 0.75rem; margin-bottom: 0.25rem; color: var(--accent); text-transform: uppercase; font-weight: 600;">🔊 Output Speaker/Kabel Server</label>
<select id="server_output_select" class="custom-select" style="font-size: 0.8rem; padding: 0.4rem;"></select>
</div>
</div>
<div class="control-group">
<label>Metode Deteksi Nada (Pitch Extraction)</label>
<div class="radio-group-modern">
<label class="radio-tile">
<input type="radio" name="f0_method" value="pm" checked>
<span class="tile-label">PM (Tercepat)</span>
</label>
<label class="radio-tile">
<input type="radio" name="f0_method" value="dio">
<span class="tile-label">DIO (Ringan)</span>
</label>
<label class="radio-tile">
<input type="radio" name="f0_method" value="harvest">
<span class="tile-label">Harvest (Stabil)</span>
</label>
<label class="radio-tile">
<input type="radio" name="f0_method" value="rmvpe">
<span class="tile-label">RMVPE (Fidelitas Tinggi)</span>
</label>
</div>
</div>
<div class="control-group">
<div class="slider-header">
<label for="transpose_slider">Transpose (Pengubah Nada)</label>
<span id="transpose_val" class="slider-value">0 semitone</span>
</div>
<input type="range" id="transpose_slider" min="-24" max="24" value="0" step="1" class="custom-slider">
<div class="slider-ticks">
<span>-24 (Pria Berat)</span>
<span>0 (Asli)</span>
<span>+24 (Wanita/Anime)</span>
</div>
</div>
</section>
<!-- AUDIO DSP & PROCESSING -->
<section class="card glassmorphism col-span-1" aria-labelledby="dsp-title">
<h2 id="dsp-title" class="card-title">🎛️ Pemrosesan Audio (DSP)</h2>
<div class="control-group">
<div class="slider-header">
<label for="gate_slider">Noise Gate (Threshold)</label>
<span id="gate_val" class="slider-value">-40 dB</span>
</div>
<input type="range" id="gate_slider" min="-60" max="-10" value="-40" step="1" class="custom-slider">
<div class="slider-ticks">
<span>-60 dB (Sensitif)</span>
<span>-40 dB (Default)</span>
<span>-10 dB (Ketat)</span>
</div>
</div>
<div class="control-group">
<div class="slider-header">
<label for="input_gain_slider">Input Gain (Penguat Mic)</label>
<span id="input_gain_val" class="slider-value">1.0x</span>
</div>
<input type="range" id="input_gain_slider" min="0" max="3" value="1" step="0.1" class="custom-slider">
</div>
<div class="control-group">
<div class="slider-header">
<label for="output_gain_slider">Output Gain (Volume Suara)</label>
<span id="output_gain_val" class="slider-value">1.0x</span>
</div>
<input type="range" id="output_gain_slider" min="0" max="3" value="1" step="0.1" class="custom-slider">
</div>
<div id="browser_noise_cancel_group" class="control-group">
<label class="checkbox-container" style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer; user-select: none;">
<input type="checkbox" id="noise_cancel_checkbox" checked style="width: 18px; height: 18px; cursor: pointer; accent-color: var(--primary);">
<span class="checkbox-label" style="font-size: 0.85rem; font-weight: 500; color: var(--text-muted); text-transform: uppercase;">🚫 Peredam Bising (Noise Cancel)</span>
</label>
</div>
<div class="control-group">
<label for="chunk_select">Ukuran Buffer (Chunk Size - Latensi vs Stabilitas)</label>
<select id="chunk_select" class="custom-select">
<option value="8192" selected>8192 sampel (~170ms - Rekomendasi Minim Distorsi)</option>
<option value="12288">12288 sampel (~250ms - Sangat Halus &amp; Merdu)</option>
<option value="16384">16384 sampel (~340ms - Kualitas Studio Sangat Stabil)</option>
<option value="24576">24576 sampel (~510ms - Super Halus &amp; Kokoh)</option>
<option value="32768">32768 sampel (~680ms - Fidelitas Maksimal)</option>
<option value="49152">49152 sampel (~1.0 detik - Ultra Smooth Cinema)</option>
<option value="65536">65536 sampel (~1.3 detik - Kestabilan Maksimal)</option>
<option value="98304">98304 sampel (~2.0 detik - Mode Penyiaran/Broadcasting)</option>
</select>
</div>
</section>
<!-- OSCILLOSCOPES / WAVEFORM VISUALIZERS -->
<section class="card glassmorphism col-span-2" aria-labelledby="visualizer-title">
<h2 id="visualizer-title" class="card-title">📊 Live Audio Waveform &amp; Visualizer</h2>
<div class="visualizer-row">
<div class="visualizer-container">
<div class="vis-label">
<span class="dot input-dot"></span>
<span>Sinyal Mikrofon (Input)</span>
</div>
<canvas id="input_canvas" class="waveform-canvas"></canvas>
</div>
<div class="visualizer-container">
<div class="vis-label">
<span class="dot output-dot"></span>
<span>Hasil AI Voice (Output)</span>
</div>
<canvas id="output_canvas" class="waveform-canvas"></canvas>
</div>
</div>
</section>
</main>
<!-- PERFORMANCE HUD FOOTER -->
<footer class="performance-hud card glassmorphism">
<div class="hud-item">
<span class="hud-label">Latensi Bulat (RTT)</span>
<span id="hud_latency" class="hud-value italic">-- ms</span>
</div>
<div class="hud-separator"></div>
<div class="hud-item">
<span class="hud-label">Rasio Pemrosesan</span>
<span id="hud_time" class="hud-value text-accent">-- ms</span>
</div>
<div class="hud-separator"></div>
<div class="hud-item">
<span class="hud-label">Sinyal Suara</span>
<span id="hud_gate_status" class="hud-value active-badge">Berdiam</span>
</div>
<div class="hud-separator"></div>
<div class="hud-item">
<span class="hud-label">Frekuensi Audio</span>
<span id="hud_sr" class="hud-value">44100 Hz</span>
</div>
</footer>
</div>
<!-- Link to premium Javascript logic -->
<script src="app.js"></script>
</body>
</html>
+595
View File
@@ -0,0 +1,595 @@
/* ==========================================================================
CSS GLOBAL TOKENS & RESET
========================================================================== */
:root {
--bg-dark: #07080e;
--bg-card: rgba(13, 17, 30, 0.7);
--border-color: rgba(99, 102, 241, 0.18);
--primary: #6366f1;
--primary-glow: rgba(99, 102, 241, 0.4);
--accent: #a855f7;
--accent-glow: rgba(168, 85, 247, 0.45);
--emerald: #10b981;
--rose: #ef4444;
--text-main: #e2e8f0;
--text-muted: #94a3b8;
--font-header: 'Outfit', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-dark);
color: var(--text-main);
font-family: var(--font-body);
min-height: 100vh;
overflow-x: hidden;
position: relative;
padding: 2rem 1.5rem;
}
/* ==========================================================================
DYNAMIC GLOWING BACKGROUND
========================================================================== */
.glow-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
background:
radial-gradient(circle at 10% 20%, rgba(99, 102, 241, 0.08) 0%, transparent 40%),
radial-gradient(circle at 90% 80%, rgba(168, 85, 247, 0.09) 0%, transparent 45%);
pointer-events: none;
}
/* ==========================================================================
LAYOUT CONTAINER & CARDS
========================================================================== */
.dashboard-container {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.glassmorphism {
background: var(--bg-card);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
transition: var(--transition-smooth);
}
.glassmorphism:hover {
border-color: rgba(99, 102, 241, 0.3);
box-shadow: 0 10px 40px 0 rgba(99, 102, 241, 0.1);
}
.card {
padding: 1.75rem;
}
.card-title {
font-family: var(--font-header);
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1.25rem;
background: linear-gradient(135deg, #fff 0%, var(--text-muted) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding-bottom: 0.75rem;
}
/* ==========================================================================
APP HEADER
========================================================================== */
.app-header {
text-align: center;
margin-bottom: 1rem;
}
.logo-area {
display: inline-flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.logo-area h1 {
font-family: var(--font-header);
font-size: 2.5rem;
font-weight: 800;
letter-spacing: -0.5px;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 40px rgba(99, 102, 241, 0.2);
}
.pulse-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--rose);
box-shadow: 0 0 10px var(--rose);
}
.pulse-indicator.active {
background-color: var(--emerald);
box-shadow: 0 0 10px var(--emerald);
animation: pulse 1.8s infinite;
}
.tagline {
color: var(--text-muted);
font-size: 0.95rem;
font-weight: 400;
max-width: 600px;
margin: 0 auto;
}
/* ==========================================================================
DASHBOARD GRID LAYOUT
========================================================================== */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
.col-span-2 {
grid-column: span 1 !important;
}
}
.col-span-2 {
grid-column: span 2;
}
/* ==========================================================================
INPUTS & CONTROLS
========================================================================== */
.control-group {
margin-bottom: 1.25rem;
}
.control-group:last-child {
margin-bottom: 0;
}
label {
display: block;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.custom-select {
width: 100%;
padding: 0.8rem 1rem;
background-color: rgba(20, 24, 45, 0.8);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-main);
font-size: 0.9rem;
font-family: var(--font-body);
outline: none;
transition: var(--transition-smooth);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 1.2rem;
}
.custom-select:focus {
border-color: var(--primary);
box-shadow: 0 0 8px var(--primary-glow);
}
.input-group input {
background-color: rgba(20, 24, 45, 0.8);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-main);
padding: 0.8rem 1rem;
width: 100%;
font-family: var(--font-body);
font-size: 0.9rem;
outline: none;
transition: var(--transition-smooth);
}
.input-group input:focus {
border-color: var(--primary);
box-shadow: 0 0 8px var(--primary-glow);
}
/* ==========================================================================
SLIDERS STYLING
========================================================================== */
.slider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.slider-value {
font-family: var(--font-header);
font-weight: 600;
color: var(--accent);
text-shadow: 0 0 8px var(--accent-glow);
font-size: 0.95rem;
}
.custom-slider {
-webkit-appearance: none;
width: 100%;
height: 6px;
border-radius: 3px;
background: rgba(99, 102, 241, 0.15);
outline: none;
margin: 0.75rem 0;
}
.custom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
cursor: pointer;
box-shadow: 0 0 10px var(--primary-glow);
transition: transform 0.1s ease;
}
.custom-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.slider-ticks {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-muted);
}
/* ==========================================================================
BUTTONS
========================================================================== */
.btn {
padding: 0.8rem 1.5rem;
border-radius: 8px;
font-family: var(--font-header);
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
border: none;
outline: none;
transition: var(--transition-smooth);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary) 0%, #4f46e5 100%);
color: white;
box-shadow: 0 4px 14px 0 var(--primary-glow);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px 0 rgba(99, 102, 241, 0.6);
}
.btn-accent {
background: linear-gradient(135deg, var(--accent) 0%, #7c3aed 100%);
color: white;
box-shadow: 0 4px 14px 0 var(--accent-glow);
}
.btn-accent:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px 0 rgba(168, 85, 247, 0.65);
}
.btn:active:not(:disabled) {
transform: translateY(0);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
}
/* ==========================================================================
CONNECTION BAR
========================================================================== */
.connection-bar {
padding: 1rem 1.5rem !important;
}
.form-row {
display: flex;
align-items: flex-end;
gap: 1.5rem;
flex-wrap: wrap;
}
.form-row .input-group {
flex: 1;
min-width: 250px;
}
.connection-status-container {
display: flex;
align-items: center;
height: 48px;
}
.status-badge {
padding: 0.4rem 0.8rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.status-badge::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-badge.connected {
background-color: rgba(16, 185, 129, 0.15);
color: var(--emerald);
border: 1px solid rgba(16, 185, 129, 0.3);
}
.status-badge.connected::before {
background-color: var(--emerald);
box-shadow: 0 0 6px var(--emerald);
}
.status-badge.disconnected {
background-color: rgba(239, 68, 68, 0.15);
color: var(--rose);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.status-badge.disconnected::before {
background-color: var(--rose);
box-shadow: 0 0 6px var(--rose);
}
.status-badge.connecting {
background-color: rgba(168, 85, 247, 0.15);
color: var(--accent);
border: 1px solid rgba(168, 85, 247, 0.3);
}
.status-badge.connecting::before {
background-color: var(--accent);
box-shadow: 0 0 6px var(--accent);
animation: blink 1s infinite;
}
.btn-group-row {
display: flex;
gap: 0.75rem;
height: 48px;
}
/* ==========================================================================
MODERN RADIO TILES
========================================================================== */
.radio-group-modern {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.radio-tile {
position: relative;
cursor: pointer;
margin: 0;
}
.radio-tile input {
position: absolute;
opacity: 0;
}
.tile-label {
display: block;
padding: 0.6rem;
background-color: rgba(20, 24, 45, 0.5);
border: 1px solid var(--border-color);
border-radius: 8px;
text-align: center;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-muted);
transition: var(--transition-smooth);
}
.radio-tile input:checked + .tile-label {
background-color: rgba(99, 102, 241, 0.12);
border-color: var(--primary);
color: var(--text-main);
box-shadow: 0 0 10px rgba(99, 102, 241, 0.2);
}
.radio-tile:hover .tile-label {
border-color: rgba(99, 102, 241, 0.4);
}
/* ==========================================================================
OSCILLOSCOPE WAVEFORM CANVASES
========================================================================== */
.visualizer-row {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
.visualizer-container {
flex: 1;
min-width: 280px;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.vis-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-muted);
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.input-dot {
background-color: var(--primary);
box-shadow: 0 0 6px var(--primary);
}
.output-dot {
background-color: var(--accent);
box-shadow: 0 0 6px var(--accent);
}
.waveform-canvas {
width: 100%;
height: 150px;
background-color: #0b0c13;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.03);
}
/* ==========================================================================
PERFORMANCE HUD
========================================================================== */
.performance-hud {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.85rem 1.75rem !important;
}
.hud-item {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.hud-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
font-weight: 500;
}
.hud-value {
font-family: var(--font-header);
font-size: 1.1rem;
font-weight: 700;
color: white;
}
.hud-separator {
width: 1px;
height: 30px;
background-color: rgba(255, 255, 255, 0.08);
}
.hud-value.text-accent {
color: var(--accent);
text-shadow: 0 0 8px var(--accent-glow);
}
.active-badge {
color: var(--emerald);
text-shadow: 0 0 6px rgba(16, 185, 129, 0.4);
}
@media (max-width: 600px) {
.performance-hud {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.hud-separator {
display: none;
}
}
/* ==========================================================================
KEYFRAME ANIMATIONS
========================================================================== */
@keyframes pulse {
0% {
transform: scale(0.9);
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
}
70% {
transform: scale(1.1);
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
}
100% {
transform: scale(0.9);
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
@keyframes blink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}