@johnmorrisdotca/kyogijo-stadiums 0.60.1 → 0.62.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.
@@ -0,0 +1,113 @@
1
+ import type { GameMoment, SoundContext, SoundCue, SoundPeriod, SoundVenue } from "./sound.mix.ts";
2
+ import type { SoundLayer, SoundLevel, SoundVoice } from "./sound.sets.ts";
3
+ import type { ReplayController } from "./replay.control.ts";
4
+ import type { ReplayEvent } from "./replay.types.ts";
5
+ /** The recordings a page may give, by the layer or the voice they stand for; anything not given is made in the browser. */
6
+ export type SoundFileName = SoundLayer | SoundVoice;
7
+ /** What the engine needs of an audio context: a page's `AudioContext`, or a stand-in in a test. */
8
+ export type SoundAudioContext = BaseAudioContext & {
9
+ resume(): Promise<void>;
10
+ suspend(): Promise<void>;
11
+ };
12
+ /** How the engine fetches a recording: `fetch` by default. */
13
+ export type SoundFetch = (url: string) => Promise<{
14
+ ok: boolean;
15
+ arrayBuffer(): Promise<ArrayBuffer>;
16
+ }>;
17
+ /** A crowd level, or `auto`: the level follows the game a sound follows (`excitementAt`), and is `engaged` with no game. */
18
+ export type SoundLevelChoice = SoundLevel | "auto";
19
+ /** The settings of a surface's sound. */
20
+ export interface SoundSettings {
21
+ /** The surface (`nhl`, `soccer`, ...). A surface with no sound set makes no sound and offers no levels (`state().has`). */
22
+ sport: string;
23
+ venue?: SoundVenue;
24
+ /** The share of the seats taken, 0 to 1: 0 is no crowd (a page's Crowd off). */
25
+ crowd?: number;
26
+ view?: "2d" | "3d";
27
+ /** The crowd's level: `engaged` by default. */
28
+ level?: SoundLevelChoice;
29
+ /** 0 to 1: 0.6 by default. 0 is muted, and nothing is heard or made. */
30
+ volume?: number;
31
+ /** Recordings by name, for the voices and layers a page has files for (the demo's: `demo/sounds/`). Fetched only when the sound is first turned on, and decoded only when it is heard. */
32
+ files?: Partial<Record<SoundFileName, string>>;
33
+ /** Makes the page's one audio context: `new AudioContext()` by default. A test gives a stand-in. */
34
+ audio?: () => SoundAudioContext;
35
+ fetch?: SoundFetch;
36
+ /** The element the surface is drawn in: the sound stops while it is out of sight. */
37
+ host?: Element;
38
+ /** The page, for whether it is hidden: `document` by default. */
39
+ doc?: Document;
40
+ /** Told of every cue as it plays: a page may show it. */
41
+ onCue?: (cue: SoundCue) => void;
42
+ }
43
+ /** Why a sound is not heard. */
44
+ export type SoundQuiet = "no-sound" | "off" | "muted" | "hidden" | "out-of-sight" | "paused";
45
+ /** What a sound is doing. */
46
+ export interface SoundState {
47
+ /** The surface has a sound set: a page shows the sound's controls only when it has. */
48
+ has: boolean;
49
+ on: boolean;
50
+ /** Heard now: on, with volume, the page shown, the surface in sight and its game not paused. */
51
+ audible: boolean;
52
+ /** Why it is not heard, when it is not. */
53
+ quiet?: SoundQuiet;
54
+ sport: string;
55
+ volume: number;
56
+ level: SoundLevelChoice;
57
+ /** The level heard: the choice, the game's under `auto`, held to what the surface offers. */
58
+ heard: SoundLevel;
59
+ /** The levels the surface offers, quietest first. */
60
+ levels: readonly SoundLevel[];
61
+ /** The voices its cues use. */
62
+ voices: readonly SoundVoice[];
63
+ /** The recordings fetched (they are decoded when heard). */
64
+ fetched: readonly SoundFileName[];
65
+ /** Following a replay. */
66
+ following: boolean;
67
+ }
68
+ /** Following a replay: its periods and its score at a time, for the cues of the periods' ends and for the level under `auto`. */
69
+ export interface SoundFollow {
70
+ periods?: readonly SoundPeriod[];
71
+ scoreAt?: (t: number) => {
72
+ home: number;
73
+ away: number;
74
+ };
75
+ }
76
+ /** A surface's sound in a page. */
77
+ export interface KyogijoSound {
78
+ /** Turn it on: call it from a press, so that the browser lets the audio start. The first call makes the audio context and fetches the recordings. */
79
+ enable(): Promise<void>;
80
+ /** Turn it off: nothing is heard, made or decoded, and the audio context is suspended when no other sound of the page is heard. */
81
+ disable(): void;
82
+ setVolume(volume: number): void;
83
+ setLevel(level: SoundLevelChoice): void;
84
+ /** Change the context: another surface, venue, crowd or view. The levels and cues offered follow the surface. */
85
+ setContext(context: Partial<SoundContext>): void;
86
+ /** Follow a replay's controller: the cues of its events as they go by while it plays, quiet while it is paused, the level under `auto` from its game. Returns a function that stops it. */
87
+ follow(controller: ReplayController, game?: SoundFollow): () => void;
88
+ /** A live event, pushed as it happens: its cues play now. A goal lifts the level under `auto` for a few seconds. */
89
+ cue(event: Pick<ReplayEvent, "kind" | "team">): void;
90
+ /** The live game's state for the level under `auto`, or `undefined` for none. */
91
+ game(moment: GameMoment | undefined): void;
92
+ state(): SoundState;
93
+ /** Stop and take everything away. */
94
+ destroy(): void;
95
+ }
96
+ /** How long the sound is held on after a game's clock stopped on a cue (its end), in seconds. */
97
+ export declare const SOUND_TAIL = 5;
98
+ /**
99
+ * The sound of a surface: its crowd bed at a level and the cues of its game. Nothing happens until `enable()`, which a page calls from a press.
100
+ * @example
101
+ * ```ts no-run
102
+ * import { createKyogijoSound } from "@johnmorrisdotca/kyogijo-stadiums/sound";
103
+ * import { mountKyogijoTurn } from "@johnmorrisdotca/kyogijo-stadiums/turn";
104
+ *
105
+ * const host = document.querySelector<HTMLElement>("#rink")!;
106
+ * const sound = createKyogijoSound({ sport: "nhl", venue: { roof: "fixed", seats: 18000 }, view: "3d", level: "auto", host });
107
+ * document.querySelector("#sound")!.addEventListener("click", () => sound.enable());
108
+ * const view = mountKyogijoTurn(host, { sport: "nhl" });
109
+ * const replay = view.replay({ frames: [{ t: 0, objects: [] }, { t: 60, objects: [] }], events: [{ t: 30, kind: "goal", team: "home" }] });
110
+ * sound.follow(replay, { periods: [{ start: 0, end: 60 }] });
111
+ * ```
112
+ */
113
+ export declare function createKyogijoSound(settings: SoundSettings): KyogijoSound;
@@ -0,0 +1,508 @@
1
+ // The sound of a surface in a page: a crowd bed at a level, the room it is heard in, and the cues of a game, from a replay it follows or from events a page pushes as they happen. Off until a
2
+ // page turns it on (a browser lets audio start only after a press anyway): nothing is fetched, made or decoded before that. One audio context serves every sound of a page, and it is
3
+ // suspended whenever none of them is heard: turned off, at no volume, the page hidden, the surface out of sight or its game paused. A bed's loops play only while it is heard.
4
+ import { cueKindOf, cuesOf, cuesOfChange, excitementAt, levelFor, soundLevelsOf, soundMix, soundSetOf, soundVoicesOf } from "./sound.mix.js";
5
+ import { SOUND_BEDS } from "./sound.sets.js";
6
+ import { bufferOf, impulseSamples, layerBuffer, layerFilter, makeVoice, LAYER_SECONDS, noiseSamples } from "./sound.synth.js";
7
+ const hubs = new Map();
8
+ const DEFAULT_AUDIO = () => new AudioContext();
9
+ /** The page's audio context for a maker, made the first time a sound is turned on. */
10
+ function hubOf(audio) {
11
+ let hub = hubs.get(audio);
12
+ if (hub === undefined) {
13
+ hub = { users: new Set() };
14
+ hubs.set(audio, hub);
15
+ }
16
+ return hub;
17
+ }
18
+ /** Run the context while any sound of the page is heard, and suspend it when none is. */
19
+ function settle(hub) {
20
+ const context = hub.context;
21
+ if (context === undefined)
22
+ return;
23
+ const wanted = [...hub.users].some((user) => user.audible());
24
+ if (wanted && context.state !== "running")
25
+ void context.resume().catch(() => undefined);
26
+ else if (!wanted && context.state === "running")
27
+ void context.suspend().catch(() => undefined);
28
+ }
29
+ const clamp = (value, least, greatest) => Math.min(greatest, Math.max(least, value));
30
+ const isNum = (value) => typeof value === "number" && Number.isFinite(value);
31
+ /** How fast a level glides to another, in seconds (a time constant). */
32
+ const GLIDE = 0.6;
33
+ /** The same voice twice within this many seconds plays once. */
34
+ const SAME_VOICE = 0.2;
35
+ /** How long the sound is held on after a game's clock stopped on a cue (its end), in seconds. */
36
+ export const SOUND_TAIL = 5;
37
+ const TAIL = SOUND_TAIL;
38
+ /** How long a goal lifts the level under `auto`, in seconds. */
39
+ const GOAL_LIFT = 6;
40
+ /**
41
+ * The sound of a surface: its crowd bed at a level and the cues of its game. Nothing happens until `enable()`, which a page calls from a press.
42
+ * @example
43
+ * ```ts no-run
44
+ * import { createKyogijoSound } from "@johnmorrisdotca/kyogijo-stadiums/sound";
45
+ * import { mountKyogijoTurn } from "@johnmorrisdotca/kyogijo-stadiums/turn";
46
+ *
47
+ * const host = document.querySelector<HTMLElement>("#rink")!;
48
+ * const sound = createKyogijoSound({ sport: "nhl", venue: { roof: "fixed", seats: 18000 }, view: "3d", level: "auto", host });
49
+ * document.querySelector("#sound")!.addEventListener("click", () => sound.enable());
50
+ * const view = mountKyogijoTurn(host, { sport: "nhl" });
51
+ * const replay = view.replay({ frames: [{ t: 0, objects: [] }, { t: 60, objects: [] }], events: [{ t: 30, kind: "goal", team: "home" }] });
52
+ * sound.follow(replay, { periods: [{ start: 0, end: 60 }] });
53
+ * ```
54
+ */
55
+ export function createKyogijoSound(settings) {
56
+ const audio = settings.audio ?? DEFAULT_AUDIO;
57
+ const get = settings.fetch ?? ((url) => fetch(url));
58
+ const doc = settings.doc ?? (typeof document === "undefined" ? undefined : document);
59
+ const files = { ...settings.files };
60
+ const context = { sport: settings.sport, venue: settings.venue, crowd: settings.crowd, view: settings.view };
61
+ let level = settings.level ?? "engaged";
62
+ let volume = isNum(settings.volume) ? clamp(settings.volume, 0, 1) : 0.6;
63
+ let on = false;
64
+ let inSight = true;
65
+ let paused = false;
66
+ let following;
67
+ let followMoment;
68
+ let liveMoment;
69
+ /** The context's time until which a live goal holds the level up. */
70
+ let liftUntil = -1;
71
+ let graph;
72
+ let destroyed = false;
73
+ /** The level the bed was last glided to, and whether a setting changed since: the bed is glided only then, not on every frame of a game. */
74
+ let glidedTo;
75
+ let dirty = false;
76
+ /** The timer that ends the hold after a game's clock stopped on a cue. */
77
+ let tailing;
78
+ const hub = hubOf(audio);
79
+ const fetched = new Map();
80
+ const decoded = new Map();
81
+ const lastPlayed = new Map();
82
+ const made = { layers: new Map() };
83
+ const user = { audible: () => audible() };
84
+ hub.users.add(user);
85
+ const hidden = () => doc?.visibilityState === "hidden";
86
+ const has = () => soundSetOf(context.sport) !== undefined;
87
+ function quiet() {
88
+ if (!has())
89
+ return "no-sound";
90
+ if (!on)
91
+ return "off";
92
+ if (volume === 0)
93
+ return "muted";
94
+ if (hidden())
95
+ return "hidden";
96
+ if (!inSight)
97
+ return "out-of-sight";
98
+ if (paused)
99
+ return "paused";
100
+ return undefined;
101
+ }
102
+ function audible() {
103
+ if (destroyed)
104
+ return false;
105
+ const why = quiet();
106
+ return why === undefined || (why === "paused" && tailing !== undefined);
107
+ }
108
+ /** Hold the sound on for `TAIL` seconds after the clock stopped on a cue, then let it go quiet: one timer, once. */
109
+ function tail() {
110
+ if (tailing !== undefined)
111
+ clearTimeout(tailing);
112
+ tailing = setTimeout(() => {
113
+ tailing = undefined;
114
+ refresh();
115
+ }, TAIL * 1000);
116
+ }
117
+ function heard() {
118
+ if (level !== "auto")
119
+ return levelFor(context.sport, level);
120
+ const ctx = hub.context;
121
+ if (ctx !== undefined && ctx.currentTime < liftUntil)
122
+ return levelFor(context.sport, "roar");
123
+ const moment = followMoment ?? liveMoment;
124
+ return levelFor(context.sport, moment === undefined ? "engaged" : excitementAt(moment));
125
+ }
126
+ const mixNow = () => soundMix(context, heard());
127
+ /** The names a surface needs: its bed's layers and its cues' voices that a page has files for. */
128
+ const wanted = () => {
129
+ const found = soundSetOf(context.sport);
130
+ if (found === undefined)
131
+ return [];
132
+ return [...SOUND_BEDS[found.bed].layers, ...soundVoicesOf(context.sport)].filter((name) => typeof files[name] === "string");
133
+ };
134
+ /** Fetch what the surface needs and has not been fetched: only after the sound was first turned on. */
135
+ function fetchWanted() {
136
+ return Promise.all(wanted()
137
+ .filter((name) => !fetched.has(name) && !decoded.has(name))
138
+ .map((name) => get(files[name]).then((answer) => (answer.ok ? answer.arrayBuffer().then((bytes) => void fetched.set(name, bytes)) : void decoded.set(name, "failed")), () => void decoded.set(name, "failed")))).then(() => undefined);
139
+ }
140
+ /** Decode the fetched recordings, only while heard; when they are ready the bed is laid again with them. */
141
+ function decodeFetched() {
142
+ const ctx = hub.context;
143
+ if (ctx === undefined || !audible())
144
+ return;
145
+ for (const [name, bytes] of fetched) {
146
+ if (decoded.has(name))
147
+ continue;
148
+ const job = ctx.decodeAudioData(bytes.slice(0)).then((buffer) => {
149
+ decoded.set(name, buffer);
150
+ fetched.delete(name);
151
+ if (graph !== undefined && SOUND_BEDS[soundSetOf(context.sport)?.bed ?? "arena"].layers.includes(name))
152
+ relayBed();
153
+ }, () => {
154
+ decoded.set(name, "failed");
155
+ fetched.delete(name);
156
+ });
157
+ decoded.set(name, job);
158
+ }
159
+ }
160
+ const recording = (name) => {
161
+ const found = decoded.get(name);
162
+ return found !== undefined && typeof found === "object" && "getChannelData" in found ? found : undefined;
163
+ };
164
+ const buffers = (ctx) => ({
165
+ noise: () => (made.noise ?? (made.noise = bufferOf(ctx, [noiseSamples(Math.round(LAYER_SECONDS * ctx.sampleRate), 71, "pink")]))),
166
+ applause: () => (made.applause ?? (made.applause = layerBuffer(ctx, "applause"))),
167
+ });
168
+ const madeLayer = (ctx, layer) => {
169
+ let buffer = made.layers.get(layer);
170
+ if (buffer === undefined) {
171
+ buffer = layer === "applause" ? buffers(ctx).applause() : layerBuffer(ctx, layer);
172
+ made.layers.set(layer, buffer);
173
+ }
174
+ return buffer;
175
+ };
176
+ /** A looping source of a layer, through its filter when it is made. */
177
+ function loop(ctx, layer, into) {
178
+ const source = ctx.createBufferSource();
179
+ const recorded = recording(layer);
180
+ source.buffer = recorded ?? madeLayer(ctx, layer);
181
+ source.loop = true;
182
+ if (recorded === undefined) {
183
+ const filter = layerFilter(ctx, layer);
184
+ source.connect(filter);
185
+ filter.connect(into);
186
+ }
187
+ else
188
+ source.connect(into);
189
+ source.start();
190
+ return source;
191
+ }
192
+ function impulse(ctx, decay) {
193
+ return bufferOf(ctx, impulseSamples(decay, ctx.sampleRate, 97));
194
+ }
195
+ /** Lay the bed of the surface into the graph: a loop for each of its layers, and the roar's swell. */
196
+ function layBed(ctx, g, mix) {
197
+ for (const layer of mix.bed.layers) {
198
+ const gain = ctx.createGain();
199
+ gain.gain.value = mix.layers[layer] * mix.gain;
200
+ gain.connect(g.mix);
201
+ g.layers.set(layer, { gain, source: loop(ctx, layer, gain) });
202
+ }
203
+ if (mix.bed.layers.includes("roar")) {
204
+ const gain = ctx.createGain();
205
+ gain.gain.value = 0;
206
+ gain.connect(g.mix);
207
+ g.swell = { gain, source: loop(ctx, "roar", gain) };
208
+ }
209
+ }
210
+ function clearBed(g) {
211
+ for (const { source, gain } of [...g.layers.values(), ...(g.swell === undefined ? [] : [g.swell])]) {
212
+ source.stop();
213
+ source.disconnect();
214
+ gain.disconnect();
215
+ }
216
+ g.layers.clear();
217
+ g.swell = undefined;
218
+ }
219
+ function relayBed() {
220
+ const ctx = hub.context;
221
+ const mix = mixNow();
222
+ if (ctx === undefined || graph === undefined)
223
+ return;
224
+ clearBed(graph);
225
+ if (mix !== undefined)
226
+ layBed(ctx, graph, mix);
227
+ }
228
+ /** Make the graph and start the bed: when the sound becomes heard. */
229
+ function build(ctx) {
230
+ const mix = mixNow();
231
+ if (mix === undefined)
232
+ return;
233
+ const master = ctx.createGain();
234
+ master.gain.value = volume;
235
+ master.connect(ctx.destination);
236
+ const mixBus = ctx.createGain();
237
+ const dry = ctx.createGain();
238
+ const wet = ctx.createGain();
239
+ const convolver = ctx.createConvolver();
240
+ convolver.buffer = impulse(ctx, mix.decay);
241
+ dry.gain.value = 1 - mix.wet;
242
+ wet.gain.value = mix.wet;
243
+ mixBus.connect(dry);
244
+ mixBus.connect(convolver);
245
+ convolver.connect(wet);
246
+ dry.connect(master);
247
+ wet.connect(master);
248
+ const cues = ctx.createGain();
249
+ cues.gain.value = 0.9;
250
+ cues.connect(mixBus);
251
+ graph = { master, mix: mixBus, dry, wet, convolver, decay: mix.decay, layers: new Map(), swell: undefined, cues };
252
+ glidedTo = mix.level;
253
+ dirty = false;
254
+ layBed(ctx, graph, mix);
255
+ }
256
+ /** Stop the bed and take the graph away: when the sound stops being heard. */
257
+ function tearDown() {
258
+ if (graph === undefined)
259
+ return;
260
+ clearBed(graph);
261
+ for (const node of [graph.cues, graph.mix, graph.dry, graph.wet, graph.convolver, graph.master])
262
+ node.disconnect();
263
+ graph = undefined;
264
+ }
265
+ /** Glide the bed to the mix of now: the level, the room, the volume. */
266
+ function glide() {
267
+ const ctx = hub.context;
268
+ if (ctx === undefined || graph === undefined)
269
+ return;
270
+ const mix = mixNow();
271
+ if (mix === undefined)
272
+ return;
273
+ dirty = false;
274
+ glidedTo = mix.level;
275
+ const now = ctx.currentTime;
276
+ graph.master.gain.setTargetAtTime(volume, now, 0.05);
277
+ graph.dry.gain.setTargetAtTime(1 - mix.wet, now, GLIDE);
278
+ graph.wet.gain.setTargetAtTime(mix.wet, now, GLIDE);
279
+ for (const [layer, { gain }] of graph.layers)
280
+ gain.gain.setTargetAtTime(mix.layers[layer] * mix.gain, now, GLIDE);
281
+ if (Math.abs(mix.decay - graph.decay) > graph.decay * 0.1) {
282
+ graph.convolver.buffer = impulse(ctx, mix.decay);
283
+ graph.decay = mix.decay;
284
+ }
285
+ }
286
+ /** Bring the graph to whether the sound is heard: made when it starts being heard, taken away when it stops, and the context run or suspended. */
287
+ function refresh() {
288
+ const ctx = hub.context;
289
+ if (ctx !== undefined) {
290
+ if (audible()) {
291
+ if (graph === undefined)
292
+ build(ctx);
293
+ else if (dirty || heard() !== glidedTo)
294
+ glide();
295
+ decodeFetched();
296
+ }
297
+ else
298
+ tearDown();
299
+ }
300
+ settle(hub);
301
+ }
302
+ /**
303
+ * A recording as a cue: whole, or for the end of a game two short whistles and a long one, or for applause (a loop) three seconds that rise and fall away, so that a loop's cut is
304
+ * never heard.
305
+ */
306
+ function playRecording(ctx, out, buffer, voice, now) {
307
+ const one = (at, length) => {
308
+ const source = ctx.createBufferSource();
309
+ source.buffer = buffer;
310
+ const gain = ctx.createGain();
311
+ source.connect(gain);
312
+ gain.connect(out);
313
+ const until = at + (length ?? buffer.duration);
314
+ gain.gain.setValueAtTime(0, at);
315
+ gain.gain.linearRampToValueAtTime(1, at + (voice === "applause" ? 0.3 : 0.005));
316
+ gain.gain.setValueAtTime(1, Math.max(at + 0.01, until - (voice === "applause" ? 1.2 : 0.05)));
317
+ gain.gain.linearRampToValueAtTime(0, until);
318
+ source.start(at, 0, length ?? buffer.duration);
319
+ source.stop(until);
320
+ };
321
+ if (voice === "whistle-end") {
322
+ one(now, 0.25);
323
+ one(now + 0.4, 0.25);
324
+ one(now + 0.8);
325
+ }
326
+ else if (voice === "applause")
327
+ one(now, Math.min(buffer.duration, 3.5));
328
+ else
329
+ one(now);
330
+ }
331
+ function play(cue) {
332
+ const ctx = hub.context;
333
+ if (ctx === undefined || graph === undefined || !audible())
334
+ return;
335
+ const now = ctx.currentTime;
336
+ const last = lastPlayed.get(cue.voice);
337
+ if (last !== undefined && now - last < SAME_VOICE)
338
+ return;
339
+ lastPlayed.set(cue.voice, now);
340
+ settings.onCue?.(cue);
341
+ if (cue.voice === "swell") {
342
+ const swell = graph.swell;
343
+ const mix = mixNow();
344
+ if (swell === undefined || mix === undefined || mix.crowd === 0)
345
+ return;
346
+ const g = swell.gain.gain;
347
+ g.cancelScheduledValues(now);
348
+ g.setValueAtTime(g.value, now);
349
+ g.linearRampToValueAtTime(mix.crowd * mix.gain, now + 0.5);
350
+ g.setValueAtTime(mix.crowd * mix.gain, now + 2.5);
351
+ g.setTargetAtTime(0, now + 2.5, 1.5);
352
+ return;
353
+ }
354
+ const recorded = recording(cue.voice === "whistle-end" ? "whistle" : cue.voice);
355
+ if (recorded !== undefined)
356
+ playRecording(ctx, graph.cues, recorded, cue.voice, now);
357
+ else
358
+ makeVoice(ctx, cue.voice, graph.cues, buffers(ctx), now);
359
+ }
360
+ const onVisibility = () => refresh();
361
+ doc?.addEventListener("visibilitychange", onVisibility);
362
+ let observer;
363
+ if (settings.host !== undefined && typeof IntersectionObserver !== "undefined") {
364
+ observer = new IntersectionObserver((entries) => {
365
+ const last = entries[entries.length - 1];
366
+ if (last === undefined)
367
+ return;
368
+ inSight = last.isIntersecting;
369
+ refresh();
370
+ });
371
+ observer.observe(settings.host);
372
+ }
373
+ const sound = {
374
+ async enable() {
375
+ if (destroyed)
376
+ return;
377
+ on = true;
378
+ hub.context ?? (hub.context = audio());
379
+ refresh();
380
+ await fetchWanted();
381
+ refresh();
382
+ },
383
+ disable() {
384
+ on = false;
385
+ refresh();
386
+ },
387
+ setVolume(next) {
388
+ volume = isNum(next) ? clamp(next, 0, 1) : volume;
389
+ dirty = true;
390
+ refresh();
391
+ },
392
+ setLevel(next) {
393
+ level = next;
394
+ dirty = true;
395
+ refresh();
396
+ },
397
+ setContext(next) {
398
+ const before = soundSetOf(context.sport)?.bed;
399
+ Object.assign(context, next);
400
+ dirty = true;
401
+ if (soundSetOf(context.sport)?.bed !== before) {
402
+ tearDown();
403
+ if (on)
404
+ void fetchWanted().then(refresh);
405
+ }
406
+ refresh();
407
+ },
408
+ follow(controller, game = {}) {
409
+ following?.stop();
410
+ const periods = game.periods ?? [];
411
+ let lastT = controller.state().t;
412
+ let goalAt;
413
+ const momentOf = (t, start, end) => ({ t, start, end, periods, score: game.scoreAt?.(t), sinceGoal: goalAt === undefined ? undefined : t - goalAt });
414
+ const take = (s) => {
415
+ paused = !s.playing;
416
+ if (s.playing && tailing !== undefined) {
417
+ clearTimeout(tailing);
418
+ tailing = undefined;
419
+ }
420
+ followMoment = momentOf(s.t, s.start, s.end);
421
+ };
422
+ take(controller.state());
423
+ const stop = controller.subscribe((change) => {
424
+ const cues = cuesOfChange(change, lastT, context.sport, periods);
425
+ if (change.state.t < lastT || change.reason === "seek")
426
+ goalAt = undefined;
427
+ for (const event of change.crossed) {
428
+ const kind = cueKindOf(event);
429
+ if (change.reason === "tick" && (kind === "goal" || kind === "point"))
430
+ goalAt = event.t;
431
+ }
432
+ lastT = change.state.t;
433
+ // The cues of the last move are heard out, though the clock stopped with it (the horn at the end of the game): the sound goes quiet a few seconds later.
434
+ if (cues.length > 0 && !change.state.playing)
435
+ tail();
436
+ take(change.state);
437
+ refresh();
438
+ for (const cue of cues)
439
+ play(cue);
440
+ });
441
+ const handle = {
442
+ stop() {
443
+ stop();
444
+ if (following === handle) {
445
+ following = undefined;
446
+ followMoment = undefined;
447
+ paused = false;
448
+ refresh();
449
+ }
450
+ },
451
+ };
452
+ following = handle;
453
+ refresh();
454
+ return () => handle.stop();
455
+ },
456
+ cue(event) {
457
+ const voices = cuesOf(event, context.sport);
458
+ const ctx = hub.context;
459
+ const kind = cueKindOf(event);
460
+ if (ctx !== undefined && (kind === "goal" || kind === "point") && voices.length > 0)
461
+ liftUntil = ctx.currentTime + GOAL_LIFT;
462
+ dirty = true;
463
+ refresh();
464
+ if (kind === undefined)
465
+ return;
466
+ const t = ctx?.currentTime ?? 0;
467
+ for (const voice of voices)
468
+ play(event.team === undefined ? { voice, kind, t } : { voice, kind, t, team: event.team });
469
+ },
470
+ game(moment) {
471
+ liveMoment = moment;
472
+ refresh();
473
+ },
474
+ state() {
475
+ const why = quiet();
476
+ return {
477
+ has: has(),
478
+ on,
479
+ audible: audible(),
480
+ ...(why === undefined ? {} : { quiet: why }),
481
+ sport: context.sport,
482
+ volume,
483
+ level,
484
+ heard: heard(),
485
+ levels: soundLevelsOf(context.sport),
486
+ voices: soundVoicesOf(context.sport),
487
+ fetched: [...new Set([...fetched.keys(), ...[...decoded].filter(([, v]) => v !== "failed").map(([k]) => k)])],
488
+ following: following !== undefined,
489
+ };
490
+ },
491
+ destroy() {
492
+ if (destroyed)
493
+ return;
494
+ following?.stop();
495
+ destroyed = true;
496
+ on = false;
497
+ if (tailing !== undefined)
498
+ clearTimeout(tailing);
499
+ tailing = undefined;
500
+ tearDown();
501
+ doc?.removeEventListener("visibilitychange", onVisibility);
502
+ observer?.disconnect();
503
+ hub.users.delete(user);
504
+ settle(hub);
505
+ },
506
+ };
507
+ return sound;
508
+ }