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) <noreply@anthropic.com>
This commit is contained in:
david
2026-05-19 17:15:06 +02:00
parent ecd9984419
commit a997445970
6 changed files with 363 additions and 1 deletions

3
.gitignore vendored
View File

@@ -4,10 +4,11 @@ dist
*.tsbuildinfo *.tsbuildinfo
coverage coverage
# Environments — never commit; .env.example is the published template # Environments — never commit; .env.example / .env.*.example are templates
.env .env
.env.* .env.*
!.env.example !.env.example
!.env.*.example
# Local data / caches (matches at any depth — covers # Local data / caches (matches at any depth — covers
# deploy/simulation/data/, indexer/data/, etc.) # deploy/simulation/data/, indexer/data/, etc.)

View File

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

View File

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

View File

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

View File

@@ -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())

85
supplier/.env.tts.example Normal file
View File

@@ -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>#<int-output-index>"
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=