Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions backend/app/features/audio/rename.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""rename_audio_entry — extracted from service.py to stay under the 300-line limit."""

import logging
from uuid import UUID

from app.core.config import settings
from app.db.supabase import get_supabase_client
from app.features.audio.schemas import AudioEntryOut
from app.features.audio.service import (
_SELECT_COLS,
_SIGNED_URL_TTL,
_row_to_entry,
)

logger = logging.getLogger(__name__)


async def rename_audio_entry(
entry_id: UUID, user_id: str, title: str
) -> AudioEntryOut | None:
"""Rename an audio entry's title.

Returns the updated AudioEntryOut, or None if the entry was not found.
Raises RuntimeError on DB or Storage failure.
"""
client = await get_supabase_client()

# UPDATE — postgrest-py's FilterRequestBuilder has no .select() after .eq(),
# so we do the update and re-fetch in a separate query.
try:
await (
client.table("audio_entries")
.update({"title": title})
.eq("id", str(entry_id))
.eq("user_id", user_id)
.execute()
)
except Exception:
logger.exception("[rename_audio_entry] UPDATE failed id=%s", entry_id)
raise RuntimeError("Failed to rename audio entry.") from None

# Re-fetch the updated row (also confirms the entry belongs to this user).
try:
fetch = (
await client.table("audio_entries")
.select(_SELECT_COLS)
.eq("id", str(entry_id))
.eq("user_id", user_id)
.execute()
)
except Exception:
logger.exception(
"[rename_audio_entry] SELECT after UPDATE failed id=%s", entry_id
)
raise RuntimeError("Failed to fetch renamed audio entry.") from None

if not fetch.data:
return None

row = fetch.data[0]
filename: str = row["filename"]
safe_log = filename.split("/")[-1] if "/" in filename else filename

try:
result = await client.storage.from_(settings.audio_bucket).create_signed_url(
filename, _SIGNED_URL_TTL
)
signed_url: str = result["signedURL"]
except Exception:
logger.exception("[rename_audio_entry] signed URL failed uuid=%s", safe_log)
raise RuntimeError("Title updated but failed to generate signed URL.") from None

logger.info("[rename_audio_entry] renamed uuid=%s", safe_log)
return _row_to_entry(row, signed_url)
42 changes: 40 additions & 2 deletions backend/app/features/audio/router.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,40 @@
"""Audio Library REST endpoints — list and delete saved audio entries."""
"""Audio Library REST endpoints — list, delete, and temp-serve audio."""

import logging
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import Response

from app.core.limiter import limiter
from app.core.security import get_current_user
from app.features.auth.schemas import UserSchema
from app.features.audio import service
from app.features.audio.schemas import AudioEntryOut
from app.features.audio.rename import rename_audio_entry as _rename_audio_entry
from app.features.audio.schemas import AudioEntryOut, AudioEntryPatch

logger = logging.getLogger(__name__)
router = APIRouter(prefix="/audio", tags=["audio"])


@router.get("/temp/{token}")
@limiter.limit("30/minute")
async def serve_temp_audio(token: UUID, request: Request) -> Response:
"""Serve a temporarily stored mp3 before the user confirms saving.

No JWT auth — the UUID token itself acts as a short-lived capability URL
(unguessable, 1-hour TTL). The browser <audio> element cannot send custom
headers, so cookie / bearer auth is not applicable here.
"""
audio_bytes = await service.retrieve_temp_audio(str(token))
if audio_bytes is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Audio not found or expired.",
)
return Response(content=audio_bytes, media_type="audio/mpeg")
Comment thread
coderabbitai[bot] marked this conversation as resolved.


@router.get("", response_model=list[AudioEntryOut])
@limiter.limit("60/minute")
async def list_audio_entries_endpoint(
Expand All @@ -39,3 +59,21 @@ async def delete_audio_entry_endpoint(
status_code=status.HTTP_404_NOT_FOUND,
detail="Audio entry not found",
)


@router.patch("/{entry_id}", response_model=AudioEntryOut)
@limiter.limit("60/minute")
async def rename_audio_entry_endpoint(
entry_id: UUID,
body: AudioEntryPatch,
request: Request,
current_user: UserSchema = Depends(get_current_user),
) -> AudioEntryOut:
"""Rename a saved audio entry's title."""
updated = await _rename_audio_entry(entry_id, current_user.id, body.title)
if updated is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Audio entry not found",
)
return updated
6 changes: 6 additions & 0 deletions backend/app/features/audio/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ class AudioGenerateResult(BaseModel):
text_preview: str = Field(..., min_length=1, max_length=1024)


class AudioEntryPatch(BaseModel):
"""Request body for PATCH /audio/{id} — rename the entry title."""

title: str = Field(..., min_length=1, max_length=200)


class AudioEntryOut(BaseModel):
"""A saved audio entry from the audio_entries table."""

Expand Down
61 changes: 56 additions & 5 deletions backend/app/features/audio/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@
generate_tts(text, user_id) -> tuple[bytes, str]
Call OpenRouter TTS endpoint, return (mp3_bytes, storage_filename).

store_temp_audio(audio_bytes) -> str
Write mp3 bytes to a system temp file and return a token (UUID).
TTL: 3600 s. Survives uvicorn --reload (file system is persistent).

retrieve_temp_audio(token) -> bytes | None
Return bytes for the given token, or None if missing / expired.

delete_temp_audio(token) -> None
Remove an entry from the temp store (called after successful upload).

upload_audio(audio_bytes, filename) -> str
Upload mp3 bytes to Supabase Storage and return a signed URL (1 week).

Expand All @@ -23,13 +33,15 @@

import asyncio
import logging
import tempfile
import time
from pathlib import Path
from uuid import UUID, uuid4
from typing import Any

import httpx

from app.core.config import settings
from app.core.privacy import mask_pii
from app.db.supabase import get_supabase_client
from app.features.audio.schemas import AudioEntryOut

Expand All @@ -41,6 +53,45 @@
# Signed URL expiry: 7 days in seconds.
_SIGNED_URL_TTL = 7 * 24 * 60 * 60 # 604 800 s

# ---------------------------------------------------------------------------
# Temp audio store — bytes written to the system temp directory.
# Survives uvicorn --reload (file system is persistent across restarts).
# TTL enforced at retrieval time via file mtime.
# ---------------------------------------------------------------------------

_TEMP_DIR = Path(tempfile.gettempdir())
_TEMP_PREFIX = "axon_audio_"
_TEMP_TTL = 3600 # 1 hour


async def store_temp_audio(audio_bytes: bytes) -> str:
"""Write mp3 bytes to a temp file and return a UUID token."""
token = str(uuid4())
path = _TEMP_DIR / f"{_TEMP_PREFIX}{token}.mp3"
await asyncio.to_thread(path.write_bytes, audio_bytes)
return token


async def retrieve_temp_audio(token: str) -> bytes | None:
"""Return mp3 bytes for the given token, or None if missing / expired."""
path = _TEMP_DIR / f"{_TEMP_PREFIX}{token}.mp3"

def _read() -> bytes | None:
if not path.exists():
return None
if time.time() - path.stat().st_mtime > _TEMP_TTL:
path.unlink(missing_ok=True)
return None
return path.read_bytes()

return await asyncio.to_thread(_read)


async def delete_temp_audio(token: str) -> None:
"""Delete the temp file after a successful Storage upload."""
path = _TEMP_DIR / f"{_TEMP_PREFIX}{token}.mp3"
await asyncio.to_thread(lambda: path.unlink(missing_ok=True))


async def generate_tts(text: str, user_id: str) -> tuple[bytes, str]:
"""Call OpenRouter TTS endpoint and return (mp3_bytes, storage_filename).
Expand All @@ -66,13 +117,10 @@ async def generate_tts(text: str, user_id: str) -> tuple[bytes, str]:
if not text.strip():
raise RuntimeError("Cannot generate TTS for empty text.")

# Mask PII before forwarding text to the external TTS provider.
masked_text = await mask_pii(text)

# OpenRouter /audio/speech endpoint — returns raw MP3 bytes (not JSON).
payload = {
"model": settings.tts_model,
"input": masked_text,
"input": text,
"voice": settings.tts_voice,
"response_format": "mp3",
Comment on lines 121 to 125

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reintroduce PII masking before sending text to OpenRouter.

Line 119 sends raw user text to an external provider. That creates a privacy/compliance gap and regresses existing protections.

Proposed fix
+from app.core.privacy import mask_pii
...
-    payload = {
+    masked_text = mask_pii(text)
+    if not masked_text.strip():
+        raise RuntimeError("Cannot generate TTS for empty text after masking.")
+
+    payload = {
         "model": settings.tts_model,
-        "input": text,
+        "input": masked_text,
         "voice": settings.tts_voice,
         "response_format": "mp3",
     }

As per coding guidelines: "NEVER send raw user input to OpenAI embeddings or OpenRouter without first calling mask_pii() from core/privacy.py."

🤖 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 117 - 121, The code is
sending raw user text to OpenRouter; call the mask_pii() function from
core/privacy.py on the user text before building the payload. Import mask_pii,
assign masked_text = mask_pii(text) and use masked_text (not text) for the
payload "input" value when constructing the dict in the function that builds the
payload (where settings.tts_model / settings.tts_voice are used). Ensure you
don't overwrite the original text variable if it's needed elsewhere.

}
Expand Down Expand Up @@ -345,3 +393,6 @@ async def delete_audio_entry(entry_id: UUID, user_id: str) -> bool:

logger.info("[delete_audio_entry] deleted uuid=%s", safe_log_name)
return True



63 changes: 44 additions & 19 deletions backend/app/features/audio/tool.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"""Generate-TTS and Save-Audio LangGraph nodes (Phase 10C + 10D).

generate_tts_node — synthesizes text to speech via OpenRouter TTS, uploads the
mp3 to Supabase Storage, stores the result in
``state["pending_audio"]`` for the cross-turn save
confirmation, and returns a ToolMessage with a markdown link.
generate_tts_node — synthesizes text to speech via OpenRouter TTS, writes the
mp3 bytes to a system temp file (NOT in Supabase Storage),
stores a temp token in ``state["pending_audio"]``
for the cross-turn save confirmation, and returns a
ToolMessage with a playable /audio/temp/{token} URL.

save_audio_entry_node — reads ``state["pending_audio"]``, persists the audio
entry to ``audio_entries``, and returns a ToolMessage
confirming the save.
save_audio_entry_node — reads ``state["pending_audio"]``, retrieves the bytes
from the temp store, uploads to Supabase Storage, saves
the DB entry, and returns a ToolMessage confirming the save.

Neither node is called via LangChain tool execution — both are invoked directly
by the orchestrator graph (same pattern as save_transcript_node).
Expand All @@ -27,13 +28,15 @@


async def generate_tts_node(state: AxonState) -> dict:
"""LangGraph node: synthesize text to speech and upload to Storage.
"""LangGraph node: synthesize text to speech and store bytes in temp store.

Reads the ``text`` argument from the supervisor's ``generate_tts`` tool_call,
calls the audio service, and returns:
- A ToolMessage closing the open tool_call (required for valid message history).
- ``pending_audio`` state update with JSON ``{filename, signed_url, text_preview}``
- A ToolMessage with a playable /api/v1/audio/temp/{token} URL.
- ``pending_audio`` state update with ``{filename, temp_token, text_preview}``
for the Phase 10D save confirmation flow.

The mp3 is NOT uploaded to Supabase Storage until the user confirms saving.
"""
# -- Resolve tool_call_id and text arg -----------------------------------
tool_call_id: str | None = None
Expand Down Expand Up @@ -63,10 +66,10 @@ def _error(msg: str) -> dict:

user_id: str = state["user_id"]

# -- Generate TTS + upload ------------------------------------------------
# -- Generate TTS + store in temp (no Storage upload yet) ---------------
try:
audio_bytes, filename = await audio_service.generate_tts(text, user_id)
signed_url = await audio_service.upload_audio(audio_bytes, filename)
temp_token = await audio_service.store_temp_audio(audio_bytes)
except RuntimeError as exc:
logger.error("[generate_tts_node] service error: %s", exc)
return _error(f"Audio generation failed: {exc}")
Expand All @@ -79,17 +82,22 @@ def _error(msg: str) -> dict:
# -- Build pending_audio payload for Phase 10D save ----------------------
pending: dict[str, str] = {
"filename": filename,
"signed_url": signed_url,
"temp_token": temp_token,
"text_preview": text[:100],
}

temp_url = f"/api/v1/audio/temp/{temp_token}"
result_msg = (
f"Audio generated successfully.\n\n"
f"[Play audio]({signed_url})\n\n"
f"[Play audio]({temp_url})\n\n"
f"Would you like me to save this to your audio library?"
)

logger.info("[generate_tts_node] audio ready filename=%s", filename)
logger.info(
"[generate_tts_node] audio ready token=%s filename=%s",
temp_token[:8],
filename,
)

messages = []
if tool_call_id:
Expand Down Expand Up @@ -140,30 +148,47 @@ def _error(msg: str) -> dict:
)

filename: str = pending_audio.get("filename", "")
if not filename:
return _error("Audio data is missing the filename — please try again.")
temp_token: str = pending_audio.get("temp_token", "")
if not filename or not temp_token:
return _error("Audio data is incomplete — please regenerate and try again.")

# Fall back to text_preview if no title was provided.
if not title:
title = pending_audio.get("text_preview", "Audio")[:80]

user_id: str = state["user_id"]

# -- Persist ------------------------------------------------------------
# -- Retrieve bytes from temp store -------------------------------------
audio_bytes = await audio_service.retrieve_temp_audio(temp_token)
if audio_bytes is None:
logger.warning(
"[save_audio_entry_node] temp audio expired token=%s",
temp_token[:8],
)
return _error(
"The audio preview has expired (> 1 hour). "
"Please regenerate the audio and try saving again."
)

# -- Upload to Storage + persist DB entry (both or neither) ---------------
try:
await audio_service.upload_audio(audio_bytes, filename)
saved = await audio_service.save_audio_entry(
user_id=user_id,
filename=filename,
title=title,
source_type="custom",
)
except RuntimeError as exc:
logger.error("[save_audio_entry_node] service error: %s", exc)
logger.error("[save_audio_entry_node] upload/save error: %s", exc)
return _error(f"Failed to save audio: {exc}")
except Exception:
logger.exception("[save_audio_entry_node] unexpected error")
return _error("An unexpected error occurred while saving the audio.")

# -- Clean up temp store -------------------------------------------------
await audio_service.delete_temp_audio(temp_token)

result_msg = f"Audio saved to your library ✓ — **{saved.title}**"
logger.info("[save_audio_entry_node] saved id=%s", saved.id)

Expand Down
Loading