narraleaf-react 0.21.1 → 0.23.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.
@@ -1,34 +1,141 @@
1
- import { Sound as SoundElement, SoundType } from "../../nlcore/elements/sound";
1
+ import { Sound as SoundElement, SoundBusId } from "../../nlcore/elements/sound";
2
2
  import { SoundToken } from "@narraleaf/sound";
3
3
  import { FadeOptions } from "../../nlcore/elements/type";
4
4
  import { Awaitable } from "../../../util/data";
5
5
  import { GameState } from "../gameState";
6
6
  import { LogicAction } from "../../nlcore/action/logicAction";
7
+ import { AudioBusState } from "../../nlcore/game/audioBus";
7
8
  export type AudioDataRaw = {
8
9
  isPlaying: boolean;
9
10
  position: number;
10
11
  };
11
12
  export type AudioManagerDataRaw = {
12
13
  sounds: [string, AudioDataRaw][];
13
- groups: [SoundType, number][];
14
+ /**
15
+ * Bus volumes, keyed by bus id.
16
+ *
17
+ * The name is historical - these used to be the three fixed "groups". A save written before
18
+ * buses existed carries exactly `bgm`/`sound`/`voice`, which are still buses, so it restores
19
+ * unchanged.
20
+ */
21
+ groups: [string, number][];
14
22
  };
15
23
  export declare class AudioManager {
16
24
  private gameState;
25
+ /**
26
+ * How much of the backend's channel budget to ask for.
27
+ *
28
+ * `@narraleaf/sound` defaults to 128 *including* the master, and throws outright when a
29
+ * `createChannel` would cross it. Three buses never came close; a game that gives every member
30
+ * of a large voiced cast its own bus can, and the failure mode is a hard throw at boot. This is
31
+ * a per-channel `GainNode` and nothing else, so a generous ceiling costs effectively nothing.
32
+ */
33
+ private static readonly MaxChannels;
34
+ /**
35
+ * Time constant of the bus-volume ramp, in seconds. ~20ms: long enough to turn a step into a
36
+ * slew nobody hears, short enough that a slider still feels attached to the sound.
37
+ */
38
+ private static readonly BusRampTimeConstant;
39
+ /**
40
+ * When to pin the exact target after the ramp. `setTargetAtTime` approaches asymptotically and
41
+ * never lands, so five time constants in (within 0.7% - inaudible) the value is written
42
+ * outright. Without this a long drag would accumulate a drift the mixer's own bookkeeping does
43
+ * not have.
44
+ */
45
+ private static readonly BusRampSettle;
17
46
  private state;
18
47
  private channels;
19
- private channelVolumes;
48
+ private busTree;
49
+ private busSubscription;
50
+ private unknownBuses;
20
51
  private globalVolume;
21
52
  private sound;
22
53
  private ready;
23
54
  private isReady;
24
55
  private isInitializing;
25
56
  constructor(gameState: GameState);
57
+ /**
58
+ * The volume of every bus, and the tree they are wired into.
59
+ *
60
+ * It lives on `Game`, not here: a bus volume is a player setting that exists before the audio
61
+ * context unlocks and outlives any one player mount. This manager is the thing that makes it
62
+ * audible, not the thing that remembers it.
63
+ */
64
+ private get mixer();
26
65
  /**
27
66
  * Must be called ONCE on the client side to prepare audio subsystem.
28
67
  * Doing it here avoids "AudioContext is not defined" on the server.
29
68
  */
30
69
  initialize(): void;
70
+ /**
71
+ * Build the host's declared bus tree into real channels, once.
72
+ *
73
+ * Walked front to back the tree hands every bus out after its parent, so a child always has a
74
+ * live parent channel to be created from - that is what nests the gain nodes, and cascading
75
+ * gain then falls out of the audio graph rather than out of any arithmetic here.
76
+ *
77
+ * Done once, at boot, and never re-shaped: `Channel.remove()` stops every token in its subtree,
78
+ * so re-parenting a bus while the game runs would cut the music off. Volumes stay live.
79
+ */
80
+ private realizeBusTree;
81
+ /**
82
+ * Write a bus's volume onto its gain node, ramping rather than stepping.
83
+ *
84
+ * `Channel.setVolume` assigns `gain.value` bare with no `cancelScheduledValues`, so a slider
85
+ * drag arrives as a staircase of discontinuities - the zipper. The backend exposes no ramping
86
+ * setter, only `getGainNode()`, so the ramp is driven from here.
87
+ *
88
+ * **Who owns the value.** `Channel.volume` remains authoritative bookkeeping and the
89
+ * `AudioParam` is authoritative for what is heard, and this method is the only writer of
90
+ * either, so the two can only disagree for the ~20ms a ramp is in flight. `channel.setVolume`
91
+ * is still called first and deliberately: it is what clamps to 0..1, what keeps
92
+ * `channel.getVolume()` truthful, and what `Channel.mute()`/`unmute()` re-read when they
93
+ * rewrite the gain themselves. Its bare write is then superseded in the same synchronous turn -
94
+ * `cancelScheduledValues` drops the implicit `setValueAtTime` that the assignment inserted at
95
+ * `currentTime`, and the ramp is scheduled from the value the parameter actually had. Nothing
96
+ * else in the engine touches a bus gain node, so there is no other automation to fight.
97
+ *
98
+ * Falls back to the plain assignment whenever the graph is not reachable - a backend without
99
+ * `getGainNode`, or a parameter without `setTargetAtTime`. Stepping is the behaviour that
100
+ * shipped; degrading to it is strictly no worse.
101
+ */
102
+ private applyBusVolume;
103
+ /**
104
+ * The value a bus's gain parameter has right now, or `null` when the graph cannot be reached.
105
+ */
106
+ private static readGain;
107
+ /**
108
+ * The channel a clip plays through.
109
+ *
110
+ * A bus id nothing declared is not fatal. Refusing to play would turn one typo in one clip's
111
+ * `type` into silence or a thrown action mid-scene; routing it to the always-seeded sfx bus
112
+ * keeps the game audible and says so once, per id, in the log. This is also where a bus name
113
+ * that {@link import("../../nlcore/game/audioBus").acceptsAudioBus} let through at story-build time is
114
+ * finally caught.
115
+ */
116
+ private channelFor;
117
+ /**
118
+ * The volume a clip started with no explicit target should reach.
119
+ *
120
+ * Full volume was never a sensible default here: a `Sound` carries the volume it was configured
121
+ * with, so a caller that says nothing about volume is asking for *that*, not for 1. Reading
122
+ * `state` rather than the user config is deliberate - it is the same value {@link SoundElement.play}
123
+ * and {@link SoundElement.resume} put in their `FadeOptions`, so a clip replayed after
124
+ * {@link AudioManager.setVolume} comes back at the volume it was last set to instead of jumping
125
+ * back to whatever the author first wrote down.
126
+ */
127
+ private static defaultFade;
31
128
  play(sound: SoundElement, options?: FadeOptions): Awaitable<void>;
129
+ /**
130
+ * Start a clip and hand the token back once playback is under way.
131
+ *
132
+ * With no `duration` the target volume is written to the token *synchronously* before this
133
+ * returns, and nothing is left running: no gain automation is in flight when the caller gets the
134
+ * token. That is what makes an explicit `token.setVolume` or a fade the caller drives itself
135
+ * afterwards the last writer, which several hosts rely on. Defaulting `duration` to anything
136
+ * above 0 would break that - `token.fade` below is deliberately not awaited, so a non-zero
137
+ * default would leave a ramp running past the return and over whatever the caller did next.
138
+ */
32
139
  playSoundToken(sound: SoundElement, options?: FadeOptions): Promise<SoundToken>;
33
140
  stop(sound: SoundElement, duration?: number): Awaitable<void>;
34
141
  setVolume(sound: SoundElement, volume: number, duration?: number): Awaitable<void>;
@@ -42,13 +149,51 @@ export declare class AudioManager {
42
149
  */
43
150
  seek(sound: SoundElement, time: number): Awaitable<void>;
44
151
  /**
45
- * The in/out points of a clip as the sound backend's play options.
152
+ * The in/out points of a clip.
46
153
  *
47
154
  * `endTime` is left off entirely when the author set none: passing `undefined` explicitly is the
48
155
  * same thing to the backend, but omitting it keeps `{...region}` spreads from writing a key that
49
156
  * reads as "there is a region here" to anything inspecting the object.
50
157
  */
51
158
  private static clipRegionOf;
159
+ /**
160
+ * The region as the sound backend's play options.
161
+ *
162
+ * A looping clip deliberately hands over **no** `endTime`. The backend turns `endTime` into a
163
+ * timer that stops the token after one pass, whether or not the clip loops, so passing it here
164
+ * is what kept the loop region from ever repeating. The region reaches a looping clip through
165
+ * {@link AudioManager.applyLoopRegion} instead; for a one-shot `endTime` *is* the out point and
166
+ * the backend's timer is exactly the right mechanism.
167
+ */
168
+ private static playRegionOf;
169
+ /**
170
+ * Write a looping clip's region onto the Web Audio node the backend is playing it through.
171
+ *
172
+ * **This is a shim against `@narraleaf/sound@0.1.0`'s internals, and the only place in this
173
+ * repo that reaches into them.** Two things in that version make the region unusable from the
174
+ * outside:
175
+ *
176
+ * - `SoundToken`'s constructor arms `setTimeout(stop, duration * 1000)` whenever a duration was
177
+ * given, without consulting `loop` — so a looping clip with an out point hard-stops after its
178
+ * first pass through the region.
179
+ * - `Sound.createToken` pins `loopStart` to the playback start offset, so "play the intro from
180
+ * 0s, then repeat 12s→90s forever" cannot be expressed at all.
181
+ *
182
+ * The fix belongs upstream and is small: honour `loop` before arming the duration timer, and
183
+ * accept an independent `loopStart` in `PlayOptions`. Until that ships, this manager withholds
184
+ * `endTime` from a looping clip's play options (which is what disarms the timer) and sets the
185
+ * loop region here.
186
+ *
187
+ * `SoundToken.sourceController` is TypeScript-`private` while `AudioSourceController.getSource`
188
+ * is public, so the node is reachable at runtime but not through the types — hence the cast and
189
+ * the shape check. If a later backend changes that shape this returns silently and the clip
190
+ * degrades to the behaviour it has today: the region plays once and the clip stops.
191
+ *
192
+ * Seeking survives this. `SoundToken.seek` rebuilds the buffer source and copies `loop`,
193
+ * `loopStart` and `loopEnd` off the old node onto the new one, so a jump inside a looping track
194
+ * keeps the region.
195
+ */
196
+ private static applyLoopRegion;
52
197
  private static clampToRegion;
53
198
  setRate(sound: SoundElement, rate: number): Awaitable<void>;
54
199
  getPosition(sound: SoundElement): number;
@@ -70,11 +215,67 @@ export declare class AudioManager {
70
215
  * as it did before.
71
216
  */
72
217
  preload(sound: SoundElement): Promise<void>;
218
+ /**
219
+ * Start a new game: stop everything and put the mixer back on the wire.
220
+ *
221
+ * The tree itself is **not** rebuilt - the channels are the same channels, because a bus is
222
+ * part of the game's declared shape, not part of its state. What is re-applied is every bus's
223
+ * current volume, read back off the mixer rather than off `SoundType`, which is what lets a
224
+ * host's own buses exist here at all.
225
+ *
226
+ * Note the seeded three are then immediately overwritten from the preferences, exactly as
227
+ * before; a bus the host declared keeps whatever volume the player left it at, because that is
228
+ * a setting and not something a new game should undo.
229
+ */
73
230
  reset(): void;
74
- setGroupVolume(type: SoundType, volume: number): void;
231
+ /**
232
+ * Set **the player's** volume for a bus, 0..1, live. 1 means "leave the author's mix alone".
233
+ *
234
+ * Reaches sounds that are **already playing**: a bus is a gain node every clip beneath it is
235
+ * routed through, so nothing has to be found, stopped or restarted for the change to be heard.
236
+ * Setting a bus that has not been realized yet is fine - the value is kept and applied when the
237
+ * audio context unlocks.
238
+ *
239
+ * Equivalent to `game.audioBuses.setVolume(...)`, which is the surface a host should prefer:
240
+ * it exists before the player mounts.
241
+ */
242
+ setBusVolume(id: SoundBusId, volume: number): void;
243
+ /**
244
+ * The player's volume for a bus - what was last set, else 1. Not the author's declared mix
245
+ * (`game.audioBuses.getDeclaredVolume`) and not what is on the gain node
246
+ * (`getEffectiveVolume`).
247
+ */
248
+ getBusVolume(id: SoundBusId): number;
249
+ /**
250
+ * Every bus with its parent and both of its volumes, parents first. `volume` is the half a
251
+ * host persists.
252
+ */
253
+ getBuses(): AudioBusState[];
254
+ /**
255
+ * @deprecated Use {@link AudioManager.setBusVolume}. Kept because the three sound types are
256
+ * still bus ids and hosts call this with them.
257
+ */
258
+ setGroupVolume(type: SoundBusId, volume: number): void;
259
+ /**
260
+ * @deprecated Use {@link AudioManager.getBusVolume}.
261
+ */
262
+ getGroupVolume(type: SoundBusId): number;
75
263
  setGlobalVolume(volume: number): void;
76
264
  getGlobalVolume(): number;
77
- getGroupVolume(type: SoundType): number;
78
265
  destroy(): void;
266
+ /**
267
+ * The three volume preferences are aliases onto the three seeded buses, and this is the alias.
268
+ *
269
+ * They stay the way a player's music/sfx/voice sliders are driven - widening the preference key
270
+ * union to cover arbitrary bus ids is not possible without reshaping `GamePreference`, and is
271
+ * not needed: a host bus is driven through {@link AudioManager.setBusVolume} instead.
272
+ *
273
+ * They write the **player's** half only. This used to write the bus's whole gain, and because
274
+ * these preferences default to 1 it meant that a host declaring `{id: "sound", volume: 0.6}`
275
+ * had its mix silently overwritten with 1 the moment the audio subsystem started - the author
276
+ * set SFX to 60% and every player heard 100%. Custom buses were unaffected because nothing
277
+ * aliases them, so the three buses every existing project uses were the only ones that ignored
278
+ * the declaration. Now `soundVolume: 1` means "do not attenuate further" and 0.6 survives.
279
+ */
79
280
  private setupGroupVolume;
80
281
  }