opencode-interrupt-plugin 0.3.1 → 0.4.1

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.
@@ -1,13 +1,12 @@
1
1
  import { EventEmitter } from 'events';
2
2
  export declare class AudioMonitor extends EventEmitter {
3
- private process;
3
+ private pollTimer;
4
4
  private isRunning;
5
- private silenceTimer;
6
5
  private threshold;
7
- private silenceMs;
8
- constructor(threshold?: number, silenceMs?: number);
6
+ private pollMs;
7
+ constructor(threshold?: number, pollMs?: number);
9
8
  start(): void;
10
- private tryFallbackMonitor;
9
+ private poll;
11
10
  stop(): void;
12
11
  private handleRMS;
13
12
  setThreshold(threshold: number): void;
@@ -1,96 +1,60 @@
1
1
  import { spawn } from 'child_process';
2
2
  import { EventEmitter } from 'events';
3
3
  export class AudioMonitor extends EventEmitter {
4
- process = null;
4
+ pollTimer = null;
5
5
  isRunning = false;
6
- silenceTimer = null;
7
6
  threshold;
8
- silenceMs;
9
- constructor(threshold = 0.02, silenceMs = 300) {
7
+ pollMs;
8
+ constructor(threshold = 0.02, pollMs = 100) {
10
9
  super();
11
10
  this.threshold = threshold;
12
- this.silenceMs = silenceMs;
11
+ this.pollMs = pollMs;
13
12
  }
14
13
  start() {
15
14
  if (this.isRunning)
16
15
  return;
17
- this.process = spawn('sox', [
18
- '-q',
19
- '-d',
16
+ this.isRunning = true;
17
+ this.emit('started');
18
+ this.poll();
19
+ }
20
+ poll() {
21
+ if (!this.isRunning)
22
+ return;
23
+ const proc = spawn('sox', [
24
+ '-q', '-d',
20
25
  '-t', 'sox', '-',
26
+ 'trim', '0', '0.1',
21
27
  'stat',
22
- '-freq',
23
- ], {
24
- stdio: ['ignore', 'pipe', 'pipe'],
25
- });
26
- let buffer = '';
27
- this.process.stderr?.on('data', (chunk) => {
28
- buffer += chunk.toString();
29
- const lines = buffer.split('\n');
30
- buffer = lines.pop() || '';
31
- for (const line of lines) {
32
- const rms = parseRMSLine(line);
33
- if (rms !== null) {
34
- this.handleRMS(rms);
35
- }
28
+ ], { stdio: ['ignore', 'ignore', 'pipe'] });
29
+ let out = '';
30
+ proc.stderr?.on('data', (d) => { out += d.toString(); });
31
+ proc.on('exit', () => {
32
+ if (!this.isRunning)
33
+ return;
34
+ const rms = parseRMSFromStat(out);
35
+ if (rms !== null) {
36
+ this.handleRMS(rms);
36
37
  }
38
+ this.pollTimer = setTimeout(() => this.poll(), this.pollMs);
37
39
  });
38
- this.process.on('error', () => {
39
- this.tryFallbackMonitor();
40
- });
41
- this.process.on('exit', () => {
42
- this.isRunning = false;
43
- this.emit('stopped');
44
- });
45
- this.isRunning = true;
46
- this.emit('started');
47
- }
48
- tryFallbackMonitor() {
49
- const poll = () => {
40
+ proc.on('error', () => {
50
41
  if (!this.isRunning)
51
42
  return;
52
- const proc = spawn('sox', [
53
- '-q', '-d',
54
- '-t', 'sox', '-',
55
- 'trim', '0', '0.1',
56
- 'stat',
57
- ], { stdio: ['ignore', 'ignore', 'pipe'] });
58
- let out = '';
59
- proc.stderr?.on('data', (d) => { out += d.toString(); });
60
- proc.on('exit', () => {
61
- const rms = parseRMSFromStat(out);
62
- if (rms !== null)
63
- this.handleRMS(rms);
64
- setTimeout(poll, 100);
65
- });
66
- proc.on('error', () => {
67
- this.emit('error', new Error('sox not found. Install it: brew install sox (macOS) or apt install sox (Linux)'));
68
- this.isRunning = false;
69
- });
70
- };
71
- this.isRunning = true;
72
- poll();
43
+ this.emit('error', new Error('sox not found. Install it: brew install sox (macOS) or apt install sox (Linux)'));
44
+ this.isRunning = false;
45
+ });
73
46
  }
74
47
  stop() {
75
48
  this.isRunning = false;
76
- if (this.process) {
77
- this.process.kill('SIGTERM');
78
- this.process = null;
79
- }
80
- if (this.silenceTimer) {
81
- clearTimeout(this.silenceTimer);
82
- this.silenceTimer = null;
49
+ if (this.pollTimer) {
50
+ clearTimeout(this.pollTimer);
51
+ this.pollTimer = null;
83
52
  }
84
53
  this.emit('stopped');
85
54
  }
86
55
  handleRMS(rms) {
87
56
  if (rms > this.threshold) {
88
57
  this.emit('voice-detected', rms);
89
- if (this.silenceTimer)
90
- clearTimeout(this.silenceTimer);
91
- this.silenceTimer = setTimeout(() => {
92
- this.emit('silence');
93
- }, this.silenceMs);
94
58
  }
95
59
  }
96
60
  setThreshold(threshold) {
@@ -9,7 +9,10 @@ export declare class VoiceOverlapDetector extends EventEmitter {
9
9
  private active;
10
10
  private cooldownUntil;
11
11
  private readonly COOLDOWN_MS;
12
- constructor(threshold?: number);
12
+ private getIsTTSPlaying;
13
+ private stopTTS;
14
+ private getPartialContent;
15
+ constructor(threshold?: number, getIsTTSPlaying?: () => boolean, stopTTS?: () => void, getPartialContent?: () => string);
13
16
  start(): void;
14
17
  stop(): void;
15
18
  updateThreshold(threshold: number): void;
@@ -1,14 +1,19 @@
1
1
  import { AudioMonitor } from './monitor.js';
2
- import { isTTSPlaying, getPartialTTSContent, onTTSEnd } from './tts-tracker.js';
3
2
  import { EventEmitter } from 'events';
4
3
  export class VoiceOverlapDetector extends EventEmitter {
5
4
  monitor;
6
5
  active = false;
7
6
  cooldownUntil = 0;
8
7
  COOLDOWN_MS = 3000;
9
- constructor(threshold = 0.02) {
8
+ getIsTTSPlaying;
9
+ stopTTS;
10
+ getPartialContent;
11
+ constructor(threshold = 0.02, getIsTTSPlaying = () => false, stopTTS = () => { }, getPartialContent = () => '') {
10
12
  super();
11
13
  this.monitor = new AudioMonitor(threshold);
14
+ this.getIsTTSPlaying = getIsTTSPlaying;
15
+ this.stopTTS = stopTTS;
16
+ this.getPartialContent = getPartialContent;
12
17
  this.monitor.on('voice-detected', (rms) => {
13
18
  this.handleVoiceDetected(rms);
14
19
  });
@@ -30,14 +35,14 @@ export class VoiceOverlapDetector extends EventEmitter {
30
35
  this.monitor.setThreshold(threshold);
31
36
  }
32
37
  handleVoiceDetected(rms) {
33
- if (!isTTSPlaying())
38
+ if (!this.getIsTTSPlaying())
34
39
  return;
35
40
  if (Date.now() < this.cooldownUntil)
36
41
  return;
37
42
  const now = Date.now();
38
43
  this.cooldownUntil = now + this.COOLDOWN_MS;
39
- const partialContent = getPartialTTSContent(now);
40
- onTTSEnd();
44
+ const partialContent = this.getPartialContent();
45
+ this.stopTTS();
41
46
  const event = {
42
47
  partialTTSContent: partialContent,
43
48
  overlapTimestamp: now,
@@ -1,11 +1,8 @@
1
1
  export interface TTSState {
2
2
  isPlaying: boolean;
3
3
  startedAt: number;
4
- content: string;
5
- estimatedDurationMs: number;
6
4
  }
7
- export declare function onTTSStart(content: string): void;
5
+ export declare function onTTSStart(): void;
8
6
  export declare function onTTSEnd(): void;
9
7
  export declare function isTTSPlaying(): boolean;
10
8
  export declare function isTTSTool(toolName: string): boolean;
11
- export declare function getPartialTTSContent(elapsedMs: number): string;
@@ -1,25 +1,10 @@
1
- const TTS_TOOL_NAMES = new Set(['speak', 'tts', 'text_to_speech', 'say']);
2
1
  const ttsState = {
3
2
  isPlaying: false,
4
3
  startedAt: 0,
5
- content: '',
6
- estimatedDurationMs: 0,
7
4
  };
8
- function estimateDuration(text) {
9
- const words = text.split(/\s+/).length;
10
- const durationMs = (words / 2.5) * 1000;
11
- return Math.max(500, Math.min(120000, durationMs));
12
- }
13
- export function onTTSStart(content) {
5
+ export function onTTSStart() {
14
6
  ttsState.isPlaying = true;
15
7
  ttsState.startedAt = Date.now();
16
- ttsState.content = content;
17
- ttsState.estimatedDurationMs = estimateDuration(content);
18
- setTimeout(() => {
19
- if (ttsState.isPlaying && Date.now() - ttsState.startedAt > ttsState.estimatedDurationMs) {
20
- ttsState.isPlaying = false;
21
- }
22
- }, ttsState.estimatedDurationMs + 2000);
23
8
  }
24
9
  export function onTTSEnd() {
25
10
  ttsState.isPlaying = false;
@@ -28,10 +13,5 @@ export function isTTSPlaying() {
28
13
  return ttsState.isPlaying;
29
14
  }
30
15
  export function isTTSTool(toolName) {
31
- return TTS_TOOL_NAMES.has(toolName.toLowerCase());
32
- }
33
- export function getPartialTTSContent(elapsedMs) {
34
- const progress = Math.min(elapsedMs / ttsState.estimatedDurationMs, 0.95);
35
- const charIndex = Math.floor(ttsState.content.length * progress);
36
- return ttsState.content.slice(0, charIndex) + '...[interrupted]';
16
+ return ['speak', 'tts', 'text_to_speech', 'say'].includes(toolName.toLowerCase());
37
17
  }
package/dist/config.d.ts CHANGED
@@ -7,6 +7,9 @@ export interface PluginConfig {
7
7
  minResponseLength?: number;
8
8
  voiceDetection?: boolean;
9
9
  debug?: boolean;
10
+ tts?: boolean;
11
+ ttsVoice?: string;
12
+ ttsRate?: string;
10
13
  }
11
14
  export interface ResolvedConfig {
12
15
  licenseKey?: string;
@@ -19,6 +22,9 @@ export interface ResolvedConfig {
19
22
  voiceDetection: boolean;
20
23
  voiceMode: string;
21
24
  debug: boolean;
25
+ tts: boolean;
26
+ ttsVoice: string;
27
+ ttsRate: string;
22
28
  }
23
29
  export declare const FREE_DEFAULTS: ResolvedConfig;
24
30
  export declare const SENSITIVITY_PRESETS: Record<string, Partial<ResolvedConfig>>;
package/dist/config.js CHANGED
@@ -1,3 +1,5 @@
1
+ const DEFAULT_TTS_VOICE = "en-US-AvaNeural";
2
+ const DEFAULT_TTS_RATE = "+25%";
1
3
  export const FREE_DEFAULTS = {
2
4
  isLicensed: false,
3
5
  micThreshold: 0.02,
@@ -8,6 +10,9 @@ export const FREE_DEFAULTS = {
8
10
  voiceDetection: true,
9
11
  voiceMode: 'enabled',
10
12
  debug: false,
13
+ tts: true,
14
+ ttsVoice: DEFAULT_TTS_VOICE,
15
+ ttsRate: DEFAULT_TTS_RATE,
11
16
  };
12
17
  export const SENSITIVITY_PRESETS = {
13
18
  low: {
@@ -41,6 +46,9 @@ export function resolveConfig(userConfig, isLicensed) {
41
46
  voiceDetection: userConfig.voiceDetection ?? FREE_DEFAULTS.voiceDetection,
42
47
  debug: userConfig.debug ?? FREE_DEFAULTS.debug,
43
48
  licenseKey: userConfig.licenseKey,
49
+ tts: userConfig.tts ?? FREE_DEFAULTS.tts,
50
+ ttsVoice: userConfig.ttsVoice ?? FREE_DEFAULTS.ttsVoice,
51
+ ttsRate: userConfig.ttsRate ?? FREE_DEFAULTS.ttsRate,
44
52
  };
45
53
  const preset = SENSITIVITY_PRESETS[base.sensitivity];
46
54
  if (!userConfig.timingWindowMs)
package/dist/index.js CHANGED
@@ -5,7 +5,13 @@ import { prepareInjection } from './injector.js';
5
5
  import { onTTSStart, onTTSEnd, isTTSTool } from './audio/tts-tracker.js';
6
6
  import { VoiceOverlapDetector } from './audio/overlap.js';
7
7
  import { detectTextInterruption } from './detector.js';
8
+ import { TTSStreamer } from './tts/index.js';
8
9
  let activeSessionId = null;
10
+ const TTS_COMMANDS = [
11
+ { name: 'tts-on', description: 'Enable streaming TTS', template: 'TTS enabled.' },
12
+ { name: 'tts-off', description: 'Disable streaming TTS', template: 'TTS disabled.' },
13
+ { name: 'tts-speak', description: 'Speak text immediately', template: '$ARGUMENTS' },
14
+ ];
9
15
  export const InterruptPlugin = (userConfig = {}) => {
10
16
  return async ({ client }) => {
11
17
  const licenseResult = await checkLicense(userConfig.licenseKey);
@@ -17,12 +23,36 @@ export const InterruptPlugin = (userConfig = {}) => {
17
23
  console.log('\n[interrupt] Free mode — voice interruption enabled with default settings.\n' +
18
24
  ' Purchase a license at camaramagic.com to customize sensitivity, mic threshold, and more.\n');
19
25
  }
20
- const overlapDetector = new VoiceOverlapDetector(config.micThreshold);
26
+ if (config.tts) {
27
+ if (config.debug) {
28
+ console.log(`[interrupt] Streaming TTS enabled (voice: ${config.ttsVoice}, rate: ${config.ttsRate})`);
29
+ }
30
+ }
31
+ const ttsStreamer = new TTSStreamer({
32
+ enabled: config.tts,
33
+ voice: config.ttsVoice,
34
+ rate: config.ttsRate,
35
+ });
36
+ ttsStreamer.on('tts-start', () => {
37
+ onTTSStart();
38
+ });
39
+ ttsStreamer.on('tts-end', () => {
40
+ onTTSEnd();
41
+ });
42
+ ttsStreamer.on('tts-error', (err) => {
43
+ if (config.debug) {
44
+ console.error(`[interrupt] TTS error: ${err}`);
45
+ }
46
+ });
47
+ const overlapDetector = new VoiceOverlapDetector(config.micThreshold, () => ttsStreamer.isPlaying(), () => {
48
+ ttsStreamer.stop();
49
+ onTTSEnd();
50
+ }, () => ttsStreamer.getPartialContent());
21
51
  overlapDetector.on('voice-overlap', (event) => {
22
52
  if (!activeSessionId)
23
53
  return;
24
54
  if (config.debug) {
25
- console.log(`[interrupt] Voice overlap (RMS: ${event.rmsLevel.toFixed(4)})`);
55
+ console.log(`[interrupt] Voice overlap detected (RMS: ${event.rmsLevel.toFixed(4)})`);
26
56
  }
27
57
  updateSessionState(activeSessionId, {
28
58
  wasInterrupted: true,
@@ -42,16 +72,35 @@ export const InterruptPlugin = (userConfig = {}) => {
42
72
  return {
43
73
  event: async ({ event }) => {
44
74
  const evt = event;
45
- const sessionId = evt.session_id || evt.properties?.info?.id;
75
+ const sessionId = evt.session_id || evt.properties?.info?.id || evt.properties?.sessionID;
46
76
  if (evt.type === 'session.created' && sessionId) {
47
77
  activeSessionId = sessionId;
48
78
  getSessionState(sessionId);
79
+ ttsStreamer.resetSession();
49
80
  }
50
81
  if (evt.type === 'session.deleted' && sessionId) {
51
82
  clearSessionState(sessionId);
83
+ ttsStreamer.resetSession();
52
84
  if (activeSessionId === sessionId)
53
85
  activeSessionId = null;
54
86
  }
87
+ if (evt.type === 'message.part.updated') {
88
+ const part = evt.properties?.part;
89
+ if (part && part.type === 'text' && part.text) {
90
+ if (config.tts) {
91
+ ttsStreamer.onTextChunk({
92
+ messageID: part.messageID,
93
+ type: part.type,
94
+ text: part.text,
95
+ });
96
+ }
97
+ }
98
+ }
99
+ if (evt.type === 'session.idle') {
100
+ if (config.tts) {
101
+ await ttsStreamer.flush();
102
+ }
103
+ }
55
104
  },
56
105
  'chat.message': async (input, output) => {
57
106
  const sessionId = input.sessionID;
@@ -110,12 +159,41 @@ export const InterruptPlugin = (userConfig = {}) => {
110
159
  });
111
160
  }
112
161
  },
162
+ config: async (input) => {
163
+ if (!input.command)
164
+ input.command = {};
165
+ for (const cmd of TTS_COMMANDS) {
166
+ input.command[cmd.name] = {
167
+ description: cmd.description,
168
+ template: cmd.template,
169
+ };
170
+ }
171
+ },
172
+ 'command.execute.before': async (cmdInput) => {
173
+ if (!cmdInput.command.startsWith('tts-'))
174
+ return;
175
+ if (cmdInput.command === 'tts-on') {
176
+ ttsStreamer.enable();
177
+ throw new Error('Command handled by interrupt plugin');
178
+ }
179
+ if (cmdInput.command === 'tts-off') {
180
+ ttsStreamer.disable();
181
+ throw new Error('Command handled by interrupt plugin');
182
+ }
183
+ if (cmdInput.command === 'tts-speak') {
184
+ const text = cmdInput.arguments.trim();
185
+ if (text) {
186
+ ttsStreamer.speakNow(text);
187
+ }
188
+ throw new Error('Command handled by interrupt plugin');
189
+ }
190
+ },
113
191
  'tool.execute.before': async (input, output) => {
114
192
  const toolName = input.tool || '';
115
193
  const args = output?.args || {};
116
194
  if (isTTSTool(toolName)) {
117
195
  const text = args.text || args.content || '';
118
- onTTSStart(text);
196
+ onTTSStart();
119
197
  }
120
198
  },
121
199
  'tool.execute.after': async (input, output) => {
@@ -0,0 +1,12 @@
1
+ export declare class SentenceBuffer {
2
+ private buffer;
3
+ private processedLength;
4
+ feed(chunk: {
5
+ messageID: string;
6
+ type: string;
7
+ text: string;
8
+ }): string[];
9
+ flush(): string;
10
+ clear(): void;
11
+ get length(): number;
12
+ }
@@ -0,0 +1,44 @@
1
+ export class SentenceBuffer {
2
+ buffer = "";
3
+ processedLength = new Map();
4
+ feed(chunk) {
5
+ if (chunk.type !== "text")
6
+ return [];
7
+ const lastLen = this.processedLength.get(chunk.messageID) ?? 0;
8
+ if (chunk.text.length <= lastLen)
9
+ return [];
10
+ const delta = chunk.text.slice(lastLen);
11
+ this.processedLength.set(chunk.messageID, chunk.text.length);
12
+ this.buffer += delta;
13
+ const sentences = [];
14
+ const re = /[.!?](?:\s|$)/g;
15
+ let match;
16
+ while ((match = re.exec(this.buffer)) !== null) {
17
+ const end = match.index + match[0].length;
18
+ const sentence = this.buffer.slice(0, end).trim();
19
+ if (sentence)
20
+ sentences.push(sentence);
21
+ this.buffer = this.buffer.slice(end).trim();
22
+ re.lastIndex = 0;
23
+ }
24
+ if (this.buffer.length >= 120) {
25
+ const forced = this.buffer.trim();
26
+ if (forced)
27
+ sentences.push(forced);
28
+ this.buffer = "";
29
+ }
30
+ return sentences;
31
+ }
32
+ flush() {
33
+ const remaining = this.buffer.trim();
34
+ this.buffer = "";
35
+ return remaining;
36
+ }
37
+ clear() {
38
+ this.buffer = "";
39
+ this.processedLength.clear();
40
+ }
41
+ get length() {
42
+ return this.buffer.length;
43
+ }
44
+ }
@@ -0,0 +1,18 @@
1
+ import { EventEmitter } from "events";
2
+ export interface TTSEngineOptions {
3
+ voice?: string;
4
+ rate?: string;
5
+ volume?: string;
6
+ }
7
+ export declare class TTSEngine extends EventEmitter {
8
+ private player;
9
+ private generator;
10
+ private playing;
11
+ private voice;
12
+ private rate;
13
+ private volume;
14
+ constructor(options?: TTSEngineOptions);
15
+ speak(text: string): Promise<void>;
16
+ stop(): void;
17
+ get isPlaying(): boolean;
18
+ }
@@ -0,0 +1,94 @@
1
+ import { spawn } from "child_process";
2
+ import { EventEmitter } from "events";
3
+ import { join } from "path";
4
+ const VENV_PYTHON = join(process.env.HOME || "/home/pystar", ".config", "opencode", "tts-venv", "bin", "python");
5
+ const DEFAULT_VOICE = "en-US-AvaNeural";
6
+ const DEFAULT_RATE = "+25%";
7
+ const DEFAULT_VOLUME = "+0%";
8
+ export class TTSEngine extends EventEmitter {
9
+ player = null;
10
+ generator = null;
11
+ playing = false;
12
+ voice;
13
+ rate;
14
+ volume;
15
+ constructor(options = {}) {
16
+ super();
17
+ this.voice = options.voice ?? DEFAULT_VOICE;
18
+ this.rate = options.rate ?? DEFAULT_RATE;
19
+ this.volume = options.volume ?? DEFAULT_VOLUME;
20
+ }
21
+ speak(text) {
22
+ if (!text.trim())
23
+ return Promise.resolve();
24
+ const t0 = Date.now();
25
+ return new Promise((resolve, reject) => {
26
+ this.generator = spawn(VENV_PYTHON, [
27
+ "-m", "edge_tts",
28
+ "--voice", this.voice,
29
+ "--rate", this.rate,
30
+ "--volume", this.volume,
31
+ "--text", text,
32
+ ], { stdio: ["ignore", "pipe", "pipe"] });
33
+ this.player = spawn("ffplay", [
34
+ "-nodisp", "-autoexit", "-loglevel", "quiet", "-i", "-",
35
+ ], { stdio: ["pipe", "ignore", "ignore"] });
36
+ this.generator.stdout.pipe(this.player.stdin);
37
+ this.playing = true;
38
+ this.emit("playback-start");
39
+ let genDone = false;
40
+ let playDone = false;
41
+ const checkDone = () => {
42
+ if (genDone && playDone) {
43
+ console.log(`[interrupt-tts] stream done text="${text.slice(0, 60)}" total=${Date.now() - t0}ms`);
44
+ resolve();
45
+ }
46
+ };
47
+ this.generator.on("close", (code) => {
48
+ genDone = true;
49
+ if (code !== 0) {
50
+ console.log(`[interrupt-tts] edge-tts exited ${code}`);
51
+ }
52
+ checkDone();
53
+ });
54
+ this.generator.on("error", (err) => {
55
+ genDone = true;
56
+ this.emit("error", `edge-tts: ${err.message}`);
57
+ if (this.player) {
58
+ this.player.kill();
59
+ this.player = null;
60
+ }
61
+ this.playing = false;
62
+ reject(err);
63
+ });
64
+ this.player.on("close", (code) => {
65
+ playDone = true;
66
+ this.player = null;
67
+ this.playing = false;
68
+ this.emit("playback-end", { interrupted: false });
69
+ checkDone();
70
+ });
71
+ this.player.on("error", () => {
72
+ playDone = true;
73
+ this.player = null;
74
+ this.playing = false;
75
+ this.emit("playback-end", { interrupted: false });
76
+ reject(new Error("ffplay not found. Install ffmpeg."));
77
+ });
78
+ });
79
+ }
80
+ stop() {
81
+ if (this.generator) {
82
+ this.generator.kill("SIGTERM");
83
+ this.generator = null;
84
+ }
85
+ if (this.player) {
86
+ this.player.kill("SIGTERM");
87
+ this.player = null;
88
+ }
89
+ this.playing = false;
90
+ }
91
+ get isPlaying() {
92
+ return this.playing;
93
+ }
94
+ }
@@ -0,0 +1,36 @@
1
+ import { EventEmitter } from "events";
2
+ export interface TTSStreamerOptions {
3
+ enabled?: boolean;
4
+ voice?: string;
5
+ rate?: string;
6
+ volume?: string;
7
+ }
8
+ export declare class TTSStreamer extends EventEmitter {
9
+ private buffer;
10
+ private engine;
11
+ private queue;
12
+ private processing;
13
+ private stopped;
14
+ private spokenText;
15
+ private enabled;
16
+ private forceTimer;
17
+ private readonly FORCE_FLUSH_MS;
18
+ private lastActivity;
19
+ constructor(options?: TTSStreamerOptions);
20
+ onTextChunk(chunk: {
21
+ messageID: string;
22
+ type: string;
23
+ text: string;
24
+ }): void;
25
+ private scheduleForceFlush;
26
+ private cancelForceFlush;
27
+ flush(): Promise<void>;
28
+ speakNow(text: string): void;
29
+ stop(): void;
30
+ disable(): void;
31
+ enable(): void;
32
+ getPartialContent(): string;
33
+ isPlaying(): boolean;
34
+ resetSession(): void;
35
+ private processQueue;
36
+ }
@@ -0,0 +1,147 @@
1
+ import { EventEmitter } from "events";
2
+ import { SentenceBuffer } from "./buffer.js";
3
+ import { TTSEngine } from "./engine.js";
4
+ export class TTSStreamer extends EventEmitter {
5
+ buffer = new SentenceBuffer();
6
+ engine;
7
+ queue = [];
8
+ processing = false;
9
+ stopped = false;
10
+ spokenText = "";
11
+ enabled;
12
+ forceTimer = null;
13
+ FORCE_FLUSH_MS = 1500;
14
+ lastActivity = 0;
15
+ constructor(options = {}) {
16
+ super();
17
+ this.enabled = options.enabled !== false;
18
+ this.engine = new TTSEngine({
19
+ voice: options.voice,
20
+ rate: options.rate,
21
+ volume: options.volume,
22
+ });
23
+ this.engine.on("playback-start", () => {
24
+ this.lastActivity = Date.now();
25
+ this.emit("tts-start");
26
+ });
27
+ this.engine.on("playback-end", ({ interrupted }) => {
28
+ this.emit("tts-end", { interrupted });
29
+ });
30
+ this.engine.on("error", (err) => {
31
+ this.emit("tts-error", err);
32
+ });
33
+ }
34
+ onTextChunk(chunk) {
35
+ if (!this.enabled)
36
+ return;
37
+ this.lastActivity = Date.now();
38
+ const sentences = this.buffer.feed(chunk);
39
+ console.log(`[interrupt-tts] chunk len=${chunk.text.length} sentences=${sentences.length} queue=${this.queue.length} processing=${this.processing} buflen=${this.buffer.length}`);
40
+ if (sentences.length > 0) {
41
+ this.cancelForceFlush();
42
+ for (const s of sentences) {
43
+ this.queue.push(s);
44
+ }
45
+ if (!this.processing) {
46
+ this.processQueue();
47
+ }
48
+ }
49
+ else if (this.buffer.length > 0 && !this.processing) {
50
+ this.scheduleForceFlush();
51
+ }
52
+ }
53
+ scheduleForceFlush() {
54
+ this.cancelForceFlush();
55
+ this.forceTimer = setTimeout(() => {
56
+ if (!this.enabled || this.stopped || this.buffer.length === 0)
57
+ return;
58
+ const text = this.buffer.flush();
59
+ if (text) {
60
+ console.log(`[interrupt-tts] force-flush "${text.slice(0, 60)}"`);
61
+ this.queue.push(text);
62
+ if (!this.processing) {
63
+ this.processQueue();
64
+ }
65
+ }
66
+ }, this.FORCE_FLUSH_MS);
67
+ }
68
+ cancelForceFlush() {
69
+ if (this.forceTimer) {
70
+ clearTimeout(this.forceTimer);
71
+ this.forceTimer = null;
72
+ }
73
+ }
74
+ async flush() {
75
+ if (!this.enabled)
76
+ return;
77
+ const remaining = this.buffer.flush();
78
+ if (remaining) {
79
+ console.log(`[interrupt-tts] flush remaining="${remaining.slice(0, 80)}"`);
80
+ this.queue.push(remaining);
81
+ }
82
+ if (!this.processing)
83
+ return;
84
+ await new Promise((resolve) => {
85
+ const check = () => {
86
+ if (!this.processing)
87
+ resolve();
88
+ else
89
+ setImmediate(check);
90
+ };
91
+ this.once("queue-drained", resolve);
92
+ if (!this.processing)
93
+ resolve();
94
+ });
95
+ }
96
+ speakNow(text) {
97
+ if (!text.trim())
98
+ return;
99
+ this.stop();
100
+ this.queue.unshift(text);
101
+ this.stopped = false;
102
+ if (!this.processing) {
103
+ this.processQueue();
104
+ }
105
+ }
106
+ stop() {
107
+ this.stopped = true;
108
+ this.queue = [];
109
+ this.engine.stop();
110
+ this.processing = false;
111
+ this.cancelForceFlush();
112
+ }
113
+ disable() {
114
+ this.enabled = false;
115
+ this.stop();
116
+ }
117
+ enable() {
118
+ this.enabled = true;
119
+ }
120
+ getPartialContent() {
121
+ if (this.spokenText.trim()) {
122
+ return this.spokenText.trim() + "...[interrupted]";
123
+ }
124
+ return "";
125
+ }
126
+ isPlaying() {
127
+ return this.engine.isPlaying || this.queue.length > 0 || this.processing || (Date.now() - this.lastActivity < 3000);
128
+ }
129
+ resetSession() {
130
+ this.buffer.clear();
131
+ this.queue = [];
132
+ this.spokenText = "";
133
+ this.cancelForceFlush();
134
+ }
135
+ async processQueue() {
136
+ this.processing = true;
137
+ while (this.queue.length > 0 && !this.stopped) {
138
+ const sentence = this.queue.shift();
139
+ await this.engine.speak(sentence);
140
+ if (!this.stopped) {
141
+ this.spokenText += sentence + " ";
142
+ }
143
+ }
144
+ this.processing = false;
145
+ this.emit("queue-drained");
146
+ }
147
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-interrupt-plugin",
3
- "version": "0.3.1",
4
- "description": "Voice interruption for OpenCode. Detects when you speak over TTS output and redirects the AI mid-response.",
3
+ "version": "0.4.1",
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",
7
7
  "types": "./dist/index.d.ts",