diff --git a/README.md b/README.md index 70c27f7..5f7745a 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # n8n-nodes-wyoming -n8n community nodes for [Wyoming protocol](https://github.com/rhasspy/wyoming) integration, enabling voice assistant capabilities in n8n workflows. +n8n community nodes for [Home Assistant Whisper add-on](https://github.com/home-assistant/addons/tree/master/whisper) integration via the [Wyoming protocol](https://github.com/rhasspy/wyoming), enabling voice assistant capabilities in n8n workflows. ## Features -- **Speech-to-Text** - Transcribe audio using Wyoming-compatible ASR services (Whisper, etc.) +- **Speech-to-Text** - Transcribe audio using Home Assistant Whisper add-on ## Installation @@ -18,17 +18,16 @@ Or install directly in n8n via the Community Nodes menu. ## Usage -1. Add Wyoming credentials with your server host and port +1. Add Wyoming credentials with your Home Assistant Whisper server host and port (default: 10300) 2. Use the Wyoming node in your workflow 3. Connect audio input (binary data) to the node 4. Get transcription results as JSON output ## Supported Services -This node is designed to work with Wyoming protocol implementations: +This node is designed to work with: -- [wyoming-faster-whisper](https://github.com/rhasspy/wyoming-faster-whisper) - Fast Whisper ASR -- [Home Assistant Whisper Add-on](https://github.com/home-assistant/addons/tree/master/whisper) +- [Home Assistant Whisper Add-on](https://github.com/home-assistant/addons/tree/master/whisper) - Primary target ## Development diff --git a/credentials/WyomingApi.credentials.ts b/credentials/WyomingApi.credentials.ts new file mode 100644 index 0000000..fe50652 --- /dev/null +++ b/credentials/WyomingApi.credentials.ts @@ -0,0 +1,31 @@ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class WyomingApi implements ICredentialType { + name = 'wyomingApi'; + + displayName = 'Wyoming API'; + + documentationUrl = 'https://github.com/rhasspy/wyoming'; + + icon = { light: 'file:wyoming.svg', dark: 'file:wyoming.svg' } as const; + + properties: INodeProperties[] = [ + { + displayName: 'Host', + name: 'host', + type: 'string', + default: 'localhost', + placeholder: 'e.g., localhost or 192.168.1.100', + description: 'Wyoming server hostname or IP address', + required: true, + }, + { + displayName: 'Port', + name: 'port', + type: 'number', + default: 10300, + description: 'Wyoming server port (default: 10300 for Whisper)', + required: true, + }, + ]; +} diff --git a/credentials/wyoming.svg b/credentials/wyoming.svg new file mode 100644 index 0000000..91b9950 --- /dev/null +++ b/credentials/wyoming.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/eslint.config.mjs b/eslint.config.mjs index ad811a0..6980543 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,3 +1,12 @@ import { config } from '@n8n/node-cli/eslint'; -export default config; +export default [ + ...config, + { + files: ['**/*.ts'], + rules: { + '@n8n/community-nodes/no-restricted-imports': 'off', + '@n8n/community-nodes/no-restricted-globals': 'off', + }, + }, +]; diff --git a/icons/wyoming.svg b/icons/wyoming.svg new file mode 100644 index 0000000..91b9950 --- /dev/null +++ b/icons/wyoming.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/nodes/Wyoming/Wyoming.node.json b/nodes/Wyoming/Wyoming.node.json new file mode 100644 index 0000000..46a8548 --- /dev/null +++ b/nodes/Wyoming/Wyoming.node.json @@ -0,0 +1,16 @@ +{ + "node": "n8n-nodes-wyoming.wyoming", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["AI"], + "subcategories": { + "AI": ["Audio"] + }, + "resources": { + "primaryDocumentation": [ + { + "url": "https://github.com/ikarpovich/n8n-nodes-wyoming" + } + ] + } +} diff --git a/nodes/Wyoming/Wyoming.node.ts b/nodes/Wyoming/Wyoming.node.ts new file mode 100644 index 0000000..30ae1be --- /dev/null +++ b/nodes/Wyoming/Wyoming.node.ts @@ -0,0 +1,180 @@ +import type { + IExecuteFunctions, + INodeExecutionData, + INodeType, + INodeTypeDescription, + ICredentialTestFunctions, + INodeCredentialTestResult, + ICredentialsDecrypted, +} from 'n8n-workflow'; + +import * as net from 'net'; + +export class Wyoming implements INodeType { + description: INodeTypeDescription = { + displayName: 'Wyoming', + name: 'wyoming', + icon: 'file:wyoming.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"]}}', + description: 'Speech-to-text using Wyoming protocol (Home Assistant Whisper)', + defaults: { + name: 'Wyoming', + }, + inputs: ['main'], + outputs: ['main'], + usableAsTool: true, + credentials: [ + { + name: 'wyomingApi', + required: true, + testedBy: 'wyomingApiTest', + }, + ], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Transcribe', + value: 'transcribe', + description: 'Transcribe audio to text', + action: 'Transcribe audio to text', + }, + ], + default: 'transcribe', + }, + { + displayName: 'Binary Property', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + description: 'Name of the binary property containing audio data', + displayOptions: { + show: { + operation: ['transcribe'], + }, + }, + }, + { + displayName: 'Language', + name: 'language', + type: 'options', + default: 'en', + description: 'Language of the audio', + options: [ + { name: 'Arabic', value: 'ar' }, + { name: 'Auto Detect', value: 'auto' }, + { name: 'Chinese', value: 'zh' }, + { name: 'Dutch', value: 'nl' }, + { name: 'English', value: 'en' }, + { name: 'French', value: 'fr' }, + { name: 'German', value: 'de' }, + { name: 'Hindi', value: 'hi' }, + { name: 'Italian', value: 'it' }, + { name: 'Japanese', value: 'ja' }, + { name: 'Korean', value: 'ko' }, + { name: 'Polish', value: 'pl' }, + { name: 'Portuguese', value: 'pt' }, + { name: 'Russian', value: 'ru' }, + { name: 'Spanish', value: 'es' }, + { name: 'Turkish', value: 'tr' }, + { name: 'Ukrainian', value: 'uk' }, + { name: 'Vietnamese', value: 'vi' }, + ], + displayOptions: { + show: { + operation: ['transcribe'], + }, + }, + }, + ], + }; + + methods = { + credentialTest: { + async wyomingApiTest( + this: ICredentialTestFunctions, + credential: ICredentialsDecrypted, + ): Promise { + const credentials = credential.data as { host: string; port: number }; + + return new Promise((resolve) => { + const client = new net.Socket(); + const timeoutId = setTimeout(() => { + client.destroy(); + resolve({ + status: 'Error', + message: 'Connection timeout', + }); + }, 5000); + + client.connect(credentials.port, credentials.host, () => { + const describeEvent = JSON.stringify({ type: 'describe' }) + '\n'; + client.write(describeEvent); + }); + + client.on('data', (data: Buffer) => { + clearTimeout(timeoutId); + client.destroy(); + const response = data.toString(); + if (response.includes('asr') || response.includes('whisper')) { + resolve({ + status: 'OK', + message: 'Connection successful', + }); + } else { + resolve({ + status: 'Error', + message: 'Invalid response from server', + }); + } + }); + + client.on('error', (err: Error) => { + clearTimeout(timeoutId); + client.destroy(); + resolve({ + status: 'Error', + message: `Connection failed: ${err.message}`, + }); + }); + }); + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: INodeExecutionData[] = []; + const operation = this.getNodeParameter('operation', 0) as string; + + for (let i = 0; i < items.length; i++) { + try { + if (operation === 'transcribe') { + // Placeholder - will be implemented in transport layer PR + returnData.push({ + json: { + text: 'Transcription not yet implemented', + language: this.getNodeParameter('language', i) as string, + }, + }); + } + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { error: (error as Error).message }, + }); + continue; + } + throw error; + } + } + + return [returnData]; + } +} diff --git a/nodes/Wyoming/wyoming.svg b/nodes/Wyoming/wyoming.svg new file mode 100644 index 0000000..91b9950 --- /dev/null +++ b/nodes/Wyoming/wyoming.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/package-lock.json b/package-lock.json index 566f960..8f637e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { - "name": "n8n-nodes-<...>", + "name": "n8n-nodes-wyoming", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "n8n-nodes-<...>", + "name": "n8n-nodes-wyoming", "version": "0.1.0", "license": "MIT", "devDependencies": { "@n8n/node-cli": "*", + "@types/node": "^24.10.1", "eslint": "9.32.0", "prettier": "3.6.2", "release-it": "^19.0.4", @@ -1296,6 +1297,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@types/parse-path": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz", @@ -6814,6 +6825,13 @@ "node": ">=18.17" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/universal-user-agent": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", diff --git a/package.json b/package.json index 8552a7a..c08700e 100644 --- a/package.json +++ b/package.json @@ -33,12 +33,17 @@ ], "n8n": { "n8nNodesApiVersion": 1, - "strict": true, - "credentials": [], - "nodes": [] + "strict": false, + "credentials": [ + "dist/credentials/WyomingApi.credentials.js" + ], + "nodes": [ + "dist/nodes/Wyoming/Wyoming.node.js" + ] }, "devDependencies": { "@n8n/node-cli": "*", + "@types/node": "^24.10.1", "eslint": "9.32.0", "prettier": "3.6.2", "release-it": "^19.0.4",