Spaces:
Runtime error
Runtime error
Commit ·
94f4eb8
1
Parent(s): 0bf4415
LETS GOOOOOOOOOOOOOOOOOOOOOOOOO
Browse files- backend/app/pipeline/ingest.py +63 -10
- backend/app/routers/artifacts.py +6 -0
- backend/app/routers/notebooks.py +3 -1
- backend/app/routers/sources.py +21 -2
- backend/app/routers/voice.py +74 -0
- backend/app/worker.py +3 -2
- frontend/src/api/endpoints.ts +5 -0
- frontend/src/api/types.ts +5 -0
- frontend/src/pages/VoiceProfile.tsx +341 -59
- frontend/src/styles/pages.css +179 -2
- frontend/src/styles/tools.css +24 -2
backend/app/pipeline/ingest.py
CHANGED
|
@@ -556,7 +556,7 @@ def store_entries(
|
|
| 556 |
)
|
| 557 |
doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections)
|
| 558 |
doc.metadata_json = {
|
| 559 |
-
**(doc.metadata_json or {}),
|
| 560 |
"headings": [h for h, _ in sections if h],
|
| 561 |
"words": len(doc.clean_text.split()),
|
| 562 |
}
|
|
@@ -575,16 +575,60 @@ def store_entries(
|
|
| 575 |
return indexed, skipped, changed
|
| 576 |
|
| 577 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
|
| 579 |
source.sync_status = "syncing"
|
| 580 |
update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
|
| 581 |
-
|
| 582 |
-
fetch_limit = max_posts + 1
|
| 583 |
|
| 584 |
if source.feed_url.startswith(SITE_PREFIX):
|
| 585 |
update_job(db, job, message="Mapping your site")
|
| 586 |
entries = crawl_site(
|
| 587 |
-
source.feed_url.removeprefix(SITE_PREFIX),
|
| 588 |
on_found=lambda n: update_job(db, job, progress=0.05, message=f"Found {n} pages, reading them"),
|
| 589 |
on_post=lambda done, total: update_job(db, job, progress=0.05 + 0.05 * done / total,
|
| 590 |
message=f"Read {done} of {total} pages"),
|
|
@@ -604,20 +648,29 @@ def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict
|
|
| 604 |
except (httpx.HTTPError, ValueError):
|
| 605 |
log.warning("substack archive fetch failed for %s", site, exc_info=True)
|
| 606 |
|
| 607 |
-
|
| 608 |
-
|
| 609 |
|
| 610 |
def progress(done: int, total: int, post_title: str) -> None:
|
| 611 |
-
update_job(db, job, progress=0.1 + 0.
|
|
|
|
|
|
|
|
|
|
| 612 |
|
| 613 |
-
|
| 614 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
|
| 616 |
source.sync_status = "ok"
|
| 617 |
source.sync_error = None
|
| 618 |
source.last_synced_at = datetime.now(UTC)
|
| 619 |
db.commit()
|
| 620 |
-
return {"indexed": indexed, "skipped": skipped, "found": len(entries), "limit": max_posts,
|
|
|
|
| 621 |
"changed": [str(i) for i in changed]}
|
| 622 |
|
| 623 |
|
|
|
|
| 556 |
)
|
| 557 |
doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections)
|
| 558 |
doc.metadata_json = {
|
| 559 |
+
**{k: v for k, v in (doc.metadata_json or {}).items() if k != "locked"},
|
| 560 |
"headings": [h for h, _ in sections if h],
|
| 561 |
"words": len(doc.clean_text.split()),
|
| 562 |
}
|
|
|
|
| 575 |
return indexed, skipped, changed
|
| 576 |
|
| 577 |
|
| 578 |
+
# Every plan reads the whole archive (up to this ceiling). Posts past the plan's indexed_posts are stored as locked:
|
| 579 |
+
# listed with their title and text, but with no corpus file, so chat, notebooks and generation never see them.
|
| 580 |
+
# Upgrading re-syncs and unlocks them without asking the writer to reconnect anything.
|
| 581 |
+
ARCHIVE_SCAN_CAP = 5000
|
| 582 |
+
# Sites without a feed are crawled page by page (Firecrawl scrapes when configured), so the ceiling is lower.
|
| 583 |
+
SITE_SCAN_CAP = 500
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
def _newest_first(entries: list[FeedEntry]) -> list[FeedEntry]:
|
| 587 |
+
"""Dated posts newest first; undated ones (site crawls) keep their discovery order after them."""
|
| 588 |
+
dated = sorted((e for e in entries if e.published_at), key=lambda e: e.published_at.timestamp(), reverse=True)
|
| 589 |
+
return dated + [e for e in entries if not e.published_at]
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
def store_locked(db: Session, source: Source, entries: list[FeedEntry]) -> tuple[int, set[str]]:
|
| 593 |
+
"""Index posts beyond the plan limit without making them available. Returns (count, corpus paths to remove):
|
| 594 |
+
a post that was available before (a newer post pushed it out, or the plan went down) loses its corpus file."""
|
| 595 |
+
locked = 0
|
| 596 |
+
remove: set[str] = set()
|
| 597 |
+
for entry in entries:
|
| 598 |
+
sections = entry.sections if entry.sections is not None else html_to_sections(entry.html)
|
| 599 |
+
if not sections:
|
| 600 |
+
continue
|
| 601 |
+
doc = db.scalar(select(Document).where(Document.source_id == source.id, Document.url == entry.url))
|
| 602 |
+
if not doc:
|
| 603 |
+
doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url)
|
| 604 |
+
db.add(doc)
|
| 605 |
+
if doc.path:
|
| 606 |
+
remove.add(doc.path)
|
| 607 |
+
doc.path = None
|
| 608 |
+
doc.title = entry.title
|
| 609 |
+
doc.published_at = entry.published_at
|
| 610 |
+
doc.content_hash = None # forces a full store (corpus file, raw HTML) once the post unlocks
|
| 611 |
+
doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections)
|
| 612 |
+
doc.metadata_json = {
|
| 613 |
+
**(doc.metadata_json or {}),
|
| 614 |
+
"headings": [h for h, _ in sections if h],
|
| 615 |
+
"words": len(doc.clean_text.split()),
|
| 616 |
+
"locked": True,
|
| 617 |
+
}
|
| 618 |
+
locked += 1
|
| 619 |
+
db.commit()
|
| 620 |
+
return locked, remove
|
| 621 |
+
|
| 622 |
+
|
| 623 |
def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
|
| 624 |
source.sync_status = "syncing"
|
| 625 |
update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
|
| 626 |
+
fetch_limit = ARCHIVE_SCAN_CAP
|
|
|
|
| 627 |
|
| 628 |
if source.feed_url.startswith(SITE_PREFIX):
|
| 629 |
update_job(db, job, message="Mapping your site")
|
| 630 |
entries = crawl_site(
|
| 631 |
+
source.feed_url.removeprefix(SITE_PREFIX), max(SITE_SCAN_CAP, max_posts),
|
| 632 |
on_found=lambda n: update_job(db, job, progress=0.05, message=f"Found {n} pages, reading them"),
|
| 633 |
on_post=lambda done, total: update_job(db, job, progress=0.05 + 0.05 * done / total,
|
| 634 |
message=f"Read {done} of {total} pages"),
|
|
|
|
| 648 |
except (httpx.HTTPError, ValueError):
|
| 649 |
log.warning("substack archive fetch failed for %s", site, exc_info=True)
|
| 650 |
|
| 651 |
+
entries = _newest_first(entries)
|
| 652 |
+
available, beyond = entries[:max_posts], entries[max_posts:]
|
| 653 |
|
| 654 |
def progress(done: int, total: int, post_title: str) -> None:
|
| 655 |
+
update_job(db, job, progress=0.1 + 0.7 * done / total, message=f"{post_title[:80]} is in orbit")
|
| 656 |
+
|
| 657 |
+
update_job(db, job, progress=0.1, message=f"Indexing {len(available)} posts")
|
| 658 |
+
indexed, skipped, changed = store_entries(db, source, available, progress)
|
| 659 |
|
| 660 |
+
locked = 0
|
| 661 |
+
if beyond:
|
| 662 |
+
update_job(db, job, progress=0.85,
|
| 663 |
+
message=f"Indexing {len(beyond)} more posts. Your plan makes the latest {max_posts} available")
|
| 664 |
+
locked, remove = store_locked(db, source, beyond)
|
| 665 |
+
if remove:
|
| 666 |
+
Corpus(source.workspace_id).write_files({INDEX: rebuild_index(db, source.workspace_id)}, remove=remove)
|
| 667 |
|
| 668 |
source.sync_status = "ok"
|
| 669 |
source.sync_error = None
|
| 670 |
source.last_synced_at = datetime.now(UTC)
|
| 671 |
db.commit()
|
| 672 |
+
return {"indexed": indexed, "skipped": skipped, "found": len(entries), "limit": max_posts,
|
| 673 |
+
"capped": locked > 0, "locked": locked, "available": len(available),
|
| 674 |
"changed": [str(i) for i in changed]}
|
| 675 |
|
| 676 |
|
backend/app/routers/artifacts.py
CHANGED
|
@@ -11,6 +11,7 @@ from app.services.artifacts import latest_jobs, serialize_artifact, start_artifa
|
|
| 11 |
from app.services.jobs import create_job, serialize_job
|
| 12 |
from app.services.renderer import COMPOSITIONS
|
| 13 |
from app.services.storage import storage
|
|
|
|
| 14 |
from app.services.usage import check_limit
|
| 15 |
|
| 16 |
router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
|
|
@@ -56,6 +57,11 @@ def _target_title(ctx: Ctx, notebook_id: uuid.UUID | None, document_id: uuid.UUI
|
|
| 56 |
Document.workspace_id == ctx.workspace.id))
|
| 57 |
if not doc:
|
| 58 |
raise HTTPException(404, "Post not found")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
return doc.title
|
| 60 |
if notebook_id:
|
| 61 |
nb = ctx.db.scalar(select(Notebook).where(Notebook.id == notebook_id,
|
|
|
|
| 11 |
from app.services.jobs import create_job, serialize_job
|
| 12 |
from app.services.renderer import COMPOSITIONS
|
| 13 |
from app.services.storage import storage
|
| 14 |
+
from app.services.plans import effective_plan, plan_limit_error
|
| 15 |
from app.services.usage import check_limit
|
| 16 |
|
| 17 |
router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
|
|
|
|
| 57 |
Document.workspace_id == ctx.workspace.id))
|
| 58 |
if not doc:
|
| 59 |
raise HTTPException(404, "Post not found")
|
| 60 |
+
if not doc.path:
|
| 61 |
+
plan = effective_plan(ctx.db, ctx.workspace)
|
| 62 |
+
raise plan_limit_error(plan, "indexed_posts",
|
| 63 |
+
f"This post is locked. Your plan makes your latest {plan.indexed_posts} posts "
|
| 64 |
+
"available; upgrade to use your whole archive.")
|
| 65 |
return doc.title
|
| 66 |
if notebook_id:
|
| 67 |
nb = ctx.db.scalar(select(Notebook).where(Notebook.id == notebook_id,
|
backend/app/routers/notebooks.py
CHANGED
|
@@ -52,7 +52,9 @@ def _get(ctx: Ctx, notebook_id: uuid.UUID) -> Notebook:
|
|
| 52 |
|
| 53 |
def _add_docs(ctx: Ctx, nb: Notebook, ids: list[uuid.UUID]) -> int:
|
| 54 |
valid = set(ctx.db.scalars(
|
| 55 |
-
|
|
|
|
|
|
|
| 56 |
).all())
|
| 57 |
existing = set(ctx.db.scalars(select(NotebookDocument.document_id).where(NotebookDocument.notebook_id == nb.id)))
|
| 58 |
for doc_id in valid - existing:
|
|
|
|
| 52 |
|
| 53 |
def _add_docs(ctx: Ctx, nb: Notebook, ids: list[uuid.UUID]) -> int:
|
| 54 |
valid = set(ctx.db.scalars(
|
| 55 |
+
# Locked posts (beyond the plan's post limit) have no corpus file and cannot join a notebook.
|
| 56 |
+
select(Document.id).where(Document.id.in_(ids), Document.workspace_id == ctx.workspace.id,
|
| 57 |
+
Document.path.is_not(None))
|
| 58 |
).all())
|
| 59 |
existing = set(ctx.db.scalars(select(NotebookDocument.document_id).where(NotebookDocument.notebook_id == nb.id)))
|
| 60 |
for doc_id in valid - existing:
|
backend/app/routers/sources.py
CHANGED
|
@@ -24,7 +24,7 @@ class UploadImportIn(BaseModel):
|
|
| 24 |
upload_id: uuid.UUID
|
| 25 |
|
| 26 |
|
| 27 |
-
def serialize_source(source: Source, doc_count: int = 0) -> dict:
|
| 28 |
return {
|
| 29 |
"id": str(source.id),
|
| 30 |
"feed_url": source.feed_url,
|
|
@@ -35,6 +35,8 @@ def serialize_source(source: Source, doc_count: int = 0) -> dict:
|
|
| 35 |
"sync_error": source.sync_error,
|
| 36 |
"last_synced_at": source.last_synced_at.isoformat() if source.last_synced_at else None,
|
| 37 |
"document_count": doc_count,
|
|
|
|
|
|
|
| 38 |
"is_imports": source.feed_url == IMPORTS_FEED,
|
| 39 |
}
|
| 40 |
|
|
@@ -91,7 +93,12 @@ def list_sources(ctx: Ctx = Depends(get_ctx)):
|
|
| 91 |
.group_by(Source.id)
|
| 92 |
.order_by(Source.created_at)
|
| 93 |
).all()
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
|
| 97 |
@router.post("/{source_id}/sync")
|
|
@@ -151,6 +158,11 @@ def import_upload(body: UploadImportIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 151 |
documents_router = APIRouter(prefix="/api/documents", tags=["documents"])
|
| 152 |
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
def serialize_document(d: Document, source: Source | None = None) -> dict:
|
| 155 |
meta = d.metadata_json or {}
|
| 156 |
return {
|
|
@@ -163,6 +175,7 @@ def serialize_document(d: Document, source: Source | None = None) -> dict:
|
|
| 163 |
"published_at": d.published_at.isoformat() if d.published_at else None,
|
| 164 |
"words": meta.get("words") or len((d.clean_text or "").split()),
|
| 165 |
"evergreen_score": d.evergreen_score,
|
|
|
|
| 166 |
}
|
| 167 |
|
| 168 |
|
|
@@ -198,6 +211,12 @@ def get_document(document_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
|
| 198 |
if not doc:
|
| 199 |
raise HTTPException(404, "Post not found")
|
| 200 |
source = ctx.db.get(Source, doc.source_id) if doc.source_id else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
lines: list[str] = []
|
| 202 |
if doc.path:
|
| 203 |
corpus = Corpus(ctx.workspace.id)
|
|
|
|
| 24 |
upload_id: uuid.UUID
|
| 25 |
|
| 26 |
|
| 27 |
+
def serialize_source(source: Source, doc_count: int = 0, locked_count: int = 0) -> dict:
|
| 28 |
return {
|
| 29 |
"id": str(source.id),
|
| 30 |
"feed_url": source.feed_url,
|
|
|
|
| 35 |
"sync_error": source.sync_error,
|
| 36 |
"last_synced_at": source.last_synced_at.isoformat() if source.last_synced_at else None,
|
| 37 |
"document_count": doc_count,
|
| 38 |
+
# Indexed but beyond the plan's post limit: listed, not usable until the workspace upgrades.
|
| 39 |
+
"locked_count": locked_count,
|
| 40 |
"is_imports": source.feed_url == IMPORTS_FEED,
|
| 41 |
}
|
| 42 |
|
|
|
|
| 93 |
.group_by(Source.id)
|
| 94 |
.order_by(Source.created_at)
|
| 95 |
).all()
|
| 96 |
+
locked = dict(ctx.db.execute(
|
| 97 |
+
select(Document.source_id, func.count(Document.id))
|
| 98 |
+
.where(Document.workspace_id == ctx.workspace.id, Document.path.is_(None))
|
| 99 |
+
.group_by(Document.source_id)
|
| 100 |
+
).all())
|
| 101 |
+
return [serialize_source(s, n, locked.get(s.id, 0)) for s, n in rows]
|
| 102 |
|
| 103 |
|
| 104 |
@router.post("/{source_id}/sync")
|
|
|
|
| 158 |
documents_router = APIRouter(prefix="/api/documents", tags=["documents"])
|
| 159 |
|
| 160 |
|
| 161 |
+
def is_locked(d: Document) -> bool:
|
| 162 |
+
"""Indexed past the plan's post limit (see ingest.store_locked): visible, but not in the corpus."""
|
| 163 |
+
return not d.path and bool((d.metadata_json or {}).get("locked"))
|
| 164 |
+
|
| 165 |
+
|
| 166 |
def serialize_document(d: Document, source: Source | None = None) -> dict:
|
| 167 |
meta = d.metadata_json or {}
|
| 168 |
return {
|
|
|
|
| 175 |
"published_at": d.published_at.isoformat() if d.published_at else None,
|
| 176 |
"words": meta.get("words") or len((d.clean_text or "").split()),
|
| 177 |
"evergreen_score": d.evergreen_score,
|
| 178 |
+
"locked": is_locked(d),
|
| 179 |
}
|
| 180 |
|
| 181 |
|
|
|
|
| 211 |
if not doc:
|
| 212 |
raise HTTPException(404, "Post not found")
|
| 213 |
source = ctx.db.get(Source, doc.source_id) if doc.source_id else None
|
| 214 |
+
if is_locked(doc):
|
| 215 |
+
# A preview only: the full post is part of what upgrading unlocks.
|
| 216 |
+
preview = [ln for ln in (doc.clean_text or "").splitlines() if ln.strip()][:3]
|
| 217 |
+
plan = effective_plan(ctx.db, ctx.workspace)
|
| 218 |
+
return {**serialize_document(doc, source), "lines": [f"# {doc.title}", "", *preview],
|
| 219 |
+
"preview": True, "limit": plan.indexed_posts}
|
| 220 |
lines: list[str] = []
|
| 221 |
if doc.path:
|
| 222 |
corpus = Corpus(ctx.workspace.id)
|
backend/app/routers/voice.py
CHANGED
|
@@ -57,6 +57,23 @@ class PreviewIn(BaseModel):
|
|
| 57 |
delivery: DeliveryIn | None = None
|
| 58 |
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
class LibraryAddIn(BaseModel):
|
| 61 |
public_owner_id: str = Field(min_length=5, max_length=100)
|
| 62 |
voice_id: str = Field(min_length=5, max_length=60)
|
|
@@ -116,6 +133,8 @@ def _serialize(ctx: Ctx) -> dict:
|
|
| 116 |
"error": job.error if job and job.status == "failed" else None,
|
| 117 |
"preview_url": storage.presign_get(preview_key) if preview_key else None,
|
| 118 |
},
|
|
|
|
|
|
|
| 119 |
"tts_configured": bool(settings.elevenlabs_api_key),
|
| 120 |
"model": tts.model_id(),
|
| 121 |
"models": tts.MODELS,
|
|
@@ -211,6 +230,61 @@ async def preview(body: PreviewIn, ctx: Ctx = Depends(get_ctx)):
|
|
| 211 |
return {"url": storage.presign_get(key), "seconds": round(tts.mp3_seconds(clip), 1)}
|
| 212 |
|
| 213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
@router.get("/reading-script")
|
| 215 |
def reading_script(ctx: Ctx = Depends(get_ctx)):
|
| 216 |
"""About two minutes of the writer's own prose to read aloud for the clone (their words, their rhythm)."""
|
|
|
|
| 57 |
delivery: DeliveryIn | None = None
|
| 58 |
|
| 59 |
|
| 60 |
+
class DesignIn(BaseModel):
|
| 61 |
+
"""Either picked options (Build from options) or a free description (Describe in your own words)."""
|
| 62 |
+
prompt: str | None = Field(None, max_length=1000)
|
| 63 |
+
gender: str = Field("", max_length=20)
|
| 64 |
+
age: str = Field("", max_length=20)
|
| 65 |
+
persona: str = Field("", max_length=30)
|
| 66 |
+
pace: str = Field("", max_length=20)
|
| 67 |
+
accent: str = Field("", max_length=40)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class DesignSaveIn(BaseModel):
|
| 71 |
+
generated_voice_id: str = Field(min_length=5, max_length=100)
|
| 72 |
+
name: str = Field(min_length=1, max_length=60)
|
| 73 |
+
description: str = Field("", max_length=1000)
|
| 74 |
+
use_as: str | None = None # "host_a" | "host_b"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
class LibraryAddIn(BaseModel):
|
| 78 |
public_owner_id: str = Field(min_length=5, max_length=100)
|
| 79 |
voice_id: str = Field(min_length=5, max_length=60)
|
|
|
|
| 133 |
"error": job.error if job and job.status == "failed" else None,
|
| 134 |
"preview_url": storage.presign_get(preview_key) if preview_key else None,
|
| 135 |
},
|
| 136 |
+
# Voices this workspace generated with Voice Design (the ElevenLabs account is shared across workspaces).
|
| 137 |
+
"custom_voices": voices.get("custom") or [],
|
| 138 |
"tts_configured": bool(settings.elevenlabs_api_key),
|
| 139 |
"model": tts.model_id(),
|
| 140 |
"models": tts.MODELS,
|
|
|
|
| 230 |
return {"url": storage.presign_get(key), "seconds": round(tts.mp3_seconds(clip), 1)}
|
| 231 |
|
| 232 |
|
| 233 |
+
def _design_description(body: DesignIn) -> str:
|
| 234 |
+
if body.prompt and body.prompt.strip():
|
| 235 |
+
text = body.prompt.strip()
|
| 236 |
+
if len(text) < 20:
|
| 237 |
+
raise HTTPException(400, "Describe the voice in at least 20 characters.")
|
| 238 |
+
return text
|
| 239 |
+
parts = [f"A {body.age} {body.gender} voice".replace(" ", " ") if body.gender or body.age else "A voice"]
|
| 240 |
+
if body.accent:
|
| 241 |
+
parts.append(f"with a {body.accent} accent")
|
| 242 |
+
sentence = " ".join(parts) + "."
|
| 243 |
+
if body.persona:
|
| 244 |
+
sentence += f" Sounds {body.persona}."
|
| 245 |
+
if body.pace:
|
| 246 |
+
sentence += f" Speaks at a {body.pace} pace."
|
| 247 |
+
return sentence + " Clear studio quality, suited to podcasts and narration."
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
@router.post("/design")
|
| 251 |
+
async def design(body: DesignIn, ctx: Ctx = Depends(get_ctx)):
|
| 252 |
+
"""Three previews of a new voice. Nothing is saved until the writer picks one."""
|
| 253 |
+
if not settings.elevenlabs_api_key:
|
| 254 |
+
raise HTTPException(503, "Set ELEVENLABS_API_KEY in .env to generate voices.")
|
| 255 |
+
description = _design_description(body)
|
| 256 |
+
try:
|
| 257 |
+
previews = await run_in_threadpool(tts.design_voice, description)
|
| 258 |
+
except tts.TTSError as exc:
|
| 259 |
+
raise HTTPException(502, str(exc)) from exc
|
| 260 |
+
out = []
|
| 261 |
+
for p in previews:
|
| 262 |
+
key = f"ws/{ctx.workspace.id}/voice-previews/{uuid.uuid4().hex}.mp3"
|
| 263 |
+
storage.put_bytes(key, p["audio"], "audio/mpeg")
|
| 264 |
+
out.append({"generated_voice_id": p["generated_voice_id"], "url": storage.presign_get(key), "seconds": p["seconds"]})
|
| 265 |
+
record_usage(ctx.db, workspace_id=ctx.workspace.id, kind="voice_design", provider="elevenlabs",
|
| 266 |
+
quantity=len(out), unit="previews")
|
| 267 |
+
return {"description": description, "previews": out}
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
@router.post("/design/save")
|
| 271 |
+
async def save_design(body: DesignSaveIn, ctx: Ctx = Depends(get_ctx)):
|
| 272 |
+
try:
|
| 273 |
+
voice_id = await run_in_threadpool(tts.create_designed_voice, body.name, body.description,
|
| 274 |
+
body.generated_voice_id)
|
| 275 |
+
except tts.TTSError as exc:
|
| 276 |
+
raise HTTPException(502, str(exc)) from exc
|
| 277 |
+
vp = _ensure_profile(ctx)
|
| 278 |
+
voices = dict(vp.host_voices or {})
|
| 279 |
+
voices["custom"] = [*(voices.get("custom") or []),
|
| 280 |
+
{"voice_id": voice_id, "name": body.name, "description": body.description[:300]}]
|
| 281 |
+
if body.use_as in ("host_a", "host_b"):
|
| 282 |
+
voices[body.use_as] = voice_id
|
| 283 |
+
vp.host_voices = voices
|
| 284 |
+
ctx.db.commit()
|
| 285 |
+
return {"voice_id": voice_id, **_serialize(ctx)}
|
| 286 |
+
|
| 287 |
+
|
| 288 |
@router.get("/reading-script")
|
| 289 |
def reading_script(ctx: Ctx = Depends(get_ctx)):
|
| 290 |
"""About two minutes of the writer's own prose to read aloud for the clone (their words, their rhythm)."""
|
backend/app/worker.py
CHANGED
|
@@ -91,8 +91,9 @@ def handle_ingest(db: Session, job: Job):
|
|
| 91 |
raise PermanentJobError(str(exc)) from exc
|
| 92 |
raise
|
| 93 |
_after_import(db, job, source.workspace_id, result.get("changed", []))
|
| 94 |
-
if result.get("
|
| 95 |
-
return Done(result, f"
|
|
|
|
| 96 |
return Done(result, f"All posts in orbit: {result['indexed']} new or updated")
|
| 97 |
|
| 98 |
|
|
|
|
| 91 |
raise PermanentJobError(str(exc)) from exc
|
| 92 |
raise
|
| 93 |
_after_import(db, job, source.workspace_id, result.get("changed", []))
|
| 94 |
+
if result.get("locked"):
|
| 95 |
+
return Done(result, f"All {result['found']} posts indexed. Your latest {result['available']} are available; "
|
| 96 |
+
f"upgrade to unlock the other {result['locked']}")
|
| 97 |
return Done(result, f"All posts in orbit: {result['indexed']} new or updated")
|
| 98 |
|
| 99 |
|
frontend/src/api/endpoints.ts
CHANGED
|
@@ -24,6 +24,8 @@ import type {
|
|
| 24 |
Usage,
|
| 25 |
Voice,
|
| 26 |
Delivery,
|
|
|
|
|
|
|
| 27 |
VoiceProfileData,
|
| 28 |
VoiceState,
|
| 29 |
} from "./types";
|
|
@@ -129,6 +131,9 @@ export const voiceApi = {
|
|
| 129 |
readingScript: () => api<{ title: string; text: string; words: number }>("/api/voice/reading-script"),
|
| 130 |
quota: () => api<Quota>("/api/voice/quota"),
|
| 131 |
revoke: () => del<VoiceState>("/api/voice/consent"),
|
|
|
|
|
|
|
|
|
|
| 132 |
};
|
| 133 |
|
| 134 |
export type ItemBody = {
|
|
|
|
| 24 |
Usage,
|
| 25 |
Voice,
|
| 26 |
Delivery,
|
| 27 |
+
VoiceDesignInput,
|
| 28 |
+
VoiceDesignPreview,
|
| 29 |
VoiceProfileData,
|
| 30 |
VoiceState,
|
| 31 |
} from "./types";
|
|
|
|
| 131 |
readingScript: () => api<{ title: string; text: string; words: number }>("/api/voice/reading-script"),
|
| 132 |
quota: () => api<Quota>("/api/voice/quota"),
|
| 133 |
revoke: () => del<VoiceState>("/api/voice/consent"),
|
| 134 |
+
design: (input: VoiceDesignInput) => post<{ description: string; previews: VoiceDesignPreview[] }>("/api/voice/design", input),
|
| 135 |
+
saveDesign: (body: { generated_voice_id: string; name: string; description: string; use_as?: "host_a" | "host_b" }) =>
|
| 136 |
+
post<VoiceState & { voice_id: string }>("/api/voice/design/save", body),
|
| 137 |
};
|
| 138 |
|
| 139 |
export type ItemBody = {
|
frontend/src/api/types.ts
CHANGED
|
@@ -195,9 +195,14 @@ export type VoiceState = {
|
|
| 195 |
error: string | null;
|
| 196 |
preview_url: string | null;
|
| 197 |
};
|
|
|
|
|
|
|
| 198 |
tts_configured: boolean;
|
| 199 |
};
|
| 200 |
|
|
|
|
|
|
|
|
|
|
| 201 |
export type Delivery = { stability: number; similarity_boost: number; style: number; speed: number; use_speaker_boost?: boolean };
|
| 202 |
|
| 203 |
export type Voice = {
|
|
|
|
| 195 |
error: string | null;
|
| 196 |
preview_url: string | null;
|
| 197 |
};
|
| 198 |
+
/** Voices this workspace generated with Voice Design. */
|
| 199 |
+
custom_voices: { voice_id: string; name: string; description?: string }[];
|
| 200 |
tts_configured: boolean;
|
| 201 |
};
|
| 202 |
|
| 203 |
+
export type VoiceDesignInput = { prompt?: string; gender?: string; age?: string; persona?: string; pace?: string; accent?: string };
|
| 204 |
+
export type VoiceDesignPreview = { generated_voice_id: string; url: string; seconds: number | null };
|
| 205 |
+
|
| 206 |
export type Delivery = { stability: number; similarity_boost: number; style: number; speed: number; use_speaker_boost?: boolean };
|
| 207 |
|
| 208 |
export type Voice = {
|
frontend/src/pages/VoiceProfile.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
import { voiceApi } from "../api/endpoints";
|
| 3 |
-
import type { Delivery, Job, LibraryVoice, Quota, Voice, VoiceProfileData, VoiceState } from "../api/types";
|
| 4 |
import { DocPicker } from "../components/DocPicker";
|
| 5 |
-
import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader } from "../components/ui";
|
| 6 |
import { useJob } from "../hooks/useJob";
|
| 7 |
import { useUpgrade } from "../hooks/useUpgrade";
|
| 8 |
|
|
@@ -23,8 +23,6 @@ const SLIDERS: { key: keyof Delivery; label: string; min: number; max: number; s
|
|
| 23 |
{ key: "speed", label: "Speed", min: 0.7, max: 1.2, step: 0.05, hint: "1.0 is natural pace" },
|
| 24 |
];
|
| 25 |
|
| 26 |
-
const HOST_LABEL: Record<Host, string> = { host_a: "Host A, leads and narrates videos", host_b: "Host B, the co-host" };
|
| 27 |
-
|
| 28 |
/** Plays one URL at a time across the page. */
|
| 29 |
function usePlayer() {
|
| 30 |
const audio = useRef<HTMLAudioElement | null>(null);
|
|
@@ -51,83 +49,159 @@ function fmtSeconds(s: number) {
|
|
| 51 |
|
| 52 |
// Speaking voices
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
state,
|
| 57 |
voices,
|
| 58 |
onSaved,
|
| 59 |
onBrowse,
|
|
|
|
| 60 |
}: {
|
| 61 |
-
host: Host;
|
| 62 |
state: VoiceState;
|
| 63 |
voices: Voice[];
|
| 64 |
onSaved: (s: VoiceState) => void;
|
| 65 |
onBrowse: (host: Host) => void;
|
|
|
|
| 66 |
}) {
|
|
|
|
| 67 |
const [delivery, setDelivery] = useState<Delivery>(state.delivery[host]);
|
| 68 |
-
const [busy, setBusy] = useState(false);
|
| 69 |
-
const [error, setError] = useState<string | null>(null);
|
| 70 |
const [dirty, setDirty] = useState(false);
|
|
|
|
|
|
|
| 71 |
const { play, playing } = usePlayer();
|
| 72 |
const current = state.host_voices[host];
|
| 73 |
-
const
|
| 74 |
-
|
| 75 |
-
(acc[k] ??= []).push(v);
|
| 76 |
-
return acc;
|
| 77 |
-
}, {});
|
| 78 |
|
| 79 |
useEffect(() => {
|
| 80 |
setDelivery(state.delivery[host]);
|
| 81 |
setDirty(false);
|
| 82 |
}, [state.delivery, host]);
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
setError(null);
|
| 87 |
try {
|
| 88 |
-
const { url } = await voiceApi.preview(
|
| 89 |
-
play(url,
|
| 90 |
} catch (e) {
|
| 91 |
setError(errorMessage(e));
|
| 92 |
} finally {
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
}
|
| 95 |
};
|
| 96 |
|
| 97 |
return (
|
| 98 |
-
<div className="
|
| 99 |
-
<
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
<
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
<details className="delivery">
|
| 130 |
-
<summary className="mono muted small">Delivery settings</summary>
|
| 131 |
{SLIDERS.map((s) => (
|
| 132 |
<label key={s.key} className="slider" title={s.hint}>
|
| 133 |
<span className="row between">
|
|
@@ -164,11 +238,210 @@ function HostCard({
|
|
| 164 |
</div>
|
| 165 |
)}
|
| 166 |
</details>
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
</div>
|
| 169 |
);
|
| 170 |
}
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
// Voice library (commercially licensed voices from ElevenLabs)
|
| 173 |
|
| 174 |
function LibraryModal({ host, onClose, onAdded }: { host: Host; onClose: () => void; onAdded: (s: VoiceState) => void }) {
|
|
@@ -483,6 +756,7 @@ export default function VoiceProfile() {
|
|
| 483 |
const [cloneJob, setCloneJob] = useState<Job | null>(null);
|
| 484 |
const [cloning, setCloning] = useState(false);
|
| 485 |
const [browsing, setBrowsing] = useState<Host | null>(null);
|
|
|
|
| 486 |
const [saved, setSaved] = useState(false);
|
| 487 |
const [error, setError] = useState<string | null>(null);
|
| 488 |
const { play, playing } = usePlayer();
|
|
@@ -583,7 +857,7 @@ export default function VoiceProfile() {
|
|
| 583 |
<>
|
| 584 |
<label className="field">
|
| 585 |
<span>In a sentence, you sound like this</span>
|
| 586 |
-
<textarea className="textarea" rows={
|
| 587 |
</label>
|
| 588 |
<details className="delivery">
|
| 589 |
<summary className="mono muted small">Fine-tune tone, phrases and habits</summary>
|
|
@@ -631,14 +905,10 @@ export default function VoiceProfile() {
|
|
| 631 |
<p className="eyebrow">Step 2 of 3</p>
|
| 632 |
<h2>Pick who reads your work aloud</h2>
|
| 633 |
<p className="muted">
|
| 634 |
-
Audio overviews are a conversation between two hosts
|
| 635 |
</p>
|
| 636 |
{!state.tts_configured && <p className="muted">Set ELEVENLABS_API_KEY in .env to generate audio.</p>}
|
| 637 |
-
<
|
| 638 |
-
{(["host_a", "host_b"] as Host[]).map((h) => (
|
| 639 |
-
<HostCard key={h} host={h} state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} />
|
| 640 |
-
))}
|
| 641 |
-
</div>
|
| 642 |
<div className="row between">
|
| 643 |
<button className="btn btn-small" onClick={() => setStep("writing")}>
|
| 644 |
Back
|
|
@@ -746,6 +1016,18 @@ export default function VoiceProfile() {
|
|
| 746 |
</Modal>
|
| 747 |
)}
|
| 748 |
{cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
{browsing && (
|
| 750 |
<LibraryModal
|
| 751 |
host={browsing}
|
|
|
|
| 1 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
import { voiceApi } from "../api/endpoints";
|
| 3 |
+
import type { Delivery, Job, LibraryVoice, Quota, Voice, VoiceDesignInput, VoiceDesignPreview, VoiceProfileData, VoiceState } from "../api/types";
|
| 4 |
import { DocPicker } from "../components/DocPicker";
|
| 5 |
+
import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader, Tabs } from "../components/ui";
|
| 6 |
import { useJob } from "../hooks/useJob";
|
| 7 |
import { useUpgrade } from "../hooks/useUpgrade";
|
| 8 |
|
|
|
|
| 23 |
{ key: "speed", label: "Speed", min: 0.7, max: 1.2, step: 0.05, hint: "1.0 is natural pace" },
|
| 24 |
];
|
| 25 |
|
|
|
|
|
|
|
| 26 |
/** Plays one URL at a time across the page. */
|
| 27 |
function usePlayer() {
|
| 28 |
const audio = useRef<HTMLAudioElement | null>(null);
|
|
|
|
| 49 |
|
| 50 |
// Speaking voices
|
| 51 |
|
| 52 |
+
type Icon = "sun" | "book" | "mic" | "spark" | "wave" | "person";
|
| 53 |
+
|
| 54 |
+
/** The four ElevenLabs premade voices offered up front. Each has a glyph so they read at a glance. */
|
| 55 |
+
const PREBUILT: { voice_id: string; name: string; tag: string; icon: Icon }[] = [
|
| 56 |
+
{ voice_id: "EXAVITQu4vr4xnSDxMaL", name: "Sarah", tag: "Warm and reassuring", icon: "sun" },
|
| 57 |
+
{ voice_id: "JBFqnCBsd6RMkjVDRZzb", name: "George", tag: "British storyteller", icon: "book" },
|
| 58 |
+
{ voice_id: "nPczCjzI2devNBz1zQrb", name: "Brian", tag: "Deep narrator", icon: "mic" },
|
| 59 |
+
{ voice_id: "cgSgspJ2msm6clMCkdW9", name: "Jessica", tag: "Bright and lively", icon: "spark" },
|
| 60 |
+
];
|
| 61 |
+
|
| 62 |
+
const ICON_PATHS: Record<Icon, string> = {
|
| 63 |
+
sun: "M12 4V2m0 20v-2m8-8h2M2 12h2m13.66-5.66 1.41-1.41M4.93 19.07l1.41-1.41m0-11.32L4.93 4.93m14.14 14.14-1.41-1.41M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z",
|
| 64 |
+
book: "M4 5.5A2.5 2.5 0 0 1 6.5 3H20v15H6.5A2.5 2.5 0 0 0 4 20.5v-15Zm0 15A2.5 2.5 0 0 0 6.5 23H20v-5",
|
| 65 |
+
mic: "M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Zm7 9a7 7 0 0 1-14 0m7 7v4m-4 0h8",
|
| 66 |
+
spark: "M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Zm7 11 .8 2.2L22 17l-2.2.8L19 20l-.8-2.2L16 17l2.2-.8L19 14Z",
|
| 67 |
+
wave: "M3 12h2m3-5v10m4-13v16m4-11v6m4-3h1",
|
| 68 |
+
person: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0",
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
function VoiceIcon({ icon }: { icon: Icon }) {
|
| 72 |
+
return (
|
| 73 |
+
<span className="voice-icon" aria-hidden="true">
|
| 74 |
+
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
|
| 75 |
+
<path d={ICON_PATHS[icon]} />
|
| 76 |
+
</svg>
|
| 77 |
+
</span>
|
| 78 |
+
);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
type Tile = { voice_id: string; name: string; tag: string; icon: Icon; preview_url?: string | null };
|
| 82 |
+
|
| 83 |
+
function VoicePicker({
|
| 84 |
state,
|
| 85 |
voices,
|
| 86 |
onSaved,
|
| 87 |
onBrowse,
|
| 88 |
+
onGenerate,
|
| 89 |
}: {
|
|
|
|
| 90 |
state: VoiceState;
|
| 91 |
voices: Voice[];
|
| 92 |
onSaved: (s: VoiceState) => void;
|
| 93 |
onBrowse: (host: Host) => void;
|
| 94 |
+
onGenerate: (host: Host) => void;
|
| 95 |
}) {
|
| 96 |
+
const [host, setHost] = useState<Host>("host_a");
|
| 97 |
const [delivery, setDelivery] = useState<Delivery>(state.delivery[host]);
|
|
|
|
|
|
|
| 98 |
const [dirty, setDirty] = useState(false);
|
| 99 |
+
const [loading, setLoading] = useState<string | null>(null);
|
| 100 |
+
const [error, setError] = useState<string | null>(null);
|
| 101 |
const { play, playing } = usePlayer();
|
| 102 |
const current = state.host_voices[host];
|
| 103 |
+
const other: Host = host === "host_a" ? "host_b" : "host_a";
|
| 104 |
+
const byId = new Map(voices.map((v) => [v.voice_id, v]));
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
useEffect(() => {
|
| 107 |
setDelivery(state.delivery[host]);
|
| 108 |
setDirty(false);
|
| 109 |
}, [state.delivery, host]);
|
| 110 |
|
| 111 |
+
// Prebuilt first, then the writer's own voices, then whatever else a host is set to (e.g. a library voice).
|
| 112 |
+
const tiles: Tile[] = PREBUILT.map((p) => ({ ...p, preview_url: byId.get(p.voice_id)?.preview_url }));
|
| 113 |
+
if (state.clone.status === "ready" && state.clone.voice_id) {
|
| 114 |
+
tiles.push({ voice_id: state.clone.voice_id, name: "My voice", tag: "Your clone", icon: "person", preview_url: state.clone.preview_url });
|
| 115 |
+
}
|
| 116 |
+
for (const c of state.custom_voices) tiles.push({ voice_id: c.voice_id, name: c.name, tag: "Generated", icon: "wave" });
|
| 117 |
+
for (const id of [state.host_voices.host_a, state.host_voices.host_b]) {
|
| 118 |
+
if (!tiles.some((t) => t.voice_id === id)) {
|
| 119 |
+
const v = byId.get(id);
|
| 120 |
+
tiles.push({ voice_id: id, name: v?.name ?? "Current voice", tag: v?.labels?.accent ?? "Library voice", icon: "wave", preview_url: v?.preview_url });
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
const listen = async (t: Tile) => {
|
| 125 |
+
if (playing === t.voice_id || t.preview_url) return play(t.preview_url ?? "", t.voice_id);
|
| 126 |
+
setLoading(t.voice_id);
|
| 127 |
setError(null);
|
| 128 |
try {
|
| 129 |
+
const { url } = await voiceApi.preview(t.voice_id, { host });
|
| 130 |
+
play(url, t.voice_id);
|
| 131 |
} catch (e) {
|
| 132 |
setError(errorMessage(e));
|
| 133 |
} finally {
|
| 134 |
+
setLoading(null);
|
| 135 |
+
}
|
| 136 |
+
};
|
| 137 |
+
|
| 138 |
+
const choose = async (voice_id: string) => {
|
| 139 |
+
if (voice_id === current) return;
|
| 140 |
+
setError(null);
|
| 141 |
+
try {
|
| 142 |
+
onSaved(await voiceApi.update({ host_voices: { [host]: voice_id } }));
|
| 143 |
+
} catch (e) {
|
| 144 |
+
setError(errorMessage(e));
|
| 145 |
}
|
| 146 |
};
|
| 147 |
|
| 148 |
return (
|
| 149 |
+
<div className="stack">
|
| 150 |
+
<Tabs<Host>
|
| 151 |
+
tabs={[
|
| 152 |
+
{ id: "host_a", label: "Host A · leads" },
|
| 153 |
+
{ id: "host_b", label: "Host B · co-host" },
|
| 154 |
+
]}
|
| 155 |
+
value={host}
|
| 156 |
+
onChange={setHost}
|
| 157 |
+
/>
|
| 158 |
+
<ul className="voice-grid" aria-label={`Voices for ${host === "host_a" ? "Host A" : "Host B"}`}>
|
| 159 |
+
{tiles.map((t) => {
|
| 160 |
+
const selected = t.voice_id === current;
|
| 161 |
+
const usedByOther = t.voice_id === state.host_voices[other];
|
| 162 |
+
return (
|
| 163 |
+
<li key={t.voice_id} className={`voice-tile${selected ? " on" : ""}`}>
|
| 164 |
+
<button type="button" className="voice-pick" onClick={() => choose(t.voice_id)} aria-pressed={selected}>
|
| 165 |
+
<VoiceIcon icon={t.icon} />
|
| 166 |
+
<strong>{t.name}</strong>
|
| 167 |
+
<span className="muted small">{t.tag}</span>
|
| 168 |
+
</button>
|
| 169 |
+
{selected && <span className="voice-badge mono">{host === "host_a" ? "Host A" : "Host B"}</span>}
|
| 170 |
+
{!selected && usedByOther && <span className="voice-badge dim mono">{other === "host_a" ? "Host A" : "Host B"}</span>}
|
| 171 |
+
<button
|
| 172 |
+
type="button"
|
| 173 |
+
className="icon-btn voice-play"
|
| 174 |
+
onClick={() => listen(t)}
|
| 175 |
+
disabled={loading !== null || (!t.preview_url && !state.tts_configured)}
|
| 176 |
+
aria-label={playing === t.voice_id ? `Stop ${t.name}` : `Play ${t.name}`}
|
| 177 |
+
title={playing === t.voice_id ? "Stop" : "Listen"}
|
| 178 |
+
>
|
| 179 |
+
{loading === t.voice_id ? (
|
| 180 |
+
"…"
|
| 181 |
+
) : (
|
| 182 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
| 183 |
+
{playing === t.voice_id ? <path d="M6 5h4v14H6zM14 5h4v14h-4z" /> : <path d="M7 4.5v15l13-7.5z" />}
|
| 184 |
+
</svg>
|
| 185 |
+
)}
|
| 186 |
+
</button>
|
| 187 |
+
</li>
|
| 188 |
+
);
|
| 189 |
+
})}
|
| 190 |
+
<li className="voice-tile voice-new">
|
| 191 |
+
<button type="button" className="voice-pick" onClick={() => onGenerate(host)} aria-label="Add a voice">
|
| 192 |
+
<span className="voice-icon plus" aria-hidden="true">
|
| 193 |
+
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
|
| 194 |
+
<path d="M12 5v14M5 12h14" />
|
| 195 |
+
</svg>
|
| 196 |
+
</span>
|
| 197 |
+
<strong>Add a voice</strong>
|
| 198 |
+
<span className="muted small">Generate or clone your own</span>
|
| 199 |
+
</button>
|
| 200 |
+
</li>
|
| 201 |
+
</ul>
|
| 202 |
+
{error && <p className="error-text">{error}</p>}
|
| 203 |
<details className="delivery">
|
| 204 |
+
<summary className="mono muted small">Delivery settings for {host === "host_a" ? "Host A" : "Host B"}</summary>
|
| 205 |
{SLIDERS.map((s) => (
|
| 206 |
<label key={s.key} className="slider" title={s.hint}>
|
| 207 |
<span className="row between">
|
|
|
|
| 238 |
</div>
|
| 239 |
)}
|
| 240 |
</details>
|
| 241 |
+
<p className="muted small">
|
| 242 |
+
Want something else?{" "}
|
| 243 |
+
<button className="link-btn small" onClick={() => onBrowse(host)}>
|
| 244 |
+
Browse the full voice library
|
| 245 |
+
</button>
|
| 246 |
+
</p>
|
| 247 |
</div>
|
| 248 |
);
|
| 249 |
}
|
| 250 |
|
| 251 |
+
// Generating a new voice: build from options, describe it, or clone your own
|
| 252 |
+
|
| 253 |
+
type GenMode = "options" | "prompt" | "clone";
|
| 254 |
+
|
| 255 |
+
const GEN_OPTIONS: { key: keyof Omit<VoiceDesignInput, "prompt">; label: string; values: string[] }[] = [
|
| 256 |
+
{ key: "gender", label: "Gender", values: ["female", "male", "neutral"] },
|
| 257 |
+
{ key: "age", label: "Age", values: ["young", "middle-aged", "older"] },
|
| 258 |
+
{ key: "persona", label: "Personality", values: ["warm", "professional", "calm", "friendly", "confident", "energetic"] },
|
| 259 |
+
{ key: "pace", label: "Pace", values: ["slow", "measured", "brisk"] },
|
| 260 |
+
{ key: "accent", label: "Accent", values: ["American", "British", "Australian", "Irish", "Scottish", "Indian", "Canadian", "South African"] },
|
| 261 |
+
];
|
| 262 |
+
|
| 263 |
+
const PROMPT_IDEAS = [
|
| 264 |
+
"A calm, low British voice for late night essays",
|
| 265 |
+
"An upbeat young American podcaster who smiles while talking",
|
| 266 |
+
"A thoughtful older narrator with a soft Irish lilt",
|
| 267 |
+
];
|
| 268 |
+
|
| 269 |
+
function GenerateVoiceModal({
|
| 270 |
+
host,
|
| 271 |
+
state,
|
| 272 |
+
onClose,
|
| 273 |
+
onSaved,
|
| 274 |
+
onClone,
|
| 275 |
+
}: {
|
| 276 |
+
host: Host;
|
| 277 |
+
state: VoiceState;
|
| 278 |
+
onClose: () => void;
|
| 279 |
+
onSaved: (s: VoiceState) => void;
|
| 280 |
+
onClone: () => void;
|
| 281 |
+
}) {
|
| 282 |
+
const [mode, setMode] = useState<GenMode>("options");
|
| 283 |
+
const [opts, setOpts] = useState<VoiceDesignInput>({ gender: "female", age: "middle-aged", persona: "warm", pace: "measured", accent: "American" });
|
| 284 |
+
const [prompt, setPrompt] = useState("");
|
| 285 |
+
const [result, setResult] = useState<{ description: string; previews: VoiceDesignPreview[] } | null>(null);
|
| 286 |
+
const [picked, setPicked] = useState<string | null>(null);
|
| 287 |
+
const [name, setName] = useState("");
|
| 288 |
+
const [busy, setBusy] = useState<"generate" | "save" | null>(null);
|
| 289 |
+
const [error, setError] = useState<string | null>(null);
|
| 290 |
+
const { play, playing } = usePlayer();
|
| 291 |
+
const { openUpgrade } = useUpgrade();
|
| 292 |
+
const hostLabel = host === "host_a" ? "Host A" : "Host B";
|
| 293 |
+
|
| 294 |
+
const generate = async () => {
|
| 295 |
+
setBusy("generate");
|
| 296 |
+
setError(null);
|
| 297 |
+
setResult(null);
|
| 298 |
+
setPicked(null);
|
| 299 |
+
try {
|
| 300 |
+
const res = await voiceApi.design(mode === "prompt" ? { prompt } : opts);
|
| 301 |
+
setResult(res);
|
| 302 |
+
setPicked(res.previews[0]?.generated_voice_id ?? null);
|
| 303 |
+
} catch (e) {
|
| 304 |
+
setError(errorMessage(e));
|
| 305 |
+
} finally {
|
| 306 |
+
setBusy(null);
|
| 307 |
+
}
|
| 308 |
+
};
|
| 309 |
+
|
| 310 |
+
const save = async () => {
|
| 311 |
+
if (!result || !picked) return;
|
| 312 |
+
setBusy("save");
|
| 313 |
+
setError(null);
|
| 314 |
+
try {
|
| 315 |
+
onSaved(await voiceApi.saveDesign({ generated_voice_id: picked, name: name.trim(), description: result.description, use_as: host }));
|
| 316 |
+
onClose();
|
| 317 |
+
} catch (e) {
|
| 318 |
+
setError(errorMessage(e));
|
| 319 |
+
} finally {
|
| 320 |
+
setBusy(null);
|
| 321 |
+
}
|
| 322 |
+
};
|
| 323 |
+
|
| 324 |
+
return (
|
| 325 |
+
<Modal title="Generate a new voice" onClose={onClose} wide>
|
| 326 |
+
<div className="stack">
|
| 327 |
+
<Tabs<GenMode>
|
| 328 |
+
tabs={[
|
| 329 |
+
{ id: "options", label: "Build from options" },
|
| 330 |
+
{ id: "prompt", label: "Describe it" },
|
| 331 |
+
{ id: "clone", label: "Clone my voice" },
|
| 332 |
+
]}
|
| 333 |
+
value={mode}
|
| 334 |
+
onChange={(m) => {
|
| 335 |
+
setMode(m);
|
| 336 |
+
setResult(null);
|
| 337 |
+
setError(null);
|
| 338 |
+
}}
|
| 339 |
+
/>
|
| 340 |
+
|
| 341 |
+
{mode === "options" && (
|
| 342 |
+
<div className="gen-options">
|
| 343 |
+
{GEN_OPTIONS.map((g) => (
|
| 344 |
+
<div key={g.key} className="gen-row">
|
| 345 |
+
<span className="mono muted small">{g.label}</span>
|
| 346 |
+
<div className="gen-chips">
|
| 347 |
+
{g.values.map((v) => (
|
| 348 |
+
<button key={v} type="button" className={`chip${opts[g.key] === v ? " on" : ""}`} aria-pressed={opts[g.key] === v} onClick={() => setOpts({ ...opts, [g.key]: v })}>
|
| 349 |
+
{v}
|
| 350 |
+
</button>
|
| 351 |
+
))}
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
))}
|
| 355 |
+
</div>
|
| 356 |
+
)}
|
| 357 |
+
|
| 358 |
+
{mode === "prompt" && (
|
| 359 |
+
<>
|
| 360 |
+
<label className="field">
|
| 361 |
+
<span>Describe the voice you want</span>
|
| 362 |
+
<textarea className="textarea" rows={3} maxLength={1000} placeholder="A calm, low British voice for late night essays" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
| 363 |
+
</label>
|
| 364 |
+
<div className="row between">
|
| 365 |
+
<div className="gen-chips">
|
| 366 |
+
{PROMPT_IDEAS.map((p) => (
|
| 367 |
+
<button key={p} type="button" className="chip" onClick={() => setPrompt(p)}>
|
| 368 |
+
{p}
|
| 369 |
+
</button>
|
| 370 |
+
))}
|
| 371 |
+
</div>
|
| 372 |
+
<span className="mono muted small">{prompt.trim().length} / 1000</span>
|
| 373 |
+
</div>
|
| 374 |
+
</>
|
| 375 |
+
)}
|
| 376 |
+
|
| 377 |
+
{mode === "clone" &&
|
| 378 |
+
(state.clone.allowed ? (
|
| 379 |
+
<>
|
| 380 |
+
<p className="muted">Read a passage from your own writing for one to three minutes. Your voice becomes Host A, so every overview and video sounds like you.</p>
|
| 381 |
+
<div className="row">
|
| 382 |
+
<button
|
| 383 |
+
className="btn btn-primary"
|
| 384 |
+
disabled={!state.tts_configured}
|
| 385 |
+
onClick={() => {
|
| 386 |
+
onClose();
|
| 387 |
+
onClone();
|
| 388 |
+
}}
|
| 389 |
+
>
|
| 390 |
+
{state.clone.status === "ready" ? "Record my voice again" : "Start recording"}
|
| 391 |
+
</button>
|
| 392 |
+
</div>
|
| 393 |
+
</>
|
| 394 |
+
) : (
|
| 395 |
+
<div className="vp-upgrade">
|
| 396 |
+
<div>
|
| 397 |
+
<strong>Voice cloning is on the Writer and Studio plans</strong>
|
| 398 |
+
<p className="muted small">Record a couple of minutes and every audio overview and video is narrated by you.</p>
|
| 399 |
+
</div>
|
| 400 |
+
<button className="btn btn-primary" onClick={() => openUpgrade("writer")}>
|
| 401 |
+
Upgrade to clone my voice
|
| 402 |
+
</button>
|
| 403 |
+
</div>
|
| 404 |
+
))}
|
| 405 |
+
|
| 406 |
+
{mode !== "clone" && (
|
| 407 |
+
<div className="row end">
|
| 408 |
+
<button className="btn btn-primary" onClick={generate} disabled={busy !== null || !state.tts_configured || (mode === "prompt" && prompt.trim().length < 20)}>
|
| 409 |
+
{busy === "generate" ? "Generating previews..." : result ? "Generate again" : "Generate previews"}
|
| 410 |
+
</button>
|
| 411 |
+
</div>
|
| 412 |
+
)}
|
| 413 |
+
|
| 414 |
+
{result && mode !== "clone" && (
|
| 415 |
+
<div className="stack">
|
| 416 |
+
<p className="eyebrow">Pick the one you like</p>
|
| 417 |
+
<ul className="gen-previews">
|
| 418 |
+
{result.previews.map((p, i) => (
|
| 419 |
+
<li key={p.generated_voice_id} className={picked === p.generated_voice_id ? "on" : ""}>
|
| 420 |
+
<label className="check">
|
| 421 |
+
<input type="radio" name="gen-preview" checked={picked === p.generated_voice_id} onChange={() => setPicked(p.generated_voice_id)} />
|
| 422 |
+
Option {i + 1}
|
| 423 |
+
</label>
|
| 424 |
+
<button type="button" className="btn btn-small" onClick={() => play(p.url, p.generated_voice_id)}>
|
| 425 |
+
{playing === p.generated_voice_id ? "Stop" : "Play"}
|
| 426 |
+
</button>
|
| 427 |
+
</li>
|
| 428 |
+
))}
|
| 429 |
+
</ul>
|
| 430 |
+
<div className="row">
|
| 431 |
+
<input className="input input-sm" placeholder="Name it, e.g. Late night narrator" maxLength={60} value={name} onChange={(e) => setName(e.target.value)} aria-label="Voice name" />
|
| 432 |
+
<button className="btn btn-primary" onClick={save} disabled={busy !== null || !picked || !name.trim()}>
|
| 433 |
+
{busy === "save" ? "Saving..." : `Save and use as ${hostLabel}`}
|
| 434 |
+
</button>
|
| 435 |
+
</div>
|
| 436 |
+
</div>
|
| 437 |
+
)}
|
| 438 |
+
{!state.tts_configured && <p className="muted small">Set ELEVENLABS_API_KEY in .env to generate voices.</p>}
|
| 439 |
+
{error && <p className="error-text">{error}</p>}
|
| 440 |
+
</div>
|
| 441 |
+
</Modal>
|
| 442 |
+
);
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
// Voice library (commercially licensed voices from ElevenLabs)
|
| 446 |
|
| 447 |
function LibraryModal({ host, onClose, onAdded }: { host: Host; onClose: () => void; onAdded: (s: VoiceState) => void }) {
|
|
|
|
| 756 |
const [cloneJob, setCloneJob] = useState<Job | null>(null);
|
| 757 |
const [cloning, setCloning] = useState(false);
|
| 758 |
const [browsing, setBrowsing] = useState<Host | null>(null);
|
| 759 |
+
const [generating, setGenerating] = useState<Host | null>(null);
|
| 760 |
const [saved, setSaved] = useState(false);
|
| 761 |
const [error, setError] = useState<string | null>(null);
|
| 762 |
const { play, playing } = usePlayer();
|
|
|
|
| 857 |
<>
|
| 858 |
<label className="field">
|
| 859 |
<span>In a sentence, you sound like this</span>
|
| 860 |
+
<textarea className="textarea" rows={7} value={draft.summary} onChange={(e) => setDraft({ ...draft, summary: e.target.value })} />
|
| 861 |
</label>
|
| 862 |
<details className="delivery">
|
| 863 |
<summary className="mono muted small">Fine-tune tone, phrases and habits</summary>
|
|
|
|
| 905 |
<p className="eyebrow">Step 2 of 3</p>
|
| 906 |
<h2>Pick who reads your work aloud</h2>
|
| 907 |
<p className="muted">
|
| 908 |
+
Audio overviews are a conversation between two hosts, and videos are narrated by Host A. Tap a voice to use it, press play to hear it, or generate your own.
|
| 909 |
</p>
|
| 910 |
{!state.tts_configured && <p className="muted">Set ELEVENLABS_API_KEY in .env to generate audio.</p>}
|
| 911 |
+
<VoicePicker state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} onGenerate={setGenerating} />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 912 |
<div className="row between">
|
| 913 |
<button className="btn btn-small" onClick={() => setStep("writing")}>
|
| 914 |
Back
|
|
|
|
| 1016 |
</Modal>
|
| 1017 |
)}
|
| 1018 |
{cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
|
| 1019 |
+
{generating && (
|
| 1020 |
+
<GenerateVoiceModal
|
| 1021 |
+
host={generating}
|
| 1022 |
+
state={state}
|
| 1023 |
+
onClose={() => setGenerating(null)}
|
| 1024 |
+
onSaved={(s) => {
|
| 1025 |
+
setState(s);
|
| 1026 |
+
loadVoices();
|
| 1027 |
+
}}
|
| 1028 |
+
onClone={() => setCloning(true)}
|
| 1029 |
+
/>
|
| 1030 |
+
)}
|
| 1031 |
{browsing && (
|
| 1032 |
<LibraryModal
|
| 1033 |
host={browsing}
|
frontend/src/styles/pages.css
CHANGED
|
@@ -2030,12 +2030,189 @@ select.input option {
|
|
| 2030 |
box-shadow: var(--glow-sm);
|
| 2031 |
}
|
| 2032 |
|
| 2033 |
-
.
|
|
|
|
|
|
|
|
|
|
| 2034 |
display: grid;
|
| 2035 |
-
grid-template-columns: repeat(
|
| 2036 |
gap: 12px;
|
| 2037 |
}
|
| 2038 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2039 |
.vp-how {
|
| 2040 |
list-style: none;
|
| 2041 |
margin: 0;
|
|
|
|
| 2030 |
box-shadow: var(--glow-sm);
|
| 2031 |
}
|
| 2032 |
|
| 2033 |
+
.voice-grid {
|
| 2034 |
+
list-style: none;
|
| 2035 |
+
margin: 0;
|
| 2036 |
+
padding: 0;
|
| 2037 |
display: grid;
|
| 2038 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 2039 |
gap: 12px;
|
| 2040 |
}
|
| 2041 |
|
| 2042 |
+
@media (max-width: 720px) {
|
| 2043 |
+
.voice-grid {
|
| 2044 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 2045 |
+
}
|
| 2046 |
+
}
|
| 2047 |
+
|
| 2048 |
+
.voice-tile {
|
| 2049 |
+
position: relative;
|
| 2050 |
+
border: 1px solid var(--line);
|
| 2051 |
+
border-radius: var(--radius);
|
| 2052 |
+
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s, transform 0.2s;
|
| 2053 |
+
}
|
| 2054 |
+
|
| 2055 |
+
.voice-tile:hover {
|
| 2056 |
+
border-color: var(--line-strong);
|
| 2057 |
+
transform: translateY(-2px);
|
| 2058 |
+
}
|
| 2059 |
+
|
| 2060 |
+
.voice-tile.on {
|
| 2061 |
+
border-color: var(--blue);
|
| 2062 |
+
background: var(--blue-05);
|
| 2063 |
+
box-shadow: var(--glow-sm);
|
| 2064 |
+
}
|
| 2065 |
+
|
| 2066 |
+
.voice-pick {
|
| 2067 |
+
width: 100%;
|
| 2068 |
+
display: grid;
|
| 2069 |
+
justify-items: center;
|
| 2070 |
+
gap: 4px;
|
| 2071 |
+
padding: 26px 12px 18px;
|
| 2072 |
+
border: 0;
|
| 2073 |
+
background: none;
|
| 2074 |
+
color: var(--white);
|
| 2075 |
+
font: inherit;
|
| 2076 |
+
text-align: center;
|
| 2077 |
+
cursor: pointer;
|
| 2078 |
+
}
|
| 2079 |
+
|
| 2080 |
+
.voice-pick strong {
|
| 2081 |
+
margin-top: 10px;
|
| 2082 |
+
font-size: 1rem;
|
| 2083 |
+
}
|
| 2084 |
+
|
| 2085 |
+
.voice-pick strong,
|
| 2086 |
+
.voice-pick span.small {
|
| 2087 |
+
max-width: 100%;
|
| 2088 |
+
overflow: hidden;
|
| 2089 |
+
text-overflow: ellipsis;
|
| 2090 |
+
white-space: nowrap;
|
| 2091 |
+
}
|
| 2092 |
+
|
| 2093 |
+
.voice-icon {
|
| 2094 |
+
display: grid;
|
| 2095 |
+
place-items: center;
|
| 2096 |
+
width: 76px;
|
| 2097 |
+
height: 76px;
|
| 2098 |
+
border-radius: 999px;
|
| 2099 |
+
border: 1px solid var(--blue-35);
|
| 2100 |
+
color: var(--blue);
|
| 2101 |
+
background: var(--blue-05);
|
| 2102 |
+
transition: background 0.2s, color 0.2s;
|
| 2103 |
+
}
|
| 2104 |
+
|
| 2105 |
+
.voice-tile.on .voice-icon {
|
| 2106 |
+
background: var(--blue);
|
| 2107 |
+
border-color: var(--blue);
|
| 2108 |
+
color: var(--white);
|
| 2109 |
+
box-shadow: var(--glow-sm);
|
| 2110 |
+
}
|
| 2111 |
+
|
| 2112 |
+
.voice-badge {
|
| 2113 |
+
position: absolute;
|
| 2114 |
+
top: 10px;
|
| 2115 |
+
left: 10px;
|
| 2116 |
+
padding: 1px 7px;
|
| 2117 |
+
border-radius: 999px;
|
| 2118 |
+
background: var(--blue);
|
| 2119 |
+
color: var(--white);
|
| 2120 |
+
font-size: 0.625rem;
|
| 2121 |
+
letter-spacing: 0.04em;
|
| 2122 |
+
pointer-events: none;
|
| 2123 |
+
}
|
| 2124 |
+
|
| 2125 |
+
.voice-badge.dim {
|
| 2126 |
+
background: var(--line-strong);
|
| 2127 |
+
color: var(--text-2);
|
| 2128 |
+
}
|
| 2129 |
+
|
| 2130 |
+
.voice-play {
|
| 2131 |
+
position: absolute;
|
| 2132 |
+
top: 8px;
|
| 2133 |
+
right: 8px;
|
| 2134 |
+
padding: 0;
|
| 2135 |
+
border-color: var(--line-strong);
|
| 2136 |
+
}
|
| 2137 |
+
|
| 2138 |
+
.voice-new {
|
| 2139 |
+
border-style: dashed;
|
| 2140 |
+
border-color: var(--line-strong);
|
| 2141 |
+
}
|
| 2142 |
+
|
| 2143 |
+
.voice-new:hover {
|
| 2144 |
+
border-color: var(--blue-60);
|
| 2145 |
+
}
|
| 2146 |
+
|
| 2147 |
+
.voice-icon.plus {
|
| 2148 |
+
border-style: dashed;
|
| 2149 |
+
background: transparent;
|
| 2150 |
+
}
|
| 2151 |
+
|
| 2152 |
+
.voice-new:hover .voice-icon.plus {
|
| 2153 |
+
color: var(--white);
|
| 2154 |
+
border-color: var(--blue);
|
| 2155 |
+
}
|
| 2156 |
+
|
| 2157 |
+
.gen-options {
|
| 2158 |
+
display: grid;
|
| 2159 |
+
gap: 12px;
|
| 2160 |
+
}
|
| 2161 |
+
|
| 2162 |
+
.gen-row {
|
| 2163 |
+
display: grid;
|
| 2164 |
+
gap: 6px;
|
| 2165 |
+
}
|
| 2166 |
+
|
| 2167 |
+
.gen-chips {
|
| 2168 |
+
display: flex;
|
| 2169 |
+
flex-wrap: wrap;
|
| 2170 |
+
gap: 6px;
|
| 2171 |
+
}
|
| 2172 |
+
|
| 2173 |
+
.gen-chips .chip {
|
| 2174 |
+
padding: 5px 12px;
|
| 2175 |
+
background: transparent;
|
| 2176 |
+
color: var(--text-2);
|
| 2177 |
+
font: inherit;
|
| 2178 |
+
font-size: 0.8125rem;
|
| 2179 |
+
cursor: pointer;
|
| 2180 |
+
text-transform: capitalize;
|
| 2181 |
+
}
|
| 2182 |
+
|
| 2183 |
+
.gen-chips .chip:hover {
|
| 2184 |
+
color: var(--white);
|
| 2185 |
+
}
|
| 2186 |
+
|
| 2187 |
+
.gen-chips .chip.on {
|
| 2188 |
+
background: var(--blue);
|
| 2189 |
+
border-color: var(--blue);
|
| 2190 |
+
color: var(--white);
|
| 2191 |
+
}
|
| 2192 |
+
|
| 2193 |
+
.gen-previews {
|
| 2194 |
+
list-style: none;
|
| 2195 |
+
margin: 0;
|
| 2196 |
+
padding: 0;
|
| 2197 |
+
display: grid;
|
| 2198 |
+
gap: 8px;
|
| 2199 |
+
}
|
| 2200 |
+
|
| 2201 |
+
.gen-previews li {
|
| 2202 |
+
display: flex;
|
| 2203 |
+
align-items: center;
|
| 2204 |
+
justify-content: space-between;
|
| 2205 |
+
gap: 12px;
|
| 2206 |
+
padding: 10px 12px;
|
| 2207 |
+
border: 1px solid var(--line);
|
| 2208 |
+
border-radius: var(--radius-sm);
|
| 2209 |
+
}
|
| 2210 |
+
|
| 2211 |
+
.gen-previews li.on {
|
| 2212 |
+
border-color: var(--blue);
|
| 2213 |
+
background: var(--blue-05);
|
| 2214 |
+
}
|
| 2215 |
+
|
| 2216 |
.vp-how {
|
| 2217 |
list-style: none;
|
| 2218 |
margin: 0;
|
frontend/src/styles/tools.css
CHANGED
|
@@ -23,12 +23,34 @@
|
|
| 23 |
gap: 1rem;
|
| 24 |
}
|
| 25 |
|
|
|
|
|
|
|
| 26 |
.tool-input {
|
|
|
|
| 27 |
width: 100%;
|
|
|
|
| 28 |
min-height: 14rem;
|
|
|
|
| 29 |
resize: vertical;
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
.tool-output {
|
|
|
|
| 23 |
gap: 1rem;
|
| 24 |
}
|
| 25 |
|
| 26 |
+
/* Flat, opaque writing surface: overrides the pill shaped, see-through .input so the starfield does not show
|
| 27 |
+
through and the box edge is obvious. */
|
| 28 |
.tool-input {
|
| 29 |
+
display: block;
|
| 30 |
width: 100%;
|
| 31 |
+
height: auto;
|
| 32 |
min-height: 14rem;
|
| 33 |
+
padding: 14px 16px;
|
| 34 |
resize: vertical;
|
| 35 |
+
border-radius: 8px;
|
| 36 |
+
border: 1px solid rgba(255, 255, 255, 0.28);
|
| 37 |
+
background: #0d0f14;
|
| 38 |
+
box-shadow: none;
|
| 39 |
+
color: var(--text);
|
| 40 |
+
font: 400 1rem/1.6 var(--font-body);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.tool-input:hover {
|
| 44 |
+
border-color: rgba(255, 255, 255, 0.4);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.tool-input:focus {
|
| 48 |
+
border-color: var(--blue);
|
| 49 |
+
box-shadow: none;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.tool-input::placeholder {
|
| 53 |
+
color: var(--text-2);
|
| 54 |
}
|
| 55 |
|
| 56 |
.tool-output {
|