@lox-audioserver/node-slimproto 0.1.0 → 0.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/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,25 @@ 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
+ // For sync-groups we want BUFFER_READY quickly so we can do coordinated unpause.
300
+ // With MP3 @ 256kbps, 200KB threshold can take ~6s to fill; lowering keeps groups snappy.
301
+ const thresholdKb = isSyncGroup ? 64 : 200;
302
+ // For sync-groups we prefer a bit more output buffer to avoid early underruns,
303
+ // especially with lossless streams or weaker WiFi links.
304
+ const outputThreshold = isSyncGroup ? 50 : 20;
234
305
  await this.sendStrm({
235
306
  command: 's',
236
307
  codecDetails,
237
308
  autostart: autostart ? '3' : '0',
238
309
  serverPort: port,
239
310
  serverIp: ipToInt(ipAddress),
240
- threshold: 20,
241
- outputThreshold: 5,
311
+ // Amount of input buffer (KB) before autostart or BUFFER_READY notification.
312
+ // Match aioslimproto defaults (200KB) for normal playback, but reduce for sync-groups.
313
+ threshold: thresholdKb,
314
+ // Amount of output buffer data before playback starts, in tenths of a second.
315
+ // Increase to reduce early underruns (tradeoff: more startup latency).
316
+ outputThreshold,
242
317
  transDuration: transitionDuration,
243
318
  transType: transition,
244
319
  flags: scheme === 'https' ? 0x20 : 0x00,
@@ -529,11 +604,41 @@ export class SlimClient {
529
604
  processStatHeartbeat(data) {
530
605
  if (data.length < 47)
531
606
  return;
607
+ const now = Date.now();
532
608
  const jiffies = data.readUInt32BE(21);
533
609
  const elapsedMs = data.readUInt32BE(39);
610
+ const serverHeartbeat = data.readUInt32BE(43);
534
611
  this._jiffies = jiffies;
535
612
  this._elapsedMs = elapsedMs;
536
- this._lastTimestamp = Date.now();
613
+ this._lastTimestamp = now;
614
+ const sentAt = this.heartbeatSentAt.get(serverHeartbeat);
615
+ if (typeof sentAt === 'number') {
616
+ const rttMs = Math.max(0, now - sentAt);
617
+ const midTime = sentAt + rttMs / 2;
618
+ const shouldReplace = !this.clockBase ||
619
+ now - this.clockBase.updatedAtMs > 10_000 ||
620
+ rttMs <= this.clockBase.rttMs;
621
+ if (shouldReplace) {
622
+ // Prefer the lowest-RTT sample; it yields the best wallclock<->jiffies mapping.
623
+ this.clockBase = {
624
+ serverTimeMs: midTime,
625
+ jiffies,
626
+ rttMs,
627
+ updatedAtMs: now,
628
+ };
629
+ }
630
+ this.heartbeatSentAt.delete(serverHeartbeat);
631
+ const pending = this.pendingClockSync.get(serverHeartbeat);
632
+ if (pending) {
633
+ pending(true);
634
+ }
635
+ }
636
+ else {
637
+ const pending = this.pendingClockSync.get(serverHeartbeat);
638
+ if (pending) {
639
+ pending(true);
640
+ }
641
+ }
537
642
  this.signalEvent(EventType.PLAYER_HEARTBEAT);
538
643
  }
539
644
  async processResp(data) {
@@ -626,16 +731,12 @@ export class SlimClient {
626
731
  if (this._heartbeatTimer) {
627
732
  clearInterval(this._heartbeatTimer);
628
733
  }
734
+ // Send one immediately to establish a clock base quickly.
735
+ void this.requestClockSync().catch(() => undefined);
629
736
  this._heartbeatTimer = setInterval(() => {
630
737
  if (!this._connected)
631
738
  return;
632
- this._heartbeatId += 1;
633
- void this.sendStrm({
634
- command: 't',
635
- autostart: '0',
636
- flags: 0,
637
- replayGain: this._heartbeatId,
638
- });
739
+ void this.requestClockSync().catch(() => undefined);
639
740
  }, HEARTBEAT_INTERVAL * 1000);
640
741
  }
641
742
  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.1",
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,26 @@ export class SlimClient {
316
382
 
317
383
  this._autoPlay = autostart;
318
384
 
385
+ const isSyncGroup = parsed.searchParams.has('sync') && parsed.searchParams.has('expect');
386
+ // For sync-groups we want BUFFER_READY quickly so we can do coordinated unpause.
387
+ // With MP3 @ 256kbps, 200KB threshold can take ~6s to fill; lowering keeps groups snappy.
388
+ const thresholdKb = isSyncGroup ? 64 : 200;
389
+ // For sync-groups we prefer a bit more output buffer to avoid early underruns,
390
+ // especially with lossless streams or weaker WiFi links.
391
+ const outputThreshold = isSyncGroup ? 50 : 20;
392
+
319
393
  await this.sendStrm({
320
394
  command: 's',
321
395
  codecDetails,
322
396
  autostart: autostart ? '3' : '0',
323
397
  serverPort: port,
324
398
  serverIp: ipToInt(ipAddress),
325
- threshold: 20,
326
- outputThreshold: 5,
399
+ // Amount of input buffer (KB) before autostart or BUFFER_READY notification.
400
+ // Match aioslimproto defaults (200KB) for normal playback, but reduce for sync-groups.
401
+ threshold: thresholdKb,
402
+ // Amount of output buffer data before playback starts, in tenths of a second.
403
+ // Increase to reduce early underruns (tradeoff: more startup latency).
404
+ outputThreshold,
327
405
  transDuration: transitionDuration,
328
406
  transType: transition,
329
407
  flags: scheme === 'https' ? 0x20 : 0x00,
@@ -636,11 +714,42 @@ export class SlimClient {
636
714
 
637
715
  private processStatHeartbeat(data: Buffer): void {
638
716
  if (data.length < 47) return;
717
+ const now = Date.now();
639
718
  const jiffies = data.readUInt32BE(21);
640
719
  const elapsedMs = data.readUInt32BE(39);
720
+ const serverHeartbeat = data.readUInt32BE(43);
641
721
  this._jiffies = jiffies;
642
722
  this._elapsedMs = elapsedMs;
643
- this._lastTimestamp = Date.now();
723
+ this._lastTimestamp = now;
724
+
725
+ const sentAt = this.heartbeatSentAt.get(serverHeartbeat);
726
+ if (typeof sentAt === 'number') {
727
+ const rttMs = Math.max(0, now - sentAt);
728
+ const midTime = sentAt + rttMs / 2;
729
+ const shouldReplace =
730
+ !this.clockBase ||
731
+ now - this.clockBase.updatedAtMs > 10_000 ||
732
+ rttMs <= this.clockBase.rttMs;
733
+ if (shouldReplace) {
734
+ // Prefer the lowest-RTT sample; it yields the best wallclock<->jiffies mapping.
735
+ this.clockBase = {
736
+ serverTimeMs: midTime,
737
+ jiffies,
738
+ rttMs,
739
+ updatedAtMs: now,
740
+ };
741
+ }
742
+ this.heartbeatSentAt.delete(serverHeartbeat);
743
+ const pending = this.pendingClockSync.get(serverHeartbeat);
744
+ if (pending) {
745
+ pending(true);
746
+ }
747
+ } else {
748
+ const pending = this.pendingClockSync.get(serverHeartbeat);
749
+ if (pending) {
750
+ pending(true);
751
+ }
752
+ }
644
753
  this.signalEvent(EventType.PLAYER_HEARTBEAT);
645
754
  }
646
755
 
@@ -754,15 +863,11 @@ export class SlimClient {
754
863
  if (this._heartbeatTimer) {
755
864
  clearInterval(this._heartbeatTimer);
756
865
  }
866
+ // Send one immediately to establish a clock base quickly.
867
+ void this.requestClockSync().catch(() => undefined);
757
868
  this._heartbeatTimer = setInterval(() => {
758
869
  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
- });
870
+ void this.requestClockSync().catch(() => undefined);
766
871
  }, HEARTBEAT_INTERVAL * 1000);
767
872
  }
768
873