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.
435 lines
14 KiB
Markdown
435 lines
14 KiB
Markdown
# 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`)
|
|
|
|
```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)
|
|
|
|
```yaml
|
|
# 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`)
|
|
|
|
```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
|
|
|
|
```bash
|
|
# 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 `--project` not specified, uses `default_project` from `projects/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)
|
|
|
|
1. User clicks "New Project" → `/projects/new`
|
|
2. Form: title, author, file upload, optional slug (auto-generated from title)
|
|
3. POST `/projects` → creates project directory, saves manuscript, returns project slug
|
|
4. Redirect to `/projects/{slug}/` (dashboard)
|
|
5. Background parsing starts automatically
|
|
|
|
### API Endpoints
|
|
|
|
```python
|
|
# 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)
|
|
|
|
```sql
|
|
-- 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:
|
|
- `project` table (single row per project)
|
|
- `chapters`, `characters`, `utterances`, `generated_audio`, etc.
|
|
- No cross-project foreign keys
|
|
|
|
### Migration from Legacy Single-Project
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
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`:
|
|
|
|
```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**:
|
|
|
|
```sql
|
|
-- 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
|
|
|
|
```python
|
|
# 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
|
|
|
|
```python
|
|
# 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 `ProjectManager` class (`umbraaudio/project_manager.py`)
|
|
- [ ] Update `load_config()` to accept project_slug
|
|
- [ ] Add `project` command group to CLI
|
|
- [ ] Update all CLI commands to accept `--project` flag
|
|
- [ ] Update web routes to be project-scoped
|
|
- [ ] Add project selector to web UI navbar
|
|
- [ ] Create `/projects/new` page
|
|
- [ ] Create `/api/projects` endpoints
|
|
- [ ] Add global `projects.db` for registry
|
|
- [ ] Implement legacy migration on startup
|
|
- [ ] Update `PipelineManager` to use project-scoped data_dir
|
|
- [ ] Update `GenerationManager` to use project-scoped data_dir
|
|
- [ ] Update all scripts to accept `--project` argument
|
|
- [ ] 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
|
|
|
|
1. **Project Templates**: Pre-defined voice configs, quality settings per genre
|
|
2. **Project Cloning**: Duplicate project structure for series books
|
|
3. **Shared Character Library**: Export/import characters across projects
|
|
4. **Multi-Book Series**: Parent project with child projects (volumes)
|
|
5. **Remote Project Sync**: Push/pull project data to cloud storage
|
|
6. **Project Permissions**: Read-only, collaborator, admin roles for web UI |