From 0d58b54977539060e907dc2588cfada80cad823a Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Thu, 3 Sep 2026 01:59:49 -0400 Subject: [PATCH] feat(radio): add radio traffic gameplay Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../frontend/radio/RadioTraffic.tsx | 513 ++++++++++++++++++ .../frontend/radio/messageGenerator.ts | 55 ++ src/components/frontend/radio/sounds.ts | 122 +++++ 3 files changed, 690 insertions(+) create mode 100644 src/components/frontend/radio/RadioTraffic.tsx create mode 100644 src/components/frontend/radio/messageGenerator.ts create mode 100644 src/components/frontend/radio/sounds.ts diff --git a/src/components/frontend/radio/RadioTraffic.tsx b/src/components/frontend/radio/RadioTraffic.tsx new file mode 100644 index 0000000..5c264cf --- /dev/null +++ b/src/components/frontend/radio/RadioTraffic.tsx @@ -0,0 +1,513 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { FormEvent, KeyboardEvent } from "react"; +import { Check, Clock3, Radio, Target, Volume2 } from "lucide-react"; +import { useRouter } from "next/navigation"; + +import { type RadioStats, submitRadioResult } from "@/app/(frontend)/radio/actions"; +import { LoginLink } from "@/components/frontend/auth/LoginLink"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Progress } from "@/components/ui/progress"; +import { Switch } from "@/components/ui/switch"; +import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick"; +import { useSessionExpired } from "@/hooks/useSessionExpired"; +import { createRadioRound, nextRadioMessage, SPRINT_MESSAGE_COUNT } from "./messageGenerator"; +import { playCountdownEnd, playCountdownTick, playCorrect, playError, playKeystroke, playRoundComplete, playXp } from "./sounds"; + +type Phase = "ready" | "playing" | "over"; +type Mode = "sprint" | "watch"; +type MessageResult = { + readonly target: string; + readonly score: number; + readonly correctKeys: number; + readonly incorrectKeys: number; + readonly accuracy: number; + readonly correct: boolean; +}; +type Feedback = { readonly correct: boolean; readonly message: string }; +type LastRun = { + readonly mode: Mode; + readonly score: number; + readonly accuracy: number; + readonly correctMessages: number; + readonly relayedMessages: number; + readonly xpEarned: number; + readonly best: number; + readonly isNewBest: boolean; + readonly error?: string; +}; + +const WATCH_DURATION_MS = 60_000; +const WATCH_MAX_MESSAGES = 30; +const FAST_TICK_THRESHOLD_MS = 10_000; +const CRITICAL_TICK_THRESHOLD_MS = 3_000; +const MODE_STORAGE_KEY = "radio-traffic-mode"; +const TICK_SOUND_STORAGE_KEY = "radio-traffic-tick-sound"; + +function isMode(value: string | null): value is Mode { + return value === "sprint" || value === "watch"; +} + +function messageScore(accuracy: number, timeMs: number): number { + const speed = Math.min(1.5, Math.max(0.25, 1.2 - timeMs / 15_000)); + return Math.round(accuracy * 100 * speed); +} + +function formatTime(timeMs: number): string { + return `${(timeMs / 1000).toFixed(1)}s`; +} + +function formatAccuracy(accuracy: number): string { + return `${Math.round(accuracy * 100)}%`; +} + +function roundAccuracy(results: readonly MessageResult[]): number { + const total = results.reduce((sum, result) => sum + result.correctKeys + result.incorrectKeys, 0); + return total === 0 ? 0 : results.reduce((sum, result) => sum + result.correctKeys, 0) / total; +} + +function Metric({ label, value }: { label: string; value: string | number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function RadioTraffic({ + initialStats: serverStats, + onGameRecordedAction, + onPhaseChangeAction, +}: { + initialStats: RadioStats; + onGameRecordedAction: () => void; + onPhaseChangeAction: (phase: Phase) => void; +}) { + const router = useRouter(); + const { expired, expiredRef } = useSessionExpired(); + const inputRef = useRef(null); + const phaseRef = useRef("ready"); + const roundStartedAtRef = useRef(0); + const roundDeadlineRef = useRef(0); + const messageStartedAtRef = useRef(0); + const remainingMsRef = useRef(0); + const submittingRef = useRef(false); + const statsRef = useRef(serverStats); + const previousRoundLastMessageRef = useRef(null); + const watchUsedRef = useRef>(new Set()); + const watchExpiryNotifiedRef = useRef(false); + const keystrokesRef = useRef({ correct: 0, incorrect: 0 }); + const lastCountdownTickRef = useRef(""); + const countdownEndPlayedRef = useRef(false); + const tickSoundRef = useRef(true); + const [phase, setPhase] = useState("ready"); + const [mode, setMode] = useState("sprint"); + const [modeHydrated, setModeHydrated] = useState(false); + const [tickSoundEnabled, setTickSoundEnabled] = useState(true); + const [messages, setMessages] = useState(() => { + const initialRound = createRadioRound(null, SPRINT_MESSAGE_COUNT); + previousRoundLastMessageRef.current = initialRound[initialRound.length - 1] ?? null; + return initialRound; + }); + const [messageIndex, setMessageIndex] = useState(0); + const [input, setInput] = useState(""); + const [results, setResults] = useState([]); + const [elapsedMs, setElapsedMs] = useState(0); + const [remainingMs, setRemainingMs] = useState(0); + const [stats, setStats] = useState(serverStats); + const [lastRun, setLastRun] = useState(null); + const [feedback, setFeedback] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const setPhaseSafe = useCallback( + (next: Phase) => { + phaseRef.current = next; + setPhase(next); + onPhaseChangeAction(next); + }, + [onPhaseChangeAction], + ); + + useEffect(() => { + const savedMode = window.localStorage.getItem(MODE_STORAGE_KEY); + if (isMode(savedMode)) setMode(savedMode); + const savedTickSound = window.localStorage.getItem(TICK_SOUND_STORAGE_KEY); + if (savedTickSound === "false") { + tickSoundRef.current = false; + setTickSoundEnabled(false); + } + setModeHydrated(true); + }, []); + + useEffect(() => { + if (modeHydrated) window.localStorage.setItem(MODE_STORAGE_KEY, mode); + }, [mode, modeHydrated]); + + const handleTickSoundChange = useCallback((enabled: boolean) => { + tickSoundRef.current = enabled; + setTickSoundEnabled(enabled); + window.localStorage.setItem(TICK_SOUND_STORAGE_KEY, String(enabled)); + }, []); + + useEffect(() => onPhaseChangeAction("ready"), [onPhaseChangeAction]); + + const recordResult = useCallback( + async (finalResults: readonly MessageResult[], resultMode: Mode, elapsedMs?: number) => { + if (submittingRef.current) return; + const score = finalResults.reduce((sum, result) => sum + result.score, 0); + const correctMessages = finalResults.filter((result) => result.correct).length; + const accuracy = roundAccuracy(finalResults); + if (expiredRef.current) { + setLastRun({ + mode: resultMode, + score, + accuracy, + correctMessages, + relayedMessages: finalResults.length, + xpEarned: 0, + best: statsRef.current.bestScore, + isNewBest: false, + error: "Your session expired. Result not recorded.", + }); + return; + } + + submittingRef.current = true; + setSubmitting(true); + const result = await submitRadioResult({ + score, + messageCount: finalResults.length, + correctMessages, + accuracy, + mode: resultMode, + timeMs: elapsedMs, + }); + submittingRef.current = false; + setSubmitting(false); + + if (result.success && result.data) { + const previousBest = statsRef.current.bestScore; + statsRef.current = result.data; + setStats(result.data); + onGameRecordedAction(); + if (result.data.xpEarned > 0) { + playXp(); + window.dispatchEvent( + new CustomEvent(PROFILE_UPDATE_EVENT, { + detail: { reason: "xp", xpGained: result.data.xpEarned }, + }), + ); + window.setTimeout(() => router.refresh(), 1700); + } + setLastRun({ + mode: resultMode, + score, + accuracy, + correctMessages, + relayedMessages: finalResults.length, + xpEarned: result.data.xpEarned, + best: result.data.bestScore, + isNewBest: score > previousBest, + }); + } else { + setLastRun({ + mode: resultMode, + score, + accuracy, + correctMessages, + relayedMessages: finalResults.length, + xpEarned: 0, + best: statsRef.current.bestScore, + isNewBest: false, + error: result.error ?? "Could not record radio traffic.", + }); + } + }, + [expiredRef, onGameRecordedAction, router], + ); + + const finishRound = useCallback( + (finalResults: readonly MessageResult[], resultMode: Mode) => { + if (phaseRef.current !== "playing") return; + const lastResult = finalResults[finalResults.length - 1]; + if (lastResult) previousRoundLastMessageRef.current = lastResult.target; + setPhaseSafe("over"); + playRoundComplete(); + if (resultMode === "watch" && finalResults.length === 0) { + setLastRun({ + mode: "watch", + score: 0, + accuracy: 0, + correctMessages: 0, + relayedMessages: 0, + xpEarned: 0, + best: statsRef.current.bestScore, + isNewBest: false, + }); + return; + } + const elapsedMs = resultMode === "sprint" ? performance.now() - roundStartedAtRef.current : undefined; + void recordResult(finalResults, resultMode, elapsedMs); + }, + [recordResult, setPhaseSafe], + ); + + useEffect(() => { + if (phase !== "playing") return; + const timer = window.setInterval(() => { + const now = performance.now(); + if (mode === "sprint") { + setElapsedMs(now - roundStartedAtRef.current); + return; + } + const nextRemaining = Math.max(0, roundDeadlineRef.current - now); + remainingMsRef.current = nextRemaining; + setRemainingMs(nextRemaining); + const tickIntensity = + nextRemaining <= CRITICAL_TICK_THRESHOLD_MS + ? "critical" + : nextRemaining <= FAST_TICK_THRESHOLD_MS + ? "fast" + : "normal"; + const tickSlotMs = tickIntensity === "critical" ? 250 : tickIntensity === "fast" ? 500 : 1000; + const tickKey = `${tickIntensity}:${Math.floor(nextRemaining / tickSlotMs)}`; + if (tickKey !== lastCountdownTickRef.current) { + lastCountdownTickRef.current = tickKey; + if (tickSoundRef.current) playCountdownTick(tickIntensity); + } + if (nextRemaining === 0) { + if (!countdownEndPlayedRef.current) { + countdownEndPlayedRef.current = true; + if (tickSoundRef.current) playCountdownEnd(); + } + if (inputRef.current?.value && !watchExpiryNotifiedRef.current) { + watchExpiryNotifiedRef.current = true; + setFeedback({ correct: false, message: "Time expired. Press Enter to log the current transmission." }); + } + } + }, 100); + return () => window.clearInterval(timer); + }, [mode, phase]); + + useEffect(() => { + if (mode !== "watch" || phase !== "playing" || remainingMs > 0 || input.length > 0) return; + finishRound(results, "watch"); + }, [finishRound, input.length, mode, phase, remainingMs, results]); + + const rejectInsertion = useCallback(() => { + setFeedback({ correct: false, message: "Typing only. Paste and drop are disabled." }); + }, []); + + const startRound = useCallback(() => { + if (expiredRef.current || submittingRef.current) return; + const now = performance.now(); + let nextMessages: readonly string[]; + watchUsedRef.current = new Set(); + watchExpiryNotifiedRef.current = false; + countdownEndPlayedRef.current = false; + if (mode === "sprint") { + nextMessages = createRadioRound(previousRoundLastMessageRef.current, SPRINT_MESSAGE_COUNT); + previousRoundLastMessageRef.current = nextMessages[nextMessages.length - 1] ?? null; + } else { + const firstMessage = nextRadioMessage(watchUsedRef.current); + watchUsedRef.current.add(firstMessage); + nextMessages = [firstMessage]; + } + setMessages(nextMessages); + setMessageIndex(0); + setInput(""); + setResults([]); + keystrokesRef.current = { correct: 0, incorrect: 0 }; + setElapsedMs(0); + const isWatch = mode === "watch"; + const nextRemaining = isWatch ? WATCH_DURATION_MS : 0; + roundStartedAtRef.current = now; + roundDeadlineRef.current = isWatch ? now + WATCH_DURATION_MS : 0; + remainingMsRef.current = nextRemaining; + setRemainingMs(nextRemaining); + setLastRun(null); + setFeedback(null); + messageStartedAtRef.current = now; + setPhaseSafe("playing"); + window.setTimeout(() => inputRef.current?.focus(), 0); + }, [expiredRef, mode, setPhaseSafe]); + + const handleKeystroke = useCallback( + (event: KeyboardEvent) => { + if (phaseRef.current !== "playing") return; + const currentInput = inputRef.current?.value ?? ""; + const currentTarget = messages[messageIndex] ?? ""; + if (event.key === "Backspace") { + if (currentInput.length > 0) keystrokesRef.current.incorrect += 1; + return; + } + if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return; + const expected = currentTarget[currentInput.length]; + if (expected !== undefined && event.key.toUpperCase() === expected) { + keystrokesRef.current.correct += 1; + } else { + keystrokesRef.current.incorrect += 1; + } + }, + [messages, messageIndex], + ); + + const submitCurrent = useCallback( + (event: FormEvent) => { + event.preventDefault(); + if (phaseRef.current !== "playing" || submittingRef.current) return; + const target = messages[messageIndex]; + if (!target) return; + const now = performance.now(); + const timeMs = Math.max(1, now - messageStartedAtRef.current); + const { correct: correctKeys, incorrect: incorrectKeys } = keystrokesRef.current; + const keyTotal = correctKeys + incorrectKeys; + const accuracy = keyTotal > 0 ? correctKeys / keyTotal : 0; + const result: MessageResult = { + target, + score: messageScore(accuracy, timeMs), + correctKeys, + incorrectKeys, + accuracy, + correct: input === target, + }; + keystrokesRef.current = { correct: 0, incorrect: 0 }; + lastCountdownTickRef.current = ""; + const finalResults = [...results, result]; + setResults(finalResults); + setInput(""); + if (result.correct) { + playCorrect(); + setFeedback({ correct: true, message: "Transmission accepted." }); + } else { + playError(); + setFeedback({ correct: false, message: "Signal mismatch. Next transmission incoming." }); + } + + const watchExpired = mode === "watch" && now >= roundDeadlineRef.current; + const watchLimitReached = mode === "watch" && finalResults.length >= WATCH_MAX_MESSAGES; + const shouldFinish = + mode === "sprint" + ? messageIndex + 1 >= messages.length + : watchExpired || watchLimitReached; + if (shouldFinish) { + if (mode === "watch") { + remainingMsRef.current = Math.max(0, roundDeadlineRef.current - now); + setRemainingMs(remainingMsRef.current); + } else { + setElapsedMs(now - roundStartedAtRef.current); + } + finishRound(finalResults, mode); + return; + } + + if (mode === "watch") { + watchUsedRef.current.add(target); + const nextTarget = nextRadioMessage(watchUsedRef.current); + watchUsedRef.current.add(nextTarget); + setMessages((current) => [...current, nextTarget]); + } + setMessageIndex((index) => index + 1); + messageStartedAtRef.current = now; + window.setTimeout(() => inputRef.current?.focus(), 0); + }, + [finishRound, input, messageIndex, messages, mode, results], + ); + + const displayMode = phase === "over" ? (lastRun?.mode ?? mode) : mode; + const target = messages[messageIndex] ?? ""; + const liveKeyTotal = keystrokesRef.current.correct + keystrokesRef.current.incorrect; + const currentAccuracy = liveKeyTotal > 0 ? keystrokesRef.current.correct / liveKeyTotal : 0; + const provisionalScore = results.reduce((sum, result) => sum + result.score, 0); + const displayScore = lastRun?.score ?? provisionalScore; + const displayAccuracy = lastRun?.accuracy ?? roundAccuracy(results); + const displayCorrect = lastRun?.correctMessages ?? results.filter((result) => result.correct).length; + const displayRelayed = lastRun?.relayedMessages ?? results.length; + const progress = + displayMode === "sprint" + ? ((phase === "over" ? messages.length : messageIndex) / messages.length) * 100 + : Math.min(100, Math.max(0, ((WATCH_DURATION_MS - remainingMs) / WATCH_DURATION_MS) * 100)); + const headerLabel = + phase === "ready" + ? "Frequency standby" + : displayMode === "watch" + ? `Relayed ${displayRelayed}` + : `Message ${Math.min(messageIndex + 1, messages.length)} of ${messages.length}`; + const modeSelectorDisabled = phase === "playing"; + + return ( +
+
+
+ +

Radio Traffic

+
+

Intercept clean transmissions under pressure.

+
+
+
+ {headerLabel} + {formatTime(displayMode === "watch" ? remainingMs : elapsedMs)} +
+ {displayMode === "sprint" && ( + + )} + {displayMode === "watch" && ( + + )} + +
+ + +
+ + {(phase === "ready" || phase === "over") && ( +
+ + +
+ )} + + {phase === "ready" &&
+
+

Incoming traffic queued

Type each transmission exactly, then press Enter to relay it.

+ +
} + + {phase === "playing" &&
+

Incoming transmission

{target}

+
+ +
{ setInput(event.target.value.toUpperCase()); playKeystroke(); }} onPaste={(event) => { event.preventDefault(); rejectInsertion(); }} onDrop={(event) => { event.preventDefault(); rejectInsertion(); }} onDragOver={(event) => event.preventDefault()} autoCapitalize="characters" autoComplete="off" autoCorrect="off" autoFocus spellCheck={false} aria-describedby="radio-traffic-hint" className="font-mono sm:flex-1" />
+

Input is auto-capitalized, so no Shift needed. Match callsigns, grid words, punctuation, and spacing.

+
+
Current accuracy {liveKeyTotal === 0 ? "—" : formatAccuracy(currentAccuracy)}Round score {provisionalScore}Completed {results.length}
+ {feedback &&

{feedback.message}

} +
} + + {phase === "over" &&
+
{displayMode === "sprint" ? "Sprint complete" : "Watch ended"}
+
+ {lastRun?.isNewBest && New best} + {lastRun?.xpEarned ? +{lastRun.xpEarned} XP : null} + {lastRun?.error &&

{lastRun.error}

} + {submitting &&

Recording result…

} + +
} + + {expired &&
Session Ending SoonYour session is about to end. The app will refresh when it expires.
} +
+
Games{stats.gamesPlayed}
Messages{stats.totalMessages}
Best run{stats.bestScore}
XP from traffic{stats.radioTrafficXp}
+
+ ); +} diff --git a/src/components/frontend/radio/messageGenerator.ts b/src/components/frontend/radio/messageGenerator.ts new file mode 100644 index 0000000..d1ec80b --- /dev/null +++ b/src/components/frontend/radio/messageGenerator.ts @@ -0,0 +1,55 @@ +type SlotName = "CALLSIGN" | "NUMBER" | "ACTION" | "LOCATION" | "STATUS" | "CLOSING"; +type MessageSlot = { readonly name: SlotName; readonly separator: string; readonly options: readonly string[] }; + +export const SPRINT_MESSAGE_COUNT = 10; +const MESSAGE_SLOTS = [ + { + name: "CALLSIGN", + separator: "", + options: ["ALPHA", "BRAVO", "CHARLIE", "DELTA", "ECHO", "FOXTROT", "VIKING", "SABER", "EAGLE", "RAVEN", "TIGER", "HAMMER", "WOLF", "FALCON", "ORION", "SENTINEL", "GHOST", "VIPER", "COBRA", "JAGUAR", "REAPER", "TITAN", "ATLAS", "COMET"], + }, + { + name: "NUMBER", + separator: " ", + options: ["ACTUAL", "ZERO", "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE", "TEN"], + }, + { + name: "ACTION", + separator: ", ", + options: ["EN ROUTE TO", "HOLD AT", "APPROACHING", "REQUESTING EVAC FROM", "MOVING TO", "REPORTING FROM", "CONTACT AT", "SECURE", "SWEEP AND CLEAR", "MOVE TO DEFEND", "REGROUP AT", "DIVERT TO", "REQUESTING FIRE MISSION AT", "PICKING UP AT", "DROPPING OFF AT", "AWAITING ORDERS AT", "PROCEED TO", "FALL BACK TO", "CONVERGE ON", "SCREENING AT"], + }, + { + name: "LOCATION", + separator: " ", + options: ["GRID 341-512", "GRID BRAVO ONE SIX", "SECTOR K-7", "CHECKPOINT ALPHA", "CHECKPOINT CHARLIE", "OVERWATCH POINT", "NORTH RIDGELINE", "OBJECTIVE DELTA", "SUPPLY ROAD", "EASTERN APPROACH", "SOUTH TRENCHLINE", "SITE 19", "FOB LIMA", "EGRESS", "WATCHPOST", "MAIN GATE", "HELI PAD", "BRIDGE CROSSING", "WESTERN VALLEY", "HILL 402", "RIVERBEND", "OLD MINE ROAD", "STAGING AREA", "PERIMETER LIMA"], + }, + { + name: "STATUS", + separator: ", ", + options: ["STATUS GREEN", "STATUS AMBER", "WEAPONS FREE", "UNDER CONTACT", "NEED MEDICAL", "ALL CLEAR", "FUEL STATE GREEN", "PACKAGE SECURE", "WE HAVE A MAN DOWN", "SITUATION CRITICAL", "LOST VISUAL", "CONTACT LOST", "RADAR CONTACTS DETECTED", "HOSTILES INBOUND", "AMMO STATE LOW", "RUNNING LOW ON FUEL", "NEED REINFORCEMENTS", "HOLDING POSITION", "ENEMY ACTIVITY INCREASED", "MEDICAL EXTRACTION READY", "PERIMETER SECURE", "NO MOVEMENT DETECTED"], + }, + { name: "CLOSING", separator: ", ", options: ["OVER", "DO YOU COPY? OVER", "HOW COPY? OVER", "REQUESTING READBACK, OVER", "OUT", "STAND BY, OVER", "AWAITING YOUR CALL, OVER", "READY WHEN YOU ARE, OVER", "SEND IT WHEN READY, OVER", "WILL WAIT FOR YOUR WORD, OVER", "STANDING BY FOR ORDERS, OVER"] }, +] as const satisfies readonly MessageSlot[]; + +function composeMessage(): string { + return `${MESSAGE_SLOTS.map((slot) => `${slot.separator}${slot.options[Math.floor(Math.random() * slot.options.length)] ?? ""}`).join("")}.`; +} + +export function nextRadioMessage(used: ReadonlySet): string { + let message = composeMessage(); + while (used.has(message)) message = composeMessage(); + return message; +} + +export function createRadioRound( + previousMessage: string | null = null, + count: number = SPRINT_MESSAGE_COUNT, +): readonly string[] { + const messages = new Set(); + while (messages.size < count) { + const message = nextRadioMessage(messages); + if (message === previousMessage && messages.size < count - 1) continue; + messages.add(message); + } + return [...messages]; +} diff --git a/src/components/frontend/radio/sounds.ts b/src/components/frontend/radio/sounds.ts new file mode 100644 index 0000000..b102cc6 --- /dev/null +++ b/src/components/frontend/radio/sounds.ts @@ -0,0 +1,122 @@ +let audioCtx: AudioContext | null = null; + +function getCtx(): AudioContext | null { + if (typeof window === "undefined") return null; + const AudioCtor = + window.AudioContext ?? + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!AudioCtor) return null; + if (!audioCtx) audioCtx = new AudioCtor(); + if (audioCtx.state === "suspended") { + void audioCtx.resume(); + } + return audioCtx; +} + +type ToneOptions = { + readonly frequency: number; + readonly endFrequency?: number; + readonly duration: number; + readonly type?: OscillatorType; + readonly volume?: number; + readonly delay?: number; +}; + +function playTone(ctx: AudioContext, opts: ToneOptions) { + const { frequency, endFrequency, duration, type = "sine", volume = 0.2, delay = 0 } = opts; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + const t0 = ctx.currentTime + delay; + osc.type = type; + osc.frequency.setValueAtTime(Math.max(frequency, 1), t0); + if (endFrequency && endFrequency !== frequency) { + osc.frequency.exponentialRampToValueAtTime(Math.max(endFrequency, 1), t0 + duration); + } + gain.gain.setValueAtTime(0.0001, t0); + gain.gain.exponentialRampToValueAtTime(volume, t0 + 0.012); + gain.gain.exponentialRampToValueAtTime(0.0001, t0 + duration); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(t0); + osc.stop(t0 + duration + 0.05); +} + +function playNoise( + ctx: AudioContext, + opts: { readonly duration: number; readonly volume?: number; readonly delay?: number; readonly lowpass?: number }, +) { + const { duration, volume = 0.2, delay = 0, lowpass = 800 } = opts; + const bufferSize = Math.max(1, Math.floor(ctx.sampleRate * duration)); + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i += 1) { + data[i] = Math.random() * 2 - 1; + } + const source = ctx.createBufferSource(); + source.buffer = buffer; + const filter = ctx.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.setValueAtTime(lowpass, ctx.currentTime + delay); + const gain = ctx.createGain(); + const t0 = ctx.currentTime + delay; + gain.gain.setValueAtTime(0.0001, t0); + gain.gain.exponentialRampToValueAtTime(volume, t0 + 0.005); + gain.gain.exponentialRampToValueAtTime(0.0001, t0 + duration); + source.connect(filter); + filter.connect(gain); + gain.connect(ctx.destination); + source.start(t0); +} + +export function playKeystroke() { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { frequency: 520, duration: 0.025, type: "square", volume: 0.025 }); +} + +export function playCorrect() { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { frequency: 620, endFrequency: 920, duration: 0.08, type: "sine", volume: 0.12 }); +} + +export function playError() { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { frequency: 150, endFrequency: 80, duration: 0.16, type: "square", volume: 0.14 }); + playNoise(ctx, { duration: 0.1, volume: 0.08, lowpass: 500 }); +} + +export function playRoundComplete() { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { frequency: 523, duration: 0.1, type: "sine", volume: 0.16 }); + playTone(ctx, { frequency: 659, duration: 0.1, type: "sine", volume: 0.16, delay: 0.1 }); + playTone(ctx, { frequency: 784, duration: 0.18, type: "sine", volume: 0.18, delay: 0.2 }); +} + +export function playXp() { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { frequency: 660, duration: 0.09, type: "sine", volume: 0.18 }); + playTone(ctx, { frequency: 880, duration: 0.16, type: "sine", volume: 0.18, delay: 0.09 }); +} + +export function playCountdownTick(intensity: "normal" | "fast" | "critical") { + const ctx = getCtx(); + if (!ctx) return; + if (intensity === "normal") { + playTone(ctx, { frequency: 880, duration: 0.03, type: "square", volume: 0.05 }); + } else if (intensity === "fast") { + playTone(ctx, { frequency: 1046, duration: 0.03, type: "square", volume: 0.07 }); + } else { + playTone(ctx, { frequency: 1318, duration: 0.045, type: "square", volume: 0.11 }); + } +} + +export function playCountdownEnd() { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { frequency: 1318, endFrequency: 240, duration: 0.75, type: "sine", volume: 0.16 }); + playTone(ctx, { frequency: 659, endFrequency: 120, duration: 0.75, type: "triangle", volume: 0.1 }); +}