Muhammad-Mehdi's picture
library added
e1222dc
Raw History Blame Contribute Delete
17.2 kB
import re
import uuid
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from app.auth import Ctx, get_ctx
from app.config import settings
from app.models import Document, Job, Upload, VoiceConsent, VoiceProfile
from app.services import tts
from app.services.jobs import create_job, record_usage, serialize_job
from app.services.notestack_voices import forget_video_voice, save_notestack_voice
from app.services.plans import effective_plan, plan_limit_error
from app.services.storage import storage
router = APIRouter(prefix="/api/voice", tags=["voice"])
CONSENT_TEXT = (
"I confirm this recording is my own voice. I allow Notestack to create a synthetic copy of it with "
"ElevenLabs, only for audio and video made in my workspace. I can revoke this at any time, which deletes "
"the synthetic voice."
)
PREVIEW_LINE = "Here is how I sound reading your archive. Every line I say comes from something you wrote."
class BuildIn(BaseModel):
document_ids: list[uuid.UUID] = Field(default_factory=list, max_length=10)
class DeliveryIn(BaseModel):
stability: float = Field(0.45, ge=0, le=1)
similarity_boost: float = Field(0.8, ge=0, le=1)
style: float = Field(0.2, ge=0, le=1)
speed: float = Field(1.0, ge=0.7, le=1.2)
class ProfileIn(BaseModel):
profile: dict | None = None
host_voices: dict[str, str] | None = None
delivery: dict[str, DeliveryIn] | None = None # per host: {"host_a": {...}, "host_b": {...}}
class ConsentIn(BaseModel):
upload_ids: list[uuid.UUID] = Field(default_factory=list, max_length=10)
upload_id: uuid.UUID | None = None # single sample (older clients)
agreed: bool
consent_text: str
remove_background_noise: bool = True
class PreviewIn(BaseModel):
voice_id: str = Field(min_length=5, max_length=60)
text: str | None = Field(None, max_length=400)
host: str | None = None # use that host's saved delivery settings
delivery: DeliveryIn | None = None
class DesignIn(BaseModel):
"""Either picked options (Build from options) or a free description (Describe in your own words)."""
prompt: str | None = Field(None, max_length=1000)
gender: str = Field("", max_length=20)
age: str = Field("", max_length=20)
persona: str = Field("", max_length=30)
pace: str = Field("", max_length=20)
accent: str = Field("", max_length=40)
class DesignSaveIn(BaseModel):
generated_voice_id: str = Field(min_length=5, max_length=100)
name: str = Field(min_length=1, max_length=60)
description: str = Field("", max_length=1000)
use_as: str | None = None # "host_a" | "host_b"
class LibraryAddIn(BaseModel):
public_owner_id: str = Field(min_length=5, max_length=100)
voice_id: str = Field(min_length=5, max_length=60)
name: str = Field(min_length=1, max_length=100)
use_as: str | None = None # "host_a" | "host_b"
def _profile(ctx: Ctx) -> VoiceProfile | None:
return ctx.db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == ctx.workspace.id))
def _ensure_profile(ctx: Ctx) -> VoiceProfile:
vp = _profile(ctx)
if not vp:
vp = VoiceProfile(workspace_id=ctx.workspace.id)
ctx.db.add(vp)
return vp
def _consent(ctx: Ctx) -> VoiceConsent | None:
return ctx.db.scalar(
select(VoiceConsent).where(VoiceConsent.workspace_id == ctx.workspace.id, VoiceConsent.revoked_at.is_(None))
.order_by(VoiceConsent.created_at.desc())
)
def _clone_job(ctx: Ctx, consent: VoiceConsent) -> Job | None:
return ctx.db.scalar(
select(Job).where(Job.workspace_id == ctx.workspace.id, Job.kind == "voice_clone")
.order_by(Job.created_at.desc()).limit(1)
)
def _serialize(ctx: Ctx) -> dict:
vp = _profile(ctx)
consent = _consent(ctx)
plan = effective_plan(ctx.db, ctx.workspace)
voices = (vp.host_voices if vp else None) or {}
delivery = voices.get("settings") or {}
job = _clone_job(ctx, consent) if consent else None
preview_key = (job.result or {}).get("preview_key") if job and job.status == "done" else None
return {
"profile": vp.profile_json if vp else None,
"sample_doc_ids": vp.sample_doc_ids if vp else [],
"updated_at": vp.updated_at.isoformat() if vp and vp.updated_at else None,
"host_voices": {"host_a": voices.get("host_a") or settings.elevenlabs_voice_a,
"host_b": voices.get("host_b") or settings.elevenlabs_voice_b},
"delivery": {h: tts.VoiceSettings.from_dict(delivery.get(h)).body() for h in ("host_a", "host_b")},
"clone": {
"allowed": plan.voice_cloning,
"consent_text": CONSENT_TEXT,
"status": ("ready" if consent and consent.elevenlabs_voice_id
else "failed" if consent and job and job.status == "failed"
else "processing" if consent else "none"),
"voice_id": consent.elevenlabs_voice_id if consent else None,
"created_at": consent.created_at.isoformat() if consent else None,
"error": job.error if job and job.status == "failed" else None,
"preview_url": storage.presign_get(preview_key) if preview_key else None,
},
# Voices this workspace generated with Voice Design (the ElevenLabs account is shared across workspaces).
"custom_voices": voices.get("custom") or [],
"tts_configured": bool(settings.elevenlabs_api_key),
"model": tts.model_id(),
"models": tts.MODELS,
}
@router.get("")
def get_voice(ctx: Ctx = Depends(get_ctx)):
return _serialize(ctx)
@router.put("")
def update_voice(body: ProfileIn, ctx: Ctx = Depends(get_ctx)):
vp = _ensure_profile(ctx)
if body.profile is not None:
vp.profile_json = body.profile
voices = dict(vp.host_voices or {})
if body.host_voices is not None:
voices.update({k: v for k, v in body.host_voices.items() if k in ("host_a", "host_b") and v})
if body.delivery is not None:
saved = dict(voices.get("settings") or {})
saved.update({h: d.model_dump() for h, d in body.delivery.items() if h in ("host_a", "host_b")})
voices["settings"] = saved
vp.host_voices = voices
ctx.db.commit()
return _serialize(ctx)
@router.post("/build")
def build(body: BuildIn, ctx: Ctx = Depends(get_ctx)):
if not body.document_ids: # the automatic build after a sync may already be running; follow that one
pending = ctx.db.scalar(select(Job).where(Job.workspace_id == ctx.workspace.id, Job.kind == "voice_profile",
Job.status.in_(("queued", "running"))).limit(1))
if pending:
return serialize_job(pending)
job = create_job(ctx.db, ctx.workspace.id, "voice_profile",
{"document_ids": [str(i) for i in body.document_ids]}, max_attempts=2)
return serialize_job(job)
@router.get("/voices")
async def voices(ctx: Ctx = Depends(get_ctx)):
items = await run_in_threadpool(tts.list_voices)
consent = _consent(ctx)
if consent and consent.elevenlabs_voice_id:
items = [{**v, "name": f"{v['name']} (my voice)", "category": "my voice"}
if v["voice_id"] == consent.elevenlabs_voice_id else v for v in items]
if not any(v["voice_id"] == consent.elevenlabs_voice_id for v in items):
items = [{"voice_id": consent.elevenlabs_voice_id, "name": "My voice", "category": "my voice"}, *items]
return items
@router.get("/library")
async def library(ctx: Ctx = Depends(get_ctx), search: str = "", gender: str = "", age: str = "",
accent: str = "", language: str = "", use_case: str = "", page: int = 0):
if not settings.elevenlabs_api_key:
raise HTTPException(503, "Set ELEVENLABS_API_KEY in .env to browse the voice library.")
try:
return await run_in_threadpool(lambda: tts.search_library(
search=search, gender=gender, age=age, accent=accent, language=language, use_case=use_case,
page=max(page, 0)))
except tts.TTSError as exc:
raise HTTPException(502, str(exc)) from exc
@router.post("/library/add")
async def add_from_library(body: LibraryAddIn, ctx: Ctx = Depends(get_ctx)):
try:
voice_id = await run_in_threadpool(tts.add_library_voice, body.public_owner_id, body.voice_id, body.name)
except tts.TTSError as exc:
raise HTTPException(502, str(exc)) from exc
vp = _ensure_profile(ctx)
voices = dict(vp.host_voices or {})
# Listed, so it can be added to the workspace's video voices too.
added = [v for v in voices.get("added") or [] if v.get("voice_id") != voice_id]
voices["added"] = [*added, {"voice_id": voice_id, "name": body.name[:100]}]
if body.use_as in ("host_a", "host_b"):
voices[body.use_as] = voice_id
vp.host_voices = voices
save_notestack_voice(ctx.db, ctx.workspace.id, voice_id, body.name) # a voice added is a voice saved
ctx.db.commit()
return {"voice_id": voice_id, **_serialize(ctx)}
@router.post("/preview")
async def preview(body: PreviewIn, ctx: Ctx = Depends(get_ctx)):
"""Speak a short line in any voice with the given (or a host's saved) delivery. Cached, metered."""
if not settings.elevenlabs_api_key:
raise HTTPException(503, "Set ELEVENLABS_API_KEY in .env to preview voices.")
if body.delivery:
vs = tts.VoiceSettings.from_dict(body.delivery.model_dump())
else:
saved = (((_profile(ctx) or VoiceProfile()).host_voices or {}).get("settings") or {}).get(body.host or "")
vs = tts.VoiceSettings.from_dict(saved)
text = (body.text or PREVIEW_LINE).strip()[:400]
try:
clip = await run_in_threadpool(tts.synthesize, ctx.workspace.id, text, body.voice_id, vs)
except tts.TTSError as exc:
raise HTTPException(502, str(exc)) from exc
key = f"ws/{ctx.workspace.id}/voice-previews/{uuid.uuid4().hex}.mp3"
storage.put_bytes(key, clip, "audio/mpeg")
record_usage(ctx.db, workspace_id=ctx.workspace.id, kind="tts", provider="elevenlabs",
quantity=round(tts.mp3_seconds(clip), 1), unit="seconds")
return {"url": storage.presign_get(key), "seconds": round(tts.mp3_seconds(clip), 1)}
def _design_description(body: DesignIn) -> str:
if body.prompt and body.prompt.strip():
text = body.prompt.strip()
if len(text) < 20:
raise HTTPException(400, "Describe the voice in at least 20 characters.")
return text
parts = [f"A {body.age} {body.gender} voice".replace(" ", " ") if body.gender or body.age else "A voice"]
if body.accent:
parts.append(f"with a {body.accent} accent")
sentence = " ".join(parts) + "."
if body.persona:
sentence += f" Sounds {body.persona}."
if body.pace:
sentence += f" Speaks at a {body.pace} pace."
return sentence + " Clear studio quality, suited to podcasts and narration."
@router.post("/design")
async def design(body: DesignIn, ctx: Ctx = Depends(get_ctx)):
"""Three previews of a new voice. Nothing is saved until the writer picks one."""
if not settings.elevenlabs_api_key:
raise HTTPException(503, "Set ELEVENLABS_API_KEY in .env to generate voices.")
description = _design_description(body)
try:
previews = await run_in_threadpool(tts.design_voice, description)
except tts.TTSError as exc:
raise HTTPException(502, str(exc)) from exc
out = []
for p in previews:
key = f"ws/{ctx.workspace.id}/voice-previews/{uuid.uuid4().hex}.mp3"
storage.put_bytes(key, p["audio"], "audio/mpeg")
out.append({"generated_voice_id": p["generated_voice_id"], "url": storage.presign_get(key),
"seconds": p["seconds"]})
record_usage(ctx.db, workspace_id=ctx.workspace.id, kind="voice_design", provider="elevenlabs",
quantity=len(out), unit="previews")
return {"description": description, "previews": out}
@router.post("/design/save")
async def save_design(body: DesignSaveIn, ctx: Ctx = Depends(get_ctx)):
try:
voice_id = await run_in_threadpool(tts.create_designed_voice, body.name, body.description,
body.generated_voice_id)
except tts.TTSError as exc:
raise HTTPException(502, str(exc)) from exc
vp = _ensure_profile(ctx)
voices = dict(vp.host_voices or {})
voices["custom"] = [*(voices.get("custom") or []),
{"voice_id": voice_id, "name": body.name, "description": body.description[:300]}]
if body.use_as in ("host_a", "host_b"):
voices[body.use_as] = voice_id
vp.host_voices = voices
save_notestack_voice(ctx.db, ctx.workspace.id, voice_id, body.name) # a voice made is a voice saved
ctx.db.commit()
return {"voice_id": voice_id, **_serialize(ctx)}
@router.get("/reading-script")
def reading_script(ctx: Ctx = Depends(get_ctx)):
"""About two minutes of the writer's own prose to read aloud for the clone (their words, their rhythm)."""
doc = ctx.db.scalar(
select(Document).where(Document.workspace_id == ctx.workspace.id, Document.clean_text != "")
.order_by(func.length(Document.clean_text).desc()).limit(1)
)
if not doc:
text = ("Writing is how I think out loud. I start with a question I cannot answer yet, then I follow it "
"until the answer surprises me. Some weeks that takes a paragraph; some weeks it takes a month. "
"What matters is that every sentence earns its place, and that the reader leaves with something "
"they did not have when they arrived.")
return {"title": "Sample passage", "text": text, "words": len(text.split())}
paragraphs = [p for p in re.split(r"\n\s*\n", doc.clean_text) if len(p.split()) > 12 and not p.startswith("## ")]
picked, words = [], 0
for p in paragraphs:
picked.append(p.strip())
words += len(p.split())
if words >= 280: # roughly two minutes read aloud
break
if not picked: # short or list heavy posts: read the text as is
plain = re.sub(r"^## .*$", "", doc.clean_text, flags=re.M).split()
picked, words = [" ".join(plain[:280])], min(len(plain), 280)
return {"title": doc.title, "text": "\n\n".join(picked), "words": words}
@router.get("/quota")
async def quota(ctx: Ctx = Depends(get_ctx)):
if not settings.elevenlabs_api_key:
return {}
return await run_in_threadpool(tts.subscription)
@router.post("/consent")
def give_consent(body: ConsentIn, request: Request, ctx: Ctx = Depends(get_ctx)):
plan = effective_plan(ctx.db, ctx.workspace)
if not plan.voice_cloning:
raise plan_limit_error(plan, "voice_cloning", "Voice cloning is on the Writer and Studio plans.")
if not body.agreed or body.consent_text.strip() != CONSENT_TEXT:
raise HTTPException(400, "Please read and accept the consent statement.")
if not settings.elevenlabs_api_key:
raise HTTPException(503, "Voice cloning needs ELEVENLABS_API_KEY in .env")
ids = body.upload_ids or ([body.upload_id] if body.upload_id else [])
uploads = ctx.db.scalars(select(Upload).where(Upload.id.in_(ids), Upload.workspace_id == ctx.workspace.id)).all()
uploads = [u for u in uploads if u.status == "complete" and u.content_type.startswith("audio/")]
if not uploads:
raise HTTPException(400, "Record or upload at least one audio sample first.")
# A new clone replaces the previous one.
old = _consent(ctx)
if old:
old.revoked_at = datetime.now(UTC)
consent = VoiceConsent(workspace_id=ctx.workspace.id, user_id=ctx.user.id, sample_key=uploads[0].key,
consent_text=CONSENT_TEXT, ip_address=request.client.host if request.client else None)
ctx.db.add(consent)
ctx.db.commit()
job = create_job(ctx.db, ctx.workspace.id, "voice_clone", {
"consent_id": str(consent.id), "sample_keys": [u.key for u in uploads],
"remove_background_noise": body.remove_background_noise,
"replaces_voice_id": old.elevenlabs_voice_id if old else None,
}, max_attempts=1)
return {"job": serialize_job(job), **_serialize(ctx)}
@router.delete("/consent")
async def revoke_consent(ctx: Ctx = Depends(get_ctx)):
consent = _consent(ctx)
if not consent:
return _serialize(ctx)
if consent.elevenlabs_voice_id and settings.elevenlabs_api_key:
try:
await run_in_threadpool(tts.delete_voice, consent.elevenlabs_voice_id)
except tts.TTSError:
pass
consent.revoked_at = datetime.now(UTC)
storage.delete(consent.sample_key)
vp = _profile(ctx)
if vp and vp.host_voices:
vp.host_voices = {k: v for k, v in vp.host_voices.items() if v != consent.elevenlabs_voice_id}
if consent.elevenlabs_voice_id:
forget_video_voice(ctx.db, ctx.workspace.id, consent.elevenlabs_voice_id)
ctx.db.commit()
return _serialize(ctx)