@kernel.chat/kbot 3.70.0 → 3.72.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/dist/cli.js +7 -1
- package/dist/integrations/ableton-bridge.d.ts +158 -0
- package/dist/integrations/ableton-bridge.js +486 -0
- package/dist/integrations/ableton-m4l.d.ts +94 -0
- package/dist/integrations/ableton-m4l.js +252 -1
- package/dist/integrations/install-remote-script.d.ts +23 -0
- package/dist/integrations/install-remote-script.js +121 -0
- package/dist/integrations/mobile-mcp-client.d.ts +111 -0
- package/dist/integrations/mobile-mcp-client.js +343 -0
- package/dist/serve.d.ts +3 -0
- package/dist/serve.js +51 -7
- package/dist/tools/ableton-bridge-tools.d.ts +14 -0
- package/dist/tools/ableton-bridge-tools.js +327 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/iphone.d.ts +2 -0
- package/dist/tools/iphone.js +800 -0
- package/dist/tools/mobile-automation.d.ts +2 -0
- package/dist/tools/mobile-automation.js +612 -0
- package/dist/tools/serum2-preset.js +27 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1962,16 +1962,22 @@ async function main() {
|
|
|
1962
1962
|
});
|
|
1963
1963
|
program
|
|
1964
1964
|
.command('serve')
|
|
1965
|
-
.description('Start HTTP server — expose all
|
|
1965
|
+
.description('Start HTTP/HTTPS server — expose all tools for kernel.chat, Claude Cowork, or any client')
|
|
1966
1966
|
.option('-p, --port <port>', 'Port to listen on', '7437')
|
|
1967
1967
|
.option('--token <token>', 'Require auth token for all requests')
|
|
1968
1968
|
.option('--computer-use', 'Enable computer use tools')
|
|
1969
|
+
.option('--https', 'Enable HTTPS with auto-generated self-signed cert (~/.kbot/certs/)')
|
|
1970
|
+
.option('--cert <path>', 'Path to TLS certificate file (implies HTTPS)')
|
|
1971
|
+
.option('--key <path>', 'Path to TLS private key file (implies HTTPS)')
|
|
1969
1972
|
.action(async (opts) => {
|
|
1970
1973
|
const { startServe } = await import('./serve.js');
|
|
1971
1974
|
await startServe({
|
|
1972
1975
|
port: parseInt(opts.port, 10),
|
|
1973
1976
|
token: opts.token,
|
|
1974
1977
|
computerUse: opts.computerUse,
|
|
1978
|
+
https: opts.https,
|
|
1979
|
+
cert: opts.cert,
|
|
1980
|
+
key: opts.key,
|
|
1975
1981
|
});
|
|
1976
1982
|
});
|
|
1977
1983
|
program
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ableton-bridge.ts — kbot ↔ AbletonBridge TCP Client
|
|
3
|
+
*
|
|
4
|
+
* Connects to AbletonBridge (https://github.com/hidingwill/AbletonBridge),
|
|
5
|
+
* a 353-tool Remote Script that exposes Ableton's full Browser API
|
|
6
|
+
* via a TCP server on localhost:9001.
|
|
7
|
+
*
|
|
8
|
+
* Protocol:
|
|
9
|
+
* Send: {"id": 1, "method": "search_browser", "params": {...}}\n
|
|
10
|
+
* Receive: {"id": 1, "result": {...}}\n
|
|
11
|
+
*
|
|
12
|
+
* Fallback chain (used by tools):
|
|
13
|
+
* 1. AbletonBridge (port 9001) — full browser API
|
|
14
|
+
* 2. KBotBridge (port 9997) — kbot's own Remote Script
|
|
15
|
+
* 3. Error with install instructions
|
|
16
|
+
*
|
|
17
|
+
* Follows the same singleton + newline-delimited JSON pattern as AbletonM4L.
|
|
18
|
+
*/
|
|
19
|
+
export interface BrowserItem {
|
|
20
|
+
name: string;
|
|
21
|
+
uri: string;
|
|
22
|
+
isLoadable: boolean;
|
|
23
|
+
isDevice: boolean;
|
|
24
|
+
isFolder: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface Preset {
|
|
27
|
+
name: string;
|
|
28
|
+
uri: string;
|
|
29
|
+
}
|
|
30
|
+
export interface Device {
|
|
31
|
+
name: string;
|
|
32
|
+
className: string;
|
|
33
|
+
index: number;
|
|
34
|
+
}
|
|
35
|
+
export interface BridgeCommand {
|
|
36
|
+
id: number;
|
|
37
|
+
method: string;
|
|
38
|
+
params?: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
export interface BridgeResponse {
|
|
41
|
+
id: number;
|
|
42
|
+
result?: unknown;
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
export declare class AbletonBridgeClient {
|
|
46
|
+
private static instance;
|
|
47
|
+
private socket;
|
|
48
|
+
private connected;
|
|
49
|
+
private pending;
|
|
50
|
+
private nextId;
|
|
51
|
+
private buffer;
|
|
52
|
+
static PORT: number;
|
|
53
|
+
static HOST: string;
|
|
54
|
+
static TIMEOUT: number;
|
|
55
|
+
static CONNECT_TIMEOUT: number;
|
|
56
|
+
private constructor();
|
|
57
|
+
/**
|
|
58
|
+
* Get the singleton instance.
|
|
59
|
+
*/
|
|
60
|
+
static getInstance(): AbletonBridgeClient;
|
|
61
|
+
/**
|
|
62
|
+
* Connect to AbletonBridge TCP server.
|
|
63
|
+
* Returns true if connected and responds to a ping/handshake.
|
|
64
|
+
*/
|
|
65
|
+
connect(): Promise<boolean>;
|
|
66
|
+
/**
|
|
67
|
+
* Check if connected.
|
|
68
|
+
*/
|
|
69
|
+
isConnected(): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Disconnect from the bridge.
|
|
72
|
+
*/
|
|
73
|
+
disconnect(): void;
|
|
74
|
+
/**
|
|
75
|
+
* Send a method call and wait for a response.
|
|
76
|
+
*/
|
|
77
|
+
send(method: string, params?: Record<string, unknown>): Promise<BridgeResponse>;
|
|
78
|
+
private handleResponse;
|
|
79
|
+
private handleDisconnect;
|
|
80
|
+
/**
|
|
81
|
+
* Search Ableton's browser for items matching a query.
|
|
82
|
+
* Optionally filter by category: "instruments", "audio_effects", "midi_effects",
|
|
83
|
+
* "drums", "sounds", "packs", "plugins", "samples", "presets".
|
|
84
|
+
*/
|
|
85
|
+
searchBrowser(query: string, category?: string): Promise<BrowserItem[]>;
|
|
86
|
+
/**
|
|
87
|
+
* Load a device onto a track by its browser URI.
|
|
88
|
+
*/
|
|
89
|
+
loadDevice(trackIndex: number, uri: string): Promise<boolean>;
|
|
90
|
+
/**
|
|
91
|
+
* Search for a device by name and load the first loadable match onto a track.
|
|
92
|
+
* Optionally filter by category to narrow results.
|
|
93
|
+
*/
|
|
94
|
+
loadDeviceByName(trackIndex: number, name: string, category?: string): Promise<boolean>;
|
|
95
|
+
/**
|
|
96
|
+
* List presets available for a device by its URI.
|
|
97
|
+
*/
|
|
98
|
+
listPresets(deviceUri: string): Promise<Preset[]>;
|
|
99
|
+
/**
|
|
100
|
+
* Load a preset onto a device on a specific track.
|
|
101
|
+
*/
|
|
102
|
+
loadPreset(trackIndex: number, deviceIndex: number, presetUri: string): Promise<boolean>;
|
|
103
|
+
/**
|
|
104
|
+
* Get the effect/device chain on a track.
|
|
105
|
+
*/
|
|
106
|
+
getEffectChain(trackIndex: number): Promise<Device[]>;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Lightweight TCP probe for the kbot Remote Script on port 9997.
|
|
110
|
+
* Uses the same newline-delimited JSON protocol as AbletonM4L.
|
|
111
|
+
*/
|
|
112
|
+
export declare class KBotRemoteClient {
|
|
113
|
+
private static instance;
|
|
114
|
+
private socket;
|
|
115
|
+
private connected;
|
|
116
|
+
private pending;
|
|
117
|
+
private nextId;
|
|
118
|
+
private buffer;
|
|
119
|
+
static PORT: number;
|
|
120
|
+
static HOST: string;
|
|
121
|
+
static TIMEOUT: number;
|
|
122
|
+
static CONNECT_TIMEOUT: number;
|
|
123
|
+
private constructor();
|
|
124
|
+
static getInstance(): KBotRemoteClient;
|
|
125
|
+
connect(): Promise<boolean>;
|
|
126
|
+
isConnected(): boolean;
|
|
127
|
+
disconnect(): void;
|
|
128
|
+
send(cmd: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
129
|
+
/** Load a device by name via the kbot Remote Script's search. */
|
|
130
|
+
loadDevice(trackIndex: number, name: string): Promise<boolean>;
|
|
131
|
+
/** Search the browser via the kbot Remote Script. */
|
|
132
|
+
searchBrowser(query: string): Promise<BrowserItem[]>;
|
|
133
|
+
private handleResponse;
|
|
134
|
+
private handleDisconnect;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Try to connect to AbletonBridge (port 9001).
|
|
138
|
+
* Returns the connected client or null if unavailable.
|
|
139
|
+
*/
|
|
140
|
+
export declare function tryAbletonBridge(): Promise<AbletonBridgeClient | null>;
|
|
141
|
+
/**
|
|
142
|
+
* Try to connect to KBotBridge Remote Script (port 9997).
|
|
143
|
+
* Returns the connected client or null if unavailable.
|
|
144
|
+
*/
|
|
145
|
+
export declare function tryKBotRemote(): Promise<KBotRemoteClient | null>;
|
|
146
|
+
/**
|
|
147
|
+
* Get any available bridge, trying AbletonBridge first, then KBotBridge.
|
|
148
|
+
* Returns { bridge, type } or null if neither is available.
|
|
149
|
+
*/
|
|
150
|
+
export declare function getAvailableBridge(): Promise<{
|
|
151
|
+
bridge: AbletonBridgeClient | KBotRemoteClient;
|
|
152
|
+
type: 'ableton-bridge' | 'kbot-remote';
|
|
153
|
+
} | null>;
|
|
154
|
+
/**
|
|
155
|
+
* Format a helpful error message when no bridge is available.
|
|
156
|
+
*/
|
|
157
|
+
export declare function formatBridgeError(): string;
|
|
158
|
+
//# sourceMappingURL=ableton-bridge.d.ts.map
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ableton-bridge.ts — kbot ↔ AbletonBridge TCP Client
|
|
3
|
+
*
|
|
4
|
+
* Connects to AbletonBridge (https://github.com/hidingwill/AbletonBridge),
|
|
5
|
+
* a 353-tool Remote Script that exposes Ableton's full Browser API
|
|
6
|
+
* via a TCP server on localhost:9001.
|
|
7
|
+
*
|
|
8
|
+
* Protocol:
|
|
9
|
+
* Send: {"id": 1, "method": "search_browser", "params": {...}}\n
|
|
10
|
+
* Receive: {"id": 1, "result": {...}}\n
|
|
11
|
+
*
|
|
12
|
+
* Fallback chain (used by tools):
|
|
13
|
+
* 1. AbletonBridge (port 9001) — full browser API
|
|
14
|
+
* 2. KBotBridge (port 9997) — kbot's own Remote Script
|
|
15
|
+
* 3. Error with install instructions
|
|
16
|
+
*
|
|
17
|
+
* Follows the same singleton + newline-delimited JSON pattern as AbletonM4L.
|
|
18
|
+
*/
|
|
19
|
+
import * as net from 'node:net';
|
|
20
|
+
// ── Client ────────────────────────────────────────────────────────────
|
|
21
|
+
export class AbletonBridgeClient {
|
|
22
|
+
static instance = null;
|
|
23
|
+
socket = null;
|
|
24
|
+
connected = false;
|
|
25
|
+
pending = new Map();
|
|
26
|
+
nextId = 1;
|
|
27
|
+
buffer = '';
|
|
28
|
+
static PORT = 9001;
|
|
29
|
+
static HOST = '127.0.0.1';
|
|
30
|
+
static TIMEOUT = 15_000;
|
|
31
|
+
static CONNECT_TIMEOUT = 5_000;
|
|
32
|
+
constructor() { }
|
|
33
|
+
/**
|
|
34
|
+
* Get the singleton instance.
|
|
35
|
+
*/
|
|
36
|
+
static getInstance() {
|
|
37
|
+
if (!AbletonBridgeClient.instance) {
|
|
38
|
+
AbletonBridgeClient.instance = new AbletonBridgeClient();
|
|
39
|
+
}
|
|
40
|
+
return AbletonBridgeClient.instance;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Connect to AbletonBridge TCP server.
|
|
44
|
+
* Returns true if connected and responds to a ping/handshake.
|
|
45
|
+
*/
|
|
46
|
+
async connect() {
|
|
47
|
+
if (this.connected && this.socket) {
|
|
48
|
+
// Already connected — verify with a lightweight call
|
|
49
|
+
try {
|
|
50
|
+
await this.send('ping');
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Connection stale, reconnect
|
|
55
|
+
this.disconnect();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
this.socket = new net.Socket();
|
|
60
|
+
this.buffer = '';
|
|
61
|
+
this.socket.on('data', (data) => {
|
|
62
|
+
this.buffer += data.toString();
|
|
63
|
+
const lines = this.buffer.split('\n');
|
|
64
|
+
this.buffer = lines.pop() || '';
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
const trimmed = line.trim();
|
|
67
|
+
if (!trimmed)
|
|
68
|
+
continue;
|
|
69
|
+
try {
|
|
70
|
+
const response = JSON.parse(trimmed);
|
|
71
|
+
this.handleResponse(response);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Malformed JSON — skip
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
this.socket.on('error', () => {
|
|
79
|
+
if (!this.connected) {
|
|
80
|
+
resolve(false);
|
|
81
|
+
}
|
|
82
|
+
this.handleDisconnect();
|
|
83
|
+
});
|
|
84
|
+
this.socket.on('close', () => {
|
|
85
|
+
this.handleDisconnect();
|
|
86
|
+
});
|
|
87
|
+
this.socket.connect(AbletonBridgeClient.PORT, AbletonBridgeClient.HOST, async () => {
|
|
88
|
+
this.connected = true;
|
|
89
|
+
// Verify connectivity
|
|
90
|
+
try {
|
|
91
|
+
const pong = await this.send('ping');
|
|
92
|
+
resolve(!pong.error);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Even if ping fails, we may still be connected to a bridge
|
|
96
|
+
// that doesn't support ping — consider it connected
|
|
97
|
+
resolve(true);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
// Connection timeout
|
|
101
|
+
setTimeout(() => {
|
|
102
|
+
if (!this.connected) {
|
|
103
|
+
this.socket?.destroy();
|
|
104
|
+
resolve(false);
|
|
105
|
+
}
|
|
106
|
+
}, AbletonBridgeClient.CONNECT_TIMEOUT);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Check if connected.
|
|
111
|
+
*/
|
|
112
|
+
isConnected() {
|
|
113
|
+
return this.connected;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Disconnect from the bridge.
|
|
117
|
+
*/
|
|
118
|
+
disconnect() {
|
|
119
|
+
this.connected = false;
|
|
120
|
+
if (this.socket) {
|
|
121
|
+
this.socket.destroy();
|
|
122
|
+
this.socket = null;
|
|
123
|
+
}
|
|
124
|
+
// Reject all pending requests
|
|
125
|
+
for (const [, req] of this.pending) {
|
|
126
|
+
clearTimeout(req.timer);
|
|
127
|
+
req.reject(new Error('Disconnected'));
|
|
128
|
+
}
|
|
129
|
+
this.pending.clear();
|
|
130
|
+
this.buffer = '';
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Send a method call and wait for a response.
|
|
134
|
+
*/
|
|
135
|
+
async send(method, params) {
|
|
136
|
+
if (!this.connected || !this.socket) {
|
|
137
|
+
throw new Error('Not connected to AbletonBridge. Is Ableton running with the AbletonBridge Remote Script?');
|
|
138
|
+
}
|
|
139
|
+
const id = this.nextId++;
|
|
140
|
+
const cmd = { id, method };
|
|
141
|
+
if (params)
|
|
142
|
+
cmd.params = params;
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
const timer = setTimeout(() => {
|
|
145
|
+
this.pending.delete(id);
|
|
146
|
+
reject(new Error(`Timeout: ${method}`));
|
|
147
|
+
}, AbletonBridgeClient.TIMEOUT);
|
|
148
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
149
|
+
const json = JSON.stringify(cmd) + '\n';
|
|
150
|
+
this.socket.write(json);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
// ── Response handling ─────────────────────────────────────────────
|
|
154
|
+
handleResponse(response) {
|
|
155
|
+
if (response.id && this.pending.has(response.id)) {
|
|
156
|
+
const req = this.pending.get(response.id);
|
|
157
|
+
this.pending.delete(response.id);
|
|
158
|
+
clearTimeout(req.timer);
|
|
159
|
+
req.resolve(response);
|
|
160
|
+
}
|
|
161
|
+
// No event/push support for AbletonBridge — all request/response
|
|
162
|
+
}
|
|
163
|
+
handleDisconnect() {
|
|
164
|
+
if (!this.connected)
|
|
165
|
+
return;
|
|
166
|
+
this.connected = false;
|
|
167
|
+
this.socket = null;
|
|
168
|
+
// Reject pending
|
|
169
|
+
for (const [, req] of this.pending) {
|
|
170
|
+
clearTimeout(req.timer);
|
|
171
|
+
req.reject(new Error('Connection lost'));
|
|
172
|
+
}
|
|
173
|
+
this.pending.clear();
|
|
174
|
+
}
|
|
175
|
+
// ── Browser API ───────────────────────────────────────────────────
|
|
176
|
+
/**
|
|
177
|
+
* Search Ableton's browser for items matching a query.
|
|
178
|
+
* Optionally filter by category: "instruments", "audio_effects", "midi_effects",
|
|
179
|
+
* "drums", "sounds", "packs", "plugins", "samples", "presets".
|
|
180
|
+
*/
|
|
181
|
+
async searchBrowser(query, category) {
|
|
182
|
+
const params = { query };
|
|
183
|
+
if (category)
|
|
184
|
+
params.category = category;
|
|
185
|
+
const resp = await this.send('search_browser', params);
|
|
186
|
+
if (resp.error)
|
|
187
|
+
throw new Error(resp.error);
|
|
188
|
+
const items = resp.result;
|
|
189
|
+
if (!Array.isArray(items))
|
|
190
|
+
return [];
|
|
191
|
+
return items.map((item) => ({
|
|
192
|
+
name: String(item.name ?? ''),
|
|
193
|
+
uri: String(item.uri ?? ''),
|
|
194
|
+
isLoadable: Boolean(item.is_loadable ?? item.isLoadable ?? false),
|
|
195
|
+
isDevice: Boolean(item.is_device ?? item.isDevice ?? false),
|
|
196
|
+
isFolder: Boolean(item.is_folder ?? item.isFolder ?? false),
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Load a device onto a track by its browser URI.
|
|
201
|
+
*/
|
|
202
|
+
async loadDevice(trackIndex, uri) {
|
|
203
|
+
const resp = await this.send('load_device', { track: trackIndex, uri });
|
|
204
|
+
if (resp.error)
|
|
205
|
+
throw new Error(resp.error);
|
|
206
|
+
return Boolean(resp.result);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Search for a device by name and load the first loadable match onto a track.
|
|
210
|
+
* Optionally filter by category to narrow results.
|
|
211
|
+
*/
|
|
212
|
+
async loadDeviceByName(trackIndex, name, category) {
|
|
213
|
+
const items = await this.searchBrowser(name, category);
|
|
214
|
+
// Find the first loadable device
|
|
215
|
+
const device = items.find((item) => item.isLoadable && item.isDevice);
|
|
216
|
+
if (!device) {
|
|
217
|
+
// Fallback: try any loadable item
|
|
218
|
+
const loadable = items.find((item) => item.isLoadable);
|
|
219
|
+
if (!loadable) {
|
|
220
|
+
throw new Error(`No loadable device found for "${name}"${category ? ` in category "${category}"` : ''}`);
|
|
221
|
+
}
|
|
222
|
+
return this.loadDevice(trackIndex, loadable.uri);
|
|
223
|
+
}
|
|
224
|
+
return this.loadDevice(trackIndex, device.uri);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* List presets available for a device by its URI.
|
|
228
|
+
*/
|
|
229
|
+
async listPresets(deviceUri) {
|
|
230
|
+
const resp = await this.send('list_presets', { uri: deviceUri });
|
|
231
|
+
if (resp.error)
|
|
232
|
+
throw new Error(resp.error);
|
|
233
|
+
const presets = resp.result;
|
|
234
|
+
if (!Array.isArray(presets))
|
|
235
|
+
return [];
|
|
236
|
+
return presets.map((p) => ({
|
|
237
|
+
name: String(p.name ?? ''),
|
|
238
|
+
uri: String(p.uri ?? ''),
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Load a preset onto a device on a specific track.
|
|
243
|
+
*/
|
|
244
|
+
async loadPreset(trackIndex, deviceIndex, presetUri) {
|
|
245
|
+
const resp = await this.send('load_preset', {
|
|
246
|
+
track: trackIndex,
|
|
247
|
+
device: deviceIndex,
|
|
248
|
+
uri: presetUri,
|
|
249
|
+
});
|
|
250
|
+
if (resp.error)
|
|
251
|
+
throw new Error(resp.error);
|
|
252
|
+
return Boolean(resp.result);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Get the effect/device chain on a track.
|
|
256
|
+
*/
|
|
257
|
+
async getEffectChain(trackIndex) {
|
|
258
|
+
const resp = await this.send('get_device_chain', { track: trackIndex });
|
|
259
|
+
if (resp.error)
|
|
260
|
+
throw new Error(resp.error);
|
|
261
|
+
const devices = resp.result;
|
|
262
|
+
if (!Array.isArray(devices))
|
|
263
|
+
return [];
|
|
264
|
+
return devices.map((d, i) => ({
|
|
265
|
+
name: String(d.name ?? ''),
|
|
266
|
+
className: String(d.class_name ?? d.className ?? ''),
|
|
267
|
+
index: typeof d.index === 'number' ? d.index : i,
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// ── KBotBridge fallback (port 9997) ────────────────────────────────────
|
|
272
|
+
/**
|
|
273
|
+
* Lightweight TCP probe for the kbot Remote Script on port 9997.
|
|
274
|
+
* Uses the same newline-delimited JSON protocol as AbletonM4L.
|
|
275
|
+
*/
|
|
276
|
+
export class KBotRemoteClient {
|
|
277
|
+
static instance = null;
|
|
278
|
+
socket = null;
|
|
279
|
+
connected = false;
|
|
280
|
+
pending = new Map();
|
|
281
|
+
nextId = 1;
|
|
282
|
+
buffer = '';
|
|
283
|
+
static PORT = 9997;
|
|
284
|
+
static HOST = '127.0.0.1';
|
|
285
|
+
static TIMEOUT = 10_000;
|
|
286
|
+
static CONNECT_TIMEOUT = 3_000;
|
|
287
|
+
constructor() { }
|
|
288
|
+
static getInstance() {
|
|
289
|
+
if (!KBotRemoteClient.instance) {
|
|
290
|
+
KBotRemoteClient.instance = new KBotRemoteClient();
|
|
291
|
+
}
|
|
292
|
+
return KBotRemoteClient.instance;
|
|
293
|
+
}
|
|
294
|
+
async connect() {
|
|
295
|
+
if (this.connected && this.socket) {
|
|
296
|
+
try {
|
|
297
|
+
await this.send({ action: 'ping' });
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
this.disconnect();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return new Promise((resolve) => {
|
|
305
|
+
this.socket = new net.Socket();
|
|
306
|
+
this.buffer = '';
|
|
307
|
+
this.socket.on('data', (data) => {
|
|
308
|
+
this.buffer += data.toString();
|
|
309
|
+
const lines = this.buffer.split('\n');
|
|
310
|
+
this.buffer = lines.pop() || '';
|
|
311
|
+
for (const line of lines) {
|
|
312
|
+
const trimmed = line.trim();
|
|
313
|
+
if (!trimmed)
|
|
314
|
+
continue;
|
|
315
|
+
try {
|
|
316
|
+
const response = JSON.parse(trimmed);
|
|
317
|
+
this.handleResponse(response);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
// skip
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
this.socket.on('error', () => {
|
|
325
|
+
if (!this.connected)
|
|
326
|
+
resolve(false);
|
|
327
|
+
this.handleDisconnect();
|
|
328
|
+
});
|
|
329
|
+
this.socket.on('close', () => {
|
|
330
|
+
this.handleDisconnect();
|
|
331
|
+
});
|
|
332
|
+
this.socket.connect(KBotRemoteClient.PORT, KBotRemoteClient.HOST, async () => {
|
|
333
|
+
this.connected = true;
|
|
334
|
+
try {
|
|
335
|
+
const pong = await this.send({ action: 'ping' });
|
|
336
|
+
resolve(Boolean(pong.ok));
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
resolve(true); // Connected but no ping support — still usable
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
setTimeout(() => {
|
|
343
|
+
if (!this.connected) {
|
|
344
|
+
this.socket?.destroy();
|
|
345
|
+
resolve(false);
|
|
346
|
+
}
|
|
347
|
+
}, KBotRemoteClient.CONNECT_TIMEOUT);
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
isConnected() {
|
|
351
|
+
return this.connected;
|
|
352
|
+
}
|
|
353
|
+
disconnect() {
|
|
354
|
+
this.connected = false;
|
|
355
|
+
if (this.socket) {
|
|
356
|
+
this.socket.destroy();
|
|
357
|
+
this.socket = null;
|
|
358
|
+
}
|
|
359
|
+
for (const [, req] of this.pending) {
|
|
360
|
+
clearTimeout(req.timer);
|
|
361
|
+
req.reject(new Error('Disconnected'));
|
|
362
|
+
}
|
|
363
|
+
this.pending.clear();
|
|
364
|
+
this.buffer = '';
|
|
365
|
+
}
|
|
366
|
+
async send(cmd) {
|
|
367
|
+
if (!this.connected || !this.socket) {
|
|
368
|
+
throw new Error('Not connected to KBotBridge Remote Script');
|
|
369
|
+
}
|
|
370
|
+
const id = this.nextId++;
|
|
371
|
+
const fullCmd = { id, ...cmd };
|
|
372
|
+
return new Promise((resolve, reject) => {
|
|
373
|
+
const timer = setTimeout(() => {
|
|
374
|
+
this.pending.delete(id);
|
|
375
|
+
reject(new Error(`Timeout: ${cmd.action ?? 'unknown'}`));
|
|
376
|
+
}, KBotRemoteClient.TIMEOUT);
|
|
377
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
378
|
+
this.socket.write(JSON.stringify(fullCmd) + '\n');
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
/** Load a device by name via the kbot Remote Script's search. */
|
|
382
|
+
async loadDevice(trackIndex, name) {
|
|
383
|
+
const resp = await this.send({ action: 'load_device', track: trackIndex, name });
|
|
384
|
+
return Boolean(resp.ok);
|
|
385
|
+
}
|
|
386
|
+
/** Search the browser via the kbot Remote Script. */
|
|
387
|
+
async searchBrowser(query) {
|
|
388
|
+
const resp = await this.send({ action: 'search_browser', query });
|
|
389
|
+
const items = resp.results;
|
|
390
|
+
if (!Array.isArray(items))
|
|
391
|
+
return [];
|
|
392
|
+
return items.map((item) => ({
|
|
393
|
+
name: String(item.name ?? ''),
|
|
394
|
+
uri: String(item.uri ?? ''),
|
|
395
|
+
isLoadable: Boolean(item.is_loadable ?? false),
|
|
396
|
+
isDevice: Boolean(item.is_device ?? false),
|
|
397
|
+
isFolder: Boolean(item.is_folder ?? false),
|
|
398
|
+
}));
|
|
399
|
+
}
|
|
400
|
+
handleResponse(response) {
|
|
401
|
+
const id = response.id;
|
|
402
|
+
if (id && this.pending.has(id)) {
|
|
403
|
+
const req = this.pending.get(id);
|
|
404
|
+
this.pending.delete(id);
|
|
405
|
+
clearTimeout(req.timer);
|
|
406
|
+
req.resolve(response);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
handleDisconnect() {
|
|
410
|
+
if (!this.connected)
|
|
411
|
+
return;
|
|
412
|
+
this.connected = false;
|
|
413
|
+
this.socket = null;
|
|
414
|
+
for (const [, req] of this.pending) {
|
|
415
|
+
clearTimeout(req.timer);
|
|
416
|
+
req.reject(new Error('Connection lost'));
|
|
417
|
+
}
|
|
418
|
+
this.pending.clear();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
// ── Convenience exports ────────────────────────────────────────────────
|
|
422
|
+
/**
|
|
423
|
+
* Try to connect to AbletonBridge (port 9001).
|
|
424
|
+
* Returns the connected client or null if unavailable.
|
|
425
|
+
*/
|
|
426
|
+
export async function tryAbletonBridge() {
|
|
427
|
+
const client = AbletonBridgeClient.getInstance();
|
|
428
|
+
if (client.isConnected())
|
|
429
|
+
return client;
|
|
430
|
+
const ok = await client.connect();
|
|
431
|
+
return ok ? client : null;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Try to connect to KBotBridge Remote Script (port 9997).
|
|
435
|
+
* Returns the connected client or null if unavailable.
|
|
436
|
+
*/
|
|
437
|
+
export async function tryKBotRemote() {
|
|
438
|
+
const client = KBotRemoteClient.getInstance();
|
|
439
|
+
if (client.isConnected())
|
|
440
|
+
return client;
|
|
441
|
+
const ok = await client.connect();
|
|
442
|
+
return ok ? client : null;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Get any available bridge, trying AbletonBridge first, then KBotBridge.
|
|
446
|
+
* Returns { bridge, type } or null if neither is available.
|
|
447
|
+
*/
|
|
448
|
+
export async function getAvailableBridge() {
|
|
449
|
+
// Try AbletonBridge first (full browser API)
|
|
450
|
+
const ab = await tryAbletonBridge();
|
|
451
|
+
if (ab)
|
|
452
|
+
return { bridge: ab, type: 'ableton-bridge' };
|
|
453
|
+
// Fallback to KBotBridge Remote Script
|
|
454
|
+
const kb = await tryKBotRemote();
|
|
455
|
+
if (kb)
|
|
456
|
+
return { bridge: kb, type: 'kbot-remote' };
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Format a helpful error message when no bridge is available.
|
|
461
|
+
*/
|
|
462
|
+
export function formatBridgeError() {
|
|
463
|
+
return [
|
|
464
|
+
'**No Ableton bridge connected**',
|
|
465
|
+
'',
|
|
466
|
+
'kbot tried two connection methods and neither is available:',
|
|
467
|
+
'',
|
|
468
|
+
'**Option 1 — AbletonBridge (recommended)**',
|
|
469
|
+
' Full browser API with 353 tools. Install:',
|
|
470
|
+
' 1. Download from https://github.com/hidingwill/AbletonBridge',
|
|
471
|
+
' 2. Copy the `AbletonBridge` folder to your Remote Scripts:',
|
|
472
|
+
' macOS: ~/Music/Ableton/User Library/Remote Scripts/',
|
|
473
|
+
' Win: ~\\Documents\\Ableton\\User Library\\Remote Scripts\\',
|
|
474
|
+
' 3. In Ableton: Preferences → Link/Tempo/MIDI → Control Surface → AbletonBridge',
|
|
475
|
+
' 4. Verify: TCP server starts on localhost:9001',
|
|
476
|
+
'',
|
|
477
|
+
'**Option 2 — KBotBridge**',
|
|
478
|
+
' kbot\'s own Remote Script. Install:',
|
|
479
|
+
' 1. Run `kbot ableton install` or copy KBotBridge to Remote Scripts',
|
|
480
|
+
' 2. Enable in Ableton: Preferences → Link/Tempo/MIDI → Control Surface → KBotBridge',
|
|
481
|
+
' 3. Verify: TCP server starts on localhost:9997',
|
|
482
|
+
'',
|
|
483
|
+
'Both require Ableton Live to be running.',
|
|
484
|
+
].join('\n');
|
|
485
|
+
}
|
|
486
|
+
//# sourceMappingURL=ableton-bridge.js.map
|