ai-sdk-provider-flowspeech 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +120 -2
- package/dist/index.d.ts +145 -0
- package/dist/index.js +479 -0
- package/package.json +66 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 - 2026-10-26 (unreleased)
|
|
4
|
+
|
|
5
|
+
- Validate complete RIFF/WAVE chunks and 24 kHz mono 16-bit PCM data; preserve valid bytes, reject malformed downloads and allow download-only recovery.
|
|
6
|
+
- Correct quota documentation to canonical `subscription`, retaining optional `entitlement` compatibility.
|
|
7
|
+
|
|
8
|
+
- Replace the retired synchronous endpoint with public API v1 submit, poll and credential-free WAV download.
|
|
9
|
+
- Preserve the framework's buffered speech result contract and expose recovery request/job IDs.
|
|
10
|
+
- Add single/multi input support through the exported client, quota/voices queries, job lookup and explicit cancellation.
|
|
11
|
+
- Bound retries, response sizes, per-request timeouts and total deadlines; reuse submission UUID/body on retries.
|
|
12
|
+
- Respect five-second polling and longer Retry-After values; report failed/cancelled/deleted/malformed jobs explicitly.
|
|
13
|
+
- Reject unsupported legacy generation options, unsafe URLs, API credential overrides and redirects.
|
|
14
|
+
- Add fake-transport framework tests and build/packed-artifact smoke tests; no paid generation tests.
|
|
15
|
+
- Update server-only credential/subscription documentation and migration guidance.
|
|
16
|
+
|
|
17
|
+
0.1.0 identifies the original repository code, not a verified npm release. Registry
|
|
18
|
+
availability was unverified/404 during the 2026-10-26 audit. Publication of 0.2.0 is
|
|
19
|
+
a separate maintainer action.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FlowSpeech
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,121 @@
|
|
|
1
|
-
#
|
|
1
|
+
# FlowSpeech Provider for the AI SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A server-side FlowSpeech public API v1 adapter for the AI SDK `SpeechModelV4` contract. `generateSpeech` resolves after submission, polling and audio download, returning WAV bytes.
|
|
4
|
+
|
|
5
|
+
## Local installation
|
|
6
|
+
|
|
7
|
+
Requires Node.js 22 or newer. Until a registry release is verified, build the local checkout:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm ci --ignore-scripts
|
|
11
|
+
npm test
|
|
12
|
+
npm run typecheck
|
|
13
|
+
npm run build
|
|
14
|
+
npm run test:dist
|
|
15
|
+
npm pack
|
|
16
|
+
# From your application:
|
|
17
|
+
npm install ai /path/to/ai-sdk-provider-flowspeech-0.2.0.tgz
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Set `FLOWSPEECH_API_KEY` in the server environment. Never put it in browser code or commit it.
|
|
21
|
+
|
|
22
|
+
## Generate speech
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { randomUUID } from 'node:crypto'
|
|
26
|
+
import { writeFile } from 'node:fs/promises'
|
|
27
|
+
import { generateSpeech } from 'ai'
|
|
28
|
+
import { createFlowSpeech, FlowSpeechClient } from 'ai-sdk-provider-flowspeech'
|
|
29
|
+
|
|
30
|
+
const requestId = randomUUID() // Persist this before submission.
|
|
31
|
+
const flowSpeech = createFlowSpeech({ timeoutMs: 600_000 })
|
|
32
|
+
const result = await generateSpeech({
|
|
33
|
+
model: flowSpeech.speech('flowspeech-tts'),
|
|
34
|
+
text: 'Welcome to FlowSpeech.[pause]Here is the update.',
|
|
35
|
+
voice: 'Kore',
|
|
36
|
+
outputFormat: 'wav',
|
|
37
|
+
providerOptions: { flowspeech: { requestId } },
|
|
38
|
+
})
|
|
39
|
+
await writeFile('speech.wav', result.audio.uint8Array)
|
|
40
|
+
console.log(result.providerMetadata?.flowspeech) // requestId, jobId, credit usage
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`flowSpeech` is also exported as a default-configured provider. Only `flowspeech-tts` is a valid framework model identifier; it is not sent to the API. The response body contains the final job object, not the legacy base64 envelope.
|
|
44
|
+
|
|
45
|
+
For multiple speakers, set the required framework `text` field to an empty string and supply explicit turns:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const result = await generateSpeech({
|
|
49
|
+
model: flowSpeech.speech(),
|
|
50
|
+
text: '',
|
|
51
|
+
providerOptions: {
|
|
52
|
+
flowspeech: {
|
|
53
|
+
requestId: randomUUID(),
|
|
54
|
+
segments: [
|
|
55
|
+
{ speaker: 'Alice', text: 'Welcome.', voiceName: 'Kore' },
|
|
56
|
+
{ speaker: 'Bob', text: 'Thank you.', voiceName: 'Puck' },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Migration from local 0.1.0 code
|
|
64
|
+
|
|
65
|
+
The synchronous endpoint and base64 response are gone. `baseUrl` now means an HTTPS origin, without an endpoint path. Separate `instructions`, `language` and numeric `speed` options are rejected rather than ignored. Only `outputFormat: 'wav'` is supported. Unknown FlowSpeech provider options are rejected.
|
|
66
|
+
|
|
67
|
+
`createFlowSpeech` accepts `apiKey`, `baseUrl`, `defaultVoice`, `headers`, `fetch`, `timeoutMs`, `requestTimeoutMs`, `pollIntervalMs`, `maxRetries` and `maxAudioBytes`. Headers are API-only; neither provider nor per-call headers may override Authorization, Cookie, Host, Idempotency-Key or transport headers. Framework abort signals are preserved.
|
|
68
|
+
|
|
69
|
+
## v1 behavior and recovery
|
|
70
|
+
|
|
71
|
+
Requires a server-side developer API key and an active paid Basic, Pro or Scale subscription. Trials do not include API generation. Website cookies and plugin login tokens are not API credentials.
|
|
72
|
+
|
|
73
|
+
The API origin is `https://flowspeech.io`. Calls submit `{ input: ... }` to `POST /api/v1/tts/jobs` with a UUID `Idempotency-Key`, poll the returned job, then download its result. No model or legacy speaker fields are sent. Audio is WAV, 24000 Hz, mono, 16 bit. HTTP 200 from a status query is not generation success.
|
|
74
|
+
|
|
75
|
+
- Pending states: queued, running, merging, settling. Failed/cancelled jobs throw; a deleted success result throws `result_deleted`.
|
|
76
|
+
- Polling defaults to 5000 ms and cannot be configured faster. Longer `Retry-After` values, including HTTP dates and rate limits, take precedence.
|
|
77
|
+
- There are at most two transient retries per HTTP operation by default (configurable from zero to five). Submission retries preserve the exact UUID and JSON body.
|
|
78
|
+
- The total deadline includes submit, polling, retry waits, response-body reads and download. The general client default is 600000 ms; each request defaults to 30000 ms.
|
|
79
|
+
- Downloads use no API headers, Bearer token or cookies. HTTPS URLs with embedded credentials are rejected. API and storage redirects are rejected rather than followed. Downloads are bounded (64 MiB for the general client).
|
|
80
|
+
- Custom fetch implementations are trusted application code and must preserve the supplied abort, redirect and credential settings.
|
|
81
|
+
- Aborting or timing out stops local work; it does not cancel the server job or reverse a charge. Use `cancelJob` explicitly when cancellation is intended.
|
|
82
|
+
|
|
83
|
+
Each package exports `FlowSpeechClient` and `FlowSpeechError`. Save a fresh UUID before each new recording and pass it as `requestId`. Reuse it only with unchanged input. Error properties include `code`, `status`, `traceId`, `requestId` and `jobId` when known; do not automatically start a new job after a timeout or download error.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
const client = new FlowSpeechClient({ apiKey: process.env.FLOWSPEECH_API_KEY })
|
|
87
|
+
const quota = await client.getQuota()
|
|
88
|
+
console.log(quota.quota.available)
|
|
89
|
+
const voices = await client.listVoices()
|
|
90
|
+
|
|
91
|
+
// Use the UUID previously saved for the interrupted recording.
|
|
92
|
+
const job = await client.findJob(savedRequestId)
|
|
93
|
+
// Or: await client.getJob(savedJobId)
|
|
94
|
+
// Poll pending jobs no faster than five seconds, or resume generate() with
|
|
95
|
+
// the same UUID and unchanged input; it replays the existing job.
|
|
96
|
+
if (job.status === 'success') {
|
|
97
|
+
const bytes = await client.downloadJob(job) // never creates a recording
|
|
98
|
+
}
|
|
99
|
+
// await client.cancelJob(job.id)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The quota response preserves canonical top-level `quota`, `subscription` and `limits`. An optional `entitlement` field is also retained for compatibility; it is not required. Result links grant public link access and should not be logged or shared unnecessarily. Deleting a result does not necessarily invalidate a previously copied link.
|
|
103
|
+
|
|
104
|
+
Downloads validate the actual RIFF/WAVE container, chunk lengths and padding,
|
|
105
|
+
PCM format (24000 Hz, mono, 16 bit), byte rate/block alignment, and complete
|
|
106
|
+
nonempty aligned sample data. Truncated, malformed or mismatched audio is
|
|
107
|
+
rejected even on HTTP 200. Bounded unknown chunks are skipped safely and valid
|
|
108
|
+
files are returned byte-for-byte unchanged. Validation remains within the
|
|
109
|
+
operation deadline; an invalid download never triggers another submission.
|
|
110
|
+
|
|
111
|
+
Full API contract: [FlowSpeech public API](https://flowspeech.io/docs/public-api).
|
|
112
|
+
|
|
113
|
+
## Development and release status
|
|
114
|
+
|
|
115
|
+
These are local 0.2.0 release candidates. The npm-name audit on 2026-10-26 returned 404; do not assume an earlier npm release exists or claim this version is published. The GitHub repository exists independently of npm availability. Publication is a separate maintainer/coordinator action.
|
|
116
|
+
|
|
117
|
+
Install locked dependencies, then run the package checks and pack locally. Tests use only fake transports and synthetic WAV data; they do not spend generation credits. See `RELEASE_CHECKLIST.md` for maintainer release steps.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { SpeechModelV4, ProviderV4 } from '@ai-sdk/provider';
|
|
2
|
+
|
|
3
|
+
type FlowSpeechInput = {
|
|
4
|
+
mode: 'single';
|
|
5
|
+
text: string;
|
|
6
|
+
voiceName: string;
|
|
7
|
+
} | {
|
|
8
|
+
mode: 'multi';
|
|
9
|
+
segments: Array<{
|
|
10
|
+
speaker: string;
|
|
11
|
+
text: string;
|
|
12
|
+
voiceName: string;
|
|
13
|
+
}>;
|
|
14
|
+
};
|
|
15
|
+
interface FlowSpeechResult {
|
|
16
|
+
url: string;
|
|
17
|
+
mimeType: 'audio/wav';
|
|
18
|
+
sampleRate: 24000;
|
|
19
|
+
channels: 1;
|
|
20
|
+
bitsPerSample: 16;
|
|
21
|
+
}
|
|
22
|
+
interface FlowSpeechJob {
|
|
23
|
+
id: string;
|
|
24
|
+
requestId: string;
|
|
25
|
+
status: 'queued' | 'running' | 'merging' | 'settling' | 'success' | 'failed' | 'cancelled';
|
|
26
|
+
result?: FlowSpeechResult | null;
|
|
27
|
+
resultDeleted?: boolean;
|
|
28
|
+
usage: {
|
|
29
|
+
billableCharacters: number;
|
|
30
|
+
reservedCredits: number;
|
|
31
|
+
chargedCredits: number;
|
|
32
|
+
};
|
|
33
|
+
error?: {
|
|
34
|
+
code: string;
|
|
35
|
+
message?: string;
|
|
36
|
+
retryable?: boolean;
|
|
37
|
+
} | null;
|
|
38
|
+
}
|
|
39
|
+
interface FlowSpeechQuota {
|
|
40
|
+
quota: {
|
|
41
|
+
remaining: number;
|
|
42
|
+
reserved: number;
|
|
43
|
+
available: number;
|
|
44
|
+
};
|
|
45
|
+
/** Optional compatibility field; production v1 returns subscription. */
|
|
46
|
+
entitlement?: Record<string, unknown> | null;
|
|
47
|
+
/** Canonical public API v1 subscription metadata. */
|
|
48
|
+
subscription?: Record<string, unknown> | null;
|
|
49
|
+
limits: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
interface FlowSpeechVoice {
|
|
52
|
+
name: string;
|
|
53
|
+
description: string;
|
|
54
|
+
gender: string;
|
|
55
|
+
}
|
|
56
|
+
interface FlowSpeechClientOptions {
|
|
57
|
+
apiKey?: string | undefined;
|
|
58
|
+
/** HTTPS API origin, not an endpoint path. */
|
|
59
|
+
baseUrl?: string | undefined;
|
|
60
|
+
/** API-only headers. Authentication, cookies and idempotency headers cannot be overridden. */
|
|
61
|
+
headers?: Record<string, string> | undefined;
|
|
62
|
+
fetch?: typeof fetch | undefined;
|
|
63
|
+
/** Total operation deadline, including requests, waits, retries and audio download. */
|
|
64
|
+
timeoutMs?: number | undefined;
|
|
65
|
+
requestTimeoutMs?: number | undefined;
|
|
66
|
+
/** At least 5000 ms. */
|
|
67
|
+
pollIntervalMs?: number | undefined;
|
|
68
|
+
/** Number of retries per HTTP operation, between zero and five. */
|
|
69
|
+
maxRetries?: number | undefined;
|
|
70
|
+
maxAudioBytes?: number | undefined;
|
|
71
|
+
}
|
|
72
|
+
interface FlowSpeechOperationOptions {
|
|
73
|
+
requestId?: string | undefined;
|
|
74
|
+
signal?: AbortSignal | undefined;
|
|
75
|
+
}
|
|
76
|
+
declare class FlowSpeechError extends Error {
|
|
77
|
+
readonly code: string;
|
|
78
|
+
readonly status?: number | undefined;
|
|
79
|
+
readonly traceId?: string | undefined;
|
|
80
|
+
readonly retryable: boolean;
|
|
81
|
+
readonly name = "FlowSpeechError";
|
|
82
|
+
requestId?: string;
|
|
83
|
+
jobId?: string;
|
|
84
|
+
constructor(message: string, code: string, status?: number | undefined, traceId?: string | undefined, retryable?: boolean);
|
|
85
|
+
}
|
|
86
|
+
declare function rejectUnsupported(value: object, supported: readonly string[]): void;
|
|
87
|
+
declare function normalizeApiOrigin(value?: string): string;
|
|
88
|
+
declare class FlowSpeechClient {
|
|
89
|
+
private readonly key;
|
|
90
|
+
private readonly origin;
|
|
91
|
+
private readonly headers;
|
|
92
|
+
private readonly fetcher;
|
|
93
|
+
private readonly timeout;
|
|
94
|
+
private readonly requestTimeout;
|
|
95
|
+
private readonly interval;
|
|
96
|
+
private readonly retries;
|
|
97
|
+
private readonly maxAudio;
|
|
98
|
+
constructor(options?: FlowSpeechClientOptions);
|
|
99
|
+
private clean;
|
|
100
|
+
private operation;
|
|
101
|
+
private request;
|
|
102
|
+
private api;
|
|
103
|
+
generate(input: FlowSpeechInput, options?: FlowSpeechOperationOptions): Promise<{
|
|
104
|
+
audio: Uint8Array<ArrayBufferLike>;
|
|
105
|
+
job: FlowSpeechJob;
|
|
106
|
+
requestId: string;
|
|
107
|
+
requestBody: string;
|
|
108
|
+
headers: Record<string, string>;
|
|
109
|
+
}>;
|
|
110
|
+
private audio;
|
|
111
|
+
getJob(id: string, options?: FlowSpeechOperationOptions): Promise<FlowSpeechJob>;
|
|
112
|
+
findJob(requestId: string, options?: Omit<FlowSpeechOperationOptions, 'requestId'>): Promise<FlowSpeechJob>;
|
|
113
|
+
cancelJob(id: string, options?: FlowSpeechOperationOptions): Promise<FlowSpeechJob>;
|
|
114
|
+
/** Retries only the download of an existing successful job, never generation. */
|
|
115
|
+
downloadJob(current: FlowSpeechJob, options?: FlowSpeechOperationOptions): Promise<Uint8Array>;
|
|
116
|
+
getQuota(options?: FlowSpeechOperationOptions): Promise<FlowSpeechQuota>;
|
|
117
|
+
listVoices(options?: FlowSpeechOperationOptions): Promise<FlowSpeechVoice[]>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Framework identifier only; no model field is sent to the API. */
|
|
121
|
+
type FlowSpeechModelId = 'flowspeech-tts' | (string & {});
|
|
122
|
+
interface FlowSpeechSpeechModelConfig extends FlowSpeechClientOptions {
|
|
123
|
+
provider: string;
|
|
124
|
+
defaultVoice: string;
|
|
125
|
+
currentDate?: () => Date;
|
|
126
|
+
}
|
|
127
|
+
declare class FlowSpeechSpeechModel implements SpeechModelV4 {
|
|
128
|
+
readonly modelId: FlowSpeechModelId;
|
|
129
|
+
private readonly config;
|
|
130
|
+
readonly specificationVersion = "v4";
|
|
131
|
+
get provider(): string;
|
|
132
|
+
constructor(modelId: FlowSpeechModelId, config: FlowSpeechSpeechModelConfig);
|
|
133
|
+
doGenerate(options: Parameters<SpeechModelV4['doGenerate']>[0]): Promise<Awaited<ReturnType<SpeechModelV4['doGenerate']>>>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface FlowSpeechProvider extends ProviderV4 {
|
|
137
|
+
speech(modelId?: FlowSpeechModelId): SpeechModelV4;
|
|
138
|
+
}
|
|
139
|
+
interface FlowSpeechProviderSettings extends FlowSpeechClientOptions {
|
|
140
|
+
defaultVoice?: string;
|
|
141
|
+
}
|
|
142
|
+
declare function createFlowSpeech(settings?: FlowSpeechProviderSettings): FlowSpeechProvider;
|
|
143
|
+
declare const flowSpeech: FlowSpeechProvider;
|
|
144
|
+
|
|
145
|
+
export { FlowSpeechClient, type FlowSpeechClientOptions, FlowSpeechError, type FlowSpeechInput, type FlowSpeechJob, type FlowSpeechModelId, type FlowSpeechOperationOptions, type FlowSpeechProvider, type FlowSpeechProviderSettings, type FlowSpeechQuota, type FlowSpeechResult, FlowSpeechSpeechModel, type FlowSpeechVoice, createFlowSpeech, flowSpeech, normalizeApiOrigin, rejectUnsupported };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
// src/flowspeech-client.ts
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
var FlowSpeechError = class extends Error {
|
|
4
|
+
constructor(message, code, status, traceId, retryable = false) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.traceId = traceId;
|
|
9
|
+
this.retryable = retryable;
|
|
10
|
+
}
|
|
11
|
+
code;
|
|
12
|
+
status;
|
|
13
|
+
traceId;
|
|
14
|
+
retryable;
|
|
15
|
+
name = "FlowSpeechError";
|
|
16
|
+
requestId;
|
|
17
|
+
jobId;
|
|
18
|
+
};
|
|
19
|
+
var uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
20
|
+
var statuses = /* @__PURE__ */ new Set(["queued", "running", "merging", "settling", "success", "failed", "cancelled"]);
|
|
21
|
+
var forbiddenHeaders = /^(authorization|proxy-authorization|cookie|cookie2|host|idempotency-key|content-type|content-length|connection|transfer-encoding)$/i;
|
|
22
|
+
function fail(message, code = "invalid_response") {
|
|
23
|
+
throw new FlowSpeechError(message, code);
|
|
24
|
+
}
|
|
25
|
+
function record(value) {
|
|
26
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
function uuid(value, label) {
|
|
29
|
+
if (typeof value !== "string" || !uuidPattern.test(value)) fail("Invalid " + label + " UUID", "invalid_request");
|
|
30
|
+
return value.toLowerCase();
|
|
31
|
+
}
|
|
32
|
+
function text(value, label) {
|
|
33
|
+
if (typeof value !== "string" || !value.trim()) fail(label + " must not be empty", "invalid_request");
|
|
34
|
+
}
|
|
35
|
+
function number(value) {
|
|
36
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
37
|
+
}
|
|
38
|
+
function integer(value, label, min = 1, max = 2147483647) {
|
|
39
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) fail("Invalid " + label, "invalid_configuration");
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
function rejectUnsupported(value, supported) {
|
|
43
|
+
for (const [key, item] of Object.entries(value)) {
|
|
44
|
+
if (item !== void 0 && !supported.includes(key)) fail("Unsupported FlowSpeech option: " + key, "unsupported_option");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function normalizeApiOrigin(value = "https://flowspeech.io") {
|
|
48
|
+
let url;
|
|
49
|
+
try {
|
|
50
|
+
url = new URL(value);
|
|
51
|
+
} catch {
|
|
52
|
+
return fail("Invalid FlowSpeech baseUrl", "invalid_configuration");
|
|
53
|
+
}
|
|
54
|
+
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.pathname !== "/") {
|
|
55
|
+
fail("FlowSpeech baseUrl must be an HTTPS origin without credentials, path, query or fragment", "invalid_configuration");
|
|
56
|
+
}
|
|
57
|
+
return url.origin;
|
|
58
|
+
}
|
|
59
|
+
function audioUrl(value) {
|
|
60
|
+
if (typeof value !== "string") fail("Missing audio URL");
|
|
61
|
+
let url;
|
|
62
|
+
try {
|
|
63
|
+
url = new URL(value);
|
|
64
|
+
} catch {
|
|
65
|
+
return fail("Invalid audio URL");
|
|
66
|
+
}
|
|
67
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash) fail("Unsafe audio URL");
|
|
68
|
+
return url.href;
|
|
69
|
+
}
|
|
70
|
+
function validateInput(input) {
|
|
71
|
+
if (!record(input)) fail("Invalid input", "invalid_request");
|
|
72
|
+
if (input.mode === "single") {
|
|
73
|
+
rejectUnsupported(input, ["mode", "text", "voiceName"]);
|
|
74
|
+
text(input.text, "text");
|
|
75
|
+
text(input.voiceName, "voiceName");
|
|
76
|
+
if (input.text.length > 1e5) fail("Text exceeds 100000 UTF-16 code units", "invalid_request");
|
|
77
|
+
return { mode: "single", text: input.text, voiceName: input.voiceName };
|
|
78
|
+
}
|
|
79
|
+
if (input.mode !== "multi" || !Array.isArray(input.segments) || input.segments.length < 1 || input.segments.length > 1e3) {
|
|
80
|
+
fail("Multi input requires 1 to 1000 segments", "invalid_request");
|
|
81
|
+
}
|
|
82
|
+
rejectUnsupported(input, ["mode", "segments"]);
|
|
83
|
+
let length = 0;
|
|
84
|
+
const speakers = /* @__PURE__ */ new Map();
|
|
85
|
+
const segments = input.segments.map((segment) => {
|
|
86
|
+
if (!record(segment)) fail("Invalid segment", "invalid_request");
|
|
87
|
+
rejectUnsupported(segment, ["speaker", "text", "voiceName"]);
|
|
88
|
+
text(segment.speaker, "speaker");
|
|
89
|
+
text(segment.text, "text");
|
|
90
|
+
text(segment.voiceName, "voiceName");
|
|
91
|
+
if (segment.speaker.length > 100) fail("Speaker label exceeds 100 characters", "invalid_request");
|
|
92
|
+
if (speakers.has(segment.speaker) && speakers.get(segment.speaker) !== segment.voiceName) {
|
|
93
|
+
fail("A speaker must use the same voice in every segment", "invalid_request");
|
|
94
|
+
}
|
|
95
|
+
speakers.set(segment.speaker, segment.voiceName);
|
|
96
|
+
length += segment.text.length;
|
|
97
|
+
return { speaker: segment.speaker, text: segment.text, voiceName: segment.voiceName };
|
|
98
|
+
});
|
|
99
|
+
if (length > 1e5) fail("Text exceeds 100000 UTF-16 code units", "invalid_request");
|
|
100
|
+
return { mode: "multi", segments };
|
|
101
|
+
}
|
|
102
|
+
function job(value, ctx) {
|
|
103
|
+
if (!record(value)) fail("Malformed job");
|
|
104
|
+
const id = uuid(value.id, "job ID");
|
|
105
|
+
if (ctx.jobId && ctx.jobId !== id) fail("Job ID changed unexpectedly");
|
|
106
|
+
ctx.jobId = id;
|
|
107
|
+
if (typeof value.requestId !== "string" || !uuidPattern.test(value.requestId)) fail("Missing or invalid job request ID");
|
|
108
|
+
const requestId = value.requestId.toLowerCase();
|
|
109
|
+
if (ctx.requestId && ctx.requestId !== requestId) fail("Request ID changed unexpectedly");
|
|
110
|
+
ctx.requestId = requestId;
|
|
111
|
+
if (typeof value.status !== "string" || !statuses.has(value.status)) fail("Unknown job status");
|
|
112
|
+
if (!record(value.usage) || !["billableCharacters", "reservedCredits", "chargedCredits"].every((k) => number(value.usage[k]))) {
|
|
113
|
+
fail("Missing or invalid job usage");
|
|
114
|
+
}
|
|
115
|
+
if (value.resultDeleted !== void 0 && typeof value.resultDeleted !== "boolean") fail("Invalid resultDeleted flag");
|
|
116
|
+
if (value.status === "success" && !(value.result === null && value.resultDeleted === true)) {
|
|
117
|
+
const result = value.result;
|
|
118
|
+
if (!record(result) || result.mimeType !== "audio/wav" || result.sampleRate !== 24e3 || result.channels !== 1 || result.bitsPerSample !== 16) {
|
|
119
|
+
fail("Missing or malformed WAV result");
|
|
120
|
+
}
|
|
121
|
+
audioUrl(result.url);
|
|
122
|
+
}
|
|
123
|
+
return { ...value, id, requestId };
|
|
124
|
+
}
|
|
125
|
+
function retryAfter(value) {
|
|
126
|
+
if (!value) return 0;
|
|
127
|
+
const seconds = Number(value);
|
|
128
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
|
|
129
|
+
const date = Date.parse(value);
|
|
130
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0;
|
|
131
|
+
}
|
|
132
|
+
async function abortable(promise, signal) {
|
|
133
|
+
if (signal.aborted) throw signal.reason;
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const abort = () => reject(signal.reason);
|
|
136
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
137
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async function wait(ms, signal) {
|
|
141
|
+
if (signal.aborted) throw signal.reason;
|
|
142
|
+
let timer;
|
|
143
|
+
try {
|
|
144
|
+
await abortable(new Promise((resolve) => {
|
|
145
|
+
timer = setTimeout(resolve, Math.min(ms, 2147483647));
|
|
146
|
+
}), signal);
|
|
147
|
+
} finally {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function boundedBody(response, max, signal) {
|
|
152
|
+
const length = response.headers.get("content-length");
|
|
153
|
+
if (length && Number(length) > max) {
|
|
154
|
+
void response.body?.cancel().catch(() => {
|
|
155
|
+
});
|
|
156
|
+
fail("FlowSpeech response exceeds " + max + " bytes", "response_too_large");
|
|
157
|
+
}
|
|
158
|
+
if (!response.body) fail("Empty response body");
|
|
159
|
+
const reader = response.body.getReader();
|
|
160
|
+
const chunks = [];
|
|
161
|
+
let size = 0;
|
|
162
|
+
try {
|
|
163
|
+
while (true) {
|
|
164
|
+
const { done, value } = await abortable(reader.read(), signal);
|
|
165
|
+
if (done) break;
|
|
166
|
+
size += value.byteLength;
|
|
167
|
+
if (size > max) fail("FlowSpeech response exceeds " + max + " bytes", "response_too_large");
|
|
168
|
+
chunks.push(value);
|
|
169
|
+
}
|
|
170
|
+
} catch (error) {
|
|
171
|
+
void reader.cancel().catch(() => {
|
|
172
|
+
});
|
|
173
|
+
throw error;
|
|
174
|
+
} finally {
|
|
175
|
+
reader.releaseLock();
|
|
176
|
+
}
|
|
177
|
+
const output = new Uint8Array(size);
|
|
178
|
+
let offset = 0;
|
|
179
|
+
for (const chunk of chunks) {
|
|
180
|
+
output.set(chunk, offset);
|
|
181
|
+
offset += chunk.byteLength;
|
|
182
|
+
}
|
|
183
|
+
return output;
|
|
184
|
+
}
|
|
185
|
+
async function validateWav(bytes, signal) {
|
|
186
|
+
const invalid = () => fail("Invalid WAV: expected complete 24000 Hz mono 16-bit PCM with nonempty aligned data", "invalid_audio");
|
|
187
|
+
if (bytes.length < 12) invalid();
|
|
188
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
189
|
+
const tag = (offset2) => view.getUint32(offset2);
|
|
190
|
+
if (tag(0) !== 1380533830 || tag(8) !== 1463899717 || view.getUint32(4, true) + 8 !== bytes.length) invalid();
|
|
191
|
+
let offset = 12;
|
|
192
|
+
let formatSeen = false;
|
|
193
|
+
let dataSeen = false;
|
|
194
|
+
let chunks = 0;
|
|
195
|
+
while (offset < bytes.length) {
|
|
196
|
+
if (signal.aborted) throw signal.reason;
|
|
197
|
+
if (++chunks % 1024 === 0) await wait(0, signal);
|
|
198
|
+
if (bytes.length - offset < 8) invalid();
|
|
199
|
+
const size = view.getUint32(offset + 4, true);
|
|
200
|
+
const start = offset + 8;
|
|
201
|
+
const end = start + size;
|
|
202
|
+
const next = end + (size & 1);
|
|
203
|
+
if (next > bytes.length) invalid();
|
|
204
|
+
switch (tag(offset)) {
|
|
205
|
+
case 1718449184:
|
|
206
|
+
if (formatSeen || dataSeen || size !== 16 && size !== 18) invalid();
|
|
207
|
+
if (view.getUint16(start, true) !== 1 || view.getUint16(start + 2, true) !== 1 || view.getUint32(start + 4, true) !== 24e3 || view.getUint32(start + 8, true) !== 48e3 || view.getUint16(start + 12, true) !== 2 || view.getUint16(start + 14, true) !== 16 || size === 18 && view.getUint16(start + 16, true) !== 0) invalid();
|
|
208
|
+
formatSeen = true;
|
|
209
|
+
break;
|
|
210
|
+
case 1684108385:
|
|
211
|
+
if (!formatSeen || dataSeen || size === 0 || size % 2 !== 0) invalid();
|
|
212
|
+
dataSeen = true;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
offset = next;
|
|
216
|
+
}
|
|
217
|
+
if (!formatSeen || !dataSeen) invalid();
|
|
218
|
+
if (signal.aborted) throw signal.reason;
|
|
219
|
+
}
|
|
220
|
+
var FlowSpeechClient = class {
|
|
221
|
+
key;
|
|
222
|
+
origin;
|
|
223
|
+
headers;
|
|
224
|
+
fetcher;
|
|
225
|
+
timeout;
|
|
226
|
+
requestTimeout;
|
|
227
|
+
interval;
|
|
228
|
+
retries;
|
|
229
|
+
maxAudio;
|
|
230
|
+
constructor(options = {}) {
|
|
231
|
+
if (typeof options !== "object" || options === null || Array.isArray(options) || Object.values(options).some((value) => value === null)) fail("Invalid FlowSpeech configuration", "invalid_configuration");
|
|
232
|
+
if (options.headers !== void 0 && (!record(options.headers) || Object.values(options.headers).some((value) => typeof value !== "string"))) fail("Invalid API headers", "invalid_configuration");
|
|
233
|
+
const key = options.apiKey ?? process.env.FLOWSPEECH_API_KEY;
|
|
234
|
+
if (typeof key !== "string" || !/^[\x21-\x7e]+$/.test(key)) fail("A valid server-side FlowSpeech API key is required", "invalid_configuration");
|
|
235
|
+
this.key = key;
|
|
236
|
+
this.origin = normalizeApiOrigin(options.baseUrl);
|
|
237
|
+
this.headers = new Headers();
|
|
238
|
+
for (const [name, value] of Object.entries(options.headers ?? {})) {
|
|
239
|
+
if (forbiddenHeaders.test(name)) fail("Reserved FlowSpeech header: " + name, "invalid_configuration");
|
|
240
|
+
try {
|
|
241
|
+
this.headers.set(name, value);
|
|
242
|
+
} catch {
|
|
243
|
+
fail("Invalid API header", "invalid_configuration");
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
this.fetcher = options.fetch ?? globalThis.fetch;
|
|
247
|
+
if (typeof this.fetcher !== "function") fail("A fetch implementation is required", "invalid_configuration");
|
|
248
|
+
this.timeout = integer(options.timeoutMs ?? 6e5, "timeoutMs");
|
|
249
|
+
this.requestTimeout = integer(options.requestTimeoutMs ?? 3e4, "requestTimeoutMs");
|
|
250
|
+
this.interval = integer(options.pollIntervalMs ?? 5e3, "pollIntervalMs", 5e3);
|
|
251
|
+
this.retries = integer(options.maxRetries ?? 2, "maxRetries", 0, 5);
|
|
252
|
+
this.maxAudio = integer(options.maxAudioBytes ?? 64 * 1024 * 1024, "maxAudioBytes");
|
|
253
|
+
}
|
|
254
|
+
clean(value) {
|
|
255
|
+
if (typeof value !== "string") return void 0;
|
|
256
|
+
let result = value.split(this.key).join("[redacted]");
|
|
257
|
+
for (const secret of this.headers.values()) if (secret) result = result.split(secret).join("[redacted]");
|
|
258
|
+
return result.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 500);
|
|
259
|
+
}
|
|
260
|
+
async operation(options, fn, jobId) {
|
|
261
|
+
const controller = new AbortController();
|
|
262
|
+
const cancel = () => controller.abort(new FlowSpeechError("FlowSpeech operation aborted; the server job was not cancelled", "aborted"));
|
|
263
|
+
options.signal?.addEventListener("abort", cancel, { once: true });
|
|
264
|
+
if (options.signal?.aborted) cancel();
|
|
265
|
+
const timer = setTimeout(() => controller.abort(new FlowSpeechError("FlowSpeech total deadline exceeded; recover with the same request or job ID", "deadline_exceeded")), this.timeout);
|
|
266
|
+
const ctx = { signal: controller.signal };
|
|
267
|
+
try {
|
|
268
|
+
if (options.requestId !== void 0) ctx.requestId = uuid(options.requestId, "request ID");
|
|
269
|
+
if (jobId !== void 0) ctx.jobId = uuid(jobId, "job ID");
|
|
270
|
+
if (ctx.signal.aborted) throw ctx.signal.reason;
|
|
271
|
+
return await abortable(fn(ctx), ctx.signal);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
const safe = error instanceof FlowSpeechError ? error : new FlowSpeechError("FlowSpeech request failed", "network_error", void 0, void 0, true);
|
|
274
|
+
if (ctx.requestId) safe.requestId = ctx.requestId;
|
|
275
|
+
if (ctx.jobId) safe.jobId = ctx.jobId;
|
|
276
|
+
throw safe;
|
|
277
|
+
} finally {
|
|
278
|
+
clearTimeout(timer);
|
|
279
|
+
options.signal?.removeEventListener("abort", cancel);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async request(url, init, ctx, download = false) {
|
|
283
|
+
for (let attempt = 0; ; attempt++) {
|
|
284
|
+
if (ctx.signal.aborted) throw ctx.signal.reason;
|
|
285
|
+
const controller = new AbortController();
|
|
286
|
+
const abort = () => controller.abort(ctx.signal.reason);
|
|
287
|
+
ctx.signal.addEventListener("abort", abort, { once: true });
|
|
288
|
+
const timer = setTimeout(() => controller.abort(new FlowSpeechError("FlowSpeech request timed out", "request_timeout", void 0, void 0, true)), this.requestTimeout);
|
|
289
|
+
let delay = this.interval;
|
|
290
|
+
try {
|
|
291
|
+
const response = await abortable(this.fetcher(url, {
|
|
292
|
+
...init,
|
|
293
|
+
signal: controller.signal,
|
|
294
|
+
redirect: "manual",
|
|
295
|
+
credentials: "omit",
|
|
296
|
+
referrerPolicy: "no-referrer"
|
|
297
|
+
}), controller.signal);
|
|
298
|
+
if (response.redirected || response.status >= 300 && response.status < 400) {
|
|
299
|
+
void response.body?.cancel().catch(() => {
|
|
300
|
+
});
|
|
301
|
+
fail("Unexpected redirect rejected", "unexpected_redirect");
|
|
302
|
+
}
|
|
303
|
+
delay = Math.max(this.interval, retryAfter(response.headers.get("retry-after")));
|
|
304
|
+
if (download && !response.ok) {
|
|
305
|
+
void response.body?.cancel().catch(() => {
|
|
306
|
+
});
|
|
307
|
+
throw new FlowSpeechError("FlowSpeech audio download failed (HTTP " + response.status + ")", "download_error", response.status, void 0, response.status === 429 || response.status >= 500);
|
|
308
|
+
}
|
|
309
|
+
const bytes = !response.ok && !response.body ? new Uint8Array() : await boundedBody(response, download ? this.maxAudio : 1024 * 1024, controller.signal);
|
|
310
|
+
if (download) {
|
|
311
|
+
await validateWav(bytes, controller.signal);
|
|
312
|
+
return bytes;
|
|
313
|
+
}
|
|
314
|
+
let value;
|
|
315
|
+
try {
|
|
316
|
+
value = JSON.parse(new TextDecoder().decode(bytes));
|
|
317
|
+
} catch {
|
|
318
|
+
if (!response.ok) throw new FlowSpeechError("FlowSpeech API error (HTTP " + response.status + ")", "http_error", response.status, void 0, response.status === 429 || response.status >= 500);
|
|
319
|
+
fail("Malformed API JSON");
|
|
320
|
+
}
|
|
321
|
+
if (!response.ok) {
|
|
322
|
+
const error = record(value) && record(value.error) ? value.error : {};
|
|
323
|
+
const code = this.clean(error.code) ?? "http_error";
|
|
324
|
+
const message = this.clean(error.message) ?? "Request failed";
|
|
325
|
+
const traceId = record(value) ? this.clean(value.traceId) : void 0;
|
|
326
|
+
throw new FlowSpeechError(
|
|
327
|
+
"FlowSpeech API error (HTTP " + response.status + "): " + message,
|
|
328
|
+
code,
|
|
329
|
+
response.status,
|
|
330
|
+
traceId,
|
|
331
|
+
error.retryable === true || error.retryable !== false && (response.status === 429 || response.status >= 500)
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return { value, retryAfter: delay, headers: Object.fromEntries(response.headers) };
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (ctx.signal.aborted) throw ctx.signal.reason;
|
|
337
|
+
const safe = error instanceof FlowSpeechError ? error : controller.signal.aborted ? controller.signal.reason : new FlowSpeechError("FlowSpeech network request failed", download ? "download_error" : "network_error", void 0, void 0, true);
|
|
338
|
+
if (!safe.retryable || attempt >= this.retries) throw safe;
|
|
339
|
+
} finally {
|
|
340
|
+
clearTimeout(timer);
|
|
341
|
+
ctx.signal.removeEventListener("abort", abort);
|
|
342
|
+
}
|
|
343
|
+
await wait(delay, ctx.signal);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async api(path, ctx, body, method = "GET") {
|
|
347
|
+
const headers = new Headers(this.headers);
|
|
348
|
+
headers.set("Authorization", "Bearer " + this.key);
|
|
349
|
+
headers.set("Accept", "application/json");
|
|
350
|
+
if (body !== void 0) {
|
|
351
|
+
headers.set("Content-Type", "application/json");
|
|
352
|
+
headers.set("Idempotency-Key", ctx.requestId);
|
|
353
|
+
}
|
|
354
|
+
return await this.request(this.origin + "/api/v1" + path, { method, headers, ...body === void 0 ? {} : { body } }, ctx);
|
|
355
|
+
}
|
|
356
|
+
async generate(input, options = {}) {
|
|
357
|
+
return this.operation({ ...options, requestId: options.requestId === void 0 ? randomUUID() : options.requestId }, async (ctx) => {
|
|
358
|
+
const body = JSON.stringify({ input: validateInput(input) });
|
|
359
|
+
if (Buffer.byteLength(body) > 1024 * 1024) fail("Input JSON exceeds 1 MiB", "invalid_request");
|
|
360
|
+
let response = await this.api("/tts/jobs", ctx, body, "POST");
|
|
361
|
+
let current = job(response.value, ctx);
|
|
362
|
+
while (!["success", "failed", "cancelled"].includes(current.status)) {
|
|
363
|
+
await wait(response.retryAfter, ctx.signal);
|
|
364
|
+
response = await this.api("/tts/jobs/" + ctx.jobId, ctx);
|
|
365
|
+
current = job(response.value, ctx);
|
|
366
|
+
}
|
|
367
|
+
const audio = await this.audio(current, ctx);
|
|
368
|
+
return { audio, job: current, requestId: ctx.requestId, requestBody: body, headers: response.headers };
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
async audio(current, ctx) {
|
|
372
|
+
if (current.status === "failed" || current.status === "cancelled") {
|
|
373
|
+
throw new FlowSpeechError("FlowSpeech job " + current.status + (current.error?.message ? ": " + this.clean(current.error.message) : ""), this.clean(current.error?.code) ?? "job_" + current.status);
|
|
374
|
+
}
|
|
375
|
+
if (current.status !== "success") fail("Job is not ready for download", "job_not_ready");
|
|
376
|
+
if (current.resultDeleted) fail("FlowSpeech result was deleted; no audio is available", "result_deleted");
|
|
377
|
+
if (!current.result) fail("Missing result");
|
|
378
|
+
return await this.request(audioUrl(current.result.url), { method: "GET" }, ctx, true);
|
|
379
|
+
}
|
|
380
|
+
async getJob(id, options = {}) {
|
|
381
|
+
return this.operation(options, async (ctx) => job((await this.api("/tts/jobs/" + ctx.jobId, ctx)).value, ctx), id);
|
|
382
|
+
}
|
|
383
|
+
async findJob(requestId, options = {}) {
|
|
384
|
+
return this.operation({ ...options, requestId }, async (ctx) => job((await this.api("/tts/jobs?requestId=" + ctx.requestId, ctx)).value, ctx));
|
|
385
|
+
}
|
|
386
|
+
async cancelJob(id, options = {}) {
|
|
387
|
+
return this.operation(options, async (ctx) => job((await this.api("/tts/jobs/" + ctx.jobId + "/cancel", ctx, void 0, "POST")).value, ctx), id);
|
|
388
|
+
}
|
|
389
|
+
/** Retries only the download of an existing successful job, never generation. */
|
|
390
|
+
async downloadJob(current, options = {}) {
|
|
391
|
+
return this.operation({ ...options, requestId: options.requestId ?? current.requestId }, async (ctx) => this.audio(job(current, ctx), ctx), current.id);
|
|
392
|
+
}
|
|
393
|
+
async getQuota(options = {}) {
|
|
394
|
+
return this.operation(options, async (ctx) => {
|
|
395
|
+
const { value } = await this.api("/quota", ctx);
|
|
396
|
+
if (!record(value) || !record(value.quota) || !record(value.limits) || !["remaining", "reserved", "available"].every((k) => number(value.quota[k]))) fail("Malformed quota response");
|
|
397
|
+
for (const field of ["entitlement", "subscription"]) {
|
|
398
|
+
if (value[field] !== void 0 && value[field] !== null && !record(value[field])) fail("Malformed subscription entitlement");
|
|
399
|
+
}
|
|
400
|
+
return value;
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
async listVoices(options = {}) {
|
|
404
|
+
return this.operation(options, async (ctx) => {
|
|
405
|
+
const { value } = await this.api("/voices", ctx);
|
|
406
|
+
if (!record(value) || !Array.isArray(value.voices) || !value.voices.every((v) => record(v) && typeof v.name === "string" && v.name.trim() && typeof v.description === "string" && typeof v.gender === "string")) fail("Malformed voices response");
|
|
407
|
+
return value.voices;
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
// src/flowspeech-provider.ts
|
|
413
|
+
import { NoSuchModelError } from "@ai-sdk/provider";
|
|
414
|
+
|
|
415
|
+
// src/flowspeech-speech-model.ts
|
|
416
|
+
var FlowSpeechSpeechModel = class {
|
|
417
|
+
constructor(modelId, config) {
|
|
418
|
+
this.modelId = modelId;
|
|
419
|
+
this.config = config;
|
|
420
|
+
if (modelId !== "flowspeech-tts") throw new FlowSpeechError("Unsupported FlowSpeech model identifier", "unsupported_option");
|
|
421
|
+
}
|
|
422
|
+
modelId;
|
|
423
|
+
config;
|
|
424
|
+
specificationVersion = "v4";
|
|
425
|
+
get provider() {
|
|
426
|
+
return this.config.provider;
|
|
427
|
+
}
|
|
428
|
+
async doGenerate(options) {
|
|
429
|
+
rejectUnsupported({ instructions: options.instructions, speed: options.speed, language: options.language }, []);
|
|
430
|
+
if (options.outputFormat !== void 0 && options.outputFormat !== "wav") throw new FlowSpeechError("FlowSpeech v1 only supports WAV output", "unsupported_option");
|
|
431
|
+
const providerOptions = options.providerOptions?.flowspeech ?? {};
|
|
432
|
+
rejectUnsupported(providerOptions, ["segments", "requestId"]);
|
|
433
|
+
if (providerOptions.segments !== void 0 && options.text.trim()) throw new FlowSpeechError("Set text to an empty string when supplying explicit segments", "invalid_request");
|
|
434
|
+
const input = providerOptions.segments === void 0 ? { mode: "single", text: options.text, voiceName: options.voice ?? this.config.defaultVoice } : { mode: "multi", segments: providerOptions.segments };
|
|
435
|
+
const headers = new Headers(this.config.headers);
|
|
436
|
+
for (const [name, value] of Object.entries(options.headers ?? {})) if (value !== void 0) headers.set(name, value);
|
|
437
|
+
const result = await new FlowSpeechClient({ ...this.config, headers: Object.fromEntries(headers) }).generate(input, {
|
|
438
|
+
signal: options.abortSignal,
|
|
439
|
+
requestId: providerOptions.requestId
|
|
440
|
+
});
|
|
441
|
+
return {
|
|
442
|
+
audio: result.audio,
|
|
443
|
+
warnings: [],
|
|
444
|
+
request: { body: result.requestBody },
|
|
445
|
+
response: { timestamp: this.config.currentDate?.() ?? /* @__PURE__ */ new Date(), modelId: this.modelId, headers: result.headers, body: result.job },
|
|
446
|
+
providerMetadata: { flowspeech: { requestId: result.requestId, jobId: result.job.id, usage: { ...result.job.usage } } }
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// src/flowspeech-provider.ts
|
|
452
|
+
function createFlowSpeech(settings = {}) {
|
|
453
|
+
const createSpeechModel = (modelId = "flowspeech-tts") => new FlowSpeechSpeechModel(modelId, {
|
|
454
|
+
...settings,
|
|
455
|
+
provider: "flowspeech.speech",
|
|
456
|
+
defaultVoice: settings.defaultVoice ?? "Kore"
|
|
457
|
+
});
|
|
458
|
+
const unsupported = (modelType, modelId) => {
|
|
459
|
+
throw new NoSuchModelError({ modelId, modelType, message: "FlowSpeech does not provide " + modelType + " models" });
|
|
460
|
+
};
|
|
461
|
+
return {
|
|
462
|
+
specificationVersion: "v4",
|
|
463
|
+
speech: createSpeechModel,
|
|
464
|
+
speechModel: createSpeechModel,
|
|
465
|
+
languageModel: (modelId) => unsupported("languageModel", modelId),
|
|
466
|
+
embeddingModel: (modelId) => unsupported("embeddingModel", modelId),
|
|
467
|
+
imageModel: (modelId) => unsupported("imageModel", modelId)
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
var flowSpeech = createFlowSpeech();
|
|
471
|
+
export {
|
|
472
|
+
FlowSpeechClient,
|
|
473
|
+
FlowSpeechError,
|
|
474
|
+
FlowSpeechSpeechModel,
|
|
475
|
+
createFlowSpeech,
|
|
476
|
+
flowSpeech,
|
|
477
|
+
normalizeApiOrigin,
|
|
478
|
+
rejectUnsupported
|
|
479
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-sdk-provider-flowspeech",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "FlowSpeech public API v1 asynchronous text-to-speech provider for the Vercel AI SDK.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"CHANGELOG.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"check:runtime": "node scripts/check-runtime.mjs",
|
|
24
|
+
"test:runtime": "node --test tests/runtime-guard.mjs",
|
|
25
|
+
"pretest": "npm run check:runtime",
|
|
26
|
+
"prebuild": "npm run check:runtime",
|
|
27
|
+
"pretypecheck": "npm run check:runtime",
|
|
28
|
+
"pretest:dist": "npm run check:runtime",
|
|
29
|
+
"preaudit:package": "npm run check:runtime",
|
|
30
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"prepublishOnly": "npm run check:runtime && npm run test:runtime && npm test && npm run typecheck && npm run build && npm run test:dist",
|
|
34
|
+
"test:dist": "node --test tests/artifact-smoke.mjs",
|
|
35
|
+
"prepack": "npm run build && npm run test:dist",
|
|
36
|
+
"audit:package": "node tests/package-audit.mjs"
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/waeckerlinfederowicz66-sketch/ai-sdk-provider-flowspeech.git"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://flowspeech.io/",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/waeckerlinfederowicz66-sketch/ai-sdk-provider-flowspeech/issues"
|
|
45
|
+
},
|
|
46
|
+
"keywords": [
|
|
47
|
+
"ai-sdk",
|
|
48
|
+
"vercel-ai-sdk",
|
|
49
|
+
"flowspeech",
|
|
50
|
+
"text-to-speech",
|
|
51
|
+
"tts",
|
|
52
|
+
"speech"
|
|
53
|
+
],
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=22"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@ai-sdk/provider": "^4.0.7",
|
|
59
|
+
"@ai-sdk/provider-utils": "^5.0.27"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"ai": "^7.0.68",
|
|
63
|
+
"tsup": "^8.5.0",
|
|
64
|
+
"typescript": "^5.9.3",
|
|
65
|
+
"vitest": "^3.2.4",
|
|
66
|
+
"@types/node": "24.13.3"
|
|
67
|
+
}
|
|
68
|
+
}
|