opencode-voice 0.1.0
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 +142 -0
- package/package.json +31 -0
- package/src/audio/detector.ts +146 -0
- package/src/audio/recorder.ts +118 -0
- package/src/config.ts +72 -0
- package/src/index.ts +177 -0
- package/src/providers/chunked.ts +143 -0
- package/src/providers/deepgram.ts +74 -0
- package/src/providers/factory.ts +20 -0
- package/src/providers/groq.ts +57 -0
- package/src/providers/openai.ts +57 -0
- package/src/providers/streaming.ts +187 -0
- package/src/providers/wav-utils.ts +53 -0
- package/src/session.ts +147 -0
- package/src/types.ts +90 -0
- package/src/ui/recording-indicator.tsx +83 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ProviderConfig,
|
|
3
|
+
TranscriptionEvent,
|
|
4
|
+
TranscriptionProvider,
|
|
5
|
+
VoiceProvider,
|
|
6
|
+
} from "../types.ts";
|
|
7
|
+
|
|
8
|
+
type StreamingConfig = {
|
|
9
|
+
keepaliveIntervalMs?: number;
|
|
10
|
+
reconnectMaxRetries?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const MAX_BUFFER_BYTES = 30 * 32 * 1000;
|
|
14
|
+
|
|
15
|
+
export abstract class StreamingProvider implements TranscriptionProvider {
|
|
16
|
+
abstract readonly name: VoiceProvider;
|
|
17
|
+
|
|
18
|
+
private transcriptCallback: ((event: TranscriptionEvent) => void) | null =
|
|
19
|
+
null;
|
|
20
|
+
private errorCallback: ((err: Error) => void) | null = null;
|
|
21
|
+
private ws: WebSocket | null = null;
|
|
22
|
+
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
|
23
|
+
private reconnectAttempts = 0;
|
|
24
|
+
private userDisconnected = false;
|
|
25
|
+
private audioBuffer: Uint8Array[] = [];
|
|
26
|
+
protected providerConfig: ProviderConfig = {};
|
|
27
|
+
|
|
28
|
+
private readonly keepaliveIntervalMs: number;
|
|
29
|
+
private readonly reconnectMaxRetries: number;
|
|
30
|
+
|
|
31
|
+
constructor(config?: StreamingConfig) {
|
|
32
|
+
this.keepaliveIntervalMs = config?.keepaliveIntervalMs ?? 8000;
|
|
33
|
+
this.reconnectMaxRetries = config?.reconnectMaxRetries ?? 3;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async connect(config: ProviderConfig): Promise<void> {
|
|
37
|
+
this.stopKeepalive();
|
|
38
|
+
this.providerConfig = config;
|
|
39
|
+
this.userDisconnected = false;
|
|
40
|
+
this.reconnectAttempts = 0;
|
|
41
|
+
this.audioBuffer = [];
|
|
42
|
+
await this.openWebSocket();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
sendAudio(chunk: Uint8Array): void {
|
|
46
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
47
|
+
this.ws.send(chunk);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const currentSize = this.audioBuffer.reduce(
|
|
52
|
+
(size, bufferedChunk) => size + bufferedChunk.length,
|
|
53
|
+
0,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (currentSize < MAX_BUFFER_BYTES) {
|
|
57
|
+
this.audioBuffer.push(chunk);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
onTranscript(callback: (event: TranscriptionEvent) => void): void {
|
|
62
|
+
this.transcriptCallback = callback;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
onError(callback: (err: Error) => void): void {
|
|
66
|
+
this.errorCallback = callback;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async disconnect(): Promise<void> {
|
|
70
|
+
this.userDisconnected = true;
|
|
71
|
+
this.stopKeepalive();
|
|
72
|
+
|
|
73
|
+
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
|
|
74
|
+
this.ws.close(1000, "User disconnected");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.ws = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async openWebSocket(): Promise<void> {
|
|
81
|
+
return await new Promise((resolve, reject) => {
|
|
82
|
+
let socket: WebSocket;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
socket = this.createWebSocket(this.providerConfig);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
reject(error);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.ws = socket;
|
|
92
|
+
|
|
93
|
+
const onOpen = () => {
|
|
94
|
+
this.reconnectAttempts = 0;
|
|
95
|
+
this.startKeepalive();
|
|
96
|
+
|
|
97
|
+
for (const chunk of this.audioBuffer) {
|
|
98
|
+
this.ws?.send(chunk);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.audioBuffer = [];
|
|
102
|
+
resolve();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const onError = (_event: Event) => {
|
|
106
|
+
if (socket.readyState === WebSocket.CONNECTING) {
|
|
107
|
+
reject(new Error("WebSocket connection failed"));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.errorCallback?.(new Error("WebSocket error"));
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const onClose = (event: CloseEvent) => {
|
|
115
|
+
this.stopKeepalive();
|
|
116
|
+
|
|
117
|
+
if (this.ws === socket) {
|
|
118
|
+
this.ws = socket.readyState === WebSocket.CLOSED ? socket : this.ws;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!this.userDisconnected && event.code !== 1000) {
|
|
122
|
+
this.scheduleReconnect();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const onMessage = (event: MessageEvent) => {
|
|
127
|
+
const parsed = this.parseMessage(event.data);
|
|
128
|
+
if (parsed) {
|
|
129
|
+
this.transcriptCallback?.(parsed);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
socket.addEventListener("open", onOpen);
|
|
134
|
+
socket.addEventListener("error", onError);
|
|
135
|
+
socket.addEventListener("close", onClose);
|
|
136
|
+
socket.addEventListener("message", onMessage);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private scheduleReconnect(): void {
|
|
141
|
+
if (this.reconnectAttempts >= this.reconnectMaxRetries) {
|
|
142
|
+
this.errorCallback?.(
|
|
143
|
+
new Error(
|
|
144
|
+
`WebSocket reconnect failed after ${this.reconnectMaxRetries} attempts`,
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 10000);
|
|
151
|
+
this.reconnectAttempts++;
|
|
152
|
+
|
|
153
|
+
setTimeout(async () => {
|
|
154
|
+
if (this.userDisconnected) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
await this.openWebSocket();
|
|
160
|
+
} catch {
|
|
161
|
+
this.scheduleReconnect();
|
|
162
|
+
}
|
|
163
|
+
}, delay);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private startKeepalive(): void {
|
|
167
|
+
this.stopKeepalive();
|
|
168
|
+
this.keepaliveTimer = setInterval(() => {
|
|
169
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
170
|
+
this.ws.send(this.buildKeepAliveFrame());
|
|
171
|
+
}
|
|
172
|
+
}, this.keepaliveIntervalMs);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private stopKeepalive(): void {
|
|
176
|
+
if (this.keepaliveTimer !== null) {
|
|
177
|
+
clearInterval(this.keepaliveTimer);
|
|
178
|
+
this.keepaliveTimer = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
protected abstract createWebSocket(config: ProviderConfig): WebSocket;
|
|
183
|
+
|
|
184
|
+
protected abstract parseMessage(data: unknown): TranscriptionEvent | null;
|
|
185
|
+
|
|
186
|
+
protected abstract buildKeepAliveFrame(): string | Uint8Array;
|
|
187
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
function buildWavHeader(
|
|
2
|
+
dataLength: number,
|
|
3
|
+
sampleRate: number = 16000,
|
|
4
|
+
): Uint8Array {
|
|
5
|
+
const header = new ArrayBuffer(44);
|
|
6
|
+
const view = new DataView(header);
|
|
7
|
+
const numChannels = 1;
|
|
8
|
+
const bitsPerSample = 16;
|
|
9
|
+
const byteRate = sampleRate * numChannels * (bitsPerSample / 8);
|
|
10
|
+
const blockAlign = numChannels * (bitsPerSample / 8);
|
|
11
|
+
|
|
12
|
+
// RIFF chunk
|
|
13
|
+
view.setUint8(0, 0x52);
|
|
14
|
+
view.setUint8(1, 0x49);
|
|
15
|
+
view.setUint8(2, 0x46);
|
|
16
|
+
view.setUint8(3, 0x46); // "RIFF"
|
|
17
|
+
view.setUint32(4, 36 + dataLength, true);
|
|
18
|
+
view.setUint8(8, 0x57);
|
|
19
|
+
view.setUint8(9, 0x41);
|
|
20
|
+
view.setUint8(10, 0x56);
|
|
21
|
+
view.setUint8(11, 0x45); // "WAVE"
|
|
22
|
+
// fmt chunk
|
|
23
|
+
view.setUint8(12, 0x66);
|
|
24
|
+
view.setUint8(13, 0x6d);
|
|
25
|
+
view.setUint8(14, 0x74);
|
|
26
|
+
view.setUint8(15, 0x20); // "fmt "
|
|
27
|
+
view.setUint32(16, 16, true);
|
|
28
|
+
view.setUint16(20, 1, true); // PCM format
|
|
29
|
+
view.setUint16(22, numChannels, true);
|
|
30
|
+
view.setUint32(24, sampleRate, true);
|
|
31
|
+
view.setUint32(28, byteRate, true);
|
|
32
|
+
view.setUint16(32, blockAlign, true);
|
|
33
|
+
view.setUint16(34, bitsPerSample, true);
|
|
34
|
+
// data chunk
|
|
35
|
+
view.setUint8(36, 0x64);
|
|
36
|
+
view.setUint8(37, 0x61);
|
|
37
|
+
view.setUint8(38, 0x74);
|
|
38
|
+
view.setUint8(39, 0x61); // "data"
|
|
39
|
+
view.setUint32(40, dataLength, true);
|
|
40
|
+
|
|
41
|
+
return new Uint8Array(header);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function wrapInWav(
|
|
45
|
+
pcm: Uint8Array,
|
|
46
|
+
sampleRate: number = 16000,
|
|
47
|
+
): Uint8Array {
|
|
48
|
+
const header = buildWavHeader(pcm.length, sampleRate);
|
|
49
|
+
const wav = new Uint8Array(header.length + pcm.length);
|
|
50
|
+
wav.set(header, 0);
|
|
51
|
+
wav.set(pcm, header.length);
|
|
52
|
+
return wav;
|
|
53
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Recorder } from "./audio/recorder";
|
|
2
|
+
import type {
|
|
3
|
+
ProviderConfig,
|
|
4
|
+
TranscriptionEvent,
|
|
5
|
+
TranscriptionProvider,
|
|
6
|
+
VoiceSessionState,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
export type VoiceSessionOptions = {
|
|
10
|
+
recorder: Recorder;
|
|
11
|
+
provider: TranscriptionProvider;
|
|
12
|
+
providerConfig: Record<string, unknown>;
|
|
13
|
+
onTranscript: (event: TranscriptionEvent) => void;
|
|
14
|
+
onStateChange: (state: VoiceSessionState) => void;
|
|
15
|
+
onError: (error: Error) => void;
|
|
16
|
+
debounceMs?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export class VoiceSession {
|
|
20
|
+
private state: VoiceSessionState = "idle";
|
|
21
|
+
private lastToggleAt = 0;
|
|
22
|
+
private readonly debounceMs: number;
|
|
23
|
+
|
|
24
|
+
constructor(private readonly opts: VoiceSessionOptions) {
|
|
25
|
+
this.debounceMs = opts.debounceMs ?? 500;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getState(): VoiceSessionState {
|
|
29
|
+
return this.state;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async toggle(): Promise<void> {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
if (now - this.lastToggleAt < this.debounceMs) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
this.lastToggleAt = now;
|
|
38
|
+
|
|
39
|
+
if (this.state === "idle") {
|
|
40
|
+
await this.startRecording();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (this.state === "recording") {
|
|
45
|
+
await this.stopRecording();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private async startRecording(): Promise<void> {
|
|
50
|
+
this.setState("recording");
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
this.opts.recorder.onError((error) => {
|
|
54
|
+
this.handleRuntimeError(error);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await this.opts.provider.connect(
|
|
58
|
+
this.opts.providerConfig as ProviderConfig,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
this.opts.provider.onTranscript((event) => {
|
|
62
|
+
this.opts.onTranscript(event);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
this.opts.provider.onError((error) => {
|
|
66
|
+
this.handleRuntimeError(error);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const stream = await this.opts.recorder.start();
|
|
70
|
+
|
|
71
|
+
void this.pipeAudio(stream).catch((error) => {
|
|
72
|
+
if (this.state === "recording") {
|
|
73
|
+
this.handleRuntimeError(error);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
} catch (error) {
|
|
77
|
+
this.setState("error");
|
|
78
|
+
this.opts.onError(
|
|
79
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async stopRecording(): Promise<void> {
|
|
85
|
+
this.setState("processing");
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
await this.opts.recorder.stop();
|
|
89
|
+
await this.opts.provider.disconnect();
|
|
90
|
+
} catch {
|
|
91
|
+
// Ignore cleanup errors
|
|
92
|
+
} finally {
|
|
93
|
+
this.setState("idle");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async pipeAudio(stream: ReadableStream<Uint8Array>): Promise<void> {
|
|
98
|
+
const reader = stream.getReader();
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
while (this.state === "recording") {
|
|
102
|
+
const { done, value } = await reader.read();
|
|
103
|
+
if (done) {
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
if (value) {
|
|
107
|
+
this.opts.provider.sendAudio(value);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} finally {
|
|
111
|
+
reader.releaseLock();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private handleRuntimeError(error: Error): void {
|
|
116
|
+
if (this.state === "error") {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
this.setState("error");
|
|
121
|
+
void this.opts.recorder.stop().catch(() => {});
|
|
122
|
+
this.opts.onError(error);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private setState(state: VoiceSessionState): void {
|
|
126
|
+
this.state = state;
|
|
127
|
+
this.opts.onStateChange(state);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async dispose(): Promise<void> {
|
|
131
|
+
if (this.state === "recording") {
|
|
132
|
+
await this.stopRecording();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (this.state === "error") {
|
|
137
|
+
try {
|
|
138
|
+
await this.opts.recorder.stop();
|
|
139
|
+
await this.opts.provider.disconnect();
|
|
140
|
+
} catch {
|
|
141
|
+
// Ignore cleanup errors during disposal
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.setState("idle");
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Voice provider identifiers
|
|
2
|
+
export type VoiceProvider = "deepgram" | "groq" | "openai-whisper";
|
|
3
|
+
|
|
4
|
+
// Main plugin configuration (loaded from plugin options or env vars)
|
|
5
|
+
export type VoiceConfig = {
|
|
6
|
+
provider: VoiceProvider;
|
|
7
|
+
language?: string; // default: undefined (auto-detect by provider)
|
|
8
|
+
chunkDurationMs?: number; // for chunked providers: chunk size in ms
|
|
9
|
+
keybind?: string; // default: "ctrl+shift+v"
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Per-provider runtime config (API key, model, etc.)
|
|
13
|
+
export type ProviderConfig = {
|
|
14
|
+
apiKey?: string;
|
|
15
|
+
model?: string;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Transcription event emitted by providers
|
|
20
|
+
export type TranscriptionEvent = {
|
|
21
|
+
text: string;
|
|
22
|
+
isFinal: boolean;
|
|
23
|
+
confidence?: number; // 0–1, optional
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Provider abstraction interface
|
|
27
|
+
export interface TranscriptionProvider {
|
|
28
|
+
readonly name: VoiceProvider;
|
|
29
|
+
connect(config: ProviderConfig): Promise<void>;
|
|
30
|
+
sendAudio(chunk: Uint8Array): void;
|
|
31
|
+
onTranscript(callback: (event: TranscriptionEvent) => void): void;
|
|
32
|
+
onError(callback: (error: Error) => void): void;
|
|
33
|
+
disconnect(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Audio capture configuration
|
|
37
|
+
export type RecorderOptions = {
|
|
38
|
+
sampleRate?: number; // default: 16000
|
|
39
|
+
channels?: number; // default: 1
|
|
40
|
+
bitDepth?: number; // default: 16
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Detected recording tool
|
|
44
|
+
export type RecordingTool = "sox" | "ffmpeg" | "arecord" | "powershell";
|
|
45
|
+
|
|
46
|
+
// Voice session state machine states
|
|
47
|
+
export type VoiceSessionState = "idle" | "recording" | "processing" | "error";
|
|
48
|
+
|
|
49
|
+
// Custom error types
|
|
50
|
+
export class VoiceError extends Error {
|
|
51
|
+
constructor(message: string) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "VoiceError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class RecorderError extends VoiceError {
|
|
58
|
+
constructor(message: string) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = "RecorderError";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class AuthError extends VoiceError {
|
|
65
|
+
constructor(message: string) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = "AuthError";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class RateLimitError extends VoiceError {
|
|
72
|
+
constructor(message: string) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = "RateLimitError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class ProviderError extends VoiceError {
|
|
79
|
+
constructor(message: string) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.name = "ProviderError";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class ConfigError extends VoiceError {
|
|
86
|
+
constructor(message: string) {
|
|
87
|
+
super(message);
|
|
88
|
+
this.name = "ConfigError";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createSignal,
|
|
3
|
+
createEffect,
|
|
4
|
+
onCleanup,
|
|
5
|
+
Show,
|
|
6
|
+
type Component,
|
|
7
|
+
} from "solid-js";
|
|
8
|
+
|
|
9
|
+
export type RecordingIndicatorProps = {
|
|
10
|
+
isRecording: () => boolean;
|
|
11
|
+
interimText: () => string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Animation frames for the recording indicator
|
|
15
|
+
export const ANIMATION_FRAMES = ["●", "● ●", "● ● ●"];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates the recording indicator state logic (extracted for testability)
|
|
19
|
+
*/
|
|
20
|
+
export function createRecordingIndicatorState() {
|
|
21
|
+
const [frame, setFrame] = createSignal(0);
|
|
22
|
+
|
|
23
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
24
|
+
|
|
25
|
+
function startAnimation() {
|
|
26
|
+
if (timer !== null) return;
|
|
27
|
+
timer = setInterval(() => {
|
|
28
|
+
setFrame((f) => (f + 1) % ANIMATION_FRAMES.length);
|
|
29
|
+
}, 500);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stopAnimation() {
|
|
33
|
+
if (timer !== null) {
|
|
34
|
+
clearInterval(timer);
|
|
35
|
+
timer = null;
|
|
36
|
+
}
|
|
37
|
+
// Always reset frame even if timer was already null
|
|
38
|
+
setFrame(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getCurrentFrame(): string {
|
|
42
|
+
return ANIMATION_FRAMES[frame()];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { frame, startAnimation, stopAnimation, getCurrentFrame };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Recording indicator component for OpenCode's home_bottom slot.
|
|
50
|
+
* Shows when recording is active, hides when idle.
|
|
51
|
+
*/
|
|
52
|
+
export const RecordingIndicator: Component<RecordingIndicatorProps> = (
|
|
53
|
+
props,
|
|
54
|
+
) => {
|
|
55
|
+
const state = createRecordingIndicatorState();
|
|
56
|
+
|
|
57
|
+
createEffect(() => {
|
|
58
|
+
if (props.isRecording()) {
|
|
59
|
+
state.startAnimation();
|
|
60
|
+
} else {
|
|
61
|
+
state.stopAnimation();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
onCleanup(() => {
|
|
66
|
+
state.stopAnimation();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Show when={props.isRecording()}>
|
|
71
|
+
<div>
|
|
72
|
+
<span style={{ color: "red" }}>
|
|
73
|
+
{state.getCurrentFrame()} Recording...
|
|
74
|
+
</span>
|
|
75
|
+
<Show when={props.interimText().length > 0}>
|
|
76
|
+
<span style={{ opacity: 0.7 }}> {props.interimText()}</span>
|
|
77
|
+
</Show>
|
|
78
|
+
</div>
|
|
79
|
+
</Show>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export default RecordingIndicator;
|