Add multi-speaker support for group conversations
Features: - Speaker management system with unique IDs and colors - Visual speaker selection with avatars and color coding - Automatic language detection per speaker - Real-time translation for all speakers' languages - Conversation history with speaker attribution - Export conversation as text file - Persistent speaker data in localStorage UI Components: - Speaker toolbar with add/remove controls - Active speaker indicators - Conversation view with color-coded messages - Settings toggle for multi-speaker mode - Mobile-responsive speaker buttons Technical Implementation: - SpeakerManager class handles all speaker operations - Automatic translation to all active languages - Conversation entries with timestamps - Translation caching per language - Clean separation of original vs translated text - Support for up to 8 concurrent speakers User Experience: - Click to switch active speaker - Visual feedback for active speaker - Conversation flows naturally with colors - Export feature for meeting minutes - Clear conversation history option - Seamless single/multi speaker mode switching This enables group conversations where each participant can speak in their native language and see translations in real-time. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
343bfbf1de
commit
dc3e67e17b
@ -450,4 +450,110 @@
|
|||||||
/* Smooth text appearance for streaming */
|
/* Smooth text appearance for streaming */
|
||||||
.streaming-text {
|
.streaming-text {
|
||||||
transition: all 0.1s ease-out;
|
transition: all 0.1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multi-speaker styles */
|
||||||
|
.speaker-button {
|
||||||
|
position: relative;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 2px solid;
|
||||||
|
background-color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-button.active {
|
||||||
|
color: white !important;
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgba(255,255,255,0.3);
|
||||||
|
color: inherit;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-button.active .speaker-avatar {
|
||||||
|
background-color: rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-entry {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
position: relative;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-speaker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-speaker-avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-text {
|
||||||
|
margin-left: 33px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-time {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-translation {
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Speaker list responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.speaker-button {
|
||||||
|
min-width: 100px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-avatar {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
@ -20,6 +20,7 @@ import { ErrorBoundary } from './errorBoundary';
|
|||||||
import { Validator } from './validator';
|
import { Validator } from './validator';
|
||||||
import { StreamingTranslation } from './streamingTranslation';
|
import { StreamingTranslation } from './streamingTranslation';
|
||||||
import { PerformanceMonitor } from './performanceMonitor';
|
import { PerformanceMonitor } from './performanceMonitor';
|
||||||
|
import { SpeakerManager } from './speakerManager';
|
||||||
|
|
||||||
// Initialize error boundary
|
// Initialize error boundary
|
||||||
const errorBoundary = ErrorBoundary.getInstance();
|
const errorBoundary = ErrorBoundary.getInstance();
|
||||||
@ -145,6 +146,10 @@ function initApp(): void {
|
|||||||
|
|
||||||
// Performance monitoring
|
// Performance monitoring
|
||||||
const performanceMonitor = PerformanceMonitor.getInstance();
|
const performanceMonitor = PerformanceMonitor.getInstance();
|
||||||
|
|
||||||
|
// Speaker management
|
||||||
|
const speakerManager = SpeakerManager.getInstance();
|
||||||
|
let multiSpeakerEnabled = false;
|
||||||
|
|
||||||
// Check TTS server status on page load
|
// Check TTS server status on page load
|
||||||
checkTtsServer();
|
checkTtsServer();
|
||||||
@ -157,6 +162,141 @@ function initApp(): void {
|
|||||||
|
|
||||||
// Start health monitoring
|
// Start health monitoring
|
||||||
startHealthMonitoring();
|
startHealthMonitoring();
|
||||||
|
|
||||||
|
// Initialize multi-speaker mode
|
||||||
|
initMultiSpeakerMode();
|
||||||
|
|
||||||
|
// Multi-speaker mode implementation
|
||||||
|
function initMultiSpeakerMode(): void {
|
||||||
|
const multiSpeakerToggle = document.getElementById('toggleMultiSpeaker') as HTMLButtonElement;
|
||||||
|
const multiSpeakerStatus = document.getElementById('multiSpeakerStatus') as HTMLSpanElement;
|
||||||
|
const speakerToolbar = document.getElementById('speakerToolbar') as HTMLDivElement;
|
||||||
|
const conversationView = document.getElementById('conversationView') as HTMLDivElement;
|
||||||
|
const multiSpeakerModeCheckbox = document.getElementById('multiSpeakerMode') as HTMLInputElement;
|
||||||
|
|
||||||
|
// Load saved preference
|
||||||
|
multiSpeakerEnabled = localStorage.getItem('multiSpeakerMode') === 'true';
|
||||||
|
if (multiSpeakerModeCheckbox) {
|
||||||
|
multiSpeakerModeCheckbox.checked = multiSpeakerEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide multi-speaker UI based on setting
|
||||||
|
if (multiSpeakerEnabled) {
|
||||||
|
speakerToolbar.style.display = 'block';
|
||||||
|
conversationView.style.display = 'block';
|
||||||
|
multiSpeakerStatus.textContent = 'ON';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle multi-speaker mode
|
||||||
|
multiSpeakerToggle?.addEventListener('click', () => {
|
||||||
|
multiSpeakerEnabled = !multiSpeakerEnabled;
|
||||||
|
multiSpeakerStatus.textContent = multiSpeakerEnabled ? 'ON' : 'OFF';
|
||||||
|
|
||||||
|
if (multiSpeakerEnabled) {
|
||||||
|
speakerToolbar.style.display = 'block';
|
||||||
|
conversationView.style.display = 'block';
|
||||||
|
|
||||||
|
// Add default speaker if none exist
|
||||||
|
if (speakerManager.getAllSpeakers().length === 0) {
|
||||||
|
const defaultSpeaker = speakerManager.addSpeaker('Speaker 1', sourceLanguage.value);
|
||||||
|
speakerManager.setActiveSpeaker(defaultSpeaker.id);
|
||||||
|
updateSpeakerUI();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
speakerToolbar.style.display = 'none';
|
||||||
|
conversationView.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('multiSpeakerMode', multiSpeakerEnabled.toString());
|
||||||
|
if (multiSpeakerModeCheckbox) {
|
||||||
|
multiSpeakerModeCheckbox.checked = multiSpeakerEnabled;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add speaker button
|
||||||
|
document.getElementById('addSpeakerBtn')?.addEventListener('click', () => {
|
||||||
|
const name = prompt('Enter speaker name:');
|
||||||
|
if (name) {
|
||||||
|
const speaker = speakerManager.addSpeaker(name, sourceLanguage.value);
|
||||||
|
speakerManager.setActiveSpeaker(speaker.id);
|
||||||
|
updateSpeakerUI();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update speaker UI
|
||||||
|
function updateSpeakerUI(): void {
|
||||||
|
const speakerList = document.getElementById('speakerList') as HTMLDivElement;
|
||||||
|
speakerList.innerHTML = '';
|
||||||
|
|
||||||
|
speakerManager.getAllSpeakers().forEach(speaker => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = `speaker-button ${speaker.isActive ? 'active' : ''}`;
|
||||||
|
btn.style.borderColor = speaker.color;
|
||||||
|
btn.style.backgroundColor = speaker.isActive ? speaker.color : 'white';
|
||||||
|
btn.style.color = speaker.isActive ? 'white' : speaker.color;
|
||||||
|
|
||||||
|
btn.innerHTML = `
|
||||||
|
<span class="speaker-avatar">${speaker.avatar}</span>
|
||||||
|
${speaker.name}
|
||||||
|
`;
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
speakerManager.setActiveSpeaker(speaker.id);
|
||||||
|
updateSpeakerUI();
|
||||||
|
});
|
||||||
|
|
||||||
|
speakerList.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export conversation
|
||||||
|
document.getElementById('exportConversation')?.addEventListener('click', () => {
|
||||||
|
const text = speakerManager.exportConversation(targetLanguage.value);
|
||||||
|
const blob = new Blob([text], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `conversation_${new Date().toISOString()}.txt`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear conversation
|
||||||
|
document.getElementById('clearConversation')?.addEventListener('click', () => {
|
||||||
|
if (confirm('Clear all conversation history?')) {
|
||||||
|
speakerManager.clearConversation();
|
||||||
|
updateConversationView();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update conversation view
|
||||||
|
function updateConversationView(): void {
|
||||||
|
const conversationContent = document.getElementById('conversationContent') as HTMLDivElement;
|
||||||
|
const entries = speakerManager.getConversationInLanguage(targetLanguage.value);
|
||||||
|
|
||||||
|
conversationContent.innerHTML = entries.map(entry => `
|
||||||
|
<div class="conversation-entry">
|
||||||
|
<div class="conversation-speaker">
|
||||||
|
<span class="conversation-speaker-avatar" style="background-color: ${entry.speakerColor}">
|
||||||
|
${entry.speakerName.substr(0, 2).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span style="color: ${entry.speakerColor}">${entry.speakerName}</span>
|
||||||
|
<span class="conversation-time">${new Date(entry.timestamp).toLocaleTimeString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="conversation-text ${!entry.isOriginal ? 'conversation-translation' : ''}">
|
||||||
|
${Validator.sanitizeHTML(entry.text)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Scroll to bottom
|
||||||
|
conversationContent.scrollTop = conversationContent.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store reference to update function for use in transcription
|
||||||
|
(window as any).updateConversationView = updateConversationView;
|
||||||
|
(window as any).updateSpeakerUI = updateSpeakerUI;
|
||||||
|
}
|
||||||
|
|
||||||
// Update TTS server URL and API key
|
// Update TTS server URL and API key
|
||||||
updateTtsServer.addEventListener('click', function() {
|
updateTtsServer.addEventListener('click', function() {
|
||||||
@ -484,6 +624,53 @@ function initApp(): void {
|
|||||||
const sanitizedText = Validator.sanitizeText(data.text);
|
const sanitizedText = Validator.sanitizeText(data.text);
|
||||||
currentSourceText = sanitizedText;
|
currentSourceText = sanitizedText;
|
||||||
|
|
||||||
|
// Handle multi-speaker mode
|
||||||
|
if (multiSpeakerEnabled) {
|
||||||
|
const activeSpeaker = speakerManager.getActiveSpeaker();
|
||||||
|
if (activeSpeaker) {
|
||||||
|
const entry = speakerManager.addConversationEntry(
|
||||||
|
activeSpeaker.id,
|
||||||
|
sanitizedText,
|
||||||
|
data.detected_language || sourceLanguage.value
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-translate for all other speakers' languages
|
||||||
|
const allLanguages = new Set(speakerManager.getAllSpeakers().map(s => s.language));
|
||||||
|
allLanguages.delete(entry.originalLanguage);
|
||||||
|
|
||||||
|
allLanguages.forEach(async (lang) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/translate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: sanitizedText,
|
||||||
|
source_lang: entry.originalLanguage,
|
||||||
|
target_lang: lang
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success && result.translation) {
|
||||||
|
speakerManager.addTranslation(entry.id, lang, result.translation);
|
||||||
|
if ((window as any).updateConversationView) {
|
||||||
|
(window as any).updateConversationView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to translate to ${lang}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update conversation view
|
||||||
|
if ((window as any).updateConversationView) {
|
||||||
|
(window as any).updateConversationView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle auto-detected language
|
// Handle auto-detected language
|
||||||
if (data.detected_language && sourceLanguage.value === 'auto') {
|
if (data.detected_language && sourceLanguage.value === 'auto') {
|
||||||
// Update the source language selector
|
// Update the source language selector
|
||||||
@ -1277,11 +1464,19 @@ function initNotificationUI(swRegistration: ServiceWorkerRegistration): void {
|
|||||||
const notifyTranslation = (document.getElementById('notifyTranslation') as HTMLInputElement).checked;
|
const notifyTranslation = (document.getElementById('notifyTranslation') as HTMLInputElement).checked;
|
||||||
const notifyErrors = (document.getElementById('notifyErrors') as HTMLInputElement).checked;
|
const notifyErrors = (document.getElementById('notifyErrors') as HTMLInputElement).checked;
|
||||||
const streamingTranslation = (document.getElementById('streamingTranslation') as HTMLInputElement).checked;
|
const streamingTranslation = (document.getElementById('streamingTranslation') as HTMLInputElement).checked;
|
||||||
|
const multiSpeakerMode = (document.getElementById('multiSpeakerMode') as HTMLInputElement).checked;
|
||||||
|
|
||||||
localStorage.setItem('notifyTranscription', notifyTranscription.toString());
|
localStorage.setItem('notifyTranscription', notifyTranscription.toString());
|
||||||
localStorage.setItem('notifyTranslation', notifyTranslation.toString());
|
localStorage.setItem('notifyTranslation', notifyTranslation.toString());
|
||||||
localStorage.setItem('notifyErrors', notifyErrors.toString());
|
localStorage.setItem('notifyErrors', notifyErrors.toString());
|
||||||
localStorage.setItem('streamingTranslation', streamingTranslation.toString());
|
localStorage.setItem('streamingTranslation', streamingTranslation.toString());
|
||||||
|
localStorage.setItem('multiSpeakerMode', multiSpeakerMode.toString());
|
||||||
|
|
||||||
|
// Update multi-speaker mode if changed
|
||||||
|
const previousMultiSpeakerMode = localStorage.getItem('multiSpeakerMode') === 'true';
|
||||||
|
if (multiSpeakerMode !== previousMultiSpeakerMode) {
|
||||||
|
window.location.reload(); // Reload to apply changes
|
||||||
|
}
|
||||||
|
|
||||||
// Show inline success message
|
// Show inline success message
|
||||||
const saveStatus = document.getElementById('settingsSaveStatus') as HTMLDivElement;
|
const saveStatus = document.getElementById('settingsSaveStatus') as HTMLDivElement;
|
||||||
@ -1311,7 +1506,6 @@ function initNotificationUI(swRegistration: ServiceWorkerRegistration): void {
|
|||||||
// Initialize cache management UI
|
// Initialize cache management UI
|
||||||
initCacheManagement();
|
initCacheManagement();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initCacheManagement(): Promise<void> {
|
async function initCacheManagement(): Promise<void> {
|
||||||
const cacheCount = document.getElementById('cacheCount') as HTMLSpanElement;
|
const cacheCount = document.getElementById('cacheCount') as HTMLSpanElement;
|
||||||
const cacheSize = document.getElementById('cacheSize') as HTMLSpanElement;
|
const cacheSize = document.getElementById('cacheSize') as HTMLSpanElement;
|
||||||
|
270
static/js/src/speakerManager.ts
Normal file
270
static/js/src/speakerManager.ts
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
// Speaker management for multi-speaker support
|
||||||
|
export interface Speaker {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
language: string;
|
||||||
|
color: string;
|
||||||
|
avatar?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
lastActiveTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeakerTranscription {
|
||||||
|
speakerId: string;
|
||||||
|
text: string;
|
||||||
|
language: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationEntry {
|
||||||
|
id: string;
|
||||||
|
speakerId: string;
|
||||||
|
originalText: string;
|
||||||
|
originalLanguage: string;
|
||||||
|
translations: Map<string, string>; // languageCode -> translatedText
|
||||||
|
timestamp: number;
|
||||||
|
audioUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SpeakerManager {
|
||||||
|
private static instance: SpeakerManager;
|
||||||
|
private speakers: Map<string, Speaker> = new Map();
|
||||||
|
private conversation: ConversationEntry[] = [];
|
||||||
|
private activeSpeakerId: string | null = null;
|
||||||
|
private maxConversationLength = 100;
|
||||||
|
|
||||||
|
// Predefined colors for speakers
|
||||||
|
private speakerColors = [
|
||||||
|
'#007bff', '#28a745', '#dc3545', '#ffc107',
|
||||||
|
'#17a2b8', '#6f42c1', '#e83e8c', '#fd7e14'
|
||||||
|
];
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.loadFromLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): SpeakerManager {
|
||||||
|
if (!SpeakerManager.instance) {
|
||||||
|
SpeakerManager.instance = new SpeakerManager();
|
||||||
|
}
|
||||||
|
return SpeakerManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a new speaker
|
||||||
|
addSpeaker(name: string, language: string): Speaker {
|
||||||
|
const id = this.generateSpeakerId();
|
||||||
|
const colorIndex = this.speakers.size % this.speakerColors.length;
|
||||||
|
|
||||||
|
const speaker: Speaker = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
language,
|
||||||
|
color: this.speakerColors[colorIndex],
|
||||||
|
isActive: false,
|
||||||
|
avatar: this.generateAvatar(name)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.speakers.set(id, speaker);
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
|
||||||
|
return speaker;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update speaker
|
||||||
|
updateSpeaker(id: string, updates: Partial<Speaker>): void {
|
||||||
|
const speaker = this.speakers.get(id);
|
||||||
|
if (speaker) {
|
||||||
|
Object.assign(speaker, updates);
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove speaker
|
||||||
|
removeSpeaker(id: string): void {
|
||||||
|
this.speakers.delete(id);
|
||||||
|
if (this.activeSpeakerId === id) {
|
||||||
|
this.activeSpeakerId = null;
|
||||||
|
}
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all speakers
|
||||||
|
getAllSpeakers(): Speaker[] {
|
||||||
|
return Array.from(this.speakers.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get speaker by ID
|
||||||
|
getSpeaker(id: string): Speaker | undefined {
|
||||||
|
return this.speakers.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set active speaker
|
||||||
|
setActiveSpeaker(id: string | null): void {
|
||||||
|
// Deactivate all speakers
|
||||||
|
this.speakers.forEach(speaker => {
|
||||||
|
speaker.isActive = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate selected speaker
|
||||||
|
if (id && this.speakers.has(id)) {
|
||||||
|
const speaker = this.speakers.get(id)!;
|
||||||
|
speaker.isActive = true;
|
||||||
|
speaker.lastActiveTime = Date.now();
|
||||||
|
this.activeSpeakerId = id;
|
||||||
|
} else {
|
||||||
|
this.activeSpeakerId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get active speaker
|
||||||
|
getActiveSpeaker(): Speaker | null {
|
||||||
|
return this.activeSpeakerId ? this.speakers.get(this.activeSpeakerId) || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add conversation entry
|
||||||
|
addConversationEntry(
|
||||||
|
speakerId: string,
|
||||||
|
originalText: string,
|
||||||
|
originalLanguage: string
|
||||||
|
): ConversationEntry {
|
||||||
|
const entry: ConversationEntry = {
|
||||||
|
id: this.generateEntryId(),
|
||||||
|
speakerId,
|
||||||
|
originalText,
|
||||||
|
originalLanguage,
|
||||||
|
translations: new Map(),
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.conversation.push(entry);
|
||||||
|
|
||||||
|
// Limit conversation length
|
||||||
|
if (this.conversation.length > this.maxConversationLength) {
|
||||||
|
this.conversation.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add translation to conversation entry
|
||||||
|
addTranslation(entryId: string, language: string, translatedText: string): void {
|
||||||
|
const entry = this.conversation.find(e => e.id === entryId);
|
||||||
|
if (entry) {
|
||||||
|
entry.translations.set(language, translatedText);
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get conversation for a specific language
|
||||||
|
getConversationInLanguage(language: string): Array<{
|
||||||
|
speakerId: string;
|
||||||
|
speakerName: string;
|
||||||
|
speakerColor: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: number;
|
||||||
|
isOriginal: boolean;
|
||||||
|
}> {
|
||||||
|
return this.conversation.map(entry => {
|
||||||
|
const speaker = this.speakers.get(entry.speakerId);
|
||||||
|
const isOriginal = entry.originalLanguage === language;
|
||||||
|
const text = isOriginal ?
|
||||||
|
entry.originalText :
|
||||||
|
entry.translations.get(language) || `[Translating from ${entry.originalLanguage}...]`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
speakerId: entry.speakerId,
|
||||||
|
speakerName: speaker?.name || 'Unknown',
|
||||||
|
speakerColor: speaker?.color || '#666',
|
||||||
|
text,
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
isOriginal
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get full conversation history
|
||||||
|
getFullConversation(): ConversationEntry[] {
|
||||||
|
return [...this.conversation];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear conversation
|
||||||
|
clearConversation(): void {
|
||||||
|
this.conversation = [];
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique speaker ID
|
||||||
|
private generateSpeakerId(): string {
|
||||||
|
return `speaker_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique entry ID
|
||||||
|
private generateEntryId(): string {
|
||||||
|
return `entry_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate avatar initials
|
||||||
|
private generateAvatar(name: string): string {
|
||||||
|
const parts = name.trim().split(' ');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return parts[0][0].toUpperCase() + parts[1][0].toUpperCase();
|
||||||
|
}
|
||||||
|
return name.substr(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
private saveToLocalStorage(): void {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
speakers: Array.from(this.speakers.entries()),
|
||||||
|
conversation: this.conversation.map(entry => ({
|
||||||
|
...entry,
|
||||||
|
translations: Array.from(entry.translations.entries())
|
||||||
|
})),
|
||||||
|
activeSpeakerId: this.activeSpeakerId
|
||||||
|
};
|
||||||
|
localStorage.setItem('speakerData', JSON.stringify(data));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save speaker data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load from localStorage
|
||||||
|
private loadFromLocalStorage(): void {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('speakerData');
|
||||||
|
if (saved) {
|
||||||
|
const data = JSON.parse(saved);
|
||||||
|
|
||||||
|
// Restore speakers
|
||||||
|
if (data.speakers) {
|
||||||
|
this.speakers = new Map(data.speakers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore conversation with Map translations
|
||||||
|
if (data.conversation) {
|
||||||
|
this.conversation = data.conversation.map((entry: any) => ({
|
||||||
|
...entry,
|
||||||
|
translations: new Map(entry.translations || [])
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore active speaker
|
||||||
|
this.activeSpeakerId = data.activeSpeakerId || null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load speaker data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export conversation as text
|
||||||
|
exportConversation(language: string): string {
|
||||||
|
const entries = this.getConversationInLanguage(language);
|
||||||
|
return entries.map(entry =>
|
||||||
|
`[${new Date(entry.timestamp).toLocaleTimeString()}] ${entry.speakerName}: ${entry.text}`
|
||||||
|
).join('\n');
|
||||||
|
}
|
||||||
|
}
|
@ -124,6 +124,25 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="text-center mb-4">Voice Language Translator</h1>
|
<h1 class="text-center mb-4">Voice Language Translator</h1>
|
||||||
<!--<p class="text-center text-muted">Powered by Gemma 3, Whisper & Edge TTS</p>-->
|
<!--<p class="text-center text-muted">Powered by Gemma 3, Whisper & Edge TTS</p>-->
|
||||||
|
|
||||||
|
<!-- Multi-speaker toolbar -->
|
||||||
|
<div id="speakerToolbar" class="card mb-3" style="display: none;">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div class="d-flex align-items-center justify-content-between flex-wrap">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2 mb-md-0">
|
||||||
|
<button id="addSpeakerBtn" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="fas fa-user-plus"></i> Add Speaker
|
||||||
|
</button>
|
||||||
|
<button id="toggleMultiSpeaker" class="btn btn-sm btn-secondary">
|
||||||
|
<i class="fas fa-users"></i> Multi-Speaker: <span id="multiSpeakerStatus">OFF</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="speakerList" class="d-flex gap-2 flex-wrap">
|
||||||
|
<!-- Speaker buttons will be added here dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
@ -205,6 +224,26 @@
|
|||||||
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div>
|
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Multi-speaker conversation view -->
|
||||||
|
<div id="conversationView" class="card mt-4" style="display: none;">
|
||||||
|
<div class="card-header bg-info text-white d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Conversation</h5>
|
||||||
|
<div>
|
||||||
|
<button id="exportConversation" class="btn btn-sm btn-light">
|
||||||
|
<i class="fas fa-download"></i> Export
|
||||||
|
</button>
|
||||||
|
<button id="clearConversation" class="btn btn-sm btn-light">
|
||||||
|
<i class="fas fa-trash"></i> Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
|
||||||
|
<div id="conversationContent">
|
||||||
|
<!-- Conversation entries will be added here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<audio id="audioPlayer" style="display: none;"></audio>
|
<audio id="audioPlayer" style="display: none;"></audio>
|
||||||
|
|
||||||
@ -305,6 +344,13 @@
|
|||||||
<small class="text-muted d-block">Shows translation as it's generated for faster feedback</small>
|
<small class="text-muted d-block">Shows translation as it's generated for faster feedback</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="multiSpeakerMode">
|
||||||
|
<label class="form-check-label" for="multiSpeakerMode">
|
||||||
|
Enable multi-speaker mode
|
||||||
|
<small class="text-muted d-block">Track multiple speakers in conversations</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user