@johnmorrisdotca/kyogijo-stadiums 0.60.0 → 0.61.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,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
+ }
@@ -0,0 +1,158 @@
1
+ import type { ReplayChange, ReplayEvent } from "./replay.types.ts";
2
+ import type { CueKind, SoundBed, SoundLayer, SoundLevel, SoundSet, SoundVoice } from "./sound.sets.ts";
3
+ /** The venue a surface is in, as far as the sound cares: its roof and its seats. Both are optional; without them the bed's own are used. */
4
+ export interface SoundVenue {
5
+ /**
6
+ * The kind of roof, as the venues and the stadiums give it: `open` and `canopy` leave the crowd under the sky; `cable` and `panels` (a retractable roof) mostly close it in; `fixed`, `dome`,
7
+ * `saddle` and `barrel` close it in. An unknown word is taken as the bed's own.
8
+ */
9
+ roof?: string;
10
+ /** The seats. */
11
+ seats?: number;
12
+ }
13
+ /** Where and how a surface is heard. */
14
+ export interface SoundContext {
15
+ /** The surface: a sport of the package (`nhl`, `soccer`, `judo`, ...). */
16
+ sport: string;
17
+ venue?: SoundVenue;
18
+ /** The share of the seats that are taken, 0 to 1 (1 by default): 0 is an empty building, as when a page's Crowd is off. */
19
+ crowd?: number;
20
+ /** The view: `3d` is in the building, `2d` the drawing from above, heard as a broadcast is, the crowd further off and the room drier. */
21
+ view?: "2d" | "3d";
22
+ }
23
+ /** The room a surface is heard in. */
24
+ export interface SoundRoom {
25
+ bed: SoundBed;
26
+ /** How closed in it is, 0 (open sky) to 1 (a roof all over). */
27
+ enclosure: number;
28
+ /** How long the room rings, in seconds. */
29
+ decay: number;
30
+ /** How much of the sound is the room's echo, 0 to 1. */
31
+ wet: number;
32
+ /** The people in it. */
33
+ people: number;
34
+ /** How loud the crowd is for the people in it, 0 (nobody) to a little over 1 (more than the bed was made for). */
35
+ crowd: number;
36
+ /** All of it, for the view: 1 in 3D, less from above. */
37
+ gain: number;
38
+ }
39
+ /** A mix: each layer's gain, and the room. */
40
+ export interface SoundMix extends SoundRoom {
41
+ level: SoundLevel;
42
+ layers: Readonly<Record<SoundLayer, number>>;
43
+ }
44
+ /** The set of a surface's sport, or `undefined` when it has none (a surface with no sound shows no sound controls). */
45
+ export declare function soundSetOf(sport: string): SoundSet | undefined;
46
+ /** The crowd levels a surface offers, quietest first: none for a surface with no sound. */
47
+ export declare function soundLevelsOf(sport: string): readonly SoundLevel[];
48
+ /** The voices a surface's cues use, each once, in the order of `CUE_KINDS`: what a page loads when it has files for them. */
49
+ export declare function soundVoicesOf(sport: string): readonly SoundVoice[];
50
+ /**
51
+ * The room of a surface in its context: how closed in the venue is (its roof, else its bed's), how long it rings (longer closed in and larger), how much echo, and how loud the crowd is for the
52
+ * people in it (the seats times the share taken, on a scale that grows as a crowd's sound does, slower than its number). `undefined` for a surface with no sound.
53
+ * @example
54
+ * ```ts
55
+ * import { soundRoom } from "@johnmorrisdotca/kyogijo-stadiums/sound";
56
+ * const arena = soundRoom({ sport: "nhl", venue: { roof: "fixed", seats: 18000 } })!;
57
+ * const field = soundRoom({ sport: "soccer", venue: { roof: "open", seats: 50000 } })!;
58
+ * console.log(arena.bed.id, arena.decay > field.decay, arena.wet > field.wet);
59
+ * console.log(soundRoom({ sport: "nhl", crowd: 0 })!.crowd);
60
+ * // arena true true
61
+ * // 0
62
+ * ```
63
+ */
64
+ export declare function soundRoom(context: SoundContext): SoundRoom | undefined;
65
+ /** Each layer's gain at each level, before the crowd's size: the room and the water stay, the voices come up. */
66
+ export declare const LEVEL_MIX: Readonly<Record<SoundLevel, Readonly<Record<SoundLayer, number>>>>;
67
+ /** The nearest level a surface offers to the one asked for: a dojo asked to roar is loud. */
68
+ export declare function levelFor(sport: string, level: SoundLevel): SoundLevel;
69
+ /**
70
+ * The mix of a surface in its context at a crowd level: each of its bed's layers' gain (the crowd's voices scaled by the people in the room; a layer the bed does not have is 0) and the room.
71
+ * `undefined` for a surface with no sound.
72
+ * @example
73
+ * ```ts
74
+ * import { soundMix } from "@johnmorrisdotca/kyogijo-stadiums/sound";
75
+ * const quiet = soundMix({ sport: "nhl" }, "quiet")!;
76
+ * const roar = soundMix({ sport: "nhl" }, "roar")!;
77
+ * console.log(quiet.layers.roar, roar.layers.roar > roar.layers.murmur, soundMix({ sport: "kyudo" }, "roar")!.level);
78
+ * // 0 true murmur
79
+ * ```
80
+ */
81
+ export declare function soundMix(context: SoundContext, level: SoundLevel): SoundMix | undefined;
82
+ /** A period of a game on the timeline's clock, as the example games give them. */
83
+ export interface SoundPeriod {
84
+ number?: number;
85
+ start: number;
86
+ end: number;
87
+ }
88
+ /** A game at a moment: what moves the crowd. */
89
+ export interface GameMoment {
90
+ /** The time on the timeline's clock. */
91
+ t: number;
92
+ /** The game's periods; without them the whole timeline is one. */
93
+ periods?: readonly SoundPeriod[];
94
+ /** The timeline's first and last moments. */
95
+ start: number;
96
+ end: number;
97
+ /** The score now. */
98
+ score?: {
99
+ home: number;
100
+ away: number;
101
+ };
102
+ /** Seconds since the last goal, when there was one. */
103
+ sinceGoal?: number;
104
+ }
105
+ /**
106
+ * How excited the crowd is at a moment of a game: a murmur before the start, between periods and after the end; engaged while it plays; loud late in a close game; a roar for a few seconds
107
+ * after a goal; and back to a murmur when one side is far ahead late on.
108
+ * @example
109
+ * ```ts
110
+ * import { excitementAt } from "@johnmorrisdotca/kyogijo-stadiums/sound";
111
+ * const periods = [{ start: 0, end: 100 }, { start: 120, end: 220 }];
112
+ * console.log(excitementAt({ t: 50, start: 0, end: 220, periods, score: { home: 0, away: 0 } }));
113
+ * console.log(excitementAt({ t: 110, start: 0, end: 220, periods }));
114
+ * console.log(excitementAt({ t: 200, start: 0, end: 220, periods, score: { home: 2, away: 1 } }));
115
+ * console.log(excitementAt({ t: 60, start: 0, end: 220, periods, sinceGoal: 2 }));
116
+ * // engaged
117
+ * // murmur
118
+ * // loud
119
+ * // roar
120
+ * ```
121
+ */
122
+ export declare function excitementAt(moment: GameMoment): SoundLevel;
123
+ /** The kind of moment a timeline's event is, or `undefined` for one with no cue (a pass). */
124
+ export declare function cueKindOf(event: Pick<ReplayEvent, "kind">): CueKind | undefined;
125
+ /**
126
+ * The voices an event calls on a surface: its kind's cue in the sport's set, or none.
127
+ * @example
128
+ * ```ts
129
+ * import { cuesOf } from "@johnmorrisdotca/kyogijo-stadiums/sound";
130
+ * console.log(cuesOf({ kind: "goal" }, "nhl").join(), cuesOf({ kind: "goal" }, "soccer").join(), cuesOf({ kind: "whistle" }, "nba").join());
131
+ * console.log(cuesOf({ kind: "pass" }, "nhl").length, cuesOf({ kind: "goal" }, "a-surface-with-no-sound").length);
132
+ * // horn,swell swell whistle
133
+ * // 0 0
134
+ * ```
135
+ */
136
+ export declare function cuesOf(event: Pick<ReplayEvent, "kind">, sport: string): readonly SoundVoice[];
137
+ /** A cue to play: its voice, the event that called it, and the time on the timeline's clock it belongs to. */
138
+ export interface SoundCue {
139
+ voice: SoundVoice;
140
+ kind: CueKind;
141
+ t: number;
142
+ team?: "home" | "away";
143
+ }
144
+ /**
145
+ * The cues of a move of a replay: the events it went by and the periods it began or ended, each with its voices. Only a move made by the clock playing (`tick`) or by a step forward calls
146
+ * cues; a seek, a jump back, a pause or a change of speed calls none, as scrubbing announces nothing. The end of the last period is the game's end.
147
+ * @example
148
+ * ```ts
149
+ * import { cuesOfChange } from "@johnmorrisdotca/kyogijo-stadiums/sound";
150
+ * const state = { t: 10, start: 0, end: 60, playing: true, ended: false, speed: 1, loop: false, motion: "smooth" as const, follow: "off" as const, followHeld: false };
151
+ * const change = { state, reason: "tick" as const, crossed: [{ t: 9.5, kind: "goal", team: "home" as const }] };
152
+ * console.log(cuesOfChange(change, 9, "nhl").map((c) => c.voice).join());
153
+ * console.log(cuesOfChange({ ...change, reason: "seek" as const }, 9, "nhl").length);
154
+ * // horn,swell
155
+ * // 0
156
+ * ```
157
+ */
158
+ export declare function cuesOfChange(change: ReplayChange, from: number, sport: string, periods?: readonly SoundPeriod[]): SoundCue[];