opencode-interrupt-plugin 0.4.35 → 0.4.37
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/index.js +98 -64
- 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
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { resolveConfig } from './config.js';
|
|
|
2
2
|
import { checkLicense } from './license/guard.js';
|
|
3
3
|
import { debug } from './log.js';
|
|
4
4
|
import { processTranscription } from './clean-text.js';
|
|
5
|
+
import { detectLanguage, getVoiceForLanguage } from './language.js';
|
|
5
6
|
import { getSessionState, updateSessionState, clearSessionState, } from './store.js';
|
|
6
7
|
import { prepareInjection } from './injector.js';
|
|
7
8
|
import { onTTSStart, onTTSEnd, isTTSTool } from './audio/tts-tracker.js';
|
|
@@ -9,9 +10,71 @@ import { VoiceOverlapDetector } from './audio/overlap.js';
|
|
|
9
10
|
import { detectTextInterruption } from './detector.js';
|
|
10
11
|
import { TTSStreamer } from './tts/index.js';
|
|
11
12
|
import { spawn, execSync } from "node:child_process";
|
|
12
|
-
import { readFileSync, existsSync, unlinkSync } from "node:fs";
|
|
13
|
+
import { readFileSync, existsSync, unlinkSync, writeFileSync, mkdirSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
13
15
|
let activeSessionId = null;
|
|
14
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
|
+
}
|
|
15
78
|
const RECORDING_FILE = "/tmp/interrupt-ptt.wav";
|
|
16
79
|
let recordingProcess = null;
|
|
17
80
|
let pttActive = false;
|
|
@@ -83,57 +146,16 @@ async function transcribeAPI() {
|
|
|
83
146
|
return null;
|
|
84
147
|
}
|
|
85
148
|
}
|
|
86
|
-
async function transcribeAndSend(sessionID,
|
|
149
|
+
async function transcribeAndSend(sessionID, client, modelPath) {
|
|
87
150
|
pttStopRecording();
|
|
88
|
-
if (!
|
|
89
|
-
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
const hasApiKey = !!process.env.OPENAI_API_KEY;
|
|
93
|
-
api.ui.toast({ variant: "info", title: "PTT", message: hasApiKey ? "⏳ Transcribing via OpenAI API..." : "⏳ Transcribing with Whisper..." });
|
|
94
|
-
let text = null;
|
|
95
|
-
if (hasApiKey) {
|
|
96
|
-
text = await transcribeAPI();
|
|
97
|
-
if (!text)
|
|
98
|
-
text = await transcribeLocal(modelPath);
|
|
99
|
-
}
|
|
100
|
-
else {
|
|
101
|
-
text = await transcribeLocal(modelPath);
|
|
102
|
-
if (!text)
|
|
103
|
-
text = await transcribeAPI();
|
|
104
|
-
}
|
|
105
|
-
if (!text) {
|
|
106
|
-
api.ui.toast({ variant: "error", title: "PTT", message: "❌ Install whisper: run scripts/install-whisper.sh, or set OPENAI_API_KEY" });
|
|
107
|
-
try {
|
|
108
|
-
unlinkSync(RECORDING_FILE);
|
|
109
|
-
}
|
|
110
|
-
catch { /* ignore */ }
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
const { text: clean, polished } = await processTranscription(text);
|
|
114
|
-
if (!clean) {
|
|
115
|
-
api.ui.toast({ variant: "warning", title: "PTT", message: "⚠️ No meaningful text in recording" });
|
|
116
|
-
try {
|
|
117
|
-
unlinkSync(RECORDING_FILE);
|
|
118
|
-
}
|
|
119
|
-
catch { /* ignore */ }
|
|
151
|
+
if (!sessionID) {
|
|
152
|
+
await client.tui.showToast({ body: { title: "PTT", message: "⚠️ Open a session first, then type /ptt", variant: "warning", duration: 5000 } });
|
|
120
153
|
return;
|
|
121
154
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
catch { /* ignore */ }
|
|
126
|
-
if (!sessionID) {
|
|
127
|
-
api.ui.toast({ variant: "warning", title: "PTT", message: "⚠️ Open a session first, then type /ptt" });
|
|
155
|
+
if (!checkInterruptCap()) {
|
|
156
|
+
await client.tui.showToast({ body: { title: "PTT", message: "⚠️ Free limit (20/day) reached — purchase Pro for unlimited interrupts", variant: "warning", duration: 6000 } });
|
|
128
157
|
return;
|
|
129
158
|
}
|
|
130
|
-
api.ui.toast({ variant: "info", title: "PTT", message: polished ? "✨ Sending polished transcript..." : "✉️ Sending transcript..." });
|
|
131
|
-
await api.client.session.prompt({ sessionID, directory, parts: [{ type: "text", text: clean }] });
|
|
132
|
-
const preview = clean.length > 80 ? clean.slice(0, 77) + "..." : clean;
|
|
133
|
-
api.ui.toast({ variant: "success", title: "PTT", message: `✅ Sent: "${preview}"` });
|
|
134
|
-
}
|
|
135
|
-
async function transcribeAndSendV1(sessionID, client, modelPath) {
|
|
136
|
-
pttStopRecording();
|
|
137
159
|
if (!existsSync(RECORDING_FILE)) {
|
|
138
160
|
await client.tui.showToast({ body: { title: "PTT", message: "⚠️ No audio captured — try again", variant: "warning" } });
|
|
139
161
|
return;
|
|
@@ -153,29 +175,22 @@ async function transcribeAndSendV1(sessionID, client, modelPath) {
|
|
|
153
175
|
}
|
|
154
176
|
if (!text) {
|
|
155
177
|
await client.tui.showToast({ body: { title: "PTT", message: "❌ Install whisper: run scripts/install-whisper.sh, or set OPENAI_API_KEY", variant: "error", duration: 8000 } });
|
|
156
|
-
try {
|
|
157
|
-
unlinkSync(RECORDING_FILE);
|
|
158
|
-
}
|
|
159
|
-
catch { /* ignore */ }
|
|
160
178
|
return;
|
|
161
179
|
}
|
|
162
180
|
const { text: clean, polished } = await processTranscription(text);
|
|
163
181
|
if (!clean) {
|
|
164
182
|
await client.tui.showToast({ body: { title: "PTT", message: "⚠️ No meaningful text in recording", variant: "warning", duration: 4000 } });
|
|
165
|
-
try {
|
|
166
|
-
unlinkSync(RECORDING_FILE);
|
|
167
|
-
}
|
|
168
|
-
catch { /* ignore */ }
|
|
169
183
|
return;
|
|
170
184
|
}
|
|
171
185
|
try {
|
|
172
186
|
unlinkSync(RECORDING_FILE);
|
|
173
187
|
}
|
|
174
188
|
catch { /* ignore */ }
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
189
|
+
const state = getSessionState(sessionID);
|
|
190
|
+
if (!state.interruptCounted)
|
|
191
|
+
incrementInterruptCount();
|
|
192
|
+
else
|
|
193
|
+
updateSessionState(sessionID, { interruptCounted: false });
|
|
179
194
|
await client.tui.showToast({ body: { title: "PTT", message: polished ? "✨ Sending polished transcript..." : "✉️ Sending transcript...", variant: "info" } });
|
|
180
195
|
await client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: "text", text: clean }] } });
|
|
181
196
|
const preview = clean.length > 80 ? clean.slice(0, 77) + "..." : clean;
|
|
@@ -190,13 +205,14 @@ const TTS_COMMANDS = [
|
|
|
190
205
|
export const InterruptPlugin = (userConfig = {}) => {
|
|
191
206
|
return async ({ client }) => {
|
|
192
207
|
const licenseResult = await checkLicense(userConfig.licenseKey);
|
|
208
|
+
isLicensed = licenseResult.allowed;
|
|
193
209
|
const config = resolveConfig(userConfig, licenseResult.allowed);
|
|
194
210
|
if (config.debug) {
|
|
195
|
-
console.log(`[interrupt] Plugin loaded (${
|
|
211
|
+
console.log(`[interrupt] Plugin loaded (${isLicensed ? 'licensed' : 'free'} mode)`);
|
|
196
212
|
}
|
|
197
|
-
if (!
|
|
198
|
-
console.log('\n[interrupt] Free mode —
|
|
199
|
-
' Purchase a license at camaramagic.com
|
|
213
|
+
if (!isLicensed) {
|
|
214
|
+
console.log('\n[interrupt] Free mode — 20 interrupts/day, base whisper model only.\n' +
|
|
215
|
+
' Purchase a license at camaramagic.com for unlimited interrupts, model selection, TTS voices, and auto-language detection.\n');
|
|
200
216
|
}
|
|
201
217
|
if (config.tts) {
|
|
202
218
|
if (config.debug) {
|
|
@@ -229,13 +245,19 @@ export const InterruptPlugin = (userConfig = {}) => {
|
|
|
229
245
|
debug(`[interrupt] Voice overlap event buffered (no active session)`);
|
|
230
246
|
return;
|
|
231
247
|
}
|
|
248
|
+
if (!checkInterruptCap()) {
|
|
249
|
+
debug(`[interrupt] Voice overlap blocked — free limit reached`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
232
252
|
debug(`[interrupt] Voice overlap detected (RMS: ${event.rmsLevel.toFixed(4)})`);
|
|
253
|
+
incrementInterruptCount();
|
|
233
254
|
updateSessionState(activeSessionId, {
|
|
234
255
|
wasInterrupted: true,
|
|
235
256
|
partialContentAtInterrupt: event.partialTTSContent,
|
|
236
257
|
interruptTimestamp: event.overlapTimestamp,
|
|
237
258
|
awaitingCorrection: true,
|
|
238
259
|
interruptSource: 'voice',
|
|
260
|
+
interruptCounted: true,
|
|
239
261
|
});
|
|
240
262
|
});
|
|
241
263
|
overlapDetector.on('monitor-error', (err) => {
|
|
@@ -299,6 +321,14 @@ export const InterruptPlugin = (userConfig = {}) => {
|
|
|
299
321
|
if (msg.role === 'user' && !overlapDetector.isActive()) {
|
|
300
322
|
const state = getSessionState(sessionId);
|
|
301
323
|
const userText = extractText(parts);
|
|
324
|
+
if (isLicensed && userText) {
|
|
325
|
+
const detectedLang = detectLanguage(userText);
|
|
326
|
+
const newVoice = getVoiceForLanguage(detectedLang);
|
|
327
|
+
if (newVoice !== config.ttsVoice) {
|
|
328
|
+
ttsStreamer.updateVoice(newVoice);
|
|
329
|
+
debug(`[interrupt] Auto-language: ${detectedLang} → ${newVoice}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
302
332
|
const signal = detectTextInterruption(userText, state);
|
|
303
333
|
if (signal.detected) {
|
|
304
334
|
const partialContent = state.lastAssistantContent;
|
|
@@ -364,9 +394,13 @@ export const InterruptPlugin = (userConfig = {}) => {
|
|
|
364
394
|
const sessionID = cmdInput.sessionID;
|
|
365
395
|
if (pttActive) {
|
|
366
396
|
pttActive = false;
|
|
367
|
-
await
|
|
397
|
+
await transcribeAndSend(sessionID, client, config.whisperModel);
|
|
368
398
|
}
|
|
369
399
|
else {
|
|
400
|
+
if (!checkInterruptCap()) {
|
|
401
|
+
await client.tui.showToast({ body: { title: "PTT", message: "⚠️ Free limit (20/day) reached — purchase Pro for unlimited interrupts", variant: "warning", duration: 6000 } });
|
|
402
|
+
throw new Error('Command handled by interrupt plugin');
|
|
403
|
+
}
|
|
370
404
|
pttActive = true;
|
|
371
405
|
if (sessionID) {
|
|
372
406
|
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.37",
|
|
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",
|