@mulingai-npm/redis 3.43.1 → 3.44.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.
@@ -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
- * Present rather than merely registered, and that is the deliberate part. A
101
- * listener whose phone has been dark for two minutes is not hearing the
102
- * language, so holding the room's last slot for them denies it to a guest
103
- * standing in the room who is. If they come back and the slot has gone to
104
- * somebody else, they choose again from what the room now has; if it is still
105
- * free they simply take it back.
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.isListenerPresent)(listener.lastHeartbeat, listener.isAway === true, now));
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
- * Present rather than merely registered, and that is the deliberate part. A
275
- * listener whose phone has been dark for two minutes is not hearing the
276
- * language, so holding the room's last slot for them denies it to a guest
277
- * standing in the room who is. If they come back and the slot has gone to
278
- * somebody else, they choose again from what the room now has; if it is still
279
- * free they simply take it back.
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 && (0, listener_presence_1.isListenerPresent)(listener.lastHeartbeat, listener.isAway === true, now)).length;
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.isListenerPresent)(l.lastHeartbeat, l.isAway === true, now)).length;
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
- // Speech synthesis is the most expensive thing we do. A language whose
568
- // only listener has a dark phone must not be synthesised.
569
- if (!(0, listener_presence_1.isListenerPresent)(listener.lastHeartbeat, listener.isAway === true, now))
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.43.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
- }
1
+ {
2
+ "name": "@mulingai-npm/redis",
3
+ "version": "3.44.2",
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
+ }