659 lines
24 KiB
HTML
659 lines
24 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>WhisperLive Audio Streaming</title>
|
|
<style>
|
|
body {
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
color: #333;
|
|
}
|
|
|
|
.container {
|
|
background: rgba(255, 255, 255, 0.95);
|
|
padding: 30px;
|
|
border-radius: 15px;
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
h1 {
|
|
text-align: center;
|
|
color: #4a5568;
|
|
margin-bottom: 30px;
|
|
font-size: 2.5em;
|
|
background: linear-gradient(45deg, #667eea, #764ba2);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
background-clip: text;
|
|
}
|
|
|
|
.controls {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
button {
|
|
padding: 12px 24px;
|
|
font-size: 16px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
min-width: 120px;
|
|
}
|
|
|
|
.start-btn {
|
|
background: linear-gradient(45deg, #48bb78, #38a169);
|
|
color: white;
|
|
}
|
|
|
|
.start-btn:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 15px rgba(72, 187, 120, 0.4);
|
|
}
|
|
|
|
.stop-btn {
|
|
background: linear-gradient(45deg, #f56565, #e53e3e);
|
|
color: white;
|
|
}
|
|
|
|
.stop-btn:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 15px rgba(245, 101, 101, 0.4);
|
|
}
|
|
|
|
.status {
|
|
text-align: center;
|
|
padding: 15px;
|
|
margin: 20px 0;
|
|
border-radius: 8px;
|
|
font-weight: 600;
|
|
font-size: 18px;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.status.disconnected {
|
|
background: linear-gradient(45deg, #fed7d7, #feb2b2);
|
|
color: #c53030;
|
|
}
|
|
|
|
.status.connecting {
|
|
background: linear-gradient(45deg, #feebc8, #fbd38d);
|
|
color: #c05621;
|
|
}
|
|
|
|
.status.connected {
|
|
background: linear-gradient(45deg, #c6f6d5, #9ae6b4);
|
|
color: #2f855a;
|
|
}
|
|
|
|
.audio-visualizer {
|
|
width: 100%;
|
|
height: 100px;
|
|
background: #f7fafc;
|
|
border-radius: 8px;
|
|
margin: 20px 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 2px solid #e2e8f0;
|
|
}
|
|
|
|
.volume-bar {
|
|
width: 80%;
|
|
height: 20px;
|
|
background: #e2e8f0;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
.volume-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #48bb78, #38a169, #2f855a);
|
|
width: 0%;
|
|
transition: width 0.1s ease;
|
|
border-radius: 10px;
|
|
}
|
|
|
|
.transcription {
|
|
background: #f8f9fa;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
margin: 20px 0;
|
|
border-left: 4px solid #667eea;
|
|
min-height: 200px;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
font-size: 16px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.transcription h3 {
|
|
margin-top: 0;
|
|
color: #4a5568;
|
|
font-size: 20px;
|
|
}
|
|
|
|
.config {
|
|
background: #f8f9fa;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
margin: 20px 0;
|
|
border: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.config h3 {
|
|
margin-top: 0;
|
|
color: #4a5568;
|
|
}
|
|
|
|
.config label {
|
|
display: block;
|
|
margin: 10px 0 5px 0;
|
|
font-weight: 600;
|
|
color: #4a5568;
|
|
}
|
|
|
|
.config input, .config select {
|
|
width: 100%;
|
|
padding: 8px 12px;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.error {
|
|
background: #fed7d7;
|
|
color: #c53030;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
margin: 20px 0;
|
|
border-left: 4px solid #f56565;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
|
|
.recording {
|
|
animation: pulse 1s infinite;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>🎤 WhisperLive Audio Streaming</h1>
|
|
|
|
<div class="config">
|
|
<h3>Configuration</h3>
|
|
<label for="wsUrl">WebSocket URL:</label>
|
|
<!-- // Use wss://yourserver.com/whisper-ws in production -->
|
|
<input type="text" id="wsUrl" value="ws://localhost:9090" placeholder="ws://localhost:9090">
|
|
|
|
<label for="sampleRate">Sample Rate:</label>
|
|
<select id="sampleRate">
|
|
<option value="16000" selected>16000 Hz</option>
|
|
<option value="44100">44100 Hz</option>
|
|
<option value="48000">48000 Hz</option>
|
|
</select>
|
|
|
|
<label for="language">Language:</label>
|
|
<select id="language">
|
|
<option value="en" selected>English</option>
|
|
<option value="es">Spanish</option>
|
|
<option value="fr">French</option>
|
|
<option value="de">German</option>
|
|
<option value="it">Italian</option>
|
|
<option value="pt">Portuguese</option>
|
|
<option value="auto">Auto-detect</option>
|
|
</select>
|
|
|
|
<label for="audioMethod">Audio Processing Method:</label>
|
|
<select id="audioMethod">
|
|
<option value="webaudio" selected>Web Audio API (Direct PCM)</option>
|
|
<option value="mediarecorder">MediaRecorder (Encoded Audio)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="controls">
|
|
<button id="startBtn" class="start-btn">Start Recording</button>
|
|
<button id="stopBtn" class="stop-btn" disabled>Stop Recording</button>
|
|
</div>
|
|
|
|
<div id="status" class="status disconnected">Disconnected</div>
|
|
|
|
<div class="audio-visualizer">
|
|
<div class="volume-bar">
|
|
<div id="volumeFill" class="volume-fill"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="transcription">
|
|
<h3>Live Transcription</h3>
|
|
<div id="transcriptionText">Click "Start Recording" to begin transcription...</div>
|
|
</div>
|
|
|
|
<div id="errorDiv" class="error" style="display: none;"></div>
|
|
</div>
|
|
|
|
<script>
|
|
class WhisperLiveClient {
|
|
constructor() {
|
|
this.websocket = null;
|
|
this.mediaRecorder = null;
|
|
this.audioStream = null;
|
|
this.audioContext = null;
|
|
this.analyser = null;
|
|
this.dataArray = null;
|
|
this.isRecording = false;
|
|
this.isConnected = false;
|
|
this.scriptProcessor = null;
|
|
this.useWebAudioAPI = true; // Use Web Audio API for direct PCM processing
|
|
|
|
this.config = {
|
|
sampleRate: 16000,
|
|
language: 'en',
|
|
wsUrl: 'ws://localhost:9090' // Use wss://yourserver.com/whisper-ws in production
|
|
};
|
|
|
|
this.setupEventListeners();
|
|
this.updateStatus('disconnected', `Disconnected from ${this.config.wsUrl}`);
|
|
}
|
|
|
|
setupEventListeners() {
|
|
document.getElementById('startBtn').addEventListener('click', () => this.startRecording());
|
|
document.getElementById('stopBtn').addEventListener('click', () => this.stopRecording());
|
|
|
|
// Configuration change listeners
|
|
document.getElementById('wsUrl').addEventListener('change', (e) => {
|
|
this.config.wsUrl = e.target.value;
|
|
});
|
|
|
|
document.getElementById('sampleRate').addEventListener('change', (e) => {
|
|
this.config.sampleRate = parseInt(e.target.value);
|
|
});
|
|
|
|
document.getElementById('language').addEventListener('change', (e) => {
|
|
this.config.language = e.target.value;
|
|
});
|
|
|
|
document.getElementById('audioMethod').addEventListener('change', (e) => {
|
|
this.useWebAudioAPI = e.target.value === 'webaudio';
|
|
});
|
|
}
|
|
|
|
async startRecording() {
|
|
try {
|
|
this.hideError();
|
|
|
|
// Connect to WebSocket first
|
|
await this.connectWebSocket();
|
|
|
|
// Get microphone access
|
|
await this.initializeAudio();
|
|
|
|
// Start recording based on the method used
|
|
if (this.useWebAudioAPI) {
|
|
// Web Audio API is already processing in real-time
|
|
this.isRecording = true;
|
|
} else {
|
|
// Start MediaRecorder with smaller chunks for better real-time performance
|
|
this.mediaRecorder.start(250); // Send data every 250ms
|
|
this.isRecording = true;
|
|
}
|
|
|
|
this.updateUI(true);
|
|
this.updateStatus('connected', 'Recording... 🎙️');
|
|
|
|
// Start audio visualization
|
|
this.startAudioVisualization();
|
|
|
|
} catch (error) {
|
|
this.showError('Failed to start recording: ' + error.message);
|
|
console.error('Error starting recording:', error);
|
|
}
|
|
}
|
|
|
|
async stopRecording() {
|
|
this.isRecording = false;
|
|
|
|
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
|
|
this.mediaRecorder.stop();
|
|
}
|
|
|
|
if (this.scriptProcessor) {
|
|
this.scriptProcessor.disconnect();
|
|
this.scriptProcessor = null;
|
|
}
|
|
|
|
if (this.audioStream) {
|
|
this.audioStream.getTracks().forEach(track => track.stop());
|
|
}
|
|
|
|
if (this.audioContext && this.audioContext.state !== 'closed') {
|
|
this.audioContext.close();
|
|
}
|
|
|
|
if (this.websocket) {
|
|
this.websocket.close();
|
|
}
|
|
|
|
this.updateUI(false);
|
|
this.updateStatus('disconnected', `Disconnected from ${this.config.wsUrl}`);
|
|
this.updateVolumeBar(0);
|
|
}
|
|
|
|
async connectWebSocket() {
|
|
return new Promise((resolve, reject) => {
|
|
console.log(`Connecting to WebSocket: ${this.config.wsUrl}`);
|
|
this.updateStatus('connecting', `Connecting to ${this.config.wsUrl}...`);
|
|
|
|
this.websocket = new WebSocket(this.config.wsUrl);
|
|
|
|
this.websocket.onopen = () => {
|
|
console.log(`WebSocket connected to: ${this.config.wsUrl}`);
|
|
this.isConnected = true;
|
|
|
|
// Generate a random UID
|
|
const uid = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
|
|
|
// Send configuration in the required format
|
|
const configMessage = {
|
|
type: 'config',
|
|
language: this.config.language,
|
|
use_vad: true,
|
|
max_clients: 4,
|
|
max_connection_time: 600,
|
|
uid: uid,
|
|
task: 'transcribe',
|
|
sample_rate: this.config.sampleRate,
|
|
send_last_n_segments: 10,
|
|
no_speech_thresh: 0.45,
|
|
clip_audio: false,
|
|
same_output_threshold: 10,
|
|
initial_prompt: '',
|
|
vad_parameters: {}
|
|
};
|
|
|
|
this.websocket.send(JSON.stringify(configMessage));
|
|
resolve();
|
|
};
|
|
|
|
this.websocket.onmessage = (event) => {
|
|
try {
|
|
const message = JSON.parse(event.data);
|
|
console.log('websocket message:', message);
|
|
this.handleWebSocketMessage(message);
|
|
} catch (error) {
|
|
console.error('Error parsing WebSocket message:', error);
|
|
}
|
|
};
|
|
|
|
this.websocket.onerror = (error) => {
|
|
console.error(`WebSocket error for ${this.config.wsUrl}:`, error);
|
|
this.showError(`WebSocket connection error for ${this.config.wsUrl}`);
|
|
reject(new Error('WebSocket connection failed'));
|
|
};
|
|
|
|
this.websocket.onclose = () => {
|
|
console.log(`WebSocket disconnected from: ${this.config.wsUrl}`);
|
|
this.isConnected = false;
|
|
if (this.isRecording) {
|
|
this.updateStatus('disconnected', `Connection lost to ${this.config.wsUrl}`);
|
|
}
|
|
};
|
|
|
|
// Timeout after 10 seconds
|
|
setTimeout(() => {
|
|
if (!this.isConnected) {
|
|
reject(new Error('WebSocket connection timeout'));
|
|
}
|
|
}, 10000);
|
|
});
|
|
}
|
|
|
|
async initializeAudio() {
|
|
try {
|
|
this.audioStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
sampleRate: this.config.sampleRate,
|
|
channelCount: 1,
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true
|
|
}
|
|
});
|
|
|
|
// Setup audio context for visualization and processing
|
|
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
|
sampleRate: this.config.sampleRate
|
|
});
|
|
|
|
this.analyser = this.audioContext.createAnalyser();
|
|
const source = this.audioContext.createMediaStreamSource(this.audioStream);
|
|
source.connect(this.analyser);
|
|
|
|
this.analyser.fftSize = 256;
|
|
this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
|
|
|
|
if (this.useWebAudioAPI) {
|
|
// Use Web Audio API for direct PCM processing
|
|
this.setupWebAudioProcessing(source);
|
|
} else {
|
|
// Use MediaRecorder as fallback
|
|
this.setupMediaRecorder();
|
|
}
|
|
|
|
} catch (error) {
|
|
throw new Error('Microphone access denied or not available: ' + error.message);
|
|
}
|
|
}
|
|
|
|
setupWebAudioProcessing(source) {
|
|
// Create a script processor for real-time audio processing
|
|
const bufferSize = 4096;
|
|
this.scriptProcessor = this.audioContext.createScriptProcessor(bufferSize, 1, 1);
|
|
|
|
this.scriptProcessor.onaudioprocess = (event) => {
|
|
if (!this.isRecording || !this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
|
|
const inputBuffer = event.inputBuffer;
|
|
const inputData = inputBuffer.getChannelData(0);
|
|
// Send PCM data directly
|
|
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
|
this.websocket.send(inputData.buffer)
|
|
}
|
|
};
|
|
|
|
// Connect the audio processing chain
|
|
source.connect(this.scriptProcessor);
|
|
this.scriptProcessor.connect(this.audioContext.destination);
|
|
}
|
|
|
|
setupMediaRecorder() {
|
|
// Try to use the best available audio format
|
|
const mimeTypes = [
|
|
'audio/webm;codecs=pcm',
|
|
'audio/wav',
|
|
'audio/webm;codecs=opus',
|
|
'audio/webm',
|
|
'audio/mp4'
|
|
];
|
|
|
|
let selectedMimeType = '';
|
|
for (const mimeType of mimeTypes) {
|
|
if (MediaRecorder.isTypeSupported(mimeType)) {
|
|
selectedMimeType = mimeType;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!selectedMimeType) {
|
|
throw new Error('No supported audio format found');
|
|
}
|
|
|
|
console.log('Using audio format:', selectedMimeType);
|
|
|
|
// Setup MediaRecorder
|
|
this.mediaRecorder = new MediaRecorder(this.audioStream, {
|
|
mimeType: selectedMimeType
|
|
});
|
|
|
|
this.mediaRecorder.ondataavailable = (event) => {
|
|
if (event.data.size > 0 && this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
|
this.sendAudioData(event.data);
|
|
}
|
|
};
|
|
}
|
|
|
|
async sendAudioData(audioBlob) {
|
|
try {
|
|
// Convert blob to ArrayBuffer
|
|
const arrayBuffer = await audioBlob.arrayBuffer();
|
|
|
|
// Decode audio data to get raw PCM
|
|
const audioData = await this.decodeAudioData(arrayBuffer);
|
|
|
|
// Send raw PCM data
|
|
this.websocket.send(audioData);
|
|
} catch (error) {
|
|
console.error('Error sending audio data:', error);
|
|
}
|
|
}
|
|
|
|
async decodeAudioData(arrayBuffer) {
|
|
try {
|
|
// Create a temporary audio context for decoding
|
|
const tempAudioContext = new (window.AudioContext || window.webkitAudioContext)({
|
|
sampleRate: this.config.sampleRate
|
|
});
|
|
|
|
// Decode the audio data
|
|
const audioBuffer = await tempAudioContext.decodeAudioData(arrayBuffer);
|
|
|
|
// Get the first channel (mono)
|
|
const channelData = audioBuffer.getChannelData(0);
|
|
|
|
// Close the temporary context
|
|
tempAudioContext.close();
|
|
|
|
return channelData.buffer;
|
|
} catch (error) {
|
|
console.error('Error decoding audio data:', error);
|
|
// Fallback: send raw data
|
|
return arrayBuffer;
|
|
}
|
|
}
|
|
|
|
handleWebSocketMessage(message) {
|
|
if(!message.segments) {
|
|
console.log('Ignoring non-transcription message:', message);
|
|
return;
|
|
}
|
|
let messageText = '';
|
|
message.segments.forEach((segment) => {
|
|
// each segment has start, end, text, and completed
|
|
//messageText += segment.completed ? segment.text : '';
|
|
messageText += segment.text;
|
|
});
|
|
this.updateTranscription({text: messageText});
|
|
}
|
|
|
|
updateTranscription(data) {
|
|
const transcriptionDiv = document.getElementById('transcriptionText');
|
|
const timestamp = new Date().toLocaleTimeString();
|
|
|
|
if (data.text && data.text.trim()) {
|
|
const newText = `[${timestamp}] ${data.text}\n`;
|
|
transcriptionDiv.innerHTML += newText;
|
|
transcriptionDiv.scrollTop = transcriptionDiv.scrollHeight;
|
|
}
|
|
}
|
|
|
|
startAudioVisualization() {
|
|
const animate = () => {
|
|
if (!this.isRecording) return;
|
|
|
|
this.analyser.getByteFrequencyData(this.dataArray);
|
|
|
|
// Calculate average volume
|
|
let sum = 0;
|
|
for (let i = 0; i < this.dataArray.length; i++) {
|
|
sum += this.dataArray[i];
|
|
}
|
|
const average = sum / this.dataArray.length;
|
|
const volume = (average / 255) * 100;
|
|
|
|
this.updateVolumeBar(volume);
|
|
|
|
requestAnimationFrame(animate);
|
|
};
|
|
|
|
animate();
|
|
}
|
|
|
|
updateVolumeBar(volume) {
|
|
const volumeFill = document.getElementById('volumeFill');
|
|
volumeFill.style.width = `${volume}%`;
|
|
}
|
|
|
|
updateUI(isRecording) {
|
|
document.getElementById('startBtn').disabled = isRecording;
|
|
document.getElementById('stopBtn').disabled = !isRecording;
|
|
|
|
const container = document.querySelector('.container');
|
|
if (isRecording) {
|
|
container.classList.add('recording');
|
|
} else {
|
|
container.classList.remove('recording');
|
|
}
|
|
|
|
// Disable configuration inputs during recording
|
|
document.getElementById('wsUrl').disabled = isRecording;
|
|
document.getElementById('sampleRate').disabled = isRecording;
|
|
document.getElementById('language').disabled = isRecording;
|
|
document.getElementById('audioMethod').disabled = isRecording;
|
|
}
|
|
|
|
updateStatus(statusClass, statusText) {
|
|
const statusDiv = document.getElementById('status');
|
|
statusDiv.className = `status ${statusClass}`;
|
|
statusDiv.textContent = statusText;
|
|
}
|
|
|
|
showError(message) {
|
|
const errorDiv = document.getElementById('errorDiv');
|
|
errorDiv.textContent = message;
|
|
errorDiv.style.display = 'block';
|
|
}
|
|
|
|
hideError() {
|
|
const errorDiv = document.getElementById('errorDiv');
|
|
errorDiv.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Initialize the client when the page loads
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
new WhisperLiveClient();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |