@umicat/three-sdk 0.9.0 → 0.9.1

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.
@@ -25,6 +25,12 @@
25
25
  * **4. iOS suspends the context when the app goes away** and does not bring it
26
26
  * back, so sound works until the first phone call and then never again.
27
27
  *
28
+ * Music is the one exception to rule 1, and for the same reason rule 1 exists.
29
+ * A three-minute track decodes to something like seventy megabytes of PCM;
30
+ * forty short effects as elements was fatal, but ONE element playing one long
31
+ * thing is exactly what elements are for. So effects are buffers and music
32
+ * streams, routed through the same graph so ducking still works.
33
+ *
28
34
  * The game brings its own clips; nothing here knows what a game sounds like.
29
35
  */
30
36
  export interface AudioClipSpec {
@@ -42,8 +48,9 @@ export interface GameAudioOptions {
42
48
  base?: string;
43
49
  /** File extension, including the dot. */
44
50
  extension?: string;
45
- /** A clip to loop as music. Loaded first, so the theme is not queued behind
46
- * every sound effect. */
51
+ /** A track to loop as music. Streamed, not decoded — see the note above.
52
+ * A name containing a dot is used as-is, so `'theme.mp3'` works alongside
53
+ * `.ogg` effects. */
47
54
  music?: string;
48
55
  musicVolume?: number;
49
56
  }
@@ -51,13 +58,13 @@ export declare class GameAudio {
51
58
  private ctx;
52
59
  private master;
53
60
  private musicGain;
54
- private musicSource;
61
+ private musicEl;
62
+ private musicName;
55
63
  private readonly buffers;
56
64
  private readonly lastPlayed;
57
65
  private readonly clips;
58
66
  private readonly base;
59
67
  private readonly ext;
60
- private readonly music?;
61
68
  private readonly musicVolume;
62
69
  private muted;
63
70
  private readonly cleanups;
@@ -66,8 +73,17 @@ export declare class GameAudio {
66
73
  * a cached flag is exactly the bug described above. */
67
74
  get ready(): boolean;
68
75
  private start;
76
+ /** `'theme.mp3'` stays as it is; `'coin'` becomes `coin.ogg`. */
77
+ private urlFor;
69
78
  private loadAll;
70
79
  private startMusic;
80
+ /**
81
+ * Change the track. Pass `null` for silence.
82
+ *
83
+ * Each scene gets its own: a lobby that sounds like the fight is a lobby you
84
+ * do not linger in.
85
+ */
86
+ setMusic(name: string | null): void;
71
87
  play(name: string): void;
72
88
  /** Duck the music for a moment — for an ending that should be heard over it. */
73
89
  duck(seconds?: number): void;
package/dist/GameAudio.js CHANGED
@@ -25,6 +25,12 @@
25
25
  * **4. iOS suspends the context when the app goes away** and does not bring it
26
26
  * back, so sound works until the first phone call and then never again.
27
27
  *
28
+ * Music is the one exception to rule 1, and for the same reason rule 1 exists.
29
+ * A three-minute track decodes to something like seventy megabytes of PCM;
30
+ * forty short effects as elements was fatal, but ONE element playing one long
31
+ * thing is exactly what elements are for. So effects are buffers and music
32
+ * streams, routed through the same graph so ducking still works.
33
+ *
28
34
  * The game brings its own clips; nothing here knows what a game sounds like.
29
35
  */
30
36
  export class GameAudio {
@@ -32,7 +38,8 @@ export class GameAudio {
32
38
  this.ctx = null;
33
39
  this.master = null;
34
40
  this.musicGain = null;
35
- this.musicSource = null;
41
+ this.musicEl = null;
42
+ this.musicName = null;
36
43
  this.buffers = new Map();
37
44
  this.lastPlayed = new Map();
38
45
  this.muted = false;
@@ -40,7 +47,7 @@ export class GameAudio {
40
47
  this.clips = opts.clips;
41
48
  this.base = opts.base ?? 'audio/';
42
49
  this.ext = opts.extension ?? '.ogg';
43
- this.music = opts.music;
50
+ this.musicName = opts.music ?? null;
44
51
  this.musicVolume = opts.musicVolume ?? 0.28;
45
52
  const unlock = () => {
46
53
  void this.start().then(() => {
@@ -92,42 +99,68 @@ export class GameAudio {
92
99
  if (this.ready)
93
100
  this.startMusic();
94
101
  }
102
+ /** `'theme.mp3'` stays as it is; `'coin'` becomes `coin.ogg`. */
103
+ urlFor(name) {
104
+ return `${this.base}${name}${name.includes('.') ? '' : this.ext}`;
105
+ }
95
106
  async loadAll() {
96
107
  const load = async (name) => {
97
108
  try {
98
- const res = await fetch(`${this.base}${name}${this.ext}`);
109
+ const res = await fetch(this.urlFor(name));
99
110
  const bytes = await res.arrayBuffer();
100
111
  const buf = await this.ctx.decodeAudioData(bytes);
101
112
  // Tagged so a test can see WHICH clip played — a buffer has no name,
102
113
  // and "some audio happened" is not a check.
103
114
  buf.__name = name;
104
115
  this.buffers.set(name, buf);
105
- if (name === this.music && this.ready && !this.musicSource)
106
- this.startMusic();
107
116
  }
108
117
  catch {
109
118
  /* a clip that will not decode is not worth taking the game down for */
110
119
  }
111
120
  };
112
- // Music first, then everything else at once. One at a time queued the theme
113
- // behind every effect and left the first actions silent — caution about
121
+ // All at once. One at a time left the first actions silent — caution about
114
122
  // decode cost, for work that is not on the main thread.
115
- if (this.music)
116
- await load(this.music);
117
123
  await Promise.all(Object.keys(this.clips).map(load));
118
124
  }
119
125
  startMusic() {
120
- if (!this.ctx || !this.musicGain || this.musicSource || this.muted || !this.music)
126
+ if (!this.ctx || !this.musicGain || this.muted || !this.musicName)
121
127
  return;
122
- const buf = this.buffers.get(this.music);
123
- if (!buf)
124
- return; // still decoding; loadAll calls back
125
- const src = this.ctx.createBufferSource();
126
- src.buffer = buf;
127
- src.loop = true;
128
- src.connect(this.musicGain);
129
- src.start();
130
- this.musicSource = src;
128
+ if (!this.musicEl) {
129
+ const el = new Audio(this.urlFor(this.musicName));
130
+ el.loop = true;
131
+ el.preload = 'auto';
132
+ el.crossOrigin = 'anonymous';
133
+ // Routed through the graph, not played on its own, so `duck()` and the
134
+ // master gain reach it like everything else.
135
+ try {
136
+ this.ctx.createMediaElementSource(el).connect(this.musicGain);
137
+ }
138
+ catch {
139
+ // Some engines refuse to route an element they consider tainted. Fall
140
+ // back to playing it directly rather than losing the music entirely.
141
+ el.volume = this.musicVolume;
142
+ }
143
+ this.musicEl = el;
144
+ }
145
+ void this.musicEl.play().catch(() => { });
146
+ }
147
+ /**
148
+ * Change the track. Pass `null` for silence.
149
+ *
150
+ * Each scene gets its own: a lobby that sounds like the fight is a lobby you
151
+ * do not linger in.
152
+ */
153
+ setMusic(name) {
154
+ if (name === this.musicName)
155
+ return;
156
+ this.musicName = name;
157
+ if (this.musicEl) {
158
+ this.musicEl.pause();
159
+ this.musicEl.src = '';
160
+ this.musicEl = null; // a new element: the old one's graph node is spent
161
+ }
162
+ if (name && this.ready && !this.muted)
163
+ this.startMusic();
131
164
  }
132
165
  play(name) {
133
166
  const ctx = this.ctx;
@@ -168,7 +201,11 @@ export class GameAudio {
168
201
  if (this.master && this.ctx) {
169
202
  this.master.gain.setTargetAtTime(on ? 0 : 1, this.ctx.currentTime, 0.02);
170
203
  }
171
- if (!on && this.ready)
204
+ // Pause the stream as well as silencing it: a muted track still costs a
205
+ // decoder and a download for something nobody can hear.
206
+ if (on)
207
+ this.musicEl?.pause();
208
+ else if (this.ready)
172
209
  this.startMusic();
173
210
  }
174
211
  get isMuted() { return this.muted; }
@@ -176,7 +213,7 @@ export class GameAudio {
176
213
  for (const c of this.cleanups)
177
214
  c();
178
215
  this.cleanups.length = 0;
179
- this.musicSource?.stop();
216
+ this.musicEl?.pause();
180
217
  void this.ctx?.close();
181
218
  this.ctx = null;
182
219
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umicat/three-sdk",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Three.js runtime for Umicat games: the scene3d design format, its loader with physics, a kinematic character controller, and the Umicat platform via @umicat/platform-sdk.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",