clideck 1.22.2
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/LICENSE +21 -0
- package/README.md +77 -0
- package/activity.js +56 -0
- package/agent-presets.json +93 -0
- package/assets/clideck-themes.jpg +0 -0
- package/bin/clideck.js +2 -0
- package/config.js +96 -0
- package/handlers.js +297 -0
- package/opencode-bridge.js +148 -0
- package/opencode-plugin/clideck-bridge.js +24 -0
- package/package.json +47 -0
- package/paths.js +41 -0
- package/plugin-loader.js +285 -0
- package/plugins/trim-clip/clideck-plugin.json +13 -0
- package/plugins/trim-clip/client.js +31 -0
- package/plugins/trim-clip/index.js +10 -0
- package/plugins/voice-input/clideck-plugin.json +49 -0
- package/plugins/voice-input/client.js +196 -0
- package/plugins/voice-input/index.js +342 -0
- package/plugins/voice-input/python/mel_filters.npz +0 -0
- package/plugins/voice-input/python/whisper_turbo.py +416 -0
- package/plugins/voice-input/python/worker.py +135 -0
- package/public/fx/bold-beep-idle.mp3 +0 -0
- package/public/fx/default-beep.mp3 +0 -0
- package/public/fx/echo-beep-idle.mp3 +0 -0
- package/public/fx/musical-beep-idle.mp3 +0 -0
- package/public/fx/small-bleep-idle.mp3 +0 -0
- package/public/fx/soft-beep.mp3 +0 -0
- package/public/fx/space-idle.mp3 +0 -0
- package/public/img/claude-code.png +0 -0
- package/public/img/clideck-logo-icon.png +0 -0
- package/public/img/clideck-logo-terminal-panel.png +0 -0
- package/public/img/codex.png +0 -0
- package/public/img/gemini.png +0 -0
- package/public/img/opencode.png +0 -0
- package/public/index.html +243 -0
- package/public/js/app.js +794 -0
- package/public/js/color-mode.js +51 -0
- package/public/js/confirm.js +27 -0
- package/public/js/creator.js +201 -0
- package/public/js/drag.js +134 -0
- package/public/js/folder-picker.js +81 -0
- package/public/js/hotkeys.js +90 -0
- package/public/js/nav.js +56 -0
- package/public/js/profiles.js +22 -0
- package/public/js/prompts.js +325 -0
- package/public/js/settings.js +489 -0
- package/public/js/state.js +15 -0
- package/public/js/terminals.js +905 -0
- package/public/js/toast.js +62 -0
- package/public/js/utils.js +27 -0
- package/public/tailwind.css +1 -0
- package/server.js +126 -0
- package/sessions.js +375 -0
- package/telemetry-receiver.js +129 -0
- package/themes.js +247 -0
- package/transcript.js +90 -0
- package/utils.js +66 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
let settings = { enabled: false, backend: 'openai', hotkey: 'F4' };
|
|
2
|
+
let recordingState = null; // { startTime, mediaRecorder, stream, cancelled, sessionId }
|
|
3
|
+
let activeToast = null;
|
|
4
|
+
let btnEl = null;
|
|
5
|
+
let _api = null;
|
|
6
|
+
|
|
7
|
+
function toast(message, type, persistent) {
|
|
8
|
+
if (activeToast) activeToast.dismiss();
|
|
9
|
+
activeToast = _api.toast(message, { type, duration: persistent ? 0 : 2000, id: 'voice-input' });
|
|
10
|
+
return activeToast;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// --- Audio: decode to 16kHz mono Float32 PCM (no ffmpeg needed) ---
|
|
14
|
+
|
|
15
|
+
async function decodeToPcm16k(blob) {
|
|
16
|
+
const buf = await blob.arrayBuffer();
|
|
17
|
+
const ctx = new AudioContext();
|
|
18
|
+
const decoded = await ctx.decodeAudioData(buf);
|
|
19
|
+
const numSamples = Math.round(decoded.duration * 16000);
|
|
20
|
+
const offline = new OfflineAudioContext(1, numSamples, 16000);
|
|
21
|
+
const src = offline.createBufferSource();
|
|
22
|
+
src.buffer = decoded;
|
|
23
|
+
src.connect(offline.destination);
|
|
24
|
+
src.start();
|
|
25
|
+
const resampled = await offline.startRendering();
|
|
26
|
+
ctx.close();
|
|
27
|
+
return resampled.getChannelData(0); // Float32Array, 16kHz mono
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function float32ToBase64(f32) {
|
|
31
|
+
const bytes = new Uint8Array(f32.buffer);
|
|
32
|
+
const chunks = [];
|
|
33
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
34
|
+
chunks.push(String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)));
|
|
35
|
+
}
|
|
36
|
+
return btoa(chunks.join(''));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- Button state ---
|
|
40
|
+
|
|
41
|
+
function updateButton() {
|
|
42
|
+
if (!btnEl) return;
|
|
43
|
+
if (recordingState) {
|
|
44
|
+
btnEl.style.color = '#ef4444';
|
|
45
|
+
btnEl.title = 'Stop recording (or press ' + (settings.hotkey || 'F4') + ')';
|
|
46
|
+
} else {
|
|
47
|
+
btnEl.style.color = '';
|
|
48
|
+
btnEl.title = 'Voice Input (' + (settings.hotkey || 'F4') + ')';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- Recording ---
|
|
53
|
+
|
|
54
|
+
async function startRecording() {
|
|
55
|
+
if (!_api || recordingState) return;
|
|
56
|
+
const sessionId = _api.getActiveSessionId();
|
|
57
|
+
if (!sessionId) { toast('No active terminal', 'error'); return; }
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
61
|
+
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
|
62
|
+
? 'audio/webm;codecs=opus'
|
|
63
|
+
: MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : '';
|
|
64
|
+
|
|
65
|
+
const chunks = [];
|
|
66
|
+
const mr = new MediaRecorder(stream, mimeType ? { mimeType } : {});
|
|
67
|
+
|
|
68
|
+
mr.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); };
|
|
69
|
+
|
|
70
|
+
mr.onstop = async () => {
|
|
71
|
+
stream.getTracks().forEach(t => t.stop());
|
|
72
|
+
const state = recordingState;
|
|
73
|
+
recordingState = null;
|
|
74
|
+
updateButton();
|
|
75
|
+
if (activeToast) activeToast.dismiss();
|
|
76
|
+
|
|
77
|
+
if (!state || state.cancelled) {
|
|
78
|
+
toast('CANCELLED', 'error');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const duration = (Date.now() - state.startTime) / 1000;
|
|
83
|
+
if (duration < 0.4) {
|
|
84
|
+
toast('Too short', 'error');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
toast('Transcribing...', 'info', true);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const blob = new Blob(chunks, { type: mr.mimeType });
|
|
92
|
+
const pcm = await decodeToPcm16k(blob);
|
|
93
|
+
const b64 = float32ToBase64(pcm);
|
|
94
|
+
_api.send('transcribe', { audio: b64, sessionId: state.sessionId });
|
|
95
|
+
} catch (e) {
|
|
96
|
+
toast('Audio decode failed', 'error');
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
mr.start(100);
|
|
101
|
+
recordingState = { startTime: Date.now(), mediaRecorder: mr, stream, cancelled: false, sessionId };
|
|
102
|
+
updateButton();
|
|
103
|
+
toast('REC \u25cf', 'error', true);
|
|
104
|
+
} catch (e) {
|
|
105
|
+
toast('Mic: ' + e.message, 'error');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stopRecording() {
|
|
110
|
+
if (recordingState) recordingState.mediaRecorder.stop();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function cancelRecording() {
|
|
114
|
+
if (!recordingState) return;
|
|
115
|
+
recordingState.cancelled = true;
|
|
116
|
+
recordingState.mediaRecorder.stop();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- Hotkey ---
|
|
120
|
+
|
|
121
|
+
let currentHotkey = null;
|
|
122
|
+
|
|
123
|
+
function bindHotkey() {
|
|
124
|
+
const code = settings.hotkey || 'F4';
|
|
125
|
+
if (code === currentHotkey) return;
|
|
126
|
+
const cb = () => {
|
|
127
|
+
if (!settings.enabled) return;
|
|
128
|
+
if (!recordingState) startRecording();
|
|
129
|
+
else stopRecording();
|
|
130
|
+
};
|
|
131
|
+
const prev = currentHotkey;
|
|
132
|
+
if (prev) _api.unregisterHotkey(prev);
|
|
133
|
+
if (_api.registerHotkey(code, cb)) {
|
|
134
|
+
currentHotkey = code;
|
|
135
|
+
} else if (prev) {
|
|
136
|
+
_api.registerHotkey(prev, cb);
|
|
137
|
+
toast(`Hotkey "${code}" is taken, keeping "${prev}"`, 'warn');
|
|
138
|
+
} else {
|
|
139
|
+
toast(`Hotkey "${code}" is unavailable`, 'warn');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Escape to cancel recording — handled separately since it's conditional
|
|
144
|
+
document.addEventListener('keydown', (e) => {
|
|
145
|
+
if (e.key === 'Escape' && recordingState) {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
e.stopPropagation();
|
|
148
|
+
cancelRecording();
|
|
149
|
+
}
|
|
150
|
+
}, true);
|
|
151
|
+
|
|
152
|
+
// --- Init ---
|
|
153
|
+
|
|
154
|
+
export function init(api) {
|
|
155
|
+
_api = api;
|
|
156
|
+
|
|
157
|
+
api.onMessage('settings', msg => {
|
|
158
|
+
settings = { ...settings, ...msg };
|
|
159
|
+
if (btnEl) btnEl.style.display = settings.enabled ? '' : 'none';
|
|
160
|
+
bindHotkey();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
api.onMessage('status', msg => {
|
|
164
|
+
if (msg.setup) toast(msg.setup, 'info', true);
|
|
165
|
+
else if (msg.workerReady) toast('Voice Input ready', 'success');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
api.onMessage('result', msg => {
|
|
169
|
+
if (activeToast) { activeToast.dismiss(); activeToast = null; }
|
|
170
|
+
if (msg.skipped || !msg.text) return;
|
|
171
|
+
const sid = msg.sessionId || _api.getActiveSessionId();
|
|
172
|
+
if (!sid) return;
|
|
173
|
+
api.writeToSession(sid, msg.text + ' ');
|
|
174
|
+
document.querySelector('.term-wrap.active textarea')?.focus();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
api.onMessage('error', msg => {
|
|
178
|
+
toast(msg.error || 'Error', 'error');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
api.send('getSettings');
|
|
182
|
+
|
|
183
|
+
btnEl = api.addToolbarButton({
|
|
184
|
+
title: 'Voice Input (F4)',
|
|
185
|
+
icon: '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="22"/></svg>',
|
|
186
|
+
onClick() {
|
|
187
|
+
if (!settings.enabled) { toast('Voice Input is disabled', 'error'); return; }
|
|
188
|
+
if (!recordingState) startRecording();
|
|
189
|
+
else stopRecording();
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
btnEl.addEventListener('mousedown', e => e.preventDefault());
|
|
194
|
+
if (!settings.enabled && btnEl) btnEl.style.display = 'none';
|
|
195
|
+
bindHotkey();
|
|
196
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
const { join } = require('path');
|
|
3
|
+
const { readFileSync, existsSync, statSync } = require('fs');
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
init(api) {
|
|
7
|
+
let worker = null;
|
|
8
|
+
let workerReady = false;
|
|
9
|
+
const pending = new Map();
|
|
10
|
+
let nextId = 0;
|
|
11
|
+
let replacements = [];
|
|
12
|
+
let replMtime = null;
|
|
13
|
+
|
|
14
|
+
// --- Python virtual environment ---
|
|
15
|
+
|
|
16
|
+
const pyDir = join(api.pluginDir, 'python');
|
|
17
|
+
const venvDir = join(pyDir, '.venv');
|
|
18
|
+
const venvPy = process.platform === 'win32'
|
|
19
|
+
? join(venvDir, 'Scripts', 'python.exe')
|
|
20
|
+
: join(venvDir, 'bin', 'python3');
|
|
21
|
+
|
|
22
|
+
function localDeps() {
|
|
23
|
+
if (process.platform === 'darwin') return ['numpy', 'mlx', 'tiktoken', 'huggingface_hub'];
|
|
24
|
+
return ['numpy', 'faster-whisper'];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function checkImport() {
|
|
28
|
+
return process.platform === 'darwin'
|
|
29
|
+
? 'import numpy, mlx, tiktoken, huggingface_hub'
|
|
30
|
+
: 'import numpy, faster_whisper';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function run(cmd, args) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
36
|
+
let err = '';
|
|
37
|
+
p.stderr.on('data', d => { err += d; });
|
|
38
|
+
p.on('close', code => code === 0 ? resolve() : reject(new Error(err.trim() || `exit ${code}`)));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function findPython() {
|
|
43
|
+
const candidates = process.platform === 'win32' ? ['python', 'python3'] : ['python3', 'python'];
|
|
44
|
+
for (const cmd of candidates) {
|
|
45
|
+
try { require('child_process').execFileSync(cmd, ['--version'], { stdio: 'ignore' }); return cmd; } catch {}
|
|
46
|
+
}
|
|
47
|
+
throw new Error('Python not found. Install Python 3 and ensure it is in your PATH.');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function ensureEnv() {
|
|
51
|
+
if (!existsSync(venvDir)) {
|
|
52
|
+
api.sendToFrontend('status', { setup: 'Creating Python environment…' });
|
|
53
|
+
api.log('creating venv');
|
|
54
|
+
await run(findPython(), ['-m', 'venv', venvDir]);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
await run(venvPy, ['-c', checkImport()]);
|
|
58
|
+
} catch {
|
|
59
|
+
const deps = localDeps();
|
|
60
|
+
api.sendToFrontend('status', { setup: `Installing dependencies (${deps.join(', ')})…` });
|
|
61
|
+
api.log(`pip install: ${deps.join(', ')}`);
|
|
62
|
+
await run(venvPy, ['-m', 'pip', 'install', '--quiet', ...deps]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Text replacements (same format as global_asr) ---
|
|
67
|
+
|
|
68
|
+
function loadReplacements() {
|
|
69
|
+
const fp = api.getSetting('replacementsFile');
|
|
70
|
+
if (!fp || !existsSync(fp)) { replacements = []; replMtime = null; return; }
|
|
71
|
+
try {
|
|
72
|
+
const mt = statSync(fp).mtimeMs;
|
|
73
|
+
if (replMtime === mt) return;
|
|
74
|
+
const rules = [];
|
|
75
|
+
for (const raw of readFileSync(fp, 'utf8').split('\n')) {
|
|
76
|
+
const line = raw.trim();
|
|
77
|
+
if (!line || line.startsWith('#') || !line.includes('=>')) continue;
|
|
78
|
+
const [srcRaw, ...rest] = line.split('=>');
|
|
79
|
+
const right = rest.join('=>').split('|');
|
|
80
|
+
const src = srcRaw.trim().replace(/^['"]|['"]$/g, '');
|
|
81
|
+
const tgt = (right[0] || '').trim().replace(/^['"]|['"]$/g, '');
|
|
82
|
+
if (!src) continue;
|
|
83
|
+
let flags = 'g';
|
|
84
|
+
for (const o of right.slice(1)) {
|
|
85
|
+
const t = o.trim().toLowerCase();
|
|
86
|
+
if (t === 'all' || t === 'match_all' || t.includes('mode=all')) flags = 'gi';
|
|
87
|
+
}
|
|
88
|
+
const esc = src.split(/\s+/).map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s+');
|
|
89
|
+
rules.push({ re: new RegExp(`(?<!\\w)${esc}(?!\\w)`, flags), tgt });
|
|
90
|
+
}
|
|
91
|
+
replacements = rules;
|
|
92
|
+
replMtime = mt;
|
|
93
|
+
} catch (e) { api.log(`replacements: ${e.message}`); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function applyReplacements(text) {
|
|
97
|
+
if (!text) return text;
|
|
98
|
+
loadReplacements();
|
|
99
|
+
for (const { re, tgt } of replacements) text = text.replace(re, tgt);
|
|
100
|
+
return text;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- Text cleaning (ported from global_asr) ---
|
|
104
|
+
|
|
105
|
+
function cleanText(text) {
|
|
106
|
+
text = text.replace(/\s*Продолжение следует\.{3}.*$/i, '').replace(/\s*Thank you[.!]*\s*$/i, '').trim();
|
|
107
|
+
const l = text.toLowerCase();
|
|
108
|
+
const gLen = ['clears throat', 'cough', 'ahem'].reduce((s, p) => s + (l.split(p).length - 1) * p.length, 0);
|
|
109
|
+
const hLen = (l.split('hmm').length - 1) * 3;
|
|
110
|
+
if (text.length > 0 && hLen / text.length > 0.6) return '';
|
|
111
|
+
if (text.length > 0 && gLen / text.length > 0.5) return '';
|
|
112
|
+
return text;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function processText(raw) {
|
|
116
|
+
const cleaned = cleanText(raw);
|
|
117
|
+
if (!cleaned || cleaned.toLowerCase() === 'you') return null;
|
|
118
|
+
return applyReplacements(cleaned);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Python worker (local backend) ---
|
|
122
|
+
|
|
123
|
+
function spawnWorker() {
|
|
124
|
+
if (worker) return;
|
|
125
|
+
const script = join(api.pluginDir, 'python', 'worker.py');
|
|
126
|
+
if (!existsSync(script)) { api.log('worker.py not found'); return; }
|
|
127
|
+
|
|
128
|
+
worker = spawn(venvPy, ['-u', script], {
|
|
129
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
130
|
+
cwd: pyDir,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
let buf = '';
|
|
134
|
+
worker.stdout.on('data', d => {
|
|
135
|
+
buf += d.toString();
|
|
136
|
+
const lines = buf.split('\n');
|
|
137
|
+
buf = lines.pop();
|
|
138
|
+
for (const line of lines) {
|
|
139
|
+
if (!line.trim()) continue;
|
|
140
|
+
try {
|
|
141
|
+
const msg = JSON.parse(line);
|
|
142
|
+
if (msg.id === 'init') { api.log('worker started'); continue; }
|
|
143
|
+
const cb = pending.get(msg.id);
|
|
144
|
+
if (cb) { pending.delete(msg.id); cb(msg); }
|
|
145
|
+
} catch { /* ignore parse errors */ }
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
worker.stderr.on('data', d => {
|
|
150
|
+
const t = d.toString().trim();
|
|
151
|
+
if (t) api.log(`py: ${t}`);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
worker.on('close', code => {
|
|
155
|
+
api.log(`worker exited (${code})`);
|
|
156
|
+
worker = null;
|
|
157
|
+
workerReady = false;
|
|
158
|
+
for (const [, cb] of pending) cb({ error: 'Worker exited' });
|
|
159
|
+
pending.clear();
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function workerCmd(action, data = {}) {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
if (!worker) { reject(new Error('No worker')); return; }
|
|
166
|
+
const id = String(++nextId);
|
|
167
|
+
const timer = setTimeout(() => { pending.delete(id); reject(new Error('Timeout')); }, 120000);
|
|
168
|
+
pending.set(id, msg => {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
msg.error ? reject(new Error(msg.error)) : resolve(msg);
|
|
171
|
+
});
|
|
172
|
+
worker.stdin.write(JSON.stringify({ id, action, ...data }) + '\n');
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function killWorker() {
|
|
177
|
+
if (!worker) return;
|
|
178
|
+
try { worker.kill(); } catch {}
|
|
179
|
+
worker = null;
|
|
180
|
+
workerReady = false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- PCM to WAV (for OpenAI upload) ---
|
|
184
|
+
|
|
185
|
+
function pcmToWav(pcmB64) {
|
|
186
|
+
const raw = Buffer.from(pcmB64, 'base64');
|
|
187
|
+
const numSamples = raw.length / 4; // float32 = 4 bytes
|
|
188
|
+
const pcm16 = Buffer.alloc(numSamples * 2);
|
|
189
|
+
for (let i = 0; i < numSamples; i++) {
|
|
190
|
+
const f = Math.max(-1, Math.min(1, raw.readFloatLE(i * 4)));
|
|
191
|
+
pcm16.writeInt16LE(Math.round(f * 32767), i * 2);
|
|
192
|
+
}
|
|
193
|
+
const dataLen = pcm16.length;
|
|
194
|
+
const header = Buffer.alloc(44);
|
|
195
|
+
header.write('RIFF', 0);
|
|
196
|
+
header.writeUInt32LE(36 + dataLen, 4);
|
|
197
|
+
header.write('WAVE', 8);
|
|
198
|
+
header.write('fmt ', 12);
|
|
199
|
+
header.writeUInt32LE(16, 16);
|
|
200
|
+
header.writeUInt16LE(1, 20); // PCM
|
|
201
|
+
header.writeUInt16LE(1, 22); // mono
|
|
202
|
+
header.writeUInt32LE(16000, 24); // sample rate
|
|
203
|
+
header.writeUInt32LE(32000, 28); // byte rate
|
|
204
|
+
header.writeUInt16LE(2, 32); // block align
|
|
205
|
+
header.writeUInt16LE(16, 34); // bits per sample
|
|
206
|
+
header.write('data', 36);
|
|
207
|
+
header.writeUInt32LE(dataLen, 40);
|
|
208
|
+
return Buffer.concat([header, pcm16]);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- OpenAI transcription (pure Node.js) ---
|
|
212
|
+
|
|
213
|
+
async function transcribeOpenAI(pcmB64) {
|
|
214
|
+
const apiKey = api.getSetting('openaiApiKey');
|
|
215
|
+
if (!apiKey) throw new Error('OpenAI API key not configured');
|
|
216
|
+
|
|
217
|
+
const lang = api.getSetting('language');
|
|
218
|
+
const wav = pcmToWav(pcmB64);
|
|
219
|
+
const boundary = '----B' + Date.now().toString(36) + Math.random().toString(36).slice(2);
|
|
220
|
+
|
|
221
|
+
const fields = [['model', 'whisper-1'], ['response_format', 'verbose_json']];
|
|
222
|
+
if (lang && lang !== 'auto') fields.push(['language', lang]);
|
|
223
|
+
|
|
224
|
+
let pre = '';
|
|
225
|
+
for (const [k, v] of fields) {
|
|
226
|
+
pre += `--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`;
|
|
227
|
+
}
|
|
228
|
+
pre += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.wav"\r\nContent-Type: audio/wav\r\n\r\n`;
|
|
229
|
+
const post = `\r\n--${boundary}--\r\n`;
|
|
230
|
+
|
|
231
|
+
const body = Buffer.concat([Buffer.from(pre), wav, Buffer.from(post)]);
|
|
232
|
+
|
|
233
|
+
const res = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
234
|
+
method: 'POST',
|
|
235
|
+
headers: {
|
|
236
|
+
Authorization: `Bearer ${apiKey}`,
|
|
237
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
238
|
+
},
|
|
239
|
+
body,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
if (!res.ok) throw new Error(`OpenAI ${res.status}: ${await res.text()}`);
|
|
243
|
+
const data = await res.json();
|
|
244
|
+
return { text: data.text || '', language: data.language || 'unknown', avg_logprob: null };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- Message handlers ---
|
|
248
|
+
|
|
249
|
+
api.onFrontendMessage('transcribe', async (msg) => {
|
|
250
|
+
const backend = api.getSetting('backend');
|
|
251
|
+
try {
|
|
252
|
+
let result;
|
|
253
|
+
if (backend === 'local') {
|
|
254
|
+
if (!worker) { api.sendToFrontend('error', { error: 'Local model not running. Enable plugin with local backend to start.' }); return; }
|
|
255
|
+
result = await workerCmd('transcribe', {
|
|
256
|
+
audio: msg.audio,
|
|
257
|
+
lang: api.getSetting('language') || 'auto',
|
|
258
|
+
});
|
|
259
|
+
} else {
|
|
260
|
+
result = await transcribeOpenAI(msg.audio);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const text = processText(result.text || '');
|
|
264
|
+
if (!text) {
|
|
265
|
+
api.sendToFrontend('result', { text: '', skipped: true, sessionId: msg.sessionId });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
api.sendToFrontend('result', { text, language: result.language, inferenceTime: result.inference_time, sessionId: msg.sessionId });
|
|
269
|
+
} catch (e) {
|
|
270
|
+
api.log(`transcribe: ${e.message}`);
|
|
271
|
+
api.sendToFrontend('error', { error: e.message });
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
api.onFrontendMessage('getSettings', () => {
|
|
276
|
+
api.sendToFrontend('settings', api.getSettings());
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
api.onFrontendMessage('getStatus', () => {
|
|
280
|
+
api.sendToFrontend('status', {
|
|
281
|
+
backend: api.getSetting('backend'),
|
|
282
|
+
workerRunning: !!worker,
|
|
283
|
+
workerReady,
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
api.onSettingsChange(() => {
|
|
288
|
+
api.sendToFrontend('settings', api.getSettings());
|
|
289
|
+
const enabled = api.getSetting('enabled');
|
|
290
|
+
const backend = api.getSetting('backend');
|
|
291
|
+
if (enabled && backend === 'local') {
|
|
292
|
+
if (!worker) startLocal();
|
|
293
|
+
} else {
|
|
294
|
+
killWorker();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
async function warmup() {
|
|
299
|
+
if (!worker) return;
|
|
300
|
+
try {
|
|
301
|
+
const result = await workerCmd('warmup');
|
|
302
|
+
workerReady = result.status === 'ready';
|
|
303
|
+
api.sendToFrontend('status', { backend: 'local', workerRunning: true, workerReady });
|
|
304
|
+
api.log(workerReady ? 'local model ready' : 'warmup failed');
|
|
305
|
+
} catch (e) {
|
|
306
|
+
api.log(`warmup: ${e.message}`);
|
|
307
|
+
api.sendToFrontend('error', { error: `Model warmup failed: ${e.message}` });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function wantsLocal() {
|
|
312
|
+
return api.getSetting('enabled') && api.getSetting('backend') === 'local';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let setupLock = null;
|
|
316
|
+
async function startLocal() {
|
|
317
|
+
if (setupLock) return setupLock;
|
|
318
|
+
setupLock = (async () => {
|
|
319
|
+
try {
|
|
320
|
+
await ensureEnv();
|
|
321
|
+
if (!wantsLocal()) return;
|
|
322
|
+
spawnWorker();
|
|
323
|
+
warmup();
|
|
324
|
+
} catch (e) {
|
|
325
|
+
api.log(`env setup failed: ${e.message}`);
|
|
326
|
+
api.sendToFrontend('error', { error: `Python setup failed: ${e.message}` });
|
|
327
|
+
} finally {
|
|
328
|
+
setupLock = null;
|
|
329
|
+
}
|
|
330
|
+
})();
|
|
331
|
+
return setupLock;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// --- Init ---
|
|
335
|
+
|
|
336
|
+
if (api.getSetting('enabled') && api.getSetting('backend') === 'local') {
|
|
337
|
+
startLocal();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
api.onShutdown(() => killWorker());
|
|
341
|
+
},
|
|
342
|
+
};
|
|
Binary file
|