Zum Inhalt

visualizer/src/utils/timeline.ts

import { useEffect, useMemo, useState } from 'react';
import { cancelRender, continueRender, delayRender, staticFile } from 'remotion';

/* ────────────────────────────────────────────────────────────────────────
   WhisperX standard schema (the `--diarize` JSON output).
   We type it loosely — WhisperX adds optional fields across versions
   (language, alignment confidences, etc.) and we don't need them all.
   ──────────────────────────────────────────────────────────────────────── */

export type WhisperXWord = {
  word: string;
  start?: number; // seconds; rare but possible to be missing on filler tokens
  end?: number;
  score?: number; // 0..1, alignment confidence
  speaker?: string; // 'SPEAKER_00' etc — sometimes only on segment level
};

export type WhisperXSegment = {
  start: number;
  end: number;
  text: string;
  speaker?: string;
  words?: WhisperXWord[];
};

export type WhisperXFile = {
  segments: WhisperXSegment[];
  word_segments?: WhisperXWord[]; // top-level mirror in newer versions
  language?: string;
};

/* ────────────────────────────────────────────────────────────────────────
   Public timeline shape — what every component reads from.
   Speakers normalised to a 0/1 index for two-lane visualisers; original
   tag preserved for caption chips and confidence labels.
   ──────────────────────────────────────────────────────────────────────── */

export type Turn = {
  startMs: number;
  endMs: number;
  speaker: string; // 'SPEAKER_00'
  speakerIdx: 0 | 1; // ≥2 collapses to 1 — second lane
  text: string;
  words: TimedWord[];
  /** Mean alignment confidence over words; falls back to 1 if absent */
  score: number;
};

export type TimedWord = {
  word: string;
  startMs: number;
  endMs: number;
  speaker: string;
  speakerIdx: 0 | 1;
  score: number;
};

export type ChapterMarker = {
  index: number;
  startMs: number;
  /** End is the next chapter's start, or +Infinity for the last. */
  endMs: number;
  title: string;
};

export type Timeline = {
  ready: boolean;
  turns: Turn[];
  words: TimedWord[];
  chapters: ChapterMarker[];
};

/* ────────────────────────────────────────────────────────────────────────
   Loader
   ──────────────────────────────────────────────────────────────────────── */

export const speakerToIdx = (speaker: string | undefined): 0 | 1 => {
  if (!speaker) return 0;
  const m = speaker.match(/(\d+)/);
  if (!m) return 0;
  const n = parseInt(m[1], 10);
  return n === 0 ? 0 : 1; // ≥1 collapses to lane 1
};

export const normaliseFile = (
  raw: WhisperXFile,
): { turns: Turn[]; words: TimedWord[]; chapters: ChapterMarker[] } => {
  const turns: Turn[] = [];
  const words: TimedWord[] = [];

  for (const seg of raw.segments ?? []) {
    const speaker = seg.speaker ?? seg.words?.find((w) => w.speaker)?.speaker ?? 'SPEAKER_00';
    const speakerIdx = speakerToIdx(speaker);
    const segWords: TimedWord[] = (seg.words ?? [])
      .filter((w) => w.start != null && w.end != null && w.word)
      .map((w) => {
        const wSpk = w.speaker ?? speaker;
        return {
          word: w.word,
          startMs: (w.start as number) * 1000,
          endMs: (w.end as number) * 1000,
          speaker: wSpk,
          speakerIdx: speakerToIdx(wSpk),
          score: typeof w.score === 'number' ? w.score : 1,
        };
      });

    const score =
      segWords.length > 0 ? segWords.reduce((a, w) => a + w.score, 0) / segWords.length : 1;

    turns.push({
      startMs: seg.start * 1000,
      endMs: seg.end * 1000,
      speaker,
      speakerIdx,
      text: seg.text.trim(),
      words: segWords,
      score,
    });
    words.push(...segWords);
  }

  // If the file has a top-level word_segments[] we use it as the source of truth
  // for word timing (it sometimes contains words that fall between segments).
  if (raw.word_segments && raw.word_segments.length > 0 && words.length === 0) {
    for (const w of raw.word_segments) {
      if (w.start == null || w.end == null) continue;
      const speaker = w.speaker ?? 'SPEAKER_00';
      words.push({
        word: w.word,
        startMs: w.start * 1000,
        endMs: w.end * 1000,
        speaker,
        speakerIdx: speakerToIdx(speaker),
        score: typeof w.score === 'number' ? w.score : 1,
      });
    }
  }

  // Stable sort, just in case input wasn't.
  turns.sort((a, b) => a.startMs - b.startMs);
  words.sort((a, b) => a.startMs - b.startMs);

  // Chapter detection — segments whose text begins with `[CHAPTER]`
  // (or `[CHAPTER: Foo]`) become section markers. The chapter title
  // is the rest of that segment (or the bracketed name).
  const chapters: ChapterMarker[] = [];
  raw.segments?.forEach((seg) => {
    const m = seg.text.match(/^\s*\[CHAPTER(?:\s*[:·-]\s*([^\]]+))?\]\s*(.*)$/i);
    if (!m) return;
    const title = (m[1] || m[2] || 'Chapter').trim();
    chapters.push({
      index: chapters.length,
      startMs: seg.start * 1000,
      endMs: Number.POSITIVE_INFINITY,
      title,
    });
  });
  for (let i = 0; i < chapters.length - 1; i++) {
    chapters[i].endMs = chapters[i + 1].startMs;
  }

  return { turns, words, chapters };
};

/**
 * Load WhisperX JSON once. delayRender bridges the fetch into the
 * Remotion render so the renderer waits before grabbing frame 0.
 *
 * Default path: `staticFile('podcast.whisperx.json')`.
 */
export const useTimeline = (src: string = staticFile('podcast.whisperx.json')): Timeline => {
  const [data, setData] = useState<{
    turns: Turn[];
    words: TimedWord[];
    chapters: ChapterMarker[];
  }>({ turns: [], words: [], chapters: [] });
  const [ready, setReady] = useState(false);
  const [handle] = useState(() => delayRender('Loading WhisperX timeline'));

  useEffect(() => {
    let cancelled = false;
    fetch(src)
      .then((r) => {
        if (!r.ok) throw new Error(`Timeline fetch failed: ${r.status}`);
        return r.json();
      })
      .then((json: WhisperXFile) => {
        if (cancelled) return;
        setData(normaliseFile(json));
        setReady(true);
        continueRender(handle);
      })
      .catch((err) => cancelRender(err));
    return () => {
      cancelled = true;
    };
  }, [src, handle]);

  return useMemo(
    () => ({
      ready,
      turns: data.turns,
      words: data.words,
      chapters: data.chapters,
    }),
    [ready, data],
  );
};

/**
 * Resolve the active chapter for a given frame, plus the signed delta
 * from its boundary (positive = into the chapter, negative = before it).
 * SectionCard uses this for entry timing.
 */
export const useActiveChapter = (
  timeline: Timeline,
  frame: number,
  fps: number,
): { chapter: ChapterMarker | null; framesFromBoundary: number } => {
  return useMemo(() => {
    const ms = (frame / fps) * 1000;
    const chapter = timeline.chapters.find((c) => ms >= c.startMs && ms < c.endMs) ?? null;
    const framesFromBoundary = chapter ? ((ms - chapter.startMs) / 1000) * fps : 0;
    return { chapter, framesFromBoundary };
  }, [timeline.chapters, frame, fps]);
};

/* ────────────────────────────────────────────────────────────────────────
   Selectors — frame-keyed lookups built on top of a Timeline.

   All return null when nothing is active. Selectors avoid binary search
   for now (linear scan is fine up to ~5k segments — well under a typical
   60-min podcast). We can swap to a sorted-index later if needed.
   ──────────────────────────────────────────────────────────────────────── */

export const useActiveTurn = (timeline: Timeline, frame: number, fps: number): Turn | null => {
  return useMemo(() => {
    const ms = (frame / fps) * 1000;
    return timeline.turns.find((t) => ms >= t.startMs && ms < t.endMs) ?? null;
  }, [timeline.turns, frame, fps]);
};

export const useActiveWord = (timeline: Timeline, frame: number, fps: number): TimedWord | null => {
  return useMemo(() => {
    const ms = (frame / fps) * 1000;
    return timeline.words.find((w) => ms >= w.startMs && ms < w.endMs) ?? null;
  }, [timeline.words, frame, fps]);
};

/**
 * Resolve the "effective" speaker, taking PRE-ROLL into account.
 *
 * Visual cues should fire *before* the speaker actually starts so the
 * lane swap doesn't feel like a delay relative to the audio. We look
 * `lookaheadFrames` ahead: if a turn starts in that window, that turn's
 * speaker becomes effective NOW.
 *
 * Returns the active turn, the upcoming turn (if within lookahead),
 * and the effective speaker index — what every visualiser should bind
 * its lane state to.
 */
export type EffectiveSpeaker = {
  current: Turn | null;
  upcoming: Turn | null;
  /** Frames until upcoming turn starts; 0 if not in lookahead. */
  framesUntilUpcoming: number;
  /** 0 | 1 if any speaker is effective, otherwise null (silence). */
  effectiveIdx: 0 | 1 | null;
};

export const useEffectiveSpeaker = (
  timeline: Timeline,
  frame: number,
  fps: number,
  lookaheadFrames: number = 4,
): EffectiveSpeaker => {
  return useMemo(() => {
    const nowMs = (frame / fps) * 1000;
    const horizonMs = nowMs + (lookaheadFrames / fps) * 1000;

    const current = timeline.turns.find((t) => nowMs >= t.startMs && nowMs < t.endMs) ?? null;

    // First turn whose start is in (now, horizon].
    const upcoming =
      timeline.turns.find((t) => t.startMs > nowMs && t.startMs <= horizonMs) ?? null;

    let effectiveIdx: 0 | 1 | null = null;
    if (current) effectiveIdx = current.speakerIdx;
    else if (upcoming) effectiveIdx = upcoming.speakerIdx;

    const framesUntilUpcoming = upcoming
      ? Math.max(0, ((upcoming.startMs - nowMs) / 1000) * fps)
      : 0;

    return { current, upcoming, framesUntilUpcoming, effectiveIdx };
  }, [timeline.turns, frame, fps, lookaheadFrames]);
};

/**
 * Look up the next turn after `frame`, regardless of horizon.
 * Used by the caption-preview ghost line.
 */
export const useNextTurn = (timeline: Timeline, frame: number, fps: number): Turn | null => {
  return useMemo(() => {
    const ms = (frame / fps) * 1000;
    return timeline.turns.find((t) => t.startMs > ms) ?? null;
  }, [timeline.turns, frame, fps]);
};