@mulingai-npm/redis 3.40.51 → 3.40.55

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.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * How long a listener counts as being in the room, and as being there RIGHT NOW.
3
+ *
4
+ * ─── WHY THIS LIVES IN THE REDIS PACKAGE ────────────────────────────────────
5
+ *
6
+ * Presence is decided from the listener hash in Redis and nowhere else, and
7
+ * every service that asks about it does so through MulingstreamListenerManager:
8
+ * the pipeline asking what to translate, the billing tick asking whether a pass
9
+ * is being spent, the listener service asking whether a language slot is free,
10
+ * the realtime service asking who has gone. Putting the thresholds beside the
11
+ * only code that reads them means there is exactly one definition. The obvious
12
+ * alternative, @mulingai-npm/shared, would have made this the first dependency
13
+ * between two private packages and changed the publish order for everyone, to
14
+ * hold two numbers that nothing outside this file interprets.
15
+ *
16
+ * ─── WHY THERE ARE TWO THRESHOLDS AND NOT ONE ───────────────────────────────
17
+ *
18
+ * There used to be one, at fifteen minutes, and it was answering five questions
19
+ * with completely different tolerances:
20
+ *
21
+ * "translate and synthesise this language?" wrong for seconds = money, per chunk
22
+ * "does this listener hold a language slot?" wrong for seconds = somebody shut out
23
+ * "is this pass being consumed?" wrong for minutes = a pass spent on nobody
24
+ * "how many listeners does the speaker see?" wants to be STEADY, not fast
25
+ * "when did this session end?" does not need answering in real time at all
26
+ *
27
+ * The last one is now answered from the last heartbeat at the moment the session
28
+ * is closed, so it needs no threshold at all. The rest split cleanly in two.
29
+ *
30
+ * **PRESENT** is the fast question. A phone that has been dark for two minutes is
31
+ * receiving nothing, so translating and synthesising for it costs real money for
32
+ * nobody, and holding its language slot denies that slot to a guest who is
33
+ * actually in the room. This has to lapse quickly.
34
+ *
35
+ * **IN THE ROOM** is the slow question. The speaker's listener count must not
36
+ * flicker every time a congregation locks its phones during a song, and one
37
+ * person's attendance must not be recorded as five short sessions, which is
38
+ * exactly what shortening the single threshold would have done to the analytics
39
+ * it was meant to fix.
40
+ *
41
+ * Separating them is what lets the second stay generous. Patience about departure
42
+ * now costs nothing, because nothing expensive waits on it any more.
43
+ */
44
+ /**
45
+ * How long a heartbeat stays fresh: three missed beats at the client's 30 second
46
+ * interval, so one dropped beat on a bad connection never marks anybody away.
47
+ *
48
+ * A listener past this is AWAY. Still in the room, still shown to the speaker,
49
+ * still holding their session and their identity, but not translated for, not
50
+ * counted toward a pass, and not holding a language slot. They come back the
51
+ * instant a heartbeat arrives, having lost nothing.
52
+ */
53
+ export declare const LISTENER_PRESENT_MS: number;
54
+ /**
55
+ * How long a listener stays in the room with no heartbeat before being removed
56
+ * and their session closed.
57
+ *
58
+ * Fifteen minutes, unchanged, and now deliberately generous rather than generous
59
+ * by accident. A phone locked through announcements, a worship set or a long
60
+ * prayer must not cost somebody their place. That generosity used to be paid for
61
+ * in translation nobody heard and passes spent on people who had gone home; it is
62
+ * not any more. The only thing this number now controls is how long a returning
63
+ * listener keeps their identity.
64
+ */
65
+ export declare const LISTENER_GONE_MS: number;
66
+ /** What the client sends. Here so the thresholds above can be read against it. */
67
+ export declare const LISTENER_HEARTBEAT_INTERVAL_MS: number;
68
+ export type ListenerPresence = 'present' | 'away' | 'gone';
69
+ /**
70
+ * Which of the three states a listener is in.
71
+ *
72
+ * `isAway` is the client having said so, and it is what makes a locked screen
73
+ * register at once instead of after three missed beats. It is a separate flag
74
+ * rather than a backdated heartbeat, because the heartbeat is now also the
75
+ * recorded end of the session and has to stay honest about when the listener was
76
+ * genuinely last there.
77
+ */
78
+ export declare function listenerPresence(lastHeartbeatMs: number, isAway: boolean, now?: number): ListenerPresence;
79
+ /** Shorthand for the common question: is this listener consuming anything right now. */
80
+ export declare function isListenerPresent(lastHeartbeatMs: number, isAway: boolean, now?: number): boolean;
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ /**
3
+ * How long a listener counts as being in the room, and as being there RIGHT NOW.
4
+ *
5
+ * ─── WHY THIS LIVES IN THE REDIS PACKAGE ────────────────────────────────────
6
+ *
7
+ * Presence is decided from the listener hash in Redis and nowhere else, and
8
+ * every service that asks about it does so through MulingstreamListenerManager:
9
+ * the pipeline asking what to translate, the billing tick asking whether a pass
10
+ * is being spent, the listener service asking whether a language slot is free,
11
+ * the realtime service asking who has gone. Putting the thresholds beside the
12
+ * only code that reads them means there is exactly one definition. The obvious
13
+ * alternative, @mulingai-npm/shared, would have made this the first dependency
14
+ * between two private packages and changed the publish order for everyone, to
15
+ * hold two numbers that nothing outside this file interprets.
16
+ *
17
+ * ─── WHY THERE ARE TWO THRESHOLDS AND NOT ONE ───────────────────────────────
18
+ *
19
+ * There used to be one, at fifteen minutes, and it was answering five questions
20
+ * with completely different tolerances:
21
+ *
22
+ * "translate and synthesise this language?" wrong for seconds = money, per chunk
23
+ * "does this listener hold a language slot?" wrong for seconds = somebody shut out
24
+ * "is this pass being consumed?" wrong for minutes = a pass spent on nobody
25
+ * "how many listeners does the speaker see?" wants to be STEADY, not fast
26
+ * "when did this session end?" does not need answering in real time at all
27
+ *
28
+ * The last one is now answered from the last heartbeat at the moment the session
29
+ * is closed, so it needs no threshold at all. The rest split cleanly in two.
30
+ *
31
+ * **PRESENT** is the fast question. A phone that has been dark for two minutes is
32
+ * receiving nothing, so translating and synthesising for it costs real money for
33
+ * nobody, and holding its language slot denies that slot to a guest who is
34
+ * actually in the room. This has to lapse quickly.
35
+ *
36
+ * **IN THE ROOM** is the slow question. The speaker's listener count must not
37
+ * flicker every time a congregation locks its phones during a song, and one
38
+ * person's attendance must not be recorded as five short sessions, which is
39
+ * exactly what shortening the single threshold would have done to the analytics
40
+ * it was meant to fix.
41
+ *
42
+ * Separating them is what lets the second stay generous. Patience about departure
43
+ * now costs nothing, because nothing expensive waits on it any more.
44
+ */
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;
47
+ /**
48
+ * How long a heartbeat stays fresh: three missed beats at the client's 30 second
49
+ * interval, so one dropped beat on a bad connection never marks anybody away.
50
+ *
51
+ * A listener past this is AWAY. Still in the room, still shown to the speaker,
52
+ * still holding their session and their identity, but not translated for, not
53
+ * counted toward a pass, and not holding a language slot. They come back the
54
+ * instant a heartbeat arrives, having lost nothing.
55
+ */
56
+ exports.LISTENER_PRESENT_MS = 90 * 1000;
57
+ /**
58
+ * How long a listener stays in the room with no heartbeat before being removed
59
+ * and their session closed.
60
+ *
61
+ * Fifteen minutes, unchanged, and now deliberately generous rather than generous
62
+ * by accident. A phone locked through announcements, a worship set or a long
63
+ * prayer must not cost somebody their place. That generosity used to be paid for
64
+ * in translation nobody heard and passes spent on people who had gone home; it is
65
+ * not any more. The only thing this number now controls is how long a returning
66
+ * listener keeps their identity.
67
+ */
68
+ exports.LISTENER_GONE_MS = 15 * 60 * 1000;
69
+ /** What the client sends. Here so the thresholds above can be read against it. */
70
+ exports.LISTENER_HEARTBEAT_INTERVAL_MS = 30 * 1000;
71
+ /**
72
+ * Which of the three states a listener is in.
73
+ *
74
+ * `isAway` is the client having said so, and it is what makes a locked screen
75
+ * register at once instead of after three missed beats. It is a separate flag
76
+ * rather than a backdated heartbeat, because the heartbeat is now also the
77
+ * recorded end of the session and has to stay honest about when the listener was
78
+ * genuinely last there.
79
+ */
80
+ function listenerPresence(lastHeartbeatMs, isAway, now = Date.now()) {
81
+ const age = now - lastHeartbeatMs;
82
+ if (age > exports.LISTENER_GONE_MS)
83
+ return 'gone';
84
+ if (isAway || age > exports.LISTENER_PRESENT_MS)
85
+ return 'away';
86
+ return 'present';
87
+ }
88
+ exports.listenerPresence = listenerPresence;
89
+ /** Shorthand for the common question: is this listener consuming anything right now. */
90
+ function isListenerPresent(lastHeartbeatMs, isAway, now = Date.now()) {
91
+ return listenerPresence(lastHeartbeatMs, isAway, now) === 'present';
92
+ }
93
+ exports.isListenerPresent = isListenerPresent;
@@ -1,4 +1,7 @@
1
1
  import { RedisClient } from '../redis-client';
2
+ import { ListenerPresence } from '../data/listener-presence';
3
+ export { LISTENER_PRESENT_MS, LISTENER_GONE_MS, LISTENER_HEARTBEAT_INTERVAL_MS, listenerPresence } from '../data/listener-presence';
4
+ export type { ListenerPresence } from '../data/listener-presence';
2
5
  export type ListenerBreakpoint = 'mobile' | 'desktop' | 'display';
3
6
  export type MulingstreamListenerData = {
4
7
  listenerId: string;
@@ -12,12 +15,54 @@ export type MulingstreamListenerData = {
12
15
  color?: string;
13
16
  isListening?: boolean;
14
17
  breakpoint?: ListenerBreakpoint;
18
+ /**
19
+ * The listener told us their page went into the background: screen locked,
20
+ * app switched, tab hidden.
21
+ *
22
+ * Set by the client rather than inferred, because the socket cannot tell the
23
+ * difference between a locked phone and a closed tab, and waiting for missed
24
+ * heartbeats to decide costs ninety seconds of translating for somebody who
25
+ * is demonstrably not looking. Cleared by the next heartbeat, which is what a
26
+ * returning page sends immediately.
27
+ *
28
+ * Being away is not being gone. See data/listener-presence.ts.
29
+ */
30
+ isAway?: boolean;
15
31
  };
16
32
  export declare class MulingstreamListenerManager {
17
33
  private redisClient;
18
34
  constructor(redisClient: RedisClient);
19
35
  private parseHashData;
20
36
  addListener(listenerData: MulingstreamListenerData): Promise<string>;
37
+ /**
38
+ * Every listener currently known, across every room.
39
+ *
40
+ * WHY THIS USES SCAN AND NOT KEYS
41
+ *
42
+ * It used to be `KEYS 'listener:*'`, with a TODO next to it saying it should
43
+ * not be. KEYS walks the entire keyspace in one go and Redis is single
44
+ * threaded, so for as long as it runs nothing else in the process is served:
45
+ * not a heartbeat, not a chunk being handed to the pipeline, not a listener
46
+ * joining. The cost is invisible on a development database with a few dozen
47
+ * keys and grows with the busiest moment we will ever have, which is precisely
48
+ * when it must not stall. This is called by the cleanup sweep every sixty
49
+ * seconds, for the entire life of the process, so it would have been the most
50
+ * frequently executed blocking command in the system.
51
+ *
52
+ * SCAN answers the same question in cursored batches, yielding between each,
53
+ * so a large keyspace costs more round trips rather than one long stall. The
54
+ * guarantees are weaker, deliberately: a key added or removed mid-scan may or
55
+ * may not appear. That is exactly right for the caller, which is looking for
56
+ * listeners who went silent minutes ago. One arriving during the scan is one
57
+ * for the next sweep, sixty seconds later, and there is nothing to do about a
58
+ * brand new listener anyway.
59
+ *
60
+ * The pattern also has to reject keys that merely start with `listener:`. The
61
+ * legacy `listener:panel-state:*` keys matched the old pattern and were parsed
62
+ * as listener records, producing entries with NaN timestamps. They never
63
+ * caused harm, since NaN fails every comparison, which is exactly how a
64
+ * defect like that survives.
65
+ */
21
66
  getAllListeners(): Promise<MulingstreamListenerData[]>;
22
67
  getListenersByRoom(roomId: string): Promise<MulingstreamListenerData[]>;
23
68
  getListener(listenerIdOrToken: string): Promise<MulingstreamListenerData | null>;
@@ -30,7 +75,54 @@ export declare class MulingstreamListenerManager {
30
75
  */
31
76
  updateBreakpoint(listenerIdOrToken: string, breakpoint: ListenerBreakpoint): Promise<MulingstreamListenerData | null>;
32
77
  getTargetSocketIdsByRoomLanguage(roomId: string, language: string): Promise<string[]>;
78
+ /**
79
+ * Which languages this room has someone PRESENT on.
80
+ *
81
+ * Feeds consumersFor, which decides what gets translated at all, so an
82
+ * over-generous answer here is money spent on every chunk for nobody. It
83
+ * therefore counts present listeners only: somebody whose phone has been dark
84
+ * for two minutes is receiving nothing, and translating for them is pure loss.
85
+ *
86
+ * It used to count every listener the room had ever registered, including ones
87
+ * whose heartbeat had been silent for fourteen minutes, because the only thing
88
+ * that removed a listener was the cleanup sweep. See data/listener-presence.ts
89
+ * for why that single threshold could not be lowered and had to be split.
90
+ */
33
91
  getUniqueLanguagesByRoom(roomId: string): Promise<string[]>;
92
+ /**
93
+ * How many PRESENT listeners in this room are on a language.
94
+ *
95
+ * Decides whether a guest-added language slot can be handed back, and counts
96
+ * the same population `consumersFor` reads, so the two cannot disagree about
97
+ * whether a language has an audience: a slot is released exactly when the
98
+ * translation for it stops, never one without the other.
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.
106
+ */
107
+ countListenersOnLanguage(roomId: string, language: string): Promise<number>;
108
+ /**
109
+ * The client saying its page went into the background, or came back.
110
+ *
111
+ * This is the difference between reacting to a locked screen at once and
112
+ * reacting to it after three missed heartbeats. Both end in the same state;
113
+ * one of them costs ninety seconds of translation and synthesis for a phone
114
+ * nobody is looking at, on every language in the room.
115
+ *
116
+ * Does NOT touch lastHeartbeat. That field is now also the recorded end of the
117
+ * listener's session, so it has to keep meaning "when they were genuinely last
118
+ * there" and nothing else.
119
+ */
120
+ setAwayState(listenerIdOrToken: string, isAway: boolean): Promise<MulingstreamListenerData | null>;
121
+ /**
122
+ * Present, away or gone, for one listener. For anything that needs to SHOW the
123
+ * distinction rather than act on it.
124
+ */
125
+ getPresence(listenerIdOrToken: string): Promise<ListenerPresence | null>;
34
126
  updateHeartbeat(listenerIdOrToken: string): Promise<MulingstreamListenerData | null>;
35
127
  getListenerStats(roomId: string): Promise<{
36
128
  totalListeners: number;
@@ -41,14 +133,35 @@ export declare class MulingstreamListenerManager {
41
133
  *
42
134
  * Church passes turn on this number rather than on how many people joined. A
43
135
  * pass is spent only when translation reached a human being, so somebody
44
- * sitting on the join screen, or with the tab open and audio stopped, must
45
- * not burn a church's pass.
46
- *
47
- * `isListening` is already maintained by setListeningState, so this is the
48
- * honest count rather than a proxy for it. Falls back to counting a listener
49
- * whose flag has never been written, because the flag arrived after some
50
- * clients shipped and an older client that is genuinely playing audio should
51
- * still count.
136
+ * sitting on the join screen must not burn a church's pass.
137
+ *
138
+ * ─── RECEIVING MEANS AUDIO **OR** TEXT ──────────────────────────────────
139
+ *
140
+ * Corrected 2026-08-19. This used to require `isListening`, the audio
141
+ * playback flag, so a guest who joined, chose their language and sat reading
142
+ * the translated text counted for nothing: the pass clock stayed at zero
143
+ * until they pressed Listening. David found it on a phone, and the counting
144
+ * spec had said otherwise all along, in as many words: "the listener session
145
+ * is open AND we are delivering translated output to it, audio or text".
146
+ *
147
+ * The reading is the product. A deaf guest, a guest in a quiet room, a guest
148
+ * with no headphones and a guest who simply prefers to read are all being
149
+ * served, all costing us translation on every chunk, and none of them are
150
+ * pressing an audio button. Charging only for audio would have meant a
151
+ * church whose congregation reads never spends a pass, which sounds generous
152
+ * until you notice we are paying for every word of it.
153
+ *
154
+ * So the test is PRESENCE, and nothing else. Presence already means joined,
155
+ * in a language, and with a heartbeat inside the last ninety seconds, which
156
+ * is exactly the population `consumersFor` is translating for. Whether they
157
+ * also chose to hear it is not our business, and the spec is explicit that we
158
+ * do not police attention: a phone in a pocket with audio playing is
159
+ * listening, and a phone in a hand with text on it is reading.
160
+ *
161
+ * `isListening` keeps its real job, which is a different question: whether to
162
+ * SYNTHESISE SPEECH. See getLanguagesWithActiveListeners. Speech is the most
163
+ * expensive thing we produce and nobody should pay to generate audio no one
164
+ * is playing. Text is already produced for the room either way.
52
165
  */
53
166
  getReceivingCount(roomId: string): Promise<number>;
54
167
  private capKey;
@@ -1,6 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MulingstreamListenerManager = void 0;
3
+ exports.MulingstreamListenerManager = exports.listenerPresence = exports.LISTENER_HEARTBEAT_INTERVAL_MS = exports.LISTENER_GONE_MS = exports.LISTENER_PRESENT_MS = void 0;
4
+ const listener_presence_1 = require("../data/listener-presence");
5
+ var listener_presence_2 = require("../data/listener-presence");
6
+ Object.defineProperty(exports, "LISTENER_PRESENT_MS", { enumerable: true, get: function () { return listener_presence_2.LISTENER_PRESENT_MS; } });
7
+ Object.defineProperty(exports, "LISTENER_GONE_MS", { enumerable: true, get: function () { return listener_presence_2.LISTENER_GONE_MS; } });
8
+ Object.defineProperty(exports, "LISTENER_HEARTBEAT_INTERVAL_MS", { enumerable: true, get: function () { return listener_presence_2.LISTENER_HEARTBEAT_INTERVAL_MS; } });
9
+ Object.defineProperty(exports, "listenerPresence", { enumerable: true, get: function () { return listener_presence_2.listenerPresence; } });
4
10
  const EXPIRATION = 24 * 60 * 60; // 24 hours in seconds
5
11
  /**
6
12
  * Generates a bright RGB color suitable for dark backgrounds.
@@ -48,7 +54,8 @@ class MulingstreamListenerManager {
48
54
  language: data.language || '',
49
55
  color: data.color || '',
50
56
  isListening: data.isListening === 'true',
51
- breakpoint: bp === 'mobile' || bp === 'desktop' || bp === 'display' ? bp : undefined
57
+ breakpoint: bp === 'mobile' || bp === 'desktop' || bp === 'display' ? bp : undefined,
58
+ isAway: data.isAway === 'true'
52
59
  };
53
60
  }
54
61
  // Creates a new listener.
@@ -74,25 +81,60 @@ class MulingstreamListenerManager {
74
81
  language: (_c = listenerData.language) !== null && _c !== void 0 ? _c : '',
75
82
  color,
76
83
  isListening: 'false',
77
- breakpoint: (_d = listenerData.breakpoint) !== null && _d !== void 0 ? _d : ''
84
+ breakpoint: (_d = listenerData.breakpoint) !== null && _d !== void 0 ? _d : '',
85
+ // A listener who just joined is looking at the page by definition.
86
+ isAway: 'false'
78
87
  });
79
88
  // expire listener
80
89
  await this.redisClient.expire(`listener:${listenerId}`, EXPIRATION);
81
90
  return listenerId;
82
91
  }
92
+ /**
93
+ * Every listener currently known, across every room.
94
+ *
95
+ * WHY THIS USES SCAN AND NOT KEYS
96
+ *
97
+ * It used to be `KEYS 'listener:*'`, with a TODO next to it saying it should
98
+ * not be. KEYS walks the entire keyspace in one go and Redis is single
99
+ * threaded, so for as long as it runs nothing else in the process is served:
100
+ * not a heartbeat, not a chunk being handed to the pipeline, not a listener
101
+ * joining. The cost is invisible on a development database with a few dozen
102
+ * keys and grows with the busiest moment we will ever have, which is precisely
103
+ * when it must not stall. This is called by the cleanup sweep every sixty
104
+ * seconds, for the entire life of the process, so it would have been the most
105
+ * frequently executed blocking command in the system.
106
+ *
107
+ * SCAN answers the same question in cursored batches, yielding between each,
108
+ * so a large keyspace costs more round trips rather than one long stall. The
109
+ * guarantees are weaker, deliberately: a key added or removed mid-scan may or
110
+ * may not appear. That is exactly right for the caller, which is looking for
111
+ * listeners who went silent minutes ago. One arriving during the scan is one
112
+ * for the next sweep, sixty seconds later, and there is nothing to do about a
113
+ * brand new listener anyway.
114
+ *
115
+ * The pattern also has to reject keys that merely start with `listener:`. The
116
+ * legacy `listener:panel-state:*` keys matched the old pattern and were parsed
117
+ * as listener records, producing entries with NaN timestamps. They never
118
+ * caused harm, since NaN fails every comparison, which is exactly how a
119
+ * defect like that survives.
120
+ */
83
121
  async getAllListeners() {
84
- // get all keys that match 'listener:*'
85
- // TODO: if we have many keys, consider using SCAN instead of KEYS to avoid performance issues.
86
- const keys = await this.redisClient.keys('listener:*');
87
- if (!keys || keys.length === 0) {
88
- return [];
89
- }
90
- // fetch each hash with HGETALL
91
122
  const listeners = [];
92
- for (const key of keys) {
93
- const data = await this.redisClient.hgetall(key);
94
- listeners.push(this.parseHashData(data));
95
- }
123
+ let cursor = 0;
124
+ do {
125
+ const [nextCursor, keys] = await this.redisClient.scan(cursor, 'listener:*', 500);
126
+ cursor = Number(nextCursor);
127
+ for (const key of keys || []) {
128
+ // `listener:{id}` only. Anything with a further colon belongs to
129
+ // some other feature that happens to share the prefix.
130
+ if (key.split(':').length !== 2)
131
+ continue;
132
+ const data = await this.redisClient.hgetall(key);
133
+ if (!data || Object.keys(data).length === 0)
134
+ continue;
135
+ listeners.push(this.parseHashData(data));
136
+ }
137
+ } while (cursor !== 0);
96
138
  return listeners;
97
139
  }
98
140
  async getListenersByRoom(roomId) {
@@ -189,13 +231,23 @@ class MulingstreamListenerManager {
189
231
  });
190
232
  return filteredListeners.map((listener) => listener.socketId);
191
233
  }
234
+ /**
235
+ * Which languages this room has someone PRESENT on.
236
+ *
237
+ * Feeds consumersFor, which decides what gets translated at all, so an
238
+ * over-generous answer here is money spent on every chunk for nobody. It
239
+ * therefore counts present listeners only: somebody whose phone has been dark
240
+ * for two minutes is receiving nothing, and translating for them is pure loss.
241
+ *
242
+ * It used to count every listener the room had ever registered, including ones
243
+ * whose heartbeat had been silent for fourteen minutes, because the only thing
244
+ * that removed a listener was the cleanup sweep. See data/listener-presence.ts
245
+ * for why that single threshold could not be lowered and had to be split.
246
+ */
192
247
  async getUniqueLanguagesByRoom(roomId) {
193
- // 1) Fetch all listeners for the room.
194
- // (This includes inactive ones, but feel free to filter out isActive === false if you only want active ones.)
195
- const listeners = await this.getListenersByRoom(roomId);
196
- // if I wanted to get only active listeners
197
- // const listeners = (await this.getListenersByRoom(roomId)).filter(l => l.isActive);
198
- // 2) Count how many times each language appears.
248
+ const now = Date.now();
249
+ const listeners = (await this.getListenersByRoom(roomId)).filter((listener) => (0, listener_presence_1.isListenerPresent)(listener.lastHeartbeat, listener.isAway === true, now));
250
+ // Count how many times each language appears.
199
251
  const languageCountMap = {};
200
252
  for (const listener of listeners) {
201
253
  // skip blank/unset language
@@ -211,6 +263,57 @@ class MulingstreamListenerManager {
211
263
  // 4) Map back to just the language strings in order
212
264
  return sortedEntries.map(([language]) => language);
213
265
  }
266
+ /**
267
+ * How many PRESENT listeners in this room are on a language.
268
+ *
269
+ * Decides whether a guest-added language slot can be handed back, and counts
270
+ * the same population `consumersFor` reads, so the two cannot disagree about
271
+ * whether a language has an audience: a slot is released exactly when the
272
+ * translation for it stops, never one without the other.
273
+ *
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.
280
+ */
281
+ async countListenersOnLanguage(roomId, language) {
282
+ if (!roomId || !language)
283
+ return 0;
284
+ const now = Date.now();
285
+ 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;
287
+ }
288
+ /**
289
+ * The client saying its page went into the background, or came back.
290
+ *
291
+ * This is the difference between reacting to a locked screen at once and
292
+ * reacting to it after three missed heartbeats. Both end in the same state;
293
+ * one of them costs ninety seconds of translation and synthesis for a phone
294
+ * nobody is looking at, on every language in the room.
295
+ *
296
+ * Does NOT touch lastHeartbeat. That field is now also the recorded end of the
297
+ * listener's session, so it has to keep meaning "when they were genuinely last
298
+ * there" and nothing else.
299
+ */
300
+ async setAwayState(listenerIdOrToken, isAway) {
301
+ const listener = await this.getListener(listenerIdOrToken);
302
+ if (!listener)
303
+ return null;
304
+ await this.redisClient.hset(`listener:${listener.listenerId}`, { isAway: isAway.toString() });
305
+ return { ...listener, isAway };
306
+ }
307
+ /**
308
+ * Present, away or gone, for one listener. For anything that needs to SHOW the
309
+ * distinction rather than act on it.
310
+ */
311
+ async getPresence(listenerIdOrToken) {
312
+ const listener = await this.getListener(listenerIdOrToken);
313
+ if (!listener)
314
+ return null;
315
+ return (0, listener_presence_1.listenerPresence)(listener.lastHeartbeat, listener.isAway === true);
316
+ }
214
317
  async updateHeartbeat(listenerIdOrToken) {
215
318
  const listener = await this.getListener(listenerIdOrToken);
216
319
  if (!listener) {
@@ -218,9 +321,16 @@ class MulingstreamListenerManager {
218
321
  return null;
219
322
  }
220
323
  const now = Date.now();
221
- // Update the heartbeat timestamp
324
+ // Update the heartbeat timestamp, and clear away in the same write.
325
+ //
326
+ // A heartbeat only arrives from a page that is running, so receiving one is
327
+ // proof the listener is back: an explicit "I have returned" event would be
328
+ // a second way of saying the same thing, and a second way to get it wrong.
329
+ // A returning page sends a heartbeat immediately on visibility, so this is
330
+ // as fast as anything else could be.
222
331
  await this.redisClient.hset(`listener:${listener.listenerId}`, {
223
- lastHeartbeat: now.toString()
332
+ lastHeartbeat: now.toString(),
333
+ isAway: 'false'
224
334
  });
225
335
  // Reset expiration on activity
226
336
  await this.redisClient.expire(`listener:${listener.listenerId}`, EXPIRATION);
@@ -244,18 +354,40 @@ class MulingstreamListenerManager {
244
354
  *
245
355
  * Church passes turn on this number rather than on how many people joined. A
246
356
  * pass is spent only when translation reached a human being, so somebody
247
- * sitting on the join screen, or with the tab open and audio stopped, must
248
- * not burn a church's pass.
357
+ * sitting on the join screen must not burn a church's pass.
358
+ *
359
+ * ─── RECEIVING MEANS AUDIO **OR** TEXT ──────────────────────────────────
360
+ *
361
+ * Corrected 2026-08-19. This used to require `isListening`, the audio
362
+ * playback flag, so a guest who joined, chose their language and sat reading
363
+ * the translated text counted for nothing: the pass clock stayed at zero
364
+ * until they pressed Listening. David found it on a phone, and the counting
365
+ * spec had said otherwise all along, in as many words: "the listener session
366
+ * is open AND we are delivering translated output to it, audio or text".
249
367
  *
250
- * `isListening` is already maintained by setListeningState, so this is the
251
- * honest count rather than a proxy for it. Falls back to counting a listener
252
- * whose flag has never been written, because the flag arrived after some
253
- * clients shipped and an older client that is genuinely playing audio should
254
- * still count.
368
+ * The reading is the product. A deaf guest, a guest in a quiet room, a guest
369
+ * with no headphones and a guest who simply prefers to read are all being
370
+ * served, all costing us translation on every chunk, and none of them are
371
+ * pressing an audio button. Charging only for audio would have meant a
372
+ * church whose congregation reads never spends a pass, which sounds generous
373
+ * until you notice we are paying for every word of it.
374
+ *
375
+ * So the test is PRESENCE, and nothing else. Presence already means joined,
376
+ * in a language, and with a heartbeat inside the last ninety seconds, which
377
+ * is exactly the population `consumersFor` is translating for. Whether they
378
+ * also chose to hear it is not our business, and the spec is explicit that we
379
+ * do not police attention: a phone in a pocket with audio playing is
380
+ * listening, and a phone in a hand with text on it is reading.
381
+ *
382
+ * `isListening` keeps its real job, which is a different question: whether to
383
+ * SYNTHESISE SPEECH. See getLanguagesWithActiveListeners. Speech is the most
384
+ * expensive thing we produce and nobody should pay to generate audio no one
385
+ * is playing. Text is already produced for the room either way.
255
386
  */
256
387
  async getReceivingCount(roomId) {
388
+ const now = Date.now();
257
389
  const listeners = await this.getListenersByRoom(roomId);
258
- return listeners.filter((l) => l.isListening !== false).length;
390
+ return listeners.filter((l) => (0, listener_presence_1.isListenerPresent)(l.lastHeartbeat, l.isAway === true, now)).length;
259
391
  }
260
392
  // ─── Per-room listener capacity (design B, 2026-07-09) ────────────────────
261
393
  // The cap value is the room owner's plan `max_audience`, resolved ONCE by the
@@ -424,6 +556,7 @@ class MulingstreamListenerManager {
424
556
  * Used by pipeline to determine which languages need TTS generation.
425
557
  */
426
558
  async getLanguagesWithActiveListeners(roomId) {
559
+ const now = Date.now();
427
560
  const listeners = await this.getListenersByRoom(roomId);
428
561
  const languageCountMap = {};
429
562
  for (const listener of listeners) {
@@ -431,6 +564,10 @@ class MulingstreamListenerManager {
431
564
  continue;
432
565
  if (!listener.isListening)
433
566
  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))
570
+ continue;
434
571
  const lang = listener.language;
435
572
  languageCountMap[lang] = (languageCountMap[lang] || 0) + 1;
436
573
  }
package/package.json CHANGED
@@ -1,34 +1,34 @@
1
- {
2
- "name": "@mulingai-npm/redis",
3
- "version": "3.40.51",
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.40.55",
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
+ }