Add Kitten TTS and resumable jobs
Browse filesAdd KittenML kitten-tts-micro-0.8 support, improve markdown cleaning, add resume-on-error with partial MP3 handling, and fix final MP3 merging for multi-chunk jobs.
- .env.example +4 -0
- Dockerfile +11 -1
- README.md +4 -0
- app/api/jobs.py +74 -21
- app/config.py +5 -1
- app/main.py +6 -1
- app/schemas_jobs.py +3 -0
- app/services/file_storage.py +35 -6
- app/services/job_runner.py +82 -9
- app/services/job_store.py +19 -1
- app/services/kitten_engine.py +132 -0
- app/services/mp3_merger.py +27 -4
- app/services/text_chunker.py +9 -3
- app/services/text_cleaner.py +14 -4
- app/templates/index.html +14 -0
- app/templates/job.html +3 -2
- requirements.txt +1 -0
- tests/test_job_store.py +3 -0
- tests/test_models.py +21 -3
- tests/test_schemas_jobs.py +21 -3
- tests/test_speech_response.py +35 -9
- tests/test_text_chunker.py +8 -0
- tests/test_text_cleaner.py +10 -0
.env.example
CHANGED
|
@@ -2,7 +2,11 @@ HOST=0.0.0.0
|
|
| 2 |
PORT=7860
|
| 3 |
MODEL_DIR=/app/models/supertonic-2
|
| 4 |
SUPERTONIC_CACHE_DIR=/app/models/supertonic-2
|
|
|
|
|
|
|
| 5 |
KOKORO_MODEL_DIR=/app/models/kokoro
|
|
|
|
|
|
|
| 6 |
WORKSPACE_DIR=/tmp/tts-server
|
| 7 |
JOB_DB_PATH=/tmp/tts-server/jobs.db
|
| 8 |
PERSISTENT_OUTPUT_DIR=/data
|
|
|
|
| 2 |
PORT=7860
|
| 3 |
MODEL_DIR=/app/models/supertonic-2
|
| 4 |
SUPERTONIC_CACHE_DIR=/app/models/supertonic-2
|
| 5 |
+
KITTEN_MODEL_NAME=KittenML/kitten-tts-micro-0.8
|
| 6 |
+
KITTEN_CACHE_DIR=/app/models/hf-cache
|
| 7 |
KOKORO_MODEL_DIR=/app/models/kokoro
|
| 8 |
+
HF_HOME=/app/models/hf-cache
|
| 9 |
+
HUGGINGFACE_HUB_CACHE=/app/models/hf-cache
|
| 10 |
WORKSPACE_DIR=/tmp/tts-server
|
| 11 |
JOB_DB_PATH=/tmp/tts-server/jobs.db
|
| 12 |
PERSISTENT_OUTPUT_DIR=/data
|
Dockerfile
CHANGED
|
@@ -7,7 +7,11 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
| 7 |
PORT=7860 \
|
| 8 |
MODEL_DIR=/app/models/supertonic-2 \
|
| 9 |
SUPERTONIC_CACHE_DIR=/app/models/supertonic-2 \
|
|
|
|
|
|
|
| 10 |
KOKORO_MODEL_DIR=/app/models/kokoro \
|
|
|
|
|
|
|
| 11 |
WORKSPACE_DIR=/tmp/tts-server \
|
| 12 |
JOB_DB_PATH=/tmp/tts-server/jobs.db \
|
| 13 |
PERSISTENT_OUTPUT_DIR=/data \
|
|
@@ -29,7 +33,7 @@ RUN pip install -r requirements.txt
|
|
| 29 |
|
| 30 |
COPY --chown=user:user . /app
|
| 31 |
|
| 32 |
-
RUN mkdir -p /app/models/supertonic-2 /app/models/kokoro /tmp/tts-server /data && \
|
| 33 |
chown -R user:user /tmp/tts-server /data && \
|
| 34 |
python -c "from supertonic import TTS; tts = TTS(model_dir='/app/models/supertonic-2', auto_download=True); print(f'Preloaded model at {tts.model_dir}')"
|
| 35 |
|
|
@@ -44,6 +48,12 @@ snapshot_download(
|
|
| 44 |
print('Preloaded Kokoro assets into /app/models/kokoro')
|
| 45 |
PY
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
USER user
|
| 48 |
|
| 49 |
EXPOSE 7860
|
|
|
|
| 7 |
PORT=7860 \
|
| 8 |
MODEL_DIR=/app/models/supertonic-2 \
|
| 9 |
SUPERTONIC_CACHE_DIR=/app/models/supertonic-2 \
|
| 10 |
+
KITTEN_MODEL_NAME=KittenML/kitten-tts-micro-0.8 \
|
| 11 |
+
KITTEN_CACHE_DIR=/app/models/hf-cache \
|
| 12 |
KOKORO_MODEL_DIR=/app/models/kokoro \
|
| 13 |
+
HF_HOME=/app/models/hf-cache \
|
| 14 |
+
HUGGINGFACE_HUB_CACHE=/app/models/hf-cache \
|
| 15 |
WORKSPACE_DIR=/tmp/tts-server \
|
| 16 |
JOB_DB_PATH=/tmp/tts-server/jobs.db \
|
| 17 |
PERSISTENT_OUTPUT_DIR=/data \
|
|
|
|
| 33 |
|
| 34 |
COPY --chown=user:user . /app
|
| 35 |
|
| 36 |
+
RUN mkdir -p /app/models/supertonic-2 /app/models/kokoro /app/models/hf-cache /tmp/tts-server /data && \
|
| 37 |
chown -R user:user /tmp/tts-server /data && \
|
| 38 |
python -c "from supertonic import TTS; tts = TTS(model_dir='/app/models/supertonic-2', auto_download=True); print(f'Preloaded model at {tts.model_dir}')"
|
| 39 |
|
|
|
|
| 48 |
print('Preloaded Kokoro assets into /app/models/kokoro')
|
| 49 |
PY
|
| 50 |
|
| 51 |
+
RUN python - <<'PY'
|
| 52 |
+
from kittentts import KittenTTS
|
| 53 |
+
KittenTTS(model_name='KittenML/kitten-tts-micro-0.8', cache_dir='/app/models/hf-cache', backend='cpu')
|
| 54 |
+
print('Preloaded Kitten TTS assets into /app/models/hf-cache')
|
| 55 |
+
PY
|
| 56 |
+
|
| 57 |
USER user
|
| 58 |
|
| 59 |
EXPOSE 7860
|
README.md
CHANGED
|
@@ -16,6 +16,7 @@ This directory is a Hugging Face Docker Space variant of the project.
|
|
| 16 |
- Writes only final merged MP3 files to `/data`
|
| 17 |
- Deletes temporary source, normalized, and chunk files after a successful merge
|
| 18 |
- Keeps auth disabled by default so the public Space UI works immediately
|
|
|
|
| 19 |
|
| 20 |
## Persistent storage behavior
|
| 21 |
|
|
@@ -33,6 +34,8 @@ Most important overrides:
|
|
| 33 |
- `API_KEY=...` when auth is enabled
|
| 34 |
- `PERSISTENT_OUTPUT_DIR=/data`
|
| 35 |
- `WORKSPACE_DIR=/tmp/tts-server`
|
|
|
|
|
|
|
| 36 |
|
| 37 |
## Local run
|
| 38 |
|
|
@@ -55,5 +58,6 @@ Then open `http://localhost:7860`.
|
|
| 55 |
## Notes
|
| 56 |
|
| 57 |
- Build-time model downloads stay inside the image under `/app/models`
|
|
|
|
| 58 |
- Runtime persistent storage is only used for final MP3s
|
| 59 |
- ffmpeg is required for MP3 encoding and merging
|
|
|
|
| 16 |
- Writes only final merged MP3 files to `/data`
|
| 17 |
- Deletes temporary source, normalized, and chunk files after a successful merge
|
| 18 |
- Keeps auth disabled by default so the public Space UI works immediately
|
| 19 |
+
- Includes Kitten TTS alongside Supertonic and Kokoro, all running CPU-only
|
| 20 |
|
| 21 |
## Persistent storage behavior
|
| 22 |
|
|
|
|
| 34 |
- `API_KEY=...` when auth is enabled
|
| 35 |
- `PERSISTENT_OUTPUT_DIR=/data`
|
| 36 |
- `WORKSPACE_DIR=/tmp/tts-server`
|
| 37 |
+
- `KITTEN_MODEL_NAME=KittenML/kitten-tts-micro-0.8`
|
| 38 |
+
- `KITTEN_CACHE_DIR=/app/models/hf-cache`
|
| 39 |
|
| 40 |
## Local run
|
| 41 |
|
|
|
|
| 58 |
## Notes
|
| 59 |
|
| 60 |
- Build-time model downloads stay inside the image under `/app/models`
|
| 61 |
+
- Kitten TTS caches Hugging Face assets under `/app/models/hf-cache`
|
| 62 |
- Runtime persistent storage is only used for final MP3s
|
| 63 |
- ffmpeg is required for MP3 encoding and merging
|
app/api/jobs.py
CHANGED
|
@@ -9,14 +9,22 @@ from app.config import Settings, get_settings
|
|
| 9 |
from app.schemas_jobs import JobListResponse, JobResponse
|
| 10 |
from app.services.engine_registry import get_engine_registry
|
| 11 |
from app.services.file_storage import JobStorage
|
| 12 |
-
from app.services.job_store import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
| 15 |
|
| 16 |
|
| 17 |
def _job_to_response(job) -> JobResponse:
|
| 18 |
download_url = None
|
| 19 |
-
|
|
|
|
| 20 |
download_url = f"/jobs/{job.id}/download"
|
| 21 |
return JobResponse(
|
| 22 |
id=job.id,
|
|
@@ -27,23 +35,28 @@ def _job_to_response(job) -> JobResponse:
|
|
| 27 |
voice=job.voice,
|
| 28 |
speed=job.speed,
|
| 29 |
quality=job.quality,
|
|
|
|
| 30 |
total_chunks=job.total_chunks,
|
| 31 |
completed_chunks=job.completed_chunks,
|
| 32 |
progress_percent=job.progress_percent,
|
| 33 |
status_message=job.status_message,
|
| 34 |
error_message=job.error_message,
|
|
|
|
| 35 |
created_at=job.created_at,
|
| 36 |
updated_at=job.updated_at,
|
| 37 |
download_url=download_url,
|
| 38 |
)
|
| 39 |
|
| 40 |
|
| 41 |
-
@router.post(
|
|
|
|
|
|
|
| 42 |
async def create_job_route(
|
| 43 |
model: str = Form(...),
|
| 44 |
voice: str = Form(...),
|
| 45 |
speed: float = Form(1.0, ge=0.25, le=4.0),
|
| 46 |
quality: str = Form("balanced"),
|
|
|
|
| 47 |
title: str | None = Form(None),
|
| 48 |
file: UploadFile | None = File(default=None),
|
| 49 |
text: str | None = Form(default=None),
|
|
@@ -57,16 +70,22 @@ async def create_job_route(
|
|
| 57 |
raise HTTPException(status_code=400, detail=f"Unknown model: {model}")
|
| 58 |
|
| 59 |
if quality not in {"low", "balanced", "high"}:
|
| 60 |
-
raise HTTPException(
|
|
|
|
|
|
|
| 61 |
|
| 62 |
if hasattr(engine, "supports_voice") and not engine.supports_voice(voice):
|
| 63 |
-
raise HTTPException(
|
|
|
|
|
|
|
| 64 |
|
| 65 |
job_id = f"job_{uuid.uuid4().hex[:12]}"
|
| 66 |
has_file = file is not None and bool(file.filename)
|
| 67 |
has_text = bool(text and text.strip())
|
| 68 |
if not has_file and not has_text:
|
| 69 |
-
raise HTTPException(
|
|
|
|
|
|
|
| 70 |
|
| 71 |
filename = None
|
| 72 |
content_bytes = None
|
|
@@ -74,7 +93,9 @@ async def create_job_route(
|
|
| 74 |
filename = file.filename # type: ignore[assignment]
|
| 75 |
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
| 76 |
if ext not in {"txt", "md"}:
|
| 77 |
-
raise HTTPException(
|
|
|
|
|
|
|
| 78 |
content_bytes = await file.read()
|
| 79 |
|
| 80 |
storage = JobStorage()
|
|
@@ -82,36 +103,47 @@ async def create_job_route(
|
|
| 82 |
source_path = None
|
| 83 |
source_filename_val = filename # default to uploaded filename
|
| 84 |
if content_bytes is not None:
|
| 85 |
-
source_path = str(
|
|
|
|
|
|
|
| 86 |
elif text and text.strip():
|
| 87 |
# Pasted text — save as a file so the runner can load it
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
| 91 |
|
| 92 |
title_value = title or (Path(filename).stem if filename else f"Job {job_id}")
|
| 93 |
job = create_job(
|
| 94 |
id=job_id,
|
| 95 |
title=title_value,
|
| 96 |
source_filename=source_filename_val,
|
| 97 |
-
source_type="md"
|
|
|
|
|
|
|
| 98 |
model=model,
|
| 99 |
voice=voice,
|
| 100 |
speed=speed,
|
| 101 |
quality=quality,
|
|
|
|
| 102 |
source_path=source_path,
|
| 103 |
output_dir=str(storage.get_job_dir(job_id)),
|
| 104 |
)
|
| 105 |
return _job_to_response(job)
|
| 106 |
|
| 107 |
|
| 108 |
-
@router.get(
|
|
|
|
|
|
|
| 109 |
async def list_jobs_route(limit: int = 20) -> JobListResponse:
|
| 110 |
jobs = list_jobs(limit=limit)
|
| 111 |
return JobListResponse(jobs=[_job_to_response(j) for j in jobs], total=len(jobs))
|
| 112 |
|
| 113 |
|
| 114 |
-
@router.get(
|
|
|
|
|
|
|
| 115 |
async def get_job_route(job_id: str) -> JobResponse:
|
| 116 |
job = get_job(job_id)
|
| 117 |
if job is None:
|
|
@@ -119,24 +151,47 @@ async def get_job_route(job_id: str) -> JobResponse:
|
|
| 119 |
return _job_to_response(job)
|
| 120 |
|
| 121 |
|
| 122 |
-
@router.post(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
async def retry_job_route(job_id: str) -> JobResponse:
|
| 124 |
job = get_job(job_id)
|
| 125 |
if job is None:
|
| 126 |
raise HTTPException(status_code=404, detail="Job not found")
|
| 127 |
if job.status not in ("failed",):
|
| 128 |
raise HTTPException(status_code=400, detail="Can only retry failed jobs")
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
|
| 132 |
-
@router.post(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
async def cancel_job_route(job_id: str) -> JobResponse:
|
| 134 |
job = get_job(job_id)
|
| 135 |
if job is None:
|
| 136 |
raise HTTPException(status_code=404, detail="Job not found")
|
| 137 |
if job.status in ("completed", "failed"):
|
| 138 |
-
raise HTTPException(
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
|
| 142 |
@router.delete("/{job_id}", dependencies=[Depends(require_bearer_auth)])
|
|
@@ -154,8 +209,6 @@ async def download_job_route(job_id: str) -> FileResponse:
|
|
| 154 |
job = get_job(job_id)
|
| 155 |
if job is None:
|
| 156 |
raise HTTPException(status_code=404, detail="Job not found")
|
| 157 |
-
if job.status != "completed":
|
| 158 |
-
raise HTTPException(status_code=400, detail="Job is not complete")
|
| 159 |
if not job.final_mp3_path:
|
| 160 |
raise HTTPException(status_code=404, detail="Output file not found")
|
| 161 |
|
|
|
|
| 9 |
from app.schemas_jobs import JobListResponse, JobResponse
|
| 10 |
from app.services.engine_registry import get_engine_registry
|
| 11 |
from app.services.file_storage import JobStorage
|
| 12 |
+
from app.services.job_store import (
|
| 13 |
+
create_job,
|
| 14 |
+
delete_job,
|
| 15 |
+
get_job,
|
| 16 |
+
list_jobs,
|
| 17 |
+
update_job,
|
| 18 |
+
)
|
| 19 |
+
from app.services.text_cleaner import detect_type
|
| 20 |
|
| 21 |
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
| 22 |
|
| 23 |
|
| 24 |
def _job_to_response(job) -> JobResponse:
|
| 25 |
download_url = None
|
| 26 |
+
partial_output_available = bool(job.final_mp3_path and job.status == "failed")
|
| 27 |
+
if job.final_mp3_path:
|
| 28 |
download_url = f"/jobs/{job.id}/download"
|
| 29 |
return JobResponse(
|
| 30 |
id=job.id,
|
|
|
|
| 35 |
voice=job.voice,
|
| 36 |
speed=job.speed,
|
| 37 |
quality=job.quality,
|
| 38 |
+
resume_on_error=job.resume_on_error,
|
| 39 |
total_chunks=job.total_chunks,
|
| 40 |
completed_chunks=job.completed_chunks,
|
| 41 |
progress_percent=job.progress_percent,
|
| 42 |
status_message=job.status_message,
|
| 43 |
error_message=job.error_message,
|
| 44 |
+
partial_output_available=partial_output_available,
|
| 45 |
created_at=job.created_at,
|
| 46 |
updated_at=job.updated_at,
|
| 47 |
download_url=download_url,
|
| 48 |
)
|
| 49 |
|
| 50 |
|
| 51 |
+
@router.post(
|
| 52 |
+
"", response_model=JobResponse, dependencies=[Depends(require_bearer_auth)]
|
| 53 |
+
)
|
| 54 |
async def create_job_route(
|
| 55 |
model: str = Form(...),
|
| 56 |
voice: str = Form(...),
|
| 57 |
speed: float = Form(1.0, ge=0.25, le=4.0),
|
| 58 |
quality: str = Form("balanced"),
|
| 59 |
+
resume_on_error: bool = Form(False),
|
| 60 |
title: str | None = Form(None),
|
| 61 |
file: UploadFile | None = File(default=None),
|
| 62 |
text: str | None = Form(default=None),
|
|
|
|
| 70 |
raise HTTPException(status_code=400, detail=f"Unknown model: {model}")
|
| 71 |
|
| 72 |
if quality not in {"low", "balanced", "high"}:
|
| 73 |
+
raise HTTPException(
|
| 74 |
+
status_code=400, detail="quality must be low, balanced, or high"
|
| 75 |
+
)
|
| 76 |
|
| 77 |
if hasattr(engine, "supports_voice") and not engine.supports_voice(voice):
|
| 78 |
+
raise HTTPException(
|
| 79 |
+
status_code=400, detail=f"Voice '{voice}' not available for model '{model}'"
|
| 80 |
+
)
|
| 81 |
|
| 82 |
job_id = f"job_{uuid.uuid4().hex[:12]}"
|
| 83 |
has_file = file is not None and bool(file.filename)
|
| 84 |
has_text = bool(text and text.strip())
|
| 85 |
if not has_file and not has_text:
|
| 86 |
+
raise HTTPException(
|
| 87 |
+
status_code=400, detail="Either a file or text field is required"
|
| 88 |
+
)
|
| 89 |
|
| 90 |
filename = None
|
| 91 |
content_bytes = None
|
|
|
|
| 93 |
filename = file.filename # type: ignore[assignment]
|
| 94 |
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
| 95 |
if ext not in {"txt", "md"}:
|
| 96 |
+
raise HTTPException(
|
| 97 |
+
status_code=400, detail="Only .txt and .md files are supported"
|
| 98 |
+
)
|
| 99 |
content_bytes = await file.read()
|
| 100 |
|
| 101 |
storage = JobStorage()
|
|
|
|
| 103 |
source_path = None
|
| 104 |
source_filename_val = filename # default to uploaded filename
|
| 105 |
if content_bytes is not None:
|
| 106 |
+
source_path = str(
|
| 107 |
+
storage.save_upload(content_bytes, filename or "upload.txt", job_id)
|
| 108 |
+
)
|
| 109 |
elif text and text.strip():
|
| 110 |
# Pasted text — save as a file so the runner can load it
|
| 111 |
+
normalized_text = text.strip()
|
| 112 |
+
text_bytes = normalized_text.encode("utf-8")
|
| 113 |
+
detected_source_type = detect_type(normalized_text)
|
| 114 |
+
source_filename_val = f"pasted.{detected_source_type}"
|
| 115 |
+
source_path = str(storage.save_upload(text_bytes, source_filename_val, job_id))
|
| 116 |
|
| 117 |
title_value = title or (Path(filename).stem if filename else f"Job {job_id}")
|
| 118 |
job = create_job(
|
| 119 |
id=job_id,
|
| 120 |
title=title_value,
|
| 121 |
source_filename=source_filename_val,
|
| 122 |
+
source_type="md"
|
| 123 |
+
if (source_filename_val and source_filename_val.endswith(".md"))
|
| 124 |
+
else "txt",
|
| 125 |
model=model,
|
| 126 |
voice=voice,
|
| 127 |
speed=speed,
|
| 128 |
quality=quality,
|
| 129 |
+
resume_on_error=resume_on_error,
|
| 130 |
source_path=source_path,
|
| 131 |
output_dir=str(storage.get_job_dir(job_id)),
|
| 132 |
)
|
| 133 |
return _job_to_response(job)
|
| 134 |
|
| 135 |
|
| 136 |
+
@router.get(
|
| 137 |
+
"", response_model=JobListResponse, dependencies=[Depends(require_bearer_auth)]
|
| 138 |
+
)
|
| 139 |
async def list_jobs_route(limit: int = 20) -> JobListResponse:
|
| 140 |
jobs = list_jobs(limit=limit)
|
| 141 |
return JobListResponse(jobs=[_job_to_response(j) for j in jobs], total=len(jobs))
|
| 142 |
|
| 143 |
|
| 144 |
+
@router.get(
|
| 145 |
+
"/{job_id}", response_model=JobResponse, dependencies=[Depends(require_bearer_auth)]
|
| 146 |
+
)
|
| 147 |
async def get_job_route(job_id: str) -> JobResponse:
|
| 148 |
job = get_job(job_id)
|
| 149 |
if job is None:
|
|
|
|
| 151 |
return _job_to_response(job)
|
| 152 |
|
| 153 |
|
| 154 |
+
@router.post(
|
| 155 |
+
"/{job_id}/retry",
|
| 156 |
+
response_model=JobResponse,
|
| 157 |
+
dependencies=[Depends(require_bearer_auth)],
|
| 158 |
+
)
|
| 159 |
async def retry_job_route(job_id: str) -> JobResponse:
|
| 160 |
job = get_job(job_id)
|
| 161 |
if job is None:
|
| 162 |
raise HTTPException(status_code=404, detail="Job not found")
|
| 163 |
if job.status not in ("failed",):
|
| 164 |
raise HTTPException(status_code=400, detail="Can only retry failed jobs")
|
| 165 |
+
next_message = (
|
| 166 |
+
"Queued to resume" if job.resume_on_error and job.completed_chunks else None
|
| 167 |
+
)
|
| 168 |
+
return _job_to_response(
|
| 169 |
+
update_job(
|
| 170 |
+
job_id,
|
| 171 |
+
status="queued",
|
| 172 |
+
error_message=None,
|
| 173 |
+
status_message=next_message,
|
| 174 |
+
final_mp3_path=None,
|
| 175 |
+
)
|
| 176 |
+
)
|
| 177 |
|
| 178 |
|
| 179 |
+
@router.post(
|
| 180 |
+
"/{job_id}/cancel",
|
| 181 |
+
response_model=JobResponse,
|
| 182 |
+
dependencies=[Depends(require_bearer_auth)],
|
| 183 |
+
)
|
| 184 |
async def cancel_job_route(job_id: str) -> JobResponse:
|
| 185 |
job = get_job(job_id)
|
| 186 |
if job is None:
|
| 187 |
raise HTTPException(status_code=404, detail="Job not found")
|
| 188 |
if job.status in ("completed", "failed"):
|
| 189 |
+
raise HTTPException(
|
| 190 |
+
status_code=400, detail="Cannot cancel completed or already-failed job"
|
| 191 |
+
)
|
| 192 |
+
return _job_to_response(
|
| 193 |
+
update_job(job_id, status="cancelled", status_message="Cancelled by user")
|
| 194 |
+
)
|
| 195 |
|
| 196 |
|
| 197 |
@router.delete("/{job_id}", dependencies=[Depends(require_bearer_auth)])
|
|
|
|
| 209 |
job = get_job(job_id)
|
| 210 |
if job is None:
|
| 211 |
raise HTTPException(status_code=404, detail="Job not found")
|
|
|
|
|
|
|
| 212 |
if not job.final_mp3_path:
|
| 213 |
raise HTTPException(status_code=404, detail="Output file not found")
|
| 214 |
|
app/config.py
CHANGED
|
@@ -6,11 +6,15 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
| 6 |
|
| 7 |
|
| 8 |
class Settings(BaseSettings):
|
| 9 |
-
model_config = SettingsConfigDict(
|
|
|
|
|
|
|
| 10 |
|
| 11 |
host: str = "0.0.0.0"
|
| 12 |
port: int = 7860
|
| 13 |
model_dir: str = "/app/models/supertonic-2"
|
|
|
|
|
|
|
| 14 |
custom_voice_dir: str = "/app/voices"
|
| 15 |
kokoro_model_dir: str = "/app/models/kokoro"
|
| 16 |
kokoro_model_file: str = "model_q8f16.onnx"
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
class Settings(BaseSettings):
|
| 9 |
+
model_config = SettingsConfigDict(
|
| 10 |
+
env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
| 11 |
+
)
|
| 12 |
|
| 13 |
host: str = "0.0.0.0"
|
| 14 |
port: int = 7860
|
| 15 |
model_dir: str = "/app/models/supertonic-2"
|
| 16 |
+
kitten_model_name: str = "KittenML/kitten-tts-micro-0.8"
|
| 17 |
+
kitten_cache_dir: str = "/app/models/hf-cache"
|
| 18 |
custom_voice_dir: str = "/app/voices"
|
| 19 |
kokoro_model_dir: str = "/app/models/kokoro"
|
| 20 |
kokoro_model_file: str = "model_q8f16.onnx"
|
app/main.py
CHANGED
|
@@ -14,6 +14,7 @@ from app.db import setup_database
|
|
| 14 |
from app.errors import register_exception_handlers
|
| 15 |
from app.services.engine_registry import EngineRegistry, set_engine_registry
|
| 16 |
from app.services.job_worker import start_worker, stop_worker
|
|
|
|
| 17 |
from app.services.kokoro_engine import KokoroEngine
|
| 18 |
from app.services.model_assets import validate_kokoro_assets, validate_model_assets
|
| 19 |
from app.services.supertonic_engine import SupertonicEngine
|
|
@@ -25,10 +26,14 @@ async def lifespan(app: FastAPI):
|
|
| 25 |
validate_model_assets(settings.model_dir, allow_missing_voice_files=True)
|
| 26 |
validate_kokoro_assets(settings.kokoro_model_dir, settings.kokoro_model_file)
|
| 27 |
supertonic_engine = SupertonicEngine.from_settings(settings)
|
|
|
|
| 28 |
kokoro_engine = KokoroEngine.from_settings(settings)
|
| 29 |
registry = EngineRegistry(
|
| 30 |
model_to_engine={
|
| 31 |
-
**{
|
|
|
|
|
|
|
|
|
|
| 32 |
**{model: kokoro_engine for model in kokoro_engine.public_model_ids},
|
| 33 |
}
|
| 34 |
)
|
|
|
|
| 14 |
from app.errors import register_exception_handlers
|
| 15 |
from app.services.engine_registry import EngineRegistry, set_engine_registry
|
| 16 |
from app.services.job_worker import start_worker, stop_worker
|
| 17 |
+
from app.services.kitten_engine import KittenEngine
|
| 18 |
from app.services.kokoro_engine import KokoroEngine
|
| 19 |
from app.services.model_assets import validate_kokoro_assets, validate_model_assets
|
| 20 |
from app.services.supertonic_engine import SupertonicEngine
|
|
|
|
| 26 |
validate_model_assets(settings.model_dir, allow_missing_voice_files=True)
|
| 27 |
validate_kokoro_assets(settings.kokoro_model_dir, settings.kokoro_model_file)
|
| 28 |
supertonic_engine = SupertonicEngine.from_settings(settings)
|
| 29 |
+
kitten_engine = KittenEngine.from_settings(settings)
|
| 30 |
kokoro_engine = KokoroEngine.from_settings(settings)
|
| 31 |
registry = EngineRegistry(
|
| 32 |
model_to_engine={
|
| 33 |
+
**{
|
| 34 |
+
model: supertonic_engine for model in supertonic_engine.public_model_ids
|
| 35 |
+
},
|
| 36 |
+
**{model: kitten_engine for model in kitten_engine.public_model_ids},
|
| 37 |
**{model: kokoro_engine for model in kokoro_engine.public_model_ids},
|
| 38 |
}
|
| 39 |
)
|
app/schemas_jobs.py
CHANGED
|
@@ -12,6 +12,7 @@ class CreateJobRequest(BaseModel):
|
|
| 12 |
file_filename: Optional[str] = Field(default=None, description="Original filename")
|
| 13 |
file_content: Optional[bytes] = Field(default=None, description="Raw file bytes")
|
| 14 |
text: Optional[str] = None
|
|
|
|
| 15 |
|
| 16 |
@model_validator(mode="after")
|
| 17 |
def require_input(self) -> "CreateJobRequest":
|
|
@@ -48,11 +49,13 @@ class JobResponse(BaseModel):
|
|
| 48 |
voice: str
|
| 49 |
speed: float
|
| 50 |
quality: str
|
|
|
|
| 51 |
total_chunks: int
|
| 52 |
completed_chunks: int
|
| 53 |
progress_percent: float
|
| 54 |
status_message: Optional[str]
|
| 55 |
error_message: Optional[str]
|
|
|
|
| 56 |
created_at: str
|
| 57 |
updated_at: str
|
| 58 |
download_url: Optional[str] = None
|
|
|
|
| 12 |
file_filename: Optional[str] = Field(default=None, description="Original filename")
|
| 13 |
file_content: Optional[bytes] = Field(default=None, description="Raw file bytes")
|
| 14 |
text: Optional[str] = None
|
| 15 |
+
resume_on_error: bool = False
|
| 16 |
|
| 17 |
@model_validator(mode="after")
|
| 18 |
def require_input(self) -> "CreateJobRequest":
|
|
|
|
| 49 |
voice: str
|
| 50 |
speed: float
|
| 51 |
quality: str
|
| 52 |
+
resume_on_error: bool = False
|
| 53 |
total_chunks: int
|
| 54 |
completed_chunks: int
|
| 55 |
progress_percent: float
|
| 56 |
status_message: Optional[str]
|
| 57 |
error_message: Optional[str]
|
| 58 |
+
partial_output_available: bool = False
|
| 59 |
created_at: str
|
| 60 |
updated_at: str
|
| 61 |
download_url: Optional[str] = None
|
app/services/file_storage.py
CHANGED
|
@@ -5,9 +5,15 @@ import re
|
|
| 5 |
|
| 6 |
|
| 7 |
class JobStorage:
|
| 8 |
-
def __init__(
|
| 9 |
-
self
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
self.jobs_dir = self.workspace_dir / "jobs"
|
| 12 |
|
| 13 |
def init_dirs(self) -> None:
|
|
@@ -18,7 +24,10 @@ class JobStorage:
|
|
| 18 |
return self.jobs_dir / job_id
|
| 19 |
|
| 20 |
def get_persistent_output_path(self, job_id: str, filename: str) -> Path:
|
| 21 |
-
safe_name =
|
|
|
|
|
|
|
|
|
|
| 22 |
if not safe_name.endswith(".mp3"):
|
| 23 |
safe_name = f"{safe_name}.mp3"
|
| 24 |
return self.persistent_output_dir / f"{job_id}-{safe_name}"
|
|
@@ -35,12 +44,32 @@ class JobStorage:
|
|
| 35 |
path.write_text(text, encoding="utf-8")
|
| 36 |
return path
|
| 37 |
|
| 38 |
-
def save_chunk(
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
path.parent.mkdir(parents=True, exist_ok=True)
|
| 41 |
path.write_bytes(audio_bytes)
|
| 42 |
return path
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
def save_final(self, audio_bytes: bytes, job_id: str, filename: str) -> str:
|
| 45 |
path = self.get_persistent_output_path(job_id, filename)
|
| 46 |
path.write_bytes(audio_bytes)
|
|
|
|
| 5 |
|
| 6 |
|
| 7 |
class JobStorage:
|
| 8 |
+
def __init__(
|
| 9 |
+
self, workspace_dir: str | None = None, persistent_output_dir: str | None = None
|
| 10 |
+
):
|
| 11 |
+
self.workspace_dir = Path(
|
| 12 |
+
workspace_dir or os.getenv("WORKSPACE_DIR", "/tmp/tts-server")
|
| 13 |
+
)
|
| 14 |
+
self.persistent_output_dir = Path(
|
| 15 |
+
persistent_output_dir or os.getenv("PERSISTENT_OUTPUT_DIR", "/data")
|
| 16 |
+
)
|
| 17 |
self.jobs_dir = self.workspace_dir / "jobs"
|
| 18 |
|
| 19 |
def init_dirs(self) -> None:
|
|
|
|
| 24 |
return self.jobs_dir / job_id
|
| 25 |
|
| 26 |
def get_persistent_output_path(self, job_id: str, filename: str) -> Path:
|
| 27 |
+
safe_name = (
|
| 28 |
+
re.sub(r"[^A-Za-z0-9._-]+", "-", Path(filename).name).strip(".-")
|
| 29 |
+
or f"{job_id}.mp3"
|
| 30 |
+
)
|
| 31 |
if not safe_name.endswith(".mp3"):
|
| 32 |
safe_name = f"{safe_name}.mp3"
|
| 33 |
return self.persistent_output_dir / f"{job_id}-{safe_name}"
|
|
|
|
| 44 |
path.write_text(text, encoding="utf-8")
|
| 45 |
return path
|
| 46 |
|
| 47 |
+
def save_chunk(
|
| 48 |
+
self, audio_bytes: bytes, job_id: str, chunk_index: int, ext: str
|
| 49 |
+
) -> Path:
|
| 50 |
+
path = self.get_chunk_path(job_id, chunk_index, ext)
|
| 51 |
path.parent.mkdir(parents=True, exist_ok=True)
|
| 52 |
path.write_bytes(audio_bytes)
|
| 53 |
return path
|
| 54 |
|
| 55 |
+
def get_chunk_path(self, job_id: str, chunk_index: int, ext: str) -> Path:
|
| 56 |
+
return (
|
| 57 |
+
self.get_job_dir(job_id) / "chunks" / f"{chunk_index:03d}.{ext.lstrip('.')}"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
def list_chunk_paths(self, job_id: str, ext: str = "mp3") -> list[Path]:
|
| 61 |
+
return sorted(
|
| 62 |
+
(self.get_job_dir(job_id) / "chunks").glob(f"*.{ext.lstrip('.')}")
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
def clear_chunks(self, job_id: str, ext: str | None = None) -> None:
|
| 66 |
+
chunk_dir = self.get_job_dir(job_id) / "chunks"
|
| 67 |
+
if not chunk_dir.exists():
|
| 68 |
+
return
|
| 69 |
+
pattern = "*" if ext is None else f"*.{ext.lstrip('.')}"
|
| 70 |
+
for path in chunk_dir.glob(pattern):
|
| 71 |
+
path.unlink(missing_ok=True)
|
| 72 |
+
|
| 73 |
def save_final(self, audio_bytes: bytes, job_id: str, filename: str) -> str:
|
| 74 |
path = self.get_persistent_output_path(job_id, filename)
|
| 75 |
path.write_bytes(audio_bytes)
|
app/services/job_runner.py
CHANGED
|
@@ -37,6 +37,28 @@ def _slugify_filename(value: str) -> str:
|
|
| 37 |
return slug or uuid.uuid4().hex
|
| 38 |
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
def run_job(job_id: str) -> None:
|
| 41 |
"""Main entry point — processes job_id synchronously."""
|
| 42 |
storage = JobStorage()
|
|
@@ -65,7 +87,7 @@ def run_job(job_id: str) -> None:
|
|
| 65 |
storage.save_normalized(cleaned, job_id)
|
| 66 |
|
| 67 |
update_job(job_id, status="chunking", status_message="Chunking text…")
|
| 68 |
-
chunks = chunk_text(cleaned, job.model)
|
| 69 |
if not chunks:
|
| 70 |
raise JobRunnerError("Text chunking produced no chunks")
|
| 71 |
|
|
@@ -76,11 +98,35 @@ def run_job(job_id: str) -> None:
|
|
| 76 |
engine = registry.get_engine(job.model)
|
| 77 |
|
| 78 |
chunk_paths: list[Path] = []
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
pct = 10.0 + (i / len(chunks)) * 80.0
|
| 81 |
update_job(
|
| 82 |
job_id,
|
| 83 |
-
completed_chunks=i,
|
| 84 |
progress_percent=round(pct, 1),
|
| 85 |
status_message=f"Chunk {i}/{len(chunks)}",
|
| 86 |
)
|
|
@@ -98,6 +144,7 @@ def run_job(job_id: str) -> None:
|
|
| 98 |
)
|
| 99 |
chunk_path = storage.save_chunk(audio_bytes, job_id, i, "mp3")
|
| 100 |
chunk_paths.append(chunk_path)
|
|
|
|
| 101 |
|
| 102 |
update_job(
|
| 103 |
job_id,
|
|
@@ -109,10 +156,7 @@ def run_job(job_id: str) -> None:
|
|
| 109 |
title_slug = _slugify_filename(job.title or job.id)
|
| 110 |
final_path = storage.get_persistent_output_path(job_id, f"{title_slug}.mp3")
|
| 111 |
|
| 112 |
-
|
| 113 |
-
final_path.write_bytes(chunk_paths[0].read_bytes())
|
| 114 |
-
else:
|
| 115 |
-
merge_mp3s(chunk_paths, final_path)
|
| 116 |
|
| 117 |
storage.cleanup_temp_job(job_id)
|
| 118 |
|
|
@@ -124,9 +168,38 @@ def run_job(job_id: str) -> None:
|
|
| 124 |
final_mp3_path=str(final_path),
|
| 125 |
)
|
| 126 |
except Exception as exc:
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
final_path.unlink(missing_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
update_job(
|
| 130 |
-
job_id,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
)
|
| 132 |
raise
|
|
|
|
| 37 |
return slug or uuid.uuid4().hex
|
| 38 |
|
| 39 |
|
| 40 |
+
def _merge_chunks_to_output(chunk_paths: list[Path], final_path: Path) -> None:
|
| 41 |
+
if not chunk_paths:
|
| 42 |
+
raise JobRunnerError("No chunk audio available to merge")
|
| 43 |
+
if len(chunk_paths) == 1:
|
| 44 |
+
final_path.parent.mkdir(parents=True, exist_ok=True)
|
| 45 |
+
final_path.write_bytes(chunk_paths[0].read_bytes())
|
| 46 |
+
return
|
| 47 |
+
merge_mp3s(chunk_paths, final_path)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _load_existing_chunk_paths(
|
| 51 |
+
storage: JobStorage, job_id: str, completed_chunks: int
|
| 52 |
+
) -> list[Path]:
|
| 53 |
+
chunk_paths: list[Path] = []
|
| 54 |
+
for chunk_index in range(1, completed_chunks + 1):
|
| 55 |
+
path = storage.get_chunk_path(job_id, chunk_index, "mp3")
|
| 56 |
+
if not path.exists():
|
| 57 |
+
return []
|
| 58 |
+
chunk_paths.append(path)
|
| 59 |
+
return chunk_paths
|
| 60 |
+
|
| 61 |
+
|
| 62 |
def run_job(job_id: str) -> None:
|
| 63 |
"""Main entry point — processes job_id synchronously."""
|
| 64 |
storage = JobStorage()
|
|
|
|
| 87 |
storage.save_normalized(cleaned, job_id)
|
| 88 |
|
| 89 |
update_job(job_id, status="chunking", status_message="Chunking text…")
|
| 90 |
+
chunks = chunk_text(cleaned, job.model, already_cleaned=True)
|
| 91 |
if not chunks:
|
| 92 |
raise JobRunnerError("Text chunking produced no chunks")
|
| 93 |
|
|
|
|
| 98 |
engine = registry.get_engine(job.model)
|
| 99 |
|
| 100 |
chunk_paths: list[Path] = []
|
| 101 |
+
start_chunk = 1
|
| 102 |
+
if job.resume_on_error and job.completed_chunks > 0:
|
| 103 |
+
chunk_paths = _load_existing_chunk_paths(
|
| 104 |
+
storage, job_id, job.completed_chunks
|
| 105 |
+
)
|
| 106 |
+
if chunk_paths and job.completed_chunks <= len(chunks):
|
| 107 |
+
start_chunk = job.completed_chunks + 1
|
| 108 |
+
update_job(
|
| 109 |
+
job_id,
|
| 110 |
+
progress_percent=round(
|
| 111 |
+
10.0 + (job.completed_chunks / len(chunks)) * 80.0, 1
|
| 112 |
+
),
|
| 113 |
+
status_message=f"Resuming from chunk {start_chunk}/{len(chunks)}",
|
| 114 |
+
)
|
| 115 |
+
else:
|
| 116 |
+
chunk_paths = []
|
| 117 |
+
start_chunk = 1
|
| 118 |
+
|
| 119 |
+
if not job.resume_on_error or start_chunk == 1:
|
| 120 |
+
storage.clear_chunks(job_id, "mp3")
|
| 121 |
+
if job.final_mp3_path:
|
| 122 |
+
Path(job.final_mp3_path).unlink(missing_ok=True)
|
| 123 |
+
update_job(job_id, completed_chunks=0, final_mp3_path=None)
|
| 124 |
+
|
| 125 |
+
for i in range(start_chunk, len(chunks) + 1):
|
| 126 |
+
chunk = chunks[i - 1]
|
| 127 |
pct = 10.0 + (i / len(chunks)) * 80.0
|
| 128 |
update_job(
|
| 129 |
job_id,
|
|
|
|
| 130 |
progress_percent=round(pct, 1),
|
| 131 |
status_message=f"Chunk {i}/{len(chunks)}",
|
| 132 |
)
|
|
|
|
| 144 |
)
|
| 145 |
chunk_path = storage.save_chunk(audio_bytes, job_id, i, "mp3")
|
| 146 |
chunk_paths.append(chunk_path)
|
| 147 |
+
update_job(job_id, completed_chunks=i)
|
| 148 |
|
| 149 |
update_job(
|
| 150 |
job_id,
|
|
|
|
| 156 |
title_slug = _slugify_filename(job.title or job.id)
|
| 157 |
final_path = storage.get_persistent_output_path(job_id, f"{title_slug}.mp3")
|
| 158 |
|
| 159 |
+
_merge_chunks_to_output(chunk_paths, final_path)
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
storage.cleanup_temp_job(job_id)
|
| 162 |
|
|
|
|
| 168 |
final_mp3_path=str(final_path),
|
| 169 |
)
|
| 170 |
except Exception as exc:
|
| 171 |
+
partial_output_path = None
|
| 172 |
+
current_job = get_job(job_id) or job
|
| 173 |
+
if current_job.resume_on_error and current_job.completed_chunks > 0:
|
| 174 |
+
try:
|
| 175 |
+
chunk_paths = _load_existing_chunk_paths(
|
| 176 |
+
storage, job_id, current_job.completed_chunks
|
| 177 |
+
)
|
| 178 |
+
if chunk_paths:
|
| 179 |
+
title_slug = _slugify_filename(current_job.title or current_job.id)
|
| 180 |
+
final_path = storage.get_persistent_output_path(
|
| 181 |
+
job_id, f"{title_slug}.mp3"
|
| 182 |
+
)
|
| 183 |
+
_merge_chunks_to_output(chunk_paths, final_path)
|
| 184 |
+
partial_output_path = str(final_path)
|
| 185 |
+
except Exception:
|
| 186 |
+
partial_output_path = None
|
| 187 |
+
elif "final_path" in locals() and isinstance(final_path, Path):
|
| 188 |
final_path.unlink(missing_ok=True)
|
| 189 |
+
|
| 190 |
+
resume_hint = (
|
| 191 |
+
" Retry to resume from the last completed chunk."
|
| 192 |
+
if current_job.resume_on_error and current_job.completed_chunks > 0
|
| 193 |
+
else ""
|
| 194 |
+
)
|
| 195 |
+
partial_hint = (
|
| 196 |
+
" Partial MP3 is available for download." if partial_output_path else ""
|
| 197 |
+
)
|
| 198 |
update_job(
|
| 199 |
+
job_id,
|
| 200 |
+
status="failed",
|
| 201 |
+
status_message="Failed",
|
| 202 |
+
error_message=f"{exc}{partial_hint}{resume_hint}",
|
| 203 |
+
final_mp3_path=partial_output_path,
|
| 204 |
)
|
| 205 |
raise
|
app/services/job_store.py
CHANGED
|
@@ -26,6 +26,7 @@ class JobRecord:
|
|
| 26 |
normalized_text_path: Optional[str]
|
| 27 |
output_dir: Optional[str]
|
| 28 |
final_mp3_path: Optional[str]
|
|
|
|
| 29 |
total_chunks: int
|
| 30 |
completed_chunks: int
|
| 31 |
progress_percent: float
|
|
@@ -67,6 +68,7 @@ def init_db() -> None:
|
|
| 67 |
normalized_text_path TEXT,
|
| 68 |
output_dir TEXT,
|
| 69 |
final_mp3_path TEXT,
|
|
|
|
| 70 |
total_chunks INTEGER DEFAULT 0,
|
| 71 |
completed_chunks INTEGER DEFAULT 0,
|
| 72 |
progress_percent REAL DEFAULT 0.0,
|
|
@@ -76,12 +78,27 @@ def init_db() -> None:
|
|
| 76 |
updated_at TEXT NOT NULL
|
| 77 |
)
|
| 78 |
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
|
| 81 |
def _row_to_job(row: sqlite3.Row | None) -> JobRecord | None:
|
| 82 |
if row is None:
|
| 83 |
return None
|
| 84 |
-
|
|
|
|
|
|
|
| 85 |
|
| 86 |
|
| 87 |
def create_job(**kwargs) -> JobRecord:
|
|
@@ -96,6 +113,7 @@ def create_job(**kwargs) -> JobRecord:
|
|
| 96 |
"normalized_text_path": None,
|
| 97 |
"output_dir": None,
|
| 98 |
"final_mp3_path": None,
|
|
|
|
| 99 |
"total_chunks": 0,
|
| 100 |
"completed_chunks": 0,
|
| 101 |
"progress_percent": 0.0,
|
|
|
|
| 26 |
normalized_text_path: Optional[str]
|
| 27 |
output_dir: Optional[str]
|
| 28 |
final_mp3_path: Optional[str]
|
| 29 |
+
resume_on_error: bool
|
| 30 |
total_chunks: int
|
| 31 |
completed_chunks: int
|
| 32 |
progress_percent: float
|
|
|
|
| 68 |
normalized_text_path TEXT,
|
| 69 |
output_dir TEXT,
|
| 70 |
final_mp3_path TEXT,
|
| 71 |
+
resume_on_error INTEGER NOT NULL DEFAULT 0,
|
| 72 |
total_chunks INTEGER DEFAULT 0,
|
| 73 |
completed_chunks INTEGER DEFAULT 0,
|
| 74 |
progress_percent REAL DEFAULT 0.0,
|
|
|
|
| 78 |
updated_at TEXT NOT NULL
|
| 79 |
)
|
| 80 |
""")
|
| 81 |
+
_ensure_columns(
|
| 82 |
+
conn,
|
| 83 |
+
{
|
| 84 |
+
"resume_on_error": "INTEGER NOT NULL DEFAULT 0",
|
| 85 |
+
},
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _ensure_columns(conn: sqlite3.Connection, expected_columns: dict[str, str]) -> None:
|
| 90 |
+
existing = {row[1] for row in conn.execute("PRAGMA table_info(jobs)").fetchall()}
|
| 91 |
+
for name, ddl in expected_columns.items():
|
| 92 |
+
if name not in existing:
|
| 93 |
+
conn.execute(f"ALTER TABLE jobs ADD COLUMN {name} {ddl}")
|
| 94 |
|
| 95 |
|
| 96 |
def _row_to_job(row: sqlite3.Row | None) -> JobRecord | None:
|
| 97 |
if row is None:
|
| 98 |
return None
|
| 99 |
+
data = dict(row)
|
| 100 |
+
data["resume_on_error"] = bool(data.get("resume_on_error", 0))
|
| 101 |
+
return JobRecord(**data)
|
| 102 |
|
| 103 |
|
| 104 |
def create_job(**kwargs) -> JobRecord:
|
|
|
|
| 113 |
"normalized_text_path": None,
|
| 114 |
"output_dir": None,
|
| 115 |
"final_mp3_path": None,
|
| 116 |
+
"resume_on_error": False,
|
| 117 |
"total_chunks": 0,
|
| 118 |
"completed_chunks": 0,
|
| 119 |
"progress_percent": 0.0,
|
app/services/kitten_engine.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
from app.config import Settings
|
| 8 |
+
from app.errors import OpenAICompatibleError
|
| 9 |
+
from app.services.supertonic_engine import SynthesisResult
|
| 10 |
+
|
| 11 |
+
try:
|
| 12 |
+
from kittentts import KittenTTS
|
| 13 |
+
except ImportError: # pragma: no cover
|
| 14 |
+
KittenTTS = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True)
|
| 18 |
+
class KittenVoiceSpec:
|
| 19 |
+
alias: str
|
| 20 |
+
canonical_name: str
|
| 21 |
+
provider_voice_id: str
|
| 22 |
+
source: str = "builtin"
|
| 23 |
+
style_path: str = ""
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class KittenEngine:
|
| 27 |
+
public_model_ids = [
|
| 28 |
+
"kitten-tts-micro-0.8",
|
| 29 |
+
"kitten-tts-micro",
|
| 30 |
+
"kitten-tts",
|
| 31 |
+
"kitten",
|
| 32 |
+
]
|
| 33 |
+
primary_model_id = "kitten-tts-micro-0.8"
|
| 34 |
+
sample_rate = 24000
|
| 35 |
+
voice_aliases = {
|
| 36 |
+
"bella": {"canonical_name": "Bella", "provider_voice_id": "expr-voice-2-f"},
|
| 37 |
+
"jasper": {"canonical_name": "Jasper", "provider_voice_id": "expr-voice-2-m"},
|
| 38 |
+
"luna": {"canonical_name": "Luna", "provider_voice_id": "expr-voice-3-f"},
|
| 39 |
+
"bruno": {"canonical_name": "Bruno", "provider_voice_id": "expr-voice-3-m"},
|
| 40 |
+
"rosie": {"canonical_name": "Rosie", "provider_voice_id": "expr-voice-4-f"},
|
| 41 |
+
"hugo": {"canonical_name": "Hugo", "provider_voice_id": "expr-voice-4-m"},
|
| 42 |
+
"kiki": {"canonical_name": "Kiki", "provider_voice_id": "expr-voice-5-f"},
|
| 43 |
+
"leo": {"canonical_name": "Leo", "provider_voice_id": "expr-voice-5-m"},
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
def __init__(self, tts: object | None, model_name: str) -> None:
|
| 47 |
+
self.tts = tts
|
| 48 |
+
self.model_name = model_name
|
| 49 |
+
|
| 50 |
+
@classmethod
|
| 51 |
+
def from_settings(cls, settings: Settings) -> "KittenEngine":
|
| 52 |
+
if KittenTTS is None:
|
| 53 |
+
return cls(tts=None, model_name=settings.kitten_model_name)
|
| 54 |
+
try:
|
| 55 |
+
tts = KittenTTS(
|
| 56 |
+
model_name=settings.kitten_model_name,
|
| 57 |
+
cache_dir=settings.kitten_cache_dir,
|
| 58 |
+
backend="cpu",
|
| 59 |
+
)
|
| 60 |
+
return cls(tts=tts, model_name=settings.kitten_model_name)
|
| 61 |
+
except Exception as exc: # pragma: no cover
|
| 62 |
+
raise OpenAICompatibleError(
|
| 63 |
+
status_code=500,
|
| 64 |
+
message=f"Failed to initialize Kitten TTS engine: {exc}",
|
| 65 |
+
error_type="server_error",
|
| 66 |
+
code="engine_init_failed",
|
| 67 |
+
) from exc
|
| 68 |
+
|
| 69 |
+
def list_voice_bindings(self) -> list[dict[str, str]]:
|
| 70 |
+
return [
|
| 71 |
+
{
|
| 72 |
+
"alias": voice,
|
| 73 |
+
"canonical_name": spec["canonical_name"],
|
| 74 |
+
"provider_voice_id": spec["provider_voice_id"],
|
| 75 |
+
"source": "builtin",
|
| 76 |
+
"style_path": "",
|
| 77 |
+
"model": self.primary_model_id,
|
| 78 |
+
}
|
| 79 |
+
for voice, spec in self.voice_aliases.items()
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
def supports_voice(self, voice: str) -> bool:
|
| 83 |
+
return voice.strip().lower() in self.voice_aliases
|
| 84 |
+
|
| 85 |
+
def synthesize(
|
| 86 |
+
self,
|
| 87 |
+
*,
|
| 88 |
+
text: str,
|
| 89 |
+
voice: str,
|
| 90 |
+
speed: float,
|
| 91 |
+
quality: str,
|
| 92 |
+
model_name: str,
|
| 93 |
+
lang: str,
|
| 94 |
+
) -> SynthesisResult:
|
| 95 |
+
del quality, model_name, lang
|
| 96 |
+
if self.tts is None:
|
| 97 |
+
raise OpenAICompatibleError(
|
| 98 |
+
status_code=500,
|
| 99 |
+
message="Kitten TTS engine is not available.",
|
| 100 |
+
error_type="server_error",
|
| 101 |
+
code="engine_unavailable",
|
| 102 |
+
)
|
| 103 |
+
normalized_voice = voice.strip().lower()
|
| 104 |
+
if normalized_voice not in self.voice_aliases:
|
| 105 |
+
raise OpenAICompatibleError(
|
| 106 |
+
status_code=400,
|
| 107 |
+
message=f"Unsupported Kitten voice '{voice}'.",
|
| 108 |
+
param="voice",
|
| 109 |
+
code="unsupported_voice",
|
| 110 |
+
)
|
| 111 |
+
if speed <= 0:
|
| 112 |
+
raise OpenAICompatibleError(
|
| 113 |
+
status_code=400,
|
| 114 |
+
message="Kitten speed must be greater than 0.",
|
| 115 |
+
param="speed",
|
| 116 |
+
code="invalid_speed",
|
| 117 |
+
)
|
| 118 |
+
try:
|
| 119 |
+
provider_voice_id = self.voice_aliases[normalized_voice][
|
| 120 |
+
"provider_voice_id"
|
| 121 |
+
]
|
| 122 |
+
audio = self.tts.generate(
|
| 123 |
+
text, voice=provider_voice_id, speed=speed, clean_text=False
|
| 124 |
+
)
|
| 125 |
+
except Exception as exc:
|
| 126 |
+
raise OpenAICompatibleError(
|
| 127 |
+
status_code=500,
|
| 128 |
+
message=f"Speech synthesis failed: {exc}",
|
| 129 |
+
error_type="server_error",
|
| 130 |
+
code="synthesis_failed",
|
| 131 |
+
) from exc
|
| 132 |
+
return SynthesisResult(waveform=np.asarray(audio), sample_rate=self.sample_rate)
|
app/services/mp3_merger.py
CHANGED
|
@@ -17,9 +17,32 @@ def merge_mp3s(chunk_paths: list[Path], output_path: Path) -> None:
|
|
| 17 |
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 18 |
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=True) as f:
|
| 19 |
for p in chunk_paths:
|
| 20 |
-
escaped = p.as_posix().replace("'", "'\\''")
|
| 21 |
f.write(f"file '{escaped}'\n")
|
| 22 |
f.flush()
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 18 |
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=True) as f:
|
| 19 |
for p in chunk_paths:
|
| 20 |
+
escaped = p.resolve().as_posix().replace("'", "'\\''")
|
| 21 |
f.write(f"file '{escaped}'\n")
|
| 22 |
f.flush()
|
| 23 |
+
try:
|
| 24 |
+
subprocess.run(
|
| 25 |
+
[
|
| 26 |
+
"ffmpeg",
|
| 27 |
+
"-y",
|
| 28 |
+
"-loglevel",
|
| 29 |
+
"error",
|
| 30 |
+
"-f",
|
| 31 |
+
"concat",
|
| 32 |
+
"-safe",
|
| 33 |
+
"0",
|
| 34 |
+
"-i",
|
| 35 |
+
f.name,
|
| 36 |
+
"-vn",
|
| 37 |
+
"-c:a",
|
| 38 |
+
"libmp3lame",
|
| 39 |
+
"-q:a",
|
| 40 |
+
"2",
|
| 41 |
+
str(output_path),
|
| 42 |
+
],
|
| 43 |
+
check=True,
|
| 44 |
+
capture_output=True,
|
| 45 |
+
)
|
| 46 |
+
except subprocess.CalledProcessError as exc:
|
| 47 |
+
stderr = exc.stderr.decode("utf-8", errors="ignore").strip()
|
| 48 |
+
raise RuntimeError(stderr or "ffmpeg failed to merge mp3 chunks") from exc
|
app/services/text_chunker.py
CHANGED
|
@@ -12,6 +12,8 @@ def get_chunk_size_limit(model: str) -> int:
|
|
| 12 |
m = (model or "").lower()
|
| 13 |
if "supertonic" in m:
|
| 14 |
return 4500
|
|
|
|
|
|
|
| 15 |
if "kokoro" in m:
|
| 16 |
return 2000
|
| 17 |
return 3000
|
|
@@ -39,11 +41,15 @@ def _split_with_limit(text: str, limit: int) -> list[str]:
|
|
| 39 |
if current:
|
| 40 |
chunks.extend(_split_with_limit(current, limit))
|
| 41 |
return chunks
|
| 42 |
-
return [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
-
def chunk_text(text: str, model: str) -> list[dict]:
|
| 46 |
-
cleaned = clean(text)
|
| 47 |
limit = get_chunk_size_limit(model)
|
| 48 |
chunks: list[str] = []
|
| 49 |
for para in _PARA_SPLIT.split(cleaned):
|
|
|
|
| 12 |
m = (model or "").lower()
|
| 13 |
if "supertonic" in m:
|
| 14 |
return 4500
|
| 15 |
+
if "kitten" in m:
|
| 16 |
+
return 3000
|
| 17 |
if "kokoro" in m:
|
| 18 |
return 2000
|
| 19 |
return 3000
|
|
|
|
| 41 |
if current:
|
| 42 |
chunks.extend(_split_with_limit(current, limit))
|
| 43 |
return chunks
|
| 44 |
+
return [
|
| 45 |
+
text[i : i + limit].strip()
|
| 46 |
+
for i in range(0, len(text), limit)
|
| 47 |
+
if text[i : i + limit].strip()
|
| 48 |
+
]
|
| 49 |
|
| 50 |
|
| 51 |
+
def chunk_text(text: str, model: str, already_cleaned: bool = False) -> list[dict]:
|
| 52 |
+
cleaned = text if already_cleaned else clean(text)
|
| 53 |
limit = get_chunk_size_limit(model)
|
| 54 |
chunks: list[str] = []
|
| 55 |
for para in _PARA_SPLIT.split(cleaned):
|
app/services/text_cleaner.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
|
|
| 1 |
import re
|
|
|
|
| 2 |
from typing import Literal
|
| 3 |
|
| 4 |
|
|
@@ -15,8 +17,11 @@ _LINK = re.compile(r"\[([^\]]+)\]\((?:[^()\s]+|\([^()]*\))*\)")
|
|
| 15 |
_IMAGE = re.compile(r"!\[[^\]]*\]\((?:[^()\s]+|\([^()]*\))*\)")
|
| 16 |
_HR = re.compile(r"^\s*(?:---+|\*\*\*+|___+)\s*$", re.MULTILINE)
|
| 17 |
_HTML = re.compile(r"<[^>]+>")
|
| 18 |
-
_MD_ATTRIBUTE_BLOCK = re.compile(r"\{[^{}\n]*\}")
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
_PUNCT = str.maketrans(
|
|
@@ -39,7 +44,10 @@ _PUNCT = str.maketrans(
|
|
| 39 |
|
| 40 |
|
| 41 |
def _txt_clean(text: str) -> str:
|
|
|
|
|
|
|
| 42 |
text = text.translate(_PUNCT)
|
|
|
|
| 43 |
text = _CONTROL_CHARS.sub("", text)
|
| 44 |
lines = []
|
| 45 |
for line in text.splitlines():
|
|
@@ -59,6 +67,9 @@ def clean_markdown(text: str) -> str:
|
|
| 59 |
text = _FENCED_CODE.sub("\n", text)
|
| 60 |
text = _IMAGE.sub("", text)
|
| 61 |
text = _LINK.sub(r"\1", text)
|
|
|
|
|
|
|
|
|
|
| 62 |
text = _INLINE_CODE.sub(r"\1", text)
|
| 63 |
text = _ATX_HEADING.sub(r"Section: \1", text)
|
| 64 |
text = _SETEXT_HEADING.sub(r"Section: \1", text)
|
|
@@ -70,8 +81,7 @@ def clean_markdown(text: str) -> str:
|
|
| 70 |
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
| 71 |
text = re.sub(r"__(.+?)__", r"\1", text)
|
| 72 |
text = re.sub(r"\*(.+?)\*", r"\1", text)
|
| 73 |
-
text = re.sub(r"_(.+?)_", r"\1", text)
|
| 74 |
-
text = _UNSUPPORTED_MD_SYMBOLS.sub(" ", text)
|
| 75 |
return _txt_clean(text)
|
| 76 |
|
| 77 |
|
|
|
|
| 1 |
+
import html
|
| 2 |
import re
|
| 3 |
+
import unicodedata
|
| 4 |
from typing import Literal
|
| 5 |
|
| 6 |
|
|
|
|
| 17 |
_IMAGE = re.compile(r"!\[[^\]]*\]\((?:[^()\s]+|\([^()]*\))*\)")
|
| 18 |
_HR = re.compile(r"^\s*(?:---+|\*\*\*+|___+)\s*$", re.MULTILINE)
|
| 19 |
_HTML = re.compile(r"<[^>]+>")
|
| 20 |
+
_MD_ATTRIBUTE_BLOCK = re.compile(r"\{\s*[.#][^{}\n]*\}")
|
| 21 |
+
_FOOTNOTE_REF = re.compile(r"\[\^([^\]]+)\]")
|
| 22 |
+
_FOOTNOTE_DEF = re.compile(r"^\[\^([^\]]+)\]:\s*(.+)$", re.MULTILINE)
|
| 23 |
+
_CARET_WRAP = re.compile(r"\^([^\^\n]+)\^")
|
| 24 |
+
_ZERO_WIDTH = re.compile(r"[\u200b-\u200f\ufeff]")
|
| 25 |
|
| 26 |
|
| 27 |
_PUNCT = str.maketrans(
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
def _txt_clean(text: str) -> str:
|
| 47 |
+
text = html.unescape(text or "")
|
| 48 |
+
text = unicodedata.normalize("NFKC", text)
|
| 49 |
text = text.translate(_PUNCT)
|
| 50 |
+
text = _ZERO_WIDTH.sub("", text)
|
| 51 |
text = _CONTROL_CHARS.sub("", text)
|
| 52 |
lines = []
|
| 53 |
for line in text.splitlines():
|
|
|
|
| 67 |
text = _FENCED_CODE.sub("\n", text)
|
| 68 |
text = _IMAGE.sub("", text)
|
| 69 |
text = _LINK.sub(r"\1", text)
|
| 70 |
+
text = _FOOTNOTE_DEF.sub(r"\2", text)
|
| 71 |
+
text = _FOOTNOTE_REF.sub(r"\1", text)
|
| 72 |
+
text = _CARET_WRAP.sub(r"\1", text)
|
| 73 |
text = _INLINE_CODE.sub(r"\1", text)
|
| 74 |
text = _ATX_HEADING.sub(r"Section: \1", text)
|
| 75 |
text = _SETEXT_HEADING.sub(r"Section: \1", text)
|
|
|
|
| 81 |
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
| 82 |
text = re.sub(r"__(.+?)__", r"\1", text)
|
| 83 |
text = re.sub(r"\*(.+?)\*", r"\1", text)
|
| 84 |
+
text = re.sub(r"(?<!\w)_(?!\s)(.+?)(?<!\s)_(?!\w)", r"\1", text)
|
|
|
|
| 85 |
return _txt_clean(text)
|
| 86 |
|
| 87 |
|
app/templates/index.html
CHANGED
|
@@ -38,6 +38,9 @@
|
|
| 38 |
.field input:focus, .field select:focus, .field textarea:focus { outline: none; border-color: var(--accent); }
|
| 39 |
.field textarea { resize: vertical; min-height: 120px; display: none; }
|
| 40 |
.field .helper { margin-top: 0.35rem; font-size: 0.78rem; color: var(--text-muted); }
|
|
|
|
|
|
|
|
|
|
| 41 |
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
| 42 |
.speed-wrap { display: flex; align-items: center; gap: 0.75rem; }
|
| 43 |
.speed-wrap input[type="range"] { flex: 1; accent-color: var(--accent); }
|
|
@@ -128,6 +131,15 @@
|
|
| 128 |
</div>
|
| 129 |
</div>
|
| 130 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
</div>
|
| 132 |
|
| 133 |
<div style="text-align:right;"><button class="btn btn-primary" id="startBtn">Start conversion</button></div>
|
|
@@ -157,6 +169,7 @@
|
|
| 157 |
const fileInfo = document.getElementById('fileInfo');
|
| 158 |
const uploadError = document.getElementById('uploadError');
|
| 159 |
const apiKeyInput = document.getElementById('apiKeyInput');
|
|
|
|
| 160 |
const authError = document.getElementById('authError');
|
| 161 |
const jobsList = document.getElementById('jobsList');
|
| 162 |
const emptyJobs = document.getElementById('emptyJobs');
|
|
@@ -246,6 +259,7 @@
|
|
| 246 |
if (!model || !voice) return showError('Please select a model and voice');
|
| 247 |
const formData = new FormData();
|
| 248 |
formData.append('model', model); formData.append('voice', voice); formData.append('quality', quality); formData.append('speed', String(speed));
|
|
|
|
| 249 |
if (selectedFile) formData.append('file', selectedFile, selectedFile.name);
|
| 250 |
else if (textInput.value.trim()) formData.append('text', textInput.value.trim());
|
| 251 |
else return showError('Please upload a file or paste text');
|
|
|
|
| 38 |
.field input:focus, .field select:focus, .field textarea:focus { outline: none; border-color: var(--accent); }
|
| 39 |
.field textarea { resize: vertical; min-height: 120px; display: none; }
|
| 40 |
.field .helper { margin-top: 0.35rem; font-size: 0.78rem; color: var(--text-muted); }
|
| 41 |
+
.check-row { display: flex; align-items: flex-start; gap: 0.6rem; }
|
| 42 |
+
.check-row input[type="checkbox"] { width: auto; margin-top: 0.2rem; }
|
| 43 |
+
.check-row label { margin-bottom: 0; }
|
| 44 |
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
| 45 |
.speed-wrap { display: flex; align-items: center; gap: 0.75rem; }
|
| 46 |
.speed-wrap input[type="range"] { flex: 1; accent-color: var(--accent); }
|
|
|
|
| 131 |
</div>
|
| 132 |
</div>
|
| 133 |
</div>
|
| 134 |
+
<div class="field">
|
| 135 |
+
<div class="check-row">
|
| 136 |
+
<input type="checkbox" id="resumeOnErrorCheckbox" />
|
| 137 |
+
<div>
|
| 138 |
+
<label for="resumeOnErrorCheckbox">Resume from the last completed chunk if conversion fails</label>
|
| 139 |
+
<div class="helper">Keeps completed chunk MP3s, creates a partial merged MP3 on failure, and lets retry continue from where it stopped.</div>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
</div>
|
| 144 |
|
| 145 |
<div style="text-align:right;"><button class="btn btn-primary" id="startBtn">Start conversion</button></div>
|
|
|
|
| 169 |
const fileInfo = document.getElementById('fileInfo');
|
| 170 |
const uploadError = document.getElementById('uploadError');
|
| 171 |
const apiKeyInput = document.getElementById('apiKeyInput');
|
| 172 |
+
const resumeOnErrorCheckbox = document.getElementById('resumeOnErrorCheckbox');
|
| 173 |
const authError = document.getElementById('authError');
|
| 174 |
const jobsList = document.getElementById('jobsList');
|
| 175 |
const emptyJobs = document.getElementById('emptyJobs');
|
|
|
|
| 259 |
if (!model || !voice) return showError('Please select a model and voice');
|
| 260 |
const formData = new FormData();
|
| 261 |
formData.append('model', model); formData.append('voice', voice); formData.append('quality', quality); formData.append('speed', String(speed));
|
| 262 |
+
formData.append('resume_on_error', resumeOnErrorCheckbox.checked ? 'true' : 'false');
|
| 263 |
if (selectedFile) formData.append('file', selectedFile, selectedFile.name);
|
| 264 |
else if (textInput.value.trim()) formData.append('text', textInput.value.trim());
|
| 265 |
else return showError('Please upload a file or paste text');
|
app/templates/job.html
CHANGED
|
@@ -89,13 +89,14 @@
|
|
| 89 |
<span class="detail-label">Voice</span><span class="detail-value">{{ job.voice }}</span>
|
| 90 |
<span class="detail-label">Speed</span><span class="detail-value">{{ job.speed }}×</span>
|
| 91 |
<span class="detail-label">Quality</span><span class="detail-value">{{ job.quality }}</span>
|
|
|
|
| 92 |
{% if job.total_chunks > 0 %}<span class="detail-label">Chunks</span><span class="detail-value">{{ job.completed_chunks }} / {{ job.total_chunks }}</span>{% endif %}
|
| 93 |
<span class="detail-label">Created</span><span class="detail-value">{{ job.created_at[:19] | replace('T', ' ') }}</span>
|
| 94 |
</div>
|
| 95 |
</div>
|
| 96 |
<div class="actions" id="actions">
|
| 97 |
-
{% if job.
|
| 98 |
-
{% if job.status == 'failed' %}<button class="btn btn-secondary" id="retryBtn">Retry</button>{% endif %}
|
| 99 |
{% if job.status not in ('completed', 'failed', 'cancelled') %}<button class="btn btn-danger" id="cancelBtn">Cancel</button>{% endif %}
|
| 100 |
{% if job.status in ('completed', 'failed', 'cancelled') %}<button class="btn btn-secondary" id="deleteBtn">Delete</button>{% endif %}
|
| 101 |
</div>
|
|
|
|
| 89 |
<span class="detail-label">Voice</span><span class="detail-value">{{ job.voice }}</span>
|
| 90 |
<span class="detail-label">Speed</span><span class="detail-value">{{ job.speed }}×</span>
|
| 91 |
<span class="detail-label">Quality</span><span class="detail-value">{{ job.quality }}</span>
|
| 92 |
+
<span class="detail-label">Resume on error</span><span class="detail-value">{{ 'Yes' if job.resume_on_error else 'No' }}</span>
|
| 93 |
{% if job.total_chunks > 0 %}<span class="detail-label">Chunks</span><span class="detail-value">{{ job.completed_chunks }} / {{ job.total_chunks }}</span>{% endif %}
|
| 94 |
<span class="detail-label">Created</span><span class="detail-value">{{ job.created_at[:19] | replace('T', ' ') }}</span>
|
| 95 |
</div>
|
| 96 |
</div>
|
| 97 |
<div class="actions" id="actions">
|
| 98 |
+
{% if job.final_mp3_path %}<button type="button" class="btn btn-primary" id="downloadBtn">{{ 'Download partial MP3' if job.status == 'failed' else 'Download MP3' }}</button>{% endif %}
|
| 99 |
+
{% if job.status == 'failed' %}<button class="btn btn-secondary" id="retryBtn">{{ 'Resume conversion' if job.resume_on_error and job.completed_chunks else 'Retry' }}</button>{% endif %}
|
| 100 |
{% if job.status not in ('completed', 'failed', 'cancelled') %}<button class="btn btn-danger" id="cancelBtn">Cancel</button>{% endif %}
|
| 101 |
{% if job.status in ('completed', 'failed', 'cancelled') %}<button class="btn btn-secondary" id="deleteBtn">Delete</button>{% endif %}
|
| 102 |
</div>
|
requirements.txt
CHANGED
|
@@ -6,6 +6,7 @@ numpy==1.26.4
|
|
| 6 |
supertonic==1.1.2
|
| 7 |
onnxruntime==1.22.1
|
| 8 |
huggingface_hub==0.34.4
|
|
|
|
| 9 |
phonemizer-fork==3.3.2
|
| 10 |
espeakng-loader==0.2.4
|
| 11 |
pytest==8.4.1
|
|
|
|
| 6 |
supertonic==1.1.2
|
| 7 |
onnxruntime==1.22.1
|
| 8 |
huggingface_hub==0.34.4
|
| 9 |
+
kittentts @ https://github.com/KittenML/KittenTTS/releases/download/0.8.1/kittentts-0.8.1-py3-none-any.whl
|
| 10 |
phonemizer-fork==3.3.2
|
| 11 |
espeakng-loader==0.2.4
|
| 12 |
pytest==8.4.1
|
tests/test_job_store.py
CHANGED
|
@@ -22,16 +22,19 @@ class TestJobStore:
|
|
| 22 |
voice="alloy",
|
| 23 |
speed=1.0,
|
| 24 |
quality="balanced",
|
|
|
|
| 25 |
)
|
| 26 |
assert job.id == "test-001"
|
| 27 |
assert job.status == "queued"
|
| 28 |
assert job.model == "supertonic-2"
|
| 29 |
assert job.voice == "alloy"
|
|
|
|
| 30 |
|
| 31 |
fetched = job_store_module.get_job("test-001")
|
| 32 |
assert fetched is not None
|
| 33 |
assert fetched.id == "test-001"
|
| 34 |
assert fetched.status == "queued"
|
|
|
|
| 35 |
finally:
|
| 36 |
job_store_module.DB_PATH = original
|
| 37 |
|
|
|
|
| 22 |
voice="alloy",
|
| 23 |
speed=1.0,
|
| 24 |
quality="balanced",
|
| 25 |
+
resume_on_error=True,
|
| 26 |
)
|
| 27 |
assert job.id == "test-001"
|
| 28 |
assert job.status == "queued"
|
| 29 |
assert job.model == "supertonic-2"
|
| 30 |
assert job.voice == "alloy"
|
| 31 |
+
assert job.resume_on_error is True
|
| 32 |
|
| 33 |
fetched = job_store_module.get_job("test-001")
|
| 34 |
assert fetched is not None
|
| 35 |
assert fetched.id == "test-001"
|
| 36 |
assert fetched.status == "queued"
|
| 37 |
+
assert fetched.resume_on_error is True
|
| 38 |
finally:
|
| 39 |
job_store_module.DB_PATH = original
|
| 40 |
|
tests/test_models.py
CHANGED
|
@@ -11,16 +11,21 @@ def test_models_is_public_by_default() -> None:
|
|
| 11 |
|
| 12 |
def test_models_success() -> None:
|
| 13 |
with TestClient(app) as client:
|
| 14 |
-
response = client.get(
|
|
|
|
|
|
|
| 15 |
assert response.status_code == 200
|
| 16 |
ids = [item["id"] for item in response.json()["data"]]
|
| 17 |
assert "supertonic-2" in ids
|
|
|
|
| 18 |
assert "kokoro" in ids
|
| 19 |
|
| 20 |
|
| 21 |
def test_voices_success() -> None:
|
| 22 |
with TestClient(app) as client:
|
| 23 |
-
response = client.get(
|
|
|
|
|
|
|
| 24 |
assert response.status_code == 200
|
| 25 |
aliases = {item["alias"] for item in response.json()["data"]}
|
| 26 |
assert "alloy" in aliases
|
|
@@ -28,9 +33,22 @@ def test_voices_success() -> None:
|
|
| 28 |
assert "grace" in aliases
|
| 29 |
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
def test_kokoro_voices_success() -> None:
|
| 32 |
with TestClient(app) as client:
|
| 33 |
-
response = client.get(
|
|
|
|
|
|
|
| 34 |
assert response.status_code == 200
|
| 35 |
rows = response.json()["data"]
|
| 36 |
assert any(item["model"] == "kokoro" for item in rows)
|
|
|
|
| 11 |
|
| 12 |
def test_models_success() -> None:
|
| 13 |
with TestClient(app) as client:
|
| 14 |
+
response = client.get(
|
| 15 |
+
"/v1/models", headers={"Authorization": "Bearer changeme"}
|
| 16 |
+
)
|
| 17 |
assert response.status_code == 200
|
| 18 |
ids = [item["id"] for item in response.json()["data"]]
|
| 19 |
assert "supertonic-2" in ids
|
| 20 |
+
assert "kitten-tts-micro-0.8" in ids
|
| 21 |
assert "kokoro" in ids
|
| 22 |
|
| 23 |
|
| 24 |
def test_voices_success() -> None:
|
| 25 |
with TestClient(app) as client:
|
| 26 |
+
response = client.get(
|
| 27 |
+
"/v1/voices", headers={"Authorization": "Bearer changeme"}
|
| 28 |
+
)
|
| 29 |
assert response.status_code == 200
|
| 30 |
aliases = {item["alias"] for item in response.json()["data"]}
|
| 31 |
assert "alloy" in aliases
|
|
|
|
| 33 |
assert "grace" in aliases
|
| 34 |
|
| 35 |
|
| 36 |
+
def test_kitten_voices_success() -> None:
|
| 37 |
+
with TestClient(app) as client:
|
| 38 |
+
response = client.get(
|
| 39 |
+
"/v1/voices?model=kitten-tts-micro-0.8",
|
| 40 |
+
headers={"Authorization": "Bearer changeme"},
|
| 41 |
+
)
|
| 42 |
+
assert response.status_code == 200
|
| 43 |
+
rows = response.json()["data"]
|
| 44 |
+
assert {item["alias"] for item in rows} >= {"bella", "jasper", "luna"}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
def test_kokoro_voices_success() -> None:
|
| 48 |
with TestClient(app) as client:
|
| 49 |
+
response = client.get(
|
| 50 |
+
"/v1/voices?model=kokoro", headers={"Authorization": "Bearer changeme"}
|
| 51 |
+
)
|
| 52 |
assert response.status_code == 200
|
| 53 |
rows = response.json()["data"]
|
| 54 |
assert any(item["model"] == "kokoro" for item in rows)
|
tests/test_schemas_jobs.py
CHANGED
|
@@ -23,8 +23,10 @@ class TestCreateJobRequest:
|
|
| 23 |
voice="af_sarah",
|
| 24 |
speed=1.5,
|
| 25 |
text="Some text to convert",
|
|
|
|
| 26 |
)
|
| 27 |
assert req.text == "Some text to convert"
|
|
|
|
| 28 |
|
| 29 |
def test_rejects_empty_input(self):
|
| 30 |
try:
|
|
@@ -52,21 +54,32 @@ class TestCreateJobRequest:
|
|
| 52 |
voice="alloy",
|
| 53 |
speed=1.0,
|
| 54 |
quality=" Balanced ",
|
|
|
|
| 55 |
)
|
| 56 |
assert req.quality == "balanced"
|
| 57 |
|
| 58 |
def test_rejects_invalid_quality(self):
|
| 59 |
try:
|
| 60 |
-
CreateJobRequest(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
assert False, "expected ValidationError"
|
| 62 |
except ValidationError:
|
| 63 |
assert True
|
| 64 |
|
| 65 |
def test_speed_bounds(self):
|
| 66 |
-
req = CreateJobRequest(
|
|
|
|
|
|
|
| 67 |
assert req.speed == 0.5
|
| 68 |
try:
|
| 69 |
-
CreateJobRequest(
|
|
|
|
|
|
|
| 70 |
assert False, "expected ValidationError"
|
| 71 |
except ValidationError:
|
| 72 |
assert True
|
|
@@ -83,17 +96,20 @@ class TestJobResponse:
|
|
| 83 |
voice="alloy",
|
| 84 |
speed=1.0,
|
| 85 |
quality="balanced",
|
|
|
|
| 86 |
total_chunks=10,
|
| 87 |
completed_chunks=4,
|
| 88 |
progress_percent=40.0,
|
| 89 |
status_message="Chunk 4/10",
|
| 90 |
error_message=None,
|
|
|
|
| 91 |
created_at="2025-01-01T12:00:00Z",
|
| 92 |
updated_at="2025-01-01T12:01:00Z",
|
| 93 |
download_url=None,
|
| 94 |
)
|
| 95 |
assert resp.id == "job_abc123"
|
| 96 |
assert resp.progress_percent == 40.0
|
|
|
|
| 97 |
assert resp.download_url is None
|
| 98 |
|
| 99 |
def test_completed_job_has_download_url(self):
|
|
@@ -106,11 +122,13 @@ class TestJobResponse:
|
|
| 106 |
voice="alloy",
|
| 107 |
speed=1.0,
|
| 108 |
quality="balanced",
|
|
|
|
| 109 |
total_chunks=1,
|
| 110 |
completed_chunks=1,
|
| 111 |
progress_percent=100.0,
|
| 112 |
status_message="Done",
|
| 113 |
error_message=None,
|
|
|
|
| 114 |
created_at="2025-01-01T12:00:00Z",
|
| 115 |
updated_at="2025-01-01T12:05:00Z",
|
| 116 |
download_url="/jobs/job_done/download",
|
|
|
|
| 23 |
voice="af_sarah",
|
| 24 |
speed=1.5,
|
| 25 |
text="Some text to convert",
|
| 26 |
+
resume_on_error=True,
|
| 27 |
)
|
| 28 |
assert req.text == "Some text to convert"
|
| 29 |
+
assert req.resume_on_error is True
|
| 30 |
|
| 31 |
def test_rejects_empty_input(self):
|
| 32 |
try:
|
|
|
|
| 54 |
voice="alloy",
|
| 55 |
speed=1.0,
|
| 56 |
quality=" Balanced ",
|
| 57 |
+
text="hello",
|
| 58 |
)
|
| 59 |
assert req.quality == "balanced"
|
| 60 |
|
| 61 |
def test_rejects_invalid_quality(self):
|
| 62 |
try:
|
| 63 |
+
CreateJobRequest(
|
| 64 |
+
model="supertonic-2",
|
| 65 |
+
voice="alloy",
|
| 66 |
+
speed=1.0,
|
| 67 |
+
quality="ultra",
|
| 68 |
+
text="hello",
|
| 69 |
+
)
|
| 70 |
assert False, "expected ValidationError"
|
| 71 |
except ValidationError:
|
| 72 |
assert True
|
| 73 |
|
| 74 |
def test_speed_bounds(self):
|
| 75 |
+
req = CreateJobRequest(
|
| 76 |
+
model="supertonic-2", voice="alloy", speed=0.5, text="hello"
|
| 77 |
+
)
|
| 78 |
assert req.speed == 0.5
|
| 79 |
try:
|
| 80 |
+
CreateJobRequest(
|
| 81 |
+
model="supertonic-2", voice="alloy", speed=5.0, text="hello"
|
| 82 |
+
)
|
| 83 |
assert False, "expected ValidationError"
|
| 84 |
except ValidationError:
|
| 85 |
assert True
|
|
|
|
| 96 |
voice="alloy",
|
| 97 |
speed=1.0,
|
| 98 |
quality="balanced",
|
| 99 |
+
resume_on_error=True,
|
| 100 |
total_chunks=10,
|
| 101 |
completed_chunks=4,
|
| 102 |
progress_percent=40.0,
|
| 103 |
status_message="Chunk 4/10",
|
| 104 |
error_message=None,
|
| 105 |
+
partial_output_available=False,
|
| 106 |
created_at="2025-01-01T12:00:00Z",
|
| 107 |
updated_at="2025-01-01T12:01:00Z",
|
| 108 |
download_url=None,
|
| 109 |
)
|
| 110 |
assert resp.id == "job_abc123"
|
| 111 |
assert resp.progress_percent == 40.0
|
| 112 |
+
assert resp.resume_on_error is True
|
| 113 |
assert resp.download_url is None
|
| 114 |
|
| 115 |
def test_completed_job_has_download_url(self):
|
|
|
|
| 122 |
voice="alloy",
|
| 123 |
speed=1.0,
|
| 124 |
quality="balanced",
|
| 125 |
+
resume_on_error=False,
|
| 126 |
total_chunks=1,
|
| 127 |
completed_chunks=1,
|
| 128 |
progress_percent=100.0,
|
| 129 |
status_message="Done",
|
| 130 |
error_message=None,
|
| 131 |
+
partial_output_available=False,
|
| 132 |
created_at="2025-01-01T12:00:00Z",
|
| 133 |
updated_at="2025-01-01T12:05:00Z",
|
| 134 |
download_url="/jobs/job_done/download",
|
tests/test_speech_response.py
CHANGED
|
@@ -20,15 +20,21 @@ class FakeEngine:
|
|
| 20 |
|
| 21 |
def fake_registry() -> EngineRegistry:
|
| 22 |
engine = FakeEngine()
|
| 23 |
-
return EngineRegistry(
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
def test_speech_returns_wav(monkeypatch) -> None:
|
|
@@ -112,3 +118,23 @@ def test_kokoro_model_route(monkeypatch) -> None:
|
|
| 112 |
assert response.headers["content-type"].startswith("audio/wav")
|
| 113 |
finally:
|
| 114 |
app.dependency_overrides.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
def fake_registry() -> EngineRegistry:
|
| 22 |
engine = FakeEngine()
|
| 23 |
+
return EngineRegistry(
|
| 24 |
+
model_to_engine={
|
| 25 |
+
"supertonic-2": engine,
|
| 26 |
+
"tts-1": engine,
|
| 27 |
+
"tts-1-hd": engine,
|
| 28 |
+
"gpt-4o-mini-tts": engine,
|
| 29 |
+
"kitten-tts-micro-0.8": engine,
|
| 30 |
+
"kitten-tts-micro": engine,
|
| 31 |
+
"kitten-tts": engine,
|
| 32 |
+
"kitten": engine,
|
| 33 |
+
"kokoro": engine,
|
| 34 |
+
"kokoro-onnx": engine,
|
| 35 |
+
"kokoro-82m": engine,
|
| 36 |
+
}
|
| 37 |
+
)
|
| 38 |
|
| 39 |
|
| 40 |
def test_speech_returns_wav(monkeypatch) -> None:
|
|
|
|
| 118 |
assert response.headers["content-type"].startswith("audio/wav")
|
| 119 |
finally:
|
| 120 |
app.dependency_overrides.clear()
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_kitten_model_route(monkeypatch) -> None:
|
| 124 |
+
app.dependency_overrides[get_engine_registry] = fake_registry
|
| 125 |
+
try:
|
| 126 |
+
with TestClient(app) as client:
|
| 127 |
+
response = client.post(
|
| 128 |
+
"/v1/audio/speech",
|
| 129 |
+
headers={"Authorization": "Bearer changeme"},
|
| 130 |
+
json={
|
| 131 |
+
"model": "kitten-tts-micro-0.8",
|
| 132 |
+
"input": "hello",
|
| 133 |
+
"voice": "bella",
|
| 134 |
+
"response_format": "wav",
|
| 135 |
+
},
|
| 136 |
+
)
|
| 137 |
+
assert response.status_code == 200
|
| 138 |
+
assert response.headers["content-type"].startswith("audio/wav")
|
| 139 |
+
finally:
|
| 140 |
+
app.dependency_overrides.clear()
|
tests/test_text_chunker.py
CHANGED
|
@@ -11,6 +11,9 @@ class TestGetChunkSizeLimit:
|
|
| 11 |
def test_kokoro_limit(self):
|
| 12 |
assert get_chunk_size_limit("kokoro") == 2000
|
| 13 |
|
|
|
|
|
|
|
|
|
|
| 14 |
def test_kokoro_onnx(self):
|
| 15 |
assert get_chunk_size_limit("kokoro-onnx") == 2000
|
| 16 |
|
|
@@ -64,3 +67,8 @@ class TestChunkText:
|
|
| 64 |
def test_empty_text_returns_empty_list(self):
|
| 65 |
chunks = chunk_text("", "supertonic-2")
|
| 66 |
assert len(chunks) == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
def test_kokoro_limit(self):
|
| 12 |
assert get_chunk_size_limit("kokoro") == 2000
|
| 13 |
|
| 14 |
+
def test_kitten_limit(self):
|
| 15 |
+
assert get_chunk_size_limit("kitten-tts-micro-0.8") == 3000
|
| 16 |
+
|
| 17 |
def test_kokoro_onnx(self):
|
| 18 |
assert get_chunk_size_limit("kokoro-onnx") == 2000
|
| 19 |
|
|
|
|
| 67 |
def test_empty_text_returns_empty_list(self):
|
| 68 |
chunks = chunk_text("", "supertonic-2")
|
| 69 |
assert len(chunks) == 0
|
| 70 |
+
|
| 71 |
+
def test_already_cleaned_skips_second_markdown_pass(self):
|
| 72 |
+
text = "Keep {literal braces}"
|
| 73 |
+
chunks = chunk_text(text, "supertonic-2", already_cleaned=True)
|
| 74 |
+
assert chunks[0]["text"] == text
|
tests/test_text_cleaner.py
CHANGED
|
@@ -103,6 +103,16 @@ class TestCleanMarkdown:
|
|
| 103 |
assert "}" not in result
|
| 104 |
assert "Paragraph with" in result
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
class TestClean:
|
| 108 |
def test_routes_to_markdown_cleaner(self):
|
|
|
|
| 103 |
assert "}" not in result
|
| 104 |
assert "Paragraph with" in result
|
| 105 |
|
| 106 |
+
def test_preserves_literal_braces_and_normalizes_entities(self):
|
| 107 |
+
result = clean_markdown("Keep {braces} & full-width:text")
|
| 108 |
+
assert "{braces}" in result
|
| 109 |
+
assert "&" not in result
|
| 110 |
+
|
| 111 |
+
def test_removes_markdown_footnote_reference_but_keeps_content(self):
|
| 112 |
+
result = clean_markdown("Paragraph[^1]\n\n[^1]: Footnote content")
|
| 113 |
+
assert "[^1]" not in result
|
| 114 |
+
assert "Footnote content" in result
|
| 115 |
+
|
| 116 |
|
| 117 |
class TestClean:
|
| 118 |
def test_routes_to_markdown_cleaner(self):
|