Files
UmbraAudio/umbraaudio/config.py
B.D. Murphy 0c9bea72e0 Add enterprise/infra tooling and repoint Qwen3-TTS submodule to internal fork
Two groups of changes, committed together as the follow-up to the initial push:

1. .gitmodules — submodule Qwen3-TTS now points at the internal Gitea fork
   (ssh://git@mini-eq:2222/umbragit/Qwen3-TTS.git) instead of upstream GitHub,
   so a clone actually gets the UmbraAudio branding/integration commit
   012e452 rather than vanilla upstream.

2. New infra/ tooling for the cross-machine work in spec 21: agent_bridge,
   remote_credential_broker (Keychain-backed, secrets read at runtime and
   generated via read_secret/random_secret — no hardcoded credentials), the
   enterprise bootstrap scripts, and the oldbox SSH/auth prompts. Plus
   backup/ restore scripts, two specs (18 UmbraTrans, 19 enterprise control
   plane), umbraaudio/voices/lmstudio_gate.py, and in-flight edits to the
   discovery/attribution/profiling modules, config, chapter_builder, emotion,
   pronunciation, qwen_tts and routes.

Also adds *.db to .gitignore: SQLite project databases are runtime state and no
.db file was tracked before.

Verified before staging: .env's only change is UMBRA_TTS__QWEN_TTS_HELPER_URL and
the committed copy has zero secret-looking values; the broker ships only
config.example.json.
2026-10-06 06:57:41 -07:00

167 lines
5.4 KiB
Python

from __future__ import annotations
import re
from pathlib import Path
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
PROJECT_BASE = Path("/Volumes/1TB_APL_Storage/UmbraAudio")
def slugify_project_name(filename: str) -> str:
stem = Path(filename).stem
stem = stem.replace(" ", "_")
stem = re.sub(r'[\\/:*?"<>|]', "-", stem)
return stem
def list_projects() -> list[dict[str, str | int]]:
if not PROJECT_BASE.exists():
return []
projects: list[dict[str, str | int]] = []
for entry in sorted(PROJECT_BASE.iterdir()):
if not entry.is_dir():
continue
# The legacy root .umbra (a duplicate of an early Deal Hunter import)
# must never show up as a project in /projects.
if entry.name == ".umbra" and entry == PROJECT_BASE / ".umbra":
continue
db_path = entry / ".umbra" / "project.db"
if not db_path.exists():
continue
slug = entry.name
name = slug
try:
import sqlite3
conn = sqlite3.connect(str(db_path))
row = conn.execute("SELECT name FROM project LIMIT 1").fetchone()
if row:
name = row[0]
conn.close()
except Exception:
pass
projects.append({"slug": slug, "name": name})
return projects
class TTSConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="UMBRA_TTS__", env_file=".env", extra="ignore")
engine: Literal["qwen", "edge", "chatterbox"] = "qwen"
device: Literal["cuda", "cpu", "mps"] = "cuda"
sample_rate: int = 24000
max_retries: int = 3
retry_delay: float = 2.0
voicedesign_model: str = "/Volumes/1TB_APL_Storage/UmbraAudio/models/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
base_model: str = "/Volumes/1TB_APL_Storage/UmbraAudio/models/Qwen3-TTS-12Hz-1.7B-Base"
qwen_tts_url: str = "http://localhost:8881"
# Optional GPU accelerator (e.g. http://127.0.0.1:18881 relay to the
# 192.168.123.14 box). Tried first when healthy; local always falls back.
qwen_tts_helper_url: str = ""
class QualityConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="UMBRA_QUALITY__", env_file=".env", extra="ignore")
min_mos: float = 3.8
min_test_mos: float = 4.0
max_wer: float = 0.03
min_speaker_similarity: float = 0.80
min_test_speaker_similarity: float = 0.85
min_artifact_score: float = 3.5
class AudioConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="UMBRA_AUDIO__", env_file=".env", extra="ignore")
target_rms_db: float = -20.0
peak_limit_db: float = -3.0
noise_floor_db: float = -60.0
silence_between_utterances_ms: int = 500
silence_between_paragraphs_ms: int = 2000
silence_between_chapters_ms: int = 4000
class LLMConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="UMBRA_LLM__", env_file=".env", extra="ignore")
provider: Literal["lmstudio", "openai", "auto"] = "auto"
lmstudio_base_url: str = "http://localhost:1234/v1"
lmstudio_model: str = "google/gemma-4-e4b"
openai_model: str = "gpt-4o"
discovery_model: str = "google/gemma-4-e4b"
temperature: float = 0.3
max_tokens: int = 4096
qwen_tts_url: str = "http://localhost:8881"
class WebConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="UMBRA_WEB__", env_file=".env", extra="ignore")
host: str = "127.0.0.1"
port: int = 8420
auto_open: bool = True
class AppConfig(BaseSettings):
model_config = SettingsConfigDict(env_prefix="UMBRA_", env_file=".env", extra="ignore")
project_dir: Path = Path(".")
data_dir: Path = Field(default=Path(".umbra"))
voice_registry_path: Path = Field(default=Path.home() / ".umbra" / "voice_registry.db")
tts: TTSConfig = Field(default_factory=TTSConfig)
quality: QualityConfig = Field(default_factory=QualityConfig)
audio: AudioConfig = Field(default_factory=AudioConfig)
llm: LLMConfig = Field(default_factory=LLMConfig)
web: WebConfig = Field(default_factory=WebConfig)
@property
def db_path(self) -> Path:
return self.data_dir / "project.db"
@property
def voices_dir(self) -> Path:
return self.data_dir / "voices"
@property
def audio_dir(self) -> Path:
return self.data_dir / "audio"
@property
def export_dir(self) -> Path:
return self.data_dir / "export"
@property
def manifests_dir(self) -> Path:
return self.data_dir / "manifests"
def ensure_dirs(self) -> None:
for d in [
self.data_dir,
self.voices_dir,
self.audio_dir,
self.export_dir,
self.manifests_dir,
]:
d.mkdir(parents=True, exist_ok=True)
def load_config(project_dir: Path | str | None = None) -> AppConfig:
cfg = AppConfig()
if project_dir is not None:
p = Path(project_dir)
if p.is_absolute():
cfg.project_dir = p
cfg.data_dir = p / ".umbra"
else:
# A bare project name always resolves under PROJECT_BASE, even for a
# brand-new (not-yet-existing) slug, so new-project creation lands in
# the right place regardless of the caller's CWD.
cfg.project_dir = PROJECT_BASE / p.name
cfg.data_dir = cfg.project_dir / ".umbra"
cfg.ensure_dirs()
return cfg