iobroker.assistant 1.4.5 → 1.4.7

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,137 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.FFMPEG_STATIC_RELEASE = void 0;
37
+ exports.ffmpegStaticAsset = ffmpegStaticAsset;
38
+ exports.ffmpegStaticPath = ffmpegStaticPath;
39
+ exports.downloadFfmpegStatic = downloadFfmpegStatic;
40
+ /**
41
+ * A static ffmpeg for hosts without one: the binaries of ffmpeg-static (github.com/eugeneware/ffmpeg-static),
42
+ * downloaded on request ("Download ffmpeg" on the Voice tab) into the instance data dir.
43
+ *
44
+ * Deliberately not the npm package: its install script downloads 20-30 MB for every installation of the adapter,
45
+ * also where nobody uses voice, and newer npm versions no longer run install scripts without asking - then the
46
+ * binary is silently missing. The release and the SHA-256 of every file are pinned here, so a replaced file is
47
+ * refused instead of executed.
48
+ */
49
+ const node_crypto_1 = require("node:crypto");
50
+ const fs = __importStar(require("node:fs"));
51
+ const path = __importStar(require("node:path"));
52
+ const node_stream_1 = require("node:stream");
53
+ const promises_1 = require("node:stream/promises");
54
+ const node_zlib_1 = require("node:zlib");
55
+ /** The ffmpeg-static release. The binaries are newer than its name (Linux arm64: ffmpeg 7.0.2). */
56
+ exports.FFMPEG_STATIC_RELEASE = 'b6.1.1';
57
+ const BASE_URL = `https://github.com/eugeneware/ffmpeg-static/releases/download/${exports.FFMPEG_STATIC_RELEASE}`;
58
+ /** SHA-256 of the gzipped binaries, as GitHub reports them for the release. Key = `<platform>-<arch>` of Node. */
59
+ const ASSETS = {
60
+ 'darwin-arm64': '8923876afa8db5585022d7860ec7e589af192f441c56793971276d450ed3bbfa',
61
+ 'darwin-x64': '929b375c1182d956c51f7ac25e0b2b0411fb01f6f407aa15c9758efeb4242106',
62
+ 'linux-arm': '64b115a12f0ab77c277e3c418aae8b40ef881e75e746a0e2d066a206b9bc5172',
63
+ 'linux-arm64': '754a678672298bc68156adff58aa7385a592c2b30b1d0ae8750c45c915c4bac0',
64
+ 'linux-ia32': '169b27c078a8ecedb814cac67afccf15a9868d63e9d74ef86088adefaa500d00',
65
+ 'linux-x64': 'bfe8a8fc511530457b528c48d77b5737527b504a3797a9bc4866aeca69c2dffa',
66
+ 'win32-x64': '8883a3dffbd0a16cf4ef95206ea05283f78908dbfb118f73c83f4951dcc06d77',
67
+ };
68
+ /**
69
+ * The file for this machine, or `null` if ffmpeg-static has none (e.g. Windows on ARM, FreeBSD).
70
+ *
71
+ * @param platform Node's `process.platform`
72
+ * @param arch Node's `process.arch`
73
+ */
74
+ function ffmpegStaticAsset(platform = process.platform, arch = process.arch) {
75
+ const key = `${platform}-${arch}`;
76
+ return ASSETS[key] ? { name: `ffmpeg-${key}.gz`, sha256: ASSETS[key] } : null;
77
+ }
78
+ /**
79
+ * Where the downloaded binary lives.
80
+ *
81
+ * @param dataDir the instance data dir
82
+ * @param platform Node's `process.platform`
83
+ */
84
+ function ffmpegStaticPath(dataDir, platform = process.platform) {
85
+ return path.join(dataDir, 'ffmpeg', platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg');
86
+ }
87
+ /**
88
+ * Download, check and unpack the static ffmpeg into `<dataDir>/ffmpeg/`. Replaces an older download.
89
+ *
90
+ * @param dataDir the instance data dir
91
+ * @param options logger; the rest is for tests
92
+ * @returns the path of the binary
93
+ */
94
+ async function downloadFfmpegStatic(dataDir, options = {}) {
95
+ const platform = options.platform ?? process.platform;
96
+ const asset = options.asset === undefined ? ffmpegStaticAsset(platform) : options.asset;
97
+ if (!asset) {
98
+ throw new Error(`there is no ffmpeg download for ${process.platform}-${process.arch} - install ffmpeg yourself and enter its path`);
99
+ }
100
+ const url = `${options.baseUrl ?? BASE_URL}/${asset.name}`;
101
+ const dest = ffmpegStaticPath(dataDir, platform);
102
+ const tmp = `${dest}.part`;
103
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
104
+ const res = await fetch(url, { signal: AbortSignal.timeout(options.timeoutMs ?? 10 * 60_000) });
105
+ if (!res.ok || !res.body) {
106
+ throw new Error(`download of ${url} failed: HTTP ${res.status}`);
107
+ }
108
+ const size = Number(res.headers.get('content-length')) || 0;
109
+ options.log?.info(`Downloading ffmpeg (${asset.name}${size ? `, ${(size / 1e6).toFixed(1)} MB` : ''}) …`);
110
+ // The hash is taken over the gzipped bytes - that is what GitHub publishes.
111
+ const hash = (0, node_crypto_1.createHash)('sha256');
112
+ const tap = new node_stream_1.Transform({
113
+ transform(chunk, _encoding, done) {
114
+ hash.update(chunk);
115
+ done(null, chunk);
116
+ },
117
+ });
118
+ try {
119
+ await (0, promises_1.pipeline)(node_stream_1.Readable.fromWeb(res.body), tap, (0, node_zlib_1.createGunzip)(), fs.createWriteStream(tmp, { mode: 0o755 }));
120
+ const actual = hash.digest('hex');
121
+ if (actual !== asset.sha256) {
122
+ throw new Error(`${asset.name} has the wrong checksum (${actual}) - not used`);
123
+ }
124
+ if (platform !== 'win32') {
125
+ fs.chmodSync(tmp, 0o755);
126
+ }
127
+ fs.rmSync(dest, { force: true });
128
+ fs.renameSync(tmp, dest);
129
+ }
130
+ catch (e) {
131
+ fs.rmSync(tmp, { force: true });
132
+ throw e;
133
+ }
134
+ options.log?.info(`ffmpeg saved to ${dest}`);
135
+ return dest;
136
+ }
137
+ //# sourceMappingURL=ffmpegStatic.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ffmpegStatic.js","sourceRoot":"","sources":["../../../src/lib/voice/ffmpegStatic.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * A static ffmpeg for hosts without one: the binaries of ffmpeg-static (github.com/eugeneware/ffmpeg-static),\n * downloaded on request (\"Download ffmpeg\" on the Voice tab) into the instance data dir.\n *\n * Deliberately not the npm package: its install script downloads 20-30 MB for every installation of the adapter,\n * also where nobody uses voice, and newer npm versions no longer run install scripts without asking - then the\n * binary is silently missing. The release and the SHA-256 of every file are pinned here, so a replaced file is\n * refused instead of executed.\n */\nimport { createHash } from 'node:crypto';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport { Readable, Transform } from 'node:stream';\nimport type { ReadableStream as WebReadableStream } from 'node:stream/web';\nimport { pipeline } from 'node:stream/promises';\nimport { createGunzip } from 'node:zlib';\n\nimport type { VoiceLogger } from './download';\n\n/** The ffmpeg-static release. The binaries are newer than its name (Linux arm64: ffmpeg 7.0.2). */\nexport const FFMPEG_STATIC_RELEASE = 'b6.1.1';\n\nconst BASE_URL = `https://github.com/eugeneware/ffmpeg-static/releases/download/${FFMPEG_STATIC_RELEASE}`;\n\n/** SHA-256 of the gzipped binaries, as GitHub reports them for the release. Key = `<platform>-<arch>` of Node. */\nconst ASSETS: Record<string, string> = {\n 'darwin-arm64': '8923876afa8db5585022d7860ec7e589af192f441c56793971276d450ed3bbfa',\n 'darwin-x64': '929b375c1182d956c51f7ac25e0b2b0411fb01f6f407aa15c9758efeb4242106',\n 'linux-arm': '64b115a12f0ab77c277e3c418aae8b40ef881e75e746a0e2d066a206b9bc5172',\n 'linux-arm64': '754a678672298bc68156adff58aa7385a592c2b30b1d0ae8750c45c915c4bac0',\n 'linux-ia32': '169b27c078a8ecedb814cac67afccf15a9868d63e9d74ef86088adefaa500d00',\n 'linux-x64': 'bfe8a8fc511530457b528c48d77b5737527b504a3797a9bc4866aeca69c2dffa',\n 'win32-x64': '8883a3dffbd0a16cf4ef95206ea05283f78908dbfb118f73c83f4951dcc06d77',\n};\n\n/** A file of the release: its name and the SHA-256 of the gzipped content. */\nexport interface FfmpegAsset {\n name: string;\n sha256: string;\n}\n\n/**\n * The file for this machine, or `null` if ffmpeg-static has none (e.g. Windows on ARM, FreeBSD).\n *\n * @param platform Node's `process.platform`\n * @param arch Node's `process.arch`\n */\nexport function ffmpegStaticAsset(\n platform: string = process.platform,\n arch: string = process.arch,\n): FfmpegAsset | null {\n const key = `${platform}-${arch}`;\n return ASSETS[key] ? { name: `ffmpeg-${key}.gz`, sha256: ASSETS[key] } : null;\n}\n\n/**\n * Where the downloaded binary lives.\n *\n * @param dataDir the instance data dir\n * @param platform Node's `process.platform`\n */\nexport function ffmpegStaticPath(dataDir: string, platform: string = process.platform): string {\n return path.join(dataDir, 'ffmpeg', platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg');\n}\n\n/** Options of {@link downloadFfmpegStatic} - besides the logger only for tests. */\nexport interface FfmpegDownloadOptions {\n log?: VoiceLogger;\n /** Release URL without the file name */\n baseUrl?: string;\n /** Instead of the file for this machine */\n asset?: FfmpegAsset | null;\n platform?: string;\n timeoutMs?: number;\n}\n\n/**\n * Download, check and unpack the static ffmpeg into `<dataDir>/ffmpeg/`. Replaces an older download.\n *\n * @param dataDir the instance data dir\n * @param options logger; the rest is for tests\n * @returns the path of the binary\n */\nexport async function downloadFfmpegStatic(dataDir: string, options: FfmpegDownloadOptions = {}): Promise<string> {\n const platform = options.platform ?? process.platform;\n const asset = options.asset === undefined ? ffmpegStaticAsset(platform) : options.asset;\n if (!asset) {\n throw new Error(\n `there is no ffmpeg download for ${process.platform}-${process.arch} - install ffmpeg yourself and enter its path`,\n );\n }\n const url = `${options.baseUrl ?? BASE_URL}/${asset.name}`;\n const dest = ffmpegStaticPath(dataDir, platform);\n const tmp = `${dest}.part`;\n fs.mkdirSync(path.dirname(dest), { recursive: true });\n\n const res = await fetch(url, { signal: AbortSignal.timeout(options.timeoutMs ?? 10 * 60_000) });\n if (!res.ok || !res.body) {\n throw new Error(`download of ${url} failed: HTTP ${res.status}`);\n }\n const size = Number(res.headers.get('content-length')) || 0;\n options.log?.info(`Downloading ffmpeg (${asset.name}${size ? `, ${(size / 1e6).toFixed(1)} MB` : ''}) …`);\n\n // The hash is taken over the gzipped bytes - that is what GitHub publishes.\n const hash = createHash('sha256');\n const tap = new Transform({\n transform(chunk: Buffer, _encoding, done): void {\n hash.update(chunk);\n done(null, chunk);\n },\n });\n try {\n await pipeline(\n Readable.fromWeb(res.body as WebReadableStream),\n tap,\n createGunzip(),\n fs.createWriteStream(tmp, { mode: 0o755 }),\n );\n const actual = hash.digest('hex');\n if (actual !== asset.sha256) {\n throw new Error(`${asset.name} has the wrong checksum (${actual}) - not used`);\n }\n if (platform !== 'win32') {\n fs.chmodSync(tmp, 0o755);\n }\n fs.rmSync(dest, { force: true });\n fs.renameSync(tmp, dest);\n } catch (e) {\n fs.rmSync(tmp, { force: true });\n throw e;\n }\n options.log?.info(`ffmpeg saved to ${dest}`);\n return dest;\n}\n"]}
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.OpenAiStt = void 0;
37
37
  exports.hintsToPrompt = hintsToPrompt;
38
38
  exports.pcmToWav = pcmToWav;
39
+ exports.normalizeSttBaseUrl = normalizeSttBaseUrl;
39
40
  /**
40
41
  * Speech-to-text engines. V1 ships the cloud engine (OpenAI Whisper); local engines
41
42
  * (Vosk / whisper.cpp / Wyoming) plug in behind the same `SttEngine` interface later.
@@ -88,12 +89,43 @@ function pcmToWav(pcm, sampleRate, numChannels = 1) {
88
89
  header.writeUInt32LE(pcm.length, 40);
89
90
  return Buffer.concat([header, pcm]);
90
91
  }
91
- /** Cloud STT via the OpenAI transcription API (Whisper). */
92
+ /**
93
+ * Base URL of an OpenAI-compatible transcription server as the SDK wants it. Practically every such server
94
+ * (speaches/faster-whisper-server, LocalAI, vLLM, Groq) serves `/v1/audio/transcriptions`, so an address typed
95
+ * without a path gets `/v1`; one with a path is taken as it is.
96
+ */
97
+ function normalizeSttBaseUrl(raw) {
98
+ const url = raw.trim().replace(/\/+$/, '');
99
+ if (!url) {
100
+ return '';
101
+ }
102
+ try {
103
+ const parsed = new URL(url);
104
+ return parsed.pathname === '/' || parsed.pathname === '' ? `${url}/v1` : url;
105
+ }
106
+ catch {
107
+ throw new Error(`speech recognition base URL "${raw}" is not a URL (e.g. http://192.168.1.10:8000/v1)`);
108
+ }
109
+ }
110
+ /**
111
+ * STT via the OpenAI transcription API (Whisper) — or any server that speaks it, e.g. a local
112
+ * whisper-large-v3-turbo next to ioBroker (`baseUrl`).
113
+ */
92
114
  class OpenAiStt {
93
115
  client;
94
116
  model;
95
- constructor(apiKey, model = 'whisper-1') {
96
- this.client = new openai_1.default({ apiKey });
117
+ /**
118
+ * @param apiKey OpenAI key, or the key of the server at `baseUrl` ('' for a server without one)
119
+ * @param model model id; on an own server as that server names it
120
+ * @param baseUrl OpenAI-compatible server instead of api.openai.com ('' = OpenAI)
121
+ */
122
+ constructor(apiKey, model = 'whisper-1', baseUrl = '') {
123
+ const baseURL = normalizeSttBaseUrl(baseUrl);
124
+ this.client = baseURL
125
+ ? // The SDK refuses an empty key; a local server ignores it. A server that hangs must not hold the
126
+ // satellite for the SDK's default ten minutes.
127
+ new openai_1.default({ apiKey: apiKey || 'none', baseURL, timeout: 60_000, maxRetries: 1 })
128
+ : new openai_1.default({ apiKey });
97
129
  this.model = model;
98
130
  }
99
131
  async transcribe(pcm, sampleRate, lang, hints) {
@@ -1 +1 @@
1
- {"version":3,"file":"stt.js","sourceRoot":"","sources":["../../../src/lib/voice/stt.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Speech-to-text engines. V1 ships the cloud engine (OpenAI Whisper); local engines\n * (Vosk / whisper.cpp / Wyoming) plug in behind the same `SttEngine` interface later.\n */\nimport OpenAI, { toFile } from 'openai';\n\nexport interface SttEngine {\n /**\n * Transcribe raw mono 16-bit PCM. `lang` is an ISO-639-1 hint ('' = auto-detect).\n *\n * `hints` is an optional domain vocabulary (device / room / scene names …) to bias recognition toward,\n * so the engine better transcribes those proper nouns. Only engines that support *soft* biasing use it\n * (OpenAI Whisper `prompt`, Azure phrase list); engines where a word list would be a hard constraint or\n * needs pre-registration (Vosk grammar, AWS custom vocabulary) ignore it.\n */\n transcribe(pcm: Buffer, sampleRate: number, lang: string, hints?: string[]): Promise<string>;\n /** Optional warm-up (install/download engine + model for `lang`) so the first request isn't slow. */\n prepare?(lang: string): Promise<void>;\n}\n\n/**\n * Join vocabulary hints into a Whisper `prompt` string (a soft bias toward these terms), de-duplicated and\n * length-bounded to stay within the model's ~224-token prompt budget. Returns '' when there are no hints.\n */\nexport function hintsToPrompt(hints: string[] | undefined, maxChars = 800): string {\n if (!hints?.length) {\n return '';\n }\n const seen = new Set<string>();\n const parts: string[] = [];\n let len = 0;\n for (const h of hints) {\n const term = (h || '').trim();\n const key = term.toLowerCase();\n if (!term || seen.has(key)) {\n continue;\n }\n if (len + term.length + 2 > maxChars) {\n break;\n }\n seen.add(key);\n parts.push(term);\n len += term.length + 2;\n }\n return parts.join(', ');\n}\n\n/** Wrap raw 16-bit-signed-LE PCM (mono, or interleaved stereo) into a minimal 44-byte WAV container. */\nexport function pcmToWav(pcm: Buffer, sampleRate: number, numChannels = 1): Buffer {\n const bitsPerSample = 16;\n const blockAlign = (numChannels * bitsPerSample) / 8;\n const byteRate = sampleRate * blockAlign;\n const header = Buffer.alloc(44);\n header.write('RIFF', 0);\n header.writeUInt32LE(36 + pcm.length, 4);\n header.write('WAVE', 8);\n header.write('fmt ', 12);\n header.writeUInt32LE(16, 16); // PCM fmt chunk size\n header.writeUInt16LE(1, 20); // audio format = PCM\n header.writeUInt16LE(numChannels, 22);\n header.writeUInt32LE(sampleRate, 24);\n header.writeUInt32LE(byteRate, 28);\n header.writeUInt16LE(blockAlign, 32);\n header.writeUInt16LE(bitsPerSample, 34);\n header.write('data', 36);\n header.writeUInt32LE(pcm.length, 40);\n return Buffer.concat([header, pcm]);\n}\n\n/** Cloud STT via the OpenAI transcription API (Whisper). */\nexport class OpenAiStt implements SttEngine {\n private readonly client: OpenAI;\n private readonly model: string;\n\n constructor(apiKey: string, model = 'whisper-1') {\n this.client = new OpenAI({ apiKey });\n this.model = model;\n }\n\n async transcribe(pcm: Buffer, sampleRate: number, lang: string, hints?: string[]): Promise<string> {\n const file = await toFile(pcmToWav(pcm, sampleRate), 'audio.wav', { type: 'audio/wav' });\n // A prompt biases Whisper toward the given domain vocabulary (device/room names) without excluding\n // other words — improves recognition of proper nouns the NLU then needs to match.\n const prompt = hintsToPrompt(hints);\n const res = await this.client.audio.transcriptions.create({\n file,\n model: this.model,\n // Whisper wants the bare ISO-639-1 code; strip any region (de-DE → de). '' → auto-detect.\n language: (lang || '').split('-')[0] || undefined,\n ...(prompt ? { prompt } : {}),\n });\n return (res.text || '').trim();\n }\n}\n"]}
1
+ {"version":3,"file":"stt.js","sourceRoot":"","sources":["../../../src/lib/voice/stt.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwBA,sCAqBC;AAGD,4BAmBC;AAOD,kDAWC;AArFD;;;GAGG;AACH,iDAAwC;AAgBxC;;;GAGG;AACH,SAAgB,aAAa,CAAC,KAA2B,EAAE,QAAQ,GAAG,GAAG;IACrE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC;QACjB,OAAO,EAAE,CAAC;IACd,CAAC;IACD,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,IAAI,GAAG,GAAG,CAAC,CAAC;IACZ,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;QACpB,MAAM,IAAI,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;QAC9B,MAAM,GAAG,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;QAC/B,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;YACzB,SAAS;QACb,CAAC;QACD,IAAI,GAAG,GAAG,IAAI,CAAC,MAAM,GAAG,CAAC,GAAG,QAAQ,EAAE,CAAC;YACnC,MAAM;QACV,CAAC;QACD,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACd,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACjB,GAAG,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC;IAC3B,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC5B,CAAC;AAED,wGAAwG;AACxG,SAAgB,QAAQ,CAAC,GAAW,EAAE,UAAkB,EAAE,WAAW,GAAG,CAAC;IACrE,MAAM,aAAa,GAAG,EAAE,CAAC;IACzB,MAAM,UAAU,GAAG,CAAC,WAAW,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;IACrD,MAAM,QAAQ,GAAG,UAAU,GAAG,UAAU,CAAC;IACzC,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAChC,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IACxB,MAAM,CAAC,aAAa,CAAC,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IACzC,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IACxB,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IACzB,MAAM,CAAC,aAAa,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,qBAAqB;IACnD,MAAM,CAAC,aAAa,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,qBAAqB;IAClD,MAAM,CAAC,aAAa,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC;IACtC,MAAM,CAAC,aAAa,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;IACrC,MAAM,CAAC,aAAa,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;IACnC,MAAM,CAAC,aAAa,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;IACrC,MAAM,CAAC,aAAa,CAAC,aAAa,EAAE,EAAE,CAAC,CAAC;IACxC,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IACzB,MAAM,CAAC,aAAa,CAAC,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IACrC,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC;AACxC,CAAC;AAED;;;;GAIG;AACH,SAAgB,mBAAmB,CAAC,GAAW;IAC3C,MAAM,GAAG,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC3C,IAAI,CAAC,GAAG,EAAE,CAAC;QACP,OAAO,EAAE,CAAC;IACd,CAAC;IACD,IAAI,CAAC;QACD,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;QAC5B,OAAO,MAAM,CAAC,QAAQ,KAAK,GAAG,IAAI,MAAM,CAAC,QAAQ,KAAK,EAAE,CAAC,CAAC,CAAC,GAAG,GAAG,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC;IACjF,CAAC;IAAC,MAAM,CAAC;QACL,MAAM,IAAI,KAAK,CAAC,gCAAgC,GAAG,mDAAmD,CAAC,CAAC;IAC5G,CAAC;AACL,CAAC;AAED;;;GAGG;AACH,MAAa,SAAS;IACD,MAAM,CAAS;IACf,KAAK,CAAS;IAE/B;;;;OAIG;IACH,YAAY,MAAc,EAAE,KAAK,GAAG,WAAW,EAAE,OAAO,GAAG,EAAE;QACzD,MAAM,OAAO,GAAG,mBAAmB,CAAC,OAAO,CAAC,CAAC;QAC7C,IAAI,CAAC,MAAM,GAAG,OAAO;YACjB,CAAC,CAAC,iGAAiG;gBACjG,+CAA+C;gBAC/C,IAAI,gBAAM,CAAC,EAAE,MAAM,EAAE,MAAM,IAAI,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC,EAAE,CAAC;YACnF,CAAC,CAAC,IAAI,gBAAM,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC;QAC7B,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACvB,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,GAAW,EAAE,UAAkB,EAAE,IAAY,EAAE,KAAgB;QAC5E,MAAM,IAAI,GAAG,MAAM,IAAA,eAAM,EAAC,QAAQ,CAAC,GAAG,EAAE,UAAU,CAAC,EAAE,WAAW,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC,CAAC;QACzF,mGAAmG;QACnG,kFAAkF;QAClF,MAAM,MAAM,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;QACpC,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,MAAM,CAAC;YACtD,IAAI;YACJ,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB,0FAA0F;YAC1F,QAAQ,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,SAAS;YACjD,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAChC,CAAC,CAAC;QACH,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IACnC,CAAC;CACJ;AAjCD,8BAiCC","sourcesContent":["/**\n * Speech-to-text engines. V1 ships the cloud engine (OpenAI Whisper); local engines\n * (Vosk / whisper.cpp / Wyoming) plug in behind the same `SttEngine` interface later.\n */\nimport OpenAI, { toFile } from 'openai';\n\nexport interface SttEngine {\n /**\n * Transcribe raw mono 16-bit PCM. `lang` is an ISO-639-1 hint ('' = auto-detect).\n *\n * `hints` is an optional domain vocabulary (device / room / scene names …) to bias recognition toward,\n * so the engine better transcribes those proper nouns. Only engines that support *soft* biasing use it\n * (OpenAI Whisper `prompt`, Azure phrase list); engines where a word list would be a hard constraint or\n * needs pre-registration (Vosk grammar, AWS custom vocabulary) ignore it.\n */\n transcribe(pcm: Buffer, sampleRate: number, lang: string, hints?: string[]): Promise<string>;\n /** Optional warm-up (install/download engine + model for `lang`) so the first request isn't slow. */\n prepare?(lang: string): Promise<void>;\n}\n\n/**\n * Join vocabulary hints into a Whisper `prompt` string (a soft bias toward these terms), de-duplicated and\n * length-bounded to stay within the model's ~224-token prompt budget. Returns '' when there are no hints.\n */\nexport function hintsToPrompt(hints: string[] | undefined, maxChars = 800): string {\n if (!hints?.length) {\n return '';\n }\n const seen = new Set<string>();\n const parts: string[] = [];\n let len = 0;\n for (const h of hints) {\n const term = (h || '').trim();\n const key = term.toLowerCase();\n if (!term || seen.has(key)) {\n continue;\n }\n if (len + term.length + 2 > maxChars) {\n break;\n }\n seen.add(key);\n parts.push(term);\n len += term.length + 2;\n }\n return parts.join(', ');\n}\n\n/** Wrap raw 16-bit-signed-LE PCM (mono, or interleaved stereo) into a minimal 44-byte WAV container. */\nexport function pcmToWav(pcm: Buffer, sampleRate: number, numChannels = 1): Buffer {\n const bitsPerSample = 16;\n const blockAlign = (numChannels * bitsPerSample) / 8;\n const byteRate = sampleRate * blockAlign;\n const header = Buffer.alloc(44);\n header.write('RIFF', 0);\n header.writeUInt32LE(36 + pcm.length, 4);\n header.write('WAVE', 8);\n header.write('fmt ', 12);\n header.writeUInt32LE(16, 16); // PCM fmt chunk size\n header.writeUInt16LE(1, 20); // audio format = PCM\n header.writeUInt16LE(numChannels, 22);\n header.writeUInt32LE(sampleRate, 24);\n header.writeUInt32LE(byteRate, 28);\n header.writeUInt16LE(blockAlign, 32);\n header.writeUInt16LE(bitsPerSample, 34);\n header.write('data', 36);\n header.writeUInt32LE(pcm.length, 40);\n return Buffer.concat([header, pcm]);\n}\n\n/**\n * Base URL of an OpenAI-compatible transcription server as the SDK wants it. Practically every such server\n * (speaches/faster-whisper-server, LocalAI, vLLM, Groq) serves `/v1/audio/transcriptions`, so an address typed\n * without a path gets `/v1`; one with a path is taken as it is.\n */\nexport function normalizeSttBaseUrl(raw: string): string {\n const url = raw.trim().replace(/\\/+$/, '');\n if (!url) {\n return '';\n }\n try {\n const parsed = new URL(url);\n return parsed.pathname === '/' || parsed.pathname === '' ? `${url}/v1` : url;\n } catch {\n throw new Error(`speech recognition base URL \"${raw}\" is not a URL (e.g. http://192.168.1.10:8000/v1)`);\n }\n}\n\n/**\n * STT via the OpenAI transcription API (Whisper) — or any server that speaks it, e.g. a local\n * whisper-large-v3-turbo next to ioBroker (`baseUrl`).\n */\nexport class OpenAiStt implements SttEngine {\n private readonly client: OpenAI;\n private readonly model: string;\n\n /**\n * @param apiKey OpenAI key, or the key of the server at `baseUrl` ('' for a server without one)\n * @param model model id; on an own server as that server names it\n * @param baseUrl OpenAI-compatible server instead of api.openai.com ('' = OpenAI)\n */\n constructor(apiKey: string, model = 'whisper-1', baseUrl = '') {\n const baseURL = normalizeSttBaseUrl(baseUrl);\n this.client = baseURL\n ? // The SDK refuses an empty key; a local server ignores it. A server that hangs must not hold the\n // satellite for the SDK's default ten minutes.\n new OpenAI({ apiKey: apiKey || 'none', baseURL, timeout: 60_000, maxRetries: 1 })\n : new OpenAI({ apiKey });\n this.model = model;\n }\n\n async transcribe(pcm: Buffer, sampleRate: number, lang: string, hints?: string[]): Promise<string> {\n const file = await toFile(pcmToWav(pcm, sampleRate), 'audio.wav', { type: 'audio/wav' });\n // A prompt biases Whisper toward the given domain vocabulary (device/room names) without excluding\n // other words — improves recognition of proper nouns the NLU then needs to match.\n const prompt = hintsToPrompt(hints);\n const res = await this.client.audio.transcriptions.create({\n file,\n model: this.model,\n // Whisper wants the bare ISO-639-1 code; strip any region (de-DE → de). '' → auto-detect.\n language: (lang || '').split('-')[0] || undefined,\n ...(prompt ? { prompt } : {}),\n });\n return (res.text || '').trim();\n }\n}\n"]}