Files
UmbraAudio/umbraaudio/web/__init__.py
B.D. Murphy 3168b51e7c Spec 30: language as a first-class dimension, with a UI mockup
Proposal plus a working read-only mockup at /language-mockup for operator
review. German selected by default, as asked.

Measured first, so the proposal reflects reality:
  utterances   4,319 rows, no language column - one text per utterance
  language     absent from the DB entirely; exists only as a filename
               convention in the experimental lang-samples page
  /v1/tts      language: str = Form("english") - free-form, unvalidated
  marker       utt_XXXX.wav.txt JSON has speaker/text/voice_fp, no language

The load-bearing finding is that audio MUST be namespaced per language
before any second language is generated. staleness.is_current() compares
the marker's recorded text against the utterance text, so German and
English sharing ch6/utt_0001.wav would each mark the other stale and
overwrite it in a loop. That is a correctness requirement, not a tidy-up.

Schema choice: a new utterance_translations table rather than a language
column on utterances. One row can hold only one language, so a column
either forces 10x duplication of speaker/emotion/pace/attribution or
overwrites the source text. UNIQUE(utterance_id, language_code) makes
UmbraTrans re-delivery an update instead of a second competing German.

English stays in utterances.text and stays editable; translations are
confirm-only, matching the agreed asymmetry.

Audio: chapter_audio/ (English, path untouched, zero regression risk)
gains siblings chapter_audio_de/ etc. Marker JSON gains "language" so an
export can prove what it shipped.

The mockup is a separate router for the reason name_token_routes.py exists
- it keeps proposed work out of the hot routes.py until it is agreed. It
writes nothing, generates nothing, and touches the English pipeline not at
all. Verified: 200 on all 10 languages, real German text rendered,
coverage read from the real 4,319-row DB, unknown codes fall back to
German without a traceback, and the auth gate still 303s to /login.

I initially invented a routes.py helper (resolve_project_dir) that does
not exist; the real one is _get_active_config(request).project_dir. I also
removed a silent cwd fallback that would have rendered English under a
German heading and looked like a working page, and made an unreadable DB
show 'could not measure' rather than a misleading 0%.

Suite: 721 passed, 8 pre-existing. ruff clean on new files.
2026-10-14 07:52:18 -07:00

141 lines
5.6 KiB
Python

from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
if TYPE_CHECKING:
from umbraaudio.config import AppConfig
_app: FastAPI | None = None
def create_app(config: AppConfig) -> FastAPI:
global _app
if _app is not None:
return _app
logger = logging.getLogger(__name__)
app = FastAPI(title="UmbraAudio Review", version="0.2.0")
static_dir = Path(__file__).parent / "static"
if static_dir.exists():
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
from umbraaudio.config import list_projects
from umbraaudio.pipeline_manager import PipelineManager
from umbraaudio.voices.generation_manager import GenerationManager
from umbraaudio.web.language_routes import router as language_router
from umbraaudio.web.name_token_routes import router as name_token_router
from umbraaudio.web.routes import NoActiveProjectError, router
from umbraaudio.web.voice_queue_routes import router as voice_queue_router
app.include_router(router)
# Spec 30 MOCKUP: language-selection UI preview for operator review. Read-only,
# and removed once spec 30 is implemented for real.
app.include_router(language_router)
# Spec 17 Phase 3: orphan name-token review queue lives in its own module so
# the UI never has to be merged into the hot routes.py.
app.include_router(name_token_router)
# Voice-regeneration queue. Separate module for the same reason, and so the
# eventual controller can drive the same endpoints remotely.
app.include_router(voice_queue_router)
# ---------------------------------------------------------------- auth gate
# Installed as middleware rather than per-route dependencies so that a newly
# added route is protected by default: forgetting to decorate a handler is
# not a way to accidentally expose the UI.
#
# Fail-CLOSED: any error while checking the session denies the request. An
# exception here must never fall through to "no session required", because
# that would turn a bug into an open door.
from umbraaudio import auth
from umbraaudio.web.auth_routes import router as auth_router
app.include_router(auth_router)
@app.middleware("http")
async def _auth_gate(request: Request, call_next):
path = request.url.path
if (
path in auth.PUBLIC_PATHS
or path.startswith(auth.PUBLIC_PREFIXES)
or request.method == "OPTIONS"
):
return await call_next(request)
try:
user = auth.verify_session(request.cookies.get(auth.COOKIE_NAME))
except Exception:
logger.exception("auth: session check failed; denying request")
user = None
if not user:
# API and HTMX callers get a status they can act on; browsers get
# sent to the login page.
wants_json = request.headers.get("hx-request") or (
"application/json" in (request.headers.get("accept") or "")
and "text/html" not in (request.headers.get("accept") or "")
)
if wants_json:
return JSONResponse(
{"detail": "Not authenticated.", "redirect": "/login"},
status_code=401,
)
return RedirectResponse(url="/login", status_code=303)
# First login is forced through a password change before any other page.
state = auth.password_state()
if state["must_change"] and path not in ("/change-password", "/api/change-password", "/api/logout"):
if path.startswith("/static/"):
return await call_next(request)
return RedirectResponse(url="/change-password", status_code=303)
return await call_next(request)
# Spec 17 §3.1/§3.2: speaker identification confidence. Exposed as a template
# global (like active_project) so the speakers page can show a
# clear / needs-review designation for every speaker without touching the
# hot routes.py rendering code.
from umbraaudio.analysis.speaker_confidence import load_speaker_assessments
from umbraaudio.web.routes import templates as _templates
def _speaker_designations_for_template(request) -> dict:
try:
slug = request.cookies.get("active_project")
if not slug:
return {}
return load_speaker_assessments(Path(slug) / ".umbra")
except Exception:
return {}
_templates.env.globals["speaker_designations"] = _speaker_designations_for_template
@app.exception_handler(NoActiveProjectError)
async def _no_active_project_handler(request: Request, exc: NoActiveProjectError):
"""HTML pages go to the project picker; API callers get a 400 error."""
accept = (request.headers.get("accept") or "").lower()
if "text/html" in accept:
return RedirectResponse(url="/projects", status_code=303)
return JSONResponse(
{"detail": str(exc), "redirect": "/projects"}, status_code=400
)
app.state.config = config
app.state.projects = list_projects()
if config.data_dir and config.data_dir.exists() and config.data_dir.name != ".umbra":
app.state.pipeline_manager = PipelineManager(config.data_dir)
app.state.generation_manager = GenerationManager(config.data_dir)
else:
app.state.pipeline_manager = None
app.state.generation_manager = None
_app = app
return app