mattkevan commited on
Commit
af4851b
·
1 Parent(s): ba12f92

First in space

Browse files
README copy.md ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Scriptorium
3
+ emoji: 📚
4
+ colorFrom: yellow
5
+ colorTo: red
6
+ sdk: gradio
7
+ app_file: app.py
8
+ python_version: 3.10
9
+ pinned: false
10
+ license: mit
11
+ ---
12
+
13
+ # Scriptorium
14
+
15
+ Public Hugging Face Space for turning EPUBs into audiobooks with OmniVoice TTS.
16
+
17
+ ## Runtime notes
18
+
19
+ - Designed for a GPU-backed Gradio Space.
20
+ - Public demo safeguards cap EPUB size, estimated runtime, preview length, and clone sample duration.
21
+ - Outputs are session-scoped temporary files only.
22
+
23
+ ## Local development
24
+
25
+ ```bash
26
+ python3 -m venv .venv
27
+ source .venv/bin/activate
28
+ pip install -r requirements.txt
29
+ pytest -q
30
+ python app.py
31
+ ```
app.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import shutil
3
+ import socket
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
8
+ from fastapi.staticfiles import StaticFiles
9
+ from gradio import Server
10
+
11
+ from backend.config import (
12
+ APP_TITLE,
13
+ MAX_PREVIEW_CHARACTERS,
14
+ MAX_REFERENCE_AUDIO_SECONDS,
15
+ SESSION_TTL_SECONDS,
16
+ TEMP_ROOT,
17
+ )
18
+ from backend.epub import EpubConfig, parse_epub
19
+ from backend.export import export_audiobook
20
+ from backend.input_files import resolve_uploaded_name, resolve_uploaded_path
21
+ from backend.omnivoice_adapter import OmniVoiceAdapter
22
+ from backend.render_pipeline import RenderPipeline
23
+ from backend.session_store import SessionStore
24
+ from backend.types import VoiceConfig
25
+
26
+
27
+ ROOT = Path(__file__).parent
28
+ FRONTEND_DIR = ROOT / "frontend"
29
+ TEMP_ROOT.mkdir(parents=True, exist_ok=True)
30
+
31
+ app = Server()
32
+ app.title = APP_TITLE
33
+
34
+ store = SessionStore(root=TEMP_ROOT, ttl_seconds=SESSION_TTL_SECONDS)
35
+ synthesizer = OmniVoiceAdapter()
36
+ pipeline = RenderPipeline(session_root=TEMP_ROOT, synthesizer=synthesizer)
37
+
38
+
39
+ def _session_root(session_id: str) -> Path:
40
+ return store.ensure_session(session_id).root
41
+
42
+
43
+ def _book_payload(session_id: str) -> Dict[str, Any]:
44
+ return store.load_json(session_id, "book.json")
45
+
46
+
47
+ def _selected_book(session_id: str, selected_ids: List[str]) -> Dict[str, Any]:
48
+ book = _book_payload(session_id)
49
+ selected = set(selected_ids)
50
+ chapters = []
51
+ for chapter in book["chapters"]:
52
+ chapter = dict(chapter)
53
+ chapter["included"] = chapter["id"] in selected
54
+ chapters.append(chapter)
55
+ book["chapters"] = chapters
56
+ return book
57
+
58
+
59
+ def _voice_config_for_backend(voice_config: Dict[str, Any], session_id: str) -> Dict[str, Any]:
60
+ config = dict(voice_config)
61
+ sample_path = config.get("samplePath") or config.get("sample_path")
62
+ if sample_path:
63
+ sample_file = resolve_uploaded_path(sample_path)
64
+ target = _session_root(session_id) / "uploads" / sample_file.name
65
+ shutil.copyfile(sample_file, target)
66
+ config["samplePath"] = str(target)
67
+ return config
68
+
69
+
70
+ @app.api(name="parse_epub")
71
+ def parse_epub_api(session_id: str, epub_file: str) -> Dict[str, Any]:
72
+ store.cleanup_expired()
73
+ session = store.ensure_session(session_id)
74
+ source = resolve_uploaded_path(epub_file)
75
+ upload_name = resolve_uploaded_name(epub_file)
76
+ if not source.exists():
77
+ raise ValueError("Uploaded EPUB file was not found")
78
+ target = session.root / "uploads" / upload_name
79
+ shutil.copyfile(source, target)
80
+ payload = parse_epub(target, config=EpubConfig())
81
+ store.save_json(session_id, "book.json", payload)
82
+ return payload
83
+
84
+
85
+ @app.api(name="generate_preview")
86
+ def generate_preview_api(
87
+ session_id: str,
88
+ chapter_id: str,
89
+ voice_config: Dict[str, Any],
90
+ diffusion_steps: int = 32,
91
+ speed: float = 1.0,
92
+ ) -> Dict[str, Any]:
93
+ book = _book_payload(session_id)
94
+ chapter = next((item for item in book["chapters"] if item["id"] == chapter_id), None)
95
+ if chapter is None:
96
+ raise ValueError("Chapter not found for preview")
97
+ text = str(chapter["text"])[:MAX_PREVIEW_CHARACTERS]
98
+ preview_path = _session_root(session_id) / "previews" / f"{chapter_id}.wav"
99
+ voice = _voice_config_for_backend(voice_config, session_id)
100
+ result = synthesizer.synthesize(
101
+ text=text,
102
+ output_path=preview_path,
103
+ voice_config=VoiceConfig.from_dict(voice),
104
+ diffusion_steps=diffusion_steps,
105
+ speed=speed,
106
+ )
107
+ return {
108
+ "url": f"/files/{session_id}/previews/{preview_path.name}",
109
+ "duration_seconds": result["duration_seconds"],
110
+ "backend": result["backend"],
111
+ }
112
+
113
+
114
+ @app.api(name="start_render", concurrency_limit=1)
115
+ def start_render_api(
116
+ session_id: str,
117
+ selected_chapter_ids: List[str],
118
+ voice_config: Dict[str, Any],
119
+ diffusion_steps: int = 32,
120
+ speed: float = 1.0,
121
+ ):
122
+ job = pipeline.get_job(session_id)
123
+ if job.status == "running":
124
+ raise ValueError("A render is already active for this session")
125
+
126
+ book = _selected_book(session_id, selected_chapter_ids)
127
+ voice = _voice_config_for_backend(voice_config, session_id)
128
+ captured: List[Dict[str, Any]] = []
129
+ for event in pipeline.render(
130
+ session_id=session_id,
131
+ book=book,
132
+ chapters=book["chapters"],
133
+ voice_config=voice,
134
+ diffusion_steps=diffusion_steps,
135
+ speed=speed,
136
+ ):
137
+ captured.append(event)
138
+ yield event
139
+
140
+ if captured and captured[-1]["type"] == "completed":
141
+ durations = {
142
+ event["chapter_id"]: event["duration_seconds"]
143
+ for event in captured
144
+ if event["type"] == "chapter_done"
145
+ }
146
+ manifest = {
147
+ "book": book,
148
+ "chapters": [
149
+ {
150
+ **chapter,
151
+ "duration_seconds": durations.get(chapter["id"], chapter.get("duration_seconds", 0)),
152
+ }
153
+ for chapter in book["chapters"]
154
+ if chapter.get("included")
155
+ ],
156
+ "render_result": captured[-1],
157
+ }
158
+ store.save_json(session_id, "render_manifest.json", manifest)
159
+
160
+
161
+ @app.api(name="pause_render")
162
+ def pause_render_api(session_id: str) -> Dict[str, Any]:
163
+ return pipeline.pause(session_id)
164
+
165
+
166
+ @app.api(name="resume_render")
167
+ def resume_render_api(session_id: str) -> Dict[str, Any]:
168
+ return pipeline.resume(session_id)
169
+
170
+
171
+ @app.api(name="cancel_render")
172
+ def cancel_render_api(session_id: str) -> Dict[str, Any]:
173
+ pipeline.cancel(session_id)
174
+ return {"type": "cancelled", "session_id": session_id}
175
+
176
+
177
+ @app.api(name="export_audiobook")
178
+ def export_audiobook_api(
179
+ session_id: str,
180
+ format: str = "m4a",
181
+ metadata: Dict[str, str] | None = None,
182
+ embed_markers: bool = True,
183
+ ) -> Dict[str, Any]:
184
+ manifest = store.load_json(session_id, "render_manifest.json")
185
+ export = export_audiobook(
186
+ session_root=_session_root(session_id),
187
+ chapters=manifest["chapters"],
188
+ output_format=format,
189
+ metadata=metadata or {},
190
+ embed_markers=embed_markers,
191
+ cover_path=None,
192
+ )
193
+ export["url"] = f"/files/{session_id}/exports/{Path(export['file']).name}"
194
+ return export
195
+
196
+
197
+ @app.get("/", response_class=HTMLResponse)
198
+ async def homepage() -> HTMLResponse:
199
+ return HTMLResponse((FRONTEND_DIR / "index.html").read_text(encoding="utf-8"))
200
+
201
+
202
+ @app.get("/health")
203
+ async def health() -> Dict[str, str]:
204
+ return {"status": "ok"}
205
+
206
+
207
+ @app.get("/files/{session_id}/{kind}/{filename}")
208
+ async def serve_generated_file(session_id: str, kind: str, filename: str):
209
+ path = _session_root(session_id) / kind / filename
210
+ if not path.exists():
211
+ return JSONResponse({"error": "file not found"}, status_code=404)
212
+ return FileResponse(path)
213
+
214
+
215
+ app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="frontend-static")
216
+
217
+
218
+ def _port_is_available(port: int, host: str = "127.0.0.1") -> bool:
219
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
220
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
221
+ return sock.connect_ex((host, port)) != 0
222
+
223
+
224
+ def _choose_server_port(preferred: int = 7860, attempts: int = 10) -> int:
225
+ env_port = os.getenv("GRADIO_SERVER_PORT")
226
+ if env_port:
227
+ return int(env_port)
228
+ for port in range(preferred, preferred + attempts):
229
+ if _port_is_available(port):
230
+ return port
231
+ raise OSError(
232
+ f"Cannot find empty port in range: {preferred}-{preferred + attempts - 1}. "
233
+ "Set GRADIO_SERVER_PORT to override."
234
+ )
235
+
236
+
237
+ if __name__ == "__main__":
238
+ server_port = _choose_server_port()
239
+ print(f"Starting {APP_TITLE} on port {server_port}")
240
+ app.launch(server_name="0.0.0.0", server_port=server_port)
backend/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Scriptorium backend package."""
backend/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (159 Bytes). View file
 
backend/__pycache__/config.cpython-313.pyc ADDED
Binary file (464 Bytes). View file
 
backend/__pycache__/epub.cpython-313.pyc ADDED
Binary file (8.59 kB). View file
 
backend/__pycache__/export.cpython-313.pyc ADDED
Binary file (8.31 kB). View file
 
backend/__pycache__/input_files.cpython-313.pyc ADDED
Binary file (2.02 kB). View file
 
backend/__pycache__/omnivoice_adapter.cpython-313.pyc ADDED
Binary file (5.73 kB). View file
 
backend/__pycache__/render_pipeline.cpython-313.pyc ADDED
Binary file (5.32 kB). View file
 
backend/__pycache__/session_store.cpython-313.pyc ADDED
Binary file (4.21 kB). View file
 
backend/__pycache__/types.cpython-313.pyc ADDED
Binary file (3.47 kB). View file
 
backend/config.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+
4
+ APP_TITLE = "Scriptorium"
5
+ TEMP_ROOT = Path("/tmp/scriptorium")
6
+ MAX_EPUB_BYTES = 25 * 1024 * 1024
7
+ MAX_REFERENCE_AUDIO_SECONDS = 30
8
+ MAX_PREVIEW_CHARACTERS = 1800
9
+ MAX_ESTIMATED_MINUTES = 12 * 60
10
+ SESSION_TTL_SECONDS = 60 * 60
11
+ CHARS_PER_MINUTE = 900
backend/epub.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+ from typing import Dict, Iterable, List, Optional, Union
4
+ import re
5
+ import zipfile
6
+
7
+ from bs4 import BeautifulSoup
8
+ from ebooklib import ITEM_DOCUMENT, epub
9
+
10
+ from backend.config import CHARS_PER_MINUTE, MAX_EPUB_BYTES, MAX_ESTIMATED_MINUTES
11
+ from backend.types import Chapter
12
+
13
+
14
+ @dataclass
15
+ class EpubConfig:
16
+ max_file_bytes: int = MAX_EPUB_BYTES
17
+ chars_per_minute: int = CHARS_PER_MINUTE
18
+ max_estimated_minutes: int = MAX_ESTIMATED_MINUTES
19
+
20
+
21
+ def _clean_text(raw_html: str) -> str:
22
+ soup = BeautifulSoup(raw_html, "html.parser")
23
+ text = soup.get_text("\n", strip=True)
24
+ text = re.sub(r"\n{3,}", "\n\n", text)
25
+ return text.strip()
26
+
27
+
28
+ def _chapter_title(item: epub.EpubItem, text: str, index: int) -> str:
29
+ soup = BeautifulSoup(item.get_content(), "html.parser")
30
+ heading = soup.find(["h1", "h2", "title"])
31
+ if heading and heading.get_text(strip=True):
32
+ return heading.get_text(strip=True)
33
+ if item.title:
34
+ return str(item.title)
35
+ first_line = text.splitlines()[0].strip() if text.splitlines() else ""
36
+ return first_line[:80] or f"Chapter {index + 1}"
37
+
38
+
39
+ def _iter_spine_documents(book: epub.EpubBook) -> Iterable[epub.EpubItem]:
40
+ seen = set()
41
+ for spine_item in book.spine:
42
+ item_id = spine_item[0] if isinstance(spine_item, tuple) else spine_item
43
+ if item_id == "nav":
44
+ continue
45
+ item = book.get_item_with_id(item_id)
46
+ if item is None:
47
+ continue
48
+ seen.add(item.get_id())
49
+ yield item
50
+ for item in book.get_items_of_type(ITEM_DOCUMENT):
51
+ if item.get_id() in seen:
52
+ continue
53
+ if item.get_id() == "nav" or getattr(item, "file_name", "") == "nav.xhtml":
54
+ continue
55
+ yield item
56
+
57
+
58
+ def _metadata_first(book: epub.EpubBook, name: str) -> Optional[str]:
59
+ values = book.get_metadata("DC", name)
60
+ if not values:
61
+ return None
62
+ value = values[0][0]
63
+ return str(value).strip() if value else None
64
+
65
+
66
+ def _validate_epub_container(path: Path) -> None:
67
+ if not zipfile.is_zipfile(path):
68
+ raise ValueError("Invalid EPUB: uploaded file is not a valid zip container")
69
+ try:
70
+ with zipfile.ZipFile(path) as archive:
71
+ names = set(archive.namelist())
72
+ mimetype = None
73
+ if "mimetype" in names:
74
+ mimetype = archive.read("mimetype").decode("utf-8", errors="ignore").strip()
75
+ has_container = "META-INF/container.xml" in names
76
+ except zipfile.BadZipFile as exc:
77
+ raise ValueError("Invalid EPUB: could not read zip container") from exc
78
+
79
+ if mimetype == "application/epub+zip" or has_container:
80
+ return
81
+ raise ValueError("Invalid EPUB: missing EPUB container metadata")
82
+
83
+
84
+ def parse_epub(epub_path: Union[Path, str], config: Optional[EpubConfig] = None) -> Dict[str, object]:
85
+ config = config or EpubConfig()
86
+ path = Path(epub_path)
87
+ if not path.exists():
88
+ raise ValueError("Invalid EPUB: file does not exist")
89
+ if path.stat().st_size > config.max_file_bytes:
90
+ raise ValueError("EPUB upload too large")
91
+ _validate_epub_container(path)
92
+
93
+ try:
94
+ book = epub.read_epub(str(path))
95
+ except Exception as exc: # pragma: no cover - exercised by malformed input
96
+ raise ValueError("Invalid EPUB: could not parse file") from exc
97
+
98
+ chapters: List[Chapter] = []
99
+ for index, item in enumerate(_iter_spine_documents(book)):
100
+ text = _clean_text(item.get_content())
101
+ if not text:
102
+ continue
103
+ chars = len(text)
104
+ est_minutes = max(1, round(chars / config.chars_per_minute))
105
+ chapters.append(
106
+ Chapter(
107
+ id=item.get_id() or f"chapter-{index + 1}",
108
+ label=_roman(index + 1),
109
+ title=_chapter_title(item, text, index),
110
+ text=text,
111
+ chars=chars,
112
+ est_minutes=est_minutes,
113
+ )
114
+ )
115
+
116
+ if not chapters:
117
+ raise ValueError("Invalid EPUB: no readable chapters found")
118
+
119
+ total_minutes = sum(ch.est_minutes for ch in chapters)
120
+ if total_minutes > config.max_estimated_minutes:
121
+ raise ValueError("EPUB exceeds maximum supported runtime")
122
+
123
+ title = _metadata_first(book, "title") or path.stem.replace("-", " ").title()
124
+ author = _metadata_first(book, "creator") or "Unknown author"
125
+ language = _metadata_first(book, "language") or "Unknown language"
126
+ rights = _metadata_first(book, "rights") or "Rights unknown"
127
+
128
+ return {
129
+ "title": title,
130
+ "author": author,
131
+ "meta": f"{language.upper()} \u00b7 {rights}",
132
+ "cover_url": None,
133
+ "chapters": [chapter.to_dict() for chapter in chapters],
134
+ "estimated_minutes": total_minutes,
135
+ }
136
+
137
+
138
+ def _roman(number: int) -> str:
139
+ numerals = (
140
+ (1000, "m"),
141
+ (900, "cm"),
142
+ (500, "d"),
143
+ (400, "cd"),
144
+ (100, "c"),
145
+ (90, "xc"),
146
+ (50, "l"),
147
+ (40, "xl"),
148
+ (10, "x"),
149
+ (9, "ix"),
150
+ (5, "v"),
151
+ (4, "iv"),
152
+ (1, "i"),
153
+ )
154
+ result = []
155
+ remainder = number
156
+ for value, numeral in numerals:
157
+ count, remainder = divmod(remainder, value)
158
+ result.append(numeral * count)
159
+ return "".join(result)
backend/export.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import shutil
3
+ import subprocess
4
+ import wave
5
+ import zipfile
6
+ from pathlib import Path
7
+ from typing import Dict, Iterable, List, Tuple
8
+
9
+ from mutagen.easyid3 import EasyID3
10
+ from mutagen.id3 import ID3, APIC, TIT2, TPE1
11
+
12
+
13
+ def export_audiobook(
14
+ *,
15
+ session_root: Path,
16
+ chapters: List[Dict[str, object]],
17
+ output_format: str,
18
+ metadata: Dict[str, str],
19
+ embed_markers: bool,
20
+ cover_path: Path | None = None,
21
+ ) -> Dict[str, object]:
22
+ exports_dir = session_root / "exports"
23
+ exports_dir.mkdir(parents=True, exist_ok=True)
24
+ rendered = _rendered_files(session_root)
25
+ if not rendered:
26
+ raise ValueError("No rendered chapters available for export")
27
+
28
+ if output_format == "zip":
29
+ archive = exports_dir / "reading-room-chapters.zip"
30
+ with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as zf:
31
+ for source in rendered:
32
+ zf.write(source, arcname=source.name)
33
+ return {"file": str(archive), "format": "zip", "size_bytes": archive.stat().st_size}
34
+
35
+ base_wav = exports_dir / "reading-room.wav"
36
+ total_seconds = _concat_wav(rendered, base_wav)
37
+ chapter_offsets = _chapter_offsets(chapters)
38
+
39
+ if output_format == "m4a":
40
+ output = exports_dir / "reading-room.m4a"
41
+ _ffmpeg_transcode(
42
+ source=base_wav,
43
+ target=output,
44
+ codec_args=["-c:a", "aac", "-b:a", "96k"],
45
+ metadata=metadata,
46
+ chapter_offsets=chapter_offsets if embed_markers else None,
47
+ )
48
+ elif output_format == "mp3":
49
+ output = exports_dir / "reading-room.mp3"
50
+ _ffmpeg_transcode(
51
+ source=base_wav,
52
+ target=output,
53
+ codec_args=["-c:a", "libmp3lame", "-b:a", "128k"],
54
+ metadata=metadata,
55
+ chapter_offsets=None,
56
+ )
57
+ _tag_mp3(output, metadata, cover_path)
58
+ else:
59
+ raise ValueError("Unsupported export format")
60
+
61
+ return {
62
+ "file": str(output),
63
+ "format": output_format,
64
+ "runtime_seconds": total_seconds,
65
+ "size_bytes": output.stat().st_size,
66
+ "chapter_count": len(chapters),
67
+ }
68
+
69
+
70
+ def _rendered_files(session_root: Path) -> List[Path]:
71
+ render_dir = session_root / "renders"
72
+ return sorted(render_dir.glob("*.wav"))
73
+
74
+
75
+ def _concat_wav(inputs: Iterable[Path], output: Path) -> int:
76
+ inputs = list(inputs)
77
+ params = None
78
+ total_frames = 0
79
+ frames: List[bytes] = []
80
+ for wav_path in inputs:
81
+ with wave.open(str(wav_path), "rb") as handle:
82
+ if params is None:
83
+ params = handle.getparams()
84
+ elif handle.getframerate() != params.framerate:
85
+ raise ValueError("Mismatched sample rates across rendered chapters")
86
+ chunk = handle.readframes(handle.getnframes())
87
+ frames.append(chunk)
88
+ total_frames += handle.getnframes()
89
+
90
+ if params is None:
91
+ raise ValueError("No WAV inputs to concatenate")
92
+
93
+ with wave.open(str(output), "wb") as handle:
94
+ handle.setparams(params)
95
+ for chunk in frames:
96
+ handle.writeframes(chunk)
97
+
98
+ return int(total_frames / params.framerate)
99
+
100
+
101
+ def _ffmpeg_transcode(
102
+ *,
103
+ source: Path,
104
+ target: Path,
105
+ codec_args: List[str],
106
+ metadata: Dict[str, str],
107
+ chapter_offsets: List[Tuple[str, int, int]] | None,
108
+ ) -> None:
109
+ target.parent.mkdir(parents=True, exist_ok=True)
110
+ ffmetadata_path = None
111
+ cmd = ["ffmpeg", "-y", "-i", str(source)]
112
+ if chapter_offsets:
113
+ ffmetadata_path = target.with_suffix(".ffmeta")
114
+ ffmetadata_path.write_text(_ffmetadata(metadata, chapter_offsets), encoding="utf-8")
115
+ cmd.extend(["-i", str(ffmetadata_path), "-map_metadata", "1"])
116
+ else:
117
+ for key, value in metadata.items():
118
+ cmd.extend(["-metadata", f"{key}={value}"])
119
+ cmd.extend(codec_args)
120
+ cmd.append(str(target))
121
+ subprocess.run(cmd, check=True, capture_output=True)
122
+
123
+
124
+ def _ffmetadata(metadata: Dict[str, str], chapter_offsets: List[Tuple[str, int, int]]) -> str:
125
+ lines = [";FFMETADATA1"]
126
+ for key, value in metadata.items():
127
+ lines.append(f"{key}={value}")
128
+ for title, start, end in chapter_offsets:
129
+ lines.extend(
130
+ [
131
+ "[CHAPTER]",
132
+ "TIMEBASE=1/1000",
133
+ f"START={start}",
134
+ f"END={end}",
135
+ f"title={title}",
136
+ ]
137
+ )
138
+ return "\n".join(lines)
139
+
140
+
141
+ def _chapter_offsets(chapters: List[Dict[str, object]]) -> List[Tuple[str, int, int]]:
142
+ offsets = []
143
+ start_ms = 0
144
+ for chapter in chapters:
145
+ duration_ms = int(chapter.get("duration_seconds", 0) * 1000)
146
+ offsets.append((str(chapter["title"]), start_ms, start_ms + duration_ms))
147
+ start_ms += duration_ms
148
+ return offsets
149
+
150
+
151
+ def _tag_mp3(path: Path, metadata: Dict[str, str], cover_path: Path | None) -> None:
152
+ try:
153
+ tags = EasyID3(str(path))
154
+ except Exception:
155
+ tags = EasyID3()
156
+ tags.save(str(path))
157
+ tags = EasyID3(str(path))
158
+ if metadata.get("title"):
159
+ tags["title"] = [metadata["title"]]
160
+ if metadata.get("artist"):
161
+ tags["artist"] = [metadata["artist"]]
162
+ tags.save()
163
+
164
+ if cover_path and cover_path.exists():
165
+ audio = ID3(str(path))
166
+ audio.add(
167
+ APIC(
168
+ encoding=3,
169
+ mime="image/jpeg",
170
+ type=3,
171
+ desc="Cover",
172
+ data=cover_path.read_bytes(),
173
+ )
174
+ )
175
+ if metadata.get("title"):
176
+ audio.add(TIT2(encoding=3, text=metadata["title"]))
177
+ if metadata.get("artist"):
178
+ audio.add(TPE1(encoding=3, text=metadata["artist"]))
179
+ audio.save(str(path))
backend/input_files.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from typing import Any
3
+
4
+
5
+ def resolve_uploaded_path(uploaded: Any) -> Path:
6
+ if isinstance(uploaded, Path):
7
+ return uploaded
8
+ if isinstance(uploaded, str):
9
+ return Path(uploaded)
10
+ if isinstance(uploaded, dict):
11
+ for key in ("path", "orig_name", "name"):
12
+ value = uploaded.get(key)
13
+ if isinstance(value, str) and value:
14
+ return Path(value)
15
+ raise TypeError(
16
+ f"Unsupported uploaded file payload: expected path-like value or Gradio file dict, got {type(uploaded).__name__}"
17
+ )
18
+
19
+
20
+ def resolve_uploaded_name(uploaded: Any) -> str:
21
+ if isinstance(uploaded, Path):
22
+ return uploaded.name
23
+ if isinstance(uploaded, str):
24
+ return Path(uploaded).name
25
+ if isinstance(uploaded, dict):
26
+ for key in ("orig_name", "name"):
27
+ value = uploaded.get(key)
28
+ if isinstance(value, str) and value:
29
+ return Path(value).name
30
+ path_value = uploaded.get("path")
31
+ if isinstance(path_value, str) and path_value:
32
+ return Path(path_value).name
33
+ raise TypeError(
34
+ f"Unsupported uploaded file payload: expected path-like value or Gradio file dict, got {type(uploaded).__name__}"
35
+ )
backend/omnivoice_adapter.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ from pathlib import Path
3
+ from typing import Dict, Optional
4
+
5
+ import numpy as np
6
+ import soundfile as sf
7
+
8
+ from backend.types import VoiceConfig
9
+
10
+
11
+ NARRATOR_PRESETS = {
12
+ "brother-anselm": "male, warm baritone, measured, british accent",
13
+ "dame-eglantine": "female, bright contralto, lyrical, british accent",
14
+ "the-reeve": "male, gravelly bass, wry, british accent",
15
+ "sister-cecilia": "female, clear soprano, gentle, british accent",
16
+ }
17
+
18
+
19
+ class OmniVoiceAdapter:
20
+ def __init__(
21
+ self,
22
+ model_id: str = "k2-fsa/OmniVoice",
23
+ device_map: str = "cuda:0",
24
+ dtype_name: str = "float16",
25
+ ) -> None:
26
+ self.model_id = model_id
27
+ self.device_map = device_map
28
+ self.dtype_name = dtype_name
29
+ self._model = None
30
+ self._backend = "fallback"
31
+
32
+ def _load_model(self):
33
+ if self._model is not None:
34
+ return self._model
35
+ try:
36
+ import torch
37
+ from omnivoice import OmniVoice
38
+ except Exception:
39
+ self._backend = "fallback"
40
+ return None
41
+
42
+ dtype = getattr(torch, self.dtype_name)
43
+ self._model = OmniVoice.from_pretrained(
44
+ self.model_id,
45
+ device_map=self.device_map,
46
+ dtype=dtype,
47
+ )
48
+ self._backend = "omnivoice"
49
+ return self._model
50
+
51
+ def synthesize(
52
+ self,
53
+ *,
54
+ text: str,
55
+ output_path: Path,
56
+ voice_config: VoiceConfig,
57
+ diffusion_steps: int,
58
+ speed: float,
59
+ language: Optional[str] = None,
60
+ ) -> Dict[str, object]:
61
+ output_path.parent.mkdir(parents=True, exist_ok=True)
62
+ model = self._load_model()
63
+ if model is None:
64
+ return self._synthesize_fallback(
65
+ text=text,
66
+ output_path=output_path,
67
+ voice_config=voice_config,
68
+ speed=speed,
69
+ )
70
+
71
+ kwargs = {
72
+ "text": text,
73
+ "num_step": diffusion_steps,
74
+ "speed": speed,
75
+ }
76
+ if language:
77
+ kwargs["language"] = language
78
+
79
+ if voice_config.mode == "clone":
80
+ kwargs["ref_audio"] = voice_config.sample_path
81
+ if voice_config.reference_text:
82
+ kwargs["ref_text"] = voice_config.reference_text
83
+ elif voice_config.mode == "design":
84
+ kwargs["instruct"] = voice_config.design_prompt
85
+ elif voice_config.narrator_id:
86
+ kwargs["instruct"] = NARRATOR_PRESETS.get(voice_config.narrator_id)
87
+
88
+ audio = model.generate(**kwargs)
89
+ waveform = np.asarray(audio[0], dtype=np.float32)
90
+ sample_rate = 24000
91
+ sf.write(str(output_path), waveform, sample_rate)
92
+ duration_seconds = int(round(len(waveform) / sample_rate))
93
+ return {
94
+ "duration_seconds": max(1, duration_seconds),
95
+ "sample_rate": sample_rate,
96
+ "backend": self._backend,
97
+ }
98
+
99
+ def _synthesize_fallback(
100
+ self,
101
+ *,
102
+ text: str,
103
+ output_path: Path,
104
+ voice_config: VoiceConfig,
105
+ speed: float,
106
+ ) -> Dict[str, object]:
107
+ sample_rate = 24000
108
+ duration_seconds = max(1.0, min(20.0, len(text.split()) / max(speed, 0.5) * 0.45))
109
+ total_samples = int(sample_rate * duration_seconds)
110
+ base_freq = 180.0
111
+ if voice_config.mode == "design":
112
+ base_freq = 220.0
113
+ elif voice_config.mode == "clone":
114
+ base_freq = 140.0
115
+ elif voice_config.narrator_id:
116
+ base_freq = 160.0 + (list(NARRATOR_PRESETS.keys()).index(voice_config.narrator_id) * 20)
117
+
118
+ timeline = np.linspace(0, duration_seconds, total_samples, endpoint=False)
119
+ waveform = (
120
+ 0.15 * np.sin(2 * math.pi * base_freq * timeline)
121
+ + 0.05 * np.sin(2 * math.pi * (base_freq / 2.0) * timeline)
122
+ ).astype(np.float32)
123
+ sf.write(str(output_path), waveform, sample_rate)
124
+ return {
125
+ "duration_seconds": int(round(duration_seconds)),
126
+ "sample_rate": sample_rate,
127
+ "backend": self._backend,
128
+ }
backend/render_pipeline.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from typing import Dict, Iterable, List, Optional
3
+
4
+ from backend.types import RenderArtifact, RenderJob, VoiceConfig
5
+
6
+
7
+ class RenderPipeline:
8
+ def __init__(self, session_root: Path, synthesizer) -> None:
9
+ self.session_root = Path(session_root)
10
+ self.session_root.mkdir(parents=True, exist_ok=True)
11
+ self.synthesizer = synthesizer
12
+ self._jobs: Dict[str, RenderJob] = {}
13
+ self._cancelled: set[str] = set()
14
+ self._paused: set[str] = set()
15
+
16
+ def get_job(self, session_id: str) -> RenderJob:
17
+ return self._jobs.setdefault(session_id, RenderJob(session_id=session_id))
18
+
19
+ def cancel(self, session_id: str) -> None:
20
+ self._cancelled.add(session_id)
21
+ self.get_job(session_id).status = "cancelled"
22
+
23
+ def pause(self, session_id: str) -> Dict[str, object]:
24
+ self._paused.add(session_id)
25
+ self.get_job(session_id).status = "paused"
26
+ return {"type": "paused", "session_id": session_id}
27
+
28
+ def resume(self, session_id: str) -> Dict[str, object]:
29
+ self._paused.discard(session_id)
30
+ self.get_job(session_id).status = "running"
31
+ return {"type": "resumed", "session_id": session_id}
32
+
33
+ def render(
34
+ self,
35
+ *,
36
+ session_id: str,
37
+ book: Dict[str, object],
38
+ chapters: List[Dict[str, object]],
39
+ voice_config: Dict[str, object],
40
+ diffusion_steps: int,
41
+ speed: float,
42
+ ) -> Iterable[Dict[str, object]]:
43
+ selected = [chapter for chapter in chapters if chapter.get("included", True)]
44
+ job = self.get_job(session_id)
45
+ job.status = "running"
46
+ job.outputs.clear()
47
+
48
+ yield {
49
+ "type": "started",
50
+ "session_id": session_id,
51
+ "total_chapters": len(selected),
52
+ "book_title": book.get("title"),
53
+ }
54
+
55
+ voice = VoiceConfig.from_dict(voice_config)
56
+ render_dir = self.session_root / session_id / "renders"
57
+ render_dir.mkdir(parents=True, exist_ok=True)
58
+
59
+ for index, chapter in enumerate(selected):
60
+ if session_id in self._cancelled:
61
+ yield {"type": "cancelled", "session_id": session_id}
62
+ self._cancelled.discard(session_id)
63
+ job.status = "cancelled"
64
+ return
65
+
66
+ chapter_id = str(chapter["id"])
67
+ job.current_chapter_id = chapter_id
68
+ yield {
69
+ "type": "chapter_started",
70
+ "session_id": session_id,
71
+ "chapter_id": chapter_id,
72
+ "chapter_title": chapter["title"],
73
+ "chapter_index": index,
74
+ "overall_progress": index / max(1, len(selected)),
75
+ }
76
+
77
+ output_path = render_dir / f"{index + 1:03d}-{chapter_id}.wav"
78
+ result = self.synthesizer.synthesize(
79
+ text=str(chapter["text"]),
80
+ output_path=output_path,
81
+ voice_config=voice,
82
+ diffusion_steps=diffusion_steps,
83
+ speed=speed,
84
+ )
85
+
86
+ yield {
87
+ "type": "chapter_progress",
88
+ "session_id": session_id,
89
+ "chapter_id": chapter_id,
90
+ "percent": 100,
91
+ }
92
+
93
+ artifact = RenderArtifact(
94
+ path=output_path,
95
+ duration_seconds=int(result.get("duration_seconds", 0)),
96
+ chapter_id=chapter_id,
97
+ )
98
+ job.outputs.append(artifact)
99
+ yield {
100
+ "type": "chapter_done",
101
+ "session_id": session_id,
102
+ "chapter_id": chapter_id,
103
+ "duration_seconds": artifact.duration_seconds,
104
+ "overall_progress": (index + 1) / max(1, len(selected)),
105
+ "output_path": str(output_path),
106
+ }
107
+
108
+ job.status = "completed"
109
+ yield {
110
+ "type": "completed",
111
+ "session_id": session_id,
112
+ "outputs": [str(artifact.path) for artifact in job.outputs],
113
+ }
backend/session_store.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import shutil
2
+ import time
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from backend.types import SessionRecord
8
+
9
+
10
+ class SessionStore:
11
+ def __init__(self, root: Path, ttl_seconds: int) -> None:
12
+ self.root = Path(root)
13
+ self.ttl_seconds = ttl_seconds
14
+ self.root.mkdir(parents=True, exist_ok=True)
15
+ self._sessions: Dict[str, SessionRecord] = {}
16
+
17
+ def _now(self) -> float:
18
+ return time.time()
19
+
20
+ def ensure_session(self, session_id: str) -> SessionRecord:
21
+ now = self._now()
22
+ session_root = self.root / session_id
23
+ session_root.mkdir(parents=True, exist_ok=True)
24
+ for child in ("uploads", "previews", "renders", "exports"):
25
+ (session_root / child).mkdir(exist_ok=True)
26
+ existing = self._sessions.get(session_id)
27
+ if existing is None:
28
+ existing = SessionRecord(
29
+ session_id=session_id,
30
+ root=session_root,
31
+ created_at=now,
32
+ touched_at=now,
33
+ )
34
+ self._sessions[session_id] = existing
35
+ else:
36
+ existing.touched_at = now
37
+ return existing
38
+
39
+ def touch(self, session_id: str) -> SessionRecord:
40
+ session = self.ensure_session(session_id)
41
+ session.touched_at = self._now()
42
+ return session
43
+
44
+ def save_json(self, session_id: str, name: str, payload: Dict[str, Any]) -> Path:
45
+ session = self.touch(session_id)
46
+ path = session.root / name
47
+ path.write_text(json.dumps(payload, ensure_ascii=True, indent=2), encoding="utf-8")
48
+ return path
49
+
50
+ def load_json(self, session_id: str, name: str) -> Dict[str, Any]:
51
+ session = self.touch(session_id)
52
+ path = session.root / name
53
+ if not path.exists():
54
+ raise FileNotFoundError(f"Session asset not found: {name}")
55
+ return json.loads(path.read_text(encoding="utf-8"))
56
+
57
+ def cleanup_expired(self, now: Optional[float] = None) -> List[str]:
58
+ current = self._now() if now is None else now
59
+ removed: List[str] = []
60
+ for session_id, record in list(self._sessions.items()):
61
+ if current - record.touched_at <= self.ttl_seconds:
62
+ continue
63
+ if record.root.exists():
64
+ shutil.rmtree(record.root)
65
+ removed.append(session_id)
66
+ del self._sessions[session_id]
67
+ return removed
backend/types.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import asdict, dataclass, field
2
+ from pathlib import Path
3
+ from typing import Any, Dict, List, Optional
4
+
5
+
6
+ JsonDict = Dict[str, Any]
7
+
8
+
9
+ @dataclass
10
+ class Chapter:
11
+ id: str
12
+ label: str
13
+ title: str
14
+ text: str
15
+ chars: int
16
+ est_minutes: int
17
+ included: bool = True
18
+ start_seconds: int = 0
19
+
20
+ def to_dict(self) -> JsonDict:
21
+ data = asdict(self)
22
+ data["n"] = data["label"]
23
+ return data
24
+
25
+
26
+ @dataclass
27
+ class SessionRecord:
28
+ session_id: str
29
+ root: Path
30
+ created_at: float
31
+ touched_at: float
32
+
33
+
34
+ @dataclass
35
+ class VoiceConfig:
36
+ mode: str
37
+ narrator_id: Optional[str] = None
38
+ sample_path: Optional[str] = None
39
+ reference_text: Optional[str] = None
40
+ design_prompt: Optional[str] = None
41
+
42
+ @classmethod
43
+ def from_dict(cls, data: JsonDict) -> "VoiceConfig":
44
+ return cls(
45
+ mode=data.get("mode", "auto"),
46
+ narrator_id=data.get("narratorId") or data.get("narrator_id"),
47
+ sample_path=data.get("samplePath") or data.get("sample_path"),
48
+ reference_text=data.get("referenceText") or data.get("reference_text"),
49
+ design_prompt=data.get("designPrompt") or data.get("design_prompt"),
50
+ )
51
+
52
+
53
+ @dataclass
54
+ class RenderArtifact:
55
+ path: Path
56
+ duration_seconds: int
57
+ chapter_id: str
58
+
59
+
60
+ @dataclass
61
+ class RenderJob:
62
+ session_id: str
63
+ status: str = "idle"
64
+ current_chapter_id: Optional[str] = None
65
+ outputs: List[RenderArtifact] = field(default_factory=list)
66
+
frontend/app.js ADDED
@@ -0,0 +1,985 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Client, handle_file } from "https://esm.sh/@gradio/client";
2
+
3
+ const NARRATORS = [
4
+ { id: "brother-anselm", name: "The Archivist", desc: "OmniVoice preset · warm baritone · measured", initial: "A" },
5
+ { id: "dame-eglantine", name: "Lady Meridian", desc: "OmniVoice preset · bright contralto · lyrical", initial: "M" },
6
+ { id: "the-reeve", name: "North Bell", desc: "OmniVoice preset · gravelled bass · wry", initial: "N" },
7
+ { id: "sister-cecilia", name: "Cecilia of the Lamp", desc: "OmniVoice preset · clear soprano · gentle", initial: "C" },
8
+ ];
9
+
10
+ const VOICE_WARNING =
11
+ "Use clone mode only with audio you have the right to use. Unauthorized voice cloning, impersonation, fraud, or scams are prohibited.";
12
+
13
+ const state = {
14
+ client: null,
15
+ sessionId: crypto.randomUUID ? crypto.randomUUID() : `session-${Date.now()}`,
16
+ step: "configure",
17
+ book: null,
18
+ chapters: [],
19
+ currentChapterId: null,
20
+ voiceMode: "auto",
21
+ narratorId: "brother-anselm",
22
+ cloneConsent: false,
23
+ cloneSampleFile: null,
24
+ cloneReferenceText: "",
25
+ designPrompt: "",
26
+ diffusionSteps: 32,
27
+ speed: 1,
28
+ statusMessage: "",
29
+ errorMessage: "",
30
+ renderStatus: null,
31
+ renderEvents: [],
32
+ renderResult: null,
33
+ renderSubmission: null,
34
+ previewUrl: "",
35
+ exportFormat: "m4a",
36
+ embedMarkers: true,
37
+ exportFile: null,
38
+ exportMetadata: {
39
+ title: "",
40
+ author: "",
41
+ narrator: "Brother Anselm (OmniVoice)",
42
+ genre: "Audiobook",
43
+ },
44
+ };
45
+
46
+ const appRoot = document.getElementById("app");
47
+
48
+ function main() {
49
+ bindGlobalEvents();
50
+ connectClient()
51
+ .catch((error) => {
52
+ state.errorMessage = error.message;
53
+ })
54
+ .finally(() => {
55
+ render();
56
+ });
57
+ }
58
+
59
+ async function connectClient() {
60
+ state.statusMessage = "Connecting to the Gradio backend…";
61
+ render();
62
+ state.client = await Client.connect(window.location.origin, {
63
+ events: ["data", "status"],
64
+ });
65
+ state.statusMessage = "Connected. Upload an EPUB to begin.";
66
+ }
67
+
68
+ function bindGlobalEvents() {
69
+ window.addEventListener("click", handleClick);
70
+ window.addEventListener("change", handleChange);
71
+ window.addEventListener("input", handleInput);
72
+ }
73
+
74
+ function handleClick(event) {
75
+ const target = event.target.closest("[data-action]");
76
+ if (!target) return;
77
+ const action = target.dataset.action;
78
+ void actionHandlers[action]?.(target, event);
79
+ }
80
+
81
+ function handleChange(event) {
82
+ const target = event.target;
83
+ if (target.matches("[data-chapter-toggle]")) {
84
+ const id = target.dataset.chapterToggle;
85
+ const chapter = state.chapters.find((item) => item.id === id);
86
+ if (chapter) {
87
+ chapter.included = target.checked;
88
+ render();
89
+ }
90
+ return;
91
+ }
92
+ if (target.matches("#epub-input")) {
93
+ void uploadEpub(target.files?.[0]);
94
+ return;
95
+ }
96
+ if (target.matches("#clone-audio")) {
97
+ state.cloneSampleFile = target.files?.[0] || null;
98
+ render();
99
+ return;
100
+ }
101
+ if (target.matches("[data-format]")) {
102
+ state.exportFormat = target.dataset.format;
103
+ render();
104
+ return;
105
+ }
106
+ if (target.matches("#embed-markers")) {
107
+ state.embedMarkers = target.checked;
108
+ render();
109
+ }
110
+ }
111
+
112
+ function handleInput(event) {
113
+ const target = event.target;
114
+ if (target.matches("#diffusion-steps")) {
115
+ state.diffusionSteps = Number(target.value);
116
+ render();
117
+ return;
118
+ }
119
+ if (target.matches("#reading-speed")) {
120
+ state.speed = Number(target.value);
121
+ render();
122
+ return;
123
+ }
124
+ if (target.matches("#clone-ref-text")) {
125
+ state.cloneReferenceText = target.value;
126
+ return;
127
+ }
128
+ if (target.matches("#design-prompt")) {
129
+ state.designPrompt = target.value;
130
+ return;
131
+ }
132
+ if (target.matches("#clone-consent")) {
133
+ state.cloneConsent = target.checked;
134
+ render();
135
+ return;
136
+ }
137
+ if (target.matches("[data-meta]")) {
138
+ state.exportMetadata[target.dataset.meta] = target.value;
139
+ }
140
+ }
141
+
142
+ const actionHandlers = {
143
+ async pickNarrator(target) {
144
+ state.narratorId = target.dataset.id;
145
+ syncNarratorMetadata();
146
+ render();
147
+ },
148
+ setVoiceMode(target) {
149
+ state.voiceMode = target.dataset.mode;
150
+ if (state.voiceMode === "auto") {
151
+ syncNarratorMetadata();
152
+ } else if (state.voiceMode === "clone") {
153
+ state.exportMetadata.narrator = "Cloned Narrator (OmniVoice)";
154
+ } else {
155
+ state.exportMetadata.narrator = "Designed Narrator (OmniVoice)";
156
+ }
157
+ render();
158
+ },
159
+ focusChapter(target) {
160
+ state.currentChapterId = target.dataset.id;
161
+ render();
162
+ },
163
+ async previewChapter(target) {
164
+ const chapterId = target.dataset.id;
165
+ await requestPreview(chapterId);
166
+ },
167
+ async startRender() {
168
+ await submitRender();
169
+ },
170
+ async pauseRender() {
171
+ await callPredict("/pause_render", { session_id: state.sessionId });
172
+ state.statusMessage = "Render paused. Resume when ready.";
173
+ render();
174
+ },
175
+ async resumeRender() {
176
+ await callPredict("/resume_render", { session_id: state.sessionId });
177
+ state.statusMessage = "Render resumed.";
178
+ render();
179
+ },
180
+ async cancelRender() {
181
+ await callPredict("/cancel_render", { session_id: state.sessionId });
182
+ state.renderSubmission?.cancel?.();
183
+ state.renderSubmission = null;
184
+ state.renderStatus = { status: "cancelled" };
185
+ state.step = "configure";
186
+ state.statusMessage = "Render cancelled.";
187
+ render();
188
+ },
189
+ async exportBook() {
190
+ await requestExport();
191
+ },
192
+ goConfigure() {
193
+ state.step = "configure";
194
+ render();
195
+ },
196
+ goGenerate() {
197
+ if (!state.book) return;
198
+ state.step = "generate";
199
+ render();
200
+ },
201
+ goExport() {
202
+ if (!state.renderResult) return;
203
+ state.step = "export";
204
+ render();
205
+ },
206
+ chooseUpload() {
207
+ document.getElementById("epub-input")?.click();
208
+ },
209
+ chooseCloneAudio() {
210
+ document.getElementById("clone-audio")?.click();
211
+ },
212
+ playExportPreview(target) {
213
+ const audio = document.getElementById("preview-audio");
214
+ if (!audio) return;
215
+ if (audio.paused) {
216
+ audio.play();
217
+ target.textContent = "❚❚";
218
+ } else {
219
+ audio.pause();
220
+ target.textContent = "▶";
221
+ }
222
+ },
223
+ };
224
+
225
+ async function uploadEpub(file) {
226
+ if (!file || !state.client) return;
227
+ state.errorMessage = "";
228
+ state.statusMessage = `Uploading ${file.name}…`;
229
+ render();
230
+ try {
231
+ const payload = await callPredict("/parse_epub", {
232
+ session_id: state.sessionId,
233
+ epub_file: handle_file(file),
234
+ });
235
+ state.book = payload;
236
+ state.chapters = payload.chapters;
237
+ state.currentChapterId = payload.chapters[0]?.id || null;
238
+ state.exportMetadata.title = payload.title;
239
+ state.exportMetadata.author = payload.author;
240
+ syncNarratorMetadata();
241
+ state.statusMessage = `Parsed ${payload.chapters.length} chapters from ${payload.title}.`;
242
+ } catch (error) {
243
+ state.errorMessage = error.message;
244
+ }
245
+ render();
246
+ }
247
+
248
+ async function requestPreview(chapterId) {
249
+ if (!state.client || !state.book) return;
250
+ state.errorMessage = "";
251
+ state.statusMessage = "Rendering preview…";
252
+ render();
253
+ try {
254
+ const payload = await callPredict("/generate_preview", {
255
+ session_id: state.sessionId,
256
+ chapter_id: chapterId,
257
+ voice_config: buildVoiceConfig(),
258
+ diffusion_steps: state.diffusionSteps,
259
+ speed: state.speed,
260
+ });
261
+ state.previewUrl = payload.url;
262
+ state.statusMessage = "Preview ready.";
263
+ } catch (error) {
264
+ state.errorMessage = error.message;
265
+ }
266
+ render();
267
+ }
268
+
269
+ async function submitRender() {
270
+ if (!state.client || !state.book) return;
271
+ state.errorMessage = "";
272
+ state.statusMessage = "Submitting audiobook render…";
273
+ state.step = "generate";
274
+ state.renderEvents = [];
275
+ state.renderResult = null;
276
+ render();
277
+
278
+ const submission = state.client.submit("/start_render", {
279
+ session_id: state.sessionId,
280
+ selected_chapter_ids: selectedChapterIds(),
281
+ voice_config: buildVoiceConfig(),
282
+ diffusion_steps: state.diffusionSteps,
283
+ speed: state.speed,
284
+ });
285
+ state.renderSubmission = submission;
286
+
287
+ try {
288
+ for await (const event of submission) {
289
+ if (event.type === "status") {
290
+ state.renderStatus = event;
291
+ if (event.position && event.status === "pending") {
292
+ state.statusMessage = `Queued in position ${event.position} of ${event.queue_size}.`;
293
+ }
294
+ render();
295
+ continue;
296
+ }
297
+ if (event.type !== "data") continue;
298
+ const payload = event.data?.[0] ?? event.data;
299
+ if (!payload) continue;
300
+ state.renderEvents.push(payload);
301
+ updateRenderState(payload);
302
+ render();
303
+ }
304
+ } catch (error) {
305
+ state.errorMessage = error.message;
306
+ render();
307
+ }
308
+ }
309
+
310
+ function updateRenderState(payload) {
311
+ switch (payload.type) {
312
+ case "started":
313
+ state.statusMessage = `Binding ${payload.total_chapters} chapters…`;
314
+ break;
315
+ case "chapter_started":
316
+ state.statusMessage = `Narrating ${payload.chapter_title}…`;
317
+ break;
318
+ case "chapter_done":
319
+ updateChapterDuration(payload.chapter_id, payload.duration_seconds);
320
+ break;
321
+ case "completed":
322
+ state.renderResult = payload;
323
+ state.step = "export";
324
+ state.statusMessage = "Audiobook render completed. Export is unlocked.";
325
+ break;
326
+ case "cancelled":
327
+ state.statusMessage = "Render cancelled.";
328
+ break;
329
+ case "failed":
330
+ state.errorMessage = payload.message || "Render failed.";
331
+ break;
332
+ default:
333
+ break;
334
+ }
335
+ }
336
+
337
+ async function requestExport() {
338
+ if (!state.client) return;
339
+ state.errorMessage = "";
340
+ state.statusMessage = `Preparing ${state.exportFormat.toUpperCase()} export…`;
341
+ render();
342
+ try {
343
+ const payload = await callPredict("/export_audiobook", {
344
+ session_id: state.sessionId,
345
+ format: state.exportFormat,
346
+ metadata: {
347
+ title: state.exportMetadata.title,
348
+ artist: state.exportMetadata.author,
349
+ narrator: state.exportMetadata.narrator,
350
+ genre: state.exportMetadata.genre,
351
+ },
352
+ embed_markers: state.embedMarkers,
353
+ });
354
+ state.exportFile = payload;
355
+ state.previewUrl = payload.url || state.previewUrl;
356
+ state.statusMessage = `${state.exportFormat.toUpperCase()} export ready.`;
357
+ } catch (error) {
358
+ state.errorMessage = error.message;
359
+ }
360
+ render();
361
+ }
362
+
363
+ async function callPredict(apiName, payload) {
364
+ if (!state.client) throw new Error("Client not connected");
365
+ const result = await state.client.predict(apiName, payload);
366
+ return result.data?.[0] ?? result;
367
+ }
368
+
369
+ function buildVoiceConfig() {
370
+ if (state.voiceMode === "clone") {
371
+ return {
372
+ mode: "clone",
373
+ referenceText: state.cloneReferenceText,
374
+ cloneConsent: state.cloneConsent,
375
+ samplePath: state.cloneSampleFile ? handle_file(state.cloneSampleFile) : null,
376
+ };
377
+ }
378
+ if (state.voiceMode === "design") {
379
+ return {
380
+ mode: "design",
381
+ designPrompt: state.designPrompt,
382
+ };
383
+ }
384
+ return {
385
+ mode: "auto",
386
+ narratorId: state.narratorId,
387
+ };
388
+ }
389
+
390
+ function selectedChapterIds() {
391
+ return state.chapters.filter((chapter) => chapter.included).map((chapter) => chapter.id);
392
+ }
393
+
394
+ function currentChapter() {
395
+ return state.chapters.find((chapter) => chapter.id === state.currentChapterId) || state.chapters[0];
396
+ }
397
+
398
+ function includedChapters() {
399
+ return state.chapters.filter((chapter) => chapter.included);
400
+ }
401
+
402
+ function totalMinutes() {
403
+ return includedChapters().reduce((sum, chapter) => sum + Number(chapter.est_minutes || chapter.estMinutes || 0), 0);
404
+ }
405
+
406
+ function formatRuntime(minutes) {
407
+ const total = Math.max(0, Math.round(minutes));
408
+ const hours = Math.floor(total / 60);
409
+ const mins = total % 60;
410
+ return hours ? `${hours}h ${String(mins).padStart(2, "0")}m` : `${mins}m`;
411
+ }
412
+
413
+ function formatStamp(totalSeconds) {
414
+ const seconds = Math.max(0, Math.round(totalSeconds));
415
+ const hours = Math.floor(seconds / 3600);
416
+ const minutes = Math.floor((seconds % 3600) / 60);
417
+ const secs = seconds % 60;
418
+ return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
419
+ }
420
+
421
+ function updateChapterDuration(chapterId, seconds) {
422
+ const chapter = state.chapters.find((item) => item.id === chapterId);
423
+ if (chapter) {
424
+ chapter.duration_seconds = seconds;
425
+ }
426
+ }
427
+
428
+ function render() {
429
+ const previousChapterListScrollTop = document.querySelector(".chapter-list")?.scrollTop ?? 0;
430
+ const previousReadingContainer = document.querySelector("[data-reading-scroll]");
431
+ const previousReadingScrollTop = previousReadingContainer?.scrollTop ?? 0;
432
+ const previousReadingChapterId = previousReadingContainer?.dataset.chapterId ?? "";
433
+ const nextReadingChapterId = String(currentChapter()?.id || "");
434
+ appRoot.innerHTML = `
435
+ <div class="app-shell">
436
+ <div class="sheet">
437
+ <div class="page">
438
+ ${renderMasthead()}
439
+ ${state.errorMessage ? `<div class="error-banner">${escapeHtml(state.errorMessage)}</div>` : ""}
440
+ ${state.statusMessage ? `<div class="status-banner">${escapeHtml(state.statusMessage)}</div>` : ""}
441
+ ${state.step === "configure" ? renderConfigure() : ""}
442
+ ${state.step === "generate" ? renderGenerate() : ""}
443
+ ${state.step === "export" ? renderExport() : ""}
444
+ </div>
445
+ </div>
446
+ </div>
447
+ `;
448
+ const chapterList = document.querySelector(".chapter-list");
449
+ if (chapterList) {
450
+ chapterList.scrollTop = previousChapterListScrollTop;
451
+ }
452
+ const nextReadingContainer = document.querySelector("[data-reading-scroll]");
453
+ if (nextReadingContainer && previousReadingChapterId === nextReadingChapterId) {
454
+ nextReadingContainer.scrollTop = previousReadingScrollTop;
455
+ }
456
+ }
457
+
458
+ function renderMasthead() {
459
+ const activeIndex = { configure: 0, generate: 1, export: 2 }[state.step] ?? 0;
460
+ return `
461
+ <div class="mast">
462
+ <div class="brand">
463
+ <div class="plaque">📖</div>
464
+ <div>
465
+ <div class="wordmark">Scriptorium</div>
466
+ <div class="tagline">Bind your library into spoken word · OmniVoice TTS</div>
467
+ </div>
468
+ </div>
469
+ <div class="steps">
470
+ ${["Upload & Configure", "Generate", "Export"].map((label, index) => {
471
+ const cls = index < activeIndex ? "done" : index === activeIndex ? "now" : "";
472
+ const prefix = index < activeIndex ? "✓ " : "";
473
+ return `${index ? `<span class="sep">—</span>` : ""}<span class="step ${cls}">${prefix}${label}</span>`;
474
+ }).join("")}
475
+ </div>
476
+ </div>
477
+ `;
478
+ }
479
+
480
+ function renderConfigure() {
481
+ const chapter = currentChapter();
482
+ const included = includedChapters();
483
+ const chapterCount = included.length;
484
+ const runtime = formatRuntime(totalMinutes());
485
+ return `
486
+ <div class="hero">
487
+ ${renderCover()}
488
+ <div class="hero-meta">
489
+ <div class="hero-title">${escapeHtml(state.book?.title || "Upload an EPUB to begin")}</div>
490
+ <div class="hero-subtitle">${escapeHtml(state.book ? `by ${state.book.author}` : "A custom Gradio Space for audiobook binding")}</div>
491
+ <div class="hero-kicker">${escapeHtml(state.book?.meta || "Public Space · GPU-backed · session-only exports")}</div>
492
+ <div style="margin-top:15px; display:flex; gap:10px; flex-wrap:wrap;">
493
+ <button class="ghost-btn" data-action="chooseUpload">Change book</button>
494
+ <input id="epub-input" type="file" accept=".epub" class="hide" />
495
+ </div>
496
+ </div>
497
+ <div class="hero-tally">
498
+ <div class="hero-big">${chapterCount}<span>/${state.chapters.length || 0}</span></div>
499
+ <div class="smallcaps">chapters on the shelf</div>
500
+ <div class="hero-run">≈ <strong>${runtime}</strong> runtime</div>
501
+ </div>
502
+ </div>
503
+
504
+ <div class="cols">
505
+ <div>
506
+ <div class="panel-head">
507
+ <h2 class="panel-title">Select chapters to bind</h2>
508
+ <span class="right">${chapterCount ? `${chapterCount} selected` : "Upload an EPUB"}</span>
509
+ </div>
510
+ <div class="queue">
511
+ <div class="list-head">
512
+ Include in audiobook
513
+ <span class="count">${chapterCount} of ${state.chapters.length}</span>
514
+ </div>
515
+ <div class="chapter-list">
516
+ ${state.chapters.length ? state.chapters.map(renderChapterRow).join("") : renderUploadPlaceholder()}
517
+ </div>
518
+ </div>
519
+ </div>
520
+
521
+ <div>
522
+ <div class="panel-head">
523
+ <h2 class="panel-title">Now reading</h2>
524
+ </div>
525
+ <div class="reading-panel">
526
+ <div class="reading-head">
527
+ <div>
528
+ <h3>${escapeHtml(chapter?.title || "Waiting for an EPUB")}</h3>
529
+ <div class="chapter-meta">${chapter ? `${chapter.chars.toLocaleString()} chars · ≈ ${chapter.est_minutes} min · chapter ${chapter.n}` : "Upload a book to inspect chapters"}</div>
530
+ </div>
531
+ <div class="chips">
532
+ <button class="chip" data-action="previewChapter" data-id="${chapter?.id || ""}" ${chapter ? "" : "disabled"}>Preview</button>
533
+ <button class="chip warn" data-action="focusChapter" data-id="${chapter?.id || ""}" ${chapter ? "" : "disabled"}>${chapter?.included === false ? "Skipped" : "Selected"}</button>
534
+ </div>
535
+ </div>
536
+ <div class="verse reading-scroll" data-reading-scroll data-chapter-id="${escapeAttr(chapter?.id || "")}">${renderChapterBody(chapter?.text || "")}</div>
537
+ ${state.previewUrl ? `<div style="margin-top: 16px;"><audio controls src="${state.previewUrl}"></audio></div>` : ""}
538
+ </div>
539
+
540
+ <div class="voice-panel">
541
+ <div class="seg">
542
+ ${["auto", "clone", "design"].map((mode) => `
543
+ <button type="button" data-action="setVoiceMode" data-mode="${mode}" class="${state.voiceMode === mode ? "active" : ""}">
544
+ ${mode}
545
+ </button>
546
+ `).join("")}
547
+ </div>
548
+ ${renderVoiceMode()}
549
+ <div class="dial-grid">
550
+ <div>
551
+ <div class="dial-head">
552
+ <span class="smallcaps">Diffusion steps</span>
553
+ <span class="dial-value">${state.diffusionSteps}</span>
554
+ </div>
555
+ <input id="diffusion-steps" class="range" type="range" min="8" max="64" value="${state.diffusionSteps}" />
556
+ </div>
557
+ <div>
558
+ <div class="dial-head">
559
+ <span class="smallcaps">Reading speed</span>
560
+ <span class="dial-value">${state.speed.toFixed(1)}×</span>
561
+ </div>
562
+ <input id="reading-speed" class="range" type="range" min="0.5" max="2" step="0.1" value="${state.speed}" />
563
+ </div>
564
+ </div>
565
+ </div>
566
+ </div>
567
+ </div>
568
+
569
+ <div class="footer">
570
+ <div class="footer-note">Binding <b>${chapterCount} chapters</b> · approx <b>${runtime}</b> · exports as <b>.m4a</b></div>
571
+ <div class="spacer"></div>
572
+ <button class="btn-ghost" data-action="previewChapter" data-id="${chapter?.id || ""}" ${chapter ? "" : "disabled"}>Hear a preview</button>
573
+ <button class="btn-primary" data-action="startRender" ${canStartRender() ? "" : "disabled"}>Generate full audiobook</button>
574
+ </div>
575
+ `;
576
+ }
577
+
578
+ function renderGenerate() {
579
+ const included = includedChapters();
580
+ const currentEvent = [...state.renderEvents].reverse().find((event) => event.type === "chapter_started");
581
+ const currentId = currentEvent?.chapter_id;
582
+ const doneCount = state.renderEvents.filter((event) => event.type === "chapter_done").length;
583
+ const percent = currentEvent ? Math.round((currentEvent.overall_progress || 0) * 100) : state.renderResult ? 100 : 0;
584
+ const activeChapter = state.chapters.find((chapter) => chapter.id === currentId) || included[0];
585
+ return `
586
+ <div class="hero">
587
+ ${renderCover({ w: 92, h: 134 })}
588
+ <div class="hero-meta">
589
+ <div class="smallcaps" style="display:flex; align-items:center; gap:9px; color:var(--leather);"><span class="status-dot"></span> Now binding · OmniVoice TTS</div>
590
+ <div class="screen-title" style="font-size:31px; font-weight:800; margin:6px 0 14px;">
591
+ Narrating <em style="color:var(--leather); font-style:italic;">${escapeHtml(activeChapter?.title || "your book")}</em>…
592
+ </div>
593
+ <div class="progress-bar"><div class="progress-fill" style="width:${percent}%"></div></div>
594
+ <div class="stats-hero">
595
+ <span><b>${doneCount} of ${included.length}</b> chapters bound</span>
596
+ <span>${formatRuntime(totalMinutes())} target runtime</span>
597
+ </div>
598
+ </div>
599
+ <div class="hero-stats">
600
+ <div class="pct">${percent}<span style="font-size:24px;">%</span></div>
601
+ <div class="smallcaps">complete</div>
602
+ <div style="margin-top:10px; display:flex; gap:10px; justify-content:flex-end; flex-wrap:wrap;">
603
+ <button class="small-btn" data-action="pauseRender">Pause</button>
604
+ <button class="small-btn danger" data-action="cancelRender">Cancel</button>
605
+ </div>
606
+ </div>
607
+ </div>
608
+
609
+ <div class="cols">
610
+ <div>
611
+ <div class="panel-head">
612
+ <h2 class="panel-title">Render queue</h2>
613
+ <span class="right">${doneCount}/${included.length} done</span>
614
+ </div>
615
+ <div class="queue">
616
+ <div class="queue-head">Chapter · status <span class="count">${state.renderStatus?.position ? `queue ${state.renderStatus.position}` : ""}</span></div>
617
+ ${state.chapters.map((chapter) => renderQueueRow(chapter, currentId)).join("")}
618
+ </div>
619
+ </div>
620
+ <div>
621
+ <div class="panel-head">
622
+ <h2 class="panel-title">Now narrating</h2>
623
+ </div>
624
+ <div class="player">
625
+ <div class="reading-head" style="border-bottom:none; margin-bottom:8px;">
626
+ <div>
627
+ <div class="now-title" style="font-size:23px; font-weight:700;">${escapeHtml(activeChapter?.title || "Waiting in queue")}</div>
628
+ <div class="chapter-meta">${activeChapter ? `chapter ${activeChapter.n} · ${activeChapter.chars.toLocaleString()} chars` : ""}</div>
629
+ </div>
630
+ <div class="pill">${escapeHtml(activeNarratorName())}</div>
631
+ </div>
632
+ <div class="wave">${new Array(25).fill(0).map((_, index) => `<i style="height:${40 + ((index * 13) % 55)}%; animation-delay:${index * 0.045}s"></i>`).join("")}</div>
633
+ <div class="spoken">
634
+ <span class="lit">${escapeHtml(excerpt(activeChapter?.text || "", 120))}</span>
635
+ <span class="dim">${escapeHtml(excerpt(activeChapter?.text || "", 220, 120))}</span>
636
+ </div>
637
+ </div>
638
+ <div class="panel-head" style="margin-top:18px;">
639
+ <h2 class="panel-title" style="font-size:19px;">Activity</h2>
640
+ </div>
641
+ <div class="log">
642
+ ${state.renderEvents.slice(-8).reverse().map(renderLogRow).join("") || `<div class="log-row"><span class="time">—</span><span>Waiting for render events…</span></div>`}
643
+ </div>
644
+ </div>
645
+ </div>
646
+
647
+ <div class="footer">
648
+ <div class="footer-note">Binding <b>${included.length} chapters</b> · <b>${doneCount} done</b> · Export unlocks when complete</div>
649
+ <div class="spacer"></div>
650
+ <button class="btn-ghost" data-action="pauseRender">Pause binding</button>
651
+ <button class="btn-primary" data-action="goExport" ${state.renderResult ? "" : "disabled"}>Continue to export</button>
652
+ </div>
653
+ `;
654
+ }
655
+
656
+ function renderExport() {
657
+ const tracks = includedChapters();
658
+ let elapsed = 0;
659
+ const trackRows = tracks.map((track, index) => {
660
+ const start = elapsed;
661
+ elapsed += Number(track.duration_seconds || track.est_minutes * 60 || 0);
662
+ return { track, start, playing: index === 2 };
663
+ });
664
+ const totalSeconds = elapsed || Math.round(totalMinutes() * 60);
665
+ const playbackSeconds = Math.min(totalSeconds, (trackRows[2]?.start || 0) + 300);
666
+ const percent = totalSeconds ? (playbackSeconds / totalSeconds) * 100 : 0;
667
+
668
+ return `
669
+ <div class="hero">
670
+ ${renderCover({ w: 96, h: 140 })}
671
+ <div class="hero-meta">
672
+ <div class="smallcaps" style="color:var(--green); display:flex; align-items:center; gap:9px;">✓ Audiobook bound · ready to export</div>
673
+ <div class="hero-title" style="font-size:33px;">${escapeHtml(state.book?.title || "Audiobook ready")}</div>
674
+ <div class="hero-subtitle">narrated by ${escapeHtml(activeNarratorName())}</div>
675
+ <div class="spec-strip">
676
+ <div class="spec"><div class="spec-value">${formatRuntime(totalSeconds / 60)}</div><div class="smallcaps">runtime</div></div>
677
+ <div class="spec"><div class="spec-value">${tracks.length}</div><div class="smallcaps">chapters</div></div>
678
+ <div class="spec"><div class="spec-value">.${state.exportFormat}</div><div class="smallcaps">format</div></div>
679
+ <div class="spec"><div class="spec-value">${state.exportFile ? humanBytes(state.exportFile.size_bytes) : "Pending"}</div><div class="smallcaps">size</div></div>
680
+ </div>
681
+ </div>
682
+ <div>
683
+ <button class="btn-primary" data-action="exportBook">${state.exportFile ? "Download again" : "Download audiobook"}</button>
684
+ ${state.exportFile ? `<div style="margin-top:10px;"><a href="${state.exportFile.url}" target="_blank" rel="noopener">Open exported file</a></div>` : `<div style="margin-top:10px; color:var(--faint); font-size:12.5px;">with embedded chapter markers</div>`}
685
+ </div>
686
+ </div>
687
+
688
+ <div class="cols">
689
+ <div>
690
+ <div class="panel-head">
691
+ <h2 class="panel-title">Chapters &amp; markers</h2>
692
+ <span class="right">${tracks.length} tracks</span>
693
+ </div>
694
+ <div class="queue">
695
+ <div class="list-head">Chapter <span class="count">${formatRuntime(totalSeconds / 60)} total</span></div>
696
+ <div class="track-list">
697
+ ${trackRows.map(({ track, start, playing }) => `
698
+ <div class="track-row ${playing ? "playing" : ""}">
699
+ <span class="roman">${escapeHtml(track.n)}</span>
700
+ <button class="circle-btn">${playing ? "❚❚" : "▶"}</button>
701
+ <div class="track-main">
702
+ <div class="track-name">${escapeHtml(track.title)}</div>
703
+ <div class="track-meta">starts at ${formatStamp(start)}</div>
704
+ </div>
705
+ <div>${formatStamp(Number(track.duration_seconds || track.est_minutes * 60 || 0))}</div>
706
+ </div>
707
+ `).join("")}
708
+ </div>
709
+ </div>
710
+ </div>
711
+ <div>
712
+ <div class="panel-head">
713
+ <h2 class="panel-title">Preview &amp; export</h2>
714
+ </div>
715
+ <div class="player">
716
+ <div style="display:flex; gap:18px; align-items:center; margin-bottom:18px;">
717
+ ${renderCover({ w: 70, h: 102 })}
718
+ <div>
719
+ <div class="smallcaps">Now playing · chapter ${trackRows[2]?.track.n || "i"}</div>
720
+ <div class="player-title" style="font-size:24px; font-weight:700;">${escapeHtml(trackRows[2]?.track.title || state.book?.title || "Preview")}</div>
721
+ <div class="chapter-meta">narrated by ${escapeHtml(activeNarratorName())} · 1.0×</div>
722
+ </div>
723
+ </div>
724
+ <div class="scrubber">
725
+ <div class="scrub-fill" style="width:${percent}%"></div>
726
+ ${trackRows.map(({ start }, index) => index ? `<span class="scrub-tick" style="left:${(start / totalSeconds) * 100}%"></span>` : "").join("")}
727
+ <span class="scrub-head" style="left:${percent}%"></span>
728
+ </div>
729
+ <div class="track-meta" style="display:flex; justify-content:space-between;"> <span>${formatStamp(playbackSeconds)} elapsed</span> <span>−${formatRuntime((totalSeconds - playbackSeconds) / 60)}</span></div>
730
+ <div class="transport">
731
+ <button class="transport-btn">⏮</button>
732
+ <button class="transport-btn">↺15</button>
733
+ <button class="transport-btn main" data-action="playExportPreview">▶</button>
734
+ <button class="transport-btn">30↻</button>
735
+ <button class="transport-btn">⏭</button>
736
+ </div>
737
+ ${state.previewUrl ? `<audio id="preview-audio" src="${state.previewUrl}" preload="metadata"></audio>` : ""}
738
+ </div>
739
+ <div class="options">
740
+ <div class="smallcaps" style="margin-bottom:9px;">Export format</div>
741
+ <div class="format-grid" style="grid-template-columns:repeat(3, minmax(0, 1fr));">
742
+ ${["m4a", "mp3", "zip"].map((format) => `
743
+ <label class="format-card ${state.exportFormat === format ? "active" : ""}">
744
+ <input type="radio" class="hide" name="format" data-format="${format}" ${state.exportFormat === format ? "checked" : ""} />
745
+ <div class="spec-value" style="font-size:20px;">.${format}</div>
746
+ <div class="chapter-meta">${format === "zip" ? "one file per chapter" : "single file export"}</div>
747
+ </label>
748
+ `).join("")}
749
+ </div>
750
+ <div class="smallcaps" style="margin:18px 0 9px;">Audiobook metadata</div>
751
+ <div class="meta-grid">
752
+ ${Object.entries(state.exportMetadata).map(([key, value]) => `
753
+ <label>
754
+ <div class="chapter-meta" style="margin-bottom:5px; text-transform:uppercase;">${escapeHtml(key)}</div>
755
+ <input class="field" data-meta="${key}" value="${escapeAttr(value)}" />
756
+ </label>
757
+ `).join("")}
758
+ </div>
759
+ <label style="display:flex; align-items:center; gap:11px; margin-top:16px;">
760
+ <input id="embed-markers" type="checkbox" ${state.embedMarkers ? "checked" : ""} />
761
+ Embed chapter markers &amp; cover art into the file
762
+ </label>
763
+ </div>
764
+ </div>
765
+ </div>
766
+
767
+ <div class="footer">
768
+ <div class="footer-note">${escapeHtml(state.book?.title || "Audiobook")} · ${formatRuntime(totalSeconds / 60)} · ${tracks.length} chapters</div>
769
+ <div class="spacer"></div>
770
+ <button class="btn-ghost" data-action="goConfigure">Back to configure</button>
771
+ <button class="btn-primary" data-action="exportBook">${state.exportFile ? "Download audiobook" : "Prepare export"}</button>
772
+ </div>
773
+ `;
774
+ }
775
+
776
+ function renderCover(options = {}) {
777
+ const w = options.w || 124;
778
+ const h = options.h || 182;
779
+ const scale = w / 124;
780
+ const style = `width:${w}px;height:${h}px;padding:${16 * scale}px ${12 * scale}px;`;
781
+ return `
782
+ <div class="cover" style="${style}">
783
+ <span class="cover-corner top"></span>
784
+ <span class="cover-corner bottom"></span>
785
+ <div class="cover-title" style="font-size:${18 * scale}px;">${escapeHtml(state.book?.title || "Scriptorium")}</div>
786
+ <div class="cover-rule" style="width:${38 * scale}px;"></div>
787
+ <div class="cover-author" style="font-size:${13 * scale}px;">${escapeHtml(state.book?.author || "OmniVoice")}</div>
788
+ </div>
789
+ `;
790
+ }
791
+
792
+ function renderUploadPlaceholder() {
793
+ return `
794
+ <div style="padding: 22px 18px;">
795
+ <p style="margin-top:0;">Upload an EPUB to unlock chapter selection, narrator controls, preview, and render/export steps.</p>
796
+ <button class="btn-primary" data-action="chooseUpload">Choose EPUB</button>
797
+ <input id="epub-input" type="file" accept=".epub" class="hide" />
798
+ </div>
799
+ `;
800
+ }
801
+
802
+ function renderChapterRow(chapter) {
803
+ const active = chapter.id === state.currentChapterId ? "focus" : "";
804
+ const off = chapter.included ? "" : "off";
805
+ return `
806
+ <label class="chapter-row ${active} ${off}">
807
+ <input type="checkbox" data-chapter-toggle="${chapter.id}" ${chapter.included ? "checked" : ""} />
808
+ <span class="roman">${escapeHtml(chapter.n)}</span>
809
+ <div class="chapter-main" data-action="focusChapter" data-id="${chapter.id}">
810
+ <div class="chapter-name">${escapeHtml(chapter.title)}</div>
811
+ <div class="chapter-meta">${chapter.chars.toLocaleString()} chars · ≈${chapter.est_minutes} min ${chapter.included ? "" : "· skipped"}</div>
812
+ </div>
813
+ <button class="circle-btn" type="button" data-action="previewChapter" data-id="${chapter.id}">▶</button>
814
+ </label>
815
+ `;
816
+ }
817
+
818
+ function renderVersePreview(text) {
819
+ if (!text) {
820
+ return `<p>Choose a chapter to inspect the reading pane.</p>`;
821
+ }
822
+ const lines = excerpt(text, 420).split(/\n+/).slice(0, 10);
823
+ return `<span class="dropcap">${escapeHtml(lines[0]?.[0] || "W")}</span>${lines
824
+ .map((line, index) => {
825
+ const body = index === 0 ? line.slice(1) : line;
826
+ return `<p>${escapeHtml(body)}</p>`;
827
+ })
828
+ .join("")}`;
829
+ }
830
+
831
+ function renderChapterBody(text) {
832
+ if (!text) {
833
+ return `<p>Choose a chapter to inspect the reading pane.</p>`;
834
+ }
835
+ const paragraphs = text
836
+ .split(/\n{2,}/)
837
+ .map((chunk) => chunk.trim())
838
+ .filter(Boolean);
839
+ if (!paragraphs.length) {
840
+ return `<p>${escapeHtml(text)}</p>`;
841
+ }
842
+ const [first, ...rest] = paragraphs;
843
+ const firstLead = first[0] || "W";
844
+ const firstBody = first.slice(1);
845
+ return `
846
+ <p><span class="dropcap">${escapeHtml(firstLead)}</span>${escapeHtml(firstBody)}</p>
847
+ ${rest.map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`).join("")}
848
+ `;
849
+ }
850
+
851
+ function renderVoiceMode() {
852
+ if (state.voiceMode === "clone") {
853
+ return `
854
+ <div class="drop-zone">
855
+ <div class="panel-title" style="font-size:21px; margin-bottom:6px;">Record your own narrator</div>
856
+ <div class="chapter-meta" style="margin-bottom:14px;">Recommended: 3–10 seconds of clean speech.</div>
857
+ <input id="clone-audio" class="field" type="file" accept="audio/*" />
858
+ <div class="chapter-meta" style="margin-top:10px;">${state.cloneSampleFile ? escapeHtml(state.cloneSampleFile.name) : "No sample selected yet."}</div>
859
+ </div>
860
+ <label style="display:block; margin-top:15px;">
861
+ <div class="smallcaps" style="margin-bottom:7px;">Reference text spoken (optional)</div>
862
+ <input id="clone-ref-text" class="field" value="${escapeAttr(state.cloneReferenceText)}" placeholder="Type exactly what is said in the sample…" />
863
+ </label>
864
+ <label style="display:flex; gap:10px; align-items:flex-start; margin-top:14px;">
865
+ <input id="clone-consent" type="checkbox" ${state.cloneConsent ? "checked" : ""} />
866
+ <span>${escapeHtml(VOICE_WARNING)}</span>
867
+ </label>
868
+ `;
869
+ }
870
+ if (state.voiceMode === "design") {
871
+ return `
872
+ <label style="display:block;">
873
+ <div class="smallcaps" style="margin-bottom:7px;">Describe the voice you want</div>
874
+ <textarea id="design-prompt" class="textarea" placeholder="A warm, unhurried narrator — low pitch, faintly British, the kind of voice that belongs by a fireplace…">${escapeHtml(state.designPrompt)}</textarea>
875
+ </label>
876
+ `;
877
+ }
878
+ return `
879
+ <div class="narrator-grid">
880
+ ${NARRATORS.map((narrator) => `
881
+ <button class="narrator-card ${state.narratorId === narrator.id ? "active" : ""}" data-action="pickNarrator" data-id="${narrator.id}">
882
+ <div class="spec-value" style="font-size:17px;">${escapeHtml(narrator.name)}</div>
883
+ <div class="chapter-meta">${escapeHtml(narrator.desc)}</div>
884
+ </button>
885
+ `).join("")}
886
+ </div>
887
+ `;
888
+ }
889
+
890
+ function renderQueueRow(chapter, currentId) {
891
+ const skipped = !chapter.included;
892
+ const done = state.renderEvents.some((event) => event.type === "chapter_done" && event.chapter_id === chapter.id);
893
+ const rendering = chapter.id === currentId && !done;
894
+ const status = skipped ? "skipped" : done ? "done" : rendering ? "rendering" : "queued";
895
+ const label = skipped
896
+ ? "Skipped · not in audiobook"
897
+ : done
898
+ ? `Bound · ${chapter.duration_seconds || chapter.est_minutes * 60}s`
899
+ : rendering
900
+ ? "Rendering now…"
901
+ : `Queued · ≈${chapter.est_minutes} min`;
902
+ return `
903
+ <div class="queue-row ${status}">
904
+ <div class="queue-status ${status}">${status === "done" ? "✓" : status === "rendering" ? "●" : status === "queued" ? "○" : "–"}</div>
905
+ <span class="roman">${escapeHtml(chapter.n)}</span>
906
+ <div class="queue-main">
907
+ <div class="queue-name">${escapeHtml(chapter.title)}</div>
908
+ <div class="queue-meta">${escapeHtml(label)}</div>
909
+ ${rendering ? `<div class="mini-bar"><div class="mini-fill" style="width:68%"></div></div>` : ""}
910
+ </div>
911
+ <div>${skipped ? "—" : `≈${chapter.est_minutes}m`}</div>
912
+ </div>
913
+ `;
914
+ }
915
+
916
+ function renderLogRow(event) {
917
+ const stamp = new Date().toLocaleTimeString();
918
+ return `<div class="log-row ${event.type === "chapter_started" ? "current" : ""}"><span class="time">${stamp}</span><span>${escapeHtml(logMessage(event))}</span></div>`;
919
+ }
920
+
921
+ function logMessage(event) {
922
+ switch (event.type) {
923
+ case "started":
924
+ return `Began binding ${event.total_chapters} chapters`;
925
+ case "chapter_started":
926
+ return `Synthesising ${event.chapter_title}`;
927
+ case "chapter_done":
928
+ return `Bound ${event.chapter_id} · ${event.duration_seconds}s`;
929
+ case "completed":
930
+ return "Completed audiobook render";
931
+ case "cancelled":
932
+ return "Cancelled render";
933
+ default:
934
+ return JSON.stringify(event);
935
+ }
936
+ }
937
+
938
+ function canStartRender() {
939
+ if (!state.book) return false;
940
+ if (!selectedChapterIds().length) return false;
941
+ if (state.voiceMode === "clone" && (!state.cloneSampleFile || !state.cloneConsent)) return false;
942
+ if (state.voiceMode === "design" && !state.designPrompt.trim()) return false;
943
+ return true;
944
+ }
945
+
946
+ function activeNarratorName() {
947
+ const narrator = NARRATORS.find((item) => item.id === state.narratorId);
948
+ return narrator?.name || "Custom narrator";
949
+ }
950
+
951
+ function syncNarratorMetadata() {
952
+ state.exportMetadata.narrator = `${activeNarratorName()} (OmniVoice)`;
953
+ }
954
+
955
+ function excerpt(text, length, start = 0) {
956
+ if (!text) return "";
957
+ return text.slice(start, start + length).trim();
958
+ }
959
+
960
+ function humanBytes(size) {
961
+ if (!size) return "0 B";
962
+ const units = ["B", "KB", "MB", "GB"];
963
+ let value = size;
964
+ let index = 0;
965
+ while (value >= 1024 && index < units.length - 1) {
966
+ value /= 1024;
967
+ index += 1;
968
+ }
969
+ return `${value.toFixed(index ? 1 : 0)} ${units[index]}`;
970
+ }
971
+
972
+ function escapeHtml(value) {
973
+ return String(value)
974
+ .replaceAll("&", "&amp;")
975
+ .replaceAll("<", "&lt;")
976
+ .replaceAll(">", "&gt;")
977
+ .replaceAll('"', "&quot;")
978
+ .replaceAll("'", "&#39;");
979
+ }
980
+
981
+ function escapeAttr(value) {
982
+ return escapeHtml(value);
983
+ }
984
+
985
+ main();
frontend/index.html ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Scriptorium</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;800&family=Spectral:wght@400;500;600;700&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.3.2/iframeResizer.contentWindow.min.js"></script>
14
+ <link rel="stylesheet" href="/static/styles.css" />
15
+ </head>
16
+ <body>
17
+ <div id="app"></div>
18
+ <script type="module" src="/static/app.js"></script>
19
+ </body>
20
+ </html>
frontend/styles.css ADDED
@@ -0,0 +1,1056 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --paper: #f6efde;
3
+ --paper2: #efe5cd;
4
+ --paperEdge: #e4d6b4;
5
+ --ink: #33271a;
6
+ --ink2: #5e4d36;
7
+ --faint: #8a795c;
8
+ --leather: #73302a;
9
+ --leather2: #8c3c33;
10
+ --leatherInk: #f3e2c8;
11
+ --brass: #b08a44;
12
+ --brass2: #caa75e;
13
+ --brassDeep: #876327;
14
+ --green: #3c5240;
15
+ --line: rgba(135, 99, 39, 0.25);
16
+ }
17
+
18
+ * {
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ body {
23
+ margin: 0;
24
+ min-height: 100vh;
25
+ font-family: "Spectral", Georgia, serif;
26
+ color: var(--ink);
27
+ background:
28
+ radial-gradient(70% 50% at 22% -6%, rgba(255, 234, 176, 0.5), transparent 55%),
29
+ repeating-linear-gradient(91deg, #5a3f28, #5a3f28 3px, #553a24 3px, #553a24 7px),
30
+ linear-gradient(160deg, #5e4229, #4a331f);
31
+ }
32
+
33
+ a {
34
+ color: inherit;
35
+ }
36
+
37
+ button,
38
+ input,
39
+ textarea,
40
+ select {
41
+ font: inherit;
42
+ }
43
+
44
+ .app-shell {
45
+ max-width: 1480px;
46
+ margin: 22px auto;
47
+ padding: 34px;
48
+ }
49
+
50
+ .sheet {
51
+ min-height: calc(100vh - 44px);
52
+ border-radius: 8px;
53
+ background:
54
+ radial-gradient(90% 60% at 18% 0%, #fbf5e6, transparent 55%),
55
+ linear-gradient(170deg, #f6efde, #ece0c4);
56
+ box-shadow:
57
+ 0 30px 70px rgba(0, 0, 0, 0.5),
58
+ 0 2px 0 rgba(255, 255, 255, 0.4) inset,
59
+ 0 0 0 1px rgba(135, 99, 39, 0.25);
60
+ overflow: hidden;
61
+ position: relative;
62
+ }
63
+
64
+ .sheet::before {
65
+ content: "";
66
+ position: absolute;
67
+ inset: 0;
68
+ pointer-events: none;
69
+ mix-blend-mode: multiply;
70
+ opacity: 0.45;
71
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='p'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.7' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23p)' opacity='.35'/%3E%3C/svg%3E");
72
+ }
73
+
74
+ .page {
75
+ position: relative;
76
+ padding: 36px 48px 30px;
77
+ }
78
+
79
+ .mast {
80
+ display: flex;
81
+ justify-content: space-between;
82
+ align-items: center;
83
+ gap: 20px;
84
+ margin-bottom: 24px;
85
+ }
86
+
87
+ .brand {
88
+ display: flex;
89
+ align-items: center;
90
+ gap: 18px;
91
+ }
92
+
93
+ .plaque {
94
+ width: 58px;
95
+ height: 58px;
96
+ border-radius: 7px;
97
+ display: grid;
98
+ place-items: center;
99
+ color: #fff6e2;
100
+ background: linear-gradient(150deg, var(--brass2), var(--brassDeep));
101
+ border: 1px solid #e9cf92;
102
+ box-shadow: inset 0 2px 3px rgba(255, 255, 255, 0.5), inset 0 -3px 6px rgba(0, 0, 0, 0.3), 0 4px 10px rgba(0, 0, 0, 0.3);
103
+ }
104
+
105
+ .wordmark {
106
+ font-family: "Playfair Display", serif;
107
+ font-size: 38px;
108
+ font-weight: 800;
109
+ color: var(--leather);
110
+ line-height: 0.95;
111
+ letter-spacing: -0.5px;
112
+ }
113
+
114
+ .tagline {
115
+ color: var(--ink2);
116
+ font-style: italic;
117
+ font-size: 16px;
118
+ margin-top: 4px;
119
+ }
120
+
121
+ .steps {
122
+ display: flex;
123
+ align-items: center;
124
+ gap: 12px;
125
+ flex-wrap: wrap;
126
+ }
127
+
128
+ .step {
129
+ color: var(--faint);
130
+ text-transform: uppercase;
131
+ letter-spacing: 1.5px;
132
+ font-size: 12.5px;
133
+ font-weight: 600;
134
+ }
135
+
136
+ .step.done {
137
+ color: var(--brassDeep);
138
+ }
139
+
140
+ .step.now {
141
+ color: var(--leather);
142
+ border-bottom: 2px solid var(--leather);
143
+ padding-bottom: 4px;
144
+ }
145
+
146
+ .sep {
147
+ color: rgba(135, 99, 39, 0.45);
148
+ }
149
+
150
+ .card,
151
+ .panel,
152
+ .hero,
153
+ .queue,
154
+ .player,
155
+ .voice-panel,
156
+ .reading-panel,
157
+ .options,
158
+ .log {
159
+ border: 1px solid var(--line);
160
+ border-radius: 6px;
161
+ background: linear-gradient(180deg, #fffdf6, #f7efdc);
162
+ box-shadow: 0 8px 20px rgba(74, 51, 31, 0.1);
163
+ }
164
+
165
+ .hero {
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 28px;
169
+ padding: 20px 26px;
170
+ margin-bottom: 18px;
171
+ background: linear-gradient(180deg, #fff, rgba(246, 239, 222, 0.4));
172
+ box-shadow: 0 2px 0 rgba(255, 255, 255, 0.6) inset, 0 10px 24px rgba(74, 51, 31, 0.12);
173
+ }
174
+
175
+ .cover {
176
+ width: 124px;
177
+ height: 182px;
178
+ flex: 0 0 auto;
179
+ padding: 16px 12px;
180
+ position: relative;
181
+ border-radius: 3px 5px 5px 3px;
182
+ background: linear-gradient(120deg, #7d342c, #5e241f);
183
+ box-shadow: 0 12px 26px rgba(0, 0, 0, 0.4), inset -8px 0 14px rgba(0, 0, 0, 0.35);
184
+ display: flex;
185
+ flex-direction: column;
186
+ justify-content: center;
187
+ text-align: center;
188
+ }
189
+
190
+ .cover::before {
191
+ content: "";
192
+ position: absolute;
193
+ inset: 0 auto 0 0;
194
+ width: 11px;
195
+ border-radius: 3px 0 0 3px;
196
+ background: linear-gradient(90deg, #4a1b17, #742c25);
197
+ box-shadow: inset -2px 0 3px rgba(0, 0, 0, 0.5);
198
+ }
199
+
200
+ .cover-title {
201
+ font-family: "Playfair Display", serif;
202
+ font-size: 18px;
203
+ font-weight: 700;
204
+ color: #f3dcb0;
205
+ line-height: 1.05;
206
+ }
207
+
208
+ .cover-rule {
209
+ width: 38px;
210
+ height: 2px;
211
+ margin: 11px auto;
212
+ background: var(--brass2);
213
+ }
214
+
215
+ .cover-author {
216
+ font-size: 13px;
217
+ font-style: italic;
218
+ color: #e6c894;
219
+ }
220
+
221
+ .cover-corner {
222
+ position: absolute;
223
+ width: 18px;
224
+ height: 18px;
225
+ border: 2px solid var(--brass2);
226
+ opacity: 0.85;
227
+ }
228
+
229
+ .cover-corner.top {
230
+ top: 7px;
231
+ right: 7px;
232
+ border-left: none;
233
+ border-bottom: none;
234
+ }
235
+
236
+ .cover-corner.bottom {
237
+ right: 7px;
238
+ bottom: 7px;
239
+ border-left: none;
240
+ border-top: none;
241
+ }
242
+
243
+ .hero-meta {
244
+ flex: 1 1 auto;
245
+ }
246
+
247
+ .hero-title,
248
+ .panel-title,
249
+ .screen-title {
250
+ font-family: "Playfair Display", serif;
251
+ color: var(--ink);
252
+ }
253
+
254
+ .hero-title {
255
+ font-weight: 800;
256
+ font-size: 38px;
257
+ line-height: 1.02;
258
+ letter-spacing: -0.5px;
259
+ }
260
+
261
+ .hero-subtitle {
262
+ margin-top: 5px;
263
+ color: var(--leather);
264
+ font-size: 20px;
265
+ font-style: italic;
266
+ }
267
+
268
+ .hero-kicker {
269
+ margin-top: 8px;
270
+ color: var(--faint);
271
+ font-size: 13px;
272
+ text-transform: uppercase;
273
+ letter-spacing: 1.6px;
274
+ }
275
+
276
+ .hero-tally {
277
+ text-align: right;
278
+ padding: 14px 22px;
279
+ border-radius: 6px;
280
+ color: #fff6e2;
281
+ background: linear-gradient(160deg, var(--brass2), var(--brassDeep));
282
+ border: 1px solid #e9cf92;
283
+ box-shadow: inset 0 2px 3px rgba(255, 255, 255, 0.4), inset 0 -3px 8px rgba(0, 0, 0, 0.28);
284
+ }
285
+
286
+ .hero-big {
287
+ font-family: "Playfair Display", serif;
288
+ font-size: 46px;
289
+ font-weight: 800;
290
+ line-height: 0.9;
291
+ }
292
+
293
+ .hero-big span {
294
+ font-size: 24px;
295
+ opacity: 0.75;
296
+ }
297
+
298
+ .hero-run,
299
+ .smallcaps {
300
+ text-transform: uppercase;
301
+ letter-spacing: 1.2px;
302
+ font-size: 12px;
303
+ }
304
+
305
+ .hero-run {
306
+ margin-top: 10px;
307
+ padding-top: 10px;
308
+ border-top: 1px solid rgba(255, 255, 255, 0.3);
309
+ font-size: 16px;
310
+ text-transform: none;
311
+ letter-spacing: 0;
312
+ }
313
+
314
+ .cols {
315
+ display: grid;
316
+ grid-template-columns: 1fr 1.08fr;
317
+ gap: 24px;
318
+ }
319
+
320
+ .panel-head {
321
+ display: flex;
322
+ align-items: center;
323
+ gap: 11px;
324
+ margin-bottom: 14px;
325
+ }
326
+
327
+ .panel-head .right {
328
+ margin-left: auto;
329
+ color: var(--brassDeep);
330
+ font-size: 13px;
331
+ text-transform: uppercase;
332
+ letter-spacing: 1.2px;
333
+ font-weight: 600;
334
+ }
335
+
336
+ .panel-title {
337
+ margin: 0;
338
+ font-size: 23px;
339
+ font-weight: 700;
340
+ }
341
+
342
+ .queue-head,
343
+ .list-head {
344
+ display: flex;
345
+ align-items: center;
346
+ gap: 12px;
347
+ padding: 12px 18px;
348
+ border-bottom: 1px solid rgba(135, 99, 39, 0.22);
349
+ background: linear-gradient(180deg, #f1e4c6, #e9d9b6);
350
+ color: var(--ink2);
351
+ text-transform: uppercase;
352
+ letter-spacing: 1.3px;
353
+ font-size: 12.5px;
354
+ font-weight: 600;
355
+ }
356
+
357
+ .queue-head .count,
358
+ .list-head .count {
359
+ margin-left: auto;
360
+ color: var(--leather);
361
+ }
362
+
363
+ .chapter-list,
364
+ .track-list {
365
+ max-height: 520px;
366
+ overflow: auto;
367
+ }
368
+
369
+ .chapter-row,
370
+ .track-row,
371
+ .queue-row {
372
+ display: grid;
373
+ grid-template-columns: auto auto 1fr auto;
374
+ gap: 14px;
375
+ align-items: center;
376
+ padding: 12px 18px;
377
+ border-bottom: 1px solid rgba(135, 99, 39, 0.13);
378
+ }
379
+
380
+ .chapter-row:nth-child(even),
381
+ .track-row:nth-child(even) {
382
+ background: rgba(135, 99, 39, 0.035);
383
+ }
384
+
385
+ .chapter-row.focus,
386
+ .queue-row.rendering,
387
+ .track-row.playing {
388
+ background: rgba(115, 48, 42, 0.08);
389
+ box-shadow: inset 3px 0 0 var(--leather);
390
+ }
391
+
392
+ .checkbox {
393
+ width: 22px;
394
+ height: 22px;
395
+ border: 1.5px solid var(--brass);
396
+ border-radius: 4px;
397
+ display: grid;
398
+ place-items: center;
399
+ background: #fff;
400
+ }
401
+
402
+ .checkbox.checked {
403
+ background: linear-gradient(180deg, var(--leather2), var(--leather));
404
+ border-color: var(--leather);
405
+ color: white;
406
+ }
407
+
408
+ .roman {
409
+ width: 30px;
410
+ text-align: center;
411
+ font-family: "Playfair Display", serif;
412
+ font-style: italic;
413
+ color: var(--brassDeep);
414
+ }
415
+
416
+ .chapter-main,
417
+ .track-main,
418
+ .queue-main {
419
+ min-width: 0;
420
+ }
421
+
422
+ .chapter-name,
423
+ .track-name,
424
+ .queue-name {
425
+ font-size: 17px;
426
+ font-weight: 500;
427
+ }
428
+
429
+ .chapter-meta,
430
+ .track-meta,
431
+ .queue-meta {
432
+ color: var(--faint);
433
+ font-size: 12.5px;
434
+ }
435
+
436
+ .chapter-row.off .chapter-name,
437
+ .queue-row.skipped .queue-name {
438
+ color: var(--faint);
439
+ text-decoration: line-through;
440
+ text-decoration-color: rgba(138, 121, 92, 0.5);
441
+ }
442
+
443
+ .circle-btn,
444
+ .transport-btn,
445
+ .pill,
446
+ .seg button,
447
+ .ghost-btn,
448
+ .small-btn {
449
+ border: 1px solid rgba(135, 99, 39, 0.4);
450
+ background: #fff;
451
+ color: var(--ink);
452
+ cursor: pointer;
453
+ }
454
+
455
+ .circle-btn,
456
+ .transport-btn {
457
+ width: 34px;
458
+ height: 34px;
459
+ border-radius: 50%;
460
+ }
461
+
462
+ .circle-btn:hover,
463
+ .transport-btn:hover,
464
+ .ghost-btn:hover,
465
+ .small-btn:hover {
466
+ background: rgba(176, 138, 68, 0.12);
467
+ }
468
+
469
+ .reading-panel,
470
+ .player {
471
+ padding: 20px 24px;
472
+ box-shadow: 0 12px 28px rgba(74, 51, 31, 0.16);
473
+ }
474
+
475
+ .reading-panel {
476
+ position: relative;
477
+ }
478
+
479
+ .reading-panel::before {
480
+ content: "";
481
+ position: absolute;
482
+ left: 54px;
483
+ top: 0;
484
+ bottom: 0;
485
+ width: 1px;
486
+ background: rgba(176, 80, 70, 0.25);
487
+ }
488
+
489
+ .reading-head {
490
+ display: flex;
491
+ justify-content: space-between;
492
+ gap: 12px;
493
+ border-bottom: 1px solid rgba(135, 99, 39, 0.25);
494
+ padding-bottom: 14px;
495
+ margin-bottom: 16px;
496
+ }
497
+
498
+ .reading-head h3,
499
+ .now-title,
500
+ .player-title {
501
+ margin: 0;
502
+ font-family: "Playfair Display", serif;
503
+ }
504
+
505
+ .reading-head h3 {
506
+ font-size: 25px;
507
+ font-weight: 700;
508
+ }
509
+
510
+ .chips {
511
+ display: flex;
512
+ gap: 8px;
513
+ }
514
+
515
+ .chip,
516
+ .small-btn,
517
+ .ghost-btn {
518
+ display: inline-flex;
519
+ align-items: center;
520
+ justify-content: center;
521
+ gap: 8px;
522
+ padding: 9px 14px;
523
+ border-radius: 5px;
524
+ }
525
+
526
+ .chip.warn,
527
+ .small-btn.danger {
528
+ color: var(--leather);
529
+ }
530
+
531
+ .verse {
532
+ padding-left: 14px;
533
+ font-size: 20px;
534
+ line-height: 1.6;
535
+ }
536
+
537
+ .reading-scroll {
538
+ max-height: 460px;
539
+ overflow: auto;
540
+ padding-right: 14px;
541
+ scroll-behavior: smooth;
542
+ }
543
+
544
+ .reading-scroll p {
545
+ margin: 0 0 1rem;
546
+ }
547
+
548
+ .dropcap {
549
+ float: left;
550
+ margin: 4px 14px 0 0;
551
+ font-family: "Playfair Display", serif;
552
+ font-size: 88px;
553
+ font-weight: 800;
554
+ line-height: 0.74;
555
+ color: var(--leather);
556
+ }
557
+
558
+ .voice-panel,
559
+ .options {
560
+ padding: 20px 24px;
561
+ margin-top: 16px;
562
+ }
563
+
564
+ .seg {
565
+ display: flex;
566
+ overflow: hidden;
567
+ border: 1px solid rgba(135, 99, 39, 0.4);
568
+ border-radius: 5px;
569
+ background: #f1e4c6;
570
+ margin-bottom: 18px;
571
+ }
572
+
573
+ .seg button {
574
+ flex: 1;
575
+ border: 0;
576
+ border-right: 1px solid rgba(135, 99, 39, 0.25);
577
+ padding: 12px 8px;
578
+ color: var(--ink2);
579
+ text-transform: uppercase;
580
+ letter-spacing: 1.4px;
581
+ font-size: 13px;
582
+ font-weight: 600;
583
+ }
584
+
585
+ .seg button:last-child {
586
+ border-right: 0;
587
+ }
588
+
589
+ .seg button.active {
590
+ color: var(--leatherInk);
591
+ background: linear-gradient(180deg, var(--leather2), var(--leather));
592
+ }
593
+
594
+ .narrator-grid,
595
+ .format-grid,
596
+ .meta-grid {
597
+ display: grid;
598
+ gap: 12px;
599
+ }
600
+
601
+ .narrator-grid {
602
+ grid-template-columns: repeat(2, minmax(0, 1fr));
603
+ }
604
+
605
+ .narrator-card,
606
+ .format-card {
607
+ border: 1px solid rgba(135, 99, 39, 0.28);
608
+ border-radius: 6px;
609
+ background: #fff;
610
+ padding: 12px 14px;
611
+ cursor: pointer;
612
+ text-align: left;
613
+ }
614
+
615
+ .narrator-card.active,
616
+ .format-card.active {
617
+ border-color: var(--leather);
618
+ background: rgba(115, 48, 42, 0.07);
619
+ box-shadow: 0 0 0 1px var(--leather) inset;
620
+ }
621
+
622
+ .drop-zone {
623
+ border: 1.5px dashed rgba(135, 99, 39, 0.5);
624
+ border-radius: 5px;
625
+ padding: 24px;
626
+ background: rgba(176, 138, 68, 0.05);
627
+ }
628
+
629
+ .drop-zone input[type="file"] {
630
+ margin-top: 8px;
631
+ }
632
+
633
+ .field,
634
+ .textarea {
635
+ width: 100%;
636
+ border: 1px solid rgba(135, 99, 39, 0.35);
637
+ border-radius: 4px;
638
+ background: #fff;
639
+ color: var(--ink);
640
+ padding: 12px 14px;
641
+ }
642
+
643
+ .textarea {
644
+ min-height: 110px;
645
+ resize: vertical;
646
+ }
647
+
648
+ .dial-grid {
649
+ display: grid;
650
+ grid-template-columns: repeat(2, minmax(0, 1fr));
651
+ gap: 22px;
652
+ margin-top: 16px;
653
+ padding-top: 16px;
654
+ border-top: 1px solid rgba(135, 99, 39, 0.22);
655
+ }
656
+
657
+ .dial-head {
658
+ display: flex;
659
+ justify-content: space-between;
660
+ margin-bottom: 11px;
661
+ }
662
+
663
+ .dial-value {
664
+ color: var(--leather);
665
+ font-family: "Playfair Display", serif;
666
+ font-size: 20px;
667
+ font-weight: 700;
668
+ }
669
+
670
+ .range {
671
+ width: 100%;
672
+ accent-color: var(--leather);
673
+ }
674
+
675
+ .footer {
676
+ display: flex;
677
+ align-items: center;
678
+ gap: 20px;
679
+ margin-top: 18px;
680
+ padding-top: 18px;
681
+ border-top: 1px solid rgba(135, 99, 39, 0.3);
682
+ }
683
+
684
+ .footer-note {
685
+ color: var(--ink2);
686
+ font-size: 16px;
687
+ font-style: italic;
688
+ }
689
+
690
+ .spacer {
691
+ flex: 1;
692
+ }
693
+
694
+ .btn-ghost,
695
+ .btn-primary {
696
+ display: inline-flex;
697
+ align-items: center;
698
+ justify-content: center;
699
+ gap: 12px;
700
+ border-radius: 5px;
701
+ padding: 15px 26px;
702
+ font-family: "Playfair Display", serif;
703
+ font-size: 18px;
704
+ font-weight: 600;
705
+ cursor: pointer;
706
+ }
707
+
708
+ .btn-ghost {
709
+ background: #fff;
710
+ color: var(--ink);
711
+ border: 1px solid rgba(135, 99, 39, 0.5);
712
+ }
713
+
714
+ .btn-primary {
715
+ background: linear-gradient(180deg, var(--leather2), var(--leather) 60%, #5e241f);
716
+ color: #f6e6cc;
717
+ border: 1px solid #9c4a40;
718
+ box-shadow: 0 8px 20px rgba(115, 48, 42, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2);
719
+ }
720
+
721
+ .btn-primary:disabled,
722
+ .btn-ghost:disabled,
723
+ .small-btn:disabled {
724
+ cursor: not-allowed;
725
+ opacity: 0.5;
726
+ filter: grayscale(0.3);
727
+ }
728
+
729
+ .status-banner,
730
+ .error-banner {
731
+ margin-bottom: 16px;
732
+ padding: 12px 14px;
733
+ border-radius: 6px;
734
+ font-size: 14px;
735
+ }
736
+
737
+ .status-banner {
738
+ background: rgba(176, 138, 68, 0.1);
739
+ border: 1px solid rgba(176, 138, 68, 0.3);
740
+ color: var(--ink2);
741
+ }
742
+
743
+ .error-banner {
744
+ background: rgba(115, 48, 42, 0.08);
745
+ border: 1px solid rgba(115, 48, 42, 0.35);
746
+ color: var(--leather);
747
+ }
748
+
749
+ .progress-bar,
750
+ .mini-bar,
751
+ .scrubber {
752
+ position: relative;
753
+ overflow: hidden;
754
+ background: rgba(135, 99, 39, 0.18);
755
+ }
756
+
757
+ .progress-bar {
758
+ height: 14px;
759
+ border-radius: 8px;
760
+ border: 1px solid rgba(135, 99, 39, 0.25);
761
+ }
762
+
763
+ .mini-bar {
764
+ height: 5px;
765
+ border-radius: 3px;
766
+ margin-top: 7px;
767
+ }
768
+
769
+ .progress-fill,
770
+ .mini-fill,
771
+ .scrub-fill {
772
+ position: absolute;
773
+ inset: 0 auto 0 0;
774
+ background: linear-gradient(90deg, var(--brassDeep), var(--brass2));
775
+ }
776
+
777
+ .progress-fill::after {
778
+ content: "";
779
+ position: absolute;
780
+ inset: 0;
781
+ background: repeating-linear-gradient(115deg, rgba(255, 255, 255, 0.18) 0 10px, transparent 10px 22px);
782
+ animation: stripe 1s linear infinite;
783
+ }
784
+
785
+ .status-dot {
786
+ width: 11px;
787
+ height: 11px;
788
+ border-radius: 50%;
789
+ background: var(--leather);
790
+ display: inline-block;
791
+ box-shadow: 0 0 0 0 rgba(115, 48, 42, 0.5);
792
+ animation: pulse 1.8s ease-out infinite;
793
+ }
794
+
795
+ .queue-status {
796
+ width: 26px;
797
+ height: 26px;
798
+ border-radius: 50%;
799
+ display: grid;
800
+ place-items: center;
801
+ font-size: 13px;
802
+ }
803
+
804
+ .queue-status.done {
805
+ background: linear-gradient(150deg, #4e6b52, #3c5240);
806
+ color: #eaf3ea;
807
+ }
808
+
809
+ .queue-status.rendering {
810
+ background: var(--leather);
811
+ color: white;
812
+ }
813
+
814
+ .queue-status.queued {
815
+ border: 1.5px solid rgba(135, 99, 39, 0.4);
816
+ color: var(--faint);
817
+ }
818
+
819
+ .queue-status.skipped {
820
+ border: 1.5px dashed rgba(138, 121, 92, 0.5);
821
+ color: var(--faint);
822
+ }
823
+
824
+ .stats-hero {
825
+ display: flex;
826
+ justify-content: space-between;
827
+ gap: 20px;
828
+ margin-top: 9px;
829
+ color: var(--ink2);
830
+ font-size: 14px;
831
+ }
832
+
833
+ .hero-stats {
834
+ padding-left: 24px;
835
+ border-left: 1px solid rgba(135, 99, 39, 0.24);
836
+ text-align: right;
837
+ }
838
+
839
+ .pct {
840
+ font-family: "Playfair Display", serif;
841
+ font-size: 52px;
842
+ line-height: 0.85;
843
+ color: var(--leather);
844
+ font-weight: 800;
845
+ }
846
+
847
+ .wave {
848
+ display: flex;
849
+ align-items: center;
850
+ justify-content: center;
851
+ gap: 3px;
852
+ height: 72px;
853
+ margin: 4px 0 16px;
854
+ }
855
+
856
+ .wave i {
857
+ width: 5px;
858
+ border-radius: 3px;
859
+ background: linear-gradient(180deg, var(--brass2), var(--leather));
860
+ animation: wave 1.1s ease-in-out infinite;
861
+ }
862
+
863
+ .spoken {
864
+ border: 1px solid rgba(135, 99, 39, 0.22);
865
+ border-radius: 5px;
866
+ background: #fff;
867
+ padding: 16px 18px;
868
+ font-size: 19px;
869
+ line-height: 1.55;
870
+ font-style: italic;
871
+ }
872
+
873
+ .spoken .lit {
874
+ color: var(--ink);
875
+ font-style: normal;
876
+ background: linear-gradient(180deg, transparent 58%, rgba(202, 167, 94, 0.55) 58%);
877
+ }
878
+
879
+ .spoken .dim {
880
+ color: var(--faint);
881
+ }
882
+
883
+ .log {
884
+ overflow: hidden;
885
+ }
886
+
887
+ .log-row {
888
+ display: flex;
889
+ gap: 14px;
890
+ align-items: baseline;
891
+ padding: 10px 16px;
892
+ border-bottom: 1px solid rgba(135, 99, 39, 0.12);
893
+ }
894
+
895
+ .log-row:last-child {
896
+ border-bottom: 0;
897
+ }
898
+
899
+ .log-row.current {
900
+ color: var(--leather);
901
+ }
902
+
903
+ .time {
904
+ color: var(--brassDeep);
905
+ font-variant-numeric: tabular-nums;
906
+ }
907
+
908
+ .spec-strip {
909
+ display: flex;
910
+ flex-wrap: wrap;
911
+ width: fit-content;
912
+ margin-top: 14px;
913
+ border-radius: 6px;
914
+ overflow: hidden;
915
+ border: 1px solid rgba(135, 99, 39, 0.22);
916
+ background: #fffaf0;
917
+ }
918
+
919
+ .spec {
920
+ padding: 10px 18px;
921
+ border-right: 1px solid rgba(135, 99, 39, 0.18);
922
+ }
923
+
924
+ .spec:last-child {
925
+ border-right: 0;
926
+ }
927
+
928
+ .spec-value {
929
+ font-family: "Playfair Display", serif;
930
+ font-size: 19px;
931
+ font-weight: 700;
932
+ }
933
+
934
+ .scrubber {
935
+ height: 8px;
936
+ border-radius: 5px;
937
+ margin: 6px 0;
938
+ }
939
+
940
+ .scrub-fill {
941
+ background: linear-gradient(90deg, var(--leather2), var(--leather));
942
+ }
943
+
944
+ .scrub-head,
945
+ .scrub-tick {
946
+ position: absolute;
947
+ }
948
+
949
+ .scrub-tick {
950
+ top: -2px;
951
+ width: 2px;
952
+ height: 12px;
953
+ background: rgba(135, 99, 39, 0.5);
954
+ }
955
+
956
+ .scrub-head {
957
+ top: 50%;
958
+ width: 16px;
959
+ height: 16px;
960
+ border-radius: 50%;
961
+ transform: translate(-50%, -50%);
962
+ background: radial-gradient(circle at 35% 30%, #fff, var(--leather));
963
+ border: 1px solid #fff;
964
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
965
+ }
966
+
967
+ .transport {
968
+ display: flex;
969
+ justify-content: center;
970
+ gap: 18px;
971
+ margin-top: 10px;
972
+ }
973
+
974
+ .transport-btn {
975
+ width: 46px;
976
+ height: 46px;
977
+ }
978
+
979
+ .transport-btn.main {
980
+ width: 64px;
981
+ height: 64px;
982
+ color: #f6e6cc;
983
+ border: 1px solid #9c4a40;
984
+ background: linear-gradient(180deg, var(--leather2), var(--leather) 60%, #5e241f);
985
+ box-shadow: 0 8px 18px rgba(115, 48, 42, 0.4);
986
+ }
987
+
988
+ .meta-grid {
989
+ grid-template-columns: repeat(2, minmax(0, 1fr));
990
+ }
991
+
992
+ .toggle {
993
+ width: 42px;
994
+ height: 24px;
995
+ border-radius: 13px;
996
+ position: relative;
997
+ background: linear-gradient(180deg, var(--leather2), var(--leather));
998
+ }
999
+
1000
+ .toggle::after {
1001
+ content: "";
1002
+ position: absolute;
1003
+ top: 2px;
1004
+ left: 20px;
1005
+ width: 20px;
1006
+ height: 20px;
1007
+ border-radius: 50%;
1008
+ background: white;
1009
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
1010
+ }
1011
+
1012
+ .hide {
1013
+ display: none !important;
1014
+ }
1015
+
1016
+ @keyframes pulse {
1017
+ 0% { box-shadow: 0 0 0 0 rgba(115, 48, 42, 0.5); }
1018
+ 70% { box-shadow: 0 0 0 9px rgba(115, 48, 42, 0); }
1019
+ 100% { box-shadow: 0 0 0 0 rgba(115, 48, 42, 0); }
1020
+ }
1021
+
1022
+ @keyframes stripe {
1023
+ to { background-position: 22px 0; }
1024
+ }
1025
+
1026
+ @keyframes wave {
1027
+ 0%, 100% { transform: scaleY(0.32); }
1028
+ 50% { transform: scaleY(1); }
1029
+ }
1030
+
1031
+ @media (max-width: 1080px) {
1032
+ .page {
1033
+ padding: 26px 24px 22px;
1034
+ }
1035
+
1036
+ .mast,
1037
+ .hero,
1038
+ .footer {
1039
+ flex-direction: column;
1040
+ align-items: flex-start;
1041
+ }
1042
+
1043
+ .cols,
1044
+ .dial-grid,
1045
+ .meta-grid,
1046
+ .narrator-grid {
1047
+ grid-template-columns: 1fr;
1048
+ }
1049
+
1050
+ .hero-tally,
1051
+ .hero-stats {
1052
+ text-align: left;
1053
+ padding-left: 0;
1054
+ border-left: 0;
1055
+ }
1056
+ }
pytest.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [pytest]
2
+ pythonpath = .
requirements-dev.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ ebooklib>=0.18
2
+ beautifulsoup4>=4.12.0
3
+ mutagen>=1.47.0
4
+ soundfile>=0.13.0
5
+ numpy>=1.26.0
6
+ pytest>=8.3.0
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=5.31.0
2
+ fastapi>=0.115.0
3
+ ebooklib>=0.18
4
+ beautifulsoup4>=4.12.0
5
+ mutagen>=1.47.0
6
+ soundfile>=0.13.0
7
+ numpy>=1.26.0
8
+ torch>=2.8.0
9
+ torchaudio>=2.8.0
10
+ omnivoice>=0.1.5
tests/test_epub.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+ from ebooklib import epub
5
+
6
+ from backend.epub import EpubConfig, parse_epub
7
+
8
+
9
+ def _write_sample_epub(target: Path, chapter_count: int = 2) -> Path:
10
+ book = epub.EpubBook()
11
+ book.set_identifier("sample-id")
12
+ book.set_title("Sample Book")
13
+ book.set_language("en")
14
+ book.add_author("Example Author")
15
+
16
+ chapters = []
17
+ for idx in range(chapter_count):
18
+ chapter = epub.EpubHtml(
19
+ title=f"Chapter {idx + 1}",
20
+ file_name=f"chap_{idx + 1}.xhtml",
21
+ lang="en",
22
+ )
23
+ chapter.content = (
24
+ f"<h1>Chapter {idx + 1}</h1>"
25
+ f"<p>This is chapter {idx + 1}. It has enough words to estimate audio.</p>"
26
+ )
27
+ book.add_item(chapter)
28
+ chapters.append(chapter)
29
+
30
+ book.toc = tuple(chapters)
31
+ book.spine = ["nav", *chapters]
32
+ book.add_item(epub.EpubNcx())
33
+ book.add_item(epub.EpubNav())
34
+ epub.write_epub(str(target), book)
35
+ return target
36
+
37
+
38
+ def test_parse_epub_extracts_metadata_and_chapters(tmp_path: Path) -> None:
39
+ epub_path = _write_sample_epub(tmp_path / "sample.epub", chapter_count=3)
40
+
41
+ payload = parse_epub(epub_path, config=EpubConfig(max_file_bytes=2_000_000))
42
+
43
+ assert payload["title"] == "Sample Book"
44
+ assert payload["author"] == "Example Author"
45
+ assert len(payload["chapters"]) == 3
46
+ assert payload["chapters"][0]["title"] == "Chapter 1"
47
+ assert payload["chapters"][0]["included"] is True
48
+ assert payload["chapters"][0]["text"]
49
+
50
+
51
+ def test_parse_epub_rejects_oversized_upload(tmp_path: Path) -> None:
52
+ epub_path = _write_sample_epub(tmp_path / "sample.epub", chapter_count=1)
53
+
54
+ with pytest.raises(ValueError, match="too large"):
55
+ parse_epub(epub_path, config=EpubConfig(max_file_bytes=32))
56
+
57
+
58
+ def test_parse_epub_rejects_malformed_file(tmp_path: Path) -> None:
59
+ bad_path = tmp_path / "bad.epub"
60
+ bad_path.write_text("not really an epub", encoding="utf-8")
61
+
62
+ with pytest.raises(ValueError, match="Invalid EPUB"):
63
+ parse_epub(bad_path, config=EpubConfig(max_file_bytes=2_000_000))
64
+
65
+
66
+ def test_parse_epub_accepts_valid_epub_without_epub_suffix(tmp_path: Path) -> None:
67
+ epub_path = _write_sample_epub(tmp_path / "sample.epub", chapter_count=1)
68
+ no_suffix_path = tmp_path / "upload-cache-file"
69
+ no_suffix_path.write_bytes(epub_path.read_bytes())
70
+
71
+ payload = parse_epub(no_suffix_path, config=EpubConfig(max_file_bytes=2_000_000))
72
+
73
+ assert payload["title"] == "Sample Book"
74
+ assert len(payload["chapters"]) == 1
tests/test_input_files.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from backend.input_files import resolve_uploaded_name, resolve_uploaded_path
6
+
7
+
8
+ def test_resolve_uploaded_path_accepts_string_path(tmp_path: Path) -> None:
9
+ path = tmp_path / "book.epub"
10
+ path.write_text("x", encoding="utf-8")
11
+
12
+ resolved = resolve_uploaded_path(str(path))
13
+
14
+ assert resolved == path
15
+
16
+
17
+ def test_resolve_uploaded_path_accepts_gradio_dict_payload(tmp_path: Path) -> None:
18
+ path = tmp_path / "book.epub"
19
+ path.write_text("x", encoding="utf-8")
20
+
21
+ resolved = resolve_uploaded_path({"path": str(path), "meta": {"_type": "gradio.FileData"}})
22
+
23
+ assert resolved == path
24
+
25
+
26
+ def test_resolve_uploaded_path_rejects_missing_path_key() -> None:
27
+ with pytest.raises(TypeError, match="Unsupported uploaded file payload"):
28
+ resolve_uploaded_path({"url": "/tmp/book.epub"})
29
+
30
+
31
+ def test_resolve_uploaded_name_prefers_original_filename() -> None:
32
+ name = resolve_uploaded_name(
33
+ {"path": "/tmp/gradio/abcd", "orig_name": "real-book.epub", "meta": {"_type": "gradio.FileData"}}
34
+ )
35
+
36
+ assert name == "real-book.epub"
37
+
38
+
39
+ def test_resolve_uploaded_name_falls_back_to_path_name(tmp_path: Path) -> None:
40
+ path = tmp_path / "fallback.epub"
41
+ path.write_text("x", encoding="utf-8")
42
+
43
+ name = resolve_uploaded_name(str(path))
44
+
45
+ assert name == "fallback.epub"
tests/test_render_pipeline.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from backend.render_pipeline import RenderPipeline
4
+
5
+
6
+ class FakeSynthesizer:
7
+ def __init__(self) -> None:
8
+ self.calls = []
9
+
10
+ def synthesize(self, *, text: str, output_path: Path, **kwargs) -> dict:
11
+ self.calls.append({"text": text, "output_path": output_path, **kwargs})
12
+ output_path.write_bytes(b"RIFFfakewave")
13
+ return {"duration_seconds": max(1, len(text.split()) // 2), "sample_rate": 24000}
14
+
15
+
16
+ def _chapters() -> list:
17
+ return [
18
+ {"id": "c1", "title": "One", "text": "hello world " * 20, "included": True, "est_minutes": 1},
19
+ {"id": "c2", "title": "Two", "text": "next chapter " * 20, "included": True, "est_minutes": 1},
20
+ ]
21
+
22
+
23
+ def test_render_pipeline_streams_ordered_progress_events(tmp_path: Path) -> None:
24
+ synthesizer = FakeSynthesizer()
25
+ pipeline = RenderPipeline(session_root=tmp_path, synthesizer=synthesizer)
26
+
27
+ events = list(
28
+ pipeline.render(
29
+ session_id="session-a",
30
+ book={"title": "Book"},
31
+ chapters=_chapters(),
32
+ voice_config={"mode": "auto"},
33
+ diffusion_steps=32,
34
+ speed=1.0,
35
+ )
36
+ )
37
+
38
+ event_types = [event["type"] for event in events]
39
+ assert event_types[0] == "started"
40
+ assert "chapter_started" in event_types
41
+ assert "chapter_done" in event_types
42
+ assert event_types[-1] == "completed"
43
+ assert len(synthesizer.calls) == 2
44
+
45
+
46
+ def test_render_pipeline_can_be_cancelled(tmp_path: Path) -> None:
47
+ synthesizer = FakeSynthesizer()
48
+ pipeline = RenderPipeline(session_root=tmp_path, synthesizer=synthesizer)
49
+ iterator = pipeline.render(
50
+ session_id="session-a",
51
+ book={"title": "Book"},
52
+ chapters=_chapters(),
53
+ voice_config={"mode": "auto"},
54
+ diffusion_steps=32,
55
+ speed=1.0,
56
+ )
57
+
58
+ first = next(iterator)
59
+ pipeline.cancel("session-a")
60
+ remaining = list(iterator)
61
+
62
+ assert first["type"] == "started"
63
+ assert remaining[-1]["type"] == "cancelled"
tests/test_session_store.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from backend.session_store import SessionStore
4
+
5
+
6
+ def test_session_store_creates_isolated_session_dirs(tmp_path: Path) -> None:
7
+ store = SessionStore(root=tmp_path, ttl_seconds=60)
8
+
9
+ first = store.ensure_session("session-a")
10
+ second = store.ensure_session("session-b")
11
+
12
+ assert first.session_id == "session-a"
13
+ assert second.session_id == "session-b"
14
+ assert first.root.exists()
15
+ assert second.root.exists()
16
+ assert first.root != second.root
17
+
18
+
19
+ def test_session_store_prunes_expired_sessions(tmp_path: Path) -> None:
20
+ store = SessionStore(root=tmp_path, ttl_seconds=0)
21
+ session = store.ensure_session("session-a")
22
+ marker = session.root / "marker.txt"
23
+ marker.write_text("old", encoding="utf-8")
24
+
25
+ removed = store.cleanup_expired(now=store._now() + 1)
26
+
27
+ assert removed == ["session-a"]
28
+ assert not session.root.exists()