From a99744597085ab5c00f978d3cb6a5d8b29c0a283 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 19 May 2026 17:15:06 +0200 Subject: [PATCH] deploy/mainnet: add PiperTTS (private) + supplier-tts (audio.synthesize.piper.v1) Mirrors deploy/testnet/docker-compose.supplier-tts.yml and the simulation PiperTTS stack, co-located on vector-marketplace as a private internal service on marketplace-mainnet-net (no Traefik labels, no public DNS for Piper itself). Supplier reaches Piper at http://marketplace-mainnet-tts-piper:8000. Voice: en_US-lessac-high. New supplier wallet + on-chain advert are published out-of-band by the operator per the runbook header. Files: - deploy/mainnet/docker-compose.tts-piper.yml openedai-speech-min, private - deploy/mainnet/docker-compose.supplier-tts.yml CAPABILITY_KIND=tts supplier - deploy/mainnet/install-voice-extras.sh voice download + yaml mapping - deploy/mainnet/voice-mappings.py idempotent yaml editor - supplier/.env.tts.example operator env template - .gitignore whitelist .env.*.example Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 +- .../mainnet/docker-compose.supplier-tts.yml | 81 ++++++++++++++++++ deploy/mainnet/docker-compose.tts-piper.yml | 61 +++++++++++++ deploy/mainnet/install-voice-extras.sh | 74 ++++++++++++++++ deploy/mainnet/voice-mappings.py | 60 +++++++++++++ supplier/.env.tts.example | 85 +++++++++++++++++++ 6 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 deploy/mainnet/docker-compose.supplier-tts.yml create mode 100644 deploy/mainnet/docker-compose.tts-piper.yml create mode 100755 deploy/mainnet/install-voice-extras.sh create mode 100644 deploy/mainnet/voice-mappings.py create mode 100644 supplier/.env.tts.example diff --git a/.gitignore b/.gitignore index d0d754e..060aac8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,11 @@ dist *.tsbuildinfo coverage -# Environments — never commit; .env.example is the published template +# Environments — never commit; .env.example / .env.*.example are templates .env .env.* !.env.example +!.env.*.example # Local data / caches (matches at any depth — covers # deploy/simulation/data/, indexer/data/, etc.) diff --git a/deploy/mainnet/docker-compose.supplier-tts.yml b/deploy/mainnet/docker-compose.supplier-tts.yml new file mode 100644 index 0000000..b0754cc --- /dev/null +++ b/deploy/mainnet/docker-compose.supplier-tts.yml @@ -0,0 +1,81 @@ +# deploy/mainnet/docker-compose.supplier-tts.yml — TTS Supplier — MAINNET +# +# Third mainnet supplier, dedicated to capability `audio.synthesize.piper.v1`. +# Reaches the colocated PRIVATE PiperTTS container at the docker-DNS name +# `marketplace-mainnet-tts-piper:8000`. Traefik publishes it at +# `mp-suppliers-tts.vector.apexfusion.org`. Coexists with the ollama-backed +# (docker-compose.supplier.yml) and ChatGPT-backed (docker-compose.supplier-gpt.yml) +# suppliers. +# +# Pre-flight networks (must already exist): +# docker network create marketplace-mainnet-net # one-shot +# # traefik-net is created by the Traefik project +# +# Pre-flight services: +# docker compose -f docker-compose.tts-piper.yml up -d +# bash deploy/mainnet/install-voice-extras.sh # one-shot voice install +# +# Bring up: +# docker compose -f deploy/mainnet/docker-compose.supplier-tts.yml up -d --build + +name: marketplace-mainnet-supplier-tts + +services: + supplier: + build: + context: ../.. + dockerfile: supplier/Dockerfile + container_name: marketplace-mainnet-supplier-tts + restart: unless-stopped + env_file: + # Self-contained env for the TTS variant — kept separate from the + # canonical ../../supplier/.env (ollama-backed) and ../../supplier/.env.gpt + # (ChatGPT-backed). Holds this supplier's distinct SUPPLIER_PRIV_KEY_HEX, + # ADVERT_REF, ADVERT_*, and the shared mainnet plumbing duplicated + # in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS, ref-script + # UTxOs, LIVE_CHAIN). No env_file ordering / override magic. + - path: ../../supplier/.env.tts + required: false + environment: + CAPABILITY_KIND: "tts" + # Internal docker-DNS — Piper is private on marketplace-mainnet-net, + # no public hostname for it. + PIPER_URL: "http://marketplace-mainnet-tts-piper:8000" + PORT: "8080" + # Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast. + # Mirrors the other mainnet supplier composes; without this the + # supplier silently falls back to ReadOnlyOgmiosProvider and + # Claim/Submit never reach chain. + LIVE_CHAIN: "1" + # NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST + # come from the operator-provided supplier/.env.tts on a mainnet host. + # Setting them via compose would be a footgun: a stray re-up on + # testnet hardware could silently re-tag the supplier as mainnet. + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"] + interval: 20s + timeout: 5s + retries: 3 + start_period: 15s + networks: + - marketplace-mainnet-net + - traefik-net + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-net" + - "traefik.http.routers.mp-suppliers-tts-mainnet.entrypoints=web" + - "traefik.http.routers.mp-suppliers-tts-mainnet.rule=Host(`mp-suppliers-tts.vector.apexfusion.org`)" + - "traefik.http.services.mp-suppliers-tts-mainnet.loadbalancer.server.port=8080" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +networks: + marketplace-mainnet-net: + external: true + name: marketplace-mainnet-net + traefik-net: + external: true + name: traefik-net diff --git a/deploy/mainnet/docker-compose.tts-piper.yml b/deploy/mainnet/docker-compose.tts-piper.yml new file mode 100644 index 0000000..5fd8eee --- /dev/null +++ b/deploy/mainnet/docker-compose.tts-piper.yml @@ -0,0 +1,61 @@ +# deploy/mainnet/docker-compose.tts-piper.yml — PiperTTS (PRIVATE) — MAINNET +# +# OpenAI-shape /v1/audio/speech served by openedai-speech-min, reachable +# ONLY from inside marketplace-mainnet-net. No Traefik labels, no public +# DNS — the colocated marketplace-mainnet-supplier-tts container hits it at +# http://marketplace-mainnet-tts-piper:8000 +# +# Mirrors deploy/simulation/docker-compose.tts-piper.yml, but private: +# the simulation host exposes Piper publicly via Traefik+Cloudflare; +# mainnet keeps it on-net only since only the colocated TTS supplier +# consumes it. +# +# Pre-flight networks (must already exist): +# docker network create marketplace-mainnet-net # one-shot +# +# Bring up: +# docker compose -f deploy/mainnet/docker-compose.tts-piper.yml up -d +# bash deploy/mainnet/install-voice-extras.sh # download voices + yaml mapping + +name: marketplace-mainnet-tts-piper + +services: + tts-piper: + image: ghcr.io/matatonic/openedai-speech-min:latest + container_name: marketplace-mainnet-tts-piper + restart: unless-stopped + environment: + # Pre-load Piper's highest-quality American-English voice so first + # /v1/audio/speech doesn't pay the model-download tax. Other voices + # can be added via install-voice-extras.sh + voice-mappings.py. + PRELOAD_MODEL: en_US-lessac-high + PORT: "8000" + volumes: + # Host bind mounts (not Docker named volumes) so the operator can + # ls/du/back up the downloaded Piper voices directly. Paths relative + # to THIS compose file, i.e. deploy/mainnet/data/tts-piper/{voices,config} + # from the repo root. install-voice-extras.sh creates them. + - ./data/tts-piper/voices:/app/voices + - ./data/tts-piper/config:/app/config + healthcheck: + # /v1/models is the OpenAI-shape model list; a 200 here proves the + # server is up and at least one voice is loaded. Image ships with + # curl (and python3) but NOT wget — using wget here marks the + # container unhealthy on every probe. + test: ["CMD-SHELL", "curl -fsS http://localhost:8000/v1/models >/dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 90s + networks: + - marketplace-mainnet-net # PRIVATE — no traefik-net join + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +networks: + marketplace-mainnet-net: + external: true + name: marketplace-mainnet-net diff --git a/deploy/mainnet/install-voice-extras.sh b/deploy/mainnet/install-voice-extras.sh new file mode 100755 index 0000000..6d4aa1a --- /dev/null +++ b/deploy/mainnet/install-voice-extras.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# install-voice-extras.sh — runs ON THE HOST (vector-marketplace, as root) +# after `docker compose -f deploy/mainnet/docker-compose.tts-piper.yml up -d` +# to install Piper voices and yaml mappings beyond what the +# openedai-speech-min image ships with. +# +# Idempotent: every step is a no-op if it already finished previously. +# Safe to re-run after a host wipe — a fresh `data/` dir gets the same +# voices back. +# +# Mirrors deploy/simulation/install-voice-extras.sh; differs only in: +# - CONTAINER name (mainnet-prefixed) +# - resolves VOICES_DIR + voice-mappings.py relative to deploy/mainnet/ +# +# What it does (in order): +# 1. Ensure the bind-mount voice files are present (curl -sSL with `[ -s ]` guard). +# 2. Copy voice-mappings.py into the container and execute it as root. +# The script edits /app/config/voice_to_speaker.yaml (host bind-mount) to +# add custom voice aliases. It prints "modified" or "unchanged" — we +# restart only on the former. +# 3. Restart the container if either step changed something. +# +# Usage: +# bash deploy/mainnet/install-voice-extras.sh + +set -euo pipefail + +MAINNET_DIR="$(cd "$(dirname "$0")" && pwd)" +VOICES_DIR="$MAINNET_DIR/data/tts-piper/voices" +CONTAINER="marketplace-mainnet-tts-piper" + +mkdir -p "$VOICES_DIR" +changes=0 + +# --- 1. Voice files to install (filename → URL) --- +# Add entries here when you want another voice baked into the deploy. +# Each .onnx pairs with a .onnx.json — both must be downloaded. +voices=( + "en_US-lessac-high.onnx|https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/high/en_US-lessac-high.onnx" + "en_US-lessac-high.onnx.json|https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/high/en_US-lessac-high.onnx.json" +) + +for entry in "${voices[@]}"; do + fn="${entry%%|*}" + url="${entry##*|}" + if [ -s "$VOICES_DIR/$fn" ]; then + echo "voice: already installed — $fn" + continue + fi + echo "voice: downloading $fn" + # `--fail` so HTTP 4xx/5xx aborts (HF returns html on 404 by default). + # Download to a tmp file then mv, so a partial download from a network + # blip doesn't leave a half-file that the next run treats as installed. + tmp="$VOICES_DIR/.${fn}.partial" + curl -sSL --fail --output "$tmp" "$url" + mv "$tmp" "$VOICES_DIR/$fn" + changes=1 +done + +# --- 2. Yaml mapping additions inside the container --- +docker cp "$MAINNET_DIR/voice-mappings.py" "$CONTAINER:/tmp/voice-mappings.py" >/dev/null +mapping_output="$(docker exec "$CONTAINER" python3 /tmp/voice-mappings.py)" +echo "mapping: $mapping_output" +if [[ "$mapping_output" == modified* ]]; then + changes=1 +fi + +# --- 3. Restart only if anything changed --- +if [ "$changes" -eq 1 ]; then + echo "→ restarting $CONTAINER to pick up new voices/mappings" + docker restart "$CONTAINER" >/dev/null +else + echo "✓ voice extras already up to date — no restart needed" +fi diff --git a/deploy/mainnet/voice-mappings.py b/deploy/mainnet/voice-mappings.py new file mode 100644 index 0000000..61422a9 --- /dev/null +++ b/deploy/mainnet/voice-mappings.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# voice-mappings.py — idempotently apply project-specific voice aliases to +# /app/config/voice_to_speaker.yaml inside the marketplace-mainnet-tts-piper container. +# +# Why this exists: +# The openedai-speech-min image populates voice_to_speaker.yaml on first +# start with its OWN defaults (alloy/echo/fable/onyx/nova/shimmer mapped +# onto the bundled LibriTTS + northern-english-male voices). Voices we +# install ourselves (e.g. en_US-lessac-high) need a name in that yaml so +# the OpenAI-shape `voice` field can resolve to them. This script adds +# those entries — and only those — without disturbing the defaults. +# +# Idempotency contract: +# Reads the yaml, compares each desired entry against what's there, only +# rewrites if at least one entry is missing or stale. Prints "modified" +# on write, "unchanged" otherwise — install-voice-extras.sh greps for +# "modified" to decide whether to restart the container. +# +# Run via: +# docker cp voice-mappings.py marketplace-mainnet-tts-piper:/tmp/voice-mappings.py +# docker exec marketplace-mainnet-tts-piper python3 /tmp/voice-mappings.py + +import sys +import yaml + +CONFIG_PATH = "/app/config/voice_to_speaker.yaml" + +# Mappings to ensure exist under `tts-1`. Adding more voices later is a +# matter of dropping the .onnx + .onnx.json into voices/ (handled by +# install-voice-extras.sh) and adding an entry here. +DESIRED_TTS1 = { + # en_US-lessac-high — high-quality American female single-speaker voice. + # Single-speaker model, so no `speaker:` key. + "lessac": {"model": "voices/en_US-lessac-high.onnx"}, +} + + +def main() -> int: + with open(CONFIG_PATH) as f: + cfg = yaml.safe_load(f) or {} + + cfg.setdefault("tts-1", {}) + + modified_keys = [] + for name, mapping in DESIRED_TTS1.items(): + if cfg["tts-1"].get(name) != mapping: + cfg["tts-1"][name] = mapping + modified_keys.append(name) + + if modified_keys: + with open(CONFIG_PATH, "w") as f: + yaml.safe_dump(cfg, f, sort_keys=False) + print(f"modified — applied: {', '.join(modified_keys)}") + else: + print("unchanged — all mappings already present") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/supplier/.env.tts.example b/supplier/.env.tts.example new file mode 100644 index 0000000..aea10db --- /dev/null +++ b/supplier/.env.tts.example @@ -0,0 +1,85 @@ +# supplier/.env.tts.example — TTS supplier (audio.synthesize.piper.v1) — MAINNET +# +# Copy to supplier/.env.tts on the vector-marketplace host (chmod 600), +# fill secrets, then follow the runbook in deploy/mainnet/docker-compose.supplier-tts.yml +# header. .env.tts is gitignored — NEVER commit a populated copy. +# +# To generate a fresh Ed25519 wallet: +# docker compose -f deploy/mainnet/docker-compose.supplier-tts.yml run --rm \ +# --entrypoint sh supplier \ +# -c "node_modules/.bin/tsx supplier/src/cli/gen-keypair.ts --network 1" +# Fund the printed addr1... with ~5 AP3X mainnet via the Vector faucet/exchange, +# THEN paste the privateKeyHex below. + +# ─── Required ───────────────────────────────────────────────────────────── + +# 64-char hex Ed25519 private key seed for the TTS supplier wallet. SECRET. +SUPPLIER_PRIV_KEY_HEX=PASTE_64_HEX_CHARS_HERE + +# Cardano network ID. "1" = mainnet. Do NOT change this on the mainnet host. +NETWORK_ID=1 + +# Public Vector mainnet Ogmios. HTTPS for builders/queries; the supplier +# uses HTTP fetch (no WebSocket), so wss:// silently errors with +# "unknown scheme". +OGMIOS_URL=https://ogmios.vector.mainnet.apexfusion.org + +# Vector mainnet genesis time (ms). lucid-evolution's slot-config reads +# this at module load — see packages/shared/src/tx/internal/lucidContext.ts +# header for the historical footgun. +VECTOR_ZERO_TIME_MS=1756485600000 + +# Shared mainnet CIP-33 reference scripts (published once; reused across all +# mainnet suppliers/buyers). +ESCROW_REF_UTXO=c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0 +ADVERT_REF_UTXO=c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1 + +# Opt in to real Ogmios submitTx/awaitTx. Any value other than literal "1" +# leaves the supplier in ReadOnly mode (safety net). +LIVE_CHAIN=1 + +# OutputReference of THIS supplier's on-chain advertisement UTxO. +# Filled in AFTER running supplier/src/cli/post-advert.ts (see runbook step i). +# Format: "<64-hex-tx-hash>#" +ADVERT_REF=PASTE_TXHASH_HERE#0 + +# ─── Advert content (read by post-advert.ts; also stored on-chain) ──────── + +# Capability published in the advertisement. Buyers filter on this string +# when picking a TTS supplier. +ADVERT_CAPABILITY_ID=audio.synthesize.piper.v1 + +# Informational model label. The actual voice is selected per-request in +# the body (`voice: lessac` etc.); this string just helps buyers identify +# the supplier in dashboards/UIs. +ADVERT_MODEL=piper-en_US-lessac-high + +# Token budget — metadata-only for TTS. The runtime always reports 0 tokens +# in receipts (see supplier/src/jobRunner.ts `runTtsJob`). Verify against +# the live testnet TTS advert before publishing if you want strict parity: +# curl https://mp-indexer.vector.testnet.apexfusion.org/suppliers \ +# | jq '.[] | select(.capability_id=="audio.synthesize.piper.v1") | .max_output_tokens' +ADVERT_MAX_OUTPUT_TOKENS=0 + +# Maximum processing time the supplier guarantees per synthesis (ms). +ADVERT_MAX_PROCESSING_MS=60000 + +# Flat price per synthesis in AP3X lovelace. +ADVERT_PRICE_LOVELACE=2000000 + +# Supplier bond locked into the advert UTxO (lovelace). +ADVERT_SUPPLIER_BOND_LOVELACE=1000000 + +# Buyer bond required per escrow (lovelace). +ADVERT_BUYER_BOND_LOVELACE=1000000 + +# HTTPS endpoint buyers will call. Must match the Traefik Host rule in +# deploy/mainnet/docker-compose.supplier-tts.yml. +ADVERT_ENDPOINT_URL=https://mp-suppliers-tts.vector.apexfusion.org + +# Optional off-chain detail pointer (IPFS / HTTPS URI). Leave empty for v1. +ADVERT_DETAIL_URI= + +# Optional sha256 of the detail JSON (64 hex chars). If empty, defaults to +# sha256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. +ADVERT_DETAIL_HASH=