const audio = document.getElementById("audio"); const playPause = document.getElementById("playPause"); const currentTimeEl = document.getElementById("currentTime"); const durationEl = document.getElementById("duration"); const activeSpeakerEl = document.getElementById("activeSpeaker"); const speakerListEl = document.getElementById("speakerList"); const spaceCanvas = document.getElementById("spaceCanvas"); const spectrogramCanvas = document.getElementById("spectrogramCanvas"); const spectrogramOverlay = document.getElementById("spectrogramOverlay"); const transcriptPanel = document.getElementById("transcriptPanel"); const configToggle = document.getElementById("configToggle"); const configPanel = document.getElementById("configPanel"); const opacityRange = document.getElementById("opacityRange"); const rowsRange = document.getElementById("rowsRange"); const sizeSelect = document.getElementById("sizeSelect"); const waveformEmbeddedCheckbox = document.getElementById("waveformEmbedded"); const stageEl = document.querySelector(".stage"); const appEl = document.querySelector(".app"); const spectrogramWrap = document.querySelector(".spectrogram-wrap"); const embeddedWaveform = document.getElementById("embeddedWaveform"); const embeddedWaveformCanvas = document.getElementById("embeddedWaveformCanvas"); const embeddedWaveformOverlay = document.getElementById("embeddedWaveformOverlay"); const spaceCtx = spaceCanvas.getContext("2d"); const spectrogramCtx = spectrogramCanvas.getContext("2d"); const spectrogramOverlayCtx = spectrogramOverlay.getContext("2d"); const embeddedWaveformCtx = embeddedWaveformCanvas ? embeddedWaveformCanvas.getContext("2d") : null; const embeddedWaveformOverlayCtx = embeddedWaveformOverlay ? embeddedWaveformOverlay.getContext("2d") : null; // Use values from config.js const transcriptPath = CONFIG.transcript_path; const waveformPath = CONFIG.waveform_path; const START_OFFSET_SECONDS = CONFIG.start_offset_seconds; const SPEAKER_LABELS = CONFIG.speaker_labels; const palette = CONFIG.palette; // Set audio source from config audio.src = CONFIG.audio_path; const state = { speakers: [], speakerMap: new Map(), intervals: [], segments: [], activeSpeaker: null, playheadDragging: false, waveformData: null, waveformReady: false, waveformLoading: false, waveformEmbedded: false, stars: [], spaceSize: { width: 0, height: 0 }, waveformSize: { width: 0, height: 0 }, transcriptRows: CONFIG.transcript.rows, transcriptOpacity: CONFIG.transcript.opacity, transcriptSize: CONFIG.transcript.size, }; function formatTime(seconds) { if (!Number.isFinite(seconds)) return "0:00"; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${String(secs).padStart(2, "0")}`; } function hashString(input) { let hash = 0; for (let i = 0; i < input.length; i += 1) { hash = (hash << 5) - hash + input.charCodeAt(i); hash |= 0; } return Math.abs(hash); } function displayName(id) { return SPEAKER_LABELS[id] || id; } function buildIntervals(segments) { const raw = []; segments.forEach((segment) => { const segmentSpeaker = segment.speaker || null; if (Array.isArray(segment.words) && segment.words.length) { segment.words.forEach((word) => { const speaker = word.speaker || segmentSpeaker; if (!speaker || word.start == null || word.end == null) return; raw.push({ start: word.start, end: word.end, speaker }); }); return; } if (segmentSpeaker && segment.start != null && segment.end != null) { raw.push({ start: segment.start, end: segment.end, speaker: segmentSpeaker }); } }); raw.sort((a, b) => a.start - b.start); const merged = []; raw.forEach((entry) => { const prev = merged[merged.length - 1]; if (prev && prev.speaker === entry.speaker && entry.start <= prev.end + 0.05) { prev.end = Math.max(prev.end, entry.end); return; } merged.push({ ...entry }); }); return merged; } function buildSpeakers(intervals) { const talkTimes = new Map(); intervals.forEach((interval) => { const duration = Math.max(0, interval.end - interval.start); talkTimes.set(interval.speaker, (talkTimes.get(interval.speaker) || 0) + duration); }); const maxTalkTime = Math.max(1, ...talkTimes.values()); const speakers = []; const speakerMap = new Map(); intervals.forEach((interval) => { if (!speakerMap.has(interval.speaker)) { const index = speakerMap.size; const seed = hashString(interval.speaker); const talkTime = talkTimes.get(interval.speaker) || 0; const growth = talkTime / maxTalkTime; const speaker = { id: interval.speaker, color: palette[index % palette.length], pulse: 0, talkTime, growth, baseSize: 12 + growth * 18, x: 0, y: 0, }; speakers.push(speaker); speakerMap.set(interval.speaker, speaker); } }); return { speakers, speakerMap }; } function layoutSpeakers() { const { width, height } = spaceCanvas.getBoundingClientRect(); if (width <= 0 || height <= 0 || !state.speakers.length) return; const centerX = width / 2; const centerY = height / 2; // Find active speaker or default to first with most talk time const activeId = state.activeSpeaker; let activeSpeaker = state.speakerMap.get(activeId); if (!activeSpeaker && state.speakers.length > 0) { // Default to speaker with most talk time activeSpeaker = [...state.speakers].sort((a, b) => b.talkTime - a.talkTime)[0]; } // Position active speaker at center if (activeSpeaker) { activeSpeaker.x = centerX; activeSpeaker.y = centerY; } // Arrange others in orbital rings const others = state.speakers.filter(s => s !== activeSpeaker); const maxRadius = Math.min(width, height) * 0.38; // Calculate rings based on speaker count const speakersPerRing = 8; const ringCount = Math.max(1, Math.ceil(others.length / speakersPerRing)); // Sort others by talk time for consistent positioning const sortedOthers = [...others].sort((a, b) => b.talkTime - a.talkTime); sortedOthers.forEach((speaker, index) => { const ring = Math.floor(index / speakersPerRing); const posInRing = index % speakersPerRing; const ringSpeakerCount = Math.min(speakersPerRing, sortedOthers.length - ring * speakersPerRing); // Calculate ring radius - inner rings are closer const ringRadius = 60 + maxRadius * ((ring + 1) / (ringCount + 0.5)); // Stagger angles between rings for visual interest const angleOffset = ring * 0.3; const angle = (posInRing / ringSpeakerCount) * Math.PI * 2 + angleOffset - Math.PI / 2; // Add slight jitter for organic feel const jitter = (hashString(speaker.id) % 8) - 4; speaker.x = centerX + Math.cos(angle) * ringRadius + jitter; speaker.y = centerY + Math.sin(angle) * ringRadius + jitter; }); } function renderSpeakerList() { speakerListEl.innerHTML = ""; const preview = state.speakers.slice(0, 8); preview.forEach((speaker) => { const chip = document.createElement("div"); chip.className = "speaker-chip"; const dot = document.createElement("span"); dot.className = "chip-dot"; dot.style.background = speaker.color; const label = document.createElement("span"); label.textContent = displayName(speaker.id); chip.append(dot, label); speakerListEl.append(chip); }); if (state.speakers.length > preview.length) { const extra = document.createElement("div"); extra.className = "speaker-chip"; extra.textContent = `+${state.speakers.length - preview.length} more`; speakerListEl.append(extra); } } function renderTranscript(time) { if (!transcriptPanel) return; const rows = state.transcriptRows; const segments = state.segments; if (!segments.length) { transcriptPanel.innerHTML = ""; return; } let idx = 0; for (let i = segments.length - 1; i >= 0; i -= 1) { if (time >= segments[i].start) { idx = i; break; } } const start = Math.max(0, idx - rows + 1); const visible = segments.slice(start, idx + 1); transcriptPanel.innerHTML = ""; let lastSpeaker = null; visible.forEach((segment, index) => { const line = document.createElement("div"); line.className = "transcript-line"; const currentSpeaker = segment.speaker || "UNKNOWN"; const speaker = document.createElement("span"); speaker.className = "transcript-speaker"; // Only show speaker label if different from previous line, or if it's the first line if (currentSpeaker !== lastSpeaker || index === 0) { speaker.textContent = displayName(currentSpeaker); } else { speaker.textContent = ""; speaker.classList.add("transcript-speaker-hidden"); } lastSpeaker = currentSpeaker; const text = document.createElement("span"); text.className = "transcript-text"; text.textContent = segment.text.trim(); line.append(speaker, text); transcriptPanel.append(line); }); } function findActiveSpeaker(time) { if (!state.intervals.length) return null; let low = 0; let high = state.intervals.length - 1; while (low <= high) { const mid = Math.floor((low + high) / 2); const entry = state.intervals[mid]; if (time < entry.start) { high = mid - 1; } else if (time > entry.end) { low = mid + 1; } else { return entry.speaker; } } return null; } function resizeCanvas(canvas, ctx) { const { width, height } = canvas.getBoundingClientRect(); const ratio = window.devicePixelRatio || 1; const scaledWidth = Math.max(1, Math.floor(width * ratio)); const scaledHeight = Math.max(1, Math.floor(height * ratio)); if (canvas.width !== scaledWidth || canvas.height !== scaledHeight) { canvas.width = scaledWidth; canvas.height = scaledHeight; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(ratio, ratio); } } function resizeAll() { resizeCanvas(spaceCanvas, spaceCtx); resizeCanvas(spectrogramCanvas, spectrogramCtx); resizeCanvas(spectrogramOverlay, spectrogramOverlayCtx); if (embeddedWaveformCanvas && embeddedWaveformCtx) { resizeCanvas(embeddedWaveformCanvas, embeddedWaveformCtx); } if (embeddedWaveformOverlay && embeddedWaveformOverlayCtx) { resizeCanvas(embeddedWaveformOverlay, embeddedWaveformOverlayCtx); } const { width, height } = spaceCanvas.getBoundingClientRect(); if (width > 0 && height > 0 && (width !== state.spaceSize.width || height !== state.spaceSize.height)) { state.spaceSize = { width, height }; state.stars = seedStars(width, height); layoutSpeakers(); } const specRect = spectrogramCanvas.getBoundingClientRect(); if (specRect.width > 0 && specRect.height > 0) { if (specRect.width !== state.waveformSize.width || specRect.height !== state.waveformSize.height) { state.waveformSize = { width: specRect.width, height: specRect.height }; } } if (state.waveformReady && state.waveformData) { drawWaveform(state.waveformData.peaks); } } function seedStars(width, height) { const stars = []; const count = Math.floor((width * height) / 5500); for (let i = 0; i < count; i += 1) { stars.push({ x: Math.random() * width, y: Math.random() * height, radius: Math.random() * 1.8 + 0.2, alpha: Math.random() * 0.7 + 0.2, }); } return stars; } function renderSpace(timeMs) { const { width, height } = spaceCanvas.getBoundingClientRect(); spaceCtx.clearRect(0, 0, width, height); // Solid dark background - no stars spaceCtx.fillStyle = "#0a0d13"; spaceCtx.fillRect(0, 0, width, height); state.speakers.forEach((speaker, index) => { const wobble = Math.sin((timeMs / 700) + index) * 1.5; const x = speaker.x + wobble; const y = speaker.y - wobble; const isActive = speaker.id === state.activeSpeaker; speaker.pulse += isActive ? 0.08 : 0.02; const pulse = 1 + Math.sin(speaker.pulse) * (isActive ? 0.25 : 0.08); const size = speaker.baseSize * pulse * (isActive ? 1.4 : 1); // Subtle glow - toned down const glowRadius = isActive ? size * 2.2 : size * 1.6; const glow = spaceCtx.createRadialGradient(x, y, 0, x, y, glowRadius); if (isActive) { glow.addColorStop(0, `${speaker.color}55`); glow.addColorStop(0.6, `${speaker.color}22`); glow.addColorStop(1, "rgba(0,0,0,0)"); } else { glow.addColorStop(0, `${speaker.color}33`); glow.addColorStop(1, "rgba(0,0,0,0)"); } spaceCtx.fillStyle = glow; spaceCtx.beginPath(); spaceCtx.arc(x, y, glowRadius, 0, Math.PI * 2); spaceCtx.fill(); // Solid circle spaceCtx.fillStyle = speaker.color; spaceCtx.beginPath(); spaceCtx.arc(x, y, size, 0, Math.PI * 2); spaceCtx.fill(); // Label spaceCtx.fillStyle = isActive ? "#ffffff" : "#c8ccd4"; spaceCtx.font = isActive ? "bold 12px 'Trebuchet MS', sans-serif" : "11px 'Trebuchet MS', sans-serif"; spaceCtx.textAlign = "center"; spaceCtx.fillText(displayName(speaker.id), x, y - size - 8); }); } function updatePlayhead() { const { width, height } = spectrogramOverlay.getBoundingClientRect(); spectrogramOverlayCtx.clearRect(0, 0, width, height); // Also update embedded waveform overlay if (embeddedWaveformOverlay && embeddedWaveformOverlayCtx) { const embRect = embeddedWaveformOverlay.getBoundingClientRect(); embeddedWaveformOverlayCtx.clearRect(0, 0, embRect.width, embRect.height); } if (!Number.isFinite(audio.duration) || audio.duration <= 0) return; const progress = audio.currentTime / audio.duration; const x = progress * width; spectrogramOverlayCtx.strokeStyle = "#f4d35e"; spectrogramOverlayCtx.lineWidth = 2; spectrogramOverlayCtx.beginPath(); spectrogramOverlayCtx.moveTo(x, 0); spectrogramOverlayCtx.lineTo(x, height); spectrogramOverlayCtx.stroke(); // Draw playhead on embedded waveform overlay if (embeddedWaveformOverlay && embeddedWaveformOverlayCtx) { const embRect = embeddedWaveformOverlay.getBoundingClientRect(); const embX = progress * embRect.width; embeddedWaveformOverlayCtx.strokeStyle = "#f4d35e"; embeddedWaveformOverlayCtx.lineWidth = 2; embeddedWaveformOverlayCtx.beginPath(); embeddedWaveformOverlayCtx.moveTo(embX, 0); embeddedWaveformOverlayCtx.lineTo(embX, embRect.height); embeddedWaveformOverlayCtx.stroke(); } } function drawWaveformOnCanvas(ctx, peaks, width, height) { ctx.clearRect(0, 0, width, height); if (!peaks || !peaks.length) return; // Fill background ctx.fillStyle = "#06080e"; ctx.fillRect(0, 0, width, height); // Draw waveform bars ctx.strokeStyle = "#55e6c1"; ctx.lineWidth = Math.max(1, width / peaks.length); const centerY = height / 2; const columnWidth = width / peaks.length; for (let i = 0; i < peaks.length; i++) { const peak = peaks[i]; const x = i * columnWidth + columnWidth / 2; const y1 = centerY + peak.min * centerY; const y2 = centerY + peak.max * centerY; ctx.beginPath(); ctx.moveTo(x, y1); ctx.lineTo(x, y2); ctx.stroke(); } } function drawWaveform(peaks) { const { width, height } = spectrogramCanvas.getBoundingClientRect(); drawWaveformOnCanvas(spectrogramCtx, peaks, width, height); // Also draw on embedded waveform canvas if (embeddedWaveformCanvas && embeddedWaveformCtx) { const embRect = embeddedWaveformCanvas.getBoundingClientRect(); drawWaveformOnCanvas(embeddedWaveformCtx, peaks, embRect.width, embRect.height); } } async function loadWaveformData() { if (state.waveformLoading || state.waveformReady) return; state.waveformLoading = true; if (spectrogramWrap) { spectrogramWrap.classList.add("loading"); } try { const response = await fetch(waveformPath); if (!response.ok) { throw new Error(`Waveform data not found: ${waveformPath}`); } state.waveformData = await response.json(); state.waveformReady = true; drawWaveform(state.waveformData.peaks); } catch (err) { console.error("Failed to load waveform data:", err.message); console.info("Generate waveform data using: node scripts/generate-waveform.js "); } finally { state.waveformLoading = false; if (spectrogramWrap) { spectrogramWrap.classList.remove("loading"); } } } function attachScrubHandlers() { if (!spectrogramWrap) return; let wasPlaying = false; const onDown = (event) => { state.playheadDragging = true; wasPlaying = !audio.paused; if (event.pointerId != null) { spectrogramWrap.setPointerCapture(event.pointerId); } scrubToEvent(event); }; const onMove = (event) => { if (!state.playheadDragging) return; scrubToEvent(event); }; const onUp = (event) => { const wasDragging = state.playheadDragging; state.playheadDragging = false; if (event.pointerId != null) { spectrogramWrap.releasePointerCapture(event.pointerId); } // Start playback on any timeline click/scrub if (wasDragging) { audio.play().catch(() => {}); } }; spectrogramWrap.addEventListener("pointerdown", onDown); spectrogramWrap.addEventListener("pointermove", onMove); spectrogramWrap.addEventListener("pointerup", onUp); spectrogramWrap.addEventListener("pointerleave", () => { const wasDragging = state.playheadDragging; state.playheadDragging = false; if (wasDragging && wasPlaying) { audio.play().catch(() => {}); } }); spectrogramWrap.addEventListener("mousedown", onDown); window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); spectrogramWrap.addEventListener("touchstart", (event) => { event.preventDefault(); onDown(event.touches[0]); }, { passive: false }); spectrogramWrap.addEventListener("touchmove", (event) => { event.preventDefault(); onMove(event.touches[0]); }, { passive: false }); spectrogramWrap.addEventListener("touchend", (event) => { event.preventDefault(); onUp(event.changedTouches[0] || event.touches[0]); }, { passive: false }); } function scrubToEvent(event) { if (!Number.isFinite(audio.duration)) return; if (!spectrogramWrap) return; const rect = spectrogramWrap.getBoundingClientRect(); const percent = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)); audio.currentTime = percent * audio.duration; } function applyTranscriptStyle() { if (!transcriptPanel) return; const sizes = { small: { fontSize: "0.78rem", lineHeight: "1.35", width: "min(360px, 52vw)", padding: "12px 14px" }, medium: { fontSize: "0.9rem", lineHeight: "1.4", width: "min(460px, 58vw)", padding: "14px 16px" }, large: { fontSize: "1.2rem", lineHeight: "1.5", width: "min(760px, 92vw)", padding: "18px 20px" }, }; const preset = sizes[state.transcriptSize] || sizes.medium; transcriptPanel.style.setProperty("--transcript-opacity", state.transcriptOpacity.toString()); transcriptPanel.style.setProperty("--transcript-font-size", preset.fontSize); transcriptPanel.style.setProperty("--transcript-line-height", preset.lineHeight); transcriptPanel.style.setProperty("--transcript-width", preset.width); transcriptPanel.style.setProperty("--transcript-padding", preset.padding); } function applyWaveformPosition() { if (!appEl) return; if (state.waveformEmbedded) { appEl.classList.add("waveform-embedded"); } else { appEl.classList.remove("waveform-embedded"); } // Sync embedded waveform width with transcript panel if (embeddedWaveform && transcriptPanel) { const transcriptWidth = getComputedStyle(transcriptPanel).getPropertyValue("--transcript-width") || "min(420px, 55vw)"; embeddedWaveform.style.width = transcriptWidth; } // Trigger resize to redraw waveform requestAnimationFrame(() => resizeAll()); } function attachEmbeddedScrubHandlers() { if (!embeddedWaveform) return; let wasPlaying = false; const onDown = (event) => { state.playheadDragging = true; wasPlaying = !audio.paused; if (event.pointerId != null) { embeddedWaveform.setPointerCapture(event.pointerId); } scrubToEventEmbedded(event); }; const onMove = (event) => { if (!state.playheadDragging) return; scrubToEventEmbedded(event); }; const onUp = (event) => { const wasDragging = state.playheadDragging; state.playheadDragging = false; if (event.pointerId != null) { embeddedWaveform.releasePointerCapture(event.pointerId); } if (wasDragging && wasPlaying) { audio.play().catch(() => {}); } }; embeddedWaveform.addEventListener("pointerdown", onDown); embeddedWaveform.addEventListener("pointermove", onMove); embeddedWaveform.addEventListener("pointerup", onUp); embeddedWaveform.addEventListener("pointerleave", () => { const wasDragging = state.playheadDragging; state.playheadDragging = false; if (wasDragging && wasPlaying) { audio.play().catch(() => {}); } }); embeddedWaveform.addEventListener("mousedown", onDown); window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); embeddedWaveform.addEventListener("touchstart", (event) => { event.preventDefault(); onDown(event.touches[0]); }, { passive: false }); embeddedWaveform.addEventListener("touchmove", (event) => { event.preventDefault(); onMove(event.touches[0]); }, { passive: false }); embeddedWaveform.addEventListener("touchend", (event) => { event.preventDefault(); onUp(event.changedTouches[0] || event.touches[0]); }, { passive: false }); } function scrubToEventEmbedded(event) { if (!Number.isFinite(audio.duration)) return; if (!embeddedWaveform) return; const rect = embeddedWaveform.getBoundingClientRect(); const percent = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)); audio.currentTime = percent * audio.duration; } function loop(timeMs) { state.activeSpeaker = findActiveSpeaker(audio.currentTime); activeSpeakerEl.textContent = state.activeSpeaker ? displayName(state.activeSpeaker) : "-"; currentTimeEl.textContent = formatTime(audio.currentTime); durationEl.textContent = formatTime(audio.duration); renderSpace(timeMs); renderTranscript(audio.currentTime); updatePlayhead(); requestAnimationFrame(loop); } async function init() { resizeAll(); window.addEventListener("resize", resizeAll); window.addEventListener("load", () => requestAnimationFrame(resizeAll)); const resizeObserver = new ResizeObserver(() => resizeAll()); if (stageEl) resizeObserver.observe(stageEl); if (spectrogramWrap) resizeObserver.observe(spectrogramWrap); if (embeddedWaveform) resizeObserver.observe(embeddedWaveform); attachScrubHandlers(); attachEmbeddedScrubHandlers(); // Load waveform data immediately on page load loadWaveformData(); playPause.addEventListener("click", () => { if (audio.paused) { audio.play(); playPause.textContent = "Pause"; } else { audio.pause(); playPause.textContent = "Play"; } }); window.addEventListener("keydown", (event) => { if (event.target && ["INPUT", "TEXTAREA"].includes(event.target.tagName)) return; const step = event.shiftKey ? 60 : 10; if (event.code === "Space") { event.preventDefault(); if (audio.paused) { audio.play(); playPause.textContent = "Pause"; } else { audio.pause(); playPause.textContent = "Play"; } return; } if (event.code === "ArrowLeft" || event.code === "KeyA") { event.preventDefault(); audio.currentTime = Math.max(0, audio.currentTime - step); return; } if (event.code === "ArrowRight" || event.code === "KeyD") { event.preventDefault(); if (Number.isFinite(audio.duration)) { audio.currentTime = Math.min(audio.duration, audio.currentTime + step); } else { audio.currentTime += step; } } }); audio.addEventListener("ended", () => { playPause.textContent = "Play"; }); audio.addEventListener("loadedmetadata", () => { // Start at minute 10 by default if (audio.currentTime < START_OFFSET_SECONDS && audio.duration >= START_OFFSET_SECONDS) { audio.currentTime = START_OFFSET_SECONDS; } if (Number.isFinite(audio.duration) && audio.duration > 0) { audio.currentTime = Math.min(START_OFFSET_SECONDS, Math.max(0, audio.duration - 0.01)); } }); if (configToggle && configPanel) { configToggle.addEventListener("click", () => { configPanel.classList.toggle("open"); }); window.addEventListener("click", (event) => { if (configPanel.contains(event.target) || configToggle.contains(event.target)) return; configPanel.classList.remove("open"); }); } if (opacityRange) { opacityRange.addEventListener("input", () => { state.transcriptOpacity = Number(opacityRange.value); applyTranscriptStyle(); }); } if (rowsRange) { rowsRange.addEventListener("input", () => { state.transcriptRows = Number(rowsRange.value); }); } if (sizeSelect) { sizeSelect.addEventListener("change", () => { state.transcriptSize = sizeSelect.value; applyTranscriptStyle(); }); } if (waveformEmbeddedCheckbox) { waveformEmbeddedCheckbox.addEventListener("change", () => { state.waveformEmbedded = waveformEmbeddedCheckbox.checked; applyWaveformPosition(); }); } const response = await fetch(transcriptPath); const transcript = await response.json(); state.intervals = buildIntervals(transcript.segments || []); state.segments = (transcript.segments || []).map((segment) => ({ start: segment.start, end: segment.end, text: segment.text || "", speaker: segment.speaker, })); const { speakers, speakerMap } = buildSpeakers(state.intervals); state.speakers = speakers; state.speakerMap = speakerMap; renderSpeakerList(); layoutSpeakers(); applyTranscriptStyle(); requestAnimationFrame(loop); } init();