@lox-audioserver/node-slimproto 0.1.0 → 0.1.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/dist/client.d.ts CHANGED
@@ -25,6 +25,9 @@ export declare class SlimClient {
25
25
  private _autoPlay;
26
26
  private _heartbeatTimer;
27
27
  private _heartbeatId;
28
+ private readonly heartbeatSentAt;
29
+ private pendingClockSync;
30
+ private clockBase;
28
31
  constructor(socket: net.Socket, callback: SlimClientCallback);
29
32
  disconnect(): void;
30
33
  get connected(): boolean;
@@ -42,6 +45,26 @@ export declare class SlimClient {
42
45
  get elapsedMilliseconds(): number;
43
46
  get jiffies(): number;
44
47
  get lastHeartbeatAt(): number | null;
48
+ /**
49
+ * Best-effort mapping between server wall clock time (ms) and player jiffies.
50
+ * Derived from `strm t` / `stat STMt` heartbeat exchange.
51
+ */
52
+ get clockSync(): {
53
+ serverTimeMs: number;
54
+ jiffies: number;
55
+ rttMs: number;
56
+ updatedAtMs: number;
57
+ } | null;
58
+ /**
59
+ * Estimate the player jiffies value at the given server time.
60
+ * Returns a 32-bit unsigned timestamp suitable for `unpauseAt`.
61
+ */
62
+ estimateJiffiesAt(serverTimeMs: number): number;
63
+ /**
64
+ * Request an immediate clock sync sample (one `strm t` roundtrip).
65
+ * Resolves true when the matching STMt arrives, false on timeout or disconnect.
66
+ */
67
+ requestClockSync(timeoutMs?: number): Promise<boolean>;
45
68
  stop(): Promise<void>;
46
69
  play(): Promise<void>;
47
70
  pause(): Promise<void>;
package/dist/client.js CHANGED
@@ -27,6 +27,9 @@ export class SlimClient {
27
27
  _autoPlay = false;
28
28
  _heartbeatTimer = null;
29
29
  _heartbeatId = 0;
30
+ heartbeatSentAt = new Map();
31
+ pendingClockSync = new Map();
32
+ clockBase = null;
30
33
  constructor(socket, callback) {
31
34
  this.socket = socket;
32
35
  this.callback = callback;
@@ -96,6 +99,67 @@ export class SlimClient {
96
99
  get lastHeartbeatAt() {
97
100
  return this._lastTimestamp || null;
98
101
  }
102
+ /**
103
+ * Best-effort mapping between server wall clock time (ms) and player jiffies.
104
+ * Derived from `strm t` / `stat STMt` heartbeat exchange.
105
+ */
106
+ get clockSync() {
107
+ return this.clockBase;
108
+ }
109
+ /**
110
+ * Estimate the player jiffies value at the given server time.
111
+ * Returns a 32-bit unsigned timestamp suitable for `unpauseAt`.
112
+ */
113
+ estimateJiffiesAt(serverTimeMs) {
114
+ if (!this.clockBase) {
115
+ return Math.max(0, Math.round(this._jiffies + (serverTimeMs - Date.now()))) >>> 0;
116
+ }
117
+ const delta = Math.round(serverTimeMs - this.clockBase.serverTimeMs);
118
+ const target = this.clockBase.jiffies + delta;
119
+ // Keep within uint32 domain (SlimProto uses 32-bit timestamps).
120
+ return (target >>> 0);
121
+ }
122
+ /**
123
+ * Request an immediate clock sync sample (one `strm t` roundtrip).
124
+ * Resolves true when the matching STMt arrives, false on timeout or disconnect.
125
+ */
126
+ async requestClockSync(timeoutMs = 800) {
127
+ if (!this._connected)
128
+ return false;
129
+ this._heartbeatId += 1;
130
+ const id = this._heartbeatId;
131
+ const sentAt = Date.now();
132
+ this.heartbeatSentAt.set(id, sentAt);
133
+ // Clean up old send timestamps to avoid unbounded growth.
134
+ for (const [key, value] of this.heartbeatSentAt) {
135
+ if (sentAt - value > 60_000) {
136
+ this.heartbeatSentAt.delete(key);
137
+ }
138
+ }
139
+ const result = await new Promise((resolve) => {
140
+ const timeout = setTimeout(() => {
141
+ this.pendingClockSync.delete(id);
142
+ resolve(false);
143
+ }, Math.max(50, timeoutMs));
144
+ timeout.unref?.();
145
+ this.pendingClockSync.set(id, (ok) => {
146
+ clearTimeout(timeout);
147
+ this.pendingClockSync.delete(id);
148
+ resolve(ok);
149
+ });
150
+ void this.sendStrm({
151
+ command: 't',
152
+ autostart: '0',
153
+ flags: 0,
154
+ replayGain: id,
155
+ }).catch(() => {
156
+ clearTimeout(timeout);
157
+ this.pendingClockSync.delete(id);
158
+ resolve(false);
159
+ });
160
+ });
161
+ return result;
162
+ }
99
163
  async stop() {
100
164
  if (this._state === PlayerState.STOPPED)
101
165
  return;
@@ -231,14 +295,28 @@ export class SlimClient {
231
295
  'Range: bytes=0-\r\n' +
232
296
  '\r\n', 'ascii');
233
297
  this._autoPlay = autostart;
298
+ const isSyncGroup = parsed.searchParams.has('sync') && parsed.searchParams.has('expect');
299
+ const expectCount = isSyncGroup ? Number(parsed.searchParams.get('expect') ?? '0') : 0;
300
+ // For sync-groups we want BUFFER_READY quickly so we can do coordinated unpause.
301
+ // With MP3 @ 256kbps, 200KB threshold can take ~6s to fill; lowering keeps groups snappy.
302
+ const thresholdKb = isSyncGroup ? (expectCount === 1 ? 32 : 64) : 200;
303
+ // For sync-groups we prefer a bit more output buffer to avoid early underruns,
304
+ // especially with lossless streams or weaker WiFi links.
305
+ // However, `expect=1` is also used for single-player "alert" playback, where startup
306
+ // latency matters more than underrun protection. Keep output buffering near zero there.
307
+ const outputThreshold = isSyncGroup ? (expectCount === 1 ? 0 : 50) : 20;
234
308
  await this.sendStrm({
235
309
  command: 's',
236
310
  codecDetails,
237
311
  autostart: autostart ? '3' : '0',
238
312
  serverPort: port,
239
313
  serverIp: ipToInt(ipAddress),
240
- threshold: 20,
241
- outputThreshold: 5,
314
+ // Amount of input buffer (KB) before autostart or BUFFER_READY notification.
315
+ // Match aioslimproto defaults (200KB) for normal playback, but reduce for sync-groups.
316
+ threshold: thresholdKb,
317
+ // Amount of output buffer data before playback starts, in tenths of a second.
318
+ // Increase to reduce early underruns (tradeoff: more startup latency).
319
+ outputThreshold,
242
320
  transDuration: transitionDuration,
243
321
  transType: transition,
244
322
  flags: scheme === 'https' ? 0x20 : 0x00,
@@ -529,11 +607,41 @@ export class SlimClient {
529
607
  processStatHeartbeat(data) {
530
608
  if (data.length < 47)
531
609
  return;
610
+ const now = Date.now();
532
611
  const jiffies = data.readUInt32BE(21);
533
612
  const elapsedMs = data.readUInt32BE(39);
613
+ const serverHeartbeat = data.readUInt32BE(43);
534
614
  this._jiffies = jiffies;
535
615
  this._elapsedMs = elapsedMs;
536
- this._lastTimestamp = Date.now();
616
+ this._lastTimestamp = now;
617
+ const sentAt = this.heartbeatSentAt.get(serverHeartbeat);
618
+ if (typeof sentAt === 'number') {
619
+ const rttMs = Math.max(0, now - sentAt);
620
+ const midTime = sentAt + rttMs / 2;
621
+ const shouldReplace = !this.clockBase ||
622
+ now - this.clockBase.updatedAtMs > 10_000 ||
623
+ rttMs <= this.clockBase.rttMs;
624
+ if (shouldReplace) {
625
+ // Prefer the lowest-RTT sample; it yields the best wallclock<->jiffies mapping.
626
+ this.clockBase = {
627
+ serverTimeMs: midTime,
628
+ jiffies,
629
+ rttMs,
630
+ updatedAtMs: now,
631
+ };
632
+ }
633
+ this.heartbeatSentAt.delete(serverHeartbeat);
634
+ const pending = this.pendingClockSync.get(serverHeartbeat);
635
+ if (pending) {
636
+ pending(true);
637
+ }
638
+ }
639
+ else {
640
+ const pending = this.pendingClockSync.get(serverHeartbeat);
641
+ if (pending) {
642
+ pending(true);
643
+ }
644
+ }
537
645
  this.signalEvent(EventType.PLAYER_HEARTBEAT);
538
646
  }
539
647
  async processResp(data) {
@@ -626,16 +734,12 @@ export class SlimClient {
626
734
  if (this._heartbeatTimer) {
627
735
  clearInterval(this._heartbeatTimer);
628
736
  }
737
+ // Send one immediately to establish a clock base quickly.
738
+ void this.requestClockSync().catch(() => undefined);
629
739
  this._heartbeatTimer = setInterval(() => {
630
740
  if (!this._connected)
631
741
  return;
632
- this._heartbeatId += 1;
633
- void this.sendStrm({
634
- command: 't',
635
- autostart: '0',
636
- flags: 0,
637
- replayGain: this._heartbeatId,
638
- });
742
+ void this.requestClockSync().catch(() => undefined);
639
743
  }, HEARTBEAT_INTERVAL * 1000);
640
744
  }
641
745
  async togglePower() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lox-audioserver/node-slimproto",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "SlimProto server/client utilities for controlling Squeezebox-compatible players in Node.js.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/client.ts CHANGED
@@ -66,6 +66,9 @@ export class SlimClient {
66
66
  private _autoPlay = false;
67
67
  private _heartbeatTimer: NodeJS.Timeout | null = null;
68
68
  private _heartbeatId = 0;
69
+ private readonly heartbeatSentAt = new Map<number, number>();
70
+ private pendingClockSync = new Map<number, (ok: boolean) => void>();
71
+ private clockBase: { serverTimeMs: number; jiffies: number; rttMs: number; updatedAtMs: number } | null = null;
69
72
 
70
73
  constructor(socket: net.Socket, callback: SlimClientCallback) {
71
74
  this.socket = socket;
@@ -153,6 +156,69 @@ export class SlimClient {
153
156
  return this._lastTimestamp || null;
154
157
  }
155
158
 
159
+ /**
160
+ * Best-effort mapping between server wall clock time (ms) and player jiffies.
161
+ * Derived from `strm t` / `stat STMt` heartbeat exchange.
162
+ */
163
+ public get clockSync(): { serverTimeMs: number; jiffies: number; rttMs: number; updatedAtMs: number } | null {
164
+ return this.clockBase;
165
+ }
166
+
167
+ /**
168
+ * Estimate the player jiffies value at the given server time.
169
+ * Returns a 32-bit unsigned timestamp suitable for `unpauseAt`.
170
+ */
171
+ public estimateJiffiesAt(serverTimeMs: number): number {
172
+ if (!this.clockBase) {
173
+ return Math.max(0, Math.round(this._jiffies + (serverTimeMs - Date.now()))) >>> 0;
174
+ }
175
+ const delta = Math.round(serverTimeMs - this.clockBase.serverTimeMs);
176
+ const target = this.clockBase.jiffies + delta;
177
+ // Keep within uint32 domain (SlimProto uses 32-bit timestamps).
178
+ return (target >>> 0);
179
+ }
180
+
181
+ /**
182
+ * Request an immediate clock sync sample (one `strm t` roundtrip).
183
+ * Resolves true when the matching STMt arrives, false on timeout or disconnect.
184
+ */
185
+ public async requestClockSync(timeoutMs = 800): Promise<boolean> {
186
+ if (!this._connected) return false;
187
+ this._heartbeatId += 1;
188
+ const id = this._heartbeatId;
189
+ const sentAt = Date.now();
190
+ this.heartbeatSentAt.set(id, sentAt);
191
+ // Clean up old send timestamps to avoid unbounded growth.
192
+ for (const [key, value] of this.heartbeatSentAt) {
193
+ if (sentAt - value > 60_000) {
194
+ this.heartbeatSentAt.delete(key);
195
+ }
196
+ }
197
+ const result = await new Promise<boolean>((resolve) => {
198
+ const timeout = setTimeout(() => {
199
+ this.pendingClockSync.delete(id);
200
+ resolve(false);
201
+ }, Math.max(50, timeoutMs));
202
+ timeout.unref?.();
203
+ this.pendingClockSync.set(id, (ok) => {
204
+ clearTimeout(timeout);
205
+ this.pendingClockSync.delete(id);
206
+ resolve(ok);
207
+ });
208
+ void this.sendStrm({
209
+ command: 't',
210
+ autostart: '0',
211
+ flags: 0,
212
+ replayGain: id,
213
+ }).catch(() => {
214
+ clearTimeout(timeout);
215
+ this.pendingClockSync.delete(id);
216
+ resolve(false);
217
+ });
218
+ });
219
+ return result;
220
+ }
221
+
156
222
  public async stop(): Promise<void> {
157
223
  if (this._state === PlayerState.STOPPED) return;
158
224
  await this.sendStrm({ command: 'q', flags: 0 });
@@ -316,14 +382,29 @@ export class SlimClient {
316
382
 
317
383
  this._autoPlay = autostart;
318
384
 
385
+ const isSyncGroup = parsed.searchParams.has('sync') && parsed.searchParams.has('expect');
386
+ const expectCount = isSyncGroup ? Number(parsed.searchParams.get('expect') ?? '0') : 0;
387
+ // For sync-groups we want BUFFER_READY quickly so we can do coordinated unpause.
388
+ // With MP3 @ 256kbps, 200KB threshold can take ~6s to fill; lowering keeps groups snappy.
389
+ const thresholdKb = isSyncGroup ? (expectCount === 1 ? 32 : 64) : 200;
390
+ // For sync-groups we prefer a bit more output buffer to avoid early underruns,
391
+ // especially with lossless streams or weaker WiFi links.
392
+ // However, `expect=1` is also used for single-player "alert" playback, where startup
393
+ // latency matters more than underrun protection. Keep output buffering near zero there.
394
+ const outputThreshold = isSyncGroup ? (expectCount === 1 ? 0 : 50) : 20;
395
+
319
396
  await this.sendStrm({
320
397
  command: 's',
321
398
  codecDetails,
322
399
  autostart: autostart ? '3' : '0',
323
400
  serverPort: port,
324
401
  serverIp: ipToInt(ipAddress),
325
- threshold: 20,
326
- outputThreshold: 5,
402
+ // Amount of input buffer (KB) before autostart or BUFFER_READY notification.
403
+ // Match aioslimproto defaults (200KB) for normal playback, but reduce for sync-groups.
404
+ threshold: thresholdKb,
405
+ // Amount of output buffer data before playback starts, in tenths of a second.
406
+ // Increase to reduce early underruns (tradeoff: more startup latency).
407
+ outputThreshold,
327
408
  transDuration: transitionDuration,
328
409
  transType: transition,
329
410
  flags: scheme === 'https' ? 0x20 : 0x00,
@@ -636,11 +717,42 @@ export class SlimClient {
636
717
 
637
718
  private processStatHeartbeat(data: Buffer): void {
638
719
  if (data.length < 47) return;
720
+ const now = Date.now();
639
721
  const jiffies = data.readUInt32BE(21);
640
722
  const elapsedMs = data.readUInt32BE(39);
723
+ const serverHeartbeat = data.readUInt32BE(43);
641
724
  this._jiffies = jiffies;
642
725
  this._elapsedMs = elapsedMs;
643
- this._lastTimestamp = Date.now();
726
+ this._lastTimestamp = now;
727
+
728
+ const sentAt = this.heartbeatSentAt.get(serverHeartbeat);
729
+ if (typeof sentAt === 'number') {
730
+ const rttMs = Math.max(0, now - sentAt);
731
+ const midTime = sentAt + rttMs / 2;
732
+ const shouldReplace =
733
+ !this.clockBase ||
734
+ now - this.clockBase.updatedAtMs > 10_000 ||
735
+ rttMs <= this.clockBase.rttMs;
736
+ if (shouldReplace) {
737
+ // Prefer the lowest-RTT sample; it yields the best wallclock<->jiffies mapping.
738
+ this.clockBase = {
739
+ serverTimeMs: midTime,
740
+ jiffies,
741
+ rttMs,
742
+ updatedAtMs: now,
743
+ };
744
+ }
745
+ this.heartbeatSentAt.delete(serverHeartbeat);
746
+ const pending = this.pendingClockSync.get(serverHeartbeat);
747
+ if (pending) {
748
+ pending(true);
749
+ }
750
+ } else {
751
+ const pending = this.pendingClockSync.get(serverHeartbeat);
752
+ if (pending) {
753
+ pending(true);
754
+ }
755
+ }
644
756
  this.signalEvent(EventType.PLAYER_HEARTBEAT);
645
757
  }
646
758
 
@@ -754,15 +866,11 @@ export class SlimClient {
754
866
  if (this._heartbeatTimer) {
755
867
  clearInterval(this._heartbeatTimer);
756
868
  }
869
+ // Send one immediately to establish a clock base quickly.
870
+ void this.requestClockSync().catch(() => undefined);
757
871
  this._heartbeatTimer = setInterval(() => {
758
872
  if (!this._connected) return;
759
- this._heartbeatId += 1;
760
- void this.sendStrm({
761
- command: 't',
762
- autostart: '0',
763
- flags: 0,
764
- replayGain: this._heartbeatId,
765
- });
873
+ void this.requestClockSync().catch(() => undefined);
766
874
  }, HEARTBEAT_INTERVAL * 1000);
767
875
  }
768
876