@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 +23 -0
- package/dist/client.js +111 -10
- package/package.json +1 -1
- package/src/client.ts +115 -10
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
|
-
|
|
241
|
-
|
|
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 =
|
|
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.
|
|
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
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
|
-
|
|
326
|
-
|
|
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 =
|
|
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.
|
|
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
|
|