nixamp 0.46.0 → 0.47.0
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/break-silence.d.ts +5 -5
- package/dist/break-silence.js +5 -5
- package/dist/channels.d.ts +21 -0
- package/dist/channels.js +82 -0
- package/dist/playlist-input.js +26 -10
- package/dist/station-routes.js +5 -5
- package/package.json +1 -1
- package/src/break-silence.ts +5 -5
- package/src/channels.ts +78 -0
- package/src/playlist-input.ts +24 -10
- package/src/station-routes.ts +5 -5
- package/web/dist/assets/{ar-BlY2yL5Y.js → ar-BOy98BLI.js} +1 -1
- package/web/dist/assets/{cs-BimNH85J.js → cs-BbbskosY.js} +1 -1
- package/web/dist/assets/{da-B3IBhGVZ.js → da-DHrF2OuF.js} +1 -1
- package/web/dist/assets/{de-CH-pHqCQ.js → de-Dx2W0spi.js} +1 -1
- package/web/dist/assets/{es-BI5-s8Fs.js → es-qnp4IRsp.js} +1 -1
- package/web/dist/assets/{fi-BQL91oQN.js → fi-CfPSgEs-.js} +1 -1
- package/web/dist/assets/{fr-CkzjINxM.js → fr-LKBu7Tgy.js} +1 -1
- package/web/dist/assets/{hi-DN8JNzbF.js → hi-BzyRQPpa.js} +1 -1
- package/web/dist/assets/{hu-Cgi1qJdP.js → hu-AmxPDNs7.js} +1 -1
- package/web/dist/assets/{id-7NL6kLrr.js → id-CxY-xwNQ.js} +1 -1
- package/web/dist/assets/{it-jg9ppgTc.js → it-eoKuPzEe.js} +1 -1
- package/web/dist/assets/main-DYATKV1O.js +22 -0
- package/web/dist/assets/{nl-Cyz8HiXs.js → nl-Ceg4F0rC.js} +1 -1
- package/web/dist/assets/{ru-BzkaVvIz.js → ru-BRNZUbKs.js} +1 -1
- package/web/dist/assets/{sv-CqTcD1de.js → sv-k-A6hu8-.js} +1 -1
- package/web/dist/assets/{uk-CvHTXcNv.js → uk-C9owZ3Lc.js} +1 -1
- package/web/dist/assets/{vi-BYQnyW2Q.js → vi-CtcFEtmC.js} +1 -1
- package/web/dist/assets/{zh-VSRcivxg.js → zh-DQrNE8sv.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +19 -19
- package/web/dist/assets/main-BXsE3ZHI.js +0 -22
package/dist/break-silence.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Silence in place of a break, for one listener of a station.
|
|
2
|
+
* Silence in place of a break, for one listener of a station: the last resort.
|
|
3
3
|
*
|
|
4
|
-
* A
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A listener with a listen pass (listen-pass.ts) skips breaks out of a head
|
|
5
|
+
* start of music (Channel.listenWithoutBreaks). Only when that is spent is a
|
|
6
|
+
* break replaced, by MP3 silence lasting as long as the bytes it stands in
|
|
7
|
+
* for, rather than a gap that stalls the player. Audio only; a
|
|
8
8
|
* fragmented MP4 cannot have fragments swapped, so a TV station's breaks are
|
|
9
9
|
* everybody's.
|
|
10
10
|
*
|
package/dist/break-silence.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Silence in place of a break, for one listener of a station.
|
|
2
|
+
* Silence in place of a break, for one listener of a station: the last resort.
|
|
3
3
|
*
|
|
4
|
-
* A
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A listener with a listen pass (listen-pass.ts) skips breaks out of a head
|
|
5
|
+
* start of music (Channel.listenWithoutBreaks). Only when that is spent is a
|
|
6
|
+
* break replaced, by MP3 silence lasting as long as the bytes it stands in
|
|
7
|
+
* for, rather than a gap that stalls the player. Audio only; a
|
|
8
8
|
* fragmented MP4 cannot have fragments swapped, so a TV station's breaks are
|
|
9
9
|
* everybody's.
|
|
10
10
|
*
|
package/dist/channels.d.ts
CHANGED
|
@@ -168,6 +168,12 @@ export declare const BACKLOG_AUDIO: number;
|
|
|
168
168
|
* until the channel ended, however long that was.
|
|
169
169
|
*/
|
|
170
170
|
export declare const LISTENER_QUEUE: number;
|
|
171
|
+
/**
|
|
172
|
+
* How much break-free radio a listener who skips breaks is handed on arrival:
|
|
173
|
+
* two minutes at 192k. Their player holds it, and each break they skip spends
|
|
174
|
+
* the length of the spot from it, so they never hear a gap.
|
|
175
|
+
*/
|
|
176
|
+
export declare const MUSIC_BACKLOG: number;
|
|
171
177
|
/** Media-time window for complete MP4 fragments. Byte limits are a separate
|
|
172
178
|
* memory ceiling; the measured byte rate is only a fallback for inputs with
|
|
173
179
|
* no usable fragment timestamps. It must never make low-bitrate video old. */
|
|
@@ -270,6 +276,11 @@ export declare class Channel {
|
|
|
270
276
|
* can begin at; for MP3 any point will do, a frame announces itself.
|
|
271
277
|
*/
|
|
272
278
|
private recent;
|
|
279
|
+
/** Whether what the encoder is writing now is a break (a radio list's spot). */
|
|
280
|
+
private breakOn;
|
|
281
|
+
/** The last couple of minutes of a radio list without its breaks, for listenWithoutBreaks. */
|
|
282
|
+
private music;
|
|
283
|
+
private musicBytes;
|
|
273
284
|
private fragmentClock;
|
|
274
285
|
private fragmentTimes;
|
|
275
286
|
private recentBytes;
|
|
@@ -420,6 +431,14 @@ export declare class Channel {
|
|
|
420
431
|
*/
|
|
421
432
|
private feedThrough;
|
|
422
433
|
listen(listener: Listener): () => void;
|
|
434
|
+
/**
|
|
435
|
+
* A radio list's listener who hears no breaks: handed the last couple of
|
|
436
|
+
* minutes of music at once, which their player holds, then everything but
|
|
437
|
+
* the breaks. Each skipped break spends its length of that head start, so
|
|
438
|
+
* there is no gap; only once it is all spent does a break become silence of
|
|
439
|
+
* the same length (`silence`), never the spot. Joining again refills it.
|
|
440
|
+
*/
|
|
441
|
+
listenWithoutBreaks(listener: Listener, kbps: number, silence: (out: Listener) => Listener): () => void;
|
|
423
442
|
/** Stay up with nobody watching: no longer on demand. */
|
|
424
443
|
keep(): void;
|
|
425
444
|
/** Nobody is watching an on-demand channel: give it a minute, then stop. */
|
|
@@ -485,6 +504,8 @@ export declare class Channels {
|
|
|
485
504
|
contentType(id: string): string;
|
|
486
505
|
/** Attach a listener, or null when nothing is playing on that channel. */
|
|
487
506
|
listen(id: string, listener: Listener): (() => void) | null;
|
|
507
|
+
/** Attach a listener who skips a radio list's breaks (Channel.listenWithoutBreaks). */
|
|
508
|
+
listenWithoutBreaks(id: string, listener: Listener, kbps: number, silence: (out: Listener) => Listener): (() => void) | null;
|
|
488
509
|
/** Feed a channel that already exists, for a publisher sending chunks. */
|
|
489
510
|
writeTo(id: string, chunk: Buffer): boolean;
|
|
490
511
|
/**
|
package/dist/channels.js
CHANGED
|
@@ -66,6 +66,12 @@ export const BACKLOG_AUDIO = 64 * 1024;
|
|
|
66
66
|
* until the channel ended, however long that was.
|
|
67
67
|
*/
|
|
68
68
|
export const LISTENER_QUEUE = 16 * 1024 * 1024;
|
|
69
|
+
/**
|
|
70
|
+
* How much break-free radio a listener who skips breaks is handed on arrival:
|
|
71
|
+
* two minutes at 192k. Their player holds it, and each break they skip spends
|
|
72
|
+
* the length of the spot from it, so they never hear a gap.
|
|
73
|
+
*/
|
|
74
|
+
export const MUSIC_BACKLOG = 3 * 1024 * 1024;
|
|
69
75
|
/** Media-time window for complete MP4 fragments. Byte limits are a separate
|
|
70
76
|
* memory ceiling; the measured byte rate is only a fallback for inputs with
|
|
71
77
|
* no usable fragment timestamps. It must never make low-bitrate video old. */
|
|
@@ -190,6 +196,11 @@ export class Channel {
|
|
|
190
196
|
* can begin at; for MP3 any point will do, a frame announces itself.
|
|
191
197
|
*/
|
|
192
198
|
recent = [];
|
|
199
|
+
/** Whether what the encoder is writing now is a break (a radio list's spot). */
|
|
200
|
+
breakOn = false;
|
|
201
|
+
/** The last couple of minutes of a radio list without its breaks, for listenWithoutBreaks. */
|
|
202
|
+
music = [];
|
|
203
|
+
musicBytes = 0;
|
|
193
204
|
fragmentClock = new FragmentClock();
|
|
194
205
|
fragmentTimes = new WeakMap();
|
|
195
206
|
recentBytes = 0;
|
|
@@ -412,6 +423,13 @@ export class Channel {
|
|
|
412
423
|
}
|
|
413
424
|
});
|
|
414
425
|
child.stdio[3]?.on("error", () => undefined);
|
|
426
|
+
// A radio list's output is constant-bitrate MP3, so how many bytes it
|
|
427
|
+
// has written is exactly how far it has played: whether this chunk is
|
|
428
|
+
// a break, to the frame, rather than to progress's once a second.
|
|
429
|
+
const kbps = playlistInput && this.info.kind !== "video"
|
|
430
|
+
? Number(/^(\d+)k$/.exec((playlistEncode ?? [])[playlistEncode.indexOf("-b:a") + 1] ?? "")?.[1] ?? 0)
|
|
431
|
+
: 0;
|
|
432
|
+
let written = 0;
|
|
415
433
|
child.stdout?.on("data", (chunk) => {
|
|
416
434
|
// An ffmpeg that was replaced can still have a chunk in the pipe.
|
|
417
435
|
if (this.child !== child)
|
|
@@ -421,6 +439,12 @@ export class Channel {
|
|
|
421
439
|
sent = true;
|
|
422
440
|
this.info.bytes += chunk.byteLength;
|
|
423
441
|
this.rearm(child);
|
|
442
|
+
if (kbps > 0 && playlistInput) {
|
|
443
|
+
this.breakOn = playlistInput.place((written * 8) / (kbps * 1000)).at === -1;
|
|
444
|
+
written += chunk.byteLength;
|
|
445
|
+
}
|
|
446
|
+
else
|
|
447
|
+
this.breakOn = this.info.playlistAt === -1;
|
|
424
448
|
this.emit(chunk);
|
|
425
449
|
});
|
|
426
450
|
child.stdout?.on("error", () => undefined);
|
|
@@ -669,6 +693,13 @@ export class Channel {
|
|
|
669
693
|
this.measure(chunk.byteLength);
|
|
670
694
|
if (!this.fragments) {
|
|
671
695
|
this.remember(chunk, BACKLOG_AUDIO, false);
|
|
696
|
+
if (this.info.playlist && !this.breakOn) {
|
|
697
|
+
this.music.push(chunk);
|
|
698
|
+
this.musicBytes += chunk.byteLength;
|
|
699
|
+
while (this.music.length > 1 && this.musicBytes - this.music[0].byteLength > MUSIC_BACKLOG) {
|
|
700
|
+
this.musicBytes -= this.music.shift().byteLength;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
672
703
|
this.send(chunk);
|
|
673
704
|
return;
|
|
674
705
|
}
|
|
@@ -934,6 +965,52 @@ export class Channel {
|
|
|
934
965
|
this.idleOut();
|
|
935
966
|
};
|
|
936
967
|
}
|
|
968
|
+
/**
|
|
969
|
+
* A radio list's listener who hears no breaks: handed the last couple of
|
|
970
|
+
* minutes of music at once, which their player holds, then everything but
|
|
971
|
+
* the breaks. Each skipped break spends its length of that head start, so
|
|
972
|
+
* there is no gap; only once it is all spent does a break become silence of
|
|
973
|
+
* the same length (`silence`), never the spot. Joining again refills it.
|
|
974
|
+
*/
|
|
975
|
+
listenWithoutBreaks(listener, kbps, silence) {
|
|
976
|
+
const bytesPerSecond = (kbps * 1000) / 8;
|
|
977
|
+
let ahead = 0;
|
|
978
|
+
for (const piece of this.music) {
|
|
979
|
+
try {
|
|
980
|
+
listener.write(piece);
|
|
981
|
+
}
|
|
982
|
+
catch {
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
ahead += piece.byteLength / bytesPerSecond;
|
|
986
|
+
}
|
|
987
|
+
const quiet = silence(listener);
|
|
988
|
+
const skipping = {
|
|
989
|
+
write: (chunk) => {
|
|
990
|
+
if (!this.breakOn)
|
|
991
|
+
return listener.write(chunk);
|
|
992
|
+
const seconds = chunk.byteLength / bytesPerSecond;
|
|
993
|
+
if (ahead >= seconds) {
|
|
994
|
+
ahead -= seconds;
|
|
995
|
+
return true;
|
|
996
|
+
}
|
|
997
|
+
return quiet.write(chunk);
|
|
998
|
+
},
|
|
999
|
+
end: () => listener.end(),
|
|
1000
|
+
...(listener.pending ? { pending: () => listener.pending() } : {}),
|
|
1001
|
+
};
|
|
1002
|
+
this.listeners.add(skipping);
|
|
1003
|
+
this.info.listeners = this.listeners.size;
|
|
1004
|
+
if (this.idle)
|
|
1005
|
+
clearTimeout(this.idle);
|
|
1006
|
+
this.idle = null;
|
|
1007
|
+
return () => {
|
|
1008
|
+
this.listeners.delete(skipping);
|
|
1009
|
+
this.info.listeners = this.listeners.size;
|
|
1010
|
+
if (this.ephemeral && this.listeners.size === 0)
|
|
1011
|
+
this.idleOut();
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
937
1014
|
/** Stay up with nobody watching: no longer on demand. */
|
|
938
1015
|
keep() {
|
|
939
1016
|
this.ephemeral = false;
|
|
@@ -1140,6 +1217,11 @@ export class Channels {
|
|
|
1140
1217
|
const channel = this.open.get(id);
|
|
1141
1218
|
return channel ? channel.listen(listener) : null;
|
|
1142
1219
|
}
|
|
1220
|
+
/** Attach a listener who skips a radio list's breaks (Channel.listenWithoutBreaks). */
|
|
1221
|
+
listenWithoutBreaks(id, listener, kbps, silence) {
|
|
1222
|
+
const channel = this.open.get(id);
|
|
1223
|
+
return channel ? channel.listenWithoutBreaks(listener, kbps, silence) : null;
|
|
1224
|
+
}
|
|
1143
1225
|
/** Feed a channel that already exists, for a publisher sending chunks. */
|
|
1144
1226
|
writeTo(id, chunk) {
|
|
1145
1227
|
return this.open.get(id)?.write(chunk) ?? false;
|
package/dist/playlist-input.js
CHANGED
|
@@ -64,6 +64,8 @@ export class PlaylistInputStream {
|
|
|
64
64
|
let played = 0;
|
|
65
65
|
let last = "";
|
|
66
66
|
let lastBreak = 0;
|
|
67
|
+
// A film cut for a break, to be picked up where it stopped.
|
|
68
|
+
let resume = null;
|
|
67
69
|
for (;;) {
|
|
68
70
|
if (signal.aborted)
|
|
69
71
|
break;
|
|
@@ -81,7 +83,8 @@ export class PlaylistInputStream {
|
|
|
81
83
|
step = o.order ? 0 : where >= 0 ? where + 1 : Math.min(step, files.length);
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
|
-
|
|
86
|
+
// A film cut for a break is not the end of the list: it has more to play.
|
|
87
|
+
if (step >= order.length && !resume) {
|
|
85
88
|
if (!o.repeat)
|
|
86
89
|
break;
|
|
87
90
|
// A pass where everything was passed over is a list with nothing
|
|
@@ -95,7 +98,8 @@ export class PlaylistInputStream {
|
|
|
95
98
|
}
|
|
96
99
|
{
|
|
97
100
|
// A break, when one is due: in place of the next entry, which stays
|
|
98
|
-
// next. Not at the very start
|
|
101
|
+
// next. Not at the very start. Never inside a song; a film is cut
|
|
102
|
+
// for one on the clock (below), since waiting out a film is an hour.
|
|
99
103
|
let breakFile = null;
|
|
100
104
|
const every = typeof o.breaks?.everySeconds === "function" ? o.breaks.everySeconds() : (o.breaks?.everySeconds ?? 0);
|
|
101
105
|
if (o.breaks && every > 0 && !first && offset - lastBreak >= every) {
|
|
@@ -105,15 +109,18 @@ export class PlaylistInputStream {
|
|
|
105
109
|
return;
|
|
106
110
|
}
|
|
107
111
|
const isBreak = breakFile !== null;
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
112
|
+
const resuming = isBreak ? null : resume;
|
|
113
|
+
if (resuming)
|
|
114
|
+
resume = null;
|
|
115
|
+
const at = isBreak ? -1 : resuming ? resuming.at : order[step];
|
|
116
|
+
if (!isBreak && !resuming)
|
|
110
117
|
step += 1;
|
|
111
|
-
const file = isBreak ? breakFile : files[at];
|
|
112
|
-
if (!isBreak)
|
|
118
|
+
const file = isBreak ? breakFile : resuming ? resuming.file : files[at];
|
|
119
|
+
if (!isBreak && !resuming)
|
|
113
120
|
last = file;
|
|
114
|
-
if (!isBreak && o.excluded?.(file))
|
|
121
|
+
if (!isBreak && !resuming && o.excluded?.(file))
|
|
115
122
|
continue;
|
|
116
|
-
const position = first && step - 1 === o.at ? o.position : 0;
|
|
123
|
+
const position = resuming ? resuming.position : first && step - 1 === o.at ? o.position : 0;
|
|
117
124
|
first = false;
|
|
118
125
|
let reached = 0;
|
|
119
126
|
try {
|
|
@@ -125,9 +132,14 @@ export class PlaylistInputStream {
|
|
|
125
132
|
if (codecs.tags && (codecs.tags.title || codecs.tags.artist)) {
|
|
126
133
|
this.tags.set(file, { title: codecs.tags.title ?? "", artist: codecs.tags.artist ?? "", album: codecs.tags.album ?? "" });
|
|
127
134
|
}
|
|
128
|
-
const
|
|
129
|
-
if (
|
|
135
|
+
const full = (codecs.duration ?? 0) - position;
|
|
136
|
+
if (full <= 0)
|
|
130
137
|
throw new Error(`No playable duration for playlist entry ${at + 1}`);
|
|
138
|
+
// A film plays up to the next break and is picked up after it;
|
|
139
|
+
// a tail of a few seconds is not worth a cut.
|
|
140
|
+
const room = o.video && !isBreak && o.breaks && every > 0 ? every - (offset - lastBreak) : Infinity;
|
|
141
|
+
const cut = room >= 1 && full - room > 5;
|
|
142
|
+
const duration = cut ? room : full;
|
|
131
143
|
this.entries.push({ at, file, start: offset, position });
|
|
132
144
|
const hasVideo = Boolean(codecs.video);
|
|
133
145
|
const extra = o.video && !hasVideo
|
|
@@ -200,6 +212,10 @@ export class PlaylistInputStream {
|
|
|
200
212
|
// longer than the gap cannot follow itself.
|
|
201
213
|
if (isBreak)
|
|
202
214
|
lastBreak = offset;
|
|
215
|
+
// Cut for a break, not skipped: the break is due now, and the
|
|
216
|
+
// film carries on from here after it.
|
|
217
|
+
if (cut && !skipped)
|
|
218
|
+
resume = { at, file, position: position + duration };
|
|
203
219
|
}
|
|
204
220
|
catch (error) {
|
|
205
221
|
// A break that would not play is simply no break.
|
package/dist/station-routes.js
CHANGED
|
@@ -246,7 +246,8 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
246
246
|
response.end(JSON.stringify({ error: refused.error, code: refused.code, retryAfter: refused.retryAfter }));
|
|
247
247
|
return true;
|
|
248
248
|
}
|
|
249
|
-
// Signed in at nixamp.com:
|
|
249
|
+
// Signed in at nixamp.com: no breaks, starting a little behind live so
|
|
250
|
+
// they can be left out without a gap. Radio only (Channel.listenWithoutBreaks).
|
|
250
251
|
const pass = url.searchParams.get(PASS_PARAM) ?? "";
|
|
251
252
|
const spared = method === "GET" && !station.video && pass !== "" && ctx.passes ? await ctx.passes.good(pass).catch(() => false) : false;
|
|
252
253
|
response.writeHead(200, { ...ctx.headers, "content-type": ctx.channels.contentType(channelId), "cache-control": "no-store" });
|
|
@@ -266,10 +267,9 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
266
267
|
pending: () => response.writableLength,
|
|
267
268
|
}
|
|
268
269
|
: response;
|
|
269
|
-
const
|
|
270
|
-
?
|
|
271
|
-
: metered;
|
|
272
|
-
const detach = ctx.channels.listen(channelId, counted);
|
|
270
|
+
const detach = spared
|
|
271
|
+
? ctx.channels.listenWithoutBreaks(channelId, metered, station.bitrate, (out) => quietDuringBreaks(out, station.bitrate, () => true))
|
|
272
|
+
: ctx.channels.listen(channelId, metered);
|
|
273
273
|
if (detach === null) {
|
|
274
274
|
response.end();
|
|
275
275
|
return true;
|
package/package.json
CHANGED
package/src/break-silence.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Silence in place of a break, for one listener of a station.
|
|
2
|
+
* Silence in place of a break, for one listener of a station: the last resort.
|
|
3
3
|
*
|
|
4
|
-
* A
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A listener with a listen pass (listen-pass.ts) skips breaks out of a head
|
|
5
|
+
* start of music (Channel.listenWithoutBreaks). Only when that is spent is a
|
|
6
|
+
* break replaced, by MP3 silence lasting as long as the bytes it stands in
|
|
7
|
+
* for, rather than a gap that stalls the player. Audio only; a
|
|
8
8
|
* fragmented MP4 cannot have fragments swapped, so a TV station's breaks are
|
|
9
9
|
* everybody's.
|
|
10
10
|
*
|
package/src/channels.ts
CHANGED
|
@@ -196,6 +196,12 @@ export const BACKLOG_AUDIO = 64 * 1024;
|
|
|
196
196
|
* until the channel ended, however long that was.
|
|
197
197
|
*/
|
|
198
198
|
export const LISTENER_QUEUE = 16 * 1024 * 1024;
|
|
199
|
+
/**
|
|
200
|
+
* How much break-free radio a listener who skips breaks is handed on arrival:
|
|
201
|
+
* two minutes at 192k. Their player holds it, and each break they skip spends
|
|
202
|
+
* the length of the spot from it, so they never hear a gap.
|
|
203
|
+
*/
|
|
204
|
+
export const MUSIC_BACKLOG = 3 * 1024 * 1024;
|
|
199
205
|
|
|
200
206
|
/** Media-time window for complete MP4 fragments. Byte limits are a separate
|
|
201
207
|
* memory ceiling; the measured byte rate is only a fallback for inputs with
|
|
@@ -346,6 +352,11 @@ export class Channel {
|
|
|
346
352
|
* can begin at; for MP3 any point will do, a frame announces itself.
|
|
347
353
|
*/
|
|
348
354
|
private recent: Buffer[] = [];
|
|
355
|
+
/** Whether what the encoder is writing now is a break (a radio list's spot). */
|
|
356
|
+
private breakOn = false;
|
|
357
|
+
/** The last couple of minutes of a radio list without its breaks, for listenWithoutBreaks. */
|
|
358
|
+
private music: Buffer[] = [];
|
|
359
|
+
private musicBytes = 0;
|
|
349
360
|
private fragmentClock = new FragmentClock();
|
|
350
361
|
private fragmentTimes = new WeakMap<Buffer, number>();
|
|
351
362
|
private recentBytes = 0;
|
|
@@ -582,6 +593,13 @@ export class Channel {
|
|
|
582
593
|
}
|
|
583
594
|
});
|
|
584
595
|
(child.stdio[3] as Readable | null)?.on("error", () => undefined);
|
|
596
|
+
// A radio list's output is constant-bitrate MP3, so how many bytes it
|
|
597
|
+
// has written is exactly how far it has played: whether this chunk is
|
|
598
|
+
// a break, to the frame, rather than to progress's once a second.
|
|
599
|
+
const kbps = playlistInput && this.info.kind !== "video"
|
|
600
|
+
? Number(/^(\d+)k$/.exec((playlistEncode ?? [])[playlistEncode!.indexOf("-b:a") + 1] ?? "")?.[1] ?? 0)
|
|
601
|
+
: 0;
|
|
602
|
+
let written = 0;
|
|
585
603
|
child.stdout?.on("data", (chunk: Buffer) => {
|
|
586
604
|
// An ffmpeg that was replaced can still have a chunk in the pipe.
|
|
587
605
|
if (this.child !== child) return;
|
|
@@ -589,6 +607,10 @@ export class Channel {
|
|
|
589
607
|
sent = true;
|
|
590
608
|
this.info.bytes += chunk.byteLength;
|
|
591
609
|
this.rearm(child);
|
|
610
|
+
if (kbps > 0 && playlistInput) {
|
|
611
|
+
this.breakOn = playlistInput.place((written * 8) / (kbps * 1000)).at === -1;
|
|
612
|
+
written += chunk.byteLength;
|
|
613
|
+
} else this.breakOn = this.info.playlistAt === -1;
|
|
592
614
|
this.emit(chunk);
|
|
593
615
|
});
|
|
594
616
|
child.stdout?.on("error", () => undefined);
|
|
@@ -823,6 +845,13 @@ export class Channel {
|
|
|
823
845
|
this.measure(chunk.byteLength);
|
|
824
846
|
if (!this.fragments) {
|
|
825
847
|
this.remember(chunk, BACKLOG_AUDIO, false);
|
|
848
|
+
if (this.info.playlist && !this.breakOn) {
|
|
849
|
+
this.music.push(chunk);
|
|
850
|
+
this.musicBytes += chunk.byteLength;
|
|
851
|
+
while (this.music.length > 1 && this.musicBytes - this.music[0]!.byteLength > MUSIC_BACKLOG) {
|
|
852
|
+
this.musicBytes -= this.music.shift()!.byteLength;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
826
855
|
this.send(chunk);
|
|
827
856
|
return;
|
|
828
857
|
}
|
|
@@ -1075,6 +1104,49 @@ export class Channel {
|
|
|
1075
1104
|
};
|
|
1076
1105
|
}
|
|
1077
1106
|
|
|
1107
|
+
/**
|
|
1108
|
+
* A radio list's listener who hears no breaks: handed the last couple of
|
|
1109
|
+
* minutes of music at once, which their player holds, then everything but
|
|
1110
|
+
* the breaks. Each skipped break spends its length of that head start, so
|
|
1111
|
+
* there is no gap; only once it is all spent does a break become silence of
|
|
1112
|
+
* the same length (`silence`), never the spot. Joining again refills it.
|
|
1113
|
+
*/
|
|
1114
|
+
listenWithoutBreaks(listener: Listener, kbps: number, silence: (out: Listener) => Listener): () => void {
|
|
1115
|
+
const bytesPerSecond = (kbps * 1000) / 8;
|
|
1116
|
+
let ahead = 0;
|
|
1117
|
+
for (const piece of this.music) {
|
|
1118
|
+
try {
|
|
1119
|
+
listener.write(piece);
|
|
1120
|
+
} catch {
|
|
1121
|
+
break;
|
|
1122
|
+
}
|
|
1123
|
+
ahead += piece.byteLength / bytesPerSecond;
|
|
1124
|
+
}
|
|
1125
|
+
const quiet = silence(listener);
|
|
1126
|
+
const skipping: Listener = {
|
|
1127
|
+
write: (chunk) => {
|
|
1128
|
+
if (!this.breakOn) return listener.write(chunk);
|
|
1129
|
+
const seconds = chunk.byteLength / bytesPerSecond;
|
|
1130
|
+
if (ahead >= seconds) {
|
|
1131
|
+
ahead -= seconds;
|
|
1132
|
+
return true;
|
|
1133
|
+
}
|
|
1134
|
+
return quiet.write(chunk);
|
|
1135
|
+
},
|
|
1136
|
+
end: () => listener.end(),
|
|
1137
|
+
...(listener.pending ? { pending: () => listener.pending!() } : {}),
|
|
1138
|
+
};
|
|
1139
|
+
this.listeners.add(skipping);
|
|
1140
|
+
this.info.listeners = this.listeners.size;
|
|
1141
|
+
if (this.idle) clearTimeout(this.idle);
|
|
1142
|
+
this.idle = null;
|
|
1143
|
+
return () => {
|
|
1144
|
+
this.listeners.delete(skipping);
|
|
1145
|
+
this.info.listeners = this.listeners.size;
|
|
1146
|
+
if (this.ephemeral && this.listeners.size === 0) this.idleOut();
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1078
1150
|
/** Stay up with nobody watching: no longer on demand. */
|
|
1079
1151
|
keep(): void {
|
|
1080
1152
|
this.ephemeral = false;
|
|
@@ -1300,6 +1372,12 @@ export class Channels {
|
|
|
1300
1372
|
return channel ? channel.listen(listener) : null;
|
|
1301
1373
|
}
|
|
1302
1374
|
|
|
1375
|
+
/** Attach a listener who skips a radio list's breaks (Channel.listenWithoutBreaks). */
|
|
1376
|
+
listenWithoutBreaks(id: string, listener: Listener, kbps: number, silence: (out: Listener) => Listener): (() => void) | null {
|
|
1377
|
+
const channel = this.open.get(id);
|
|
1378
|
+
return channel ? channel.listenWithoutBreaks(listener, kbps, silence) : null;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1303
1381
|
/** Feed a channel that already exists, for a publisher sending chunks. */
|
|
1304
1382
|
writeTo(id: string, chunk: Buffer): boolean {
|
|
1305
1383
|
return this.open.get(id)?.write(chunk) ?? false;
|
package/src/playlist-input.ts
CHANGED
|
@@ -109,6 +109,8 @@ export class PlaylistInputStream {
|
|
|
109
109
|
let played = 0;
|
|
110
110
|
let last = "";
|
|
111
111
|
let lastBreak = 0;
|
|
112
|
+
// A film cut for a break, to be picked up where it stopped.
|
|
113
|
+
let resume: { at: number; file: string; position: number } | null = null;
|
|
112
114
|
for (;;) {
|
|
113
115
|
if (signal.aborted) break;
|
|
114
116
|
// Asked at every boundary, so an edit lands on the next track. The
|
|
@@ -124,7 +126,8 @@ export class PlaylistInputStream {
|
|
|
124
126
|
step = o.order ? 0 : where >= 0 ? where + 1 : Math.min(step, files.length);
|
|
125
127
|
}
|
|
126
128
|
}
|
|
127
|
-
|
|
129
|
+
// A film cut for a break is not the end of the list: it has more to play.
|
|
130
|
+
if (step >= order.length && !resume) {
|
|
128
131
|
if (!o.repeat) break;
|
|
129
132
|
// A pass where everything was passed over is a list with nothing
|
|
130
133
|
// to play, and repeating it would be the loop this exists to avoid.
|
|
@@ -136,7 +139,8 @@ export class PlaylistInputStream {
|
|
|
136
139
|
}
|
|
137
140
|
{
|
|
138
141
|
// A break, when one is due: in place of the next entry, which stays
|
|
139
|
-
// next. Not at the very start
|
|
142
|
+
// next. Not at the very start. Never inside a song; a film is cut
|
|
143
|
+
// for one on the clock (below), since waiting out a film is an hour.
|
|
140
144
|
let breakFile: string | null = null;
|
|
141
145
|
const every = typeof o.breaks?.everySeconds === "function" ? o.breaks.everySeconds() : (o.breaks?.everySeconds ?? 0);
|
|
142
146
|
if (o.breaks && every > 0 && !first && offset - lastBreak >= every) {
|
|
@@ -145,12 +149,14 @@ export class PlaylistInputStream {
|
|
|
145
149
|
if (signal.aborted) return;
|
|
146
150
|
}
|
|
147
151
|
const isBreak = breakFile !== null;
|
|
148
|
-
const at = isBreak ?
|
|
149
|
-
if (
|
|
150
|
-
const
|
|
151
|
-
if (!isBreak)
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
const resuming: { at: number; file: string; position: number } | null = isBreak ? null : resume;
|
|
153
|
+
if (resuming) resume = null;
|
|
154
|
+
const at: number = isBreak ? -1 : resuming ? resuming.at : order[step]!;
|
|
155
|
+
if (!isBreak && !resuming) step += 1;
|
|
156
|
+
const file: string = isBreak ? breakFile! : resuming ? resuming.file : files[at]!;
|
|
157
|
+
if (!isBreak && !resuming) last = file;
|
|
158
|
+
if (!isBreak && !resuming && o.excluded?.(file)) continue;
|
|
159
|
+
const position: number = resuming ? resuming.position : first && step - 1 === o.at ? o.position : 0;
|
|
154
160
|
first = false;
|
|
155
161
|
let reached = 0;
|
|
156
162
|
try {
|
|
@@ -160,8 +166,13 @@ export class PlaylistInputStream {
|
|
|
160
166
|
if (codecs.tags && (codecs.tags.title || codecs.tags.artist)) {
|
|
161
167
|
this.tags.set(file, { title: codecs.tags.title ?? "", artist: codecs.tags.artist ?? "", album: codecs.tags.album ?? "" });
|
|
162
168
|
}
|
|
163
|
-
const
|
|
164
|
-
if (
|
|
169
|
+
const full = (codecs.duration ?? 0) - position;
|
|
170
|
+
if (full <= 0) throw new Error(`No playable duration for playlist entry ${at + 1}`);
|
|
171
|
+
// A film plays up to the next break and is picked up after it;
|
|
172
|
+
// a tail of a few seconds is not worth a cut.
|
|
173
|
+
const room = o.video && !isBreak && o.breaks && every > 0 ? every - (offset - lastBreak) : Infinity;
|
|
174
|
+
const cut = room >= 1 && full - room > 5;
|
|
175
|
+
const duration = cut ? room : full;
|
|
165
176
|
this.entries.push({ at, file, start: offset, position });
|
|
166
177
|
const hasVideo = Boolean(codecs.video);
|
|
167
178
|
const extra = o.video && !hasVideo
|
|
@@ -228,6 +239,9 @@ export class PlaylistInputStream {
|
|
|
228
239
|
// The next break is counted from the end of this one, so a spot
|
|
229
240
|
// longer than the gap cannot follow itself.
|
|
230
241
|
if (isBreak) lastBreak = offset;
|
|
242
|
+
// Cut for a break, not skipped: the break is due now, and the
|
|
243
|
+
// film carries on from here after it.
|
|
244
|
+
if (cut && !skipped) resume = { at, file, position: position + duration };
|
|
231
245
|
} catch (error) {
|
|
232
246
|
// A break that would not play is simply no break.
|
|
233
247
|
if (isBreak && !signal.aborted) continue;
|
package/src/station-routes.ts
CHANGED
|
@@ -329,7 +329,8 @@ export async function handleStations(
|
|
|
329
329
|
response.end(JSON.stringify({ error: refused.error, code: refused.code, retryAfter: refused.retryAfter }));
|
|
330
330
|
return true;
|
|
331
331
|
}
|
|
332
|
-
// Signed in at nixamp.com:
|
|
332
|
+
// Signed in at nixamp.com: no breaks, starting a little behind live so
|
|
333
|
+
// they can be left out without a gap. Radio only (Channel.listenWithoutBreaks).
|
|
333
334
|
const pass = url.searchParams.get(PASS_PARAM) ?? "";
|
|
334
335
|
const spared = method === "GET" && !station.video && pass !== "" && ctx.passes ? await ctx.passes.good(pass).catch(() => false) : false;
|
|
335
336
|
response.writeHead(200, { ...ctx.headers, "content-type": ctx.channels.contentType(channelId), "cache-control": "no-store" });
|
|
@@ -349,10 +350,9 @@ export async function handleStations(
|
|
|
349
350
|
pending: (): number => response.writableLength,
|
|
350
351
|
}
|
|
351
352
|
: response;
|
|
352
|
-
const
|
|
353
|
-
?
|
|
354
|
-
: metered;
|
|
355
|
-
const detach = ctx.channels.listen(channelId, counted);
|
|
353
|
+
const detach = spared
|
|
354
|
+
? ctx.channels.listenWithoutBreaks(channelId, metered, station.bitrate, (out) => quietDuringBreaks(out, station.bitrate, () => true))
|
|
355
|
+
: ctx.channels.listen(channelId, metered);
|
|
356
356
|
if (detach === null) {
|
|
357
357
|
response.end();
|
|
358
358
|
return true;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./main-
|
|
1
|
+
import{t as e}from"./main-DYATKV1O.js";var t={...e,"No parties live right now.":`لا توجد جلسات مباشرة الآن.`,"Could not refresh parties.":`تعذر تحديث الجلسات.`,"Pick a server in the directory, or paste its link here.":`اختر خادمًا من الدليل أو الصق رابطه هنا.`,"Interface language":`لغة الواجهة`,Language:`اللغة`,"Translation language":`لغة الترجمة`,"Original (auto-detect)":`الأصل (اكتشاف تلقائي)`,Play:`تشغيل`,Pause:`إيقاف مؤقت`,Stop:`إيقاف`,Next:`التالي`,Previous:`السابق`,Select:`اختيار`,"Next/Prev":`التالي/السابق`,Quit:`خروج`,"Now Playing":`قيد التشغيل`,"Spectrum Analyser":`محلل الطيف`,Playlist:`قائمة التشغيل`,Files:`الملفات`,"Open files":`فتح ملفات`,"Open folder":`فتح مجلد`,"Add to library":`إضافة إلى المكتبة`,"Replace playlist":`استبدال قائمة التشغيل`,Filter:`تصفية`,Search:`بحث`,"Nothing loaded.":`لم يُحمّل أي محتوى.`,"Empty.":`فارغ.`,"Loading…":`جارٍ التحميل…`,"Connecting…":`جارٍ الاتصال…`,Connected:`متصل`,"Not connected":`غير متصل`,"No source":`لا يوجد مصدر`,Playing:`يعمل`,Stopped:`متوقف`,Volume:`مستوى الصوت`,Seek:`موضع التشغيل`,"Full screen":`ملء الشاشة`,Download:`تنزيل`,Share:`مشاركة`,Copy:`نسخ`,"Copy link":`نسخ الرابط`,Copied:`تم النسخ`,Save:`حفظ`,Cancel:`إلغاء`,Close:`إغلاق`,Remove:`إزالة`,Delete:`حذف`,Edit:`تعديل`,Add:`إضافة`,Send:`إرسال`,Retry:`إعادة المحاولة`,Back:`رجوع`,"Next page":`الصفحة التالية`,"Previous page":`الصفحة السابقة`,"Show more":`عرض المزيد`,Panels:`اللوحات`,"Reset layout":`إعادة ضبط التخطيط`,Show:`إظهار`,Hide:`إخفاء`,Collapse:`طي`,Expand:`توسيع`,"Move up":`نقل لأعلى`,"Move down":`نقل لأسفل`,Install:`تثبيت`,"TV mode":`وضع التلفاز`,On:`مفعّل`,Off:`معطّل`,Account:`الحساب`,"Sign in":`تسجيل الدخول`,"Sign out":`تسجيل الخروج`,"Create account":`إنشاء حساب`,Email:`البريد الإلكتروني`,Password:`كلمة المرور`,"Forgot password?":`نسيت كلمة المرور؟`,"Welcome back":`مرحبًا بعودتك`,Profile:`الملف الشخصي`,Handle:`اسم المستخدم`,"Your servers":`خوادمك`,Server:`الخادم`,Connect:`اتصال`,Disconnect:`قطع الاتصال`,Directory:`الدليل`,Favourites:`المفضلة`,Follow:`متابعة`,Unfollow:`إلغاء المتابعة`,Notifications:`الإشعارات`,"Phone number":`رقم الهاتف`,"Save number":`حفظ الرقم`,Transcript:`النص المفرغ`,Captions:`الترجمة النصية`,"Translate audio":`ترجمة الصوت`,"Audio language":`لغة الصوت`,"Audio options":`خيارات الصوت`,"Keep background sound":`الاحتفاظ بأصوات الخلفية`,"Background level":`مستوى صوت الخلفية`,"Speaker voices":`أصوات المتحدثين`,"Any voice":`أي صوت`,"Male voice":`صوت رجالي`,"Female voice":`صوت نسائي`,"Original audio.":`الصوت الأصلي.`,"Buy translated audio":`شراء الصوت المترجم`,"Audio credit":`رصيد الصوت`,"Buy pass":`شراء تصريح`,Pass:`تصريح`,"Pay with":`الدفع باستخدام`,"Check payment":`التحقق من الدفع`,"Open checkout":`الانتقال للدفع`,"Free sessions":`جلسات مجانية`,"Usage rates and expiry":`الأسعار ومدة الصلاحية`,Catalogs:`الكتالوجات`,"Add catalog":`إضافة كتالوج`,"Catalog name":`اسم الكتالوج`,Link:`الرابط`,"Play link":`تشغيل الرابط`,"Go live":`بدء بث مباشر`,Publish:`نشر`,Listen:`استماع`,"Live now":`مباشر الآن`,Upcoming:`قريبًا`,Parties:`حفلات المشاهدة`,"Join party":`الانضمام للحفلة`,"Invite code":`رمز الدعوة`,Invite:`دعوة`,Chat:`الدردشة`,"Chat message":`رسالة دردشة`,People:`الأشخاص`,Speaker:`متحدث`,Speakers:`المتحدثون`,Listener:`مستمع`,Moderator:`مشرف`,"Raise hand":`رفع اليد`,"Hand raises":`الأيدي المرفوعة`,Questions:`الأسئلة`,Resources:`الموارد`,Replay:`إعادة العرض`,Schedule:`الجدول`,Event:`الفعالية`,Host:`المضيف`,About:`حول`,Stage:`المنصة`,Title:`العنوان`,Description:`الوصف`,Topic:`الموضوع`,Public:`عام`,"Anyone with the link":`أي شخص لديه الرابط`,"Invited people":`الأشخاص المدعوون`,"Who can join":`من يمكنه الانضمام`,"Date and time":`التاريخ والوقت`,"Expected length":`المدة المتوقعة`,Repeat:`تكرار`,"Does not repeat":`لا يتكرر`,"Every day":`كل يوم`,"Every week":`كل أسبوع`,Homepage:`الموقع الإلكتروني`,"Host name":`اسم المضيف`,"Profile photo URL":`رابط صورة الملف الشخصي`,"Broadcast link":`رابط البث`,"Edit event":`تعديل الفعالية`,"Save changes":`حفظ التغييرات`,"Create live":`إنشاء بث مباشر`,"Schedule class":`جدولة درس`,"Start class":`بدء الدرس`,"End event":`إنهاء الفعالية`,"End session":`إنهاء الجلسة`,"Session ended":`انتهت الجلسة`,"Event ended":`انتهت الفعالية`,Cancelled:`ملغاة`,"Replay available":`إعادة العرض متاحة`,Join:`انضمام`,"Create invite":`إنشاء دعوة`,"Invite as":`الدعوة بصفة`,"Watch now":`شاهد الآن`,"Start a room":`إنشاء غرفة`,"Recent replays":`إعادات العرض الأخيرة`,"Your account":`حسابك`,"Write with AI":`الكتابة بالذكاء الاصطناعي`,"Suggested title":`العنوان المقترح`,"Suggested description":`الوصف المقترح`,"Suggested topic":`الموضوع المقترح`,"Use these details":`استخدام هذه التفاصيل`,"Learn something live.":`تعلّم شيئًا مباشرة.`,"Screen share":`مشاركة الشاشة`,"Upload and broadcast":`رفع وبث`,Help:`مساعدة`,"Reload Player":`إعادة تحميل المشغل`,"Copy Bundled CLI Path":`نسخ مسار أداة سطر الأوامر المضمنة`,"Developer tools":`أدوات المطور`,Undo:`تراجع`,Redo:`إعادة`,Cut:`قص`,Paste:`لصق`,"Select all":`تحديد الكل`,Status:`الحالة`,"Activity log":`سجل النشاط`,Admin:`الإدارة`,"Playback controls":`عناصر التحكم بالتشغيل`,"Tracks and folders":`المقاطع والمجلدات`,"Original-language captions.":`ترجمة نصية باللغة الأصلية.`,"Skip to playback controls":`الانتقال إلى عناصر التحكم بالتشغيل`,"Skip to playlist":`الانتقال إلى قائمة التشغيل`,"Skip to transcript and translated audio":`الانتقال إلى النص المفرغ والصوت المترجم`};export{t as default};
|