@mulingai-npm/redis 3.43.1 → 3.44.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.
|
@@ -78,3 +78,42 @@ export type ListenerPresence = 'present' | 'away' | 'gone';
|
|
|
78
78
|
export declare function listenerPresence(lastHeartbeatMs: number, isAway: boolean, now?: number): ListenerPresence;
|
|
79
79
|
/** Shorthand for the common question: is this listener consuming anything right now. */
|
|
80
80
|
export declare function isListenerPresent(lastHeartbeatMs: number, isAway: boolean, now?: number): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Is this listener consuming the product right now, for the purposes of deciding
|
|
83
|
+
* what to translate, what to synthesise, and what a pass is being spent on.
|
|
84
|
+
*
|
|
85
|
+
* ─── WHY THIS IS NOT JUST isListenerPresent ─────────────────────────────────
|
|
86
|
+
*
|
|
87
|
+
* A dark screen means "receiving nothing" only if the listener is READING. The
|
|
88
|
+
* whole presence model above was written from the reader's case and then applied
|
|
89
|
+
* to everyone, and for somebody listening to audio it is simply false. Earbuds
|
|
90
|
+
* in, phone in a pocket, screen off is the most ordinary way a congregation uses
|
|
91
|
+
* live audio translation, and it is `visibilityState === 'hidden'` every time.
|
|
92
|
+
*
|
|
93
|
+
* What that cost, found on 2026-08-27 while testing H7: the listener tab went to
|
|
94
|
+
* the background, the client correctly reported PRESENCE_AWAY, and the pipeline
|
|
95
|
+
* stopped synthesising mid-sentence. Chunks 358 and 359 logged "Active listener
|
|
96
|
+
* languages: [NONE]" and were skipped, sitting between chunks that generated
|
|
97
|
+
* normally, with the heartbeat only seventeen seconds old. In a service that is
|
|
98
|
+
* the congregation losing part of the sermon, with nothing on the speaker's
|
|
99
|
+
* screen to say it happened. The same mechanism withheld TRANSLATION earlier the
|
|
100
|
+
* same day, which is why the room kept going quiet for a few chunks at a time.
|
|
101
|
+
*
|
|
102
|
+
* ─── WHAT THIS DOES AND DOES NOT CONCEDE ────────────────────────────────────
|
|
103
|
+
*
|
|
104
|
+
* Only the CLIENT-DECLARED away is overridden, and only for somebody whose audio
|
|
105
|
+
* is actually on. That distinction is the whole safety of it:
|
|
106
|
+
*
|
|
107
|
+
* isAway means the page told us it is hidden. The page is alive, it is still
|
|
108
|
+
* sending heartbeats, and its audio element is still playing. We know what is
|
|
109
|
+
* happening and it is being consumed.
|
|
110
|
+
*
|
|
111
|
+
* A stale heartbeat means we have heard nothing at all for ninety seconds.
|
|
112
|
+
* That is the genuinely unknown case, and it stays not-consuming exactly as
|
|
113
|
+
* before, because a closed tab that never got to send AUDIO_PAUSE must not go
|
|
114
|
+
* on burning speech synthesis for a quarter of an hour.
|
|
115
|
+
*
|
|
116
|
+
* So a hidden reader still costs nothing, which was the original point, and a
|
|
117
|
+
* hidden listener is served, which is the product.
|
|
118
|
+
*/
|
|
119
|
+
export declare function isListenerConsuming(lastHeartbeatMs: number, isAway: boolean, isListening: boolean, now?: number): boolean;
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
* now costs nothing, because nothing expensive waits on it any more.
|
|
44
44
|
*/
|
|
45
45
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
|
-
exports.isListenerPresent = exports.listenerPresence = exports.LISTENER_HEARTBEAT_INTERVAL_MS = exports.LISTENER_GONE_MS = exports.LISTENER_PRESENT_MS = void 0;
|
|
46
|
+
exports.isListenerConsuming = exports.isListenerPresent = exports.listenerPresence = exports.LISTENER_HEARTBEAT_INTERVAL_MS = exports.LISTENER_GONE_MS = exports.LISTENER_PRESENT_MS = void 0;
|
|
47
47
|
/**
|
|
48
48
|
* How long a heartbeat stays fresh: three missed beats at the client's 30 second
|
|
49
49
|
* interval, so one dropped beat on a bad connection never marks anybody away.
|
|
@@ -91,3 +91,53 @@ function isListenerPresent(lastHeartbeatMs, isAway, now = Date.now()) {
|
|
|
91
91
|
return listenerPresence(lastHeartbeatMs, isAway, now) === 'present';
|
|
92
92
|
}
|
|
93
93
|
exports.isListenerPresent = isListenerPresent;
|
|
94
|
+
/**
|
|
95
|
+
* Is this listener consuming the product right now, for the purposes of deciding
|
|
96
|
+
* what to translate, what to synthesise, and what a pass is being spent on.
|
|
97
|
+
*
|
|
98
|
+
* ─── WHY THIS IS NOT JUST isListenerPresent ─────────────────────────────────
|
|
99
|
+
*
|
|
100
|
+
* A dark screen means "receiving nothing" only if the listener is READING. The
|
|
101
|
+
* whole presence model above was written from the reader's case and then applied
|
|
102
|
+
* to everyone, and for somebody listening to audio it is simply false. Earbuds
|
|
103
|
+
* in, phone in a pocket, screen off is the most ordinary way a congregation uses
|
|
104
|
+
* live audio translation, and it is `visibilityState === 'hidden'` every time.
|
|
105
|
+
*
|
|
106
|
+
* What that cost, found on 2026-08-27 while testing H7: the listener tab went to
|
|
107
|
+
* the background, the client correctly reported PRESENCE_AWAY, and the pipeline
|
|
108
|
+
* stopped synthesising mid-sentence. Chunks 358 and 359 logged "Active listener
|
|
109
|
+
* languages: [NONE]" and were skipped, sitting between chunks that generated
|
|
110
|
+
* normally, with the heartbeat only seventeen seconds old. In a service that is
|
|
111
|
+
* the congregation losing part of the sermon, with nothing on the speaker's
|
|
112
|
+
* screen to say it happened. The same mechanism withheld TRANSLATION earlier the
|
|
113
|
+
* same day, which is why the room kept going quiet for a few chunks at a time.
|
|
114
|
+
*
|
|
115
|
+
* ─── WHAT THIS DOES AND DOES NOT CONCEDE ────────────────────────────────────
|
|
116
|
+
*
|
|
117
|
+
* Only the CLIENT-DECLARED away is overridden, and only for somebody whose audio
|
|
118
|
+
* is actually on. That distinction is the whole safety of it:
|
|
119
|
+
*
|
|
120
|
+
* isAway means the page told us it is hidden. The page is alive, it is still
|
|
121
|
+
* sending heartbeats, and its audio element is still playing. We know what is
|
|
122
|
+
* happening and it is being consumed.
|
|
123
|
+
*
|
|
124
|
+
* A stale heartbeat means we have heard nothing at all for ninety seconds.
|
|
125
|
+
* That is the genuinely unknown case, and it stays not-consuming exactly as
|
|
126
|
+
* before, because a closed tab that never got to send AUDIO_PAUSE must not go
|
|
127
|
+
* on burning speech synthesis for a quarter of an hour.
|
|
128
|
+
*
|
|
129
|
+
* So a hidden reader still costs nothing, which was the original point, and a
|
|
130
|
+
* hidden listener is served, which is the product.
|
|
131
|
+
*/
|
|
132
|
+
function isListenerConsuming(lastHeartbeatMs, isAway, isListening, now = Date.now()) {
|
|
133
|
+
const age = now - lastHeartbeatMs;
|
|
134
|
+
// Heard nothing recently: unknown, and unknown is not consuming. Covers both
|
|
135
|
+
// 'gone' and the stale-heartbeat half of 'away'.
|
|
136
|
+
if (age > exports.LISTENER_PRESENT_MS)
|
|
137
|
+
return false;
|
|
138
|
+
// Hidden and reading only. Nothing is reaching them, so nothing is made.
|
|
139
|
+
if (isAway && !isListening)
|
|
140
|
+
return false;
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
exports.isListenerConsuming = isListenerConsuming;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { RedisClient } from '../redis-client';
|
|
2
2
|
import { ListenerPresence } from '../data/listener-presence';
|
|
3
|
-
export { LISTENER_PRESENT_MS, LISTENER_GONE_MS, LISTENER_HEARTBEAT_INTERVAL_MS, listenerPresence } from '../data/listener-presence';
|
|
3
|
+
export { LISTENER_PRESENT_MS, LISTENER_GONE_MS, LISTENER_HEARTBEAT_INTERVAL_MS, listenerPresence, isListenerConsuming } from '../data/listener-presence';
|
|
4
4
|
export type { ListenerPresence } from '../data/listener-presence';
|
|
5
5
|
export type ListenerBreakpoint = 'mobile' | 'desktop' | 'display';
|
|
6
6
|
export type MulingstreamListenerData = {
|
|
@@ -97,12 +97,19 @@ export declare class MulingstreamListenerManager {
|
|
|
97
97
|
* whether a language has an audience: a slot is released exactly when the
|
|
98
98
|
* translation for it stops, never one without the other.
|
|
99
99
|
*
|
|
100
|
-
*
|
|
101
|
-
* listener
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
100
|
+
* Consuming rather than merely registered, and that is the deliberate part. A
|
|
101
|
+
* listener we have not heard from for two minutes is not hearing the language,
|
|
102
|
+
* so holding the room's last slot for them denies it to a guest standing in
|
|
103
|
+
* the room who is. If they come back and the slot has gone to somebody else,
|
|
104
|
+
* they choose again from what the room now has; if it is still free they
|
|
105
|
+
* simply take it back.
|
|
106
|
+
*
|
|
107
|
+
* It has to be the SAME test getUniqueLanguagesByRoom uses, which is why this
|
|
108
|
+
* moved to isListenerConsuming with it on 2026-08-27 rather than being left on
|
|
109
|
+
* isListenerPresent. Leaving it would have broken the invariant this comment
|
|
110
|
+
* opens with, in the least visible direction: somebody listening with their
|
|
111
|
+
* phone in a pocket would keep receiving their language while the room quietly
|
|
112
|
+
* handed their slot to somebody else.
|
|
106
113
|
*/
|
|
107
114
|
countListenersOnLanguage(roomId: string, language: string): Promise<number>;
|
|
108
115
|
/**
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.MulingstreamListenerManager = exports.listenerPresence = exports.LISTENER_HEARTBEAT_INTERVAL_MS = exports.LISTENER_GONE_MS = exports.LISTENER_PRESENT_MS = void 0;
|
|
3
|
+
exports.MulingstreamListenerManager = exports.isListenerConsuming = exports.listenerPresence = exports.LISTENER_HEARTBEAT_INTERVAL_MS = exports.LISTENER_GONE_MS = exports.LISTENER_PRESENT_MS = void 0;
|
|
4
4
|
const listener_presence_1 = require("../data/listener-presence");
|
|
5
5
|
var listener_presence_2 = require("../data/listener-presence");
|
|
6
6
|
Object.defineProperty(exports, "LISTENER_PRESENT_MS", { enumerable: true, get: function () { return listener_presence_2.LISTENER_PRESENT_MS; } });
|
|
7
7
|
Object.defineProperty(exports, "LISTENER_GONE_MS", { enumerable: true, get: function () { return listener_presence_2.LISTENER_GONE_MS; } });
|
|
8
8
|
Object.defineProperty(exports, "LISTENER_HEARTBEAT_INTERVAL_MS", { enumerable: true, get: function () { return listener_presence_2.LISTENER_HEARTBEAT_INTERVAL_MS; } });
|
|
9
9
|
Object.defineProperty(exports, "listenerPresence", { enumerable: true, get: function () { return listener_presence_2.listenerPresence; } });
|
|
10
|
+
Object.defineProperty(exports, "isListenerConsuming", { enumerable: true, get: function () { return listener_presence_2.isListenerConsuming; } });
|
|
10
11
|
const EXPIRATION = 24 * 60 * 60; // 24 hours in seconds
|
|
11
12
|
/**
|
|
12
13
|
* Generates a bright RGB color suitable for dark backgrounds.
|
|
@@ -244,9 +245,16 @@ class MulingstreamListenerManager {
|
|
|
244
245
|
* that removed a listener was the cleanup sweep. See data/listener-presence.ts
|
|
245
246
|
* for why that single threshold could not be lowered and had to be split.
|
|
246
247
|
*/
|
|
248
|
+
/*
|
|
249
|
+
* Consuming, not merely present. A listener with audio on and a hidden screen
|
|
250
|
+
* still needs the translation their speech is synthesised from, so excluding
|
|
251
|
+
* them here would withhold the text and leave the TTS below with nothing to
|
|
252
|
+
* say. See isListenerConsuming for why a hidden READER still counts for
|
|
253
|
+
* nothing. Corrected 2026-08-27.
|
|
254
|
+
*/
|
|
247
255
|
async getUniqueLanguagesByRoom(roomId) {
|
|
248
256
|
const now = Date.now();
|
|
249
|
-
const listeners = (await this.getListenersByRoom(roomId)).filter((listener) => (0, listener_presence_1.
|
|
257
|
+
const listeners = (await this.getListenersByRoom(roomId)).filter((listener) => (0, listener_presence_1.isListenerConsuming)(listener.lastHeartbeat, listener.isAway === true, listener.isListening === true, now));
|
|
250
258
|
// Count how many times each language appears.
|
|
251
259
|
const languageCountMap = {};
|
|
252
260
|
for (const listener of listeners) {
|
|
@@ -271,19 +279,27 @@ class MulingstreamListenerManager {
|
|
|
271
279
|
* whether a language has an audience: a slot is released exactly when the
|
|
272
280
|
* translation for it stops, never one without the other.
|
|
273
281
|
*
|
|
274
|
-
*
|
|
275
|
-
* listener
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
282
|
+
* Consuming rather than merely registered, and that is the deliberate part. A
|
|
283
|
+
* listener we have not heard from for two minutes is not hearing the language,
|
|
284
|
+
* so holding the room's last slot for them denies it to a guest standing in
|
|
285
|
+
* the room who is. If they come back and the slot has gone to somebody else,
|
|
286
|
+
* they choose again from what the room now has; if it is still free they
|
|
287
|
+
* simply take it back.
|
|
288
|
+
*
|
|
289
|
+
* It has to be the SAME test getUniqueLanguagesByRoom uses, which is why this
|
|
290
|
+
* moved to isListenerConsuming with it on 2026-08-27 rather than being left on
|
|
291
|
+
* isListenerPresent. Leaving it would have broken the invariant this comment
|
|
292
|
+
* opens with, in the least visible direction: somebody listening with their
|
|
293
|
+
* phone in a pocket would keep receiving their language while the room quietly
|
|
294
|
+
* handed their slot to somebody else.
|
|
280
295
|
*/
|
|
281
296
|
async countListenersOnLanguage(roomId, language) {
|
|
282
297
|
if (!roomId || !language)
|
|
283
298
|
return 0;
|
|
284
299
|
const now = Date.now();
|
|
285
300
|
const listeners = await this.getListenersByRoom(roomId);
|
|
286
|
-
return listeners.filter((listener) => listener.language === language &&
|
|
301
|
+
return listeners.filter((listener) => listener.language === language &&
|
|
302
|
+
(0, listener_presence_1.isListenerConsuming)(listener.lastHeartbeat, listener.isAway === true, listener.isListening === true, now)).length;
|
|
287
303
|
}
|
|
288
304
|
/**
|
|
289
305
|
* The client saying its page went into the background, or came back.
|
|
@@ -384,10 +400,18 @@ class MulingstreamListenerManager {
|
|
|
384
400
|
* expensive thing we produce and nobody should pay to generate audio no one
|
|
385
401
|
* is playing. Text is already produced for the room either way.
|
|
386
402
|
*/
|
|
403
|
+
/*
|
|
404
|
+
* This decides whether a pass is being spent, so it has to agree with what we
|
|
405
|
+
* actually served. Since 2026-08-27 a hidden listener with audio on IS served,
|
|
406
|
+
* and counting them is not generosity: they are receiving the product, and a
|
|
407
|
+
* church whose congregation listens with their phones in their pockets would
|
|
408
|
+
* otherwise get every service for free while we paid for all of the speech.
|
|
409
|
+
* A hidden reader still counts for nothing, because nothing reaches them.
|
|
410
|
+
*/
|
|
387
411
|
async getReceivingCount(roomId) {
|
|
388
412
|
const now = Date.now();
|
|
389
413
|
const listeners = await this.getListenersByRoom(roomId);
|
|
390
|
-
return listeners.filter((l) => (0, listener_presence_1.
|
|
414
|
+
return listeners.filter((l) => (0, listener_presence_1.isListenerConsuming)(l.lastHeartbeat, l.isAway === true, l.isListening === true, now)).length;
|
|
391
415
|
}
|
|
392
416
|
// ─── Per-room listener capacity (design B, 2026-07-09) ────────────────────
|
|
393
417
|
// The cap value is the room owner's plan `max_audience`, resolved ONCE by the
|
|
@@ -564,9 +588,18 @@ class MulingstreamListenerManager {
|
|
|
564
588
|
continue;
|
|
565
589
|
if (!listener.isListening)
|
|
566
590
|
continue;
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
591
|
+
/*
|
|
592
|
+
* Speech synthesis is the most expensive thing we do, so this stays a
|
|
593
|
+
* real gate. What changed on 2026-08-27 is what a dark phone means.
|
|
594
|
+
*
|
|
595
|
+
* It used to skip anyone marked away, and a phone in a pocket is
|
|
596
|
+
* marked away the instant its screen goes off, which is precisely how
|
|
597
|
+
* somebody listens. So the one listener whose audio we were certain
|
|
598
|
+
* about was the one we stopped generating for, mid-sermon. The gate
|
|
599
|
+
* now turns on whether they are CONSUMING: audio on and heartbeats
|
|
600
|
+
* still arriving qualifies, a stale heartbeat still does not.
|
|
601
|
+
*/
|
|
602
|
+
if (!(0, listener_presence_1.isListenerConsuming)(listener.lastHeartbeat, listener.isAway === true, true, now))
|
|
570
603
|
continue;
|
|
571
604
|
const lang = listener.language;
|
|
572
605
|
languageCountMap[lang] = (languageCountMap[lang] || 0) + 1;
|
package/package.json
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@mulingai-npm/redis",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"main": "dist/index.js",
|
|
5
|
-
"types": "dist/index.d.ts",
|
|
6
|
-
"repository": {
|
|
7
|
-
"type": "git",
|
|
8
|
-
"url": "https://github.com/mulingai/mulingai-backend.git"
|
|
9
|
-
},
|
|
10
|
-
"publishConfig": {
|
|
11
|
-
"registry": "https://registry.npmjs.org/"
|
|
12
|
-
},
|
|
13
|
-
"private": false,
|
|
14
|
-
"scripts": {
|
|
15
|
-
"dev": "rm -f tsconfig.tsbuildinfo && tsc --watch",
|
|
16
|
-
"build": "rm -f tsconfig.tsbuildinfo && tsc",
|
|
17
|
-
"prepublishOnly": "npm run build"
|
|
18
|
-
},
|
|
19
|
-
"dependencies": {
|
|
20
|
-
"ioredis": "^5.6.0",
|
|
21
|
-
"uuid": "^11.1.0"
|
|
22
|
-
},
|
|
23
|
-
"devDependencies": {
|
|
24
|
-
"concurrently": "^9.1.2",
|
|
25
|
-
"copyfiles": "^2.4.1",
|
|
26
|
-
"nodemon": "^3.1.9",
|
|
27
|
-
"typescript": "^4.9.5"
|
|
28
|
-
},
|
|
29
|
-
"files": [
|
|
30
|
-
"dist",
|
|
31
|
-
"package.json",
|
|
32
|
-
"README.md"
|
|
33
|
-
]
|
|
34
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@mulingai-npm/redis",
|
|
3
|
+
"version": "3.44.1",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/mulingai/mulingai-backend.git"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"registry": "https://registry.npmjs.org/"
|
|
12
|
+
},
|
|
13
|
+
"private": false,
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "rm -f tsconfig.tsbuildinfo && tsc --watch",
|
|
16
|
+
"build": "rm -f tsconfig.tsbuildinfo && tsc",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"ioredis": "^5.6.0",
|
|
21
|
+
"uuid": "^11.1.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"concurrently": "^9.1.2",
|
|
25
|
+
"copyfiles": "^2.4.1",
|
|
26
|
+
"nodemon": "^3.1.9",
|
|
27
|
+
"typescript": "^4.9.5"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"package.json",
|
|
32
|
+
"README.md"
|
|
33
|
+
]
|
|
34
|
+
}
|