Files
space-talkers/app.js

781 lines
26 KiB
JavaScript

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;
const transcriptPath = "outputs/float32/amuta_2026-01-12_1.json";
const waveformPath = "outputs/float32/amuta_2026-01-12_1.waveform.json";
const START_OFFSET_SECONDS = 600;
const SPEAKER_LABELS = {
// Example: "SPEAKER_01": "Maya",
};
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: 6,
transcriptOpacity: 0.85,
transcriptSize: "medium",
};
const palette = [
"#52f0c5",
"#ffb347",
"#ff7aa2",
"#9cbbff",
"#f4d35e",
"#7bffd2",
"#ffb4a2",
"#b8e1ff",
"#ffd36d",
"#d0b3ff",
];
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 <audio-file>");
} 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();