Muhammad-Mehdi commited on
Commit
e1222dc
·
1 Parent(s): ebe8d3a

library added

Browse files
backend/app/pipeline/media.py CHANGED
@@ -64,7 +64,8 @@ def audio_overview(db: Session, job: Job, artifact: Artifact) -> dict:
64
  if not lines:
65
  raise RuntimeError("The script came back empty")
66
  tools = tools_for(corpus, docs)
67
- voice_a, voice_b = host_voices(db, artifact.workspace_id)
 
68
  delivery = host_settings(db, artifact.workspace_id)
69
  script = [
70
  tts.Line(text=ln["text"], voice_id=voice_b if ln.get("speaker") == "host_b" else voice_a,
 
64
  if not lines:
65
  raise RuntimeError("The script came back empty")
66
  tools = tools_for(corpus, docs)
67
+ default_a, default_b = host_voices(db, artifact.workspace_id)
68
+ voice_a, voice_b = params.get("host_a") or default_a, params.get("host_b") or default_b # picked when made
69
  delivery = host_settings(db, artifact.workspace_id)
70
  script = [
71
  tts.Line(text=ln["text"], voice_id=voice_b if ln.get("speaker") == "host_b" else voice_a,
backend/app/routers/artifacts.py CHANGED
@@ -6,7 +6,8 @@ from pydantic import BaseModel, Field
6
  from sqlalchemy import func, or_, select
7
 
8
  from app.auth import Ctx, get_ctx
9
- from app.models import Artifact, CalendarItem, Document, Job, Notebook
 
10
  from app.routers.notebooks import ensure_archive_notebook
11
  from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
12
  from app.services.jobs import create_job, serialize_job
@@ -33,6 +34,9 @@ class GenerateIn(BaseModel):
33
  # audio_overview
34
  format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
35
  minutes: int = Field(6, ge=1, le=30)
 
 
 
36
  # video: only audiograms here; other videos are made by blog2video (/api/videos)
37
  style: str = "audiogram"
38
  audio_artifact_id: uuid.UUID | None = None
@@ -44,6 +48,18 @@ class GenerateIn(BaseModel):
44
  focus: str | None = Field(None, max_length=500)
45
 
46
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  class PatchIn(BaseModel):
48
  title: str | None = Field(None, max_length=300)
49
  content: dict | None = None
@@ -102,7 +118,7 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
102
  target = _target_title(ctx, body.notebook_id, body.document_id)
103
  if body.type == "audio_overview":
104
  check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
105
- params = {"format": body.format, "minutes": body.minutes}
106
  title = f"Audio overview: {target}"
107
  elif body.type == "mind_map":
108
  if not body.notebook_id:
 
6
  from sqlalchemy import func, or_, select
7
 
8
  from app.auth import Ctx, get_ctx
9
+ from app.config import settings
10
+ from app.models import Artifact, CalendarItem, Document, Job, Notebook, UserSavedVoice
11
  from app.routers.notebooks import ensure_archive_notebook
12
  from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
13
  from app.services.jobs import create_job, serialize_job
 
34
  # audio_overview
35
  format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
36
  minutes: int = Field(6, ge=1, le=30)
37
+ # the two hosts' voices, from the workspace's voices (none: the defaults)
38
+ host_a: str | None = Field(None, max_length=100)
39
+ host_b: str | None = Field(None, max_length=100)
40
  # video: only audiograms here; other videos are made by blog2video (/api/videos)
41
  style: str = "audiogram"
42
  audio_artifact_id: uuid.UUID | None = None
 
48
  focus: str | None = Field(None, max_length=500)
49
 
50
 
51
+ def _hosts(ctx: Ctx, body: GenerateIn) -> dict:
52
+ """The voices picked for an audio overview's two hosts: each one from the workspace's voices (or a default)."""
53
+ picked = {k: v for k, v in (("host_a", body.host_a), ("host_b", body.host_b)) if v}
54
+ if picked:
55
+ ok = set(ctx.db.scalars(select(UserSavedVoice.voice_id).where(UserSavedVoice.workspace_id == ctx.workspace.id,
56
+ UserSavedVoice.voice_id.in_(picked.values()))))
57
+ ok |= {settings.elevenlabs_voice_a, settings.elevenlabs_voice_b}
58
+ if any(v not in ok for v in picked.values()):
59
+ raise HTTPException(400, "Pick the hosts' voices from your voices")
60
+ return picked
61
+
62
+
63
  class PatchIn(BaseModel):
64
  title: str | None = Field(None, max_length=300)
65
  content: dict | None = None
 
118
  target = _target_title(ctx, body.notebook_id, body.document_id)
119
  if body.type == "audio_overview":
120
  check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
121
+ params = {"format": body.format, "minutes": body.minutes, **_hosts(ctx, body)}
122
  title = f"Audio overview: {target}"
123
  elif body.type == "mind_map":
124
  if not body.notebook_id:
backend/app/routers/video_voices.py CHANGED
@@ -1,8 +1,8 @@
1
  """Voices for videos: the workspace's saved voices, the built-in library, custom voices (design, clone), and its
2
  Notestack voices (made on the Voice page; blog2video speaks with the same ElevenLabs account).
3
 
4
- "My voices" (user_saved_voices, at most MAX_SAVED_VOICES) lives only here: blog2video's saved-voice list is one list
5
- for the whole account.
6
  Custom voices are made on our blog2video account, so b2v_custom_voices records which workspace made each one;
7
  only that workspace can use, play or delete it. Designing, keeping, cloning and samples are premium (★) and come out
8
  of the workspace's own daily and total counters (video_limits).
@@ -20,7 +20,7 @@ from app.models import B2VCustomVoice, UserSavedVoice
20
  from app.routers.videos import b2v_ready, upstream
21
  from app.services import b2v_access, video_limits
22
  from app.services import blog2video as b2v
23
- from app.services.notestack_voices import notestack_voices
24
 
25
  log = logging.getLogger(__name__)
26
 
@@ -28,7 +28,6 @@ router = APIRouter(prefix="/api/video-voices", tags=["videos"])
28
 
29
  CLONE_MAX_BYTES = 50 * 1024 * 1024
30
  STARTER_VOICES = 4 # a new workspace starts with this many free built-in voices saved
31
- MAX_SAVED_VOICES = 5 # "My voices": what step 3 of a new video offers
32
 
33
 
34
  def _labels(v: dict) -> dict:
@@ -41,14 +40,6 @@ def _voice_out(v: UserSavedVoice, ours: set[str] = frozenset()) -> dict:
41
  "source": "notestack" if v.voice_id in ours else "blog2video"}
42
 
43
 
44
- def _room(ctx: Ctx) -> bool:
45
- return len(_saved(ctx)) < MAX_SAVED_VOICES
46
-
47
-
48
- def _full() -> HTTPException:
49
- return HTTPException(409, f"You can keep {MAX_SAVED_VOICES} voices. Remove one first.")
50
-
51
-
52
  def _library_out(v: dict, saved: set[str]) -> dict:
53
  labels = _labels(v)
54
  return {"voice_id": v["voice_id"], "name": (v.get("name") or "Voice").split(" - ")[0].strip(),
@@ -74,7 +65,7 @@ def _saved(ctx: Ctx) -> list[UserSavedVoice]:
74
 
75
  @router.get("")
76
  def voices(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
77
- """My voices (for the wizard), the built-in library (to save from) and my custom voices."""
78
  with upstream():
79
  library = b2v.prebuilt_voices()
80
  saved = _saved(ctx)
@@ -88,7 +79,7 @@ def voices(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
88
  .order_by(B2VCustomVoice.created_at.desc()))
89
  mine = notestack_voices(ctx.db, ctx.workspace.id)
90
  ours = {v["voice_id"] for v in mine}
91
- return {"saved": [_voice_out(v, ours) for v in saved], "max_saved": MAX_SAVED_VOICES,
92
  "notestack": [{**v, "saved": v["voice_id"] in ids} for v in mine],
93
  "library": [_library_out(v, ids) for v in library if v.get("voice_id")],
94
  "custom": [{"id": c.b2v_custom_voice_id, "voice_id": c.voice_id, "name": c.name, "source": c.source,
@@ -103,16 +94,12 @@ class SaveIn(BaseModel):
103
  def save_voice(body: SaveIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
104
  if ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}):
105
  return _voice_out(ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}))
106
- if not _room(ctx):
107
- raise _full()
108
  custom = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == body.voice_id,
109
  B2VCustomVoice.workspace_id == ctx.workspace.id))
110
  mine = next((v for v in notestack_voices(ctx.db, ctx.workspace.id) if v["voice_id"] == body.voice_id), None)
111
  if mine:
112
- # A Notestack voice: premium, like a custom voice. No preview link (the Voice page plays it).
113
- video_limits.require_premium(ctx.db, ctx.workspace, "Custom voices")
114
- row = ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=mine["voice_id"],
115
- name=mine["name"][:255], premium=True, is_custom=True))
116
  ctx.db.commit()
117
  return _voice_out(row, {row.voice_id})
118
  if custom:
@@ -191,13 +178,11 @@ def _record_custom(ctx: Ctx, made: dict, source: str) -> dict:
191
  voice_id=made["voice_id"], name=(made.get("name") or "My voice")[:255], source=source,
192
  preview_url=made.get("preview_url"))
193
  ctx.db.add(row)
194
- saved = _room(ctx) # kept in My voices only while there is room; otherwise added from the list later
195
- if saved:
196
- ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=row.voice_id, name=row.name,
197
- preview_url=row.preview_url, premium=True, is_custom=True))
198
  ctx.db.commit()
199
  return {"id": row.b2v_custom_voice_id, "voice_id": row.voice_id, "name": row.name, "source": source,
200
- "preview_url": row.preview_url, "saved": saved}
201
 
202
 
203
  def _make_custom(ctx: Ctx, source: str, send) -> dict:
 
1
  """Voices for videos: the workspace's saved voices, the built-in library, custom voices (design, clone), and its
2
  Notestack voices (made on the Voice page; blog2video speaks with the same ElevenLabs account).
3
 
4
+ "Your voices" (user_saved_voices, as many as wanted) is the one list for audio overviews and videos, and lives only
5
+ here: blog2video's saved-voice list is one list for the whole account.
6
  Custom voices are made on our blog2video account, so b2v_custom_voices records which workspace made each one;
7
  only that workspace can use, play or delete it. Designing, keeping, cloning and samples are premium (★) and come out
8
  of the workspace's own daily and total counters (video_limits).
 
20
  from app.routers.videos import b2v_ready, upstream
21
  from app.services import b2v_access, video_limits
22
  from app.services import blog2video as b2v
23
+ from app.services.notestack_voices import notestack_voices, save_notestack_voice
24
 
25
  log = logging.getLogger(__name__)
26
 
 
28
 
29
  CLONE_MAX_BYTES = 50 * 1024 * 1024
30
  STARTER_VOICES = 4 # a new workspace starts with this many free built-in voices saved
 
31
 
32
 
33
  def _labels(v: dict) -> dict:
 
40
  "source": "notestack" if v.voice_id in ours else "blog2video"}
41
 
42
 
 
 
 
 
 
 
 
 
43
  def _library_out(v: dict, saved: set[str]) -> dict:
44
  labels = _labels(v)
45
  return {"voice_id": v["voice_id"], "name": (v.get("name") or "Voice").split(" - ")[0].strip(),
 
65
 
66
  @router.get("")
67
  def voices(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
68
+ """Your voices (audio overviews and videos), the built-in library (to save from) and my custom voices."""
69
  with upstream():
70
  library = b2v.prebuilt_voices()
71
  saved = _saved(ctx)
 
79
  .order_by(B2VCustomVoice.created_at.desc()))
80
  mine = notestack_voices(ctx.db, ctx.workspace.id)
81
  ours = {v["voice_id"] for v in mine}
82
+ return {"saved": [_voice_out(v, ours) for v in saved],
83
  "notestack": [{**v, "saved": v["voice_id"] in ids} for v in mine],
84
  "library": [_library_out(v, ids) for v in library if v.get("voice_id")],
85
  "custom": [{"id": c.b2v_custom_voice_id, "voice_id": c.voice_id, "name": c.name, "source": c.source,
 
94
  def save_voice(body: SaveIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
95
  if ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}):
96
  return _voice_out(ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}))
 
 
97
  custom = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == body.voice_id,
98
  B2VCustomVoice.workspace_id == ctx.workspace.id))
99
  mine = next((v for v in notestack_voices(ctx.db, ctx.workspace.id) if v["voice_id"] == body.voice_id), None)
100
  if mine:
101
+ # A Notestack voice: saved on any plan, premium to use in a video. No preview link (the Voice page plays it).
102
+ row = save_notestack_voice(ctx.db, ctx.workspace.id, mine["voice_id"], mine["name"])
 
 
103
  ctx.db.commit()
104
  return _voice_out(row, {row.voice_id})
105
  if custom:
 
178
  voice_id=made["voice_id"], name=(made.get("name") or "My voice")[:255], source=source,
179
  preview_url=made.get("preview_url"))
180
  ctx.db.add(row)
181
+ ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=row.voice_id, name=row.name,
182
+ preview_url=row.preview_url, premium=True, is_custom=True)) # a new voice is saved
 
 
183
  ctx.db.commit()
184
  return {"id": row.b2v_custom_voice_id, "voice_id": row.voice_id, "name": row.name, "source": source,
185
+ "preview_url": row.preview_url, "saved": True}
186
 
187
 
188
  def _make_custom(ctx: Ctx, source: str, send) -> dict:
backend/app/routers/voice.py CHANGED
@@ -12,7 +12,7 @@ from app.config import settings
12
  from app.models import Document, Job, Upload, VoiceConsent, VoiceProfile
13
  from app.services import tts
14
  from app.services.jobs import create_job, record_usage, serialize_job
15
- from app.services.notestack_voices import forget_video_voice
16
  from app.services.plans import effective_plan, plan_limit_error
17
  from app.services.storage import storage
18
 
@@ -215,6 +215,7 @@ async def add_from_library(body: LibraryAddIn, ctx: Ctx = Depends(get_ctx)):
215
  if body.use_as in ("host_a", "host_b"):
216
  voices[body.use_as] = voice_id
217
  vp.host_voices = voices
 
218
  ctx.db.commit()
219
  return {"voice_id": voice_id, **_serialize(ctx)}
220
 
@@ -293,6 +294,7 @@ async def save_design(body: DesignSaveIn, ctx: Ctx = Depends(get_ctx)):
293
  if body.use_as in ("host_a", "host_b"):
294
  voices[body.use_as] = voice_id
295
  vp.host_voices = voices
 
296
  ctx.db.commit()
297
  return {"voice_id": voice_id, **_serialize(ctx)}
298
 
 
12
  from app.models import Document, Job, Upload, VoiceConsent, VoiceProfile
13
  from app.services import tts
14
  from app.services.jobs import create_job, record_usage, serialize_job
15
+ from app.services.notestack_voices import forget_video_voice, save_notestack_voice
16
  from app.services.plans import effective_plan, plan_limit_error
17
  from app.services.storage import storage
18
 
 
215
  if body.use_as in ("host_a", "host_b"):
216
  voices[body.use_as] = voice_id
217
  vp.host_voices = voices
218
+ save_notestack_voice(ctx.db, ctx.workspace.id, voice_id, body.name) # a voice added is a voice saved
219
  ctx.db.commit()
220
  return {"voice_id": voice_id, **_serialize(ctx)}
221
 
 
294
  if body.use_as in ("host_a", "host_b"):
295
  voices[body.use_as] = voice_id
296
  vp.host_voices = voices
297
+ save_notestack_voice(ctx.db, ctx.workspace.id, voice_id, body.name) # a voice made is a voice saved
298
  ctx.db.commit()
299
  return {"voice_id": voice_id, **_serialize(ctx)}
300
 
backend/app/services/notestack_voices.py CHANGED
@@ -31,6 +31,18 @@ def is_notestack_voice(db: Session, workspace_id: uuid.UUID, voice_id: str) -> b
31
  return any(v["voice_id"] == voice_id for v in notestack_voices(db, workspace_id))
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  def forget_video_voice(db: Session, workspace_id: uuid.UUID, voice_id: str) -> None:
35
  """A Notestack voice that is gone (clone revoked) leaves the workspace's saved video voices too."""
36
  db.execute(delete(UserSavedVoice).where(UserSavedVoice.workspace_id == workspace_id,
 
31
  return any(v["voice_id"] == voice_id for v in notestack_voices(db, workspace_id))
32
 
33
 
34
+ def save_notestack_voice(db: Session, workspace_id: uuid.UUID, voice_id: str, name: str) -> UserSavedVoice:
35
+ """Put a Notestack voice in the workspace's voices (the one list for audio and video), once. Saving needs no plan:
36
+ using it in a video is premium (b2v_access.check_voice). No preview link: it is played through /api/voice/preview.
37
+ The caller commits."""
38
+ row = db.get(UserSavedVoice, {"workspace_id": workspace_id, "voice_id": voice_id})
39
+ if row is None:
40
+ row = UserSavedVoice(workspace_id=workspace_id, voice_id=voice_id, name=(name or "Voice")[:255], premium=True,
41
+ is_custom=True)
42
+ db.add(row)
43
+ return row
44
+
45
+
46
  def forget_video_voice(db: Session, workspace_id: uuid.UUID, voice_id: str) -> None:
47
  """A Notestack voice that is gone (clone revoked) leaves the workspace's saved video voices too."""
48
  db.execute(delete(UserSavedVoice).where(UserSavedVoice.workspace_id == workspace_id,
backend/app/worker.py CHANGED
@@ -61,6 +61,7 @@ from app.services.jobs import (
61
  stale_jobs,
62
  update_job,
63
  )
 
64
  from app.services.plans import effective_plan
65
  from app.services.renderer import PermanentJobError, request_render
66
  from app.services.storage import keys, storage
@@ -212,13 +213,15 @@ def handle_voice_clone(db: Session, job: Job):
212
  consent.elevenlabs_voice_id = voice_id
213
  db.commit()
214
 
215
- # Make the new voice host A and record a short preview so the writer can judge it straight away.
 
216
  update_job(db, job, progress=0.7, message="Recording a preview in your voice")
217
  vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == consent.workspace_id))
218
  if not vp:
219
  vp = VoiceProfile(workspace_id=consent.workspace_id)
220
  db.add(vp)
221
  vp.host_voices = {**(vp.host_voices or {}), "host_a": voice_id}
 
222
  db.commit()
223
  preview_text = job.params.get("preview_text") or (
224
  "Hi, this is my Notestack voice. From now on, my audio overviews and videos can sound like me.")
 
61
  stale_jobs,
62
  update_job,
63
  )
64
+ from app.services.notestack_voices import save_notestack_voice
65
  from app.services.plans import effective_plan
66
  from app.services.renderer import PermanentJobError, request_render
67
  from app.services.storage import keys, storage
 
213
  consent.elevenlabs_voice_id = voice_id
214
  db.commit()
215
 
216
+ # Save the new voice to the workspace's voices (and keep it as the default host A), then record a short preview
217
+ # so the writer can judge it straight away.
218
  update_job(db, job, progress=0.7, message="Recording a preview in your voice")
219
  vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == consent.workspace_id))
220
  if not vp:
221
  vp = VoiceProfile(workspace_id=consent.workspace_id)
222
  db.add(vp)
223
  vp.host_voices = {**(vp.host_voices or {}), "host_a": voice_id}
224
+ save_notestack_voice(db, consent.workspace_id, voice_id, "My voice")
225
  db.commit()
226
  preview_text = job.params.get("preview_text") or (
227
  "Hi, this is my Notestack voice. From now on, my audio overviews and videos can sound like me.")
backend/tests/test_buildout.py CHANGED
@@ -18,6 +18,7 @@ from app.models import (
18
  Source,
19
  TrackedLink,
20
  UsageEvent,
 
21
  Workspace,
22
  )
23
  from app.services.email import ConsoleEmailProvider
@@ -766,7 +767,13 @@ def test_artifact_ownership(client, auth, db_session):
766
  # Voice pipeline (ElevenLabs stubbed)
767
 
768
 
769
- def test_voice_library_preview_and_script(client, auth, monkeypatch, feed, run_jobs):
 
 
 
 
 
 
770
  from app.config import settings
771
  from app.services import tts
772
 
@@ -783,6 +790,7 @@ def test_voice_library_preview_and_script(client, auth, monkeypatch, feed, run_j
783
  "public_owner_id": "owner-1", "voice_id": "lib-voice-1", "name": "Narrator",
784
  "use_as": "host_b"}, headers=auth).json()
785
  assert r["voice_id"] == "added-lib-voice-1" and r["host_voices"]["host_b"] == "added-lib-voice-1"
 
786
 
787
  prev = client.post("/api/voice/preview", json={"voice_id": "added-lib-voice-1", "text": "Hello there",
788
  "delivery": {"stability": 0.3, "similarity_boost": 0.8,
@@ -794,7 +802,7 @@ def test_voice_library_preview_and_script(client, auth, monkeypatch, feed, run_j
794
  assert script["words"] > 5 and script["title"]
795
 
796
 
797
- def test_voice_clone_multi_sample_sets_host_and_preview(client, auth, monkeypatch, run_jobs):
798
  from app.config import settings
799
  from app.services import tts
800
 
@@ -823,6 +831,54 @@ def test_voice_clone_multi_sample_sets_host_and_preview(client, auth, monkeypatc
823
  voice = client.get("/api/voice", headers=auth).json()
824
  assert voice["clone"]["status"] == "ready" and voice["host_voices"]["host_a"] == "clone-1"
825
  assert voice["clone"]["preview_url"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
826
 
827
 
828
  def test_tts_retries_rate_limits(monkeypatch):
 
18
  Source,
19
  TrackedLink,
20
  UsageEvent,
21
+ UserSavedVoice,
22
  Workspace,
23
  )
24
  from app.services.email import ConsoleEmailProvider
 
767
  # Voice pipeline (ElevenLabs stubbed)
768
 
769
 
770
+ def saved_voices(db_session) -> dict[str, str]:
771
+ """The workspace's voices (the one list for audio and video): voice id -> name."""
772
+ db_session.expire_all()
773
+ return {v.voice_id: v.name for v in db_session.query(UserSavedVoice)}
774
+
775
+
776
+ def test_voice_library_preview_and_script(client, auth, monkeypatch, feed, run_jobs, db_session):
777
  from app.config import settings
778
  from app.services import tts
779
 
 
790
  "public_owner_id": "owner-1", "voice_id": "lib-voice-1", "name": "Narrator",
791
  "use_as": "host_b"}, headers=auth).json()
792
  assert r["voice_id"] == "added-lib-voice-1" and r["host_voices"]["host_b"] == "added-lib-voice-1"
793
+ assert saved_voices(db_session) == {"added-lib-voice-1": "Narrator"} # added, so saved
794
 
795
  prev = client.post("/api/voice/preview", json={"voice_id": "added-lib-voice-1", "text": "Hello there",
796
  "delivery": {"stability": 0.3, "similarity_boost": 0.8,
 
802
  assert script["words"] > 5 and script["title"]
803
 
804
 
805
+ def test_voice_clone_multi_sample_sets_host_and_preview(client, auth, monkeypatch, run_jobs, db_session):
806
  from app.config import settings
807
  from app.services import tts
808
 
 
831
  voice = client.get("/api/voice", headers=auth).json()
832
  assert voice["clone"]["status"] == "ready" and voice["host_voices"]["host_a"] == "clone-1"
833
  assert voice["clone"]["preview_url"]
834
+ assert saved_voices(db_session) == {"clone-1": "My voice"}
835
+
836
+
837
+ def test_a_designed_voice_is_saved_to_the_voices(client, auth, monkeypatch, db_session):
838
+ from app.config import settings
839
+ from app.services import tts
840
+
841
+ monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
842
+ monkeypatch.setattr(tts, "create_designed_voice", lambda name, description, generated: "designed-1")
843
+ r = client.post("/api/voice/design/save", json={"generated_voice_id": "gen-12345", "name": "Narrator",
844
+ "description": "A calm narrator"}, headers=auth)
845
+ assert r.status_code == 200 and r.json()["voice_id"] == "designed-1"
846
+ assert saved_voices(db_session) == {"designed-1": "Narrator"}
847
+ client.post("/api/voice/design/save", json={"generated_voice_id": "gen-12345", "name": "Narrator"}, headers=auth)
848
+ assert len(saved_voices(db_session)) == 1 # saved once
849
+
850
+
851
+ def test_audio_overview_hosts_are_picked_from_the_voices(client, auth, run_jobs, feed, llm, monkeypatch,
852
+ db_session):
853
+ from app.config import settings
854
+ from app.services import tts
855
+
856
+ connect(client, auth, run_jobs)
857
+ doc = docs(client, auth)[0]
858
+ monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
859
+ used = []
860
+ monkeypatch.setattr(tts, "synthesize", lambda ws, text, voice, vs=None, *a: used.append((text, voice)) or
861
+ b"\x00" * 16000)
862
+ llm["PodcastScript"] = {"lines": [{"speaker": "host_a", "text": "Welcome in.", "sources": []},
863
+ {"speaker": "host_b", "text": "Thanks.", "sources": []}]}
864
+ ws_id = db_session.get(Document, uuid.UUID(doc["id"])).workspace_id
865
+ db_session.add_all([UserSavedVoice(workspace_id=ws_id, voice_id=v, name=v) for v in ("mine-1", "mine-2")])
866
+ db_session.commit()
867
+ body = {"type": "audio_overview", "document_id": doc["id"], "minutes": 3}
868
+ art = client.post("/api/artifacts/generate", json={**body, "host_a": "mine-2", "host_b": "mine-1"},
869
+ headers=auth).json()
870
+ run_jobs()
871
+ done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
872
+ assert done["content"]["voices"] == {"host_a": "mine-2", "host_b": "mine-1"}
873
+ assert dict(used) == {"Welcome in.": "mine-2", "Thanks.": "mine-1"}
874
+ # Only the workspace's own voices (or the defaults).
875
+ r = client.post("/api/artifacts/generate", json={**body, "host_a": "someone-elses"}, headers=auth)
876
+ assert r.status_code == 400
877
+ # None picked: the defaults, as before.
878
+ art = client.post("/api/artifacts/generate", json=body, headers=auth).json()
879
+ run_jobs()
880
+ voices = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["content"]["voices"]
881
+ assert voices == {"host_a": settings.elevenlabs_voice_a, "host_b": settings.elevenlabs_voice_b}
882
 
883
 
884
  def test_tts_retries_rate_limits(monkeypatch):
backend/tests/test_videos.py CHANGED
@@ -1157,15 +1157,15 @@ def notestack_voice(db, ws, voice_id="nv_narrator", name="Narrator") -> None:
1157
  db.commit()
1158
 
1159
 
1160
- def test_my_voices_are_capped_at_five(client, b2v, owner, db_session):
1161
  headers, ws = owner
1162
  client.get("/api/video-voices", headers=headers) # the starter voice
1163
- for i in range(4):
1164
  db_session.add(UserSavedVoice(workspace_id=ws.id, voice_id=f"v{i}", name=f"V{i}"))
1165
  db_session.commit()
1166
- r = client.post("/api/video-voices/saved", json={"voice_id": BELLA}, headers=headers)
1167
- assert r.status_code == 409 and "5 voices" in r.json()["detail"]
1168
- assert client.get("/api/video-voices", headers=headers).json()["max_saved"] == 5
1169
 
1170
 
1171
  def test_notestack_voices_can_be_saved_and_used_in_a_video(client, b2v, owner, other, db_session):
@@ -1185,11 +1185,14 @@ def test_notestack_voices_can_be_saved_and_used_in_a_video(client, b2v, owner, o
1185
  assert r.status_code == 404
1186
 
1187
 
1188
- def test_notestack_voices_need_premium_in_videos(client, b2v, owner, db_session, free_plan):
 
1189
  headers, ws = owner
1190
  notestack_voice(db_session, ws)
1191
  assert client.post("/api/video-voices/saved", json={"voice_id": "nv_narrator"},
1192
- headers=headers).status_code == 402
 
 
1193
 
1194
 
1195
  def test_revoking_the_clone_drops_it_from_video_voices(client, b2v, owner, db_session, monkeypatch):
 
1157
  db.commit()
1158
 
1159
 
1160
+ def test_any_number_of_voices_can_be_saved(client, b2v, owner, db_session):
1161
  headers, ws = owner
1162
  client.get("/api/video-voices", headers=headers) # the starter voice
1163
+ for i in range(7):
1164
  db_session.add(UserSavedVoice(workspace_id=ws.id, voice_id=f"v{i}", name=f"V{i}"))
1165
  db_session.commit()
1166
+ assert client.post("/api/video-voices/saved", json={"voice_id": BELLA}, headers=headers).status_code == 201
1167
+ got = client.get("/api/video-voices", headers=headers).json()
1168
+ assert len(got["saved"]) == 9 and "max_saved" not in got
1169
 
1170
 
1171
  def test_notestack_voices_can_be_saved_and_used_in_a_video(client, b2v, owner, other, db_session):
 
1185
  assert r.status_code == 404
1186
 
1187
 
1188
+ def test_notestack_voices_are_saved_on_any_plan_but_need_premium_in_videos(client, b2v, owner, db_session,
1189
+ free_plan):
1190
  headers, ws = owner
1191
  notestack_voice(db_session, ws)
1192
  assert client.post("/api/video-voices/saved", json={"voice_id": "nv_narrator"},
1193
+ headers=headers).status_code == 201 # in the list, for audio overviews
1194
+ r = client.post("/api/videos", json={"url": "https://a.test/x", "custom_voice_id": "nv_narrator"}, headers=headers)
1195
+ assert r.status_code == 402 and not creates(b2v)
1196
 
1197
 
1198
  def test_revoking_the_clone_drops_it_from_video_voices(client, b2v, owner, db_session, monkeypatch):
frontend/src/App.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { Navigate, Route, Routes } from "react-router-dom";
2
  import AppShell from "./components/AppShell";
3
  import { PublicFooter, PublicNav } from "./components/PublicChrome";
4
  import PricingTiers from "./components/PricingTiers";
@@ -20,10 +20,8 @@ import Sources from "./pages/Sources";
20
  import Studio from "./pages/Studio";
21
  import VideoCreate from "./pages/VideoCreate";
22
  import VideoEditor from "./pages/VideoEditor";
23
- import Videos from "./pages/Videos";
24
  import TopicMap from "./pages/TopicMap";
25
  import { ToolPage, ToolsHub } from "./pages/Tools";
26
- import VoiceProfile from "./pages/VoiceProfile";
27
  import Welcome from "./pages/Welcome";
28
  import { SupportWidget } from "./components/support/SupportWidget";
29
 
@@ -60,13 +58,13 @@ export default function App() {
60
  <Route index element={<MissionControl />} />
61
  <Route path="notebooks" element={<Notebooks />} />
62
  <Route path="notebooks/:id" element={<NotebookView />} />
63
- <Route path="videos" element={<Videos />} />
64
  <Route path="videos/new" element={<VideoCreate />} />
65
- <Route path="videos/voices" element={<Navigate to="/app/voice?step=hosts#video-voices" replace />} />
66
  <Route path="videos/:id" element={<VideoEditor />} />
67
  <Route path="sources" element={<Sources />} />
68
  <Route path="map" element={<TopicMap />} />
69
- <Route path="voice" element={<VoiceProfile />} />
70
  <Route path="studio" element={<Studio />} />
71
  <Route path="launch-kit" element={<LaunchKit />} />
72
  <Route path="launchpad" element={<Launchpad />} />
@@ -81,3 +79,10 @@ export default function App() {
81
  </>
82
  );
83
  }
 
 
 
 
 
 
 
 
1
+ import { Navigate, Route, Routes, useSearchParams } from "react-router-dom";
2
  import AppShell from "./components/AppShell";
3
  import { PublicFooter, PublicNav } from "./components/PublicChrome";
4
  import PricingTiers from "./components/PricingTiers";
 
20
  import Studio from "./pages/Studio";
21
  import VideoCreate from "./pages/VideoCreate";
22
  import VideoEditor from "./pages/VideoEditor";
 
23
  import TopicMap from "./pages/TopicMap";
24
  import { ToolPage, ToolsHub } from "./pages/Tools";
 
25
  import Welcome from "./pages/Welcome";
26
  import { SupportWidget } from "./components/support/SupportWidget";
27
 
 
58
  <Route index element={<MissionControl />} />
59
  <Route path="notebooks" element={<Notebooks />} />
60
  <Route path="notebooks/:id" element={<NotebookView />} />
61
+ <Route path="videos" element={<Navigate to="/app/archive?tab=videos" replace />} />
62
  <Route path="videos/new" element={<VideoCreate />} />
63
+ <Route path="videos/voices" element={<Navigate to="/app/archive?tab=voices" replace />} />
64
  <Route path="videos/:id" element={<VideoEditor />} />
65
  <Route path="sources" element={<Sources />} />
66
  <Route path="map" element={<TopicMap />} />
67
+ <Route path="voice" element={<VoiceRedirect />} />
68
  <Route path="studio" element={<Studio />} />
69
  <Route path="launch-kit" element={<LaunchKit />} />
70
  <Route path="launchpad" element={<Launchpad />} />
 
79
  </>
80
  );
81
  }
82
+
83
+ /** The old Voice page (/app/voice, ?step=writing for the writing voice) is the Library's Voices tab now. */
84
+ function VoiceRedirect() {
85
+ const [params] = useSearchParams();
86
+ const writing = params.get("step") === "writing";
87
+ return <Navigate to={`/app/archive?tab=voices${writing ? "&step=writing" : ""}`} replace />;
88
+ }
frontend/src/api/endpoints.ts CHANGED
@@ -113,6 +113,9 @@ export type GenerateBody = {
113
  minutes?: number;
114
  style?: "audiogram";
115
  audio_artifact_id?: string;
 
 
 
116
  slides?: { heading: string; body: string }[];
117
  parent_id?: string;
118
  /** mind_map: the posts to chart (none: all in the notebook) and what to centre on. */
 
113
  minutes?: number;
114
  style?: "audiogram";
115
  audio_artifact_id?: string;
116
+ /** audio_overview: the two hosts' voices, from the workspace's voices (none: the defaults). */
117
+ host_a?: string;
118
+ host_b?: string;
119
  slides?: { heading: string; body: string }[];
120
  parent_id?: string;
121
  /** mind_map: the posts to chart (none: all in the notebook) and what to centre on. */
frontend/src/api/types.ts CHANGED
@@ -485,8 +485,6 @@ export type VideoVoicesResponse = {
485
  library: VideoLibraryVoice[];
486
  custom: VideoCustomVoice[];
487
  notestack: VideoNotestackVoice[];
488
- /** How many voices "My voices" can hold. */
489
- max_saved: number;
490
  };
491
 
492
  export type VideoDesignedVoice = {
 
485
  library: VideoLibraryVoice[];
486
  custom: VideoCustomVoice[];
487
  notestack: VideoNotestackVoice[];
 
 
488
  };
489
 
490
  export type VideoDesignedVoice = {
frontend/src/components/AppShell.tsx CHANGED
@@ -7,9 +7,8 @@ import { FuelGauge, NudgeDock } from "./Nudges";
7
  import SkyCanvas from "./SkyCanvas";
8
  import UpgradeModal from "./UpgradeModal";
9
  import {
10
- CometIcon,
11
- HelmetIcon,
12
  LaunchpadIcon,
 
13
  OrbitIcon,
14
  PlanetIcon,
15
  SatelliteDishIcon,
@@ -19,8 +18,7 @@ import {
19
  const NAV: { to: string; label: string; icon: typeof PlanetIcon; end?: boolean; also?: string[] }[] = [
20
  { to: "/app/notebooks", label: "Notebooks", icon: PlanetIcon },
21
  { to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
22
- { to: "/app/videos", label: "Videos", icon: CometIcon },
23
- { to: "/app/voice", label: "Voices", icon: HelmetIcon },
24
  { to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface"] },
25
  ];
26
 
 
7
  import SkyCanvas from "./SkyCanvas";
8
  import UpgradeModal from "./UpgradeModal";
9
  import {
 
 
10
  LaunchpadIcon,
11
+ LibraryIcon,
12
  OrbitIcon,
13
  PlanetIcon,
14
  SatelliteDishIcon,
 
18
  const NAV: { to: string; label: string; icon: typeof PlanetIcon; end?: boolean; also?: string[] }[] = [
19
  { to: "/app/notebooks", label: "Notebooks", icon: PlanetIcon },
20
  { to: "/app/sources", label: "Sources", icon: SatelliteDishIcon, also: ["/app/map"] },
21
+ { to: "/app/archive", label: "Library", icon: LibraryIcon, also: ["/app/videos"] },
 
22
  { to: "/app/launchpad", label: "Launchpad", icon: LaunchpadIcon, also: ["/app/resurface"] },
23
  ];
24
 
frontend/src/components/icons/Icons.tsx CHANGED
@@ -161,6 +161,32 @@ export const DownloadIcon = (p: P) => (
161
  </Icon>
162
  );
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  export const FilmIcon = (p: P) => (
165
  <Icon {...p}>
166
  <rect x="3" y="5" width="18" height="14" rx="2" />
 
161
  </Icon>
162
  );
163
 
164
+ /** A stack of media with a play mark on the front: the Library (what you made, your videos and your voices). */
165
+ export const LibraryIcon = (p: P) => (
166
+ <Icon {...p}>
167
+ <rect x="3" y="8" width="18" height="12.5" rx="2.5" />
168
+ <path d="M5.5 5h13M8 2.5h8" />
169
+ <path d="M10.25 11.6v5.3l4.4-2.65z" />
170
+ </Icon>
171
+ );
172
+
173
+ /** A video camera: the Videos section. */
174
+ export const VideoIcon = (p: P) => (
175
+ <Icon {...p}>
176
+ <rect x="3" y="6.5" width="12.5" height="11" rx="2.5" />
177
+ <path d="M15.5 10.5l4.6-2.7a.6.6 0 0 1 .9.5v7.4a.6.6 0 0 1-.9.5l-4.6-2.7" />
178
+ </Icon>
179
+ );
180
+
181
+ /** A microphone with sound waves: the Voices section. */
182
+ export const MicIcon = (p: P) => (
183
+ <Icon {...p}>
184
+ <rect x="9" y="3" width="6" height="11" rx="3" />
185
+ <path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21M9 21h6" />
186
+ <path d="M2.5 9v3M21.5 9v3" />
187
+ </Icon>
188
+ );
189
+
190
  export const FilmIcon = (p: P) => (
191
  <Icon {...p}>
192
  <rect x="3" y="5" width="18" height="14" rx="2" />
frontend/src/components/video/VideoVoicesSection.tsx DELETED
@@ -1,208 +0,0 @@
1
- import { type ReactNode, useCallback, useEffect, useState } from "react";
2
- import { videosApi, videoVoicesApi, voiceApi } from "../../api/endpoints";
3
- import type { VideoSavedVoice, VideoVoicesResponse } from "../../api/types";
4
- import { errorMessage, Loading, Modal, Tabs } from "../ui";
5
- import { useUpgrade } from "../../hooks/useUpgrade";
6
- import { VoiceLibrary } from "./VoiceLibrary";
7
- import { Premium, PlayButton, useAudio } from "./parts";
8
-
9
- const KIND = { clone: "Your cloned voice", designed: "Generated", library: "From the voice library" } as const;
10
-
11
- /** Play a voice: blog2video voices have a sample link; Notestack voices have none, so the Voice page speaks a line. */
12
- function useVoicePlayer(onError: (msg: string) => void) {
13
- const { playing, play, stop } = useAudio();
14
- const playNotestack = async (voiceId: string) => {
15
- if (playing === voiceId) return stop();
16
- const res = await voiceApi.preview(voiceId).catch((e) => {
17
- onError(errorMessage(e));
18
- return null;
19
- });
20
- if (res?.url) play(voiceId, res.url);
21
- };
22
- const playSaved = (v: VideoSavedVoice) =>
23
- v.source === "notestack" ? playNotestack(v.voice_id) : play(v.voice_id, v.preview_url);
24
- return { playing, playNotestack, playSaved };
25
- }
26
-
27
- /** The Voice page's "Video voiceovers" tab: My video voices (what step 3 of a new video offers, up to max_saved) and
28
- * "Manage your voiceovers", which adds from the video voice library or the workspace's own Notestack voices. Notestack
29
- * voices work in videos because blog2video speaks with the same ElevenLabs account. */
30
- export function VideoVoicesSection({ renderAddVoice, reloadKey = 0 }: {
31
- /** The modal's Add your voice tab: the Voice page's generator. onDone = saved (back to the library); close = close the
32
- * modal (cloning opens its own). The new voice is added to the video voices by the Voice page. */
33
- renderAddVoice: (onDone: () => void, close: () => void) => ReactNode;
34
- /** Bumped by the Voice page after it made a voice, so the list here reloads. */
35
- reloadKey?: number;
36
- }) {
37
- const [voices, setVoices] = useState<VideoVoicesResponse | null>(null);
38
- const [premium, setPremium] = useState(false);
39
- const [managing, setManaging] = useState(false);
40
- const [busy, setBusy] = useState<string | null>(null);
41
- const [error, setError] = useState<string | null>(null);
42
- const { playing, playSaved } = useVoicePlayer(setError);
43
-
44
- const load = useCallback(() => videoVoicesApi.list().then(setVoices).catch((e) => setError(errorMessage(e))), []);
45
- useEffect(() => {
46
- load();
47
- videosApi.config().then((c) => setPremium(!!c.premium)).catch(() => undefined);
48
- }, [load, reloadKey]);
49
-
50
- const remove = async (voiceId: string) => {
51
- setBusy(voiceId);
52
- setError(null);
53
- try {
54
- await videoVoicesApi.unsave(voiceId);
55
- await load();
56
- } catch (e) {
57
- setError(errorMessage(e));
58
- } finally {
59
- setBusy(null);
60
- }
61
- };
62
-
63
- return (
64
- <div id="video-voices" className="stack">
65
- <div className="row between wrap manage-top">
66
- <p className="muted">
67
- Up to {voices?.max_saved ?? 5} voices, offered in step 3 of a new video. Mix your own voices with the video
68
- voice library.
69
- </p>
70
- <button className="btn btn-primary" disabled={!voices} onClick={() => setManaging(true)}>
71
- Manage your voiceovers
72
- </button>
73
- </div>
74
- {error && <p className="error-text">{error}</p>}
75
- {!voices && !error && <div className="loading-center inline"><Loading label="Loading voices" /></div>}
76
-
77
- {voices && (
78
- <div className="stack">
79
- <h3>My video voices <span className="mono muted small">{voices.saved.length} of {voices.max_saved}</span></h3>
80
- <div className="vw-voice-list">
81
- {voices.saved.map((v) => (
82
- <div key={v.voice_id} className="vw-vrow">
83
- <PlayButton on={playing === v.voice_id} disabled={v.source !== "notestack" && !v.preview_url}
84
- label={v.name} onClick={() => playSaved(v)} />
85
- <div className="vw-vpick static">
86
- <strong>{v.name}</strong>
87
- <span className="muted">
88
- {v.source === "notestack" || v.is_custom ? "Your voice" : [v.gender, v.accent].filter(Boolean).join(" • ")}
89
- </span>
90
- </div>
91
- {v.premium && <Premium small />}
92
- <button className="btn btn-small" disabled={busy === v.voice_id} onClick={() => remove(v.voice_id)}>
93
- Remove
94
- </button>
95
- </div>
96
- ))}
97
- {voices.saved.length === 0 && <p className="muted">No voices yet. Add some with Manage your voiceovers.</p>}
98
- </div>
99
- </div>
100
- )}
101
-
102
- {managing && voices && (
103
- <ManageVideoVoicesModal voices={voices} premium={premium} onChange={load} onClose={() => setManaging(false)}
104
- renderAddVoice={renderAddVoice} />
105
- )}
106
- </div>
107
- );
108
- }
109
-
110
- /** Add or remove video voices: blog2video's library (and custom voices) plus the workspace's own Notestack voices, or
111
- * make a new voice (Add your voice). */
112
- function ManageVideoVoicesModal({ voices, premium, onChange, onClose, renderAddVoice }: {
113
- voices: VideoVoicesResponse;
114
- premium: boolean;
115
- onChange: () => Promise<unknown>;
116
- onClose: () => void;
117
- renderAddVoice: (onDone: () => void, close: () => void) => ReactNode;
118
- }) {
119
- const { openUpgrade } = useUpgrade();
120
- const [tab, setTab] = useState<"library" | "add">("library");
121
- const [busy, setBusy] = useState<string | null>(null);
122
- const [error, setError] = useState<string | null>(null);
123
- const { playing, playNotestack } = useVoicePlayer(setError);
124
- const full = voices.saved.length >= voices.max_saved;
125
- // Sections: saved (from a voice library) and cloned voices, voices created (designed), and the built-in library.
126
- const ownSaved = voices.notestack.filter((v) => v.kind !== "designed");
127
- const ownCreated = voices.notestack.filter((v) => v.kind === "designed");
128
- const clonedCustom = voices.custom.some((c) => c.source === "clone");
129
- const createdCustom = voices.custom.some((c) => c.source !== "clone");
130
- const libProps = { voices, premium, full, onChange, onLocked: () => openUpgrade() };
131
-
132
- const ownRow = (v: VideoVoicesResponse["notestack"][number]) => (
133
- <div key={v.voice_id} className="vw-vrow">
134
- <PlayButton on={playing === v.voice_id} label={v.name} onClick={() => playNotestack(v.voice_id)} />
135
- <div className="vw-vpick static">
136
- <strong>{v.name}</strong>
137
- <span className="muted">{KIND[v.kind]}</span>
138
- </div>
139
- <Premium small />
140
- {v.saved ? (
141
- <button className="btn btn-small" disabled={busy === v.voice_id}
142
- onClick={() => act(v.voice_id, () => videoVoicesApi.unsave(v.voice_id))}>Remove</button>
143
- ) : (
144
- <button className="btn btn-small btn-primary" disabled={busy === v.voice_id || full}
145
- onClick={() => (premium ? act(v.voice_id, () => videoVoicesApi.save(v.voice_id)) : openUpgrade())}>
146
- Add
147
- </button>
148
- )}
149
- </div>
150
- );
151
-
152
- async function act(key: string, fn: () => Promise<unknown>) {
153
- setBusy(key);
154
- setError(null);
155
- try {
156
- await fn();
157
- await onChange();
158
- } catch (e) {
159
- setError(errorMessage(e));
160
- } finally {
161
- setBusy(null);
162
- }
163
- }
164
-
165
- return (
166
- <Modal title="Manage your voiceovers" onClose={onClose} wide>
167
- <div className="stack">
168
- <p className="mono muted small">
169
- {voices.saved.length} of {voices.max_saved} added
170
- {full ? ". Remove one to add another." : ""}
171
- </p>
172
- <Tabs
173
- tabs={[
174
- { id: "library", label: "Voice library" },
175
- { id: "add", label: "Add your voice" },
176
- ]}
177
- value={tab}
178
- onChange={setTab}
179
- />
180
- {error && <p className="error-text">{error}</p>}
181
- {tab === "add" ? (
182
- renderAddVoice(() => setTab("library"), onClose)
183
- ) : (
184
- <div className="stack">
185
- {(ownSaved.length > 0 || clonedCustom) && (
186
- <section className="stack">
187
- <h3>Saved and cloned</h3>
188
- {ownSaved.length > 0 && <div className="vw-voice-list">{ownSaved.map(ownRow)}</div>}
189
- <VoiceLibrary {...libProps} part="custom" customKind="clone" />
190
- </section>
191
- )}
192
- {(ownCreated.length > 0 || createdCustom) && (
193
- <section className="stack">
194
- <h3>Created by you</h3>
195
- {ownCreated.length > 0 && <div className="vw-voice-list">{ownCreated.map(ownRow)}</div>}
196
- <VoiceLibrary {...libProps} part="custom" customKind="designed" />
197
- </section>
198
- )}
199
- <section className="stack">
200
- <h3>Built-in voices</h3>
201
- <VoiceLibrary {...libProps} part="library" />
202
- </section>
203
- </div>
204
- )}
205
- </div>
206
- </Modal>
207
- );
208
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/video/VoiceLibrary.tsx DELETED
@@ -1,107 +0,0 @@
1
- import { useState } from "react";
2
- import { videoVoicesApi } from "../../api/endpoints";
3
- import type { VideoVoicesResponse } from "../../api/types";
4
- import { ConfirmButton, errorMessage } from "../ui";
5
- import { Premium, PlayButton, useAudio } from "./parts";
6
-
7
- const cap = (s?: string | null) => (s ? s[0].toUpperCase() + s.slice(1) : "");
8
-
9
- /** The built-in voices to add to "My voices", and this workspace's custom voices. */
10
- export function VoiceLibrary({ voices, premium, onChange, onLocked, full = false, part, customKind }: {
11
- voices: VideoVoicesResponse; premium: boolean; onChange: () => void; onLocked: () => void;
12
- /** My voices is at its cap: nothing more can be added until one is removed. */
13
- full?: boolean;
14
- /** Just one part, with no heading of its own (the caller titles it): the custom voices or the built-in library. */
15
- part?: "custom" | "library";
16
- /** With part "custom": only cloned or only designed custom voices. */
17
- customKind?: "clone" | "designed";
18
- }) {
19
- const [busy, setBusy] = useState<string | null>(null);
20
- const [error, setError] = useState<string | null>(null);
21
- const { playing, play } = useAudio();
22
-
23
- const shown = voices.library;
24
- const custom = voices.custom.filter((c) => !customKind || (customKind === "clone") === (c.source === "clone"));
25
-
26
- async function act(key: string, fn: () => Promise<unknown>) {
27
- setBusy(key);
28
- setError(null);
29
- try {
30
- await fn();
31
- onChange();
32
- } catch (e) {
33
- setError(errorMessage(e));
34
- } finally {
35
- setBusy(null);
36
- }
37
- }
38
-
39
- async function playCustom(id: number, url: string | null) {
40
- if (url) return play(`c${id}`, url);
41
- const res = await videoVoicesApi.customPreview(id).catch(() => null);
42
- if (res?.preview_url) play(`c${id}`, res.preview_url);
43
- else setError("The preview is not ready yet. Try again in a minute.");
44
- }
45
-
46
- return (
47
- <div className="stack">
48
- {part !== "library" && custom.length > 0 && (
49
- <section className="stack">
50
- {!part && <h3>Your custom voices</h3>}
51
- <div className="vw-voice-list">
52
- {custom.map((c) => (
53
- <div key={c.id} className="vw-vrow">
54
- <PlayButton on={playing === `c${c.id}`} label={c.name} onClick={() => playCustom(c.id, c.preview_url)} />
55
- <div className="vw-vpick static">
56
- <strong>{c.name}</strong>
57
- <span className="muted">{c.source === "clone" ? "Cloned" : "Designed"}</span>
58
- </div>
59
- {c.saved ? (
60
- <button className="btn btn-small" disabled={busy === c.voice_id}
61
- onClick={() => act(c.voice_id, () => videoVoicesApi.unsave(c.voice_id))}>Remove</button>
62
- ) : (
63
- <button className="btn btn-small" disabled={busy === c.voice_id || full}
64
- onClick={() => act(c.voice_id, () => videoVoicesApi.save(c.voice_id))}>Add</button>
65
- )}
66
- <ConfirmButton onConfirm={() => act(`d${c.id}`, () => videoVoicesApi.removeCustom(c.id))}>Delete</ConfirmButton>
67
- </div>
68
- ))}
69
- </div>
70
- </section>
71
- )}
72
-
73
- {part === "custom" && error && <p className="error-text">{error}</p>}
74
- {part !== "custom" && (
75
- <section className="stack">
76
- {error && <p className="error-text">{error}</p>}
77
- <div className="vw-voice-list">
78
- {shown.map((v) => (
79
- <div key={v.voice_id} className="vw-vrow">
80
- <PlayButton on={playing === v.voice_id} disabled={!v.preview_url} label={v.name}
81
- onClick={() => play(v.voice_id, v.preview_url)} />
82
- <div className="vw-vpick static">
83
- <strong>{v.name}</strong>
84
- <span className="muted">
85
- {[cap(v.gender), cap(v.accent), cap(v.age)].filter(Boolean).join(" • ")}
86
- {v.description ? ` - ${v.description}` : ""}
87
- </span>
88
- </div>
89
- {v.premium && <Premium small />}
90
- {v.saved ? (
91
- <button className="btn btn-small" disabled={busy === v.voice_id}
92
- onClick={() => act(v.voice_id, () => videoVoicesApi.unsave(v.voice_id))}>Remove</button>
93
- ) : (
94
- <button className="btn btn-small btn-primary" disabled={busy === v.voice_id || full}
95
- onClick={() => (v.premium && !premium ? onLocked() : act(v.voice_id, () => videoVoicesApi.save(v.voice_id)))}>
96
- Add
97
- </button>
98
- )}
99
- </div>
100
- ))}
101
- {shown.length === 0 && <p className="muted">No voices in the library right now.</p>}
102
- </div>
103
- </section>
104
- )}
105
- </div>
106
- );
107
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/voice/YourVoices.tsx ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { videosApi, videoVoicesApi, voiceApi } from "../../api/endpoints";
3
+ import type { VideoLibraryVoice, VideoSavedVoice, VideoVoicesResponse } from "../../api/types";
4
+ import { useUpgrade } from "../../hooks/useUpgrade";
5
+ import { errorMessage, Loading } from "../ui";
6
+ import { Premium, PlayButton, useAudio } from "../video/parts";
7
+
8
+ const cap = (s?: string | null) => (s ? s[0].toUpperCase() + s.slice(1) : "");
9
+
10
+ /** Play a voice: library voices have a sample link; the workspace's own (Notestack) voices have none, so the Voice
11
+ * page speaks a line in them. */
12
+ export function useVoicePlayer(onError: (msg: string) => void) {
13
+ const { playing, play, stop } = useAudio();
14
+ const playNotestack = async (voiceId: string) => {
15
+ if (playing === voiceId) return stop();
16
+ const res = await voiceApi.preview(voiceId).catch((e) => {
17
+ onError(errorMessage(e));
18
+ return null;
19
+ });
20
+ if (res?.url) play(voiceId, res.url);
21
+ };
22
+ const playSaved = (v: VideoSavedVoice) =>
23
+ v.source === "notestack" ? playNotestack(v.voice_id) : play(v.voice_id, v.preview_url);
24
+ return { playing, play, playNotestack, playSaved };
25
+ }
26
+
27
+ /** The Voice page's Speaking voices: the workspace's voices (one list for audio overviews and videos, as many as
28
+ * wanted) and, below, the built-in library to add from. New voices (Add voices) are saved here by the server. */
29
+ export function YourVoices({ reloadKey = 0, onAdd }: {
30
+ /** Bumped by the Voice page after a voice was made or cloned, so the list reloads. */
31
+ reloadKey?: number;
32
+ /** Opens the Add voices modal (build, describe or clone). */
33
+ onAdd: () => void;
34
+ }) {
35
+ const [voices, setVoices] = useState<VideoVoicesResponse | null>(null);
36
+ const [premium, setPremium] = useState(false);
37
+ const [busy, setBusy] = useState<string | null>(null);
38
+ const [error, setError] = useState<string | null>(null);
39
+ const { playing, play, playSaved } = useVoicePlayer(setError);
40
+ const { openUpgrade } = useUpgrade();
41
+
42
+ const load = useCallback(() => videoVoicesApi.list().then(setVoices).catch((e) => setError(errorMessage(e))), []);
43
+ useEffect(() => {
44
+ load();
45
+ videosApi.config().then((c) => setPremium(!!c.premium)).catch(() => undefined);
46
+ }, [load, reloadKey]);
47
+
48
+ async function act(voiceId: string, fn: () => Promise<unknown>) {
49
+ setBusy(voiceId);
50
+ setError(null);
51
+ try {
52
+ await fn();
53
+ await load();
54
+ } catch (e) {
55
+ setError(errorMessage(e));
56
+ } finally {
57
+ setBusy(null);
58
+ }
59
+ }
60
+
61
+ const add = (v: VideoLibraryVoice) =>
62
+ v.premium && !premium ? openUpgrade() : act(v.voice_id, () => videoVoicesApi.save(v.voice_id));
63
+ const library = voices?.library.filter((v) => !v.saved) ?? [];
64
+
65
+ return (
66
+ <div className="stack">
67
+ <div className="row between wrap manage-top">
68
+ <p className="muted">Your voices read your audio overviews and videos. Add your own, or pick from the library.</p>
69
+ <button className="btn btn-primary" onClick={onAdd}>Add voices</button>
70
+ </div>
71
+ {error && <p className="error-text">{error}</p>}
72
+ {!voices && !error && <div className="loading-center inline"><Loading label="Loading voices" /></div>}
73
+
74
+ {voices && (
75
+ <>
76
+ <section className="stack yv-panel">
77
+ <h3 className="yv-head">Your voices <span className="yv-count mono">{voices.saved.length}</span></h3>
78
+ {voices.saved.length === 0 ? (
79
+ <p className="muted">No voices yet. Add one, or pick from the library below.</p>
80
+ ) : (
81
+ <div className="vw-voice-list vw-voice-list-tight vw-voice-list-four">
82
+ {voices.saved.map((v) => (
83
+ <div key={v.voice_id} className="vw-vrow">
84
+ <PlayButton on={playing === v.voice_id} disabled={v.source !== "notestack" && !v.preview_url}
85
+ label={v.name} onClick={() => playSaved(v)} />
86
+ <div className="vw-vpick static">
87
+ <strong>{v.name}</strong>
88
+ <span className="muted">
89
+ {v.source === "notestack" || v.is_custom ? "Your voice"
90
+ : [cap(v.gender), cap(v.accent)].filter(Boolean).join(" • ")}
91
+ </span>
92
+ </div>
93
+ {v.premium && <Premium small />}
94
+ <button className="btn btn-small" disabled={busy === v.voice_id}
95
+ onClick={() => act(v.voice_id, () => videoVoicesApi.unsave(v.voice_id))}>
96
+ Remove
97
+ </button>
98
+ </div>
99
+ ))}
100
+ </div>
101
+ )}
102
+ </section>
103
+
104
+ <section className="stack yv-panel">
105
+ <h3 className="yv-head">Voice library</h3>
106
+ {library.length === 0 ? (
107
+ <p className="muted">Every library voice is in your voices.</p>
108
+ ) : (
109
+ <div className="vw-voice-list vw-voice-list-tight">
110
+ {library.map((v) => (
111
+ <div key={v.voice_id} className="vw-vrow">
112
+ <PlayButton on={playing === v.voice_id} disabled={!v.preview_url} label={v.name}
113
+ onClick={() => play(v.voice_id, v.preview_url)} />
114
+ <div className="vw-vpick static">
115
+ <strong>{v.name}</strong>
116
+ <span className="muted">
117
+ {[cap(v.gender), cap(v.accent), cap(v.age)].filter(Boolean).join(" • ")}
118
+ {v.description ? ` - ${v.description}` : ""}
119
+ </span>
120
+ </div>
121
+ {v.premium && <Premium small />}
122
+ <button className="btn btn-small btn-primary" disabled={busy === v.voice_id} onClick={() => add(v)}>
123
+ Add
124
+ </button>
125
+ </div>
126
+ ))}
127
+ </div>
128
+ )}
129
+ </section>
130
+ </>
131
+ )}
132
+ </div>
133
+ );
134
+ }
frontend/src/pages/Archive.tsx CHANGED
@@ -1,16 +1,41 @@
1
  import { useCallback, useEffect, useState } from "react";
2
- import { Link } from "react-router-dom";
3
  import { artifactsApi } from "../api/endpoints";
4
  import type { Artifact } from "../api/types";
5
  import { ArtifactRow } from "../components/ArtifactRow";
6
  import { Reader } from "../components/Reader";
7
  import { EmptyState, Loading, PageHeader, Tabs } from "../components/ui";
 
 
8
 
9
- type Filter = "all" | "summary" | "audio_overview" | "video" | "launch_kit" | "quote_card,carousel";
 
 
 
 
 
 
 
 
10
  const PAGE = 30;
11
 
12
  export default function Archive() {
13
- const [filter, setFilter] = useState<Filter>("all");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  const [q, setQ] = useState("");
15
  const [items, setItems] = useState<Artifact[] | null>(null);
16
  const [total, setTotal] = useState(0);
@@ -33,21 +58,8 @@ export default function Archive() {
33
  }, [load]);
34
 
35
  return (
36
- <div className="page-wrap">
37
- <PageHeader eyebrow="Library" title="Everything you have made" />
38
- <div className="row between wrap">
39
- <Tabs<Filter>
40
- tabs={[
41
- { id: "all", label: "All" },
42
- { id: "summary", label: "Summaries" },
43
- { id: "audio_overview", label: "Audio" },
44
- { id: "video", label: "Video" },
45
- { id: "launch_kit", label: "Launch Kits" },
46
- { id: "quote_card,carousel", label: "Stills" },
47
- ]}
48
- value={filter}
49
- onChange={setFilter}
50
- />
51
  <input className="input input-sm search" placeholder={`Search ${total} items by post or notebook`} value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" />
52
  </div>
53
  {!items && <Loading />}
 
1
  import { useCallback, useEffect, useState } from "react";
2
+ import { Link, useSearchParams } from "react-router-dom";
3
  import { artifactsApi } from "../api/endpoints";
4
  import type { Artifact } from "../api/types";
5
  import { ArtifactRow } from "../components/ArtifactRow";
6
  import { Reader } from "../components/Reader";
7
  import { EmptyState, Loading, PageHeader, Tabs } from "../components/ui";
8
+ import Videos from "./Videos";
9
+ import VoiceProfile from "./VoiceProfile";
10
 
11
+ type Filter = "all" | "summary" | "audio_overview" | "launch_kit" | "quote_card,carousel";
12
+ /** The Library's tabs: kinds of things made (a filter on one list), plus your videos and your voices (their own pages). */
13
+ type Tab = Filter | "videos" | "voices";
14
+ // For now just these three; the per-kind filters (Summaries, Audio, Launch Kits, Stills) can come back as tabs.
15
+ const TABS: { id: Tab; label: string }[] = [
16
+ { id: "all", label: "All" },
17
+ { id: "videos", label: "Videos" },
18
+ { id: "voices", label: "Voices" },
19
+ ];
20
  const PAGE = 30;
21
 
22
  export default function Archive() {
23
+ const [params, setParams] = useSearchParams();
24
+ const tab: Tab = TABS.find((t) => t.id === params.get("tab"))?.id ?? "all";
25
+ // The tab lives in the URL (?tab=videos), so the sidebar, redirects and Back land on it.
26
+ const pickTab = (t: Tab) => setParams(t === "all" ? {} : { tab: t });
27
+
28
+ return (
29
+ <div className="page-wrap">
30
+ <PageHeader eyebrow="Library" title="Everything you have made" />
31
+ <Tabs<Tab> tabs={TABS} value={tab} onChange={pickTab} />
32
+ {tab === "videos" ? <Videos /> : tab === "voices" ? <VoiceProfile /> : <MadeList filter={tab} />}
33
+ </div>
34
+ );
35
+ }
36
+
37
+ /** What was made, of one kind (or all), searchable. */
38
+ function MadeList({ filter }: { filter: Filter }) {
39
  const [q, setQ] = useState("");
40
  const [items, setItems] = useState<Artifact[] | null>(null);
41
  const [total, setTotal] = useState(0);
 
58
  }, [load]);
59
 
60
  return (
61
+ <div className="stack">
62
+ <div className="row end wrap">
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  <input className="input input-sm search" placeholder={`Search ${total} items by post or notebook`} value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search" />
64
  </div>
65
  {!items && <Loading />}
frontend/src/pages/NotebookView.tsx CHANGED
@@ -1,8 +1,8 @@
1
  import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
2
  import { Link, useParams, useSearchParams } from "react-router-dom";
3
- import { artifactsApi, notebooksApi, type GenerateBody } from "../api/endpoints";
4
  import { streamSSE } from "../api/stream";
5
- import type { AnswerFeedback as Feedback, Artifact, ChatSummary, Citation, MemoryChange, Notebook } from "../api/types";
6
  import { AnswerFeedback } from "../components/AnswerFeedback";
7
  import { ArtifactCard, CitationList } from "../components/ArtifactCard";
8
  import { Markdown } from "../components/Markdown";
@@ -52,6 +52,9 @@ function StudioPanel({ notebookId, docs, disabled, onCreateVideo }: {
52
  const [busy, setBusy] = useState(false);
53
  const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
54
  const [audioOpen, setAudioOpen] = useState(false); // the Audio overview tile is opened to its options
 
 
 
55
  const [mapDialog, setMapDialog] = useState(false);
56
  const [mapBusy, setMapBusy] = useState(false);
57
  const [mapError, setMapError] = useState<string | null>(null);
@@ -59,6 +62,25 @@ function StudioPanel({ notebookId, docs, disabled, onCreateVideo }: {
59
  const [exploring, setExploring] = useState<Artifact | null>(null);
60
 
61
  const load = useCallback(() => notebooksApi.artifacts(notebookId).then(setArtifacts), [notebookId]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  useEffect(() => {
63
  load();
64
  }, [load]);
@@ -134,9 +156,18 @@ function StudioPanel({ notebookId, docs, disabled, onCreateVideo }: {
134
  <Dropdown<string> label="Length" value={String(minutes)} onChange={(v) => setMinutes(Number(v))}
135
  options={[3, 6, 10, 15].map((m) => ({ value: String(m), label: `${m} min` }))} />
136
  </div>
 
 
 
 
 
 
 
137
  </div>
138
- <button className="btn btn-primary btn-small" disabled={off}
139
- onClick={() => make({ type: "audio_overview", format, minutes }).then(() => setAudioOpen(false))}>
 
 
140
  {busy ? "Starting..." : "Create audio overview"}
141
  </button>
142
  </div>
 
1
  import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
2
  import { Link, useParams, useSearchParams } from "react-router-dom";
3
+ import { artifactsApi, notebooksApi, videoVoicesApi, type GenerateBody } from "../api/endpoints";
4
  import { streamSSE } from "../api/stream";
5
+ import type { AnswerFeedback as Feedback, Artifact, ChatSummary, Citation, MemoryChange, Notebook, VideoSavedVoice } from "../api/types";
6
  import { AnswerFeedback } from "../components/AnswerFeedback";
7
  import { ArtifactCard, CitationList } from "../components/ArtifactCard";
8
  import { Markdown } from "../components/Markdown";
 
52
  const [busy, setBusy] = useState(false);
53
  const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null);
54
  const [audioOpen, setAudioOpen] = useState(false); // the Audio overview tile is opened to its options
55
+ // The two hosts' voices, picked from the workspace's voices (loaded when the options first open).
56
+ const [hostVoices, setHostVoices] = useState<VideoSavedVoice[] | null>(null);
57
+ const [hosts, setHosts] = useState<[string, string]>(["", ""]);
58
  const [mapDialog, setMapDialog] = useState(false);
59
  const [mapBusy, setMapBusy] = useState(false);
60
  const [mapError, setMapError] = useState<string | null>(null);
 
62
  const [exploring, setExploring] = useState<Artifact | null>(null);
63
 
64
  const load = useCallback(() => notebooksApi.artifacts(notebookId).then(setArtifacts), [notebookId]);
65
+
66
+ useEffect(() => {
67
+ if (!audioOpen || hostVoices) return;
68
+ videoVoicesApi.list().then((r) => {
69
+ setHostVoices(r.saved);
70
+ // The first two voices by default; with just one, it reads both parts.
71
+ setHosts([r.saved[0]?.voice_id ?? "", (r.saved[1] ?? r.saved[0])?.voice_id ?? ""]);
72
+ }).catch(() => setHostVoices([])); // none to pick: the server's default voices
73
+ }, [audioOpen, hostVoices]);
74
+
75
+ /** Pick a host's voice; the other host moves off it, so the two never share one (when there are two to pick). */
76
+ const pickHost = (i: 0 | 1, voiceId: string) =>
77
+ setHosts((cur) => {
78
+ const next: [string, string] = [...cur];
79
+ next[i] = voiceId;
80
+ const other = i === 0 ? 1 : 0;
81
+ if (next[other] === voiceId) next[other] = hostVoices?.find((v) => v.voice_id !== voiceId)?.voice_id ?? voiceId;
82
+ return next;
83
+ });
84
  useEffect(() => {
85
  load();
86
  }, [load]);
 
156
  <Dropdown<string> label="Length" value={String(minutes)} onChange={(v) => setMinutes(Number(v))}
157
  options={[3, 6, 10, 15].map((m) => ({ value: String(m), label: `${m} min` }))} />
158
  </div>
159
+ {hostVoices && hostVoices.length > 0 && ([0, 1] as const).map((i) => (
160
+ <div key={i} className="field">
161
+ <span className="small muted">Host {i + 1}</span>
162
+ <Dropdown<string> label={`Host ${i + 1} voice`} value={hosts[i]} onChange={(v) => pickHost(i, v)}
163
+ options={hostVoices.map((v) => ({ value: v.voice_id, label: v.name }))} />
164
+ </div>
165
+ ))}
166
  </div>
167
+ <button className="btn btn-primary btn-small" disabled={off || (audioOpen && hostVoices === null)}
168
+ onClick={() => make({ type: "audio_overview", format, minutes,
169
+ ...(hosts[0] ? { host_a: hosts[0], host_b: hosts[1] || hosts[0] } : {}) })
170
+ .then(() => setAudioOpen(false))}>
171
  {busy ? "Starting..." : "Create audio overview"}
172
  </button>
173
  </div>
frontend/src/pages/VideoCreate.tsx CHANGED
@@ -145,7 +145,7 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
145
  }).catch((e) => setLoadError(errorMessage(e)));
146
  loadVoices()
147
  .then((v) => v.saved[0] && pickVoice(v.saved[0]))
148
- .catch(() => setVoices({ saved: [], library: [], custom: [], notestack: [], max_saved: 5 }));
149
  // eslint-disable-next-line react-hooks/exhaustive-deps
150
  }, [config?.configured]);
151
 
@@ -279,7 +279,7 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
279
  {step === 1 && !inModal && (
280
  // Leaves the wizard for wherever it was opened from. Steps 2 and 3 have their own Back at the bottom.
281
  <button type="button" className="vw-back"
282
- onClick={() => (window.history.length > 1 ? navigate(-1) : navigate("/app/videos"))}>
283
  Back
284
  </button>
285
  )}
@@ -367,13 +367,7 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
367
  <small className="muted mono">{custom.length}/500</small>
368
  </span>
369
  </label>
370
- {shownTopics && (
371
- <small className="muted vw-hint">
372
- {focus ? "The video is mainly about this topic."
373
- : custom.trim() ? "The video follows your topic, as far as the posts cover it."
374
- : "None picked: the video covers the material as a whole."}
375
- </small>
376
- )}
377
  </div>
378
  )}
379
 
@@ -471,7 +465,7 @@ export function VideoCreateForm({ startNotebook = null, startDoc = null, inModal
471
  {voices === null ? (
472
  <Loading label="Loading voices" />
473
  ) : saved.length === 0 ? (
474
- <p className="muted">No voices saved yet. Add some on the <Link to="/app/voice?step=hosts#video-voices">Voice</Link> page.</p>
475
  ) : (
476
  <div className="vw-voice-list vw-voice-list-compact" role="radiogroup" aria-label="Narration voice">
477
  {saved.map((v) => {
 
145
  }).catch((e) => setLoadError(errorMessage(e)));
146
  loadVoices()
147
  .then((v) => v.saved[0] && pickVoice(v.saved[0]))
148
+ .catch(() => setVoices({ saved: [], library: [], custom: [], notestack: [] }));
149
  // eslint-disable-next-line react-hooks/exhaustive-deps
150
  }, [config?.configured]);
151
 
 
279
  {step === 1 && !inModal && (
280
  // Leaves the wizard for wherever it was opened from. Steps 2 and 3 have their own Back at the bottom.
281
  <button type="button" className="vw-back"
282
+ onClick={() => (window.history.length > 1 ? navigate(-1) : navigate("/app/archive?tab=videos"))}>
283
  Back
284
  </button>
285
  )}
 
367
  <small className="muted mono">{custom.length}/500</small>
368
  </span>
369
  </label>
370
+
 
 
 
 
 
 
371
  </div>
372
  )}
373
 
 
465
  {voices === null ? (
466
  <Loading label="Loading voices" />
467
  ) : saved.length === 0 ? (
468
+ <p className="muted">No voices yet. Add some on the <Link to="/app/archive?tab=voices">Voices</Link> tab of your Library.</p>
469
  ) : (
470
  <div className="vw-voice-list vw-voice-list-compact" role="radiogroup" aria-label="Narration voice">
471
  {saved.map((v) => {
frontend/src/pages/VideoEditor.tsx CHANGED
@@ -282,14 +282,14 @@ export default function VideoEditor() {
282
 
283
  const remove = async () => {
284
  await videosApi.remove(id);
285
- navigate("/app/videos");
286
  };
287
 
288
  if (notFound) {
289
  return (
290
  <div className="page-wrap">
291
  <PageHeader eyebrow="Videos" title="Video not found" />
292
- <Link to="/app/videos" className="btn">Back to videos</Link>
293
  </div>
294
  );
295
  }
 
282
 
283
  const remove = async () => {
284
  await videosApi.remove(id);
285
+ navigate("/app/archive?tab=videos");
286
  };
287
 
288
  if (notFound) {
289
  return (
290
  <div className="page-wrap">
291
  <PageHeader eyebrow="Videos" title="Video not found" />
292
+ <Link to="/app/archive?tab=videos" className="btn">Back to videos</Link>
293
  </div>
294
  );
295
  }
frontend/src/pages/VideoTemplates.tsx CHANGED
@@ -16,7 +16,7 @@ export default function VideoTemplates() {
16
  return (
17
  <div className="page-wrap">
18
  <PageHeader eyebrow="Videos" title="Templates">
19
- <Link to="/app/videos" className="btn">Back to videos</Link>
20
  <Link to="/app/videos/templates/new" className="btn btn-primary">Create a template</Link>
21
  </PageHeader>
22
  {slots && (
 
16
  return (
17
  <div className="page-wrap">
18
  <PageHeader eyebrow="Videos" title="Templates">
19
+ <Link to="/app/archive?tab=videos" className="btn">Back to videos</Link>
20
  <Link to="/app/videos/templates/new" className="btn btn-primary">Create a template</Link>
21
  </PageHeader>
22
  {slots && (
frontend/src/pages/Videos.tsx CHANGED
@@ -3,7 +3,7 @@ import { Link, useLocation } from "react-router-dom";
3
  import { videosApi } from "../api/endpoints";
4
  import type { VideoListItem, VideoQuota } from "../api/types";
5
  import { TrashIcon } from "../components/icons/Icons";
6
- import { EmptyState, errorMessage, formatDate, Loading, Modal, PageHeader } from "../components/ui";
7
  import { useUpgrade } from "../hooks/useUpgrade";
8
 
9
  const JOB_LABELS: Record<string, string> = {
@@ -76,21 +76,21 @@ export default function Videos() {
76
  const out = !!quota && quota.used >= quota.limit;
77
 
78
  return (
79
- <div className="page-wrap">
80
- <PageHeader eyebrow="Videos" title="Your videos">
 
 
 
 
 
 
 
81
  {out && status?.can_upgrade ? (
82
  <button className="btn btn-primary" onClick={() => openUpgrade()}>Get more videos</button>
83
  ) : (
84
  <Link to="/app/videos/new" className="btn btn-primary">New video</Link>
85
  )}
86
- </PageHeader>
87
-
88
- {quota && (
89
- <p className="mono muted">
90
- {quota.used} of {quota.limit} video{quota.limit === 1 ? "" : "s"} used
91
- {quota.resets_at ? ` this period, refills ${formatDate(quota.resets_at)}` : " on your plan"}
92
- </p>
93
- )}
94
  {notice && <p className="notice">{notice}</p>}
95
  {error && <p className="error-text">{error}</p>}
96
  {!videos && !error && <div className="loading-center"><Loading label="Loading videos" /></div>}
 
3
  import { videosApi } from "../api/endpoints";
4
  import type { VideoListItem, VideoQuota } from "../api/types";
5
  import { TrashIcon } from "../components/icons/Icons";
6
+ import { EmptyState, errorMessage, formatDate, Loading, Modal } from "../components/ui";
7
  import { useUpgrade } from "../hooks/useUpgrade";
8
 
9
  const JOB_LABELS: Record<string, string> = {
 
76
  const out = !!quota && quota.used >= quota.limit;
77
 
78
  return (
79
+ // The Library's Videos tab: the allowance on the left, New video on the right, then the list.
80
+ <div className="stack">
81
+ <div className="row between wrap lib-tab-head">
82
+ <p className="mono muted">
83
+ {quota && <>
84
+ {quota.used} of {quota.limit} video{quota.limit === 1 ? "" : "s"} used
85
+ {quota.resets_at ? ` this period, refills ${formatDate(quota.resets_at)}` : " on your plan"}
86
+ </>}
87
+ </p>
88
  {out && status?.can_upgrade ? (
89
  <button className="btn btn-primary" onClick={() => openUpgrade()}>Get more videos</button>
90
  ) : (
91
  <Link to="/app/videos/new" className="btn btn-primary">New video</Link>
92
  )}
93
+ </div>
 
 
 
 
 
 
 
94
  {notice && <p className="notice">{notice}</p>}
95
  {error && <p className="error-text">{error}</p>}
96
  {!videos && !error && <div className="loading-center"><Loading label="Loading videos" /></div>}
frontend/src/pages/VoiceProfile.tsx CHANGED
@@ -1,16 +1,13 @@
1
- import { type CSSProperties, useCallback, useEffect, useRef, useState } from "react";
2
  import { useSearchParams } from "react-router-dom";
3
- import { videoVoicesApi, voiceApi } from "../api/endpoints";
4
- import type { Delivery, Job, LibraryVoice, Voice, VoiceDesignInput, VoiceDesignPreview, VoiceProfileData, VoiceState } from "../api/types";
5
  import { DocPicker } from "../components/DocPicker";
6
- import { PlayButton } from "../components/video/parts";
7
- import { VideoVoicesSection } from "../components/video/VideoVoicesSection";
8
- import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader, Tabs } from "../components/ui";
9
  import { useJob } from "../hooks/useJob";
10
  import { useUpgrade } from "../hooks/useUpgrade";
11
 
12
- type Host = "host_a" | "host_b";
13
-
14
  const LIST_FIELDS: { key: keyof VoiceProfileData; label: string; hint: string }[] = [
15
  { key: "tone", label: "Tone", hint: "warm, wry, direct" },
16
  { key: "vocabulary", label: "Signature words and phrases", hint: "readers, the long game" },
@@ -19,13 +16,6 @@ const LIST_FIELDS: { key: keyof VoiceProfileData; label: string; hint: string }[
19
  { key: "avoid", label: "Never does", hint: "jargon, hashtags" },
20
  ];
21
 
22
- const SLIDERS: { key: keyof Delivery; label: string; min: number; max: number; step: number; hint: string }[] = [
23
- { key: "stability", label: "Stability", min: 0, max: 1, step: 0.05, hint: "Lower is more expressive, higher is steadier" },
24
- { key: "similarity_boost", label: "Clarity and similarity", min: 0, max: 1, step: 0.05, hint: "How closely it sticks to the original voice" },
25
- { key: "style", label: "Style", min: 0, max: 1, step: 0.05, hint: "Exaggerates the speaker's style; keep low for news" },
26
- { key: "speed", label: "Speed", min: 0.7, max: 1.2, step: 0.05, hint: "1.0 is natural pace" },
27
- ];
28
-
29
  /** Plays one URL at a time across the page. */
30
  function usePlayer() {
31
  const audio = useRef<HTMLAudioElement | null>(null);
@@ -50,188 +40,6 @@ function fmtSeconds(s: number) {
50
  return `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`;
51
  }
52
 
53
- // Speaking voices
54
-
55
- type Icon = "sun" | "book" | "mic" | "spark" | "wave" | "person";
56
-
57
- /** The four ElevenLabs premade voices offered up front. Each has a glyph so they read at a glance. */
58
- const PREBUILT: { voice_id: string; name: string; tag: string; icon: Icon }[] = [
59
- { voice_id: "EXAVITQu4vr4xnSDxMaL", name: "Sarah", tag: "Warm and reassuring", icon: "sun" },
60
- { voice_id: "JBFqnCBsd6RMkjVDRZzb", name: "George", tag: "British storyteller", icon: "book" },
61
- { voice_id: "nPczCjzI2devNBz1zQrb", name: "Brian", tag: "Deep narrator", icon: "mic" },
62
- { voice_id: "cgSgspJ2msm6clMCkdW9", name: "Jessica", tag: "Bright and lively", icon: "spark" },
63
- ];
64
-
65
- type Tile = { voice_id: string; name: string; tag: string; icon: Icon; preview_url?: string | null };
66
-
67
- /** The voices a host can use: the premade four, then the writer's own (clone, generated), then whatever else a host is
68
- * set to (e.g. a library voice). Shared by the tiles and the Manage voices modal. */
69
- function hostTiles(state: VoiceState, voices: Voice[]): Tile[] {
70
- const byId = new Map(voices.map((v) => [v.voice_id, v]));
71
- const tiles: Tile[] = PREBUILT.map((p) => ({ ...p, preview_url: byId.get(p.voice_id)?.preview_url }));
72
- if (state.clone.status === "ready" && state.clone.voice_id) {
73
- tiles.push({ voice_id: state.clone.voice_id, name: "My voice", tag: "Your clone", icon: "person", preview_url: state.clone.preview_url });
74
- }
75
- for (const c of state.custom_voices) tiles.push({ voice_id: c.voice_id, name: c.name, tag: "Generated", icon: "wave" });
76
- for (const id of [state.host_voices.host_a, state.host_voices.host_b]) {
77
- if (!tiles.some((t) => t.voice_id === id)) {
78
- const v = byId.get(id);
79
- tiles.push({ voice_id: id, name: v?.name ?? "Current voice", tag: v?.labels?.accent ?? "Library voice", icon: "wave", preview_url: v?.preview_url });
80
- }
81
- }
82
- return tiles;
83
- }
84
-
85
- /** Listen to a voice: its sample link when it has one, otherwise a line spoken with the host's delivery. */
86
- function useListen(host: Host, onError: (msg: string | null) => void) {
87
- const [loading, setLoading] = useState<string | null>(null);
88
- const { play, playing } = usePlayer();
89
- const listen = async (t: Tile) => {
90
- if (playing === t.voice_id || t.preview_url) return play(t.preview_url ?? "", t.voice_id);
91
- setLoading(t.voice_id);
92
- onError(null);
93
- try {
94
- const { url } = await voiceApi.preview(t.voice_id, { host });
95
- play(url, t.voice_id);
96
- } catch (e) {
97
- onError(errorMessage(e));
98
- } finally {
99
- setLoading(null);
100
- }
101
- };
102
- return { listen, loading, playing };
103
- }
104
-
105
- function VoicePicker({
106
- state,
107
- voices,
108
- onSaved,
109
- onManage,
110
- }: {
111
- state: VoiceState;
112
- voices: Voice[];
113
- onSaved: (s: VoiceState) => void;
114
- onManage: (host: Host) => void;
115
- }) {
116
- const [host, setHost] = useState<Host>("host_a");
117
- const [delivery, setDelivery] = useState<Delivery>(state.delivery[host]);
118
- const [dirty, setDirty] = useState(false);
119
- const [error, setError] = useState<string | null>(null);
120
- const { listen, loading, playing } = useListen(host, setError);
121
- const current = state.host_voices[host];
122
- const other: Host = host === "host_a" ? "host_b" : "host_a";
123
-
124
- useEffect(() => {
125
- setDelivery(state.delivery[host]);
126
- setDirty(false);
127
- }, [state.delivery, host]);
128
-
129
- const tiles = hostTiles(state, voices);
130
-
131
- const choose = async (voice_id: string) => {
132
- if (voice_id === current) return;
133
- setError(null);
134
- try {
135
- onSaved(await voiceApi.update({ host_voices: { [host]: voice_id } }));
136
- } catch (e) {
137
- setError(errorMessage(e));
138
- }
139
- };
140
-
141
- return (
142
- <div className="stack">
143
- <div className="row between wrap manage-top">
144
- <p className="muted">Audio overviews are a conversation between two hosts. Tap a voice to use it, or add your own.</p>
145
- <button className="btn btn-primary" onClick={() => onManage(host)}>
146
- Manage voices
147
- </button>
148
- </div>
149
- <Tabs<Host>
150
- tabs={[
151
- { id: "host_a", label: "Host A · leads" },
152
- { id: "host_b", label: "Host B · co-host" },
153
- ]}
154
- value={host}
155
- onChange={setHost}
156
- />
157
- {/* One row per voice, as in Video voiceovers: tap a row to give it to this host */}
158
- <div className="vw-voice-list" role="radiogroup" 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
- <div key={t.voice_id} className={`vw-vrow${selected ? " on" : ""}`}>
164
- <PlayButton on={playing === t.voice_id} label={t.name} onClick={() => listen(t)}
165
- disabled={loading !== null || (!t.preview_url && !state.tts_configured)} />
166
- <button type="button" role="radio" aria-checked={selected} className="vw-vpick" onClick={() => choose(t.voice_id)}>
167
- <strong>{t.name}</strong>
168
- <span className="muted">{t.tag}</span>
169
- </button>
170
- {selected && <span className="voice-badge-inline mono">{host === "host_a" ? "Host A" : "Host B"}</span>}
171
- {!selected && usedByOther && <span className="voice-badge-inline dim mono">{other === "host_a" ? "Host A" : "Host B"}</span>}
172
- </div>
173
- );
174
- })}
175
- </div>
176
- {error && <p className="error-text">{error}</p>}
177
- <details className="delivery-card">
178
- <summary>
179
- Delivery settings for {host === "host_a" ? "Host A" : "Host B"}
180
- <svg className="delivery-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
181
- strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
182
- <path d="m6 9 6 6 6-6" />
183
- </svg>
184
- </summary>
185
- <div className="delivery-grid">
186
- {SLIDERS.map((s) => {
187
- const value = Number(delivery[s.key]);
188
- const pct = ((value - s.min) / (s.max - s.min)) * 100;
189
- return (
190
- <label key={s.key} className="delivery-slider">
191
- <span className="row between">
192
- <span>{s.label}</span>
193
- <span className="delivery-value mono">{value.toFixed(2)}</span>
194
- </span>
195
- <input
196
- type="range"
197
- min={s.min}
198
- max={s.max}
199
- step={s.step}
200
- value={value}
201
- style={{ "--pct": `${pct}%` } as CSSProperties}
202
- onChange={(e) => {
203
- setDelivery({ ...delivery, [s.key]: Number(e.target.value) });
204
- setDirty(true);
205
- }}
206
- />
207
- <span className="muted small">{s.hint}</span>
208
- </label>
209
- );
210
- })}
211
- </div>
212
- {dirty && (
213
- <div className="row end delivery-actions">
214
- <button className="btn btn-small" onClick={() => (setDelivery(state.delivery[host]), setDirty(false))}>
215
- Reset
216
- </button>
217
- <button
218
- className="btn btn-small btn-primary"
219
- onClick={async () => {
220
- onSaved(await voiceApi.update({ delivery: { [host]: delivery } }));
221
- setDirty(false);
222
- }}
223
- >
224
- Save delivery
225
- </button>
226
- </div>
227
- )}
228
- </details>
229
- </div>
230
- );
231
- }
232
-
233
- // Generating a new voice: build from options, describe it, or clone your own
234
-
235
  type GenMode = "options" | "prompt" | "clone";
236
 
237
  const GEN_OPTIONS: { key: keyof Omit<VoiceDesignInput, "prompt">; label: string; values: string[] }[] = [
@@ -250,21 +58,18 @@ const PROMPT_IDEAS = [
250
 
251
  /** Make a new voice: build it from options, describe it, or clone your own. Inside the Manage voices modals. */
252
  function GenerateVoicePanel({
253
- host,
254
  state,
255
  onDone,
256
  onSaved,
257
  onClone,
258
  }: {
259
- /** The host the new voice is for; null when made for video voiceovers (no host changes). */
260
- host: Host | null;
261
  state: VoiceState;
262
  /** Saved: the caller closes or switches away. */
263
  onDone: () => void;
264
  onSaved: (s: VoiceState, voiceId: string) => void;
265
  onClone: () => void;
266
  }) {
267
- const [mode, setMode] = useState<GenMode>("options");
268
  const [opts, setOpts] = useState<VoiceDesignInput>({ gender: "female", age: "middle-aged", persona: "warm", pace: "measured", accent: "American" });
269
  const [prompt, setPrompt] = useState("");
270
  const [result, setResult] = useState<{ description: string; previews: VoiceDesignPreview[] } | null>(null);
@@ -274,7 +79,6 @@ function GenerateVoicePanel({
274
  const [error, setError] = useState<string | null>(null);
275
  const { play, playing } = usePlayer();
276
  const { openUpgrade } = useUpgrade();
277
- const hostLabel = host === "host_a" ? "Host A" : host === "host_b" ? "Host B" : null;
278
 
279
  const generate = async () => {
280
  setBusy("generate");
@@ -297,8 +101,7 @@ function GenerateVoicePanel({
297
  setBusy("save");
298
  setError(null);
299
  try {
300
- const saved = await voiceApi.saveDesign({ generated_voice_id: picked, name: name.trim(), description: result.description,
301
- ...(host ? { use_as: host } : {}) });
302
  onSaved(saved, saved.voice_id);
303
  onDone();
304
  } catch (e) {
@@ -313,9 +116,9 @@ function GenerateVoicePanel({
313
  <div className="stack">
314
  <Tabs<GenMode>
315
  tabs={[
316
- { id: "options", label: "Build from options" },
317
  { id: "prompt", label: "Describe it" },
318
  { id: "clone", label: "Clone my voice" },
 
319
  ]}
320
  value={mode}
321
  onChange={(m) => {
@@ -329,8 +132,8 @@ function GenerateVoicePanel({
329
  <div className="gen-options">
330
  {GEN_OPTIONS.map((g) => (
331
  <div key={g.key} className="gen-row">
332
- <span className="mono muted small">{g.label}</span>
333
- <div className="gen-chips">
334
  {g.values.map((v) => (
335
  <button key={v} type="button" className={`chip${opts[g.key] === v ? " on" : ""}`} aria-pressed={opts[g.key] === v} onClick={() => setOpts({ ...opts, [g.key]: v })}>
336
  {v}
@@ -364,7 +167,7 @@ function GenerateVoicePanel({
364
  {mode === "clone" &&
365
  (state.clone.allowed ? (
366
  <>
367
- <p className="muted">Read a passage from your own writing for one to three minutes. Your voice becomes Host A, so every audio overview sounds like you.</p>
368
  <div className="row">
369
  <button
370
  className="btn btn-primary"
@@ -414,7 +217,7 @@ function GenerateVoicePanel({
414
  <div className="row">
415
  <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" />
416
  <button className="btn btn-primary" onClick={save} disabled={busy !== null || !picked || !name.trim()}>
417
- {busy === "save" ? "Saving..." : hostLabel ? `Save and use as ${hostLabel}` : "Save voice"}
418
  </button>
419
  </div>
420
  </div>
@@ -426,152 +229,6 @@ function GenerateVoicePanel({
426
  );
427
  }
428
 
429
- // Voice library (commercially licensed voices from ElevenLabs)
430
-
431
- function LibraryBrowser({ host, onAdded }: { host: Host; onAdded: (s: VoiceState) => void }) {
432
- const [filters, setFilters] = useState({ search: "", gender: "", age: "", accent: "", use_case: "" });
433
- const [voices, setVoices] = useState<LibraryVoice[] | null>(null);
434
- const [page, setPage] = useState(0);
435
- const [hasMore, setHasMore] = useState(false);
436
- const [adding, setAdding] = useState<string | null>(null);
437
- const [error, setError] = useState<string | null>(null);
438
- const { play, playing } = usePlayer();
439
-
440
- const load = useCallback(
441
- async (p = 0) => {
442
- setError(null);
443
- try {
444
- const res = await voiceApi.library({ ...filters, page: p });
445
- setVoices((cur) => (p ? [...(cur ?? []), ...res.voices] : res.voices));
446
- setHasMore(res.has_more);
447
- setPage(p);
448
- } catch (e) {
449
- setError(errorMessage(e));
450
- setVoices([]);
451
- }
452
- },
453
- [filters],
454
- );
455
-
456
- useEffect(() => {
457
- const t = setTimeout(() => load(0), 250);
458
- return () => clearTimeout(t);
459
- }, [load]);
460
-
461
- const select = (key: keyof typeof filters, options: [string, string][]) => (
462
- <select className="input input-sm" value={filters[key]} onChange={(e) => setFilters({ ...filters, [key]: e.target.value })} aria-label={key}>
463
- {options.map(([v, l]) => (
464
- <option key={v} value={v}>
465
- {l}
466
- </option>
467
- ))}
468
- </select>
469
- );
470
-
471
- return (
472
- <>
473
- <div className="stack">
474
- <p className="muted small">
475
- Voices from the ElevenLabs Voice Library, licensed for commercial use on paid ElevenLabs plans. Adding one puts it in your ElevenLabs account.
476
- </p>
477
- <div className="library-filters">
478
- <input className="input input-sm" placeholder="Search: calm narrator, british, podcast" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} aria-label="Search voices" />
479
- {select("gender", [["", "Any gender"], ["female", "Female"], ["male", "Male"], ["neutral", "Neutral"]])}
480
- {select("age", [["", "Any age"], ["young", "Young"], ["middle_aged", "Middle aged"], ["old", "Older"]])}
481
- {select("accent", [["", "Any accent"], ["american", "American"], ["british", "British"], ["australian", "Australian"], ["irish", "Irish"], ["indian", "Indian"], ["african", "African"]])}
482
- {select("use_case", [["", "Any use"], ["conversational", "Conversational"], ["narrative_story", "Narration"], ["informative_educational", "Educational"], ["social_media", "Social media"], ["entertainment_tv", "Entertainment"]])}
483
- </div>
484
- {error && <p className="error-text">{error}</p>}
485
- {!voices && <Loading label="Searching voices" />}
486
- <ul className="library-grid">
487
- {voices?.map((v) => (
488
- <li key={`${v.public_owner_id}-${v.voice_id}`} className="library-voice">
489
- <div className="row between">
490
- <strong>{v.name}</strong>
491
- {v.preview_url && (
492
- <button className="btn btn-small" onClick={() => play(v.preview_url!, v.voice_id)} aria-label={`Play ${v.name}`}>
493
- {playing === v.voice_id ? "Stop" : "Play"}
494
- </button>
495
- )}
496
- </div>
497
- <p className="mono muted small">{[v.gender, v.age?.replace("_", " "), v.accent, v.use_case?.replace(/_/g, " ")].filter(Boolean).join(" · ")}</p>
498
- {v.description && <p className="muted small clamp-3">{v.description}</p>}
499
- {v.notice_period ? <p className="mono muted small">Owner notice period: {v.notice_period} days</p> : null}
500
- <button
501
- className="btn btn-small btn-primary"
502
- disabled={adding !== null}
503
- onClick={async () => {
504
- setAdding(v.voice_id);
505
- setError(null);
506
- try {
507
- onAdded(await voiceApi.addFromLibrary(v, host));
508
- } catch (e) {
509
- setError(errorMessage(e));
510
- } finally {
511
- setAdding(null);
512
- }
513
- }}
514
- >
515
- {adding === v.voice_id ? "Adding..." : `Use as ${host === "host_a" ? "Host A" : "Host B"}`}
516
- </button>
517
- </li>
518
- ))}
519
- {voices?.length === 0 && !error && <li className="muted">No voices match those filters.</li>}
520
- </ul>
521
- {hasMore && (
522
- <button className="btn" onClick={() => load(page + 1)}>
523
- More voices
524
- </button>
525
- )}
526
- </div>
527
- </>
528
- );
529
- }
530
-
531
- // Managing a host's voices: add one from the library, or make your own
532
-
533
- type ManageTab = "library" | "add";
534
-
535
- function ManageHostVoicesModal({
536
- host,
537
- state,
538
- onClose,
539
- onSaved,
540
- onClone,
541
- }: {
542
- host: Host;
543
- state: VoiceState;
544
- onClose: () => void;
545
- onSaved: (s: VoiceState) => void;
546
- onClone: () => void;
547
- }) {
548
- const [tab, setTab] = useState<ManageTab>("library");
549
- const { openUpgrade } = useUpgrade();
550
-
551
- return (
552
- <Modal title={`Voices for ${host === "host_a" ? "Host A" : "Host B"}`} onClose={onClose} wide>
553
- <div className="stack">
554
- <Tabs<ManageTab>
555
- tabs={[
556
- { id: "library", label: "Voice library" },
557
- { id: "add", label: "Add your voice" },
558
- ]}
559
- value={tab}
560
- onChange={setTab}
561
- />
562
- {/* Either way the voice is set for this host and shows in the rows, so the modal closes */}
563
- {tab === "library" && <LibraryBrowser host={host} onAdded={(s) => { onSaved(s); onClose(); }} />}
564
- {tab === "add" && (
565
- <GenerateVoicePanel host={host} state={state} onSaved={(s) => onSaved(s)} onDone={onClose}
566
- onClone={() => (state.clone.allowed ? onClone() : openUpgrade("writer"))} />
567
- )}
568
- </div>
569
- </Modal>
570
- );
571
- }
572
-
573
- // Cloning
574
-
575
  type Take = { file: File; url: string; seconds: number };
576
 
577
  function Recorder({ onTake }: { onTake: (t: Take) => void }) {
@@ -764,29 +421,31 @@ function CloneModal({ state, onClose, onDone }: { state: VoiceState; onClose: ()
764
 
765
  type Step = "writing" | "hosts";
766
 
767
- const STEPS: { id: Step; label: string }[] = [
768
- { id: "writing", label: "Writing voice" },
769
- { id: "hosts", label: "Speaking voices" },
770
- ];
 
 
 
 
 
 
 
771
 
772
  export default function VoiceProfile() {
773
  const [state, setState] = useState<VoiceState | null>(null);
774
  const [draft, setDraft] = useState<VoiceProfileData | null>(null);
775
- const [voices, setVoices] = useState<Voice[]>([]);
776
  const [params] = useSearchParams();
777
- // ?step=hosts opens Speaking voices (the old Videos > Voices link lands there, on the video voices).
778
- const [step, setStep] = useState<Step | null>(params.get("step") === "hosts" ? "hosts" : null);
779
  const [picking, setPicking] = useState(false);
780
  const [samples, setSamples] = useState<string[]>([]);
781
  const [buildJob, setBuildJob] = useState<Job | null>(null);
782
  const [cloneJob, setCloneJob] = useState<Job | null>(null);
783
  const [cloning, setCloning] = useState(false);
784
- const [managing, setManaging] = useState<Host | null>(null);
785
- // The Speaking voices card's two tabs. #video-voices (the old Videos > Voices link) opens the video one.
786
- const [speakTab, setSpeakTab] = useState<"hosts" | "video">(
787
- typeof window !== "undefined" && window.location.hash === "#video-voices" ? "video" : "hosts",
788
- );
789
- const [videoKey, setVideoKey] = useState(0);
790
  const { openUpgrade } = useUpgrade();
791
  const [saved, setSaved] = useState(false);
792
  const [error, setError] = useState<string | null>(null);
@@ -798,10 +457,9 @@ export default function VoiceProfile() {
798
  setState(s);
799
  setDraft(s.profile);
800
  setSamples(s.sample_doc_ids);
801
- setStep((cur) => cur ?? (s.profile ? "hosts" : "writing"));
802
  return s;
803
  });
804
- const loadVoices = () => voiceApi.voices().then(setVoices, () => setVoices([]));
805
 
806
  const startBuild = async (ids: string[]) => {
807
  setPicking(false);
@@ -821,21 +479,18 @@ export default function VoiceProfile() {
821
  void startBuild([]);
822
  }
823
  });
824
- loadVoices();
825
  // eslint-disable-next-line react-hooks/exhaustive-deps
826
  }, []);
827
 
828
  const build = useJob(buildJob, () => load());
829
  const clone = useJob(cloneJob, () => {
830
  load();
831
- loadVoices();
832
- setVideoKey((k) => k + 1); // a new clone shows up under video voiceovers' Your voices
833
  });
834
 
835
- if (!state || !step) return <Loading />;
836
  const building = build && (build.status === "queued" || build.status === "running");
837
  const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
838
- const done: Record<Step, boolean> = { writing: Boolean(state.profile), hosts: Boolean(state.profile) && step === "hosts" };
839
  const showClone = state.clone.status !== "none" || Boolean(clone);
840
 
841
  const saveProfile = async () => {
@@ -850,22 +505,14 @@ export default function VoiceProfile() {
850
  };
851
 
852
  return (
853
- <div className="page-wrap vp">
854
- <PageHeader eyebrow="Voice profile" title="How you sound, on the page and out loud" />
855
 
856
- <ol className="vp-steps mono" aria-label="Voice profile steps">
857
- {STEPS.map((s, i) => (
858
- <li key={s.id} className={s.id === step ? "current" : done[s.id] ? "done" : ""}>
859
- <button type="button" onClick={() => setStep(s.id)} aria-current={s.id === step ? "step" : undefined}>
860
- <span className="vp-dot" />
861
- <span>
862
- {i + 1}. {s.label}
863
- {done[s.id] && s.id !== step ? " ✓" : ""}
864
- </span>
865
- </button>
866
- </li>
867
- ))}
868
- </ol>
869
  {error && <p className="error-text">{error}</p>}
870
 
871
  {step === "writing" && (
@@ -922,9 +569,6 @@ export default function VoiceProfile() {
922
  <button className="btn btn-small" onClick={saveProfile}>
923
  {saved ? "Saved" : "Save changes"}
924
  </button>
925
- <button className="btn btn-primary" onClick={() => setStep("hosts")}>
926
- Next: pick your hosts
927
- </button>
928
  </div>
929
  </>
930
  )}
@@ -934,47 +578,14 @@ export default function VoiceProfile() {
934
  {step === "hosts" && (
935
  <section className="card stack vp-card">
936
  <h2>Who reads your work aloud</h2>
937
- <Tabs<"hosts" | "video">
938
- tabs={[
939
- { id: "hosts", label: "Hosts Audios" },
940
- { id: "video", label: "Video voiceovers" },
941
- ]}
942
- value={speakTab}
943
- onChange={setSpeakTab}
944
- />
945
- {speakTab === "video" && (
946
- <VideoVoicesSection reloadKey={videoKey}
947
- renderAddVoice={(onDone, close) => (
948
- <GenerateVoicePanel
949
- host={null}
950
- state={state}
951
- onDone={onDone}
952
- onSaved={(s, voiceId) => {
953
- setState(s);
954
- loadVoices();
955
- // Made for video voiceovers: added there straight away (room and plan permitting).
956
- videoVoicesApi.save(voiceId)
957
- .catch((e) => setError(`Saved to your voices, not added to video voiceovers: ${errorMessage(e)}`))
958
- .finally(() => setVideoKey((k) => k + 1));
959
- }}
960
- onClone={() => {
961
- close(); // the recorder opens in its own modal
962
- if (state.clone.allowed) setCloning(true);
963
- else openUpgrade("writer");
964
- }}
965
- />
966
- )} />
967
- )}
968
- {speakTab === "hosts" && (
969
- <>
970
  {!state.tts_configured && <p className="muted">Voice previews are unavailable right now.</p>}
971
- <VoicePicker state={state} voices={voices} onSaved={setState} onManage={setManaging} />
972
 
973
  {showClone && (
974
  <div className="vp-clone">
975
  <strong>Your cloned voice</strong>
976
  {clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} compact />}
977
- {state.clone.status === "processing" && !clone && <p className="muted small">Your voice is being created. It becomes Host A when ready.</p>}
978
  {state.clone.status === "failed" && state.clone.error && !cloneRunning && <p className="error-text">{state.clone.error}</p>}
979
  <div className="row">
980
  {state.clone.status === "ready" && state.clone.preview_url && (
@@ -1001,19 +612,6 @@ export default function VoiceProfile() {
1001
  </div>
1002
  </div>
1003
  )}
1004
-
1005
- </>
1006
- )}
1007
-
1008
- <div className="row">
1009
- <button className="back-link" onClick={() => setStep("writing")}>
1010
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"
1011
- strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
1012
- <path d="M19 12H5m6-6-6 6 6 6" />
1013
- </svg>
1014
- Back to writing voice
1015
- </button>
1016
- </div>
1017
  </section>
1018
  )}
1019
 
@@ -1029,20 +627,22 @@ export default function VoiceProfile() {
1029
  </Modal>
1030
  )}
1031
  {cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
1032
- {managing && (
1033
- <ManageHostVoicesModal
1034
- host={managing}
1035
- state={state}
1036
- onClose={() => setManaging(null)}
1037
- onSaved={(s) => {
1038
- setState(s);
1039
- loadVoices();
1040
- }}
1041
- onClone={() => {
1042
- setCloning(true);
1043
- setManaging(null);
1044
- }}
1045
- />
 
 
1046
  )}
1047
  </div>
1048
  );
 
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
  import { useSearchParams } from "react-router-dom";
3
+ import { voiceApi } from "../api/endpoints";
4
+ import type { Job, VoiceDesignInput, VoiceDesignPreview, VoiceProfileData, VoiceState } from "../api/types";
5
  import { DocPicker } from "../components/DocPicker";
6
+ import { YourVoices } from "../components/voice/YourVoices";
7
+ import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, Tabs } from "../components/ui";
 
8
  import { useJob } from "../hooks/useJob";
9
  import { useUpgrade } from "../hooks/useUpgrade";
10
 
 
 
11
  const LIST_FIELDS: { key: keyof VoiceProfileData; label: string; hint: string }[] = [
12
  { key: "tone", label: "Tone", hint: "warm, wry, direct" },
13
  { key: "vocabulary", label: "Signature words and phrases", hint: "readers, the long game" },
 
16
  { key: "avoid", label: "Never does", hint: "jargon, hashtags" },
17
  ];
18
 
 
 
 
 
 
 
 
19
  /** Plays one URL at a time across the page. */
20
  function usePlayer() {
21
  const audio = useRef<HTMLAudioElement | null>(null);
 
40
  return `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`;
41
  }
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  type GenMode = "options" | "prompt" | "clone";
44
 
45
  const GEN_OPTIONS: { key: keyof Omit<VoiceDesignInput, "prompt">; label: string; values: string[] }[] = [
 
58
 
59
  /** Make a new voice: build it from options, describe it, or clone your own. Inside the Manage voices modals. */
60
  function GenerateVoicePanel({
 
61
  state,
62
  onDone,
63
  onSaved,
64
  onClone,
65
  }: {
 
 
66
  state: VoiceState;
67
  /** Saved: the caller closes or switches away. */
68
  onDone: () => void;
69
  onSaved: (s: VoiceState, voiceId: string) => void;
70
  onClone: () => void;
71
  }) {
72
+ const [mode, setMode] = useState<GenMode>("prompt"); // Describe it first
73
  const [opts, setOpts] = useState<VoiceDesignInput>({ gender: "female", age: "middle-aged", persona: "warm", pace: "measured", accent: "American" });
74
  const [prompt, setPrompt] = useState("");
75
  const [result, setResult] = useState<{ description: string; previews: VoiceDesignPreview[] } | null>(null);
 
79
  const [error, setError] = useState<string | null>(null);
80
  const { play, playing } = usePlayer();
81
  const { openUpgrade } = useUpgrade();
 
82
 
83
  const generate = async () => {
84
  setBusy("generate");
 
101
  setBusy("save");
102
  setError(null);
103
  try {
104
+ const saved = await voiceApi.saveDesign({ generated_voice_id: picked, name: name.trim(), description: result.description });
 
105
  onSaved(saved, saved.voice_id);
106
  onDone();
107
  } catch (e) {
 
116
  <div className="stack">
117
  <Tabs<GenMode>
118
  tabs={[
 
119
  { id: "prompt", label: "Describe it" },
120
  { id: "clone", label: "Clone my voice" },
121
+ { id: "options", label: "Build from options" },
122
  ]}
123
  value={mode}
124
  onChange={(m) => {
 
132
  <div className="gen-options">
133
  {GEN_OPTIONS.map((g) => (
134
  <div key={g.key} className="gen-row">
135
+ <span className="gen-label">{g.label}</span>
136
+ <div className="gen-chips" role="group" aria-label={g.label}>
137
  {g.values.map((v) => (
138
  <button key={v} type="button" className={`chip${opts[g.key] === v ? " on" : ""}`} aria-pressed={opts[g.key] === v} onClick={() => setOpts({ ...opts, [g.key]: v })}>
139
  {v}
 
167
  {mode === "clone" &&
168
  (state.clone.allowed ? (
169
  <>
170
+ <p className="muted">Read a passage from your own writing for one to three minutes. Your voice joins your voices, ready for audio overviews and videos.</p>
171
  <div className="row">
172
  <button
173
  className="btn btn-primary"
 
217
  <div className="row">
218
  <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" />
219
  <button className="btn btn-primary" onClick={save} disabled={busy !== null || !picked || !name.trim()}>
220
+ {busy === "save" ? "Saving..." : "Save voice"}
221
  </button>
222
  </div>
223
  </div>
 
229
  );
230
  }
231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  type Take = { file: File; url: string; seconds: number };
233
 
234
  function Recorder({ onTake }: { onTake: (t: Take) => void }) {
 
421
 
422
  type Step = "writing" | "hosts";
423
 
424
+ /** The underlined link between the two views (Speaking voices <-> Writing voice), its arrow pointing the way. */
425
+ function ViewLink({ to, label, onGo }: { to: Step; label: string; onGo: (s: Step) => void }) {
426
+ const back = to === "hosts";
427
+ return (
428
+ <button type="button" className={`vp-switch${back ? " back" : ""}`} onClick={() => onGo(to)}>
429
+ {back && <span aria-hidden="true">←</span>}
430
+ <span className="vp-switch-text">{label}</span>
431
+ {!back && <span aria-hidden="true">→</span>}
432
+ </button>
433
+ );
434
+ }
435
 
436
  export default function VoiceProfile() {
437
  const [state, setState] = useState<VoiceState | null>(null);
438
  const [draft, setDraft] = useState<VoiceProfileData | null>(null);
 
439
  const [params] = useSearchParams();
440
+ // Opens on Speaking voices; ?step=writing opens the writing voice (links between the two switch views).
441
+ const [step, setStep] = useState<Step>(params.get("step") === "writing" ? "writing" : "hosts");
442
  const [picking, setPicking] = useState(false);
443
  const [samples, setSamples] = useState<string[]>([]);
444
  const [buildJob, setBuildJob] = useState<Job | null>(null);
445
  const [cloneJob, setCloneJob] = useState<Job | null>(null);
446
  const [cloning, setCloning] = useState(false);
447
+ const [adding, setAdding] = useState(false); // the Add voices modal
448
+ const [voicesKey, setVoicesKey] = useState(0); // bumped when a voice was made, so Your voices reloads
 
 
 
 
449
  const { openUpgrade } = useUpgrade();
450
  const [saved, setSaved] = useState(false);
451
  const [error, setError] = useState<string | null>(null);
 
457
  setState(s);
458
  setDraft(s.profile);
459
  setSamples(s.sample_doc_ids);
 
460
  return s;
461
  });
462
+ const loadVoices = () => setVoicesKey((k) => k + 1);
463
 
464
  const startBuild = async (ids: string[]) => {
465
  setPicking(false);
 
479
  void startBuild([]);
480
  }
481
  });
 
482
  // eslint-disable-next-line react-hooks/exhaustive-deps
483
  }, []);
484
 
485
  const build = useJob(buildJob, () => load());
486
  const clone = useJob(cloneJob, () => {
487
  load();
488
+ loadVoices(); // a finished clone is saved to Your voices as "My voice"
 
489
  });
490
 
491
+ if (!state) return <Loading />;
492
  const building = build && (build.status === "queued" || build.status === "running");
493
  const cloneRunning = clone && (clone.status === "queued" || clone.status === "running");
 
494
  const showClone = state.clone.status !== "none" || Boolean(clone);
495
 
496
  const saveProfile = async () => {
 
505
  };
506
 
507
  return (
508
+ // The Library's Voices tab: speaking voices, and the writing voice behind a link.
509
+ <div className="stack vp">
510
 
511
+ <div className="vp-switch-row">
512
+ {step === "hosts"
513
+ ? <ViewLink to="writing" label="Writing voice" onGo={setStep} />
514
+ : <ViewLink to="hosts" label="Speaking voices" onGo={setStep} />}
515
+ </div>
 
 
 
 
 
 
 
 
516
  {error && <p className="error-text">{error}</p>}
517
 
518
  {step === "writing" && (
 
569
  <button className="btn btn-small" onClick={saveProfile}>
570
  {saved ? "Saved" : "Save changes"}
571
  </button>
 
 
 
572
  </div>
573
  </>
574
  )}
 
578
  {step === "hosts" && (
579
  <section className="card stack vp-card">
580
  <h2>Who reads your work aloud</h2>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
581
  {!state.tts_configured && <p className="muted">Voice previews are unavailable right now.</p>}
582
+ <YourVoices reloadKey={voicesKey} onAdd={() => setAdding(true)} />
583
 
584
  {showClone && (
585
  <div className="vp-clone">
586
  <strong>Your cloned voice</strong>
587
  {clone && (cloneRunning || clone.status === "failed") && <JobProgress job={clone} compact />}
588
+ {state.clone.status === "processing" && !clone && <p className="muted small">Your voice is being created. It joins your voices when ready.</p>}
589
  {state.clone.status === "failed" && state.clone.error && !cloneRunning && <p className="error-text">{state.clone.error}</p>}
590
  <div className="row">
591
  {state.clone.status === "ready" && state.clone.preview_url && (
 
612
  </div>
613
  </div>
614
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  </section>
616
  )}
617
 
 
627
  </Modal>
628
  )}
629
  {cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
630
+ {adding && (
631
+ <Modal title="Add voices" onClose={() => setAdding(false)} wide>
632
+ <GenerateVoicePanel
633
+ state={state}
634
+ onDone={() => setAdding(false)}
635
+ onSaved={(s) => {
636
+ setState(s);
637
+ loadVoices(); // the server saved it to Your voices
638
+ }}
639
+ onClone={() => {
640
+ setAdding(false); // the recorder opens in its own modal
641
+ if (state.clone.allowed) setCloning(true);
642
+ else openUpgrade("writer");
643
+ }}
644
+ />
645
+ </Modal>
646
  )}
647
  </div>
648
  );
frontend/src/styles/pages.css CHANGED
@@ -662,7 +662,6 @@ select.input option {
662
  accent-color: var(--blue);
663
  }
664
 
665
-
666
  .picker-list {
667
  list-style: none;
668
  margin: 0;
@@ -2180,51 +2179,52 @@ select.input option {
2180
  animation: welcome-in 0.35s ease both;
2181
  }
2182
 
2183
- .vp-steps {
2184
- list-style: none;
2185
- margin: 0;
2186
- padding: 0;
2187
- display: grid;
2188
- grid-template-columns: repeat(2, 1fr);
2189
- gap: 8px;
2190
- font-size: 0.75rem;
2191
  }
2192
 
2193
- .vp-steps button {
2194
- display: grid;
 
2195
  gap: 8px;
2196
- width: 100%;
2197
- padding: 0;
2198
  border: 0;
2199
  background: none;
2200
- color: var(--text-3);
2201
  font: inherit;
2202
- text-align: left;
2203
  cursor: pointer;
 
2204
  }
2205
 
2206
- .vp-steps li.done button {
2207
- color: var(--text-2);
 
 
 
2208
  }
2209
 
2210
- .vp-steps li.current button {
2211
- color: var(--white);
2212
  }
2213
 
2214
- .vp-dot {
2215
- height: 3px;
2216
- border-radius: 999px;
2217
- background: var(--line-strong);
2218
- transition: background 0.3s, box-shadow 0.3s;
2219
  }
2220
 
2221
- .vp-steps li.done .vp-dot {
2222
- background: var(--blue-60);
2223
  }
2224
 
2225
- .vp-steps li.current .vp-dot {
2226
- background: var(--blue);
2227
- box-shadow: var(--glow-sm);
 
 
 
2228
  }
2229
 
2230
  .voice-grid {
@@ -2352,14 +2352,69 @@ select.input option {
2352
  border-color: var(--blue);
2353
  }
2354
 
 
2355
  .gen-options {
2356
  display: grid;
2357
- gap: 12px;
 
 
 
2358
  }
2359
 
2360
  .gen-row {
2361
  display: grid;
2362
- gap: 6px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2363
  }
2364
 
2365
  .gen-chips {
@@ -2478,30 +2533,6 @@ select.input option {
2478
  grid-column: 1 / -1;
2479
  }
2480
 
2481
- .library-grid {
2482
- list-style: none;
2483
- margin: 0;
2484
- padding: 0;
2485
- display: grid;
2486
- gap: 10px;
2487
- grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
2488
- max-height: 52vh;
2489
- overflow: auto;
2490
- }
2491
-
2492
- .library-voice {
2493
- display: grid;
2494
- gap: 6px;
2495
- align-content: start;
2496
- padding: 12px;
2497
- border: 1px solid var(--line);
2498
- border-radius: var(--radius-sm);
2499
- }
2500
-
2501
- .library-voice:hover {
2502
- border-color: var(--blue-35);
2503
- }
2504
-
2505
  .clone-tips {
2506
  margin: 0;
2507
  padding-left: 18px;
@@ -2777,7 +2808,6 @@ select.input option {
2777
  .artifact-head > div,
2778
  .source-title,
2779
  .nb-card,
2780
- .library-voice,
2781
  .host-card,
2782
  .post-edit,
2783
  .slide-edit,
@@ -4082,27 +4112,11 @@ details.map-legend[open] summary {
4082
  white-space: nowrap;
4083
  }
4084
 
4085
-
4086
  .voice-new .voice-pick:disabled {
4087
  cursor: not-allowed;
4088
  opacity: 0.6;
4089
  }
4090
 
4091
- /* The host a voice row belongs to (Audio hosts list) */
4092
- .voice-badge-inline {
4093
- padding: 3px 10px;
4094
- border-radius: 999px;
4095
- background: var(--blue);
4096
- color: var(--white);
4097
- font-size: 0.72rem;
4098
- letter-spacing: 0.06em;
4099
- }
4100
-
4101
- .voice-badge-inline.dim {
4102
- background: var(--surface-2, rgba(255, 255, 255, 0.08));
4103
- color: var(--text-2);
4104
- }
4105
-
4106
  /* Audio hosts / Video voiceovers: the explainer takes the room, the Manage button sits at the right end */
4107
  .manage-top > p {
4108
  flex: 1;
@@ -4141,98 +4155,6 @@ details.map-legend[open] summary {
4141
  transition: transform 0.15s;
4142
  }
4143
 
4144
- /* Delivery settings for a host: a text link with a chevron that opens a two-column grid of sliders */
4145
- .delivery-card summary {
4146
- display: inline-flex;
4147
- align-items: center;
4148
- gap: 6px;
4149
- color: var(--text-2);
4150
- font-size: 0.9rem;
4151
- cursor: pointer;
4152
- list-style: none;
4153
- }
4154
-
4155
- .delivery-card summary::-webkit-details-marker {
4156
- display: none;
4157
- }
4158
-
4159
- .delivery-card summary:hover {
4160
- color: var(--text);
4161
- }
4162
-
4163
- .delivery-chevron {
4164
- flex-shrink: 0;
4165
- transition: transform 0.2s;
4166
- }
4167
-
4168
- .delivery-card[open] .delivery-chevron {
4169
- transform: rotate(180deg);
4170
- }
4171
-
4172
- .delivery-grid {
4173
- display: grid;
4174
- grid-template-columns: repeat(2, minmax(0, 1fr));
4175
- gap: 22px 32px;
4176
- padding-top: 18px;
4177
- }
4178
-
4179
- .delivery-slider {
4180
- display: grid;
4181
- gap: 8px;
4182
- font-size: 0.9rem;
4183
- }
4184
-
4185
- .delivery-value {
4186
- padding: 2px 8px;
4187
- border-radius: 999px;
4188
- background: rgba(33, 124, 255, 0.14);
4189
- color: var(--blue);
4190
- font-size: 0.78rem;
4191
- }
4192
-
4193
- /* A slim track, filled up to the thumb (--pct) */
4194
- .delivery-slider input[type="range"] {
4195
- width: 100%;
4196
- height: 4px;
4197
- border-radius: 999px;
4198
- background: linear-gradient(to right, var(--blue) var(--pct, 50%), var(--line-strong) var(--pct, 50%));
4199
- appearance: none;
4200
- outline: none;
4201
- cursor: pointer;
4202
- }
4203
-
4204
- .delivery-slider input[type="range"]::-webkit-slider-thumb {
4205
- width: 16px;
4206
- height: 16px;
4207
- border: 2px solid var(--white);
4208
- border-radius: 50%;
4209
- background: var(--blue);
4210
- box-shadow: 0 0 0 4px rgba(33, 124, 255, 0.2);
4211
- appearance: none;
4212
- }
4213
-
4214
- .delivery-slider input[type="range"]::-moz-range-thumb {
4215
- width: 14px;
4216
- height: 14px;
4217
- border: 2px solid var(--white);
4218
- border-radius: 50%;
4219
- background: var(--blue);
4220
- }
4221
-
4222
- .delivery-slider input[type="range"]:focus-visible::-webkit-slider-thumb {
4223
- box-shadow: 0 0 0 6px rgba(33, 124, 255, 0.35);
4224
- }
4225
-
4226
- .delivery-actions {
4227
- padding-top: 14px;
4228
- }
4229
-
4230
- @media (max-width: 640px) {
4231
- .delivery-grid {
4232
- grid-template-columns: 1fr;
4233
- }
4234
- }
4235
-
4236
  .mm-row-progress {
4237
  flex-basis: 100%;
4238
  padding: 0 6px 6px;
 
662
  accent-color: var(--blue);
663
  }
664
 
 
665
  .picker-list {
666
  list-style: none;
667
  margin: 0;
 
2179
  animation: welcome-in 0.35s ease both;
2180
  }
2181
 
2182
+ /* The link between the two views (Speaking voices / Writing voice): underlined text and an arrow */
2183
+ .vp-switch-row {
2184
+ display: flex;
2185
+ justify-content: flex-end; /* at the right end */
2186
+ margin: -6px 0 4px;
 
 
 
2187
  }
2188
 
2189
+ .vp-switch {
2190
+ display: inline-flex;
2191
+ align-items: center;
2192
  gap: 8px;
2193
+ padding: 4px 0;
 
2194
  border: 0;
2195
  background: none;
2196
+ color: var(--text-2);
2197
  font: inherit;
2198
+ font-size: 0.95rem;
2199
  cursor: pointer;
2200
+ transition: color 0.15s;
2201
  }
2202
 
2203
+ .vp-switch-text {
2204
+ text-decoration: underline;
2205
+ text-decoration-color: var(--line-strong);
2206
+ text-underline-offset: 4px;
2207
+ transition: text-decoration-color 0.15s;
2208
  }
2209
 
2210
+ .vp-switch:hover {
2211
+ color: var(--text);
2212
  }
2213
 
2214
+ .vp-switch:hover .vp-switch-text {
2215
+ text-decoration-color: var(--blue);
 
 
 
2216
  }
2217
 
2218
+ .vp-switch > span[aria-hidden] {
2219
+ transition: transform 0.15s;
2220
  }
2221
 
2222
+ .vp-switch:hover > span[aria-hidden] {
2223
+ transform: translateX(3px);
2224
+ }
2225
+
2226
+ .vp-switch.back:hover > span[aria-hidden] {
2227
+ transform: translateX(-3px);
2228
  }
2229
 
2230
  .voice-grid {
 
2352
  border-color: var(--blue);
2353
  }
2354
 
2355
+ /* Build from options: one grey card, a label column on the left and its choices on the right, a line between rows */
2356
  .gen-options {
2357
  display: grid;
2358
+ padding: 6px 18px;
2359
+ border: 1px solid var(--line);
2360
+ border-radius: var(--radius-lg);
2361
+ background: rgba(255, 255, 255, 0.05);
2362
  }
2363
 
2364
  .gen-row {
2365
  display: grid;
2366
+ grid-template-columns: 110px minmax(0, 1fr);
2367
+ align-items: start; /* the label lines up with the first line of chips, also when they wrap */
2368
+ gap: 8px 16px;
2369
+ padding: 18px 0;
2370
+ }
2371
+
2372
+ .gen-row + .gen-row {
2373
+ border-top: 1px solid var(--line);
2374
+ }
2375
+
2376
+ .gen-label {
2377
+ color: var(--text-2);
2378
+ font-size: 0.85rem;
2379
+ font-weight: 500;
2380
+ line-height: 32px; /* a chip's height */
2381
+ }
2382
+
2383
+ .gen-options .gen-chips {
2384
+ gap: 8px;
2385
+ }
2386
+
2387
+ /* Its chips: smaller than the app's default chip (the later .chip rule), only in this card */
2388
+ /* Soft pills: a faint fill and border; the picked one is tinted blue rather than solid */
2389
+ .gen-options .gen-chips .chip {
2390
+ height: 32px;
2391
+ padding: 0 14px;
2392
+ border: 1px solid var(--line);
2393
+ border-radius: 999px;
2394
+ background: rgba(255, 255, 255, 0.04);
2395
+ color: var(--text-2);
2396
+ font-size: 0.85rem;
2397
+ font-weight: 400;
2398
+ line-height: 30px;
2399
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
2400
+ }
2401
+
2402
+ .gen-options .gen-chips .chip:hover {
2403
+ border-color: var(--line-strong);
2404
+ background: rgba(255, 255, 255, 0.08);
2405
+ color: var(--text);
2406
+ }
2407
+
2408
+ .gen-options .gen-chips .chip.on {
2409
+ border-color: rgba(33, 124, 255, 0.55);
2410
+ background: rgba(33, 124, 255, 0.2);
2411
+ color: var(--white);
2412
+ }
2413
+
2414
+ @media (max-width: 560px) {
2415
+ .gen-row {
2416
+ grid-template-columns: minmax(0, 1fr);
2417
+ }
2418
  }
2419
 
2420
  .gen-chips {
 
2533
  grid-column: 1 / -1;
2534
  }
2535
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2536
  .clone-tips {
2537
  margin: 0;
2538
  padding-left: 18px;
 
2808
  .artifact-head > div,
2809
  .source-title,
2810
  .nb-card,
 
2811
  .host-card,
2812
  .post-edit,
2813
  .slide-edit,
 
4112
  white-space: nowrap;
4113
  }
4114
 
 
4115
  .voice-new .voice-pick:disabled {
4116
  cursor: not-allowed;
4117
  opacity: 0.6;
4118
  }
4119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4120
  /* Audio hosts / Video voiceovers: the explainer takes the room, the Manage button sits at the right end */
4121
  .manage-top > p {
4122
  flex: 1;
 
4155
  transition: transform 0.15s;
4156
  }
4157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4158
  .mm-row-progress {
4159
  flex-basis: 100%;
4160
  padding: 0 6px 6px;
frontend/src/styles/videos.css CHANGED
@@ -477,6 +477,74 @@
477
  gap: 2px;
478
  }
479
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
480
  .vw-vrow:hover {
481
  border-color: var(--line-strong);
482
  }
 
477
  gap: 2px;
478
  }
479
 
480
+ /* The Voice page's two lists (Your voices, Voice library): each in its own grey panel so they read as separate */
481
+ .yv-panel {
482
+ gap: 12px;
483
+ padding: 16px;
484
+ border: 1px solid var(--line);
485
+ border-radius: var(--radius-lg);
486
+ background: rgba(255, 255, 255, 0.05);
487
+ }
488
+
489
+ .yv-panel + .yv-panel {
490
+ margin-top: 8px;
491
+ }
492
+
493
+ /* Section headings: a step up from the rows, with the count in a small pill */
494
+ .yv-head {
495
+ display: flex;
496
+ align-items: center;
497
+ gap: 10px;
498
+ margin: 0;
499
+ font-size: 1.2rem;
500
+ font-weight: 600;
501
+ letter-spacing: -0.01em;
502
+ }
503
+
504
+ .yv-count {
505
+ padding: 2px 9px;
506
+ border-radius: 999px;
507
+ background: var(--surface-2);
508
+ color: var(--text-2);
509
+ font-size: 0.78rem;
510
+ font-weight: 500;
511
+ }
512
+
513
+ /* Their rows: darker than the panel, four and a half in view, the rest scrolls inside */
514
+ .vw-voice-list-tight {
515
+ gap: 6px;
516
+ max-height: calc(4.5 * 60px + 4 * 6px);
517
+ }
518
+
519
+ .vw-voice-list-tight .vw-vrow {
520
+ gap: 12px;
521
+ height: 60px;
522
+ padding: 0 12px 0 10px;
523
+ background: rgba(0, 0, 0, 0.35);
524
+ }
525
+
526
+ /* Your voices: four rows in view and a sliver of the fifth (so it reads as scrollable), plus the list's 2px padding */
527
+ .vw-voice-list-tight.vw-voice-list-four {
528
+ max-height: calc(4.3 * 60px + 4 * 6px + 2px);
529
+ }
530
+
531
+ .vw-voice-list-tight .vw-vplay {
532
+ width: 38px;
533
+ height: 38px;
534
+ }
535
+
536
+ .vw-voice-list-tight .vw-vpick {
537
+ gap: 1px;
538
+ }
539
+
540
+ .vw-voice-list-tight .vw-vpick strong {
541
+ font-size: 0.95rem;
542
+ }
543
+
544
+ .vw-voice-list-tight .vw-vpick span {
545
+ font-size: 0.8rem;
546
+ }
547
+
548
  .vw-vrow:hover {
549
  border-color: var(--line-strong);
550
  }