303 lines
10 KiB
HTML
303 lines
10 KiB
HTML
<!--
|
|
Copyright 2025 Google LLC
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
-->
|
|
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Project Livewire</title>
|
|
<link rel="stylesheet" href="styles/style.css">
|
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
|
|
|
|
<!-- Favicon -->
|
|
<link rel="icon" type="image/x-icon" href="assets/favicon.ico">
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>Project Livewire</h1>
|
|
<p>Speak into your microphone and optionally share your webcam or screen to engage in a multimedia conversation.</p>
|
|
</div>
|
|
|
|
<div class="input-container">
|
|
<button id="micButton" disabled class="action-button">
|
|
<span class="material-symbols-outlined">mic</span>
|
|
</button>
|
|
<button id="webcamButton" class="action-button">
|
|
<span class="material-symbols-outlined">videocam</span>
|
|
</button>
|
|
<button id="screenButton" class="action-button">
|
|
<span class="material-symbols-outlined">present_to_all</span>
|
|
</button>
|
|
<div class="text-input-container">
|
|
<input type="text" id="textInput" placeholder="Type your message..." class="text-input">
|
|
<button id="sendButton" class="action-button">
|
|
<span class="material-symbols-outlined">send</span>
|
|
</button>
|
|
<button id="interruptButton" class="action-button" style="display: none;">
|
|
<span class="material-symbols-outlined">cancel</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="video-container">
|
|
<video id="videoPreview" autoplay playsinline class="hidden"></video>
|
|
</div>
|
|
|
|
<div id="output"></div>
|
|
|
|
<!-- Load EventEmitter3 first -->
|
|
<script src="https://cdn.jsdelivr.net/npm/eventemitter3@5.0.1/dist/eventemitter3.umd.min.js"></script>
|
|
|
|
<script type="module">
|
|
import { AudioRecorder } from './src/audio/audio-recorder.js';
|
|
import { AudioStreamer } from './src/audio/audio-streamer.js';
|
|
import { MediaHandler } from './src/media/media-handler.js';
|
|
import { GeminiAPI } from './src/api/gemini-api.js';
|
|
import { base64ToArrayBuffer } from './src/utils/utils.js';
|
|
|
|
// Initialize components
|
|
const output = document.getElementById('output');
|
|
const audioRecorder = new AudioRecorder();
|
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 24000 });
|
|
const audioStreamer = new AudioStreamer(audioContext);
|
|
const mediaHandler = new MediaHandler();
|
|
const wsEndpoint = 'ws://localhost:8081';
|
|
const api = new GeminiAPI(wsEndpoint);
|
|
|
|
let isRecording = false;
|
|
let hasShownSpeakingMessage = false;
|
|
let currentTurn = 0;
|
|
let lastAudioTurn = -1;
|
|
|
|
// Initialize media handler
|
|
mediaHandler.initialize(document.getElementById('videoPreview'));
|
|
|
|
// Set up API handlers
|
|
api.onReady = () => {
|
|
document.getElementById('micButton').disabled = false;
|
|
};
|
|
|
|
api.onAudioData = async (audioData) => {
|
|
try {
|
|
if (!api.isSpeaking || lastAudioTurn !== currentTurn) {
|
|
logMessage('Gemini: Speaking...');
|
|
api.isSpeaking = true;
|
|
lastAudioTurn = currentTurn;
|
|
document.getElementById('interruptButton').style.display = 'inline-block';
|
|
}
|
|
const arrayBuffer = base64ToArrayBuffer(audioData);
|
|
audioStreamer.addPCM16(new Uint8Array(arrayBuffer));
|
|
audioStreamer.resume();
|
|
} catch (error) {
|
|
console.error('Error playing audio:', error);
|
|
}
|
|
};
|
|
|
|
api.onTextContent = (text) => {
|
|
if (text.trim()) {
|
|
logMessage('Gemini: ' + text);
|
|
}
|
|
};
|
|
|
|
api.onTurnComplete = () => {
|
|
logMessage('Gemini: Finished speaking');
|
|
api.isSpeaking = false; // Reset speaking state
|
|
audioStreamer.complete();
|
|
document.getElementById('interruptButton').style.display = 'none';
|
|
};
|
|
|
|
// Add interruption handler
|
|
api.onInterrupted = (data) => {
|
|
logMessage('Gemini: Response interrupted');
|
|
api.isSpeaking = false;
|
|
audioStreamer.stop(); // Stop current playback and clear queue
|
|
document.getElementById('interruptButton').style.display = 'none';
|
|
|
|
// Show visual feedback for interruption
|
|
const messageElement = document.createElement('p');
|
|
messageElement.className = 'interrupted-message';
|
|
messageElement.textContent = 'Response interrupted by user input';
|
|
output.appendChild(messageElement);
|
|
output.scrollTop = output.scrollHeight;
|
|
};
|
|
|
|
// Add function call and response handlers
|
|
api.onFunctionCall = (data) => {
|
|
logMessage('Function: ' + data.name);
|
|
logMessage('Parameters: ' + JSON.stringify(data.args, null, 2));
|
|
};
|
|
|
|
api.onFunctionResponse = (data) => {
|
|
logMessage('API Response: ' + JSON.stringify(data, null, 2));
|
|
};
|
|
|
|
// UI Event Handlers
|
|
async function startRecording() {
|
|
try {
|
|
// If model is speaking, treat this as an interruption
|
|
if (api.isSpeaking) {
|
|
audioStreamer.stop();
|
|
api.isSpeaking = false;
|
|
}
|
|
|
|
await audioContext.resume();
|
|
await audioRecorder.start();
|
|
hasShownSpeakingMessage = false;
|
|
currentTurn++;
|
|
|
|
audioRecorder.on('data', (base64Data) => {
|
|
if (!hasShownSpeakingMessage) {
|
|
logMessage('You: Speaking...');
|
|
hasShownSpeakingMessage = true;
|
|
}
|
|
api.sendAudioChunk(base64Data);
|
|
});
|
|
|
|
isRecording = true;
|
|
document.getElementById('micButton').innerHTML =
|
|
'<span class="material-symbols-outlined">stop</span>';
|
|
} catch (error) {
|
|
console.error('Error starting recording:', error);
|
|
logMessage('Error: ' + error.message);
|
|
}
|
|
}
|
|
|
|
function stopRecording() {
|
|
audioRecorder.stop();
|
|
isRecording = false;
|
|
hasShownSpeakingMessage = false;
|
|
document.getElementById('micButton').innerHTML =
|
|
'<span class="material-symbols-outlined">mic</span>';
|
|
logMessage('You: Recording stopped.');
|
|
|
|
// Stop video streams
|
|
mediaHandler.stopAll();
|
|
document.getElementById('webcamButton').innerHTML =
|
|
'<span class="material-symbols-outlined">videocam</span>';
|
|
document.getElementById('screenButton').innerHTML =
|
|
'<span class="material-symbols-outlined">present_to_all</span>';
|
|
|
|
api.sendEndMessage();
|
|
api.isSpeaking = false;
|
|
}
|
|
|
|
function logMessage(message) {
|
|
const messageElement = document.createElement('p');
|
|
|
|
// Add specific styling based on message content
|
|
if (message.startsWith('Function:')) {
|
|
messageElement.className = 'function-name';
|
|
} else if (message.startsWith('Parameters:')) {
|
|
messageElement.className = 'function-params';
|
|
} else if (message.startsWith('API Response:')) {
|
|
messageElement.className = 'api-response';
|
|
} else if (message.startsWith('Gemini:')) {
|
|
messageElement.className = 'gemini-message';
|
|
} else if (message.startsWith('You:')) {
|
|
messageElement.className = 'user-message';
|
|
}
|
|
|
|
messageElement.textContent = message;
|
|
output.appendChild(messageElement);
|
|
output.scrollTop = output.scrollHeight;
|
|
}
|
|
|
|
// Add function to send text message
|
|
function sendTextMessage() {
|
|
const textInput = document.getElementById('textInput');
|
|
const text = textInput.value.trim();
|
|
if (!text) return;
|
|
|
|
// Clear input
|
|
textInput.value = '';
|
|
|
|
// Log user message
|
|
logMessage('You: ' + text);
|
|
|
|
// Send text message
|
|
api.sendTextMessage(text);
|
|
}
|
|
|
|
// Set up button click handlers
|
|
document.getElementById('micButton').onclick = () => {
|
|
if (isRecording) {
|
|
stopRecording();
|
|
} else {
|
|
startRecording();
|
|
}
|
|
};
|
|
|
|
// Add send button handler
|
|
document.getElementById('sendButton').onclick = sendTextMessage;
|
|
|
|
// Add keypress handler for text input
|
|
document.getElementById('textInput').addEventListener('keypress', function(e) {
|
|
if (e.key === 'Enter') {
|
|
sendTextMessage();
|
|
}
|
|
});
|
|
|
|
document.getElementById('webcamButton').onclick = async () => {
|
|
if (mediaHandler.isWebcamActive) {
|
|
mediaHandler.stopAll();
|
|
document.getElementById('webcamButton').innerHTML =
|
|
'<span class="material-symbols-outlined">videocam</span>';
|
|
} else {
|
|
const success = await mediaHandler.startWebcam();
|
|
if (success) {
|
|
document.getElementById('webcamButton').innerHTML =
|
|
'<span class="material-symbols-outlined">videocam_off</span>';
|
|
mediaHandler.startFrameCapture((base64Image) => {
|
|
api.sendImage(base64Image);
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
document.getElementById('screenButton').onclick = async () => {
|
|
if (mediaHandler.isScreenActive) {
|
|
mediaHandler.stopAll();
|
|
document.getElementById('screenButton').innerHTML =
|
|
'<span class="material-symbols-outlined">present_to_all</span>';
|
|
} else {
|
|
const success = await mediaHandler.startScreenShare();
|
|
if (success) {
|
|
document.getElementById('screenButton').innerHTML =
|
|
'<span class="material-symbols-outlined">cancel_presentation</span>';
|
|
mediaHandler.startFrameCapture((base64Image) => {
|
|
api.sendImage(base64Image);
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
// Add CSS for interrupted message
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
.interrupted-message {
|
|
color: #ff6b6b;
|
|
font-style: italic;
|
|
margin: 4px 0;
|
|
padding: 4px 8px;
|
|
border-left: 3px solid #ff6b6b;
|
|
background-color: rgba(255, 107, 107, 0.1);
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
</script>
|
|
</body>
|
|
</html> |