grimshaw commited on
Commit
d552b93
·
verified ·
1 Parent(s): e5550d9

Add Kitten TTS and resumable jobs

Browse files

Add 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 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 create_job, delete_job, get_job, list_jobs, update_job
 
 
 
 
 
 
 
13
 
14
  router = APIRouter(prefix="/jobs", tags=["jobs"])
15
 
16
 
17
  def _job_to_response(job) -> JobResponse:
18
  download_url = None
19
- if job.status == "completed" and job.final_mp3_path:
 
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("", response_model=JobResponse, dependencies=[Depends(require_bearer_auth)])
 
 
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(status_code=400, detail="quality must be low, balanced, or high")
 
 
61
 
62
  if hasattr(engine, "supports_voice") and not engine.supports_voice(voice):
63
- raise HTTPException(status_code=400, detail=f"Voice '{voice}' not available for model '{model}'")
 
 
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(status_code=400, detail="Either a file or text field is required")
 
 
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(status_code=400, detail="Only .txt and .md files are supported")
 
 
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(storage.save_upload(content_bytes, filename or "upload.txt", job_id))
 
 
86
  elif text and text.strip():
87
  # Pasted text — save as a file so the runner can load it
88
- text_bytes = text.strip().encode("utf-8")
89
- source_path = str(storage.save_upload(text_bytes, "pasted.txt", job_id))
90
- source_filename_val = "pasted.txt"
 
 
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" if (source_filename_val and source_filename_val.endswith(".md")) else "txt",
 
 
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("", response_model=JobListResponse, dependencies=[Depends(require_bearer_auth)])
 
 
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("/{job_id}", response_model=JobResponse, dependencies=[Depends(require_bearer_auth)])
 
 
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("/{job_id}/retry", response_model=JobResponse, dependencies=[Depends(require_bearer_auth)])
 
 
 
 
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
- return _job_to_response(update_job(job_id, status="queued", error_message=None, status_message=None))
 
 
 
 
 
 
 
 
 
 
 
130
 
131
 
132
- @router.post("/{job_id}/cancel", response_model=JobResponse, dependencies=[Depends(require_bearer_auth)])
 
 
 
 
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(status_code=400, detail="Cannot cancel completed or already-failed job")
139
- return _job_to_response(update_job(job_id, status="cancelled", status_message="Cancelled by user"))
 
 
 
 
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(env_file=".env", env_file_encoding="utf-8", extra="ignore")
 
 
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
- **{model: supertonic_engine for model in supertonic_engine.public_model_ids},
 
 
 
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__(self, workspace_dir: str | None = None, persistent_output_dir: str | None = None):
9
- self.workspace_dir = Path(workspace_dir or os.getenv("WORKSPACE_DIR", "/tmp/tts-server"))
10
- self.persistent_output_dir = Path(persistent_output_dir or os.getenv("PERSISTENT_OUTPUT_DIR", "/data"))
 
 
 
 
 
 
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 = re.sub(r"[^A-Za-z0-9._-]+", "-", Path(filename).name).strip(".-") or f"{job_id}.mp3"
 
 
 
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(self, audio_bytes: bytes, job_id: str, chunk_index: int, ext: str) -> Path:
39
- path = self.get_job_dir(job_id) / "chunks" / f"{chunk_index:03d}.{ext.lstrip('.')}"
 
 
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
- for i, chunk in enumerate(chunks, start=1):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if len(chunk_paths) == 1:
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
- if "final_path" in locals() and isinstance(final_path, Path):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  final_path.unlink(missing_ok=True)
 
 
 
 
 
 
 
 
 
129
  update_job(
130
- job_id, status="failed", status_message="Failed", error_message=str(exc)
 
 
 
 
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
- return JobRecord(**dict(row))
 
 
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
- subprocess.run([
24
- "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", f.name, "-c", "copy", str(output_path)
25
- ], check=True, capture_output=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 [text[i:i + limit].strip() for i in range(0, len(text), limit) if text[i:i + limit].strip()]
 
 
 
 
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
- _UNSUPPORTED_MD_SYMBOLS = re.compile(r"[\^{}]")
 
 
 
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.status == 'completed' %}<button type="button" class="btn btn-primary" id="downloadBtn">Download MP3</button>{% endif %}
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("/v1/models", headers={"Authorization": "Bearer changeme"})
 
 
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("/v1/voices", headers={"Authorization": "Bearer changeme"})
 
 
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("/v1/voices?model=kokoro", headers={"Authorization": "Bearer changeme"})
 
 
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(model="supertonic-2", voice="alloy", speed=1.0, quality="ultra")
 
 
 
 
 
 
61
  assert False, "expected ValidationError"
62
  except ValidationError:
63
  assert True
64
 
65
  def test_speed_bounds(self):
66
- req = CreateJobRequest(model="supertonic-2", voice="alloy", speed=0.5, text="hello")
 
 
67
  assert req.speed == 0.5
68
  try:
69
- CreateJobRequest(model="supertonic-2", voice="alloy", speed=5.0, text="hello")
 
 
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(model_to_engine={
24
- "supertonic-2": engine,
25
- "tts-1": engine,
26
- "tts-1-hd": engine,
27
- "gpt-4o-mini-tts": engine,
28
- "kokoro": engine,
29
- "kokoro-onnx": engine,
30
- "kokoro-82m": engine,
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} &amp; full-width:text")
108
+ assert "{braces}" in result
109
+ assert "&amp;" 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):