mirror of
https://github.com/zenlm/zen-live.git
synced 2026-10-30 14:57:52 +00:00
2404 lines
92 KiB
HTML
2404 lines
92 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Zen Live - Control Room</title>
|
|
<style>
|
|
:root {
|
|
--bg-dark: #0a0a0a;
|
|
--bg-panel: #111;
|
|
--accent: #333;
|
|
--highlight: #fff;
|
|
--text: #fff;
|
|
--text-muted: #888; /* Improved contrast from #666 */
|
|
--success: #10b981;
|
|
--warning: #888;
|
|
/* Spacing system - 8px base unit */
|
|
--spacing-xs: 4px;
|
|
--spacing-sm: 8px;
|
|
--spacing-md: 16px;
|
|
--spacing-lg: 24px;
|
|
--spacing-xl: 32px;
|
|
}
|
|
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: var(--bg-dark);
|
|
color: var(--text);
|
|
min-height: 100vh;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.header {
|
|
background: var(--bg-panel);
|
|
padding: 12px 24px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
border-bottom: 2px solid var(--accent);
|
|
}
|
|
|
|
.logo {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
color: var(--highlight);
|
|
}
|
|
|
|
.logo svg {
|
|
width: 28px;
|
|
height: 28px;
|
|
}
|
|
|
|
.nav-links {
|
|
display: flex;
|
|
gap: 16px;
|
|
align-items: center;
|
|
}
|
|
|
|
.nav-links a {
|
|
color: var(--text-muted);
|
|
text-decoration: none;
|
|
font-size: 0.85rem;
|
|
padding: 6px 12px;
|
|
border: 1px solid var(--accent);
|
|
border-radius: 4px;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.nav-links a:hover {
|
|
color: var(--text);
|
|
border-color: var(--text-muted);
|
|
}
|
|
|
|
.status-bar {
|
|
display: flex;
|
|
gap: 20px;
|
|
align-items: center;
|
|
}
|
|
|
|
.status-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
background: var(--text-muted);
|
|
}
|
|
|
|
.status-dot.live {
|
|
background: var(--success);
|
|
animation: pulse 1.5s infinite;
|
|
}
|
|
|
|
.status-dot.warning {
|
|
background: var(--warning);
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
|
|
.main-container {
|
|
display: grid;
|
|
grid-template-columns: 1fr 400px;
|
|
height: calc(100vh - 60px);
|
|
gap: 0;
|
|
}
|
|
|
|
.video-section {
|
|
position: relative;
|
|
background: #000;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
padding-bottom: 120px; /* Space for fixed caption overlay */
|
|
}
|
|
|
|
.video-container {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 100%;
|
|
min-height: 0;
|
|
}
|
|
|
|
#videoElement {
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.video-overlay {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
width: calc(100% - 400px); /* Exclude sidebar width */
|
|
background: rgba(0,0,0,0.9);
|
|
padding: 16px 24px;
|
|
z-index: 100;
|
|
backdrop-filter: blur(8px);
|
|
}
|
|
|
|
/* Monitor mode - full width overlay */
|
|
body.monitor-mode .video-overlay {
|
|
width: 100%;
|
|
}
|
|
|
|
.live-caption {
|
|
font-size: 1.4rem;
|
|
line-height: 1.6;
|
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
|
|
min-height: 80px;
|
|
}
|
|
|
|
.live-caption .stable {
|
|
color: #fff;
|
|
}
|
|
|
|
.live-caption .interim {
|
|
color: #aaa;
|
|
font-style: italic;
|
|
}
|
|
|
|
.source-caption {
|
|
font-size: 1rem;
|
|
line-height: 1.4;
|
|
color: #888;
|
|
text-shadow: 1px 1px 2px rgba(0,0,0,0.8);
|
|
margin-bottom: 8px;
|
|
padding-bottom: 8px;
|
|
border-bottom: 1px solid rgba(255,255,255,0.2);
|
|
}
|
|
|
|
.source-caption .label {
|
|
font-size: 0.7rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
color: #666;
|
|
margin-right: 8px;
|
|
}
|
|
|
|
.sidebar {
|
|
background: var(--bg-panel);
|
|
display: flex;
|
|
flex-direction: column;
|
|
border-left: 2px solid var(--accent);
|
|
}
|
|
|
|
.sidebar-header {
|
|
padding: 16px;
|
|
border-bottom: 1px solid var(--accent);
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
font-size: 0.85rem;
|
|
letter-spacing: 1px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.controls-section {
|
|
padding: 16px;
|
|
border-bottom: 1px solid var(--accent);
|
|
}
|
|
|
|
.control-group {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.control-group:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
label {
|
|
display: block;
|
|
font-size: 0.9rem; /* Improved from 0.8rem for accessibility */
|
|
color: var(--text-muted);
|
|
margin-bottom: var(--spacing-sm);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
select, input {
|
|
width: 100%;
|
|
padding: 10px 12px;
|
|
background: var(--bg-dark);
|
|
border: 1px solid var(--accent);
|
|
border-radius: 6px;
|
|
color: var(--text);
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
select:focus, input:focus {
|
|
outline: none;
|
|
border-color: var(--highlight);
|
|
}
|
|
|
|
.btn-group {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.btn {
|
|
flex: 1;
|
|
padding: 12px 16px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: var(--text);
|
|
color: var(--bg-dark);
|
|
border: 2px solid var(--text);
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: transparent;
|
|
color: var(--text);
|
|
}
|
|
|
|
.btn-primary:disabled {
|
|
background: var(--text-muted);
|
|
border-color: var(--text-muted);
|
|
color: var(--bg-panel);
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: var(--accent);
|
|
color: var(--text);
|
|
border: 2px solid var(--accent);
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: var(--text-muted);
|
|
}
|
|
|
|
.btn-stop {
|
|
background: transparent;
|
|
color: var(--text);
|
|
border: 2px solid var(--text);
|
|
}
|
|
|
|
.btn-stop:hover {
|
|
background: var(--text);
|
|
color: var(--bg-dark);
|
|
}
|
|
|
|
.btn-stop:disabled {
|
|
border-color: var(--text-muted);
|
|
color: var(--text-muted);
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.transcript-section {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.transcript-log {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 16px;
|
|
}
|
|
|
|
.transcript-entry {
|
|
padding: 12px;
|
|
margin-bottom: 8px;
|
|
background: var(--bg-dark);
|
|
border-radius: 8px;
|
|
border-left: 3px solid var(--highlight);
|
|
}
|
|
|
|
.transcript-entry .time {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.transcript-entry .text {
|
|
font-size: 0.95rem;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.audio-monitor {
|
|
padding: 16px;
|
|
border-top: 1px solid var(--accent);
|
|
background: var(--bg-dark);
|
|
}
|
|
|
|
.audio-level {
|
|
height: 8px;
|
|
background: var(--accent);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.audio-level-bar {
|
|
height: 100%;
|
|
width: 0%;
|
|
background: linear-gradient(90deg, var(--success), var(--warning), var(--highlight));
|
|
transition: width 0.1s;
|
|
}
|
|
|
|
/* Audio Mixer Styles */
|
|
.audio-mixer {
|
|
padding: 16px;
|
|
border-top: 1px solid var(--accent);
|
|
background: var(--bg-panel);
|
|
}
|
|
|
|
.mixer-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.mixer-header label {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.mixer-toggle {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
|
|
.mixer-toggle button {
|
|
padding: 4px 10px;
|
|
font-size: 0.75rem;
|
|
border: 1px solid var(--accent);
|
|
background: transparent;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.mixer-toggle button:first-child {
|
|
border-radius: 4px 0 0 4px;
|
|
}
|
|
|
|
.mixer-toggle button:last-child {
|
|
border-radius: 0 4px 4px 0;
|
|
}
|
|
|
|
.mixer-toggle button.active {
|
|
background: var(--text);
|
|
color: var(--bg-dark);
|
|
border-color: var(--text);
|
|
}
|
|
|
|
.mixer-channel {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.mixer-channel:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.channel-label {
|
|
width: 80px;
|
|
font-size: 0.8rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.channel-slider {
|
|
flex: 1;
|
|
-webkit-appearance: none;
|
|
height: 6px;
|
|
background: var(--accent);
|
|
border-radius: 3px;
|
|
outline: none;
|
|
}
|
|
|
|
.channel-slider::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
background: var(--text);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.channel-slider::-moz-range-thumb {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
background: var(--text);
|
|
cursor: pointer;
|
|
border: none;
|
|
}
|
|
|
|
.channel-value {
|
|
width: 40px;
|
|
text-align: right;
|
|
font-size: 0.8rem;
|
|
color: var(--text-muted);
|
|
font-family: monospace;
|
|
}
|
|
|
|
.mute-btn {
|
|
min-width: 36px;
|
|
min-height: 36px;
|
|
padding: var(--spacing-sm);
|
|
font-size: 0.8rem;
|
|
border: 1px solid var(--accent);
|
|
background: transparent;
|
|
color: var(--text-muted);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.mute-btn:focus-visible {
|
|
outline: 2px solid var(--highlight);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.mute-btn.muted {
|
|
background: var(--warning);
|
|
color: var(--bg-dark);
|
|
border-color: var(--warning);
|
|
}
|
|
|
|
.latency-display {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-top: 12px;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.latency-value {
|
|
color: var(--success);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.latency-value.high {
|
|
color: var(--warning);
|
|
}
|
|
|
|
.output-endpoints {
|
|
padding: 16px;
|
|
border-top: 1px solid var(--accent);
|
|
}
|
|
|
|
.endpoint-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 8px 0;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.endpoint-url {
|
|
font-family: monospace;
|
|
color: var(--text-muted);
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.copy-btn {
|
|
background: var(--accent);
|
|
border: none;
|
|
color: var(--text);
|
|
min-width: 36px;
|
|
min-height: 36px;
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.copy-btn:focus-visible {
|
|
outline: 2px solid var(--highlight);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.fullscreen-toggle {
|
|
position: absolute;
|
|
top: 16px;
|
|
right: 16px;
|
|
background: rgba(0,0,0,0.6);
|
|
border: none;
|
|
color: white;
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Monitor-only mode */
|
|
body.monitor-mode .sidebar {
|
|
display: none;
|
|
}
|
|
|
|
body.monitor-mode .main-container {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
body.monitor-mode .header {
|
|
padding: 8px 16px;
|
|
}
|
|
|
|
/* Hidden audio indicator - removed from UI */
|
|
.audio-output-indicator {
|
|
display: none !important;
|
|
}
|
|
|
|
/* Hidden audio element for TTS playback */
|
|
#audioOutput {
|
|
display: none;
|
|
}
|
|
|
|
/* Broadcast settings panel */
|
|
.broadcast-settings {
|
|
margin-top: 16px;
|
|
border: 1px solid var(--accent);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.settings-header {
|
|
padding: 12px;
|
|
background: var(--bg-dark);
|
|
cursor: pointer;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.settings-header:hover {
|
|
background: var(--accent);
|
|
}
|
|
|
|
.settings-panel {
|
|
padding: 16px;
|
|
background: var(--bg-dark);
|
|
border-top: 1px solid var(--accent);
|
|
border-radius: 0 0 6px 6px;
|
|
}
|
|
|
|
.settings-panel .control-group {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.settings-panel label input[type="checkbox"] {
|
|
margin-right: 8px;
|
|
}
|
|
|
|
.help-text {
|
|
display: block;
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.settings-info {
|
|
text-align: center;
|
|
margin-top: 12px;
|
|
padding-top: 12px;
|
|
border-top: 1px solid var(--accent);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 1024px) {
|
|
.main-container {
|
|
grid-template-columns: 1fr;
|
|
grid-template-rows: auto 1fr; /* Fixed: auto for video, 1fr for sidebar */
|
|
}
|
|
|
|
.sidebar {
|
|
border-left: none;
|
|
border-top: 2px solid var(--accent);
|
|
max-height: 50vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
/* Full-width overlay on mobile/tablet */
|
|
.video-overlay {
|
|
width: 100%;
|
|
}
|
|
|
|
/* Auto-collapse advanced sections on mobile */
|
|
body:not(.advanced-mode) .audio-mixer,
|
|
body:not(.advanced-mode) .output-endpoints {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
/* ==========================================
|
|
Progressive Disclosure - Simple/Advanced Mode
|
|
========================================== */
|
|
|
|
/* Advanced mode toggle button */
|
|
.mode-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-sm);
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
background: transparent;
|
|
border: 1px solid var(--accent);
|
|
border-radius: 4px;
|
|
color: var(--text-muted);
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.mode-toggle:hover {
|
|
border-color: var(--text-muted);
|
|
color: var(--text);
|
|
}
|
|
|
|
.mode-toggle:focus-visible {
|
|
outline: 2px solid var(--highlight);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.mode-toggle.active {
|
|
background: var(--accent);
|
|
border-color: var(--text-muted);
|
|
color: var(--text);
|
|
}
|
|
|
|
.mode-toggle-icon {
|
|
font-size: 1rem;
|
|
}
|
|
|
|
/* Simple mode (default) - show audio mixer, hide output endpoints */
|
|
body:not(.advanced-mode) .audio-mixer {
|
|
display: block; /* Show audio mixer by default */
|
|
}
|
|
|
|
body:not(.advanced-mode) .output-endpoints {
|
|
display: none;
|
|
}
|
|
|
|
/* Section transitions */
|
|
.audio-mixer,
|
|
.output-endpoints {
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
/* Advanced mode indicator */
|
|
.advanced-indicator {
|
|
display: none;
|
|
font-size: 0.7rem;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-left: var(--spacing-sm);
|
|
}
|
|
|
|
body.advanced-mode .advanced-indicator {
|
|
display: inline;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<div class="logo">
|
|
<svg width="28" height="28" viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="currentColor"/>
|
|
<path d="M0 44.6369L22.21 46.8285V44.6369H0Z" fill="currentColor"/>
|
|
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="currentColor"/>
|
|
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="currentColor"/>
|
|
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="currentColor"/>
|
|
<path d="M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z" fill="currentColor"/>
|
|
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="currentColor"/>
|
|
</svg>
|
|
Zen Live
|
|
</div>
|
|
<div class="nav-links">
|
|
<button class="mode-toggle" id="advancedToggle" onclick="toggleAdvancedMode()" aria-label="Toggle advanced mode" title="Toggle advanced controls (Ctrl+Shift+A)" style="display:none;">
|
|
<span class="mode-toggle-icon" id="modeIcon">+</span>
|
|
<span id="modeLabel">Advanced</span>
|
|
</button>
|
|
<a href="/docs" target="_blank">API Docs</a>
|
|
<a href="/api/spec" target="_blank">Spec</a>
|
|
<a href="https://github.com/zenlm/zen-live" target="_blank">GitHub</a>
|
|
</div>
|
|
<div class="status-bar">
|
|
<div class="status-item">
|
|
<div class="status-dot" id="connectionStatus"></div>
|
|
<span id="connectionText">Disconnected</span>
|
|
</div>
|
|
<div class="status-item" style="display:none;">
|
|
<div class="status-dot" id="audioStatus"></div>
|
|
<span>Audio</span>
|
|
</div>
|
|
<div class="status-item">
|
|
<span id="latencyDisplay">--ms</span>
|
|
<span style="color: var(--text-muted); margin-left: 8px;">avg: <span id="headerAvgLatency">--ms</span></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="main-container">
|
|
<div class="video-section">
|
|
<div class="video-container">
|
|
<video id="videoElement" autoplay muted playsinline></video>
|
|
</div>
|
|
<button class="fullscreen-toggle" onclick="toggleFullscreen()">Fullscreen</button>
|
|
<div class="video-overlay">
|
|
<div class="source-caption" id="sourceCaption">
|
|
<span class="label" id="sourceLangLabel">SOURCE</span>
|
|
<span class="text"></span>
|
|
</div>
|
|
<div class="live-caption" id="liveCaption">
|
|
<span class="label" style="font-size: 0.7rem; text-transform: uppercase; letter-spacing: 1px; color: #999; display: block; margin-bottom: 4px;" id="targetLangLabel">TRANSLATED</span>
|
|
<span class="stable"></span>
|
|
<span class="interim"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sidebar">
|
|
<div class="sidebar-header">Control Panel</div>
|
|
|
|
<div class="controls-section">
|
|
<div class="control-group">
|
|
<label>Source Language</label>
|
|
<select id="srcLanguage" onchange="onLanguageChange()">
|
|
<option value="Spanish" selected>Spanish (Espanol)</option>
|
|
<option value="English">English</option>
|
|
<option value="Chinese">Chinese (Mandarin)</option>
|
|
<option value="Portuguese">Portuguese</option>
|
|
<option value="French">French</option>
|
|
<option value="German">German</option>
|
|
<option value="Italian">Italian</option>
|
|
<option value="Russian">Russian</option>
|
|
<option value="Arabic">Arabic</option>
|
|
<option value="Japanese">Japanese</option>
|
|
<option value="Korean">Korean</option>
|
|
</select>
|
|
<small class="help-text" id="langDetectInfo" style="display:none;">Detecting language...</small>
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<label>Target Language</label>
|
|
<select id="targetLanguage" onchange="onLanguageChange()">
|
|
<option value="English" selected>English</option>
|
|
<option value="Spanish">Spanish (Espanol)</option>
|
|
<option value="Chinese">Chinese (Mandarin)</option>
|
|
<option value="Portuguese">Portuguese</option>
|
|
<option value="French">French</option>
|
|
<option value="German">German</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<label>Voice</label>
|
|
<select id="voiceSelect" onchange="onVoiceChange()">
|
|
<option value="Cherry">Cherry - Female</option>
|
|
<option value="Nofish" selected>Cort - Male</option>
|
|
</select>
|
|
<small class="help-text" id="voiceInfo">Multilingual: en, es, zh, fr, de, it, pt, ru, ja, ko</small>
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<label>Input Source</label>
|
|
<select id="inputSource">
|
|
<option value="camera">Camera/Webcam</option>
|
|
<option value="screen">Screen Capture</option>
|
|
<option value="srt">SRT Input (URL)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="control-group" id="micSelectGroup">
|
|
<label>Microphone</label>
|
|
<select id="micSelect">
|
|
<option value="">Loading devices...</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<label>Audio Output</label>
|
|
<select id="speakerSelect">
|
|
<option value="">Loading devices...</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="control-group" id="srtInputGroup" style="display:none;">
|
|
<label>SRT Input URL</label>
|
|
<input type="text" id="srtUrl" placeholder="srt://ip:port?streamid=...">
|
|
</div>
|
|
|
|
<div class="btn-group" style="margin-bottom: 16px;">
|
|
<button class="btn btn-primary" id="startBtn" onclick="startStream()">
|
|
Start
|
|
</button>
|
|
<button class="btn btn-stop" id="stopBtn" onclick="stopStream()" disabled>
|
|
Stop
|
|
</button>
|
|
<button class="btn" id="shareBtn" onclick="shareSession()" disabled style="background: var(--accent); color: white;">
|
|
Share
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Share link display -->
|
|
<div id="sharePanel" style="display: none; margin-bottom: 16px; padding: 12px; background: var(--surface); border-radius: 8px; border: 1px solid var(--border);">
|
|
<div style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 8px;">Share this link with remote viewers:</div>
|
|
<div style="display: flex; gap: 8px;">
|
|
<input type="text" id="shareLink" readonly style="flex: 1; font-size: 0.75rem; padding: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text);">
|
|
<button class="btn" onclick="copyShareLink()" style="padding: 8px 12px; font-size: 0.75rem;">Copy</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Broadcast Output Settings (collapsible) -->
|
|
<div class="broadcast-settings">
|
|
<div class="settings-header" onclick="toggleBroadcastSettings()">
|
|
<span>Broadcast Output Settings</span>
|
|
<span id="settingsArrow">▼</span>
|
|
</div>
|
|
<div id="broadcastSettingsPanel" class="settings-panel" style="display: none;">
|
|
<div class="control-group">
|
|
<label>SRT Output URL</label>
|
|
<input type="text" id="srtOutputUrl" placeholder="srt://destination:9000?mode=caller">
|
|
<small class="help-text">For ffmpeg relay to SRT destination</small>
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<label>RTMP Output URL</label>
|
|
<input type="text" id="rtmpOutputUrl" placeholder="rtmp://server/live/stream_key">
|
|
<small class="help-text">For ffmpeg relay to RTMP server</small>
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<label>NDI Output Name</label>
|
|
<input type="text" id="ndiOutputName" placeholder="ZEN-LIVE-TRANSLATED">
|
|
<small class="help-text">NDI source name for video walls</small>
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<label>
|
|
<input type="checkbox" id="autoReconnect" checked>
|
|
Auto-reconnect on disconnect
|
|
</label>
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<label>
|
|
<input type="checkbox" id="enableTranscriptOverlay">
|
|
Burn-in transcript overlay
|
|
</label>
|
|
</div>
|
|
|
|
<div class="btn-group">
|
|
<button class="btn btn-secondary" onclick="saveSettings()">Save Settings</button>
|
|
<button class="btn btn-secondary" onclick="clearSettings()">Clear</button>
|
|
</div>
|
|
|
|
<div class="settings-info">
|
|
<small>Settings are saved to browser localStorage</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sidebar-header">Transcript</div>
|
|
<div class="transcript-section">
|
|
<div class="transcript-log" id="transcriptLog"></div>
|
|
</div>
|
|
|
|
<div class="audio-monitor" style="display:none;">
|
|
<div class="latency-display" style="margin-top: 0;">
|
|
<span>Translation latency:</span>
|
|
<span class="latency-value" id="e2eLatency">--ms</span>
|
|
</div>
|
|
<div class="latency-display" style="margin-top: 8px;">
|
|
<span>Avg latency (last 10):</span>
|
|
<span class="latency-value" id="avgLatency">--ms</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Audio Mixer -->
|
|
<div class="audio-mixer">
|
|
<div class="mixer-header">
|
|
<label>AUDIO MIXER</label>
|
|
<div class="mixer-toggle">
|
|
<button id="modeSource" onclick="setAudioMode('source')">Source</button>
|
|
<button id="modeMix" class="active" onclick="setAudioMode('mix')">Mix</button>
|
|
<button id="modeTranslated" onclick="setAudioMode('translated')">Translated</button>
|
|
</div>
|
|
</div>
|
|
<div class="mixer-channel">
|
|
<span class="channel-label" id="sourceLabel">Source</span>
|
|
<input type="range" class="channel-slider" id="sourceVolume" min="0" max="100" value="30" oninput="updateVolume('source')" aria-labelledby="sourceLabel" aria-valuenow="30" aria-valuemin="0" aria-valuemax="100">
|
|
<span class="channel-value" id="sourceValue">30%</span>
|
|
<button class="mute-btn" id="sourceMute" onclick="toggleMute('source')" aria-label="Mute source audio" aria-pressed="false">M</button>
|
|
</div>
|
|
<div class="mixer-channel">
|
|
<span class="channel-label" id="translatedLabel">Translated</span>
|
|
<input type="range" class="channel-slider" id="translatedVolume" min="0" max="100" value="100" oninput="updateVolume('translated')" aria-labelledby="translatedLabel" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100">
|
|
<span class="channel-value" id="translatedValue">100%</span>
|
|
<button class="mute-btn" id="translatedMute" onclick="toggleMute('translated')" aria-label="Mute translated audio" aria-pressed="false">M</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="output-endpoints">
|
|
<div class="sidebar-header" style="padding: 0 0 12px 0;">Output Endpoints</div>
|
|
<div class="endpoint-item">
|
|
<div>
|
|
<div>WebRTC Audio</div>
|
|
<div class="endpoint-url" id="webrtcUrl">--</div>
|
|
</div>
|
|
<button class="copy-btn" onclick="copyEndpoint('webrtc')">Copy</button>
|
|
</div>
|
|
<div class="endpoint-item">
|
|
<div>
|
|
<div>Audio Stream (SSE)</div>
|
|
<div class="endpoint-url" id="sseUrl">--</div>
|
|
</div>
|
|
<button class="copy-btn" onclick="copyEndpoint('sse')">Copy</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="audio-output-indicator" id="audioIndicator" style="display:none;">
|
|
<span class="speaker-icon" id="speakerIcon">🔊</span>
|
|
<span>Translated audio playing</span>
|
|
</div>
|
|
|
|
<audio id="audioOutput"></audio>
|
|
|
|
<script type="module">
|
|
const RTC_CONFIG = __RTC_CONFIGURATION__;
|
|
|
|
// View mode detection - /view?session=SESSION_ID
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const viewSessionId = urlParams.get('session');
|
|
const isViewMode = window.location.pathname === '/view' && viewSessionId;
|
|
|
|
let peerConnection = null;
|
|
let webrtcId = viewSessionId || null; // Use session from URL in view mode
|
|
let mediaStream = null;
|
|
let audioContext = null;
|
|
let audioAnalyser = null;
|
|
let isStreaming = false;
|
|
let lastTranscriptTime = 0; // Track time between transcripts for latency
|
|
let streamStartTime = 0; // When stream started
|
|
|
|
// Audio mixer state
|
|
let sourceGainNode = null;
|
|
let translatedGainNode = null;
|
|
let mixerDestination = null;
|
|
let sourceVolume = 0.3;
|
|
let translatedVolume = 1.0;
|
|
let sourceMuted = false;
|
|
let translatedMuted = false;
|
|
let audioMode = 'mix'; // 'source', 'mix', or 'translated'
|
|
|
|
// Hanzo ASR for source language transcription (via backend proxy)
|
|
let asrWebSocket = null;
|
|
let asrAudioProcessor = null;
|
|
let asrAudioContext = null;
|
|
let asrSourceNode = null;
|
|
let sourceFinalText = '';
|
|
let sourceInterimText = '';
|
|
|
|
// ASR via backend proxy (API key secured server-side)
|
|
const ASR_WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/v1/asr`;
|
|
|
|
const videoElement = document.getElementById('videoElement');
|
|
const startBtn = document.getElementById('startBtn');
|
|
const stopBtn = document.getElementById('stopBtn');
|
|
const connectionStatus = document.getElementById('connectionStatus');
|
|
const connectionText = document.getElementById('connectionText');
|
|
const audioStatus = document.getElementById('audioStatus');
|
|
const liveCaption = document.getElementById('liveCaption');
|
|
const transcriptLog = document.getElementById('transcriptLog');
|
|
const audioLevelBar = document.getElementById('audioLevelBar');
|
|
const e2eLatency = document.getElementById('e2eLatency');
|
|
const inputSource = document.getElementById('inputSource');
|
|
const srtInputGroup = document.getElementById('srtInputGroup');
|
|
const sourceCaption = document.getElementById('sourceCaption');
|
|
const latencyDisplay = document.getElementById('latencyDisplay');
|
|
const avgLatencyEl = document.getElementById('avgLatency');
|
|
|
|
// Latency tracking
|
|
let latencyHistory = [];
|
|
|
|
// Update URLs display
|
|
const baseUrl = window.location.origin;
|
|
document.getElementById('webrtcUrl').textContent = `${baseUrl}/webrtc`;
|
|
document.getElementById('sseUrl').textContent = `${baseUrl}/outputs?webrtc_id=...`;
|
|
|
|
// Input source toggle
|
|
inputSource.addEventListener('change', (e) => {
|
|
srtInputGroup.style.display = e.target.value === 'srt' ? 'block' : 'none';
|
|
// Hide mic selection for screen capture
|
|
document.getElementById('micSelectGroup').style.display =
|
|
e.target.value === 'srt' ? 'none' : 'block';
|
|
});
|
|
|
|
// ==========================================
|
|
// Device Enumeration
|
|
// ==========================================
|
|
const micSelect = document.getElementById('micSelect');
|
|
const speakerSelect = document.getElementById('speakerSelect');
|
|
|
|
async function enumerateDevices() {
|
|
try {
|
|
// Request permission first to get device labels
|
|
await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
|
|
|
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
|
|
// Clear existing options
|
|
micSelect.innerHTML = '';
|
|
speakerSelect.innerHTML = '';
|
|
|
|
// Add default options
|
|
micSelect.innerHTML = '<option value="">System Default</option>';
|
|
speakerSelect.innerHTML = '<option value="">System Default</option>';
|
|
|
|
devices.forEach(device => {
|
|
const option = document.createElement('option');
|
|
option.value = device.deviceId;
|
|
option.text = device.label || `${device.kind} (${device.deviceId.substring(0, 8)}...)`;
|
|
|
|
if (device.kind === 'audioinput') {
|
|
micSelect.appendChild(option);
|
|
} else if (device.kind === 'audiooutput') {
|
|
speakerSelect.appendChild(option);
|
|
}
|
|
});
|
|
|
|
console.log('Devices enumerated:', devices.length);
|
|
} catch (err) {
|
|
console.error('Error enumerating devices:', err);
|
|
micSelect.innerHTML = '<option value="">Permission denied</option>';
|
|
speakerSelect.innerHTML = '<option value="">Permission denied</option>';
|
|
}
|
|
}
|
|
|
|
// Listen for device changes (e.g., plugging in headphones)
|
|
navigator.mediaDevices.addEventListener('devicechange', enumerateDevices);
|
|
|
|
// Enumerate devices on page load
|
|
enumerateDevices();
|
|
|
|
// ==========================================
|
|
// View Mode Initialization
|
|
// ==========================================
|
|
if (isViewMode) {
|
|
console.log('📺 View mode active, session:', viewSessionId);
|
|
initViewMode();
|
|
}
|
|
|
|
function initViewMode() {
|
|
// Update UI for view mode
|
|
document.querySelector('.logo').textContent = 'Zen Live - Remote View';
|
|
|
|
// Hide controls not needed for viewing
|
|
startBtn.style.display = 'none';
|
|
stopBtn.style.display = 'none';
|
|
document.getElementById('shareBtn').style.display = 'none';
|
|
document.getElementById('sharePanel').style.display = 'none';
|
|
|
|
// Hide input source selection
|
|
inputSource.closest('.control-group').style.display = 'none';
|
|
document.getElementById('micSelectGroup').style.display = 'none';
|
|
document.getElementById('srtInputGroup').style.display = 'none';
|
|
|
|
// Set video element to show MJPEG stream
|
|
videoElement.style.display = 'none';
|
|
const mjpegImg = document.createElement('img');
|
|
mjpegImg.id = 'mjpegVideo';
|
|
mjpegImg.src = `/video/stream/${viewSessionId}`;
|
|
mjpegImg.style.cssText = 'width: 100%; height: auto; max-height: 100%; object-fit: contain; background: #000; display: block; margin: auto;';
|
|
videoElement.parentElement.style.display = 'flex';
|
|
videoElement.parentElement.style.alignItems = 'center';
|
|
videoElement.parentElement.style.justifyContent = 'center';
|
|
videoElement.parentElement.appendChild(mjpegImg);
|
|
|
|
// Start listening for transcripts
|
|
startOutputListener();
|
|
|
|
// Update status
|
|
updateStatus('live');
|
|
connectionText.textContent = 'Viewing';
|
|
|
|
// Setup audio player
|
|
const audioPlayer = document.getElementById('audioOutput');
|
|
audioPlayer.src = `/audio/wav/${viewSessionId}`;
|
|
|
|
// Add play button for audio (browsers require user interaction)
|
|
const playAudioBtn = document.createElement('button');
|
|
playAudioBtn.className = 'btn btn-primary';
|
|
playAudioBtn.textContent = 'Play Audio';
|
|
playAudioBtn.style.marginTop = '16px';
|
|
playAudioBtn.onclick = () => {
|
|
audioPlayer.play().then(() => {
|
|
playAudioBtn.textContent = 'Audio Playing';
|
|
playAudioBtn.disabled = true;
|
|
audioStatus.classList.add('live');
|
|
}).catch(err => {
|
|
console.error('Audio play failed:', err);
|
|
alert('Click again to play audio');
|
|
});
|
|
};
|
|
|
|
// Add to controls section
|
|
const controlsSection = document.querySelector('.controls-section');
|
|
if (controlsSection) {
|
|
controlsSection.appendChild(playAudioBtn);
|
|
}
|
|
|
|
console.log('📺 View mode initialized');
|
|
}
|
|
|
|
async function startStream() {
|
|
try {
|
|
startBtn.disabled = true;
|
|
updateStatus('connecting');
|
|
|
|
const sourceType = inputSource.value;
|
|
|
|
// Get selected devices
|
|
const selectedMic = micSelect.value;
|
|
const selectedSpeaker = speakerSelect.value;
|
|
|
|
// Build audio constraints with selected device
|
|
const audioConstraints = {
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
sampleRate: 16000
|
|
};
|
|
if (selectedMic) {
|
|
audioConstraints.deviceId = { exact: selectedMic };
|
|
}
|
|
|
|
// Get media based on source type
|
|
if (sourceType === 'camera') {
|
|
// Get video and audio separately to support different devices
|
|
// (e.g., webcam video + NDI audio input)
|
|
try {
|
|
// First get video from camera
|
|
const videoStream = await navigator.mediaDevices.getUserMedia({
|
|
video: { width: 1280, height: 720, facingMode: 'environment' },
|
|
audio: false
|
|
});
|
|
console.log('📹 Camera video captured');
|
|
|
|
// Then get audio from selected mic (may be different device)
|
|
const audioStream = await navigator.mediaDevices.getUserMedia({
|
|
video: false,
|
|
audio: audioConstraints
|
|
});
|
|
console.log('🎤 Audio captured from:', selectedMic || 'default');
|
|
|
|
// Combine into single MediaStream
|
|
mediaStream = new MediaStream([
|
|
...videoStream.getVideoTracks(),
|
|
...audioStream.getAudioTracks()
|
|
]);
|
|
console.log('📡 Combined stream: video tracks:', mediaStream.getVideoTracks().length, 'audio tracks:', mediaStream.getAudioTracks().length);
|
|
} catch (e) {
|
|
console.warn('Failed to get separate streams, falling back to combined:', e);
|
|
mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
video: { width: 1280, height: 720 },
|
|
audio: audioConstraints
|
|
});
|
|
}
|
|
} else if (sourceType === 'screen') {
|
|
mediaStream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: { width: 1920, height: 1080 },
|
|
audio: true
|
|
});
|
|
} else if (sourceType === 'srt') {
|
|
// For SRT input, audio comes from server side (WHIP/SRT feed)
|
|
// We still need a mic for monitoring or pass-through, but video comes from server
|
|
try {
|
|
// Get audio-only stream from mic for monitoring
|
|
mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
video: false,
|
|
audio: audioConstraints
|
|
});
|
|
console.log('🎤 SRT mode: Audio captured from mic for monitoring');
|
|
} catch (e) {
|
|
console.warn('🎤 Could not get mic audio, proceeding without local audio:', e);
|
|
// Create an empty MediaStream for SRT mode when no mic available
|
|
mediaStream = new MediaStream();
|
|
}
|
|
} else {
|
|
// Unknown source type - fall back to camera
|
|
console.warn('Unknown source type:', sourceType, '- falling back to camera');
|
|
mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
video: { width: 1280, height: 720 },
|
|
audio: audioConstraints
|
|
});
|
|
}
|
|
|
|
// Set output device if supported
|
|
if (selectedSpeaker && typeof document.getElementById('audioOutput').setSinkId === 'function') {
|
|
try {
|
|
await document.getElementById('audioOutput').setSinkId(selectedSpeaker);
|
|
console.log('Audio output set to:', selectedSpeaker);
|
|
} catch (e) {
|
|
console.warn('Could not set audio output device:', e);
|
|
}
|
|
}
|
|
|
|
videoElement.srcObject = mediaStream;
|
|
|
|
// Setup WebRTC
|
|
await setupWebRTC();
|
|
|
|
} catch (err) {
|
|
console.error('Error starting stream:', err);
|
|
updateStatus('error');
|
|
startBtn.disabled = false;
|
|
alert('Failed to start: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function setupWebRTC() {
|
|
const config = RTC_CONFIG || {
|
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
};
|
|
|
|
peerConnection = new RTCPeerConnection(config);
|
|
|
|
// Create data channel - REQUIRED for FastRTC handler lifecycle
|
|
// FastRTC's AudioCallback.start() waits for this before calling start_up()
|
|
const dataChannel = peerConnection.createDataChannel('fastrtc');
|
|
dataChannel.onopen = () => console.log('📡 Data channel opened');
|
|
dataChannel.onmessage = (e) => {
|
|
try {
|
|
const msg = JSON.parse(e.data);
|
|
handleDataChannelMessage(msg);
|
|
} catch (err) {
|
|
console.log('📡 Data channel message (non-JSON):', e.data);
|
|
}
|
|
};
|
|
dataChannel.onerror = (e) => console.error('📡 Data channel error:', e);
|
|
|
|
// Add tracks (if we have any - SRT mode may have empty stream)
|
|
if (mediaStream && mediaStream.getTracks().length > 0) {
|
|
mediaStream.getTracks().forEach(track => {
|
|
peerConnection.addTrack(track, mediaStream);
|
|
});
|
|
} else {
|
|
console.log('📡 No local tracks to add (SRT/server-side input mode)');
|
|
}
|
|
|
|
// Handle incoming audio (translated TTS)
|
|
peerConnection.ontrack = (event) => {
|
|
console.log('🎵 Received remote track:', event.track.kind, 'streams:', event.streams.length);
|
|
if (event.track.kind === 'audio') {
|
|
// Don't play directly via audio element - use mixer instead
|
|
const translatedStream = event.streams[0];
|
|
console.log('🎵 Translated stream:', translatedStream?.id, 'tracks:', translatedStream?.getAudioTracks().length);
|
|
|
|
// Update audio status in header
|
|
audioStatus.classList.add('live');
|
|
|
|
// Get audio track from local media for source mixing
|
|
const sourceAudioTrack = mediaStream?.getAudioTracks()[0];
|
|
const sourceStream = sourceAudioTrack ? new MediaStream([sourceAudioTrack]) : null;
|
|
console.log('🎵 Source stream:', sourceStream?.id);
|
|
|
|
// Setup audio mixer with both streams
|
|
setupAudioMixer(sourceStream, translatedStream);
|
|
} else if (event.track.kind === 'video') {
|
|
// Also handle video track
|
|
videoElement.srcObject = event.streams[0];
|
|
console.log('🎵 Video stream attached to element');
|
|
}
|
|
};
|
|
|
|
// ICE handling
|
|
peerConnection.onicecandidate = (event) => {
|
|
if (event.candidate) {
|
|
// Send candidate to server
|
|
}
|
|
};
|
|
|
|
peerConnection.onconnectionstatechange = () => {
|
|
console.log('Connection state:', peerConnection.connectionState);
|
|
if (peerConnection.connectionState === 'connected') {
|
|
updateStatus('live');
|
|
isStreaming = true;
|
|
stopBtn.disabled = false;
|
|
console.log('🔗 Enabling share button, webrtcId:', webrtcId);
|
|
document.getElementById('shareBtn').disabled = false; // Enable share
|
|
lastTranscriptTime = 0; // Reset latency tracking
|
|
latencyHistory = []; // Clear history
|
|
streamStartTime = Date.now(); // Track when stream started
|
|
// Update caption labels with current languages
|
|
updateCaptionLabels();
|
|
// Start browser speech recognition for source captions
|
|
startSpeechRecognition();
|
|
// Auto-start broadcast outputs if configured
|
|
if (typeof startBroadcastOutputs === 'function') {
|
|
startBroadcastOutputs();
|
|
}
|
|
} else if (peerConnection.connectionState === 'failed') {
|
|
updateStatus('error');
|
|
}
|
|
};
|
|
|
|
// Create offer
|
|
const offer = await peerConnection.createOffer();
|
|
await peerConnection.setLocalDescription(offer);
|
|
|
|
// Wait for ICE gathering (minimal timeout - trickle ICE handles rest)
|
|
await new Promise((resolve) => {
|
|
if (peerConnection.iceGatheringState === 'complete') {
|
|
resolve();
|
|
} else {
|
|
peerConnection.onicegatheringstatechange = () => {
|
|
if (peerConnection.iceGatheringState === 'complete') resolve();
|
|
};
|
|
// Minimal timeout - trickle ICE handles candidates
|
|
setTimeout(resolve, 150);
|
|
}
|
|
});
|
|
|
|
// Send to server
|
|
const srcLang = document.getElementById('srcLanguage').value;
|
|
const targetLang = document.getElementById('targetLanguage').value;
|
|
const voice = document.getElementById('voiceSelect').value;
|
|
|
|
const response = await fetch('/api/webrtc/offer', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
sdp: peerConnection.localDescription.sdp,
|
|
type: peerConnection.localDescription.type,
|
|
src_language: srcLang,
|
|
target_language: targetLang,
|
|
voice: voice
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
console.log('📥 Server response:', data);
|
|
|
|
// Check for error responses
|
|
if (data.error) {
|
|
throw new Error(data.error);
|
|
}
|
|
|
|
// Check for FastRTC-style error response (status: "failed")
|
|
if (data.status === 'failed') {
|
|
const errorMsg = data.meta?.error || 'Connection failed';
|
|
throw new Error(`WebRTC error: ${errorMsg}`);
|
|
}
|
|
|
|
// Validate response has required fields
|
|
if (!data.sdp || !data.type) {
|
|
console.error('Invalid server response:', data);
|
|
throw new Error('Invalid server response: missing sdp or type');
|
|
}
|
|
|
|
webrtcId = data.webrtc_id;
|
|
|
|
// Set remote description
|
|
await peerConnection.setRemoteDescription(new RTCSessionDescription({
|
|
sdp: data.sdp,
|
|
type: data.type
|
|
}));
|
|
|
|
// Start listening for outputs (transcripts)
|
|
startOutputListener();
|
|
}
|
|
|
|
function startOutputListener() {
|
|
if (!webrtcId) return;
|
|
|
|
const evtSource = new EventSource(`/outputs?webrtc_id=${webrtcId}`);
|
|
|
|
evtSource.addEventListener('output', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
console.log('📨 SSE output:', data);
|
|
updateTranscript(data);
|
|
|
|
// Update latency displays - measure time between transcript updates
|
|
const now = Date.now();
|
|
|
|
// Track latency for any content (assistant or source)
|
|
if (data.content || data.source_content) {
|
|
console.log('📊 Latency check - lastTime:', lastTranscriptTime, 'now:', now, 'data:', data.role || 'source');
|
|
|
|
// Show update indicator immediately
|
|
const timeSinceStart = Math.round((now - streamStartTime) / 1000);
|
|
|
|
if (lastTranscriptTime > 0) {
|
|
const latency = now - lastTranscriptTime;
|
|
console.log('📊 Calculated latency:', latency, 'ms');
|
|
|
|
// Update displays with time since last update
|
|
e2eLatency.textContent = `${latency}ms`;
|
|
e2eLatency.className = latency > 5000 ? 'latency-value high' : 'latency-value';
|
|
latencyDisplay.textContent = `${latency}ms`;
|
|
latencyDisplay.style.color = latency > 5000 ? 'var(--warning)' : '';
|
|
|
|
// Track for average (only reasonable values)
|
|
if (latency > 50 && latency < 30000) {
|
|
latencyHistory.push(latency);
|
|
if (latencyHistory.length > 10) latencyHistory.shift();
|
|
|
|
const avg = Math.round(latencyHistory.reduce((a, b) => a + b, 0) / latencyHistory.length);
|
|
avgLatencyEl.textContent = `${avg}ms`;
|
|
avgLatencyEl.className = avg > 5000 ? 'latency-value high' : 'latency-value';
|
|
|
|
const headerAvg = document.getElementById('headerAvgLatency');
|
|
if (headerAvg) headerAvg.textContent = `${avg}ms`;
|
|
}
|
|
} else {
|
|
// First update
|
|
e2eLatency.textContent = 'connected';
|
|
latencyDisplay.textContent = 'connected';
|
|
}
|
|
lastTranscriptTime = now;
|
|
}
|
|
});
|
|
|
|
evtSource.onerror = () => {
|
|
console.log('SSE connection error, reconnecting...');
|
|
};
|
|
}
|
|
|
|
function updateTranscript(data) {
|
|
// Handle source language caption (English input)
|
|
if (data.source_content !== undefined) {
|
|
sourceCaption.querySelector('.text').textContent = data.source_content;
|
|
return;
|
|
}
|
|
|
|
const content = data.content || '';
|
|
|
|
// Parse HTML content for stable/interim text
|
|
const stableMatch = content.match(/<span style='color:black'>([^<]*)<\/span>/);
|
|
const interimMatch = content.match(/<span style='color:gray'>([^<]*)<\/span>/);
|
|
|
|
const stableText = stableMatch ? stableMatch[1] : '';
|
|
const interimText = interimMatch ? interimMatch[1] : '';
|
|
|
|
// Update live caption (translated text)
|
|
liveCaption.querySelector('.stable').textContent = stableText;
|
|
liveCaption.querySelector('.interim').textContent = interimText;
|
|
|
|
// Add to transcript log if message is complete
|
|
if (data.new_message === false && stableText && !interimText) {
|
|
addTranscriptEntry(stableText);
|
|
}
|
|
|
|
// Reset latency timer for next segment
|
|
latencyStart = Date.now();
|
|
}
|
|
|
|
function addTranscriptEntry(text) {
|
|
const entry = document.createElement('div');
|
|
entry.className = 'transcript-entry';
|
|
entry.innerHTML = `
|
|
<div class="time">${new Date().toLocaleTimeString()}</div>
|
|
<div class="text">${text}</div>
|
|
`;
|
|
transcriptLog.appendChild(entry);
|
|
transcriptLog.scrollTop = transcriptLog.scrollHeight;
|
|
}
|
|
|
|
// ==========================================
|
|
// Hanzo ASR for Source Language Transcription
|
|
// Uses zen-asr-flash-realtime model
|
|
// ==========================================
|
|
|
|
// Language mapping from our names to Hanzo codes
|
|
const asrLangMap = {
|
|
'English': 'en',
|
|
'Spanish': 'es',
|
|
'Chinese': 'zh',
|
|
'French': 'fr',
|
|
'German': 'de',
|
|
'Japanese': 'ja',
|
|
'Korean': 'ko',
|
|
'Portuguese': 'pt',
|
|
'Russian': 'ru',
|
|
'Italian': 'it',
|
|
'Arabic': 'ar',
|
|
'Cantonese': 'yue'
|
|
};
|
|
|
|
function startSpeechRecognition() {
|
|
// Start separate ASR for source language transcription
|
|
// The main translation API (zen-livetranslate) only outputs translated text
|
|
// We need zen-asr-flash-realtime for source language captions
|
|
console.log('🎙️ Starting source language ASR...');
|
|
|
|
// Get audio track from the media stream for ASR
|
|
if (mediaStream) {
|
|
const audioTrack = mediaStream.getAudioTracks()[0];
|
|
if (audioTrack) {
|
|
const asrStream = new MediaStream([audioTrack]);
|
|
startHanzoASR(asrStream);
|
|
} else {
|
|
console.warn('🎙️ No audio track available for ASR');
|
|
sourceCaption.querySelector('.text').textContent = '[No audio]';
|
|
}
|
|
} else {
|
|
console.warn('🎙️ No media stream available for ASR');
|
|
sourceCaption.querySelector('.text').textContent = '[No stream]';
|
|
}
|
|
}
|
|
|
|
async function startHanzoASR(stream) {
|
|
console.log('🎙️ Starting Hanzo ASR...');
|
|
|
|
// Show listening indicator
|
|
sourceCaption.querySelector('.text').textContent = 'Connecting to ASR...';
|
|
sourceCaption.style.opacity = '1';
|
|
|
|
try {
|
|
// Create audio context - must match the stream's sample rate
|
|
// We'll resample to 16kHz when sending to the ASR API
|
|
asrAudioContext = new AudioContext();
|
|
asrSourceNode = asrAudioContext.createMediaStreamSource(stream);
|
|
|
|
// Create script processor for audio capture (4096 samples buffer)
|
|
asrAudioProcessor = asrAudioContext.createScriptProcessor(4096, 1, 1);
|
|
|
|
// Connect to backend ASR proxy (API key handled server-side)
|
|
asrWebSocket = new WebSocket(ASR_WS_URL);
|
|
|
|
asrWebSocket.onopen = () => {
|
|
console.log('🎙️ ASR WebSocket connected');
|
|
|
|
// Get source language for ASR
|
|
const srcLang = document.getElementById('srcLanguage').value;
|
|
const asrLang = asrLangMap[srcLang] || 'en';
|
|
|
|
// Send session update with ASR config for zen-asr-flash-realtime
|
|
// Note: This model expects PCM16@16kHz audio by default
|
|
const sessionUpdate = {
|
|
type: 'session.update',
|
|
session: {
|
|
modalities: ['text'],
|
|
input_audio_transcription: {
|
|
language: asrLang
|
|
}
|
|
}
|
|
};
|
|
asrWebSocket.send(JSON.stringify(sessionUpdate));
|
|
console.log('🎙️ ASR session config sent, language:', asrLang);
|
|
|
|
// Update caption
|
|
sourceCaption.querySelector('.text').textContent = 'Listening...';
|
|
};
|
|
|
|
asrWebSocket.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data);
|
|
handleASRMessage(msg);
|
|
};
|
|
|
|
asrWebSocket.onerror = (error) => {
|
|
console.error('🎙️ ASR WebSocket error:', error);
|
|
sourceCaption.querySelector('.text').textContent = '[ASR connection error]';
|
|
};
|
|
|
|
asrWebSocket.onclose = (event) => {
|
|
console.log('🎙️ ASR WebSocket closed:', event.code, event.reason);
|
|
};
|
|
|
|
// Audio processor callback - capture and send audio
|
|
asrAudioProcessor.onaudioprocess = (event) => {
|
|
if (asrWebSocket?.readyState === WebSocket.OPEN) {
|
|
const inputData = event.inputBuffer.getChannelData(0);
|
|
const inputSampleRate = asrAudioContext.sampleRate;
|
|
const targetSampleRate = 16000;
|
|
|
|
// Resample to 16kHz if needed
|
|
let resampledData;
|
|
if (inputSampleRate !== targetSampleRate) {
|
|
const ratio = inputSampleRate / targetSampleRate;
|
|
const newLength = Math.round(inputData.length / ratio);
|
|
resampledData = new Float32Array(newLength);
|
|
for (let i = 0; i < newLength; i++) {
|
|
const srcIndex = Math.floor(i * ratio);
|
|
resampledData[i] = inputData[srcIndex];
|
|
}
|
|
} else {
|
|
resampledData = inputData;
|
|
}
|
|
|
|
// Convert Float32 to Int16 PCM
|
|
const pcmData = new Int16Array(resampledData.length);
|
|
for (let i = 0; i < resampledData.length; i++) {
|
|
const s = Math.max(-1, Math.min(1, resampledData[i]));
|
|
pcmData[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
|
}
|
|
|
|
// Convert to base64
|
|
const base64Audio = btoa(String.fromCharCode.apply(null, new Uint8Array(pcmData.buffer)));
|
|
|
|
// Send audio chunk
|
|
asrWebSocket.send(JSON.stringify({
|
|
type: 'input_audio_buffer.append',
|
|
audio: base64Audio
|
|
}));
|
|
}
|
|
};
|
|
|
|
// Connect audio nodes - DO NOT connect to destination to prevent echo
|
|
asrSourceNode.connect(asrAudioProcessor);
|
|
// Create a silent gain node to prevent echo through speakers
|
|
const silentGain = asrAudioContext.createGain();
|
|
silentGain.gain.value = 0;
|
|
asrAudioProcessor.connect(silentGain);
|
|
silentGain.connect(asrAudioContext.destination);
|
|
|
|
console.log('🎙️ ASR audio capture started');
|
|
|
|
} catch (error) {
|
|
console.error('🎙️ Failed to start ASR:', error);
|
|
sourceCaption.querySelector('.text').textContent = '[ASR failed to start]';
|
|
}
|
|
}
|
|
|
|
function handleASRMessage(msg) {
|
|
const type = msg.type;
|
|
|
|
switch (type) {
|
|
case 'session.created':
|
|
console.log('🎙️ ASR session created:', msg.session?.id);
|
|
break;
|
|
|
|
case 'session.updated':
|
|
console.log('🎙️ ASR session updated');
|
|
break;
|
|
|
|
case 'input_audio_buffer.speech_started':
|
|
console.log('🎙️ Speech started (VAD)');
|
|
sourceCaption.style.opacity = '1';
|
|
break;
|
|
|
|
case 'input_audio_buffer.speech_stopped':
|
|
console.log('🎙️ Speech stopped (VAD)');
|
|
break;
|
|
|
|
case 'conversation.item.input_audio_transcription.text':
|
|
// Interim transcription
|
|
const interimText = msg.text || '';
|
|
if (interimText) {
|
|
sourceInterimText = interimText;
|
|
sourceCaption.querySelector('.text').textContent = sourceFinalText + interimText;
|
|
sourceCaption.style.opacity = '1';
|
|
console.log('🎙️ Interim:', interimText.substring(0, 50));
|
|
}
|
|
break;
|
|
|
|
case 'conversation.item.input_audio_transcription.completed':
|
|
// Final transcription
|
|
const finalText = msg.transcript || '';
|
|
if (finalText) {
|
|
sourceFinalText = finalText;
|
|
sourceInterimText = '';
|
|
sourceCaption.querySelector('.text').textContent = finalText;
|
|
sourceCaption.style.opacity = '1';
|
|
console.log('🎙️ Final:', finalText.substring(0, 50));
|
|
}
|
|
break;
|
|
|
|
case 'error':
|
|
console.error('🎙️ ASR error:', msg.error);
|
|
sourceCaption.querySelector('.text').textContent = `[ASR error: ${msg.error?.message || 'Unknown'}]`;
|
|
break;
|
|
|
|
default:
|
|
// Ignore other message types
|
|
break;
|
|
}
|
|
}
|
|
|
|
function stopHanzoASR() {
|
|
// Close WebSocket connection
|
|
if (asrWebSocket) {
|
|
asrWebSocket.onclose = null; // Prevent reconnect
|
|
asrWebSocket.onerror = null;
|
|
asrWebSocket.onmessage = null;
|
|
asrWebSocket.close();
|
|
asrWebSocket = null;
|
|
console.log('🎙️ ASR WebSocket closed');
|
|
}
|
|
|
|
// Disconnect audio processing
|
|
if (asrAudioProcessor) {
|
|
asrAudioProcessor.disconnect();
|
|
asrAudioProcessor = null;
|
|
}
|
|
if (asrSourceNode) {
|
|
asrSourceNode.disconnect();
|
|
asrSourceNode = null;
|
|
}
|
|
if (asrAudioContext) {
|
|
asrAudioContext.close();
|
|
asrAudioContext = null;
|
|
}
|
|
|
|
sourceFinalText = '';
|
|
sourceInterimText = '';
|
|
}
|
|
|
|
// Handle data channel messages (audio and transcripts from server)
|
|
function handleDataChannelMessage(msg) {
|
|
const type = msg.type;
|
|
|
|
switch(type) {
|
|
case 'response.audio.delta':
|
|
// Translated audio is handled via WebRTC track, not data channel
|
|
// This avoids dual playback causing double audio or silence
|
|
// Only log for debugging - do NOT play audio here
|
|
if (msg.delta) {
|
|
console.log('📡 Audio delta received via data channel (handled by WebRTC track)');
|
|
}
|
|
break;
|
|
|
|
case 'response.audio_transcript.text':
|
|
case 'response.audio_transcript.delta':
|
|
// Update translated caption
|
|
if (msg.text) {
|
|
targetCaption.querySelector('.text').textContent = msg.text;
|
|
targetCaption.style.opacity = '1';
|
|
console.log('📝 Translation:', msg.text);
|
|
}
|
|
break;
|
|
|
|
case 'input_audio_transcript.text':
|
|
case 'input_audio_transcript.delta':
|
|
// Update source caption (from main translation API)
|
|
if (msg.text) {
|
|
sourceCaption.querySelector('.text').textContent = msg.text;
|
|
sourceCaption.style.opacity = '1';
|
|
console.log('🎤 Source:', msg.text);
|
|
}
|
|
break;
|
|
|
|
default:
|
|
console.log('📡 Data channel message:', type, msg);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Update caption labels with current language names
|
|
function updateCaptionLabels() {
|
|
const srcLang = document.getElementById('srcLanguage').value;
|
|
const targetLang = document.getElementById('targetLanguage').value;
|
|
const sourceLangLabel = document.getElementById('sourceLangLabel');
|
|
const targetLangLabel = document.getElementById('targetLangLabel');
|
|
|
|
if (sourceLangLabel) {
|
|
sourceLangLabel.textContent = `SOURCE (${srcLang})`;
|
|
}
|
|
if (targetLangLabel) {
|
|
targetLangLabel.textContent = `TRANSLATED (${targetLang})`;
|
|
}
|
|
}
|
|
|
|
function stopStream() {
|
|
isStreaming = false;
|
|
|
|
// Stop broadcast outputs
|
|
if (typeof stopBroadcastOutputs === 'function') {
|
|
stopBroadcastOutputs();
|
|
}
|
|
|
|
// Stop Hanzo ASR
|
|
stopHanzoASR();
|
|
|
|
if (mediaStream) {
|
|
mediaStream.getTracks().forEach(track => track.stop());
|
|
mediaStream = null;
|
|
}
|
|
|
|
if (peerConnection) {
|
|
peerConnection.close();
|
|
peerConnection = null;
|
|
}
|
|
|
|
// Clean up audio mixer
|
|
sourceGainNode = null;
|
|
translatedGainNode = null;
|
|
mixerDestination = null;
|
|
|
|
if (audioContext) {
|
|
audioContext.close();
|
|
audioContext = null;
|
|
}
|
|
|
|
videoElement.srcObject = null;
|
|
|
|
// Clear source caption
|
|
sourceCaption.querySelector('.text').textContent = '';
|
|
|
|
// Reset latency displays
|
|
latencyHistory = [];
|
|
latencyDisplay.textContent = '--ms';
|
|
e2eLatency.textContent = '--ms';
|
|
avgLatencyEl.textContent = '--ms';
|
|
const headerAvg = document.getElementById('headerAvgLatency');
|
|
if (headerAvg) headerAvg.textContent = '--ms';
|
|
|
|
updateStatus('disconnected');
|
|
startBtn.disabled = false;
|
|
stopBtn.disabled = true;
|
|
|
|
// Hide share panel and disable share button
|
|
document.getElementById('shareBtn').disabled = true;
|
|
document.getElementById('sharePanel').style.display = 'none';
|
|
}
|
|
|
|
function shareSession() {
|
|
if (!webrtcId) {
|
|
alert('Start a session first to generate a share link');
|
|
return;
|
|
}
|
|
|
|
const shareUrl = `${window.location.origin}/view?session=${webrtcId}`;
|
|
document.getElementById('shareLink').value = shareUrl;
|
|
document.getElementById('sharePanel').style.display = 'block';
|
|
}
|
|
|
|
function copyShareLink() {
|
|
const shareLink = document.getElementById('shareLink');
|
|
shareLink.select();
|
|
document.execCommand('copy');
|
|
|
|
// Show feedback
|
|
showNotification('Share link copied to clipboard!');
|
|
}
|
|
|
|
function updateStatus(status) {
|
|
connectionStatus.className = 'status-dot';
|
|
audioStatus.className = 'status-dot';
|
|
|
|
switch (status) {
|
|
case 'connecting':
|
|
connectionText.textContent = 'Connecting...';
|
|
connectionStatus.classList.add('warning');
|
|
break;
|
|
case 'live':
|
|
connectionText.textContent = 'LIVE';
|
|
connectionStatus.classList.add('live');
|
|
break;
|
|
case 'error':
|
|
connectionText.textContent = 'Error';
|
|
break;
|
|
default:
|
|
connectionText.textContent = 'Disconnected';
|
|
}
|
|
}
|
|
|
|
window.toggleFullscreen = function() {
|
|
const videoSection = document.querySelector('.video-section');
|
|
if (document.fullscreenElement) {
|
|
document.exitFullscreen();
|
|
} else {
|
|
videoSection.requestFullscreen();
|
|
}
|
|
};
|
|
|
|
window.copyEndpoint = function(type) {
|
|
const url = type === 'webrtc'
|
|
? document.getElementById('webrtcUrl').textContent
|
|
: document.getElementById('sseUrl').textContent.replace('...', webrtcId || 'SESSION_ID');
|
|
navigator.clipboard.writeText(url);
|
|
alert('Copied to clipboard!');
|
|
};
|
|
|
|
window.startStream = startStream;
|
|
window.stopStream = stopStream;
|
|
window.shareSession = shareSession;
|
|
window.copyShareLink = copyShareLink;
|
|
|
|
// ==========================================
|
|
// Language Change Handler
|
|
// ==========================================
|
|
window.onLanguageChange = function() {
|
|
// If currently streaming, restart with new language settings
|
|
if (isStreaming) {
|
|
const shouldRestart = confirm('Language changed. Restart stream with new settings?');
|
|
if (shouldRestart) {
|
|
stopStream();
|
|
setTimeout(() => startStream(), 500);
|
|
}
|
|
}
|
|
};
|
|
|
|
// ==========================================
|
|
// Voice Change Handler
|
|
// ==========================================
|
|
window.onVoiceChange = function() {
|
|
// If currently streaming, restart with new voice
|
|
if (isStreaming) {
|
|
const shouldRestart = confirm('Voice changed. Restart stream with new voice?');
|
|
if (shouldRestart) {
|
|
stopStream();
|
|
setTimeout(() => startStream(), 500);
|
|
}
|
|
}
|
|
};
|
|
|
|
// ==========================================
|
|
// Audio Mixer Functions
|
|
// ==========================================
|
|
|
|
window.setAudioMode = function(mode) {
|
|
audioMode = mode;
|
|
|
|
// Update toggle button states
|
|
document.getElementById('modeSource').classList.toggle('active', mode === 'source');
|
|
document.getElementById('modeMix').classList.toggle('active', mode === 'mix');
|
|
document.getElementById('modeTranslated').classList.toggle('active', mode === 'translated');
|
|
|
|
// Apply mode to gain nodes
|
|
applyAudioMode();
|
|
};
|
|
|
|
function applyAudioMode() {
|
|
if (!sourceGainNode || !translatedGainNode) return;
|
|
|
|
switch (audioMode) {
|
|
case 'source':
|
|
sourceGainNode.gain.value = sourceMuted ? 0 : sourceVolume;
|
|
translatedGainNode.gain.value = 0;
|
|
break;
|
|
case 'translated':
|
|
sourceGainNode.gain.value = 0;
|
|
translatedGainNode.gain.value = translatedMuted ? 0 : translatedVolume;
|
|
break;
|
|
case 'mix':
|
|
default:
|
|
sourceGainNode.gain.value = sourceMuted ? 0 : sourceVolume;
|
|
translatedGainNode.gain.value = translatedMuted ? 0 : translatedVolume;
|
|
break;
|
|
}
|
|
}
|
|
|
|
window.updateVolume = function(channel) {
|
|
const slider = document.getElementById(channel + 'Volume');
|
|
const display = document.getElementById(channel + 'Value');
|
|
const value = parseInt(slider.value) / 100;
|
|
|
|
display.textContent = slider.value + '%';
|
|
|
|
if (channel === 'source') {
|
|
sourceVolume = value;
|
|
if (sourceGainNode && !sourceMuted && audioMode !== 'translated') {
|
|
sourceGainNode.gain.value = value;
|
|
}
|
|
} else {
|
|
translatedVolume = value;
|
|
if (translatedGainNode && !translatedMuted && audioMode !== 'source') {
|
|
translatedGainNode.gain.value = value;
|
|
}
|
|
}
|
|
};
|
|
|
|
window.toggleMute = function(channel) {
|
|
const btn = document.getElementById(channel + 'Mute');
|
|
|
|
if (channel === 'source') {
|
|
sourceMuted = !sourceMuted;
|
|
btn.classList.toggle('muted', sourceMuted);
|
|
btn.setAttribute('aria-pressed', sourceMuted ? 'true' : 'false');
|
|
} else {
|
|
translatedMuted = !translatedMuted;
|
|
btn.classList.toggle('muted', translatedMuted);
|
|
btn.setAttribute('aria-pressed', translatedMuted ? 'true' : 'false');
|
|
}
|
|
|
|
applyAudioMode();
|
|
};
|
|
|
|
// Setup audio mixing with Web Audio API
|
|
function setupAudioMixer(sourceStream, translatedStream) {
|
|
if (!audioContext) {
|
|
audioContext = new AudioContext();
|
|
}
|
|
|
|
// Resume audio context if suspended (browser autoplay policy)
|
|
if (audioContext.state === 'suspended') {
|
|
audioContext.resume().then(() => {
|
|
console.log('🔊 AudioContext resumed');
|
|
});
|
|
}
|
|
|
|
// Create gain nodes for volume control
|
|
sourceGainNode = audioContext.createGain();
|
|
translatedGainNode = audioContext.createGain();
|
|
|
|
// Create destination (speakers)
|
|
mixerDestination = audioContext.destination;
|
|
|
|
// Connect source audio (from local mic/screen)
|
|
if (sourceStream) {
|
|
const sourceNode = audioContext.createMediaStreamSource(sourceStream);
|
|
sourceNode.connect(sourceGainNode);
|
|
sourceGainNode.connect(mixerDestination);
|
|
console.log('🔊 Source audio connected to mixer');
|
|
}
|
|
|
|
// Connect translated audio
|
|
if (translatedStream) {
|
|
const translatedNode = audioContext.createMediaStreamSource(translatedStream);
|
|
translatedNode.connect(translatedGainNode);
|
|
translatedGainNode.connect(mixerDestination);
|
|
console.log('🔊 Translated audio connected to mixer');
|
|
|
|
// Also setup visualization on translated audio
|
|
audioAnalyser = audioContext.createAnalyser();
|
|
translatedNode.connect(audioAnalyser);
|
|
} else {
|
|
console.warn('⚠️ No translated stream received for audio mixer');
|
|
}
|
|
|
|
// Apply initial mode
|
|
applyAudioMode();
|
|
console.log(`🔊 Audio mode: ${audioMode}, translated gain: ${translatedGainNode?.gain.value}`);
|
|
}
|
|
|
|
// Keyboard shortcuts for control room
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'F11') {
|
|
e.preventDefault();
|
|
toggleFullscreen();
|
|
}
|
|
if (e.key === 'm' && e.ctrlKey) {
|
|
document.body.classList.toggle('monitor-mode');
|
|
}
|
|
// Ctrl+Shift+A for advanced mode toggle
|
|
if (e.key === 'A' && e.ctrlKey && e.shiftKey) {
|
|
e.preventDefault();
|
|
toggleAdvancedMode();
|
|
}
|
|
});
|
|
|
|
// ==========================================
|
|
// Advanced Mode Toggle (Progressive Disclosure)
|
|
// ==========================================
|
|
const ADVANCED_MODE_KEY = 'zenLiveAdvancedMode';
|
|
|
|
window.toggleAdvancedMode = function() {
|
|
const isAdvanced = document.body.classList.toggle('advanced-mode');
|
|
const toggle = document.getElementById('advancedToggle');
|
|
const icon = document.getElementById('modeIcon');
|
|
const label = document.getElementById('modeLabel');
|
|
|
|
// Update button state
|
|
toggle.classList.toggle('active', isAdvanced);
|
|
icon.textContent = isAdvanced ? '-' : '+';
|
|
label.textContent = isAdvanced ? 'Simple' : 'Advanced';
|
|
|
|
// Save preference
|
|
try {
|
|
localStorage.setItem(ADVANCED_MODE_KEY, isAdvanced ? 'true' : 'false');
|
|
} catch (e) {
|
|
console.warn('Could not save advanced mode preference:', e);
|
|
}
|
|
|
|
// Show notification
|
|
showNotification(isAdvanced ? 'Advanced mode enabled' : 'Simple mode enabled');
|
|
};
|
|
|
|
// Load advanced mode preference
|
|
function loadAdvancedModePreference() {
|
|
try {
|
|
const saved = localStorage.getItem(ADVANCED_MODE_KEY);
|
|
if (saved === 'true') {
|
|
document.body.classList.add('advanced-mode');
|
|
const toggle = document.getElementById('advancedToggle');
|
|
const icon = document.getElementById('modeIcon');
|
|
const label = document.getElementById('modeLabel');
|
|
toggle.classList.add('active');
|
|
icon.textContent = '-';
|
|
label.textContent = 'Simple';
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not load advanced mode preference:', e);
|
|
}
|
|
}
|
|
|
|
// Initialize advanced mode on load
|
|
loadAdvancedModePreference();
|
|
|
|
// ==========================================
|
|
// Broadcast Settings - localStorage
|
|
// ==========================================
|
|
|
|
const STORAGE_KEY = 'zenLiveBroadcastSettings';
|
|
|
|
// Toggle settings panel
|
|
window.toggleBroadcastSettings = function() {
|
|
const panel = document.getElementById('broadcastSettingsPanel');
|
|
const arrow = document.getElementById('settingsArrow');
|
|
if (panel.style.display === 'none') {
|
|
panel.style.display = 'block';
|
|
arrow.textContent = '▲';
|
|
} else {
|
|
panel.style.display = 'none';
|
|
arrow.textContent = '▼';
|
|
}
|
|
};
|
|
|
|
// Save all settings to localStorage
|
|
window.saveSettings = function() {
|
|
const settings = {
|
|
// Main stream settings
|
|
srcLanguage: document.getElementById('srcLanguage').value,
|
|
targetLanguage: document.getElementById('targetLanguage').value,
|
|
voice: document.getElementById('voiceSelect').value,
|
|
inputSource: document.getElementById('inputSource').value,
|
|
srtInputUrl: document.getElementById('srtUrl').value,
|
|
// Broadcast output settings
|
|
srtOutputUrl: document.getElementById('srtOutputUrl').value,
|
|
rtmpOutputUrl: document.getElementById('rtmpOutputUrl').value,
|
|
ndiOutputName: document.getElementById('ndiOutputName').value,
|
|
autoReconnect: document.getElementById('autoReconnect').checked,
|
|
enableTranscriptOverlay: document.getElementById('enableTranscriptOverlay').checked,
|
|
// Metadata
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
|
showNotification('Settings saved to browser');
|
|
} catch (e) {
|
|
console.error('Failed to save settings:', e);
|
|
alert('Failed to save settings: ' + e.message);
|
|
}
|
|
};
|
|
|
|
// Load settings from localStorage
|
|
function loadSettings() {
|
|
try {
|
|
const stored = localStorage.getItem(STORAGE_KEY);
|
|
if (!stored) return;
|
|
|
|
const settings = JSON.parse(stored);
|
|
|
|
// Main stream settings
|
|
if (settings.srcLanguage) document.getElementById('srcLanguage').value = settings.srcLanguage;
|
|
if (settings.targetLanguage) document.getElementById('targetLanguage').value = settings.targetLanguage;
|
|
if (settings.voice) document.getElementById('voiceSelect').value = settings.voice;
|
|
if (settings.inputSource) {
|
|
document.getElementById('inputSource').value = settings.inputSource;
|
|
srtInputGroup.style.display = settings.inputSource === 'srt' ? 'block' : 'none';
|
|
}
|
|
if (settings.srtInputUrl) document.getElementById('srtUrl').value = settings.srtInputUrl;
|
|
|
|
// Broadcast output settings
|
|
if (settings.srtOutputUrl) document.getElementById('srtOutputUrl').value = settings.srtOutputUrl;
|
|
if (settings.rtmpOutputUrl) document.getElementById('rtmpOutputUrl').value = settings.rtmpOutputUrl;
|
|
if (settings.ndiOutputName) document.getElementById('ndiOutputName').value = settings.ndiOutputName;
|
|
if (typeof settings.autoReconnect === 'boolean') document.getElementById('autoReconnect').checked = settings.autoReconnect;
|
|
if (typeof settings.enableTranscriptOverlay === 'boolean') document.getElementById('enableTranscriptOverlay').checked = settings.enableTranscriptOverlay;
|
|
|
|
console.log('Settings loaded from localStorage (saved:', settings.savedAt, ')');
|
|
} catch (e) {
|
|
console.error('Failed to load settings:', e);
|
|
}
|
|
}
|
|
|
|
// Clear all settings
|
|
window.clearSettings = function() {
|
|
if (confirm('Clear all saved broadcast settings?')) {
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
// Reset form fields
|
|
document.getElementById('srtOutputUrl').value = '';
|
|
document.getElementById('rtmpOutputUrl').value = '';
|
|
document.getElementById('ndiOutputName').value = '';
|
|
document.getElementById('autoReconnect').checked = true;
|
|
document.getElementById('enableTranscriptOverlay').checked = false;
|
|
showNotification('Settings cleared');
|
|
}
|
|
};
|
|
|
|
// Get broadcast settings for external use
|
|
window.getBroadcastSettings = function() {
|
|
return {
|
|
srtOutputUrl: document.getElementById('srtOutputUrl').value,
|
|
rtmpOutputUrl: document.getElementById('rtmpOutputUrl').value,
|
|
ndiOutputName: document.getElementById('ndiOutputName').value,
|
|
autoReconnect: document.getElementById('autoReconnect').checked,
|
|
enableTranscriptOverlay: document.getElementById('enableTranscriptOverlay').checked
|
|
};
|
|
};
|
|
|
|
// Active streaming outputs (track for cleanup)
|
|
let activeOutputs = {};
|
|
|
|
// Start streaming outputs based on settings
|
|
window.startBroadcastOutputs = async function() {
|
|
const settings = getBroadcastSettings();
|
|
const sessionId = webrtcId || 'default';
|
|
|
|
// Start SRT output if configured
|
|
if (settings.srtOutputUrl) {
|
|
try {
|
|
const resp = await fetch(`/api/streaming/output/start?session_id=${sessionId}&output_type=srt&output_url=${encodeURIComponent(settings.srtOutputUrl)}`, {
|
|
method: 'POST'
|
|
});
|
|
const data = await resp.json();
|
|
if (resp.ok) {
|
|
activeOutputs.srt = data.output_id;
|
|
console.log('📤 SRT output started:', data);
|
|
showNotification('SRT output started');
|
|
} else {
|
|
console.error('Failed to start SRT output:', data);
|
|
showNotification('SRT output failed: ' + (data.detail || 'Unknown error'));
|
|
}
|
|
} catch (e) {
|
|
console.error('SRT output error:', e);
|
|
}
|
|
}
|
|
|
|
// Start RTMP output if configured
|
|
if (settings.rtmpOutputUrl) {
|
|
try {
|
|
const resp = await fetch(`/api/streaming/output/start?session_id=${sessionId}&output_type=rtmp&output_url=${encodeURIComponent(settings.rtmpOutputUrl)}`, {
|
|
method: 'POST'
|
|
});
|
|
const data = await resp.json();
|
|
if (resp.ok) {
|
|
activeOutputs.rtmp = data.output_id;
|
|
console.log('📤 RTMP output started:', data);
|
|
showNotification('RTMP output started');
|
|
} else {
|
|
console.error('Failed to start RTMP output:', data);
|
|
}
|
|
} catch (e) {
|
|
console.error('RTMP output error:', e);
|
|
}
|
|
}
|
|
|
|
// Start NDI output if configured
|
|
if (settings.ndiOutputName) {
|
|
try {
|
|
const resp = await fetch(`/api/streaming/output/start?session_id=${sessionId}&output_type=ndi&output_url=${encodeURIComponent(settings.ndiOutputName)}`, {
|
|
method: 'POST'
|
|
});
|
|
const data = await resp.json();
|
|
if (resp.ok) {
|
|
activeOutputs.ndi = data.output_id;
|
|
console.log('📤 NDI output started:', data);
|
|
showNotification('NDI output started');
|
|
} else {
|
|
console.error('Failed to start NDI output:', data);
|
|
}
|
|
} catch (e) {
|
|
console.error('NDI output error:', e);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Stop all streaming outputs
|
|
window.stopBroadcastOutputs = async function() {
|
|
for (const [type, outputId] of Object.entries(activeOutputs)) {
|
|
if (outputId) {
|
|
try {
|
|
await fetch(`/api/streaming/output/${outputId}`, { method: 'DELETE' });
|
|
console.log(`📤 Stopped ${type} output: ${outputId}`);
|
|
} catch (e) {
|
|
console.error(`Failed to stop ${type} output:`, e);
|
|
}
|
|
}
|
|
}
|
|
activeOutputs = {};
|
|
showNotification('Broadcast outputs stopped');
|
|
};
|
|
|
|
// Check streaming status
|
|
window.checkStreamingStatus = async function() {
|
|
try {
|
|
const resp = await fetch('/api/streaming/status');
|
|
const data = await resp.json();
|
|
console.log('📊 Streaming status:', data);
|
|
return data;
|
|
} catch (e) {
|
|
console.error('Failed to get streaming status:', e);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Simple notification function
|
|
function showNotification(message) {
|
|
const notification = document.createElement('div');
|
|
notification.style.cssText = `
|
|
position: fixed;
|
|
bottom: 80px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: #10b981;
|
|
color: white;
|
|
padding: 12px 24px;
|
|
border-radius: 6px;
|
|
font-weight: 600;
|
|
z-index: 10000;
|
|
animation: fadeInOut 2s ease-in-out;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
`;
|
|
notification.textContent = message;
|
|
document.body.appendChild(notification);
|
|
setTimeout(() => notification.remove(), 2000);
|
|
}
|
|
|
|
// Add fadeInOut animation
|
|
const styleSheet = document.createElement('style');
|
|
styleSheet.textContent = `
|
|
@keyframes fadeInOut {
|
|
0% { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
|
20% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
80% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
100% { opacity: 0; transform: translateX(-50%) translateY(-20px); }
|
|
}
|
|
`;
|
|
document.head.appendChild(styleSheet);
|
|
|
|
// Load settings on page load
|
|
loadSettings();
|
|
</script>
|
|
</body>
|
|
</html>
|