@agentdeck/bridge 1.0.12 → 1.0.13
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/assets/fm-helper/agentdeck-fm-helper +0 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +34 -19
- package/dist/cli.js.map +1 -1
- package/dist/d200h/hid-protocol.d.ts +62 -0
- package/dist/d200h/hid-protocol.d.ts.map +1 -0
- package/dist/d200h/hid-protocol.js +124 -0
- package/dist/d200h/hid-protocol.js.map +1 -0
- package/dist/d200h/image-renderer.d.ts +24 -0
- package/dist/d200h/image-renderer.d.ts.map +1 -0
- package/dist/d200h/image-renderer.js +321 -0
- package/dist/d200h/image-renderer.js.map +1 -0
- package/dist/daemon-server.d.ts.map +1 -1
- package/dist/daemon-server.js +47 -1
- package/dist/daemon-server.js.map +1 -1
- package/dist/daemon.js +1 -1
- package/dist/hook-migration.d.ts +2 -0
- package/dist/hook-migration.d.ts.map +1 -0
- package/dist/hook-migration.js +116 -0
- package/dist/hook-migration.js.map +1 -0
- package/dist/modules/d200h-module.d.ts +65 -0
- package/dist/modules/d200h-module.d.ts.map +1 -0
- package/dist/modules/d200h-module.js +495 -0
- package/dist/modules/d200h-module.js.map +1 -0
- package/dist/modules/d200h-renderer.d.ts +7 -0
- package/dist/modules/d200h-renderer.d.ts.map +1 -0
- package/dist/modules/d200h-renderer.js +321 -0
- package/dist/modules/d200h-renderer.js.map +1 -0
- package/dist/modules/trmnl-module.d.ts +38 -0
- package/dist/modules/trmnl-module.d.ts.map +1 -0
- package/dist/modules/trmnl-module.js +111 -0
- package/dist/modules/trmnl-module.js.map +1 -0
- package/dist/trmnl/byos-server.d.ts +39 -0
- package/dist/trmnl/byos-server.d.ts.map +1 -0
- package/dist/trmnl/byos-server.js +301 -0
- package/dist/trmnl/byos-server.js.map +1 -0
- package/dist/trmnl/frame-cache.d.ts +37 -0
- package/dist/trmnl/frame-cache.d.ts.map +1 -0
- package/dist/trmnl/frame-cache.js +157 -0
- package/dist/trmnl/frame-cache.js.map +1 -0
- package/dist/trmnl/image-renderer.d.ts +27 -0
- package/dist/trmnl/image-renderer.d.ts.map +1 -0
- package/dist/trmnl/image-renderer.js +235 -0
- package/dist/trmnl/image-renderer.js.map +1 -0
- package/dist/trmnl/trmnl-settings.d.ts +96 -0
- package/dist/trmnl/trmnl-settings.d.ts.map +1 -0
- package/dist/trmnl/trmnl-settings.js +177 -0
- package/dist/trmnl/trmnl-settings.js.map +1 -0
- package/dist/trmnl/trmnl-telemetry.d.ts +47 -0
- package/dist/trmnl/trmnl-telemetry.d.ts.map +1 -0
- package/dist/trmnl/trmnl-telemetry.js +56 -0
- package/dist/trmnl/trmnl-telemetry.js.map +1 -0
- package/dist/whisper-server-manager.d.ts +19 -0
- package/dist/whisper-server-manager.d.ts.map +1 -0
- package/dist/whisper-server-manager.js +171 -0
- package/dist/whisper-server-manager.js.map +1 -0
- package/fm-helper/AgentDeckFMHelper.swift +111 -10
- package/package.json +3 -3
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whisper-server singleton manager.
|
|
3
|
+
*
|
|
4
|
+
* Fixed port 9100 — one server shared across all agentdeck sessions.
|
|
5
|
+
* Info file at ~/.agentdeck/whisper-server.json for discovery.
|
|
6
|
+
* Last session to exit kills the server.
|
|
7
|
+
*/
|
|
8
|
+
export declare const WHISPER_SERVER_PORT = 9100;
|
|
9
|
+
/**
|
|
10
|
+
* Ensure a whisper-server is running on the singleton port.
|
|
11
|
+
* Returns port on success, null if unavailable (binary missing, model missing, etc).
|
|
12
|
+
*/
|
|
13
|
+
export declare function ensureWhisperServer(serverBin: string, modelPath: string): Promise<number | null>;
|
|
14
|
+
/**
|
|
15
|
+
* Release the whisper-server if no other sessions remain.
|
|
16
|
+
* Call AFTER deregisterSession() so the count is already decremented.
|
|
17
|
+
*/
|
|
18
|
+
export declare function releaseWhisperServer(): void;
|
|
19
|
+
//# sourceMappingURL=whisper-server-manager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"whisper-server-manager.d.ts","sourceRoot":"","sources":["../src/whisper-server-manager.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAaH,eAAO,MAAM,mBAAmB,OAAO,CAAC;AA2DxC;;;GAGG;AACH,wBAAsB,mBAAmB,CACvC,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA6ExB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,IAAI,IAAI,CAiB3C"}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whisper-server singleton manager.
|
|
3
|
+
*
|
|
4
|
+
* Fixed port 9100 — one server shared across all agentdeck sessions.
|
|
5
|
+
* Info file at ~/.agentdeck/whisper-server.json for discovery.
|
|
6
|
+
* Last session to exit kills the server.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import { readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync, existsSync, } from 'fs';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
import { randomUUID } from 'crypto';
|
|
13
|
+
import { listActive } from './session-registry.js';
|
|
14
|
+
import { debug } from './logger.js';
|
|
15
|
+
export const WHISPER_SERVER_PORT = 9100;
|
|
16
|
+
const INFO_DIR = join(homedir(), '.agentdeck');
|
|
17
|
+
const INFO_FILE = join(INFO_DIR, 'whisper-server.json');
|
|
18
|
+
function ensureDir() {
|
|
19
|
+
if (!existsSync(INFO_DIR)) {
|
|
20
|
+
mkdirSync(INFO_DIR, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function readInfo() {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(INFO_FILE, 'utf-8'));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function writeInfo(info) {
|
|
32
|
+
ensureDir();
|
|
33
|
+
const tmp = join(INFO_DIR, `.whisper-server.${randomUUID()}.tmp`);
|
|
34
|
+
writeFileSync(tmp, JSON.stringify(info, null, 2), 'utf-8');
|
|
35
|
+
renameSync(tmp, INFO_FILE);
|
|
36
|
+
}
|
|
37
|
+
function removeInfo() {
|
|
38
|
+
try {
|
|
39
|
+
unlinkSync(INFO_FILE);
|
|
40
|
+
}
|
|
41
|
+
catch { /* already gone */ }
|
|
42
|
+
}
|
|
43
|
+
function isProcessAlive(pid) {
|
|
44
|
+
try {
|
|
45
|
+
process.kill(pid, 0);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function isServerResponding(port) {
|
|
53
|
+
try {
|
|
54
|
+
await fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(1000) });
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function pollUntilReady(port, maxMs) {
|
|
62
|
+
const start = Date.now();
|
|
63
|
+
while (Date.now() - start < maxMs) {
|
|
64
|
+
if (await isServerResponding(port))
|
|
65
|
+
return true;
|
|
66
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Ensure a whisper-server is running on the singleton port.
|
|
72
|
+
* Returns port on success, null if unavailable (binary missing, model missing, etc).
|
|
73
|
+
*/
|
|
74
|
+
export async function ensureWhisperServer(serverBin, modelPath) {
|
|
75
|
+
if (!existsSync(serverBin)) {
|
|
76
|
+
debug('WhisperMgr', `whisper-server not found at ${serverBin}, skipping`);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
// 1. Check existing info file
|
|
80
|
+
const info = readInfo();
|
|
81
|
+
if (info) {
|
|
82
|
+
if (isProcessAlive(info.pid)) {
|
|
83
|
+
// PID alive — check if server is responding
|
|
84
|
+
if (await isServerResponding(info.port)) {
|
|
85
|
+
debug('WhisperMgr', `Reusing existing server (pid=${info.pid}, port=${info.port})`);
|
|
86
|
+
return info.port;
|
|
87
|
+
}
|
|
88
|
+
// PID alive but not responding — model may still be loading, poll 30s
|
|
89
|
+
debug('WhisperMgr', `Server pid=${info.pid} alive but not responding, waiting for model load...`);
|
|
90
|
+
if (await pollUntilReady(info.port, 30_000)) {
|
|
91
|
+
debug('WhisperMgr', `Server became ready (pid=${info.pid}, port=${info.port})`);
|
|
92
|
+
return info.port;
|
|
93
|
+
}
|
|
94
|
+
// Still not responding after 30s — stale
|
|
95
|
+
debug('WhisperMgr', `Server pid=${info.pid} never responded, treating as stale`);
|
|
96
|
+
}
|
|
97
|
+
// PID dead or stale — clean up
|
|
98
|
+
removeInfo();
|
|
99
|
+
}
|
|
100
|
+
// 2. Spawn new server (detached so it survives parent exit)
|
|
101
|
+
debug('WhisperMgr', `Starting whisper-server on port ${WHISPER_SERVER_PORT}...`);
|
|
102
|
+
const args = [
|
|
103
|
+
'--model', modelPath,
|
|
104
|
+
'--port', String(WHISPER_SERVER_PORT),
|
|
105
|
+
'--host', '127.0.0.1',
|
|
106
|
+
'-l', 'auto',
|
|
107
|
+
'--no-timestamps',
|
|
108
|
+
'--convert',
|
|
109
|
+
'--prompt', 'coding, programming, Claude, terminal, git, function, component, API',
|
|
110
|
+
];
|
|
111
|
+
const proc = spawn(serverBin, args, {
|
|
112
|
+
stdio: 'ignore',
|
|
113
|
+
detached: true,
|
|
114
|
+
});
|
|
115
|
+
proc.unref();
|
|
116
|
+
const pid = proc.pid;
|
|
117
|
+
if (!pid) {
|
|
118
|
+
debug('WhisperMgr', 'Failed to spawn whisper-server (no pid)');
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
// 3. Brief wait to detect immediate crash (e.g. port conflict)
|
|
122
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
123
|
+
if (!isProcessAlive(pid)) {
|
|
124
|
+
debug('WhisperMgr', `whisper-server died immediately (pid=${pid}), checking for existing server...`);
|
|
125
|
+
// Another process may have won the port race — re-check info file
|
|
126
|
+
const otherInfo = readInfo();
|
|
127
|
+
if (otherInfo && isProcessAlive(otherInfo.pid) && await isServerResponding(otherInfo.port)) {
|
|
128
|
+
debug('WhisperMgr', `Found winner server (pid=${otherInfo.pid}, port=${otherInfo.port})`);
|
|
129
|
+
return otherInfo.port;
|
|
130
|
+
}
|
|
131
|
+
removeInfo();
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
// 4. Write info file
|
|
135
|
+
writeInfo({ pid, port: WHISPER_SERVER_PORT, modelPath, startedAt: new Date().toISOString() });
|
|
136
|
+
// 5. Poll until ready (up to 30s for model loading)
|
|
137
|
+
if (await pollUntilReady(WHISPER_SERVER_PORT, 30_000)) {
|
|
138
|
+
debug('WhisperMgr', `whisper-server ready on port ${WHISPER_SERVER_PORT} (pid=${pid})`);
|
|
139
|
+
return WHISPER_SERVER_PORT;
|
|
140
|
+
}
|
|
141
|
+
debug('WhisperMgr', 'whisper-server failed to become ready within 30s');
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Release the whisper-server if no other sessions remain.
|
|
146
|
+
* Call AFTER deregisterSession() so the count is already decremented.
|
|
147
|
+
*/
|
|
148
|
+
export function releaseWhisperServer() {
|
|
149
|
+
const activeSessions = listActive();
|
|
150
|
+
if (activeSessions.length > 0) {
|
|
151
|
+
debug('WhisperMgr', `Keeping whisper-server alive (${activeSessions.length} session(s) remain)`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const info = readInfo();
|
|
155
|
+
if (!info)
|
|
156
|
+
return;
|
|
157
|
+
debug('WhisperMgr', `Killing whisper-server (pid=${info.pid})`);
|
|
158
|
+
try {
|
|
159
|
+
process.kill(info.pid, 'SIGTERM');
|
|
160
|
+
}
|
|
161
|
+
catch { /* already dead */ }
|
|
162
|
+
// Force kill after 3s
|
|
163
|
+
setTimeout(() => {
|
|
164
|
+
try {
|
|
165
|
+
process.kill(info.pid, 'SIGKILL');
|
|
166
|
+
}
|
|
167
|
+
catch { /* already dead */ }
|
|
168
|
+
}, 3000);
|
|
169
|
+
removeInfo();
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=whisper-server-manager.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"whisper-server-manager.js","sourceRoot":"","sources":["../src/whisper-server-manager.ts"],"names":[],"mappings":"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"}
|
|
@@ -48,17 +48,17 @@ struct AgentDeckFMHelper {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
if request.type == "transcribe" {
|
|
51
|
-
await handleTranscribe(request)
|
|
51
|
+
dispatchLongWork { await handleTranscribe(request) }
|
|
52
52
|
return
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
if request.type == "speak" {
|
|
56
|
-
await handleSpeak(request)
|
|
56
|
+
dispatchLongWork { await handleSpeak(request) }
|
|
57
57
|
return
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
if request.type == "synthesize" {
|
|
61
|
-
await handleSynthesize(request)
|
|
61
|
+
dispatchLongWork { await handleSynthesize(request) }
|
|
62
62
|
return
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -76,6 +76,26 @@ struct AgentDeckFMHelper {
|
|
|
76
76
|
return
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
dispatchLongWork { await handleGenerate(request) }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// Long work must never run on the stdin read loop.
|
|
83
|
+
///
|
|
84
|
+
/// Measured 2026-08-08 on a D200H: one in-flight `generate` kept the loop
|
|
85
|
+
/// busy for 25 s, so the `record_stop` queued behind it was not even READ
|
|
86
|
+
/// until it finished. The push-to-talk capture ran to its 30 s cap and
|
|
87
|
+
/// transcribed 30 s of mostly silence, which reached the user as "voice
|
|
88
|
+
/// does nothing". `record` was already detached for exactly this reason;
|
|
89
|
+
/// every other slow request needed the same treatment.
|
|
90
|
+
///
|
|
91
|
+
/// The work stays serialized, in read order, exactly as it was when the
|
|
92
|
+
/// loop ran it — only the READING of the next line is freed, which is all a
|
|
93
|
+
/// control line like `record_stop` needs.
|
|
94
|
+
private static func dispatchLongWork(_ body: @escaping @Sendable () async -> Void) {
|
|
95
|
+
workChain.enqueue(body)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private static func handleGenerate(_ request: HelperRequest) async {
|
|
79
99
|
guard let prompt = request.prompt, !prompt.isEmpty else {
|
|
80
100
|
write(["id": request.id, "error": "bad_request", "reason": "missing prompt"])
|
|
81
101
|
return
|
|
@@ -198,8 +218,15 @@ struct AgentDeckFMHelper {
|
|
|
198
218
|
write(["id": request.id, "error": "bad_request", "reason": "missing wav output path"])
|
|
199
219
|
return
|
|
200
220
|
}
|
|
221
|
+
// Reserve before the slow setup below, so a stop arriving mid-setup has
|
|
222
|
+
// somewhere to land (see RecordingBox.pendingStop).
|
|
223
|
+
guard activeRecording.reserve(requestId: request.id) else {
|
|
224
|
+
write(["id": request.id, "error": "busy", "reason": "a recording is already in progress"])
|
|
225
|
+
return
|
|
226
|
+
}
|
|
201
227
|
let granted = await ensureMicAuthorization()
|
|
202
228
|
guard granted else {
|
|
229
|
+
activeRecording.release()
|
|
203
230
|
write([
|
|
204
231
|
"id": request.id,
|
|
205
232
|
"error": "unauthorized",
|
|
@@ -212,6 +239,7 @@ struct AgentDeckFMHelper {
|
|
|
212
239
|
sampleRate: sampleRate,
|
|
213
240
|
channels: 1,
|
|
214
241
|
interleaved: true) else {
|
|
242
|
+
activeRecording.release()
|
|
215
243
|
write(["id": request.id, "error": "record_failed", "reason": "output format unavailable"])
|
|
216
244
|
return
|
|
217
245
|
}
|
|
@@ -220,12 +248,19 @@ struct AgentDeckFMHelper {
|
|
|
220
248
|
let input = engine.inputNode
|
|
221
249
|
let inFormat = input.outputFormat(forBus: 0)
|
|
222
250
|
guard inFormat.sampleRate > 0, inFormat.channelCount > 0 else {
|
|
251
|
+
activeRecording.release()
|
|
223
252
|
write(["id": request.id, "error": "record_failed", "reason": "no audio input device"])
|
|
224
253
|
return
|
|
225
254
|
}
|
|
226
255
|
let sink = PCMSink()
|
|
227
|
-
|
|
228
|
-
|
|
256
|
+
if let stopped = activeRecording.begin(engine: engine, sink: sink) {
|
|
257
|
+
// The stop beat the setup. Answer it as the capture it was meant
|
|
258
|
+
// for instead of starting an engine nobody is waiting on.
|
|
259
|
+
if stopped.cancelled {
|
|
260
|
+
write(["id": request.id, "cancelled": true])
|
|
261
|
+
} else {
|
|
262
|
+
write(["id": request.id, "error": "record_failed", "reason": "stopped before capture started"])
|
|
263
|
+
}
|
|
229
264
|
return
|
|
230
265
|
}
|
|
231
266
|
input.installTap(onBus: 0, bufferSize: 4096, format: inFormat) { buffer, _ in
|
|
@@ -437,6 +472,9 @@ struct AgentDeckFMHelper {
|
|
|
437
472
|
/// task; the lock keeps two replies from interleaving inside one line.
|
|
438
473
|
private static let writeLock = NSLock()
|
|
439
474
|
|
|
475
|
+
/// See `dispatchLongWork`.
|
|
476
|
+
private static let workChain = WorkChain()
|
|
477
|
+
|
|
440
478
|
private static func write(_ object: [String: Any]) {
|
|
441
479
|
guard JSONSerialization.isValidJSONObject(object),
|
|
442
480
|
let data = try? JSONSerialization.data(withJSONObject: object),
|
|
@@ -449,6 +487,27 @@ struct AgentDeckFMHelper {
|
|
|
449
487
|
}
|
|
450
488
|
}
|
|
451
489
|
|
|
490
|
+
/// Serializes the helper's slow requests (LLM generation, speech recognition,
|
|
491
|
+
/// TTS) off the stdin read loop, while the loop stays free to read control
|
|
492
|
+
/// lines such as `record_stop`.
|
|
493
|
+
///
|
|
494
|
+
/// A chain rather than an actor: actor executors guarantee mutual exclusion but
|
|
495
|
+
/// NOT FIFO, and `Task.detached` does not even preserve the order in which the
|
|
496
|
+
/// tasks were created — so two queued `speak` requests could have been voiced
|
|
497
|
+
/// out of order. Linking each unit of work to its predecessor's completion
|
|
498
|
+
/// keeps the read order the loop used to provide. The enqueue itself is O(1)
|
|
499
|
+
/// and happens on the read loop, which is the only mutator.
|
|
500
|
+
private final class WorkChain: @unchecked Sendable {
|
|
501
|
+
private let lock = NSLock()
|
|
502
|
+
private var tail: Task<Void, Never> = Task {}
|
|
503
|
+
|
|
504
|
+
func enqueue(_ body: @escaping @Sendable () async -> Void) {
|
|
505
|
+
lock.lock(); defer { lock.unlock() }
|
|
506
|
+
let previous = tail
|
|
507
|
+
tail = Task { await previous.value; await body() }
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
452
511
|
/// State for the single in-flight microphone capture. One at a time by design:
|
|
453
512
|
/// the daemon models push-to-talk, and two overlapping engine taps on the same
|
|
454
513
|
/// input device produce garbage anyway.
|
|
@@ -463,17 +522,43 @@ private final class RecordingBox: @unchecked Sendable {
|
|
|
463
522
|
/// Bumped per recording so a stale max-duration timer from a finished
|
|
464
523
|
/// capture cannot terminate the one that started after it.
|
|
465
524
|
private var generation = 0
|
|
466
|
-
|
|
467
|
-
|
|
525
|
+
/// A stop that arrived while the capture was still being set up.
|
|
526
|
+
///
|
|
527
|
+
/// `handleRecord` reserves the slot, then spends real time on mic
|
|
528
|
+
/// authorization, format negotiation and engine construction before the
|
|
529
|
+
/// tap is live. Hold-to-talk routinely beats that: a short hold puts
|
|
530
|
+
/// `record_stop` on stdin while `requestId` was set but no continuation
|
|
531
|
+
/// exists yet. Without this latch the stop was dropped, the capture ran to
|
|
532
|
+
/// its 30s cap, and 30s of room noise was transcribed and injected into
|
|
533
|
+
/// the user's session — worst of all for the sub-250ms tap, whose whole
|
|
534
|
+
/// meaning is "never mind".
|
|
535
|
+
private var pendingStop: Outcome?
|
|
536
|
+
|
|
537
|
+
/// Claim the slot before the slow setup. Returns false when a capture is
|
|
538
|
+
/// already in flight.
|
|
539
|
+
func reserve(requestId: Int) -> Bool {
|
|
468
540
|
lock.lock(); defer { lock.unlock() }
|
|
469
541
|
guard self.requestId == nil else { return false }
|
|
470
542
|
self.requestId = requestId
|
|
471
|
-
self.
|
|
472
|
-
self.sink = sink
|
|
543
|
+
self.pendingStop = nil
|
|
473
544
|
generation += 1
|
|
474
545
|
return true
|
|
475
546
|
}
|
|
476
547
|
|
|
548
|
+
/// Arm the reserved slot with the live engine. Returns the stop that
|
|
549
|
+
/// arrived during setup, if any — the caller must then not start at all.
|
|
550
|
+
func begin(engine: AVAudioEngine, sink: PCMSink) -> Outcome? {
|
|
551
|
+
lock.lock(); defer { lock.unlock() }
|
|
552
|
+
if let stop = pendingStop {
|
|
553
|
+
pendingStop = nil
|
|
554
|
+
requestId = nil
|
|
555
|
+
return stop
|
|
556
|
+
}
|
|
557
|
+
self.engine = engine
|
|
558
|
+
self.sink = sink
|
|
559
|
+
return nil
|
|
560
|
+
}
|
|
561
|
+
|
|
477
562
|
func wait(maxMs: Int) async -> Outcome {
|
|
478
563
|
let gen = currentGeneration()
|
|
479
564
|
let outcome: Outcome = await withCheckedContinuation { cont in
|
|
@@ -507,16 +592,32 @@ private final class RecordingBox: @unchecked Sendable {
|
|
|
507
592
|
lock.lock()
|
|
508
593
|
let cont = continuation
|
|
509
594
|
let active = requestId != nil
|
|
595
|
+
let outcome = Outcome(cancelled: cancelled, reason: cancelled ? "cancelled" : reason)
|
|
596
|
+
if active && cont == nil {
|
|
597
|
+
// Reserved but not yet armed: latch the stop for `begin` rather
|
|
598
|
+
// than dropping it on the floor. Keep the reservation so the
|
|
599
|
+
// in-flight setup still owns the slot.
|
|
600
|
+
pendingStop = outcome
|
|
601
|
+
lock.unlock()
|
|
602
|
+
return true
|
|
603
|
+
}
|
|
510
604
|
continuation = nil
|
|
511
605
|
requestId = nil
|
|
512
606
|
engine = nil
|
|
513
607
|
sink = nil
|
|
514
608
|
lock.unlock()
|
|
515
609
|
guard active, let cont else { return false }
|
|
516
|
-
cont.resume(
|
|
610
|
+
cont.resume(outcome)
|
|
517
611
|
return true
|
|
518
612
|
}
|
|
519
613
|
|
|
614
|
+
/// Drop a reservation whose setup failed before the engine existed.
|
|
615
|
+
func release() {
|
|
616
|
+
lock.lock(); defer { lock.unlock() }
|
|
617
|
+
requestId = nil
|
|
618
|
+
pendingStop = nil
|
|
619
|
+
}
|
|
620
|
+
|
|
520
621
|
func clear() {
|
|
521
622
|
_ = finish(cancelled: true, reason: "cleared")
|
|
522
623
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentdeck/bridge",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"sharp": "^0.34.5",
|
|
34
34
|
"strip-ansi": "^7.2.0",
|
|
35
35
|
"ws": "^8.20.0",
|
|
36
|
-
"@agentdeck/
|
|
37
|
-
"@agentdeck/
|
|
36
|
+
"@agentdeck/hooks": "1.0.13",
|
|
37
|
+
"@agentdeck/shared": "1.0.13"
|
|
38
38
|
},
|
|
39
39
|
"optionalDependencies": {
|
|
40
40
|
"@picovoice/porcupine-node": "^4.0.2",
|