From 44b78ba7d9c40e5da3150ea3be0b9a2782c7c5f7 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sun, 6 Sep 2026 17:22:52 -0400 Subject: [PATCH] feat(voiceover): add submission pages, recorder UI, and server actions Add /voiceover script list and take detail pages, teleprompter reader, voice capture/recorder components, submission server actions with a 9 MB audio cap, and a Contribute > Voiceover entry in the user menu. Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent) Co-authored-by: Sisyphus --- src/app/(frontend)/voiceover/[id]/page.tsx | 62 +++ src/app/(frontend)/voiceover/actions.ts | 303 +++++++++++++ src/app/(frontend)/voiceover/page.tsx | 174 +++++++ src/components/frontend/blocks/NavUser.tsx | 17 + .../frontend/voiceover/TeleprompterReader.tsx | 373 +++++++++++++++ .../frontend/voiceover/VoiceCapture.tsx | 425 ++++++++++++++++++ .../frontend/voiceover/VoiceoverRecorder.tsx | 112 +++++ .../frontend/voiceover/VoiceoverTakes.tsx | 116 +++++ src/lib/voiceover/emotionTags.ts | 105 +++++ src/lib/voiceover/index.ts | 128 ++++++ src/lib/voiceover/pacing.ts | 120 +++++ 11 files changed, 1935 insertions(+) create mode 100644 src/app/(frontend)/voiceover/[id]/page.tsx create mode 100644 src/app/(frontend)/voiceover/actions.ts create mode 100644 src/app/(frontend)/voiceover/page.tsx create mode 100644 src/components/frontend/voiceover/TeleprompterReader.tsx create mode 100644 src/components/frontend/voiceover/VoiceCapture.tsx create mode 100644 src/components/frontend/voiceover/VoiceoverRecorder.tsx create mode 100644 src/components/frontend/voiceover/VoiceoverTakes.tsx create mode 100644 src/lib/voiceover/emotionTags.ts create mode 100644 src/lib/voiceover/index.ts create mode 100644 src/lib/voiceover/pacing.ts 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 && ( +
+
+
+
+ + {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 ? ( +
+ ); + }) + )} +
+
+ ); +} 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 }; +}