opencode-interrupt-plugin 0.4.34 → 0.4.36
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/dist/clean-text.d.ts +6 -0
- package/dist/clean-text.js +112 -0
- package/dist/index.js +138 -11
- package/dist/language.d.ts +2 -0
- package/dist/language.js +46 -0
- package/dist/store.d.ts +1 -0
- package/dist/store.js +1 -0
- package/dist/tts/engine.d.ts +2 -0
- package/dist/tts/engine.js +5 -0
- package/dist/tts/index.d.ts +2 -0
- package/dist/tts/index.js +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/* ------------------------------------------------------------------ */
|
|
2
|
+
/* Layer 1: Regex text cleaning — always-on, no API needed */
|
|
3
|
+
/* ------------------------------------------------------------------ */
|
|
4
|
+
const FILLER_PATTERNS = [
|
|
5
|
+
/\bum+\b/gi,
|
|
6
|
+
/\buh+\b/gi,
|
|
7
|
+
/\blike\b/gi,
|
|
8
|
+
/\byou know\b/gi,
|
|
9
|
+
/\bi mean\b/gi,
|
|
10
|
+
/\bsort of\b/gi,
|
|
11
|
+
/\bkind of\b/gi,
|
|
12
|
+
/\byeah\b/gi,
|
|
13
|
+
/\bso basically\b/gi,
|
|
14
|
+
/\bright\b/gi,
|
|
15
|
+
/\bokay\b/gi,
|
|
16
|
+
/\balright\b/gi,
|
|
17
|
+
/\banyways?\b/gi,
|
|
18
|
+
/\bactually\b(?=\s+(?:the|a|an|it|i|we|you|they|he|she)\b)/gi,
|
|
19
|
+
];
|
|
20
|
+
const STUTTER_PATTERN = /\b(\w+)(?: \1\b)+/gi;
|
|
21
|
+
const LEADING_FILLER = /^(?:and |so |but |or |then |well |oh )+/i;
|
|
22
|
+
const CONSECUTIVE_SPACES = /\s{2,}/g;
|
|
23
|
+
export function cleanText(raw) {
|
|
24
|
+
let t = raw.trim();
|
|
25
|
+
if (!t)
|
|
26
|
+
return t;
|
|
27
|
+
// Remove filler words
|
|
28
|
+
for (const pat of FILLER_PATTERNS) {
|
|
29
|
+
t = t.replace(pat, '');
|
|
30
|
+
}
|
|
31
|
+
// Remove stutters / repeated words
|
|
32
|
+
t = t.replace(STUTTER_PATTERN, '$1');
|
|
33
|
+
// Remove leading fillers (false starts at beginning)
|
|
34
|
+
t = t.replace(LEADING_FILLER, '');
|
|
35
|
+
// Collapse whitespace
|
|
36
|
+
t = t.replace(CONSECUTIVE_SPACES, ' ');
|
|
37
|
+
// Capitalize first letter
|
|
38
|
+
if (t.length > 0) {
|
|
39
|
+
t = t[0].toUpperCase() + t.slice(1);
|
|
40
|
+
}
|
|
41
|
+
// Ensure ending punctuation
|
|
42
|
+
if (t.length > 0 && !/[.!?]/.test(t[t.length - 1])) {
|
|
43
|
+
t += '.';
|
|
44
|
+
}
|
|
45
|
+
return t.trim();
|
|
46
|
+
}
|
|
47
|
+
/* ------------------------------------------------------------------ */
|
|
48
|
+
/* Layers 2+3: LLM polish — uses OPENAI_API_KEY when set */
|
|
49
|
+
/* ------------------------------------------------------------------ */
|
|
50
|
+
const POLISH_SYSTEM_PROMPT = `You are a voice transcription cleaner. Your job is to take raw voice-to-text output and produce clean, concise text.
|
|
51
|
+
|
|
52
|
+
Rules:
|
|
53
|
+
1. Remove all filler words (um, uh, like, you know, etc.)
|
|
54
|
+
2. If the speaker corrected themselves mid-sentence, keep ONLY the final version
|
|
55
|
+
3. Remove false starts and abandoned sentences
|
|
56
|
+
4. Fix capitalization and punctuation
|
|
57
|
+
5. Remove repeated words
|
|
58
|
+
6. If the text is a command or request, make it direct and clear
|
|
59
|
+
7. Output ONLY the cleaned text — no explanations, no quotes, no prefixes`;
|
|
60
|
+
async function polishViaOpenAI(raw) {
|
|
61
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
62
|
+
if (!apiKey)
|
|
63
|
+
return null;
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
66
|
+
try {
|
|
67
|
+
const resp = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: {
|
|
70
|
+
'Content-Type': 'application/json',
|
|
71
|
+
Authorization: `Bearer ${apiKey}`,
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify({
|
|
74
|
+
model: 'gpt-4o-mini',
|
|
75
|
+
messages: [
|
|
76
|
+
{ role: 'system', content: POLISH_SYSTEM_PROMPT },
|
|
77
|
+
{ role: 'user', content: raw },
|
|
78
|
+
],
|
|
79
|
+
max_tokens: 500,
|
|
80
|
+
temperature: 0.1,
|
|
81
|
+
}),
|
|
82
|
+
signal: controller.signal,
|
|
83
|
+
});
|
|
84
|
+
clearTimeout(timeout);
|
|
85
|
+
if (!resp.ok)
|
|
86
|
+
return null;
|
|
87
|
+
const data = await resp.json();
|
|
88
|
+
const cleaned = data.choices?.[0]?.message?.content?.trim();
|
|
89
|
+
return cleaned || null;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
clearTimeout(timeout);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/* ------------------------------------------------------------------ */
|
|
97
|
+
/* Public pipeline */
|
|
98
|
+
/* ------------------------------------------------------------------ */
|
|
99
|
+
export async function processTranscription(raw) {
|
|
100
|
+
if (!raw)
|
|
101
|
+
return { text: raw, cleaned: false, polished: false };
|
|
102
|
+
// Layer 1: always on
|
|
103
|
+
const layer1 = cleanText(raw);
|
|
104
|
+
let polished = false;
|
|
105
|
+
// Layers 2+3: LLM polish when API key is set
|
|
106
|
+
const llmResult = await polishViaOpenAI(layer1);
|
|
107
|
+
if (llmResult && llmResult !== layer1) {
|
|
108
|
+
polished = true;
|
|
109
|
+
return { text: llmResult, cleaned: true, polished: true };
|
|
110
|
+
}
|
|
111
|
+
return { text: layer1, cleaned: true, polished: false };
|
|
112
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { resolveConfig } from './config.js';
|
|
2
2
|
import { checkLicense } from './license/guard.js';
|
|
3
3
|
import { debug } from './log.js';
|
|
4
|
+
import { processTranscription } from './clean-text.js';
|
|
5
|
+
import { detectLanguage, getVoiceForLanguage } from './language.js';
|
|
4
6
|
import { getSessionState, updateSessionState, clearSessionState, } from './store.js';
|
|
5
7
|
import { prepareInjection } from './injector.js';
|
|
6
8
|
import { onTTSStart, onTTSEnd, isTTSTool } from './audio/tts-tracker.js';
|
|
@@ -8,9 +10,71 @@ import { VoiceOverlapDetector } from './audio/overlap.js';
|
|
|
8
10
|
import { detectTextInterruption } from './detector.js';
|
|
9
11
|
import { TTSStreamer } from './tts/index.js';
|
|
10
12
|
import { spawn, execSync } from "node:child_process";
|
|
11
|
-
import { readFileSync, existsSync, unlinkSync } from "node:fs";
|
|
13
|
+
import { readFileSync, existsSync, unlinkSync, writeFileSync, mkdirSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
12
15
|
let activeSessionId = null;
|
|
13
16
|
let pendingInterrupt = null;
|
|
17
|
+
let isLicensed = false;
|
|
18
|
+
const MAX_FREE_INTERRUPTS_PER_DAY = 20;
|
|
19
|
+
const INTERRUPT_COUNT_DIR = join(process.env.HOME || '/tmp', '.cache', 'opencode');
|
|
20
|
+
const INTERRUPT_COUNT_FILE = join(INTERRUPT_COUNT_DIR, 'interrupt-count.json');
|
|
21
|
+
// simple checksum to detect casual tampering — not cryptographic
|
|
22
|
+
const COUNT_CK_KEY = 'interrupt-cap-v1';
|
|
23
|
+
function checksumCount(data) {
|
|
24
|
+
let hash = 0;
|
|
25
|
+
for (let i = 0; i < data.length; i++) {
|
|
26
|
+
hash = ((hash << 5) - hash) + data.charCodeAt(i);
|
|
27
|
+
hash |= 0;
|
|
28
|
+
}
|
|
29
|
+
const keyHash = COUNT_CK_KEY.split('').reduce((h, c) => ((h << 5) - h) + c.charCodeAt(0), 0);
|
|
30
|
+
return String((hash ^ keyHash) >>> 0);
|
|
31
|
+
}
|
|
32
|
+
function getInterruptCount() {
|
|
33
|
+
try {
|
|
34
|
+
const raw = readFileSync(INTERRUPT_COUNT_FILE, 'utf-8');
|
|
35
|
+
const parsed = JSON.parse(raw);
|
|
36
|
+
if (!parsed.date || typeof parsed.count !== 'number' || !parsed.ck)
|
|
37
|
+
return { date: '', count: 0 };
|
|
38
|
+
const expected = checksumCount(`${parsed.date}:${parsed.count}`);
|
|
39
|
+
if (parsed.ck !== expected)
|
|
40
|
+
return { date: '', count: 0 };
|
|
41
|
+
return { date: parsed.date, count: parsed.count };
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { date: '', count: 0 };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function writeInterruptCount(date, count) {
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(INTERRUPT_COUNT_DIR, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
catch { /* ignore */ }
|
|
52
|
+
const ck = checksumCount(`${date}:${count}`);
|
|
53
|
+
writeFileSync(INTERRUPT_COUNT_FILE, JSON.stringify({ date, count, ck }));
|
|
54
|
+
}
|
|
55
|
+
function checkInterruptCap() {
|
|
56
|
+
if (isLicensed)
|
|
57
|
+
return true;
|
|
58
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
59
|
+
const record = getInterruptCount();
|
|
60
|
+
if (record.date !== today) {
|
|
61
|
+
writeInterruptCount(today, 0);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return record.count < MAX_FREE_INTERRUPTS_PER_DAY;
|
|
65
|
+
}
|
|
66
|
+
function incrementInterruptCount() {
|
|
67
|
+
if (isLicensed)
|
|
68
|
+
return;
|
|
69
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
70
|
+
const record = getInterruptCount();
|
|
71
|
+
if (record.date !== today) {
|
|
72
|
+
writeInterruptCount(today, 1);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
writeInterruptCount(today, record.count + 1);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
14
78
|
const RECORDING_FILE = "/tmp/interrupt-ptt.wav";
|
|
15
79
|
let recordingProcess = null;
|
|
16
80
|
let pttActive = false;
|
|
@@ -109,6 +173,15 @@ async function transcribeAndSend(sessionID, directory, api, modelPath) {
|
|
|
109
173
|
catch { /* ignore */ }
|
|
110
174
|
return;
|
|
111
175
|
}
|
|
176
|
+
const { text: clean, polished } = await processTranscription(text);
|
|
177
|
+
if (!clean) {
|
|
178
|
+
api.ui.toast({ variant: "warning", title: "PTT", message: "⚠️ No meaningful text in recording" });
|
|
179
|
+
try {
|
|
180
|
+
unlinkSync(RECORDING_FILE);
|
|
181
|
+
}
|
|
182
|
+
catch { /* ignore */ }
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
112
185
|
try {
|
|
113
186
|
unlinkSync(RECORDING_FILE);
|
|
114
187
|
}
|
|
@@ -117,9 +190,22 @@ async function transcribeAndSend(sessionID, directory, api, modelPath) {
|
|
|
117
190
|
api.ui.toast({ variant: "warning", title: "PTT", message: "⚠️ Open a session first, then type /ptt" });
|
|
118
191
|
return;
|
|
119
192
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
193
|
+
if (!checkInterruptCap()) {
|
|
194
|
+
api.ui.toast({ variant: "warning", title: "PTT", message: "⚠️ Free limit (20/day) reached — purchase Pro for unlimited interrupts" });
|
|
195
|
+
try {
|
|
196
|
+
unlinkSync(RECORDING_FILE);
|
|
197
|
+
}
|
|
198
|
+
catch { /* ignore */ }
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const state = getSessionState(sessionID);
|
|
202
|
+
if (!state.interruptCounted)
|
|
203
|
+
incrementInterruptCount();
|
|
204
|
+
else
|
|
205
|
+
updateSessionState(sessionID, { interruptCounted: false });
|
|
206
|
+
api.ui.toast({ variant: "info", title: "PTT", message: polished ? "✨ Sending polished transcript..." : "✉️ Sending transcript..." });
|
|
207
|
+
await api.client.session.prompt({ sessionID, directory, parts: [{ type: "text", text: clean }] });
|
|
208
|
+
const preview = clean.length > 80 ? clean.slice(0, 77) + "..." : clean;
|
|
123
209
|
api.ui.toast({ variant: "success", title: "PTT", message: `✅ Sent: "${preview}"` });
|
|
124
210
|
}
|
|
125
211
|
async function transcribeAndSendV1(sessionID, client, modelPath) {
|
|
@@ -149,6 +235,15 @@ async function transcribeAndSendV1(sessionID, client, modelPath) {
|
|
|
149
235
|
catch { /* ignore */ }
|
|
150
236
|
return;
|
|
151
237
|
}
|
|
238
|
+
const { text: clean, polished } = await processTranscription(text);
|
|
239
|
+
if (!clean) {
|
|
240
|
+
await client.tui.showToast({ body: { title: "PTT", message: "⚠️ No meaningful text in recording", variant: "warning", duration: 4000 } });
|
|
241
|
+
try {
|
|
242
|
+
unlinkSync(RECORDING_FILE);
|
|
243
|
+
}
|
|
244
|
+
catch { /* ignore */ }
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
152
247
|
try {
|
|
153
248
|
unlinkSync(RECORDING_FILE);
|
|
154
249
|
}
|
|
@@ -157,9 +252,22 @@ async function transcribeAndSendV1(sessionID, client, modelPath) {
|
|
|
157
252
|
await client.tui.showToast({ body: { title: "PTT", message: "⚠️ Open a session first, then type /ptt", variant: "warning", duration: 5000 } });
|
|
158
253
|
return;
|
|
159
254
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
255
|
+
if (!checkInterruptCap()) {
|
|
256
|
+
await client.tui.showToast({ body: { title: "PTT", message: "⚠️ Free limit (20/day) reached — purchase Pro for unlimited interrupts", variant: "warning", duration: 6000 } });
|
|
257
|
+
try {
|
|
258
|
+
unlinkSync(RECORDING_FILE);
|
|
259
|
+
}
|
|
260
|
+
catch { /* ignore */ }
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const state = getSessionState(sessionID);
|
|
264
|
+
if (!state.interruptCounted)
|
|
265
|
+
incrementInterruptCount();
|
|
266
|
+
else
|
|
267
|
+
updateSessionState(sessionID, { interruptCounted: false });
|
|
268
|
+
await client.tui.showToast({ body: { title: "PTT", message: polished ? "✨ Sending polished transcript..." : "✉️ Sending transcript...", variant: "info" } });
|
|
269
|
+
await client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text: clean }] } });
|
|
270
|
+
const preview = clean.length > 80 ? clean.slice(0, 77) + "..." : clean;
|
|
163
271
|
await client.tui.showToast({ body: { title: "PTT", message: `✅ Sent: "${preview}"`, variant: "success", duration: 5000 } });
|
|
164
272
|
}
|
|
165
273
|
const TTS_COMMANDS = [
|
|
@@ -171,13 +279,14 @@ const TTS_COMMANDS = [
|
|
|
171
279
|
export const InterruptPlugin = (userConfig = {}) => {
|
|
172
280
|
return async ({ client }) => {
|
|
173
281
|
const licenseResult = await checkLicense(userConfig.licenseKey);
|
|
282
|
+
isLicensed = licenseResult.allowed;
|
|
174
283
|
const config = resolveConfig(userConfig, licenseResult.allowed);
|
|
175
284
|
if (config.debug) {
|
|
176
|
-
console.log(`[interrupt] Plugin loaded (${
|
|
285
|
+
console.log(`[interrupt] Plugin loaded (${isLicensed ? 'licensed' : 'free'} mode)`);
|
|
177
286
|
}
|
|
178
|
-
if (!
|
|
179
|
-
console.log('\n[interrupt] Free mode —
|
|
180
|
-
' Purchase a license at camaramagic.com
|
|
287
|
+
if (!isLicensed) {
|
|
288
|
+
console.log('\n[interrupt] Free mode — 20 interrupts/day, base whisper model only.\n' +
|
|
289
|
+
' Purchase a license at camaramagic.com for unlimited interrupts, model selection, TTS voices, and auto-language detection.\n');
|
|
181
290
|
}
|
|
182
291
|
if (config.tts) {
|
|
183
292
|
if (config.debug) {
|
|
@@ -210,13 +319,19 @@ export const InterruptPlugin = (userConfig = {}) => {
|
|
|
210
319
|
debug(`[interrupt] Voice overlap event buffered (no active session)`);
|
|
211
320
|
return;
|
|
212
321
|
}
|
|
322
|
+
if (!checkInterruptCap()) {
|
|
323
|
+
debug(`[interrupt] Voice overlap blocked — free limit reached`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
213
326
|
debug(`[interrupt] Voice overlap detected (RMS: ${event.rmsLevel.toFixed(4)})`);
|
|
327
|
+
incrementInterruptCount();
|
|
214
328
|
updateSessionState(activeSessionId, {
|
|
215
329
|
wasInterrupted: true,
|
|
216
330
|
partialContentAtInterrupt: event.partialTTSContent,
|
|
217
331
|
interruptTimestamp: event.overlapTimestamp,
|
|
218
332
|
awaitingCorrection: true,
|
|
219
333
|
interruptSource: 'voice',
|
|
334
|
+
interruptCounted: true,
|
|
220
335
|
});
|
|
221
336
|
});
|
|
222
337
|
overlapDetector.on('monitor-error', (err) => {
|
|
@@ -280,6 +395,14 @@ export const InterruptPlugin = (userConfig = {}) => {
|
|
|
280
395
|
if (msg.role === 'user' && !overlapDetector.isActive()) {
|
|
281
396
|
const state = getSessionState(sessionId);
|
|
282
397
|
const userText = extractText(parts);
|
|
398
|
+
if (isLicensed && userText) {
|
|
399
|
+
const detectedLang = detectLanguage(userText);
|
|
400
|
+
const newVoice = getVoiceForLanguage(detectedLang);
|
|
401
|
+
if (newVoice !== config.ttsVoice) {
|
|
402
|
+
ttsStreamer.updateVoice(newVoice);
|
|
403
|
+
debug(`[interrupt] Auto-language: ${detectedLang} → ${newVoice}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
283
406
|
const signal = detectTextInterruption(userText, state);
|
|
284
407
|
if (signal.detected) {
|
|
285
408
|
const partialContent = state.lastAssistantContent;
|
|
@@ -348,6 +471,10 @@ export const InterruptPlugin = (userConfig = {}) => {
|
|
|
348
471
|
await transcribeAndSendV1(sessionID, client, config.whisperModel);
|
|
349
472
|
}
|
|
350
473
|
else {
|
|
474
|
+
if (!checkInterruptCap()) {
|
|
475
|
+
await client.tui.showToast({ body: { title: "PTT", message: "⚠️ Free limit (20/day) reached — purchase Pro for unlimited interrupts", variant: "warning", duration: 6000 } });
|
|
476
|
+
throw new Error('Command handled by interrupt plugin');
|
|
477
|
+
}
|
|
351
478
|
pttActive = true;
|
|
352
479
|
if (sessionID) {
|
|
353
480
|
try {
|
package/dist/language.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/* ------------------------------------------------------------------ */
|
|
2
|
+
/* Auto-language detection + edge-tts voice mapping (Pro-only) */
|
|
3
|
+
/* ------------------------------------------------------------------ */
|
|
4
|
+
const CJK_RANGE = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/;
|
|
5
|
+
const HIRAGANA_KATAKANA = /[\u3040-\u309f\u30a0-\u30ff]/;
|
|
6
|
+
const CYRILLIC = /[\u0400-\u04ff]/;
|
|
7
|
+
const ARABIC = /[\u0600-\u06ff]/;
|
|
8
|
+
const DEVANAGARI = /[\u0900-\u097f]/;
|
|
9
|
+
const KOREAN = /[\uac00-\ud7af]/;
|
|
10
|
+
const LANG_MAP = [
|
|
11
|
+
{ name: 'ja', voice: 'ja-JP-NanamiNeural', test: (t) => HIRAGANA_KATAKANA.test(t) || (CJK_RANGE.test(t) && !KOREAN.test(t) && /[のをにがはがでと]$/.test(t)) },
|
|
12
|
+
{ name: 'zh', voice: 'zh-CN-XiaoxiaoNeural', test: (t) => CJK_RANGE.test(t) && !HIRAGANA_KATAKANA.test(t) && !KOREAN.test(t) },
|
|
13
|
+
{ name: 'ko', voice: 'ko-KR-SunHiNeural', test: (t) => KOREAN.test(t) },
|
|
14
|
+
{ name: 'ru', voice: 'ru-RU-SvetlanaNeural', test: (t) => CYRILLIC.test(t) },
|
|
15
|
+
{ name: 'ar', voice: 'ar-SA-ZariyahNeural', test: (t) => ARABIC.test(t) },
|
|
16
|
+
{ name: 'hi', voice: 'hi-IN-SwaraNeural', test: (t) => DEVANAGARI.test(t) },
|
|
17
|
+
{ name: 'fr', voice: 'fr-FR-DeniseNeural', test: (t) => /[àâçéèêëîïôûùüÿœæ]/i.test(t) || /\b(je|tu|nous|vous|ils|elles|est|sont|avec|pour|dans|sur|pas|les|des|ces|mes|tes|ses|leur|lequel|laquelle|comment|pourquoi|donc|mais|ou|et|car|rien|toujours|jamais|bonjour|merci|oui|non|salut|au|aux|du|de|la|le|ce|cet|cette)\b/i.test(t) },
|
|
18
|
+
{ name: 'de', voice: 'de-DE-KatjaNeural', test: (t) => /[äöüß]/i.test(t) || /\b(der|die|das|den|dem|des|ein|eine|einen|einer|einem|ist|sind|war|wird|haben|sein|kein|aber|und|oder|weil|dass|nicht|sehr|auch|noch|schon|immer|nie|bitte|danke|hallo|tschüss|ja|nein|gut|schlecht|groß|klein)\b/i.test(t) },
|
|
19
|
+
{ name: 'es', voice: 'es-ES-ElviraNeural', test: (t) => /[áéíóúüñ¿¡]/i.test(t) || /\b(el|la|los|las|un|una|unos|unas|es|son|está|están|tiene|tienen|hay|con|sin|para|por|muy|más|menos|como|qué|quién|dónde|cuándo|cuál|este|esta|ese|esa|aquel|aquella|pero|porque|y|o|no|sí|gracias|hola|adiós|bueno|malo|grande|pequeño)\b/i.test(t) },
|
|
20
|
+
{ name: 'pt', voice: 'pt-BR-FranciscaNeural', test: (t) => /[àãáâéêíóôõúç]/i.test(t) },
|
|
21
|
+
{ name: 'it', voice: 'it-IT-ElsaNeural', test: (t) => /[àèéìòù]/i.test(t) },
|
|
22
|
+
{ name: 'nl', voice: 'nl-NL-MaartenNeural', test: (t) => /\b(de|het|een|van|voor|met|niet|ook|maar)\b/i.test(t) && !/[äöüß]/i.test(t) },
|
|
23
|
+
{ name: 'pl', voice: 'pl-PL-AgnieszkaNeural', test: (t) => /[ąćęłńóśźż]/i.test(t) },
|
|
24
|
+
{ name: 'da', voice: 'da-DK-ChristelNeural', test: (t) => /[æøå]/i.test(t) },
|
|
25
|
+
{ name: 'sv', voice: 'sv-SE-SofieNeural', test: (t) => /[åäö]/i.test(t) && !/[øæ]/i.test(t) && !/\b(og|er|det|til|at|en|den|de|han|hun|jeg|vi|ikke|der|med|sig|men|som|for|på|af|eller|et|det|kan|skal|har|være|været|blevet|have|gør|gøre)\b/i.test(t) },
|
|
26
|
+
{ name: 'fi', voice: 'fi-FI-SelmaNeural', test: (t) => /[äö]/i.test(t) && !CYRILLIC.test(t) && !CJK_RANGE.test(t) && /\b(ja|on|se|ei|mutta|että|myös|tai|voidaan|tämä|ovat|hän|kanssa|voi|miten|mikä)\b/i.test(t) },
|
|
27
|
+
{ name: 'tr', voice: 'tr-TR-EmelNeural', test: (t) => /[çğıöşü]/i.test(t) && !/[äåæø]/i.test(t) },
|
|
28
|
+
{ name: 'ro', voice: 'ro-RO-AlinaNeural', test: (t) => /[ăâîșț]/i.test(t) },
|
|
29
|
+
{ name: 'hu', voice: 'hu-HU-NoemiNeural', test: (t) => /[áéíóöúű]/i.test(t) && !/[ăâîșț]/i.test(t) },
|
|
30
|
+
];
|
|
31
|
+
const DEFAULT_VOICE = 'en-US-AvaNeural';
|
|
32
|
+
export function detectLanguage(text) {
|
|
33
|
+
if (!text)
|
|
34
|
+
return 'en';
|
|
35
|
+
for (const entry of LANG_MAP) {
|
|
36
|
+
if (entry.test(text))
|
|
37
|
+
return entry.name;
|
|
38
|
+
}
|
|
39
|
+
return 'en';
|
|
40
|
+
}
|
|
41
|
+
export function getVoiceForLanguage(lang) {
|
|
42
|
+
if (lang === 'en')
|
|
43
|
+
return DEFAULT_VOICE;
|
|
44
|
+
const entry = LANG_MAP.find(e => e.name === lang);
|
|
45
|
+
return entry?.voice ?? DEFAULT_VOICE;
|
|
46
|
+
}
|
package/dist/store.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface SessionState {
|
|
|
6
6
|
interruptTimestamp: number;
|
|
7
7
|
awaitingCorrection: boolean;
|
|
8
8
|
interruptSource?: 'voice' | 'text';
|
|
9
|
+
interruptCounted: boolean;
|
|
9
10
|
}
|
|
10
11
|
export declare function getSessionState(sessionId: string): SessionState;
|
|
11
12
|
export declare function updateSessionState(sessionId: string, updates: Partial<SessionState>): SessionState;
|
package/dist/store.js
CHANGED
package/dist/tts/engine.d.ts
CHANGED
|
@@ -9,10 +9,12 @@ export declare class TTSEngine extends EventEmitter {
|
|
|
9
9
|
private generator;
|
|
10
10
|
private playing;
|
|
11
11
|
private voice;
|
|
12
|
+
get currentVoice(): string;
|
|
12
13
|
private rate;
|
|
13
14
|
private volume;
|
|
14
15
|
constructor(options?: TTSEngineOptions);
|
|
15
16
|
speak(text: string): Promise<void>;
|
|
16
17
|
stop(): void;
|
|
18
|
+
updateVoice(voice: string): void;
|
|
17
19
|
get isPlaying(): boolean;
|
|
18
20
|
}
|
package/dist/tts/engine.js
CHANGED
|
@@ -11,6 +11,7 @@ export class TTSEngine extends EventEmitter {
|
|
|
11
11
|
generator = null;
|
|
12
12
|
playing = false;
|
|
13
13
|
voice;
|
|
14
|
+
get currentVoice() { return this.voice; }
|
|
14
15
|
rate;
|
|
15
16
|
volume;
|
|
16
17
|
constructor(options = {}) {
|
|
@@ -90,6 +91,10 @@ export class TTSEngine extends EventEmitter {
|
|
|
90
91
|
}
|
|
91
92
|
this.playing = false;
|
|
92
93
|
}
|
|
94
|
+
updateVoice(voice) {
|
|
95
|
+
this.voice = voice;
|
|
96
|
+
debug(`[interrupt-tts] voice updated to ${voice}`);
|
|
97
|
+
}
|
|
93
98
|
get isPlaying() {
|
|
94
99
|
return this.playing;
|
|
95
100
|
}
|
package/dist/tts/index.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export declare class TTSStreamer extends EventEmitter {
|
|
|
29
29
|
stop(): void;
|
|
30
30
|
disable(): void;
|
|
31
31
|
enable(): void;
|
|
32
|
+
/** Pro-only feature — free tier always uses default voice */
|
|
33
|
+
updateVoice(voice: string): void;
|
|
32
34
|
getPartialContent(): string;
|
|
33
35
|
isPlaying(): boolean;
|
|
34
36
|
resetSession(): void;
|
package/dist/tts/index.js
CHANGED
|
@@ -119,6 +119,12 @@ export class TTSStreamer extends EventEmitter {
|
|
|
119
119
|
enable() {
|
|
120
120
|
this.enabled = true;
|
|
121
121
|
}
|
|
122
|
+
/** Pro-only feature — free tier always uses default voice */
|
|
123
|
+
updateVoice(voice) {
|
|
124
|
+
if (voice && voice !== this.engine.currentVoice) {
|
|
125
|
+
this.engine.updateVoice(voice);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
122
128
|
getPartialContent() {
|
|
123
129
|
if (this.spokenText.trim()) {
|
|
124
130
|
return this.spokenText.trim() + "...[interrupted]";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-interrupt-plugin",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.36",
|
|
4
4
|
"description": "Streaming TTS + voice interruption for OpenCode. Speaks responses as they arrive and detects when you talk over it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|