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:
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user