diff --git a/static/css/styles.css b/static/css/styles.css
index 70bff66..7e07a1d 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -450,4 +450,110 @@
/* Smooth text appearance for streaming */
.streaming-text {
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;
+ }
}
\ No newline at end of file
diff --git a/static/js/src/app.ts b/static/js/src/app.ts
index b28e59e..644392f 100644
--- a/static/js/src/app.ts
+++ b/static/js/src/app.ts
@@ -20,6 +20,7 @@ import { ErrorBoundary } from './errorBoundary';
import { Validator } from './validator';
import { StreamingTranslation } from './streamingTranslation';
import { PerformanceMonitor } from './performanceMonitor';
+import { SpeakerManager } from './speakerManager';
// Initialize error boundary
const errorBoundary = ErrorBoundary.getInstance();
@@ -145,6 +146,10 @@ function initApp(): void {
// Performance monitoring
const performanceMonitor = PerformanceMonitor.getInstance();
+
+ // Speaker management
+ const speakerManager = SpeakerManager.getInstance();
+ let multiSpeakerEnabled = false;
// Check TTS server status on page load
checkTtsServer();
@@ -157,6 +162,141 @@ function initApp(): void {
// Start health monitoring
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 = `
+ ${speaker.avatar}
+ ${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 => `
+
+
+
+ ${entry.speakerName.substr(0, 2).toUpperCase()}
+
+ ${entry.speakerName}
+ ${new Date(entry.timestamp).toLocaleTimeString()}
+
+
+ ${Validator.sanitizeHTML(entry.text)}
+
+
+ `).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
updateTtsServer.addEventListener('click', function() {
@@ -484,6 +624,53 @@ function initApp(): void {
const sanitizedText = Validator.sanitizeText(data.text);
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
if (data.detected_language && sourceLanguage.value === 'auto') {
// Update the source language selector
@@ -1277,11 +1464,19 @@ function initNotificationUI(swRegistration: ServiceWorkerRegistration): void {
const notifyTranslation = (document.getElementById('notifyTranslation') as HTMLInputElement).checked;
const notifyErrors = (document.getElementById('notifyErrors') 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('notifyTranslation', notifyTranslation.toString());
localStorage.setItem('notifyErrors', notifyErrors.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
const saveStatus = document.getElementById('settingsSaveStatus') as HTMLDivElement;
@@ -1311,7 +1506,6 @@ function initNotificationUI(swRegistration: ServiceWorkerRegistration): void {
// Initialize cache management UI
initCacheManagement();
}
-
async function initCacheManagement(): Promise {
const cacheCount = document.getElementById('cacheCount') as HTMLSpanElement;
const cacheSize = document.getElementById('cacheSize') as HTMLSpanElement;
diff --git a/static/js/src/speakerManager.ts b/static/js/src/speakerManager.ts
new file mode 100644
index 0000000..9525304
--- /dev/null
+++ b/static/js/src/speakerManager.ts
@@ -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; // languageCode -> translatedText
+ timestamp: number;
+ audioUrl?: string;
+}
+
+export class SpeakerManager {
+ private static instance: SpeakerManager;
+ private speakers: Map = 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): 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');
+ }
+}
\ No newline at end of file
diff --git a/templates/index.html b/templates/index.html
index 7bf17d2..79e9005 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -124,6 +124,25 @@
Voice Language Translator
+
+
+
+
+
+
@@ -305,6 +344,13 @@
Shows translation as it's generated for faster feedback
+
+
+
+