Skip to content

feat(tts): Phase 10C — TTS generation via OpenRouter /audio/speech#22

Merged
Krasimir-Hristov merged 4 commits into
mainfrom
feature/phase-10c-tts-generation
May 12, 2026
Merged

feat(tts): Phase 10C — TTS generation via OpenRouter /audio/speech#22
Krasimir-Hristov merged 4 commits into
mainfrom
feature/phase-10c-tts-generation

Conversation

@Krasimir-Hristov

@Krasimir-Hristov Krasimir-Hristov commented May 12, 2026

Copy link
Copy Markdown
Owner
  • Add generate_tts LangGraph tool + node (features/audio/)

    • service.py: POST to openrouter /audio/speech with gpt-4o-mini-tts, upload mp3 to Supabase Storage, return 7-day signed URL
    • tool.py: extract text arg from supervisor tool_call, call service, return ToolMessage with markdown audio link
    • schemas.py: AudioGenerateResult Pydantic model
  • Wire generate_tts into supervisor + orchestrator

    • supervisor.py: bind generate_tts tool, add TTS section to system prompt, detect text-format tool calls (DeepSeek compat) and convert to structured
    • orchestrator.py: add generate_tts node, conditional edge from supervisor
  • Config: tts_model=openai/gpt-4o-mini-tts-2025-12-15, tts_voice, audio_bucket

  • State: add pending_audio field for cross-turn save confirmation (Phase 10D)

  • Emit Generating audio... SSE tool_use event from chat service

  • Frontend: inline player for .mp3 links in MessageBubble

  • Frontend: purple Volume2 icon in ChatWindow during TTS tool_use status

  • Supabase migration: audio bucket with RLS policies scoped to {user_id}/*

Summary by CodeRabbit

  • New Features

    • Text-to-speech: generate audio from conversation text and play it inline.
    • In-chat audio playback: MP3 links render as embedded audio players.
    • Audio status indicator: pulsing icon and "Generating audio…" status during creation.
  • Improvements

    • Secure per-user storage for generated files with scoped access controls.
    • Input validation and privacy-aware handling to trim/clean overly long or empty requests.

Review Change Stack

- Add generate_tts LangGraph tool + node (features/audio/)
  - service.py: POST to openrouter /audio/speech with gpt-4o-mini-tts,
    upload mp3 to Supabase Storage, return 7-day signed URL
  - tool.py: extract text arg from supervisor tool_call, call service,
    return ToolMessage with markdown audio link
  - schemas.py: AudioGenerateResult Pydantic model

- Wire generate_tts into supervisor + orchestrator
  - supervisor.py: bind generate_tts tool, add TTS section to system prompt,
    detect text-format tool calls (DeepSeek compat) and convert to structured
  - orchestrator.py: add generate_tts node, conditional edge from supervisor

- Config: tts_model=openai/gpt-4o-mini-tts-2025-12-15, tts_voice, audio_bucket
- State: add pending_audio field for cross-turn save confirmation (Phase 10D)

- Emit Generating audio... SSE tool_use event from chat service
- Frontend: inline <audio> player for .mp3 links in MessageBubble
- Frontend: purple Volume2 icon in ChatWindow during TTS tool_use status

- Supabase migration: audio bucket with RLS policies scoped to {user_id}/*
@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

Walkthrough

Supervisor can request TTS via a new generate_tts tool; orchestrator routes to a generate_tts node that calls OpenRouter, uploads MP3 to Supabase, stores pending_audio in state, and frontend renders audio players and a TTS status icon.

Changes

Text-to-Speech Feature

Layer / File(s) Summary
Configuration, state, and data models
backend/app/core/config.py, backend/app/agents/state.py, backend/app/features/audio/schemas.py
TTS settings (tts_model, tts_voice, audio_bucket) added to Settings; AxonState gains pending_audio field documented in the docstring; AudioGenerateResult schema defines signed_url, filename, and text_preview.
Audio service implementation
backend/app/features/audio/service.py
generate_tts enforces 4096-char limit, masks PII, calls OpenRouter /audio/speech for MP3 and returns bytes + user-scoped filename; upload_audio uploads MP3 to Supabase and creates a 7-day signed URL with error handling and logging.
Supervisor TTS tool registration
backend/app/agents/supervisor.py
Adds generate_tts tool and TTS instructions to the system prompt, binds the tool for first-pass model calls, and post-processes text-form generate_tts(text="...") strings into structured AIMessage tool_calls.
Orchestrator graph routing
backend/app/agents/orchestrator.py
Imports GENERATE_TTS_TOOL_NAME and generate_tts_node; _should_continue routes to "generate_tts" on matching tool calls; node registered in _build_graph; conditional edge maps generate_tts back to supervisor.
LangGraph TTS node implementation
backend/app/features/audio/tool.py
generate_tts_node resolves the supervisor tool call, validates text, calls audio_service.generate_tts and upload_audio, builds pending_audio payload (filename, signed_url, 100-char preview), and returns a ToolMessage closing the tool call plus pending_audio.
Chat streaming TTS integration
backend/app/features/chat/service.py
Stream handler treats the generate_tts node start as a tool use: clears pass-1 supervisor narration buffer, sets the agent-invoked flag, and emits a tool_use SSE frame with "Generating audio…".
Frontend audio player and status icon
frontend/features/chat/components/ChatWindow.tsx, frontend/features/chat/components/MessageBubble.tsx
Chat window shows pulsing Volume2 icon when toolStatus starts with "Generating audio"; message bubble renders .mp3 links as HTML <audio controls> players with aria-label.
Supabase audio bucket and RLS policies
supabase/migrations/20260512100000_create_audio_bucket_and_policies.sql
Creates private audio bucket (10MB limit; allowed MIME audio/mpeg,audio/mp3) and INSERT/SELECT/DELETE RLS policies restricting access to objects in audio whose folder prefix matches auth.uid().

Sequence Diagram

sequenceDiagram
  participant User
  participant Frontend
  participant Supervisor
  participant Orchestrator
  participant generate_tts_node
  participant AudioService
  participant SupabaseStorage

  User->>Frontend: send message / request TTS
  Frontend->>Supervisor: user input
  Supervisor->>Orchestrator: supervisor emits tool_call generate_tts(text)
  Orchestrator->>generate_tts_node: route to "generate_tts"
  generate_tts_node->>AudioService: generate_tts(text, user_id) -> mp3 bytes + filename
  AudioService->>SupabaseStorage: upload mp3 -> signed URL
  SupabaseStorage-->>AudioService: signed URL
  AudioService-->>generate_tts_node: (signed_url, filename)
  generate_tts_node-->>Orchestrator: ToolMessage + pending_audio payload
  Orchestrator-->>Supervisor: return flow to supervisor for final response
  Supervisor-->>Frontend: final message (includes pending_audio metadata)
  Frontend-->>User: render audio player with signed URL
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding TTS generation via OpenRouter's /audio/speech endpoint as part of Phase 10C, which aligns with the comprehensive backend/frontend implementation across multiple files.
Docstring Coverage ✅ Passed Docstring coverage is 81.82% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/phase-10c-tts-generation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/app/features/audio/schemas.py`:
- Around line 9-11: The text_preview field in the schema lacks length
constraints; update the schema in schemas.py to import pydantic.Field and change
text_preview: str to text_preview: str = Field(..., min_length=1,
max_length=1024) (choose an appropriate max if 1024 is too large) so
user-derived content is bounded; ensure the Field import is added and leave
signed_url / filename unchanged unless they are also user-input — in that case
apply similar Field(..., min_length=1, max_length=N) constraints to those
symbols.

In `@backend/app/features/audio/service.py`:
- Around line 68-73: Wrap the HTTP call using httpx.AsyncClient (the block that
posts to f"{settings.openrouter_base_url}/audio/speech" with json=payload and
headers=headers) in a try/except that catches httpx.RequestError and
httpx.HTTPStatusError (or Exception), obtain a module logger via
logging.getLogger(__name__), log the exception with context (include URL,
payload summary or headers as appropriate), and then convert or re-raise into
the service-level error contract (e.g., raise a custom exception or return an
error result) so callers receive a consistent error; update the async function
containing this request to use that try/except around the client.post call and
ensure response handling occurs only on success.
- Around line 57-62: The payload currently sends raw user `text`; call the
privacy masker and send the masked value instead: import and use mask_pii(text)
(from core/privacy.py) before building the `payload`, assign the result to a new
variable (e.g., `masked_text`) and replace `input: text` with `input:
masked_text`; ensure any downstream uses in the same function (e.g., in the TTS
call that builds `payload`) reference the masked variable so no raw user input
is forwarded to OpenRouter.

In `@backend/app/features/audio/tool.py`:
- Around line 91-95: The success log in generate_tts_node currently logs a raw
user identifier via logger.info("[generate_tts_node] audio ready filename=%s
user=%s", filename, user_id); remove or redact the sensitive user_id from the
log (e.g., log only filename and a non-identifying status or a hashed/obfuscated
id) so that generate_tts_node no longer emits raw PII; update the logger.info
call to exclude user_id (or replace with a safe token) and ensure any callers or
tests expecting the old message are adjusted accordingly.

In `@supabase/migrations/20260512100000_create_audio_bucket_and_policies.sql`:
- Line 20: Update the RLS policy predicates to use a subquery form of auth.uid()
instead of the bare function: replace occurrences of (auth.uid()) with (select
auth.uid()) in the policy expressions (e.g., the clause comparing
(storage.foldername(name))[1] to auth.uid()). Specifically modify the
predicate(s) involving storage.foldername(name) and auth.uid() so they read like
(storage.foldername(name))[1] = (select auth.uid())::text; apply the same
replacement at the other affected predicates referenced in the comment.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: bfbcd21f-1fe5-41ad-b29f-a8ee3147a095

📥 Commits

Reviewing files that changed from the base of the PR and between f6ee436 and bd075d5.

📒 Files selected for processing (12)
  • backend/app/agents/orchestrator.py
  • backend/app/agents/state.py
  • backend/app/agents/supervisor.py
  • backend/app/core/config.py
  • backend/app/features/audio/__init__.py
  • backend/app/features/audio/schemas.py
  • backend/app/features/audio/service.py
  • backend/app/features/audio/tool.py
  • backend/app/features/chat/service.py
  • frontend/features/chat/components/ChatWindow.tsx
  • frontend/features/chat/components/MessageBubble.tsx
  • supabase/migrations/20260512100000_create_audio_bucket_and_policies.sql

Comment thread backend/app/features/audio/schemas.py Outdated
Comment thread backend/app/features/audio/service.py
Comment thread backend/app/features/audio/service.py Outdated
Comment thread backend/app/features/audio/tool.py Outdated
Comment thread supabase/migrations/20260512100000_create_audio_bucket_and_policies.sql Outdated
- schemas.py: add Field(min_length=1, max_length=1024) to text_preview
- service.py: mask_pii(text) before sending to OpenRouter TTS endpoint
- service.py: wrap httpx call in try/except HTTPStatusError+RequestError
  with structured logging; use response.raise_for_status() on success path
- tool.py: remove raw user_id from logger.info (PII leak)
- migration: replace auth.uid() with (select auth.uid()) in all three
  RLS policy predicates to avoid per-row re-evaluation (Supabase best practice)

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/app/features/audio/service.py`:
- Around line 92-95: The log statements currently emit the constructed filename
which includes raw user_id (see the filename variable and logger.info calls in
generate_tts and related places), leaking PII; change these logger.info calls to
omit or redact user_id — e.g., log only the file size and the generated uuid (or
a redacted filename like "<redacted_user>/{uuid}.mp3" or just the uuid string
from uuid4()), and ensure any other logger.info calls that reference filename or
user_id (the occurrences around the other noted lines) are updated similarly to
avoid logging raw user identifiers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9b22c5e9-f675-4f0e-bb97-97a74a4ba310

📥 Commits

Reviewing files that changed from the base of the PR and between bd075d5 and f12a6bf.

📒 Files selected for processing (4)
  • backend/app/features/audio/schemas.py
  • backend/app/features/audio/service.py
  • backend/app/features/audio/tool.py
  • supabase/migrations/20260512100000_create_audio_bucket_and_policies.sql

Comment thread backend/app/features/audio/service.py Outdated

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

♻️ Duplicate comments (1)
backend/app/features/audio/service.py (1)

123-137: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

PII leakage in storage logs (filename contains user_id)

Line 123, Line 133, and Line 137 still log filename, which includes raw user_id ({user_id}/{uuid}.mp3).

Suggested fix
 async def upload_audio(audio_bytes: bytes, filename: str) -> str:
@@
-    try:
+    object_name = filename.rsplit("/", 1)[-1]
+    try:
         await client.storage.from_(bucket).upload(
             path=filename,
             file=audio_bytes,
             file_options={"content-type": "audio/mpeg"},
         )
     except Exception as exc:
-        logger.exception("[upload_audio] Storage upload failed filename=%s", filename)
+        logger.exception("[upload_audio] Storage upload failed object=%s", object_name)
         raise RuntimeError("Failed to upload audio to storage.") from exc
@@
     except Exception as exc:
         logger.exception(
-            "[upload_audio] Failed to create signed URL filename=%s", filename
+            "[upload_audio] Failed to create signed URL object=%s", object_name
         )
         raise RuntimeError("Audio uploaded but could not generate a signed URL.") from exc
 
-    logger.info("[upload_audio] signed URL created filename=%s", filename)
+    logger.info("[upload_audio] signed URL created object=%s", object_name)
     return signed_url

As per coding guidelines, backend/**/*.py: Never log sensitive data (tokens, passwords, PII).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/features/audio/service.py` around lines 123 - 137, The logs in
upload_audio (the logger.exception calls around the storage upload and
create_signed_url and the logger.info after signed URL creation) currently
include filename which embeds user_id (PII); remove or replace raw filename in
those log calls with a non-PII value — e.g., compute a safe identifier (use only
the basename/UUID part or replace the leading user_id segment with "<redacted>")
and log that instead, or omit the filename entirely in the logger.exception and
logger.info calls for create_signed_url and storage upload so no user_id is
emitted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/app/features/audio/service.py`:
- Around line 90-97: The code assigns response.content to audio_bytes and
returns it without validation; update the block around response, audio_bytes and
filename (where uuid4() and logger.info are used) to validate the TTS payload:
ensure response.status_code is 200, response.content is non-empty, and
Content-Type indicates audio (e.g., startswith "audio/") and/or verify MP3 magic
bytes (ID3 or 0xFF 0xFB) before constructing filename and returning; on
validation failure log a descriptive error with response.status_code and headers
(using logger.error) and raise or return an explicit error/None instead of
returning/uploading a broken file.

---

Duplicate comments:
In `@backend/app/features/audio/service.py`:
- Around line 123-137: The logs in upload_audio (the logger.exception calls
around the storage upload and create_signed_url and the logger.info after signed
URL creation) currently include filename which embeds user_id (PII); remove or
replace raw filename in those log calls with a non-PII value — e.g., compute a
safe identifier (use only the basename/UUID part or replace the leading user_id
segment with "<redacted>") and log that instead, or omit the filename entirely
in the logger.exception and logger.info calls for create_signed_url and storage
upload so no user_id is emitted.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7196585c-9bb5-4525-adc4-7265a3e19b3e

📥 Commits

Reviewing files that changed from the base of the PR and between f12a6bf and 01fb080.

📒 Files selected for processing (1)
  • backend/app/features/audio/service.py

Comment thread backend/app/features/audio/service.py
…udio logs

- Validate response content: check non-empty + content-type starts with
  'audio/' or MP3 magic bytes (ID3 / 0xFF sync word); raise RuntimeError
  with status/content-type/size logged on failure
- Replace raw filename (embeds user_id) in all upload_audio logger calls
  with safe_log_name (UUID basename only)
@Krasimir-Hristov
Krasimir-Hristov merged commit aa4926e into main May 12, 2026
1 of 2 checks passed
@Krasimir-Hristov
Krasimir-Hristov deleted the feature/phase-10c-tts-generation branch May 12, 2026 22:50
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.

1 participant