@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.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.
- package/README.md +202 -705
- package/dist/AudioAnalyzer.js +1294 -91
- package/dist/PatternStore.js +276 -23
- package/dist/StrudelController.js +388 -56
- package/dist/server/resources.js +27 -20
- package/dist/server/server.js +384 -76
- package/dist/server/tools/ai.js +124 -91
- package/dist/server/tools/analysis.js +168 -59
- package/dist/server/tools/capture.js +135 -24
- package/dist/server/tools/compose.js +26 -26
- package/dist/server/tools/diagnostics.js +20 -3
- package/dist/server/tools/editor.js +60 -6
- package/dist/server/tools/generate.js +38 -15
- package/dist/server/tools/history.js +30 -8
- package/dist/server/tools/playback.js +2 -1
- package/dist/server/tools/session.js +38 -6
- package/dist/server/tools/storage.js +179 -8
- package/dist/server/tools/transform.js +127 -43
- package/dist/server/tools/types.js +209 -14
- package/dist/services/AudioCaptureService.js +302 -33
- package/dist/services/AudioExportService.js +318 -0
- package/dist/services/BrowserOnlyFunctions.js +149 -0
- package/dist/services/EventDensityProbe.js +324 -0
- package/dist/services/GeminiService.js +221 -36
- package/dist/services/IsolatedEngineRunner.js +308 -0
- package/dist/services/IsolatedStrudelEngine.js +135 -0
- package/dist/services/LocalPatternEngine.js +16 -0
- package/dist/services/MIDIExportService.js +765 -71
- package/dist/services/MIDIImportService.js +474 -0
- package/dist/services/MusicTheory.js +85 -13
- package/dist/services/PatternGenerator.js +168 -85
- package/dist/services/PatternSandbox.js +359 -0
- package/dist/services/SessionManager.js +338 -65
- package/dist/services/StrudelEngine.js +218 -12
- package/dist/services/StrudelEngineHelpers.js +24 -8
- package/dist/services/StyleRegistry.js +204 -0
- package/dist/services/Transferable.js +114 -0
- package/dist/services/ai/AiTransport.js +56 -0
- package/dist/services/ai/AudioMeasurements.js +155 -0
- package/dist/services/ai/CliTransport.js +256 -0
- package/dist/services/engineChild.js +83 -0
- package/dist/services/engineChildPath.js +28 -0
- package/dist/utils/CategorisedError.js +59 -0
- package/dist/utils/ErrorRecovery.js +130 -110
- package/dist/utils/InputValidator.js +55 -7
- package/dist/utils/PatternValidator.js +136 -17
- package/dist/utils/SafePath.js +112 -0
- package/dist/utils/ServerConfig.js +248 -0
- package/dist/utils/TableLookup.js +41 -0
- package/dist/utils/Tempo.js +143 -0
- package/package.json +27 -16
- package/dist/services/__mocks__/StrudelEngine.js +0 -157
- package/dist/types/index.js +0 -1
|
@@ -20,6 +20,8 @@ export class AudioCaptureService {
|
|
|
20
20
|
DEFAULT_SAMPLE_RATE = 48000;
|
|
21
21
|
DEFAULT_DURATION_MS = 5000;
|
|
22
22
|
MIME_TYPE = 'audio/webm;codecs=opus';
|
|
23
|
+
/** Cap on an unbounded streaming capture. Ten minutes (#437). */
|
|
24
|
+
static DEFAULT_MAX_CAPTURE_MS = 10 * 60 * 1000;
|
|
23
25
|
/**
|
|
24
26
|
* Injects audio capture code into the Strudel page.
|
|
25
27
|
* Must be called before any capture operations.
|
|
@@ -27,10 +29,83 @@ export class AudioCaptureService {
|
|
|
27
29
|
* @param page - Playwright page instance to inject into
|
|
28
30
|
* @throws {Error} When injection fails
|
|
29
31
|
*/
|
|
32
|
+
/**
|
|
33
|
+
* The page this service's recorder was injected into.
|
|
34
|
+
*
|
|
35
|
+
* A service is bound to one page: `injectRecorder` defines
|
|
36
|
+
* `window.strudelAudioCapture` there, and every later call evaluates
|
|
37
|
+
* against it. Callers that cache services must be able to tell whether
|
|
38
|
+
* a cached one still matches the page they are about to use — a session
|
|
39
|
+
* recreated under the same id, or a browser recovered by `init`, gets a
|
|
40
|
+
* NEW page, and the old recorder does not exist on it (#264).
|
|
41
|
+
*/
|
|
42
|
+
injectedPage = null;
|
|
43
|
+
/**
|
|
44
|
+
* Whether this service's recorder is live on the given page.
|
|
45
|
+
*
|
|
46
|
+
* Asks the page, rather than comparing object identity. A Playwright
|
|
47
|
+
* `Page` outlives the JS realm it points at: reload strudel.cc — which
|
|
48
|
+
* a user can do at any time, since CLAUDE.md makes the visible browser
|
|
49
|
+
* window the intended interface — and `window.strudelAudioCapture` is
|
|
50
|
+
* gone while `injectedPage === page` still holds.
|
|
51
|
+
*
|
|
52
|
+
* The cached service was then returned without re-injecting, and every
|
|
53
|
+
* capture and export failed with "Audio capture not initialized" for
|
|
54
|
+
* the rest of the session. `init` could not recover it either:
|
|
55
|
+
* `initialize()` returns 'Already initialized' whenever the page is
|
|
56
|
+
* alive, so the same page and the same cached service came back (#437).
|
|
57
|
+
*
|
|
58
|
+
* Identity is still checked first — it is free, and it catches the
|
|
59
|
+
* recreated-session and recovered-browser cases (#264) without a round
|
|
60
|
+
* trip to the page.
|
|
61
|
+
*/
|
|
62
|
+
async isInjectedInto(page) {
|
|
63
|
+
if (this.injectedPage !== page)
|
|
64
|
+
return false;
|
|
65
|
+
try {
|
|
66
|
+
return await page.evaluate(/* istanbul ignore next */ () => typeof window.strudelAudioCapture === 'object'
|
|
67
|
+
&& window.strudelAudioCapture !== null);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// An unreadable page is not an injected one.
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
30
74
|
async injectRecorder(page) {
|
|
31
75
|
/* istanbul ignore next -- browser-injected IIFE, covered by integration tests */
|
|
32
|
-
await page.evaluate(() => {
|
|
76
|
+
await page.evaluate(/* istanbul ignore next */ () => {
|
|
77
|
+
// Clear the previous object's timers before replacing it.
|
|
78
|
+
//
|
|
79
|
+
// This assignment overwrites `strudelAudioCapture` outright, and
|
|
80
|
+
// a `setTimeout` does not stop because nothing points at its
|
|
81
|
+
// owner any more. `server.ts` builds a fresh service for a page
|
|
82
|
+
// that may already carry an injected object, so a cap timer or a
|
|
83
|
+
// stop watchdog from the old one could still fire — and both of
|
|
84
|
+
// them reach in and empty `chunks`, which is how the previous
|
|
85
|
+
// capture's watchdog destroyed the NEXT capture in #464.
|
|
86
|
+
//
|
|
87
|
+
// The same hazard, measured on the analyzer's collector, is
|
|
88
|
+
// #479: a re-inject there left the old 20ms interval sampling
|
|
89
|
+
// into an orphaned array.
|
|
90
|
+
const previous = window.strudelAudioCapture;
|
|
91
|
+
if (previous) {
|
|
92
|
+
if (previous.capTimer)
|
|
93
|
+
clearTimeout(previous.capTimer);
|
|
94
|
+
if (previous.stopWatchdog)
|
|
95
|
+
clearTimeout(previous.stopWatchdog);
|
|
96
|
+
previous.capTimer = null;
|
|
97
|
+
previous.stopWatchdog = null;
|
|
98
|
+
previous.isCapturing = false;
|
|
99
|
+
}
|
|
33
100
|
window.strudelAudioCapture = {
|
|
101
|
+
/** In-flight stop, so a second one waits rather than racing it. */
|
|
102
|
+
stopping: null,
|
|
103
|
+
/** Auto-stop at maxDuration; cleared when a stop lands first. */
|
|
104
|
+
capTimer: null,
|
|
105
|
+
/** Set when the cap ended the recording, so a later stop knows. */
|
|
106
|
+
reachedCap: false,
|
|
107
|
+
/** Watchdog for a stop that never completes; cleared when it does. */
|
|
108
|
+
stopWatchdog: null,
|
|
34
109
|
mediaStreamDest: null,
|
|
35
110
|
recorder: null,
|
|
36
111
|
isConnected: false,
|
|
@@ -64,7 +139,7 @@ export class AudioCaptureService {
|
|
|
64
139
|
* Starts audio capture
|
|
65
140
|
* @returns Success status and any error message
|
|
66
141
|
*/
|
|
67
|
-
startCapture() {
|
|
142
|
+
startCapture(maxDurationMs) {
|
|
68
143
|
const capture = window.strudelAudioCapture;
|
|
69
144
|
if (!capture.isConnected || !capture.mediaStreamDest) {
|
|
70
145
|
return {
|
|
@@ -76,9 +151,21 @@ export class AudioCaptureService {
|
|
|
76
151
|
return { success: false, error: 'Capture already in progress.' };
|
|
77
152
|
}
|
|
78
153
|
try {
|
|
79
|
-
// Reset state
|
|
154
|
+
// Reset state. Timers included: a leftover timer from an
|
|
155
|
+
// earlier capture fires against THIS one, and both of them
|
|
156
|
+
// reach in and empty `chunks` (#464).
|
|
80
157
|
capture.chunks = [];
|
|
81
158
|
capture.error = null;
|
|
159
|
+
capture.reachedCap = false;
|
|
160
|
+
if (capture.capTimer !== null) {
|
|
161
|
+
clearTimeout(capture.capTimer);
|
|
162
|
+
capture.capTimer = null;
|
|
163
|
+
}
|
|
164
|
+
if (capture.stopWatchdog !== null) {
|
|
165
|
+
clearTimeout(capture.stopWatchdog);
|
|
166
|
+
capture.stopWatchdog = null;
|
|
167
|
+
}
|
|
168
|
+
capture.stopping = null;
|
|
82
169
|
// Create MediaRecorder with WebM/Opus codec (Gemini compatible)
|
|
83
170
|
const options = {
|
|
84
171
|
mimeType: 'audio/webm;codecs=opus'
|
|
@@ -98,10 +185,45 @@ export class AudioCaptureService {
|
|
|
98
185
|
};
|
|
99
186
|
capture.startTime = Date.now();
|
|
100
187
|
capture.isCapturing = true;
|
|
188
|
+
// A previous capture's error is not this one's.
|
|
189
|
+
capture.error = null;
|
|
101
190
|
capture.recorder.start(100); // Collect data every 100ms
|
|
191
|
+
// A streaming capture had no upper bound at all: it
|
|
192
|
+
// accumulated a 100ms blob in page memory until someone
|
|
193
|
+
// stopped it, and `maxDuration` — advertised by the tool —
|
|
194
|
+
// was accepted and discarded (#437).
|
|
195
|
+
//
|
|
196
|
+
// The cap stops the recorder and marks WHY. Without the mark
|
|
197
|
+
// the recording was thrown away: `onstop` clears
|
|
198
|
+
// `isCapturing`, so a later `stop` fell into the "No capture
|
|
199
|
+
// in progress" branch with every chunk still sitting in
|
|
200
|
+
// `capture.chunks`. The comment here used to claim the
|
|
201
|
+
// opposite — and with a ten-minute default on every capture,
|
|
202
|
+
// any long take lost all of its audio (#464).
|
|
203
|
+
if (typeof maxDurationMs === 'number' && maxDurationMs > 0) {
|
|
204
|
+
capture.capTimer = setTimeout(() => {
|
|
205
|
+
if (!capture.isCapturing)
|
|
206
|
+
return;
|
|
207
|
+
capture.reachedCap = true;
|
|
208
|
+
try {
|
|
209
|
+
capture.recorder.stop();
|
|
210
|
+
}
|
|
211
|
+
catch { /* already stopped */ }
|
|
212
|
+
}, maxDurationMs);
|
|
213
|
+
}
|
|
102
214
|
return { success: true };
|
|
103
215
|
}
|
|
104
216
|
catch (err) {
|
|
217
|
+
// Reset before returning, or the page is wedged for good.
|
|
218
|
+
//
|
|
219
|
+
// `isCapturing` is set just above, and `recorder.start()` can
|
|
220
|
+
// throw — on a track-less stream, for instance. The flag then
|
|
221
|
+
// stayed true while the Node mirror stayed false, so every
|
|
222
|
+
// later start got "Capture already in progress", every export
|
|
223
|
+
// got "A capture is already in progress", and stop was
|
|
224
|
+
// refused by the Node-side gate. Nothing cleared it short of
|
|
225
|
+
// a new page (#437).
|
|
226
|
+
capture.isCapturing = false;
|
|
105
227
|
return { success: false, error: err.message || 'Failed to start capture' };
|
|
106
228
|
}
|
|
107
229
|
},
|
|
@@ -112,13 +234,89 @@ export class AudioCaptureService {
|
|
|
112
234
|
async stopCapture() {
|
|
113
235
|
const capture = window.strudelAudioCapture;
|
|
114
236
|
if (!capture.recorder || !capture.isCapturing) {
|
|
237
|
+
// The cap ended it. That is a finished recording, not an
|
|
238
|
+
// absent one — hand it over (#464).
|
|
239
|
+
if (capture.reachedCap === true && capture.chunks.length > 0) {
|
|
240
|
+
const collected = capture.chunks;
|
|
241
|
+
capture.chunks = [];
|
|
242
|
+
capture.reachedCap = false;
|
|
243
|
+
return {
|
|
244
|
+
success: true,
|
|
245
|
+
blob: new Blob(collected, { type: 'audio/webm;codecs=opus' }),
|
|
246
|
+
duration: Date.now() - capture.startTime,
|
|
247
|
+
format: 'audio/webm;codecs=opus',
|
|
248
|
+
warning: 'Recording stopped at maxDuration; this is the audio up to that point.',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
// A recorder error clears `isCapturing` too, so this branch
|
|
252
|
+
// covered two very different situations with one message.
|
|
253
|
+
// `capture.error` was set and nothing ever read it — the
|
|
254
|
+
// real cause was discarded and the caller was told there had
|
|
255
|
+
// been no capture at all (#437).
|
|
256
|
+
if (capture.error !== null) {
|
|
257
|
+
const failure = capture.error;
|
|
258
|
+
const orphaned = capture.chunks.length;
|
|
259
|
+
capture.error = null;
|
|
260
|
+
capture.chunks = [];
|
|
261
|
+
return {
|
|
262
|
+
success: false,
|
|
263
|
+
error: `${failure}. ${String(orphaned)} chunk(s) were recorded before it and have been discarded.`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
115
266
|
return { success: false, error: 'No capture in progress.' };
|
|
116
267
|
}
|
|
117
|
-
|
|
268
|
+
// A stop already in flight is awaited, not started again.
|
|
269
|
+
//
|
|
270
|
+
// `isCapturing` is cleared inside `onstop`, which has not run
|
|
271
|
+
// yet when a second stop arrives — so both calls got past the
|
|
272
|
+
// check above, the second overwrote the first's `onstop` and
|
|
273
|
+
// called `stop()` on an inactive recorder. The second threw
|
|
274
|
+
// InvalidStateError; the FIRST call's resolver became
|
|
275
|
+
// unreachable, and `page.evaluate` has no timeout, so that MCP
|
|
276
|
+
// request never returned (#437).
|
|
277
|
+
if (capture.stopping) {
|
|
278
|
+
return await capture.stopping;
|
|
279
|
+
}
|
|
280
|
+
capture.stopping = new Promise((resolve) => {
|
|
118
281
|
const recorder = capture.recorder;
|
|
119
282
|
const startTime = capture.startTime;
|
|
283
|
+
// Both timers die on whichever path settles first. The
|
|
284
|
+
// watchdog handle was not kept at all, so it outlived its
|
|
285
|
+
// own capture and fired during the NEXT one — passing its
|
|
286
|
+
// guard, clearing `isCapturing` and emptying `chunks`, which
|
|
287
|
+
// destroyed a recording that was going perfectly well. The
|
|
288
|
+
// cap timer leaked the same way across captures and could
|
|
289
|
+
// stop a recorder that belonged to a later take (#464).
|
|
290
|
+
// Method shorthand, not `const clearTimers = () => {}`:
|
|
291
|
+
// esbuild rewrites the arrow into `__name(fn, "fn")` and
|
|
292
|
+
// `__name` does not exist in page context. Written as an
|
|
293
|
+
// arrow first; PageEvaluateNameWrapping.test.ts caught it.
|
|
294
|
+
const timers = {
|
|
295
|
+
clear() {
|
|
296
|
+
if (capture.capTimer !== null) {
|
|
297
|
+
clearTimeout(capture.capTimer);
|
|
298
|
+
capture.capTimer = null;
|
|
299
|
+
}
|
|
300
|
+
if (capture.stopWatchdog !== null) {
|
|
301
|
+
clearTimeout(capture.stopWatchdog);
|
|
302
|
+
capture.stopWatchdog = null;
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
};
|
|
120
306
|
recorder.onstop = () => {
|
|
121
307
|
capture.isCapturing = false;
|
|
308
|
+
timers.clear();
|
|
309
|
+
// An error that fired after this handler was installed
|
|
310
|
+
// still delivers `dataavailable` and `stop`, so this used
|
|
311
|
+
// to return success:true over a truncated recording and
|
|
312
|
+
// the tool reported a normal capture (#437).
|
|
313
|
+
if (capture.error !== null) {
|
|
314
|
+
const failure = capture.error;
|
|
315
|
+
capture.error = null;
|
|
316
|
+
capture.chunks = [];
|
|
317
|
+
resolve({ success: false, error: `${failure} during stop; the recording is incomplete.` });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
122
320
|
const duration = Date.now() - startTime;
|
|
123
321
|
if (capture.chunks.length === 0) {
|
|
124
322
|
resolve({ success: false, error: 'No audio data captured.' });
|
|
@@ -133,8 +331,41 @@ export class AudioCaptureService {
|
|
|
133
331
|
format: 'audio/webm;codecs=opus'
|
|
134
332
|
});
|
|
135
333
|
};
|
|
334
|
+
// A stop that never completes must not hang the request.
|
|
335
|
+
//
|
|
336
|
+
// `page.evaluate` has no timeout, so if `onstop` never fires
|
|
337
|
+
// — a wedged recorder, a stream whose tracks died — the MCP
|
|
338
|
+
// call waits forever. Resolving with whatever was collected
|
|
339
|
+
// is a worse recording than the caller asked for and an
|
|
340
|
+
// infinitely better outcome than no answer (#437).
|
|
341
|
+
capture.stopWatchdog = setTimeout(() => {
|
|
342
|
+
// Always settles. This used to `return` without resolving
|
|
343
|
+
// when the capture was already flagged done and empty —
|
|
344
|
+
// which is precisely the never-returning request #437 was
|
|
345
|
+
// written to remove, reintroduced by its own fix (#464).
|
|
346
|
+
capture.stopWatchdog = null;
|
|
347
|
+
timers.clear();
|
|
348
|
+
capture.isCapturing = false;
|
|
349
|
+
const collected = capture.chunks;
|
|
350
|
+
capture.chunks = [];
|
|
351
|
+
resolve(collected.length === 0
|
|
352
|
+
? { success: false, error: 'Recorder did not stop, and nothing was captured.' }
|
|
353
|
+
: {
|
|
354
|
+
success: true,
|
|
355
|
+
blob: new Blob(collected, { type: 'audio/webm;codecs=opus' }),
|
|
356
|
+
duration: Date.now() - startTime,
|
|
357
|
+
format: 'audio/webm;codecs=opus',
|
|
358
|
+
warning: 'Recorder did not stop cleanly; returned what was captured.',
|
|
359
|
+
});
|
|
360
|
+
}, 5000);
|
|
136
361
|
recorder.stop();
|
|
137
362
|
});
|
|
363
|
+
try {
|
|
364
|
+
return await capture.stopping;
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
capture.stopping = null;
|
|
368
|
+
}
|
|
138
369
|
},
|
|
139
370
|
/**
|
|
140
371
|
* Returns current capture state
|
|
@@ -151,6 +382,7 @@ export class AudioCaptureService {
|
|
|
151
382
|
// Initialize connection interception
|
|
152
383
|
window.strudelAudioCapture.connect();
|
|
153
384
|
});
|
|
385
|
+
this.injectedPage = page;
|
|
154
386
|
this.logger.debug('Audio capture injected');
|
|
155
387
|
}
|
|
156
388
|
/**
|
|
@@ -161,14 +393,19 @@ export class AudioCaptureService {
|
|
|
161
393
|
* recorder config is fixed at injectRecorder time)
|
|
162
394
|
* @throws {Error} When capture fails to start
|
|
163
395
|
*/
|
|
164
|
-
async startCapture(page,
|
|
165
|
-
|
|
396
|
+
async startCapture(page, config) {
|
|
397
|
+
// Bounded even when the caller does not ask. An unbounded streaming
|
|
398
|
+
// capture grows in page memory until someone remembers to stop it,
|
|
399
|
+
// and nothing here ever timed it out (#437). Ten minutes is far
|
|
400
|
+
// longer than any musical take and far shorter than forever.
|
|
401
|
+
const maxDurationMs = config?.maxDuration ?? AudioCaptureService.DEFAULT_MAX_CAPTURE_MS;
|
|
402
|
+
const result = await page.evaluate(/* istanbul ignore next */ (capMs) => {
|
|
166
403
|
const capture = window.strudelAudioCapture;
|
|
167
404
|
if (!capture) {
|
|
168
405
|
return { success: false, error: 'Audio capture not initialized. Call injectRecorder first.' };
|
|
169
406
|
}
|
|
170
|
-
return capture.startCapture();
|
|
171
|
-
});
|
|
407
|
+
return capture.startCapture(capMs);
|
|
408
|
+
}, maxDurationMs);
|
|
172
409
|
if (!result.success) {
|
|
173
410
|
throw new Error(result.error || 'Failed to start capture');
|
|
174
411
|
}
|
|
@@ -189,18 +426,50 @@ export class AudioCaptureService {
|
|
|
189
426
|
if (!capture) {
|
|
190
427
|
return { success: false, error: 'Audio capture not initialized.' };
|
|
191
428
|
}
|
|
192
|
-
|
|
429
|
+
const captureResult = await capture.stopCapture();
|
|
430
|
+
if (!captureResult.success || !captureResult.blob) {
|
|
431
|
+
return captureResult;
|
|
432
|
+
}
|
|
433
|
+
// Playwright cannot serialize a browser Blob into a Node Blob with
|
|
434
|
+
// methods intact. Convert to base64 inside the browser context and
|
|
435
|
+
// rehydrate a real Node-side Blob below.
|
|
436
|
+
const buffer = await captureResult.blob.arrayBuffer();
|
|
437
|
+
const bytes = new Uint8Array(buffer);
|
|
438
|
+
let binary = '';
|
|
439
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
440
|
+
binary += String.fromCharCode(bytes[i]);
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
success: true,
|
|
444
|
+
audio: btoa(binary),
|
|
445
|
+
duration: captureResult.duration,
|
|
446
|
+
format: captureResult.format,
|
|
447
|
+
warning: captureResult.warning,
|
|
448
|
+
};
|
|
449
|
+
}).finally(() => {
|
|
450
|
+
// Cleared whatever happens.
|
|
451
|
+
//
|
|
452
|
+
// This assignment sat after the await, so a rejected evaluate --
|
|
453
|
+
// a closed page, a navigation, a crashed renderer -- skipped it
|
|
454
|
+
// and left the Node mirror reading "capturing" for a capture that
|
|
455
|
+
// is over. Recoverable, since a later stop clears it, but it
|
|
456
|
+
// costs a wasted call and makes `isCapturing()` lie until then
|
|
457
|
+
// (#437 item 8).
|
|
458
|
+
this._isCapturing = false;
|
|
193
459
|
});
|
|
194
|
-
this._isCapturing = false;
|
|
195
460
|
if (!result.success) {
|
|
196
461
|
throw new Error(result.error || 'Failed to stop capture');
|
|
197
462
|
}
|
|
198
463
|
this.logger.debug(`Audio capture stopped: ${result.duration}ms`);
|
|
464
|
+
const blob = result.audio
|
|
465
|
+
? new Blob([Buffer.from(result.audio, 'base64')], { type: result.format })
|
|
466
|
+
: result.blob;
|
|
199
467
|
return {
|
|
200
|
-
blob
|
|
468
|
+
blob,
|
|
201
469
|
duration: result.duration,
|
|
202
470
|
format: result.format,
|
|
203
|
-
timestamp: Date.now()
|
|
471
|
+
timestamp: Date.now(),
|
|
472
|
+
...(result.warning ? { warning: result.warning } : {}),
|
|
204
473
|
};
|
|
205
474
|
}
|
|
206
475
|
/**
|
|
@@ -232,16 +501,30 @@ export class AudioCaptureService {
|
|
|
232
501
|
return this._isCapturing;
|
|
233
502
|
}
|
|
234
503
|
/**
|
|
235
|
-
*
|
|
236
|
-
*
|
|
504
|
+
* Whether the PAGE thinks a capture is running, whoever started it.
|
|
505
|
+
*
|
|
506
|
+
* `isCapturing()` reports this service's own mirror. `export_audio`
|
|
507
|
+
* drives the page-side recorder directly and never touches that
|
|
508
|
+
* mirror, so for the whole of a 30s export the mirror read false and
|
|
509
|
+
* `audio_capture({action:'stop'})` answered "No audio capture in
|
|
510
|
+
* progress" — which is not true, and told the caller the opposite of
|
|
511
|
+
* what was happening (#437 item 9).
|
|
237
512
|
*
|
|
238
|
-
*
|
|
513
|
+
* Reported rather than acted on: stopping the export's recorder from
|
|
514
|
+
* here would leave the export's own `stopCapture` with nothing to
|
|
515
|
+
* collect, which is a worse outcome than saying what is going on.
|
|
516
|
+
*
|
|
517
|
+
* @param page - Playwright page instance
|
|
518
|
+
* @returns true when the injected recorder is mid-capture
|
|
239
519
|
*/
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return
|
|
520
|
+
async isPageCapturing(page) {
|
|
521
|
+
try {
|
|
522
|
+
return await page.evaluate(/* istanbul ignore next */ () => window.strudelAudioCapture?.isCapturing === true);
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
// An unreadable page is not a capturing one.
|
|
526
|
+
return false;
|
|
243
527
|
}
|
|
244
|
-
return Date.now() - this._startTime;
|
|
245
528
|
}
|
|
246
529
|
/**
|
|
247
530
|
* Checks if audio capture is connected to Strudel's audio output.
|
|
@@ -255,20 +538,6 @@ export class AudioCaptureService {
|
|
|
255
538
|
return capture?.isConnected ?? false;
|
|
256
539
|
});
|
|
257
540
|
}
|
|
258
|
-
/**
|
|
259
|
-
* Clears any recorded chunks without stopping capture.
|
|
260
|
-
* Useful for discarding unwanted audio.
|
|
261
|
-
*
|
|
262
|
-
* @param page - Playwright page instance
|
|
263
|
-
*/
|
|
264
|
-
async clearChunks(page) {
|
|
265
|
-
await page.evaluate(/* istanbul ignore next */ () => {
|
|
266
|
-
const capture = window.strudelAudioCapture;
|
|
267
|
-
if (capture) {
|
|
268
|
-
capture.chunks = [];
|
|
269
|
-
}
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
541
|
/**
|
|
273
542
|
* Gets the MIME type used for recording.
|
|
274
543
|
*
|