@ai-sdk/fish-audio 0.0.0 → 2.0.1

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.
@@ -0,0 +1,126 @@
1
+ export type FishAudioSpeechFormat = 'wav' | 'pcm' | 'mp3' | 'opus';
2
+
3
+ export type FishAudioSpeechMp3Bitrate = 64 | 128 | 192;
4
+
5
+ export type FishAudioSpeechOpusBitrate = -1000 | 24000 | 32000 | 48000 | 64000;
6
+
7
+ export type FishAudioSpeechLatency = 'low' | 'normal' | 'balanced';
8
+
9
+ export type FishAudioProsodyControl = {
10
+ /**
11
+ * Speech rate multiplier, 0.5 to 2.0.
12
+ */
13
+ speed?: number;
14
+
15
+ /**
16
+ * Volume offset in dB.
17
+ */
18
+ volume?: number;
19
+
20
+ /**
21
+ * Loudness normalization. S2-Pro only.
22
+ */
23
+ normalize_loudness?: boolean;
24
+ };
25
+
26
+ /**
27
+ * Request body for `POST /v1/tts`. The model is selected via the `model` HTTP
28
+ * header rather than a body field.
29
+ *
30
+ * Inline `references` (zero-shot voice cloning) are intentionally omitted:
31
+ * they require a MessagePack request body, and this provider sends JSON only.
32
+ * Pre-upload reference audio and pass `reference_id` instead.
33
+ *
34
+ * https://docs.fish.audio/api-reference/endpoint/openapi-v1/text-to-speech
35
+ */
36
+ export type FishAudioSpeechAPITypes = {
37
+ /**
38
+ * The text to synthesize.
39
+ */
40
+ text: string;
41
+
42
+ /**
43
+ * Voice model ID, or an array of IDs for multi-speaker dialogue.
44
+ */
45
+ reference_id?: string | string[];
46
+
47
+ /**
48
+ * Speed and volume adjustments.
49
+ */
50
+ prosody?: FishAudioProsodyControl;
51
+
52
+ /**
53
+ * Output audio container format.
54
+ */
55
+ format?: FishAudioSpeechFormat;
56
+
57
+ /**
58
+ * Output sample rate in Hz. Falls back to the format default when unset.
59
+ */
60
+ sample_rate?: number;
61
+
62
+ /**
63
+ * Bitrate in kbps for mp3 output.
64
+ */
65
+ mp3_bitrate?: FishAudioSpeechMp3Bitrate;
66
+
67
+ /**
68
+ * Bitrate in bps for opus output. `-1000` selects automatic.
69
+ */
70
+ opus_bitrate?: FishAudioSpeechOpusBitrate;
71
+
72
+ /**
73
+ * Latency/quality tradeoff.
74
+ */
75
+ latency?: FishAudioSpeechLatency;
76
+
77
+ /**
78
+ * Expressiveness, 0 to 1.
79
+ */
80
+ temperature?: number;
81
+
82
+ /**
83
+ * Nucleus sampling, 0 to 1.
84
+ */
85
+ top_p?: number;
86
+
87
+ /**
88
+ * Text segment size for processing, 100 to 300.
89
+ */
90
+ chunk_length?: number;
91
+
92
+ /**
93
+ * Minimum characters before splitting into a new chunk, 0 to 100.
94
+ */
95
+ min_chunk_length?: number;
96
+
97
+ /**
98
+ * Text normalization for English and Chinese.
99
+ */
100
+ normalize?: boolean;
101
+
102
+ /**
103
+ * Maximum audio tokens to generate per text chunk.
104
+ */
105
+ max_new_tokens?: number;
106
+
107
+ /**
108
+ * Values above 1.0 discourage repeated audio patterns.
109
+ */
110
+ repetition_penalty?: number;
111
+
112
+ /**
113
+ * Reuse prior audio as context for voice consistency.
114
+ */
115
+ condition_on_previous_chunks?: boolean;
116
+
117
+ /**
118
+ * Early-stop threshold used in batch processing, 0 to 1.
119
+ */
120
+ early_stop_threshold?: number;
121
+
122
+ /**
123
+ * Request-scoped backend flags, e.g. `['quality-guard']`.
124
+ */
125
+ features?: string[];
126
+ };
@@ -0,0 +1,115 @@
1
+ import { z } from 'zod/v4';
2
+
3
+ // https://docs.fish.audio/api-reference/endpoint/openapi-v1/text-to-speech
4
+ export const fishAudioSpeechModelOptionsSchema = z.object({
5
+ /**
6
+ * Voice model ID(s). A single ID selects one speaker; an array enables
7
+ * multi-speaker dialogue (S2-Pro models), in which case the text must mark
8
+ * turns with speaker tokens such as `<|speaker:0|>`. The speaker index maps
9
+ * to the position in this array.
10
+ *
11
+ * Takes precedence over the top-level `voice` option.
12
+ */
13
+ referenceId: z.union([z.string(), z.array(z.string())]).optional(),
14
+
15
+ /**
16
+ * Output sample rate in Hz. Falls back to the format default when unset
17
+ * (44100 Hz for wav/pcm/mp3, 48000 Hz for opus).
18
+ */
19
+ sampleRate: z.number().int().positive().optional(),
20
+
21
+ /**
22
+ * Bitrate in kbps for mp3 output. Ignored for other formats.
23
+ */
24
+ mp3Bitrate: z
25
+ .union([z.literal(64), z.literal(128), z.literal(192)])
26
+ .optional(),
27
+
28
+ /**
29
+ * Bitrate in bps for opus output, where `-1000` selects automatic. Ignored
30
+ * for other formats.
31
+ */
32
+ opusBitrate: z
33
+ .union([
34
+ z.literal(-1000),
35
+ z.literal(24_000),
36
+ z.literal(32_000),
37
+ z.literal(48_000),
38
+ z.literal(64_000),
39
+ ])
40
+ .optional(),
41
+
42
+ /**
43
+ * Latency/quality tradeoff. `normal` gives the best quality, `balanced`
44
+ * reduces latency, and `low` is the fastest.
45
+ */
46
+ latency: z.enum(['low', 'normal', 'balanced']).optional(),
47
+
48
+ /**
49
+ * Volume offset in dB. Negative values are quieter.
50
+ */
51
+ volume: z.number().optional(),
52
+
53
+ /**
54
+ * Loudness normalization. Supported by the S2 family (`s2-pro` and
55
+ * `s2.1-pro`). Fish Audio accepts it on `s1` but ignores it, so the provider
56
+ * emits a warning in that case.
57
+ */
58
+ normalizeLoudness: z.boolean().optional(),
59
+
60
+ /**
61
+ * Governs expressiveness. Higher values are more varied, lower values more
62
+ * consistent.
63
+ */
64
+ temperature: z.number().min(0).max(1).optional(),
65
+
66
+ /**
67
+ * Controls diversity via nucleus sampling.
68
+ */
69
+ topP: z.number().min(0).max(1).optional(),
70
+
71
+ /**
72
+ * Text segment size for processing.
73
+ */
74
+ chunkLength: z.number().int().min(100).max(300).optional(),
75
+
76
+ /**
77
+ * Minimum characters before splitting into a new chunk.
78
+ */
79
+ minChunkLength: z.number().int().min(0).max(100).optional(),
80
+
81
+ /**
82
+ * Text normalization for English and Chinese. Helps stability with numbers.
83
+ */
84
+ normalize: z.boolean().optional(),
85
+
86
+ /**
87
+ * Maximum audio tokens to generate per text chunk.
88
+ */
89
+ maxNewTokens: z.number().int().positive().optional(),
90
+
91
+ /**
92
+ * Values above 1.0 discourage repeated audio patterns.
93
+ */
94
+ repetitionPenalty: z.number().optional(),
95
+
96
+ /**
97
+ * Reuse prior audio as context for voice consistency across chunks.
98
+ */
99
+ conditionOnPreviousChunks: z.boolean().optional(),
100
+
101
+ /**
102
+ * Early-stop threshold used in batch processing.
103
+ */
104
+ earlyStopThreshold: z.number().min(0).max(1).optional(),
105
+
106
+ /**
107
+ * Request-scoped flags passed through to the inference backend, e.g.
108
+ * `['quality-guard']`.
109
+ */
110
+ features: z.array(z.string()).optional(),
111
+ });
112
+
113
+ export type FishAudioSpeechModelOptions = z.infer<
114
+ typeof fishAudioSpeechModelOptionsSchema
115
+ >;
@@ -0,0 +1,280 @@
1
+ import type { SharedV3Warning, SpeechModelV3 } from '@ai-sdk/provider';
2
+ import {
3
+ combineHeaders,
4
+ createBinaryResponseHandler,
5
+ parseProviderOptions,
6
+ postJsonToApi,
7
+ } from '@ai-sdk/provider-utils';
8
+ import type { FishAudioConfig } from './fish-audio-config';
9
+ import { fishAudioFailedResponseHandler } from './fish-audio-error';
10
+ import type {
11
+ FishAudioSpeechAPITypes,
12
+ FishAudioSpeechFormat,
13
+ FishAudioProsodyControl,
14
+ } from './fish-audio-speech-api-types';
15
+ import { fishAudioSpeechModelOptionsSchema } from './fish-audio-speech-model-options';
16
+ import type { FishAudioSpeechModelId } from './fish-audio-speech-options';
17
+
18
+ interface FishAudioSpeechModelConfig extends FishAudioConfig {
19
+ _internal?: {
20
+ currentDate?: () => Date;
21
+ };
22
+ }
23
+
24
+ const SUPPORTED_FORMATS: FishAudioSpeechFormat[] = [
25
+ 'wav',
26
+ 'pcm',
27
+ 'mp3',
28
+ 'opus',
29
+ ];
30
+
31
+ const DEFAULT_FORMAT: FishAudioSpeechFormat = 'mp3';
32
+
33
+ // Fish Audio accepts `prosody.speed` between 0.5 and 2.0.
34
+ const MIN_SPEED = 0.5;
35
+ const MAX_SPEED = 2;
36
+
37
+ function resolveFormat({
38
+ outputFormat,
39
+ warnings,
40
+ }: {
41
+ outputFormat: string | undefined;
42
+ warnings: SharedV3Warning[];
43
+ }): FishAudioSpeechFormat {
44
+ if (outputFormat == null) {
45
+ return DEFAULT_FORMAT;
46
+ }
47
+
48
+ const normalized = outputFormat.toLowerCase();
49
+ const matched = SUPPORTED_FORMATS.find(format => format === normalized);
50
+
51
+ if (matched == null) {
52
+ warnings.push({
53
+ type: 'unsupported',
54
+ feature: 'outputFormat',
55
+ details: `Fish Audio does not support the output format "${outputFormat}". Falling back to ${DEFAULT_FORMAT}. Supported formats are ${SUPPORTED_FORMATS.join(', ')}.`,
56
+ });
57
+ return DEFAULT_FORMAT;
58
+ }
59
+
60
+ return matched;
61
+ }
62
+
63
+ export class FishAudioSpeechModel implements SpeechModelV3 {
64
+ readonly specificationVersion = 'v3';
65
+
66
+ get provider(): string {
67
+ return this.config.provider;
68
+ }
69
+
70
+ constructor(
71
+ readonly modelId: FishAudioSpeechModelId,
72
+ private readonly config: FishAudioSpeechModelConfig,
73
+ ) {}
74
+
75
+ private async getArgs({
76
+ text,
77
+ voice,
78
+ outputFormat,
79
+ instructions,
80
+ language,
81
+ speed,
82
+ providerOptions,
83
+ }: Parameters<SpeechModelV3['doGenerate']>[0]) {
84
+ const warnings: SharedV3Warning[] = [];
85
+
86
+ const fishAudioOptions = await parseProviderOptions({
87
+ provider: 'fishAudio',
88
+ providerOptions,
89
+ schema: fishAudioSpeechModelOptionsSchema,
90
+ });
91
+
92
+ const format = resolveFormat({ outputFormat, warnings });
93
+
94
+ const requestBody: FishAudioSpeechAPITypes = {
95
+ text,
96
+ format,
97
+ };
98
+
99
+ // `providerOptions.fishAudio.referenceId` wins over the generic `voice` so
100
+ // that multi-speaker arrays are expressible.
101
+ const referenceId = fishAudioOptions?.referenceId ?? voice;
102
+ if (referenceId != null) {
103
+ requestBody.reference_id = referenceId;
104
+ }
105
+
106
+ const prosody: FishAudioProsodyControl = {};
107
+
108
+ if (speed != null) {
109
+ if (speed >= MIN_SPEED && speed <= MAX_SPEED) {
110
+ prosody.speed = speed;
111
+ } else {
112
+ warnings.push({
113
+ type: 'unsupported',
114
+ feature: 'speed',
115
+ details: `Fish Audio speed must be between ${MIN_SPEED} and ${MAX_SPEED}. The speed option was ignored.`,
116
+ });
117
+ }
118
+ }
119
+
120
+ if (fishAudioOptions?.volume != null) {
121
+ prosody.volume = fishAudioOptions.volume;
122
+ }
123
+
124
+ if (fishAudioOptions?.normalizeLoudness != null) {
125
+ // Fish Audio accepts `normalize_loudness` on s1 but ignores it, so warn
126
+ // rather than let it silently do nothing. Only s1 is known to ignore it;
127
+ // unrecognized model IDs are left alone.
128
+ if (this.modelId === 's1') {
129
+ warnings.push({
130
+ type: 'unsupported',
131
+ feature: 'providerOptions.fishAudio.normalizeLoudness',
132
+ details:
133
+ 'Fish Audio ignores normalizeLoudness on s1. It is supported by the S2 family (s2-pro, s2.1-pro).',
134
+ });
135
+ } else {
136
+ prosody.normalize_loudness = fishAudioOptions.normalizeLoudness;
137
+ }
138
+ }
139
+
140
+ if (Object.keys(prosody).length > 0) {
141
+ requestBody.prosody = prosody;
142
+ }
143
+
144
+ if (language != null) {
145
+ warnings.push({
146
+ type: 'unsupported',
147
+ feature: 'language',
148
+ details:
149
+ 'Fish Audio infers the language from the input text and the selected voice, and has no language parameter. The language option was ignored.',
150
+ });
151
+ }
152
+
153
+ if (instructions != null) {
154
+ warnings.push({
155
+ type: 'unsupported',
156
+ feature: 'instructions',
157
+ details:
158
+ 'Fish Audio does not support instructions. The instructions option was ignored.',
159
+ });
160
+ }
161
+
162
+ if (fishAudioOptions != null) {
163
+ if (fishAudioOptions.sampleRate != null) {
164
+ requestBody.sample_rate = fishAudioOptions.sampleRate;
165
+ }
166
+
167
+ if (fishAudioOptions.mp3Bitrate != null) {
168
+ if (format === 'mp3') {
169
+ requestBody.mp3_bitrate = fishAudioOptions.mp3Bitrate;
170
+ } else {
171
+ warnings.push({
172
+ type: 'unsupported',
173
+ feature: 'providerOptions.fishAudio.mp3Bitrate',
174
+ details: `mp3Bitrate only applies to mp3 output. The option was ignored for ${format} output.`,
175
+ });
176
+ }
177
+ }
178
+
179
+ if (fishAudioOptions.opusBitrate != null) {
180
+ if (format === 'opus') {
181
+ requestBody.opus_bitrate = fishAudioOptions.opusBitrate;
182
+ } else {
183
+ warnings.push({
184
+ type: 'unsupported',
185
+ feature: 'providerOptions.fishAudio.opusBitrate',
186
+ details: `opusBitrate only applies to opus output. The option was ignored for ${format} output.`,
187
+ });
188
+ }
189
+ }
190
+
191
+ if (fishAudioOptions.latency != null) {
192
+ requestBody.latency = fishAudioOptions.latency;
193
+ }
194
+
195
+ if (fishAudioOptions.temperature != null) {
196
+ requestBody.temperature = fishAudioOptions.temperature;
197
+ }
198
+
199
+ if (fishAudioOptions.topP != null) {
200
+ requestBody.top_p = fishAudioOptions.topP;
201
+ }
202
+
203
+ if (fishAudioOptions.chunkLength != null) {
204
+ requestBody.chunk_length = fishAudioOptions.chunkLength;
205
+ }
206
+
207
+ if (fishAudioOptions.minChunkLength != null) {
208
+ requestBody.min_chunk_length = fishAudioOptions.minChunkLength;
209
+ }
210
+
211
+ if (fishAudioOptions.normalize != null) {
212
+ requestBody.normalize = fishAudioOptions.normalize;
213
+ }
214
+
215
+ if (fishAudioOptions.maxNewTokens != null) {
216
+ requestBody.max_new_tokens = fishAudioOptions.maxNewTokens;
217
+ }
218
+
219
+ if (fishAudioOptions.repetitionPenalty != null) {
220
+ requestBody.repetition_penalty = fishAudioOptions.repetitionPenalty;
221
+ }
222
+
223
+ if (fishAudioOptions.conditionOnPreviousChunks != null) {
224
+ requestBody.condition_on_previous_chunks =
225
+ fishAudioOptions.conditionOnPreviousChunks;
226
+ }
227
+
228
+ if (fishAudioOptions.earlyStopThreshold != null) {
229
+ requestBody.early_stop_threshold = fishAudioOptions.earlyStopThreshold;
230
+ }
231
+
232
+ if (fishAudioOptions.features != null) {
233
+ requestBody.features = fishAudioOptions.features;
234
+ }
235
+ }
236
+
237
+ return { requestBody, warnings };
238
+ }
239
+
240
+ async doGenerate(
241
+ options: Parameters<SpeechModelV3['doGenerate']>[0],
242
+ ): Promise<Awaited<ReturnType<SpeechModelV3['doGenerate']>>> {
243
+ const currentDate = this.config._internal?.currentDate?.() ?? new Date();
244
+ const { requestBody, warnings } = await this.getArgs(options);
245
+
246
+ const {
247
+ value: audio,
248
+ responseHeaders,
249
+ rawValue: rawResponse,
250
+ } = await postJsonToApi({
251
+ url: this.config.url({ path: '/v1/tts', modelId: this.modelId }),
252
+ // Fish Audio selects the TTS model with a `model` HTTP header rather
253
+ // than a request body field.
254
+ headers: combineHeaders(
255
+ this.config.headers?.(),
256
+ { model: this.modelId },
257
+ options.headers,
258
+ ),
259
+ body: requestBody,
260
+ failedResponseHandler: fishAudioFailedResponseHandler,
261
+ successfulResponseHandler: createBinaryResponseHandler(),
262
+ abortSignal: options.abortSignal,
263
+ fetch: this.config.fetch,
264
+ });
265
+
266
+ return {
267
+ audio,
268
+ warnings,
269
+ request: {
270
+ body: JSON.stringify(requestBody),
271
+ },
272
+ response: {
273
+ timestamp: currentDate,
274
+ modelId: this.modelId,
275
+ headers: responseHeaders,
276
+ body: rawResponse,
277
+ },
278
+ };
279
+ }
280
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Fish Audio TTS model IDs, sent via the `model` HTTP header.
3
+ *
4
+ * `s2.1-pro` is the model Fish Audio recommends by default. `s2.1-pro-free` is
5
+ * a free developer tier with no time-to-first-audio or data-processing
6
+ * guarantees, so prefer `s2.1-pro` for production use.
7
+ *
8
+ * https://docs.fish.audio/api-reference/endpoint/openapi-v1/text-to-speech
9
+ */
10
+ export type FishAudioSpeechModelId =
11
+ | 's1'
12
+ | 's2-pro'
13
+ | 's2.1-pro'
14
+ | 's2.1-pro-free'
15
+ | (string & {});
16
+
17
+ /**
18
+ * A Fish Audio voice model ID (`reference_id`), either from the Fish Audio
19
+ * voice library or one of your own uploaded models.
20
+ */
21
+ export type FishAudioSpeechVoiceId = string;
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod/v4';
2
+
3
+ // https://docs.fish.audio/api-reference/endpoint/openapi-v1/speech-to-text
4
+ export const fishAudioTranscriptionModelOptionsSchema = z.object({
5
+ /**
6
+ * Language of the audio.
7
+ *
8
+ * A hint only. Fish Audio passes it to the model, but auto-detection is
9
+ * authoritative and overrides it, so this changes neither the transcript nor
10
+ * the reported language. The detected language is reported as
11
+ * `result.language`.
12
+ */
13
+ language: z.string().optional(),
14
+
15
+ /**
16
+ * Whether to skip precise timestamps. Mirrors the Fish Audio
17
+ * `ignore_timestamps` parameter, whose API default is `true`.
18
+ *
19
+ * This provider defaults it to `false` so that `result.segments` is
20
+ * populated. Fish Audio documents an added latency cost for audio shorter
21
+ * than 30 seconds; set this to `true` to trade segments for that latency.
22
+ */
23
+ ignoreTimestamps: z.boolean().optional(),
24
+ });
25
+
26
+ export type FishAudioTranscriptionModelOptions = z.infer<
27
+ typeof fishAudioTranscriptionModelOptionsSchema
28
+ >;