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