diff --git a/src/app/(frontend)/voiceover/[id]/page.tsx b/src/app/(frontend)/voiceover/[id]/page.tsx
new file mode 100644
index 0000000..a4a39bc
--- /dev/null
+++ b/src/app/(frontend)/voiceover/[id]/page.tsx
@@ -0,0 +1,62 @@
+import type { Metadata } from "next";
+import config from "@payload-config";
+import { getPayload } from "payload";
+import { headers as nextHeaders } from "next/headers";
+import Link from "next/link";
+import { notFound, redirect } from "next/navigation";
+import { ArrowLeftIcon, MicIcon } from "lucide-react";
+
+import { VoiceoverRecorder } from "@/components/frontend/voiceover/VoiceoverRecorder";
+import { getScriptForUser } from "@/lib/voiceover";
+import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
+
+export const metadata: Metadata = {
+ title: "Record voiceover — Polaris Task Force",
+ description: "Read a Polaris Task Force voiceover script and submit a recording.",
+};
+
+export default async function VoiceoverScriptPage({
+ params,
+}: {
+ readonly params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+ const scriptId = Number(id);
+ if (!Number.isInteger(scriptId) || scriptId < 1) notFound();
+
+ const headers = await nextHeaders();
+ const payloadConfig = await config;
+ const payload = await getPayload({ config: payloadConfig });
+ const { user } = await payload.auth({ headers, canSetHeaders: false });
+
+ if (!isPayloadUser(user)) {
+ redirect(`/login?returnTo=${encodeURIComponent(`/voiceover/${id}`)}`);
+ }
+
+ const script = await getScriptForUser(payload, user, scriptId);
+ if (!script) notFound();
+
+ return (
+
+
+
+ Voiceover scripts
+
+
+
+
+
+
{script.title}
+
+ Read the script at your pace, then submit one clear take for review.
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(frontend)/voiceover/actions.ts b/src/app/(frontend)/voiceover/actions.ts
new file mode 100644
index 0000000..7643316
--- /dev/null
+++ b/src/app/(frontend)/voiceover/actions.ts
@@ -0,0 +1,303 @@
+"use server";
+
+import config from "@payload-config";
+import { getPayload } from "payload";
+import { emitGameEvent } from "@/utils/event-log/emit";
+import { EventTypes } from "@/utils/event-log/eventTypes";
+import { broadcastToUser } from "@/lib/realtime/bus";
+import { notifyUser } from "@/lib/notifications";
+import hasRoles from "@/utils/access-control/hasRoles";
+import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
+import type { VoiceScript, VoiceSubmission } from "@/payload-types";
+
+interface ActionResult {
+ success: boolean;
+ error?: string;
+ data?: T;
+}
+
+/** Server actions body limit is 10mb — enforce a 9 MB cap server-side with a clear error. */
+const MAX_AUDIO_BYTES = 9 * 1024 * 1024;
+
+async function authenticate() {
+ const payloadConfig = await config;
+ const payload = await getPayload({ config: payloadConfig });
+ const { headers } = await import("next/headers");
+ const hdrs = await headers();
+ const { user } = await payload.auth({
+ headers: hdrs,
+ canSetHeaders: false,
+ });
+
+ if (!isPayloadUser(user)) {
+ throw new Error("Unauthorized");
+ }
+
+ return { payload, user };
+}
+
+async function fetchScript(
+ payload: Awaited>,
+ id: number,
+): Promise {
+ try {
+ return await payload.findByID({
+ collection: "voice-scripts",
+ id,
+ depth: 0,
+ overrideAccess: true,
+ });
+ } catch {
+ return null;
+ }
+}
+
+async function fetchSubmission(
+ payload: Awaited>,
+ id: number,
+): Promise {
+ try {
+ return await payload.findByID({
+ collection: "voice-submissions",
+ id,
+ depth: 0,
+ overrideAccess: true,
+ });
+ } catch {
+ return null;
+ }
+}
+
+export async function submitVoiceRecording(
+ scriptId: number,
+ formData: FormData,
+): Promise> {
+ try {
+ const { payload, user } = await authenticate();
+
+ const script = await fetchScript(payload, scriptId);
+ if (!script) {
+ return { success: false, error: "Voiceover script not found." };
+ }
+ if (script.status !== "open") {
+ return { success: false, error: "This script is closed to new submissions." };
+ }
+
+ const file = formData.get("file");
+ if (!(file instanceof File)) {
+ return { success: false, error: "No audio file provided." };
+ }
+ if (!file.type.startsWith("audio/")) {
+ return { success: false, error: "Only audio files can be submitted." };
+ }
+ if (file.size > MAX_AUDIO_BYTES) {
+ return { success: false, error: "Audio recordings must be 9 MB or smaller." };
+ }
+
+ const submission = await payload.create({
+ collection: "voice-submissions",
+ data: {
+ script: scriptId,
+ user: user.id,
+ status: "pending",
+ },
+ file: {
+ data: Buffer.from(await file.arrayBuffer()),
+ mimetype: file.type,
+ name: file.name,
+ size: file.size,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+
+ await emitGameEvent(payload, {
+ type: EventTypes.VoiceoverSubmit,
+ message: `${user.displayName ?? user.username} submitted a recording for "${script.title}"`,
+ actor: user.id,
+ targetCollection: "voice-submissions",
+ targetId: submission.id,
+ data: { scriptId, scriptTitle: script.title },
+ });
+
+ return { success: true, data: { id: submission.id } };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unknown error";
+ if (message === "Unauthorized") {
+ return { success: false, error: "You must be logged in." };
+ }
+ return { success: false, error: message };
+ }
+}
+
+export async function withdrawSubmission(
+ submissionId: number,
+): Promise> {
+ try {
+ const { payload, user } = await authenticate();
+
+ const submission = await fetchSubmission(payload, submissionId);
+ if (!submission) {
+ return { success: false, error: "Submission not found." };
+ }
+ if (submission.user !== user.id) {
+ return { success: false, error: "You can only withdraw your own submissions." };
+ }
+ if (submission.status !== "pending") {
+ return { success: false, error: "Only pending submissions can be withdrawn." };
+ }
+
+ await payload.delete({
+ collection: "voice-submissions",
+ id: submissionId,
+ overrideAccess: true,
+ });
+
+ return { success: true, data: { ok: true } };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unknown error";
+ if (message === "Unauthorized") {
+ return { success: false, error: "You must be logged in." };
+ }
+ return { success: false, error: message };
+ }
+}
+
+export async function reviewVoiceSubmission(
+ submissionId: number,
+ decision: "accepted" | "rejected",
+ reviewNote?: string,
+): Promise> {
+ try {
+ const { payload, user } = await authenticate();
+
+ if (!hasRoles(["admin", "developer"], user)) {
+ return {
+ success: false,
+ error: "Only admins can review voiceover submissions.",
+ };
+ }
+
+ const submission = await fetchSubmission(payload, submissionId);
+ if (!submission) {
+ return { success: false, error: "Submission not found." };
+ }
+ if (submission.status !== "pending") {
+ return { success: false, error: "This submission has already been reviewed." };
+ }
+
+ const script = await fetchScript(
+ payload,
+ typeof submission.script === "number" ? submission.script : submission.script.id,
+ );
+ if (!script) {
+ return { success: false, error: "The script for this submission no longer exists." };
+ }
+
+ const submitterId = typeof submission.user === "number" ? submission.user : submission.user.id;
+
+ // Resolve the profile before mutating anything so a missing profile fails
+ // the review cleanly instead of leaving the submission accepted without XP.
+ let profileId: number | undefined;
+ let progression: {
+ xp?: unknown;
+ voiceover?: { xpEarned?: number };
+ } = {};
+ if (decision === "accepted") {
+ const profileRes = await payload.find({
+ collection: "profiles",
+ where: { user: { equals: submitterId } },
+ limit: 1,
+ depth: 0,
+ overrideAccess: true,
+ });
+ const profile = profileRes.docs[0];
+ if (!profile) {
+ return { success: false, error: "No profile found for the submitter." };
+ }
+ profileId = profile.id;
+ progression = (profile.progression ?? {}) as {
+ xp?: unknown;
+ voiceover?: { xpEarned?: number };
+ };
+ }
+
+ await payload.update({
+ collection: "voice-submissions",
+ id: submissionId,
+ data: {
+ status: decision,
+ reviewNote: reviewNote ?? null,
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+
+ if (decision === "accepted" && profileId !== undefined) {
+ const voiceover = progression.voiceover ?? {};
+ const xpEarned = (voiceover.xpEarned ?? 0) + script.xpAmount;
+ // Strip the virtual `xp` field before writing (computed, not stored).
+ const { xp: _xp, ...progressionRest } = progression;
+
+ await payload.update({
+ collection: "profiles",
+ id: profileId,
+ data: {
+ progression: {
+ ...progressionRest,
+ voiceover: {
+ ...voiceover,
+ xpEarned,
+ },
+ },
+ },
+ overrideAccess: true,
+ depth: 0,
+ });
+ broadcastToUser("profile-update", submitterId, {
+ reason: "xp",
+ xpGained: script.xpAmount,
+ profileId,
+ });
+ await notifyUser(payload, {
+ userId: submitterId,
+ type: "voiceover:accepted",
+ title: "Voiceover accepted",
+ message: `Your recording for "${script.title}" was accepted (+${script.xpAmount} XP).`,
+ link: "/voiceover",
+ });
+ } else {
+ await notifyUser(payload, {
+ userId: submitterId,
+ type: "voiceover:rejected",
+ title: "Voiceover rejected",
+ message: `Your recording for "${script.title}" was not accepted this time.`,
+ link: "/voiceover",
+ });
+ }
+
+ await emitGameEvent(payload, {
+ type: decision === "accepted" ? EventTypes.VoiceoverAccepted : EventTypes.VoiceoverRejected,
+ message: `${user.displayName ?? user.username} ${decision} a voiceover submission for "${script.title}"`,
+ actor: user.id,
+ targetCollection: "voice-submissions",
+ targetId: submissionId,
+ data: {
+ scriptId: script.id,
+ scriptTitle: script.title,
+ submitterId,
+ decision,
+ reviewNote: reviewNote ?? null,
+ xpAwarded: decision === "accepted" ? script.xpAmount : 0,
+ },
+ });
+
+ return { success: true, data: { id: submissionId, status: decision } };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unknown error";
+ if (message === "Unauthorized") {
+ return { success: false, error: "You must be logged in." };
+ }
+ return { success: false, error: message };
+ }
+}
diff --git a/src/app/(frontend)/voiceover/page.tsx b/src/app/(frontend)/voiceover/page.tsx
new file mode 100644
index 0000000..8741ec5
--- /dev/null
+++ b/src/app/(frontend)/voiceover/page.tsx
@@ -0,0 +1,174 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { headers as nextHeaders } from "next/headers";
+import { redirect } from "next/navigation";
+import { getPayload } from "payload";
+import config from "@payload-config";
+import { ArrowRightIcon, CheckCircle2Icon, Clock3Icon, MicIcon, SparklesIcon } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { listScriptsForUser, type VoiceoverScriptView } from "@/lib/voiceover";
+import { cn } from "@/lib/utils";
+import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
+
+export const metadata: Metadata = {
+ title: "Voiceover — Polaris Task Force",
+ description: "Record voiceover takes for active Polaris Task Force scripts.",
+};
+
+const submissionStatusClasses: Record = {
+ pending: "border-amber-500/40 text-amber-400",
+ accepted: "border-emerald-500/40 text-emerald-400",
+ rejected: "border-red-500/40 text-red-400",
+};
+
+function ScriptAvailability({ status }: { readonly status: string }) {
+ const isOpen = status === "open";
+
+ return (
+
+ {isOpen ? : }
+ {isOpen ? "Open" : "Closed"}
+
+ );
+}
+
+function SubmissionStatuses({ script }: { readonly script: VoiceoverScriptView }) {
+ if (script.mySubmissions.length === 0) {
+ return No takes submitted yet.
;
+ }
+
+ return (
+
+ {script.mySubmissions.map((submission, index) => (
+
+ Take {index + 1}: {submission.status}
+
+ ))}
+
+ );
+}
+
+function ScriptCard({ script }: { readonly script: VoiceoverScriptView }) {
+ const isOpen = script.status === "open";
+
+ return (
+
+
+
+
+
+ {isOpen ? (
+
+ {script.title}
+
+ ) : (
+ script.title
+ )}
+
+ Voiceover script #{script.id}
+
+
+
+
+
+
+ +{script.xpAmount} XP
+
+
+ {script.otherSubmissionCount} other submission
+ {script.otherSubmissionCount === 1 ? "" : "s"}
+
+
+
+
+
+
+
+ Your submissions
+
+
+
+
+ {isOpen ? (
+
+ ) : (
+
+ This script is closed to new takes.
+
+ )}
+
+
+ );
+}
+
+export default async function VoiceoverPage() {
+ const headers = await nextHeaders();
+ const payloadConfig = await config;
+ const payload = await getPayload({ config: payloadConfig });
+ const { user } = await payload.auth({ headers, canSetHeaders: false });
+
+ if (!isPayloadUser(user)) {
+ redirect(`/login?returnTo=${encodeURIComponent("/voiceover")}`);
+ }
+
+ const scripts = await listScriptsForUser(payload, user);
+
+ return (
+
+
+
+
+
Voiceover submissions
+
+
+ Choose an active script, read it in the teleprompter, and submit your best take.
+
+
+
+ {scripts.length > 0 ? (
+
+ {scripts.map((script) => (
+
+ ))}
+
+ ) : (
+
+
+
+
+
No scripts available
+
+ New voiceover opportunities will appear here when they are published.
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/frontend/blocks/NavUser.tsx b/src/components/frontend/blocks/NavUser.tsx
index c8b5c26..ef0f315 100644
--- a/src/components/frontend/blocks/NavUser.tsx
+++ b/src/components/frontend/blocks/NavUser.tsx
@@ -9,6 +9,7 @@ import {
FileTextIcon,
Gamepad2,
LogOut,
+ Mic,
Radio,
Users2,
} from "lucide-react";
@@ -166,10 +167,26 @@ export function NavUser({
Qualification Course
+
+
+
+ Qualification Course
+
+
+
+ Contribute
+
+
+
+ Voiceover
+
+
+
+
diff --git a/src/components/frontend/voiceover/TeleprompterReader.tsx b/src/components/frontend/voiceover/TeleprompterReader.tsx
new file mode 100644
index 0000000..022779c
--- /dev/null
+++ b/src/components/frontend/voiceover/TeleprompterReader.tsx
@@ -0,0 +1,373 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ AudioLinesIcon,
+ GaugeIcon,
+ PauseIcon,
+ PlayIcon,
+ RotateCcwIcon,
+ SlidersHorizontalIcon,
+} from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { cn } from "@/lib/utils";
+import { parseEmotionTags } from "@/lib/voiceover/emotionTags";
+import {
+ DEFAULT_WORDS_PER_MINUTE,
+ MAX_WORDS_PER_MINUTE,
+ MIN_WORDS_PER_MINUTE,
+ buildPacingTimeline,
+} from "@/lib/voiceover/pacing";
+
+const MIN_SCROLL_SPEED = 5;
+const MAX_SCROLL_SPEED = 100;
+const DEFAULT_SCROLL_SPEED = 20;
+
+const TAG_COLORS = [
+ { text: "text-sky-200/75", underline: "decoration-sky-300/50", label: "text-sky-300/80" },
+ { text: "text-amber-200/75", underline: "decoration-amber-300/50", label: "text-amber-300/80" },
+ {
+ text: "text-emerald-200/75",
+ underline: "decoration-emerald-300/50",
+ label: "text-emerald-300/80",
+ },
+ { text: "text-rose-200/75", underline: "decoration-rose-300/50", label: "text-rose-300/80" },
+ {
+ text: "text-violet-200/75",
+ underline: "decoration-violet-300/50",
+ label: "text-violet-300/80",
+ },
+ { text: "text-teal-200/75", underline: "decoration-teal-300/50", label: "text-teal-300/80" },
+ {
+ text: "text-fuchsia-200/75",
+ underline: "decoration-fuchsia-300/50",
+ label: "text-fuchsia-300/80",
+ },
+ {
+ text: "text-orange-200/75",
+ underline: "decoration-orange-300/50",
+ label: "text-orange-300/80",
+ },
+] as const;
+
+function tagColor(tags: readonly string[]) {
+ const key = tags.join(", ");
+ let hash = 0;
+ for (let index = 0; index < key.length; index += 1) {
+ hash = (hash * 31 + key.charCodeAt(index)) >>> 0;
+ }
+ return TAG_COLORS[hash % TAG_COLORS.length];
+}
+
+export function TeleprompterReader({ script }: { readonly script: string }) {
+ const readerRef = useRef(null);
+ const elapsedRef = useRef(0);
+ const [isScrolling, setIsScrolling] = useState(false);
+ const [scrollSpeed, setScrollSpeed] = useState(DEFAULT_SCROLL_SPEED);
+ const [pacingEnabled, setPacingEnabled] = useState(false);
+ const [wordsPerMinute, setWordsPerMinute] = useState(DEFAULT_WORDS_PER_MINUTE);
+ const [elapsedMs, setElapsedMs] = useState(0);
+ const segments = useMemo(() => parseEmotionTags(script), [script]);
+ const timeline = useMemo(
+ () => buildPacingTimeline(segments, wordsPerMinute),
+ [segments, wordsPerMinute],
+ );
+
+ const toggleScrolling = useCallback(() => {
+ setIsScrolling((scrolling) => !scrolling);
+ }, []);
+
+ const scrollMultiplier = useCallback(
+ (elapsed: number) => {
+ let multiplier = 1;
+ for (const directive of timeline.scrollDirectives) {
+ if (elapsed < directive.atMs) break;
+ multiplier = directive.multiplier;
+ }
+ return multiplier;
+ },
+ [timeline.scrollDirectives],
+ );
+
+ useEffect(() => {
+ if (!isScrolling) return;
+
+ let animationFrame = 0;
+ let previousTime = performance.now();
+
+ const scroll = (now: number) => {
+ const reader = readerRef.current;
+ if (!reader) {
+ setIsScrolling(false);
+ return;
+ }
+
+ const delta = now - previousTime;
+ previousTime = now;
+
+ if (pacingEnabled) {
+ elapsedRef.current += delta;
+ setElapsedMs(elapsedRef.current);
+ }
+
+ const effectiveSpeed =
+ scrollSpeed * (pacingEnabled ? scrollMultiplier(elapsedRef.current) : 1);
+
+ const maxScrollTop = reader.scrollHeight - reader.clientHeight;
+ const scrollFinished = maxScrollTop <= 0 || reader.scrollTop >= maxScrollTop;
+ const pacingFinished = !pacingEnabled || elapsedRef.current >= timeline.totalMs;
+
+ if (scrollFinished && pacingFinished) {
+ setIsScrolling(false);
+ return;
+ }
+
+ if (!scrollFinished) {
+ reader.scrollTop = Math.min(
+ maxScrollTop,
+ reader.scrollTop + (effectiveSpeed * delta) / 1000,
+ );
+ }
+ animationFrame = requestAnimationFrame(scroll);
+ };
+
+ animationFrame = requestAnimationFrame(scroll);
+ return () => cancelAnimationFrame(animationFrame);
+ }, [isScrolling, scrollSpeed, pacingEnabled, timeline.totalMs, scrollMultiplier]);
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.code !== "Space" || event.repeat) return;
+
+ const target = event.target;
+ if (
+ target instanceof HTMLElement &&
+ (target.isContentEditable ||
+ ["BUTTON", "INPUT", "SELECT", "TEXTAREA"].includes(target.tagName))
+ ) {
+ return;
+ }
+
+ event.preventDefault();
+ toggleScrolling();
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [toggleScrolling]);
+
+ function resetReader() {
+ const reader = readerRef.current;
+ if (reader) reader.scrollTop = 0;
+ elapsedRef.current = 0;
+ setElapsedMs(0);
+ setIsScrolling(false);
+ }
+
+ function handlePacingToggle(enabled: boolean) {
+ elapsedRef.current = 0;
+ setElapsedMs(0);
+ setPacingEnabled(enabled);
+ }
+
+ function handleWordsPerMinuteChange(value: string) {
+ const next = Number(value);
+ if (!Number.isFinite(next)) return;
+ setWordsPerMinute(Math.min(MAX_WORDS_PER_MINUTE, Math.max(MIN_WORDS_PER_MINUTE, next)));
+ }
+
+ function renderSpokenText(
+ segment: (typeof segments)[number],
+ pacing: (typeof timeline.segmentPacing)[number],
+ wordClass?: string,
+ ) {
+ if (!pacingEnabled || !pacing || pacing.words.length === 0) {
+ return segment.text;
+ }
+
+ return (
+ <>
+ {pacing.leadingWhitespace}
+ {pacing.words.map((word, wordIndex) => (
+ = word.endMs && "opacity-35",
+ )}
+ >
+ {word.text}
+ {word.whitespaceAfter}
+
+ ))}
+ >
+ );
+ }
+
+ function handleSpeedChange(value: string) {
+ const nextSpeed = Number(value);
+ if (!Number.isFinite(nextSpeed)) return;
+ setScrollSpeed(Math.min(MAX_SCROLL_SPEED, Math.max(MIN_SCROLL_SPEED, nextSpeed)));
+ }
+
+ return (
+
+
+
+
+ Teleprompter
+ Press spacebar to start or pause the reader.
+
+
+
+
+
+
+
+ {pacingEnabled && (
+
+ handleWordsPerMinuteChange(event.target.value)}
+ className="h-7 w-20 text-xs"
+ aria-label="Speaking pace in words per minute"
+ />
+ wpm
+
+ )}
+
+
+
+ handleSpeedChange(event.target.value)}
+ className="w-24"
+ />
+
+
+
+
+
+
+
+
+
+
+ {segments.map((segment, index) => {
+ if (segment.directive) {
+ return (
+
+
+ {segment.directive}
+
+ );
+ }
+
+ if (segment.sfx) {
+ return (
+
+
+ {segment.sfx}
+
+ );
+ }
+
+ if (segment.tags.length === 0) {
+ return (
+
+ {renderSpokenText(segment, timeline.segmentPacing[index])}
+
+ );
+ }
+
+ const color = tagColor(segment.tags);
+ const segmentPacing = timeline.segmentPacing[index];
+ const pacingActive = pacingEnabled && segmentPacing !== undefined;
+
+ return (
+
+
+ {segment.tags.join(", ")}
+
+
+ {renderSpokenText(
+ segment,
+ segmentPacing,
+ pacingActive ? cn("underline", color.underline) : undefined,
+ )}
+
+
+ );
+ })}
+
+
+
+
+ Constant scroll speed · {scrollSpeed} px/s
+
+
+
+ );
+}
diff --git a/src/components/frontend/voiceover/VoiceCapture.tsx b/src/components/frontend/voiceover/VoiceCapture.tsx
new file mode 100644
index 0000000..28effd9
--- /dev/null
+++ b/src/components/frontend/voiceover/VoiceCapture.tsx
@@ -0,0 +1,425 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState, type ChangeEvent } from "react";
+import { MicIcon, SquareIcon, UploadIcon } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { cn } from "@/lib/utils";
+
+const MAX_AUDIO_BYTES = 9 * 1024 * 1024;
+const PREFERRED_MIME_TYPE = "audio/webm;codecs=opus";
+
+export type VoiceCaptureSource = Blob | File;
+
+interface VoiceCaptureProps {
+ readonly onSubmitTake: (source: VoiceCaptureSource) => Promise;
+}
+
+function formatDuration(seconds: number): string {
+ const minutes = Math.floor(seconds / 60);
+ const remainder = seconds % 60;
+ return `${minutes.toString().padStart(2, "0")}:${remainder.toString().padStart(2, "0")}`;
+}
+
+function recordingErrorMessage(error: unknown): string {
+ if (
+ error instanceof DOMException &&
+ ["NotAllowedError", "PermissionDeniedError", "SecurityError"].includes(error.name)
+ ) {
+ return "Microphone access was denied. Allow microphone access in your browser settings, then try again.";
+ }
+
+ return "The microphone could not be opened. Check that it is connected and available to this browser.";
+}
+
+export function VoiceCapture({ onSubmitTake }: VoiceCaptureProps) {
+ const fileInputRef = useRef(null);
+ const mediaRecorderRef = useRef(null);
+ const streamRef = useRef(null);
+ const chunksRef = useRef([]);
+ const recordingStartedAtRef = useRef(null);
+ const audioContextRef = useRef(null);
+ const analyserRef = useRef(null);
+ const meterAnimationRef = useRef(null);
+ const mountedRef = useRef(true);
+ const [support, setSupport] = useState<"checking" | "supported" | "unsupported">("checking");
+ const [isRecording, setIsRecording] = useState(false);
+ const [isStopping, setIsStopping] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [recordingSeconds, setRecordingSeconds] = useState(0);
+ const [level, setLevel] = useState(0);
+ const [recordingError, setRecordingError] = useState(null);
+ const [reviewBlob, setReviewBlob] = useState(null);
+ const [reviewUrl, setReviewUrl] = useState(null);
+ const [selectedFile, setSelectedFile] = useState(null);
+
+ const stopLevelMeter = useCallback(() => {
+ if (meterAnimationRef.current !== null) {
+ cancelAnimationFrame(meterAnimationRef.current);
+ meterAnimationRef.current = null;
+ }
+ analyserRef.current = null;
+ const context = audioContextRef.current;
+ audioContextRef.current = null;
+ if (context) void context.close();
+ setLevel(0);
+ }, []);
+
+ const stopStream = useCallback(() => {
+ streamRef.current?.getTracks().forEach((track) => track.stop());
+ streamRef.current = null;
+ }, []);
+
+ const clearReview = useCallback(() => {
+ setReviewBlob(null);
+ setReviewUrl(null);
+ }, []);
+
+ useEffect(() => {
+ setSupport(
+ typeof MediaRecorder !== "undefined" &&
+ typeof navigator !== "undefined" &&
+ !!navigator.mediaDevices?.getUserMedia
+ ? "supported"
+ : "unsupported",
+ );
+ }, []);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ if (mediaRecorderRef.current?.state === "recording") mediaRecorderRef.current.stop();
+ stopLevelMeter();
+ stopStream();
+ };
+ }, [stopLevelMeter, stopStream]);
+
+ useEffect(() => {
+ if (!reviewUrl) return;
+ return () => URL.revokeObjectURL(reviewUrl);
+ }, [reviewUrl]);
+
+ useEffect(() => {
+ if (!isRecording) return;
+
+ const timer = window.setInterval(() => {
+ const startedAt = recordingStartedAtRef.current;
+ if (startedAt !== null) setRecordingSeconds(Math.floor((Date.now() - startedAt) / 1000));
+ }, 250);
+
+ return () => window.clearInterval(timer);
+ }, [isRecording]);
+
+ function startLevelMeter(stream: MediaStream) {
+ if (typeof AudioContext === "undefined") return;
+
+ try {
+ const context = new AudioContext();
+ const analyser = context.createAnalyser();
+ analyser.fftSize = 256;
+ context.createMediaStreamSource(stream).connect(analyser);
+ const data = new Uint8Array(analyser.fftSize);
+ audioContextRef.current = context;
+ analyserRef.current = analyser;
+
+ const update = () => {
+ if (!mountedRef.current || !analyserRef.current) return;
+ analyser.getByteTimeDomainData(data);
+ let sum = 0;
+ for (const value of data) {
+ const normalized = (value - 128) / 128;
+ sum += normalized * normalized;
+ }
+ setLevel(Math.min(1, Math.sqrt(sum / data.length) * 4));
+ meterAnimationRef.current = requestAnimationFrame(update);
+ };
+
+ void context.resume().catch(() => undefined);
+ meterAnimationRef.current = requestAnimationFrame(update);
+ } catch {
+ setLevel(0);
+ }
+ }
+
+ async function handleStart() {
+ if (support !== "supported" || isRecording || isSubmitting) return;
+
+ setRecordingError(null);
+ clearReview();
+ setSelectedFile(null);
+
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ if (!mountedRef.current) {
+ stream.getTracks().forEach((track) => track.stop());
+ return;
+ }
+
+ const mimeType = MediaRecorder.isTypeSupported(PREFERRED_MIME_TYPE)
+ ? PREFERRED_MIME_TYPE
+ : "";
+ const recorder = mimeType
+ ? new MediaRecorder(stream, { mimeType })
+ : new MediaRecorder(stream);
+ chunksRef.current = [];
+ streamRef.current = stream;
+ mediaRecorderRef.current = recorder;
+ recordingStartedAtRef.current = Date.now();
+ setRecordingSeconds(0);
+ setIsStopping(false);
+
+ recorder.addEventListener("dataavailable", (event) => {
+ if (event.data.size > 0) chunksRef.current.push(event.data);
+ });
+ recorder.addEventListener("error", () => {
+ if (mountedRef.current)
+ setRecordingError("The recording could not be completed. Try again.");
+ });
+ recorder.addEventListener("stop", () => {
+ const blob = new Blob(chunksRef.current, {
+ type: recorder.mimeType || mimeType || "audio/webm",
+ });
+ stopLevelMeter();
+ stopStream();
+ mediaRecorderRef.current = null;
+ recordingStartedAtRef.current = null;
+ if (!mountedRef.current) return;
+ setReviewBlob(blob);
+ setReviewUrl(URL.createObjectURL(blob));
+ setIsRecording(false);
+ setIsStopping(false);
+ setLevel(0);
+ });
+
+ recorder.start();
+ setIsRecording(true);
+ startLevelMeter(stream);
+ } catch (error) {
+ setIsRecording(false);
+ setIsStopping(false);
+ setRecordingError(recordingErrorMessage(error));
+ stopLevelMeter();
+ stopStream();
+ }
+ }
+
+ function handleStop() {
+ if (mediaRecorderRef.current?.state !== "recording") return;
+ setIsStopping(true);
+ mediaRecorderRef.current.stop();
+ }
+
+ async function submitSource(source: VoiceCaptureSource) {
+ if (source.size > MAX_AUDIO_BYTES) {
+ toast.error("Audio files must be 9 MB or smaller.");
+ return;
+ }
+
+ setIsSubmitting(true);
+ const submitted = await onSubmitTake(source);
+ setIsSubmitting(false);
+ if (!submitted) return;
+
+ clearReview();
+ setSelectedFile(null);
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ }
+
+ function handleFileChange(event: ChangeEvent) {
+ const file = event.target.files?.[0] ?? null;
+ if (!file) {
+ setSelectedFile(null);
+ return;
+ }
+ if (file.size > MAX_AUDIO_BYTES) {
+ toast.error("Audio files must be 9 MB or smaller.");
+ event.target.value = "";
+ setSelectedFile(null);
+ return;
+ }
+ if (file.type && !file.type.startsWith("audio/")) {
+ toast.error("Choose an audio file to submit.");
+ event.target.value = "";
+ setSelectedFile(null);
+ return;
+ }
+ setRecordingError(null);
+ setSelectedFile(file);
+ }
+
+ async function handleReviewSubmit() {
+ if (reviewBlob) await submitSource(reviewBlob);
+ }
+
+ async function handleFileSubmit() {
+ if (selectedFile) await submitSource(selectedFile);
+ }
+
+ function handleRerecord() {
+ clearReview();
+ setRecordingError(null);
+ }
+
+ const meterValue = Math.max(0.08, level);
+
+ return (
+
+
+ Record your take
+
+ Use your microphone or upload an existing audio file. Audio submissions are limited to 9
+ MB.
+
+
+
+ {support === "unsupported" && (
+
+ This browser does not support microphone recording. Upload an audio file instead.
+
+ )}
+
+ {support === "checking" && (
+ Checking microphone support…
+ )}
+
+ {support === "supported" && !reviewBlob && (
+
+
+
+
+
+ {isRecording ? "Recording" : "Microphone ready"}
+
+
+
+ {formatDuration(recordingSeconds)}
+
+
+
+
+ {!isRecording ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+ {recordingError && (
+
+ {recordingError}
+
+ )}
+
+ {reviewBlob && reviewUrl && (
+
+
+
Review your take
+
+ Listen back before sending it for review.
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
Audio files only, up to 9 MB.
+
+
+ {selectedFile && (
+
+
+ {selectedFile.name}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/frontend/voiceover/VoiceoverRecorder.tsx b/src/components/frontend/voiceover/VoiceoverRecorder.tsx
new file mode 100644
index 0000000..791128c
--- /dev/null
+++ b/src/components/frontend/voiceover/VoiceoverRecorder.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { CheckCircle2Icon, LockKeyholeIcon, MicIcon, SparklesIcon } from "lucide-react";
+import { toast } from "sonner";
+
+import { submitVoiceRecording, withdrawSubmission } from "@/app/(frontend)/voiceover/actions";
+import { Badge } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
+import type { VoiceoverScriptView } from "@/lib/voiceover";
+import { TeleprompterReader } from "@/components/frontend/voiceover/TeleprompterReader";
+import {
+ VoiceCapture,
+ type VoiceCaptureSource,
+} from "@/components/frontend/voiceover/VoiceCapture";
+import { VoiceoverTakes } from "@/components/frontend/voiceover/VoiceoverTakes";
+
+export function VoiceoverRecorder({ script }: { readonly script: VoiceoverScriptView }) {
+ const router = useRouter();
+ const isOpen = script.status === "open";
+
+ async function handleSubmitTake(source: VoiceCaptureSource): Promise {
+ const formData = new FormData();
+ if (source instanceof File) {
+ formData.append("file", source, source.name);
+ } else {
+ formData.append("file", source, "take.webm");
+ }
+
+ try {
+ const result = await submitVoiceRecording(script.id, formData);
+ if (!result.success) {
+ toast.error(result.error ?? "Could not submit your take.");
+ return false;
+ }
+
+ toast.success("Take submitted", {
+ description: "Your recording is now pending review.",
+ });
+ router.refresh();
+ return true;
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Could not submit your take.");
+ return false;
+ }
+ }
+
+ async function handleWithdraw(submissionId: number): Promise {
+ try {
+ const result = await withdrawSubmission(submissionId);
+ if (!result.success) {
+ toast.error(result.error ?? "Could not withdraw this take.");
+ return false;
+ }
+
+ toast.success("Take withdrawn");
+ router.refresh();
+ return true;
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Could not withdraw this take.");
+ return false;
+ }
+ }
+
+ return (
+
+
+
+ +{script.xpAmount} XP
+
+
+ {isOpen ? (
+
+ ) : (
+
+ )}
+ {isOpen ? "Open for submissions" : "Closed to submissions"}
+
+
+ {script.otherSubmissionCount} other submission
+ {script.otherSubmissionCount === 1 ? "" : "s"}
+
+
+
+
+
+
+
+
+
+
+
+ {isOpen ? (
+
+ ) : (
+
+
+
+ This script is closed. Existing takes remain available below for playback.
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/frontend/voiceover/VoiceoverTakes.tsx b/src/components/frontend/voiceover/VoiceoverTakes.tsx
new file mode 100644
index 0000000..5121eb9
--- /dev/null
+++ b/src/components/frontend/voiceover/VoiceoverTakes.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import { useState } from "react";
+import { CheckIcon, Clock3Icon, Trash2Icon, XIcon } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import type { VoiceoverSubmissionSummary } from "@/lib/voiceover";
+import { cn } from "@/lib/utils";
+
+interface VoiceoverTakesProps {
+ readonly submissions: readonly VoiceoverSubmissionSummary[];
+ readonly onWithdraw: (submissionId: number) => Promise;
+}
+
+const statusMeta: Record =
+ {
+ pending: {
+ className: "border-amber-500/40 text-amber-400",
+ icon: Clock3Icon,
+ },
+ accepted: {
+ className: "border-emerald-500/40 text-emerald-400",
+ icon: CheckIcon,
+ },
+ rejected: { className: "border-red-500/40 text-red-400", icon: XIcon },
+ };
+
+function formatDate(value: string): string {
+ return new Intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(new Date(value));
+}
+
+export function VoiceoverTakes({ submissions, onWithdraw }: VoiceoverTakesProps) {
+ const [withdrawingId, setWithdrawingId] = useState(null);
+
+ async function handleWithdraw(id: number) {
+ setWithdrawingId(id);
+ await onWithdraw(id);
+ setWithdrawingId(null);
+ }
+
+ return (
+
+
+ My takes
+ Your submitted recordings and their review status.
+
+
+ {submissions.length === 0 ? (
+
+ You have not submitted a take for this script.
+
+ ) : (
+ submissions.map((submission, index) => {
+ const meta = statusMeta[submission.status] ?? {
+ className: "text-muted-foreground",
+ icon: Clock3Icon,
+ };
+ const StatusIcon = meta.icon;
+ const canWithdraw = submission.status === "pending";
+
+ return (
+
+
+
+ Take {index + 1}
+
+
+ {submission.status}
+
+
+
+
+
+ {submission.audioUrl ? (
+
+ ) : (
+
Audio playback is unavailable.
+ )}
+
+ {canWithdraw && (
+
+ )}
+
+ );
+ })
+ )}
+
+
+ );
+}
diff --git a/src/lib/voiceover/emotionTags.ts b/src/lib/voiceover/emotionTags.ts
new file mode 100644
index 0000000..b113eb0
--- /dev/null
+++ b/src/lib/voiceover/emotionTags.ts
@@ -0,0 +1,105 @@
+export interface VoiceoverSegment {
+ readonly text: string;
+ readonly tags: readonly string[];
+ readonly sfx?: string;
+ readonly directive?: string;
+}
+
+const TAG_PATTERN = /\[([a-z][a-z\s,-]*)\]|\{([a-z][a-z\s,-]*)\}|<([a-z]+)>/gi;
+
+function normalizeScript(script: string): string {
+ return script
+ .replace(/\r\n?/g, "\n")
+ .replace(/\n{3,}/g, "\n\n")
+ .replace(/(?
+ segment !== undefined && (segment.sfx !== undefined || segment.directive !== undefined);
+ return segments.map((segment, index) => {
+ let text = segment.text;
+ if (isStandalone(segments[index - 1])) text = text.replace(/^\s+/, "");
+ if (isStandalone(segments[index + 1])) text = text.replace(/\s+$/, "");
+ return text === segment.text ? segment : { ...segment, text };
+ });
+}
+
+export function parseEmotionTags(script: string): VoiceoverSegment[] {
+ const segments: VoiceoverSegment[] = [];
+ let cursor = 0;
+ let pendingTags: string[] = [];
+ let carriedWhitespace = "";
+
+ const source = normalizeScript(script);
+
+ const flush = (rawText: string, isFinal: boolean) => {
+ let text = carriedWhitespace + rawText;
+ carriedWhitespace = "";
+
+ // Whitespace-only runs between consecutive tags carry forward so a group
+ // like "[calm] [formal]\nWE RECEIVED…" attaches every tag to the line.
+ if (pendingTags.length > 0 && text.trim().length === 0 && !isFinal) {
+ carriedWhitespace = text;
+ return;
+ }
+
+ if (text.length === 0) return;
+
+ if (pendingTags.length > 0 && text.trim().length > 0) {
+ // A tag labels the text that follows it; strip the whitespace the source
+ // used to separate the tag line from its paragraph — the label already
+ // renders on its own line.
+ segments.push({ text: text.replace(/^\s+/, ""), tags: pendingTags });
+ pendingTags = [];
+ return;
+ }
+
+ segments.push({ text, tags: [] });
+ };
+
+ for (const match of source.matchAll(TAG_PATTERN)) {
+ const matchIndex = match.index ?? cursor;
+ if (matchIndex > cursor) {
+ flush(source.slice(cursor, matchIndex), false);
+ }
+
+ const emotive = normalizeTag(match[1] ?? "");
+ if (emotive) {
+ pendingTags.push(emotive);
+ }
+
+ const sfx = normalizeTag(match[2] ?? "");
+ if (sfx) {
+ segments.push({ text: "", tags: [], sfx });
+ }
+
+ const directive = normalizeTag(match[3] ?? "");
+ if (directive) {
+ segments.push({ text: "", tags: [], directive });
+ }
+
+ cursor = matchIndex + match[0].length;
+ }
+
+ flush(source.slice(cursor), true);
+
+ const hasSfx = segments.some((segment) => segment.sfx);
+ const trimmed = hasSfx ? trimAroundSfx(segments) : segments;
+ return trimmed.filter(
+ (segment) =>
+ segment.sfx !== undefined ||
+ segment.directive !== undefined ||
+ segment.tags.length > 0 ||
+ segment.text.length > 0,
+ );
+}
diff --git a/src/lib/voiceover/index.ts b/src/lib/voiceover/index.ts
new file mode 100644
index 0000000..6bd5ca7
--- /dev/null
+++ b/src/lib/voiceover/index.ts
@@ -0,0 +1,128 @@
+import type { User, VoiceScript, VoiceSubmission } from "@/payload-types";
+
+type PayloadType = Awaited>;
+
+export interface VoiceoverSubmissionSummary {
+ id: number;
+ status: string;
+ createdAt: string;
+ audioUrl: string;
+}
+
+export interface VoiceoverScriptView {
+ id: number;
+ title: string;
+ script: string;
+ xpAmount: number;
+ status: string;
+ createdAt: string;
+ /** This user's own submissions on the script, newest first. */
+ mySubmissions: VoiceoverSubmissionSummary[];
+ /**
+ * How many submissions OTHER users have made on this script. A number only —
+ * never identities (privacy requirement).
+ */
+ otherSubmissionCount: number;
+}
+
+/** Upload docs expose `url`; fall back to the media file route like wiki image uploads do. */
+function submissionAudioUrl(submission: VoiceSubmission): string {
+ return submission.url ?? (submission.filename ? `/api/media/file/${submission.filename}` : "");
+}
+
+function toSubmissionSummary(submission: VoiceSubmission): VoiceoverSubmissionSummary {
+ return {
+ id: submission.id,
+ status: submission.status,
+ createdAt: submission.createdAt,
+ audioUrl: submissionAudioUrl(submission),
+ };
+}
+
+function buildScriptView(
+ script: VoiceScript,
+ submissions: VoiceSubmission[],
+ userId: number,
+): VoiceoverScriptView {
+ const mySubmissions: VoiceoverSubmissionSummary[] = [];
+ let otherSubmissionCount = 0;
+
+ for (const submission of submissions) {
+ if (submission.user === userId) {
+ mySubmissions.push(toSubmissionSummary(submission));
+ } else {
+ // Other users' submissions are counted, never surfaced — identities stay server-side.
+ otherSubmissionCount += 1;
+ }
+ }
+
+ return {
+ id: script.id,
+ title: script.title,
+ script: script.script,
+ xpAmount: script.xpAmount,
+ status: script.status,
+ createdAt: script.createdAt,
+ mySubmissions,
+ otherSubmissionCount,
+ };
+}
+
+/**
+ * All voiceover scripts (newest first) annotated with the user's own submissions
+ * and an anonymous count of other users' submissions.
+ *
+ * Round-trip pattern: one query for scripts + one for all submissions, grouped
+ * in memory — avoids per-script count queries (N+1).
+ */
+export async function listScriptsForUser(
+ payload: PayloadType,
+ user: User,
+): Promise {
+ const [scriptsRes, submissionsRes] = await Promise.all([
+ payload.find({
+ collection: "voice-scripts",
+ sort: "-createdAt",
+ limit: 0,
+ depth: 0,
+ overrideAccess: true,
+ }),
+ payload.find({
+ collection: "voice-submissions",
+ sort: "-createdAt",
+ limit: 0,
+ depth: 0,
+ overrideAccess: true,
+ }),
+ ]);
+
+ return scriptsRes.docs.map((script) => buildScriptView(script, submissionsRes.docs, user.id));
+}
+
+/** A single voiceover script in the same shape as `listScriptsForUser`, or null. */
+export async function getScriptForUser(
+ payload: PayloadType,
+ user: User,
+ scriptId: number,
+): Promise {
+ const script = await payload
+ .findByID({
+ collection: "voice-scripts",
+ id: scriptId,
+ depth: 0,
+ overrideAccess: true,
+ })
+ .catch(() => null);
+ if (!script) return null;
+
+ const submissionsRes = await payload.find({
+ collection: "voice-submissions",
+ where: { script: { equals: scriptId } },
+ sort: "-createdAt",
+ limit: 0,
+ depth: 0,
+ overrideAccess: true,
+ });
+
+ return buildScriptView(script, submissionsRes.docs, user.id);
+}
diff --git a/src/lib/voiceover/pacing.ts b/src/lib/voiceover/pacing.ts
new file mode 100644
index 0000000..d7bcd82
--- /dev/null
+++ b/src/lib/voiceover/pacing.ts
@@ -0,0 +1,120 @@
+import type { VoiceoverSegment } from "./emotionTags";
+
+export const DEFAULT_WORDS_PER_MINUTE = 140;
+export const MIN_WORDS_PER_MINUTE = 60;
+export const MAX_WORDS_PER_MINUTE = 300;
+
+export interface PacingWord {
+ readonly text: string;
+ readonly whitespaceAfter: string;
+ readonly startMs: number;
+ readonly endMs: number;
+}
+
+export interface SegmentPacing {
+ readonly leadingWhitespace: string;
+ readonly words: ReadonlyArray;
+}
+
+export interface ScrollDirective {
+ readonly atMs: number;
+ readonly multiplier: number;
+}
+
+export interface PacingTimeline {
+ readonly segmentPacing: ReadonlyArray;
+ readonly scrollDirectives: ReadonlyArray;
+ readonly totalMs: number;
+}
+
+const WORD_RATE_MULTIPLIERS: Record = { slow: 1.5, fast: 0.7, normal: 1 };
+const SCROLL_RATE_MULTIPLIERS: Record = { slow: 0.6, fast: 1.5, normal: 1 };
+const DIRECTIVE_PAUSE_MS = 1000;
+
+const WORD_SPLIT_PATTERN = /(\s+)/;
+
+const SENTENCE_PAUSE_PATTERN = /[.!?…]["')\]]?$/;
+const CLAUSE_PAUSE_PATTERN = /[,;:—–-]["')\]]?$/;
+
+function wordCost(word: string, baseMs: number): number {
+ const spokenLength = word.replace(/[^\p{L}\p{N}]/gu, "").length || 1;
+ const lengthFactor =
+ spokenLength <= 3 ? 0.7 : spokenLength <= 6 ? 1 : 1 + (spokenLength - 6) * 0.1;
+ return baseMs * lengthFactor;
+}
+
+function pauseAfter(word: string, whitespaceAfter: string, baseMs: number): number {
+ if (SENTENCE_PAUSE_PATTERN.test(word)) return baseMs * 0.9;
+ if (CLAUSE_PAUSE_PATTERN.test(word)) return baseMs * 0.4;
+ if (whitespaceAfter.includes("\n\n")) return baseMs * 0.6;
+ return 0;
+}
+
+export function buildPacingTimeline(
+ segments: ReadonlyArray,
+ wordsPerMinute: number,
+): PacingTimeline {
+ const clampedWpm = Math.min(MAX_WORDS_PER_MINUTE, Math.max(MIN_WORDS_PER_MINUTE, wordsPerMinute));
+ const baseMs = 60000 / clampedWpm;
+ const segmentPacing: Array = [];
+ const scrollDirectives: ScrollDirective[] = [];
+ let cursorMs = 0;
+ let wordRate = 1;
+
+ for (const segment of segments) {
+ if (segment.directive) {
+ if (segment.directive === "pause") {
+ cursorMs += DIRECTIVE_PAUSE_MS;
+ } else if (segment.directive in WORD_RATE_MULTIPLIERS) {
+ wordRate = WORD_RATE_MULTIPLIERS[segment.directive] ?? 1;
+ scrollDirectives.push({
+ atMs: cursorMs,
+ multiplier: SCROLL_RATE_MULTIPLIERS[segment.directive] ?? 1,
+ });
+ }
+ segmentPacing.push(undefined);
+ continue;
+ }
+
+ if (segment.sfx || segment.text.length === 0) {
+ segmentPacing.push(undefined);
+ continue;
+ }
+
+ const pieces = segment.text.split(WORD_SPLIT_PATTERN);
+ const words: Array<{
+ text: string;
+ whitespaceAfter: string;
+ startMs: number;
+ endMs: number;
+ }> = [];
+ let leadingWhitespace = "";
+
+ for (const piece of pieces) {
+ if (piece === undefined || piece.length === 0) continue;
+ if (/^\s+$/.test(piece)) {
+ const lastWord = words[words.length - 1];
+ if (lastWord) {
+ lastWord.whitespaceAfter += piece;
+ } else {
+ leadingWhitespace += piece;
+ }
+ continue;
+ }
+ words.push({ text: piece, whitespaceAfter: "", startMs: 0, endMs: 0 });
+ }
+
+ for (const word of words) {
+ word.startMs = cursorMs;
+ const spokenMs =
+ (wordCost(word.text, baseMs) + pauseAfter(word.text, word.whitespaceAfter, baseMs)) *
+ wordRate;
+ word.endMs = cursorMs + spokenMs;
+ cursorMs = word.endMs;
+ }
+
+ segmentPacing.push({ leadingWhitespace, words });
+ }
+
+ return { segmentPacing, scrollDirectives, totalMs: cursorMs };
+}