ableton-js 3.7.0 → 3.7.2

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/CHANGELOG.md CHANGED
@@ -4,8 +4,25 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ #### [v3.7.2](https://github.com/leolabs/ableton.js/compare/v3.7.1...v3.7.2)
8
+
9
+ - Fix a missing str() coercion [`#136`](https://github.com/leolabs/ableton.js/pull/136)
10
+ - :bug: Fix issues with larger payloads not being received properly [`bed4e22`](https://github.com/leolabs/ableton.js/commit/bed4e226cb7febb448700c32979ce50a6a67ef69)
11
+ - :zap: Always assign a new port to the Python UDP server on start and store it in the port file [`47566f9`](https://github.com/leolabs/ableton.js/commit/47566f9eb347784746f6d50093491f3b2d591c58)
12
+ - :label: Fix types of `re_enable_automation_enabled` property [`4e547e0`](https://github.com/leolabs/ableton.js/commit/4e547e051f6df9be69ad11797479b7f5d6262ba8)
13
+ - :mute: Reduce logging around packet processing [`c916d67`](https://github.com/leolabs/ableton.js/commit/c916d675905f4660b130e26a904ada6e9f64ac6f)
14
+ - :wrench: Fix `ableton12:start` starting Live 11 instead [`7a0b8a3`](https://github.com/leolabs/ableton.js/commit/7a0b8a34bcee6bd3224fdfc25662363a714440db)
15
+
16
+ #### [v3.7.1](https://github.com/leolabs/ableton.js/compare/v3.7.0...v3.7.1)
17
+
18
+ > 9 July 2025
19
+
20
+ - :sparkles: Add `reEnableAutomation` song function [`b3aa668`](https://github.com/leolabs/ableton.js/commit/b3aa668bfc1844d60722ac557b2be047c0630da8)
21
+
7
22
  #### [v3.7.0](https://github.com/leolabs/ableton.js/compare/v3.6.1...v3.7.0)
8
23
 
24
+ > 16 June 2025
25
+
9
26
  - Add more clip and note operation features in Ableton Live 11 and above versions [`#133`](https://github.com/leolabs/ableton.js/pull/133)
10
27
  - Add new functions and fix bugs [`#130`](https://github.com/leolabs/ableton.js/pull/130)
11
28
  - adds ability to launch v12 via yarn [`#131`](https://github.com/leolabs/ableton.js/pull/131)
package/index.d.ts CHANGED
@@ -96,11 +96,13 @@ export declare class Ableton extends EventEmitter implements ConnectionEventEmit
96
96
  private options?;
97
97
  private client;
98
98
  private msgMap;
99
+ private timeoutMap;
99
100
  private eventListeners;
100
101
  private heartbeatInterval;
101
102
  private _isConnected;
102
103
  private buffer;
103
104
  private latency;
105
+ private messageId;
104
106
  private serverPort;
105
107
  cache?: Cache;
106
108
  song: Song;
@@ -155,7 +157,7 @@ export declare class Ableton extends EventEmitter implements ConnectionEventEmit
155
157
  * disconnects, for example.
156
158
  */
157
159
  removeAllPropListeners(): void;
158
- sendRaw(msg: string): Promise<void>;
160
+ sendRaw(msg: string, messageId: number): Promise<void>;
159
161
  isConnected(): boolean;
160
162
  }
161
163
  export { getPackageVersion } from "./util/package-version";
package/index.js CHANGED
@@ -48,11 +48,13 @@ class Ableton extends events_1.EventEmitter {
48
48
  options;
49
49
  client;
50
50
  msgMap = new Map();
51
+ timeoutMap = new Map();
51
52
  eventListeners = new Map();
52
53
  heartbeatInterval;
53
54
  _isConnected = false;
54
55
  buffer = [];
55
56
  latency = 0;
57
+ messageId = 0;
56
58
  serverPort;
57
59
  cache;
58
60
  song = new song_1.Song(this);
@@ -266,6 +268,8 @@ class Ableton extends events_1.EventEmitter {
266
268
  const messageIndex = msg[1];
267
269
  const totalMessages = msg[2];
268
270
  const message = msg.subarray(3);
271
+ // Reset the timeout when receiving a new message
272
+ this.timeoutMap.get(messageId)?.();
269
273
  if (messageIndex === 0 && totalMessages === 1) {
270
274
  this.handleUncompressedMessage((0, zlib_1.unzipSync)(message).toString());
271
275
  return;
@@ -274,8 +278,7 @@ class Ableton extends events_1.EventEmitter {
274
278
  this.buffer[messageId] = [];
275
279
  }
276
280
  this.buffer[messageId][messageIndex] = message;
277
- if (!this.buffer[messageId].includes(undefined) &&
278
- this.buffer[messageId].length === totalMessages) {
281
+ if (this.buffer[messageId].filter(Boolean).length === totalMessages) {
279
282
  this.handleUncompressedMessage((0, zlib_1.unzipSync)(Buffer.concat(this.buffer[messageId])).toString());
280
283
  delete this.buffer[messageId];
281
284
  }
@@ -338,9 +341,20 @@ class Ableton extends events_1.EventEmitter {
338
341
  const timeout = this.options?.commandTimeoutMs ?? 2000;
339
342
  const arg = (0, lodash_1.truncate)(JSON.stringify(command.args), { length: 100 });
340
343
  const cls = command.nsid ? `${command.ns}(${command.nsid})` : command.ns;
341
- const timeoutId = setTimeout(() => {
342
- rej(new TimeoutError(`The command ${cls}.${command.name}(${arg}) timed out after ${timeout} ms.`, payload));
343
- }, timeout);
344
+ this.messageId = (this.messageId + 1) % 256;
345
+ let timeoutId = null;
346
+ const clearCurrentTimeout = () => {
347
+ if (timeoutId) {
348
+ clearTimeout(timeoutId);
349
+ }
350
+ };
351
+ const startTimeout = () => {
352
+ clearCurrentTimeout();
353
+ timeoutId = setTimeout(() => {
354
+ rej(new TimeoutError(`The command ${cls}.${command.name}(${arg}) timed out after ${timeout} ms.`, payload));
355
+ }, timeout);
356
+ };
357
+ this.timeoutMap.set(this.messageId, startTimeout);
344
358
  const currentTimestamp = Date.now();
345
359
  this.msgMap.set(msgId, {
346
360
  res: (result) => {
@@ -352,16 +366,16 @@ class Ableton extends events_1.EventEmitter {
352
366
  });
353
367
  }
354
368
  this.setPing(duration);
355
- clearTimeout(timeoutId);
369
+ clearCurrentTimeout();
356
370
  res(result);
357
371
  },
358
372
  rej,
359
373
  clearTimeout: () => {
360
- clearTimeout(timeoutId);
374
+ clearCurrentTimeout();
361
375
  rej(new DisconnectError(`Live disconnected before being able to respond to ${cls}.${command.name}(${arg})`, payload));
362
376
  },
363
377
  });
364
- this.sendRaw(msg);
378
+ this.sendRaw(msg, this.messageId).finally(startTimeout);
365
379
  });
366
380
  }
367
381
  async sendCachedCommand(command) {
@@ -452,24 +466,29 @@ class Ableton extends events_1.EventEmitter {
452
466
  removeAllPropListeners() {
453
467
  this.eventListeners.clear();
454
468
  }
455
- async sendRaw(msg) {
469
+ async sendRaw(msg, messageId) {
456
470
  if (!this.client || !this.serverPort) {
457
471
  throw new Error("The client hasn't been started yet. Please call start() first.");
458
472
  }
459
473
  const buffer = (0, zlib_1.deflateSync)(Buffer.from(msg));
460
474
  const byteLimit = this.client.getSendBufferSize() - 100;
461
- const chunks = Math.ceil(buffer.byteLength / byteLimit);
475
+ const totalChunks = Math.ceil(buffer.byteLength / byteLimit);
462
476
  // Split the message into chunks if it becomes too large
463
- for (let i = 0; i < chunks; i++) {
477
+ for (let i = 0; i < totalChunks; i++) {
464
478
  const chunk = Buffer.concat([
465
- // Add a counter to the message, the last message is always 255
466
- Buffer.alloc(1, i + 1 === chunks ? 255 : i),
479
+ // Message ID (1 byte) - identifies which message this chunk belongs to
480
+ Buffer.alloc(1, messageId),
481
+ // Chunk index (1 byte) - 0, 1, 2, ... for regular chunks
482
+ Buffer.alloc(1, i),
483
+ // Total chunks (1 byte) - number of chunks in this message
484
+ Buffer.alloc(1, totalChunks),
485
+ // Chunk data
467
486
  buffer.subarray(i * byteLimit, i * byteLimit + byteLimit),
468
487
  ]);
469
488
  this.client.send(chunk, 0, chunk.length, this.serverPort, "127.0.0.1");
470
489
  // Add a bit of a delay between sent chunks to reduce the chance of the
471
490
  // receiving buffer filling up which would cause chunks to be discarded.
472
- await new Promise((res) => setTimeout(res, 20));
491
+ await new Promise((res) => setTimeout(res, 1));
473
492
  }
474
493
  }
475
494
  isConnected() {
@@ -33,7 +33,7 @@ class Session(Interface):
33
33
  Sets the offset of the SessionComponent instance.
34
34
  """
35
35
  logger.info(
36
- "Moving session box offset to " + str(track_offset) + " and " + scene_offset + ".")
36
+ "Moving session box offset to " + str(track_offset) + " and " + str(scene_offset) + ".")
37
37
 
38
38
  if hasattr(self, 'session'):
39
39
  self.session.set_offsets(track_offset, scene_offset)
@@ -37,11 +37,14 @@ class Socket(object):
37
37
  self._last_error = ""
38
38
  self._socket = None
39
39
  self._chunk_limit = None
40
+ self._send_buffer = []
40
41
  self._message_id = 0
41
42
  self._receive_buffer = bytearray()
43
+ # Dictionary to store chunks per message: {message_id: {chunk_index: chunk_data}}
44
+ self._chunks = {}
42
45
 
43
46
  self.read_remote_port()
44
- self.init_socket(True)
47
+ self.init_socket()
45
48
 
46
49
  def log_error_once(self, msg):
47
50
  if self._last_error != msg:
@@ -53,18 +56,6 @@ class Socket(object):
53
56
  self.show_message("Client connected on port " + str(port))
54
57
  self._client_addr = ("127.0.0.1", int(port))
55
58
 
56
- def read_last_server_port(self):
57
- try:
58
- with open(server_port_path) as file:
59
- port = int(file.read())
60
-
61
- logger.info("Stored server port: " + str(port))
62
- return port
63
- except Exception as e:
64
- logger.error("Couldn't read stored server port:")
65
- logger.exception(e)
66
- return None
67
-
68
59
  def read_remote_port(self):
69
60
  '''Reads the port our client is listening on'''
70
61
 
@@ -96,18 +87,11 @@ class Socket(object):
96
87
  self._socket.close()
97
88
  self._socket = None
98
89
 
99
- def init_socket(self, try_stored=False):
100
- logger.info(
101
- "Initializing socket, from stored: " + str(try_stored))
90
+ def init_socket(self):
91
+ logger.info("Initializing socket")
102
92
 
103
93
  try:
104
- stored_port = self.read_last_server_port()
105
-
106
- # Try the port we used last time first
107
- if try_stored and stored_port:
108
- self._server_addr = ("127.0.0.1", stored_port)
109
- else:
110
- self._server_addr = ("127.0.0.1", 0)
94
+ self._server_addr = ("127.0.0.1", 0)
111
95
 
112
96
  self._socket = socket.socket(
113
97
  socket.AF_INET, socket.SOCK_DGRAM)
@@ -123,16 +107,15 @@ class Socket(object):
123
107
 
124
108
  # Write the chosen port to a file
125
109
  try:
126
- if stored_port != port:
127
- with open(server_port_path, "w") as file:
128
- file.write(str(port))
110
+ with open(server_port_path, "w") as file:
111
+ file.write(str(port))
129
112
  except Exception as e:
130
113
  self.log_error_once(
131
114
  "Couldn't save port in file: " + str(e.args))
132
115
  raise e
133
116
 
134
117
  try:
135
- self.send("connect", {"port": self._server_addr[1]})
118
+ self.send("connect", {"port": port})
136
119
  except Exception as e:
137
120
  logger.error("Couldn't send connect to " +
138
121
  str(self._client_addr) + ":")
@@ -165,18 +148,16 @@ class Socket(object):
165
148
  message_id_byte = struct.pack("B", self._message_id)
166
149
 
167
150
  if len(compressed) < self._chunk_limit:
168
- self._socket.sendto(
169
- message_id_byte + b'\x00\x01' + compressed, self._client_addr)
151
+ self._send_buffer.append(
152
+ message_id_byte + b'\x00\x01' + compressed)
170
153
  else:
171
154
  chunks = list(split_by_n(compressed, self._chunk_limit))
172
155
  count = len(chunks)
173
156
  count_byte = struct.pack("B", count)
174
157
  for i, chunk in enumerate(chunks):
175
- logger.info("Sending packet " + str(self._message_id) +
176
- " - " + str(i) + "/" + str(count))
177
158
  packet_byte = struct.pack("B", i)
178
- self._socket.sendto(
179
- message_id_byte + packet_byte + count_byte + chunk, self._client_addr)
159
+ self._send_buffer.append(
160
+ message_id_byte + packet_byte + count_byte + chunk)
180
161
 
181
162
  def send(self, name, obj=None, uuid=None):
182
163
  def jsonReplace(o):
@@ -206,33 +187,84 @@ class Socket(object):
206
187
  def process(self):
207
188
  try:
208
189
  while 1:
190
+ try:
191
+ # Send 5 UDP packets at a time, to avoid
192
+ for i in range(5):
193
+ self._socket.sendto(
194
+ self._send_buffer.pop(0), self._client_addr)
195
+ except:
196
+ pass
197
+
209
198
  data = self._socket.recv(65536)
210
199
  if len(data) and self.input_handler:
211
- self._receive_buffer.extend(data[1:])
212
-
213
- # \xFF for Live 10 (Python2) and 255 for Live 11 (Python3)
214
- if (data[0] == b'\xFF' or data[0] == 255):
215
- packet = self._receive_buffer
216
- self._receive_buffer = bytearray()
217
-
218
- # Handle Python 2/3 compatibility for zlib.decompress
219
- if sys.version_info[0] < 3:
220
- packet = str(packet)
221
-
222
- unzipped = zlib.decompress(packet)
223
-
224
- # Handle bytes to string conversion for Python 3
225
- if sys.version_info[0] >= 3 and isinstance(unzipped, bytes):
226
- unzipped = unzipped.decode('utf-8')
227
-
228
- payload = json.loads(unzipped)
229
- self.input_handler(payload)
200
+ # Parse packet format: [messageId][chunkIndex][totalChunks][chunkData]
201
+ if len(data) < 3:
202
+ # Packet too short, skip it
203
+ continue
204
+
205
+ # Get message ID, chunk index, and total chunks from first 3 bytes
206
+ message_id = data[0]
207
+ chunk_index = data[1]
208
+ total_chunks = data[2]
209
+
210
+ # Handle Python 2/3 compatibility
211
+ if isinstance(message_id, bytes):
212
+ message_id = ord(message_id)
213
+ if isinstance(chunk_index, bytes):
214
+ chunk_index = ord(chunk_index)
215
+ if isinstance(total_chunks, bytes):
216
+ total_chunks = ord(total_chunks)
217
+
218
+ chunk_data = data[3:]
219
+
220
+ # Initialize message tracking if this is the first chunk for this message
221
+ if message_id not in self._chunks:
222
+ self._chunks[message_id] = {}
223
+
224
+ # Store the chunk
225
+ self._chunks[message_id][chunk_index] = chunk_data
226
+
227
+ # Check if we have all chunks for this message
228
+ if len(self._chunks[message_id]) == total_chunks:
229
+ # We have all chunks! Reassemble in order
230
+ packet_parts = []
231
+ for i in range(total_chunks):
232
+ if i in self._chunks[message_id]:
233
+ packet_parts.append(
234
+ self._chunks[message_id][i])
235
+ else:
236
+ # Missing chunk - this shouldn't happen if total_chunks is correct
237
+ logger.error(
238
+ "Missing chunk %d for message %d" % (i, message_id))
239
+ break
240
+ else:
241
+ # All chunks present, reassemble
242
+ packet = b''.join(packet_parts)
243
+
244
+ # Remove this message from tracking
245
+ del self._chunks[message_id]
246
+
247
+ # Handle Python 2/3 compatibility for zlib.decompress
248
+ if sys.version_info[0] < 3:
249
+ packet = str(packet)
250
+
251
+ unzipped = zlib.decompress(packet)
252
+
253
+ # Handle bytes to string conversion for Python 3
254
+ if sys.version_info[0] >= 3 and isinstance(unzipped, bytes):
255
+ unzipped = unzipped.decode('utf-8')
256
+
257
+ payload = json.loads(unzipped)
258
+ self.input_handler(payload)
230
259
 
231
260
  except socket.error as e:
232
- if (e.errno != 35 and e.errno != 10035 and e.errno != 10054):
261
+ if (e.errno != 35 and e.errno != 10035 and e.errno != 10054 and e.errno != 10022):
233
262
  logger.error("Socket error:")
234
263
  logger.exception(e)
235
264
  return
236
265
  except Exception as e:
237
266
  logger.error("Error processing request:")
238
267
  logger.exception(e)
268
+ # Clear chunks on error to prevent stuck state
269
+ # Optionally, we could clear only the problematic message_id, but for safety, clear all
270
+ self._chunks = {}
@@ -1 +1 @@
1
- version = "3.7.0"
1
+ version = "3.7.2"
package/ns/song.d.ts CHANGED
@@ -35,7 +35,7 @@ export interface GettableProperties {
35
35
  overdub: boolean;
36
36
  punch_in: boolean;
37
37
  punch_out: boolean;
38
- re_enable_automation_enabled: number;
38
+ re_enable_automation_enabled: boolean;
39
39
  record_mode: number;
40
40
  return_tracks: RawTrack[];
41
41
  root_note: number;
@@ -86,7 +86,7 @@ export interface SettableProperties {
86
86
  overdub: boolean;
87
87
  punch_in: boolean;
88
88
  punch_out: boolean;
89
- re_enable_automation_enabled: number;
89
+ re_enable_automation_enabled: boolean;
90
90
  record_mode: number;
91
91
  return_tracks: number;
92
92
  root_note: number;
@@ -129,7 +129,7 @@ export interface ObservableProperties {
129
129
  overdub: boolean;
130
130
  punch_in: boolean;
131
131
  punch_out: boolean;
132
- re_enable_automation_enabled: number;
132
+ re_enable_automation_enabled: boolean;
133
133
  record_mode: number;
134
134
  return_tracks: RawTrack[];
135
135
  scenes: RawScene[];
@@ -207,6 +207,7 @@ export declare class Song extends Namespace<GettableProperties, TransformedPrope
207
207
  jumpToNextCue(): Promise<any>;
208
208
  jumpToPrevCue(): Promise<any>;
209
209
  playSelection(): Promise<any>;
210
+ reEnableAutomation(): Promise<any>;
210
211
  redo(): Promise<any>;
211
212
  scrubBy(amount: number): Promise<any>;
212
213
  setData(key: string, value: any): Promise<any>;
package/ns/song.js CHANGED
@@ -126,6 +126,9 @@ class Song extends _1.Namespace {
126
126
  async playSelection() {
127
127
  return this.sendCommand("play_selection");
128
128
  }
129
+ async reEnableAutomation() {
130
+ return this.sendCommand("re_enable_automation");
131
+ }
129
132
  async redo() {
130
133
  return this.sendCommand("redo");
131
134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ableton-js",
3
- "version": "3.7.0",
3
+ "version": "3.7.2",
4
4
  "description": "Control Ableton Live from Node",
5
5
  "main": "index.js",
6
6
  "author": "Leo Bernard <admin@leolabs.org>",
@@ -24,7 +24,7 @@
24
24
  "ableton:kill": "pkill -KILL -f \"Ableton Live\"",
25
25
  "ableton10:start": "yarn ableton:kill; yarn ableton:clean && yarn ableton:copy-script && yarn ableton10:launch && yarn ableton:logs",
26
26
  "ableton11:start": "yarn ableton:kill; yarn ableton:clean && yarn ableton:copy-script && yarn ableton11:launch && yarn ableton:logs",
27
- "ableton12:start": "yarn ableton:kill; yarn ableton:clean && yarn ableton:copy-script && yarn ableton11:launch && yarn ableton:logs",
27
+ "ableton12:start": "yarn ableton:kill; yarn ableton:clean && yarn ableton:copy-script && yarn ableton12:launch && yarn ableton:logs",
28
28
  "prepublishOnly": "yarn build",
29
29
  "build:doc": "jsdoc2md --files src/**/*.ts --configure ./jsdoc2md.json > ./API.md",
30
30
  "version": "node hooks/prepublish.js && git add midi-script/version.py && auto-changelog -p -l 100 && git add CHANGELOG.md",