const state = { config: { moonshine: {}, sensevoice: {}, llms: {} }, backend: 'sensevoice', utterances: [], diarizedUtterances: null, diarizationStats: null, speakerNames: {}, // Maps speaker_id to detected name info summary: '', title: '', audioUrl: null, sourcePath: null, uploadedFile: null, transcribing: false, summarizing: false, detectingSpeakerNames: false, transcriptionController: null, // AbortController for transcription summaryController: null, // AbortController for summarization }; const elements = { backendSelect: document.getElementById('backend-select'), modelSelect: document.getElementById('model-select'), llmSelect: document.getElementById('llm-select'), promptInput: document.getElementById('prompt-input'), vadSlider: document.getElementById('vad-threshold'), vadValue: document.getElementById('vad-value'), diarizationToggle: document.getElementById('diarization-toggle'), diarizationSettings: document.getElementById('diarization-settings'), numSpeakers: document.getElementById('num-speakers'), clusterSlider: document.getElementById('cluster-threshold'), clusterValue: document.getElementById('cluster-value'), sensevoiceOptions: document.getElementById('sensevoice-options'), sensevoiceLanguage: document.getElementById('sensevoice-language'), transcribeBtn: document.getElementById('transcribe-btn'), summaryBtn: document.getElementById('summary-btn'), detectSpeakerNamesBtn: document.getElementById('detect-speaker-names-btn'), statusText: document.getElementById('status-text'), audioPlayer: document.getElementById('audio-player'), transcriptList: document.getElementById('transcript-list'), transcriptTemplate: document.getElementById('utterance-template'), utteranceCount: document.getElementById('utterance-count'), summaryOutput: document.getElementById('summary-output'), titleOutput: document.getElementById('title-output'), diarizationPanel: document.getElementById('diarization-summary'), diarizationMetrics: document.getElementById('diarization-metrics'), speakerBreakdown: document.getElementById('speaker-breakdown'), transcriptFormat: document.getElementById('transcript-format'), summaryFormat: document.getElementById('summary-format'), exportTranscriptBtn: document.getElementById('export-transcript'), exportSummaryBtn: document.getElementById('export-summary'), includeTimestamps: document.getElementById('include-timestamps'), fileInput: document.getElementById('file-input'), youtubeUrl: document.getElementById('youtube-url'), youtubeFetch: document.getElementById('youtube-fetch'), podcastQuery: document.getElementById('podcast-query'), podcastSearch: document.getElementById('podcast-search'), podcastResults: document.getElementById('podcast-results'), episodeResults: document.getElementById('episode-results'), progressContainer: document.getElementById('progress-container'), progressFill: document.getElementById('progress-fill'), cancelTranscribeBtn: document.getElementById('cancel-transcribe-btn'), cancelSummaryBtn: document.getElementById('cancel-summary-btn'), // Custom player elements playPauseBtn: document.getElementById('play-pause-btn'), playIcon: document.querySelector('.play-icon'), pauseIcon: document.querySelector('.pause-icon'), currentTimeDisplay: document.getElementById('current-time'), durationTimeDisplay: document.getElementById('duration-time'), timelineBar: document.getElementById('timeline-bar'), timelineProgress: document.getElementById('timeline-progress'), timelineSegments: document.getElementById('timeline-segments'), timelineHandle: document.getElementById('timeline-handle'), waveformCanvas: document.getElementById('waveform-canvas'), volumeBtn: document.getElementById('volume-btn'), volumeSlider: document.getElementById('volume-slider'), }; const TRANSCRIPT_FORMATS = [ 'SRT (SubRip)', 'VTT (WebVTT)', 'ASS (Advanced SubStation Alpha)', 'Plain Text', 'JSON', 'ELAN (EAF)', ]; const SUMMARY_FORMATS = ['Markdown', 'Plain Text']; // Expanded speaker color palette (30 distinct colors) const SPEAKER_COLORS = [ '#ef4444', // Red '#3b82f6', // Blue '#10b981', // Green '#f59e0b', // Amber '#8b5cf6', // Purple '#ec4899', // Pink '#14b8a6', // Teal '#f97316', // Orange '#06b6d4', // Cyan '#84cc16', // Lime '#dc2626', // Dark Red '#2563eb', // Dark Blue '#059669', // Dark Green '#d97706', // Dark Amber '#7c3aed', // Dark Purple '#db2777', // Dark Pink '#0d9488', // Dark Teal '#ea580c', // Dark Orange '#0891b2', // Dark Cyan '#65a30d', // Dark Lime '#f87171', // Light Red '#60a5fa', // Light Blue '#34d399', // Light Green '#fbbf24', // Light Amber '#a78bfa', // Light Purple '#f472b6', // Light Pink '#2dd4bf', // Light Teal '#fb923c', // Light Orange '#22d3ee', // Light Cyan '#a3e635', // Light Lime ]; function getSpeakerColor(speakerId) { if (typeof speakerId !== 'number') return null; return SPEAKER_COLORS[speakerId % SPEAKER_COLORS.length]; } let activeTab = 'podcast-tab'; let activeUtteranceIndex = -1; // Configuration de Marked pour un rendu sécurisé marked.setOptions({ breaks: true, // Convertir les sauts de ligne simples en
gfm: true, // GitHub Flavored Markdown headerIds: false, // Pas d'IDs automatiques sur les headers mangle: false, // Pas de mangling des emails }); // Fonction simple pour convertir Markdown en HTML function renderMarkdown(markdown) { if (!markdown) return ''; return marked.parse(markdown); } function setStatus(message, tone = 'info') { elements.statusText.textContent = message; elements.statusText.dataset.tone = tone; } function showProgress(visible = true) { if (visible) { elements.progressContainer.classList.remove('hidden'); } else { elements.progressContainer.classList.add('hidden'); elements.progressFill.style.width = '0%'; } } function updateProgress(percent, text = null) { elements.progressFill.style.width = `${Math.min(100, Math.max(0, percent))}%`; // Remove text parameter - let status text handle messaging } function formatTime(seconds) { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60).toString().padStart(2, '0'); return `${mins}:${secs}`; } function setListEmpty(container, message) { if (!container) return; container.innerHTML = `
${message}
`; } async function fetchConfig() { try { const res = await fetch('/api/config/models'); if (!res.ok) throw new Error('Failed to fetch model catalog'); state.config = await res.json(); populateModelSelect(); populateLLMSelect(); populateExportSelects(); } catch (err) { console.error(err); setStatus(err.message, 'error'); } } function populateModelSelect() { const backend = state.backend; elements.modelSelect.innerHTML = ''; const models = backend === 'moonshine' ? state.config.moonshine : state.config.sensevoice; Object.entries(models).forEach(([label, value]) => { const option = document.createElement('option'); option.value = value; option.textContent = label; elements.modelSelect.appendChild(option); }); if (elements.modelSelect.options.length > 0) { elements.modelSelect.selectedIndex = 0; } elements.sensevoiceOptions.classList.toggle('hidden', backend !== 'sensevoice'); } function populateLLMSelect() { elements.llmSelect.innerHTML = ''; Object.keys(state.config.llms).forEach((name) => { const option = document.createElement('option'); option.value = name; option.textContent = name; elements.llmSelect.appendChild(option); }); } function populateExportSelects() { elements.transcriptFormat.innerHTML = ''; TRANSCRIPT_FORMATS.forEach((fmt) => { const option = document.createElement('option'); option.value = fmt; option.textContent = fmt; elements.transcriptFormat.appendChild(option); }); elements.summaryFormat.innerHTML = ''; SUMMARY_FORMATS.forEach((fmt) => { const option = document.createElement('option'); option.value = fmt; option.textContent = fmt; elements.summaryFormat.appendChild(option); }); } function initTabs() { document.querySelectorAll('.tab').forEach((tab) => { tab.addEventListener('click', () => { if (tab.dataset.target === activeTab) return; document.querySelectorAll('.tab').forEach((btn) => btn.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach((panel) => panel.classList.remove('active')); tab.classList.add('active'); document.getElementById(tab.dataset.target).classList.add('active'); activeTab = tab.dataset.target; }); }); } function initSidebarInteractions() { elements.backendSelect.addEventListener('change', () => { state.backend = elements.backendSelect.value; populateModelSelect(); }); elements.vadSlider.addEventListener('input', () => { elements.vadValue.textContent = Number(elements.vadSlider.value).toFixed(2); }); elements.diarizationToggle.addEventListener('change', () => { elements.diarizationSettings.classList.toggle('hidden', !elements.diarizationToggle.checked); }); elements.clusterSlider.addEventListener('input', () => { elements.clusterValue.textContent = Number(elements.clusterSlider.value).toFixed(2); }); } function resetTranscriptionState() { state.utterances = []; state.diarizedUtterances = null; state.diarizationStats = null; activeUtteranceIndex = -1; elements.transcriptList.innerHTML = ''; elements.utteranceCount.textContent = ''; elements.diarizationPanel.classList.add('hidden'); } function resetCompleteSession() { // Reset transcription data resetTranscriptionState(); // Reset speaker names state.speakerNames = {}; // Reset summary and title state.summary = ''; state.title = ''; // Clear summary and title UI elements.summaryOutput.innerHTML = ''; elements.titleOutput.textContent = ''; // Reset timeline visualization renderTimelineSegments(); // Hide speaker name detection button elements.detectSpeakerNamesBtn.classList.add('hidden'); // Reset status setStatus('Ready for new transcription', 'info'); } function prepareTranscriptionOptions() { const textnormValue = document.querySelector('input[name="textnorm"]:checked')?.value || 'withitn'; return { backend: state.backend, model_name: elements.modelSelect.value, vad_threshold: Number(elements.vadSlider.value), language: state.backend === 'sensevoice' ? elements.sensevoiceLanguage.value : 'auto', textnorm: textnormValue, diarization: { enable: elements.diarizationToggle.checked, num_speakers: Number(elements.numSpeakers.value || -1), cluster_threshold: Number(elements.clusterSlider.value), }, }; } async function handleTranscription() { if (state.transcribing) return; if (!state.uploadedFile && !state.audioUrl) { setStatus('Upload or select an audio source first', 'warning'); return; } resetTranscriptionState(); state.transcribing = true; state.transcriptionController = new AbortController(); setStatus('Starting transcription...', 'info'); showProgress(true); updateProgress(0, 'Initializing...'); // Show cancel button and hide transcribe button elements.transcribeBtn.classList.add('hidden'); elements.cancelTranscribeBtn.classList.remove('hidden'); const formData = new FormData(); if (state.uploadedFile) { formData.append('audio', state.uploadedFile, state.uploadedFile.name); } else if (state.audioUrl) { formData.append('source', state.audioUrl); } formData.append('options', JSON.stringify(prepareTranscriptionOptions())); try { const response = await fetch('/api/transcribe', { method: 'POST', body: formData, signal: state.transcriptionController.signal, }); if (!response.ok || !response.body) { throw new Error('Transcription request failed'); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { if (!line.trim()) continue; const event = JSON.parse(line); handleTranscriptionEvent(event); } } if (buffer.trim()) { handleTranscriptionEvent(JSON.parse(buffer)); } setStatus('Transcription complete', 'success'); showProgress(false); } catch (err) { if (err.name === 'AbortError') { setStatus('Transcription cancelled', 'warning'); } else { console.error(err); setStatus(err.message, 'error'); } showProgress(false); } finally { state.transcribing = false; state.transcriptionController = null; // Hide cancel button and show transcribe button elements.cancelTranscribeBtn.classList.add('hidden'); elements.transcribeBtn.classList.remove('hidden'); } } function handleTranscriptionEvent(event) { switch (event.type) { case 'ready': if (event.audioUrl) { state.audioUrl = event.audioUrl; elements.audioPlayer.src = event.audioUrl; elements.audioPlayer.currentTime = 0; } break; case 'status': setStatus(event.message, 'info'); break; case 'progress': if (event.stage === 'diarization') { setStatus(`Performing speaker diarization... (${event.progress}%)`, 'info'); updateProgress(event.progress); } else { updateProgress(event.progress || 0); } break; case 'utterance': state.utterances.push(event.utterance); const progress = event.progress || 0; setStatus(`Transcribing audio... (${state.utterances.length} utterances, ${progress}%)`, 'info'); updateProgress(progress); renderTranscript(); break; case 'complete': if (event.diarization) { state.diarizedUtterances = event.diarization.utterances || []; state.diarizationStats = event.diarization.stats || null; } if (event.utterances) { const diarized = state.diarizedUtterances?.length ? state.diarizedUtterances : null; state.utterances = diarized ? diarized.map((utt, index) => ({ ...(event.utterances[index] || {}), ...utt, })) : event.utterances; } else if (state.diarizedUtterances?.length) { state.utterances = state.diarizedUtterances; } renderTranscript(); renderDiarizationStats(); break; case 'error': setStatus(event.message || 'Transcription error', 'error'); break; } } function createUtteranceElement(utt, index) { const node = elements.transcriptTemplate.content.cloneNode(true); const item = node.querySelector('.utterance-item'); item.dataset.index = index.toString(); item.dataset.start = utt.start; item.dataset.end = utt.end; node.querySelector('.timestamp').textContent = `[${formatTime(utt.start)}]`; node.querySelector('.utterance-text').textContent = utt.text; const speakerTag = node.querySelector('.speaker-tag'); if (typeof utt.speaker === 'number') { const speakerId = utt.speaker; const speakerInfo = state.speakerNames?.[speakerId]; const speakerName = speakerInfo?.name || `Speaker ${speakerId + 1}`; const speakerColor = getSpeakerColor(speakerId); speakerTag.textContent = speakerName; speakerTag.classList.remove('hidden'); speakerTag.classList.add('editable-speaker'); speakerTag.dataset.speakerId = speakerId; speakerTag.title = 'Click to edit speaker name'; // Apply speaker color dynamically if (speakerColor) { speakerTag.style.backgroundColor = speakerColor + '40'; // Add transparency speakerTag.style.borderColor = speakerColor; speakerTag.style.color = '#ffffff'; } } // Réappliquer la classe 'active' si cet élément est actuellement surligné if (index === activeUtteranceIndex) { item.classList.add('active'); } return node; } function renderTranscript(forceRebuild = false) { const currentCount = elements.transcriptList.children.length; const totalCount = state.utterances.length; // Cas 1: Rendu complet (réinitialisation ou reconstruction complète) if (currentCount === 0 && totalCount > 0) { const fragment = document.createDocumentFragment(); state.utterances.forEach((utt, index) => { fragment.appendChild(createUtteranceElement(utt, index)); }); elements.transcriptList.appendChild(fragment); } // Cas 2: Rendu incrémental (nouveaux énoncés seulement) else if (totalCount > currentCount && !forceRebuild) { const fragment = document.createDocumentFragment(); const newUtterances = state.utterances.slice(currentCount); newUtterances.forEach((utt, i) => { const index = currentCount + i; fragment.appendChild(createUtteranceElement(utt, index)); }); elements.transcriptList.appendChild(fragment); } // Cas 3: Reconstruction complète (forcée ou nombre d'éléments différent) else if (forceRebuild || totalCount !== currentCount) { elements.transcriptList.innerHTML = ''; const fragment = document.createDocumentFragment(); state.utterances.forEach((utt, index) => { fragment.appendChild(createUtteranceElement(utt, index)); }); elements.transcriptList.appendChild(fragment); } elements.utteranceCount.textContent = `${state.utterances.length} segments`; // Update timeline segments when transcript changes renderTimelineSegments(); } function renderDiarizationStats() { if (!state.diarizationStats) { elements.diarizationPanel.classList.add('hidden'); elements.detectSpeakerNamesBtn.classList.add('hidden'); return; } elements.diarizationPanel.classList.remove('hidden'); elements.detectSpeakerNamesBtn.classList.remove('hidden'); const stats = state.diarizationStats; elements.diarizationMetrics.innerHTML = ''; const metricsFragment = document.createDocumentFragment(); const totalCard = document.createElement('div'); totalCard.className = 'metric-card'; totalCard.innerHTML = `Total speakers: ${stats.total_speakers || 0}
Duration: ${stats.total_duration?.toFixed(1) || 0}s`; metricsFragment.appendChild(totalCard); elements.diarizationMetrics.appendChild(metricsFragment); elements.speakerBreakdown.innerHTML = ''; const speakersFragment = document.createDocumentFragment(); Object.entries(stats.speakers || {}).forEach(([speakerId, info]) => { const card = document.createElement('div'); card.className = 'metric-card'; card.innerHTML = ` Speaker ${Number(speakerId) + 1}
Speaking time: ${info.speaking_time.toFixed(1)}s
Percentage: ${info.percentage.toFixed(1)}%
Utterances: ${info.utterances}
Avg length: ${info.avg_utterance_length.toFixed(1)}s `; speakersFragment.appendChild(card); }); elements.speakerBreakdown.appendChild(speakersFragment); } function findActiveUtterance(currentTime) { let left = 0; let right = state.utterances.length - 1; let match = -1; while (left <= right) { const mid = Math.floor((left + right) / 2); const utt = state.utterances[mid]; if (currentTime >= utt.start && currentTime < utt.end) { return mid; } if (currentTime < utt.start) { right = mid - 1; } else { match = mid; left = mid + 1; } } return match; } function updateActiveUtterance(index) { if (index === activeUtteranceIndex) return; const previous = elements.transcriptList.querySelector('.utterance-item.active'); if (previous) previous.classList.remove('active'); const current = elements.transcriptList.querySelector(`.utterance-item[data-index="${index}"]`); if (current) { current.classList.add('active'); current.scrollIntoView({ behavior: 'smooth', block: 'center' }); } activeUtteranceIndex = index; } function initAudioInteractions() { elements.audioPlayer.addEventListener('timeupdate', () => { if (!state.utterances.length) return; const idx = findActiveUtterance(elements.audioPlayer.currentTime); if (idx >= 0) { updateActiveUtterance(idx); updateActiveSegment(); } }); elements.transcriptList.addEventListener('click', (event) => { const item = event.target.closest('.utterance-item'); if (!item) return; const editButton = event.target.closest('.edit-btn'); const saveButton = event.target.closest('.save-edit'); const cancelButton = event.target.closest('.cancel-edit'); const speakerTag = event.target.closest('.editable-speaker'); const editArea = event.target.closest('.edit-area'); // Check if clicking directly on textarea or if textarea is an ancestor const isTextarea = event.target.tagName === 'TEXTAREA'; const index = Number(item.dataset.index); // Handle speaker tag editing if (speakerTag && !speakerTag.querySelector('input')) { startSpeakerEdit(speakerTag); return; } // Handle edit button click if (editButton) { event.stopPropagation(); // Prevent seek toggleEdit(item, true); return; } // Handle save button click if (saveButton) { event.stopPropagation(); // Prevent seek const textarea = item.querySelector('textarea'); const newText = textarea.value.trim(); if (newText.length === 0) return; state.utterances[index].text = newText; item.querySelector('.utterance-text').textContent = newText; toggleEdit(item, false); return; } // Handle cancel button click if (cancelButton) { event.stopPropagation(); // Prevent seek toggleEdit(item, false); return; } // Prevent seek when clicking on textarea or edit area if (isTextarea || editArea) { return; // Do nothing, allow text selection/editing } // Default behavior: seek to utterance start time const start = Number(item.dataset.start); seekToTime(start); }); } function toggleEdit(item, editing) { const textBlock = item.querySelector('.utterance-text'); const editArea = item.querySelector('.edit-area'); if (!textBlock || !editArea) return; if (editing) { const textarea = editArea.querySelector('textarea'); textarea.value = textBlock.textContent; textBlock.classList.add('hidden'); editArea.classList.remove('hidden'); } else { textBlock.classList.remove('hidden'); editArea.classList.add('hidden'); } } function startSpeakerEdit(speakerTag) { const speakerId = Number(speakerTag.dataset.speakerId); const currentName = speakerTag.textContent; // Create input field const input = document.createElement('input'); input.type = 'text'; input.className = 'speaker-edit-input'; input.value = currentName; input.dataset.speakerId = speakerId; // Replace speaker tag content with input speakerTag.innerHTML = ''; speakerTag.appendChild(input); input.focus(); input.select(); // Handle input events const finishEdit = (save = true) => { const newName = input.value.trim(); if (save) { if (newName) { // Non-empty name: Save as user-edited if (!state.speakerNames) state.speakerNames = {}; state.speakerNames[speakerId] = { name: newName, confidence: 'user', // Mark as user-edited reason: 'User edited' }; } else { // Empty name: Clear from state (allow auto-detection to fill later) if (state.speakerNames && state.speakerNames[speakerId]) { delete state.speakerNames[speakerId]; } } // Force re-render to update all UI elements renderTranscript(true); renderTimelineSegments(); renderDiarizationStats(); } else { // Cancel edit: Restore current name without clearing state const originalName = state.speakerNames?.[speakerId]?.name || `Speaker ${speakerId + 1}`; speakerTag.textContent = originalName; speakerTag.classList.add('editable-speaker'); } }; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); finishEdit(true); } else if (e.key === 'Escape') { e.preventDefault(); finishEdit(false); } }); input.addEventListener('blur', () => { finishEdit(true); }); } function seekToTime(timeInSeconds) { if (!Number.isFinite(timeInSeconds)) return; const audio = elements.audioPlayer; const executeSeek = () => { audio.currentTime = Math.max(0, timeInSeconds); updateActiveUtterance(findActiveUtterance(audio.currentTime)); audio.play().catch(() => {}); }; if (audio.readyState >= 1) { executeSeek(); } else { const onLoaded = () => { executeSeek(); audio.removeEventListener('loadedmetadata', onLoaded); }; audio.addEventListener('loadedmetadata', onLoaded); audio.load(); } } // ==================== Custom Audio Player Functions ==================== function initCustomAudioPlayer() { const audio = elements.audioPlayer; // Play/Pause button elements.playPauseBtn.addEventListener('click', () => { if (audio.paused) { audio.play(); } else { audio.pause(); } }); // Update play/pause icon audio.addEventListener('play', () => { elements.playIcon.classList.add('hidden'); elements.pauseIcon.classList.remove('hidden'); }); audio.addEventListener('pause', () => { elements.playIcon.classList.remove('hidden'); elements.pauseIcon.classList.add('hidden'); }); // Time update audio.addEventListener('timeupdate', () => { updateTimelinePosition(); updateTimeDisplays(); }); // Duration loaded audio.addEventListener('loadedmetadata', () => { updateTimeDisplays(); renderTimelineSegments(); }); audio.addEventListener('durationchange', () => { updateTimeDisplays(); renderTimelineSegments(); }); // Timeline click and drag let isDragging = false; elements.timelineBar.addEventListener('mousedown', (e) => { isDragging = true; seekToPosition(e); }); document.addEventListener('mousemove', (e) => { if (isDragging) { seekToPosition(e); } }); document.addEventListener('mouseup', () => { isDragging = false; }); elements.timelineBar.addEventListener('click', (e) => { if (!isDragging) { seekToPosition(e); } }); // Volume controls elements.volumeBtn.addEventListener('click', () => { audio.muted = !audio.muted; updateVolumeIcon(); }); elements.volumeSlider.addEventListener('input', (e) => { audio.volume = e.target.value / 100; audio.muted = false; updateVolumeIcon(); }); audio.addEventListener('volumechange', () => { updateVolumeIcon(); }); // Keyboard shortcuts document.addEventListener('keydown', (e) => { // Only if not typing in an input/textarea if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.code === 'Space') { e.preventDefault(); if (audio.paused) { audio.play(); } else { audio.pause(); } } else if (e.code === 'ArrowLeft') { e.preventDefault(); audio.currentTime = Math.max(0, audio.currentTime - 5); } else if (e.code === 'ArrowRight') { e.preventDefault(); audio.currentTime = Math.min(audio.duration, audio.currentTime + 5); } }); } function seekToPosition(e) { const audio = elements.audioPlayer; const rect = elements.timelineBar.getBoundingClientRect(); const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); audio.currentTime = percent * audio.duration; } function updateTimelinePosition() { const audio = elements.audioPlayer; if (!audio.duration) return; const percent = (audio.currentTime / audio.duration) * 100; elements.timelineProgress.style.width = `${percent}%`; elements.timelineHandle.style.left = `${percent}%`; } function updateTimeDisplays() { const audio = elements.audioPlayer; elements.currentTimeDisplay.textContent = formatTime(audio.currentTime || 0); elements.durationTimeDisplay.textContent = formatTime(audio.duration || 0); } function updateVolumeIcon() { const audio = elements.audioPlayer; if (audio.muted || audio.volume === 0) { elements.volumeBtn.textContent = '🔇'; } else if (audio.volume < 0.5) { elements.volumeBtn.textContent = '🔉'; } else { elements.volumeBtn.textContent = '🔊'; } } function renderTimelineSegments() { const audio = elements.audioPlayer; if (!audio.duration || !state.utterances.length) { elements.timelineSegments.innerHTML = ''; return; } elements.timelineSegments.innerHTML = ''; const fragment = document.createDocumentFragment(); state.utterances.forEach((utt, index) => { const segment = document.createElement('div'); segment.className = 'timeline-segment'; segment.dataset.index = index; // Calculate position and width as percentage const startPercent = (utt.start / audio.duration) * 100; const endPercent = (utt.end / audio.duration) * 100; const widthPercent = endPercent - startPercent; segment.style.left = `${startPercent}%`; segment.style.width = `${widthPercent}%`; // Apply speaker color dynamically if (typeof utt.speaker === 'number') { const speakerColor = getSpeakerColor(utt.speaker); if (speakerColor) { segment.style.backgroundColor = speakerColor + '66'; // Add transparency (40%) } } else { segment.style.backgroundColor = 'rgba(148, 163, 184, 0.5)'; } // Add tooltip const speakerInfo = typeof utt.speaker === 'number' ? (state.speakerNames?.[utt.speaker]?.name || `Speaker ${utt.speaker + 1}`) : ''; segment.title = `${speakerInfo ? speakerInfo + ': ' : ''}${utt.text.substring(0, 50)}${utt.text.length > 50 ? '...' : ''}`; // Click to seek segment.addEventListener('click', (e) => { e.stopPropagation(); seekToTime(utt.start); }); fragment.appendChild(segment); }); elements.timelineSegments.appendChild(fragment); // Update active segment on time update updateActiveSegment(); } function updateActiveSegment() { const audio = elements.audioPlayer; if (!state.utterances.length) return; const currentIndex = findActiveUtterance(audio.currentTime); // Remove previous active const prevActive = elements.timelineSegments.querySelector('.timeline-segment.active'); if (prevActive) prevActive.classList.remove('active'); // Add active to current if (currentIndex >= 0) { const activeSegment = elements.timelineSegments.querySelector(`.timeline-segment[data-index="${currentIndex}"]`); if (activeSegment) activeSegment.classList.add('active'); } } async function handleSummaryGeneration() { if (state.summarizing || !state.utterances.length) return; state.summarizing = true; state.summaryController = new AbortController(); setStatus('Generating summary...', 'info'); showProgress(true); updateProgress(0, 'Initializing summary generation...'); elements.summaryOutput.textContent = ''; elements.titleOutput.textContent = ''; state.title = ''; // Show cancel button and hide summary button elements.summaryBtn.classList.add('hidden'); elements.cancelSummaryBtn.classList.remove('hidden'); const payload = { transcript: state.utterances.map((u) => u.text).join('\n'), llm_model: elements.llmSelect.value, prompt: elements.promptInput.value || 'Summarize the transcript below.', generate_title: true, }; try { const response = await fetch('/api/summarize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: state.summaryController.signal, }); if (!response.ok || !response.body) throw new Error('Failed to generate summary'); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { if (!line.trim()) continue; const event = JSON.parse(line); if (event.type === 'title' && event.content) { state.title = event.content; elements.titleOutput.textContent = event.content; updateProgress(50); } else if (event.type === 'partial' && event.content) { elements.summaryOutput.innerHTML = renderMarkdown(event.content); updateProgress(75); } } } setStatus('Summary ready', 'success'); showProgress(false); } catch (err) { if (err.name === 'AbortError') { setStatus('Summary generation cancelled', 'warning'); } else { console.error(err); setStatus(err.message, 'error'); } showProgress(false); } finally { state.summarizing = false; state.summaryController = null; // Hide cancel button and show summary button elements.cancelSummaryBtn.classList.add('hidden'); elements.summaryBtn.classList.remove('hidden'); } } async function handleSpeakerNameDetection() { if (state.detectingSpeakerNames || !state.diarizationStats) return; state.detectingSpeakerNames = true; setStatus('Detecting speaker names...', 'info'); const payload = { utterances: state.utterances, llm_model: elements.llmSelect.value, }; try { const response = await fetch('/api/detect-speaker-names', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!response.ok) throw new Error('Failed to detect speaker names'); const speakerNames = await response.json(); // Merge detected names with existing user-edited names (preserve user edits) const mergedNames = { ...speakerNames }; if (state.speakerNames) { Object.entries(state.speakerNames).forEach(([speakerId, info]) => { if (info.confidence === 'user') { // Preserve user-edited names mergedNames[speakerId] = info; } }); } state.speakerNames = mergedNames; // Re-render transcript to show detected names (force rebuild) renderTranscript(true); const detectedCount = Object.keys(speakerNames).length; if (detectedCount > 0) { setStatus(`Detected names for ${detectedCount} speaker(s)`, 'success'); } else { setStatus('No speaker names could be confidently detected', 'info'); } } catch (err) { console.error(err); setStatus(err.message, 'error'); } finally { state.detectingSpeakerNames = false; } } async function handleExportTranscript() { if (!state.utterances.length) return; const payload = { format: elements.transcriptFormat.value, include_timestamps: elements.includeTimestamps.checked, utterances: state.utterances, title: state.title || null, }; await downloadFile('/api/export/transcript', payload, 'transcript'); } async function handleExportSummary() { if (!elements.summaryOutput.textContent.trim()) return; const payload = { format: elements.summaryFormat.value, summary: elements.summaryOutput.textContent, metadata: {}, title: state.title || null, }; await downloadFile('/api/export/summary', payload, 'summary'); } async function downloadFile(url, payload, prefix) { try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!response.ok) throw new Error('Export failed'); const blob = await response.blob(); const filename = getFilenameFromDisposition(response.headers.get('Content-Disposition')) || `${prefix}.txt`; const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click(); URL.revokeObjectURL(link.href); setStatus('Export complete', 'success'); } catch (err) { console.error(err); setStatus(err.message, 'error'); } } function getFilenameFromDisposition(disposition) { if (!disposition) return null; const match = disposition.match(/filename="?([^"]+)"?/i); return match ? match[1] : null; } function handleFileUpload(event) { const file = event.target.files?.[0]; if (!file) return; // Reset complete session when new file loaded resetCompleteSession(); state.uploadedFile = file; state.audioUrl = null; const objectUrl = URL.createObjectURL(file); elements.audioPlayer.src = objectUrl; setStatus(`Loaded ${file.name}`, 'info'); } async function handleYoutubeFetch() { if (!elements.youtubeUrl.value.trim()) return; setStatus('Downloading audio from YouTube...', 'info'); try { const res = await fetch('/api/youtube/fetch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: elements.youtubeUrl.value.trim() }), }); if (!res.ok) throw new Error('YouTube download failed'); const data = await res.json(); // Reset complete session when new YouTube audio loaded resetCompleteSession(); state.audioUrl = data.audioUrl; state.uploadedFile = null; elements.audioPlayer.src = data.audioUrl; setStatus('YouTube audio ready', 'success'); } catch (err) { console.error(err); setStatus(err.message, 'error'); } } async function handlePodcastSearch() { const query = elements.podcastQuery.value.trim(); if (!query) return; setStatus('Searching podcasts...', 'info'); setListEmpty(elements.podcastResults, 'Searching podcasts...'); setListEmpty(elements.episodeResults, 'Select a podcast to view episodes.'); try { const res = await fetch(`/api/podcast/search?query=${encodeURIComponent(query)}`); if (!res.ok) throw new Error('Podcast search failed'); const series = await res.json(); if (!series.length) { setListEmpty(elements.podcastResults, 'No podcasts match your search yet.'); return; } elements.podcastResults.innerHTML = ''; const fragment = document.createDocumentFragment(); series.forEach((item) => { const div = document.createElement('div'); div.className = 'list-item'; div.innerHTML = `
${item.title}
${item.artist || 'Unknown artist'}
`; fragment.appendChild(div); }); elements.podcastResults.appendChild(fragment); setListEmpty(elements.episodeResults, 'Select a podcast to view episodes.'); } catch (err) { console.error(err); setStatus(err.message, 'error'); setListEmpty(elements.podcastResults, 'Unable to load podcasts right now.'); } } async function loadEpisodes(feedUrl, sourceItem = null) { setStatus('Loading episodes...', 'info'); if (sourceItem) { elements.podcastResults.querySelectorAll('.list-item').forEach((item) => item.classList.remove('selected')); sourceItem.classList.add('selected'); } setListEmpty(elements.episodeResults, 'Loading episodes...'); try { const res = await fetch(`/api/podcast/episodes?feed_url=${encodeURIComponent(feedUrl)}`); if (!res.ok) throw new Error('Failed to load episodes'); const episodes = await res.json(); if (!episodes.length) { setListEmpty(elements.episodeResults, 'No episodes available for this podcast.'); return; } elements.episodeResults.innerHTML = ''; const fragment = document.createDocumentFragment(); episodes.slice(0, 15).forEach((ep) => { const div = document.createElement('div'); div.className = 'list-item'; div.innerHTML = `
${ep.title}
${ep.published || ''}
`; fragment.appendChild(div); }); elements.episodeResults.appendChild(fragment); setStatus('Episodes ready', 'success'); } catch (err) { console.error(err); setStatus(err.message, 'error'); setListEmpty(elements.episodeResults, 'Unable to load episodes right now.'); } } async function downloadEpisode(audioUrl, title, triggerButton = null) { setStatus('Downloading episode...', 'info'); let originalLabel = null; if (triggerButton) { originalLabel = triggerButton.innerHTML; triggerButton.disabled = true; triggerButton.classList.add('loading'); triggerButton.textContent = 'Downloading…'; } try { const res = await fetch('/api/podcast/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audioUrl, title }), }); if (!res.ok) throw new Error('Episode download failed'); const data = await res.json(); // Reset complete session when new episode loaded resetCompleteSession(); state.audioUrl = data.audioUrl; state.uploadedFile = null; elements.audioPlayer.src = data.audioUrl; setStatus('Episode ready', 'success'); if (triggerButton) { triggerButton.textContent = '✓ Ready'; triggerButton.classList.add('success'); setTimeout(() => { triggerButton.classList.remove('success'); triggerButton.textContent = originalLabel || 'Download'; }, 3000); } } catch (err) { console.error(err); setStatus(err.message, 'error'); if (triggerButton) { triggerButton.textContent = '❌ Retry'; triggerButton.classList.add('error'); setTimeout(() => { triggerButton.classList.remove('error'); triggerButton.textContent = originalLabel || 'Download'; }, 3000); } } finally { if (triggerButton) { triggerButton.disabled = false; triggerButton.classList.remove('loading'); } } } // Initialize the application document.addEventListener('DOMContentLoaded', async () => { // Initialize tabs initTabs(); // Initialize sidebar interactions initSidebarInteractions(); // Initialize audio interactions initAudioInteractions(); // Initialize custom audio player initCustomAudioPlayer(); // Load configuration await fetchConfig(); // Initialize backend selector elements.backendSelect.innerHTML = ` `; state.backend = elements.backendSelect.value; // Initialize podcast lists setListEmpty(elements.podcastResults, 'Search to discover podcasts.'); setListEmpty(elements.episodeResults, 'Select a podcast to view episodes.'); // Set up podcast interactions elements.podcastResults.addEventListener('click', (event) => { const button = event.target.closest('button[data-feed]'); if (button) { const feedUrl = button.dataset.feed; const sourceItem = button.closest('.list-item'); loadEpisodes(feedUrl, sourceItem); } }); elements.episodeResults.addEventListener('click', (event) => { const button = event.target.closest('button[data-url]'); if (button) { const audioUrl = button.dataset.url; const title = button.dataset.title; downloadEpisode(audioUrl, title, button); } }); elements.podcastQuery.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); handlePodcastSearch(); } }); // Set up other event listeners elements.transcribeBtn.addEventListener('click', handleTranscription); elements.summaryBtn.addEventListener('click', handleSummaryGeneration); elements.detectSpeakerNamesBtn.addEventListener('click', handleSpeakerNameDetection); elements.exportTranscriptBtn.addEventListener('click', handleExportTranscript); elements.exportSummaryBtn.addEventListener('click', handleExportSummary); elements.fileInput.addEventListener('change', handleFileUpload); elements.youtubeFetch.addEventListener('click', handleYoutubeFetch); elements.podcastSearch.addEventListener('click', handlePodcastSearch); elements.cancelTranscribeBtn.addEventListener('click', () => { if (state.transcriptionController) { state.transcriptionController.abort(); } }); elements.cancelSummaryBtn.addEventListener('click', () => { if (state.summaryController) { state.summaryController.abort(); } }); // Set initial status setStatus('Ready', 'info'); });