Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion components/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export default function ExportDialog() {
const duration = useEditorStore((s) => s.duration);
const words = useEditorStore((s) => s.words);
const speakers = useEditorStore((s) => s.speakers);
const hasAudioTrack = useEditorStore((s) => s.audio !== null);
const hasAudioTrack = useEditorStore((s) => s.hasAudio);
const status = useEditorStore((s) => s.status);
const setStatus = useEditorStore((s) => s.setStatus);
const exportUrl = useEditorStore((s) => s.exportUrl);
Expand Down
24 changes: 8 additions & 16 deletions components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
import type { ClipSegment, Word } from "@/lib/types";
import { isDisfluencyPlaceholder } from "@/lib/disfluencies";
import { VAD_SAMPLE_RATE } from "@/lib/vad";
import { peakBetween } from "@/lib/waveform";
import { useCutRanges } from "@/hooks/useCutRanges";
import { useIsDark } from "@/hooks/useIsDark";

Expand Down Expand Up @@ -94,7 +95,7 @@ type DragKind =
| { type: "trim"; edge: "in" | "out"; time: number; lo: number; hi: number };

export default function Timeline() {
const audio = useEditorStore((s) => s.audio);
const waveform = useEditorStore((s) => s.waveform);
const words = useEditorStore((s) => s.words);
const sceneBoundaries = useEditorStore((s) => s.sceneBoundaries);
const duration = useEditorStore((s) => s.duration);
Expand Down Expand Up @@ -319,32 +320,23 @@ export default function Timeline() {
ctx.restore();
});

// Waveform
if (!audio) return;
// Waveform. Drawn from the precomputed min/max envelope rather than the
// decoded PCM — see lib/waveform.ts. peakBetween already clamps the
// overshoot hot sources produce, so the bar cannot spill into the wordbar.
if (!waveform) return;
const samplesPerPx = VAD_SAMPLE_RATE / pps;
const stride = Math.max(1, Math.floor(samplesPerPx / 40));
for (let x = 0; x < width; x++) {
const t = (scrollLeft + x) / pps;
if (t > duration) break;
const i0 = Math.floor(t * VAD_SAMPLE_RATE);
const i1 = Math.min(audio.length, Math.floor(i0 + samplesPerPx) + 1);
let min = 0;
let max = 0;
for (let i = i0; i < i1; i += stride) {
const v = audio[i];
if (v < min) min = v;
if (v > max) max = v;
}
const peak = peakBetween(waveform, i0, Math.floor(i0 + samplesPerPx) + 1);
const inCut = cuts.some((c) => t >= c.start && t < c.end);
ctx.fillStyle = inCut ? "#fca5a5" : "#818cf8";
// Resampling overshoot on hot sources puts samples past ±1, so clamp to
// full scale rather than letting the bar spill into the wordbar lane.
const peak = Math.min(1, (max - min) / 2);
const h = Math.max(1, peak * trackH * WAVE_LANE_FILL);
ctx.fillRect(x, midY - h / 2, 1, h);
}
}, [
audio,
waveform,
cuts,
clips,
duration,
Expand Down
11 changes: 7 additions & 4 deletions hooks/useTranscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,14 @@ export function useTranscriber() {
);
};

// Transfer a copy so the original stays available for the waveform.
const copy = audio.slice();
// Transfer, not copy: the worker takes ownership of the PCM and `audio` is
// detached here. Nothing on the main thread reads it afterwards — the
// waveform draws from the envelope the store built in setAudio — and on a
// long recording the copy this replaces was hundreds of megabytes held for
// the length of the run.
workerRef.current.postMessage(
{ audio: copy, duration, model, language: transcriptLanguage },
[copy.buffer]
{ audio, duration, model, language: transcriptLanguage },
[audio.buffer]
);
}, []);

Expand Down
94 changes: 70 additions & 24 deletions lib/align.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,7 @@ function landmarkScoreAtLag(
let score = 0;
const vote = (times: number[], candidates: number[]) => {
for (const t of times) {
let bestDist = Infinity;
for (const c of candidates) bestDist = Math.min(bestDist, Math.abs(t - lag - c));
const bestDist = nearestDistance(candidates, t - lag);
if (bestDist < tol) score += 1 - bestDist / tol;
}
};
Expand All @@ -318,6 +317,34 @@ function landmarkScoreAtLag(
return score;
}

/**
* Index of the first element of `sorted` that is >= `target`, or `length`.
*
* The arrays this file searches — VAD edges, anchors — are all ascending and
* all get probed once per word, per lag candidate, or both. Scanning them
* linearly makes those passes quadratic in the length of the recording, which
* is unnoticeable on a clip and hundreds of milliseconds on an hour.
*/
function lowerBound(sorted: number[], target: number): number {
let lo = 0;
let hi = sorted.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (sorted[mid] < target) lo = mid + 1;
else hi = mid;
}
return lo;
}

/** Distance from `target` to the closest value in ascending `sorted`. */
function nearestDistance(sorted: number[], target: number): number {
if (sorted.length === 0) return Infinity;
const i = lowerBound(sorted, target);
const after = i < sorted.length ? sorted[i] - target : Infinity;
const before = i > 0 ? target - sorted[i - 1] : Infinity;
return Math.min(after, before);
}

/** Candidate shifts, ordered outward from 0 so the smallest wins any tie. */
function lagCandidates(maxLagS: number, lagStepS: number): number[] {
const steps = Math.floor(maxLagS / lagStepS);
Expand Down Expand Up @@ -508,16 +535,23 @@ export function buildSpeechAnchors(
const anchors: SpeechAnchor[] = [];
words.forEach((w, i) => {
if (i > 0 && w.start - words[i - 1].end < minGapS) return;
let onset: number | null = null;
let bestDist = Infinity;
for (const o of onsets) {
const d = Math.abs(w.start - lag - o);
if (d < bestDist) {
bestDist = d;
onset = o;
}
}
if (onset === null || bestDist > landmarkTolS) return;
// Nearest onset to the de-lagged word start. Ties go to the earlier onset,
// matching the linear scan this replaces (which kept its first best).
const target = w.start - lag;
const at = lowerBound(onsets, target);
const before = at > 0 ? onsets[at - 1] : null;
const after = at < onsets.length ? onsets[at] : null;
const onset =
before === null
? after
: after === null
? before
: after - target < target - before
? after
: before;
if (onset === null) return;
const bestDist = Math.abs(target - onset);
if (bestDist > landmarkTolS) return;

const prev = anchors[anchors.length - 1];
if (!prev) {
Expand Down Expand Up @@ -545,14 +579,21 @@ export function correctionAt(anchors: SpeechAnchor[], fallbackLag: number, t: nu
if (t <= first.from) return first.from - first.to;
const last = anchors[anchors.length - 1];
if (t >= last.from) return last.from - last.to;
for (let k = 0; k + 1 < anchors.length; k++) {
const a = anchors[k];
const b = anchors[k + 1];
if (t >= a.from && t <= b.from) {
const da = a.from - a.to;
const db = b.from - b.to;
return da + ((db - da) * (t - a.from)) / (b.from - a.from);
}
// Anchors are strictly increasing in `from` (buildSpeechAnchors guarantees
// it), so binary search the bracketing pair — this runs twice per word.
let lo = 0;
let hi = anchors.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (anchors[mid].from <= t) lo = mid;
else hi = mid;
}
const a = anchors[lo];
const b = anchors[hi];
if (t >= a.from && t <= b.from) {
const da = a.from - a.to;
const db = b.from - b.to;
return da + ((db - da) * (t - a.from)) / (b.from - a.from);
}
return fallbackLag;
}
Expand All @@ -567,15 +608,20 @@ function nearestEdge(
): number | null {
let best: number | null = null;
let bestDist = Infinity;
for (const v of sorted) {
if (v < target - maxDist) continue;
if (v > target + maxDist) break; // sorted ascending
if (v < lo || v > hi) continue;
// Seek to the window rather than scanning from the start: only the handful of
// edges within `maxDist` can win, and `maxDist` is a snap tolerance of a few
// tens of milliseconds.
const consider = (v: number) => {
if (v < lo || v > hi) return;
const d = Math.abs(v - target);
if (d < bestDist) {
bestDist = d;
best = v;
}
};
const at = lowerBound(sorted, target - maxDist);
for (let i = at; i < sorted.length && sorted[i] <= target + maxDist; i++) {
consider(sorted[i]);
}
return best;
}
Expand Down
Loading
Loading