diff --git a/README.md b/README.md index 0c239b0e..8161a789 100644 --- a/README.md +++ b/README.md @@ -862,6 +862,101 @@ optional address lookup stops after five seconds without retrying and respects a shorter `--timeout`. If it fails or times out, the command keeps the password on screen and exits successfully. +## Voice Automation (beta) + +Voice Automation is a beta (`calls.voice-automation`) for enrolled teams only. +For other teams, every change except delete fails with `voice_not_enabled`. +Reads, previews and deletes keep working after a team leaves the beta. The +commands require an organization server API key with `voice:read` (lists, +views, previews) or `voice:manage` (changes). + +### Audio library + +The audio library holds recordings and text-to-speech renders for calls. The +API transcodes each asset to 16 kHz mono; an asset is `transcoding` until it is +`ready` or `failed`. + +```shell +polymorfa voice audio upload greeting.mp3 --name Greeting \ + --project 018f0000-0000-7000-8000-000000000002 --wait +polymorfa voice audio tts --name Welcome --text "Thanks for calling." \ + --provider openai --voice alloy \ + --project 018f0000-0000-7000-8000-000000000002 +polymorfa voice audio list --status ready \ + --project 018f0000-0000-7000-8000-000000000002 +polymorfa voice audio view 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 +polymorfa voice audio update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 \ + --name "Greeting v2" --retention-days none --expected-revision 2 +polymorfa voice audio preview 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 \ + --output greeting.ogg +polymorfa voice audio delete 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --yes +``` + +- `upload` takes `.mp3`, `.wav`, `.ogg`, `.opus`, `.m4a` and `.mp4` files up to + 16 MB and sets the content type from the extension. Other types, empty files + and larger files fail with exit code 2 before any request is sent. The file + goes to a short-lived upload URL that carries no credential. +- `tts` reads the text from `--text` or `--text-file` (`-` for stdin): 1 to + 5000 characters, or 4096 for `openai`. `--voice` is an OpenAI voice name or + an ElevenLabs voice ID. `--model` defaults to the provider's default model. + Unsupported OpenAI voices and provider-specific models fail with exit code 2 + before any request is sent. + Without `--credential`, the render uses Polymorfa's managed key for the + provider. +- `upload --wait` and `tts --wait` wait until the asset is `ready` (exit 0) or + `failed` (exit 13, with the failure reason). `--wait-timeout` defaults to 120 + seconds and exits with code 5 when reached, including when a status request + stalls. This budget starts after upload or TTS submission and covers status + requests, response bodies, and retries. +- `--retention-days` deletes the asset that many days after creation (1 to + 3650). `update --retention-days none` keeps it until you delete it. +- `preview` prints a link to the transcoded audio (Opus in Ogg). The link works + for 5 minutes; treat it as a secret. `--output` downloads it instead and + refuses an existing file unless `--force` is given. With `--output`, JSON + results omit the signed link. Storage refusals retain the HTTP status but + omit the signed link and storage response text. +- `delete` asks for confirmation, or takes `--yes`, and exits with code 4 while + a campaign or flow uses the asset. + +### Provider credentials + +Store your own ElevenLabs or OpenAI key to render text to speech with it. The +CLI never takes the key from an argument. It reads the environment variable +named by `--key-env` (default `POLYMORFA_VOICE_PROVIDER_KEY`) or, when that is +unset, piped standard input, and it refuses to read the key from a terminal: + +```shell +printf '%s' "$ELEVENLABS_API_KEY" | \ + polymorfa voice credential add --provider elevenlabs --label "Studio key" +polymorfa voice credential add --provider openai --label "Support line" \ + --key-env OPENAI_API_KEY --project 018f0000-0000-7000-8000-000000000002 +polymorfa voice credential list --project 018f0000-0000-7000-8000-000000000002 +polymorfa voice credential view 7a2d3e4f-5b6c-4d7e-8f90-1a2b3c4d5e6f +polymorfa voice credential verify 7a2d3e4f-5b6c-4d7e-8f90-1a2b3c4d5e6f +polymorfa voice credential delete 7a2d3e4f-5b6c-4d7e-8f90-1a2b3c4d5e6f --yes +``` + +Without `--project`, the credential is available to every project of the team. +Output shows only the key's fingerprint (the first 8 hex characters of its +SHA-256). The provider checks the key when it is added and on `verify`; a +rejected key fails with `provider_credential_invalid` (exit code 2). + +### Voice exit codes + +| Exit code | Error code | Meaning | +| --------- | ------------------------------------------------------------------------ | ------------------------------------------------------- | +| 6 | `voice_not_enabled` | The team is not enrolled in the Voice Automation beta. | +| 7 | `gate_limit_reached` | A voice limit is reached. | +| 13 | `voice_asset_failed` | Audio awaited with `--wait` failed processing. | +| 4 | `voice_asset_in_use`, `voice_asset_revision_conflict`, `asset_not_ready` | The asset is in use, changed, or not ready. | +| 2 | `provider_credential_invalid`, `invalid_parameter` | The provider rejected the key, or the input is invalid. | +| 5 | `provider_unavailable`, `voice_unavailable` | The provider or voice service did not answer. | + +Other errors keep the standard exit codes: 2 for invalid input, 3 for +authentication and authorization, 4 for conflicts, missing resources and other +payment errors, and 5 for network, rate-limit, server and timeout failures. +With `--json`, stderr carries the error code in `error.code`. + ### Call usage and limits When Calls access is enabled for a team, Polymorfa measures the calls its numbers host. Usage is measured, not charged. diff --git a/docs/command-research/voice.md b/docs/command-research/voice.md new file mode 100644 index 00000000..1cb61ad9 --- /dev/null +++ b/docs/command-research/voice.md @@ -0,0 +1,146 @@ +# Voice Automation command research + +- **Status:** Implemented against contract revision `voice-audio-v1`, + unpublished, beta (`calls.voice-automation`) +- **Reviewed:** 2026-09-19 +- **API contract:** polymorfa/polymorfa draft PR #227, branch + `t3code/voice-audio-library`, revision `voice-audio-v1` +- **SDK reference:** `client.voice` in polymorfa/sdks draft PR #279 at + `8c61d642da51e14290ae916cd342f71cc0bf94e6` +- **SDK pin:** `@polymorfa/sdk@0.1.0-dev.20260922102711`, inherited from + CLI `dev` (see + [SDK dependency](#sdk-dependency)) + +## Sources reviewed + +- The voice audio library and provider credential contract, revision + `voice-audio-v1`: routes, schemas, closed enums, error codes and webhook + events. +- Design section 18.2 (CLI) of the Voice Automation specification: command + tree, key input rule, and distinct exit codes for `gate_limit_reached` and + `voice_not_enabled`. +- `packages/typescript/src/platform/voice.ts` and the upload URL transport in + polymorfa/sdks `8c61d64`. + +## Command tree + +```text +voice + audio + upload --name --project [--retention-days] [--wait] [--wait-timeout] + tts --name (--text | --text-file) --provider elevenlabs|openai --voice + [--model] [--credential] [--retention-days] --project [--wait] + list --project [--status] [--limit] [--cursor] + view + update [--name] [--retention-days ] [--expected-revision] + delete [--yes] + preview [--output ] [--force] + credential + add --provider --label [--project] [--key-env] + list [--project] + view + verify + delete [--yes] +``` + +Campaigns, flows, agents, usage and gates (design section 18.2) belong to later +Voice Automation phases and are not part of this revision. + +## Operation mapping + +| Command | Operation | SDK `client.voice` (8c61d64) | +| ------------------------- | ---------------------------------------------------------- | ------------------------------ | +| `voice audio upload` | `POST /platform/voice/audio`, upload URL, `.../complete` | `audio.upload` | +| `voice audio tts` | `POST /platform/voice/audio/tts` | `audio.synthesize` | +| `voice audio list` | `GET /platform/voice/audio` | `audio.list` | +| `voice audio view` | `GET /platform/voice/audio/{id}` | `audio.retrieve` | +| `voice audio update` | `PATCH /platform/voice/audio/{id}` | `audio.update` | +| `voice audio delete` | `DELETE /platform/voice/audio/{id}` | `audio.delete` | +| `voice audio preview` | `GET /platform/voice/audio/{id}/preview` | `audio.previewUrl` | +| `--wait` | `GET /platform/voice/audio/{id}` until `ready` or `failed` | `audio.retrieve` | +| `voice credential add` | `POST /platform/voice/provider-credentials` | `providerCredentials.create` | +| `voice credential list` | `GET /platform/voice/provider-credentials` | `providerCredentials.list` | +| `voice credential view` | `GET /platform/voice/provider-credentials/{id}` | `providerCredentials.retrieve` | +| `voice credential verify` | `POST /platform/voice/provider-credentials/{id}/verify` | `providerCredentials.verify` | +| `voice credential delete` | `DELETE /platform/voice/provider-credentials/{id}` | `providerCredentials.delete` | + +## SDK dependency + +The committed npm pin, `0.1.0-dev.20260922102711`, predates `client.voice`. +This branch uses the typed resource from SDK PR #279 and was verified with a +temporary local package. API PR #227 and SDK PR #279 must merge before this CLI +PR can pin the exact published SDK prerelease. No local package path belongs in +the committed manifest or lockfile. + +`src/sdk/voice.ts` calls the typed audio and provider-credential resources. +The SDK handles upload URL transfer without the API credential. The SDK does +not expose a preview download method, so the CLI downloads the signed preview +URL itself with no credential, no redirects, a bounded response, and a timeout. + +## Credential rules + +- The commands resolve Platform credentials: an organization server API key + (`POLYMORFA_API_KEY` or an API-key profile). Client tokens fail before any + request. The API also accepts project tokens; the CLI does not use them for + these commands, matching `sip-trunk`. +- `audio list`, `upload` and `tts` require `--project`. `credential add` and + `credential list` take an optional `--project`; without it, `add` creates a + team-wide credential and `list` returns only team-wide credentials. +- Provider keys are never read from an argument. `credential add` reads the + variable named by `--key-env` (default `POLYMORFA_VOICE_PROVIDER_KEY`); when + the default variable is unset it reads piped standard input (64 KiB limit) + and refuses a terminal, because the repository has no hidden-input prompt. + An explicitly named but unset variable is an error; it never falls back to + stdin. The key is registered for redaction, so it is replaced with + `[REDACTED]` if an error message ever contains it. + +## Command decisions + +- **Local refusals.** Upload type (from the extension, case-insensitive), + empty files, files over 16,777,216 bytes, names over 100 characters, + retention outside 1 to 3650, TTS text length (5000, or 4096 for `openai`), + ElevenLabs voice IDs (`^[A-Za-z0-9]{1,64}$`), provider-specific model and + OpenAI voice names outside the SDK's accepted sets, non-UUID IDs and oversized + keys (over 512 characters) fail with exit code 2 before any request. +- **Content types.** `.mp3` sends `audio/mpeg`, `.wav` `audio/wav`, `.ogg` and + `.opus` `audio/ogg`, `.m4a` `audio/x-m4a`, `.mp4` `audio/mp4`. +- **Waiting.** `--wait` reads the asset first and then every 2 seconds, like the + SDK's `waitUntilReady`. `failed` exits with `voice_asset_failed` (13); + reaching `--wait-timeout` exits with `request_timeout` (5). The budget begins + after upload or TTS submission. Each status request receives an abort signal + for the remaining budget, including body reads and SDK retries; no poll + starts after the deadline and a result received after it is not returned. + The upload request itself is not retried, matching the SDK. +- **Preview.** Without `--output` the command prints the 5-minute URL. With it, + the file is downloaded without a credential, limited to 32 MiB, and an + existing file is refused unless `--force` is given. JSON download results, + error messages, and nested causes must not retain the signed URL or storage + response text. +- **Confirmation.** Both deletes use the shared confirmation prompt, or + `--yes` in noninteractive use. +- **No idempotency flags.** The contract defines no idempotency behavior for + these routes. + +## Exit codes + +| Exit code | Code | +| --------- | ------------------------------------------------------------------------ | +| 6 | `voice_not_enabled` (any status) | +| 7 | `gate_limit_reached` (any status) | +| 13 | `voice_asset_failed` (CLI condition for `--wait`) | +| 4 | `voice_asset_in_use`, `voice_asset_revision_conflict`, `asset_not_ready` | +| 2 | `provider_credential_invalid`, `invalid_parameter`, local refusals | +| 5 | `provider_unavailable`, `voice_unavailable`, `request_timeout` | + +`src/errors/present.ts` maps the two voice codes before the status-based +classification, so they keep their exit code even if the API changes the HTTP +status. + +## Explicit gaps + +- This revision adds no command for the `voice.asset_ready` and + `voice.asset_failed` webhook events; they arrive through the existing + webhook, event and listener commands. +- No project-token support in these commands (see Credential rules). +- The CLI cannot release until `@polymorfa/sdk` with `client.voice` is + published and pinned, and the API revision is deployed. diff --git a/package-lock.json b/package-lock.json index 43bccdd0..898db896 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260923132258", + "@polymorfa/sdk": "0.1.0-dev.20260923144429", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -997,9 +997,10 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20260923132258", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923132258.tgz", - "integrity": "sha512-ol2nSNl0tfRJQOAk1kywPwMu3+7KJxO1pVVlZj5S6Akj2+mnyKzjSgSU1EVHIObDLY9LLgg/kkTSeVbBmf0FGg==", + "version": "0.1.0-dev.20260923144429", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923144429.tgz", + "integrity": "sha512-U/JQY4f5hE9LZFPQaOHSH8bSXXTNKFmd5ZtU6mpS7HmMcQH89eJlFfquTI3bgLtHJiyt4j/jt1wp8ULVv6IY7Q==", + "license": "MIT", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index 4034f73b..c4cb310e 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260923132258", + "@polymorfa/sdk": "0.1.0-dev.20260923144429", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -228,6 +228,15 @@ "session-ban": { "description": "Inspect recorded organization session bans." }, + "voice": { + "description": "Voice Automation (beta): audio library and text-to-speech provider credentials." + }, + "voice:audio": { + "description": "Upload, synthesize, inspect, preview, and delete library audio (beta)." + }, + "voice:credential": { + "description": "Store, check, and delete your text-to-speech provider keys (beta)." + }, "session:configuration": { "description": "Inspect and update inherited session configuration." }, diff --git a/skills/polymorfa/SKILL.md b/skills/polymorfa/SKILL.md index d3e28ddf..bdd25c97 100644 --- a/skills/polymorfa/SKILL.md +++ b/skills/polymorfa/SKILL.md @@ -16,6 +16,8 @@ Use `polymorfa --help` and `polymorfa --help` as the installed command a Organization API keys and exact-project tokens are durable principals. Browser client tokens and listener credentials are not general CLI credentials. Do not print, log, persist in project files, or pass credentials in command arguments. +Voice Automation commands (`polymorfa voice audio`, `polymorfa voice credential`) are a beta for enrolled teams; `voice_not_enabled` (exit 6) means the team is not enrolled and `gate_limit_reached` (exit 7) means a voice limit is reached, so do not retry either. Pass a provider key to `polymorfa voice credential add` only through standard input or the environment variable named by `--key-env`, never as an argument. + Use `polymorfa quicklink create --project PROJECT --input request.json` with an organization API key for standard number pairing. Treat the returned hosted URL as credential material; do not request direct session QR payloads or pairing codes. ## Choose an interface diff --git a/src/commands/voice/audio/delete.ts b/src/commands/voice/audio/delete.ts new file mode 100644 index 00000000..359119a5 --- /dev/null +++ b/src/commands/voice/audio/delete.ts @@ -0,0 +1,39 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {confirmDangerous} from '../../../input/confirmation.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import { + deleteAudio, + renderDeleted, + validateVoiceId, +} from '../../../services/voice.js' + +export default class VoiceAudioDelete extends BaseCommand { + static override args = { + asset: Args.string({description: 'Audio asset ID.', required: true}), + } + + static override description = + 'Delete an audio asset permanently. Fails with voice_asset_in_use (exit code 4) while a campaign or flow uses it. Works after the Voice Automation beta is withdrawn. Requires a server API key with voice:manage.' + + static override flags = { + yes: Flags.boolean({ + description: 'Confirm permanent deletion without prompting.', + }), + } + + static override summary = 'Delete audio (beta).' + + async run(): Promise { + const {args, flags} = await this.parse(VoiceAudioDelete) + const assetId = validateVoiceId(args.asset, 'asset') + await confirmDangerous({ + prompt: `Delete audio ${assetId} permanently?`, + yes: flags.yes, + }) + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await deleteAudio(client, assetId) + this.writeValue(result, renderDeleted('audio', result.data), flags) + } +} diff --git a/src/commands/voice/audio/list.ts b/src/commands/voice/audio/list.ts new file mode 100644 index 00000000..fbd8d047 --- /dev/null +++ b/src/commands/voice/audio/list.ts @@ -0,0 +1,48 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient, VOICE_AUDIO_STATUSES} from '../../../sdk/voice.js' +import { + BETA_NOTICE, + listAudio, + renderAudioList, + requireVoiceProject, +} from '../../../services/voice.js' + +export default class VoiceAudioList extends BaseCommand { + static override description = `List the project's audio library, newest first. Requires a server API key with voice:read. ${BETA_NOTICE}` + + static override examples = [ + '<%= config.bin %> voice audio list --project 018f0000-0000-7000-8000-000000000002', + '<%= config.bin %> voice audio list --status ready --limit 100 --project 018f0000-0000-7000-8000-000000000002', + ] + + static override flags = { + cursor: Flags.string({description: 'Continue from this opaque cursor.'}), + limit: Flags.integer({ + description: + 'Return at most this many assets (1 to 100; API default 50).', + max: 100, + min: 1, + }), + status: Flags.string({ + description: 'Only list assets with this status.', + options: [...VOICE_AUDIO_STATUSES], + }), + } + + static override summary = 'List audio (beta).' + + async run(): Promise { + const {flags} = await this.parse(VoiceAudioList) + const projectId = requireVoiceProject(flags.project) + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await listAudio(client, { + projectId, + ...(flags.status === undefined ? {} : {status: flags.status}), + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + }) + this.writeValue(result, renderAudioList(result.data, result.page), flags) + } +} diff --git a/src/commands/voice/audio/preview.ts b/src/commands/voice/audio/preview.ts new file mode 100644 index 00000000..243fca14 --- /dev/null +++ b/src/commands/voice/audio/preview.ts @@ -0,0 +1,42 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import { + BETA_NOTICE, + previewAudio, + renderPreview, +} from '../../../services/voice.js' + +export default class VoiceAudioPreview extends BaseCommand { + static override args = { + asset: Args.string({description: 'Audio asset ID.', required: true}), + } + + static override description = `Print a link to the transcoded audio (Opus in Ogg, 16 kHz mono). The link works for 5 minutes; treat it as a secret. --output downloads it instead of printing it. Fails with asset_not_ready (exit code 4) before the asset is ready. Requires a server API key with voice:read. ${BETA_NOTICE}` + + static override examples = [ + '<%= config.bin %> voice audio preview 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10', + '<%= config.bin %> voice audio preview 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --output greeting.ogg', + ] + + static override flags = { + force: Flags.boolean({description: 'Overwrite an existing output file.'}), + output: Flags.string({ + char: 'o', + description: 'Download the preview to this file.', + }), + } + + static override summary = 'Get a preview link for audio (beta).' + + async run(): Promise { + const {args, flags} = await this.parse(VoiceAudioPreview) + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await previewAudio(client, args.asset, { + force: flags.force, + output: flags.output, + }) + this.writeValue(result, renderPreview(result.data), flags) + } +} diff --git a/src/commands/voice/audio/tts.ts b/src/commands/voice/audio/tts.ts new file mode 100644 index 00000000..79e7d95e --- /dev/null +++ b/src/commands/voice/audio/tts.ts @@ -0,0 +1,111 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient, VOICE_PROVIDERS} from '../../../sdk/voice.js' +import { + BETA_NOTICE, + DEFAULT_WAIT_TIMEOUT_SECONDS, + parseRetentionDays, + readTextFile, + renderAudioSubmitted, + requireVoiceProject, + synthesizeAudio, + validateName, + validateTtsText, + validateTtsVoice, + validateVoiceId, + waitForAudio, +} from '../../../services/voice.js' + +export default class VoiceAudioTts extends BaseCommand { + static override description = `Render text to speech into a new library asset. Without --credential, Polymorfa's managed key for the provider is used; with it, your stored provider credential for the same provider. Text is 1 to 5000 characters (4096 for openai). OpenAI voices: alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse. ElevenLabs takes a voice ID. Models: elevenlabs eleven_multilingual_v2 (default), eleven_flash_v2_5, eleven_turbo_v2_5; openai gpt-4o-mini-tts (default), tts-1, tts-1-hd. Requires a server API key with voice:manage. ${BETA_NOTICE}` + + static override examples = [ + '<%= config.bin %> voice audio tts --name "Welcome" --text "Thanks for calling." --provider openai --voice alloy --project 018f0000-0000-7000-8000-000000000002', + '<%= config.bin %> voice audio tts --name "Menu" --text-file menu.txt --provider elevenlabs --voice 21m00Tcm4TlvDq8ikWAM --credential 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --wait --project 018f0000-0000-7000-8000-000000000002', + ] + + static override flags = { + credential: Flags.string({ + description: + 'Use this stored provider credential instead of the managed key.', + }), + model: Flags.string({ + description: "Provider model; the provider's default when omitted.", + }), + name: Flags.string({ + description: 'Name shown in the library (1 to 100 characters).', + required: true, + }), + provider: Flags.string({ + description: 'Text-to-speech provider.', + options: [...VOICE_PROVIDERS], + required: true, + }), + 'retention-days': Flags.string({ + description: + 'Delete the audio this many days after creation (1 to 3650). Omit to keep it until deleted.', + }), + text: Flags.string({ + description: 'Text to speak.', + exactlyOne: ['text', 'text-file'], + }), + 'text-file': Flags.string({ + description: 'Read the text from a UTF-8 file, or stdin (-).', + exactlyOne: ['text', 'text-file'], + }), + voice: Flags.string({ + description: 'OpenAI voice name or ElevenLabs voice ID.', + required: true, + }), + wait: Flags.boolean({ + description: + 'Wait until the audio is ready or failed. A failed asset exits with code 13.', + }), + 'wait-timeout': Flags.integer({ + default: DEFAULT_WAIT_TIMEOUT_SECONDS, + description: 'Stop waiting after this many seconds (exit code 5).', + min: 1, + }), + } + + static override summary = 'Create audio from text to speech (beta).' + + async run(): Promise { + const {flags} = await this.parse(VoiceAudioTts) + const projectId = requireVoiceProject(flags.project) + const provider = flags.provider + const name = validateName(flags.name) + const voiceId = validateTtsVoice(flags.voice, provider) + const credentialId = + flags.credential === undefined + ? undefined + : validateVoiceId(flags.credential, 'credential') + const retentionDays = + flags['retention-days'] === undefined + ? undefined + : (parseRetentionDays(flags['retention-days'], false) as number) + const text = validateTtsText( + flags.text ?? (await readTextFile(flags['text-file'] as string)), + provider, + ) + const model = flags.model?.trim() + const client = createVoiceClient(await this.platformEnvironment(flags)) + let asset = await synthesizeAudio(client, { + name, + projectId, + provider, + text, + voiceId, + ...(model === undefined || model.length === 0 ? {} : {model}), + ...(credentialId === undefined ? {} : {credentialId}), + ...(retentionDays === undefined ? {} : {retentionDays}), + }) + if (flags.wait) { + asset = await waitForAudio(client, asset, { + timeoutSeconds: flags['wait-timeout'], + }) + } + this.writeValue(asset, renderAudioSubmitted(asset.data), flags) + } +} diff --git a/src/commands/voice/audio/update.ts b/src/commands/voice/audio/update.ts new file mode 100644 index 00000000..5b46dbf1 --- /dev/null +++ b/src/commands/voice/audio/update.ts @@ -0,0 +1,53 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import { + BETA_NOTICE, + parseRetentionDays, + renderAudio, + updateAudio, +} from '../../../services/voice.js' + +export default class VoiceAudioUpdate extends BaseCommand { + static override args = { + asset: Args.string({description: 'Audio asset ID.', required: true}), + } + + static override description = `Rename an audio asset or change how long it is kept. --expected-revision refuses the change with voice_asset_revision_conflict (exit code 4) if the asset changed. Requires a server API key with voice:manage. ${BETA_NOTICE}` + + static override examples = [ + '<%= config.bin %> voice audio update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --name "Greeting v2" --expected-revision 2', + '<%= config.bin %> voice audio update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --retention-days none', + ] + + static override flags = { + 'expected-revision': Flags.integer({ + description: + 'Refuse the change if the asset no longer has this revision.', + min: 1, + }), + name: Flags.string({description: 'New name (1 to 100 characters).'}), + 'retention-days': Flags.string({ + description: + 'Days to keep the audio after creation (1 to 3650), or none to keep it until deleted.', + }), + } + + static override summary = 'Change audio (beta).' + + async run(): Promise { + const {args, flags} = await this.parse(VoiceAudioUpdate) + const retentionDays = + flags['retention-days'] === undefined + ? undefined + : parseRetentionDays(flags['retention-days'], true) + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await updateAudio(client, args.asset, { + expectedRevision: flags['expected-revision'], + name: flags.name, + retentionDays, + }) + this.writeValue(result, renderAudio(result.data), flags) + } +} diff --git a/src/commands/voice/audio/upload.ts b/src/commands/voice/audio/upload.ts new file mode 100644 index 00000000..cb33a57c --- /dev/null +++ b/src/commands/voice/audio/upload.ts @@ -0,0 +1,79 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import { + BETA_NOTICE, + DEFAULT_WAIT_TIMEOUT_SECONDS, + parseRetentionDays, + readUploadFile, + renderAudioSubmitted, + requireVoiceProject, + uploadAudio, + validateName, + waitForAudio, +} from '../../../services/voice.js' + +export default class VoiceAudioUpload extends BaseCommand { + static override args = { + file: Args.string({ + description: + 'Audio file: .mp3, .wav, .ogg, .opus, .m4a or .mp4, at most 16 MB.', + required: true, + }), + } + + static override description = `Upload a recording to the project's audio library. The content type comes from the file extension; other types and files over 16 MB are refused before anything is sent. The asset is returned while it is transcoded to 16 kHz mono; pass --wait to wait until it is ready or failed. Requires a server API key with voice:manage. ${BETA_NOTICE}` + + static override examples = [ + '<%= config.bin %> voice audio upload greeting.mp3 --name "Greeting" --project 018f0000-0000-7000-8000-000000000002', + '<%= config.bin %> voice audio upload hold.wav --name "Hold music" --retention-days 30 --wait --project 018f0000-0000-7000-8000-000000000002', + ] + + static override flags = { + name: Flags.string({ + description: 'Name shown in the library (1 to 100 characters).', + required: true, + }), + 'retention-days': Flags.string({ + description: + 'Delete the audio this many days after creation (1 to 3650). Omit to keep it until deleted.', + }), + wait: Flags.boolean({ + description: + 'Wait until the audio is ready or failed. A failed asset exits with code 13.', + }), + 'wait-timeout': Flags.integer({ + default: DEFAULT_WAIT_TIMEOUT_SECONDS, + description: 'Stop waiting after this many seconds (exit code 5).', + min: 1, + }), + } + + static override summary = 'Upload an audio file (beta).' + + async run(): Promise { + const {args, flags} = await this.parse(VoiceAudioUpload) + const projectId = requireVoiceProject(flags.project) + const name = validateName(flags.name) + const retentionDays = + flags['retention-days'] === undefined + ? undefined + : (parseRetentionDays(flags['retention-days'], false) as number) + const file = await readUploadFile(args.file) + const client = createVoiceClient(await this.platformEnvironment(flags)) + let asset = await uploadAudio(client, { + bytes: file.bytes, + contentType: file.contentType, + name, + projectId, + ...(retentionDays === undefined ? {} : {retentionDays}), + }) + if (flags.wait) { + asset = await waitForAudio(client, asset, { + timeoutSeconds: flags['wait-timeout'], + }) + } + this.writeValue(asset, renderAudioSubmitted(asset.data), flags) + } +} diff --git a/src/commands/voice/audio/view.ts b/src/commands/voice/audio/view.ts new file mode 100644 index 00000000..b2986b86 --- /dev/null +++ b/src/commands/voice/audio/view.ts @@ -0,0 +1,22 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import {BETA_NOTICE, renderAudio, viewAudio} from '../../../services/voice.js' + +export default class VoiceAudioView extends BaseCommand { + static override args = { + asset: Args.string({description: 'Audio asset ID.', required: true}), + } + + static override description = `Show one audio asset, including its status, failure reason and revision. Requires a server API key with voice:read. ${BETA_NOTICE}` + + static override summary = 'Show audio (beta).' + + async run(): Promise { + const {args, flags} = await this.parse(VoiceAudioView) + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await viewAudio(client, args.asset) + this.writeValue(result, renderAudio(result.data), flags) + } +} diff --git a/src/commands/voice/credential/add.ts b/src/commands/voice/credential/add.ts new file mode 100644 index 00000000..be4bb33d --- /dev/null +++ b/src/commands/voice/credential/add.ts @@ -0,0 +1,57 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient, VOICE_PROVIDERS} from '../../../sdk/voice.js' +import { + addCredential, + BETA_NOTICE, + DEFAULT_PROVIDER_KEY_ENV, + readProviderKey, + renderCredential, +} from '../../../services/voice.js' + +export default class VoiceCredentialAdd extends BaseCommand { + static override description = `Store your own ElevenLabs or OpenAI key for text to speech. The key is read from the environment variable named by --key-env (default ${DEFAULT_PROVIDER_KEY_ENV}) or, when that is unset, from standard input; never from an argument. The provider checks the key first; a rejected key fails with provider_credential_invalid (exit code 2). Only the key's fingerprint is ever shown. With --project the credential belongs to that project; without it, every project of the team can use it. Requires a server API key with voice:manage. ${BETA_NOTICE}` + + static override examples = [ + `printf '%s' "$ELEVENLABS_API_KEY" | <%= config.bin %> voice credential add --provider elevenlabs --label "Studio key"`, + `${DEFAULT_PROVIDER_KEY_ENV}="$OPENAI_API_KEY" <%= config.bin %> voice credential add --provider openai --label "Support line" --project 018f0000-0000-7000-8000-000000000002`, + '<%= config.bin %> voice credential add --provider openai --label "CI" --key-env OPENAI_API_KEY', + ] + + static override flags = { + 'key-env': Flags.string({ + description: `Read the key from this environment variable (default ${DEFAULT_PROVIDER_KEY_ENV}; standard input when that is unset).`, + }), + label: Flags.string({ + description: 'Label shown in lists (1 to 100 characters).', + required: true, + }), + provider: Flags.string({ + description: 'Provider the key belongs to.', + options: [...VOICE_PROVIDERS], + required: true, + }), + } + + static override summary = 'Store a provider key (beta).' + + async run(): Promise { + const {flags} = await this.parse(VoiceCredentialAdd) + const apiKey = await readProviderKey({ + env: process.env, + keyEnv: flags['key-env'], + stdin: process.stdin, + }) + this.registerSecret(apiKey) + const projectId = flags.project?.trim() + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await addCredential(client, { + apiKey, + label: flags.label, + provider: flags.provider, + ...(projectId === undefined || projectId.length === 0 ? {} : {projectId}), + }) + this.writeValue(result, renderCredential(result.data), flags) + } +} diff --git a/src/commands/voice/credential/delete.ts b/src/commands/voice/credential/delete.ts new file mode 100644 index 00000000..865477ca --- /dev/null +++ b/src/commands/voice/credential/delete.ts @@ -0,0 +1,46 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {confirmDangerous} from '../../../input/confirmation.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import { + deleteCredential, + renderDeleted, + validateVoiceId, +} from '../../../services/voice.js' + +export default class VoiceCredentialDelete extends BaseCommand { + static override args = { + credential: Args.string({ + description: 'Provider credential ID.', + required: true, + }), + } + + static override description = + 'Delete a stored provider key. Text to speech that names it then fails; audio already created is kept. Works after the Voice Automation beta is withdrawn. Requires a server API key with voice:manage.' + + static override flags = { + yes: Flags.boolean({ + description: 'Confirm permanent deletion without prompting.', + }), + } + + static override summary = 'Delete a provider credential (beta).' + + async run(): Promise { + const {args, flags} = await this.parse(VoiceCredentialDelete) + const credentialId = validateVoiceId(args.credential, 'credential') + await confirmDangerous({ + prompt: `Delete provider credential ${credentialId}?`, + yes: flags.yes, + }) + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await deleteCredential(client, credentialId) + this.writeValue( + result, + renderDeleted('provider credential', result.data), + flags, + ) + } +} diff --git a/src/commands/voice/credential/list.ts b/src/commands/voice/credential/list.ts new file mode 100644 index 00000000..ad4e4973 --- /dev/null +++ b/src/commands/voice/credential/list.ts @@ -0,0 +1,29 @@ +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import { + BETA_NOTICE, + listCredentials, + renderCredentials, +} from '../../../services/voice.js' + +export default class VoiceCredentialList extends BaseCommand { + static override description = `List the team-wide provider credentials and, with --project, that project's own. Keys are never returned; each credential shows its fingerprint and status. Requires a server API key with voice:read. ${BETA_NOTICE}` + + static override examples = [ + '<%= config.bin %> voice credential list', + '<%= config.bin %> voice credential list --project 018f0000-0000-7000-8000-000000000002', + ] + + static override summary = 'List provider credentials (beta).' + + async run(): Promise { + const {flags} = await this.parse(VoiceCredentialList) + const projectId = flags.project?.trim() + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await listCredentials( + client, + projectId === undefined || projectId.length === 0 ? undefined : projectId, + ) + this.writeValue(result, renderCredentials(result.data), flags) + } +} diff --git a/src/commands/voice/credential/verify.ts b/src/commands/voice/credential/verify.ts new file mode 100644 index 00000000..71de1c7d --- /dev/null +++ b/src/commands/voice/credential/verify.ts @@ -0,0 +1,29 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import { + BETA_NOTICE, + renderCredential, + verifyCredential, +} from '../../../services/voice.js' + +export default class VoiceCredentialVerify extends BaseCommand { + static override args = { + credential: Args.string({ + description: 'Provider credential ID.', + required: true, + }), + } + + static override description = `Check the stored key with the provider again and show the updated status. A rejected key fails with provider_credential_invalid (exit code 2); an unreachable provider with provider_unavailable (exit code 5). Requires a server API key with voice:manage. ${BETA_NOTICE}` + + static override summary = 'Check a provider credential again (beta).' + + async run(): Promise { + const {args, flags} = await this.parse(VoiceCredentialVerify) + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await verifyCredential(client, args.credential) + this.writeValue(result, renderCredential(result.data), flags) + } +} diff --git a/src/commands/voice/credential/view.ts b/src/commands/voice/credential/view.ts new file mode 100644 index 00000000..53b3ad6b --- /dev/null +++ b/src/commands/voice/credential/view.ts @@ -0,0 +1,29 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import { + BETA_NOTICE, + renderCredential, + viewCredential, +} from '../../../services/voice.js' + +export default class VoiceCredentialView extends BaseCommand { + static override args = { + credential: Args.string({ + description: 'Provider credential ID.', + required: true, + }), + } + + static override description = `Show one provider credential: its provider, label, key fingerprint, status and last error. The key is never returned. Requires a server API key with voice:read. ${BETA_NOTICE}` + + static override summary = 'Show a provider credential (beta).' + + async run(): Promise { + const {args, flags} = await this.parse(VoiceCredentialView) + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await viewCredential(client, args.credential) + this.writeValue(result, renderCredential(result.data), flags) + } +} diff --git a/src/errors/conditions.ts b/src/errors/conditions.ts index e0763ecb..5e7bb5c8 100644 --- a/src/errors/conditions.ts +++ b/src/errors/conditions.ts @@ -5,6 +5,7 @@ export type CliCondition = | 'forwarding_failed' | 'partial_success' | 'authorization' + | 'voice_asset_failed' const EXIT_CODES: Readonly> = { action_required: 8, @@ -13,6 +14,7 @@ const EXIT_CODES: Readonly> = { partial_success: 12, stream_dropped: 10, stream_gap: 9, + voice_asset_failed: 13, } export class CliConditionError extends Error { diff --git a/src/errors/present.ts b/src/errors/present.ts index d4e49135..bd767bc3 100644 --- a/src/errors/present.ts +++ b/src/errors/present.ts @@ -96,6 +96,19 @@ export function presentError( } } +/** + * API error codes with their own exit code, whatever their HTTP status, so + * scripts can tell a missing beta enrollment or a reached limit apart from + * other authorization or payment failures. + */ +export const CODE_EXIT_CODES: ReadonlyMap< + string, + Pick +> = new Map>([ + ['voice_not_enabled', {category: 'authorization', exitCode: 6}], + ['gate_limit_reached', {category: 'payment_required', exitCode: 7}], +]) + /** Human stderr lines for a failure: message, then request and docs links. */ export function renderErrorDetails(presented: PresentedError): string { return [ @@ -115,6 +128,11 @@ export function renderErrorDetails(presented: PresentedError): string { function classifyError( error: unknown, ): Pick { + if (error instanceof PolymorfaError && error.code !== undefined) { + const mapped = CODE_EXIT_CODES.get(error.code) + if (mapped !== undefined) return mapped + } + if (error instanceof PolymorfaPaymentRequiredError) { return {category: 'payment_required', exitCode: 4} } diff --git a/src/sdk/voice.ts b/src/sdk/voice.ts new file mode 100644 index 00000000..9a3a87d2 --- /dev/null +++ b/src/sdk/voice.ts @@ -0,0 +1,428 @@ +import { + ELEVENLABS_TTS_MODELS, + OPENAI_TTS_MODELS, + OPENAI_TTS_VOICES, + VOICE_AUDIO_MAX_UPLOAD_BYTES, + VOICE_AUDIO_STATUSES, + VOICE_PROVIDERS, + PolymorfaAuthorizationError, + PolymorfaConfigurationError, + PolymorfaConnectionError, + PolymorfaError, + PolymorfaNotFoundError, + PolymorfaServerError, + PolymorfaTimeoutError, + PolymorfaValidationError, + type ApiResponse, + type KnownVoiceAudioUploadContentType, + type RequestOptions, + type UpdateVoiceAudioInput, + type VoiceAudioAsset, + type VoiceAudioPreview, + type VoiceProviderCredential, + type VoiceResourceDeleted, +} from '@polymorfa/sdk' + +import type {ResolvedEnvironment} from '../context/environment.js' +import {createPlatformClient} from './client.js' + +export {VOICE_AUDIO_MAX_UPLOAD_BYTES, VOICE_AUDIO_STATUSES, VOICE_PROVIDERS} +export type { + VoiceAudioAsset, + VoiceAudioPreview, + VoiceProviderCredential, + VoiceResourceDeleted, +} + +const DEFAULT_BASE_URL = 'https://api.polymorfa.com' +const DEFAULT_TRANSFER_TIMEOUT_MS = 120_000 + +export interface VoiceAudioPage { + readonly items: readonly VoiceAudioAsset[] + readonly nextCursor: string | null + readonly hasMore: boolean +} + +export interface VoiceClient { + listAudio(params: { + projectId: string + status?: string + cursor?: string + limit?: number + }): Promise> + uploadAudio(input: { + projectId: string + name: string + bytes: Uint8Array + contentType: KnownVoiceAudioUploadContentType + retentionDays?: number + }): Promise> + synthesizeAudio(input: { + projectId: string + name: string + text: string + provider: string + voiceId: string + model?: string + credentialId?: string + retentionDays?: number + }): Promise> + retrieveAudio( + assetId: string, + options?: RequestOptions, + ): Promise> + updateAudio( + assetId: string, + input: UpdateVoiceAudioInput, + ): Promise> + deleteAudio(assetId: string): Promise> + previewAudio(assetId: string): Promise> + downloadPreview( + preview: VoiceAudioPreview, + maxBytes: number, + ): Promise + listCredentials( + projectId?: string, + ): Promise> + createCredential(input: { + provider: string + label: string + apiKey: string + projectId?: string + }): Promise> + retrieveCredential( + credentialId: string, + ): Promise> + verifyCredential( + credentialId: string, + ): Promise> + deleteCredential( + credentialId: string, + ): Promise> +} + +function memberOf( + values: T, + value: string, +): value is T[number] { + return (values as readonly string[]).includes(value) +} + +/** The Platform API calls use the typed SDK. Only signed preview downloads use fetch. */ +export function createVoiceClient( + environment: ResolvedEnvironment, + fetchImplementation: typeof fetch = fetch, +): VoiceClient { + const voice = createPlatformClient(environment).voice + const baseUrl = environment.baseUrl ?? DEFAULT_BASE_URL + const transferTimeoutMs = environment.timeoutMs ?? DEFAULT_TRANSFER_TIMEOUT_MS + + async function transfer( + url: URL, + init: RequestInit, + label: string, + consume: (response: Response, signal: AbortSignal) => Promise, + ): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), transferTimeoutMs) + try { + const response = await fetchImplementation(url, { + ...init, + redirect: 'error', + signal: controller.signal, + }) + return await consume(response, controller.signal) + } catch (error) { + if (controller.signal.aborted) { + throw new PolymorfaTimeoutError( + `The ${label} did not finish within ${transferTimeoutMs}ms.`, + {code: 'request_timeout'}, + ) + } + if (error instanceof PolymorfaError) throw error + throw new PolymorfaConnectionError( + `The ${label} could not reach its URL.`, + {code: 'connection_error'}, + ) + } finally { + clearTimeout(timer) + } + } + + return { + async listAudio(params) { + const {projectId, status, ...rest} = params + if (status !== undefined && !memberOf(VOICE_AUDIO_STATUSES, status)) { + throw new PolymorfaConfigurationError( + 'Unsupported voice audio status.', + 'status', + ) + } + const filters = {...rest, ...(status === undefined ? {} : {status})} + const page = await voice.audio.list(projectId, filters) + return { + data: { + items: page.items, + hasMore: page.hasMore, + nextCursor: page.nextCursor ?? null, + }, + metadata: page.response.metadata, + } + }, + uploadAudio: ({projectId, name, bytes, contentType, retentionDays}) => + voice.audio.upload(projectId, { + name, + body: bytes, + contentType, + ...(retentionDays === undefined ? {} : {retentionDays}), + }), + synthesizeAudio: ({projectId, provider, voiceId, model, ...fields}) => { + if (provider === 'elevenlabs') { + if (model !== undefined && !memberOf(ELEVENLABS_TTS_MODELS, model)) { + throw new PolymorfaConfigurationError( + 'Unsupported ElevenLabs model.', + 'model', + ) + } + return voice.audio.synthesize(projectId, { + ...fields, + provider, + voiceId, + ...(model === undefined ? {} : {model}), + }) + } + if (provider === 'openai') { + if (!memberOf(OPENAI_TTS_VOICES, voiceId)) { + throw new PolymorfaConfigurationError( + 'Unsupported OpenAI voice.', + 'voice', + ) + } + if (model !== undefined && !memberOf(OPENAI_TTS_MODELS, model)) { + throw new PolymorfaConfigurationError( + 'Unsupported OpenAI model.', + 'model', + ) + } + return voice.audio.synthesize(projectId, { + ...fields, + provider, + voiceId, + ...(model === undefined ? {} : {model}), + }) + } + throw new PolymorfaConfigurationError( + 'Unsupported voice provider.', + 'provider', + ) + }, + retrieveAudio: (assetId, options) => voice.audio.retrieve(assetId, options), + updateAudio: (assetId, input) => voice.audio.update(assetId, input), + deleteAudio: (assetId) => voice.audio.delete(assetId), + previewAudio: (assetId) => voice.audio.previewUrl(assetId), + async downloadPreview(preview, maxBytes) { + const url = capabilityUrl(baseUrl, preview.url, 'preview') + return transfer( + url, + {method: 'GET'}, + 'preview download', + async (response, signal) => { + if (!response.ok) { + throw transferError( + response, + await safeBody(response, signal), + 'preview download', + ) + } + const declared = Number(response.headers.get('content-length')) + if (Number.isFinite(declared) && declared > maxBytes) { + await discardBody(response) + throw tooLargePreview(maxBytes) + } + const bytes = await readBody(response, signal, maxBytes) + if (bytes === undefined) throw tooLargePreview(maxBytes) + return bytes + }, + ) + }, + listCredentials: (projectId) => + voice.providerCredentials.list( + projectId === undefined ? {} : {projectId}, + ), + createCredential: (input) => { + if (!memberOf(VOICE_PROVIDERS, input.provider)) { + throw new PolymorfaConfigurationError( + 'Unsupported voice provider.', + 'provider', + ) + } + return voice.providerCredentials.create({ + ...input, + provider: input.provider, + }) + }, + retrieveCredential: (credentialId) => + voice.providerCredentials.retrieve(credentialId), + verifyCredential: (credentialId) => + voice.providerCredentials.verify(credentialId), + deleteCredential: (credentialId) => + voice.providerCredentials.delete(credentialId), + } +} + +/** + * Resolve an upload or preview URL. It must use HTTPS (or HTTP on loopback) + * and carry no credentials, matching the SDK's upload rules. + */ +function capabilityUrl(baseUrl: string, value: string, label: string): URL { + let url: URL + try { + url = new URL(value, `${baseUrl.replace(/\/+$/, '')}/`) + } catch { + throw new PolymorfaValidationError( + `The API returned an invalid ${label} URL.`, + {code: `invalid_${label}_url`}, + ) + } + const loopback = + url.hostname === 'localhost' || + url.hostname === '127.0.0.1' || + url.hostname === '[::1]' + if ( + url.username !== '' || + url.password !== '' || + !(url.protocol === 'https:' || (url.protocol === 'http:' && loopback)) + ) { + throw new PolymorfaValidationError( + `The ${label} URL must use HTTPS and must not contain credentials.`, + {code: `invalid_${label}_url`}, + ) + } + return url +} + +/** Error bodies are only inspected for an error code. */ +const MAX_ERROR_BODY_BYTES = 65_536 + +async function safeBody( + response: Response, + signal: AbortSignal, +): Promise { + let bytes: Uint8Array | undefined + try { + bytes = await readBody(response, signal, MAX_ERROR_BODY_BYTES) + } catch (error) { + // A timeout must surface as a timeout, not as the status error. + if (signal.aborted) throw error + return undefined + } + if (bytes === undefined || bytes.byteLength === 0) return undefined + try { + return JSON.parse(new TextDecoder().decode(bytes)) as unknown + } catch { + return undefined + } +} + +/** + * Reads a response body incrementally. Returns undefined, after cancelling + * the stream, as soon as more than maxBytes arrive. Aborting the signal + * cancels the stream and rejects, even when the body source ignores it. + */ +async function readBody( + response: Response, + signal: AbortSignal, + maxBytes: number, +): Promise { + const body = response.body + if (body === null) return new Uint8Array() + const reader = body.getReader() + const onAbort = () => { + reader.cancel(signal.reason).catch(() => undefined) + } + signal.addEventListener('abort', onAbort, {once: true}) + const chunks: Uint8Array[] = [] + let total = 0 + try { + for (;;) { + if (signal.aborted) throw abortReason(signal) + const {done, value} = await reader.read() + // A cancelled read resolves as done; never return a truncated body. + if (signal.aborted) throw abortReason(signal) + if (done) break + total += value.byteLength + if (total > maxBytes) { + await reader.cancel().catch(() => undefined) + return undefined + } + chunks.push(value) + } + } finally { + signal.removeEventListener('abort', onAbort) + } + const bytes = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return bytes +} + +async function discardBody(response: Response): Promise { + await response.body?.cancel().catch(() => undefined) +} + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? new Error('The transfer was aborted.') +} + +function transferError( + response: Response, + body: unknown, + label: string, +): PolymorfaError { + const candidateCode = + typeof body === 'object' && + body !== null && + typeof (body as {error?: {code?: unknown}}).error?.code === 'string' + ? (body as {error: {code: string}}).error.code + : undefined + // The external storage response is untrusted and may echo the signed URL. + const bodyCode = candidateCode === 'link_expired' ? candidateCode : undefined + const status = response.status + const options = { + status, + ...(bodyCode === undefined + ? { + code: + status === 413 ? 'too_large' : `${label.replace(' ', '_')}_failed`, + } + : {code: bodyCode}), + } + const message = + status === 413 + ? `The ${label} was refused: the file is larger than 16 MB.` + : status === 415 + ? `The ${label} was refused: the content type does not match the upload.` + : `The ${label} failed with status ${status}.` + if (status === 400 || status === 413 || status === 415 || status === 422) { + return new PolymorfaValidationError(message, options) + } + if (status === 401 || status === 403) { + return new PolymorfaAuthorizationError( + `${message} The link may have expired; request a new one.`, + options, + ) + } + if (status === 404) return new PolymorfaNotFoundError(message, options) + if (status >= 500) return new PolymorfaServerError(message, options) + return new PolymorfaError(message, options) +} + +function tooLargePreview(maxBytes: number): PolymorfaError { + return new PolymorfaServerError( + `The preview is larger than ${maxBytes} bytes; nothing was written.`, + {code: 'preview_too_large'}, + ) +} diff --git a/src/services/voice.ts b/src/services/voice.ts new file mode 100644 index 00000000..fa424680 --- /dev/null +++ b/src/services/voice.ts @@ -0,0 +1,719 @@ +import {open, readFile, stat, writeFile} from 'node:fs/promises' +import {extname, resolve} from 'node:path' +import type {Readable} from 'node:stream' + +import { + PolymorfaConfigurationError, + PolymorfaConflictError, + PolymorfaTimeoutError, + type KnownVoiceAudioUploadContentType, + type ResponseMetadata, +} from '@polymorfa/sdk' + +import {CliConditionError} from '../errors/conditions.js' +import {readSecretFromStdin} from '../input/stdin.js' +import { + VOICE_AUDIO_MAX_UPLOAD_BYTES, + type VoiceAudioAsset, + type VoiceAudioPreview, + type VoiceClient, + type VoiceProviderCredential, + type VoiceResourceDeleted, +} from '../sdk/voice.js' + +export interface VoiceResult { + readonly data: T + readonly metadata: { + readonly apiVersion?: string + readonly attempts: number + readonly requestId?: string + readonly status: number + } +} + +export interface VoiceListResult extends VoiceResult { + readonly page: {readonly hasMore: boolean; readonly nextCursor: string | null} +} + +export const BETA_NOTICE = + 'Voice Automation is a beta (calls.voice-automation) for enrolled teams only; other teams get voice_not_enabled (exit code 6).' + +/** File extensions the CLI uploads, and the content type it sends for each. */ +export const UPLOAD_CONTENT_TYPES: Readonly< + Record +> = { + '.m4a': 'audio/x-m4a', + '.mp3': 'audio/mpeg', + '.mp4': 'audio/mp4', + '.ogg': 'audio/ogg', + '.opus': 'audio/ogg', + '.wav': 'audio/wav', +} + +export const DEFAULT_PROVIDER_KEY_ENV = 'POLYMORFA_VOICE_PROVIDER_KEY' +export const DEFAULT_WAIT_TIMEOUT_SECONDS = 120 +export const WAIT_INTERVAL_MS = 2_000 +export const MAX_PREVIEW_BYTES = 32 * 1024 * 1024 + +const MAX_NAME_LENGTH = 100 +const MAX_TTS_TEXT = 5_000 +const MAX_OPENAI_TTS_TEXT = 4_096 +const MAX_TEXT_FILE_BYTES = 64 * 1024 +const MAX_PROVIDER_KEY_LENGTH = 512 +const ELEVENLABS_VOICE_ID = /^[A-Za-z0-9]{1,64}$/ +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +// ── Validation ───────────────────────────────────────────────────────────── + +export function requireVoiceProject(projectId?: string): string { + if (projectId === undefined || projectId.trim().length === 0) { + throw new PolymorfaConfigurationError( + 'Provide --project with the project ID that owns the audio library.', + 'project', + ) + } + return projectId.trim() +} + +export function validateVoiceId(value: string, label: string): string { + const trimmed = value.trim() + if (!UUID.test(trimmed)) { + throw new PolymorfaConfigurationError(`${label} must be a UUID.`, label) + } + return trimmed +} + +export function validateName(name: string): string { + const trimmed = name.trim() + if (trimmed.length === 0 || trimmed.length > MAX_NAME_LENGTH) { + throw new PolymorfaConfigurationError( + `--name must be 1 to ${MAX_NAME_LENGTH} characters.`, + 'name', + ) + } + return trimmed +} + +/** Parse `--retention-days`: a whole number 1 to 3650, or `none` when allowed. */ +export function parseRetentionDays( + value: string, + allowNone: boolean, +): number | null { + const trimmed = value.trim().toLowerCase() + if (allowNone && trimmed === 'none') return null + const days = /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN + if (!Number.isInteger(days) || days < 1 || days > 3650) { + throw new PolymorfaConfigurationError( + allowNone + ? '--retention-days must be a whole number from 1 to 3650, or none to keep the audio until it is deleted.' + : '--retention-days must be a whole number from 1 to 3650.', + 'retention-days', + ) + } + return days +} + +export function uploadContentType( + path: string, +): KnownVoiceAudioUploadContentType { + const extension = extname(path).toLowerCase() + const contentType = UPLOAD_CONTENT_TYPES[extension] + if (contentType === undefined) { + throw new PolymorfaConfigurationError( + `Unsupported audio file type ${extension === '' ? '(no extension)' : extension}. Upload .mp3, .wav, .ogg, .opus, .m4a or .mp4.`, + 'file', + ) + } + return contentType +} + +/** Read an upload after checking its type and size, before any request. */ +export async function readUploadFile(path: string): Promise<{ + readonly bytes: Uint8Array + readonly contentType: KnownVoiceAudioUploadContentType +}> { + const contentType = uploadContentType(path) + const absolute = resolve(path) + const info = await stat(absolute).catch((error: unknown) => { + throw new PolymorfaConfigurationError( + `Cannot read ${path}: ${nodeErrorCode(error) ?? 'unknown error'}.`, + 'file', + ) + }) + if (!info.isFile()) { + throw new PolymorfaConfigurationError(`${path} is not a file.`, 'file') + } + assertUploadSize(info.size, path) + const bytes = new Uint8Array(await readFile(absolute)) + // The file may have changed between stat and read. + assertUploadSize(bytes.byteLength, path) + return {bytes, contentType} +} + +function assertUploadSize(size: number, path: string): void { + if (size === 0) { + throw new PolymorfaConfigurationError(`${path} is empty.`, 'file') + } + if (size > VOICE_AUDIO_MAX_UPLOAD_BYTES) { + throw new PolymorfaConfigurationError( + `${path} is ${size} bytes; audio uploads are limited to 16 MB (${VOICE_AUDIO_MAX_UPLOAD_BYTES} bytes).`, + 'file', + ) + } +} + +export function validateTtsText(text: string, provider: string): string { + const limit = provider === 'openai' ? MAX_OPENAI_TTS_TEXT : MAX_TTS_TEXT + if (text.trim().length === 0 || text.length > limit) { + throw new PolymorfaConfigurationError( + `The text must be 1 to ${limit} characters for ${provider}.`, + 'text', + ) + } + return text +} + +export function validateTtsVoice(voice: string, provider: string): string { + const trimmed = voice.trim() + if (provider === 'elevenlabs' && !ELEVENLABS_VOICE_ID.test(trimmed)) { + throw new PolymorfaConfigurationError( + '--voice must be an ElevenLabs voice ID: 1 to 64 letters and digits.', + 'voice', + ) + } + if (trimmed.length === 0) { + throw new PolymorfaConfigurationError('--voice is required.', 'voice') + } + return trimmed +} + +/** Read `--text-file`: a path, or `-` for standard input. */ +export async function readTextFile( + path: string, + input: Readable = process.stdin, +): Promise { + if (path === '-') { + const chunks: Buffer[] = [] + let bytes = 0 + for await (const chunk of input) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)) + bytes += buffer.byteLength + if (bytes > MAX_TEXT_FILE_BYTES) throw textTooLarge() + chunks.push(buffer) + } + return Buffer.concat(chunks) + .toString('utf8') + .replace(/\r?\n$/, '') + } + const handle = await open(resolve(path), 'r').catch((error: unknown) => { + throw new PolymorfaConfigurationError( + `Cannot read ${path}: ${nodeErrorCode(error) ?? 'unknown error'}.`, + 'text-file', + ) + }) + try { + const info = await handle.stat() + if (info.size > MAX_TEXT_FILE_BYTES) throw textTooLarge() + return (await handle.readFile('utf8')).replace(/\r?\n$/, '') + } finally { + await handle.close() + } +} + +function textTooLarge(): PolymorfaConfigurationError { + return new PolymorfaConfigurationError( + '--text-file exceeded 64 KiB.', + 'text-file', + ) +} + +// ── Provider key input ───────────────────────────────────────────────────── + +export interface ProviderKeySource { + readonly env: Readonly> + /** Name of the environment variable given with --key-env, if any. */ + readonly keyEnv?: string | undefined + readonly stdin: Readable & {readonly isTTY?: boolean} +} + +/** + * Read a provider key without ever taking it from a command argument. The + * environment variable wins when it is set (by default + * POLYMORFA_VOICE_PROVIDER_KEY); otherwise the key is read from piped + * standard input. An explicitly named variable that is empty is an error, and + * a terminal is refused so the key is never echoed. + */ +export async function readProviderKey( + source: ProviderKeySource, +): Promise { + const name = source.keyEnv ?? DEFAULT_PROVIDER_KEY_ENV + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + throw new PolymorfaConfigurationError( + '--key-env must name an environment variable.', + 'key-env', + ) + } + const fromEnv = source.env[name]?.trim() + let key: string + if (fromEnv !== undefined && fromEnv.length > 0) { + key = fromEnv + } else if (source.keyEnv !== undefined) { + throw new PolymorfaConfigurationError( + `Environment variable ${name} is not set.`, + 'key-env', + ) + } else if (source.stdin.isTTY === true) { + throw new PolymorfaConfigurationError( + `Pipe the provider key on standard input or set ${DEFAULT_PROVIDER_KEY_ENV}; the key is never read from an argument or typed at a visible prompt.`, + 'key', + ) + } else { + key = await readSecretFromStdin(source.stdin) + } + if (key.length > MAX_PROVIDER_KEY_LENGTH) { + throw new PolymorfaConfigurationError( + `The provider key must be 1 to ${MAX_PROVIDER_KEY_LENGTH} characters.`, + 'key', + ) + } + return key +} + +// ── Operations ───────────────────────────────────────────────────────────── + +export interface WaitOptions { + readonly intervalMs?: number + readonly sleep?: (milliseconds: number) => Promise + readonly timeoutSeconds: number +} + +export async function uploadAudio( + client: VoiceClient, + input: { + readonly bytes: Uint8Array + readonly contentType: KnownVoiceAudioUploadContentType + readonly name: string + readonly projectId: string + readonly retentionDays?: number + }, +): Promise> { + const uploaded = await client.uploadAudio(input) + return result(uploaded.data, uploaded.metadata) +} + +export async function synthesizeAudio( + client: VoiceClient, + input: Parameters[0], +): Promise> { + const response = await client.synthesizeAudio(input) + return result(response.data, response.metadata) +} + +/** + * Read the asset until it is ready or failed. A failed asset exits with + * voice_asset_failed (exit code 13) so scripts can tell it from success. + */ +export async function waitForAudio( + client: VoiceClient, + asset: VoiceResult, + options: WaitOptions, +): Promise> { + const intervalMs = options.intervalMs ?? WAIT_INTERVAL_MS + const sleep = options.sleep ?? defaultSleep + const deadline = Date.now() + options.timeoutSeconds * 1000 + let current = asset + const timedOut = () => + new PolymorfaTimeoutError( + `Audio ${current.data.id} was still ${current.data.status} after ${options.timeoutSeconds}s. Check it later with polymorfa voice audio view ${current.data.id}.`, + {code: 'request_timeout'}, + ) + // Like the SDK's waitUntilReady: read first, then sleep between reads. + while (current.data.status !== 'ready' && current.data.status !== 'failed') { + const remaining = deadline - Date.now() + if (remaining <= 0) throw timedOut() + // Bound the entire poll, including response-body reads and retries. + const expiry = new AbortController() + const timer = setTimeout(() => expiry.abort(), remaining) + let response + try { + response = await client.retrieveAudio(current.data.id, { + signal: expiry.signal, + }) + } catch (error) { + if (expiry.signal.aborted) throw timedOut() + throw error + } finally { + clearTimeout(timer) + } + if (Date.now() >= deadline) throw timedOut() + current = result(response.data, response.metadata) + if (current.data.status === 'ready' || current.data.status === 'failed') { + break + } + const left = deadline - Date.now() + if (left <= 0) throw timedOut() + await sleep(Math.min(intervalMs, left)) + } + if (current.data.status === 'failed') { + throw new CliConditionError( + 'voice_asset_failed', + `Audio ${current.data.id} failed: ${current.data.failureReason ?? 'unknown reason'}.`, + ) + } + return current +} + +export async function listAudio( + client: VoiceClient, + params: Parameters[0], +): Promise> { + const response = await client.listAudio(params) + return listResult(response.data, response.metadata) +} + +export async function viewAudio( + client: VoiceClient, + assetId: string, +): Promise> { + const response = await client.retrieveAudio(validateVoiceId(assetId, 'asset')) + return result(response.data, response.metadata) +} + +export async function updateAudio( + client: VoiceClient, + assetId: string, + input: { + readonly expectedRevision?: number | undefined + readonly name?: string | undefined + readonly retentionDays?: number | null | undefined + }, +): Promise> { + if (input.name === undefined && input.retentionDays === undefined) { + throw new PolymorfaConfigurationError( + 'Provide --name or --retention-days with the change to make.', + 'name', + ) + } + const response = await client.updateAudio(validateVoiceId(assetId, 'asset'), { + ...(input.expectedRevision === undefined + ? {} + : {expectedRevision: input.expectedRevision}), + ...(input.name === undefined ? {} : {name: validateName(input.name)}), + ...(input.retentionDays === undefined + ? {} + : {retentionDays: input.retentionDays}), + }) + return result(response.data, response.metadata) +} + +export async function deleteAudio( + client: VoiceClient, + assetId: string, +): Promise> { + const response = await client.deleteAudio(assetId) + return result(response.data, response.metadata) +} + +export type PreviewResult = + | { + readonly preview: VoiceAudioPreview + readonly download?: undefined + } + | { + readonly preview: Pick + readonly download: {readonly bytes: number; readonly path: string} + } + +export async function previewAudio( + client: VoiceClient, + assetId: string, + options: { + readonly force?: boolean + readonly output?: string | undefined + } = {}, +): Promise> { + const id = validateVoiceId(assetId, 'asset') + const output = + options.output === undefined ? undefined : resolve(options.output) + if (output !== undefined && options.force !== true) { + await rejectExistingOutput(output) + } + const response = await client.previewAudio(id) + if (output === undefined) { + return result({preview: response.data}, response.metadata) + } + const bytes = await client.downloadPreview(response.data, MAX_PREVIEW_BYTES) + try { + await writeFile(output, bytes, {flag: options.force === true ? 'w' : 'wx'}) + } catch (error) { + if (nodeErrorCode(error) === 'EEXIST') throw outputConflict(output) + throw error + } + return result( + { + download: {bytes: bytes.byteLength, path: output}, + preview: { + contentType: response.data.contentType, + expiresAt: response.data.expiresAt, + }, + }, + response.metadata, + ) +} + +export async function addCredential( + client: VoiceClient, + input: { + readonly apiKey: string + readonly label: string + readonly projectId?: string | undefined + readonly provider: string + }, +): Promise> { + const response = await client.createCredential({ + apiKey: input.apiKey, + label: validateLabel(input.label), + provider: input.provider, + ...(input.projectId === undefined ? {} : {projectId: input.projectId}), + }) + return result(response.data, response.metadata) +} + +export async function listCredentials( + client: VoiceClient, + projectId?: string, +): Promise> { + const response = await client.listCredentials(projectId) + return result(response.data, response.metadata) +} + +export async function viewCredential( + client: VoiceClient, + credentialId: string, +): Promise> { + const response = await client.retrieveCredential( + validateVoiceId(credentialId, 'credential'), + ) + return result(response.data, response.metadata) +} + +export async function verifyCredential( + client: VoiceClient, + credentialId: string, +): Promise> { + const response = await client.verifyCredential( + validateVoiceId(credentialId, 'credential'), + ) + return result(response.data, response.metadata) +} + +export async function deleteCredential( + client: VoiceClient, + credentialId: string, +): Promise> { + const response = await client.deleteCredential(credentialId) + return result(response.data, response.metadata) +} + +function validateLabel(label: string): string { + const trimmed = label.trim() + if (trimmed.length === 0 || trimmed.length > MAX_NAME_LENGTH) { + throw new PolymorfaConfigurationError( + `--label must be 1 to ${MAX_NAME_LENGTH} characters.`, + 'label', + ) + } + return trimmed +} + +// ── Rendering ────────────────────────────────────────────────────────────── + +export function renderAudioList( + items: readonly VoiceAudioAsset[], + page: VoiceListResult['page'], +): string { + const rows = items.map((asset) => + [ + asset.id, + asset.name, + asset.source, + asset.status, + duration(asset.durationMs), + asset.createdAt, + ].join('\t'), + ) + if (rows.length === 0) rows.push('No audio found.') + if (page.hasMore && page.nextCursor !== null) { + rows.push(`More results: --cursor ${page.nextCursor}`) + } + return rows.join('\n') +} + +export function renderAudio(asset: VoiceAudioAsset): string { + const lines = [ + `Audio\t${asset.id}`, + `Name\t${asset.name}`, + `Project\t${asset.projectId}`, + `Source\t${asset.source}`, + `Status\t${asset.status}`, + ] + if (asset.failureReason !== null) { + lines.push(`Failure reason\t${asset.failureReason}`) + } + lines.push( + `Duration\t${duration(asset.durationMs)}`, + `Original format\t${asset.originalFormat ?? '-'}`, + `Original size\t${asset.sizeBytes === null ? '-' : `${asset.sizeBytes} bytes`}`, + ) + if (asset.contentSha256 !== null) { + lines.push(`Content SHA-256\t${asset.contentSha256}`) + } + if (asset.tts !== null) { + lines.push( + `TTS provider\t${asset.tts.provider}`, + `TTS voice\t${asset.tts.voiceId}`, + `TTS model\t${asset.tts.model}`, + `TTS characters\t${asset.tts.characters}`, + `TTS key\t${ + asset.tts.credentialId === null + ? asset.tts.keySource + : `${asset.tts.keySource} (${asset.tts.credentialId})` + }`, + ) + } + lines.push( + `Retention\t${ + asset.retentionDays === null + ? 'kept until deleted' + : `${asset.retentionDays} days (expires ${asset.expiresAt ?? '-'})` + }`, + `In use\t${asset.inUseCount}`, + `Revision\t${asset.revision}`, + `Created\t${asset.createdAt}`, + `Updated\t${asset.updatedAt}`, + ) + if (asset.readyAt !== null) lines.push(`Ready\t${asset.readyAt}`) + return lines.join('\n') +} + +export function renderAudioSubmitted(asset: VoiceAudioAsset): string { + if (asset.status === 'ready' || asset.status === 'failed') { + return renderAudio(asset) + } + return `${renderAudio(asset)}\nProcessing continues; run polymorfa voice audio view ${asset.id} or pass --wait.` +} + +export function renderPreview(preview: PreviewResult): string { + if (preview.download !== undefined) { + return `Saved ${preview.download.bytes} bytes of ${preview.preview.contentType} to ${preview.download.path}.` + } + return [ + preview.preview.url, + `Expires ${preview.preview.expiresAt} (${preview.preview.contentType}). Treat the link as a secret.`, + ].join('\n') +} + +export function renderCredentials( + credentials: readonly VoiceProviderCredential[], +): string { + if (credentials.length === 0) return 'No provider credentials found.' + return credentials + .map((credential) => + [ + credential.id, + credential.provider, + credential.label, + credential.projectId ?? 'team-wide', + credential.status, + credential.keyFingerprint, + ].join('\t'), + ) + .join('\n') +} + +export function renderCredential(credential: VoiceProviderCredential): string { + return [ + `Credential\t${credential.id}`, + `Provider\t${credential.provider}`, + `Label\t${credential.label}`, + `Project\t${credential.projectId ?? 'team-wide'}`, + `Key fingerprint\t${credential.keyFingerprint}`, + `Status\t${credential.status}`, + `Last error\t${credential.lastError ?? '-'}`, + `Verified\t${credential.verifiedAt ?? '-'}`, + `Revision\t${credential.revision}`, + `Updated\t${credential.updatedAt}`, + ].join('\n') +} + +export function renderDeleted( + kind: 'audio' | 'provider credential', + deleted: VoiceResourceDeleted, +): string { + return `Deleted ${kind} ${deleted.id}.` +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function duration(durationMs: number | null): string { + if (durationMs === null) return '-' + return `${(durationMs / 1000).toFixed(1)}s` +} + +async function rejectExistingOutput(output: string): Promise { + try { + await stat(output) + } catch (error) { + if (nodeErrorCode(error) === 'ENOENT') return + throw error + } + throw outputConflict(output) +} + +function outputConflict(output: string): PolymorfaConflictError { + return new PolymorfaConflictError( + `Output file ${output} already exists. Pass --force to overwrite it.`, + ) +} + +function nodeErrorCode(error: unknown): string | undefined { + return (error as {code?: string} | undefined)?.code +} + +function defaultSleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +function metadataOf( + metadata: ResponseMetadata, +): VoiceResult['metadata'] { + return { + attempts: metadata.attempts, + status: metadata.status, + ...(metadata.apiVersion === undefined + ? {} + : {apiVersion: metadata.apiVersion}), + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + } +} + +function result(data: T, metadata: ResponseMetadata): VoiceResult { + return {data, metadata: metadataOf(metadata)} +} + +function listResult( + page: { + readonly hasMore: boolean + readonly items: readonly T[] + readonly nextCursor: string | null + }, + metadata: ResponseMetadata, +): VoiceListResult { + return { + data: page.items, + metadata: metadataOf(metadata), + page: {hasMore: page.hasMore, nextCursor: page.nextCursor}, + } +} diff --git a/test/commands/voice-wait-timeout.test.ts b/test/commands/voice-wait-timeout.test.ts new file mode 100644 index 00000000..032d06d5 --- /dev/null +++ b/test/commands/voice-wait-timeout.test.ts @@ -0,0 +1,129 @@ +import {spawn} from 'node:child_process' +import {once} from 'node:events' +import {rm} from 'node:fs/promises' +import {createServer} from 'node:http' +import {fileURLToPath} from 'node:url' +import {expect, test} from 'vitest' + +import {temporaryDirectory} from '../support/fs.js' + +const API_KEY = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const ASSET_ID = '6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10' +const PROJECT = '018f0000-0000-7000-8000-000000000002' + +test.each(['headers', 'body'] as const)( + 'exits at --wait-timeout and closes a poll stalled on its %s', + async (stall) => { + let polls = 0 + let pollStarted = 0 + let pollClosed = false + const server = createServer((request, reply) => { + request.resume() + if ( + request.method === 'POST' && + request.url === '/platform/voice/audio/tts' + ) { + reply.writeHead(201, {'content-type': 'application/json'}) + reply.end( + JSON.stringify({ + data: {id: ASSET_ID, status: 'transcoding'}, + success: true, + }), + ) + return + } + if ( + request.method === 'GET' && + request.url === `/platform/voice/audio/${ASSET_ID}` + ) { + polls += 1 + pollStarted = Date.now() + reply.once('close', () => { + pollClosed = true + }) + if (stall === 'body') { + reply.writeHead(200, {'content-type': 'application/json'}) + reply.write('{"success":true,"data":') + } + return + } + reply.writeHead(404).end() + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') + throw new Error('Server did not bind') + const directory = await temporaryDirectory() + const child = spawn( + process.execPath, + [ + '--import', + 'tsx', + fileURLToPath(new URL('../../bin/dev.js', import.meta.url)), + 'voice', + 'audio', + 'tts', + '--name', + 'Greeting', + '--text', + 'Hello', + '--provider', + 'openai', + '--voice', + 'alloy', + '--project', + PROJECT, + '--wait', + '--wait-timeout', + '1', + '--timeout', + '30000', + '--json', + ], + { + env: { + ...process.env, + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: `http://127.0.0.1:${address.port}`, + POLYMORFA_CLIENT_TOKEN: '', + POLYMORFA_PROJECT_TOKEN: '', + POLYMORFA_CONFIG_DIR: directory, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { + stdout += String(chunk) + }) + child.stderr.on('data', (chunk: Buffer) => { + stderr += String(chunk) + }) + let timer: NodeJS.Timeout | undefined + const closed = once(child, 'close') + try { + const outcome = await Promise.race([ + closed, + new Promise<'watchdog'>((resolve) => { + timer = setTimeout(() => resolve('watchdog'), 8_000) + }), + ]) + expect(polls).toBe(1) + expect(outcome, stderr).toEqual([5, null]) + expect(Date.now() - pollStarted).toBeLessThan(2_500) + expect(pollClosed).toBe(true) + expect(stdout + stderr).toContain('request_timeout') + expect(stdout + stderr).not.toContain(API_KEY) + } finally { + clearTimeout(timer) + child.kill() + await closed + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + await rm(directory, {force: true, recursive: true}) + } + }, + 15_000, +) diff --git a/test/commands/voice.test.ts b/test/commands/voice.test.ts new file mode 100644 index 00000000..7f3109da --- /dev/null +++ b/test/commands/voice.test.ts @@ -0,0 +1,794 @@ +import {runCommand} from '@oclif/test' +import {mkdtemp, readFile, rm, truncate, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {Readable} from 'node:stream' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpSequenceServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const API_KEY = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const PROVIDER_KEY = 'sk-provider-secret-0123456789' +const PROJECT = '018f0000-0000-7000-8000-000000000002' +const ASSET_ID = '6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10' +const CREDENTIAL_ID = '7a2d3e4f-5b6c-4d7e-8f90-1a2b3c4d5e6f' + +const asset = { + contentSha256: null, + createdAt: '2026-09-19T10:00:00.000Z', + durationMs: null, + expiresAt: null, + failureReason: null, + id: ASSET_ID, + inUseCount: 0, + name: 'Greeting', + originalContentType: 'audio/mpeg', + originalFormat: 'mp3', + projectId: PROJECT, + readyAt: null, + retentionDays: null, + revision: 1, + sizeBytes: 4, + source: 'upload', + status: 'transcoding', + tts: null, + updatedAt: '2026-09-19T10:00:00.000Z', +} + +const readyAsset = { + ...asset, + contentSha256: 'ab'.repeat(32), + durationMs: 12_345, + readyAt: '2026-09-19T10:00:05.000Z', + revision: 2, + status: 'ready', +} + +const credential = { + createdAt: '2026-09-19T10:00:00.000Z', + id: CREDENTIAL_ID, + keyFingerprint: '1a2b3c4d', + label: 'Studio key', + lastError: null, + projectId: null, + provider: 'elevenlabs', + revision: 1, + status: 'valid', + updatedAt: '2026-09-19T10:00:00.000Z', + verifiedAt: '2026-09-19T10:00:00.000Z', +} + +function ok(data: unknown, status = 200) { + return {body: {data, success: true}, status} +} + +function failure(status: number, code: string, message: string) { + return {body: {error: {code, message}, success: false}, status} +} + +describe('voice audio commands', () => { + let server: TestHttpServer | undefined + let directory: string | undefined + + afterEach(async () => { + await server?.close() + server = undefined + if (directory !== undefined) + await rm(directory, {force: true, recursive: true}) + directory = undefined + }) + + test('uploads a file without the credential, completes it, and waits until ready', async () => { + server = await startHttpSequenceServer([ + ok( + { + asset: {...asset, status: 'pending_upload'}, + upload: { + expiresAt: '2026-09-19T10:05:00.000Z', + headers: {'Content-Type': 'audio/mpeg'}, + maxBytes: 16_777_216, + method: 'POST', + url: '/upload/capability-token', + }, + }, + 201, + ), + {body: {storageId: 'storage_1'}, status: 201}, + ok(asset), + ok(readyAsset), + ]) + directory = await mkdtemp(join(tmpdir(), 'polymorfa-voice-')) + const file = join(directory, 'greeting.MP3') + await writeFile(file, Buffer.from([0x49, 0x44, 0x33, 0x04])) + + const uploaded = await apiKey(() => + runCommand( + `voice audio upload ${file} --name Greeting --retention-days 30 --wait --project ${PROJECT}`, + ), + ) + + expect(uploaded.error, uploaded.stderr).toBeUndefined() + expect(uploaded.stdout).toContain('Status\tready') + expect(uploaded.stdout).toContain('Duration\t12.3s') + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'POST /platform/voice/audio', + 'POST /upload/capability-token', + `POST /platform/voice/audio/${ASSET_ID}/complete`, + `GET /platform/voice/audio/${ASSET_ID}`, + ]) + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + contentType: 'audio/mpeg', + name: 'Greeting', + projectId: PROJECT, + retentionDays: 30, + sizeBytes: 4, + }) + const upload = server.requests[1] + expect(upload?.headers.authorization).toBeUndefined() + expect(upload?.headers['content-type']).toBe('audio/mpeg') + expect(Buffer.from(upload?.body ?? '', 'utf8')).toHaveLength(4) + expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${API_KEY}`) + }) + + test('detects the content type from each supported extension', async () => { + const types: Record = { + 'a.m4a': 'audio/x-m4a', + 'a.mp4': 'audio/mp4', + 'a.ogg': 'audio/ogg', + 'a.opus': 'audio/ogg', + 'a.wav': 'audio/wav', + } + directory = await mkdtemp(join(tmpdir(), 'polymorfa-voice-')) + for (const [name, contentType] of Object.entries(types)) { + server = await startHttpSequenceServer([ + failure(403, 'voice_not_enabled', 'Voice Automation is not enabled.'), + ]) + const file = join(directory, name) + await writeFile(file, 'RIFF') + await apiKey(() => + runCommand( + `voice audio upload ${file} --name Clip --project ${PROJECT}`, + ), + ) + expect( + (JSON.parse(server.requests[0]?.body ?? '') as {contentType: string}) + .contentType, + ).toBe(contentType) + await server.close() + server = undefined + } + }) + + test('refuses unsupported, empty, and oversized files before calling the API', async () => { + server = await startHttpSequenceServer([ok(asset)]) + directory = await mkdtemp(join(tmpdir(), 'polymorfa-voice-')) + const flac = join(directory, 'clip.flac') + await writeFile(flac, 'fLaC') + const empty = join(directory, 'empty.wav') + await writeFile(empty, '') + const large = join(directory, 'large.wav') + await writeFile(large, '') + await truncate(large, 16_777_217) + const limit = join(directory, 'limit.wav') + await writeFile(limit, '') + await truncate(limit, 16_777_216) + + const unsupported = await apiKey(() => + runCommand(`voice audio upload ${flac} --name Clip --project ${PROJECT}`), + ) + const emptied = await apiKey(() => + runCommand( + `voice audio upload ${empty} --name Clip --project ${PROJECT}`, + ), + ) + const oversized = await apiKey(() => + runCommand( + `voice audio upload ${large} --name Clip --project ${PROJECT} --json`, + ), + ) + const missingProject = await apiKey(() => + runCommand(`voice audio upload ${limit} --name Clip`), + ) + const badRetention = await apiKey(() => + runCommand( + `voice audio upload ${limit} --name Clip --retention-days 0 --project ${PROJECT}`, + ), + ) + + expect(exitCode(unsupported.error)).toBe(2) + expect(unsupported.stderr).toContain('Unsupported audio file type .flac') + expect(exitCode(emptied.error)).toBe(2) + expect(exitCode(oversized.error)).toBe(2) + expect( + (JSON.parse(oversized.stderr) as {error: {message: string}}).error + .message, + ).toContain('limited to 16 MB') + expect(exitCode(missingProject.error)).toBe(2) + expect(exitCode(badRetention.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + + test('exits 13 when the awaited audio fails processing', async () => { + server = await startHttpSequenceServer([ + ok({...asset, source: 'tts', status: 'transcoding'}, 201), + ok({ + ...asset, + failureReason: 'tts_failed', + source: 'tts', + status: 'failed', + }), + ]) + + const failed = await apiKey(() => + runCommand( + `voice audio tts --name Welcome --text Hello --provider openai --voice alloy --wait --project ${PROJECT} --json`, + ), + ) + + expect(exitCode(failed.error)).toBe(13) + expect(JSON.parse(failed.stderr)).toMatchObject({ + error: { + code: 'voice_asset_failed', + message: expect.stringContaining('tts_failed') as unknown, + }, + }) + }) + + test('maps voice_not_enabled and gate_limit_reached to distinct exit codes', async () => { + server = await startHttpSequenceServer([ + failure( + 403, + 'voice_not_enabled', + 'Voice Automation is not enabled for this team.', + ), + failure(402, 'gate_limit_reached', 'The voice audio limit is reached.'), + failure(403, 'permission_denied', 'Denied.'), + failure(402, 'payment_required', 'Add credit.'), + ]) + + const notEnabled = await apiKey(() => + runCommand( + `voice audio tts --name A --text Hi --provider openai --voice alloy --project ${PROJECT} --json`, + ), + ) + const limited = await apiKey(() => + runCommand( + `voice audio tts --name A --text Hi --provider openai --voice alloy --project ${PROJECT}`, + ), + ) + const denied = await apiKey(() => + runCommand( + `voice audio tts --name A --text Hi --provider openai --voice alloy --project ${PROJECT}`, + ), + ) + const unpaid = await apiKey(() => + runCommand( + `voice audio tts --name A --text Hi --provider openai --voice alloy --project ${PROJECT}`, + ), + ) + + expect(exitCode(notEnabled.error)).toBe(6) + expect(JSON.parse(notEnabled.stderr)).toMatchObject({ + error: {category: 'authorization', code: 'voice_not_enabled'}, + }) + expect(exitCode(limited.error)).toBe(7) + expect(limited.stderr).toContain('The voice audio limit is reached.') + expect(exitCode(denied.error)).toBe(3) + expect(exitCode(unpaid.error)).toBe(4) + }) + + test('synthesizes text from a file with a stored credential', async () => { + server = await startHttpSequenceServer([ + ok( + { + ...asset, + source: 'tts', + tts: { + characters: 11, + credentialId: CREDENTIAL_ID, + keySource: 'customer', + model: 'eleven_flash_v2_5', + provider: 'elevenlabs', + text: 'Hello there', + voiceId: 'voice123', + }, + }, + 201, + ), + ]) + directory = await mkdtemp(join(tmpdir(), 'polymorfa-voice-')) + const text = join(directory, 'menu.txt') + await writeFile(text, 'Hello there\n') + + const created = await apiKey(() => + runCommand( + `voice audio tts --name Menu --text-file ${text} --provider elevenlabs --voice voice123 --model eleven_flash_v2_5 --credential ${CREDENTIAL_ID} --retention-days 7 --project ${PROJECT}`, + ), + ) + const badVoice = await apiKey(() => + runCommand( + `voice audio tts --name Menu --text Hi --provider elevenlabs --voice bad-voice! --project ${PROJECT}`, + ), + ) + const tooLong = await apiKey(() => + runCommand( + `voice audio tts --name Menu --text ${'a'.repeat(4097)} --provider openai --voice alloy --project ${PROJECT}`, + ), + ) + const both = await apiKey(() => + runCommand( + `voice audio tts --name Menu --text Hi --text-file ${text} --provider openai --voice alloy --project ${PROJECT}`, + ), + ) + const badProvider = await apiKey(() => + runCommand( + `voice audio tts --name Menu --text Hi --provider azure --voice alloy --project ${PROJECT}`, + ), + ) + + expect(created.error, created.stderr).toBeUndefined() + expect(created.stdout).toContain('TTS key\tcustomer') + expect(created.stdout).toContain('pass --wait') + expect(server.requests[0]?.url).toBe('/platform/voice/audio/tts') + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + credentialId: CREDENTIAL_ID, + model: 'eleven_flash_v2_5', + name: 'Menu', + projectId: PROJECT, + provider: 'elevenlabs', + retentionDays: 7, + text: 'Hello there', + voiceId: 'voice123', + }) + expect(exitCode(badVoice.error)).toBe(2) + expect(exitCode(tooLong.error)).toBe(2) + expect(exitCode(both.error)).toBe(2) + expect(exitCode(badProvider.error)).toBe(2) + expect(server.requests).toHaveLength(1) + }) + + test('rejects provider-specific voices and models before sending TTS requests', async () => { + server = await startHttpSequenceServer([ok(asset)]) + const base = `voice audio tts --name Greeting --text Hello --project ${PROJECT}` + const badVoice = await apiKey(() => + runCommand(`${base} --provider openai --voice unsupported --json`), + ) + const badOpenAiModel = await apiKey(() => + runCommand(`${base} --provider openai --voice alloy --model unsupported`), + ) + const badElevenModel = await apiKey(() => + runCommand( + `${base} --provider elevenlabs --voice voice123 --model unsupported --json`, + ), + ) + + expect(exitCode(badVoice.error)).toBe(2) + expect(JSON.parse(badVoice.stderr)).toMatchObject({ + error: {category: 'configuration', message: 'Unsupported OpenAI voice.'}, + }) + expect(exitCode(badOpenAiModel.error)).toBe(2) + expect(badOpenAiModel.stderr).toContain('Unsupported OpenAI model.') + expect(exitCode(badElevenModel.error)).toBe(2) + expect(JSON.parse(badElevenModel.stderr)).toMatchObject({ + error: { + category: 'configuration', + message: 'Unsupported ElevenLabs model.', + }, + }) + expect(server.requests).toHaveLength(0) + }) + + test('lists with filters and shows the next cursor', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: [readyAsset], + page: {hasMore: true, nextCursor: 'cursor_2'}, + success: true, + }, + }, + { + body: { + data: [], + page: {hasMore: false, nextCursor: null}, + success: true, + }, + }, + ]) + + const listed = await apiKey(() => + runCommand( + `voice audio list --status ready --limit 10 --cursor cursor_1 --project ${PROJECT}`, + ), + ) + const json = await apiKey(() => + runCommand(`voice audio list --project ${PROJECT} --json`), + ) + const badStatus = await apiKey(() => + runCommand(`voice audio list --status done --project ${PROJECT}`), + ) + + expect(listed.error, listed.stderr).toBeUndefined() + expect(listed.stdout).toBe( + `${ASSET_ID}\tGreeting\tupload\tready\t12.3s\t2026-09-19T10:00:00.000Z\nMore results: --cursor cursor_2\n`, + ) + expect(server.requests[0]?.url).toBe( + `/platform/voice/audio?projectId=${PROJECT}&status=ready&cursor=cursor_1&limit=10`, + ) + expect(JSON.parse(json.stdout)).toMatchObject({ + data: [], + page: {hasMore: false, nextCursor: null}, + }) + expect(exitCode(badStatus.error)).toBe(2) + expect(server.requests).toHaveLength(2) + }) + + test('views and updates with a revision guard', async () => { + server = await startHttpSequenceServer([ + ok(readyAsset), + ok({...readyAsset, name: 'Greeting v2', revision: 3}), + ok({...readyAsset, retentionDays: null, revision: 4}), + failure(409, 'voice_asset_revision_conflict', 'The asset changed.'), + ]) + + const viewed = await apiKey(() => + runCommand(`voice audio view ${ASSET_ID}`), + ) + const renamed = await apiKey(() => + runCommand( + `voice audio update ${ASSET_ID} --name "Greeting v2" --expected-revision 2`, + ), + ) + const forever = await apiKey(() => + runCommand(`voice audio update ${ASSET_ID} --retention-days none`), + ) + const stale = await apiKey(() => + runCommand( + `voice audio update ${ASSET_ID} --retention-days 5 --expected-revision 2`, + ), + ) + const nothing = await apiKey(() => + runCommand(`voice audio update ${ASSET_ID} --expected-revision 2`), + ) + const badId = await apiKey(() => runCommand('voice audio view nope')) + + expect(viewed.stdout).toContain('Content SHA-256\t' + 'ab'.repeat(32)) + expect(viewed.stdout).toContain('Retention\tkept until deleted') + expect(renamed.stdout).toContain('Name\tGreeting v2') + expect(server.requests.map(({body, method}) => [method, body])).toEqual([ + ['GET', ''], + ['PATCH', JSON.stringify({expectedRevision: 2, name: 'Greeting v2'})], + ['PATCH', JSON.stringify({retentionDays: null})], + ['PATCH', JSON.stringify({expectedRevision: 2, retentionDays: 5})], + ]) + expect(forever.error).toBeUndefined() + expect(exitCode(stale.error)).toBe(4) + expect(stale.stderr).toContain('The asset changed.') + expect(exitCode(nothing.error)).toBe(2) + expect(exitCode(badId.error)).toBe(2) + }) + + test('requires confirmation to delete and reports audio in use', async () => { + server = await startHttpSequenceServer([ + ok({deleted: true, id: ASSET_ID}), + failure(409, 'voice_asset_in_use', 'A campaign uses this audio.'), + ]) + + const unconfirmed = await apiKey(() => + runCommand(`voice audio delete ${ASSET_ID}`), + ) + const deleted = await apiKey(() => + runCommand(`voice audio delete ${ASSET_ID} --yes`), + ) + const inUse = await apiKey(() => + runCommand(`voice audio delete ${ASSET_ID} --yes --json`), + ) + + expect(exitCode(unconfirmed.error)).toBe(2) + expect(deleted.stdout).toBe(`Deleted audio ${ASSET_ID}.\n`) + expect(exitCode(inUse.error)).toBe(4) + expect(JSON.parse(inUse.stderr)).toMatchObject({ + error: {code: 'voice_asset_in_use'}, + }) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + `DELETE /platform/voice/audio/${ASSET_ID}`, + `DELETE /platform/voice/audio/${ASSET_ID}`, + ]) + }) + + test('prints the preview link or downloads it without the credential', async () => { + const preview = { + contentType: 'audio/ogg', + expiresAt: '2026-09-19T10:05:00.000Z', + url: '/previews/signed-capability', + } + server = await startHttpSequenceServer([ + ok(preview), + ok(preview), + {rawBody: Buffer.from('OggS-audio')}, + failure(409, 'asset_not_ready', 'The audio is not ready.'), + ]) + directory = await mkdtemp(join(tmpdir(), 'polymorfa-voice-')) + const output = join(directory, 'greeting.ogg') + + const printed = await apiKey(() => + runCommand(`voice audio preview ${ASSET_ID}`), + ) + const downloaded = await apiKey(() => + runCommand(`voice audio preview ${ASSET_ID} --output ${output}`), + ) + const exists = await apiKey(() => + runCommand(`voice audio preview ${ASSET_ID} --output ${output}`), + ) + const notReady = await apiKey(() => + runCommand(`voice audio preview ${ASSET_ID}`), + ) + + expect(printed.stdout).toContain('/previews/signed-capability') + expect(printed.stdout).toContain('Treat the link as a secret.') + expect(downloaded.error, downloaded.stderr).toBeUndefined() + expect(downloaded.stdout).toContain('Saved 10 bytes of audio/ogg') + expect(await readFile(output, 'utf8')).toBe('OggS-audio') + expect(server.requests[2]?.url).toBe('/previews/signed-capability') + expect(server.requests[2]?.headers.authorization).toBeUndefined() + expect(exitCode(exists.error)).toBe(4) + expect(exitCode(notReady.error)).toBe(4) + expect(notReady.stderr).toContain('The audio is not ready.') + // The existing output file is refused before any request. + expect( + server.requests.slice(0, 4).map(({method, url}) => `${method} ${url}`), + ).toEqual([ + `GET /platform/voice/audio/${ASSET_ID}/preview`, + `GET /platform/voice/audio/${ASSET_ID}/preview`, + 'GET /previews/signed-capability', + `GET /platform/voice/audio/${ASSET_ID}/preview`, + ]) + }) + + test('omits the signed link from JSON after downloading a preview', async () => { + const signedUrl = '/previews/signed-capability' + server = await startHttpSequenceServer([ + ok({ + contentType: 'audio/ogg', + expiresAt: '2026-09-19T10:05:00.000Z', + url: signedUrl, + }), + {rawBody: Buffer.from('OggS-audio')}, + ]) + directory = await mkdtemp(join(tmpdir(), 'polymorfa-voice-')) + const output = join(directory, 'greeting.ogg') + + const downloaded = await apiKey(() => + runCommand(`voice audio preview ${ASSET_ID} --output ${output} --json`), + ) + + expect(downloaded.error, downloaded.stderr).toBeUndefined() + expect(JSON.parse(downloaded.stdout)).toMatchObject({ + data: {download: {bytes: 10, path: output}}, + }) + expect(downloaded.stdout).not.toContain(signedUrl) + expect(await readFile(output, 'utf8')).toBe('OggS-audio') + expect(server.requests[1]?.url).toBe(signedUrl) + expect(server.requests[1]?.headers.authorization).toBeUndefined() + }) + + async function apiKey(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_VOICE_PROVIDER_KEY: undefined, + }, + run, + ) + } +}) + +describe('voice credential commands', () => { + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + test('adds a key from the default environment variable and never prints it', async () => { + server = await startHttpSequenceServer([ok(credential, 201)]) + + const added = await apiKey( + () => + runCommand( + 'voice credential add --provider elevenlabs --label "Studio key" --json', + ), + {POLYMORFA_VOICE_PROVIDER_KEY: PROVIDER_KEY}, + ) + + expect(added.error, added.stderr).toBeUndefined() + expect(JSON.parse(added.stdout)).toMatchObject({ + data: {keyFingerprint: '1a2b3c4d', projectId: null}, + }) + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + apiKey: PROVIDER_KEY, + label: 'Studio key', + provider: 'elevenlabs', + }) + expect(added.stdout).not.toContain(PROVIDER_KEY) + expect(added.stderr).not.toContain(PROVIDER_KEY) + }) + + test('adds a project key from a named variable or standard input', async () => { + server = await startHttpSequenceServer([ + ok({...credential, projectId: PROJECT, provider: 'openai'}, 201), + ok({...credential, projectId: PROJECT, provider: 'openai'}, 201), + ]) + + const fromNamed = await apiKey( + () => + runCommand( + `voice credential add --provider openai --label Support --key-env OPENAI_TEST_KEY --project ${PROJECT}`, + ), + {OPENAI_TEST_KEY: PROVIDER_KEY}, + ) + const fromStdin = await apiKey(() => + withStdin(`${PROVIDER_KEY}\n`, () => + runCommand( + `voice credential add --provider openai --label Support --project ${PROJECT}`, + ), + ), + ) + const missingNamed = await apiKey(() => + runCommand( + 'voice credential add --provider openai --label Support --key-env OPENAI_TEST_KEY', + ), + ) + const keyFlag = await apiKey(() => + runCommand( + `voice credential add --provider openai --label Support --key ${PROVIDER_KEY}`, + ), + ) + + expect(fromNamed.error, fromNamed.stderr).toBeUndefined() + expect(fromNamed.stdout).toContain(`Project\t${PROJECT}`) + expect(fromStdin.error, fromStdin.stderr).toBeUndefined() + for (const request of server.requests) { + expect(JSON.parse(request.body)).toEqual({ + apiKey: PROVIDER_KEY, + label: 'Support', + projectId: PROJECT, + provider: 'openai', + }) + } + expect(exitCode(missingNamed.error)).toBe(2) + expect(missingNamed.stderr).toContain('OPENAI_TEST_KEY is not set') + expect(exitCode(keyFlag.error)).toBe(2) + expect(server.requests).toHaveLength(2) + for (const output of [fromNamed, fromStdin, missingNamed]) { + expect(output.stdout).not.toContain(PROVIDER_KEY) + expect(output.stderr).not.toContain(PROVIDER_KEY) + } + }) + + test('redacts the key from a rejected credential error', async () => { + server = await startHttpSequenceServer([ + failure( + 422, + 'provider_credential_invalid', + `The provider rejected ${PROVIDER_KEY}.`, + ), + failure(403, 'voice_not_enabled', 'Voice Automation is not enabled.'), + ]) + + const rejected = await apiKey( + () => + runCommand('voice credential add --provider openai --label Bad --json'), + {POLYMORFA_VOICE_PROVIDER_KEY: PROVIDER_KEY}, + ) + const notEnabled = await apiKey( + () => runCommand('voice credential add --provider openai --label Bad'), + {POLYMORFA_VOICE_PROVIDER_KEY: PROVIDER_KEY}, + ) + + expect(exitCode(rejected.error)).toBe(2) + expect(JSON.parse(rejected.stderr)).toMatchObject({ + error: {code: 'provider_credential_invalid'}, + }) + expect(rejected.stderr).toContain('[REDACTED]') + expect(rejected.stderr).not.toContain(PROVIDER_KEY) + expect(exitCode(notEnabled.error)).toBe(6) + }) + + test('lists, views, verifies, and deletes credentials', async () => { + server = await startHttpSequenceServer([ + ok([credential, {...credential, id: ASSET_ID, projectId: PROJECT}]), + ok(credential), + ok({...credential, lastError: 'quota_exceeded', status: 'invalid'}), + failure(503, 'provider_unavailable', 'The provider did not answer.'), + ok({deleted: true, id: CREDENTIAL_ID}), + ]) + + const listed = await apiKey(() => + runCommand(`voice credential list --project ${PROJECT}`), + ) + const viewed = await apiKey(() => + runCommand(`voice credential view ${CREDENTIAL_ID}`), + ) + const verified = await apiKey(() => + runCommand(`voice credential verify ${CREDENTIAL_ID}`), + ) + const unavailable = await apiKey(() => + runCommand(`voice credential verify ${CREDENTIAL_ID}`), + ) + const unconfirmed = await apiKey(() => + runCommand(`voice credential delete ${CREDENTIAL_ID}`), + ) + const deleted = await apiKey(() => + runCommand(`voice credential delete ${CREDENTIAL_ID} --yes`), + ) + + expect(listed.stdout).toBe( + [ + `${CREDENTIAL_ID}\televenlabs\tStudio key\tteam-wide\tvalid\t1a2b3c4d`, + `${ASSET_ID}\televenlabs\tStudio key\t${PROJECT}\tvalid\t1a2b3c4d`, + '', + ].join('\n'), + ) + expect(viewed.stdout).toContain('Key fingerprint\t1a2b3c4d') + expect(verified.stdout).toContain('Status\tinvalid') + expect(verified.stdout).toContain('Last error\tquota_exceeded') + expect(exitCode(unavailable.error)).toBe(5) + expect(exitCode(unconfirmed.error)).toBe(2) + expect(deleted.stdout).toBe( + `Deleted provider credential ${CREDENTIAL_ID}.\n`, + ) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + `GET /platform/voice/provider-credentials?projectId=${PROJECT}`, + `GET /platform/voice/provider-credentials/${CREDENTIAL_ID}`, + `POST /platform/voice/provider-credentials/${CREDENTIAL_ID}/verify`, + `POST /platform/voice/provider-credentials/${CREDENTIAL_ID}/verify`, + `DELETE /platform/voice/provider-credentials/${CREDENTIAL_ID}`, + ]) + }) + + async function apiKey( + run: () => Promise, + extra: Readonly> = {}, + ): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + OPENAI_TEST_KEY: undefined, + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_VOICE_PROVIDER_KEY: undefined, + ...extra, + }, + run, + ) + } +}) + +function exitCode(error: unknown): number | undefined { + return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit +} + +async function withStdin(value: string, run: () => Promise): Promise { + const descriptor = Object.getOwnPropertyDescriptor(process, 'stdin') + Object.defineProperty(process, 'stdin', { + configurable: true, + value: Readable.from([value]), + }) + try { + return await run() + } finally { + if (descriptor !== undefined) + Object.defineProperty(process, 'stdin', descriptor) + } +} diff --git a/test/errors/present.test.ts b/test/errors/present.test.ts index 0a0d9d81..ff74055d 100644 --- a/test/errors/present.test.ts +++ b/test/errors/present.test.ts @@ -1,9 +1,11 @@ import { PolymorfaAuthenticationError, + PolymorfaAuthorizationError, PolymorfaRateLimitError, PolymorfaCancelledError, PolymorfaConfigurationError, PolymorfaConflictError, + PolymorfaPaymentRequiredError, } from '@polymorfa/sdk' import {describe, expect, test} from 'vitest' @@ -121,6 +123,38 @@ describe('presentError', () => { }) }) + test('gives voice_not_enabled and gate_limit_reached their own exit codes', () => { + expect( + presentError( + new PolymorfaAuthorizationError('Not enrolled.', { + code: 'voice_not_enabled', + status: 403, + }), + ), + ).toMatchObject({ + category: 'authorization', + code: 'voice_not_enabled', + exitCode: 6, + }) + expect( + presentError( + new PolymorfaPaymentRequiredError('Limit reached.', { + code: 'gate_limit_reached', + status: 402, + }), + ), + ).toMatchObject({ + category: 'payment_required', + code: 'gate_limit_reached', + exitCode: 7, + }) + expect( + presentError( + new PolymorfaAuthorizationError('Denied.', {code: 'toString'}), + ), + ).toMatchObject({category: 'authorization', exitCode: 3}) + }) + test('carries the request log link and doc URL from the error body', () => { const error = new PolymorfaRateLimitError( 'WhatsApp is limiting requests.', diff --git a/test/services/voice.test.ts b/test/services/voice.test.ts new file mode 100644 index 00000000..c4d8744a --- /dev/null +++ b/test/services/voice.test.ts @@ -0,0 +1,368 @@ +import {readdir, rm} from 'node:fs/promises' +import {join} from 'node:path' +import {PassThrough, Readable} from 'node:stream' +import {inspect} from 'node:util' +import {describe, expect, test, vi} from 'vitest' + +import {createVoiceClient, type VoiceClient} from '../../src/sdk/voice.js' +import { + MAX_PREVIEW_BYTES, + previewAudio, + readProviderKey, + uploadContentType, + waitForAudio, + type VoiceResult, +} from '../../src/services/voice.js' +import type {VoiceAudioAsset} from '../../src/sdk/voice.js' +import {temporaryDirectory} from '../support/fs.js' + +const KEY = 'sk-provider-secret-0123456789' + +function stdin(value: string | undefined, isTTY = false) { + const stream = ( + value === undefined ? new PassThrough() : Readable.from([value]) + ) as Readable & {isTTY?: boolean} + stream.isTTY = isTTY + return stream +} + +describe('readProviderKey', () => { + test('prefers the default environment variable over standard input', async () => { + await expect( + readProviderKey({ + env: {POLYMORFA_VOICE_PROVIDER_KEY: ` ${KEY} `}, + stdin: stdin('other'), + }), + ).resolves.toBe(KEY) + }) + + test('reads piped standard input when no variable is set', async () => { + await expect( + readProviderKey({env: {}, stdin: stdin(`${KEY}\n`)}), + ).resolves.toBe(KEY) + }) + + test('refuses a terminal so the key is never echoed', async () => { + await expect( + readProviderKey({env: {}, stdin: stdin(undefined, true)}), + ).rejects.toThrow(/never read from an argument/) + }) + + test('refuses an unset named variable, a bad name, and an oversized key', async () => { + await expect( + readProviderKey({env: {}, keyEnv: 'MY_KEY', stdin: stdin(KEY)}), + ).rejects.toThrow('Environment variable MY_KEY is not set.') + await expect( + readProviderKey({env: {}, keyEnv: 'not a name', stdin: stdin(KEY)}), + ).rejects.toThrow('--key-env must name an environment variable.') + await expect( + readProviderKey({ + env: {MY_KEY: 'k'.repeat(513)}, + keyEnv: 'MY_KEY', + stdin: stdin(KEY), + }), + ).rejects.toThrow('1 to 512 characters') + }) +}) + +describe('uploadContentType', () => { + test('maps extensions case-insensitively and refuses others', () => { + expect(uploadContentType('a.MP3')).toBe('audio/mpeg') + expect(uploadContentType('a.opus')).toBe('audio/ogg') + expect(() => uploadContentType('a.aac')).toThrow('Unsupported audio file') + expect(() => uploadContentType('noext')).toThrow('(no extension)') + }) +}) + +describe('waitForAudio', () => { + const asset = { + id: '6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10', + status: 'transcoding', + } as VoiceAudioAsset + const submitted: VoiceResult = { + data: asset, + metadata: {attempts: 1, status: 201}, + } + + test('times out with request_timeout while the asset keeps processing', async () => { + const retrieveAudio = vi.fn(() => + Promise.resolve({ + data: asset, + metadata: {attempts: 1, headers: {}, status: 200}, + }), + ) + const client = {retrieveAudio} as unknown as VoiceClient + let now = 0 + vi.spyOn(Date, 'now').mockImplementation(() => now) + try { + await expect( + waitForAudio(client, submitted, { + sleep: (milliseconds) => { + now += milliseconds + return Promise.resolve() + }, + timeoutSeconds: 5, + }), + ).rejects.toMatchObject({code: 'request_timeout'}) + // No fourth request starts after sleeping up to the deadline. + expect(retrieveAudio).toHaveBeenCalledTimes(3) + } finally { + vi.restoreAllMocks() + } + }) + + test('does not return a ready result that arrives after the wait deadline', async () => { + let now = 0 + vi.spyOn(Date, 'now').mockImplementation(() => now) + const client = { + retrieveAudio: () => { + now = 1_001 + return Promise.resolve({ + data: {...asset, status: 'ready'}, + metadata: {attempts: 1, headers: {}, status: 200}, + }) + }, + } as unknown as VoiceClient + try { + await expect( + waitForAudio(client, submitted, {timeoutSeconds: 1}), + ).rejects.toMatchObject({code: 'request_timeout'}) + } finally { + vi.restoreAllMocks() + } + }) +}) + +describe('voice transfers', () => { + const ASSET_ID = '6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10' + const preview = { + contentType: 'audio/ogg' as const, + expiresAt: '2026-09-19T10:05:00.000Z', + url: 'https://previews.example.test/p/token', + } + function environment(timeoutMs?: number) { + return { + baseUrl: 'https://api.example.test', + credential: { + type: 'apiKey' as const, + value: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + surface: 'platform' as const, + ...(timeoutMs === undefined ? {} : {timeoutMs}), + } + } + + /** Headers arrive at once; the body never produces a byte. */ + function stalledBody() { + const cancel = vi.fn() + const body = new ReadableStream({ + cancel, + pull: () => new Promise(() => undefined), + }) + return {body, cancel} + } + + /** An endless body with no Content-Length that counts what it produced. */ + function endlessBody(chunkBytes: number) { + const chunk = new Uint8Array(chunkBytes) + const state = {cancelled: false, produced: 0} + const body = new ReadableStream({ + cancel() { + state.cancelled = true + }, + pull(controller) { + state.produced += chunk.byteLength + controller.enqueue(chunk) + }, + }) + return {body, state} + } + + // The mocked fetch ignores the abort signal, so these tests prove the + // client cancels the body itself rather than relying on the transport. + function fetchReturning(response: () => Response) { + return vi.fn(() => Promise.resolve(response())) as typeof fetch + } + + test('times out a preview whose body stalls after the headers', async () => { + const {body, cancel} = stalledBody() + const client = createVoiceClient( + environment(50), + fetchReturning(() => new Response(body, {status: 200})), + ) + const started = Date.now() + + const download = client.downloadPreview(preview, 1024) + + await expect(download).rejects.toMatchObject({code: 'request_timeout'}) + await expect(download).rejects.toThrow( + 'The preview download did not finish within 50ms.', + ) + await expect(download).rejects.not.toThrow(/token/) + expect(Date.now() - started).toBeLessThan(2_000) + expect(cancel).toHaveBeenCalled() + }) + + test('times out an error response whose body stalls after the headers', async () => { + const preview500 = stalledBody() + const previewClient = createVoiceClient( + environment(50), + fetchReturning(() => new Response(preview500.body, {status: 500})), + ) + + await expect( + previewClient.downloadPreview(preview, 1024), + ).rejects.toMatchObject({code: 'request_timeout'}) + expect(preview500.cancel).toHaveBeenCalled() + }) + + test('still reports the status and body code when the error body arrives', async () => { + const client = createVoiceClient( + environment(50), + fetchReturning( + () => new Response('{"error":{"code":"link_expired"}}', {status: 403}), + ), + ) + + await expect(client.downloadPreview(preview, 1_000)).rejects.toMatchObject({ + code: 'link_expired', + status: 403, + }) + }) + + test('does not retain a signed preview URL in network errors or storage response codes', async () => { + const secret = preview.url + const networkClient = createVoiceClient( + environment(50), + vi.fn(() => Promise.reject(new Error(`GET ${secret} failed`))), + ) + const networkError = await networkClient + .downloadPreview(preview, 1_000) + .catch((error: unknown) => error) + expect(networkError).toMatchObject({code: 'connection_error'}) + expect(inspect(networkError, {depth: 10})).not.toContain(secret) + + const storageClient = createVoiceClient( + environment(50), + fetchReturning( + () => + new Response(JSON.stringify({error: {code: secret}}), {status: 403}), + ), + ) + const storageError = await storageClient + .downloadPreview(preview, 1_000) + .catch((error: unknown) => error) + expect(storageError).toMatchObject({status: 403}) + expect(inspect(storageError, {depth: 10})).not.toContain(secret) + }) + + test('stops reading a preview without Content-Length once it passes the limit', async () => { + const maxBytes = 10_000 + const chunkBytes = 1_000 + const {body, state} = endlessBody(chunkBytes) + const client = createVoiceClient( + environment(5_000), + fetchReturning(() => new Response(body, {status: 200})), + ) + + await expect( + client.downloadPreview(preview, maxBytes), + ).rejects.toMatchObject({code: 'preview_too_large'}) + expect(state.cancelled).toBe(true) + // The reader stops on the first chunk past the limit; the stream may + // have queued one more chunk ahead of the reader. + expect(state.produced).toBeGreaterThan(maxBytes) + expect(state.produced).toBeLessThanOrEqual(maxBytes + 2 * chunkBytes) + }) + + test('refuses a declared oversize preview before reading it', async () => { + const {body, state} = endlessBody(1_000) + const client = createVoiceClient( + environment(5_000), + fetchReturning( + () => + new Response(body, { + headers: {'content-length': '10001'}, + status: 200, + }), + ), + ) + + await expect(client.downloadPreview(preview, 10_000)).rejects.toMatchObject( + {code: 'preview_too_large'}, + ) + expect(state.cancelled).toBe(true) + expect(state.produced).toBeLessThanOrEqual(1_000) + }) + + test('returns a preview at exactly the limit', async () => { + const client = createVoiceClient( + environment(5_000), + fetchReturning(() => new Response(new Uint8Array(1_000), {status: 200})), + ) + + await expect(client.downloadPreview(preview, 1_000)).resolves.toHaveLength( + 1_000, + ) + }) + + function previewClient(fetchImplementation: typeof fetch, timeoutMs: number) { + const client = createVoiceClient( + environment(timeoutMs), + fetchImplementation, + ) + return { + ...client, + previewAudio: () => + Promise.resolve({ + data: preview, + metadata: {attempts: 1, headers: {}, status: 200}, + }), + } satisfies VoiceClient + } + + test('writes no output file when an unbounded preview is refused', async () => { + const directory = await temporaryDirectory() + try { + const {body, state} = endlessBody(1_048_576) + const client = previewClient( + fetchReturning(() => new Response(body, {status: 200})), + 30_000, + ) + + await expect( + previewAudio(client, ASSET_ID, { + output: join(directory, 'preview.ogg'), + }), + ).rejects.toMatchObject({code: 'preview_too_large'}) + expect(state.cancelled).toBe(true) + expect(state.produced).toBeLessThanOrEqual( + MAX_PREVIEW_BYTES + 2 * 1_048_576, + ) + await expect(readdir(directory)).resolves.toEqual([]) + } finally { + await rm(directory, {force: true, recursive: true}) + } + }) + + test('writes no output file when the preview body times out', async () => { + const directory = await temporaryDirectory() + try { + const {body} = stalledBody() + const client = previewClient( + fetchReturning(() => new Response(body, {status: 200})), + 50, + ) + + await expect( + previewAudio(client, ASSET_ID, { + output: join(directory, 'preview.ogg'), + }), + ).rejects.toMatchObject({code: 'request_timeout'}) + await expect(readdir(directory)).resolves.toEqual([]) + } finally { + await rm(directory, {force: true, recursive: true}) + } + }) +})