{
  "openapi": "3.0.0",
  "paths": {
    "/api/v1/audio/speech": {
      "post": {
        "description": "Drop-in for OpenAI `POST /v1/audio/speech`: point an OpenAI SDK at `https://api.vieneu.io/api/v1` with your VieNeu key and it works unchanged. Returns the audio bytes directly — mp3 by default, like OpenAI; `wav`, `opus` and `pcm` are also available via `response_format`. Set `stream_format` to `audio` or `sse` to receive audio as it is generated instead of waiting for the whole file — that requires `response_format: pcm` or `ulaw`, the only two encodings whose frames concatenate. Maps `input`→text, `voice`→VieNeu voice id (from GET /v1/audio/voices) and `model`→engine. Billed by the submitted character count (minimum 50, × the AI surcharge only if you opt in with `aiRefine: true`) and refunded if synthesis fails. Auth via `Authorization: Bearer <api key>`.",
        "operationId": "createSpeech",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OpenAiSpeechRequestDto"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The audio bytes. Content-Type follows `response_format` (mp3 by default). With `stream_format: \"sse\"` the body is instead a `text/event-stream` of base64 audio deltas; with `\"audio\"` it is the same bytes, chunked. For the headerless `pcm` and `ulaw` formats the sample rate is in `X-Sample-Rate` (`pcm` defaults to 24 kHz here, not the engine native rate).",
            "content": {
              "audio/mpeg": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "audio/wav": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "audio/ogg": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "audio/basic": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "text/event-stream": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "OpenAI-shaped error: missing `input`, unknown voice, unsupported format.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OpenAiErrorResponseDto"
                }
              }
            }
          },
          "401": {
            "description": "OpenAI-shaped error: API key missing, malformed, or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OpenAiErrorResponseDto"
                }
              }
            }
          },
          "403": {
            "description": "OpenAI-shaped error: the grant is out of tokens, has expired, or the plan does not allow the requested engine. A daily/weekly cap comes back as 429 instead.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OpenAiErrorResponseDto"
                }
              }
            }
          },
          "429": {
            "description": "OpenAI-shaped error: rate limit or token quota. See `Retry-After`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OpenAiErrorResponseDto"
                }
              }
            }
          },
          "503": {
            "description": "OpenAI-shaped error: no worker available for the requested engine or format. Retry shortly.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OpenAiErrorResponseDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "apiKeyHeader": []
          },
          {
            "apiKeyBearer": []
          }
        ],
        "summary": "OpenAI-compatible speech synthesis",
        "tags": [
          "Public API v1"
        ]
      }
    },
    "/api/v1/audio/voices": {
      "get": {
        "description": "The companion to POST /v1/audio/speech. OpenAI has no such endpoint — its six voice names are a constant — but a VieNeu voice id is not guessable, so the OpenAI-compatible clients that support a custom base URL look for this route to populate their voice picker. Returns a bare `{ voices: [...] }` list of ids, which is the shape those clients expect. GET /v1/voices is the richer version (names, gender, region, your own cloned voices).",
        "operationId": "listOpenAiVoices",
        "parameters": [
          {
            "name": "engine",
            "required": false,
            "in": "query",
            "description": "Return only the voices available on this engine.",
            "schema": {
              "enum": [
                "v3",
                "v4"
              ],
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Bare voice ids, in the shape OpenAI-compatible clients expect.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicOpenAiVoiceListResponseDto"
                }
              }
            }
          },
          "400": {
            "description": "`engine` is not a known engine.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "401": {
            "description": "API key missing, malformed, or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "apiKeyHeader": []
          },
          {
            "apiKeyBearer": []
          }
        ],
        "summary": "List voice ids for the OpenAI-compatible endpoint",
        "tags": [
          "Public API v1"
        ]
      }
    },
    "/api/v1/clone": {
      "post": {
        "description": "Zero-shot voice cloning. Upload a reference clip via POST /v1/upload, then pass its `refFileId` plus the exact `refText` (transcript of that clip) to synthesize `text` in that voice. Pass `engine` (\"v3\" default, \"v4\" premium) to choose the cloning engine; billing carries that engine's multiplier. Tokens are deducted upfront from `text`. Returns a presigned `audioUrl`.",
        "operationId": "createClonedSpeech",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PublicCloneRequestDto"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The synthesized audio in the cloned voice, as a presigned URL.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicCloneResponseDto"
                }
              }
            }
          },
          "400": {
            "description": "Unknown `refFileId`, a reference clip outside the engine’s duration window, or a `refText` that does not match the clip.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "401": {
            "description": "API key missing, malformed, or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "403": {
            "description": "Your plan does not include cloning or this engine, or the grant is out of tokens / expired.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "422": {
            "description": "Content refused by moderation. Only reachable with `aiRefine: true`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "apiKeyHeader": []
          },
          {
            "apiKeyBearer": []
          }
        ],
        "summary": "Synthesize text in a cloned voice",
        "tags": [
          "Public API v1"
        ]
      }
    },
    "/api/v1/dialogue": {
      "post": {
        "description": "Synthesizes an ordered list of turns (1–50), each with its own voice and emotion, into a single concatenated WAV. Tokens are deducted upfront, billed by the submitted character count (minimum 50, × the AI surcharge only when you opt in with `aiRefine: true`), and refunded if synthesis fails. With `aiRefine: true` the dialogue is moderated and each turn is pronunciation-normalized (formulas, acronyms, mixed English), billed with the surcharge; by default (`false`) turns are synthesized as provided — no content check, no normalization, no surcharge. Pass `engine` (\"v3\" default, \"v4\" premium — billed at its own multiplier) to choose the TTS engine; every turn's voice must belong to it. Returns a presigned `audioUrl`.",
        "operationId": "createDialogue",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PublicDialogueRequestDto"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The concatenated dialogue, as a presigned WAV URL.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicDialogueResponseDto"
                }
              }
            }
          },
          "400": {
            "description": "Empty or over-long turn list, an unknown voice, or a voice from another engine.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "401": {
            "description": "API key missing, malformed, or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "403": {
            "description": "Your plan does not include dialogue or this engine, or the grant is out of tokens / expired.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "422": {
            "description": "Content refused by moderation. Only reachable with `aiRefine: true`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "apiKeyHeader": []
          },
          {
            "apiKeyBearer": []
          }
        ],
        "summary": "Synthesize a multi-speaker dialogue",
        "tags": [
          "Public API v1"
        ]
      }
    },
    "/api/v1/dub": {
      "post": {
        "description": "Transcribes the uploaded Vietnamese audio (Whisper STT), then re-synthesizes it with the chosen preset voice. Tokens are billed by the OUTPUT audio duration (same rate as the app, × the engine multiplier), charged only on success. Max file size: 10 MB.",
        "operationId": "createDub",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "Vietnamese audio file plus the target voice.",
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "audio",
                  "voiceId"
                ],
                "properties": {
                  "audio": {
                    "type": "string",
                    "format": "binary"
                  },
                  "voiceId": {
                    "type": "string",
                    "example": "Ngọc Lan"
                  },
                  "emotion": {
                    "type": "string",
                    "example": "natural"
                  },
                  "temperature": {
                    "type": "number",
                    "example": 1
                  },
                  "engine": {
                    "type": "string",
                    "enum": [
                      "v3",
                      "v4"
                    ],
                    "example": "v3",
                    "description": "TTS engine — \"v3\" (default) or \"v4\" (premium, billed at its own multiplier). The voice must belong to this engine."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The re-voiced audio plus the transcript it was built from.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicDubResponseDto"
                }
              }
            }
          },
          "400": {
            "description": "No audio sent, unsupported audio type, or an unknown voice.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "401": {
            "description": "API key missing, malformed, or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "403": {
            "description": "Your plan does not include dubbing or this engine, or the grant is out of tokens / expired.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "413": {
            "description": "Audio file larger than 10 MB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "apiKeyHeader": []
          },
          {
            "apiKeyBearer": []
          }
        ],
        "summary": "Dub a Vietnamese audio file with a new voice",
        "tags": [
          "Public API v1"
        ]
      }
    },
    "/api/v1/emotion-tags": {
      "get": {
        "description": "Returns the reading styles for the `emotion` request field plus the inline cue tags you can embed directly inside the text. Pass `?engine=v3|v4` to get what THAT engine can actually render: v3 has two styles (natural, storytelling) and all cues; v4 has NO styles and only the two cues it can voice ([cười], [thở dài]) — it deletes any other tag from your text. Omitting `engine` returns the v3 set, for backwards compatibility. No authentication required.",
        "operationId": "listEmotionTags",
        "parameters": [
          {
            "name": "engine",
            "required": false,
            "in": "query",
            "description": "Return the styles and cues THIS engine can render. Omit for the v3 set.",
            "schema": {
              "enum": [
                "v3",
                "v4"
              ],
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Styles for the `emotion` field plus the inline cue tags.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicEmotionTagsResponseDto"
                }
              }
            }
          },
          "401": {
            "description": "API key missing, malformed, or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "apiKeyHeader": []
          },
          {
            "apiKeyBearer": []
          },
          {}
        ],
        "summary": "List emotion styles and inline cue tags",
        "tags": [
          "Public API v1"
        ]
      }
    },
    "/api/v1/engines": {
      "get": {
        "description": "The engine registry as it is configured right now: which engines are live, which one you get when you omit `engine`, and the BILLING MULTIPLIER applied to each. Until this endpoint existed the multipliers lived only in prose, so a caller sizing a budget had to trust a table that could go stale the moment pricing changed. `features` tells you what each engine may be asked to do — note that `stream` appears on v4 only, which is why every stream is charged at the v4 rate regardless of what you send. No authentication required.",
        "operationId": "listEngines",
        "parameters": [],
        "responses": {
          "200": {
            "description": "One entry per enabled engine, in display order.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "engines": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "key": {
                            "type": "string",
                            "example": "v4"
                          },
                          "label": {
                            "type": "string",
                            "example": "V4 — Nhân bản (48kHz)"
                          },
                          "isDefault": {
                            "type": "boolean",
                            "description": "The engine used when a request omits `engine`."
                          },
                          "sampleRate": {
                            "type": "integer",
                            "example": 48000
                          },
                          "billingMultiplier": {
                            "type": "number",
                            "example": 3,
                            "description": "Multiplies the per-character token charge. A request on an engine with multiplier 3 costs twice one with multiplier 1.5."
                          },
                          "features": {
                            "type": "array",
                            "items": {
                              "type": "string"
                            },
                            "example": [
                              "generate",
                              "clone",
                              "stream"
                            ],
                            "description": "What this engine may be asked to do. `stream` is the one that decides whether POST /v1/tts/stream accepts it."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "API key missing, malformed, or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "apiKeyHeader": []
          },
          {
            "apiKeyBearer": []
          },
          {}
        ],
        "summary": "List engines, what each can do, and what each costs",
        "tags": [
          "Public API v1"
        ]
      }
    },
    "/api/v1/prepare": {
      "post": {
        "description": "Denoises a reference clip and transcribes it with Whisper, returning a cleaned 44.1 kHz WAV (stored as a new `fileId`) plus the transcript. Run this BEFORE POST /v1/voices or /v1/clone and pass the returned `fileId` + `transcript` on: V4 uses `refText` as a continuation prompt, so a transcript that does not match the clip degrades EVERY generation with that voice, not just enrolment. Review the transcript before enrolling — `warnings` and `languageProbability` flag clips Whisper struggled with. Costs no tokens.",
        "operationId": "prepareReference",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "The clip to prepare: EITHER a multipart `reference` file (WAV/MP3/M4A, max 10 MB) OR a `refFileId` from POST /v1/upload — exactly one of the two.",
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "reference": {
                    "type": "string",
                    "format": "binary"
                  },
                  "refFileId": {
                    "type": "string",
                    "example": "uploads/user-123/1716000000000_abc123.wav"
                  },
                  "engine": {
                    "type": "string",
                    "enum": [
                      "v3",
                      "v4"
                    ],
                    "example": "v4",
                    "description": "Engine the clip will be enrolled on — decides the duration band the response warns against (V3 4–7s, V4 6–20s)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The cleaned clip and its transcript. Review the transcript before enrolling.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicPrepareResponseDto"
                }
              }
            }
          },
          "400": {
            "description": "No clip (or both a file and refFileId), unsupported format, unknown fileId, or audio the worker cannot decode."
          },
          "401": {
            "description": "API key missing, malformed, or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "403": {
            "description": "refFileId belongs to another user."
          },
          "429": {
            "description": "Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "503": {
            "description": "Prepare is disabled, or no worker with clone-prepare support is available."
          }
        },
        "security": [
          {
            "apiKeyHeader": []
          },
          {
            "apiKeyBearer": []
          }
        ],
        "summary": "Prepare a clone reference: denoise + auto-transcribe",
        "tags": [
          "Public API v1"
        ]
      }
    },
    "/api/v1/srt": {
      "post": {
        "description": "Synthesizes each subtitle cue and places it at its start timecode (silence-padded; overruns shift later cues). Billed by total submitted cue-text characters (~1 token/char, minimum 50, × the AI surcharge only when you opt in with `aiRefine=true`) — NOT output duration. With `aiRefine=true` the subtitles are moderated and each cue is pronunciation-normalized; by default cues are synthesized as provided, with no content check and no surcharge. Pass `engine` (\"v3\" default, \"v4\" premium — billed at its own multiplier) to choose the TTS engine. Tokens are deducted upfront and refunded if synthesis fails.",
        "operationId": "createSrtDub",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "A .srt subtitle file plus the target voice.",
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file",
                  "voiceId"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  },
                  "voiceId": {
                    "type": "string",
                    "example": "Ngọc Lan"
                  },
                  "emotion": {
                    "type": "string",
                    "example": "natural"
                  },
                  "temperature": {
                    "type": "number",
                    "example": 1
                  },
                  "aiRefine": {
                    "type": "boolean",
                    "example": false,
                    "default": false,
                    "description": "AI text refinement across all cues (moderation + per-cue pronunciation normalization, billed with the AI surcharge). Defaults to FALSE: cues are synthesized as submitted, with no content check and no surcharge. Pass true to opt in — as a multipart field, the string \"true\" (or 1/on/yes); anything else, an empty field included, leaves the AI step off."
                  },
                  "engine": {
                    "type": "string",
                    "enum": [
                      "v3",
                      "v4"
                    ],
                    "example": "v3",
                    "description": "TTS engine — \"v3\" (default) or \"v4\" (premium, billed at its own multiplier). The voice must belong to this engine."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The timecode-aligned track, plus per-cue placement showing where speed-fit or drift moved a cue.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicSrtResponseDto"
                }
              }
            }
          },
          "400": {
            "description": "No file sent, an unparseable .srt, or an unknown voice.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "401": {
            "description": "API key missing, malformed, or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "403": {
            "description": "Your plan does not include SRT dubbing or this engine, or the grant is out of tokens / expired.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "413": {
            "description": "Subtitle file larger than 10 MB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "422": {
            "description": "Content refused by moderation. Only reachable with `aiRefine=true`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "apiKeyHeader": []
          },
          {
            "apiKeyBearer": []
          }
        ],
        "summary": "Dub an .srt subtitle file into a timecode-aligned WAV",
        "tags": [
          "Public API v1"
        ]
      }
    },
    "/api/v1/tts": {
      "post": {
        "description": "Submits a text-to-speech job. Returns immediately with a `jobId`. Poll `GET /v1/tts/{jobId}` to check progress and retrieve the audio URL when complete. Tokens are deducted on submission, billed by the submitted character count (minimum 50, × the AI surcharge only when you opt in with `aiRefine: true`), and refunded if the job permanently fails.",
        "operationId": "createTtsJob",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PublicTtsRequestDto"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Job accepted and queued. Poll GET /api/v1/tts/{jobId}.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicTtsSubmitResponseDto"
                }
              }
            }
          },
          "400": {
            "description": "Unknown voice, voice belonging to another engine, or text too long.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "401": {
            "description": "API key missing, malformed, or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "403": {
            "description": "Your plan does not include this engine or feature, or the grant is out of tokens / expired.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "422": {
            "description": "Content refused by moderation. Only reachable with `aiRefine: true`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "apiKeyHeader": []
          },
          {
            "apiKeyBearer": []
          }
        ],
        "summary": "Submit a TTS generation job",
        "tags": [
          "Public API v1"
        ]
      }
    },
    "/api/v1/tts/{jobId}": {
      "get": {
        "description": "Poll this endpoint every 2–3 seconds until `status` is `completed` or `failed`. When `completed`, `audioUrl` contains a presigned S3 URL valid for 24 hours.",
        "operationId": "getTtsJob",
        "parameters": [
          {
            "name": "jobId",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Current job state; `audioUrl` is present once `status` is `completed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicTtsStatusResponseDto"
                }
              }
            }
          },
          "401": {
            "description": "API key missing, malformed, or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "404": {
            "description": "No job with that id belongs to you. Jobs are scoped to the key’s owner — a valid key proves identity, not ownership of this job.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "apiKeyHeader": []
          },
          {
            "apiKeyBearer": []
          }
        ],
        "summary": "Get job status and audio URL",
        "tags": [
          "Public API v1"
        ]
      }
    },
    "/api/v1/tts/stream": {
      "post": {
        "description": "Streams audio as it is generated so playback can start within ~1–2s instead of waiting for the whole text. The response is a binary stream (Content-Type: application/octet-stream) of length-prefixed frames: repeat { read a 4-byte big-endian uint32 = N, then read N bytes = one audio frame } until EOF. A COMPLETE stream ends with a zero-length frame (N = 0) — if it is missing, the stream was cut short and you should discard the audio; it is not billed. Each frame is a self-contained WAV by default; pass `outputFormat` for raw headerless `pcm`/`ulaw` to feed a telephony or voice-agent pipeline, or mp3/opus to cut bandwidth. DECODE EACH FRAME SEPARATELY — frames are encoded independently, so concatenating mp3 or opus frames produces gaps and clicks; only pcm/ulaw can simply be joined. The actual sample rate is in X-Sample-Rate, the encoding in X-Output-Format, the framing in X-Stream-Format. PRICE: the same per-character rate as POST /v1/tts, but every stream runs on the v4 engine and is therefore charged at the v4 engine multiplier — higher than the v3 one. Sizing a budget with POST /v1/tts on v3 and then streaming the same text will cost more; GET /v1/engines returns the live multipliers. (+ the AI surcharge only if you opt in with `aiRefine: true`, which also moderates and normalizes the text first.) `voiceId` accepts a preset or one of your own cloned voices (`clone_…` from POST /v1/voices) — a clone costs exactly what a preset costs on the engine you name; cloning adds no surcharge of its own. Note the engine you name is the engine that renders: a clone is NOT currently checked against the engine it was enrolled on. Clones have enrolled on v4 since 2026-08-28, so for those the two always agree; an older v3-enrolled clone named with v4 will render on v4 from a v3 reference clip and will not sound like the intended voice. Re-enrol it instead.",
        "operationId": "streamSpeech",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PublicStreamRequestDto"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A binary stream of length-prefixed frames: repeat { 4-byte big-endian uint32 N, then N bytes }. A COMPLETE stream ends with N = 0; without that marker the audio was cut short — discard it (it is not billed). `X-Sample-Rate`, `X-Output-Format` and `X-Stream-Format` describe the frames.",
            "content": {
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            },
            "headers": {
              "X-Sample-Rate": {
                "description": "Sample rate of the audio, in Hz. The ACTUAL rate produced.",
                "schema": {
                  "type": "integer",
                  "example": 48000
                }
              },
              "X-Output-Format": {
                "description": "Encoding of each frame: wav, mp3, opus, pcm or ulaw.",
                "schema": {
                  "type": "string",
                  "example": "wav"
                }
              },
              "X-Stream-Format": {
                "description": "`len32-wav-chunks` when each frame is a self-contained WAV, `len32-frames` for raw codec bytes.",
                "schema": {
                  "type": "string",
                  "enum": [
                    "len32-wav-chunks",
                    "len32-frames"
                  ]
                }
              },
              "X-Stream-Heartbeat": {
                "description": "Present only when this stream may insert HEARTBEAT frames — a valid WAV carrying zero samples, sent at most this many seconds apart to keep the connection alive while nothing new has been synthesized. Skip those frames: they are not audio. Absent means none will be sent (raw codec streams never carry them).",
                "schema": {
                  "type": "integer",
                  "example": 10
                }
              }
            }
          },
          "400": {
            "description": "Unknown voice, a `clone_…` voice that is not yours (or whose reference is gone), or a format/sample-rate pair that cannot be honoured (`opus` is always 48000, `ulaw` always 8000). Unlike /v1/audio/speech`s `stream_format`, every `outputFormat` is accepted here — the framing is what keeps mp3/opus usable, one frame at a time.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "401": {
            "description": "API key missing, malformed, or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "403": {
            "description": "Your plan does not include this engine or feature, or the grant is out of tokens / expired.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "502": {
            "description": "Every worker for this engine failed. The charge is refunded automatically. Distinct from 503: a 503 means the fleet is alive but out of streaming capacity (see the STREAM_BUSY code) and retrying — or the queued POST /v1/tts path — will work.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "503": {
            "description": "No worker available for the requested engine or format. Retry shortly.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "apiKeyHeader": []
          },
          {
            "apiKeyBearer": []
          }
        ],
        "summary": "Stream speech synthesis with low latency",
        "tags": [
          "Public API v1"
        ]
      }
    },
    "/api/v1/upload": {
      "post": {
        "description": "Upload an audio file (WAV, MP3, OGG, FLAC) to S3 storage. Returns a `fileId` you can use as `refFileId` in voice-clone TTS requests. Max file size: 10 MB. The presigned download URL is valid for 24 hours.",
        "operationId": "uploadReferenceAudio",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "Audio file to upload (WAV, MP3, OGG, FLAC — max 10 MB).",
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The stored file. Pass `fileId` as `refFileId` to POST /api/v1/clone.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicUploadResponseDto"
                }
              }
            }
          },
          "400": {
            "description": "No file sent, or an audio type we do not accept.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "401": {
            "description": "API key missing, malformed, or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "413": {
            "description": "File larger than 10 MB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "apiKeyHeader": []
          },
          {
            "apiKeyBearer": []
          }
        ],
        "summary": "Upload a reference audio file",
        "tags": [
          "Public API v1"
        ]
      }
    },
    "/api/v1/vapi/speech": {
      "post": {
        "description": "Point a Vapi assistant's `custom-voice` provider at this URL to give it a Vietnamese voice. Vapi POSTs `{message: {type: \"voice-request\", text, sampleRate}}` and expects raw mono 16-bit little-endian PCM at exactly that rate — which is what this returns, streamed as it is generated. Authenticate by putting your VieNeu API key in the assistant's `server.secret`; Vapi sends it as `X-VAPI-SECRET`. Choose the voice with `?voiceId=` on the URL (and `?engine=` for v4), since Vapi's payload has no field for it. AI refinement is never applied here: an agent needs the reply to start, not an extra model round-trip. Billed per submitted character like the rest of /v1, and refunded if the audio is cut short.",
        "operationId": "vapiSpeech",
        "parameters": [
          {
            "name": "voiceId",
            "required": false,
            "in": "query",
            "description": "Preset voice id; omit for the engine default.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "engine",
            "required": false,
            "in": "query",
            "schema": {
              "enum": [
                "v3",
                "v4"
              ],
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/VapiVoiceRequestDto"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Raw mono 16-bit little-endian PCM at exactly the `sampleRate` Vapi asked for, streamed as it is generated. Headerless — the rate is the one you sent.",
            "content": {
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "400": {
            "description": "Payload is not a `voice-request` message, `text` is empty, or `sampleRate` is not one Vapi and the worker share.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "401": {
            "description": "API key missing, malformed, or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "403": {
            "description": "The grant is out of tokens or expired, or the plan does not allow the requested engine. A daily/weekly cap comes back as 429 instead.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "503": {
            "description": "No worker answered within the deadline. Vapi’s `fallbackPlan` should handle this.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "apiKeyHeader": []
          },
          {
            "apiKeyBearer": []
          },
          {
            "vapiSecret": []
          }
        ],
        "summary": "Vapi custom-voice webhook (raw PCM for voice agents)",
        "tags": [
          "Public API v1"
        ]
      }
    },
    "/api/v1/voices": {
      "get": {
        "description": "Returns the voices an administrator has activated in the catalog (`kind:\"catalog\"`). Authentication is optional: when you send your API key, the response ALSO includes your own saved cloned voices (`kind:\"cloned\"`, ids like `clone_…`) — create them with POST /v1/voices, then pass the id as `voiceId` to POST /v1/tts or POST /v1/tts/stream. Each voice carries the `engine` it renders on — a voice is only usable on that engine, cloned voices included (a clone is enrolled for one engine at creation). Pass `?engine=v3` (or `v4`) to list one engine only; omitting it lists every engine.",
        "operationId": "listVoices",
        "parameters": [
          {
            "name": "engine",
            "required": false,
            "in": "query",
            "description": "Return only the voices available on this engine.",
            "schema": {
              "enum": [
                "v3",
                "v4"
              ],
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The catalogue, plus your own cloned voices when an API key is sent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicVoiceListResponseDto"
                }
              }
            }
          },
          "400": {
            "description": "`engine` is not a known engine.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "401": {
            "description": "API key missing, malformed, or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "apiKeyHeader": []
          },
          {
            "apiKeyBearer": []
          },
          {}
        ],
        "summary": "List available voices",
        "tags": [
          "Public API v1"
        ]
      },
      "post": {
        "description": "Clones a voice from a short reference clip and SAVES it, returning a persistent `voiceId` (clone_…). The voice then appears in GET /v1/voices (when you send your key) and is usable as `voiceId` in POST /v1/tts like any catalog voice. Pass `engine` to enrol on \"v3\" (default) or \"v4\"; the reference-clip duration limits differ per engine. A flat per-clone token cost is charged on create (independent of clip/text length, × the engine multiplier); subsequent generation bills per character at that engine's rate. Subject to your plan’s cloned-voice limit — delete unused voices with DELETE /v1/voices/{voiceId}.",
        "operationId": "createVoice",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "A reference clip (WAV/MP3/M4A) plus its exact transcript and a display name. The clip must be 4–7s for V3 or 6–20s for V4 — see `engine` below.",
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "reference",
                  "name",
                  "refText",
                  "consent"
                ],
                "properties": {
                  "reference": {
                    "type": "string",
                    "format": "binary"
                  },
                  "name": {
                    "type": "string",
                    "example": "My narrator voice"
                  },
                  "refText": {
                    "type": "string",
                    "example": "Đây là nội dung chính xác của đoạn audio tham chiếu."
                  },
                  "consent": {
                    "type": "string",
                    "example": "true"
                  },
                  "engine": {
                    "type": "string",
                    "enum": [
                      "v3",
                      "v4"
                    ],
                    "example": "v3",
                    "description": "Engine to enrol the voice for — \"v3\" (default) or \"v4\" (premium; the enrolment fee carries that engine's multiplier). The voice then renders on that engine only."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The saved voice. Use `voiceId` in any later generation call.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicClonedVoiceDto"
                }
              }
            }
          },
          "400": {
            "description": "Missing reference clip, missing/incorrect `refText`, clip outside the engine's duration window, or `consent` not given.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "401": {
            "description": "API key missing, malformed, or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "403": {
            "description": "Your plan does not include cloning, does not include this engine, you have reached its cloned-voice limit, or the grant is out of tokens / expired.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "413": {
            "description": "Reference clip larger than 10 MB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "apiKeyHeader": []
          },
          {
            "apiKeyBearer": []
          }
        ],
        "summary": "Create a reusable cloned voice",
        "tags": [
          "Public API v1"
        ]
      }
    },
    "/api/v1/voices/{voiceId}": {
      "delete": {
        "description": "Deletes a cloned voice you created (frees a slot against your plan’s cloned-voice limit). Only `clone_…` ids you own can be deleted; catalog voices cannot.",
        "operationId": "deleteVoice",
        "parameters": [
          {
            "name": "voiceId",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The voice was deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicDeletedVoiceResponseDto"
                }
              }
            }
          },
          "401": {
            "description": "API key missing, malformed, or revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "404": {
            "description": "No cloned voice with that id belongs to you.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit or token quota exceeded. When the limit came from the application the response carries `Retry-After` (seconds) and the `X-RateLimit-*` headers and is counted against your API key; a 429 with none of those headers came from the edge proxy and is counted against your source address, shared with every other key calling from it. Back off on either.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicApiErrorDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "apiKeyHeader": []
          },
          {
            "apiKeyBearer": []
          }
        ],
        "summary": "Delete one of your cloned voices",
        "tags": [
          "Public API v1"
        ]
      }
    }
  },
  "info": {
    "title": "VieNeu API",
    "description": "## Vietnamese text-to-speech over HTTPS\n\nThis document covers the **public integration surface** only — everything under\n`/api/v1`. The application's own endpoints (dashboard, billing,\nadministration) are not part of the published contract and are deliberately absent.\n\n**Quick start:**\n1. Create an API key in the dashboard. Live keys begin `vn_sk_`; test keys begin\n   `vn_test_` and cap each request at 100 words.\n2. Send it as `Authorization: Bearer <key>` or `X-API-Key: <key>`.\n3. Call `POST /api/v1/audio/speech` for audio bytes in the response, or\n   `POST /api/v1/tts` + `GET /api/v1/tts/{jobId}` for long text.\n\n**Billing** is per submitted character (minimum 50), refunded automatically when a\nrequest fails to produce audio. `aiRefine` is off by default here and carries a\nsurcharge when enabled.\n\n**Rate limits** are applied in two places. The application counts them per API key\nand answers with `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`\nand, on a 429, `Retry-After`. In front of it the edge proxy also caps requests and\nconcurrent connections **per source address**, shared by every key behind that\naddress; when that cap trips the 429 (or 503) carries none of those headers. So back\noff on any 429, and do not assume a bare one means your own key is over its limit.\n\nQuote the `X-Request-Id` of a failing call in any support request.",
    "version": "1.0",
    "contact": {}
  },
  "tags": [
    {
      "name": "Public API v1",
      "description": "Synthesis, voices and voice cloning over HTTPS. Authenticated with an API key."
    }
  ],
  "servers": [
    {
      "url": "https://api.vieneu.io",
      "description": "Production"
    }
  ],
  "components": {
    "securitySchemes": {
      "apiKeyBearer": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "VieNeu API key",
        "description": "Your VieNeu API key, sent as `Authorization: Bearer vn_sk_…`. This is an API key, not a JWT — do not call /api/auth/login for it."
      },
      "apiKeyHeader": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "Your VieNeu API key, sent as `X-API-Key: vn_sk_…`. Equivalent to the Authorization header — send one or the other, not both."
      },
      "vapiSecret": {
        "type": "apiKey",
        "in": "header",
        "name": "X-VAPI-SECRET",
        "description": "Vapi's own header. A Vapi assistant cannot set Authorization or X-API-Key, so put your VieNeu API key in the assistant's `server.secret` and Vapi forwards it here. Declared on POST /api/v1/vapi/speech, the only route a Vapi assistant calls."
      }
    },
    "schemas": {
      "OpenAiErrorBodyDto": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string",
            "example": "Missing required parameter: 'input'."
          },
          "type": {
            "type": "string",
            "example": "invalid_request_error"
          },
          "param": {
            "type": "string",
            "example": "input",
            "nullable": true
          },
          "code": {
            "type": "string",
            "example": null,
            "nullable": true
          }
        },
        "required": [
          "message",
          "type"
        ]
      },
      "OpenAiErrorResponseDto": {
        "type": "object",
        "properties": {
          "error": {
            "$ref": "#/components/schemas/OpenAiErrorBodyDto"
          }
        },
        "required": [
          "error"
        ]
      },
      "OpenAiSpeechRequestDto": {
        "type": "object",
        "properties": {
          "model": {
            "type": "string",
            "example": "tts-1",
            "description": "Selects the engine. OpenAI model names (`tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`) map to the default engine so an unmodified OpenAI client works; `vieneu-v3` and `vieneu-v4` select an engine explicitly. Any other value is accepted and ignored, as this endpoint has always done. A `vieneu-…` name for an engine that does not exist IS rejected — that is a choice of engine, and rendering on a different one would bill at a rate you did not pick. `engine` wins if both are sent."
          },
          "input": {
            "type": "string",
            "example": "Xin chào, đây là VieNeu.",
            "description": "The text to synthesize (OpenAI: `input`). Required."
          },
          "voice": {
            "type": "string",
            "example": "Ngọc Lan",
            "description": "VieNeu voice id from GET /v1/voices (e.g. \"Ngọc Lan\"). Omit to use the default voice. (OpenAI voice names like \"alloy\" are not mapped — pass a VieNeu voice id.)"
          },
          "response_format": {
            "type": "string",
            "example": "mp3",
            "enum": [
              "wav",
              "mp3",
              "opus",
              "pcm",
              "ulaw"
            ],
            "default": "mp3",
            "description": "Output format. Defaults to `mp3`, as OpenAI does — a client that omits the field expects mp3, and returning WAV to it produced a file its player refused. `wav`, `opus`, `pcm` and `ulaw` are also supported; `pcm` is raw headerless signed 16-bit little-endian at 24 kHz — the rate OpenAI documents, and what this route defaults to unless `sample_rate` says otherwise — and `ulaw` is raw 8 kHz G.711 for telephony. OpenAI's `aac` and `flac` are not supported and return 400."
          },
          "sample_rate": {
            "type": "number",
            "example": 24000,
            "enum": [
              8000,
              16000,
              22050,
              24000,
              44100,
              48000
            ],
            "description": "VieNeu extension: output sample rate in Hz. Omit for 48000 — except with `pcm`, which defaults to 24000 (the rate OpenAI documents, since an OpenAI client has no field to ask for another and headerless PCM at the wrong rate plays at the wrong speed), and `ulaw`, which is always 8000."
          },
          "speed": {
            "type": "number",
            "example": 1,
            "minimum": 0.25,
            "maximum": 4,
            "default": 1,
            "description": "Playback rate, applied by a pitch-preserving time-stretch. OpenAI accepts 0.25–4.0 and so does this endpoint, but VieNeu's engine only holds quality across 0.5–2.0, so values outside that are CLAMPED, not rejected — a 400 for a value OpenAI itself considers valid would break the drop-in promise. The native /v1 endpoints validate 0.5–2.0 strictly instead."
          },
          "instructions": {
            "type": "string",
            "example": "Speak in a calm tone.",
            "description": "Accepted and ignored. It exists because `gpt-4o-mini-tts` — one of the model names this endpoint accepts — is the model whose whole point is this parameter, and the global validation pipe rejects unknown fields. Sending it must not 400; VieNeu conveys style through `emotion` and inline cues."
          },
          "stream_format": {
            "type": "string",
            "example": "audio",
            "enum": [
              "audio",
              "sse"
            ],
            "description": "Stream the response instead of buffering the whole file. `audio` sends the encoded bytes as they are produced (chunked transfer). `sse` sends OpenAI-shaped Server-Sent Events: `speech.audio.delta` carrying base64 audio, then a final `speech.audio.done`. That final event is the only positive proof the stream finished — without it, treat the audio as truncated. ONLY valid with `response_format: pcm` or `ulaw`: streamed frames are encoded independently, so mp3/opus/wav cannot be concatenated into one playable file and are rejected with 400. Note `response_format` defaults to mp3, so streaming requires setting it explicitly."
          },
          "emotion": {
            "type": "string",
            "example": "natural",
            "description": "VieNeu extension: speaking style — 'natural' or 'storytelling'."
          },
          "aiRefine": {
            "type": "boolean",
            "example": false,
            "default": false,
            "description": "VieNeu extension — AI text refinement. Defaults to FALSE on the public API: the text is synthesized as submitted, with no AI moderation and no pronunciation normalization, billed at the plain per-character rate. Set true to run the same AI step the web app uses — formulas, acronyms and mixed-in English are read correctly and the content is checked — billed with the AI surcharge and one extra model round-trip of latency. Deterministic text preparation is applied either way."
          },
          "engine": {
            "type": "string",
            "example": "v3",
            "enum": [
              "v3",
              "v4"
            ],
            "description": "VieNeu extension: TTS engine — \"v3\" (48 kHz, default) or \"v4\" (48 kHz, premium). Omit for the configured default."
          }
        },
        "required": [
          "input"
        ]
      },
      "PublicApiErrorDto": {
        "type": "object",
        "properties": {
          "statusCode": {
            "type": "number",
            "example": 402,
            "description": "HTTP status code, repeated in the body."
          },
          "message": {
            "type": "string",
            "example": "Insufficient tokens",
            "description": "What went wrong. A validation failure returns an array of messages here."
          },
          "traceId": {
            "type": "string",
            "example": "0f7c1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b",
            "description": "Correlation id, also returned as the `X-Request-Id` response header. Quote it in a support request — it is the id our logs are keyed by."
          }
        },
        "required": [
          "statusCode",
          "message"
        ]
      },
      "PublicClonedVoiceDto": {
        "type": "object",
        "properties": {
          "voiceId": {
            "type": "string",
            "example": "clone_9f1c2d34-...",
            "description": "Persistent voice id — pass as `voiceId` to POST /v1/tts."
          },
          "name": {
            "type": "string",
            "example": "My narrator voice"
          },
          "engine": {
            "type": "string",
            "example": "v3",
            "enum": [
              "v3",
              "v4"
            ],
            "description": "The engine this voice was enrolled for, and the only one it renders on. Pass it as `engine` alongside this `voiceId` in POST /v1/tts."
          },
          "sampleAudioUrl": {
            "type": "string",
            "nullable": true,
            "example": "/api/tts/voices/{id}/sample?v={cacheBuster}",
            "description": "Preview sample, rendered best-effort after the voice is saved — expect null for a short while after create, and treat null as \"no preview yet\", not as a failed clone. A HOST-RELATIVE path outside `/api/v1`: it is served by the application, is not part of this versioned contract, and may change or disappear without a version bump. Do not build against it — re-synthesize with the `voiceId` if you need a durable sample."
          },
          "createdAt": {
            "type": "string",
            "example": "2026-07-11T10:00:00.000Z"
          }
        },
        "required": [
          "voiceId",
          "name",
          "engine",
          "createdAt"
        ]
      },
      "PublicCloneRequestDto": {
        "type": "object",
        "properties": {
          "text": {
            "type": "string",
            "example": "Xin chào, đây là giọng nói được nhân bản.",
            "description": "Vietnamese text to synthesize in the cloned voice.",
            "maxLength": 50000
          },
          "refFileId": {
            "type": "string",
            "example": "uploads/user-123/1716000000000_abc123.wav",
            "description": "fileId returned by POST /v1/upload — the reference audio to clone. Must be one of your own uploads."
          },
          "refText": {
            "type": "string",
            "example": "Đây là nội dung chính xác của đoạn audio tham chiếu.",
            "description": "Exact transcript of the reference audio. Required for zero-shot cloning quality.",
            "maxLength": 5000
          },
          "emotion": {
            "type": "string",
            "example": "natural",
            "enum": [
              "natural",
              "storytelling",
              "tin_tuc",
              "tu_nhien",
              "doc_truyen",
              "emotion_0",
              "emotion_1",
              "emotion_2",
              "emotion_3",
              "emotion_4",
              "emotion_5",
              "emotion_6",
              "emotion_7",
              "emotion_8",
              "emotion_9",
              "emotion_10",
              "emotion_11",
              "emotion_12",
              "emotion_13",
              "emotion_14",
              "emotion_15",
              "emotion_16",
              "emotion_17",
              "emotion_18",
              "emotion_19"
            ],
            "default": "natural"
          },
          "temperature": {
            "type": "number",
            "example": 1,
            "minimum": 0,
            "maximum": 2,
            "default": 1
          },
          "aiRefine": {
            "type": "boolean",
            "example": false,
            "default": false,
            "description": "AI text refinement. Defaults to FALSE on the public API: the text is synthesized as submitted, with no AI moderation and no pronunciation normalization, billed at the plain per-character rate. Set true to run the same AI step the web app uses — formulas, acronyms and mixed-in English are read correctly and the content is checked — billed with the AI surcharge and one extra model round-trip of latency. Deterministic text preparation is applied either way."
          },
          "engine": {
            "type": "string",
            "enum": [
              "v3",
              "v4"
            ],
            "example": "v3",
            "description": "TTS engine to clone on — \"v3\" (default) or \"v4\" (premium, billed at its own multiplier; V4 is built for cloning). The engine must declare the \"clone\" capability. Omit to use the platform default."
          }
        },
        "required": [
          "text",
          "refFileId",
          "refText"
        ]
      },
      "PublicCloneResponseDto": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean",
            "example": true
          },
          "audioUrl": {
            "type": "string",
            "example": "https://s3.amazonaws.com/bucket/cloned/job.wav?X-Amz-Expires=3600&...",
            "description": "Presigned S3 download URL for the cloned-voice WAV."
          },
          "audioUrlExpiresIn": {
            "type": "number",
            "example": 3600,
            "description": "Seconds until audioUrl expires."
          },
          "duration": {
            "type": "number",
            "example": 4.1,
            "description": "Duration of the synthesized audio (seconds)."
          },
          "tokenCost": {
            "type": "number",
            "example": 60,
            "description": "Tokens deducted for this request."
          }
        },
        "required": [
          "success",
          "audioUrl",
          "audioUrlExpiresIn",
          "duration",
          "tokenCost"
        ]
      },
      "PublicDeletedVoiceResponseDto": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean",
            "example": true
          }
        },
        "required": [
          "success"
        ]
      },
      "PublicDialogueRequestDto": {
        "type": "object",
        "properties": {
          "turns": {
            "description": "Ordered list of dialogue turns (1–50).",
            "minItems": 1,
            "maxItems": 50,
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PublicDialogueTurnDto"
            }
          },
          "pauseBetweenTurnsMs": {
            "type": "number",
            "example": 500,
            "description": "Silence gap between turns (ms, 0–5000).",
            "default": 500
          },
          "temperature": {
            "type": "number",
            "example": 1,
            "description": "Sampling temperature applied to all turns (0.0–2.0).",
            "default": 1
          },
          "aiRefine": {
            "type": "boolean",
            "example": false,
            "default": false,
            "description": "AI text refinement. Defaults to FALSE on the public API: the text is synthesized as submitted, with no AI moderation and no pronunciation normalization, billed at the plain per-character rate. Set true to run the same AI step the web app uses — formulas, acronyms and mixed-in English are read correctly and the content is checked — billed with the AI surcharge and one extra model round-trip of latency. Deterministic text preparation is applied either way. Applies across every turn."
          },
          "engine": {
            "type": "string",
            "enum": [
              "v3",
              "v4"
            ],
            "example": "v3",
            "description": "TTS engine — \"v3\" (default) or \"v4\" (premium, billed at its own multiplier). Every turn's voice must belong to this engine (see GET /v1/voices?engine=…). Omit to use the platform default."
          }
        },
        "required": [
          "turns"
        ]
      },
      "PublicDialogueResponseDto": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean",
            "example": true
          },
          "turnsCount": {
            "type": "number",
            "example": 4,
            "description": "Number of turns synthesized."
          },
          "speakers": {
            "example": [
              "Host",
              "Guest"
            ],
            "description": "Unique speaker labels in order of first appearance.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "audioUrl": {
            "type": "string",
            "example": "https://s3.amazonaws.com/bucket/dialogue/job.wav?X-Amz-Expires=3600&...",
            "description": "Presigned S3 download URL for the concatenated WAV."
          },
          "audioUrlExpiresIn": {
            "type": "number",
            "example": 3600,
            "description": "Seconds until audioUrl expires."
          },
          "duration": {
            "type": "number",
            "example": 12.4,
            "description": "Duration of the concatenated audio (seconds)."
          },
          "tokenCost": {
            "type": "number",
            "example": 120,
            "description": "Tokens deducted for this request."
          }
        },
        "required": [
          "success",
          "turnsCount",
          "speakers",
          "audioUrl",
          "audioUrlExpiresIn",
          "duration",
          "tokenCost"
        ]
      },
      "PublicDialogueTurnDto": {
        "type": "object",
        "properties": {
          "speaker": {
            "type": "string",
            "example": "Host",
            "description": "Speaker label (display/metadata only)."
          },
          "voiceId": {
            "type": "string",
            "example": "Ngọc Lan",
            "description": "Preset voice ID from GET /v1/voices."
          },
          "text": {
            "type": "string",
            "example": "Xin chào, hôm nay bạn thế nào?",
            "description": "Vietnamese text for this turn (max 5000 chars).",
            "maxLength": 5000
          },
          "emotion": {
            "type": "string",
            "example": "natural",
            "enum": [
              "natural",
              "storytelling",
              "tin_tuc",
              "tu_nhien",
              "doc_truyen",
              "emotion_0",
              "emotion_1",
              "emotion_2",
              "emotion_3",
              "emotion_4",
              "emotion_5",
              "emotion_6",
              "emotion_7",
              "emotion_8",
              "emotion_9",
              "emotion_10",
              "emotion_11",
              "emotion_12",
              "emotion_13",
              "emotion_14",
              "emotion_15",
              "emotion_16",
              "emotion_17",
              "emotion_18",
              "emotion_19"
            ],
            "default": "natural"
          }
        },
        "required": [
          "speaker",
          "voiceId",
          "text"
        ]
      },
      "PublicDubResponseDto": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean",
            "example": true
          },
          "transcript": {
            "type": "string",
            "example": "Xin chào, đây là nội dung được phiên âm.",
            "description": "Vietnamese text transcribed from the uploaded audio."
          },
          "voiceUsed": {
            "type": "string",
            "example": "Tuyen",
            "description": "Voice the audio was re-synthesized with."
          },
          "audioUrl": {
            "type": "string",
            "example": "https://s3.amazonaws.com/bucket/dub/job.wav?X-Amz-Expires=3600&...",
            "description": "Presigned S3 download URL for the dubbed WAV."
          },
          "audioUrlExpiresIn": {
            "type": "number",
            "example": 3600,
            "description": "Seconds until audioUrl expires."
          },
          "duration": {
            "type": "number",
            "example": 5.2,
            "description": "Duration of the dubbed audio (seconds)."
          },
          "tokenCost": {
            "type": "number",
            "example": 80,
            "description": "Tokens deducted (computed from the transcript)."
          }
        },
        "required": [
          "success",
          "transcript",
          "voiceUsed",
          "audioUrl",
          "audioUrlExpiresIn",
          "duration",
          "tokenCost"
        ]
      },
      "PublicEmotionCueDto": {
        "type": "object",
        "properties": {
          "tag": {
            "type": "string",
            "example": "[cười]",
            "description": "Inline cue, embedded directly in the text you submit."
          },
          "label": {
            "type": "string",
            "example": "Cười"
          },
          "description": {
            "type": "string",
            "example": "Tiếng cười ngắn."
          },
          "aliases": {
            "example": [
              "[laugh]"
            ],
            "description": "Alternative spellings accepted for the same cue.",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "tag"
        ]
      },
      "PublicEmotionStyleDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "natural",
            "description": "Value for the `emotion` request field."
          },
          "label": {
            "type": "string",
            "example": "Tự nhiên",
            "description": "Human-readable label (Vietnamese)."
          },
          "description": {
            "type": "string",
            "example": "Giọng hội thoại thường ngày (mặc định)."
          }
        },
        "required": [
          "id",
          "label"
        ]
      },
      "PublicEmotionTagsResponseDto": {
        "type": "object",
        "properties": {
          "engine": {
            "type": "string",
            "example": "v3",
            "enum": [
              "v3",
              "v4"
            ],
            "nullable": true,
            "description": "The engine these styles and cues apply to; null when none was requested."
          },
          "styles": {
            "description": "Reading styles for the `emotion` field. Empty on engines that have none.",
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PublicEmotionStyleDto"
            }
          },
          "tags": {
            "description": "Inline cue tags this engine can voice. Any other tag is deleted from your text.",
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PublicEmotionCueDto"
            }
          },
          "count": {
            "type": "number",
            "example": 6,
            "description": "Number of cues in `tags`."
          }
        },
        "required": [
          "styles",
          "tags",
          "count"
        ]
      },
      "PublicOpenAiVoiceListResponseDto": {
        "type": "object",
        "properties": {
          "voices": {
            "example": [
              "Ngọc Lan",
              "Trúc Ly"
            ],
            "description": "Bare voice ids, the shape OpenAI-compatible clients expect.",
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "voices"
        ]
      },
      "PublicPrepareResponseDto": {
        "type": "object",
        "properties": {
          "fileId": {
            "type": "string",
            "example": "uploads/user-123/prepared_1716000000000_abc123.wav",
            "description": "S3 key of the cleaned 44.1 kHz WAV — pass it as `refFileId` to POST /v1/clone, or download it via `url` to enrol with POST /v1/voices."
          },
          "transcript": {
            "type": "string",
            "example": "Xin chào, đây là giọng nói của tôi.",
            "description": "Whisper transcript of the cleaned clip — use it as `refText` after reviewing it (on V4 every generation aligns against this text)."
          },
          "seconds": {
            "type": "number",
            "example": 11.4,
            "description": "Duration of the cleaned clip (seconds)."
          },
          "languageProbability": {
            "type": "number",
            "example": 0.93,
            "nullable": true,
            "description": "Whisper language-detection confidence (0–1); null when unknown. A low value usually means the clip is noisy or not Vietnamese."
          },
          "warnings": {
            "example": [
              "clip_long_for_engine"
            ],
            "description": "Non-fatal quality warnings, e.g. denoiser_unavailable, empty_transcript, low_language_probability, clip_short_for_engine, clip_long_for_engine.",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "url": {
            "type": "string",
            "example": "https://s3.amazonaws.com/bucket/uploads/user-123/prepared_….wav?X-Amz-Expires=86400",
            "description": "Presigned S3 download URL for the cleaned WAV."
          },
          "urlExpiresIn": {
            "type": "number",
            "example": 86400,
            "description": "Seconds until url expires."
          }
        },
        "required": [
          "fileId",
          "transcript",
          "seconds",
          "languageProbability",
          "warnings",
          "url",
          "urlExpiresIn"
        ]
      },
      "PublicSrtPlacementDto": {
        "type": "object",
        "properties": {
          "index": {
            "type": "number",
            "example": 7,
            "description": "1-based cue index."
          },
          "scheduledStartMs": {
            "type": "number",
            "example": 12000,
            "description": "Start time from the subtitle (ms)."
          },
          "actualStartMs": {
            "type": "number",
            "example": 12180,
            "description": "Start time after timeline assembly (ms)."
          },
          "windowMs": {
            "type": "number",
            "example": 2000,
            "description": "Subtitle window length, end − start (ms)."
          },
          "durationMs": {
            "type": "number",
            "example": 2400,
            "description": "Synthesized cue length after speed-fit (ms)."
          },
          "speed": {
            "type": "number",
            "example": 1.2,
            "description": "Tempo multiplier applied (1.0 = none)."
          },
          "overflowed": {
            "type": "boolean",
            "example": true,
            "description": "True if the cue was pushed later than its start (drift)."
          }
        },
        "required": [
          "index",
          "scheduledStartMs",
          "actualStartMs",
          "windowMs",
          "durationMs",
          "speed",
          "overflowed"
        ]
      },
      "PublicSrtResponseDto": {
        "type": "object",
        "properties": {
          "success": {
            "type": "boolean",
            "example": true
          },
          "cuesCount": {
            "type": "number",
            "example": 42,
            "description": "Number of subtitle cues synthesized."
          },
          "audioUrl": {
            "type": "string",
            "example": "https://s3.amazonaws.com/bucket/srt/job.wav?X-Amz-Expires=86400&...",
            "description": "Presigned S3 download URL for the timecode-aligned WAV."
          },
          "audioUrlExpiresIn": {
            "type": "number",
            "example": 86400,
            "description": "Seconds until audioUrl expires."
          },
          "duration": {
            "type": "number",
            "example": 128.4,
            "description": "Total duration of the dubbed track (seconds)."
          },
          "sampleRate": {
            "type": "number",
            "example": 48000,
            "description": "Output sample rate."
          },
          "tokenCost": {
            "type": "number",
            "example": 1875,
            "description": "Tokens deducted (computed from total cue text)."
          },
          "placements": {
            "description": "Per-cue timeline placement (speed-fit + drift). May be empty — treat it as diagnostic detail, not as part of the result.",
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PublicSrtPlacementDto"
            }
          }
        },
        "required": [
          "success",
          "cuesCount",
          "audioUrl",
          "audioUrlExpiresIn",
          "duration",
          "sampleRate",
          "tokenCost",
          "placements"
        ]
      },
      "PublicStreamRequestDto": {
        "type": "object",
        "properties": {
          "text": {
            "type": "string",
            "example": "Xin chào Việt Nam!",
            "description": "Vietnamese text to synthesize and stream.",
            "maxLength": 50000
          },
          "voiceId": {
            "type": "string",
            "example": "Ngọc Lan",
            "description": "Voice ID from GET /v1/voices: a preset, or one of your own cloned voices (`clone_…`, created with POST /v1/voices — an admin-published clone works too). A voice is only usable on the engine it belongs to. Omit to use the default voice."
          },
          "emotion": {
            "type": "string",
            "example": "natural",
            "enum": [
              "natural",
              "storytelling",
              "tin_tuc",
              "tu_nhien",
              "doc_truyen",
              "emotion_0",
              "emotion_1",
              "emotion_2",
              "emotion_3",
              "emotion_4",
              "emotion_5",
              "emotion_6",
              "emotion_7",
              "emotion_8",
              "emotion_9",
              "emotion_10",
              "emotion_11",
              "emotion_12",
              "emotion_13",
              "emotion_14",
              "emotion_15",
              "emotion_16",
              "emotion_17",
              "emotion_18",
              "emotion_19"
            ],
            "default": "natural"
          },
          "speed": {
            "type": "number",
            "example": 1,
            "description": "Playback speed (0.5–2.0).",
            "minimum": 0.5,
            "maximum": 2,
            "default": 1
          },
          "engine": {
            "type": "string",
            "example": "v4",
            "enum": [
              "v4"
            ],
            "description": "Streaming runs on v4 only — v3 is rejected here. You may omit this: v4 is the default engine. NOTE ON PRICE: because every stream runs on v4, it is charged at the v4 engine multiplier, which is higher than the v3 one. If you priced your workload with POST /v1/tts on v3, streaming the same text will cost more. GET /v1/engines returns the live multipliers."
          },
          "aiRefine": {
            "type": "boolean",
            "example": false,
            "default": false,
            "description": "AI text refinement. Defaults to FALSE on the public API: the text is synthesized as submitted, with no AI moderation and no pronunciation normalization, billed at the plain per-character rate. Set true to run the same AI step the web app uses — formulas, acronyms and mixed-in English are read correctly and the content is checked — billed with the AI surcharge and one extra model round-trip of latency. Deterministic text preparation is applied either way."
          },
          "outputFormat": {
            "type": "string",
            "example": "wav",
            "enum": [
              "wav",
              "mp3",
              "opus",
              "pcm",
              "ulaw"
            ],
            "default": "wav",
            "description": "Encoding of the audio inside each frame. `wav` (default) keeps every frame self-contained and is what existing clients expect. `mp3` and `opus` cut bandwidth roughly six-fold. `pcm` and `ulaw` are HEADERLESS raw audio for telephony and voice-agent pipelines — read the rate from the X-Sample-Rate response header. The frame FRAMING is unchanged in every case, so the end-of-stream marker still means the same thing."
          },
          "sampleRate": {
            "type": "number",
            "example": 24000,
            "enum": [
              8000,
              16000,
              22050,
              24000,
              44100,
              48000
            ],
            "description": "Output sample rate in Hz. Omit for the engine native rate (48000). `opus` is always 48000 and `ulaw` always 8000 — passing a conflicting value is rejected rather than quietly ignored."
          }
        },
        "required": [
          "text"
        ]
      },
      "PublicTtsRequestDto": {
        "type": "object",
        "properties": {
          "text": {
            "type": "string",
            "example": "Xin chào Việt Nam!",
            "description": "Vietnamese text to synthesize.",
            "maxLength": 50000
          },
          "voiceId": {
            "type": "string",
            "example": "Trúc Ly",
            "description": "Voice ID — must be one returned by GET /v1/voices for the requested engine. Omit to use that engine's default voice."
          },
          "emotion": {
            "type": "string",
            "example": "natural",
            "enum": [
              "natural",
              "storytelling",
              "tin_tuc",
              "tu_nhien",
              "doc_truyen",
              "emotion_0",
              "emotion_1",
              "emotion_2",
              "emotion_3",
              "emotion_4",
              "emotion_5",
              "emotion_6",
              "emotion_7",
              "emotion_8",
              "emotion_9",
              "emotion_10",
              "emotion_11",
              "emotion_12",
              "emotion_13",
              "emotion_14",
              "emotion_15",
              "emotion_16",
              "emotion_17",
              "emotion_18",
              "emotion_19"
            ],
            "default": "natural"
          },
          "speed": {
            "type": "number",
            "example": 1,
            "default": 1,
            "minimum": 0.5,
            "maximum": 2,
            "description": "Playback rate of the synthesized speech. 1.0 is the natural pace."
          },
          "engine": {
            "type": "string",
            "example": "v3",
            "enum": [
              "v3",
              "v4"
            ],
            "description": "TTS engine: \"v3\" (48 kHz, default, full feature set) or \"v4\" (48 kHz, reference-based voices, premium rate). Omit to use the configured default. The voice must belong to the engine you pick."
          },
          "aiRefine": {
            "type": "boolean",
            "example": false,
            "default": false,
            "description": "AI text refinement. Defaults to FALSE on the public API: the text is synthesized as submitted, with no AI moderation and no pronunciation normalization, billed at the plain per-character rate. Set true to run the same AI step the web app uses — formulas, acronyms and mixed-in English are read correctly and the content is checked — billed with the AI surcharge and one extra model round-trip of latency. Deterministic text preparation is applied either way."
          }
        },
        "required": [
          "text"
        ]
      },
      "PublicTtsStatusResponseDto": {
        "type": "object",
        "properties": {
          "jobId": {
            "type": "string",
            "example": "550e8400-e29b-41d4-a716-446655440000"
          },
          "status": {
            "type": "string",
            "example": "completed",
            "enum": [
              "queued",
              "processing",
              "completed",
              "failed"
            ]
          },
          "audioUrl": {
            "type": "string",
            "example": "https://s3.amazonaws.com/bucket/tts/job.wav?X-Amz-Expires=86400&...",
            "description": "Presigned S3 download URL. Only present when status=completed. Valid for 24 hours."
          },
          "audioUrlExpiresIn": {
            "type": "number",
            "example": 86400,
            "description": "Seconds until audioUrl expires."
          },
          "duration": {
            "type": "number",
            "example": 3.42
          },
          "voiceId": {
            "type": "string",
            "example": "Tuyen"
          },
          "textLength": {
            "type": "number",
            "example": 150
          },
          "createdAt": {
            "format": "date-time",
            "type": "string",
            "example": "2026-05-17T10:00:00Z"
          },
          "completedAt": {
            "format": "date-time",
            "type": "string",
            "example": "2026-05-17T10:00:08Z"
          },
          "queuePosition": {
            "type": "number",
            "example": 2,
            "description": "Position in queue (only when status=queued)."
          },
          "estimatedWaitSeconds": {
            "type": "number",
            "example": 8,
            "description": "Estimated seconds until processing starts (only when status=queued)."
          },
          "error": {
            "type": "string",
            "example": "Generation failed after retries."
          }
        },
        "required": [
          "jobId",
          "status"
        ]
      },
      "PublicTtsSubmitResponseDto": {
        "type": "object",
        "properties": {
          "jobId": {
            "type": "string",
            "example": "550e8400-e29b-41d4-a716-446655440000"
          },
          "status": {
            "type": "string",
            "example": "queued"
          },
          "message": {
            "type": "string",
            "example": "Job queued. Poll GET /v1/tts/{jobId} for status and audio URL."
          }
        },
        "required": [
          "jobId",
          "status",
          "message"
        ]
      },
      "PublicUploadResponseDto": {
        "type": "object",
        "properties": {
          "fileId": {
            "type": "string",
            "example": "uploads/user-123/1716000000000_abc123.wav",
            "description": "Unique file identifier (S3 key). Pass this as refFileId in future voice-clone requests."
          },
          "fileName": {
            "type": "string",
            "example": "my-voice.wav"
          },
          "mimeType": {
            "type": "string",
            "example": "audio/wav"
          },
          "size": {
            "type": "number",
            "example": 204800,
            "description": "File size in bytes."
          },
          "url": {
            "type": "string",
            "example": "https://s3.amazonaws.com/bucket/uploads/user-123/…?X-Amz-Expires=86400",
            "description": "Presigned S3 download URL. Valid for 24 hours."
          },
          "urlExpiresIn": {
            "type": "number",
            "example": 86400
          },
          "uploadedAt": {
            "type": "string",
            "example": "2026-05-17T10:00:00.000Z"
          }
        },
        "required": [
          "fileId",
          "fileName",
          "mimeType",
          "size",
          "url",
          "urlExpiresIn",
          "uploadedAt"
        ]
      },
      "PublicVoiceDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "Ngọc Lan",
            "description": "Voice id — pass this as `voiceId` (or `voice` on the OpenAI-compatible route). Cloned voices you created look like `clone_9f1c2d34-…`."
          },
          "description": {
            "type": "string",
            "example": "Giọng nữ miền Nam, ấm và rõ."
          },
          "name": {
            "type": "string",
            "example": "Ngọc Lan",
            "description": "Display name."
          },
          "gender": {
            "type": "string",
            "example": "female",
            "nullable": true,
            "description": "Speaker gender. Explicitly null when the catalogue does not record one."
          },
          "region": {
            "type": "string",
            "example": "south",
            "nullable": true,
            "description": "Regional accent. Explicitly null when the catalogue does not record one."
          },
          "engine": {
            "type": "string",
            "example": "v3",
            "enum": [
              "v3",
              "v4"
            ],
            "description": "The engine this voice renders on. A voice is usable on that engine ONLY — passing it with a different `engine` is rejected, not substituted."
          },
          "kind": {
            "type": "string",
            "example": "catalog",
            "enum": [
              "catalog",
              "cloned"
            ],
            "description": "`catalog` for a voice VieNeu publishes; `cloned` for one you created with POST /v1/voices. Cloned voices are only listed when you send your API key."
          }
        },
        "required": [
          "id",
          "engine",
          "kind"
        ]
      },
      "PublicVoiceListResponseDto": {
        "type": "object",
        "properties": {
          "voices": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PublicVoiceDto"
            }
          },
          "count": {
            "type": "number",
            "example": 42,
            "description": "Number of voices in `voices`."
          },
          "error": {
            "type": "string",
            "example": "Could not fetch voices",
            "description": "Present only when the catalogue could not be read. The response is still 200 with an empty list — treat a non-empty `error` as \"retry\", not as \"no voices\"."
          }
        },
        "required": [
          "voices",
          "count"
        ]
      },
      "VapiVoiceMessageDto": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "example": "voice-request"
          },
          "text": {
            "type": "string",
            "example": "Xin chào, tôi có thể giúp gì cho bạn?"
          },
          "sampleRate": {
            "type": "number",
            "example": 24000,
            "enum": [
              8000,
              16000,
              22050,
              24000
            ]
          }
        },
        "required": [
          "type",
          "text",
          "sampleRate"
        ]
      },
      "VapiVoiceRequestDto": {
        "type": "object",
        "properties": {
          "message": {
            "$ref": "#/components/schemas/VapiVoiceMessageDto"
          }
        },
        "required": [
          "message"
        ]
      }
    }
  }
}
