Files
UmbraAudio/specs/15-multi-project.md
B.D. Murphy c86e323d7a Initial commit: UmbraAudio audiobook generation program
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.
2026-08-30 07:58:24 -07:00

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 --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

# 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:

  • project table (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 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