feat: complete Quantumancy web app — full frontend + docs
Frontend (React 18 + TS + Vite): - Landing: glitching hero, live /api/stats veil ticker, mode cards, featured spirits - Séance: three.js shader ghost (hue/form per entity, mood + audio-reactive), Ouija planchette board spelling utterances, transcript with TTS replay, entity dossier, direct contact streaming, passive/active listening - Modes: Wire Ghost telemetry panel, EVP mic anomaly detection, WebUSB RTL-SDR sweep + waterfall (hardware pass pending), Ouija/Direct Contact - Codex: public registry + entity dossiers, rarity tiers, i18n EN/ES complete - State: VeilSocket (reconnect/backoff), seance reducer, auth context - 72 vitest tests green; served by FastAPI at :7777 Docs: README + as-built plans 3-7
This commit is contained in:
61
frontend/src/state/auth.tsx
Normal file
61
frontend/src/state/auth.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
// Auth context: who is seated at the table. Wraps the /auth REST endpoints
|
||||
// and exposes { user, checking, login, register, logout } to the app.
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import * as api from '../api'
|
||||
import type { User } from '../api'
|
||||
|
||||
export type AuthApi = {
|
||||
user: User | null
|
||||
checking: boolean
|
||||
login: (username: string, password: string) => Promise<User>
|
||||
register: (username: string, password: string) => Promise<User>
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthApi | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [checking, setChecking] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.me()
|
||||
.then(setUser)
|
||||
.catch(() => setUser(null))
|
||||
.finally(() => setChecking(false))
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const u = await api.login(username, password)
|
||||
setUser(u)
|
||||
return u
|
||||
}, [])
|
||||
|
||||
const register = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
await api.register(username, password)
|
||||
return login(username, password)
|
||||
},
|
||||
[login],
|
||||
)
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await api.logout()
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<AuthApi>(
|
||||
() => ({ user, checking, login, register, logout }),
|
||||
[user, checking, login, register, logout],
|
||||
)
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth(): AuthApi {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>')
|
||||
return ctx
|
||||
}
|
||||
257
frontend/src/state/seance.test.ts
Normal file
257
frontend/src/state/seance.test.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { initialSeanceState, seanceReducer } from './seance'
|
||||
import type { SeanceState } from './seance'
|
||||
import type { ServerFrame, SpiritEntity, Telemetry } from '../lib/types'
|
||||
|
||||
const AT = 1_700_000_000_000
|
||||
|
||||
const entity: SpiritEntity = {
|
||||
id: 'e-1',
|
||||
name: 'Mirela',
|
||||
epithet: 'the Drowned',
|
||||
persona: 'A soft-spoken drowned girl.',
|
||||
rarity: 'rare',
|
||||
voice: { voice_id: 'v-1', pitch: 0.8, rate: 0.9, noise: 0.3, echo: 0.6 },
|
||||
visual: { hue: 210, form: 'wisp' },
|
||||
quotes: ['the water remembers'],
|
||||
contact_count: 4,
|
||||
discovered_at: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
|
||||
function frame(state: SeanceState, f: ServerFrame, at = AT): SeanceState {
|
||||
return seanceReducer(state, { type: 'frame', frame: f, at })
|
||||
}
|
||||
|
||||
function lastRow(state: SeanceState) {
|
||||
return state.transcript[state.transcript.length - 1]
|
||||
}
|
||||
|
||||
describe('seanceReducer', () => {
|
||||
it('stores the session id on a session frame', () => {
|
||||
const next = frame(initialSeanceState, { type: 'session', id: 's-1' })
|
||||
expect(next.sessionId).toBe('s-1')
|
||||
expect(initialSeanceState.sessionId).toBeNull() // reducer is pure
|
||||
})
|
||||
|
||||
it('sets the entity, clears status and logs a system row on entity frames', () => {
|
||||
let state = frame(initialSeanceState, { type: 'status', state: 'summoning' })
|
||||
expect(state.status).toBe('summoning')
|
||||
|
||||
state = frame(state, { type: 'entity', is_new: true, entity })
|
||||
expect(state.entity).toEqual(entity)
|
||||
expect(state.entityIsNew).toBe(true)
|
||||
expect(state.status).toBeNull()
|
||||
|
||||
const row = lastRow(state)
|
||||
expect(row.kind).toBe('system')
|
||||
if (row.kind === 'system') {
|
||||
expect(row.text).toBe('⟁ Mirela — the Drowned')
|
||||
}
|
||||
})
|
||||
|
||||
it('pairs an audio frame with its utterance in both the store and the transcript', () => {
|
||||
let state = frame(initialSeanceState, {
|
||||
type: 'utterance',
|
||||
id: 'u-1',
|
||||
kind: 'greeting',
|
||||
text: 'we are here',
|
||||
entity: 'Mirela',
|
||||
})
|
||||
expect(state.utterances).toHaveLength(1)
|
||||
expect(state.utterances[0]).toMatchObject({
|
||||
id: 'u-1',
|
||||
kind: 'greeting',
|
||||
text: 'we are here',
|
||||
entity: 'Mirela',
|
||||
audioUrl: null,
|
||||
})
|
||||
expect(lastRow(state)).toMatchObject({
|
||||
kind: 'utterance',
|
||||
utteranceId: 'u-1',
|
||||
speaker: 'Mirela',
|
||||
utKind: 'greeting',
|
||||
audioUrl: null,
|
||||
})
|
||||
|
||||
state = frame(state, { type: 'audio', id: 'u-1', url: '/audio/u-1.mp3' })
|
||||
expect(state.utterances[0].audioUrl).toBe('/audio/u-1.mp3')
|
||||
expect(lastRow(state)).toMatchObject({
|
||||
kind: 'utterance',
|
||||
utteranceId: 'u-1',
|
||||
audioUrl: '/audio/u-1.mp3',
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores audio frames for unknown utterances', () => {
|
||||
const state = frame(initialSeanceState, { type: 'audio', id: 'ghost', url: '/audio/x.mp3' })
|
||||
expect(state.utterances).toEqual([])
|
||||
expect(state.transcript).toEqual([])
|
||||
})
|
||||
|
||||
it('assembles a streaming reply and lands it as a reply utterance', () => {
|
||||
let state = frame(initialSeanceState, { type: 'entity', is_new: false, entity })
|
||||
|
||||
state = frame(state, { type: 'reply_start' })
|
||||
expect(state.replyStreaming).toEqual({ active: true, text: '' })
|
||||
|
||||
state = frame(state, { type: 'reply_token', token: 'the veil ' })
|
||||
state = frame(state, { type: 'reply_token', token: 'is thin' })
|
||||
expect(state.replyStreaming).toEqual({ active: true, text: 'the veil is thin' })
|
||||
expect(state.utterances).toEqual([]) // nothing committed while streaming
|
||||
|
||||
state = frame(state, { type: 'reply_end', id: 'u-9', text: 'the veil is thin' })
|
||||
expect(state.replyStreaming).toEqual({ active: false, text: '' })
|
||||
expect(state.utterances).toHaveLength(1)
|
||||
expect(state.utterances[0]).toMatchObject({
|
||||
id: 'u-9',
|
||||
kind: 'reply',
|
||||
text: 'the veil is thin',
|
||||
entity: 'Mirela',
|
||||
audioUrl: null,
|
||||
})
|
||||
expect(lastRow(state)).toMatchObject({
|
||||
kind: 'utterance',
|
||||
utteranceId: 'u-9',
|
||||
utKind: 'reply',
|
||||
text: 'the veil is thin',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a null speaker for replies without a summoned entity', () => {
|
||||
let state = frame(initialSeanceState, { type: 'reply_start' })
|
||||
state = frame(state, { type: 'reply_end', id: 'u-1', text: 'boo' })
|
||||
expect(state.utterances[0].entity).toBeNull()
|
||||
})
|
||||
|
||||
it('stores telemetry values', () => {
|
||||
const telemetry: Telemetry = {
|
||||
jitter_bytes_per_s: 12.5,
|
||||
latency_variance_ms: 3.2,
|
||||
latency_mean_ms: 41.5,
|
||||
dns_ms: 7,
|
||||
}
|
||||
const state = frame(initialSeanceState, { type: 'telemetry', ...telemetry })
|
||||
expect(state.telemetry).toEqual(telemetry)
|
||||
})
|
||||
|
||||
it('appends error toasts and caps them at four, dropping the oldest', () => {
|
||||
let state = frame(initialSeanceState, {
|
||||
type: 'error',
|
||||
code: 'rate_limited',
|
||||
message: 'slow down, medium',
|
||||
})
|
||||
expect(state.toasts).toHaveLength(1)
|
||||
expect(state.toasts[0]).toMatchObject({ code: 'rate_limited', message: 'slow down, medium' })
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
state = frame(state, { type: 'error', code: `e${i}`, message: `m${i}` })
|
||||
}
|
||||
expect(state.toasts).toHaveLength(4)
|
||||
expect(state.toasts.map((t) => t.code)).toEqual(['e1', 'e2', 'e3', 'e4'])
|
||||
})
|
||||
|
||||
it('records local anomalies in both the anomalies list and the transcript', () => {
|
||||
const state = seanceReducer(initialSeanceState, {
|
||||
type: 'local_anomaly',
|
||||
source: 'evp',
|
||||
frequency: 1171.9,
|
||||
magnitude: 12.3,
|
||||
at: AT,
|
||||
})
|
||||
expect(state.anomalies).toHaveLength(1)
|
||||
expect(state.anomalies[0]).toMatchObject({
|
||||
source: 'evp',
|
||||
frequency: 1171.9,
|
||||
magnitude: 12.3,
|
||||
at: AT,
|
||||
})
|
||||
expect(lastRow(state)).toMatchObject({
|
||||
kind: 'anomaly',
|
||||
source: 'evp',
|
||||
frequency: 1171.9,
|
||||
magnitude: 12.3,
|
||||
})
|
||||
// The server-side count only moves when anomaly_ack arrives.
|
||||
expect(state.anomalyCount).toBe(0)
|
||||
})
|
||||
|
||||
it('appends local questions to the transcript', () => {
|
||||
const state = seanceReducer(initialSeanceState, {
|
||||
type: 'local_question',
|
||||
text: 'who are you',
|
||||
at: AT,
|
||||
})
|
||||
expect(lastRow(state)).toMatchObject({ kind: 'question', text: 'who are you', at: AT })
|
||||
})
|
||||
|
||||
it('tracks the server anomaly count via anomaly_ack', () => {
|
||||
const state = frame(initialSeanceState, { type: 'anomaly_ack', count: 7 })
|
||||
expect(state.anomalyCount).toBe(7)
|
||||
})
|
||||
|
||||
it('caps the transcript at 400 entries, dropping the oldest', () => {
|
||||
let state = initialSeanceState
|
||||
for (let i = 1; i <= 410; i++) {
|
||||
state = seanceReducer(state, { type: 'local_question', text: `q${i}`, at: AT + i })
|
||||
}
|
||||
expect(state.transcript).toHaveLength(400)
|
||||
expect(state.transcript[0]).toMatchObject({ kind: 'question', text: 'q11' })
|
||||
expect(lastRow(state)).toMatchObject({ kind: 'question', text: 'q410' })
|
||||
})
|
||||
|
||||
it('caps the utterance store at 200 entries', () => {
|
||||
let state = initialSeanceState
|
||||
for (let i = 1; i <= 205; i++) {
|
||||
state = frame(
|
||||
state,
|
||||
{ type: 'utterance', id: `u${i}`, kind: 'ambient', text: `t${i}`, entity: null },
|
||||
AT + i,
|
||||
)
|
||||
}
|
||||
expect(state.utterances).toHaveLength(200)
|
||||
expect(state.utterances[0].id).toBe('u6')
|
||||
expect(state.utterances[199].id).toBe('u205')
|
||||
})
|
||||
|
||||
it('applies connection/mode/language/passive/speaking setters', () => {
|
||||
let state = seanceReducer(initialSeanceState, { type: 'connection', state: 'open' })
|
||||
expect(state.connection).toBe('open')
|
||||
state = seanceReducer(state, { type: 'set_mode', mode: 'ouija' })
|
||||
expect(state.mode).toBe('ouija')
|
||||
state = seanceReducer(state, { type: 'set_language', language: 'es' })
|
||||
expect(state.language).toBe('es')
|
||||
state = seanceReducer(state, { type: 'set_passive', enabled: true })
|
||||
expect(state.passive).toBe(true)
|
||||
state = seanceReducer(state, { type: 'set_speaking', utteranceId: 'u-1' })
|
||||
expect(state.speakingId).toBe('u-1')
|
||||
})
|
||||
|
||||
it('applies mode and status frames, and ignores pong', () => {
|
||||
let state = frame(initialSeanceState, { type: 'mode', mode: 'radio' })
|
||||
expect(state.mode).toBe('radio')
|
||||
state = frame(state, { type: 'status', state: 'gathering' })
|
||||
expect(state.status).toBe('gathering')
|
||||
const afterPong = frame(state, { type: 'pong' })
|
||||
expect(afterPong).toBe(state) // same reference: pong is a no-op
|
||||
})
|
||||
|
||||
it('dismisses toasts by id', () => {
|
||||
let state = frame(initialSeanceState, { type: 'error', code: 'a', message: 'a' })
|
||||
state = frame(state, { type: 'error', code: 'b', message: 'b' })
|
||||
const victim = state.toasts[0].id
|
||||
state = seanceReducer(state, { type: 'dismiss_toast', id: victim })
|
||||
expect(state.toasts).toHaveLength(1)
|
||||
expect(state.toasts[0].code).toBe('b')
|
||||
})
|
||||
|
||||
it('reset returns to the initial state', () => {
|
||||
let state = frame(initialSeanceState, { type: 'session', id: 's-1' })
|
||||
state = frame(state, { type: 'entity', is_new: true, entity })
|
||||
state = frame(state, { type: 'error', code: 'x', message: 'x' })
|
||||
state = seanceReducer(state, { type: 'local_question', text: 'hi', at: AT })
|
||||
expect(state).not.toEqual(initialSeanceState)
|
||||
|
||||
state = seanceReducer(state, { type: 'reset' })
|
||||
expect(state).toEqual(initialSeanceState)
|
||||
})
|
||||
})
|
||||
466
frontend/src/state/seance.tsx
Normal file
466
frontend/src/state/seance.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
// Séance state: React context + reducer holding the whole session model,
|
||||
// plus a provider that wires the VeilSocket and the spirit-audio queue to it.
|
||||
// The reducer is pure and unit-tested; side effects live in the provider.
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { VeilSocket } from '../lib/ws'
|
||||
import type { VeilConnectionState } from '../lib/ws'
|
||||
import { SpiritAudioPlayer } from '../lib/audio'
|
||||
import type {
|
||||
Language,
|
||||
Mode,
|
||||
ServerFrame,
|
||||
SessionStatus,
|
||||
SpiritEntity,
|
||||
Telemetry,
|
||||
UtteranceKind,
|
||||
} from '../lib/types'
|
||||
|
||||
export type Utterance = {
|
||||
id: string
|
||||
kind: UtteranceKind
|
||||
text: string
|
||||
entity: string | null
|
||||
audioUrl: string | null
|
||||
at: number
|
||||
}
|
||||
|
||||
export type AnomalyEntry = {
|
||||
id: number
|
||||
source: 'radio' | 'evp' | 'wire'
|
||||
frequency: number
|
||||
magnitude: number
|
||||
at: number
|
||||
}
|
||||
|
||||
export type TranscriptEntry =
|
||||
| { id: number; kind: 'anomaly'; source: string; frequency: number; magnitude: number; at: number }
|
||||
| { id: number; kind: 'utterance'; utteranceId: string; speaker: string | null; utKind: UtteranceKind; text: string; audioUrl: string | null; at: number }
|
||||
| { id: number; kind: 'question'; text: string; at: number }
|
||||
| { id: number; kind: 'system'; text: string; at: number }
|
||||
|
||||
export type Toast = { id: number; code: string; message: string; at: number }
|
||||
|
||||
export type SeanceState = {
|
||||
connection: VeilConnectionState
|
||||
sessionId: string | null
|
||||
mode: Mode
|
||||
passive: boolean
|
||||
language: Language
|
||||
status: SessionStatus | null
|
||||
entity: SpiritEntity | null
|
||||
entityIsNew: boolean
|
||||
anomalyCount: number
|
||||
utterances: Utterance[]
|
||||
anomalies: AnomalyEntry[]
|
||||
transcript: TranscriptEntry[]
|
||||
replyStreaming: { active: boolean; text: string }
|
||||
telemetry: Telemetry | null
|
||||
toasts: Toast[]
|
||||
speakingId: string | null
|
||||
}
|
||||
|
||||
export const initialSeanceState: SeanceState = {
|
||||
connection: 'closed',
|
||||
sessionId: null,
|
||||
mode: 'wire',
|
||||
passive: false,
|
||||
language: 'en',
|
||||
status: null,
|
||||
entity: null,
|
||||
entityIsNew: false,
|
||||
anomalyCount: 0,
|
||||
utterances: [],
|
||||
anomalies: [],
|
||||
transcript: [],
|
||||
replyStreaming: { active: false, text: '' },
|
||||
telemetry: null,
|
||||
toasts: [],
|
||||
speakingId: null,
|
||||
}
|
||||
|
||||
// ---- Actions ----
|
||||
|
||||
let idSeq = 1
|
||||
const nextId = () => idSeq++
|
||||
|
||||
const TRANSCRIPT_CAP = 400
|
||||
const UTTERANCE_CAP = 200
|
||||
const ANOMALY_CAP = 200
|
||||
const TOAST_CAP = 4
|
||||
|
||||
export type SeanceAction =
|
||||
| { type: 'connection'; state: VeilConnectionState }
|
||||
| { type: 'frame'; frame: ServerFrame; at?: number }
|
||||
| { type: 'local_anomaly'; source: 'radio' | 'evp' | 'wire'; frequency: number; magnitude: number; at?: number }
|
||||
| { type: 'local_question'; text: string; at?: number }
|
||||
| { type: 'set_mode'; mode: Mode }
|
||||
| { type: 'set_language'; language: Language }
|
||||
| { type: 'set_passive'; enabled: boolean }
|
||||
| { type: 'set_speaking'; utteranceId: string | null }
|
||||
| { type: 'dismiss_toast'; id: number }
|
||||
| { type: 'reset' }
|
||||
|
||||
function pushCapped<T>(arr: T[], item: T, cap: number): T[] {
|
||||
const out = arr.length >= cap ? arr.slice(arr.length - cap + 1) : [...arr]
|
||||
out.push(item)
|
||||
return out
|
||||
}
|
||||
|
||||
function withToast(state: SeanceState, toast: Toast): SeanceState {
|
||||
const toasts = state.toasts.length >= TOAST_CAP ? state.toasts.slice(1) : [...state.toasts]
|
||||
toasts.push(toast)
|
||||
return { ...state, toasts }
|
||||
}
|
||||
|
||||
export function seanceReducer(state: SeanceState, action: SeanceAction): SeanceState {
|
||||
const at = ('at' in action && action.at) || Date.now()
|
||||
|
||||
switch (action.type) {
|
||||
case 'connection':
|
||||
return { ...state, connection: action.state }
|
||||
|
||||
case 'local_anomaly': {
|
||||
const entry: AnomalyEntry = {
|
||||
id: nextId(),
|
||||
source: action.source,
|
||||
frequency: action.frequency,
|
||||
magnitude: action.magnitude,
|
||||
at,
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
anomalies: pushCapped(state.anomalies, entry, ANOMALY_CAP),
|
||||
transcript: pushCapped(
|
||||
state.transcript,
|
||||
{
|
||||
id: entry.id,
|
||||
kind: 'anomaly',
|
||||
source: action.source,
|
||||
frequency: action.frequency,
|
||||
magnitude: action.magnitude,
|
||||
at,
|
||||
},
|
||||
TRANSCRIPT_CAP,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
case 'local_question':
|
||||
return {
|
||||
...state,
|
||||
transcript: pushCapped(
|
||||
state.transcript,
|
||||
{ id: nextId(), kind: 'question', text: action.text, at },
|
||||
TRANSCRIPT_CAP,
|
||||
),
|
||||
}
|
||||
|
||||
case 'set_mode':
|
||||
return { ...state, mode: action.mode }
|
||||
|
||||
case 'set_language':
|
||||
return { ...state, language: action.language }
|
||||
|
||||
case 'set_passive':
|
||||
return { ...state, passive: action.enabled }
|
||||
|
||||
case 'set_speaking':
|
||||
return { ...state, speakingId: action.utteranceId }
|
||||
|
||||
case 'dismiss_toast':
|
||||
return { ...state, toasts: state.toasts.filter((t) => t.id !== action.id) }
|
||||
|
||||
case 'reset':
|
||||
return { ...initialSeanceState }
|
||||
|
||||
case 'frame': {
|
||||
const frame = action.frame
|
||||
switch (frame.type) {
|
||||
case 'session':
|
||||
return { ...state, sessionId: frame.id }
|
||||
|
||||
case 'pong':
|
||||
return state
|
||||
|
||||
case 'mode':
|
||||
return { ...state, mode: frame.mode }
|
||||
|
||||
case 'status':
|
||||
return { ...state, status: frame.state }
|
||||
|
||||
case 'entity': {
|
||||
return {
|
||||
...state,
|
||||
entity: frame.entity,
|
||||
entityIsNew: frame.is_new,
|
||||
status: null,
|
||||
transcript: pushCapped(
|
||||
state.transcript,
|
||||
{
|
||||
id: nextId(),
|
||||
kind: 'system',
|
||||
text: `⟁ ${frame.entity.name} — ${frame.entity.epithet}`,
|
||||
at,
|
||||
},
|
||||
TRANSCRIPT_CAP,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
case 'anomaly_ack':
|
||||
return { ...state, anomalyCount: frame.count }
|
||||
|
||||
case 'utterance': {
|
||||
const ut: Utterance = {
|
||||
id: frame.id,
|
||||
kind: frame.kind,
|
||||
text: frame.text,
|
||||
entity: frame.entity,
|
||||
audioUrl: null,
|
||||
at,
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
status: null,
|
||||
utterances: pushCapped(state.utterances, ut, UTTERANCE_CAP),
|
||||
transcript: pushCapped(
|
||||
state.transcript,
|
||||
{
|
||||
id: nextId(),
|
||||
kind: 'utterance',
|
||||
utteranceId: ut.id,
|
||||
speaker: ut.entity,
|
||||
utKind: ut.kind,
|
||||
text: ut.text,
|
||||
audioUrl: null,
|
||||
at,
|
||||
},
|
||||
TRANSCRIPT_CAP,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
case 'audio': {
|
||||
// Pair the audio URL with its utterance (both the store and the
|
||||
// transcript row gain the replay button).
|
||||
return {
|
||||
...state,
|
||||
utterances: state.utterances.map((u) =>
|
||||
u.id === frame.id ? { ...u, audioUrl: frame.url } : u,
|
||||
),
|
||||
transcript: state.transcript.map((t) =>
|
||||
t.kind === 'utterance' && t.utteranceId === frame.id
|
||||
? { ...t, audioUrl: frame.url }
|
||||
: t,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
case 'reply_start':
|
||||
return { ...state, replyStreaming: { active: true, text: '' } }
|
||||
|
||||
case 'reply_token':
|
||||
return {
|
||||
...state,
|
||||
replyStreaming: {
|
||||
active: true,
|
||||
text: state.replyStreaming.text + frame.token,
|
||||
},
|
||||
}
|
||||
|
||||
case 'reply_end': {
|
||||
const ut: Utterance = {
|
||||
id: frame.id,
|
||||
kind: 'reply',
|
||||
text: frame.text,
|
||||
entity: state.entity?.name ?? null,
|
||||
audioUrl: null,
|
||||
at,
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
status: null,
|
||||
replyStreaming: { active: false, text: '' },
|
||||
utterances: pushCapped(state.utterances, ut, UTTERANCE_CAP),
|
||||
transcript: pushCapped(
|
||||
state.transcript,
|
||||
{
|
||||
id: nextId(),
|
||||
kind: 'utterance',
|
||||
utteranceId: ut.id,
|
||||
speaker: ut.entity,
|
||||
utKind: 'reply',
|
||||
text: ut.text,
|
||||
audioUrl: null,
|
||||
at,
|
||||
},
|
||||
TRANSCRIPT_CAP,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
case 'telemetry':
|
||||
return {
|
||||
...state,
|
||||
telemetry: {
|
||||
jitter_bytes_per_s: frame.jitter_bytes_per_s,
|
||||
latency_variance_ms: frame.latency_variance_ms,
|
||||
latency_mean_ms: frame.latency_mean_ms,
|
||||
dns_ms: frame.dns_ms,
|
||||
},
|
||||
}
|
||||
|
||||
case 'error':
|
||||
return withToast(state, {
|
||||
id: nextId(),
|
||||
code: frame.code,
|
||||
message: frame.message,
|
||||
at,
|
||||
})
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Provider ----
|
||||
|
||||
export type SeanceApi = {
|
||||
state: SeanceState
|
||||
socket: VeilSocket
|
||||
audioPlayer: SpiritAudioPlayer
|
||||
setMode: (mode: Mode) => void
|
||||
setPassive: (enabled: boolean) => void
|
||||
setLanguage: (language: Language) => void
|
||||
summon: () => void
|
||||
ask: (text: string) => void
|
||||
sendAnomaly: (source: 'radio' | 'evp' | 'wire', frequency: number, magnitude: number) => void
|
||||
playUtterance: (utteranceId: string) => void
|
||||
dismissToast: (id: number) => void
|
||||
}
|
||||
|
||||
const SeanceContext = createContext<SeanceApi | null>(null)
|
||||
|
||||
export function SeanceProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(seanceReducer, initialSeanceState)
|
||||
const socketRef = useRef<VeilSocket | null>(null)
|
||||
const playerRef = useRef<SpiritAudioPlayer | null>(null)
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
|
||||
if (!socketRef.current) {
|
||||
socketRef.current = new VeilSocket()
|
||||
playerRef.current = new SpiritAudioPlayer()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const socket = socketRef.current!
|
||||
const player = playerRef.current!
|
||||
|
||||
const offFrame = socket.onFrame((frame) => {
|
||||
dispatch({ type: 'frame', frame, at: Date.now() })
|
||||
if (frame.type === 'audio') {
|
||||
// Auto-play spirit voice on arrival (queued behind anything playing).
|
||||
player.enqueue(frame.id, frame.url)
|
||||
}
|
||||
})
|
||||
const offState = socket.onState((s) => dispatch({ type: 'connection', state: s }))
|
||||
|
||||
player.setCallbacks({
|
||||
onStart: (info) => dispatch({ type: 'set_speaking', utteranceId: info.utteranceId }),
|
||||
onEnd: () => dispatch({ type: 'set_speaking', utteranceId: null }),
|
||||
})
|
||||
|
||||
socket.connect()
|
||||
return () => {
|
||||
offFrame()
|
||||
offState()
|
||||
socket.close()
|
||||
player.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setMode = useCallback((mode: Mode) => {
|
||||
dispatch({ type: 'set_mode', mode })
|
||||
socketRef.current?.send({ type: 'set_mode', mode })
|
||||
}, [])
|
||||
|
||||
const setPassive = useCallback((enabled: boolean) => {
|
||||
dispatch({ type: 'set_passive', enabled })
|
||||
socketRef.current?.send({ type: 'passive', enabled })
|
||||
}, [])
|
||||
|
||||
const setLanguage = useCallback((language: Language) => {
|
||||
dispatch({ type: 'set_language', language })
|
||||
socketRef.current?.send({ type: 'language', language })
|
||||
}, [])
|
||||
|
||||
const summon = useCallback(() => {
|
||||
socketRef.current?.send({ type: 'summon' })
|
||||
}, [])
|
||||
|
||||
const ask = useCallback((text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
dispatch({ type: 'local_question', text: trimmed, at: Date.now() })
|
||||
socketRef.current?.send({ type: 'question', text: trimmed })
|
||||
}, [])
|
||||
|
||||
const sendAnomaly = useCallback(
|
||||
(source: 'radio' | 'evp' | 'wire', frequency: number, magnitude: number) => {
|
||||
dispatch({ type: 'local_anomaly', source, frequency, magnitude, at: Date.now() })
|
||||
socketRef.current?.send({ type: 'anomaly', source, frequency, magnitude })
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const playUtterance = useCallback((utteranceId: string) => {
|
||||
// Replay: re-queue through the same single-queue player.
|
||||
const ut = stateRef.current.utterances.find((u) => u.id === utteranceId)
|
||||
if (ut?.audioUrl) playerRef.current?.enqueue(ut.id, ut.audioUrl)
|
||||
}, [])
|
||||
|
||||
const dismissToast = useCallback((id: number) => {
|
||||
dispatch({ type: 'dismiss_toast', id })
|
||||
}, [])
|
||||
|
||||
const api = useMemo<SeanceApi>(
|
||||
() => ({
|
||||
state,
|
||||
socket: socketRef.current!,
|
||||
audioPlayer: playerRef.current!,
|
||||
setMode,
|
||||
setPassive,
|
||||
setLanguage,
|
||||
summon,
|
||||
ask,
|
||||
sendAnomaly,
|
||||
playUtterance,
|
||||
dismissToast,
|
||||
}),
|
||||
[state, setMode, setPassive, setLanguage, summon, ask, sendAnomaly, playUtterance, dismissToast],
|
||||
)
|
||||
|
||||
return <SeanceContext.Provider value={api}>{children}</SeanceContext.Provider>
|
||||
}
|
||||
|
||||
export function useSeance(): SeanceApi {
|
||||
const ctx = useContext(SeanceContext)
|
||||
if (!ctx) throw new Error('useSeance must be used inside <SeanceProvider>')
|
||||
return ctx
|
||||
}
|
||||
Reference in New Issue
Block a user