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.
167 lines
5.4 KiB
Python
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
|