Magazine P1:
- Images shown automatically during playback (assign_images_to_articles
links each figure to the article whose column it falls in; the client
shows the last image reached in reading order as playback advances).
- "Play all articles" queues every approved article and advances
automatically at the end of each rather than stopping.
- Word-level highlighting, explicitly documented as an ESTIMATE (no
forced-alignment/timestamp source exists): each word's share of the
sentence's real measured audio duration is proportional to its
character length. A checkbox turns it off; sentence highlighting
(measured, not estimated) is unaffected.
- Driving mode: full-screen overlay, two oversized controls, reuses
whatever is already playing/queued.
- Sidebars/pull-out boxes excluded from narration automatically
(mark_sidebars: an anomalously narrow column vs. the page's regular
grid). Fixed a real defect found while building this: a full-width
headline was left in the column-width analysis and could bridge two
genuinely separate columns into one, hiding a real sidebar column
entirely -- caught by a failing test before the fix.
Second OCR engine + fallback ladder:
- KDL Frontier Parser nano wired in via vision_ocr_page_chain (ordered
engine list, first usable result wins, tesseract last resort).
Confirmed live: ~3x faster than Baidu (~12s vs ~40s/page) but returns
plain text with NO coordinate markers, unlike Baidu's labeled regions.
_whole_page_region treats the whole response as one ambiguous
full-page block (gated by ocr_quality_score) rather than discarding
usable text just because it lacks layout structure.
OCR engines page + a real UI bug:
- The OCR & Document Parsing page had no .s-engines-page[data-page="ocr"]
markup at all -- renderLocalContainers already supported an OCR grid
generically, but $('dc-grid-ocr') was always null, so the card grid
silently never rendered. Added an Active OCR Engine panel mirroring
Active Language Model exactly, plus the Local grid -- both configured
engines now appear as ready-made cards, no "Add Custom" dialog needed.
- Fixed the audiobook pipeline's "Previous/Next" nav bar bleeding into
every .section-head in the document (confirmed live on the Engines
page: a "Cast Audiobook" button and "STEP 1/8" hint that made no sense
there). Changed from a blocklist (naming each unrelated section as
noticed) to an allowlist of the pipeline's three actual destinations.
Verified live end-to-end: play-all queue building and manual advance,
word-span markup, driving-mode toggle, OCR page showing both engines as
local cards, zero stray nav bars on the Engines page, no console errors.
20 new backend tests (156 total in the magazine module).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
157 lines
8.5 KiB
Python
157 lines
8.5 KiB
Python
"""Boot-time defaults, path constants, in-memory log buffer, and routing log."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import shutil
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
# ── Version ───────────────────────────────────────────────────────────────────
|
|
|
|
def _read_version() -> str:
|
|
v_file = Path(__file__).parent.parent / "VERSION"
|
|
try:
|
|
return v_file.read_text().strip()
|
|
except Exception:
|
|
return "0.0.0"
|
|
|
|
__version__: str = _read_version()
|
|
|
|
# ── Boot-time defaults ────────────────────────────────────────────────────────
|
|
|
|
_VOICES_DIR_DEFAULT = os.environ.get("VOICES_DIR", "/voices")
|
|
_OUTPUT_DIR_DEFAULT = os.environ.get("OUTPUT_DIR", "/voices/active_voices")
|
|
_WHISPER_DEFAULT = os.environ.get("WHISPER_URL", "http://192.168.178.8:8010")
|
|
_TTS_DEFAULT = os.environ.get("TTS_URL", "http://192.168.178.8:8020")
|
|
_TTS_STREAM_DEFAULT = os.environ.get("TTS_STREAM_URL", "http://192.168.178.8:8023")
|
|
_CUSTOMVOICE_DEFAULT = os.environ.get("CUSTOMVOICE_URL", "http://192.168.178.8:8022")
|
|
_VOICE_DESIGN_DEFAULT = os.environ.get("VOICE_DESIGN_URL", "http://192.168.178.8:8021")
|
|
_NVIDIA_ROUTER_DEFAULT = os.environ.get("NVIDIA_SPEECH_ROUTER_URL", "http://192.168.178.8:8090")
|
|
_NVIDIA_TTS_DEFAULT = os.environ.get("NVIDIA_MAGPIE_TTS_URL", "http://192.168.178.8:8091")
|
|
_NVIDIA_ASR_DEFAULT = os.environ.get("NVIDIA_PARAKEET_ASR_URL", "http://192.168.178.8:8092")
|
|
_NVIDIA_CLONE_DEFAULT = os.environ.get("NVIDIA_TTS_CLONE_URL", "http://192.168.178.8:8093")
|
|
_NVIDIA_ZEROSHOT_DEFAULT = os.environ.get("NVIDIA_ZEROSHOT_TTS_URL", _NVIDIA_CLONE_DEFAULT)
|
|
_NVIDIA_FLOW_DEFAULT = os.environ.get("NVIDIA_FLOW_TTS_URL", "http://192.168.178.8:8094")
|
|
_FASTER_WHISPER_DEFAULT = os.environ.get("FASTER_WHISPER_URL", "http://192.168.178.8:8010")
|
|
_WHISPER_CPP_DEFAULT = os.environ.get("WHISPER_CPP_URL", "http://192.168.178.8:8085")
|
|
_GROQ_STT_ENDPOINT = "https://api.groq.com/openai/v1"
|
|
_KOKORO_DEFAULT = os.environ.get("KOKORO_URL", "http://192.168.178.8:8880/v1")
|
|
# Layout-aware OCR engine for magazine ingestion (see routes/magazine.py). Not
|
|
# a hardware URL like the others above -- points at whichever "ocr-*" engine
|
|
# card (Engines > OCR & Document Parsing) is configured. Defaults to the
|
|
# Baidu Unlimited-OCR container's HOST-published port via host.docker.internal
|
|
# (wired in docker-compose.yml's extra_hosts) rather than its container name --
|
|
# confirmed live that it runs on its own Docker network, not dgx_net, so a
|
|
# container-name default would not resolve from the app container.
|
|
_VISION_OCR_DEFAULT = os.environ.get("VISION_OCR_URL", "http://host.docker.internal:8098/v1")
|
|
_VISION_OCR_MODEL_DEFAULT = os.environ.get("VISION_OCR_MODEL", "baidu/Unlimited-OCR")
|
|
# Second vision OCR engine, tried when the primary fails or is unreachable.
|
|
# Confirmed live: answers faster than Baidu Unlimited-OCR but returns plain
|
|
# text with no layout coordinates (see _whole_page_region in core/magazine.py).
|
|
_VISION_OCR_FALLBACK_DEFAULT = os.environ.get("VISION_OCR_FALLBACK_URL", "http://host.docker.internal:8099/v1")
|
|
_VISION_OCR_FALLBACK_MODEL_DEFAULT = os.environ.get("VISION_OCR_FALLBACK_MODEL", "kdl-frontier-parser-nano")
|
|
_VIBEVOICE_DEFAULT = os.environ.get("VIBEVOICE_URL", "http://192.168.178.8:8027")
|
|
_XTTS_DEFAULT = os.environ.get("XTTS_URL", "http://192.168.178.8:8005")
|
|
_FISHSPEECH_DEFAULT = os.environ.get("FISHSPEECH_URL", "http://192.168.178.8:38080")
|
|
_TTS_CONTAINER = os.environ.get("TTS_CONTAINER_NAME", "faster-qwen3-tts")
|
|
_TTS_CONTAINERS_RAW = os.environ.get("TTS_CONTAINER_NAMES", "") # comma-separated override
|
|
_VOICE_DESIGN_MODEL = os.environ.get("VOICE_DESIGN_MODEL", "Qwen3-TTS-12Hz-1.7B-VoiceDesign")
|
|
_VOICE_TARGET_DBFS = float(os.environ.get("VOICE_TARGET_DBFS", "-20.0"))
|
|
_VOICE_PEAK_DBFS = float(os.environ.get("VOICE_PEAK_DBFS", "-1.0"))
|
|
# Keep uploads bounded by default. Individual endpoints apply smaller limits for
|
|
# pictures and sound assets; MAX_UPLOAD_MB remains configurable for unusually
|
|
# large source audio or PDFs without making an accidental 1 GB request the
|
|
# default for every multipart endpoint.
|
|
_MAX_UPLOAD_BYTES = int(os.environ.get("MAX_UPLOAD_MB", "256")) * 1024 * 1024
|
|
_MAX_JSON_BYTES = int(os.environ.get("MAX_JSON_MB", "32")) * 1024 * 1024
|
|
_MAX_RAW_REQUEST_BYTES = int(os.environ.get("MAX_RAW_REQUEST_MB", "256")) * 1024 * 1024
|
|
_MAX_PICTURE_BYTES = int(os.environ.get("MAX_PICTURE_MB", "10")) * 1024 * 1024
|
|
_MAX_SOUND_BYTES = int(os.environ.get("MAX_SOUND_MB", "25")) * 1024 * 1024
|
|
_MAX_TTS_OUTPUT_SECONDS = float(os.environ.get("MAX_TTS_OUTPUT_SECONDS", "60"))
|
|
_STT_REQUEST_TIMEOUT = float(os.environ.get("STT_REQUEST_TIMEOUT", os.environ.get("REQUEST_TIMEOUT", "900")))
|
|
_BENCHMARK_TEXT = os.environ.get("VOICE_BENCHMARK_TEXT", "This is a short realtime voice benchmark.")
|
|
_BENCHMARK_SENTENCES = [
|
|
("short", _BENCHMARK_TEXT),
|
|
("medium", "The quick brown fox jumps over the lazy dog near the river bank."),
|
|
(
|
|
"long",
|
|
"Artificial intelligence is transforming the way we interact with technology. "
|
|
"From voice assistants to autonomous vehicles, machine learning models are becoming "
|
|
"an integral part of everyday life.",
|
|
),
|
|
]
|
|
_ALLOW_PRIVATE_DOWNLOADS = os.environ.get("ALLOW_PRIVATE_DOWNLOADS", "").lower() in {"1", "true", "yes"}
|
|
# External callers must present the app's inbound key by default. Set this only
|
|
# for a deliberately isolated local deployment that has another auth layer.
|
|
_REQUIRE_EXTERNAL_API_KEY = os.environ.get("REQUIRE_EXTERNAL_API_KEY", "1").lower() not in {"0", "false", "no", "off"}
|
|
|
|
# ── Config dir calculation ────────────────────────────────────────────────────
|
|
|
|
logger = logging.getLogger("uvicorn.error")
|
|
|
|
|
|
def _default_config_dir() -> Path:
|
|
env_dir = os.environ.get("CONFIG_DIR")
|
|
if env_dir:
|
|
return Path(env_dir)
|
|
current = Path("/home/app/.config/tts-voice-creator")
|
|
legacy = Path("/home/app/.config/voice-clone-factory")
|
|
if legacy.exists():
|
|
legacy.mkdir(parents=True, exist_ok=True)
|
|
if current.exists():
|
|
for name in ("settings.json", "voice_design_presets.json", "tts_routes.json"):
|
|
src, dst = current / name, legacy / name
|
|
if src.exists() and not dst.exists():
|
|
try:
|
|
shutil.copy2(src, dst)
|
|
except Exception as exc:
|
|
logger.warning("Could not migrate config %s to mounted config dir: %s", name, exc)
|
|
return legacy
|
|
return current
|
|
|
|
|
|
CONFIG_DIR = _default_config_dir()
|
|
CONFIG_FILE = CONFIG_DIR / "settings.json"
|
|
DESIGN_PRESETS_FILE = CONFIG_DIR / "voice_design_presets.json"
|
|
TTS_ROUTES_FILE = CONFIG_DIR / "tts_routes.json"
|
|
|
|
# ── Static dir ────────────────────────────────────────────────────────────────
|
|
|
|
STATIC_DIR = Path(__file__).parent.parent / "static"
|
|
STATIC_DIR.mkdir(exist_ok=True)
|
|
|
|
# ── In-memory log buffer ───────────────────────────────────────────────────────
|
|
|
|
_LOG_BUFFER_MAX = 400
|
|
_log_buffer: list[dict] = []
|
|
|
|
|
|
class _BufferHandler(logging.Handler):
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
try:
|
|
_log_buffer.insert(0, {
|
|
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
|
"level": record.levelname,
|
|
"name": record.name,
|
|
"msg": record.getMessage(),
|
|
})
|
|
del _log_buffer[_LOG_BUFFER_MAX:]
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ── Routing log ───────────────────────────────────────────────────────────────
|
|
|
|
_ROUTING_LOG_MAX = int(os.environ.get("TTS_ROUTING_LOG_MAX", "120"))
|
|
_routing_log: list[dict] = []
|
|
|
|
|
|
def _routing_log_add(**entry) -> None:
|
|
item = {
|
|
"ts": datetime.now(timezone.utc).isoformat(),
|
|
**entry,
|
|
}
|
|
_routing_log.insert(0, item)
|
|
del _routing_log[_ROUTING_LOG_MAX:]
|