Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,9 @@ GEMINI.md
ds-bundle/
.design-sync/.cache/
.design-sync/learnings/
.design-sync/node_modules
.design-sync/node_modules

# Local dev artifacts (never commit)
.DS_Store
venv/
extension/_metadata/
31 changes: 24 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -711,27 +711,44 @@ Optional narrator voice for scenes. Uses [OmniVoice](https://github.com/tuannguy

### Setup

See `skills/fk-gen-tts-template.md` for full install guide. Quick version:
See `skills/fk-gen-tts-template.md` for the full install guide. The launcher uses the FlowKit venv
at `$HOME/.venvs/flowkit`; install OmniVoice there:

```bash
pip install torch==2.8.0 torchaudio==2.8.0 # or +cu128 for NVIDIA
pip install omnivoice
python3 -c "from omnivoice import OmniVoice; print('OK')"
$HOME/.venvs/flowkit/bin/python -m pip install torch==2.8.0 torchaudio==2.8.0
$HOME/.venvs/flowkit/bin/python -m pip install omnivoice
$HOME/.venvs/flowkit/bin/python -c 'from omnivoice import OmniVoice; print("OmniVoice OK")'
```

If OmniVoice is in a separate venv, point to it:
The local subprocess defaults to the interpreter that launched FlowKit, so `TTS_PYTHON_BIN` is
optional. Set it only when OmniVoice intentionally lives in a separate venv.

### Optional remote OmniVoice API

`agent/omnivoice_api.py` is a standalone warm FastAPI service. It loads the model once and exposes
`POST /v1/tts` as multipart form data (`text`, `speed`, optional `instruct`, `ref_text`, and WAV
file `ref_audio`), returning raw `audio/wav` bytes. Run it from the FlowKit checkout:

```bash
export TTS_PYTHON_BIN=/path/to/omnivoice-venv/bin/python3
$HOME/.venvs/flowkit/bin/python -m pip install python-multipart
./flowkit/run-omnivoice-api.sh
```

The main FlowKit API can call it by setting `TTS_BACKEND=remote` and `TTS_REMOTE_URL=http://127.0.0.1:8200`.
The remote backend reads each local `ref_audio` WAV, uploads its bytes, validates the returned WAV,
and writes it atomically to the existing local output path. For a non-loopback deployment, set
`OMNIVOICE_API_TOKEN` on the inference service and the matching `TTS_REMOTE_TOKEN` on FlowKit,
use HTTPS, and do not expose the service publicly without authentication.

### Workflow

1. **Create voice template** — `/fk-gen-tts-template` — generates an anchor voice WAV
2. **Add narrator text** to scenes — `PATCH /api/scenes/{id}` with `narrator_text`
3. **Generate narration** — `/fk-gen-narrator` — voice-clones the template for each scene
4. **Concat with narration** — `/fk-concat-fit-narrator` — trims scene videos to match TTS duration

CPU-only recommended (MPS produces artifacts). ~15-30s per scene.
CPU-only is recommended for the local backend (MPS produces artifacts). Remote mode is serialized
per scene and keeps the model warm in the standalone inference service.

## YouTube Upload Pipeline

Expand Down
34 changes: 30 additions & 4 deletions agent/api/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

from agent.config import BASE_DIR, USE_BATCH_RPC
from agent.config import BASE_DIR, USE_BATCH_RPC, FLOW_PROJECT_ID
from agent.models.project import Project, ProjectCreate, ProjectUpdate
from agent.models.character import Character
from agent.sdk.persistence.sqlite_repository import SQLiteRepository
Expand Down Expand Up @@ -172,8 +172,9 @@ async def create(body: ProjectCreate):

detected_tier = await _detect_user_tier(client)

# On the batch path Flow no longer creates projects for us — the uuid comes
# from the request or from FLOW_PROJECT_ID. The legacy path still mints one.
# On the batch path a pinned FLOW_PROJECT_ID (or a uuid passed in the request)
# is reused; with neither, create_project now mints one via jHPbke. The legacy
# path always mints one.
flow_project_id = client.flow_project_id(body.flow_project_id) if USE_BATCH_RPC else None
if flow_project_id:
logger.info("Flow project reused: %s", flow_project_id)
Expand All @@ -186,6 +187,15 @@ async def create(body: ProjectCreate):

repo = _get_repo()

# Reuse is idempotent: a pinned or request-supplied project id may already
# have a local row. Re-inserting that id violates the primary key
# (sqlite3.IntegrityError -> 500), so hand back the row we already have. A
# freshly minted id never matches, so this is a no-op on the create path.
existing = await repo.get_project(flow_project_id)
if existing:
logger.info("Flow project already tracked, reusing: %s", flow_project_id)
return existing

# Step 2: Create local project with the Flow-assigned ID and detected tier
create_data = body.model_dump(exclude_none=True)
create_data.pop("tool_name", None)
Expand Down Expand Up @@ -259,9 +269,25 @@ async def update(pid: str, body: ProjectUpdate):

@router.delete("/{pid}")
async def delete(pid: str):
"""Delete a project locally, and on Flow too when it is safe.

The project must exist locally before anything is deleted, so a stray uuid
cannot reach through to Flow — only projects Flow Kit already tracks are
eligible. Flow is then deleted first (except the pinned FLOW_PROJECT_ID, the
shared/operator project, which is only ever removed locally) so a remote
failure leaves the local row in place rather than letting the two drift.
"""
repo = _get_repo()
if not await repo.delete_project(pid):
if not await repo.get_project(pid):
raise HTTPException(404, "Project not found")
if USE_BATCH_RPC and pid != (FLOW_PROJECT_ID or None):
client = get_flow_client()
if not client.connected:
raise HTTPException(503, "Extension not connected — cannot delete this project on Google Flow")
result = await client.delete_project(pid)
if result.get("error"):
raise HTTPException(502, f"Flow delete failed: {result['error']}")
await repo.delete_project(pid)
return {"ok": True}


Expand Down
4 changes: 4 additions & 0 deletions agent/api/tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ async def narrate_video(vid: str, body: NarrateVideoRequest):
logger.info("Auto-resolved ref_text from template '%s'", tmpl["name"])
break

# The project/template fallback is operator data too; revalidate after resolution so remote TTS
# can never read and upload a path that bypassed the request-body check above.
if ref_audio:
_validate_ref_audio(ref_audio)
project_name = project.get("name") or "unnamed_project"
project_slug = slugify(project_name)
out_dir = OUTPUT_DIR / project_slug / "tts"
Expand Down
16 changes: 16 additions & 0 deletions agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,25 @@
MUSIC_OUTPUT_DIR = SHARED_OUTPUT_DIR / "music"

# ─── TTS (OmniVoice) ─────────────────────────────────────────
TTS_BACKEND = os.environ.get("TTS_BACKEND", "local").strip().lower()
TTS_MODEL = os.environ.get("TTS_MODEL", "k2-fsa/OmniVoice")
TTS_DEVICE = os.environ.get("TTS_DEVICE", "cpu") # MPS produces gibberish; CPU+fp32 works
TTS_SAMPLE_RATE = int(os.environ.get("TTS_SAMPLE_RATE", "24000"))
TTS_REMOTE_URL = os.environ.get("TTS_REMOTE_URL", "").strip().rstrip("/")
TTS_REMOTE_TOKEN = os.environ.get("TTS_REMOTE_TOKEN", "")
TTS_REMOTE_TIMEOUT_S = float(os.environ.get("TTS_REMOTE_TIMEOUT_S", "180"))
TTS_REMOTE_MAX_BYTES = int(os.environ.get("TTS_REMOTE_MAX_BYTES", str(50 * 1024 * 1024)))

# ─── Standalone OmniVoice API ────────────────────────────────
OMNIVOICE_API_HOST = os.environ.get("OMNIVOICE_API_HOST", "127.0.0.1")
OMNIVOICE_API_PORT = int(os.environ.get("OMNIVOICE_API_PORT", "8200"))
OMNIVOICE_API_TOKEN = os.environ.get("OMNIVOICE_API_TOKEN", "")
OMNIVOICE_MAX_REF_AUDIO_BYTES = int(
os.environ.get("OMNIVOICE_MAX_REF_AUDIO_BYTES", str(10 * 1024 * 1024))
)
OMNIVOICE_MAX_REQUEST_BYTES = int(
os.environ.get("OMNIVOICE_MAX_REQUEST_BYTES", str(12 * 1024 * 1024))
)

# ─── Review / Claude Vision ──────────────────────────────────
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
Expand Down
224 changes: 224 additions & 0 deletions agent/omnivoice_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
"""Standalone warm OmniVoice HTTP service for remote FlowKit TTS."""
from __future__ import annotations

import asyncio
import hmac
import io
import math
import tempfile
import wave
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional

from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse, Response

from agent.config import (
OMNIVOICE_API_HOST,
OMNIVOICE_API_PORT,
OMNIVOICE_API_TOKEN,
OMNIVOICE_MAX_REF_AUDIO_BYTES,
OMNIVOICE_MAX_REQUEST_BYTES,
TTS_DEVICE,
TTS_MODEL,
TTS_SAMPLE_RATE,
)

_model = None
_inference_lock = asyncio.Lock()


def _validate_wav_bytes(audio_bytes: bytes) -> None:
if len(audio_bytes) < 12 or audio_bytes[:4] != b"RIFF" or audio_bytes[8:12] != b"WAVE":
raise HTTPException(status_code=400, detail="ref_audio must be a WAV file")
try:
with wave.open(io.BytesIO(audio_bytes), "rb") as wav:
frame_count = wav.getnframes()
frame_width = wav.getnchannels() * wav.getsampwidth()
if frame_count <= 0 or wav.getframerate() <= 0 or frame_width <= 0:
raise HTTPException(status_code=400, detail="ref_audio must contain audio")
if len(wav.readframes(frame_count)) != frame_count * frame_width:
raise HTTPException(status_code=400, detail="ref_audio is truncated")
except (wave.Error, EOFError) as error:
raise HTTPException(status_code=400, detail="ref_audio is an invalid WAV file") from error


def _load_model():
global _model
import torch
from omnivoice import OmniVoice

dtype = torch.float16 if TTS_DEVICE.startswith("cuda") else torch.float32
_model = OmniVoice.from_pretrained(TTS_MODEL, device_map=TTS_DEVICE, dtype=dtype)


def _generate_wav_bytes(
text: str,
instruct: Optional[str],
ref_text: Optional[str],
ref_audio_bytes: Optional[bytes],
speed: float,
) -> bytes:
import torchaudio

if _model is None:
raise RuntimeError("OmniVoice model is not loaded")

with tempfile.TemporaryDirectory(prefix="omnivoice-") as temp_dir:
ref_audio_path = None
if ref_audio_bytes is not None:
ref_audio_path = Path(temp_dir) / "reference.wav"
ref_audio_path.write_bytes(ref_audio_bytes)

output_path = Path(temp_dir) / "output.wav"
kwargs = {"text": text}
if ref_audio_path is not None and ref_text:
kwargs["ref_audio"] = str(ref_audio_path)
kwargs["ref_text"] = ref_text
elif instruct:
kwargs["instruct"] = instruct
if speed != 1.0:
kwargs["speed"] = speed

audio = _model.generate(**kwargs)
torchaudio.save(str(output_path), audio[0], TTS_SAMPLE_RATE)
return output_path.read_bytes()


def _authorization_header_valid(actual: str) -> bool:
if not OMNIVOICE_API_TOKEN:
return True
return hmac.compare_digest(actual, f"Bearer {OMNIVOICE_API_TOKEN}")


def _authorization_valid(request: Request) -> bool:
return _authorization_header_valid(request.headers.get("authorization", ""))


async def _require_authorized(request: Request) -> None:
if not _authorization_valid(request):
raise HTTPException(status_code=401, detail="Invalid bearer token")


@asynccontextmanager
async def lifespan(app: FastAPI):
if OMNIVOICE_API_HOST not in {"127.0.0.1", "localhost", "::1"} and not OMNIVOICE_API_TOKEN:
raise RuntimeError("OMNIVOICE_API_TOKEN is required when the API is not loopback-only")
await asyncio.to_thread(_load_model)
yield


app = FastAPI(title="OmniVoice TTS", version="1.0.0", lifespan=lifespan)


class _RequestTooLarge(Exception):
pass


async def _send_json_error(send, status_code: int, detail: str) -> None:
body = f'{{"detail":"{detail}"}}'.encode("utf-8")
await send({"type": "http.response.start", "status": status_code, "headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode("ascii")),
]})
await send({"type": "http.response.body", "body": body})


class _TtsRequestLimitMiddleware:
def __init__(self, app):
self.app = app

async def __call__(self, scope, receive, send):
if scope.get("type") != "http" or scope.get("path") != "/v1/tts":
await self.app(scope, receive, send)
return

request_headers = {key.lower(): value for key, value in scope.get("headers", [])}
authorization = request_headers.get(b"authorization", b"").decode("latin-1")
if not _authorization_header_valid(authorization):
await _send_json_error(send, 401, "Invalid bearer token")
return

content_length = request_headers.get(b"content-length")
if content_length is not None:
try:
if int(content_length) > OMNIVOICE_MAX_REQUEST_BYTES:
await _send_json_error(send, 413, "request is too large")
return
except ValueError:
await _send_json_error(send, 400, "invalid content-length")
return

received = 0

async def limited_receive():
nonlocal received
message = await receive()
if message["type"] == "http.request":
received += len(message.get("body", b""))
if received > OMNIVOICE_MAX_REQUEST_BYTES:
raise _RequestTooLarge
return message

try:
await self.app(scope, limited_receive, send)
except _RequestTooLarge:
await _send_json_error(send, 413, "request is too large")


app.add_middleware(_TtsRequestLimitMiddleware)

@app.get("/health")
async def health():
return {"ok": _model is not None, "model": TTS_MODEL, "device": TTS_DEVICE}


@app.post("/v1/tts", response_class=Response)
async def synthesize(
request: Request,
text: str = Form(...),
speed: float = Form(1.0),
instruct: Optional[str] = Form(None),
ref_text: Optional[str] = Form(None),
ref_audio: Optional[UploadFile] = File(None),
):
await _require_authorized(request)
if not text or len(text) > 5000:
raise HTTPException(status_code=422, detail="text must contain 1-5000 characters")
if not math.isfinite(speed) or speed < 0.5 or speed > 3.0:
raise HTTPException(status_code=422, detail="speed must be between 0.5 and 3.0")
if instruct is not None and len(instruct) > 200:
raise HTTPException(status_code=422, detail="instruct must contain at most 200 characters")
if ref_text is not None and len(ref_text) > 5000:
raise HTTPException(status_code=422, detail="ref_text is too long")

ref_audio_bytes = None
if ref_audio is not None:
ref_audio_bytes = await ref_audio.read(OMNIVOICE_MAX_REF_AUDIO_BYTES + 1)
if len(ref_audio_bytes) > OMNIVOICE_MAX_REF_AUDIO_BYTES:
raise HTTPException(status_code=413, detail="ref_audio is too large")
_validate_wav_bytes(ref_audio_bytes)

async with _inference_lock:
try:
wav_bytes = await asyncio.to_thread(
_generate_wav_bytes,
text,
instruct,
ref_text,
ref_audio_bytes,
speed,
)
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=500, detail="OmniVoice synthesis failed")

return Response(content=wav_bytes, media_type="audio/wav")


if __name__ == "__main__":
import uvicorn

uvicorn.run(app, host=OMNIVOICE_API_HOST, port=OMNIVOICE_API_PORT)
Loading