feat: Implement unsupported character handling in Supertonic pipeline and add related tests

This commit is contained in:
JB
2025-12-20 12:18:27 -08:00
parent de8debb6b1
commit eb57744533
8 changed files with 321 additions and 15 deletions
+99 -10
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import ast
from dataclasses import dataclass
import logging
import math
import re
from typing import Any, Iterable, Iterator, Optional
@@ -8,6 +10,9 @@ from typing import Any, Iterable, Iterator, Optional
import numpy as np
logger = logging.getLogger(__name__)
DEFAULT_SUPERTONIC_VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5")
@@ -76,6 +81,48 @@ def _split_text(text: str, *, split_pattern: Optional[str], max_chunk_length: in
return result
_UNSUPPORTED_CHARS_RE = re.compile(r"unsupported character\(s\):\s*(\[[^\]]*\])", re.IGNORECASE)
def _parse_unsupported_characters(error: BaseException) -> list[str]:
"""Best-effort extraction of unsupported characters from SuperTonic errors."""
message = " ".join(str(part) for part in getattr(error, "args", ()) if part is not None) or str(error)
match = _UNSUPPORTED_CHARS_RE.search(message)
if not match:
return []
raw = match.group(1)
try:
value = ast.literal_eval(raw)
except Exception:
return []
if isinstance(value, (list, tuple)):
out: list[str] = []
for item in value:
if item is None:
continue
s = str(item)
if s:
out.append(s)
return out
if isinstance(value, str) and value:
return [value]
return []
def _remove_unsupported_characters(text: str, unsupported: Iterable[str]) -> str:
result = text
for item in unsupported:
if not item:
continue
result = result.replace(item, "")
return result
class SupertonicPipeline:
"""Minimal adapter that mimics Kokoro's pipeline iteration interface."""
@@ -118,15 +165,57 @@ class SupertonicPipeline:
style = self._tts.get_voice_style(voice_name=voice_name)
chunks = _split_text(text, split_pattern=split_pattern, max_chunk_length=self.max_chunk_length)
for chunk in chunks:
wav, duration = self._tts.synthesize(
text=chunk,
voice_style=style,
total_steps=steps,
speed=speed_value,
max_chunk_length=self.max_chunk_length,
silence_duration=0.0,
verbose=False,
)
chunk_to_speak = chunk
removed: set[str] = set()
last_exc: Exception | None = None
# SuperTonic can raise ValueError for unsupported characters; strip and retry.
for attempt in range(3):
try:
wav, duration = self._tts.synthesize(
text=chunk_to_speak,
voice_style=style,
total_steps=steps,
speed=speed_value,
max_chunk_length=self.max_chunk_length,
silence_duration=0.0,
verbose=False,
)
break
except ValueError as exc:
last_exc = exc
unsupported = _parse_unsupported_characters(exc)
if not unsupported:
raise
removed.update(unsupported)
sanitized = _remove_unsupported_characters(chunk_to_speak, unsupported).strip()
# If we didn't change anything, don't loop forever.
if sanitized == chunk_to_speak.strip():
raise
chunk_to_speak = sanitized
if not chunk_to_speak:
logger.warning(
"SuperTonic: dropped a chunk after removing unsupported characters: %s",
sorted(removed),
)
break
if attempt == 0:
logger.warning(
"SuperTonic: removed unsupported characters %s and retried.",
sorted(removed),
)
else:
# Exhausted retries.
assert last_exc is not None
raise last_exc
if not chunk_to_speak:
continue
audio = _ensure_float32_mono(wav)
# If duration is present, infer the source sample rate and resample if needed.
@@ -143,4 +232,4 @@ class SupertonicPipeline:
if src_rate != self.sample_rate:
audio = _resample_linear(audio, src_rate, self.sample_rate)
yield SupertonicSegment(graphemes=chunk, audio=audio)
yield SupertonicSegment(graphemes=chunk_to_speak, audio=audio)
+95 -2
View File
@@ -30,6 +30,7 @@ from abogen.normalization_settings import (
apply_overrides as apply_normalization_overrides,
)
from abogen.entity_analysis import normalize_token as normalize_entity_token
from abogen.entity_analysis import normalize_manual_override_token
from abogen.text_extractor import ExtractedChapter, extract_from_path
from abogen.utils import (
calculate_text_length,
@@ -907,6 +908,97 @@ def _normalize_for_pipeline(
return normalize_for_pipeline(text, config=apostrophe_config, settings=runtime_settings)
def _merge_pronunciation_overrides(job: Any) -> List[Dict[str, Any]]:
"""Return pronunciation override entries, ensuring manual overrides are included.
Pending jobs keep both `manual_overrides` and `pronunciation_overrides`, but the
latter can be stale if the UI didn't resync before enqueue. During conversion,
we must merge manual overrides so they always apply (before TTS).
Precedence: manual overrides win over existing entries for the same normalized key.
"""
collected: Dict[str, Dict[str, Any]] = {}
existing = getattr(job, "pronunciation_overrides", None)
if isinstance(existing, list):
for entry in existing:
if not isinstance(entry, Mapping):
continue
token_value = str(entry.get("token") or "").strip()
pronunciation_value = str(entry.get("pronunciation") or "").strip()
if not token_value or not pronunciation_value:
continue
normalized = str(entry.get("normalized") or "").strip() or normalize_entity_token(token_value)
if not normalized:
continue
collected[normalized] = {
"token": token_value,
"normalized": normalized,
"pronunciation": pronunciation_value,
"voice": str(entry.get("voice") or "").strip() or None,
"notes": str(entry.get("notes") or "").strip() or None,
"context": str(entry.get("context") or "").strip() or None,
"source": str(entry.get("source") or "pronunciation"),
"language": getattr(job, "language", None),
}
# Speaker pronunciation entries (optional), mirrored from the pending-job collector.
speakers = getattr(job, "speakers", None)
if isinstance(speakers, dict):
for payload in speakers.values():
if not isinstance(payload, Mapping):
continue
token_value = str(payload.get("token") or "").strip()
pronunciation_value = str(payload.get("pronunciation") or "").strip()
if not token_value or not pronunciation_value:
continue
normalized = normalize_entity_token(token_value)
if not normalized:
continue
collected[normalized] = {
"token": token_value,
"normalized": normalized,
"pronunciation": pronunciation_value,
"voice": str(
payload.get("resolved_voice")
or payload.get("voice")
or getattr(job, "voice", "")
).strip()
or None,
"notes": None,
"context": None,
"source": "speaker",
"language": getattr(job, "language", None),
}
# Manual overrides should take precedence.
manual = getattr(job, "manual_overrides", None)
if isinstance(manual, list):
for entry in manual:
if not isinstance(entry, Mapping):
continue
token_value = str(entry.get("token") or "").strip()
pronunciation_value = str(entry.get("pronunciation") or "").strip()
if not token_value or not pronunciation_value:
continue
normalized = str(entry.get("normalized") or "").strip() or normalize_manual_override_token(token_value)
if not normalized:
continue
collected[normalized] = {
"token": token_value,
"normalized": normalized,
"pronunciation": pronunciation_value,
"voice": str(entry.get("voice") or "").strip() or None,
"notes": str(entry.get("notes") or "").strip() or None,
"context": str(entry.get("context") or "").strip() or None,
"source": str(entry.get("source") or "manual"),
"language": getattr(job, "language", None),
}
return list(collected.values())
def _compile_pronunciation_rules(
overrides: Optional[Iterable[Mapping[str, Any]]],
) -> List[Dict[str, Any]]:
@@ -1535,7 +1627,8 @@ def run_conversion_job(job: Job) -> None:
extraction = extract_from_path(job.stored_path)
file_type = _infer_file_type(job.stored_path)
pronunciation_rules = _compile_pronunciation_rules(job.pronunciation_overrides)
pronunciation_overrides = _merge_pronunciation_overrides(job)
pronunciation_rules = _compile_pronunciation_rules(pronunciation_overrides)
heteronym_sentence_rules = _compile_heteronym_sentence_rules(
getattr(job, "heteronym_overrides", None)
)
@@ -1550,7 +1643,7 @@ def run_conversion_job(job: Job) -> None:
f"Applying {count} pronunciation override{'s' if count != 1 else ''} during conversion.",
level="debug",
)
for override_entry in job.pronunciation_overrides or []:
for override_entry in pronunciation_overrides or []:
if not isinstance(override_entry, Mapping):
continue
raw_token = str(override_entry.get("token") or "").strip()
+9
View File
@@ -252,6 +252,15 @@ def job_logs(job_id: str) -> str:
abort(404)
return render_template("job_logs_static.html", job=job)
@jobs_bp.get("/<job_id>/logs/partial")
def job_logs_partial(job_id: str) -> ResponseReturnValue:
job = get_service().get_job(job_id)
if not job:
# Return a non-polling section so HTMX stops retrying.
return render_template("partials/logs_section_missing.html"), 200
return render_template("partials/logs_section.html", job=job)
@jobs_bp.get("/<job_id>/logs/stream")
def stream_logs(job_id: str) -> ResponseReturnValue:
job = get_service().get_job(job_id)
+1 -3
View File
@@ -149,9 +149,7 @@
</section>
{% endif %}
<section class="card" id="logs" hx-get="{{ url_for('jobs.job_logs', job_id=job.id) }}" hx-trigger="load, every 2s" hx-target="#logs" hx-swap="innerHTML">
{% include "partials/logs.html" %}
</section>
{% include "partials/logs_section.html" %}
{% endblock %}
{% block scripts %}
@@ -0,0 +1,7 @@
<section class="card" id="logs"
hx-get="{{ url_for('jobs.job_logs_partial', job_id=job.id) }}"
hx-trigger="load, every 2s"
hx-target="#logs"
hx-swap="outerHTML">
{% include "partials/logs.html" %}
</section>
@@ -0,0 +1,6 @@
<section class="card" id="logs">
<div class="card__title-row">
<div class="card__title">Live log</div>
</div>
<p>Job not found (it may have completed, been removed, or the server restarted). Refresh the page to load an active job.</p>
</section>