Quality-first multi-pass audiobook generation from English manuscripts. Includes: - umbraaudio/ core package (pipeline, config, voices, web review UI) - scripts/ CLI utilities + model download script - tts_server/ remote TTS server - specs/ design docs, tests/, pyproject.toml + uv.lock - Qwen3-TTS git submodule (pinned 022e286) with local demo.py UI patch saved at scripts/qwen3_tts_demo_umbra_patch.patch - .env with runtime config/secrets Excluded (see .gitignore): models/ (downloaded via scripts/download_models.sh), output/, logs/, .venv, .umbra runtime data, and book projects (DealHunterShip_*), which are committed separately.
14 KiB
Spec: Multi-Project Support
Overview
UmbraAudio currently operates on a single project stored in .umbra/. This spec adds first-class support for multiple independent book projects, each isolated with its own data, configuration, and outputs. Projects are identified by a slug (e.g., deal-hunter, the-great-adventure) and can be created, listed, switched, and removed via CLI and Web UI.
Architecture
Project Root Layout
UmbraAudio/ # Repository root
├── projects/ # Project registry (new)
│ ├── index.json # Master index of all projects
│ ├── deal-hunter/ # Project slug directory
│ │ ├── .umbra/ # Project data (was .umbra/)
│ │ ├── config.yaml # Project-local overrides
│ │ └── manifest.json # Project metadata
│ ├── the-great-adventure/
│ │ ├── .umbra/
│ │ ├── config.yaml
│ │ └── manifest.json
│ └── ...
├── .umbra/ # DEPRECATED: legacy single-project location
└── umbraaudio/ # Package code (unchanged)
Project Identity
| Field | Type | Description |
|---|---|---|
slug |
string | URL-safe identifier (kebab-case), unique, immutable |
title |
string | Human-readable book title |
author |
string | Author name |
created_at |
ISO8601 | Creation timestamp |
updated_at |
ISO8601 | Last activity timestamp |
status |
enum | active, archived, error |
source_format |
string | docx, epub, pdf, txt, md |
source_path |
string | Path to original manuscript (relative to project dir or absolute) |
chapters |
int | Number of parsed chapters |
speakers |
int | Number of discovered speakers |
pipeline_step |
string | Current pipeline stage (parsed, analyzed, designed, mapped, tested, generated, exported) |
Project Index (projects/index.json)
{
"version": 1,
"default_project": "deal-hunter",
"projects": [
{
"slug": "deal-hunter",
"title": "Deal Hunter",
"author": "B.D. Murphy",
"created_at": "2026-08-01T04:22:45",
"updated_at": "2026-08-06T10:30:00",
"status": "active",
"source_format": "docx",
"source_path": "uploads/DealHunterShip_4.0e_whole-to-audio.docx",
"chapters": 35,
"speakers": 61,
"pipeline_step": "generated"
},
{
"slug": "the-great-adventure",
"title": "The Great Adventure",
"author": "John Smith",
"created_at": "2026-08-06T14:00:00",
"updated_at": "2026-08-06T14:00:00",
"status": "active",
"source_format": "epub",
"source_path": "uploads/the-great-adventure.epub",
"chapters": 12,
"speakers": 8,
"pipeline_step": "parsed"
}
]
}
Data Isolation
Each project is completely independent:
| Resource | Location | Shared? |
|---|---|---|
SQLite database (project.db) |
projects/{slug}/.umbra/project.db |
No |
| Speakers JSON | projects/{slug}/.umbra/speakers.json |
No |
| Voice assignments | projects/{slug}/.umbra/voice_assignments.json |
No |
| Voice references | projects/{slug}/.umbra/voices/ |
No |
| Chapter audio | projects/{slug}/.umbra/chapter_audio/ |
No |
| Voice tests | projects/{slug}/.umbra/voice_tests/ |
No |
| Pronunciation guide | projects/{slug}/.umbra/pronunciation_guide/ |
No |
| Exports | projects/{slug}/export/ |
No |
| Logs | projects/{slug}/.umbra/logs/ |
No |
| Voice Registry DB | .umbra/voice_registry.db |
Yes (global) |
| Phonetic names | .umbra/phonetic_names.json |
Yes (global) |
| Qwen TTS models | models/ |
Yes (global) |
| LM Studio | external | Yes (global) |
Shared resources are global infrastructure; project resources are fully isolated.
Configuration
Global Config (config.yaml at repo root)
# Global settings (apply to all projects unless overridden)
tts:
engine: qwen
device: cuda
sample_rate: 24000
qwen_tts_url: "http://localhost:8000"
voicedesign_model: "/mnt/models/qwen3-tts/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
base_model: "/mnt/models/qwen3-tts/Qwen3-TTS-12Hz-1.7B-Base"
llm:
provider: auto
lmstudio_base_url: "http://localhost:1234/v1"
lmstudio_model: "google/gemma-4-e4b"
temperature: 0.3
max_tokens: 4096
web:
host: "0.0.0.0"
port: 8420
auto_open: false
quality:
min_mos: 3.8
min_test_mos: 4.0
max_wer: 0.03
min_speaker_similarity: 0.80
audio:
target_rms_db: -20.0
peak_limit_db: -3.0
Project-Local Config (projects/{slug}/config.yaml)
# Optional overrides for this project only
project:
slug: "deal-hunter"
title: "Deal Hunter"
author: "B.D. Murphy"
tts:
engine: qwen
# device inherits from global
# qwen_tts_url inherits from global
llm:
# lmstudio_model can override per project
# temperature can override per project
quality:
min_mos: 4.0 # stricter for this project
audio:
target_rms_db: -18.0 # louder master
Merge precedence: project config → global config → defaults.
CLI Interface
Project Management Commands
# List all projects
python -m umbraaudio.cli project list
# Show current project
python -m umbraaudio.cli project current
# Create new project from manuscript
python -m umbraaudio.cli project create \
--title "The Great Adventure" \
--author "John Smith" \
--source /path/to/book.epub \
--slug the-great-adventure
# Switch active project
python -m umbraaudio.cli project switch the-great-adventure
# Show project details
python -m umbraaudio.cli project info the-great-adventure
# Archive project (keeps data, marks inactive)
python -m umbraaudio.cli project archive the-great-adventure
# Restore archived project
python -m umbraaudio.cli project restore the-great-adventure
# Delete project (removes all data - requires confirmation)
python -m umbraaudio.cli project delete the-great-adventure --confirm
# Run pipeline commands on specific project
python -m umbraaudio.cli analyze --project the-great-adventure
python -m umbraaudio.cli generate --project the-great-adventure
python -m umbraaudio.cli pipeline --project the-great-adventure -c all
Default Project Behavior
- If
--projectnot specified, usesdefault_projectfromprojects/index.json - If no default set, uses first project in index
- Legacy single-project mode: if
projects/doesn't exist but.umbra/does, auto-migrate on first run
Web UI
Project Selector
- Top-left dropdown in navbar showing current project
- Clicking opens modal with project cards (title, author, status, last updated)
- "New Project" button in modal → upload form
- "Switch" action reloads page with new project context
- "Create New" navigates to
/projects/new
Project-Aware Endpoints
All existing endpoints become project-scoped:
| Old Path | New Path | Notes |
|---|---|---|
/ |
/projects/{slug}/ |
Dashboard |
/voices |
/projects/{slug}/voices |
Voice review |
/speakers |
/projects/{slug}/speakers |
Speaker review |
/chapters-audio |
/projects/{slug}/chapters-audio |
Audio review |
/proper-names |
/projects/{slug}/proper-names |
Pronunciation |
/api/upload-manuscript |
/projects/{slug}/api/upload-manuscript |
Upload |
/api/tts/health |
/api/tts/health |
Global (unchanged) |
/api/models/status |
/api/models/status |
Global (unchanged) |
Project Creation Flow (Web)
- User clicks "New Project" →
/projects/new - Form: title, author, file upload, optional slug (auto-generated from title)
- POST
/projects→ creates project directory, saves manuscript, returns project slug - Redirect to
/projects/{slug}/(dashboard) - Background parsing starts automatically
API Endpoints
# Project CRUD
GET /api/projects # List all projects
POST /api/projects # Create new project
GET /api/projects/{slug} # Get project info
PUT /api/projects/{slug} # Update project metadata
DELETE /api/projects/{slug} # Delete project (with ?confirm=true)
POST /api/projects/{slug}/archive # Archive project
POST /api/projects/{slug}/restore # Restore project
POST /api/projects/{slug}/set-default # Set as default
# Project-scoped existing endpoints (all prefixed with /projects/{slug})
GET /projects/{slug}/api/voices
POST /projects/{slug}/api/voices/regenerate
GET /projects/{slug}/api/chapters
# ... etc
Database Changes
Project Registry Table (Global)
-- In a new global SQLite DB: .umbra/projects.db
CREATE TABLE IF NOT EXISTS projects (
slug TEXT PRIMARY KEY,
title TEXT NOT NULL,
author TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
status TEXT NOT NULL DEFAULT 'active', -- active, archived, error
source_format TEXT,
source_path TEXT,
chapters INTEGER DEFAULT 0,
speakers INTEGER DEFAULT 0,
pipeline_step TEXT DEFAULT 'initialized',
config_json TEXT, -- project-local config overrides
manifest_json TEXT -- extended metadata
);
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
CREATE INDEX IF NOT EXISTS idx_projects_updated ON projects(updated_at);
Per-Project Database (Unchanged)
Each project's project.db retains the exact same schema as today:
projecttable (single row per project)chapters,characters,utterances,generated_audio, etc.- No cross-project foreign keys
Migration from Legacy Single-Project
# On first run if .umbra/ exists but projects/ doesn't:
1. Create projects/ directory
2. Generate slug from project name in .umbra/project.db
3. Move .umbra/ → projects/{slug}/.umbra/
4. Create projects/{slug}/config.yaml (empty/inherit all)
5. Create projects/{slug}/manifest.json
6. Create projects/index.json with the migrated project as default
7. Update .env UMBRA_DATA_DIR to point to projects/{slug}/.umbra (or use runtime resolution)
Config Resolution
def load_config(project_slug: str | None = None) -> AppConfig:
# 1. Load global config.yaml
global_cfg = load_yaml("config.yaml")
# 2. Resolve project slug
if project_slug is None:
project_slug = get_default_project_slug() # from index.json
# 3. Load project config
project_cfg = load_yaml(f"projects/{project_slug}/config.yaml") if exists else {}
# 4. Merge: project overrides global
merged = deep_merge(global_cfg, project_cfg)
merged["project_slug"] = project_slug
merged["data_dir"] = Path(f"projects/{project_slug}/.umbra")
merged["export_dir"] = Path(f"projects/{project_slug}/export")
return AppConfig(**merged)
Pipeline Execution
Per-Project Pipeline State
Each project tracks its own pipeline status in projects/{slug}/.umbra/pipeline_status.json:
{
"step": "generation",
"status": "running",
"detail": "Chapter 12 of 35",
"progress": 34,
"started_at": "2026-08-06T10:00:00",
"updated_at": "2026-08-06T10:15:00"
}
Concurrent Execution
- Multiple projects can run pipeline steps simultaneously if resources allow
- Qwen TTS server is shared; requests from different projects queued
- LM Studio is shared; requests from different projects queued
- Web UI shows status for active project only; other projects' status visible in project list
Voice Registry Integration
The global voice registry (voice_registry.db) tracks voices across all projects:
-- Extended schema
ALTER TABLE voice_registry ADD COLUMN source_projects TEXT; -- JSON array of project slugs
ALTER TABLE voice_registry ADD COLUMN last_used_project TEXT;
ALTER TABLE voice_registry ADD COLUMN last_used_at TEXT;
- A voice created in Project A can be reused in Project B
- Web UI "Voice Registry" page shows all voices with project badges
- When generating voice reference, if voice exists in registry and matches description, reuse it
Testing Strategy
Unit Tests
# tests/test_multi_project.py
def test_create_project_creates_directory_structure()
def test_project_index_persists_and_loads()
def test_config_merge_precedence()
def test_load_config_resolves_data_dir_per_project()
def test_migrate_legacy_single_project()
def test_project_slug_uniqueness()
def test_archive_restore_cycle()
def test_delete_project_removes_all_data()
Integration Tests
# tests/test_integration_multi_project.py
def test_two_projects_independent_pipeline()
def test_shared_voice_registry_reuse()
def test_web_ui_project_switching()
def test_concurrent_generation_different_projects()
Migration Checklist
- Create
projects/directory structure - Add
ProjectManagerclass (umbraaudio/project_manager.py) - Update
load_config()to accept project_slug - Add
projectcommand group to CLI - Update all CLI commands to accept
--projectflag - Update web routes to be project-scoped
- Add project selector to web UI navbar
- Create
/projects/newpage - Create
/api/projectsendpoints - Add global
projects.dbfor registry - Implement legacy migration on startup
- Update
PipelineManagerto use project-scoped data_dir - Update
GenerationManagerto use project-scoped data_dir - Update all scripts to accept
--projectargument - Add tests for multi-project isolation
- Update documentation
Backward Compatibility
| Scenario | Behavior |
|---|---|
Legacy .umbra/ exists, no projects/ |
Auto-migrate on first run |
UMBRA_DATA_DIR env var set |
Used as-is (legacy mode) |
--project not specified |
Uses default from index.json |
Scripts without --project |
Use default project |
Web UI accessed at / |
Redirects to /projects/{default}/ |
Future Considerations
- Project Templates: Pre-defined voice configs, quality settings per genre
- Project Cloning: Duplicate project structure for series books
- Shared Character Library: Export/import characters across projects
- Multi-Book Series: Parent project with child projects (volumes)
- Remote Project Sync: Push/pull project data to cloud storage
- Project Permissions: Read-only, collaborator, admin roles for web UI