Skip to content

feat(voice): spoken Sparring and Crossfire rounds - #84

Merged
Programcode merged 11 commits into
mainfrom
t3code/voice-sparring-crossfire
Oct 2, 2026
Merged

Programcode merged 11 commits into
mainfrom
t3code/voice-sparring-crossfire

Conversation

@Programcode

@Programcode Programcode commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Implements plans/2026-10-01-voice-sparring-crossfire.md: students can speak their Sparring and Crossfire turns and hear the opponent answer aloud.

What changed

The student speaks. A voice switch on both setup screens turns a round into a spoken one. The microphone feeds the browser's speech recognition, and the recognised words go into the same draft a typed turn uses, so the student can still type or correct them. DebateLab never receives the audio. Where the browser has no speech recognition, or the microphone is blocked, the switch is replaced by a one-line reason and the round is typed as before. If the microphone is refused in the middle of a round, the round carries on typed.

The opponent answers aloud. A new voice Edge Function calls ElevenLabs: eleven_v4 for Sparring and eleven_v4_turbo for Crossfire. I chose Turbo for Crossfire because its replies are one to three sentences and the wait matters more there than polish. If the key is missing, the user is over the cap, or the call fails, the browser's speechSynthesis reads the reply and the round continues.

Audio tags. In a voice round the opponent prompt allows a short list of tags such as [sighs], [laughs], [pause] and [sarcastic]. The server then splits the reply in two. The clean text has every tag removed, and it is the only text that is shown, signed, saved, or read by the fallback voice. The spoken text keeps allowed tags and goes to ElevenLabs only. The server also rations tags, so a model that ignores the prompt still gets at most one tag per sentence and never two sentences running.

Measured feedback. For spoken turns the app measures speaking time against the limit, words per minute, filler words from a per-language list, and silences of three seconds or more. The sparring scorecard and the Crossfire debrief show those numbers. The sparring judge receives them with a prompt that lets it quote them and comment on structure, and forbids any claim about tone, confidence or volume. Typed rounds keep today's prompt and wording byte for byte (a test pins this).

Privacy. A first-use notice and the privacy page both say that DebateLab never records or stores audio, that Chrome's recognition sends audio to Google, and that ElevenLabs receives the opponent's text. All new text exists in English, Spanish and Hungarian.

Decisions Adam should know about

  1. vercel.json now sends Permissions-Policy: microphone=(self). It was microphone=(), which blocks the microphone for every origin including ours. Production sends the old value today (checked with curl -I), which makes getUserMedia fail, so voice rounds could not work there and dictation in Ask and Generate is blocked by the same header. This ships with the merge. The header test is updated to pin the new value.
  2. Daily voice cap: 60 clips for paid access, 12 on a trial. A clip is one request of at most 1,000 characters, so the cap also bounds spend. It uses the existing consume_daily_quota counter under a voice key, so there is no migration. A rate_limits row with limit_type = 'daily_voice_clips' overrides it per user or globally. These numbers are my guess at a sensible start and are easy to change.
  3. Cost rows use list prices. ElevenLabs reports billed characters, not dollars, so cost.ts prices voice rows from a dated snapshot of the API pricing page: $0.08 per 1,000 characters for v4 and $0.04 for v4 Turbo. That page shows a promotion until 12 October, and plan credits are not modelled, so a voice row can overstate the real charge. At list price a full School 4-6-6-3 round is roughly $1.30 of opponent speech.
  4. [pause] becomes an ellipsis before it reaches ElevenLabs. The Eleven v4 docs say there is no pause tag and that ... is how to pause. The model may still write [pause].
  5. Voice. The default is "George", the voice the ElevenLabs quickstart uses with eleven_v4. Setting ELEVENLABS_VOICE_ID replaces it.
  6. The voice function speaks only server-written text. Each segment carries an HMAC token bound to the user, the feature and the exact text, valid for 30 minutes. A caller cannot use the endpoint to voice arbitrary text.
  7. Voice rounds recognise speech in the AI-output language, not the interface language, because that is the language the round is held in.

What Adam needs to do after merge

The merge ships the frontend, and voice rounds then work on the browser voice straight away. For ElevenLabs audio:

  1. Deploy the ai and voice Edge Functions together.
  2. Set the ELEVENLABS_API_KEY secret.
  3. Try one Sparring and one Crossfire reply in a real browser, and look for voice_sparring and voice_crossfire rows with a cost in ai_requests.

There is no migration. I did not deploy, touch secrets, or merge.

Changed since the first review at 3c12919

Five commits from another session (055d77a to 6cfb585) changed how voice behaves. In short:

  • After any refusal from the voice function except an invalid token, the browser voice takes the rest of the round, and the next round tries ElevenLabs again. Before, only "no key" and "over the cap" switched it off.
  • A refused clip stays counted against the daily cap, so a failing provider cannot be called without limit.
  • The provider call times out at 25 seconds, the request body is read up to 8,000 bytes and no further, and a token up to 30 seconds ahead of the verifier's clock is accepted.
  • At most 12 segments per reply are signed. The rest of a very long reply is returned as untagged overflow for the browser voice, so nothing is cut off.
  • A reply that arrives after the student left Sparring is saved but not spoken, and a browser utterance that never ends is cancelled.

Two commits from this session:

The mobile failure in e2e/full-stack/voice.spec.ts:12

Cause. The scripted student turned the microphone on as soon as the opponent's reply appeared. That speaks over the opponent, which stops its voice and aborts the request to the voice function. The test then asserted that the function had answered 503 voice_unavailable exactly once, so it passed only when one of the round's two requests beat the click. In the failing CI trace both requests ended in ERR_ABORTED: the first 170 to 350 ms after it started, the second within 160 ms. Desktop passed in the same run only because its second request got through.

It did not start after 3c12919. With the local stack up I measured both things:

  • The voice function answers in a median 71 to 77 ms once warm and 210 to 240 ms after a reload. Almost all of that is the auth lookup, which runs before the body is read. Swapping readBoundedBody for the old req.text() made no measurable difference (30 requests each, measured twice).
  • The unchanged test failed 2 of 8 on mobile at cc4bad7, and 3 of 12 on mobile with the 3c12919 voice client and function restored. Desktop passed 8 of 8.

So the race was in the test from its first version, and the one green mobile run at 3c12919 was luck.

Fix. After each reply the test now waits for the voice function's answer and for the browser voice to finish reading that reply, the same way e2e/voice-rounds.spec.ts already does. Nothing the test checks is looser. One assertion is tighter: the browser voice must have read both replies, where it used to need only one. That makes the test exercise the round-level fallback (no second request) on every run. Removing the fallback from the client makes the test fail.

The interrupted request the test used to hit by chance is now a unit test: an abandoned request is aborted, nothing is read aloud, and the next reply still asks ElevenLabs.

Test evidence

The head is cc03de2.

GitHub Actions on cc03de2:

  • quality: pass.
  • database-tests: pass. pgTAP 451 of 451 in 19 files. Full-stack journeys: 71 passed, 1 skipped, none failed. Both voice journeys passed on desktop and mobile Chromium.
  • browser-tests: pass. 250 passed, 6 skipped.
  • live-e2e: pass. It failed at 3c12919 and has passed since test(live): answer the Current Debate prompt and pin Muse Spark 1.3 Contributor #85 merged into main.
  • model-matrix: still running when this was written. It is not a check for this PR and was failing on main before.

Local, on cc03de2:

  • npm run verify: docs:check, lint and typecheck clean; unit and component tests 1208 of 1208 in 116 files (90.65% statements, 84.14% branches); collaboration 30 of 30; Edge Function tests 325 of 325; build and service-worker smoke pass; browser suite 250 passed, 6 skipped, none failed or flaky. A first attempt lost one unit test (SparringPage.test.tsx, "renders setup…") to its 5-second timeout while the machine's load average was above 9; that test takes 0.9 seconds alone and passed in the full rerun.
  • ./.superset/db-gate.sh: exit 0. pgTAP 451 of 451; full-stack 71 passed, 1 skipped, none failed; both voice journeys passed on both Chromium projects.
  • The fixed voice spec repeated 12 times: 48 of 48 passed (two tests, two projects).

Three earlier gate attempts on the same commit did not finish cleanly, none because of voice:

  • Two died when the shared debatelab-fake-openrouter container was removed mid-run. A process watcher caught the cause: src/test/scripts/db-gate.test.ts runs the real db-gate.sh with only supabase and npm stubbed and HOME pointed at a temp folder. That bypasses the shared lock, and the script's cleanup calls the real docker rm -f on the shared container. So a unit-test run in any worktree kills a gate running anywhere on the machine. The test is on main and is not changed here; another branch (t3code/fix-preview-auth-and-faster-ci) had a fix in progress.
  • One ran to the end with all four voice tests green but lost two account tests on mobile to the 30-second test timeout under the same machine load. Both passed in the clean run above and in CI.

Review: Codex (gpt-6.1-sol, high effort) reviewed the original work at 7af7209..f5a1821 and 3c12919; of the later voice commits, 6cfb585 records a Codex review at the same settings. I did not run a new Codex pass on those five commits. CodeRabbit shows "Review completed" with no review threads. The two commits from this session change tests and the gate script's readiness loop only.

What is not proven

  • No real ElevenLabs call was made, because there is no key. The request shape follows the API reference, and tests run against a fake. How the voice sounds, whether eleven_v4 accepts every allowlisted tag gracefully, and the real latency are unverified until the key exists.
  • No live model wrote a tagged reply. The fake provider and unit tests supply tagged text. Whether real models use tags sparingly is unknown, though the server rations them either way. live-e2e passes now, but it has no voice round.
  • The new header is not checked on a deployment. The Vercel preview is behind Vercel sign-in, so only the config test covers microphone=(self).
  • No real microphone or speech service was used. Tests script the recogniser. Recognition accuracy, and Chrome's behaviour over a six-minute speech, are unverified.
  • Safari and iOS were not tested. The Capacitor shell has no speech recognition, so it shows the "not available" reason.
  • Chrome's recogniser often drops hesitation sounds such as "um", so filler counts are a floor. The scorecard says the transcript can miss words.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added optional voice rounds for Sparring and Crossfire, with speech-to-text input and spoken AI replies.
    • Added browser speech playback when generated voice is unavailable, plus microphone availability notices and controls.
    • Spoken turns can show measured delivery details, including speaking time, pace, filler words, and pauses; transcripts and session history remain available.
    • Added voice privacy disclosures and support in English, Spanish, and Hungarian.
  • Documentation
    • Added guidance on voice availability, billing, deployment, and test coverage.

Programcode and others added 3 commits October 1, 2026 09:52
Students can speak their turns and hear the opponent answer aloud.

The student's speech goes through the browser's speech recognition into
the same draft a typed turn uses. DebateLab never receives the audio. A
new capture hook restarts recognition when the browser ends a session on
silence, shows interim words, and times the take. From that the app
measures speaking time, words per minute, filler words per language, and
long pauses. The scorecard and the Crossfire debrief show those numbers,
and the sparring judge may quote them. Nothing claims to have heard tone,
confidence, or volume. Typed rounds keep their prompt and wording.

The opponent speaks through a new `voice` Edge Function that calls
ElevenLabs (eleven_v4 for Sparring, eleven_v4_turbo for Crossfire). The
ai function lets the model use a short list of audio tags in a voice
round, then splits the reply. The clean text is shown, signed, and saved.
The tagged text is cut into segments, each with an HMAC token, so the
voice function speaks only text the server wrote for that user. The
function checks the session, the token, the account, and a per-user daily
cap on the existing quota counter, and logs each request with its cost.

Without the key, over the cap, or on any failure, the browser's
speechSynthesis reads the clean text and the round continues.

vercel.json now allows the microphone for the app's own origin. The old
value blocked it for every origin, including dictation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two callers could stop the microphone at once, for example the Stop
button and then Submit. The second call replaced the first one's
completion handler, so the first caller waited forever and a submitted
speech could be lost. Both now wait on the same stop.

Some browsers never report the end of a speechSynthesis utterance. The
player now moves on after a time limit based on the utterance length, so
the sparring clock cannot stay paused behind a reply that has ended. A
reply stopped while it waited for a tap to play now ends as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A microphone start that was still waiting for the permission prompt
stayed alive when the turn was submitted, so recognition could open on
the next turn. Submitting, and the end of the student's turn, now
withdraw a pending start. An attempt counter keeps a late answer to an
old prompt from opening a second recogniser.

In Crossfire, words spoken but never sent stayed in the capture when the
round ended, and the next round's first turn inherited their
measurements. The capture is now dropped whenever the round or the
account changes. Sparring keys the same reset on the round and the turn.

If ElevenLabs started a successful response and the audio stream then
broke, the voice function threw without a usage row, although the cap
was spent and the clip may have been billed. It now logs the request
with an unknown cost and returns the fallback error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 1, 2026 11:43
@vercel

vercel Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
debate-navigator Ready Ready Preview Oct 1, 2026 8:53pm UTC

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: c85c234e-1942-4907-a144-de24b14fa022

📥 Commits

Reviewing files that changed from the base of the PR and between cc4bad7 and cc03de2.

📒 Files selected for processing (12)
  • .superset/db-gate.sh
  • docs/architecture.md
  • docs/operations.md
  • docs/testing.md
  • e2e/full-stack/voice.spec.ts
  • e2e/voice-rounds.spec.ts
  • package.json
  • src/locales/en.json
  • src/locales/es.json
  • src/locales/hu.json
  • src/test/voice/opponent-voice.test.ts
  • supabase/README.md
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/locales/en.json
  • src/locales/hu.json
  • src/locales/es.json
  • docs/architecture.md
  • supabase/README.md

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

This PR adds spoken Sparring and Crossfire rounds. It adds browser speech capture, delivery measurements, signed opponent audio, and an ElevenLabs endpoint with browser-speech fallback. It also updates session data, interface text, privacy disclosures, deployment guidance, and automated tests.

Changes

Voice-round feature

Layer / File(s) Summary
Speech capture and delivery measurement
src/hooks/use-speech-capture.ts, src/lib/voice/availability.ts, src/lib/voice/delivery.ts, src/components/features/voice/*
Browser speech recognition provides transcripts and timing data. Delivery metrics include speaking time, pace, fillers, and pauses. Voice controls show availability, consent, capture state, and measured facts.
Voice requests and persisted session data
src/lib/ai/client.ts, src/lib/ai/schemas/*, src/lib/crossfire/*, src/lib/stores/sparring-store.ts
AI requests can include a voice flag and responses can include validated voice payloads. Crossfire state and Sparring sessions preserve voice selection and user delivery measurements.
Sparring and Crossfire integration
src/pages/SparringPage.tsx, src/pages/CrossfirePage.tsx, src/components/features/sparring/*
Both pages connect voice selection, speech capture, delivery data, and opponent playback. Sparring pauses the user timer during opponent playback. Crossfire includes delivery data in its debrief when available.
Prompts and signed voice replies
supabase/functions/_shared/prompts/*, supabase/functions/_shared/voice-tags.ts, supabase/functions/_shared/voice-token.ts, supabase/functions/ai/features/*
Spoken prompts define tag rules. AI handlers return cleaned reply text and signed speech segments when voice is enabled.
Voice endpoint and cost accounting
supabase/functions/voice/*, supabase/functions/_shared/cost.ts, supabase/functions/_shared/entitlements.ts, supabase/config.toml
The voice endpoint authenticates requests, validates signed segments, checks quotas, calls ElevenLabs, and logs usage. Cost calculation uses billed characters for supported ElevenLabs models.
Deployment, documentation, and localization
.superset/db-gate.sh, vercel.json, package.json, docs/*, plans/*, supabase/README.md, src/locales/*
The readiness check includes voice, and microphone access is allowed from the app origin. Documentation and translations describe voice behavior, privacy, deployment, testing, quotas, and billing.

Estimated code review effort: 5 (Critical) | ~90 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Student
  participant SparringPage
  participant SpeechCapture
  participant AI
  participant VoiceFunction
  participant ElevenLabs
  Student->>SparringPage: enable voice and submit spoken turn
  SparringPage->>SpeechCapture: capture speech
  SpeechCapture-->>SparringPage: transcript and delivery measurements
  SparringPage->>AI: submit voice-enabled turn
  AI-->>SparringPage: clean reply and signed segments
  SparringPage->>VoiceFunction: request signed audio segment
  VoiceFunction->>ElevenLabs: request opponent audio
  ElevenLabs-->>VoiceFunction: audio stream
  VoiceFunction-->>SparringPage: MP3 audio or coded error
Loading

Architecture Summary

Architecture risk: 🔵 Low · up to cc03d

The change affects 7 systems.

Changed systems: src, supabase, e2e, docs, plans, package.json, vercel.json

Architecture concerns
No architecture-level concerns identified.

Review details

Systems and components

  • observed — src (service) was modified; 34 changed files map to changed impact.
  • observed — supabase (service) was modified; 22 changed files map to changed impact.
  • observed — e2e (service) was modified; 4 changed files map to changed impact.
  • observed — docs (service) was modified; 3 changed files map to changed impact.

Before / after behavior

  • observed — Modified behavior in e2e/full-stack/fake-openrouter.mjs: sparring now accepts the system prompt; the accompanying comments describe using allowed and invented audio tags to test whether they reach the page.
  • observed — Modified behavior in e2e/full-stack/fake-openrouter.mjs: For sparring responses, the speech content now includes [sighs] and [dramatic gasp] when the system prompt contains SPOKEN ROUND:; otherwise it retains the prior untagged content.
  • observed — Modified behavior in e2e/full-stack/fake-openrouter.mjs: Crossfire responses now prefix their text with [scoffs] when the user prompt contains SPOKEN EXCHANGE:; other responses keep the previous text.
  • observed — Modified behavior in e2e/full-stack/fake-openrouter.mjs: The sparring route now passes both user and system prompt text to sparring, instead of passing only the user prompt.
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

@Programcode

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 57 minutes.

Programcode and others added 5 commits October 1, 2026 16:23
- Give the clip back when ElevenLabs refuses a request with an error
  status, since nothing was billed. Timeouts, aborts and cut-off audio
  keep their clip, because the provider may have billed them and a
  refund would let a caller that aborts early spend without a cap.
  The refund is a compare-and-swap on daily_usage_counters, so it
  needs no migration.
- Read the request body up to 8,000 bytes and no further, and refuse a
  larger declared Content-Length before reading anything.
- Time out the provider call at 25 s, below the browser's 30 s wait, so
  a slow clip ends with an error the browser acts on instead of being
  paid for and discarded.
- Sign at most 12 segments per reply as before, but return the rest of
  the spoken text, untagged and unsigned, as voice.overflow for the
  browser voice. Replies past 12 segments were cut off.
- Accept tokens up to 30 s ahead of the verifier's clock, so a small
  clock difference between the ai and voice functions does not reject
  a fresh token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- After any refusal from the voice function other than an invalid
  token, including voice_provider_error, use the browser voice for the
  rest of the round instead of asking again on every reply. A new
  Sparring or Crossfire round (opponentVoice.beginRound) gives
  ElevenLabs another chance and clears the allowance notice.
- Read voice.overflow with the browser voice after the signed segments.
- Split a sentence longer than one browser utterance at clause breaks,
  then spaces, so Chrome does not cut it off.
- Resume the Sparring clock only for the turn the reply paused. A reply
  still playing when the next one arrived ended under it and restarted
  the clock while the new reply played.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Giving back a clip when ElevenLabs refuses meant the daily cap stopped
bounding calls while the provider failed: a replayed token could call
the function, and ElevenLabs, without limit during an outage or with a
bad key. The client already switches to the browser voice for the rest
of the round after a refusal, so a refusal costs at most one clip per
round. Keep every attempt counted and test that it stays so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A reply that arrived after the student left the Sparring page still
started the opponent's voice, playing audio elsewhere in the app and
spending a clip. It is now saved but not spoken.

When the browser never reported the end of an utterance, the time limit
moved on but left the utterance queued, so it could play after the
student's clock restarted. The limit now cancels it.

Found by a Codex review (gpt-6.1-sol, high).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Programcode

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Programcode and others added 2 commits October 1, 2026 19:07
Brings in #79 to #83 and #85.

Conflict in .superset/db-gate.sh: both sides added a function to the
readiness loop. The loop now waits for both `voice` and `account`.

#82 replaced the "Which debate is this for?" dialog with the first-debate
offer when no debate is current. e2e/voice-rounds.spec.ts still clicked
the old button in two places, so it answers the new offer the way
e2e/sparring.spec.ts does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The full-stack sparring voice test turned the microphone on as soon as
the opponent's reply appeared. Turning it on speaks over the opponent,
which stops its voice and aborts the request to the voice function. The
test then asserted that the function had answered 503 voice_unavailable
exactly once, so it passed only when one of the two requests beat the
click.

The CI trace at cc4bad7 shows both requests ending in ERR_ABORTED on
mobile Chromium, 170 to 350 ms and at most 160 ms after they started.
Desktop passed in the same run because its second request got through.

This is not a regression after 3c12919. With the local stack up:

- The voice function answers in a median 71 to 77 ms once warm and 210
  to 240 ms after a reload, almost all of it the auth lookup that runs
  before the body is read. Swapping readBoundedBody for the old
  req.text() changes nothing measurable (30 requests each, twice).
- The unchanged test failed 2 of 8 on mobile at this head and 3 of 12
  on mobile with the 3c12919 voice client and function restored.

The test now waits, after each reply, for the voice function's answer
and for the browser voice to read that reply. The final assertions are
unchanged except one that is tighter: the browser voice must have read
both replies, not just contain one. That also makes the test exercise
the round-level fallback (no second request) on every run. 48 of 48
passed over 12 repeats of both voice tests on both Chromium projects,
and the test fails when the fallback is removed.

The interrupted request the test used to hit by chance is now a unit
test: an abandoned request is aborted, nothing is read aloud, and the
next reply still asks ElevenLabs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — cc03de25 Deployed Oct 1, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants