- Add speaker_merges config option in config.js - Merge SPEAKER_08 and SPEAKER_09 into SPEAKER_07 - Add resolveSpeaker() function to map through merges - Apply merges in buildIntervals() and renderTranscript()
786 lines
26 KiB
JavaScript
786 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;
|
|
|
|
// 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;
|
|
|
|
// Build speaker merge map (source -> target)
|
|
const speakerMergeMap = new Map();
|
|
if (CONFIG.speaker_merges) {
|
|
Object.entries(CONFIG.speaker_merges).forEach(([target, sources]) => {
|
|
sources.forEach((source) => {
|
|
speakerMergeMap.set(source, target);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Resolve speaker ID through merge map
|
|
function resolveSpeaker(speakerId) {
|
|
return speakerMergeMap.get(speakerId) || speakerId;
|
|
}
|
|
|
|
// 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 = resolveSpeaker(segment.speaker || null);
|
|
if (Array.isArray(segment.words) && segment.words.length) {
|
|
segment.words.forEach((word) => {
|
|
const speaker = resolveSpeaker(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 = resolveSpeaker(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();
|