@neosapience/n8n-nodes-typecast 1.0.2 → 1.0.4

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.
@@ -1,10 +1,12 @@
1
1
  import {
2
- NodeConnectionTypes,
3
- type IExecuteFunctions,
4
- type INodeExecutionData,
5
- type INodeType,
6
- type INodeTypeDescription,
7
- type IDataObject,
2
+ NodeConnectionTypes,
3
+ type IExecuteFunctions,
4
+ type ILoadOptionsFunctions,
5
+ type INodeExecutionData,
6
+ type INodeListSearchResult,
7
+ type INodeType,
8
+ type INodeTypeDescription,
9
+ type IDataObject,
8
10
  } from 'n8n-workflow';
9
11
 
10
12
  import { typecastApiRequest, typecastApiRequestBinary } from './shared/transport';
@@ -13,197 +15,354 @@ import { voiceDescription } from './resources/voice';
13
15
  import { speechDescription } from './resources/speech';
14
16
 
15
17
  export class Typecast implements INodeType {
16
- description: INodeTypeDescription = {
17
- displayName: 'Typecast',
18
- name: 'typecast',
19
- icon: 'file:../../icons/typecast.svg',
20
- group: ['transform'],
21
- version: 1,
22
- subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
23
- description: 'Interact with Typecast TTS API',
24
- usableAsTool: true,
25
- defaults: {
26
- name: 'Typecast',
27
- },
28
- inputs: [NodeConnectionTypes.Main],
29
- outputs: [NodeConnectionTypes.Main],
30
- credentials: [
31
- {
32
- name: 'typecastApi',
33
- required: true,
34
- },
35
- ],
36
- properties: [
37
- {
38
- displayName: 'Resource',
39
- name: 'resource',
40
- type: 'options',
41
- noDataExpression: true,
42
- options: [
43
- {
44
- name: 'Speech',
45
- value: 'speech',
46
- },
47
- {
48
- name: 'Voice',
49
- value: 'voice',
50
- },
51
- ],
52
- default: 'speech',
53
- },
54
- ...voiceDescription,
55
- ...speechDescription,
56
- ],
57
- };
58
-
59
- async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
60
- const items = this.getInputData();
61
- const returnData: INodeExecutionData[] = [];
62
- const resource = this.getNodeParameter('resource', 0);
63
- const operation = this.getNodeParameter('operation', 0);
64
-
65
- for (let i = 0; i < items.length; i++) {
66
- try {
67
- if (resource === 'voice') {
68
- // ----------------------------------
69
- // voice:getMany
70
- // ----------------------------------
71
- if (operation === 'getMany') {
72
- const model = this.getNodeParameter('model', i) as string;
73
- const qs: IDataObject = {};
74
- if (model) {
75
- qs.model = model;
76
- }
77
- const response = await typecastApiRequest.call(this, 'GET', '/voices', {}, qs);
78
- returnData.push(...this.helpers.constructExecutionMetaData(
79
- this.helpers.returnJsonArray(response),
80
- { itemData: { item: i } },
81
- ));
82
- }
83
-
84
- // ----------------------------------
85
- // voice:get
86
- // ----------------------------------
87
- if (operation === 'get') {
88
- const voiceId = this.getNodeParameter('voiceId', i) as string;
89
- const model = this.getNodeParameter('model', i) as string;
90
- const qs: IDataObject = {};
91
- if (model) {
92
- qs.model = model;
93
- }
94
- const response = await typecastApiRequest.call(
95
- this,
96
- 'GET',
97
- `/voices/${voiceId}`,
98
- {},
99
- qs,
100
- );
101
- returnData.push(...this.helpers.constructExecutionMetaData(
102
- this.helpers.returnJsonArray(response),
103
- { itemData: { item: i } },
104
- ));
105
- }
106
- }
107
-
108
- if (resource === 'speech') {
109
- // ----------------------------------
110
- // speech:textToSpeech
111
- // ----------------------------------
112
- if (operation === 'textToSpeech') {
113
- const voiceId = this.getNodeParameter('voiceId', i) as string;
114
- const text = this.getNodeParameter('text', i) as string;
115
- const model = this.getNodeParameter('model', i) as string;
116
- const additionalOptions = this.getNodeParameter('additionalOptions', i, {}) as IDataObject;
117
-
118
- const body: IDataObject = {
119
- voice_id: voiceId,
120
- text,
121
- model,
122
- };
123
-
124
- // Add optional language parameter
125
- if (additionalOptions.language) {
126
- body.language = additionalOptions.language;
127
- }
128
-
129
- // Add emotion settings
130
- const prompt: IDataObject = {};
131
- if (additionalOptions.emotionPreset) {
132
- prompt.emotion_preset = additionalOptions.emotionPreset;
133
- }
134
- if (additionalOptions.emotionIntensity !== undefined) {
135
- prompt.emotion_intensity = additionalOptions.emotionIntensity;
136
- }
137
- if (Object.keys(prompt).length > 0) {
138
- body.prompt = prompt;
139
- }
140
-
141
- // Add output settings
142
- const output: IDataObject = {};
143
- if (additionalOptions.volume !== undefined) {
144
- output.volume = additionalOptions.volume;
145
- }
146
- if (additionalOptions.audioPitch !== undefined) {
147
- output.audio_pitch = additionalOptions.audioPitch;
148
- }
149
- if (additionalOptions.audioTempo !== undefined) {
150
- output.audio_tempo = additionalOptions.audioTempo;
151
- }
152
- if (additionalOptions.audioFormat) {
153
- output.audio_format = additionalOptions.audioFormat;
154
- }
155
- if (Object.keys(output).length > 0) {
156
- body.output = output;
157
- }
158
-
159
- // Add seed if provided
160
- if (additionalOptions.seed !== undefined) {
161
- body.seed = additionalOptions.seed;
162
- }
163
-
164
- const binaryProperty = additionalOptions.binaryProperty || 'data';
165
- const audioFormat = additionalOptions.audioFormat || 'wav';
166
- const mimeType = audioFormat === 'mp3' ? 'audio/mpeg' : 'audio/wav';
167
-
168
- const response = await typecastApiRequestBinary.call(
169
- this,
170
- 'POST',
171
- '/text-to-speech',
172
- body,
173
- );
174
-
175
- const newItem: INodeExecutionData = {
176
- json: {
177
- voice_id: voiceId,
178
- text,
179
- model,
180
- },
181
- binary: {
182
- [binaryProperty as string]: await this.helpers.prepareBinaryData(
183
- response,
184
- `audio.${audioFormat}`,
185
- mimeType,
186
- ),
187
- },
188
- };
189
-
190
- returnData.push(newItem);
191
- }
192
- }
193
- } catch (error) {
194
- if (this.continueOnFail()) {
195
- returnData.push({
196
- json: {
197
- error: (error as Error).message,
198
- },
199
- pairedItem: { item: i },
200
- });
201
- continue;
202
- }
203
- throw error;
204
- }
205
- }
206
-
207
- return [returnData];
208
- }
18
+ description: INodeTypeDescription = {
19
+ displayName: 'Typecast',
20
+ name: 'typecast',
21
+ icon: 'file:../../icons/typecast.svg',
22
+ group: ['transform'],
23
+ version: 1,
24
+ subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
25
+ description: 'Interact with Typecast TTS API',
26
+ documentationUrl: 'https://typecast.ai/docs/integrations/n8n',
27
+ usableAsTool: true,
28
+ defaults: {
29
+ name: 'Typecast',
30
+ },
31
+ inputs: [NodeConnectionTypes.Main],
32
+ outputs: [NodeConnectionTypes.Main],
33
+ credentials: [
34
+ {
35
+ name: 'typecastApi',
36
+ required: true,
37
+ },
38
+ ],
39
+ properties: [
40
+ {
41
+ displayName: 'Resource',
42
+ name: 'resource',
43
+ type: 'options',
44
+ noDataExpression: true,
45
+ options: [
46
+ {
47
+ name: 'Speech',
48
+ value: 'speech',
49
+ },
50
+ {
51
+ name: 'Voice',
52
+ value: 'voice',
53
+ },
54
+ ],
55
+ default: 'speech',
56
+ },
57
+ ...voiceDescription,
58
+ ...speechDescription,
59
+ ],
60
+ };
61
+
62
+ methods = {
63
+ listSearch: {
64
+ async searchVoices(
65
+ this: ILoadOptionsFunctions,
66
+ filter?: string,
67
+ ): Promise<INodeListSearchResult> {
68
+ const results: INodeListSearchResult = {
69
+ results: [],
70
+ };
71
+
72
+ try {
73
+ // Get the selected model to filter voices
74
+ const model = (this.getNodeParameter('model', 0) as string) || 'ssfm-v30';
75
+ const qs: IDataObject = {};
76
+
77
+ // Add model filter to query string
78
+ if (model) {
79
+ qs.model = model;
80
+ }
81
+
82
+ // Fetch voices from v2 API filtered by model
83
+ const response = await typecastApiRequest.call(this, 'GET', '/voices', {}, qs, 'v2');
84
+
85
+ // Process the response - it could be an array directly or wrapped in a result object
86
+ const voices = Array.isArray(response) ? response : response.result || [];
87
+
88
+ for (const voice of voices) {
89
+ const voiceId = voice.voice_id;
90
+ const voiceName = voice.voice_name || voiceId;
91
+ const gender = voice.gender
92
+ ? voice.gender.charAt(0).toUpperCase() + voice.gender.slice(1)
93
+ : 'Unknown';
94
+ const age = voice.age
95
+ ? voice.age.replace(/_/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase())
96
+ : 'Unknown';
97
+
98
+ // Get supported emotions from models array (prefer ssfm-v30)
99
+ let emotions: string[] = [];
100
+ if (voice.models && Array.isArray(voice.models)) {
101
+ // Try to find ssfm-v30 first, then fall back to first model
102
+ const v30Model = voice.models.find((m: IDataObject) => m.version === 'ssfm-v30');
103
+ const modelToUse = v30Model || voice.models[0];
104
+ if (modelToUse && Array.isArray(modelToUse.emotions)) {
105
+ emotions = modelToUse.emotions as string[];
106
+ }
107
+ }
108
+ // Format emotions for display
109
+ const emotionList = emotions.join(', ');
110
+ const emotionDisplay = emotionList || 'N/A';
111
+
112
+ // Get use cases
113
+ const useCases = voice.use_cases || [];
114
+ const useCaseList = Array.isArray(useCases) ? useCases.join(', ') : '';
115
+ const useCaseDisplay = useCaseList || 'N/A';
116
+
117
+ // Format: Name | Gender | Age | Emotions (all info in name for visibility)
118
+ const displayName = `${voiceName} | ${gender} | ${age} | ${emotionDisplay}`;
119
+ const description = `ID: ${voiceId} | Use Cases: ${useCaseDisplay}`;
120
+
121
+ // Apply filter if provided
122
+ if (filter) {
123
+ const searchLower = filter.toLowerCase();
124
+ const matchesName = voiceName.toLowerCase().includes(searchLower);
125
+ const matchesId = voiceId.toLowerCase().includes(searchLower);
126
+ const matchesGender = gender.toLowerCase().includes(searchLower);
127
+ const matchesAge = age.toLowerCase().includes(searchLower);
128
+ const matchesEmotion = emotions.some((e: string) =>
129
+ e.toLowerCase().includes(searchLower),
130
+ );
131
+ const matchesUseCase = useCases.some((u: string) =>
132
+ u.toLowerCase().includes(searchLower),
133
+ );
134
+
135
+ if (
136
+ !matchesName &&
137
+ !matchesId &&
138
+ !matchesGender &&
139
+ !matchesAge &&
140
+ !matchesEmotion &&
141
+ !matchesUseCase
142
+ ) {
143
+ continue;
144
+ }
145
+ }
146
+
147
+ results.results.push({
148
+ name: displayName,
149
+ value: voiceId,
150
+ url: `https://typecast.ai/developers/api/voices/${voiceId}`,
151
+ description,
152
+ });
153
+ }
154
+
155
+ // Sort by name
156
+ results.results.sort((a, b) => a.name.localeCompare(b.name));
157
+ } catch (error) {
158
+ // If API call fails, show helpful message - user can still use "By ID" mode
159
+ const errorMessage = (error as Error).message || 'Unknown error';
160
+ let hint = 'Check your Typecast API credentials';
161
+
162
+ if (errorMessage.includes('401') || errorMessage.includes('Unauthorized')) {
163
+ hint = 'Invalid API key - update your credentials';
164
+ } else if (errorMessage.includes('403') || errorMessage.includes('Forbidden')) {
165
+ hint = 'API key has no permission - check credentials';
166
+ } else if (errorMessage.includes('network') || errorMessage.includes('ENOTFOUND')) {
167
+ hint = 'Network error - check internet connection';
168
+ }
169
+
170
+ results.results = [
171
+ {
172
+ name: `⚠️ ${hint}`,
173
+ value: '',
174
+ description: 'Use "By ID" mode to enter Voice ID directly',
175
+ },
176
+ {
177
+ name: '💡 Switch to "By ID" Mode to Enter Voice ID Manually',
178
+ value: '',
179
+ description: 'Click the dropdown on the left and select "By ID"',
180
+ },
181
+ ];
182
+ }
183
+
184
+ return results;
185
+ },
186
+ },
187
+ };
188
+
189
+ async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
190
+ const items = this.getInputData();
191
+ const returnData: INodeExecutionData[] = [];
192
+ const resource = this.getNodeParameter('resource', 0);
193
+ const operation = this.getNodeParameter('operation', 0);
194
+
195
+ for (let i = 0; i < items.length; i++) {
196
+ try {
197
+ if (resource === 'voice') {
198
+ // ----------------------------------
199
+ // voice:getMany
200
+ // ----------------------------------
201
+ if (operation === 'getMany') {
202
+ const filters = this.getNodeParameter('filters', i, {}) as IDataObject;
203
+ const qs: IDataObject = {};
204
+
205
+ // Add filters to query string
206
+ if (filters.model) {
207
+ qs.model = filters.model;
208
+ }
209
+ if (filters.gender) {
210
+ qs.gender = filters.gender;
211
+ }
212
+ if (filters.age) {
213
+ qs.age = filters.age;
214
+ }
215
+ if (filters.use_cases) {
216
+ qs.use_cases = filters.use_cases;
217
+ }
218
+
219
+ const response = await typecastApiRequest.call(this, 'GET', '/voices', {}, qs, 'v2');
220
+ returnData.push(
221
+ ...this.helpers.constructExecutionMetaData(this.helpers.returnJsonArray(response), {
222
+ itemData: { item: i },
223
+ }),
224
+ );
225
+ }
226
+ }
227
+
228
+ if (resource === 'speech') {
229
+ // ----------------------------------
230
+ // speech:textToSpeech
231
+ // ----------------------------------
232
+ if (operation === 'textToSpeech') {
233
+ const voiceId = this.getNodeParameter('voiceId', i, '', {
234
+ extractValue: true,
235
+ }) as string;
236
+ const text = this.getNodeParameter('text', i) as string;
237
+ const model = this.getNodeParameter('model', i) as string;
238
+ const additionalOptions = this.getNodeParameter(
239
+ 'additionalOptions',
240
+ i,
241
+ {},
242
+ ) as IDataObject;
243
+
244
+ const body: IDataObject = {
245
+ voice_id: voiceId,
246
+ text,
247
+ model,
248
+ };
249
+
250
+ // Add optional language parameter
251
+ if (additionalOptions.language) {
252
+ body.language = additionalOptions.language;
253
+ }
254
+
255
+ // Build prompt object based on model and emotion type
256
+ const prompt: IDataObject = {};
257
+
258
+ if (model === 'ssfm-v30') {
259
+ // Get emotion type for ssfm-v30
260
+ const emotionType = this.getNodeParameter('emotionType', i, 'preset') as string;
261
+
262
+ if (emotionType === 'smart') {
263
+ // Smart Emotion: AI automatically infers emotion from context
264
+ prompt.emotion_type = 'smart';
265
+
266
+ const previousText = this.getNodeParameter('previousText', i, '') as string;
267
+ const nextText = this.getNodeParameter('nextText', i, '') as string;
268
+
269
+ if (previousText) {
270
+ prompt.previous_text = previousText;
271
+ }
272
+ if (nextText) {
273
+ prompt.next_text = nextText;
274
+ }
275
+ } else {
276
+ // Preset Emotion: Manual selection
277
+ prompt.emotion_type = 'preset';
278
+
279
+ const emotionPreset = this.getNodeParameter('emotionPreset', i, 'normal') as string;
280
+ const emotionIntensity = this.getNodeParameter('emotionIntensity', i, 1) as number;
281
+
282
+ prompt.emotion_preset = emotionPreset;
283
+ prompt.emotion_intensity = emotionIntensity;
284
+ }
285
+ } else {
286
+ // ssfm-v21: Use legacy prompt format (no emotion_type field)
287
+ // For v21, emotion settings are in additionalOptions
288
+ if (additionalOptions.emotionPresetV21) {
289
+ prompt.emotion_preset = additionalOptions.emotionPresetV21;
290
+ }
291
+ if (additionalOptions.emotionIntensityV21 !== undefined) {
292
+ prompt.emotion_intensity = additionalOptions.emotionIntensityV21;
293
+ }
294
+ }
295
+
296
+ if (Object.keys(prompt).length > 0) {
297
+ body.prompt = prompt;
298
+ }
299
+
300
+ // Add output settings
301
+ const output: IDataObject = {};
302
+ if (additionalOptions.volume !== undefined) {
303
+ output.volume = additionalOptions.volume;
304
+ }
305
+ if (additionalOptions.audioPitch !== undefined) {
306
+ output.audio_pitch = additionalOptions.audioPitch;
307
+ }
308
+ if (additionalOptions.audioTempo !== undefined) {
309
+ output.audio_tempo = additionalOptions.audioTempo;
310
+ }
311
+ if (additionalOptions.audioFormat) {
312
+ output.audio_format = additionalOptions.audioFormat;
313
+ }
314
+ if (Object.keys(output).length > 0) {
315
+ body.output = output;
316
+ }
317
+
318
+ // Add seed if provided
319
+ if (additionalOptions.seed !== undefined) {
320
+ body.seed = additionalOptions.seed;
321
+ }
322
+
323
+ const binaryProperty = additionalOptions.binaryProperty || 'data';
324
+ const audioFormat = additionalOptions.audioFormat || 'wav';
325
+ const mimeType = audioFormat === 'mp3' ? 'audio/mpeg' : 'audio/wav';
326
+
327
+ const response = await typecastApiRequestBinary.call(
328
+ this,
329
+ 'POST',
330
+ '/text-to-speech',
331
+ body,
332
+ );
333
+
334
+ const newItem: INodeExecutionData = {
335
+ json: {
336
+ voice_id: voiceId,
337
+ text,
338
+ model,
339
+ },
340
+ binary: {
341
+ [binaryProperty as string]: await this.helpers.prepareBinaryData(
342
+ response,
343
+ `audio.${audioFormat}`,
344
+ mimeType,
345
+ ),
346
+ },
347
+ };
348
+
349
+ returnData.push(newItem);
350
+ }
351
+ }
352
+ } catch (error) {
353
+ if (this.continueOnFail()) {
354
+ returnData.push({
355
+ json: {
356
+ error: (error as Error).message,
357
+ },
358
+ pairedItem: { item: i },
359
+ });
360
+ continue;
361
+ }
362
+ throw error;
363
+ }
364
+ }
365
+
366
+ return [returnData];
367
+ }
209
368
  }