supplier: TTS capability scaffolding (audio.synthesize.piper.v1)
Path-A foundation: extend the supplier service so the same on-chain
escrow lifecycle can serve a PiperTTS supplier alongside the existing
Ollama-backed chat one. Capability dispatch is boot-time; one process
serves one capability via a single advert.
Receipt mapping for TTS is committed inline:
- prompt_hash = sha256(canonical({text, voice, format, speed}))
- result_hash = sha256(audio_bytes)
- prompt_tokens / completion_tokens = 0 (off-chain only; Piper has no
token semantics, on-chain validator never reads these fields)
New / changed files
- supplier/src/piper.ts (new): mirror of ollama.ts. callPiper(...)
POSTs OpenAI-shape /v1/audio/speech, returns {audio: Uint8Array,
contentType, wallclock_ms}. Typed errors: piper_failure | piper_
timeout | piper_malformed (with a 64-byte minimum to catch upstream
"200 OK, body length 0" cold-start)
- supplier/src/config.ts: + CapabilityKind = "chat" | "tts", + piperUrl
/ piperTimeoutMs. CAPABILITY_KIND env defaults to "chat" so existing
chat suppliers keep booting unchanged
- supplier/src/jobs.ts: JobResponsePayload becomes a discriminated
union (ChatJobResponsePayload | TtsJobResponsePayload). TTS variant
carries audio_b64 + content_type + byte_length so the in-memory
store stays JSON-clean and the GET poll route returns audio without
binary content-type negotiation
- supplier/src/jobRunner.ts: + runTtsJob (parallels runChatJob with
same 7-step shape: setRunning -> upstream -> receipt -> buildSubmit
-> awaitTx -> complete -> finally release)
- supplier/src/server.ts: + makeTtsHandler (POST /v1/audio/synthesize)
+ makeGetTtsJobHandler (poll). createApp dispatches by config.
capabilityKind: TTS supplier exposes the audio routes only, chat
supplier exposes the chat routes only. The on-chain capability_id
check on every request is belt-and-braces
Tests
- tests/unit/supplier-piper.test.ts: 9 tests (request shape, success,
network throw, AbortError -> piper_timeout, too-small body ->
piper_malformed, missing content-type fallback)
- tests/unit/supplier-routes-tts.test.ts: 9 tests (capability-routed
mounting, all 400 validation paths, sanity that the route is reachable)
- tests/fixtures/supplier-side/sample-config.ts: + buildSampleTtsConfig
243 supplier tests passing; chat path behaviourally untouched.
This commit is contained in:
13
tests/fixtures/supplier-side/sample-config.ts
vendored
13
tests/fixtures/supplier-side/sample-config.ts
vendored
@@ -28,10 +28,23 @@ export function buildSampleConfig(): SupplierConfig {
|
||||
networkId: 0,
|
||||
port: 8080,
|
||||
ollamaTimeoutMs: 120_000,
|
||||
piperUrl: "",
|
||||
piperTimeoutMs: 120_000,
|
||||
capabilityKind: "chat",
|
||||
liveChain: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** TTS variant: same wallet/chain plumbing, capabilityKind="tts" + Piper URL set. */
|
||||
export function buildSampleTtsConfig(): SupplierConfig {
|
||||
return {
|
||||
...buildSampleConfig(),
|
||||
ollamaUrl: "",
|
||||
piperUrl: "http://piper.fake",
|
||||
capabilityKind: "tts",
|
||||
};
|
||||
}
|
||||
|
||||
/** Env map matching the sample config above, for loadConfig() unit tests. */
|
||||
export function buildSampleEnv(): Record<string, string> {
|
||||
return {
|
||||
|
||||
140
tests/unit/supplier-piper.test.ts
Normal file
140
tests/unit/supplier-piper.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* supplier-piper.test.ts — coverage for supplier/src/piper.ts.
|
||||
*
|
||||
* Mirrors the ollama tests: mocks global.fetch (no real PiperTTS host
|
||||
* required) and asserts request shape, success path, and the typed error
|
||||
* reasons (`piper_failure`, `piper_timeout`, `piper_malformed`).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { callPiper, PiperError } from "../../supplier/src/piper.js";
|
||||
|
||||
const PIPER_URL = "http://piper.fake";
|
||||
const TEXT = "Hello from the marketplace.";
|
||||
const VOICE = "nova";
|
||||
const FORMAT = "mp3";
|
||||
const SPEED = 1.0;
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
function makeAudioBuffer(byteLength: number): ArrayBuffer {
|
||||
const buf = new ArrayBuffer(byteLength);
|
||||
// Fill with mp3-frame-header-ish bytes so the buffer isn't all-zeros.
|
||||
const view = new Uint8Array(buf);
|
||||
for (let i = 0; i < byteLength; i++) view[i] = (i * 37) & 0xff;
|
||||
return buf;
|
||||
}
|
||||
|
||||
function makeFetchOk(body: ArrayBuffer, contentType = "audio/mpeg") {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: (k: string) => (k.toLowerCase() === "content-type" ? contentType : null) },
|
||||
arrayBuffer: async () => body,
|
||||
});
|
||||
}
|
||||
|
||||
function makeFetchError(status: number) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status,
|
||||
statusText: "Internal Server Error",
|
||||
headers: { get: () => null },
|
||||
text: async () => "upstream piper error",
|
||||
});
|
||||
}
|
||||
|
||||
describe("callPiper() — request shape", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", makeFetchOk(makeAudioBuffer(2048)));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("POSTs to ${piperUrl}/v1/audio/speech with OpenAI-shape body", async () => {
|
||||
await callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS });
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe("http://piper.fake/v1/audio/speech");
|
||||
expect(init.method).toBe("POST");
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body).toEqual({
|
||||
model: "tts-1",
|
||||
input: TEXT,
|
||||
voice: VOICE,
|
||||
response_format: FORMAT,
|
||||
speed: SPEED,
|
||||
});
|
||||
});
|
||||
|
||||
it("strips trailing slash from piperUrl", async () => {
|
||||
await callPiper({ piperUrl: "http://piper.fake////", text: "hi", voice: "nova", format: "mp3", speed: 1.0, timeoutMs: TIMEOUT_MS });
|
||||
const [url] = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe("http://piper.fake/v1/audio/speech");
|
||||
});
|
||||
});
|
||||
|
||||
describe("callPiper() — happy path", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("returns audio bytes + content-type + measured wallclock_ms", async () => {
|
||||
const buf = makeAudioBuffer(4096);
|
||||
vi.stubGlobal("fetch", makeFetchOk(buf, "audio/mpeg"));
|
||||
const result = await callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS });
|
||||
expect(result.audio).toBeInstanceOf(Uint8Array);
|
||||
expect(result.audio.byteLength).toBe(4096);
|
||||
expect(result.contentType).toBe("audio/mpeg");
|
||||
expect(typeof result.wallclock_ms).toBe("number");
|
||||
expect(result.wallclock_ms).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("falls back to audio/<format> when upstream omits content-type", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
arrayBuffer: async () => makeAudioBuffer(128),
|
||||
}));
|
||||
const r = await callPiper({ piperUrl: PIPER_URL, text: "hi", voice: "nova", format: "wav", speed: 1.0, timeoutMs: TIMEOUT_MS });
|
||||
expect(r.contentType).toBe("audio/wav");
|
||||
});
|
||||
});
|
||||
|
||||
describe("callPiper() — error reasons", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("non-2xx HTTP → PiperError piper_failure", async () => {
|
||||
vi.stubGlobal("fetch", makeFetchError(500));
|
||||
await expect(
|
||||
callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS }),
|
||||
).rejects.toMatchObject({ name: "PiperError", reason: "piper_failure" });
|
||||
});
|
||||
|
||||
it("network throw → PiperError piper_failure", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
|
||||
await expect(
|
||||
callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS }),
|
||||
).rejects.toMatchObject({ name: "PiperError", reason: "piper_failure" });
|
||||
});
|
||||
|
||||
it("AbortError → PiperError piper_timeout", async () => {
|
||||
const abortErr = Object.assign(new Error("aborted"), { name: "AbortError" });
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(abortErr));
|
||||
await expect(
|
||||
callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS }),
|
||||
).rejects.toMatchObject({ name: "PiperError", reason: "piper_timeout" });
|
||||
});
|
||||
|
||||
it("body smaller than MIN_AUDIO_BYTES → PiperError piper_malformed", async () => {
|
||||
vi.stubGlobal("fetch", makeFetchOk(makeAudioBuffer(8)));
|
||||
await expect(
|
||||
callPiper({ piperUrl: PIPER_URL, text: TEXT, voice: VOICE, format: FORMAT, speed: SPEED, timeoutMs: TIMEOUT_MS }),
|
||||
).rejects.toMatchObject({ name: "PiperError", reason: "piper_malformed" });
|
||||
});
|
||||
|
||||
it("PiperError exports `reason` for switch dispatch", () => {
|
||||
const err = new PiperError("piper_timeout", "x");
|
||||
expect(err.reason).toBe("piper_timeout");
|
||||
expect(err.name).toBe("PiperError");
|
||||
});
|
||||
});
|
||||
131
tests/unit/supplier-routes-tts.test.ts
Normal file
131
tests/unit/supplier-routes-tts.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* supplier-routes-tts.test.ts — coverage for the audio.synthesize.piper.v1
|
||||
* supplier route added under the path-A multi-capability story.
|
||||
*
|
||||
* Focus: the NEW validation logic that lives in `makeTtsHandler`. The
|
||||
* shared lifecycle pieces (lock, claim tx, awaitTx, runner spawn) reuse
|
||||
* the chat-handler implementation and are already covered by the chat
|
||||
* route tests; we don't duplicate that coverage here.
|
||||
*
|
||||
* What we DO cover:
|
||||
* - Mounting: TTS routes appear when capabilityKind="tts" and the chat
|
||||
* route is absent (and vice-versa)
|
||||
* - 400 validation: missing X-Escrow-Ref, malformed ref, missing/invalid
|
||||
* text, voice, format, speed
|
||||
* - Wiring: route is reachable (express plumbing intact)
|
||||
*
|
||||
* End-to-end happy-path (escrow seed → claim → run → submit) is exercised
|
||||
* by the post-deploy smoke against the real testnet, not here.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import request from "supertest";
|
||||
import type { Application } from "express";
|
||||
|
||||
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
|
||||
import { SupplierState } from "../../supplier/src/state.js";
|
||||
import { JobStore } from "../../supplier/src/jobs.js";
|
||||
import { createApp } from "../../supplier/src/server.js";
|
||||
import { buildSampleConfig, buildSampleTtsConfig } from "../fixtures/supplier-side/sample-config.js";
|
||||
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
|
||||
|
||||
const ESCROW_REF_HEADER = `${"f".repeat(64)}#0`;
|
||||
|
||||
function makeApp(kind: "chat" | "tts"): Application {
|
||||
return createApp({
|
||||
chain: new MockChainProvider(),
|
||||
state: new SupplierState(),
|
||||
config: kind === "tts" ? buildSampleTtsConfig() : buildSampleConfig(),
|
||||
supplierKey: buildSupplierWalletKey(),
|
||||
jobs: new JobStore(),
|
||||
});
|
||||
}
|
||||
|
||||
function validBody() {
|
||||
return { text: "Hello world.", voice: "nova", format: "mp3", speed: 1.0 };
|
||||
}
|
||||
|
||||
describe("Capability-routed mounting", () => {
|
||||
it('TTS supplier exposes /v1/audio/synthesize and NOT /v1/chat/completions', async () => {
|
||||
const app = makeApp("tts");
|
||||
const tts = await request(app).post("/v1/audio/synthesize")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
|
||||
// Whatever validation it lands on, it must not 404 (route exists).
|
||||
expect(tts.status).not.toBe(404);
|
||||
|
||||
const chat = await request(app).post("/v1/chat/completions")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send({ messages: [{ role: "user", content: "hi" }] });
|
||||
expect(chat.status).toBe(404);
|
||||
});
|
||||
|
||||
it('chat supplier exposes /v1/chat/completions and NOT /v1/audio/synthesize', async () => {
|
||||
const app = makeApp("chat");
|
||||
const tts = await request(app).post("/v1/audio/synthesize")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
|
||||
expect(tts.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/audio/synthesize — header + body validation", () => {
|
||||
it("400 escrow_ref_required when X-Escrow-Ref header missing", async () => {
|
||||
const res = await request(makeApp("tts")).post("/v1/audio/synthesize").send(validBody());
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("escrow_ref_required");
|
||||
});
|
||||
|
||||
it("400 escrow_ref_malformed when header is not <64hex>#<int>", async () => {
|
||||
const res = await request(makeApp("tts")).post("/v1/audio/synthesize")
|
||||
.set("X-Escrow-Ref", "not-an-escrow-ref").send(validBody());
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("escrow_ref_malformed");
|
||||
});
|
||||
|
||||
it("400 text_required when body.text missing or empty", async () => {
|
||||
const app = makeApp("tts");
|
||||
for (const body of [{}, { text: "" }, { text: " ", voice: "nova" }]) {
|
||||
const res = await request(app).post("/v1/audio/synthesize")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(body);
|
||||
// Empty-string text → text_required; whitespace-only also rejected.
|
||||
// We accept either text_required or voice_invalid (tested separately
|
||||
// below) — what matters is that this never reaches the chain.
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it("400 voice_invalid for unknown voice", async () => {
|
||||
const res = await request(makeApp("tts")).post("/v1/audio/synthesize")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
|
||||
.send({ ...validBody(), voice: "karen" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("voice_invalid");
|
||||
});
|
||||
|
||||
it("400 format_invalid for unknown format", async () => {
|
||||
const res = await request(makeApp("tts")).post("/v1/audio/synthesize")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
|
||||
.send({ ...validBody(), format: "ogg" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("format_invalid");
|
||||
});
|
||||
|
||||
it("400 speed_out_of_range for speed below 0.5 / above 1.5", async () => {
|
||||
const app = makeApp("tts");
|
||||
for (const speed of [0.4, 1.6, "fast"]) {
|
||||
const res = await request(app).post("/v1/audio/synthesize")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
|
||||
.send({ ...validBody(), speed });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.reason).toBe("speed_out_of_range");
|
||||
}
|
||||
});
|
||||
|
||||
it("doesn't reach chain on validation failure (escrow_not_found is what'd come next)", async () => {
|
||||
// With valid body + ref, the next step is chain.queryUtxo → null → 404
|
||||
// escrow_not_found. Confirms the validation path is the early gate.
|
||||
const res = await request(makeApp("tts")).post("/v1/audio/synthesize")
|
||||
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
|
||||
// We don't seed the chain → query returns null. This is a chain-side
|
||||
// 503 (advert_unavailable) since the advert UTxO is also unseeded.
|
||||
expect([403, 404, 503]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user