levante 0.1.4 → 0.2.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.
@@ -0,0 +1,258 @@
1
+ import { createServer } from 'node:http';
2
+ import { readFileSync, existsSync } from 'node:fs';
3
+ import { resolve, dirname, extname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { WebSocketServer } from 'ws';
6
+ import { createState } from './state.mjs';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const uiDir = resolve(__dirname, 'ui');
10
+
11
+ const outputDir = process.env.COMPANION_OUTPUT_DIR || '/tmp';
12
+ const timestamp = process.env.COMPANION_TIMESTAMP || Date.now().toString();
13
+ const hasOpenAIKey = !!process.env.OPENAI_API_KEY;
14
+
15
+ const MIME_TYPES = {
16
+ '.html': 'text/html',
17
+ '.js': 'application/javascript',
18
+ '.css': 'text/css',
19
+ '.json': 'application/json',
20
+ };
21
+
22
+ const state = createState();
23
+ const clients = new Set();
24
+
25
+ function broadcast(msg) {
26
+ const data = JSON.stringify(msg);
27
+ for (const ws of clients) {
28
+ if (ws.readyState === 1) ws.send(data);
29
+ }
30
+ }
31
+
32
+ function broadcastState() {
33
+ broadcast({ type: 'state:update', state: state.getState() });
34
+ }
35
+
36
+ const server = createServer((req, res) => {
37
+ if (req.url === '/api/capabilities') {
38
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
39
+ res.end(JSON.stringify({ hasOpenAIKey }));
40
+ return;
41
+ }
42
+
43
+ let filePath;
44
+ if (req.url === '/' || req.url === '/companion') {
45
+ filePath = resolve(uiDir, 'companion.html');
46
+ } else {
47
+ filePath = resolve(uiDir, req.url.slice(1));
48
+ }
49
+
50
+ if (!filePath.startsWith(uiDir) || !existsSync(filePath)) {
51
+ res.writeHead(404);
52
+ res.end('Not found');
53
+ return;
54
+ }
55
+
56
+ const ext = extname(filePath);
57
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
58
+ res.writeHead(200, { 'Content-Type': contentType, 'Access-Control-Allow-Origin': '*' });
59
+ res.end(readFileSync(filePath));
60
+ });
61
+
62
+ const wss = new WebSocketServer({ server });
63
+
64
+ wss.on('connection', (ws) => {
65
+ clients.add(ws);
66
+ ws.send(JSON.stringify({ type: 'state:update', state: state.getState() }));
67
+
68
+ ws.on('message', (raw, isBinary) => {
69
+ // Binary messages are audio chunks from MediaRecorder (background Whisper mode)
70
+ if (isBinary || (raw instanceof Buffer && !raw.toString('utf-8').startsWith('{'))) {
71
+ handleAudioChunk(raw);
72
+ return;
73
+ }
74
+
75
+ let msg;
76
+ try { msg = JSON.parse(raw.toString()); } catch { return; }
77
+
78
+ switch (msg.type) {
79
+ case 'control:pause':
80
+ state.pause();
81
+ broadcastState();
82
+ break;
83
+ case 'control:resume':
84
+ state.resume(msg.cursorSegmentId);
85
+ broadcastState();
86
+ break;
87
+ case 'control:stop':
88
+ state.stop();
89
+ broadcastState();
90
+ break;
91
+ case 'transcript:segment':
92
+ if (msg.segment) { state.addSegment(msg.segment); broadcastState(); }
93
+ break;
94
+ case 'transcript:edit':
95
+ if (msg.id && msg.text !== undefined) { state.editSegment(msg.id, msg.text); broadcastState(); }
96
+ break;
97
+ case 'transcript:tag':
98
+ if (msg.id && msg.tags) { state.tagSegment(msg.id, msg.tags); broadcastState(); }
99
+ break;
100
+ case 'transcript:interim':
101
+ for (const client of clients) {
102
+ if (client !== ws && client.readyState === 1) {
103
+ client.send(JSON.stringify({ type: 'transcript:interim', text: msg.text }));
104
+ }
105
+ }
106
+ break;
107
+ case 'engine:set':
108
+ if (msg.engine) { state.setEngine(msg.engine); broadcastState(); }
109
+ break;
110
+ case 'save':
111
+ handleSave();
112
+ break;
113
+ case 'save-and-exit':
114
+ // User clicked Save & Close in companion — save, then notify parent
115
+ handleSave();
116
+ // Forward to parent via stdout so it can kill codegen
117
+ console.log(JSON.stringify({ type: 'save-and-exit' }));
118
+ break;
119
+ case 'codegen:closed':
120
+ // Parent notifies that codegen browser was closed
121
+ state.stop();
122
+ broadcast({ type: 'codegen:closed' });
123
+ broadcastState();
124
+ break;
125
+ }
126
+ });
127
+
128
+ ws.on('close', () => { clients.delete(ws); });
129
+ });
130
+
131
+ // --- Whisper transcription for background audio chunks ---
132
+ let audioChunkBuffer = [];
133
+ let whisperProcessing = false;
134
+
135
+ async function handleAudioChunk(chunk) {
136
+ if (!hasOpenAIKey || state.getStatus() !== 'recording') return;
137
+
138
+ audioChunkBuffer.push(chunk);
139
+
140
+ // Process accumulated chunks (debounce to avoid too many API calls)
141
+ if (!whisperProcessing) {
142
+ whisperProcessing = true;
143
+ // Small delay to batch nearby chunks
144
+ setTimeout(() => processWhisperQueue(), 500);
145
+ }
146
+ }
147
+
148
+ async function processWhisperQueue() {
149
+ if (audioChunkBuffer.length === 0) {
150
+ whisperProcessing = false;
151
+ return;
152
+ }
153
+
154
+ const chunks = audioChunkBuffer.splice(0);
155
+ const blob = Buffer.concat(chunks);
156
+
157
+ try {
158
+ const elapsed = state.getElapsed();
159
+ const text = await transcribeWithWhisper(blob);
160
+ if (text && text.trim()) {
161
+ const segStart = elapsed - 3; // approximate: chunk was ~3s ago
162
+ state.addSegment({
163
+ text: text.trim(),
164
+ start: Math.max(0, segStart),
165
+ end: elapsed,
166
+ tags: [],
167
+ edited: false,
168
+ });
169
+ broadcastState();
170
+ }
171
+ } catch (err) {
172
+ console.error('Whisper transcription error:', err.message);
173
+ }
174
+
175
+ whisperProcessing = false;
176
+ // Process any chunks that arrived while we were transcribing
177
+ if (audioChunkBuffer.length > 0) {
178
+ whisperProcessing = true;
179
+ setTimeout(() => processWhisperQueue(), 200);
180
+ }
181
+ }
182
+
183
+ async function transcribeWithWhisper(audioBuffer) {
184
+ // Build multipart/form-data manually (avoid dependency on form-data package)
185
+ const boundary = '----WhisperBoundary' + Date.now();
186
+ const preamble = [
187
+ `--${boundary}`,
188
+ 'Content-Disposition: form-data; name="file"; filename="chunk.webm"',
189
+ 'Content-Type: audio/webm',
190
+ '',
191
+ '',
192
+ ].join('\r\n');
193
+ const modelField = [
194
+ '',
195
+ `--${boundary}`,
196
+ 'Content-Disposition: form-data; name="model"',
197
+ '',
198
+ 'whisper-1',
199
+ `--${boundary}--`,
200
+ '',
201
+ ].join('\r\n');
202
+
203
+ const body = Buffer.concat([
204
+ Buffer.from(preamble),
205
+ audioBuffer,
206
+ Buffer.from(modelField),
207
+ ]);
208
+
209
+ const res = await fetch('https://api.openai.com/v1/audio/transcriptions', {
210
+ method: 'POST',
211
+ headers: {
212
+ 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
213
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
214
+ },
215
+ body,
216
+ });
217
+
218
+ if (!res.ok) {
219
+ const err = await res.text();
220
+ throw new Error(`Whisper API ${res.status}: ${err}`);
221
+ }
222
+
223
+ const data = await res.json();
224
+ return data.text || '';
225
+ }
226
+
227
+ function handleSave() {
228
+ const transcriptPath = resolve(outputDir, `transcript-${timestamp}.json`);
229
+ state.saveTranscript(transcriptPath);
230
+ console.log(JSON.stringify({ type: 'transcript:saved', path: transcriptPath }));
231
+ broadcastState();
232
+ }
233
+
234
+ const COMPANION_PORT = parseInt(process.env.COMPANION_PORT || '6391', 10);
235
+
236
+ server.on('error', (err) => {
237
+ if (err.code === 'EADDRINUSE') {
238
+ console.log(JSON.stringify({ type: 'server-error', error: `Port ${COMPANION_PORT} is already in use. Is another recording session running?` }));
239
+ process.exit(1);
240
+ }
241
+ throw err;
242
+ });
243
+
244
+ server.listen(COMPANION_PORT, '127.0.0.1', () => {
245
+ const { port } = server.address();
246
+ console.log(JSON.stringify({ type: 'server-started', port, url: `http://localhost:${port}` }));
247
+ });
248
+
249
+ process.stdin.setEncoding('utf-8');
250
+ process.stdin.on('data', (data) => {
251
+ const trimmed = data.trim();
252
+ if (trimmed === 'save') handleSave();
253
+ else if (trimmed === 'stop') { state.stop(); broadcastState(); }
254
+ else if (trimmed === 'pause') { state.pause(); broadcastState(); }
255
+ else if (trimmed === 'resume') { state.resume(); broadcastState(); }
256
+ });
257
+
258
+ process.on('SIGTERM', () => { wss.close(); server.close(); process.exit(0); });
@@ -0,0 +1,97 @@
1
+ import { writeFileSync } from 'node:fs';
2
+
3
+ export function createState() {
4
+ let status = 'recording';
5
+ let engine = 'webspeech';
6
+ let segments = [];
7
+ let lastResumeTime = Date.now();
8
+ let accumulatedMs = 0;
9
+ let insertAfterSegmentId = null;
10
+ let nextSegmentIndex = 0;
11
+
12
+ function getElapsed() {
13
+ if (status === 'recording') {
14
+ return (accumulatedMs + (Date.now() - lastResumeTime)) / 1000;
15
+ }
16
+ return accumulatedMs / 1000;
17
+ }
18
+
19
+ function getState() {
20
+ return { status, elapsed: getElapsed(), segments, engine };
21
+ }
22
+
23
+ function pause() {
24
+ if (status !== 'recording') return getState();
25
+ accumulatedMs += Date.now() - lastResumeTime;
26
+ status = 'paused';
27
+ return getState();
28
+ }
29
+
30
+ function resume(cursorSegmentId) {
31
+ if (status !== 'paused') return getState();
32
+ insertAfterSegmentId = cursorSegmentId || null;
33
+ lastResumeTime = Date.now();
34
+ status = 'recording';
35
+ return getState();
36
+ }
37
+
38
+ function stop() {
39
+ if (status === 'recording') {
40
+ accumulatedMs += Date.now() - lastResumeTime;
41
+ }
42
+ status = 'stopped';
43
+ return getState();
44
+ }
45
+
46
+ function addSegment(segment) {
47
+ const seg = {
48
+ ...segment,
49
+ id: segment.id || `seg-${String(nextSegmentIndex++).padStart(3, '0')}`,
50
+ };
51
+
52
+ if (insertAfterSegmentId) {
53
+ const idx = segments.findIndex((s) => s.id === insertAfterSegmentId);
54
+ if (idx !== -1) {
55
+ segments.splice(idx + 1, 0, seg);
56
+ insertAfterSegmentId = seg.id;
57
+ return getState();
58
+ }
59
+ }
60
+
61
+ segments.push(seg);
62
+ return getState();
63
+ }
64
+
65
+ function editSegment(id, text) {
66
+ const seg = segments.find((s) => s.id === id);
67
+ if (seg) { seg.text = text; seg.edited = true; }
68
+ return getState();
69
+ }
70
+
71
+ function tagSegment(id, tags) {
72
+ const seg = segments.find((s) => s.id === id);
73
+ if (seg) seg.tags = tags;
74
+ return getState();
75
+ }
76
+
77
+ function setEngine(eng) { engine = eng; }
78
+
79
+ function saveTranscript(outputPath) {
80
+ const data = {
81
+ engine,
82
+ duration: getElapsed(),
83
+ segments: segments.map((s) => ({
84
+ id: s.id, text: s.text, start: s.start, end: s.end,
85
+ tags: s.tags || [], edited: s.edited || false,
86
+ })),
87
+ };
88
+ writeFileSync(outputPath, JSON.stringify(data, null, 2));
89
+ return outputPath;
90
+ }
91
+
92
+ return {
93
+ getState, getElapsed, pause, resume, stop,
94
+ addSegment, editSegment, tagSegment,
95
+ setEngine, saveTranscript, getStatus: () => status,
96
+ };
97
+ }