diff --git a/.superset/db-gate.sh b/.superset/db-gate.sh index 0d2055c3a..9be7c0a30 100755 --- a/.superset/db-gate.sh +++ b/.superset/db-gate.sh @@ -137,7 +137,7 @@ for url in "$mailer_url/api/v1/messages" "$app_origin" "http://127.0.0.1:18090/_ done curl --fail --silent --output /dev/null "$url" done -for function_name in workspace-invites ai ai-stream demo-code-login admin announcements account; do +for function_name in workspace-invites ai ai-stream voice demo-code-login admin announcements account; do function_url="$api_url/functions/v1/$function_name" for _ in $(seq 1 200); do if curl --fail --silent --output /dev/null --request OPTIONS --header "Origin: $app_origin" "$function_url"; then break; fi diff --git a/docs/architecture.md b/docs/architecture.md index 787fb2683..8c18dbc4c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,7 +39,7 @@ Collaborative Workspace (`/app/workspaces/*`, the `workspace-invites` function, [`src/lib/languages.ts`](../src/lib/languages.ts) is the shared catalog for English, Spanish, and Hungarian language codes, labels, and Intl locales. [`src/i18n.ts`](../src/i18n.ts) owns the locale resources and applies the selected interface language to i18next, document metadata, and ``. Logged-out startup preserves local language preferences; authenticated hydration accepts only catalog languages. -Interface language and AI-output language are separate settings. Interface language controls rendered copy, accessible names, generated app boilerplate, dates, numbers, relative time, PDF chrome, and Web Speech's recognition locale. AI-output language is sent through every structured, streaming, Crossfire, and Sparring request path and becomes an explicit backend prompt instruction. Existing user/provider content and previously saved titles are not rewritten when either setting changes. +Interface language and AI-output language are separate settings. Interface language controls rendered copy, accessible names, generated app boilerplate, dates, numbers, relative time, PDF chrome, and Web Speech's recognition locale for dictation. Voice rounds recognise speech in the AI-output language instead, because that is the language the round is held in. AI-output language is sent through every structured, streaming, Crossfire, and Sparring request path and becomes an explicit backend prompt instruction. Existing user/provider content and previously saved titles are not rewritten when either setting changes. ## Supabase environments and sessions @@ -64,6 +64,18 @@ Client-side environment selection is a developer convenience, not authorization. For a validated Crossfire next turn, the server returns the reply first with a short-lived signed sidecar token. The client then requests a Jev classification of only the newest completed question-answer exchange. The token is atomically claimed through its own one-use counter, separate from the product's daily AI quota. The optional directness, concession, and follow-up signal is attached by turn ID to the answer; a missing, slow, replayed, forged, or failed sidecar never delays or invalidates the main reply. Draft version 1 remains compatible because turn signals are optional. +### Voice rounds + +Sparring and Crossfire can be spoken. The setup screen offers a voice switch only where the browser has the Web Speech recogniser and the microphone is not blocked; otherwise it shows a one-line reason and the round is typed as before. The first time the switch is turned on, a notice says where audio and text go, and [`src/lib/voice/availability.ts`](../src/lib/voice/availability.ts) remembers the acknowledgement in that browser. + +The student's speech never reaches DebateLab as audio. [`src/hooks/use-speech-capture.ts`](../src/hooks/use-speech-capture.ts) feeds the microphone to the browser's recogniser (in Chrome that sends audio to Google), restarts it when the browser ends a session on silence, and puts the recognised words into the same draft a typed turn uses. It also times the take, and [`src/lib/voice/delivery.ts`](../src/lib/voice/delivery.ts) turns that into measured facts: speaking time, words per minute, filler words from a per-language list, and silences of three seconds or more. The facts ride on the user's speech or turn as an optional `delivery` field, so sessions and drafts from before voice rounds still load. The sparring scorecard and the Crossfire debrief show them as numbers. The sparring judge receives them with a prompt that allows it to quote those numbers and comment on structure, and forbids any claim about tone, confidence, or volume; typed rounds keep the typed prompt unchanged. The Crossfire debrief is never asked to judge delivery. + +A voice request adds a top-level `voice: true` beside `input`, where a server from before voice rounds ignores it. The opponent prompt then allows a short allowlist of inline audio tags such as `[sighs]` and `[sarcastic]`. [`supabase/functions/_shared/voice-tags.ts`](../supabase/functions/_shared/voice-tags.ts) splits the model's reply in two: the clean text, with every tag removed, is what the response's `output` carries, what is signed, shown, saved, and read by the fallback voice; the spoken text keeps allowlisted tags, turns `[pause]` into the ellipsis Eleven v4 documents for pauses, rations tags to at most one per sentence and never two sentences running, and is cut into segments of at most 1,000 characters. Up to 12 segments are returned in a top-level `voice.segments` list, each with a 30-minute HMAC token from [`_shared/voice-token.ts`](../supabase/functions/_shared/voice-token.ts) that binds the text to the user and the feature; the verifier allows 30 seconds of clock difference between the two functions. Text past the twelfth segment comes back unsigned and without tags in `voice.overflow`, which only the browser voice reads, so a long reply is heard in full without raising the ElevenLabs cost of one reply. The spoken text lives only in memory in the browser. + +[`src/lib/voice/opponent-voice.ts`](../src/lib/voice/opponent-voice.ts) plays a reply segment by segment through the [`voice`](../supabase/functions/voice/index.ts) Edge Function, fetching one segment ahead and decoding with Web Audio. The function authenticates the caller, verifies the token so it speaks only text the server wrote, and returns `voice_unavailable` when `ELEVENLABS_API_KEY` is not set, before it reserves anything. It then checks the account, reserves one clip from the per-user daily voice cap through `consume_daily_quota` under the `voice` key (60 clips for paid access, 12 on a trial, overridable with a `daily_voice_clips` row in `rate_limits`), calls ElevenLabs (`eleven_v4` for Sparring, `eleven_v4_turbo` for Crossfire), and logs an `ai_requests` row with the billed characters. Every attempt keeps its clip, including one ElevenLabs refuses, so replayed tokens cannot reach the provider without limit while it fails. Whenever a segment cannot be had, because the key is missing, the cap is reached, or the call fails, the browser's `speechSynthesis` reads the rest of the reply from the clean text and the round continues. After any refusal from the function other than an invalid token, the browser stops asking for the rest of that round and tries ElevenLabs again when the next round starts; a network error is retried on the next reply. In Sparring the student's clock waits while the opponent speaks. Starting to speak stops the opponent's audio. + +[`vercel.json`](../vercel.json) allows the microphone for the app's own origin only (`microphone=(self)`); dictation and voice rounds both depend on it. + A configured mock provider bypasses Supabase for local and test behavior. It is not authenticated backend evidence. ## Research and source import @@ -112,7 +124,7 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b Checkout and portal functions create Stripe sessions after authenticated ownership and redirect validation. The webhook updates subscription state and records event processing for idempotency. AI handlers record the provider-reported usage and charge for each request. -All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, Luna Basic default, Pro-only entries, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. +All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, Luna Basic default, Pro-only entries, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. The opponent's voice is the one exception to the OpenRouter route: the `voice` function calls ElevenLabs with `ELEVENLABS_API_KEY`. ElevenLabs reports billed characters, not dollars, so [`supabase/functions/_shared/cost.ts`](../supabase/functions/_shared/cost.ts) prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge. `Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. The Edge Functions resolve them through TypeSafe Jev on OpenRouter before calling the concrete writing model. Auto Fast is available to Basic accounts and normally resolves to GLM 5.3 Flash; manual GLM selection keeps its Pro restriction. Auto Pro remains Pro-only. Provider calls, usage rows, and response metadata record the concrete resolved model, while response metadata also retains the requested automatic selection. A Jev router call uses the same OpenRouter key and does not consume a second daily quota unit. diff --git a/docs/operations.md b/docs/operations.md index 43c17957b..9e8d4f149 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -41,7 +41,7 @@ Vercel builds and serves the frontend from the Git repository. Merging to `main` - SPA fallback to `index.html`; - no-cache headers for `index.html`, `theme-init.js`, and `sw.js`, and immutable caching for hashed `/assets/`; - `noindex` headers for `/app`, `/login`, and `/share` routes; -- Content Security Policy and the other response security headers. +- Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only. [`src/test/vercel-deployment-config.test.ts`](../src/test/vercel-deployment-config.test.ts) guards these rules. Update it together with `vercel.json`, and do not paste the full configuration into another guide. @@ -139,6 +139,8 @@ Auth patterns include: A successful deploy command proves upload only. Verify the behavior through the real endpoint or protected staging flow. +Voice rounds span three independent steps, and the frontend works at every stage of them. The frontend alone (a merge) offers voice rounds that speak with the browser's voice. Deploying `ai` adds audio tags and signed segments to voice replies; deploying `voice` adds the endpoint, which answers `voice_unavailable` until `ELEVENLABS_API_KEY` is set. Deploy `ai` and `voice` together, since they share the token code. Once the key is set, check one Sparring and one Crossfire reply in a real browser, and confirm `voice_sparring` and `voice_crossfire` rows with a cost in `ai_requests`. Removing the key returns every round to the browser voice without a deploy. + ## CORS Current functions mostly use local explicit origin allowlists. The exact list can differ by endpoint. Treat handler code as the source of truth. @@ -153,6 +155,7 @@ Secret names may be documented; values may not. Server-side categories include: - Supabase service credentials; - AI provider credentials; +- the ElevenLabs credential for the opponent's voice (`ELEVENLABS_API_KEY`); - research provider credentials; - Stripe API and webhook credentials; - demo-code hashing material; diff --git a/docs/testing.md b/docs/testing.md index d7ffcaa5b..c7a5b5211 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -68,6 +68,8 @@ A browser-oriented Vitest pass does not replace native Deno tests. The stream ac Current Debate context has focused Deno tests: `_shared/debate-context.test.ts` covers selection rules, the IDs-only request parser, digest budgets, per-feature policy, and loader isolation against real PostgREST query shapes; `ai/debate-context.test.ts` drives the `ai` and `ai-stream` handlers with forged debates, cross-debate IDs, a retired custom prompt, and stored text that tries to close its block. +Voice has its own Deno tests. `voice/index_test.ts` drives the `voice` handler against a fake ElevenLabs: a signed segment becomes audio and one costed usage row; a missing key, an invalid or expired token, edited text, another user's token, a reached cap, an expired or banned account, and a provider failure each return their code without reaching paid work they should not; every provider call keeps its clip however it ends, and an oversized body is refused without being read. `ai/voice.test.ts` drives the `ai` handler and checks that tags reach only the voice payload, that typed requests and the typed prompt are unchanged, and that measured delivery facts reach the judge and the saved round. `_shared/voice-tags.test.ts`, `_shared/voice-token.test.ts`, and `_shared/cost.test.ts` cover tag stripping and rationing, token binding, and character pricing. The permission list includes `ELEVENLABS_API_KEY` because the handler reads it; no real key is used. + `_shared/prompts/prompt-examples.test.ts` checks the two representative examples per feature in [`supabase/functions/_shared/prompts/prompt-examples.ts`](../supabase/functions/_shared/prompts/prompt-examples.ts) against each feature's quality rules and the fake provider's routing lines. ### Prompt example comparison @@ -124,6 +126,8 @@ The server is never reused. CI uses retries and two workers. On failure, traces, The speech unit test proves that Hungarian selects `recognition.lang = 'hu-HU'`. Credential-free browser automation has no real microphone or speech service, so it does not prove recognition accuracy or hardware support. +Voice rounds are tested without a microphone, a speech service, or an ElevenLabs key. `src/test/voice/` covers the capture hook against a scripted recogniser (interim words, restart on silence, last words after stop, pause timing, refused microphone), the delivery measurements in all three languages, and the opponent voice player against a scripted Web Audio and `speechSynthesis` (segment order, every fallback path, stop, and blocked autoplay). `src/test/pages/SparringVoice.test.tsx` and `CrossfireVoice.test.tsx` drive both pages through a spoken turn. `e2e/voice-rounds.spec.ts` does the same in Chromium with the scripted recogniser and `speechSynthesis` from `e2e/helpers/voice.ts`, once with the `voice` route returning audio and once with it returning `voice_unavailable`, and asserts that no audio tag reaches the page or storage. None of this proves how ElevenLabs sounds, that a real browser recogniser hears a student, or that the live `voice` function is deployed with a key. + AI client and Edge tests prove that every request path carries a validated language and that prompts require Hungarian output. Provider responses are mocked; only an authorized protected-staging run can prove that a live provider follows the instruction across every prose field. ## Full credential-free gate @@ -182,6 +186,7 @@ Each test signs in through the Login page with a fresh demo code and deletes tha - a fresh account's first run in English and Hungarian: the tour starts the first debate, the language picked before sign-up survives the first sign-in, and a tool offers a debate from the typed motion when none is current; - Current Debate context: every request carries the debate's motion, instructions, and sources; Review drops a source and pins a saved case for exactly one request; the pin is stored; Crossfire leaves instructions out; and one-off work sends nothing; - a judged Lincoln-Douglas sparring round and a Crossfire debrief after a Jev signal attaches to an answer; +- spoken Sparring and Crossfire rounds against the real `ai` and `voice` functions with no ElevenLabs key: the opponent prompt carries the voice rules, the reply comes back clean with signed segments, `voice` answers `voice_unavailable`, the browser voice reads the clean text, the judge gets the measured facts, and the saved round holds no audio tag (the recogniser and the browser voice are scripted); - Library activation and deletion, output-language sync across devices, flowchart saves, and Delete All My Data (it must leave another account's rows intact); - Download my data (only the caller's rows, even when the request names another user) and Delete account (no row in any `public` or `private` table mentions the user afterwards, another account is untouched, and a call without a session or with another user's confirmation deletes nothing); - a promo code redeemed in Settings that restores AI access, and signed Stripe webhooks that grant and revoke Pro while a forged or replayed one changes nothing; diff --git a/e2e/full-stack/fake-openrouter.mjs b/e2e/full-stack/fake-openrouter.mjs index 289fafd9a..1583e2611 100644 --- a/e2e/full-stack/fake-openrouter.mjs +++ b/e2e/full-stack/fake-openrouter.mjs @@ -112,7 +112,9 @@ const ask = () => ({ uncertainties: [], }); -const sparring = (user) => /^SPARRING ACTION: score/m.test(user) +// In a voice round the prompt allows audio tags. The fake uses one allowed tag +// and one invented tag, so the journey can prove neither reaches the page. +const sparring = (user, system) => /^SPARRING ACTION: score/m.test(user) ? { action: "score", scorecard: { @@ -129,7 +131,9 @@ const sparring = (user) => /^SPARRING ACTION: score/m.test(user) : { action: "respond", speech: { - content: `${MARK} opponent speech: the policy needs a stronger comparative case.`, + content: system.includes("SPOKEN ROUND:") + ? `[sighs] ${MARK} opponent speech: the policy needs a stronger comparative case. [dramatic gasp] It is not there yet.` + : `${MARK} opponent speech: the policy needs a stronger comparative case.`, strategy: "Test the warrant, then weigh the alternative.", targetedPoints: ["The comparative mechanism is underdeveloped."], }, @@ -149,7 +153,8 @@ const crossfire = (user) => { } const type = /must be type "(question|answer)"/.exec(user)?.[1]; if (!type) return null; - return { type, text: `${MARK} crossfire ${type}: what evidence links fewer cars to safer streets?` }; + const tag = user.includes("SPOKEN EXCHANGE:") ? "[scoffs] " : ""; + return { type, text: `${tag}${MARK} crossfire ${type}: what evidence links fewer cars to safer streets?` }; }; // Order matters only where one marker could appear inside another prompt. @@ -160,7 +165,7 @@ const routes = [ ["attack", "Analyze the opponent's case clearly", attack], ["brainstorm", "Brainstorm arguments like experienced competitive debaters", brainstorm], ["factoids", "You are a debate research specialist", factoids], - ["sparring", "live debate sparring opponent", (user) => sparring(user)], + ["sparring", "live debate sparring opponent", (user, system) => sparring(user, system)], ["crossfire", "You are DebateLab's crossfire coach", (user) => crossfire(user)], ["ask", "You are a debate coach chat bot", ask], ]; @@ -174,7 +179,7 @@ function classify(body) { } const route = routes.find(([, marker]) => system.includes(marker)); if (!route) return { feature: null, system, user }; - return { feature: route[0], system, user, output: route[2](user) }; + return { feature: route[0], system, user, output: route[2](user, system) }; } function jevAnswers(questions) { diff --git a/e2e/full-stack/voice.spec.ts b/e2e/full-stack/voice.spec.ts new file mode 100644 index 000000000..854db7164 --- /dev/null +++ b/e2e/full-stack/voice.spec.ts @@ -0,0 +1,145 @@ +import { admin, chooseForMotion, expect, fake, MARK, providerCall, stackReady, test } from "./stack"; +import { say, spokenByBrowser, stubSpeech } from "../helpers/voice"; + +test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); + +// The real ai and voice Edge Functions with no ElevenLabs key, which is how +// the stack runs until an operator adds one. The recogniser and the browser +// voice are scripted; everything behind the browser is real. + +type VoiceBody = { voice?: { segments: Array<{ text: string; token: string }> } }; + +test("a spoken sparring round runs on the browser voice, and no audio tag is shown or saved", async ({ signedIn: page, user }) => { + await stubSpeech(page); + const aiVoice: VoiceBody[] = []; + const voiceAnswers: Array<{ status: number; code?: string }> = []; + page.on("response", async (response) => { + if (response.request().method() !== "POST") return; + const path = new URL(response.url()).pathname; + if (path.endsWith("/functions/v1/ai")) aiVoice.push(await response.json().catch(() => ({}))); + if (path.endsWith("/functions/v1/voice")) { + const body = await response.json().catch(() => ({})); + voiceAnswers.push({ status: response.status(), code: body.code }); + } + }); + + await page.goto("/app/sparring"); + await page.getByLabel("Motion").fill("This House would make public transport free at the point of use."); + await page.getByRole("combobox", { name: "Round format" }).click(); + await page.getByRole("option", { name: /Lincoln-Douglas · 5 speeches/ }).click(); + await page.getByTestId("voice-mode").click(); + await page.getByRole("button", { name: "Turn on voice" }).click(); + await page.getByRole("button", { name: "Start round" }).click(); + await chooseForMotion(page, "create"); + + const speeches = [ + ["Affirmative constructive", "Um, universal access removes the direct price barrier to participation."], + ["First affirmative rebuttal", "Targeted discounts preserve eligibility gaps and administrative friction."], + ["Second affirmative rebuttal", "The universal policy covers every marginal trip, so it is more reliable."], + ] as const; + const reply = `${MARK} opponent speech: the policy needs a stronger comparative case. It is not there yet.`; + let replies = 0; + for (const [turn, words] of speeches) { + const draft = page.getByRole("textbox", { name: `Draft for ${turn}` }); + await expect(draft).toBeVisible({ timeout: 30_000 }); + await page.getByTestId("voice-mic").click(); + await expect(page.getByText("Listening")).toBeVisible(); + await say(page, words); + await expect(draft).toHaveValue(words); + await page.getByRole("button", { name: "Submit speech" }).click(); + if (turn !== "Second affirmative rebuttal") { + replies += 1; + await expect(page.getByText(`${MARK} opponent speech`).last()).toBeVisible({ timeout: 30_000 }); + // The student lets the opponent finish. Turning the microphone on any + // sooner speaks over the opponent, which stops its voice and abandons + // the voice request before the answer arrives. + await expect.poll(() => voiceAnswers.length, { timeout: 30_000 }).toBe(1); + await expect.poll(async () => (await spokenByBrowser(page)).split(reply).length - 1, { timeout: 30_000 }).toBe(replies); + } + } + await expect(page.getByText(`${MARK} verdict: the user had the edge.`)).toBeVisible({ timeout: 30_000 }); + + // The opponent was told the speech is spoken, and the server split its reply: + // signed segments keep the allowed tag and drop the invented one. + const requests = await fake.requests(); + expect(requests.some((request) => request.feature === "sparring" && request.system.includes("SPOKEN ROUND:"))).toBe(true); + const segments = aiVoice.flatMap((body) => body.voice?.segments ?? []); + expect(segments.length).toBeGreaterThan(0); + expect(segments[0].text).toBe(`[sighs] ${reply}`); + expect(segments[0].token).toMatch(/^\d+\.[a-f0-9]{64}$/); + + // With no key the voice function says so, once, and the browser voice reads + // the clean text of both replies: the second without asking the function again. + expect(voiceAnswers).toEqual([{ status: 503, code: "voice_unavailable" }]); + expect(await spokenByBrowser(page)).toBe(`${reply} ${reply}`); + expect(await spokenByBrowser(page)).not.toContain("["); + + // The judge was given the measured facts and the rule against claiming to have heard anything. + const judge = requests.find((request) => request.feature === "sparring" && request.user.includes("SPARRING ACTION: score")); + expect(judge?.system).toContain("You did not hear this round"); + expect(judge?.user).toContain('"wordsPerMinute"'); + + // Scorecard: measured facts, and no tag anywhere on the page. + await expect(page.getByTestId("spoken-delivery")).toContainText("Nobody listened for tone, confidence or volume"); + await page.getByRole("button", { name: "Show transcript" }).click(); + await expect(page.locator("body")).not.toContainText("[sighs]"); + await expect(page.locator("body")).not.toContainText("[dramatic gasp]"); + + // The saved round holds clean opponent text and the user's measurements. + let saved: { turns: Array<{ speaker: string; content: string; delivery?: { wordCount: number; fillerCount: number } }> } | null = null; + await expect.poll(async () => { + const { data } = await admin.from("sparring_sessions").select("status,turns").eq("user_id", await user.userId()); + saved = data?.[0] ?? null; + return data?.map((row) => row.status); + }).toEqual(["complete"]); + const turns = saved!.turns; + expect(turns.filter((turn) => turn.speaker === "ai").map((turn) => turn.content)).toEqual([reply, reply]); + expect(turns[0].delivery).toMatchObject({ wordCount: 10, fillerCount: 1 }); + expect(turns[1].delivery).toBeUndefined(); + + // No provider voice was used, so no voice usage was billed. + const { data: usage } = await admin.from("ai_requests").select("feature").eq("user_id", await user.userId()).like("feature", "voice_%"); + expect(usage).toEqual([]); +}); + +test("a spoken Crossfire reply is cleaned, signed, and read by the browser voice", async ({ signedIn: page }) => { + await stubSpeech(page); + const aiVoice: Array = []; + page.on("response", async (response) => { + if (response.request().method() === "POST" && new URL(response.url()).pathname.endsWith("/functions/v1/ai")) { + aiVoice.push(await response.json().catch(() => ({}))); + } + }); + + await page.goto("/app/crossfire"); + await page.getByTestId("crossfire-resolution").fill("Resolved: cities should ban private cars downtown."); + await page.getByTestId("crossfire-argument").fill("Private cars create congestion and unsafe streets."); + await page.getByTestId("mode-question-ai").click(); + await page.getByTestId("voice-mode").click(); + await page.getByRole("button", { name: "Turn on voice" }).click(); + await page.getByTestId("start-crossfire").click(); + await chooseForMotion(page, "create"); + + await page.getByTestId("voice-mic").click(); + await expect(page.getByText("Listening")).toBeVisible(); + await say(page, "What evidence supports the safety mechanism?"); + await expect(page.getByTestId("crossfire-input")).toHaveValue("What evidence supports the safety mechanism?"); + await page.getByTestId("submit-turn").click(); + + const answer = `${MARK} crossfire answer: what evidence links fewer cars to safer streets?`; + await expect(page.getByText(answer)).toBeVisible({ timeout: 30_000 }); + expect((await providerCall("crossfire")).user).toContain("SPOKEN EXCHANGE:"); + await expect.poll(() => spokenByBrowser(page)).toBe(answer); + await expect(page.locator("body")).not.toContainText("[scoffs]"); + + const reply = aiVoice.find((body) => body.voice); + expect(reply?.output?.text).toBe(answer); + expect(reply?.voice?.segments[0].text).toBe(`[scoffs] ${answer}`); + + // The Jev signal still attaches: the signed reply is the clean one. + await expect(page.getByText("Direct answer")).toBeVisible(); + + await page.getByTestId("end-crossfire").click(); + await expect(page.getByTestId("crossfire-debrief")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId("spoken-delivery")).toBeVisible(); +}); diff --git a/e2e/helpers/voice.ts b/e2e/helpers/voice.ts new file mode 100644 index 000000000..c7a77d47a --- /dev/null +++ b/e2e/helpers/voice.ts @@ -0,0 +1,61 @@ +import type { Page } from '@playwright/test'; + +// Browser automation has no microphone and no speech service. These helpers +// replace the page's speech recognition, speech synthesis, and microphone +// with scripted ones, so a test can "say" words and read what the browser +// voice was asked to speak. + +interface VoiceWindow { + __speech: { say: (text: string) => void }; + __spoken: string[]; +} + +/** Install the scripted speech APIs. `recognition: false` models a browser without speech recognition. */ +export async function stubSpeech(page: Page, options: { recognition?: boolean } = {}) { + await page.addInitScript(({ recognition }) => { + const speech = { say: (_text: string) => {} }; + class ScriptedRecognition { + continuous = false; + interimResults = false; + lang = ''; + onresult: (event: unknown) => void = () => {}; + onerror: (event: unknown) => void = () => {}; + onend: () => void = () => {}; + start() { + speech.say = (text: string) => this.onresult({ resultIndex: 0, results: [{ isFinal: true, 0: { transcript: text, confidence: 1 } }] }); + } + stop() { setTimeout(() => this.onend(), 0); } + abort() {} + } + Object.defineProperty(window, 'SpeechRecognition', { configurable: true, writable: true, value: recognition ? ScriptedRecognition : undefined }); + Object.defineProperty(window, 'webkitSpeechRecognition', { configurable: true, writable: true, value: undefined }); + + const spoken: string[] = []; + Object.defineProperty(window, 'speechSynthesis', { + configurable: true, + value: { + getVoices: () => [], + cancel: () => {}, + speak: (utterance: SpeechSynthesisUtterance) => { + spoken.push(utterance.text); + setTimeout(() => utterance.onend?.(new Event('end') as SpeechSynthesisEvent), 20); + }, + }, + }); + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { getUserMedia: async () => ({ getTracks: () => [] }) }, + }); + Object.assign(window, { __speech: speech, __spoken: spoken }); + }, { recognition: options.recognition ?? true }); +} + +/** Deliver words to the page as a final recognition result. The microphone must be on. */ +export async function say(page: Page, text: string) { + await page.evaluate((words) => (window as unknown as VoiceWindow).__speech.say(words), text); +} + +/** Everything the browser's own voice was asked to read, in order. */ +export async function spokenByBrowser(page: Page): Promise { + return await page.evaluate(() => (window as unknown as VoiceWindow).__spoken.join(' ')); +} diff --git a/e2e/voice-rounds.spec.ts b/e2e/voice-rounds.spec.ts new file mode 100644 index 000000000..6912fd813 --- /dev/null +++ b/e2e/voice-rounds.spec.ts @@ -0,0 +1,272 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { setLocale, stubSupabaseSession } from './helpers/auth'; +import { say, spokenByBrowser, stubSpeech } from './helpers/voice'; + +// Voice rounds without a microphone, a speech service, or an ElevenLabs key. +// The recogniser and speechSynthesis are scripted in the page, and the `voice` +// route either returns audio or says the key is missing. These tests prove +// the flow and the fallback, not how any voice sounds. + +const corsHeaders = { + 'access-control-allow-origin': '*', + 'access-control-allow-headers': 'authorization, x-client-info, apikey, content-type', + 'access-control-allow-methods': 'POST, OPTIONS', +}; +const jsonHeaders = { ...corsHeaders, 'content-type': 'application/json' }; + +/** One and a half seconds of silence as a WAV file, enough for Web Audio to decode and play. */ +function silentWav(): Buffer { + const sampleRate = 8_000; + const samples = sampleRate * 1.5; + const header = Buffer.alloc(44); + header.write('RIFF', 0); + header.writeUInt32LE(36 + samples * 2, 4); + header.write('WAVEfmt ', 8); + header.writeUInt32LE(16, 16); + header.writeUInt16LE(1, 20); + header.writeUInt16LE(1, 22); + header.writeUInt32LE(sampleRate, 24); + header.writeUInt32LE(sampleRate * 2, 28); + header.writeUInt16LE(2, 32); + header.writeUInt16LE(16, 34); + header.write('data', 36); + header.writeUInt32LE(samples * 2, 40); + return Buffer.concat([header, Buffer.alloc(samples * 2)]); +} + +type VoiceRequest = { feature: string; text: string; token: string }; + +/** Serve the `voice` Edge Function: audio when `available`, otherwise the missing-key error. */ +async function routeVoice(page: Page, available: boolean, requests: VoiceRequest[]) { + await page.route('**/functions/v1/voice', async (route: Route) => { + if (route.request().method() === 'OPTIONS') { + await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' }); + return; + } + requests.push(route.request().postDataJSON() as VoiceRequest); + if (available) { + await route.fulfill({ status: 200, headers: { ...corsHeaders, 'content-type': 'audio/mpeg' }, body: silentWav() }); + } else { + await route.fulfill({ status: 503, headers: jsonHeaders, body: JSON.stringify({ error: 'Voice is not configured', code: 'voice_unavailable' }) }); + } + }); +} + +const scorecard = { + action: 'score', + scorecard: { + userScore: 78, + aiScore: 74, + categories: [ + { name: 'Logic', userScore: 20, aiScore: 18, feedback: 'The causal chain was clear.' }, + { name: 'Evidence', userScore: 18, aiScore: 18, feedback: 'The factual warrant needed more support.' }, + { name: 'Delivery', userScore: 19, aiScore: 19, feedback: 'The constructive used the measured time well.' }, + { name: 'Strategy', userScore: 21, aiScore: 19, feedback: 'The user prioritized the central clash.' }, + ], + overallFeedback: 'The user won a narrow edge.', + keyMoments: ['The rebuttal answered the strongest objection.'], + improvementAreas: ['Compare the alternative policy explicitly.'], + winner: 'user', + verdict: 'The user had the edge.', + }, +}; + +type SparringBody = { voice?: boolean; input: { action: string; speeches: Array<{ speaker: string; content: string; delivery?: Record }> } }; + +/** Serve sparring: clean text in `output`, tagged and "signed" segments beside it for voice requests. */ +async function routeSparring(page: Page, bodies: SparringBody[]) { + let reply = 0; + await page.route('**/functions/v1/ai**', async (route: Route) => { + if (route.request().method() === 'OPTIONS') { + await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' }); + return; + } + const body = route.request().postDataJSON() as SparringBody; + bodies.push(body); + if (body.input.action === 'score') { + await route.fulfill({ status: 200, headers: jsonHeaders, body: JSON.stringify({ output: scorecard }) }); + return; + } + reply += 1; + const clean = `Opponent reply ${reply}: a narrower pass reaches the same riders for less.`; + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ + output: { action: 'respond', speech: { content: clean, strategy: 'Compare the alternative.', targetedPoints: ['The price barrier.'] } }, + ...(body.voice ? { voice: { segments: [{ text: `[sighs] ${clean}`, token: `token-${reply}` }] } } : {}), + }), + }); + }); +} + +async function startVoiceSparring(page: Page) { + await page.goto('/app/sparring', { waitUntil: 'domcontentloaded' }); + await page.getByLabel('Motion').fill('This House would make public transport free at the point of use.'); + await page.getByRole('combobox', { name: 'Round format' }).click(); + await page.getByRole('option', { name: /Lincoln-Douglas · 5 speeches/ }).click(); + + await page.getByTestId('voice-mode').click(); + const notice = page.getByTestId('voice-notice'); + await expect(notice).toContainText('DebateLab never records or stores your audio.'); + await expect(notice).toContainText('In Chrome, that sends your audio to Google'); + await expect(notice).toContainText('sent as text to ElevenLabs'); + await page.getByRole('button', { name: 'Turn on voice' }).click(); + await expect(page.getByTestId('voice-mode')).toHaveAttribute('aria-checked', 'true'); + + await page.getByRole('button', { name: 'Start round' }).click(); + // No Current Debate yet: run this round once without saving it to a debate. + await page.getByRole('dialog', { name: 'Start a debate with this motion?' }).getByRole('button', { name: 'Not now, use it once' }).click(); + await expect(page.getByText('Round in progress')).toBeVisible(); +} + +async function speakSpeech(page: Page, turnLabel: string, words: string) { + const draft = page.getByRole('textbox', { name: `Draft for ${turnLabel}` }); + await expect(draft).toBeVisible(); + await page.getByTestId('voice-mic').click(); + await expect(page.getByText('Listening')).toBeVisible(); + await say(page, words); + await expect(draft).toHaveValue(words); + await page.getByRole('button', { name: 'Submit speech' }).click(); +} + +test.beforeEach(async ({ page }) => { + await setLocale(page, 'en'); + await stubSupabaseSession(page); +}); + +test('a spoken sparring round works on the browser voice when the server has no ElevenLabs key', async ({ page }) => { + const aiBodies: SparringBody[] = []; + const voiceRequests: VoiceRequest[] = []; + await stubSpeech(page); + await routeSparring(page, aiBodies); + await routeVoice(page, false, voiceRequests); + await startVoiceSparring(page); + + await speakSpeech(page, 'Affirmative constructive', 'Um, universal access removes the price barrier for every rider.'); + await expect(page.getByText('Opponent reply 1:')).toBeVisible(); + // The browser voice reads the reply, without the audio tag. + await expect.poll(() => spokenByBrowser(page)).toBe('Opponent reply 1: a narrower pass reaches the same riders for less.'); + expect(voiceRequests).toHaveLength(1); + expect(voiceRequests[0]).toEqual({ feature: 'sparring', text: '[sighs] Opponent reply 1: a narrower pass reaches the same riders for less.', token: 'token-1' }); + + await speakSpeech(page, 'First affirmative rebuttal', 'A pass still leaves an eligibility gap, you know.'); + await expect(page.getByText('Opponent reply 2:')).toBeVisible(); + await expect.poll(() => spokenByBrowser(page)).toContain('Opponent reply 2:'); + // After the missing-key answer the page does not ask the voice function again. + expect(voiceRequests).toHaveLength(1); + + await speakSpeech(page, 'Second affirmative rebuttal', 'Every marginal trip is covered, so the universal policy is more reliable.'); + await expect(page.getByRole('heading', { name: 'Here is what happened in the room.' })).toBeVisible(); + + // Every opponent request asked for a spoken reply; the judge got the measurements. + expect(aiBodies.map((body) => body.input.action)).toEqual(['respond', 'respond', 'score']); + expect(aiBodies[0].voice).toBe(true); + const judged = aiBodies[2].input.speeches; + expect(judged[0].delivery).toMatchObject({ wordCount: 10, fillerCount: 1 }); + expect(judged[2].delivery).toMatchObject({ fillerCount: 1 }); + expect(judged.filter((speech) => speech.speaker === 'ai').every((speech) => !speech.content.includes('['))).toBe(true); + + const panel = page.getByTestId('spoken-delivery'); + await expect(panel).toContainText('Spoken delivery, measured'); + await expect(panel).toContainText('Nobody listened for tone, confidence or volume'); + await expect(panel).toContainText('words per minute'); + await expect(page.getByText('measured, not heard')).toBeVisible(); + + // No audio tag is shown, spoken by the browser, or kept in the saved round. + await page.getByRole('button', { name: 'Show transcript' }).click(); + await expect(page.getByText('Opponent reply 2: a narrower pass reaches the same riders for less.')).toBeVisible(); + await expect(page.locator('body')).not.toContainText('[sighs]'); + expect(await spokenByBrowser(page)).not.toContain('['); + const stored = await page.evaluate(() => `${JSON.stringify({ ...window.sessionStorage })}${JSON.stringify({ ...window.localStorage })}`); + expect(stored).toContain('Opponent reply 1'); + expect(stored).not.toContain('[sighs]'); +}); + +test('a spoken Crossfire exchange plays ElevenLabs audio through the voice function', async ({ page }) => { + const voiceRequests: VoiceRequest[] = []; + const aiVoiceFlags: Array = []; + await stubSpeech(page); + await routeVoice(page, true, voiceRequests); + await page.route('**/functions/v1/ai**', async (route: Route) => { + if (route.request().method() === 'OPTIONS') { + await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' }); + return; + } + const body = route.request().postDataJSON() as { voice?: boolean; input: { transcript: Array> } }; + aiVoiceFlags.push(body.voice); + // The server accepts only the turn fields it knows; measurements stay in the browser. + expect(body.input.transcript.every((turn) => !('delivery' in turn))).toBe(true); + await route.fulfill({ + status: 200, + headers: jsonHeaders, + body: JSON.stringify({ + output: { type: 'answer', text: 'Traffic counts show fewer vehicle trips.' }, + voice: { segments: [{ text: '[scoffs] Traffic counts show fewer vehicle trips.', token: 'crossfire-token' }] }, + }), + }); + }); + + await page.goto('/app/crossfire', { waitUntil: 'domcontentloaded' }); + await page.getByTestId('crossfire-resolution').fill('Resolved: cities should ban private cars downtown.'); + await page.getByTestId('crossfire-argument').fill('Private cars create congestion and unsafe streets.'); + await page.getByTestId('voice-mode').click(); + await page.getByRole('button', { name: 'Turn on voice' }).click(); + await page.getByTestId('start-crossfire').click(); + await page.getByRole('dialog', { name: 'Start a debate with this motion?' }).getByRole('button', { name: 'Not now, use it once' }).click(); + + await page.getByTestId('voice-mic').click(); + await expect(page.getByText('Listening')).toBeVisible(); + await say(page, 'What evidence supports the congestion claim?'); + await expect(page.getByTestId('crossfire-input')).toHaveValue('What evidence supports the congestion claim?'); + await page.getByTestId('submit-turn').click(); + + await expect(page.getByText('Traffic counts show fewer vehicle trips.')).toBeVisible(); + // The audio comes from the voice function, which received the tagged text and its token. + await expect(page.locator('[data-testid="opponent-voice"][data-engine="elevenlabs"][data-status="speaking"]')).toBeVisible(); + expect(aiVoiceFlags).toEqual([true]); + expect(voiceRequests).toEqual([{ feature: 'crossfire', text: '[scoffs] Traffic counts show fewer vehicle trips.', token: 'crossfire-token' }]); + expect(await spokenByBrowser(page)).toBe(''); + + // The student can cut the opponent off. + await page.getByTestId('opponent-voice-stop').click(); + await expect(page.getByTestId('opponent-voice')).toHaveCount(0); + + await expect(page.locator('body')).not.toContainText('[scoffs]'); + const stored = await page.evaluate(() => JSON.stringify({ ...window.localStorage })); + expect(stored).toContain('Traffic counts show fewer vehicle trips.'); + expect(stored).not.toContain('[scoffs]'); +}); + +test('voice mode is hidden with a reason where the browser cannot recognise speech', async ({ page }) => { + await stubSpeech(page, { recognition: false }); + await page.goto('/app/sparring', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Sparring' })).toBeVisible(); + await expect(page.getByTestId('voice-mode')).toHaveCount(0); + await expect(page.getByTestId('voice-unavailable')).toHaveText('Voice rounds are not available here because this browser has no speech recognition. Typing works as usual.'); + + await page.goto('/app/crossfire', { waitUntil: 'domcontentloaded' }); + await expect(page.getByTestId('crossfire-resolution')).toBeVisible(); + await expect(page.getByTestId('voice-mode')).toHaveCount(0); + await expect(page.getByTestId('voice-unavailable')).toBeVisible(); +}); + +for (const [locale, label, noticeLine, privacyLine] of [ + ['es', 'Ronda con voz', 'DebateLab nunca graba ni guarda tu audio.', 'ElevenLabs para la voz del oponente'], + ['hu', 'Hangos kör', 'A DebateLab soha nem rögzíti és nem tárolja a hangodat.', 'az ElevenLabsszel az ellenfél hangjához'], +] as const) { + test(`the voice notice and the privacy page are translated (${locale})`, async ({ page }) => { + await setLocale(page, locale); + await stubSpeech(page); + await page.goto('/app/sparring', { waitUntil: 'domcontentloaded' }); + await expect(page.getByText(label, { exact: true })).toBeVisible(); + await page.getByTestId('voice-mode').click(); + await expect(page.getByTestId('voice-notice')).toContainText(noticeLine); + await expect(page.getByTestId('voice-notice')).toContainText('Google'); + await expect(page.getByTestId('voice-notice')).toContainText('ElevenLabs'); + + await page.goto('/privacy', { waitUntil: 'domcontentloaded' }); + await expect(page.locator('main')).toContainText(privacyLine); + await expect(page.locator('main')).toContainText('Google'); + }); +} diff --git a/package.json b/package.json index 4fb982123..10cc5cb9d 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "test:collaboration": "vitest run --config services/collaboration/vitest.config.ts", "start:collaboration": "tsx services/collaboration/src/index.ts", "test:watch": "vitest", - "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/", + "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,ELEVENLABS_API_KEY,ELEVENLABS_VOICE_ID,OPENROUTER_API_KEY,OPENROUTER_API_URL,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/", "test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh", "test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium", "test:e2e:webkit": "playwright test --project=webkit", diff --git a/plans/2026-10-01-voice-sparring-crossfire.md b/plans/2026-10-01-voice-sparring-crossfire.md new file mode 100644 index 000000000..5aaffd2fc --- /dev/null +++ b/plans/2026-10-01-voice-sparring-crossfire.md @@ -0,0 +1,32 @@ +# Voice Sparring and Crossfire + +**Status:** Plan. Written 2026-10-01 against `main` at `fe6100a`. It records only the general choices. Implementation details are up to the implementer. + +## Why + +Sparring and Crossfire are typed today. The sparring scorecard admits it judges delivery "from the written speech, not audio". Real debate is spoken, so students should be able to practise out loud against an opponent who talks back. + +## Adam's choices + +- **Opponent voice: ElevenLabs Eleven v4, with the browser voice as fallback.** The opponent speaks through ElevenLabs `eleven_v4`; whether the Turbo variant is better for back-and-forth Crossfire is up to the implementer. If the key is missing, the user is over their cap, or the call fails, the browser's built-in `speechSynthesis` takes over and the round continues. +- **Use v4 expressively.** The opponent's AI responses should include inline audio tags such as `[sighs]`, `[laughs]`, `[pause]` and `[sarcastic]`, used the way a sharp human debater would: occasionally and with purpose, never every sentence. The tags must not show in the on-screen text or the saved transcript, and the browser fallback voice must not read them aloud. +- **Student speech: browser speech recognition.** This is the Web Speech API, which already powers dictation (`src/hooks/use-speech-to-text.ts`). Where it isn't supported, or microphone permission is denied, voice mode is hidden with a short reason and typing works as it does today. +- **Scope: Sparring and Crossfire.** + +## Fixed constraints + +- The ElevenLabs key lives only on the server. The browser asks DebateLab's backend for audio, and the backend enforces auth, a per-user daily voice cap and cost accounting, reusing the existing quota and cost code. +- Adam adds the `ELEVENLABS_API_KEY` secret himself. The implementer never creates, prints or changes secrets. Without the key, everything must work on the browser fallback, including tests. +- DebateLab never records or stores the student's audio. In Chrome, recognition audio goes to Google, and ElevenLabs receives the opponent's text, so the privacy page and a first-use notice must say both. +- Feedback states only measured facts: speaking time against the limit, words per minute, filler words (per language), long pauses and structure. It never claims to have heard tone, confidence or volume. Typed rounds keep today's wording. +- All new UI text exists in English, Spanish and Hungarian. +- Typed Sparring and Crossfire, saved sessions and Current Debate context keep working. Old saved sessions still load. + +## Finish line + +Push a branch, open a PR with the test evidence, and babysit it with the `babysit-pr` skill until it is ready to merge. Do not merge, deploy Edge Functions, apply migrations remotely, or touch secrets. Each of those needs Adam. + +## References + +- [Eleven v4 overview](https://elevenlabs.io/docs/overview/capabilities/text-to-speech/eleven-v4) +- [ElevenLabs models](https://elevenlabs.io/docs/overview/models) diff --git a/plans/README.md b/plans/README.md index 974dd5f27..b46027e13 100644 --- a/plans/README.md +++ b/plans/README.md @@ -8,6 +8,8 @@ Every plan lives in this folder. Before executing one, recheck its paths, assump - **Debate-aware AI and smaller modules (PR 2).** Every feature gets server-loaded Current Debate context with a visible Review control, each feature gets its own prompt module, and the 88 KB `supabase/functions/ai/index.ts` is split into per-feature handlers behind the same endpoints. Plan: [2026-09-25 debate AI quality modules](2026-09-25_074255-debate-ai-quality-modules.md). +- **Voice Sparring and Crossfire.** Students speak their turns through the browser's speech recognition and the opponent answers aloud through ElevenLabs Eleven v4, with the browser voice as fallback. Plan: [2026-10-01 voice sparring and crossfire](2026-10-01-voice-sparring-crossfire.md). + ## Backlog - **Admin staging switcher.** `src/lib/supabase/env.ts` already resolves staging from the `debate-supabase-env` localStorage key, but no admin UI sets it. Staging migration and function sync remain operations that need Adam's authorization. diff --git a/src/components/features/sparring/SparringRound.tsx b/src/components/features/sparring/SparringRound.tsx index c4647bf99..0fc706974 100644 --- a/src/components/features/sparring/SparringRound.tsx +++ b/src/components/features/sparring/SparringRound.tsx @@ -11,15 +11,24 @@ import { cn } from '@/lib/utils'; import { formatRoundTime, type SparringTurnKind } from '@/lib/sparring/formats'; import type { SparringSession } from '@/lib/stores/sparring-store'; import { useSparringStore } from '@/lib/stores/sparring-store'; +import { useSpeechCapture } from '@/hooks/use-speech-capture'; +import { VoiceMicControl } from '@/components/features/voice/VoiceMicControl'; +import { measureDelivery, type SpeechDelivery } from '@/lib/voice/delivery'; +import { opponentVoice } from '@/lib/voice/opponent-voice'; +import type { AppLanguage } from '@/lib/languages'; + +const MAX_SPEECH_CHARS = 6_000; interface SparringRoundProps { session: SparringSession; + /** The student speaks this round; the language is the one they speak in. */ + voice?: { language: AppLanguage } | null; aiLoading?: boolean; aiError?: string | null; judging?: boolean; judgingError?: string | null; submissionError?: string | null; - onSubmit: (content: string) => void; + onSubmit: (content: string, delivery?: SpeechDelivery | null) => void; onRetryAi?: () => void; onRetryJudging?: () => void; } @@ -38,6 +47,7 @@ const SCHOOL_ROLE_GUIDANCE: Partial> = { export function SparringRound({ session, + voice = null, aiLoading = false, aiError, judging = false, @@ -83,10 +93,45 @@ export function SparringRound({ return () => window.clearInterval(intervalId); }, [session.timerRunning, tickTimer, userTurn]); + // Recognised words join the draft, so the student can see and fix them. + const capture = useSpeechCapture(voice?.language ?? 'en', (text) => { + const draft = useSparringStore.getState().currentSession?.draft ?? ''; + setDraft(`${draft}${draft && !/\s$/.test(draft) ? ' ' : ''}${text}`.slice(0, MAX_SPEECH_CHARS)); + }); + const { listening, stop: stopCapture, cancel: cancelCapture } = capture; + const turnKey = `${session.id}:${currentTurn?.id ?? ''}`; + + // Each turn of each round is measured on its own, and a microphone start + // still waiting for permission does not carry over into the next turn. + useEffect(() => cancelCapture, [cancelCapture, turnKey]); + + // The microphone closes when the student's time or turn is over. + const micAllowed = userTurn && !judging && !timeExpired; + useEffect(() => { + if (!micAllowed) void stopCapture(); + }, [micAllowed, stopCapture]); + + const startSpeaking = () => { + // Speaking over the opponent ends its audio, and the clock runs again. + opponentVoice.stop(); + resumeTimer(); + void capture.start(); + }; + + const submitSpeech = async () => { + // Stopping also withdraws a start that is still waiting for permission. + if (listening) await stopCapture(); + else void stopCapture(); + const draft = useSparringStore.getState().currentSession?.draft ?? ''; + if (!draft.trim()) return; + onSubmit(draft, voice ? measureDelivery(capture.getTake(), voice.language) : null); + }; + const canSubmit = (Boolean(session.draft.trim()) || listening) && isOnline && !timeExpired; + const handleKeyDown = (event: React.KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault(); - if (userTurn && session.draft.trim() && !judging && isOnline && !timeExpired) onSubmit(session.draft); + if (userTurn && !judging && canSubmit) void submitSpeech(); } }; @@ -177,19 +222,20 @@ export function SparringRound({ + {voice && void stopCapture()} disabled={timeExpired} />}