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>
270 lines
8.1 KiB
TypeScript
270 lines
8.1 KiB
TypeScript
// 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');
|
|
}
|
|
} |